feedback5: merge R2 — server/match/Match.js split into server/match/match/* and PlayerState.js into server/match/player/* (fixed install order, same exports); golden unchanged
This commit is contained in:
commit
b1ec04b58c
49 files changed
+4776
-4466
No files matched your search
@@ -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
|
||||
+9
-2
@@ -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
|
||||
|
||||
+8
-1
@@ -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
|
||||
|
||||
@@ -318,7 +318,7 @@ The public mirror (ArknightsAssets2 `cn`, `ui/emoticon/theme/[uc]<themeId>/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]<themeId>/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]
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 } });
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 || '华法琳';
|
||||
|
||||
|
||||
@@ -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', '完美作战'],
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
+56
-2920
File diff suppressed because it is too large.
Load diff
+39
-1517
File diff suppressed because it is too large.
Load diff
@@ -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' }));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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(); }
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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<string, { skill: number, module: string|null }> | null} loadout
|
||||
* @returns {{ ok: true } | { error: string, detail?: string }}
|
||||
*/
|
||||
setLoadout(playerId, loadout) {
|
||||
const ps = this.players.get(playerId);
|
||||
if (!ps || ps.isBot || ps.left) return fail(ERR.NOT_IN_ROOM);
|
||||
if (this.disposed || this.ended || this.phase !== PHASE.INFO_CHECK) return fail(ERR.WRONG_PHASE, 'loadout locked for this match');
|
||||
let res = OK;
|
||||
this.guard(() => {
|
||||
if (!ps.setLoadout(loadout)) { res = fail(ERR.BAD_TARGET, 'loadout does not match the game data'); return; }
|
||||
this.markPrivate(ps);
|
||||
});
|
||||
return res;
|
||||
}
|
||||
|
||||
onDisconnect(playerId) {
|
||||
const ps = this.players.get(playerId);
|
||||
if (!ps || ps.isBot || this.disposed) return;
|
||||
this.guard(() => {
|
||||
ps.connected = false;
|
||||
// a paused solo battle resumes (the server takes the field over; nobody is left to resume it)
|
||||
this._resume();
|
||||
if (this.clientCombat) this._authorityLost(ps, 'disconnect');
|
||||
this.markPublic();
|
||||
});
|
||||
}
|
||||
|
||||
onReconnect(playerId) {
|
||||
const ps = this.players.get(playerId);
|
||||
if (!ps || ps.isBot || ps.left || this.disposed) return;
|
||||
this.guard(() => {
|
||||
const was = ps.connected;
|
||||
ps.connected = true;
|
||||
this._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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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); }
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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:<pid>' id
|
||||
// must name a live field (else the boss-group rule above could be bypassed, and the viewer would stop receiving
|
||||
// its own field's snapshots). 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:<pid>` 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -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 });
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<string> | 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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -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
|
||||
|
||||
@@ -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)');
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user