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

277 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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, '队友已选'); // 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);
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.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();
}
}