feedback6: merge R21G — battle control messages that arrive while the sim loads or catches up (takeover, forced end, a duplicate start, pool, pause) are held and applied in order (PR #266 by @siruimei07, ported); golden unchanged

This commit is contained in:
sganggs committed 2026-10-07 17:57:18 +08:00
commit 53ccf5d8fe
3 files changed
+381 -57

No files matched your search

+2 -2
View File
@@ -79,7 +79,7 @@ Piece = { uid, kind: 'chess'|'item'|'token', id, golden: bool, tier, items: [ {
- C→S `b.progress { battleId, gt, killed, total, leaks?, left?, bossDmg?, by?, done? }` — authoritative clients, ~1 Hz (boss fields 4 Hz). Normal/联防: `leaks` = counted leaks so far; 联防: `left` = per leaker its enemies still standing (`spec.js uniteLeft`, clamped by the server to `fields.js uniteBillBounds`; §20.6). Boss: `bossDmg` = cumulative damage of this field to the shared pool, `by` = the same per player, `leaks` = the field's cumulative LP cost (leaks × `lpr` + leader "扣除目标生命" effects; `spec.js attachLpMeter`). The server applies deltas (never twice). Stale reports (unknown battle, not the authority, after the match ended) are ignored, never answered with an error; the platform never answers a rid-less `b.progress` with an error frame (it would surface as a toast).
- C→S `b.result { battleId, result }` — authoritative client at battle end; `result` = `compactResult(BattleResult)` (the fields the match consumes; ≤ 4 players, ≤ 400 leaks, ≤ 64 unitsEnd, ≤ 160 unitStats, ≤ 40 layer gains; `fitResult` keeps the frame ≤ 60 KB: unit statistics, then leak mods, then — boss only — leak entries go first). Structural checks in `isBattleResult`; `server/net.js` routes `b.*` frames to the match (lobby `routeGame`).
- S→C `b.pool { hp, max, teamLp, acked: { [fieldId]: cumulative damage counted } }` (boss rounds, ≤ 4 Hz; exact numbers, never rounded) — clients reconcile their `LocalBossPool`: displayed pool = server hp − (local cumulative damage − acked). Pools never hold less than 1 HP (`BOSS_POOL_MIN_HP`: `SharedBossPool` and `LocalBossPool`; a browser pool reads any finite rest below 1 as 0 — §20.5); the 'cleared'-result dust rule (a boss field whose client result is `'cleared'` while < 1 HP is left on the server empties the pool) only catches noise at that boundary.
- S→C `b.end { battleId, fieldId, reason }` — `'cleared'` (pool 0) / `'forced'` (team LP 0): the client calls `battle.forceEnd` and reports; `'takeover'`: stop reporting, keep displaying.
- S→C `b.end { battleId, fieldId, reason }` — `'cleared'` (pool 0) / `'forced'` (team LP 0): the client calls `battle.forceEnd` and reports; `'takeover'`: stop reporting, keep displaying. Both also reach a battle the client is still preparing (the sim loading, the silent catch-up): Client runner below.
- `m.public.fields[]` gains `progress: { killed, total, done }`; `m.public.combatMode`; `m.public.paused: boolean` (always present; solo pause below).
- `b.snap` / `b.ev` are **not sent for combat** in client mode. `g.watch` rules: see Spectating.
@@ -91,7 +91,7 @@ Piece = { uid, kind: 'chess'|'item'|'token', id, golden: bool, tier, items: [ {
**Spectating (research 09 §3.1 / §6.3).** No live spectating while your own normal battle runs (`g.watch` → `WRONG_PHASE 'own battle running'`; the UI toasts "作战中无法查看队友"). After your own battle ends you see "⌛ 作战结束,等待队友完成作战" with each teammate's live progress (`m.public.fields[].progress`); tapping a teammate's avatar expands **前往查看** → `g.watch` → `b.start` (display replica fast-forwarded to the field clock); the own row shows **返回战场**. Prep: the same flow shows the teammate's board (`m.field` with `prep: true`) and sends it again when that board changes (GitHub #87). In a boss round's prep that board is built on the boss field — the player's half (rows − 7, the right half mirrored with RIGHT ↔ LEFT, `server/match/finalAssault.js bossFieldPlacement`), `kind: 'boss'`, `side`, the leader in `nextEnemies` at its spawn tile — and framed by the boss-field prep camera of that side, so a watcher sees the leader as the players do (0.2.0, community report item 55). The scout is framed by the prep camera in its shop-folded form and renders the teammate’s hand (整备区) and 临时整备区 as units on their rows — the own prep bench’s exact look (user playtest #2 item 1, the GitHub #44 suggestion; the spectator seat's scout view is the same, §23.19's never-a-private-view staying the no-`m.private` rule). While scouting, the 本局信息 drawer shows the watched player's 策略 in place of one's own (`game.js scoutPid` → `enemyDrawer.js InfoTab bandId / bandOwner`, labelled "X 的策略"; user playtest #2 item 2), and the right effects column follows the watched field too: the scout meta carries the player's effects view, a display-replica battle derives it from the spec's `playerEffects` (`battle/observe.js spectateEffects`) — undefined (empty column, never one's own) for 联防 / boss pair fields and server-run watched fields. 联防: every human receives the unite spec (helpers + observers). Final Assault: each human sees its own pair field; the other pair → `BAD_TARGET 'other group hidden'`. In 联防/FA fields the ‹ › pill switches the camera LEFT half / 全景 / RIGHT half ("你自己" / "👁 name" / "全景" / "无人在家"); there is **no** view switcher during normal combat. Eliminated players and spectator seats follow a player through every phase reset (0.2.0, community report item 56; the official DeadAutoObDn { obIndex, state, preparation }; the idea of PR #189): the player they last watched with a manual `g.watch` (`Match.watchPref`; a shared field records the `playerId` tapped, else nobody), else the first player still in (seat order, AI seats included) — the round start pushes that player's prep board (`Match._followScout`, adopted by the screen like a 前往查看 tap: `battle/observe.js followedScout`; kept through 机变 and 休整期), each battle starts on that player's field, a resync / a spectator joining in prep gets the board again; they may watch any field.
**Client runner.** `public/js/battle/runner.js` owns the local Battles: fixed-step clock at the battle speed (60 ticks per real second at 2×, at most `max(8, 4·speed)` ticks per frame, bounded fast-forward of ≤ 240 ticks per frame when far behind; a new battle is caught up silently before it is shown), feeds the existing renderer through the same `snapshot()`/`drainEvents()` wire formats every frame (`view.setLocalFeed` shrinks the interpolation buffer to ~2 frames), publishes the field meta (m.field shape + `local`, `battleId`, `players`, `sides`, `speed`) into `store.match.field` and its state into `store.match.battle` (with `leaks: { [fieldId]: counted leaks so far }` of every normal field it simulates and, while the 联防 field is on screen, `uniteLeft` per leaker (§20.6), authoritative or display replica — the settle rule's count, `/sim/spec.js battleProgress`; published in the frame a count changes: the live LP of §17.5), sends `b.progress`/`b.result` when authoritative (results retried once on timeout; a result lost with the socket — the request failed `DISCONNECTED` / `OFFLINE` or timed out twice — is kept and sent again when the session is back online and whenever a `b.start` still names that finished battle `authoritative: true`; the server answers a duplicate `ok` and changes nothing; a server refusal is final), follows `m.public.paused` (solo pause: every local clock stands still, resume shifts it by the paused time), handles `b.pool`/`b.end`, fast-forwards on reconnect/observe, and survives tab backgrounding (a 250 ms interval pump keeps authoritative battles on their clock while hidden; the server deadline + takeover cover extreme cases). Catch-up frames keep the state events and every enemy form fx (`keepsState`); while hidden, the battle on screen holds them for the first frame back (`hold`; past `HELD_MAX` the superseded status / skill toggles are dropped, `compactHeld`, and only a backlog still past it re-enters the view from the field meta), and a battle that ended while hidden delivers them when the tab is shown; a catch-up frame or a hidden step drains its events every `EV_SLICE` (15) ticks, each batch stamped with its own game time (`gt`), so a late form fx reaches the render engine as late as it is; the game screen's pre-entry buffer (`keepEarly`, also while a re-sent field meta re-enters the field on screen) and the render engine's event queue (`render/interp.js isCosmeticEvent`) never drop a form fx either (§21.4). Sim content errors are logged as console warnings (the server logs its own). The sim loader fetches the ten data files (one retry each) and fails when any is missing — a battle on partial data would report a plausible wrong result — so the next `b.start` retries and the server's deadline covers the field meanwhile. `public/js/battle/observe.js` holds the observing rules and labels.
**Client runner.** `public/js/battle/runner.js` owns the local Battles: fixed-step clock at the battle speed (60 ticks per real second at 2×, at most `max(8, 4·speed)` ticks per frame, bounded fast-forward of ≤ 240 ticks per frame when far behind; a new battle is caught up silently before it is shown), feeds the existing renderer through the same `snapshot()`/`drainEvents()` wire formats every frame (`view.setLocalFeed` shrinks the interpolation buffer to ~2 frames), publishes the field meta (m.field shape + `local`, `battleId`, `players`, `sides`, `speed`) into `store.match.field` and its state into `store.match.battle` (with `leaks: { [fieldId]: counted leaks so far }` of every normal field it simulates and, while the 联防 field is on screen, `uniteLeft` per leaker (§20.6), authoritative or display replica — the settle rule's count, `/sim/spec.js battleProgress`; published in the frame a count changes: the live LP of §17.5), sends `b.progress`/`b.result` when authoritative (results retried once on timeout; a result lost with the socket — the request failed `DISCONNECTED` / `OFFLINE` or timed out twice — is kept and sent again when the session is back online and whenever a `b.start` still names that finished battle `authoritative: true`; the server answers a duplicate `ok` and changes nothing; a server refusal is final), follows `m.public.paused` (solo pause: every local clock stands still, resume shifts it by the paused time), handles `b.pool`/`b.end`, fast-forwards on reconnect/observe, and survives tab backgrounding (a 250 ms interval pump keeps authoritative battles on their clock while hidden; the server deadline + takeover cover extreme cases). A `b.start` is registered as pending the moment it arrives — while the sim module still loads and while the new battle is built and caught up — so what comes for that battle meanwhile is applied to it in order once it exists (0.2.1, PR #266 by @siruimei07; before, such messages were dropped): `b.end takeover` stops its reports; a forced / timeout `b.end` (the first end counts) ends it as soon as the Battle is built, without catching up, and an authority reports it; a second `b.start` of the same battle updates its flags (one Battle per `battleId`); `b.pool` and the solo pause reach it. A `b.start` of another field supersedes it: an authority, or a battle the server already ended, is still built and kept (a superseded authority catches up in the background), a display replica is dropped; the next prep's clear drops it (nothing of it is built, stepped or reported afterwards). Catch-up frames keep the state events and every enemy form fx (`keepsState`); while hidden, the battle on screen holds them for the first frame back (`hold`; past `HELD_MAX` the superseded status / skill toggles are dropped, `compactHeld`, and only a backlog still past it re-enters the view from the field meta), and a battle that ended while hidden delivers them when the tab is shown; a catch-up frame or a hidden step drains its events every `EV_SLICE` (15) ticks, each batch stamped with its own game time (`gt`), so a late form fx reaches the render engine as late as it is; the game screen's pre-entry buffer (`keepEarly`, also while a re-sent field meta re-enters the field on screen) and the render engine's event queue (`render/interp.js isCosmeticEvent`) never drop a form fx either (§21.4). Sim content errors are logged as console warnings (the server logs its own). The sim loader fetches the ten data files (one retry each) and fails when any is missing — a battle on partial data would report a plausible wrong result — so the next `b.start` retries and the server's deadline covers the field meanwhile. `public/js/battle/observe.js` holds the observing rules and labels.
**Corrections from research 08/09 (normative):**
- Waves: official generator (research 08 §2): 3 of 6 types, each owns exactly 3 of the 15 round slots, SPECIAL the rest (shuffled per match); per round one type + one weighted special entry (half by round); each template SPAWN **action** becomes `clamp(roundHalfEven(n·P(t)/P(new)), 1, 5)` units over the same window; placeholder actions of the other movement class are skipped. Stage per match from the mode's list (标准 = 战场#01 only, §6.5). No custom balance multipliers (`data/tuning.json` keeps only titles); co-op boss pool = `bloodPoint[difficulty]` unscaled.
+106 -55
View File
@@ -23,6 +23,12 @@
// time (`gt`): the render engine then knows how late a form fx is and skips a change clip that has already ended.
// Display replicas (a teammate's field after the own battle, 联防 observers, the partner of a boss pair) run the same
// spec fast-forwarded to the server's clock (`elapsed`) and never report.
// A b.start is registered as pending the moment it arrives — before the sim module has loaded, while the Battle is built
// and caught up silently — so what comes for that battle meanwhile is applied to it in order (PR #266): a b.end takeover
// stops its reports; a forced / timeout b.end (the first end counts) ends it as soon as the Battle exists, without
// catching up; a second b.start of it updates its flags (never a second Battle); b.pool and the solo pause reach it. A
// b.start of another field supersedes it: an authority, or a battle the server already ended, is still built and kept, a
// display replica is dropped. The next prep's clear() drops it.
// A b.result lost with the socket (the request failed DISCONNECTED / OFFLINE, or timed out twice) is kept and sent
// again when the session is back online ('status' → 'online') or when a b.start still names this finished battle
// authoritative (the server is waiting for it); the server takes a duplicate idempotently. A server refusal is final.
@@ -60,7 +66,7 @@
// play, DESIGN §20.15, 变形同构体 wearers included; a 补位 stand-in names the replaced charId) | []
//
// createBattleRunner(deps) builds an instance with injectable net / store / clock / frame scheduler / sim loader
// (test/match/runner.test.js drives it under Node).
// (test/match/runner.test.js and test/match/runner-pending.test.js drive it under Node).
import { net as appNet } from '../net.js';
import { store as appStore } from '../store.js';
@@ -186,10 +192,12 @@ export function createBattleRunner(deps) {
/** @type {Map<string, any>} battleId → entry */
const entries = new Map();
/** @type {Map<string, any>} battleId → entry of a b.start still being prepared (prepare(); `battle` null until built) */
const pending = new Map();
let cur = null; // entry on screen
let simP = null;
let startSeq = 0;
let loading = null; // b.start being prepared
let startSeq = 0; // counts the b.starts: the latest one is the view asked for
let loading = null; // the pending entry of the latest b.start (shown when prepared)
let rafH = null;
let ivH = null;
let lastPool = null;
@@ -503,7 +511,8 @@ export function createBattleRunner(deps) {
} else {
const d = Math.max(0, now() - pausedAt);
pausedAt = null;
for (const e of entries.values()) { e.t0 += d; e.lastProgressAt += d; }
// a pending battle not built yet takes its clock when it is built
for (const e of [...entries.values(), ...pending.values()]) if (e.t0 != null) { e.t0 += d; e.lastProgressAt += d; }
}
publishState();
schedule();
@@ -592,53 +601,46 @@ export function createBattleRunner(deps) {
async function onStart(msg) {
if (!msg || typeof msg !== 'object' || !msg.spec || typeof msg.battleId !== 'string') return;
const speed = Number(msg.speed) > 0 ? Number(msg.speed) : 2;
const existing = entries.get(msg.battleId);
const seq = ++startSeq;
const prep = pending.get(msg.battleId);
const existing = entries.get(msg.battleId) || prep;
if (existing) {
existing.seq = seq;
const was = existing.authoritative;
existing.authoritative = !!msg.authoritative && !existing.resultSent;
existing.watch = !!msg.watch;
// the server still waits for this finished battle's result (lost with the socket, or its answer was): again
if (msg.authoritative && existing.resultSent) deliver(existing);
if (existing.authoritative && !was) {
if (!existing.battle) {
// still loading: it starts from the latest field clock
existing.elapsed = Number(msg.elapsed) || 0;
} else if (existing.authoritative && !was) {
// handover (the partner left): continue from the field's clock and report from now on
existing.t0 = clock() - ((Number(msg.elapsed) || 0) / speed) * 1000;
existing.lastProgressAt = -Infinity;
if (existing.battle.finished) { existing.done = false; finished(existing); }
}
++startSeq;
if (prep) {
// being prepared: the view asked for again, shown when ready (one Battle per battleId)
loading = prep;
publishState();
return prep.ready;
}
loading = null;
// a resend of what is already on screen (reconnect / resync) only updates the state; switching back shows it
if (cur !== existing) show(existing);
else { publishState(); schedule(); }
return;
}
const seq = ++startSeq;
loading = { battleId: msg.battleId, fieldId: msg.fieldId, kind: msg.kind };
publishState();
let sim;
try { sim = await ensureSim(); } catch (err) {
console.warn('[runner] simulation unavailable', err);
if (seq === startSeq) { loading = null; publishState(); }
return;
}
if (seq !== startSeq) return; // superseded by a newer b.start
let battle;
try {
battle = sim.spec.createBattleFromSpec(msg.spec, sim.ds, { logger });
} catch (err) {
console.warn('[runner] battle construction failed', err);
loading = null;
publishState();
return;
}
stats.battles++;
const e = {
battleId: msg.battleId, fieldId: msg.fieldId || msg.spec.fieldId, kind: msg.kind || msg.spec.kind, spec: msg.spec, sim, battle,
battleId: msg.battleId, fieldId: msg.fieldId || msg.spec.fieldId, kind: msg.kind || msg.spec.kind, spec: msg.spec,
// built by prepare(); `seq` = its latest b.start, `elapsed` = the field clock to start from, `endReason` = the first
// forced / timeout b.end (applied once the Battle exists), `ready` = the preparation (a promise)
sim: null, battle: null, meter: null, seq, elapsed: Number(msg.elapsed) || 0, endReason: null, ready: null,
authoritative: !!msg.authoritative, watch: !!msg.watch, own: !msg.watch, speed,
members: (msg.spec.players || []).map((p) => p && p.playerId).filter(Boolean),
t0: clock() - ((Number(msg.elapsed) || 0) / speed) * 1000, lastProgressAt: -Infinity, done: false, resultSent: false,
t0: null, lastProgressAt: -Infinity, done: false, resultSent: false,
result: null, delivery: null,
meter: sim.spec.attachLpMeter(battle),
// counted leaks so far (normal fields; noteLeaks) and the Battle state they were counted at; 联防 fields: each
// leaker's enemies still standing (noteUniteLeft)
leaks: 0, leakMark: '', left: null,
@@ -649,43 +651,83 @@ export function createBattleRunner(deps) {
// view from the field meta); the event batches of the current sliced step (stepEntry)
held: [], stale: false, slices: [],
};
if (lastPool && battle.sharedBoss && typeof battle.sharedBoss.sync === 'function') {
battle.sharedBoss.sync(lastPool.hp, lastPool.acked ? lastPool.acked[e.fieldId] : undefined);
}
// silent catch-up to the field's clock before it is shown (a reconnect / observing a running field)
while (!battle.finished && targetTick(e, clock()) - battle.tickCount > ticksPerFrameCap(speed)) {
const n = Math.min(PREPARE_SLICE, targetTick(e, clock()) - battle.tickCount);
stepEntry(e, n);
try { battle.drainEvents(); } catch { /* ignore */ }
noteLeaks(e);
if (e.authoritative) progress(e);
await yieldFrame();
if (seq !== startSeq) {
// superseded while preparing: an authoritative battle must still finish (it is kept), a replica is dropped
if (e.authoritative) { entries.set(e.battleId, e); evict(); if (e.leaks) leaksDirty = true; schedule(); }
pending.set(e.battleId, e);
loading = e;
publishState();
e.ready = prepare(e);
return e.ready;
}
/**
* Build a pending entry's Battle once the sim is loaded, apply the b.end that came meanwhile, catch it up silently to
* the field's clock and register it (shown when it is still the view asked for). It is dropped when clear() removed it
* (or a new b.start replaced it after a clear) and when a b.start of another field superseded it — unless it is an
* authority (it must finish and report) or the server already ended it (switching back shows the end).
*/
async function prepare(e) {
const wanted = () => pending.get(e.battleId) === e && (e.seq === startSeq || e.authoritative || !!e.endReason);
try {
let sim;
try { sim = await ensureSim(); } catch (err) {
console.warn('[runner] simulation unavailable', err);
return;
}
if (!wanted()) return;
let battle;
try {
battle = sim.spec.createBattleFromSpec(e.spec, sim.ds, { logger });
} catch (err) {
console.warn('[runner] battle construction failed', err);
return;
}
stats.battles++;
e.sim = sim;
e.battle = battle;
e.meter = sim.spec.attachLpMeter(battle);
e.t0 = clock() - (e.elapsed / e.speed) * 1000;
if (lastPool && battle.sharedBoss && typeof battle.sharedBoss.sync === 'function') {
battle.sharedBoss.sync(lastPool.hp, lastPool.acked ? lastPool.acked[e.fieldId] : undefined);
}
if (e.endReason) endBattle(e);
// silent catch-up to the field's clock before it is shown (a reconnect / observing a running field) — for the view
// asked for; a superseded authority catches up in the background (advance)
while (!battle.finished && e.seq === startSeq && targetTick(e, clock()) - battle.tickCount > ticksPerFrameCap(e.speed)) {
const n = Math.min(PREPARE_SLICE, targetTick(e, clock()) - battle.tickCount);
stepEntry(e, n);
try { battle.drainEvents(); } catch { /* ignore */ }
noteLeaks(e);
progress(e);
await yieldFrame();
if (!wanted()) return;
}
pending.delete(e.battleId);
entries.set(e.battleId, e);
evict();
noteLeaks(e);
leaksDirty = true; // its leak count / bond layers join the published state
if (e.seq === startSeq) { loading = null; show(e); }
if (battle.finished) finished(e);
flushLeaks();
schedule();
} finally {
if (pending.get(e.battleId) === e) pending.delete(e.battleId);
if (loading === e) { loading = null; publishState(); }
}
entries.set(e.battleId, e);
evict();
loading = null;
noteLeaks(e);
show(e);
if (battle.finished) finished(e);
}
function onPool(msg) {
if (!msg || typeof msg !== 'object') return;
lastPool = msg;
for (const e of entries.values()) {
const pool = e.battle.sharedBoss;
// a pending battle not built yet takes lastPool when it is built
for (const e of [...entries.values(), ...pending.values()]) {
const pool = e.battle ? e.battle.sharedBoss : null;
if (pool && typeof pool.sync === 'function') pool.sync(msg.hp, msg.acked ? msg.acked[e.fieldId] : undefined);
}
emit('pool', msg);
}
function onEnd(msg) {
const e = msg && entries.get(msg.battleId);
const e = msg && (entries.get(msg.battleId) || pending.get(msg.battleId));
if (!e) return;
if (msg.reason === 'takeover') {
e.authoritative = false;
@@ -693,8 +735,15 @@ export function createBattleRunner(deps) {
schedule();
return;
}
// the first end counts; a battle still loading ends as soon as it is built (prepare)
if (!e.endReason) e.endReason = msg.reason === 'timeout' ? 'timeout' : 'forced';
if (e.battle) endBattle(e);
}
/** End an entry's battle with its b.end reason; an authority reports it at once. */
function endBattle(e) {
if (!e.battle.finished) {
try { e.battle.forceEnd(msg.reason === 'timeout' ? 'timeout' : 'forced'); } catch { /* ignore */ }
try { e.battle.forceEnd(e.endReason); } catch { /* ignore */ }
}
if (e === cur) emitFrame(e, false);
finished(e);
@@ -710,6 +759,8 @@ export function createBattleRunner(deps) {
// never drop an unreported authoritative result (the round already moved on: the server has its own)
if (e.authoritative && !e.resultSent && !e.battle.finished) { try { e.battle.forceEnd('forced'); } catch { /* ignore */ } }
}
// a battle still being prepared too: its preparation finds itself gone and stops (prepare)
pending.clear();
entries.clear();
cur = null;
lastPool = null;
@@ -737,7 +788,7 @@ export function createBattleRunner(deps) {
if (phase === lastPhase) return;
lastPhase = phase;
if (!phase || ['PREP', 'ROUND_START', 'SP_DRAFT', 'RESULT', 'LOBBY', 'INFO_CHECK', 'BAND_DRAFT', 'BATTLE_CHECK'].includes(phase)) {
if (entries.size || loading) clear();
if (entries.size || pending.size) clear();
}
// warm the simulation up as soon as a match runs (the first b.start then starts at once)
if (phase && phase !== 'LOBBY' && !simP) ensureSim().catch(() => {});
+273
View File
@@ -0,0 +1,273 @@
// The browser battle runner (public/js/battle/runner.js) while a b.start is still being prepared — the simulation module
// loading (a deferred loader here) or the silent catch-up to the field clock (it waits for an animation frame after every
// PREPARE_SLICE of 600 ticks): a b.end (takeover / forced), a second b.start of the same battle, a b.start of another
// field, b.pool, the solo pause and the next prep's clear that arrive meanwhile are applied to that battle, in order,
// once it exists (PR #266 by @siruimei07). Manual clock and animation frames, a real round-2 b.start.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createBattleRunner } from '../../public/js/battle/runner.js';
import { createStore, initialState } from '../../public/js/store.js';
import * as specMod from '../../server/sim/spec.js';
import { DataSource } from '../../server/sim/simdata.js';
import { validateC2S } from '../../shared/protocol.js';
import { PHASE } from '../../shared/constants.js';
import { DATA, makeMatch } from './harness.js';
const DS = new DataSource(DATA, null);
const QUIET = { error() {}, warn() {}, info() {}, debug() {} };
const tick = () => new Promise((res) => setImmediate(res));
/** A real authoritative b.start of round 2 (a board with operators) from a client-combat match. */
const START = (() => {
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 1, bots: 1, seed: 7301, captureFrames: false, clientCombat: true, clients: false });
h.autoHumans();
h.m.start();
h.run(() => h.m.phase === PHASE.COMBAT && h.m.round === 2, { maxSteps: 3e6 });
const msg = h.lastTo('p_0', 'b.start');
h.m.dispose();
assert.ok(msg && msg.authoritative && msg.kind === 'normal');
return msg;
})();
let bossStart = null;
/** A real b.start of a Final Assault field (a shared boss pool). */
function realBossStart() {
if (bossStart) return bossStart;
const h = makeMatch({ mode: 'solo', difficulty: 'FUNNY', humans: 1, seed: 7304, captureFrames: false, clientCombat: true, clients: false });
h.autoHumans();
h.m.start();
h.run(() => h.ended != null || h.m.phase === PHASE.FINAL_ASSAULT, { maxSteps: 5e6 });
bossStart = h.lastTo('p_0', 'b.start');
h.m.dispose();
assert.equal(bossStart && bossStart.kind, 'boss');
return bossStart;
}
/** A display replica of another field (a teammate's battle). */
const watched = () => ({ ...START, battleId: `${START.battleId}w`, fieldId: 'n:p_9', spec: { ...START.spec, fieldId: 'n:p_9' }, authoritative: false, watch: true, elapsed: 0 });
function rig() {
let t = 1000;
let frames = [];
const handlers = new Map();
const sent = [];
const built = [];
const shown = [];
let release;
const loaded = new Promise((res) => { release = res; });
const sim = { ds: DS, spec: { ...specMod, createBattleFromSpec: (...a) => { const b = specMod.createBattleFromSpec(...a); built.push(b); return b; } } };
const net = {
on(type, fn) { if (!handlers.has(type)) handlers.set(type, new Set()); handlers.get(type).add(fn); return () => handlers.get(type).delete(fn); },
emit(type, msg) { for (const fn of handlers.get(type) || []) fn({ t: type, ...msg }); },
send(type, fields) { const msg = { ...fields, t: type }; assert.equal(validateC2S(msg), null, `invalid ${type}`); sent.push(msg); return true; },
request(type, fields) { const msg = { ...fields, t: type, rid: 1 }; assert.equal(validateC2S(msg), null, `invalid ${type}`); sent.push(msg); return Promise.resolve({ t: 'ok' }); },
};
const store = createStore(initialState);
const runner = createBattleRunner({
net, store, doc: { hidden: false, addEventListener() {} }, now: () => t,
raf: (fn) => { frames.push(fn); return frames.length; }, caf: () => {},
setInterval: () => 1, clearInterval: () => {},
loadSim: () => loaded, logger: QUIET,
});
runner.on('field', (f) => shown.push(f.battleId));
store.patch('match', { public: { phase: PHASE.COMBAT } });
return {
runner, net, store, sent, built, shown,
load() { release(sim); },
elapse(ms) { t += ms; },
/** `n` animation frames `ms` apart, the promise continuations settled before and after each */
async frames(n = 1, ms = 0) {
for (let i = 0; i < n; i++) {
await tick();
t += ms;
const q = frames;
frames = [];
for (const fn of q) fn(t);
}
await tick();
},
end(reason, battleId = START.battleId) { net.emit('b.end', { battleId, fieldId: START.fieldId, reason }); },
results: (battleId = START.battleId) => sent.filter((m) => m.t === 'b.result' && m.battleId === battleId),
};
}
/**
* The b.start, stopped while the sim still loads ('load', elapsed 0: the COMBAT start) or while the silent catch-up to
* 30 game s (900 ticks at 2×) waits for its next frame after the first 600-tick slice ('catch-up', a reconnect).
*/
async function startAt(r, stage, msg = START) {
r.net.emit('b.start', { ...msg, elapsed: stage === 'catch-up' ? 30 : 0 });
if (stage === 'catch-up') r.load();
await tick();
if (stage === 'catch-up') {
assert.equal(r.built.length, 1);
assert.equal(r.built[0].tickCount, 600, 'one silent catch-up slice, waiting for the next frame');
assert.ok(!r.built[0].finished);
} else {
assert.equal(r.built.length, 0, 'the simulation still loads');
}
assert.equal(r.runner._entries.size, 0, 'not registered yet');
assert.equal(r.runner.state().loading, true);
}
/** Let the preparation finish (the sim arrives when it was still loading). */
async function prepared(r, stage) {
if (stage === 'load') r.load();
await r.frames(5);
}
async function runOut(r, e) {
for (let i = 0; i < 400 && !e.battle.finished; i++) await r.frames(1, 1000);
assert.ok(e.battle.finished, 'the battle ran to its end');
}
for (const stage of ['load', 'catch-up']) {
test(`b.end takeover during the ${stage}: the battle is still shown but never reports from then on`, async () => {
const r = rig();
await startAt(r, stage);
const before = r.sent.length;
r.end('takeover');
await prepared(r, stage);
const e = r.runner._entries.get(START.battleId);
assert.ok(e, 'registered and shown (a takeover keeps displaying)');
assert.deepEqual(r.shown, [START.battleId]);
assert.equal(e.authoritative, false);
assert.equal(r.runner.state().authoritative, false);
await runOut(r, e);
assert.equal(r.sent.length, before, 'no b.progress / b.result after the takeover');
r.runner.dispose();
});
test(`b.end forced during the ${stage}: the battle ends with that reason as soon as it exists and reports once (the first end wins)`, async () => {
const r = rig();
await startAt(r, stage);
r.end('forced');
if (stage === 'catch-up') {
assert.ok(r.built[0].finished, 'ended at once, no further catch-up');
assert.equal(r.built[0].tickCount, 600);
assert.equal(r.results().length, 1, 'reported at once');
}
r.end('timeout');
await prepared(r, stage);
const e = r.runner._entries.get(START.battleId);
assert.ok(e && e.done && e.battle.finished);
assert.equal(e.battle.tickCount, stage === 'catch-up' ? 600 : 0, 'never stepped past the end');
assert.equal(e.battle.result().reason, 'forced');
assert.equal(r.results().length, 1);
assert.equal(r.results()[0].result.reason, 'forced');
assert.deepEqual(r.shown, [START.battleId]);
assert.equal(r.runner.state().done, true);
await r.frames(5, 1000);
r.end('forced');
assert.equal(r.results().length, 1, 'once');
r.runner.dispose();
});
test(`b.end takeover then forced during the ${stage}: applied in that order — ended, no result`, async () => {
const r = rig();
await startAt(r, stage);
const before = r.sent.length;
r.end('takeover');
r.end('forced');
await prepared(r, stage);
const e = r.runner._entries.get(START.battleId);
assert.ok(e && e.battle.finished);
assert.equal(e.battle.result().reason, 'forced');
assert.equal(e.authoritative, false);
assert.equal(r.sent.length, before, 'the former authority sends nothing');
r.runner.dispose();
});
test(`a second b.start of the battle during the ${stage}: the same preparation, one Battle, the flags of the last message`, async () => {
const r = rig();
await startAt(r, stage);
r.end('takeover');
r.net.emit('b.start', { ...START, elapsed: 30 }); // authoritative again (a resync / a handover back)
await prepared(r, stage);
assert.equal(r.built.length, 1, 'one Battle per battleId');
const e = r.runner._entries.get(START.battleId);
assert.equal(e.authoritative, true, 'the later b.start grants the authority again');
assert.deepEqual(r.shown, [START.battleId], 'shown once');
await r.frames(3, 1000);
assert.ok(r.sent.some((m) => m.t === 'b.progress'), 'reporting');
r.end('takeover');
const n = r.sent.length;
await r.frames(5, 1000);
assert.equal(r.sent.length, n, 'the last takeover wins');
r.runner.dispose();
});
test(`the next prep during the ${stage} drops the battle: nothing is built, stepped, registered or reported afterwards`, async () => {
const r = rig();
await startAt(r, stage);
r.store.patch('match', { public: { phase: PHASE.PREP } });
const n = r.sent.length;
const ticks = r.built.map((b) => b.tickCount);
if (stage === 'load') r.load();
await r.frames(5, 1000);
r.end('forced');
await r.frames(3, 1000);
assert.equal(r.runner._entries.size, 0);
assert.equal(r.runner.state(), null);
assert.equal(r.store.get().match.battle, null);
assert.deepEqual(r.built.map((b) => b.tickCount), ticks, 'no build, no step');
assert.equal(r.sent.length, n);
assert.deepEqual(r.shown, []);
r.runner.dispose();
});
test(`another field's b.start during the ${stage}: the latest view is shown, the own authoritative battle still runs and reports`, async () => {
const r = rig();
await startAt(r, stage);
r.net.emit('b.start', watched());
await prepared(r, stage);
assert.equal(r.runner.state().battleId, watched().battleId, 'the latest view');
const e = r.runner._entries.get(START.battleId);
assert.ok(e && e.authoritative, 'the authority is kept');
await runOut(r, e);
await r.frames(2);
assert.equal(r.results().length, 1);
assert.equal(r.results(watched().battleId).length, 0, 'the display replica never reports');
assert.deepEqual(r.shown, [watched().battleId], 'the authority never takes the view back');
r.runner.dispose();
});
}
test('a display replica superseded during the catch-up is dropped (no Battle kept for it)', async () => {
const r = rig();
await startAt(r, 'catch-up', { ...START, authoritative: false, watch: true });
r.net.emit('b.start', watched());
await r.frames(5);
assert.equal(r.runner._entries.has(START.battleId), false);
assert.equal(r.runner.state().battleId, watched().battleId);
assert.equal(r.sent.length, 0);
r.runner.dispose();
});
test('solo pause during the catch-up: the paused time is not caught up', async () => {
const r = rig();
await startAt(r, 'catch-up');
r.store.patch('match', { public: { phase: PHASE.COMBAT, paused: true } });
r.elapse(5000);
r.store.patch('match', { public: { phase: PHASE.COMBAT, paused: false } });
await r.frames(3);
const e = r.runner._entries.get(START.battleId);
assert.equal(e.battle.tickCount, 900, 'the field clock (30 game s), not 5 paused real seconds further');
r.runner.dispose();
});
test('b.pool during the catch-up of a boss field reaches its Battle at once', async () => {
const start = realBossStart();
const r = rig();
r.net.emit('b.start', { ...start, elapsed: 30 });
r.load();
await tick();
assert.equal(r.runner._entries.size, 0, 'still catching up');
const pool = r.built[0].sharedBoss;
const hp = pool.maxHp * 0.75;
r.net.emit('b.pool', { hp, max: pool.maxHp, teamLp: 20, acked: { [start.fieldId]: pool.cum } });
assert.ok(Math.abs(pool.hp - hp) < 1e-6, 'the server hp when everything is acknowledged');
await r.frames(3);
assert.equal(r.runner._entries.get(start.battleId).battle, r.built[0]);
r.runner.dispose();
});