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.
This commit is contained in:
sganggs committed 2026-10-07 02:06:10 +08:00
1 parent 464a823bc5
commit e8d2b45ad4
14 files changed
+330 -47

No files matched your search

+9 -1
View File
@@ -64,7 +64,9 @@
// addSpectator(id) (optional for the platform) A spectator seat joined during the match, came back or asked
// for a resync: register it (idempotent) and resend what an ELIMINATED player watching sees —
// m.public and, while a battle runs, the b.start (watch) of the field it watches (default: the
// first field; server-run mode: m.field + b.snap), or m.result once ended. A spectator gets
// field of the player it follows — the one it last watched, else the first player still in;
// server-run mode: m.field + b.snap), in a prep phase that player's board (m.field prep), or
// m.result once ended. A spectator gets
// every broadcast through the platform, never an m.private / m.toast / m.unitStats, is never
// a field's player or authority, and is shown fields like an eliminated player in every phase.
// handle(id, msg) answers only its 'g.watch' (anything else → SPECTATOR; the platform routes
@@ -358,6 +360,12 @@ export class Match {
this.runner = null;
/** playerId → fieldId */
this.watchers = new Map();
/**
* Watch preference (community report of 2026-10-06, item 56; the idea of PR #189): viewer playerId → the player it
* last chose to watch with a manual g.watch. Every phase reset starts an eliminated human or a spectator seat on that
* player again (its prep board, its battle field) — else the first player still in — instead of its own empty board.
*/
this.watchPref = new Map();
this.lastResults = new Map();
this.unitePlan = null;
/** server-run 联防: the leakers' counts last published (_uniteTick) */
+5 -3
View File
@@ -92,7 +92,8 @@ export class MatchCombat {
this.deadline = this.sched.instant ? 0 : this.sched.now() + Math.round((limit / this.gameSpeed) * 1000);
this._launch(fields);
// every fighting human runs its own field; eliminated humans (and spectator seats) keep watching (research 09 §3.1
// "Keep-watching auto-observes the first available field", switching freely with 前往查看): a replica of the first field
// "Keep-watching auto-observes the first available field", switching freely with 前往查看): a replica of the field
// of the player they follow — the one they last watched, else the first player still in (item 56) — else the first
for (const f of fields) for (const pid of f.players) {
const ps = this.players.get(pid);
if (!ps || ps.isBot || ps.left) continue;
@@ -103,8 +104,9 @@ export class MatchCombat {
if (first) {
for (const ps of this._viewers()) {
if (ps.alive || this.watchers.has(ps.playerId)) continue;
this.watchers.set(ps.playerId, first.fieldId);
this._sendStart(ps.playerId, first, { watch: true });
const f = this._watchTargetField(ps, fields) || first;
this.watchers.set(ps.playerId, f.fieldId);
this._sendStart(ps.playerId, f, { watch: true });
}
}
this.markPublic();
+2 -1
View File
@@ -34,7 +34,8 @@ export class MatchIntents {
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);
case 'g.watch': return this.watch(ps, msg.fieldId);
// 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)
+3
View File
@@ -253,6 +253,9 @@ export class MatchPhases {
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. */
+6 -2
View File
@@ -100,7 +100,8 @@ export class MatchPlatform {
/**
* The full state of one human (a reconnect, a resync, a spectator seat): m.public, its m.private (players only), the
* field it is on / watches — a spectator, like an eliminated player, the first field — or the result once ended.
* field it is on / watches — a spectator, like an eliminated player, the field of the player it follows (item 56),
* else the first; in a prep phase that player's board — or the result once ended.
*/
_resync(ps) {
const playerId = ps.playerId;
@@ -111,9 +112,10 @@ export class MatchPlatform {
this._sendPrivate(ps, true);
}
if (this.clientCombat) this._resendBattle(ps);
else if (!this.fields.length && this._follows(ps)) this._followScout(ps, { keep: true });
else {
let fid = this.watchers.get(playerId);
if (!fid && ps.spectator && this.fields.length) { fid = this.fields[0].fieldId; this.watchers.set(playerId, fid); }
if (!fid && ps.spectator && this.fields.length) { fid = (this._watchTargetField(ps, this.fields) || this.fields[0]).fieldId; this.watchers.set(playerId, fid); }
if (fid) this._sendField(playerId, fid);
}
} else if (this.lastResultMsg) {
@@ -180,6 +182,8 @@ export class MatchPlatform {
this._planBossWaves();
for (const p of this.alivePlayers()) p.recompute();
}
// whoever scouted the departed player's board follows the next player still in (item 56)
for (const v of this._viewers()) if (this.watchers.get(v.playerId) === `n:${ps.playerId}`) this._followScout(v);
this.markPublic();
if (this.teamLp != null) this._syncTeamLp();
if (!this.alivePlayers().length) {
+76 -10
View File
@@ -2,7 +2,9 @@
// #26: stand-ins shown fields like an eliminated player), g.watch in both combat modes (the boss-group rule, prep
// scouting of a teammate's board), who is shown which field (the default watch, the boss fields, the resend on a
// reconnect, the spectator's spec without the funds), a field's m.field + b.snap (server-run mode) and the prep-scout
// pushes (GitHub #87).
// pushes (GitHub #87). Eliminated humans and spectator seats follow a player through every phase reset (community
// report of 2026-10-06, item 56; the official DeadAutoObDn { obIndex, state, preparation }, research 09 §3.1; the idea
// of PR #189): the player they last watched with a manual g.watch (`watchPref`), else the first player still in.
// 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';
@@ -44,9 +46,13 @@ export class MatchWatch {
return out;
}
watch(ps, fieldId) {
/**
* g.watch { fieldId, playerId? } — `playerId`: the player the viewer tapped (a shared 联防 / boss pair field shows two,
* its id does not say which; recorded as the watch preference when that player is on the field).
*/
watch(ps, fieldId, playerId = null) {
if (typeof fieldId !== 'string') return fail(ERR.BAD_TARGET);
if (this.clientCombat && this.fields.length && this.fields.some((x) => x.cc)) return this._watchClient(ps, fieldId);
if (this.clientCombat && this.fields.length && this.fields.some((x) => x.cc)) return this._watchClient(ps, fieldId, playerId);
const f = this.fields.find((x) => x.fieldId === fieldId);
if (f) {
// 最终攻势 / 隐秘核心: "两名参与者会处于同一个战场,但无法查看另一组队友的战场情况" — a fighting player sees its own
@@ -54,6 +60,7 @@ export class MatchWatch {
const own = this.fieldOf(ps);
if ((f.kind === 'boss' || f.kind === 'hidden') && own && own !== f.fieldId) return fail(ERR.BAD_TARGET, 'other group hidden');
this.watchers.set(ps.playerId, fieldId);
this._watchPrefSet(ps, f, playerId);
this._sendField(ps.playerId, fieldId);
return OK;
}
@@ -66,18 +73,68 @@ export class MatchWatch {
const target = this.players.get(fieldId.slice(2));
if (!target || !target.alive) return fail(ERR.BAD_TARGET);
this.watchers.set(ps.playerId, fieldId);
if (target !== ps) this.watchPref.set(ps.playerId, target.playerId);
this._notifyPrepScouts(target, { to: ps.playerId });
return OK;
}
return fail(ERR.BAD_TARGET);
}
/**
* Record the player a manual watch of field `f` names: `playerId` when it is one of the field's players, else the
* field's only player — never the viewer itself; a shared field without `playerId` names nobody (PR #189's review).
* Only manual watches write it: the automatic assignments below read it.
*/
_watchPrefSet(ps, f, playerId = null) {
const players = Array.isArray(f?.players) ? f.players : [];
const pid = typeof playerId === 'string' && players.includes(playerId) ? playerId : players.length === 1 ? players[0] : null;
if (pid && pid !== ps.playerId) this.watchPref.set(ps.playerId, pid);
}
/**
* The player a viewer follows (an eliminated human, a spectator seat): its watch preference while that player is
* still in, else the first player still in (seat order — the official FindFirstAvailObTarget, AI teammates included:
* the remake's AI seats are players), never the viewer itself; null when nobody is left.
*/
_watchTargetOf(ps) {
const ok = (pid) => { const t = typeof pid === 'string' && pid !== ps.playerId ? this.players.get(pid) : null; return !!t && t.alive && !t.left; };
const pref = this.watchPref.get(ps.playerId);
if (ok(pref)) return pref;
return this.order.find((q) => ok(q.playerId))?.playerId ?? null;
}
/** The field of the player `ps` follows among `fields` (null when that player has none this phase). */
_watchTargetField(ps, fields) {
const pid = this._watchTargetOf(ps);
return pid ? fields.find((f) => Array.isArray(f.players) && f.players.includes(pid)) || null : null;
}
/** Does `ps` follow a player (an eliminated human or a spectator seat, still in the room)? */
_follows(ps) {
return !!ps && !ps.isBot && !ps.left && (ps.spectator || !ps.alive);
}
/**
* A prep phase (回合开始 / 机变 / 休整期): an eliminated human / spectator seat scouts the board of the player it follows
* — `keep`: a scout it already has this phase stays (a manual pick, a resync). Until 0.2.0 a phase reset dropped it
* onto its own empty board every round (community report of 2026-10-06, item 56).
*/
_followScout(ps, { keep = false } = {}) {
if (!this._follows(ps) || this.fields.length) return;
const cur = keep ? this.watchers.get(ps.playerId) : null;
const kept = typeof cur === 'string' && cur.startsWith('n:') ? this.players.get(cur.slice(2)) : null;
const target = kept && kept.alive && !kept.left ? kept : this.players.get(this._watchTargetOf(ps));
if (!target) return;
this.watchers.set(ps.playerId, `n:${target.playerId}`);
if (ps.connected) this._notifyPrepScouts(target, { to: ps.playerId });
}
/**
* g.watch under client-side combat: the watcher gets the field's spec (b.start, display only) and runs a local
* replica fast-forwarded to the field's clock. No watching while the own normal battle runs; the other pair's boss
* field is never shown to a fighting player; eliminated players may watch anything.
*/
_watchClient(ps, fieldId) {
_watchClient(ps, fieldId, playerId = null) {
const f = this.fields.find((x) => x.fieldId === fieldId) || null;
if (!f) return fail(ERR.BAD_TARGET, 'no such field');
const own = this.fields.find((x) => x.players.includes(ps.playerId)) || null;
@@ -86,17 +143,22 @@ export class MatchWatch {
if (f.kind === 'normal' && own && own !== f && own.live) return fail(ERR.WRONG_PHASE, 'own battle running');
}
this.watchers.set(ps.playerId, f.fieldId);
this._watchPrefSet(ps, f, playerId);
this.sendTo(ps.playerId, this._startMsg(f, ps.playerId, { watch: !f.players.includes(ps.playerId) }));
return OK;
}
/** Reconnect / resync: the spec of the field the player is on (fast-forwarded by the client). */
/**
* Reconnect / resync: the spec of the field the player is on (fast-forwarded by the client); an eliminated human /
* spectator seat the field it watches, else the one of the player it follows. With no client field up (a prep phase):
* the board of the player it follows (item 56; PR #189's review — a resync in prep used to show nothing).
*/
_resendBattle(ps) {
if (!this.fields.some((f) => f.cc)) return;
if (!this.fields.some((f) => f.cc)) { this._followScout(ps, { keep: true }); return; }
const fid = this.watchers.get(ps.playerId);
let f = fid ? this.fields.find((x) => x.fieldId === fid) : null;
if (!f) f = this.fields.find((x) => x.players.includes(ps.playerId)) || (this.phase === PHASE.UNITE ? this.fields[0] : null);
if (!f && !ps.alive) f = this.fields[0] || null;
if (!f && !ps.alive) f = this._watchTargetField(ps, this.fields) || this.fields[0] || null;
if (!f) return;
this.watchers.set(ps.playerId, f.fieldId);
this.sendTo(ps.playerId, this._startMsg(f, ps.playerId, { watch: !f.players.includes(ps.playerId) }));
@@ -106,18 +168,22 @@ export class MatchWatch {
this.watchers.clear();
for (const ps of this._viewers()) {
const own = this.fields.find((f) => f.players.includes(ps.playerId));
const f = own || this.fields[0];
// an eliminated human / spectator seat: the field of the player it follows (item 56), else the first
const f = own || this._watchTargetField(ps, this.fields) || this.fields[0];
if (!f) continue;
this.watchers.set(ps.playerId, f.fieldId);
if (ps.connected) this._sendField(ps.playerId, f.fieldId);
}
}
/** b.start of the boss fields: players get their own pair field, eliminated humans and spectator seats the first. */
/**
* b.start of the boss fields: players get their own pair field, eliminated humans and spectator seats the field of
* the player they follow (item 56), else the first.
*/
_watchBossFields(fields) {
for (const ps of this._viewers()) {
const own = fields.find((f) => f.players.includes(ps.playerId)) || null;
const f = own || fields[0];
const f = own || this._watchTargetField(ps, fields) || fields[0];
if (!f) continue;
this.watchers.set(ps.playerId, f.fieldId);
this._sendStart(ps.playerId, f, { watch: !own });