Files
ark-stronghold-protocol/test/docs-consistency.test.js
T
sganggs 01a44cd288 卫戍协议:盟约 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).
2026-09-29 16:21:31 +08:00

176 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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}: <prefix>.<round>.<seq>[.<fieldId>]`);
assert.ok(id.length <= 64, 'protocol ids are ≤ 64 chars');
}
assert.match(DESIGN, /`<prefix>\.<round>\.<seq>\.<fieldId>`/);
});
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, /同盟模拟的作战不能暂停/);
});