feedback5: merge W5G — 补位 end to end: the 干员持有 tab (drop NORMAL chess), room.ownership, stand-ins in every field, 「替补:X」 badges, the stand-in's body on the board and detail card, golden standins family

Golden regenerated on the merged code (the textual merge had no conflict and equals the regenerated files): against feedback5 only the new match coop2-NORMAL-14-standins and the new standins.json (10 battles); against fb5-standin-game exactly the W5F and W5J scenarios (roster-001/002/007/009/016/026/027/028/038/040/041/042/044, bond-egirShip-low / siracusaShip-high / suntShip-high, boss-boss_2-pair / boss_6-pair, solo-NORMAL-1, coop2-NORMAL-3, coop3-ABYSS-5, coop4-FUNNY-6, coop2-NORMAL-8-serverrun); standins unchanged. test/match 536/536, the stand-in / golden / docs suites 175/175.
This commit is contained in:
sganggs committed 2026-10-05 19:57:20 +08:00
commit ce0d85c78a
51 files changed
+2741 -188

No files matched your search

+103 -15
View File
@@ -8,7 +8,7 @@
// Usage:
// node tools/golden.mjs compare the corpus with test/golden/*.json (exit 1 on a difference)
// node tools/golden.mjs --update recompute and rewrite test/golden/*.json (npm run golden:update)
// options: --family roster,bonds,fields,matches --fast (the test's default subset) --only <id,id>
// options: --family roster,bonds,fields,matches,standins --fast (the test's default subset) --only <id,id>
// --jobs N (worker threads, default: up to 4) --list --coverage (skills cast, stages, enemies…)
// --twice determinism: compute twice in this process (the second pass in reverse order) and compare
//
@@ -25,9 +25,14 @@
// ended at 200 game s) and 联防 fields (1 and 2 helpers on the 联防 map of their count — boards laid out on a
// battle stage —, carried HP / SP, a knocked-out operator, two leakers' enemies with a summoned-only kind and
// a bounty)
// matches 16 bot-only matches (solo 标准 / 险境 / 绝境 / 终极 ×2 seeds, co-op 2 / 3 / 4, one server-run combat match,
// two with LP and layers raised at the first prep so they reach the Hidden Core) run to the end in virtual
// time with the match's default bot rehearsal
// matches 17 matches run to the end in virtual time with the match's default bot rehearsal: 16 bot-only (solo 标准 /
// 险境 / 绝境 / 终极 ×2 seeds, co-op 2 / 3 / 4, one server-run combat match, two with LP and layers raised at the
// first prep so they reach the Hidden Core) and one co-op match whose human seat (AI 托管, offline: its
// battles run on the server) does not own a few NORMAL chess — they fight as their 补位 stand-ins (0.2.0)
// standins 10 battles: every NORMAL chess record (normal + elite, 110) fielded as its 补位 stand-in (PlayerBattleInput
// standIn: true — DATA.md §18: the stand-in's body, its backup skill / module, its kit by charId; all 17
// stand-ins and every skill a chess names for them), 12 per battle by strength band, laid out by the stand-in's
// position on a real stage, against the round's real wave three times over, an item each, bonds from the board
// Battles run through the production BattleSpec path (server/sim/spec.js buildBattleSpec → createBattleFromSpec, the
// path browsers and the server's headless fields use) with every option explicit; matches construct Match directly
// with a VirtualScheduler (as tools/botbench.mjs) — test-harness defaults never move a digest.
@@ -68,7 +73,7 @@ import { GEO } from '../shared/constants.js';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
export const GOLDEN_DIR = join(ROOT, 'test', 'golden');
export const FAMILY_NAMES = Object.freeze(['roster', 'bonds', 'fields', 'matches']);
export const FAMILY_NAMES = Object.freeze(['roster', 'bonds', 'fields', 'matches', 'standins']);
const QUIET = Object.freeze({ warn() {}, error() {}, info() {}, log() {}, debug() {} });
const data = getData({ log: QUIET });
@@ -323,11 +328,13 @@ function layout(gd, stageId, wanted, { field = 'normal', colOffset = 0, max = 12
const rank = (r, c) => { const d = dist(r, c); return d === 0 ? 1.5 : d; }; // beside a path first, then on it
return out.sort((a, b) => rank(a[0], a[1]) - rank(b[0], b[1]));
};
const isMelee = (w) => positionClass(data.chess[w.chessId], w.moduleId) === 'melee';
const order = wanted.slice().sort((a, b) => (isMelee(b) - isMelee(a)) || (isMelee(a) ? (data.chess[b.chessId].stats.blockCnt || 0) - (data.chess[a.chessId].stats.blockCnt || 0) : 0));
// a 补位 unit (`standIn`) is laid out by its stand-in's body (position, block count); every other by its chess record
const recOf = (w) => (w.standIn ? gd.standIn(w.chessId) || data.chess[w.chessId] : data.chess[w.chessId]);
const isMelee = (w) => positionClass(recOf(w), w.moduleId) === 'melee';
const order = wanted.slice().sort((a, b) => (isMelee(b) - isMelee(a)) || (isMelee(a) ? (recOf(b).stats.blockCnt || 0) - (recOf(a).stats.blockCnt || 0) : 0));
let uid = uid0;
for (const w of order) {
const rec = data.chess[w.chessId];
const rec = recOf(w);
const free = units.length < max ? tiles(positionClass(rec, w.moduleId)) : [];
if (!free.length) { rest.push(w); continue; }
const [r, c] = free[0];
@@ -336,12 +343,14 @@ function layout(gd, stageId, wanted, { field = 'normal', colOffset = 0, max = 12
const u = { uid: uid++, kind: 'chess', chessId: w.chessId, row: r, col: c, dir, items: w.items ?? [] };
if (w.skillIndex != null) u.skillIndex = w.skillIndex;
if (w.moduleId != null) u.moduleId = w.moduleId;
if (w.standIn) u.standIn = true;
if (w.carryState) u.carryState = w.carryState;
units.push(u);
}
// placeable summons of the placed operators
// placeable summons of the placed operators (a stand-in makes none: none of the 17 has one)
const tokens = [];
for (const u of units) {
if (u.standIn) continue;
for (const { tokenId, count } of gd.placeableTokens(u.chessId, { skillIndex: u.skillIndex ?? null })) {
const trec = data.tokens[tokenId];
let allowed = null;
@@ -625,6 +634,60 @@ export function fieldScenarios() {
return scenarios;
}
// ---------------------------------------------------------------------------------------------------------------
// family: standins (0.2.0 补位)
/** The NORMAL chess records (normal + elite) that have a stand-in (shared/standIn.js: PRESET / 自选 have none). */
const STANDIN_CHESS = CHESS.filter((c) => c.chessType === 'NORMAL' && !c.isDiy && c.backup && c.backup.charId && c.backup.charId !== c.charId);
export function standInScenarios() {
// normal records first, then the elites; within them by strength band (tier + elite) and id; a battle takes up to 12
// with distinct chess ids and bands within one of the first waiting record's
const queue = STANDIN_CHESS.map((rec) => ({ chessId: rec.chessId, standIn: true, elite: rec.isGolden ? 1 : 0, band: rec.tier + (rec.isGolden ? 1 : 0) }));
queue.sort((a, b) => a.elite - b.elite || a.band - b.band || byId(a.chessId, b.chessId));
// every battle also gets 8 ground enemy kinds as extra spawns: a 飞行 round wave would leave the melee stand-ins
// nobody to block, and their skills (cast with an enemy in range) uncast
const ground = cursor(EXTRA_ENEMIES.filter((k) => !data.enemies[k].tokenOnly && !isFlyKey(gdFor('mode_multi_normal'), k)));
const items = cursor(EQUIPS);
const bands = cursor(BANDS.slice().reverse());
const scenarios = [];
let n = 0;
while (queue.length) {
const i = n++;
const stageId = STAGES[(i * 3 + 1) % STAGES.length];
const modeId = ROSTER_MODES[(i + 2) % ROSTER_MODES.length];
const gd = gdFor(modeId);
const first = queue[0];
const picked = [];
for (const w of queue) {
if (picked.length >= 12) break;
if (picked.some((x) => x.chessId === w.chessId) || Math.abs(w.band - first.band) > 1 || w.elite !== first.elite) continue;
picked.push(w);
}
const chunk = picked.map((w) => ({ ...w, items: [items.next()] }));
const { units } = layout(gd, stageId, chunk, { max: 12 });
if (!units.length) throw new Error(`standins: nothing fits on ${stageId}`);
const placed = new Set(units.map((u) => u.chessId));
for (let q = queue.length - 1; q >= 0; q--) if (picked.includes(queue[q]) && placed.has(queue[q].chessId)) queue.splice(q, 1);
if (!placed.size) throw new Error('standins: no progress');
const band = Math.max(...picked.filter((w) => placed.has(w.chessId)).map((w) => w.band));
const round = BAND_ROUND[band];
const seed = deriveSeed(20261005, `standins:${i}`);
const wave = normalWave(gd, round, seed);
const pid = 'p1';
scenarios.push({
id: `standin-${String(i + 1).padStart(2, '0')}`, family: 'standins', kind: 'normal', modeId, round, stageId, seed, pass: first.elite,
rect: { ...GEO.NORMAL_RECT }, timeLimit: wave.timeLimit, routes: wave.routes, waveId: wave.templateId, enemyOverrides: wave.overrides,
flags: { layerGainsEnabled: true, ...gd.dp },
players: [playerInput(pid, 0, units, { bonds: bondsOf(gd, units, LAYER_STEPS[i % LAYER_STEPS.length]), bandId: bands.next() })],
spawns: [0, 1, 2].flatMap((k) => wave.spawns.map((sp) => ({ ...sp, time: sp.time + k * Math.round(wave.timeLimit / 4), ownerPlayerId: pid })))
.concat(extraSpawns(gd, wave.routes, Array.from({ length: 8 }, () => ground.next()), round, pid, 6, 6)),
about: `${first.elite ? 'elite' : 'normal'} band ${band} as stand-ins: ${units.map((u) => `${u.chessId}→${gd.standIn(u.chessId)?.charId ?? '?'}`).join(' ')}`,
});
}
return scenarios;
}
// ---------------------------------------------------------------------------------------------------------------
// family: matches
@@ -641,9 +704,19 @@ export function matchScenarios() {
// match reaches the Final Assault and — Σ activated layers over the threshold — the Hidden Core
out.push({ id: 'solo-HARD-9-boosted', mode: 'solo', difficulty: 'HARD', bots: 1, seed: 9, boost: { lp: 400, layers: 300 } });
out.push({ id: 'coop2-ABYSS-10-boosted', mode: 'coop', difficulty: 'ABYSS', bots: 2, seed: 10, boost: { lp: 400, layers: 300 } });
return out.map((m) => ({ ...m, family: 'matches', kind: 'match', about: `${m.mode} ${m.difficulty}, ${m.bots} bot seat(s), seed ${m.seed}${m.clientCombat === false ? ', server-run combat' : ''}${m.boost ? `, LP ${m.boost.lp} and ${m.boost.layers} layers per bond from the first prep` : ''}` }));
// 0.2.0 补位: a human seat (AI 托管, offline: its battles run on the server) that does not own a few NORMAL chess
out.push({ id: 'coop2-NORMAL-14-standins', mode: 'coop', difficulty: 'NORMAL', bots: 1, seed: 14, human: { notOwned: MATCH_NOT_OWNED } });
return out.map((m) => ({ ...m, family: 'matches', kind: 'match', about: `${m.mode} ${m.difficulty}, ${m.bots} bot seat(s)${m.human ? ` + 1 human seat (AI 托管) without ${m.human.notOwned.length} operators (补位 stand-ins)` : ''}, seed ${m.seed}${m.clientCombat === false ? ', server-run combat' : ''}${m.boost ? `, LP ${m.boost.lp} and ${m.boost.layers} layers per bond from the first prep` : ''}` }));
}
/**
* The NORMAL chess the 补位 match's human seat does not own: operators its AI fields with seed 14 (瑕光 → 郁金香 from
* round 4, 异客 → Stormeye, 耀骑士临光 → 郁金香 later; the digest's `standIns` lists them per round).
*/
const MATCH_NOT_OWNED = Object.freeze([
'chess_char_3_12_a', 'chess_char_3_18_a', 'chess_char_5_20_a', 'chess_char_6_05_a', 'chess_char_6_06_a', 'chess_char_6_17_a', 'chess_char_6_19_a',
]);
const pieceStr = (p, r, c) => `${p.id.replace(/^chess_char_/, '')}@${r},${c}${p.dir && p.dir !== 'RIGHT' ? p.dir[0] : ''}${p.items && p.items.length ? `[${p.items.map((i) => i.id.replace(/^chess_item_/, '')).join('+')}]` : ''}`;
function boardStr(ps) {
const out = [];
@@ -656,7 +729,10 @@ const MATCH_ROW_COLS = Object.freeze(['alive', 'lp', 'prepFunds', 'funds', 'pend
export function runMatch(cfg) {
const sched = new VirtualScheduler();
const seats = [];
for (let i = 0; i < cfg.bots; i++) seats.push({ seat: i, playerId: `ai_${i}`, name: `AI-${i + 1}`, isBot: true, connected: true });
// a human seat (cfg.human): offline (its battles run on the server) and on AI 托管 from the start; its not-owned chess
// fight as their 补位 stand-ins
if (cfg.human) seats.push({ seat: 0, playerId: 'h_0', name: 'H-1', isBot: false, connected: false, notOwned: [...cfg.human.notOwned] });
for (let i = 0; i < cfg.bots; i++) seats.push({ seat: seats.length, playerId: `ai_${i}`, name: `AI-${i + 1}`, isBot: true, connected: true });
let summary = null;
const logged = [];
const log = { info() {}, debug() {}, warn() {}, error: (...a) => logged.push(a.map(String).join(' ').slice(0, 120)) };
@@ -665,7 +741,10 @@ export function runMatch(cfg) {
send: () => true, broadcast: () => {}, onEnd: (s) => { summary = s; },
clientCombat: cfg.clientCombat !== false, verify: 'off',
});
if (cfg.human) m.players.get('h_0').autoplay = true;
const rounds = [];
// 补位: per round at SETTLE, the human's board pieces that fought as stand-ins (chess id → stand-in charId)
const standIns = [];
let last = '';
m.start();
const setup = { stageId: m.stageId, bossId: m.bossId, hiddenBossId: m.hiddenBossId, factions: [...(m.factions || [])] };
@@ -696,6 +775,11 @@ export function runMatch(cfg) {
];
}
rounds.push(row);
if (cfg.human) {
const h = m.players.get('h_0');
const fielded = [...h.board.values()].filter((p) => p.kind === 'chess' && h.fieldsStandIn(p.id)).map((p) => `${p.id.replace(/^chess_char_/, '')}→${m.gd.standIn(p.id)?.charId}`).sort(byId);
standIns.push([m.round, fielded.join(' ')]);
}
}
}
return summary != null;
@@ -726,6 +810,7 @@ export function runMatch(cfg) {
rounds,
players,
};
if (cfg.human) digest.standIns = { notOwned: [...m.players.get('h_0').standIns], rounds: standIns };
m.dispose();
return digest;
}
@@ -733,22 +818,25 @@ export function runMatch(cfg) {
// ---------------------------------------------------------------------------------------------------------------
// families, fast subset, comparison
const GENERATORS = { roster: rosterScenarios, bonds: bondScenarios, fields: fieldScenarios, matches: matchScenarios };
const GENERATORS = { roster: rosterScenarios, bonds: bondScenarios, fields: fieldScenarios, matches: matchScenarios, standins: standInScenarios };
const ABOUT = {
roster: 'every visible chess record × every selectable skill / module, every stage, every non-leader enemy kind, items, bands, 机变 cards, map cards, placeable summons',
bonds: 'every bond at its activation threshold (layers 1) and at its top tier (layers 999)',
fields: 'Final Assault / Hidden Core leaders (pair + solo templates, shared pool, 200 s cap) and 联防 fields (1 / 2 helpers)',
matches: 'bot-only matches run to the end in virtual time (solo ×4 difficulties ×2 seeds, co-op 2/3/4, one server-run, two boosted to the Hidden Core)',
matches: 'matches run to the end in virtual time: bot-only (solo ×4 difficulties ×2 seeds, co-op 2/3/4, one server-run, two boosted to the Hidden Core) and one co-op match whose human seat fields 补位 stand-ins',
standins: 'every NORMAL chess record (normal + elite) fielded as its 补位 stand-in (all 17 stand-ins and the skills the chess name for them), 12 per battle on real stages and waves',
};
/**
* The default test subset (GOLDEN_FULL=1 runs everything): the pass-0 roster battles (every chess record with its
* default loadout, every stage, every non-leader enemy kind), every bond at its top tier, both 联防 fields, four leader
* fields (one of them a Hidden Core) and five matches (solo 标准 / 绝境, co-op 2 / 4, the boosted Hidden Core run).
* fields (one of them a Hidden Core), six matches (solo 标准 / 绝境, co-op 2 / 4, the boosted Hidden Core run, the 补位
* match) and the normal-record stand-in battles.
*/
const FAST_MATCHES = new Set(['solo-FUNNY-1', 'solo-HARD-1', 'coop2-NORMAL-3', 'coop4-ABYSS-7', 'solo-HARD-9-boosted']);
const FAST_MATCHES = new Set(['solo-FUNNY-1', 'solo-HARD-1', 'coop2-NORMAL-3', 'coop4-ABYSS-7', 'solo-HARD-9-boosted', 'coop2-NORMAL-14-standins']);
const FAST_FIELDS = new Set(['boss-boss_1-pair', 'boss-boss_4-solo', 'boss-boss_7-pair', 'hidden-boss_9-pair', 'unite-1', 'unite-2']);
export function isFast(sc) {
if (sc.family === 'matches') return FAST_MATCHES.has(sc.id);
if (sc.family === 'standins') return sc.pass === 0; // the normal records (the elites with GOLDEN_FULL)
if (sc.family === 'fields') return FAST_FIELDS.has(sc.id);
if (sc.family === 'bonds') return sc.id.endsWith('-high');
return sc.pass === 0;