feedback5 R2: Match.js → server/match/match/*.js, a pure move — the 167 class members (166 methods and the soloUntimed getter), verbatim with their comments, go to 16 per-concern method containers (platform, infra, messaging, views, watch, intents, pause, phases, spDraft, prep, combat, clientCombat, reports, unitePhase, bossRounds, settle) that Match.js installs on Match.prototype in a fixed order with class-method descriptors (a name defined twice throws); the constants the modules share go to match/common.js (FLOW_TICKER_PRIORITY, DELAYS, BAND_TURN_SECONDS re-exported by Match.js; OK, fail, BOSS_CLOCK_MS), GAME_TYPES to platform.js, the boss-round budgets / rates to bossRounds.js; Match.js keeps the header (+ a code-layout paragraph), the constructor-only constants and the class with its constructor. Section-header comments are replaced by the module headers. docs-consistency reads the method modules (matchText) and pins layerGainRoom in match/settle.js
This commit is contained in:
1 parent
8ab6d021da
commit
1ca4936f0a
19 files changed
+3105
-2923
No files matched your search
@@ -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 };
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user