Player report: with 变形同构体 and the matching bond item the wearer gets the bond's effect, but the bond's member count leaves the converted operator out. The count that activates the bond already included the wearer on every server path (bondsMeta.pieceBonds: prep count, views, battle input; support.unitBonds: the battle). The client did not: the bond popup's member list (成员 x/y) and the wearer's card read only the chess records' bonds, so the popup said 在场 3 but 成员 2/10 without the wearer, and the card had no chip for the bond. - gameLogic.grantedBonds / pieceBondIds: the server's 变形同构体 rule on the client; bondMembers lists wearers (tagged 同构, one row per operator, on the board or owned), so 成员 x/y agrees with 在场. - detailPanel: the card's chips include the granted bond (dashed, 同构). - Teammates: UnitInfo carries an ally operator's item ids (sim unitInfo, Match.prepFieldMeta); ownerBoard / runner.ownerOps keep them. - Tests: every pairing on real matches (prep, views, battle input, the real battle), bench vs board, half pairs, distinct members; the client rule against the server's; popup / card rendering.
3055 lines
149 KiB
JavaScript
3055 lines
149 KiB
JavaScript
// server/match/Match.js — the match & meta engine: state machine, timers, round loop, co-op orchestration,
|
||
// broadcasting views (DESIGN §6, §8). Rules are documented in the module headers of ./PlayerState.js, ./pool.js,
|
||
// ./board.js, ./bondsMeta.js, ./effectsMeta.js, ./choices.js, ./waves.js, ./unite.js, ./finalAssault.js,
|
||
// ./results.js, ./bot.js and in docs/META.md.
|
||
//
|
||
// ─────────────────────────────────────────────────────────────────────────────────────────────────────
|
||
// MATCH INTERFACE (platform contract: server/lobby.js ⇄ server/match/Match.js)
|
||
// ─────────────────────────────────────────────────────────────────────────────────────────────────────
|
||
//
|
||
// new Match(opts)
|
||
// opts.roomCode string 4-letter room code (logging only)
|
||
// opts.mode 'solo' | 'coop'
|
||
// opts.difficulty 'FUNNY'|'NORMAL'|'HARD'|'ABYSS'
|
||
// opts.modeId string modeIdFor(mode, difficulty), e.g. 'mode_multi_hard'
|
||
// opts.seats Array<{ seat: 0..3, playerId: string, name: string, isBot: boolean, connected: boolean,
|
||
// loadout?: { [baseChessId]: { skill: index, module: uniEquipId|'none'|null } } | null }>
|
||
// sorted by seat, 1–4 entries, ≥ 1 human; solo ⇒ exactly 1 human and no bots.
|
||
// Bot playerIds start with 'ai_'. Seat indexes may have gaps (e.g. seats 0 and 2).
|
||
// `loadout` (DESIGN §16, optional): the human's operator loadout, already checked by the lobby
|
||
// (shared/protocol.js checkLoadout); PlayerState re-checks it against opts.data and ignores it for bots.
|
||
// opts.seed uint32 master seed for all match randomness
|
||
// opts.matchNo integer ≥ 1 (optional) the room's match number (lobby: room.matchCount + 1); with the seed it
|
||
// makes this match's battleIds unique within the room (DESIGN §14)
|
||
// opts.data frozen game data (server/data.js getData()); may lack keys while data is generated
|
||
// opts.log { info, warn, error, debug }
|
||
// opts.now () => ms epoch injectable clock (Date.now in production)
|
||
// opts.send(playerId, msg) → boolean unicast; never throws; silently drops for bots, disconnected
|
||
// or departed players (they get a full resync on reconnect).
|
||
// `b.snap` frames may be dropped under backpressure.
|
||
// opts.broadcast(msg) → void to every connected, non-departed human of the room.
|
||
// opts.onEnd(summary) → void call EXACTLY ONCE when the match is over, after m.result was
|
||
// sent. The platform then (synchronously) returns the room to
|
||
// LOBBY and (asynchronously, next macrotask) calls dispose().
|
||
// `summary` is free-form JSON (kept as room.lastSummary).
|
||
//
|
||
// start() Called once, right after construction. Must broadcast the first m.public and send
|
||
// each human its m.private. May call onEnd synchronously (the platform copes).
|
||
// handle(playerId, msg) A validated 'g.*' intent or client-side combat report 'b.progress' / 'b.result'
|
||
// (msg passed validateC2S; never 'g.leave', which the
|
||
// → { ok: true } platform turns into onLeave). Return { error: ERR code (shared/constants.js),
|
||
// | { error, detail? } detail?: string } to reject. Must not throw (if it does, the platform logs and
|
||
// replies ERR.INTERNAL). Replies ('ok'/'error' with rid) are sent by the platform.
|
||
// onDisconnect(playerId) A human's socket dropped. The seat is kept; apply the auto-play policy.
|
||
// onReconnect(playerId) The human is back (new socket with its token) or re-sent hello on a live socket
|
||
// (a resync request). May be called without a preceding onDisconnect. Resend full
|
||
// state: m.public, m.private and, if a battle is on (client-side combat), the
|
||
// b.start of the field the player is on / watching (server-run mode: m.field + b.snap).
|
||
// setLoadout(playerId, loadout) → { ok } | { error, detail? } (DESIGN §16; optional for the platform) a new checked
|
||
// operator loadout from room.loadout. Accepted only during INFO_CHECK (the briefing's
|
||
// 干员调配 entry); afterwards the match's loadout is locked (WRONG_PHASE).
|
||
// onLeave(playerId) The human quit permanently (g.leave, room.leave, or the 10-minute reconnect
|
||
// window expired). They will never return under this playerId in this match;
|
||
// treat as quit (AI takes over / eliminated per DESIGN). No onDisconnect follows.
|
||
// dispose() Stop every timer/interval and release resources. Idempotent. After dispose the
|
||
// platform ignores send/broadcast/onEnd from this instance.
|
||
//
|
||
// Bot seats never produce intents or hooks: the match drives bots itself (server/match/bot.js).
|
||
// Messages the match emits are the S2C 'm.*' / 'b.*' frames of DESIGN §8.2 (room.* frames are platform-owned).
|
||
// ─────────────────────────────────────────────────────────────────────────────────────────────────────
|
||
//
|
||
// Client-side combat (DESIGN §14; the default): every battle is described by a JSON BattleSpec (server/sim/spec.js).
|
||
// A normal field's owner (a connected human) simulates it in the browser and reports b.progress / b.result; a 联防 or
|
||
// boss pair field is simulated by its lowest-seat connected human (the other humans run display-only replicas).
|
||
// Bots, departed / disconnected players and fields without a connected human are simulated by the server: normal and
|
||
// 联防 fields headlessly at once (the result is released at the battle's natural end for the teammates' progress UI),
|
||
// boss fields in real time without snapshots (they share the pool). A silent authority (deadline = time limit + 15 s)
|
||
// or a disconnected one loses the field to the server, which re-simulates the spec; an implausible b.result is
|
||
// replaced by the server's own simulation (fields.js validateClientResult). The shared boss pool, the team LP and the
|
||
// overtime drain live here (b.pool ≤ 4 Hz, b.end; m.public follows a boss fight at ~1 Hz); the first end condition the
|
||
// server registers decides the Final Assault (pool 0 → victory, team LP 0 → defeat; nothing reported after a defeat is
|
||
// credited — _finalEnding, user playtest #6 item 5). No b.snap / b.ev is sent in
|
||
// this mode; g.watch hands out specs. battleIds are `<seed36>[-<matchNo36>].<round>.<seq>[.<fieldId>]` (≤ 64 chars):
|
||
// unique across the matches of a room, so a report that crossed into the next match is ignored like any stale one; a
|
||
// duplicate b.result (a browser re-sending one it believes lost) is answered ok and changes nothing.
|
||
// Solo pause (g.pause { on }, setPause): freezes the field clocks, result deadlines, server release timers, the boss
|
||
// clock and the server pacers; m.public.paused; resume shifts every clock / deadline by the pause.
|
||
// Live LP (user playtest #3 item 2): during COMBAT / 联防 m.public players[].pendingLp = min(lpCapPerRound, the counted
|
||
// leaks of the player's own battle so far) (_pendingLpView; omitted when 0) — the teammates' rows of the team panel
|
||
// show lp − pendingLp; the settlement lands with the SETTLE view, where it is gone. 联防 (user playtest #6 item 7): a
|
||
// leaker's players[].uniteLeft = its enemies still standing on the 联防 field (not spawned yet, alive, or through again;
|
||
// uncapped, live: the authority's b.progress `left`, the server-run timeline or battle; exact once the field has its
|
||
// result — _uniteLeft) and pendingLp = min(lpCapPerRound, uniteLeft): the counter falls as the helpers kill them (and
|
||
// rises when one of them splits or summons — the children are billed to the same leaker).
|
||
// User playtest #4: a match with a single human (独立模拟, or a 同盟 room started alone / with AI teammates) times no
|
||
// phase outside its battles (soloUntimed); the co-op strategy draft has ONE countdown — BAND_TURN_SECONDS per turn,
|
||
// published as m.public.deadline — AI seats pick at once and a turn that runs out takes the highlighted strategy
|
||
// (g.bandFocus → timeoutBand); g.unitStats answers m.unitStats: the stats the board's units start their next battle with.
|
||
// opts.clientCombat default true (env SP_COMBAT=server → false: the legacy server-run + snapshot streaming mode)
|
||
// opts.verify 'off' | 'sample' | 'all' (env SP_VERIFY, default 'off'): re-simulate accepted client results
|
||
// ('sample': ~1 in 8, in a later callback, mismatches logged; 'all': before accepting — the
|
||
// server's result wins on a mismatch)
|
||
//
|
||
// Engine-only extra options (tests / tools; the lobby never passes them):
|
||
// opts.scheduler RealScheduler (default, uses opts.now) | VirtualScheduler (./scheduler.js)
|
||
// opts.registry MetaRegistry (default: built-ins + content, ./effectsMeta.js getDefaultRegistry())
|
||
// opts.BattleClass Battle implementation (default server/sim/Battle.js; tests inject test/match/fakeBattle.js)
|
||
// opts.battleContent 'full' | 'generic' | 'none' (sim content mode, default 'full')
|
||
// opts.timerScale multiplier on every real-time phase timer (default 1)
|
||
// opts.combatSpeed game seconds per real second while battles run in real time (default 2, the forced 2×)
|
||
// opts.botRehearsal candidate layouts a bot simulates per prep before placing (default 3, 0 = heuristic only;
|
||
// a whole battle per candidate: ~20–300 ms of CPU each, see server/match/bot.js createRehearsal)
|
||
// opts.botSliceMs wall-clock ms of rehearsal per scheduler callback (default 8 with a real scheduler, unbounded
|
||
// with a virtual one); the rest runs in later callbacks (scheduleBotPrep)
|
||
// opts.headlessSliceMs wall-clock ms per callback of a server-run normal / 联防 field (client-side combat: bots,
|
||
// takeovers; default 8 with a real scheduler, at once with a virtual one)
|
||
// Seats may be all bots (tools/matchrun.mjs); the lobby always has ≥ 1 human.
|
||
//
|
||
// Diagnostics: m.errors / m.errorCount (engine), m.dispatcher.errors / .errorsByKey (meta handlers), m.simErrors
|
||
// (count reported by battle results) and m.simErrorLog (Map key → { label, who, message, stack, battles, count }
|
||
// of the unique errors each finished battle recorded in battle.errors). tools/matchrun.mjs --errors prints them.
|
||
//
|
||
// Disconnect / leave policy (research 06 §10.3 + DESIGN §6.6):
|
||
// * disconnected human: the seat keeps playing its last lineup; draft turns and prep auto-resolve at their
|
||
// deadlines (band → 华法琳, 机变 → a random remaining card, prep → auto-ready with temp auto-resolved). Nothing is
|
||
// bought for them unless they toggled "AI 托管" (g.autoplay { on: true }), which lets the bot play the seat.
|
||
// * departed human (onLeave): 中途退出 counts as elimination (research 00-INDEX §3, 01 §9, 06 §7 / §10.3) — every
|
||
// copy the seat holds returns to the shared pool at once, the seat leaves the round loop, the Final Assault
|
||
// pairing and the boss pool; its own running normal battle is force-ended. The seat shows status 'left'. When no
|
||
// human is left at all the match ends ('abandoned'); when nobody alive is left it ends as 'eliminated'.
|
||
|
||
import { C2S, unitStatsEntry } from '../../shared/protocol.js';
|
||
import { PHASE, ERR, EMOTES, EMOTE_COOLDOWN_MS, GEO, modeIdFor, layerGainRoom } from '../../shared/constants.js';
|
||
import { Battle } from '../sim/Battle.js';
|
||
import { DataSource } from '../sim/simdata.js';
|
||
import { createRng, deriveSeed } from '../sim/rng.js';
|
||
import { GameData } from './gamedata.js';
|
||
import { RealScheduler } from './scheduler.js';
|
||
import { SharedPool, drawDisabledBonds } from './pool.js';
|
||
import { PlayerState } from './PlayerState.js';
|
||
import { buildDeployMap, boardOrder, pieceDir } from './board.js';
|
||
import { bondList } from './bondsMeta.js';
|
||
import { EffectDispatcher, getDefaultRegistry } from './effectsMeta.js';
|
||
import { generateDraft, applyCard, cardView, bountyBattles, isMultiRoundBounty } from './choices.js';
|
||
import { setupMatchWaves, buildNormalWave, buildBossWave, bountySpawns, withBounties, previewOf, weightedPick } from './waves.js';
|
||
import { planUnite, uniteBattleOpts, uniteSurvivors } from './unite.js';
|
||
import { pairPlayers, bossPoolHp, SharedBossPool, hiddenEligible, BOSS_HIT_STEPS } from './finalAssault.js';
|
||
import {
|
||
FieldRunner, DeadBattle, GAME_SPEED, snapFrame, runHeadless, timelineAt, HeadlessPacer, syntheticResult,
|
||
validateClientResult, RESULT_GRACE_MS, BOSS_SILENCE_MS, HARD_CAP_SECONDS, HeadlessJob, HEADLESS_SLICE_MS, CATCHUP_TICKS_PER_INTERVAL,
|
||
uniteBillBounds,
|
||
} from './fields.js';
|
||
import { buildBattleSpec, createBattleFromSpec, resultDigest, compactResult as compactForVerify, battleProgress, uniteLeft } from '../sim/spec.js';
|
||
import { CreditPool } from './finalAssault.js';
|
||
import { buildResult } from './results.js';
|
||
import { botPrepBeginSteps, botPrepEndSteps, botPickBand, botPickCard } from './bot.js';
|
||
|
||
const BOT_REHEARSAL_DEFAULT = 3;
|
||
/** Wall-clock ms of bot layout rehearsal per scheduler callback (real time; virtual time runs it in one go). */
|
||
const BOT_SLICE_MS = 8;
|
||
const GAME_TYPES = new Set(Object.keys(C2S).filter((t) => Object.hasOwn(C2S, t) && (t.startsWith('g.') || t.startsWith('b.'))));
|
||
const env = (k) => (typeof process !== 'undefined' && process.env ? process.env[k] : undefined);
|
||
/** Default combat mode: client-side unless SP_COMBAT=server. */
|
||
const envClientCombat = () => String(env('SP_COMBAT') || '').toLowerCase() !== 'server';
|
||
/** SP_VERIFY → 'off' | 'sample' | 'all'. */
|
||
export function parseVerify(v) {
|
||
const s = String(v ?? '').trim().toLowerCase();
|
||
return s === 'all' || s === 'sample' ? s : 'off';
|
||
}
|
||
/** b.pool broadcasts at most this often (ms). */
|
||
const POOL_MIN_GAP_MS = 250;
|
||
/** Boss clock period (overtime drain, end checks, silence watchdog) in real ms. */
|
||
const BOSS_CLOCK_MS = 250;
|
||
/**
|
||
* Final Assault / Hidden Core under client-side combat: m.public (boss HP, team LP, field progress) at most this often
|
||
* (ms) — b.pool (≤ 4 Hz) already carries the exact pool / team LP, so a boss b.progress never re-marks the whole
|
||
* public state.
|
||
*/
|
||
const BOSS_PUBLIC_MS = 1000;
|
||
/** How long the match waits for a boss field's b.result after it forced the end (real ms, × timerScale). */
|
||
const BOSS_RESULT_GRACE_MS = 6000;
|
||
/**
|
||
* Plausibility of a boss field's client reports (b.progress bossDmg / leaks, the b.result damage), on the SERVER's
|
||
* field clock — so no single frame decides the Final Assault: the credited pool damage of one field stays ≤ the whole
|
||
* pool per BOSS_MIN_CLEAR_GS game seconds (20 % of the pool per game second; the balance model's fastest mean kills
|
||
* take ≈ 18–20 s at ≈ 5 %/s per field, docs/BALANCE.md §3), the credited LP cost ≤ BOSS_LP_BURST + BOSS_LP_PER_GS per
|
||
* game second (a leader's "扣除所有目标生命" comes after ≥ 200 s). Reports are cumulative: what exceeds the budget is
|
||
* credited later as the budget grows (the boss clock re-applies the latest report), never lost — a 'cleared' b.result
|
||
* whose report covers the pool waits for it too (`heldResult`; 999-layer kills take 2–4 game s), it is not handed over.
|
||
*/
|
||
const BOSS_MIN_CLEAR_GS = 5;
|
||
const BOSS_LP_BURST = 10;
|
||
const BOSS_LP_PER_GS = 1;
|
||
const OK = Object.freeze({ ok: true });
|
||
const noopLog = { info() {}, warn() {}, error() {}, debug() {} };
|
||
const fail = (error, detail) => (detail ? { error, detail } : { error });
|
||
|
||
/** Fixed presentation delays (real ms, × timerScale). */
|
||
export const DELAYS = Object.freeze({
|
||
ROUND_START: 2000,
|
||
COMBAT_END: 1500,
|
||
SETTLE: 3000,
|
||
BOT_ACTION: 900,
|
||
BOT_STAGGER: 350,
|
||
PUBLIC_THROTTLE: 100,
|
||
});
|
||
|
||
/**
|
||
* Seconds of one turn of the co-op strategy draft (user playtest #4 item 4: the old 12 s per turn — research 06 §724,
|
||
* itself [ASSUMED] — inside the 50 s step was far too little and counted apart from the header's 50 s). [ASSUMED]: the
|
||
* official data only gives the whole BAND_CHECK step (autoChessData.enterStepList: 50 s, hint 15 s); the turn clock is
|
||
* the remake's. It is also the step's only countdown (m.public.deadline = draft.turnDeadline). × timerScale.
|
||
*/
|
||
export const BAND_TURN_SECONDS = 30;
|
||
|
||
const dsCache = new WeakMap();
|
||
function dataSourceFor(data) {
|
||
if (!data || typeof data !== 'object' || !data.chess) return undefined;
|
||
let ds = dsCache.get(data);
|
||
if (!ds) { ds = new DataSource(data, null); dsCache.set(data, ds); }
|
||
return ds;
|
||
}
|
||
|
||
export class Match {
|
||
/** @param {object} opts see MATCH INTERFACE above */
|
||
constructor(opts) {
|
||
if (!opts || !Array.isArray(opts.seats) || opts.seats.length === 0) throw new TypeError('Match: seats required');
|
||
if (typeof opts.send !== 'function' || typeof opts.broadcast !== 'function' || typeof opts.onEnd !== 'function') {
|
||
throw new TypeError('Match: send/broadcast/onEnd callbacks required');
|
||
}
|
||
this.opts = opts;
|
||
this.roomCode = opts.roomCode ?? '----';
|
||
this.mode = opts.mode === 'solo' ? 'solo' : 'coop';
|
||
this.difficulty = opts.difficulty;
|
||
this.modeId = opts.modeId || modeIdFor(this.mode, opts.difficulty);
|
||
this.seed = (Number(opts.seed) >>> 0) || 1;
|
||
this.log = opts.log || noopLog;
|
||
this.sendFn = opts.send;
|
||
this.broadcastFn = opts.broadcast;
|
||
this.onEndFn = opts.onEnd;
|
||
this.data = opts.data && typeof opts.data === 'object' ? opts.data : {};
|
||
this.gd = new GameData(this.data, this.modeId);
|
||
if (!this.difficulty) this.difficulty = this.gd.difficulty;
|
||
this.isSolo = this.mode === 'solo' || this.gd.isSolo;
|
||
this.ownsScheduler = !opts.scheduler;
|
||
this.sched = opts.scheduler || new RealScheduler({ now: opts.now || Date.now, onError: (e) => this.reportError('timer', e) });
|
||
this.registry = opts.registry || getDefaultRegistry();
|
||
this.dispatcher = new EffectDispatcher(this, this.registry);
|
||
this.BattleClass = typeof opts.BattleClass === 'function' ? opts.BattleClass : Battle;
|
||
this.battleContent = opts.battleContent || 'full';
|
||
this.timerScale = Number.isFinite(opts.timerScale) && opts.timerScale >= 0 ? opts.timerScale : 1;
|
||
this.gameSpeed = Number.isFinite(opts.combatSpeed) && opts.combatSpeed > 0 ? Math.min(opts.combatSpeed, 200) : GAME_SPEED;
|
||
/** layouts a bot rehearses per prep with the real simulation (bot.js; 0 = heuristic placement only) */
|
||
this.botRehearsal = Number.isInteger(opts.botRehearsal) && opts.botRehearsal >= 0 ? Math.min(opts.botRehearsal, 8) : BOT_REHEARSAL_DEFAULT;
|
||
/** wall-clock budget of one rehearsal slice (scheduleBotPrep) */
|
||
this.botSliceMs = Number.isFinite(opts.botSliceMs) && opts.botSliceMs > 0 ? opts.botSliceMs : this.sched.virtual ? Infinity : BOT_SLICE_MS;
|
||
this.ds = dataSourceFor(this.data);
|
||
/** client-side combat (DESIGN §14) — see the header */
|
||
this.clientCombat = opts.clientCombat != null ? !!opts.clientCombat : envClientCombat();
|
||
this.verifyMode = parseVerify(opts.verify ?? env('SP_VERIFY'));
|
||
/** wall-clock ms per slice of a server-run normal / 联防 field (virtual time: at once) */
|
||
this.headlessSliceMs = Number.isFinite(opts.headlessSliceMs) && opts.headlessSliceMs > 0 ? opts.headlessSliceMs : this.sched.virtual ? Infinity : HEADLESS_SLICE_MS;
|
||
this.verifyStats = { checked: 0, mismatches: 0, rejected: 0, takeovers: 0 };
|
||
this._battleSeq = 0;
|
||
/** solo pause (g.pause, DESIGN §14): the field clocks / deadlines are frozen while true (m.public.paused) */
|
||
this.paused = false;
|
||
this._pausedAt = 0;
|
||
/** real ms spent paused this match (diagnostics) */
|
||
this.pausedMs = 0;
|
||
/** boss rounds (client-side combat): the throttled m.public refresh (_bossPublic) */
|
||
this._bossPubTimer = null;
|
||
this._bossPubAt = -Infinity;
|
||
this._bossClockOn = false;
|
||
/**
|
||
* battleId prefix (DESIGN §14): the seed (+ the room's match number) — a b.progress / b.result that crossed into the
|
||
* next match of the room (the same socket, the same field ids) never names a battle of this match.
|
||
*/
|
||
this.battlePrefix = `${this.seed.toString(36)}${Number.isInteger(opts.matchNo) && opts.matchNo > 0 ? `-${opts.matchNo.toString(36)}` : ''}`;
|
||
this._progressTimer = null;
|
||
this._bossClock = null;
|
||
this._lastPoolAt = -Infinity;
|
||
this._poolTimer = null;
|
||
this._lastPoolKey = '';
|
||
this.pacer = null;
|
||
|
||
const rng = (name) => createRng(deriveSeed(this.seed, name));
|
||
this.rngSetup = rng('setup');
|
||
this.rngShop = rng('shop');
|
||
this.rngWaves = rng('waves');
|
||
this.rngDraft = rng('draft');
|
||
this.rngBots = rng('bots');
|
||
this.rngMeta = rng('meta');
|
||
|
||
/** @type {Map<string, PlayerState>} */
|
||
this.players = new Map();
|
||
const seen = new Set();
|
||
for (const s of opts.seats) {
|
||
if (!s || typeof s.playerId !== 'string' || seen.has(s.playerId)) continue;
|
||
seen.add(s.playerId);
|
||
this.players.set(s.playerId, new PlayerState(this, s));
|
||
}
|
||
if (!this.players.size) throw new TypeError('Match: seats required');
|
||
this.order = [...this.players.values()].sort((a, b) => a.seat - b.seat);
|
||
/**
|
||
* Exactly one human seat at the start (独立模拟, or a 同盟 room started alone / with AI teammates only): nobody waits
|
||
* on anybody, so no phase outside a battle is timed — soloUntimed (user playtest #4 item 3). The mode's own rules
|
||
* (draft order and skip, 6 机变 cards, 联防 …) stay.
|
||
*/
|
||
this.loneHuman = this.order.filter((p) => !p.isBot).length === 1;
|
||
|
||
// per-match setup (DESIGN §6.5)
|
||
const setup = setupMatchWaves(this.gd, this.rngSetup);
|
||
this.stageId = setup.stageId;
|
||
this.stage = this.stageId ? this.gd.stage(this.stageId) : null;
|
||
this.factions = setup.factions;
|
||
this.bossId = setup.bossId;
|
||
this.hiddenBossId = setup.hiddenBossId;
|
||
const bans = drawDisabledBonds(this.gd, this.rngSetup);
|
||
this.disabledBonds = bans.drawn;
|
||
this.staticInactiveBonds = bans.staticOff;
|
||
this.bannedChess = bans.banned;
|
||
this.pool = new SharedPool(this.gd, { banned: bans.banned });
|
||
|
||
this.phase = PHASE.LOBBY;
|
||
this.round = 0;
|
||
this.deadline = 0;
|
||
/** Final Assault / Hidden Core: ms epoch when the overtime drain starts (m.public.overtimeAt) */
|
||
this.overtimeAt = 0;
|
||
this.uidSeq = 0;
|
||
this.ended = false;
|
||
this.disposed = false;
|
||
this.startedAt = this.sched.now();
|
||
/** @type {Set<any>} */
|
||
this._timers = new Set();
|
||
this._phaseTimer = null;
|
||
this._turnTimer = null;
|
||
this._pubDirty = false;
|
||
this._pubTimer = null;
|
||
this._lastPubAt = -Infinity;
|
||
this._lastPubJson = '';
|
||
/** @type {Set<PlayerState>} */
|
||
this._privDirty = new Set();
|
||
this.errors = [];
|
||
this.errorCount = 0;
|
||
this.simErrors = 0;
|
||
/** @type {Map<string, { label: string, who: string, message: string, stack: string|null, battles: number, count: number }>} */
|
||
this.simErrorLog = new Map();
|
||
this._prepEndQueued = false;
|
||
|
||
this.draft = null;
|
||
this.sp = null;
|
||
this.wave = null;
|
||
this.bossWaves = null;
|
||
this.fields = [];
|
||
this.runner = null;
|
||
/** playerId → fieldId */
|
||
this.watchers = new Map();
|
||
this.lastResults = new Map();
|
||
this.unitePlan = null;
|
||
/** server-run 联防: the leakers' counts last published (_uniteTick) */
|
||
this._uniteLeftKey = null;
|
||
/** 联防: { plan, bounds } — per leaker the most survivors settlement can bill (_uniteLeft's clamp) */
|
||
this._uniteBounds = null;
|
||
this.teamLp = null;
|
||
this.bossPool = null;
|
||
this.hiddenLayerSum = 0;
|
||
this.hiddenReached = false;
|
||
this.outcome = null;
|
||
this._turnToken = 0;
|
||
}
|
||
|
||
// ===================================================================================================
|
||
// platform interface
|
||
|
||
start() {
|
||
if (this.disposed || this.ended || this.phase !== PHASE.LOBBY) return;
|
||
this.guard(() => {
|
||
if (!this.gd.visibleChess.length || this.pool.entries.size === 0) {
|
||
this.log.error?.(`[match ${this.roomCode}] game data unusable (no chess pool) — ending the match`);
|
||
this.phase = PHASE.INFO_CHECK;
|
||
this.markPublic();
|
||
this.flush(true);
|
||
this.finish({ victory: false, reason: 'error' });
|
||
return;
|
||
}
|
||
this.enterInfoCheck();
|
||
});
|
||
}
|
||
|
||
/**
|
||
* @param {string} playerId
|
||
* @param {{ t: string }} msg validated intent
|
||
* @returns {{ ok: true } | { error: string, detail?: string }}
|
||
*/
|
||
handle(playerId, msg) {
|
||
const ps = this.players.get(playerId);
|
||
if (!ps || ps.isBot || ps.left) return fail(ERR.NOT_IN_ROOM);
|
||
if (this.disposed || this.ended) {
|
||
// a battle report that crossed the match end (the last b.progress of a field) is stale: ignored, never an error
|
||
// (DESIGN §14 — an error frame without a rid would surface as a toast in the browser)
|
||
return this.clientCombat && msg && (msg.t === 'b.progress' || msg.t === 'b.result') ? OK : fail(ERR.WRONG_PHASE);
|
||
}
|
||
if (!msg || typeof msg !== 'object' || typeof msg.t !== 'string' || !GAME_TYPES.has(msg.t)) return fail(ERR.BAD_MSG);
|
||
let res;
|
||
try {
|
||
res = this._handle(ps, msg);
|
||
} catch (e) {
|
||
this.reportError(`handle ${msg.t}`, e);
|
||
res = fail(ERR.INTERNAL);
|
||
}
|
||
try { this.flush(); } catch (e) { this.reportError('flush', e); }
|
||
if (res && typeof res === 'object' && res.error) return res;
|
||
return OK;
|
||
}
|
||
|
||
/**
|
||
* room.loadout during the match (DESIGN §16): only while INFO_CHECK runs (the briefing's 干员调配 entry); the lobby
|
||
* already checked it against the data (PlayerState.setLoadout re-checks it).
|
||
* @param {string} playerId
|
||
* @param {Record<string, { skill: number, module: string|null }> | null} loadout
|
||
* @returns {{ ok: true } | { error: string, detail?: string }}
|
||
*/
|
||
setLoadout(playerId, loadout) {
|
||
const ps = this.players.get(playerId);
|
||
if (!ps || ps.isBot || ps.left) return fail(ERR.NOT_IN_ROOM);
|
||
if (this.disposed || this.ended || this.phase !== PHASE.INFO_CHECK) return fail(ERR.WRONG_PHASE, 'loadout locked for this match');
|
||
let res = OK;
|
||
this.guard(() => {
|
||
if (!ps.setLoadout(loadout)) { res = fail(ERR.BAD_TARGET, 'loadout does not match the game data'); return; }
|
||
this.markPrivate(ps);
|
||
});
|
||
return res;
|
||
}
|
||
|
||
onDisconnect(playerId) {
|
||
const ps = this.players.get(playerId);
|
||
if (!ps || ps.isBot || this.disposed) return;
|
||
this.guard(() => {
|
||
ps.connected = false;
|
||
// a paused solo battle resumes (the server takes the field over; nobody is left to resume it)
|
||
this._resume();
|
||
if (this.clientCombat) this._authorityLost(ps, 'disconnect');
|
||
this.markPublic();
|
||
});
|
||
}
|
||
|
||
onReconnect(playerId) {
|
||
const ps = this.players.get(playerId);
|
||
if (!ps || ps.isBot || ps.left || this.disposed) return;
|
||
this.guard(() => {
|
||
const was = ps.connected;
|
||
ps.connected = true;
|
||
this.sendTo(playerId, this.publicView());
|
||
if (!this.ended) {
|
||
ps._lastPriv = null;
|
||
this._sendPrivate(ps, true);
|
||
if (this.clientCombat) this._resendBattle(ps);
|
||
else {
|
||
const fid = this.watchers.get(playerId);
|
||
if (fid) this._sendField(playerId, fid);
|
||
}
|
||
} else if (this.lastResultMsg) {
|
||
this.sendTo(playerId, { ...this.lastResultMsg, playerId });
|
||
}
|
||
if (!was) this.markPublic();
|
||
});
|
||
}
|
||
|
||
onLeave(playerId) {
|
||
const ps = this.players.get(playerId);
|
||
if (!ps || ps.isBot || ps.left || this.disposed) return;
|
||
this.guard(() => {
|
||
ps.left = true;
|
||
ps.connected = false;
|
||
ps.autoplay = false;
|
||
this.watchers.delete(playerId);
|
||
if (this.ended) return;
|
||
this._resume();
|
||
if (this.clientCombat) this._authorityLost(ps, 'left');
|
||
this.markPublic();
|
||
if (!this.order.some((p) => !p.isBot && !p.left)) {
|
||
this.finish({ victory: false, reason: 'abandoned' });
|
||
return;
|
||
}
|
||
this._quit(ps);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 中途退出 counts as elimination (research 00-INDEX §3, 01 §9, 06 §7 / §10.3): every copy the player holds goes back
|
||
* to the shared pool at once, and the seat has no place in later rounds, the Final Assault pairing or the boss pool
|
||
* (alive × 25 %). Rounds passed = the rounds the player had survived when leaving.
|
||
*/
|
||
_quit(ps) {
|
||
this.maybeEndInfo();
|
||
if (!ps.alive) return;
|
||
const phase = this.phase;
|
||
const d = this.draft;
|
||
if (phase === PHASE.BAND_DRAFT && d && !d.picks[ps.playerId]) {
|
||
// the departed seat passes its turn with the default band (never one a teammate holds — defaultBand)
|
||
const turn = this.draftTurn() === ps.playerId;
|
||
d.picks[ps.playerId] = this.defaultBand(ps.playerId);
|
||
ps.bandId = d.picks[ps.playerId];
|
||
if (turn) this.startDraftTurn();
|
||
}
|
||
const passedRound = phase === PHASE.SETTLE ? this.round + 1 : Math.max(1, this.round);
|
||
// its own normal battle has nobody left to fight for
|
||
for (const f of this.fields) {
|
||
if (f.live && f.kind === 'normal' && f.players.length === 1 && f.players[0] === ps.playerId) {
|
||
if (this.clientCombat && f.cc) {
|
||
if (!f.result) f.result = syntheticResult(f.players);
|
||
this._fieldDone(f);
|
||
continue;
|
||
}
|
||
try { f.battle.forceEnd('left'); } catch (e) { this.reportError('quit forceEnd', e); }
|
||
}
|
||
}
|
||
ps.lp = 0;
|
||
ps.eliminate(passedRound);
|
||
this.tickerText(`${ps.name}博士中途退出了模拟`);
|
||
if (this.bossWaves && (phase === PHASE.ROUND_START || phase === PHASE.SP_DRAFT || phase === PHASE.PREP)) {
|
||
// before the boss fight: pair the players left again (the prep preview shows the new partner / template); a
|
||
// player moved to the other half re-checks its board there at once (recompute → deployMap, marks it private)
|
||
this._planBossWaves();
|
||
for (const p of this.alivePlayers()) p.recompute();
|
||
}
|
||
this.markPublic();
|
||
if (this.teamLp != null) this._syncTeamLp();
|
||
if (!this.alivePlayers().length) {
|
||
// only eliminated spectators are left
|
||
this.finish({ victory: false, reason: 'eliminated' });
|
||
return;
|
||
}
|
||
if (phase === PHASE.SP_DRAFT && this.sp && this.spTurn() === ps.playerId) this.startSpTurn();
|
||
else if (phase === PHASE.PREP) this.maybeEndPrep();
|
||
}
|
||
|
||
dispose() {
|
||
if (this.disposed) return;
|
||
this.disposed = true;
|
||
if (this.runner) { try { this.runner.stop(); } catch { /* ignore */ } }
|
||
this._stopClientCombat();
|
||
for (const h of this._timers) { try { this.sched.clearTimeout(h); } catch { /* ignore */ } }
|
||
this._timers.clear();
|
||
if (this._pubTimer) { try { this.sched.clearTimeout(this._pubTimer); } catch { /* ignore */ } this._pubTimer = null; }
|
||
if (this.ownsScheduler) this.sched.dispose();
|
||
}
|
||
|
||
// ===================================================================================================
|
||
// infrastructure
|
||
|
||
/** Run a callback with error isolation, then flush the views. */
|
||
guard(fn) {
|
||
if (this.disposed) return;
|
||
try { fn(); } catch (e) { this.reportError('guard', e); }
|
||
try { this.flush(); } catch (e) { this.reportError('flush', e); }
|
||
}
|
||
|
||
reportError(label, e) {
|
||
this.errorCount++;
|
||
const msg = `${label}: ${e && e.message ? e.message : e}`;
|
||
if (this.errors.length < 50) this.errors.push({ label, message: String(e && e.message ? e.message : e), stack: e && e.stack ? String(e.stack).split('\n').slice(0, 6).join('\n') : null });
|
||
this.log.error?.(`[match ${this.roomCode}] ${msg}`, e && e.stack ? String(e.stack).split('\n').slice(1, 4).join(' | ') : '');
|
||
}
|
||
|
||
/** Schedule a guarded callback (tracked for dispose). */
|
||
later(ms, fn) {
|
||
if (this.disposed) return null;
|
||
let h = null;
|
||
h = this.sched.setTimeout(() => {
|
||
this._timers.delete(h);
|
||
if (this.disposed) return;
|
||
this.guard(fn);
|
||
}, ms);
|
||
if (h) this._timers.add(h);
|
||
return h;
|
||
}
|
||
|
||
cancel(h) {
|
||
if (!h) return;
|
||
this.sched.clearTimeout(h);
|
||
this._timers.delete(h);
|
||
}
|
||
|
||
scaled(ms) { return Math.max(0, Math.round(ms * this.timerScale)); }
|
||
|
||
/**
|
||
* Set the phase deadline (seconds; null/0 ⇒ untimed) and its timeout callback. `silent`: the timer runs but no
|
||
* deadline is published (m.public.deadline 0 ⇒ no countdown) — the fixed presentation steps of a solo match.
|
||
*/
|
||
setDeadline(seconds, fn, { silent = false } = {}) {
|
||
this.cancel(this._phaseTimer);
|
||
this._phaseTimer = null;
|
||
if (!(seconds > 0) || typeof fn !== 'function') { this.deadline = 0; return; }
|
||
const ms = this.scaled(seconds * 1000);
|
||
this.deadline = silent ? 0 : this.sched.now() + ms;
|
||
this._phaseTimer = this.later(ms, () => { this._phaseTimer = null; fn(); });
|
||
}
|
||
|
||
/**
|
||
* Solo timers (research 01 §843 / 06 §3: 下半 独立模拟 has no time limit on 休整期 / 机变 — "休整期及机变阶段没有时间
|
||
* 限制"; the strategy draft is free too): a solo match publishes a deadline ONLY for its battles. INFO_CHECK waits
|
||
* for 准备就绪 (co-op keeps the official 25 s guard), BAND_DRAFT / SP_DRAFT / PREP are untimed, and the fixed
|
||
* presentation steps (BATTLE_CHECK, ROUND_START, SETTLE) run silently (no countdown). The same holds for any match
|
||
* with a single human (loneHuman: a 同盟 room started alone or with AI teammates only — user playtest #4 item 3):
|
||
* the timers only ever made humans wait on each other; AI seats act at once.
|
||
*/
|
||
get soloUntimed() { return this.isSolo || this.loneHuman; }
|
||
|
||
nextUid() { return ++this.uidSeq; }
|
||
|
||
alivePlayers() { return this.order.filter((p) => p.alive); }
|
||
|
||
/** Whether any chess of a bond is in this match's pool (a 驰援 card of a fully banned bond is never offered). */
|
||
bondInPool(bondId) {
|
||
for (const id of this.pool.entries.keys()) {
|
||
const c = this.gd.chess(id);
|
||
if (c && Array.isArray(c.bonds) && c.bonds.includes(bondId)) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* Whether a bond can matter in this match: not in the mode's static inactive list (标准: 拉特兰 / 阿戈尔 / 卡西米尔 /
|
||
* 奥术 … never activate, research 02 §2.1) and still with chess in the pool. 机变 tactic cards whose every target bond
|
||
* is dead are not offered (choices.js).
|
||
*/
|
||
bondLive(bondId) {
|
||
return !this.gd.modeInactiveBonds.has(bondId) && this.bondInPool(bondId);
|
||
}
|
||
humans() { return this.order.filter((p) => !p.isBot && !p.left); }
|
||
|
||
/**
|
||
* The boss-round group of a player (`bossWaves`: the seat pairs of finalAssault.js pairPlayers, planned in startRound
|
||
* before the players' round start) and its side — 'L', or 'R' for the second player of a pair (the mirrored right
|
||
* half) — or null outside a boss round / for a player without a field.
|
||
* @returns {{ wave: object, players: string[], side: 'L'|'R' } | null}
|
||
*/
|
||
bossGroupOf(ps) {
|
||
if (!ps || !Array.isArray(this.bossWaves)) return null;
|
||
const g = this.bossWaves.find((x) => Array.isArray(x.players) && x.players.includes(ps.playerId));
|
||
return g ? { wave: g.wave, players: g.players, side: g.players.indexOf(ps.playerId) === 1 ? 'R' : 'L' } : null;
|
||
}
|
||
|
||
/**
|
||
* The field a player deploys on (server/match/board.js DEPLOY_FIELDS): in a boss round (最终攻势 / 隐秘核心, from its
|
||
* ROUND_START on — the pairing exists before PlayerState.startRound's recompute) the player's half of the boss field,
|
||
* 'bossL' or 'bossR' (bossGroupOf); otherwise its own normal board. User playtest #5 item 7.
|
||
* @returns {'normal'|'bossL'|'bossR'}
|
||
*/
|
||
deployFieldOf(ps) {
|
||
const g = this.bossGroupOf(ps);
|
||
return !g ? 'normal' : g.side === 'R' ? 'bossR' : 'bossL';
|
||
}
|
||
|
||
/**
|
||
* A fresh deploy map of a player on `field` (default: the field it deploys on now) under its device / tile overrides.
|
||
* Pure — no PlayerState cache is touched (the read-only checker invariants.js uses it; PlayerState.deployMap caches).
|
||
*/
|
||
deployMapFor(ps, field = this.deployFieldOf(ps)) {
|
||
return buildDeployMap(this.stage, { deviceOverrides: ps.deviceOverrides, tileOverrides: ps.tileOverrides, field });
|
||
}
|
||
|
||
dispatch(ps, hook, ev = {}, opts = {}) {
|
||
try { return this.dispatcher.dispatch(ps, hook, ev, opts); } catch (e) { this.reportError(`dispatch ${hook}`, e); return ev; }
|
||
}
|
||
|
||
dispatchItem(ps, item, holder, hook, ev) {
|
||
try { return this.dispatcher.dispatchItem(ps, item, holder, hook, ev); } catch (e) { this.reportError(`dispatchItem ${hook}`, e); return ev; }
|
||
}
|
||
|
||
// ---- messaging
|
||
|
||
sendTo(playerId, msg) {
|
||
if (this.disposed) return false;
|
||
const ps = this.players.get(playerId);
|
||
if (!ps || ps.isBot || ps.left) return false;
|
||
try { return !!this.sendFn(playerId, msg); } catch (e) { this.reportError('send', e); return false; }
|
||
}
|
||
|
||
broadcast(msg) {
|
||
if (this.disposed) return;
|
||
try { this.broadcastFn(msg); } catch (e) { this.reportError('broadcast', e); }
|
||
}
|
||
|
||
toast(ps, kind, text) {
|
||
if (!ps || ps.isBot || ps.left || !ps.connected) return;
|
||
this.sendTo(ps.playerId, { t: 'm.toast', kind, text });
|
||
}
|
||
|
||
/** Broadcast ticker from config.broadcasts by type; `param` picks the variant (SHOP_LEVEL level, BOSS_HIT share…). */
|
||
tickerFor(type, args = [], { playerId = null, to = null, param = null } = {}) {
|
||
const list = Array.isArray(this.gd.config.broadcasts) ? this.gd.config.broadcasts.filter((b) => b && b.type === type) : [];
|
||
let b = list[0];
|
||
if (param != null) b = list.find((x) => Array.isArray(x.params) && x.params.includes(String(param))) || b;
|
||
const tpl = b && typeof b.text === 'string' ? b.text : null;
|
||
if (!tpl) return;
|
||
const text = tpl.replace(/\{(\d)\}/g, (_, i) => (args[Number(i)] != null ? String(args[Number(i)]) : ''));
|
||
const msg = { t: 'm.ticker', text, id: b.id, type, priority: Number(b.priority) || 0, playerId };
|
||
if (to) this.sendTo(to, msg);
|
||
else this.broadcast(msg);
|
||
}
|
||
|
||
tickerText(text) {
|
||
if (!text) return;
|
||
this.broadcast({ t: 'm.ticker', text: String(text).slice(0, 200), id: null, type: 'CUSTOM', priority: 0, playerId: null });
|
||
}
|
||
|
||
markPublic() { this._pubDirty = true; }
|
||
/** A player's state changed: its m.private and (throttled, deduplicated) m.public (level, board, bonds…). */
|
||
markPrivate(ps) { if (ps) { this._privDirty.add(ps); this._pubDirty = true; } }
|
||
|
||
/** Send pending m.private (per player, only when changed) and m.public (throttled ≤ 10/s). */
|
||
flush(forcePublic = false) {
|
||
if (this.disposed) return;
|
||
if (this._privDirty.size) {
|
||
const list = [...this._privDirty];
|
||
this._privDirty.clear();
|
||
for (const ps of list) {
|
||
if (ps.isBot || ps.left || !ps.connected) continue;
|
||
this._sendPrivate(ps, false);
|
||
}
|
||
}
|
||
if (this._pubDirty || forcePublic) this._maybeSendPublic(forcePublic);
|
||
}
|
||
|
||
_sendPrivate(ps, force) {
|
||
let view;
|
||
try { view = ps.privateView(); } catch (e) { this.reportError('privateView', e); return; }
|
||
const json = JSON.stringify(view);
|
||
if (!force && json === ps._lastPriv) return;
|
||
ps._lastPriv = json;
|
||
this.sendTo(ps.playerId, view);
|
||
}
|
||
|
||
_maybeSendPublic(force) {
|
||
const now = this.sched.now();
|
||
if (!force && now - this._lastPubAt < DELAYS.PUBLIC_THROTTLE) {
|
||
if (!this._pubTimer) {
|
||
const wait = Math.max(1, DELAYS.PUBLIC_THROTTLE - (now - this._lastPubAt));
|
||
this._pubTimer = this.sched.setTimeout(() => {
|
||
this._pubTimer = null;
|
||
if (this.disposed) return;
|
||
try { this._maybeSendPublic(false); } catch (e) { this.reportError('public', e); }
|
||
}, wait);
|
||
}
|
||
return;
|
||
}
|
||
this._pubDirty = false;
|
||
let view;
|
||
try { view = this.publicView(); } catch (e) { this.reportError('publicView', e); return; }
|
||
const { serverNow, ...rest } = view;
|
||
const json = JSON.stringify(rest);
|
||
if (!force && json === this._lastPubJson) return;
|
||
this._lastPubJson = json;
|
||
this._lastPubAt = now;
|
||
this.broadcast(view);
|
||
}
|
||
|
||
// ===================================================================================================
|
||
// views
|
||
|
||
statusOf(ps) {
|
||
if (ps.left) return 'left';
|
||
if (!ps.alive) return 'dead';
|
||
switch (this.phase) {
|
||
case PHASE.INFO_CHECK: return ps.infoReady ? 'ready' : 'deciding';
|
||
case PHASE.BAND_DRAFT:
|
||
if (this.draft && this.draft.picks[ps.playerId]) return 'ready';
|
||
return this.draft && this.draftTurn() === ps.playerId ? 'deciding' : 'acting';
|
||
case PHASE.SP_DRAFT:
|
||
if (this.sp && this.sp.picks[ps.playerId] != null) return 'ready';
|
||
return this.sp && this.spTurn() === ps.playerId ? 'deciding' : 'acting';
|
||
case PHASE.PREP: return ps.ready ? 'ready' : 'acting';
|
||
case PHASE.COMBAT: case PHASE.FINAL_ASSAULT: case PHASE.HIDDEN_CORE: {
|
||
const f = this.fields.find((x) => x.players.includes(ps.playerId));
|
||
return f && f.live ? 'combat' : 'done';
|
||
}
|
||
case PHASE.UNITE: return this.unitePlan && this.unitePlan.helpers.includes(ps) ? 'helping' : 'done';
|
||
case PHASE.ROUND_START: return 'acting';
|
||
default: return 'done';
|
||
}
|
||
}
|
||
|
||
fieldOf(ps) {
|
||
const f = this.fields.find((x) => x.players.includes(ps.playerId));
|
||
return f ? f.fieldId : null;
|
||
}
|
||
|
||
publicView() {
|
||
const v = {
|
||
t: 'm.public',
|
||
phase: this.phase,
|
||
round: this.round,
|
||
lastRound: this.gd.lastRound,
|
||
deadline: this.deadline,
|
||
serverNow: this.sched.now(),
|
||
modeId: this.modeId,
|
||
difficulty: this.difficulty,
|
||
stageId: this.stageId,
|
||
factions: this.factions.slice(),
|
||
disabledBonds: [...new Set([...this.disabledBonds, ...this.staticInactiveBonds])].sort(),
|
||
drawnDisabledBonds: this.disabledBonds.slice(),
|
||
bannedChess: this.bannedChess.slice(),
|
||
bossId: this.bossId,
|
||
hiddenBossId: this.hiddenBossId,
|
||
bossRound: this.gd.bossRound,
|
||
hiddenRound: this.gd.hiddenRound,
|
||
spRound: this.gd.spRounds().includes(this.round),
|
||
// DESIGN §14: 'client' = battles are simulated by the browsers (b.start specs), 'server' = legacy streaming
|
||
combatMode: this.clientCombat ? 'client' : 'server',
|
||
// solo pause (g.pause, DESIGN §14): the battle, its field clock and every deadline are frozen while true
|
||
paused: !!this.paused,
|
||
players: this.order.map((ps) => ({
|
||
playerId: ps.playerId,
|
||
seat: ps.seat,
|
||
name: ps.name,
|
||
isBot: ps.isBot,
|
||
connected: ps.isBot || (ps.connected && !ps.left),
|
||
alive: ps.alive,
|
||
lp: Math.max(0, ps.lp),
|
||
bandId: ps.bandId,
|
||
shopLevel: ps.shop.level,
|
||
boardCount: ps.deployCount,
|
||
ready: this.phase === PHASE.INFO_CHECK ? ps.infoReady : ps.ready,
|
||
// the strip of a teammate watching this player (DESIGN §20.15): every bond with members, layers or an active tier
|
||
// (= the player's own m.private list without thresholds / countsHand — the client reads those from bonds.json),
|
||
// this round's in-battle gains included once the COMBAT phase ended (PlayerState.bondsView); [] once eliminated —
|
||
// nobody can watch an eliminated player (g.watch refuses them, they have no field) and the result screen reads
|
||
// m.result's own bonds, so their layers would only cost every m.public bytes for the rest of the match
|
||
bonds: ps.alive ? bondList(this.gd, ps.bondsView()) : [],
|
||
fieldId: this.fieldOf(ps),
|
||
status: this.statusOf(ps),
|
||
autoplay: ps.autoplay,
|
||
// the LP this round's own battle will cost at settlement so far (COMBAT / 联防 only, omitted when 0)
|
||
...this._pendingLpView(ps),
|
||
})),
|
||
fields: this.fields.map((f) => {
|
||
const v = { fieldId: f.fieldId, kind: f.kind, players: f.players.slice(), live: !!f.live };
|
||
const pr = this._fieldProgress(f);
|
||
if (pr) v.progress = pr;
|
||
return v;
|
||
}),
|
||
};
|
||
if (this.teamLp != null) v.teamLp = Math.max(0, Math.round(this.teamLp));
|
||
// 最终攻势 / 隐秘核心: when the overtime drain starts (ms epoch; `deadline` is the level's 120 s countdown)
|
||
if ((this.phase === PHASE.FINAL_ASSAULT || this.phase === PHASE.HIDDEN_CORE) && this.overtimeAt) v.overtimeAt = this.overtimeAt;
|
||
if (this.bossPool) v.bossHp = { hp: Math.max(0, Math.round(this.bossPool.hp)), max: Math.round(this.bossPool.maxHp) };
|
||
if (this.phase === PHASE.BAND_DRAFT && this.draft) {
|
||
const d = this.draft;
|
||
// turnSeconds: the length of a turn (the countdown gauge's total; 0 when untimed) — deadline = turnDeadline
|
||
v.draft = {
|
||
order: d.order.slice(), turn: this.draftTurn(), picks: { ...d.picks }, skipsLeft: { ...d.skipsLeft }, turnDeadline: d.turnDeadline || 0,
|
||
turnSeconds: d.untimed ? 0 : this.bandTurnMs() / 1000, untimed: !!d.untimed,
|
||
};
|
||
}
|
||
if (this.phase === PHASE.SP_DRAFT && this.sp) {
|
||
const s = this.sp;
|
||
v.sp = {
|
||
family: s.family, name: s.name, desc: s.desc, eventId: s.eventId, cards: s.cards.map(cardView), order: s.order.slice(),
|
||
turn: this.spTurn(), picks: { ...s.picks }, taken: { ...s.taken }, untimed: !!s.untimed,
|
||
};
|
||
}
|
||
if (this.phase === PHASE.UNITE && this.unitePlan) v.unite = { helpers: this.unitePlan.helpers.map((p) => p.playerId), leakers: this.unitePlan.leakers.map((p) => p.playerId) };
|
||
return v;
|
||
}
|
||
|
||
/** nextEnemies preview for m.private. */
|
||
nextEnemiesFor(ps) {
|
||
if (!ps.alive) return [];
|
||
if (this.bossWaves) {
|
||
const g = this.bossGroupOf(ps);
|
||
if (!g) return [];
|
||
return previewOf([...g.wave.spawns, ...bountySpawns(this.gd, this.round, g.wave, ps.bounties, ps.playerId, { solo: this.isSolo, side: g.side })]);
|
||
}
|
||
if (!this.wave) return [];
|
||
const bounty = bountySpawns(this.gd, this.round, this.wave, ps.bounties, ps.playerId, { solo: this.isSolo });
|
||
return previewOf([...this.wave.spawns, ...bounty]);
|
||
}
|
||
|
||
/** UnitInfo list of a player's board (prep scouting). */
|
||
prepFieldMeta(ps) {
|
||
const units = [];
|
||
for (const { r, c, piece } of boardOrder(ps.board)) {
|
||
const rec = piece.kind === 'token' ? this.gd.token(piece.id) : this.gd.chess(piece.id);
|
||
const assets = (rec && rec.assets) || {};
|
||
// DESIGN §16: the skill / module THIS player's operator fights with (the scout's detail card shows it, like the
|
||
// sim's UnitInfo in a shared field); moduleId only for an elite
|
||
const lo = piece.kind === 'chess' && rec ? ps.loadoutFor(rec) : null;
|
||
units.push({
|
||
id: piece.uid, uid: piece.uid, kind: piece.kind === 'token' ? 'token' : 'op', side: 'ally', ownerId: ps.playerId, defId: piece.id,
|
||
name: rec ? rec.name : piece.id, tier: rec && Number.isInteger(rec.tier) ? rec.tier : 1, golden: !!(rec && rec.isGolden),
|
||
spine: assets.spine || (rec && rec.charId) || piece.id, avatar: assets.avatar || (rec && rec.charId) || piece.id,
|
||
x: c, y: r, dir: pieceDir(piece), facing: pieceDir(piece) === 'LEFT' ? -1 : 1, maxHp: rec && rec.stats && Number.isFinite(rec.stats.maxHp) ? rec.stats.maxHp : 1,
|
||
skillIndex: lo && Number.isInteger(lo.skillIndex) ? lo.skillIndex : undefined,
|
||
moduleId: lo && typeof lo.moduleId === 'string' ? lo.moduleId : undefined,
|
||
// the equipped items (like the sim's UnitInfo): a 变形同构体 wearer shows as a member of the bond it grants
|
||
items: piece.kind === 'chess' && Array.isArray(piece.items) && piece.items.length ? piece.items.map((it) => it.id) : undefined,
|
||
});
|
||
}
|
||
// `nextEnemies`: the scouted player's coming enemies — their preview pen shows on the scouting board too (research 09
|
||
// §2.2 "Teammates"; render/app.js enterBattle({ prep: true, nextEnemies }))
|
||
let nextEnemies = [];
|
||
try { nextEnemies = this.nextEnemiesFor(ps); } catch (e) { this.reportError('nextEnemies', e); }
|
||
return { t: 'm.field', fieldId: `n:${ps.playerId}`, kind: 'normal', rect: { ...GEO.NORMAL_RECT }, stageId: this.stageId, units, prep: true, nextEnemies };
|
||
}
|
||
|
||
_sendField(playerId, fieldId) {
|
||
const f = this.fields.find((x) => x.fieldId === fieldId);
|
||
if (f) {
|
||
let meta;
|
||
try { meta = f.battle.fieldMeta(); } catch (e) { this.reportError('fieldMeta', e); return; }
|
||
this.sendTo(playerId, { t: 'm.field', ...meta, fieldId: f.fieldId, kind: f.kind, live: !!f.live });
|
||
try { this.sendTo(playerId, snapFrame(f.fieldId, f.battle.snapshot())); } catch (e) { this.reportError('snapshot', e); }
|
||
return;
|
||
}
|
||
if (typeof fieldId === 'string' && fieldId.startsWith('n:')) {
|
||
const target = this.players.get(fieldId.slice(2));
|
||
if (target && target.alive) this.sendTo(playerId, this.prepFieldMeta(target));
|
||
}
|
||
}
|
||
|
||
watchersOf(fieldId) {
|
||
const out = [];
|
||
for (const [pid, fid] of this.watchers) if (fid === fieldId) out.push(pid);
|
||
return out;
|
||
}
|
||
|
||
// ===================================================================================================
|
||
// intents
|
||
|
||
_handle(ps, msg) {
|
||
switch (msg.t) {
|
||
case 'g.infoReady':
|
||
if (this.phase !== PHASE.INFO_CHECK) return fail(ERR.WRONG_PHASE);
|
||
if (!ps.infoReady) { ps.infoReady = true; this.markPublic(); this.maybeEndInfo(); }
|
||
return OK;
|
||
case 'g.band': return this.pickBand(ps, msg.bandId);
|
||
case 'g.bandSkip': return this.skipBand(ps);
|
||
// the strategy highlighted in the draft screen (what a timed-out turn takes, timeoutBand)
|
||
case 'g.bandFocus': return this.bandFocus(ps, msg.bandId ?? null);
|
||
case 'g.buy': return ps.buy(msg.slot);
|
||
case 'g.refresh': return ps.refresh();
|
||
case 'g.freeze': return ps.freeze();
|
||
case 'g.levelUp': return ps.levelUp();
|
||
case 'g.sell': return ps.sell(msg.uid);
|
||
// dir: the deploy wheel's facing (DESIGN §3; absent ⇒ PlayerState reads to.dir, then RIGHT)
|
||
case 'g.move': return ps.move(msg.uid, msg.to, msg.dir);
|
||
case 'g.equip': return ps.equip(msg.itemUid, msg.targetUid, msg.replaceUid ?? null);
|
||
case 'g.art': return ps.useArt(msg.itemUid, msg.row, msg.col, msg.dir);
|
||
case 'g.destroy': return ps.destroy(msg.uid);
|
||
case 'g.reward': return ps.pickReward(msg.idx);
|
||
case 'g.choice': return this.pickCard(ps, msg.idx);
|
||
case 'g.ready': return ps.setReady(!!msg.ready);
|
||
case 'g.emote': return this.emote(ps, msg.id);
|
||
case 'g.watch': return this.watch(ps, msg.fieldId);
|
||
case 'g.autoplay': return this.setAutoplay(ps, !!msg.on);
|
||
case 'g.pause': return this.setPause(ps, !!msg.on);
|
||
// the stats the board's units start their next battle with (the detail card in prep, user playtest #4 item 7)
|
||
case 'g.unitStats': return this.unitStats(ps, msg.seq ?? null);
|
||
case 'g.leave': this.onLeave(ps.playerId); return OK;
|
||
case 'b.progress': return this._onProgress(ps, msg);
|
||
case 'b.result': return this._onResult(ps, msg);
|
||
default: return fail(ERR.BAD_MSG);
|
||
}
|
||
}
|
||
|
||
emote(ps, id) {
|
||
if (!EMOTES.includes(id)) return fail(ERR.BAD_MSG, 'unknown emote');
|
||
const now = this.sched.now();
|
||
if (now - ps.lastEmoteAt < EMOTE_COOLDOWN_MS) return fail(ERR.RATE);
|
||
ps.lastEmoteAt = now;
|
||
this.broadcast({ t: 'm.emote', playerId: ps.playerId, id });
|
||
return OK;
|
||
}
|
||
|
||
watch(ps, fieldId) {
|
||
if (typeof fieldId !== 'string') return fail(ERR.BAD_TARGET);
|
||
if (this.clientCombat && this.fields.length && this.fields.some((x) => x.cc)) return this._watchClient(ps, fieldId);
|
||
const f = this.fields.find((x) => x.fieldId === fieldId);
|
||
if (f) {
|
||
// 最终攻势 / 隐秘核心: "两名参与者会处于同一个战场,但无法查看另一组队友的战场情况" — a fighting player sees its own
|
||
// boss field only (eliminated / departed players spectate freely)
|
||
const own = this.fieldOf(ps);
|
||
if ((f.kind === 'boss' || f.kind === 'hidden') && own && own !== f.fieldId) return fail(ERR.BAD_TARGET, 'other group hidden');
|
||
this.watchers.set(ps.playerId, fieldId);
|
||
this._sendField(ps.playerId, fieldId);
|
||
return OK;
|
||
}
|
||
if (fieldId.startsWith('n:')) {
|
||
// prep scouting — a one-shot board view — only while no battle field is up: during 各自行动 / 联防 / 最终攻势 /
|
||
// 隐秘核心 an 'n:<pid>' id must name a live field (else the boss-group rule above could be bypassed, and the
|
||
// viewer would stop receiving its own field's snapshots)
|
||
if (this.fields.length) return fail(ERR.BAD_TARGET, 'no such field');
|
||
const target = this.players.get(fieldId.slice(2));
|
||
if (!target || !target.alive) return fail(ERR.BAD_TARGET);
|
||
this.watchers.delete(ps.playerId);
|
||
this.sendTo(ps.playerId, this.prepFieldMeta(target));
|
||
return OK;
|
||
}
|
||
return fail(ERR.BAD_TARGET);
|
||
}
|
||
|
||
setAutoplay(ps, on) {
|
||
if (ps.autoplay === on) return OK;
|
||
ps.autoplay = on;
|
||
this.markPublic();
|
||
if (on) this.kickBot(ps);
|
||
return OK;
|
||
}
|
||
|
||
/**
|
||
* g.unitStats { seq? } (user playtest #4 item 7: the detail card showed fixed record stats): the stats every unit of
|
||
* the player's board will fight with at the start of its next battle — equipment, bonds and their layers, 特质, the
|
||
* band and 机变 effects — computed exactly by the shared sim. The player's battle input after the onBattleStart meta
|
||
* handlers (`ev.preview: true`, no enemies — those handlers must not change the match for a preview) builds a Battle
|
||
* of the battle's options that is started (initial deployment + battleStart hooks), read and dropped: it is never
|
||
* stepped, so skills and timed effects do not show. Pushed to the player as `m.unitStats { seq, round, units }`
|
||
* (units: shared/protocol.js unitStatsEntry, board operators and summons by uid); cached per input (a build costs
|
||
* ≈ 0.3–0.7 ms). Prep phases only (ROUND_START, 机变, PREP); a battle's live stats come from the browser's own sim.
|
||
* @param {PlayerState} ps @param {number|null} seq echoed (the client keeps the newest answer)
|
||
*/
|
||
unitStats(ps, seq = null) {
|
||
if (!ps.alive) return fail(ERR.ELIMINATED);
|
||
if (this.phase !== PHASE.ROUND_START && this.phase !== PHASE.SP_DRAFT && this.phase !== PHASE.PREP) return fail(ERR.WRONG_PHASE);
|
||
const units = this._unitStatsOf(ps);
|
||
this.sendTo(ps.playerId, { t: 'm.unitStats', seq: Number.isInteger(seq) ? seq : null, round: this.round, units });
|
||
return OK;
|
||
}
|
||
|
||
/** The start-of-battle stats of a player's board units (see unitStats); [] when the preview battle cannot be built. */
|
||
_unitStatsOf(ps) {
|
||
const input = ps.battleInput({ side: 'L', colOffset: 0 });
|
||
const ev = { input, kind: 'normal', round: this.round, preview: true };
|
||
this.dispatch(ps, 'onBattleStart', ev);
|
||
const players = [ev.input && typeof ev.input === 'object' ? ev.input : input];
|
||
let key = null;
|
||
try { key = JSON.stringify([this.round, this.stageId, this.battleContent, players]); } catch { key = null; }
|
||
if (!this._unitStatsCache) this._unitStatsCache = new WeakMap(); // PlayerState → { key, units } (the last preview)
|
||
const cached = this._unitStatsCache.get(ps);
|
||
if (key && cached && cached.key === key) return cached.units;
|
||
const units = [];
|
||
// the flags of the battle it previews: a normal round gains IN_BATTLE layers from its start (a <战斗开始时> layer gain
|
||
// raises bond stats at t = 0 there too); the Final Assault / Hidden Core fight without (previewed as a normal field)
|
||
const bossRound = this.round === this.gd.bossRound || this.round === this.gd.hiddenRound;
|
||
const b = this.newBattle({
|
||
seed: deriveSeed(this.seed, `preview:${this.round}:${ps.seat}`), kind: 'normal', modeId: this.modeId, round: this.round,
|
||
stageId: this.stageId, rect: { ...GEO.NORMAL_RECT }, timeLimit: 60, players, spawns: [], routes: this.wave ? this.wave.routes : [],
|
||
sharedBoss: null, flags: { layerGainsEnabled: !bossRound, ...this.gd.dp }, fieldId: `n:${ps.playerId}`, recordEvents: false,
|
||
});
|
||
try {
|
||
if (typeof b.start === 'function') b.start();
|
||
for (const u of Array.isArray(b.allyUnits) ? b.allyUnits : []) {
|
||
if (u && Number.isInteger(u.uid) && (u.kind === 'op' || u.kind === 'token')) units.push(unitStatsEntry(u, u.s));
|
||
}
|
||
} catch (e) { this.reportError('unitStats', e); }
|
||
this._unitStatsCache.set(ps, { key, units });
|
||
return units;
|
||
}
|
||
|
||
/**
|
||
* Solo pause (official PauseUp / ResumeUp; DESIGN §14 "Solo pause"): g.pause { on } freezes the running battle — the
|
||
* field clock (b.start `elapsed`, the boss budgets and overtime), the result deadline / server release timers, the
|
||
* boss clock, the HUD `deadline` / `overtimeAt` (shifted by the pause on resume) and the server-run pacers
|
||
* (FieldRunner / HeadlessPacer skip their intervals) — and the browser's runner stops its local clock while
|
||
* `m.public.paused` is true. Solo matches only (co-op battles never pause: WRONG_PHASE) and only while a battle runs;
|
||
* `{ on: false }` is always accepted. A disconnect / leave, or the battle phase ending, resumes.
|
||
*/
|
||
setPause(ps, on) {
|
||
void ps;
|
||
if (!this.isSolo) return fail(ERR.WRONG_PHASE, 'co-op battles never pause');
|
||
if (!on) { this._resume(); return OK; }
|
||
if (this.paused) return OK;
|
||
const battlePhase = this.phase === PHASE.COMBAT || this.phase === PHASE.FINAL_ASSAULT || this.phase === PHASE.HIDDEN_CORE;
|
||
if (!battlePhase || this._finalEnding || !this.fields.some((f) => f.live && !f.done)) return fail(ERR.WRONG_PHASE, 'no battle running');
|
||
this.paused = true;
|
||
this._pausedAt = this.sched.now();
|
||
for (const f of this.fields) {
|
||
if (!f.cc) continue;
|
||
if (f.deadlineTimer) { this.cancel(f.deadlineTimer); f.deadlineTimer = null; f.rearmDeadline = true; }
|
||
if (f.doneTimer) { this.cancel(f.doneTimer); f.doneTimer = null; f.rearmRelease = true; }
|
||
}
|
||
if (this._bossClock) { this.cancel(this._bossClock); this._bossClock = null; }
|
||
this.markPublic();
|
||
return OK;
|
||
}
|
||
|
||
/** End a solo pause: every clock and deadline moves on by the paused time (no-op when not paused). */
|
||
_resume() {
|
||
if (!this.paused) return;
|
||
const d = Math.max(0, this.sched.now() - this._pausedAt);
|
||
this.paused = false;
|
||
this._pausedAt = 0;
|
||
this.pausedMs += d;
|
||
if (this.deadline) this.deadline += d;
|
||
if (this.overtimeAt) this.overtimeAt += d;
|
||
if (this._bossStartAt != null) this._bossStartAt += d;
|
||
for (const f of this.fields) {
|
||
if (!f.cc || f.done) continue;
|
||
f.startAt += d;
|
||
f.lastProgressAt += d;
|
||
if (f.rearmDeadline && f.mode === 'client') this._armDeadline(f);
|
||
if (f.rearmRelease && f.mode === 'server') this._armRelease(f);
|
||
f.rearmDeadline = false;
|
||
f.rearmRelease = false;
|
||
}
|
||
if (this._bossClockOn && !this._bossClock && (this.phase === PHASE.FINAL_ASSAULT || this.phase === PHASE.HIDDEN_CORE)) {
|
||
this._bossClock = this.later(BOSS_CLOCK_MS, () => this._bossClockTick());
|
||
}
|
||
this.markPublic();
|
||
}
|
||
|
||
/** The battle phase is over: drop the pause without shifting anything (its timers are gone). */
|
||
_clearPause() {
|
||
if (!this.paused) return;
|
||
this.pausedMs += Math.max(0, this.sched.now() - this._pausedAt);
|
||
this.paused = false;
|
||
this._pausedAt = 0;
|
||
this.markPublic();
|
||
}
|
||
|
||
/** The field clocks' "now": frozen at the pause instant while paused. */
|
||
_clockNow() { return this.paused ? this._pausedAt : this.sched.now(); }
|
||
|
||
/** Let the bot act for a (newly) bot-controlled seat in the current phase. */
|
||
kickBot(ps) {
|
||
if (!ps.botControlled || this.ended) return;
|
||
if (this.phase === PHASE.INFO_CHECK && !ps.infoReady) { ps.infoReady = true; this.markPublic(); this.maybeEndInfo(); }
|
||
else if (this.phase === PHASE.BAND_DRAFT && this.draftTurn() === ps.playerId) this.scheduleBandBot();
|
||
else if (this.phase === PHASE.SP_DRAFT && this.spTurn() === ps.playerId) this.scheduleSpBot();
|
||
else if (this.phase === PHASE.PREP && ps.alive && !ps.ready) this.scheduleBotPrep(ps, 0);
|
||
}
|
||
|
||
// ===================================================================================================
|
||
// INFO_CHECK
|
||
|
||
enterInfoCheck() {
|
||
this.phase = PHASE.INFO_CHECK;
|
||
for (const ps of this.order) if (ps.botControlled) ps.infoReady = true;
|
||
// solo: no time limit (the player confirms); co-op: the official 25 s guard
|
||
this.setDeadline(this.soloUntimed ? 0 : this.gd.timer('infoCheck'), () => this.enterBandDraft());
|
||
this.markPublic();
|
||
for (const ps of this.order) this.markPrivate(ps);
|
||
this.flush(true);
|
||
this.maybeEndInfo();
|
||
}
|
||
|
||
maybeEndInfo() {
|
||
if (this.phase !== PHASE.INFO_CHECK) return;
|
||
if (this.order.every((p) => p.isBot || p.left || p.infoReady)) {
|
||
this.setDeadline(0);
|
||
this.later(0, () => { if (this.phase === PHASE.INFO_CHECK) this.enterBandDraft(); });
|
||
}
|
||
}
|
||
|
||
// ===================================================================================================
|
||
// BAND_DRAFT
|
||
|
||
/**
|
||
* The strategy draft (user playtest #4 item 4): ONE countdown — every turn has the same clock, BAND_TURN_SECONDS, and
|
||
* m.public.deadline is the current turn's end (= draft.turnDeadline; the step header and the turn indicator show the
|
||
* same number). No separate step cap: the turns bound the step (≤ (seats + skips) × turn). AI seats pick at once. A
|
||
* turn that runs out takes the strategy the player has highlighted (g.bandFocus) while it is free, else the default
|
||
* (timeoutBand). Solo, and any single-human match (soloUntimed): untimed. Solo also keeps seat order and has no skip.
|
||
*/
|
||
enterBandDraft() {
|
||
if (this.phase !== PHASE.INFO_CHECK) return;
|
||
this.phase = PHASE.BAND_DRAFT;
|
||
const order = this.order.map((p) => p.playerId);
|
||
if (!this.isSolo) this.rngDraft.shuffle(order);
|
||
const skips = this.isSolo ? 0 : this.gd.bandDraft.skipsPerPlayer;
|
||
const untimed = this.soloUntimed;
|
||
this.draft = {
|
||
order, idx: 0, picks: {}, skipsLeft: Object.fromEntries(order.map((pid) => [pid, skips])), untimed, turnDeadline: 0,
|
||
/** playerId → the strategy highlighted in the draft screen (g.bandFocus) */
|
||
focus: new Map(),
|
||
};
|
||
this.setDeadline(0);
|
||
this.startDraftTurn();
|
||
this.markPublic();
|
||
}
|
||
|
||
draftTurn() {
|
||
const d = this.draft;
|
||
if (!d) return null;
|
||
return d.order[d.idx] ?? null;
|
||
}
|
||
|
||
/** Real ms of one strategy-draft turn (BAND_TURN_SECONDS × timerScale). */
|
||
bandTurnMs() { return this.scaled(BAND_TURN_SECONDS * 1000); }
|
||
|
||
startDraftTurn() {
|
||
const d = this.draft;
|
||
this.cancel(this._turnTimer);
|
||
this._turnTimer = null;
|
||
while (d.idx < d.order.length && d.picks[d.order[d.idx]]) d.idx++;
|
||
if (d.idx >= d.order.length) {
|
||
d.turnDeadline = 0;
|
||
this.deadline = 0;
|
||
this.later(0, () => this.finishBandDraft(false));
|
||
return;
|
||
}
|
||
const token = ++this._turnToken;
|
||
if (!d.untimed) {
|
||
const ms = this.bandTurnMs();
|
||
d.turnDeadline = this.sched.now() + ms;
|
||
// the step's countdown IS the turn's (one number everywhere)
|
||
this.deadline = d.turnDeadline;
|
||
this._turnTimer = this.later(ms, () => {
|
||
if (this.phase !== PHASE.BAND_DRAFT || token !== this._turnToken) return;
|
||
const pid = this.draftTurn();
|
||
if (pid) this._applyBand(this.players.get(pid), this.timeoutBand(pid));
|
||
});
|
||
} else {
|
||
d.turnDeadline = 0;
|
||
this.deadline = 0;
|
||
}
|
||
const cur = this.players.get(this.draftTurn());
|
||
if (cur && cur.botControlled) this.scheduleBandBot();
|
||
this.markPublic();
|
||
}
|
||
|
||
/** An AI seat's (or an AI 托管 seat's) turn: it picks at once (user playtest #4 item 4 — nobody waits on the AI). */
|
||
scheduleBandBot() {
|
||
const token = this._turnToken;
|
||
this.later(0, () => {
|
||
if (this.phase !== PHASE.BAND_DRAFT || token !== this._turnToken) return;
|
||
const ps = this.players.get(this.draftTurn());
|
||
if (!ps || !ps.botControlled) return;
|
||
// a strategy a teammate already took is not selectable (队友已选): the bot re-draws, else the first free one
|
||
let id = botPickBand(this, ps);
|
||
for (let k = 0; k < 8 && this.bandTaken(id, ps.playerId); k++) id = botPickBand(this, ps);
|
||
if (this.bandTaken(id, ps.playerId)) id = this.gd.bandIds().find((b) => !this.bandTaken(b, ps.playerId)) || id;
|
||
this._applyBand(ps, id, { dedupe: true });
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Whether `bandId` was already picked by another player of this draft. Research 09 §5 / DESIGN §14 corrections:
|
||
* the strategy draft marks a teammate's pick as 队友已选 and it cannot be chosen again (co-op). The automatic
|
||
* assignments — a turn that runs out and a departing seat — obey the same rule: see timeoutBand / defaultBand.
|
||
*/
|
||
bandTaken(bandId, playerId) {
|
||
const picks = this.draft?.picks || {};
|
||
for (const [pid, id] of Object.entries(picks)) if (pid !== playerId && id === bandId) return true;
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* The strategy an automatic assignment gives `playerId` (a departing seat; a timed-out turn without a usable
|
||
* highlight, timeoutBand): the official default 「华法琳」 (bandDraft.timeoutBandId) while no teammate holds it, else the
|
||
* first strategy of the mode (sortId order, gd.bandIds) that nobody else picked — never a duplicate (队友已选; the
|
||
* client shows the same choice: public/js/screens/bandDraft.js timeoutBand). Solo drafts have no teammates, so it is
|
||
* always the default.
|
||
* @param {string} playerId
|
||
*/
|
||
defaultBand(playerId) {
|
||
const def = this.gd.bandDraft.timeoutBandId;
|
||
if (!this.bandTaken(def, playerId)) return def;
|
||
return this.gd.bandIds().find((b) => !this.bandTaken(b, playerId)) || def;
|
||
}
|
||
|
||
/**
|
||
* What a turn that runs out assigns (user playtest #4 item 4): the strategy the player has highlighted in the draft
|
||
* screen (g.bandFocus — the detail pane's band, the one 确认选择 would take) while it is allowed and no teammate holds
|
||
* it, else defaultBand.
|
||
* @param {string} playerId
|
||
*/
|
||
timeoutBand(playerId) {
|
||
const f = this.draft && this.draft.focus instanceof Map ? this.draft.focus.get(playerId) : null;
|
||
if (typeof f === 'string' && this.gd.bandAllowed(f) && !this.bandTaken(f, playerId)) return f;
|
||
return this.defaultBand(playerId);
|
||
}
|
||
|
||
/**
|
||
* g.bandFocus { bandId? }: the strategy the player highlights in the draft screen (any time before its pick; also
|
||
* while waiting for its turn). A missing / null bandId clears it. Only a timed-out turn reads it (timeoutBand).
|
||
*/
|
||
bandFocus(ps, bandId) {
|
||
if (this.phase !== PHASE.BAND_DRAFT || !this.draft) return fail(ERR.WRONG_PHASE);
|
||
const d = this.draft;
|
||
if (d.picks[ps.playerId]) return fail(ERR.ALREADY);
|
||
if (!(d.focus instanceof Map)) d.focus = new Map();
|
||
if (bandId == null) { d.focus.delete(ps.playerId); return OK; }
|
||
if (typeof bandId !== 'string' || !this.gd.bandAllowed(bandId)) return fail(ERR.BAD_TARGET);
|
||
d.focus.set(ps.playerId, bandId);
|
||
return OK;
|
||
}
|
||
|
||
pickBand(ps, bandId) {
|
||
if (this.phase !== PHASE.BAND_DRAFT || !this.draft) return fail(ERR.WRONG_PHASE);
|
||
if (this.draft.picks[ps.playerId]) return fail(ERR.ALREADY);
|
||
if (this.draftTurn() !== ps.playerId) return fail(ERR.NOT_YOUR_TURN);
|
||
if (typeof bandId !== 'string' || !this.gd.bandAllowed(bandId)) return fail(ERR.BAD_TARGET);
|
||
if (this.bandTaken(bandId, ps.playerId)) return fail(ERR.BAD_TARGET, '队友已选');
|
||
this._applyBand(ps, bandId);
|
||
return OK;
|
||
}
|
||
|
||
_applyBand(ps, bandId, { dedupe = false } = {}) {
|
||
const d = this.draft;
|
||
if (!d || !ps || d.picks[ps.playerId]) return;
|
||
let id = this.gd.bandAllowed(bandId) ? bandId : this.defaultBand(ps.playerId);
|
||
if (dedupe && this.bandTaken(id, ps.playerId)) id = this.gd.bandIds().find((b) => !this.bandTaken(b, ps.playerId)) || id;
|
||
d.picks[ps.playerId] = id;
|
||
ps.bandId = id;
|
||
ps.lp = this.gd.startLp(id);
|
||
this.markPrivate(ps);
|
||
this.markPublic();
|
||
this.startDraftTurn();
|
||
}
|
||
|
||
skipBand(ps) {
|
||
if (this.phase !== PHASE.BAND_DRAFT || !this.draft) return fail(ERR.WRONG_PHASE);
|
||
const d = this.draft;
|
||
if (this.isSolo) return fail(ERR.WRONG_PHASE, 'no skip in solo');
|
||
if (d.picks[ps.playerId]) return fail(ERR.ALREADY);
|
||
if (this.draftTurn() !== ps.playerId) return fail(ERR.NOT_YOUR_TURN);
|
||
if (!(d.skipsLeft[ps.playerId] > 0)) return fail(ERR.ALREADY, 'no skip left');
|
||
if (d.order.length - d.idx <= 1) return fail(ERR.BAD_TARGET, 'nobody to pass to');
|
||
d.skipsLeft[ps.playerId]--;
|
||
d.order.splice(d.idx, 1);
|
||
d.order.push(ps.playerId);
|
||
this.startDraftTurn();
|
||
return OK;
|
||
}
|
||
|
||
finishBandDraft(timeout) {
|
||
if (this.phase !== PHASE.BAND_DRAFT) return;
|
||
this.cancel(this._turnTimer);
|
||
this._turnTimer = null;
|
||
for (const ps of this.order) {
|
||
if (!this.draft.picks[ps.playerId]) {
|
||
// one after another in seat order, so each default sees the ones assigned before it (no duplicates)
|
||
const id = this.defaultBand(ps.playerId);
|
||
this.draft.picks[ps.playerId] = id;
|
||
ps.bandId = id;
|
||
ps.lp = this.gd.startLp(id);
|
||
}
|
||
this.markPrivate(ps);
|
||
}
|
||
void timeout;
|
||
this.enterBattleCheck();
|
||
}
|
||
|
||
// ===================================================================================================
|
||
// BATTLE_CHECK → rounds
|
||
|
||
enterBattleCheck() {
|
||
this.phase = PHASE.BATTLE_CHECK;
|
||
this.setDeadline(this.gd.timer('battleCheck'), () => this.startRound(1), { silent: this.soloUntimed });
|
||
this.markPublic();
|
||
}
|
||
|
||
startRound(r) {
|
||
this.phase = PHASE.ROUND_START;
|
||
this.round = r;
|
||
this.fields = [];
|
||
this.watchers.clear();
|
||
this.unitePlan = null;
|
||
this.sp = null;
|
||
this.wave = null;
|
||
this.bossWaves = null;
|
||
const alive = this.alivePlayers();
|
||
// the round's enemies (shared composition, generated now so the prep preview is exact). Planned BEFORE the players'
|
||
// round start: its recompute() checks the board on the field the player deploys on this round (deployFieldOf reads
|
||
// the boss pairing), so R14 → R15 never re-checks a boss-field board against the normal field (user playtest #5
|
||
// item 7). rngWaves is used only here, so the order leaves every random stream unchanged.
|
||
const isBoss = r === this.gd.bossRound || r === this.gd.hiddenRound;
|
||
if (isBoss) {
|
||
this._planBossWaves();
|
||
} else {
|
||
this.wave = buildNormalWave(this.gd, this.rngWaves, this.factions, r);
|
||
}
|
||
for (const ps of alive) ps.startRound(r);
|
||
for (const ps of alive) this.dispatch(ps, 'onRoundStart', { round: r });
|
||
for (const ps of alive) ps.recompute();
|
||
this.setDeadline(DELAYS.ROUND_START / 1000, () => this.afterRoundStart(), { silent: this.soloUntimed });
|
||
this.markPublic();
|
||
}
|
||
|
||
/** The boss round's fields (seat pairs of the alive players) and their templates, generated for the prep preview. */
|
||
_planBossWaves() {
|
||
const r = this.round;
|
||
const bossId = r === this.gd.hiddenRound && r !== this.gd.bossRound ? this.hiddenBossId : this.bossId;
|
||
this.bossWaves = pairPlayers(this.alivePlayers()).map((g) => ({
|
||
players: g.map((p) => p.playerId),
|
||
wave: buildBossWave(this.gd, this.rngWaves, this.factions, r, { bossId, solo: this.isSolo || g.length === 1 }),
|
||
}));
|
||
}
|
||
|
||
afterRoundStart() {
|
||
if (this.gd.spRounds().includes(this.round)) this.enterSpDraft();
|
||
else this.enterPrep();
|
||
}
|
||
|
||
// ===================================================================================================
|
||
// SP_DRAFT (机变)
|
||
|
||
enterSpDraft() {
|
||
const draft = generateDraft(this.gd, this.rngDraft, this.round, { stageId: this.stageId, bondAvailable: (bondId) => this.bondLive(bondId) });
|
||
const alive = this.alivePlayers();
|
||
if (!draft || !alive.length) { this.enterPrep(); return; }
|
||
this.phase = PHASE.SP_DRAFT;
|
||
const order = alive.map((p) => p.playerId);
|
||
if (!this.isSolo) this.rngDraft.shuffle(order);
|
||
// untimed: solo and any single-human match (soloUntimed); the co-op order / 6 cards stay
|
||
const untimed = this.soloUntimed;
|
||
this.sp = { ...draft, order, idx: 0, picks: {}, taken: {}, untimed, turnDeadline: 0 };
|
||
this.setDeadline(0);
|
||
this.startSpTurn();
|
||
this.markPublic();
|
||
}
|
||
|
||
spTurn() {
|
||
const s = this.sp;
|
||
if (!s) return null;
|
||
return s.order[s.idx] ?? null;
|
||
}
|
||
|
||
startSpTurn() {
|
||
const s = this.sp;
|
||
this.cancel(this._turnTimer);
|
||
this._turnTimer = null;
|
||
while (s.idx < s.order.length) {
|
||
const ps = this.players.get(s.order[s.idx]);
|
||
if (ps && ps.alive && s.picks[ps.playerId] == null) break;
|
||
s.idx++;
|
||
}
|
||
const available = s.cards.map((c) => c.idx).filter((i) => s.taken[i] == null);
|
||
if (s.idx >= s.order.length || !available.length) { this.setDeadline(0); this.later(0, () => this.finishSpDraft()); return; }
|
||
const token = ++this._turnToken;
|
||
if (!s.untimed) {
|
||
const first = s.idx === 0;
|
||
const secs = first ? this.gd.timer('spFirst') : this.gd.timer('spTurn');
|
||
this.setDeadline(secs, () => {
|
||
if (this.phase !== PHASE.SP_DRAFT || token !== this._turnToken) return;
|
||
const ps = this.players.get(this.spTurn());
|
||
if (!ps) return;
|
||
const avail = s.cards.map((c) => c.idx).filter((i) => s.taken[i] == null);
|
||
if (!avail.length) { this.finishSpDraft(); return; }
|
||
this._applyCard(ps, avail[Math.floor(this.rngDraft() * avail.length)]);
|
||
});
|
||
s.turnDeadline = this.deadline;
|
||
} else {
|
||
this.setDeadline(0);
|
||
}
|
||
const cur = this.players.get(this.spTurn());
|
||
if (cur && cur.botControlled) this.scheduleSpBot();
|
||
this.markPublic();
|
||
}
|
||
|
||
scheduleSpBot() {
|
||
const token = this._turnToken;
|
||
this.later(this.scaled(DELAYS.BOT_ACTION), () => {
|
||
if (this.phase !== PHASE.SP_DRAFT || token !== this._turnToken || !this.sp) return;
|
||
const ps = this.players.get(this.spTurn());
|
||
if (!ps || !ps.botControlled) return;
|
||
const avail = this.sp.cards.map((c) => c.idx).filter((i) => this.sp.taken[i] == null);
|
||
if (!avail.length) return;
|
||
this._applyCard(ps, botPickCard(this, ps, this.sp.cards, avail));
|
||
});
|
||
}
|
||
|
||
pickCard(ps, idx) {
|
||
if (this.phase !== PHASE.SP_DRAFT || !this.sp) return fail(ERR.WRONG_PHASE);
|
||
if (!ps.alive) return fail(ERR.ELIMINATED);
|
||
if (this.sp.picks[ps.playerId] != null) return fail(ERR.ALREADY);
|
||
if (this.spTurn() !== ps.playerId) return fail(ERR.NOT_YOUR_TURN);
|
||
if (!Number.isInteger(idx) || idx < 0 || idx >= this.sp.cards.length) return fail(ERR.BAD_TARGET);
|
||
if (this.sp.taken[idx] != null) return fail(ERR.SOLD_OUT);
|
||
this._applyCard(ps, idx);
|
||
return OK;
|
||
}
|
||
|
||
_applyCard(ps, idx) {
|
||
const s = this.sp;
|
||
if (!s || s.picks[ps.playerId] != null || s.taken[idx] != null) return;
|
||
const card = s.cards[idx];
|
||
if (!card) return;
|
||
s.picks[ps.playerId] = idx;
|
||
s.taken[idx] = ps.playerId;
|
||
try { applyCard(this, ps, card); } catch (e) { this.reportError(`applyCard ${card.id}`, e); }
|
||
this.markPrivate(ps);
|
||
this.markPublic();
|
||
this.startSpTurn();
|
||
}
|
||
|
||
finishSpDraft() {
|
||
if (this.phase !== PHASE.SP_DRAFT) return;
|
||
this.cancel(this._turnTimer);
|
||
this._turnTimer = null;
|
||
this.enterPrep();
|
||
}
|
||
|
||
addBounty(ps, card) {
|
||
if (!ps || !card || !this.gd.enemy(card.enemyKey)) return null;
|
||
// a multi-round card lasts MULTI_ROUND_BOUNTY_BATTLES battles (choices.js; the user's call after playtest #6)
|
||
const rounds = bountyBattles(card);
|
||
const b = {
|
||
id: `bounty:${this.nextUid()}`,
|
||
card: { effectId: card.effectId ?? card.id ?? null, name: card.name ?? '悬赏', desc: card.desc ?? '', tier: card.tier ?? 1, coin: Math.max(0, Math.trunc(Number(card.coin) || 0)), payout: card.payout === 'perfect' ? 'perfect' : 'kill', rounds, multiRound: isMultiRoundBounty(card), enemyKey: card.enemyKey, count: Math.max(1, Math.min(20, Number.isInteger(card.count) ? card.count : 1)) },
|
||
roundsLeft: rounds,
|
||
};
|
||
ps.bounties.push(b);
|
||
ps.dirty();
|
||
return b.id;
|
||
}
|
||
|
||
/** Random item id: a choices.json server pool ({ pool }), a tier, or ≤ maxTier shop-eligible items. */
|
||
rollItemId({ pool = null, tier = null, maxTier = 6, shopLevel = 6 } = {}) {
|
||
const pools = this.gd.choices.pools && typeof this.gd.choices.pools === 'object' ? this.gd.choices.pools : {};
|
||
const p = typeof pool === 'string' && Object.hasOwn(pools, pool) ? pools[pool] : null;
|
||
const rng = this.rngMeta;
|
||
const tierList = (lo, hi) => { const out = []; for (let t = lo; t <= hi; t++) for (const id of this.gd.shopItemsByTier[t] || []) out.push(id); return out; };
|
||
if (p && p.kind === 'equip') {
|
||
if (Array.isArray(p.weighted) && p.weighted.length) {
|
||
const pairs = p.weighted.filter((x) => Array.isArray(x) && this.gd.item(x[0]));
|
||
let total = 0;
|
||
for (const [, w] of pairs) total += Math.max(0, Number(w) || 0);
|
||
let r = rng() * total;
|
||
for (const [id, w] of pairs) { r -= Math.max(0, Number(w) || 0); if (r < 0) return id; }
|
||
return pairs.length ? pairs[pairs.length - 1][0] : null;
|
||
}
|
||
if (Array.isArray(p.items) && p.items.length) {
|
||
const items = p.items.filter((id) => this.gd.item(id));
|
||
return items.length ? items[Math.floor(rng() * items.length)] : null;
|
||
}
|
||
let list;
|
||
if (Array.isArray(p.tiers) && p.tiers.length) list = p.tiers.flatMap((t) => this.gd.shopItemsByTier[t] || []);
|
||
else list = tierList(1, p.maxTier === 'shopLevel' ? Math.max(1, Math.min(6, shopLevel)) : 6);
|
||
return list.length ? list[Math.floor(rng() * list.length)] : null;
|
||
}
|
||
const list = Number.isInteger(tier) ? tierList(tier, tier) : tierList(1, Math.max(1, Math.min(6, maxTier)));
|
||
return list.length ? list[Math.floor(rng() * list.length)] : null;
|
||
}
|
||
|
||
/**
|
||
* Roll a choices.json pool (ctx.rollPool). Equip pools → rollItemId. Chess pools: an `items` (uniform) or `weighted`
|
||
* list — only chess with a free pool copy (or outside the pool) qualify — else a copy-weighted draw from the shared
|
||
* pool filtered by `tier` / `minTier` / `maxTier` (number or 'shopLevel') / `bond`; `golden: true` yields the elite id.
|
||
* @returns {{ kind: 'item'|'chess', id: string, golden?: boolean } | null}
|
||
*/
|
||
rollPool(poolId, { shopLevel = 6 } = {}) {
|
||
const pools = this.gd.choices.pools && typeof this.gd.choices.pools === 'object' ? this.gd.choices.pools : {};
|
||
const p = typeof poolId === 'string' && Object.hasOwn(pools, poolId) ? pools[poolId] : null;
|
||
if (!p || typeof p !== 'object') return null;
|
||
const lvl = Math.max(1, Math.min(6, Number.isInteger(shopLevel) ? shopLevel : 6));
|
||
if (p.kind === 'equip') {
|
||
const id = this.rollItemId({ pool: poolId, shopLevel: lvl });
|
||
return id ? { kind: 'item', id } : null;
|
||
}
|
||
if (p.kind !== 'chess') return null;
|
||
const rng = this.rngMeta;
|
||
const free = (id) => {
|
||
if (typeof id !== 'string' || !this.gd.chess(id)) return false;
|
||
const base = this.gd.baseIdOf(id);
|
||
return !this.pool.has(base) || this.pool.left(base) > 0;
|
||
};
|
||
let id = null;
|
||
if (Array.isArray(p.weighted) && p.weighted.length) {
|
||
const pairs = p.weighted.filter((x) => Array.isArray(x) && free(x[0]));
|
||
id = pairs.length ? weightedPick(rng, pairs) : null;
|
||
} else if (Array.isArray(p.items) && p.items.length) {
|
||
const list = p.items.filter(free);
|
||
id = list.length ? list[Math.floor(rng() * list.length)] : null;
|
||
} else {
|
||
const maxTier = p.maxTier === 'shopLevel' ? lvl : Number.isInteger(p.maxTier) ? p.maxTier : 6;
|
||
const minTier = Number.isInteger(p.minTier) ? p.minTier : 1;
|
||
const bond = typeof p.bond === 'string' ? p.bond : null;
|
||
id = this.pool.roll(rng, {
|
||
tier: Number.isInteger(p.tier) ? p.tier : null,
|
||
maxTier,
|
||
filter: (cid, e) => e.tier >= minTier && (!bond || (Array.isArray(this.gd.chess(cid)?.bonds) && this.gd.chess(cid).bonds.includes(bond))),
|
||
});
|
||
}
|
||
if (!id) return null;
|
||
const golden = !!p.golden;
|
||
return { kind: 'chess', id: golden ? this.gd.goldenIdOf(id) || id : id, golden };
|
||
}
|
||
|
||
// ===================================================================================================
|
||
// PREP
|
||
|
||
enterPrep() {
|
||
this.phase = PHASE.PREP;
|
||
this.sp = null;
|
||
const alive = this.alivePlayers();
|
||
for (const ps of alive) {
|
||
ps.ready = false;
|
||
this.dispatch(ps, 'onPrepStart', { round: this.round });
|
||
ps.recompute();
|
||
}
|
||
// solo / single-human matches: untimed (soloUntimed); co-op: the round's prepTime
|
||
const secs = this.soloUntimed ? null : this.gd.prepTime(this.round);
|
||
this.setDeadline(secs, () => this.prepDeadline());
|
||
let i = 0;
|
||
for (const ps of alive) if (ps.botControlled) this.scheduleBotPrep(ps, i++);
|
||
this.markPublic();
|
||
this.maybeEndPrep();
|
||
}
|
||
|
||
/**
|
||
* The bot plays a prep in three stages, every one in slices of ≤ botSliceMs wall-clock ms (one scheduler callback
|
||
* each, so other rooms' battles and every player's requests keep flowing): economy + the default layout
|
||
* (bot.js botPrepBeginSteps — shop decisions and layout planning, 50–120 ms late in a 4-bot match), the layout
|
||
* rehearsal (whole simulated battles, 0.2–1 s of CPU per bot late in a match), then botPrepEndSteps (the rehearsed
|
||
* layout, temp, Ready). The step generators run the same actions in the same order as the one-shot routine (same
|
||
* rng draws, same decisions); in virtual time (botSliceMs unbounded) each stage runs at once. The prep ending first
|
||
* (deadline) or a newer schedule for the seat drops the job (a step never leaves a transient board behind).
|
||
*/
|
||
scheduleBotPrep(ps, i = 0) {
|
||
const round = this.round;
|
||
const token = (ps._botPrepToken = (ps._botPrepToken || 0) + 1);
|
||
const valid = () => this.phase === PHASE.PREP && this.round === round && ps.alive && !ps.ready && ps.botControlled && ps._botPrepToken === token;
|
||
const bounded = Number.isFinite(this.botSliceMs);
|
||
const now = () => (typeof performance !== 'undefined' ? performance.now() : Date.now());
|
||
/** Step a generator until done or the slice budget is used; `then(value)` once it is done (null on an error). */
|
||
const drive = (gen, label, then) => {
|
||
const t0 = now();
|
||
let r = null;
|
||
try {
|
||
do r = gen.next(); while (!r.done && !(bounded && now() - t0 >= this.botSliceMs));
|
||
} catch (e) {
|
||
this.reportError(`bot ${ps.playerId}${label}`, e);
|
||
then(null);
|
||
return;
|
||
}
|
||
if (r.done) { then(r.value); return; }
|
||
this.later(0, () => { if (valid()) drive(gen, label, then); });
|
||
};
|
||
const ready = () => {
|
||
if (!ps.ready) {
|
||
ps.resolveTemp();
|
||
ps.setReady(true);
|
||
}
|
||
};
|
||
const end = (job) => {
|
||
let gen = null;
|
||
try { gen = botPrepEndSteps(this, ps, job); } catch (e) { this.reportError(`bot ${ps.playerId}`, e); }
|
||
if (!gen) { ready(); return; }
|
||
drive(gen, '', ready);
|
||
};
|
||
this.later(this.scaled(DELAYS.BOT_ACTION + i * DELAYS.BOT_STAGGER), () => {
|
||
if (!valid()) return;
|
||
drive(botPrepBeginSteps(this, ps), '', (job) => {
|
||
if (!job) { end(null); return; }
|
||
const slice = () => {
|
||
if (!valid()) return;
|
||
let done = true;
|
||
try { done = job.run(this.botSliceMs); } catch (e) { this.reportError(`bot ${ps.playerId} rehearsal`, e); }
|
||
if (done) { if (bounded) this.later(0, () => { if (valid()) end(job); }); else end(job); }
|
||
else this.later(0, slice);
|
||
};
|
||
// bounded slices start in a callback of their own (the economy + default layout above already used this one)
|
||
if (bounded) this.later(0, slice);
|
||
else slice();
|
||
});
|
||
});
|
||
}
|
||
|
||
onReadyChanged(ps) {
|
||
this.markPublic();
|
||
void ps;
|
||
this.maybeEndPrep();
|
||
}
|
||
|
||
maybeEndPrep() {
|
||
if (this.phase !== PHASE.PREP || this._prepEndQueued) return;
|
||
const allReady = () => { const alive = this.alivePlayers(); return alive.length > 0 && alive.every((p) => p.ready); };
|
||
if (!allReady()) return;
|
||
const round = this.round;
|
||
this._prepEndQueued = true;
|
||
// the prep deadline stays armed until the phase really ends: a player may un-ready before this runs
|
||
this.later(0, () => {
|
||
this._prepEndQueued = false;
|
||
if (this.phase === PHASE.PREP && this.round === round && allReady()) this.endPrep();
|
||
});
|
||
}
|
||
|
||
prepDeadline() {
|
||
if (this.phase !== PHASE.PREP) return;
|
||
for (const ps of this.alivePlayers()) {
|
||
if (ps.ready) continue;
|
||
ps.resolveTemp();
|
||
ps.ready = true;
|
||
ps.dirty();
|
||
}
|
||
this.endPrep();
|
||
}
|
||
|
||
endPrep() {
|
||
if (this.phase !== PHASE.PREP) return;
|
||
this.setDeadline(0);
|
||
const alive = this.alivePlayers();
|
||
for (const ps of alive) this.dispatch(ps, 'onPrepEnd', { round: this.round });
|
||
for (const ps of alive) ps.endPrep();
|
||
const r = this.round;
|
||
if (r === this.gd.bossRound) {
|
||
this.hiddenLayerSum = alive.reduce((s, p) => s + p.activatedLayers(), 0);
|
||
this.startFinalAssault(false);
|
||
} else if (r === this.gd.hiddenRound) {
|
||
this.startFinalAssault(true);
|
||
} else {
|
||
this.startCombat();
|
||
}
|
||
}
|
||
|
||
// ===================================================================================================
|
||
// battles
|
||
|
||
/** Construct a battle; a constructor failure yields a finished stand-in (clean result) and is logged. */
|
||
newBattle(opts) {
|
||
const full = { data: this.ds, content: this.battleContent, logger: this.log, ...opts };
|
||
try {
|
||
return new this.BattleClass(full);
|
||
} catch (e) {
|
||
this.reportError(`battle ${opts.fieldId} construct`, e);
|
||
return new DeadBattle(full, 'forced');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Spawn list of a field after the onBattleStart handlers (which may edit `ev.spawns`): malformed entries are dropped
|
||
* and a kill bounty is copied into `mods.bountyCoins`, so a leaked bounty enemy keeps paying its killer in 联防.
|
||
*/
|
||
_sanitizeSpawns(list, ownerPlayerId = null) {
|
||
const out = [];
|
||
for (const s of Array.isArray(list) ? list : []) {
|
||
if (!s || typeof s !== 'object' || typeof s.enemyKey !== 'string' || !this.gd.enemy(s.enemyKey)) continue;
|
||
const spec = { ...s, mods: s.mods && typeof s.mods === 'object' ? { ...s.mods } : {} };
|
||
if (ownerPlayerId && spec.ownerPlayerId == null) spec.ownerPlayerId = ownerPlayerId;
|
||
const coins = spec.bounty && typeof spec.bounty === 'object' ? Math.trunc(Number(spec.bounty.coins) || 0) : 0;
|
||
if (coins > 0 && spec.mods.bountyId == null && spec.mods.bountyCoins == null) spec.mods.bountyCoins = coins;
|
||
out.push(spec);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
_normalBattle(ps) { return this.newBattle(this._normalOpts(ps)); }
|
||
|
||
/** Battle options of a player's normal field (after the onBattleStart handlers). */
|
||
_normalOpts(ps) {
|
||
const wave = this.wave;
|
||
const input = ps.battleInput({ side: 'L', colOffset: 0 });
|
||
// the client's list: bounty units inserted among their host action's own units, which are re-timed around them
|
||
const spawns = withBounties(this.gd, this.round, wave, ps.bounties, ps.playerId).map((s) => ({ ...s, ownerPlayerId: ps.playerId }));
|
||
const ev = { input, kind: 'normal', round: this.round, spawns };
|
||
this.dispatch(ps, 'onBattleStart', ev);
|
||
return {
|
||
seed: deriveSeed(this.seed, `n:${this.round}:${ps.seat}`),
|
||
kind: 'normal',
|
||
modeId: this.modeId,
|
||
round: this.round,
|
||
stageId: this.stageId,
|
||
rect: { ...GEO.NORMAL_RECT },
|
||
timeLimit: wave.timeLimit,
|
||
players: [ev.input && typeof ev.input === 'object' ? ev.input : input],
|
||
spawns: this._sanitizeSpawns(Array.isArray(ev.spawns) ? ev.spawns : spawns, ps.playerId),
|
||
routes: wave.routes,
|
||
sharedBoss: null,
|
||
flags: { layerGainsEnabled: true, ...this.gd.dp },
|
||
fieldId: `n:${ps.playerId}`,
|
||
enemyOverrides: wave.overrides,
|
||
waveId: wave.templateId,
|
||
};
|
||
}
|
||
|
||
_defaultWatch() {
|
||
this.watchers.clear();
|
||
for (const ps of this.order) {
|
||
if (ps.isBot || ps.left) continue;
|
||
const own = this.fields.find((f) => f.players.includes(ps.playerId));
|
||
const f = own || this.fields[0];
|
||
if (!f) continue;
|
||
this.watchers.set(ps.playerId, f.fieldId);
|
||
if (ps.connected) this._sendField(ps.playerId, f.fieldId);
|
||
}
|
||
}
|
||
|
||
startCombat() {
|
||
if (this.clientCombat) { this._startCombatClient(); return; }
|
||
this.phase = PHASE.COMBAT;
|
||
const alive = this.alivePlayers();
|
||
this.lastResults = new Map();
|
||
this.fields = alive.map((ps) => ({ fieldId: `n:${ps.playerId}`, kind: 'normal', players: [ps.playerId], battle: this._normalBattle(ps), live: true }));
|
||
const limit = this.wave ? this.wave.timeLimit : 60;
|
||
this.deadline = this.sched.instant ? 0 : this.sched.now() + Math.round((limit / this.gameSpeed) * 1000);
|
||
this._defaultWatch();
|
||
this.markPublic();
|
||
this.runner = new FieldRunner(this, this.fields, { onDone: (runner) => this.combatDone(runner) });
|
||
this.runner.start();
|
||
}
|
||
|
||
/** Aggregate a finished field's content/engine errors (battle.errors: unique records) for diagnostics. */
|
||
_collectSimErrors(f, res) {
|
||
this.simErrors += Number(res && res.errors) || 0;
|
||
const list = f && f.battle && Array.isArray(f.battle.errors) ? f.battle.errors : [];
|
||
for (const e of list) {
|
||
if (!e || typeof e !== 'object') continue;
|
||
const key = `${e.label}|${e.who || ''}|${e.message}`;
|
||
let rec = this.simErrorLog.get(key);
|
||
if (!rec) {
|
||
if (this.simErrorLog.size >= 500) continue;
|
||
rec = { label: String(e.label), who: String(e.who || ''), message: String(e.message), stack: e.stack ? String(e.stack) : null, battles: 0, count: 0 };
|
||
this.simErrorLog.set(key, rec);
|
||
}
|
||
rec.battles++;
|
||
rec.count++;
|
||
}
|
||
}
|
||
|
||
combatDone(runner) { this._finishCombat((f) => runner.resultOf(f)); }
|
||
|
||
/** Every normal field has its result: record them, then 联防 or SETTLE. */
|
||
_finishCombat(resultOf) {
|
||
if (this.phase !== PHASE.COMBAT) return;
|
||
this._stopClientCombat();
|
||
for (const f of this.fields) {
|
||
const res = resultOf(f);
|
||
this._collectSimErrors(f, res);
|
||
for (const pid of f.players) {
|
||
const pp = res.perPlayer && res.perPlayer[pid];
|
||
this.lastResults.set(pid, pp || { killed: 0, total: 0, leaked: [], perfect: true, layerGains: {}, coins: 0, damageDealt: 0, unitsEnd: [], unitStats: [] });
|
||
// the views show the layers the battle reached until settle() makes them persistent (DESIGN §20.15)
|
||
const ps = this.players.get(pid);
|
||
const gains = pp && pp.layerGains && typeof pp.layerGains === 'object' ? pp.layerGains : null;
|
||
if (ps && gains && Object.keys(gains).length) { ps.pendingLayerGains = { ...gains }; ps.dirty(); }
|
||
}
|
||
}
|
||
for (const f of this.fields) f.live = false;
|
||
this.deadline = 0;
|
||
this.markPublic();
|
||
this.later(this.scaled(DELAYS.COMBAT_END), () => this._afterCombat());
|
||
}
|
||
|
||
/**
|
||
* After the COMBAT_END pause: 联防 or SETTLE. The plan is made now, from the players still in — a player who quit
|
||
* during the last field or the pause (中途退出 = eliminated, board cleared) is neither a helper nor a leaker whose
|
||
* enemies re-enter.
|
||
*/
|
||
_afterCombat() {
|
||
if (this.phase !== PHASE.COMBAT) return;
|
||
const plan = planUnite(this, this.lastResults);
|
||
if (plan) this.startUnite(plan);
|
||
else this.settle(null, null);
|
||
}
|
||
|
||
startUnite(plan) {
|
||
if (this.clientCombat) { this._startUniteClient(plan); return; }
|
||
this.phase = PHASE.UNITE;
|
||
this.unitePlan = plan;
|
||
const limit = this.wave ? this.wave.timeLimit : 60;
|
||
const battle = this.newBattle(this._uniteOpts(plan, limit));
|
||
this.fields = [{ fieldId: 'u', kind: 'unite', players: plan.helpers.map((p) => p.playerId), battle, live: true }];
|
||
this.deadline = this.sched.instant ? 0 : this.sched.now() + Math.round((limit / this.gameSpeed) * 1000);
|
||
this._defaultWatch();
|
||
this.markPublic();
|
||
this.tickerText(`联防阶段:${plan.helpers.map((p) => p.name).join('、')} 迎战突破防线的敌人`);
|
||
this._uniteLeftKey = null;
|
||
this.runner = new FieldRunner(this, this.fields, {
|
||
onTick: (runner) => this._uniteTick(runner),
|
||
onDone: (runner) => {
|
||
if (this.phase !== PHASE.UNITE) return;
|
||
const res = runner.resultOf(this.fields[0]);
|
||
this._collectSimErrors(this.fields[0], res);
|
||
this.fields[0].live = false;
|
||
this.deadline = 0;
|
||
this.markPublic();
|
||
this.later(this.scaled(DELAYS.COMBAT_END), () => this.settle(plan, res));
|
||
},
|
||
});
|
||
this.runner.start();
|
||
}
|
||
|
||
/** Battle options of the 联防 field (helpers' carried end state, the leakers' enemies). */
|
||
_uniteOpts(plan, limit) {
|
||
const { wave, players } = uniteBattleOpts(this, plan, limit);
|
||
return {
|
||
seed: deriveSeed(this.seed, `u:${this.round}`),
|
||
kind: 'unite',
|
||
modeId: this.modeId,
|
||
round: this.round,
|
||
stageId: this.stageId,
|
||
rect: { ...GEO.UNITE_RECT },
|
||
timeLimit: limit,
|
||
players,
|
||
spawns: this._sanitizeSpawns(wave.spawns),
|
||
routes: wave.routes,
|
||
sharedBoss: null,
|
||
flags: { layerGainsEnabled: false, ...this.gd.dp },
|
||
fieldId: 'u',
|
||
// leaked enemies re-enter with the stats they had: the round template's stat overrides apply again
|
||
enemyOverrides: this.wave && this.wave.overrides ? this.wave.overrides : {},
|
||
waveId: wave.templateId,
|
||
};
|
||
}
|
||
|
||
// ===================================================================================================
|
||
// client-side combat (DESIGN §14)
|
||
|
||
/** Stop every client-combat timer and the headless pacer (phase end, finish, dispose). */
|
||
_stopClientCombat() {
|
||
if (this._progressTimer) { this.cancel(this._progressTimer); this._progressTimer = null; }
|
||
if (this._bossClock) { this.cancel(this._bossClock); this._bossClock = null; }
|
||
this._bossClockOn = false;
|
||
// a pending throttled boss refresh: the merged team LP is written back now (m.result / settlement read it)
|
||
if (this._bossPubTimer) { this.cancel(this._bossPubTimer); this._bossPubTimer = null; this._syncTeamLp(); this.markPublic(); }
|
||
this._clearPause();
|
||
if (this._poolTimer) { this.cancel(this._poolTimer); this._poolTimer = null; }
|
||
if (this.pacer) { try { this.pacer.stop(); } catch { /* ignore */ } this.pacer = null; }
|
||
for (const f of this.fields) this._clearFieldTimers(f);
|
||
}
|
||
|
||
_clearFieldTimers(f) {
|
||
if (!f || !f.cc) return;
|
||
for (const k of ['deadlineTimer', 'doneTimer', 'waitTimer', 'sliceTimer']) if (f[k]) { this.cancel(f[k]); f[k] = null; }
|
||
f.job = null;
|
||
}
|
||
|
||
/** A client-combat field record: the JSON BattleSpec of its Battle options plus the authority / result state. */
|
||
_ccField({ fieldId, kind, players, opts, boss = null }) {
|
||
const seq = `${this.battlePrefix}.${this.round}.${++this._battleSeq}`;
|
||
// protocol ids are ≤ 64 chars (shared/protocol.js isId): the field id is informational, the sequence is unique
|
||
const battleId = seq.length + 1 + String(fieldId).length <= 64 ? `${seq}.${fieldId}` : seq;
|
||
const spec = buildBattleSpec({ ...opts, battleId, fieldId, kind, content: this.battleContent, boss });
|
||
let total = 0;
|
||
for (const x of spec.spawns) if (x && x.tag !== 'boss' && x.tag !== 'part') total += Math.max(1, Math.floor(Number(x.count) || 1));
|
||
return {
|
||
cc: true, fieldId, kind, players: players.slice(), battleId, spec, battle: null, live: true, done: false,
|
||
mode: null, authority: null, startAt: this.sched.now(), result: null, resultSource: null, timeline: null, endGt: null,
|
||
progress: { gt: 0, killed: 0, total, leaks: 0, done: false }, lastProgressAt: this.sched.now(),
|
||
bossAcked: 0, bossBy: {}, lpAcked: 0, lpCum: 0, deadlineTimer: null, doneTimer: null, waitTimer: null,
|
||
// boss fields: the latest client reports (re-credited as the plausibility budget grows), the server run's
|
||
// CreditPool, humans demoted for an implausible result (never the authority of this field again), a 'cleared'
|
||
// b.result waiting for the budget to credit the pool it emptied (`heldResult`, _onResult)
|
||
bossReported: null, lpReported: 0, credit: null, demoted: new Set(), heldResult: null,
|
||
};
|
||
}
|
||
|
||
/** A battle built from a spec on the server (headless / takeover / verification); never throws. */
|
||
_specBattle(spec, { sharedBoss = null } = {}) {
|
||
try {
|
||
return createBattleFromSpec(spec, this.ds, { BattleClass: this.BattleClass, sharedBoss, logger: this.log, recordEvents: false });
|
||
} catch (e) {
|
||
this.reportError(`battle ${spec && spec.fieldId} construct`, e);
|
||
return new DeadBattle({ fieldId: spec && spec.fieldId, kind: spec && spec.kind, players: (spec && spec.players) || [], rect: spec && spec.rect, stageId: spec && spec.stageId }, 'forced');
|
||
}
|
||
}
|
||
|
||
/** Game seconds a field has run (its clock; a finished field: its final time). */
|
||
_fieldElapsed(f) {
|
||
if (f.done && f.result && Number.isFinite(f.result.time)) return f.result.time;
|
||
const gt = Math.max(0, ((this._clockNow() - f.startAt) / 1000) * this.gameSpeed);
|
||
if (f.endGt != null) return Math.min(gt, f.endGt);
|
||
const lim = f.spec && f.spec.timeLimit > 0 ? f.spec.timeLimit : HARD_CAP_SECONDS;
|
||
return Math.min(gt, lim);
|
||
}
|
||
|
||
/** m.public.fields[].progress: { killed, total, done } (teammates' waiting UI). */
|
||
_fieldProgress(f) {
|
||
if (!f) return null;
|
||
if (!f.cc) {
|
||
const b = f.battle;
|
||
if (!b) return null;
|
||
return { killed: Number(b.killed) || 0, total: Number(b.total) || 0, done: !f.live };
|
||
}
|
||
if (f.done && f.result) {
|
||
let killed = 0, total = 0;
|
||
for (const pp of Object.values(f.result.perPlayer || {})) { killed += Number(pp && pp.killed) || 0; total += Number(pp && pp.total) || 0; }
|
||
if (f.result.synthetic) { killed = f.progress.killed; total = f.progress.total; }
|
||
return { killed, total, done: true };
|
||
}
|
||
if (f.mode === 'server' && f.timeline) {
|
||
const [, killed, total] = timelineAt(f.timeline, this._fieldElapsed(f));
|
||
return { killed, total, done: false };
|
||
}
|
||
return { killed: f.progress.killed, total: f.progress.total, done: false };
|
||
}
|
||
|
||
/**
|
||
* LP a player's own battle of this normal round will cost at settlement so far — settle()'s min(lpCapPerRound,
|
||
* counted leaks) — for the teammates' live LP (m.public players[].pendingLp, user playtest #3 item 2; the own client
|
||
* counts its local battle itself). COMBAT: the recorded result once every field is done, else the field's result, else
|
||
* the authority's b.progress leaks (a server-run field reports none before its result is released); 联防: a leaker's
|
||
* enemies still standing on the 联防 field (_uniteLeft, uncapped in `uniteLeft`, user playtest #6 item 7), anyone
|
||
* else's own battle count (0: they were perfect). Omitted when 0 and in every other phase (boss rounds charge the
|
||
* merged team LP live).
|
||
* @returns {{ pendingLp?: number, uniteLeft?: number }}
|
||
*/
|
||
_pendingLpView(ps) {
|
||
if (!ps || !ps.alive || (this.phase !== PHASE.COMBAT && this.phase !== PHASE.UNITE)) return {};
|
||
const counted = (r) => (r && Array.isArray(r.leaked) ? r.leaked.filter((l) => l && l.counted !== false).length : 0);
|
||
// 联防: a leaker's enemies still standing on the 联防 field (uncapped), the loss capped like settle()
|
||
const left = this._uniteLeft(ps);
|
||
if (left != null) {
|
||
const loss = Math.min(this.gd.lpCapPerRound, left);
|
||
return loss > 0 ? { uniteLeft: left, pendingLp: loss } : { uniteLeft: left };
|
||
}
|
||
let n = 0;
|
||
if (this.lastResults.has(ps.playerId)) n = counted(this.lastResults.get(ps.playerId));
|
||
else if (this.phase === PHASE.COMBAT) {
|
||
const f = this.fields.find((x) => x && x.kind === 'normal' && Array.isArray(x.players) && x.players.includes(ps.playerId));
|
||
if (f && f.cc) n = f.done && f.result ? counted(f.result.perPlayer && f.result.perPlayer[ps.playerId]) : Number(f.progress && f.progress.leaks) || 0;
|
||
else if (f && f.battle) { try { n = battleProgress(f.battle).leaks; } catch { n = 0; } }
|
||
}
|
||
const loss = Math.min(this.gd.lpCapPerRound, Math.max(0, Math.trunc(Number(n) || 0)));
|
||
return loss > 0 ? { pendingLp: loss } : {};
|
||
}
|
||
|
||
/**
|
||
* 联防 (user playtest #6 item 7; PRTS 卫戍协议/帮助 "防卫失败的玩家可通过上方信息栏确认自身所属敌人的剩余数量"): how many of
|
||
* a leaker's enemies are still standing on the 联防 field — not spawned yet, alive, or through the objective again —
|
||
* plus its leaks that could not re-enter: what settle() charges it (before the per-round cap) if the 联防 ended now.
|
||
* It falls as the helpers strike them down and rises when one splits or summons (the children carry the leaker).
|
||
* Live from the field (client run: the authority's b.progress `left`; server run: the headless timeline on the field
|
||
* clock, or the streamed battle itself), clamped to what settlement can bill that leaker (fields.js uniteBillBounds:
|
||
* sent in + the offspring bound, validateClientResult's budget); exact once the field has its result (unite.js
|
||
* uniteSurvivors; a synthetic result charges the own leaks, as settle()). null for anyone but a leaker of the running
|
||
* 联防.
|
||
* @returns {number|null}
|
||
*/
|
||
_uniteLeft(ps) {
|
||
const plan = this.unitePlan;
|
||
if (this.phase !== PHASE.UNITE || !plan || !ps || !plan.leakers.includes(ps)) return null;
|
||
const pid = ps.playerId;
|
||
const f = this.fields.find((x) => x && x.kind === 'unite') || null;
|
||
let res = null;
|
||
if (f && f.cc) res = f.done ? f.result : null;
|
||
else if (f && f.battle && f.battle.finished) { try { res = f.battle.result(); } catch { res = null; } }
|
||
if (res && res.synthetic) {
|
||
const own = this.lastResults.get(pid);
|
||
return own && Array.isArray(own.leaked) ? own.leaked.filter((l) => l && l.counted !== false).length : 0;
|
||
}
|
||
if (res) return uniteSurvivors(plan, res).get(pid) || 0;
|
||
const sent = plan.leaked.filter((l) => l.sourcePlayerId === pid).length;
|
||
let live = null;
|
||
if (f && f.cc) {
|
||
if (f.mode === 'server' && f.timeline) {
|
||
const sample = timelineAt(f.timeline, this._fieldElapsed(f));
|
||
live = sample && sample[3] && typeof sample[3] === 'object' ? sample[3] : null;
|
||
} else live = f.progress && f.progress.left && typeof f.progress.left === 'object' ? f.progress.left : null;
|
||
} else if (f && f.battle) {
|
||
try { live = uniteLeft(f.battle); } catch { live = null; }
|
||
}
|
||
if (!this._uniteBounds || this._uniteBounds.plan !== plan) this._uniteBounds = { plan, bounds: uniteBillBounds(plan.leaked, this.gd) };
|
||
const bound = this._uniteBounds.bounds.get(pid) ?? sent;
|
||
const standing = live ? Math.min(bound, Math.max(0, Math.trunc(Number(live[pid]) || 0))) : sent;
|
||
return standing + (plan.notReentered.get(pid) || 0);
|
||
}
|
||
|
||
/** Server-run 联防 (streaming mode): refresh m.public about once a game second when a leaker's count moved. */
|
||
_uniteTick(runner) {
|
||
const f = runner && runner.fields ? runner.fields[0] : null;
|
||
if (!f || !f.battle || runner.ticks % 30 !== 0) return;
|
||
let key = '';
|
||
try { key = JSON.stringify(uniteLeft(f.battle)); } catch { key = ''; }
|
||
if (key === this._uniteLeftKey) return;
|
||
this._uniteLeftKey = key;
|
||
this.markPublic();
|
||
}
|
||
|
||
/** The connected human who simulates a field: lowest seat among its players (normal: the owner). */
|
||
_authorityFor(f, exclude = null) {
|
||
let best = null;
|
||
for (const pid of f.players) {
|
||
if (pid === exclude || (f.demoted && f.demoted.has(pid))) continue;
|
||
const ps = this.players.get(pid);
|
||
if (!ps || ps.isBot || ps.left || !ps.connected) continue;
|
||
if (!best || ps.seat < best.seat) best = ps;
|
||
}
|
||
return best ? best.playerId : null;
|
||
}
|
||
|
||
/** b.start of a field for one recipient (`watch`: not a player of the field). */
|
||
_startMsg(f, pid, { watch = false } = {}) {
|
||
return {
|
||
t: 'b.start', battleId: f.battleId, fieldId: f.fieldId, kind: f.kind, spec: f.spec,
|
||
authoritative: !!(!f.done && f.mode === 'client' && f.authority === pid && !watch),
|
||
startAt: f.startAt, serverNow: this.sched.now(), elapsed: Math.round(this._fieldElapsed(f) * 1000) / 1000,
|
||
speed: this.gameSpeed, watch: !!watch, done: !!f.done,
|
||
};
|
||
}
|
||
|
||
_sendStart(pid, f, opts = {}) {
|
||
const ps = this.players.get(pid);
|
||
if (!ps || ps.isBot || ps.left || !ps.connected) return false;
|
||
return this.sendTo(pid, this._startMsg(f, pid, opts));
|
||
}
|
||
|
||
/** Humans currently shown a field (its players and its watchers). */
|
||
_humansShowing(f) {
|
||
const out = new Set();
|
||
for (const pid of f.players) out.add(pid);
|
||
for (const [pid, fid] of this.watchers) if (fid === f.fieldId) out.add(pid);
|
||
return [...out].filter((pid) => { const ps = this.players.get(pid); return ps && !ps.isBot && !ps.left; });
|
||
}
|
||
|
||
/** Give every field its authority (a connected human) or run it on the server. */
|
||
_launch(fields) {
|
||
const now = this.sched.now();
|
||
this.fields = fields;
|
||
for (const f of fields) { f.startAt = now; f.lastProgressAt = now; }
|
||
for (const f of fields) {
|
||
const auth = this._authorityFor(f);
|
||
if (auth) this._assignClient(f, auth);
|
||
else this._runOnServer(f, 'no-human');
|
||
}
|
||
}
|
||
|
||
_assignClient(f, pid) {
|
||
f.mode = 'client';
|
||
f.authority = pid;
|
||
f.lastProgressAt = this.sched.now();
|
||
if (f.deadlineTimer) { this.cancel(f.deadlineTimer); f.deadlineTimer = null; }
|
||
if (f.kind === 'boss' || f.kind === 'hidden') return; // the pool / team LP / silence watchdog end those
|
||
if (this.paused) { f.rearmDeadline = true; return; }
|
||
this._armDeadline(f);
|
||
}
|
||
|
||
/** A client field's result deadline: its time limit on the field clock + RESULT_GRACE_MS (then the server takes over). */
|
||
_armDeadline(f) {
|
||
if (f.deadlineTimer) { this.cancel(f.deadlineTimer); f.deadlineTimer = null; }
|
||
const lim = f.spec.timeLimit > 0 ? f.spec.timeLimit : 60;
|
||
const at = f.startAt + Math.round((lim / this.gameSpeed) * 1000) + RESULT_GRACE_MS;
|
||
f.deadlineTimer = this.later(Math.max(0, at - this.sched.now()), () => {
|
||
f.deadlineTimer = null;
|
||
if (f.done || f.mode !== 'client') return;
|
||
this._runOnServer(f, 'timeout');
|
||
});
|
||
}
|
||
|
||
/** A server-run normal / 联防 field's result is released at the battle's natural end on the field clock. */
|
||
_armRelease(f) {
|
||
if (f.doneTimer) { this.cancel(f.doneTimer); f.doneTimer = null; }
|
||
if (f.done || f.result == null) return;
|
||
if (this.paused) { f.rearmRelease = true; return; }
|
||
const doneAt = f.startAt + Math.round(((f.endGt || 0) / this.gameSpeed) * 1000);
|
||
const wait = this.sched.instant ? 0 : Math.max(0, doneAt - this.sched.now());
|
||
f.doneTimer = this.later(wait, () => { f.doneTimer = null; this._fieldDone(f); });
|
||
}
|
||
|
||
/**
|
||
* The server simulates a field: normal / 联防 headlessly at once (the result is released at the battle's natural end
|
||
* on the field's clock, so the teammates' progress UI and the round pacing stay as if it ran live); boss fields in
|
||
* real time on the pacer (they share the pool), fast-forwarded to the field's clock on a takeover.
|
||
*/
|
||
_runOnServer(f, reason) {
|
||
const prev = f.mode === 'client' ? f.authority : null;
|
||
if (prev) {
|
||
this.verifyStats.takeovers++;
|
||
this.log.info?.(`[match ${this.roomCode}] ${f.fieldId} R${this.round}: server takeover from ${prev} (${reason})`);
|
||
}
|
||
f.mode = 'server';
|
||
f.authority = null;
|
||
if (f.deadlineTimer) { this.cancel(f.deadlineTimer); f.deadlineTimer = null; }
|
||
// the former authority (still online after a timeout / an invalid result) stops reporting and keeps its view
|
||
if (prev) this.sendTo(prev, { t: 'b.end', battleId: f.battleId, fieldId: f.fieldId, reason: 'takeover' });
|
||
if (f.kind === 'boss' || f.kind === 'hidden') { this._bossServerRun(f); return; }
|
||
const job = new HeadlessJob(this._specBattle(f.spec), { onError: (e) => this.reportError(`field ${f.fieldId} step`, e), players: f.players });
|
||
f.job = job;
|
||
f.battle = job.battle;
|
||
f.timeline = job.timeline; // grows while the job runs (the teammates' progress UI reads it on the field clock)
|
||
if (f.sliceTimer) { this.cancel(f.sliceTimer); f.sliceTimer = null; }
|
||
const complete = () => {
|
||
if (f.job !== job || f.done) return;
|
||
f.job = null;
|
||
const run = job.output();
|
||
f.battle = run.battle;
|
||
f.result = run.result;
|
||
f.resultSource = 'server';
|
||
f.timeline = run.timeline;
|
||
f.endGt = Number(run.battle.time) || 0;
|
||
this._armRelease(f);
|
||
};
|
||
if (!Number.isFinite(this.headlessSliceMs)) {
|
||
job.run(Infinity);
|
||
complete();
|
||
} else {
|
||
// a real host: wall-clock-bounded slices in callbacks of their own (3 bot fields at combat start would otherwise
|
||
// block the event loop for ~0.2–0.5 s here, seconds on a low-power mini PC)
|
||
const slice = () => {
|
||
f.sliceTimer = null;
|
||
if (f.job !== job || f.done) return;
|
||
if (job.run(this.headlessSliceMs)) complete();
|
||
else f.sliceTimer = this.later(0, slice);
|
||
};
|
||
f.sliceTimer = this.later(0, slice);
|
||
}
|
||
this._armProgressTicker();
|
||
}
|
||
|
||
/** m.public ~1 Hz while server-run fields progress along their timelines. */
|
||
_armProgressTicker() {
|
||
if (this._progressTimer || this.sched.instant) return;
|
||
const tick = () => {
|
||
this._progressTimer = null;
|
||
if (!this.fields.some((f) => f.cc && f.mode === 'server' && !f.done && f.timeline)) return;
|
||
this.markPublic();
|
||
this._progressTimer = this.later(1000, tick);
|
||
};
|
||
this._progressTimer = this.later(1000, tick);
|
||
}
|
||
|
||
/** A field has its final result. */
|
||
_fieldDone(f) {
|
||
if (!f || f.done) return;
|
||
f.done = true;
|
||
f.live = false;
|
||
this._clearFieldTimers(f);
|
||
if (!f.result) f.result = syntheticResult(f.players, { bossBy: f.bossBy });
|
||
f.progress.done = true;
|
||
this.markPublic();
|
||
this._maybeFieldsDone();
|
||
}
|
||
|
||
_maybeFieldsDone() {
|
||
if (!this.fields.length || this.fields.some((f) => f.cc && !f.done)) return;
|
||
if (!this.fields.every((f) => f.cc)) return;
|
||
if (this.phase === PHASE.COMBAT) this._finishCombat((f) => f.result);
|
||
else if (this.phase === PHASE.UNITE) this._finishUniteClient();
|
||
else if (this.phase === PHASE.FINAL_ASSAULT || this.phase === PHASE.HIDDEN_CORE) this._finishFinal(this.phase === PHASE.HIDDEN_CORE, (f) => f.result);
|
||
}
|
||
|
||
/** An authoritative human disconnected / left: normal & 联防 fields → server takeover; boss → the partner or the server. */
|
||
_authorityLost(ps, why) {
|
||
for (const f of this.fields) {
|
||
if (!f.cc || f.done || f.mode !== 'client' || f.authority !== ps.playerId) continue;
|
||
if (f.kind === 'boss' || f.kind === 'hidden') this._bossHandover(f, why);
|
||
else this._runOnServer(f, why);
|
||
}
|
||
}
|
||
|
||
// ---- COMBAT / UNITE
|
||
|
||
_startCombatClient() {
|
||
this.phase = PHASE.COMBAT;
|
||
const alive = this.alivePlayers();
|
||
this.lastResults = new Map();
|
||
this.watchers.clear();
|
||
const fields = alive.map((ps) => this._ccField({ fieldId: `n:${ps.playerId}`, kind: 'normal', players: [ps.playerId], opts: this._normalOpts(ps) }));
|
||
const limit = this.wave ? this.wave.timeLimit : 60;
|
||
this.deadline = this.sched.instant ? 0 : this.sched.now() + Math.round((limit / this.gameSpeed) * 1000);
|
||
this._launch(fields);
|
||
// every fighting human runs its own field; eliminated humans keep watching (research 09 §3.1 "Keep-watching
|
||
// auto-observes the first available field", switching freely with 前往查看): a display replica of the first field
|
||
for (const f of fields) for (const pid of f.players) {
|
||
const ps = this.players.get(pid);
|
||
if (!ps || ps.isBot || ps.left) continue;
|
||
this.watchers.set(pid, f.fieldId);
|
||
this._sendStart(pid, f);
|
||
}
|
||
const first = fields.find((f) => f.mode === 'client') || fields[0] || null;
|
||
if (first) {
|
||
for (const ps of this.order) {
|
||
if (ps.isBot || ps.left || ps.alive || this.watchers.has(ps.playerId)) continue;
|
||
this.watchers.set(ps.playerId, first.fieldId);
|
||
this._sendStart(ps.playerId, first, { watch: true });
|
||
}
|
||
}
|
||
this.markPublic();
|
||
}
|
||
|
||
_startUniteClient(plan) {
|
||
this.phase = PHASE.UNITE;
|
||
this.unitePlan = plan;
|
||
const limit = this.wave ? this.wave.timeLimit : 60;
|
||
const f = this._ccField({ fieldId: 'u', kind: 'unite', players: plan.helpers.map((p) => p.playerId), opts: this._uniteOpts(plan, limit) });
|
||
this.deadline = this.sched.instant ? 0 : this.sched.now() + Math.round((limit / this.gameSpeed) * 1000);
|
||
this.watchers.clear();
|
||
this._launch([f]);
|
||
// helpers and everyone else (as observers) simulate the same 联防 spec locally
|
||
for (const ps of this.order) {
|
||
if (ps.isBot || ps.left) continue;
|
||
this.watchers.set(ps.playerId, 'u');
|
||
this._sendStart(ps.playerId, f, { watch: !f.players.includes(ps.playerId) });
|
||
}
|
||
this.markPublic();
|
||
this.tickerText(`联防阶段:${plan.helpers.map((p) => p.name).join('、')} 迎战突破防线的敌人`);
|
||
}
|
||
|
||
_finishUniteClient() {
|
||
if (this.phase !== PHASE.UNITE) return;
|
||
const f = this.fields[0];
|
||
const res = f.result;
|
||
this._collectSimErrors(f, res);
|
||
this._stopClientCombat();
|
||
f.live = false;
|
||
this.deadline = 0;
|
||
this.markPublic();
|
||
const plan = this.unitePlan;
|
||
this.later(this.scaled(DELAYS.COMBAT_END), () => this.settle(plan, res));
|
||
}
|
||
|
||
// ---- reports
|
||
|
||
_fieldByBattle(battleId) {
|
||
return typeof battleId === 'string' ? this.fields.find((f) => f.cc && f.battleId === battleId) || null : null;
|
||
}
|
||
|
||
/** b.progress from a field's authority (anything else — a stale battle, a demoted client — is ignored). */
|
||
_onProgress(ps, msg) {
|
||
if (!this.clientCombat) return fail(ERR.WRONG_PHASE, 'server-run combat');
|
||
const f = this._fieldByBattle(msg.battleId);
|
||
if (!f || f.done || f.mode !== 'client' || f.authority !== ps.playerId) return OK;
|
||
const p = f.progress;
|
||
const maxTotal = Math.max(p.total, (f.spec.spawns.length + 1) * 400);
|
||
p.gt = Math.max(p.gt, Math.min(Number(msg.gt) || 0, HARD_CAP_SECONDS));
|
||
// the latest total (spawns never reached before the limit leave it at the end)
|
||
p.total = Math.min(maxTotal, Math.max(0, msg.total | 0));
|
||
p.killed = Math.min(p.total, Math.max(p.killed, msg.killed | 0));
|
||
f.lastProgressAt = this.sched.now();
|
||
if (f.kind === 'boss' || f.kind === 'hidden') {
|
||
this._creditBoss(f, msg.bossDmg, msg.by);
|
||
this._creditLp(f, msg.leaks, true);
|
||
this._checkFinalEnd();
|
||
this._broadcastPool(false);
|
||
// 4 Hz per field: b.pool carries the exact pool / team LP; m.public (boss HP, LP, progress) follows at ~1 Hz
|
||
this._bossPublic();
|
||
return OK;
|
||
}
|
||
if (Number.isFinite(msg.leaks)) p.leaks = Math.max(p.leaks, msg.leaks);
|
||
// 联防: the leakers' enemies still standing (the latest report; clamped where it is read, _uniteLeft)
|
||
if (f.kind === 'unite' && msg.left && typeof msg.left === 'object') p.left = { ...msg.left };
|
||
this.markPublic();
|
||
return OK;
|
||
}
|
||
|
||
/** b.result from a field's authority: validated against the spec; an implausible one is replaced by the server's run. */
|
||
_onResult(ps, msg) {
|
||
if (!this.clientCombat) return fail(ERR.WRONG_PHASE, 'server-run combat');
|
||
const f = this._fieldByBattle(msg.battleId);
|
||
if (!f || f.done || f.heldResult || f.mode !== 'client' || f.authority !== ps.playerId) return OK;
|
||
const bossLike = f.kind === 'boss' || f.kind === 'hidden';
|
||
const v = validateClientResult(f.spec, msg.result, { gd: this.gd });
|
||
if (!v.ok) {
|
||
this.verifyStats.rejected++;
|
||
this.log.warn?.(`[match ${this.roomCode}] ${f.fieldId}: rejected client result from ${ps.playerId} (${v.reason}) — server re-simulation`);
|
||
if (bossLike && this._finalEnding) { f.result = syntheticResult(f.players, { bossBy: f.bossBy, time: this._fieldElapsed(f) }); this._fieldDone(f); this._checkFinalEnd(); }
|
||
else if (bossLike) this._bossHandover(f, 'invalid', { demote: true });
|
||
else this._runOnServer(f, 'invalid');
|
||
return OK;
|
||
}
|
||
let result = v.result;
|
||
if (bossLike) {
|
||
// the final b.progress normally carried everything; the result's per-player damage is a lower bound
|
||
const by = {};
|
||
let sum = 0;
|
||
for (const pid of f.players) { const d = Number(result.perPlayer[pid] && result.perPlayer[pid].bossDamage) || 0; by[pid] = d; sum += d; }
|
||
if (sum > f.bossAcked) this._creditBoss(f, sum, by);
|
||
// the client emptied the pool as it saw it (server hp − its unacknowledged damage): what is left on the server is
|
||
// float dust from summing the fields' reports in another order — the boss is down (pools never hold less than
|
||
// BOSS_POOL_MIN_HP, finalAssault.js: this only catches a noise-level disagreement at that boundary)
|
||
const pool = this.bossPool;
|
||
if (result.reason === 'cleared' && this._finalEnding !== 'forced' && pool && pool.hp > 0 && pool.hp < 1 + 1e-6) pool.damage(f.players[0] ?? null, pool.hp);
|
||
// a boss field ends only when the shared pool is empty (the client saw it reach 0) or the match forced the end
|
||
// (b.end): any other result — 'forced' / 'timeout' at t = 0, 'cleared' while the pool still holds — would stop
|
||
// the pair's fight (and, with every field done, end the Final Assault as a defeat). The field is handed to the
|
||
// partner's replica or the server instead, and the sender never reports it again.
|
||
if (!this._finalEnding && !(pool && pool.hp <= 0)) {
|
||
// 'cleared' while the budget still holds part of the client's cumulative report back: when that report covers
|
||
// what the pool holds, the client emptied the pool as it saw it, only faster than BOSS_MIN_CLEAR_GS lets the
|
||
// server credit (999-layer boards kill a 绝境 leader in 2–4 game s; official "boss一秒死", DESIGN §20.10). The
|
||
// result waits: the boss clock credits the report as the budget grows and the pool's end (_endFinal) completes
|
||
// the field with it — no takeover, nothing credited faster than the budget (a forged report gains nothing a
|
||
// b.progress could not already get). The 1 HP slack is the pool's dust floor (BOSS_POOL_MIN_HP).
|
||
const reported = Math.max(sum, f.bossReported ? f.bossReported.cum : 0);
|
||
if (result.reason === 'cleared' && pool && reported - f.bossAcked >= pool.hp - 1) {
|
||
f.heldResult = result;
|
||
return OK;
|
||
}
|
||
this.verifyStats.rejected++;
|
||
this.log.warn?.(`[match ${this.roomCode}] ${f.fieldId}: implausible boss result from ${ps.playerId} (${result.reason} while the pool holds ${pool ? Math.round(pool.hp) : '?'}) — handed over`);
|
||
this._bossHandover(f, 'invalid', { demote: true });
|
||
return OK;
|
||
}
|
||
this._bossResultDamage(f, result);
|
||
} else {
|
||
result = this._verifyResult(f, result);
|
||
}
|
||
f.result = result;
|
||
f.resultSource = 'client';
|
||
this._fieldDone(f);
|
||
if (bossLike) { this._checkFinalEnd(); this._broadcastPool(false); }
|
||
return OK;
|
||
}
|
||
|
||
/** A boss field's accepted client result: each player's bossDamage is at least what the server credited them. */
|
||
_bossResultDamage(f, result) {
|
||
for (const pid of f.players) if (result.perPlayer[pid]) result.perPlayer[pid].bossDamage = Math.max(result.perPlayer[pid].bossDamage || 0, f.bossBy[pid] || 0);
|
||
}
|
||
|
||
/**
|
||
* SP_VERIFY: re-simulate an accepted client result ('all': now, the server's result wins on a mismatch; 'sample':
|
||
* ~1 battle in 8 in a later callback, mismatches are only logged).
|
||
*/
|
||
_verifyResult(f, result) {
|
||
if (this.verifyMode === 'off') return result;
|
||
const check = () => {
|
||
const run = runHeadless(this._specBattle(f.spec), { players: f.players });
|
||
const mine = validateClientResult(f.spec, compactForVerify(run.result), { gd: this.gd });
|
||
const server = mine.ok ? mine.result : run.result;
|
||
this.verifyStats.checked++;
|
||
if (resultDigest(server).hash !== resultDigest(result).hash) {
|
||
this.verifyStats.mismatches++;
|
||
this.log.warn?.(`[match ${this.roomCode}] ${f.fieldId}: client result differs from the server's simulation`);
|
||
return server;
|
||
}
|
||
return null;
|
||
};
|
||
if (this.verifyMode === 'all') return check() || result;
|
||
let h = 0;
|
||
for (let i = 0; i < f.battleId.length; i++) h = (h * 31 + f.battleId.charCodeAt(i)) >>> 0;
|
||
if (h % 8 === 0) this.later(0, () => { try { check(); } catch (e) { this.reportError('verify', e); } });
|
||
return result;
|
||
}
|
||
|
||
// ---- watching (research 09 §3.1 / §6.3)
|
||
|
||
/**
|
||
* g.watch under client-side combat: the watcher gets the field's spec (b.start, display only) and runs a local
|
||
* replica fast-forwarded to the field's clock. No watching while the own normal battle runs; the other pair's boss
|
||
* field is never shown to a fighting player; eliminated players may watch anything.
|
||
*/
|
||
_watchClient(ps, fieldId) {
|
||
const f = this.fields.find((x) => x.fieldId === fieldId) || null;
|
||
if (!f) return fail(ERR.BAD_TARGET, 'no such field');
|
||
const own = this.fields.find((x) => x.players.includes(ps.playerId)) || null;
|
||
if (ps.alive) {
|
||
if ((f.kind === 'boss' || f.kind === 'hidden') && own && own !== f) return fail(ERR.BAD_TARGET, 'other group hidden');
|
||
if (f.kind === 'normal' && own && own !== f && own.live) return fail(ERR.WRONG_PHASE, 'own battle running');
|
||
}
|
||
this.watchers.set(ps.playerId, f.fieldId);
|
||
this.sendTo(ps.playerId, this._startMsg(f, ps.playerId, { watch: !f.players.includes(ps.playerId) }));
|
||
return OK;
|
||
}
|
||
|
||
/** Reconnect / resync: the spec of the field the player is on (fast-forwarded by the client). */
|
||
_resendBattle(ps) {
|
||
if (!this.fields.some((f) => f.cc)) return;
|
||
const fid = this.watchers.get(ps.playerId);
|
||
let f = fid ? this.fields.find((x) => x.fieldId === fid) : null;
|
||
if (!f) f = this.fields.find((x) => x.players.includes(ps.playerId)) || (this.phase === PHASE.UNITE ? this.fields[0] : null);
|
||
if (!f && !ps.alive) f = this.fields[0] || null;
|
||
if (!f) return;
|
||
this.watchers.set(ps.playerId, f.fieldId);
|
||
this.sendTo(ps.playerId, this._startMsg(f, ps.playerId, { watch: !f.players.includes(ps.playerId) }));
|
||
}
|
||
|
||
// ---- Final Assault / Hidden Core
|
||
|
||
_startFinalClient(hidden) {
|
||
const pool = this.bossPool;
|
||
const recs = this.fields.map((x) => this._ccField({ fieldId: x.fieldId, kind: x.kind, players: x.players, opts: x.opts, boss: { poolHp: pool.hp, poolMax: pool.maxHp } }));
|
||
this._finalEnding = null;
|
||
this._bossStartAt = this.sched.now();
|
||
this.watchers.clear();
|
||
if (!recs.some((f) => this._authorityFor(f))) {
|
||
// nobody to simulate on a client: the server runs every boss field (FieldRunner, no streaming)
|
||
this.fields = recs;
|
||
for (const f of recs) {
|
||
f.mode = 'server';
|
||
// a CreditPool with nothing acknowledged credits everything; it counts this field's damage for b.pool `acked`
|
||
f.credit = new CreditPool(pool);
|
||
f.battle = this._specBattle(f.spec, { sharedBoss: f.credit });
|
||
try {
|
||
f.battle.on('enemyLeak', (ctx) => this._bossLeak(ctx && ctx.enemy), { priority: -1000, owner: 'match' });
|
||
f.battle.on('lpLoss', (ctx) => this._teamLpLoss(ctx && ctx.amount), { priority: -1000, owner: 'match' });
|
||
} catch (e) { this.reportError('boss leak hook', e); }
|
||
}
|
||
this._watchBossFields(recs);
|
||
this.markPublic();
|
||
this.runner = new FieldRunner(this, recs, {
|
||
onTick: (runner) => this._bossTick(runner),
|
||
onDone: (runner) => this._finalDone(runner, hidden),
|
||
emit: false,
|
||
});
|
||
this.runner.start();
|
||
return;
|
||
}
|
||
this._launch(recs);
|
||
this._watchBossFields(recs);
|
||
this.markPublic();
|
||
this._broadcastPool(true);
|
||
this._bossClockOn = true;
|
||
this._bossClock = this.later(BOSS_CLOCK_MS, () => this._bossClockTick());
|
||
}
|
||
|
||
/** b.start of the boss fields: players get their own pair field, eliminated humans the first field. */
|
||
_watchBossFields(fields) {
|
||
for (const ps of this.order) {
|
||
if (ps.isBot || ps.left) continue;
|
||
const own = fields.find((f) => f.players.includes(ps.playerId)) || null;
|
||
const f = own || fields[0];
|
||
if (!f) continue;
|
||
this.watchers.set(ps.playerId, f.fieldId);
|
||
this._sendStart(ps.playerId, f, { watch: !own });
|
||
}
|
||
}
|
||
|
||
/** The most shared-pool damage a boss field's client reports may have credited by now (server field clock). */
|
||
_bossDmgBudget(f) {
|
||
return this.bossPool ? (this.bossPool.maxHp * this._fieldElapsed(f)) / BOSS_MIN_CLEAR_GS : 0;
|
||
}
|
||
|
||
/** The most team LP a boss field's client reports may have cost by now (server field clock). */
|
||
_bossLpBudget(f) {
|
||
return BOSS_LP_BURST + BOSS_LP_PER_GS * this._fieldElapsed(f);
|
||
}
|
||
|
||
/**
|
||
* A boss field's shared-pool damage, as reported cumulatively by its client (`by`: per player) — credited up to the
|
||
* plausibility budget of the field clock; the rest is credited by later reports / the boss clock (`bossReported`).
|
||
*/
|
||
_creditBoss(f, cum, by) {
|
||
const pool = this.bossPool;
|
||
const reported = Number(cum);
|
||
// the team LP ran out first: the run failed at that moment (PRTS "…使目标生命值扣除至0,则无视倒计时直接失败"), so
|
||
// damage reported afterwards — in flight, or rounded up in the final b.result — changes nothing (user playtest #6)
|
||
if (!pool || !Number.isFinite(reported) || this._finalEnding === 'forced') return;
|
||
if (!f.bossReported || reported > f.bossReported.cum) f.bossReported = { cum: reported, by: by && typeof by === 'object' ? { ...by } : null };
|
||
const c = Math.min(reported, this._bossDmgBudget(f));
|
||
if (!(c > f.bossAcked)) return;
|
||
const delta = c - f.bossAcked;
|
||
f.bossAcked = c;
|
||
let attributed = 0;
|
||
if (by && typeof by === 'object') {
|
||
for (const pid of f.players) {
|
||
const v = Number(by[pid]);
|
||
const prev = f.bossBy[pid] || 0;
|
||
if (!Number.isFinite(v) || v <= prev) continue;
|
||
const d = Math.min(delta - attributed, v - prev);
|
||
if (d > 0) { f.bossBy[pid] = prev + d; pool.damage(pid, d); attributed += d; }
|
||
}
|
||
}
|
||
if (delta - attributed > 0) {
|
||
const pid = by ? null : f.players[0];
|
||
if (pid) f.bossBy[pid] = (f.bossBy[pid] || 0) + (delta - attributed);
|
||
pool.damage(pid, delta - attributed);
|
||
}
|
||
}
|
||
|
||
/** A boss field's LP cost (leaks × lpr + leader LP effects): `cumulative` totals from its client, deltas from the server run. */
|
||
_creditLp(f, amount, cumulative = false) {
|
||
let n = Number(amount);
|
||
if (!Number.isFinite(n) || n <= 0) return;
|
||
if (cumulative) {
|
||
// a client's cumulative report: credited up to the field clock's plausibility budget (the rest later)
|
||
if (n > f.lpReported) f.lpReported = n;
|
||
n = Math.min(n, this._bossLpBudget(f));
|
||
if (n <= f.lpAcked) return;
|
||
const d = n - f.lpAcked;
|
||
f.lpAcked = n;
|
||
this._teamLpLoss(d);
|
||
return;
|
||
}
|
||
const before = f.lpCum;
|
||
f.lpCum += n;
|
||
const credit = Math.max(0, f.lpCum - Math.max(before, f.lpAcked));
|
||
if (credit > 0) this._teamLpLoss(credit);
|
||
}
|
||
|
||
/** The server runs a boss field in real time (no client left): credits only what exceeds the client's reports. */
|
||
_bossServerRun(f) {
|
||
const credit = new CreditPool(this.bossPool, { acked: f.bossAcked, ackedBy: f.bossBy });
|
||
const battle = this._specBattle(f.spec, { sharedBoss: credit });
|
||
f.battle = battle;
|
||
f.credit = credit;
|
||
f.lpCum = 0;
|
||
try {
|
||
battle.on('enemyLeak', (ctx) => { const e = ctx && ctx.enemy; if (e) this._creditLp(f, Number.isFinite(e.lpr) && e.lpr >= 0 ? e.lpr : 1); }, { priority: -1000, owner: 'match' });
|
||
battle.on('lpLoss', (ctx) => this._creditLp(f, ctx && ctx.amount), { priority: -1000, owner: 'match' });
|
||
} catch (e) { this.reportError('boss leak hook', e); }
|
||
if (!this.pacer) this.pacer = new HeadlessPacer(this);
|
||
const entry = this.pacer.add({
|
||
battle,
|
||
onDone: () => {
|
||
if (f.done) return;
|
||
let res = null;
|
||
try { res = battle.result(); } catch (e) { this.reportError('boss result', e); }
|
||
f.result = res && res.perPlayer ? res : syntheticResult(f.players, { bossBy: f.bossBy });
|
||
f.resultSource = 'server';
|
||
this._fieldDone(f);
|
||
this._checkFinalEnd();
|
||
},
|
||
onTick: () => this._checkFinalEnd(),
|
||
});
|
||
const gt = this._fieldElapsed(f);
|
||
// the fast-forward to the field clock is spread over the pacing intervals on a real host (virtual time: at once)
|
||
if (gt > 0 && !entry.done) this.pacer.skipTo(entry, gt, { budgetTicks: this.sched.virtual ? Infinity : CATCHUP_TICKS_PER_INTERVAL });
|
||
}
|
||
|
||
/**
|
||
* A boss field's authority left, went silent or sent an implausible result (`demote`: never its authority again):
|
||
* its partner (a replica already running) takes over, else the server.
|
||
*/
|
||
_bossHandover(f, why, { demote = false } = {}) {
|
||
if (f.done || f.mode !== 'client' || f.heldResult) return; // a held 'cleared' result is in: nothing left to run
|
||
const prev = f.authority;
|
||
if (demote && prev) f.demoted.add(prev);
|
||
const next = this._authorityFor(f, prev);
|
||
if (next) {
|
||
f.authority = next;
|
||
f.lastProgressAt = this.sched.now();
|
||
if (prev) this.sendTo(prev, { t: 'b.end', battleId: f.battleId, fieldId: f.fieldId, reason: 'takeover' });
|
||
this._sendStart(next, f);
|
||
return;
|
||
}
|
||
this._runOnServer(f, why);
|
||
}
|
||
|
||
_bossClockTick() {
|
||
this._bossClock = null;
|
||
if (this.phase !== PHASE.FINAL_ASSAULT && this.phase !== PHASE.HIDDEN_CORE) return;
|
||
if (this.paused) return; // re-armed by _resume
|
||
const now = this.sched.now();
|
||
this._applyOvertime(((now - this._bossStartAt) / 1000) * this.gameSpeed);
|
||
for (const f of this.fields) {
|
||
// reports held back by the plausibility budget are credited as the field clock advances
|
||
if (f.cc && !f.done && f.mode === 'client') {
|
||
if (f.bossReported && f.bossReported.cum > f.bossAcked) this._creditBoss(f, f.bossReported.cum, f.bossReported.by);
|
||
if (f.lpReported > f.lpAcked) this._creditLp(f, f.lpReported, true);
|
||
}
|
||
if (f.cc && !f.done && f.mode === 'client' && !f.heldResult && now - f.lastProgressAt > BOSS_SILENCE_MS) {
|
||
this.log.info?.(`[match ${this.roomCode}] ${f.fieldId}: no progress from ${f.authority} — handing the field over`);
|
||
this._bossHandover(f, 'silent');
|
||
}
|
||
}
|
||
this._checkFinalEnd();
|
||
this._broadcastPool(false);
|
||
if (this.fields.some((f) => f.cc && !f.done)) this._bossClock = this.later(BOSS_CLOCK_MS, () => this._bossClockTick());
|
||
else this._bossClockOn = false;
|
||
}
|
||
|
||
/** Pool depleted → victory; team LP 0 → defeat: every boss field is ended. */
|
||
_checkFinalEnd() {
|
||
if (this._finalEnding || !this.bossPool || !this.fields.some((f) => f.cc)) return;
|
||
if (this.phase !== PHASE.FINAL_ASSAULT && this.phase !== PHASE.HIDDEN_CORE) return;
|
||
if (this.bossPool.hp <= 0) this._endFinal('cleared');
|
||
else if (this.teamLp != null && this.teamLp <= 0) this._endFinal('forced');
|
||
}
|
||
|
||
_endFinal(reason) {
|
||
if (this._finalEnding) return;
|
||
this._finalEnding = reason;
|
||
this._broadcastPool(true);
|
||
for (const f of this.fields) {
|
||
if (!f.cc || f.done) continue;
|
||
if (f.mode === 'server' && f.battle) {
|
||
try { f.battle.forceEnd('forced'); } catch (e) { this.reportError('boss forceEnd', e); }
|
||
let res = null;
|
||
try { res = f.battle.result(); } catch (e) { this.reportError('boss result', e); }
|
||
f.result = res && res.perPlayer ? res : syntheticResult(f.players, { bossBy: f.bossBy });
|
||
f.resultSource = 'server';
|
||
continue; // _fieldDone below (after every field was told)
|
||
}
|
||
for (const pid of this._humansShowing(f)) this.sendTo(pid, { t: 'b.end', battleId: f.battleId, fieldId: f.fieldId, reason });
|
||
if (f.heldResult) {
|
||
// its authority already reported 'cleared' (_onResult): that result completes the field
|
||
f.result = f.heldResult;
|
||
this._bossResultDamage(f, f.result);
|
||
f.resultSource = 'client';
|
||
continue; // _fieldDone below
|
||
}
|
||
f.waitTimer = this.later(this.scaled(BOSS_RESULT_GRACE_MS), () => {
|
||
f.waitTimer = null;
|
||
if (f.done) return;
|
||
f.result = syntheticResult(f.players, { bossBy: f.bossBy, time: this._fieldElapsed(f) });
|
||
this._fieldDone(f);
|
||
});
|
||
}
|
||
if (this.pacer) { this.pacer.stop(); this.pacer = null; }
|
||
for (const f of this.fields) if (f.cc && !f.done && (f.mode === 'server' || f.heldResult)) this._fieldDone(f);
|
||
}
|
||
|
||
/** b.pool { hp, max, teamLp, acked } to everyone (≤ 4 Hz; `force` skips the dedupe, never the rate). */
|
||
_broadcastPool(force) {
|
||
if (!this.bossPool || this.disposed) return;
|
||
const now = this.sched.now();
|
||
if (now - this._lastPoolAt < POOL_MIN_GAP_MS) {
|
||
if (!this._poolTimer) {
|
||
this._poolTimer = this.later(Math.max(1, POOL_MIN_GAP_MS - (now - this._lastPoolAt)), () => { this._poolTimer = null; this._broadcastPool(force); });
|
||
}
|
||
return;
|
||
}
|
||
// exact numbers: a client shows `hp − (its cumulative damage − acked)` and ends its battle as 'cleared' when that
|
||
// reaches 0 — rounded values could show 0 while the server's pool still holds a fraction of a point
|
||
// a server-run field (no client left, or none at all): what its battle has put into the pool so far — a display
|
||
// replica (a reconnecting / watching human) subtracts only its local damage beyond that from the server hp
|
||
const acked = {};
|
||
for (const f of this.fields) if (f.cc) acked[f.fieldId] = f.mode === 'server' && f.credit ? Math.max(f.bossAcked, f.credit.cum) : f.bossAcked;
|
||
const msg = {
|
||
t: 'b.pool', hp: Math.max(0, this.bossPool.hp), max: this.bossPool.maxHp,
|
||
teamLp: this.teamLp == null ? null : Math.max(0, Math.round(this.teamLp)), acked,
|
||
};
|
||
const key = JSON.stringify(msg);
|
||
if (!force && key === this._lastPoolKey) return;
|
||
this._lastPoolKey = key;
|
||
this._lastPoolAt = now;
|
||
this.broadcast(msg);
|
||
}
|
||
|
||
// ===================================================================================================
|
||
// SETTLE
|
||
|
||
settle(plan, uniteResult) {
|
||
this.phase = PHASE.SETTLE;
|
||
this.runner = null;
|
||
this._stopClientCombat();
|
||
// the pending in-battle gains the views showed become persistent below (alive players) or lapse (DESIGN §20.15)
|
||
for (const ps of this.order) if (ps.pendingLayerGains) { ps.pendingLayerGains = null; ps.dirty(); }
|
||
const cap = this.gd.lpCapPerRound;
|
||
// a 联防 battle that could not run at all (synthetic result) must not wipe the leakers' losses: charge their own leaks
|
||
const uniteRan = !!(plan && uniteResult && !uniteResult.synthetic);
|
||
const survivors = uniteRan ? uniteSurvivors(plan, uniteResult) : null;
|
||
const alive = this.alivePlayers();
|
||
for (const ps of alive) {
|
||
const r = this.lastResults.get(ps.playerId) || { leaked: [], perfect: true, coins: 0, layerGains: {}, killed: 0, damageDealt: 0 };
|
||
const counted = (r.leaked || []).filter((l) => l && l.counted !== false).length;
|
||
const loss = uniteRan && plan.leakers.includes(ps) ? Math.min(cap, survivors.get(ps.playerId) || 0) : Math.min(cap, counted);
|
||
ps.lp -= loss;
|
||
ps.stats.lpLost += loss;
|
||
ps.stats.leaks += counted;
|
||
ps.stats.kills += Number(r.killed) || 0;
|
||
ps.stats.dmgDealt += Number(r.damageDealt) || 0;
|
||
ps.stats.healing += Number(r.healingDone) || 0;
|
||
if (r.perfect !== false && counted === 0) ps.stats.perfectRounds++;
|
||
// bounty coins (own battle + unite kills) are credited to the next prep
|
||
let coins = Math.max(0, Math.trunc(Number(r.coins) || 0));
|
||
const up = uniteResult && uniteResult.perPlayer && uniteResult.perPlayer[ps.playerId];
|
||
if (up) {
|
||
coins += Math.max(0, Math.trunc(Number(up.coins) || 0));
|
||
ps.stats.dmgDealt += Number(up.damageDealt) || 0;
|
||
ps.stats.kills += Number(up.killed) || 0;
|
||
}
|
||
// perfect-payout bounties (战术特训): own phase perfect
|
||
for (const b of ps.bounties) if (b.card.payout === 'perfect' && counted === 0 && r.perfect !== false) coins += b.card.coin;
|
||
if (coins > 0) { ps.pendingFunds += coins; ps.stats.fundsGained += coins; }
|
||
for (const b of ps.bounties) b.roundsLeft--;
|
||
ps.bounties = ps.bounties.filter((b) => b.roundsLeft > 0);
|
||
// IN_BATTLE layer gains (normal battles only), at most the room left under BOND_LAYER_CAP (999, as the battle's
|
||
// live copy: Battle.addLayers); a bond at the cap gains nothing and dispatches nothing
|
||
for (const [bondId, n] of Object.entries(r.layerGains || {})) {
|
||
if (!this.gd.bond(bondId) || !(n > 0)) continue;
|
||
const before = ps.layers[bondId] || 0;
|
||
const add = layerGainRoom(before, Math.floor(n));
|
||
if (!(add > 0)) continue;
|
||
ps.layers[bondId] = before + add;
|
||
this.dispatch(ps, 'onLayers', { bondId, from: before, to: ps.layers[bondId], reason: 'battle' });
|
||
}
|
||
this._charDamageTickers(ps, r);
|
||
this.dispatch(ps, 'onBattleResult', { result: r, lpLoss: loss, perfect: counted === 0 && r.perfect !== false, unite: uniteResult || null });
|
||
ps.recompute();
|
||
}
|
||
for (const ps of alive) {
|
||
if (ps.lp <= 0) {
|
||
ps.lp = 0;
|
||
ps.eliminate(this.round);
|
||
this.toast(ps, 'error', '你的目标生命值耗尽,已被淘汰');
|
||
this.tickerText(`${ps.name}博士的目标生命值已耗尽`);
|
||
}
|
||
}
|
||
this.fields = [];
|
||
this.watchers.clear();
|
||
this.markPublic();
|
||
this.setDeadline(DELAYS.SETTLE / 1000, () => this.afterSettle(), { silent: this.soloUntimed });
|
||
}
|
||
|
||
/**
|
||
* CHAR_DAMAGE tickers: one per board unit of the battle (its highest threshold); units created in battle (summons,
|
||
* no board uid) count once per unit type (the highest of them) — a result can never announce more units than the
|
||
* lineup has kinds of.
|
||
*/
|
||
_charDamageTickers(ps, r) {
|
||
const steps = (Array.isArray(this.gd.config.broadcasts) ? this.gd.config.broadcasts : []).filter((b) => b.type === 'CHAR_DAMAGE' && Array.isArray(b.params)).map((b) => Number(b.params[0])).filter((n) => n > 0).sort((a, b) => b - a);
|
||
if (!steps.length) return;
|
||
const board = new Set();
|
||
for (const p of ps.board.values()) board.add(p.uid);
|
||
const best = new Map();
|
||
for (const u of r.unitStats || []) {
|
||
if (!u) continue;
|
||
const key = Number.isInteger(u.uid) && board.has(u.uid) ? `uid:${u.uid}` : `def:${u.defId}`;
|
||
const cur = best.get(key);
|
||
if (!cur || (Number(u.dmg) || 0) > (Number(cur.dmg) || 0)) best.set(key, u);
|
||
}
|
||
for (const u of best.values()) {
|
||
const hit = steps.find((s) => (u.dmg || 0) >= s);
|
||
if (hit) this.tickerFor('CHAR_DAMAGE', [ps.name, u.name || u.defId, String(hit)], { playerId: ps.playerId, param: String(hit) });
|
||
}
|
||
}
|
||
|
||
afterSettle() {
|
||
if (!this.alivePlayers().length) { this.finish({ victory: false, reason: 'eliminated' }); return; }
|
||
this.startRound(this.round + 1);
|
||
}
|
||
|
||
// ===================================================================================================
|
||
// FINAL_ASSAULT / HIDDEN_CORE
|
||
|
||
startFinalAssault(hidden) {
|
||
const alive = this.alivePlayers();
|
||
if (!alive.length) { this.finish({ victory: false, reason: 'eliminated' }); return; }
|
||
this.phase = hidden ? PHASE.HIDDEN_CORE : PHASE.FINAL_ASSAULT;
|
||
this.lastResults = new Map();
|
||
if (!hidden) {
|
||
this.teamLp = alive.reduce((s, p) => s + Math.max(0, p.lp), 0);
|
||
for (const ps of alive) ps.lpAtFinal = Math.max(0, ps.lp);
|
||
}
|
||
const bossId = hidden ? this.hiddenBossId : this.bossId;
|
||
const hitSteps = new Map();
|
||
this.bossPool = new SharedBossPool(bossPoolHp(this.gd, bossId, alive.length), {
|
||
onHit: (pid, dmg) => {
|
||
const ps = this.players.get(pid);
|
||
if (!ps) return;
|
||
ps.stats.bossDamage += dmg;
|
||
const share = ps.stats.bossDamage / this.bossPool.maxHp;
|
||
const done = hitSteps.get(pid) || 0;
|
||
let reached = done;
|
||
BOSS_HIT_STEPS.forEach((s, i) => { if (share >= s) reached = Math.max(reached, i + 1); });
|
||
if (reached > done) {
|
||
hitSteps.set(pid, reached);
|
||
this.tickerFor('BOSS_HIT', [ps.name], { playerId: pid, param: String(BOSS_HIT_STEPS[reached - 1]) });
|
||
}
|
||
},
|
||
});
|
||
const groups = pairPlayers(alive);
|
||
const reuse = this.bossWaves && this.bossWaves.length === groups.length && this.bossWaves.every((w, i) => w.players.join() === groups[i].map((p) => p.playerId).join());
|
||
this.fields = groups.map((g, i) => {
|
||
const solo = this.isSolo || g.length === 1;
|
||
const wave = reuse ? this.bossWaves[i].wave : buildBossWave(this.gd, this.rngWaves, this.factions, this.round, { bossId, solo });
|
||
// one spawn list per field, shared by the field's players' onBattleStart handlers (edit it in place)
|
||
const spawns = wave.spawns.map((s) => ({ ...s, mods: s.mods ? { ...s.mods } : undefined }));
|
||
// bounties with battles left (a multi-round card lasts MULTI_ROUND_BOUNTY_BATTLES) follow their player into the
|
||
// boss field, on the player's half
|
||
g.forEach((ps, j) => {
|
||
for (const b of bountySpawns(this.gd, this.round, wave, ps.bounties, ps.playerId, { solo: this.isSolo, side: j === 0 ? 'L' : 'R' })) spawns.push(b);
|
||
});
|
||
const inputs = g.map((ps, j) => {
|
||
const input = ps.battleInput({ side: j === 0 ? 'L' : 'R', colOffset: j === 0 ? 0 : 8 });
|
||
input.lpForBoss = this.teamLp;
|
||
const ev = { input, kind: hidden ? 'hidden' : 'boss', round: this.round, spawns };
|
||
this.dispatch(ps, 'onBattleStart', ev);
|
||
return ev.input && typeof ev.input === 'object' ? ev.input : input;
|
||
});
|
||
const fieldId = `b${i + 1}`;
|
||
const bopts = {
|
||
seed: deriveSeed(this.seed, `${fieldId}:${this.round}`),
|
||
kind: hidden ? 'hidden' : 'boss',
|
||
modeId: this.modeId,
|
||
round: this.round,
|
||
stageId: this.stageId,
|
||
rect: { ...GEO.BOSS_RECT },
|
||
timeLimit: Infinity,
|
||
players: inputs,
|
||
spawns: this._sanitizeSpawns(spawns),
|
||
routes: wave.routes,
|
||
sharedBoss: this.bossPool,
|
||
flags: { layerGainsEnabled: false, ...this.gd.dp },
|
||
fieldId,
|
||
enemyOverrides: wave.overrides,
|
||
waveId: wave.templateId,
|
||
bossId,
|
||
};
|
||
if (this.clientCombat) return { fieldId, kind: hidden ? 'hidden' : 'boss', players: g.map((p) => p.playerId), opts: bopts, battle: null, live: true };
|
||
const battle = this.newBattle(bopts);
|
||
try {
|
||
battle.on('enemyLeak', (ctx) => this._bossLeak(ctx && ctx.enemy), { priority: -1000, owner: 'match' });
|
||
// leader "扣除目标生命" effects (boss_7 Doom, 斥退 …: server/sim/content/bosses.js lpLoss) hit the team pool
|
||
battle.on('lpLoss', (ctx) => this._teamLpLoss(ctx && ctx.amount), { priority: -1000, owner: 'match' });
|
||
} catch (e) { this.reportError('boss leak hook', e); }
|
||
return { fieldId, kind: hidden ? 'hidden' : 'boss', players: g.map((p) => p.playerId), battle, live: true };
|
||
});
|
||
this.overtimeApplied = 0;
|
||
// HUD: the boss level's countdown (maxPlayTime, 120 real s — the battle goes on past it) and the moment the
|
||
// overtime drain starts (150 real s), both on the field clock
|
||
const onClock = (realS) => this.sched.now() + Math.round(((realS * this.gd.combatTimeScale) / this.gameSpeed) * 1000);
|
||
const levelTime = this.gd.bossLevelTime(this.round);
|
||
this.deadline = this.sched.instant || !levelTime ? 0 : onClock(levelTime);
|
||
this.overtimeAt = this.sched.instant ? 0 : onClock(this.gd.bossOvertimeAfterReal);
|
||
if (this.clientCombat) { this._startFinalClient(hidden); return; }
|
||
this._defaultWatch();
|
||
this.markPublic();
|
||
this.runner = new FieldRunner(this, this.fields, {
|
||
onTick: (runner) => this._bossTick(runner),
|
||
onDone: (runner) => this._finalDone(runner, hidden),
|
||
});
|
||
this.runner.start();
|
||
}
|
||
|
||
/**
|
||
* Bounties after a boss field: kill-bounty coins go to pending funds (spent in the Hidden Core's prep) and every
|
||
* bounty used one of its battles, exactly like SETTLE does for normal rounds.
|
||
*/
|
||
_settleBossBounties(ps, pp) {
|
||
const coins = Math.max(0, Math.trunc(Number(pp.coins) || 0));
|
||
if (coins > 0) { ps.pendingFunds += coins; ps.stats.fundsGained += coins; }
|
||
if (!ps.bounties.length) return;
|
||
for (const b of ps.bounties) b.roundsLeft--;
|
||
ps.bounties = ps.bounties.filter((b) => b.roundsLeft > 0);
|
||
ps.dirty();
|
||
}
|
||
|
||
_bossLeak(enemy) {
|
||
if (!enemy || this.teamLp == null) return;
|
||
// lifePointReduce from data: 0 for harmless enemies (e.g. 装置 / unharmful escorts), 1 when absent
|
||
const lpr = Number.isFinite(enemy.lpr) && enemy.lpr >= 0 ? enemy.lpr : 1;
|
||
this._teamLpLoss(lpr);
|
||
}
|
||
|
||
_teamLpLoss(amount) {
|
||
const n = Number(amount);
|
||
if (this.teamLp == null || !Number.isFinite(n) || !(n > 0)) return;
|
||
this.teamLp = Math.max(0, this.teamLp - n);
|
||
if (this._bossLazyPublic()) { this._bossPublic(); return; }
|
||
this._syncTeamLp();
|
||
this.markPublic();
|
||
}
|
||
|
||
/** Boss rounds under client-side combat: m.public / the per-player LP shares refresh at ~1 Hz (b.pool is live). */
|
||
_bossLazyPublic() {
|
||
return this.clientCombat && (this.phase === PHASE.FINAL_ASSAULT || this.phase === PHASE.HIDDEN_CORE) && this.fields.some((f) => f.cc);
|
||
}
|
||
|
||
/** Throttled (BOSS_PUBLIC_MS) refresh of m.public and the players' LP shares during a boss round. */
|
||
_bossPublic() {
|
||
if (this._bossPubTimer || this.disposed || this.ended) return;
|
||
const wait = Math.max(0, BOSS_PUBLIC_MS - (this.sched.now() - this._bossPubAt));
|
||
this._bossPubTimer = this.later(wait, () => {
|
||
this._bossPubTimer = null;
|
||
this._bossPubAt = this.sched.now();
|
||
this._syncTeamLp();
|
||
this.markPublic();
|
||
});
|
||
}
|
||
|
||
/**
|
||
* The merged team LP (Final Assault / Hidden Core, research 01 §9) written back to the alive players as shares of
|
||
* what each brought in (`lpAtFinal`, largest remainder, ties → seat), so m.public / m.private / m.result never show
|
||
* the pre-merge LP of a team that lost LP in the boss fight (Σ alive lp = round(teamLp)).
|
||
*/
|
||
_syncTeamLp() {
|
||
if (this.teamLp == null) return;
|
||
const alive = this.alivePlayers().filter((p) => p.lpAtFinal != null);
|
||
if (!alive.length) return;
|
||
const total = Math.max(0, Math.round(this.teamLp));
|
||
const base = alive.reduce((s, p) => s + Math.max(0, p.lpAtFinal), 0);
|
||
const rows = alive.map((p) => {
|
||
const v = base > 0 ? (total * Math.max(0, p.lpAtFinal)) / base : total / alive.length;
|
||
const n = Math.floor(v + 1e-9);
|
||
return { p, n, frac: v - n };
|
||
});
|
||
let left = total - rows.reduce((s, x) => s + x.n, 0);
|
||
for (const x of rows.slice().sort((a, b) => b.frac - a.frac || a.p.seat - b.p.seat)) {
|
||
if (left <= 0) break;
|
||
x.n++;
|
||
left--;
|
||
}
|
||
for (const x of rows) if (x.p.lp !== x.n) { x.p.lp = x.n; x.p.dirty(); }
|
||
}
|
||
|
||
/**
|
||
* Overtime drain on the boss field clock (`gt` game seconds): 1 team LP per real second from the 150 real-second mark
|
||
* (bossTurnHpReduceTime, gamedata.js bossOvertimeDue).
|
||
*/
|
||
_applyOvertime(gt) {
|
||
const due = this.gd.bossOvertimeDue(gt);
|
||
if (due > this.overtimeApplied) {
|
||
const loss = due - this.overtimeApplied;
|
||
this.overtimeApplied = due;
|
||
this._teamLpLoss(loss);
|
||
}
|
||
}
|
||
|
||
_bossTick(runner) {
|
||
this._applyOvertime(runner.time);
|
||
if (this.teamLp <= 0 && this.bossPool.hp > 0) runner.forceAll('forced');
|
||
// client-side combat with every boss field on the server: humans that reconnect / watch run display replicas
|
||
// whose LocalBossPool follows b.pool
|
||
if (this.fields.some((f) => f.cc)) { this._broadcastPool(false); this._bossPublic(); }
|
||
else if (runner.ticks % 6 === 0) this.markPublic();
|
||
if (runner.ticks % 30 === 0) this.flush();
|
||
}
|
||
|
||
_finalDone(runner, hidden) { this._finishFinal(hidden, (f) => runner.resultOf(f)); }
|
||
|
||
/** Every boss field is over: stats, bounties, then the Hidden Core or RESULT. */
|
||
_finishFinal(hidden, resultOf) {
|
||
if (this.phase !== (hidden ? PHASE.HIDDEN_CORE : PHASE.FINAL_ASSAULT)) return;
|
||
this._stopClientCombat();
|
||
for (const f of this.fields) {
|
||
const res = resultOf(f);
|
||
this._collectSimErrors(f, res);
|
||
f.live = false;
|
||
for (const pid of f.players) {
|
||
const pp = res.perPlayer && res.perPlayer[pid];
|
||
const ps = this.players.get(pid);
|
||
if (pp) this.lastResults.set(pid, pp);
|
||
if (pp && ps) {
|
||
ps.stats.dmgDealt += Number(pp.damageDealt) || 0;
|
||
ps.stats.kills += Number(pp.killed) || 0;
|
||
ps.dirty(); // m.private.stats
|
||
this._charDamageTickers(ps, pp);
|
||
}
|
||
if (pp && ps) this._settleBossBounties(ps, pp);
|
||
if (pp && ps) this.dispatch(ps, 'onBattleResult', { result: pp, lpLoss: 0, perfect: !!pp.perfect, boss: true });
|
||
}
|
||
}
|
||
// the end condition the server registered first decides (client-side combat: _endFinal — pool 0 → victory, team LP 0
|
||
// → defeat); a boss field's final result may never turn a defeat into a victory (user playtest #6 item 5)
|
||
const victory = this._finalEnding ? this._finalEnding === 'cleared' : this.bossPool.hp <= 0;
|
||
this._syncTeamLp();
|
||
this.deadline = 0;
|
||
this.overtimeAt = 0;
|
||
this.markPublic();
|
||
this.runner = null;
|
||
if (!hidden) {
|
||
const eligible = victory && !!this.hiddenBossId && hiddenEligible(this.gd, { layerSum: this.hiddenLayerSum, teamLp: this.teamLp });
|
||
this.later(this.scaled(DELAYS.SETTLE), () => {
|
||
if (eligible) {
|
||
this.hiddenReached = true;
|
||
this.bossPool = null;
|
||
this.tickerText('隐秘核心已解锁');
|
||
this.startRound(this.gd.hiddenRound);
|
||
} else {
|
||
this.finish({ victory, reason: victory ? 'victory' : 'defeat' });
|
||
}
|
||
});
|
||
} else {
|
||
this.later(this.scaled(DELAYS.SETTLE), () => this.finish({ victory: true, hiddenCleared: victory, reason: 'victory' }));
|
||
}
|
||
}
|
||
|
||
// ===================================================================================================
|
||
// RESULT
|
||
|
||
finish({ victory, hiddenCleared = false, reason = 'defeat' }) {
|
||
if (this.ended || this.disposed) return;
|
||
this.ended = true;
|
||
if (this.runner) { try { this.runner.stop(); } catch { /* ignore */ } this.runner = null; }
|
||
this._stopClientCombat();
|
||
this.cancel(this._phaseTimer);
|
||
this.cancel(this._turnTimer);
|
||
for (const h of this._timers) { try { this.sched.clearTimeout(h); } catch { /* ignore */ } }
|
||
this._timers.clear();
|
||
this.phase = PHASE.RESULT;
|
||
this.deadline = 0;
|
||
for (const f of this.fields) f.live = false;
|
||
this.outcome = { victory: !!victory, hiddenReached: this.hiddenReached, hiddenCleared: !!hiddenCleared, reason };
|
||
let result;
|
||
try {
|
||
result = buildResult(this, this.outcome);
|
||
} catch (e) {
|
||
this.reportError('buildResult', e);
|
||
result = { t: 'm.result', victory: !!victory, roundsPassed: 0, reason, modeId: this.modeId, difficulty: this.difficulty, players: [] };
|
||
}
|
||
this.lastResultMsg = result;
|
||
this.markPublic();
|
||
try { this.flush(true); } catch (e) { this.reportError('flush', e); }
|
||
for (const ps of this.order) if (!ps.isBot && !ps.left) this.sendTo(ps.playerId, { ...result, playerId: ps.playerId });
|
||
const { t, ...summary } = result;
|
||
void t;
|
||
summary.errors = this.errorCount;
|
||
try { this.onEndFn(summary); } catch (e) { this.reportError('onEnd', e); }
|
||
}
|
||
}
|