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:
1 parent
464a823bc5
commit
e8d2b45ad4
14 files changed
+330
-47
No files matched your search
@@ -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) */
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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
@@ -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 });
|
||||
|
||||
Reference in new issue
Block a user