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:
commit
53ccf5d8fe
3 files changed
+381
-57
No files matched your search
+106
-55
@@ -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(() => {});
|
||||
|
||||
Reference in new issue
Block a user