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
@@ -0,0 +1,156 @@
|
||||
// Community report of 2026-10-06, item 56: 「死亡观战时每一回合结束都会被转回自己家」. An eliminated human (and a spectator
|
||||
// seat) now follows a player through every phase reset — the official keeps observing after death (research 09 §3.1:
|
||||
// self_dead_dialog KEEP_WATCH, DeadAutoObDn { obIndex, state, preparation }, FindFirstAvailObTarget): the player it last
|
||||
// watched with a manual g.watch (Match.watchPref), else the first player still in (seat order, AI seats included —
|
||||
// they are players of the remake). Round start: that player's prep board (m.field prep); 各自行动 (both combat modes),
|
||||
// 最终攻势 / 隐秘核心: that player's field; a resync / a spectator joining in prep: the board again. The idea and most
|
||||
// cases come from PR #189 by @2321Robin (watch preference), not merged; the shared-field rule of its review is solved
|
||||
// with g.watch's optional `playerId` (the tapped player). The client side: battle/observe.js followedScout.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { PHASE } from '../../shared/constants.js';
|
||||
import { validateC2S } from '../../shared/protocol.js';
|
||||
import { makeMatch } from './harness.js';
|
||||
import { followedScout } from '../../public/js/battle/observe.js';
|
||||
|
||||
const S = 's_spec';
|
||||
|
||||
test('round start: an eliminated viewer scouts the board it last watched; a spectator without a pick the first player still in', () => {
|
||||
const h = makeMatch({ mode: 'coop', humans: 2, seed: 1001, fake: true, spectators: [S] }).start();
|
||||
h.toPrep(1);
|
||||
const m = h.m;
|
||||
assert.deepEqual(m.handle('p_0', { t: 'g.watch', fieldId: 'n:p_1' }), { ok: true });
|
||||
assert.equal(m.watchPref.get('p_0'), 'p_1', 'the manual scout is the preference');
|
||||
h.ps('p_0').eliminate(1);
|
||||
h.toPrep(2);
|
||||
assert.equal(m.watchers.get('p_0'), 'n:p_1', 'the eliminated viewer scouts its preference again');
|
||||
const meta = h.lastTo('p_0', 'm.field');
|
||||
assert.deepEqual([meta?.fieldId, meta?.prep], ['n:p_1', true], 'the prep board itself was pushed');
|
||||
assert.equal(m.watchPref.get('p_0'), 'p_1', 'the automatic assignment leaves the preference alone');
|
||||
assert.equal(m.watchers.get(S), 'n:p_1', 'the spectator (no pick) follows the first player still in: p_1 (p_0 is out)');
|
||||
assert.equal(h.lastTo(S, 'm.field')?.fieldId, 'n:p_1');
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('the item: one human with AI teammates — eliminated, it follows an AI seat\'s board every round, never its own empty one', () => {
|
||||
const h = makeMatch({ mode: 'coop', humans: 1, bots: 3, seed: 1010, fake: true }).start();
|
||||
h.toPrep(1);
|
||||
const m = h.m;
|
||||
h.ps('p_0').eliminate(1);
|
||||
h.toPrep(2);
|
||||
assert.equal(m.watchers.get('p_0'), 'n:ai_0', 'the first player still in');
|
||||
assert.equal(h.lastTo('p_0', 'm.field')?.fieldId, 'n:ai_0');
|
||||
// a row tapped: that AI seat from now on, in combat and at the next round start
|
||||
assert.deepEqual(m.handle('p_0', { t: 'g.watch', fieldId: 'n:ai_2' }), { ok: true });
|
||||
h.runToPhase(PHASE.COMBAT, 2);
|
||||
assert.equal(m.watchers.get('p_0'), 'n:ai_2', 'its battle (server-run combat)');
|
||||
h.toPrep(3);
|
||||
assert.equal(m.watchers.get('p_0'), 'n:ai_2', 'its board again at the round start');
|
||||
assert.equal(h.lastTo('p_0', 'm.field')?.fieldId, 'n:ai_2');
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('a dead preference falls back to the first player still in; a followed player who quits mid-prep hands over at once', () => {
|
||||
const h = makeMatch({ mode: 'coop', humans: 4, seed: 1002, fake: true }).start();
|
||||
h.toPrep(1);
|
||||
const m = h.m;
|
||||
assert.deepEqual(m.handle('p_0', { t: 'g.watch', fieldId: 'n:p_3' }), { ok: true });
|
||||
h.ps('p_0').eliminate(1);
|
||||
h.ps('p_3').eliminate(1);
|
||||
h.toPrep(2);
|
||||
assert.equal(m.watchers.get('p_0'), 'n:p_1', 'p_3 is gone → the first player still in (p_1)');
|
||||
// p_1 quits during the prep: whoever scouted it follows the next player still in at once
|
||||
m.onLeave('p_1');
|
||||
assert.equal(m.watchers.get('p_0'), 'n:p_2');
|
||||
assert.deepEqual([h.lastTo('p_0', 'm.field')?.fieldId, h.lastTo('p_0', 'm.field')?.prep], ['n:p_2', true]);
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('server-run combat: the default watch starts an eliminated viewer on its preference; a mid-phase switch re-records it', () => {
|
||||
const h = makeMatch({ mode: 'coop', humans: 3, seed: 1003, fake: true }).start();
|
||||
h.toPrep(1);
|
||||
const m = h.m;
|
||||
assert.deepEqual(m.handle('p_0', { t: 'g.watch', fieldId: 'n:p_2' }), { ok: true });
|
||||
h.ps('p_0').eliminate(1);
|
||||
h.runToPhase(PHASE.COMBAT, 1);
|
||||
assert.equal(m.watchers.get('p_0'), 'n:p_2');
|
||||
assert.equal(m.watchers.get('p_1'), 'n:p_1', 'a fighting player stays on its own field');
|
||||
assert.equal(m.watchPref.get('p_0'), 'p_2', 'the combat assignment does not touch the preference');
|
||||
assert.deepEqual(m.handle('p_0', { t: 'g.watch', fieldId: 'n:p_1' }), { ok: true });
|
||||
assert.equal(m.watchers.get('p_0'), 'n:p_1');
|
||||
assert.equal(m.watchPref.get('p_0'), 'p_1', 'the manual switch is the new preference');
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('client combat: an eliminated human is started on the field of the player it last watched; a reconnect puts it back there', () => {
|
||||
const h = makeMatch({ mode: 'coop', humans: 3, seed: 1004, fake: true, clientCombat: true }).start();
|
||||
h.toPrep(1);
|
||||
const m = h.m;
|
||||
assert.deepEqual(m.handle('p_0', { t: 'g.watch', fieldId: 'n:p_2' }), { ok: true });
|
||||
h.ps('p_0').eliminate(1);
|
||||
h.runToPhase(PHASE.COMBAT, 1);
|
||||
let start = h.lastTo('p_0', 'b.start');
|
||||
assert.deepEqual([start?.watch, start?.fieldId], [true, 'n:p_2'], 'the preference\'s field, not the first seat\'s');
|
||||
m.onDisconnect('p_0');
|
||||
m.onReconnect('p_0');
|
||||
start = h.lastTo('p_0', 'b.start');
|
||||
assert.deepEqual([start?.watch, start?.fieldId], [true, 'n:p_2']);
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('Final Assault: an eliminated viewer is started on its preference\'s boss field; a shared field records the tapped player only', () => {
|
||||
const h = makeMatch({ mode: 'coop', difficulty: 'FUNNY', humans: 4, seed: 54, fake: true, instant: false, script: (b) => (b.kind === 'boss' ? { bossDps: 1 } : {}) }).start();
|
||||
const m = h.m;
|
||||
h.drive(() => m.phase === PHASE.PREP && m.round === 14);
|
||||
h.ps('p_3').eliminate(13);
|
||||
assert.deepEqual(m.handle('p_3', { t: 'g.watch', fieldId: 'n:p_2' }), { ok: true }, 'records the preference in prep');
|
||||
h.drive(() => m.phase === PHASE.FINAL_ASSAULT);
|
||||
assert.deepEqual(m.fields.map((f) => [f.fieldId, f.players]), [['b1', ['p_0', 'p_1']], ['b2', ['p_2']]]);
|
||||
assert.equal(m.watchers.get('p_3'), 'b2', 'follows p_2 into b2 without a tap');
|
||||
assert.equal(m.watchers.get('p_0'), 'b1', 'a fighting player stays on its own boss field');
|
||||
// PR #189's review: a shared field's id does not say who was tapped — without `playerId` nothing is recorded
|
||||
assert.deepEqual(m.handle('p_3', { t: 'g.watch', fieldId: 'b1' }), { ok: true });
|
||||
assert.equal(m.watchPref.get('p_3'), 'p_2');
|
||||
// with it (the client sends the row tapped), that player is the preference; a player not on the field is not
|
||||
assert.deepEqual(m.handle('p_3', { t: 'g.watch', fieldId: 'b1', playerId: 'p_1' }), { ok: true });
|
||||
assert.equal(m.watchPref.get('p_3'), 'p_1');
|
||||
assert.deepEqual(m.handle('p_3', { t: 'g.watch', fieldId: 'b1', playerId: 'p_2' }), { ok: true });
|
||||
assert.equal(m.watchPref.get('p_3'), 'p_1', 'p_2 is not on b1');
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('a resync in prep sends the followed board: a spectator joining, an eliminated human reconnecting (client combat)', () => {
|
||||
const h = makeMatch({ mode: 'coop', humans: 2, seed: 1006, fake: true, clientCombat: true }).start();
|
||||
h.toPrep(1);
|
||||
const m = h.m;
|
||||
m.addSpectator(S);
|
||||
let meta = h.lastTo(S, 'm.field');
|
||||
assert.deepEqual([meta?.fieldId, meta?.prep], ['n:p_0', true], 'no pick → the first player still in');
|
||||
assert.equal(m.watchers.get(S), 'n:p_0', 'registered as a scout: later board changes push again');
|
||||
assert.deepEqual(m.handle('p_0', { t: 'g.watch', fieldId: 'n:p_1' }), { ok: true });
|
||||
h.ps('p_0').eliminate(1);
|
||||
m.onDisconnect('p_0');
|
||||
const n = h.allTo('p_0', 'm.field').length;
|
||||
m.onReconnect('p_0');
|
||||
assert.ok(h.allTo('p_0', 'm.field').length > n, 'the reconnect pushed a board');
|
||||
meta = h.lastTo('p_0', 'm.field');
|
||||
assert.deepEqual([meta?.fieldId, meta?.prep], ['n:p_1', true], 'the preference survived the disconnect');
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('g.watch\'s optional playerId passes the protocol check; junk does not', () => {
|
||||
assert.equal(validateC2S({ t: 'g.watch', fieldId: 'b1' }), null);
|
||||
assert.equal(validateC2S({ t: 'g.watch', fieldId: 'b1', playerId: 'p_1' }), null);
|
||||
assert.notEqual(validateC2S({ t: 'g.watch', fieldId: 'b1', playerId: 7 }), null);
|
||||
});
|
||||
|
||||
test('client: followedScout adopts the server\'s prep board for an eliminated player / spectator seat only, from home', () => {
|
||||
const scout = { t: 'm.field', fieldId: 'n:p_1', prep: true };
|
||||
assert.equal(followedScout({ field: scout, watching: null, alive: false, myId: 'p_0' }), 'n:p_1');
|
||||
assert.equal(followedScout({ field: scout, watching: null, alive: false, spectator: true, myId: 's_x' }), 'n:p_1');
|
||||
assert.equal(followedScout({ field: scout, watching: null, alive: true, myId: 'p_0' }), null, 'a living player keeps its own board');
|
||||
assert.equal(followedScout({ field: scout, watching: 'n:p_2', alive: false, myId: 'p_0' }), null, 'already watching');
|
||||
assert.equal(followedScout({ field: { ...scout, prep: false }, alive: false, myId: 'p_0' }), null, 'a battle field');
|
||||
assert.equal(followedScout({ field: { ...scout, fieldId: 'n:p_0' }, alive: false, myId: 'p_0' }), null, 'its own board');
|
||||
assert.equal(followedScout({ field: null, alive: false, myId: 'p_0' }), null);
|
||||
});
|
||||
@@ -1,7 +1,8 @@
|
||||
// Spectator seats, the match side (community report #26, owner's decision 2026-10-04 — a remake feature: the official
|
||||
// room has no spectator seat). A spectator (opts.spectators / addSpectator, server/lobby.js spectate) is shown fields like
|
||||
// an ELIMINATED player in every phase — the first field of each battle (b.start watch), any field on g.watch, the 联防
|
||||
// spec, the first boss field, a prep board on g.watch 'n:<pid>' — and the settlement; it never receives an m.private
|
||||
// an ELIMINATED player in every phase — the field of the player it follows in each battle (b.start watch: the one it last
|
||||
// watched, else the first), any field on g.watch, the 联防 spec, that player's boss field, a prep board on g.watch
|
||||
// 'n:<pid>' or of the player it follows — and the settlement; it never receives an m.private
|
||||
// (shop, hand, funds …), an m.toast or m.unitStats, is never a field's player or authority, and every intent but g.watch
|
||||
// is refused (SPECTATOR). The platform half (seats, cap, host removal, reconnect): test/lobby.test.js; over sockets with
|
||||
// the real Match: test/match/lobby-integration.test.js.
|
||||
@@ -65,8 +66,9 @@ test('a spectator seat watches a whole match (各自行动, 联防, 最终攻势
|
||||
}
|
||||
const kinds = new Set(starts.map((x) => x.kind));
|
||||
for (const k of ['normal', 'unite', 'boss']) assert.ok(kinds.has(k), `a ${k} field was shown (${[...kinds]})`);
|
||||
// what it was shown in 各自行动 is what an eliminated player is shown: the first field of the phase
|
||||
for (const st of starts.filter((x) => x.kind === 'normal')) assert.equal(st.fieldId, 'n:p_0');
|
||||
// what it was shown in 各自行动 is what an eliminated player is shown: the field of the player it follows — p_1, the one
|
||||
// it scouted itself in prep (a manual watch is the preference: item 56 of 2026-10-06); with no pick the first field
|
||||
for (const st of starts.filter((x) => x.kind === 'normal')) assert.equal(st.fieldId, 'n:p_1');
|
||||
// the Final Assault's end reaches the boss field it watched, like every human shown that field
|
||||
const ends = h.allTo(S, 'b.end');
|
||||
assert.ok(ends.some((e) => e.fieldId === starts.filter((x) => x.kind === 'boss').pop().fieldId), 'b.end of the watched boss field');
|
||||
|
||||
Reference in new issue
Block a user