Add special-target activation without bypassing attack cadence, cooperative card preferences and emotes, skill sound banks, per-operator voice choices, text sizing, install prompts, closed-tab recovery and unanimous setup rerolls. Retain late deployment elapsed time and resolve echo/counter pulses without recursive hook overflow. Independent final review remains pending. Golden: roster-016, roster-035, roster-036, roster-045; boss-boss_1-pair, boss-boss_3-pair, boss-boss_5-pair, boss-boss_7-pair, hidden-boss_9-pair; solo-HARD-9-boosted: Lemuen attack cadence. diy-132: Necras extra activation targets. coop2-NORMAL-14-standins: cooperative purchase preference and simulation fixes. hidden-boss_10-solo: echo pulse FIFO resolution. See DESIGN 28.13-28.24 for assumptions, sources and contributor credit.
306 lines
14 KiB
JavaScript
306 lines
14 KiB
JavaScript
// 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 || this.setupVote || this._infoAdvanceTimer) return;
|
||
if (this.order.every((p) => p.isBot || p.left || p.infoReady)) {
|
||
this.setDeadline(0);
|
||
this._infoAdvanceTimer = this.later(0, () => {
|
||
this._infoAdvanceTimer = null;
|
||
if (this.phase === PHASE.INFO_CHECK && !this.setupVote) 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;
|
||
let order = this.order.map((p) => p.playerId);
|
||
if (!this.isSolo) this.rngDraft.shuffle(order);
|
||
order = this.humansFirst(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();
|
||
}
|
||
|
||
/**
|
||
* The co-op room option 「AI 队友最后选择」 (this.aiPicksLast, GitHub #338): every human seat before every AI seat, each
|
||
* group in the order the draft drew (a stable partition applied AFTER the shuffle — no extra random draw: the order
|
||
* with the option off is unchanged, and with it on every random stream stands where it would without it; only the
|
||
* picks made in the new order can differ). A human is any seat that is not an AI seat (room.addBot): under AI 托管,
|
||
* disconnected or departed it still counts as a human. Used by the strategy draft and the 机变 draft
|
||
* (MatchSpDraft.enterSpDraft).
|
||
* @param {string[]} order playerIds in drawn order
|
||
* @returns {string[]}
|
||
*/
|
||
humansFirst(order) {
|
||
if (!this.aiPicksLast) return order;
|
||
const bot = (pid) => !!this.players.get(pid)?.isBot;
|
||
return [...order.filter((pid) => !bot(pid)), ...order.filter(bot)];
|
||
}
|
||
|
||
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, '队友已选'); // i18n-ignore: developer detail (players see ERR_TEXT)
|
||
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);
|
||
// the skipper goes to the end; with 「AI 队友最后选择」 to the end of the humans still to pick — behind them, ahead of
|
||
// the AI seats — and to the very end only when no other human is left to pass to [ASSUMED: the option's intent,
|
||
// humans before AI, kept through a skip; no source, a remake option]
|
||
let at = d.order.length;
|
||
if (this.aiPicksLast) {
|
||
for (let j = d.order.length - 1; j >= d.idx; j--) {
|
||
if (!this.players.get(d.order[j])?.isBot) { at = j + 1; break; }
|
||
}
|
||
}
|
||
d.order.splice(at, 0, 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.uniteResultView = 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();
|
||
// eliminated humans and spectator seats scout the board of the player they follow through the round's prep, not
|
||
// their own empty board (community report of 2026-10-06, item 56; MatchWatch._followScout)
|
||
for (const ps of this._viewers()) this._followScout(ps);
|
||
}
|
||
|
||
/** 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();
|
||
}
|
||
}
|