// Documentation ⇄ code consistency (docs/DESIGN.md, DATA.md, META.md, SIM.md, README.md, DEPLOY.md, PLAYING.md). // Every rule the final documentation sweep corrected is checked twice: the code still behaves as the docs now say, and // the stale wording does not come back. Topics: combat / boss clocks in REAL seconds (overtime after 150 real s, // m.public.overtimeAt), 联防 helper order (unite.helperOrder, research 08 §5), boss results handed over instead of a // synthetic zero, reconnect windows (co-op 10 min, solo singleReconnectTime 24 h), g.equip replaceUid + equipped items // locked, battleId unique per match, solo pause (g.pause / m.public.paused), road-over-floor lanes, module icons from // local art, 标准 = 战场#01 only. import { test } from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { GameData } from '../server/match/gamedata.js'; import { helperOrder } from '../server/match/unite.js'; import { NET_DEFAULTS } from '../server/net.js'; import { SOLO_RECONNECT_FALLBACK_SEC } from '../server/lobby.js'; import { moduleTypeIconUrl } from '../public/js/ui/assetUrls.js'; import { validateC2S } from '../shared/protocol.js'; import { ERR } from '../shared/constants.js'; import { DATA, makeMatch } from './match/harness.js'; const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); const doc = (p) => readFileSync(join(ROOT, p), 'utf8'); const DESIGN = doc('docs/DESIGN.md'); const META = doc('docs/META.md'); const DATA_MD = doc('docs/DATA.md'); const SIM = doc('docs/SIM.md'); const README = doc('README.md'); const DEPLOY = doc('docs/DEPLOY.md'); const PLAYING = doc('docs/PLAYING.md'); /** The table row of DATA.md whose first cell is `key` (backticked), or ''. */ const dataRow = (key, from = 0) => { const i = DATA_MD.indexOf(`| \`${key}\` |`, from); return i < 0 ? '' : DATA_MD.slice(i, DATA_MD.indexOf('\n', i)); }; test('combat limits and the boss overtime are REAL seconds (code) and the docs say so', () => { const gd = new GameData(DATA, 'mode_multi_hard'); assert.equal(gd.combatTimeScale, 2); assert.equal(gd.combatTimeLimit(1), 2 * gd.combatTimeLimitReal(1), 'Battle limit = 2 × data (game s)'); assert.equal(gd.bossOvertimeAfterReal, 150); assert.equal(gd.bossOvertimeDue(300), 0, 'nothing at 150 real s = 300 game s'); assert.equal(gd.bossOvertimeDue(302), 1, 'the first point at 151 real s'); assert.equal(gd.bossOvertimeDue(320), 10, '1 LP per real second'); assert.equal(gd.bossLevelTime(14), 120, 'the Final Assault countdown (not a hard stop)'); // DESIGN assert.ok(!/overtime −1 LP\/s after 150 s game time/.test(DESIGN), 'DESIGN §6.1: no game-second overtime'); assert.ok(!/team LP per game second after `bossOvertimeAfter` game s/.test(DESIGN), 'DESIGN §14: no game-second overtime'); assert.match(DESIGN, /150 real s/); assert.match(DESIGN, /overtimeAt/); // DATA assert.match(dataRow('combatTimeLimit', DATA_MD.indexOf('`rounds[r]`:')), /\*\*real\*\* seconds/, 'rounds[r].combatTimeLimit row'); assert.match(dataRow('maxPlayTime'), /\*\*real\*\* seconds/, 'waves.json maxPlayTime row'); assert.match(dataRow('bossOvertimeAfter'), /\*\*real\*\* second/, 'rounds[r].bossOvertimeAfter row'); assert.match(dataRow('levelMaxPlayTime'), /countdown/); // META / SIM / PLAYING assert.match(META, /150 real s/); assert.match(META, /`m\.public\.overtimeAt`/); assert.match(SIM, /2 × the round's combatTimeLimit/); assert.match(PLAYING, /120 秒倒计时结束后战斗\*\*继续\*\*/); }); test('联防 helpers follow unite.helperOrder (units > active bond > standing > seat; LP plays no part) and the docs name it', () => { const player = (playerId, seat, lp, units, bond = false) => ({ playerId, seat, lp, deployCount: units, board: new Map(), layers: {}, bonds: bond ? { bond_x: { active: true, layers: 5 } } : {}, }); const m = { gd: { unite: { maxHelpers: 2 } } }; const a = player('a', 0, 40, 3); // most LP, fewest units const b = player('b', 1, 1, 5); // most units const c = player('c', 2, 2, 5, true); // most units + an active bond const order = helperOrder(m, [a, b, c], new Map()).map((p) => p.playerId); assert.deepEqual(order, ['c', 'b'], 'LP never decides; the first helper (right-hand field) is c'); assert.ok(!/highest LP, then seat/.test(DESIGN), 'DESIGN §6.1 no longer picks helpers by LP'); assert.match(DESIGN, /helperOrder/); assert.match(META, /unite\.js helperOrder/); assert.match(DATA_MD, /server\/match\/unite\.js helperOrder/); assert.ok(!/spawn window ≤ 40 % of the limit/.test(META), 'META §7: the 联防 timing is the official one'); }); test('boss results: no synthetic zero for a running boss field (docs), handover documented', () => { assert.ok(!/synthetic zero result \(boss\)/.test(DESIGN)); assert.match(DESIGN, /_bossHandover/); assert.match(META, /hands the field to the partner's replica or the server/); }); test('reconnect windows: co-op 10 min, solo singleReconnectTime 24 h (code, data, every doc)', () => { assert.equal(NET_DEFAULTS.reconnectWindowMs, 10 * 60_000); assert.equal(SOLO_RECONNECT_FALLBACK_SEC, 86_400); assert.equal(DATA.config.constants.singleReconnectTime, 86_400); assert.match(DESIGN, /singleReconnectTime/); assert.match(DESIGN, /24 h/); assert.match(META, /singleReconnectTime/); assert.match(dataRow('constants'), /singleReconnectTime/); for (const [name, text] of [['README', README], ['DEPLOY', DEPLOY], ['PLAYING', PLAYING]]) { assert.match(text, /24 小时/, `${name}: solo resume window`); assert.match(text, /10 分钟/, `${name}: co-op window`); } assert.ok(!/断线 10 分钟内可重连,掉线期间 AI 托管/.test(README), 'README: a dropped seat is not AI-played unless 暂离'); }); test('equipment: g.equip replaceUid and locked equipped items are in the contract docs', () => { assert.match(DESIGN, /`g\.equip \{itemUid, targetUid, replaceUid\?\}`/); assert.match(DESIGN, /equipped items are locked/); assert.match(META, /g\.equip\s*\n?\s*\{ itemUid, targetUid, replaceUid \}/); assert.match(README, /已配发的装备锁定在干员身上/); assert.match(PLAYING, /已配发的装备锁定在干员身上/); }); test('battleId is unique per match although field ids repeat (code) and DESIGN §14 says so', () => { const h = makeMatch({ mode: 'coop', difficulty: 'FUNNY', humans: 2, bots: 1, seed: 4242, fake: true, clientCombat: true, captureFrames: false }); h.autoHumans(); const byId = new Map(); // battleId → 'round|fieldId' h.onSend.push((pid, msg) => { if (msg.t !== 'b.start') return; const key = `${msg.spec && msg.spec.round}|${msg.fieldId}`; if (!byId.has(msg.battleId)) byId.set(msg.battleId, key); else assert.equal(byId.get(msg.battleId), key, 'one battleId never names two battles'); }); h.start(); h.run(() => h.ended != null || h.m.round >= 4, { maxSteps: 2e6 }); h.m.dispose(); const keys = [...byId.values()]; assert.ok(keys.length >= 4, `battles seen (${keys.length})`); assert.equal(new Set(keys).size, keys.length, 'every battle has its own id'); const own = [...byId].filter(([, k]) => k.endsWith('|n:p_0')).map(([id]) => id); assert.ok(own.length >= 2 && new Set(own).size === own.length, 'the same fieldId gets a new battleId every round'); for (const [id, key] of byId) { const round = key.split('|')[0]; assert.ok(id.split('.')[1] === round, `${id}: ..[.]`); assert.ok(id.length <= 64, 'protocol ids are ≤ 64 chars'); } assert.match(DESIGN, /`\.\.\.`/); }); test('stages: 标准 = 战场#01 only (data) and the docs say so; road-over-floor lanes documented', () => { for (const id of ['mode_multi_funny', 'mode_single_funny']) assert.deepEqual(DATA.config.modes[id].stages, ['act1autochess_m01'], id); assert.ok(!DATA.config.modes.mode_multi_hard.stages.includes('act1autochess_m01'), '绝境 has no 战场#01'); assert.match(DESIGN, /标准 FUNNY = 战场#01/); assert.match(dataRow('stages'), /战场#01 only/); assert.match(PLAYING, /标准模拟固定为「战场#01」/); // 战场#01 lower gate: the col-8 road, not the col-9 floor lane (grid.js blockable-ground preference) const path = DATA.stages.act1autochess_m01.groundPaths['9,10->9,2']; assert.ok(path.some(([r, c]) => r === 10 && c === 8) && !path.some(([r, c]) => r === 10 && c === 9), 'col-8 road'); assert.match(dataRow('groundPaths` / `groundPathsWithDevices'), /road-over-floor/); assert.match(SIM, /fewest non-blockable tiles/); }); test('module type icons come from the local-client art, case-insensitively (code + DESIGN §13)', () => { const local = { groups: { module: { 'PRI-X': { path: 'a.png' }, 'isw-a': { path: 'b.png' } } } }; assert.equal(moduleTypeIconUrl(local, 'PRI-X'), 'a.png'); assert.equal(moduleTypeIconUrl(local, 'ISW-α'), 'b.png', 'Greek letter → Latin file name'); assert.equal(moduleTypeIconUrl(null, 'PRI-X'), null, 'no local art ⇒ lettered fallback'); assert.match(DESIGN, /moduleTypeIconUrl/); assert.match(DESIGN, /`ISW-α` → `isw-a`/); }); test('solo pause: g.pause {on} is solo-only and m.public.paused follows (code) — documented everywhere', () => { assert.equal(validateC2S({ t: 'g.pause', on: true }), null, 'protocol knows g.pause {on}'); const co = makeMatch({ mode: 'coop', difficulty: 'FUNNY', humans: 1, bots: 1, seed: 7, fake: true }); co.start(); assert.equal(co.m.handle('p_0', { t: 'g.pause', on: true }).error, ERR.WRONG_PHASE, 'co-op battles never pause'); assert.equal(co.m.publicView().paused, false); co.m.dispose(); const solo = makeMatch({ mode: 'solo', difficulty: 'FUNNY', humans: 1, seed: 7, fake: true }); solo.start(); assert.equal(solo.m.handle('p_0', { t: 'g.pause', on: true }).error, ERR.WRONG_PHASE, 'no battle running yet'); assert.deepEqual(solo.m.handle('p_0', { t: 'g.pause', on: false }), { ok: true }, 'on: false is always accepted'); solo.m.dispose(); assert.match(DESIGN, /`g\.pause \{on\}`/); assert.match(DESIGN, /\*\*Solo pause/); assert.match(META, /`m\.public\.paused`|`paused`\n?\(solo pause/); assert.match(README, /暂停(独立模拟)/); assert.match(PLAYING, /同盟模拟的作战不能暂停/); });