Merge upstream 0.2.2 and resolve reroll conflicts (#408)
This commit is contained in:
commit
be095d8638
447 files changed
+33376
-8843
No files matched your search
@@ -0,0 +1,208 @@
|
||||
// The co-op room option 「AI 队友最后选择」 (opts.aiPicksLast; GitHub #338): in the strategy draft (BAND_DRAFT) and the
|
||||
// 机变 draft (SP_DRAFT) every human seat picks before every AI seat. The drawn order is kept inside each group (a stable
|
||||
// partition AFTER the usual shuffle): no extra random draw, so the option off is today's order and the option on moves
|
||||
// no random stream. A human under AI 托管, disconnected or departed is still a human (only room.addBot seats are AI).
|
||||
// A human's skip passes the turn to the other humans still to pick, ahead of the AI seats [ASSUMED]; with no other
|
||||
// human left it goes to the end as without the option.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { PHASE } from '../../shared/constants.js';
|
||||
import { makeMatch } from './harness.js';
|
||||
|
||||
const isAi = (pid) => pid.startsWith('ai_');
|
||||
/** the stable humans-first partition of a drawn order */
|
||||
const humansFirst = (order) => [...order.filter((p) => !isAi(p)), ...order.filter(isAi)];
|
||||
/** humans first, then AI seats, each in seat order — 1 host + 3 AI by default */
|
||||
const seatsOf = (ids) => ids.map((playerId, seat) => ({ seat, playerId, name: playerId, isBot: isAi(playerId), connected: true }));
|
||||
|
||||
/** A match driven to BAND_DRAFT (every human confirms the briefing); `before(m)` runs while still in INFO_CHECK. */
|
||||
function toBandDraft(o, before) {
|
||||
const h = makeMatch({ mode: 'coop', ...o }).start();
|
||||
if (before) before(h.m);
|
||||
for (const ps of h.m.players.values()) if (!ps.isBot && !ps.left) h.m.handle(ps.playerId, { t: 'g.infoReady' });
|
||||
h.run(() => h.m.phase !== PHASE.INFO_CHECK);
|
||||
assert.equal(h.m.phase, PHASE.BAND_DRAFT);
|
||||
return h;
|
||||
}
|
||||
|
||||
/** The band-draft order (taken at its start, before any skip) and the next draws of the random streams after it. */
|
||||
function bandOrder(o, before) {
|
||||
const h = toBandDraft(o, before);
|
||||
const order = h.m.draft.order.slice();
|
||||
const draws = { draft: h.m.rngDraft(), shop: h.m.rngShop(), bots: h.m.rngBots(), meta: h.m.rngMeta(), waves: h.m.rngWaves() };
|
||||
h.m.dispose();
|
||||
return { order, draws };
|
||||
}
|
||||
|
||||
test('the option: off by default, on only when opts.aiPicksLast === true, never in solo', () => {
|
||||
const mk = (o) => { const h = makeMatch(o); const v = h.m.aiPicksLast; h.m.dispose(); return v; };
|
||||
assert.equal(mk({ mode: 'coop', humans: 1, bots: 3 }), false);
|
||||
assert.equal(mk({ mode: 'coop', humans: 1, bots: 3, aiPicksLast: true }), true);
|
||||
assert.equal(mk({ mode: 'coop', humans: 1, bots: 3, aiPicksLast: 'yes' }), false, 'only a real boolean');
|
||||
assert.equal(mk({ mode: 'solo', humans: 1, aiPicksLast: true }), false, 'solo: nothing to order');
|
||||
});
|
||||
|
||||
test('band draft, option off: the order is exactly today\'s for the same seed (no option = off)', () => {
|
||||
let humanNotFirst = 0;
|
||||
for (let seed = 1; seed <= 12; seed++) {
|
||||
const o = { seats: seatsOf(['p_0', 'ai_0', 'ai_1', 'ai_2']), seed };
|
||||
const def = bandOrder(o);
|
||||
const off = bandOrder({ ...o, aiPicksLast: false });
|
||||
assert.deepEqual(off, def, `seed ${seed}`);
|
||||
if (def.order[0] !== 'p_0') humanNotFirst++;
|
||||
}
|
||||
assert.ok(humanNotFirst > 0, 'the shuffle does put an AI first without the option (so the next test means something)');
|
||||
});
|
||||
|
||||
test('band draft, option on, 1 human + 3 AI: the human always picks first; the same shuffle, every random stream unchanged', () => {
|
||||
for (let seed = 1; seed <= 12; seed++) {
|
||||
const o = { seats: seatsOf(['p_0', 'ai_0', 'ai_1', 'ai_2']), seed };
|
||||
const off = bandOrder(o);
|
||||
const on = bandOrder({ ...o, aiPicksLast: true });
|
||||
assert.equal(on.order[0], 'p_0', `seed ${seed}`);
|
||||
assert.deepEqual(on.order, humansFirst(off.order), `seed ${seed}: the AI seats keep their drawn order`);
|
||||
assert.deepEqual(on.draws, off.draws, `seed ${seed}: no extra random draw`);
|
||||
}
|
||||
});
|
||||
|
||||
test('band draft, option on, 2 humans + 2 AI on interleaved seats: both humans first, in their drawn order', () => {
|
||||
const ids = ['p_0', 'ai_0', 'p_1', 'ai_1'];
|
||||
const seen = new Set();
|
||||
for (let seed = 1; seed <= 12; seed++) {
|
||||
const o = { seats: seatsOf(ids), seed };
|
||||
const off = bandOrder(o);
|
||||
const on = bandOrder({ ...o, aiPicksLast: true });
|
||||
assert.deepEqual(on.order, humansFirst(off.order), `seed ${seed}`);
|
||||
assert.deepEqual(on.order.slice(0, 2).sort(), ['p_0', 'p_1']);
|
||||
seen.add(on.order[0]);
|
||||
}
|
||||
assert.equal(seen.size, 2, 'the humans\' own order is still drawn (either may lead)');
|
||||
});
|
||||
|
||||
test('band draft, option on: all humans, or all AI seats (tools/matchrun), keep today\'s order', () => {
|
||||
for (const ids of [['p_0', 'p_1', 'p_2', 'p_3'], ['ai_0', 'ai_1', 'ai_2', 'ai_3']]) {
|
||||
for (let seed = 1; seed <= 4; seed++) {
|
||||
const o = { seats: seatsOf(ids), seed };
|
||||
if (ids[0] === 'ai_0') {
|
||||
// no human: INFO_CHECK ends by itself
|
||||
const run = (x) => { const h = makeMatch({ mode: 'coop', ...x }).start(); h.run(() => h.m.phase !== PHASE.INFO_CHECK); const r = h.m.draft.order.slice(); h.m.dispose(); return r; };
|
||||
assert.deepEqual(run({ ...o, aiPicksLast: true }), run(o), `all AI, seed ${seed}`);
|
||||
} else {
|
||||
assert.deepEqual(bandOrder({ ...o, aiPicksLast: true }), bandOrder(o), `all humans, seed ${seed}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('band draft, option on: a human under AI 托管, disconnected or departed is still ordered as a human', () => {
|
||||
for (let seed = 1; seed <= 8; seed++) {
|
||||
const o = { seats: seatsOf(['p_0', 'p_1', 'p_2', 'ai_0']), seed, aiPicksLast: true };
|
||||
const before = (m) => {
|
||||
m.handle('p_0', { t: 'g.autoplay', on: true });
|
||||
m.onDisconnect('p_1');
|
||||
m.onLeave('p_2');
|
||||
};
|
||||
const h = toBandDraft(o, before);
|
||||
assert.equal(h.m.draft.order[3], 'ai_0', `seed ${seed}: ${h.m.draft.order}`);
|
||||
h.m.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test('band draft, option on: a skip passes the turn to the other humans, ahead of the AI seats; the last human\'s skip goes to the end [ASSUMED]', () => {
|
||||
const h = toBandDraft({ seats: seatsOf(['p_0', 'ai_0', 'p_1', 'ai_1']), seed: 3, aiPicksLast: true });
|
||||
const m = h.m;
|
||||
const [h1, h2, a1, a2] = m.draft.order;
|
||||
assert.ok(!isAi(h1) && !isAi(h2) && isAi(a1) && isAi(a2), String(m.draft.order));
|
||||
// the first human passes: behind the other human, still before the AI seats
|
||||
assert.deepEqual(m.handle(h1, { t: 'g.bandSkip' }), { ok: true });
|
||||
assert.deepEqual(m.draft.order, [h2, h1, a1, a2]);
|
||||
assert.equal(m.draftTurn(), h2);
|
||||
// the second human passes: behind the first again
|
||||
assert.deepEqual(m.handle(h2, { t: 'g.bandSkip' }), { ok: true });
|
||||
assert.deepEqual(m.draft.order, [h1, h2, a1, a2]);
|
||||
assert.deepEqual(m.handle(h1, { t: 'g.band', bandId: 'band_amiya' }), { ok: true });
|
||||
assert.equal(m.draftTurn(), h2);
|
||||
m.dispose();
|
||||
|
||||
// a lone human (host + AI): no other human to pass to — the skip moves it to the end as without the option
|
||||
const h3 = toBandDraft({ seats: seatsOf(['p_0', 'ai_0', 'ai_1', 'ai_2']), seed: 3, aiPicksLast: true });
|
||||
const order = h3.m.draft.order.slice();
|
||||
assert.equal(order[0], 'p_0');
|
||||
assert.deepEqual(h3.m.handle('p_0', { t: 'g.bandSkip' }), { ok: true });
|
||||
assert.deepEqual(h3.m.draft.order, [...order.slice(1), 'p_0']);
|
||||
h3.m.dispose();
|
||||
|
||||
// option off: the skip still goes to the very end (today's rule)
|
||||
const h4 = toBandDraft({ seats: seatsOf(['p_0', 'ai_0', 'p_1', 'ai_1']), seed: 3 });
|
||||
h4.run(() => !isAi(h4.m.draftTurn() || 'ai_'));
|
||||
const o4 = h4.m.draft.order.slice();
|
||||
const at = h4.m.draft.idx;
|
||||
const cur = h4.m.draftTurn();
|
||||
assert.deepEqual(h4.m.handle(cur, { t: 'g.bandSkip' }), { ok: true });
|
||||
assert.deepEqual(h4.m.draft.order, [...o4.slice(0, at), ...o4.slice(at + 1), cur]);
|
||||
h4.m.dispose();
|
||||
});
|
||||
|
||||
/** Drive a fake-battle match into the SP draft of round `r`; `flip(m)` runs just before it is entered. */
|
||||
function toSpDraft(o, r, flip) {
|
||||
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', fake: true, ...o }).start();
|
||||
const m = h.m;
|
||||
h.drive(() => m.phase === PHASE.ROUND_START && m.round === r);
|
||||
assert.equal(m.round, r);
|
||||
if (flip) flip(m);
|
||||
h.run(() => m.phase === PHASE.SP_DRAFT);
|
||||
assert.equal(m.phase, PHASE.SP_DRAFT);
|
||||
return h;
|
||||
}
|
||||
|
||||
test('机变 draft, option off vs on from the same state: on = the humans-first partition of the same shuffle; no random stream moves', () => {
|
||||
for (const [ids, seed] of [[['p_0', 'ai_0', 'ai_1', 'ai_2'], 14], [['p_0', 'ai_0', 'p_1', 'ai_1'], 5], [['p_0', 'ai_0', 'ai_1', 'ai_2'], 2]]) {
|
||||
const o = { seats: seatsOf(ids), seed };
|
||||
const off = toSpDraft(o, 3);
|
||||
const on = toSpDraft(o, 3, (m) => { m.aiPicksLast = true; });
|
||||
assert.deepEqual(on.m.sp.order, humansFirst(off.m.sp.order), `seed ${seed}`);
|
||||
assert.deepEqual(on.m.sp.cards, off.m.sp.cards, 'the same cards');
|
||||
for (const k of ['rngDraft', 'rngShop', 'rngBots', 'rngMeta', 'rngWaves']) assert.equal(on.m[k](), off.m[k](), `${k} unchanged (seed ${seed})`);
|
||||
off.m.dispose();
|
||||
on.m.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test('机变 draft, option on, 1 human + 3 AI: the human picks first in every 机变 round of the match', () => {
|
||||
for (const seed of [1, 14]) {
|
||||
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', fake: true, seats: seatsOf(['p_0', 'ai_0', 'ai_1', 'ai_2']), seed, aiPicksLast: true }).start();
|
||||
const m = h.m;
|
||||
const firsts = {};
|
||||
const enter = m.enterSpDraft;
|
||||
m.enterSpDraft = function () {
|
||||
enter.call(this);
|
||||
if (this.sp) firsts[this.round] = this.sp.order[0];
|
||||
};
|
||||
h.drive(() => h.ended != null || m.round > 9);
|
||||
const rounds = m.gd.spRounds().filter((r) => r <= 9);
|
||||
assert.deepEqual(rounds, [3, 6, 9]);
|
||||
for (const r of rounds) if (firsts[r] != null) assert.equal(firsts[r], 'p_0', `seed ${seed} R${r}`);
|
||||
assert.ok(firsts[3] === 'p_0', 'round 3 is always reached');
|
||||
m.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test('机变 draft, option on, 2 humans + 2 AI interleaved, one under AI 托管: both humans ahead of the AI seats', () => {
|
||||
const o = { seats: seatsOf(['p_0', 'ai_0', 'p_1', 'ai_1']), seed: 5 };
|
||||
const h = toSpDraft({ ...o, aiPicksLast: true }, 3, (m) => { m.handle('p_1', { t: 'g.autoplay', on: true }); });
|
||||
assert.deepEqual(h.m.sp.order.slice(0, 2).sort(), ['p_0', 'p_1']);
|
||||
assert.ok(h.m.sp.order.slice(2).every(isAi));
|
||||
h.m.dispose();
|
||||
});
|
||||
|
||||
test('solo: the option changes nothing (seat order, no skip)', () => {
|
||||
const run = (aiPicksLast) => {
|
||||
const h = makeMatch({ mode: 'solo', humans: 1, seed: 7, aiPicksLast }).start();
|
||||
h.m.handle('p_0', { t: 'g.infoReady' });
|
||||
h.run(() => h.m.phase !== PHASE.INFO_CHECK);
|
||||
const r = { order: h.m.draft.order.slice(), next: h.m.rngDraft() };
|
||||
h.m.dispose();
|
||||
return r;
|
||||
};
|
||||
assert.deepEqual(run(true), run(undefined));
|
||||
});
|
||||
@@ -88,8 +88,10 @@ test('终极 Final Assault (+200 layers per active bond): one pool (bloodPoint
|
||||
for (const k of MUL_STATS) assert.ok(!(k in b.mods), `${u.name}: ${b.key} is a 直接乘算 bonus, not ${k} (${JSON.stringify(b.mods)})`);
|
||||
}
|
||||
const { add, mul } = aggregateMods(u.buffs);
|
||||
const want = Math.max(0, ((u.base.atk + (add.atkFlat ?? 0)) * Math.max(0, 1 + (add.atkPct ?? 0)) + (add.atkFinal ?? 0)) * (mul.atkMul ?? 1));
|
||||
assert.ok(Math.abs(u.s.atk - want) <= 1e-6 * Math.max(1, want), `${u.name}: ATK = ((base + flat) × (1 + Σ%) + 最终加算) × Π(提升至/runes)`);
|
||||
// the 练度 multiplier (0.2.2, the unit's own cultMul: default 精英2 Lv.60 = ×1.1) is a separate factor, after 直接乘算
|
||||
const cult = u.cultMul ? u.cultMul.atk : 1;
|
||||
const want = Math.max(0, ((u.base.atk + (add.atkFlat ?? 0)) * Math.max(0, 1 + (add.atkPct ?? 0)) + (add.atkFinal ?? 0)) * (mul.atkMul ?? 1) * cult);
|
||||
assert.ok(Math.abs(u.s.atk - want) <= 1e-6 * Math.max(1, want), `${u.name}: ATK = ((base + flat) × (1 + Σ%) + 最终加算) × Π(提升至/runes) × 练度`);
|
||||
}
|
||||
}
|
||||
assert.ok(ops >= 12 && withBonds >= 8, `the bots' lineups carry bond / item bonuses (${withBonds} of ${ops})`);
|
||||
|
||||
@@ -38,9 +38,45 @@ test('bounty pick: the card the own board can beat, never one it cannot (even wh
|
||||
assert.equal(botPickCard(m, ps, [hard, easy, huge].map((c, idx) => ({ ...c, idx })), [0, 1, 2]), 1);
|
||||
ps.lp = 2;
|
||||
assert.equal(botPickCard(m, ps, [hard, huge, easy].map((c, idx) => ({ ...c, idx })), [0, 1, 2]), 2, 'low LP: still the beatable one');
|
||||
const raw = [hard, easy].map((c) => DATA.choices.cards.bounty.find((x) => x.effectId === c.id));
|
||||
assert.deepEqual(m.offerBountyChoice(ps, raw, 'chess_item_6_03_m'), { ok: true });
|
||||
assert.deepEqual(m.autoPickPersonalChoice(ps, 'bot'), { ok: true });
|
||||
assert.equal(ps.personalChoice, null);
|
||||
assert.equal(ps.bounties.at(-1).card.enemyKey, easy.enemyKey, 'personal cards use bounty scoring too');
|
||||
const metaState = m.rngMeta.state(), botState = m.rngBots.state();
|
||||
m.autoPickPersonalChoice(ps, 'bot');
|
||||
m.autoPickPersonalChoice(ps, 'random');
|
||||
assert.equal(m.rngMeta.state(), metaState);
|
||||
assert.equal(m.rngBots.state(), botState, 'nothing pending: no RNG draws');
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('a bot\'s 教鞭 pick is deterministic: one seed offers the same three cards and picks the same one, drawing only on the bots\' rng', () => {
|
||||
const runs = [];
|
||||
for (let k = 0; k < 2; k++) {
|
||||
const h = soloBot({ seed: 5, difficulty: 'HARD' }).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.PREP && m.round === 5);
|
||||
const art = giveItem(m, ps, 'chess_item_6_03_m');
|
||||
assert.deepEqual(ps.useArt(art.uid, 10, 5), { ok: true });
|
||||
const offered = ps.personalChoice.cards.map((c) => c.effectId);
|
||||
assert.equal(offered.length, 3);
|
||||
assert.equal(new Set(offered).size, 3, 'three different cards');
|
||||
const meta = m.rngMeta.state(), bots = m.rngBots.state();
|
||||
assert.deepEqual(m.autoPickPersonalChoice(ps, 'bot'), { ok: true });
|
||||
assert.equal(m.rngMeta.state(), meta, 'the pick leaves the meta rng alone');
|
||||
assert.notEqual(m.rngBots.state(), bots, 'one scoring draw per card from the bots\' rng');
|
||||
assert.equal(ps.personalChoice, null);
|
||||
runs.push({ offered, picked: ps.bounties.at(-1).card.effectId, bots: m.rngBots.state() });
|
||||
assert.ok(offered.includes(runs[k].picked));
|
||||
m.dispose();
|
||||
}
|
||||
assert.deepEqual(runs[0], runs[1]);
|
||||
});
|
||||
|
||||
test('bounty pick in real drafts (绝境 R3 悬赏决策): a card the board likely beats (p ≥ 0.5) whenever a near-sure one (p ≥ 0.9) is offered', () => {
|
||||
let drafts = 0;
|
||||
for (const seed of [1, 2, 3, 4]) {
|
||||
@@ -138,9 +174,12 @@ test('bounty Arts (教鞭): kept after a battle with leaks, used after a perfect
|
||||
assert.ok(owns(ps, WHIP), 'kept after a battle with leaks (not destroyed)');
|
||||
h.run(() => m.phase === PHASE.PREP && m.round === 4);
|
||||
m.lastResults.set(ps.playerId, clean);
|
||||
giveItem(m, ps, WHIP);
|
||||
const bounties = ps.bounties.length;
|
||||
h.run(() => m.phase === PHASE.COMBAT && m.round === 4);
|
||||
assert.ok(!owns(ps, WHIP) && ps.bounties.length > bounties, 'used after a perfect battle');
|
||||
assert.ok(!owns(ps, WHIP) && ps.bounties.length === bounties + 2, 'both Arts used and picked after a perfect battle');
|
||||
assert.equal(ps.personalChoice, null);
|
||||
assert.equal(ps.round.arts, 2);
|
||||
assert.equal(m.errorCount, 0);
|
||||
m.dispose();
|
||||
}
|
||||
|
||||
+79
-2
@@ -3,11 +3,62 @@ 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, placeClass, parseKey } from '../../server/match/board.js';
|
||||
import { makeMatch, checkInvariants, give, DATA } from './harness.js';
|
||||
import { FIELD, canPlace, placeClass, parseKey, legalTiles, tileKey } from '../../server/match/board.js';
|
||||
import { makeMatch, checkInvariants, give, giveItem, DATA } from './harness.js';
|
||||
import { makeBattle } from '../helpers/battleHarness.js';
|
||||
|
||||
const soloBot = (o = {}) => makeMatch({ mode: 'solo', difficulty: 'NORMAL', seats: [{ seat: 0, playerId: 'ai_0', name: 'AI', isBot: true, connected: true }], ...o });
|
||||
|
||||
test('user: Given a saturated layout, When placing a self-range defender, Then prefer a free enemy path tile', () => {
|
||||
const h = makeMatch({ mode: 'solo', seed: 11 }).start().toPrep();
|
||||
const m = h.m;
|
||||
const ps = m.order[0];
|
||||
try {
|
||||
const pieces = ['chess_char_6_03_a', 'chess_char_5_11_a', 'chess_char_1_02_a',
|
||||
'chess_char_1_10_a', 'chess_char_2_08_a', 'chess_char_3_16_a'].map((id) => give(m, ps, id, 'hand'));
|
||||
const plan = planLayout(m, ps, pieces);
|
||||
// act2autochess_m04, first wave: the ground route crosses these deployable tiles.
|
||||
const road = new Set(['9,9', '9,8', '9,7', '9,6', '11,5', '11,4', '10,4', '9,4', '9,3']);
|
||||
assert.equal(m.stageId, 'act2autochess_m04');
|
||||
assert.ok(road.has(plan.get(pieces.at(-1).uid)), `Cuora must block the route, got ${plan.get(pieces.at(-1).uid)}`);
|
||||
for (const [key, expectedToBlock] of [[plan.get(pieces.at(-1).uid), true], ['12,5', false]]) {
|
||||
const [row, col] = parseKey(key);
|
||||
const battle = makeBattle({ stageId: m.stageId, waveTemplate: 'act1autochess_01',
|
||||
units: [{ chessId: 'chess_char_3_16_a', row, col }] });
|
||||
battle.runToEnd();
|
||||
assert.equal(battle.hooksOf('blocked').length > 0, expectedToBlock, `blocking at ${key}`);
|
||||
assert.equal(battle.result().perPlayer.p1.damageDealt > 0, expectedToBlock, `damage at ${key}`);
|
||||
}
|
||||
// When every path position is occupied, a legal fallback remains available.
|
||||
const fallback = planLayout(m, ps, [pieces.at(-1)], undefined, { occupied: road });
|
||||
const key = fallback.get(pieces.at(-1).uid);
|
||||
assert.ok(key && !road.has(key));
|
||||
assert.equal(ps.deployMap().get(key), 'melee');
|
||||
} finally { m.dispose(); }
|
||||
});
|
||||
|
||||
test('self-range blockers (range 0-1, only their own tile) fill the free enemy-road tiles before any other tile, on every stage and seed', () => {
|
||||
// official range_table "0-1": the one grid (0,0) — 角峰 古米 泡泡 折桠 菲莱 蛇屠箱 塞雷娅 余
|
||||
const guards = Object.values(DATA.chess).filter((c) => c && !c.isGolden && c.rangeGrid && c.rangeGrid.length === 1 && c.rangeGrid[0].every((v) => v === 0));
|
||||
assert.equal(guards.length, 8);
|
||||
const stages = new Set();
|
||||
for (let seed = 1; seed <= 40; seed++) {
|
||||
const h = makeMatch({ mode: 'solo', seed }).start().toPrep();
|
||||
const m = h.m;
|
||||
const ps = m.order[0];
|
||||
try {
|
||||
stages.add(m.stageId);
|
||||
const pieces = guards.map((c) => give(m, ps, c.chessId, 'hand'));
|
||||
const plan = planLayout(m, ps, pieces);
|
||||
const model = fieldModel(m, ps);
|
||||
const road = legalTiles(ps.deployMap(), 'melee').filter(([r, c]) => model.ground.has(tileKey(r, c)));
|
||||
const onRoad = pieces.filter((p) => model.ground.has(plan.get(p.uid))).length;
|
||||
assert.equal(onRoad, Math.min(pieces.length, road.length), `seed ${seed}, ${m.stageId}: ${onRoad} of ${pieces.length} guards on the ${road.length} road tiles`);
|
||||
} finally { m.dispose(); }
|
||||
}
|
||||
assert.equal(stages.size, 8, 'every stage was tried');
|
||||
});
|
||||
|
||||
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;
|
||||
@@ -222,6 +273,32 @@ test('a sliced rehearsal is dropped when the prep ends first; the default layout
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('AI takeover resolves a personal choice in untimed PREP, including generator-error Ready cleanup', () => {
|
||||
for (const broken of [false, true]) {
|
||||
const h = makeMatch({ mode: 'coop', humans: 1, seed: 6, fake: true, botSliceMs: 0 }).start();
|
||||
h.toPrep(1);
|
||||
const m = h.m, ps = h.ps('p_0');
|
||||
assert.equal(m.deadline, 0, 'single-human co-op is untimed');
|
||||
const art = giveItem(m, ps, 'chess_item_6_03_m');
|
||||
assert.deepEqual(ps.useArt(art.uid, 10, 5), { ok: true });
|
||||
const pick = m.autoPickPersonalChoice.bind(m), modes = [];
|
||||
m.autoPickPersonalChoice = (p, mode) => {
|
||||
if (p.personalChoice) modes.push(mode);
|
||||
if (broken && mode === 'bot') throw new Error('scripted choice scoring failure');
|
||||
return pick(p, mode);
|
||||
};
|
||||
assert.deepEqual(m.handle(ps.playerId, { t: 'g.autoplay', on: true }), { ok: true });
|
||||
assert.ok(h.run(() => m.phase !== PHASE.PREP));
|
||||
assert.equal(ps.personalChoice, null);
|
||||
assert.equal(ps.bounties.length, 1);
|
||||
assert.equal(ps.round.arts, 1);
|
||||
assert.ok(ps.ready);
|
||||
assert.ok(modes.includes(broken ? 'random' : 'bot'));
|
||||
assert.equal(m.errorCount > 0, broken);
|
||||
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();
|
||||
|
||||
@@ -9,7 +9,7 @@ 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 { validateClientResult, specBounds, syntheticResult } 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';
|
||||
@@ -95,7 +95,14 @@ test('COMBAT: humans get their own spec (authoritative), bots are simulated by t
|
||||
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 });
|
||||
// `resolved` is null while the authority has not reported one — never 0, so the client's `resolved ?? killed` fallback
|
||||
// holds (a report without `resolved` must read as "unknown", not as "nothing resolved")
|
||||
assert.deepEqual(pub.fields.find((f) => f.fieldId === 'n:p_0').progress, { killed: 3, resolved: null, total: 9, done: false });
|
||||
// a report that carries `resolved` is adopted, a reported 0 included (PR #157 capsule numerator)
|
||||
assert.deepEqual(m.handle('p_0', { t: 'b.progress', battleId: human.battleId, gt: 5, killed: 3, total: 9, leaks: 0, resolved: 0 }), { ok: true });
|
||||
m.flush(true);
|
||||
const pub2 = m.publicView();
|
||||
assert.deepEqual(pub2.fields.find((f) => f.fieldId === 'n:p_0').progress, { killed: 3, resolved: 0, 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);
|
||||
@@ -509,6 +516,10 @@ test('Final Assault: an early boss b.result (forced at t≈0) does not end the p
|
||||
|
||||
test('Final Assault: a 999-layer kill faster than the budget — the \'cleared\' b.result whose report covers the pool waits for the budget, no takeover (QA 6b)', () => {
|
||||
const { h, m, f } = faWithForger(9122);
|
||||
const ps = h.ps('p_0');
|
||||
const training = DATA.choices.cards.bounty.find((c) => c.payout === 'perfect' && m.gd.enemy(c.enemyKey));
|
||||
m.addBounty(ps, { ...training, rounds: 2, multiRound: false });
|
||||
const pending = ps.pendingFunds;
|
||||
const max = m.bossPool.maxHp;
|
||||
h.sched.advance(500); // 1 game s on the field clock: the budget credits ≤ 20 % of the pool
|
||||
const gt = m._fieldElapsed(f);
|
||||
@@ -537,10 +548,141 @@ test('Final Assault: a 999-layer kill faster than the budget — the \'cleared\'
|
||||
assert.equal(f.resultSource, 'client', 'the held client result completed the field');
|
||||
assert.equal(m.verifyStats.takeovers, takeovers0);
|
||||
assert.equal(m.verifyStats.rejected, rejected0);
|
||||
assert.equal(ps.pendingFunds, pending + training.coin, 'validated held client result earns the server card amount');
|
||||
assert.equal(ps.bounties[0].roundsLeft, 1);
|
||||
assert.deepEqual(m.handle('p_0', { t: 'b.result', battleId: f.battleId, result: res }), { ok: true });
|
||||
assert.equal(ps.pendingFunds, pending + training.coin, 'duplicate accepted result does not pay twice');
|
||||
assert.equal(ps.bounties[0].roundsLeft, 1);
|
||||
for (const pid of f.players) assert.ok(Math.abs((f.bossBy[pid] || 0) - Math.min(by[pid], max)) <= max * 1e-9 + 5, `${pid} credited as reported (${f.bossBy[pid]} vs ${by[pid]})`);
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('boss bounty eligibility: forced real results on a team victory qualify; synthetic, leaked and team defeat do not', () => {
|
||||
for (const [ending, synthetic, perfect, leaked, eligible] of [
|
||||
['cleared', false, true, [], true],
|
||||
['cleared', true, true, [], false],
|
||||
['cleared', false, false, [], false],
|
||||
['cleared', false, true, [{ counted: true }], false],
|
||||
['cleared', false, true, [{ counted: false }], true],
|
||||
['forced', false, true, [], false],
|
||||
]) {
|
||||
const { m, f } = faWithForger(9122);
|
||||
const ps = m.players.get(f.players[0]);
|
||||
const card = DATA.choices.cards.bounty.find((c) => c.payout === 'perfect' && m.gd.enemy(c.enemyKey));
|
||||
m.addBounty(ps, { ...card, rounds: 2, multiRound: false });
|
||||
m.addBounty(ps, { ...card, rounds: 2, multiRound: false });
|
||||
const res = syntheticResult(f.players);
|
||||
if (!synthetic) delete res.synthetic;
|
||||
res.reason = 'forced';
|
||||
Object.assign(res.perPlayer[ps.playerId], { perfect, leaked, coins: 3 });
|
||||
const pending = ps.pendingFunds, gained = ps.stats.fundsGained;
|
||||
m._finalEnding = ending;
|
||||
m.bossPool.hp = 0; // even a late clearing report cannot override an already-registered team defeat
|
||||
m._finishFinal(false, () => res);
|
||||
assert.equal(ps.pendingFunds, pending + 3 + (eligible ? 2 * card.coin : 0));
|
||||
assert.equal(ps.stats.fundsGained, gained + 3 + (eligible ? 2 * card.coin : 0));
|
||||
assert.deepEqual(ps.bounties.map((b) => b.roundsLeft), [1, 1]);
|
||||
m.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
// ---- a perfect-payout bounty and what the authority reported (a modified client could tell the two halves apart) -------
|
||||
|
||||
/**
|
||||
* A Final Assault pair field played by hand (the SimClient of p_0, the authority, is muted): both players hold the same
|
||||
* perfect-payout card; the authority sends one b.progress — `leaks` (the field's LP cost) and `leaksBy` (its per-player
|
||||
* split, when given) — then a 'cleared' b.result whose leaked lists hold `leaked[pid]` counted leaks (lpr 1); the fight
|
||||
* is driven to its end. Returns the funds each player was paid (kill coins: none) and what the server charged.
|
||||
*/
|
||||
function bossPaid(seed, { leaks = 0, leaksBy = null, leaked = {} }) {
|
||||
const { h, m, f } = faWithForger(seed);
|
||||
const card = DATA.choices.cards.bounty.find((c) => c.payout === 'perfect' && m.gd.enemy(c.enemyKey));
|
||||
const ps = f.players.map((pid) => h.ps(pid));
|
||||
for (const p of ps) m.addBounty(p, { ...card, rounds: 2, multiRound: false });
|
||||
const before = ps.map((p) => p.pendingFunds);
|
||||
const lp0 = m.teamLp;
|
||||
const max = m.bossPool.maxHp;
|
||||
h.sched.advance(500);
|
||||
const gt = m._fieldElapsed(f);
|
||||
const by = { [f.players[0]]: max * 0.6, [f.players[1]]: max * 0.4 + 5 };
|
||||
const progress = { t: 'b.progress', battleId: f.battleId, gt, killed: 0, total: 0, bossDmg: max + 5, by, leaks, ...(leaksBy ? { leaksBy } : {}) };
|
||||
assert.equal(validateC2S(progress), null);
|
||||
assert.deepEqual(m.handle('p_0', progress), { ok: true });
|
||||
const entry = (pid) => ({ enemyKey: card.enemyKey, mods: null, lpr: 1, sourcePlayerId: pid, tag: null, counted: true, spawned: true });
|
||||
const res = { reason: 'cleared', time: gt, killed: 0, total: 0, errors: 0,
|
||||
perPlayer: Object.fromEntries(f.players.map((pid) => {
|
||||
const list = Array.from({ length: leaked[pid] || 0 }, () => entry(pid));
|
||||
return [pid, { killed: 0, total: 0, leaked: list, perfect: list.length === 0, layerGains: {}, coins: 0, damageDealt: by[pid], bossDamage: by[pid], 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 });
|
||||
h.drive(() => m._finalEnding != null || m.phase !== PHASE.FINAL_ASSAULT, { maxSteps: 2e5 });
|
||||
const out = { ending: m._finalEnding, source: f.resultSource, coin: card.coin, charged: lp0 - m.teamLp, paid: ps.map((p, i) => p.pendingFunds - before[i]), left: ps.map((p) => p.bounties.map((b) => b.roundsLeft)) };
|
||||
m.dispose();
|
||||
return out;
|
||||
}
|
||||
|
||||
test('Final Assault, a modified authority: LP charged by b.progress while both leaked lists are empty pays no perfect-payout card', () => {
|
||||
const r = bossPaid(9124, { leaks: 1, leaked: {} });
|
||||
assert.equal(r.ending, 'cleared');
|
||||
assert.equal(r.source, 'client');
|
||||
assert.equal(r.charged, 1, 'the server charged the team leak');
|
||||
assert.deepEqual(r.paid, [0, 0], 'a result that shows no leak after a charged one is not believed');
|
||||
assert.deepEqual(r.left, [[1], [1]], 'the boss battle still used one of the cards\' battles');
|
||||
});
|
||||
|
||||
test('Final Assault, a modified authority: one seat\'s reported leak cannot be moved to the other — nobody is paid', () => {
|
||||
// progress: the leak is p_0's; the result pins it on p_1 so that p_0 reads perfect (and p_1 cannot be paid anyway)
|
||||
const r = bossPaid(9125, { leaks: 1, leaksBy: { p_0: 1, p_1: 0 }, leaked: { p_1: 1 } });
|
||||
assert.equal(r.ending, 'cleared');
|
||||
assert.equal(r.charged, 1);
|
||||
assert.deepEqual(r.paid, [0, 0]);
|
||||
// …and with no split at all the result decides nothing either: the same leak pinned on p_1
|
||||
assert.deepEqual(bossPaid(9126, { leaks: 1, leaked: { p_1: 1 } }).paid, [0, 0]);
|
||||
});
|
||||
|
||||
test('Final Assault, honest authority: the seat whose half let nothing through is paid, the leaker is not; a leader\'s own LP effect costs nobody the card', () => {
|
||||
const leak = bossPaid(9127, { leaks: 1, leaksBy: { p_0: 0, p_1: 1 }, leaked: { p_1: 1 } });
|
||||
assert.equal(leak.charged, 1);
|
||||
assert.deepEqual(leak.paid, [leak.coin, 0]);
|
||||
// 2 LP of leader "扣除目标生命" effects (no leaked enemy): the report says so, the result agrees — both halves are perfect
|
||||
const fx = bossPaid(9128, { leaks: 2, leaksBy: { p_0: 0, p_1: 0 }, leaked: {} });
|
||||
assert.equal(fx.charged, 2);
|
||||
assert.deepEqual(fx.paid, [fx.coin, fx.coin]);
|
||||
// nothing charged, nothing leaked: as before
|
||||
assert.deepEqual(bossPaid(9129, { leaks: 0, leaked: {} }).paid.map((n, i, a) => n === a[0]), [true, true]);
|
||||
assert.ok(bossPaid(9130, { leaks: 0, leaked: {} }).paid.every((n) => n > 0));
|
||||
});
|
||||
|
||||
test('Final Assault, honest clients (client-side combat): a leak on one half costs that seat its perfect-payout card, not its partner; the leader\'s own LP effect costs nobody', () => {
|
||||
const h = makeMatch({ mode: 'coop', difficulty: 'FUNNY', humans: 2, seed: 9132, fake: true, clientCombat: true, instant: false,
|
||||
script: (b) => (b.kind === 'boss' ? { bossDps: 20000, leaks: { p_1: 1 }, leakEvents: [{ at: 1, lpr: 1 }], lpLossEvents: [{ at: 1.5, amount: 2 }] } : { duration: 2 }) }).start();
|
||||
const m = h.m;
|
||||
h.autoHumans();
|
||||
h.drive(() => m.phase === PHASE.FINAL_ASSAULT);
|
||||
const card = DATA.choices.cards.bounty.find((c) => c.payout === 'perfect' && m.gd.enemy(c.enemyKey));
|
||||
const [p0, p1] = [h.ps('p_0'), h.ps('p_1')];
|
||||
for (const p of [p0, p1]) m.addBounty(p, { ...card, rounds: 2, multiRound: false });
|
||||
const [a, b] = [p0.pendingFunds, p1.pendingFunds];
|
||||
const end = h.runToEnd();
|
||||
assert.equal(end.victory, true);
|
||||
const f = fields(h)[0];
|
||||
assert.equal(f.resultSource, 'client');
|
||||
assert.equal(f.lpReported, 3, 'leak lpr 1 + leader effect 2');
|
||||
assert.deepEqual(f.leaksBy, { p_1: 1 }, 'the authority split it: p_1\'s one leak (the other 2 LP are the leader\'s effect), nothing on p_0');
|
||||
assert.equal(p0.pendingFunds - a, card.coin, 'the seat that let nothing through is paid');
|
||||
assert.equal(p1.pendingFunds - b, 0, 'the seat that leaked is not');
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('b.progress carries `leaksBy`, the per-player split of a boss field\'s leak LP (shared/protocol.js)', () => {
|
||||
const base = { t: 'b.progress', battleId: 'a.1.1.b1', gt: 3, killed: 0, total: 0, leaks: 2, bossDmg: 10, by: { p_0: 10 } };
|
||||
assert.equal(validateC2S({ ...base, leaksBy: { p_0: 1, p_1: 0.5 } }), null);
|
||||
assert.equal(validateC2S({ ...base, leaksBy: {} }), null);
|
||||
assert.equal(validateC2S(base), null, 'optional: a client that never sends it still validates');
|
||||
for (const bad of [{ p_0: -1 }, { p_0: 'x' }, { p_0: 1e7 }, [1, 2], 'p_0', null, { 'a b': 1 }]) assert.notEqual(validateC2S({ ...base, leaksBy: bad }), null, JSON.stringify(bad));
|
||||
});
|
||||
|
||||
test('Final Assault: a \'cleared\' b.result whose report does NOT cover the pool is still handed over (the partner takes the field)', () => {
|
||||
const { h, m, f } = faWithForger(9123);
|
||||
const max = m.bossPool.maxHp;
|
||||
|
||||
@@ -4,7 +4,7 @@ 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 { DATA, makeMatch, checkInvariants, give, giveItem, chessOfTier } from './harness.js';
|
||||
import { bossPoolHp } from '../../server/match/finalAssault.js';
|
||||
|
||||
const STATUSES = ['acting', 'ready', 'deciding', 'combat', 'done', 'helping', 'left', 'dead'];
|
||||
@@ -51,6 +51,7 @@ test('m.private shape in PREP matches DESIGN §8.3 (pieces, slots, bonds, effect
|
||||
assert.ok(s.kind === 'chess' || s.kind === 'item');
|
||||
}
|
||||
assert.equal(priv.shop.rewardOffer, null);
|
||||
assert.equal(priv.personalChoice, null);
|
||||
assert.equal(priv.deployCap, 8);
|
||||
assert.ok(priv.effects.some((e) => e.iconKind === 'band' && e.name));
|
||||
assert.ok(priv.nextEnemies.length > 0);
|
||||
@@ -109,6 +110,45 @@ test('disconnect: the seat keeps playing; draft turns / prep auto-resolve at dea
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('教鞭: simultaneous private choices survive reconnect; foreign, duplicate and old IDs cannot add bounties', () => {
|
||||
const h = makeMatch({ mode: 'coop', humans: 2, spectators: ['watcher'], seed: 63, fake: true }).start();
|
||||
h.toPrep(1);
|
||||
const m = h.m, a = h.ps('p_0'), b = h.ps('p_1');
|
||||
for (const ps of [a, b]) {
|
||||
const art = giveItem(m, ps, 'chess_item_6_03_m');
|
||||
assert.deepEqual(ps.useArt(art.uid, 10, 5), { ok: true });
|
||||
}
|
||||
const aChoice = a.privateView().personalChoice, bChoice = b.privateView().personalChoice;
|
||||
assert.notEqual(aChoice.id, bChoice.id);
|
||||
assert.equal(m.handle('p_0', { t: 'g.choice', idx: 0, choiceId: bChoice.id }).error, ERR.BAD_TARGET);
|
||||
assert.equal(a.bounties.length + b.bounties.length, 0);
|
||||
m.onDisconnect('p_0');
|
||||
m.onReconnect('p_0');
|
||||
assert.deepEqual(h.lastTo('p_0', 'm.private').personalChoice, aChoice);
|
||||
m.flush(true);
|
||||
assert.deepEqual(h.lastTo('p_1', 'm.private').personalChoice, bChoice);
|
||||
assert.ok(!h.allTo('watcher', 'm.private').length);
|
||||
assert.ok(!JSON.stringify(m.publicView()).includes(aChoice.id));
|
||||
assert.ok(!JSON.stringify(m.prepFieldMeta(a)).includes(aChoice.id));
|
||||
assert.deepEqual(m.handle('p_0', { t: 'g.choice', idx: 1, choiceId: aChoice.id }), { ok: true });
|
||||
m.flush(true);
|
||||
assert.equal(h.lastTo('p_0', 'm.private').personalChoice, null);
|
||||
assert.equal(m.handle('p_0', { t: 'g.choice', idx: 1, choiceId: aChoice.id }).error, ERR.BAD_TARGET);
|
||||
const next = giveItem(m, a, 'chess_item_6_03_m');
|
||||
assert.deepEqual(a.useArt(next.uid, 10, 5), { ok: true });
|
||||
const nextId = a.personalChoice.id;
|
||||
assert.notEqual(nextId, aChoice.id);
|
||||
assert.equal(m.handle('p_0', { t: 'g.choice', idx: 0, choiceId: aChoice.id }).error, ERR.BAD_TARGET);
|
||||
assert.equal(a.personalChoice.id, nextId);
|
||||
assert.equal(a.bounties.length, 1);
|
||||
m.onDisconnect('p_1');
|
||||
h.sched.advance(m.deadline - h.sched.now() + 1);
|
||||
m.onReconnect('p_1');
|
||||
assert.equal(h.lastTo('p_1', 'm.private').personalChoice, null);
|
||||
assert.equal(b.bounties.length, 1, 'offline choice picked at the existing PREP deadline');
|
||||
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;
|
||||
|
||||
@@ -96,7 +96,8 @@ test('seats[].diy → PlayerState.diy (re-checked, frozen; bots none); the playe
|
||||
const want = diyRecord(T5A, SIEGE_PICK, { elite, data: DATA });
|
||||
assert.deepEqual([rec.name, rec.charId, rec.diyFor, rec.bonds, rec.garrisonIds, rec.tier, rec.price, rec.sellPrice], [want.name, SIEGE, T5A, ['victoriaShip'], [], 5, 4, 1]);
|
||||
assert.equal(rec.skill.skillId, 'skchr_siege_3');
|
||||
assert.deepEqual(p0.loadoutFor(h.m.gd.chess(id)), { skillIndex: 2, moduleId: elite ? 'uniequip_002_siege' : null }, 'the pick, never the loadout');
|
||||
// (0.2.2: an owned pick at the player's 潜能 / 练度 — none set: 6 / 3)
|
||||
assert.deepEqual(p0.loadoutFor(h.m.gd.chess(id)), { skillIndex: 2, moduleId: elite ? 'uniequip_002_siege' : null, potential: 6, cultivate: 3 }, 'the pick, never the loadout');
|
||||
assert.equal(h.m.gd.chess(id).name, DATA.chess[id].name, 'the match\'s own record is the slot');
|
||||
}
|
||||
const s1 = DATA.backups.units[SIEGE].forms['2/1/4/0'].skills.find((x) => x.index === 0).skillId;
|
||||
|
||||
@@ -150,6 +150,8 @@ test('机变 (co-op): 6 shared cards, random order, 30 s first / 16 s others, ti
|
||||
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(a, { t: 'g.choice', idx: 2, choiceId: 'old.choice.1' }).error, ERR.WRONG_PHASE);
|
||||
assert.deepEqual(m2.sp.picks, {}, 'personal IDs never fall through to the global draft');
|
||||
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);
|
||||
|
||||
@@ -214,6 +214,15 @@ test('ready gating: temp must be empty (a freed hand slot pulls the temp piece i
|
||||
assert.equal(a.hand[3], t);
|
||||
assert.ok(a.tempEmpty);
|
||||
assert.equal(a.privateView().canReady, true);
|
||||
const art = giveItem(m, a, 'chess_item_6_03_m', 'temp');
|
||||
assert.deepEqual(a.useArt(art.uid, 10, 5), { ok: true });
|
||||
assert.ok(a.tempEmpty);
|
||||
assert.equal(a.privateView().canReady, false);
|
||||
assert.deepEqual(a.setReady(true), { error: ERR.BAD_TARGET, detail: '请先完成教鞭选择' });
|
||||
assert.deepEqual(a.setReady(false), { ok: true });
|
||||
assert.deepEqual(m.handle('p_0', { t: 'g.choice', idx: 0, choiceId: a.personalChoice.id }), { ok: true });
|
||||
assert.equal(a.privateView().canReady, true);
|
||||
assert.equal(a.round.arts, 1);
|
||||
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 });
|
||||
@@ -235,13 +244,26 @@ test('prep deadline (co-op): unready players are auto-readied and their temp is
|
||||
const a = h.ps('p_0');
|
||||
const id = chessOfTier(1).find((x) => m.pool.has(x));
|
||||
const left = m.pool.left(id);
|
||||
const art = giveItem(m, a, 'chess_item_6_03_m');
|
||||
assert.deepEqual(a.useArt(art.uid, 10, 5), { ok: true });
|
||||
fillHand(a);
|
||||
give(m, a, id, 'temp');
|
||||
assert.ok(!a.tempEmpty, 'a full hand: it waits in temp');
|
||||
const endPrep = m.endPrep.bind(m);
|
||||
let checked = false;
|
||||
m.endPrep = () => {
|
||||
assert.equal(a.personalChoice, null, 'deadline picks before endPrep builds the battle');
|
||||
assert.equal(a.bounties.length, 1);
|
||||
assert.equal(a.round.arts, 1);
|
||||
assert.ok(a.ready && a.tempEmpty);
|
||||
checked = true;
|
||||
endPrep();
|
||||
};
|
||||
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(checked);
|
||||
assert.ok(a.tempEmpty);
|
||||
assert.equal(m.pool.left(id), left, 'temp chess sold back to the pool');
|
||||
checkInvariants(m);
|
||||
@@ -252,7 +274,12 @@ 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);
|
||||
const ps = h.ps('p_0');
|
||||
const art = giveItem(h.m, ps, 'chess_item_6_03_m');
|
||||
assert.deepEqual(ps.useArt(art.uid, 10, 5), { ok: true });
|
||||
const id = ps.personalChoice.id;
|
||||
h.sched.advance(10 * 60 * 1000);
|
||||
assert.equal(h.m.phase, PHASE.PREP);
|
||||
assert.equal(ps.personalChoice.id, id, 'no separate personal-choice timer');
|
||||
h.m.dispose();
|
||||
});
|
||||
@@ -17,6 +17,8 @@ import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { createBattleRunner, keepsState, compactHeld, HELD_MAX, EV_SLICE } from '../../public/js/battle/runner.js';
|
||||
import { SnapshotBuffer } from '../../public/js/render/interp.js';
|
||||
import { keepEarly, audioEarly } from '../../public/js/screens/game/early.js';
|
||||
import { AudioManager } from '../../public/js/audio.js';
|
||||
import { createStore, initialState } from '../../public/js/store.js';
|
||||
import * as specMod from '../../server/sim/spec.js';
|
||||
import { DataSource } from '../../server/sim/simdata.js';
|
||||
@@ -243,6 +245,56 @@ test('screens/game.js buffers the form fx with the state-bearing events it repla
|
||||
assert.match(src, /lastFieldRef\.current = field\.fieldId;\s+reentryRef\.current = null;/);
|
||||
});
|
||||
|
||||
// GitHub PR #292 (by @LimitlessHPPK) wanted the whole pre-entry buffer handed to the sound as well, so that a deploy-tick cast
|
||||
// ahead of its unit's spawn would be heard. The review kept the hold of such a cast (audio.js `pendingSkill`, which the live
|
||||
// path needs: `onEv` already forwards every list) and refused the replay: the buffer is every spawn, die, deploy, status and
|
||||
// skill since the field's m.field (up to 1500 of them), so the sound would play the field's old deaths, deploy lines and casts
|
||||
// all at once on entering.
|
||||
test('entering a field late: the view replays every buffered state event, the sound only the spawn tuples that teach it who is there', () => {
|
||||
const src = readFileSync(path.join(ROOT, 'public/js/screens/game.js'), 'utf8');
|
||||
assert.match(src, /view\.pushEvents\(early\);/, 'the view gets the buffered list');
|
||||
assert.match(src, /audio\.handleBattleEvents\(audioEarly\(early\)\);/, 'the sound gets audioEarly of it');
|
||||
assert.ok(!/audio\.handleBattleEvents\(early\)/.test(src), 'never the whole list');
|
||||
const stream = [
|
||||
['atk', 1, 2, 'none'], // stale cosmetics: dropped by keepEarly
|
||||
['dmg', 2, 40, 'phys'],
|
||||
['skill', 1, 1], // a deploy-tick cast, ahead of its unit's spawn
|
||||
['spawn', { id: 1, side: 'ally', kind: 'op', spine: 'char_a', skillIndex: 2 }],
|
||||
['spawn', { id: 2, side: 'enemy', kind: 'enemy', spine: 'enemy_x' }],
|
||||
['deploy', 1],
|
||||
['skill', 1, 0],
|
||||
['fx', 'form', 2, 3, { id: 2, form: 'translator_youling' }],
|
||||
['die', 2, 'killed'],
|
||||
];
|
||||
const early = stream.filter(keepEarly);
|
||||
assert.deepEqual(early.map((e) => e[0]), ['skill', 'spawn', 'spawn', 'deploy', 'skill', 'fx', 'die'], 'the pre-entry buffer keeps every state-bearing kind');
|
||||
assert.deepEqual(audioEarly(early).map((e) => e[0]), ['spawn', 'spawn'], 'the sound is told who is on the field, nothing else');
|
||||
assert.deepEqual(audioEarly(null), [], 'no buffer, nothing');
|
||||
// through a real AudioManager: the replay makes no sound at all (no old death, deploy line or cast) but tracks the units,
|
||||
// so the first live cast after entering sounds
|
||||
const vm = { audio: { sfx: { ui: {}, battle: {}, units: { char_a: { skill: '/s/a_skill.mp3', born: '/s/a_born.mp3' }, enemy_x: { die: '/s/x_die.mp3' } } }, voice: {} } };
|
||||
const a = new AudioManager({ win: null, getManifest: () => vm });
|
||||
a.ctx = {};
|
||||
const played = [];
|
||||
a._play = (url) => { played.push(url); };
|
||||
a.voice = () => { played.push('voice'); return true; };
|
||||
a.setFieldUnits([]);
|
||||
a.handleBattleEvents(audioEarly(early));
|
||||
assert.deepEqual(played, [], 'the replay is silent');
|
||||
assert.equal(a.units.size, 2, 'and has taught the sound both units');
|
||||
a.handleBattleEvents([['skill', 1, 1]]);
|
||||
assert.ok(played.includes('/s/a_skill.mp3'), 'the next live cast of a known unit sounds');
|
||||
// (the contrast: the whole list would have played the buffered death, the deploy and the cast on entering)
|
||||
const b = new AudioManager({ win: null, getManifest: () => vm });
|
||||
b.ctx = {};
|
||||
const heard = [];
|
||||
b._play = (url) => { heard.push(url); };
|
||||
b.voice = () => true;
|
||||
b.setFieldUnits([]);
|
||||
b.handleBattleEvents(early);
|
||||
assert.ok(heard.length >= 2, `the whole list would sound ${heard.length} old cues at once`);
|
||||
});
|
||||
|
||||
/**
|
||||
* The view's side too (render/app.js): the runner's frames go into the render engine's SnapshotBuffer, whose clock
|
||||
* follows the snapshots, and processEvents takes the due events with the 1.5 game s stale-cosmetic window — a form fx
|
||||
|
||||
@@ -56,6 +56,7 @@ test('a move of the partner reaches the player\'s m.private and a scout of eithe
|
||||
const h = bossPrep(4, 5102);
|
||||
const m = h.m;
|
||||
const b = place(h, 'p_1', chessOfTier(1)[1]);
|
||||
const held = give(m, h.ps('p_0'), chessOfTier(1)[2]); // into p_0's hand
|
||||
m.flush(true);
|
||||
// p_3 (eliminated) scouts p_0: the scout shows p_0's half and p_1's board on the other half
|
||||
h.ps('p_3').eliminate(13);
|
||||
@@ -63,6 +64,9 @@ test('a move of the partner reaches the player\'s m.private and a scout of eithe
|
||||
let scout = h.lastTo('p_3', 'm.field');
|
||||
assert.deepEqual([scout.kind, scout.side, scout.mate], ['boss', 'L', { playerId: 'p_1', side: 'R' }]);
|
||||
assert.ok(scout.units.some((u) => u.uid === b.piece.uid && u.x >= 10), 'the partner\'s piece on the right half');
|
||||
// the area tags survive the boss-half remap (the rows do not): the bond popup tells the bench from the board (GitHub #385)
|
||||
assert.equal(scout.units.find((u) => u.uid === b.piece.uid).area, 'board');
|
||||
assert.equal(scout.units.find((u) => u.uid === held.uid).area, 'hand', 'a held operator stays tagged as held');
|
||||
// p_1 moves its piece: p_0's m.private and p_3's scout of p_0 follow
|
||||
const ps1 = h.ps('p_1');
|
||||
const to = legalTileFor(m, ps1, b.piece.id, new Set([`${b.tile[0]},${b.tile[1]}`]));
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
// Community report of 2026-10-06, item 19: 「升级商店获得新的商店位时用新卡补上,而不是空着」. The official tutorial's 休整期
|
||||
// page says 「升级:消耗资金升级调度中心。升级后将出现更多的商品栏位、可调度干员以及新装备」: the upgrade itself opens the
|
||||
// new level's extra slot. Until 0.2.0 the shop kept its old slot count until the next roll (a refresh or the round start),
|
||||
// so a level-up from 1 to 2 (险境: 3 → 4 operator slots) or 3 to 4 (4 → 5) showed no new card. Now the upgrade adds the
|
||||
// extra slots at once, each with a new card drawn at the new level, and leaves the cards already shown where they are.
|
||||
// GitHub #332 / PR #333 by @2321Robin (the footage of an official 1→2 upgrade: bilibili BV1AXwuzdEys 1:39): a 调度中心
|
||||
// upgrade opens the new level's extra slots at once but EMPTY — the official shop shows the new slot, draws no card into
|
||||
// it, and fills it with the next roll (a manual refresh or the round start). The official texts only name the slots: the
|
||||
// tutorial's 休整期 page 「升级后将出现更多的商品栏位」, PRTS 帮助 「增加刷新栏位」. 0.2.0 drew a card into each new slot instead
|
||||
// (item 19 of the community report of 2026-10-06, 「升级商店获得新的商店位时用新卡补上,而不是空着」, one uncorroborated remark);
|
||||
// before that the shop kept its old slot count until the next roll. Either way the upgrade leaves the cards shown where they are.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { makeMatch, DATA } from './harness.js';
|
||||
@@ -11,7 +12,7 @@ import { GameData } from '../../server/match/gamedata.js';
|
||||
const ids = (slots) => slots.map((s) => (s ? `${s.kind}:${s.id}${s.sold ? '(sold)' : ''}` : null));
|
||||
const tierOf = (id) => DATA.chess[id]?.tier ?? 0;
|
||||
|
||||
test('a level-up opens the new level\'s operator slot at once with a new card; the cards shown stay in place', () => {
|
||||
test('a level-up opens the new level\'s operator slot EMPTY; the cards shown stay in place; a refresh fills it', () => {
|
||||
const h = makeMatch({ mode: 'solo', difficulty: 'NORMAL', seed: 7, fake: true }).start();
|
||||
h.toPrep(1);
|
||||
const ps = h.ps('p_0');
|
||||
@@ -20,35 +21,37 @@ test('a level-up opens the new level\'s operator slot at once with a new card; t
|
||||
const s1 = ps.shop.slots.slice();
|
||||
assert.equal(s1.length, 4);
|
||||
assert.deepEqual(h.m.handle('p_0', { t: 'g.levelUp' }), { ok: true });
|
||||
// level 2: 4 operator slots + the item slot — the three cards and the item kept (the same objects), one new card
|
||||
// level 2: 4 operator slots + the item slot — the three cards and the item kept (the same objects), the new slot empty
|
||||
assert.equal(ps.shop.level, 2);
|
||||
assert.deepEqual(ps.shop.layout, { chess: 4, item: 1 });
|
||||
assert.equal(ps.shop.slots.length, 5);
|
||||
for (let i = 0; i < 3; i++) assert.equal(ps.shop.slots[i], s1[i], `operator card ${i} kept`);
|
||||
assert.equal(ps.shop.slots[4], s1[3], 'the item card stays after the operator cards');
|
||||
const added = ps.shop.slots[3];
|
||||
assert.ok(added && added.kind === 'chess' && !added.sold, `a new operator card: ${JSON.stringify(added)}`);
|
||||
assert.ok(tierOf(added.id) >= 1 && tierOf(added.id) <= 2, `drawn at the new level (tier ${tierOf(added.id)})`);
|
||||
assert.equal(added.frozen, false);
|
||||
assert.equal(ps.shop.slots[3], null, 'the new slot is open and empty');
|
||||
// the player sees it right away (m.private shop)
|
||||
assert.deepEqual(ids(ps.privateView().shop.slots), ids(ps.shop.slots));
|
||||
// 2 → 3 keeps 4 operator slots: nothing rerolled
|
||||
// a manual refresh fills every slot at the new level (the empty one included)
|
||||
assert.deepEqual(h.m.handle('p_0', { t: 'g.refresh' }), { ok: true });
|
||||
assert.equal(ps.shop.slots.length, 5);
|
||||
for (const s of ps.shop.slots) assert.ok(s && !s.sold, 'every slot carries a fresh card after the refresh');
|
||||
for (const s of ps.shop.slots) if (s.kind === 'chess') assert.ok(tierOf(s.id) >= 1 && tierOf(s.id) <= 2, `drawn at level 2 (tier ${tierOf(s.id)})`);
|
||||
// 2 → 3 keeps 4 operator slots: nothing rerolled, nothing added
|
||||
const s2 = ps.shop.slots.slice();
|
||||
h.m.handle('p_0', { t: 'g.levelUp' });
|
||||
assert.equal(ps.shop.level, 3);
|
||||
assert.deepEqual(ps.shop.slots, s2, 'no extra slot at level 3, no reroll');
|
||||
// 3 → 4: the fifth operator slot
|
||||
// 3 → 4: the fifth operator slot, empty again
|
||||
h.m.handle('p_0', { t: 'g.levelUp' });
|
||||
assert.equal(ps.shop.level, 4);
|
||||
assert.deepEqual(ps.shop.layout, { chess: 5, item: 1 });
|
||||
for (let i = 0; i < 4; i++) assert.equal(ps.shop.slots[i], s2[i]);
|
||||
assert.equal(ps.shop.slots[5], s2[4]);
|
||||
assert.ok(ps.shop.slots[4]?.kind === 'chess' && tierOf(ps.shop.slots[4].id) <= 4);
|
||||
assert.equal(ps.shop.slots[4], null, 'the new slot is open and empty');
|
||||
h.invariants();
|
||||
h.m.dispose();
|
||||
});
|
||||
|
||||
test('a bought card stays sold; a frozen shop freezes the new card too [ASSUMED], and the round start keeps it', () => {
|
||||
test('a bought card stays sold; the empty new slot has nothing to freeze; the round start fills the shop to the new level', () => {
|
||||
const h = makeMatch({ mode: 'solo', difficulty: 'NORMAL', seed: 11, fake: true }).start();
|
||||
h.toPrep(1);
|
||||
const ps = h.ps('p_0');
|
||||
@@ -58,35 +61,43 @@ test('a bought card stays sold; a frozen shop freezes the new card too [ASSUMED]
|
||||
assert.deepEqual(h.m.handle('p_0', { t: 'g.freeze' }), { ok: true });
|
||||
h.m.handle('p_0', { t: 'g.levelUp' });
|
||||
assert.equal(ps.shop.slots[0].sold, true, 'the bought slot is not refilled');
|
||||
const added = ps.shop.slots[3];
|
||||
assert.ok(added && !added.sold && added.frozen === true, 'the new card follows the freeze toggle');
|
||||
assert.equal(ps.shop.slots[3], null, 'the new slot is open and empty — nothing to freeze');
|
||||
const keep = ps.shop.slots.filter((s) => s && !s.sold && s.frozen).map((s) => s.id);
|
||||
assert.equal(keep.length, 4, 'two operator cards, the new one and the item');
|
||||
// the next round start keeps every frozen card (in place) and rolls only the sold slot
|
||||
assert.equal(keep.length, 3, 'two operator cards and the item carry the freeze toggle');
|
||||
// the round start fills every slot of the new level: frozen cards kept in place, the sold and the empty slot rolled
|
||||
h.drive(() => h.m.phase === 'PREP' && h.m.round === 2);
|
||||
assert.deepEqual(ps.shop.slots.slice(1).map((s) => s.id), keep);
|
||||
assert.equal(ps.shop.slots.length, 5);
|
||||
for (const s of ps.shop.slots) assert.ok(s, 'the round start leaves no empty slot');
|
||||
assert.deepEqual(ps.shop.slots.slice(1).map((s) => s.id).slice(0, 2), keep.slice(0, 2), 'the frozen operator cards kept');
|
||||
assert.equal(ps.shop.slots[4].id, keep[2], 'the frozen item kept after the operator slots');
|
||||
assert.notEqual(ps.shop.slots[0].id, undefined, 'the sold slot rerolled');
|
||||
h.invariants();
|
||||
h.m.dispose();
|
||||
});
|
||||
|
||||
test('every mode\'s slot table: a level-up adds exactly the slots the new level has more of (标准: 3,4,4,4,4,5)', () => {
|
||||
test('every mode\'s slot table: a level-up opens exactly the new positions, empty (标准: 3,4,4,4,4,5)', () => {
|
||||
for (const [mode, difficulty] of [['solo', 'FUNNY'], ['solo', 'NORMAL'], ['coop', 'FUNNY'], ['coop', 'ABYSS']]) {
|
||||
const h = makeMatch({ mode, difficulty, seed: 3, fake: true, humans: 1, bots: mode === 'coop' ? 1 : 0 }).start();
|
||||
h.toPrep(1);
|
||||
const ps = h.ps('p_0');
|
||||
const gd = h.m.gd;
|
||||
ps.funds = 500;
|
||||
let empty = 0;
|
||||
for (let lv = 2; lv <= gd.maxShopLevel; lv++) {
|
||||
const before = ps.shop.slots.slice();
|
||||
assert.deepEqual(h.m.handle('p_0', { t: 'g.levelUp' }), { ok: true });
|
||||
const { chess, item } = gd.shopSlots(lv);
|
||||
assert.equal(ps.shop.slots.length, chess + item, `${mode} ${difficulty} L${lv}`);
|
||||
const grew = chess - gd.shopSlots(lv - 1).chess;
|
||||
const kept = ps.shop.slots.filter((s) => before.includes(s)).length;
|
||||
assert.equal(kept, before.length, `${mode} ${difficulty} L${lv}: every shown card kept`);
|
||||
assert.equal(ps.shop.slots.length - before.length, grew + (item - gd.shopSlots(lv - 1).item));
|
||||
const grew = (chess - gd.shopSlots(lv - 1).chess) + (item - gd.shopSlots(lv - 1).item);
|
||||
const kept = ps.shop.slots.filter((s) => s && before.includes(s)).length;
|
||||
assert.equal(kept, before.filter((s) => s).length, `${mode} ${difficulty} L${lv}: every shown card kept`);
|
||||
// the empty positions accumulate: nothing fills them between the upgrades (no refresh / round start here)
|
||||
empty += grew;
|
||||
assert.equal(ps.shop.slots.filter((s) => !s).length, empty, `${mode} ${difficulty} L${lv}: the new positions are open and empty`);
|
||||
}
|
||||
// one refresh fills everything at the final level
|
||||
assert.deepEqual(h.m.handle('p_0', { t: 'g.refresh' }), { ok: true });
|
||||
for (const s of ps.shop.slots) assert.ok(s, `${mode} ${difficulty}: the refresh leaves no empty slot`);
|
||||
h.invariants();
|
||||
h.m.dispose();
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ 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';
|
||||
import { DATA, makeMatch, checkInvariants, giveItem } from './harness.js';
|
||||
|
||||
const bossFields = () => FakeBattle.instances.filter((b) => b.kind === 'boss' || b.kind === 'hidden');
|
||||
|
||||
@@ -56,6 +56,90 @@ test('multi-round bounties spawn in the Final Assault on the owner\'s half, show
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
// A 战术特训 card pays its coins when the player's own battle is perfect (PRTS "若各自行动阶段就达成完美作战,获得N资金");
|
||||
// the Final Assault and the Hidden Core are such battles — they pay it with the kill coins, at the next income.
|
||||
test('perfect-payout bounties in the boss rounds: a 战术特训 card\'s coins join the kill coins at the next income (R14 → R15, then the Hidden Core)', () => {
|
||||
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 2, seed: 61, fake: true,
|
||||
script: (b) => (b.kind === 'boss' || b.kind === 'hidden' ? { bossDps: 1e9, coins: { p_0: 3 } } : {}) }).start();
|
||||
const m = h.m;
|
||||
h.toPrep(14);
|
||||
const ps = h.ps('p_0');
|
||||
const [card, second] = DATA.choices.cards.bounty.filter((c) => c.payout === 'perfect' && m.gd.enemy(c.enemyKey) && !m.gd.inactiveEnemies.has(c.enemyKey));
|
||||
m.addBounty(ps, card);
|
||||
const pending = ps.pendingFunds, gained = ps.stats.fundsGained;
|
||||
h.drive(() => m.phase === PHASE.FINAL_ASSAULT);
|
||||
m.hiddenLayerSum = 1e6; // the Hidden Core opens
|
||||
assert.ok(bossFields()[0].opts.spawns.some((s) => s.tag === 'bounty' && s.enemyKey === card.enemyKey && s.ownerPlayerId === ps.playerId), 'the card\'s enemies are in the boss battle');
|
||||
h.run(() => m.runner === null);
|
||||
assert.equal(ps.pendingFunds, pending + 3 + card.coin, 'kill coins + one card amount, regardless of its enemy count');
|
||||
assert.equal(ps.stats.fundsGained, gained + 3 + card.coin);
|
||||
const left = bountyBattles(card);
|
||||
assert.equal(ps.bounties.length, left > 1 ? 1 : 0, 'the boss battle used one of its battles');
|
||||
if (left > 1) assert.equal(ps.bounties[0].roundsLeft, left - 1);
|
||||
h.toPrep(15);
|
||||
assert.equal(ps.pendingFunds, 0);
|
||||
assert.equal(ps.funds, m.gd.income(15) + pending + 3 + card.coin, 'the base income is separate from the bounty funds');
|
||||
m.addBounty(ps, second);
|
||||
const owed = ps.bounties.filter((b) => b.card.payout === 'perfect').reduce((n, b) => n + b.card.coin, 0);
|
||||
h.drive(() => m.phase === PHASE.HIDDEN_CORE);
|
||||
assert.ok(bossFields().at(-1).opts.spawns.some((s) => s.tag === 'bounty' && s.enemyKey === second.enemyKey), 'and in the Hidden Core');
|
||||
h.runToEnd();
|
||||
assert.equal(ps.pendingFunds, 3 + owed, 'the Hidden Core pays by the same rule');
|
||||
assert.equal(ps.bounties.length, 0, 'every bounty used its battles');
|
||||
checkInvariants(m);
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('教鞭 in the boss rounds (R14, R15): the card the owner chose spawns in the boss battle and pays at the next income, the Hidden Core takes another choice', () => {
|
||||
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 2, seed: 61, fake: true,
|
||||
script: (b) => (b.kind === 'boss' || b.kind === 'hidden' ? { bossDps: 1e9, coins: { p_0: 3 } } : {}) }).start();
|
||||
const m = h.m;
|
||||
h.toPrep(14);
|
||||
const ps = h.ps('p_0');
|
||||
const art = giveItem(m, ps, 'chess_item_6_03_m');
|
||||
assert.deepEqual(ps.useArt(art.uid, 10, 5), { ok: true });
|
||||
assert.equal(ps.bounties.length, 0, 'nothing is applied before the owner confirms');
|
||||
assert.equal(ps.setReady(true).error, 'BAD_TARGET', 'Ready waits for the choice');
|
||||
const card = ps.personalChoice.cards[2];
|
||||
assert.deepEqual(m.handle(ps.playerId, { t: 'g.choice', idx: 2, choiceId: ps.personalChoice.id }), { ok: true });
|
||||
assert.equal(ps.bounties[0].card.effectId, card.effectId, 'the card of the tapped slot');
|
||||
const pending = ps.pendingFunds;
|
||||
h.drive(() => m.phase === PHASE.FINAL_ASSAULT);
|
||||
m.hiddenLayerSum = 1e6;
|
||||
assert.ok(bossFields()[0].opts.spawns.some((s) => s.tag === 'bounty' && s.enemyKey === card.enemyKey && s.ownerPlayerId === ps.playerId), 'the chosen card\'s enemies come in the boss battle');
|
||||
h.run(() => m.runner === null);
|
||||
assert.equal(ps.pendingFunds, pending + 3 + card.coin, 'kill coins + the chosen card\'s coins');
|
||||
h.toPrep(15);
|
||||
assert.equal(ps.funds, m.gd.income(15) + pending + 3 + card.coin, 'paid at the R15 income, apart from the base income');
|
||||
const hiddenArt = giveItem(m, ps, 'chess_item_6_03_m');
|
||||
assert.deepEqual(ps.useArt(hiddenArt.uid, 10, 5), { ok: true });
|
||||
const hiddenCard = ps.personalChoice.cards[0];
|
||||
assert.deepEqual(m.handle(ps.playerId, { t: 'g.choice', idx: 0, choiceId: ps.personalChoice.id }), { ok: true });
|
||||
const owed = ps.bounties.filter((b) => b.card.payout === 'perfect').reduce((n, b) => n + b.card.coin, 0);
|
||||
h.drive(() => m.phase === PHASE.HIDDEN_CORE);
|
||||
assert.ok(bossFields().at(-1).opts.spawns.some((s) => s.tag === 'bounty' && s.enemyKey === hiddenCard.enemyKey));
|
||||
h.runToEnd();
|
||||
assert.equal(ps.pendingFunds, 3 + owed, 'the Hidden Core pays by the same rule');
|
||||
checkInvariants(m);
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('boss victory without a perfect own result keeps the kill coins but gives no perfect-card funds', () => {
|
||||
const h = makeMatch({ mode: 'coop', humans: 2, seed: 61, fake: true,
|
||||
script: (b) => (b.kind === 'boss' ? { bossDps: 1e9, leaks: { p_0: 1 }, coins: { p_0: 3 } } : {}) }).start();
|
||||
h.toPrep(14);
|
||||
const m = h.m, ps = h.ps('p_0');
|
||||
const card = DATA.choices.cards.bounty.find((c) => c.payout === 'perfect' && !m.gd.inactiveEnemies.has(c.enemyKey));
|
||||
m.addBounty(ps, { ...card, rounds: 2, multiRound: false });
|
||||
const gained = ps.stats.fundsGained;
|
||||
const result = h.drive(() => h.ended != null);
|
||||
assert.ok(result && h.ended.victory);
|
||||
assert.equal(ps.pendingFunds, 3);
|
||||
assert.equal(ps.stats.fundsGained, gained + 3);
|
||||
assert.equal(ps.bounties[0].roundsLeft, 1);
|
||||
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;
|
||||
|
||||
@@ -397,6 +397,22 @@ test('collectViolations reports corrupted state (funds, pool accounting, duplica
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('collectViolations: a 教鞭 choice belongs to its owner\'s open prep only', () => {
|
||||
const { m, ps } = prepSolo({ seed: 922 });
|
||||
const art = giveItem(m, ps, 'chess_item_6_03_m');
|
||||
assert.deepEqual(ps.useArt(art.uid, 10, 5), { ok: true });
|
||||
assert.deepEqual(collectViolations(m), [], 'an open choice in its own prep is fine');
|
||||
const pc = ps.personalChoice;
|
||||
pc.round = m.round - 1;
|
||||
assert.match(collectViolations(m).join('\n'), /personal choice outside its own prep/, 'a choice left from an earlier round');
|
||||
pc.round = m.round;
|
||||
pc.cards = [pc.cards[0], pc.cards[0]];
|
||||
assert.match(collectViolations(m).join('\n'), /personal choice of 2 cards/, 'the same card twice');
|
||||
ps.personalChoice = null;
|
||||
assert.deepEqual(collectViolations(m), []);
|
||||
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;
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
// 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).
|
||||
// botSliceMs (bot rehearsal slice budget; default: unbounded in virtual time), aiPicksLast (the room option
|
||||
// AI 队友最后选择: the drafts order every human seat before every AI seat).
|
||||
// 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
|
||||
@@ -77,6 +78,7 @@ export function makeMatch(o = {}) {
|
||||
clientCombat: o.clientCombat ?? false,
|
||||
verify: o.verify ?? 'off',
|
||||
headlessSliceMs: o.headlessSliceMs,
|
||||
aiPicksLast: o.aiPicksLast,
|
||||
});
|
||||
const m = h.m;
|
||||
if (m.clientCombat && o.clients !== false) {
|
||||
@@ -122,6 +124,7 @@ export function makeMatch(o = {}) {
|
||||
if (idx != null) m.handle(ps.playerId, { t: 'g.choice', idx });
|
||||
}
|
||||
if (ready && m.phase === 'PREP' && ps.alive && !ps.ready) {
|
||||
if (ps.personalChoice) m.handle(ps.playerId, { t: 'g.choice', idx: 0, choiceId: ps.personalChoice.id });
|
||||
if (!ps.tempEmpty) ps.resolveTemp();
|
||||
m.handle(ps.playerId, { t: 'g.ready', ready: true });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
// test/match/hud-capsule-resolved.test.js — the HUD capsule's server half (PR #157): m.public.fields[].progress carries
|
||||
// the field's own `resolved` (knocked out + leaked among the enemies the round scheduled), and
|
||||
// * a bot / server-run field has no authority reporting b.progress — its capsule must read the battle's own counters
|
||||
// (`timelineSample` → Battle.resolved), not the report-driven `progress.leaks`, which stays 0 forever (maintainer
|
||||
// review point ①);
|
||||
// * `progress.resolved` starts at **null**, never 0: only a real reported number is adopted, so the client's
|
||||
// `resolved ?? killed` fallback keeps working (a `Number(null) === 0` would defeat it) — and a reported 0 is a real
|
||||
// value, told apart from "not reported" (review point ②);
|
||||
// * a finished two-half field (联防 with two helpers, the boss pair) publishes the FIELD's own `resolved` — the capsule's
|
||||
// number — never the sum of the players' own clamps: an enemy that spawns on one half and leaks on the other is
|
||||
// billed to one player's `total` and to the other's `leakedInTotal`, so both players' min(total, …) read 0 for it
|
||||
// while the field resolved it (found reviewing the capsule port, 2026-10-08).
|
||||
// Run: node --test test/match/hud-capsule-resolved.test.js
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { PHASE } from '../../shared/constants.js';
|
||||
import { makeMatch, DATA } from './harness.js';
|
||||
import { timelineSample, timelineAt, validateClientResult, runHeadless } from '../../server/match/fields.js';
|
||||
import { buildBattleSpec, createBattleFromSpec, compactResult, battleProgress } from '../../server/sim/spec.js';
|
||||
import { GameData } from '../../server/match/gamedata.js';
|
||||
import { MatchViews } from '../../server/match/match/views.js';
|
||||
import { makeBattle } from '../helpers/battleHarness.js';
|
||||
import { teammateProgress } from '../../public/js/battle/observe.js';
|
||||
import { uniteLeftMark } from '../../public/js/battle/runner.js';
|
||||
|
||||
const fields = (h) => h.m.fields;
|
||||
const progressOf = (m, fieldId) => (m.publicView().fields.find((f) => f.fieldId === fieldId) || {}).progress;
|
||||
|
||||
test('① 机器人战场 (mode server, 无上报): 胶囊读该战斗自己的计数器,不是停在 0 的 progress.leaks', () => {
|
||||
// no FakeBattle: the bot's field runs the real sim on the server (HeadlessJob + timeline), and nobody reports b.progress
|
||||
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 1, bots: 1, seed: 7, instant: false, clientCombat: true, clients: false }).start();
|
||||
const m = h.m;
|
||||
h.toPrep(1);
|
||||
m.handle('p_0', { t: 'g.ready', ready: true });
|
||||
h.run(() => m.phase === PHASE.COMBAT);
|
||||
const f = fields(h).find((x) => x.fieldId === 'n:ai_0');
|
||||
assert.equal(f.mode, 'server', 'the bot field runs on the server');
|
||||
assert.equal(f.authority, null, 'no authority ever reports it');
|
||||
assert.ok(f.timeline && f.timeline.length > 1, 'it has a progress timeline');
|
||||
assert.equal(f.progress.leaks, 0, 'nothing was reported: progress.leaks stays 0');
|
||||
assert.equal(f.progress.resolved, null, 'and progress.resolved stays null (never a fabricated 0)');
|
||||
|
||||
let filled = 0;
|
||||
for (let i = 0; i < 20 && !f.done; i++) {
|
||||
h.sched.advance(2000);
|
||||
const pr = progressOf(m, 'n:ai_0');
|
||||
// the capsule reads the battle's own counters, sampled on the field clock
|
||||
assert.equal(pr.resolved, timelineAt(f.timeline, m._fieldElapsed(f))[3], 'resolved = the timeline sample');
|
||||
assert.ok(pr.resolved != null && pr.resolved <= pr.total, 'a real number, never above the denominator');
|
||||
if (pr.resolved > 0) { filled = pr.resolved; break; }
|
||||
}
|
||||
assert.ok(filled > 0, 'the bot field\'s capsule fills up (a report-driven number would have stayed at 0)');
|
||||
// the sample is composed of the battle's own two counters — the field's own scheduled enemies that were resolved
|
||||
assert.equal(timelineSample(f.battle)[3], Math.min(f.battle.total, f.battle.killedInTotal + f.battle.leakedInTotal));
|
||||
assert.equal(progressOf(m, 'n:ai_0').resolved, timelineAt(f.timeline, m._fieldElapsed(f))[3], 'clamped by the field clock');
|
||||
});
|
||||
|
||||
test('① 一场只漏不杀的机器人战场: resolved 由 leakedInTotal 顶起来 (不是 0)', () => {
|
||||
// three walkers, nobody to stop them: the field's own enemies leak — leakedInTotal is what the capsule must show
|
||||
const h = makeBattle({ seed: 5, enemies: [{ key: 'enemy_1005_yokai', count: 3 }], content: 'full' });
|
||||
h.runToEnd(120);
|
||||
assert.equal(h.b.total, 3);
|
||||
assert.equal(h.b.leakedInTotal, 3, 'the field leaked all three of its own enemies');
|
||||
assert.equal(h.b.leakedCount, 3, 'counted leaks — the LP charge, unchanged from master');
|
||||
const sample = timelineSample(h.b);
|
||||
assert.equal(sample[3], 3, 'the timeline sample carries the capsule numerator');
|
||||
assert.equal(battleProgress(h.b).resolved, 3, 'b.progress too (what a teammate HUD reads)');
|
||||
assert.equal(battleProgress(h.b).total, 3);
|
||||
assert.equal(battleProgress(h.b).killed, 0);
|
||||
});
|
||||
|
||||
test('② 上报没有 resolved ⇒ progress.resolved = null,客户端回落到 killed;上报 resolved: 0 ⇒ 采用 0', () => {
|
||||
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 human = fields(h).find((x) => x.fieldId === 'n:p_0');
|
||||
assert.equal(human.mode, 'client');
|
||||
assert.equal(human.progress.resolved, null, 'a fresh field: resolved unknown, not 0');
|
||||
|
||||
// a report without `resolved` (an older client / a field that cannot count it) leaves it unknown
|
||||
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);
|
||||
assert.deepEqual(progressOf(m, 'n:p_0'), { killed: 3, resolved: null, total: 9, done: false });
|
||||
// … and the teammate HUD falls back to `killed` (master's display: resolved ?? killed)
|
||||
const mine = (pub) => teammateProgress(pub, 'p_1').find((x) => x.playerId === 'p_0');
|
||||
assert.deepEqual(mine(m.publicView()), { playerId: 'p_0', name: 'P0', isBot: false, killed: 3, resolved: 3, total: 9, done: false });
|
||||
|
||||
// a reported 0 is a real value: adopted, and the HUD shows 0 (not the fallback)
|
||||
assert.deepEqual(m.handle('p_0', { t: 'b.progress', battleId: human.battleId, gt: 5, killed: 3, total: 9, leaks: 0, resolved: 0 }), { ok: true });
|
||||
m.flush(true);
|
||||
assert.deepEqual(progressOf(m, 'n:p_0'), { killed: 3, resolved: 0, total: 9, done: false });
|
||||
assert.deepEqual(mine(m.publicView()), { playerId: 'p_0', name: 'P0', isBot: false, killed: 3, resolved: 0, total: 9, done: false });
|
||||
});
|
||||
|
||||
test('a done field\'s m.public progress carries the reported resolved (never a fabricated one)', () => {
|
||||
const h = makeMatch({ mode: 'coop', humans: 2, bots: 1, seed: 9104, fake: true, clientCombat: true, clients: false, script: () => ({ duration: 2 }) }).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 human = fields(h).find((x) => x.fieldId === 'n:p_0');
|
||||
// a result carrying the capsule numbers: 5 counted knock-outs (a split child included), 3 of the round's own resolved
|
||||
const result = {
|
||||
reason: 'cleared', time: 6, killed: 5, total: 3, resolved: 3,
|
||||
perPlayer: { p_0: { killed: 5, total: 3, resolved: 3, leaked: [], perfect: true, layerGains: {}, coins: 0, damageDealt: 0, bossDamage: 0, healingDone: 0, deaths: 0, unitsEnd: [], unitStats: [] } },
|
||||
errors: 0,
|
||||
};
|
||||
assert.deepEqual(m.handle('p_0', { t: 'b.result', battleId: human.battleId, result }), { ok: true });
|
||||
m.flush(true);
|
||||
const pr = progressOf(m, 'n:p_0');
|
||||
assert.equal(pr.done, true);
|
||||
assert.equal(pr.killed, 5, '`killed` keeps the counted reading (may exceed the denominator)');
|
||||
assert.equal(pr.total, 3, 'the denominator is the round\'s own list');
|
||||
assert.equal(pr.resolved, 3, 'the capsule numerator of the finished field');
|
||||
});
|
||||
|
||||
test('a 联防 battle\'s live-counter mark still moves for a spawn — `total` no longer does (a split child / a summon is outside the capsule)', () => {
|
||||
// the runner re-reads the leakers' enemies still standing (uniteLeft) only when this mark moves; before the capsule
|
||||
// counted the round's own enemies only, every counted spawn raised `total` and so moved it
|
||||
const h = makeBattle({ seed: 41, autoFinish: false, timeLimit: 60, enemies: [{ key: 'enemy_1195_sfyin', pos: [9, 6] }] });
|
||||
h.step();
|
||||
const m0 = uniteLeftMark(h.b);
|
||||
assert.equal(h.b.total, 1);
|
||||
assert.equal(uniteLeftMark(h.b), m0, 'nothing happened: the same mark');
|
||||
const child = h.spawn('enemy_1196_msfyin', { pos: [9, 4] }); // a runtime spawn: counted, outside the denominator
|
||||
assert.ok(child && child.counted && !child.inTotal);
|
||||
assert.equal(h.b.total, 1, 'the denominator did not move');
|
||||
assert.notEqual(uniteLeftMark(h.b), m0, 'the mark did: the spawn is a standing enemy the counter must read');
|
||||
const m1 = uniteLeftMark(h.b);
|
||||
h.b.kill(child, null);
|
||||
assert.notEqual(uniteLeftMark(h.b), m1, 'a knock-out moves it');
|
||||
});
|
||||
|
||||
// ---- an enemy that crosses the midline of a two-half field -------------------------------------------------------------
|
||||
//
|
||||
// The capsule counts per FIELD (Battle.resolved = min(total, killedInTotal + leakedInTotal)), but the sim also keeps every
|
||||
// player's own counters: `total` and a knock-out belong to the half the enemy spawned on, a leak to the half whose tile it
|
||||
// reached (deploy.js _recordLeak). A lane that runs through both halves — the 联防 lane (col 18 → col 2), the boss pair's
|
||||
// crossing routes — bills one enemy's `total` to one player and its leak to the other, so each player's own
|
||||
// min(total, killedInTotal + leakedInTotal) clamps it away: 0 and 0 for an enemy the field resolved. A finished field must
|
||||
// publish the field's own number (what the capsule showed), never the sum of the players' clamps.
|
||||
|
||||
const gd = new GameData(DATA, 'mode_multi_hard');
|
||||
/** m.public.fields[].progress of a finished client-combat field (the done branch of Match._fieldProgress). */
|
||||
const doneView = (result, progress = { killed: 0, total: 0, leaks: 0, resolved: null }) => MatchViews.prototype._fieldProgress.call({}, { cc: true, done: true, result, progress });
|
||||
const clone = (v) => JSON.parse(JSON.stringify(v));
|
||||
|
||||
/** A 联防 spec of two helpers who fielded nothing, and one escaped enemy that walks the whole lane (col 18 → col 2). */
|
||||
function uniteCrossSpec() {
|
||||
return buildBattleSpec({
|
||||
battleId: 'mid-u', fieldId: 'u', kind: 'unite', seed: 11, modeId: 'mode_multi_normal', round: 1, stageId: 'act1autochess_m02',
|
||||
rect: { r0: 9, r1: 12, c0: 0, c1: 20 }, timeLimit: 90,
|
||||
players: [
|
||||
{ playerId: 'p_1', seat: 1, side: 'L', colOffset: 8, units: [], bonds: {} }, // the half the lane starts on (col 18)
|
||||
{ playerId: 'p_2', seat: 2, side: 'L', colOffset: 0, units: [], bonds: {} }, // the half it leaves on (col 2)
|
||||
],
|
||||
routes: [{ motion: 'WALK', start: [9, 18], end: [9, 2], checkpoints: [] }],
|
||||
spawns: [{ time: 1, enemyKey: 'enemy_1007_slime', routeIndex: 0, count: 1, sourcePlayerId: 'p_0', mods: { hpMul: 0.8, atkMul: 0.8, speedMul: 1, slot: 'N' } }],
|
||||
flags: { layerGainsEnabled: false, dpInit: 10, dpPerSec: 1, dpMax: 99 },
|
||||
});
|
||||
}
|
||||
const newBattle = (spec) => createBattleFromSpec(spec, null, { recordEvents: false, quiet: true });
|
||||
|
||||
test('联防 whose escaped enemies enter on one helper\'s half and leak on the other\'s: the finished field publishes the capsule\'s own 4/4, not 0/4', () => {
|
||||
let ran = null;
|
||||
// every client runs the 联防 spec with the REAL sim (the fake battles only stand in for the normal round); the helpers
|
||||
// fielded nothing, so the leaker's four escaped enemies walk the whole lane and leak on the far half
|
||||
const realUnite = (result, spec) => {
|
||||
if (spec.kind !== 'unite') return result;
|
||||
const b = createBattleFromSpec(spec, h.m.ds, { recordEvents: false, quiet: true });
|
||||
const res = b.runToEnd(4000);
|
||||
ran = { res, capsule: battleProgress(b) };
|
||||
return compactResult(res);
|
||||
};
|
||||
const h = makeMatch({ mode: 'coop', humans: 3, seed: 9111, fake: true, clientCombat: true,
|
||||
script: (b) => (b.kind === 'normal' ? { leaks: { p_0: 4 } } : {}),
|
||||
perPlayer: Object.fromEntries(['p_0', 'p_1', 'p_2'].map((pid) => [pid, { tamper: realUnite }])) }).start();
|
||||
const m = h.m;
|
||||
h.toPrep(1);
|
||||
const unite = () => fields(h).find((x) => x.fieldId === 'u');
|
||||
assert.ok(h.drive(() => (unite() && unite().done) || h.ended != null) && unite().done, 'the 联防 field finished');
|
||||
const f = unite();
|
||||
assert.equal(f.resultSource, 'client', 'the authority\'s own result was accepted');
|
||||
assert.ok(ran, 'the authority ran the 联防 spec with the real sim');
|
||||
|
||||
// the premise: the enemies crossed the midline — `total` on the half they entered, the leaks on the half they left
|
||||
const per = Object.values(ran.res.perPlayer);
|
||||
assert.deepEqual(per.map((p) => p.total).sort(), [0, 4], 'every total is billed to the half the enemies spawned on');
|
||||
assert.deepEqual(per.map((p) => p.leaked.length).sort(), [0, 4], 'every leak to the half they reached');
|
||||
assert.deepEqual(per.map((p) => p.resolved), [0, 0], 'so each player\'s own min(total, …) reads 0');
|
||||
assert.equal(ran.capsule.resolved, 4, 'while the field\'s capsule read 4/4');
|
||||
assert.equal(ran.res.resolved, 4);
|
||||
|
||||
// what the server keeps and publishes is the field's number
|
||||
assert.equal(f.result.resolved, 4, 'the validated result carries the field-level numerator');
|
||||
m.flush(true);
|
||||
assert.deepEqual(progressOf(m, 'u'), { killed: 0, resolved: 4, total: 4, done: true }, 'm.public: the capsule\'s own 4/4');
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('the boss pair: an enemy spawned on one half that leaks on the other half\'s goal reads 1/1 in the published progress too', () => {
|
||||
const PAIR = [
|
||||
{ playerId: 'L', seat: 0, side: 'L', colOffset: 0, units: [], bonds: {} },
|
||||
{ playerId: 'R', seat: 1, side: 'R', colOffset: 8, units: [], bonds: {} },
|
||||
];
|
||||
// route 3 of the flat boss stage: the centre gate (col 10, the left half's) → the right goal (col 18, the right half's)
|
||||
const h = makeBattle({ kind: 'boss', players: PAIR, enemies: [{ key: 'enemy_1007_slime', time: 0, route: 3 }], timeLimit: Infinity, content: 'none', autoFinish: true });
|
||||
h.runToEnd(120);
|
||||
const res = h.result();
|
||||
assert.equal(res.reason, 'cleared');
|
||||
assert.equal(res.perPlayer.L.total, 1, 'billed to the half it spawned on');
|
||||
assert.equal(res.perPlayer.R.leaked.length, 1, 'leaked on the other half');
|
||||
assert.deepEqual([res.perPlayer.L.resolved, res.perPlayer.R.resolved], [0, 0], 'each player\'s own clamp reads 0');
|
||||
assert.equal(res.resolved, 1, 'the field resolved it');
|
||||
|
||||
const spec = buildBattleSpec({ battleId: 'mid-b', fieldId: 'b:pair', kind: 'boss', seed: 3, round: 8, players: PAIR,
|
||||
spawns: [{ time: 0, enemyKey: 'enemy_1007_slime', routeIndex: 3, count: 1 }], flags: { layerGainsEnabled: false } });
|
||||
const v = validateClientResult(spec, compactResult(res), { gd });
|
||||
assert.ok(v.ok, v.reason);
|
||||
assert.equal(v.result.resolved, 1);
|
||||
assert.deepEqual(doneView(v.result), { killed: 0, resolved: 1, total: 1, done: true });
|
||||
});
|
||||
|
||||
test('a server-run 联防 field (a bot, a takeover) reads the same: Battle.result()\'s field-level number, and so does its timeline', () => {
|
||||
const spec = uniteCrossSpec();
|
||||
const run = runHeadless(newBattle(spec), { players: spec.players.map((p) => p.playerId) });
|
||||
const res = run.result;
|
||||
assert.equal(res.reason, 'cleared');
|
||||
assert.deepEqual(Object.values(res.perPlayer).map((p) => [p.total, p.leaked.length, p.resolved]).sort(), [[0, 1, 0], [1, 0, 0]], 'the lane crossed the midline');
|
||||
assert.equal(res.resolved, 1);
|
||||
assert.deepEqual(doneView(res), { killed: 0, resolved: 1, total: 1, done: true }, 'the done field of a server run');
|
||||
assert.equal(run.timeline[run.timeline.length - 1][3], 1, 'the live timeline sample of that field read 1 too');
|
||||
// SP_VERIFY compares a client result with the server's run: both go through the same validation
|
||||
assert.equal(validateClientResult(spec, compactResult(res), { gd }).result.resolved, 1);
|
||||
});
|
||||
|
||||
test('validateClientResult: the field-level resolved is kept as reported (clamped to the field\'s total); the players\' own numbers are only the fallback', () => {
|
||||
const spec = uniteCrossSpec();
|
||||
const raw = compactResult(newBattle(spec).runToEnd(200));
|
||||
assert.equal(raw.resolved, 1);
|
||||
const checked = (edit) => {
|
||||
const r = clone(raw);
|
||||
edit(r);
|
||||
const v = validateClientResult(spec, r, { gd });
|
||||
assert.ok(v.ok, v.reason);
|
||||
return v.result;
|
||||
};
|
||||
assert.equal(checked(() => {}).resolved, 1, 'as the client reported it (the per-player numbers are both 0 here)');
|
||||
assert.equal(checked((r) => { r.resolved = 0; }).resolved, 0, 'a reported 0 is a value');
|
||||
assert.equal(checked((r) => { r.resolved = 99; }).resolved, 1, 'never above the field\'s total');
|
||||
// an older client (or a hand-made result) that reports no field-level number: the players' own, when every one has one
|
||||
assert.equal(checked((r) => { delete r.resolved; r.perPlayer.p_1.resolved = 1; }).resolved, 1, 'the fallback is the sum of the players\' own numbers');
|
||||
assert.equal(checked((r) => { delete r.resolved; r.perPlayer.p_1.resolved = 5; r.perPlayer.p_2.resolved = 5; }).resolved, 1, 'the fallback too is clamped to the total');
|
||||
// a number that is not a non-negative integer counts as not reported
|
||||
for (const junk of [-1, 0.5, '1', null, NaN]) assert.equal(checked((r) => { r.resolved = junk; r.perPlayer.p_1.resolved = 1; }).resolved, 1, `junk ${String(junk)} → the fallback`);
|
||||
// nothing reported anywhere, or one player without a number: absent — the teammate UI falls back to `killed`, never to a fabricated 0
|
||||
assert.equal('resolved' in checked((r) => { delete r.resolved; delete r.perPlayer.p_1.resolved; delete r.perPlayer.p_2.resolved; }), false);
|
||||
assert.equal('resolved' in checked((r) => { delete r.resolved; delete r.perPlayer.p_2.resolved; }), false);
|
||||
assert.equal(doneView(checked((r) => { delete r.resolved; delete r.perPlayer.p_1.resolved; delete r.perPlayer.p_2.resolved; })).resolved, null, 'm.public: null, not 0');
|
||||
});
|
||||
|
||||
test('a finished field\'s progress: the result\'s field-level numerator wins, the players\' own are the fallback, a synthetic result keeps the field\'s live report', () => {
|
||||
const pp = (o) => ({ killed: 0, total: 3, leaked: [], perfect: true, layerGains: {}, coins: 0, ...o });
|
||||
// the field-level number wins over the players' own (a crossing field sums to less than it resolved)
|
||||
assert.deepEqual(doneView({ perPlayer: { a: pp({ total: 3, resolved: 0 }), b: pp({ total: 0, resolved: 0 }) }, resolved: 2 }), { killed: 0, resolved: 2, total: 3, done: true });
|
||||
assert.equal(doneView({ perPlayer: { a: pp({ resolved: 0 }) }, resolved: 9 }).resolved, 3, 'clamped to the published total');
|
||||
assert.equal(doneView({ perPlayer: { a: pp({ resolved: 0 }) }, resolved: 0 }).resolved, 0, 'a reported 0 is a value');
|
||||
// no field-level number: the sum of the players' own when every one reported, else unknown (never a fabricated 0)
|
||||
assert.equal(doneView({ perPlayer: { a: pp({ resolved: 1 }), b: pp({ resolved: 2 }) } }).resolved, 3);
|
||||
assert.equal(doneView({ perPlayer: { a: pp({ resolved: 1 }), b: pp({}) } }).resolved, null);
|
||||
assert.equal(doneView({ perPlayer: { a: pp({}) } }).resolved, null);
|
||||
// a synthetic result (the field could not run: zeroes everywhere) shows the last live report instead
|
||||
const synthetic = { synthetic: true, resolved: 0, perPlayer: { a: pp({ total: 0, resolved: 0 }) } };
|
||||
assert.deepEqual(doneView(synthetic, { killed: 2, total: 5, leaks: 0, resolved: 3 }), { killed: 2, resolved: 3, total: 5, done: true });
|
||||
assert.deepEqual(doneView(synthetic, { killed: 2, total: 5, leaks: 0, resolved: null }), { killed: 2, resolved: null, total: 5, done: true });
|
||||
});
|
||||
@@ -202,6 +202,69 @@ test('co-op spectator over websockets: watches the real match like an eliminated
|
||||
assert.deepEqual(errors, []);
|
||||
});
|
||||
|
||||
// GitHub #120 (PR #120 by @salt-fishes — the in-match 观战席 list, ui/hud.js SpectatorPill): the host frees a spectator seat WHILE
|
||||
// the match runs. The server took room.removeSpectator at any time (server/lobby.js removeSpectator; its header says "any
|
||||
// time") but the lobby tests only removed one in the lobby: this is the running match.
|
||||
test('co-op spectator removed by the host while the match runs: room.closed {kicked}, the seat freed and refilled, no frame after it, the other spectator watches on', async () => {
|
||||
FakeBattle.reset();
|
||||
FakeBattle.script = () => ({ duration: 1 });
|
||||
const watcher = async (name) => {
|
||||
const srvNow = await server();
|
||||
const c = await TestClient.connect(`ws://127.0.0.1:${srvNow.port}/ws`);
|
||||
clients.push(c);
|
||||
const w = await c.hello(name);
|
||||
c.id = w.playerId;
|
||||
c.token = w.token;
|
||||
return c;
|
||||
};
|
||||
const a = await player('A');
|
||||
await ok(a, { t: 'room.create', mode: 'coop', difficulty: 'NORMAL' });
|
||||
const st = await a.waitFor('room.state');
|
||||
await ok(a, { t: 'room.addBot' });
|
||||
const s1 = await watcher('Spec1');
|
||||
const s2 = await watcher('Spec2');
|
||||
await ok(s1, { t: 'room.spectate', code: st.code });
|
||||
await ok(s2, { t: 'room.spectate', code: st.code });
|
||||
await ok(a, { t: 'room.start' });
|
||||
await s1.waitFor('m.public', (p) => p.phase === 'INFO_CHECK');
|
||||
await ok(a, { t: 'g.infoReady' });
|
||||
await pickCoopBands([a]);
|
||||
await a.waitFor('m.public', (p) => p.phase === 'PREP' && p.round === 1, 10000);
|
||||
const running = await a.waitFor('room.state', (x) => x.inMatch && x.spectators.length === 2);
|
||||
assert.deepEqual(running.spectators.map((x) => x.playerId), [s1.id, s2.id], 'both seats are listed while the match runs: what the game screen\'s capsule shows');
|
||||
|
||||
// who may remove: the host only, and only a spectator of this room
|
||||
assert.equal((await s1.request({ t: 'room.removeSpectator', playerId: s2.id })).code, 'NOT_HOST', 'a spectator cannot remove another');
|
||||
assert.equal((await a.request({ t: 'room.removeSpectator', playerId: 'nobody' })).code, 'BAD_TARGET');
|
||||
assert.equal((await a.request({ t: 'room.removeSpectator', playerId: a.id })).code, 'BAD_TARGET', 'a player is no spectator');
|
||||
|
||||
// the host removes the first one: told room.closed {kicked}; the room.state of the match now lists the other only
|
||||
await ok(a, { t: 'room.removeSpectator', playerId: s1.id });
|
||||
assert.equal((await s1.waitFor('room.closed', () => true, 5000)).reason, 'kicked');
|
||||
const after = await a.waitFor('room.state', (x) => x.inMatch && x.spectators.length === 1);
|
||||
assert.deepEqual(after.spectators.map((x) => x.playerId), [s2.id]);
|
||||
assert.equal((await s2.waitFor('room.state', (x) => x.inMatch && x.spectators.length === 1)).spectators[0].playerId, s2.id, 'the other spectator is told too');
|
||||
assert.equal((await a.request({ t: 'room.removeSpectator', playerId: s1.id })).code, 'BAD_TARGET', 'a seat already freed');
|
||||
assert.equal((await s1.request({ t: 'g.watch', fieldId: `n:${a.id}` })).code, 'NOT_IN_ROOM', 'out of the room: no more watching');
|
||||
|
||||
// the match runs on: the removed seat gets no frame of it any more, the other spectator and the player do
|
||||
const mark = s1.log.length;
|
||||
s2.log.length = 0;
|
||||
await ok(a, { t: 'g.ready', ready: true });
|
||||
await s2.waitFor('b.start', (x) => x.kind === 'normal', 10000);
|
||||
await a.waitFor('m.public', (p) => p.phase === 'PREP' && p.round === 2, 15000);
|
||||
assert.deepEqual(s1.log.slice(mark).filter((x) => x.t !== 'pong').map((x) => x.t), [], 'nothing reaches the removed spectator');
|
||||
assert.ok(s2.log.some((x) => x.t === 'm.public'), 'the other spectator still gets the match');
|
||||
|
||||
// the freed seat can be taken again (no ban list: the key is all it takes)
|
||||
const s3 = await watcher('Spec3');
|
||||
await ok(s3, { t: 'room.spectate', code: st.code });
|
||||
const refilled = await a.waitFor('room.state', (x) => x.inMatch && x.spectators.length === 2);
|
||||
assert.deepEqual(refilled.spectators.map((x) => x.playerId), [s2.id, s3.id]);
|
||||
assert.deepEqual(errors, []);
|
||||
await ok(a, { t: 'g.leave' });
|
||||
});
|
||||
|
||||
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 });
|
||||
|
||||
@@ -22,7 +22,12 @@ test('observeTarget: prep, own battle running / over, boss pairs, eliminated pla
|
||||
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 }]);
|
||||
// the teammate's capsule reads `resolved` (the field's own enemies knocked out or leaked); a report without one falls
|
||||
// back to `killed` (public/js/battle/observe.js teammateProgress)
|
||||
assert.deepEqual(teammateProgress(combat(false), 'a'), [{ playerId: 'b', name: 'B', isBot: false, killed: 3, resolved: 3, total: 8, done: false }]);
|
||||
const reported = pubOf('COMBAT', [{ fieldId: 'n:b', kind: 'normal', players: ['b'], live: true, progress: { killed: 5, resolved: 3, total: 8, done: false } }], players);
|
||||
assert.deepEqual(teammateProgress(reported, 'a'), [{ playerId: 'b', name: 'B', isBot: false, killed: 5, resolved: 3, total: 8, done: false }],
|
||||
'a reported `resolved` wins over `killed` (5 counted knock-outs — splits included —, 3 of the round\'s own enemies resolved)');
|
||||
const fa = pubOf('FINAL_ASSAULT', [
|
||||
{ fieldId: 'b1', kind: 'boss', players: ['a', 'b'], live: true },
|
||||
{ fieldId: 'b2', kind: 'boss', players: ['d'], live: true },
|
||||
|
||||
@@ -430,7 +430,13 @@ test('#7 a leaked enemy that splits (磨砻: DeadSpawn ×2) raises the counter
|
||||
seen.push(p.uniteLeft);
|
||||
h.sched.advance(500);
|
||||
}
|
||||
assert.ok(b.total > SENT, `the 磨砻 split (${SENT} → ${b.total} on the field)`);
|
||||
// the split children are runtime spawns: they enter neither part of the HUD capsule (PR #157: the denominator counts
|
||||
// only what the 联防 scheduled — `b.total` — and the numerator only that set's own knock-outs / leaks), while the
|
||||
// 联防 live counter (m.public uniteLeft) still bills them to the leaker
|
||||
const kids = b.units.filter((u) => u.side === 'enemy' && !u.inTotal).length;
|
||||
assert.ok(kids > 0, `the 磨砻 split into ${kids} runtime children`);
|
||||
assert.equal(b.total, SENT, `the children stay out of the capsule's denominator (total = the ${SENT} the 联防 scheduled)`);
|
||||
assert.equal(b.resolved, Math.min(SENT, b.killedInTotal + b.leakedInTotal), 'the capsule numerator counts only the 联防\'s own enemies');
|
||||
const rises = seen.filter((v, i) => i > 0 && v > seen[i - 1]).length;
|
||||
assert.ok(rises > 0, `the counter rose after a split: ${seen.filter((v, i) => i === 0 || v !== seen[i - 1]).join(' → ')}`);
|
||||
assert.ok(Math.max(...seen) > SENT, 'above the number sent in (the old clamp hid it)');
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
// test/match/potential.test.js — the player's 潜能 / 练度 on the server (0.2.2, the owner's decision of 2026-10-08:
|
||||
// 「调配干员里自己设置吧,默认满潜满加成」): room.loadout `ops` (shared/protocol.js checkLoadoutOps) → seats[].ops →
|
||||
// PlayerState.ops (re-checked, frozen; bots none) → every owned operator's PlayerBattleInput entry states `potential` /
|
||||
// `cultivate` (defaults 6 / 3 — also an old loadout without ops), never a 补位 stand-in or a prototype 自选 pick; the
|
||||
// 自选 summons' hand count follows the owner's potential; m.private.ops; the scouting units; Match.setLoadout takes
|
||||
// them in INFO_CHECK only.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { ERR, PHASE } from '../../shared/constants.js';
|
||||
import { validateC2S, checkLoadoutOps, isLoadoutOps, cultivationCharIds, LOADOUT_LIMITS } from '../../shared/protocol.js';
|
||||
import { buildBattleSpec, createBattleFromSpec } from '../../server/sim/spec.js';
|
||||
import { DATA, makeMatch } from './harness.js';
|
||||
|
||||
const chess = (id) => (Object.hasOwn(DATA.chess, id) ? DATA.chess[id] : null);
|
||||
const VENDLA = 'chess_char_1_06_a'; // 刺玫 (PRESET)
|
||||
const VENDLA_CHAR = chess(VENDLA).charId;
|
||||
const GRANI = Object.values(DATA.chess).find((c) => c.chessType === 'NORMAL' && !c.isGolden && c.visible && c.backup?.charId && c.backup.charId !== c.charId);
|
||||
const T6A = 'chess_char_6_diy1_a';
|
||||
const T5A = 'chess_char_5_diy1_a';
|
||||
const KALTS = 'char_003_kalts';
|
||||
const WANG = 'char_2027_wang';
|
||||
const SHARP = 'char_609_acguad';
|
||||
const IDS = cultivationCharIds(DATA.chess, DATA.backups);
|
||||
|
||||
function place(ps, chessId, row, col) {
|
||||
const p = ps.newPiece('chess', chessId);
|
||||
ps.board.set(`${row},${col}`, p);
|
||||
return p;
|
||||
}
|
||||
|
||||
test('room.loadout ops: structure (≤ 256 charIds → { potential 1–6?, cultivate 0–3? }) and the strict data check', () => {
|
||||
const ok = (ops) => validateC2S({ t: 'room.loadout', entries: {}, ops }) === null;
|
||||
assert.ok(validateC2S({ t: 'room.loadout', entries: {} }) === null, 'ops optional (an older client)');
|
||||
assert.ok(ok({}));
|
||||
assert.ok(ok({ [VENDLA_CHAR]: { potential: 1 } }));
|
||||
assert.ok(ok({ [VENDLA_CHAR]: { cultivate: 0 } }));
|
||||
assert.ok(ok({ [VENDLA_CHAR]: { potential: 6, cultivate: 3 } }));
|
||||
for (const bad of [[], null, { [VENDLA_CHAR]: {} }, { [VENDLA_CHAR]: { potential: 0 } }, { [VENDLA_CHAR]: { potential: 6.5 } },
|
||||
{ [VENDLA_CHAR]: { cultivate: -1 } }, { [VENDLA_CHAR]: { cultivate: 4 } }, { [VENDLA_CHAR]: { potential: '3' } }, { 'a b': { potential: 1 } },
|
||||
{ [VENDLA_CHAR]: { potential: 1, trust: 100 } }]) assert.ok(!ok(bad), JSON.stringify(bad));
|
||||
const many = Object.fromEntries(Array.from({ length: LOADOUT_LIMITS.ops + 1 }, (_, i) => [`char_${i}`, { potential: 1 }]));
|
||||
assert.ok(!isLoadoutOps(many));
|
||||
// the operators one may set: the 112-chess roster's charIds (hidden twins share them) and the 71 owned 自选 picks
|
||||
assert.ok(IDS.has(VENDLA_CHAR) && IDS.has(KALTS) && IDS.has(WANG));
|
||||
assert.ok(!IDS.has(SHARP) && !IDS.has(GRANI.backup.charId), 'a prototype / stand-in character is no setting');
|
||||
assert.ok(IDS.size >= 180 && IDS.size <= 200, `${IDS.size}`);
|
||||
const has = (id) => IDS.has(id);
|
||||
assert.deepEqual(checkLoadoutOps(undefined, has), { ok: true, ops: {} });
|
||||
assert.deepEqual(checkLoadoutOps({ [VENDLA_CHAR]: { potential: 2 }, [KALTS]: { potential: 6, cultivate: 3 } }, has), { ok: true, ops: { [VENDLA_CHAR]: { potential: 2, cultivate: 3 } } });
|
||||
assert.equal(checkLoadoutOps({ [SHARP]: { potential: 2 } }, has).error, 'BAD_TARGET');
|
||||
assert.equal(checkLoadoutOps({ [VENDLA_CHAR]: { potential: 9 } }, has).error, 'BAD_MSG');
|
||||
});
|
||||
|
||||
test('battle input: every owned operator states 潜能 / 练度 — the human\'s settings, the defaults (6 / 3) for the rest, for a seat without ops and for bots', () => {
|
||||
const ops = { [VENDLA_CHAR]: { potential: 1, cultivate: 0 } };
|
||||
const seats = [
|
||||
{ seat: 0, playerId: 'p_0', name: 'P0', isBot: false, connected: true, loadout: null, ops, notOwned: [GRANI.chessId] },
|
||||
{ seat: 1, playerId: 'p_1', name: 'P1', isBot: false, connected: true, loadout: {} }, // an old seat: no ops
|
||||
{ seat: 2, playerId: 'ai_0', name: 'AI0', isBot: true, connected: true, ops },
|
||||
];
|
||||
const h = makeMatch({ mode: 'coop', seats, seed: 3 }).start();
|
||||
const m = h.m;
|
||||
const [p0, p1, bot] = ['p_0', 'p_1', 'ai_0'].map((id) => h.ps(id));
|
||||
assert.deepEqual(p0.ops, { [VENDLA_CHAR]: { potential: 1, cultivate: 0 } });
|
||||
assert.ok(Object.isFrozen(p0.ops) && Object.isFrozen(p0.ops[VENDLA_CHAR]));
|
||||
assert.deepEqual([p1.ops, bot.ops], [{}, {}], 'no settings / a bot: the defaults');
|
||||
h.flushAll();
|
||||
assert.deepEqual(h.lastTo('p_0', 'm.private').ops, p0.ops, 'm.private exposes the effective settings');
|
||||
h.toPrep(1);
|
||||
for (const ps of [p0, p1, bot]) { for (const p of [...ps.board.values()]) ps.returnCopies(p); ps.board.clear(); }
|
||||
place(p0, VENDLA, 9, 3);
|
||||
place(p0, chess(VENDLA).goldenId, 10, 3);
|
||||
place(p0, 'chess_char_1_02_a', 11, 3);
|
||||
place(p0, GRANI.chessId, 12, 3); // not owned: its stand-in
|
||||
place(p1, VENDLA, 9, 3);
|
||||
place(bot, VENDLA, 9, 3);
|
||||
const by = (ps, id) => ps.battleInput().units.find((u) => u.chessId === id);
|
||||
assert.deepEqual([by(p0, VENDLA).potential, by(p0, VENDLA).cultivate], [1, 0], 'the human\'s setting');
|
||||
assert.deepEqual([by(p0, chess(VENDLA).goldenId).potential, by(p0, chess(VENDLA).goldenId).cultivate], [1, 0], 'the elite shares the charId');
|
||||
assert.deepEqual([by(p0, 'chess_char_1_02_a').potential, by(p0, 'chess_char_1_02_a').cultivate], [6, 3], 'no entry: 满潜满加成');
|
||||
assert.deepEqual(['potential' in by(p0, GRANI.chessId), 'cultivate' in by(p0, GRANI.chessId), by(p0, GRANI.chessId).standIn], [false, false, true], 'a stand-in has neither');
|
||||
assert.deepEqual([by(p1, VENDLA).potential, by(p1, VENDLA).cultivate], [6, 3], 'an old loadout: the defaults');
|
||||
assert.deepEqual([by(bot, VENDLA).potential, by(bot, VENDLA).cultivate], [6, 3], 'bots: the defaults');
|
||||
// the spec keeps them and the battle fields them
|
||||
const spec = buildBattleSpec(m._normalOpts(p0));
|
||||
const b = createBattleFromSpec(spec, undefined, { quiet: true });
|
||||
const u = b.allyUnits.find((x) => x.defId === VENDLA);
|
||||
const e = b.allyUnits.find((x) => x.defId === 'chess_char_1_02_a');
|
||||
assert.deepEqual([u.base.atk, u.base.cost, u.cultivate, u.s.atk], [413, 17, 0, 413]);
|
||||
assert.deepEqual([e.cultivate, e.s.maxHp], [3, e.base.maxHp * 1.1]);
|
||||
// the scouting units carry them (a teammate's card)
|
||||
const scout = m.prepFieldMeta(p0).units;
|
||||
const sv = scout.find((x) => x.defId === VENDLA);
|
||||
assert.deepEqual([sv.potential, sv.cultivate], [1, 0]);
|
||||
const sg = scout.find((x) => x.defId === GRANI.chessId);
|
||||
assert.deepEqual([sg.potential, sg.cultivate], [undefined, undefined]);
|
||||
assert.deepEqual([scout.find((x) => x.defId === 'chess_char_1_02_a').potential, scout.find((x) => x.defId === 'chess_char_1_02_a').cultivate], [undefined, 3]);
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('自选: an owned pick states its operator\'s settings, a prototype neither; 望\'s 棋子 hand count follows her potential', () => {
|
||||
const ops = { [KALTS]: { potential: 2, cultivate: 1 }, [WANG]: { potential: 2 } };
|
||||
const seats = [{ seat: 0, playerId: 'p_0', name: 'P0', isBot: false, connected: true, ops,
|
||||
diy: { [T6A]: { charId: KALTS, skillIndex: 0 }, [T5A]: { charId: SHARP }, chess_char_6_diy2_a: { charId: WANG, skillIndex: 0 } } }];
|
||||
const h = makeMatch({ mode: 'solo', seats, seed: 7 }).start();
|
||||
const m = h.m;
|
||||
const p0 = h.ps('p_0');
|
||||
h.toPrep(1);
|
||||
for (const p of [...p0.board.values()]) p0.returnCopies(p);
|
||||
p0.board.clear();
|
||||
place(p0, T6A, 10, 3);
|
||||
place(p0, T5A, 10, 5);
|
||||
const units = p0.battleInput().units;
|
||||
const k = units.find((u) => u.chessId === T6A);
|
||||
const s = units.find((u) => u.chessId === T5A);
|
||||
assert.deepEqual([k.diy.charId, k.potential, k.cultivate], [KALTS, 2, 1]);
|
||||
assert.deepEqual([s.diy.charId, 'potential' in s, 'cultivate' in s], [SHARP, false, false]);
|
||||
// 望 (潜能 2): 6 棋子 to the hand, not 7
|
||||
const lo = p0.loadoutFor(p0.gd.chess('chess_char_6_diy2_a'));
|
||||
assert.equal(lo.potential, 2);
|
||||
assert.deepEqual(p0.gd.placeableTokens('chess_char_6_diy2_a', lo), [{ tokenId: 'token_10064_wang_stone1', count: 6 }]);
|
||||
assert.deepEqual(p0.gd.placeableTokens('chess_char_6_diy2_a', { ...lo, potential: 6 }), [{ tokenId: 'token_10064_wang_stone1', count: 7 }]);
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('Match.setLoadout: the settings change with the loadout in INFO_CHECK only; re-checked; a refusal changes nothing', () => {
|
||||
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.ops, {});
|
||||
assert.deepEqual(m.setLoadout('p_0', {}, { [VENDLA_CHAR]: { potential: 3 } }), { ok: true });
|
||||
assert.deepEqual(ps.ops, { [VENDLA_CHAR]: { potential: 3, cultivate: 3 } });
|
||||
assert.deepEqual(m.setLoadout('p_0', {}), { ok: true }, 'no ops argument: unchanged');
|
||||
assert.deepEqual(ps.ops, { [VENDLA_CHAR]: { potential: 3, cultivate: 3 } });
|
||||
assert.equal(m.setLoadout('p_0', {}, { [SHARP]: { potential: 1 } }).error, ERR.BAD_TARGET);
|
||||
assert.equal(m.setLoadout('p_0', { chess_char_1_01_a: { skill: 0 } }, { [VENDLA_CHAR]: { potential: 9 } }).error, ERR.BAD_TARGET);
|
||||
assert.deepEqual([ps.ops, ps.loadout], [{ [VENDLA_CHAR]: { potential: 3, cultivate: 3 } }, {}], 'a refusal changes nothing');
|
||||
assert.deepEqual(m.setLoadout('p_0', {}, null), { ok: true }, 'null: none set');
|
||||
assert.deepEqual(ps.ops, {});
|
||||
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);
|
||||
assert.equal(m.setLoadout('p_0', {}, { [VENDLA_CHAR]: { potential: 1 } }).error, ERR.WRONG_PHASE, 'locked after INFO_CHECK');
|
||||
assert.deepEqual(ps.ops, {});
|
||||
m.dispose();
|
||||
});
|
||||
@@ -37,6 +37,7 @@ test('#44 prep scout: held pieces are units on the hand row; deploying moves the
|
||||
const u = first.units.find((x) => x.uid === piece.uid);
|
||||
assert.ok(u, `${pid}: the held operator is a unit of the scout`);
|
||||
assert.deepEqual([u.x, u.y], [0, GEO.HAND_ROW], `${pid}: standing on the first hand slot`);
|
||||
assert.equal(u.area, 'hand', 'tagged with its area: the bond popup counts it as held, not in play (GitHub #385)');
|
||||
assert.equal(u.kind, 'op');
|
||||
assert.equal(u.items, undefined, 'no items equipped yet');
|
||||
}
|
||||
@@ -48,6 +49,7 @@ test('#44 prep scout: held pieces are units on the hand row; deploying moves the
|
||||
const last = h.lastTo('p_0', 'm.field');
|
||||
const u = last.units.find((x) => x.uid === piece.uid);
|
||||
assert.deepEqual([u.x, u.y], [c, r], 'deployed onto the watched board');
|
||||
assert.equal(u.area, 'board');
|
||||
assert.equal(last.units.some((x) => x.uid === piece.uid && x.y === GEO.HAND_ROW), false, 'and gone from the hand row');
|
||||
m.dispose();
|
||||
});
|
||||
@@ -74,6 +76,7 @@ test('#44 prep scout: a held item is a kind-item unit on the hand row; equipped
|
||||
assert.ok(iu, 'the held item is a unit of the scout');
|
||||
assert.equal(iu.kind, 'item');
|
||||
assert.equal(iu.y, GEO.HAND_ROW);
|
||||
assert.equal(iu.area, 'hand');
|
||||
// equip the item onto the operator (still in the hand): the operator's unit carries the item id
|
||||
assert.deepEqual(m.handle('p_1', { t: 'g.equip', itemUid: item.uid, targetUid: op.uid }), { ok: true });
|
||||
const after = h.lastTo('p_0', 'm.field');
|
||||
@@ -109,7 +112,10 @@ test("PR #129 review: moving a hand piece to another slot is a scout change (x f
|
||||
const first = h.lastTo('p_0', 'm.field');
|
||||
const tu = first.units.find((x) => x.uid === c.uid);
|
||||
assert.deepEqual([tu.x, tu.y], [GEO.TEMP_C0, GEO.TEMP_ROW], 'the temp piece scouts on the temp row (first slot, col 4)');
|
||||
assert.equal(first.units.find((x) => x.uid === a.uid).x, 0);
|
||||
assert.equal(tu.area, 'temp');
|
||||
const hu = first.units.find((x) => x.uid === a.uid);
|
||||
assert.equal(hu.x, 0);
|
||||
assert.equal(hu.area, 'hand');
|
||||
|
||||
// a hand piece moved to another slot keeps uid and id — the slot must be in the signature for the push to fire
|
||||
assert.deepEqual(m.handle('p_1', { t: 'g.move', uid: a.uid, to: { area: 'hand', idx: 3 } }), { ok: true });
|
||||
|
||||
@@ -55,3 +55,93 @@ test('#87 prep watch: seat B moves during prep and seat A\'s view of B changes b
|
||||
assert.equal(h.allTo(S, 'm.private').length, 0);
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
for (const clientCombat of [false, true]) {
|
||||
test(`#346 prep watch: a bounty choice refreshes existing teammate and spectator views (clientCombat=${clientCombat})`, (t) => {
|
||||
const S = 's_spec';
|
||||
// Only the earlier battles are fake; the draft, choice, views and notifications are real.
|
||||
const h = makeMatch({ mode: 'coop', difficulty: 'HARD', humans: 2, seed: 1, fake: true, clientCombat, spectators: [S] }).start();
|
||||
const m = h.m;
|
||||
t.after(() => m.dispose());
|
||||
assert.ok(h.drive(() => m.phase === PHASE.SP_DRAFT));
|
||||
assert.equal(m.round, 3);
|
||||
assert.equal(m.sp.family, 'bounty');
|
||||
assert.equal(m.spTurn(), 'p_0');
|
||||
assert.equal(m.sp.cards[0].name, '悬赏·持续III');
|
||||
for (const pid of ['p_1', S]) assert.deepEqual(m.handle(pid, { t: 'g.watch', fieldId: 'n:p_0' }), { ok: true });
|
||||
h.flushAll();
|
||||
const before = h.lastTo(S, 'm.field');
|
||||
const counts = ['p_1', S].map((pid) => [pid, h.allTo(pid, 'm.field').length]);
|
||||
|
||||
assert.deepEqual(m.handle('p_0', { t: 'g.choice', idx: 0 }), { ok: true });
|
||||
h.flushAll();
|
||||
for (const [pid, count] of counts) {
|
||||
const fields = h.allTo(pid, 'm.field');
|
||||
assert.equal(fields.length, count + 1, `${pid}: the choice pushes one updated scout view`);
|
||||
const after = fields.at(-1);
|
||||
assert.deepEqual(after.units, before.units, 'no board, hand or temp piece changed');
|
||||
assert.deepEqual(after.effects.map((e) => e.name), [...before.effects.map((e) => e.name), '悬赏·持续III']);
|
||||
assert.deepEqual(after.nextEnemies.filter((e) => e.tag === 'bounty').map((e) => [e.enemyKey, e.count]), [['enemy_1272_nhtank', 1]]);
|
||||
assert.deepEqual(after.nextEnemies.filter((e) => e.tag !== 'bounty'), before.nextEnemies);
|
||||
assert.deepEqual(after, m.prepFieldMeta(h.ps('p_0')), 'the watcher receives the current public view');
|
||||
assert.equal(Object.hasOwn(after, 'funds'), false);
|
||||
assert.equal(Object.hasOwn(after, 'shop'), false);
|
||||
}
|
||||
|
||||
// Exercise dedup even when the player is dirty, rather than just flushing an empty queue.
|
||||
m.markPrivate(h.ps('p_0'));
|
||||
h.flushAll();
|
||||
h.flushAll();
|
||||
for (const [pid, count] of counts) assert.equal(h.allTo(pid, 'm.field').length, count + 1, `${pid}: unchanged public content is not pushed again`);
|
||||
assert.equal(h.allTo(S, 'm.private').length, 0, 'the spectator never receives private player state');
|
||||
|
||||
assert.deepEqual(m.handle(S, { t: 'g.watch', fieldId: 'n:p_0' }), { ok: true });
|
||||
assert.equal(h.allTo(S, 'm.field').length, counts[1][1] + 2, 'an explicit watch still resends the current view');
|
||||
assert.equal(h.allTo('p_1', 'm.field').length, counts[0][1] + 1, 'the resend does not duplicate the other watcher\'s view');
|
||||
|
||||
// Shop actions are only legal after the draft ends, in PREP.
|
||||
h.toPrep(3);
|
||||
h.flushAll();
|
||||
const prepCounts = ['p_1', S].map((pid) => [pid, h.allTo(pid, 'm.field').length]);
|
||||
assert.deepEqual(m.handle('p_0', { t: 'g.freeze' }), { ok: true });
|
||||
h.flushAll();
|
||||
for (const [pid, count] of prepCounts) assert.equal(h.allTo(pid, 'm.field').length, count, `${pid}: a shop toggle does not change the scout view`);
|
||||
assert.equal(h.allTo(S, 'm.private').length, 0);
|
||||
assert.equal(m.errorCount, 0);
|
||||
});
|
||||
}
|
||||
|
||||
for (const field of ['effects', 'nextEnemies']) {
|
||||
test(`#346 prep watch: ${field} changes alone refresh the existing view`, (t) => {
|
||||
const h = makeMatch({ mode: 'coop', difficulty: 'HARD', humans: 2, seed: 1, fake: true, spectators: ['s_spec'] }).start();
|
||||
const m = h.m;
|
||||
t.after(() => m.dispose());
|
||||
assert.ok(h.drive(() => m.phase === PHASE.SP_DRAFT));
|
||||
assert.deepEqual(m.handle('p_0', { t: 'g.choice', idx: 0 }), { ok: true });
|
||||
assert.deepEqual(m.handle('s_spec', { t: 'g.watch', fieldId: 'n:p_0' }), { ok: true });
|
||||
h.flushAll();
|
||||
const before = h.lastTo('s_spec', 'm.field');
|
||||
const count = h.allTo('s_spec', 'm.field').length;
|
||||
const ps = h.ps('p_0');
|
||||
const bounty = ps.bounties[0];
|
||||
if (field === 'effects') bounty.roundsLeft--;
|
||||
else bounty.card.count++;
|
||||
m.markPrivate(ps);
|
||||
h.flushAll();
|
||||
|
||||
assert.equal(h.allTo('s_spec', 'm.field').length, count + 1);
|
||||
const after = h.lastTo('s_spec', 'm.field');
|
||||
assert.deepEqual(after.units, before.units);
|
||||
if (field === 'effects') {
|
||||
assert.equal(after.effects.at(-1).counter, 1, 'the remaining bounty battles update in place');
|
||||
assert.deepEqual(after.nextEnemies, before.nextEnemies);
|
||||
} else {
|
||||
const enemies = after.nextEnemies.filter((e) => e.tag === 'bounty');
|
||||
assert.ok(enemies.every((e) => e.enemyKey === 'enemy_1272_nhtank'));
|
||||
assert.equal(enemies.reduce((n, e) => n + e.count, 0), 2, 'both bounty enemies reach the preview');
|
||||
assert.deepEqual(after.effects, before.effects);
|
||||
}
|
||||
assert.equal(h.allTo('s_spec', 'm.private').length, 0);
|
||||
assert.equal(m.errorCount, 0);
|
||||
});
|
||||
}
|
||||
@@ -217,6 +217,12 @@ test('boss field: the local pool follows b.pool (server hp − unacknowledged lo
|
||||
assert.ok(last.bossDmg <= pool.cum);
|
||||
assert.ok(last.by && typeof last.by === 'object');
|
||||
assert.equal(typeof last.leaks, 'number');
|
||||
// the per-player split of the leak LP (the server holds the result's leaked lists to it, Match._bossLeaksAgree): the
|
||||
// leaks of every player of the field, never more than the field's LP cost (leader effects make up the rest)
|
||||
assert.ok(last.leaksBy && typeof last.leaksBy === 'object', 'boss progress carries leaksBy');
|
||||
assert.deepEqual(Object.keys(last.leaksBy).sort(), start.spec.players.map((p) => p.playerId).sort());
|
||||
assert.ok(Object.values(last.leaksBy).every((n) => Number.isFinite(n) && n >= 0));
|
||||
assert.ok(Object.values(last.leaksBy).reduce((a, b) => a + b, 0) <= last.leaks + 1e-9);
|
||||
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();
|
||||
@@ -432,8 +438,10 @@ test('live unit stats (user playtest #4 item 7): unitStats(id) reads the battle
|
||||
assert.equal(got.interval, Math.round(s.interval * 100) / 100);
|
||||
assert.equal(got.blockCnt, s.blockCnt);
|
||||
assert.equal(got.hp, Math.round(ally.hp));
|
||||
assert.equal(got.base.atk, Math.round(ally.base.atk));
|
||||
assert.equal(got.base.maxHp, Math.round(ally.base.maxHp));
|
||||
// the unit's own numbers: its base with its 练度 (0.2.2: the match states 精英2 Lv.60 by default — ×1.1)
|
||||
assert.equal(ally.cultivate, 3);
|
||||
assert.equal(got.base.atk, Math.round(ally.base.atk * ally.cultMul.atk));
|
||||
assert.equal(got.base.maxHp, Math.round(ally.base.maxHp * ally.cultMul.hp));
|
||||
assert.equal(r.runner.unitStats(ally.id, start.fieldId)?.id, ally.id, 'on the named field');
|
||||
assert.equal(r.runner.unitStats(ally.id, 'n:someone_else'), null, 'another field: nothing');
|
||||
assert.equal(r.runner.unitStats(999999), null, 'unknown unit');
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
// A Picture Scroll (画卷) gain can consume its own target in an immediate three-copy promotion (GitHub #389, PR #390).
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { makeMatch, legalTileFor } from './harness.js';
|
||||
|
||||
const OP = 'chess_char_1_06_a'; // 刺玫, the operator of the report
|
||||
const SCROLL = 'chess_item_6_02_m';
|
||||
const HAMMER = 'chess_item_1_01_e_a';
|
||||
const SHIELD = 'chess_item_1_02_e_a';
|
||||
const FILLER = 'chess_item_6_09_e_a'; // non-mergeable
|
||||
const OK = { ok: true };
|
||||
|
||||
function prep(t, { copies = 2, golden = false, items = [HAMMER] } = {}) {
|
||||
const h = makeMatch({ mode: 'solo', difficulty: 'FUNNY', seed: 8 }).start();
|
||||
t.after(() => h.m.dispose());
|
||||
h.toPrep(1, { band: 'band_dusk' });
|
||||
const m = h.m, ps = h.ps('p_0');
|
||||
assert.equal(m.gd.mergeCount(OP), 3);
|
||||
const id = golden ? m.gd.goldenIdOf(OP) : OP;
|
||||
const target = ps.acquireChess(id);
|
||||
const [row, col] = legalTileFor(m, ps, id);
|
||||
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: target.uid, to: { area: 'board', row, col }, dir: 'RIGHT' }), OK);
|
||||
for (let i = 1; i < copies; i++) ps.acquireChess(id);
|
||||
for (const itemId of items) {
|
||||
const item = ps.acquireItem(itemId);
|
||||
assert.deepEqual(m.handle('p_0', { t: 'g.equip', itemUid: item.uid, targetUid: target.uid }), OK);
|
||||
}
|
||||
const art = ps.hand.find((p) => p?.id === SCROLL);
|
||||
assert.ok(art, 'Dusk grants the scroll in round 1');
|
||||
h.invariants();
|
||||
const cast = () => m.handle('p_0', { t: 'g.art', itemUid: art.uid, row, col, dir: 'RIGHT' });
|
||||
return { h, m, ps, target, art, row, col, cast };
|
||||
}
|
||||
|
||||
function itemIds(ps) {
|
||||
return [...ps.hand, ...ps.temp, ...ps.board.values()].filter(Boolean)
|
||||
.flatMap((p) => p.kind === 'item' ? [p.id] : (p.items || []).map((it) => it.id)).sort();
|
||||
}
|
||||
|
||||
test('Picture Scroll: a third normal copy preserves the target equipment through promotion', (t) => {
|
||||
const { h, m, ps, target, art, row, col, cast } = prep(t);
|
||||
const funds = ps.funds;
|
||||
assert.deepEqual(cast(), OK);
|
||||
const elite = ps.board.get(`${row},${col}`);
|
||||
assert.equal(elite.id, m.gd.goldenIdOf(OP));
|
||||
assert.equal(elite.dir, 'RIGHT');
|
||||
assert.equal(ps.find(target.uid), null, 'the original target was consumed');
|
||||
assert.equal(ps.find(art.uid), null, 'the scroll was consumed');
|
||||
assert.deepEqual(itemIds(ps), [m.gd.item(HAMMER).goldenId]);
|
||||
assert.ok(ps.hand.some((p) => p?.id === m.gd.item(HAMMER).goldenId), 'the original and copied hammer merge into an advanced hammer in hand');
|
||||
assert.deepEqual(elite.items, []);
|
||||
assert.equal(ps.stats.merges, 1);
|
||||
assert.equal(ps.stats.itemMerges, 1);
|
||||
assert.equal(ps.funds, funds);
|
||||
h.invariants();
|
||||
});
|
||||
|
||||
for (const advanced of [false, true]) {
|
||||
test(`Picture Scroll: promotion copies both ${advanced ? 'advanced' : 'normal'} equipped items, none of them worn by the elite`, (t) => {
|
||||
const items = [HAMMER, SHIELD].map((id) => advanced ? id.replace(/_a$/, '_b') : id);
|
||||
const { h, m, ps, row, col, cast } = prep(t, { items });
|
||||
assert.deepEqual(cast(), OK);
|
||||
const elite = ps.board.get(`${row},${col}`);
|
||||
const upgraded = items.map((id) => m.gd.item(id).goldenId || id).sort();
|
||||
// advanced items do not merge: the returned originals and the copies all wait in the hand ("获得的装备为未装备状态")
|
||||
assert.deepEqual(itemIds(ps), advanced ? [...items, ...items].sort() : upgraded);
|
||||
assert.deepEqual(elite.items, [], 'the promotion unequips, and the copies are not put back on the elite');
|
||||
assert.equal([...ps.hand, ...ps.temp].filter((p) => p?.kind === 'item').length, advanced ? 4 : 2);
|
||||
h.invariants();
|
||||
});
|
||||
}
|
||||
|
||||
for (const golden of [false, true]) {
|
||||
test(`Picture Scroll: one ${golden ? 'elite' : 'normal'} target still copies its equipment without promotion`, (t) => {
|
||||
const { h, m, ps, target, cast } = prep(t, { copies: 1, golden });
|
||||
assert.deepEqual(cast(), OK);
|
||||
assert.equal(ps.find(target.uid)?.piece, target);
|
||||
assert.equal(ps.allChess().length, 2);
|
||||
assert.equal(ps.allChess().filter((p) => p.id === target.id).length, 2);
|
||||
assert.deepEqual(itemIds(ps), [m.gd.item(HAMMER).goldenId]);
|
||||
assert.equal(ps.stats.merges, 0);
|
||||
h.invariants();
|
||||
});
|
||||
}
|
||||
|
||||
test('Picture Scroll: a third copy without equipment still promotes normally', (t) => {
|
||||
const { h, m, ps, row, col, cast } = prep(t, { items: [] });
|
||||
assert.deepEqual(cast(), OK);
|
||||
assert.equal(ps.board.get(`${row},${col}`).id, m.gd.goldenIdOf(OP));
|
||||
assert.deepEqual(itemIds(ps), []);
|
||||
h.invariants();
|
||||
});
|
||||
|
||||
test('Picture Scroll: a promotion at full hand and temp still copies the hammer', (t) => {
|
||||
const { h, m, ps, cast } = prep(t);
|
||||
while (ps.hand.some((p) => p == null) || ps.temp.some((p) => p == null)) assert.ok(ps.acquireItem(FILLER));
|
||||
h.invariants();
|
||||
assert.deepEqual(cast(), OK);
|
||||
assert.deepEqual(itemIds(ps).filter((id) => id !== FILLER), [m.gd.item(HAMMER).goldenId]);
|
||||
assert.equal(itemIds(ps).filter((id) => id === FILLER).length, 13);
|
||||
h.invariants();
|
||||
});
|
||||
|
||||
test('Picture Scroll: a full hand and temp without promotion keeps the refused scroll and target equipment', (t) => {
|
||||
const { h, ps, target, art, cast } = prep(t, { copies: 1 });
|
||||
while (ps.hand.some((p) => p == null) || ps.temp.some((p) => p == null)) assert.ok(ps.acquireItem(FILLER));
|
||||
const before = ps.privateView();
|
||||
assert.equal(cast().error, 'HAND_FULL');
|
||||
assert.ok(ps.find(art.uid));
|
||||
assert.deepEqual(target.items.map((p) => p.id), [HAMMER]);
|
||||
assert.deepEqual(ps.privateView(), before);
|
||||
h.invariants();
|
||||
});
|
||||
|
||||
// PRTS 画卷 备注 「使用后销毁,获得的装备为未装备状态」: without a promotion too, the copied items are gained like any item —
|
||||
// the hand, overflow temp — and never put on the copy (until 0.2.2 a copy that did not merge was equipped onto it)
|
||||
const ADV = [HAMMER, SHIELD].map((id) => id.replace(/_a$/, '_b'));
|
||||
const looseItems = (ps) => [...ps.hand, ...ps.temp].filter((p) => p?.kind === 'item').map((p) => p.id).sort();
|
||||
for (const golden of [false, true]) {
|
||||
test(`Picture Scroll: one ${golden ? 'elite' : 'normal'} target — the copied (advanced) items arrive unequipped in the hand`, (t) => {
|
||||
const { h, m, ps, target, cast } = prep(t, { copies: 1, golden, items: ADV });
|
||||
assert.deepEqual(cast(), OK);
|
||||
const copy = ps.allChess().find((p) => p.id === target.id && p !== target);
|
||||
assert.ok(copy, 'the copy was gained');
|
||||
assert.deepEqual(copy.items, [], 'the copy wears nothing');
|
||||
assert.deepEqual(target.items.map((p) => p.id).sort(), [...ADV].sort(), 'the target keeps its own');
|
||||
assert.deepEqual(looseItems(ps), [...ADV].sort(), 'the copied items wait in the hand');
|
||||
assert.equal(m.gd.chess(copy.id).isGolden, golden);
|
||||
h.invariants();
|
||||
});
|
||||
}
|
||||
|
||||
test('Picture Scroll: a full hand — the copy and its items overflow into temp; an item with no slot left is destroyed like any gained item', (t) => {
|
||||
for (const free of [3, 2]) {
|
||||
const { h, ps, target, cast } = prep(t, { copies: 1, items: ADV });
|
||||
while (ps.hand.some((p) => p == null)) assert.ok(ps.acquireItem(FILLER));
|
||||
while (ps.temp.filter((p) => p == null).length > free) assert.ok(ps.acquireItem(FILLER));
|
||||
const toasts = () => h.allTo('p_0', 'm.toast').map((x) => JSON.stringify(x));
|
||||
const before = toasts().length;
|
||||
assert.deepEqual(cast(), OK);
|
||||
const copy = ps.allChess().find((p) => p.id === target.id && p !== target);
|
||||
const where = copy ? ps.find(copy.uid)?.area : null;
|
||||
assert.ok(where === 'temp' || where === 'hand', `free ${free}: the copy was gained into temp (the hand fills from temp once the scroll left)`);
|
||||
assert.deepEqual(copy.items, [], `free ${free}: the copy wears nothing`);
|
||||
assert.deepEqual(target.items.map((p) => p.id).sort(), [...ADV].sort());
|
||||
const got = looseItems(ps).filter((id) => id !== FILLER);
|
||||
if (free === 3) assert.deepEqual(got, [...ADV].sort(), 'room for the copy and both items');
|
||||
else {
|
||||
assert.equal(got.length, 1, 'room for the copy and one item: the other is destroyed');
|
||||
assert.ok(toasts().slice(before).some((s) => s.includes('整备区已满,获得的装备已销毁')), 'with the usual toast');
|
||||
}
|
||||
h.invariants();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { ERR } from '../../shared/constants.js';
|
||||
import { makeMatch, legalTileFor, checkInvariants } from './harness.js';
|
||||
|
||||
const SILENCE = 'chess_char_2_02_a';
|
||||
const CATHY = 'chess_char_4_11_a';
|
||||
const EQUIPMENT = 'chess_item_1_02_e_b'; // Elite equipment does not merge.
|
||||
|
||||
function setup(t) {
|
||||
const h = makeMatch({ mode: 'solo', difficulty: 'FUNNY', seed: 1, fake: true }).start();
|
||||
t.after(() => h.m.dispose());
|
||||
h.toPrep(1);
|
||||
return { h, m: h.m, ps: h.ps('p_0') };
|
||||
}
|
||||
|
||||
function deploy(m, ps, piece, chessId = piece.id) {
|
||||
const tile = legalTileFor(m, ps, chessId);
|
||||
assert.ok(tile);
|
||||
assert.deepEqual(m.handle(ps.playerId, { t: 'g.move', uid: piece.uid, to: { area: 'board', row: tile[0], col: tile[1] } }), { ok: true });
|
||||
}
|
||||
|
||||
function equip(m, ps, owner, count) {
|
||||
return Array.from({ length: count }, () => {
|
||||
const item = ps.acquireItem(EQUIPMENT, { silent: true });
|
||||
assert.ok(item);
|
||||
assert.deepEqual(m.handle(ps.playerId, { t: 'g.equip', itemUid: item.uid, targetUid: owner.uid }), { ok: true });
|
||||
return item;
|
||||
});
|
||||
}
|
||||
|
||||
function fillStorage(ps) {
|
||||
while ([...ps.hand, ...ps.temp].some((p) => p === null)) assert.ok(ps.acquireItem(EQUIPMENT, { silent: true }));
|
||||
}
|
||||
|
||||
function swapIntoTemp(m, ps, piece) {
|
||||
const idx = ps.hand.indexOf(piece);
|
||||
const filler = ps.temp.find((p) => p?.kind === 'item');
|
||||
assert.ok(idx >= 0 && filler);
|
||||
assert.deepEqual(m.handle(ps.playerId, { t: 'g.move', uid: filler.uid, to: { area: 'hand', idx } }), { ok: true });
|
||||
assert.equal(ps.find(piece.uid).area, 'temp');
|
||||
}
|
||||
|
||||
function snapshot(ps) {
|
||||
return structuredClone({
|
||||
hand: ps.hand, temp: ps.temp, board: ps.board, funds: ps.funds, pendingFunds: ps.pendingFunds,
|
||||
stats: ps.stats, round: ps.round, tempDue: ps._tempDue,
|
||||
});
|
||||
}
|
||||
|
||||
const inventory = (ps) => [...ps.hand, ...ps.temp, ...ps.board.values()].filter(Boolean);
|
||||
const tokensOf = (ps, owner) => inventory(ps).filter((p) => p.kind === 'token' && p.ownerUid === owner.uid);
|
||||
|
||||
for (const area of ['hand', 'temp']) {
|
||||
for (const itemCount of [1, 2]) {
|
||||
test(`sale uses the freed ${area} summon slot to return ${itemCount} equipment item(s)`, (t) => {
|
||||
const { h, m, ps } = setup(t);
|
||||
const owner = ps.acquireChess(SILENCE, { silent: true });
|
||||
deploy(m, ps, owner);
|
||||
const items = equip(m, ps, owner, itemCount);
|
||||
const other = ps.acquireChess(SILENCE, { silent: true });
|
||||
deploy(m, ps, other);
|
||||
const [stack] = tokensOf(ps, owner);
|
||||
fillStorage(ps);
|
||||
if (area === 'temp') swapIntoTemp(m, ps, stack);
|
||||
assert.equal(ps.find(stack.uid).area, area);
|
||||
// Two items need one existing free slot as well as the slot released by the stack.
|
||||
if (itemCount === 2) {
|
||||
const filler = ps.temp.find((p) => p?.kind === 'item');
|
||||
assert.deepEqual(m.handle(ps.playerId, { t: 'g.destroy', uid: filler.uid }), { ok: true });
|
||||
}
|
||||
const untouched = structuredClone(inventory(ps).filter((p) => p !== owner && p !== stack));
|
||||
const funds = ps.funds;
|
||||
const poolBefore = m.pool.left(SILENCE);
|
||||
const copies = owner.poolCopies;
|
||||
assert.deepEqual(m.handle(ps.playerId, { t: 'g.sell', uid: owner.uid }), { ok: true });
|
||||
assert.equal(ps.find(owner.uid), null);
|
||||
assert.deepEqual(tokensOf(ps, owner), []);
|
||||
assert.equal(ps.funds, funds + 1);
|
||||
assert.equal(m.pool.left(SILENCE), poolBefore + copies);
|
||||
assert.deepEqual(owner.items, []);
|
||||
for (const item of items) {
|
||||
assert.equal(ps.find(item.uid).piece, item, 'the original equipment object and UID return');
|
||||
assert.ok(['hand', 'temp'].includes(ps.find(item.uid).area));
|
||||
}
|
||||
assert.equal(ps.find(items[0].uid).area, area, 'hand is preferred, with overflow into temp');
|
||||
for (const piece of untouched) assert.deepEqual(ps.find(piece.uid).piece, piece);
|
||||
assert.equal(inventory(ps).length, untouched.length + items.length);
|
||||
assert.deepEqual(h.logs.warn, []);
|
||||
checkInvariants(m);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const { name, ownerId, area, itemCount, stackCount } of [
|
||||
{ name: 'one hand stack cannot hold two equipment items', ownerId: SILENCE, area: 'hand', itemCount: 2, stackCount: 1 },
|
||||
{ name: 'one temp stack cannot hold two equipment items', ownerId: SILENCE, area: 'temp', itemCount: 2, stackCount: 1 },
|
||||
{ name: 'a two-summon stack frees only one slot', ownerId: CATHY, area: 'hand', itemCount: 2, stackCount: 2 },
|
||||
{ name: 'board summons and foreign stacks free no usable slot', ownerId: SILENCE, area: 'board', itemCount: 1, stackCount: 1 },
|
||||
]) {
|
||||
test(`sale rejects atomically when ${name}`, (t) => {
|
||||
const { m, ps } = setup(t);
|
||||
const owner = ps.acquireChess(ownerId, { silent: true });
|
||||
deploy(m, ps, owner);
|
||||
const items = equip(m, ps, owner, itemCount);
|
||||
const other = ps.acquireChess(SILENCE, { silent: true });
|
||||
deploy(m, ps, other);
|
||||
const [stack] = tokensOf(ps, owner);
|
||||
assert.equal(stack.count, stackCount);
|
||||
if (area === 'board') deploy(m, ps, stack, owner.id);
|
||||
fillStorage(ps);
|
||||
if (area === 'temp') swapIntoTemp(m, ps, stack);
|
||||
assert.equal(ps.find(stack.uid).area, area);
|
||||
const before = snapshot(ps);
|
||||
const poolBefore = m.pool.left(ownerId);
|
||||
assert.equal(m.handle(ps.playerId, { t: 'g.sell', uid: owner.uid }).error, ERR.HAND_FULL);
|
||||
assert.deepEqual(snapshot(ps), before, 'pieces, equipment, funds, and bookkeeping remain unchanged');
|
||||
assert.equal(m.pool.left(ownerId), poolBefore);
|
||||
for (const item of items) assert.equal(ps.find(item.uid).area, 'equipped');
|
||||
checkInvariants(m);
|
||||
// Free exactly one additional slot and retry the same public intent.
|
||||
const filler = ps.temp.find((p) => p?.kind === 'item');
|
||||
assert.deepEqual(m.handle(ps.playerId, { t: 'g.destroy', uid: filler.uid }), { ok: true });
|
||||
assert.deepEqual(m.handle(ps.playerId, { t: 'g.sell', uid: owner.uid }), { ok: true });
|
||||
assert.equal(ps.find(owner.uid), null);
|
||||
assert.deepEqual(tokensOf(ps, owner), []);
|
||||
assert.equal(ps.funds, before.funds + 1);
|
||||
for (const item of items) {
|
||||
assert.equal(ps.find(item.uid).piece, item);
|
||||
assert.ok(['hand', 'temp'].includes(ps.find(item.uid).area));
|
||||
}
|
||||
assert.equal(tokensOf(ps, other).length, 1);
|
||||
checkInvariants(m);
|
||||
});
|
||||
}
|
||||
|
||||
for (const area of ['hand', 'temp']) {
|
||||
test(`sale still counts the operator's own ${area} slot`, (t) => {
|
||||
const { m, ps } = setup(t);
|
||||
const owner = ps.acquireChess(SILENCE, { silent: true });
|
||||
const [item] = equip(m, ps, owner, 1);
|
||||
fillStorage(ps);
|
||||
if (area === 'temp') swapIntoTemp(m, ps, owner);
|
||||
assert.equal(ps.find(owner.uid).area, area);
|
||||
const funds = ps.funds;
|
||||
assert.deepEqual(m.handle(ps.playerId, { t: 'g.sell', uid: owner.uid }), { ok: true });
|
||||
assert.equal(ps.find(owner.uid), null);
|
||||
assert.equal(ps.find(item.uid).piece, item);
|
||||
assert.equal(ps.find(item.uid).area, area);
|
||||
assert.equal(ps.funds, funds + 1);
|
||||
checkInvariants(m);
|
||||
});
|
||||
}
|
||||
@@ -35,6 +35,8 @@ test('real four-player sockets: host-only proposal, unanimous sync, reconnect an
|
||||
return c;
|
||||
};
|
||||
const host = await player('Host');
|
||||
const ops = { char_017_huang: { potential: 2, cultivate: 1 } };
|
||||
await ok(host, { t: 'room.loadout', entries: {}, ops });
|
||||
await ok(host, { t: 'room.create', mode: 'coop', difficulty: 'HARD' });
|
||||
const room = await host.waitFor('room.state');
|
||||
const guests = [];
|
||||
@@ -76,6 +78,8 @@ test('real four-player sockets: host-only proposal, unanimous sync, reconnect an
|
||||
assert.notDeepEqual(setupOf(fresh[0]), { ...setupOf(initial), setupRevision: 1 });
|
||||
assert.equal(srv.lobby.rooms.get(room.code).match, m, 'room/match instance and sockets retained');
|
||||
assert.ok(all.every((c) => c.isOpen));
|
||||
assert.deepEqual(m.players.get(host.id).ops, ops, 'the new loadout fields survive reroll');
|
||||
assert.deepEqual((await host.waitFor('m.private', (p) => p.ops?.char_017_huang?.potential === 2)).ops, ops);
|
||||
await err(guests[0], { t: 'g.infoReady', setupRevision: 0 }, ERR.BAD_TARGET);
|
||||
await err(guests[0], { t: 'g.infoReady' }, ERR.BAD_TARGET);
|
||||
await err(host, { t: 'room.rerollSetup', setupRevision: 0 }, ERR.BAD_TARGET);
|
||||
|
||||
@@ -53,9 +53,10 @@ test('four humans must agree; rerolls the entire official setup and stock, prese
|
||||
const players = [...m.order];
|
||||
const rngs = [m.rngSetup, m.rngShop, m.rngDraft, m.rngWaves, m.rngBots, m.rngMeta].map((r) => [r, r.state()]);
|
||||
const p0 = h.ps('p_0');
|
||||
assert.equal(p0.setLoadout(p0.loadout, { char_017_huang: { potential: 2, cultivate: 1 } }), true);
|
||||
assert.equal(p0.setDiy({ chess_char_5_diy1_a: { charId: 'char_017_huang', skillIndex: 0 } }), true);
|
||||
p0.initDiyStock(new Set([...m.disabledBonds, ...m.staticInactiveBonds]));
|
||||
const settings = [p0.loadout, p0.standIns, p0.diy];
|
||||
const settings = [p0.loadout, p0.standIns, p0.diy, p0.ops];
|
||||
const stock = p0.diyStock;
|
||||
m.handle('p_1', { t: 'g.infoReady' });
|
||||
h.sched.advance(500);
|
||||
@@ -80,8 +81,10 @@ test('four humans must agree; rerolls the entire official setup and stock, prese
|
||||
assert.equal(m.stage, m.gd.stage(m.stageId));
|
||||
assert.notEqual(m.pool, pool);
|
||||
assert.deepEqual(m.order, players);
|
||||
assert.deepEqual([p0.loadout, p0.standIns, p0.diy], settings);
|
||||
assert.deepEqual([p0.loadout, p0.standIns, p0.diy, p0.ops], settings);
|
||||
assert.equal(p0.diy, settings[2]);
|
||||
assert.equal(p0.ops, settings[3], 'per-operator potential and cultivation stay unchanged');
|
||||
assert.deepEqual(h.lastTo('p_0', 'm.private').ops, settings[3]);
|
||||
assert.notEqual(p0.diyStock, stock);
|
||||
const off = new Set([...bans.drawn, ...bans.staticOff]);
|
||||
const diy = p0.gd.chess('chess_char_5_diy1_a');
|
||||
|
||||
@@ -129,10 +129,13 @@ export class SimClient {
|
||||
_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 };
|
||||
// the HUD capsule's numerator, like public/js/battle/runner.js (kept only when the battle reports one)
|
||||
if (Number.isFinite(p.resolved)) msg.resolved = Math.min(p.resolved, p.total);
|
||||
const bossLike = e.spec.kind === 'boss' || e.spec.kind === 'hidden';
|
||||
if (bossLike) {
|
||||
const pool = e.battle.sharedBoss;
|
||||
msg.leaks = e.meter.lp;
|
||||
if (p.leaksBy) msg.leaksBy = p.leaksBy; // like public/js/battle/runner.js
|
||||
msg.bossDmg = pool && Number.isFinite(pool.cum) ? pool.cum : 0;
|
||||
if (pool && pool.byPlayer) msg.by = { ...pool.byPlayer };
|
||||
} else {
|
||||
|
||||
@@ -42,6 +42,11 @@ test('protocol: g.unitStats { seq? } and g.bandFocus { bandId? } validate; m.uni
|
||||
assert.equal(validateC2S({ t: 'g.bandFocus', bandId: 'band_bldsk' }), null);
|
||||
assert.notEqual(validateC2S({ t: 'g.bandFocus', bandId: 'a b' }), null);
|
||||
assert.ok(S2C.includes('m.unitStats'));
|
||||
assert.equal(validateC2S({ t: 'g.choice', idx: 0 }), null);
|
||||
assert.equal(validateC2S({ t: 'g.choice', idx: 2, choiceId: 'seed-2.choice.12' }), null);
|
||||
for (const choiceId of [null, '', 'a b', 12, 'a'.repeat(65)]) {
|
||||
assert.notEqual(validateC2S({ t: 'g.choice', idx: 0, choiceId }), null);
|
||||
}
|
||||
});
|
||||
|
||||
test('unitStatsEntry: effective stats next to the base, rounded for display; the interval from bat / aspd when absent', () => {
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { setTimeout as pause } from 'node:timers/promises';
|
||||
import { PHASE } from '../../shared/constants.js';
|
||||
import { runHeadless, validateClientResult, RESULT_GRACE_MS } from '../../server/match/fields.js';
|
||||
import { RealScheduler } from '../../server/match/scheduler.js';
|
||||
import { compactResult, createBattleFromSpec, resultDigest } from '../../server/sim/spec.js';
|
||||
import { makeMatch } from './harness.js';
|
||||
|
||||
const SLICE_MS = 0.000001;
|
||||
|
||||
function combat(t, verify = 'all', options = {}) {
|
||||
const h = makeMatch({ mode: 'coop', humans: 1, bots: 0, seed: 345, clientCombat: true,
|
||||
clients: false, verify, headlessSliceMs: SLICE_MS, timerScale: 0, ...options });
|
||||
t.after(() => h.m.dispose());
|
||||
h.start();
|
||||
assert.ok(h.drive(() => h.m.phase === PHASE.COMBAT));
|
||||
return h;
|
||||
}
|
||||
|
||||
function results(h, f) {
|
||||
const run = runHeadless(createBattleFromSpec(f.spec, h.m.ds, { recordEvents: false, quiet: true }), { players: f.players });
|
||||
const checked = validateClientResult(f.spec, compactResult(run.result), { gd: h.m.gd });
|
||||
assert.ok(checked.ok, checked.reason);
|
||||
const server = checked.result;
|
||||
assert.ok(server.perPlayer.p_0.leaked.length > 0, 'the empty board leaks real enemies');
|
||||
const forged = structuredClone(server);
|
||||
forged.killed = forged.total;
|
||||
Object.assign(forged.perPlayer.p_0, { leaked: [], perfect: true, killed: forged.perPlayer.p_0.total });
|
||||
const plausible = validateClientResult(f.spec, forged, { gd: h.m.gd });
|
||||
assert.ok(plausible.ok, plausible.reason);
|
||||
return { server, forged: plausible.result };
|
||||
}
|
||||
|
||||
function report(h, f, result) {
|
||||
assert.deepEqual(h.m.handle('p_0', { t: 'b.result', battleId: f.battleId, result }), { ok: true });
|
||||
}
|
||||
|
||||
function observeSimulation(m, onStep = () => {}) {
|
||||
const seen = { battles: 0, steps: 0 };
|
||||
const create = m._specBattle.bind(m);
|
||||
m._specBattle = (...args) => {
|
||||
const battle = create(...args);
|
||||
seen.battles++;
|
||||
const step = battle.step.bind(battle);
|
||||
battle.step = (...stepArgs) => {
|
||||
const result = step(...stepArgs);
|
||||
seen.steps++;
|
||||
onStep(battle, seen);
|
||||
return result;
|
||||
};
|
||||
return battle;
|
||||
};
|
||||
return seen;
|
||||
}
|
||||
|
||||
test('all verification yields between slices and settles with the synchronous server digest', (t) => {
|
||||
const h = combat(t);
|
||||
const m = h.m;
|
||||
const f = m.fields[0];
|
||||
const { server, forged } = results(h, f);
|
||||
let between = null;
|
||||
const seen = observeSimulation(m, (battle, state) => {
|
||||
if (state.steps === 1) h.sched.setTimeout(() => {
|
||||
between = { steps: state.steps, finished: battle.finished, checked: m.verifyStats.checked, done: f.done };
|
||||
}, 0);
|
||||
});
|
||||
report(h, f, forged);
|
||||
assert.equal(seen.steps, 0, 'report handling schedules simulation without stepping it');
|
||||
assert.equal(f.done, false, 'the result awaits verification');
|
||||
assert.equal(m.lastResults.has('p_0'), false, 'settlement cannot consume the unverified report');
|
||||
assert.ok(h.run(() => m.verifyStats.checked === 1));
|
||||
assert.ok(between && between.steps > 0, 'an unrelated timer runs after simulation starts');
|
||||
assert.deepEqual([between.finished, between.checked, between.done], [false, 0, false], 'the timer runs between unfinished slices');
|
||||
assert.equal(resultDigest(f.result).hash, resultDigest(server).hash, 'sliced verification matches synchronous simulation');
|
||||
assert.equal(m.verifyStats.mismatches, 1);
|
||||
assert.equal(m.verifyStats.rejected, 0, 'the forgery passes plausibility validation');
|
||||
h.runToPhase(PHASE.SETTLE);
|
||||
assert.deepEqual(m.lastResults.get('p_0').leaked, server.perPlayer.p_0.leaked);
|
||||
assert.equal(m.errorCount, 0);
|
||||
});
|
||||
|
||||
for (const verify of ['all', 'sample']) {
|
||||
test(`${verify} verification lets an unrelated real timer run between battle slices`, async (t) => {
|
||||
const h = makeMatch({ mode: 'coop', humans: 1, bots: 0, seed: 345, clientCombat: true,
|
||||
clients: false, verify, headlessSliceMs: SLICE_MS, timerScale: 0 });
|
||||
const m = h.m;
|
||||
// Install the real scheduler before start, while the harness has no queued timers.
|
||||
const scheduler = new RealScheduler();
|
||||
m.sched = scheduler;
|
||||
t.after(() => { m.dispose(); scheduler.dispose(); });
|
||||
const waitFor = async (predicate) => {
|
||||
const deadline = Date.now() + 10000;
|
||||
while (!predicate() && Date.now() < deadline) await pause(1);
|
||||
assert.ok(predicate(), `timed out in phase ${m.phase}`);
|
||||
};
|
||||
h.autoHumans().start();
|
||||
await waitFor(() => m.phase === PHASE.COMBAT);
|
||||
const f = m.fields[0];
|
||||
const result = compactResult(runHeadless(createBattleFromSpec(f.spec, m.ds, { recordEvents: false, quiet: true }), { players: f.players }).result);
|
||||
let between = null;
|
||||
observeSimulation(m, (battle, seen) => {
|
||||
if (seen.steps === 1) setTimeout(() => {
|
||||
between = { steps: seen.steps, finished: battle.finished, checked: m.verifyStats.checked, done: f.done };
|
||||
}, 0);
|
||||
});
|
||||
report(h, f, result);
|
||||
await waitFor(() => between !== null);
|
||||
assert.ok(between.steps > 0, 'the timer was scheduled by the first actual battle step');
|
||||
assert.deepEqual([between.finished, between.checked, between.done], [false, 0, verify === 'sample'], 'Node executes the timer while verification is unfinished');
|
||||
await waitFor(() => m.verifyStats.checked === 1);
|
||||
assert.equal(resultDigest(f.result).hash, resultDigest(result).hash);
|
||||
assert.equal(m.verifyStats.mismatches, 0);
|
||||
assert.equal(m.errorCount, 0);
|
||||
});
|
||||
}
|
||||
|
||||
test('duplicate reports during all verification start one simulation and settle once', (t) => {
|
||||
const h = combat(t);
|
||||
const m = h.m;
|
||||
const f = m.fields[0];
|
||||
const { forged } = results(h, f);
|
||||
const seen = observeSimulation(m);
|
||||
let settlements = 0;
|
||||
const settle = m.settle.bind(m);
|
||||
m.settle = (...args) => { settlements++; return settle(...args); };
|
||||
report(h, f, forged);
|
||||
assert.equal(f.done, false, 'the duplicate arrives while verification is pending');
|
||||
report(h, f, forged);
|
||||
assert.ok(h.run(() => seen.steps > 0));
|
||||
report(h, f, forged);
|
||||
h.runToPhase(PHASE.SETTLE);
|
||||
report(h, f, forged);
|
||||
assert.equal(seen.battles, 1);
|
||||
assert.equal(m.verifyStats.checked, 1);
|
||||
assert.equal(m.verifyStats.mismatches, 1);
|
||||
assert.equal(settlements, 1);
|
||||
});
|
||||
|
||||
test('sample verification keeps the accepted result and finishes after the phase changes', (t) => {
|
||||
// Seed 345 selects the first field for sampling; zero phase delays let settlement overtake verification.
|
||||
const h = combat(t, 'sample');
|
||||
const m = h.m;
|
||||
const f = m.fields[0];
|
||||
const { forged } = results(h, f);
|
||||
const seen = observeSimulation(m);
|
||||
report(h, f, forged);
|
||||
assert.equal(f.done, true, 'sampling accepts the report immediately');
|
||||
assert.equal(seen.steps, 0);
|
||||
h.runToPhase(PHASE.SETTLE);
|
||||
assert.equal(m.verifyStats.checked, 0, 'the sampled battle is still running after settlement');
|
||||
assert.ok(h.run(() => m.verifyStats.checked === 1));
|
||||
assert.equal(m.verifyStats.mismatches, 1);
|
||||
assert.equal(resultDigest(f.result).hash, resultDigest(forged).hash);
|
||||
assert.deepEqual(m.lastResults.get('p_0').leaked, [], 'sampling never replaces the accepted result');
|
||||
assert.ok(h.logs.warn.some((line) => line.includes('client result differs')));
|
||||
assert.equal(m.errorCount, 0);
|
||||
});
|
||||
|
||||
for (const event of ['disconnect and reconnect', 'expired deadline']) {
|
||||
test(`pending all verification does not restart simulation after ${event}`, (t) => {
|
||||
const h = combat(t);
|
||||
const m = h.m;
|
||||
const f = m.fields[0];
|
||||
const { forged } = results(h, f);
|
||||
const seen = observeSimulation(m);
|
||||
// Leave an already-due deadline callback queued when the result arrives.
|
||||
if (event === 'expired deadline') h.sched.t += f.spec.timeLimit / m.gameSpeed * 1000 + RESULT_GRACE_MS + 1;
|
||||
report(h, f, forged);
|
||||
assert.equal(f.done, false);
|
||||
if (event === 'disconnect and reconnect') {
|
||||
m.onDisconnect('p_0');
|
||||
m.onReconnect('p_0');
|
||||
assert.equal(h.lastTo('p_0', 'b.start').authoritative, false, 'reconnect does not request a second result');
|
||||
}
|
||||
h.runToPhase(PHASE.SETTLE);
|
||||
assert.equal(seen.battles, 1);
|
||||
assert.equal(m.verifyStats.takeovers, 0, 'a received result no longer needs a client authority');
|
||||
assert.equal(m.verifyStats.checked, 1);
|
||||
assert.equal(m.verifyStats.mismatches, 1);
|
||||
});
|
||||
}
|
||||
|
||||
test('resuming a solo pause does not rearm a result deadline while verification is pending', (t) => {
|
||||
const h = combat(t, 'all', { mode: 'solo' });
|
||||
const m = h.m;
|
||||
const f = m.fields[0];
|
||||
const { forged } = results(h, f);
|
||||
const seen = observeSimulation(m);
|
||||
assert.deepEqual(m.handle('p_0', { t: 'g.pause', on: true }), { ok: true });
|
||||
report(h, f, forged);
|
||||
assert.equal(f.done, false);
|
||||
assert.deepEqual(m.handle('p_0', { t: 'g.pause', on: false }), { ok: true });
|
||||
h.sched.t += f.spec.timeLimit / m.gameSpeed * 1000 + RESULT_GRACE_MS + 1;
|
||||
h.runToPhase(PHASE.SETTLE);
|
||||
assert.equal(seen.battles, 1);
|
||||
assert.equal(m.verifyStats.takeovers, 0);
|
||||
assert.equal(m.verifyStats.checked, 1);
|
||||
});
|
||||
|
||||
test('a verification comparison error falls back to a server result and completes the field', (t) => {
|
||||
const h = combat(t);
|
||||
const m = h.m;
|
||||
const f = m.fields[0];
|
||||
const { server, forged } = results(h, f);
|
||||
const seen = observeSimulation(m);
|
||||
const create = m._specBattle.bind(m);
|
||||
m._specBattle = (...args) => {
|
||||
const battle = create(...args);
|
||||
if (seen.battles === 1) {
|
||||
const result = battle.result.bind(battle);
|
||||
battle.result = () => {
|
||||
const out = result();
|
||||
Object.defineProperty(out.perPlayer.p_0, 'unitStats', { get() { throw new Error('verification result cannot be normalized'); } });
|
||||
return out;
|
||||
};
|
||||
}
|
||||
return battle;
|
||||
};
|
||||
report(h, f, forged);
|
||||
h.runToPhase(PHASE.SETTLE);
|
||||
assert.equal(seen.battles, 2, 'the failed comparison is followed by one independent server run');
|
||||
assert.equal(f.resultSource, 'server');
|
||||
assert.equal(resultDigest(f.result).hash, resultDigest(server).hash, 'fallback never accepts the forged result');
|
||||
assert.equal(m.errorCount, 1);
|
||||
assert.ok(h.logs.error.some((line) => line.includes('verification result cannot be normalized')));
|
||||
assert.deepEqual(h.sched.errors, []);
|
||||
});
|
||||
|
||||
for (const verify of ['all', 'sample']) {
|
||||
test(`dispose stops pending ${verify} verification without later completion`, (t) => {
|
||||
const h = combat(t, verify, { timerScale: 1 });
|
||||
const m = h.m;
|
||||
const f = m.fields[0];
|
||||
const { forged } = results(h, f);
|
||||
const seen = observeSimulation(m);
|
||||
report(h, f, forged);
|
||||
assert.ok(h.run(() => seen.steps > 0));
|
||||
assert.equal(m.verifyStats.checked, 0, 'dispose interrupts an unfinished verification');
|
||||
m.dispose();
|
||||
const steps = seen.steps;
|
||||
h.sched.runAll({ maxSteps: 10000 });
|
||||
assert.equal(seen.steps, steps, 'no further battle steps execute');
|
||||
assert.equal(m.verifyStats.checked, 0);
|
||||
assert.equal(m.verifyStats.mismatches, 0);
|
||||
assert.equal(f.done, verify === 'sample');
|
||||
assert.equal(m.errorCount, 0);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user