From 1ca4936f0a541cabda9acae5fe6f9213df4b5e6a Mon Sep 17 00:00:00 2001 From: sganggs Date: Mon, 5 Oct 2026 17:10:30 +0800 Subject: [PATCH] =?UTF-8?q?feedback5=20R2:=20Match.js=20=E2=86=92=20server?= =?UTF-8?q?/match/match/*.js,=20a=20pure=20move=20=E2=80=94=20the=20167=20?= =?UTF-8?q?class=20members=20(166=20methods=20and=20the=20soloUntimed=20ge?= =?UTF-8?q?tter),=20verbatim=20with=20their=20comments,=20go=20to=2016=20p?= =?UTF-8?q?er-concern=20method=20containers=20(platform,=20infra,=20messag?= =?UTF-8?q?ing,=20views,=20watch,=20intents,=20pause,=20phases,=20spDraft,?= =?UTF-8?q?=20prep,=20combat,=20clientCombat,=20reports,=20unitePhase,=20b?= =?UTF-8?q?ossRounds,=20settle)=20that=20Match.js=20installs=20on=20Match.?= =?UTF-8?q?prototype=20in=20a=20fixed=20order=20with=20class-method=20desc?= =?UTF-8?q?riptors=20(a=20name=20defined=20twice=20throws);=20the=20consta?= =?UTF-8?q?nts=20the=20modules=20share=20go=20to=20match/common.js=20(FLOW?= =?UTF-8?q?=5FTICKER=5FPRIORITY,=20DELAYS,=20BAND=5FTURN=5FSECONDS=20re-ex?= =?UTF-8?q?ported=20by=20Match.js;=20OK,=20fail,=20BOSS=5FCLOCK=5FMS),=20G?= =?UTF-8?q?AME=5FTYPES=20to=20platform.js,=20the=20boss-round=20budgets=20?= =?UTF-8?q?/=20rates=20to=20bossRounds.js;=20Match.js=20keeps=20the=20head?= =?UTF-8?q?er=20(+=20a=20code-layout=20paragraph),=20the=20constructor-onl?= =?UTF-8?q?y=20constants=20and=20the=20class=20with=20its=20constructor.?= =?UTF-8?q?=20Section-header=20comments=20are=20replaced=20by=20the=20modu?= =?UTF-8?q?le=20headers.=20docs-consistency=20reads=20the=20method=20modul?= =?UTF-8?q?es=20(matchText)=20and=20pins=20layerGainRoom=20in=20match/sett?= =?UTF-8?q?le.js?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/match/Match.js | 2976 +--------------------------- server/match/match/bossRounds.js | 516 +++++ server/match/match/clientCombat.js | 233 +++ server/match/match/combat.js | 166 ++ server/match/match/common.js | 31 + server/match/match/infra.js | 129 ++ server/match/match/intents.js | 123 ++ server/match/match/messaging.js | 98 + server/match/match/pause.js | 72 + server/match/match/phases.js | 272 +++ server/match/match/platform.js | 202 ++ server/match/match/prep.js | 139 ++ server/match/match/reports.js | 133 ++ server/match/match/settle.js | 133 ++ server/match/match/spDraft.js | 194 ++ server/match/match/unitePhase.js | 145 ++ server/match/match/views.js | 240 +++ server/match/match/watch.js | 218 ++ test/docs-consistency.test.js | 8 +- 19 files changed, 3105 insertions(+), 2923 deletions(-) create mode 100644 server/match/match/bossRounds.js create mode 100644 server/match/match/clientCombat.js create mode 100644 server/match/match/combat.js create mode 100644 server/match/match/common.js create mode 100644 server/match/match/infra.js create mode 100644 server/match/match/intents.js create mode 100644 server/match/match/messaging.js create mode 100644 server/match/match/pause.js create mode 100644 server/match/match/phases.js create mode 100644 server/match/match/platform.js create mode 100644 server/match/match/prep.js create mode 100644 server/match/match/reports.js create mode 100644 server/match/match/settle.js create mode 100644 server/match/match/spDraft.js create mode 100644 server/match/match/unitePhase.js create mode 100644 server/match/match/views.js create mode 100644 server/match/match/watch.js diff --git a/server/match/Match.js b/server/match/Match.js index f7b43932..b9c33537 100644 --- a/server/match/Match.js +++ b/server/match/Match.js @@ -128,9 +128,35 @@ // 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'. +// +// Code layout: this file keeps the constructor (options, seats, the per-match setup, the state fields); the methods +// live in server/match/match/, one module per concern, and are installed on Match.prototype below in a fixed order, +// with the descriptors of class methods (non-enumerable; `this` is the match, subclasses and `super` calls work as +// before): +// platform.js the interface above: start, handle, setLoadout, disconnect / reconnect / resync, leave, dispose +// infra.js error isolation, timers and the phase deadline, player lists, bonds in the pool, deploy field, +// meta dispatch +// messaging.js send / broadcast / toasts / tickers, dirty marks, flush (m.private per player, m.public throttled) +// views.js m.public (statuses, field progress, pendingLp / uniteLeft), the nextEnemies preview, the prep +// scout's m.field +// watch.js spectator seats, g.watch, who is shown which field, resend on reconnect, prep-scout pushes +// intents.js the g.* / b.* router, emotes, AI 托管, g.unitStats +// pause.js the solo pause +// phases.js INFO_CHECK, BAND_DRAFT (the strategy draft), BATTLE_CHECK, ROUND_START +// spDraft.js SP_DRAFT (机变) and the rolls the meta effects ask for (bounties, item ids, pools) +// prep.js PREP: its start, the AI seats' sliced preps, Ready, the deadline, its end +// combat.js battle construction, COMBAT (both modes), the per-field results, then 联防 or SETTLE +// clientCombat.js client-side combat bookkeeping: field records, the field clock, authority, b.start, result +// deadlines and releases, server runs, field completion +// reports.js b.progress / b.result (validation, held boss results), SP_VERIFY +// unitePhase.js 联防 (UNITE) in both modes and the leakers' live counts +// bossRounds.js FINAL_ASSAULT / HIDDEN_CORE: boss fields, the shared pool, plausibility budgets, the boss clock, +// b.pool, the merged team LP, overtime, bounties after a boss field +// settle.js SETTLE and RESULT +// common.js what the modules share (FLOW_TICKER_PRIORITY, DELAYS, BAND_TURN_SECONDS — re-exported here, OK, +// fail, BOSS_CLOCK_MS) -import { C2S, unitStatsEntry } from '../../shared/protocol.js'; -import { PHASE, ERR, EMOTES, EMOTE_COOLDOWN_MS, GEO, modeIdFor, layerGainRoom } from '../../shared/constants.js'; +import { PHASE, modeIdFor } from '../../shared/constants.js'; import { Battle } from '../sim/Battle.js'; import { DataSource } from '../sim/simdata.js'; import { createRng, deriveSeed } from '../sim/rng.js'; @@ -138,33 +164,31 @@ 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, offBondCounts } 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'; +import { setupMatchWaves } from './waves.js'; +import { GAME_SPEED, HEADLESS_SLICE_MS } from './fields.js'; +import { MatchPlatform } from './match/platform.js'; +import { MatchInfra } from './match/infra.js'; +import { MatchMessaging } from './match/messaging.js'; +import { MatchViews } from './match/views.js'; +import { MatchWatch } from './match/watch.js'; +import { MatchIntents } from './match/intents.js'; +import { MatchPause } from './match/pause.js'; +import { MatchPhases } from './match/phases.js'; +import { MatchSpDraft } from './match/spDraft.js'; +import { MatchPrep } from './match/prep.js'; +import { MatchCombat } from './match/combat.js'; +import { MatchClientCombat } from './match/clientCombat.js'; +import { MatchReports } from './match/reports.js'; +import { MatchUnite } from './match/unitePhase.js'; +import { MatchBoss } from './match/bossRounds.js'; +import { MatchSettle } from './match/settle.js'; + +export { FLOW_TICKER_PRIORITY, DELAYS, BAND_TURN_SECONDS } from './match/common.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; -/** - * Ticker priority of the remake's match-flow notices (隐秘核心已解锁, 联防阶段, a player out or gone): the official lines - * (research 06 §9.2) go BOSS_HIT 30 > CHAR_DAMAGE 20 > SHOP_LEVEL 11 > GOLDEN_CHAR 2 > CHAR_GIFT 1; ours sit under the - * leader-damage lines [ASSUMED]. - */ -export const FLOW_TICKER_PRIORITY = 25; -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'; @@ -173,51 +197,7 @@ 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) { @@ -381,2857 +361,13 @@ export class Match { 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) || this.spectators.get(playerId); - if (!ps || ps.isBot || ps.left) return fail(ERR.NOT_IN_ROOM); - // a spectator seat only watches (the platform routes nothing else of it) - if (ps.spectator && (!msg || msg.t !== 'g.watch')) return fail(ERR.SPECTATOR); - 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 | 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._resync(ps); - if (!was) this.markPublic(); - }); - } - - /** - * The full state of one human (a reconnect, a resync, a spectator seat): m.public, its m.private (players only), the - * field it is on / watches — a spectator, like an eliminated player, the first field — or the result once ended. - */ - _resync(ps) { - const playerId = ps.playerId; - this.sendTo(playerId, this.publicView()); - if (!this.ended) { - if (!ps.spectator) { - ps._lastPriv = null; - this._sendPrivate(ps, true); - } - if (this.clientCombat) this._resendBattle(ps); - else { - let fid = this.watchers.get(playerId); - if (!fid && ps.spectator && this.fields.length) { fid = this.fields[0].fieldId; this.watchers.set(playerId, fid); } - if (fid) this._sendField(playerId, fid); - } - } else if (this.lastResultMsg) { - this.sendTo(playerId, { ...this.lastResultMsg, playerId }); - } - } - - /** - * A spectator seat (community report #26; server/lobby.js spectate) joined during the match, came back or asked for a - * resync: registered once, then resent what an eliminated player watching sees (_resync — never an m.private). - */ - addSpectator(playerId) { - if (this.disposed) return; - const s = this._spectator(playerId); - if (s) this.guard(() => this._resync(s)); - } - - /** The spectator left (room.leave / g.leave, removed by the host, reconnect window expired). */ - removeSpectator(playerId) { - if (this.spectators.delete(playerId)) this.watchers.delete(playerId); - } - - /** The stand-in of a spectator seat, created once (null for a player's id or a bad id). */ - _spectator(playerId) { - if (typeof playerId !== 'string' || !playerId || this.players.has(playerId)) return null; - let s = this.spectators.get(playerId); - if (!s) { - s = Object.freeze({ playerId, isBot: false, left: false, alive: false, connected: true, spectator: true }); - this.spectators.set(playerId, s); - } - return s; - } - - /** Everyone shown fields: the seated humans still in (seat order), then the spectator seats' stand-ins. */ - _viewers() { - const out = this.order.filter((ps) => !ps.isBot && !ps.left); - for (const s of this.spectators.values()) out.push(s); - return out; - } - - 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}博士中途退出了模拟`, FLOW_TICKER_PRIORITY); - 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) || this.spectators.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); - } - - /** - * A ticker line of the remake's own (type CUSTOM). `priority`: the match-flow notices (隐秘核心已解锁, 联防阶段, a player out - * or gone) take FLOW_TICKER_PRIORITY so the strip does not hold them behind shop-level lines; other lines 0. - */ - tickerText(text, priority = 0) { - if (!text) return; - this.broadcast({ t: 'm.ticker', text: String(text).slice(0, 200), id: null, type: 'CUSTOM', priority: Number(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)) this._sendPrivate(ps, false); - this._notifyPrepScouts(ps); - } - } - 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 - // (the mode-off bonds with members included, `off: true`, as in m.private — bondsMeta.offBondCounts) - bonds: ps.alive ? bondList(this.gd, ps.bondsView(), { off: offBondCounts(this.gd, ps) }) : [], - 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 and hand (prep scouting): board pieces on their tiles, held pieces on the - * hand row (row 7) — the scout renders like the own prep bench. */ - 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, - }); - } - // the hand (整备区) and the 临时整备区 scout exactly like the own prep bench renders them: pieces as units on - // their rows (hand row 7, col = hand slot; temp row 8, cols 4..8 = temp slots; no dir — bench pieces face right), - // items included (the client draws their floating plates). PRTS 帮助 counts the temp area with the hand (review of - // PR #129). Part of the meta for every watcher alike — the spectator seat's copy equals a teammate's - // (test/match/spectator.test.js). User playtest #2 item 1 (GitHub #44). - const benchUnit = (piece, i, y) => { - const rec = piece.kind === 'item' ? this.gd.item(piece.id) : piece.kind === 'token' ? this.gd.token(piece.id) : this.gd.chess(piece.id); - const assets = (rec && rec.assets) || {}; - const lo = piece.kind === 'chess' && rec ? ps.loadoutFor(rec) : null; - units.push({ - id: piece.uid, uid: piece.uid, kind: piece.kind === 'token' ? 'token' : piece.kind === 'item' ? 'item' : '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: i, y, 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, - items: piece.kind === 'chess' && Array.isArray(piece.items) && piece.items.length ? piece.items.map((it) => it.id) : undefined, - }); - }; - for (let i = 0; i < ps.hand.length; i++) { - if (ps.hand[i]) benchUnit(ps.hand[i], i, GEO.HAND_ROW); - } - for (let i = 0; i < ps.temp.length; i++) { - if (ps.temp[i]) benchUnit(ps.temp[i], GEO.TEMP_C0 + i, GEO.TEMP_ROW); - } - // `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); } - // the scouted player's effects column (策略 / 机变 / 悬赏 …), display-ready (user playtest #2: while scouting, the - // right column shows the watched player's effects, not one's own) - return { t: 'm.field', fieldId: `n:${ps.playerId}`, kind: 'normal', rect: { ...GEO.NORMAL_RECT }, stageId: this.stageId, units, effects: ps.effectsView(), prep: true, nextEnemies }; - } - - /** Board signature of a prep scout view (units and hand: a shop or funds change is not a board change). */ - /** Board signature of a prep scout view (board, hand and temp rows: a shop or funds change is not a board change). - * Hand / temp entries carry their slot — `prepFieldMeta` draws x from it, so a piece moved to another slot is a - * change (review of PR #129). */ - _prepScoutSig(ps) { - const parts = []; - for (const { r, c, piece } of boardOrder(ps.board)) { - const items = piece.kind === 'chess' && Array.isArray(piece.items) ? piece.items.map((it) => `${it.uid}:${it.id}`).join(',') : ''; - parts.push(`${piece.uid}:${piece.id}@${r},${c}:${pieceDir(piece)}:${items}`); - } - for (let i = 0; i < ps.hand.length; i++) { - const piece = ps.hand[i]; - if (!piece) continue; - const items = piece.kind === 'chess' && Array.isArray(piece.items) ? piece.items.map((it) => `${it.uid}:${it.id}`).join(',') : ''; - parts.push(`h${i}:${piece.uid}:${piece.id}:${items}`); - } - for (let i = 0; i < ps.temp.length; i++) { - const piece = ps.temp[i]; - if (!piece) continue; - const items = piece.kind === 'chess' && Array.isArray(piece.items) ? piece.items.map((it) => `${it.uid}:${it.id}`).join(',') : ''; - parts.push(`t${i}:${piece.uid}:${piece.id}:${items}`); - } - return parts.join(';'); - } - - /** - * Push prepFieldMeta to whoever is scouting `ps` during prep (GitHub #87). `to` always receives the current board - * (the player who just asked to watch); everyone scouting it receives a new `m.field` only when the board changed. - * A live battle field owns the `n:` id, so this stays quiet once fields exist. - */ - _notifyPrepScouts(ps, { to = null } = {}) { - if (!ps || !ps.alive || this.fields.length) return; - const fid = `n:${ps.playerId}`; - const watchers = this.watchersOf(fid); - if (!watchers.length) return; - const sig = this._prepScoutSig(ps); - const changed = sig !== ps._prepScoutSig; - ps._prepScoutSig = sig; - const dest = changed ? watchers : (to ? [to] : []); - if (!dest.length) return; - const meta = this.prepFieldMeta(ps); - for (const pid of dest) this.sendTo(pid, meta); - } - - _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, only while no battle field is up: during 各自行动 / 联防 / 最终攻势 / 隐秘核心 an 'n:' 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). The scout stays in `watchers` so a later board change pushes prepFieldMeta again - // (GitHub #87); combat start clears the map and reassigns live fields, spectators included. - 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.set(ps.playerId, fieldId); - this._notifyPrepScouts(target, { to: ps.playerId }); - 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 }); - // an eliminated player's pending 信标 gift still goes to its teammate (effects flagged afterElimination; GitHub #86) - for (const ps of this.order) { - if (ps.alive) continue; - try { this.dispatcher.dispatchEliminated(ps, 'onRoundStart', { round: r }); } catch (e) { this.reportError('dispatch onRoundStart (eliminated)', e); } - } - 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; - // Items gained as the previous prep ended waited unmerged (acquireItem deferMerge). Merge them now, before - // this prep's onPrepStart grants and before the player acts — not in endPrep, which runs in the same prep - // that granted them and would take an equipped copy off for the fight about to start. - ps.checkItemMerges(); - ps.recompute(); - 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._viewers()) { - 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('、')} 迎战突破防线的敌人`, FLOW_TICKER_PRIORITY); - 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; - } - - /** - * The spec a spectator seat is shown: the field's own, minus the players' `contentInfo.funds` — a private number (the - * player's funds at the battle start) that no battle effect reads, so the replica still plays the same battle. The - * other contentInfo counters stay: battle effects read them (sim/content: handUnits, roundStats.gainedChess). - */ - _spectatorSpec(f) { - if (!f.spectatorSpec) { - const s = f.spec; - const strip = (p) => { - if (!p || !p.contentInfo || !Object.hasOwn(p.contentInfo, 'funds')) return p; - const { funds, ...contentInfo } = p.contentInfo; - void funds; - return { ...p, contentInfo }; - }; - f.spectatorSpec = s && Array.isArray(s.players) ? { ...s, players: s.players.map(strip) } : s; - } - return f.spectatorSpec; - } - - /** 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: this.spectators.has(pid) ? this._spectatorSpec(f) : 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) || this.spectators.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, spectator seats included). */ - _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) || this.spectators.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 (and spectator seats) keep watching (research 09 §3.1 - // "Keep-watching auto-observes the first available field", switching freely with 前往查看): a 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._viewers()) { - if (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, spectator seats included) simulate the same 联防 spec locally - for (const ps of this._viewers()) { - 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('、')} 迎战突破防线的敌人`, FLOW_TICKER_PRIORITY); - } - - _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 and spectator seats the first. */ - _watchBossFields(fields) { - for (const ps of this._viewers()) { - 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}博士的目标生命值已耗尽`, FLOW_TICKER_PRIORITY); - } - } - 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; - // BOSS_HIT tickers ("对敌方领袖造成的伤害超过20% / 50% / 80%"): the player's damage to THIS leader over its pool — - // the pool's own per-player tally, one pool per boss round. stats.bossDamage (the result's 领袖伤害) adds up both - // rounds, so it would credit the Final Assault's damage to the hidden leader ("隐藏boss还没打就出了50%播报"). - const hitSteps = new Map(); - const pool = 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 = (pool.byPlayer.get(pid) || 0) / pool.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]) }); - } - }, - }); - this.bossPool = pool; - 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; - // `side` + `routes`: the player's half of a pair field (spawn-list edits for one player, e.g. 鸭爵's swap) - const ev = { input, kind: hidden ? 'hidden' : 'boss', round: this.round, spawns, routes: wave.routes, side: g.length > 1 ? (j === 0 ? 'L' : 'R') : null }; - 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('隐秘核心已解锁', FLOW_TICKER_PRIORITY); - 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); } - // every human still here gets the settlement — the spectator seats the same public rows (none of them their own) - for (const ps of this._viewers()) 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); } +} + +// the method modules, in this order (a name defined twice is an error, never a silent override) +for (const part of [MatchPlatform, MatchInfra, MatchMessaging, MatchViews, MatchWatch, MatchIntents, MatchPause, MatchPhases, MatchSpDraft, MatchPrep, MatchCombat, MatchClientCombat, MatchReports, MatchUnite, MatchBoss, MatchSettle]) { + for (const key of Reflect.ownKeys(part.prototype)) { + if (key === 'constructor') continue; + if (Object.prototype.hasOwnProperty.call(Match.prototype, key)) throw new Error(`Match.${String(key)} is defined twice`); + Object.defineProperty(Match.prototype, key, Object.getOwnPropertyDescriptor(part.prototype, key)); } } diff --git a/server/match/match/bossRounds.js b/server/match/match/bossRounds.js new file mode 100644 index 00000000..cfeee3b7 --- /dev/null +++ b/server/match/match/bossRounds.js @@ -0,0 +1,516 @@ +// server/match/match/bossRounds.js — Match methods: the Final Assault / Hidden Core (the rules: +// server/match/finalAssault.js) — the boss fields and the shared pool; client-side boss fields (plausibility budgets on +// the field clock, cumulative credits, handovers, the boss clock, the end: pool 0 → victory, team LP 0 → defeat); +// b.pool; the merged team LP and its shares; the overtime drain; bounties after a boss field; then the Hidden Core or +// RESULT. +// Installed on Match.prototype by server/match/Match.js (a method container: never instantiated; `this` is the match). + +import { PHASE, GEO } from '../../../shared/constants.js'; +import { deriveSeed } from '../../sim/rng.js'; +import { buildBossWave, bountySpawns } from '../waves.js'; +import { pairPlayers, bossPoolHp, SharedBossPool, hiddenEligible, BOSS_HIT_STEPS } from '../finalAssault.js'; +import { FieldRunner, HeadlessPacer, syntheticResult, BOSS_SILENCE_MS, CATCHUP_TICKS_PER_INTERVAL } from '../fields.js'; +import { CreditPool } from '../finalAssault.js'; +import { FLOW_TICKER_PRIORITY, BOSS_CLOCK_MS, DELAYS } from './common.js'; + +/** b.pool broadcasts at most this often (ms). */ +const POOL_MIN_GAP_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; + +export class MatchBoss { + 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; + // BOSS_HIT tickers ("对敌方领袖造成的伤害超过20% / 50% / 80%"): the player's damage to THIS leader over its pool — + // the pool's own per-player tally, one pool per boss round. stats.bossDamage (the result's 领袖伤害) adds up both + // rounds, so it would credit the Final Assault's damage to the hidden leader ("隐藏boss还没打就出了50%播报"). + const hitSteps = new Map(); + const pool = 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 = (pool.byPlayer.get(pid) || 0) / pool.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]) }); + } + }, + }); + this.bossPool = pool; + 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; + // `side` + `routes`: the player's half of a pair field (spawn-list edits for one player, e.g. 鸭爵's swap) + const ev = { input, kind: hidden ? 'hidden' : 'boss', round: this.round, spawns, routes: wave.routes, side: g.length > 1 ? (j === 0 ? 'L' : 'R') : null }; + 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(); + } + + _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()); + } + + /** 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); + } + + /** + * 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('隐秘核心已解锁', FLOW_TICKER_PRIORITY); + 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' })); + } + } +} diff --git a/server/match/match/clientCombat.js b/server/match/match/clientCombat.js new file mode 100644 index 00000000..3b78229d --- /dev/null +++ b/server/match/match/clientCombat.js @@ -0,0 +1,233 @@ +// server/match/match/clientCombat.js — Match methods: client-side combat bookkeeping (DESIGN §14) — field records (the +// JSON BattleSpec + the authority / result state), spec battles on the server, the field clock, the authority (the +// lowest connected seat), b.start per recipient, result deadlines and releases, server runs (headless slices, +// takeovers), field completion → the phase end, and the timers of it all. +// Installed on Match.prototype by server/match/Match.js (a method container: never instantiated; `this` is the match). + +import { PHASE } from '../../../shared/constants.js'; +import { DeadBattle, syntheticResult, RESULT_GRACE_MS, HARD_CAP_SECONDS, HeadlessJob } from '../fields.js'; +import { buildBattleSpec, createBattleFromSpec } from '../../sim/spec.js'; + +export class MatchClientCombat { + /** 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); + } + + /** 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: this.spectators.has(pid) ? this._spectatorSpec(f) : 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) || this.spectators.get(pid); + if (!ps || ps.isBot || ps.left || !ps.connected) return false; + return this.sendTo(pid, this._startMsg(f, pid, opts)); + } + + /** 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); + } + } +} diff --git a/server/match/match/combat.js b/server/match/match/combat.js new file mode 100644 index 00000000..2d0ee700 --- /dev/null +++ b/server/match/match/combat.js @@ -0,0 +1,166 @@ +// server/match/match/combat.js — Match methods: the battles of a normal round — Battle construction (newBattle, the +// spawn list sanitised, a player's normal field after the onBattleStart handlers), the COMBAT phase in both modes +// (server-run streaming, client-side combat), the per-field results and the sim-error log, then 联防 or SETTLE. +// Installed on Match.prototype by server/match/Match.js (a method container: never instantiated; `this` is the match). + +import { PHASE, GEO } from '../../../shared/constants.js'; +import { deriveSeed } from '../../sim/rng.js'; +import { withBounties } from '../waves.js'; +import { planUnite } from '../unite.js'; +import { FieldRunner, DeadBattle } from '../fields.js'; +import { DELAYS } from './common.js'; + +export class MatchCombat { + /** 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, + }; + } + + 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(); + } + + _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 (and spectator seats) keep watching (research 09 §3.1 + // "Keep-watching auto-observes the first available field", switching freely with 前往查看): a 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._viewers()) { + if (ps.alive || this.watchers.has(ps.playerId)) continue; + this.watchers.set(ps.playerId, first.fieldId); + this._sendStart(ps.playerId, first, { watch: true }); + } + } + this.markPublic(); + } + + /** 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); + } +} diff --git a/server/match/match/common.js b/server/match/match/common.js new file mode 100644 index 00000000..1d3f0da7 --- /dev/null +++ b/server/match/match/common.js @@ -0,0 +1,31 @@ +// server/match/match/common.js — constants and small helpers shared by the Match method modules +// (server/match/match/*.js); server/match/Match.js re-exports FLOW_TICKER_PRIORITY, DELAYS and BAND_TURN_SECONDS. + +/** + * Ticker priority of the remake's match-flow notices (隐秘核心已解锁, 联防阶段, a player out or gone): the official lines + * (research 06 §9.2) go BOSS_HIT 30 > CHAR_DAMAGE 20 > SHOP_LEVEL 11 > GOLDEN_CHAR 2 > CHAR_GIFT 1; ours sit under the + * leader-damage lines [ASSUMED]. + */ +export const FLOW_TICKER_PRIORITY = 25; +/** Boss clock period (overtime drain, end checks, silence watchdog) in real ms. */ +export const BOSS_CLOCK_MS = 250; +export const OK = Object.freeze({ ok: true }); +export 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; diff --git a/server/match/match/infra.js b/server/match/match/infra.js new file mode 100644 index 00000000..660fe34d --- /dev/null +++ b/server/match/match/infra.js @@ -0,0 +1,129 @@ +// server/match/match/infra.js — Match methods: infrastructure — error isolation (guard, reportError), timers tracked +// for dispose (later, cancel, scaled) and the phase deadline (setDeadline), soloUntimed, uids, the player lists +// (alivePlayers, humans), the bonds this match's pool holds (bondInPool, bondLive), a player's boss-round group, deploy +// field and deploy map, and the guarded meta dispatch (dispatch, dispatchItem → effectsMeta.js EffectDispatcher). +// Installed on Match.prototype by server/match/Match.js (a method container: never instantiated; `this` is the match). + +import { buildDeployMap } from '../board.js'; + +export class MatchInfra { + /** 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; } + } +} diff --git a/server/match/match/intents.js b/server/match/match/intents.js new file mode 100644 index 00000000..499447ad --- /dev/null +++ b/server/match/match/intents.js @@ -0,0 +1,123 @@ +// server/match/match/intents.js — Match methods: the g.* / b.* intent router (_handle: the prep intents go to +// PlayerState, the drafts, watching, the pause and the battle reports to the match), emotes, AI 托管 (setAutoplay, +// kickBot) and g.unitStats (the stats the board's units start their next battle with: a preview battle, started, read +// and dropped). +// Installed on Match.prototype by server/match/Match.js (a method container: never instantiated; `this` is the match). + +import { unitStatsEntry } from '../../../shared/protocol.js'; +import { PHASE, ERR, EMOTES, EMOTE_COOLDOWN_MS, GEO } from '../../../shared/constants.js'; +import { deriveSeed } from '../../sim/rng.js'; +import { OK, fail } from './common.js'; + +export class MatchIntents { + _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; + } + + setAutoplay(ps, on) { + if (ps.autoplay === on) return OK; + ps.autoplay = on; + this.markPublic(); + if (on) this.kickBot(ps); + return OK; + } + + /** 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); + } + + /** + * 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; + } +} diff --git a/server/match/match/messaging.js b/server/match/match/messaging.js new file mode 100644 index 00000000..6532cd7c --- /dev/null +++ b/server/match/match/messaging.js @@ -0,0 +1,98 @@ +// server/match/match/messaging.js — Match methods: messaging — unicast, broadcast, toasts, the ticker lines +// (config.broadcasts by type; the remake's CUSTOM lines), the dirty marks and flush: m.private per player only when it +// changed (plus the prep scouts of that player's board), m.public throttled (DELAYS.PUBLIC_THROTTLE) and deduplicated. +// Installed on Match.prototype by server/match/Match.js (a method container: never instantiated; `this` is the match). + +import { DELAYS } from './common.js'; + +export class MatchMessaging { + sendTo(playerId, msg) { + if (this.disposed) return false; + const ps = this.players.get(playerId) || this.spectators.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); + } + + /** + * A ticker line of the remake's own (type CUSTOM). `priority`: the match-flow notices (隐秘核心已解锁, 联防阶段, a player out + * or gone) take FLOW_TICKER_PRIORITY so the strip does not hold them behind shop-level lines; other lines 0. + */ + tickerText(text, priority = 0) { + if (!text) return; + this.broadcast({ t: 'm.ticker', text: String(text).slice(0, 200), id: null, type: 'CUSTOM', priority: Number(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)) this._sendPrivate(ps, false); + this._notifyPrepScouts(ps); + } + } + 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); + } +} diff --git a/server/match/match/pause.js b/server/match/match/pause.js new file mode 100644 index 00000000..72501d77 --- /dev/null +++ b/server/match/match/pause.js @@ -0,0 +1,72 @@ +// server/match/match/pause.js — Match methods: the solo pause (g.pause, DESIGN §14 "Solo pause") — freeze, resume +// (every clock and deadline shifted by the pause), the drop at the battle phase's end, and the field clocks' "now". +// Installed on Match.prototype by server/match/Match.js (a method container: never instantiated; `this` is the match). + +import { PHASE, ERR } from '../../../shared/constants.js'; +import { BOSS_CLOCK_MS, OK, fail } from './common.js'; + +export class MatchPause { + /** + * 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(); } +} diff --git a/server/match/match/phases.js b/server/match/match/phases.js new file mode 100644 index 00000000..25df77df --- /dev/null +++ b/server/match/match/phases.js @@ -0,0 +1,272 @@ +// server/match/match/phases.js — Match methods: the round flow up to the prep — INFO_CHECK, the strategy draft +// (BAND_DRAFT: one countdown of BAND_TURN_SECONDS per turn, 队友已选, the highlighted strategy on a timeout, skips), +// BATTLE_CHECK and ROUND_START (the round's enemies — a normal wave or the boss pairing — planned before the players' +// round start). +// Installed on Match.prototype by server/match/Match.js (a method container: never instantiated; `this` is the match). + +import { PHASE, ERR } from '../../../shared/constants.js'; +import { buildNormalWave, buildBossWave } from '../waves.js'; +import { pairPlayers } from '../finalAssault.js'; +import { botPickBand } from '../bot.js'; +import { OK, fail, DELAYS, BAND_TURN_SECONDS } from './common.js'; + +export class MatchPhases { + 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(); }); + } + } + + /** + * 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(); + } + + 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 }); + // an eliminated player's pending 信标 gift still goes to its teammate (effects flagged afterElimination; GitHub #86) + for (const ps of this.order) { + if (ps.alive) continue; + try { this.dispatcher.dispatchEliminated(ps, 'onRoundStart', { round: r }); } catch (e) { this.reportError('dispatch onRoundStart (eliminated)', e); } + } + 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(); + } +} diff --git a/server/match/match/platform.js b/server/match/match/platform.js new file mode 100644 index 00000000..96b4b233 --- /dev/null +++ b/server/match/match/platform.js @@ -0,0 +1,202 @@ +// server/match/match/platform.js — Match methods: the platform interface of the server/match/Match.js header +// (server/lobby.js ⇄ Match) — start, handle (validation, error isolation, flush; the intents themselves: intents.js +// _handle), setLoadout (INFO_CHECK only), onDisconnect / onReconnect and the full resync of one human, onLeave and 中途退出 +// = elimination (_quit), dispose. +// Installed on Match.prototype by server/match/Match.js (a method container: never instantiated; `this` is the match). + +import { C2S } from '../../../shared/protocol.js'; +import { PHASE, ERR } from '../../../shared/constants.js'; +import { syntheticResult } from '../fields.js'; +import { FLOW_TICKER_PRIORITY, OK, fail } from './common.js'; + +const GAME_TYPES = new Set(Object.keys(C2S).filter((t) => Object.hasOwn(C2S, t) && (t.startsWith('g.') || t.startsWith('b.')))); + +export class MatchPlatform { + 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) || this.spectators.get(playerId); + if (!ps || ps.isBot || ps.left) return fail(ERR.NOT_IN_ROOM); + // a spectator seat only watches (the platform routes nothing else of it) + if (ps.spectator && (!msg || msg.t !== 'g.watch')) return fail(ERR.SPECTATOR); + 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 | 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._resync(ps); + if (!was) this.markPublic(); + }); + } + + /** + * The full state of one human (a reconnect, a resync, a spectator seat): m.public, its m.private (players only), the + * field it is on / watches — a spectator, like an eliminated player, the first field — or the result once ended. + */ + _resync(ps) { + const playerId = ps.playerId; + this.sendTo(playerId, this.publicView()); + if (!this.ended) { + if (!ps.spectator) { + ps._lastPriv = null; + this._sendPrivate(ps, true); + } + if (this.clientCombat) this._resendBattle(ps); + else { + let fid = this.watchers.get(playerId); + if (!fid && ps.spectator && this.fields.length) { fid = this.fields[0].fieldId; this.watchers.set(playerId, fid); } + if (fid) this._sendField(playerId, fid); + } + } else if (this.lastResultMsg) { + this.sendTo(playerId, { ...this.lastResultMsg, playerId }); + } + } + + 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}博士中途退出了模拟`, FLOW_TICKER_PRIORITY); + 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(); + } +} diff --git a/server/match/match/prep.js b/server/match/match/prep.js new file mode 100644 index 00000000..f385df00 --- /dev/null +++ b/server/match/match/prep.js @@ -0,0 +1,139 @@ +// server/match/match/prep.js — Match methods: the PREP phase — its start (deferred item merges, onPrepStart), the AI +// seats' sliced preps (scheduleBotPrep: economy + layout, rehearsal, Ready), Ready and the deadline, and its end +// (onPrepEnd, PlayerState.endPrep, then COMBAT or the Final Assault / Hidden Core). +// Installed on Match.prototype by server/match/Match.js (a method container: never instantiated; `this` is the match). + +import { PHASE } from '../../../shared/constants.js'; +import { botPrepBeginSteps, botPrepEndSteps } from '../bot.js'; +import { DELAYS } from './common.js'; + +export class MatchPrep { + enterPrep() { + this.phase = PHASE.PREP; + this.sp = null; + const alive = this.alivePlayers(); + for (const ps of alive) { + ps.ready = false; + // Items gained as the previous prep ended waited unmerged (acquireItem deferMerge). Merge them now, before + // this prep's onPrepStart grants and before the player acts — not in endPrep, which runs in the same prep + // that granted them and would take an equipped copy off for the fight about to start. + ps.checkItemMerges(); + ps.recompute(); + 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(); + } + } +} diff --git a/server/match/match/reports.js b/server/match/match/reports.js new file mode 100644 index 00000000..1ee18482 --- /dev/null +++ b/server/match/match/reports.js @@ -0,0 +1,133 @@ +// server/match/match/reports.js — Match methods: the battle reports of client-side combat (DESIGN §14) — b.progress +// (field progress, boss credits, the 联防 counts) and b.result (validated against the spec; an implausible one is +// replaced by the server's run; a boss field's 'cleared' result may wait for the budget, `heldResult`), SP_VERIFY +// re-simulation. +// Installed on Match.prototype by server/match/Match.js (a method container: never instantiated; `this` is the match). + +import { ERR } from '../../../shared/constants.js'; +import { runHeadless, syntheticResult, validateClientResult, HARD_CAP_SECONDS } from '../fields.js'; +import { resultDigest, compactResult as compactForVerify } from '../../sim/spec.js'; +import { OK, fail } from './common.js'; + +export class MatchReports { + _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; + } +} diff --git a/server/match/match/settle.js b/server/match/match/settle.js new file mode 100644 index 00000000..97cfcc2d --- /dev/null +++ b/server/match/match/settle.js @@ -0,0 +1,133 @@ +// server/match/match/settle.js — Match methods: SETTLE (the LP loss — a leaker's 联防 survivors —, stats, bounty coins, +// the IN_BATTLE layer gains clamped by layerGainRoom, CHAR_DAMAGE tickers, eliminations) and RESULT (finish: m.result +// to every human still here, onEnd). +// Installed on Match.prototype by server/match/Match.js (a method container: never instantiated; `this` is the match). + +import { PHASE, layerGainRoom } from '../../../shared/constants.js'; +import { uniteSurvivors } from '../unite.js'; +import { buildResult } from '../results.js'; +import { FLOW_TICKER_PRIORITY, DELAYS } from './common.js'; + +export class MatchSettle { + 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}博士的目标生命值已耗尽`, FLOW_TICKER_PRIORITY); + } + } + 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); + } + + 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); } + // every human still here gets the settlement — the spectator seats the same public rows (none of them their own) + for (const ps of this._viewers()) 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); } + } +} diff --git a/server/match/match/spDraft.js b/server/match/match/spDraft.js new file mode 100644 index 00000000..d8ee43c0 --- /dev/null +++ b/server/match/match/spDraft.js @@ -0,0 +1,194 @@ +// server/match/match/spDraft.js — Match methods: the 机变 draft (SP_DRAFT: turn order, timers, AI picks, the card applied +// by choices.js applyCard) and the rolls the meta effects ask for — bounties (addBounty), item ids (rollItemId) and +// choices.json pools (rollPool). +// Installed on Match.prototype by server/match/Match.js (a method container: never instantiated; `this` is the match). + +import { PHASE, ERR } from '../../../shared/constants.js'; +import { generateDraft, applyCard, bountyBattles, isMultiRoundBounty } from '../choices.js'; +import { weightedPick } from '../waves.js'; +import { botPickCard } from '../bot.js'; +import { OK, fail, DELAYS } from './common.js'; + +export class MatchSpDraft { + 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 }; + } +} diff --git a/server/match/match/unitePhase.js b/server/match/match/unitePhase.js new file mode 100644 index 00000000..b51959ac --- /dev/null +++ b/server/match/match/unitePhase.js @@ -0,0 +1,145 @@ +// server/match/match/unitePhase.js — Match methods: 联防 glue (the rules: server/match/unite.js) — the UNITE phase in +// both modes (the 联防 field from the helpers' carried state and the leakers' enemies), its end, and the leakers' live +// counts (_uniteLeft, user playtest #6 item 7; _uniteTick in the server-run mode). +// Installed on Match.prototype by server/match/Match.js (a method container: never instantiated; `this` is the match). + +import { PHASE, GEO } from '../../../shared/constants.js'; +import { deriveSeed } from '../../sim/rng.js'; +import { uniteBattleOpts, uniteSurvivors } from '../unite.js'; +import { FieldRunner, timelineAt, uniteBillBounds } from '../fields.js'; +import { uniteLeft } from '../../sim/spec.js'; +import { FLOW_TICKER_PRIORITY, DELAYS } from './common.js'; + +export class MatchUnite { + 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('、')} 迎战突破防线的敌人`, FLOW_TICKER_PRIORITY); + 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, + }; + } + + _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, spectator seats included) simulate the same 联防 spec locally + for (const ps of this._viewers()) { + 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('、')} 迎战突破防线的敌人`, FLOW_TICKER_PRIORITY); + } + + _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)); + } + + /** + * 联防 (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(); + } +} diff --git a/server/match/match/views.js b/server/match/match/views.js new file mode 100644 index 00000000..5336006a --- /dev/null +++ b/server/match/match/views.js @@ -0,0 +1,240 @@ +// server/match/match/views.js — Match methods: the state builders — m.public (publicView with statusOf / fieldOf, the +// fields' progress and the teammates' live pendingLp / uniteLeft), the nextEnemies preview of m.private and the prep +// scout's m.field (prepFieldMeta: board, hand and temp as units, the scouted player's effects and coming enemies). +// Installed on Match.prototype by server/match/Match.js (a method container: never instantiated; `this` is the match). + +import { PHASE, GEO } from '../../../shared/constants.js'; +import { boardOrder, pieceDir } from '../board.js'; +import { bondList, offBondCounts } from '../bondsMeta.js'; +import { cardView } from '../choices.js'; +import { bountySpawns, previewOf } from '../waves.js'; +import { timelineAt } from '../fields.js'; +import { battleProgress } from '../../sim/spec.js'; + +export class MatchViews { + 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 + // (the mode-off bonds with members included, `off: true`, as in m.private — bondsMeta.offBondCounts) + bonds: ps.alive ? bondList(this.gd, ps.bondsView(), { off: offBondCounts(this.gd, ps) }) : [], + 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; + } + + /** 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 } : {}; + } + + /** 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 and hand (prep scouting): board pieces on their tiles, held pieces on the + * hand row (row 7) — the scout renders like the own prep bench. */ + 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, + }); + } + // the hand (整备区) and the 临时整备区 scout exactly like the own prep bench renders them: pieces as units on + // their rows (hand row 7, col = hand slot; temp row 8, cols 4..8 = temp slots; no dir — bench pieces face right), + // items included (the client draws their floating plates). PRTS 帮助 counts the temp area with the hand (review of + // PR #129). Part of the meta for every watcher alike — the spectator seat's copy equals a teammate's + // (test/match/spectator.test.js). User playtest #2 item 1 (GitHub #44). + const benchUnit = (piece, i, y) => { + const rec = piece.kind === 'item' ? this.gd.item(piece.id) : piece.kind === 'token' ? this.gd.token(piece.id) : this.gd.chess(piece.id); + const assets = (rec && rec.assets) || {}; + const lo = piece.kind === 'chess' && rec ? ps.loadoutFor(rec) : null; + units.push({ + id: piece.uid, uid: piece.uid, kind: piece.kind === 'token' ? 'token' : piece.kind === 'item' ? 'item' : '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: i, y, 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, + items: piece.kind === 'chess' && Array.isArray(piece.items) && piece.items.length ? piece.items.map((it) => it.id) : undefined, + }); + }; + for (let i = 0; i < ps.hand.length; i++) { + if (ps.hand[i]) benchUnit(ps.hand[i], i, GEO.HAND_ROW); + } + for (let i = 0; i < ps.temp.length; i++) { + if (ps.temp[i]) benchUnit(ps.temp[i], GEO.TEMP_C0 + i, GEO.TEMP_ROW); + } + // `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); } + // the scouted player's effects column (策略 / 机变 / 悬赏 …), display-ready (user playtest #2: while scouting, the + // right column shows the watched player's effects, not one's own) + return { t: 'm.field', fieldId: `n:${ps.playerId}`, kind: 'normal', rect: { ...GEO.NORMAL_RECT }, stageId: this.stageId, units, effects: ps.effectsView(), prep: true, nextEnemies }; + } +} diff --git a/server/match/match/watch.js b/server/match/match/watch.js new file mode 100644 index 00000000..03cef425 --- /dev/null +++ b/server/match/match/watch.js @@ -0,0 +1,218 @@ +// server/match/match/watch.js — Match methods: watching (research 09 §3.1 / §6.3) — spectator seats (community report +// #26: stand-ins shown fields like an eliminated player), g.watch in both combat modes (the boss-group rule, prep +// scouting of a teammate's board), who is shown which field (the default watch, the boss fields, the resend on a +// reconnect, the spectator's spec without the funds), a field's m.field + b.snap (server-run mode) and the prep-scout +// pushes (GitHub #87). +// Installed on Match.prototype by server/match/Match.js (a method container: never instantiated; `this` is the match). + +import { PHASE, ERR } from '../../../shared/constants.js'; +import { boardOrder, pieceDir } from '../board.js'; +import { snapFrame } from '../fields.js'; +import { OK, fail } from './common.js'; + +export class MatchWatch { + /** + * A spectator seat (community report #26; server/lobby.js spectate) joined during the match, came back or asked for a + * resync: registered once, then resent what an eliminated player watching sees (_resync — never an m.private). + */ + addSpectator(playerId) { + if (this.disposed) return; + const s = this._spectator(playerId); + if (s) this.guard(() => this._resync(s)); + } + + /** The spectator left (room.leave / g.leave, removed by the host, reconnect window expired). */ + removeSpectator(playerId) { + if (this.spectators.delete(playerId)) this.watchers.delete(playerId); + } + + /** The stand-in of a spectator seat, created once (null for a player's id or a bad id). */ + _spectator(playerId) { + if (typeof playerId !== 'string' || !playerId || this.players.has(playerId)) return null; + let s = this.spectators.get(playerId); + if (!s) { + s = Object.freeze({ playerId, isBot: false, left: false, alive: false, connected: true, spectator: true }); + this.spectators.set(playerId, s); + } + return s; + } + + /** Everyone shown fields: the seated humans still in (seat order), then the spectator seats' stand-ins. */ + _viewers() { + const out = this.order.filter((ps) => !ps.isBot && !ps.left); + for (const s of this.spectators.values()) out.push(s); + return out; + } + + 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, only while no battle field is up: during 各自行动 / 联防 / 最终攻势 / 隐秘核心 an 'n:' 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). The scout stays in `watchers` so a later board change pushes prepFieldMeta again + // (GitHub #87); combat start clears the map and reassigns live fields, spectators included. + 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.set(ps.playerId, fieldId); + this._notifyPrepScouts(target, { to: ps.playerId }); + return OK; + } + return fail(ERR.BAD_TARGET); + } + + /** + * 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) })); + } + + _defaultWatch() { + this.watchers.clear(); + for (const ps of this._viewers()) { + 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); + } + } + + /** b.start of the boss fields: players get their own pair field, eliminated humans and spectator seats the first. */ + _watchBossFields(fields) { + for (const ps of this._viewers()) { + 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 spec a spectator seat is shown: the field's own, minus the players' `contentInfo.funds` — a private number (the + * player's funds at the battle start) that no battle effect reads, so the replica still plays the same battle. The + * other contentInfo counters stay: battle effects read them (sim/content: handUnits, roundStats.gainedChess). + */ + _spectatorSpec(f) { + if (!f.spectatorSpec) { + const s = f.spec; + const strip = (p) => { + if (!p || !p.contentInfo || !Object.hasOwn(p.contentInfo, 'funds')) return p; + const { funds, ...contentInfo } = p.contentInfo; + void funds; + return { ...p, contentInfo }; + }; + f.spectatorSpec = s && Array.isArray(s.players) ? { ...s, players: s.players.map(strip) } : s; + } + return f.spectatorSpec; + } + + /** Humans currently shown a field (its players and its watchers, spectator seats included). */ + _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) || this.spectators.get(pid); return ps && !ps.isBot && !ps.left; }); + } + + watchersOf(fieldId) { + const out = []; + for (const [pid, fid] of this.watchers) if (fid === fieldId) out.push(pid); + return out; + } + + _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)); + } + } + + /** Board signature of a prep scout view (units and hand: a shop or funds change is not a board change). */ + /** Board signature of a prep scout view (board, hand and temp rows: a shop or funds change is not a board change). + * Hand / temp entries carry their slot — `prepFieldMeta` draws x from it, so a piece moved to another slot is a + * change (review of PR #129). */ + _prepScoutSig(ps) { + const parts = []; + for (const { r, c, piece } of boardOrder(ps.board)) { + const items = piece.kind === 'chess' && Array.isArray(piece.items) ? piece.items.map((it) => `${it.uid}:${it.id}`).join(',') : ''; + parts.push(`${piece.uid}:${piece.id}@${r},${c}:${pieceDir(piece)}:${items}`); + } + for (let i = 0; i < ps.hand.length; i++) { + const piece = ps.hand[i]; + if (!piece) continue; + const items = piece.kind === 'chess' && Array.isArray(piece.items) ? piece.items.map((it) => `${it.uid}:${it.id}`).join(',') : ''; + parts.push(`h${i}:${piece.uid}:${piece.id}:${items}`); + } + for (let i = 0; i < ps.temp.length; i++) { + const piece = ps.temp[i]; + if (!piece) continue; + const items = piece.kind === 'chess' && Array.isArray(piece.items) ? piece.items.map((it) => `${it.uid}:${it.id}`).join(',') : ''; + parts.push(`t${i}:${piece.uid}:${piece.id}:${items}`); + } + return parts.join(';'); + } + + /** + * Push prepFieldMeta to whoever is scouting `ps` during prep (GitHub #87). `to` always receives the current board + * (the player who just asked to watch); everyone scouting it receives a new `m.field` only when the board changed. + * A live battle field owns the `n:` id, so this stays quiet once fields exist. + */ + _notifyPrepScouts(ps, { to = null } = {}) { + if (!ps || !ps.alive || this.fields.length) return; + const fid = `n:${ps.playerId}`; + const watchers = this.watchersOf(fid); + if (!watchers.length) return; + const sig = this._prepScoutSig(ps); + const changed = sig !== ps._prepScoutSig; + ps._prepScoutSig = sig; + const dest = changed ? watchers : (to ? [to] : []); + if (!dest.length) return; + const meta = this.prepFieldMeta(ps); + for (const pid of dest) this.sendTo(pid, meta); + } +} diff --git a/test/docs-consistency.test.js b/test/docs-consistency.test.js index 65a796dc..ab9c67dd 100644 --- a/test/docs-consistency.test.js +++ b/test/docs-consistency.test.js @@ -52,6 +52,8 @@ const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); const doc = (p) => readFileSync(join(ROOT, p), 'utf8'); /** server/sim/Battle.js with its method modules (server/sim/battle/*.js) as one text. */ const battleText = () => [doc('server/sim/Battle.js'), ...readdirSync(join(ROOT, 'server/sim/battle')).sort().map((f) => doc(`server/sim/battle/${f}`))].join('\n'); +// the Match class: the façade (constructor) and its method modules (server/match/match/) +const matchText = () => [doc('server/match/Match.js'), ...readdirSync(join(ROOT, 'server/match/match')).sort().map((f) => doc(`server/match/match/${f}`))].join('\n'); // the sources of a former kits/tierN.js: its helpers (kits/shared/tierN.js) and its kit files (kits/ops/, kits/index.js) const tierSources = (t) => [`server/sim/content/kits/shared/tier${t}.js`, ...KIT_FILES[t - 1].map((f) => `server/sim/content/kits/ops/${f}`)]; const DESIGN = doc('docs/DESIGN.md'); @@ -557,7 +559,7 @@ test('playtest6b follow-up (DESIGN §20.10–§20.13): leader HP, 直接乘算, assert.equal(BOND_LAYER_CAP, 999); assert.equal(layerGainRoom(995, 10), 4); assert.ok(!('BOND_LAYER_CAP' in SIM_CONST) && !('layerRoom' in SIM_CONST), 'no second cap in server/sim/constants.js'); - for (const f of ['server/match/PlayerState.js', 'server/match/Match.js', 'server/sim/battle/economy.js']) { + for (const f of ['server/match/PlayerState.js', 'server/match/match/settle.js', 'server/sim/battle/economy.js']) { const src = doc(f); assert.match(src, /layerGainRoom/, `${f} clamps with layerGainRoom`); assert.ok(!/layerRoom\b/.test(src), `${f}: no layerRoom`); @@ -614,8 +616,8 @@ test('playtest6b QA residuals (DESIGN §20.14): the held boss result, the cue be assert.ok(s2014.length > 100, '§20.14 exists'); const intro = S20.slice(0, S20.indexOf('### 20.1 ')); assert.match(intro, /residual issues are handled in §20\.14/); - // the held 'cleared' result: Match.js = §14 = §20.14 = META - const match = doc('server/match/Match.js'); + // the held 'cleared' result: Match (match/reports.js, match/bossRounds.js) = §14 = §20.14 = META + const match = matchText(); assert.match(match, /if \(result\.reason === 'cleared' && pool && reported - f\.bossAcked >= pool\.hp - 1\) \{\s*f\.heldResult = result;/); assert.match(match, /_bossHandover\(f, why, \{ demote = false \} = \{\}\) \{\s*if \(f\.done \|\| f\.mode !== 'client' \|\| f\.heldResult\) return;/); assert.match(match, /const BOSS_MIN_CLEAR_GS = 5;/, 'the budget itself is unchanged');