diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs index 3372a17c..76bdb3aa 100644 --- a/.git-blame-ignore-revs +++ b/.git-blame-ignore-revs @@ -11,3 +11,7 @@ b48a67caec5ba0699097e21f3db4d4dbb35a2aed 560328cacc863c26d07780096527e255ebf62bc4 # feedback5 R4: kits/tier1..6.js → kits/ops/ (one file per kit), kits/shared/, kits/index.js 1169102d46742474bf0f0189d467c791984d9e05 +# feedback5 R2: server/match/Match.js → server/match/match/*.js (the Match methods, verbatim; shared constants in common.js) +1ca4936f0a541cabda9acae5fe6f9213df4b5e6a +# feedback5 R2: server/match/PlayerState.js → server/match/player/*.js (the PlayerState methods, verbatim) +64443b4aab3126d6703509b8c47e9cfa58b75157 diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 16a9c3e7..d020a46c 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -56,8 +56,15 @@ server/ lobby.js rooms (4-letter codes), seats, host, AI seats, ready/start, reconnect tokens, room→Match wiring data.js loads data/*.json once, builds indexes (getChess, getBond, …); frozen objects match/ - Match.js match state machine, timers, round loop, co-op orchestration, broadcasting views - PlayerState.js per-player economy/shop/hand/board/items/bonds/LP state + all prep-intent handlers + Match.js match state machine, timers, round loop, co-op orchestration, broadcasting views — the class: + the lobby⇄match interface (header), constructor + method install + match/ Match's methods by concern (platform, infra, messaging, views, watch, intents, pause, phases, + spDraft, prep, combat, clientCombat, reports, unitePhase, bossRounds, settle; shared constants + in common.js), installed on Match.prototype + PlayerState.js per-player economy/shop/hand/board/items/bonds/LP state + all prep-intent handlers — the class: + constructor + method install + player/ PlayerState's methods by concern (basics, pieces, acquire, economy, placement, items, prep, + round, views; common.js), installed on PlayerState.prototype pool.js SharedPool (copies per base chess), banned/disabled bonds, odds & rolls board.js placement legality, deploy cap, hand/temp management, merge detection & execution bondsMeta.js bond member counting (BOARD / BOARD_AND_DECK / golden), activation tiers, persistent layers diff --git a/docs/META.md b/docs/META.md index ccf20097..3ff9df23 100644 --- a/docs/META.md +++ b/docs/META.md @@ -7,7 +7,14 @@ Normative contracts stay in DESIGN.md §6 and §8; this file documents the imple ``` server/match/ Match.js state machine, timers, round loop, co-op orchestration, views (the lobby⇄match interface is at the top) - PlayerState.js per-player economy / shop / hand / board / items / bonds / LP + every prep intent handler + — the class: constructor + method install + match/ Match's methods by concern: platform, infra, messaging, views, watch, intents, pause, phases, + spDraft (机变), prep, combat, clientCombat, reports, unitePhase (联防), bossRounds (Final Assault / + Hidden Core), settle; common.js (FLOW_TICKER_PRIORITY, DELAYS, BAND_TURN_SECONDS, re-exported by Match.js) + PlayerState.js per-player economy / shop / hand / board / items / bonds / LP + every prep intent handler — the class: + constructor + method install + player/ PlayerState's methods by concern: basics, pieces, acquire (gains, merges), economy (funds, layers, + the shop), placement (g.move), items, prep, round (lifecycle, battleInput), views; common.js gamedata.js typed, defaulted view of data/*.json (config tunables with research defaults) + the balance layer (data/tuning.json, §3.1) pool.js SharedPool (copies per base chess, across players), per-match bans, copy-weighted rolls diff --git a/docs/research/09-ux-official.md b/docs/research/09-ux-official.md index 5ed7a559..dbd810b0 100644 --- a/docs/research/09-ux-official.md +++ b/docs/research/09-ux-official.md @@ -318,7 +318,7 @@ The public mirror (ArknightsAssets2 `cn`, `ui/emoticon/theme/[uc]/icon/ - `g.move {uid, to:{area:'board',row,col}, dir}` with `dir ∈ 'UP'|'RIGHT'|'DOWN'|'LEFT'`; the server defaults to `'RIGHT'` when absent, for bots and old clients. - `g.art {itemUid,row,col,dir}`. - Broadcast `dir` in the board state (public and private views, snapshot `UnitInfo.dir`). -2. **`server/match/PlayerState.js`.** +2. **`server/match/PlayerState.js`** (the methods named here: `server/match/player/placement.js`, `items.js`). - Store `piece.dir`, preserved across rounds. - In `_moveChessToBoard` and `_moveTokenToBoard`, `if (occ === piece) return OK;` must become "update `piece.dir`, recompute, OK". This is the in-place re-orient. - Swaps keep each piece's own dir. @@ -363,7 +363,7 @@ The public mirror (ArknightsAssets2 `cn`, `ui/emoticon/theme/[uc]/icon/ 4. Prep: the same avatar → 前往查看 flow (read-only board + pen). 5. 联防 and FA fields: the ‹ › pill cycles LEFT half / 全景 / RIGHT half, with captions "你自己" / "👁 name#" / "全景". Non-helpers are auto-moved to the 联防 field as observers. In FA the other pair's field is not selectable. 6. Eliminated players: a dialog offers 继续观战 (auto-observe the first live field, free switching) or 退出. -7. `server/match/Match.js watch()` must enforce the same rules (prep: any teammate; combat: only if your own field finished; FA: own pair only; dead: anything). +7. `Match.watch()` (`server/match/match/watch.js`) must enforce the same rules (prep: any teammate; combat: only if your own field finished; FA: own pair only; dead: anything). ### 6.4 Combat computation model (user item 5, architecture) [proposal based on §3.2] diff --git a/docs/research/11-limits-official.md b/docs/research/11-limits-official.md index eb6101a4..873e8707 100644 --- a/docs/research/11-limits-official.md +++ b/docs/research/11-limits-official.md @@ -220,7 +220,7 @@ line is lost rather than clamped. - `shared/constants.js`: `BOND_LAYER_CAP = 999` and `BOSS_HIT_LIMIT = 300000` (0 / Infinity = off), plus `layerGainRoom(before, n)` = min(n, cap − before), never negative. - Layers: every writer clamps with it — `PlayerState.addLayers` (every prep-side gain: 特质, items, bands, 机变 cards, - bonds), the settle of the in-battle gains (`Match.js`), `Battle.addLayers` (the live in-battle copy, after the + bonds), the settle of the in-battle gains (`Match.settle`, `server/match/match/settle.js`), `Battle.addLayers` (the live in-battle copy, after the `layerGain` hook, as the client's `AddBondCount`); a gain at the cap adds 0 (no onLayers, no 'layer' event, no hook), so the per-N milestones (远见, 奇迹, 维多利亚 …) stop with the count; the client-result check (`fields.js`) bounds a reported gain by the room left from the bond's starting layers; `invariants.js` flags a bond above the cap. The dev diff --git a/public/dev/game-mock.js b/public/dev/game-mock.js index d4b3e72f..d8fb5e5f 100644 --- a/public/dev/game-mock.js +++ b/public/dev/game-mock.js @@ -681,7 +681,7 @@ async function mockRequest(t, f = {}) { const who = f.fieldId.replace(/^n:/, ''); if (who !== ME) { const units = shuffle(S.pool).slice(0, 6).map((c, i) => ({ id: 900 + i, kind: 'op', side: 'ally', ownerId: who, defId: c.chessId, name: c.name, tier: c.tier, golden: i === 0, spine: c.charId, avatar: c.assets.avatar, x: [3, 4, 5, 7, 8, 5][i], y: [9, 9, 10, 11, 12, 12][i], facing: 1, maxHp: c.stats.maxHp })); - // like server/match/Match.js prepFieldMeta: a read-only prep board with THEIR coming enemies (the pen shows them) + // like server/match/match/views.js prepFieldMeta: a read-only prep board with THEIR coming enemies (the pen shows them) const theirs = S.priv.nextEnemies.filter((e) => e.source !== 'bounty').slice(0, 3).map((e) => ({ ...e, gate: 'upper', count: e.count + 1 })); store.patch('match', { field: { fieldId: f.fieldId, kind: 'normal', rect: { ...GEO.NORMAL_RECT }, stageId: pub.stageId, units, prep: true, nextEnemies: theirs } }); } diff --git a/public/js/battle/observe.js b/public/js/battle/observe.js index e7fa93da..7b4112a8 100644 --- a/public/js/battle/observe.js +++ b/public/js/battle/observe.js @@ -1,5 +1,5 @@ // Observing rules and labels of client-side combat (research 09 §3.1 / §6.3, DESIGN §14 "Spectating") — pure helpers -// for the game screen, the team panel and the combat HUD (mirror of server/match/Match.js _watchClient): +// for the game screen, the team panel and the combat HUD (mirror of server/match/match/watch.js _watchClient): // * prep (休整期): tap a teammate → 前往查看 → their board (read-only); // * own normal battle running: no observing ("当前无法查看"); // * own battle over: "⌛ 作战结束,等待队友完成作战" + the teammates' progress; tap a teammate → 前往查看 → a local diff --git a/public/js/screens/bandDraft.js b/public/js/screens/bandDraft.js index e16e78a3..0bd162a4 100644 --- a/public/js/screens/bandDraft.js +++ b/public/js/screens/bandDraft.js @@ -58,7 +58,7 @@ export function allowedBands(bands, modeType) { export const DEFAULT_TIMEOUT_BAND = 'band_bldsk'; /** - * The strategy the server assigns me when my turn times out (server/match/Match.js defaultBand): the official default + * The strategy the server assigns me when my turn times out (server/match/match/phases.js defaultBand): the official default * 「华法琳」 while no teammate holds it, else the first free strategy in draft order (sortId) — never one a teammate * already picked (队友已选). * @param {any[]} bands allowedBands(...) (sortId order) @@ -218,7 +218,7 @@ export function BandDraftScreen() { act('g.bandFocus', { bandId: sel }, { sfx: false, quiet: true }); }, [sel, timed, myPick]); - // what a timeout gives me: the highlighted band while free, else the default (never 队友已选 — Match.js timeoutBand) + // what a timeout gives me: the highlighted band while free, else the default (never 队友已选 — match/phases.js timeoutBand) const autoId = autoPickBand(sel, { bands, taken, myPick, defaultId }); const autoName = (autoId && gd.band(autoId)?.name) || gd.band(defaultId)?.name || '华法琳'; diff --git a/public/js/screens/result.js b/public/js/screens/result.js index 86226ef2..8d9857c5 100644 --- a/public/js/screens/result.js +++ b/public/js/screens/result.js @@ -22,7 +22,7 @@ import { audio } from '../audio.js'; const cx = (...p) => p.flat().filter(Boolean).join(' '); // The first seven present are shown (+ remaining LP = a 4×2 grid); the title (评语) stats come first. -// `gold` is the funds a player SPENT (server/match/PlayerState.js spend(); the 挥金如土 title stat). +// `gold` is the funds a player SPENT (server/match/player/economy.js spend(); the 挥金如土 title stat). const STAT_ROWS = [ ['dmgDealt', '造成伤害'], ['kills', '击倒敌人'], ['bossDamage', '领袖伤害'], ['activatedLayers', '盟约层数'], ['merges', '晋升次数'], ['itemsEquipped', '配发装备'], ['gold', '消耗资金'], ['perfectRounds', '完美作战'], diff --git a/public/js/ui/hud.js b/public/js/ui/hud.js index 1e43eb69..b522d5bd 100644 --- a/public/js/ui/hud.js +++ b/public/js/ui/hud.js @@ -102,7 +102,7 @@ export function MissTag({ n, name = null }) { const LEAK_PHASES = new Set([PHASE.COMBAT, PHASE.UNITE]); /** - * LP a round's leaks cost at settlement — the server rule (server/match/Match.js settle): min(lpCapPerRound, counted + * LP a round's leaks cost at settlement — the server rule (server/match/match/settle.js): min(lpCapPerRound, counted * leaks), never negative. * @param {number} leaks counted leaks so far * @param {number} [cap] data/config.json lpCapPerRound diff --git a/public/js/ui/teamPanel.js b/public/js/ui/teamPanel.js index 1f44d10a..815bbbca 100644 --- a/public/js/ui/teamPanel.js +++ b/public/js/ui/teamPanel.js @@ -7,7 +7,7 @@ // watches that player's field at once. // Live LP (user playtest #3 item 2): during a normal round's battle each row's tower shows lp − the loss that player's // leaks so far will cost (red, −N): the own row the top bar's live value (`self`, ui/hud.js liveLp), a teammate's row -// m.public players[].pendingLp (server/match/Match.js, ~1 Hz). 联防 (user playtest #6 item 7): a leaker's row adds the +// m.public players[].pendingLp (server/match/match/views.js, ~1 Hz). 联防 (user playtest #6 item 7): a leaker's row adds the // runner tag ×N — its enemies still standing on the 联防 field, uncapped and live (falling as the helpers kill them, // rising when one splits) — next to lp − min(lpCapPerRound, N): the own row the top bar's value, a teammate's row the // local 联防 replica's count while it is on screen (`uniteLocal`: the battle runner's state().uniteLeft, the same battle diff --git a/public/js/ui/underframe.js b/public/js/ui/underframe.js index 95258c0b..9382a035 100644 --- a/public/js/ui/underframe.js +++ b/public/js/ui/underframe.js @@ -135,7 +135,7 @@ export function tempRowFrame(a, b, { labelW = 0, gap = 8, vw = Infinity } = {}) } /** - * What the temp row's label says will happen to its pieces (server/match/PlayerState.js tempDue): a piece is resolved + * What the temp row's label says will happen to its pieces (server/match/player/basics.js tempDue): a piece is resolved * at the end of the first prep in which the player can act on it. Not ready (or outside PREP): at the end of this / the * coming prep, and 准备就绪 waits for the row to be cleared. Ready in PREP: Ready is refused while the row holds pieces, * so whatever lies there arrived after it — kept through the NEXT prep (cancelling Ready makes it due at this one). 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/PlayerState.js b/server/match/PlayerState.js index c364935f..85d842c2 100644 --- a/server/match/PlayerState.js +++ b/server/match/PlayerState.js @@ -69,22 +69,38 @@ // mismatch falls back to the defaults) and kept frozen; bots always use the defaults. Match.setLoadout may replace // it during INFO_CHECK only. battleInput() resolves every chess unit to `skillIndex` + `moduleId` (resolveLoadout: // normal chess → moduleId null, elite → uniEquipId | 'none'); m.private exposes `loadout`. +// +// Code layout: this file keeps the constructor (the per-player state fields); the methods live in +// server/match/player/, one module per concern, and are installed on PlayerState.prototype below in a fixed order, +// with the descriptors of class methods (non-enumerable getters included; `this` is the player state): +// basics.js seat flags and counts (getters), the temp due prep, the operator loadout, the deploy map and the +// eviction after a terrain / deploy-field change, dirty +// pieces.js piece bookkeeping: new pieces, lookup, detach / stow, per-piece round counters, pool copies, +// summon stacks +// acquire.js gains and merges: acquireChess / acquireItem, the chess and item merges, promotion, +// transformation, reward / item offers +// economy.js funds, spending, prep-side layer gains, the shop (prices, rolls) and its intents: buy, refresh, +// freeze, levelUp, sell +// placement.js g.move: placement legality, summon ranges, board / hand / temp moves and swaps, re-orientation +// items.js g.equip, g.art, g.destroy +// prep.js the prep-intent gate, g.reward, Ready, the temp resolution +// round.js the round lifecycle the match calls (startRound, endPrep, eliminate, recompute, bond views) and +// battleInput +// views.js pieceView, effectsView, m.private (privateView) +// common.js HAND_SIZE, TEMP_SIZE, MAX_OFFER_SLOTS, OK, fail -import { ERR, GEO, PHASE, layerGainRoom } from '../../shared/constants.js'; -import { checkLoadout, resolveLoadout } from '../../shared/protocol.js'; -import { FIELD, tileKey, parseKey, inField, canPlace, placeClass, boardOrder, freeSlot, pieceDir, parseDir, mergeTile, ownerRangeKeys } from './board.js'; -import { attackRangeGrid, loadoutRecord, resolveRecordLoadout } from '../../shared/loadoutRecord.js'; -import { offsetTile } from '../sim/dir.js'; -import { computeBonds, bondList, bondSnapshot, activatedLayers, bondsWithGains, offBondCounts } from './bondsMeta.js'; -import { itemKey } from './gamedata.js'; -import { bountyText } from './choices.js'; - -const HAND_SIZE = GEO.HAND_SIZE; -const TEMP_SIZE = GEO.TEMP_SIZE; -/** g.reward accepts idx 0..5 (shared/protocol.js) */ -const MAX_OFFER_SLOTS = 6; -const OK = Object.freeze({ ok: true }); -const fail = (error, detail) => (detail ? { error, detail } : { error }); +import { FIELD } from './board.js'; +import { computeBonds } from './bondsMeta.js'; +import { HAND_SIZE, TEMP_SIZE } from './player/common.js'; +import { PlayerBasics } from './player/basics.js'; +import { PlayerPieces } from './player/pieces.js'; +import { PlayerAcquire } from './player/acquire.js'; +import { PlayerEconomy } from './player/economy.js'; +import { PlayerPlacement } from './player/placement.js'; +import { PlayerItems } from './player/items.js'; +import { PlayerPrep } from './player/prep.js'; +import { PlayerRound } from './player/round.js'; +import { PlayerViews } from './player/views.js'; export class PlayerState { /** @@ -165,1508 +181,14 @@ export class PlayerState { this._botPrepToken = 0; this.bonds = computeBonds(this.gd, this); } - - // ================================================================================================= - // basics - - get isHumanActive() { return !this.isBot && !this.left; } - /** The engine acts for this seat (AI teammate or "AI 托管"; a departed human is eliminated, so nothing is left to do). */ - get botControlled() { return this.isBot || this.left || this.autoplay; } - - get deployCap() { return Math.max(1, this.gd.deployCap + this.deployCapBonus, this.deployCapMin); } - get deployCount() { let n = 0; for (const p of this.board.values()) if (p.kind === 'chess') n++; return n; } - get tempEmpty() { return this.temp.every((x) => x == null); } - - /** - * Index of the prep whose deadline resolves a temp piece (compare with `prepsEnded`): recorded when the piece entered - * temp (_putTemp); a piece put there by other means counts as due at the current (or next) prep. - */ - tempDue(piece) { - const due = piece ? this._tempDue.get(piece.uid) : undefined; - return Number.isInteger(due) ? due : this.prepsEnded; - } - - /** - * Due prep of a piece entering temp now: the current prep while the player can still act on it (PREP, not ready); - * after Ready or at the prep end (onPrepEnd grants) the next one; outside PREP (COMBAT, SETTLE, ROUND_START, 机变) - * the next prep to end — `prepsEnded` then already names it. - */ - _tempDueNow() { - return this.prepsEnded + (this.m.phase === PHASE.PREP && this.ready ? 1 : 0); - } - - /** Every write of a piece into a temp slot goes through here (records its due prep). */ - _putTemp(i, piece) { - this.temp[i] = piece; - this._tempDue.set(piece.uid, this._tempDueNow()); - } - - /** - * Replace the operator loadout (DESIGN §16) after re-checking it against this match's data. Accepts the checked - * form `{ id: { skill, module|null } }` or raw `room.loadout` entries. Returns false (loadout unchanged) when it does - * not fit the data; bots keep the defaults. - * @param {any} loadout - * @returns {boolean} - */ - setLoadout(loadout) { - if (this.isBot) return false; - const entries = {}; - if (loadout && typeof loadout === 'object' && !Array.isArray(loadout)) { - for (const [id, e] of Object.entries(loadout)) { - if (!e || typeof e !== 'object') continue; - const x = {}; - if (Number.isInteger(e.skill)) x.skill = e.skill; - if (typeof e.module === 'string') x.module = e.module; - if (Object.keys(x).length) entries[id] = x; - } - } - const res = checkLoadout(entries, (id) => this.gd.chess(id)); - if (!res || !res.ok) { - this.m.log?.warn?.(`[match ${this.m.roomCode}] loadout of ${this.playerId} ignored: ${res && res.detail}`); - return false; - } - const out = {}; - for (const [id, e] of Object.entries(res.loadout)) out[id] = Object.freeze({ skill: e.skill, module: e.module ?? null }); - this.loadout = Object.freeze(out); - return true; - } - - /** The skill index / module a chess record fights with under this player's loadout (DESIGN §16). */ - loadoutFor(chessRecord) { - return resolveLoadout(this.loadout, chessRecord, (id) => this.gd.chess(id)); - } - - /** - * Deploy classes of the board tiles (server/match/board.js buildDeployMap) on the field the player deploys on now - * (Match.deployFieldOf: the own board, or its half of the boss field in a boss round — user playtest #5 item 7). - * A change of that field (the boss round begins, a re-pairing) re-checks the board's legality like a terrain change. - */ - deployMap() { - const field = typeof this.m.deployFieldOf === 'function' ? this.m.deployFieldOf(this) : 'normal'; - if (this._deployMap && this._deployField !== undefined && this._deployField !== field) { - this._deployMap = null; - this._legalityStale = true; - } - if (!this._deployMap) this._deployMap = this.m.deployMapFor(this, field); - this._deployField = field; - return this._deployMap; - } - /** - * The board's terrain changed (terrain 机变 cards, content device / tile overrides): legality is re-checked at the - * next recompute() / battleInput() — after the whole change, so an intermediate state of a card that toggles several - * devices never moves a piece. - */ - invalidateDeployMap() { this._deployMap = null; this._legalityStale = true; } - - /** - * After a terrain change, pieces standing on tiles they may no longer occupy (a melee operator on a tile that became - * a 射击台, anything on a tile that became undeployable) are withdrawn like 撤退: an operator goes to the hand - * (overflow temp — a passive move; the player re-places it during the prep), its summons leave the board with it; a - * summon returns to its owner's stack. Nothing is lost: with the hand and temp both full a piece stays put. - * @returns {number} pieces moved - */ - _evictIllegal() { - this._legalityStale = false; - const names = []; - let moved = 0; - for (const kind of ['chess', 'token']) { - for (const { r, c, piece } of boardOrder(this.board)) { - if (piece.kind !== kind || this._legal(piece, r, c)) continue; - const key = tileKey(r, c); - this.board.delete(key); - const ok = kind === 'chess' ? !!this.stow(piece, { allowTemp: true }) : this._returnToken(piece); - if (!ok) { this.board.set(key, piece); continue; } - moved++; - if (kind === 'chess') { - this.removeTokensOf(piece.uid); - const rec = this.gd.chess(piece.id); - names.push(rec && rec.name ? rec.name : piece.id); - } - } - } - if (names.length) this.m.toast(this, 'warn', `地形变化:${names.join('、')}无法停留在原位置,已撤回整备区`); - return moved; - } - - dirty() { this.m.markPrivate(this); } - - // ================================================================================================= - // piece bookkeeping - - newPiece(kind, id, extra = {}) { - return { uid: this.m.nextUid(), kind, id, items: kind === 'chess' ? [] : undefined, count: kind === 'token' ? 1 : undefined, ownerUid: undefined, poolCopies: 0, boughtRound: this.m.round, meta: {}, ...extra }; - } - - /** - * Locate a piece by uid. Returns { piece, area: 'board'|'hand'|'temp'|'equipped', idx?, key?, holder? } or null. - */ - find(uid) { - if (!Number.isInteger(uid)) return null; - for (let i = 0; i < this.hand.length; i++) { - const p = this.hand[i]; - if (!p) continue; - if (p.uid === uid) return { piece: p, area: 'hand', idx: i }; - if (p.items) for (const it of p.items) if (it.uid === uid) return { piece: it, area: 'equipped', holder: p }; - } - for (let i = 0; i < this.temp.length; i++) { - const p = this.temp[i]; - if (!p) continue; - if (p.uid === uid) return { piece: p, area: 'temp', idx: i }; - if (p.items) for (const it of p.items) if (it.uid === uid) return { piece: it, area: 'equipped', holder: p }; - } - for (const [key, p] of this.board) { - if (p.uid === uid) return { piece: p, area: 'board', key }; - if (p.items) for (const it of p.items) if (it.uid === uid) return { piece: it, area: 'equipped', holder: p }; - } - return null; - } - - /** Every owned chess piece: board (reading order, board.js boardOrder) then hand then temp. */ - allChess() { - const out = []; - for (const { piece } of boardOrder(this.board)) if (piece.kind === 'chess') out.push(piece); - for (const p of this.hand) if (p && p.kind === 'chess') out.push(p); - for (const p of this.temp) if (p && p.kind === 'chess') out.push(p); - return out; - } - - /** Locations of owned pieces, in merge-consumption preference order: temp, hand (left→right), board (reading order). */ - _chessLocations() { - const out = []; - for (let i = 0; i < this.temp.length; i++) if (this.temp[i] && this.temp[i].kind === 'chess') out.push({ piece: this.temp[i], area: 'temp', idx: i }); - for (let i = 0; i < this.hand.length; i++) if (this.hand[i] && this.hand[i].kind === 'chess') out.push({ piece: this.hand[i], area: 'hand', idx: i }); - for (const { r, c, piece } of boardOrder(this.board)) if (piece.kind === 'chess') out.push({ piece, area: 'board', key: tileKey(r, c) }); - return out; - } - - /** Remove a located piece from its container (no side effects). */ - _detach(loc) { - if (!loc) return; - if (loc.area === 'hand') this.hand[loc.idx] = null; - else if (loc.area === 'temp') { this.temp[loc.idx] = null; this._tempDue.delete(loc.piece.uid); } - else if (loc.area === 'board') this.board.delete(loc.key); - else if (loc.area === 'equipped') { - const i = loc.holder.items.indexOf(loc.piece); - if (i >= 0) loc.holder.items.splice(i, 1); - } - } - - /** - * Put a piece into the hand (right→left) or, when `allowTemp`, the temp slots (due at the deadline of the first prep - * in which the player can act on it, _tempDueNow). Returns 'hand' | 'temp' | null. - */ - stow(piece, { allowTemp = true, toTemp = false, preferIdx = null } = {}) { - if (!toTemp) { - if (Number.isInteger(preferIdx) && preferIdx >= 0 && preferIdx < this.hand.length && this.hand[preferIdx] == null) { - this.hand[preferIdx] = piece; - return 'hand'; - } - const i = freeSlot(this.hand); - if (i >= 0) { this.hand[i] = piece; return 'hand'; } - if (!allowTemp) return null; - } - const j = freeSlot(this.temp); - if (j >= 0) { this._putTemp(j, piece); return 'temp'; } - return null; - } - - /** - * Per-piece counter of the current round (`piece.meta.round` = { r, n: { key: count } }): 0 for a key not counted yet - * this round. The counters belong to the operator: a move keeps them, a new piece (bought, granted, transformed) - * starts at 0, and an elite merged this round keeps the highest count of its copies (_mergeChess) — 拉普兰德's - * "本回合首次主动刷新" is the first manual refresh she witnesses (player feedback after 0.1.0, garrisons/meta.js). - */ - pieceRoundCount(piece, key) { - const rc = piece && piece.meta && piece.meta.round; - return rc && rc.r === this.m.round && Number.isFinite(rc.n[key]) ? rc.n[key] : 0; - } - - /** Add `n` to a piece's counter of the current round (pieceRoundCount); returns the new count. */ - bumpPieceRoundCount(piece, key, n = 1) { - if (!piece || typeof key !== 'string' || !Number.isFinite(n)) return 0; - if (!piece.meta || typeof piece.meta !== 'object') piece.meta = {}; - const v = this.pieceRoundCount(piece, key) + n; - if (!piece.meta.round || piece.meta.round.r !== this.m.round) piece.meta.round = { r: this.m.round, n: {} }; - piece.meta.round.n[key] = v; - return v; - } - - /** Return a piece's pool copies (and its equipped items are handled by the caller). */ - returnCopies(piece) { - if (piece && piece.kind === 'chess' && piece.poolCopies > 0) { - this.m.pool.give(this.gd.baseIdOf(piece.id), piece.poolCopies); - piece.poolCopies = 0; - } - } - - /** Remove every token owned by a chess piece (board, hand, temp). */ - removeTokensOf(ownerUid) { - for (const [k, p] of [...this.board]) if (p.kind === 'token' && p.ownerUid === ownerUid) this.board.delete(k); - for (let i = 0; i < this.hand.length; i++) if (this.hand[i] && this.hand[i].kind === 'token' && this.hand[i].ownerUid === ownerUid) this.hand[i] = null; - for (let i = 0; i < this.temp.length; i++) if (this.temp[i] && this.temp[i].kind === 'token' && this.temp[i].ownerUid === ownerUid) this.temp[i] = null; - } - - /** - * An owner that changes its board tile (moved, swapped): its summons on the board go back onto its stack (PRTS - * 卫戍协议/帮助 "移动干员时,其所属召唤物全部退场并重置至手牌区"); overflow temp when no stack or slot is left (a - * stack lost there comes back at the next round start). `keep`: a summon the player just placed (the one dragged - * onto its owner, which swapped the owner away) stays where it was put. - */ - _liftTokensOf(ownerUid, keep = null) { - for (const [k, p] of [...this.board]) { - if (p.kind !== 'token' || p.ownerUid !== ownerUid || p === keep) continue; - this.board.delete(k); - if (!this._returnToken(p, null, { allowTemp: true })) this.board.set(k, p); // nowhere to go: it stays put - } - } - - /** Copies of one summon type an owner has (placed pieces + stacks in the hand / temp). */ - _tokenCountOf(ownerUid, tokenId) { - const mine = (p) => !!p && p.kind === 'token' && p.ownerUid === ownerUid && p.id === tokenId; - let n = 0; - for (const p of this.board.values()) if (mine(p)) n += p.count || 1; - for (const p of this.hand) if (mine(p)) n += p.count || 1; - for (const p of this.temp) if (mine(p)) n += p.count || 1; - return n; - } - - /** - * Owner on the board: send its placeable summons to the hand (one stack per token type, topped up to the deploy limit; - * gamedata.placeableTokens / tokens.json `placeable`, user playtest #6) — those its equipped skill / module makes - * (DESIGN §16: 赫默 S2 医疗无人机 a drone, 赫默 S1 none). Called when the owner is placed and at every round start, which - * returns a stack removed from temp at the last prep deadline (PRTS 卫戍协议/帮助 §手牌区 "干员所属召唤物会于下一回合 - * 返还"); copies the owner still has (placed or stacked) are not granted again. - */ - grantTokensFor(owner) { - const rec = owner && owner.kind === 'chess' ? this.gd.chess(owner.id) : null; - if (!rec) return; - for (const { tokenId, count } of this.gd.placeableTokens(owner.id, this.loadoutFor(rec))) { - const missing = count - this._tokenCountOf(owner.uid, tokenId); - if (missing <= 0) continue; - const stack = [...this.hand, ...this.temp].find((p) => p && p.kind === 'token' && p.ownerUid === owner.uid && p.id === tokenId); - if (stack) { stack.count = (stack.count || 1) + missing; continue; } - const t = this.newPiece('token', tokenId, { count: missing, ownerUid: owner.uid }); - this.stow(t, { allowTemp: true }); - } - } - - // ================================================================================================= - // acquisition, merges, promotion - - /** Normal copies of a base chess currently owned (board/hand/temp). */ - countCopies(baseId) { - let n = 0; - for (const loc of this._chessLocations()) { - const p = loc.piece; - if (!this.gd.isGolden(p.id) && this.gd.baseIdOf(p.id) === baseId) n++; - } - return n; - } - - /** Would acquiring one more normal copy of `chessId` complete a merge? */ - completesChessMerge(chessId) { - const rec = this.gd.chess(chessId); - if (!rec || rec.isGolden) return false; - const need = this.gd.mergeCount(chessId); - if (!(need > 1) || !this.gd.goldenIdOf(chessId)) return false; - return this.countCopies(this.gd.baseIdOf(chessId)) + 1 >= need; - } - - /** - * Acquire a chess (buy, reward, effect grant). Takes pool copies (normal 1, elite goldenCopies) when available, - * merges immediately when it completes a set, otherwise stows it (hand, overflow temp). Fires onGain (for the - * elite when a merge happened, research 01 §7 "1+1+2"). Returns the owned piece (the elite after a merge) or null. - * @param {string} chessId - * @param {{ source?: string, toTemp?: boolean, fromPool?: boolean, silent?: boolean }} [opts] - */ - acquireChess(chessId, { source = 'grant', toTemp = false, fromPool = true, silent = false } = {}) { - const rec = this.gd.chess(chessId); - if (!rec) return null; - const base = this.gd.baseIdOf(chessId); - const need = rec.isGolden ? this.gd.goldenCopies : 1; - const taken = fromPool ? this.m.pool.take(base, need) : 0; - const piece = this.newPiece('chess', chessId, { poolCopies: taken }); - this.round.gainedChess++; - let owned = piece; - if (!rec.isGolden && this.completesChessMerge(chessId)) { - owned = this._mergeChess(base, piece); - if (!owned) return null; - } else { - const where = this.stow(piece, { allowTemp: true, toTemp }); - if (!where) { - this.returnCopies(piece); - this.m.toast(this, 'warn', '整备区已满,获得的干员已返还'); - return null; - } - } - this.recompute(); - if (!silent) this.m.dispatch(this, 'onGain', { piece: owned, kind: 'chess', source }); - this.recompute(); - return owned; - } - - /** - * Merge `need` normal copies of `baseId` (the incoming, not yet stowed piece first, then temp, hand, board) into - * the elite — PRTS 卫戍协议/帮助 §干员的获得与精锐化: "发送1名【精锐】状态的该干员至手牌区(若消耗已部署至作战区的干员, - * 则发送至作战区对应位置)" (the user's playtest #6 follow-up confirms it). The tile (`mergeTile`): when a consumed copy - * stood on the board the elite takes its tile and facing — of several, the one that deploys first (the left board - * column first, top to bottom within a column — Battle.start's order) [ASSUMED]. The incoming copy is never deployed (a 突变细胞 transformation - * destroyed its carrier before the gain: that tile is no copy's). It replaces a deployed copy, so the deploy count - * never grows. Otherwise the elite goes to the hand, overflow temp — outside PREP too (a SETTLE merge's elite waits in - * temp through the next prep, tempDue). The copies' equipment returns to the hand ("干员晋级后已配发装备会回收至整备区"; - * overflow temp; with both full it stays on the elite, up to its equipPerChess (2) slots — any further item is - * destroyed with a log warning, as before the official rule) and an identical normal pair among it merges like any gain - * (checkItemMerges); their summons are removed, and an elite on the board - * gets its own summon stack (grantTokensFor: its loadout, like any deployment). Returns the elite piece (or null if - * the elite could not be stored). - * @param {string} baseId - * @param {any} incoming the acquired, not yet stowed copy (null: only owned copies) - */ - _mergeChess(baseId, incoming) { - const need = this.gd.mergeCount(baseId); - const goldenId = this.gd.goldenIdOf(baseId); - if (!(need > 1) || !goldenId) return null; - const locs = this._chessLocations().filter((l) => !this.gd.isGolden(l.piece.id) && this.gd.baseIdOf(l.piece.id) === baseId); - const consumed = []; - if (incoming) consumed.push({ piece: incoming, area: 'new' }); - for (const l of locs) { if (consumed.length >= need) break; consumed.push(l); } - if (consumed.length < need) return null; - let copies = 0; - const items = []; - for (const l of consumed) { - copies += l.piece.poolCopies || 0; - if (l.area !== 'new') this._detach(l); - this.removeTokensOf(l.piece.uid); - for (const it of l.piece.items || []) items.push(it); - l.piece.items = []; - } - const elite = this.newPiece('chess', goldenId, { poolCopies: copies }); - // this round's per-piece counters: the highest of the copies' (an elite made from 拉普兰德 that already saw their - // first refresh this round does not fire again this round — [ASSUMED] conservative, pieceRoundCount) - for (const l of consumed) { - const rc = l.piece.meta && l.piece.meta.round; - if (rc && rc.r === this.m.round) for (const [k, v] of Object.entries(rc.n)) this.bumpPieceRoundCount(elite, k, Math.max(0, v - this.pieceRoundCount(elite, k))); - } - const deployed = consumed.filter((l) => l.key && !this.board.has(l.key)).map((l) => ({ key: l.key, dir: pieceDir(l.piece) })); - const toTile = (t) => { elite.dir = parseDir(t.dir) || 'RIGHT'; this.board.set(t.key, elite); return 'board'; }; - const tile = mergeTile(deployed, (r, c) => this._legal(elite, r, c)); - let where = tile ? toTile(tile) : this.stow(elite, { allowTemp: true }); - // hand and temp full and no deployed tile legal for it (a terrain change not re-checked yet): it stays on the first - // deployed copy's tile rather than being lost, like a piece _evictIllegal finds no room for - if (!where && deployed.length) where = toTile(mergeTile(deployed)); - for (const it of items) { - if (this.stow(it, { allowTemp: true })) continue; - if (where && elite.items.length < this.gd.equipPerChess) { elite.items.push(it); continue; } - this.m.log.warn?.(`[match ${this.m.roomCode}] ${this.playerId}: returned item ${it.id} destroyed (no space)`); - } - // the returned equipment follows the auto-merge rule like any other gain ("已拥有2件同一初始装备时…自动合并") - this.checkItemMerges(); - // deployed like any operator placed by hand: its manually deployable summons join the hand (after the returned - // equipment, which would be lost in temp — a summon stack removed there comes back at the next round start) - if (where === 'board') this.grantTokensFor(elite); - if (!where) { - this.m.pool.give(baseId, copies); - this.m.toast(this, 'warn', '整备区已满,晋升的精锐干员无法放入'); - this.m.log.warn?.(`[match ${this.m.roomCode}] ${this.playerId}: merge result dropped (hand+temp full)`); - this.recompute(); - return null; - } - this.stats.merges++; - this.pushRewardOffer('merge'); - const rec = this.gd.chess(goldenId); - this.m.tickerFor('GOLDEN_CHAR', [this.name, rec ? rec.name : goldenId], { playerId: this.playerId }); - this.m.dispatch(this, 'onMerge', { kind: 'chess', piece: elite, baseId, consumed: consumed.map((l) => l.piece.uid), area: where }); - return elite; - } - - /** Promote a normal chess piece to its elite in place (升华, 博士投影). Takes extra pool copies when available. */ - promote(piece) { - if (!piece || piece.kind !== 'chess' || this.gd.isGolden(piece.id)) return false; - const goldenId = this.gd.goldenIdOf(piece.id); - if (!goldenId) return false; - const base = this.gd.baseIdOf(piece.id); - const extra = Math.max(0, this.gd.goldenCopies - (piece.poolCopies || 0)); - piece.poolCopies = (piece.poolCopies || 0) + this.m.pool.take(base, extra); - piece.id = goldenId; - this.recompute(); - return true; - } - - /** - * Transformation (突变细胞 "战斗结束后,装备者替换为高一阶的随机干员"; PRTS 卫戍协议:盟约 下半/PRTS盟约记录 备注 "生效时,原 - * 干员销毁,获得一名高一阶的随机初始干员(最高六阶)"): a destroy followed by a gain. The carrier is destroyed wherever it - * stands — a board tile is freed (the deploy count drops), its summons are removed, its pool copies return. Its - * equipment, the cell included, comes off first (PRTS 卫戍协议/帮助 "在失去该干员(干员出售、销毁、合并等)…时自动卸除"): to - * the hand, overflowing into temp, auto-merging like any gain. Then `newId` is gained like any other gained operator - * (acquireChess, onGain source 'transform'): the hand, overflow temp ("被发送至手牌区的物资优先从右到左填充空位"), and with - * both full it goes back to the pool ("整备区已满,获得的干员已返还"); it gets no summon card in the hand (only a deployment - * brings one), and a merge it completes follows the ordinary rule (_mergeChess: the elite on a consumed deployed copy's - * tile, else the hand — the carrier's freed tile is no copy's). An item that found no slot takes one the gain freed (a - * merge consumes copies), else stays on the gained operator up to its equipPerChess slots, else it is destroyed with a - * log warning (as in a merge). Official footage (bilibili BV1vzyVBuEN9, BV1Qkw1zMEoR; pointed out in PR #2): at the - * next prep the carrier's tile is empty, one more deployment is left and the new operator waits in the 整备区. - * @param {any} piece the carrier (an owned chess piece) - * @param {string} newId chess id gained in its place - * @returns {any} the gained piece (the elite when it completed a merge) or null - */ - transformChess(piece, newId) { - const loc = this.find(piece.uid); - if (!loc || loc.piece.kind !== 'chess' || !this.gd.chess(newId)) return null; - // 原干员销毁: off its tile / slot, its summons removed, its copies back to the pool - this._detach(loc); - this.removeTokensOf(piece.uid); - const items = piece.items || []; - piece.items = []; - this.returnCopies(piece); - // its equipment comes off first (the returned pair auto-merges, which may free a slot for the gain) - const left = items.filter((it) => !this.stow(it, { allowTemp: true })); - this.checkItemMerges(); - // 获得一名…干员: gained like any other gained operator - const np = this.acquireChess(newId, { source: 'transform' }); - for (const it of left) { - if (this.stow(it, { allowTemp: true })) continue; - if (np && this.find(np.uid) && np.items.length < this.gd.equipPerChess) { np.items.push(it); continue; } - this.m.log.warn?.(`[match ${this.m.roomCode}] ${this.playerId}: returned item ${it.id} destroyed (no space)`); - } - if (left.length) this.checkItemMerges(); - this.recompute(); - return np; - } - - /** Normal item → golden version in place (整备). */ - upgradeItem(piece) { - const rec = this.gd.item(piece.id); - if (!rec || rec.isGolden) return false; - const gid = rec.upgradeChessId || rec.goldenId; - if (!gid || !this.gd.item(gid)) return false; - piece.id = gid; - this.recompute(); - return true; - } - - /** - * Queue a reward offer: `count` DIFFERENT chess of tier min(level + offset, maxTier) at price 0 (pick 1). Each is a - * copy-weighted roll from the shared pool excluding the ones already drawn; a tier left without another chess tops - * up from the tier below (user playtest #6 item 19: the official promotion reward never offers one operator twice — - * the user's first-hand report; the normal shop's slots may repeat). The offer reserves no copies (the pick takes one). - */ - pushRewardOffer(source = 'merge', { tier = null, ids = null, label = null } = {}) { - const ro = this.gd.rewardOffer(); - const t = Number.isInteger(tier) ? tier : Math.min(this.shop.level + ro.tierOffset, ro.maxTier); - // an offer never shows one operator twice, whoever built the list (user playtest #6 item 19) - let list = Array.isArray(ids) ? [...new Set(ids)].filter((id) => this.gd.chess(id)) : null; - if (!list) { - list = []; - const fresh = (id) => !list.includes(id); - for (let i = 0; i < ro.count; i++) { - let id = null; - for (let tt = t; tt >= 1 && !id; tt--) id = this.m.pool.roll(this.m.rngShop, { tier: tt, filter: fresh }); - if (id) list.push(id); - } - } - if (!list.length) return null; - const offer = { tier: t, source, label: typeof label === 'string' && label ? label : null, slots: list.slice(0, MAX_OFFER_SLOTS).map((id) => ({ kind: 'chess', id, price: ro.price, sold: false })) }; - this.offers.push(offer); - this.dirty(); - return offer; - } - - /** - * Queue a free pick-one offer of items (凯瑟琳 定向投放, 娜仁图亚 见者有份); shown as shop.rewardOffer with slots of kind - * 'item' under its `label` (the effect's name; player report #6 after 0.1.0). - */ - pushItemOffer(ids, { source = 'effect', tier = null, label = null } = {}) { - const list = [...new Set(Array.isArray(ids) ? ids : [])].filter((id) => this.gd.item(id)).slice(0, MAX_OFFER_SLOTS); - if (!list.length) return null; - const offer = { tier: Number.isInteger(tier) ? tier : null, source, label: typeof label === 'string' && label ? label : null, slots: list.map((id) => ({ kind: 'item', id, price: 0, sold: false })) }; - this.offers.push(offer); - this.dirty(); - return offer; - } - - /** Item merge candidates: normal, mergeable, with a golden version. */ - _itemMergeable(id) { - const rec = this.gd.item(id); - if (!rec || rec.isGolden || rec.itemType !== 'EQUIP' || !rec.mergeable) return false; - const n = Number.isInteger(rec.upgradeNum) ? rec.upgradeNum : this.gd.itemMergeCount; - if (!(n > 1 && n < 100)) return false; - const gid = rec.upgradeChessId || rec.goldenId; - return !!(gid && this.gd.item(gid)); - } - - _itemLocations(id) { - const out = []; - for (let i = 0; i < this.temp.length; i++) if (this.temp[i] && this.temp[i].kind === 'item' && this.temp[i].id === id) out.push({ piece: this.temp[i], area: 'temp', idx: i }); - for (let i = 0; i < this.hand.length; i++) if (this.hand[i] && this.hand[i].kind === 'item' && this.hand[i].id === id) out.push({ piece: this.hand[i], area: 'hand', idx: i }); - for (const holder of this.allChess()) for (const it of holder.items || []) if (it.id === id) out.push({ piece: it, area: 'equipped', holder }); - return out; - } - - completesItemMerge(id) { - if (!this._itemMergeable(id)) return false; - const rec = this.gd.item(id); - const n = Number.isInteger(rec.upgradeNum) ? rec.upgradeNum : this.gd.itemMergeCount; - return this._itemLocations(id).length + 1 >= n; - } - - /** - * Acquire an item (buy, supply card, grant). Merges with an identical normal copy (hand/temp/equipped) into the - * golden item (to the hand). Returns the owned piece or null. - * `deferMerge` (effectsMeta, while onPrepEnd is on the stack): stow it — hand, else temp — and do not merge in this - * call, even when an identical normal copy is already owned. The next prep's start runs checkItemMerges. Nothing - * already equipped is taken off for the fight about to start. Hand and temp both full keeps the 「整备区已满,获得的装备已销毁」 - * outcome. [ASSUMED] every item granted at 休整期结束, not only 维多利亚's 战栗维式重锤 (owner's decision 2026-10-04). - */ - acquireItem(itemId, { source = 'grant', toTemp = false, silent = false, deferMerge = false } = {}) { - const rec = this.gd.item(itemId); - if (!rec) return null; - let piece = this.newPiece('item', itemId); - // A prep-end grant may sit beside an identical copy until the next prep. The invariant counts only copies - // without this mark, so the fight that is about to start is not reported as a missed merge. - if (deferMerge) piece.deferMerge = true; - if (!deferMerge && this.completesItemMerge(itemId)) { - piece = this._mergeItem(itemId, piece); - if (!piece) return null; - } else if (!this.stow(piece, { allowTemp: true, toTemp })) { - this.m.toast(this, 'warn', '整备区已满,获得的装备已销毁'); - return null; - } - this.recompute(); - if (!silent) this.m.dispatch(this, 'onGain', { piece, kind: 'item', source }); - return piece; - } - - _mergeItem(itemId, incoming = null) { - const rec = this.gd.item(itemId); - const need = Number.isInteger(rec.upgradeNum) ? rec.upgradeNum : this.gd.itemMergeCount; - const locs = this._itemLocations(itemId); - const consumed = incoming ? [{ piece: incoming, area: 'new' }] : []; - for (const l of locs) { if (consumed.length >= need) break; consumed.push(l); } - if (consumed.length < need) return null; - // remember where equipped twins sat: with a full hand AND a full temp the golden item takes the first one's slot - const slotOf = consumed.filter((l) => l.area === 'equipped').map((l) => ({ holder: l.holder, idx: l.holder.items.indexOf(l.piece) })); - for (const l of consumed) if (l.area !== 'new') this._detach(l); - const golden = this.newPiece('item', rec.upgradeChessId || rec.goldenId); - if (!this.stow(golden, { allowTemp: true })) { - const at = slotOf[0]; - if (!at || !this.find(at.holder.uid)) { - this.m.toast(this, 'warn', '整备区已满,合成的装备已销毁'); - return null; - } - at.holder.items.splice(Math.max(0, Math.min(at.idx, at.holder.items.length)), 0, golden); - } - this.stats.itemMerges++; - this.m.dispatch(this, 'onMerge', { kind: 'item', piece: golden, itemId, consumed: consumed.map((l) => l.piece.uid) }); - return golden; - } - - /** Merge every identical normal item pair currently owned (after equips / returns). */ - checkItemMerges() { - for (let guard = 0; guard < 20; guard++) { - const counts = new Map(); - const scan = (p) => { if (p && p.kind === 'item' && this._itemMergeable(p.id)) counts.set(p.id, (counts.get(p.id) || 0) + 1); }; - for (const p of this.hand) scan(p); - for (const p of this.temp) scan(p); - for (const holder of this.allChess()) for (const it of holder.items || []) scan(it); - let did = false; - for (const [id, n] of counts) { - const rec = this.gd.item(id); - const need = Number.isInteger(rec.upgradeNum) ? rec.upgradeNum : this.gd.itemMergeCount; - if (n >= need) { if (this._mergeItem(id, null)) did = true; break; } - } - if (!did) return; - } - } - - // ================================================================================================= - // economy - - addFunds(n, { reason = '' } = {}) { - if (!Number.isFinite(n) || n === 0) return 0; - const v = Math.trunc(n); - const before = this.funds; - this.funds = Math.max(0, this.funds + v); - if (v > 0) this.stats.fundsGained += v; - this.dirty(); - return this.funds - before; - } - - spend(n) { - const v = Number.isFinite(n) ? Math.max(0, Math.trunc(n)) : 0; - if (v > this.funds) return false; - this.funds -= v; - this.stats.gold += v; - this.round.spent += v; - this.dirty(); - return true; - } - - /** onSpend, dispatched once a payment's action is complete (buy / refresh / levelUp / reward / effect). */ - _afterSpend(amount, reason) { - if (!(amount > 0)) return; - this.m.dispatch(this, 'onSpend', { amount, reason, total: this.stats.gold }); - } - - /** - * Bond layer gain (prep-side). `requireActive` = "使已激活的【X】层数+N". Returns layers added: at most the room left - * under BOND_LAYER_CAP (999, shared/constants.js) — a gain at the cap adds 0 and dispatches nothing. - */ - addLayers(bondId, n, { requireActive = false, reason = '' } = {}) { - if (!this.gd.bond(bondId) || !Number.isFinite(n) || n <= 0) return 0; - if (requireActive && !(this.bonds[bondId] && this.bonds[bondId].active)) return 0; - const before = this.layers[bondId] || 0; - const add = layerGainRoom(before, Math.floor(n)); - if (add <= 0) return 0; - this.layers[bondId] = before + add; - this.recompute(); - this.m.dispatch(this, 'onLayers', { bondId, from: before, to: before + add, reason }); - return add; - } - - // ================================================================================================= - // shop - - /** Effective price of a shop slot after onPrice modifiers (never negative). */ - priceOf(slot) { - if (!slot) return 0; - const ev = { slot, kind: slot.kind, id: slot.id, price: slot.basePrice }; - this.m.dispatch(this, 'onPrice', ev, { quiet: true }); - const p = Number(ev.price); - return Number.isFinite(p) ? Math.max(0, Math.round(p)) : slot.basePrice; - } - - _rollChessSlot() { - const id = this.m.pool.roll(this.m.rngShop, { maxTier: this.shop.level }); - return id ? { kind: 'chess', id, basePrice: this.gd.chessPrice(id), frozen: false, sold: false } : null; - } - - _rollItemSlot() { - const id = this.m.pool.rollItem(this.m.rngShop, this.shop.level); - return id ? { kind: 'item', id, basePrice: this.gd.itemPrice(id), frozen: false, sold: false } : null; - } - - /** - * Reroll the shop. keepFrozen → frozen unsold slots survive (round start); otherwise everything is rerolled - * (manual refresh). Slot counts follow the current level. - */ - rollShop({ keepFrozen = false } = {}) { - const { chess: nChess, item: nItem } = this.gd.shopSlots(this.shop.level); - const old = this.shop.slots; - const layout = this.shop.layout || { chess: old.length, item: 0 }; - const oldChess = old.slice(0, layout.chess); - const oldItems = old.slice(layout.chess); - const keep = (s, kind) => (keepFrozen && s && s.kind === kind && !s.sold && s.frozen ? { ...s } : null); - // frozen slots keep their position; sold / empty / unfrozen positions are rerolled - const slots = []; - for (let i = 0; i < nChess; i++) slots.push(keep(oldChess[i], 'chess') ?? this._rollChessSlot()); - for (let i = 0; i < nItem; i++) slots.push(keep(oldItems[i], 'item') ?? this._rollItemSlot()); - this.shop.slots = slots; - this.shop.layout = { chess: nChess, item: nItem }; - for (const s of slots) if (s) s.frozen = this.shop.frozen; - this.dirty(); - } - - /** Combat start: unfrozen slots are emptied (research 01 A1). */ - clearUnfrozenShop() { - this.shop.slots = this.shop.slots.map((s) => (s && s.frozen && !s.sold ? s : null)); - this.dirty(); - } - - // ================================================================================================= - // gating - - _gate({ allowWhenReady = false } = {}) { - if (!this.alive) return fail(ERR.ELIMINATED); - if (this.m.phase !== PHASE.PREP) return fail(ERR.WRONG_PHASE); - if (this.ready && !allowWhenReady) return fail(ERR.WRONG_PHASE, 'ready'); - return null; - } - - // ================================================================================================= - // intent handlers - - buy(slotIdx) { - const g = this._gate(); if (g) return g; - if (!Number.isInteger(slotIdx) || slotIdx < 0 || slotIdx >= this.shop.slots.length) return fail(ERR.BAD_TARGET); - const slot = this.shop.slots[slotIdx]; - if (!slot) return fail(ERR.BAD_TARGET); - if (slot.sold) return fail(ERR.SOLD_OUT); - const price = this.priceOf(slot); - if (this.funds < price) return fail(ERR.NO_FUNDS); - const handFull = freeSlot(this.hand) < 0; - let piece; - if (slot.kind === 'chess') { - const rec = this.gd.chess(slot.id); - if (!rec) return fail(ERR.BAD_TARGET); - const base = this.gd.baseIdOf(slot.id); - const need = rec.isGolden ? this.gd.goldenCopies : 1; - if (this.m.pool.has(base) && this.m.pool.left(base) < need) return fail(ERR.SOLD_OUT); - if (handFull && !this.completesChessMerge(slot.id)) return fail(ERR.HAND_FULL); - this.spend(price); - slot.sold = true; - piece = this.acquireChess(slot.id, { source: 'buy' }); - } else { - if (!this.gd.item(slot.id)) return fail(ERR.BAD_TARGET); - if (handFull && !this.completesItemMerge(slot.id)) return fail(ERR.HAND_FULL); - this.spend(price); - slot.sold = true; - piece = this.acquireItem(slot.id, { source: 'buy' }); - } - this.stats.buys++; - this.round.buys++; - this.m.dispatch(this, 'onBuy', { piece, slot, price, kind: slot.kind }); - this._afterSpend(price, 'buy'); - this.recompute(); - return OK; - } - - refresh() { - const g = this._gate(); if (g) return g; - const free = this.shop.freeRefreshes > 0; - const price = free ? 0 : this.gd.refreshPrice; - if (!free && this.funds < price) return fail(ERR.NO_FUNDS); - if (free) this.shop.freeRefreshes--; - else this.spend(price); - this.rollShop({ keepFrozen: false }); - this.stats.refreshes++; - this.round.refreshes++; - this.m.dispatch(this, 'onRefresh', { slots: this.shop.slots, free, price }); - this._afterSpend(price, 'refresh'); - this.dirty(); - return OK; - } - - freeze() { - const g = this._gate(); if (g) return g; - this.shop.frozen = !this.shop.frozen; - for (const s of this.shop.slots) if (s && !s.sold) s.frozen = this.shop.frozen; - this.dirty(); - return OK; - } - - levelUp() { - const g = this._gate(); if (g) return g; - if (this.shop.level >= this.gd.maxShopLevel) return fail(ERR.MAX_LEVEL); - const price = Math.max(0, this.shop.upgradePrice); - if (this.funds < price) return fail(ERR.NO_FUNDS); - this.spend(price); - this.shop.level++; - this.shop.upgradePrice = this.gd.upgradeBase(this.shop.level) ?? 0; - this.m.tickerFor('SHOP_LEVEL', [this.name, String(this.shop.level)], { playerId: this.playerId, param: String(this.shop.level) }); - this.m.dispatch(this, 'onLevelUp', { level: this.shop.level, price }); - this._afterSpend(price, 'levelUp'); - this.dirty(); - return OK; - } - - sell(uid) { - const g = this._gate(); if (g) return g; - const loc = this.find(uid); - if (!loc) return fail(ERR.BAD_TARGET); - if (loc.piece.kind !== 'chess') return fail(ERR.BAD_TARGET, loc.piece.kind === 'item' ? 'items cannot be sold' : 'tokens cannot be sold'); - const piece = loc.piece; - // its equipment returns to the hand (overflow temp): refuse rather than destroy it when there is no room - const room = this.hand.filter((x) => x == null).length + this.temp.filter((x) => x == null).length + (loc.area === 'hand' || loc.area === 'temp' ? 1 : 0); - if ((piece.items || []).length > room) return fail(ERR.HAND_FULL, 'no room for the equipment'); - this._detach(loc); - this.removeTokensOf(piece.uid); - for (const it of piece.items || []) { - if (!this.stow(it, { allowTemp: true })) this.m.log.warn?.(`[match ${this.m.roomCode}] ${this.playerId}: item ${it.id} lost on sell (no space)`); - } - piece.items = []; - this.returnCopies(piece); - const ev = { piece, gain: this.gd.sellPrice(piece.id) }; - this.m.dispatch(this, 'onSold', ev); - const gain = Number.isFinite(ev.gain) ? Math.max(0, Math.trunc(ev.gain)) : 1; - this.addFunds(gain, { reason: 'sell' }); - this.stats.sells++; - this.round.sells++; - this.checkItemMerges(); - this.recompute(); - return OK; - } - - /** - * g.move {uid, to, dir?}. Pieces: chess / token between board, hand and temp; items only within the hand. `dir` - * (UP|RIGHT|DOWN|LEFT, absent ⇒ RIGHT; `to.dir` is read when the top-level one is absent) is the facing of a piece - * moved onto the board — onto its own tile it re-orients the piece in place. - */ - move(uid, to, dir) { - const g = this._gate(); if (g) return g; - const loc = this.find(uid); - if (!loc || loc.area === 'equipped') return fail(ERR.BAD_TARGET); - if (!to || typeof to !== 'object') return fail(ERR.BAD_TARGET); - const piece = loc.piece; - if (to.area === 'board') { - if (piece.kind === 'item') return fail(ERR.BAD_TARGET, 'items are equipped, not placed'); - const d = parseDir(dir !== undefined ? dir : to.dir); - if (!d) return fail(ERR.BAD_TARGET, 'bad dir'); - return piece.kind === 'token' ? this._moveTokenToBoard(loc, to.row, to.col, d) : this._moveChessToBoard(loc, to.row, to.col, d); - } - if (to.area === 'hand') { - if (!Number.isInteger(to.idx) || to.idx < 0 || to.idx >= this.hand.length) return fail(ERR.BAD_TARGET); - return this._moveToHand(loc, to.idx); - } - return fail(ERR.BAD_TARGET); - } - - _placementOf(piece) { - const rec = piece.kind === 'token' ? this.gd.token(piece.id) : this.gd.chess(piece.id); - // elite 歌蕾蒂娅 + HOK-Y may use a 高台; the module is this player's loadout (owner's decision 2026-10-04) - return placeClass(this, rec); - } - - /** - * Where piece may stand on (r, c): the deploy map of its position class (board.js canPlace) and, for a summon whose - * text reads "只能部署在召唤者攻击范围内" (tokens.json `ownerRange`: 伺夜's 狼群, 缪尔赛思's 流形), a tile of its owner's - * attack range (summonRange). `owner` = the owner's position after the move being checked ({ key, piece, dir }: a - * summon swapped with its own owner). - */ - _legal(piece, r, c, owner = null) { - if (!canPlace(this.deployMap(), this._placementOf(piece), r, c)) return false; - const range = this.summonRange(piece, owner); - return !range || range.has(tileKey(r, c)); - } - - /** - * The 'r,c' keys of the attack range of a range-bound summon's owner (player report #9 after 0.1.0: 伺夜's tactical - * point could be placed anywhere; PRTS 狼群 特性 "只能部署在召唤者攻击范围内"): the owner's loadout-resolved range grid - * (shared/loadoutRecord.js attackRangeGrid — what the deploy wheel previews) rotated by its facing around its board - * tile (board.js ownerRangeKeys). Null when the piece is not range-bound or its owner is not on the board (the other - * rules refuse such a placement). - * @param {any} piece - * @param {{ key: string, piece: any, dir: string } | null} [owner] the owner's position to use instead of its current one - * @returns {Set | null} - */ - summonRange(piece, owner = null) { - if (!piece || piece.kind !== 'token' || this.gd.token(piece.id)?.ownerRange !== true) return null; - let at = owner; - if (!at) for (const [key, p] of this.board) if (p.uid === piece.ownerUid && p.kind === 'chess') { at = { key, piece: p, dir: pieceDir(p) }; break; } - const rec = at && this.gd.chess(at.piece.id); - if (!rec) return null; - const grid = attackRangeGrid(loadoutRecord(rec, resolveRecordLoadout(rec, this.loadoutFor(rec)))) || rec.rangeGrid; - const [r, c] = parseKey(at.key); - return ownerRangeKeys(grid, r, c, at.dir); - } - - /** - * Range-bound summons left outside their owner's attack range (the owner re-oriented in place, promoted, its loadout - * changed, moved with no room to take its summons back) go back onto their stack — a summon still inside stays - * [ASSUMED: the official moves every summon of a MOVED owner back, PRTS 卫戍协议/帮助 "移动干员时,其所属召唤物全部退场 - * 并重置至手牌区"; an in-place re-orientation keeps the ones it can]. One with no stack and no free hand / temp slot - * leaves the board: its stack comes back at the next round start like a summon stack removed from temp at a prep - * deadline (startRound → grantTokensFor, "干员所属召唤物会于下一回合返还"), so no illegal placement reaches the - * battle. Returns the number taken off the board; a toast names them. - */ - _liftOutOfRange() { - const back = [], gone = []; - for (const [k, p] of [...this.board]) { - if (p.kind !== 'token') continue; - const range = this.summonRange(p); - if (!range || range.has(k)) continue; - this.board.delete(k); - (this._returnToken(p, null, { allowTemp: true }) ? back : gone).push(this.gd.token(p.id)?.name || p.id); - } - if (back.length) this.m.toast(this, 'warn', `${back.join('、')}只能部署在召唤者攻击范围内,已退回整备区`); - if (gone.length) this.m.toast(this, 'warn', `${gone.join('、')}只能部署在召唤者攻击范围内,整备区已满,下回合返还`); - return back.length + gone.length; - } - - /** - * Whether every range-bound summon of `owner` that the range from (ownerKey, dir) leaves out can go back onto its - * stack or into a free hand / temp slot (_reorient refuses otherwise: the player's own re-orientation never costs a - * summon, like withdrawing one into a full hand gives HAND_FULL). - */ - _roomForOutOfRange(owner, ownerKey, dir) { - const at = { key: ownerKey, piece: owner, dir }; - const stacks = [...this.hand, ...this.temp].filter((p) => p && p.kind === 'token' && p.ownerUid === owner.uid); - const needSlot = new Set(); - for (const [k, p] of this.board) { - if (p.kind !== 'token' || p.ownerUid !== owner.uid || stacks.some((s) => s.id === p.id)) continue; - const range = this.summonRange(p, at); - if (range && !range.has(k)) needSlot.add(p.id); - } - return needSlot.size <= [...this.hand, ...this.temp].filter((p) => p == null).length; - } - - _moveChessToBoard(loc, r, c, dir = 'RIGHT') { - const piece = loc.piece; - if (!inField(r, c) || !this._legal(piece, r, c)) return fail(ERR.BAD_TILE); - const key = tileKey(r, c); - const occ = this.board.get(key) || null; - if (occ === piece) return this._reorient(piece, dir); - if (loc.area === 'board') { - // board → board: move or swap (the occupant must be legal on the source tile and keeps its own facing); an - // operator that changes its tile takes its summons off the board (back onto their stacks, _liftTokensOf) — its - // own summon swapped onto its old tile included, so that one needs no tile check - if (occ) { - const [sr, sc] = parseKey(loc.key); - const ownSummon = occ.kind === 'token' && occ.ownerUid === piece.uid; - if (!ownSummon && !this._legal(occ, sr, sc)) return fail(ERR.BAD_TILE); - this.board.set(loc.key, occ); - if (occ.kind === 'chess') this._liftTokensOf(occ.uid); - } else { - this.board.delete(loc.key); - } - piece.dir = dir; - this.board.set(key, piece); - this._liftTokensOf(piece.uid); - this.recompute(); - return OK; - } - // hand/temp → board - if (!occ || occ.kind !== 'chess') { - if (this.deployCount >= this.deployCap) return fail(ERR.BOARD_FULL); - } - this._detach(loc); - if (occ) { - this.board.delete(key); - if (occ.kind === 'chess') { - this.removeTokensOf(occ.uid); - this._putBack(loc, occ); - } else { - this._returnToken(occ, loc); - } - } - piece.dir = dir; - this.board.set(key, piece); - this.grantTokensFor(piece); - this.recompute(); - return OK; - } - - /** - * In-place re-orientation (g.move onto the piece's own tile with a new direction). An owner's range-bound summons the - * new range leaves out go back onto their stack (recompute → _liftOutOfRange); HAND_FULL (nothing changes) when one - * of them would have nowhere to go. - */ - _reorient(piece, dir) { - if (pieceDir(piece) === dir) return OK; - if (piece.kind === 'chess') { - const loc = this.find(piece.uid); - if (loc && loc.area === 'board' && !this._roomForOutOfRange(piece, loc.key, dir)) return fail(ERR.HAND_FULL); - } - piece.dir = dir; - this.recompute(); - return OK; - } - - /** Put a piece into the container slot described by `loc` (hand/temp idx), or anywhere free. */ - _putBack(loc, piece) { - if (loc.area === 'hand' && this.hand[loc.idx] == null) { this.hand[loc.idx] = piece; return true; } - if (loc.area === 'temp' && this.temp[loc.idx] == null) { this._putTemp(loc.idx, piece); return true; } - return !!this.stow(piece, { allowTemp: true }); - } - - /** - * A board token goes back to the hand: merge into its owner's stack, else occupy a free slot. `allowTemp: false` - * (the player's own withdrawal): a summon stack is a card, so with a full hand and no stack to join it is refused - * like any other card (research 01 A1) instead of overflowing into temp. - */ - _returnToken(tok, preferLoc = null, { allowTemp = true } = {}) { - const stack = [...this.hand, ...this.temp].find((p) => p && p.kind === 'token' && p.ownerUid === tok.ownerUid && p.id === tok.id); - if (stack) { stack.count = (stack.count || 1) + (tok.count || 1); return true; } - tok.count = tok.count || 1; - if (preferLoc && preferLoc.area === 'hand' && this.hand[preferLoc.idx] == null) { this.hand[preferLoc.idx] = tok; return true; } - return !!this.stow(tok, { allowTemp }); - } - - _moveTokenToBoard(loc, r, c, dir = 'RIGHT') { - const piece = loc.piece; - if (!inField(r, c)) return fail(ERR.BAD_TILE); - const key = tileKey(r, c); - const occ = this.board.get(key) || null; - // a summon dragged onto its own owner swaps with it: a range-bound one must be inside the owner's range from the - // tile the owner takes (the summon's, with the owner's facing) - const ownerAfter = loc.area === 'board' && occ && occ !== piece && occ.uid === piece.ownerUid ? { key: loc.key, piece: occ, dir: pieceDir(occ) } : null; - if (!this._legal(piece, r, c, ownerAfter)) return fail(ERR.BAD_TILE); - if (occ === piece) return this._reorient(piece, dir); - if (loc.area === 'board') { - // board → board: move or swap; an operator swapped onto the summon's old tile changed its tile, so its other - // summons go back onto their stacks like any moved operator (_liftTokensOf; the summon just placed stays) - if (occ) { - const [sr, sc] = parseKey(loc.key); - if (!this._legal(occ, sr, sc)) return fail(ERR.BAD_TILE); - this.board.set(loc.key, occ); - } else { - this.board.delete(loc.key); - } - piece.dir = dir; - this.board.set(key, piece); - if (occ && occ.kind === 'chess') this._liftTokensOf(occ.uid, piece); - this.recompute(); - return OK; - } - if (occ) return fail(ERR.BAD_TILE, 'occupied'); - // owner must be on the board for its summons to be deployable - const owner = [...this.board.values()].find((p) => p.uid === piece.ownerUid); - if (!owner) return fail(ERR.BAD_TARGET, 'owner not deployed'); - if ((piece.count || 1) > 1) { - piece.count -= 1; - const one = this.newPiece('token', piece.id, { count: 1, ownerUid: piece.ownerUid, dir }); - this.board.set(key, one); - } else { - this._detach(loc); - piece.count = 1; - piece.dir = dir; - this.board.set(key, piece); - } - this.recompute(); - return OK; - } - - _moveToHand(loc, idx) { - const piece = loc.piece; - const occ = this.hand[idx]; - if (occ === piece) return OK; - if (piece.kind === 'token') { - if (loc.area !== 'board') { - // hand/temp token stack: plain slot move / swap inside the containers - return this._swapContainers(loc, idx); - } - this.board.delete(loc.key); - if (!this._returnToken(piece, occ == null ? { area: 'hand', idx } : null, { allowTemp: false })) { - this.board.set(loc.key, piece); - return fail(ERR.HAND_FULL); - } - this.recompute(); - return OK; - } - if (loc.area === 'board') { - // withdraw (撤退): to an empty slot, or swap with a chess occupant (which takes the board tile). The piece's own - // summon stacks leave the hand with it, so a slot holding one counts as free (a full hand nets zero cards) - const ownStack = (p) => !!p && p.kind === 'token' && p.ownerUid === piece.uid; - if (occ == null || ownStack(occ)) { - this.board.delete(loc.key); - this.removeTokensOf(piece.uid); - this.hand[idx] = piece; - } else if (occ.kind === 'chess') { - const [sr, sc] = parseKey(loc.key); - if (!this._legal(occ, sr, sc)) return fail(ERR.BAD_TILE); - // the bench card takes the withdrawn piece's tile with that tile's facing - occ.dir = pieceDir(piece); - this.board.set(loc.key, occ); - this.hand[idx] = piece; - this.removeTokensOf(piece.uid); - this.grantTokensFor(occ); - } else { - let j = freeSlot(this.hand); - if (j < 0) j = this.hand.findIndex(ownStack); - if (j < 0) return fail(ERR.HAND_FULL); - this.board.delete(loc.key); - this.removeTokensOf(piece.uid); - this.hand[j] = piece; - } - this.recompute(); - return OK; - } - return this._swapContainers(loc, idx); - } - - /** hand/temp → hand slot: move into an empty slot or swap with the occupant. */ - _swapContainers(loc, idx) { - const piece = loc.piece; - const occ = this.hand[idx]; - this._detach(loc); - this.hand[idx] = piece; - if (occ) { - if (loc.area === 'hand') this.hand[loc.idx] = occ; - else this._putTemp(loc.idx, occ); - } - this.recompute(); - return OK; - } - - /** - * g.equip {itemUid, targetUid, replaceUid?}. A third item on a carrier with both slots used replaces the equipped item - * the player picked in the replace dialog (`replaceUid`, research 09 §1.2 UseEquipUp.unloadInstId; absent ⇒ the - * oldest); the replaced item is destroyed. A `replaceUid` that is not one of the target's equipped items is refused. - */ - equip(itemUid, targetUid, replaceUid = null) { - const g = this._gate(); if (g) return g; - const iloc = this.find(itemUid); - if (!iloc || iloc.piece.kind !== 'item' || (iloc.area !== 'hand' && iloc.area !== 'temp')) return fail(ERR.BAD_TARGET); - const rec = this.gd.item(iloc.piece.id); - if (!rec || rec.itemType !== 'EQUIP') return fail(ERR.BAD_TARGET, 'not equipment'); - const tloc = this.find(targetUid); - if (!tloc || tloc.piece.kind !== 'chess' || tloc.area === 'equipped') return fail(ERR.BAD_TARGET); - const item = iloc.piece; - const target = tloc.piece; - if (replaceUid != null && !(Number.isInteger(replaceUid) && (target.items || []).some((x) => x.uid === replaceUid))) return fail(ERR.BAD_TARGET, 'replace: not equipped on the target'); - const consume = typeof rec.kind === 'string' && rec.kind.startsWith('consume_on_equip'); - if (consume) { - const key = 'item:' + itemKey(item.id); - if (!this.m.registry.has(key)) return fail(ERR.BAD_TARGET, 'effect not available'); - const ev = { item, target, golden: !!rec.isGolden, keep: false, error: null, consumed: true }; - this.m.dispatchItem(this, item, target, 'onEquip', ev); - if (ev.error) return fail(ERR[ev.error] ? ev.error : ERR.BAD_TARGET, typeof ev.detail === 'string' ? ev.detail : undefined); - // the handler may have destroyed the target (信标) — resolve the item again - const again = this.find(item.uid); - if (again && again.area !== 'equipped') this._detach(again); - if (ev.keep) { - const holder = this.find(target.uid); - if (holder && holder.piece.kind === 'chess') this._attach(holder.piece, item); - } - this.stats.itemsEquipped++; - this.checkItemMerges(); - this.recompute(); - return OK; - } - this._detach(iloc); - if (this.completesItemMerge(item.id)) { - // an identical normal copy is already owned (equipped somewhere): merge instead of equipping — the golden - // item goes to the hand (research 04 §2 "copies in hand and on operators both count") - if (this._mergeItem(item.id, item)) { this.recompute(); return OK; } - if (!this.find(item.uid)) this.stow(item, { allowTemp: true }); - } - this._attach(target, item, replaceUid); - this.stats.itemsEquipped++; - this.m.dispatchItem(this, item, target, 'onEquip', { item, target, golden: !!rec.isGolden, consumed: false }); - this.checkItemMerges(); - this.recompute(); - return OK; - } - - /** - * Attach an item. With both slots used the item `replaceUid` names (the player's pick) is replaced, else the oldest; - * the replaced item is destroyed. - */ - _attach(target, item, replaceUid = null) { - target.items = target.items || []; - while (target.items.length >= this.gd.equipPerChess) { - const i = replaceUid != null ? target.items.findIndex((x) => x.uid === replaceUid) : -1; - const [old] = target.items.splice(i >= 0 ? i : 0, 1); - replaceUid = null; - this.m.dispatchItem(this, old, target, 'onDestroy', { item: old, holder: target, reason: 'replace' }); - } - target.items.push(item); - } - - /** g.art {itemUid, row, col, dir?}: the Art's range grid is rotated by `dir` (absent ⇒ RIGHT). */ - useArt(itemUid, row, col, dir) { - const g = this._gate(); if (g) return g; - const d = parseDir(dir); - if (!d) return fail(ERR.BAD_TARGET, 'bad dir'); - const loc = this.find(itemUid); - if (!loc || loc.piece.kind !== 'item' || (loc.area !== 'hand' && loc.area !== 'temp')) return fail(ERR.BAD_TARGET); - const rec = this.gd.item(loc.piece.id); - if (!rec || rec.itemType !== 'MAGIC') return fail(ERR.BAD_TARGET, 'not an art'); - if (!inField(row, col)) return fail(ERR.BAD_TILE); - if (this.round.arts >= this.gd.maxArtsPerRound) return fail(ERR.BAD_TARGET, 'art limit'); - const key = 'item:' + itemKey(loc.piece.id); - if (!this.m.registry.has(key)) return fail(ERR.BAD_TARGET, 'effect not available'); - const grid = Array.isArray(rec.rangeGrid) && rec.rangeGrid.length ? rec.rangeGrid : [[0, 0]]; - const targets = []; - for (const [dr, dc] of grid) { - const [tr, tc] = offsetTile(row, col, dr, dc, d); - const p = this.board.get(tileKey(tr, tc)); - if (p) targets.push(p); - } - const ev = { item: loc.piece, row, col, dir: d, targets, error: null, used: true }; - this.m.dispatchItem(this, loc.piece, null, 'onArt', ev); - if (ev.error) return fail(ERR[ev.error] ? ev.error : ERR.BAD_TARGET, typeof ev.detail === 'string' ? ev.detail : undefined); - if (ev.used !== false) { - const again = this.find(itemUid); - if (again) this._detach(again); - this.round.arts++; - } - this.recompute(); - return OK; - } - - /** - * g.destroy {uid}: an item in the hand / temp (Arts included). Equipped items are locked (research 04 §2 / addendum: - * they leave the operator only on promotion, merge or sale); replacing one is g.equip's `replaceUid`. - */ - destroy(uid) { - const g = this._gate(); if (g) return g; - const loc = this.find(uid); - if (!loc || loc.piece.kind !== 'item') return fail(ERR.BAD_TARGET, 'only items can be destroyed'); - if (loc.area === 'equipped') return fail(ERR.BAD_TARGET, 'equipped items are locked'); - this._detach(loc); - this.m.dispatchItem(this, loc.piece, loc.holder || null, 'onDestroy', { item: loc.piece, holder: loc.holder || null, reason: 'player' }); - this.recompute(); - return OK; - } - - pickReward(idx) { - const g = this._gate(); if (g) return g; - const offer = this.offers[0]; - if (!offer) return fail(ERR.BAD_TARGET, 'no reward'); - if (!Number.isInteger(idx) || idx < 0 || idx >= offer.slots.length) return fail(ERR.BAD_TARGET); - const slot = offer.slots[idx]; - if (!slot || slot.sold) return fail(ERR.SOLD_OUT); - const handFull = freeSlot(this.hand) < 0; - if (slot.kind === 'item') { - if (!this.gd.item(slot.id)) return fail(ERR.BAD_TARGET); - if (handFull && !this.completesItemMerge(slot.id)) return fail(ERR.HAND_FULL); - } else { - const rec = this.gd.chess(slot.id); - if (!rec) return fail(ERR.BAD_TARGET); - const base = this.gd.baseIdOf(slot.id); - const need = rec.isGolden ? this.gd.goldenCopies : 1; - if (this.m.pool.has(base) && this.m.pool.left(base) < need) return fail(ERR.SOLD_OUT); - if (handFull && !this.completesChessMerge(slot.id)) return fail(ERR.HAND_FULL); - } - const price = Number.isFinite(slot.price) && slot.price > 0 ? Math.trunc(slot.price) : 0; - if (price > this.funds) return fail(ERR.NO_FUNDS); - slot.sold = true; - this.offers.shift(); - if (price > 0) this.spend(price); - if (slot.kind === 'item') this.acquireItem(slot.id, { source: 'reward' }); - else this.acquireChess(slot.id, { source: 'reward' }); - this._afterSpend(price, 'reward'); - this.recompute(); - return OK; - } - - setReady(on) { - if (!this.alive) return fail(ERR.ELIMINATED); - if (this.m.phase !== PHASE.PREP) return fail(ERR.WRONG_PHASE); - if (on && !this.tempEmpty) return fail(ERR.TEMP_NOT_EMPTY); - if (this.ready === !!on) return OK; - this.ready = !!on; - // un-ready: the player can act again, so what overflowed while it was ready is due at this prep's deadline - if (!on) for (const p of this.temp) if (p && this.tempDue(p) > this.prepsEnded) this._tempDue.set(p.uid, this.prepsEnded); - this.dirty(); - this.m.onReadyChanged(this); - return OK; - } - - /** - * Prep deadline: every temp piece due at this prep (tempDue ≤ prepsEnded) is resolved — a chess is sold back (its - * pool copies return, its summons are removed), items are destroyed, the summon stack of an owner still on the board - * is removed and comes back at the next round start (startRound → grantTokensFor; PRTS "干员所属召唤物会于下一回合 - * 返还"). Pieces that overflowed after the player could no longer act on them (after Ready, at the prep end) are kept - * for the next prep. - */ - resolveTemp() { - let changed = false; - for (let i = 0; i < this.temp.length; i++) { - const p = this.temp[i]; - if (!p || this.tempDue(p) > this.prepsEnded) continue; - this.temp[i] = null; - this._tempDue.delete(p.uid); - changed = true; - if (p.kind === 'chess') { - this.removeTokensOf(p.uid); - this.returnCopies(p); - } - } - if (changed) this.recompute(); - } - - // ================================================================================================= - // round lifecycle helpers (called by Match) - - startRound(r) { - this.round = { refreshes: 0, buys: 0, sells: 0, spent: 0, gainedChess: 0, arts: 0 }; - this.pendingLayerGains = null; // settled (or lapsed) at the last SETTLE - if (r > 1) this.shop.upgradePrice = Math.max(0, this.shop.upgradePrice - 1); - // onIncome handlers may rewrite ev.income / ev.pending (e.g. 老鲤 withholds R1–R2 income until R3) - const ev = { round: r, income: this.gd.income(r), pending: this.pendingFunds }; - this.pendingFunds = 0; - this.m.dispatch(this, 'onIncome', ev); - const nonNeg = (v) => (Number.isFinite(v) && v > 0 ? Math.trunc(v) : 0); - this.addFunds(nonNeg(ev.income) + nonNeg(ev.pending), { reason: 'income' }); - // temp is NOT wiped here: the last prep's deadline resolved what the player could act on (endPrep); what overflowed - // after it (battle-result grants, SETTLE merges, returned equipment) is shown and usable in this prep (tempDue). - // Likewise reward offers of the last prep already expired at its end; what is still queued was earned after it — - // a merge completed during SETTLE / the Final Assault (突变细胞, battle-result grants) — and is shown in this prep - this.ready = false; - // summon stacks removed from temp at the last prep deadline come back (PRTS 卫戍协议/帮助 §手牌区); full hand ⇒ temp - for (const p of [...this.board.values()]) if (p.kind === 'chess') this.grantTokensFor(p); - this.rollShop({ keepFrozen: true }); - this.shop.frozen = false; - for (const s of this.shop.slots) if (s) s.frozen = false; - this.recompute(); - } - - /** - * Prep deadline (Match.endPrep, after the <休整期结束时> onPrepEnd effects): the temp pieces due at this prep are - * resolved; what overflowed after Ready or during onPrepEnd stays for the next prep, which `prepsEnded` now names. - */ - endPrep() { - this.resolveTemp(); - this.prepsEnded++; - for (const uid of [...this._tempDue.keys()]) if (!this.temp.some((p) => p && p.uid === uid)) this._tempDue.delete(uid); - this.offers = []; - this.clearUnfrozenShop(); - if (!this.gd.leftoverKeptBands.includes(this.bandId)) this.funds = 0; - this.ready = true; - this.dirty(); - } - - // `effects` are kept: a 信标 gift still pending is delivered to the teammate at the next round start (builtin_gift is - // flagged afterElimination — GitHub #86); nothing else of an eliminated player is dispatched. - eliminate(round) { - this.alive = false; - this.ready = false; - this.eliminatedRound = round; - const all = []; - for (const p of this.board.values()) all.push(p); - for (const p of this.hand) if (p) all.push(p); - for (const p of this.temp) if (p) all.push(p); - for (const p of all) this.returnCopies(p); - this.board.clear(); - this.hand.fill(null); - this.temp.fill(null); - this._tempDue.clear(); - this.offers = []; - this.bounties = []; - this.shop.slots = []; - this.funds = 0; - this.pendingFunds = 0; - this.recompute(); - } - - recompute() { - this.deployMap(); // a change of the deploy field (a boss round's prep) marks the legality stale - if (this._legalityStale) this._evictIllegal(); - this._liftOutOfRange(); - this.bonds = computeBonds(this.gd, this); - this.dirty(); - } - - activatedLayers() { return activatedLayers(this.bonds); } - - /** - * The bond states the views show (m.private bonds, m.public players[].bonds): the computed states plus the pending - * in-battle gains of this round's finished normal battle (bondsMeta.bondsWithGains). The 联防 field fights with them too - * (battleInput `reached`); no other rule reads them. - */ - bondsView() { return bondsWithGains(this.bonds, this.pendingLayerGains); } - - // ================================================================================================= - // battle input - - /** - * `reached`: the bonds carry the layers this round's own combat reached (bondsView: the pending in-battle gains, capped - * like settle()) — the 联防 field (unite.js; PRTS 卫戍协议/帮助 §联防阶段 "将以其阵地当前的状态", [ASSUMED] the current - * state includes those layers, as the strip shows them). The gains stay pending: settle() adds them once. - */ - battleInput({ side = 'L', colOffset = 0, carry = null, reached = false } = {}) { - // a terrain change not yet followed by a recompute (a content hook at the prep end) never fields an illegal board - this.deployMap(); - if (this._legalityStale) this.recompute(); - const units = []; - for (const { r, c, piece } of boardOrder(this.board)) { - if (piece.kind === 'chess') { - const u = { uid: piece.uid, kind: 'chess', chessId: piece.id, row: r, col: c, dir: pieceDir(piece), items: (piece.items || []).map((i) => i.id) }; - // DESIGN §16: the equipped skill / module (elite only) from the loadout (defaults when absent) - const lo = this.loadoutFor(this.gd.chess(piece.id)); - u.skillIndex = lo.skillIndex; - u.moduleId = lo.moduleId; - if (carry && carry.has(piece.uid)) u.carryState = carry.get(piece.uid); - units.push(u); - } else if (piece.kind === 'token') { - const u = { uid: piece.uid, kind: 'token', tokenId: piece.id, row: r, col: c, dir: pieceDir(piece), ownerUid: piece.ownerUid }; - if (carry && carry.has(piece.uid)) u.carryState = carry.get(piece.uid); // 联防: { sp } (unite.js) - units.push(u); - } - } - return { - playerId: this.playerId, - seat: this.seat, - side, - colOffset, - units, - bonds: bondSnapshot(reached ? this.bondsView() : this.bonds), - bandId: this.bandId, - playerEffects: this.effects.filter((e) => e.battle !== false).map((e) => ({ - id: e.id, key: e.key ?? null, source: e.iconKind ?? null, params: e.params ?? null, counter: e.counter ?? null, data: e.data ?? null, - })), - deviceOverrides: { ...this.deviceOverrides }, - }; - } - - // ================================================================================================= - // views - - pieceView(p, rc = null) { - const rec = p.kind === 'item' ? this.gd.item(p.id) : p.kind === 'token' ? this.gd.token(p.id) : this.gd.chess(p.id); - const v = { - uid: p.uid, - kind: p.kind, - id: p.id, - golden: !!(rec && rec.isGolden), - tier: rec && Number.isInteger(rec.tier) ? rec.tier : null, - items: p.kind === 'chess' ? (p.items || []).map((it) => ({ uid: it.uid, id: it.id })) : [], - count: p.kind === 'token' ? (p.count || 1) : 1, - ownerUid: p.kind === 'token' ? p.ownerUid ?? null : null, - }; - if (rc) { v.row = rc[0]; v.col = rc[1]; v.dir = pieceDir(p); } - return v; - } - - effectsView() { - const out = []; - const band = this.bandId ? this.gd.band(this.bandId) : null; - if (band) out.push({ id: band.effectId || band.bandId, name: band.effectName || band.name, desc: band.desc || '', iconKind: 'band', iconId: band.iconId || band.bandId }); - for (const e of this.effects) { - if (e.hidden) continue; - const v = { id: e.id, name: e.name || e.id, desc: e.desc || '', iconKind: e.iconKind || 'choice', iconId: e.iconId || e.id }; - if (e.counter != null) v.counter = e.counter; - out.push(v); - } - for (const b of this.bounties) { - // the battles the bounty's enemies still come for (an official multi-round card: every battle, no counter) and - // the card's official rich text (blue "下场作战" / "两场作战", red "每场"; a multi-round card reads as long as it - // lasts — choices.js bountyText, MULTI_ROUND_BOUNTY_BATTLES) — user playtest #6 item 4 - const left = b.roundsLeft >= 90 ? null : b.roundsLeft; - const eff = b.card.effectId ? this.gd.effect(b.card.effectId) : null; - out.push({ - id: b.id, name: b.card.name || '悬赏', desc: bountyText((eff && eff.descRaw) || b.card.desc || '', b.card), iconKind: 'choice', iconId: b.card.effectId || 'bounty', - counter: left, counterText: left == null ? '之后的每场作战' : `还剩 ${left} 场作战`, - }); - } - return out; - } - - privateView() { - const slots = this.shop.slots.map((s) => (s ? { kind: s.kind, id: s.id, price: this.priceOf(s), basePrice: s.basePrice, sold: !!s.sold, frozen: !!s.frozen } : null)); - const offer = this.offers[0] || null; - const free = this.shop.freeRefreshes > 0; - const board = []; - for (const { r, c, piece } of boardOrder(this.board)) board.push(this.pieceView(piece, [r, c])); - return { - t: 'm.private', - playerId: this.playerId, - seat: this.seat, - alive: this.alive, - lp: this.lp, - funds: this.funds, - bandId: this.bandId, - ready: this.ready, - canReady: this.alive && this.tempEmpty && this.m.phase === PHASE.PREP, - shop: { - level: this.shop.level, - maxLevel: this.gd.maxShopLevel, - upgradePrice: this.shop.level >= this.gd.maxShopLevel ? 0 : this.shop.upgradePrice, - refreshPrice: free ? 0 : this.gd.refreshPrice, - freeRefreshes: this.shop.freeRefreshes, - frozen: this.shop.frozen, - slots, - // `source` 'merge' = the promotion reward (晋升奖励); any other offer (a strategy, an item, a 特质) carries the - // `label` the bar shows instead; `queued` = offers waiting behind it (player report #6 after 0.1.0) - rewardOffer: offer ? { tier: offer.tier, source: offer.source === 'merge' ? 'merge' : 'special', label: offer.label || null, queued: this.offers.length - 1, slots: offer.slots.map((s) => ({ kind: s.kind === 'item' ? 'item' : 'chess', id: s.id, price: s.price, sold: !!s.sold })) } : null, - }, - hand: this.hand.map((p) => (p ? this.pieceView(p) : null)), - temp: this.temp.map((p) => (p ? this.pieceView(p) : null)), - board, - deployCap: this.deployCap, - deployCount: this.deployCount, - // + the mode-off bonds it has members of (`off: true`, the strip's grey 本局禁用 discs — bondsMeta.offBondCounts) - bonds: bondList(this.gd, this.bondsView(), { full: true, off: offBondCounts(this.gd, this) }), - effects: this.effectsView(), - nextEnemies: this.m.nextEnemiesFor(this), - // DESIGN §16: the effective operator loadout ({ [baseChessId]: { skill, module } }; chess not listed use defaults) - loadout: this.loadout, - stats: { - dmgDealt: Math.round(this.stats.dmgDealt), kills: this.stats.kills, leaks: this.stats.leaks, gold: this.stats.gold, - refreshes: this.stats.refreshes, merges: this.stats.merges, - }, - }; +} + +// the method modules, in this order (a name defined twice is an error, never a silent override) +for (const part of [PlayerBasics, PlayerPieces, PlayerAcquire, PlayerEconomy, PlayerPlacement, PlayerItems, PlayerPrep, PlayerRound, PlayerViews]) { + for (const key of Reflect.ownKeys(part.prototype)) { + if (key === 'constructor') continue; + if (Object.prototype.hasOwnProperty.call(PlayerState.prototype, key)) throw new Error(`PlayerState.${String(key)} is defined twice`); + Object.defineProperty(PlayerState.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..e411c78f --- /dev/null +++ b/server/match/match/bossRounds.js @@ -0,0 +1,515 @@ +// 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, CreditPool, hiddenEligible, BOSS_HIT_STEPS } from '../finalAssault.js'; +import { FieldRunner, HeadlessPacer, syntheticResult, BOSS_SILENCE_MS, CATCHUP_TICKS_PER_INTERVAL } from '../fields.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..d73c7688 --- /dev/null +++ b/server/match/match/watch.js @@ -0,0 +1,217 @@ +// 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 (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/server/match/player/acquire.js b/server/match/player/acquire.js new file mode 100644 index 00000000..b1a6dbd1 --- /dev/null +++ b/server/match/player/acquire.js @@ -0,0 +1,336 @@ +// server/match/player/acquire.js — PlayerState methods: gains and merges — copy counts, acquireChess (hand, overflow +// temp, the no-room rule) and the chess merge (3 copies → the elite, on a consumed deployed copy's tile or in the hand; +// the reward offer), promotion, transformation (突变细胞: a destroy, then a gain), item upgrades, reward / item offers, +// acquireItem and the item merges (2 identical → the golden item; deferred merges at the next prep start). +// Installed on PlayerState.prototype by server/match/PlayerState.js (a method container: never instantiated; `this` is +// the player state). + +import { pieceDir, parseDir, mergeTile } from '../board.js'; +import { MAX_OFFER_SLOTS } from './common.js'; + +export class PlayerAcquire { + /** Normal copies of a base chess currently owned (board/hand/temp). */ + countCopies(baseId) { + let n = 0; + for (const loc of this._chessLocations()) { + const p = loc.piece; + if (!this.gd.isGolden(p.id) && this.gd.baseIdOf(p.id) === baseId) n++; + } + return n; + } + + /** Would acquiring one more normal copy of `chessId` complete a merge? */ + completesChessMerge(chessId) { + const rec = this.gd.chess(chessId); + if (!rec || rec.isGolden) return false; + const need = this.gd.mergeCount(chessId); + if (!(need > 1) || !this.gd.goldenIdOf(chessId)) return false; + return this.countCopies(this.gd.baseIdOf(chessId)) + 1 >= need; + } + + /** + * Acquire a chess (buy, reward, effect grant). Takes pool copies (normal 1, elite goldenCopies) when available, + * merges immediately when it completes a set, otherwise stows it (hand, overflow temp). Fires onGain (for the + * elite when a merge happened, research 01 §7 "1+1+2"). Returns the owned piece (the elite after a merge) or null. + * @param {string} chessId + * @param {{ source?: string, toTemp?: boolean, fromPool?: boolean, silent?: boolean }} [opts] + */ + acquireChess(chessId, { source = 'grant', toTemp = false, fromPool = true, silent = false } = {}) { + const rec = this.gd.chess(chessId); + if (!rec) return null; + const base = this.gd.baseIdOf(chessId); + const need = rec.isGolden ? this.gd.goldenCopies : 1; + const taken = fromPool ? this.m.pool.take(base, need) : 0; + const piece = this.newPiece('chess', chessId, { poolCopies: taken }); + this.round.gainedChess++; + let owned = piece; + if (!rec.isGolden && this.completesChessMerge(chessId)) { + owned = this._mergeChess(base, piece); + if (!owned) return null; + } else { + const where = this.stow(piece, { allowTemp: true, toTemp }); + if (!where) { + this.returnCopies(piece); + this.m.toast(this, 'warn', '整备区已满,获得的干员已返还'); + return null; + } + } + this.recompute(); + if (!silent) this.m.dispatch(this, 'onGain', { piece: owned, kind: 'chess', source }); + this.recompute(); + return owned; + } + + /** + * Merge `need` normal copies of `baseId` (the incoming, not yet stowed piece first, then temp, hand, board) into + * the elite — PRTS 卫戍协议/帮助 §干员的获得与精锐化: "发送1名【精锐】状态的该干员至手牌区(若消耗已部署至作战区的干员, + * 则发送至作战区对应位置)" (the user's playtest #6 follow-up confirms it). The tile (`mergeTile`): when a consumed copy + * stood on the board the elite takes its tile and facing — of several, the one that deploys first (the left board + * column first, top to bottom within a column — Battle.start's order) [ASSUMED]. The incoming copy is never deployed (a 突变细胞 transformation + * destroyed its carrier before the gain: that tile is no copy's). It replaces a deployed copy, so the deploy count + * never grows. Otherwise the elite goes to the hand, overflow temp — outside PREP too (a SETTLE merge's elite waits in + * temp through the next prep, tempDue). The copies' equipment returns to the hand ("干员晋级后已配发装备会回收至整备区"; + * overflow temp; with both full it stays on the elite, up to its equipPerChess (2) slots — any further item is + * destroyed with a log warning, as before the official rule) and an identical normal pair among it merges like any gain + * (checkItemMerges); their summons are removed, and an elite on the board + * gets its own summon stack (grantTokensFor: its loadout, like any deployment). Returns the elite piece (or null if + * the elite could not be stored). + * @param {string} baseId + * @param {any} incoming the acquired, not yet stowed copy (null: only owned copies) + */ + _mergeChess(baseId, incoming) { + const need = this.gd.mergeCount(baseId); + const goldenId = this.gd.goldenIdOf(baseId); + if (!(need > 1) || !goldenId) return null; + const locs = this._chessLocations().filter((l) => !this.gd.isGolden(l.piece.id) && this.gd.baseIdOf(l.piece.id) === baseId); + const consumed = []; + if (incoming) consumed.push({ piece: incoming, area: 'new' }); + for (const l of locs) { if (consumed.length >= need) break; consumed.push(l); } + if (consumed.length < need) return null; + let copies = 0; + const items = []; + for (const l of consumed) { + copies += l.piece.poolCopies || 0; + if (l.area !== 'new') this._detach(l); + this.removeTokensOf(l.piece.uid); + for (const it of l.piece.items || []) items.push(it); + l.piece.items = []; + } + const elite = this.newPiece('chess', goldenId, { poolCopies: copies }); + // this round's per-piece counters: the highest of the copies' (an elite made from 拉普兰德 that already saw their + // first refresh this round does not fire again this round — [ASSUMED] conservative, pieceRoundCount) + for (const l of consumed) { + const rc = l.piece.meta && l.piece.meta.round; + if (rc && rc.r === this.m.round) for (const [k, v] of Object.entries(rc.n)) this.bumpPieceRoundCount(elite, k, Math.max(0, v - this.pieceRoundCount(elite, k))); + } + const deployed = consumed.filter((l) => l.key && !this.board.has(l.key)).map((l) => ({ key: l.key, dir: pieceDir(l.piece) })); + const toTile = (t) => { elite.dir = parseDir(t.dir) || 'RIGHT'; this.board.set(t.key, elite); return 'board'; }; + const tile = mergeTile(deployed, (r, c) => this._legal(elite, r, c)); + let where = tile ? toTile(tile) : this.stow(elite, { allowTemp: true }); + // hand and temp full and no deployed tile legal for it (a terrain change not re-checked yet): it stays on the first + // deployed copy's tile rather than being lost, like a piece _evictIllegal finds no room for + if (!where && deployed.length) where = toTile(mergeTile(deployed)); + for (const it of items) { + if (this.stow(it, { allowTemp: true })) continue; + if (where && elite.items.length < this.gd.equipPerChess) { elite.items.push(it); continue; } + this.m.log.warn?.(`[match ${this.m.roomCode}] ${this.playerId}: returned item ${it.id} destroyed (no space)`); + } + // the returned equipment follows the auto-merge rule like any other gain ("已拥有2件同一初始装备时…自动合并") + this.checkItemMerges(); + // deployed like any operator placed by hand: its manually deployable summons join the hand (after the returned + // equipment, which would be lost in temp — a summon stack removed there comes back at the next round start) + if (where === 'board') this.grantTokensFor(elite); + if (!where) { + this.m.pool.give(baseId, copies); + this.m.toast(this, 'warn', '整备区已满,晋升的精锐干员无法放入'); + this.m.log.warn?.(`[match ${this.m.roomCode}] ${this.playerId}: merge result dropped (hand+temp full)`); + this.recompute(); + return null; + } + this.stats.merges++; + this.pushRewardOffer('merge'); + const rec = this.gd.chess(goldenId); + this.m.tickerFor('GOLDEN_CHAR', [this.name, rec ? rec.name : goldenId], { playerId: this.playerId }); + this.m.dispatch(this, 'onMerge', { kind: 'chess', piece: elite, baseId, consumed: consumed.map((l) => l.piece.uid), area: where }); + return elite; + } + + /** Promote a normal chess piece to its elite in place (升华, 博士投影). Takes extra pool copies when available. */ + promote(piece) { + if (!piece || piece.kind !== 'chess' || this.gd.isGolden(piece.id)) return false; + const goldenId = this.gd.goldenIdOf(piece.id); + if (!goldenId) return false; + const base = this.gd.baseIdOf(piece.id); + const extra = Math.max(0, this.gd.goldenCopies - (piece.poolCopies || 0)); + piece.poolCopies = (piece.poolCopies || 0) + this.m.pool.take(base, extra); + piece.id = goldenId; + this.recompute(); + return true; + } + + /** + * Transformation (突变细胞 "战斗结束后,装备者替换为高一阶的随机干员"; PRTS 卫戍协议:盟约 下半/PRTS盟约记录 备注 "生效时,原 + * 干员销毁,获得一名高一阶的随机初始干员(最高六阶)"): a destroy followed by a gain. The carrier is destroyed wherever it + * stands — a board tile is freed (the deploy count drops), its summons are removed, its pool copies return. Its + * equipment, the cell included, comes off first (PRTS 卫戍协议/帮助 "在失去该干员(干员出售、销毁、合并等)…时自动卸除"): to + * the hand, overflowing into temp, auto-merging like any gain. Then `newId` is gained like any other gained operator + * (acquireChess, onGain source 'transform'): the hand, overflow temp ("被发送至手牌区的物资优先从右到左填充空位"), and with + * both full it goes back to the pool ("整备区已满,获得的干员已返还"); it gets no summon card in the hand (only a deployment + * brings one), and a merge it completes follows the ordinary rule (_mergeChess: the elite on a consumed deployed copy's + * tile, else the hand — the carrier's freed tile is no copy's). An item that found no slot takes one the gain freed (a + * merge consumes copies), else stays on the gained operator up to its equipPerChess slots, else it is destroyed with a + * log warning (as in a merge). Official footage (bilibili BV1vzyVBuEN9, BV1Qkw1zMEoR; pointed out in PR #2): at the + * next prep the carrier's tile is empty, one more deployment is left and the new operator waits in the 整备区. + * @param {any} piece the carrier (an owned chess piece) + * @param {string} newId chess id gained in its place + * @returns {any} the gained piece (the elite when it completed a merge) or null + */ + transformChess(piece, newId) { + const loc = this.find(piece.uid); + if (!loc || loc.piece.kind !== 'chess' || !this.gd.chess(newId)) return null; + // 原干员销毁: off its tile / slot, its summons removed, its copies back to the pool + this._detach(loc); + this.removeTokensOf(piece.uid); + const items = piece.items || []; + piece.items = []; + this.returnCopies(piece); + // its equipment comes off first (the returned pair auto-merges, which may free a slot for the gain) + const left = items.filter((it) => !this.stow(it, { allowTemp: true })); + this.checkItemMerges(); + // 获得一名…干员: gained like any other gained operator + const np = this.acquireChess(newId, { source: 'transform' }); + for (const it of left) { + if (this.stow(it, { allowTemp: true })) continue; + if (np && this.find(np.uid) && np.items.length < this.gd.equipPerChess) { np.items.push(it); continue; } + this.m.log.warn?.(`[match ${this.m.roomCode}] ${this.playerId}: returned item ${it.id} destroyed (no space)`); + } + if (left.length) this.checkItemMerges(); + this.recompute(); + return np; + } + + /** Normal item → golden version in place (整备). */ + upgradeItem(piece) { + const rec = this.gd.item(piece.id); + if (!rec || rec.isGolden) return false; + const gid = rec.upgradeChessId || rec.goldenId; + if (!gid || !this.gd.item(gid)) return false; + piece.id = gid; + this.recompute(); + return true; + } + + /** + * Queue a reward offer: `count` DIFFERENT chess of tier min(level + offset, maxTier) at price 0 (pick 1). Each is a + * copy-weighted roll from the shared pool excluding the ones already drawn; a tier left without another chess tops + * up from the tier below (user playtest #6 item 19: the official promotion reward never offers one operator twice — + * the user's first-hand report; the normal shop's slots may repeat). The offer reserves no copies (the pick takes one). + */ + pushRewardOffer(source = 'merge', { tier = null, ids = null, label = null } = {}) { + const ro = this.gd.rewardOffer(); + const t = Number.isInteger(tier) ? tier : Math.min(this.shop.level + ro.tierOffset, ro.maxTier); + // an offer never shows one operator twice, whoever built the list (user playtest #6 item 19) + let list = Array.isArray(ids) ? [...new Set(ids)].filter((id) => this.gd.chess(id)) : null; + if (!list) { + list = []; + const fresh = (id) => !list.includes(id); + for (let i = 0; i < ro.count; i++) { + let id = null; + for (let tt = t; tt >= 1 && !id; tt--) id = this.m.pool.roll(this.m.rngShop, { tier: tt, filter: fresh }); + if (id) list.push(id); + } + } + if (!list.length) return null; + const offer = { tier: t, source, label: typeof label === 'string' && label ? label : null, slots: list.slice(0, MAX_OFFER_SLOTS).map((id) => ({ kind: 'chess', id, price: ro.price, sold: false })) }; + this.offers.push(offer); + this.dirty(); + return offer; + } + + /** + * Queue a free pick-one offer of items (凯瑟琳 定向投放, 娜仁图亚 见者有份); shown as shop.rewardOffer with slots of kind + * 'item' under its `label` (the effect's name; player report #6 after 0.1.0). + */ + pushItemOffer(ids, { source = 'effect', tier = null, label = null } = {}) { + const list = [...new Set(Array.isArray(ids) ? ids : [])].filter((id) => this.gd.item(id)).slice(0, MAX_OFFER_SLOTS); + if (!list.length) return null; + const offer = { tier: Number.isInteger(tier) ? tier : null, source, label: typeof label === 'string' && label ? label : null, slots: list.map((id) => ({ kind: 'item', id, price: 0, sold: false })) }; + this.offers.push(offer); + this.dirty(); + return offer; + } + + /** Item merge candidates: normal, mergeable, with a golden version. */ + _itemMergeable(id) { + const rec = this.gd.item(id); + if (!rec || rec.isGolden || rec.itemType !== 'EQUIP' || !rec.mergeable) return false; + const n = Number.isInteger(rec.upgradeNum) ? rec.upgradeNum : this.gd.itemMergeCount; + if (!(n > 1 && n < 100)) return false; + const gid = rec.upgradeChessId || rec.goldenId; + return !!(gid && this.gd.item(gid)); + } + + _itemLocations(id) { + const out = []; + for (let i = 0; i < this.temp.length; i++) if (this.temp[i] && this.temp[i].kind === 'item' && this.temp[i].id === id) out.push({ piece: this.temp[i], area: 'temp', idx: i }); + for (let i = 0; i < this.hand.length; i++) if (this.hand[i] && this.hand[i].kind === 'item' && this.hand[i].id === id) out.push({ piece: this.hand[i], area: 'hand', idx: i }); + for (const holder of this.allChess()) for (const it of holder.items || []) if (it.id === id) out.push({ piece: it, area: 'equipped', holder }); + return out; + } + + completesItemMerge(id) { + if (!this._itemMergeable(id)) return false; + const rec = this.gd.item(id); + const n = Number.isInteger(rec.upgradeNum) ? rec.upgradeNum : this.gd.itemMergeCount; + return this._itemLocations(id).length + 1 >= n; + } + + /** + * Acquire an item (buy, supply card, grant). Merges with an identical normal copy (hand/temp/equipped) into the + * golden item (to the hand). Returns the owned piece or null. + * `deferMerge` (effectsMeta, while onPrepEnd is on the stack): stow it — hand, else temp — and do not merge in this + * call, even when an identical normal copy is already owned. The next prep's start runs checkItemMerges. Nothing + * already equipped is taken off for the fight about to start. Hand and temp both full keeps the 「整备区已满,获得的装备已销毁」 + * outcome. [ASSUMED] every item granted at 休整期结束, not only 维多利亚's 战栗维式重锤 (owner's decision 2026-10-04). + */ + acquireItem(itemId, { source = 'grant', toTemp = false, silent = false, deferMerge = false } = {}) { + const rec = this.gd.item(itemId); + if (!rec) return null; + let piece = this.newPiece('item', itemId); + // A prep-end grant may sit beside an identical copy until the next prep. The invariant counts only copies + // without this mark, so the fight that is about to start is not reported as a missed merge. + if (deferMerge) piece.deferMerge = true; + if (!deferMerge && this.completesItemMerge(itemId)) { + piece = this._mergeItem(itemId, piece); + if (!piece) return null; + } else if (!this.stow(piece, { allowTemp: true, toTemp })) { + this.m.toast(this, 'warn', '整备区已满,获得的装备已销毁'); + return null; + } + this.recompute(); + if (!silent) this.m.dispatch(this, 'onGain', { piece, kind: 'item', source }); + return piece; + } + + _mergeItem(itemId, incoming = null) { + const rec = this.gd.item(itemId); + const need = Number.isInteger(rec.upgradeNum) ? rec.upgradeNum : this.gd.itemMergeCount; + const locs = this._itemLocations(itemId); + const consumed = incoming ? [{ piece: incoming, area: 'new' }] : []; + for (const l of locs) { if (consumed.length >= need) break; consumed.push(l); } + if (consumed.length < need) return null; + // remember where equipped twins sat: with a full hand AND a full temp the golden item takes the first one's slot + const slotOf = consumed.filter((l) => l.area === 'equipped').map((l) => ({ holder: l.holder, idx: l.holder.items.indexOf(l.piece) })); + for (const l of consumed) if (l.area !== 'new') this._detach(l); + const golden = this.newPiece('item', rec.upgradeChessId || rec.goldenId); + if (!this.stow(golden, { allowTemp: true })) { + const at = slotOf[0]; + if (!at || !this.find(at.holder.uid)) { + this.m.toast(this, 'warn', '整备区已满,合成的装备已销毁'); + return null; + } + at.holder.items.splice(Math.max(0, Math.min(at.idx, at.holder.items.length)), 0, golden); + } + this.stats.itemMerges++; + this.m.dispatch(this, 'onMerge', { kind: 'item', piece: golden, itemId, consumed: consumed.map((l) => l.piece.uid) }); + return golden; + } + + /** Merge every identical normal item pair currently owned (after equips / returns). */ + checkItemMerges() { + for (let guard = 0; guard < 20; guard++) { + const counts = new Map(); + const scan = (p) => { if (p && p.kind === 'item' && this._itemMergeable(p.id)) counts.set(p.id, (counts.get(p.id) || 0) + 1); }; + for (const p of this.hand) scan(p); + for (const p of this.temp) scan(p); + for (const holder of this.allChess()) for (const it of holder.items || []) scan(it); + let did = false; + for (const [id, n] of counts) { + const rec = this.gd.item(id); + const need = Number.isInteger(rec.upgradeNum) ? rec.upgradeNum : this.gd.itemMergeCount; + if (n >= need) { if (this._mergeItem(id, null)) did = true; break; } + } + if (!did) return; + } + } +} diff --git a/server/match/player/basics.js b/server/match/player/basics.js new file mode 100644 index 00000000..8603d50c --- /dev/null +++ b/server/match/player/basics.js @@ -0,0 +1,134 @@ +// server/match/player/basics.js — PlayerState methods: the basics — seat flags and counts (isHumanActive, +// botControlled, deployCap, deployCount, tempEmpty), the prep that resolves a temp piece (tempDue, _putTemp: every +// write into a temp slot), the operator loadout (DESIGN §16: setLoadout, loadoutFor), the deploy map on the field the +// player deploys on (Match.deployFieldOf) and the withdrawal of pieces a terrain / deploy-field change left on tiles +// they may no longer occupy (_evictIllegal), dirty. +// Installed on PlayerState.prototype by server/match/PlayerState.js (a method container: never instantiated; `this` is +// the player state). + +import { PHASE } from '../../../shared/constants.js'; +import { checkLoadout, resolveLoadout } from '../../../shared/protocol.js'; +import { tileKey, boardOrder } from '../board.js'; + +export class PlayerBasics { + get isHumanActive() { return !this.isBot && !this.left; } + /** The engine acts for this seat (AI teammate or "AI 托管"; a departed human is eliminated, so nothing is left to do). */ + get botControlled() { return this.isBot || this.left || this.autoplay; } + + get deployCap() { return Math.max(1, this.gd.deployCap + this.deployCapBonus, this.deployCapMin); } + get deployCount() { let n = 0; for (const p of this.board.values()) if (p.kind === 'chess') n++; return n; } + get tempEmpty() { return this.temp.every((x) => x == null); } + + /** + * Index of the prep whose deadline resolves a temp piece (compare with `prepsEnded`): recorded when the piece entered + * temp (_putTemp); a piece put there by other means counts as due at the current (or next) prep. + */ + tempDue(piece) { + const due = piece ? this._tempDue.get(piece.uid) : undefined; + return Number.isInteger(due) ? due : this.prepsEnded; + } + + /** + * Due prep of a piece entering temp now: the current prep while the player can still act on it (PREP, not ready); + * after Ready or at the prep end (onPrepEnd grants) the next one; outside PREP (COMBAT, SETTLE, ROUND_START, 机变) + * the next prep to end — `prepsEnded` then already names it. + */ + _tempDueNow() { + return this.prepsEnded + (this.m.phase === PHASE.PREP && this.ready ? 1 : 0); + } + + /** Every write of a piece into a temp slot goes through here (records its due prep). */ + _putTemp(i, piece) { + this.temp[i] = piece; + this._tempDue.set(piece.uid, this._tempDueNow()); + } + + /** + * Replace the operator loadout (DESIGN §16) after re-checking it against this match's data. Accepts the checked + * form `{ id: { skill, module|null } }` or raw `room.loadout` entries. Returns false (loadout unchanged) when it does + * not fit the data; bots keep the defaults. + * @param {any} loadout + * @returns {boolean} + */ + setLoadout(loadout) { + if (this.isBot) return false; + const entries = {}; + if (loadout && typeof loadout === 'object' && !Array.isArray(loadout)) { + for (const [id, e] of Object.entries(loadout)) { + if (!e || typeof e !== 'object') continue; + const x = {}; + if (Number.isInteger(e.skill)) x.skill = e.skill; + if (typeof e.module === 'string') x.module = e.module; + if (Object.keys(x).length) entries[id] = x; + } + } + const res = checkLoadout(entries, (id) => this.gd.chess(id)); + if (!res || !res.ok) { + this.m.log?.warn?.(`[match ${this.m.roomCode}] loadout of ${this.playerId} ignored: ${res && res.detail}`); + return false; + } + const out = {}; + for (const [id, e] of Object.entries(res.loadout)) out[id] = Object.freeze({ skill: e.skill, module: e.module ?? null }); + this.loadout = Object.freeze(out); + return true; + } + + /** The skill index / module a chess record fights with under this player's loadout (DESIGN §16). */ + loadoutFor(chessRecord) { + return resolveLoadout(this.loadout, chessRecord, (id) => this.gd.chess(id)); + } + + /** + * Deploy classes of the board tiles (server/match/board.js buildDeployMap) on the field the player deploys on now + * (Match.deployFieldOf: the own board, or its half of the boss field in a boss round — user playtest #5 item 7). + * A change of that field (the boss round begins, a re-pairing) re-checks the board's legality like a terrain change. + */ + deployMap() { + const field = typeof this.m.deployFieldOf === 'function' ? this.m.deployFieldOf(this) : 'normal'; + if (this._deployMap && this._deployField !== undefined && this._deployField !== field) { + this._deployMap = null; + this._legalityStale = true; + } + if (!this._deployMap) this._deployMap = this.m.deployMapFor(this, field); + this._deployField = field; + return this._deployMap; + } + /** + * The board's terrain changed (terrain 机变 cards, content device / tile overrides): legality is re-checked at the + * next recompute() / battleInput() — after the whole change, so an intermediate state of a card that toggles several + * devices never moves a piece. + */ + invalidateDeployMap() { this._deployMap = null; this._legalityStale = true; } + + /** + * After a terrain change, pieces standing on tiles they may no longer occupy (a melee operator on a tile that became + * a 射击台, anything on a tile that became undeployable) are withdrawn like 撤退: an operator goes to the hand + * (overflow temp — a passive move; the player re-places it during the prep), its summons leave the board with it; a + * summon returns to its owner's stack. Nothing is lost: with the hand and temp both full a piece stays put. + * @returns {number} pieces moved + */ + _evictIllegal() { + this._legalityStale = false; + const names = []; + let moved = 0; + for (const kind of ['chess', 'token']) { + for (const { r, c, piece } of boardOrder(this.board)) { + if (piece.kind !== kind || this._legal(piece, r, c)) continue; + const key = tileKey(r, c); + this.board.delete(key); + const ok = kind === 'chess' ? !!this.stow(piece, { allowTemp: true }) : this._returnToken(piece); + if (!ok) { this.board.set(key, piece); continue; } + moved++; + if (kind === 'chess') { + this.removeTokensOf(piece.uid); + const rec = this.gd.chess(piece.id); + names.push(rec && rec.name ? rec.name : piece.id); + } + } + } + if (names.length) this.m.toast(this, 'warn', `地形变化:${names.join('、')}无法停留在原位置,已撤回整备区`); + return moved; + } + + dirty() { this.m.markPrivate(this); } +} diff --git a/server/match/player/common.js b/server/match/player/common.js new file mode 100644 index 00000000..f44c95d2 --- /dev/null +++ b/server/match/player/common.js @@ -0,0 +1,11 @@ +// server/match/player/common.js — constants and small helpers shared by the PlayerState method modules +// (server/match/player/*.js) and its constructor (server/match/PlayerState.js). + +import { GEO } from '../../../shared/constants.js'; + +export const HAND_SIZE = GEO.HAND_SIZE; +export const TEMP_SIZE = GEO.TEMP_SIZE; +/** g.reward accepts idx 0..5 (shared/protocol.js) */ +export const MAX_OFFER_SLOTS = 6; +export const OK = Object.freeze({ ok: true }); +export const fail = (error, detail) => (detail ? { error, detail } : { error }); diff --git a/server/match/player/economy.js b/server/match/player/economy.js new file mode 100644 index 00000000..b6fa9c9e --- /dev/null +++ b/server/match/player/economy.js @@ -0,0 +1,200 @@ +// server/match/player/economy.js — PlayerState methods: the economy — funds, spending (onSpend), prep-side bond layer +// gains (clamped by layerGainRoom), the shop (onPrice prices, copy-weighted rolls from the shared pool, freeze) and its +// intents: g.buy, g.refresh, g.freeze, g.levelUp, g.sell. +// Installed on PlayerState.prototype by server/match/PlayerState.js (a method container: never instantiated; `this` is +// the player state). + +import { ERR, layerGainRoom } from '../../../shared/constants.js'; +import { freeSlot } from '../board.js'; +import { OK, fail } from './common.js'; + +export class PlayerEconomy { + addFunds(n, { reason = '' } = {}) { + if (!Number.isFinite(n) || n === 0) return 0; + const v = Math.trunc(n); + const before = this.funds; + this.funds = Math.max(0, this.funds + v); + if (v > 0) this.stats.fundsGained += v; + this.dirty(); + return this.funds - before; + } + + spend(n) { + const v = Number.isFinite(n) ? Math.max(0, Math.trunc(n)) : 0; + if (v > this.funds) return false; + this.funds -= v; + this.stats.gold += v; + this.round.spent += v; + this.dirty(); + return true; + } + + /** onSpend, dispatched once a payment's action is complete (buy / refresh / levelUp / reward / effect). */ + _afterSpend(amount, reason) { + if (!(amount > 0)) return; + this.m.dispatch(this, 'onSpend', { amount, reason, total: this.stats.gold }); + } + + /** + * Bond layer gain (prep-side). `requireActive` = "使已激活的【X】层数+N". Returns layers added: at most the room left + * under BOND_LAYER_CAP (999, shared/constants.js) — a gain at the cap adds 0 and dispatches nothing. + */ + addLayers(bondId, n, { requireActive = false, reason = '' } = {}) { + if (!this.gd.bond(bondId) || !Number.isFinite(n) || n <= 0) return 0; + if (requireActive && !(this.bonds[bondId] && this.bonds[bondId].active)) return 0; + const before = this.layers[bondId] || 0; + const add = layerGainRoom(before, Math.floor(n)); + if (add <= 0) return 0; + this.layers[bondId] = before + add; + this.recompute(); + this.m.dispatch(this, 'onLayers', { bondId, from: before, to: before + add, reason }); + return add; + } + + /** Effective price of a shop slot after onPrice modifiers (never negative). */ + priceOf(slot) { + if (!slot) return 0; + const ev = { slot, kind: slot.kind, id: slot.id, price: slot.basePrice }; + this.m.dispatch(this, 'onPrice', ev, { quiet: true }); + const p = Number(ev.price); + return Number.isFinite(p) ? Math.max(0, Math.round(p)) : slot.basePrice; + } + + _rollChessSlot() { + const id = this.m.pool.roll(this.m.rngShop, { maxTier: this.shop.level }); + return id ? { kind: 'chess', id, basePrice: this.gd.chessPrice(id), frozen: false, sold: false } : null; + } + + _rollItemSlot() { + const id = this.m.pool.rollItem(this.m.rngShop, this.shop.level); + return id ? { kind: 'item', id, basePrice: this.gd.itemPrice(id), frozen: false, sold: false } : null; + } + + /** + * Reroll the shop. keepFrozen → frozen unsold slots survive (round start); otherwise everything is rerolled + * (manual refresh). Slot counts follow the current level. + */ + rollShop({ keepFrozen = false } = {}) { + const { chess: nChess, item: nItem } = this.gd.shopSlots(this.shop.level); + const old = this.shop.slots; + const layout = this.shop.layout || { chess: old.length, item: 0 }; + const oldChess = old.slice(0, layout.chess); + const oldItems = old.slice(layout.chess); + const keep = (s, kind) => (keepFrozen && s && s.kind === kind && !s.sold && s.frozen ? { ...s } : null); + // frozen slots keep their position; sold / empty / unfrozen positions are rerolled + const slots = []; + for (let i = 0; i < nChess; i++) slots.push(keep(oldChess[i], 'chess') ?? this._rollChessSlot()); + for (let i = 0; i < nItem; i++) slots.push(keep(oldItems[i], 'item') ?? this._rollItemSlot()); + this.shop.slots = slots; + this.shop.layout = { chess: nChess, item: nItem }; + for (const s of slots) if (s) s.frozen = this.shop.frozen; + this.dirty(); + } + + /** Combat start: unfrozen slots are emptied (research 01 A1). */ + clearUnfrozenShop() { + this.shop.slots = this.shop.slots.map((s) => (s && s.frozen && !s.sold ? s : null)); + this.dirty(); + } + + buy(slotIdx) { + const g = this._gate(); if (g) return g; + if (!Number.isInteger(slotIdx) || slotIdx < 0 || slotIdx >= this.shop.slots.length) return fail(ERR.BAD_TARGET); + const slot = this.shop.slots[slotIdx]; + if (!slot) return fail(ERR.BAD_TARGET); + if (slot.sold) return fail(ERR.SOLD_OUT); + const price = this.priceOf(slot); + if (this.funds < price) return fail(ERR.NO_FUNDS); + const handFull = freeSlot(this.hand) < 0; + let piece; + if (slot.kind === 'chess') { + const rec = this.gd.chess(slot.id); + if (!rec) return fail(ERR.BAD_TARGET); + const base = this.gd.baseIdOf(slot.id); + const need = rec.isGolden ? this.gd.goldenCopies : 1; + if (this.m.pool.has(base) && this.m.pool.left(base) < need) return fail(ERR.SOLD_OUT); + if (handFull && !this.completesChessMerge(slot.id)) return fail(ERR.HAND_FULL); + this.spend(price); + slot.sold = true; + piece = this.acquireChess(slot.id, { source: 'buy' }); + } else { + if (!this.gd.item(slot.id)) return fail(ERR.BAD_TARGET); + if (handFull && !this.completesItemMerge(slot.id)) return fail(ERR.HAND_FULL); + this.spend(price); + slot.sold = true; + piece = this.acquireItem(slot.id, { source: 'buy' }); + } + this.stats.buys++; + this.round.buys++; + this.m.dispatch(this, 'onBuy', { piece, slot, price, kind: slot.kind }); + this._afterSpend(price, 'buy'); + this.recompute(); + return OK; + } + + refresh() { + const g = this._gate(); if (g) return g; + const free = this.shop.freeRefreshes > 0; + const price = free ? 0 : this.gd.refreshPrice; + if (!free && this.funds < price) return fail(ERR.NO_FUNDS); + if (free) this.shop.freeRefreshes--; + else this.spend(price); + this.rollShop({ keepFrozen: false }); + this.stats.refreshes++; + this.round.refreshes++; + this.m.dispatch(this, 'onRefresh', { slots: this.shop.slots, free, price }); + this._afterSpend(price, 'refresh'); + this.dirty(); + return OK; + } + + freeze() { + const g = this._gate(); if (g) return g; + this.shop.frozen = !this.shop.frozen; + for (const s of this.shop.slots) if (s && !s.sold) s.frozen = this.shop.frozen; + this.dirty(); + return OK; + } + + levelUp() { + const g = this._gate(); if (g) return g; + if (this.shop.level >= this.gd.maxShopLevel) return fail(ERR.MAX_LEVEL); + const price = Math.max(0, this.shop.upgradePrice); + if (this.funds < price) return fail(ERR.NO_FUNDS); + this.spend(price); + this.shop.level++; + this.shop.upgradePrice = this.gd.upgradeBase(this.shop.level) ?? 0; + this.m.tickerFor('SHOP_LEVEL', [this.name, String(this.shop.level)], { playerId: this.playerId, param: String(this.shop.level) }); + this.m.dispatch(this, 'onLevelUp', { level: this.shop.level, price }); + this._afterSpend(price, 'levelUp'); + this.dirty(); + return OK; + } + + sell(uid) { + const g = this._gate(); if (g) return g; + const loc = this.find(uid); + if (!loc) return fail(ERR.BAD_TARGET); + if (loc.piece.kind !== 'chess') return fail(ERR.BAD_TARGET, loc.piece.kind === 'item' ? 'items cannot be sold' : 'tokens cannot be sold'); + const piece = loc.piece; + // its equipment returns to the hand (overflow temp): refuse rather than destroy it when there is no room + const room = this.hand.filter((x) => x == null).length + this.temp.filter((x) => x == null).length + (loc.area === 'hand' || loc.area === 'temp' ? 1 : 0); + if ((piece.items || []).length > room) return fail(ERR.HAND_FULL, 'no room for the equipment'); + this._detach(loc); + this.removeTokensOf(piece.uid); + for (const it of piece.items || []) { + if (!this.stow(it, { allowTemp: true })) this.m.log.warn?.(`[match ${this.m.roomCode}] ${this.playerId}: item ${it.id} lost on sell (no space)`); + } + piece.items = []; + this.returnCopies(piece); + const ev = { piece, gain: this.gd.sellPrice(piece.id) }; + this.m.dispatch(this, 'onSold', ev); + const gain = Number.isFinite(ev.gain) ? Math.max(0, Math.trunc(ev.gain)) : 1; + this.addFunds(gain, { reason: 'sell' }); + this.stats.sells++; + this.round.sells++; + this.checkItemMerges(); + this.recompute(); + return OK; + } +} diff --git a/server/match/player/items.js b/server/match/player/items.js new file mode 100644 index 00000000..204f3590 --- /dev/null +++ b/server/match/player/items.js @@ -0,0 +1,125 @@ +// server/match/player/items.js — PlayerState methods: items — g.equip (max 2, a 3rd replaces the one the player picks; +// consume-on-equip items), g.art (Arts on a tile, rotated by the facing) and g.destroy (hand / temp items; equipped +// ones are locked). +// Installed on PlayerState.prototype by server/match/PlayerState.js (a method container: never instantiated; `this` is +// the player state). + +import { ERR } from '../../../shared/constants.js'; +import { tileKey, inField, parseDir } from '../board.js'; +import { offsetTile } from '../../sim/dir.js'; +import { itemKey } from '../gamedata.js'; +import { OK, fail } from './common.js'; + +export class PlayerItems { + /** + * g.equip {itemUid, targetUid, replaceUid?}. A third item on a carrier with both slots used replaces the equipped item + * the player picked in the replace dialog (`replaceUid`, research 09 §1.2 UseEquipUp.unloadInstId; absent ⇒ the + * oldest); the replaced item is destroyed. A `replaceUid` that is not one of the target's equipped items is refused. + */ + equip(itemUid, targetUid, replaceUid = null) { + const g = this._gate(); if (g) return g; + const iloc = this.find(itemUid); + if (!iloc || iloc.piece.kind !== 'item' || (iloc.area !== 'hand' && iloc.area !== 'temp')) return fail(ERR.BAD_TARGET); + const rec = this.gd.item(iloc.piece.id); + if (!rec || rec.itemType !== 'EQUIP') return fail(ERR.BAD_TARGET, 'not equipment'); + const tloc = this.find(targetUid); + if (!tloc || tloc.piece.kind !== 'chess' || tloc.area === 'equipped') return fail(ERR.BAD_TARGET); + const item = iloc.piece; + const target = tloc.piece; + if (replaceUid != null && !(Number.isInteger(replaceUid) && (target.items || []).some((x) => x.uid === replaceUid))) return fail(ERR.BAD_TARGET, 'replace: not equipped on the target'); + const consume = typeof rec.kind === 'string' && rec.kind.startsWith('consume_on_equip'); + if (consume) { + const key = 'item:' + itemKey(item.id); + if (!this.m.registry.has(key)) return fail(ERR.BAD_TARGET, 'effect not available'); + const ev = { item, target, golden: !!rec.isGolden, keep: false, error: null, consumed: true }; + this.m.dispatchItem(this, item, target, 'onEquip', ev); + if (ev.error) return fail(ERR[ev.error] ? ev.error : ERR.BAD_TARGET, typeof ev.detail === 'string' ? ev.detail : undefined); + // the handler may have destroyed the target (信标) — resolve the item again + const again = this.find(item.uid); + if (again && again.area !== 'equipped') this._detach(again); + if (ev.keep) { + const holder = this.find(target.uid); + if (holder && holder.piece.kind === 'chess') this._attach(holder.piece, item); + } + this.stats.itemsEquipped++; + this.checkItemMerges(); + this.recompute(); + return OK; + } + this._detach(iloc); + if (this.completesItemMerge(item.id)) { + // an identical normal copy is already owned (equipped somewhere): merge instead of equipping — the golden + // item goes to the hand (research 04 §2 "copies in hand and on operators both count") + if (this._mergeItem(item.id, item)) { this.recompute(); return OK; } + if (!this.find(item.uid)) this.stow(item, { allowTemp: true }); + } + this._attach(target, item, replaceUid); + this.stats.itemsEquipped++; + this.m.dispatchItem(this, item, target, 'onEquip', { item, target, golden: !!rec.isGolden, consumed: false }); + this.checkItemMerges(); + this.recompute(); + return OK; + } + + /** + * Attach an item. With both slots used the item `replaceUid` names (the player's pick) is replaced, else the oldest; + * the replaced item is destroyed. + */ + _attach(target, item, replaceUid = null) { + target.items = target.items || []; + while (target.items.length >= this.gd.equipPerChess) { + const i = replaceUid != null ? target.items.findIndex((x) => x.uid === replaceUid) : -1; + const [old] = target.items.splice(i >= 0 ? i : 0, 1); + replaceUid = null; + this.m.dispatchItem(this, old, target, 'onDestroy', { item: old, holder: target, reason: 'replace' }); + } + target.items.push(item); + } + + /** g.art {itemUid, row, col, dir?}: the Art's range grid is rotated by `dir` (absent ⇒ RIGHT). */ + useArt(itemUid, row, col, dir) { + const g = this._gate(); if (g) return g; + const d = parseDir(dir); + if (!d) return fail(ERR.BAD_TARGET, 'bad dir'); + const loc = this.find(itemUid); + if (!loc || loc.piece.kind !== 'item' || (loc.area !== 'hand' && loc.area !== 'temp')) return fail(ERR.BAD_TARGET); + const rec = this.gd.item(loc.piece.id); + if (!rec || rec.itemType !== 'MAGIC') return fail(ERR.BAD_TARGET, 'not an art'); + if (!inField(row, col)) return fail(ERR.BAD_TILE); + if (this.round.arts >= this.gd.maxArtsPerRound) return fail(ERR.BAD_TARGET, 'art limit'); + const key = 'item:' + itemKey(loc.piece.id); + if (!this.m.registry.has(key)) return fail(ERR.BAD_TARGET, 'effect not available'); + const grid = Array.isArray(rec.rangeGrid) && rec.rangeGrid.length ? rec.rangeGrid : [[0, 0]]; + const targets = []; + for (const [dr, dc] of grid) { + const [tr, tc] = offsetTile(row, col, dr, dc, d); + const p = this.board.get(tileKey(tr, tc)); + if (p) targets.push(p); + } + const ev = { item: loc.piece, row, col, dir: d, targets, error: null, used: true }; + this.m.dispatchItem(this, loc.piece, null, 'onArt', ev); + if (ev.error) return fail(ERR[ev.error] ? ev.error : ERR.BAD_TARGET, typeof ev.detail === 'string' ? ev.detail : undefined); + if (ev.used !== false) { + const again = this.find(itemUid); + if (again) this._detach(again); + this.round.arts++; + } + this.recompute(); + return OK; + } + + /** + * g.destroy {uid}: an item in the hand / temp (Arts included). Equipped items are locked (research 04 §2 / addendum: + * they leave the operator only on promotion, merge or sale); replacing one is g.equip's `replaceUid`. + */ + destroy(uid) { + const g = this._gate(); if (g) return g; + const loc = this.find(uid); + if (!loc || loc.piece.kind !== 'item') return fail(ERR.BAD_TARGET, 'only items can be destroyed'); + if (loc.area === 'equipped') return fail(ERR.BAD_TARGET, 'equipped items are locked'); + this._detach(loc); + this.m.dispatchItem(this, loc.piece, loc.holder || null, 'onDestroy', { item: loc.piece, holder: loc.holder || null, reason: 'player' }); + this.recompute(); + return OK; + } +} diff --git a/server/match/player/pieces.js b/server/match/player/pieces.js new file mode 100644 index 00000000..a18fa84c --- /dev/null +++ b/server/match/player/pieces.js @@ -0,0 +1,167 @@ +// server/match/player/pieces.js — PlayerState methods: piece bookkeeping — new pieces, lookup by uid (board / hand / +// temp / equipped), the chess locations, detach and stow (hand first, overflow temp), the per-piece round counters +// (拉普兰德), pool copies back to the shared pool, and summon stacks (removal, lifting, counts, the stack granted for an +// owner). +// Installed on PlayerState.prototype by server/match/PlayerState.js (a method container: never instantiated; `this` is +// the player state). + +import { tileKey, boardOrder, freeSlot } from '../board.js'; + +export class PlayerPieces { + newPiece(kind, id, extra = {}) { + return { uid: this.m.nextUid(), kind, id, items: kind === 'chess' ? [] : undefined, count: kind === 'token' ? 1 : undefined, ownerUid: undefined, poolCopies: 0, boughtRound: this.m.round, meta: {}, ...extra }; + } + + /** + * Locate a piece by uid. Returns { piece, area: 'board'|'hand'|'temp'|'equipped', idx?, key?, holder? } or null. + */ + find(uid) { + if (!Number.isInteger(uid)) return null; + for (let i = 0; i < this.hand.length; i++) { + const p = this.hand[i]; + if (!p) continue; + if (p.uid === uid) return { piece: p, area: 'hand', idx: i }; + if (p.items) for (const it of p.items) if (it.uid === uid) return { piece: it, area: 'equipped', holder: p }; + } + for (let i = 0; i < this.temp.length; i++) { + const p = this.temp[i]; + if (!p) continue; + if (p.uid === uid) return { piece: p, area: 'temp', idx: i }; + if (p.items) for (const it of p.items) if (it.uid === uid) return { piece: it, area: 'equipped', holder: p }; + } + for (const [key, p] of this.board) { + if (p.uid === uid) return { piece: p, area: 'board', key }; + if (p.items) for (const it of p.items) if (it.uid === uid) return { piece: it, area: 'equipped', holder: p }; + } + return null; + } + + /** Every owned chess piece: board (reading order, board.js boardOrder) then hand then temp. */ + allChess() { + const out = []; + for (const { piece } of boardOrder(this.board)) if (piece.kind === 'chess') out.push(piece); + for (const p of this.hand) if (p && p.kind === 'chess') out.push(p); + for (const p of this.temp) if (p && p.kind === 'chess') out.push(p); + return out; + } + + /** Locations of owned pieces, in merge-consumption preference order: temp, hand (left→right), board (reading order). */ + _chessLocations() { + const out = []; + for (let i = 0; i < this.temp.length; i++) if (this.temp[i] && this.temp[i].kind === 'chess') out.push({ piece: this.temp[i], area: 'temp', idx: i }); + for (let i = 0; i < this.hand.length; i++) if (this.hand[i] && this.hand[i].kind === 'chess') out.push({ piece: this.hand[i], area: 'hand', idx: i }); + for (const { r, c, piece } of boardOrder(this.board)) if (piece.kind === 'chess') out.push({ piece, area: 'board', key: tileKey(r, c) }); + return out; + } + + /** Remove a located piece from its container (no side effects). */ + _detach(loc) { + if (!loc) return; + if (loc.area === 'hand') this.hand[loc.idx] = null; + else if (loc.area === 'temp') { this.temp[loc.idx] = null; this._tempDue.delete(loc.piece.uid); } + else if (loc.area === 'board') this.board.delete(loc.key); + else if (loc.area === 'equipped') { + const i = loc.holder.items.indexOf(loc.piece); + if (i >= 0) loc.holder.items.splice(i, 1); + } + } + + /** + * Put a piece into the hand (right→left) or, when `allowTemp`, the temp slots (due at the deadline of the first prep + * in which the player can act on it, _tempDueNow). Returns 'hand' | 'temp' | null. + */ + stow(piece, { allowTemp = true, toTemp = false, preferIdx = null } = {}) { + if (!toTemp) { + if (Number.isInteger(preferIdx) && preferIdx >= 0 && preferIdx < this.hand.length && this.hand[preferIdx] == null) { + this.hand[preferIdx] = piece; + return 'hand'; + } + const i = freeSlot(this.hand); + if (i >= 0) { this.hand[i] = piece; return 'hand'; } + if (!allowTemp) return null; + } + const j = freeSlot(this.temp); + if (j >= 0) { this._putTemp(j, piece); return 'temp'; } + return null; + } + + /** + * Per-piece counter of the current round (`piece.meta.round` = { r, n: { key: count } }): 0 for a key not counted yet + * this round. The counters belong to the operator: a move keeps them, a new piece (bought, granted, transformed) + * starts at 0, and an elite merged this round keeps the highest count of its copies (_mergeChess) — 拉普兰德's + * "本回合首次主动刷新" is the first manual refresh she witnesses (player feedback after 0.1.0, garrisons/meta.js). + */ + pieceRoundCount(piece, key) { + const rc = piece && piece.meta && piece.meta.round; + return rc && rc.r === this.m.round && Number.isFinite(rc.n[key]) ? rc.n[key] : 0; + } + + /** Add `n` to a piece's counter of the current round (pieceRoundCount); returns the new count. */ + bumpPieceRoundCount(piece, key, n = 1) { + if (!piece || typeof key !== 'string' || !Number.isFinite(n)) return 0; + if (!piece.meta || typeof piece.meta !== 'object') piece.meta = {}; + const v = this.pieceRoundCount(piece, key) + n; + if (!piece.meta.round || piece.meta.round.r !== this.m.round) piece.meta.round = { r: this.m.round, n: {} }; + piece.meta.round.n[key] = v; + return v; + } + + /** Return a piece's pool copies (and its equipped items are handled by the caller). */ + returnCopies(piece) { + if (piece && piece.kind === 'chess' && piece.poolCopies > 0) { + this.m.pool.give(this.gd.baseIdOf(piece.id), piece.poolCopies); + piece.poolCopies = 0; + } + } + + /** Remove every token owned by a chess piece (board, hand, temp). */ + removeTokensOf(ownerUid) { + for (const [k, p] of [...this.board]) if (p.kind === 'token' && p.ownerUid === ownerUid) this.board.delete(k); + for (let i = 0; i < this.hand.length; i++) if (this.hand[i] && this.hand[i].kind === 'token' && this.hand[i].ownerUid === ownerUid) this.hand[i] = null; + for (let i = 0; i < this.temp.length; i++) if (this.temp[i] && this.temp[i].kind === 'token' && this.temp[i].ownerUid === ownerUid) this.temp[i] = null; + } + + /** + * An owner that changes its board tile (moved, swapped): its summons on the board go back onto its stack (PRTS + * 卫戍协议/帮助 "移动干员时,其所属召唤物全部退场并重置至手牌区"); overflow temp when no stack or slot is left (a + * stack lost there comes back at the next round start). `keep`: a summon the player just placed (the one dragged + * onto its owner, which swapped the owner away) stays where it was put. + */ + _liftTokensOf(ownerUid, keep = null) { + for (const [k, p] of [...this.board]) { + if (p.kind !== 'token' || p.ownerUid !== ownerUid || p === keep) continue; + this.board.delete(k); + if (!this._returnToken(p, null, { allowTemp: true })) this.board.set(k, p); // nowhere to go: it stays put + } + } + + /** Copies of one summon type an owner has (placed pieces + stacks in the hand / temp). */ + _tokenCountOf(ownerUid, tokenId) { + const mine = (p) => !!p && p.kind === 'token' && p.ownerUid === ownerUid && p.id === tokenId; + let n = 0; + for (const p of this.board.values()) if (mine(p)) n += p.count || 1; + for (const p of this.hand) if (mine(p)) n += p.count || 1; + for (const p of this.temp) if (mine(p)) n += p.count || 1; + return n; + } + + /** + * Owner on the board: send its placeable summons to the hand (one stack per token type, topped up to the deploy limit; + * gamedata.placeableTokens / tokens.json `placeable`, user playtest #6) — those its equipped skill / module makes + * (DESIGN §16: 赫默 S2 医疗无人机 a drone, 赫默 S1 none). Called when the owner is placed and at every round start, which + * returns a stack removed from temp at the last prep deadline (PRTS 卫戍协议/帮助 §手牌区 "干员所属召唤物会于下一回合 + * 返还"); copies the owner still has (placed or stacked) are not granted again. + */ + grantTokensFor(owner) { + const rec = owner && owner.kind === 'chess' ? this.gd.chess(owner.id) : null; + if (!rec) return; + for (const { tokenId, count } of this.gd.placeableTokens(owner.id, this.loadoutFor(rec))) { + const missing = count - this._tokenCountOf(owner.uid, tokenId); + if (missing <= 0) continue; + const stack = [...this.hand, ...this.temp].find((p) => p && p.kind === 'token' && p.ownerUid === owner.uid && p.id === tokenId); + if (stack) { stack.count = (stack.count || 1) + missing; continue; } + const t = this.newPiece('token', tokenId, { count: missing, ownerUid: owner.uid }); + this.stow(t, { allowTemp: true }); + } + } +} diff --git a/server/match/player/placement.js b/server/match/player/placement.js new file mode 100644 index 00000000..25263187 --- /dev/null +++ b/server/match/player/placement.js @@ -0,0 +1,303 @@ +// server/match/player/placement.js — PlayerState methods: g.move — placement legality on the deploy field (board.js), +// the summon ranges of 狼群 / 流形 (ownerRange) and the summons an owner's move or re-orientation leaves out of range, +// operator / summon moves onto the board, back to the hand, between hand and temp, swaps and re-orientation in place. +// Installed on PlayerState.prototype by server/match/PlayerState.js (a method container: never instantiated; `this` is +// the player state). + +import { ERR } from '../../../shared/constants.js'; +import { tileKey, parseKey, inField, canPlace, placeClass, freeSlot, pieceDir, parseDir, ownerRangeKeys } from '../board.js'; +import { attackRangeGrid, loadoutRecord, resolveRecordLoadout } from '../../../shared/loadoutRecord.js'; +import { OK, fail } from './common.js'; + +export class PlayerPlacement { + /** + * g.move {uid, to, dir?}. Pieces: chess / token between board, hand and temp; items only within the hand. `dir` + * (UP|RIGHT|DOWN|LEFT, absent ⇒ RIGHT; `to.dir` is read when the top-level one is absent) is the facing of a piece + * moved onto the board — onto its own tile it re-orients the piece in place. + */ + move(uid, to, dir) { + const g = this._gate(); if (g) return g; + const loc = this.find(uid); + if (!loc || loc.area === 'equipped') return fail(ERR.BAD_TARGET); + if (!to || typeof to !== 'object') return fail(ERR.BAD_TARGET); + const piece = loc.piece; + if (to.area === 'board') { + if (piece.kind === 'item') return fail(ERR.BAD_TARGET, 'items are equipped, not placed'); + const d = parseDir(dir !== undefined ? dir : to.dir); + if (!d) return fail(ERR.BAD_TARGET, 'bad dir'); + return piece.kind === 'token' ? this._moveTokenToBoard(loc, to.row, to.col, d) : this._moveChessToBoard(loc, to.row, to.col, d); + } + if (to.area === 'hand') { + if (!Number.isInteger(to.idx) || to.idx < 0 || to.idx >= this.hand.length) return fail(ERR.BAD_TARGET); + return this._moveToHand(loc, to.idx); + } + return fail(ERR.BAD_TARGET); + } + + _placementOf(piece) { + const rec = piece.kind === 'token' ? this.gd.token(piece.id) : this.gd.chess(piece.id); + // elite 歌蕾蒂娅 + HOK-Y may use a 高台; the module is this player's loadout (owner's decision 2026-10-04) + return placeClass(this, rec); + } + + /** + * Where piece may stand on (r, c): the deploy map of its position class (board.js canPlace) and, for a summon whose + * text reads "只能部署在召唤者攻击范围内" (tokens.json `ownerRange`: 伺夜's 狼群, 缪尔赛思's 流形), a tile of its owner's + * attack range (summonRange). `owner` = the owner's position after the move being checked ({ key, piece, dir }: a + * summon swapped with its own owner). + */ + _legal(piece, r, c, owner = null) { + if (!canPlace(this.deployMap(), this._placementOf(piece), r, c)) return false; + const range = this.summonRange(piece, owner); + return !range || range.has(tileKey(r, c)); + } + + /** + * The 'r,c' keys of the attack range of a range-bound summon's owner (player report #9 after 0.1.0: 伺夜's tactical + * point could be placed anywhere; PRTS 狼群 特性 "只能部署在召唤者攻击范围内"): the owner's loadout-resolved range grid + * (shared/loadoutRecord.js attackRangeGrid — what the deploy wheel previews) rotated by its facing around its board + * tile (board.js ownerRangeKeys). Null when the piece is not range-bound or its owner is not on the board (the other + * rules refuse such a placement). + * @param {any} piece + * @param {{ key: string, piece: any, dir: string } | null} [owner] the owner's position to use instead of its current one + * @returns {Set | null} + */ + summonRange(piece, owner = null) { + if (!piece || piece.kind !== 'token' || this.gd.token(piece.id)?.ownerRange !== true) return null; + let at = owner; + if (!at) for (const [key, p] of this.board) if (p.uid === piece.ownerUid && p.kind === 'chess') { at = { key, piece: p, dir: pieceDir(p) }; break; } + const rec = at && this.gd.chess(at.piece.id); + if (!rec) return null; + const grid = attackRangeGrid(loadoutRecord(rec, resolveRecordLoadout(rec, this.loadoutFor(rec)))) || rec.rangeGrid; + const [r, c] = parseKey(at.key); + return ownerRangeKeys(grid, r, c, at.dir); + } + + /** + * Range-bound summons left outside their owner's attack range (the owner re-oriented in place, promoted, its loadout + * changed, moved with no room to take its summons back) go back onto their stack — a summon still inside stays + * [ASSUMED: the official moves every summon of a MOVED owner back, PRTS 卫戍协议/帮助 "移动干员时,其所属召唤物全部退场 + * 并重置至手牌区"; an in-place re-orientation keeps the ones it can]. One with no stack and no free hand / temp slot + * leaves the board: its stack comes back at the next round start like a summon stack removed from temp at a prep + * deadline (startRound → grantTokensFor, "干员所属召唤物会于下一回合返还"), so no illegal placement reaches the + * battle. Returns the number taken off the board; a toast names them. + */ + _liftOutOfRange() { + const back = [], gone = []; + for (const [k, p] of [...this.board]) { + if (p.kind !== 'token') continue; + const range = this.summonRange(p); + if (!range || range.has(k)) continue; + this.board.delete(k); + (this._returnToken(p, null, { allowTemp: true }) ? back : gone).push(this.gd.token(p.id)?.name || p.id); + } + if (back.length) this.m.toast(this, 'warn', `${back.join('、')}只能部署在召唤者攻击范围内,已退回整备区`); + if (gone.length) this.m.toast(this, 'warn', `${gone.join('、')}只能部署在召唤者攻击范围内,整备区已满,下回合返还`); + return back.length + gone.length; + } + + /** + * Whether every range-bound summon of `owner` that the range from (ownerKey, dir) leaves out can go back onto its + * stack or into a free hand / temp slot (_reorient refuses otherwise: the player's own re-orientation never costs a + * summon, like withdrawing one into a full hand gives HAND_FULL). + */ + _roomForOutOfRange(owner, ownerKey, dir) { + const at = { key: ownerKey, piece: owner, dir }; + const stacks = [...this.hand, ...this.temp].filter((p) => p && p.kind === 'token' && p.ownerUid === owner.uid); + const needSlot = new Set(); + for (const [k, p] of this.board) { + if (p.kind !== 'token' || p.ownerUid !== owner.uid || stacks.some((s) => s.id === p.id)) continue; + const range = this.summonRange(p, at); + if (range && !range.has(k)) needSlot.add(p.id); + } + return needSlot.size <= [...this.hand, ...this.temp].filter((p) => p == null).length; + } + + _moveChessToBoard(loc, r, c, dir = 'RIGHT') { + const piece = loc.piece; + if (!inField(r, c) || !this._legal(piece, r, c)) return fail(ERR.BAD_TILE); + const key = tileKey(r, c); + const occ = this.board.get(key) || null; + if (occ === piece) return this._reorient(piece, dir); + if (loc.area === 'board') { + // board → board: move or swap (the occupant must be legal on the source tile and keeps its own facing); an + // operator that changes its tile takes its summons off the board (back onto their stacks, _liftTokensOf) — its + // own summon swapped onto its old tile included, so that one needs no tile check + if (occ) { + const [sr, sc] = parseKey(loc.key); + const ownSummon = occ.kind === 'token' && occ.ownerUid === piece.uid; + if (!ownSummon && !this._legal(occ, sr, sc)) return fail(ERR.BAD_TILE); + this.board.set(loc.key, occ); + if (occ.kind === 'chess') this._liftTokensOf(occ.uid); + } else { + this.board.delete(loc.key); + } + piece.dir = dir; + this.board.set(key, piece); + this._liftTokensOf(piece.uid); + this.recompute(); + return OK; + } + // hand/temp → board + if (!occ || occ.kind !== 'chess') { + if (this.deployCount >= this.deployCap) return fail(ERR.BOARD_FULL); + } + this._detach(loc); + if (occ) { + this.board.delete(key); + if (occ.kind === 'chess') { + this.removeTokensOf(occ.uid); + this._putBack(loc, occ); + } else { + this._returnToken(occ, loc); + } + } + piece.dir = dir; + this.board.set(key, piece); + this.grantTokensFor(piece); + this.recompute(); + return OK; + } + + /** + * In-place re-orientation (g.move onto the piece's own tile with a new direction). An owner's range-bound summons the + * new range leaves out go back onto their stack (recompute → _liftOutOfRange); HAND_FULL (nothing changes) when one + * of them would have nowhere to go. + */ + _reorient(piece, dir) { + if (pieceDir(piece) === dir) return OK; + if (piece.kind === 'chess') { + const loc = this.find(piece.uid); + if (loc && loc.area === 'board' && !this._roomForOutOfRange(piece, loc.key, dir)) return fail(ERR.HAND_FULL); + } + piece.dir = dir; + this.recompute(); + return OK; + } + + /** Put a piece into the container slot described by `loc` (hand/temp idx), or anywhere free. */ + _putBack(loc, piece) { + if (loc.area === 'hand' && this.hand[loc.idx] == null) { this.hand[loc.idx] = piece; return true; } + if (loc.area === 'temp' && this.temp[loc.idx] == null) { this._putTemp(loc.idx, piece); return true; } + return !!this.stow(piece, { allowTemp: true }); + } + + /** + * A board token goes back to the hand: merge into its owner's stack, else occupy a free slot. `allowTemp: false` + * (the player's own withdrawal): a summon stack is a card, so with a full hand and no stack to join it is refused + * like any other card (research 01 A1) instead of overflowing into temp. + */ + _returnToken(tok, preferLoc = null, { allowTemp = true } = {}) { + const stack = [...this.hand, ...this.temp].find((p) => p && p.kind === 'token' && p.ownerUid === tok.ownerUid && p.id === tok.id); + if (stack) { stack.count = (stack.count || 1) + (tok.count || 1); return true; } + tok.count = tok.count || 1; + if (preferLoc && preferLoc.area === 'hand' && this.hand[preferLoc.idx] == null) { this.hand[preferLoc.idx] = tok; return true; } + return !!this.stow(tok, { allowTemp }); + } + + _moveTokenToBoard(loc, r, c, dir = 'RIGHT') { + const piece = loc.piece; + if (!inField(r, c)) return fail(ERR.BAD_TILE); + const key = tileKey(r, c); + const occ = this.board.get(key) || null; + // a summon dragged onto its own owner swaps with it: a range-bound one must be inside the owner's range from the + // tile the owner takes (the summon's, with the owner's facing) + const ownerAfter = loc.area === 'board' && occ && occ !== piece && occ.uid === piece.ownerUid ? { key: loc.key, piece: occ, dir: pieceDir(occ) } : null; + if (!this._legal(piece, r, c, ownerAfter)) return fail(ERR.BAD_TILE); + if (occ === piece) return this._reorient(piece, dir); + if (loc.area === 'board') { + // board → board: move or swap; an operator swapped onto the summon's old tile changed its tile, so its other + // summons go back onto their stacks like any moved operator (_liftTokensOf; the summon just placed stays) + if (occ) { + const [sr, sc] = parseKey(loc.key); + if (!this._legal(occ, sr, sc)) return fail(ERR.BAD_TILE); + this.board.set(loc.key, occ); + } else { + this.board.delete(loc.key); + } + piece.dir = dir; + this.board.set(key, piece); + if (occ && occ.kind === 'chess') this._liftTokensOf(occ.uid, piece); + this.recompute(); + return OK; + } + if (occ) return fail(ERR.BAD_TILE, 'occupied'); + // owner must be on the board for its summons to be deployable + const owner = [...this.board.values()].find((p) => p.uid === piece.ownerUid); + if (!owner) return fail(ERR.BAD_TARGET, 'owner not deployed'); + if ((piece.count || 1) > 1) { + piece.count -= 1; + const one = this.newPiece('token', piece.id, { count: 1, ownerUid: piece.ownerUid, dir }); + this.board.set(key, one); + } else { + this._detach(loc); + piece.count = 1; + piece.dir = dir; + this.board.set(key, piece); + } + this.recompute(); + return OK; + } + + _moveToHand(loc, idx) { + const piece = loc.piece; + const occ = this.hand[idx]; + if (occ === piece) return OK; + if (piece.kind === 'token') { + if (loc.area !== 'board') { + // hand/temp token stack: plain slot move / swap inside the containers + return this._swapContainers(loc, idx); + } + this.board.delete(loc.key); + if (!this._returnToken(piece, occ == null ? { area: 'hand', idx } : null, { allowTemp: false })) { + this.board.set(loc.key, piece); + return fail(ERR.HAND_FULL); + } + this.recompute(); + return OK; + } + if (loc.area === 'board') { + // withdraw (撤退): to an empty slot, or swap with a chess occupant (which takes the board tile). The piece's own + // summon stacks leave the hand with it, so a slot holding one counts as free (a full hand nets zero cards) + const ownStack = (p) => !!p && p.kind === 'token' && p.ownerUid === piece.uid; + if (occ == null || ownStack(occ)) { + this.board.delete(loc.key); + this.removeTokensOf(piece.uid); + this.hand[idx] = piece; + } else if (occ.kind === 'chess') { + const [sr, sc] = parseKey(loc.key); + if (!this._legal(occ, sr, sc)) return fail(ERR.BAD_TILE); + // the bench card takes the withdrawn piece's tile with that tile's facing + occ.dir = pieceDir(piece); + this.board.set(loc.key, occ); + this.hand[idx] = piece; + this.removeTokensOf(piece.uid); + this.grantTokensFor(occ); + } else { + let j = freeSlot(this.hand); + if (j < 0) j = this.hand.findIndex(ownStack); + if (j < 0) return fail(ERR.HAND_FULL); + this.board.delete(loc.key); + this.removeTokensOf(piece.uid); + this.hand[j] = piece; + } + this.recompute(); + return OK; + } + return this._swapContainers(loc, idx); + } + + /** hand/temp → hand slot: move into an empty slot or swap with the occupant. */ + _swapContainers(loc, idx) { + const piece = loc.piece; + const occ = this.hand[idx]; + this._detach(loc); + this.hand[idx] = piece; + if (occ) { + if (loc.area === 'hand') this.hand[loc.idx] = occ; + else this._putTemp(loc.idx, occ); + } + this.recompute(); + return OK; + } +} diff --git a/server/match/player/prep.js b/server/match/player/prep.js new file mode 100644 index 00000000..1ac447bb --- /dev/null +++ b/server/match/player/prep.js @@ -0,0 +1,85 @@ +// server/match/player/prep.js — PlayerState methods: the gate of every prep intent (_gate: alive, PREP, not ready), +// g.reward (a reward offer's pick), Ready (refused while temp holds pieces) and the resolution of the temp pieces due +// at a prep's deadline. +// Installed on PlayerState.prototype by server/match/PlayerState.js (a method container: never instantiated; `this` is +// the player state). + +import { ERR, PHASE } from '../../../shared/constants.js'; +import { freeSlot } from '../board.js'; +import { OK, fail } from './common.js'; + +export class PlayerPrep { + _gate({ allowWhenReady = false } = {}) { + if (!this.alive) return fail(ERR.ELIMINATED); + if (this.m.phase !== PHASE.PREP) return fail(ERR.WRONG_PHASE); + if (this.ready && !allowWhenReady) return fail(ERR.WRONG_PHASE, 'ready'); + return null; + } + + pickReward(idx) { + const g = this._gate(); if (g) return g; + const offer = this.offers[0]; + if (!offer) return fail(ERR.BAD_TARGET, 'no reward'); + if (!Number.isInteger(idx) || idx < 0 || idx >= offer.slots.length) return fail(ERR.BAD_TARGET); + const slot = offer.slots[idx]; + if (!slot || slot.sold) return fail(ERR.SOLD_OUT); + const handFull = freeSlot(this.hand) < 0; + if (slot.kind === 'item') { + if (!this.gd.item(slot.id)) return fail(ERR.BAD_TARGET); + if (handFull && !this.completesItemMerge(slot.id)) return fail(ERR.HAND_FULL); + } else { + const rec = this.gd.chess(slot.id); + if (!rec) return fail(ERR.BAD_TARGET); + const base = this.gd.baseIdOf(slot.id); + const need = rec.isGolden ? this.gd.goldenCopies : 1; + if (this.m.pool.has(base) && this.m.pool.left(base) < need) return fail(ERR.SOLD_OUT); + if (handFull && !this.completesChessMerge(slot.id)) return fail(ERR.HAND_FULL); + } + const price = Number.isFinite(slot.price) && slot.price > 0 ? Math.trunc(slot.price) : 0; + if (price > this.funds) return fail(ERR.NO_FUNDS); + slot.sold = true; + this.offers.shift(); + if (price > 0) this.spend(price); + if (slot.kind === 'item') this.acquireItem(slot.id, { source: 'reward' }); + else this.acquireChess(slot.id, { source: 'reward' }); + this._afterSpend(price, 'reward'); + this.recompute(); + return OK; + } + + setReady(on) { + if (!this.alive) return fail(ERR.ELIMINATED); + if (this.m.phase !== PHASE.PREP) return fail(ERR.WRONG_PHASE); + if (on && !this.tempEmpty) return fail(ERR.TEMP_NOT_EMPTY); + if (this.ready === !!on) return OK; + this.ready = !!on; + // un-ready: the player can act again, so what overflowed while it was ready is due at this prep's deadline + if (!on) for (const p of this.temp) if (p && this.tempDue(p) > this.prepsEnded) this._tempDue.set(p.uid, this.prepsEnded); + this.dirty(); + this.m.onReadyChanged(this); + return OK; + } + + /** + * Prep deadline: every temp piece due at this prep (tempDue ≤ prepsEnded) is resolved — a chess is sold back (its + * pool copies return, its summons are removed), items are destroyed, the summon stack of an owner still on the board + * is removed and comes back at the next round start (startRound → grantTokensFor; PRTS "干员所属召唤物会于下一回合 + * 返还"). Pieces that overflowed after the player could no longer act on them (after Ready, at the prep end) are kept + * for the next prep. + */ + resolveTemp() { + let changed = false; + for (let i = 0; i < this.temp.length; i++) { + const p = this.temp[i]; + if (!p || this.tempDue(p) > this.prepsEnded) continue; + this.temp[i] = null; + this._tempDue.delete(p.uid); + changed = true; + if (p.kind === 'chess') { + this.removeTokensOf(p.uid); + this.returnCopies(p); + } + } + if (changed) this.recompute(); + } +} diff --git a/server/match/player/round.js b/server/match/player/round.js new file mode 100644 index 00000000..498f20e7 --- /dev/null +++ b/server/match/player/round.js @@ -0,0 +1,129 @@ +// server/match/player/round.js — PlayerState methods: the round lifecycle the match calls — startRound (income, pending +// coins, the shop, summon stacks topped up), endPrep, eliminate (every copy back to the pool), recompute (legality, +// out-of-range summons, bonds), the bond views — and battleInput (the player's PlayerBattleInput: board units with +// their loadout, carried 联防 state, the reached layers). +// Installed on PlayerState.prototype by server/match/PlayerState.js (a method container: never instantiated; `this` is +// the player state). + +import { boardOrder, pieceDir } from '../board.js'; +import { computeBonds, bondSnapshot, activatedLayers, bondsWithGains } from '../bondsMeta.js'; + +export class PlayerRound { + startRound(r) { + this.round = { refreshes: 0, buys: 0, sells: 0, spent: 0, gainedChess: 0, arts: 0 }; + this.pendingLayerGains = null; // settled (or lapsed) at the last SETTLE + if (r > 1) this.shop.upgradePrice = Math.max(0, this.shop.upgradePrice - 1); + // onIncome handlers may rewrite ev.income / ev.pending (e.g. 老鲤 withholds R1–R2 income until R3) + const ev = { round: r, income: this.gd.income(r), pending: this.pendingFunds }; + this.pendingFunds = 0; + this.m.dispatch(this, 'onIncome', ev); + const nonNeg = (v) => (Number.isFinite(v) && v > 0 ? Math.trunc(v) : 0); + this.addFunds(nonNeg(ev.income) + nonNeg(ev.pending), { reason: 'income' }); + // temp is NOT wiped here: the last prep's deadline resolved what the player could act on (endPrep); what overflowed + // after it (battle-result grants, SETTLE merges, returned equipment) is shown and usable in this prep (tempDue). + // Likewise reward offers of the last prep already expired at its end; what is still queued was earned after it — + // a merge completed during SETTLE / the Final Assault (突变细胞, battle-result grants) — and is shown in this prep + this.ready = false; + // summon stacks removed from temp at the last prep deadline come back (PRTS 卫戍协议/帮助 §手牌区); full hand ⇒ temp + for (const p of [...this.board.values()]) if (p.kind === 'chess') this.grantTokensFor(p); + this.rollShop({ keepFrozen: true }); + this.shop.frozen = false; + for (const s of this.shop.slots) if (s) s.frozen = false; + this.recompute(); + } + + /** + * Prep deadline (Match.endPrep, after the <休整期结束时> onPrepEnd effects): the temp pieces due at this prep are + * resolved; what overflowed after Ready or during onPrepEnd stays for the next prep, which `prepsEnded` now names. + */ + endPrep() { + this.resolveTemp(); + this.prepsEnded++; + for (const uid of [...this._tempDue.keys()]) if (!this.temp.some((p) => p && p.uid === uid)) this._tempDue.delete(uid); + this.offers = []; + this.clearUnfrozenShop(); + if (!this.gd.leftoverKeptBands.includes(this.bandId)) this.funds = 0; + this.ready = true; + this.dirty(); + } + + // `effects` are kept: a 信标 gift still pending is delivered to the teammate at the next round start (builtin_gift is + // flagged afterElimination — GitHub #86); nothing else of an eliminated player is dispatched. + eliminate(round) { + this.alive = false; + this.ready = false; + this.eliminatedRound = round; + const all = []; + for (const p of this.board.values()) all.push(p); + for (const p of this.hand) if (p) all.push(p); + for (const p of this.temp) if (p) all.push(p); + for (const p of all) this.returnCopies(p); + this.board.clear(); + this.hand.fill(null); + this.temp.fill(null); + this._tempDue.clear(); + this.offers = []; + this.bounties = []; + this.shop.slots = []; + this.funds = 0; + this.pendingFunds = 0; + this.recompute(); + } + + recompute() { + this.deployMap(); // a change of the deploy field (a boss round's prep) marks the legality stale + if (this._legalityStale) this._evictIllegal(); + this._liftOutOfRange(); + this.bonds = computeBonds(this.gd, this); + this.dirty(); + } + + activatedLayers() { return activatedLayers(this.bonds); } + + /** + * The bond states the views show (m.private bonds, m.public players[].bonds): the computed states plus the pending + * in-battle gains of this round's finished normal battle (bondsMeta.bondsWithGains). The 联防 field fights with them too + * (battleInput `reached`); no other rule reads them. + */ + bondsView() { return bondsWithGains(this.bonds, this.pendingLayerGains); } + + /** + * `reached`: the bonds carry the layers this round's own combat reached (bondsView: the pending in-battle gains, capped + * like settle()) — the 联防 field (unite.js; PRTS 卫戍协议/帮助 §联防阶段 "将以其阵地当前的状态", [ASSUMED] the current + * state includes those layers, as the strip shows them). The gains stay pending: settle() adds them once. + */ + battleInput({ side = 'L', colOffset = 0, carry = null, reached = false } = {}) { + // a terrain change not yet followed by a recompute (a content hook at the prep end) never fields an illegal board + this.deployMap(); + if (this._legalityStale) this.recompute(); + const units = []; + for (const { r, c, piece } of boardOrder(this.board)) { + if (piece.kind === 'chess') { + const u = { uid: piece.uid, kind: 'chess', chessId: piece.id, row: r, col: c, dir: pieceDir(piece), items: (piece.items || []).map((i) => i.id) }; + // DESIGN §16: the equipped skill / module (elite only) from the loadout (defaults when absent) + const lo = this.loadoutFor(this.gd.chess(piece.id)); + u.skillIndex = lo.skillIndex; + u.moduleId = lo.moduleId; + if (carry && carry.has(piece.uid)) u.carryState = carry.get(piece.uid); + units.push(u); + } else if (piece.kind === 'token') { + const u = { uid: piece.uid, kind: 'token', tokenId: piece.id, row: r, col: c, dir: pieceDir(piece), ownerUid: piece.ownerUid }; + if (carry && carry.has(piece.uid)) u.carryState = carry.get(piece.uid); // 联防: { sp } (unite.js) + units.push(u); + } + } + return { + playerId: this.playerId, + seat: this.seat, + side, + colOffset, + units, + bonds: bondSnapshot(reached ? this.bondsView() : this.bonds), + bandId: this.bandId, + playerEffects: this.effects.filter((e) => e.battle !== false).map((e) => ({ + id: e.id, key: e.key ?? null, source: e.iconKind ?? null, params: e.params ?? null, counter: e.counter ?? null, data: e.data ?? null, + })), + deviceOverrides: { ...this.deviceOverrides }, + }; + } +} diff --git a/server/match/player/views.js b/server/match/player/views.js new file mode 100644 index 00000000..8a97408a --- /dev/null +++ b/server/match/player/views.js @@ -0,0 +1,97 @@ +// server/match/player/views.js — PlayerState methods: the views — pieceView, effectsView (the effects column: 策略 / 机变 / +// 悬赏 …) and m.private (privateView). +// Installed on PlayerState.prototype by server/match/PlayerState.js (a method container: never instantiated; `this` is +// the player state). + +import { PHASE } from '../../../shared/constants.js'; +import { boardOrder, pieceDir } from '../board.js'; +import { bondList, offBondCounts } from '../bondsMeta.js'; +import { bountyText } from '../choices.js'; + +export class PlayerViews { + pieceView(p, rc = null) { + const rec = p.kind === 'item' ? this.gd.item(p.id) : p.kind === 'token' ? this.gd.token(p.id) : this.gd.chess(p.id); + const v = { + uid: p.uid, + kind: p.kind, + id: p.id, + golden: !!(rec && rec.isGolden), + tier: rec && Number.isInteger(rec.tier) ? rec.tier : null, + items: p.kind === 'chess' ? (p.items || []).map((it) => ({ uid: it.uid, id: it.id })) : [], + count: p.kind === 'token' ? (p.count || 1) : 1, + ownerUid: p.kind === 'token' ? p.ownerUid ?? null : null, + }; + if (rc) { v.row = rc[0]; v.col = rc[1]; v.dir = pieceDir(p); } + return v; + } + + effectsView() { + const out = []; + const band = this.bandId ? this.gd.band(this.bandId) : null; + if (band) out.push({ id: band.effectId || band.bandId, name: band.effectName || band.name, desc: band.desc || '', iconKind: 'band', iconId: band.iconId || band.bandId }); + for (const e of this.effects) { + if (e.hidden) continue; + const v = { id: e.id, name: e.name || e.id, desc: e.desc || '', iconKind: e.iconKind || 'choice', iconId: e.iconId || e.id }; + if (e.counter != null) v.counter = e.counter; + out.push(v); + } + for (const b of this.bounties) { + // the battles the bounty's enemies still come for (an official multi-round card: every battle, no counter) and + // the card's official rich text (blue "下场作战" / "两场作战", red "每场"; a multi-round card reads as long as it + // lasts — choices.js bountyText, MULTI_ROUND_BOUNTY_BATTLES) — user playtest #6 item 4 + const left = b.roundsLeft >= 90 ? null : b.roundsLeft; + const eff = b.card.effectId ? this.gd.effect(b.card.effectId) : null; + out.push({ + id: b.id, name: b.card.name || '悬赏', desc: bountyText((eff && eff.descRaw) || b.card.desc || '', b.card), iconKind: 'choice', iconId: b.card.effectId || 'bounty', + counter: left, counterText: left == null ? '之后的每场作战' : `还剩 ${left} 场作战`, + }); + } + return out; + } + + privateView() { + const slots = this.shop.slots.map((s) => (s ? { kind: s.kind, id: s.id, price: this.priceOf(s), basePrice: s.basePrice, sold: !!s.sold, frozen: !!s.frozen } : null)); + const offer = this.offers[0] || null; + const free = this.shop.freeRefreshes > 0; + const board = []; + for (const { r, c, piece } of boardOrder(this.board)) board.push(this.pieceView(piece, [r, c])); + return { + t: 'm.private', + playerId: this.playerId, + seat: this.seat, + alive: this.alive, + lp: this.lp, + funds: this.funds, + bandId: this.bandId, + ready: this.ready, + canReady: this.alive && this.tempEmpty && this.m.phase === PHASE.PREP, + shop: { + level: this.shop.level, + maxLevel: this.gd.maxShopLevel, + upgradePrice: this.shop.level >= this.gd.maxShopLevel ? 0 : this.shop.upgradePrice, + refreshPrice: free ? 0 : this.gd.refreshPrice, + freeRefreshes: this.shop.freeRefreshes, + frozen: this.shop.frozen, + slots, + // `source` 'merge' = the promotion reward (晋升奖励); any other offer (a strategy, an item, a 特质) carries the + // `label` the bar shows instead; `queued` = offers waiting behind it (player report #6 after 0.1.0) + rewardOffer: offer ? { tier: offer.tier, source: offer.source === 'merge' ? 'merge' : 'special', label: offer.label || null, queued: this.offers.length - 1, slots: offer.slots.map((s) => ({ kind: s.kind === 'item' ? 'item' : 'chess', id: s.id, price: s.price, sold: !!s.sold })) } : null, + }, + hand: this.hand.map((p) => (p ? this.pieceView(p) : null)), + temp: this.temp.map((p) => (p ? this.pieceView(p) : null)), + board, + deployCap: this.deployCap, + deployCount: this.deployCount, + // + the mode-off bonds it has members of (`off: true`, the strip's grey 本局禁用 discs — bondsMeta.offBondCounts) + bonds: bondList(this.gd, this.bondsView(), { full: true, off: offBondCounts(this.gd, this) }), + effects: this.effectsView(), + nextEnemies: this.m.nextEnemiesFor(this), + // DESIGN §16: the effective operator loadout ({ [baseChessId]: { skill, module } }; chess not listed use defaults) + loadout: this.loadout, + stats: { + dmgDealt: Math.round(this.stats.dmgDealt), kills: this.stats.kills, leaks: this.stats.leaks, gold: this.stats.gold, + refreshes: this.stats.refreshes, merges: this.stats.merges, + }, + }; + } +} diff --git a/shared/constants.js b/shared/constants.js index 8ae63403..deb1abaf 100644 --- a/shared/constants.js +++ b/shared/constants.js @@ -10,7 +10,7 @@ export const MAX_SEATS = 4; * Spectator seats of a co-op room (community report #26, owner's decision 2026-10-04) — a remake feature: the official * room has 1–4 players and no spectator seat (there only eliminated players and 联防 bystanders watch, research 09 §3.1). * A spectator never counts as a player, may not act, and watches like an eliminated player (server/lobby.js spectate, - * server/match/Match.js addSpectator). + * server/match/match/watch.js addSpectator). */ export const MAX_SPECTATORS = 2; export const ROOM_CODE_LEN = 4; @@ -79,8 +79,8 @@ export const SKILL_SUMMON_START_DEPLOY = true; * `Torappu.Battle.AutoChessBattleConst.MAX_GARRISON_STACK = 999`, and its bond counter (`AddBondCount`) stores * `min(L + n, 999)` — each bond stops at 999 on its own; the community reports bonds sitting at 999 while fed (巴哈姆特 * 12534 "每把都能999层", 12316 "999謝"; research 02 §layers). The only implementation of the cap (DESIGN §20.12). Every - * writer of a bond's layers goes through `layerGainRoom`: the prep-side gains (server/match/PlayerState.js addLayers — - * 特质, items, bands, 机变 cards, bonds), the settle of the in-battle gains (server/match/Match.js) and the live in-battle + * writer of a bond's layers goes through `layerGainRoom`: the prep-side gains (server/match/player/economy.js addLayers — + * 特质, items, bands, 机变 cards, bonds), the settle of the in-battle gains (server/match/match/settle.js) and the live in-battle * copy (server/sim/battle/economy.js addLayers, as the client's AddBondCount) — and the dev tools' direct writes (tools/matchrun.mjs * --layers, tools/balance.mjs applyBoard); a gain at the cap adds 0 (no onLayers, no 'layer' * event), and the client-result check (server/match/fields.js) bounds a reported gain by the room left. Milestones paid diff --git a/test/docs-consistency.test.js b/test/docs-consistency.test.js index 65a796dc..9a48ce53 100644 --- a/test/docs-consistency.test.js +++ b/test/docs-consistency.test.js @@ -52,6 +52,10 @@ 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 PlayerState class: the façade (header, constructor) and its method modules (server/match/player/) +const playerText = () => [doc('server/match/PlayerState.js'), ...readdirSync(join(ROOT, 'server/match/player')).sort().map((f) => doc(`server/match/player/${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,11 +561,12 @@ 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/player/economy.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`); } + for (const [name, src] of [['Match', matchText()], ['PlayerState', playerText()]]) assert.ok(!/layerRoom\b/.test(src), `${name}: no layerRoom`); // 限伤: official constant, not an overflow — no doc keeps the boss-HP branch's "fixed-point overflow, not modelled" assert.equal(BOSS_HIT_LIMIT, 300000); for (const [name, text] of [['BALANCE', BALANCE], ['DATA', DATA_MD], ['PLAYING', PLAYING], ['SIM', SIM], ['META', META], ['research 02', R02], ['DESIGN', DESIGN]]) { @@ -614,8 +619,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'); @@ -810,7 +815,7 @@ test('突变细胞 after the WA merge (DESIGN §21.1): the carrier is destroyed, assert.match(PLAYING, /原来的格子空出来,剩余可放置角色加 1/); assert.match(DATA.items.chess_item_5_08_e_a.note, /进入整备区,需要重新部署/); // the code: a destroy, then a gain through acquireChess; _mergeChess has no carrier-tile option left - const PS = doc('server/match/PlayerState.js'); + const PS = playerText(); const { PlayerState } = await import('../server/match/PlayerState.js'); assert.equal(PlayerState.prototype.transformChess.length, 2, 'transformChess(piece, newId)'); assert.equal(PlayerState.prototype._mergeChess.length, 2, '_mergeChess(baseId, incoming)'); diff --git a/test/match/playtest6-bosspool.test.js b/test/match/playtest6-bosspool.test.js index dd5e6d60..4dafc88c 100644 --- a/test/match/playtest6-bosspool.test.js +++ b/test/match/playtest6-bosspool.test.js @@ -9,7 +9,7 @@ // the verdict then came from the pool after the final b.result, whose per-player damage is rounded: rounding up // emptied the dust → 胜利 (what the user saw), rounding down → 失败. // -// Now (sim/constants.js BOSS_POOL_MIN_HP, finalAssault.js SharedBossPool, sim/spec.js LocalBossPool, Match.js): a pool +// Now (sim/constants.js BOSS_POOL_MIN_HP, finalAssault.js SharedBossPool, sim/spec.js LocalBossPool, match/reports.js): a pool // never holds less than 1 HP (the hit that would leave less takes the rest; a browser pool reading below 1 is 0), so // the leader dies on every field as soon as the pool shows 0 and the fight ends; the first end condition the server // registers decides the verdict (pool 0 → victory, team LP 0 → defeat: PRTS 卫戍协议:盟约 下半 "…使目标生命值扣除至0, diff --git a/test/match/playtest6-elite-to-board.test.js b/test/match/playtest6-elite-to-board.test.js index 0f5b1daa..232eed47 100644 --- a/test/match/playtest6-elite-to-board.test.js +++ b/test/match/playtest6-elite-to-board.test.js @@ -1,7 +1,7 @@ // User playtest #6 follow-up — a merge's elite goes to the board when it consumed a deployed copy. The user (first-hand, // official game): "官方就是合成精锐时,如果消耗了场上的干员,精锐会出现在场上那个位置". PRTS 卫戍协议/帮助 §干员的获得与精锐化: // "不获得第3名干员,销毁已有的2名初始干员,发送1名【精锐】状态的该干员至手牌区(若消耗已部署至作战区的干员,则发送至作战区 -// 对应位置)". Remake (server/match/PlayerState.js _mergeChess, board.js mergeTile): the elite takes the tile and facing of the +// 对应位置)". Remake (server/match/player/acquire.js _mergeChess, board.js mergeTile): the elite takes the tile and facing of the // consumed copy that deploys first (row desc, col asc — [ASSUMED] when several stood on the board); a 突变细胞 carrier is // destroyed before its gain, so its tile never counts; with no deployed copy it goes to the hand (overflow temp). // Equipment returns to the hand ("干员晋级后已配发装备会回收至整备区"), the copies' summons are removed and the elite on diff --git a/test/match/unitStats.test.js b/test/match/unitStats.test.js index f5a3d159..6401c69a 100644 --- a/test/match/unitStats.test.js +++ b/test/match/unitStats.test.js @@ -1,7 +1,7 @@ // Live operator stats (user playtest #4 item 7 — the detail card showed the fixed record numbers): g.unitStats answers // m.unitStats { seq, round, units } with the stats every unit of the player's board starts its next battle with — the // real battle input (equipment, bonds / layers, 特质, band and 机变 effects, the onBattleStart meta) built into a Battle -// that is started and read, never stepped (server/match/Match.js unitStats); the shape is shared/protocol.js +// that is started and read, never stepped (server/match/match/intents.js unitStats); the shape is shared/protocol.js // unitStatsEntry (also the browser runner's live battle stats, test/match/runner.test.js). The preview changes nothing // of the match. import { test } from 'node:test'; diff --git a/test/ui/bandDraft.test.js b/test/ui/bandDraft.test.js index 05d7c380..6a0c5889 100644 --- a/test/ui/bandDraft.test.js +++ b/test/ui/bandDraft.test.js @@ -1,8 +1,8 @@ // Strategy draft 队友已选 (research 09 §5 / §7, DESIGN §14 corrections): a strategy a teammate already picked cannot be -// chosen again — the server refuses it (server/match/Match.js pickBand → BAD_TARGET '队友已选'), bots re-draw, and the +// chosen again — the server refuses it (server/match/match/phases.js pickBand → BAD_TARGET '队友已选'), bots re-draw, and the // UI marks it (screens/bandDraft.js teammateBands). Automatic assignments (a turn that runs out, a departing seat) give -// the official default 「华法琳」 only while no teammate holds it, else the first free strategy (Match.js defaultBand; the -// UI names it: bandDraft.js timeoutBand) — a timed-out turn takes the highlighted band first (g.bandFocus, Match.js +// the official default 「华法琳」 only while no teammate holds it, else the first free strategy (match/phases.js defaultBand; the +// UI names it: bandDraft.js timeoutBand) — a timed-out turn takes the highlighted band first (g.bandFocus, match/phases.js // timeoutBand; user playtest #4 item 4: one countdown, BAND_TURN_SECONDS per turn, no separate step cap). import { test, describe } from 'node:test'; diff --git a/test/ui/bosshit-ticker.test.js b/test/ui/bosshit-ticker.test.js index 8c90492a..5746d370 100644 --- a/test/ui/bosshit-ticker.test.js +++ b/test/ui/bosshit-ticker.test.js @@ -1,5 +1,5 @@ // Player report after 0.1.0, client part: "隐藏boss还没打就出了造成50%伤害播报". Besides the server's share (fixed in -// server/match/Match.js, test/match/bosshit-ticker.test.js) the ticker strip plays its queue in order, TICKER_MS (5.2 s) +// server/match/match/bossRounds.js, test/match/bosshit-ticker.test.js) the ticker strip plays its queue in order, TICKER_MS (5.2 s) // per line and up to QUEUE_MAX (4) lines behind: in a headless-Chrome run the Final Assault's "超过20%" / "超过80%" // lines played 2–8 s into the Hidden Core with its leader at 96 % / 53 %. A leader-damage line (BOSS_HIT) now plays // only during the round it came in, and a player's newer BOSS_HIT line of the round supersedes their older one, queued diff --git a/test/ui/playtest3.test.js b/test/ui/playtest3.test.js index 106d98a7..83e71346 100644 --- a/test/ui/playtest3.test.js +++ b/test/ui/playtest3.test.js @@ -2,7 +2,7 @@ // 2 the own LP drops live while the own battle's enemies enter the blue gate — pendingLoss / liveLp (the settle // rule min(lpCapPerRound, counted leaks), reset when the settled m.private lands, 联防中 during 联防), the team // panel rows (rowLp: own live value, teammates' m.public players[].pendingLp), the LP tower's −N tick, and the -// server's pendingLp (server/match/Match.js _pendingLpView) +// server's pendingLp (server/match/match/views.js _pendingLpView) // 3 the temp overflow row (临时整备区): the ready button's visible reason, the row's frame geometry (tempRowFrame) // 8 the operator's own effect (特质 / garrison) right under the detail card's header (CHESS_SECTIONS, rendered order) // 9 no spinning busy indicators next to texts: no wait / progress cursors, the button's busy bar instead of a