Files
ark-stronghold-protocol/server/match/match/views.js
T

259 lines
15 KiB
JavaScript

// 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(ps.gd || 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 = [];
// the scouted player's own view of the data (0.2.0 自选编队: its slotted DIY slots are its operators — player/diy.js)
const gd = ps.gd || this.gd;
// a 自选 piece's pick (like the sim's UnitInfo.diy): the scout's card composes the operator from it (shared/diy.js)
const diyOf = (piece) => {
const p = piece.kind === 'chess' && typeof ps.diyPickOf === 'function' ? ps.diyPickOf(piece.id) : null;
return p ? { charId: p.charId, skillIndex: p.skillIndex, uniEquipId: p.uniEquipId } : undefined;
};
for (const { r, c, piece } of boardOrder(ps.board)) {
const chess = piece.kind === 'token' ? null : gd.chess(piece.id);
// 0.2.0 补位: a chess this player fields as its stand-in is deployed with the stand-in's body — name, art, max HP,
// skill, like the sim's UnitInfo (`standInFor` = the replaced operator's charId)
const rec = piece.kind === 'token' ? gd.token(piece.id) : ps.fieldRecord(chess);
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' && chess ? ps.loadoutFor(chess) : 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,
standInFor: rec && rec.standInFor ? rec.standInFor : undefined,
diy: diyOf(piece),
});
}
// 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).
// (a held chess the player fields as its stand-in is the stand-in there too — name, art, max HP — and carries
// `standInFor`, like a board piece: the owner's recall of the official mode, 2026-10-06, the hand shows the stand-in)
const benchUnit = (piece, i, y) => {
const rec = piece.kind === 'item' ? gd.item(piece.id) : piece.kind === 'token' ? gd.token(piece.id) : gd.chess(piece.id);
const standIn = piece.kind === 'chess' && rec && ps.fieldsStandIn(rec) ? this.gd.standIn(rec.chessId) : null;
const body = standIn || rec;
const assets = (body && body.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: body ? body.name : piece.id, tier: rec && Number.isInteger(rec.tier) ? rec.tier : 1, golden: !!(rec && rec.isGolden),
spine: assets.spine || (body && body.charId) || piece.id, avatar: assets.avatar || (body && body.charId) || piece.id,
x: i, y, maxHp: body && body.stats && Number.isFinite(body.stats.maxHp) ? body.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,
standInFor: standIn && standIn.standInFor ? standIn.standInFor : undefined,
diy: diyOf(piece),
});
};
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 };
}
}