卫戍协议:盟约 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:
commit
01a44cd288
408 files changed
+308986
No files matched your search
@@ -0,0 +1,175 @@
|
||||
// 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, /同盟模拟的作战不能暂停/);
|
||||
});
|
||||
Reference in new issue
Block a user