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).
127 lines
7.2 KiB
JavaScript
127 lines
7.2 KiB
JavaScript
// server/match/match/intents.js — Match methods: the g.* / b.* intent router (_handle: the prep intents go to
|
||
// PlayerState, the drafts, watching, the pause and the battle reports to the match), emotes, AI 托管 (setAutoplay,
|
||
// kickBot) and g.unitStats (the stats the board's units start their next battle with: a preview battle, started, read
|
||
// and dropped).
|
||
// Installed on Match.prototype by server/match/Match.js (a method container: never instantiated; `this` is the match).
|
||
|
||
import { unitStatsEntry } from '../../../shared/protocol.js';
|
||
import { PHASE, ERR, EMOTES, EMOTE_COOLDOWN_MS, GEO } from '../../../shared/constants.js';
|
||
import { deriveSeed } from '../../sim/rng.js';
|
||
import { OK, fail } from './common.js';
|
||
|
||
export class MatchIntents {
|
||
_handle(ps, msg) {
|
||
switch (msg.t) {
|
||
case 'g.infoReady':
|
||
if (this.phase !== PHASE.INFO_CHECK) return fail(ERR.WRONG_PHASE);
|
||
if (!ps.infoReady) { ps.infoReady = true; this.markPublic(); this.maybeEndInfo(); }
|
||
return OK;
|
||
case 'g.band': return this.pickBand(ps, msg.bandId);
|
||
case 'g.bandSkip': return this.skipBand(ps);
|
||
// the strategy highlighted in the draft screen (what a timed-out turn takes, timeoutBand)
|
||
case 'g.bandFocus': return this.bandFocus(ps, msg.bandId ?? null);
|
||
case 'g.buy': return ps.buy(msg.slot);
|
||
case 'g.refresh': return ps.refresh();
|
||
case 'g.freeze': return ps.freeze();
|
||
case 'g.levelUp': return ps.levelUp();
|
||
case 'g.sell': return ps.sell(msg.uid);
|
||
// dir: the deploy wheel's facing (DESIGN §3; absent ⇒ PlayerState reads to.dir, then RIGHT)
|
||
case 'g.move': return ps.move(msg.uid, msg.to, msg.dir);
|
||
case 'g.equip': return ps.equip(msg.itemUid, msg.targetUid, msg.replaceUid ?? null);
|
||
case 'g.art': return ps.useArt(msg.itemUid, msg.row, msg.col, msg.dir);
|
||
case 'g.destroy': return ps.destroy(msg.uid);
|
||
case 'g.reward': return ps.pickReward(msg.idx);
|
||
case 'g.choice': return msg.choiceId !== undefined
|
||
? this.pickPersonalChoice(ps, msg.idx, msg.choiceId)
|
||
: this.pickCard(ps, msg.idx);
|
||
case 'g.ready': return ps.setReady(!!msg.ready);
|
||
case 'g.emote': return this.emote(ps, msg.id);
|
||
// playerId: the player tapped (a shared field names two) — the watch preference (item 56)
|
||
case 'g.watch': return this.watch(ps, msg.fieldId, msg.playerId ?? null);
|
||
case 'g.autoplay': return this.setAutoplay(ps, !!msg.on);
|
||
case 'g.pause': return this.setPause(ps, !!msg.on);
|
||
// the stats the board's units start their next battle with (the detail card in prep, user playtest #4 item 7)
|
||
case 'g.unitStats': return this.unitStats(ps, msg.seq ?? null);
|
||
case 'g.leave': this.onLeave(ps.playerId); return OK;
|
||
case 'b.progress': return this._onProgress(ps, msg);
|
||
case 'b.result': return this._onResult(ps, msg);
|
||
default: return fail(ERR.BAD_MSG);
|
||
}
|
||
}
|
||
|
||
emote(ps, id) {
|
||
if (!EMOTES.includes(id)) return fail(ERR.BAD_MSG, 'unknown emote');
|
||
const now = this.sched.now();
|
||
if (now - ps.lastEmoteAt < EMOTE_COOLDOWN_MS) return fail(ERR.RATE);
|
||
ps.lastEmoteAt = now;
|
||
this.broadcast({ t: 'm.emote', playerId: ps.playerId, id });
|
||
return OK;
|
||
}
|
||
|
||
setAutoplay(ps, on) {
|
||
if (ps.autoplay === on) return OK;
|
||
ps.autoplay = on;
|
||
this.markPublic();
|
||
if (on) this.kickBot(ps);
|
||
return OK;
|
||
}
|
||
|
||
/** Let the bot act for a (newly) bot-controlled seat in the current phase. */
|
||
kickBot(ps) {
|
||
if (!ps.botControlled || this.ended) return;
|
||
if (this.phase === PHASE.INFO_CHECK && !ps.infoReady) { ps.infoReady = true; this.markPublic(); this.maybeEndInfo(); }
|
||
else if (this.phase === PHASE.BAND_DRAFT && this.draftTurn() === ps.playerId) this.scheduleBandBot();
|
||
else if (this.phase === PHASE.SP_DRAFT && this.spTurn() === ps.playerId) this.scheduleSpBot();
|
||
else if (this.phase === PHASE.PREP && ps.alive && !ps.ready) this.scheduleBotPrep(ps, 0);
|
||
}
|
||
|
||
/**
|
||
* g.unitStats { seq? } (user playtest #4 item 7: the detail card showed fixed record stats): the stats every unit of
|
||
* the player's board will fight with at the start of its next battle — equipment, bonds and their layers, 特质, the
|
||
* band and 机变 effects — computed exactly by the shared sim. The player's battle input after the onBattleStart meta
|
||
* handlers (`ev.preview: true`, no enemies — those handlers must not change the match for a preview) builds a Battle
|
||
* of the battle's options that is started (initial deployment + battleStart hooks), read and dropped: it is never
|
||
* stepped, so skills and timed effects do not show. Pushed to the player as `m.unitStats { seq, round, units }`
|
||
* (units: shared/protocol.js unitStatsEntry, board operators and summons by uid); cached per input (a build costs
|
||
* ≈ 0.3–0.7 ms). Prep phases only (ROUND_START, 机变, PREP); a battle's live stats come from the browser's own sim.
|
||
* @param {PlayerState} ps @param {number|null} seq echoed (the client keeps the newest answer)
|
||
*/
|
||
unitStats(ps, seq = null) {
|
||
if (!ps.alive) return fail(ERR.ELIMINATED);
|
||
if (this.phase !== PHASE.ROUND_START && this.phase !== PHASE.SP_DRAFT && this.phase !== PHASE.PREP) return fail(ERR.WRONG_PHASE);
|
||
const units = this._unitStatsOf(ps);
|
||
this.sendTo(ps.playerId, { t: 'm.unitStats', seq: Number.isInteger(seq) ? seq : null, round: this.round, units });
|
||
return OK;
|
||
}
|
||
|
||
/** The start-of-battle stats of a player's board units (see unitStats); [] when the preview battle cannot be built. */
|
||
_unitStatsOf(ps) {
|
||
const input = ps.battleInput({ side: 'L', colOffset: 0 });
|
||
const ev = { input, kind: 'normal', round: this.round, preview: true };
|
||
this.dispatch(ps, 'onBattleStart', ev);
|
||
const players = [ev.input && typeof ev.input === 'object' ? ev.input : input];
|
||
let key = null;
|
||
try { key = JSON.stringify([this.round, this.stageId, this.battleContent, players]); } catch { key = null; }
|
||
if (!this._unitStatsCache) this._unitStatsCache = new WeakMap(); // PlayerState → { key, units } (the last preview)
|
||
const cached = this._unitStatsCache.get(ps);
|
||
if (key && cached && cached.key === key) return cached.units;
|
||
const units = [];
|
||
// the flags of the battle it previews: a normal round gains IN_BATTLE layers from its start (a <战斗开始时> layer gain
|
||
// raises bond stats at t = 0 there too); the Final Assault / Hidden Core fight without (previewed as a normal field)
|
||
const bossRound = this.round === this.gd.bossRound || this.round === this.gd.hiddenRound;
|
||
const b = this.newBattle({
|
||
seed: deriveSeed(this.seed, `preview:${this.round}:${ps.seat}`), kind: 'normal', modeId: this.modeId, round: this.round,
|
||
stageId: this.stageId, rect: { ...GEO.NORMAL_RECT }, timeLimit: 60, players, spawns: [], routes: this.wave ? this.wave.routes : [],
|
||
sharedBoss: null, flags: { layerGainsEnabled: !bossRound, ...this.gd.dp }, fieldId: `n:${ps.playerId}`, recordEvents: false,
|
||
});
|
||
try {
|
||
if (typeof b.start === 'function') b.start();
|
||
for (const u of Array.isArray(b.allyUnits) ? b.allyUnits : []) {
|
||
if (u && Number.isInteger(u.uid) && (u.kind === 'op' || u.kind === 'token')) units.push(unitStatsEntry(u, u.s));
|
||
}
|
||
} catch (e) { this.reportError('unitStats', e); }
|
||
this._unitStatsCache.set(ps, { key, units });
|
||
return units;
|
||
}
|
||
}
|