Files
ark-stronghold-protocol/server/match/match/intents.js
T
sganggs e8d2b45ad4 feedback5 CB5: an eliminated player (and a spectator seat) follows a player through every phase reset — the one it last watched, else the first player still in — instead of being sent back to its own empty board each round (community report of 2026-10-06, item 56; the idea of PR #189)
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.
2026-10-07 02:06:10 +08:00

125 lines
7.1 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/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;
}
}