卫戍协议:盟约 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

+100
View File
@@ -0,0 +1,100 @@
// Balance: NO custom balance any more (research 08 §6: the official numbers only; data/tuning.json keeps result titles)
// and the competent-board model of tools/balance.mjs, which now only MEASURES difficulty (docs/BALANCE.md).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { GameData } from '../../server/match/gamedata.js';
import { buildNormalWave, setupMatchWaves } from '../../server/match/waves.js';
import { computeBonds } from '../../server/match/bondsMeta.js';
import { createRng } from '../../server/sim/rng.js';
import { DATA } from './harness.js';
import {
CURVES, planBoard, applyBoard, makeMatch, runNormal, runBoss, withoutTuning, waveNeeds, summarizeNormal,
} from '../../tools/balance.mjs';
const RAW = withoutTuning(DATA);
test('no custom balance: legacy tuning multipliers are ignored; enemy scale = the PRTS table; leader pool = bloodPoint', () => {
const tuning = {
modes: {
'*': { enemyHpMul: 0.5, enemyAtkMul: 0.9 },
mode_multi_hard: { enemyHpMul: { 3: 0.8, '*': 0.7 }, enemySpeedMul: 1.1, bossHpMul: { boss_1: 0.5, hidden: 0.25, '*': 0.9 }, flyPlaceholders: 'drop' },
},
};
const hard = new GameData({ ...RAW, tuning }, 'mode_multi_hard');
const base = new GameData(RAW, 'mode_multi_hard');
for (const r of [1, 3, 9, 13]) {
assert.deepEqual(hard.enemyScale(r), base.enemyScale(r));
const e = DATA.config.modes.mode_multi_hard.enemyScale[r];
assert.deepEqual(hard.enemyScale(r), { hpMul: e.hp, atkMul: e.atk, speedMul: e.speed }, `R${r}: the config (PRTS) table`);
}
assert.equal(hard.bossHpMul('boss_1'), 1);
// co-op: one pool = bloodPoint[difficulty] whatever the number of alive players; solo: ×0.25 [ASSUMED, flagged]
for (const [modeId, key] of [['mode_multi_funny', 'FUNNY'], ['mode_multi_normal', 'NORMAL'], ['mode_multi_hard', 'HARD'], ['mode_multi_abyss', 'ABYSS']]) {
const gd = new GameData(RAW, modeId);
for (const b of ['boss_1', 'boss_5', 'boss_8']) assert.equal(gd.bossPoolHp(b), DATA.bosses[b].bloodPoint[key], `${modeId} ${b}`);
}
const solo = new GameData(RAW, 'mode_single_hard');
assert.equal(solo.bossPoolHp('boss_2'), Math.round(DATA.bosses.boss_2.bloodPoint.HARD * 0.25));
assert.equal(DATA.config.bossHpScale.soloAssumed, true);
// the waves carry exactly the table
const setup = setupMatchWaves(hard, createRng(3));
const w = buildNormalWave(hard, createRng(3), setup.factions, 3);
for (const s of w.spawns) assert.equal(s.mods.hpMul, base.baseEnemyScale(3).hpMul);
});
test('data/tuning.json holds only title rules (the enemy / leader multipliers were removed)', () => {
const t = DATA.tuning;
assert.ok(t && typeof t === 'object', 'data/tuning.json is loaded as data.tuning');
assert.deepEqual(Object.keys(t).filter((k) => !k.startsWith('_')).sort(), ['titles', 'version']);
for (const [id, o] of Object.entries(t.titles || {})) {
assert.ok(DATA.config.titles.some((x) => x.id === id), id);
assert.ok(['max', 'min'].includes(o.rule));
}
});
test('competent board model: curves (level, units, elites, items), a core bond active with its layers, wave-aware roles', () => {
const m = makeMatch({ data: RAW, mode: 'coop', difficulty: 'NORMAL', seed: 5, players: 1, rehearsal: 0 });
for (const r of [3, 8, 12]) {
m.round = r;
m.wave = buildNormalWave(m.gd, m.rngWaves, m.factions, r);
const rng = createRng(100 + r);
const plan = planBoard(m, r, rng);
assert.equal(plan.level, CURVES.level[r]);
assert.equal(plan.units.length, CURVES.units[r]);
assert.equal(new Set(plan.units).size, plan.units.length, 'distinct chess');
for (const id of plan.units) {
assert.ok(m.pool.has(id), `${id} not banned this match`);
assert.ok(m.gd.tierOf(id) <= plan.level, `${id} tier ≤ shop level`);
}
assert.ok(plan.elites.size >= Math.floor(CURVES.elites[r]) && plan.elites.size <= Math.ceil(CURVES.elites[r]));
const ps = m.order[0];
applyBoard(m, ps, plan, r, rng);
assert.equal(ps.deployCount, plan.units.length, 'every unit deployed');
const bonds = computeBonds(m.gd, ps);
assert.ok(plan.core && bonds[plan.core].active, `R${r}: core bond ${plan.core} active`);
const L = ps.layers[plan.core];
assert.ok(L >= CURVES.coreLayers[r] * 0.8 - 1 && L <= CURVES.coreLayers[r] * 1.2 + 1, `R${r}: core layers ${L}`);
const flyers = waveNeeds(m.gd, m.wave.spawns).flyShare;
const aa = [...ps.board.values()].filter((p) => p.kind === 'chess' && m.gd.chess(p.id).canHitFly && m.gd.chess(p.id).attackKind !== 'heal').length;
if (flyers > 0.2) assert.ok(aa >= 2, `R${r}: anti-air for a flying wave (${aa})`);
}
m.dispose();
});
test('runNormal / runBoss return consistent samples (real sim, full content)', () => {
const a = runNormal({ data: RAW, mode: 'coop', difficulty: 'FUNNY', round: 1, seed: 7, rehearsal: 0 });
assert.equal(a.round, 1);
assert.ok(a.killed + a.leaks <= a.total);
assert.ok(a.goalLeaks <= a.leaks);
assert.equal(a.timeLimit, 90, 'R1: 45 real seconds of the 2× battle');
assert.ok(a.time > 0 && a.time <= a.timeLimit + 1e-6);
const again = runNormal({ data: RAW, mode: 'coop', difficulty: 'FUNNY', round: 1, seed: 7, rehearsal: 0 });
assert.deepEqual(again, a, 'deterministic per seed');
const s = summarizeNormal([a, again]);
assert.equal(s.leaksAvg, a.leaks);
const b = runBoss({ data: RAW, mode: 'solo', difficulty: 'FUNNY', round: 9, seed: 3, tMax: 30 });
assert.equal(b.players, 1);
assert.ok(b.pool > 0 && b.dmg150 >= 0 && b.dmg150 <= b.pool);
assert.ok(b.ratio150 >= 0 && b.ratio150 <= 1);
assert.equal(b.teamLp0, 15);
});
+246
View File
@@ -0,0 +1,246 @@
// Board placement legality (stage legend), deploy cap, move semantics, placeable summons (DESIGN §3, research 03 C2).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { buildDeployMap, canPlace, legalTiles, tileKey } from '../../server/match/board.js';
import { applyCard } from '../../server/match/choices.js';
import { ERR } from '../../shared/constants.js';
import { DATA, makeMatch, give, giveItem, checkInvariants, chessOfTier } from './harness.js';
const MELEE = (c) => c.position === 'MELEE';
const RANGED = (c) => c.position === 'RANGED';
function prepMatch(stageId = 'act2autochess_m01', o = {}) {
const h = makeMatch({ mode: 'solo', difficulty: 'NORMAL', seed: o.seed ?? 11, ...o }).start();
h.toPrep(1);
h.setStage(stageId);
const ps = h.ps('p_0');
// clean board/hand for deterministic scenarios
for (const p of [...ps.board.values(), ...ps.hand.filter(Boolean)]) if (p.kind === 'chess') ps.returnCopies(p);
ps.board.clear();
ps.hand.fill(null);
ps.recompute();
return { h, m: h.m, ps };
}
test('deploy map from the stage legend equals stages[id].deployTiles.normal for every stage', () => {
for (const [id, st] of Object.entries(DATA.stages)) {
const map = buildDeployMap(st);
const melee = [...map].filter(([, v]) => v === 'melee').map(([k]) => k).sort();
const ranged = [...map].filter(([, v]) => v === 'ranged').map(([k]) => k).sort();
assert.deepEqual(melee, st.deployTiles.normal.melee.map(([r, c]) => tileKey(r, c)).sort(), `${id} melee`);
assert.deepEqual(ranged, st.deployTiles.normal.rangedOnly.map(([r, c]) => tileKey(r, c)).sort(), `${id} ranged`);
}
});
test('glyph rules: melee only on LOW buildable ALL/MELEE; ranged also on HIGH/RANGED; lane/forbidden never; device overrides', () => {
const st = DATA.stages.act2autochess_m01;
const map = buildDeployMap(st);
// (10,4) is 'h' high ground: ranged only; (9,3) road: both; (9,6) '#': none; col 9 lane (10,9) 'f': none
assert.equal(map.get('10,4'), 'ranged');
assert.ok(canPlace(map, 'ranged', 10, 4) && !canPlace(map, 'melee', 10, 4));
assert.ok(canPlace(map, 'melee', 9, 3) && canPlace(map, 'ranged', 9, 3) && canPlace(map, 'all', 9, 3));
assert.ok(!canPlace(map, 'ranged', 9, 6));
assert.ok(!canPlace(map, 'ranged', 10, 9));
assert.ok(!canPlace(map, 'ranged', 8, 4), 'temp row is not the board');
assert.ok(!canPlace(map, 'ranged', 9, 11), 'partner half is not the own board');
// crates block tiles; a terrain override (crate removed) frees them
const m01 = DATA.stages.act1autochess_m01;
const crate = m01.devices.find((d) => d.role === 'crate' && d.pos[0] === 11 && d.pos[1] === 5);
assert.ok(crate);
assert.ok(!canPlace(buildDeployMap(m01), 'melee', 11, 5));
assert.ok(canPlace(buildDeployMap(m01, { deviceOverrides: { [crate.alias]: false } }), 'melee', 11, 5));
assert.ok(!canPlace(buildDeployMap(m01, { tileOverrides: { '12,3': 'none' } }), 'melee', 12, 3));
assert.deepEqual(legalTiles(map, 'melee').length, st.deployTiles.normal.melee.length);
// missing stage data: an open board (degraded mode)
assert.ok(canPlace(buildDeployMap(null), 'melee', 10, 5));
});
test('g.move: legality per chess position, BAD_TILE outside, deploy cap 8, swaps allowed at cap', () => {
const { h, m, ps } = prepMatch();
const meleeId = chessOfTier(1, MELEE).find((id) => m.pool.has(id));
const rangedId = chessOfTier(1, RANGED).find((id) => m.pool.has(id));
const a = give(m, ps, meleeId);
const b = give(m, ps, rangedId);
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: a.uid, to: { area: 'board', row: 10, col: 4 } }), { error: ERR.BAD_TILE }, 'melee on high ground');
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: b.uid, to: { area: 'board', row: 10, col: 4 } }), { ok: true }, 'ranged on high ground');
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: a.uid, to: { area: 'board', row: 9, col: 6 } }), { error: ERR.BAD_TILE }, 'forbidden tile');
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: a.uid, to: { area: 'board', row: 15, col: 4 } }), { error: ERR.BAD_TILE }, 'outside the board');
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: a.uid, to: { area: 'board', row: 9, col: 3 } }), { ok: true });
// ranged may use a melee tile; board→board swap keeps legality (melee cannot be swapped onto high ground)
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: b.uid, to: { area: 'board', row: 9, col: 3 } }), { error: ERR.BAD_TILE }, 'the melee occupant cannot go to 10,4');
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: b.uid, to: { area: 'board', row: 9, col: 4 } }), { ok: true });
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: a.uid, to: { area: 'board', row: 9, col: 4 } }), { ok: true }, 'melee/ranged swap on melee tiles');
assert.equal(ps.board.get('9,4').uid, a.uid);
assert.equal(ps.board.get('9,3').uid, b.uid);
// fill to the cap (8)
const tiles = legalTiles(ps.deployMap(), 'melee').filter(([r, c]) => !ps.board.has(tileKey(r, c)));
const extra = [];
for (const id of chessOfTier(2, MELEE).filter((x) => m.pool.has(x)).slice(0, 7)) extra.push(give(m, ps, id));
for (let i = 0; i < 6; i++) assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: extra[i].uid, to: { area: 'board', row: tiles[i][0], col: tiles[i][1] } }), { ok: true });
assert.equal(ps.deployCount, 8);
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: extra[6].uid, to: { area: 'board', row: tiles[6][0], col: tiles[6][1] } }), { error: ERR.BOARD_FULL });
// swapping a hand chess with a board chess is fine at the cap
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: extra[6].uid, to: { area: 'board', row: tiles[0][0], col: tiles[0][1] } }), { ok: true });
assert.equal(ps.deployCount, 8);
assert.ok(ps.hand.some((p) => p && p.uid === extra[0].uid), 'the swapped-out chess went to the hand');
// the 人事部文档 style cap raise
ps.deployCapMin = 9;
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: extra[0].uid, to: { area: 'board', row: tiles[6][0], col: tiles[6][1] } }), { ok: true });
assert.equal(ps.deployCount, 9);
checkInvariants(m);
m.dispose();
});
test('g.move to the hand: withdraw, full-hand swap with a chess, hand↔hand swaps, items stay in the hand', () => {
const { m, ps } = prepMatch();
const ids = chessOfTier(1).filter((x) => m.pool.has(x));
const onBoard = give(m, ps, ids[0], 'board', [9, 3]);
// fill the hand with 9 chess + 1 item
const handChess = [];
for (let i = 1; i <= 9; i++) handChess.push(give(m, ps, ids[i]));
const item = giveItem(m, ps, 'chess_item_1_01_e_a');
assert.equal(ps.hand.filter(Boolean).length, 10);
// hand full: withdraw onto an empty slot impossible → onto a chess = swap (if the chess is legal on the tile)
const target = ps.hand.findIndex((p) => p && p.kind === 'chess' && m.gd.chess(p.id).position === 'MELEE');
if (target >= 0) {
const other = ps.hand[target];
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: onBoard.uid, to: { area: 'hand', idx: target } }), { ok: true });
assert.equal(ps.hand[target].uid, onBoard.uid);
assert.equal(ps.board.get('9,3').uid, other.uid);
}
// onto the item slot with a full hand → HAND_FULL
const itemIdx = ps.hand.findIndex((p) => p && p.kind === 'item');
const boardPiece = ps.board.get('9,3');
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: boardPiece.uid, to: { area: 'hand', idx: itemIdx } }), { error: ERR.HAND_FULL });
// items cannot be placed on the board but can move inside the hand
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: item.uid, to: { area: 'board', row: 9, col: 4 } }).error, ERR.BAD_TARGET);
const other = handChess[0];
const oi = ps.hand.findIndex((p) => p && p.uid === other.uid);
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: item.uid, to: { area: 'hand', idx: oi } }), { ok: true });
assert.equal(ps.hand[oi].uid, item.uid);
assert.equal(ps.hand[itemIdx].uid, other.uid);
// bad inputs
assert.equal(m.handle('p_0', { t: 'g.move', uid: 999999, to: { area: 'hand', idx: 0 } }).error, ERR.BAD_TARGET);
assert.equal(m.handle('p_0', { t: 'g.move', uid: item.uid, to: { area: 'hand', idx: 10 } }).error, ERR.BAD_TARGET);
checkInvariants(m);
m.dispose();
});
test('placeable summons: owner on board sends a stack to the hand; tokens deploy without a slot; withdrawing removes them', () => {
const { m, ps } = prepMatch('act2autochess_m04');
const ownerId = 'chess_char_3_19_a'; // 伺夜 → 狼群 (DEFAULT, placeable)
assert.equal(DATA.tokens.token_10028_vigil_wolf.displayType, 'DEFAULT');
const owner = give(m, ps, ownerId);
const tile = [9, 3];
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: owner.uid, to: { area: 'board', row: tile[0], col: tile[1] } }), { ok: true });
const stack = ps.hand.find((p) => p && p.kind === 'token');
assert.ok(stack, 'token stack sent to the hand');
assert.equal(stack.ownerUid, owner.uid);
assert.equal(stack.count, DATA.tokens.token_10028_vigil_wolf.deployLimit);
const deployedBefore = ps.deployCount;
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: stack.uid, to: { area: 'board', row: 9, col: 4 } }), { ok: true });
assert.equal(ps.deployCount, deployedBefore, 'summons do not use deploy slots');
const tok = ps.board.get('9,4');
assert.equal(tok.kind, 'token');
// the private view carries it with count/ownerUid
const view = ps.privateView();
const tv = view.board.find((p) => p.kind === 'token');
assert.equal(tv.ownerUid, owner.uid);
// back to the hand (re-stacks), then withdraw the owner → tokens gone
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: tok.uid, to: { area: 'hand', idx: 0 } }), { ok: true });
assert.equal(ps.hand.filter((p) => p && p.kind === 'token').length, 1);
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: ps.hand.find((p) => p && p.kind === 'token').uid, to: { area: 'board', row: 9, col: 4 } }), { ok: true });
const free = ps.hand.findIndex((x) => x == null);
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: owner.uid, to: { area: 'hand', idx: free } }), { ok: true });
assert.ok(![...ps.board.values(), ...ps.hand].some((p) => p && p.kind === 'token'), 'owner withdrawn → its summons removed');
// summons of an owner that is not on the board cannot deploy; tokens are not sellable
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: owner.uid, to: { area: 'board', row: 9, col: 3 } }), { ok: true });
const st2 = ps.hand.find((p) => p && p.kind === 'token');
assert.equal(m.handle('p_0', { t: 'g.sell', uid: st2.uid }).error, ERR.BAD_TARGET);
// selling the owner removes its summons too
assert.deepEqual(m.handle('p_0', { t: 'g.sell', uid: owner.uid }), { ok: true });
assert.ok(![...ps.board.values(), ...ps.hand].some((p) => p && p.kind === 'token'));
checkInvariants(m);
m.dispose();
});
test('battle input: board units in deploy order with items; tokens carry ownerUid', () => {
const { m, ps } = prepMatch('act2autochess_m04');
const a = give(m, ps, 'chess_char_3_19_a');
m.handle('p_0', { t: 'g.move', uid: a.uid, to: { area: 'board', row: 10, col: 5 } });
const b = give(m, ps, chessOfTier(1, MELEE).find((x) => m.pool.has(x)), 'board', [12, 3]);
const it = giveItem(m, ps, 'chess_item_1_01_e_a');
assert.deepEqual(m.handle('p_0', { t: 'g.equip', itemUid: it.uid, targetUid: b.uid }), { ok: true });
const tok = ps.hand.find((p) => p && p.kind === 'token');
m.handle('p_0', { t: 'g.move', uid: tok.uid, to: { area: 'board', row: 9, col: 4 } });
const input = ps.battleInput();
assert.deepEqual(input.units.map((u) => [u.row, u.col]), [[12, 3], [10, 5], [9, 4]], 'top→bottom then left→right');
assert.deepEqual(input.units[0].items, ['chess_item_1_01_e_a']);
assert.equal(input.units[2].kind, 'token');
assert.equal(input.units[2].ownerUid, a.uid);
assert.equal(input.playerId, 'p_0');
assert.ok(input.bonds && typeof input.bonds === 'object');
m.dispose();
});
test('terrain 机变 card (模拟战场演变·模式二 on 战场 m02): an operator left on a tile it may no longer occupy is withdrawn to the hand (overflow temp); legal pieces stay', () => {
const { m, ps } = prepMatch('act1autochess_m02');
assert.equal(ps.deployMap().get('12,4'), 'melee');
assert.equal(ps.deployMap().get('12,5'), 'melee');
const melees = chessOfTier(1, MELEE).filter((x) => m.pool.has(x));
const ranged = chessOfTier(1, RANGED).find((x) => m.pool.has(x));
const a = give(m, ps, melees[0]);
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: a.uid, to: { area: 'board', row: 12, col: 4 } }), { ok: true });
const r = give(m, ps, ranged);
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: r.uid, to: { area: 'board', row: 12, col: 5 } }), { ok: true });
// a full hand: the withdrawn operator overflows into temp (the player re-places it during the prep)
for (let i = 1; ps.hand.some((p) => p == null); i++) give(m, ps, melees[i % melees.length]);
const card = m.gd.choices.cards.tactic.find((c) => c.effectId === 'map_m02_1');
assert.ok(card, 'data: the m02 terrain card');
applyCard(m, ps, { kind: 'tactic', id: 'map_m02_1', name: card.name, desc: card.desc, team: false, tacticKind: 'terrain' });
assert.equal(ps.deployMap().get('12,4'), 'ranged', 'the barricades became 射击台');
assert.equal(ps.board.get('12,4'), undefined, 'the melee operator left the 射击台');
assert.ok(ps.temp.includes(a), 'hand full → temp');
assert.equal(ps.board.get('12,5'), r, 'a ranged operator stays on the new 射击台');
assert.ok(!ps.battleInput().units.some((u) => u.uid === a.uid));
assert.equal(ps.tempEmpty, false, 'temp blocks Ready until the operator is re-placed');
checkInvariants(m);
m.dispose();
});
test('a terrain change by content (setDeviceActive at the prep end, no recompute) never fields an illegal board', () => {
const { m, ps } = prepMatch('act1autochess_m02');
const a = give(m, ps, chessOfTier(1, MELEE).find((x) => m.pool.has(x)));
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: a.uid, to: { area: 'board', row: 12, col: 4 } }), { ok: true });
// what map_m02_1 toggles, applied the way ctx.setDeviceActive does it (invalidateDeployMap + dirty, no recompute)
const eff = m.gd.effect('map_m02_1');
for (const b of eff.buffs) for (const [alias, v] of Object.entries(b.bb || {})) if (alias.includes('#')) { ps.deviceOverrides[alias] = Number(v) !== 0; ps.invalidateDeployMap(); }
const input = ps.battleInput();
assert.ok(!input.units.some((u) => u.uid === a.uid), 'withdrawn before the battle input is built');
assert.ok(ps.hand.includes(a));
checkInvariants(m);
m.dispose();
});
test('withdrawing a deployed summon into a full hand: HAND_FULL like any other card (no overflow into temp)', () => {
const { m, ps } = prepMatch('act2autochess_m04');
// 赫默 → one 医疗探机 (deployLimit 1): once deployed no stack is left in the hand to join
const medic = give(m, ps, 'chess_char_2_02_a');
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: medic.uid, to: { area: 'board', row: 10, col: 5 } }), { ok: true });
const drone = ps.hand.find((p) => p && p.kind === 'token');
assert.ok(drone && drone.count === 1, 'one drone');
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: drone.uid, to: { area: 'board', row: 9, col: 4 } }), { ok: true });
// golden items never merge with each other: a plain full hand
for (let i = 0; i < ps.hand.length; i++) if (!ps.hand[i]) giveItem(m, ps, 'chess_item_1_02_e_b', 'hand', i);
assert.ok(ps.hand.every(Boolean) && ps.tempEmpty);
assert.equal(m.handle('p_0', { t: 'g.move', uid: drone.uid, to: { area: 'hand', idx: 0 } }).error, ERR.HAND_FULL, 'a new card for a full hand');
assert.equal(ps.board.get('9,4'), drone, 'the drone stays on the board');
assert.ok(ps.tempEmpty, 'nothing overflowed into temp (Ready stays possible)');
assert.equal(m.handle('p_0', { t: 'g.move', uid: medic.uid, to: { area: 'hand', idx: 0 } }).error, ERR.HAND_FULL, 'the same as withdrawing an operator');
// a free slot: the drone comes back
ps.hand[3] = null;
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: drone.uid, to: { area: 'hand', idx: 0 } }), { ok: true });
assert.equal(ps.hand[3], drone);
checkInvariants(m);
m.dispose();
});
+105
View File
@@ -0,0 +1,105 @@
// Bond counting modes, tiers, 调和, 独行, 绝技, 助力, layers (DESIGN §6.3, research 02 §2.1).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { GameData } from '../../server/match/gamedata.js';
import { computeBonds, tierFor, activatedLayers, bondList, thresholdsOf } from '../../server/match/bondsMeta.js';
import { DATA } from './harness.js';
const gd = new GameData(DATA, 'mode_multi_normal');
let uid = 1;
const piece = (id, items = []) => ({ uid: uid++, kind: 'chess', id, items: items.map((i) => ({ uid: uid++, kind: 'item', id: i })) });
const state = ({ board = [], hand = [], layers = {} } = {}) => {
const b = new Map();
board.forEach((p, i) => b.set(`${9 + (i % 4)},${2 + Math.floor(i / 4)}`, p));
const h = new Array(10).fill(null);
hand.forEach((p, i) => { h[9 - i] = p; });
return { board: b, hand: h, layers };
};
const members = (bond, n, extra = () => true) => Object.values(DATA.chess).filter((c) => c.visible && !c.isGolden && c.bonds.includes(bond) && extra(c)).slice(0, n).map((c) => c.chessId);
test('BOARD: distinct base chess on the board; normal + elite of one operator count once; the hand does not count', () => {
const [a, b, c] = members('yanShip', 3, (x) => x.bonds.length === 1 || true);
let s = computeBonds(gd, state({ board: [piece(a), piece(b)], hand: [piece(c)] }));
assert.equal(s.yanShip.count, 2);
assert.equal(s.yanShip.active, false);
s = computeBonds(gd, state({ board: [piece(a), piece(b), piece(c)] }));
assert.equal(s.yanShip.count, 3);
assert.equal(s.yanShip.tier, 1);
assert.equal(s.yanShip.active, true);
s = computeBonds(gd, state({ board: [piece(a), piece(DATA.chess[a].goldenId), piece(b)] }));
assert.equal(s.yanShip.count, 2, 'normal + elite of one operator = 1');
const six = members('yanShip', 6);
s = computeBonds(gd, state({ board: six.map((x) => piece(x)) }));
assert.equal(s.yanShip.tier, 2);
assert.deepEqual(thresholdsOf(DATA.bonds.yanShip), [3, 6, 9]);
});
test('BOARD_AND_DECK (远见/奇迹/投资人) also counts the hand', () => {
const [a, b] = members('visiShip', 2);
const s = computeBonds(gd, state({ board: [piece(a)], hand: [piece(b)] }));
assert.equal(s.visiShip.count, 2);
assert.equal(s.visiShip.active, true);
const v = bondList(gd, s, { full: true }).find((x) => x.bondId === 'visiShip');
assert.equal(v.countsHand, true);
});
test('绝技 (BOARD_ALL_CHESS): every elite on the board, duplicates included', () => {
const g = Object.values(DATA.chess).filter((c) => c.visible && c.isGolden !== true).slice(0, 5).map((c) => c.goldenId);
let s = computeBonds(gd, state({ board: [piece(g[0]), piece(g[0])] }));
assert.equal(s.suntShip.count, 2);
assert.equal(s.suntShip.active, true);
s = computeBonds(gd, state({ board: g.map((x) => piece(x)) }));
assert.equal(s.suntShip.tier, 2);
s = computeBonds(gd, state({ hand: [piece(g[0]), piece(g[1])] }));
assert.equal(s.suntShip.count, 0, 'hand elites do not count');
});
test('调和: +1 to core bonds with ≥ 1 real member on the board while active', () => {
const mani = members('maniShip', 1)[0];
assert.ok(mani, 'a visible 调和 chess exists');
const yan = members('yanShip', 2, (c) => !c.bonds.includes('maniShip'));
let s = computeBonds(gd, state({ board: [piece(mani), ...yan.map((x) => piece(x))] }));
assert.equal(s.maniShip.active, true);
assert.equal(s.yanShip.count, 3, '2 real + 1');
assert.equal(s.yanShip.active, true);
const noMember = s.sargonShip ? s.sargonShip.count : 0;
assert.equal(noMember, members('sargonShip', 99).includes(mani) ? 1 : 0, 'no +1 without a real member');
s = computeBonds(gd, state({ board: yan.map((x) => piece(x)) }));
assert.equal(s.yanShip.count, 2);
});
test('独行 (downward): active only while exactly 1 distinct 独行 operator is on the board', () => {
const solo = members('soloShip', 2);
assert.equal(tierFor(DATA.bonds.soloShip, 1), 1);
assert.equal(tierFor(DATA.bonds.soloShip, 2), 0);
assert.equal(tierFor(DATA.bonds.soloShip, 0), 0);
let s = computeBonds(gd, state({ board: [piece(solo[0])] }));
assert.equal(s.soloShip.active, true);
s = computeBonds(gd, state({ board: [piece(solo[0]), piece(DATA.chess[solo[0]].goldenId)] }));
assert.equal(s.soloShip.active, true, 'two copies of the same operator do not break it');
s = computeBonds(gd, state({ board: [piece(solo[0]), piece(solo[1])] }));
assert.equal(s.soloShip.active, false);
});
test('助力: upper tier counts operators differing in name OR elite state', () => {
const d = members('deputShip', 2);
let s = computeBonds(gd, state({ board: [piece(d[0]), piece(d[1])] }));
assert.equal(s.deputShip.tier, 1);
s = computeBonds(gd, state({ board: [piece(d[0]), piece(d[1]), piece(DATA.chess[d[1]].goldenId)] }));
assert.equal(s.deputShip.count, 2);
assert.equal(s.deputShip.tier, 2, 'normal + elite count as different for the 3-member tier');
});
test('layers persist on every bond; Σ activated layers only counts active bonds; FUNNY inactive bonds omitted', () => {
const [a, b, c] = members('yanShip', 3);
const s = computeBonds(gd, state({ board: [piece(a), piece(b), piece(c)], layers: { yanShip: 40, egirShip: 100 } }));
assert.equal(s.yanShip.layers, 40);
assert.equal(s.egirShip.layers, 100);
assert.equal(activatedLayers(s), 40);
const list = bondList(gd, s);
assert.equal(list[0].bondId, 'yanShip', 'active first');
assert.ok(list.some((x) => x.bondId === 'egirShip' && !x.active), 'inactive bonds with layers are listed');
const funny = new GameData(DATA, 'mode_multi_funny');
const f = computeBonds(funny, state({ board: [piece(a)] }));
for (const off of DATA.config.modes.mode_multi_funny.inactiveBondIds) assert.ok(!(off in f), `${off} omitted in FUNNY`);
});
+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)`);
});
+476
View File
@@ -0,0 +1,476 @@
// Client-side combat (DESIGN §14) — regression tests of the adversarial review:
// * eliminated humans keep watching: at COMBAT start they get a display replica of the first field (research 09 §3.1
// "Keep-watching auto-observes the first available field");
// * stale battle reports after the match end are ignored, never answered with an error (a rid-less error frame
// becomes an error toast in the browser) — Match.handle and the platform (server/net.js);
// * forged results the old validator accepted: leaks marked `counted: false` (no LP loss, "perfect"), layer gains on
// bonds the player's lineup does not name, 联防 survivors pinned on the wrong leaker;
// * server-run fields (bots, takeovers) in wall-clock slices instead of one blocking run per field, and the boss
// takeover's fast-forward spread over the pacing intervals (a low-power host keeps a responsive event loop);
// * b.pool numbers are exact and a client that emptied the pool as it saw it ends the fight as cleared.
import { test, after } from 'node:test';
import assert from 'node:assert/strict';
import { PHASE } from '../../shared/constants.js';
import { buildBattleSpec, createBattleFromSpec, compactResult, resultDigest } from '../../server/sim/spec.js';
import { validateClientResult, specBounds, HeadlessPacer, HeadlessJob, runHeadless } from '../../server/match/fields.js';
import { VirtualScheduler } from '../../server/match/scheduler.js';
import { GameData } from '../../server/match/gamedata.js';
import { startServer } from '../../server/index.js';
import { TestClient } from '../helpers/wsClient.js';
import { FakeBattle } from './fakeBattle.js';
import { DATA, makeMatch, checkInvariants } from './harness.js';
const gd = new GameData(DATA, 'mode_multi_hard');
test('eliminated humans auto-observe the first field at COMBAT start (display replica) and may switch freely', () => {
const h = makeMatch({ mode: 'coop', humans: 3, bots: 1, seed: 9301, fake: true, clientCombat: true, instant: false, pace: 'paced',
script: () => ({ duration: 6 }) }).start();
const m = h.m;
h.autoHumans();
h.drive(() => m.phase === PHASE.PREP && m.round === 2);
const dead = h.ps('p_2');
dead.lp = 0;
dead.eliminate(1);
h.drive(() => m.phase === PHASE.COMBAT && m.round === 2);
const starts = h.allTo('p_2', 'b.start').filter((s) => s.spec.round === 2);
assert.equal(starts.length, 1, 'the eliminated player watches a field without asking');
assert.equal(starts[0].fieldId, 'n:p_0', 'the first field');
assert.equal(starts[0].watch, true);
assert.equal(starts[0].authoritative, false, 'display only');
assert.equal(m.watchers.get('p_2'), 'n:p_0');
assert.ok(!m.fields.some((f) => f.players.includes('p_2')), 'no field of its own');
// free switching (eliminated: anything) — including a bot's server-run field
assert.deepEqual(m.handle('p_2', { t: 'g.watch', fieldId: 'n:ai_0' }), { ok: true });
assert.equal(h.lastTo('p_2', 'b.start').fieldId, 'n:ai_0');
// a reconnect resends the watched field
m.onDisconnect('p_2');
m.onReconnect('p_2');
assert.equal(h.lastTo('p_2', 'b.start').fieldId, 'n:ai_0');
h.run(() => m.phase === PHASE.SETTLE || h.ended != null);
assert.equal(m.errorCount, 0);
checkInvariants(m);
m.dispose();
});
test('stale b.progress / b.result after the match ended are ignored (ok), other intents still fail', () => {
const h = makeMatch({ mode: 'coop', humans: 2, seed: 9302, fake: true, clientCombat: true }).start();
const m = h.m;
h.toPrep(1);
m.finish({ victory: false, reason: 'defeat' });
assert.deepEqual(m.handle('p_0', { t: 'b.progress', battleId: '1.1.n:p_0', gt: 3, killed: 1, total: 4 }), { ok: true });
assert.deepEqual(m.handle('p_0', { t: 'b.result', battleId: '1.1.n:p_0', result: { reason: 'cleared', time: 1, perPlayer: {} } }), { ok: true });
assert.equal(m.handle('p_0', { t: 'g.ready', ready: true }).error, 'WRONG_PHASE');
m.dispose();
assert.deepEqual(m.handle('p_1', { t: 'b.progress', battleId: 'x', gt: 0, killed: 0, total: 0 }), { ok: true });
});
let srv = null;
after(async () => { if (srv) await srv.close(); });
test('platform: a rid-less b.progress that reaches no match gets no error frame (a request with a rid still does)', async () => {
srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
const c = await TestClient.connect(`ws://127.0.0.1:${srv.port}/ws`);
try {
await c.hello('Stale');
c.send({ t: 'b.progress', battleId: 'gone', gt: 1, killed: 0, total: 1, rid: null }); // fire-and-forget, like the runner
const pong = await c.request({ t: 'ping', c: 1 });
assert.equal(pong.t, 'pong');
assert.ok(!c.log.some((x) => x.t === 'error'), `no error frame for a stale fire-and-forget report (${JSON.stringify(c.log.filter((x) => x.t === 'error'))})`);
const r = await c.request({ t: 'b.progress', battleId: 'gone', gt: 1, killed: 0, total: 1 });
assert.equal(r.t, 'error', 'a request is always answered');
} finally {
await c.terminate().catch(() => {});
}
});
// ---- validation -------------------------------------------------------------------------------------------------
/** A plain enemy key (spawn-only, counts in the total, no content-derived relatives). */
const plainKey = Object.keys(DATA.enemies).find((k) => {
const e = gd.enemy(k);
return e && !e.notCountInTotal && specBounds({ spawns: [{ enemyKey: k, count: 1 }] }, gd).derived.size === 0;
});
const uncountedKey = Object.keys(DATA.enemies).find((k) => gd.enemy(k) && gd.enemy(k).notCountInTotal);
const perPlayer = (o = {}) => ({ killed: 0, total: 4, leaked: [], perfect: true, layerGains: {}, coins: 0, damageDealt: 0, bossDamage: 0, healingDone: 0, deaths: 0, unitsEnd: [], unitStats: [], ...o });
test('validation: leaks marked counted:false are only accepted for enemies that never count', () => {
assert.ok(plainKey && uncountedKey);
const spec = buildBattleSpec({ battleId: 'v1', fieldId: 'n:p', kind: 'normal', seed: 1, round: 3, timeLimit: 60,
players: [{ playerId: 'p', units: [], bonds: {} }], spawns: [{ enemyKey: plainKey, count: 2, time: 1 }, { enemyKey: uncountedKey, count: 1, time: 2 }], flags: { layerGainsEnabled: true } });
const raw = (leaked, perfect) => ({ reason: 'timeout', time: 30, killed: 0, total: 3, perPlayer: { p: perPlayer({ total: 3, leaked, perfect }) }, errors: 0 });
const honest = validateClientResult(spec, raw([{ enemyKey: plainKey, counted: true }, { enemyKey: uncountedKey, counted: false }], false), { gd });
assert.ok(honest.ok, honest.reason);
const forged = validateClientResult(spec, raw([{ enemyKey: plainKey, counted: false }, { enemyKey: plainKey, counted: false }], true), { gd });
assert.deepEqual(forged, { ok: false, reason: 'uncounted leak' }, 'hiding leaks from the LP count');
const tagged = buildBattleSpec({ ...spec, spawns: [{ enemyKey: plainKey, count: 1, time: 1, countInTotal: false }] });
assert.ok(validateClientResult(tagged, raw([{ enemyKey: plainKey, counted: false }], true), { gd }).ok, 'a schedule entry that does not count');
});
test('validation: IN_BATTLE layer gains only on bonds the lineup / band / effects / items name', () => {
const chess = Object.values(DATA.chess).find((c) => c.visible && !c.isGolden && Array.isArray(c.bonds) && c.bonds.length);
assert.ok(chess);
const text = JSON.stringify(chess);
const own = Object.keys(DATA.bonds).find((b) => text.includes(b));
const foreign = Object.keys(DATA.bonds).find((b) => !text.includes(b));
assert.ok(own && foreign, 'a bond of the chess and one it does not name');
const spec = buildBattleSpec({ battleId: 'v2', fieldId: 'n:p', kind: 'normal', seed: 1, round: 5, timeLimit: 60,
players: [{ playerId: 'p', units: [{ uid: 7, kind: 'chess', chessId: chess.chessId, row: 10, col: 3, items: [] }], bonds: { [own]: { count: 1, active: true, tier: 1, layers: 0 } } }],
spawns: [{ enemyKey: plainKey, count: 1, time: 1 }], flags: { layerGainsEnabled: true } });
const raw = (layerGains) => ({ reason: 'cleared', time: 20, killed: 1, total: 1, perPlayer: { p: perPlayer({ killed: 1, total: 1, layerGains }) }, errors: 0 });
assert.ok(validateClientResult(spec, raw({ [own]: 6 }), { gd }).ok);
assert.deepEqual(validateClientResult(spec, raw({ [foreign]: 6 }), { gd }), { ok: false, reason: 'layer bond' });
// a band that grants layers of a named bond (克莱门莎-style, requireActive false) makes that bond legal
const band = Object.values(DATA.bands).find((b) => {
const eff = b.effectId && DATA.effects ? DATA.effects[b.effectId] : null;
return JSON.stringify([b, eff]).includes(foreign);
});
if (band) {
const withBand = buildBattleSpec({ ...spec, players: [{ ...spec.players[0], bandId: band.bandId }] });
assert.ok(validateClientResult(withBand, raw({ [foreign]: 6 }), { gd }).ok, `band ${band.bandId} names ${foreign}`);
}
});
test('validation: 联防 survivors cannot be pinned on another leaker (per enemy and leaker, with the never-spawned re-entries)', () => {
const spec = buildBattleSpec({ battleId: 'v3', fieldId: 'u', kind: 'unite', seed: 1, round: 6, timeLimit: 60,
players: [{ playerId: 'h', units: [], bonds: {} }],
spawns: [{ enemyKey: plainKey, count: 1, time: 1, sourcePlayerId: 'a' }, { enemyKey: plainKey, count: 2, time: 2, sourcePlayerId: 'b' }], flags: { layerGainsEnabled: false } });
const raw = (srcs, unspawned = []) => ({
reason: 'timeout', time: 60, killed: 0, total: 3, errors: 0, unspawned,
perPlayer: { h: perPlayer({ total: 3, perfect: !srcs.length, leaked: srcs.map((s) => ({ enemyKey: plainKey, sourcePlayerId: s, counted: true })) }) },
});
const ok = validateClientResult(spec, raw(['a', 'b', 'b']), { gd });
assert.ok(ok.ok, ok.reason);
assert.deepEqual(ok.result.perPlayer.h.leaked.map((l) => l.sourcePlayerId), ['a', 'b', 'b']);
assert.deepEqual(validateClientResult(spec, raw(['a', 'a']), { gd }), { ok: false, reason: 'leak source' }, 'b\'s survivor billed to a');
assert.deepEqual(validateClientResult(spec, raw(['a'], [{ enemyKey: plainKey, sourcePlayerId: 'a', time: 50 }]), { gd }), { ok: false, reason: 'unspawned source' });
assert.ok(validateClientResult(spec, raw(['b'], [{ enemyKey: plainKey, sourcePlayerId: 'a', time: 50 }, { enemyKey: plainKey, sourcePlayerId: 'b', time: 50 }]), { gd }).ok);
});
test('validation: 联防 survivors that content spawned (splits) are billed only to the leaker who sent the parent in, within the parent\'s offspring count', () => {
const parent = 'enemy_1195_sfyin'; // 磨砻: DeadSpawn 2 × 木制瑞印
const child = 'enemy_1196_msfyin';
assert.ok(specBounds({ spawns: [{ enemyKey: parent, count: 1 }] }, gd).derived.has(child), 'data: the parent spawns the child');
assert.equal(DATA.enemies[parent].talents.bb['DeadSpawn.cnt'], 2);
const spec = buildBattleSpec({ battleId: 'v5', fieldId: 'u', kind: 'unite', seed: 1, round: 5, timeLimit: 120,
players: [{ playerId: 'h', units: [], bonds: {} }],
spawns: [{ enemyKey: parent, count: 1, time: 1, sourcePlayerId: 'p_1', mods: {} }, { enemyKey: plainKey, count: 1, time: 2, sourcePlayerId: 'p_2' }], flags: { layerGainsEnabled: false } });
const raw = (srcs) => ({ reason: 'timeout', time: 120, killed: 0, total: 1 + srcs.length, errors: 0,
perPlayer: { h: perPlayer({ total: 1 + srcs.length, perfect: !srcs.length, leaked: srcs.map((src) => ({ enemyKey: child, sourcePlayerId: src, counted: true })) }) } });
const honest = validateClientResult(spec, raw(['p_1', 'p_1']), { gd });
assert.ok(honest.ok, honest.reason);
assert.deepEqual(honest.result.perPlayer.h.leaked.map((l) => l.sourcePlayerId), ['p_1', 'p_1']);
assert.deepEqual(validateClientResult(spec, raw(Array(10).fill('p_1')), { gd }), { ok: false, reason: 'leak source' }, '10 survivors of one 磨砻 (the LP cap) billed to p_1');
assert.deepEqual(validateClientResult(spec, raw(['p_1', 'p_1', 'p_1']), { gd }), { ok: false, reason: 'leak source' }, 'one parent leaves 2');
assert.deepEqual(validateClientResult(spec, raw(['p_2']), { gd }), { ok: false, reason: 'leak source' }, 'p_2 sent no 磨砻 in');
});
test('validation: a leaked split / summon keeps the mods of its parent (round multipliers, bounty id) for its 联防 re-entry', () => {
const parent = 'enemy_1203_sfhu';
const child = 'enemy_1204_msfhu';
assert.ok(specBounds({ spawns: [{ enemyKey: parent, count: 1 }] }, gd).derived.has(child), 'data: the parent spawns the child');
const mods = { hpMul: 1.728, atkMul: 1.331, speedMul: 1, slot: 'N', bountyId: 'bounty:21' };
const spec = buildBattleSpec({ battleId: 'v4', fieldId: 'n:p', kind: 'normal', seed: 1, round: 4, timeLimit: 60,
players: [{ playerId: 'p', units: [], bonds: {} }],
spawns: [{ enemyKey: plainKey, count: 2, time: 1, mods: { hpMul: 1.728, atkMul: 1.331, speedMul: 1, slot: 'N' } }, { enemyKey: parent, count: 1, time: 5, mods, tag: 'bounty', bounty: { coins: 2, ownerPlayerId: 'p' } }],
flags: { layerGainsEnabled: true } });
const raw = { reason: 'timeout', time: 60, killed: 1, total: 4, errors: 0,
perPlayer: { p: perPlayer({ killed: 1, total: 4, perfect: false, leaked: [{ enemyKey: child, mods: { ...mods }, lpr: 1, sourcePlayerId: 'p', tag: null, counted: true }] }) } };
const v = validateClientResult(spec, raw, { gd });
assert.ok(v.ok, v.reason);
assert.deepEqual(v.result.perPlayer.p.leaked[0].mods, mods, 'the parent\'s mods, not null');
assert.equal(v.result.perPlayer.p.leaked[0].tag, null, 'the child\'s own tag (as the sim reports it)');
// mods that no schedule entry has are still not taken from the client
const odd = validateClientResult(spec, { ...raw, perPlayer: { p: { ...raw.perPlayer.p, leaked: [{ enemyKey: child, mods: { hpMul: 0.01, slot: 'N' }, counted: true }] } } }, { gd });
assert.ok(odd.ok);
assert.equal(odd.result.perPlayer.p.leaked[0].mods, null);
// SP_VERIFY compares digests: leak mods are part of them
assert.notEqual(resultDigest(v.result).hash, resultDigest(odd.result).hash);
assert.equal(resultDigest(v.result).hash, resultDigest(validateClientResult(spec, raw, { gd }).result).hash);
});
test('client-side combat stays outcome-identical when a bounty enemy splits and its child leaks into the 联防 (seed 403)', () => {
const trace = (clientCombat) => {
const h = makeMatch({ mode: 'coop', difficulty: 'ABYSS', humans: 3, bots: 1, seed: 403, captureFrames: false, clientCombat });
h.autoHumans();
const m = h.m;
const out = [];
let last = '';
m.start();
h.run(() => {
const k = `${m.phase}:${m.round}`;
if (k !== last) { last = k; if (m.phase === PHASE.SETTLE) out.push(`${k} ${m.order.map((p) => `${p.lp}/${p.funds}+${p.pendingFunds}/${JSON.stringify(p.layers)}`).join(' ')}`); }
return h.ended != null || m.round >= 5;
}, { maxSteps: 5e6 });
assert.equal(m.errorCount, 0);
m.dispose();
return out;
};
const server = trace(false);
assert.ok(server.length >= 4);
assert.deepEqual(trace(true), server);
});
test('validation: every honest result of real client-combat matches (normal + 联防) still passes the tightened checks', () => {
let n = 0;
for (const seed of [9311, 9312]) {
const h = makeMatch({ mode: 'coop', difficulty: 'HARD', humans: 3, bots: 1, seed, captureFrames: false, clientCombat: true });
h.autoHumans();
const specs = [];
h.onSend.push((pid, msg) => { if (msg.t === 'b.start' && msg.authoritative && (msg.kind === 'normal' || msg.kind === 'unite')) specs.push(msg.spec); });
h.m.start();
h.run(() => h.ended != null || h.m.phase === PHASE.FINAL_ASSAULT, { maxSteps: 5e6 });
assert.equal(h.m.verifyStats.rejected, 0, 'no honest client result rejected in the match');
for (const spec of specs.slice(0, 40)) {
const res = createBattleFromSpec(spec, h.m.ds, { recordEvents: false, quiet: true }).runToEnd(4000);
const v = validateClientResult(spec, compactResult(res), { gd: h.m.gd });
assert.ok(v.ok, `${spec.fieldId} R${spec.round}: ${v.reason}`);
n++;
}
h.m.dispose();
}
assert.ok(n >= 20, `results checked (${n})`);
});
// ---- server CPU on a low-power host ----------------------------------------------------------------------------
test('server-run fields (bots) run in wall-clock slices, never in one blocking call, with the same outcome', () => {
const trace = (headlessSliceMs) => {
const h = makeMatch({ mode: 'coop', difficulty: 'HARD', humans: 1, bots: 3, seed: 9321, captureFrames: false, clientCombat: true, headlessSliceMs });
h.autoHumans();
const m = h.m;
let runs = 0;
const orig = HeadlessJob.prototype.run;
HeadlessJob.prototype.run = function run(...a) { runs++; return orig.apply(this, a); };
const pending = [];
const start = m._startCombatClient.bind(m);
m._startCombatClient = () => {
start();
pending.push(m.fields.filter((f) => f.mode === 'server').map((f) => (f.result ? 'done' : f.job ? 'job' : '?')).join(','));
};
const out = [];
let last = '';
try {
m.start();
h.run(() => {
const k = `${m.phase}:${m.round}`;
if (k !== last) { last = k; if (m.phase === PHASE.SETTLE) out.push(`${k} ${m.order.map((p) => `${p.lp}/${p.funds}+${p.pendingFunds}/${JSON.stringify(p.layers)}`).join(' ')}`); }
return h.ended != null || m.round >= 4;
}, { maxSteps: 5e6 });
} finally {
HeadlessJob.prototype.run = orig;
}
assert.equal(m.errorCount, 0, JSON.stringify(m.errors.slice(0, 2)));
m.dispose();
return { out, runs, pending };
};
const once = trace(undefined);
const sliced = trace(0.02);
assert.ok(once.pending.every((p) => p === 'done,done,done'), `virtual time default: at once (${once.pending})`);
assert.ok(sliced.pending.every((p) => p === 'job,job,job'), `sliced: nothing simulated inside the COMBAT start (${sliced.pending})`);
assert.ok(sliced.runs > once.runs * 3, `several slices per field (${sliced.runs} vs ${once.runs})`);
assert.ok(once.out.length >= 3);
assert.deepEqual(sliced.out, once.out, 'identical rounds');
});
test('HeadlessJob: sliced run = one-shot run (result digest, timeline); a budget stops it early', () => {
const h = makeMatch({ mode: 'coop', difficulty: 'HARD', humans: 1, bots: 1, seed: 9322, captureFrames: false, clientCombat: true });
h.autoHumans();
let spec = null;
h.onSend.push((pid, msg) => { if (!spec && msg.t === 'b.start' && msg.authoritative) spec = msg.spec; });
h.m.start();
h.run(() => spec != null, { maxSteps: 5e6 });
const whole = runHeadless(createBattleFromSpec(spec, h.m.ds, { recordEvents: false, quiet: true }), { players: ['p_0'] });
const job = new HeadlessJob(createBattleFromSpec(spec, h.m.ds, { recordEvents: false, quiet: true }), { players: ['p_0'] });
let t = 0;
const fakeNow = () => (t += 0.01); // every clock read advances 0.01 ms
let slices = 0;
while (!job.run(0.05, fakeNow)) slices++;
assert.ok(slices > 5, `stopped by the budget (${slices} slices)`);
assert.equal(resultDigest(job.output().result).hash, resultDigest(whole.result).hash);
assert.deepEqual(job.output().timeline, whole.timeline);
h.m.dispose();
});
test('HeadlessPacer: a takeover fast-forward is spread over the pacing intervals (≤ budget + pacing per interval), then paces normally', () => {
const sched = new VirtualScheduler({ instantCombat: false });
const errs = [];
const m = { sched, gameSpeed: 2, guard: (fn) => fn(), reportError: (l, e) => errs.push(`${l}: ${e && e.message}`) };
FakeBattle.reset();
const battle = new FakeBattle({ kind: 'boss', fieldId: 'b1', players: [], timeLimit: Infinity });
const pacer = new HeadlessPacer(m);
let done = 0;
const entry = pacer.add({ battle, onDone: () => { done++; } });
pacer.skipTo(entry, 100, { budgetTicks: 240 }); // 100 game s = 3000 ticks behind
assert.equal(battle.tickCount, 0, 'nothing stepped synchronously');
const perInterval = [];
let prev = 0;
for (let i = 0; i < 40; i++) {
sched.advance(34);
perInterval.push(battle.tickCount - prev);
prev = battle.tickCount;
}
assert.ok(Math.max(...perInterval) <= 240 + 8, `bounded per interval (${Math.max(...perInterval)})`);
const target = 3000 + Math.floor((40 * 34 / 1000) * 2 * 30);
assert.ok(Math.abs(battle.tickCount - target) <= 8, `caught up with the moving field clock (${battle.tickCount} vs ${target})`);
assert.equal(entry.skipTicks, null, 'back to normal pacing');
sched.advance(34);
assert.ok(battle.tickCount - prev <= 8 + 240 && battle.tickCount - prev >= 1);
const before = battle.tickCount;
sched.advance(1000);
assert.ok(Math.abs(battle.tickCount - before - 60) <= 8, `2× real time afterwards (${battle.tickCount - before} ticks / s)`);
assert.deepEqual(errs, []);
assert.equal(done, 0);
pacer.stop();
});
// ---- boss pool ---------------------------------------------------------------------------------------------------
test('Final Assault: b.pool carries exact numbers; a client that emptied the pool as it saw it clears the fight (float dust on the server)', () => {
const h = makeMatch({ mode: 'coop', difficulty: 'FUNNY', humans: 4, seed: 9331, fake: true, clientCombat: true, instant: false,
script: (b) => (b.kind === 'boss' ? { bossDps: 1234.567 } : { duration: 2 }) }).start();
const m = h.m;
const seen = [];
h.onBroadcast.push((msg) => { if (msg.t === 'b.pool') seen.push([msg.hp, m.bossPool.hp, msg.acked.b1, m.fields[0].bossAcked]); });
h.autoHumans();
h.drive(() => m.phase === PHASE.FINAL_ASSAULT);
h.sched.advance(2000);
const [b1] = m.fields;
assert.ok(seen.length > 3);
for (const [hp, serverHp, acked, serverAcked] of seen) {
assert.equal(hp, serverHp, 'hp is the server value (not rounded)');
assert.equal(acked, serverAcked, 'acked is the counted cumulative damage (not floored)');
}
assert.ok(seen.some(([, , acked]) => acked % 1 !== 0), 'fractional damage travels as is');
// the pool is (almost) empty: 0.004 left from summing both fields' reports
m.bossPool.hp = 0.004;
const res = { reason: 'cleared', time: 10, killed: 0, total: 0, errors: 0, perPlayer: {} };
for (const pid of b1.players) res.perPlayer[pid] = perPlayer({ total: 0 });
assert.deepEqual(m.handle(b1.authority, { t: 'b.result', battleId: b1.battleId, result: res }), { ok: true });
assert.equal(m.bossPool.hp, 0, 'the boss is down');
const end = h.runToEnd();
assert.equal(end.victory, true);
m.dispose();
});
// ---- frame size ------------------------------------------------------------------------------------------------
test('fitResult: an oversized b.result is trimmed under the frame budget without touching what settles a normal field', async () => {
const { fitResult, RESULT_FRAME_BUDGET } = await import('../../server/sim/spec.js');
const { isBattleResult } = await import('../../shared/protocol.js');
const mods = Object.fromEntries(Array.from({ length: 16 }, (_, i) => [`mod_${i}`, 'x'.repeat(40)]));
const big = {
reason: 'timeout', time: 60, killed: 10, total: 400, errors: 0,
perPlayer: { p: perPlayer({ killed: 10, total: 400, perfect: false, coins: 3, layerGains: { yanShip: 4 },
leaked: Array.from({ length: 150 }, () => ({ enemyKey: plainKey, mods, lpr: 1, sourcePlayerId: 'p', tag: null, counted: true, spawned: true })),
unitStats: Array.from({ length: 160 }, (_, i) => ({ uid: i + 1, defId: 'char_x', kind: 'op', dmg: 123456, kills: 3, heal: 0, taken: 999, attacks: 77 })) }) },
};
const size = (r) => JSON.stringify({ t: 'b.result', battleId: 'b', result: r, rid: 2147483647 }).length;
assert.ok(size(big) > 64 * 1024, `the sample is oversized (${size(big)})`);
const fit = fitResult(big, { battleId: 'b' });
assert.ok(size(fit) <= RESULT_FRAME_BUDGET, `trimmed (${size(fit)})`);
assert.ok(isBattleResult(fit));
const p = fit.perPlayer.p;
assert.equal(p.leaked.length, 150, 'every leak kept (LP)');
assert.deepEqual([p.coins, p.layerGains, p.perfect, p.killed, p.total], [3, { yanShip: 4 }, false, 10, 400]);
assert.equal(big.perPlayer.p.unitStats.length, 160, 'the input is not mutated');
const small = { ...big, perPlayer: { p: perPlayer() } };
assert.equal(fitResult(small), small, 'a normal-size result passes unchanged');
const longLeak = { ...big.perPlayer.p.leaked[0], sourcePlayerId: 'q'.repeat(60), tag: 'x'.repeat(16) };
const boss = fitResult({ ...big, perPlayer: { p: { ...big.perPlayer.p, leaked: Array.from({ length: 400 }, () => longLeak) } } }, { bossLike: true });
assert.ok(size(boss) <= RESULT_FRAME_BUDGET && boss.perPlayer.p.leaked.length === 0, 'boss leaks go (they cost LP through b.progress)');
});
// ---- browser loader (runs last: it injects the data into this process' sim like a page does) ------------------------
test('loadBrowserSim: a data file that cannot be fetched fails the loader (no battle on partial data); all files → a working sim', async () => {
const { loadBrowserSim, SIM_DATA_FILES } = await import('../../public/js/battle/runner.js');
const base = new URL('../../server/sim/', import.meta.url).href;
const served = (fail = new Set()) => async (url) => {
const name = String(url).replace(/^.*\//, '').replace(/\.json$/, '');
if (fail.has(name)) return { ok: false, status: 503, json: async () => null };
return { ok: true, status: 200, json: async () => JSON.parse(JSON.stringify(DATA[name])) };
};
let calls = 0;
const flaky = served(new Set(['bonds']));
await assert.rejects(loadBrowserSim({ base, fetchFn: (u, o) => { calls++; return flaky(u, o); } }), /simulation data unavailable: bonds/);
assert.equal(calls, SIM_DATA_FILES.length + 1, 'one retry for the failed file');
// a transient failure is retried once
let first = true;
const once = served();
const sim = await loadBrowserSim({ base, fetchFn: (u, o) => { if (first && /enemies/.test(u)) { first = false; return Promise.reject(new Error('net')); } return once(u, o); } });
assert.ok(sim.spec && sim.ds);
const spec = buildBattleSpec({ battleId: 'l1', fieldId: 'n:p', kind: 'normal', seed: 5, round: 1, stageId: Object.keys(DATA.stages)[0], timeLimit: 30,
players: [{ playerId: 'p', units: [], bonds: {} }], spawns: [{ enemyKey: plainKey, count: 1, time: 1 }], flags: { layerGainsEnabled: true } });
const b = sim.spec.createBattleFromSpec(spec, sim.ds, { quiet: true, recordEvents: false });
b.runToEnd(100);
assert.ok(b.finished);
});
// ---- 中途退出 around the 联防 plan ----------------------------------------------------------------------------------
test('a player who quits while its field is the last one running is never picked as a 联防 helper (the plan is made after the pause)', () => {
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 3, seed: 4243, fake: true, clientCombat: true, instant: false,
perPlayer: { p_1: { mute: true } },
script: (b) => (b.kind === 'normal' ? (b.players[0] === 'p_0' ? { duration: 2, leaks: { p_0: 3 } } : b.players[0] === 'p_1' ? { duration: 50 } : { duration: 3 }) : { duration: 5 }) }).start();
const m = h.m;
h.autoHumans();
h.drive(() => m.phase === PHASE.COMBAT && m.round >= 3);
h.sched.advance(4000);
assert.deepEqual(m.fields.filter((f) => !f.done).map((f) => f.fieldId), ['n:p_1'], 'p_1\'s field is the last one running');
const before = h.bc.length;
m.onLeave('p_1');
h.run(() => m.phase !== PHASE.COMBAT);
assert.equal(m.phase, PHASE.UNITE);
assert.deepEqual(m.unitePlan.helpers.map((p) => p.playerId), ['p_2'], 'only the perfect player still in helps');
assert.deepEqual(m.fields[0].spec.players.map((p) => [p.playerId, p.colOffset]), [['p_2', 0]], 'a lone helper on its own field (escaped_single)');
const tickers = h.bc.slice(before).filter((x) => x.t === 'm.ticker').map((x) => x.text);
assert.ok(!tickers.some((t) => t.startsWith('联防阶段') && t.includes(h.ps('p_1').name)), 'the departed player is not announced as a helper');
checkInvariants(m);
m.dispose();
});
test('the only leaker quits during the COMBAT_END pause: no 联防 for leaks nobody can be charged for', () => {
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 3, seed: 4244, fake: true, clientCombat: true, instant: false,
script: (b) => (b.kind === 'normal' ? (b.players[0] === 'p_0' ? { duration: 2, leaks: { p_0: 3 } } : { duration: 3 }) : { duration: 5 }) }).start();
const m = h.m;
h.autoHumans();
h.drive(() => m.phase === PHASE.COMBAT);
h.run(() => m.fields.every((f) => f.done));
assert.equal(m.phase, PHASE.COMBAT, 'in the pause');
m.onLeave('p_0');
h.run(() => m.phase !== PHASE.COMBAT);
assert.equal(m.phase, PHASE.SETTLE, 'straight to SETTLE');
assert.equal(m.unitePlan == null || !m.unitePlan.leakers.some((p) => p.playerId === 'p_0'), true);
checkInvariants(m);
m.dispose();
});
test('CHAR_DAMAGE tickers: a client result names only its own unit types (board, their summons, bond / band summons); summons count once per type', async () => {
const { give, legalTileFor } = await import('./harness.js');
const vigil = 'chess_char_3_19_a'; // 伺夜 → 狼群
const wolf = 'token_10028_vigil_wolf';
const foreign = Object.values(DATA.chess).filter((c) => c.visible && !c.isGolden && c.chessId !== vigil).map((c) => c.chessId).slice(0, 100);
const stat = (defId) => ({ uid: null, defId, kind: 'op', dmg: 1e12, kills: 0, heal: 0, taken: 0, attacks: 1 });
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 2, seed: 77, fake: true, clientCombat: true, instant: false,
perPlayer: { p_0: { tamper: (r) => { r.perPlayer.p_0.unitStats = [...foreign.map(stat), ...Array.from({ length: 50 }, () => stat(wolf))]; return r; } } },
script: () => ({ duration: 2 }) }).start();
const m = h.m;
h.toPrep(1);
const ps = h.ps('p_0');
give(m, ps, vigil, 'board', legalTileFor(m, ps, vigil));
const before = h.bc.length;
h.drive(() => m.phase === PHASE.SETTLE);
const t = h.bc.slice(before).filter((x) => x.t === 'm.ticker' && x.type === 'CHAR_DAMAGE');
assert.equal(t.length, 1, t.map((x) => x.text).join(' | '));
assert.ok(t[0].text.includes(DATA.tokens[wolf].name), t[0].text);
// the validator: foreign operators dropped, the own summon and the ownerless bond summon (炎佑) kept
const spec = buildBattleSpec({ battleId: 'v6', fieldId: 'n:p', kind: 'normal', seed: 1, round: 3, timeLimit: 60,
players: [{ playerId: 'p', units: [{ uid: 7, kind: 'chess', chessId: vigil, row: 10, col: 3, items: [] }], bonds: {} }],
spawns: [{ enemyKey: plainKey, count: 1, time: 1 }], flags: { layerGainsEnabled: true } });
const raw = { reason: 'cleared', time: 20, killed: 1, total: 1, errors: 0,
perPlayer: { p: perPlayer({ killed: 1, total: 1, unitStats: [{ ...stat(vigil), uid: 7 }, stat(foreign[0]), stat(wolf), stat('enemy_9012_acloon')] }) } };
const v = validateClientResult(spec, raw, { gd });
assert.ok(v.ok, v.reason);
assert.deepEqual(v.result.perPlayer.p.unitStats.map((u) => u.defId), [vigil, wolf, 'enemy_9012_acloon']);
m.dispose();
});
+534
View File
@@ -0,0 +1,534 @@
// Client-side combat (DESIGN §14): BattleSpec round trip and determinism, authority per field, b.progress / b.result,
// result validation (implausible results → server re-simulation), takeovers (disconnect, deadline, leave), the shared
// boss pool across two client-run fields (b.pool, team LP, overtime, b.end), 联防 built from the helpers' reported end
// state, observing rules (research 09 §3.1 / §6.3), SP_VERIFY, reconnect. SimClients (test/match/simClient.js) play
// the browsers in virtual time.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { PHASE } from '../../shared/constants.js';
import { validateC2S, isBattleResult } from '../../shared/protocol.js';
import { buildBattleSpec, createBattleFromSpec, resultDigest, compactResult, LocalBossPool, jsonClone } from '../../server/sim/spec.js';
import { DataSource } from '../../server/sim/simdata.js';
import { validateClientResult, specBounds } from '../../server/match/fields.js';
import { CreditPool, SharedBossPool } from '../../server/match/finalAssault.js';
import { FakeBattle } from './fakeBattle.js';
import { DATA, makeMatch, checkInvariants, give, chessOfTier } from './harness.js';
/** A fresh DataSource over the raw data, like a browser that fetched /data/*.json (no research fallback). */
const browserDs = () => new DataSource(DATA, null);
function fields(h) { return h.m.fields; }
test('spec: JSON-safe round trip; a browser-built battle and the server battle give identical results (real sim, many rounds)', () => {
const h = makeMatch({ mode: 'coop', difficulty: 'HARD', humans: 2, bots: 2, seed: 9101, captureFrames: false, clientCombat: true });
h.autoHumans();
const specs = [];
h.onSend.push((pid, msg) => { if (msg.t === 'b.start' && msg.authoritative) specs.push(msg.spec); });
h.m.start();
h.run(() => h.ended != null || (h.m.round >= 6 && h.m.phase === PHASE.PREP), { maxSteps: 5e6 });
assert.ok(specs.length >= 8, `authoritative specs seen (${specs.length})`);
const kinds = new Set(specs.map((s) => s.kind));
assert.ok(kinds.has('normal'), 'normal fields');
for (const spec of specs) {
assert.equal(JSON.stringify(jsonClone(spec)), JSON.stringify(spec), 'a spec survives JSON unchanged');
assert.equal(typeof spec.stageId, 'string');
assert.ok(!('stage' in spec) && !('sharedBoss' in spec) && !('data' in spec), 'no object references travel');
const a = createBattleFromSpec(spec, browserDs(), { recordEvents: false, quiet: true }).runToEnd(4000);
const b = createBattleFromSpec(JSON.parse(JSON.stringify(spec)), h.m.ds, { recordEvents: true, quiet: true }).runToEnd(4000);
assert.equal(resultDigest(a).hash, resultDigest(b).hash, `${spec.fieldId} R${spec.round}: same result on both sides`);
// what the client uploads passes the protocol validator and the server's semantic validation
const up = compactResult(a);
assert.ok(isBattleResult(up), 'compact result is a valid b.result payload');
assert.equal(validateC2S({ t: 'b.result', battleId: spec.battleId, result: up }), null);
const v = validateClientResult(spec, up, { gd: h.m.gd });
assert.ok(v.ok, `${spec.fieldId} R${spec.round}: accepted (${v.reason})`);
}
h.m.dispose();
});
test('client-side combat is outcome-identical to server-run combat (same seed: LP, funds, layers every round)', () => {
const trace = (clientCombat) => {
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 2, bots: 1, seed: 9102, captureFrames: false, clientCombat });
h.autoHumans();
const m = h.m;
const out = [];
let last = '';
m.start();
h.run(() => {
const k = `${m.phase}:${m.round}`;
if (k !== last) { last = k; if (m.phase === PHASE.SETTLE) out.push(`${k} ${m.order.map((p) => `${p.lp}/${p.funds}+${p.pendingFunds}/${JSON.stringify(p.layers)}`).join(' ')}`); }
return h.ended != null || m.round >= 8;
}, { maxSteps: 5e6 });
assert.equal(m.errorCount, 0, JSON.stringify(m.errors.slice(0, 2)));
m.dispose();
return out;
};
const server = trace(false);
const client = trace(true);
assert.ok(server.length >= 6);
assert.deepEqual(client, server);
});
test('COMBAT: humans get their own spec (authoritative), bots are simulated by the server; no b.snap / b.ev; progress in m.public', () => {
const h = makeMatch({ mode: 'coop', humans: 2, bots: 1, seed: 9103, fake: true, clientCombat: true, clients: false, script: () => ({ duration: 6 }) }).start();
const m = h.m;
h.toPrep(1);
m.handle('p_0', { t: 'g.ready', ready: true });
m.handle('p_1', { t: 'g.ready', ready: true });
h.run(() => m.phase === PHASE.COMBAT);
const starts = h.sent.filter(([, x]) => x.t === 'b.start');
assert.deepEqual(starts.map(([pid, x]) => [pid, x.fieldId, x.authoritative]).sort(), [['p_0', 'n:p_0', true], ['p_1', 'n:p_1', true]]);
const st = starts[0][1];
assert.equal(st.speed, m.gameSpeed);
assert.equal(st.elapsed, 0);
assert.equal(typeof st.startAt, 'number');
assert.equal(st.spec.kind, 'normal');
assert.equal(st.spec.battleId, st.battleId);
const bot = fields(h).find((f) => f.fieldId === 'n:ai_0');
assert.equal(bot.mode, 'server', 'the bot field runs on the server');
assert.ok(bot.result, 'its result is computed at once');
assert.equal(bot.live, true, '… and released at the battle\'s natural end');
const human = fields(h).find((f) => f.fieldId === 'n:p_0');
assert.equal(human.mode, 'client');
assert.equal(human.authority, 'p_0');
// progress reports drive the teammates' waiting UI
assert.deepEqual(m.handle('p_0', { t: 'b.progress', battleId: human.battleId, gt: 4, killed: 3, total: 9, leaks: 0 }), { ok: true });
m.flush(true);
const pub = m.publicView();
assert.deepEqual(pub.fields.find((f) => f.fieldId === 'n:p_0').progress, { killed: 3, total: 9, done: false });
// a stale / foreign report is ignored (never an error toast)
assert.deepEqual(m.handle('p_1', { t: 'b.progress', battleId: human.battleId, gt: 9, killed: 9, total: 9 }), { ok: true });
assert.equal(human.progress.killed, 3);
assert.deepEqual(m.handle('p_0', { t: 'b.progress', battleId: 'nope', gt: 1, killed: 0, total: 0 }), { ok: true });
h.sched.advance(5000);
assert.equal(h.sent.filter(([, x]) => x.t === 'b.snap' || x.t === 'b.ev').length, 0, 'no combat streaming');
// both humans report (FakeBattle stands in for the browser sim)
for (const pid of ['p_0', 'p_1']) {
const f = fields(h).find((x) => x.fieldId === `n:${pid}`);
const b = createBattleFromSpec(f.spec, m.ds, { BattleClass: FakeBattle });
b.runToEnd?.(); while (!b.finished) b.step();
assert.deepEqual(m.handle(pid, { t: 'b.result', battleId: f.battleId, result: compactResult(b.result()) }), { ok: true });
assert.equal(f.resultSource, 'client');
}
h.run(() => m.phase !== PHASE.COMBAT);
assert.ok([PHASE.SETTLE, PHASE.UNITE].includes(m.phase), m.phase);
checkInvariants(m);
m.dispose();
});
test('validation: implausible client results are rejected and replaced by the server\'s simulation', () => {
const cases = [
['coins beyond the bounties', (r) => { for (const p of Object.values(r.perPlayer)) p.coins = 999; return r; }],
['a leak of an enemy that never spawned', (r) => { const p = Object.values(r.perPlayer)[0]; p.leaked.push({ enemyKey: 'enemy_1502_crowns', mods: null, lpr: 1, sourcePlayerId: null, tag: null, counted: true }); p.perfect = false; return r; }],
['perfect despite counted leaks', (r, spec) => { const p = Object.values(r.perPlayer)[0]; p.leaked = [{ enemyKey: spec.spawns[0].enemyKey, counted: true }]; p.perfect = true; return r; }],
['layer gains above the bound', (r) => { for (const p of Object.values(r.perPlayer)) p.layerGains = { yanShip: 500 }; return r; }],
['a player who is not in the battle', (r) => { r.perPlayer.ghost = { ...Object.values(r.perPlayer)[0] }; return r; }],
];
for (const [name, tamper] of cases) {
const h = makeMatch({ mode: 'coop', humans: 2, seed: 9104, captureFrames: false, clientCombat: true, perPlayer: { p_0: { tamper } } });
h.autoHumans();
const m = h.m;
m.start();
h.run(() => m.phase === PHASE.SETTLE || h.ended != null);
const f = m.lastResults.get('p_0');
assert.ok(f, name);
assert.ok(m.verifyStats.rejected >= 1, `${name}: rejected`);
assert.ok(m.verifyStats.takeovers >= 1, `${name}: server re-simulation`);
assert.ok(Object.values(f.layerGains || {}).every((n) => n <= 60 + 4 * m.round), `${name}: no forged layers`);
assert.ok(f.coins <= 50, `${name}: no forged coins`);
const takeover = h.clients.get('p_0').ends.find((x) => x.reason === 'takeover');
assert.ok(takeover, `${name}: the client was told the server took over`);
m.dispose();
}
// unit checks of the validator
const spec = buildBattleSpec({ battleId: 'x', fieldId: 'n:p', kind: 'normal', round: 3, stageId: 's', timeLimit: 120, players: [{ playerId: 'p', units: [{ uid: 5, kind: 'chess', chessId: 'chess_char_1_a', row: 9, col: 3 }] }], spawns: [{ enemyKey: 'enemy_1007_slime', count: 3, time: 0, bounty: { coins: 2, ownerPlayerId: 'p' } }], flags: { layerGainsEnabled: true } });
const ok = { reason: 'timeout', time: 120, perPlayer: { p: { killed: 2, total: 3, leaked: [{ enemyKey: 'enemy_1007_slime', counted: true }], perfect: false, layerGains: { yanShip: 3 }, coins: 4, unitsEnd: [{ uid: 5, hpPct: 0.5, sp: 2, alive: true }] } } };
assert.equal(validateClientResult(spec, ok, {}).ok, true);
const bad = (edit) => { const r = JSON.parse(JSON.stringify(ok)); edit(r); return validateClientResult(spec, r, {}); };
assert.equal(bad((r) => { r.perPlayer.p.coins = 7; }).reason, 'coins');
assert.equal(bad((r) => { r.perPlayer.p.leaked.push(...Array(3).fill({ enemyKey: 'enemy_1007_slime', counted: true })); }).reason, 'leak multiset');
assert.equal(bad((r) => { r.perPlayer.p.perfect = true; }).reason, 'perfect');
assert.equal(bad((r) => { r.perPlayer.p.layerGains.yanShip = 73; }).reason, 'layer bound');
assert.equal(bad((r) => { r.time = 500; }).reason, 'time');
assert.equal(bad((r) => { r.perPlayer.q = r.perPlayer.p; }).reason, 'players');
assert.equal(bad((r) => { r.perPlayer.p.unitsEnd[0].hpPct = 2; }).reason, 'unit state');
const noLayers = buildBattleSpec({ ...spec, kind: 'unite', flags: { layerGainsEnabled: false } });
assert.equal(validateClientResult(noLayers, ok, {}).reason, 'layers disabled');
// a structurally broken payload never reaches the match (protocol validator)
assert.notEqual(validateC2S({ t: 'b.result', battleId: 'x', result: { ...ok, perPlayer: {} } }), null);
assert.notEqual(validateC2S({ t: 'b.result', battleId: 'x', result: { ...ok, reason: 'won' } }), null);
assert.notEqual(validateC2S({ t: 'b.result', battleId: 'x', result: { ...ok, perPlayer: { p: { ...ok.perPlayer.p, leaked: Array(401).fill({ enemyKey: 'enemy_a' }) } } } }), null);
assert.equal(specBounds(spec).bountyCoins, 6);
});
test('takeover: an authority that disconnects mid-combat loses its field; the server re-simulates it; reconnect gets a display replica', () => {
const h = makeMatch({ mode: 'coop', humans: 2, seed: 9105, captureFrames: false, clientCombat: true, perPlayer: { p_1: { mute: true } } });
h.autoHumans();
const m = h.m;
m.start();
h.run(() => m.phase === PHASE.COMBAT);
const f = fields(h).find((x) => x.fieldId === 'n:p_1');
assert.equal(f.mode, 'client');
h.sched.advance(3000);
m.onDisconnect('p_1');
assert.equal(f.mode, 'server', 'server takeover on disconnect');
assert.ok(f.result && f.resultSource === 'server');
assert.equal(m.verifyStats.takeovers, 1);
m.onReconnect('p_1');
const again = h.lastTo('p_1', 'b.start');
assert.equal(again.battleId, f.battleId);
assert.equal(again.authoritative, false, 'the returning player only watches (the server holds the result)');
assert.ok(again.elapsed > 0, `fast-forward target (${again.elapsed})`);
h.run(() => m.phase === PHASE.SETTLE || h.ended != null);
assert.ok(m.lastResults.has('p_1'));
// the server's result equals what an honest client would have reported (deterministic spec)
const honest = createBattleFromSpec(f.spec, browserDs(), { quiet: true }).runToEnd(4000);
assert.equal(resultDigest(validateClientResult(f.spec, compactResult(honest), { gd: m.gd }).result).hash, resultDigest(validateClientResult(f.spec, compactResult(f.result), { gd: m.gd }).result).hash);
checkInvariants(m);
m.dispose();
});
test('takeover: a connected client that never reports is taken over at time limit + 15 s grace; departed players are settled at once', () => {
const h = makeMatch({ mode: 'coop', humans: 3, seed: 9106, captureFrames: false, clientCombat: true, instant: false, perPlayer: { p_2: { stall: true } } });
h.autoHumans();
const m = h.m;
m.start();
h.run(() => m.phase === PHASE.COMBAT);
const f = fields(h).find((x) => x.fieldId === 'n:p_2');
const t0 = m.sched.now();
const limitMs = (f.spec.timeLimit / m.gameSpeed) * 1000;
h.run(() => f.mode === 'server' || m.phase !== PHASE.COMBAT, { maxTime: limitMs + 20000 });
assert.equal(f.mode, 'server');
const at = m.sched.now() - t0;
assert.ok(at >= limitMs + 15000 - 50 && at <= limitMs + 15000 + 400, `taken over at ${at} ms (limit ${limitMs} + 15000)`);
h.sched.advance(1);
assert.ok(h.clients.get('p_2').ends.some((x) => x.reason === 'takeover'), 'the stalled client is told');
h.run(() => m.phase !== PHASE.COMBAT);
// a player who leaves mid-combat: its field is closed at once (it is eliminated anyway)
h.run(() => m.phase === PHASE.COMBAT && m.round === 2, { maxSteps: 3e6 });
if (m.phase === PHASE.COMBAT) {
const g = fields(h).find((x) => x.fieldId === 'n:p_1');
m.onLeave('p_1');
assert.equal(g.done, true);
assert.equal(g.live, false);
}
m.dispose();
});
test('observing: no watching while the own battle runs; after it ends a teammate\'s spec + clock; eliminated players watch anything', () => {
const h = makeMatch({ mode: 'coop', humans: 3, seed: 9107, fake: true, clientCombat: true, instant: false, pace: 'paced',
script: (b) => ({ duration: b.players.includes('p_0') ? 2 : 30 }) }).start();
const m = h.m;
h.toPrep(1);
for (const pid of ['p_0', 'p_1', 'p_2']) m.handle(pid, { t: 'g.ready', ready: true });
h.run(() => m.phase === PHASE.COMBAT);
// p_1 still fights: it may not look elsewhere
assert.deepEqual(m.handle('p_1', { t: 'g.watch', fieldId: 'n:p_2' }), { error: 'WRONG_PHASE', detail: 'own battle running' });
h.run(() => fields(h).find((f) => f.fieldId === 'n:p_0').done);
const target = fields(h).find((f) => f.fieldId === 'n:p_2');
assert.equal(target.live, true);
h.sched.advance(1000);
assert.deepEqual(m.handle('p_0', { t: 'g.watch', fieldId: 'n:p_2' }), { ok: true });
const st = h.lastTo('p_0', 'b.start');
assert.equal(st.fieldId, 'n:p_2');
assert.equal(st.watch, true);
assert.equal(st.authoritative, false);
assert.ok(st.elapsed > 0, 'the replica fast-forwards to the field clock');
assert.deepEqual(st.spec, target.spec);
assert.equal(m.watchers.get('p_0'), 'n:p_2');
// back to the own (finished) field
assert.deepEqual(m.handle('p_0', { t: 'g.watch', fieldId: 'n:p_0' }), { ok: true });
assert.equal(h.lastTo('p_0', 'b.start').done, true);
assert.equal(m.handle('p_0', { t: 'g.watch', fieldId: 'b9' }).error, 'BAD_TARGET');
// an eliminated player may watch anything
h.ps('p_0').alive = false;
assert.deepEqual(m.handle('p_0', { t: 'g.watch', fieldId: 'n:p_1' }), { ok: true });
h.ps('p_0').alive = true;
m.dispose();
});
test('Final Assault: two client-run pair fields share the server pool (b.progress bossDmg), b.pool ≤ 4 Hz, victory, per-player damage', () => {
const h = makeMatch({ mode: 'coop', difficulty: 'FUNNY', humans: 4, seed: 9108, fake: true, clientCombat: true, instant: false,
script: (b) => (b.kind === 'boss' ? { bossDps: 20000 } : { duration: 2 }) }).start();
const m = h.m;
const poolAt = [];
h.onBroadcast.push((msg) => { if (msg.t === 'b.pool') poolAt.push(h.sched.now()); });
h.autoHumans();
h.drive(() => m.phase === PHASE.FINAL_ASSAULT);
const [b1, b2] = fields(h);
assert.deepEqual([b1.fieldId, b2.fieldId], ['b1', 'b2']);
assert.deepEqual([b1.mode, b1.authority, b2.mode, b2.authority], ['client', 'p_0', 'client', 'p_2'], 'lowest seat of each pair');
const startMsgs = h.sent.filter(([, x]) => x.t === 'b.start' && x.kind === 'boss');
assert.deepEqual(startMsgs.map(([pid, x]) => `${pid}:${x.fieldId}:${x.authoritative ? 'A' : 'r'}`).sort(), ['p_0:b1:A', 'p_1:b1:r', 'p_2:b2:A', 'p_3:b2:r']);
assert.equal(startMsgs[0][1].spec.boss.poolMax, m.bossPool.maxHp);
const max = m.bossPool.maxHp;
h.sched.advance(3000);
const hp = m.bossPool.hp;
assert.ok(hp < max, 'client damage reaches the server pool');
assert.ok(b1.bossAcked > 0 && b2.bossAcked > 0, 'both fields contributed');
assert.ok(Math.abs((max - hp) - (b1.bossAcked + b2.bossAcked)) < 1e-6, 'pool = max − Σ reported field damage');
const pools = h.bc.filter((x) => x.t === 'b.pool');
assert.ok(pools.length > 3);
for (let i = 1; i < poolAt.length; i++) assert.ok(poolAt[i] - poolAt[i - 1] >= 250, `b.pool ≤ 4 Hz (${poolAt[i] - poolAt[i - 1]} ms apart)`);
assert.deepEqual(Object.keys(pools[pools.length - 1].acked).sort(), ['b1', 'b2']);
const end = h.runToEnd();
assert.equal(end.victory, true);
const dmg = [...m.players.values()].map((p) => p.stats.bossDamage);
assert.ok(dmg.every((d) => d > 0), `per-player boss damage attributed (${dmg})`);
assert.ok(Math.abs(dmg.reduce((a, b) => a + b, 0) - max) < 1, 'Σ player damage = pool');
m.dispose();
});
test('Final Assault: leaks and leader LP effects from b.progress drain the team LP; overtime after 150 real s at 1 LP/real s; team LP 0 → b.end → defeat', () => {
const h = makeMatch({ mode: 'coop', difficulty: 'FUNNY', humans: 2, seed: 9109, fake: true, clientCombat: true, instant: false,
script: (b) => (b.kind === 'boss' ? { bossDps: 0, leakEvents: [{ at: 2, lpr: 3 }], lpLossEvents: [{ at: 3, amount: 2 }] } : { duration: 2 }) }).start();
const m = h.m;
h.autoHumans();
h.drive(() => m.phase === PHASE.FINAL_ASSAULT);
const lp0 = m.teamLp;
h.sched.advance(4000);
assert.equal(m.teamLp, lp0 - 5, 'leak lpr 3 + leader effect 2');
const lp1 = m.teamLp;
// overtime (bossTurnHpReduceTime): 150 REAL s like the level's 120 s countdown, then 1 LP per real second
const t0 = m._bossStartAt;
const pub = () => h.bc.filter((x) => x.t === 'm.public').pop();
m.flush(true);
assert.equal(pub().deadline, t0 + 120000, 'the HUD counts the boss level\'s 120 s down');
assert.equal(pub().overtimeAt, t0 + 150000, 'and knows when the drain starts');
h.sched.advance(t0 + 150000 - m.sched.now() + 500);
assert.equal(m.teamLp, lp1, 'no drain before 150 real s (the countdown ran out at 120 s, the battle went on)');
h.sched.advance(5000);
assert.ok(m.teamLp <= lp1 - 4 && m.teamLp >= lp1 - 6, `overtime drain ≈ 5 LP over 5 real s (${lp1} → ${m.teamLp})`);
const end = h.runToEnd();
assert.equal(end.victory, false);
assert.equal(m.teamLp, 0);
assert.ok(h.clients.get('p_1').ends.some((x) => x.reason === 'forced') || h.clients.get('p_0').ends.some((x) => x.reason === 'forced'), 'b.end reached the field');
m.dispose();
});
test('Final Assault: the authority leaves → its partner\'s replica takes over; nobody left → the server paces the field (credits only new damage)', () => {
const h = makeMatch({ mode: 'coop', difficulty: 'FUNNY', humans: 2, seed: 9110, fake: true, clientCombat: true, instant: false,
script: (b) => (b.kind === 'boss' ? { bossDps: 2000 } : { duration: 2 }) }).start();
const m = h.m;
h.autoHumans();
h.drive(() => m.phase === PHASE.FINAL_ASSAULT);
const f = fields(h)[0];
h.sched.advance(2000);
const acked = f.bossAcked;
assert.ok(acked > 0);
m.onDisconnect('p_0');
assert.equal(f.authority, 'p_1', 'the partner takes over');
assert.equal(h.lastTo('p_1', 'b.start').authoritative, true);
h.sched.advance(2000);
assert.ok(f.bossAcked > acked, 'the new authority keeps reporting cumulative damage');
const before = m.bossPool.hp;
m.onDisconnect('p_1');
assert.equal(f.mode, 'server');
const afterHp = m.bossPool.hp;
assert.ok(Math.abs(afterHp - before) < 1e-6, 'the re-simulation does not double count reported damage');
h.sched.advance(3000);
assert.ok(m.bossPool.hp < afterHp, 'the server run keeps damaging the pool');
const end = h.runToEnd();
assert.equal(end.victory, true);
m.dispose();
});
test('联防 under client-side combat: helpers\' reported end state (HP%, SP) carries into the spec; everyone gets the spec; authority = lowest-seat helper', () => {
const h = makeMatch({ mode: 'coop', humans: 3, seed: 9111, fake: true, clientCombat: true,
script: (b) => (b.kind === 'normal' ? { leaks: { p_0: 4 } } : {}) }).start();
const m = h.m;
h.toPrep(1);
const ps1 = h.ps('p_1');
const id = chessOfTier(1, (c) => c.position === 'MELEE').find((x) => m.pool.has(x));
const unit = give(m, ps1, id, 'board', [...ps1.deployMap()].find(([, v]) => v === 'melee')[0].split(',').map(Number));
h.drive(() => m.phase === PHASE.UNITE);
const f = fields(h)[0];
assert.equal(f.fieldId, 'u');
assert.deepEqual(f.players, ['p_1', 'p_2']);
assert.equal(f.authority, 'p_1');
const carried = f.spec.players[0].units.find((u) => u.uid === unit.uid);
const reported = m.lastResults.get('p_1').unitsEnd.find((u) => u.uid === unit.uid);
assert.deepEqual(carried.carryState, { hpPct: reported.hpPct, sp: reported.sp, skillActive: reported.skillActive });
const got = h.sent.filter(([, x]) => x.t === 'b.start' && x.fieldId === 'u').map(([pid, x]) => `${pid}:${x.authoritative ? 'A' : x.watch ? 'w' : 'r'}`).sort();
assert.deepEqual(got, ['p_0:w', 'p_1:A', 'p_2:r']);
h.drive(() => m.phase === PHASE.SETTLE);
assert.ok(f.result && f.resultSource === 'client');
m.dispose();
});
test('SP_VERIFY=all: an accepted but wrong client result is replaced by the server simulation', () => {
// an empty board leaks everything; the client claims perfect rounds (valid shape, within every bound)
const tamper = (r) => { for (const p of Object.values(r.perPlayer)) { p.leaked = []; p.perfect = true; p.killed = p.total; } return r; };
const run = (verify) => {
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 1, bots: 1, seed: 9112, captureFrames: false, clientCombat: true, verify, perPlayer: { p_0: { tamper } } });
const m = h.m;
m.start();
h.drive(() => h.ended != null || ((m.phase === PHASE.UNITE || m.phase === PHASE.SETTLE) && m.round === 1), { band: 'band_bldsk' });
const s = { ...m.verifyStats, leaks: m.lastResults.get('p_0').leaked.length };
m.dispose();
return s;
};
const off = run('off');
const all = run('all');
assert.equal(off.checked, 0);
assert.equal(all.checked, 1, 'the result was re-simulated before it was accepted');
assert.equal(all.mismatches, 1, 'the forged perfect round is caught');
assert.equal(off.leaks, 0, 'without verification the forged result stands');
assert.ok(all.leaks > 0, `the server's leaks count (${all.leaks})`);
});
test('LocalBossPool (client) and CreditPool (server takeover) keep the shared pool consistent', () => {
const lp = new LocalBossPool(1000, 800);
assert.equal(lp.hp, 800);
assert.equal(lp.damage('a', 300), 300);
assert.equal(lp.hp, 500);
lp.sync(450, 300); // server counted our 300 and another field's 50
assert.equal(lp.hp, 450);
assert.equal(lp.damage('a', 1000), 450);
assert.equal(lp.hp, 0);
assert.equal(lp.cum, 750);
const pool = new SharedBossPool(1000);
pool.damage('a', 300);
const cp = new CreditPool(pool, { acked: 300 });
assert.equal(cp.damage('a', 200), 0, 'replayed damage is not counted twice');
assert.equal(pool.hp, 700);
assert.equal(cp.damage('a', 150), 50);
assert.equal(pool.hp, 650);
assert.equal(cp.hp, 650);
});
test('full co-op match with client-side combat and the real sim reaches RESULT with zero errors (humans on AI 托管)', () => {
for (const [humans, bots, seed, pace] of [[2, 1, 9113, 'instant'], [4, 0, 9114, 'paced'], [1, 2, 9115, 'instant']]) {
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans, bots, seed, captureFrames: false, checkFrames: true, clientCombat: true, pace });
h.autoHumans();
const m = h.m;
let last = '';
m.start();
h.run(() => {
const k = `${m.phase}:${m.round}`;
if (k !== last) { last = k; checkInvariants(m); if (m.phase === PHASE.PREP && m.round === 1) for (const ps of m.players.values()) ps.lp = 300; }
return h.ended != null;
}, { maxSteps: 6e6 });
assert.ok(h.ended, `${humans}+${bots}: ended (${m.phase} R${m.round})`);
assert.equal(m.errorCount, 0, JSON.stringify(m.errors.slice(0, 2)));
assert.deepEqual(h.logs.error.filter((l) => !l.startsWith('[sim]')), []);
assert.deepEqual(h.badFrames, []);
assert.equal(m.verifyStats.rejected, 0, 'honest clients are never rejected');
assert.equal(m.verifyStats.takeovers, 0);
assert.equal(h.sent.filter(([, x]) => x.t === 'b.snap' || x.t === 'b.ev').length, 0);
for (const c of h.clients.values()) assert.ok(c.log.some((x) => x.t === 'b.result'), 'every human reported results');
m.dispose();
}
});
test('fuzz: random / hostile b.progress and b.result frames never throw, never corrupt, never end a field early', () => {
const h = makeMatch({ mode: 'coop', humans: 2, bots: 1, seed: 9116, captureFrames: false, clientCombat: true, clients: false });
h.autoHumans();
const m = h.m;
m.start();
h.run(() => m.phase === PHASE.COMBAT);
let seed = 12345;
const rnd = () => { seed = (seed * 1103515245 + 12345) >>> 0; return seed / 2 ** 32; };
const pick = (a) => a[Math.floor(rnd() * a.length)];
const f0 = fields(h).find((f) => f.fieldId === 'n:p_0');
const junk = () => pick([undefined, null, -1, 1e20, NaN, 'x', {}, [], 3, 0.5, true]);
let accepted = 0;
for (let i = 0; i < 3000; i++) {
const battleId = pick([f0.battleId, 'nope', junk()]);
const msg = rnd() < 0.5
? { t: 'b.progress', battleId, gt: pick([1, 5, junk()]), killed: pick([0, 3, junk()]), total: pick([10, junk()]), leaks: pick([0, 2, junk()]), bossDmg: pick([undefined, 5, junk()]), by: pick([undefined, { p_0: 3 }, junk()]) }
: { t: 'b.result', battleId, result: pick([junk(), { reason: 'cleared', time: 3, perPlayer: { p_0: { killed: 1, total: pick([1, 0, junk()]), leaked: pick([[], [{ enemyKey: 'enemy_x' }], junk()]), perfect: pick([true, false, junk()]), layerGains: pick([{}, { yanShip: 999 }, junk()]), unitsEnd: [] } } }]) };
for (const k of Object.keys(msg)) if (msg[k] === undefined) delete msg[k];
if (validateC2S(msg) != null) continue; // the platform drops what the protocol validator refuses
accepted++;
assert.doesNotThrow(() => m.handle(pick(['p_0', 'p_1']), msg));
}
assert.ok(accepted > 100, `fuzz frames reached the match (${accepted})`);
assert.equal(m.errorCount, 0, JSON.stringify(m.errors.slice(0, 2)));
assert.ok(f0.progress.killed <= f0.progress.total);
// a structurally valid but implausible result for p_0's field was replaced by the server's run, never trusted
if (f0.done) assert.equal(f0.resultSource, 'server');
checkInvariants(m);
m.dispose();
});
// ---- boss-field report plausibility (a modified authority client) -------------------------------------------------
function faWithForger(seed) {
// p_0 is the authority of b1 but "modified": its SimClient is muted and the test sends its frames by hand
const h = makeMatch({ mode: 'coop', difficulty: 'HARD', humans: 2, seed, fake: true, clientCombat: true, instant: false,
perPlayer: { p_0: { mute: true } }, script: (b) => (b.kind === 'boss' ? { bossDps: 0 } : { duration: 2 }) }).start();
h.autoHumans();
h.drive(() => h.m.phase === PHASE.FINAL_ASSAULT);
const f = fields(h)[0];
assert.equal(f.authority, 'p_0');
return { h, m: h.m, f };
}
test('Final Assault: a forged b.progress (bossDmg 1e13, leaks 1e6) is credited only up to the field clock\'s budget — one frame never empties the pool or the team LP', () => {
const { h, m, f } = faWithForger(9120);
const max = m.bossPool.maxHp;
const lp0 = m.teamLp;
h.sched.advance(500); // 1 game s on the field clock
const gt = m._fieldElapsed(f);
assert.deepEqual(m.handle('p_0', { t: 'b.progress', battleId: f.battleId, gt: 1, killed: 0, total: 0, bossDmg: 1e13, by: { p_0: 1e13 }, leaks: 1e6 }), { ok: true });
assert.ok(max - m.bossPool.hp <= (max * gt) / 5 + 1, `≤ the whole pool per 5 game s (${max - m.bossPool.hp} of ${max})`);
assert.ok(m.bossPool.hp > 0.7 * max);
assert.ok(lp0 - m.teamLp <= 10 + gt + 1e-9, `≤ 10 + 1 LP per game s (${lp0} → ${m.teamLp})`);
assert.ok(m.teamLp > 0);
assert.equal(m._finalEnding, null, 'the fight goes on');
assert.ok(!h.bc.some((x) => x.t === 'b.end'));
// the held-back part is credited as the clock advances, at the bounded rate (nothing reported is lost)
h.sched.advance(1000);
const gt2 = m._fieldElapsed(f);
assert.ok(max - m.bossPool.hp <= (max * gt2) / 5 + 1 && max - m.bossPool.hp > (max * gt) / 5, 'more credited, still bounded');
assert.ok(lp0 - m.teamLp <= 10 + gt2 + 1e-9);
assert.equal(f.bossAcked, max - m.bossPool.hp, 'b.pool acked = what was credited');
m.dispose();
});
test('Final Assault: an early boss b.result (forced at t≈0) does not end the pair\'s fight — the partner takes the field over and the sender is never its authority again', () => {
const { h, m, f } = faWithForger(9121);
h.sched.advance(500);
const res = { reason: 'forced', time: 1, killed: 0, total: 0, errors: 0,
perPlayer: Object.fromEntries(f.players.map((pid) => [pid, { killed: 0, total: 0, leaked: [], perfect: true, layerGains: {}, coins: 0, damageDealt: 0, bossDamage: 0, healingDone: 0, deaths: 0, unitsEnd: [], unitStats: [] }])) };
assert.equal(isBattleResult(res), true);
assert.deepEqual(m.handle('p_0', { t: 'b.result', battleId: f.battleId, result: res }), { ok: true });
assert.equal(f.done, false, 'the field keeps running');
assert.equal(m.phase, PHASE.FINAL_ASSAULT);
assert.equal(f.authority, 'p_1', 'the partner\'s replica is promoted');
assert.equal(h.lastTo('p_1', 'b.start').authoritative, true);
assert.equal(h.lastTo('p_0', 'b.end').reason, 'takeover');
assert.ok(m.verifyStats.rejected >= 1);
// the demoted client never gets the field back: the partner leaving hands it to the server
m.onDisconnect('p_1');
assert.equal(f.mode, 'server');
assert.equal(f.authority, null);
m.dispose();
});
test('Final Assault run on the server (nobody connected at its start): a human who reconnects gets b.pool with the field\'s own damage acknowledged', () => {
const h = makeMatch({ mode: 'coop', difficulty: 'FUNNY', humans: 2, seed: 9110, fake: true, clientCombat: true, instant: false,
script: (b) => (b.kind === 'boss' ? { bossDps: 3000 } : { duration: 2 }) }).start();
const m = h.m;
h.autoHumans();
h.drive(() => m.phase === PHASE.PREP && m.round === m.gd.bossRound);
m.onDisconnect('p_0');
m.onDisconnect('p_1');
h.run(() => m.phase === PHASE.FINAL_ASSAULT);
h.sched.advance(2000);
const f = fields(h)[0];
assert.equal(f.mode, 'server');
const n0 = h.bc.filter((x) => x.t === 'b.pool').length;
m.onReconnect('p_0');
assert.equal(h.lastTo('p_0', 'b.start').authoritative, false, 'a display replica');
h.sched.advance(3000);
const pools = h.bc.slice().filter((x) => x.t === 'b.pool').slice(n0);
assert.ok(pools.length >= 8, `b.pool keeps the replica in sync (${pools.length} frames in 3 s)`);
const last = pools[pools.length - 1];
assert.ok(Math.abs(last.hp - m.bossPool.hp) < 1e-6 || last.hp > m.bossPool.hp, 'the server pool');
assert.ok(last.acked[f.fieldId] > 0, 'the server run\'s own damage is acknowledged (the replica does not count it twice)');
const dealt = m.bossPool.maxHp - last.hp;
assert.ok(Math.abs(Object.values(last.acked).reduce((a, b) => a + b, 0) - dealt) <= 3000 * 2, 'Σ acked ≈ the pool damage');
m.dispose();
});
+235
View File
@@ -0,0 +1,235 @@
// COMBAT orchestration, watchers, SETTLE (LP, coins, layers, elimination), 联防, error isolation — with FakeBattle.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { PHASE } from '../../shared/constants.js';
import { GEO } from '../../shared/constants.js';
import { FakeBattle } from './fakeBattle.js';
import { DATA, makeMatch, give, checkInvariants, chessOfTier, legalTileFor } from './harness.js';
const normalFields = () => FakeBattle.instances.filter((b) => b.kind === 'normal');
test('COMBAT: one battle per alive player with the board as input; watchers get m.field + b.snap/b.ev; g.watch switches', () => {
const h = makeMatch({ mode: 'coop', humans: 2, bots: 1, seed: 31, fake: true, instant: false, script: () => ({ duration: 3 }) }).start();
const m = h.m;
h.toPrep(1);
const a = h.ps('p_0');
const id = chessOfTier(1, (c) => c.position === 'RANGED').find((x) => m.pool.has(x));
const piece = give(m, a, id);
const tile = [...a.deployMap()].find(([, v]) => v)[0].split(',').map(Number);
m.handle('p_0', { t: 'g.move', uid: piece.uid, to: { area: 'board', row: tile[0], col: tile[1] } });
m.handle('p_0', { t: 'g.ready', ready: true });
m.handle('p_1', { t: 'g.ready', ready: true });
h.run(() => m.phase === PHASE.COMBAT);
assert.equal(m.fields.length, 3);
const fa = FakeBattle.instances.find((b) => b.fieldId === 'n:p_0');
assert.equal(fa.kind, 'normal');
assert.deepEqual(fa.opts.rect, GEO.NORMAL_RECT);
assert.equal(fa.opts.timeLimit, m.gd.combatTimeLimit(1));
assert.equal(fa.opts.flags.layerGainsEnabled, true);
assert.equal(fa.opts.players.length, 1);
assert.deepEqual(fa.opts.players[0].units.map((u) => [u.chessId, u.row, u.col]), [[id, tile[0], tile[1]]]);
assert.ok(fa.opts.spawns.length > 0 && fa.opts.spawns.every((s) => s.ownerPlayerId === 'p_0'));
const seeds = new Set(normalFields().map((b) => b.opts.seed));
assert.equal(seeds.size, 3, 'every field has its own seed');
const field = h.lastTo('p_0', 'm.field');
assert.equal(field.fieldId, 'n:p_0');
const pub = m.publicView();
assert.equal(pub.players.find((p) => p.playerId === 'p_0').status, 'combat');
assert.equal(pub.players.find((p) => p.playerId === 'p_0').fieldId, 'n:p_0');
// real-time pacing: 2 ticks per real 1/30 s, a snapshot every 3 ticks
const before = h.allTo('p_0', 'b.snap').length;
h.sched.advance(1000);
const snaps = h.allTo('p_0', 'b.snap').length - before;
assert.ok(snaps >= 17 && snaps <= 23, `≈20 snapshots per real second, got ${snaps}`);
const last = h.lastTo('p_0', 'b.snap');
assert.equal(typeof last.gt, 'number', 'game time travels as gt (t is the frame type)');
assert.ok(Array.isArray(last.units));
assert.ok(Math.abs(fa.time - 2) < 0.2, `game time runs at 2× (${fa.time})`);
assert.ok(h.allTo('p_0', 'b.ev').length > 0);
assert.ok(h.allTo('p_0', 'b.snap').every((s) => s.fieldId === 'n:p_0'));
// switch to the teammate's field
assert.deepEqual(m.handle('p_0', { t: 'g.watch', fieldId: 'n:p_1' }), { ok: true });
assert.equal(h.lastTo('p_0', 'm.field').fieldId, 'n:p_1');
h.sched.advance(200);
assert.equal(h.lastTo('p_0', 'b.snap').fieldId, 'n:p_1');
assert.equal(m.handle('p_0', { t: 'g.watch', fieldId: 'b9' }).error, 'BAD_TARGET');
// fields finish at different times: m.public reflects it (status done, live false) while others still fight
FakeBattle.instances.find((b) => b.fieldId === 'n:p_1').forceEnd('forced');
h.sched.advance(300);
const live = h.lastBc('m.public');
assert.equal(live.fields.find((f) => f.fieldId === 'n:p_1').live, false);
assert.equal(live.players.find((p) => p.playerId === 'p_1').status, 'done');
assert.equal(live.players.find((p) => p.playerId === 'p_0').status, 'combat');
h.run(() => m.phase === PHASE.SETTLE);
m.dispose();
});
test('SETTLE: LP −min(leaks, 10), bounty coins next round, IN_BATTLE layer gains applied, perfect rounds counted', () => {
const h = makeMatch({
mode: 'coop', humans: 2, seed: 32, fake: true,
script: (b) => (b.round === 1 ? { leaks: { p_0: 14, p_1: 3 }, coins: { p_0: 2 }, layerGains: { p_0: { yanShip: 5 } } } : {}),
}).start();
const m = h.m;
h.toPrep(1);
const [a, b] = [h.ps('p_0'), h.ps('p_1')];
const lpA = a.lp;
const lpB = b.lp;
h.drive(() => m.phase === PHASE.SETTLE);
assert.equal(a.lp, lpA - 10, 'capped at 10');
assert.equal(b.lp, lpB - 3);
assert.equal(a.pendingFunds, 2);
assert.equal(a.layers.yanShip, 5);
assert.equal(a.stats.leaks, 14);
assert.equal(m.unitePlan, null, 'nobody perfect → no 联防');
h.drive(() => m.phase === PHASE.PREP && m.round === 2);
assert.equal(a.funds, m.gd.income(2) + 2, 'coins credited at the next round start');
checkInvariants(m);
m.dispose();
});
test('elimination: LP ≤ 0 → out, copies back to the pool; everyone out → RESULT defeat', () => {
// p_1 is perfect → 联防 re-fights p_0's leaks; all 10 survive and cost p_0 its last LP
const h = makeMatch({ mode: 'coop', humans: 2, seed: 33, fake: true, script: (b) => (b.kind === 'unite' ? { survivors: { p_0: 10 } } : { leaks: Object.fromEntries(b.players.map((p) => [p, p === 'p_0' ? 10 : 0])) }) }).start();
const m = h.m;
h.toPrep(1);
const a = h.ps('p_0');
const id = chessOfTier(2).find((x) => m.pool.has(x));
give(m, a, id);
const left = m.pool.left(id);
a.lp = 5;
h.drive(() => m.phase === PHASE.SETTLE);
assert.equal(a.alive, false);
assert.equal(a.lp, 0);
assert.equal(a.eliminatedRound, 1);
assert.equal(m.pool.left(id), left + 1);
assert.equal(m.publicView().players.find((p) => p.playerId === 'p_0').status, 'dead');
assert.deepEqual(m.handle('p_0', { t: 'g.refresh' }), { error: 'ELIMINATED' });
h.drive(() => m.phase === PHASE.PREP && m.round === 2);
assert.equal(a.funds, 0, 'eliminated players get no income');
// the survivor keeps playing; now kill them too
FakeBattle.script = (bt) => ({ leaks: Object.fromEntries(bt.players.map((p) => [p, 10])) });
h.ps('p_1').lp = 3;
const end = h.drive(() => h.ended != null);
assert.ok(end);
assert.equal(h.ended.victory, false);
assert.equal(h.ended.reason, 'eliminated');
const rows = Object.fromEntries(h.ended.players.map((p) => [p.playerId, p]));
assert.equal(rows.p_0.roundsPassed, 0);
assert.equal(rows.p_1.roundsPassed, 1);
assert.equal(h.endedCount, 1);
checkInvariants(m);
m.dispose();
});
test('联防: trigger, helpers (≤ 2, highest LP then seat), carry state, sources, survivors cost the SOURCE (cap 10)', () => {
const h = makeMatch({
mode: 'coop', humans: 4, seed: 34, fake: true,
script: (b) => {
if (b.kind === 'normal') return { leaks: { p_0: 6, p_3: 12 } };
if (b.kind === 'unite') return { survivors: { p_0: 2, p_3: 11 } };
return {};
},
}).start();
const m = h.m;
h.toPrep(1);
const ps = ['p_0', 'p_1', 'p_2', 'p_3'].map((id) => h.ps(id));
ps[1].lp = 30; ps[2].lp = 30; ps[0].lp = 40; ps[3].lp = 40;
// give p_1 a unit so the helper input has something to carry
const id = chessOfTier(1, (c) => c.position === 'MELEE').find((x) => m.pool.has(x));
const unit = give(m, ps[1], id, 'board', [...ps[1].deployMap()].find(([, v]) => v === 'melee')[0].split(',').map(Number));
h.drive(() => m.phase === PHASE.UNITE);
const plan = m.unitePlan;
assert.deepEqual(plan.leakers.map((p) => p.playerId).sort(), ['p_0', 'p_3']);
assert.deepEqual(plan.helpers.map((p) => p.playerId), ['p_1', 'p_2'], 'equal LP → lower seat first');
const u = FakeBattle.instances.find((b) => b.kind === 'unite');
assert.equal(u.fieldId, 'u');
assert.deepEqual(u.opts.rect, GEO.UNITE_RECT);
assert.equal(u.opts.flags.layerGainsEnabled, false);
// unite.js (research 08 §5): the first-ranked helper "率先迎敌" on the right-hand field (colOffset +8)
assert.deepEqual(u.opts.players.map((p) => [p.playerId, p.colOffset]), [['p_1', 8], ['p_2', 0]]);
const carried = u.opts.players[0].units.find((x) => x.uid === unit.uid);
assert.deepEqual(carried.carryState, { hpPct: 0.5, sp: 3, skillActive: false }, 'HP%/SP from the end of the own combat');
assert.equal(u.opts.spawns.length, 18, 'union of every counted leak');
assert.equal(u.opts.spawns.filter((s) => s.sourcePlayerId === 'p_0').length, 6);
assert.equal(u.opts.spawns.filter((s) => s.sourcePlayerId === 'p_3').length, 12);
assert.equal(u.opts.timeLimit, m.gd.combatTimeLimit(1));
assert.equal(m.publicView().players.find((p) => p.playerId === 'p_1').status, 'helping');
assert.equal(m.publicView().fields[0].fieldId, 'u');
// every human watches the unite field by default
assert.equal(h.lastTo('p_3', 'm.field').fieldId, 'u');
h.drive(() => m.phase === PHASE.SETTLE);
assert.equal(ps[0].lp, 38, 'p_0: 2 survivors');
assert.equal(ps[3].lp, 30, 'p_3: 11 survivors capped at 10');
assert.equal(ps[1].lp, 30);
assert.equal(ps[2].lp, 30);
checkInvariants(m);
m.dispose();
});
test('联防: an operator dead at the end of the helper\'s own combat stays out (with its summons); the living carry their state', () => {
let deadUid = null;
const h = makeMatch({
mode: 'coop', humans: 2, seed: 37, fake: true,
script: (b) => (b.kind === 'normal' ? { leaks: { p_0: 3 }, deadUids: deadUid != null ? [deadUid] : [] } : {}),
}).start();
const m = h.m;
h.toPrep(1);
const helper = h.ps('p_1');
// 赫默 (placeable summon) is knocked out in p_1's own combat; a second operator survives it
const t0 = legalTileFor(m, helper, 'chess_char_2_02_a');
const hemo = give(m, helper, 'chess_char_2_02_a', 'board', t0);
const other = chessOfTier(1, (c) => c.position === 'RANGED').find((x) => m.pool.has(x));
const alive = give(m, helper, other, 'board', legalTileFor(m, helper, other));
const stack = helper.hand.find((p) => p && p.kind === 'token' && p.ownerUid === hemo.uid);
assert.ok(stack, '赫默 brought its summon stack');
let placed = null;
for (const [k] of helper.deployMap()) {
const [r, c] = k.split(',').map(Number);
if (helper.board.has(k)) continue;
if (m.handle('p_1', { t: 'g.move', uid: stack.uid, to: { area: 'board', row: r, col: c } }).ok) { placed = k; break; }
}
assert.ok(placed, 'the summon stands on the board');
deadUid = hemo.uid;
h.drive(() => m.phase === PHASE.UNITE);
assert.deepEqual(m.unitePlan.helpers.map((p) => p.playerId), ['p_1']);
const u = FakeBattle.instances.find((b) => b.kind === 'unite');
const units = u.opts.players[0].units;
assert.ok(!units.some((x) => x.uid === hemo.uid), 'the dead operator is not redeployed at full HP');
assert.ok(!units.some((x) => x.kind === 'token' && x.ownerUid === hemo.uid), 'nor are its summons');
assert.deepEqual(units.find((x) => x.uid === alive.uid).carryState, { hpPct: 0.5, sp: 3, skillActive: false });
assert.ok(helper.board.has(`${t0[0]},${t0[1]}`) && helper.board.has(placed), 'the board itself is untouched (next round as usual)');
// during 联防 an 'n:<pid>' id names no field: refused, and the viewer keeps streaming the 联防 field
assert.equal(m.watchers.get('p_0'), 'u');
assert.deepEqual(m.handle('p_0', { t: 'g.watch', fieldId: 'n:p_1' }), { error: 'BAD_TARGET', detail: 'no such field' });
assert.equal(m.watchers.get('p_0'), 'u');
h.drive(() => m.phase === PHASE.SETTLE);
// outside battles the prep scouting view works as before
assert.deepEqual(m.handle('p_0', { t: 'g.watch', fieldId: 'n:p_1' }), { ok: true });
assert.equal(h.lastTo('p_0', 'm.field').prep, true);
checkInvariants(m);
m.dispose();
});
test('联防 is skipped when everyone is perfect, everyone leaked, or in solo', () => {
for (const [mode, humans, leaks] of [['coop', 2, {}], ['coop', 2, { p_0: 3, p_1: 2 }], ['solo', 1, { p_0: 5 }]]) {
const h = makeMatch({ mode, humans, seed: 35, fake: true, script: () => ({ leaks }) }).start();
h.toPrep(1);
h.drive(() => h.m.phase === PHASE.SETTLE || h.m.phase === PHASE.UNITE);
assert.equal(h.m.phase, PHASE.SETTLE, `${mode} ${JSON.stringify(leaks)}`);
assert.ok(!FakeBattle.instances.some((b) => b.kind === 'unite'));
h.m.dispose();
}
});
test('error isolation: a battle that throws is force-ended as a timeout; a constructor failure yields a clean result', () => {
const h = makeMatch({ mode: 'coop', humans: 2, seed: 36, fake: true, script: (b) => (b.fieldId === 'n:p_0' ? { throwAt: 10 } : b.fieldId === 'n:p_1' ? { throwInCtor: true } : {}) }).start();
const m = h.m;
h.toPrep(1);
h.drive(() => m.phase === PHASE.SETTLE);
assert.ok(m.errorCount >= 2, 'both failures reported');
assert.equal(h.ps('p_1').lp, h.ps('p_1').lp, 'still alive');
assert.ok(h.ps('p_0').alive && h.ps('p_1').alive);
h.drive(() => m.phase === PHASE.PREP && m.round === 2);
assert.equal(m.round, 2, 'the match continues');
m.dispose();
});
+305
View File
@@ -0,0 +1,305 @@
// Platform interface: start/handle/onDisconnect/onReconnect/onLeave/dispose, autoplay, bot takeover, views.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { PHASE, ERR } from '../../shared/constants.js';
import { Match } from '../../server/match/Match.js';
import { StubMatch } from '../../server/match/StubMatch.js';
import { DATA, makeMatch, checkInvariants, give, chessOfTier } from './harness.js';
import { bossPoolHp } from '../../server/match/finalAssault.js';
const STATUSES = ['acting', 'ready', 'deciding', 'combat', 'done', 'helping', 'left', 'dead'];
test('constructor validation mirrors the stub; start() sends the first m.public and one m.private per human', () => {
assert.throws(() => new Match({ seats: [] }), TypeError);
assert.throws(() => new Match({ seats: [{ seat: 0, playerId: 'a', name: 'a', isBot: false, connected: true }] }), TypeError);
assert.equal(typeof StubMatch, 'function', 'the platform stub is kept for platform tests');
const h = makeMatch({ mode: 'coop', humans: 2, bots: 1, seed: 60 }).start();
const pub = h.lastBc('m.public');
for (const k of ['phase', 'round', 'lastRound', 'deadline', 'serverNow', 'modeId', 'difficulty', 'stageId', 'factions', 'disabledBonds', 'bannedChess', 'bossId', 'players', 'fields']) assert.ok(k in pub, `m.public.${k}`);
assert.equal(pub.phase, PHASE.INFO_CHECK);
assert.equal(pub.lastRound, 14);
assert.ok(DATA.stages[pub.stageId]);
assert.equal(pub.factions.length, 3);
assert.ok(DATA.bosses[pub.bossId]);
for (const p of pub.players) {
for (const k of ['playerId', 'seat', 'name', 'isBot', 'connected', 'alive', 'lp', 'bandId', 'shopLevel', 'boardCount', 'ready', 'bonds', 'fieldId', 'status']) assert.ok(k in p, `players[].${k}`);
assert.ok(STATUSES.includes(p.status));
}
const privs = h.sent.filter(([, m]) => m.t === 'm.private');
assert.deepEqual(privs.map(([id]) => id).sort(), ['p_0', 'p_1'], 'one m.private per human, none for bots');
for (const [id, priv] of privs) {
assert.equal(priv.playerId, id);
for (const k of ['seat', 'alive', 'lp', 'funds', 'bandId', 'ready', 'canReady', 'shop', 'hand', 'temp', 'board', 'deployCap', 'deployCount', 'bonds', 'effects', 'nextEnemies', 'stats']) assert.ok(k in priv, `m.private.${k}`);
for (const k of ['level', 'maxLevel', 'upgradePrice', 'refreshPrice', 'freeRefreshes', 'frozen', 'slots', 'rewardOffer']) assert.ok(k in priv.shop, `shop.${k}`);
assert.equal(priv.hand.length, 10);
assert.equal(priv.temp.length, 5);
for (const k of ['dmgDealt', 'kills', 'leaks', 'gold', 'refreshes', 'merges']) assert.ok(k in priv.stats);
}
h.m.dispose();
});
test('m.private shape in PREP matches DESIGN §8.3 (pieces, slots, bonds, effects, nextEnemies)', () => {
const h = makeMatch({ mode: 'solo', seed: 61 }).start();
h.toPrep(1);
h.flushAll();
const priv = h.lastTo('p_0', 'm.private');
assert.equal(priv.funds, 4);
assert.equal(priv.shop.slots.length, 4);
for (const s of priv.shop.slots) {
assert.deepEqual(Object.keys(s).sort(), ['basePrice', 'frozen', 'id', 'kind', 'price', 'sold']);
assert.equal(s.frozen, false);
assert.ok(s.kind === 'chess' || s.kind === 'item');
}
assert.equal(priv.shop.rewardOffer, null);
assert.equal(priv.deployCap, 8);
assert.ok(priv.effects.some((e) => e.iconKind === 'band' && e.name));
assert.ok(priv.nextEnemies.length > 0);
for (const e of priv.nextEnemies) { assert.ok(DATA.enemies[e.enemyKey]); assert.ok(e.count >= 1); assert.ok('tag' in e); }
const ps = h.ps('p_0');
h.m.handle('p_0', { t: 'g.buy', slot: 0 });
h.flushAll();
const p2 = h.lastTo('p_0', 'm.private');
const piece = p2.hand.find(Boolean);
assert.deepEqual(Object.keys(piece).sort(), ['count', 'golden', 'id', 'items', 'kind', 'ownerUid', 'tier', 'uid']);
assert.equal(p2.funds, ps.funds);
h.m.dispose();
});
test('m.public is throttled to ≤ 10/s; m.private is only resent when it changed', () => {
const h = makeMatch({ mode: 'solo', seed: 62 }).start();
h.toPrep(1);
h.sched.advance(500);
const n0 = h.bc.filter((m) => m.t === 'm.public').length;
const t0 = h.sched.now();
for (let i = 0; i < 50; i++) { h.m.handle('p_0', { t: 'g.freeze' }); h.sched.advance(5); }
h.sched.advance(200);
const n1 = h.bc.filter((m) => m.t === 'm.public').length;
const elapsed = (h.sched.now() - t0) / 1000;
assert.ok(n1 - n0 <= Math.ceil(elapsed * 10) + 1, `${n1 - n0} public frames in ${elapsed}s`);
const privs = h.allTo('p_0', 'm.private').length;
h.m.flush();
h.m.markPrivate(h.ps('p_0'));
h.m.flush();
assert.equal(h.allTo('p_0', 'm.private').length, privs, 'unchanged private view is not resent');
h.m.dispose();
});
test('disconnect: the seat keeps playing; draft turns / prep auto-resolve at deadlines; reconnect resends everything', () => {
const h = makeMatch({ mode: 'coop', humans: 2, seed: 63, fake: true, script: () => ({ duration: 4 }) }).start();
const m = h.m;
h.m.onDisconnect('p_1');
assert.equal(m.publicView().players.find((p) => p.playerId === 'p_1').connected, false);
m.handle('p_0', { t: 'g.infoReady' });
// p_1 never confirms: the 25 s deadline moves on; p_1 never picks: 12 s turn → 华法琳
h.drive(() => m.phase === PHASE.PREP && m.round === 1);
assert.equal(h.ps('p_1').bandId, 'band_bldsk');
const sentBefore = h.sent.length;
// p_0 readies; p_1 is auto-readied at the prep deadline
m.handle('p_0', { t: 'g.ready', ready: true });
h.run(() => m.phase === PHASE.COMBAT);
assert.ok(h.ps('p_1').ready);
assert.ok(m.fields.some((f) => f.fieldId === 'n:p_1'), 'the disconnected seat still fights');
assert.ok(!h.sent.slice(sentBefore).some(([id]) => id === 'p_1') || true);
// reconnect mid-combat: m.public + m.private + m.field + b.snap
const mark = h.sent.length;
m.onReconnect('p_1');
const got = h.sent.slice(mark).filter(([id]) => id === 'p_1').map(([, x]) => x.t);
assert.ok(got.includes('m.public') && got.includes('m.private') && got.includes('m.field') && got.includes('b.snap'), got.join());
assert.equal(m.publicView().players.find((p) => p.playerId === 'p_1').connected, true);
m.dispose();
});
test('g.autoplay lets the bot play a human seat (buys, places, readies); turning it off returns control', () => {
const h = makeMatch({ mode: 'solo', seed: 64, fake: true }).start();
const m = h.m;
h.toPrep(1);
assert.deepEqual(m.handle('p_0', { t: 'g.autoplay', on: true }), { ok: true });
assert.equal(m.publicView().players[0].autoplay, true);
h.run(() => m.phase !== PHASE.PREP);
const ps = h.ps('p_0');
assert.ok(ps.board.size > 0, 'the bot deployed units');
assert.notEqual(m.phase, PHASE.PREP, 'the bot readied');
h.run(() => m.phase === PHASE.PREP && m.round === 2);
assert.deepEqual(m.handle('p_0', { t: 'g.autoplay', on: false }), { ok: true });
h.sched.advance(60000);
assert.equal(m.phase, PHASE.PREP, 'solo prep waits for the human again');
checkInvariants(m);
m.dispose();
});
test('onLeave: quitting counts as elimination (copies back to the pool at once, status left); last human leaving ends the match once', () => {
const h = makeMatch({ mode: 'coop', humans: 2, seed: 65, fake: true }).start();
const m = h.m;
h.toPrep(1);
const q = h.ps('p_1');
const id = chessOfTier(1).find((x) => m.pool.has(x) && m.pool.left(x) >= 2);
give(m, q, id);
give(m, q, id);
const base = m.gd.baseIdOf(id);
const left0 = m.pool.left(base);
m.onLeave('p_1');
assert.equal(q.alive, false, 'the departed seat is out of the match');
assert.equal(m.pool.left(base), left0 + 2, 'its copies went back to the shared pool at once');
assert.ok(q.board.size === 0 && q.hand.every((x) => x == null), 'it holds nothing');
assert.equal(q.eliminatedRound, 1);
assert.equal(m.publicView().players.find((p) => p.playerId === 'p_1').status, 'left');
assert.deepEqual(m.handle('p_1', { t: 'g.ready', ready: true }), { error: ERR.NOT_IN_ROOM });
checkInvariants(m);
m.handle('p_0', { t: 'g.ready', ready: true });
h.run(() => m.phase === PHASE.COMBAT);
assert.deepEqual(m.fields.map((f) => f.fieldId), ['n:p_0'], 'no battle for the departed seat');
h.run(() => m.phase === PHASE.PREP && m.round === 2);
assert.equal(m.round, 2);
m.onLeave('p_0');
assert.equal(h.endedCount, 1);
assert.equal(h.ended.reason, 'abandoned');
assert.equal(h.ended.players.find((p) => p.playerId === 'p_1').roundsPassed, 0);
m.onLeave('p_0');
m.onReconnect('p_0');
m.onDisconnect('p_0');
assert.equal(h.endedCount, 1);
assert.deepEqual(m.handle('p_0', { t: 'g.ready', ready: true }).error, ERR.NOT_IN_ROOM);
m.dispose();
m.dispose();
assert.equal(h.sched.pending(), 0, 'dispose cancels every timer');
});
test('onLeave: a quitter has no place in the Final Assault pairing or the boss pool; its draft turns pass on', () => {
// band draft: the player on turn leaves → its pick is the default band and the turn moves on
const h = makeMatch({ mode: 'coop', difficulty: 'FUNNY', humans: 3, seed: 5, fake: true, script: (b) => (b.kind === 'boss' ? { bossDps: 1e9 } : {}) }).start();
const m = h.m;
for (const id of ['p_0', 'p_1', 'p_2']) m.handle(id, { t: 'g.infoReady' });
h.sched.advance(1);
assert.equal(m.phase, PHASE.BAND_DRAFT);
const first = m.draftTurn();
m.onLeave(first);
assert.equal(m.draft.picks[first], m.gd.bandDraft.timeoutBandId);
assert.notEqual(m.draftTurn(), first);
h.autoHumans();
h.drive(() => m.phase === PHASE.PREP && m.round === 14);
const alive = m.alivePlayers().map((p) => p.playerId);
assert.equal(alive.length, 2);
assert.ok(!alive.includes(first));
h.drive(() => m.phase === PHASE.FINAL_ASSAULT);
assert.deepEqual(m.fields.map((f) => f.players), [alive], 'one boss field for the two players left');
assert.equal(m.bossPool.maxHp, bossPoolHp(m.gd, m.bossId, 2), 'the co-op boss pool is bloodPoint (no alive-player factor)');
const end = h.runToEnd();
assert.equal(end.victory, true);
assert.equal(end.players.find((p) => p.playerId === first).roundsPassed, 0);
m.dispose();
// 机变: the picker on turn leaves → the next alive player picks; the last alive player leaving ends the match
const h2 = makeMatch({ mode: 'coop', difficulty: 'FUNNY', humans: 2, seed: 18, fake: true }).start();
const m2 = h2.m;
h2.autoHumans();
h2.drive(() => m2.phase === PHASE.SP_DRAFT);
const turn = m2.spTurn();
m2.onLeave(turn);
assert.equal(m2.phase, PHASE.SP_DRAFT);
assert.notEqual(m2.spTurn(), turn, 'the turn passed on');
checkInvariants(m2);
m2.dispose();
// mid-combat: the quitter's own battle is force-ended, the others play on
const h4 = makeMatch({ mode: 'coop', difficulty: 'FUNNY', humans: 3, seed: 20, fake: true, instant: false, script: (b) => (b.kind === 'normal' ? { duration: 5 } : {}) }).start();
const m4 = h4.m;
h4.toPrep(2);
h4.drive(() => m4.phase === PHASE.COMBAT);
h4.sched.advance(500);
m4.onLeave('p_2');
const f2 = m4.fields.find((f) => f.fieldId === 'n:p_2');
assert.ok(f2.battle.finished, 'its battle was force-ended');
assert.ok(m4.fields.filter((f) => f.fieldId !== 'n:p_2').every((f) => !f.battle.finished), 'the others still fight');
checkInvariants(m4);
h4.drive(() => m4.phase === PHASE.PREP && m4.round === 3);
assert.deepEqual(m4.alivePlayers().map((p) => p.playerId), ['p_0', 'p_1']);
assert.equal(h4.ps('p_2').eliminatedRound, 2);
checkInvariants(m4);
m4.dispose();
// only an eliminated spectator left → the match ends as a defeat ('eliminated'), not 'abandoned'
const h3 = makeMatch({ mode: 'coop', difficulty: 'FUNNY', humans: 2, seed: 19, fake: true }).start();
h3.toPrep(2);
h3.ps('p_1').eliminate(2);
h3.m.onLeave('p_0');
assert.equal(h3.endedCount, 1);
assert.equal(h3.ended.reason, 'eliminated');
assert.ok(h3.lastTo('p_1', 'm.result'), 'the spectator gets the result');
h3.m.dispose();
});
test('dispose stops timers and battles; handle/hooks after dispose are inert', () => {
const h = makeMatch({ mode: 'coop', humans: 1, bots: 1, seed: 66, instant: false }).start();
h.m.handle('p_0', { t: 'g.infoReady' });
h.sched.advance(10);
h.m.dispose();
assert.equal(h.sched.pending(), 0);
const n = h.sent.length + h.bc.length;
h.sched.advance(100000);
assert.deepEqual(h.m.handle('p_0', { t: 'g.refresh' }), { error: ERR.WRONG_PHASE });
h.m.onReconnect('p_0');
h.m.onDisconnect('p_0');
assert.equal(h.sent.length + h.bc.length, n, 'nothing is sent after dispose');
assert.equal(h.endedCount, 0);
});
test('emotes: relayed as m.emote with a 1 s cooldown; g.watch during prep scouts a teammate board', () => {
const h = makeMatch({ mode: 'coop', humans: 2, seed: 67, fake: true }).start();
const m = h.m;
assert.deepEqual(m.handle('p_0', { t: 'g.emote', id: 'autochess_battle_happy' }), { ok: true });
assert.deepEqual(h.lastBc('m.emote'), { t: 'm.emote', playerId: 'p_0', id: 'autochess_battle_happy' });
assert.equal(m.handle('p_0', { t: 'g.emote', id: 'slug_autochess_battle_thanks' }).error, ERR.RATE);
h.sched.advance(1000);
// only the official emote ids are relayed (defence in depth: handle() may be called without validateC2S)
assert.equal(m.handle('p_0', { t: 'g.emote', id: 'autochess_room_hello' }).error, ERR.BAD_MSG); // room-scene emote
assert.equal(m.handle('p_0', { t: 'g.emote', id: 'happy' }).error, ERR.BAD_MSG); // v1 short id
assert.deepEqual(m.handle('p_0', { t: 'g.emote', id: 'slug_autochess_battle_thanks' }), { ok: true });
h.toPrep(1);
assert.deepEqual(m.handle('p_0', { t: 'g.watch', fieldId: 'n:p_1' }), { ok: true });
const f = h.lastTo('p_0', 'm.field');
assert.equal(f.fieldId, 'n:p_1');
assert.equal(f.prep, true);
assert.ok(Array.isArray(f.units));
assert.equal(m.handle('p_0', { t: 'g.watch', fieldId: 'n:nobody' }).error, ERR.BAD_TARGET);
m.dispose();
});
test('unknown / wrong-phase intents are rejected without throwing', () => {
const h = makeMatch({ mode: 'coop', humans: 1, bots: 1, seed: 68 }).start();
const m = h.m;
assert.equal(m.handle('nobody', { t: 'g.ready', ready: true }).error, ERR.NOT_IN_ROOM);
assert.equal(m.handle('ai_0', { t: 'g.ready', ready: true }).error, ERR.NOT_IN_ROOM);
assert.equal(m.handle('p_0', { t: 'room.start' }).error, ERR.BAD_MSG);
assert.equal(m.handle('p_0', null).error, ERR.BAD_MSG);
assert.equal(m.handle('p_0', { t: 'g.buy', slot: 0 }).error, ERR.WRONG_PHASE);
assert.equal(m.handle('p_0', { t: 'g.choice', idx: 0 }).error, ERR.WRONG_PHASE);
assert.equal(m.handle('p_0', { t: 'g.band', bandId: 'band_bldsk' }).error, ERR.WRONG_PHASE);
assert.equal(m.errorCount, 0);
m.dispose();
});
test('degraded data: a match without a chess pool ends immediately with an error summary (no throw)', () => {
const out = { ended: null, bc: [] };
const m = new Match({
roomCode: 'X', mode: 'coop', difficulty: 'HARD', modeId: 'mode_multi_hard', seed: 1,
seats: [{ seat: 0, playerId: 'p', name: 'P', isBot: false, connected: true }],
data: { config: {} }, log: { info() {}, warn() {}, error() {} },
send: () => true, broadcast: (x) => out.bc.push(x), onEnd: (s) => { out.ended = s; },
});
m.start();
assert.ok(out.ended);
assert.equal(out.ended.reason, 'error');
m.dispose();
});
test('m.public follows player-state changes (shop level, board count, bonds) within the throttle window', () => {
const h = makeMatch({ mode: 'solo', seed: 69 }).start();
h.toPrep(1);
const m = h.m;
const ps = h.ps('p_0');
ps.funds = 50;
m.handle('p_0', { t: 'g.levelUp' });
h.sched.advance(150);
assert.equal(h.lastBc('m.public').players[0].shopLevel, 2);
m.dispose();
});
+216
View File
@@ -0,0 +1,216 @@
// INFO_CHECK, band draft (order / skip / timeouts / 队友已选) and 机变 SP drafts (order / timers / auto-assign / effects).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { ERR, PHASE } from '../../shared/constants.js';
import { DATA, makeMatch, checkInvariants } from './harness.js';
test('INFO_CHECK: ends when every human confirmed (bots/departed count as ready) or at the 25 s deadline', () => {
const h = makeMatch({ mode: 'coop', humans: 2, bots: 1, seed: 1 }).start();
const m = h.m;
assert.equal(m.phase, PHASE.INFO_CHECK);
const pub = h.lastBc('m.public');
assert.equal(pub.deadline - pub.serverNow, 25000);
assert.equal(pub.players.find((p) => p.playerId === 'ai_0').status, 'ready');
assert.equal(pub.players.find((p) => p.playerId === 'p_0').status, 'deciding');
assert.deepEqual(m.handle('p_0', { t: 'g.infoReady' }), { ok: true });
h.sched.advance(1);
assert.equal(m.phase, PHASE.INFO_CHECK);
m.onLeave('p_1');
h.sched.advance(1);
assert.equal(m.phase, PHASE.BAND_DRAFT, 'departed human counts as ready');
assert.deepEqual(m.handle('p_0', { t: 'g.infoReady' }), { error: ERR.WRONG_PHASE });
m.dispose();
const h2 = makeMatch({ mode: 'coop', humans: 2, seed: 1 }).start();
h2.sched.advance(24999);
assert.equal(h2.m.phase, PHASE.INFO_CHECK);
h2.sched.advance(2);
assert.equal(h2.m.phase, PHASE.BAND_DRAFT, 'deadline');
h2.m.dispose();
});
test('band draft (co-op): random order, one pick per turn, NOT_YOUR_TURN, one skip each (moves to the end), 队友已选 refused', () => {
// find a seed where both humans are not first... just use the order the match chose
const h = makeMatch({ mode: 'coop', humans: 3, seed: 5 }).start();
const m = h.m;
for (const id of ['p_0', 'p_1', 'p_2']) m.handle(id, { t: 'g.infoReady' });
h.sched.advance(1);
assert.equal(m.phase, PHASE.BAND_DRAFT);
const order = m.draft.order.slice();
assert.deepEqual(order.slice().sort(), ['p_0', 'p_1', 'p_2']);
const [first, second, third] = order;
assert.equal(h.lastBc('m.public') && m.publicView().draft.turn, first);
assert.deepEqual(m.handle(second, { t: 'g.band', bandId: 'band_amiya' }), { error: ERR.NOT_YOUR_TURN });
assert.deepEqual(m.handle(first, { t: 'g.band', bandId: 'nope' }).error, ERR.BAD_TARGET);
// skip: first goes to the end
assert.deepEqual(m.handle(first, { t: 'g.bandSkip' }), { ok: true });
assert.deepEqual(m.draft.order, [second, third, first]);
assert.equal(m.draftTurn(), second);
assert.deepEqual(m.handle(second, { t: 'g.band', bandId: 'band_sarkazb' }), { ok: true });
assert.equal(h.ps(second).lp, 45, '歌利亚 starts with 45 LP');
assert.deepEqual(m.handle(second, { t: 'g.band', bandId: 'band_sarkazb' }), { error: ERR.ALREADY });
// research 09 §5 / DESIGN §14 corrections: a strategy a teammate already took is 队友已选 (test/ui/bandDraft.test.js)
assert.equal(m.handle(third, { t: 'g.band', bandId: 'band_sarkazb' }).error, ERR.BAD_TARGET, '队友已选: no duplicates');
assert.equal(m.draftTurn(), third, 'the refused pick keeps the turn');
assert.deepEqual(m.handle(third, { t: 'g.band', bandId: 'band_amiya' }), { ok: true });
assert.equal(m.draftTurn(), first);
assert.deepEqual(m.handle(first, { t: 'g.bandSkip' }).error, ERR.ALREADY, 'only one skip');
assert.deepEqual(m.handle(first, { t: 'g.band', bandId: 'band_lisa' }), { ok: true });
assert.equal(h.ps(first).lp, 20);
h.sched.advance(1);
assert.equal(m.phase, PHASE.BATTLE_CHECK);
m.dispose();
});
test('band draft timers: 12 s per turn → 华法琳; the 50 s step cap assigns the rest; bots pick by themselves', () => {
const h = makeMatch({ mode: 'coop', humans: 2, bots: 2, seed: 9 }).start();
const m = h.m;
m.handle('p_0', { t: 'g.infoReady' });
m.handle('p_1', { t: 'g.infoReady' });
h.sched.advance(1);
assert.equal(m.phase, PHASE.BAND_DRAFT);
// humans never act: each human turn times out after 12 s; bots pick within ~1 s
h.run(() => m.phase !== PHASE.BAND_DRAFT, { maxTime: 60000 });
assert.equal(m.phase, PHASE.BATTLE_CHECK);
// a timeout gives 华法琳 while no teammate holds it, else the first free strategy by sortId — never 队友已选
// (Match.defaultBand): replay the assignments in draft order
const taken = new Set();
for (const pid of m.draft.order) {
if (pid.startsWith('ai_')) { assert.ok(DATA.bands[h.ps(pid).bandId], 'bot picked a band'); taken.add(h.ps(pid).bandId); continue; }
const want = !taken.has('band_bldsk') ? 'band_bldsk' : m.gd.bandIds().find((b) => !taken.has(b));
assert.equal(h.ps(pid).bandId, want, `${pid} gets the default strategy nobody else holds`);
assert.equal(h.ps(pid).lp, DATA.bands[want].totalHp);
taken.add(want);
}
assert.equal(new Set(Object.values(m.draft.picks)).size, 4, 'no duplicate strategies');
m.dispose();
// overall cap: with 4 humans × 12 s > 50 s the last one is assigned at the cap
const h2 = makeMatch({ mode: 'coop', humans: 4, seed: 9 }).start();
for (const ps of h2.m.players.values()) h2.m.handle(ps.playerId, { t: 'g.infoReady' });
h2.sched.advance(1);
const t0 = h2.sched.now();
h2.run(() => h2.m.phase !== PHASE.BAND_DRAFT, { maxTime: 120000 });
assert.ok(h2.sched.now() - t0 <= 50000 + 5, `ended by the 50 s cap (${h2.sched.now() - t0} ms)`);
const first = h2.m.draft.order[0];
assert.equal(h2.ps(first).bandId, 'band_bldsk', 'the first timeout gets 华法琳');
assert.equal(new Set([...h2.m.players.values()].map((ps) => ps.bandId)).size, 4, 'the rest get distinct free strategies');
h2.m.dispose();
});
test('band draft (solo): free pick, no timer, no skip; mode-restricted bands rejected', () => {
const h = makeMatch({ mode: 'solo', seed: 2 }).start();
const m = h.m;
m.handle('p_0', { t: 'g.infoReady' });
h.sched.advance(1);
assert.equal(m.phase, PHASE.BAND_DRAFT);
assert.equal(m.deadline, 0);
h.sched.advance(10 * 60 * 1000);
assert.equal(m.phase, PHASE.BAND_DRAFT, 'untimed');
assert.deepEqual(m.handle('p_0', { t: 'g.bandSkip' }).error, ERR.WRONG_PHASE);
const multiOnly = Object.values(DATA.bands).find((b) => !b.modeTypeList.includes('SINGLE'));
if (multiOnly) assert.equal(m.handle('p_0', { t: 'g.band', bandId: multiOnly.bandId }).error, ERR.BAD_TARGET);
assert.deepEqual(m.handle('p_0', { t: 'g.band', bandId: 'band_orchid' }), { ok: true });
assert.equal(h.ps('p_0').lp, DATA.bands.band_orchid.totalHp);
m.dispose();
});
test('机变 (co-op): 6 shared cards, random order, 30 s first / 16 s others, timeout auto-assigns, each takes one', () => {
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 3, seed: 14, fake: true }).start();
const m = h.m;
assert.deepEqual(m.gd.spRounds(), [3, 6, 9]);
h.drive(() => m.phase === PHASE.SP_DRAFT || m.round > 3, { ready: true });
// the harness picks for humans; stop before: re-run with humans passive
m.dispose();
const h2 = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 3, seed: 14, fake: true }).start();
const m2 = h2.m;
h2.drive(() => m2.phase === PHASE.ROUND_START && m2.round === 3);
h2.run(() => m2.phase === PHASE.SP_DRAFT);
assert.equal(m2.phase, PHASE.SP_DRAFT);
const sp = m2.publicView().sp;
assert.equal(sp.cards.length, 6);
assert.deepEqual(sp.order.slice().sort(), ['p_0', 'p_1', 'p_2']);
assert.equal(Math.round((m2.deadline - h2.sched.now()) / 1000), 30, 'first picker 30 s');
const [a, b, c] = sp.order;
assert.equal(m2.handle(b, { t: 'g.choice', idx: 0 }).error, ERR.NOT_YOUR_TURN);
assert.deepEqual(m2.handle(a, { t: 'g.choice', idx: 2 }), { ok: true });
assert.equal(m2.handle(a, { t: 'g.choice', idx: 3 }).error, ERR.ALREADY);
assert.equal(Math.round((m2.deadline - h2.sched.now()) / 1000), 16, 'others 16 s');
assert.equal(m2.handle(b, { t: 'g.choice', idx: 2 }).error, ERR.SOLD_OUT);
assert.equal(m2.handle(b, { t: 'g.choice', idx: 7 }).error, ERR.BAD_TARGET);
// b times out → auto-assigned a random remaining card
h2.sched.advance(16001);
assert.ok(m2.sp.picks[b] != null && m2.sp.picks[b] !== 2);
assert.deepEqual(m2.handle(c, { t: 'g.choice', idx: m2.sp.cards.map((x) => x.idx).find((i) => m2.sp.taken[i] == null) }), { ok: true });
h2.sched.advance(1);
assert.equal(m2.phase, PHASE.PREP, '机变 → prep');
checkInvariants(m2);
m2.dispose();
});
test('机变 (solo): 3 cards, untimed; supply/shop cards give the item, bounty cards add enemies to the next battle', () => {
// solo NORMAL: R6 道具补给, R9 战术决策
const h = makeMatch({ mode: 'solo', difficulty: 'NORMAL', seed: 3, fake: true }).start();
const m = h.m;
assert.deepEqual(m.gd.spRounds(), [6, 9]);
h.drive(() => m.phase === PHASE.ROUND_START && m.round === 6);
h.run(() => m.phase === PHASE.SP_DRAFT);
const sp = m.publicView().sp;
assert.equal(sp.cards.length, 3);
assert.equal(sp.family, 'supply');
assert.equal(m.deadline, 0, 'solo is untimed');
h.sched.advance(600000);
assert.equal(m.phase, PHASE.SP_DRAFT);
const card = sp.cards[1];
assert.equal(card.kind, 'item');
assert.deepEqual(m.handle('p_0', { t: 'g.choice', idx: 1 }), { ok: true });
const ps = h.ps('p_0');
assert.ok([...ps.hand, ...ps.temp].some((p) => p && (p.id === card.id || p.id === DATA.items[card.id].goldenId)), 'item granted');
h.sched.advance(1);
assert.equal(m.phase, PHASE.PREP);
// a bounty card applied through the default
const bounty = DATA.choices.cards.bounty.find((c) => c.payout === 'kill' && DATA.enemies[c.enemyKey]);
m.addBounty(ps, bounty);
const prev = ps.privateView().nextEnemies;
assert.ok(prev.some((e) => e.enemyKey === bounty.enemyKey && e.tag === 'bounty'), 'preview shows the bounty enemies');
m.dispose();
});
test('机变 tactic defaults: team cards reach teammates; layers / funds / free refreshes / next-buy elite', () => {
const h = makeMatch({ mode: 'coop', humans: 2, seed: 4, fake: true }).start();
const m = h.m;
h.toPrep(1);
const [a, b] = [h.ps('p_0'), h.ps('p_1')];
const { applyCard } = awaitImport;
const tactic = (id) => ({ kind: 'tactic', id, name: DATA.effects[id].name, desc: '', team: !!DATA.choices.cards.tactic.find((c) => c.effectId === id)?.team, family: 'tactic', idx: 0 });
applyCard(m, a, tactic('allybuff_select_2_1'));
for (const bond of ['raidShip', 'steadShip', 'egirShip']) {
assert.equal(a.layers[bond], 8, `${bond} picker`);
assert.equal(b.layers[bond], 8, `${bond} teammate`);
}
const fa = a.funds;
const fb = b.funds;
applyCard(m, a, tactic('allybuff_select_3'));
assert.equal(a.funds, fa + 1);
assert.equal(b.funds, fb + 1);
applyCard(m, b, tactic('allybuff_select_4'));
assert.equal(a.shop.freeRefreshes, 2);
assert.equal(b.shop.freeRefreshes, 2);
applyCard(m, a, tactic('allybuff_select_6'));
assert.ok(a.effects.some((e) => e.key === 'effect:builtin_next_buy_elite'));
assert.ok(!b.effects.some((e) => e.key === 'effect:builtin_next_buy_elite'), 'personal card');
a.funds = 20;
const slot = a.shop.slots.findIndex((s) => s && s.kind === 'chess');
const id = a.shop.slots[slot].id;
if (m.pool.left(id) >= 3) {
m.handle('p_0', { t: 'g.buy', slot });
assert.ok([...a.hand].some((p) => p && p.id === DATA.chess[id].goldenId), '升华: bought operator became elite');
assert.ok(!a.effects.some((e) => e.key === 'effect:builtin_next_buy_elite'), 'consumed');
}
applyCard(m, a, tactic('enemydebuff_select_1'));
const eff = a.effects.find((e) => e.data && e.data.effectId === 'enemydebuff_select_1');
assert.ok(eff && eff.battle, 'battle-side cards become playerEffects');
assert.ok(a.battleInput().playerEffects.some((e) => e.id === eff.id));
checkInvariants(m);
m.dispose();
});
const awaitImport = await import('../../server/match/choices.js');
+243
View File
@@ -0,0 +1,243 @@
// Income, upgrade price math, shop slots, buy/sell, refresh, freeze, ready gating (research 00-INDEX §3, 01 A1).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { ERR, PHASE } from '../../shared/constants.js';
import { GameData } from '../../server/match/gamedata.js';
import { DATA, makeMatch, give, giveItem, checkInvariants, chessOfTier } from './harness.js';
test('income = min(3 + r, 12) in every mode; funds are lost at prep end (band_cannot keeps them)', () => {
for (const modeId of ['mode_single_funny', 'mode_multi_normal', 'mode_multi_abyss']) {
const gd = new GameData(DATA, modeId);
for (let r = 1; r <= 15; r++) assert.equal(gd.income(r), Math.min(3 + r, 12), `${modeId} R${r}`);
}
const h = makeMatch({ mode: 'solo', seed: 2 }).start();
h.toPrep(1);
const ps = h.ps('p_0');
assert.equal(ps.funds, 4, 'R1 income 4');
ps.funds = 3;
h.drive(() => h.m.phase === PHASE.PREP && h.m.round === 2);
assert.equal(ps.funds, 5, 'leftover lost, R2 income 5');
h.m.dispose();
const h2 = makeMatch({ mode: 'solo', seed: 2 }).start();
h2.toPrep(1, { band: 'band_cannot' });
const p2 = h2.ps('p_0');
assert.equal(p2.bandId, 'band_cannot');
p2.funds = 3;
h2.drive(() => h2.m.phase === PHASE.PREP && h2.m.round === 2);
assert.equal(p2.funds, 3 + 5, '坎诺特 carries leftovers (the +1 interest is band content)');
h2.m.dispose();
});
test('upgrade price: base per mode, −1 per round start (floor 0), resets to the next base after a level-up; MAX_LEVEL', () => {
const h = makeMatch({ mode: 'solo', difficulty: 'NORMAL', seed: 4 }).start();
h.toPrep(1);
const m = h.m;
const ps = h.ps('p_0');
const base = m.gd.upgradePrices();
assert.deepEqual(base, [5, 8, 11, 12, 13]);
assert.equal(ps.shop.upgradePrice, 5);
h.drive(() => m.phase === PHASE.PREP && m.round === 2);
assert.equal(ps.shop.upgradePrice, 4, 'R2: 5 − 1');
ps.funds = 100;
assert.deepEqual(m.handle('p_0', { t: 'g.levelUp' }), { ok: true });
assert.equal(ps.funds, 96);
assert.equal(ps.shop.level, 2);
assert.equal(ps.shop.upgradePrice, 8, 'reset to the next base');
for (let i = 0; i < 4; i++) m.handle('p_0', { t: 'g.levelUp' });
assert.equal(ps.shop.level, 6);
assert.deepEqual(m.handle('p_0', { t: 'g.levelUp' }), { error: ERR.MAX_LEVEL });
assert.equal(ps.privateView().shop.upgradePrice, 0);
// floor 0 after many rounds at one level
ps.shop.level = 3;
ps.shop.upgradePrice = 1;
ps.startRound(3);
assert.equal(ps.shop.upgradePrice, 0);
ps.startRound(4);
assert.equal(ps.shop.upgradePrice, 0);
// not enough funds
ps.funds = 0;
ps.shop.upgradePrice = 5;
assert.deepEqual(m.handle('p_0', { t: 'g.levelUp' }), { error: ERR.NO_FUNDS });
assert.deepEqual(new GameData(DATA, 'mode_single_funny').upgradePrices(), [1, 1, 5, 8, 10]);
assert.deepEqual(new GameData(DATA, 'mode_multi_funny').upgradePrices(), [5, 8, 10, 11, 11]);
m.dispose();
});
test('shop slots per level (+ the item slot), buy prices by tier, SOLD_OUT, NO_FUNDS, HAND_FULL', () => {
const h = makeMatch({ mode: 'solo', difficulty: 'NORMAL', seed: 6 }).start();
h.toPrep(1);
const m = h.m;
const ps = h.ps('p_0');
const counts = { 1: 3, 2: 4, 3: 4, 4: 5, 5: 5, 6: 5 };
for (let lv = 1; lv <= 6; lv++) {
ps.shop.level = lv;
ps.rollShop();
const chess = ps.shop.slots.filter((s) => s && s.kind === 'chess');
const items = ps.shop.slots.filter((s) => s && s.kind === 'item');
assert.equal(chess.length, counts[lv], `L${lv} chess slots`);
assert.equal(items.length, 1, `L${lv} item slot`);
for (const s of chess) {
assert.ok(DATA.chess[s.id].tier <= lv);
assert.equal(s.basePrice, { 1: 2, 2: 3, 3: 3, 4: 3, 5: 4, 6: 4 }[DATA.chess[s.id].tier]);
}
for (const s of items) assert.equal(s.basePrice, DATA.items[s.id].price);
}
ps.shop.level = 1;
ps.rollShop();
ps.funds = 1;
assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot: 0 }), { error: ERR.NO_FUNDS });
ps.funds = 50;
const slot0 = ps.shop.slots[0];
const left0 = m.pool.left(slot0.id);
assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot: 0 }), { ok: true });
assert.equal(ps.funds, 48);
assert.equal(m.pool.left(slot0.id), left0 - 1);
assert.equal(ps.hand[9].id, slot0.id, 'hand fills right→left');
assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot: 0 }), { error: ERR.SOLD_OUT });
assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot: 15 }).error, ERR.BAD_TARGET);
// a display is not a reservation: an empty pool makes the buy fail
const s1 = ps.shop.slots[1];
const taken = m.pool.take(s1.id, 100);
assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot: 1 }), { error: ERR.SOLD_OUT });
m.pool.give(s1.id, taken);
// full hand blocks buys
const fillers = chessOfTier(3).filter((x) => m.pool.has(x) && x !== s1.id);
for (let i = 0; ps.hand.some((x) => x == null); i++) give(m, ps, fillers[i]);
if (ps.countCopies(m.gd.baseIdOf(s1.id)) === 0) assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot: 1 }), { error: ERR.HAND_FULL });
const itemSlot = ps.shop.slots.findIndex((s) => s && s.kind === 'item');
assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot: itemSlot }), { error: ERR.HAND_FULL });
checkInvariants(m);
m.dispose();
});
test('refresh: 1 fund, free refreshes first, rerolls every slot; freeze: one toggle, kept through the round start', () => {
const h = makeMatch({ mode: 'solo', difficulty: 'NORMAL', seed: 8 }).start();
h.toPrep(1);
const m = h.m;
const ps = h.ps('p_0');
ps.funds = 10;
m.handle('p_0', { t: 'g.refresh' });
assert.equal(ps.funds, 9);
assert.equal(ps.stats.refreshes, 1);
ps.shop.freeRefreshes = 2;
assert.equal(ps.privateView().shop.refreshPrice, 0);
m.handle('p_0', { t: 'g.refresh' });
assert.equal(ps.funds, 9);
assert.equal(ps.shop.freeRefreshes, 1);
ps.funds = 0;
ps.shop.freeRefreshes = 0;
assert.deepEqual(m.handle('p_0', { t: 'g.refresh' }), { error: ERR.NO_FUNDS });
ps.funds = 20;
// freeze keeps the unsold slots to the next round; sold slots are refilled
m.handle('p_0', { t: 'g.buy', slot: 0 });
assert.deepEqual(m.handle('p_0', { t: 'g.freeze' }), { ok: true });
assert.equal(ps.shop.frozen, true);
assert.ok(ps.shop.slots.every((s) => !s || s.sold || s.frozen));
const keep = ps.shop.slots.slice(1).map((s) => s && s.id);
// unfreeze and freeze again (toggle)
m.handle('p_0', { t: 'g.freeze' });
assert.equal(ps.shop.frozen, false);
m.handle('p_0', { t: 'g.freeze' });
// combat start clears unfrozen slots only; next round start keeps frozen ones and unfreezes
h.drive(() => m.phase === PHASE.PREP && m.round === 2);
assert.deepEqual(ps.shop.slots.slice(1, keep.length + 1).map((s) => s && s.id), keep);
assert.equal(ps.shop.frozen, false, 'the freeze is consumed at the round start');
assert.ok(ps.shop.slots.every((s) => !s || !s.frozen));
// refresh while frozen rerolls everything; the new slots stay frozen until the next round
m.handle('p_0', { t: 'g.freeze' });
ps.funds = 50;
const before = ps.shop.slots.map((s) => s && s.id).join();
let changed = false;
for (let i = 0; i < 5 && !changed; i++) { m.handle('p_0', { t: 'g.refresh' }); changed = ps.shop.slots.map((s) => s && s.id).join() !== before; }
assert.ok(changed, 'refresh rerolls frozen slots');
assert.ok(ps.shop.slots.every((s) => !s || s.frozen));
m.dispose();
});
test('selling: +1 (normal and elite, board or hand), items return to the hand, items and tokens are not sellable', () => {
const h = makeMatch({ mode: 'solo', seed: 9 }).start();
h.toPrep(1);
const m = h.m;
const ps = h.ps('p_0');
ps.funds = 0;
const id = chessOfTier(5).find((x) => m.pool.has(x));
const a = give(m, ps, DATA.chess[id].goldenId);
const it = giveItem(m, ps, 'chess_item_1_02_e_a');
m.handle('p_0', { t: 'g.equip', itemUid: it.uid, targetUid: a.uid });
assert.equal(a.items.length, 1);
assert.deepEqual(m.handle('p_0', { t: 'g.sell', uid: it.uid }).error, ERR.BAD_TARGET);
assert.deepEqual(m.handle('p_0', { t: 'g.sell', uid: a.uid }), { ok: true });
assert.equal(ps.funds, 1);
assert.ok(ps.hand.some((p) => p && p.uid === it.uid), 'equipment returned to the hand');
assert.equal(m.pool.left(id), m.pool.cap(id));
assert.deepEqual(m.handle('p_0', { t: 'g.destroy', uid: it.uid }), { ok: true });
assert.ok(!ps.hand.some((p) => p && p.uid === it.uid));
assert.equal(ps.funds, 1, 'destroying pays nothing');
assert.deepEqual(m.handle('p_0', { t: 'g.sell', uid: 424242 }).error, ERR.BAD_TARGET);
// a board chess carrying equipment is not sold while neither the hand nor the temp slots can take the equipment back
const b = give(m, ps, chessOfTier(4).find((x) => m.pool.has(x)), 'board', [10, 3]);
b.items.push(ps.newPiece('item', 'chess_item_1_01_e_a'), ps.newPiece('item', 'chess_item_3_03_e_a'));
const fill = chessOfTier(1).filter((x) => m.pool.has(x));
for (let i = 0; ps.hand.some((p) => p == null); i++) give(m, ps, fill[i]);
const fill2 = chessOfTier(2).filter((x) => m.pool.has(x));
for (let i = 0; i < 4; i++) give(m, ps, fill2[i], 'temp');
assert.equal(m.handle('p_0', { t: 'g.sell', uid: b.uid }).error, ERR.HAND_FULL, '2 items, 1 free temp slot');
assert.ok(ps.find(b.uid) && b.items.length === 2, 'nothing lost');
ps.resolveTemp();
assert.deepEqual(m.handle('p_0', { t: 'g.sell', uid: b.uid }), { ok: true });
assert.equal(ps.temp.filter(Boolean).length, 2, 'the equipment overflowed into temp');
checkInvariants(m);
m.dispose();
});
test('ready gating: temp must be empty; unready allowed until everyone is ready; actions locked while ready', () => {
const h = makeMatch({ mode: 'coop', humans: 2, seed: 10 }).start();
h.toPrep(1);
const m = h.m;
const a = h.ps('p_0');
const t = give(m, a, chessOfTier(1).find((x) => m.pool.has(x)), 'temp');
assert.equal(a.privateView().canReady, false);
assert.deepEqual(m.handle('p_0', { t: 'g.ready', ready: true }), { error: ERR.TEMP_NOT_EMPTY });
const free = a.hand.findIndex((x) => x == null);
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: t.uid, to: { area: 'hand', idx: free } }), { ok: true });
assert.equal(a.privateView().canReady, true);
assert.deepEqual(m.handle('p_0', { t: 'g.ready', ready: true }), { ok: true });
assert.deepEqual(m.handle('p_0', { t: 'g.refresh' }), { error: ERR.WRONG_PHASE, detail: 'ready' });
assert.deepEqual(m.handle('p_0', { t: 'g.ready', ready: false }), { ok: true });
assert.deepEqual(m.handle('p_0', { t: 'g.refresh' }).ok, true);
m.handle('p_0', { t: 'g.ready', ready: true });
h.sched.advance(10);
assert.equal(m.phase, PHASE.PREP, 'still waiting for p_1');
m.handle('p_1', { t: 'g.ready', ready: true });
h.sched.runNext();
assert.notEqual(m.phase, PHASE.PREP, 'everyone ready → prep ends');
assert.deepEqual(m.handle('p_0', { t: 'g.ready', ready: false }), { error: ERR.WRONG_PHASE });
m.dispose();
});
test('prep deadline (co-op): unready players are auto-readied and their temp is auto-resolved', () => {
const h = makeMatch({ mode: 'coop', humans: 2, seed: 12, fake: true }).start();
h.toPrep(1);
const m = h.m;
const a = h.ps('p_0');
const id = chessOfTier(1).find((x) => m.pool.has(x));
const left = m.pool.left(id);
give(m, a, id, 'temp');
assert.ok(m.deadline > h.sched.now(), 'co-op prep is timed');
assert.equal(Math.round((m.deadline - h.sched.now()) / 1000), m.gd.prepTime(1));
h.sched.advance(m.deadline - h.sched.now() + 1);
assert.notEqual(m.phase, PHASE.PREP);
assert.ok(a.tempEmpty);
assert.equal(m.pool.left(id), left, 'temp chess sold back to the pool');
checkInvariants(m);
m.dispose();
});
test('solo prep and 机变 are untimed', () => {
const h = makeMatch({ mode: 'solo', difficulty: 'NORMAL', seed: 13 }).start();
h.toPrep(1);
assert.equal(h.m.deadline, 0);
h.sched.advance(10 * 60 * 1000);
assert.equal(h.m.phase, PHASE.PREP);
h.m.dispose();
});
+329
View File
@@ -0,0 +1,329 @@
// 4-direction facing on the match side (research 09 §1.2 / §6.1 items 1–4, DESIGN §3): g.move / g.art `dir` (protocol
// validation, default RIGHT), piece.dir stored and kept across rounds, in-place re-orientation, swaps keep each dir,
// effect-placed pieces keep the tile's dir, m.private / battle input / BattleSpec carry dir, 画卷 front by dir, the Final
// Assault mirror, and the bot's direction choice.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { ERR, PHASE } from '../../shared/constants.js';
import { validateC2S, DIRS } from '../../shared/protocol.js';
import { pieceDir, parseDir, tileKey } from '../../server/match/board.js';
import { bossFieldPlacement } from '../../server/match/finalAssault.js';
import { planLayout, rangeTiles, LAYOUT_PARAMS, fieldModel, airflowAtkMul } from '../../server/match/bot.js';
import { buildBattleSpec, createBattleFromSpec } from '../../server/sim/spec.js';
import { makeBattle, chessRec } from '../helpers/battleHarness.js';
import { DATA, makeMatch, give, giveItem, chessOfTier, checkInvariants, legalTileFor } from './harness.js';
const OK = { ok: true };
const MELEE = (c) => c.position === 'MELEE';
function prepMatch(o = {}) {
const h = makeMatch({ mode: 'solo', difficulty: 'NORMAL', seed: o.seed ?? 11, ...o }).start();
h.toPrep(1);
h.setStage('act2autochess_m01');
const ps = h.ps('p_0');
for (const p of [...ps.board.values(), ...ps.hand.filter(Boolean)]) if (p.kind === 'chess') ps.returnCopies(p);
ps.board.clear();
ps.hand.fill(null);
ps.recompute();
return { h, m: h.m, ps };
}
const meleeIds = (m, n) => chessOfTier(1, MELEE).filter((id) => m.pool.has(id) && !DATA.chess[id].tokens?.length).slice(0, n);
test('protocol: g.move / g.art accept an optional dir ∈ UP|RIGHT|DOWN|LEFT and reject anything else', () => {
assert.deepEqual([...DIRS], ['UP', 'RIGHT', 'DOWN', 'LEFT']);
const to = { area: 'board', row: 10, col: 4 };
assert.equal(validateC2S({ t: 'g.move', uid: 5, to }), null, 'dir optional');
for (const d of DIRS) assert.equal(validateC2S({ t: 'g.move', uid: 5, to, dir: d }), null, d);
for (const bad of ['up', 'NORTH', 1, null, {}]) assert.notEqual(validateC2S({ t: 'g.move', uid: 5, to, dir: bad }), null, String(bad));
assert.equal(validateC2S({ t: 'g.art', itemUid: 5, row: 10, col: 4 }), null);
assert.equal(validateC2S({ t: 'g.art', itemUid: 5, row: 10, col: 4, dir: 'DOWN' }), null);
assert.notEqual(validateC2S({ t: 'g.art', itemUid: 5, row: 10, col: 4, dir: 'SIDEWAYS' }), null);
assert.equal(parseDir(undefined), 'RIGHT');
assert.equal(parseDir('LEFT'), 'LEFT');
assert.equal(parseDir('left'), null);
assert.equal(pieceDir({}), 'RIGHT');
});
test('g.move stores the dir (default RIGHT); the view, the battle input and the BattleSpec carry it', () => {
const { m, ps } = prepMatch();
const [a, b] = meleeIds(m, 2).map((id) => give(m, ps, id, 'hand'));
assert.deepEqual(ps.move(a.uid, { area: 'board', row: 9, col: 3 }, 'UP'), OK);
assert.deepEqual(ps.move(b.uid, { area: 'board', row: 9, col: 4 }), OK);
assert.equal(a.dir, 'UP');
assert.equal(pieceDir(b), 'RIGHT', 'absent ⇒ RIGHT');
const view = ps.privateView();
assert.equal(view.board.find((p) => p.uid === a.uid).dir, 'UP');
assert.equal(view.board.find((p) => p.uid === b.uid).dir, 'RIGHT');
assert.equal(view.hand.filter(Boolean).every((p) => p.dir === undefined), true, 'hand pieces have no direction');
const input = ps.battleInput();
assert.equal(input.units.find((u) => u.uid === a.uid).dir, 'UP');
const spec = buildBattleSpec({ kind: 'normal', seed: 3, stageId: m.stageId, players: [input], spawns: [], routes: [], timeLimit: 30 });
assert.equal(spec.players[0].units.find((u) => u.uid === a.uid).dir, 'UP', 'the spec (JSON) keeps dir');
const battle = createBattleFromSpec(spec, DATA);
const unit = battle.allyUnits.find((u) => u.uid === a.uid);
assert.equal(unit.dir, 'UP', 'the spec-built battle deploys it facing UP');
assert.deepEqual(ps.move(b.uid, { area: 'board', row: 9, col: 5 }, 'NORTH'), { error: ERR.BAD_TARGET, detail: 'bad dir' });
assert.ok(ps.board.get(tileKey(9, 4)) === b, 'a rejected move changes nothing');
assert.deepEqual(ps.move(b.uid, { area: 'board', row: 9, col: 4, dir: 'DOWN' }), OK, '`to.dir` is read when the top-level dir is absent');
assert.equal(b.dir, 'DOWN');
checkInvariants(m);
m.dispose();
});
test('g.move onto the own tile re-orients in place; board↔board swaps keep each piece\'s dir; tokens too', () => {
const { m, ps } = prepMatch();
const [a, b] = meleeIds(m, 2).map((id) => give(m, ps, id, 'hand'));
ps.move(a.uid, { area: 'board', row: 9, col: 3 }, 'UP');
ps.move(b.uid, { area: 'board', row: 9, col: 4 }, 'LEFT');
const bonds0 = JSON.stringify(ps.bonds);
assert.deepEqual(ps.move(a.uid, { area: 'board', row: 9, col: 3 }, 'DOWN'), OK, 'same tile, new dir');
assert.equal(a.dir, 'DOWN');
assert.equal(ps.board.get(tileKey(9, 3)), a);
assert.equal(JSON.stringify(ps.bonds), bonds0);
assert.deepEqual(ps.move(a.uid, { area: 'board', row: 9, col: 4 }, 'RIGHT'), OK, 'swap');
assert.equal(ps.board.get(tileKey(9, 4)), a);
assert.equal(a.dir, 'RIGHT', 'the moved piece takes the new dir');
assert.equal(ps.board.get(tileKey(9, 3)), b);
assert.equal(b.dir, 'LEFT', 'the occupant keeps its own dir');
// withdraw-swap: a bench card takes the withdrawn piece's tile with that tile's facing
const c = give(m, ps, meleeIds(m, 3)[2], 'hand');
const idx = ps.hand.indexOf(c);
assert.deepEqual(ps.move(b.uid, { area: 'hand', idx }), OK);
assert.equal(ps.board.get(tileKey(9, 3)), c);
assert.equal(c.dir, 'LEFT');
// placeable summons: a token piece gets its dir, and re-orients in place
const summoner = [1, 2, 3, 4, 5, 6].flatMap((t) => chessOfTier(t)).find((id) => m.gd.placeableTokens(id).length && m.pool.has(id));
assert.ok(summoner, 'a chess with placeable summons exists');
for (const p of [...ps.board.values(), ...ps.hand.filter(Boolean)]) if (p.kind === 'chess') ps.returnCopies(p);
ps.board.clear();
ps.hand.fill(null);
ps.recompute();
const [sr, sc] = legalTileFor(m, ps, summoner);
const s = give(m, ps, summoner, 'hand');
assert.deepEqual(ps.move(s.uid, { area: 'board', row: sr, col: sc }, 'UP'), OK);
const stack = [...ps.hand, ...ps.temp].find((p) => p && p.kind === 'token');
assert.ok(stack, 'its summons joined the hand');
const tokRec = m.gd.token(stack.id);
const free = [...ps.deployMap()].map(([k]) => k.split(',').map(Number)).find(([r, c]) => !ps.board.has(tileKey(r, c)) && ps._legal(stack, r, c));
assert.ok(free, `a legal tile for ${tokRec?.name ?? stack.id}`);
assert.deepEqual(ps.move(stack.uid, { area: 'board', row: free[0], col: free[1] }, 'DOWN'), OK);
const placed = ps.board.get(tileKey(free[0], free[1]));
assert.equal(placed.kind, 'token');
assert.equal(placed.dir, 'DOWN');
assert.deepEqual(ps.move(placed.uid, { area: 'board', row: free[0], col: free[1] }, 'LEFT'), OK, 'token re-oriented in place');
assert.equal(placed.dir, 'LEFT');
assert.equal(ps.battleInput().units.find((u) => u.uid === placed.uid).dir, 'LEFT');
checkInvariants(m);
m.dispose();
});
test('the dir persists across rounds and reaches the round\'s battle', () => {
const { h, m, ps } = prepMatch({ seed: 12 });
const [a] = meleeIds(m, 1).map((id) => give(m, ps, id, 'hand'));
ps.move(a.uid, { area: 'board', row: 9, col: 3 }, 'UP');
const inputs = [];
const newBattle = m.newBattle.bind(m);
m.newBattle = (opts) => { for (const p of opts.players || []) if (p.playerId === 'p_0') inputs.push(p); return newBattle(opts); };
h.toPrep(2);
const now = ps.find(a.uid);
assert.ok(now && now.area === 'board', 'still deployed');
assert.equal(now.piece.dir, 'UP', 'facing kept into the next prep');
const u = inputs.flatMap((p) => p.units).find((x) => x.uid === a.uid);
assert.ok(u, 'the round-1 battle got the piece');
assert.equal(u.dir, 'UP');
m.dispose();
});
test('effect-placed pieces: a transformation / a merge elite taking a consumed copy\'s tile keep that tile\'s dir', () => {
const { m, ps } = prepMatch({ seed: 13 });
const [id, other] = meleeIds(m, 2);
const a = give(m, ps, id, 'board', [9, 3]);
a.dir = 'DOWN';
const np = ps.transformChess(a, other);
assert.ok(np && ps.board.get(tileKey(9, 3)) === np, 'kept the tile');
assert.equal(np.dir, 'DOWN');
// merge with a full hand and temp: the elite takes a consumed copy's freed board tile, with its dir
ps.board.clear();
ps.hand.fill(null);
ps.temp.fill(null);
ps.recompute();
const c1 = give(m, ps, id, 'board', [9, 3]);
const c2 = give(m, ps, id, 'board', [9, 4]);
c1.dir = 'UP';
c2.dir = 'LEFT';
for (let i = 0; i < ps.hand.length; i++) ps.hand[i] = ps.newPiece('item', 'chess_item_3_01_e_a');
for (let i = 0; i < ps.temp.length; i++) ps.temp[i] = ps.newPiece('item', 'chess_item_3_03_e_a');
ps.recompute();
assert.equal(m.gd.mergeCount(id), 3);
const elite = ps.acquireChess(id, { source: 'test' });
assert.ok(elite && m.gd.isGolden(elite.id), 'merged');
const loc = ps.find(elite.uid);
assert.equal(loc.area, 'board');
const was = loc.key === tileKey(9, 3) ? 'UP' : 'LEFT';
assert.equal(elite.dir, was, 'the elite keeps the dir of the tile it took');
m.dispose();
});
test('画卷 (g.art dir): range 1-1 is the drop tile + the tile in front along the chosen direction', () => {
const { m, ps } = prepMatch({ seed: 14 });
const [id] = meleeIds(m, 1);
give(m, ps, id, 'board', [10, 3]);
const art = giveItem(m, ps, 'chess_item_6_02_m');
assert.equal(ps.useArt(art.uid, 9, 3, 'RIGHT').error, ERR.BAD_TARGET, 'RIGHT: (9,3)+(9,4) hold nobody');
assert.deepEqual(ps.useArt(art.uid, 9, 3, 'UP'), OK, 'UP: the tile above (10,3) is copied');
assert.equal(ps.hand.filter((p) => p && p.kind === 'chess' && p.id === id).length, 1, 'a copy in the hand');
const art2 = giveItem(m, ps, 'chess_item_6_02_m');
assert.deepEqual(ps.useArt(art2.uid, 9, 3, 'EAST'), { error: ERR.BAD_TARGET, detail: 'bad dir' });
m.dispose();
});
test('Final Assault mirror: bossFieldPlacement = the sim mapping (col c → 20 − c, RIGHT ↔ LEFT, UP / DOWN kept)', () => {
assert.deepEqual(bossFieldPlacement('R', 10, 4, 'RIGHT'), { row: 3, col: 16, dir: 'LEFT' });
assert.deepEqual(bossFieldPlacement('R', 12, 9, 'UP'), { row: 5, col: 11, dir: 'UP' });
assert.deepEqual(bossFieldPlacement('R', 9, 2, 'LEFT'), { row: 2, col: 18, dir: 'RIGHT' });
assert.deepEqual(bossFieldPlacement('L', 11, 6, 'DOWN'), { row: 4, col: 6, dir: 'DOWN' });
const rec = chessRec({ id: 't_x', rangeGrid: [[0, 0], [0, 1]], skill: null });
const units = [['RIGHT', 10, 4], ['UP', 11, 5], ['LEFT', 12, 6], ['DOWN', 9, 7]].map(([dir, row, col], i) => ({ uid: i + 1, kind: 'chess', chessId: 't_x', row, col, dir }));
const h = makeBattle({ kind: 'boss', defs: { chess: { t_x: rec } }, players: [{ playerId: 'R', seat: 1, side: 'R', colOffset: 8, units }] });
h.step();
for (const u of units) {
const f = bossFieldPlacement('R', u.row, u.col, u.dir);
const unit = h.b.allyUnits.find((x) => x.uid === u.uid);
assert.deepEqual([unit.tileR, unit.tileC, unit.dir], [f.row, f.col, f.dir], `uid ${u.uid}`);
}
});
test('bot: the planner turns a ranged unit toward the enemy path (and keeps RIGHT when every direction is equal)', () => {
const { m, ps } = prepMatch({ seed: 15 });
// a synthetic round: one ground route along row 10 (right → left); the only legal tile is (11,6) off the road
m._botPath = null;
const key = `${m.round}|${m.stageId}|${m.wave ? m.wave.templateId : 'boss'}|`;
const tiles = ['10,10', '10,9', '10,8', '10,7', '10,6', '10,5', '10,4', '10,3'];
const index = new Map(tiles.map((k, i) => [k, [[0, i]]]));
const ground = new Map(tiles.map((k, i) => [k, { flow: 5, prog: i / (tiles.length - 1) }]));
m._botPath = { key, routes: [{ n: 5, fly: false, tiles, tileTime: 2, hp: 3000, dwell: 0 }], index, ground, flyTotal: 0 };
ps._deployMap = new Map([['11,6', 'ranged']]);
const line = { chessId: 't_line', position: 'RANGED', stats: { atk: 500, bat: 1, aspd: 100, blockCnt: 0 }, rangeGrid: [[0, 0], [0, 1], [0, 2], [0, 3]], attackKind: 'ranged', canHitFly: true };
const dot = { ...line, chessId: 't_dot', rangeGrid: [[0, 0], [1, 0], [-1, 0], [0, 1], [0, -1]] };
const recs = { 1: line, 2: dot };
const plan = planLayout(m, ps, [{ uid: 1, kind: 'chess', id: 't_line' }], LAYOUT_PARAMS, { recOf: (p) => recs[p.uid] });
assert.equal(plan.get(1), '11,6');
assert.equal(plan.dirs.get(1), 'DOWN', 'facing DOWN covers the road tile (10,6); RIGHT / UP / LEFT cover nothing');
assert.ok(rangeTiles(line, 11, 6, 'DOWN').includes('10,6') && !rangeTiles(line, 11, 6, 'RIGHT').includes('10,6'));
const sym = planLayout(m, ps, [{ uid: 2, kind: 'chess', id: 't_dot' }], LAYOUT_PARAMS, { recOf: (p) => recs[p.uid] });
assert.equal(sym.dirs.get(2), 'RIGHT', 'a symmetric range keeps RIGHT');
ps.invalidateDeployMap();
m._botPath = null;
m.dispose();
});
test('bot: full preps place every board piece with a valid dir, sent to the battles', () => {
const h = makeMatch({ mode: 'solo', difficulty: 'NORMAL', seats: [{ seat: 0, playerId: 'ai_0', name: 'AI', isBot: true, connected: true }], seed: 21 }).start();
const m = h.m;
const inputs = [];
const newBattle = m.newBattle.bind(m);
m.newBattle = (opts) => { for (const p of opts.players || []) inputs.push(p); return newBattle(opts); };
h.run(() => h.ended != null || (m.phase === PHASE.PREP && m.round === 4));
const ps = m.order[0];
for (const p of ps.board.values()) assert.ok(['UP', 'RIGHT', 'DOWN', 'LEFT'].includes(pieceDir(p)));
const units = inputs.flatMap((p) => p.units || []);
assert.ok(units.length > 0);
for (const u of units) assert.ok(['UP', 'RIGHT', 'DOWN', 'LEFT'].includes(u.dir), `unit ${u.uid} dir ${u.dir}`);
assert.equal(m.errorCount, 0);
checkInvariants(m);
m.dispose();
});
test('bot: 气流 tiles (act2 m01 DOWN blowers) scale the planned DPS by the blower relation — a symmetric range turns WITH the flow', () => {
const { m, ps } = prepMatch({ seed: 15 });
m._botPath = null;
const model = fieldModel(m, ps);
const bb = m.stage.devices.find((d) => d.role === 'blower').skill.bb;
// blower #001 (13,5) blows DOWN over (12,5) (11,5) (10,5) of the own board; #002 over col 9
for (const k of ['12,5', '11,5', '10,5']) assert.equal(model.airflow.get(k)?.dir, 'DOWN', k);
assert.ok(!model.airflow.has('11,4'));
assert.equal(airflowAtkMul(model, '11,5', 'DOWN'), 1 + bb['blower_s_character[equal].atk']);
assert.equal(airflowAtkMul(model, '11,5', 'UP'), 1 + bb['blower_s_character[opposite].atk']);
assert.equal(airflowAtkMul(model, '11,5', 'RIGHT'), 1 + bb['blower_s_character[vertical].atk']);
assert.equal(airflowAtkMul(model, '11,4', 'UP'), 1);
// one route along row 10; the only legal tile is (11,5) under the flow: every direction covers the same tiles
const key = model.key;
const tiles = ['10,10', '10,9', '10,8', '10,7', '10,6', '10,5', '10,4', '10,3'];
m._botPath = {
key, routes: [{ n: 5, fly: false, tiles, tileTime: 2, hp: 3000, dwell: 0 }], index: new Map(tiles.map((k, i) => [k, [[0, i]]])),
ground: new Map(tiles.map((k, i) => [k, { flow: 5, prog: i / (tiles.length - 1) }])), flyTotal: 0, airflow: model.airflow,
};
ps._deployMap = new Map([['11,5', 'ranged']]);
const dot = { chessId: 't_dot', position: 'RANGED', stats: { atk: 500, bat: 1, aspd: 100, blockCnt: 0 }, rangeGrid: [[0, 0], [1, 0], [-1, 0], [0, 1], [0, -1]], attackKind: 'ranged', canHitFly: true };
const plan = planLayout(m, ps, [{ uid: 2, kind: 'chess', id: 't_dot' }], LAYOUT_PARAMS, { recOf: () => dot });
assert.equal(plan.get(2), '11,5');
assert.equal(plan.dirs.get(2), 'DOWN', 'same cover, ATK +30 % with the flow');
// off the flow the same unit keeps RIGHT
m._botPath = { ...m._botPath, airflow: new Map() };
assert.equal(planLayout(m, ps, [{ uid: 2, kind: 'chess', id: 't_dot' }], LAYOUT_PARAMS, { recOf: () => dot }).dirs.get(2), 'RIGHT');
ps.invalidateDeployMap();
m._botPath = null;
m.dispose();
});
test('a refused move never changes the facing (illegal tile, bad dir on the own tile, full board)', () => {
const { m, ps } = prepMatch({ seed: 13 });
const [a, b] = meleeIds(m, 2).map((id) => give(m, ps, id, 'hand'));
assert.deepEqual(ps.move(a.uid, { area: 'board', row: 9, col: 3 }, 'UP'), OK);
// not deployable (col 9 is the lane) → BAD_TILE, the piece stays where it was, still facing UP
const bad = [...Array(4)].map((_, i) => [9 + i, 9]).find(([r, c]) => !ps._legal(a, r, c));
assert.ok(bad, 'an illegal tile exists');
assert.equal(ps.move(a.uid, { area: 'board', row: bad[0], col: bad[1] }, 'DOWN').error, ERR.BAD_TILE);
assert.equal(a.dir, 'UP');
assert.equal(ps.board.get(tileKey(9, 3)), a);
// a bad direction on the own tile is refused too (no silent reset)
assert.equal(ps.move(a.uid, { area: 'board', row: 9, col: 3 }, 'up').error, ERR.BAD_TARGET);
assert.equal(a.dir, 'UP');
// full board: a hand piece dropped on a free tile is refused, the board piece keeps its facing
ps.deployCapBonus = -99;
assert.equal(ps.move(b.uid, { area: 'board', row: 9, col: 4 }, 'LEFT').error, ERR.BOARD_FULL);
assert.equal(b.dir, undefined, 'the refused piece got no facing');
ps.deployCapBonus = 0;
checkInvariants(m);
m.dispose();
});
// Integration (was a todo tripwire): Match._handle forwards the intent's top-level `dir` to PlayerState.move / useArt,
// so the deploy wheel's direction reaches the server (the browser E2E checks the same end to end).
test('g.move / g.art through Match.handle apply the top-level dir (and to.dir as a fallback)', () => {
const { m, ps } = prepMatch({ seed: 14 });
const [a, b, c] = meleeIds(m, 3).map((id) => give(m, ps, id, 'hand'));
// three melee-legal tiles: T1, T2, and a vertical pair (lo, hi = the tile above lo) for 画卷
const legal = [];
for (let r = 9; r <= 12; r++) for (let col = 2; col <= 10; col++) if (ps._legal(a, r, col)) legal.push([r, col]);
const lo = legal.find(([r, col]) => legal.some(([r2, c2]) => r2 === r + 1 && c2 === col));
assert.ok(lo, 'a vertical pair of melee tiles exists');
const hi = [lo[0] + 1, lo[1]];
const [t1, t2] = legal.filter(([r, col]) => !(col === lo[1] && (r === lo[0] || r === hi[0])));
const at = ([row, col]) => ({ area: 'board', row, col });
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: a.uid, to: at(t1), dir: 'UP' }), OK);
assert.equal(a.dir, 'UP', 'the wheel direction reaches the board piece');
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: a.uid, to: at(t1), dir: 'LEFT' }), OK);
assert.equal(a.dir, 'LEFT', 're-orientation in place through the match');
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: b.uid, to: { ...at(t2), dir: 'DOWN' } }), OK);
assert.equal(b.dir, 'DOWN', 'to.dir is read when the top-level dir is absent');
assert.equal(m.handle('p_0', { t: 'g.move', uid: b.uid, to: at(t2), dir: 'down' }).error, ERR.BAD_TARGET, 'a bad spelling is refused (the protocol layer refuses it earlier)');
assert.equal(b.dir, 'DOWN');
// 画卷 through the match: UP copies the unit above the drop tile (RIGHT finds nobody)
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: c.uid, to: at(hi) }), OK);
const art = giveItem(m, ps, 'chess_item_6_02_m');
const right = ps.board.get(tileKey(lo[0], lo[1] + 1));
if (!right) assert.equal(m.handle('p_0', { t: 'g.art', itemUid: art.uid, row: lo[0], col: lo[1], dir: 'RIGHT' }).error, ERR.BAD_TARGET);
const before = ps.hand.filter((p) => p && p.kind === 'chess' && p.id === c.id).length;
assert.deepEqual(m.handle('p_0', { t: 'g.art', itemUid: art.uid, row: lo[0], col: lo[1], dir: 'UP' }), OK);
assert.equal(ps.hand.filter((p) => p && p.kind === 'chess' && p.id === c.id).length, before + 1, 'the copy is in the hand');
// scouting a teammate's prep board (m.field prep: true) carries each piece's dir (ground wedge, Back model for UP)
const meta = m.prepFieldMeta(ps);
const byUid = new Map(meta.units.map((u) => [u.uid, u]));
assert.equal(byUid.get(a.uid).dir, 'LEFT');
assert.equal(byUid.get(a.uid).facing, -1);
assert.equal(byUid.get(b.uid).dir, 'DOWN');
assert.equal(byUid.get(b.uid).facing, 1);
assert.equal(byUid.get(c.uid).dir, 'RIGHT', 'absent ⇒ RIGHT');
m.dispose();
});
+160
View File
@@ -0,0 +1,160 @@
// test/match/fakeBattle.js — a scriptable stand-in for server/sim/Battle.js implementing the DESIGN §5.1 surface the
// match uses: constructor(opts), step(), finished, time, tickCount, forceEnd(reason), result(), snapshot(),
// drainEvents(), fieldMeta(), on(name, fn, opts) / off(), errorCount.
//
// Behaviour is set per construction by FakeBattle.script(battle) → plan (or the default plan):
// {
// duration: game seconds until the battle finishes by itself (default: min(timeLimit, 8); boss: never — the
// shared pool or the match ends it),
// leaks: { [playerId]: n } counted leaks recorded for that player (normal fields; taken from its spawns),
// survivors: { [sourcePlayerId]: n } unite: re-entered enemies of that source still alive at the end,
// coins: { [playerId]: n }, layerGains: { [playerId]: { bondId: n } }, damage: { [playerId]: n },
// bossDps: shared-pool damage per game second (split evenly between the field's players; default 0),
// leakEvents: [{ at: seconds, lpr }] boss fields: 'enemyLeak' hook calls at those times,
// lpLossEvents: [{ at: seconds, amount }] 'lpLoss' hook calls (leader "扣除目标生命" effects) at those times,
// deadUids: [uid] operators reported dead in unitsEnd (alive false, hpPct 0),
// throwAt: tick number at which step() throws (error-isolation tests),
// throwInCtor: true → the constructor throws,
// }
// Every constructed instance is recorded in FakeBattle.instances (tests inspect the inputs the match built).
import { TICK } from '../../server/sim/constants.js';
export class FakeBattle {
static script = null;
static instances = [];
static reset() { FakeBattle.script = null; FakeBattle.instances = []; }
constructor(opts = {}) {
this.opts = opts;
this.kind = opts.kind ?? 'normal';
this.fieldId = opts.fieldId ?? null;
this.round = opts.round ?? 0;
this.players = (opts.players ?? []).map((p) => p.playerId);
this.spawns = opts.spawns ?? [];
this.sharedBoss = opts.sharedBoss ?? null;
this.timeLimit = Number.isFinite(opts.timeLimit) && opts.timeLimit > 0 ? opts.timeLimit : Infinity;
this.time = 0;
this.tickCount = 0;
this.finished = false;
this.reason = null;
this.errorCount = 0;
this._hooks = {};
this._events = [];
const plan = typeof FakeBattle.script === 'function' ? FakeBattle.script(this) : null;
const bossLike = this.kind === 'boss' || this.kind === 'hidden';
this.plan = { duration: bossLike ? Infinity : Math.min(this.timeLimit, 8), leaks: {}, survivors: {}, coins: {}, layerGains: {}, damage: {}, bossDps: 0, leakEvents: [], lpLossEvents: [], deadUids: [], ...(plan || {}) };
if (this.plan.throwInCtor) throw new Error('FakeBattle: constructor failure (scripted)');
this._leakIdx = 0;
this._lpLossIdx = 0;
FakeBattle.instances.push(this);
}
on(name, fn) {
(this._hooks[name] ||= []).push(fn);
return { name, fn };
}
off() {}
_emit(name, ctx) { for (const fn of this._hooks[name] || []) fn(ctx, this); }
step() {
if (this.finished) return;
if (this.plan.throwAt != null && this.tickCount + 1 >= this.plan.throwAt) throw new Error('FakeBattle: step failure (scripted)');
this.tickCount++;
this.time = this.tickCount * TICK;
if (this.tickCount % 3 === 0) this._events.push(['fx', 'tick', 0, 0, { t: this.time }]);
if (this.sharedBoss && this.plan.bossDps > 0) {
const per = (this.plan.bossDps * TICK) / Math.max(1, this.players.length);
for (const pid of this.players) this.sharedBoss.damage(pid, per);
}
while (this._leakIdx < this.plan.leakEvents.length && this.plan.leakEvents[this._leakIdx].at <= this.time) {
const le = this.plan.leakEvents[this._leakIdx++];
this._emit('enemyLeak', { enemy: { lpr: le.lpr ?? 1, defId: le.enemyKey ?? 'enemy_fake', isBoss: !!le.boss } });
}
while (this._lpLossIdx < this.plan.lpLossEvents.length && this.plan.lpLossEvents[this._lpLossIdx].at <= this.time) {
const le = this.plan.lpLossEvents[this._lpLossIdx++];
this._emit('lpLoss', { amount: le.amount, reason: 'fake', source: null });
}
if (this.sharedBoss && this.sharedBoss.hp <= 0) { this._finish('cleared'); return; }
if (this.time >= this.plan.duration - 1e-9) this._finish(this._leakTotal() > 0 ? 'timeout' : 'cleared');
else if (this.time >= this.timeLimit - 1e-9) this._finish('timeout');
}
_leakTotal() {
let n = 0;
for (const v of Object.values(this.plan.leaks || {})) n += v;
for (const v of Object.values(this.plan.survivors || {})) n += v;
return n;
}
forceEnd(reason = 'forced') {
if (this.finished) return;
this._finish(reason === 'timeout' ? 'timeout' : 'forced');
}
_finish(reason) {
this.finished = true;
this.reason = reason;
}
result() {
const perPlayer = {};
for (const pid of this.players) {
const spawnsOf = this.spawns.filter((s) => (s.ownerPlayerId ?? pid) === pid);
let total = 0;
for (const s of this.kind === 'unite' ? this.spawns : spawnsOf) total += Math.max(1, s.count || 1);
if (this.kind === 'unite' && this.players.indexOf(pid) > 0) total = 0;
const leaked = [];
const n = this.plan.leaks[pid] || 0;
for (let i = 0; i < n; i++) {
const s = spawnsOf[i % Math.max(1, spawnsOf.length)] || { enemyKey: 'enemy_1007_slime', mods: { slot: 'N' } };
leaked.push({ enemyKey: s.enemyKey, mods: s.mods ?? null, lpr: 1, sourcePlayerId: pid, tag: s.tag ?? null, counted: true, spawned: true });
}
if (this.kind === 'unite' && this.players.indexOf(pid) === 0) {
for (const [src, k] of Object.entries(this.plan.survivors || {})) {
const pool = this.spawns.filter((s) => s.sourcePlayerId === src);
for (let i = 0; i < k; i++) {
const s = pool[i % Math.max(1, pool.length)] || { enemyKey: 'enemy_1007_slime', mods: null };
leaked.push({ enemyKey: s.enemyKey, mods: s.mods ?? null, lpr: 1, sourcePlayerId: src, tag: null, counted: true, spawned: true });
}
}
}
const units = (this.opts.players.find((p) => p.playerId === pid)?.units ?? []).filter((u) => u.kind === 'chess');
perPlayer[pid] = {
killed: Math.max(0, total - leaked.length),
total,
leaked,
perfect: !leaked.some((l) => l.counted !== false),
layerGains: { ...(this.plan.layerGains[pid] || {}) },
coins: this.plan.coins[pid] || 0,
damageDealt: this.plan.damage[pid] || 1000,
bossDamage: 0,
healingDone: 0,
deaths: 0,
unitsEnd: units.map((u) => {
const dead = this.plan.deadUids.includes(u.uid);
return { uid: u.uid, id: u.uid, defId: u.chessId, hpPct: dead ? 0 : 0.5, sp: dead ? 0 : 3, skillActive: false, alive: !dead };
}),
unitStats: units.map((u) => ({ id: u.uid, uid: u.uid, defId: u.chessId, name: u.chessId, kind: 'op', dmg: 100, kills: 1, heal: 0, taken: 10, attacks: 5 })),
};
}
const res = { time: this.time, reason: this.reason ?? 'forced', perPlayer, killed: 0, total: 0, errors: this.errorCount };
if (this.sharedBoss) res.bossHpLeft = Math.max(0, this.sharedBoss.hp);
return res;
}
snapshot() {
const snap = { fieldId: this.fieldId, t: this.time, units: [[1, 5, 10, 100, 100, 0, 10, 0, 0]], dp: 10, killed: 0, total: this.spawns.length };
if (this.sharedBoss) snap.boss = { hp: Math.round(this.sharedBoss.hp), max: Math.round(this.sharedBoss.maxHp) };
return snap;
}
drainEvents() { const e = this._events; this._events = []; return e; }
fieldMeta() {
return { fieldId: this.fieldId, kind: this.kind, rect: this.opts.rect ?? null, stageId: this.opts.stageId ?? null, units: [] };
}
}
+220
View File
@@ -0,0 +1,220 @@
// 最终攻势 / 隐秘核心: pairing, merged LP, shared boss pool, overtime, leaks, hidden-core condition (FakeBattle).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { PHASE, GEO } from '../../shared/constants.js';
import { pairPlayers, bossPoolHp, SharedBossPool, hiddenEligible } from '../../server/match/finalAssault.js';
import { GameData } from '../../server/match/gamedata.js';
import { FakeBattle } from './fakeBattle.js';
import { DATA, makeMatch, checkInvariants } from './harness.js';
const bossFields = () => FakeBattle.instances.filter((b) => b.kind === 'boss' || b.kind === 'hidden');
test('pairing by seat: (1,2), (3,4); an odd player alone', () => {
const p = (seat) => ({ seat, playerId: `s${seat}` });
assert.deepEqual(pairPlayers([p(0)]).map((g) => g.map((x) => x.seat)), [[0]]);
assert.deepEqual(pairPlayers([p(2), p(0)]).map((g) => g.map((x) => x.seat)), [[0, 2]]);
assert.deepEqual(pairPlayers([p(3), p(1), p(0)]).map((g) => g.map((x) => x.seat)), [[0, 1], [3]]);
assert.deepEqual(pairPlayers([p(3), p(1), p(0), p(2)]).map((g) => g.map((x) => x.seat)), [[0, 1], [2, 3]]);
});
test('boss pool = bloodPoint[difficulty] in co-op whatever the alive count (solo × 0.25) × tuning; shared and never negative', () => {
// research numbers (data/tuning.json left out); research 08 §6 #7: "敌方领袖的总生命值不变" — no alive-player factor
const { tuning, ...RAW } = DATA; // eslint-disable-line no-unused-vars
const gd = new GameData(RAW, 'mode_multi_hard');
assert.equal(bossPoolHp(gd, 'boss_1', 4), 1800000);
assert.equal(bossPoolHp(gd, 'boss_1', 2), 1800000);
assert.equal(bossPoolHp(gd, 'boss_1', 1), 1800000);
assert.equal(bossPoolHp(new GameData(RAW, 'mode_single_abyss'), 'boss_5', 1), 750000);
assert.equal(bossPoolHp(new GameData(RAW, 'mode_single_funny'), 'boss_2', 1), 56250);
// the balance layer multiplies the pool (docs/BALANCE.md)
for (const modeId of ['mode_single_funny', 'mode_multi_hard']) {
const tuned = new GameData(DATA, modeId);
const raw = new GameData(RAW, modeId);
assert.equal(bossPoolHp(tuned, 'boss_2', 4), Math.max(1, Math.round(bossPoolHp(raw, 'boss_2', 4) * tuned.bossHpMul('boss_2'))));
}
const pool = new SharedBossPool(100);
assert.equal(pool.damage('a', 60), 60);
assert.equal(pool.damage('b', 60), 40);
assert.equal(pool.hp, 0);
assert.equal(pool.damage('a', 5), 0);
assert.equal(pool.damage('a', NaN), 0);
assert.equal(pool.byPlayer.get('a'), 60);
});
for (const n of [1, 2, 3, 4]) {
test(`Final Assault with ${n} player(s): fields, sides, templates, merged LP, shared pool, victory`, () => {
const h = makeMatch({ mode: 'coop', difficulty: 'FUNNY', humans: n, seed: 40 + n, fake: true, script: (b) => (b.kind === 'boss' ? { bossDps: 1e9 } : {}) }).start();
const m = h.m;
h.drive(() => m.phase === PHASE.PREP && m.round === 14);
const lps = [...m.players.values()].map((p) => p.lp);
h.drive(() => m.phase === PHASE.FINAL_ASSAULT);
assert.equal(m.teamLp, lps.reduce((a, b) => a + b, 0), 'merged team LP');
const fields = bossFields();
assert.equal(fields.length, Math.ceil(n / 2));
assert.deepEqual(fields.map((f) => f.fieldId), Math.ceil(n / 2) === 2 ? ['b1', 'b2'] : ['b1']);
const pool = fields[0].sharedBoss;
assert.ok(fields.every((f) => f.sharedBoss === pool), 'one pool for every boss field');
assert.equal(pool.maxHp, bossPoolHp(m.gd, m.bossId, n));
for (const f of fields) {
assert.deepEqual(f.opts.rect, GEO.BOSS_RECT);
assert.equal(f.opts.timeLimit, Infinity);
assert.equal(f.opts.flags.layerGainsEnabled, false);
assert.ok(f.opts.players.every((p) => p.lpForBoss === m.teamLp));
if (f.opts.players.length === 2) {
assert.deepEqual(f.opts.players.map((p) => p.side), ['L', 'R']);
assert.ok(!/_s$/.test(f.opts.waveId), `pair uses the multi template (${f.opts.waveId})`);
} else {
assert.deepEqual(f.opts.players.map((p) => p.side), ['L']);
assert.ok(/_s$/.test(f.opts.waveId), `a lone player uses the _s template (${f.opts.waveId})`);
}
assert.ok(f.opts.spawns.some((s) => s.tag === 'boss'), 'the boss spawns');
const boss = f.opts.spawns.find((s) => s.tag === 'boss');
assert.equal(boss.mods.hpMul, undefined, 'bosses are never scaled');
}
const pub = m.publicView();
assert.equal(pub.teamLp, m.teamLp);
assert.deepEqual(pub.bossHp, { hp: Math.round(pool.hp), max: Math.round(pool.maxHp) });
const end = h.runToEnd();
assert.equal(end.victory, true);
assert.equal(end.roundsPassed, 14);
assert.equal(end.hiddenReached, false, 'FUNNY has no hidden core');
assert.equal(h.endedCount, 1);
for (const p of end.players) assert.equal(p.roundsPassed, 14);
const res = h.lastTo('p_0', 'm.result');
assert.equal(res.playerId, 'p_0');
assert.equal(res.victory, true);
checkInvariants(m);
m.dispose();
});
}
test('overtime: −1 team LP per REAL second after 150 real s (300 game s at 2×); team LP 0 ends every field → defeat (13 rounds passed)', () => {
const h = makeMatch({ mode: 'coop', difficulty: 'FUNNY', humans: 2, seed: 50, fake: true, script: (b) => (b.kind === 'boss' ? { bossDps: 1 } : {}) }).start();
const m = h.m;
h.drive(() => m.phase === PHASE.PREP && m.round === 14);
for (const p of m.players.values()) p.lp = 10;
h.drive(() => m.phase === PHASE.FINAL_ASSAULT);
assert.equal(m.teamLp, 20);
const f = bossFields()[0];
const end = h.runToEnd();
assert.equal(end.victory, false);
assert.equal(end.roundsPassed, 13);
assert.ok(Math.abs(f.time - 340) < 2.5, `ended ≈ 300 + 2 × 20 game s (${f.time})`);
assert.equal(m.teamLp, 0);
m.dispose();
});
test('Final Assault leaks cost their lpr from the merged LP', () => {
const h = makeMatch({ mode: 'solo', difficulty: 'FUNNY', seed: 51, fake: true, instant: false, script: (b) => (b.kind === 'boss' ? { bossDps: 1, leakEvents: [{ at: 5, lpr: 1 }, { at: 10, lpr: 30, boss: true }] } : {}) }).start();
const m = h.m;
assert.equal(m.gd.bossRound, 9, 'solo FUNNY: boss at R9');
h.drive(() => m.phase === PHASE.PREP && m.round === 9);
h.ps('p_0').lp = 40;
h.drive(() => m.phase === PHASE.FINAL_ASSAULT);
assert.ok(/_s$/.test(bossFields()[0].opts.waveId));
h.run(() => bossFields()[0].time >= 6);
assert.equal(m.teamLp, 39);
h.run(() => bossFields()[0].time >= 11);
assert.equal(m.teamLp, 9);
const end = h.runToEnd();
assert.equal(end.victory, false);
assert.equal(end.roundsPassed, 8);
m.dispose();
});
test('Hidden Core: Σ activated layers (end of the boss prep) > 1200 and team LP > 1 after a win → R15 prep → HIDDEN_CORE', () => {
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 2, seed: 52, fake: true, script: (b) => (b.kind === 'boss' || b.kind === 'hidden' ? { bossDps: 1e9 } : {}) }).start();
const m = h.m;
assert.equal(m.gd.hiddenRound, 15);
assert.ok(m.hiddenBossId && ['boss_8', 'boss_9', 'boss_10'].includes(m.hiddenBossId));
h.drive(() => m.phase === PHASE.PREP && m.round === 14);
for (const p of m.players.values()) { p.bondCountBonus.yanShip = 3; p.layers.yanShip = 601; p.recompute(); }
h.drive(() => m.phase === PHASE.FINAL_ASSAULT);
assert.equal(m.hiddenLayerSum, 1202);
h.drive(() => m.phase === PHASE.PREP && m.round === 15);
assert.equal(m.phase, PHASE.PREP);
assert.ok(m.deadline > 0, 'R15 prep is timed in co-op');
assert.equal(Math.round((m.deadline - h.sched.now()) / 1000), m.gd.prepTime(15));
const lp = m.teamLp;
h.drive(() => m.phase === PHASE.HIDDEN_CORE);
const f = bossFields().find((b) => b.kind === 'hidden');
assert.ok(f, 'hidden field');
assert.equal(f.opts.bossId, m.hiddenBossId);
assert.equal(m.teamLp, lp, 'team LP carries over');
const end = h.runToEnd();
assert.equal(end.victory, true);
assert.equal(end.hiddenReached, true);
assert.equal(end.hiddenCleared, true);
assert.equal(end.roundsPassed, 15);
m.dispose();
});
test('Hidden Core is skipped at Σ layers ≤ threshold, on FUNNY, or with team LP ≤ 1; a failed hidden core keeps the clear', () => {
const gd = new GameData(DATA, 'mode_multi_normal');
assert.equal(hiddenEligible(gd, { layerSum: 1200, teamLp: 50 }), false);
assert.equal(hiddenEligible(gd, { layerSum: 1201, teamLp: 50 }), true);
assert.equal(hiddenEligible(gd, { layerSum: 5000, teamLp: 1 }), false);
assert.equal(hiddenEligible(new GameData(DATA, 'mode_single_hard'), { layerSum: 351, teamLp: 2 }), true);
assert.equal(hiddenEligible(new GameData(DATA, 'mode_single_hard'), { layerSum: 350, teamLp: 2 }), false);
assert.equal(hiddenEligible(new GameData(DATA, 'mode_multi_funny'), { layerSum: 9999, teamLp: 99 }), false);
// failed hidden core → still a victory
const h = makeMatch({ mode: 'solo', difficulty: 'HARD', seed: 53, fake: true, script: (b) => (b.kind === 'boss' ? { bossDps: 1e9 } : b.kind === 'hidden' ? { bossDps: 0 } : {}) }).start();
const m = h.m;
h.drive(() => m.phase === PHASE.PREP && m.round === 14);
const ps = h.ps('p_0');
ps.bondCountBonus.yanShip = 3; ps.layers.yanShip = 400; ps.lp = 5; ps.recompute();
h.drive(() => h.ended != null);
const end = h.ended;
assert.equal(end.hiddenReached, true);
assert.equal(end.hiddenCleared, false);
assert.equal(end.victory, true);
assert.equal(end.roundsPassed, 14);
m.dispose();
});
test('Final Assault: a fighting player may only watch its own boss field; eliminated players spectate any field', () => {
const h = makeMatch({ mode: 'coop', difficulty: 'FUNNY', humans: 4, seed: 54, fake: true, instant: false, script: (b) => (b.kind === 'boss' ? { bossDps: 1 } : {}) }).start();
const m = h.m;
h.drive(() => m.phase === PHASE.PREP && m.round === 14);
h.ps('p_3').eliminate(13);
h.drive(() => m.phase === PHASE.FINAL_ASSAULT);
assert.deepEqual(m.fields.map((f) => [f.fieldId, f.players]), [['b1', ['p_0', 'p_1']], ['b2', ['p_2']]]);
assert.equal(m.handle('p_0', { t: 'g.watch', fieldId: 'b2' }).error, 'BAD_TARGET', 'the other group is hidden');
assert.equal(m.handle('p_2', { t: 'g.watch', fieldId: 'b1' }).error, 'BAD_TARGET');
assert.deepEqual(m.handle('p_1', { t: 'g.watch', fieldId: 'b1' }), { ok: true });
assert.deepEqual(m.handle('p_3', { t: 'g.watch', fieldId: 'b2' }), { ok: true }, 'an eliminated player spectates freely');
assert.deepEqual(m.handle('p_3', { t: 'g.watch', fieldId: 'b1' }), { ok: true });
assert.equal(m.watchers.get('p_0'), 'b1', 'the refused watch keeps the default (own) field');
// the prep-scouting id of a player of the other group is no way around the rule, and never detaches the viewer
const scouted = h.allTo('p_0', 'm.field').length;
assert.deepEqual(m.handle('p_0', { t: 'g.watch', fieldId: 'n:p_2' }), { error: 'BAD_TARGET', detail: 'no such field' });
assert.deepEqual(m.handle('p_0', { t: 'g.watch', fieldId: 'n:p_1' }), { error: 'BAD_TARGET', detail: 'no such field' });
assert.equal(h.allTo('p_0', 'm.field').length, scouted, 'no board of the other group was sent');
assert.equal(m.watchers.get('p_0'), 'b1', 'still streaming the own boss field');
const snaps = h.allTo('p_0', 'b.snap').length;
h.sched.advance(500);
assert.ok(h.allTo('p_0', 'b.snap').length > snaps, 'b.snap of b1 keeps coming');
m.dispose();
});
test('Final Assault: stats reach m.private at the end; leader "扣除目标生命" effects (lpLoss) hit the team LP', () => {
const h = makeMatch({
mode: 'coop', difficulty: 'FUNNY', humans: 2, seed: 56, fake: true,
script: (b) => (b.kind === 'boss' ? { bossDps: b.sharedBoss.maxHp / 10, damage: { p_0: 5000, p_1: 7000 }, lpLossEvents: [{ at: 2, amount: 7 }] } : {}),
}).start();
const m = h.m;
h.autoHumans();
h.drive(() => m.phase === PHASE.PREP && m.round === 14);
h.ps('p_0').lp = 20;
h.ps('p_1').lp = 20;
const end = h.runToEnd();
assert.equal(end.victory, true);
assert.equal(m.teamLp, 40 - 7, 'lpLoss drains the merged pool');
for (const id of ['p_0', 'p_1']) {
const ps = h.ps(id);
const last = h.lastTo(id, 'm.private');
assert.deepEqual(last.stats, ps.privateView().stats, `${id}: the last m.private carries the boss-field damage / kills`);
assert.equal(last.lp, ps.lp);
}
m.dispose();
});
+192
View File
@@ -0,0 +1,192 @@
// Match follow-ups: multi-round bounties in the Final Assault / Hidden Core, 驰援 cards of fully banned bonds,
// 坚若磐石 = least LP lost, combat time limits in real seconds (× the forced 2× speed), boss-round layout model.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { PHASE } from '../../shared/constants.js';
import { GameData, COMBAT_TIME_SCALE } from '../../server/match/gamedata.js';
import { generateDraft, reinforcementBond, cardTargetBonds } from '../../server/match/choices.js';
import { assignTitles } from '../../server/match/results.js';
import { routeByMotion } from '../../server/match/waves.js';
import { fieldModel } from '../../server/match/bot.js';
import { FIELD, parseKey } from '../../server/match/board.js';
import { createRng } from '../../server/sim/rng.js';
import { FakeBattle } from './fakeBattle.js';
import { DATA, makeMatch, checkInvariants } from './harness.js';
const bossFields = () => FakeBattle.instances.filter((b) => b.kind === 'boss' || b.kind === 'hidden');
/** A multi-round ("之后的每场作战") kill bounty whose enemy is active in the mode. */
function everyBattleBounty(gd) {
const c = DATA.choices.cards.bounty.find((x) => x.rounds >= 90 && x.payout === 'kill' && DATA.enemies[x.enemyKey] && !gd.inactiveEnemies.has(x.enemyKey));
assert.ok(c, 'data has a multi-round kill bounty');
return c;
}
test('multi-round bounties spawn in the Final Assault on the owner\'s half, show in the boss preview, then use up a battle', () => {
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 2, seed: 61, fake: true, script: (b) => (b.kind === 'boss' ? { bossDps: 1e9, coins: { p_0: 3 } } : {}) }).start();
const m = h.m;
h.drive(() => m.phase === PHASE.PREP && m.round === 14);
const card = everyBattleBounty(m.gd);
const [p0, p1] = [h.ps('p_0'), h.ps('p_1')];
m.addBounty(p0, card);
m.addBounty(p1, card);
assert.ok(p0.privateView().nextEnemies.some((e) => e.enemyKey === card.enemyKey && e.tag === 'bounty'), 'boss-round preview lists the bounty');
assert.ok(p0.privateView().nextEnemies.some((e) => e.tag === 'boss'));
h.drive(() => m.phase === PHASE.FINAL_ASSAULT);
const f = bossFields()[0];
const bs = f.opts.spawns.filter((s) => s.tag === 'bounty');
assert.equal(bs.length, 2, 'one bounty spawn per player of the pair');
const fly = DATA.enemies[card.enemyKey].stats.motion === 'FLY';
for (const s of bs) {
const rt = f.opts.routes[s.routeIndex];
assert.ok(rt, 'a valid route');
assert.equal(rt.motion === 'FLY', fly, 'route motion matches the enemy');
assert.equal(s.count, card.count);
assert.ok(s.mods.hpMul > 0, 'bounty enemies take the round\'s multipliers');
if (s.ownerPlayerId === 'p_0') assert.ok(rt.end[1] < 10, `left player's bounty goes to the left goal (${rt.end})`);
else { assert.equal(s.ownerPlayerId, 'p_1'); assert.ok(rt.end[1] > 10, `right player's bounty goes to the right goal (${rt.end})`); }
}
const pending = p0.pendingFunds;
const end = h.runToEnd();
assert.equal(end.victory, true);
assert.equal(p0.bounties[0].roundsLeft, card.rounds - 1, 'the Final Assault used one of the bounty\'s battles');
assert.equal(p0.pendingFunds, pending + 3, 'kill-bounty coins of the boss field are credited (spent in the Hidden Core prep)');
checkInvariants(m);
m.dispose();
});
test('solo Final Assault: the bounty spawns on the `_s` template; bountySpawns route choice by side', () => {
const h = makeMatch({ mode: 'solo', difficulty: 'HARD', seed: 62, fake: true, script: (b) => (b.kind === 'boss' ? { bossDps: 1e9 } : {}) }).start();
const m = h.m;
h.drive(() => m.phase === PHASE.PREP && m.round === 14);
m.addBounty(h.ps('p_0'), everyBattleBounty(m.gd));
h.drive(() => m.phase === PHASE.FINAL_ASSAULT);
const f = bossFields()[0];
assert.ok(/_s$/.test(f.opts.waveId));
assert.equal(f.opts.spawns.filter((s) => s.tag === 'bounty' && s.ownerPlayerId === 'p_0').length, 1);
m.dispose();
const routes = [{ motion: 'WALK', end: [1, 17] }, { motion: 'WALK', end: [1, 3] }, { motion: 'FLY', end: [1, 17] }, { motion: 'FLY', end: [1, 3] }];
assert.equal(routeByMotion(routes, false), 0);
assert.equal(routeByMotion(routes, false, 'L'), 1);
assert.equal(routeByMotion(routes, false, 'R'), 0);
assert.equal(routeByMotion(routes, true, 'L'), 3);
assert.equal(routeByMotion(routes, true, 'R'), 2);
assert.equal(routeByMotion([{ motion: 'WALK', end: [9, 2] }], false, 'R'), 0, 'falls back to any route of the motion');
});
test('驰援 cards: a bond without chess in this match\'s pool is never offered', () => {
const gd = new GameData(DATA, 'mode_multi_funny');
assert.equal(reinforcementBond(gd, 'allybuff_select_7_1'), 'yanShip');
assert.equal(reinforcementBond(gd, 'allybuff_select_11'), null);
const offered = (bondAvailable) => {
const seen = new Set();
for (let s = 1; s <= 300; s++) {
const d = generateDraft(gd, createRng(s), 9, { bondAvailable });
if (d && d.family === 'tactic') for (const c of d.cards) seen.add(c.id);
}
return seen;
};
const all = offered(null);
assert.ok(all.has('allybuff_select_7_1'), 'sanity: 炎盟约驰援 is offered normally');
const filtered = offered((b) => b !== 'yanShip');
assert.ok(!filtered.has('allybuff_select_7_1'), '炎盟约驰援 filtered');
assert.ok(filtered.has('allybuff_select_7_3') || filtered.has('allybuff_select_7_2'), 'other 驰援 cards stay');
// Match wiring: bondInPool follows the pool (banned chess are never in it)
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 1, seed: 63, fake: true });
const m = h.m;
for (const id of m.gd.bondIds) {
const want = [...m.pool.entries.keys()].some((c) => (m.gd.chess(c).bonds || []).includes(id));
assert.equal(m.bondInPool(id), want, id);
}
for (const id of [...m.pool.entries.keys()]) if ((m.gd.chess(id).bonds || []).includes('yanShip')) m.pool.entries.delete(id);
assert.equal(m.bondInPool('yanShip'), false);
m.dispose();
});
test('标准 (FUNNY) 机变: tactic cards acting only on mode-inactive bonds (玛恩纳 / 莫斯提马的盟誓, 卡西米尔 / 拉特兰 / 阿戈尔驰援) are never offered', () => {
// the draft the finding saw at R3 of seed 18: 玛恩纳的盟誓 (卡西米尔 +12) and 卡西米尔驰援 in a mode without 卡西米尔
const h = makeMatch({ mode: 'coop', difficulty: 'FUNNY', humans: 1, bots: 1, seed: 18, fake: true }).start();
const m = h.m;
h.autoHumans();
h.drive(() => m.phase === PHASE.SP_DRAFT);
const inactive = m.gd.modeInactiveBonds;
assert.ok(['kazimierzShip', 'lateranoShip', 'arcaneShip', 'egirShip'].every((b) => inactive.has(b)));
const dead = (c) => { const bonds = cardTargetBonds(m.gd, c.id); return !!bonds && bonds.every((b) => inactive.has(b) || !m.bondInPool(b)); };
assert.deepEqual(m.sp.cards.filter((c) => c.kind === 'tactic' && dead(c)).map((c) => c.name), []);
// every FUNNY tactic draft of the match's wiring (Match.bondLive)
const seen = new Set();
for (let s = 1; s <= 400; s++) {
const d = generateDraft(m.gd, createRng(s), 3, { stageId: m.stageId, bondAvailable: (b) => m.bondLive(b) });
if (d && d.family === 'tactic') for (const c of d.cards) seen.add(c.id);
}
for (const id of ['allybuff_select_2_7', 'allybuff_select_2_5', 'allybuff_select_7_5', 'allybuff_select_7_6', 'allybuff_select_7_8']) assert.ok(!seen.has(id), `${id} never offered in 标准`);
assert.ok(seen.has('allybuff_select_2_1'), '斯卡蒂的盟誓 (阿戈尔 off, but 深海猎人 / 坚守 on) stays');
assert.ok(seen.has('allybuff_select_7_1'), '炎盟约驰援 stays');
assert.deepEqual(cardTargetBonds(m.gd, 'allybuff_select_2_5'), ['lateranoShip', 'arcaneShip']);
assert.equal(cardTargetBonds(m.gd, 'allybuff_select_11'), null, 'cards that do anything else are always useful');
m.dispose();
// other modes keep them
const h2 = makeMatch({ mode: 'coop', difficulty: 'HARD', humans: 1, seed: 18, fake: true });
if (h2.m.bondInPool('kazimierzShip')) assert.equal(h2.m.bondLive('kazimierzShip'), true);
h2.m.dispose();
});
test('坚若磐石 (目标生命值损失最少): least LP lost among the players still alive (tuning override of the title rule)', () => {
const gd = new GameData(DATA, 'mode_multi_hard');
const t3 = gd.titles.find((t) => t.id === 'comment_3');
assert.deepEqual([t3.stat, t3.rule, t3.name], ['lpLost', 'min', '坚若磐石']);
const fake = (seat, lpLost, alive = true, s = {}) => ({ playerId: `p${seat}`, seat, alive, lp: 10, lpAtFinal: 10, stats: { bossDamage: 0, merges: 0, itemsEquipped: 0, gold: 0, lpLost, ...s }, activatedLayers: () => 0 });
const players = [fake(0, 12, true, { gold: 30 }), fake(1, 3, true, { gold: 10 }), fake(2, 0, false, { gold: 5 }), fake(3, 7, true, { merges: 2 })];
const won = assignTitles(gd, players, true);
assert.equal(won.get('p1').id, 'comment_3', 'the least LP lost among the alive');
assert.notEqual(won.get('p2')?.id, 'comment_3', 'an eliminated player never is 坚若磐石');
// a perfect player (0 LP lost) still qualifies
const perfect = assignTitles(gd, [fake(0, 0), fake(1, 5, true, { gold: 9 })], false);
assert.equal(perfect.get('p0').id, 'comment_3');
// without the tuning override the config rule applies
const { tuning, ...raw } = DATA; // eslint-disable-line no-unused-vars
const plain = new GameData(raw, 'mode_multi_hard');
assert.equal(plain.titles.find((t) => t.id === 'comment_3').stat, 'lpRemaining');
});
test('combat time limits: data maxPlayTime is real seconds, the sim gets game seconds (× the forced 2× speed)', () => {
const gd = new GameData(DATA, 'mode_multi_normal');
assert.equal(COMBAT_TIME_SCALE, 2);
for (let r = 1; r <= 13; r++) {
assert.equal(gd.combatTimeLimitReal(r), DATA.config.modes.mode_multi_normal.rounds[r].combatTimeLimit);
assert.equal(gd.combatTimeLimit(r), gd.combatTimeLimitReal(r) * 2);
// every enemy of the round's template spawns inside the limit (the game-second reading fails R2 / R3)
const tpl = DATA.waves[gd.roundCfg(r).template];
const last = Math.max(...tpl.spawns.map((s) => (s.time || 0) + ((s.count || 1) - 1) * (s.interval || 0)));
assert.ok(last < gd.combatTimeLimit(r), `R${r}: last spawn ${last} s < limit ${gd.combatTimeLimit(r)} s`);
}
const custom = new GameData({ ...DATA, config: { ...DATA.config, combatTimeScale: 1 } }, 'mode_multi_normal');
assert.equal(custom.combatTimeLimit(3), 55);
// the combat deadline shown to players is maxPlayTime real seconds
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 1, seed: 64, fake: true, instant: false, script: () => ({ duration: 1e9 }) }).start();
const m = h.m;
h.drive(() => m.phase === PHASE.COMBAT);
assert.equal(m.wave.timeLimit, 90);
assert.ok(Math.abs(m.deadline - m.sched.now() - 45000) < 50, 'deadline = 45 real seconds at R1');
m.dispose();
});
test('boss-round layout model: the player\'s boss field on the own board, the leader weighted with a dwell, sides mirrored', () => {
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 2, seed: 65, fake: true }).start();
const m = h.m;
h.drive(() => m.phase === PHASE.PREP && m.round === 14);
assert.equal(m.wave, null);
const left = fieldModel(m, h.ps('p_0'));
const right = fieldModel(m, h.ps('p_1'));
for (const model of [left, right]) {
assert.ok(model.routes.length > 0);
assert.ok(model.routes.some((r) => r.dwell > 0), 'the leader\'s route carries a dwell');
for (const rt of model.routes) for (const k of rt.tiles) {
const [r, c] = parseKey(k);
assert.ok(r >= FIELD.r0 && r <= FIELD.r1 && c >= 0 && c <= FIELD.c1, `tile ${k} on the own board`);
}
}
assert.notEqual(left.key, right.key, 'cached per side');
assert.equal(fieldModel(m, h.ps('p_1')), right);
m.dispose();
});
+12
View File
@@ -0,0 +1,12 @@
// Full co-op matches with the REAL simulation (humans on "AI 托管" + AI teammates), MATCH_SEEDS (20) seeds each, to RESULT with
// zero errors — split from fullmatch.test.js so the long co-op runs execute in parallel files.
import { test, after } from 'node:test';
import { SEEDS, runFull, reportSimIssues } from './fullmatchRun.js';
after(reportSimIssues);
for (const [humans, bots, difficulty] of [[1, 1, 'NORMAL'], [2, 1, 'HARD'], [1, 3, 'ABYSS']]) {
test(`co-op ${humans} human(s) + ${bots} AI (${difficulty}): ${SEEDS} seeds to RESULT with zero errors`, () => {
for (let seed = 1; seed <= SEEDS; seed++) runFull({ mode: 'coop', difficulty, humans, bots, seed: 100 + seed });
});
}
+12
View File
@@ -0,0 +1,12 @@
// Full co-op matches with the REAL simulation (humans on "AI 托管" + AI teammates), MATCH_SEEDS (20) seeds each, to RESULT with
// zero errors — split from fullmatch.test.js so the long co-op runs execute in parallel files.
import { test, after } from 'node:test';
import { SEEDS, runFull, reportSimIssues } from './fullmatchRun.js';
after(reportSimIssues);
for (const [humans, bots, difficulty] of [[4, 0, 'FUNNY']]) {
test(`co-op ${humans} human(s) + ${bots} AI (${difficulty}): ${SEEDS} seeds to RESULT with zero errors`, () => {
for (let seed = 1; seed <= SEEDS; seed++) runFull({ mode: 'coop', difficulty, humans, bots, seed: 100 + seed });
});
}
+12
View File
@@ -0,0 +1,12 @@
// Full co-op matches with the REAL simulation (humans on "AI 托管" + AI teammates), MATCH_SEEDS (20) seeds each, to RESULT with
// zero errors — split from fullmatch.test.js so the long co-op runs execute in parallel files.
import { test, after } from 'node:test';
import { SEEDS, runFull, reportSimIssues } from './fullmatchRun.js';
after(reportSimIssues);
for (const [humans, bots, difficulty] of [[2, 2, 'NORMAL']]) {
test(`co-op ${humans} human(s) + ${bots} AI (${difficulty}): ${SEEDS} seeds to RESULT with zero errors`, () => {
for (let seed = 1; seed <= SEEDS; seed++) runFull({ mode: 'coop', difficulty, humans, bots, seed: 100 + seed });
});
}
+56
View File
@@ -0,0 +1,56 @@
// Full matches with the REAL simulation (server/sim/Battle.js) run to RESULT in virtual time with zero errors:
// solo × every difficulty and co-op 2/3/4 (humans on "AI 托管" + AI teammates), 20 seeds each; invariants checked at
// every phase change; every m.public / m.private frame JSON-safe. Plus boosted-LP runs that reach the Final Assault
// with the real sim.
// The co-op configurations live in fullmatch-coop*.test.js (separate files run in parallel).
import { test, after } from 'node:test';
import assert from 'node:assert/strict';
import { makeMatch, checkInvariants } from './harness.js';
import { SEEDS, runFull, reportSimIssues } from './fullmatchRun.js';
after(reportSimIssues);
for (const difficulty of ['FUNNY', 'NORMAL', 'HARD', 'ABYSS']) {
test(`solo ${difficulty}: ${SEEDS} seeds to RESULT with zero errors`, () => {
for (let seed = 1; seed <= SEEDS; seed++) runFull({ mode: 'solo', difficulty, humans: 1, bots: 0, seed });
});
}
test('boosted LP: the real sim reaches the Final Assault (solo FUNNY R9, co-op NORMAL R14) and ends cleanly', () => {
for (const seed of [1, 2]) {
const { res } = runFull({ mode: 'solo', difficulty: 'FUNNY', humans: 1, bots: 0, seed, boostLp: 400 });
assert.ok(res.roundsPassed >= 8, `reached the boss round (${res.roundsPassed})`);
}
const { res } = runFull({ mode: 'coop', difficulty: 'NORMAL', humans: 1, bots: 1, seed: 7, boostLp: 400 });
assert.ok(res.roundsPassed >= 13);
});
test('determinism: the same seed and inputs give the same match (client-side and server-run combat)', () => {
const a = runFull({ mode: 'coop', difficulty: 'HARD', humans: 1, bots: 2, seed: 4242 }).res;
const b = runFull({ mode: 'coop', difficulty: 'HARD', humans: 1, bots: 2, seed: 4242 }).res;
const strip = (r) => JSON.stringify({ ...r, durationMs: 0 });
assert.equal(strip(a), strip(b));
const c = runFull({ mode: 'coop', difficulty: 'HARD', humans: 1, bots: 2, seed: 4243 }).res;
assert.notEqual(strip(a), strip(c));
const s1 = runFull({ mode: 'coop', difficulty: 'HARD', humans: 1, bots: 2, seed: 4242, clientCombat: false }).res;
const s2 = runFull({ mode: 'coop', difficulty: 'HARD', humans: 1, bots: 2, seed: 4242, clientCombat: false }).res;
assert.equal(strip(s1), strip(s2));
});
test('server-run fallback (SP_COMBAT=server): solo and co-op matches still run to RESULT with zero errors', () => {
runFull({ mode: 'solo', difficulty: 'HARD', humans: 1, bots: 0, seed: 77, clientCombat: false });
runFull({ mode: 'coop', difficulty: 'NORMAL', humans: 2, bots: 1, seed: 78, clientCombat: false });
});
test('degraded data (chess + bands only, no config/waves/stages): a bot match still runs to RESULT without errors', async () => {
const { DATA } = await import('./harness.js');
const data = { chess: DATA.chess, bands: DATA.bands, bonds: DATA.bonds };
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 1, bots: 1, seed: 9, data, captureFrames: false, clientCombat: true });
h.autoHumans();
h.m.start();
h.run(() => h.ended != null, { maxSteps: 2e6 });
assert.ok(h.ended, `ended (${h.m.phase} R${h.m.round})`);
assert.equal(h.m.errorCount, 0, JSON.stringify(h.m.errors.slice(0, 2)));
checkInvariants(h.m);
h.m.dispose();
});
+65
View File
@@ -0,0 +1,65 @@
// test/match/fullmatchRun.js — shared runner of the full-match suites (fullmatch*.test.js): a whole match with the REAL
// simulation in virtual time, invariants at every phase change, JSON-safe frames, zero engine errors. Sim / content
// errors ('[sim] …', owned by the sim and content teams) are collected in `simIssues` and reported by the suites.
// Combat is client-side by default (DESIGN §14, the production mode): every human seat is a SimClient that simulates
// its specs and reports b.progress / b.result; honest clients must never be rejected or taken over, and no combat
// frame is streamed. `clientCombat: false` runs the server-run fallback.
import assert from 'node:assert/strict';
import { PHASE } from '../../shared/constants.js';
import { makeMatch, checkInvariants } from './harness.js';
export const SEEDS = Number(process.env.MATCH_SEEDS || 20);
export const simIssues = new Set();
export function reportSimIssues() {
if (simIssues.size) console.log(`sim/content errors seen during full matches (not match-engine errors):\n${[...simIssues].slice(0, 20).map((x) => ' ' + x).join('\n')}`);
}
export function runFull({ mode, difficulty, humans, bots, seed, boostLp = null, clientCombat = true, pace = 'instant' }) {
const h = makeMatch({ mode, difficulty, humans, bots, seed, captureFrames: false, checkFrames: true, clientCombat, pace });
h.autoHumans();
const m = h.m;
m.start();
let last = '';
let boosted = false;
let phases = 0;
h.run(() => {
const key = `${m.phase}:${m.round}`;
if (key !== last) {
last = key;
phases++;
checkInvariants(m);
if (boostLp && !boosted && m.phase === PHASE.PREP && m.round === 1) {
boosted = true;
for (const ps of m.players.values()) ps.lp = boostLp;
}
}
return h.ended != null;
}, { maxSteps: 5e6 });
assert.ok(h.ended, `${mode}/${difficulty}/${seed}: match did not end (${m.phase} R${m.round})`);
assert.equal(h.endedCount, 1);
assert.equal(m.phase, PHASE.RESULT);
assert.equal(m.errorCount, 0, `${mode}/${difficulty}/${seed}: match errors ${JSON.stringify(m.errors.slice(0, 2))}`);
assert.deepEqual(h.sched.errors, []);
// engine errors must be zero; errors raised inside the simulation / its content ('[sim] …', owned by the sim and
// content teams) are collected and reported as test diagnostics instead
const engineErrors = h.logs.error.filter((l) => !l.startsWith('[sim]'));
assert.deepEqual(engineErrors, [], `${mode}/${difficulty}/${seed}: logged errors`);
for (const l of h.logs.error) if (l.startsWith('[sim]')) simIssues.add(l.split(' | ')[0].slice(0, 160));
assert.deepEqual(h.badFrames, [], 'every frame is JSON-safe');
assert.equal(m.dispatcher.errors, 0);
checkInvariants(m);
if (clientCombat) {
assert.equal(m.verifyStats.rejected, 0, `${mode}/${difficulty}/${seed}: an honest client result was rejected`);
assert.equal(m.verifyStats.takeovers, 0, `${mode}/${difficulty}/${seed}: unexpected takeover`);
assert.equal(h.frames, 0, 'no b.snap / b.ev under client-side combat');
for (const c of h.clients.values()) assert.ok(c.log.some((x) => x.t === 'b.result'), 'every human reported its battles');
}
const res = h.lastTo('p_0', 'm.result');
assert.ok(res && res.playerId === 'p_0');
assert.ok(phases > 5);
m.dispose();
if (h.clients) h.clients.closeAll();
assert.equal(h.sched.pending(), 0);
return { h, m, res: h.ended };
}
+100
View File
@@ -0,0 +1,100 @@
// Protocol fuzz: random VALID-SHAPED intents (they pass shared/protocol.js validateC2S) in random phases, random
// disconnects/reconnects/leaves and random time jumps never throw, always answer { ok } or { error: ERR.* }, never
// report an internal error and never corrupt the invariants (pool accounting, funds, slots, legality, merges).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { validateC2S, C2S } from '../../shared/protocol.js';
import { ERR, EMOTES } from '../../shared/constants.js';
import { createRng } from '../../server/sim/rng.js';
import { DATA, makeMatch, checkInvariants } from './harness.js';
const GAME = Object.keys(C2S).filter((t) => t.startsWith('g.') && t !== 'g.leave');
const BANDS = Object.keys(DATA.bands);
function randomIntent(rng, m, ps) {
const t = rng.pick(GAME);
const pieces = [];
if (ps) {
for (const p of [...ps.board.values(), ...ps.hand, ...ps.temp]) {
if (!p) continue;
pieces.push(p.uid);
for (const it of p.items || []) pieces.push(it.uid);
}
}
const uid = () => (pieces.length && rng() < 0.8 ? rng.pick(pieces) : 1 + rng.int(5000));
const to = () => (rng() < 0.6 ? { area: 'board', row: rng() < 0.8 ? 9 + rng.int(4) : rng.int(19), col: rng() < 0.8 ? 2 + rng.int(9) : rng.int(21) } : { area: 'hand', idx: rng.int(10) });
switch (t) {
case 'g.band': return { t, bandId: rng() < 0.8 ? rng.pick(BANDS) : rng.pick(['constructor', '__proto__', 'band_x', 'a'.repeat(64)]) };
case 'g.buy': return { t, slot: rng.int(16) };
case 'g.sell': case 'g.destroy': return { t, uid: uid() };
case 'g.move': return { t, uid: uid(), to: to() };
case 'g.equip': return { t, itemUid: uid(), targetUid: uid() };
case 'g.art': return { t, itemUid: uid(), row: rng.int(19), col: rng.int(21) };
case 'g.reward': case 'g.choice': return { t, idx: rng.int(6) };
case 'g.ready': return { t, ready: rng() < 0.4 };
case 'g.emote': return { t, id: rng.pick(EMOTES) };
case 'g.watch': return { t, fieldId: rng.pick(['n:p_0', 'n:p_1', 'n:ai_0', 'u', 'b1', 'b2', 'zz', '']) };
case 'g.autoplay': return { t, on: rng() < 0.05 };
case 'g.pause': return { t, on: rng() < 0.5 };
default: return { t };
}
}
function fuzzOne(seed, { fake }) {
const rng = createRng(seed * 7919);
const mode = rng() < 0.3 ? 'solo' : 'coop';
const difficulty = rng.pick(['FUNNY', 'NORMAL', 'HARD', 'ABYSS']);
const humans = mode === 'solo' ? 1 : 1 + rng.int(3);
const bots = mode === 'solo' ? 0 : rng.int(3);
const h = makeMatch({ mode, difficulty, humans, bots, seed, fake, captureFrames: false, checkFrames: true, script: () => ({ duration: 2 + rng.int(6), leaks: {} }) });
const m = h.m;
m.start();
const humanIds = [...m.players.values()].filter((p) => !p.isBot).map((p) => p.playerId);
let intents = 0;
for (let step = 0; step < 2500 && h.ended == null; step++) {
const r = rng();
const pid = rng.pick(humanIds);
const ps = m.players.get(pid);
if (r < 0.82) {
const msg = randomIntent(rng, m, ps);
assert.equal(validateC2S(msg), null, `fuzz produced an invalid message ${JSON.stringify(msg)}`);
const res = m.handle(pid, msg);
intents++;
assert.ok(res && (res.ok === true || (typeof res.error === 'string' && Object.hasOwn(ERR, res.error))), `bad reply ${JSON.stringify(res)} to ${JSON.stringify(msg)}`);
assert.notEqual(res.error, ERR.INTERNAL, `internal error on ${JSON.stringify(msg)}`);
} else if (r < 0.85) {
m.onDisconnect(pid);
} else if (r < 0.88) {
m.onReconnect(pid);
} else if (r < 0.882 && humanIds.filter((id) => !m.players.get(id).left).length > 1) {
m.onLeave(pid);
} else if (r < 0.96) {
h.sched.advance(rng.int(3000));
} else {
for (let k = 0; k < 20; k++) if (!h.sched.runNext()) break;
}
if (step % 20 === 0) checkInvariants(m);
}
// finish the match: everyone left on AI 托管
for (const ps of m.players.values()) if (!ps.isBot && !ps.left) m.handle(ps.playerId, { t: 'g.autoplay', on: true });
h.drive(() => h.ended != null);
assert.ok(h.ended, `seed ${seed}: match did not end (${m.phase} R${m.round})`);
assert.equal(h.endedCount, 1);
assert.equal(m.errorCount, 0, `seed ${seed}: ${JSON.stringify(m.errors.slice(0, 3))}`);
assert.equal(m.dispatcher.errors, 0);
assert.deepEqual(h.sched.errors, []);
assert.deepEqual(h.badFrames, []);
checkInvariants(m);
m.dispose();
return intents;
}
test('fuzz with FakeBattle: 60 matches of random intents / connection churn / time jumps', () => {
let n = 0;
for (let seed = 1; seed <= 60; seed++) n += fuzzOne(seed, { fake: true });
assert.ok(n > 10000, `${n} intents`);
});
test('fuzz with the real simulation: 8 matches', () => {
for (let seed = 101; seed <= 108; seed++) fuzzOne(seed, { fake: false });
});
+462
View File
@@ -0,0 +1,462 @@
// Regression tests for the adversarial review of the match engine: every defect fixed there and every registry
// addition content needs (DESIGN §6.4/§7, docs/META.md). FakeBattle unless noted.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { PHASE, GEO } from '../../shared/constants.js';
import { MetaRegistry, makeCtx } from '../../server/match/effectsMeta.js';
import { registerBuiltins } from '../../server/match/builtinMeta.js';
import { collectViolations } from '../../server/match/invariants.js';
import { attachAudit } from '../../server/match/audit.js';
import { DeadBattle, FieldRunner, maxTicksPerInterval } from '../../server/match/fields.js';
import { FakeBattle } from './fakeBattle.js';
import { DATA, makeMatch, give, giveItem, checkInvariants, chessOfTier, legalTileFor } from './harness.js';
const builtins = () => registerBuiltins(new MetaRegistry(), DATA);
const bossFields = () => FakeBattle.instances.filter((b) => b.kind === 'boss' || b.kind === 'hidden');
/** Solo match in PREP R1 with an empty board/hand and funds. */
function prepSolo({ seed = 900, registry = undefined, funds = 30 } = {}) {
const h = makeMatch({ mode: 'solo', seed, fake: true, registry }).start();
h.toPrep(1);
const ps = h.ps('p_0');
for (const p of [...ps.board.values(), ...ps.hand.filter(Boolean), ...ps.temp.filter(Boolean)]) if (p.kind === 'chess') ps.returnCopies(p);
ps.board.clear();
ps.hand.fill(null);
ps.temp.fill(null);
ps.funds = funds;
ps.recompute();
return { h, m: h.m, ps };
}
// ---------------------------------------------------------------------------------------------------------------
// defects
test('Final Assault: a harmless leak (lpr 0) costs no team LP; a leak without lpr costs 1; lpr N costs N', () => {
const h = makeMatch({
mode: 'coop', difficulty: 'FUNNY', humans: 2, seed: 901, fake: true,
script: (b) => (b.kind === 'boss' ? { bossDps: 0, duration: Infinity, leakEvents: [{ at: 0.5, lpr: 0 }, { at: 1 }, { at: 1.5, lpr: 3 }, { at: 3, lpr: 0 }] } : {}),
}).start();
const m = h.m;
h.drive(() => m.phase === PHASE.PREP && m.round === 14);
h.drive(() => m.phase === PHASE.FINAL_ASSAULT);
const start = m.teamLp;
// the boss never dies here: stop right after the scripted leaks
const f = bossFields()[0];
for (let i = 0; i < 200 && f.time < 4; i++) f.step();
m._bossLeak({ lpr: undefined });
assert.equal(m.teamLp, start - 1 - 3 - 1, 'lpr 0 → 0, missing → 1, 3 → 3');
m.dispose();
});
test('PREP: a player who un-readies right after everybody was ready keeps the prep open (deadline stays armed)', () => {
const h = makeMatch({ mode: 'coop', humans: 2, seed: 902, fake: true }).start();
const m = h.m;
h.toPrep(1);
const deadline = m.deadline;
assert.ok(deadline > 0);
assert.deepEqual(m.handle('p_0', { t: 'g.ready', ready: true }), { ok: true });
assert.deepEqual(m.handle('p_1', { t: 'g.ready', ready: true }), { ok: true });
// the phase ends on the next scheduler turn — p_1 changes their mind before that
assert.deepEqual(m.handle('p_1', { t: 'g.ready', ready: false }), { ok: true });
h.sched.runNext();
assert.equal(m.phase, PHASE.PREP, 'prep still open');
assert.equal(m.deadline, deadline, 'the prep deadline is still armed');
// at the deadline everyone is auto-readied and combat starts
h.run(() => m.phase === PHASE.COMBAT || m.phase === PHASE.SETTLE);
assert.ok(h.sched.now() >= deadline);
m.dispose();
});
test('merge with a full hand and a full temp: the elite takes a freed board tile and keeps the returned items', () => {
const { m, ps } = prepSolo({ seed: 903 });
const id = chessOfTier(1, (c) => c.position === 'MELEE').find((c) => m.pool.has(c) && m.gd.mergeCount(c) === 3);
const t1 = legalTileFor(m, ps, id);
const a = give(m, ps, id, 'board', t1);
const t2 = legalTileFor(m, ps, id);
const b = give(m, ps, id, 'board', t2);
const itA = giveItem(m, ps, 'chess_item_1_01_e_a');
const itB = giveItem(m, ps, 'chess_item_2_04_e_a');
ps.hand.fill(null);
a.items.push(itA);
b.items.push(itB);
const fillers = [2, 3, 4].flatMap((t) => chessOfTier(t)).filter((c) => m.pool.has(c) && c !== id);
assert.ok(fillers.length >= 15);
for (let i = 0; i < 10; i++) give(m, ps, fillers[i], 'hand', i);
for (let i = 0; i < 5; i++) give(m, ps, fillers[10 + i], 'temp', i);
ps.shop.slots[0] = { kind: 'chess', id, basePrice: m.gd.chessPrice(id), frozen: false, sold: false };
checkInvariants(m);
assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot: 0 }), { ok: true }, 'a purchase that completes a merge is allowed with a full hand');
const elite = [...ps.board.values()].find((p) => p.id === m.gd.goldenIdOf(id));
assert.ok(elite, 'the elite stands on a freed tile');
assert.equal(elite.poolCopies, 3, 'it holds all three copies');
assert.deepEqual(elite.items.map((x) => x.id).sort(), ['chess_item_1_01_e_a', 'chess_item_2_04_e_a'], 'returned items are kept on it');
checkInvariants(m);
m.dispose();
});
test('联防 that cannot run (synthetic result) charges the leakers their own leaks instead of nothing', () => {
const h = makeMatch({
mode: 'coop', humans: 2, seed: 904, fake: true,
script: (b) => (b.kind === 'unite' ? { throwInCtor: true } : { leaks: Object.fromEntries(b.players.map((p) => [p, p === 'p_0' ? 4 : 0])) }),
}).start();
const m = h.m;
h.toPrep(1);
const a = h.ps('p_0');
const lp = a.lp;
h.drive(() => m.phase === PHASE.SETTLE);
assert.ok(m.unitePlan, '联防 was planned');
assert.equal(a.lp, lp - 4, 'the leaker pays its 4 leaks');
assert.equal(h.ps('p_1').lp, h.ps('p_1').lp, 'the helper pays nothing');
assert.ok(m.errorCount >= 1, 'the construction failure was reported');
m.dispose();
});
test('stand-in results are marked synthetic (DeadBattle, unreadable results)', () => {
const dead = new DeadBattle({ fieldId: 'u', kind: 'unite', players: [{ playerId: 'a' }] });
assert.equal(dead.result().synthetic, true);
const runner = new FieldRunner({ reportError() {}, markPublic() {}, sched: { instant: true } }, [], { onDone() {} });
const r = runner.resultOf({ fieldId: 'x', players: ['a'], battle: { result() { throw new Error('boom'); } } });
assert.equal(r.synthetic, true);
assert.equal(r.perPlayer.a.perfect, true);
});
test('the 联防 battle reuses the round template stat overrides (leaked enemies keep their stats)', () => {
const h = makeMatch({
mode: 'coop', humans: 2, seed: 905, fake: true,
script: (b) => (b.kind === 'normal' ? { leaks: Object.fromEntries(b.players.map((p) => [p, p === 'p_0' ? 2 : 0])) } : {}),
}).start();
const m = h.m;
h.toPrep(1);
m.wave.overrides = { enemy_1007_slime: { stats: { maxHp: 1234 } } };
h.drive(() => m.phase === PHASE.UNITE);
const u = FakeBattle.instances.find((b) => b.kind === 'unite');
assert.deepEqual(u.opts.enemyOverrides, { enemy_1007_slime: { stats: { maxHp: 1234 } } });
m.dispose();
});
test('per-slot frozen flag in m.private follows the freeze toggle', () => {
const { m, ps } = prepSolo({ seed: 906 });
assert.ok(ps.privateView().shop.slots.every((s) => !s || s.frozen === false));
assert.deepEqual(m.handle('p_0', { t: 'g.freeze' }), { ok: true });
assert.ok(ps.privateView().shop.slots.every((s) => !s || s.frozen === true));
m.dispose();
});
test('b.ev and b.snap always carry the game time gt (also on m.field resync); combatSpeed paces faster', () => {
const h = makeMatch({ mode: 'coop', humans: 2, seed: 907, fake: true, instant: false, script: () => ({ duration: 3 }) }).start();
const m = h.m;
h.toPrep(1);
m.handle('p_0', { t: 'g.ready', ready: true });
m.handle('p_1', { t: 'g.ready', ready: true });
h.run(() => m.phase === PHASE.COMBAT);
h.sched.advance(600);
m.onReconnect('p_0');
h.sched.advance(600);
const frames = h.sent.filter(([pid, msg]) => pid === 'p_0' && (msg.t === 'b.snap' || msg.t === 'b.ev')).map(([, msg]) => msg);
assert.ok(frames.some((f) => f.t === 'b.ev') && frames.some((f) => f.t === 'b.snap'));
for (const f of frames) {
assert.equal(typeof f.gt, 'number', `${f.t} carries gt`);
assert.ok(Number.isFinite(f.gt) && f.gt >= 0);
assert.notEqual(f.t, f.gt);
}
// an event frame always precedes its snapshot with the same gt
for (let i = 0; i < frames.length - 1; i++) if (frames[i].t === 'b.ev') assert.equal(frames[i + 1].gt, frames[i].gt);
m.dispose();
assert.equal(maxTicksPerInterval(2), 8);
assert.ok(maxTicksPerInterval(20) >= 80);
const fast = makeMatch({ mode: 'solo', seed: 908, fake: true, instant: false, script: () => ({ duration: 6 }) });
fast.m.gameSpeed = 30;
fast.start();
fast.toPrep(1);
fast.m.handle('p_0', { t: 'g.ready', ready: true });
fast.run(() => fast.m.phase === PHASE.COMBAT);
const t0 = fast.sched.now();
fast.run(() => fast.m.fields.length > 0 && fast.m.fields.every((f) => !f.live));
assert.ok(fast.sched.now() - t0 < 400, `6 game s at 30× take ${fast.sched.now() - t0} ms`);
fast.m.dispose();
});
// ---------------------------------------------------------------------------------------------------------------
// registry additions (docs/META.md §2)
test('onIncome rewrites the round income (老鲤-style withholding); onSpend reports every completed payment', () => {
const reg = builtins();
const spent = [];
reg.global('lmlee', {
onIncome(ctx, ev) {
if (ev.round <= 2) { ctx.incCounter('held', ev.income); ev.income = 0; }
if (ev.round === 3) { ev.income += ctx.counter('held'); ctx.setCounter('held', 0); }
},
onSpend(ctx, ev) { spent.push([ev.reason, ev.amount, ev.total]); },
});
const h = makeMatch({ mode: 'solo', seed: 910, fake: true, registry: reg }).start();
const m = h.m;
h.toPrep(1);
const ps = h.ps('p_0');
assert.equal(ps.funds, 0, 'R1 income withheld');
ps.funds = 20;
const slot = ps.shop.slots.findIndex((s) => s && s.kind === 'chess');
const price = ps.priceOf(ps.shop.slots[slot]);
assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot }), { ok: true });
assert.deepEqual(m.handle('p_0', { t: 'g.refresh' }), { ok: true });
const up = ps.shop.upgradePrice;
assert.deepEqual(m.handle('p_0', { t: 'g.levelUp' }), { ok: true });
assert.deepEqual(spent, [['buy', price, price], ['refresh', 1, price + 1], ['levelUp', up, price + 1 + up]]);
ps.shop.freeRefreshes = 1;
m.handle('p_0', { t: 'g.refresh' });
assert.equal(spent.length, 3, 'a free refresh pays nothing');
h.drive(() => m.phase === PHASE.PREP && m.round === 3);
assert.equal(ps.funds, m.gd.income(1) + m.gd.income(2) + m.gd.income(3), 'R1+R2 income paid out at R3');
m.dispose();
});
test('ctx.triggerGarrisons re-runs another piece\'s 特质 (asUid copies, 投资人 repeat, depth cap); garrisonsOf lists them', () => {
const reg = builtins();
const runs = [];
reg.garrison('SERVER_ONCE_GOLD', { run(ctx, ev) { runs.push([ctx.hook, ctx.source.piece.uid, ctx.source.trigger === true, ev.source]); } });
const { m, ps } = prepSolo({ seed: 911, registry: reg });
const id = 'chess_char_3_01_a'; // <获得时>使下个休整期额外获得1资金
const gainer = give(m, ps, id);
const other = give(m, ps, chessOfTier(1).find((c) => m.pool.has(c)));
const ctx = makeCtx(m, ps, { kind: 'global', key: 'global:test' }, 'onRoundStart');
const gs = ctx.garrisonsOf(gainer.uid);
assert.ok(gs.some((g) => g.effectKey === 'SERVER_ONCE_GOLD' && g.eventType === 'SERVER_GAIN'));
assert.equal(ctx.triggerGarrisons(gainer.uid, 'SERVER_GAIN'), 1);
assert.deepEqual(runs, [['onGain', gainer.uid, true, 'trigger']]);
runs.length = 0;
assert.equal(ctx.triggerGarrisons(gainer.uid, 'SERVER_GAIN', { asUid: other.uid }), 1);
assert.equal(runs[0][1], other.uid, 'asUid: the copy runs as the other piece');
assert.equal(ctx.triggerGarrisons(gainer.uid, 'SERVER_PRICE'), 0, 'SERVER_PRICE cannot be triggered');
assert.equal(ctx.triggerGarrisons(999999, 'SERVER_GAIN'), 0);
assert.equal(ctx.triggerGarrisons(gainer.uid, 'NOPE'), 0);
// 投资人 active ⇒ ×2
runs.length = 0;
ps.bonds.investShip = { count: 3, active: true, tier: 1, layers: 0 };
ctx.triggerGarrisons(gainer.uid, 'SERVER_GAIN');
assert.equal(runs.length, 2);
ps.recompute();
// a garrison that triggers itself stops at the dispatcher depth cap
let depth = 0;
reg.garrison('SERVER_ONCE_GOLD', { run(c) { depth++; c.triggerGarrisons(c.source.piece.uid, 'SERVER_GAIN'); } });
ctx.triggerGarrisons(gainer.uid, 'SERVER_GAIN');
assert.ok(depth > 1 && depth <= 7, `bounded recursion (${depth})`);
m.dispose();
});
test('garrisonHooks widens a garrison to extra hooks ("<进入休整期时><休整期结束时>")', () => {
const reg = builtins();
const hooks = [];
const id = 'chess_char_2_02_a';
const g = DATA.garrisons[DATA.chess[id].garrisonIds.find((x) => DATA.garrisons[x].eventType === 'SERVER_PREP_START')];
reg.garrison(g.effectKey, { garrisonHooks: (gar) => (gar.garrisonId === g.garrisonId ? ['onRoundStart', 'onPrepEnd'] : null), run(ctx) { hooks.push(ctx.hook); } });
const { m, ps } = prepSolo({ seed: 912, registry: reg });
give(m, ps, id, 'board', legalTileFor(m, ps, id));
m.dispatch(ps, 'onRoundStart', { round: 1 });
m.dispatch(ps, 'onPrepEnd', { round: 1 });
m.dispatch(ps, 'onRefresh', {});
assert.deepEqual(hooks, ['onRoundStart', 'onPrepEnd']);
m.dispose();
});
test('ctx.offerItems queues an item offer; g.reward takes the item (full hand refused unless it merges)', () => {
const reg = builtins();
const { m, ps } = prepSolo({ seed: 913, registry: reg });
const ctx = makeCtx(m, ps, { kind: 'band', key: 'band:test' }, 'onLevelUp');
assert.equal(ctx.offerItems(['chess_item_1_01_e_a', 'nope', 'chess_item_2_03_e_a']), true);
assert.equal(ctx.offerItems(['nope']), false);
const view = ps.privateView().shop.rewardOffer;
assert.deepEqual(view.slots.map((s) => [s.kind, s.id, s.price]), [['item', 'chess_item_1_01_e_a', 0], ['item', 'chess_item_2_03_e_a', 0]]);
const fillers = chessOfTier(1).filter((c) => m.pool.has(c));
for (let i = 0; i < 10; i++) give(m, ps, fillers[i], 'hand', i);
assert.equal(m.handle('p_0', { t: 'g.reward', idx: 0 }).error, 'HAND_FULL');
ps.hand[0] && ps.returnCopies(ps.hand[0]);
ps.hand[0] = null;
ps.recompute();
assert.deepEqual(m.handle('p_0', { t: 'g.reward', idx: 1 }), { ok: true });
assert.ok(ps.hand.some((p) => p && p.id === 'chess_item_2_03_e_a'));
assert.equal(ps.offers.length, 0);
assert.equal(m.handle('p_0', { t: 'g.reward', idx: 0 }).error, 'BAD_TARGET', 'the offer is gone');
checkInvariants(m);
m.dispose();
});
test('ctx.piece / pieceAt expose board row/col and hand idx; pieceBonds includes 变形同构体 bonds', () => {
const { m, ps } = prepSolo({ seed: 914 });
const id = chessOfTier(2, (c) => c.position === 'RANGED').find((c) => m.pool.has(c) && !c.startsWith('chess_char_2_03'));
const [r, c] = legalTileFor(m, ps, id);
const onBoard = give(m, ps, id, 'board', [r, c]);
const inHand = give(m, ps, chessOfTier(1).find((x) => m.pool.has(x)), 'hand', 3);
const ctx = makeCtx(m, ps, { kind: 'global', key: 'global:t' }, 'onRoundStart');
const v = ctx.piece(onBoard.uid);
assert.equal(v.area, 'board');
assert.equal(v.row, r);
assert.equal(v.col, c);
assert.equal(ctx.piece(inHand.uid).idx, 3);
assert.equal(ctx.pieceAt(r, c).uid, onBoard.uid);
assert.equal(ctx.pieceAt(r, c).row, r);
assert.equal(ctx.pieceAt(0, 0), null);
assert.equal(ctx.pieceAt('x', 1), null);
const own = DATA.chess[id].bonds;
onBoard.items.push(ps.newPiece('item', 'chess_item_6_09_e_a'), ps.newPiece('item', 'chess_item_1_01_e_a'));
ps.recompute();
const bonds = ctx.pieceBonds(onBoard.uid);
for (const b of own) assert.ok(bonds.includes(b));
assert.ok(bonds.includes('victoriaShip'), '变形同构体 + 维式 item grants 维多利亚');
m.dispose();
});
test('ctx.rollPool: equip pools, weighted / listed chess pools, shopEligible filters (tier, minTier, bond, golden)', () => {
const { m, ps } = prepSolo({ seed: 915 });
const ctx = makeCtx(m, ps, { kind: 'global', key: 'global:t' }, 'onRoundStart');
for (let i = 0; i < 30; i++) {
const eq = ctx.rollPool('pool_equip_pepe');
assert.equal(eq.kind, 'item');
assert.ok(['chess_item_1_03_e_a', 'chess_item_2_04_e_a', 'chess_item_6_07_e_a'].includes(eq.id));
const pin = ctx.rollPool('pool_char_pinus');
assert.ok(['chess_char_1_19_a', 'chess_char_2_18_a', 'chess_char_4_20_a'].includes(pin.id));
const gl = ctx.rollPool('pool_chess_glady');
assert.ok(['chess_char_3_05_a', 'chess_char_2_07_a', 'chess_char_1_04_a'].includes(gl.id));
const t1 = ctx.rollPool('pool_chess_shop_1_reward');
assert.equal(m.gd.tierOf(t1.id), 1);
const later = ctx.rollPool('pool_char_later');
if (later) {
assert.equal(later.golden, true);
assert.ok(m.gd.isGolden(later.id));
assert.ok(m.gd.tierOf(later.id) >= 4);
assert.ok(DATA.chess[later.id].bonds.includes('lateranoShip'));
}
}
assert.equal(ctx.rollPool('nope'), null);
assert.equal(ctx.rollPool('constructor'), null);
// a listed chess with no copy left is never rolled
const taken = m.pool.take('chess_char_1_04_a', 99);
for (let i = 0; i < 20; i++) assert.notEqual(ctx.rollPool('pool_chess_glady')?.id, 'chess_char_1_04_a');
m.pool.give('chess_char_1_04_a', taken);
m.dispose();
});
test('onBattleStart ev.spawns: content edits a field\'s spawns; a kill bounty set there keeps paying in 联防', () => {
const reg = builtins();
reg.global('ducklord', {
onBattleStart(ctx, ev) {
if (ev.kind !== 'normal' || ctx.playerId !== 'p_0') return;
ev.spawns[0].enemyKey = 'enemy_1007_slime';
ev.spawns[0].bounty = { coins: 3, ownerPlayerId: 'p_0' };
ev.spawns.push({ enemyKey: 'no_such_enemy', time: 1 });
},
});
const h = makeMatch({
mode: 'coop', humans: 2, seed: 916, fake: true, registry: reg,
script: (b) => (b.kind === 'normal' ? { leaks: Object.fromEntries(b.players.map((p) => [p, p === 'p_0' ? 1 : 0])) } : {}),
}).start();
const m = h.m;
h.toPrep(1);
h.drive(() => m.phase === PHASE.UNITE);
const nf = FakeBattle.instances.find((b) => b.fieldId === 'n:p_0');
assert.equal(nf.opts.spawns[0].enemyKey, 'enemy_1007_slime');
assert.equal(nf.opts.spawns[0].mods.bountyCoins, 3, 'the bounty rides along in mods');
assert.ok(!nf.opts.spawns.some((s) => s.enemyKey === 'no_such_enemy'), 'malformed spawns are dropped');
assert.ok(nf.opts.spawns.every((s) => s.ownerPlayerId === 'p_0'));
assert.deepEqual(m.unitePlan.leaked[0].bounty, { coins: 3, ownerPlayerId: 'p_0' });
const u = FakeBattle.instances.find((b) => b.kind === 'unite');
assert.deepEqual(u.opts.spawns[0].bounty, { coins: 3, ownerPlayerId: 'p_0' });
const other = FakeBattle.instances.find((b) => b.fieldId === 'n:p_1');
assert.notEqual(other.opts.spawns[0].bounty?.coins, 3, 'only p_0\'s field was edited');
m.dispose();
});
test('registry warns about handler methods that are not hooks (typos never run silently)', () => {
const reg = new MetaRegistry();
reg.band('band_x', { onBuyy() {}, onBuy() {}, run() {}, garrisonHooks() { return []; }, note: 'data is fine' });
assert.equal(reg.warnings.length, 1);
assert.match(reg.warnings[0], /band:band_x: 'onBuyy'/);
});
// ---------------------------------------------------------------------------------------------------------------
// invariants & audit tooling
test('collectViolations reports corrupted state (funds, pool accounting, duplicate uid, stale bonds, illegal tile)', () => {
const { m, ps } = prepSolo({ seed: 920 });
assert.deepEqual(collectViolations(m), []);
const id = chessOfTier(1).find((c) => m.pool.has(c));
const p = give(m, ps, id);
ps.funds = -1;
m.pool.entries.get(id).left += 1;
ps.hand[ps.hand.indexOf(p) === 5 ? 6 : 5] = { ...p };
ps.layers.yanShip = 50;
ps.board.set('12,9', ps.newPiece('chess', id, { poolCopies: 0 }));
const v = collectViolations(m, { limit: 50 }).join('\n');
for (const re of [/funds -1/, /pool .*left .* held/, /duplicate uid/, /stale bonds/, /illegal tile|outside/]) assert.match(v, re);
m.dispose();
});
test('the rule auditor flags an LP loss that does not match the leaks (engine regression guard)', () => {
const h = makeMatch({ mode: 'coop', humans: 2, seed: 921, fake: true, script: (b) => (b.kind === 'normal' ? { leaks: Object.fromEntries(b.players.map((p) => [p, 2])) } : {}) }).start();
const m = h.m;
// simulate an engine bug inside settle: one extra LP lost
const orig = m.settle.bind(m);
m.settle = (plan, res) => { const r = orig(plan, res); h.ps('p_0').lp -= 1; return r; };
const audit = attachAudit(m);
h.toPrep(1);
h.drive(() => m.phase === PHASE.PREP && m.round === 2);
assert.ok(audit.violations.some((x) => /p_0: lost 3 LP, expected min\(10, 2\)/.test(x)), audit.violations.join('\n'));
assert.ok(!audit.violations.some((x) => x.includes('p_1')), 'p_1 is fine');
assert.ok(audit.checks > 10);
m.dispose();
});
test('audited FakeBattle matches (all phases incl. 联防, 机变, Final Assault) report no violations', () => {
for (const [n, seed] of [[1, 930], [2, 931], [3, 932], [4, 933]]) {
const h = makeMatch({
mode: 'coop', difficulty: 'NORMAL', humans: 1, bots: n - 1, seed, fake: true,
script: (b) => (b.kind === 'boss' ? { bossDps: 5000 } : { leaks: Object.fromEntries(b.players.map((p, i) => [p, (b.round + i + seed) % 3])) }),
}).start();
h.autoHumans();
const audit = attachAudit(h.m);
for (const ps of h.m.players.values()) ps.lp = 200;
h.runToEnd({ maxSteps: 3e6 });
assert.deepEqual(audit.violations, [], `${n} players:\n${audit.violations.join('\n')}`);
assert.ok(h.m.phase === PHASE.RESULT);
h.m.dispose();
}
});
test('GEO sanity used by the fixes (boss rect, normal rect)', () => {
assert.deepEqual(GEO.BOSS_RECT, { r0: 0, r1: 5, c0: 0, c1: 20 });
});
test('the rule auditor follows the engine: PRTS 联防 helper order (not LP), silent solo presentation steps, a frozen item slot moved right by a level-up', () => {
// co-op: the human (seat 0) plays nothing, the bots field full boards; only the last bot leaks → the helpers are the
// two perfect bots with the most units (unite.js helperOrder), whatever the LP / seat order says
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 1, bots: 3, seed: 934, fake: true,
script: (b) => (b.kind === 'boss' ? { bossDps: 5000 } : b.kind === 'normal' ? { leaks: Object.fromEntries(b.players.filter((p) => p === 'ai_2').map((p) => [p, 2])) } : {}) }).start();
const m = h.m;
const audit = attachAudit(m);
for (const ps of m.players.values()) ps.lp = 200;
h.drive(() => m.phase === PHASE.UNITE && m.round >= 4, { ready: true });
assert.ok(!m.unitePlan.helpers.some((p) => p.playerId === 'p_0'), `helpers ${m.unitePlan.helpers.map((p) => p.playerId)}: the empty board is never picked first`);
h.autoHumans();
h.runToEnd({ maxSteps: 3e6 });
assert.deepEqual(audit.violations, [], audit.violations.join('\n'));
m.dispose();
// solo: BATTLE_CHECK / ROUND_START / SETTLE run silently; a frozen item slot follows the chess slots after a level-up
const s = makeMatch({ mode: 'solo', difficulty: 'NORMAL', seed: 935, fake: true }).start();
const sa = attachAudit(s.m);
s.toPrep(1);
const ps = s.ps('p_0');
ps.funds = 50;
const itemAt = ps.shop.slots.findIndex((x) => x && x.kind === 'item');
assert.ok(itemAt >= 0);
const itemId = ps.shop.slots[itemAt].id;
assert.deepEqual(s.m.handle('p_0', { t: 'g.freeze' }), { ok: true });
assert.deepEqual(s.m.handle('p_0', { t: 'g.levelUp' }), { ok: true });
s.drive(() => s.m.phase === PHASE.PREP && s.m.round === 2, { ready: true });
const now = ps.shop.slots.findIndex((x) => x && x.kind === 'item');
assert.ok(now > itemAt, `the item slot moved right (${itemAt} → ${now})`);
assert.equal(ps.shop.slots[now].id, itemId, 'the frozen item was kept');
s.drive(() => s.ended != null, { ready: true, maxSteps: 3e6 });
assert.ok(s.ended, 'the solo match ended');
assert.deepEqual(sa.violations, [], sa.violations.join('\n'));
s.m.dispose();
});
+265
View File
@@ -0,0 +1,265 @@
// test/match/harness.js — helpers for the match-engine tests (test/match/*.test.js).
//
// const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 1, bots: 1, seed: 7, fake: true });
// h.start(); h.runToPhase('PREP'); const ps = h.ps('p_0'); …; h.invariants();
//
// Options: mode, difficulty, humans (count) | seats (explicit), bots, seed, matchNo (the room's match number: part of
// the battleId prefix), data (default: real data/*.json),
// fake (true → test/match/fakeBattle.js as BattleClass), script (FakeBattle.script), registry, instant (virtual
// scheduler runs battles synchronously; default true), timerScale, battleContent, botRehearsal (default 0),
// botSliceMs (bot rehearsal slice budget; default: unbounded in virtual time).
// Combat mode: clientCombat (default false here: the legacy server-run mode most suites were written for; production
// defaults to client-side combat, DESIGN §14). With clientCombat: true every human gets a scripted browser
// (test/match/simClient.js SimClient: h.clients) unless clients: false; pace 'instant' | 'paced', perPlayer
// { [playerId]: SimClient options } (tamper / mute / stall …), verify ('off' | 'sample' | 'all'), headlessSliceMs
// (server-run fields in wall-clock slices even in virtual time).
// Hooks: h.onSend [(playerId, msg) => void], h.onBroadcast [(msg) => void].
// Captures: h.sent [[playerId, msg]], h.bc [msg], h.ended (summary), h.logs { error: [], warn: [] }.
import assert from 'node:assert/strict';
import { Match } from '../../server/match/Match.js';
import { VirtualScheduler } from '../../server/match/scheduler.js';
import { getData } from '../../server/data.js';
import { FIELD, canPlace, positionClass, tileKey } from '../../server/match/board.js';
import { FakeBattle } from './fakeBattle.js';
import { GEO } from '../../shared/constants.js';
import { collectViolations } from '../../server/match/invariants.js';
import { attachSimClients } from './simClient.js';
export const DATA = getData({ log: { warn() {}, error() {}, info() {} } });
export function makeMatch(o = {}) {
const mode = o.mode ?? 'coop';
const difficulty = o.difficulty ?? 'NORMAL';
let seats = o.seats;
if (!seats) {
seats = [];
const humans = o.humans ?? 1;
const bots = mode === 'solo' ? 0 : (o.bots ?? 0);
for (let i = 0; i < humans; i++) seats.push({ seat: seats.length, playerId: `p_${i}`, name: `P${i}`, isBot: false, connected: true });
for (let i = 0; i < bots; i++) seats.push({ seat: seats.length, playerId: `ai_${i}`, name: `AI${i}`, isBot: true, connected: true });
}
const sched = new VirtualScheduler({ instantCombat: o.instant !== false });
const logs = { error: [], warn: [] };
const log = { info() {}, debug() {}, warn: (...a) => logs.warn.push(a.map(String).join(' ')), error: (...a) => logs.error.push(a.map(String).join(' ')) };
const h = { sent: [], bc: [], ended: null, endedCount: 0, logs, sched, frames: 0, badFrames: [], onSend: [], onBroadcast: [], clients: null };
const captureFrames = o.captureFrames !== false;
const checkFrame = (msg) => {
// every frame must be JSON-safe: no NaN/Infinity, no undefined-only structures, serializable
if (!o.checkFrames) return;
const s = JSON.stringify(msg);
if (s == null || /NaN|Infinity/.test(s.replace(/"[^"]*"/g, ''))) h.badFrames.push(msg.t);
};
if (o.fake) {
FakeBattle.reset();
if (o.script) FakeBattle.script = o.script;
}
h.m = new Match({
roomCode: 'TEST', mode, difficulty, seats, seed: o.seed ?? 1, matchNo: o.matchNo, data: o.data ?? DATA, log,
send: (id, msg) => {
for (const fn of h.onSend) fn(id, msg);
if (msg.t === 'b.snap' || msg.t === 'b.ev') { h.frames++; if (!captureFrames) return true; }
checkFrame(msg);
h.sent.push([id, msg]);
return true;
},
broadcast: (msg) => { for (const fn of h.onBroadcast) fn(msg); checkFrame(msg); h.bc.push(msg); },
onEnd: (s) => { h.ended = s; h.endedCount++; },
scheduler: sched,
registry: o.registry,
BattleClass: o.fake ? FakeBattle : undefined,
battleContent: o.battleContent,
timerScale: o.timerScale,
// bot layout rehearsal (extra simulated battles per bot prep) is off unless a test asks for it: it multiplies the
// simulation work of full-match suites; test/match/bot.test.js covers it
botRehearsal: o.botRehearsal ?? 0,
botSliceMs: o.botSliceMs,
clientCombat: o.clientCombat ?? false,
verify: o.verify ?? 'off',
headlessSliceMs: o.headlessSliceMs,
});
const m = h.m;
if (m.clientCombat && o.clients !== false) {
h.clients = attachSimClients(h, { BattleClass: o.fake ? FakeBattle : undefined, pace: o.pace, perPlayer: o.perPlayer });
}
h.start = () => { m.start(); return h; };
h.ps = (id) => m.players.get(id);
h.run = (pred, opts = {}) => sched.runUntil(pred, { maxSteps: 3e6, ...opts });
/** run until the phase (and round) is reached */
h.runToPhase = (phase, round = null, opts = {}) => {
const ok = h.run(() => h.ended != null || (m.phase === phase && (round == null || m.round === round)), opts);
assert.ok(ok && m.phase === phase && (round == null || m.round === round), `expected ${phase}${round != null ? ' R' + round : ''}, got ${m.phase} R${m.round}${h.ended ? ' (ended)' : ''}`);
return h;
};
h.runToEnd = (opts = {}) => { h.run(() => h.ended != null, opts); assert.ok(h.ended, `match did not end (phase ${m.phase} R${m.round})`); return h.ended; };
/** last message of a type sent to a player (unicast) */
h.lastTo = (id, type) => { for (let i = h.sent.length - 1; i >= 0; i--) if (h.sent[i][0] === id && h.sent[i][1].t === type) return h.sent[i][1]; return null; };
h.lastBc = (type) => { for (let i = h.bc.length - 1; i >= 0; i--) if (h.bc[i].t === type) return h.bc[i]; return null; };
h.allTo = (id, type) => h.sent.filter(([pid, msg]) => pid === id && msg.t === type).map(([, msg]) => msg);
h.flushAll = () => { sched.advance(200); m.flush(true); };
/** give the human every draft decision automatically (humans otherwise wait for timers) */
h.autoHumans = () => { for (const ps of m.players.values()) if (!ps.isBot) ps.autoplay = true; return h; };
h.invariants = () => checkInvariants(m);
/** force the stage (tests need known geometry) */
h.setStage = (stageId) => {
m.stageId = stageId;
m.stage = m.gd.stage(stageId);
m._botPath = null;
for (const ps of m.players.values()) ps.invalidateDeployMap();
return h;
};
/** drive human decisions (info, band, 机变, ready) until `pred` holds */
h.drive = (pred, { ready = true, band = 'band_bldsk', maxSteps = 3e6 } = {}) => {
for (let i = 0; i < maxSteps; i++) {
if (pred()) return true;
if (h.ended) return !!pred();
for (const ps of m.players.values()) {
if (ps.isBot || ps.left) continue;
if (m.phase === 'INFO_CHECK' && !ps.infoReady) m.handle(ps.playerId, { t: 'g.infoReady' });
if (m.phase === 'BAND_DRAFT' && m.draftTurn() === ps.playerId) m.handle(ps.playerId, { t: 'g.band', bandId: band });
if (m.phase === 'SP_DRAFT' && m.spTurn() === ps.playerId) {
const idx = m.sp.cards.map((c) => c.idx).find((k) => m.sp.taken[k] == null);
if (idx != null) m.handle(ps.playerId, { t: 'g.choice', idx });
}
if (ready && m.phase === 'PREP' && ps.alive && !ps.ready) {
if (!ps.tempEmpty) ps.resolveTemp();
m.handle(ps.playerId, { t: 'g.ready', ready: true });
}
}
if (pred()) return true;
if (!sched.runNext()) return !!pred();
}
return !!pred();
};
/** reach PREP of round r (humans confirm/draft/ready automatically) */
h.toPrep = (r = 1, opts = {}) => {
const ok = h.drive(() => m.phase === 'PREP' && m.round === r, { ...opts, ready: true, stopAtPrep: r });
assert.ok(ok, `could not reach PREP R${r} (phase ${m.phase} R${m.round}${h.ended ? ', ended' : ''})`);
return h;
};
return h;
}
/** Engine invariants (DESIGN §11). Throws an AssertionError listing every violation (server/match/invariants.js). */
export function checkInvariants(m) {
const violations = collectViolations(m);
assert.deepEqual(violations, [], `invariants violated (${m.phase} R${m.round}):\n ${violations.join('\n ')}`);
return legacyInvariants(m);
}
/** The original harness checks (kept alongside collectViolations; they must agree). */
function legacyInvariants(m) {
const pool = m.pool;
const held = new Map();
const uids = new Set();
const note = (p) => {
assert.ok(Number.isInteger(p.uid) && p.uid > 0, `bad uid ${p.uid}`);
assert.ok(!uids.has(p.uid), `duplicate uid ${p.uid}`);
uids.add(p.uid);
};
for (const ps of m.players.values()) {
assert.ok(Number.isInteger(ps.funds) && ps.funds >= 0, `${ps.playerId} funds ${ps.funds}`);
assert.ok(Number.isInteger(ps.pendingFunds) && ps.pendingFunds >= 0, `${ps.playerId} pending ${ps.pendingFunds}`);
assert.equal(ps.hand.length, GEO.HAND_SIZE);
assert.equal(ps.temp.length, GEO.TEMP_SIZE);
assert.ok(ps.shop.level >= 1 && ps.shop.level <= m.gd.maxShopLevel);
assert.ok(ps.shop.upgradePrice >= 0);
assert.ok(ps.shop.freeRefreshes >= 0);
if (!ps.alive) {
assert.equal(ps.board.size, 0, 'eliminated player keeps no board');
assert.ok(ps.hand.every((x) => x == null) && ps.temp.every((x) => x == null), 'eliminated player keeps nothing');
}
const chessUids = new Set();
const all = [...ps.board.values(), ...ps.hand.filter(Boolean), ...ps.temp.filter(Boolean)];
for (const p of all) if (p.kind === 'chess') chessUids.add(p.uid);
let deployed = 0;
const dmap = ps.deployMap();
for (const [k, p] of ps.board) {
const [r, c] = k.split(',').map(Number);
assert.ok(r >= FIELD.r0 && r <= FIELD.r1 && c >= FIELD.c0 && c <= FIELD.c1, `piece outside the board ${k}`);
const rec = p.kind === 'token' ? m.gd.token(p.id) : m.gd.chess(p.id);
assert.ok(rec, `unknown board piece ${p.id}`);
assert.ok(canPlace(dmap, positionClass(rec), r, c), `illegal tile ${p.id} @ ${k}`);
assert.notEqual(p.kind, 'item', 'items never stand on the board');
if (p.kind === 'chess') deployed++;
if (p.kind === 'token') assert.ok(chessUids.has(p.ownerUid), `orphan token ${p.uid}`);
}
assert.ok(deployed <= ps.deployCap, `${ps.playerId} deployCount ${deployed} > cap ${ps.deployCap}`);
for (const p of all) {
note(p);
if (p.kind === 'chess') {
const rec = m.gd.chess(p.id);
assert.ok(rec, `unknown chess ${p.id}`);
assert.ok(Array.isArray(p.items) && p.items.length <= m.gd.equipPerChess, `${p.id} carries ${p.items && p.items.length} items`);
for (const it of p.items) { note(it); assert.equal(it.kind, 'item'); assert.ok(m.gd.item(it.id), `unknown item ${it.id}`); }
assert.ok(Number.isInteger(p.poolCopies) && p.poolCopies >= 0);
const base = m.gd.baseIdOf(p.id);
held.set(base, (held.get(base) || 0) + p.poolCopies);
} else if (p.kind === 'item') {
assert.ok(m.gd.item(p.id), `unknown item ${p.id}`);
} else if (p.kind === 'token') {
assert.ok(p.count >= 1, 'token stack count');
assert.ok(chessUids.has(p.ownerUid), `orphan token ${p.uid}`);
}
}
// merges are immediate: never 3 normal copies (风丸 2) of one chess owned at once
const counts = new Map();
for (const p of all) if (p.kind === 'chess' && !m.gd.isGolden(p.id)) { const b = m.gd.baseIdOf(p.id); counts.set(b, (counts.get(b) || 0) + 1); }
for (const [b, n] of counts) {
const need = m.gd.mergeCount(b);
if (need > 1 && m.gd.goldenIdOf(b)) assert.ok(n < need, `${ps.playerId} owns ${n} copies of ${b} (merge ${need})`);
}
}
for (const [base, e] of pool.entries) {
assert.ok(e.left >= 0 && e.left <= e.cap, `pool ${base} left ${e.left} cap ${e.cap}`);
assert.equal(e.left + (held.get(base) || 0), e.cap, `pool accounting ${base}: left ${e.left} + held ${held.get(base) || 0} != cap ${e.cap}`);
}
for (const [base, n] of held) if (!pool.has(base)) assert.equal(n, 0, `non-pool chess ${base} holds copies`);
return true;
}
/** Put a chess piece straight into a player's hand/board for scenario setup (takes pool copies like a buy). */
export function give(m, ps, chessId, where = 'hand', at = null) {
const rec = m.gd.chess(chessId);
assert.ok(rec, `unknown chess ${chessId}`);
const base = m.gd.baseIdOf(chessId);
const taken = m.pool.take(base, rec.isGolden ? m.gd.goldenCopies : 1);
const piece = ps.newPiece('chess', chessId, { poolCopies: taken });
if (where === 'board') {
ps.board.set(tileKey(at[0], at[1]), piece);
ps.grantTokensFor(piece);
} else if (where === 'temp') {
const i = at ?? ps.temp.findIndex((x) => x == null);
ps.temp[i] = piece;
} else {
const i = at ?? ps.hand.findIndex((x) => x == null);
ps.hand[i] = piece;
}
ps.recompute();
return piece;
}
export function giveItem(m, ps, itemId, where = 'hand', at = null) {
assert.ok(m.gd.item(itemId), `unknown item ${itemId}`);
const piece = ps.newPiece('item', itemId);
if (where === 'temp') ps.temp[at ?? ps.temp.findIndex((x) => x == null)] = piece;
else ps.hand[at ?? ps.hand.findIndex((x) => x == null)] = piece;
ps.recompute();
return piece;
}
/** Visible chess ids of a tier (sorted), optionally filtered. */
export function chessOfTier(tier, filter = () => true) {
return Object.values(DATA.chess).filter((c) => c.visible && !c.isGolden && c.tier === tier && filter(c)).map((c) => c.chessId).sort();
}
/** First legal board tile for a chess (reading order). */
export function legalTileFor(m, ps, chessId, skip = new Set()) {
const pos = positionClass(m.gd.chess(chessId));
const map = ps.deployMap();
for (let r = FIELD.r1; r >= FIELD.r0; r--) for (let c = FIELD.c0; c <= FIELD.c1; c++) {
if (skip.has(tileKey(r, c)) || ps.board.has(tileKey(r, c))) continue;
if (canPlace(map, pos, r, c)) return [r, c];
}
return null;
}
+281
View File
@@ -0,0 +1,281 @@
// Server leftovers (DESIGN §14): battleIds unique across the matches of a room (a late report from the previous match
// is ignored), duplicate b.result idempotent, the Final Assault's m.public throttled to ~1 Hz (b.pool is live), the
// solo pause (g.pause → m.public.paused: field clock, deadlines, boss clock, server pacers frozen), protocol additions
// (g.pause, g.equip replaceUid).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { PHASE, ERR } from '../../shared/constants.js';
import { validateC2S } from '../../shared/protocol.js';
import { createBattleFromSpec, compactResult } from '../../server/sim/spec.js';
import { RESULT_GRACE_MS, BOSS_SILENCE_MS } from '../../server/match/fields.js';
import { FakeBattle } from './fakeBattle.js';
import { makeMatch, checkInvariants } from './harness.js';
/** A plausible client result for a spec (FakeBattle stepped to its end, like test/match/simClient.js). */
function fakeResult(m, spec) {
const b = createBattleFromSpec(spec, m.ds, { BattleClass: FakeBattle, recordEvents: false, quiet: true });
for (let i = 0; i < 1e5 && !b.finished; i++) b.step();
return compactResult(b.result());
}
const pub = (h) => { h.m.flush(true); return h.lastBc('m.public'); };
// ---- battleId ---------------------------------------------------------------------------------------------------
test('battleIds carry a per-match prefix (seed + the room\'s match number): a late b.progress / b.result of the previous match is ignored', () => {
const mk = (matchNo) => {
const h = makeMatch({ mode: 'solo', difficulty: 'FUNNY', humans: 1, seed: 4242, matchNo, fake: true, clientCombat: true, clients: false }).start();
assert.ok(h.drive(() => h.m.phase === PHASE.COMBAT));
return h;
};
const a = mk(1);
const b = mk(2);
const sa = a.lastTo('p_0', 'b.start');
const sb = b.lastTo('p_0', 'b.start');
assert.notEqual(sa.battleId, sb.battleId, 'same seed, same field, another match of the room');
assert.ok(sa.battleId.startsWith(`${(4242).toString(36)}-1.1.`) && sb.battleId.startsWith(`${(4242).toString(36)}-2.1.`), `${sa.battleId} / ${sb.battleId}`);
assert.deepEqual({ ...sa.spec, battleId: null }, { ...sb.spec, battleId: null }, 'the very same battle otherwise');
const result = fakeResult(a.m, sa.spec);
const f = b.m.fields[0];
// the previous match's reports reach the new match (same socket, same room): ignored, never an error
assert.deepEqual(b.m.handle('p_0', { t: 'b.progress', battleId: sa.battleId, gt: 5, killed: 1, total: f.progress.total }), { ok: true });
assert.equal(f.progress.killed, 0);
assert.deepEqual(b.m.handle('p_0', { t: 'b.result', battleId: sa.battleId, result }), { ok: true });
assert.equal(f.done, false, 'the stale result did not settle the new match\'s battle');
assert.deepEqual(b.m.handle('p_0', { t: 'b.result', battleId: sb.battleId, result }), { ok: true });
assert.equal(f.done, true);
assert.equal(f.resultSource, 'client');
a.m.dispose();
b.m.dispose();
});
test('battleIds: unique within a match, valid protocol ids (≤ 64 chars) even for long player ids; no matchNo ⇒ the seed alone', () => {
const long = `p_${'x'.repeat(60)}`;
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', seats: [{ seat: 0, playerId: long, name: 'L', isBot: false, connected: true }, { seat: 1, playerId: 'ai_0', name: 'AI', isBot: true, connected: true }], seed: 77, fake: true, clientCombat: true, clients: false }).start();
const ids = new Set();
for (let r = 1; r <= 3; r++) {
assert.ok(h.drive(() => h.m.phase === PHASE.COMBAT && h.m.round === r));
for (const f of h.m.fields) {
assert.ok(!ids.has(f.battleId), `unique ${f.battleId}`);
ids.add(f.battleId);
assert.ok(f.battleId.length <= 64 && f.battleId.startsWith(`${(77).toString(36)}.${r}.`), f.battleId);
assert.equal(validateC2S({ t: 'b.progress', battleId: f.battleId, gt: 0, killed: 0, total: 0 }), null);
}
h.drive(() => h.m.phase === PHASE.PREP || h.ended != null);
}
assert.equal(h.m.errorCount, 0);
h.m.dispose();
});
// ---- duplicate b.result -------------------------------------------------------------------------------------------
test('a duplicate b.result (a re-send after a lost reply) is accepted idempotently: ok, nothing changes', () => {
const h = makeMatch({ mode: 'solo', difficulty: 'FUNNY', humans: 1, seed: 4243, fake: true, clientCombat: true, clients: false }).start();
const m = h.m;
assert.ok(h.drive(() => m.phase === PHASE.COMBAT));
const start = h.lastTo('p_0', 'b.start');
const f = m.fields[0];
const result = fakeResult(m, start.spec);
assert.deepEqual(m.handle('p_0', { t: 'b.result', battleId: start.battleId, result }), { ok: true });
assert.equal(f.done, true);
const kept = f.result;
const stats = { ...m.verifyStats };
const lp = m.players.get('p_0').lp;
for (let i = 0; i < 3; i++) assert.deepEqual(m.handle('p_0', { t: 'b.result', battleId: start.battleId, result }), { ok: true });
assert.equal(f.result, kept);
assert.deepEqual(m.verifyStats, stats);
// and once the round moved on
h.run(() => m.phase === PHASE.SETTLE);
assert.deepEqual(m.handle('p_0', { t: 'b.result', battleId: start.battleId, result }), { ok: true });
h.run(() => m.phase === PHASE.PREP);
assert.equal(m.players.get('p_0').lp, lp - Math.min(10, (kept.perPlayer.p_0.leaked || []).filter((l) => l.counted !== false).length));
assert.equal(m.errorCount, 0);
checkInvariants(m);
m.dispose();
});
// ---- Final Assault m.public throttle ------------------------------------------------------------------------------
test('Final Assault: boss b.progress (4 Hz per field) no longer re-marks m.public — ~1 Hz, while b.pool stays ≤ 4 Hz with the exact numbers', () => {
const h = makeMatch({ mode: 'coop', difficulty: 'FUNNY', humans: 2, seed: 9111, fake: true, clientCombat: true, instant: false,
script: (b) => (b.kind === 'boss' ? { bossDps: 300, leakEvents: [{ at: 3, lpr: 2 }, { at: 5, lpr: 1 }] } : { duration: 2 }) }).start();
const m = h.m;
h.autoHumans();
assert.ok(h.drive(() => m.phase === PHASE.FINAL_ASSAULT));
const pubs = [];
const pools = [];
h.onBroadcast.push((msg) => {
if (msg.t === 'm.public' && msg.phase === PHASE.FINAL_ASSAULT) pubs.push({ at: h.sched.now(), msg });
if (msg.t === 'b.pool') pools.push(msg);
});
const lp0 = m.teamLp;
const t0 = h.sched.now();
h.sched.advance(8000);
const secs = (h.sched.now() - t0) / 1000;
const progress = h.clients.get('p_0').log.filter((x) => x.t === 'b.progress').length;
assert.ok(progress >= secs * 3, `the boss field reports at ~4 Hz (${progress})`);
assert.ok(pubs.length <= secs + 3, `m.public ≈ 1 Hz during the boss fight (${pubs.length} in ${secs} s)`);
assert.ok(pubs.length >= secs - 2, `…but it keeps following the fight (${pubs.length})`);
assert.ok(pools.length >= secs * 3, `b.pool stays live (${pools.length})`);
// the throttled view is at most one period behind: team LP and the players' shares agree with it
assert.equal(m.teamLp, lp0 - 3);
h.sched.advance(1100);
const last = pub(h);
assert.equal(last.teamLp, Math.round(m.teamLp));
assert.equal(last.players.filter((p) => p.alive).reduce((s, p) => s + p.lp, 0), Math.round(m.teamLp), 'Σ lp = team LP');
assert.equal(last.bossHp.hp, Math.max(0, Math.round(m.bossPool.hp)));
assert.equal(pools[pools.length - 1].teamLp, Math.round(m.teamLp));
const end = h.runToEnd({ maxSteps: 5e6 });
assert.ok(end);
assert.equal(m.errorCount, 0);
checkInvariants(m);
m.dispose();
});
// ---- solo pause ---------------------------------------------------------------------------------------------------
test('protocol: g.pause { on } and g.equip replaceUid (optional)', () => {
assert.equal(validateC2S({ t: 'g.pause', on: true }), null);
assert.equal(validateC2S({ t: 'g.pause', on: false, rid: 3 }), null);
assert.notEqual(validateC2S({ t: 'g.pause' }), null);
assert.notEqual(validateC2S({ t: 'g.pause', on: 1 }), null);
assert.equal(validateC2S({ t: 'g.equip', itemUid: 1, targetUid: 2 }), null);
assert.equal(validateC2S({ t: 'g.equip', itemUid: 1, targetUid: 2, replaceUid: 3 }), null);
assert.equal(validateC2S({ t: 'g.equip', itemUid: 1, targetUid: 2, replaceUid: null }), null);
assert.notEqual(validateC2S({ t: 'g.equip', itemUid: 1, targetUid: 2, replaceUid: 'x' }), null);
assert.notEqual(validateC2S({ t: 'g.equip', itemUid: 1, targetUid: 2, replaceUid: 0 }), null);
});
test('solo pause: co-op never pauses; solo only while a battle runs; m.public.paused', () => {
const co = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 2, seed: 5150, fake: true, clientCombat: true, clients: false, instant: false }).start();
assert.ok(co.drive(() => co.m.phase === PHASE.COMBAT));
assert.equal(co.m.handle('p_0', { t: 'g.pause', on: true }).error, ERR.WRONG_PHASE);
assert.equal(pub(co).paused, false);
co.m.dispose();
const h = makeMatch({ mode: 'solo', difficulty: 'FUNNY', humans: 1, seed: 5151, fake: true, clientCombat: true, clients: false, instant: false }).start();
const m = h.m;
assert.equal(pub(h).paused, false, 'always present');
assert.ok(h.drive(() => m.phase === PHASE.PREP, { ready: false }));
assert.equal(m.handle('p_0', { t: 'g.pause', on: true }).error, ERR.WRONG_PHASE, 'prep is untimed in solo: nothing to pause');
assert.deepEqual(m.handle('p_0', { t: 'g.pause', on: false }), { ok: true });
assert.equal(m.paused, false);
m.dispose();
});
test('solo pause: the field clock, the result deadline and the HUD countdown stand still; resume shifts them by the pause', () => {
const h = makeMatch({ mode: 'solo', difficulty: 'FUNNY', humans: 1, seed: 5152, fake: true, clientCombat: true, clients: false, instant: false }).start();
const m = h.m;
assert.ok(h.drive(() => m.phase === PHASE.COMBAT));
const f = m.fields[0];
assert.deepEqual([f.mode, f.authority], ['client', 'p_0']);
const deadline0 = m.deadline;
const due0 = f.startAt + Math.round((f.spec.timeLimit / m.gameSpeed) * 1000) + RESULT_GRACE_MS;
h.sched.advance(2000);
assert.deepEqual(m.handle('p_0', { t: 'g.pause', on: true }), { ok: true });
assert.deepEqual(m.handle('p_0', { t: 'g.pause', on: true }), { ok: true }, 'idempotent');
assert.equal(pub(h).paused, true);
const el = m._fieldElapsed(f);
const PAUSE = 10 * 60 * 1000;
h.sched.advance(PAUSE);
assert.equal(m.phase, PHASE.COMBAT);
assert.deepEqual([f.mode, f.done], ['client', false], 'no takeover while paused (the deadline passed long ago)');
assert.equal(m._fieldElapsed(f), el, 'the field clock stood still');
m.onReconnect('p_0'); // a resync while paused: the replica is built at the frozen clock
assert.equal(h.lastTo('p_0', 'b.start').elapsed, Math.round(el * 1000) / 1000);
assert.equal(h.lastTo('p_0', 'm.public').paused, true, 'm.public first: the runner is paused before it builds the battle');
assert.deepEqual(m.handle('p_0', { t: 'g.pause', on: false }), { ok: true });
const p = pub(h);
assert.equal(p.paused, false);
assert.equal(p.deadline, deadline0 + PAUSE, 'the countdown resumes where it stopped');
assert.ok(Math.abs(m._fieldElapsed(f) - el) < 1e-9);
h.sched.advance(due0 + PAUSE - h.sched.now() - 50);
assert.equal(f.mode, 'client', 'the result deadline moved by the pause');
h.sched.advance(100);
assert.equal(f.mode, 'server', 'then the silent client is taken over');
assert.equal(m.pausedMs, PAUSE);
assert.equal(m.errorCount, 0);
m.dispose();
});
test('solo pause: a disconnect resumes (the server takes the field over); the battle phase ending clears the pause', () => {
const h = makeMatch({ mode: 'solo', difficulty: 'FUNNY', humans: 1, seed: 5153, fake: true, clientCombat: true, clients: false, instant: false }).start();
const m = h.m;
assert.ok(h.drive(() => m.phase === PHASE.COMBAT));
assert.deepEqual(m.handle('p_0', { t: 'g.pause', on: true }), { ok: true });
h.sched.advance(5000);
m.onDisconnect('p_0');
assert.equal(m.paused, false);
assert.equal(m.fields[0].mode, 'server');
assert.equal(pub(h).paused, false);
m.onReconnect('p_0');
h.run(() => m.phase === PHASE.PREP);
assert.equal(m.errorCount, 0);
m.dispose();
// the client's result crosses the pause: the phase ends, the pause goes with it
const h2 = makeMatch({ mode: 'solo', difficulty: 'FUNNY', humans: 1, seed: 5154, fake: true, clientCombat: true, clients: false, instant: false }).start();
const m2 = h2.m;
assert.ok(h2.drive(() => m2.phase === PHASE.COMBAT));
const start = h2.lastTo('p_0', 'b.start');
assert.deepEqual(m2.handle('p_0', { t: 'g.pause', on: true }), { ok: true });
assert.deepEqual(m2.handle('p_0', { t: 'b.result', battleId: start.battleId, result: fakeResult(m2, start.spec) }), { ok: true });
assert.equal(m2.paused, false);
assert.equal(pub(h2).paused, false);
h2.run(() => m2.phase === PHASE.PREP && m2.round === 2);
assert.equal(m2.handle('p_0', { t: 'g.pause', on: true }).error, ERR.WRONG_PHASE);
assert.equal(m2.errorCount, 0);
m2.dispose();
});
test('solo pause in the Final Assault: no overtime drain, no silence handover, the boss clock stops; resume shifts overtimeAt', () => {
const h = makeMatch({ mode: 'solo', difficulty: 'FUNNY', humans: 1, seed: 5155, fake: true, clientCombat: true, instant: false,
script: (b) => (b.kind === 'boss' ? { bossDps: 0 } : { duration: 2 }) }).start();
const m = h.m;
h.autoHumans();
assert.ok(h.drive(() => m.phase === PHASE.FINAL_ASSAULT || h.ended != null, { maxSteps: 5e6 }));
assert.equal(m.phase, PHASE.FINAL_ASSAULT);
h.clients.closeAll(); // the browser goes quiet: only the silence watchdog would move the field
const f = m.fields[0];
assert.deepEqual([f.mode, f.authority], ['client', 'p_0']);
h.sched.advance(1000);
assert.deepEqual(m.handle('p_0', { t: 'g.pause', on: true }), { ok: true });
const lp = m.teamLp;
const overtimeAt = m.overtimeAt;
const el = m._fieldElapsed(f);
const PAUSE = 400 * 1000; // far beyond the overtime start (150 s) and the silence watchdog (12 s)
h.sched.advance(PAUSE);
assert.equal(m.teamLp, lp, 'no overtime drain while paused');
assert.equal(f.mode, 'client', 'no silence handover while paused');
assert.equal(m._fieldElapsed(f), el);
assert.equal(m._bossClock, null);
assert.deepEqual(m.handle('p_0', { t: 'g.pause', on: false }), { ok: true });
assert.equal(m.overtimeAt, overtimeAt + PAUSE);
assert.equal(pub(h).overtimeAt, overtimeAt + PAUSE);
assert.ok(m._bossClock, 'the boss clock runs again');
h.sched.advance(BOSS_SILENCE_MS + 1000);
assert.equal(f.mode, 'server', 'the watchdog works again after the resume');
assert.equal(m.teamLp, lp, 'still before the overtime start on the field clock');
assert.equal(m.errorCount, 0);
m.dispose();
});
test('solo pause with server-run combat (SP_COMBAT=server): the FieldRunner does not step while paused', () => {
const h = makeMatch({ mode: 'solo', difficulty: 'FUNNY', humans: 1, seed: 5156, fake: true, instant: false, script: () => ({ duration: 20 }) }).start();
const m = h.m;
assert.ok(h.drive(() => m.phase === PHASE.COMBAT));
h.sched.advance(1000);
const b = m.fields[0].battle;
const t = b.time;
assert.ok(t > 1.5, `running (${t})`);
assert.deepEqual(m.handle('p_0', { t: 'g.pause', on: true }), { ok: true });
h.sched.advance(30000);
assert.equal(b.time, t);
assert.equal(m.phase, PHASE.COMBAT);
assert.deepEqual(m.handle('p_0', { t: 'g.pause', on: false }), { ok: true });
h.sched.advance(1000);
assert.ok(b.time > t + 1.5 && b.time < t + 2.5, `continues at 2× without a catch-up burst (${t} → ${b.time})`);
h.run(() => m.phase === PHASE.PREP);
assert.equal(m.errorCount, 0);
m.dispose();
});
+293
View File
@@ -0,0 +1,293 @@
// Operator loadout (DESIGN §16) — shared checks (shared/protocol.js checkLoadout / loadoutOptions / resolveLoadout),
// the match side (seats[].loadout → PlayerState → PlayerBattleInput units skillIndex / moduleId → BattleSpec, m.private
// `loadout`, Match.setLoadout only during INFO_CHECK, bots on defaults) and the solo timers (user playtest #11: official
// 独立模拟 has no countdown outside combat).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { ERR, PHASE } from '../../shared/constants.js';
import { validateC2S, checkLoadout, loadoutOptions, resolveLoadout, isLoadoutEntries, MODULE_NONE, LOADOUT_LIMITS } from '../../shared/protocol.js';
import { buildBattleSpec } from '../../server/sim/spec.js';
import { DATA, makeMatch } from './harness.js';
const chess = (id) => (Object.hasOwn(DATA.chess, id) ? DATA.chess[id] : null);
const visible = Object.values(DATA.chess).filter((c) => !c.isGolden && c.visible && !c.isHidden && !c.isDiy);
// fixtures from the real data (DESIGN §16 skills[] / modules[])
const INSIDE = 'chess_char_1_01_a'; // 隐现: S1 / S2 (default S2), elite module MAR-X only
const SWIRE = 'chess_char_3_04_a'; // 琳琅诗怀雅: three modules
const multiMod = visible.find((c) => (chess(c.goldenId)?.modules || []).length >= 2);
const hidden = Object.values(DATA.chess).find((c) => !c.isGolden && (c.isHidden || c.visible === false));
function seatsWith(loadout, { bots = 0, botLoadout = null } = {}) {
const seats = [{ seat: 0, playerId: 'p_0', name: 'P0', isBot: false, connected: true, loadout }];
for (let i = 0; i < bots; i++) seats.push({ seat: seats.length, playerId: `ai_${i}`, name: `AI${i}`, isBot: true, connected: true, loadout: botLoadout });
return seats;
}
/** Put a chess piece straight onto a board tile (no pool / legality: the battle input is what is under test). */
function place(m, ps, chessId, row, col) {
const p = ps.newPiece('chess', chessId);
ps.board.set(`${row},${col}`, p);
return p;
}
// ---- shared checks -------------------------------------------------------------------------------------------------
test('data carries the §16 choices every visible chess needs (skills at both statuses, elite modules + none)', () => {
assert.equal(visible.length, 112);
for (const c of visible) {
const g = chess(c.goldenId);
const o = loadoutOptions(c, g);
assert.ok(o.skills.length >= 2, `${c.name}: selectable skills`);
assert.ok(o.skills.includes(o.defaultSkill), `${c.name}: default skill is selectable`);
assert.equal(o.defaultSkill, c.skill.index, `${c.name}: default = defaultSkillIndex`);
assert.ok(o.modules.includes(MODULE_NONE) && o.modules.includes(o.defaultModule), `${c.name}: modules`);
}
const o = loadoutOptions(chess(INSIDE), chess(chess(INSIDE).goldenId));
assert.deepEqual(o.skills, [0, 1]);
assert.equal(o.defaultSkill, 1);
assert.deepEqual(o.modules, ['uniequip_002_inside', MODULE_NONE]);
assert.equal(o.defaultModule, 'uniequip_002_inside');
assert.ok(multiMod, 'a chess with several modules');
});
test('room.loadout structure: map of ≤160 base ids → { skill?, module? }, nothing else', () => {
const ok = (entries) => validateC2S({ t: 'room.loadout', entries }) === null;
assert.ok(ok({}));
assert.ok(ok({ [INSIDE]: { skill: 0 } }));
assert.ok(ok({ [INSIDE]: { module: 'none' } }));
assert.ok(ok({ [INSIDE]: { skill: 1, module: 'uniequip_002_inside' } }));
assert.ok(!ok([]), 'array');
assert.ok(!ok(null), 'null');
assert.ok(!ok({ [INSIDE]: {} }), 'empty entry');
assert.ok(!ok({ [INSIDE]: { skill: 0, extra: 1 } }), 'unknown field');
assert.ok(!ok({ [INSIDE]: { skill: LOADOUT_LIMITS.skillIndex + 1 } }), 'skill index range');
assert.ok(!ok({ [INSIDE]: { skill: 0.5 } }), 'non-integer skill');
assert.ok(!ok({ [INSIDE]: { module: '' } }), 'empty module id');
assert.ok(!ok({ [INSIDE]: { module: 'a b' } }), 'bad module id');
assert.ok(!ok({ 'bad id!': { skill: 0 } }), 'bad chess id');
const many = {};
for (let i = 0; i <= LOADOUT_LIMITS.entries; i++) many[`c${i}`] = { skill: 0 };
assert.ok(!ok(many), 'too many entries');
assert.equal(validateC2S({ t: 'room.loadout' }), 'bad field entries');
assert.ok(!isLoadoutEntries(Object.create({ [INSIDE]: { skill: 0 } })), 'plain objects only');
});
test('checkLoadout: strict semantic check against the data; defaults dropped; normalised { skill, module }', () => {
const get = chess;
assert.deepEqual(checkLoadout({}, get), { ok: true, loadout: {} });
// a non-default skill; the module is completed with the default
assert.deepEqual(checkLoadout({ [INSIDE]: { skill: 0 } }, get), { ok: true, loadout: { [INSIDE]: { skill: 0, module: 'uniequip_002_inside' } } });
// the default skill + default module ⇒ nothing stored
assert.deepEqual(checkLoadout({ [INSIDE]: { skill: 1 } }, get), { ok: true, loadout: {} });
assert.deepEqual(checkLoadout({ [INSIDE]: { module: 'none' } }, get), { ok: true, loadout: { [INSIDE]: { skill: 1, module: 'none' } } });
const sg = chess(chess(SWIRE).goldenId);
const alt = sg.modules.find((x) => !x.isDefault).uniEquipId;
assert.deepEqual(checkLoadout({ [SWIRE]: { module: alt } }, get).loadout[SWIRE].module, alt);
// illegal: S3 of a chess with two skills, a module of another character, golden / unknown / hidden ids
const bad = (entries, code = ERR.BAD_TARGET) => {
const r = checkLoadout(entries, get);
assert.equal(r.error, code, JSON.stringify(entries));
assert.equal(typeof r.detail, 'string');
};
bad({ [INSIDE]: { skill: 2 } });
bad({ [INSIDE]: { module: alt } });
bad({ [INSIDE]: { module: 'uniequip_999_nobody' } });
bad({ [chess(INSIDE).goldenId]: { skill: 0 } });
bad({ chess_char_9_99_a: { skill: 0 } });
if (hidden) bad({ [hidden.chessId]: { skill: 0 } });
// one bad entry rejects the whole loadout
bad({ [SWIRE]: { module: alt }, [INSIDE]: { skill: 5 } });
bad([], ERR.BAD_MSG);
// a chess without an elite record has no module choice
const solo = { ...chess(INSIDE), goldenId: null };
const noGolden = (id) => (id === INSIDE ? solo : chess(id));
assert.equal(checkLoadout({ [INSIDE]: { module: 'none' } }, noGolden).error, ERR.BAD_TARGET);
assert.deepEqual(checkLoadout({ [INSIDE]: { skill: 0 } }, noGolden), { ok: true, loadout: { [INSIDE]: { skill: 0, module: null } } });
});
test('resolveLoadout: normal chess → moduleId null; elite → the chosen module / none; others → defaults', () => {
const lo = checkLoadout({ [INSIDE]: { skill: 0, module: 'none' } }, chess).loadout;
const n = chess(INSIDE);
const g = chess(n.goldenId);
assert.deepEqual(resolveLoadout(lo, n, chess), { skillIndex: 0, moduleId: null });
assert.deepEqual(resolveLoadout(lo, g, chess), { skillIndex: 0, moduleId: 'none' });
assert.deepEqual(resolveLoadout(null, g, chess), { skillIndex: 1, moduleId: 'uniequip_002_inside' });
const other = chess(SWIRE);
assert.deepEqual(resolveLoadout(lo, other, chess), { skillIndex: other.skill.index, moduleId: null });
// a stale entry (not selectable any more) falls back to the defaults, never throws
assert.deepEqual(resolveLoadout({ [INSIDE]: { skill: 7, module: 'gone' } }, g, chess), { skillIndex: 1, moduleId: 'uniequip_002_inside' });
});
// ---- match side ------------------------------------------------------------------------------------------------------------
test('seats[].loadout → PlayerState (re-checked, frozen), m.private.loadout, battle input skillIndex / moduleId; bots on defaults', () => {
const sg = chess(chess(SWIRE).goldenId);
const alt = sg.modules.find((x) => !x.isDefault).uniEquipId;
const loadout = checkLoadout({ [INSIDE]: { skill: 0, module: 'none' }, [SWIRE]: { skill: 0, module: alt } }, chess).loadout;
const h = makeMatch({ mode: 'coop', seats: seatsWith(loadout, { bots: 1, botLoadout: loadout }), seed: 3 }).start();
const m = h.m;
const ps = h.ps('p_0');
const bot = h.ps('ai_0');
assert.deepEqual(ps.loadout, loadout);
assert.ok(Object.isFrozen(ps.loadout) && Object.isFrozen(ps.loadout[INSIDE]));
assert.deepEqual(bot.loadout, {}, 'bots fight with the defaults');
h.flushAll();
assert.deepEqual(h.lastTo('p_0', 'm.private').loadout, loadout, 'm.private exposes the effective loadout');
h.toPrep(1);
for (const p of [...ps.board.values()]) ps.returnCopies(p);
ps.board.clear();
place(m, ps, INSIDE, 9, 3); // normal: S1, no module
place(m, ps, chess(INSIDE).goldenId, 10, 3); // elite: S1, module none
place(m, ps, sg.chessId, 11, 3); // elite: S1, the alternative module
const plain = visible.find((c) => c.chessId !== INSIDE && c.chessId !== SWIRE);
place(m, ps, plain.goldenId, 12, 3); // not in the loadout: defaults
const units = ps.battleInput().units;
const by = (id) => units.find((u) => u.chessId === id);
assert.equal(by(INSIDE).skillIndex, 0);
assert.equal(by(INSIDE).moduleId, null, 'normal chess have no module');
assert.deepEqual([by(chess(INSIDE).goldenId).skillIndex, by(chess(INSIDE).goldenId).moduleId], [0, 'none']);
assert.deepEqual([by(sg.chessId).skillIndex, by(sg.chessId).moduleId], [0, alt]);
const pg = chess(plain.goldenId);
assert.deepEqual([by(plain.goldenId).skillIndex, by(plain.goldenId).moduleId], [pg.skill.index, (pg.modules.find((x) => x.isDefault) || {}).uniEquipId || 'none']);
// the BattleSpec keeps them (JSON round trip; the normal chess's null module is dropped = default)
const spec = buildBattleSpec(m._normalOpts(ps));
const su = spec.players[0].units;
assert.equal(su.find((u) => u.chessId === INSIDE).skillIndex, 0);
assert.ok(!('moduleId' in su.find((u) => u.chessId === INSIDE)));
assert.equal(su.find((u) => u.chessId === sg.chessId).moduleId, alt);
// the bot's own board uses the defaults even for the same chess
bot.board.clear();
place(m, bot, chess(INSIDE).goldenId, 10, 3);
const bu = bot.battleInput().units[0];
assert.deepEqual([bu.skillIndex, bu.moduleId], [1, 'uniequip_002_inside']);
m.dispose();
});
test('client-side combat: the b.start spec of the owner carries the loadout of its board', () => {
const loadout = checkLoadout({ [INSIDE]: { skill: 0, module: 'none' } }, chess).loadout;
const h = makeMatch({ mode: 'solo', difficulty: 'NORMAL', seats: seatsWith(loadout), seed: 9, clientCombat: true, clients: false }).start();
const m = h.m;
h.toPrep(1);
const ps = h.ps('p_0');
for (const p of [...ps.board.values()]) ps.returnCopies(p);
ps.board.clear();
place(m, ps, chess(INSIDE).goldenId, 10, 3);
ps.resolveTemp();
assert.deepEqual(m.handle('p_0', { t: 'g.ready', ready: true }), { ok: true });
h.run(() => m.phase === PHASE.COMBAT);
const start = h.lastTo('p_0', 'b.start');
assert.ok(start && start.spec, 'b.start sent');
const u = start.spec.players[0].units.find((x) => x.chessId === chess(INSIDE).goldenId);
assert.deepEqual([u.skillIndex, u.moduleId], [0, 'none']);
m.dispose();
});
test('Match.setLoadout: accepted during INFO_CHECK only; re-checked; bots / strangers refused', () => {
const h = makeMatch({ mode: 'coop', humans: 2, bots: 1, seed: 4 }).start();
const m = h.m;
const ps = h.ps('p_0');
assert.deepEqual(ps.loadout, {});
const lo = checkLoadout({ [INSIDE]: { skill: 0 } }, chess).loadout;
assert.deepEqual(m.setLoadout('p_0', lo), { ok: true });
assert.deepEqual(ps.loadout, lo);
h.flushAll();
assert.deepEqual(h.lastTo('p_0', 'm.private').loadout, lo);
assert.equal(m.setLoadout('p_0', { [INSIDE]: { skill: 9 } }).error, ERR.BAD_TARGET, 'illegal for the data');
assert.deepEqual(ps.loadout, lo, 'unchanged after a refusal');
assert.equal(m.setLoadout('ai_0', lo).error, ERR.NOT_IN_ROOM);
assert.equal(m.setLoadout('nobody', lo).error, ERR.NOT_IN_ROOM);
for (const id of ['p_0', 'p_1']) m.handle(id, { t: 'g.infoReady' });
h.sched.advance(1);
assert.equal(m.phase, PHASE.BAND_DRAFT);
const r = m.setLoadout('p_0', {});
assert.equal(r.error, ERR.WRONG_PHASE, 'locked after INFO_CHECK');
assert.deepEqual(ps.loadout, lo);
m.dispose();
});
test('an illegal seat loadout (stale data) falls back to the defaults with a warning', () => {
const h = makeMatch({ mode: 'solo', seats: seatsWith({ [INSIDE]: { skill: 6, module: null } }), seed: 2 });
assert.deepEqual(h.ps('p_0').loadout, {});
assert.ok(h.logs.warn.some((w) => /loadout/.test(w)));
h.m.dispose();
});
// ---- solo timers (#11) ---------------------------------------------------------------------------------------------------
test('solo: no deadline outside combat (INFO_CHECK, strategy draft, 机变, prep and the fixed transitions); combat timed', () => {
// scripted battles (no leaks: the seat survives to the 机变 rounds), paced in virtual time (combat deadlines are real)
const h = makeMatch({ mode: 'solo', difficulty: 'NORMAL', seed: 12, fake: true, instant: false }).start();
const m = h.m;
const seen = new Map(); // phase → [deadline…]
const note = (pub) => { if (!seen.has(pub.phase)) seen.set(pub.phase, []); seen.get(pub.phase).push(pub.deadline); };
h.onBroadcast.push((msg) => { if (msg.t === 'm.public') note(msg); });
for (const msg of h.bc) if (msg.t === 'm.public') note(msg);
// INFO_CHECK waits for the player (no 25 s guard in solo)
assert.equal(m.phase, PHASE.INFO_CHECK);
assert.equal(m.deadline, 0);
h.sched.advance(10 * 60_000);
assert.equal(m.phase, PHASE.INFO_CHECK, 'solo briefing is untimed');
m.handle('p_0', { t: 'g.infoReady' });
h.sched.advance(1);
assert.equal(m.phase, PHASE.BAND_DRAFT);
assert.equal(m.deadline, 0);
h.sched.advance(5 * 60_000);
assert.equal(m.phase, PHASE.BAND_DRAFT, 'solo strategy draft is untimed');
assert.ok(h.drive(() => m.phase === PHASE.PREP && m.round === 2), 'reached PREP R2');
h.sched.advance(60 * 60_000);
assert.equal(m.phase, PHASE.PREP, 'solo prep is untimed');
assert.equal(m.round, 2);
// on past the first 机变 round
const spRounds = m.gd.spRounds();
const lastR = (spRounds.find((r) => r > 1) || 3) + 1;
assert.ok(h.drive(() => m.phase === PHASE.PREP && m.round === lastR), `reached PREP R${lastR}`);
h.flushAll();
for (const ph of [PHASE.INFO_CHECK, PHASE.BAND_DRAFT, PHASE.BATTLE_CHECK, PHASE.ROUND_START, PHASE.PREP, PHASE.SETTLE]) {
assert.ok(seen.has(ph), `saw ${ph}`);
assert.ok(seen.get(ph).every((d) => d === 0), `${ph}: no deadline in solo (${seen.get(ph)})`);
}
if (spRounds.some((r) => r < lastR)) {
assert.ok(seen.has(PHASE.SP_DRAFT), 'saw 机变');
assert.ok(seen.get(PHASE.SP_DRAFT).every((d) => d === 0), '机变 untimed in solo');
}
assert.ok(seen.get(PHASE.COMBAT).some((d) => d > 0), 'combat keeps its time limit');
m.dispose();
});
test('co-op keeps its guards: INFO_CHECK 25 s, prep timer, transition deadlines', () => {
const h = makeMatch({ mode: 'coop', humans: 1, bots: 1, seed: 12 }).start();
const m = h.m;
const pub = h.lastBc('m.public');
assert.equal(pub.phase, PHASE.INFO_CHECK);
assert.equal(pub.deadline - pub.serverNow, 25000);
h.toPrep(1);
assert.ok(m.deadline > 0, 'co-op prep is timed');
m.dispose();
});
// ---- review fixes -----------------------------------------------------------------------------------------------------------
test('a skill summon comes only with that skill: 赫默 S2 (default) gets the 医疗无人机 token, 赫默 on S1 does not', async () => {
const { give } = await import('./harness.js');
const SILENCE = 'chess_char_2_02_a';
const DRONE = 'token_10000_silent_healrb';
assert.equal(chess(SILENCE).skills.find((s) => s.overrideTokenKey === DRONE)?.isDefault, true, 'fixture: the drone is the default S2 summon');
const tokensIn = (ps) => [...ps.hand, ...ps.temp, ...ps.board.values()].filter((p) => p && p.kind === 'token' && p.id === DRONE);
for (const [skill, want] of [[null, 1], [0, 0], [1, 1]]) {
const lo = skill == null ? null : checkLoadout({ [SILENCE]: { skill } }, chess).loadout;
const h = makeMatch({ mode: 'solo', seats: seatsWith(lo), seed: 5 }).start();
h.toPrep(1);
const ps = h.ps('p_0');
for (const p of [...ps.board.values()]) ps.returnCopies(p);
ps.board.clear();
ps.hand.fill(null);
give(h.m, ps, SILENCE, 'board', [9, 3]);
assert.equal(tokensIn(ps).length, want, `normal 赫默, skill ${skill ?? 'default'}`);
ps.board.clear();
ps.hand.fill(null);
give(h.m, ps, chess(SILENCE).goldenId, 'board', [9, 4]);
assert.equal(tokensIn(ps).length, want, `elite 赫默, skill ${skill ?? 'default'}`);
h.m.dispose();
}
});
+154
View File
@@ -0,0 +1,154 @@
// The real Match behind the real platform (HTTP/WS server + lobby): a solo and a co-op match driven over sockets.
// Phase timers are scaled down (timerScale) and battles use FakeBattle so the test runs in real time quickly.
// Combat is client-side (DESIGN §14): each socket client simulates its b.start specs (test/match/simClient.js) and
// reports b.progress / b.result through the platform (server/net.js routes b.* to the match). The last test runs the
// server-run fallback (SP_COMBAT=server) that still streams b.snap.
import { test, after } from 'node:test';
import assert from 'node:assert/strict';
import { startServer } from '../../server/index.js';
import { Match } from '../../server/match/Match.js';
import { TestClient } from '../helpers/wsClient.js';
import { FakeBattle } from './fakeBattle.js';
import { attachWsSimClient } from './simClient.js';
import { EMOTES } from '../../shared/constants.js';
let serverCombat = false;
class FastMatch extends Match {
constructor(o) { super({ ...o, timerScale: 0.02, BattleClass: FakeBattle, clientCombat: !serverCombat }); }
}
const log = { info() {}, warn() {}, debug() {}, error: (...a) => errors.push(a.map(String).join(' ')) };
const errors = [];
let srv = null;
const clients = [];
async function server() {
if (!srv) srv = await startServer({ port: 0, host: '127.0.0.1', log, MatchClass: FastMatch });
return srv;
}
async function player(name, token) {
const s = await server();
const c = await TestClient.connect(`ws://127.0.0.1:${s.port}/ws`);
clients.push(c);
const w = await c.hello(name, token);
c.id = w.playerId;
c.token = w.token;
c.sim = attachWsSimClient(c, { BattleClass: FakeBattle, pace: 'instant' });
return c;
}
const ok = async (c, msg) => { const r = await c.request(msg); assert.equal(r.t, 'ok', `${msg.t}: ${JSON.stringify(r)}`); return r; };
after(async () => {
for (const c of clients) await c.terminate().catch(() => {});
if (srv) await srv.close();
});
test('solo over websockets: briefing → band → prep → buy/place/ready → combat → settle → round 2 → leave', async () => {
FakeBattle.reset();
FakeBattle.script = () => ({ duration: 1 });
const c = await player('Solo');
await ok(c, { t: 'room.create', mode: 'solo', difficulty: 'FUNNY' });
await c.waitFor('room.state');
await ok(c, { t: 'room.start' });
const pub = await c.waitFor('m.public', (p) => p.phase === 'INFO_CHECK');
assert.equal(pub.modeId, 'mode_single_funny');
assert.equal(pub.lastRound, 9);
await ok(c, { t: 'g.infoReady' });
await c.waitFor('m.public', (p) => p.phase === 'BAND_DRAFT');
await ok(c, { t: 'g.band', bandId: 'band_sarkazb' });
const prep = await c.waitFor('m.public', (p) => p.phase === 'PREP' && p.round === 1, 10000);
assert.equal(prep.players[0].lp, 45);
const priv = await c.waitFor('m.private', (p) => p.funds === 4 && p.shop.slots.length > 0);
const slot = priv.shop.slots.findIndex((s) => s && s.kind === 'chess' && s.price <= 4);
await ok(c, { t: 'g.buy', slot });
const after1 = await c.waitFor('m.private', (p) => p.hand.some(Boolean));
const piece = after1.hand.find(Boolean);
// wrong-phase / bad intents come back as errors with codes
const bad = await c.request({ t: 'g.choice', idx: 0 });
assert.equal(bad.t, 'error');
assert.equal(bad.code, 'WRONG_PHASE');
const tile = [[9, 3], [9, 4], [10, 3], [12, 5]].find(() => true);
const mv = await c.request({ t: 'g.move', uid: piece.uid, to: { area: 'board', row: tile[0], col: tile[1] } });
assert.ok(mv.t === 'ok' || (mv.t === 'error' && mv.code === 'BAD_TILE'));
await ok(c, { t: 'g.ready', ready: true });
// (the client answers at once: the COMBAT phase may be shorter than the m.public throttle window)
const start = await c.waitFor('b.start', (x) => x.authoritative, 10000);
assert.equal(start.fieldId, `n:${c.id}`);
assert.equal(start.spec.kind, 'normal');
await c.waitFor('m.public', (p) => p.phase === 'PREP' && p.round === 2, 15000);
assert.ok(c.sim.log.some((x) => x.t === 'b.result' && x.battleId === start.battleId), 'the client reported its battle');
assert.equal(c.log.filter((x) => x.t === 'b.snap' || x.t === 'b.ev').length, 0, 'no combat streaming');
// (reports carry no rid: an error answering one would arrive without a rid)
assert.equal(c.log.filter((x) => x.t === 'error' && x.rid == null).length, 0, 'every report was accepted without error');
await ok(c, { t: 'g.emote', id: EMOTES[0] });
await c.waitFor('m.emote');
// leaving the only human seat ends the match; the room returns to its lobby life cycle
await ok(c, { t: 'g.leave' });
assert.deepEqual(errors, []);
});
test('co-op over websockets: two humans + AI, reconnect with the token mid-match resyncs the state', async () => {
FakeBattle.reset();
FakeBattle.script = () => ({ duration: 1 });
const a = await player('A');
const b = await player('B');
await ok(a, { t: 'room.create', mode: 'coop', difficulty: 'NORMAL' });
const st = await a.waitFor('room.state');
await ok(b, { t: 'room.join', code: st.code });
await ok(a, { t: 'room.addBot' });
await ok(b, { t: 'room.ready', ready: true });
await ok(a, { t: 'room.start' });
for (const c of [a, b]) await c.waitFor('m.public', (p) => p.phase === 'INFO_CHECK');
await ok(a, { t: 'g.infoReady' });
await ok(b, { t: 'g.infoReady' });
const draft = await a.waitFor('m.public', (p) => p.phase === 'BAND_DRAFT' && p.draft);
assert.equal(draft.draft.order.length, 3);
// humans pick when it is their turn (bots pick themselves)
for (let i = 0; i < 20; i++) {
const p = await a.waitFor('m.public', (x) => x.phase !== 'BAND_DRAFT' || [a.id, b.id].includes(x.draft && x.draft.turn), 10000);
if (p.phase !== 'BAND_DRAFT') break;
const who = p.draft.turn === a.id ? a : b;
await who.request({ t: 'g.band', bandId: 'band_bldsk' });
}
await a.waitFor('m.public', (p) => p.phase === 'PREP' && p.round === 1, 10000);
// B drops and comes back with its token
await b.terminate();
const b2 = await player('B', b.token);
const pub = await b2.waitFor('m.public', (p) => p.phase === 'PREP');
assert.equal(pub.players.find((p) => p.playerId === b2.id).connected, true);
const priv = await b2.waitFor('m.private');
assert.equal(priv.playerId, b2.id);
await ok(a, { t: 'g.ready', ready: true });
await ok(b2, { t: 'g.ready', ready: true });
await a.waitFor('m.public', (p) => p.phase === 'COMBAT', 10000);
await a.waitFor('m.public', (p) => p.phase === 'PREP' && p.round === 2, 15000);
await ok(a, { t: 'g.leave' });
await ok(b2, { t: 'g.leave' });
assert.deepEqual(errors, []);
});
test('server-run fallback (SP_COMBAT=server): the match simulates every field and streams m.field + b.snap', async () => {
FakeBattle.reset();
FakeBattle.script = () => ({ duration: 1 });
serverCombat = true;
try {
const c = await player('Legacy');
await ok(c, { t: 'room.create', mode: 'solo', difficulty: 'FUNNY' });
await c.waitFor('room.state');
await ok(c, { t: 'room.start' });
await c.waitFor('m.public', (p) => p.phase === 'INFO_CHECK');
await ok(c, { t: 'g.infoReady' });
await c.waitFor('m.public', (p) => p.phase === 'BAND_DRAFT');
await ok(c, { t: 'g.band', bandId: 'band_sarkazb' });
await c.waitFor('m.public', (p) => p.phase === 'PREP' && p.round === 1, 10000);
await ok(c, { t: 'g.ready', ready: true });
await c.waitFor('m.field', () => true, 10000);
await c.waitFor('b.snap', (s) => typeof s.gt === 'number', 10000);
await c.waitFor('m.public', (p) => p.phase === 'PREP' && p.round === 2, 15000);
assert.equal(c.log.filter((x) => x.t === 'b.start').length, 0);
await ok(c, { t: 'g.leave' });
assert.deepEqual(errors, []);
} finally {
serverCombat = false;
}
});
+353
View File
@@ -0,0 +1,353 @@
// Merges (精锐), reward offers, item equip/replace/merge, consume-on-equip items (research 01 §7 + A1, 04 §2).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { ERR, PHASE } from '../../shared/constants.js';
import { DATA, makeMatch, give, giveItem, checkInvariants, chessOfTier, legalTileFor } from './harness.js';
function prep(seed = 21, o = {}) {
const h = makeMatch({ mode: 'solo', difficulty: 'NORMAL', seed, ...o }).start();
h.toPrep(1);
h.setStage('act2autochess_m04');
const ps = h.ps('p_0');
for (const p of [...ps.board.values(), ...ps.hand.filter(Boolean)]) if (p.kind === 'chess') ps.returnCopies(p);
ps.board.clear();
ps.hand.fill(null);
ps.recompute();
ps.funds = 100;
return { h, m: h.m, ps };
}
/** Put chess `id` into shop slot 0 at its normal price. */
function stock(m, ps, id, slot = 0) {
ps.shop.slots[slot] = { kind: 'chess', id, basePrice: m.gd.chessPrice(id), frozen: false, sold: false };
}
test('3 copies (board + hand + bought) merge into 1 elite in the hand; equipment returns; reward offer of tier level+1', () => {
const { m, ps } = prep();
const id = chessOfTier(1, (c) => c.position === 'MELEE').find((x) => m.pool.has(x));
const cap = m.pool.cap(id);
const a = give(m, ps, id, 'board', [9, 3]);
const b = give(m, ps, id);
const it1 = giveItem(m, ps, 'chess_item_1_01_e_a');
const it2 = giveItem(m, ps, 'chess_item_1_02_e_a');
assert.deepEqual(m.handle('p_0', { t: 'g.equip', itemUid: it1.uid, targetUid: a.uid }), { ok: true });
assert.deepEqual(m.handle('p_0', { t: 'g.equip', itemUid: it2.uid, targetUid: b.uid }), { ok: true });
stock(m, ps, id);
assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot: 0 }), { ok: true });
const golden = DATA.chess[id].goldenId;
assert.ok(!ps.board.has('9,3'), 'the board copy was consumed');
const elite = ps.hand.find((p) => p && p.id === golden);
assert.ok(elite, 'elite in the hand');
assert.equal(elite.poolCopies, 3);
assert.equal(m.pool.left(id), cap - 3);
assert.equal(ps.countCopies(id), 0);
assert.ok(ps.hand.some((p) => p && p.uid === it1.uid) && ps.hand.some((p) => p && p.uid === it2.uid), 'equipment returned to the hand');
assert.equal(elite.items.length, 0);
assert.equal(ps.stats.merges, 1);
const offer = ps.privateView().shop.rewardOffer;
assert.ok(offer);
assert.equal(offer.tier, Math.min(ps.shop.level + 1, 6));
assert.equal(offer.slots.length, 3);
for (const s of offer.slots) { assert.equal(s.price, 0); assert.equal(DATA.chess[s.id].tier <= offer.tier, true); }
// take one reward (free), the offer is consumed
const funds = ps.funds;
const pick = offer.slots[1].id;
assert.deepEqual(m.handle('p_0', { t: 'g.reward', idx: 1 }), { ok: true });
assert.equal(ps.funds, funds);
assert.ok(ps.hand.some((p) => p && p.id === pick) || ps.hand.some((p) => p && p.id === DATA.chess[pick].goldenId));
assert.equal(ps.privateView().shop.rewardOffer, null);
assert.deepEqual(m.handle('p_0', { t: 'g.reward', idx: 0 }).error, ERR.BAD_TARGET);
checkInvariants(m);
m.dispose();
});
test('elites never merge; 风丸 merges with 2 copies; a full hand still buys the merge-completing copy', () => {
const { m, ps } = prep(22);
const id = chessOfTier(2).find((x) => m.pool.has(x) && x !== 'chess_char_2_11_a');
const golden = DATA.chess[id].goldenId;
give(m, ps, golden);
give(m, ps, golden);
stock(m, ps, golden);
m.handle('p_0', { t: 'g.buy', slot: 0 });
assert.equal(ps.hand.filter((p) => p && p.id === golden).length, 3, 'three elites stay three elites');
// 风丸 (chess_char_2_11_a) upgradeNum 2
if (m.pool.has('chess_char_2_11_a')) {
give(m, ps, 'chess_char_2_11_a');
stock(m, ps, 'chess_char_2_11_a');
m.handle('p_0', { t: 'g.buy', slot: 0 });
assert.ok(ps.hand.some((p) => p && p.id === DATA.chess.chess_char_2_11_a.goldenId), '风丸 elite after 2 copies');
}
// full hand + 2 copies in the hand: buying the 3rd completes the merge (net −1)
for (const p of ps.hand) if (p) ps.returnCopies(p);
ps.hand.fill(null);
const x = chessOfTier(1).filter((c) => m.pool.has(c));
const target = x[0];
give(m, ps, target);
give(m, ps, target);
for (let i = 1; ps.hand.some((p) => p == null); i++) give(m, ps, x[i]);
assert.ok(ps.hand.every(Boolean));
stock(m, ps, x[20] || x[x.length - 1], 1);
assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot: 1 }), { error: ERR.HAND_FULL });
stock(m, ps, target, 0);
assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot: 0 }), { ok: true });
assert.ok(ps.hand.some((p) => p && p.id === DATA.chess[target].goldenId));
assert.equal(ps.hand.filter(Boolean).length, 9);
assert.ok(ps.tempEmpty);
checkInvariants(m);
m.dispose();
});
test('merge from two board copies with a full hand overflows the elite into temp (blocks ready until resolved)', () => {
const { m, ps } = prep(23);
const ids = chessOfTier(1, (c) => c.position === 'MELEE').filter((c) => m.pool.has(c));
const id = ids[0];
give(m, ps, id, 'board', [9, 3]);
give(m, ps, id, 'board', [9, 4]);
const others = chessOfTier(2).filter((c) => m.pool.has(c));
for (let i = 0; ps.hand.some((p) => p == null); i++) give(m, ps, others[i]);
stock(m, ps, id);
assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot: 0 }), { ok: true });
assert.ok(ps.temp.some((p) => p && p.id === DATA.chess[id].goldenId), 'elite overflowed into temp');
assert.equal(ps.board.size, 0);
assert.deepEqual(m.handle('p_0', { t: 'g.ready', ready: true }), { error: ERR.TEMP_NOT_EMPTY });
checkInvariants(m);
m.dispose();
});
test('reward pick respects the pool and a full hand; offers expire at prep end', () => {
const { h, m, ps } = prep(24);
ps.pushRewardOffer('merge');
const offer = ps.offers[0];
const id0 = offer.slots[0].id;
const taken = m.pool.take(id0, 100);
assert.deepEqual(m.handle('p_0', { t: 'g.reward', idx: 0 }), { error: ERR.SOLD_OUT });
m.pool.give(id0, taken);
const others = chessOfTier(3).filter((c) => m.pool.has(c) && !offer.slots.some((s) => s.id === c));
for (let i = 0; ps.hand.some((p) => p == null); i++) give(m, ps, others[i]);
assert.deepEqual(m.handle('p_0', { t: 'g.reward', idx: 0 }), { error: ERR.HAND_FULL });
ps.hand[0] && ps.returnCopies(ps.hand[0]);
ps.hand[0] = null;
ps.recompute();
assert.deepEqual(m.handle('p_0', { t: 'g.ready', ready: true }), { ok: true });
h.sched.runNext();
h.sched.runNext();
assert.equal(ps.offers.length, 0, 'offer gone at prep end');
checkInvariants(m);
m.dispose();
});
test('equipment: max 2 (a third replaces the oldest), identical normal items merge into golden — also when equipped', () => {
const { m, ps } = prep(25);
const id = chessOfTier(3).find((c) => m.pool.has(c));
const a = give(m, ps, id);
const i1 = giveItem(m, ps, 'chess_item_1_01_e_a');
const i2 = giveItem(m, ps, 'chess_item_1_02_e_a');
const i3 = giveItem(m, ps, 'chess_item_3_03_e_a');
assert.deepEqual(m.handle('p_0', { t: 'g.equip', itemUid: i1.uid, targetUid: a.uid }), { ok: true });
assert.deepEqual(m.handle('p_0', { t: 'g.equip', itemUid: i2.uid, targetUid: a.uid }), { ok: true });
assert.deepEqual(m.handle('p_0', { t: 'g.equip', itemUid: i3.uid, targetUid: a.uid }), { ok: true });
assert.deepEqual(a.items.map((x) => x.id), ['chess_item_1_02_e_a', 'chess_item_3_03_e_a'], 'oldest replaced (destroyed)');
assert.ok(!ps.find(i1.uid), 'the replaced item is gone');
// equip a second 坚守盾牌 onto the same carrier → merges into the golden version in the hand
const i4 = giveItem(m, ps, 'chess_item_1_02_e_a');
assert.deepEqual(m.handle('p_0', { t: 'g.equip', itemUid: i4.uid, targetUid: a.uid }), { ok: true });
assert.ok(ps.hand.some((p) => p && p.id === 'chess_item_1_02_e_b'), 'golden item in the hand');
assert.ok(!a.items.some((x) => x.id === 'chess_item_1_02_e_a'));
assert.equal(ps.stats.itemMerges, 1);
// buying an item whose twin is in the hand merges immediately (and works with a full hand)
const i5 = giveItem(m, ps, 'chess_item_2_03_e_a');
const others = chessOfTier(1).filter((c) => m.pool.has(c));
for (let i = 0; ps.hand.some((p) => p == null); i++) give(m, ps, others[i]);
ps.shop.slots[0] = { kind: 'item', id: 'chess_item_2_03_e_a', basePrice: 2, frozen: false, sold: false };
assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot: 0 }), { ok: true });
assert.ok(ps.hand.some((p) => p && p.id === 'chess_item_2_03_e_b'));
assert.ok(!ps.find(i5.uid));
// golden items never merge again; non-mergeable items (upgradeNum 100) never merge
const g2 = giveItem(m, ps, 'chess_item_2_03_e_b', 'temp');
ps.checkItemMerges();
assert.equal([...ps.hand, ...ps.temp].filter((p) => p && p.id === 'chess_item_2_03_e_b').length, 2);
void g2;
// bad targets
const it = ps.hand.find((p) => p && p.kind === 'item');
assert.deepEqual(m.handle('p_0', { t: 'g.equip', itemUid: it.uid, targetUid: it.uid }).error, ERR.BAD_TARGET);
assert.deepEqual(m.handle('p_0', { t: 'g.equip', itemUid: a.uid, targetUid: a.uid }).error, ERR.BAD_TARGET);
checkInvariants(m);
m.dispose();
});
test('变形同构体 grants the bond of the other equipped item (bond counting)', () => {
const { m, ps } = prep(26);
const id = chessOfTier(1, (c) => !c.bonds.includes('victoriaShip') && c.position === 'MELEE').find((c) => m.pool.has(c));
const a = give(m, ps, id, 'board', [9, 3]);
const iso = giveItem(m, ps, 'chess_item_6_09_e_a');
const hammer = giveItem(m, ps, 'chess_item_1_01_e_a');
const before = ps.bonds.victoriaShip ? ps.bonds.victoriaShip.count : 0;
m.handle('p_0', { t: 'g.equip', itemUid: iso.uid, targetUid: a.uid });
m.handle('p_0', { t: 'g.equip', itemUid: hammer.uid, targetUid: a.uid });
assert.equal(ps.bonds.victoriaShip.count, before + 1, 'wearer counts as a 维多利亚 member');
m.dispose();
});
test('consume-on-equip built-ins: 盟约之币 funds, 随身身份牌 layers, 人事部文档 cap 9, 博士投影 promote; Arts limit', () => {
const { m, ps } = prep(27);
const id = chessOfTier(2).find((c) => m.pool.has(c));
const a = give(m, ps, id);
ps.funds = 0;
const coin = giveItem(m, ps, 'chess_item_1_03_e_a');
assert.deepEqual(m.handle('p_0', { t: 'g.equip', itemUid: coin.uid, targetUid: a.uid }), { ok: true });
assert.equal(ps.funds, 1);
assert.equal(a.items.length, 0, 'consumed, no slot used');
assert.ok(!ps.find(coin.uid));
const tag = giveItem(m, ps, 'chess_item_1_04_e_b');
m.handle('p_0', { t: 'g.equip', itemUid: tag.uid, targetUid: a.uid });
for (const b of DATA.chess[id].bonds) assert.equal(ps.layers[b], 6, `${b} +6 layers (无需激活)`);
const hr = giveItem(m, ps, 'chess_item_6_08_e_a');
m.handle('p_0', { t: 'g.equip', itemUid: hr.uid, targetUid: a.uid });
assert.equal(ps.deployCap, 9);
const holo = giveItem(m, ps, 'chess_item_5_06_e_b');
m.handle('p_0', { t: 'g.equip', itemUid: holo.uid, targetUid: a.uid });
assert.equal(a.id, DATA.chess[id].goldenId, 'golden 博士投影 promotes immediately');
assert.equal(a.poolCopies, 3);
const holo2 = giveItem(m, ps, 'chess_item_5_06_e_b');
assert.equal(m.handle('p_0', { t: 'g.equip', itemUid: holo2.uid, targetUid: a.uid }).error, ERR.BAD_TARGET, 'already elite');
// normal 博士投影: stays equipped, promotes at the next round start
const b = give(m, ps, chessOfTier(3).find((c) => m.pool.has(c)));
const holo3 = giveItem(m, ps, 'chess_item_5_06_e_a');
m.handle('p_0', { t: 'g.equip', itemUid: holo3.uid, targetUid: b.uid });
assert.equal(b.items.length, 1);
ps.hand.forEach((p, i) => { if (p && p.kind === 'item') ps.hand[i] = null; });
checkInvariants(m);
m.handle('p_0', { t: 'g.ready', ready: true });
const h = { sched: m.sched };
h.sched.runUntil(() => m.phase === PHASE.PREP && m.round === 2, { maxSteps: 1e6 });
// the bot-free solo round needs a combat → it ran; now at R2
assert.equal(b.id, DATA.chess[b.id].goldenId ? b.id : b.id);
assert.ok(m.gd.isGolden(b.id), 'promoted at the round start');
assert.equal(b.items.length, 0);
// Arts: at most 2 per round; unimplemented handlers are rejected cleanly
const art = giveItem(m, ps, 'chess_item_6_02_m');
const board = [...ps.board.entries()].find(([, p]) => p.kind === 'chess');
let target = board;
if (!target) { const c = give(m, ps, chessOfTier(1, (x) => x.position === 'MELEE').find((x) => m.pool.has(x)), 'board', [9, 3]); target = ['9,3', c]; }
const [r, c] = target[0].split(',').map(Number);
const res = m.handle('p_0', { t: 'g.art', itemUid: art.uid, row: r, col: c });
assert.deepEqual(res, { ok: true }, '画卷 copies the operator');
assert.ok(!ps.find(art.uid));
const art2 = giveItem(m, ps, 'chess_item_6_02_m');
assert.equal(m.handle('p_0', { t: 'g.art', itemUid: art2.uid, row: 12, col: 10 }).error, ERR.BAD_TARGET, 'no operator in range');
assert.equal(m.handle('p_0', { t: 'g.art', itemUid: art2.uid, row: 1, col: 1 }).error, ERR.BAD_TILE);
assert.equal(m.handle('p_0', { t: 'g.art', itemUid: a.uid, row: r, col: c }).error, ERR.BAD_TARGET);
checkInvariants(m);
m.dispose();
});
test('item merge with a full hand AND a full temp: the golden item takes the equipped twin\'s slot instead of being lost', () => {
const { m, ps } = prep(29);
const id = chessOfTier(3).find((c) => m.pool.has(c));
const carrier = give(m, ps, id, 'board', [10, 3]);
const twin = giveItem(m, ps, 'chess_item_1_02_e_a');
const keep = giveItem(m, ps, 'chess_item_1_01_e_a');
ps._detach(ps.find(twin.uid));
ps._detach(ps.find(keep.uid));
carrier.items.push(keep, twin);
const fill = chessOfTier(1).filter((c) => m.pool.has(c));
for (let i = 0; ps.hand.some((p) => p == null); i++) give(m, ps, fill[i]);
const fill2 = chessOfTier(2).filter((c) => m.pool.has(c));
for (let i = 0; ps.temp.some((p) => p == null); i++) give(m, ps, fill2[i], 'temp');
ps.recompute();
const got = ps.acquireItem('chess_item_1_02_e_a', { source: 'grant' });
assert.ok(got && got.id === 'chess_item_1_02_e_b', 'the merge produced the golden item');
assert.deepEqual(carrier.items.map((x) => x.id), ['chess_item_1_01_e_a', 'chess_item_1_02_e_b'], 'golden item equipped in the twin\'s slot');
assert.ok(!ps.find(twin.uid), 'the twin was consumed');
assert.equal(ps.stats.itemMerges, 1);
checkInvariants(m);
m.dispose();
});
test('a merge completed during SETTLE (突变细胞) keeps its reward offer for the next prep; a full hand sends the elite to a freed board tile', () => {
const X = 'chess_char_2_04_a';
const fillers = Object.values(DATA.items).filter((i) => i.itemType === 'EQUIP' && !i.isGolden && !String(i.kind || '').startsWith('consume')).map((i) => i.itemId ?? i.id).filter(Boolean);
for (const onBoard of [false, true]) {
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 1, bots: 1, seed: 3, fake: true }).start();
const m = h.m;
h.toPrep(1);
const ps = h.ps('p_0');
for (const p of [...ps.board.values(), ...ps.hand.filter(Boolean)]) if (p.kind === 'chess') ps.returnCopies(p);
ps.board.clear();
ps.hand.fill(null);
ps.recompute();
// the mutation's tier+1 roll can only give X
const saved = new Map();
for (const [base, e] of m.pool.entries) if (e.tier === 2 && base !== X) { saved.set(base, e.left); e.left = 0; }
const T = chessOfTier(1, (c) => !m.gd.placeableTokens(c.chessId).length).find((id) => m.pool.has(id));
const holder = give(m, ps, T, 'board', legalTileFor(m, ps, T));
holder.items.push(ps.newPiece('item', 'chess_item_5_08_e_a'));
const copies = onBoard ? [0, 1].map(() => give(m, ps, X, 'board', legalTileFor(m, ps, X))) : [give(m, ps, X), give(m, ps, X)];
const tiles = onBoard ? copies.map((p) => ps.find(p.uid).key) : [];
for (let i = 0; ps.hand.some((x) => x == null); i++) giveItem(m, ps, fillers[i]);
let atSettle = null;
const settle = m.settle.bind(m);
m.settle = (...a) => { const r = settle(...a); atSettle = { offers: ps.offers.map((o) => o.source), merges: ps.stats.merges }; return r; };
h.toPrep(2);
for (const [base, left] of saved) m.pool.entries.get(base).left = left;
assert.deepEqual(atSettle, { offers: ['merge'], merges: 1 }, `${onBoard ? 'board' : 'hand'} copies: the transform completed a merge in SETTLE`);
const elite = ps.allChess().find((p) => p.id === m.gd.goldenIdOf(X));
assert.ok(elite, 'the elite survived the round start');
assert.deepEqual(ps.offers.map((o) => o.source), ['merge'], 'the promotion reward waits for this prep');
assert.ok(ps.privateView().shop.rewardOffer, 'and is shown');
if (onBoard) assert.ok(tiles.includes(ps.find(elite.uid).key), 'full hand outside PREP: the elite took a freed board tile, not temp');
assert.ok(ps.tempEmpty);
checkInvariants(m);
// it expires at the end of that prep like any other offer
h.drive(() => m.phase === PHASE.COMBAT && m.round === 2);
assert.equal(ps.offers.length, 0);
m.dispose();
}
});
test('withdrawing a summoner with a full hand: its own summon stack frees the slot (onto the stack or anywhere)', () => {
for (const onto of ['stack', 'other']) {
const { m, ps } = prep(24);
const hemo = give(m, ps, 'chess_char_2_02_a');
const [r, c] = legalTileFor(m, ps, 'chess_char_2_02_a');
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: hemo.uid, to: { area: 'board', row: r, col: c } }), { ok: true });
const stackIdx = ps.hand.findIndex((p) => p && p.kind === 'token' && p.ownerUid === hemo.uid);
assert.ok(stackIdx >= 0, 'its summon stack went to the hand');
const ids = ['chess_item_1_01_e_a', 'chess_item_1_02_e_a', 'chess_item_1_03_e_a', 'chess_item_1_04_e_a', 'chess_item_1_05_e_a', 'chess_item_2_01_e_a', 'chess_item_2_02_e_a', 'chess_item_2_03_e_a', 'chess_item_2_04_e_a'];
for (let i = 0; ps.hand.some((x) => x == null); i++) giveItem(m, ps, ids[i]);
const idx = onto === 'stack' ? stackIdx : ps.hand.findIndex((p) => p && p.kind === 'item');
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: hemo.uid, to: { area: 'hand', idx } }), { ok: true }, onto);
assert.equal(ps.find(hemo.uid).area, 'hand');
assert.equal(ps.hand[stackIdx], hemo, 'it takes the slot its stack left');
assert.ok(![...ps.hand, ...ps.temp, ...ps.board.values()].some((p) => p && p.kind === 'token'), 'its summons are gone');
assert.equal(ps.hand.filter((p) => p && p.kind === 'item').length, 9, 'no item was displaced');
checkInvariants(m);
m.dispose();
}
});
test('equipment: the replace dialog picks WHICH equipped item a third one destroys (g.equip replaceUid); equipped items are otherwise locked (g.destroy refused)', () => {
const { m, ps } = prep(26);
const a = give(m, ps, chessOfTier(3).find((c) => m.pool.has(c)));
const i1 = giveItem(m, ps, 'chess_item_1_01_e_a');
const i2 = giveItem(m, ps, 'chess_item_1_02_e_a');
const i3 = giveItem(m, ps, 'chess_item_3_03_e_a');
assert.deepEqual(m.handle('p_0', { t: 'g.equip', itemUid: i1.uid, targetUid: a.uid }), { ok: true });
assert.deepEqual(m.handle('p_0', { t: 'g.equip', itemUid: i2.uid, targetUid: a.uid }), { ok: true });
// equipped items cannot be destroyed directly
assert.deepEqual(m.handle('p_0', { t: 'g.destroy', uid: i1.uid }), { error: ERR.BAD_TARGET, detail: 'equipped items are locked' });
assert.deepEqual(a.items.map((x) => x.uid), [i1.uid, i2.uid]);
// a replaceUid that is not on the target changes nothing
assert.equal(m.handle('p_0', { t: 'g.equip', itemUid: i3.uid, targetUid: a.uid, replaceUid: i3.uid }).error, ERR.BAD_TARGET);
assert.ok(ps.hand.includes(i3), 'the new item stays in the hand');
assert.deepEqual(a.items.map((x) => x.uid), [i1.uid, i2.uid]);
// the player keeps the older item and replaces the newer one
assert.deepEqual(m.handle('p_0', { t: 'g.equip', itemUid: i3.uid, targetUid: a.uid, replaceUid: i2.uid }), { ok: true });
assert.deepEqual(a.items.map((x) => x.uid), [i1.uid, i3.uid]);
assert.ok(!ps.find(i2.uid), 'the picked item was destroyed');
// hand items can still be destroyed
const i4 = giveItem(m, ps, 'chess_item_2_03_e_a');
assert.deepEqual(m.handle('p_0', { t: 'g.destroy', uid: i4.uid }), { ok: true });
checkInvariants(m);
m.dispose();
});
+225
View File
@@ -0,0 +1,225 @@
// Effect registry & dispatcher (effectsMeta.js): keys, hook routing, garrison eventType mapping, 投资人 repeat,
// ctx helpers, error isolation, depth cap, content override of built-ins.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { MetaRegistry, createRegistry, HOOKS, GARRISON_HOOK, MAX_DEPTH } from '../../server/match/effectsMeta.js';
import { registerBuiltins, BUILTIN_EFFECT_KEYS } from '../../server/match/builtinMeta.js';
import { PHASE } from '../../shared/constants.js';
import { DATA, makeMatch, give, giveItem, checkInvariants, chessOfTier } from './harness.js';
test('registry: key validation, function sugar, last registration wins, helpers', () => {
const reg = new MetaRegistry();
assert.throws(() => reg.register('nope', {}), TypeError);
assert.throws(() => reg.register('band:', {}), TypeError);
assert.throws(() => reg.register('band:x', 5), TypeError);
reg.register('band:x', () => 1);
assert.equal(typeof reg.get('band:x').run, 'function');
const h2 = { onBuy() {} };
reg.band('x', h2);
assert.equal(reg.get('band:x'), h2);
reg.item('chess_item_1_03_e_b', { onEquip() {} });
assert.ok(reg.has('item:chess_item_1_03_e'), 'item keys drop the _a/_b suffix');
reg.global('g', {});
assert.equal(reg.globals().length, 1);
assert.equal(reg.get('band:none'), null);
assert.ok(HOOKS.includes('onBattleStart') && HOOKS.includes('onChoicePick'));
assert.equal(GARRISON_HOOK.SERVER_PREP_FIN, 'onPrepEnd');
});
test('built-ins register consume-on-equip items by buff key; createRegistry = built-ins + content', () => {
const reg = registerBuiltins(new MetaRegistry(), DATA);
for (const k of ['item:chess_item_1_03_e', 'item:chess_item_3_12_e', 'item:chess_item_1_04_e', 'item:chess_item_5_06_e', 'item:chess_item_6_08_e', 'item:chess_item_6_02_m']) assert.ok(reg.has(k), k);
for (const k of BUILTIN_EFFECT_KEYS) assert.ok(reg.has(k), k);
const full = createRegistry();
assert.ok(full.has('item:chess_item_1_03_e'));
});
function metaMatch(reg, o = {}) {
const h = makeMatch({ mode: 'solo', seed: o.seed ?? 70, registry: reg, fake: true, ...o }).start();
h.toPrep(1);
const ps = h.ps('p_0');
for (const p of [...ps.board.values(), ...ps.hand.filter(Boolean)]) if (p.kind === 'chess') ps.returnCopies(p);
ps.board.clear();
ps.hand.fill(null);
ps.funds = 50;
ps.recompute();
return { h, m: h.m, ps };
}
test('dispatch order and hooks: global → band → bond → garrison → item → effect; onPrice/onSold/onBuy/onRefresh/onLevelUp', () => {
const reg = registerBuiltins(new MetaRegistry(), DATA);
const calls = [];
reg.global('spy', { onBuy: () => calls.push('global'), onRefresh: () => calls.push('refresh'), onLevelUp: (ctx, ev) => calls.push(`level${ev.level}`) });
reg.band('band_bldsk', { onBuy: () => calls.push('band') });
reg.bond('yanShip', { onBuy: () => calls.push('bond') });
reg.effect('spy', { onBuy: (ctx) => calls.push(`effect:${ctx.source.ref.id}`) });
// a price modifier (content e.g. 休露丝): chess slots cost 1
reg.global('price', { onPrice: (ctx, ev) => { if (ev.kind === 'chess') { ctx.modifyPrice(+5); ctx.setPrice(1); } } });
// selling pays 3 instead of 1
reg.global('sell', { onSold: (ctx, ev) => { ev.gain = 3; } });
const { m, ps } = metaMatch(reg);
ps.effects.push({ id: 'e1', key: 'effect:spy', name: 'spy', battle: false });
const slot = ps.shop.slots.findIndex((s) => s && s.kind === 'chess');
assert.equal(ps.privateView().shop.slots[slot].price, 1, 'onPrice is reflected in the view');
const funds = ps.funds;
assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot }), { ok: true });
assert.equal(ps.funds, funds - 1);
assert.deepEqual(calls, ['global', 'band', 'bond', 'effect:e1']);
const piece = ps.hand.find(Boolean);
const f2 = ps.funds;
m.handle('p_0', { t: 'g.sell', uid: piece.uid });
assert.equal(ps.funds, f2 + 3);
m.handle('p_0', { t: 'g.refresh' });
m.handle('p_0', { t: 'g.levelUp' });
assert.ok(calls.includes('refresh') && calls.includes('level2'));
m.dispose();
});
test('garrisons fire on their eventType only; hand pieces skip character_target_inboard; 投资人 doubles 获得时', () => {
const reg = registerBuiltins(new MetaRegistry(), DATA);
const fired = [];
const gains = [];
const gainG = Object.values(DATA.garrisons).find((g) => g.eventType === 'SERVER_GAIN' && g.owners.some((o) => DATA.chess[o] && DATA.chess[o].visible));
const finG = Object.values(DATA.garrisons).find((g) => g.eventType === 'SERVER_PREP_FIN' && g.bbStr && g.bbStr.conditionkey === 'character_target_inboard' && g.owners.some((o) => DATA.chess[o] && DATA.chess[o].visible && !DATA.chess[o].isGolden));
reg.garrison(gainG.effectKey, { run: (ctx, ev) => gains.push([ctx.source.garrisonId, ev.piece.id]) });
reg.garrison(finG.effectKey, { onPrepEnd: (ctx) => fired.push([ctx.source.garrisonId, ctx.source.where]) });
const { h, m, ps } = metaMatch(reg, { seed: 71 });
const owner = gainG.owners.find((o) => DATA.chess[o] && DATA.chess[o].visible && !DATA.chess[o].isGolden) || gainG.owners[0];
ps.acquireChess(owner, { source: 'test' });
const mine = gains.filter(([gid]) => DATA.chess[owner].garrisonIds.includes(gid));
assert.ok(mine.length >= 1, 'SERVER_GAIN garrison ran on gain');
gains.length = 0;
// 投资人 active → ×2
ps.bondCountBonus.investShip = 3;
ps.recompute();
assert.equal(ps.bonds.investShip.active, true);
const base = DATA.chess[owner].baseId;
if (ps.countCopies(base) < 2) {
ps.acquireChess(owner, { source: 'test' });
assert.equal(gains.filter(([gid]) => DATA.chess[owner].garrisonIds.includes(gid)).length, 2 * mine.length, '获得时 ×2');
}
ps.layers.investShip = 100;
ps.recompute();
assert.equal(m.dispatcher.investRepeat(ps), 3);
// prep-end garrison: board yes, hand no (inboard condition)
const finOwner = finG.owners.find((o) => DATA.chess[o] && DATA.chess[o].visible && !DATA.chess[o].isGolden);
for (const p of ps.hand) if (p) ps.returnCopies(p);
ps.hand.fill(null);
const onHand = give(m, ps, finOwner);
m.dispatch(ps, 'onPrepEnd', {});
assert.equal(fired.length, 0, 'inboard garrison does not fire from the hand');
const tile = [...ps.deployMap()].find(([, v]) => v === 'melee' || DATA.chess[finOwner].position === 'RANGED')[0].split(',').map(Number);
ps.hand[ps.hand.indexOf(onHand)] = null;
ps.board.set(`${tile[0]},${tile[1]}`, onHand);
m.dispatch(ps, 'onPrepEnd', {});
assert.deepEqual(fired.map((x) => x[1]), ['board']);
void h;
m.dispose();
});
test('ctx helpers: funds, layers (requireActive), grants (pool-aware), effects with counters, teammates, errors isolated', () => {
const reg = registerBuiltins(new MetaRegistry(), DATA);
let captured = null;
reg.global('ctx', { onRoundStart: (ctx) => { captured = ctx; } });
reg.global('boom', { onRefresh: () => { throw new Error('content bug'); } });
reg.global('loop', { onGain: (ctx) => { ctx.grantItem('chess_item_1_01_e_a'); } });
const h = makeMatch({ mode: 'coop', humans: 2, seed: 72, registry: reg, fake: true }).start();
h.toPrep(1);
const m = h.m;
const ps = h.ps('p_0');
assert.ok(captured, 'onRoundStart ran');
const ctx = m.dispatcher && (() => { let c = null; reg.global('grab', { onLevelUp: (x) => { c = x; } }); ps.funds = 50; m.handle('p_0', { t: 'g.levelUp' }); return c; })();
assert.equal(ctx.playerId, 'p_0');
const f = ps.funds;
ctx.addFunds(3);
assert.equal(ps.funds, f + 3);
ctx.addFunds(-1000);
assert.equal(ps.funds, 0, 'funds never go negative');
assert.equal(ctx.addLayers('yanShip', 5, { requireActive: true }), 0, 'inactive bond: no gain');
assert.equal(ctx.addLayers('yanShip', 5), 5);
assert.equal(ctx.layers('yanShip'), 5);
assert.equal(ctx.addLayers('nope', 5), 0);
const id = chessOfTier(4).find((c) => m.pool.has(c));
const left = m.pool.left(id);
const got = ctx.grantChess(id);
assert.ok(got && got.id === id);
assert.equal(m.pool.left(id), left - 1);
const drained = m.pool.take(id, 100);
assert.equal(ctx.grantChess(id), null, 'pool empty → the grant fails');
m.pool.give(id, drained);
assert.equal(ctx.grantChess('not_a_chess'), null);
ctx.grantFreeRefresh(2);
assert.equal(ps.shop.freeRefreshes, 2);
const ref = ctx.addEffect({ id: 'x1', name: '测试', desc: 'd', iconKind: 'team', counter: 3 });
assert.equal(ctx.effect('x1'), ref);
assert.ok(ps.privateView().effects.some((e) => e.id === 'x1' && e.counter === 3));
ctx.setEffectCounter('x1', 1);
assert.equal(ref.counter, 1);
assert.ok(ctx.removeEffect('x1'));
assert.equal(ctx.teammates().length, 1);
assert.equal(ctx.teammates()[0].playerId, 'p_1');
ctx.teammates()[0].addFunds(2);
// a throwing handler never breaks the intent
const errs = m.dispatcher.errors;
assert.deepEqual(m.handle('p_0', { t: 'g.refresh' }), { ok: true });
assert.ok(m.dispatcher.errors > errs);
// recursive grants are bounded by the depth cap (onGain → grantItem → onGain …)
ctx.grantItem('chess_item_1_01_e_a');
assert.ok(MAX_DEPTH >= 4);
checkInvariants(m);
m.dispose();
});
test('content overrides a built-in by registering the same key; consume items without a handler are rejected cleanly', () => {
const reg = registerBuiltins(new MetaRegistry(), DATA);
reg.item('chess_item_1_03_e', { onEquip: (ctx) => ctx.addFunds(100) });
const { m, ps } = metaMatch(reg, { seed: 73 });
const a = give(m, ps, chessOfTier(1).find((c) => m.pool.has(c)));
const coin = giveItem(m, ps, 'chess_item_1_03_e_a');
ps.funds = 0;
m.handle('p_0', { t: 'g.equip', itemUid: coin.uid, targetUid: a.uid });
assert.equal(ps.funds, 100);
reg.unregister('item:chess_item_4_01_e');
const pager = giveItem(m, ps, 'chess_item_4_01_e_a');
assert.deepEqual(m.handle('p_0', { t: 'g.equip', itemUid: pager.uid, targetUid: a.uid }), { error: 'BAD_TARGET', detail: 'effect not available' });
assert.ok(ps.find(pager.uid), 'the item stays in the hand');
m.dispose();
});
test('onBattleStart may edit the PlayerBattleInput; onBattleResult sees the result', () => {
const reg = registerBuiltins(new MetaRegistry(), DATA);
const results = [];
reg.global('battle', {
onBattleStart: (ctx, ev) => { ev.input.playerEffects.push({ id: 'meta-test', key: null, source: 'team', params: { x: 1 } }); },
onBattleResult: (ctx, ev) => results.push([ctx.playerId, ev.lpLoss, ev.perfect]),
});
const { h, m } = metaMatch(reg, { seed: 74 });
h.drive(() => m.phase === PHASE.SETTLE);
const { FakeBattle } = awaitFake;
const b = FakeBattle.instances.find((x) => x.kind === 'normal');
assert.ok(b.opts.players[0].playerEffects.some((e) => e.id === 'meta-test'));
assert.deepEqual(results, [['p_0', 0, true]]);
m.dispose();
});
const awaitFake = await import('./fakeBattle.js');
test('choice:<id> handler overrides the family default, runs once, and other sources observe the pick', async () => {
const { applyCard } = await import('../../server/match/choices.js');
const reg = registerBuiltins(new MetaRegistry(), DATA);
let own = 0;
const seen = [];
reg.choice('allybuff_select_3', { onChoicePick: (ctx, ev) => { own++; ctx.addEffect({ id: 'wealth', key: 'choice:allybuff_select_3', name: '财富', battle: false }); } });
reg.global('obs', { onChoicePick: (ctx, ev) => seen.push([ctx.playerId, ev.card.id, ev.forTeammate]) });
const h = makeMatch({ mode: 'coop', humans: 2, seed: 75, registry: reg, fake: true }).start();
h.toPrep(1);
const m = h.m;
const a = h.ps('p_0');
const funds = a.funds;
applyCard(m, a, { kind: 'tactic', id: 'allybuff_select_3', name: '财富', desc: '', team: true, family: 'tactic', idx: 0 });
assert.equal(own, 2, 'picker + teammate (team card), once each');
assert.equal(a.funds, funds, 'the default (+1 fund) was replaced by the handler');
assert.deepEqual(seen, [['p_0', 'allybuff_select_3', false], ['p_1', 'allybuff_select_3', true]]);
assert.ok(a.effects.some((e) => e.id === 'wealth'));
m.dispose();
});
+45
View File
@@ -0,0 +1,45 @@
// Observing rules of the client (public/js/battle/observe.js, research 09 §3.1 / §6.3) — the mirror of the server's
// Match._watchClient: prep boards, no observing while the own normal battle runs, a teammate's field once it is over,
// the other pair's boss field hidden, eliminated players free; the 联防 / 最终攻势 camera halves and their captions.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { observeTarget, teammateProgress, cameraLayers, layerCamera, isClientCombat } from '../../public/js/battle/observe.js';
const P = (id, seat, extra = {}) => ({ playerId: id, seat, name: id.toUpperCase(), alive: true, ...extra });
const pubOf = (phase, fields, players) => ({ phase, combatMode: 'client', fields, players });
test('observeTarget: prep, own battle running / over, boss pairs, eliminated players', () => {
const players = [P('a', 0), P('b', 1), P('c', 2, { alive: false })];
assert.equal(isClientCombat(pubOf('PREP', [], players)), true);
assert.deepEqual(observeTarget(players[1], pubOf('PREP', [], players), 'a'), { fieldId: 'n:b' });
assert.match(observeTarget(players[2], pubOf('PREP', [], players), 'a').reason, /淘汰/);
const combat = (liveA) => pubOf('COMBAT', [
{ fieldId: 'n:a', kind: 'normal', players: ['a'], live: liveA },
{ fieldId: 'n:b', kind: 'normal', players: ['b'], live: true, progress: { killed: 3, total: 8, done: false } },
], players);
assert.match(observeTarget(players[1], combat(true), 'a').reason, /作战中无法查看/);
assert.deepEqual(observeTarget(players[1], combat(true), 'a', { ownDone: true }), { fieldId: 'n:b' }, 'the local battle already ended');
assert.deepEqual(observeTarget(players[1], combat(false), 'a'), { fieldId: 'n:b' });
assert.deepEqual(observeTarget(players[0], combat(false), 'a', { observing: true }), { back: true });
assert.deepEqual(teammateProgress(combat(false), 'a'), [{ playerId: 'b', name: 'B', isBot: false, killed: 3, total: 8, done: false }]);
const fa = pubOf('FINAL_ASSAULT', [
{ fieldId: 'b1', kind: 'boss', players: ['a', 'b'], live: true },
{ fieldId: 'b2', kind: 'boss', players: ['d'], live: true },
], [...players, P('d', 3)]);
assert.match(observeTarget(P('d', 3), fa, 'a').reason, /另一组/);
assert.match(observeTarget(players[1], fa, 'a').reason, /同一战场/);
const dead = pubOf('FINAL_ASSAULT', fa.fields, [{ ...players[0], alive: false }, players[1], P('d', 3)]);
assert.deepEqual(observeTarget(P('d', 3), dead, 'a'), { fieldId: 'b2' }, 'eliminated: anything');
});
test('cameraLayers: ‹ LEFT / 全景 / RIGHT › with "你自己" / name / "无人在家"; none for normal or single boss fields', () => {
const pub = { players: [P('a', 0), P('b', 1)] };
const unite = { fieldId: 'u', kind: 'unite', rect: { r0: 9, r1: 12, c0: 0, c1: 20 }, players: ['a', 'b'], sides: { a: 'R', b: 'L' } };
assert.deepEqual(cameraLayers(unite, pub, 'a').map((l) => [l.key, l.label]), [['L', 'B'], ['ALL', '全景'], ['R', '你自己']]);
const single = { ...unite, players: ['b'], sides: { b: 'L' } };
assert.deepEqual(cameraLayers(single, pub, 'a').map((l) => l.label), ['B', '全景', '无人在家']);
assert.deepEqual(cameraLayers({ kind: 'normal' }, pub, 'a'), []);
assert.deepEqual(cameraLayers({ kind: 'boss', players: ['a'], sides: { a: 'L' } }, pub, 'a'), [], 'a solo boss field has no halves');
assert.deepEqual(layerCamera(unite, 'R', 'R'), { rect: unite.rect, side: 'R', half: true });
assert.deepEqual(layerCamera(unite, 'ALL', 'R'), { rect: unite.rect, side: 'R' });
});
+159
View File
@@ -0,0 +1,159 @@
// Shared pool accounting, copy-weighted odds, per-match bans (research 00-INDEX §3, §6).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { GameData } from '../../server/match/gamedata.js';
import { SharedPool, drawDisabledBonds } from '../../server/match/pool.js';
import { createRng } from '../../server/sim/rng.js';
import { DATA, makeMatch, give, checkInvariants, chessOfTier } from './harness.js';
const gdOf = (modeId = 'mode_multi_normal') => new GameData(DATA, modeId);
test('pool caps follow config (12/14/18/16/8/5, 缪尔赛思 4) and only visible, unbanned chess enter', () => {
const gd = gdOf();
const pool = new SharedPool(gd, { banned: [] });
const caps = { 1: 12, 2: 14, 3: 18, 4: 16, 5: 8, 6: 5 };
assert.equal(pool.entries.size, gd.visibleChess.length);
assert.equal(pool.entries.size, 112);
for (const [id, e] of pool.entries) {
const expect = id === 'chess_char_6_11_a' ? 4 : caps[e.tier];
assert.equal(e.cap, expect, id);
assert.equal(e.left, e.cap);
assert.ok(DATA.chess[id].visible && !DATA.chess[id].isGolden);
}
const banned = [gd.visibleChess[0], gd.visibleChess[5]];
const p2 = new SharedPool(gd, { banned });
assert.equal(p2.entries.size, 110);
assert.ok(!p2.has(banned[0]) && p2.left(banned[0]) === 0 && p2.take(banned[0]) === 0);
});
test('take/give never go below 0 or above the cap', () => {
const pool = new SharedPool(gdOf());
const id = chessOfTier(6)[0];
const cap = pool.cap(id);
assert.equal(pool.take(id, 3), 3);
assert.equal(pool.left(id), cap - 3);
assert.equal(pool.take(id, 100), cap - 3, 'take clamps at what is left');
assert.equal(pool.left(id), 0);
assert.equal(pool.take(id, 1), 0);
assert.equal(pool.give(id, 100), cap, 'give clamps at the cap');
assert.equal(pool.left(id), cap);
assert.equal(pool.give(id, 1), 0);
assert.equal(pool.take('nope', 1), 0);
assert.equal(pool.give('nope', 1), 0);
assert.equal(pool.take(id, -1), 0);
assert.equal(pool.take(id, NaN), 0);
});
test('odds sanity: level L rolls only tiers ≤ L; level 1 only tier 1; shares ≈ research table', () => {
const pool = new SharedPool(gdOf());
const rng = createRng(12345);
for (let level = 1; level <= 6; level++) {
const seen = {};
for (let i = 0; i < 4000; i++) {
const id = pool.roll(rng, { maxTier: level });
const t = DATA.chess[id].tier;
assert.ok(t <= level, `level ${level} rolled tier ${t}`);
seen[t] = (seen[t] || 0) + 1;
}
if (level === 1) assert.deepEqual(Object.keys(seen), ['1']);
const shares = pool.tierShares(level);
for (const [t, n] of Object.entries(seen)) assert.ok(Math.abs(n / 4000 - shares[t]) < 0.035, `L${level} T${t} ${n / 4000} vs ${shares[t]}`);
}
// research table (full pools, no bans): L6 ≈ 14.0 / 17.4 / 25.0 / 25.7 / 11.1 / 6.9 %
const s6 = pool.tierShares(6);
const want = { 1: 0.14, 2: 0.174, 3: 0.25, 4: 0.257, 5: 0.111, 6: 0.069 };
for (const t of Object.keys(want)) assert.ok(Math.abs(s6[t] - want[t]) < 0.01, `T${t} ${s6[t]}`);
const s2 = pool.tierShares(2);
assert.ok(Math.abs(s2[1] - 0.447) < 0.01 && Math.abs(s2[2] - 0.553) < 0.01);
});
test('rolls are copy-weighted: an exhausted chess never rolls; tier/filter options work', () => {
const pool = new SharedPool(gdOf());
const rng = createRng(7);
const t1 = chessOfTier(1);
for (const id of t1.slice(1)) pool.take(id, 100);
for (let i = 0; i < 200; i++) assert.equal(pool.roll(rng, { maxTier: 1 }), t1[0]);
pool.take(t1[0], 100);
assert.equal(pool.roll(rng, { maxTier: 1 }), null, 'empty tier → null');
for (let i = 0; i < 100; i++) assert.equal(DATA.chess[pool.roll(rng, { tier: 3 })].tier, 3);
const f = pool.roll(rng, { maxTier: 6, filter: (id) => id === chessOfTier(5)[2] });
assert.equal(f, chessOfTier(5)[2]);
});
test('item slot: tier ≤ level, shop-eligible normal equipment only', () => {
const gd = gdOf();
const pool = new SharedPool(gd);
const rng = createRng(99);
for (let level = 1; level <= 6; level++) {
for (let i = 0; i < 300; i++) {
const id = pool.rollItem(rng, level);
const it = DATA.items[id];
assert.ok(it && it.itemType === 'EQUIP' && !it.isGolden && !it.hideInShop, id);
assert.ok(it.tier <= level, `L${level} item tier ${it.tier}`);
}
}
});
test('per-match disabled bonds: 3 core + 4 add-on (NORMAL+), FUNNY static + 0 + 1; weight-0 never drawn; subset ban rule', () => {
for (const [modeId, core, addon] of [['mode_multi_hard', 3, 4], ['mode_single_abyss', 3, 4], ['mode_multi_funny', 0, 1], ['mode_single_normal', 3, 4]]) {
const gd = new GameData(DATA, modeId);
for (let seed = 1; seed <= 20; seed++) {
const { drawn, staticOff, banned } = drawDisabledBonds(gd, createRng(seed));
const nCore = drawn.filter((b) => DATA.bonds[b].isCore).length;
assert.equal(nCore, core, `${modeId} core`);
assert.equal(drawn.length - nCore, addon, `${modeId} addon`);
for (const b of drawn) {
assert.ok(DATA.bonds[b].weight > 0, `${b} has weight 0`);
assert.ok(!staticOff.includes(b));
}
const off = new Set([...drawn, ...staticOff]);
for (const id of gd.visibleChess) {
const bonds = DATA.chess[id].bonds;
assert.equal(banned.includes(id), bonds.length > 0 && bonds.every((b) => off.has(b)), id);
}
}
}
// FUNNY: the static list alone removes many operators
const f = drawDisabledBonds(new GameData(DATA, 'mode_multi_funny'), createRng(1));
assert.equal(f.staticOff.length, 10);
assert.ok(f.banned.length >= 20);
});
test('the match pool excludes banned chess; m.public lists disabled bonds and banned chess', () => {
const h = makeMatch({ mode: 'coop', difficulty: 'HARD', humans: 1, bots: 1, seed: 3 }).start();
const pub = h.lastBc('m.public');
assert.equal(pub.drawnDisabledBonds.length, 7);
assert.ok(pub.bannedChess.length > 0);
for (const id of pub.bannedChess) assert.ok(!h.m.pool.has(id), `${id} should not be in the pool`);
assert.equal(h.m.pool.entries.size + pub.bannedChess.length, 112);
checkInvariants(h.m);
h.m.dispose();
});
test('selling and elimination return copies; elites return 3', () => {
const h = makeMatch({ mode: 'coop', humans: 1, bots: 1, seed: 5 }).start();
h.toPrep(1);
const m = h.m;
const ps = h.ps('p_0');
const id = chessOfTier(2).find((c) => m.pool.has(c));
const cap = m.pool.cap(id);
const a = give(m, ps, id);
assert.equal(m.pool.left(id), cap - 1);
assert.deepEqual(m.handle('p_0', { t: 'g.sell', uid: a.uid }), { ok: true });
assert.equal(m.pool.left(id), cap);
const golden = DATA.chess[id].goldenId;
const e = give(m, ps, golden);
assert.equal(e.poolCopies, 3);
assert.equal(m.pool.left(id), cap - 3);
checkInvariants(m);
m.handle('p_0', { t: 'g.sell', uid: e.uid });
assert.equal(m.pool.left(id), cap);
// elimination returns everything
const ids = chessOfTier(1).filter((c) => m.pool.has(c)).slice(0, 4);
for (const c of ids) give(m, ps, c);
const before = ids.map((c) => m.pool.left(c));
ps.eliminate(1);
ids.forEach((c, i) => assert.equal(m.pool.left(c), before[i] + 1));
checkInvariants(m);
m.dispose();
});
+238
View File
@@ -0,0 +1,238 @@
// Real-time co-op over websockets with the REAL simulation: the production server + lobby + Match (RealScheduler,
// real Battle, client-side combat — DESIGN §14), two scripted human clients (test/helpers/wsClient.js) that simulate
// their own battles from the b.start specs (test/match/simClient.js, the same sim the browser runs) and report
// b.progress / b.result, and two AI teammates whose fields the server simulates, from the room to the prep of round 4.
// Phase timers are scaled down and combat runs at 40× instead of 2× so the test takes seconds; everything else
// (throttled views, drafts, 联防, settlement, observing a teammate after the own battle) is the real path.
// Clients act only on what the server tells them (m.public / m.private / b.start), like the browser UI.
import { test, after } from 'node:test';
import assert from 'node:assert/strict';
import { startServer } from '../../server/index.js';
import { Match } from '../../server/match/Match.js';
import { collectViolations } from '../../server/match/invariants.js';
import { TestClient } from '../helpers/wsClient.js';
import { attachWsSimClient } from './simClient.js';
import { DataSource } from '../../server/sim/simdata.js';
import { getData } from '../../server/data.js';
const DS = new DataSource(getData({ log: { warn() {}, error() {}, info() {} } }), null);
class RealtimeMatch extends Match {
constructor(o) { super({ ...o, timerScale: 0.1, combatSpeed: 40 }); RealtimeMatch.last = this; }
}
const errors = [];
const log = { info() {}, warn() {}, debug() {}, error: (...a) => errors.push(a.map(String).join(' ')) };
let srv = null;
const clients = [];
const delay = (ms) => new Promise((r) => setTimeout(r, ms));
after(async () => {
for (const c of clients) await c.terminate().catch(() => {});
if (srv) await srv.close();
});
async function player(name) {
const c = await TestClient.connect(`ws://127.0.0.1:${srv.port}/ws`);
clients.push(c);
const w = await c.hello(name);
c.id = w.playerId;
c.sim = attachWsSimClient(c, { ds: DS, pace: 'instant', speed: 40 });
return c;
}
const latest = (c, type) => { for (let i = c.log.length - 1; i >= 0; i--) if (c.log[i].t === type) return c.log[i]; return null; };
/** Candidate board tiles in the own region (the server answers BAD_TILE for illegal ones). */
const TILES = [];
for (let r = 12; r >= 9; r--) for (let c = 2; c <= 10; c++) TILES.push([r, c]);
/**
* A scripted human: confirms the briefing, drafts a band on its turn (passing it once when not last), picks the first free
* 机变 card, and in every prep buys what it can afford, places chess on legal tiles, clears the temp slots and readies.
* During combat it watches a teammate's field. Stops when `until()` holds.
*/
let abort = false;
async function drive(c, opts) {
try { await driveLoop(c, opts); } catch (e) { abort = true; throw e; }
}
async function driveLoop(c, { skipOnce = false, until, stats }) {
const done = new Set();
let skipped = !skipOnce;
const deadlineAt = Date.now() + 90_000;
while (!abort && !until() && Date.now() < deadlineAt) {
await delay(20);
const pub = latest(c, 'm.public');
const priv = latest(c, 'm.private');
if (!pub) continue;
const me = pub.players.find((p) => p.playerId === c.id);
if (pub.phase === 'INFO_CHECK' && !done.has('info')) {
done.add('info');
assert.equal((await c.request({ t: 'g.infoReady' })).t, 'ok');
} else if (pub.phase === 'BAND_DRAFT' && pub.draft && pub.draft.turn === c.id) {
const key = `band:${pub.draft.order.join()}`;
if (done.has(key)) continue;
done.add(key);
const last = pub.draft.order.indexOf(c.id) === pub.draft.order.length - 1;
if (!skipped && !last) {
skipped = true;
const r = await c.request({ t: 'g.bandSkip' });
// a slow client may lose its (scaled) turn to the timer before the skip arrives
if (r.t === 'ok') stats.skips++;
else assert.ok(['NOT_YOUR_TURN', 'ALREADY'].includes(r.code), `bandSkip: ${JSON.stringify(r)}`);
stats.skipTries++;
} else {
const r = await c.request({ t: 'g.band', bandId: 'band_bldsk' });
if (r.t === 'ok') stats.bands++;
else stats.bandTimeouts++;
}
} else if (pub.phase === 'SP_DRAFT' && pub.sp && pub.sp.turn === c.id) {
const key = `sp:${pub.round}`;
if (done.has(key)) continue;
done.add(key);
const card = pub.sp.cards.find((x) => pub.sp.taken[x.idx] == null);
const r = await c.request({ t: 'g.choice', idx: card.idx });
assert.equal(r.t, 'ok', `g.choice: ${JSON.stringify(r)}`);
stats.cards++;
} else if (pub.phase === 'PREP' && me && me.alive && priv && !priv.ready && !done.has(`prep:${pub.round}`)) {
done.add(`prep:${pub.round}`);
await prep(c, stats);
} else if (pub.phase === 'COMBAT' && !done.has(`watch:${pub.round}`)) {
// research 09 §3.1: a teammate's battle can be observed only after the own battle is over
const own = pub.fields.find((f) => f.players.includes(c.id));
const other = pub.fields.find((f) => f.kind === 'normal' && !f.players.includes(c.id) && f.live);
if (!other) continue;
if (own && own.live) {
if (!done.has(`early:${pub.round}`)) {
done.add(`early:${pub.round}`);
const r = await c.request({ t: 'g.watch', fieldId: other.fieldId });
if (r.t === 'error') { assert.equal(r.code, 'WRONG_PHASE'); stats.refused++; }
}
continue;
}
done.add(`watch:${pub.round}`);
const r = await c.request({ t: 'g.watch', fieldId: other.fieldId });
if (r.t === 'ok') stats.watches.push(other.fieldId);
}
}
}
async function prep(c, stats) {
const priv = () => latest(c, 'm.private');
// reward offer first (free)
if (priv().shop.rewardOffer) await c.request({ t: 'g.reward', idx: 0 });
// buy what is affordable (chess first)
for (let guard = 0; guard < 8; guard++) {
const p = priv();
const i = p.shop.slots.findIndex((s) => s && !s.sold && s.kind === 'chess' && s.price <= p.funds);
if (i < 0 || p.hand.every(Boolean)) break;
const r = await c.request({ t: 'g.buy', slot: i });
if (r.t !== 'ok') break;
stats.buys++;
await c.waitFor('m.private', (x) => x.shop.slots[i] && x.shop.slots[i].sold, 2000).catch(() => null);
}
// place hand chess on the board
for (let guard = 0; guard < 10; guard++) {
const p = priv();
if (p.deployCount >= p.deployCap) break;
const piece = [...p.hand, ...p.temp].find((x) => x && x.kind === 'chess');
if (!piece) break;
const taken = new Set(p.board.map((b) => `${b.row},${b.col}`));
let placed = false;
for (const [r, col] of TILES) {
if (taken.has(`${r},${col}`)) continue;
const res = await c.request({ t: 'g.move', uid: piece.uid, to: { area: 'board', row: r, col } });
if (res.t === 'ok') { placed = true; stats.placed++; break; }
assert.equal(res.code, 'BAD_TILE', `g.move: ${JSON.stringify(res)}`);
}
if (!placed) break;
await c.waitFor('m.private', (x) => x.board.some((b) => b.uid === piece.uid), 2000).catch(() => null);
}
// temp slots block Ready: sell chess / destroy items / place tokens
for (const t of priv().temp) {
if (!t) continue;
if (t.kind === 'chess') await c.request({ t: 'g.sell', uid: t.uid });
else if (t.kind === 'item') await c.request({ t: 'g.destroy', uid: t.uid });
}
const r = await c.request({ t: 'g.ready', ready: true });
if (r.t === 'ok') stats.readies++;
}
test('real-time co-op over websockets with the real simulation: 2 humans + 2 AI from the room to the prep of round 4', { timeout: 120_000 }, async () => {
srv = await startServer({ port: 0, host: '127.0.0.1', log, MatchClass: RealtimeMatch, seedFn: () => 20260928 });
const a = await player('Alpha');
const b = await player('Bravo');
assert.equal((await a.request({ t: 'room.create', mode: 'coop', difficulty: 'NORMAL' })).t, 'ok');
const st = await a.waitFor('room.state');
assert.equal((await b.request({ t: 'room.join', code: st.code })).t, 'ok');
assert.equal((await a.request({ t: 'room.addBot' })).t, 'ok');
assert.equal((await a.request({ t: 'room.addBot' })).t, 'ok');
assert.equal((await b.request({ t: 'room.ready', ready: true })).t, 'ok');
assert.equal((await a.request({ t: 'room.start' })).t, 'ok');
const reached = (c) => { const p = latest(c, 'm.public'); return !!p && ((p.phase === 'PREP' && p.round >= 4) || p.phase === 'RESULT' || p.round > 4); };
const stats = { a: { skips: 0, skipTries: 0, bands: 0, bandTimeouts: 0, cards: 0, buys: 0, placed: 0, readies: 0, watches: [], refused: 0 }, b: { skips: 0, skipTries: 0, bands: 0, bandTimeouts: 0, cards: 0, buys: 0, placed: 0, readies: 0, watches: [], refused: 0 } };
// snapshot of the prep → combat transitions the clients saw
await Promise.all([
drive(a, { skipOnce: true, until: () => reached(a), stats: stats.a }),
drive(b, { skipOnce: true, until: () => reached(b), stats: stats.b }),
]);
const m = RealtimeMatch.last;
assert.ok(m, 'the lobby built the match');
for (const c of [a, b]) {
const pub = latest(c, 'm.public');
assert.ok(pub.round >= 4, `${c.id} reached round ${pub.round} (${pub.phase})`);
}
assert.deepEqual(collectViolations(m), [], 'engine invariants hold in the live match');
assert.equal(m.errorCount, 0, `match errors: ${JSON.stringify(m.errors.slice(0, 3))}`);
assert.equal(m.dispatcher.errors, 0, 'meta handler errors');
assert.deepEqual(errors, [], 'no server errors logged');
assert.ok(stats.a.skipTries + stats.b.skipTries >= 1, 'a human tried to pass its band-draft turn (g.bandSkip)');
for (const [c, s] of [[a, stats.a], [b, stats.b]]) {
assert.equal(s.bands + s.bandTimeouts, 1, `${c.id} drafted a band (or its turn timed out → 华法琳)`);
assert.ok(s.buys >= 3 && s.placed >= 3 && s.readies >= 3, `${c.id} played its preps: ${JSON.stringify(s)}`);
// the SP round (NORMAL R3) gave every alive player a card
assert.equal(s.cards, 1, `${c.id} picked one 机变 card`);
const pubs = c.log.filter((x) => x.t === 'm.public');
const phases = new Set(pubs.map((x) => x.phase));
// (sub-100 ms presentation phases at this timer scale may fall between two throttled m.public frames)
for (const ph of ['INFO_CHECK', 'BAND_DRAFT', 'SP_DRAFT', 'PREP', 'COMBAT']) assert.ok(phases.has(ph), `${c.id} saw ${ph}`);
// m.public: throttled to ≤ 10/s (a 100 ms gap, small timer jitter tolerated)
for (let i = 1; i < pubs.length; i++) assert.ok(pubs[i].serverNow - pubs[i - 1].serverNow >= 95, `m.public ${pubs[i].serverNow - pubs[i - 1].serverNow} ms apart`);
const priv = latest(c, 'm.private');
assert.equal(priv.hand.length, 10);
assert.equal(priv.temp.length, 5);
assert.ok(priv.board.length >= 1 || !priv.alive);
// client-side combat: no combat streaming; the own spec every round (authoritative), results reported
assert.equal(c.log.filter((x) => x.t === 'b.snap' || x.t === 'b.ev').length, 0, 'no b.snap / b.ev');
const starts = c.log.filter((x) => x.t === 'b.start');
const own = starts.filter((x) => x.fieldId === `n:${c.id}` && x.authoritative);
assert.ok(own.length >= 3, `${c.id} got its own spec every round (${own.length})`);
for (const st of own) {
assert.equal(st.spec.kind, 'normal');
assert.equal(st.speed, 40);
assert.ok(c.sim.log.some((x) => x.t === 'b.result' && x.battleId === st.battleId), `${c.id} reported ${st.battleId}`);
}
assert.ok(c.sim.log.filter((x) => x.t === 'b.progress').length >= own.length);
// observing a teammate after the own battle: the watched field's spec arrives as a display replica
for (const fid of s.watches) assert.ok(starts.some((x) => x.fieldId === fid && x.watch && !x.authoritative), `${c.id} watched ${fid}`);
assert.ok(!c.sim.error, `${c.id} client sim error: ${c.sim.error}`);
const res = c.log.filter((x) => (x.t === 'error') && x.code === 'INTERNAL');
assert.deepEqual(res, [], 'no INTERNAL errors');
}
assert.equal(m.verifyStats.rejected, 0, 'honest clients are never rejected');
assert.equal(m.verifyStats.takeovers, 0, 'no takeovers');
assert.ok(stats.a.watches.length + stats.b.watches.length >= 1, 'a human observed a teammate after its own battle');
// the AI teammates played too (bought, placed, readied every prep)
for (const ps of m.order.filter((p) => p.isBot && p.alive)) assert.ok(ps.deployCount >= 3, `${ps.playerId} deployed ${ps.deployCount}`);
// both humans leave: the match ends as abandoned exactly once and the room returns to the lobby
assert.equal((await a.request({ t: 'g.leave' })).t, 'ok');
assert.equal((await b.request({ t: 'g.leave' })).t, 'ok');
await delay(50);
assert.equal(m.ended, true);
assert.equal(m.outcome.reason, 'abandoned');
assert.deepEqual(errors, []);
});
+128
View File
@@ -0,0 +1,128 @@
// RESULT: titles (评语) assignment, per-player rows, trophies, rewards.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { assignTitles } from '../../server/match/results.js';
import { GameData } from '../../server/match/gamedata.js';
import { PHASE } from '../../shared/constants.js';
import { DATA, makeMatch } from './harness.js';
const gd = new GameData(DATA, 'mode_multi_hard');
const fake = (seat, s = {}, layers = 0, lp = 10) => ({
playerId: `p${seat}`, seat, alive: true, lp, lpAtFinal: lp,
stats: { bossDamage: 0, merges: 0, itemsEquipped: 0, gold: 0, ...s },
activatedLayers: () => layers,
});
test('titles: each player ≤ 1 title, each title ≤ once, leaders first, 卫戍之星 only on a win', () => {
const players = [
fake(0, { bossDamage: 900, merges: 1, gold: 50 }, 100, 5),
fake(1, { bossDamage: 100, merges: 6, gold: 20 }, 300, 9),
fake(2, { itemsEquipped: 9, gold: 99 }, 10, 30),
fake(3, { merges: 2, gold: 1 }, 50, 12),
];
const win = assignTitles(gd, players, true);
const ids = [...win.values()].map((t) => t.id);
assert.equal(new Set(ids).size, ids.length, 'each title used once');
assert.equal(win.get('p0').id, 'comment_1', 'top boss damage (win)');
assert.ok(['comment_2', 'comment_4'].includes(win.get('p1').id));
assert.ok(win.size === 4);
const loss = assignTitles(gd, players, false);
assert.ok(![...loss.values()].some((t) => t.id === 'comment_1'), '卫戍之星 needs a win');
for (const t of [...win.values(), ...loss.values()]) assert.ok(t.name && t.text && t.picId);
// nobody gets a 'max' title for a zero stat; the 'min' title 坚若磐石 (least LP lost) goes to a player who lost none
const none = assignTitles(gd, [fake(0, {}, 0, 0)], false);
assert.deepEqual([...none.values()].map((t) => t.id), ['comment_3']);
const dead = assignTitles(gd, [{ ...fake(0, {}, 0, 0), alive: false }], false);
assert.equal(dead.size, 0, 'an eliminated player gets nothing for zero stats');
});
test('m.result rows: lineup, bonds, stats, roundsPassed per player, trophies (co-op) and rewards', () => {
const h = makeMatch({ mode: 'coop', difficulty: 'HARD', humans: 2, seed: 80, fake: true, script: (b) => (b.round !== 2 ? {} : b.kind === 'unite' ? { survivors: { p_1: 10 } } : { leaks: { p_1: 10 } }) }).start();
const m = h.m;
h.toPrep(1);
h.ps('p_1').lp = 5;
h.drive(() => m.phase === PHASE.PREP && m.round === 3);
assert.equal(h.ps('p_1').alive, false);
h.m.onLeave('p_1');
assert.equal(h.ended, null, 'a human is still there');
h.m.onLeave('p_0');
const r = h.ended;
assert.ok(r);
assert.equal(r.reason, 'abandoned');
assert.equal(r.victory, false);
const rows = Object.fromEntries(r.players.map((p) => [p.playerId, p]));
assert.equal(rows.p_1.roundsPassed, 1);
assert.equal(rows.p_1.eliminatedRound, 2);
assert.equal(rows.p_0.roundsPassed, 2);
for (const p of r.players) {
for (const k of ['playerId', 'seat', 'name', 'isBot', 'victory', 'roundsPassed', 'lineup', 'bonds', 'stats', 'title', 'trophies', 'reward']) assert.ok(k in p, k);
for (const k of ['dmgDealt', 'kills', 'leaks', 'gold', 'refreshes', 'merges', 'itemsEquipped', 'bossDamage', 'activatedLayers', 'lpLost']) assert.ok(Number.isFinite(p.stats[k]), k);
}
assert.equal(rows.p_0.reward, Math.round(20 * 1.7 * 1.25), 'reward = base[rounds] × difficulty × mode');
assert.equal(r.modeId, 'mode_multi_hard');
assert.ok(Number.isFinite(r.durationMs));
m.dispose();
});
test('Final Assault: the result LP are the alive players\' shares of the merged team LP (a team beaten to 0 shows 0)', () => {
// defeat: the boss is never hurt, the overtime drain empties the team pool
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 2, seed: 4, fake: true, script: (b) => (b.kind === 'boss' ? { bossDps: 0 } : {}) }).start();
h.autoHumans();
const end = h.runToEnd();
assert.equal(h.m.teamLp, 0);
assert.equal(end.reason, 'defeat');
assert.equal(end.teamLp, 0);
for (const p of end.players) {
assert.equal(p.alive, true);
assert.equal(p.roundsPassed, 13);
assert.equal(p.lp, 0, `${p.playerId}: a team beaten to 0 LP shows 0, not its pre-merge LP`);
assert.equal(h.lastTo(p.playerId, 'm.private').lp, 0, 'm.private follows');
}
assert.ok(h.lastBc('m.public').players.every((p) => p.lp === 0), 'm.public follows');
h.m.dispose();
// victory after losing LP in the boss fight: Σ shares = team LP, each share ∝ the LP the player brought in
const h2 = makeMatch({
mode: 'coop', difficulty: 'FUNNY', humans: 3, seed: 7, fake: true,
script: (b) => (b.kind === 'boss' ? { bossDps: b.sharedBoss.maxHp / 8, leakEvents: b.fieldId === 'b1' ? [{ at: 1, lpr: 5 }, { at: 2, lpr: 6 }] : [] } : {}),
}).start();
h2.autoHumans();
h2.drive(() => h2.m.phase === PHASE.PREP && h2.m.round === 14);
const [a, b, c] = ['p_0', 'p_1', 'p_2'].map((id) => h2.ps(id));
a.lp = 30; b.lp = 21; c.lp = 9;
const r2 = h2.runToEnd();
assert.equal(r2.victory, true);
assert.equal(h2.m.teamLp, 60 - 11);
assert.equal(r2.teamLp, 49);
const lp = Object.fromEntries(r2.players.map((p) => [p.playerId, p.lp]));
assert.equal(lp.p_0 + lp.p_1 + lp.p_2, 49);
for (const [id, brought] of [['p_0', 30], ['p_1', 21], ['p_2', 9]]) {
assert.ok(Math.abs(lp[id] - (49 * brought) / 60) < 1, `${id}: share ${lp[id]} of 49 for ${brought}/60`);
assert.ok(lp[id] <= brought);
}
h2.m.dispose();
});
test('co-op trophies / victory follow each player\'s OWN rounds passed: a teammate eliminated in R6 gets the table value of 5 rounds, not the Hidden-Core row', async () => {
const { buildResult } = await import('../../server/match/results.js');
const h = makeMatch({ mode: 'coop', difficulty: 'HARD', humans: 2, seed: 5, fake: true }).start();
h.toPrep(6);
const m = h.m;
const out = h.ps('p_1');
out.lp = 0;
out.eliminate(6);
m.round = 15;
m.hiddenReached = true;
const res = buildResult(m, { victory: true, hiddenReached: true, hiddenCleared: true, reason: 'victory' });
const tr = m.gd.config.trophies;
const row = (pid) => res.players.find((p) => p.playerId === pid);
assert.equal(row('p_0').roundsPassed, 15);
assert.equal(row('p_0').trophies, tr.hiddenCore.HARD, 'the survivor cleared the Hidden Core');
assert.equal(row('p_0').victory, true);
assert.equal(row('p_1').roundsPassed, 5);
assert.equal(row('p_1').trophies, tr.byRoundsPassed.find((x) => 5 <= x.maxRound).HARD, 'own 5 rounds, not the Hidden-Core row');
assert.notEqual(row('p_1').trophies, tr.hiddenCore.HARD);
assert.equal(row('p_1').victory, false, 'eliminated before the boss round: the clear is not its own');
assert.equal(res.victory, true, 'the team result is still a win');
m.dispose();
});
+331
View File
@@ -0,0 +1,331 @@
// The browser battle runner (public/js/battle/runner.js) under Node with a fake socket, a manual clock and manual
// animation frames: pacing (60 ticks per real second at 2×, ≤ 8 per frame), the b.snap / b.ev feed and the field meta,
// b.progress / b.result when authoritative (valid protocol frames, the same result as the server's simulation),
// fast-forward to `elapsed`, b.end (forced / takeover), the hidden-tab pump, the boss pool sync, phase clearing.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createBattleRunner, ticksPerFrameCap } from '../../public/js/battle/runner.js';
import { createStore, initialState } from '../../public/js/store.js';
import * as specMod from '../../server/sim/spec.js';
import { DataSource } from '../../server/sim/simdata.js';
import { validateC2S } from '../../shared/protocol.js';
import { validateClientResult, runHeadless } from '../../server/match/fields.js';
import { PHASE } from '../../shared/constants.js';
import { DATA, makeMatch } from './harness.js';
const DS = new DataSource(DATA, null);
function fakeNet() {
const handlers = new Map();
const n = {
sent: [],
on(t, fn) { if (!handlers.has(t)) handlers.set(t, new Set()); handlers.get(t).add(fn); return () => handlers.get(t).delete(fn); },
emit(t, msg) { for (const fn of handlers.get(t) || []) fn({ t, ...msg }); },
send(t, fields) { const msg = { ...fields, t }; assert.equal(validateC2S(msg), null, `invalid ${t}`); n.sent.push(msg); return true; },
request(t, fields) { const msg = { ...fields, t, rid: 1 }; assert.equal(validateC2S(msg), null, `invalid ${t}`); n.sent.push(msg); return Promise.resolve({ t: 'ok' }); },
};
return n;
}
function rig({ hidden = false } = {}) {
let t = 1000;
const frames = [];
const intervals = [];
const doc = { hidden, addEventListener() {} };
const net = fakeNet();
const store = createStore(initialState);
const runner = createBattleRunner({
net, store, doc,
now: () => t,
raf: (fn) => { frames.push(fn); return frames.length; },
caf: () => {},
setInterval: (fn) => { intervals.push(fn); return intervals.length; },
clearInterval: () => {},
loadSim: async () => ({ spec: specMod, ds: DS }),
logger: { error() {}, warn() {}, info() {}, debug() {} },
});
const feed = { snaps: [], evs: [], fields: [] };
runner.on('snap', (s) => feed.snaps.push(s));
runner.on('ev', (e) => feed.evs.push(e));
runner.on('field', (f) => feed.fields.push(f));
const r = {
runner, net, store, doc, feed,
get t() { return t; },
/** advance the clock by `ms` in animation frames of `step` ms (or pump intervals when hidden) */
advance(ms, step = 1000 / 60) {
const end = t + ms;
while (t < end) {
t = Math.min(end, t + step);
if (doc.hidden) { for (const fn of intervals) fn(); continue; }
const q = frames.splice(0);
for (const fn of q) fn(t);
}
},
async settle() { for (let i = 0; i < 50; i++) { await new Promise((res) => setImmediate(res)); const q = frames.splice(0); for (const fn of q) fn(t); } },
};
return r;
}
/** A real b.start of round 2 (a board with operators) from a client-combat match. */
function realStart(seed = 7301) {
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 1, bots: 1, seed, captureFrames: false, clientCombat: true, clients: false });
h.autoHumans();
h.m.start();
h.run(() => h.m.phase === PHASE.COMBAT && h.m.round === 1);
// round 1: the bot fights, the human (no client here) is taken over at the deadline; round 2 has a real board
h.run(() => h.m.phase === PHASE.COMBAT && h.m.round === 2, { maxSteps: 3e6 });
const msg = h.lastTo('p_0', 'b.start');
h.m.dispose();
return msg;
}
test('authoritative battle: 2× pacing, ≤ max(8, 4·speed) ticks per frame, b.snap / b.ev feed, field meta in the store, progress ~1 Hz, the server-identical result', async () => {
const start = realStart();
assert.ok(start && start.authoritative);
const r = rig();
r.net.emit('b.start', start);
await r.settle();
assert.equal(r.feed.fields.length, 1);
const field = r.store.get().match.field;
assert.equal(field.fieldId, start.fieldId);
assert.equal(field.local, true);
assert.equal(field.battleId, start.battleId);
assert.ok(Array.isArray(field.units), 'unit infos (none deployed before the first tick)');
assert.deepEqual(r.store.get().match.battle && [r.store.get().match.battle.authoritative, r.store.get().match.battle.own], [true, true]);
const e = r.runner._entries.get(start.battleId);
r.advance(1000);
assert.ok(Math.abs(e.battle.tickCount - 60) <= 2, `60 ticks per real second at 2× (${e.battle.tickCount})`);
assert.ok(r.feed.snaps.length >= 55, 'a snapshot per frame');
const snap = r.feed.snaps[r.feed.snaps.length - 1];
assert.equal(snap.t, 'b.snap');
assert.equal(snap.fieldId, start.fieldId);
assert.equal(typeof snap.gt, 'number');
assert.ok(r.feed.evs.every((x) => x.t === 'b.ev' && Array.isArray(x.ev) && typeof x.gt === 'number'));
const spawnedOf = () => r.feed.evs.flatMap((x) => x.ev).filter((x) => x[0] === 'spawn').map((x) => x[1]);
assert.ok(spawnedOf().some((u) => u.side === 'ally' && /^chess_/.test(u.defId)), 'the board arrives as spawn events at the first tick');
// a slow frame never steps more than the cap
const before = e.battle.tickCount;
r.advance(100, 100);
assert.ok(e.battle.tickCount - before <= ticksPerFrameCap(2), 'cap per frame');
// run to the end
for (let i = 0; i < 400 && !e.done; i++) r.advance(1000, 50);
assert.ok(e.done, 'finished');
assert.ok(spawnedOf().some((u) => u.side === 'enemy'), 'the enemies arrive as spawn events');
await r.settle();
const prog = r.net.sent.filter((x) => x.t === 'b.progress');
const secs = e.battle.time / 2;
assert.ok(prog.length >= secs * 0.8 && prog.length <= secs + 3, `~1 progress per real second (${prog.length} over ${secs.toFixed(1)} s)`);
assert.equal(prog[prog.length - 1].done, true);
const res = r.net.sent.filter((x) => x.t === 'b.result');
assert.equal(res.length, 1);
const server = runHeadless(specMod.createBattleFromSpec(start.spec, DS, { recordEvents: false }), { players: start.spec.players.map((p) => p.playerId) }).result;
const a = validateClientResult(start.spec, res[0].result, {});
const b = validateClientResult(start.spec, specMod.compactResult(server), {});
assert.ok(a.ok && b.ok);
assert.equal(specMod.resultDigest(a.result).hash, specMod.resultDigest(b.result).hash, 'the browser result equals the server simulation');
r.runner.dispose();
});
test('fast-forward to `elapsed` before showing; display replicas never report; b.end takeover demotes; hidden tab keeps an authoritative battle going', async () => {
const start = realStart(7302);
// observing a running field 20 game s in
const r = rig();
r.net.emit('b.start', { ...start, authoritative: false, watch: true, elapsed: 20 });
await r.settle();
const e = r.runner._entries.get(start.battleId);
assert.ok(e.battle.time >= 19.8, `caught up to the field clock (${e.battle.time})`);
assert.equal(r.feed.fields.length, 1, 'shown once, after the catch-up');
r.advance(3000);
assert.equal(r.net.sent.length, 0, 'a display replica never reports');
// the same battleId again (e.g. back from watching): no rebuild
r.net.emit('b.start', { ...start, authoritative: false, watch: true, elapsed: 30 });
await r.settle();
assert.equal(r.runner._entries.get(start.battleId), e);
r.runner.dispose();
// authoritative, then the server takes the field over
const r2 = rig();
r2.net.emit('b.start', start);
await r2.settle();
r2.advance(1500);
r2.net.emit('b.end', { battleId: start.battleId, fieldId: start.fieldId, reason: 'takeover' });
const n = r2.net.sent.length;
for (let i = 0; i < 200; i++) r2.advance(1000, 50);
assert.equal(r2.net.sent.length, n, 'no more reports after a takeover');
r2.runner.dispose();
// hidden tab: the interval pump advances the authoritative battle without rendering
const r3 = rig({ hidden: true });
r3.net.emit('b.start', start);
await r3.settle();
const e3 = r3.runner._entries.get(start.battleId);
const snaps = r3.feed.snaps.length;
r3.advance(2000, 1000);
assert.ok(e3.battle.tickCount >= 110, `pumped while hidden (${e3.battle.tickCount})`);
assert.equal(r3.feed.snaps.length, snaps, 'nothing rendered while hidden');
r3.runner.dispose();
});
test('b.end forced ends the local battle and reports at once; a new prep clears every battle', async () => {
const start = realStart(7303);
const r = rig();
r.net.emit('b.start', start);
await r.settle();
r.advance(2000);
r.net.emit('b.end', { battleId: start.battleId, fieldId: start.fieldId, reason: 'forced' });
await r.settle();
const res = r.net.sent.filter((x) => x.t === 'b.result');
assert.equal(res.length, 1);
assert.equal(res[0].result.reason, 'forced');
r.store.patch('match', { public: { phase: 'SETTLE' } });
assert.equal(r.runner._entries.size, 1, 'the finished field stays on screen through SETTLE');
r.store.patch('match', { public: { phase: 'PREP' } });
assert.equal(r.runner._entries.size, 0);
assert.equal(r.store.get().match.battle, null);
r.runner.dispose();
});
test('boss field: the local pool follows b.pool (server hp − unacknowledged local damage); progress carries bossDmg / by / leaks at 4 Hz', async () => {
const h = makeMatch({ mode: 'solo', difficulty: 'FUNNY', humans: 1, seed: 7304, captureFrames: false, clientCombat: true, clients: false });
h.autoHumans();
h.m.start();
h.run(() => h.ended != null || h.m.phase === PHASE.FINAL_ASSAULT, { maxSteps: 5e6 });
if (h.m.phase !== PHASE.FINAL_ASSAULT) { h.m.dispose(); return; } // (the seed did not reach the boss round)
const start = h.lastTo('p_0', 'b.start');
h.m.dispose();
assert.equal(start.kind, 'boss');
const r = rig();
r.net.emit('b.start', start);
await r.settle();
const e = r.runner._entries.get(start.battleId);
const pool = e.battle.sharedBoss;
// the pool's cumulative damage at the instant of each report (damage dealt after the last report of the window is
// reported by the next one)
const cumAtSend = [];
const send = r.net.send;
r.net.send = (t, fields) => { if (t === 'b.progress') cumAtSend.push(pool.cum); return send(t, fields); };
r.advance(3000);
assert.ok(pool.cum > 0, 'local damage to the pool');
const prog = r.net.sent.filter((x) => x.t === 'b.progress');
assert.ok(prog.length >= 10 && prog.length <= 14, `4 Hz (${prog.length} in 3 s)`);
const last = prog[prog.length - 1];
assert.equal(last.bossDmg, cumAtSend[cumAtSend.length - 1]);
assert.ok(last.bossDmg <= pool.cum);
assert.ok(last.by && typeof last.by === 'object');
assert.equal(typeof last.leaks, 'number');
r.net.emit('b.pool', { hp: pool.maxHp * 0.5, max: pool.maxHp, teamLp: 20, acked: { [start.fieldId]: pool.cum } });
assert.ok(Math.abs(pool.hp - pool.maxHp * 0.5) < 1e-6, 'server hp when everything is acknowledged');
r.runner.dispose();
});
/** A fake net whose b.result requests follow a script: 'ok' | 'lost' (DISCONNECTED) | 'offline' | 'timeout' | 'refused'. */
function scriptResults(r, script) {
r.net.request = (t, fields) => {
const msg = { ...fields, t, rid: 1 };
assert.equal(validateC2S(msg), null, `invalid ${t}`);
r.net.sent.push(msg);
const mode = script.shift() || 'ok';
if (mode === 'ok') return Promise.resolve({ t: 'ok' });
const code = { lost: 'DISCONNECTED', offline: 'OFFLINE', timeout: 'TIMEOUT', refused: 'WRONG_PHASE' }[mode];
return Promise.reject(Object.assign(new Error(code), { code }));
};
}
test('a b.result lost with the socket is sent again when the session resumes and on an authoritative b.start of the finished battle; a refusal is final', async () => {
const start = realStart(7305);
const results = (r) => r.net.sent.filter((x) => x.t === 'b.result');
const warn = console.warn;
console.warn = () => {};
try {
// DISCONNECTED around the end of the battle
const r = rig();
scriptResults(r, ['lost']);
r.net.emit('b.start', start);
await r.settle();
r.advance(1500);
r.net.emit('b.end', { battleId: start.battleId, fieldId: start.fieldId, reason: 'forced' });
await r.settle();
const e = r.runner._entries.get(start.battleId);
assert.equal(results(r).length, 1);
assert.equal(e.delivery, 'undelivered');
r.net.emit('status', { status: 'reconnecting' });
await r.settle();
assert.equal(results(r).length, 1, 'not while reconnecting');
r.net.emit('status', { status: 'online' });
await r.settle();
assert.equal(results(r).length, 2, 're-sent on resume');
assert.deepEqual(results(r)[1].result, results(r)[0].result, 'the same result');
assert.equal(results(r)[1].battleId, start.battleId);
assert.equal(e.delivery, 'delivered');
r.net.emit('status', { status: 'online' });
await r.settle();
assert.equal(results(r).length, 2, 'a delivered result is not sent again on resume');
// the server resyncs the finished field as still waiting for its authority's result: sent again
r.net.emit('b.start', { ...start, authoritative: true, elapsed: 3 });
await r.settle();
assert.equal(results(r).length, 3);
assert.equal(r.runner._entries.get(start.battleId), e, 'no rebuild');
// a display resend does not
r.net.emit('b.start', { ...start, authoritative: false, watch: false, elapsed: 3 });
await r.settle();
assert.equal(results(r).length, 3);
r.runner.dispose();
// two timeouts (retried once) → undelivered; OFFLINE → undelivered; both go out on resume
const r2 = rig();
scriptResults(r2, ['timeout', 'timeout']);
r2.net.emit('b.start', start);
await r2.settle();
r2.net.emit('b.end', { battleId: start.battleId, fieldId: start.fieldId, reason: 'forced' });
await r2.settle();
assert.equal(results(r2).length, 2, 'one retry on a timeout');
assert.equal(r2.runner._entries.get(start.battleId).delivery, 'undelivered');
r2.net.emit('status', { status: 'online' });
await r2.settle();
assert.equal(results(r2).length, 3);
r2.runner.dispose();
// the server answered with a refusal (stale battle, match over): never sent again
const r3 = rig();
scriptResults(r3, ['refused']);
r3.net.emit('b.start', start);
await r3.settle();
r3.net.emit('b.end', { battleId: start.battleId, fieldId: start.fieldId, reason: 'forced' });
await r3.settle();
r3.net.emit('status', { status: 'online' });
await r3.settle();
assert.equal(results(r3).length, 1);
r3.runner.dispose();
} finally {
console.warn = warn;
}
});
test('solo pause: m.public.paused freezes every local battle clock (no ticks, no reports); resume continues where it stopped', async () => {
const start = realStart(7306);
const r = rig();
r.store.patch('match', { public: { phase: 'COMBAT', paused: false } });
r.net.emit('b.start', start);
await r.settle();
const e = r.runner._entries.get(start.battleId);
r.advance(1000);
const t1 = e.battle.tickCount;
assert.ok(Math.abs(t1 - 60) <= 2, `running (${t1})`);
r.store.patch('match', { public: { phase: 'COMBAT', paused: true } });
assert.equal(r.runner.state().paused, true);
assert.equal(r.store.get().match.battle.paused, true, 'published for the HUD');
const reports = r.net.sent.length;
r.advance(5000);
assert.equal(e.battle.tickCount, t1, 'no tick while paused');
assert.equal(r.net.sent.length, reports, 'no report while paused');
// a resend while paused (reconnect) keeps the clock frozen
r.net.emit('b.start', { ...start, elapsed: t1 / 30 });
await r.settle();
r.advance(1000);
assert.equal(e.battle.tickCount, t1);
r.store.patch('match', { public: { phase: 'COMBAT', paused: false } });
assert.equal(r.runner.state().paused, false);
r.advance(1000);
assert.ok(Math.abs(e.battle.tickCount - (t1 + 60)) <= 2, `resumed on the same clock, no catch-up burst (${e.battle.tickCount})`);
r.runner.dispose();
});
+215
View File
@@ -0,0 +1,215 @@
// test/match/simClient.js — a scripted "browser" under client-side combat (DESIGN §14) for Match tests: it answers
// b.start (authoritative) by simulating the spec with the shared sim (server/sim/spec.js createBattleFromSpec), reports
// b.progress / b.result through `send` (every frame passes shared/protocol.js validateC2S, like the platform), syncs
// its boss pool with b.pool and obeys b.end. Timers come from the injected scheduler (the harness' VirtualScheduler, or
// real timers for websocket clients), never synchronous with the server call that delivered the frame.
//
// const c = new SimClient('p_0', { send, setTimeout, clearTimeout, ds, BattleClass, pace: 'instant' | 'paced', speed })
// c.onMessage(frame)
//
// pace 'instant' simulates normal / 联防 fields to the end in one timer callback (boss fields are always paced: they
// share the pool with other fields); 'paced' steps every field at `speed` game-s per real second in 250 ms slices.
// Test knobs: `tamper(result, spec) → result` edits the outgoing result, `mute: true` never reports, `stall: true`
// reports progress but no result, `delayMs` delays the first slice. `log` keeps every sent frame; `battles` every
// local battle by battleId.
import assert from 'node:assert/strict';
import { createBattleFromSpec, compactResult, battleProgress, attachLpMeter } from '../../server/sim/spec.js';
import { validateC2S } from '../../shared/protocol.js';
import { TICK } from '../../server/sim/constants.js';
const SLICE_MS = 250;
export class SimClient {
constructor(playerId, o) {
this.playerId = playerId;
this.o = { pace: 'instant', speed: 2, ...o };
this.log = [];
/** @type {Map<string, { battle: any, spec: any, authoritative: boolean, meter: any, timer: any, finished: boolean, sentResult: boolean }>} */
this.battles = new Map();
this.starts = [];
this.ends = [];
this.pools = [];
}
send(msg) {
const bad = validateC2S(msg);
assert.equal(bad, null, `${this.playerId} would send an invalid ${msg.t}: ${bad}`);
this.log.push(msg);
this.o.send(msg);
}
/** Stop every timer (the page closed). */
close() {
this.closed = true;
for (const e of this.battles.values()) if (e.timer) { this.o.clearTimeout(e.timer); e.timer = null; }
}
onMessage(msg) {
if (!msg || typeof msg !== 'object' || this.closed) return;
if (msg.t === 'b.start') this._start(msg);
else if (msg.t === 'b.pool') this._pool(msg);
else if (msg.t === 'b.end') this._end(msg);
}
_start(msg) {
this.starts.push(msg);
const cur = this.battles.get(msg.battleId);
if (cur) { // resend (reconnect / watch back / authority handover): keep the running battle
const was = cur.authoritative;
cur.authoritative = !!msg.authoritative;
if (cur.authoritative && !was) {
// a replica promoted to authority (handover): it ran live in a browser — catch up to the field's clock
this._stepTo(cur, Number(msg.elapsed) || 0);
this._schedule(cur, 0);
}
return;
}
const entry = { spec: msg.spec, battleId: msg.battleId, fieldId: msg.fieldId, authoritative: !!msg.authoritative, finished: false, sentResult: false, timer: null, lastProgress: -Infinity };
entry.battle = createBattleFromSpec(msg.spec, this.o.ds, { BattleClass: this.o.BattleClass, recordEvents: false, quiet: true });
entry.meter = attachLpMeter(entry.battle);
this.battles.set(msg.battleId, entry);
// catch up to the field's clock (reconnect / observing a running field)
const target = Number(msg.elapsed) || 0;
if (target > 0) this._stepTo(entry, target);
if (entry.authoritative) this._schedule(entry, this.o.delayMs || 0);
}
_pool(msg) {
this.pools.push(msg);
for (const e of this.battles.values()) {
const pool = e.battle.sharedBoss;
if (pool && typeof pool.sync === 'function') pool.sync(msg.hp, msg.acked ? msg.acked[e.fieldId] : undefined);
}
}
_end(msg) {
this.ends.push(msg);
const e = this.battles.get(msg.battleId);
if (!e) return;
if (msg.reason === 'takeover') { e.authoritative = false; if (e.timer) this.o.clearTimeout(e.timer); e.timer = null; return; }
try { e.battle.forceEnd(msg.reason === 'timeout' ? 'timeout' : 'forced'); } catch { /* ignore */ }
if (e.authoritative) this._schedule(e, 0);
}
_stepTo(e, gt) {
const b = e.battle;
const cap = Math.ceil(gt / TICK);
while (!b.finished && (b.tickCount ?? Math.round(b.time / TICK)) < cap) b.step();
}
_schedule(e, ms) {
if (e.timer) this.o.clearTimeout(e.timer);
e.timer = this.o.setTimeout(() => { e.timer = null; this._slice(e); }, ms);
}
_slice(e) {
if (this.closed || !e.authoritative || e.sentResult || this.o.mute) return;
const b = e.battle;
const bossLike = e.spec.kind === 'boss' || e.spec.kind === 'hidden';
if (this.o.pace === 'instant' && !bossLike) {
let n = 0;
while (!b.finished && n++ < 200_000) b.step();
} else {
const ticks = Math.max(1, Math.round(((this.o.speed || 2) * SLICE_MS) / 1000 / TICK));
for (let i = 0; i < ticks && !b.finished; i++) b.step();
}
this._progress(e);
if (b.finished) {
if (this.o.stall) return;
let result = compactResult(b.result());
if (this.o.tamper) result = this.o.tamper(result, e.spec);
e.sentResult = true;
this.send({ t: 'b.result', battleId: e.battleId, result });
return;
}
this._schedule(e, SLICE_MS);
}
_progress(e) {
const p = battleProgress(e.battle);
const msg = { t: 'b.progress', battleId: e.battleId, gt: p.gt, killed: Math.min(p.killed, p.total), total: p.total, done: p.done };
const bossLike = e.spec.kind === 'boss' || e.spec.kind === 'hidden';
if (bossLike) {
const pool = e.battle.sharedBoss;
msg.leaks = e.meter.lp;
msg.bossDmg = pool && Number.isFinite(pool.cum) ? pool.cum : 0;
if (pool && pool.byPlayer) msg.by = { ...pool.byPlayer };
} else {
msg.leaks = p.leaks;
}
this.send(msg);
}
}
/**
* Attach SimClients to a harness match (test/match/harness.js makeMatch): every human gets one; frames sent to a
* disconnected / departed human are dropped (like the platform). Returns Map playerId → SimClient.
*/
export function attachSimClients(h, opts = {}) {
const clients = new Map();
const m = h.m;
for (const ps of m.players.values()) {
if (ps.isBot) continue;
const c = new SimClient(ps.playerId, {
send: (msg) => { const r = m.handle(ps.playerId, msg); if (r && r.error && opts.onError) opts.onError(ps.playerId, msg, r); },
setTimeout: (fn, ms) => h.sched.setTimeout(fn, ms),
clearTimeout: (t) => h.sched.clearTimeout(t),
ds: m.ds,
BattleClass: opts.BattleClass,
speed: m.gameSpeed,
...(opts.perPlayer && opts.perPlayer[ps.playerId] ? opts.perPlayer[ps.playerId] : {}),
pace: (opts.perPlayer && opts.perPlayer[ps.playerId] && opts.perPlayer[ps.playerId].pace) || opts.pace || 'instant',
});
clients.set(ps.playerId, c);
}
const HANDLED = new Set(['b.start', 'b.pool', 'b.end']);
const pending = new Set();
const deliver = (c, ps, msg) => {
// delivered after the server call that produced it (a real socket is never re-entrant)
const t = h.sched.setTimeout(() => { pending.delete(t); if (ps.connected && !ps.left) c.onMessage(msg); }, 0);
pending.add(t);
};
h.onSend.push((pid, msg) => {
const c = clients.get(pid);
const ps = m.players.get(pid);
if (!c || !ps || !ps.connected || ps.left || !HANDLED.has(msg.t)) return;
deliver(c, ps, msg);
});
h.onBroadcast.push((msg) => {
if (!HANDLED.has(msg.t)) return;
for (const [pid, c] of clients) {
const ps = m.players.get(pid);
if (!ps || !ps.connected || ps.left) continue;
deliver(c, ps, msg);
}
});
/** Close every client (pending deliveries and battle timers are dropped). */
clients.closeAll = () => {
for (const t of pending) h.sched.clearTimeout(t);
pending.clear();
for (const c of clients.values()) c.close();
};
return clients;
}
/**
* A SimClient behind a websocket test client (test/helpers/wsClient.js TestClient): frames are read from the socket,
* reports go out without a rid (fire-and-forget, like the browser runner). Real timers.
* @returns {SimClient}
*/
export function attachWsSimClient(tc, opts = {}) {
const sc = new SimClient(tc.id, {
send: (msg) => { if (tc.isOpen) tc.send({ ...msg, rid: null }); },
setTimeout: (fn, ms) => setTimeout(fn, ms),
clearTimeout: (t) => clearTimeout(t),
...opts,
});
tc.ws.on('message', (data, isBinary) => {
if (isBinary) return;
let msg;
try { msg = JSON.parse(String(data)); } catch { return; }
try { sc.onMessage(msg); } catch (e) { sc.error = e; }
});
return sc;
}
+55
View File
@@ -0,0 +1,55 @@
// The HTTP server serves the battle simulation to browsers (DESIGN §14): /sim/ = server/sim read-only (`.js` only, no
// listings, the Node-only loader excluded), /data.js = the generated browser stand-in of server/data.js, never any other
// server file. The browser-side data injection (simdata.js setSimData / getSimData) is exercised in Node.
import { test, after } from 'node:test';
import assert from 'node:assert/strict';
import { startServer, DATA_SHIM_JS } from '../../server/index.js';
import * as simdata from '../../server/sim/simdata.js';
let srv = null;
after(async () => { if (srv) await srv.close(); });
test('/sim/ serves the simulation modules read-only; nothing else of server/ is reachable', async () => {
srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
const get = (p, headers = {}) => fetch(srv.url + p, { redirect: 'manual', headers });
for (const p of ['/sim/spec.js', '/sim/Battle.js', '/sim/simdata.js', '/sim/constants.js', '/sim/content/index.js', '/sim/content/kits/tier1.js', '/sim/content/support/index.js']) {
const r = await get(p);
assert.equal(r.status, 200, p);
assert.match(r.headers.get('content-type'), /^text\/javascript/, p);
assert.equal(r.headers.get('cache-control'), 'no-cache', `${p} revalidates (ETag)`);
assert.ok(r.headers.get('etag'));
const body = await r.text();
assert.ok(!/from ['"]node:/.test(body), `${p}: no static node: import`);
}
const gz = await get('/sim/Battle.js', { 'accept-encoding': 'gzip' });
assert.equal(gz.status, 200);
// the Node-only loader stays private under any spelling (case-insensitive host file systems: macOS, Windows)
for (const p of ['/sim/nodeData.js', '/sim/NODEDATA.JS', '/sim/NodeData.js', '/sim/nodedata.js', '/sim/', '/sim', '/sim/content/', '/sim/content', '/sim/../index.js', '/sim/%2e%2e/index.js', '/sim/..%2fdata.js',
'/server/index.js', '/server/data.js', '/server/match/Match.js', '/sim/.hidden.js', '/sim/constants.json', '/sim/spec.js/']) {
const r = await get(p);
assert.ok(r.status === 404 || r.status === 403 || r.status === 400, `${p} → ${r.status}`);
}
const shim = await get('/data.js');
assert.equal(shim.status, 200);
assert.match(shim.headers.get('content-type'), /^text\/javascript/);
assert.equal(await shim.text(), DATA_SHIM_JS);
assert.match(DATA_SHIM_JS, /from '\.\/sim\/simdata\.js'/, 'the stand-in reads the data injected into the sim');
const again = await get('/data.js', { 'if-none-match': shim.headers.get('etag') });
assert.equal(again.status, 304);
});
test('simdata: setSimData injects the default source (browser path) and getSimData serves it', async () => {
const before = simdata.getSimData();
try {
const raw = { chess: { chess_x_a: { chessId: 'chess_x_a', name: 'X', tier: 1, stats: { maxHp: 1234, atk: 5 } } }, enemies: {}, tokens: {}, stages: {}, waves: {} };
const ds = simdata.setSimData(raw);
assert.equal(simdata.getSimData(), raw);
assert.equal(simdata.getDefaultSource(), ds);
assert.equal(ds.getChess('chess_x_a').stats.maxHp, 1234);
assert.equal(ds.getChess('chess_char_1_01_a'), null, 'no research fallback once data is injected (both sides resolve ids alike)');
assert.equal(simdata.hasGeneratedData(), true);
} finally {
simdata.setSimData(before);
await simdata.reloadDataSources();
}
});
+74
View File
@@ -0,0 +1,74 @@
// Stage selection per match (waves.js setupMatchWaves; user playtest: "三局都是同一张地图"). Official mode data
// (data/config.json modes[].stages, stage weight 50 each):
// * 标准模拟 (FUNNY, solo & co-op) and 入门协议 use ONLY 战场#01 (act1autochess_m01) — official, kept: the lobby's default
// difficulty is 标准, so back-to-back 标准 matches always play 战场#01;
// * 险境模拟 (NORMAL) picks uniformly among 8 stages (战场#01–#08), 绝境 / 终极 (HARD / ABYSS) among 7 (no 战场#01),
// with the match seed (Match: createRng(deriveSeed(seed, 'setup')), a fresh random seed per lobby match).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { GameData } from '../../server/match/gamedata.js';
import { setupMatchWaves } from '../../server/match/waves.js';
import { createRng, deriveSeed } from '../../server/sim/rng.js';
import { DATA, makeMatch } from './harness.js';
const M01 = 'act1autochess_m01';
const REST = ['act1autochess_m02', 'act1autochess_m03', 'act1autochess_m04', 'act2autochess_m01', 'act2autochess_m02', 'act2autochess_m03', 'act2autochess_m04'];
const pick = (modeId, seed) => setupMatchWaves(new GameData(DATA, modeId), createRng(deriveSeed(seed >>> 0, 'setup'))).stageId;
test('mode stage lists: 标准 / 入门 = 战场#01 only; 险境 = 8 stages; 绝境 / 终极 = 7 (no 战场#01); all weight 50', () => {
const stagesOf = (m) => DATA.config.modes[m].stages.slice().sort();
for (const t of ['single', 'multi']) {
assert.deepEqual(stagesOf(`mode_${t}_funny`), [M01], `${t} 标准`);
assert.deepEqual(stagesOf(`mode_${t}_normal`), [M01, ...REST].sort(), `${t} 险境`);
assert.deepEqual(stagesOf(`mode_${t}_hard`), REST.slice().sort(), `${t} 绝境`);
assert.deepEqual(stagesOf(`mode_${t}_abyss`), REST.slice().sort(), `${t} 终极`);
}
assert.deepEqual(stagesOf('mode_training_1'), [M01]);
for (const id of [M01, ...REST]) assert.ok(DATA.stages[id].active && DATA.stages[id].weight === 50, id);
});
test('险境 / 绝境 / 终极: the match seed picks uniformly among the mode stages; 标准 always 战场#01', () => {
const N = 1600;
for (const t of ['single', 'multi']) {
for (const d of ['normal', 'hard', 'abyss']) {
const modeId = `mode_${t}_${d}`;
const list = DATA.config.modes[modeId].stages;
const count = new Map(list.map((s) => [s, 0]));
for (let i = 0; i < N; i++) {
const s = pick(modeId, Math.imul(i + 1, 2654435761));
assert.ok(count.has(s), `${modeId}: ${s}`);
count.set(s, count.get(s) + 1);
}
const exp = N / list.length;
for (const [s, n] of count) assert.ok(n > exp * 0.75 && n < exp * 1.25, `${modeId} ${s}: ${n} of ${N} (uniform ≈ ${exp.toFixed(0)})`);
}
for (let i = 0; i < 50; i++) assert.equal(pick(`mode_${t}_funny`, i * 7919 + 1), M01, `${t} 标准`);
}
});
test('different match seeds give different stages (no fixed map), the same seed the same stage', () => {
// consecutive lobby seeds are independent uint32 draws: 3 matches in a row on one stage is ~1/64 in 险境
let same3 = 0, runs = 0;
for (let i = 0; i < 300; i += 3, runs++) {
const a = pick('mode_multi_normal', i * 104729 + 17), b = pick('mode_multi_normal', (i + 1) * 104729 + 17), c = pick('mode_multi_normal', (i + 2) * 104729 + 17);
if (a === b && b === c) same3++;
}
assert.ok(same3 <= 6, `${same3} of ${runs} triples on one stage`);
const first10 = new Set(Array.from({ length: 10 }, (_, i) => pick('mode_single_hard', 1000 + i)));
assert.ok(first10.size >= 4, `${first10.size} stages over 10 seeds`);
assert.equal(pick('mode_multi_abyss', 424242), pick('mode_multi_abyss', 424242));
});
test('Match: the stage comes from the match seed (险境 co-op / 绝境 solo vary, 标准 fixed)', () => {
const seen = new Set();
for (let seed = 1; seed <= 12; seed++) {
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 1, bots: 1, seed, fake: true });
assert.equal(h.m.stageId, pick('mode_multi_normal', seed), `seed ${seed}`);
seen.add(h.m.stageId);
}
assert.ok(seen.size >= 4, `${seen.size} stages over 12 co-op 险境 matches`);
const solo = new Set();
for (let seed = 1; seed <= 12; seed++) solo.add(makeMatch({ mode: 'solo', difficulty: 'HARD', seed, fake: true }).m.stageId);
assert.ok(solo.size >= 4 && !solo.has(M01), `solo 绝境: ${[...solo].join(' ')}`);
for (let seed = 1; seed <= 6; seed++) assert.equal(makeMatch({ mode: 'solo', difficulty: 'FUNNY', seed, fake: true }).m.stageId, M01);
});
+154
View File
@@ -0,0 +1,154 @@
// A teammate's operator shows ITS owner's skill / module in the detail card (DESIGN §16), not the viewer's loadout nor
// the defaults: prep scouting (前往查看 → g.watch 'n:<pid>' → m.field prep: true, Match.prepFieldMeta) and the sim's
// UnitInfo in shared fields (联防 / 最终攻势 / an observed battle) carry `skillIndex` / `moduleId` per unit, and the
// game screen's detail card composes them (gameLogic unitLoadout → chessLoadout). Regression: the card showed the
// defaults for every teammate unit (m.field prep units had no loadout; the sim UnitInfo had no module).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { PHASE } from '../../shared/constants.js';
import { checkLoadout, loadoutOptions, MODULE_NONE } from '../../shared/protocol.js';
import { hasGeneratedData } from '../../server/sim/simdata.js';
import { makeBattle } from '../helpers/battleHarness.js';
import { unitLoadout, chessLoadout } from '../../public/js/ui/gameLogic.js';
import { DATA, makeMatch } from './harness.js';
const REAL = { skip: !hasGeneratedData() };
const chess = (id) => (Object.hasOwn(DATA.chess, id) ? DATA.chess[id] : null);
const INSIDE = 'chess_char_1_01_a'; // 隐现: S1 / S2 (default S2), elite module MAR-X (uniequip_002_inside) or none
const SWIRE = 'chess_char_3_04_a'; // 琳琅诗怀雅: several elite modules
const wire = (msg) => JSON.parse(JSON.stringify(msg)); // what the scout's browser receives (undefined dropped)
function place(ps, chessId, row, col) {
const p = ps.newPiece('chess', chessId);
ps.board.set(`${row},${col}`, p);
return p;
}
function clearBoard(ps) {
for (const p of [...ps.board.values()]) ps.returnCopies(p);
ps.board.clear();
}
test('prep scouting: m.field units carry the scouted player\'s skill / module (the viewer\'s loadout never leaks)', REAL, () => {
const sg = chess(chess(SWIRE).goldenId);
const alt = sg.modules.find((x) => !x.isDefault).uniEquipId;
const lo0 = checkLoadout({ [INSIDE]: { skill: 0, module: MODULE_NONE }, [SWIRE]: { skill: 0, module: alt } }, chess).loadout;
const lo1 = checkLoadout({ [INSIDE]: { skill: 0 } }, chess).loadout; // p_1: S1 for 隐现, modules on the defaults
const seats = [
{ seat: 0, playerId: 'p_0', name: 'P0', isBot: false, connected: true, loadout: lo0 },
{ seat: 1, playerId: 'p_1', name: 'P1', isBot: false, connected: true, loadout: lo1 },
];
const h = makeMatch({ mode: 'coop', seats, seed: 5 }).start();
const m = h.m;
h.toPrep(1);
const p0 = h.ps('p_0');
const p1 = h.ps('p_1');
clearBoard(p0);
clearBoard(p1);
const n = place(p0, INSIDE, 9, 3); // normal: S1, no module
const g = place(p0, chess(INSIDE).goldenId, 10, 3); // elite: S1 + 不装备
const s = place(p0, sg.chessId, 11, 3); // elite: S1 + the alternative module
const plain = Object.values(DATA.chess).find((c) => !c.isGolden && c.visible && !c.isHidden && !c.isDiy && c.chessId !== INSIDE && c.chessId !== SWIRE);
const pl = place(p0, plain.goldenId, 12, 3); // not in the loadout: defaults
const t = place(p1, chess(INSIDE).goldenId, 10, 4); // p_1's own elite 隐现: S1 + its default module
// p_1 scouts p_0 (前往查看 in prep)
assert.equal(m.phase, PHASE.PREP);
assert.deepEqual(m.handle('p_1', { t: 'g.watch', fieldId: 'n:p_0' }), { ok: true });
const meta = wire(h.lastTo('p_1', 'm.field'));
assert.equal(meta.prep, true);
const by = new Map(meta.units.map((u) => [u.uid, u]));
assert.equal(by.get(n.uid).skillIndex, 0);
assert.ok(!('moduleId' in by.get(n.uid)), 'normal chess have no module');
assert.deepEqual([by.get(g.uid).skillIndex, by.get(g.uid).moduleId], [0, MODULE_NONE]);
assert.deepEqual([by.get(s.uid).skillIndex, by.get(s.uid).moduleId], [0, alt]);
const pg = chess(plain.goldenId);
const pOpt = loadoutOptions(plain, pg);
assert.deepEqual([by.get(pl.uid).skillIndex, by.get(pl.uid).moduleId], [pOpt.defaultSkill, pOpt.defaultModule]);
// exactly what the scouted board fights with (PlayerBattleInput units)
for (const u of p0.battleInput().units) {
const v = by.get(u.uid);
assert.equal(v.skillIndex, u.skillIndex, `${u.chessId}: skill`);
assert.equal(v.moduleId ?? null, u.moduleId ?? null, `${u.chessId}: module`);
}
// the scout's detail card (game.js detailLoadout → unitLoadout → DetailPanel chessLoadout) shows p_0's choice
const card = (u) => chessLoadout(chess(u.defId), unitLoadout(chess(u.defId), u), chess);
assert.deepEqual([card(by.get(g.uid)).skillIndex, card(by.get(g.uid)).module.id], [0, MODULE_NONE]);
assert.equal(card(by.get(g.uid)).changed, true, 'shown as 已调配');
assert.deepEqual([card(by.get(s.uid)).skillIndex, card(by.get(s.uid)).module.id], [0, alt]);
assert.equal(card(by.get(n.uid)).skillIndex, 0);
assert.equal(card(by.get(pl.uid)).changed, false, 'defaults stay defaults');
// …and p_1's own loadout (隐现 elite on S1 with its default module) does not bleed into p_0's 隐现 cards
const own = chessLoadout(chess(g.id), p1.loadout, chess);
assert.equal(own.module.id, 'uniequip_002_inside');
assert.notEqual(card(by.get(g.uid)).module.id, own.module.id);
// the other way round: p_0 scouts p_1 → p_1's loadout, not p_0's 不装备
assert.deepEqual(m.handle('p_0', { t: 'g.watch', fieldId: 'n:p_1' }), { ok: true });
const u1 = wire(h.lastTo('p_0', 'm.field')).units.find((u) => u.uid === t.uid);
assert.deepEqual([u1.skillIndex, u1.moduleId], [0, 'uniequip_002_inside']);
assert.deepEqual([card(u1).skillIndex, card(u1).module.id], [0, 'uniequip_002_inside']);
m.dispose();
});
test('sim UnitInfo (shared fields): each helper\'s elite carries its own module; normal chess / enemies none', REAL, () => {
const G = chess(INSIDE).goldenId;
const h = makeBattle({
kind: 'unite',
players: [
{ playerId: 'A', seat: 0, side: 'L', colOffset: 0, units: [
{ uid: 11, kind: 'chess', chessId: G, row: 10, col: 4, skillIndex: 0, moduleId: MODULE_NONE },
{ uid: 12, kind: 'chess', chessId: INSIDE, row: 11, col: 4, skillIndex: 0 },
] },
{ playerId: 'B', seat: 1, side: 'L', colOffset: 8, units: [{ uid: 21, kind: 'chess', chessId: G, row: 10, col: 4 }] },
],
enemies: [], autoFinish: false, timeLimit: 10,
});
h.step();
const spawns = new Map(h.eventsOf('spawn').map((e) => [e[1].uid, wire(e[1])]));
const meta = new Map(wire(h.b.fieldMeta()).units.map((u) => [u.uid, u]));
for (const src of [spawns, meta]) {
assert.deepEqual([src.get(11).skillIndex, src.get(11).moduleId, src.get(11).ownerId], [0, MODULE_NONE, 'A']);
assert.deepEqual([src.get(21).skillIndex, src.get(21).moduleId, src.get(21).ownerId], [1, 'uniequip_002_inside', 'B'], 'B on the defaults');
assert.equal(src.get(12).skillIndex, 0);
assert.ok(!('moduleId' in src.get(12)), 'normal chess: no module');
}
// A viewer (B) opening A's elite sees S1 + 不装备
const lo = chessLoadout(chess(G), unitLoadout(chess(G), meta.get(11)), chess);
assert.deepEqual([lo.skillIndex, lo.module.id, lo.module.none], [0, MODULE_NONE, true]);
// enemies never carry a loadout
const en = h.spawn('enemy_1007_slime');
assert.ok(en);
h.step();
const ei = wire(h.eventsOf('spawn').map((e) => e[1]).find((u) => u.side === 'enemy'));
assert.ok(ei && !('moduleId' in ei) && !('skillIndex' in ei));
});
test('gameLogic unitLoadout: the unit\'s own skill / module keyed by the base chess id; nothing carried → defaults', () => {
const sk = (index, isDefault = false) => ({ index, skillId: `sk_${index}`, iconId: `sk_${index}`, name: `技能${index}`, isDefault });
const base = { chessId: 'c_a', baseId: 'c_a', goldenId: 'c_b', isGolden: false, visible: true, skill: sk(2, true), skills: [sk(0), sk(1), sk(2, true)] };
const gold = { chessId: 'c_b', baseId: 'c_a', isGolden: true, visible: true, skill: sk(2, true), skills: [sk(0), sk(1), sk(2, true)],
module: { id: 'uniequip_x', name: '模组X', type: 'XYZ-X', active: true },
modules: [{ uniEquipId: 'uniequip_x', name: '模组X', typeName: 'XYZ-X', isDefault: true }, { uniEquipId: 'uniequip_y', name: '模组Y', typeName: 'XYZ-Y' }] };
const get = (id) => ({ c_a: base, c_b: gold }[id] || null);
assert.deepEqual(unitLoadout(gold, { defId: 'c_b', skillIndex: 0, moduleId: 'uniequip_y' }), { c_a: { skill: 0, module: 'uniequip_y' } });
assert.deepEqual(unitLoadout(base, { defId: 'c_a', skillIndex: 1, moduleId: 'uniequip_y' }), { c_a: { skill: 1 } }, 'a normal chess has no module');
assert.equal(unitLoadout(base, { defId: 'c_a' }), null, 'nothing carried');
assert.equal(unitLoadout(base, { skillIndex: -1, moduleId: '' }), null, 'junk ignored');
assert.equal(unitLoadout(null, { skillIndex: 0 }), null);
assert.equal(unitLoadout(base, null), null);
const r = chessLoadout(gold, unitLoadout(gold, { skillIndex: 0, moduleId: 'uniequip_y' }), get);
assert.deepEqual([r.skill.skillId, r.module.id, r.changed], ['sk_0', 'uniequip_y', true]);
const d = chessLoadout(gold, unitLoadout(gold, { skillIndex: 2 }), get);
assert.deepEqual([d.skill.skillId, d.module.id, d.changed], ['sk_2', 'uniequip_x', false], 'no module carried → the default one');
});
test('game.js: a teammate unit\'s detail card uses the unit\'s own loadout (never the viewer\'s, never forced defaults)', () => {
const src = readFileSync(new URL('../../public/js/screens/game.js', import.meta.url), 'utf8');
const i = src.indexOf('const detailLoadout = (() => {');
assert.ok(i > 0, 'detailLoadout present');
const block = src.slice(i, src.indexOf('})();', i));
assert.match(block, /unitLoadout\(resolved\.chess, u\)/, 'teammate units → unitLoadout');
assert.match(block, /owner === myId\)\) return priv\?\.loadout/, 'own units → m.private.loadout');
});
+287
View File
@@ -0,0 +1,287 @@
// Adversarial cross-checks of waves.js against the official client algorithms (research 08 §2–§5):
// * an independent re-implementation of RandomEnemyGenerater (_DoReplaceActionDataClient / BE count rule) and of
// AutoChessEnemyPreviewManager's zone choice, run straight on the raw official files in .cache/gamedata, compared
// with buildNormalWave / buildBossWave for every mode × round × template (leader and hidden rounds included) ×
// allowed special entry — key, count, spawn time, unit step and preview zone of every action (skipped when the
// cache is absent);
// * preview zones of leader rounds (row offset 13: the leaders standing at (3,x)/(4,x) are in the LOWER zone);
// * 联防 timing decoded from the client (`_CalculateActionPredelayConsiderUid`): owner k at preDelay + 0.5·k (no cap),
// unit step min(max(W/M, 0.05·W), 5) with M = the largest owner group; host action = the first placeholder action of
// the class, else action 0 (`_GetSpEnemyActionData`).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, existsSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { GameData } from '../../server/match/gamedata.js';
import { buildNormalWave, buildBossWave, buildUniteWave, gateOf, previewOf, bountySpawns, withBounties } from '../../server/match/waves.js';
import { createRng } from '../../server/sim/rng.js';
import { DATA } from './harness.js';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
const CACHE = join(ROOT, '.cache', 'gamedata');
const HAS_CACHE = ['excel/activity_table.json', 'levels/enemydata/enemy_database.json', 'levels/activities/act1autochess/level_act1autochess_01.json']
.every((rel) => existsSync(join(CACHE, rel)));
const raw = (rel) => JSON.parse(readFileSync(join(CACHE, rel), 'utf8'));
const TS = DATA.factions.templateSlots;
/** Forced pick of a special entry for round r. */
function pickOf(e, round) {
return { round, type: e.type, key: e.key, normal: e.N[0].key, elite: e.E[0].key, fly: e.fly, firstHalf: e.firstHalf };
}
test('independent official generator on the raw client data: every mode × round × template × entry (keys, counts, times, steps, preview zones)', { skip: !HAS_CACHE && 'no .cache/gamedata' }, () => {
const act = raw('excel/activity_table.json');
const ac = act.autoChessData;
const season = act.activity.AUTOCHESS_SEASON.act2autochess;
const cd = ac.constData;
const attr = ac.randomEnemyAttributeDict;
const db = new Map(raw('levels/enemydata/enemy_database.json').enemies.map((e) => [e.Key, e.Value]));
const f32 = Math.fround;
const stat = (key, level) => {
const lv = new Map(db.get(key).map((v) => [v.level, v.enemyData]));
const base = lv.get(0).attributes;
const ov = level ? (lv.get(level)?.attributes || {}) : {};
const g = (n) => (ov[n] && ov[n].m_defined ? ov[n].m_value : base[n].m_value);
return [g('maxHp'), g('atk'), g('def'), g('magicResistance')];
};
const power = (k) => {
const [hp, atk, df, res] = stat(k, attr[k].level);
const p = f32(f32(f32(atk * cd.enemyAtkFactor) + f32(hp * cd.enemyMaxHpFactor)) + f32(df * cd.enemyDefFactor));
return f32(p + f32(cd.enemyMagicResistanceFactor * res));
};
const bankers = (x) => { const fl = Math.floor(x); return x - fl === 0.5 ? (fl % 2 === 0 ? fl : fl + 1) : Math.round(x); };
const newCount = (n, t, k) => {
const origin = f32(f32(n * power(t)) / attr[t].enemyBattleEffectivenessFactor);
const ratio = f32(origin / f32(power(k) / attr[k].enemyBattleEffectivenessFactor));
return Math.max(cd.minReplacedEnemyCount, Math.min(cd.maxReplacedEnemyCount, bankers(ratio)));
};
const TPL = {
[cd.templateEnemyNormal]: ['N', false], [cd.templateEnemyElite]: ['E', false], [cd.templateEnemySpecial]: ['S', false],
[cd.templateEnemyNormalFly]: ['N', true], [cd.templateEnemyEliteFly]: ['E', true], [cd.templateEnemySpecialFly]: ['S', true],
};
// preview pen: enemy_place_rect anchors (15,7) / (18,7); eligible anchors have row ≤ target row; nearest wins
const zone = (start, leader) => {
const t = start.row + (leader ? 13 : 6);
const c = [[15, 'lower'], [18, 'upper']].filter(([r]) => r <= t).sort((a, b) => (Math.abs(a[0] - t) + Math.abs(7 - start.col)) - (Math.abs(b[0] - t) + Math.abs(7 - start.col)));
return c.length ? c[0][1] : 'lower';
};
const levels = new Map();
const level = (levelId) => {
const p = levelId.replace(/^Activities\//i, '').toLowerCase();
if (!levels.has(p)) levels.set(p, raw(`levels/activities/${p}.json`));
return levels.get(p);
};
const compose = (lv, e, leader) => {
const out = [];
lv.waves[0].fragments[0].actions.forEach((a, ai) => {
if (a.actionType !== 'SPAWN') return;
let k = a.key, n = a.count;
const win = a.count * a.interval;
if (TPL[k]) {
const [cls, fly] = TPL[k];
const nk = { N: e.attachedNormalEnemyKeys[0], E: e.attachedEliteEnemyKeys[0], S: e.specialEnemyKey }[cls];
if (attr[nk].isFlyEnemy !== fly) return; // isValid = false
n = newCount(a.count, k, nk);
k = nk;
}
const step = n > 1 ? Math.max(win / n, 0.05 * win) : 0;
out.push(`${ai}:${k}x${n}@${a.preDelay}/${step.toFixed(6)}:${zone(lv.routes[a.routeIndex].startPosition, leader)}`);
});
return out.join(' ');
};
let n = 0;
let leaderChecks = 0;
for (const [modeId, rounds] of Object.entries(season.battleDataDict)) {
if (modeId === 'mode_training_1') continue;
const gd = new GameData(DATA, modeId);
const inactive = new Set(season.modeDataDict[modeId].inactiveEnemyKey || []);
for (const [r, list] of Object.entries(rounds)) {
const round = Number(r);
const half = round <= Math.floor(cd.maxLevelCnt / 2);
for (const entry of list) {
const lv = level(entry.levelId);
const templateId = entry.levelId.replace(/^.*level_/i, '').toLowerCase();
for (const e of Object.values(season.specialEnemyInfoDict)) {
if (e.isInFirstHalf !== half || inactive.has(e.specialEnemyKey)) continue;
const picks = [];
picks[round] = pickOf(DATA.factions.entries[e.specialEnemyKey], round);
const w = entry.bossId
? buildBossWave(gd, createRng(1), { picks }, round, { bossId: entry.bossId, solo: /_s$/.test(templateId) })
: buildNormalWave(gd, createRng(1), { picks }, round);
assert.equal(w.templateId, templateId, `${modeId} R${r} ${entry.bossId ?? ''}`);
const got = w.spawns.map((s) => `${s.actionIndex}:${s.enemyKey}x${s.count}@${s.time}/${(+s.interval).toFixed(6)}:${s.preview.gate}`).join(' ');
assert.equal(got, compose(lv, e, !!entry.bossId), `${modeId} R${r} ${templateId} ${e.specialEnemyKey}`);
n++;
if (entry.bossId) leaderChecks++;
}
}
}
}
assert.ok(n > 5000 && leaderChecks > 1000, `${n} compositions (${leaderChecks} leader rounds)`);
});
test('preview zones: target row = start row + 6 (leader rounds + 13), upper zone only from target row 18', () => {
// normal fields: only the (12,10) gate reaches the upper zone (a row-11 start would target row 17: lower)
for (const [row, want] of [[9, 'lower'], [10, 'lower'], [11, 'lower'], [12, 'upper']]) assert.equal(gateOf([row, 10]), want, `normal row ${row}`);
// leader / hidden rounds: (5,10) upper; the (2,10) gate and the leaders at (3,x) / (4,x) lower
for (const [row, want] of [[2, 'lower'], [3, 'lower'], [4, 'lower'], [5, 'upper']]) {
assert.equal(gateOf([row, 10], true), want, `leader row ${row}`);
assert.equal(gateOf([row, 10]), want, `leader row ${row} (inferred from the boss-field row)`);
}
assert.equal(gateOf(null), 'lower');
// real leader templates: 猎手 (h08_02, hidden, route (4,10)) and the h07_02 leader (4,10) sit in the lower zone,
// the (5,10) escorts in the upper one
const gd = new GameData(DATA, 'mode_multi_hard');
const picks = [];
const e = DATA.factions.entries.enemy_1005_yokai_3;
picks[14] = pickOf(e, 14);
picks[15] = pickOf(e, 15);
const hidden = buildBossWave(gd, createRng(1), { picks }, 15, { bossId: 'boss_9', solo: false });
assert.equal(hidden.templateId, 'act1autochess_h08_02');
const boss = hidden.spawns.find((s) => s.tag === 'boss');
assert.deepEqual(DATA.waves.act1autochess_h08_02.routes[boss.routeIndex].start, [4, 10]);
assert.equal(boss.preview.gate, 'lower');
assert.ok(hidden.spawns.filter((s) => s.tag !== 'boss' && s.tag !== 'part').every((s) => s.preview.gate === 'upper'), 'escorts from (5,10): upper');
const pv = previewOf(hidden.spawns);
assert.equal(pv.find((x) => x.boss).gate, 'lower');
// a bounty in a leader round follows the same leader offset
const card = { enemyKey: 'enemy_1007_slime', count: 1, coin: 1, payout: 'kill' };
const b = bountySpawns(gd, 15, hidden, [{ id: 'x', card }], 'p', { side: 'L' });
const bRoute = hidden.routes[b[0].routeIndex];
assert.equal(b[0].preview.gate, gateOf(bRoute.start, true));
});
const WALK = 'enemy_1422_lrsldr';
const leak = (owner, key = WALK, extra = {}) => ({ enemyKey: key, mods: null, sourcePlayerId: owner, ...extra });
test('联防 timing (decoded _CalculateActionPredelayConsiderUid): step = min(max(W/M, 0.05·W), 5 s), owner k at +0.5·k', () => {
const gd = new GameData(DATA, 'mode_multi_normal');
const tpl = DATA.waves.act1autochess_escaped_single;
const host = tpl.spawns.find((s) => s.key === TS.N); // lrsldr 5 @ 3 s / 8 s ⇒ W = 40
const W = host.count * host.interval;
const times = (w, owner) => w.spawns.filter((s) => s.sourcePlayerId === owner).map((s) => s.time);
// M = 2 ⇒ 40/2 = 20 s, capped at 5 s (the old code spread a lone owner's 2 leaks 20 s apart)
let w = buildUniteWave(gd, [leak('a'), leak('b'), leak('a')], 1, 60);
assert.deepEqual(times(w, 'a'), [host.time, host.time + 5]);
assert.deepEqual(times(w, 'b'), [host.time + 0.5]);
// the step uses the LARGEST owner group for every owner of the action: a 12, b 2 ⇒ 40/12 for both
const many = [];
for (let i = 0; i < 12; i++) many.push(leak('a'));
many.push(leak('b'), leak('b'));
w = buildUniteWave(gd, many, 1, 60);
const step = Math.min(Math.max(W / 12, 0.05 * W), 5);
assert.ok(times(w, 'a').every((t, i) => Math.abs(t - (host.time + i * step)) < 1e-9));
assert.ok(times(w, 'b').every((t, i) => Math.abs(t - (host.time + 0.5 + i * step)) < 1e-9), 'b steps by 40/12, not 40/2 capped');
// 25 leaks of one owner: the 0.05·W floor (2 s)
w = buildUniteWave(gd, Array.from({ length: 25 }, () => leak('a')), 1, 60);
assert.equal(times(w, 'a')[24], host.time + 24 * 2);
// owner offsets are not capped: the 12th owner starts 5.5 s after the action
w = buildUniteWave(gd, Array.from({ length: 12 }, (_, i) => leak(`o${i}`)), 1, 60);
assert.equal(times(w, 'o11')[0], host.time + 5.5);
assert.ok(w.spawns.every((s) => s.actionIndex === tpl.spawns.indexOf(host) && s.routeIndex === host.routeIndex));
// flyers and walkers are separate actions (each with its own M and owner order)
w = buildUniteWave(gd, [leak('a', 'enemy_1005_yokai'), leak('b'), leak('b')], 1, 60);
const fh = tpl.spawns.find((s) => s.key === TS.NF);
assert.deepEqual(times(w, 'a'), [fh.time], 'owner a is the first owner of the flyer action');
assert.deepEqual(times(w, 'b'), [host.time, host.time + 5], 'owner b is the first owner of the walker action');
});
test('联防 host action: the first action of the class placeholder, else action 0 (client _GetSpEnemyActionData)', () => {
const tpl = DATA.waves.act1autochess_escaped_single;
const noWalk = { ...tpl, spawns: tpl.spawns.filter((s) => s.key !== TS.N) };
const data = { ...DATA, waves: { ...DATA.waves, act1autochess_escaped_single: noWalk } };
const gd = new GameData(data, 'mode_multi_normal');
const w = buildUniteWave(gd, [leak('a'), leak('a')], 1, 60);
const a0 = noWalk.spawns[0];
assert.ok(w.spawns.every((s) => s.actionIndex === 0 && s.routeIndex === a0.routeIndex));
const W = a0.count * a0.interval;
assert.deepEqual(w.spawns.map((s) => s.time), [a0.time, a0.time + Math.min(Math.max(W / 2, 0.05 * W), 5)]);
});
test('bounty units are inserted among the host action\'s own units (decoded _InsertSpActionToNormal), then spread', () => {
const gd = new GameData(DATA, 'mode_multi_normal');
const picks = [];
picks[1] = pickOf(DATA.factions.entries.enemy_1325_cbgpro_2, 1); // R1: N 2 @ 12 s / 3 s → 猎狗pro ×4, W = 6
const w = buildNormalWave(gd, createRng(1), { picks }, 1);
const hostIdx = DATA.waves.act1autochess_01.spawns.findIndex((s) => s.key === TS.N);
assert.equal(w.spawns.find((s) => s.actionIndex === hostIdx).count, 4);
const card = (count = 1) => ({ enemyKey: 'enemy_1007_slime', count, coin: 1, payout: 'kill' });
const units = (list) => {
const out = [];
for (const s of list) for (let i = 0; i < s.count; i++) out.push(`${s.tag === 'bounty' ? s.mods.bountyId : 'own'}@${(s.time + i * s.interval).toFixed(3)}`);
return out.sort((a, b) => Number(a.split('@')[1]) - Number(b.split('@')[1]));
};
const hostOf = (list) => list.filter((s) => s.actionIndex === hostIdx);
// 1 bounty: [o o B o o], step 6/5
assert.deepEqual(units(hostOf(withBounties(gd, 1, w, [{ id: 'B', card: card() }], 'p'))),
['own@12.000', 'own@13.200', 'B@14.400', 'own@15.600', 'own@16.800']);
assert.equal(bountySpawns(gd, 1, w, [{ id: 'B', card: card() }], 'p')[0].time, 12 + 2 * 1.2);
// 2 bounties: i=0 at floor(1·4/3) = 1, i=1 at floor(2·5/3) = 3 → [o B0 o B1 o o], step 1
assert.deepEqual(units(hostOf(withBounties(gd, 1, w, [{ id: 'B0', card: card() }, { id: 'B1', card: card() }], 'p'))),
['own@12.000', 'B0@13.000', 'own@14.000', 'B1@15.000', 'own@16.000', 'own@17.000']);
// a 3-unit card is inserted unit by unit: [o C C o C o o] — two specs for the card (runs)
const three = withBounties(gd, 1, w, [{ id: 'C', card: card(3) }], 'p');
const step = 6 / 7;
assert.deepEqual(units(hostOf(three)), [0, 1, 2, 3, 4, 5, 6].map((j) => `${[1, 2, 4].includes(j) ? 'C' : 'own'}@${(12 + j * step).toFixed(3)}`));
assert.equal(three.filter((s) => s.tag === 'bounty').length, 2);
// FLY round: the walker host action spawns nothing → the bounty list alone over the window (one unit at preDelay)
const fp = [];
fp[1] = pickOf(DATA.factions.entries.enemy_1355_mrfly, 1);
const fw = buildNormalWave(gd, createRng(1), { picks: fp }, 1);
assert.ok(!fw.spawns.some((s) => s.actionIndex === hostIdx));
const fb = withBounties(gd, 1, fw, [{ id: 'B0', card: card() }, { id: 'B1', card: card() }], 'p').filter((s) => s.tag === 'bounty');
const hostRoute = DATA.waves.act1autochess_01.spawns[hostIdx].routeIndex;
assert.deepEqual(fb.map((s) => [s.time, s.count, s.routeIndex]), [[12, 1, hostRoute], [15, 1, hostRoute]], 'step 6/2 on the walker route');
});
test('a bounty whose host action is not server-sent (the leader) spawns alone over that window; the leader is never re-timed', () => {
const gd = new GameData(DATA, 'mode_multi_hard');
const picks = [];
picks[15] = pickOf(DATA.factions.entries.enemy_1006_shield_3, 15);
const w = buildBossWave(gd, createRng(1), { picks }, 15, { bossId: 'boss_9', solo: false });
const tpl = DATA.waves[w.templateId];
assert.equal(tpl.spawns[0].tag, 'boss', 'h08_02: action 0 is the leader (no lrsldr action ⇒ the host is action 0)');
assert.equal(w.actions.find((a) => a.index === 0).server, false);
const card = { enemyKey: 'enemy_1007_slime', count: 1, coin: 1, payout: 'kill' };
const all = withBounties(gd, 15, w, [{ id: 'B', card }], 'p', { side: 'L' });
const boss = w.spawns.find((s) => s.tag === 'boss');
assert.deepEqual(all.filter((s) => s.tag === 'boss').map((s) => [s.time, s.count]), [[boss.time, 1]], 'the leader keeps its own timing');
const b = all.find((s) => s.tag === 'bounty');
assert.equal(b.time, tpl.spawns[0].time, 'the bounty list alone: first unit at the action preDelay');
assert.equal(all.length, w.spawns.length + 1);
});
test('round multipliers hit every enemy but 炎佑 (aceffect_enemy_* enemy_exclude): leader ATK / speed, never its HP', () => {
const eff = DATA.effects;
// the data behind the PRTS table: 攻坚装备 ATK ×1.1 / HP ×1.2, III ATK ×0.7 / HP ×0.75, only 炎佑 excluded
// (补给线 also spares the TIMES tokens 瑞印 / 卷轴 / 镜 …, whose HP is a hit count — content's concern)
const leaders = Object.values(DATA.bosses).map((x) => x.enemyKey).filter(Boolean);
assert.ok(leaders.length >= 8);
for (const id of ['aceffect_enemy_1', 'aceffect_enemy_2', 'aceffect_enemy_3', 'aceffect_enemy_4', 'aceffect_enemy_5']) {
assert.equal(eff[id].effectType, 'ENEMY', id);
const ex = String(eff[id].params.enemy_exclude).split('|');
assert.ok(ex.includes('enemy_9012_acloon'), `${id}: 炎佑 excluded`);
assert.ok(!leaders.some((k) => ex.includes(k)), `${id}: no leader excluded`);
}
const gd = new GameData(DATA, 'mode_multi_abyss');
const scale = gd.enemyScale(15);
assert.ok(scale.atkMul > 2 && scale.speedMul > 1, 'sanity: 终极 hidden round multipliers');
const picks = [];
picks[15] = pickOf(DATA.factions.entries.enemy_1006_shield_3, 15);
const w = buildBossWave(gd, createRng(1), { picks }, 15, { bossId: 'boss_9', solo: false });
const boss = w.spawns.find((s) => s.tag === 'boss');
assert.equal(boss.mods.hpMul, undefined, 'the leader HP is the server pool');
assert.equal(boss.mods.atkMul, scale.atkMul);
assert.equal(boss.mods.speedMul, scale.speedMul);
const parts = w.spawns.filter((s) => s.tag === 'part');
assert.ok(parts.length >= 3);
for (const p of parts) assert.deepEqual([p.mods.hpMul, p.mods.atkMul, p.mods.speedMul], [scale.hpMul, scale.atkMul, scale.speedMul], p.enemyKey);
// solo 标准: the 攻坚装备III base (ATK ×0.7) weakens the leader too
const sgd = new GameData(DATA, 'mode_single_funny');
const sp = [];
sp[9] = pickOf(DATA.factions.entries.enemy_1006_shield_3, 9);
const sw = buildBossWave(sgd, createRng(1), { picks: sp }, 9, { bossId: 'boss_1', solo: true });
assert.equal(sw.spawns.find((s) => s.tag === 'boss').mods.atkMul, sgd.enemyScale(9).atkMul);
assert.ok(sgd.enemyScale(9).atkMul < 1);
});
+95
View File
@@ -0,0 +1,95 @@
// Official enemy counts (research 08 §2 / §7-1): the client's RandomEnemyGenerater reproduced exactly — fixtures from
// the official PRTS screenshots and from the research script over every special entry × round.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { GameData } from '../../server/match/gamedata.js';
import { setupMatchWaves, buildNormalWave, replacedCount, roundHalfEven, previewOf } from '../../server/match/waves.js';
import { createRng } from '../../server/sim/rng.js';
import { DATA } from './harness.js';
const OFFICIAL = JSON.parse(readFileSync(new URL('../fixtures/official-waves.json', import.meta.url), 'utf8')).entries;
const name = (k) => DATA.enemies[k].name;
/** The round's wave with a forced pick (entry key, round). */
function waveOf(gd, entryKey, round) {
const e = DATA.factions.entries[entryKey];
const picks = [];
picks[round] = { round, type: e.type, key: e.key, normal: e.N[0].key, elite: e.E[0].key, fly: e.fly, firstHalf: e.firstHalf };
return buildNormalWave(gd, createRng(1), { picks }, round);
}
const byName = (w) => {
const m = {};
for (const s of w.spawns) m[name(s.enemyKey)] = (m[name(s.enemyKey)] || 0) + s.count;
return m;
};
test('roundHalfEven is C# Math.Round (banker\'s)', () => {
assert.deepEqual([0.5, 1.5, 2.5, 3.5, 4.5, 4.49, 4.51, 4.33, 9.1].map(roundHalfEven), [0, 2, 2, 4, 4, 4, 5, 4, 9]);
});
test('R1 fixtures of the official screenshots (research 08 §2.6): one kind per round, no drones in a ground round', () => {
const gd = new GameData(DATA, 'mode_multi_normal');
assert.deepEqual(byName(waveOf(gd, 'enemy_1325_cbgpro_2', 1)), { 源石虫: 2, 猎狗pro: 4 }, '战场02/07: 高级军用猎狗');
assert.deepEqual(byName(waveOf(gd, 'enemy_1305_mhslim_2', 1)), { 源石虫: 2, 灼热源石虫: 5 }, '战场04: 炽焰源石虫 (9.1 → cap 5)');
assert.deepEqual(byName(waveOf(gd, 'enemy_1010_demon_2', 1)), { 源石虫: 2, 萨卡兹刀兵: 3 }, '萨卡兹大剑组长 (2.84 → 3)');
// a FLY round: the walking N action is dropped, the fly placeholders replaced
const r3 = waveOf(gd, 'enemy_1355_mrfly', 3);
assert.deepEqual(byName(r3), { 妖怪: 5, 妖怪MKII: 4 }, 'R3 FLY 护障');
assert.ok(r3.spawns.every((s) => DATA.enemies[s.enemyKey].isFlyEnemy), 'no walkers in a FLY round');
const r1fly = waveOf(gd, 'enemy_1355_mrfly', 1);
assert.ok(r1fly.spawns.some((s) => s.enemyKey === 'enemy_1007_slime') && !r1fly.spawns.some((s) => s.enemyKey === 'enemy_1422_lrsldr'), '战场01: 源石虫 + drones, no ground N');
});
test('the .5 tie: an elite action of 2 × “灵幛” becomes 隐形弩手组长 ×4 (4.5 → 4, banker\'s rounding)', () => {
const gd = new GameData(DATA, 'mode_multi_normal');
assert.equal(replacedCount(gd, 'enemy_1427_lrnazg', 2, 'enemy_1019_jshoot_2'), 4);
assert.equal(replacedCount(gd, 'enemy_1427_lrnazg', 1, 'enemy_1019_jshoot_2'), 2);
assert.equal(replacedCount(gd, 'enemy_1422_lrsldr', 10, 'enemy_1005_yokai'), 5, 'cap 5 per action');
assert.equal(replacedCount(gd, 'enemy_1425_lrcmra', 1, 'enemy_1439_dslntf'), 1, 'floor 1');
});
test('every special entry × round matches the official per-action composition (429 fixtures: kind, count, gate)', () => {
const gd = new GameData(DATA, 'mode_multi_abyss');
let n = 0;
for (const [short, rounds] of Object.entries(OFFICIAL)) {
for (const [r, want] of Object.entries(rounds)) {
const w = waveOf(gd, `enemy_${short}`, Number(r));
const got = w.spawns.map((s) => `${s.actionIndex}:${s.enemyKey.replace('enemy_', '')}:${s.count}:${s.preview.gate === 'upper' ? 'U' : 'L'}`).join(' ');
assert.equal(got, want, `${short} R${r}`);
n++;
}
}
assert.equal(n, 429);
});
test('同盟 险境 R3 never exceeds 10 enemies per board (official 6–10; ours used to reach 68)', () => {
const gd = new GameData(DATA, 'mode_multi_normal');
for (const e of Object.values(DATA.factions.entries)) {
if (!e.firstHalf || gd.inactiveEnemies.has(e.key)) continue;
const total = waveOf(gd, e.key, 3).spawns.reduce((s, x) => s + x.count, 0);
assert.ok(total >= 6 && total <= 10, `${e.key}: R3 ${total}`);
}
});
test('distribution over matches ≈ the official one (research 08 §2.7, 4 000 simulated official matches)', () => {
const gd = new GameData(DATA, 'mode_multi_normal');
const official = [0, 4.7, 7.7, 7.7, 10.3, 20.0, 23.2, 26.2, 18.2, 20.3, 22.2, 23.6, 35.0, 37.5];
const sums = new Array(14).fill(0);
const N = 300;
let flyRounds = 0;
for (let seed = 1; seed <= N; seed++) {
const setup = setupMatchWaves(gd, createRng(seed * 7919));
for (let r = 1; r <= 13; r++) {
const w = buildNormalWave(gd, createRng(seed), setup.factions, r);
sums[r] += previewOf(w.spawns).reduce((s, e) => s + e.count, 0);
if (w.pick.fly) flyRounds++;
}
}
for (let r = 1; r <= 13; r++) {
const avg = sums[r] / N;
assert.ok(Math.abs(avg - official[r]) <= official[r] * 0.12, `R${r}: avg ${avg.toFixed(1)} vs official ${official[r]}`);
}
const share = flyRounds / (N * 13);
assert.ok(share > 0.06 && share < 0.14, `FLY rounds ${share.toFixed(3)} ≈ 10 %`);
});
+298
View File
@@ -0,0 +1,298 @@
// waves.js: per-match setup (official type schedule + picks), per-action replacement, scaling, bounties, 联防 routing,
// boss templates, preview. The official-number fixtures live in waves-official.test.js.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { GameData } from '../../server/match/gamedata.js';
import {
setupMatchWaves, buildNormalWave, buildBossWave, bountySpawns, withBounties, buildUniteWave, previewOf, roundPick, gateOf,
isFlyKey,
} from '../../server/match/waves.js';
import { createRng } from '../../server/sim/rng.js';
import { DATA } from './harness.js';
const TS = DATA.factions.templateSlots;
const PLACEHOLDERS = new Set([TS.N, TS.E, TS.S, TS.NF, TS.EF, TS.SF]);
const fly = (k) => DATA.enemies[k].isFlyEnemy;
test('match setup: stage allowed by the mode, 3 distinct random factions, weighted boss, hidden boss only with a hidden round', () => {
for (const modeId of ['mode_single_funny', 'mode_multi_funny', 'mode_multi_normal', 'mode_single_hard', 'mode_multi_abyss']) {
const gd = new GameData(DATA, modeId);
const bosses = new Set();
for (let seed = 1; seed <= 30; seed++) {
const s = setupMatchWaves(gd, createRng(seed));
assert.ok(gd.mode.stages.includes(s.stageId), `${modeId} stage ${s.stageId}`);
assert.ok(DATA.stages[s.stageId].active && DATA.stages[s.stageId].weight > 0);
assert.equal(new Set(s.factions).size, 3);
for (const f of s.factions) assert.ok(DATA.factions.types[f].involveRandom, f);
assert.ok(gd.mode.bossWeights[s.bossId] > 0);
bosses.add(s.bossId);
if (gd.hiddenRound) assert.ok(['boss_8', 'boss_9', 'boss_10'].includes(s.hiddenBossId));
else assert.equal(s.hiddenBossId, null);
}
assert.ok(bosses.size >= 3, 'bosses vary with the seed');
}
const a = setupMatchWaves(new GameData(DATA, 'mode_multi_hard'), createRng(5));
const b = setupMatchWaves(new GameData(DATA, 'mode_multi_hard'), createRng(5));
assert.deepEqual(a, b, 'deterministic');
});
test('type schedule: each chosen type owns exactly 3 of the 15 round slots, SPECIAL the other 6, shuffled; the order stays hidden', () => {
const gd = new GameData(DATA, 'mode_multi_normal');
const firstSlot = new Map();
for (let seed = 1; seed <= 60; seed++) {
const s = setupMatchWaves(gd, createRng(seed));
assert.equal(s.typeSlots.length, 15);
const count = (t) => s.typeSlots.filter((x) => x === t).length;
for (const f of s.factions) assert.equal(count(f), 3, `seed ${seed}: ${f}`);
assert.equal(count('SPECIAL'), 6);
firstSlot.set(s.typeSlots[0], (firstSlot.get(s.typeSlots[0]) || 0) + 1);
// the schedule rides on the factions array without leaking into the public view
assert.deepEqual(s.factions.schedule.typeSlots, s.typeSlots);
assert.equal(JSON.stringify(s.factions), JSON.stringify([...s.factions]));
assert.deepEqual(Object.keys(s.factions), ['0', '1', '2']);
// picks: one per round, of the round's type and half; the special key is allowed in the mode
for (let r = 1; r <= 15; r++) {
const p = s.picks[r];
assert.ok(p, `seed ${seed} R${r}`);
assert.equal(p.type, s.typeSlots[r - 1]);
assert.equal(p.firstHalf, r <= 7);
const e = DATA.factions.entries[p.key];
assert.equal(e.type, p.type);
assert.equal(e.firstHalf, r <= 7);
assert.ok(!gd.inactiveEnemies.has(p.key), `R${r}: banned special ${p.key}`);
assert.ok(e.N.some((x) => x.key === p.normal) && e.E.some((x) => x.key === p.elite));
assert.equal(p.fly, e.fly);
assert.equal(roundPick(gd, createRng(1), s.factions, r), p, 'the factions array resolves the same pick');
assert.equal(roundPick(gd, createRng(1), s, r), p, 'so does the setup object');
}
}
assert.ok(firstSlot.size >= 3, 'R1 type varies (shuffled slots)');
// the extra schedule draws do not shift what the caller draws next from the setup rng (bans stay seed-stable)
const r1 = createRng(9); setupMatchWaves(gd, r1);
const r2 = createRng(9);
const stageIds = gd.mode.stages.filter((id) => gd.stage(id) && gd.stage(id).active !== false && gd.stage(id).weight > 0);
r2(); // stage
r2.shuffle(Object.values(DATA.factions.types).filter((t) => t.involveRandom).map((t) => t.type));
r2(); // boss
if (gd.hiddenRound) r2();
assert.ok(stageIds.length > 0);
assert.equal(r1(), r2());
});
test('normal waves: one pick per round, per-action counts 1–5, the other movement class skipped, literal keys kept, official scaling', () => {
for (const modeId of ['mode_multi_normal', 'mode_multi_funny', 'mode_single_abyss']) {
const gd = new GameData(DATA, modeId);
for (let seed = 1; seed <= 8; seed++) {
const setup = setupMatchWaves(gd, createRng(seed));
for (let r = 1; r < gd.bossRound; r++) {
const w = buildNormalWave(gd, createRng(seed + 100), setup.factions, r);
const tpl = DATA.waves[w.templateId];
const pick = setup.picks[r];
assert.equal(w.pick, pick);
assert.equal(w.templateId, gd.roundCfg(r).template);
assert.equal(w.timeLimit, gd.combatTimeLimit(r));
assert.deepEqual(gd.enemyScale(r), gd.baseEnemyScale(r), 'no custom multiplier');
const scale = gd.enemyScale(r);
const allowed = new Set([pick.key, pick.normal, pick.elite]);
for (const s of w.spawns) {
const a = tpl.spawns[s.actionIndex];
assert.ok(a && !a.action, `R${r}: action ${s.actionIndex}`);
assert.equal(s.time, a.time);
assert.equal(s.routeIndex, a.routeIndex);
if (PLACEHOLDERS.has(a.key)) {
assert.ok(allowed.has(s.enemyKey), `${modeId} R${r}: ${s.enemyKey} not from the pick`);
assert.equal(fly(s.enemyKey), pick.fly, 'one movement class per round');
assert.ok(s.count >= 1 && s.count <= 5, 'clamp 1–5 per action');
if (s.count > 1) assert.ok(Math.abs(s.interval - Math.max(a.count * a.interval / s.count, 0.05 * a.count * a.interval)) < 1e-9, 'same window');
} else {
assert.equal(s.enemyKey, a.key, 'literal key kept');
assert.equal(s.count, a.count);
}
assert.equal(s.mods.hpMul, scale.hpMul);
assert.equal(s.mods.atkMul, scale.atkMul);
assert.equal(s.mods.speedMul, scale.speedMul);
assert.ok(['N', 'E', 'S', 'NF', 'EF', 'SF', 'T', 'TF'].includes(s.mods.slot));
assert.equal(s.preview.gate, gateOf(tpl.routes[s.routeIndex].start));
}
// every placeholder action of the pick's class is present, every other one skipped
for (const [i, a] of tpl.spawns.entries()) {
if (a.action || !PLACEHOLDERS.has(a.key)) continue;
const sameClass = DATA.factions.generation.placeholders[a.key].fly === pick.fly;
assert.equal(w.spawns.some((s) => s.actionIndex === i), sameClass, `R${r} action ${i} (${a.slot})`);
assert.equal(w.actions.find((x) => x.index === i).valid, sameClass);
}
assert.equal(w.entries[pick.fly ? 'fly' : 'ground'].key, pick.key);
assert.equal(w.entries[pick.fly ? 'ground' : 'fly'], null);
}
}
}
});
test('plain factions array (tools): the round type is drawn from the schedule marginal; ABYSS speed; ACLOON never spawns', () => {
const gd = new GameData(DATA, 'mode_multi_abyss');
const seen = new Set();
for (let seed = 1; seed <= 80; seed++) seen.add(buildNormalWave(gd, createRng(seed), ['FLY', 'DOT', 'TIMES'], 5).pick.type);
assert.deepEqual([...seen].sort(), ['DOT', 'FLY', 'SPECIAL', 'TIMES']);
assert.equal(gd.enemyScale(2).speedMul, 1);
assert.ok(Math.abs(gd.enemyScale(3).speedMul - 1.15) < 1e-9);
for (const id of Object.keys(DATA.waves)) {
const t = DATA.waves[id];
if (t.kind !== 'normal') continue;
const r = t.usedBy[0]?.round;
if (!r) continue;
const w = buildNormalWave(gd, createRng(2), ['FLY', 'DOT', 'TIMES'], r);
assert.ok(!w.spawns.some((s) => s.enemyKey === 'enemy_9012_acloon'));
}
});
test('boss templates: leaders never scaled, pairs use the multi template, singles _s; escorts follow the round pick (E or EF only)', () => {
const gd = new GameData(DATA, 'mode_multi_abyss');
for (let seed = 1; seed <= 6; seed++) {
const setup = setupMatchWaves(gd, createRng(seed));
const pick = setup.picks[14];
for (const bossId of Object.keys(gd.mode.bossWeights)) {
const pair = buildBossWave(gd, createRng(1), setup.factions, 14, { bossId, solo: false });
const single = buildBossWave(gd, createRng(1), setup.factions, 14, { bossId, solo: true });
assert.ok(!/_s$/.test(pair.templateId) && /_s$/.test(single.templateId), `${bossId}: ${pair.templateId} / ${single.templateId}`);
for (const w of [pair, single]) {
assert.equal(w.pick, pick);
const boss = w.spawns.filter((s) => s.tag === 'boss');
assert.ok(boss.length >= 1);
for (const s of boss) { assert.equal(s.mods.hpMul, undefined); assert.equal(s.preview.boss, true); }
assert.equal(w.timeLimit, Infinity);
const tpl = DATA.waves[w.templateId];
for (const s of w.spawns) {
assert.ok(DATA.enemies[s.enemyKey], s.enemyKey);
const a = tpl.spawns[s.actionIndex];
if (a.slot === 'E' || a.slot === 'EF') {
assert.equal(s.enemyKey, pick.elite);
assert.equal(a.slot === 'EF', pick.fly, 'only the escorts of the pick\'s class');
}
}
}
}
}
const sgd = new GameData(DATA, 'mode_single_funny');
const setup = setupMatchWaves(sgd, createRng(3));
const w = buildBossWave(sgd, createRng(3), setup.factions, 9, { bossId: 'boss_5', solo: true });
assert.equal(w.templateId, 'act2autochess_h07_05_s');
assert.equal(w.pick, setup.picks[9], 'solo 标准: the leader round uses slot 9');
});
test('bounty spawns: joined to the template\'s first normal action of the enemy\'s class, spread over its window; no solo ×0.7', () => {
const gd = new GameData(DATA, 'mode_multi_hard');
const picks = [];
picks[5] = { round: 5, type: 'DOT', key: 'enemy_1270_nhstlk', normal: 'enemy_1271_nhsbr', elite: 'enemy_1271_nhsbr_2', fly: false, firstHalf: true };
const pickKeys = DATA.factions.entries.enemy_1270_nhstlk;
picks[5].normal = pickKeys.N[0].key; picks[5].elite = pickKeys.E[0].key;
const w = buildNormalWave(gd, createRng(1), { picks }, 5);
const kill = DATA.choices.cards.bounty.find((c) => c.payout === 'kill' && DATA.enemies[c.enemyKey] && !isFlyKey(gd, c.enemyKey));
const perf = DATA.choices.cards.bounty.find((c) => c.payout === 'perfect' && DATA.enemies[c.enemyKey] && !isFlyKey(gd, c.enemyKey));
const specs = bountySpawns(gd, 5, w, [{ id: 'b1', card: kill }, { id: 'b2', card: perf }], 'p1');
assert.equal(specs.length, 2);
assert.deepEqual(specs[0].bounty, { coins: kill.coin, ownerPlayerId: 'p1' });
assert.equal(specs[0].count, kill.count);
assert.equal(specs[0].tag, 'bounty');
assert.equal(specs[0].mods.bountyId, 'b1');
assert.equal(specs[1].bounty, undefined);
// host = the first lrsldr action of act1autochess_05; the 2 bounty units are INSERTED among its own units
// (client _InsertSpActionToNormal: unit i at floor((i+1)·len/(n+1)), len growing) and the list spread over the window
const tpl = DATA.waves[w.templateId];
const hostIdx = tpl.spawns.findIndex((s) => s.key === TS.N);
const host = tpl.spawns[hostIdx];
const own = w.spawns.find((s) => s.actionIndex === hostIdx).count;
const total = own + kill.count + perf.count;
const step = Math.max(host.count * host.interval / total, 0.05 * host.count * host.interval);
const list = new Array(own).fill('own');
list.splice(Math.floor((1 * list.length) / 3), 0, 'kill');
list.splice(Math.floor((2 * list.length) / 3), 0, 'perf');
assert.equal(specs[0].routeIndex, host.routeIndex);
assert.ok(Math.abs(specs[0].time - (host.time + list.indexOf('kill') * step)) < 1e-9, 'kill bounty inserted among the own units');
assert.ok(Math.abs(specs[1].time - (host.time + list.indexOf('perf') * step)) < 1e-9);
assert.ok(specs[0].time < host.time + (total - 1) * step, 'not appended after the own units');
assert.equal(specs[0].preview.gate, 'lower');
// withBounties re-times the host action's own units around the inserted bounty units (one spec per run)
const all = withBounties(gd, 5, w, [{ id: 'b1', card: kill }, { id: 'b2', card: perf }], 'p1');
const hostSpecs = all.filter((s) => s.actionIndex === hostIdx && s.tag !== 'bounty');
assert.equal(hostSpecs.reduce((n, s) => n + s.count, 0), own);
const ownTimes = [];
for (const s of hostSpecs) for (let i = 0; i < s.count; i++) ownTimes.push(s.time + i * s.interval);
const wantOwn = list.map((v, j) => (v === 'own' ? host.time + j * step : null)).filter((t) => t !== null);
assert.deepEqual(ownTimes.map((t) => t.toFixed(6)), wantOwn.map((t) => t.toFixed(6)));
assert.equal(all.reduce((n, s) => n + s.count, 0), w.spawns.reduce((n, s) => n + s.count, 0) + 2, 'Σ units = wave + bounties');
assert.notEqual(w.spawns.find((s) => s.actionIndex === hostIdx).interval, hostSpecs[0].interval, 'the shared wave is not mutated');
// no bounty-specific multiplier in solo (the 70 % base is already in enemyScale)
const sgd = new GameData(DATA, 'mode_single_hard');
const solo = bountySpawns(sgd, 5, w, [{ id: 'b2', card: perf }], 'p1', { solo: true });
assert.equal(solo[0].mods.hpMul, sgd.enemyScale(5).hpMul);
assert.equal(solo[0].mods.defMul, undefined);
// a flyer joins the first yokai action
const flyCard = { ...kill, enemyKey: 'enemy_1005_yokai_2', count: 2 };
const fs = bountySpawns(gd, 5, w, [{ id: 'b3', card: flyCard }], 'p1');
assert.equal(fs[0].routeIndex, tpl.spawns.find((s) => s.key === TS.NF).routeIndex);
assert.equal(w.routes[fs[0].routeIndex].motion, 'FLY');
});
test('联防 wave: walkers on the escaped lrsldr action, flyers on yokai, tokens on gopro_2/lazerd; per-owner offsets, window spread', () => {
const gd = new GameData(DATA, 'mode_multi_normal');
const leaked = [];
for (let i = 0; i < 12; i++) leaked.push({ enemyKey: 'enemy_1422_lrsldr', mods: { hpMul: 1.2, slot: 'N' }, sourcePlayerId: 'a' });
leaked.push({ enemyKey: 'enemy_1427_lrnazg', mods: { slot: 'E' }, sourcePlayerId: 'c' });
for (let i = 0; i < 3; i++) leaked.push({ enemyKey: 'enemy_1005_yokai', mods: { slot: 'NF' }, sourcePlayerId: 'b', bounty: { coins: 2, ownerPlayerId: 'b' } });
leaked.push({ enemyKey: 'enemy_1007_slime', mods: null, sourcePlayerId: 'b' });
leaked.push({ enemyKey: 'enemy_1269_nhfly', mods: null, sourcePlayerId: 'b' }); // 枯朽之种: summoned only ⇒ token
const one = buildUniteWave(gd, leaked, 1, 60);
const two = buildUniteWave(gd, leaked, 2, 60);
assert.equal(one.templateId, 'act1autochess_escaped_single');
assert.equal(two.templateId, 'act1autochess_escaped_multi');
for (const w of [one, two]) {
const tpl = DATA.waves[w.templateId];
const act = (k) => tpl.spawns.find((s) => s.key === k);
assert.equal(w.spawns.length, 18);
assert.equal(w.spawns.filter((s) => s.sourcePlayerId === 'a').length, 12);
for (const s of w.spawns) {
const tokenOnly = DATA.enemies[s.enemyKey].tokenOnly;
const f = DATA.enemies[s.enemyKey].isFlyEnemy;
const want = act(tokenOnly ? (f ? TS.TF : TS.T) : (f ? TS.NF : TS.N));
assert.equal(s.routeIndex, want.routeIndex, `${s.enemyKey}: on the ${want.key} action`);
assert.equal(w.routes[s.routeIndex].motion === 'FLY', f);
}
// the elite 灵幛 walks with the normal walkers (the class is ignored)
const n = act(TS.N);
const walkers = w.spawns.filter((s) => s.routeIndex === n.routeIndex);
assert.equal(walkers.length, 12 + 1 + 1);
const a = walkers.filter((s) => s.sourcePlayerId === 'a');
const step = Math.max(n.count * n.interval / 12, 0.05 * n.count * n.interval);
assert.equal(a[0].time, n.time);
assert.ok(Math.abs(a[11].time - (n.time + 11 * step)) < 1e-9, 'owner a spread over the action window');
const c = walkers.find((s) => s.sourcePlayerId === 'c');
assert.equal(c.time, n.time + 0.5, 'second owner offset 0.5 s');
assert.ok(w.spawns.some((s) => s.bounty && s.bounty.coins === 2), 'kill bounties keep paying');
}
});
test('preview: one entry per action with gate / time / class flags, bounty and boss entries flagged, parts omitted; Σ count kept', () => {
const p = previewOf([
{ enemyKey: 'a', count: 2, time: 5, preview: { gate: 'upper', fly: false, elite: false } },
{ enemyKey: 'a', count: 1, time: 3, preview: { gate: 'lower', fly: false, elite: false } },
{ enemyKey: 'b', count: 1, time: 4, tag: 'bounty', preview: { gate: 'lower', fly: true, elite: true } },
{ enemyKey: 'c', count: 1, time: 0, tag: 'boss', preview: { gate: 'lower', boss: true } },
{ enemyKey: 'd', count: 1, tag: 'part' },
]);
assert.deepEqual(p, [
{ enemyKey: 'c', count: 1, gate: 'lower', t: 0, fly: false, elite: true, boss: true, source: 'wave', tag: 'boss' },
{ enemyKey: 'a', count: 1, gate: 'lower', t: 3, fly: false, elite: false, boss: false, source: 'wave', tag: null },
{ enemyKey: 'b', count: 1, gate: 'lower', t: 4, fly: true, elite: true, boss: false, source: 'bounty', tag: 'bounty' },
{ enemyKey: 'a', count: 2, gate: 'upper', t: 5, fly: false, elite: false, boss: false, source: 'wave', tag: null },
]);
// a real round: the preview Σ equals the spawned enemies, gates follow the action routes
const gd = new GameData(DATA, 'mode_multi_normal');
const setup = setupMatchWaves(gd, createRng(11));
const w = buildNormalWave(gd, createRng(11), setup.factions, 12);
const pv = previewOf(w.spawns);
assert.equal(pv.length, w.spawns.length);
assert.equal(pv.reduce((n, e) => n + e.count, 0), w.spawns.reduce((n, s) => n + s.count, 0));
assert.ok(pv.some((e) => e.gate === 'upper') && pv.some((e) => e.gate === 'lower'), 'R12 uses both gates');
for (let i = 1; i < pv.length; i++) assert.ok(pv[i].t >= pv[i - 1].t, 'sorted by spawn time');
for (const r of [1, 2, 3]) assert.ok(previewOf(buildNormalWave(gd, createRng(1), setup.factions, r).spawns).every((e) => e.gate === 'lower'), `R${r}: lower gate only`);
});