Files
ark-stronghold-protocol/test/match/combat.test.js
T
sganggs d4f868cd19 Playtest #4 fixes: tile picking, knocked-down state, element gauges, draft timer, live stats, shop items, drones, 赫默, 炎佑, UI flash
- #1 picking by the tile under the pointer (prep, battle, pen, equipment drops); the dragged model stays
  under the pointer (mouse and touch); pixel probe removed; phone ghost-click on the underframe fixed
- #2 two-tap confirm on 机变 cards (like the shop)
- #3 a 同盟 room with one human is untimed like 独立模拟 (no countdown outside battles)
- #4 strategy draft: one synced countdown, 30 s per human turn, timeout takes the highlighted band
- #5 special 维式重锤 (4) and 突变细胞 no longer sold; item card shows where they come from
- #6 operator skill-mode sounds no longer play on normal attacks (纯烬艾雅法拉 and 55 others)
- #7 detail card shows live battle stats and the prep-time battle-start stats (g.unitStats)
- #8 official element gauges on units; burst cooldown (阻回) per PRTS
- #9 knocked-down operators stay on their tile with a redeploy countdown ring
- #10 drone behaviour per PRTS (暴鸰, 先兆者 shells, 黑云, 萨科塔之眼)
- #11 赫默's drone only from her skill; placeable summons limited to talent tokens
- #12 炎佑 stays where it is with no enemies; 祛恶之焰 as a continuous channel
- #13 the occasional giant bond icon (loading texture scaled ×46) fixed
- docs (DESIGN §18), recordings, data and tests updated (2467 unit tests, all browser suites green)
2026-09-30 05:35:45 +08:00

236 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// COMBAT orchestration, watchers, SETTLE (LP, coins, layers, elimination), 联防, error isolation — with FakeBattle.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { PHASE } from '../../shared/constants.js';
import { GEO } from '../../shared/constants.js';
import { FakeBattle } from './fakeBattle.js';
import { DATA, makeMatch, give, checkInvariants, chessOfTier, legalTileFor } from './harness.js';
const normalFields = () => FakeBattle.instances.filter((b) => b.kind === 'normal');
test('COMBAT: one battle per alive player with the board as input; watchers get m.field + b.snap/b.ev; g.watch switches', () => {
const h = makeMatch({ mode: 'coop', humans: 2, bots: 1, seed: 31, fake: true, instant: false, script: () => ({ duration: 3 }) }).start();
const m = h.m;
h.toPrep(1);
const a = h.ps('p_0');
const id = chessOfTier(1, (c) => c.position === 'RANGED').find((x) => m.pool.has(x));
const piece = give(m, a, id);
const tile = [...a.deployMap()].find(([, v]) => v)[0].split(',').map(Number);
m.handle('p_0', { t: 'g.move', uid: piece.uid, to: { area: 'board', row: tile[0], col: tile[1] } });
m.handle('p_0', { t: 'g.ready', ready: true });
m.handle('p_1', { t: 'g.ready', ready: true });
h.run(() => m.phase === PHASE.COMBAT);
assert.equal(m.fields.length, 3);
const fa = FakeBattle.instances.find((b) => b.fieldId === 'n:p_0');
assert.equal(fa.kind, 'normal');
assert.deepEqual(fa.opts.rect, GEO.NORMAL_RECT);
assert.equal(fa.opts.timeLimit, m.gd.combatTimeLimit(1));
assert.equal(fa.opts.flags.layerGainsEnabled, true);
assert.equal(fa.opts.players.length, 1);
assert.deepEqual(fa.opts.players[0].units.map((u) => [u.chessId, u.row, u.col]), [[id, tile[0], tile[1]]]);
assert.ok(fa.opts.spawns.length > 0 && fa.opts.spawns.every((s) => s.ownerPlayerId === 'p_0'));
const seeds = new Set(normalFields().map((b) => b.opts.seed));
assert.equal(seeds.size, 3, 'every field has its own seed');
const field = h.lastTo('p_0', 'm.field');
assert.equal(field.fieldId, 'n:p_0');
const pub = m.publicView();
assert.equal(pub.players.find((p) => p.playerId === 'p_0').status, 'combat');
assert.equal(pub.players.find((p) => p.playerId === 'p_0').fieldId, 'n:p_0');
// real-time pacing: 2 ticks per real 1/30 s, a snapshot every 3 ticks
const before = h.allTo('p_0', 'b.snap').length;
h.sched.advance(1000);
const snaps = h.allTo('p_0', 'b.snap').length - before;
assert.ok(snaps >= 17 && snaps <= 23, `≈20 snapshots per real second, got ${snaps}`);
const last = h.lastTo('p_0', 'b.snap');
assert.equal(typeof last.gt, 'number', 'game time travels as gt (t is the frame type)');
assert.ok(Array.isArray(last.units));
assert.ok(Math.abs(fa.time - 2) < 0.2, `game time runs at 2× (${fa.time})`);
assert.ok(h.allTo('p_0', 'b.ev').length > 0);
assert.ok(h.allTo('p_0', 'b.snap').every((s) => s.fieldId === 'n:p_0'));
// switch to the teammate's field
assert.deepEqual(m.handle('p_0', { t: 'g.watch', fieldId: 'n:p_1' }), { ok: true });
assert.equal(h.lastTo('p_0', 'm.field').fieldId, 'n:p_1');
h.sched.advance(200);
assert.equal(h.lastTo('p_0', 'b.snap').fieldId, 'n:p_1');
assert.equal(m.handle('p_0', { t: 'g.watch', fieldId: 'b9' }).error, 'BAD_TARGET');
// fields finish at different times: m.public reflects it (status done, live false) while others still fight
FakeBattle.instances.find((b) => b.fieldId === 'n:p_1').forceEnd('forced');
h.sched.advance(300);
const live = h.lastBc('m.public');
assert.equal(live.fields.find((f) => f.fieldId === 'n:p_1').live, false);
assert.equal(live.players.find((p) => p.playerId === 'p_1').status, 'done');
assert.equal(live.players.find((p) => p.playerId === 'p_0').status, 'combat');
h.run(() => m.phase === PHASE.SETTLE);
m.dispose();
});
test('SETTLE: LP −min(leaks, 10), bounty coins next round, IN_BATTLE layer gains applied, perfect rounds counted', () => {
const h = makeMatch({
mode: 'coop', humans: 2, seed: 32, fake: true,
script: (b) => (b.round === 1 ? { leaks: { p_0: 14, p_1: 3 }, coins: { p_0: 2 }, layerGains: { p_0: { yanShip: 5 } } } : {}),
}).start();
const m = h.m;
h.toPrep(1);
const [a, b] = [h.ps('p_0'), h.ps('p_1')];
const lpA = a.lp;
const lpB = b.lp;
h.drive(() => m.phase === PHASE.SETTLE);
assert.equal(a.lp, lpA - 10, 'capped at 10');
assert.equal(b.lp, lpB - 3);
assert.equal(a.pendingFunds, 2);
assert.equal(a.layers.yanShip, 5);
assert.equal(a.stats.leaks, 14);
assert.equal(m.unitePlan, null, 'nobody perfect → no 联防');
h.drive(() => m.phase === PHASE.PREP && m.round === 2);
assert.equal(a.funds, m.gd.income(2) + 2, 'coins credited at the next round start');
checkInvariants(m);
m.dispose();
});
test('elimination: LP ≤ 0 → out, copies back to the pool; everyone out → RESULT defeat', () => {
// p_1 is perfect → 联防 re-fights p_0's leaks; all 10 survive and cost p_0 its last LP
const h = makeMatch({ mode: 'coop', humans: 2, seed: 33, fake: true, script: (b) => (b.kind === 'unite' ? { survivors: { p_0: 10 } } : { leaks: Object.fromEntries(b.players.map((p) => [p, p === 'p_0' ? 10 : 0])) }) }).start();
const m = h.m;
h.toPrep(1);
const a = h.ps('p_0');
const id = chessOfTier(2).find((x) => m.pool.has(x));
give(m, a, id);
const left = m.pool.left(id);
a.lp = 5;
h.drive(() => m.phase === PHASE.SETTLE);
assert.equal(a.alive, false);
assert.equal(a.lp, 0);
assert.equal(a.eliminatedRound, 1);
assert.equal(m.pool.left(id), left + 1);
assert.equal(m.publicView().players.find((p) => p.playerId === 'p_0').status, 'dead');
assert.deepEqual(m.handle('p_0', { t: 'g.refresh' }), { error: 'ELIMINATED' });
h.drive(() => m.phase === PHASE.PREP && m.round === 2);
assert.equal(a.funds, 0, 'eliminated players get no income');
// the survivor keeps playing; now kill them too
FakeBattle.script = (bt) => ({ leaks: Object.fromEntries(bt.players.map((p) => [p, 10])) });
h.ps('p_1').lp = 3;
const end = h.drive(() => h.ended != null);
assert.ok(end);
assert.equal(h.ended.victory, false);
assert.equal(h.ended.reason, 'eliminated');
const rows = Object.fromEntries(h.ended.players.map((p) => [p.playerId, p]));
assert.equal(rows.p_0.roundsPassed, 0);
assert.equal(rows.p_1.roundsPassed, 1);
assert.equal(h.endedCount, 1);
checkInvariants(m);
m.dispose();
});
test('联防: trigger, helpers (≤ 2, highest LP then seat), carry state, sources, survivors cost the SOURCE (cap 10)', () => {
const h = makeMatch({
mode: 'coop', humans: 4, seed: 34, fake: true,
script: (b) => {
if (b.kind === 'normal') return { leaks: { p_0: 6, p_3: 12 } };
if (b.kind === 'unite') return { survivors: { p_0: 2, p_3: 11 } };
return {};
},
}).start();
const m = h.m;
h.toPrep(1);
const ps = ['p_0', 'p_1', 'p_2', 'p_3'].map((id) => h.ps(id));
ps[1].lp = 30; ps[2].lp = 30; ps[0].lp = 40; ps[3].lp = 40;
// give p_1 a unit so the helper input has something to carry
const id = chessOfTier(1, (c) => c.position === 'MELEE').find((x) => m.pool.has(x));
const unit = give(m, ps[1], id, 'board', [...ps[1].deployMap()].find(([, v]) => v === 'melee')[0].split(',').map(Number));
h.drive(() => m.phase === PHASE.UNITE);
const plan = m.unitePlan;
assert.deepEqual(plan.leakers.map((p) => p.playerId).sort(), ['p_0', 'p_3']);
assert.deepEqual(plan.helpers.map((p) => p.playerId), ['p_1', 'p_2'], 'equal LP → lower seat first');
const u = FakeBattle.instances.find((b) => b.kind === 'unite');
assert.equal(u.fieldId, 'u');
assert.deepEqual(u.opts.rect, GEO.UNITE_RECT);
assert.equal(u.opts.flags.layerGainsEnabled, false);
// unite.js (research 08 §5): the first-ranked helper "率先迎敌" on the right-hand field (colOffset +8)
assert.deepEqual(u.opts.players.map((p) => [p.playerId, p.colOffset]), [['p_1', 8], ['p_2', 0]]);
const carried = u.opts.players[0].units.find((x) => x.uid === unit.uid);
assert.deepEqual(carried.carryState, { hpPct: 0.5, sp: 3, skillActive: false }, 'HP%/SP from the end of the own combat');
assert.equal(u.opts.spawns.length, 18, 'union of every counted leak');
assert.equal(u.opts.spawns.filter((s) => s.sourcePlayerId === 'p_0').length, 6);
assert.equal(u.opts.spawns.filter((s) => s.sourcePlayerId === 'p_3').length, 12);
assert.equal(u.opts.timeLimit, m.gd.combatTimeLimit(1));
assert.equal(m.publicView().players.find((p) => p.playerId === 'p_1').status, 'helping');
assert.equal(m.publicView().fields[0].fieldId, 'u');
// every human watches the unite field by default
assert.equal(h.lastTo('p_3', 'm.field').fieldId, 'u');
h.drive(() => m.phase === PHASE.SETTLE);
assert.equal(ps[0].lp, 38, 'p_0: 2 survivors');
assert.equal(ps[3].lp, 30, 'p_3: 11 survivors capped at 10');
assert.equal(ps[1].lp, 30);
assert.equal(ps[2].lp, 30);
checkInvariants(m);
m.dispose();
});
test('联防: an operator dead at the end of the helper\'s own combat stays out (with its summons); the living carry their state', () => {
let deadUid = null;
const h = makeMatch({
mode: 'coop', humans: 2, seed: 37, fake: true,
script: (b) => (b.kind === 'normal' ? { leaks: { p_0: 3 }, deadUids: deadUid != null ? [deadUid] : [] } : {}),
}).start();
const m = h.m;
h.toPrep(1);
const helper = h.ps('p_1');
// 伺夜 (placeable talent summon 狼群) is knocked out in p_1's own combat; a second operator survives it
const t0 = legalTileFor(m, helper, 'chess_char_3_19_a');
const hemo = give(m, helper, 'chess_char_3_19_a', 'board', t0);
const other = chessOfTier(1, (c) => c.position === 'RANGED').find((x) => m.pool.has(x));
const alive = give(m, helper, other, 'board', legalTileFor(m, helper, other));
const stack = helper.hand.find((p) => p && p.kind === 'token' && p.ownerUid === hemo.uid);
assert.ok(stack, '伺夜 brought its summon stack');
let placed = null;
for (const [k] of helper.deployMap()) {
const [r, c] = k.split(',').map(Number);
if (helper.board.has(k)) continue;
if (m.handle('p_1', { t: 'g.move', uid: stack.uid, to: { area: 'board', row: r, col: c } }).ok) { placed = k; break; }
}
assert.ok(placed, 'the summon stands on the board');
deadUid = hemo.uid;
h.drive(() => m.phase === PHASE.UNITE);
assert.deepEqual(m.unitePlan.helpers.map((p) => p.playerId), ['p_1']);
const u = FakeBattle.instances.find((b) => b.kind === 'unite');
const units = u.opts.players[0].units;
assert.ok(!units.some((x) => x.uid === hemo.uid), 'the dead operator is not redeployed at full HP');
assert.ok(!units.some((x) => x.kind === 'token' && x.ownerUid === hemo.uid), 'nor are its summons');
assert.deepEqual(units.find((x) => x.uid === alive.uid).carryState, { hpPct: 0.5, sp: 3, skillActive: false });
assert.ok(helper.board.has(`${t0[0]},${t0[1]}`) && helper.board.has(placed), 'the board itself is untouched (next round as usual)');
// during 联防 an 'n:<pid>' id names no field: refused, and the viewer keeps streaming the 联防 field
assert.equal(m.watchers.get('p_0'), 'u');
assert.deepEqual(m.handle('p_0', { t: 'g.watch', fieldId: 'n:p_1' }), { error: 'BAD_TARGET', detail: 'no such field' });
assert.equal(m.watchers.get('p_0'), 'u');
h.drive(() => m.phase === PHASE.SETTLE);
// outside battles the prep scouting view works as before
assert.deepEqual(m.handle('p_0', { t: 'g.watch', fieldId: 'n:p_1' }), { ok: true });
assert.equal(h.lastTo('p_0', 'm.field').prep, true);
checkInvariants(m);
m.dispose();
});
test('联防 is skipped when everyone is perfect, everyone leaked, or in solo', () => {
for (const [mode, humans, leaks] of [['coop', 2, {}], ['coop', 2, { p_0: 3, p_1: 2 }], ['solo', 1, { p_0: 5 }]]) {
const h = makeMatch({ mode, humans, seed: 35, fake: true, script: () => ({ leaks }) }).start();
h.toPrep(1);
h.drive(() => h.m.phase === PHASE.SETTLE || h.m.phase === PHASE.UNITE);
assert.equal(h.m.phase, PHASE.SETTLE, `${mode} ${JSON.stringify(leaks)}`);
assert.ok(!FakeBattle.instances.some((b) => b.kind === 'unite'));
h.m.dispose();
}
});
test('error isolation: a battle that throws is force-ended as a timeout; a constructor failure yields a clean result', () => {
const h = makeMatch({ mode: 'coop', humans: 2, seed: 36, fake: true, script: (b) => (b.fieldId === 'n:p_0' ? { throwAt: 10 } : b.fieldId === 'n:p_1' ? { throwInCtor: true } : {}) }).start();
const m = h.m;
h.toPrep(1);
h.drive(() => m.phase === PHASE.SETTLE);
assert.ok(m.errorCount >= 2, 'both failures reported');
assert.equal(h.ps('p_1').lp, h.ps('p_1').lp, 'still alive');
assert.ok(h.ps('p_0').alive && h.ps('p_1').alive);
h.drive(() => m.phase === PHASE.PREP && m.round === 2);
assert.equal(m.round, 2, 'the match continues');
m.dispose();
});