Official: research 09 §3.1 — after elimination the client keeps observing (self_dead_dialog KEEP_WATCH,
DeadAutoObDn { obIndex, state, preparation }, FindFirstAvailObTarget). Until 0.2.0 every phase reset cleared
an eliminated player's watch: in 回合开始 / 机变 / 休整期 it saw its own empty board, in battle the first field.
Server (server/match/match/watch.js): Match.watchPref (viewer → the player of its last manual g.watch: a prep
scout, a single-player field, or the new optional g.watch playerId on a shared 联防 / boss pair field — PR
#189's review point); _watchTargetOf = that player while still in, else the first player still in (seat
order, AI seats included); startRound scouts its board (_followScout, kept through 机变 and 休整期), both
combat modes and the boss fields start on its field, a resync / a spectator joining in prep gets the board,
a followed player who quits hands over at once. Client: the screen adopts the pushed board like a 前往查看 tap
(battle/observe.js followedScout; the stale-field fix of PR #189), keeps it through the prep phases (no camera
flight home and back), a spectator no longer asks for the first player itself; g.watch sends the tapped row.
Overlap with PR #189 (not merged): the same preference chain; differences — the fallback includes AI seats
(a lone human with AI teammates had nobody to follow), shared fields record the tapped playerId.
Golden unchanged. Tests: test/match/feedback5-watch-follow.test.js (new, 9), spectator.test.js updated (the
spectator follows the player it scouted). Docs: design/network.md, PLAYING.md.
125 lines
7.1 KiB
JavaScript
125 lines
7.1 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 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;
|
||
}
|
||
}
|