feedback3: merge W3E — two spectator seats per co-op room (GitHub-relayed community request #26; a remake feature): watch like an eliminated player, never act, no private data

This commit is contained in:
sganggs committed 2026-10-04 17:57:09 +08:00
commit 8707c161b9
28 files changed
+967 -73

No files matched your search

+1 -1
View File
@@ -156,7 +156,7 @@ npm start # 启动服务器:http://localhost:3000
| 方向轮盘键盘操作 | 方向键预览 · `Enter` 确认 · `Esc` 取消 | | 方向轮盘键盘操作 | 方向键预览 · `Enter` 确认 · `Esc` 取消 |
| 暂停(独立模拟) | 作战中(含最终攻势 / 隐秘核心)点顶栏的「暂停」或按 `Space`,再点「继续作战」(或 `Space`)继续;同盟模拟的作战不能暂停 | | 暂停(独立模拟) | 作战中(含最终攻势 / 隐秘核心)点顶栏的「暂停」或按 `Space`,再点「继续作战」(或 `Space`)继续;同盟模拟的作战不能暂停 |
| 表情 | 左下角「交流」,左右滑动(或方向键)换主题,冷却 1 秒 | | 表情 | 左下角「交流」,左右滑动(或方向键)换主题,冷却 1 秒 |
| 观战 | 自己的作战结束后(或休整期)点左侧队友头像 →「前往查看」 | | 观战 | 自己的作战结束后(或休整期)点左侧队友头像 →「前往查看」;不参战的朋友可以在大厅输入同盟密钥点「观战」(每个同盟最多 2 名观战者,本作新增) |
完整的规则、数值和小技巧见 **[docs/PLAYING.md](docs/PLAYING.md)**(游戏内左下角也有「玩法说明」)。 完整的规则、数值和小技巧见 **[docs/PLAYING.md](docs/PLAYING.md)**(游戏内左下角也有「玩法说明」)。
+2
View File
@@ -412,6 +412,8 @@ S→C: `welcome {playerId, token, name, serverNow}` · `room.state {code, hostId
Platform additions (implemented): `welcome.{resumed, version}`, `room.state.inMatch`, `error.detail`; `room.closed.reason ∈ 'timeout'|'kicked'|'shutdown'`; WebSocket close codes 4001 (session taken over by another socket — client must not auto-reconnect), 4002 (no hello in time), 1008 (flooding), 1001 (shutdown). The host's `room.start` counts as the host being ready. After any (re)`hello` the server pushes `room.state` and, if a match runs, the match resends `m.public`/`m.private` (+`m.field`, or the field's `b.start` under client-side combat). **Reconnect window:** a dropped session stays resumable for 10 min (`net.js NET_DEFAULTS.reconnectWindowMs`; co-op seats keep playing their last lineup meanwhile, §6.6); a session that drops while its **solo** room's match runs stays resumable for the official `config.constants.singleReconnectTime` (86 400 s = 24 h; lobby option `soloReconnectWindowMs`; research 01 §1 "24小时内随时返回") — the untimed solo run simply waits. Only when the window expires does the drop become `match.onLeave` (co-op: 中途退出 = elimination; solo: the run ends `'abandoned'`). The client keeps its token in sessionStorage plus a recent-token list in localStorage, so reopening the tab resumes. Everything lives in server memory: a server restart ends every room and run. The lobby⇄match interface (constructor opts, `start/handle/onDisconnect/onReconnect/onLeave/dispose`) is documented verbatim at the top of `server/match/Match.js` and is normative. `server/data.js` provides `getData()` + index getters (`getChess`, `getBond`, `getGarrison`, `getItem`, `getBand`, `getEffect`, `getEnemy`, `getWave`, `getStage`, `getBoss`, `getToken`, `getConfig`, `getMode`). Note: the skill trigger rule lives at `chess.skill.trigger.rule` in data. Platform additions (implemented): `welcome.{resumed, version}`, `room.state.inMatch`, `error.detail`; `room.closed.reason ∈ 'timeout'|'kicked'|'shutdown'`; WebSocket close codes 4001 (session taken over by another socket — client must not auto-reconnect), 4002 (no hello in time), 1008 (flooding), 1001 (shutdown). The host's `room.start` counts as the host being ready. After any (re)`hello` the server pushes `room.state` and, if a match runs, the match resends `m.public`/`m.private` (+`m.field`, or the field's `b.start` under client-side combat). **Reconnect window:** a dropped session stays resumable for 10 min (`net.js NET_DEFAULTS.reconnectWindowMs`; co-op seats keep playing their last lineup meanwhile, §6.6); a session that drops while its **solo** room's match runs stays resumable for the official `config.constants.singleReconnectTime` (86 400 s = 24 h; lobby option `soloReconnectWindowMs`; research 01 §1 "24小时内随时返回") — the untimed solo run simply waits. Only when the window expires does the drop become `match.onLeave` (co-op: 中途退出 = elimination; solo: the run ends `'abandoned'`). The client keeps its token in sessionStorage plus a recent-token list in localStorage, so reopening the tab resumes. Everything lives in server memory: a server restart ends every room and run. The lobby⇄match interface (constructor opts, `start/handle/onDisconnect/onReconnect/onLeave/dispose`) is documented verbatim at the top of `server/match/Match.js` and is normative. `server/data.js` provides `getData()` + index getters (`getChess`, `getBond`, `getGarrison`, `getItem`, `getBand`, `getEffect`, `getEnemy`, `getWave`, `getStage`, `getBoss`, `getToken`, `getConfig`, `getMode`). Note: the skill trigger rule lives at `chess.skill.trigger.rule` in data.
**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 ### 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}` · `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: S→C:
+6
View File
@@ -203,6 +203,12 @@ player) sends no `g.watch` (it would be refused with `BAD_TARGET 'no such field'
teammate's battle after the own one gets the watched field again (`_resendBattle`, `b.start watch: true`); the fresh teammate's battle after the own one gets the watched field again (`_resendBattle`, `b.start watch: true`); the fresh
screen adopts it as the watched field once per battle (`battle/observe.js resumedWatch`: a living player's teammate screen adopts it as the watched field once per battle (`battle/observe.js resumedWatch`: a living player's teammate
normal battle in COMBAT, first seen while watching nothing), so the observing pill, 返回战场 and the own row work again. normal battle in COMBAT, first seen while watching nothing), so the observing pill, 返回战场 and the own row work again.
Spectator seats (a remake feature, community report #26; server/lobby.js): `opts.spectators` / `addSpectator(id)` register
a spectator — no PlayerState, a stand-in with `alive: false` — that every watch path treats like an eliminated human
(`_viewers()`: the first field of each battle, the 联防 spec, the first boss field, `g.watch` anything; `addSpectator`
also resends the state on a join mid-match and each resume); it never gets `m.private` / `m.toast` / `m.unitStats`, is
never a field's player or authority, its `b.start` spec omits the players' `contentInfo.funds` (`_spectatorSpec`: read by
no battle effect), it gets the settlement's `m.result`, and `handle()` answers only its `g.watch` (else `SPECTATOR`).
The rest of this section is the legacy server-run mode The rest of this section is the legacy server-run mode
(`SP_COMBAT=server`): (`SP_COMBAT=server`):
`g.watch { fieldId }`: any live field during COMBAT / 联防; while no battle field is up (PREP, drafts, SETTLE) `g.watch { fieldId }`: any live field during COMBAT / 联防; while no battle field is up (PREP, drafts, SETTLE)
+1
View File
@@ -117,6 +117,7 @@
- 左下角「交流」打开表情面板:6 套主题 × 6 个表情,左右滑动(或方向键)换主题。表情以气泡形式显示在你头像旁 3 秒,发送冷却 1 秒。 - 左下角「交流」打开表情面板:6 套主题 × 6 个表情,左右滑动(或方向键)换主题。表情以气泡形式显示在你头像旁 3 秒,发送冷却 1 秒。
- 休整期、或自己的作战结束后(显示「作战结束,等待队友完成作战」),点左侧队友头像 →「前往查看」观看他的战场;自己的作战进行中不能观战。联防和最终攻势中可以用 ‹ › 切换左半 / 全景 / 右半。 - 休整期、或自己的作战结束后(显示「作战结束,等待队友完成作战」),点左侧队友头像 →「前往查看」观看他的战场;自己的作战进行中不能观战。联防和最终攻势中可以用 ‹ › 切换左半 / 全景 / 右半。
- 被淘汰后可以自由观看任何战场。 - 被淘汰后可以自由观看任何战场。
- **观战席**(本作新增,原版没有):每个同盟房间另有 2 个观战席。在大厅输入同盟密钥后点「观战」进入(模拟进行中也可以),不占博士席位、不算参战人数,也不能购买、部署、准备或发送表情;观看方式与被淘汰的玩家相同——作战时自动观看第一个战场,休整期自动显示第一位博士的阵地,点左侧成员头像可切换查看任何人。断线重连会回到观战席;创建者可以在等待室用观战者名字旁的 ✕ 将其移出;观战者在等待室有空位时可以点「入座」成为博士。
- **观看谁,盟约栏就显示谁的盟约**:查看队友的阵地或战场时(休整期、作战结束后、被淘汰后自动观战),左上的盟约栏换成**该队友的盟约、人数和层数**,前面有琥珀色的「👁 队友名」标签,点开的盟约详情也写着「队友名 的盟约」(成员一栏只标出他在场上的干员,整备区看不到);作战中层数实时增长(与他自己看到的一致)。联防和最终攻势里跟随 ‹ ›:镜头在队友的半场时显示他的盟约,在你自己的半场时显示你自己的;「全景」时,你在这个战场上作战就显示你自己的,你不在这个战场上(漏怪后观看联防、被淘汰后观战)则显示你点「前往查看」选中的那名队友,没选过就显示该战场的第一名玩家(1 号联防者 / 这一组的第一位),**不会显示你自己的**。在这样的战场上点某名队友的头像 →「前往查看」,镜头会直接移到他的半场,‹ › 上的「👁 队友名」与盟约栏的标签一致;‹ › 旁边有「返回战场」(被淘汰时没有)。盟约栏换人时,从盟约栏打开的盟约详情会自动关闭。点开场上某个干员的详情卡时,卡上的盟约(以及点它打开的盟约详情)属于**这个干员的主人**,与盟约栏当前显示谁无关——例如在「全景」点队友的干员,看到的是「队友名 的盟约」及他的人数和层数;在队友的半场点你自己的干员,看到的是你自己的。回到自己的阵地 / 战场后恢复成你自己的盟约。作战结束后观看队友时刷新页面 / 断线重连,仍会回到正在观看的队友战场,「👁 队友名」和「返回战场」都还在。已被淘汰的队友不再显示盟约(也无法被查看)。 - **观看谁,盟约栏就显示谁的盟约**:查看队友的阵地或战场时(休整期、作战结束后、被淘汰后自动观战),左上的盟约栏换成**该队友的盟约、人数和层数**,前面有琥珀色的「👁 队友名」标签,点开的盟约详情也写着「队友名 的盟约」(成员一栏只标出他在场上的干员,整备区看不到);作战中层数实时增长(与他自己看到的一致)。联防和最终攻势里跟随 ‹ ›:镜头在队友的半场时显示他的盟约,在你自己的半场时显示你自己的;「全景」时,你在这个战场上作战就显示你自己的,你不在这个战场上(漏怪后观看联防、被淘汰后观战)则显示你点「前往查看」选中的那名队友,没选过就显示该战场的第一名玩家(1 号联防者 / 这一组的第一位),**不会显示你自己的**。在这样的战场上点某名队友的头像 →「前往查看」,镜头会直接移到他的半场,‹ › 上的「👁 队友名」与盟约栏的标签一致;‹ › 旁边有「返回战场」(被淘汰时没有)。盟约栏换人时,从盟约栏打开的盟约详情会自动关闭。点开场上某个干员的详情卡时,卡上的盟约(以及点它打开的盟约详情)属于**这个干员的主人**,与盟约栏当前显示谁无关——例如在「全景」点队友的干员,看到的是「队友名 的盟约」及他的人数和层数;在队友的半场点你自己的干员,看到的是你自己的。回到自己的阵地 / 战场后恢复成你自己的盟约。作战结束后观看队友时刷新页面 / 断线重连,仍会回到正在观看的队友战场,「👁 队友名」和「返回战场」都还在。已被淘汰的队友不再显示盟约(也无法被查看)。
## 9. 结算称号 ## 9. 结算称号
+2
View File
@@ -421,6 +421,8 @@
padding: .1rem .26rem; background: rgba(40, 8, 6, .9); border: 1px solid rgba(255, 84, 84, .5); color: var(--red-premium); font-size: .18rem; font-weight: 700; padding: .1rem .26rem; background: rgba(40, 8, 6, .9); border: 1px solid rgba(255, 84, 84, .5); color: var(--red-premium); font-size: .18rem; font-weight: 700;
} }
.gm__dead .icon { width: .22rem; height: .22rem; } .gm__dead .icon { width: .22rem; height: .22rem; }
/* a spectator seat (community report #26): the same place, the watching pill's amber — nobody was eliminated */
.gm__dead--spectator { background: rgba(24, 16, 4, .92); border-color: rgba(246, 163, 41, .6); color: var(--amber); }
.chud { position: absolute; inset: 0; pointer-events: none; } .chud { position: absolute; inset: 0; pointer-events: none; }
.chud > * { pointer-events: auto; } .chud > * { pointer-events: auto; }
+29
View File
@@ -208,6 +208,35 @@
.solo-brief li::before { content: ''; display: inline-block; width: .06rem; height: .06rem; margin: 0 .12rem .02rem 0; background: var(--mint-600); transform: rotate(45deg); vertical-align: middle; } .solo-brief li::before { content: ''; display: inline-block; width: .06rem; height: .06rem; margin: 0 .12rem .02rem 0; background: var(--mint-600); transform: rotate(45deg); vertical-align: middle; }
.solo-brief b { color: var(--text-hi); } .solo-brief b { color: var(--text-hi); }
/* ---- spectator seats (观战席, community report #26) -------------------------------------------------------------- */
.specbar {
flex: none;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: .14rem;
padding: 0 .6rem .16rem;
font-size: .15rem;
color: var(--text-lo);
}
.specbar__label { display: flex; align-items: baseline; gap: .06rem; font-weight: 700; color: var(--text-md); }
.specbar__label .icon { width: .18rem; height: .18rem; align-self: center; color: var(--mint-500); }
.specbar__label b { color: var(--mint-400); }
.specbar__who {
display: inline-flex;
align-items: center;
gap: .06rem;
min-height: .36rem;
padding: 0 .14rem;
border: 1px solid var(--line-2);
background: rgba(0, 0, 0, .32);
color: var(--text-hi);
}
.specbar__who.is-me { border-color: var(--mint-600); }
.specbar__who.is-offline { color: var(--text-dim); }
.specbar__who > .icon { width: .16rem; height: .16rem; }
/* ---- bottom bar ----------------------------------------------------------------------------------------- */ /* ---- bottom bar ----------------------------------------------------------------------------------------- */
.room-bar { .room-bar {
position: relative; position: relative;
+3 -3
View File
@@ -32,7 +32,7 @@ import { html, UiHosts, Button, MicroLabel, closeAllDialogs } from './ui/compone
import { ConnectionBanner } from './ui/connBanner.js'; import { ConnectionBanner } from './ui/connBanner.js';
import { ToastHost, toast, toastError, describeError } from './ui/toasts.js'; import { ToastHost, toast, toastError, describeError } from './ui/toasts.js';
import { net, identity, NetError } from './net.js'; import { net, identity, NetError } from './net.js';
import { store, useStore, emptyMatch, selectRoute, sessionResetNotice } from './store.js'; import { store, useStore, emptyMatch, selectRoute, sessionResetNotice, isSpectating } from './store.js';
import { data } from './data.js'; import { data } from './data.js';
import { GAME_FILES } from './ui/gameComponents.js'; import { GAME_FILES } from './ui/gameComponents.js';
import { TitleScreen, sanitizeName } from './screens/title.js'; import { TitleScreen, sanitizeName } from './screens/title.js';
@@ -168,8 +168,8 @@ function onRoomState(msg) {
roomStateAt = Date.now(); roomStateAt = Date.now();
const myId = store.get().me.playerId; const myId = store.get().me.playerId;
const seats = Array.isArray(room.seats) ? room.seats : []; const seats = Array.isArray(room.seats) ? room.seats : [];
if (myId != null && seats.length && !seats.some((s) => s && s.playerId === myId)) { if (myId != null && seats.length && !seats.some((s) => s && s.playerId === myId) && !isSpectating(room, myId)) {
// We are no longer seated (kicked / left elsewhere). // We are no longer seated (kicked / left elsewhere) — neither in a player seat nor a spectator seat.
if (store.get().room) toast('你已不在该同盟中', 'warn'); if (store.get().room) toast('你已不在该同盟中', 'warn');
store.set({ room: null, match: emptyMatch() }); store.set({ room: null, match: emptyMatch() });
return; return;
+2 -1
View File
@@ -212,7 +212,8 @@ export function BandDraftScreen() {
const timed = !solo && !!pub?.draft && !pub.draft.untimed; const timed = !solo && !!pub?.draft && !pub.draft.untimed;
const focusSent = useRef(null); const focusSent = useRef(null);
useEffect(() => { useEffect(() => {
if (!timed || myPick || !sel || focusSent.current === sel) return; // (a spectator seat — community report #26 — is in no draft order: it never reports)
if (!timed || myPick || !sel || focusSent.current === sel || !draft.order.includes(myId)) return;
focusSent.current = sel; focusSent.current = sel;
act('g.bandFocus', { bandId: sel }, { sfx: false, quiet: true }); act('g.bandFocus', { bandId: sel }, { sfx: false, quiet: true });
}, [sel, timed, myPick]); }, [sel, timed, myPick]);
+2 -2
View File
@@ -92,8 +92,8 @@ export function BriefingScreen() {
<span class="brief-ready__txt">已就绪 <b class="num">${readyN}</b><span class="num">/${players.length}</span></span> <span class="brief-ready__txt">已就绪 <b class="num">${readyN}</b><span class="num">/${players.length}</span></span>
<span class="brief-ready__pips">${players.map((p) => html`<i key=${p.playerId} class=${cx(p.ready && 'on', p.playerId === myId && 'me')} title=${p.name}><${Icon} name="user" /></i>`)}</span> <span class="brief-ready__pips">${players.map((p) => html`<i key=${p.playerId} class=${cx(p.ready && 'on', p.playerId === myId && 'me')} title=${p.name}><${Icon} name="user" /></i>`)}</span>
</div> </div>
<${Button} variant="primary" size="xl" icon=${me?.ready ? 'check' : 'play'} active=${!!me?.ready} loading=${busy} <${Button} variant="primary" size="xl" icon=${me?.ready ? 'check' : me ? 'play' : 'eye'} active=${!!me?.ready} loading=${busy}
disabled=${!!me?.ready || !me} onClick=${ready}>${me?.ready ? '已就绪' : '准备就绪'}<//> disabled=${!!me?.ready || !me} onClick=${ready}>${me?.ready ? '已就绪' : me ? '准备就绪' : '观战中'}<//>
</footer> </footer>
<${ExitModal} open=${exit} onClose=${() => setExit(false)} solo=${solo} /> <${ExitModal} open=${exit} onClose=${() => setExit(false)} solo=${solo} />
</div>`; </div>`;
+27 -7
View File
@@ -58,6 +58,10 @@
// the battle on screen (battle/runner.js state().bondLayers). Picking a teammate on such a field (a 联防 leaker, an // the battle on screen (battle/runner.js state().bondLayers). Picking a teammate on such a field (a 联防 leaker, an
// eliminated spectator) frames their half and keeps the ‹ › pill (with 返回战场); an open bond popup closes when the // eliminated spectator) frames their half and keeps the ‹ › pill (with 返回战场); an open bond popup closes when the
// strip changes hands. // strip changes hands.
// Spectator seats (community report #26, a remake feature): a spectator has no m.private and plays like an ELIMINATED
// player — no shop, no ready, no emotes (▸ [ASSUMED] off), the server auto-observes the first field of every battle, a
// team row → 前往查看 any player; in 休整期 / 机变 / round start it is shown the first player's board by itself (once per
// phase). Its pill reads 观战中, never "你已被淘汰"; its exit only leaves the seat (ui/matchChrome.js ExitModal).
import { useCallback, useEffect, useMemo, useRef, useState } from '../../vendor/hooks.module.js'; import { useCallback, useEffect, useMemo, useRef, useState } from '../../vendor/hooks.module.js';
import { PHASE, GEO } from '../../../shared/constants.js'; import { PHASE, GEO } from '../../../shared/constants.js';
@@ -99,7 +103,7 @@ import { BriefingScreen } from './briefing.js';
import { BandDraftScreen } from './bandDraft.js'; import { BandDraftScreen } from './bandDraft.js';
import { ResultScreen } from './result.js'; import { ResultScreen } from './result.js';
import { net } from '../net.js'; import { net } from '../net.js';
import { store, useStore, shallowEqual, serverNow, emptyMatch } from '../store.js'; import { store, useStore, shallowEqual, serverNow, emptyMatch, isSpectating } from '../store.js';
import { battleRunner } from '../battle/runner.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 } from '../battle/observe.js';
import { screenStrip, playerBonds, playerLayer, detailBondOwner, toggleBond, popupView } from '../ui/watchBonds.js'; import { screenStrip, playerBonds, playerLayer, detailBondOwner, toggleBond, popupView } from '../ui/watchBonds.js';
@@ -184,6 +188,7 @@ function MatchScreen() {
const conn = useStore((s) => s.connection, shallowEqual); const conn = useStore((s) => s.connection, shallowEqual);
const emotes = useStore((s) => s.emotes); const emotes = useStore((s) => s.emotes);
const roomSolo = useStore((s) => s.room?.mode === 'solo'); const roomSolo = useStore((s) => s.room?.mode === 'solo');
const spectator = useStore((s) => isSpectating(s.room, s.me.playerId));
const gd = useGameData(); const gd = useGameData();
const hostRef = useRef(null); const hostRef = useRef(null);
@@ -225,7 +230,8 @@ function MatchScreen() {
const solo = roomSolo || String(pub?.modeId || '').includes('single'); const solo = roomSolo || String(pub?.modeId || '').includes('single');
const players = sortedPlayers(pub); const players = sortedPlayers(pub);
const meP = players.find((p) => p.playerId === myId) || null; const meP = players.find((p) => p.playerId === myId) || null;
const alive = priv ? priv.alive !== false : meP?.alive !== false; // a spectator seat watches like an eliminated player (it has no m.private and no row in m.public)
const alive = spectator ? false : priv ? priv.alive !== false : meP?.alive !== false;
const home = homeFieldId(pub, myId); const home = homeFieldId(pub, myId);
const watchingOther = !!watching && watching !== home && watching !== ownFieldId(myId); const watchingOther = !!watching && watching !== home && watching !== ownFieldId(myId);
const editable = phase === PHASE.PREP && !!priv && alive && !priv.ready && !watchingOther; const editable = phase === PHASE.PREP && !!priv && alive && !priv.ready && !watchingOther;
@@ -722,6 +728,18 @@ function MatchScreen() {
const watchField = useCallback((fid) => { requestWatch(fid); }, []); 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);
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]);
// ---- view events (drag & drop, clicks) ---------------------------------------------------------------------- // ---- view events (drag & drop, clicks) ----------------------------------------------------------------------
useEffect(() => { useEffect(() => {
if (!view) return undefined; if (!view) return undefined;
@@ -1206,7 +1224,7 @@ function MatchScreen() {
readyBusy=${readyBusy} readyCount=${readyCount} playerCount=${solo ? 1 : aliveCount} readyBusy=${readyBusy} readyCount=${readyCount} playerCount=${solo ? 1 : aliveCount}
pen=${pen} penAvail=${penAvail} onPen=${togglePen} config=${gd.config} frozenAt=${frozenAt} pen=${pen} penAvail=${penAvail} onPen=${togglePen} config=${gd.config} frozenAt=${frozenAt}
pause=${canPause || paused ? { show: canPause, paused, busy: pauseBusy, onToggle: () => togglePause(!paused) } : null} pause=${canPause || paused ? { show: canPause, paused, busy: pauseBusy, onToggle: () => togglePause(!paused) } : null}
live=${liveLpNow} /> live=${liveLpNow} spectator=${spectator} />
<div class="gm__bonds"> <div class="gm__bonds">
<${BondStrip} bonds=${stripBonds} layersDisabled=${layersDisabled} openId=${bondPop && bondPop.ownerId === strip.ownerId ? bondPop.bondId : null} <${BondStrip} bonds=${stripBonds} layersDisabled=${layersDisabled} openId=${bondPop && bondPop.ownerId === strip.ownerId ? bondPop.bondId : null}
@@ -1221,7 +1239,7 @@ function MatchScreen() {
${watchingOther && !combat ? html`<div class="gm__watching" role="status"> ${watchingOther && !combat ? html`<div class="gm__watching" role="status">
<${GIcon} name="eye" /><span>正在查看 <b>${watchedName}</b> 的阵地(只读)</span> <${GIcon} name="eye" /><span>正在查看 <b>${watchedName}</b> 的阵地(只读)</span>
<${Button} size="sm" variant="primary" icon="back" onClick=${() => watchPlayer({ playerId: myId })}>返回自己<//> ${spectator ? null : html`<${Button} size="sm" variant="primary" icon="back" onClick=${() => watchPlayer({ playerId: myId })}>返回自己<//>`}
</div>` : null} </div>` : null}
${showShop ? html`<${ShopBar} priv=${priv} editable=${editable} collapsed=${collapsed} onCollapse=${setCollapsed} ${showShop ? html`<${ShopBar} priv=${priv} editable=${editable} collapsed=${collapsed} onCollapse=${setCollapsed}
@@ -1237,15 +1255,17 @@ function MatchScreen() {
onMinimize=${(m) => { setRewardMin(m); if (!m) setCollapsed(false); }} />` : null} onMinimize=${(m) => { setRewardMin(m); if (!m) setCollapsed(false); }} />` : null}
${combat || mode === 'settle' ? html`<${CombatHud} pub=${pub} myId=${myId} watching=${watchingNow} hud=${hud} myDone=${!!myDone && alive} ${combat || mode === 'settle' ? html`<${CombatHud} pub=${pub} myId=${myId} watching=${watchingNow} hud=${hud} myDone=${!!myDone && alive}
spectating=${!alive} onWatch=${watchField} spectating=${!alive} spectator=${spectator} onWatch=${watchField}
client=${cc ? { progress, observing: observingName ? { name: observingName } : null, onBack: alive ? backHome : null, layers, layer, onLayer: setLayer } : null} />` : null} client=${cc ? { progress, observing: observingName ? { name: observingName } : null, onBack: alive ? backHome : null, layers, layer, onLayer: setLayer } : null} />` : null}
${showDeadPill(alive, phase) ? html`<div class="gm__dead" role="status"><${Icon} name="close" />你已被淘汰 · 可继续观战队友</div>` : null} ${showDeadPill(alive, phase) ? (spectator
? html`<div class="gm__dead gm__dead--spectator" role="status"><${GIcon} name="eye" />观战中 · 点击左侧成员头像切换查看</div>`
: html`<div class="gm__dead" role="status"><${Icon} name="close" />你已被淘汰 · 可继续观战队友</div>`) : null}
<${Ticker} /> <${Ticker} />
<div class="gm__corner"> <div class="gm__corner">
<${EmoteWheel} open=${emoteOpen} onToggle=${setEmoteOpen} onSend=${(id) => actions.emote(id)} disabled=${conn.status !== 'online'} /> ${spectator ? null : html`<${EmoteWheel} open=${emoteOpen} onToggle=${setEmoteOpen} onSend=${(id) => actions.emote(id)} disabled=${conn.status !== 'online'} />`}
<button type="button" class="gm__gear" aria-label="设置" title="设置" onClick=${() => setSettingsOpen(true)}><${GIcon} name="gear" /></button> <button type="button" class="gm__gear" aria-label="设置" title="设置" onClick=${() => setSettingsOpen(true)}><${GIcon} name="gear" /></button>
<button type="button" class="gm__gear gm__guide" aria-label="玩法说明" title="玩法说明" onClick=${() => openGuide(0)}><${Icon} name="book" /></button> <button type="button" class="gm__gear gm__guide" aria-label="玩法说明" title="玩法说明" onClick=${() => openGuide(0)}><${Icon} name="book" /></button>
<${FullscreenButton} class="gm__gear gm__fs" /> <${FullscreenButton} class="gm__gear gm__fs" />
+13 -2
View File
@@ -1,5 +1,7 @@
// Lobby screen: pick 独立模拟 / 同盟模拟 and a difficulty (标准/险境/绝境/终极), create a room, // Lobby screen: pick 独立模拟 / 同盟模拟 and a difficulty (标准/险境/绝境/终极), create a room,
// or join one with a 同盟密钥 (recent codes remembered). Shows connection status + ping. // or join one with a 同盟密钥 (recent codes remembered) — as a player (加入同盟) or in one of its MAX_SPECTATORS
// spectator seats (观战: room.spectate, also while its match runs; community report #26, a remake feature — the
// official room has none). Shows connection status + ping.
// //
// Difficulty descriptions come from data/config.json `modes[modeId]` when present, else from the // Difficulty descriptions come from data/config.json `modes[modeId]` when present, else from the
// official act2autochess `modeDataDict` texts embedded below (desc + effectDescList), so the // official act2autochess `modeDataDict` texts embedded below (desc + effectDescList), so the
@@ -8,7 +10,7 @@
// plays 战场#01, 险境 draws one of 8, 绝境 / 终极 one of 7 (m01 excluded). // plays 战场#01, 险境 draws one of 8, 绝境 / 终极 one of 7 (m01 excluded).
import { useEffect, useRef, useState } from '../../vendor/hooks.module.js'; import { useEffect, useRef, useState } from '../../vendor/hooks.module.js';
import { DIFFICULTIES, DIFFICULTY_NAMES, DIFFICULTY_COLORS, ROOM_CODE_LEN, MAX_SEATS, modeIdFor } from '../../../shared/constants.js'; import { DIFFICULTIES, DIFFICULTY_NAMES, DIFFICULTY_COLORS, ROOM_CODE_LEN, MAX_SEATS, MAX_SPECTATORS, modeIdFor } from '../../../shared/constants.js';
import { html, Button, Icon, MicroLabel, Panel, TextField, PingPill, AvatarFrame, Tooltip, Spinner, DifficultyIcon, doctorNo } from '../ui/components.js'; import { html, Button, Icon, MicroLabel, Panel, TextField, PingPill, AvatarFrame, Tooltip, Spinner, DifficultyIcon, doctorNo } from '../ui/components.js';
import { toast, toastError } from '../ui/toasts.js'; import { toast, toastError } from '../ui/toasts.js';
import { GuideButton } from '../ui/guide.js'; import { GuideButton } from '../ui/guide.js';
@@ -250,6 +252,12 @@ export function LobbyScreen() {
if (!CODE_RE.test(k)) { toast(`同盟密钥为 ${ROOM_CODE_LEN} 位字母或数字`, 'warn'); return; } if (!CODE_RE.test(k)) { toast(`同盟密钥为 ${ROOM_CODE_LEN} 位字母或数字`, 'warn'); return; }
run('join', () => net.request('room.join', { code: k })); run('join', () => net.request('room.join', { code: k }));
}; };
// a spectator seat: no player seat taken, nothing to do but watch (also a match already running)
const spectate = () => {
const k = normalizeCode(code);
if (!CODE_RE.test(k)) { toast(`同盟密钥为 ${ROOM_CODE_LEN} 位字母或数字`, 'warn'); return; }
run('spectate', () => net.request('room.spectate', { code: k }));
};
const backToTitle = () => { const backToTitle = () => {
identity.setEntered(false); identity.setEntered(false);
store.set((s) => ({ session: { ...s.session, entered: false } })); store.set((s) => ({ session: { ...s.session, entered: false } }));
@@ -291,6 +299,9 @@ export function LobbyScreen() {
<${TextField} size="code" icon="key" value=${code} placeholder="输入同盟密钥 / 粘贴邀请链接" <${TextField} size="code" icon="key" value=${code} placeholder="输入同盟密钥 / 粘贴邀请链接"
transform=${normalizeCode} onInput=${(v) => setCode(normalizeCode(v))} onEnter=${() => join()} /> transform=${normalizeCode} onInput=${(v) => setCode(normalizeCode(v))} onEnter=${() => join()} />
<${Button} variant="amber" size="lg" icon="users" loading=${busy === 'join'} disabled=${!codeOk || !online} onClick=${() => join()}>加入同盟<//> <${Button} variant="amber" size="lg" icon="users" loading=${busy === 'join'} disabled=${!codeOk || !online} onClick=${() => join()}>加入同盟<//>
<${Tooltip} text=${`以观战者身份进入:不占博士席位,只能观看(每个同盟最多 ${MAX_SPECTATORS} 名,模拟进行中也可进入)`}>
<${Button} variant="secondary" size="lg" icon="eye" class="join-spectate" loading=${busy === 'spectate'} disabled=${!codeOk || !online} onClick=${spectate}>观战<//>
<//>
</div> </div>
<div class="join-foot"> <div class="join-foot">
${recent.length ? html`<span class="t-lo">最近的同盟</span> ${recent.length ? html`<span class="t-lo">最近的同盟</span>
+30 -2
View File
@@ -6,9 +6,12 @@
// host's start counts as the host's ready. So 开始模拟 is enabled exactly then and sends room.start // host's start counts as the host's ready. So 开始模拟 is enabled exactly then and sends room.start
// alone (no separate room.ready round trip that could leave the host "ready" after a failed start). // alone (no separate room.ready round trip that could leave the host "ready" after a failed start).
// Solo rooms show a single seat. // Solo rooms show a single seat.
// Spectator seats (community report #26, a remake feature): a co-op room with spectators shows the 观战席 strip under
// the seats — names, offline marks, the host's ✕ (room.removeSpectator) — and a spectator's own view swaps the ready
// button for 观战中 and offers 入座 (room.join of the room) while a player seat is free.
import { useEffect, useRef, useState } from '../../vendor/hooks.module.js'; import { useEffect, useRef, useState } from '../../vendor/hooks.module.js';
import { DIFFICULTIES, DIFFICULTY_NAMES, DIFFICULTY_COLORS, MAX_SEATS } from '../../../shared/constants.js'; import { DIFFICULTIES, DIFFICULTY_NAMES, DIFFICULTY_COLORS, MAX_SEATS, MAX_SPECTATORS } from '../../../shared/constants.js';
import { import {
html, Button, Icon, MicroLabel, PingPill, AvatarFrame, DifficultyTag, DifficultyIcon, Tooltip, confirmDialog, doctorNo, html, Button, Icon, MicroLabel, PingPill, AvatarFrame, DifficultyTag, DifficultyIcon, Tooltip, confirmDialog, doctorNo,
} from '../ui/components.js'; } from '../ui/components.js';
@@ -17,7 +20,7 @@ import { copyText } from '../ui/clipboard.js';
import { GuideButton } from '../ui/guide.js'; import { GuideButton } from '../ui/guide.js';
import { LoadoutButton } from './loadout.js'; import { LoadoutButton } from './loadout.js';
import { net } from '../net.js'; import { net } from '../net.js';
import { store, useStore, shallowEqual, emptyMatch } from '../store.js'; import { store, useStore, shallowEqual, emptyMatch, isSpectating } from '../store.js';
import { difficultyInfo } from './lobby.js'; import { difficultyInfo } from './lobby.js';
/** /**
@@ -58,6 +61,9 @@ export function roomFacts(room, myId) {
emptySeats: seats.filter((s) => !s).length, emptySeats: seats.filter((s) => !s).length,
canStart: isHost && othersReady && !!mine, canStart: isHost && othersReady && !!mine,
othersReady, othersReady,
// spectator seats (never players: not in `humans`, never counted for ready / start)
spectators: Array.isArray(room?.spectators) ? room.spectators.filter((s) => s && typeof s === 'object') : [],
spectating: isSpectating(room, myId),
}; };
} }
@@ -133,6 +139,20 @@ function SeatCard({ seat, index, room, facts, myId, busy, onAddBot, onRemoveBot,
</article>`; </article>`;
} }
/** 观战席: the room's spectators (host: ✕ frees a seat), and 入座 for a spectator while a player seat is free. */
function SpectatorBar({ facts, myId, busy, onRemove, onSit }) {
if (!facts.spectators.length) return null;
return html`<section class="specbar" aria-label="观战席">
<span class="specbar__label"><${Icon} name="eye" />观战席<b class="num">${facts.spectators.length}</b><span class="num t-dim">/${MAX_SPECTATORS}</span></span>
${facts.spectators.map((s) => html`<span key=${s.playerId} class=${`specbar__who${s.playerId === myId ? ' is-me' : ''}${s.connected === false ? ' is-offline' : ''}`}>
${s.connected === false ? html`<${Icon} name="wifiOff" />` : null}${s.name || '博士'}${s.playerId === myId ? html`<span class="seat__you">你</span>` : null}
${facts.isHost ? html`<${Button} variant="ghost" size="sm" square=${true} icon="close" loading=${busy === `rs${s.playerId}`}
onClick=${() => onRemove(s.playerId)} aria-label=${`移出观战者 ${s.name || ''}`} title="移出该观战者" />` : null}
</span>`)}
${facts.spectating && facts.emptySeats > 0 ? html`<${Button} size="sm" icon="user" loading=${busy === 'sit'} onClick=${onSit}>入座<//>` : null}
</section>`;
}
function InviteBox({ code }) { function InviteBox({ code }) {
const copy = async (what) => { const copy = async (what) => {
const ok = await copyText(what === 'code' ? code : inviteLink(code)); const ok = await copyText(what === 'code' ? code : inviteLink(code));
@@ -205,6 +225,9 @@ export function RoomScreen() {
if (ok) run(`kick${seat}`, () => net.request('room.kick', { seat, playerId })); if (ok) run(`kick${seat}`, () => net.request('room.kick', { seat, playerId }));
}; };
const setDifficulty = (difficulty) => run('diff', () => net.request('room.setDifficulty', { difficulty })); const setDifficulty = (difficulty) => run('diff', () => net.request('room.setDifficulty', { difficulty }));
// spectator seats: the host frees one; a spectator takes a free player seat with room.join of this room
const removeSpectator = (playerId) => run(`rs${playerId}`, () => net.request('room.removeSpectator', { playerId }));
const sit = () => run('sit', () => net.request('room.join', { code: room.code }));
const leave = async () => { const leave = async () => {
if (inFlight.current) return; if (inFlight.current) return;
const othersHere = facts.humans.some((s) => s.playerId !== me.playerId); const othersHere = facts.humans.some((s) => s.playerId !== me.playerId);
@@ -228,6 +251,8 @@ export function RoomScreen() {
const statusLine = !online const statusLine = !online
? html`<span class="t-orange"><${Icon} name="wifiOff" />连接中断,正在重连…</span>` ? html`<span class="t-orange"><${Icon} name="wifiOff" />连接中断,正在重连…</span>`
: facts.spectating
? html`<span class="t-lo"><${Icon} name="eye" />观战中 · 不占博士席位,模拟开始后可切换观看各位博士</span>`
: !coop : !coop
? html`<span class="t-mint">*模拟协议已就绪,准许进入模拟</span>` ? html`<span class="t-mint">*模拟协议已就绪,准许进入模拟</span>`
: facts.isHost : facts.isHost
@@ -273,6 +298,7 @@ export function RoomScreen() {
</ul> </ul>
</aside>`} </aside>`}
</main> </main>
<${SpectatorBar} facts=${facts} myId=${me.playerId} busy=${busy} onRemove=${removeSpectator} onSit=${sit} />
<footer class="room-bar"> <footer class="room-bar">
<div class="room-bar__left"> <div class="room-bar__left">
@@ -295,6 +321,8 @@ export function RoomScreen() {
? html`<${Tooltip} text=${facts.canStart ? null : '仍有博士未准备就绪'}> ? html`<${Tooltip} text=${facts.canStart ? null : '仍有博士未准备就绪'}>
<${Button} variant="primary" size="xl" icon="play" loading=${busy === 'start'} disabled=${!facts.canStart || !online} onClick=${start}>开始模拟<//> <${Button} variant="primary" size="xl" icon="play" loading=${busy === 'start'} disabled=${!facts.canStart || !online} onClick=${start}>开始模拟<//>
<//>` <//>`
: facts.spectating
? html`<${Button} variant="secondary" size="xl" icon="eye" disabled=${true}>观战中<//>`
: html`<${Button} variant=${myReady ? 'primary' : 'secondary'} size="xl" icon=${myReady ? 'check' : 'hourglass'} active=${myReady} : html`<${Button} variant=${myReady ? 'primary' : 'secondary'} size="xl" icon=${myReady ? 'check' : 'hourglass'} active=${myReady}
loading=${busy === 'ready'} disabled=${!online || !facts.mine} onClick=${toggleReady}>${myReady ? '已就绪' : '准备就绪'}<//>`} loading=${busy === 'ready'} disabled=${!online || !facts.mine} onClick=${toggleReady}>${myReady ? '已就绪' : '准备就绪'}<//>`}
</div> </div>
+11
View File
@@ -103,6 +103,17 @@ export function selectRoute(s) {
return 'lobby'; return 'lobby';
} }
/**
* Whether the player holds one of the room's spectator seats (room.state `spectators`; community report #26, a remake
* feature): it is no player of the room or its match, watches like an eliminated player and may not act.
* @param {any} room room.state payload
* @param {string|null|undefined} playerId
* @returns {boolean}
*/
export function isSpectating(room, playerId) {
return playerId != null && Array.isArray(room?.spectators) && room.spectators.some((s) => s && s.playerId === playerId);
}
/** /**
* The notice after a `welcome` that starts a brand-new server session (the server restarted, or this session expired * The notice after a `welcome` that starts a brand-new server session (the server restarted, or this session expired
* on it: a new playerId) while a room or a match was on screen — everything shown is gone. Null when nothing is lost * on it: a new playerId) while a room or a match was on screen — everything shown is gone. Null when nothing is lost
+8 -5
View File
@@ -34,8 +34,9 @@ function ProgressList({ list }) {
* client?: null | { progress?: any[]|null, observing?: { name: string }|null, onBack?: () => void, * client?: null | { progress?: any[]|null, observing?: { name: string }|null, onBack?: () => void,
* layers?: Array<{ key: string, label: string, self: boolean, watch: boolean }>, layer?: string, onLayer?: (k: string) => void } }} props * layers?: Array<{ key: string, label: string, self: boolean, watch: boolean }>, layer?: string, onLayer?: (k: string) => void } }} props
*/ */
export function CombatHud({ pub, myId, watching, hud, myDone, onWatch, spectating = false, client = null }) { export function CombatHud({ pub, myId, watching, hud, myDone, onWatch, spectating = false, spectator = false, client = null }) {
if (client) return ClientHud({ hud, myDone, spectating, client }); // `spectator`: a spectator seat (community report #26) watches like an eliminated player, under its own caption
if (client) return ClientHud({ hud, myDone, spectating, spectator, client });
const fields = (Array.isArray(pub?.fields) ? pub.fields : []).filter((f) => f && f.live !== false); const fields = (Array.isArray(pub?.fields) ? pub.fields : []).filter((f) => f && f.live !== false);
// the watched field may already have finished (not live): still name it // the watched field may already have finished (not live): still name it
const label = switcherLabel(pub, watching, myId, spectating); const label = switcherLabel(pub, watching, myId, spectating);
@@ -44,7 +45,8 @@ export function CombatHud({ pub, myId, watching, hud, myDone, onWatch, spectatin
<${DpCounter} dp=${hud?.dp} /> <${DpCounter} dp=${hud?.dp} />
<div class="chud__bottom"> <div class="chud__bottom">
${myDone ? html`<div class="chud__msg" role="status"><${Icon} name="hourglass" /><span>作战结束,等待队友完成作战</span></div>` : null} ${myDone ? html`<div class="chud__msg" role="status"><${Icon} name="hourglass" /><span>作战结束,等待队友完成作战</span></div>` : null}
${spectating ? html`<div class="chud__msg chud__msg--dead" role="status"><${Icon} name="close" /><span>你已被淘汰,正在观战</span></div>` : null} ${spectating ? (spectator ? html`<div class="chud__msg" role="status"><${GIcon} name="eye" /><span>观战中</span></div>`
: html`<div class="chud__msg chud__msg--dead" role="status"><${Icon} name="close" /><span>你已被淘汰,正在观战</span></div>`) : null}
${fields.length ? html`<div class=${cx('vswitch', !canCycle && 'is-single')}> ${fields.length ? html`<div class=${cx('vswitch', !canCycle && 'is-single')}>
<button type="button" class="vswitch__arrow" disabled=${!canCycle} aria-label="上一个战场" <button type="button" class="vswitch__arrow" disabled=${!canCycle} aria-label="上一个战场"
onClick=${() => { const n = cycleField(fields, watching, -1); if (n && n !== watching) onWatch(n); }}><${Icon} name="chevronLeft" /></button> onClick=${() => { const n = cycleField(fields, watching, -1); if (n && n !== watching) onWatch(n); }}><${Icon} name="chevronLeft" /></button>
@@ -56,7 +58,7 @@ export function CombatHud({ pub, myId, watching, hud, myDone, onWatch, spectatin
</div>`; </div>`;
} }
function ClientHud({ hud, myDone, spectating, client }) { function ClientHud({ hud, myDone, spectating, spectator = false, client }) {
const layers = Array.isArray(client.layers) ? client.layers : []; const layers = Array.isArray(client.layers) ? client.layers : [];
const idx = Math.max(0, layers.findIndex((l) => l.key === (client.layer || 'ALL'))); const idx = Math.max(0, layers.findIndex((l) => l.key === (client.layer || 'ALL')));
const cur = layers[idx] || null; const cur = layers[idx] || null;
@@ -72,7 +74,8 @@ function ClientHud({ hud, myDone, spectating, client }) {
<div class="chud__bottom"> <div class="chud__bottom">
${myDone && !observing ? html`<div class="chud__msg chud__wait" role="status"><${Icon} name="hourglass" /><span>作战结束,等待队友完成作战</span></div> ${myDone && !observing ? html`<div class="chud__msg chud__wait" role="status"><${Icon} name="hourglass" /><span>作战结束,等待队友完成作战</span></div>
<${ProgressList} list=${client.progress} />` : null} <${ProgressList} list=${client.progress} />` : null}
${spectating && !observing ? html`<div class="chud__msg chud__msg--dead" role="status"><${Icon} name="close" /><span>你已被淘汰,可点击队友头像前往查看</span></div>` : null} ${spectating && !observing ? (spectator ? html`<div class="chud__msg" role="status"><${GIcon} name="eye" /><span>观战中,可点击左侧成员头像前往查看</span></div>`
: html`<div class="chud__msg chud__msg--dead" role="status"><${Icon} name="close" /><span>你已被淘汰,可点击队友头像前往查看</span></div>`) : null}
${observing && !halves ? html`<div class="vswitch chud__observe is-single" role="status"> ${observing && !halves ? html`<div class="vswitch chud__observe is-single" role="status">
<span class="vswitch__label"><${GIcon} name="eye" /><span>${observing.name}</span></span> <span class="vswitch__label"><${GIcon} name="eye" /><span>${observing.name}</span></span>
${client.onBack ? html`<button type="button" class="btn btn--secondary btn--sm chud__back" onClick=${() => client.onBack()}> ${client.onBack ? html`<button type="button" class="btn btn--secondary btn--sm chud__back" onClick=${() => client.onBack()}>
+2
View File
@@ -74,6 +74,8 @@ export const ICONS = {
collapse: { d: 'M8 3h2v7H3V8h5zm6 0h2v5h5v2h-7zM3 14h7v7H8v-5H3zm11 0h7v2h-5v5h-2z' }, collapse: { d: 'M8 3h2v7H3V8h5zm6 0h2v5h5v2h-7zM3 14h7v7H8v-5H3zm11 0h7v2h-5v5h-2z' },
rotate: { d: 'M7 2h8a2 2 0 0 1 2 2v7h-2V4H7v16h4v2H7a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2zm8 12h5a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2h-5a2 2 0 0 1-2-2v-4a2 2 0 0 1 2-2zm0 2v4h5v-4z', eo: true }, rotate: { d: 'M7 2h8a2 2 0 0 1 2 2v7h-2V4H7v16h4v2H7a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2zm8 12h5a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2h-5a2 2 0 0 1-2-2v-4a2 2 0 0 1 2-2zm0 2v4h5v-4z', eo: true },
book: { d: 'M2 4h7.5A3.5 3.5 0 0 1 12 5.1 3.5 3.5 0 0 1 14.5 4H22v16h-7.5a1.5 1.5 0 0 0-1.5 1.5h-2A1.5 1.5 0 0 0 9.5 20H2zm2 2v12h5.5c.5 0 1 .1 1.5.3V7.5A1.5 1.5 0 0 0 9.5 6zm10.5 0A1.5 1.5 0 0 0 13 7.5v10.8c.5-.2 1-.3 1.5-.3H20V6z', eo: true }, book: { d: 'M2 4h7.5A3.5 3.5 0 0 1 12 5.1 3.5 3.5 0 0 1 14.5 4H22v16h-7.5a1.5 1.5 0 0 0-1.5 1.5h-2A1.5 1.5 0 0 0 9.5 20H2zm2 2v12h5.5c.5 0 1 .1 1.5.3V7.5A1.5 1.5 0 0 0 9.5 6zm10.5 0A1.5 1.5 0 0 0 13 7.5v10.8c.5-.2 1-.3 1.5-.3H20V6z', eo: true },
// 观战 (spectator seats; the same eye as gameComponents.js GIcon 'eye')
eye: { d: 'M12 5c5 0 9 4.5 10 7-1 2.5-5 7-10 7S3 14.5 2 12c1-2.5 5-7 10-7zm0 3a4 4 0 1 0 0 8 4 4 0 0 0 0-8zm0 2a2 2 0 1 1 0 4 2 2 0 0 1 0-4z', eo: true },
}; };
/** /**
+3 -2
View File
@@ -308,7 +308,7 @@ export function PauseButton({ paused, busy = false, onToggle }) {
* `left` (a leaker in 联防): its enemies still standing — the capsule's ×N tag * `left` (a leaker in 联防): its enemies still standing — the capsule's ×N tag
*/ */
export function TopBar({ pub, priv, conn, hud, total, drawer, onExit, onDrawer, onReady, readyBusy, readyCount, playerCount, pen = false, penAvail = false, onPen = () => {}, export function TopBar({ pub, priv, conn, hud, total, drawer, onExit, onDrawer, onReady, readyBusy, readyCount, playerCount, pen = false, penAvail = false, onPen = () => {},
config = null, frozenAt = null, pause = null, live = null }) { config = null, frozenAt = null, pause = null, live = null, spectator = false }) {
const phase = pub?.phase; const phase = pub?.phase;
const boss = isBossPhase(phase); const boss = isBossPhase(phase);
const lp = boss && Number.isFinite(pub?.teamLp) ? pub.teamLp : Number.isFinite(priv?.lp) ? priv.lp : null; const lp = boss && Number.isFinite(pub?.teamLp) ? pub.teamLp : Number.isFinite(priv?.lp) ? priv.lp : null;
@@ -316,7 +316,8 @@ export function TopBar({ pub, priv, conn, hud, total, drawer, onExit, onDrawer,
const pending = !boss && Number.isFinite(lp) && live && live.pending > 0 ? Math.min(lp, live.pending) : 0; const pending = !boss && Number.isFinite(lp) && live && live.pending > 0 ? Math.min(lp, live.pending) : 0;
const hidden = phase === PHASE.HIDDEN_CORE || (Number.isFinite(pub?.lastRound) && pub.round > pub.lastRound); const hidden = phase === PHASE.HIDDEN_CORE || (Number.isFinite(pub?.lastRound) && pub.round > pub.lastRound);
const roundText = hidden ? '??' : pub?.round > 0 ? String(pub.round) : '--'; const roundText = hidden ? '??' : pub?.round > 0 ? String(pub.round) : '--';
const showReady = phase === PHASE.PREP && priv?.alive !== false; // a spectator seat (no m.private, community report #26) never readies
const showReady = phase === PHASE.PREP && !spectator && priv?.alive !== false;
// boss rounds: the overtime warning follows the clock (4 Hz while live; frozen while paused) // boss rounds: the overtime warning follows the clock (4 Hz while live; frozen while paused)
const otLive = boss && Number(pub?.overtimeAt) > 0; const otLive = boss && Number(pub?.overtimeAt) > 0;
useTicker(otLive && frozenAt == null ? 250 : 0); useTicker(otLive && frozenAt == null ? 250 : 0);
+10 -1
View File
@@ -9,7 +9,7 @@ import { html, Button, Modal, PingPill, DifficultyTag, Countdown, MicroLabel } f
import { actions } from './gameActions.js'; import { actions } from './gameActions.js';
import { toastError } from './toasts.js'; import { toastError } from './toasts.js';
import { net } from '../net.js'; import { net } from '../net.js';
import { store, useStore, shallowEqual, emptyMatch, createStore } from '../store.js'; import { store, useStore, shallowEqual, emptyMatch, createStore, isSpectating } from '../store.js';
import { GIcon } from './gameComponents.js'; import { GIcon } from './gameComponents.js';
import { GuideButton } from './guide.js'; import { GuideButton } from './guide.js';
@@ -58,12 +58,21 @@ export async function quitMatch() {
*/ */
export function ExitModal({ open, onClose, solo, onAway }) { export function ExitModal({ open, onClose, solo, onAway }) {
const [busy, setBusy] = useState(null); const [busy, setBusy] = useState(null);
// a spectator seat (community report #26) only leaves its seat: nothing of the match is given up
const spectator = useStore((s) => isSpectating(s.room, s.me.playerId));
const quit = async () => { const quit = async () => {
setBusy('quit'); setBusy('quit');
await quitMatch(); await quitMatch();
setBusy(null); setBusy(null);
onClose(); onClose();
}; };
if (spectator) {
return html`<${Modal} open=${open} onClose=${onClose} tone="red" title="离开观战" micro="LEAVE SPECTATING" width="6.8rem"
actions=${html`<${Button} variant="secondary" onClick=${onClose}>取消<//>
<${Button} variant="danger" icon="exit" loading=${busy === 'quit'} onClick=${quit}>离开观战<//>`}>
<div class="exitm"><p>离开观战席并返回大厅,本局模拟不受影响;观战席空着时可以凭同盟密钥再次观战。</p></div>
<//>`;
}
const away = async () => { const away = async () => {
setBusy('away'); setBusy('away');
const ok = await actions.autoplay(true); const ok = await actions.autoplay(true);
+117 -16
View File
@@ -63,9 +63,21 @@
// (or outside a room) it simply replaces the stored one; while the room's match runs it is also handed to // (or outside a room) it simply replaces the stored one; while the room's match runs it is also handed to
// match.setLoadout(playerId, loadout), which accepts it only during INFO_CHECK (the 干员调配 entry of the briefing) // match.setLoadout(playerId, loadout), which accepts it only during INFO_CHECK (the 干员调配 entry of the briefing)
// and refuses it afterwards (WRONG_PHASE: the match's loadout is locked, the stored one applies to the next match). // and refuses it afterwards (WRONG_PHASE: the match's loadout is locked, the stored one applies to the next match).
// * Spectator seats (community report #26, owner's decision 2026-10-04 — a remake feature, the official room has none):
// room.spectate { code } takes one of a co-op room's MAX_SPECTATORS (2) spectator seats, in its lobby or while its
// match runs (▸ solo rooms: ROOM_FULL). A spectator is not a player: never in `seats`, never counted for the 1–4 players
// or the start gate, never host, never keeps a room alive (a room whose last human leaves closes with room.closed
// {empty} for its spectators). It receives room.state (`spectators: [{ playerId, name, connected }]`) and every match
// broadcast (m.public, m.ticker, m.emote, b.pool — public data); the match registers it (opts.spectators /
// addSpectator) and shows it fields like an eliminated player (b.start watch / m.field), never an m.private. It may
// only g.watch (the heavy bucket, like every watcher), g.leave / room.leave, and room.loadout (stored for its session,
// never handed to the match); anything else → SPECTATOR (▸ emotes too). Host: room.removeSpectator { playerId } any
// time → room.closed {kicked} to it. A spectator in a LOBBY room may take a free player seat with room.join of the same
// code; a player never switches to spectating in place (ALREADY). Disconnect / grace / reconnect / expiry work as for
// a player seat (the seat is kept and given back on resume).
import { randomBytes, randomInt } from 'node:crypto'; import { randomBytes, randomInt } from 'node:crypto';
import { ERR, MAX_SEATS, ROOM_CODE_LEN, modeIdFor } from '../shared/constants.js'; import { ERR, MAX_SEATS, MAX_SPECTATORS, ROOM_CODE_LEN, modeIdFor } from '../shared/constants.js';
import { checkLoadout } from '../shared/protocol.js'; import { checkLoadout } from '../shared/protocol.js';
import { encode, isDroppable, isErrCode, sendRaw, sendSession } from './net.js'; import { encode, isDroppable, isErrCode, sendRaw, sendSession } from './net.js';
import { getData as defaultGetData, lookup } from './data.js'; import { getData as defaultGetData, lookup } from './data.js';
@@ -117,6 +129,8 @@ export class Room {
this.hostId = null; this.hostId = null;
/** @type {(Seat | null)[]} */ /** @type {(Seat | null)[]} */
this.seats = new Array(MAX_SEATS).fill(null); this.seats = new Array(MAX_SEATS).fill(null);
/** @type {{ playerId: string, name: string, connected: boolean }[]} spectator seats, ≤ MAX_SPECTATORS (header) */
this.spectators = [];
/** @type {any} running Match instance */ /** @type {any} running Match instance */
this.match = null; this.match = null;
/** @type {{ live: boolean, ended: boolean, disposed: boolean, match: any } | null} */ /** @type {{ live: boolean, ended: boolean, disposed: boolean, match: any } | null} */
@@ -143,6 +157,9 @@ export class Room {
return null; return null;
} }
/** @param {string} playerId @returns {{ playerId: string, name: string, connected: boolean } | null} */
spectatorOf(playerId) { return this.spectators.find((s) => s.playerId === playerId) || null; }
/** Lowest free seat index, or -1. */ /** Lowest free seat index, or -1. */
freeSeat() { return this.seats.indexOf(null); } freeSeat() { return this.seats.indexOf(null); }
@@ -161,6 +178,7 @@ export class Room {
seats: this.seats.map((s) => (s seats: this.seats.map((s) => (s
? { seat: s.seat, playerId: s.playerId, name: s.name, isBot: s.isBot, ready: s.ready, connected: s.connected && !s.left } ? { seat: s.seat, playerId: s.playerId, name: s.name, isBot: s.isBot, ready: s.ready, connected: s.connected && !s.left }
: null)), : null)),
spectators: this.spectators.map((s) => ({ playerId: s.playerId, name: s.name, connected: s.connected })),
}; };
} }
} }
@@ -204,11 +222,13 @@ export class Lobby {
let matches = 0; let matches = 0;
let humans = 0; let humans = 0;
let bots = 0; let bots = 0;
let spectators = 0;
for (const r of this.rooms.values()) { for (const r of this.rooms.values()) {
if (r.match) matches++; if (r.match) matches++;
for (const s of r.seats) if (s && !s.left) (s.isBot ? bots++ : humans++); for (const s of r.seats) if (s && !s.left) (s.isBot ? bots++ : humans++);
spectators += r.spectators.length;
} }
return { rooms: this.rooms.size, matches, humans, bots }; return { rooms: this.rooms.size, matches, humans, bots, spectators };
} }
// --------------------------------------------------------------------------------------------------- // ---------------------------------------------------------------------------------------------------
@@ -236,7 +256,8 @@ export class Lobby {
} }
session.notice = null; session.notice = null;
session.pendingResult = null; session.pendingResult = null;
const seat = room.seatOf(session.playerId); // a player seat, or a spectator seat (header): both carry `connected` / `name`
const seat = room.seatOf(session.playerId) || room.spectatorOf(session.playerId);
this.clearGrace(session.playerId); this.clearGrace(session.playerId);
// Only a visible change (reconnect, rename, new host) is broadcast; a plain resync (repeated hello on a // Only a visible change (reconnect, rename, new host) is broadcast; a plain resync (repeated hello on a
// live socket) answers the requester alone, so hello spam cannot amplify into room-wide traffic. // live socket) answers the requester alone, so hello spam cannot amplify into room-wide traffic.
@@ -267,6 +288,8 @@ export class Lobby {
case 'room.kick': return this.kick(session, msg); case 'room.kick': return this.kick(session, msg);
case 'room.start': return this.start(session); case 'room.start': return this.start(session);
case 'room.loadout': return this.loadout(session, msg); case 'room.loadout': return this.loadout(session, msg);
case 'room.spectate': return this.spectate(session, msg);
case 'room.removeSpectator': return this.removeSpectator(session, msg);
default: default:
if (typeof msg.t === 'string' && msg.t.startsWith('g.')) return this.routeGame(session, msg); if (typeof msg.t === 'string' && msg.t.startsWith('g.')) return this.routeGame(session, msg);
return fail(ERR.BAD_MSG, `unhandled type ${String(msg.t).slice(0, 32)}`); return fail(ERR.BAD_MSG, `unhandled type ${String(msg.t).slice(0, 32)}`);
@@ -280,10 +303,11 @@ export class Lobby {
// a solo run may be resumed within singleReconnectTime (24 h); everything else keeps the registry's window // a solo run may be resumed within singleReconnectTime (24 h); everything else keeps the registry's window
session.resumeWindowMs = room && room.match && room.mode === 'solo' ? this.soloResumeWindowMs() : null; session.resumeWindowMs = room && room.match && room.mode === 'solo' ? this.soloResumeWindowMs() : null;
if (!room) return; if (!room) return;
const seat = room.seatOf(session.playerId); const player = room.seatOf(session.playerId);
const seat = player || room.spectatorOf(session.playerId);
seat.connected = false; seat.connected = false;
if (room.match) this.callMatch(room, 'onDisconnect', session.playerId); // a spectator's seat is kept like a player's (nothing to tell the match: it plays no field)
else this.startGrace(room, seat); if (room.match) { if (player) this.callMatch(room, 'onDisconnect', session.playerId); } else this.startGrace(room, seat);
this.broadcastState(room); this.broadcastState(room);
} }
@@ -320,8 +344,8 @@ export class Lobby {
if (this.rooms.size >= this.opts.maxRooms) return fail(ERR.INTERNAL, 'too many rooms'); if (this.rooms.size >= this.opts.maxRooms) return fail(ERR.INTERNAL, 'too many rooms');
const key = session.limitKey || null; const key = session.limitKey || null;
if (key && this.opts.maxRoomsPerAddr > 0) { if (key && this.opts.maxRoomsPerAddr > 0) {
// The room being left disappears with this create when the creator is its only human. // The room being left disappears with this create when the creator is its only human (a spectator is none).
const leaving = cur && cur.ownerKey === key && cur.activeHumans().length === 1 ? 1 : 0; const leaving = cur && cur.ownerKey === key && cur.activeHumans().length === 1 && !cur.spectatorOf(session.playerId) ? 1 : 0;
if (this.countRooms((r) => r.ownerKey === key) - leaving >= this.opts.maxRoomsPerAddr) { if (this.countRooms((r) => r.ownerKey === key) - leaving >= this.opts.maxRoomsPerAddr) {
this.limitWarn(`room limit (${this.opts.maxRoomsPerAddr}) reached for ${session.addr}`); this.limitWarn(`room limit (${this.opts.maxRoomsPerAddr}) reached for ${session.addr}`);
return fail(ERR.RATE, 'too many rooms from your network'); return fail(ERR.RATE, 'too many rooms from your network');
@@ -348,7 +372,8 @@ export class Lobby {
const room = norm.length === ROOM_CODE_LEN ? this.rooms.get(norm) : undefined; const room = norm.length === ROOM_CODE_LEN ? this.rooms.get(norm) : undefined;
if (!room) return fail(ERR.ROOM_NOT_FOUND); if (!room) return fail(ERR.ROOM_NOT_FOUND);
const cur = this.roomOf(session); const cur = this.roomOf(session);
if (cur === room) { this.sendState(room, session); return OK; } // idempotent for members; a spectator of this room goes on below: it may take a free player seat (header)
if (cur === room && !room.spectatorOf(session.playerId)) { this.sendState(room, session); return OK; }
if (cur && cur.match) return fail(ERR.ROOM_STARTED, 'leave your running match first'); if (cur && cur.match) return fail(ERR.ROOM_STARTED, 'leave your running match first');
if (room.match) return fail(ERR.ROOM_STARTED); if (room.match) return fail(ERR.ROOM_STARTED);
if (room.mode === 'solo') return fail(ERR.ROOM_FULL, 'solo room'); if (room.mode === 'solo') return fail(ERR.ROOM_FULL, 'solo room');
@@ -371,9 +396,55 @@ export class Lobby {
return OK; return OK;
} }
/**
* room.spectate: one of a co-op room's MAX_SPECTATORS spectator seats, in its lobby or during its match (header). In a
* running match the match registers the spectator and resends what it may see (Match.addSpectator).
*/
spectate(session, { code }) {
const norm = String(code).trim().toUpperCase();
const room = norm.length === ROOM_CODE_LEN ? this.rooms.get(norm) : undefined;
if (!room) return fail(ERR.ROOM_NOT_FOUND);
const cur = this.roomOf(session);
if (cur === room) {
if (!room.spectatorOf(session.playerId)) return fail(ERR.ALREADY, 'seated as a player');
this.sendState(room, session);
return OK;
}
if (cur && cur.match) return fail(ERR.ROOM_STARTED, 'leave your running match first');
if (room.mode === 'solo') return fail(ERR.ROOM_FULL, 'solo room');
if (room.spectators.length >= MAX_SPECTATORS) return fail(ERR.ROOM_FULL, 'no free spectator seat');
if (cur) this.removeMember(cur, session.playerId);
room.spectators.push({ playerId: session.playerId, name: session.name, connected: session.connected });
session.roomCode = room.code;
session.notice = null;
session.pendingResult = null;
this.broadcastState(room);
if (room.match) this.callMatch(room, 'addSpectator', session.playerId);
return OK;
}
/** room.removeSpectator (host, any time): the spectator gets room.closed {kicked} and its seat is freed. */
removeSpectator(session, { playerId }) {
const room = this.roomOf(session);
if (!room) return fail(ERR.NOT_IN_ROOM);
if (room.hostId !== session.playerId) return fail(ERR.NOT_HOST);
if (!room.spectatorOf(playerId)) return fail(ERR.BAD_TARGET, 'not a spectator of this room');
const target = this.registry.byId(playerId);
const wasHere = !!target && target.roomCode === room.code;
const replay = this.replayFor(room, playerId);
this.removeMember(room, playerId);
if (wasHere) {
// like room.kick: now, or on the next resume (with the result replay, as after the grace timeout)
if (target.connected) sendSession(target, { t: 'room.closed', reason: 'kicked' });
else { target.notice = 'kicked'; target.pendingResult = replay; }
}
return OK;
}
ready(session, { ready }) { ready(session, { ready }) {
const room = this.roomOf(session); const room = this.roomOf(session);
if (!room) return fail(ERR.NOT_IN_ROOM); if (!room) return fail(ERR.NOT_IN_ROOM);
if (room.spectatorOf(session.playerId)) return fail(ERR.SPECTATOR);
if (room.match) return fail(ERR.ROOM_STARTED); if (room.match) return fail(ERR.ROOM_STARTED);
this.dropReplay(room, session.playerId); this.dropReplay(room, session.playerId);
const seat = room.seatOf(session.playerId); const seat = room.seatOf(session.playerId);
@@ -488,7 +559,7 @@ export class Lobby {
if (!room) return OK; if (!room) return OK;
const seat = room.seatOf(session.playerId); const seat = room.seatOf(session.playerId);
if (seat) seat.loadout = loadout; if (seat) seat.loadout = loadout;
if (!room.match) return OK; if (!room.match || !seat) return OK; // a spectator's loadout stays on its session, never reaching the match
if (typeof room.match.setLoadout !== 'function') return fail(ERR.ROOM_STARTED, 'stored for the next match'); if (typeof room.match.setLoadout !== 'function') return fail(ERR.ROOM_STARTED, 'stored for the next match');
let r; let r;
try { try {
@@ -527,6 +598,8 @@ export class Lobby {
difficulty: room.difficulty, difficulty: room.difficulty,
modeId: modeIdFor(room.mode, room.difficulty), modeId: modeIdFor(room.mode, room.difficulty),
seats, seats,
// the spectator seats (header): watched like eliminated players, never players
spectators: room.spectators.map((s) => s.playerId),
seed, seed,
// the room's match number: with the seed it keeps battleIds unique across the room's matches (DESIGN §14) // the room's match number: with the seed it keeps battleIds unique across the room's matches (DESIGN §14)
matchNo: room.matchCount + 1, matchNo: room.matchCount + 1,
@@ -574,6 +647,7 @@ export class Lobby {
s.ready = false; s.ready = false;
if (!s.connected) this.startGrace(room, s); if (!s.connected) this.startGrace(room, s);
} }
for (const s of room.spectators) if (!s.connected) this.startGrace(room, s);
const host = room.hostId ? room.seatOf(room.hostId) : null; const host = room.hostId ? room.seatOf(room.hostId) : null;
if (!host || host.isBot || host.left) this.migrateHost(room); if (!host || host.isBot || host.left) this.migrateHost(room);
if (room.activeHumans().length === 0) this.disposeRoom(room, 'empty'); if (room.activeHumans().length === 0) this.disposeRoom(room, 'empty');
@@ -604,7 +678,7 @@ export class Lobby {
*/ */
buildReplay(room, ctx) { buildReplay(room, ctx) {
const frames = new Map(); const frames = new Map();
for (const s of room.seats) { for (const s of [...room.seats, ...room.spectators]) {
if (!s || s.isBot || s.left) continue; if (!s || s.isBot || s.left) continue;
const frame = ctx.results.get(s.playerId) || ctx.sharedResult; const frame = ctx.results.get(s.playerId) || ctx.sharedResult;
if (frame) frames.set(s.playerId, frame); if (frame) frames.set(s.playerId, frame);
@@ -658,7 +732,7 @@ export class Lobby {
if (!room) return; if (!room) return;
session.resyncAt = this.now(); session.resyncAt = this.now();
if (room.match) { if (room.match) {
this.callMatch(room, 'onReconnect', session.playerId); this.callMatch(room, room.spectatorOf(session.playerId) ? 'addSpectator' : 'onReconnect', session.playerId);
return; return;
} }
const frames = this.replayFor(room, session.playerId); const frames = this.replayFor(room, session.playerId);
@@ -696,6 +770,8 @@ export class Lobby {
this.removeMember(room, session.playerId); this.removeMember(room, session.playerId);
return OK; return OK;
} }
// a spectator only watches (header): nothing else of it ever reaches the match
if (msg.t !== 'g.watch' && room.spectatorOf(session.playerId)) return fail(ERR.SPECTATOR);
let res; let res;
try { try {
res = room.match.handle(session.playerId, msg); res = room.match.handle(session.playerId, msg);
@@ -758,6 +834,7 @@ export class Lobby {
if (!session.roomCode) return null; if (!session.roomCode) return null;
const room = this.rooms.get(session.roomCode); const room = this.rooms.get(session.roomCode);
const seat = room ? room.seatOf(session.playerId) : null; const seat = room ? room.seatOf(session.playerId) : null;
if (room && !seat && room.spectatorOf(session.playerId)) return room; // a spectator seat
if (!room || !seat || seat.left || seat.isBot) { session.roomCode = null; return null; } if (!room || !seat || seat.left || seat.isBot) { session.roomCode = null; return null; }
return room; return room;
} }
@@ -780,6 +857,7 @@ export class Lobby {
if (session && session.roomCode === room.code) session.roomCode = null; if (session && session.roomCode === room.code) session.roomCode = null;
this.clearGrace(playerId); this.clearGrace(playerId);
this.dropReplay(room, playerId); this.dropReplay(room, playerId);
if (this.freeSpectatorSeat(room, playerId)) return;
const seat = room.seatOf(playerId); const seat = room.seatOf(playerId);
if (!seat || seat.isBot || seat.left || room.disposed) return; if (!seat || seat.isBot || seat.left || room.disposed) return;
if (room.match) { if (room.match) {
@@ -796,6 +874,20 @@ export class Lobby {
else this.broadcastState(room); else this.broadcastState(room);
} }
/**
* Free a spectator seat (removeMember): the match forgets the spectator; never a host change or a disposal — a
* spectator neither holds the host nor keeps a room alive. @returns {boolean} true when it was a spectator seat
*/
freeSpectatorSeat(room, playerId) {
const i = room.spectators.findIndex((s) => s.playerId === playerId);
if (i < 0) return false;
room.spectators.splice(i, 1);
if (room.disposed) return true;
this.callMatch(room, 'removeSpectator', playerId);
this.broadcastState(room);
return true;
}
/** Lowest-seat connected human becomes host (else lowest-seat human, else null). */ /** Lowest-seat connected human becomes host (else lowest-seat human, else null). */
migrateHost(room) { migrateHost(room) {
const humans = room.activeHumans(); const humans = room.activeHumans();
@@ -811,7 +903,7 @@ export class Lobby {
const t = setTimeout(() => { const t = setTimeout(() => {
this.graceTimers.delete(playerId); this.graceTimers.delete(playerId);
if (room.disposed || room.match) return; if (room.disposed || room.match) return;
const s = room.seatOf(playerId); const s = room.seatOf(playerId) || room.spectatorOf(playerId);
if (!s || s.connected) return; if (!s || s.connected) return;
const session = this.registry.byId(playerId); const session = this.registry.byId(playerId);
if (session && session.roomCode === room.code) { if (session && session.roomCode === room.code) {
@@ -852,6 +944,15 @@ export class Lobby {
if (session.connected) sendSession(session, { t: 'room.closed', reason }); if (session.connected) sendSession(session, { t: 'room.closed', reason });
else session.notice = reason; else session.notice = reason;
} }
// spectators did not leave: they are told whatever closed the room (its last human leaving included)
for (const s of room.spectators) {
this.clearGrace(s.playerId);
const session = this.registry.byId(s.playerId);
if (!session || session.roomCode !== room.code) continue;
session.roomCode = null;
if (session.connected) sendSession(session, { t: 'room.closed', reason });
else session.notice = reason;
}
if (ctx) this.disposeMatchCtx(ctx); if (ctx) this.disposeMatchCtx(ctx);
this.log.info(`[lobby] ${room.code} disposed (${reason})`); this.log.info(`[lobby] ${room.code} disposed (${reason})`);
} }
@@ -869,9 +970,9 @@ export class Lobby {
// Sending // Sending
// --------------------------------------------------------------------------------------------------- // ---------------------------------------------------------------------------------------------------
/** Connected, non-departed human sessions of a room. */ /** Connected, non-departed human sessions of a room — its spectators included (room.state, match broadcasts). */
*memberSessions(room) { *memberSessions(room) {
for (const s of room.seats) { for (const s of [...room.seats, ...room.spectators]) {
if (!s || s.isBot || s.left) continue; if (!s || s.isBot || s.left) continue;
const session = this.registry.byId(s.playerId); const session = this.registry.byId(s.playerId);
if (session && session.connected && session.roomCode === room.code) yield session; if (session && session.connected && session.roomCode === room.code) yield session;
@@ -901,7 +1002,7 @@ export class Lobby {
/** Match unicast. @returns {boolean} */ /** Match unicast. @returns {boolean} */
sendToPlayer(room, playerId, msg) { sendToPlayer(room, playerId, msg) {
if (room.disposed) return false; if (room.disposed) return false;
const seat = room.seatOf(playerId); const seat = room.seatOf(playerId) || room.spectatorOf(playerId);
if (!seat || seat.isBot || seat.left) return false; if (!seat || seat.isBot || seat.left) return false;
const session = this.registry.byId(playerId); const session = this.registry.byId(playerId);
if (!session || session.roomCode !== room.code) return false; if (!session || session.roomCode !== room.code) return false;
+105 -22
View File
@@ -18,6 +18,8 @@
// Bot playerIds start with 'ai_'. Seat indexes may have gaps (e.g. seats 0 and 2). // Bot playerIds start with 'ai_'. Seat indexes may have gaps (e.g. seats 0 and 2).
// `loadout` (DESIGN §16, optional): the human's operator loadout, already checked by the lobby // `loadout` (DESIGN §16, optional): the human's operator loadout, already checked by the lobby
// (shared/protocol.js checkLoadout); PlayerState re-checks it against opts.data and ignores it for bots. // (shared/protocol.js checkLoadout); PlayerState re-checks it against opts.data and ignores it for bots.
// opts.spectators string[] (optional) the room's spectator seats (remake feature, community report #26;
// server/lobby.js): never players — see addSpectator below
// opts.seed uint32 master seed for all match randomness // opts.seed uint32 master seed for all match randomness
// opts.matchNo integer ≥ 1 (optional) the room's match number (lobby: room.matchCount + 1); with the seed it // opts.matchNo integer ≥ 1 (optional) the room's match number (lobby: room.matchCount + 1); with the seed it
// makes this match's battleIds unique within the room (DESIGN §14) // makes this match's battleIds unique within the room (DESIGN §14)
@@ -53,6 +55,15 @@
// treat as quit (AI takes over / eliminated per DESIGN). No onDisconnect follows. // treat as quit (AI takes over / eliminated per DESIGN). No onDisconnect follows.
// dispose() Stop every timer/interval and release resources. Idempotent. After dispose the // dispose() Stop every timer/interval and release resources. Idempotent. After dispose the
// platform ignores send/broadcast/onEnd from this instance. // platform ignores send/broadcast/onEnd from this instance.
// 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
// 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
// nothing else of it).
// removeSpectator(id) The spectator left (room.leave / g.leave, removed by the host, reconnect window expired).
// //
// Bot seats never produce intents or hooks: the match drives bots itself (server/match/bot.js). // Bot seats never produce intents or hooks: the match drives bots itself (server/match/bot.js).
// Messages the match emits are the S2C 'm.*' / 'b.*' frames of DESIGN §8.2 (room.* frames are platform-owned). // Messages the match emits are the S2C 'm.*' / 'b.*' frames of DESIGN §8.2 (room.* frames are platform-owned).
@@ -296,6 +307,13 @@ export class Match {
} }
if (!this.players.size) throw new TypeError('Match: seats required'); if (!this.players.size) throw new TypeError('Match: seats required');
this.order = [...this.players.values()].sort((a, b) => a.seat - b.seat); this.order = [...this.players.values()].sort((a, b) => a.seat - b.seat);
/**
* Spectator seats (opts.spectators / addSpectator): playerId → a stand-in every watch path treats like an eliminated
* human (alive false; no PlayerState, never a field's player or authority).
* @type {Map<string, { playerId: string, isBot: false, left: false, alive: false, connected: true, spectator: true }>}
*/
this.spectators = new Map();
for (const id of Array.isArray(opts.spectators) ? opts.spectators : []) this._spectator(id);
/** /**
* Exactly one human seat at the start (独立模拟, or a 同盟 room started alone / with AI teammates only): nobody waits * Exactly one human seat at the start (独立模拟, or a 同盟 room started alone / with AI teammates only): nobody waits
* on anybody, so no phase outside a battle is timed — soloUntimed (user playtest #4 item 3). The mode's own rules * on anybody, so no phase outside a battle is timed — soloUntimed (user playtest #4 item 3). The mode's own rules
@@ -388,8 +406,10 @@ export class Match {
* @returns {{ ok: true } | { error: string, detail?: string }} * @returns {{ ok: true } | { error: string, detail?: string }}
*/ */
handle(playerId, msg) { handle(playerId, msg) {
const ps = this.players.get(playerId); const ps = this.players.get(playerId) || this.spectators.get(playerId);
if (!ps || ps.isBot || ps.left) return fail(ERR.NOT_IN_ROOM); if (!ps || ps.isBot || ps.left) return fail(ERR.NOT_IN_ROOM);
// a spectator seat only watches (the platform routes nothing else of it)
if (ps.spectator && (!msg || msg.t !== 'g.watch')) return fail(ERR.SPECTATOR);
if (this.disposed || this.ended) { if (this.disposed || this.ended) {
// a battle report that crossed the match end (the last b.progress of a field) is stale: ignored, never an error // a battle report that crossed the match end (the last b.progress of a field) is stale: ignored, never an error
// (DESIGN §14 — an error frame without a rid would surface as a toast in the browser) // (DESIGN §14 — an error frame without a rid would surface as a toast in the browser)
@@ -445,20 +465,65 @@ export class Match {
this.guard(() => { this.guard(() => {
const was = ps.connected; const was = ps.connected;
ps.connected = true; ps.connected = true;
this._resync(ps);
if (!was) this.markPublic();
});
}
/**
* 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.
*/
_resync(ps) {
const playerId = ps.playerId;
this.sendTo(playerId, this.publicView()); this.sendTo(playerId, this.publicView());
if (!this.ended) { if (!this.ended) {
if (!ps.spectator) {
ps._lastPriv = null; ps._lastPriv = null;
this._sendPrivate(ps, true); this._sendPrivate(ps, true);
}
if (this.clientCombat) this._resendBattle(ps); if (this.clientCombat) this._resendBattle(ps);
else { else {
const fid = this.watchers.get(playerId); 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) this._sendField(playerId, fid); if (fid) this._sendField(playerId, fid);
} }
} else if (this.lastResultMsg) { } else if (this.lastResultMsg) {
this.sendTo(playerId, { ...this.lastResultMsg, playerId }); this.sendTo(playerId, { ...this.lastResultMsg, playerId });
} }
if (!was) this.markPublic(); }
});
/**
* A spectator seat (community report #26; server/lobby.js spectate) joined during the match, came back or asked for a
* resync: registered once, then resent what an eliminated player watching sees (_resync — never an m.private).
*/
addSpectator(playerId) {
if (this.disposed) return;
const s = this._spectator(playerId);
if (s) this.guard(() => this._resync(s));
}
/** The spectator left (room.leave / g.leave, removed by the host, reconnect window expired). */
removeSpectator(playerId) {
if (this.spectators.delete(playerId)) this.watchers.delete(playerId);
}
/** The stand-in of a spectator seat, created once (null for a player's id or a bad id). */
_spectator(playerId) {
if (typeof playerId !== 'string' || !playerId || this.players.has(playerId)) return null;
let s = this.spectators.get(playerId);
if (!s) {
s = Object.freeze({ playerId, isBot: false, left: false, alive: false, connected: true, spectator: true });
this.spectators.set(playerId, s);
}
return s;
}
/** Everyone shown fields: the seated humans still in (seat order), then the spectator seats' stand-ins. */
_viewers() {
const out = this.order.filter((ps) => !ps.isBot && !ps.left);
for (const s of this.spectators.values()) out.push(s);
return out;
} }
onLeave(playerId) { onLeave(playerId) {
@@ -668,7 +733,7 @@ export class Match {
sendTo(playerId, msg) { sendTo(playerId, msg) {
if (this.disposed) return false; if (this.disposed) return false;
const ps = this.players.get(playerId); const ps = this.players.get(playerId) || this.spectators.get(playerId);
if (!ps || ps.isBot || ps.left) return false; if (!ps || ps.isBot || ps.left) return false;
try { return !!this.sendFn(playerId, msg); } catch (e) { this.reportError('send', e); return false; } try { return !!this.sendFn(playerId, msg); } catch (e) { this.reportError('send', e); return false; }
} }
@@ -1772,8 +1837,7 @@ export class Match {
_defaultWatch() { _defaultWatch() {
this.watchers.clear(); this.watchers.clear();
for (const ps of this.order) { for (const ps of this._viewers()) {
if (ps.isBot || ps.left) continue;
const own = this.fields.find((f) => f.players.includes(ps.playerId)); const own = this.fields.find((f) => f.players.includes(ps.playerId));
const f = own || this.fields[0]; const f = own || this.fields[0];
if (!f) continue; if (!f) continue;
@@ -2076,10 +2140,30 @@ export class Match {
return best ? best.playerId : null; return best ? best.playerId : null;
} }
/**
* The spec a spectator seat is shown: the field's own, minus the players' `contentInfo.funds` — a private number (the
* player's funds at the battle start) that no battle effect reads, so the replica still plays the same battle. The
* other contentInfo counters stay: battle effects read them (sim/content: handUnits, roundStats.gainedChess).
*/
_spectatorSpec(f) {
if (!f.spectatorSpec) {
const s = f.spec;
const strip = (p) => {
if (!p || !p.contentInfo || !Object.hasOwn(p.contentInfo, 'funds')) return p;
const { funds, ...contentInfo } = p.contentInfo;
void funds;
return { ...p, contentInfo };
};
f.spectatorSpec = s && Array.isArray(s.players) ? { ...s, players: s.players.map(strip) } : s;
}
return f.spectatorSpec;
}
/** b.start of a field for one recipient (`watch`: not a player of the field). */ /** b.start of a field for one recipient (`watch`: not a player of the field). */
_startMsg(f, pid, { watch = false } = {}) { _startMsg(f, pid, { watch = false } = {}) {
return { return {
t: 'b.start', battleId: f.battleId, fieldId: f.fieldId, kind: f.kind, spec: f.spec, t: 'b.start', battleId: f.battleId, fieldId: f.fieldId, kind: f.kind,
spec: this.spectators.has(pid) ? this._spectatorSpec(f) : f.spec,
authoritative: !!(!f.done && f.mode === 'client' && f.authority === pid && !watch), authoritative: !!(!f.done && f.mode === 'client' && f.authority === pid && !watch),
startAt: f.startAt, serverNow: this.sched.now(), elapsed: Math.round(this._fieldElapsed(f) * 1000) / 1000, startAt: f.startAt, serverNow: this.sched.now(), elapsed: Math.round(this._fieldElapsed(f) * 1000) / 1000,
speed: this.gameSpeed, watch: !!watch, done: !!f.done, speed: this.gameSpeed, watch: !!watch, done: !!f.done,
@@ -2087,17 +2171,17 @@ export class Match {
} }
_sendStart(pid, f, opts = {}) { _sendStart(pid, f, opts = {}) {
const ps = this.players.get(pid); const ps = this.players.get(pid) || this.spectators.get(pid);
if (!ps || ps.isBot || ps.left || !ps.connected) return false; if (!ps || ps.isBot || ps.left || !ps.connected) return false;
return this.sendTo(pid, this._startMsg(f, pid, opts)); return this.sendTo(pid, this._startMsg(f, pid, opts));
} }
/** Humans currently shown a field (its players and its watchers). */ /** Humans currently shown a field (its players and its watchers, spectator seats included). */
_humansShowing(f) { _humansShowing(f) {
const out = new Set(); const out = new Set();
for (const pid of f.players) out.add(pid); for (const pid of f.players) out.add(pid);
for (const [pid, fid] of this.watchers) if (fid === f.fieldId) out.add(pid); for (const [pid, fid] of this.watchers) if (fid === f.fieldId) out.add(pid);
return [...out].filter((pid) => { const ps = this.players.get(pid); return ps && !ps.isBot && !ps.left; }); return [...out].filter((pid) => { const ps = this.players.get(pid) || this.spectators.get(pid); return ps && !ps.isBot && !ps.left; });
} }
/** Give every field its authority (a connected human) or run it on the server. */ /** Give every field its authority (a connected human) or run it on the server. */
@@ -2246,8 +2330,8 @@ export class Match {
const limit = this.wave ? this.wave.timeLimit : 60; const limit = this.wave ? this.wave.timeLimit : 60;
this.deadline = this.sched.instant ? 0 : this.sched.now() + Math.round((limit / this.gameSpeed) * 1000); this.deadline = this.sched.instant ? 0 : this.sched.now() + Math.round((limit / this.gameSpeed) * 1000);
this._launch(fields); this._launch(fields);
// every fighting human runs its own field; eliminated humans keep watching (research 09 §3.1 "Keep-watching // every fighting human runs its own field; eliminated humans (and spectator seats) keep watching (research 09 §3.1
// auto-observes the first available field", switching freely with 前往查看): a display replica of the first field // "Keep-watching auto-observes the first available field", switching freely with 前往查看): a replica of the first field
for (const f of fields) for (const pid of f.players) { for (const f of fields) for (const pid of f.players) {
const ps = this.players.get(pid); const ps = this.players.get(pid);
if (!ps || ps.isBot || ps.left) continue; if (!ps || ps.isBot || ps.left) continue;
@@ -2256,8 +2340,8 @@ export class Match {
} }
const first = fields.find((f) => f.mode === 'client') || fields[0] || null; const first = fields.find((f) => f.mode === 'client') || fields[0] || null;
if (first) { if (first) {
for (const ps of this.order) { for (const ps of this._viewers()) {
if (ps.isBot || ps.left || ps.alive || this.watchers.has(ps.playerId)) continue; if (ps.alive || this.watchers.has(ps.playerId)) continue;
this.watchers.set(ps.playerId, first.fieldId); this.watchers.set(ps.playerId, first.fieldId);
this._sendStart(ps.playerId, first, { watch: true }); this._sendStart(ps.playerId, first, { watch: true });
} }
@@ -2273,9 +2357,8 @@ export class Match {
this.deadline = this.sched.instant ? 0 : this.sched.now() + Math.round((limit / this.gameSpeed) * 1000); this.deadline = this.sched.instant ? 0 : this.sched.now() + Math.round((limit / this.gameSpeed) * 1000);
this.watchers.clear(); this.watchers.clear();
this._launch([f]); this._launch([f]);
// helpers and everyone else (as observers) simulate the same 联防 spec locally // helpers and everyone else (as observers, spectator seats included) simulate the same 联防 spec locally
for (const ps of this.order) { for (const ps of this._viewers()) {
if (ps.isBot || ps.left) continue;
this.watchers.set(ps.playerId, 'u'); this.watchers.set(ps.playerId, 'u');
this._sendStart(ps.playerId, f, { watch: !f.players.includes(ps.playerId) }); this._sendStart(ps.playerId, f, { watch: !f.players.includes(ps.playerId) });
} }
@@ -2490,10 +2573,9 @@ export class Match {
this._bossClock = this.later(BOSS_CLOCK_MS, () => this._bossClockTick()); this._bossClock = this.later(BOSS_CLOCK_MS, () => this._bossClockTick());
} }
/** b.start of the boss fields: players get their own pair field, eliminated humans the first field. */ /** b.start of the boss fields: players get their own pair field, eliminated humans and spectator seats the first. */
_watchBossFields(fields) { _watchBossFields(fields) {
for (const ps of this.order) { for (const ps of this._viewers()) {
if (ps.isBot || ps.left) continue;
const own = fields.find((f) => f.players.includes(ps.playerId)) || null; const own = fields.find((f) => f.players.includes(ps.playerId)) || null;
const f = own || fields[0]; const f = own || fields[0];
if (!f) continue; if (!f) continue;
@@ -3065,7 +3147,8 @@ export class Match {
this.lastResultMsg = result; this.lastResultMsg = result;
this.markPublic(); this.markPublic();
try { this.flush(true); } catch (e) { this.reportError('flush', e); } try { this.flush(true); } catch (e) { this.reportError('flush', e); }
for (const ps of this.order) if (!ps.isBot && !ps.left) this.sendTo(ps.playerId, { ...result, playerId: ps.playerId }); // every human still here gets the settlement — the spectator seats the same public rows (none of them their own)
for (const ps of this._viewers()) this.sendTo(ps.playerId, { ...result, playerId: ps.playerId });
const { t, ...summary } = result; const { t, ...summary } = result;
void t; void t;
summary.errors = this.errorCount; summary.errors = this.errorCount;
+4 -3
View File
@@ -59,10 +59,11 @@ export const NET_DEFAULTS = Object.freeze({
}); });
/** /**
* Intents that also draw from the per-connection heavy bucket: g.watch (its reply is a large state resend, m.field) and * Intents that also draw from the per-connection heavy bucket: g.watch (its reply is a large state resend, m.field),
* room.loadout (a ≤ 160-entry map validated against the game data; the client debounces its edits). * room.loadout (a ≤ 160-entry map validated against the game data; the client debounces its edits) and room.spectate
* (taking a spectator seat in a running match resends its state like a watcher's g.watch — server/lobby.js spectate).
*/ */
export const HEAVY_TYPES = new Set(['g.watch', 'room.loadout']); export const HEAVY_TYPES = new Set(['g.watch', 'room.loadout', 'room.spectate']);
/** Close codes (see header). */ /** Close codes (see header). */
export const CLOSE = Object.freeze({ REPLACED: 4001, HELLO_TIMEOUT: 4002, POLICY: 1008, SHUTDOWN: 1001 }); export const CLOSE = Object.freeze({ REPLACED: 4001, HELLO_TIMEOUT: 4002, POLICY: 1008, SHUTDOWN: 1001 });
+9 -1
View File
@@ -6,6 +6,13 @@ export const PROTOCOL_VERSION = 1;
export const APP_VERSION = '0.1.2'; export const APP_VERSION = '0.1.2';
export const MAX_SEATS = 4; export const MAX_SEATS = 4;
/**
* Spectator seats of a co-op room (community report #26, owner's decision 2026-10-04) — a remake feature: the official
* room has 1–4 players and no spectator seat (there only eliminated players and 联防 bystanders watch, research 09 §3.1).
* A spectator never counts as a player, may not act, and watches like an eliminated player (server/lobby.js spectate,
* server/match/Match.js addSpectator).
*/
export const MAX_SPECTATORS = 2;
export const ROOM_CODE_LEN = 4; export const ROOM_CODE_LEN = 4;
export const NAME_MAX_LEN = 12; export const NAME_MAX_LEN = 12;
@@ -133,6 +140,7 @@ export const ERR = Object.freeze({
ALREADY: 'ALREADY', ALREADY: 'ALREADY',
TEMP_NOT_EMPTY: 'TEMP_NOT_EMPTY', TEMP_NOT_EMPTY: 'TEMP_NOT_EMPTY',
ELIMINATED: 'ELIMINATED', ELIMINATED: 'ELIMINATED',
SPECTATOR: 'SPECTATOR', // a spectator seat only watches (MAX_SPECTATORS)
INTERNAL: 'INTERNAL', INTERNAL: 'INTERNAL',
}); });
@@ -142,7 +150,7 @@ export const ERR_TEXT = {
WRONG_PHASE: '当前阶段无法进行该操作', NO_FUNDS: '资金不足', HAND_FULL: '整备区已满', BOARD_FULL: '已达到部署上限', WRONG_PHASE: '当前阶段无法进行该操作', NO_FUNDS: '资金不足', HAND_FULL: '整备区已满', BOARD_FULL: '已达到部署上限',
BAD_TILE: '无法部署在该位置', BAD_TARGET: '无效的目标', SOLD_OUT: '已售出', MAX_LEVEL: '调度中心已达最高等级', BAD_TILE: '无法部署在该位置', BAD_TARGET: '无效的目标', SOLD_OUT: '已售出', MAX_LEVEL: '调度中心已达最高等级',
NOT_YOUR_TURN: '尚未轮到你', ALREADY: '已完成该操作', TEMP_NOT_EMPTY: '临时整备区不为空', ELIMINATED: '你已被淘汰', NOT_YOUR_TURN: '尚未轮到你', ALREADY: '已完成该操作', TEMP_NOT_EMPTY: '临时整备区不为空', ELIMINATED: '你已被淘汰',
INTERNAL: '服务器内部错误', SPECTATOR: '观战中无法进行该操作', INTERNAL: '服务器内部错误',
}; };
// ---- Emotes (交流, research 09 §4) ----------------------------------------------------------------------------- // ---- Emotes (交流, research 09 §4) -----------------------------------------------------------------------------
+5
View File
@@ -251,6 +251,11 @@ export const C2S = {
'room.start': {}, 'room.start': {},
// operator loadout (DESIGN §16): stored per session/seat; accepted until the match leaves INFO_CHECK // operator loadout (DESIGN §16): stored per session/seat; accepted until the match leaves INFO_CHECK
'room.loadout': { entries: isLoadoutEntries }, 'room.loadout': { entries: isLoadoutEntries },
// spectator seats (remake feature, community report #26; MAX_SPECTATORS): take one of a co-op room's spectator seats —
// in its lobby or while its match runs — never a player seat; the host frees one by playerId (the spectator gets
// room.closed { reason: 'kicked' }). room.leave / g.leave leave a spectator seat like a player seat.
'room.spectate': { code: (v) => isStr(v, ROOM_CODE_LEN + 2) && /^[A-Za-z0-9]+$/.test(v) },
'room.removeSpectator': { playerId: isId },
// match // match
'g.infoReady': {}, 'g.infoReady': {},
+25
View File
@@ -1047,6 +1047,31 @@ describe('screen helpers', () => {
assert.match(inviteLink('ABCD'), /\?room=ABCD$/); assert.match(inviteLink('ABCD'), /\?room=ABCD$/);
}); });
test('room: spectator seats (community report #26) — isSpectating; roomFacts never counts a spectator as a player', async () => {
const { roomFacts } = await mod('screens/room.js');
const { isSpectating } = await mod('store.js');
const room = {
code: 'ABCD', hostId: 'h', mode: 'coop', difficulty: 'HARD',
seats: [{ seat: 0, playerId: 'h', name: 'Host', isBot: false, ready: false, connected: true }, null, null, null],
spectators: [{ playerId: 's', name: 'Spec', connected: true }],
};
assert.equal(isSpectating(room, 's'), true);
assert.equal(isSpectating(room, 'h'), false);
assert.equal(isSpectating({ ...room, spectators: undefined }, 's'), false, 'a room.state without the list');
assert.equal(isSpectating(room, null), false);
assert.equal(isSpectating(null, 's'), false);
const f = roomFacts(room, 's');
assert.equal(f.spectating, true);
assert.equal(f.mine, null);
assert.equal(f.humans.length, 1, 'never a player');
assert.equal(f.emptySeats, 3, 'a free player seat stays free (入座)');
assert.deepEqual(f.spectators.map((x) => x.playerId), ['s']);
const hf = roomFacts(room, 'h');
assert.equal(hf.canStart, true, 'a spectator never blocks the start');
assert.equal(hf.spectating, false);
assert.deepEqual(roomFacts({ ...room, spectators: 'bad' }, 'h').spectators, []);
});
test('components: roman / doctorNo / secondsLeft', async () => { test('components: roman / doctorNo / secondsLeft', async () => {
const { roman, doctorNo, secondsLeft } = await mod('ui/components.js'); const { roman, doctorNo, secondsLeft } = await mod('ui/components.js');
assert.deepEqual([1, 2, 3, 4, 5, 6].map(roman), ['I', 'II', 'III', 'IV', 'V', 'VI']); assert.deepEqual([1, 2, 3, 4, 5, 6].map(roman), ['I', 'II', 'III', 'IV', 'V', 'VI']);
+179 -2
View File
@@ -19,7 +19,7 @@ import { sanitizeName, TokenBucket, SessionRegistry, clientAddress, normalizeIp,
import { StubMatch as Match } from '../server/match/StubMatch.js'; import { StubMatch as Match } from '../server/match/StubMatch.js';
import { Match as RealMatch } from '../server/match/Match.js'; import { Match as RealMatch } from '../server/match/Match.js';
import { TestClient } from './helpers/wsClient.js'; import { TestClient } from './helpers/wsClient.js';
import { ERR, MAX_SEATS, PHASE } from '../shared/constants.js'; import { ERR, MAX_SEATS, MAX_SPECTATORS, PHASE, EMOTES } from '../shared/constants.js';
const CODE_RE = new RegExp(`^[${CODE_ALPHABET}]{4}$`); const CODE_RE = new RegExp(`^[${CODE_ALPHABET}]{4}$`);
const ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..'); const ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..');
@@ -606,6 +606,121 @@ describe('websocket lobby', () => {
await expectError(solo, { t: 'room.addBot' }, ERR.ROOM_FULL); await expectError(solo, { t: 'room.addBot' }, ERR.ROOM_FULL);
}); });
// community report #26 (a remake feature): spectator seats — server/lobby.js header
test('spectator seats: join / cap / leave / host removal; never a player, never acting, never keeping a room', async () => {
const host = await pool.player('Host');
const st = await createRoom(host);
assert.deepEqual(st.spectators, []);
const guest = await pool.player('Guest');
await joinRoom(guest, st.code);
const s1 = await pool.player('Watcher1');
await expectOk(s1, { t: 'room.spectate', code: st.code.toLowerCase() });
const v1 = await s1.waitFor('room.state', (s) => s.spectators.some((x) => x.playerId === s1.id));
assert.deepEqual(v1.spectators, [{ playerId: s1.id, name: 'Watcher1', connected: true }]);
assert.ok(!v1.seats.some((x) => x && x.playerId === s1.id), 'never in a player seat');
await host.waitFor('room.state', (s) => s.spectators.length === 1);
await expectOk(s1, { t: 'room.spectate', code: st.code }); // idempotent
await expectError(guest, { t: 'room.spectate', code: st.code }, ERR.ALREADY); // a player never switches in place
// a spectator may not act
await expectError(s1, { t: 'room.ready', ready: true }, ERR.SPECTATOR);
for (const msg of [{ t: 'room.start' }, { t: 'room.addBot' }, { t: 'room.setDifficulty', difficulty: 'HARD' }, { t: 'room.removeSpectator', playerId: s1.id }]) {
await expectError(s1, msg, ERR.NOT_HOST);
}
await expectError(s1, { t: 'g.ready', ready: true }, ERR.WRONG_PHASE);
// the cap; spectators never count toward the players: two player seats still free
const s2 = await pool.player('Watcher2');
await expectOk(s2, { t: 'room.spectate', code: st.code });
assert.equal(MAX_SPECTATORS, 2);
const s3 = await pool.player('Watcher3');
await expectError(s3, { t: 'room.spectate', code: st.code }, ERR.ROOM_FULL);
await joinRoom(s3, st.code);
await expectOk(host, { t: 'room.addBot' });
const full = await host.waitFor('room.state', (s) => s.seats.every(Boolean));
assert.equal(full.spectators.length, 2);
await expectOk(s3, { t: 'room.ready', ready: true });
await expectOk(guest, { t: 'room.ready', ready: true });
// a spectator in the lobby takes a free player seat with room.join of the same code
await expectError(s2, { t: 'room.join', code: st.code }, ERR.ROOM_FULL);
await expectOk(host, { t: 'room.removeBot', seat: 3 });
const sat = await joinRoom(s2, st.code);
assert.equal(seatOf(sat, s2.id).seat, 3);
assert.deepEqual(sat.spectators.map((x) => x.playerId), [s1.id]);
// leave
host.clearInbox();
await expectOk(s1, { t: 'room.leave' });
await host.waitFor('room.state', (s) => s.spectators.length === 0);
await expectError(s1, { t: 'room.leave' }, ERR.NOT_IN_ROOM);
await expectError(s1, { t: 'room.ready', ready: true }, ERR.NOT_IN_ROOM);
// host removal: room.closed {kicked}
const s4 = await pool.player('Watcher4');
await expectOk(s4, { t: 'room.spectate', code: st.code });
await host.waitFor('room.state', (s) => s.spectators.length === 1);
await expectError(host, { t: 'room.removeSpectator', playerId: guest.id }, ERR.BAD_TARGET);
await expectError(guest, { t: 'room.removeSpectator', playerId: s4.id }, ERR.NOT_HOST);
await expectOk(host, { t: 'room.removeSpectator', playerId: s4.id });
assert.equal((await s4.waitFor('room.closed')).reason, 'kicked');
await host.waitFor('room.state', (s) => s.spectators.length === 0);
await expectError(s4, { t: 'room.leave' }, ERR.NOT_IN_ROOM);
// an offline spectator removed: freed at once, told on its next resume
const s5 = await pool.player('Watcher5');
await expectOk(s5, { t: 'room.spectate', code: st.code });
await s5.terminate();
await host.waitFor('room.state', (s) => s.spectators.find((x) => x.playerId === s5.id)?.connected === false);
await expectOk(host, { t: 'room.removeSpectator', playerId: s5.id });
const s5back = await pool.connect();
await s5back.hello('Watcher5', s5.token);
assert.equal((await s5back.waitFor('room.closed')).reason, 'kicked');
await s5back.expectNone('room.state');
// solo rooms have no spectator seat; unknown codes
const solo = await pool.player('Solo');
const soloSt = await createRoom(solo, 'solo', 'FUNNY');
await expectError(s4, { t: 'room.spectate', code: soloSt.code }, ERR.ROOM_FULL);
await expectError(s4, { t: 'room.spectate', code: st.code === 'ZZZZ' ? 'YYYY' : 'ZZZZ' }, ERR.ROOM_NOT_FOUND);
// a spectator never keeps a room alive: its last human leaving closes it for the spectator
const lone = await pool.player('Lone');
const loneSt = await createRoom(lone);
await expectOk(s4, { t: 'room.spectate', code: loneSt.code });
await lone.waitFor('room.state', (s) => s.spectators.length === 1);
await expectOk(lone, { t: 'room.leave' });
assert.equal((await s4.waitFor('room.closed')).reason, 'empty');
await expectError(s4, { t: 'room.spectate', code: loneSt.code }, ERR.ROOM_NOT_FOUND);
});
test('spectators during a match: broadcasts but no private view, join mid-match, only watching, reconnect gets the seat back', async () => {
const host = await pool.player('Host');
const st = await createRoom(host);
const spec = await pool.player('Spec');
await expectOk(spec, { t: 'room.spectate', code: st.code });
await expectOk(host, { t: 'room.start' }); // the spectator is no player to wait for
const pub = await spec.waitFor('m.public');
assert.deepEqual(pub.players.map((p) => p.playerId), [host.id], 'never one of the match\'s players');
const late = await pool.player('Late');
await expectError(late, { t: 'room.join', code: st.code }, ERR.ROOM_STARTED);
await expectOk(late, { t: 'room.spectate', code: st.code }); // a running match: a spectator seat is still free
const lateState = await late.waitFor('room.state', (s) => s.inMatch);
assert.deepEqual(lateState.spectators.map((x) => x.playerId), [spec.id, late.id]);
for (const msg of [{ t: 'g.infoReady' }, { t: 'g.buy', slot: 0 }, { t: 'g.ready', ready: true }, { t: 'g.emote', id: EMOTES[0] }, { t: 'g.autoplay', on: true }]) {
await expectError(spec, msg, ERR.SPECTATOR);
}
await expectOk(spec, { t: 'room.loadout', entries: {} }); // kept for its session, never handed to the match
// drop and resume with the token: the seat is kept meanwhile and given back
await late.terminate();
await host.waitFor('room.state', (s) => s.spectators.find((x) => x.playerId === late.id)?.connected === false);
const back = await pool.connect();
const w = await back.hello('Late', late.token);
assert.equal(w.resumed, true);
const rs = await back.waitFor('room.state');
assert.deepEqual(rs.spectators.find((x) => x.playerId === late.id), { playerId: late.id, name: 'Late', connected: true });
await expectError(back, { t: 'g.infoReady' }, ERR.SPECTATOR);
// the match ends: the spectators see the result (a broadcast) and stay in the room, which returns to its lobby
await expectOk(host, { t: 'g.infoReady' });
await spec.waitFor('m.result');
const lobby = await spec.waitFor('room.state', (s) => !s.inMatch && s.spectators.length === 2);
assert.deepEqual(lobby.spectators.map((x) => x.playerId), [spec.id, late.id]);
assert.equal(spec.log.filter((x) => x.t === 'm.private').length, 0, 'no private view, ever');
assert.equal(back.log.filter((x) => x.t === 'm.private').length, 0);
});
test('host-only commands, difficulty change un-readies guests, host migration on leave', async () => { test('host-only commands, difficulty change un-readies guests, host migration on leave', async () => {
const host = await pool.player('Host'); const host = await pool.player('Host');
const st = await createRoom(host, 'coop', 'NORMAL'); const st = await createRoom(host, 'coop', 'NORMAL');
@@ -873,6 +988,12 @@ describe('websocket lobby', () => {
if (m.t === 'g.emote' && rnd() < 0.5) m.id = 'autochess_battle_happy'; if (m.t === 'g.emote' && rnd() < 0.5) m.id = 'autochess_battle_happy';
return m; return m;
} }
// spectator seats (community report #26)
if (r < 0.95) {
return rnd() < 0.7
? { t: 'room.spectate', code: pick([...codes(), 'ZZZZ', ...codes().map((c) => c.toLowerCase())]) || 'QQQQ' }
: { t: 'room.removeSpectator', playerId: pick([...clients.map((x) => x.id), 'p_nope', 1]) };
}
return pick([{ t: 'hello', name: pick(['Re', '', 'x'.repeat(20)]) }, { t: 'ping', c: pick([1, 'x']) }, { t: pick(['nope', '__proto__', 'toString']) }, { t: 'room.join', code: pick(junk) }]); return pick([{ t: 'hello', name: pick(['Re', '', 'x'.repeat(20)]) }, { t: 'ping', c: pick([1, 'x']) }, { t: pick(['nope', '__proto__', 'toString']) }, { t: 'room.join', code: pick(junk) }]);
}; };
const okCounts = {}; const okCounts = {};
@@ -910,9 +1031,20 @@ describe('websocket lobby', () => {
assert.ok(sess, 'seated session exists'); assert.ok(sess, 'seated session exists');
assert.equal(sess.roomCode, room.code); assert.equal(sess.roomCode, room.code);
} }
// spectator seats: capped, none in solo rooms, never also a player (here or elsewhere), never the host
assert.ok(room.spectators.length <= MAX_SPECTATORS, `room ${room.code} spectator cap`);
if (room.mode === 'solo') assert.equal(room.spectators.length, 0, 'no spectator seat in a solo room');
for (const sp of room.spectators) {
assert.ok(!seen.has(sp.playerId), 'a spectator is seated (or spectating) twice');
seen.add(sp.playerId);
assert.ok(!room.seatOf(sp.playerId) && room.hostId !== sp.playerId);
assert.equal(srv.registry.byId(sp.playerId)?.roomCode, room.code, 'the spectator\'s session points at its room');
} }
}
if (okCounts['room.spectate'] == null) assert.fail('fuzz never took a spectator seat');
for (const sess of srv.registry.all()) { for (const sess of srv.registry.all()) {
if (sess.roomCode) assert.ok(srv.lobby.rooms.get(sess.roomCode)?.seatOf(sess.playerId), 'session points at its seat'); const r = sess.roomCode ? srv.lobby.rooms.get(sess.roomCode) : null;
if (sess.roomCode) assert.ok(r?.seatOf(sess.playerId) || r?.spectatorOf(sess.playerId), 'session points at its seat');
} }
const h = JSON.parse((await httpReq(srv.port, '/healthz')).body.toString()); const h = JSON.parse((await httpReq(srv.port, '/healthz')).body.toString());
assert.equal(h.ok, true); assert.equal(h.ok, true);
@@ -1012,6 +1144,8 @@ class RecordingMatch extends Match {
onDisconnect(playerId) { this.calls.push(['onDisconnect', playerId]); super.onDisconnect(playerId); } onDisconnect(playerId) { this.calls.push(['onDisconnect', playerId]); super.onDisconnect(playerId); }
onReconnect(playerId) { this.calls.push(['onReconnect', playerId]); super.onReconnect(playerId); } onReconnect(playerId) { this.calls.push(['onReconnect', playerId]); super.onReconnect(playerId); }
onLeave(playerId) { this.calls.push(['onLeave', playerId]); super.onLeave(playerId); } onLeave(playerId) { this.calls.push(['onLeave', playerId]); super.onLeave(playerId); }
addSpectator(playerId) { this.calls.push(['addSpectator', playerId]); }
removeSpectator(playerId) { this.calls.push(['removeSpectator', playerId]); }
dispose() { this.calls.push(['dispose']); super.dispose(); } dispose() { this.calls.push(['dispose']); super.dispose(); }
} }
@@ -1063,6 +1197,49 @@ describe('lobby timers and match interface', () => {
await expectOk(guest, { t: 'room.addBot' }); await expectOk(guest, { t: 'room.addBot' });
}); });
test('spectator seats: lobby grace like a player; never the host; the match hooks (opts.spectators, addSpectator / removeSpectator, only g.watch)', async () => {
const host = await pool.player('Host');
const st = await createRoom(host);
const spec = await pool.player('Spec');
await expectOk(spec, { t: 'room.spectate', code: st.code });
// a dropped spectator keeps its seat for the lobby grace, then is freed and told room.closed {timeout} on resume
const gone = await pool.player('Gone');
await expectOk(gone, { t: 'room.spectate', code: st.code });
await gone.terminate();
await host.waitFor('room.state', (s) => s.spectators.find((x) => x.playerId === gone.id)?.connected === false);
host.clearInbox();
await host.waitFor('room.state', (s) => s.spectators.length === 1, GRACE + 1000);
const goneBack = await pool.connect();
await goneBack.hello('Gone', gone.token);
assert.equal((await goneBack.waitFor('room.closed')).reason, 'timeout');
// the match: the spectator is passed apart from the seats
RecordingMatch.instances.length = 0;
await expectOk(host, { t: 'room.start' });
const m = RecordingMatch.instances[0];
assert.deepEqual(m.opts.spectators, [spec.id]);
assert.deepEqual(m.opts.seats.map((s) => s.playerId), [host.id]);
// only g.watch of a spectator reaches the match
await expectError(spec, { t: 'g.levelUp' }, ERR.SPECTATOR);
await spec.request({ t: 'g.watch', fieldId: `n:${host.id}` });
assert.deepEqual(m.calls.filter((c) => c[0] === 'handle' && c[1] === spec.id).map((c) => c[2]), ['g.watch']);
// joining mid-match, a drop (the match is not told), a resume (resync) → addSpectator; leaving → removeSpectator
const late = await pool.player('Late');
await expectOk(late, { t: 'room.spectate', code: st.code });
await late.terminate();
await host.waitFor('room.state', (s) => s.spectators.find((x) => x.playerId === late.id)?.connected === false);
host.clearInbox();
const back = await pool.connect();
await back.hello('Late', late.token);
await host.waitFor('room.state', (s) => s.spectators.find((x) => x.playerId === late.id)?.connected === true);
await expectOk(back, { t: 'g.leave' });
await host.waitFor('room.state', (s) => s.spectators.length === 1);
const hooks = m.calls.filter((c) => c[1] === late.id).map((c) => c[0]);
assert.deepEqual(hooks, ['addSpectator', 'addSpectator', 'removeSpectator'], 'never onDisconnect / onReconnect / onLeave');
// the host leaves mid-match: a spectator never becomes the host and never keeps the room
await expectOk(host, { t: 'room.leave' });
assert.equal((await spec.waitFor('room.closed')).reason, 'empty');
});
test('match number: every match of a room gets the next matchNo (battleIds stay unique even with the same seed)', async () => { test('match number: every match of a room gets the next matchNo (battleIds stay unique even with the same seed)', async () => {
const solo = await pool.player('Solo'); const solo = await pool.player('Solo');
await createRoom(solo, 'solo', 'FUNNY'); await createRoom(solo, 'solo', 'FUNNY');
+3 -3
View File
@@ -3,8 +3,8 @@
// const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 1, bots: 1, seed: 7, fake: true }); // const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 1, bots: 1, seed: 7, fake: true });
// h.start(); h.runToPhase('PREP'); const ps = h.ps('p_0'); …; h.invariants(); // h.start(); h.runToPhase('PREP'); const ps = h.ps('p_0'); …; h.invariants();
// //
// Options: mode, difficulty, humans (count) | seats (explicit), bots, seed, matchNo (the room's match number: part of // Options: mode, difficulty, humans (count) | seats (explicit), bots, spectators (spectator seat ids, opts.spectators),
// the battleId prefix), data (default: real data/*.json), // seed, matchNo (the room's match number: part of the battleId prefix), data (default: real data/*.json),
// fake (true → test/match/fakeBattle.js as BattleClass), script (FakeBattle.script), registry, instant (virtual // fake (true → test/match/fakeBattle.js as BattleClass), script (FakeBattle.script), registry, instant (virtual
// scheduler runs battles synchronously; default true), timerScale, battleContent, botRehearsal (default 0), // scheduler runs battles synchronously; default true), timerScale, battleContent, botRehearsal (default 0),
// botSliceMs (bot rehearsal slice budget; default: unbounded in virtual time). // botSliceMs (bot rehearsal slice budget; default: unbounded in virtual time).
@@ -55,7 +55,7 @@ export function makeMatch(o = {}) {
if (o.script) FakeBattle.script = o.script; if (o.script) FakeBattle.script = o.script;
} }
h.m = new Match({ h.m = new Match({
roomCode: 'TEST', mode, difficulty, seats, seed: o.seed ?? 1, matchNo: o.matchNo, data: o.data ?? DATA, log, roomCode: 'TEST', mode, difficulty, seats, spectators: o.spectators, seed: o.seed ?? 1, matchNo: o.matchNo, data: o.data ?? DATA, log,
send: (id, msg) => { send: (id, msg) => {
for (const fn of h.onSend) fn(id, msg); for (const fn of h.onSend) fn(id, msg);
if (msg.t === 'b.snap' || msg.t === 'b.ev') { h.frames++; if (!captureFrames) return true; } if (msg.t === 'b.snap' || msg.t === 'b.ev') { h.frames++; if (!captureFrames) return true; }
+65
View File
@@ -127,6 +127,71 @@ test('co-op over websockets: two humans + AI, reconnect with the token mid-match
assert.deepEqual(errors, []); assert.deepEqual(errors, []);
}); });
// community report #26 (a remake feature): a spectator seat — server/lobby.js header, test/match/spectator.test.js
test('co-op spectator over websockets: watches the real match like an eliminated player, never a private view, cannot act, resumes', async () => {
FakeBattle.reset();
FakeBattle.script = () => ({ duration: 1 });
const watcher = async (name, token) => {
const s = await server();
const c = await TestClient.connect(`ws://127.0.0.1:${s.port}/ws`); // no simulated browser: it only watches
clients.push(c);
const w = await c.hello(name, token);
c.id = w.playerId;
c.token = w.token;
return c;
};
const a = await player('A');
await ok(a, { t: 'room.create', mode: 'coop', difficulty: 'NORMAL' });
const st = await a.waitFor('room.state');
await ok(a, { t: 'room.addBot' });
const s = await watcher('Spec');
await ok(s, { t: 'room.spectate', code: st.code });
await ok(a, { t: 'room.start' });
const info = await s.waitFor('m.public', (p) => p.phase === 'INFO_CHECK');
assert.ok(!info.players.some((p) => p.playerId === s.id), 'never a player');
for (const msg of [{ t: 'g.infoReady' }, { t: 'g.band', bandId: 'band_bldsk' }, { t: 'g.emote', id: EMOTES[0] }]) {
const r = await s.request(msg);
assert.equal(r.code, 'SPECTATOR', msg.t);
}
await ok(a, { t: 'g.infoReady' });
for (let i = 0; i < 40; i++) { // every draft frame in order: pick on the own turn (the AI picks at once)
const p = await a.waitFor('m.public', (x) => x.phase !== 'INFO_CHECK', 10000);
if (p.phase !== 'BAND_DRAFT') break;
if (p.draft && p.draft.turn === a.id && !p.draft.picks[a.id]) await ok(a, { t: 'g.band', bandId: 'band_bldsk' });
}
await a.waitFor('m.public', (p) => p.phase === 'PREP' && p.round === 1, 10000);
for (const msg of [{ t: 'g.buy', slot: 0 }, { t: 'g.ready', ready: true }, { t: 'g.refresh' }]) {
const r = await s.request(msg);
assert.equal(r.code, 'SPECTATOR', msg.t);
}
// prep: the player's board, read-only (what a teammate scouting it gets)
await ok(s, { t: 'g.watch', fieldId: `n:${a.id}` });
const scout = await s.waitFor('m.field', (f) => f.prep === true);
assert.equal(scout.fieldId, `n:${a.id}`);
await ok(a, { t: 'g.ready', ready: true });
const start = await s.waitFor('b.start', (x) => x.kind === 'normal', 10000);
assert.equal(start.watch, true);
assert.equal(start.authoritative, false);
assert.ok(!start.spec.players.some((p) => Object.hasOwn(p.contentInfo || {}, 'funds')), 'no player funds in a spectator\'s spec');
// drop and resume with the token: the seat comes back with the match state
await s.terminate();
await a.waitFor('room.state', (x) => x.spectators.some((y) => y.playerId === s.id && !y.connected));
const back = await watcher('Spec', s.token);
assert.equal(back.id, s.id);
const rs = await back.waitFor('room.state');
assert.deepEqual(rs.spectators, [{ playerId: s.id, name: 'Spec', connected: true }]);
await back.waitFor('m.public');
await a.waitFor('m.public', (p) => p.phase === 'PREP' && p.round === 2, 15000);
for (const c of [s, back]) {
assert.equal(c.log.filter((x) => x.t === 'm.private' || x.t === 'm.toast' || x.t === 'm.unitStats').length, 0, 'no private frame');
assert.ok(!c.log.some((x) => /"(funds|hand|shop|temp)":/.test(JSON.stringify(x))), 'no private player data');
}
// the last player leaves: the match ends and the room closes for its spectator
await ok(a, { t: 'g.leave' });
assert.equal((await back.waitFor('room.closed', () => true, 5000)).reason, 'empty');
assert.deepEqual(errors, []);
});
test('server-run fallback (SP_COMBAT=server): the match simulates every field and streams m.field + b.snap', async () => { test('server-run fallback (SP_COMBAT=server): the match simulates every field and streams m.field + b.snap', async () => {
FakeBattle.reset(); FakeBattle.reset();
FakeBattle.script = () => ({ duration: 1 }); FakeBattle.script = () => ({ duration: 1 });
+145
View File
@@ -0,0 +1,145 @@
// 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
// (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.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { ERR, PHASE, EMOTES } from '../../shared/constants.js';
import { makeMatch } from './harness.js';
const S = 's_spec';
/** Keys only the private player view carries (DESIGN §8.3 m.private): none may reach a spectator, at any depth. */
const PRIVATE_KEYS = new Set(['funds', 'pendingFunds', 'hand', 'temp', 'shop', 'canReady', 'rewardOffer', 'freeRefreshes', 'loadout']);
function privateKeys(v, path = '', out = []) {
if (Array.isArray(v)) v.forEach((x, i) => privateKeys(x, `${path}[${i}]`, out));
else if (v && typeof v === 'object') {
for (const [k, x] of Object.entries(v)) {
if (PRIVATE_KEYS.has(k)) out.push(`${path}.${k}`);
privateKeys(x, `${path}.${k}`, out);
}
}
return out;
}
const frames = (h, id) => h.sent.filter(([pid]) => pid === id).map(([, x]) => x);
/** The intents a player has, refused for a spectator (the platform never routes them; the match refuses them too). */
const REFUSED = [
{ t: 'g.infoReady' }, { t: 'g.buy', slot: 0 }, { t: 'g.refresh' }, { t: 'g.levelUp' }, { t: 'g.ready', ready: true },
{ t: 'g.emote', id: EMOTES[0] }, { t: 'g.autoplay', on: true }, { t: 'g.unitStats', seq: 1 }, { t: 'g.choice', idx: 0 },
{ t: 'g.band', bandId: 'band_bldsk' }, { t: 'b.progress', battleId: 'x', gt: 1, killed: 0, total: 1 },
];
test('a spectator seat watches a whole match (各自行动, 联防, 最终攻势, settlement) like an eliminated player — never a private view', () => {
const h = makeMatch({
mode: 'coop', difficulty: 'FUNNY', humans: 3, seed: 9301, fake: true, clientCombat: true, spectators: [S],
// round 1: p_0 leaks → p_1 / p_2 (perfect) hold a 联防; the Final Assault falls fast
script: (b) => (b.kind === 'boss' ? { bossDps: 20000 } : b.kind === 'normal' && b.round === 1 ? { duration: 1, leaks: { p_0: 4 } } : { duration: 1 }),
}).start();
const m = h.m;
h.autoHumans();
assert.ok(m.spectators.has(S) && !m.players.has(S), 'a spectator, never a player');
for (const msg of REFUSED) assert.deepEqual(m.handle(S, msg), { error: ERR.SPECTATOR }, msg.t);
h.toPrep(1);
for (const msg of REFUSED) assert.deepEqual(m.handle(S, msg), { error: ERR.SPECTATOR }, msg.t);
// prep: a player's board, like a teammate scouting it (m.field prep: true)
assert.deepEqual(m.handle(S, { t: 'g.watch', fieldId: 'n:p_1' }), { ok: true });
const scout = h.lastTo(S, 'm.field');
assert.equal(scout.fieldId, 'n:p_1');
assert.equal(scout.prep, true);
assert.deepEqual(scout, m.prepFieldMeta(h.ps('p_1')), 'exactly what a teammate scouting the board gets');
assert.equal(m.handle(S, { t: 'g.watch', fieldId: `n:${S}` }).error, ERR.BAD_TARGET, 'a spectator has no board');
const end = h.runToEnd();
assert.equal(end.victory, true);
const got = frames(h, S);
for (const x of got) assert.ok(['m.public', 'm.field', 'b.start', 'b.end', 'm.result'].includes(x.t), `${x.t} sent to a spectator`);
assert.equal(h.allTo(S, 'm.private').length, 0, 'no private player view');
const starts = h.allTo(S, 'b.start');
assert.ok(starts.length > 0);
for (const st of starts) {
assert.equal(st.watch, true, `${st.fieldId}: watched`);
assert.equal(st.authoritative, false, `${st.fieldId}: never the authority`);
assert.ok(!st.spec.players.some((p) => p.playerId === S), 'never a player of a field');
}
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');
// 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');
// the settlement: every player's public row, none of its own
const res = h.lastTo(S, 'm.result');
assert.ok(res, 'the spectator gets the result');
assert.equal(res.playerId, S);
assert.deepEqual(res.players.map((p) => p.playerId), ['p_0', 'p_1', 'p_2']);
assert.deepEqual({ ...res, playerId: null }, { ...h.lastTo('p_1', 'm.result'), playerId: null }, 'the same rows a player gets');
// nothing shown to it — unicast or broadcast — carries a private key
for (const x of [...got, ...h.bc]) assert.deepEqual(privateKeys(x), [], `${x.t}: private keys`);
assert.equal(m.errorCount, 0, JSON.stringify(m.errors.slice(0, 2)));
m.dispose();
});
test('during a battle a spectator may watch any field at once; one added mid-battle gets the state; a removed one gets nothing', () => {
const h = makeMatch({
mode: 'coop', humans: 2, seed: 9302, fake: true, clientCombat: true, instant: false, pace: 'paced', spectators: [S],
script: () => ({ duration: 30 }),
}).start();
const m = h.m;
h.toPrep(1);
for (const pid of ['p_0', 'p_1']) m.handle(pid, { t: 'g.ready', ready: true });
h.run(() => m.phase === PHASE.COMBAT);
h.sched.advance(500);
const first = h.lastTo(S, 'b.start');
assert.deepEqual([first.fieldId, first.watch, first.authoritative], ['n:p_0', true, false], 'auto-observes the first field');
// a fighting player may not look elsewhere — a spectator (like an eliminated player) may
assert.equal(m.handle('p_0', { t: 'g.watch', fieldId: 'n:p_1' }).error, ERR.WRONG_PHASE);
assert.deepEqual(m.handle(S, { t: 'g.watch', fieldId: 'n:p_1' }), { ok: true });
const st = h.lastTo(S, 'b.start');
assert.deepEqual([st.fieldId, st.watch, st.authoritative], ['n:p_1', true, false]);
// the field's spec as every watcher gets it — minus the player's funds at the battle start (no battle effect reads it)
const spec = m.fields.find((f) => f.fieldId === 'n:p_1').spec;
assert.ok(Object.hasOwn(spec.players[0].contentInfo, 'funds'), 'a player / teammate replica gets contentInfo.funds');
const { funds, ...counters } = spec.players[0].contentInfo;
assert.ok(Number.isFinite(funds));
assert.deepEqual(st.spec, { ...spec, players: [{ ...spec.players[0], contentInfo: counters }] }, 'the same spec without funds');
assert.deepEqual(privateKeys(st), []);
assert.equal(m.watchers.get(S), 'n:p_1');
for (const f of m.fields) assert.notEqual(f.authority, S);
// a spectator seat taken mid-battle: m.public and the first field at once
const LATE = 's_late';
m.addSpectator(LATE);
assert.ok(h.lastTo(LATE, 'm.public'));
assert.equal(h.lastTo(LATE, 'b.start').fieldId, 'n:p_0');
assert.equal(h.allTo(LATE, 'm.private').length, 0);
m.addSpectator(LATE); // a resync: idempotent registration, the state again
assert.equal(h.allTo(LATE, 'm.public').length, 2);
assert.equal(m.spectators.size, 2);
m.addSpectator('p_0'); // a player's id is never a spectator
assert.ok(!m.spectators.has('p_0'));
assert.equal(h.allTo('p_0', 'm.private').length > 0, true);
// removed: nothing reaches it any more, its watch is gone
m.removeSpectator(LATE);
const before = frames(h, LATE).length;
assert.equal(m.watchers.has(LATE), false);
assert.equal(m.handle(LATE, { t: 'g.watch', fieldId: 'n:p_0' }).error, ERR.NOT_IN_ROOM);
h.run(() => m.phase === PHASE.PREP && m.round === 2, { maxSteps: 2e6 });
assert.equal(frames(h, LATE).length, before, 'a removed spectator is sent nothing');
assert.equal(h.allTo(S, 'm.private').length, 0);
m.dispose();
});
test('server-run combat (SP_COMBAT=server): a spectator is streamed the first field like an eliminated player', () => {
const h = makeMatch({ mode: 'coop', humans: 2, seed: 9303, fake: true, spectators: [S] }).start();
const m = h.m;
h.toPrep(1);
for (const pid of ['p_0', 'p_1']) m.handle(pid, { t: 'g.ready', ready: true });
h.run(() => m.phase === PHASE.SETTLE);
const meta = h.allTo(S, 'm.field');
assert.ok(meta.length > 0 && meta[0].fieldId === 'n:p_0', 'm.field of the first field');
assert.ok(h.allTo(S, 'b.snap').length > 0, 'its snapshots');
assert.equal(h.allTo(S, 'm.private').length, 0);
m.dispose();
});
+158
View File
@@ -0,0 +1,158 @@
// Real-server browser E2E — spectator seats (community report #26, a remake feature: the official room has none). The
// lobby's 观战 takes a spectator seat; the room lists it under 观战席 (the host's ✕ frees one — that spectator is told
// room.closed {kicked}); the spectator's room view has 观战中 instead of the ready button; in the match it has no shop,
// no ready toggle, no emote wheel and reads 观战中 (never "你已被淘汰"), is shown the first player's board in 休整期 by
// itself and the first field in battle, never holds an m.private, sends nothing but g.watch (plus its own room.spectate /
// room.loadout), gets its seat back after a reload, and 离开观战 returns it to the lobby.
// Server side: test/lobby.test.js, test/match/spectator.test.js, test/match/lobby-integration.test.js.
//
// SP_E2E=1 node --test test/ui/spectator.e2e.test.js
//
// Screenshots: test/e2e/out/spectator-*.png.
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync } from 'node:fs';
import path from 'node:path';
import { Client, ROOT, sleep, hasChrome, startRealServer, problemsOf } from '../e2e/client.mjs';
import { TestClient } from '../helpers/wsClient.js';
const ENABLED = process.env.SP_E2E === '1' && hasChrome() && existsSync(path.join(ROOT, 'public/assets'));
const ALLOWED = new Set(['room.spectate', 'room.loadout', 'g.watch', 'g.leave', 'room.leave']);
/** The spectator's own view of the store: still a spectator, never a private view. */
const specState = (c) => c.page.evaluate(() => {
const s = globalThis.__SP__.store.get();
return {
spectating: !!s.room?.spectators?.some((x) => x.playerId === s.me.playerId),
priv: s.match.private != null,
battle: s.match.battle ? { watch: !!s.match.battle.watch, authoritative: !!s.match.battle.authoritative, fieldId: s.match.battle.fieldId } : null,
field: s.match.field ? { fieldId: s.match.field.fieldId, prep: !!s.match.field.prep } : null,
};
});
describe('spectator seats (community report #26, real server)', { skip: !ENABLED && 'set SP_E2E=1 (Chrome + public/assets)' }, () => {
test('观战 from the lobby → 观战席 in the room → the match: watching only, no private view, resume, 离开观战', { timeout: 6 * 60 * 1000 }, async () => {
const srv = await startRealServer({ fast: { timerScale: 1, combatSpeed: 4, startRound: 1, kit: 3, autoPlace: true } });
const P = (await import('puppeteer-core')).default;
const host = new Client(P, srv.base, 'host', { prefix: 'spectator' });
const spec = new Client(P, srv.base, 'spec', { prefix: 'spectator', w: 1280, h: 720 });
let extra = null;
try {
await host.open();
await host.enter('凯尔希');
await host.click('.mode-card', '同盟模拟');
await host.click('.diff-card', '标准模拟');
await host.click('.create-box button', '创建同盟');
const room = (await host.waitFor((s) => !!s.room?.code, 'room created')).room;
await host.click('.seat--empty button', '添加 AI 队友');
// the lobby's 观战 entry
await spec.open();
await spec.hookRequests();
await spec.enter('阿米娅');
await spec.click('.join-row input');
await spec.page.keyboard.type(room.code);
await spec.click('.join-row button', '观战');
await spec.page.waitForSelector('.room-screen .specbar__who.is-me', { timeout: 10000 });
const roomView = await spec.page.evaluate(() => ({
bar: document.querySelector('.room-bar__right .btn--xl')?.textContent || '',
barDisabled: !!document.querySelector('.room-bar__right .btn--xl')?.disabled,
status: document.querySelector('.room-bar__status')?.textContent || '',
seats: [...document.querySelectorAll('.seat__name')].map((e) => e.textContent),
}));
assert.match(roomView.bar, /观战中/);
assert.equal(roomView.barDisabled, true);
assert.match(roomView.status, /观战中/);
assert.ok(!roomView.seats.includes('阿米娅'), 'never in a player seat');
await spec.shot('room-spectator');
// a second spectator (a bare socket) — the host's ✕ removes it: room.closed {kicked}
extra = await TestClient.connect(`ws://127.0.0.1:${srv.port}/ws`);
await extra.hello('华法琳');
assert.equal((await extra.request({ t: 'room.spectate', code: room.code })).t, 'ok');
await host.page.waitForFunction(() => document.querySelectorAll('.specbar__who').length === 2, { timeout: 8000 });
await host.shot('room-host-specbar');
await host.click('.specbar__who button[aria-label*="华法琳"]');
assert.equal((await extra.waitFor('room.closed', () => true, 5000)).reason, 'kicked');
await host.page.waitForFunction(() => document.querySelectorAll('.specbar__who').length === 1, { timeout: 8000 });
// the match: a spectator is no player to wait for
await host.click('.room-bar__right button', '开始模拟', { timeout: 20000 });
for (const c of [host, spec]) await c.waitFor((s) => s.phase === 'INFO_CHECK', 'briefing', 30000);
assert.match(await spec.page.evaluate(() => document.querySelector('.brief__foot .btn--primary')?.textContent || ''), /观战中/);
await host.click('.brief__foot .btn--primary', '准备就绪');
const t0 = Date.now();
for (;;) {
const s = await host.st();
if (s.phase === 'PREP') break;
assert.ok(Date.now() - t0 < 120000, `host stuck in ${s.phase}`);
if (s.phase === 'BAND_DRAFT' && s.draft?.turn === s.me) {
await host.click('.dband:not(.is-taken)', null, { nth: 2, optional: true, timeout: 2000 });
await sleep(200);
await host.click('.draft-detail__btns .btn--primary', '确认选择', { optional: true, timeout: 2000 });
}
await sleep(300);
}
// 休整期: the first player's board by itself, read-only; no shop / ready / 交流
await spec.waitFor((s) => s.phase === 'PREP', 'spectator in prep', 30000);
await spec.page.waitForSelector('.gm__watching', { timeout: 10000 });
await spec.page.waitForSelector('.gm__dead--spectator', { timeout: 10000 });
const prep = await spec.page.evaluate(() => ({
watching: document.querySelector('.gm__watching')?.textContent || '',
back: [...document.querySelectorAll('.gm__watching button')].length,
pill: document.querySelector('.gm__dead')?.textContent || '',
shop: !!document.querySelector('.shopbar, .shopbar-tab'),
ready: !!document.querySelector('.readybtn'),
emote: !!document.querySelector('.ewheel'),
}));
assert.match(prep.watching, /凯尔希/, 'the host\'s board is shown');
assert.equal(prep.back, 0, 'no 返回自己 — a spectator has no board');
assert.match(prep.pill, /观战中/);
assert.doesNotMatch(prep.pill, /淘汰/);
assert.deepEqual([prep.shop, prep.ready, prep.emote], [false, false, false]);
const sp1 = await specState(spec);
assert.deepEqual([sp1.spectating, sp1.priv], [true, false]);
assert.equal(sp1.field?.prep, true);
await sleep(1200);
await spec.shot('prep');
// battle: the first field, a display replica (watch), never the authority
await host.click('.readybtn', null, { timeout: 10000 });
await spec.waitFor((s) => s.phase === 'COMBAT', 'combat', 60000);
await spec.page.waitForFunction(() => !!globalThis.__SP__.store.get().match.battle?.watch, { timeout: 20000 });
const sp2 = await specState(spec);
assert.deepEqual([sp2.priv, sp2.battle.watch, sp2.battle.authoritative], [false, true, false]);
// the auto-observed field reads "👁 name" (as for an eliminated player), with no 返回战场 — never "你已被淘汰"
await spec.page.waitForSelector('.chud__observe', { timeout: 10000 });
const hud = await spec.page.evaluate(() => ({
text: document.querySelector('.chud')?.textContent || '',
back: !!document.querySelector('.chud__back'),
emote: !!document.querySelector('.ewheel'),
}));
assert.match(hud.text, /凯尔希/);
assert.doesNotMatch(hud.text, /淘汰/);
assert.deepEqual([hud.back, hud.emote], [false, false]);
await sleep(1500);
await spec.shot('battle');
// a reload: the seat comes back with the match
await spec.page.reload({ waitUntil: 'domcontentloaded' });
await spec.page.waitForFunction(() => !!globalThis.__SP__ && !!globalThis.__SP__.store.get().match.public, { timeout: 30000 });
const sp3 = await specState(spec);
assert.deepEqual([sp3.spectating, sp3.priv], [true, false]);
// 离开观战: back to the lobby, the match goes on
await spec.click('.gtop__exit', null, { timeout: 15000 });
await spec.click('.modal button', '离开观战');
await spec.page.waitForSelector('.lobby-screen', { timeout: 10000 });
await host.page.waitForFunction(() => (globalThis.__SP__.store.get().room?.spectators || []).length === 0, { timeout: 8000 });
const sent = await spec.requests();
assert.deepEqual([...new Set(sent.map((r) => r[0]))].filter((t) => !ALLOWED.has(t)), [], 'a spectator sends no player action');
assert.deepEqual(problemsOf([host, spec]), []);
} finally {
await extra?.terminate().catch(() => {});
for (const c of [host, spec]) await c.close();
await srv.stop();
}
});
});