Seen: the Art 教鞭 (杜宾's strategy) drew three 战术特训 cards and added a random one — a documented simplification (docs/META.md §2.5: a personal choice would need its own phase, protocol message, timer and AI takeover). Official: act2autochess choice_event hunter_band_1 is a PERSONAL_CHOOSE; PRTS 下半 记录 §法术 教鞭 "使用后销毁,于3个战术特训的悬赏任务中选择一项". The owner decided on 2026-10-08 to follow it. Taken from PR #353 (code and tests by @Sukvii), ported to the split 0.2.2 modules. Now: ctx.offerBountyChoice -> Match.offerBountyChoice keeps up to three different mode-eligible cards (15 of 20 in 标准 / 险境, 20 in 绝境 / 终极; one meta-rng shuffle) in PlayerState.personalChoice, m.private only (same cards after a reconnect); g.choice { idx, choiceId } confirms it (Match.pickPersonalChoice: owner, id, round, index; a request without a choiceId is still the public draft, a stale id never falls through to it). No new phase or timer: Ready is refused while it is open, the prep's deadline picks at random before the temp pieces and Ready, a bot or a 托管 seat picks with the bounty scorer (botPickCard, the bots' seeded rng; at the prep's start, after the cast, before Ready; the scheduler's fallback picks at random if a step fails). The client reuses ChoiceOverlay (personal: no order, no turns, the prep's countdown) over a locked prep, the Ready reasons, the mock harness; six UI strings in en / ja / ko / zh-TW. An eliminated player's offer goes with it; invariants.js flags an offer outside its owner's own open prep. Not taken from the PR: its §25.24 in the released docs/history/0.2.0.md (the 0.2.2 section is the integrator's), its edits of the Ready toggle beyond the personal-choice case, its tail placement of the i18n keys. Golden: coop2-NORMAL-8-serverrun (ai_0 杜宾 casts 教鞭 from R2 on and now scores the three cards: teamLp 39 -> 34, virtualMs 121400 -> 118400, rng shop 511 -> 483 / bots 18063 -> 18070 / meta 112 -> 103) and coop2-ABYSS-10-boosted (ai_1 杜宾: virtualMs 121650 -> 124650, shop 590 -> 589 / bots 22075 -> 22096 / meta 141 -> 154); both first differ in round 2, every other scenario and family is unchanged; node tools/golden.mjs --twice is identical. Tests: items.test.js (three distinct cards, the tapped one applied once, nothing before the confirm, a refused cast keeps the Art / count / rng / uid, an eliminated player's offer), economy.test.js (Ready refused, the deadline picks before endPrep, solo prep untimed), connection.test.js (two players' offers, reconnect, foreign / duplicate / old ids, an offline player's pick at the deadline), bot.test.js (托管 takeover incl. the failing-step fallback), bot-smarter.test.js (the scorer's pick, deterministic per seed, no rng draw when nothing is pending), followups.test.js (R14 / R15), draft.test.js, unitStats.test.js (protocol), hardening.test.js (invariant), gameLogic / playtest3 / playtest4 (view model, Ready reason, ChoiceView, actions), mock.e2e.test.js and playtest2.real.e2e.test.js (browser, SP_E2E=1).
142 lines
5.9 KiB
JavaScript
142 lines
5.9 KiB
JavaScript
// server/match/match/prep.js — Match methods: the PREP phase — its start (deferred item merges, onPrepStart), the AI
|
||
// seats' sliced preps (scheduleBotPrep: economy + layout, rehearsal, Ready), Ready and the deadline, and its end
|
||
// (onPrepEnd, PlayerState.endPrep, then COMBAT or the Final Assault / Hidden Core).
|
||
// Installed on Match.prototype by server/match/Match.js (a method container: never instantiated; `this` is the match).
|
||
|
||
import { PHASE } from '../../../shared/constants.js';
|
||
import { botPrepBeginSteps, botPrepEndSteps } from '../bot.js';
|
||
import { DELAYS } from './common.js';
|
||
|
||
export class MatchPrep {
|
||
enterPrep() {
|
||
this.phase = PHASE.PREP;
|
||
this.sp = null;
|
||
const alive = this.alivePlayers();
|
||
for (const ps of alive) {
|
||
ps.ready = false;
|
||
// Items gained as the previous prep ended waited unmerged (acquireItem deferMerge). Merge them now, before
|
||
// this prep's onPrepStart grants and before the player acts — not in endPrep, which runs in the same prep
|
||
// that granted them and would take an equipped copy off for the fight about to start.
|
||
ps.checkItemMerges();
|
||
ps.recompute();
|
||
this.dispatch(ps, 'onPrepStart', { round: this.round });
|
||
ps.recompute();
|
||
}
|
||
// solo / single-human matches: untimed (soloUntimed); co-op: the round's prepTime
|
||
const secs = this.soloUntimed ? null : this.gd.prepTime(this.round);
|
||
this.setDeadline(secs, () => this.prepDeadline());
|
||
let i = 0;
|
||
for (const ps of alive) if (ps.botControlled) this.scheduleBotPrep(ps, i++);
|
||
this.markPublic();
|
||
this.maybeEndPrep();
|
||
}
|
||
|
||
/**
|
||
* The bot plays a prep in three stages, every one in slices of ≤ botSliceMs wall-clock ms (one scheduler callback
|
||
* each, so other rooms' battles and every player's requests keep flowing): economy + the default layout
|
||
* (bot.js botPrepBeginSteps — shop decisions and layout planning, 50–120 ms late in a 4-bot match), the layout
|
||
* rehearsal (whole simulated battles, 0.2–1 s of CPU per bot late in a match), then botPrepEndSteps (the rehearsed
|
||
* layout, temp, Ready). The step generators run the same actions in the same order as the one-shot routine (same
|
||
* rng draws, same decisions); in virtual time (botSliceMs unbounded) each stage runs at once. The prep ending first
|
||
* (deadline) or a newer schedule for the seat drops the job (a step never leaves a transient board behind).
|
||
*/
|
||
scheduleBotPrep(ps, i = 0) {
|
||
const round = this.round;
|
||
const token = (ps._botPrepToken = (ps._botPrepToken || 0) + 1);
|
||
const valid = () => this.phase === PHASE.PREP && this.round === round && ps.alive && !ps.ready && ps.botControlled && ps._botPrepToken === token;
|
||
const bounded = Number.isFinite(this.botSliceMs);
|
||
const now = () => (typeof performance !== 'undefined' ? performance.now() : Date.now());
|
||
/** Step a generator until done or the slice budget is used; `then(value)` once it is done (null on an error). */
|
||
const drive = (gen, label, then) => {
|
||
const t0 = now();
|
||
let r = null;
|
||
try {
|
||
do r = gen.next(); while (!r.done && !(bounded && now() - t0 >= this.botSliceMs));
|
||
} catch (e) {
|
||
this.reportError(`bot ${ps.playerId}${label}`, e);
|
||
then(null);
|
||
return;
|
||
}
|
||
if (r.done) { then(r.value); return; }
|
||
this.later(0, () => { if (valid()) drive(gen, label, then); });
|
||
};
|
||
const ready = () => {
|
||
if (!ps.ready) {
|
||
this.autoPickPersonalChoice(ps, 'random');
|
||
ps.resolveTemp();
|
||
ps.setReady(true);
|
||
}
|
||
};
|
||
const end = (job) => {
|
||
let gen = null;
|
||
try { gen = botPrepEndSteps(this, ps, job); } catch (e) { this.reportError(`bot ${ps.playerId}`, e); }
|
||
if (!gen) { ready(); return; }
|
||
drive(gen, '', ready);
|
||
};
|
||
this.later(this.scaled(DELAYS.BOT_ACTION + i * DELAYS.BOT_STAGGER), () => {
|
||
if (!valid()) return;
|
||
drive(botPrepBeginSteps(this, ps), '', (job) => {
|
||
if (!job) { end(null); return; }
|
||
const slice = () => {
|
||
if (!valid()) return;
|
||
let done = true;
|
||
try { done = job.run(this.botSliceMs); } catch (e) { this.reportError(`bot ${ps.playerId} rehearsal`, e); }
|
||
if (done) { if (bounded) this.later(0, () => { if (valid()) end(job); }); else end(job); }
|
||
else this.later(0, slice);
|
||
};
|
||
// bounded slices start in a callback of their own (the economy + default layout above already used this one)
|
||
if (bounded) this.later(0, slice);
|
||
else slice();
|
||
});
|
||
});
|
||
}
|
||
|
||
onReadyChanged(ps) {
|
||
this.markPublic();
|
||
void ps;
|
||
this.maybeEndPrep();
|
||
}
|
||
|
||
maybeEndPrep() {
|
||
if (this.phase !== PHASE.PREP || this._prepEndQueued) return;
|
||
const allReady = () => { const alive = this.alivePlayers(); return alive.length > 0 && alive.every((p) => p.ready); };
|
||
if (!allReady()) return;
|
||
const round = this.round;
|
||
this._prepEndQueued = true;
|
||
// the prep deadline stays armed until the phase really ends: a player may un-ready before this runs
|
||
this.later(0, () => {
|
||
this._prepEndQueued = false;
|
||
if (this.phase === PHASE.PREP && this.round === round && allReady()) this.endPrep();
|
||
});
|
||
}
|
||
|
||
prepDeadline() {
|
||
if (this.phase !== PHASE.PREP) return;
|
||
for (const ps of this.alivePlayers()) {
|
||
if (ps.ready) continue;
|
||
this.autoPickPersonalChoice(ps, 'random');
|
||
ps.resolveTemp();
|
||
ps.ready = true;
|
||
ps.dirty();
|
||
}
|
||
this.endPrep();
|
||
}
|
||
|
||
endPrep() {
|
||
if (this.phase !== PHASE.PREP) return;
|
||
this.setDeadline(0);
|
||
const alive = this.alivePlayers();
|
||
for (const ps of alive) this.dispatch(ps, 'onPrepEnd', { round: this.round });
|
||
for (const ps of alive) ps.endPrep();
|
||
const r = this.round;
|
||
if (r === this.gd.bossRound) {
|
||
this.hiddenLayerSum = alive.reduce((s, p) => s + p.activatedLayers(), 0);
|
||
this.startFinalAssault(false);
|
||
} else if (r === this.gd.hiddenRound) {
|
||
this.startFinalAssault(true);
|
||
} else {
|
||
this.startCombat();
|
||
}
|
||
}
|
||
}
|