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
+2
-2
@@ -127,8 +127,8 @@
|
||||
|
||||
- 左下角「交流」打开表情面板:6 套主题 × 6 个表情,左右滑动(或方向键)换主题。表情以气泡形式显示在你头像旁 3 秒,发送冷却 1 秒。
|
||||
- 休整期、或自己的作战结束后(显示「作战结束,等待队友完成作战」),点左侧队友头像 →「前往查看」观看他的战场;自己的作战进行中不能观战。联防和最终攻势中可以用 ‹ › 切换左半 / 全景 / 右半。
|
||||
- 被淘汰后可以自由观看任何战场。
|
||||
- **观战席**(本作新增,原版没有):每个同盟房间另有 2 个观战席。在大厅输入同盟密钥后点「观战」进入(模拟进行中也可以),不占博士席位、不算参战人数,也不能购买、部署、准备或发送表情;观看方式与被淘汰的玩家相同——作战时自动观看第一个战场,休整期自动显示第一位博士的阵地,点左侧成员头像可切换查看任何人。断线重连会回到观战席;创建者可以在等待室用观战者名字旁的 ✕ 将其移出;观战者在等待室有空位时可以点「入座」成为博士。
|
||||
- 被淘汰后可以自由观看任何战场,而且**不用每回合重选**:你上次点「前往查看」的是谁,之后每个阶段(回合开始、机变、休整期、作战、最终攻势)都会自动跟到他的阵地或战场;没选过或他也被淘汰时,跟随座位顺序第一位还在场的博士(包括 AI 队友)。阶段中途换人立即生效,之后就跟随新选的人。
|
||||
- **观战席**(本作新增,原版没有):每个同盟房间另有 2 个观战席。在大厅输入同盟密钥后点「观战」进入(模拟进行中也可以),不占博士席位、不算参战人数,也不能购买、部署、准备或发送表情;观看方式与被淘汰的玩家相同——自动跟随你上次查看的博士(没查看过则是座位顺序第一位还在场的博士):休整期显示他的阵地,作战时观看他的战场,点左侧成员头像可切换查看任何人。断线重连会回到观战席;创建者可以在等待室用观战者名字旁的 ✕ 将其移出;观战者在等待室有空位时可以点「入座」成为博士。
|
||||
- **观看谁,盟约栏就显示谁的盟约**:查看队友的阵地或战场时(休整期、作战结束后、被淘汰后自动观战),左上的盟约栏换成**该队友的盟约、人数和层数**,前面有琥珀色的「👁 队友名」标签,点开的盟约详情也写着「队友名 的盟约」(成员一栏只标出他在场上的干员,整备区看不到);作战中层数实时增长(与他自己看到的一致)。联防和最终攻势里跟随 ‹ ›:镜头在队友的半场时显示他的盟约,在你自己的半场时显示你自己的;「全景」时,你在这个战场上作战就显示你自己的,你不在这个战场上(漏怪后观看联防、被淘汰后观战)则显示你点「前往查看」选中的那名队友,没选过就显示该战场的第一名玩家(1 号联防者 / 这一组的第一位),**不会显示你自己的**。在这样的战场上点某名队友的头像 →「前往查看」,镜头会直接移到他的半场,‹ › 上的「👁 队友名」与盟约栏的标签一致;‹ › 旁边有「返回战场」(被淘汰时没有)。盟约栏换人时,从盟约栏打开的盟约详情会自动关闭。点开场上某个干员的详情卡时,卡上的盟约(以及点它打开的盟约详情)属于**这个干员的主人**,与盟约栏当前显示谁无关——例如在「全景」点队友的干员,看到的是「队友名 的盟约」及他的人数和层数;在队友的半场点你自己的干员,看到的是你自己的。回到自己的阵地 / 战场后恢复成你自己的盟约。作战结束后观看队友时刷新页面 / 断线重连,仍会回到正在观看的队友战场,「👁 队友名」和「返回战场」都还在。已被淘汰的队友不再显示盟约(也无法被查看)。
|
||||
|
||||
## 9. 结算称号
|
||||
|
||||
@@ -15,7 +15,7 @@ Platform additions (implemented): `welcome.{resumed, version}`, `welcome.diyKitt
|
||||
**Spectator seats** (a remake feature — the official room has none; community report #26, owner's decision 2026-10-04; the spectator-seat subsection of §23): `room.spectate {code}` takes one of a co-op room's `MAX_SPECTATORS` (2) spectator seats, in its lobby or during its match; `room.removeSpectator {playerId}` (host) frees one (`room.closed {reason:'kicked'}` to it); `room.state.spectators: [{playerId, name, connected}]`. A spectator is never a player (not in `seats`, not counted for the start, never host, keeps no room alive — `room.closed {empty}` when the last human leaves), receives the room's broadcasts and watches like an eliminated player (`Match.addSpectator`: `b.start` watch / `m.field`, never `m.private`); it may only `g.watch`, leave, and store a `room.loadout` — anything else answers `error SPECTATOR`. Its seat is kept and given back on reconnect like a player's.
|
||||
|
||||
### 8.2 Match
|
||||
C→S (all carry `rid`): `g.infoReady` · `g.band {bandId}` · `g.bandSkip` · `g.bandFocus {bandId?}` (the strategy highlighted in the draft screen — what a timed-out turn takes; absent / null clears it; `WRONG_PHASE` outside BAND_DRAFT, `ALREADY` after the pick, `BAD_TARGET` for a band not allowed; §18.2) · `g.buy {slot}` · `g.refresh` · `g.freeze` · `g.levelUp` · `g.sell {uid}` · `g.move {uid, to:{area:'board', row, col}|{area:'hand', idx}, dir?}` · `g.equip {itemUid, targetUid, replaceUid?}` (`replaceUid`: which equipped item a third one replaces, §6.2) · `g.art {itemUid, row, col, dir?}` (`dir` ∈ UP|RIGHT|DOWN|LEFT from the deploy wheel, §3; absent ⇒ `to.dir`, then RIGHT; `g.move` onto the piece's own tile re-orients it; board pieces in `m.private` / `m.field {prep:true}` carry `dir`) · `g.destroy {uid}` (hand / temp items only; equipped items are locked, §6.2) · `g.reward {idx}` · `g.choice {idx}` · `g.ready {ready}` · `g.emote {id}` · `g.watch {fieldId}` · `g.autoplay {on}` · `g.pause {on}` (solo battles only, §14 Solo pause) · `g.unitStats {seq?}` (ROUND_START / SP_DRAFT / PREP, else `WRONG_PHASE`; eliminated: `ELIMINATED`) → push `m.unitStats` (§18.5) · `g.leave`
|
||||
C→S (all carry `rid`): `g.infoReady` · `g.band {bandId}` · `g.bandSkip` · `g.bandFocus {bandId?}` (the strategy highlighted in the draft screen — what a timed-out turn takes; absent / null clears it; `WRONG_PHASE` outside BAND_DRAFT, `ALREADY` after the pick, `BAD_TARGET` for a band not allowed; §18.2) · `g.buy {slot}` · `g.refresh` · `g.freeze` · `g.levelUp` · `g.sell {uid}` · `g.move {uid, to:{area:'board', row, col}|{area:'hand', idx}, dir?}` · `g.equip {itemUid, targetUid, replaceUid?}` (`replaceUid`: which equipped item a third one replaces, §6.2) · `g.art {itemUid, row, col, dir?}` (`dir` ∈ UP|RIGHT|DOWN|LEFT from the deploy wheel, §3; absent ⇒ `to.dir`, then RIGHT; `g.move` onto the piece's own tile re-orients it; board pieces in `m.private` / `m.field {prep:true}` carry `dir`) · `g.destroy {uid}` (hand / temp items only; equipped items are locked, §6.2) · `g.reward {idx}` · `g.choice {idx}` · `g.ready {ready}` · `g.emote {id}` · `g.watch {fieldId, playerId?}` (`playerId`: the player tapped — a shared 联防 / boss pair field shows two; what an eliminated viewer follows, §25) · `g.autoplay {on}` · `g.pause {on}` (solo battles only, §14 Solo pause) · `g.unitStats {seq?}` (ROUND_START / SP_DRAFT / PREP, else `WRONG_PHASE`; eliminated: `ELIMINATED`) → push `m.unitStats` (§18.5) · `g.leave`
|
||||
S→C:
|
||||
- `m.public` — full public state on every change (throttled ≤ 10/s): `{ phase, round, lastRound, deadline, serverNow, modeId, difficulty, stageId, factions, disabledBonds, bannedChess, bossId, hiddenBossId?, teamLp?, bossHp?:{hp,max}, overtimeAt? /* FA / HC: ms epoch the overtime drain starts, §4 */, paused /* solo pause, §14 */, draft?:{order,turn,picks,skipsLeft,turnDeadline,turnSeconds /* one turn's length, 0 untimed; deadline = turnDeadline (§18.2) */,untimed}, sp?:{family,cards /* bounty cards carry descRaw?, §20.6 */,turn,picks}, players:[{playerId, seat, name, isBot, connected, alive, lp, bandId, shopLevel, boardCount, ready, bonds:[{bondId,count,active,tier,layers,harmony? /* 调和's +1 is in count, §21.26 */}], fieldId, status, pendingLp? /* COMBAT / 联防 of a normal round: the LP the player's own battle will cost at settlement so far, min(lpCapPerRound, counted leaks) — in 联防 min(lpCapPerRound, uniteLeft) — omitted when 0 and in every other phase (§17.5 live LP) */, uniteLeft? /* 联防: the leaker's enemies still standing — not spawned yet + alive + counted leaks, uncapped, live (§20.6) */}] , fields:[{fieldId, kind, players:[playerId...]}] }`
|
||||
- `m.private` — the recipient's full PlayerState view (§6.2 minus server-only data) on every change.
|
||||
@@ -75,7 +75,7 @@ Piece = { uid, kind: 'chess'|'item'|'token', id, golden: bool, tier, items: [ {
|
||||
|
||||
**Protocol (additions; `shared/protocol.js` is normative).**
|
||||
- `battleId` = `<prefix>.<round>.<seq>.<fieldId>` (`Match._ccField`; `<prefix>` = the match seed in base 36 plus `-<matchNo>`, the room's match number the lobby passes as `opts.matchNo`; `<seq>` counts every battle of the match; the informational `.<fieldId>` is dropped when the id would exceed the protocol's 64 chars): unique for the whole match although field ids repeat (`n:<pid>` every round, `u`, `b1`/`b2` in both boss rounds), and across the matches of one room. The client runner keys its local battles by it (the same id again = the same battle, no rebuild) and the server matches reports by it, so a late `b.progress` / `b.result` of a finished battle — or one that crossed into the room's next match on the same socket — is ignored instead of being credited to another battle.
|
||||
- S→C `b.start { battleId, fieldId, kind, spec, authoritative, startAt, serverNow, elapsed, speed, watch, done }` — sent at COMBAT/UNITE/FINAL_ASSAULT/HIDDEN start to every human in the field (联防: to every human; COMBAT and boss rounds: eliminated humans get the first field as a display replica — research 09 "keep-watching auto-observes the first available field"), on `g.watch`, and on reconnect/resync. `elapsed` = game seconds on the field clock (a finished field: its end time); the client fast-forwards to it. `speed` = game seconds per real second (2; tests/tools may speed up).
|
||||
- S→C `b.start { battleId, fieldId, kind, spec, authoritative, startAt, serverNow, elapsed, speed, watch, done }` — sent at COMBAT/UNITE/FINAL_ASSAULT/HIDDEN start to every human in the field (联防: to every human; COMBAT and boss rounds: eliminated humans and spectator seats get the field of the player they follow as a display replica — the one they last watched with `g.watch`, else the first player still in; research 09 "keep-watching auto-observes the first available field", 0.2.0 item 56), on `g.watch`, and on reconnect/resync. `elapsed` = game seconds on the field clock (a finished field: its end time); the client fast-forwards to it. `speed` = game seconds per real second (2; tests/tools may speed up).
|
||||
- C→S `b.progress { battleId, gt, killed, total, leaks?, left?, bossDmg?, by?, done? }` — authoritative clients, ~1 Hz (boss fields 4 Hz). Normal/联防: `leaks` = counted leaks so far; 联防: `left` = per leaker its enemies still standing (`spec.js uniteLeft`, clamped by the server to `fields.js uniteBillBounds`; §20.6). Boss: `bossDmg` = cumulative damage of this field to the shared pool, `by` = the same per player, `leaks` = the field's cumulative LP cost (leaks × `lpr` + leader "扣除目标生命" effects; `spec.js attachLpMeter`). The server applies deltas (never twice). Stale reports (unknown battle, not the authority, after the match ended) are ignored, never answered with an error; the platform never answers a rid-less `b.progress` with an error frame (it would surface as a toast).
|
||||
- C→S `b.result { battleId, result }` — authoritative client at battle end; `result` = `compactResult(BattleResult)` (the fields the match consumes; ≤ 4 players, ≤ 400 leaks, ≤ 64 unitsEnd, ≤ 160 unitStats, ≤ 40 layer gains; `fitResult` keeps the frame ≤ 60 KB: unit statistics, then leak mods, then — boss only — leak entries go first). Structural checks in `isBattleResult`; `server/net.js` routes `b.*` frames to the match (lobby `routeGame`).
|
||||
- S→C `b.pool { hp, max, teamLp, acked: { [fieldId]: cumulative damage counted } }` (boss rounds, ≤ 4 Hz; exact numbers, never rounded) — clients reconcile their `LocalBossPool`: displayed pool = server hp − (local cumulative damage − acked). Pools never hold less than 1 HP (`BOSS_POOL_MIN_HP`: `SharedBossPool` and `LocalBossPool`; a browser pool reads any finite rest below 1 as 0 — §20.5); the 'cleared'-result dust rule (a boss field whose client result is `'cleared'` while < 1 HP is left on the server empties the pool) only catches noise at that boundary.
|
||||
@@ -89,7 +89,7 @@ Piece = { uid, kind: 'chess'|'item'|'token', id, golden: bool, tier, items: [ {
|
||||
|
||||
**Solo pause (official `PauseUp` / `ResumeUp`; `Match.setPause`).** C→S `g.pause { on }`: solo matches only (co-op → `WRONG_PHASE 'co-op battles never pause'`), and `on: true` only while a battle runs (COMBAT / FINAL_ASSAULT / HIDDEN_CORE with a live field, not once the Final Assault is ending — else `WRONG_PHASE 'no battle running'`); `on: false` is always accepted. While `m.public.paused` is true everything of the battle is frozen: the field clock (`b.start elapsed`, the boss plausibility budgets and the overtime drain), the authority deadline and the server-run release timers, the boss clock, the HUD `deadline` / `overtimeAt` (shifted by the paused time on resume) and the server-run pacers (FieldRunner / HeadlessPacer skip their intervals); the browser's runner stops its local clock. In solo battles the top bar shows a 暂停 / 继续作战 button (`ui/hud.js PauseButton`, `ui/matchStatus.js pauseAvailable`; `Space` toggles it), a paused overlay offers 继续作战 / 放弃模拟, and the HUD clocks freeze at the pause moment. A disconnect or leave resumes (the server takes the field over — nobody is left to resume it); the battle phase ending drops the pause.
|
||||
|
||||
**Spectating (research 09 §3.1 / §6.3).** No live spectating while your own normal battle runs (`g.watch` → `WRONG_PHASE 'own battle running'`; the UI toasts "作战中无法查看队友"). After your own battle ends you see "⌛ 作战结束,等待队友完成作战" with each teammate's live progress (`m.public.fields[].progress`); tapping a teammate's avatar expands **前往查看** → `g.watch` → `b.start` (display replica fast-forwarded to the field clock); the own row shows **返回战场**. Prep: the same flow shows the teammate's board (`m.field` with `prep: true`) and sends it again when that board changes (GitHub #87). The scout is framed by the prep camera in its shop-folded form and renders the teammate’s hand (整备区) and 临时整备区 as units on their rows — the own prep bench’s exact look (user playtest #2 item 1, the GitHub #44 suggestion; the spectator seat's scout view is the same, §23.19's never-a-private-view staying the no-`m.private` rule). While scouting, the 本局信息 drawer shows the watched player's 策略 in place of one's own (`game.js scoutPid` → `enemyDrawer.js InfoTab bandId / bandOwner`, labelled "X 的策略"; user playtest #2 item 2), and the right effects column follows the watched field too: the scout meta carries the player's effects view, a display-replica battle derives it from the spec's `playerEffects` (`battle/observe.js spectateEffects`) — undefined (empty column, never one's own) for 联防 / boss pair fields and server-run watched fields. 联防: every human receives the unite spec (helpers + observers). Final Assault: each human sees its own pair field; the other pair → `BAD_TARGET 'other group hidden'`. In 联防/FA fields the ‹ › pill switches the camera LEFT half / 全景 / RIGHT half ("你自己" / "👁 name" / "全景" / "无人在家"); there is **no** view switcher during normal combat. Eliminated players start each combat phase on the first field (auto-observe) and may watch any field.
|
||||
**Spectating (research 09 §3.1 / §6.3).** No live spectating while your own normal battle runs (`g.watch` → `WRONG_PHASE 'own battle running'`; the UI toasts "作战中无法查看队友"). After your own battle ends you see "⌛ 作战结束,等待队友完成作战" with each teammate's live progress (`m.public.fields[].progress`); tapping a teammate's avatar expands **前往查看** → `g.watch` → `b.start` (display replica fast-forwarded to the field clock); the own row shows **返回战场**. Prep: the same flow shows the teammate's board (`m.field` with `prep: true`) and sends it again when that board changes (GitHub #87). The scout is framed by the prep camera in its shop-folded form and renders the teammate’s hand (整备区) and 临时整备区 as units on their rows — the own prep bench’s exact look (user playtest #2 item 1, the GitHub #44 suggestion; the spectator seat's scout view is the same, §23.19's never-a-private-view staying the no-`m.private` rule). While scouting, the 本局信息 drawer shows the watched player's 策略 in place of one's own (`game.js scoutPid` → `enemyDrawer.js InfoTab bandId / bandOwner`, labelled "X 的策略"; user playtest #2 item 2), and the right effects column follows the watched field too: the scout meta carries the player's effects view, a display-replica battle derives it from the spec's `playerEffects` (`battle/observe.js spectateEffects`) — undefined (empty column, never one's own) for 联防 / boss pair fields and server-run watched fields. 联防: every human receives the unite spec (helpers + observers). Final Assault: each human sees its own pair field; the other pair → `BAD_TARGET 'other group hidden'`. In 联防/FA fields the ‹ › pill switches the camera LEFT half / 全景 / RIGHT half ("你自己" / "👁 name" / "全景" / "无人在家"); there is **no** view switcher during normal combat. Eliminated players and spectator seats follow a player through every phase reset (0.2.0, community report item 56; the official DeadAutoObDn { obIndex, state, preparation }; the idea of PR #189): the player they last watched with a manual `g.watch` (`Match.watchPref`; a shared field records the `playerId` tapped, else nobody), else the first player still in (seat order, AI seats included) — the round start pushes that player's prep board (`Match._followScout`, adopted by the screen like a 前往查看 tap: `battle/observe.js followedScout`; kept through 机变 and 休整期), each battle starts on that player's field, a resync / a spectator joining in prep gets the board again; they may watch any field.
|
||||
|
||||
**Client runner.** `public/js/battle/runner.js` owns the local Battles: fixed-step clock at the battle speed (60 ticks per real second at 2×, at most `max(8, 4·speed)` ticks per frame, bounded fast-forward of ≤ 240 ticks per frame when far behind; a new battle is caught up silently before it is shown), feeds the existing renderer through the same `snapshot()`/`drainEvents()` wire formats every frame (`view.setLocalFeed` shrinks the interpolation buffer to ~2 frames), publishes the field meta (m.field shape + `local`, `battleId`, `players`, `sides`, `speed`) into `store.match.field` and its state into `store.match.battle` (with `leaks: { [fieldId]: counted leaks so far }` of every normal field it simulates and, while the 联防 field is on screen, `uniteLeft` per leaker (§20.6), authoritative or display replica — the settle rule's count, `/sim/spec.js battleProgress`; published in the frame a count changes: the live LP of §17.5), sends `b.progress`/`b.result` when authoritative (results retried once on timeout; a result lost with the socket — the request failed `DISCONNECTED` / `OFFLINE` or timed out twice — is kept and sent again when the session is back online and whenever a `b.start` still names that finished battle `authoritative: true`; the server answers a duplicate `ok` and changes nothing; a server refusal is final), follows `m.public.paused` (solo pause: every local clock stands still, resume shifts it by the paused time), handles `b.pool`/`b.end`, fast-forwards on reconnect/observe, and survives tab backgrounding (a 250 ms interval pump keeps authoritative battles on their clock while hidden; the server deadline + takeover cover extreme cases). Catch-up frames keep the state events and every enemy form fx (`keepsState`); while hidden, the battle on screen holds them for the first frame back (`hold`; past `HELD_MAX` the superseded status / skill toggles are dropped, `compactHeld`, and only a backlog still past it re-enters the view from the field meta), and a battle that ended while hidden delivers them when the tab is shown; a catch-up frame or a hidden step drains its events every `EV_SLICE` (15) ticks, each batch stamped with its own game time (`gt`), so a late form fx reaches the render engine as late as it is; the game screen's pre-entry buffer (`keepEarly`, also while a re-sent field meta re-enters the field on screen) and the render engine's event queue (`render/interp.js isCosmeticEvent`) never drop a form fx either (§21.4). Sim content errors are logged as console warnings (the server logs its own). The sim loader fetches the ten data files (one retry each) and fails when any is missing — a battle on partial data would report a plausible wrong result — so the next `b.start` retries and the server's deadline covers the field meanwhile. `public/js/battle/observe.js` holds the observing rules and labels.
|
||||
|
||||
|
||||
@@ -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
@@ -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(() => {
|
||||
|
||||
@@ -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' }),
|
||||
|
||||
@@ -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 });
|
||||
|
||||
+3
-1
@@ -368,7 +368,9 @@ export const C2S = {
|
||||
'g.choice': { idx: (v) => isInt(v, 0, 5) },
|
||||
'g.ready': { ready: isBool },
|
||||
'g.emote': { id: (v) => EMOTES.includes(v) },
|
||||
'g.watch': { fieldId: (v) => isStr(v, 32) },
|
||||
// playerId: the player tapped in the team panel (a 联防 / boss pair field shows two) — what an eliminated viewer or a
|
||||
// spectator seat follows from then on (Match.watchPref; community report of 2026-10-06, item 56)
|
||||
'g.watch': { fieldId: (v) => isStr(v, 32), playerId: isId, $optional: ['playerId'] },
|
||||
'g.autoplay': { on: isBool },
|
||||
// solo pause (official PauseUp / ResumeUp, DESIGN §14): freezes the running battle (field clock, deadlines, the
|
||||
// browser's local runner) — solo matches only (co-op ⇒ WRONG_PHASE), only while a battle runs; m.public.paused
|
||||
|
||||
@@ -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