- Shared card pool and item stock as in the official mode (the owner's request, from the 路标档案馆 video and public notes): solo low-tier exceptions, co-op operators x2, shared item stock with special-item exceptions, normal / advanced occupancy 1 / 2, re-check on buy, 机变 exhaustion, release on consume / destroy / elimination; 整备 never exceeds stock; invariant held <= cap. - 突袭: one taunt-first reselect right after a revive (the owner's rule); ordinary polls and redeploys keep the 0.2.3 rule; a revive on an enemy's tile stays. #475: online boss / hidden-boss Raid may land on the empty half (owner-limited scope). - 浊心斯卡蒂 S1 x 坚守 no longer bounce redirected damage, and the two shares conserve the hit (idea of PR #464 by @HappyTanuki). - Operator fixes adapted from PRs by @Xinglan233 (#459 哈洛德 S2 elemental trigger, #460 涤火杰西卡 shield on the four adjacent tiles, #461 家族徽章 first hit after stealth, #462 松果 S2 regrow per deploy); 克莱门莎 S2 splash 1.1 / 100% ATK and air auto-trigger (#467); 白面 copying 歌蕾蒂娅 / 余 skips the three-member condition (#473, the owner's video). - 香槟炸弹 is not chosen by friendly auras / heals (PR #468 / #476 by @Siornya); 琳琅诗怀雅 coin balance (PR #450 by @IceCodeNew); Touch prep display (PR #451 by @YinqiMargatroid); lobby header (PR #471 by @YuriRestia); Japanese UI terms (PR #448 by @scutkane). - AI-only boss countdown grace, render-failure cache / recovery without restarting a battle, device units kept on a same-field re-entry, DIY detail / skill previews, large-text result buttons. - golden: battle families equal 0.2.3 except diy-135 / diy-136 (克莱门莎); all 18 release matches move through the shared pool, every flip named in docs/history/0.2.4.md §29.12; two coverage scenarios added.
396 lines
24 KiB
JavaScript
396 lines
24 KiB
JavaScript
// server/match/match/views.js — Match methods: the state builders — m.public (publicView with statusOf / fieldOf, the
|
||
// fields' progress, the teammates' live pendingLp / uniteLeft, the SETTLE uniteResult), 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; in a boss round's prep on the player's half of the boss field).
|
||
// 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 { bossFieldPlacement } from '../finalAssault.js';
|
||
import { battleProgress } from '../../sim/spec.js';
|
||
|
||
/** A reported capsule numerator clamped to its denominator, else null (unknown — never a fabricated 0). */
|
||
const finiteOrNull = (v, cap = Infinity) => {
|
||
if (v == null) return null; // Number(null) === 0: an unreported value must not read as "0 resolved"
|
||
const n = Number(v);
|
||
if (!Number.isFinite(n)) return null;
|
||
return Math.max(0, Math.min(Number.isFinite(cap) ? cap : Infinity, Math.trunc(n)));
|
||
};
|
||
|
||
/**
|
||
* A scouted operator's potential (below 6) and 练度 (0.2.2), like the sim's UnitInfo: PlayerState.loadoutFor's
|
||
* `potential` / `cultivate` (null for a stand-in or a prototype 自选 pick: neither field).
|
||
*/
|
||
function cultivationInfo(lo) {
|
||
const out = {};
|
||
if (lo && Number.isInteger(lo.potential) && lo.potential < 6) out.potential = lo.potential;
|
||
if (lo && Number.isInteger(lo.cultivate)) out.cultivate = lo.cultivate;
|
||
return out;
|
||
}
|
||
|
||
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,
|
||
setupRevision: this.setupRevision,
|
||
rerollVote: this.setupVote ? {
|
||
id: this.setupVote.id,
|
||
proposerId: this.setupVote.proposerId,
|
||
voters: this.setupVote.voters.slice(),
|
||
agreed: [...this.setupVote.agreed],
|
||
} : null,
|
||
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(c => ({ ...cardView(c), soldOut: c.kind === 'item' && !this.itemPool.canGain(c.id) })), 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) };
|
||
// SETTLE after a 联防: its outcome as data (settle.js uniteResultView; GitHub #235, PR #112) — { through, helpers,
|
||
// leakers, losses: { playerId: the LP settlement charged this round } } — the client's result box reads the viewer's
|
||
// own charge from it; absent when no 联防 resolved (the client then shows the round's own battle result)
|
||
if (this.phase === PHASE.SETTLE && this.uniteResultView) {
|
||
const ur = this.uniteResultView;
|
||
v.uniteResult = { through: ur.through, helpers: ur.helpers.slice(), leakers: ur.leakers.slice(), losses: { ...ur.losses } };
|
||
}
|
||
return v;
|
||
}
|
||
|
||
/**
|
||
* m.public.fields[].progress: { killed, resolved, total, done } (teammates' waiting UI). `resolved` is the HUD
|
||
* capsule's numerator — the field's own scheduled enemies knocked out or leaked (Battle.resolved / the reported
|
||
* b.progress `resolved`); it is `null` (never 0) while unknown, so the client's `resolved ?? killed` fallback holds.
|
||
* `total` is the capsule's denominator: only the enemies the round scheduled (runtime splits / summons — boss summons
|
||
* included — are in neither part).
|
||
*/
|
||
_fieldProgress(f) {
|
||
if (!f) return null;
|
||
if (!f.cc) {
|
||
const b = f.battle;
|
||
if (!b) return null;
|
||
const total = Number(b.total) || 0;
|
||
return { killed: Number(b.killed) || 0, resolved: finiteOrNull(b.resolved, total), total, done: !f.live };
|
||
}
|
||
if (f.done && f.result) {
|
||
let killed = 0, total = 0, own = 0, ownKnown = true;
|
||
for (const pp of Object.values(f.result.perPlayer || {})) {
|
||
killed += Number(pp && pp.killed) || 0;
|
||
total += Number(pp && pp.total) || 0;
|
||
if (Number.isFinite(pp && pp.resolved)) own += Number(pp.resolved); else ownKnown = false;
|
||
}
|
||
// the FIELD's numerator (the validated client result / Battle.result(): what the capsule showed) — not the sum of the
|
||
// players' own: an enemy that spawns on one half and leaks on the other (a 联防 lane, the boss pair's crossing routes)
|
||
// is billed to one player's `total` and to the other's leak, so their own min(total, …) clamp it to 0
|
||
let resolved = finiteOrNull(f.result.resolved, total);
|
||
if (resolved == null && ownKnown) resolved = Math.min(total, own);
|
||
if (f.result.synthetic) { killed = f.progress.killed; total = f.progress.total; resolved = finiteOrNull(f.progress.resolved, total); }
|
||
return { killed, resolved, total, done: true };
|
||
}
|
||
if (f.mode === 'server' && f.timeline) {
|
||
// a server-run / bot field: no authority ever sends a b.progress, so the capsule reads the battle's own counters —
|
||
// the timeline sample carries `resolved` (Battle.resolved: knocked out + leaked among the field's own enemies),
|
||
// never the report-driven `progress.leaks`, which would leave such a field at 0 forever
|
||
const [, killed, total, resolved] = timelineAt(f.timeline, this._fieldElapsed(f));
|
||
return { killed, resolved: finiteOrNull(resolved, total), total, done: false };
|
||
}
|
||
const total = f.progress.total;
|
||
return { killed: f.progress.killed, resolved: finiteOrNull(f.progress.resolved, total), 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]);
|
||
}
|
||
|
||
/** A 自选 piece's pick (like the sim's UnitInfo.diy): a scout's card composes the operator from it (shared/diy.js). */
|
||
_diyInfo(ps, 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;
|
||
}
|
||
|
||
/** The 外勤医疗 reserve medic already standing on each player's field in preparation. Touch replaces it only at
|
||
* battleStart, so the prep view always uses the reserve medic, regardless of the current elite count. The stage's
|
||
* normal-board slot is transformed to the correct half of a boss field by _onBossHalf / the client prep camera. */
|
||
prepMapChars(ps) {
|
||
if (!ps?.alive || this.phase !== PHASE.PREP || !this.order.some((p) => p.alive && p.bandId === 'band_amedic')) return [];
|
||
const id = 'char_605_cmedic';
|
||
const slot = this.stage?.mapChars?.find((m) => m?.key === id && Array.isArray(m.pos) && m.pos[0] >= 9 && m.pos[0] <= 12
|
||
&& m.pos[1] >= 0 && m.pos[1] <= 10 && !/multi_only/.test(String(m.alias)));
|
||
const rec = this.gd.token(id);
|
||
if (!slot || !rec) return [];
|
||
const dir = slot.dir || 'RIGHT';
|
||
return [{
|
||
id: -ps.seat - 1, uid: -ps.seat - 1, kind: 'token', side: 'ally', ownerId: ps.playerId, defId: id,
|
||
area: 'board', name: rec.name || id, tier: 1, golden: false,
|
||
spine: rec.assets?.spine || id, avatar: rec.assets?.avatar || id,
|
||
x: slot.pos[1], y: slot.pos[0], dir, facing: dir === 'LEFT' ? -1 : 1, maxHp: rec.stats?.maxHp || 1,
|
||
}];
|
||
}
|
||
|
||
/** UnitInfo of a player's board pieces on their (board) tiles — a prep scout's board, the boss partner's (bossMateView).
|
||
* `area: 'board'` (a bench unit says 'hand' / 'temp': prepFieldMeta). */
|
||
_prepBoardUnits(ps) {
|
||
const units = this.prepMapChars(ps);
|
||
// the 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;
|
||
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; 0.2.2 its potential (below 6) and 练度
|
||
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,
|
||
area: 'board',
|
||
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: this._diyInfo(ps, piece),
|
||
...cultivationInfo(lo),
|
||
});
|
||
}
|
||
return units;
|
||
}
|
||
|
||
/**
|
||
* A boss round's prep (最终攻势 / 隐秘核心: the pairing planned, no field up yet): the other player of `ps`'s pair as
|
||
* { mate: PlayerState, side: its half 'L' | 'R' }, else null (a lone player, any other phase).
|
||
*/
|
||
_bossMateOf(ps) {
|
||
if (!ps || !this.bossWaves || this.fields.length) return null;
|
||
const g = this.bossGroupOf(ps);
|
||
const pid = g && g.players.length > 1 ? g.players.find((x) => x !== ps.playerId) : null;
|
||
const mate = pid ? this.players.get(pid) : null;
|
||
return mate ? { mate, side: g.side === 'R' ? 'L' : 'R' } : null;
|
||
}
|
||
|
||
/**
|
||
* m.private `bossMate` (community report of 2026-10-06, item 51): in a boss round's prep the partner's board on its
|
||
* half of the boss field, as the battle will place it (bossFieldPlacement: the right half mirrored, RIGHT ↔ LEFT) — the
|
||
* own prep view draws it beside the own half, read-only, as the official prep shows both players of a pair together
|
||
* (the official client knows every board: ChangePositionDn { boardStatus }, research 09). Null otherwise.
|
||
* @returns {{ playerId: string, side: 'L'|'R', units: object[] } | null}
|
||
*/
|
||
bossMateView(ps) {
|
||
const m = ps && ps.alive ? this._bossMateOf(ps) : null;
|
||
if (!m) return null;
|
||
return { playerId: m.mate.playerId, side: m.side, units: this._prepBoardUnits(m.mate).map((u) => this._onBossHalf(u, m.side)) };
|
||
}
|
||
|
||
/** 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 = this._prepBoardUnits(ps);
|
||
const gd = ps.gd || this.gd;
|
||
// 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)
|
||
// Each unit says where it waits (`area` 'hand' | 'temp'; a board unit 'board'): the bond popup of a watched player
|
||
// counts it like the player's own (ui/watchBonds.js ownerBoard — a hand operator is owned and counts for 投资人 远见
|
||
// 奇迹, a temp one counts for nothing; GitHub #385, PR #387). The tag survives the boss-half remap (_onBossHalf), the
|
||
// row does not.
|
||
const benchUnit = (piece, i, y, area) => {
|
||
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, area,
|
||
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: this._diyInfo(ps, piece),
|
||
...cultivationInfo(lo),
|
||
});
|
||
};
|
||
for (let i = 0; i < ps.hand.length; i++) {
|
||
if (ps.hand[i]) benchUnit(ps.hand[i], i, GEO.HAND_ROW, 'hand');
|
||
}
|
||
for (let i = 0; i < ps.temp.length; i++) {
|
||
if (ps.temp[i]) benchUnit(ps.temp[i], GEO.TEMP_C0 + i, GEO.TEMP_ROW, 'temp');
|
||
}
|
||
// `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)
|
||
const meta = { t: 'm.field', fieldId: `n:${ps.playerId}`, kind: 'normal', rect: { ...GEO.NORMAL_RECT }, stageId: this.stageId, units, effects: ps.effectsView(), prep: true, nextEnemies };
|
||
// 最终攻势 / 隐秘核心 prep: the pieces stand on the player's half of the boss field — the scout shows them there (rows
|
||
// − 7, the right half mirrored with RIGHT ↔ LEFT: finalAssault.js bossFieldPlacement, as the own prep view), with the
|
||
// round's leader at its spawn tile (nextEnemies `start`), framed by the boss-field prep camera of the player's side
|
||
// (`side`). Until 0.2.0 an eliminated player or a spectator seat scouting a player then saw the normal board and no
|
||
// leader at all (community report of 2026-10-06, item 55).
|
||
// The pair's other player stands on the other half (item 51: both players of a pair together, as in the battle).
|
||
const g = this.fields.length ? null : this.bossGroupOf(ps);
|
||
if (!g) return meta;
|
||
const mate = this.bossMateView(ps);
|
||
const own = units.map((u) => this._onBossHalf(u, g.side));
|
||
return { ...meta, kind: 'boss', rect: { ...GEO.BOSS_RECT }, side: g.side, units: mate ? [...own, ...mate.units] : own, ...(mate ? { mate: { playerId: mate.playerId, side: mate.side } } : {}) };
|
||
}
|
||
|
||
/** A prep UnitInfo (board, bench or temp row) placed on side 'L' | 'R' of the boss field (bossFieldPlacement). */
|
||
_onBossHalf(u, side) {
|
||
const p = bossFieldPlacement(side, u.y, u.x, u.dir || 'RIGHT');
|
||
const v = { ...u, x: p.col, y: p.row };
|
||
// a board piece keeps facing the same way relative to the leader (mirrored on the right half); a bench piece has no
|
||
// stored facing (it faces right, as the own prep bench draws it)
|
||
if (u.dir) { v.dir = p.dir; v.facing = p.dir === 'LEFT' ? -1 : 1; }
|
||
return v;
|
||
}
|
||
}
|