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

+19 -1
View File
@@ -6,7 +6,8 @@
// replica of their battle; 返回战场 goes back;
// * 联防 / 最终攻势: the ‹ › pill switches the camera LEFT half / 全景 / RIGHT half of the own field; the other pair's
// boss field is never shown to a fighting player;
// * eliminated: anything.
// * eliminated: anything — each phase reset starts on the player it follows (the server's prep scout / b.start,
// followedScout).
import { PHASE } from '../../../shared/constants.js';
import { data } from '../data.js';
@@ -82,6 +83,23 @@ export function resumedWatch(b, { pub = null, myId = '', alive = true, watching
return { seen: b.battleId, fieldId: b.fieldId };
}
/**
* The prep board the server pushed to a viewer that follows a player (an eliminated player or a spectator seat:
* Match._followScout — the player it last watched, else the first player still in; community report of 2026-10-06,
* item 56, the idea of PR #189), to adopt as the watched board like a 前往查看 tap: `field` is such a scout (m.field
* `prep`, an `n:<pid>` id other than the own) and the screen watches nothing (`watching` null — a 返回战场 this phase is
* not overridden: the caller adopts a board once per phase). Null for a living player: its phase resets keep its own
* board.
* @param {{ field?: any, watching?: string|null, alive?: boolean, spectator?: boolean, myId?: string }} o
* @returns {string|null} the fieldId to watch
*/
export function followedScout({ field = null, watching = null, alive = true, spectator = false, myId = '' } = {}) {
if (watching || (alive && !spectator) || !isObj(field) || !field.prep) return null;
const fid = field.fieldId;
if (typeof fid !== 'string' || !fid.startsWith('n:') || fid === `n:${myId}`) return null;
return fid;
}
/** Teammates' progress for the waiting pill: [{ playerId, name, killed, total, done, isBot }]. */
export function teammateProgress(pub, myId) {
const out = [];
+38 -18
View File
@@ -106,7 +106,7 @@ import { ResultScreen } from './result.js';
import { net } from '../net.js';
import { store, useStore, shallowEqual, serverNow, isSpectating } from '../store.js';
import { battleRunner } from '../battle/runner.js';
import { isClientCombat, observeTarget, teammateProgress, cameraLayers, layerCamera, sidesOf, resumedWatch } from '../battle/observe.js';
import { isClientCombat, observeTarget, teammateProgress, cameraLayers, layerCamera, sidesOf, resumedWatch, followedScout } from '../battle/observe.js';
import { screenStrip, playerBonds, playerLayer, detailBondOwner, toggleBond, popupView } from '../ui/watchBonds.js';
import { data, getMode } from '../data.js';
import { audio, resultSpeaker, resultVoiceSlot } from '../audio.js';
@@ -251,7 +251,7 @@ function MatchScreen() {
getChess: (id) => { const c = gd.chess(id); return ownDiyRecord(c, priv, { chess: data.get('chess'), backups: data.get('backups') }) || c; },
getToken: gd.token, getItem: gd.item, getEffect: gd.effect, backups: gd.backups,
}), [priv, pub?.stageId, editable, gd.ready, deployField]);
live.current = { pub, priv, field, editable, placeCtx, watching, watchWho, home, myId, detail, drawer, bondOpen, emoteOpen, settingsOpen, exitOpen, drag, facing, sel, selBusy, pen, collapsedNow: collapsed, localDone: false, canPause: false, paused };
live.current = { pub, priv, field, editable, placeCtx, watching, watchWho, home, myId, alive, spectator, detail, drawer, bondOpen, emoteOpen, settingsOpen, exitOpen, drag, facing, sel, selBusy, pen, collapsedNow: collapsed, localDone: false, canPause: false, paused };
// ---- camera: every request goes through setCam, which remembers it for the pen's way back -----------------------
// the own prep board: the normal board, or — in the prep of a boss round — the player's half of the boss field
@@ -582,8 +582,15 @@ function MatchScreen() {
else if (phase === PHASE.FINAL_ASSAULT) audio.sfx(solo ? 'bossRoundSingle' : 'bossRoundTeam');
else if (phase === PHASE.HIDDEN_CORE) audio.sfx('bossRoundSecret');
else if (phase === PHASE.SP_DRAFT) audio.sfx('draft');
setWatching(null); // the server resets every watcher to its own field on phase changes
setWatchWho(null);
// the server resets every watcher to its own field on phase changes — but an eliminated player / spectator seat
// keeps the prep board it follows from 回合开始 through 机变 and 休整期 (Match._followScout re-points it only at the
// round start): resetting here would fly the camera home and back at every prep phase (item 56)
const keepScout = (phase === PHASE.SP_DRAFT || phase === PHASE.PREP) && (!alive || spectator)
&& typeof live.current.watching === 'string' && live.current.watching.startsWith('n:');
if (!keepScout) {
setWatching(null);
setWatchWho(null);
}
// the pen is a 休整期 view: leaving prep returns the camera (the next setCam would, too)
if (phase !== PHASE.PREP && penRef.current.on) togglePenRef.current(false);
// a battle unit's panel (live HP of a unit of the fight that just ended) never outlives its battle
@@ -699,7 +706,7 @@ function MatchScreen() {
const who = playerId ? { fieldId: fid, playerId } : null;
setWatching(fid);
setWatchWho(who);
const ok = await actions.watch(fid);
const ok = await actions.watch(fid, playerId);
if (!ok) {
setWatching((w) => (w === fid ? prev : w));
setWatchWho((w) => (w === who ? prevWho : w));
@@ -713,8 +720,11 @@ function MatchScreen() {
if (L.watching && L.watching !== L.home) {
const target = isCombatPhase(L.pub?.phase) ? L.home : ownFieldId(L.myId);
// client-side combat without an own field to go back to (a 联防 leaker, an eliminated player): the screen keeps
// the field it shows — g.watch of a field that does not exist would only be refused (an error toast)
const exists = !isClientCombat(L.pub) || !isCombatPhase(L.pub?.phase) || (Array.isArray(L.pub?.fields) && L.pub.fields.some((f) => f && f.fieldId === target));
// the field it shows — g.watch of a field that does not exist would only be refused (an error toast); an
// eliminated player / spectator seat has no prep board of its own either
const combatNow = isCombatPhase(L.pub?.phase);
const exists = combatNow ? (!isClientCombat(L.pub) || (Array.isArray(L.pub?.fields) && L.pub.fields.some((f) => f && f.fieldId === target)))
: (L.alive && !L.spectator);
if (exists) actions.watch(target);
}
setWatching(null);
@@ -733,7 +743,9 @@ function MatchScreen() {
}
const self = p.playerId === L.myId;
if (self) {
if (L.watching && L.watching !== L.home) actions.watch(isCombatPhase(L.pub?.phase) ? L.home : ownFieldId(L.myId));
// (an eliminated player / spectator seat has no prep board of its own to ask for: item 56)
const combatNow = isCombatPhase(L.pub?.phase);
if (L.watching && L.watching !== L.home && (combatNow || (L.alive && !L.spectator))) actions.watch(combatNow ? L.home : ownFieldId(L.myId));
setWatching(null);
setWatchWho(null);
return;
@@ -745,17 +757,25 @@ function MatchScreen() {
const watchField = useCallback((fid) => { requestWatch(fid); }, []);
// a spectator seat has no board of its own: in 休整期 / 机变 / round start it is shown the first player still in (as a
// tap on that row would — g.watch 'n:<pid>', the read-only board), once per phase; a row switches to another player
const scoutedRef = useRef(null);
// an eliminated player or a spectator seat follows a player through every phase reset (community report of
// 2026-10-06, item 56; the idea of PR #189): the server pushes that player's prep board (Match._followScout) — the one
// it last watched, else the first player still in — and the screen adopts it like a 前往查看 tap, once per phase and
// board (a 返回战场 this phase is not overridden; a row switches to another player, who is followed from then on).
// Until 0.2.0 every round's reset dropped an eliminated player onto its own empty board (a spectator seat asked for
// the first player itself).
const followRef = useRef(null);
useEffect(() => {
if (!spectator || watching || !pub || scoutedRef.current === phaseKey) return;
if (phase !== PHASE.PREP && phase !== PHASE.SP_DRAFT && phase !== PHASE.ROUND_START) return;
const first = players.find((p) => p.alive !== false && p.status !== 'left');
if (!first) return;
scoutedRef.current = phaseKey;
requestWatch(ownFieldId(first.playerId), first.playerId);
}, [spectator, phaseKey, watching]);
const fid = followedScout({ field, watching, alive, spectator, myId });
if (!fid || !pub) return;
const key = `${phaseKey}:${fid}`;
if (followRef.current === key) return;
followRef.current = key;
// the phase reset's render (watching null) ran the own-prep branch with this scout meta in `field` and marked it
// stale: un-mark it, or the enter effect refuses it and the board stays blank until the player moves (PR #189)
if (staleFieldRef.current === field) staleFieldRef.current = null;
setWatching(fid);
setWatchWho({ fieldId: fid, playerId: fid.slice(2) });
}, [field, pub, watching, alive, spectator, phaseKey, myId]);
// ---- view events (drag & drop, clicks) ----------------------------------------------------------------------
useEffect(() => {
+2 -1
View File
@@ -67,7 +67,8 @@ export const actions = {
choice: (idx) => act('g.choice', { idx }),
ready: (ready) => act('g.ready', { ready }, { sfx: ready ? 'ready' : 'back' }),
emote: (id) => act('g.emote', { id }, { quiet: true }),
watch: (fieldId) => act('g.watch', { fieldId }, { sfx: 'tab' }),
// `playerId`: the player tapped in the team panel (a shared field shows two) — what an eliminated viewer follows
watch: (fieldId, playerId = null) => act('g.watch', typeof playerId === 'string' && playerId ? { fieldId, playerId } : { fieldId }, { sfx: 'tab' }),
autoplay: (on) => act('g.autoplay', { on }),
// solo battles only (ui/matchStatus.js pauseAvailable): m.public.paused follows
pause: (on) => act('g.pause', { on: !!on }, { sfx: on ? 'click' : 'confirm' }),