- #1 近地悬浮 enemies count as air units (Unit.isFlying): ground-only attacks, splash, terrain and traps skip them; PRTS 不可对空 skill effects made ground-only; 掠海漂移体 / 吉兆飞鳞 drop modes; the bot plans them as air - #2 an operator knocked out before 联防 is fielded and force-exited (PRTS 强制退场): it lies on its tile with the redeploy ring and redeploys (full timer [ASSUMED]); partners, observers and the server fallback included - #3 element audit of every operator, item and enemy: 海嗣 numbers are official; bursts are 无来源, 元素脆弱 only raises 元素伤害, 元素抗性 applied, 盟约·辅助干员 fills all three gauges, 菲莱 / 哈洛德 / 纯烬 / enemy kits fixed - #4 blocking by the official contact radius (0.7071): another operator in contact takes over at once when the blocker dies, withdraws or is stunned; melee blockers attack what they block - #5 enemy target order per PRTS 索敌: blocker, special priority, taunt, last deployed; initial deploy 从上到下>从左到右, operators before summons; special priorities break ties by taunt then deploy order - #6 act2 m01's normal-field blower machines (row 13) are drawn in the normal / 联防 views - #7 Final Assault prep legality reads the player's half of the boss field (fence tiles deployable); all 11 stages audited against the official level files - #8 regular settings gear; #9 the prep camera keeps the bench and temp rows clear of the shop bar on phones - #10 huge bosses have the official 4.95 × 2.95 hit area (sim/body.js) and are 自缚 / unblockable - #11 至简 costs 1: 购买价格为N's bb.price is a discount off the tier price, applied before other price effects - DESIGN §19 and the normative §3 / §5 / §6 / §7 lines, SIM / META / DATA / PLAYING updated; 2595 unit tests and every browser suite green; dev recordings regenerated
238 lines
13 KiB
JavaScript
238 lines
13 KiB
JavaScript
// 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 knocked out at the end of the helper\'s own combat is fielded `down` (强制退场, 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;
|
||
// PRTS 卫戍协议/帮助: deployed, then "上一阶段为退场状态的干员强制退场" — the sim puts it down on its tile with its redeploy
|
||
// timer (user playtest #5 item 2: it used to be left out and vanished); its summons are fielded as the board has them
|
||
assert.deepEqual(units.find((x) => x.uid === hemo.uid)?.carryState, { down: true }, 'the knocked-out operator enters 联防 down');
|
||
assert.ok(units.some((x) => x.kind === 'token' && x.ownerUid === hemo.uid), 'its summons are fielded');
|
||
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();
|
||
});
|