卫戍协议:盟约 web remake — initial import

Online co-op browser remake of Arknights' Stronghold Protocol: Alliance
(client-simulated combat, official waves/bonds/items/strategies, 3D board,
facing wheel, operator loadout, official emotes, deployment scripts).
This commit is contained in:
sganggs committed 2026-09-29 16:21:31 +08:00
commit 01a44cd288
408 files changed
+308986

No files matched your search

+313
View File
@@ -0,0 +1,313 @@
// AI player (server/match/bot.js): field model, layout planner, rehearsal, buying / leveling / bench management.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { PHASE } from '../../shared/constants.js';
import { fieldModel, planLayout, rehearse, rangeTiles, REHEARSAL_VARIANTS, LAYOUT_PARAMS, botPickCard, botPickBand } from '../../server/match/bot.js';
import { FIELD, canPlace, positionClass, parseKey } from '../../server/match/board.js';
import { makeMatch, checkInvariants, give, DATA } from './harness.js';
const soloBot = (o = {}) => makeMatch({ mode: 'solo', difficulty: 'NORMAL', seats: [{ seat: 0, playerId: 'ai_0', name: 'AI', isBot: true, connected: true }], ...o });
test('field model: the round\'s routes traced over the own board, weighted by the enemies that use them', () => {
const h = soloBot({ seed: 2 }).start();
const m = h.m;
h.run(() => m.phase === PHASE.PREP && m.round === 2);
const model = fieldModel(m);
assert.ok(model.routes.length > 0);
const enemies = m.wave.spawns.reduce((s, x) => s + Math.max(1, x.count || 1), 0);
assert.equal(model.routes.reduce((s, r) => s + r.n, 0), enemies, 'every spawned enemy is on one traced route');
for (const rt of model.routes) {
assert.ok(rt.tiles.length > 0 && rt.tileTime > 0 && rt.hp > 0);
for (const k of rt.tiles) {
const [r, c] = parseKey(k);
assert.ok(r >= FIELD.r0 && r <= FIELD.r1 && c >= 0 && c <= FIELD.c1, `route tile ${k} inside the own board`);
}
}
// ground routes follow the stage's device-aware ground paths
const st = m.stage;
const paths = st.groundPathsWithDevices || st.groundPaths;
const ground = m.wave.routes.filter((r) => r.motion !== 'FLY');
for (const rt of ground) {
const path = paths[`${rt.start[0]},${rt.start[1]}->${rt.end[0]},${rt.end[1]}`];
if (!path) continue;
for (const [r, c] of path.slice(0, -1)) if (r >= 9 && r <= 12 && c <= 10) assert.ok(model.ground.has(`${r},${c}`), `ground path tile ${r},${c}`);
}
assert.equal(fieldModel(m), model, 'cached per round');
m.dispose();
});
test('layout planner: legal distinct tiles; blockers on the enemy road, ranged units cover it without standing on it', () => {
const h = soloBot({ seed: 3 }).start();
const m = h.m;
h.run(() => m.phase === PHASE.PREP && m.round === 2);
const ps = m.order[0];
// a controlled lineup: 2 blockers, 3 ranged
for (const p of [...ps.board.values()]) ps.board.delete([...ps.board.entries()].find(([, x]) => x === p)[0]);
ps.hand.fill(null);
ps.recompute();
const melee = Object.values(DATA.chess).filter((c) => c.visible && !c.isGolden && c.tier === 1 && c.position === 'MELEE' && c.attackKind === 'melee' && c.stats.blockCnt >= 1).slice(0, 2);
const ranged = Object.values(DATA.chess).filter((c) => c.visible && !c.isGolden && c.tier === 1 && c.position === 'RANGED' && c.attackKind === 'ranged').slice(0, 3);
const pieces = [...melee, ...ranged].map((c) => give(m, ps, c.chessId, 'hand'));
const plan = planLayout(m, ps, pieces);
assert.equal(plan.size, pieces.length);
assert.equal(new Set(plan.values()).size, pieces.length, 'distinct tiles');
const map = ps.deployMap();
const model = fieldModel(m);
for (const p of pieces) {
const [r, c] = parseKey(plan.get(p.uid));
assert.ok(canPlace(map, positionClass(m.gd.chess(p.id)), r, c), `${p.id} legal at ${r},${c}`);
}
for (const p of pieces.slice(0, 2)) assert.ok(model.ground.has(plan.get(p.uid)), 'a blocker stands on the enemy road');
for (const p of pieces.slice(2)) {
const rec = m.gd.chess(p.id);
const [r, c] = parseKey(plan.get(p.uid));
// the planned direction rotates the range (DESIGN §3)
const dir = plan.dirs.get(p.uid);
assert.ok(['UP', 'RIGHT', 'DOWN', 'LEFT'].includes(dir), `${rec.name} has a planned direction`);
const covered = rangeTiles(rec, r, c, dir).some((k) => model.ground.has(k) || model.routes.some((rt) => rt.fly && rt.tiles.includes(k)));
assert.ok(covered, `${rec.name} covers the enemy route from ${r},${c} facing ${dir}`);
}
m.dispose();
});
test('rehearsal: candidate layouts are simulated without side effects; the chosen plan is one of them', () => {
const h = soloBot({ seed: 4, botRehearsal: 3 }).start();
const m = h.m;
h.run(() => m.phase === PHASE.PREP && m.round === 3);
const ps = m.order[0];
const chosen = ps.allChess().slice(0, ps.deployCap);
assert.ok(chosen.length >= 3);
const snap = () => JSON.stringify({ board: [...ps.board.entries()].map(([k, p]) => [k, p.uid, p.dir]), hand: ps.hand.map((p) => p && p.uid), bonds: ps.bonds, pool: m.pool.snapshot(), lp: ps.lp, funds: ps.funds, layers: ps.layers, round: m.round, phase: m.phase, uid: m.uidSeq });
const before = snap();
const plans = REHEARSAL_VARIANTS.map((v) => planLayout(m, ps, chosen, { ...LAYOUT_PARAMS, ...v }));
const best = rehearse(m, ps, chosen, plans);
assert.ok(plans.includes(best));
assert.equal(snap(), before, 'board, hand, bonds, pool, economy untouched');
assert.equal(m.errorCount, 0);
checkInvariants(m);
// the whole match still runs deterministically with rehearsal on
const run = () => { const x = soloBot({ seed: 11, botRehearsal: 3, difficulty: 'FUNNY' }).start(); return JSON.stringify({ ...x.runToEnd(), durationMs: 0 }); };
assert.equal(run(), run());
m.dispose();
});
test('rehearsal runs in bounded slices between scheduler callbacks and plays exactly like the one-shot run', () => {
const run = (botSliceMs) => {
const h = soloBot({ seed: 11, botRehearsal: 3, difficulty: 'FUNNY', botSliceMs }).start();
const m = h.m;
/** scheduler callback → rehearsal ticks stepped in it */
const perCallback = new Map();
const newBattle = m.newBattle.bind(m);
m.newBattle = (opts) => {
const b = newBattle(opts);
if (String(opts.fieldId).startsWith('r:')) {
const step = b.step.bind(b);
b.step = () => { perCallback.set(h.sched.executed, (perCallback.get(h.sched.executed) || 0) + 1); step(); };
}
return b;
};
const end = h.runToEnd();
assert.equal(m.errorCount, 0);
m.dispose();
return { result: JSON.stringify({ ...end, durationMs: 0 }), perCallback };
};
const whole = run(undefined);
const sliced = run(1e-6);
assert.ok(whole.perCallback.size > 0, 'sanity: the bot rehearsed');
assert.ok(Math.max(...whole.perCallback.values()) > 500, 'unbounded: a whole rehearsal in one callback (virtual time)');
assert.ok(Math.max(...sliced.perCallback.values()) <= 4, `sliced: ≤ 4 ticks per callback (got ${Math.max(...sliced.perCallback.values())})`);
assert.ok(sliced.perCallback.size > whole.perCallback.size * 10);
assert.equal(sliced.result, whole.result, 'same decisions, same match');
});
test('the prep routine itself (shop, lineup, layout planning) runs in bounded slices and plays exactly like the one-shot run', () => {
const seats = [0, 1].map((i) => ({ seat: i, playerId: `ai_${i}`, name: `AI${i}`, isBot: true, connected: true }));
const run = (botSliceMs) => {
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', seats, seed: 13, fake: true, botRehearsal: 3, botSliceMs }).start();
const m = h.m;
/** `${bot}:${round}` → the scheduler callbacks in which the bot acted */
const cbs = new Map();
for (const ps of m.players.values()) {
for (const k of ['buy', 'move', 'sell', 'levelUp', 'refresh', 'equip', 'pickReward']) {
const f = ps[k].bind(ps);
ps[k] = (...a) => {
const key = `${ps.playerId}:${m.round}`;
if (!cbs.has(key)) cbs.set(key, new Set());
cbs.get(key).add(h.sched.executed);
return f(...a);
};
}
}
h.run(() => h.ended != null || (m.phase === PHASE.SETTLE && m.round === 6), { maxSteps: 5e6 });
assert.equal(m.errorCount, 0);
checkInvariants(m);
const state = JSON.stringify([...m.players.values()].map((ps) => ({
board: [...ps.board.entries()].map(([k, p]) => [k, p.id, p.dir ?? null, (p.items || []).map((x) => x.id)]),
hand: ps.hand.map((p) => p && p.id), funds: ps.funds, level: ps.shop.level, lp: ps.lp, bonds: ps.bonds,
})));
m.dispose();
return { state, perPrep: [...cbs.values()].map((s) => s.size) };
};
const whole = run(undefined);
const sliced = run(1e-6);
assert.ok(Math.max(...whole.perPrep) <= 1, `unbounded (virtual time): a whole prep in one callback (${whole.perPrep})`);
assert.ok(Math.min(...sliced.perPrep) >= 3 && Math.max(...sliced.perPrep) >= 8, `sliced: every prep spread over many callbacks (${sliced.perPrep})`);
assert.equal(sliced.state, whole.state, 'same decisions, same boards');
});
test('a bot prep dropped mid-planning (the prep ended) leaves a legal board and no rehearsal layout behind', () => {
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 1, bots: 1, seed: 14, fake: true, botRehearsal: 3, botSliceMs: 1e-6 }).start();
const m = h.m;
h.toPrep(4);
const bot = h.ps('ai_0');
let moves = 0;
const move = bot.move.bind(bot);
bot.move = (...a) => { moves++; return move(...a); };
// stop right after the bot started placing (mid-way through its sliced prep)
h.run(() => moves > 0);
assert.equal(bot.ready, false, 'mid-prep');
m.prepDeadline();
assert.equal(m.phase, PHASE.COMBAT);
const at = moves;
h.run(() => m.phase === PHASE.SETTLE);
assert.equal(moves, at, 'the pending steps were dropped');
assert.ok(bot.deployCount <= bot.deployCap);
assert.equal(m.errorCount, 0);
checkInvariants(m);
m.dispose();
});
test('a sliced rehearsal is dropped when the prep ends first; the default layout is already on the board', () => {
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 1, bots: 1, seed: 12, fake: true, botRehearsal: 3, botSliceMs: 1e-6 }).start();
const m = h.m;
h.toPrep(3);
const bot = h.ps('ai_0');
let ticks = 0;
const newBattle = m.newBattle.bind(m);
m.newBattle = (opts) => {
const b = newBattle(opts);
if (String(opts.fieldId).startsWith('r:')) { const step = b.step.bind(b); b.step = () => { ticks++; step(); }; }
return b;
};
h.run(() => ticks > 0);
assert.equal(bot.ready, false, 'mid-rehearsal');
const layout = JSON.stringify([...bot.board.entries()].map(([k, p]) => [k, p.uid]));
assert.ok(bot.deployCount > 0, 'the default layout already stands');
m.prepDeadline();
assert.equal(m.phase, PHASE.COMBAT);
const at = ticks;
h.run(() => m.phase === PHASE.SETTLE);
assert.equal(ticks, at, 'the pending slices dropped the job');
assert.equal(JSON.stringify([...bot.board.entries()].map(([k, p]) => [k, p.uid])), layout, 'the board was left alone');
assert.equal(m.errorCount, 0);
checkInvariants(m);
m.dispose();
});
test('economy: the bot fills the board first (8 units by round 4), levels on its curve and keeps a hand slot free', () => {
for (const seed of [5, 8]) {
const h = soloBot({ seed }).start();
const m = h.m;
const ps = m.order[0];
h.run(() => m.phase === PHASE.PREP && m.round === 1);
ps.lp = 999;
const funds1 = ps.funds; // 老鲤 (band_lmlee) withholds R1–R2 income until R3
h.run(() => m.phase === PHASE.COMBAT && m.round === 1);
assert.equal(ps.shop.level, 1, 'no level-up in round 1 (units first)');
assert.equal(ps.deployCount, Math.floor(funds1 / 2), `R1: ${funds1} funds → tier-I operators deployed`);
h.run(() => m.phase === PHASE.COMBAT && m.round === 4);
assert.equal(ps.deployCount, ps.deployCap, 'full board by round 4');
h.run(() => m.phase === PHASE.COMBAT && m.round === 6);
assert.ok(ps.shop.level >= 2, `level ${ps.shop.level} by round 6`);
h.run(() => m.phase === PHASE.COMBAT && m.round === 10);
assert.ok(ps.shop.level >= 4, `level ${ps.shop.level} by round 10`);
assert.ok(ps.hand.some((x) => x == null), 'a hand slot is kept free for merges');
checkInvariants(m);
m.dispose();
}
});
test('bench management: stale low-tier singles are sold at a high shop level; pairs that can still merge are kept', () => {
const h = soloBot({ seed: 6 }).start();
const m = h.m;
const ps = m.order[0];
ps.lp = 999;
h.run(() => m.phase === PHASE.ROUND_START && m.round === 7);
// a crowded bench of tier-I singles at shop level 5, plus a live pair of a tier-V chess
ps.shop.level = 5;
const owned = new Set(ps.allChess().map((p) => m.gd.baseIdOf(p.id)));
const t5 = Object.values(DATA.chess).find((c) => c.visible && !c.isGolden && c.tier === 5 && m.pool.has(c.chessId) && m.pool.left(c.chessId) >= 3 && !owned.has(c.chessId));
const freeIdx = () => ps.hand.findIndex((x) => x == null);
assert.ok(freeIdx() >= 0);
const pairA = give(m, ps, t5.chessId, 'hand', freeIdx());
assert.ok(freeIdx() >= 0);
const pairB = give(m, ps, t5.chessId, 'hand', freeIdx());
const t1 = Object.values(DATA.chess).filter((c) => c.visible && !c.isGolden && c.tier === 1 && m.pool.has(c.chessId) && !owned.has(c.chessId));
const junk = [];
for (const c of t1) if (freeIdx() >= 0) junk.push(give(m, ps, c.chessId, 'hand', freeIdx()));
assert.ok(junk.length >= 2, 'the bench is crowded with tier-I singles');
h.run(() => m.phase === PHASE.COMBAT && m.round === 7);
const bench = ps.hand.filter((p) => p && p.kind === 'chess');
assert.ok(bench.length <= 6, `bench trimmed to ${bench.length}`);
const stillOwned = junk.filter((p) => ps.find(p.uid)).length;
assert.ok(stillOwned < junk.length, `sold stale singles (${junk.length - stillOwned}/${junk.length})`);
const kept = ps.allChess().filter((p) => m.gd.baseIdOf(p.id) === t5.chessId);
assert.ok(kept.length >= 2 || kept.some((p) => m.gd.isGolden(p.id)) || (ps.find(pairA.uid) && ps.find(pairB.uid)), 'the tier-V pair is kept (or merged)');
checkInvariants(m);
m.dispose();
});
test('机变 pick: items and team buffs before bounties (bounties add enemies to the bot\'s own battle)', () => {
const h = soloBot({ seed: 7 }).start();
const m = h.m;
const ps = m.order[0];
const cards = [
{ idx: 0, kind: 'bounty', payout: 'kill', coin: 3, tier: 2, count: 3, rounds: 1 },
{ idx: 1, kind: 'item', id: 'x', tier: 2 },
{ idx: 2, kind: 'tactic', team: true, tacticKind: 'ally' },
];
assert.equal(botPickCard(m, ps, cards, [0, 1, 2]), 2);
assert.equal(botPickCard(m, ps, cards, [0, 1]), 1);
assert.equal(botPickCard(m, ps, cards, [0]), 0);
m.dispose();
});
test('merge rewards: a merge completed while buying is followed by taking its free reward offer the same prep', () => {
const h = soloBot({ seed: 9 }).start();
const m = h.m;
const ps = m.order[0];
h.run(() => m.phase === PHASE.PREP && m.round === 1);
ps.lp = 999;
h.run(() => m.phase === PHASE.ROUND_START && m.round === 3);
// two copies owned, the third in the shop: the bot buys it (merge) and must pick the reward offer it queues
const id = Object.values(DATA.chess).find((c) => c.visible && !c.isGolden && c.tier === 1 && m.pool.has(c.chessId) && m.pool.left(c.chessId) >= 3 && ps.countCopies(c.chessId) === 0).chessId;
const freeIdx = () => ps.hand.findIndex((x) => x == null);
give(m, ps, id, 'hand', freeIdx());
give(m, ps, id, 'hand', freeIdx());
ps.funds = 20;
h.run(() => m.phase === PHASE.PREP && m.round === 3);
ps.shop.slots[0] = { kind: 'chess', id, basePrice: m.gd.chessPrice(id), price: m.gd.chessPrice(id), sold: false, frozen: false };
const picks = [];
const orig = ps.pickReward.bind(ps);
ps.pickReward = (i) => { const r = orig(i); picks.push(r); return r; };
const merges = ps.stats.merges;
h.run(() => m.phase === PHASE.COMBAT && m.round === 3);
assert.ok(ps.stats.merges > merges, 'the third copy was bought (merge)');
assert.ok(picks.some((r) => r && r.ok), 'the merge reward was taken in the same prep');
assert.ok(ps.allChess().some((p) => m.gd.goldenIdOf(id) === p.id), 'the elite is owned');
checkInvariants(m);
m.dispose();
});
test('band pick: alone, the bot avoids a band that withholds the first rounds\' funds (老鲤); in co-op it may take it', () => {
const counts = { solo: 0, coop: 0 };
for (let seed = 1; seed <= 60; seed++) {
for (const mode of ['solo', 'coop']) {
const h = makeMatch({ mode, difficulty: 'NORMAL', seats: [{ seat: 0, playerId: 'ai_0', name: 'AI', isBot: true, connected: true }], seed, fake: true });
if (botPickBand(h.m, h.m.order[0]) === 'band_lmlee') counts[mode]++;
h.m.dispose();
}
}
assert.ok(counts.solo <= 1, `solo picks 老鲤 ${counts.solo}/60`);
assert.ok(counts.coop >= 1, `co-op may pick it (${counts.coop}/60)`);
});