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
+976 -82

No files matched your search

+1 -1
View File
@@ -156,7 +156,7 @@ npm start # 启动服务器:http://localhost:3000
| 方向轮盘键盘操作 | 方向键预览 · `Enter` 确认 · `Esc` 取消 |
| 暂停(独立模拟) | 作战中(含最终攻势 / 隐秘核心)点顶栏的「暂停」或按 `Space`,再点「继续作战」(或 `Space`)继续;同盟模拟的作战不能暂停 |
| 表情 | 左下角「交流」,左右滑动(或方向键)换主题,冷却 1 秒 |
| 观战 | 自己的作战结束后(或休整期)点左侧队友头像 →「前往查看」 |
| 观战 | 自己的作战结束后(或休整期)点左侧队友头像 →「前往查看」;不参战的朋友可以在大厅输入同盟密钥点「观战」(每个同盟最多 2 名观战者,本作新增) |
完整的规则、数值和小技巧见 **[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.
**Spectator seats** (a remake feature — the official room has none; community report #26, owner's decision 2026-10-04; the spectator-seat subsection of §23): `room.spectate {code}` takes one of a co-op room's `MAX_SPECTATORS` (2) spectator seats, in its lobby or during its match; `room.removeSpectator {playerId}` (host) frees one (`room.closed {reason:'kicked'}` to it); `room.state.spectators: [{playerId, name, connected}]`. A spectator is never a player (not in `seats`, not counted for the start, never host, keeps no room alive — `room.closed {empty}` when the last human leaves), receives the room's broadcasts and watches like an eliminated player (`Match.addSpectator`: `b.start` watch / `m.field`, never `m.private`); it may only `g.watch`, leave, and store a `room.loadout` — anything else answers `error SPECTATOR`. Its seat is kept and given back on reconnect like a player's.
### 8.2 Match
C→S (all carry `rid`): `g.infoReady` · `g.band {bandId}` · `g.bandSkip` · `g.bandFocus {bandId?}` (the strategy highlighted in the draft screen — what a timed-out turn takes; absent / null clears it; `WRONG_PHASE` outside BAND_DRAFT, `ALREADY` after the pick, `BAD_TARGET` for a band not allowed; §18.2) · `g.buy {slot}` · `g.refresh` · `g.freeze` · `g.levelUp` · `g.sell {uid}` · `g.move {uid, to:{area:'board', row, col}|{area:'hand', idx}, dir?}` · `g.equip {itemUid, targetUid, replaceUid?}` (`replaceUid`: which equipped item a third one replaces, §6.2) · `g.art {itemUid, row, col, dir?}` (`dir` ∈ UP|RIGHT|DOWN|LEFT from the deploy wheel, §3; absent ⇒ `to.dir`, then RIGHT; `g.move` onto the piece's own tile re-orients it; board pieces in `m.private` / `m.field {prep:true}` carry `dir`) · `g.destroy {uid}` (hand / temp items only; equipped items are locked, §6.2) · `g.reward {idx}` · `g.choice {idx}` · `g.ready {ready}` · `g.emote {id}` · `g.watch {fieldId}` · `g.autoplay {on}` · `g.pause {on}` (solo battles only, §14 Solo pause) · `g.unitStats {seq?}` (ROUND_START / SP_DRAFT / PREP, else `WRONG_PHASE`; eliminated: `ELIMINATED`) → push `m.unitStats` (§18.5) · `g.leave`
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
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.
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
(`SP_COMBAT=server`):
`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 秒。
- 休整期、或自己的作战结束后(显示「作战结束,等待队友完成作战」),点左侧队友头像 →「前往查看」观看他的战场;自己的作战进行中不能观战。联防和最终攻势中可以用 ‹ › 切换左半 / 全景 / 右半。
- 被淘汰后可以自由观看任何战场。
- **观战席**(本作新增,原版没有):每个同盟房间另有 2 个观战席。在大厅输入同盟密钥后点「观战」进入(模拟进行中也可以),不占博士席位、不算参战人数,也不能购买、部署、准备或发送表情;观看方式与被淘汰的玩家相同——作战时自动观看第一个战场,休整期自动显示第一位博士的阵地,点左侧成员头像可切换查看任何人。断线重连会回到观战席;创建者可以在等待室用观战者名字旁的 ✕ 将其移出;观战者在等待室有空位时可以点「入座」成为博士。
- **观看谁,盟约栏就显示谁的盟约**:查看队友的阵地或战场时(休整期、作战结束后、被淘汰后自动观战),左上的盟约栏换成**该队友的盟约、人数和层数**,前面有琥珀色的「👁 队友名」标签,点开的盟约详情也写着「队友名 的盟约」(成员一栏只标出他在场上的干员,整备区看不到);作战中层数实时增长(与他自己看到的一致)。联防和最终攻势里跟随 ‹ ›:镜头在队友的半场时显示他的盟约,在你自己的半场时显示你自己的;「全景」时,你在这个战场上作战就显示你自己的,你不在这个战场上(漏怪后观看联防、被淘汰后观战)则显示你点「前往查看」选中的那名队友,没选过就显示该战场的第一名玩家(1 号联防者 / 这一组的第一位),**不会显示你自己的**。在这样的战场上点某名队友的头像 →「前往查看」,镜头会直接移到他的半场,‹ › 上的「👁 队友名」与盟约栏的标签一致;‹ › 旁边有「返回战场」(被淘汰时没有)。盟约栏换人时,从盟约栏打开的盟约详情会自动关闭。点开场上某个干员的详情卡时,卡上的盟约(以及点它打开的盟约详情)属于**这个干员的主人**,与盟约栏当前显示谁无关——例如在「全景」点队友的干员,看到的是「队友名 的盟约」及他的人数和层数;在队友的半场点你自己的干员,看到的是你自己的。回到自己的阵地 / 战场后恢复成你自己的盟约。作战结束后观看队友时刷新页面 / 断线重连,仍会回到正在观看的队友战场,「👁 队友名」和「返回战场」都还在。已被淘汰的队友不再显示盟约(也无法被查看)。
## 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;
}
.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 > * { 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 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 ----------------------------------------------------------------------------------------- */
.room-bar {
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 { ToastHost, toast, toastError, describeError } from './ui/toasts.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 { GAME_FILES } from './ui/gameComponents.js';
import { TitleScreen, sanitizeName } from './screens/title.js';
@@ -168,8 +168,8 @@ function onRoomState(msg) {
roomStateAt = Date.now();
const myId = store.get().me.playerId;
const seats = Array.isArray(room.seats) ? room.seats : [];
if (myId != null && seats.length && !seats.some((s) => s && s.playerId === myId)) {
// We are no longer seated (kicked / left elsewhere).
if (myId != null && seats.length && !seats.some((s) => s && s.playerId === myId) && !isSpectating(room, myId)) {
// We are no longer seated (kicked / left elsewhere) — neither in a player seat nor a spectator seat.
if (store.get().room) toast('你已不在该同盟中', 'warn');
store.set({ room: null, match: emptyMatch() });
return;
+2 -1
View File
@@ -212,7 +212,8 @@ export function BandDraftScreen() {
const timed = !solo && !!pub?.draft && !pub.draft.untimed;
const focusSent = useRef(null);
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;
act('g.bandFocus', { bandId: sel }, { sfx: false, quiet: true });
}, [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__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>
<${Button} variant="primary" size="xl" icon=${me?.ready ? 'check' : 'play'} active=${!!me?.ready} loading=${busy}
disabled=${!!me?.ready || !me} onClick=${ready}>${me?.ready ? '已就绪' : '准备就绪'}<//>
<${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 ? '已就绪' : me ? '准备就绪' : '观战中'}<//>
</footer>
<${ExitModal} open=${exit} onClose=${() => setExit(false)} solo=${solo} />
</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
// eliminated spectator) frames their half and keeps the ‹ › pill (with 返回战场); an open bond popup closes when the
// 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 { PHASE, GEO } from '../../../shared/constants.js';
@@ -99,7 +103,7 @@ import { BriefingScreen } from './briefing.js';
import { BandDraftScreen } from './bandDraft.js';
import { ResultScreen } from './result.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 { isClientCombat, observeTarget, teammateProgress, cameraLayers, layerCamera, sidesOf, resumedWatch } from '../battle/observe.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 emotes = useStore((s) => s.emotes);
const roomSolo = useStore((s) => s.room?.mode === 'solo');
const spectator = useStore((s) => isSpectating(s.room, s.me.playerId));
const gd = useGameData();
const hostRef = useRef(null);
@@ -225,7 +230,8 @@ function MatchScreen() {
const solo = roomSolo || String(pub?.modeId || '').includes('single');
const players = sortedPlayers(pub);
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 watchingOther = !!watching && watching !== home && watching !== ownFieldId(myId);
const editable = phase === PHASE.PREP && !!priv && alive && !priv.ready && !watchingOther;
@@ -722,6 +728,18 @@ function MatchScreen() {
const watchField = useCallback((fid) => { requestWatch(fid); }, []);
// a spectator seat has no board of its own: in 休整期 / 机变 / round start it is shown the first player still in (as a
// tap on that row would — g.watch 'n:<pid>', the read-only board), once per phase; a row switches to another player
const scoutedRef = useRef(null);
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) ----------------------------------------------------------------------
useEffect(() => {
if (!view) return undefined;
@@ -1206,7 +1224,7 @@ function MatchScreen() {
readyBusy=${readyBusy} readyCount=${readyCount} playerCount=${solo ? 1 : aliveCount}
pen=${pen} penAvail=${penAvail} onPen=${togglePen} config=${gd.config} frozenAt=${frozenAt}
pause=${canPause || paused ? { show: canPause, paused, busy: pauseBusy, onToggle: () => togglePause(!paused) } : null}
live=${liveLpNow} />
live=${liveLpNow} spectator=${spectator} />
<div class="gm__bonds">
<${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">
<${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}
${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}
${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}
${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} />
<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 gm__guide" aria-label="玩法说明" title="玩法说明" onClick=${() => openGuide(0)}><${Icon} name="book" /></button>
<${FullscreenButton} class="gm__gear gm__fs" />
+13 -2
View File
@@ -1,5 +1,7 @@
// 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
// 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).
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 { toast, toastError } from '../ui/toasts.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; }
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 = () => {
identity.setEntered(false);
store.set((s) => ({ session: { ...s.session, entered: false } }));
@@ -291,6 +299,9 @@ export function LobbyScreen() {
<${TextField} size="code" icon="key" value=${code} placeholder="输入同盟密钥 / 粘贴邀请链接"
transform=${normalizeCode} onInput=${(v) => setCode(normalizeCode(v))} onEnter=${() => 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 class="join-foot">
${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
// alone (no separate room.ready round trip that could leave the host "ready" after a failed start).
// 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 { 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 {
html, Button, Icon, MicroLabel, PingPill, AvatarFrame, DifficultyTag, DifficultyIcon, Tooltip, confirmDialog, doctorNo,
} from '../ui/components.js';
@@ -17,7 +20,7 @@ import { copyText } from '../ui/clipboard.js';
import { GuideButton } from '../ui/guide.js';
import { LoadoutButton } from './loadout.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';
/**
@@ -58,6 +61,9 @@ export function roomFacts(room, myId) {
emptySeats: seats.filter((s) => !s).length,
canStart: isHost && othersReady && !!mine,
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>`;
}
/** 观战席: 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 }) {
const copy = async (what) => {
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 }));
};
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 () => {
if (inFlight.current) return;
const othersHere = facts.humans.some((s) => s.playerId !== me.playerId);
@@ -228,6 +251,8 @@ export function RoomScreen() {
const statusLine = !online
? html`<span class="t-orange"><${Icon} name="wifiOff" />连接中断,正在重连…</span>`
: facts.spectating
? html`<span class="t-lo"><${Icon} name="eye" />观战中 · 不占博士席位,模拟开始后可切换观看各位博士</span>`
: !coop
? html`<span class="t-mint">*模拟协议已就绪,准许进入模拟</span>`
: facts.isHost
@@ -273,6 +298,7 @@ export function RoomScreen() {
</ul>
</aside>`}
</main>
<${SpectatorBar} facts=${facts} myId=${me.playerId} busy=${busy} onRemove=${removeSpectator} onSit=${sit} />
<footer class="room-bar">
<div class="room-bar__left">
@@ -295,6 +321,8 @@ export function RoomScreen() {
? html`<${Tooltip} text=${facts.canStart ? null : '仍有博士未准备就绪'}>
<${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}
loading=${busy === 'ready'} disabled=${!online || !facts.mine} onClick=${toggleReady}>${myReady ? '已就绪' : '准备就绪'}<//>`}
</div>
+11
View File
@@ -103,6 +103,17 @@ export function selectRoute(s) {
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
* 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,
* 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 }) {
if (client) return ClientHud({ hud, myDone, spectating, client });
export function CombatHud({ pub, myId, watching, hud, myDone, onWatch, spectating = false, spectator = false, client = null }) {
// `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);
// the watched field may already have finished (not live): still name it
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} />
<div class="chud__bottom">
${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')}>
<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>
@@ -56,7 +58,7 @@ export function CombatHud({ pub, myId, watching, hud, myDone, onWatch, spectatin
</div>`;
}
function ClientHud({ hud, myDone, spectating, client }) {
function ClientHud({ hud, myDone, spectating, spectator = false, client }) {
const layers = Array.isArray(client.layers) ? client.layers : [];
const idx = Math.max(0, layers.findIndex((l) => l.key === (client.layer || 'ALL')));
const cur = layers[idx] || null;
@@ -72,7 +74,8 @@ function ClientHud({ hud, myDone, spectating, client }) {
<div class="chud__bottom">
${myDone && !observing ? html`<div class="chud__msg chud__wait" role="status"><${Icon} name="hourglass" /><span>作战结束,等待队友完成作战</span></div>
<${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">
<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()}>
+2
View File
@@ -74,6 +74,8 @@ export const ICONS = {
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 },
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
*/
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 boss = isBossPhase(phase);
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 hidden = phase === PHASE.HIDDEN_CORE || (Number.isFinite(pub?.lastRound) && pub.round > pub.lastRound);
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)
const otLive = boss && Number(pub?.overtimeAt) > 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 { toastError } from './toasts.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 { GuideButton } from './guide.js';
@@ -58,12 +58,21 @@ export async function quitMatch() {
*/
export function ExitModal({ open, onClose, solo, onAway }) {
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 () => {
setBusy('quit');
await quitMatch();
setBusy(null);
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 () => {
setBusy('away');
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
// 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).
// * 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 { 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 { encode, isDroppable, isErrCode, sendRaw, sendSession } from './net.js';
import { getData as defaultGetData, lookup } from './data.js';
@@ -117,6 +129,8 @@ export class Room {
this.hostId = null;
/** @type {(Seat | 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 */
this.match = null;
/** @type {{ live: boolean, ended: boolean, disposed: boolean, match: any } | null} */
@@ -143,6 +157,9 @@ export class Room {
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. */
freeSeat() { return this.seats.indexOf(null); }
@@ -161,6 +178,7 @@ export class Room {
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 }
: 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 humans = 0;
let bots = 0;
let spectators = 0;
for (const r of this.rooms.values()) {
if (r.match) matches++;
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.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);
// 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.
@@ -267,6 +288,8 @@ export class Lobby {
case 'room.kick': return this.kick(session, msg);
case 'room.start': return this.start(session);
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:
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)}`);
@@ -280,10 +303,11 @@ export class Lobby {
// 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;
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;
if (room.match) this.callMatch(room, 'onDisconnect', session.playerId);
else this.startGrace(room, seat);
// a spectator's seat is kept like a player's (nothing to tell the match: it plays no field)
if (room.match) { if (player) this.callMatch(room, 'onDisconnect', session.playerId); } else this.startGrace(room, seat);
this.broadcastState(room);
}
@@ -320,8 +344,8 @@ export class Lobby {
if (this.rooms.size >= this.opts.maxRooms) return fail(ERR.INTERNAL, 'too many rooms');
const key = session.limitKey || null;
if (key && this.opts.maxRoomsPerAddr > 0) {
// The room being left disappears with this create when the creator is its only human.
const leaving = cur && cur.ownerKey === key && cur.activeHumans().length === 1 ? 1 : 0;
// 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 && !cur.spectatorOf(session.playerId) ? 1 : 0;
if (this.countRooms((r) => r.ownerKey === key) - leaving >= this.opts.maxRoomsPerAddr) {
this.limitWarn(`room limit (${this.opts.maxRoomsPerAddr}) reached for ${session.addr}`);
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;
if (!room) return fail(ERR.ROOM_NOT_FOUND);
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 (room.match) return fail(ERR.ROOM_STARTED);
if (room.mode === 'solo') return fail(ERR.ROOM_FULL, 'solo room');
@@ -371,9 +396,55 @@ export class Lobby {
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 }) {
const room = this.roomOf(session);
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);
this.dropReplay(room, session.playerId);
const seat = room.seatOf(session.playerId);
@@ -488,7 +559,7 @@ export class Lobby {
if (!room) return OK;
const seat = room.seatOf(session.playerId);
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');
let r;
try {
@@ -527,6 +598,8 @@ export class Lobby {
difficulty: room.difficulty,
modeId: modeIdFor(room.mode, room.difficulty),
seats,
// the spectator seats (header): watched like eliminated players, never players
spectators: room.spectators.map((s) => s.playerId),
seed,
// the room's match number: with the seed it keeps battleIds unique across the room's matches (DESIGN §14)
matchNo: room.matchCount + 1,
@@ -574,6 +647,7 @@ export class Lobby {
s.ready = false;
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;
if (!host || host.isBot || host.left) this.migrateHost(room);
if (room.activeHumans().length === 0) this.disposeRoom(room, 'empty');
@@ -604,7 +678,7 @@ export class Lobby {
*/
buildReplay(room, ctx) {
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;
const frame = ctx.results.get(s.playerId) || ctx.sharedResult;
if (frame) frames.set(s.playerId, frame);
@@ -658,7 +732,7 @@ export class Lobby {
if (!room) return;
session.resyncAt = this.now();
if (room.match) {
this.callMatch(room, 'onReconnect', session.playerId);
this.callMatch(room, room.spectatorOf(session.playerId) ? 'addSpectator' : 'onReconnect', session.playerId);
return;
}
const frames = this.replayFor(room, session.playerId);
@@ -696,6 +770,8 @@ export class Lobby {
this.removeMember(room, session.playerId);
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;
try {
res = room.match.handle(session.playerId, msg);
@@ -758,6 +834,7 @@ export class Lobby {
if (!session.roomCode) return null;
const room = this.rooms.get(session.roomCode);
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; }
return room;
}
@@ -780,6 +857,7 @@ export class Lobby {
if (session && session.roomCode === room.code) session.roomCode = null;
this.clearGrace(playerId);
this.dropReplay(room, playerId);
if (this.freeSpectatorSeat(room, playerId)) return;
const seat = room.seatOf(playerId);
if (!seat || seat.isBot || seat.left || room.disposed) return;
if (room.match) {
@@ -796,6 +874,20 @@ export class Lobby {
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). */
migrateHost(room) {
const humans = room.activeHumans();
@@ -811,7 +903,7 @@ export class Lobby {
const t = setTimeout(() => {
this.graceTimers.delete(playerId);
if (room.disposed || room.match) return;
const s = room.seatOf(playerId);
const s = room.seatOf(playerId) || room.spectatorOf(playerId);
if (!s || s.connected) return;
const session = this.registry.byId(playerId);
if (session && session.roomCode === room.code) {
@@ -852,6 +944,15 @@ export class Lobby {
if (session.connected) sendSession(session, { t: 'room.closed', 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);
this.log.info(`[lobby] ${room.code} disposed (${reason})`);
}
@@ -869,9 +970,9 @@ export class Lobby {
// 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) {
for (const s of room.seats) {
for (const s of [...room.seats, ...room.spectators]) {
if (!s || s.isBot || s.left) continue;
const session = this.registry.byId(s.playerId);
if (session && session.connected && session.roomCode === room.code) yield session;
@@ -901,7 +1002,7 @@ export class Lobby {
/** Match unicast. @returns {boolean} */
sendToPlayer(room, playerId, msg) {
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;
const session = this.registry.byId(playerId);
if (!session || session.roomCode !== room.code) return false;
+114 -31
View File
@@ -18,6 +18,8 @@
// 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
// (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.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)
@@ -53,6 +55,15 @@
// treat as quit (AI takes over / eliminated per DESIGN). No onDisconnect follows.
// dispose() Stop every timer/interval and release resources. Idempotent. After dispose the
// 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).
// 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');
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
* 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 }}
*/
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);
// 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) {
// 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)
@@ -445,22 +465,67 @@ export class Match {
this.guard(() => {
const was = ps.connected;
ps.connected = true;
this.sendTo(playerId, this.publicView());
if (!this.ended) {
ps._lastPriv = null;
this._sendPrivate(ps, true);
if (this.clientCombat) this._resendBattle(ps);
else {
const fid = this.watchers.get(playerId);
if (fid) this._sendField(playerId, fid);
}
} else if (this.lastResultMsg) {
this.sendTo(playerId, { ...this.lastResultMsg, playerId });
}
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());
if (!this.ended) {
if (!ps.spectator) {
ps._lastPriv = null;
this._sendPrivate(ps, true);
}
if (this.clientCombat) this._resendBattle(ps);
else {
let fid = this.watchers.get(playerId);
if (!fid && ps.spectator && this.fields.length) { fid = this.fields[0].fieldId; this.watchers.set(playerId, fid); }
if (fid) this._sendField(playerId, fid);
}
} else if (this.lastResultMsg) {
this.sendTo(playerId, { ...this.lastResultMsg, playerId });
}
}
/**
* 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) {
const ps = this.players.get(playerId);
if (!ps || ps.isBot || ps.left || this.disposed) return;
@@ -668,7 +733,7 @@ export class Match {
sendTo(playerId, msg) {
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;
try { return !!this.sendFn(playerId, msg); } catch (e) { this.reportError('send', e); return false; }
}
@@ -1772,8 +1837,7 @@ export class Match {
_defaultWatch() {
this.watchers.clear();
for (const ps of this.order) {
if (ps.isBot || ps.left) continue;
for (const ps of this._viewers()) {
const own = this.fields.find((f) => f.players.includes(ps.playerId));
const f = own || this.fields[0];
if (!f) continue;
@@ -2076,10 +2140,30 @@ export class Match {
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). */
_startMsg(f, pid, { watch = false } = {}) {
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),
startAt: f.startAt, serverNow: this.sched.now(), elapsed: Math.round(this._fieldElapsed(f) * 1000) / 1000,
speed: this.gameSpeed, watch: !!watch, done: !!f.done,
@@ -2087,17 +2171,17 @@ export class Match {
}
_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;
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) {
const out = new Set();
for (const pid of f.players) 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. */
@@ -2246,8 +2330,8 @@ export class Match {
const limit = this.wave ? this.wave.timeLimit : 60;
this.deadline = this.sched.instant ? 0 : this.sched.now() + Math.round((limit / this.gameSpeed) * 1000);
this._launch(fields);
// every fighting human runs its own field; eliminated humans keep watching (research 09 §3.1 "Keep-watching
// auto-observes the first available field", switching freely with 前往查看): a display replica of the first field
// every fighting human runs its own field; eliminated humans (and spectator seats) keep watching (research 09 §3.1
// "Keep-watching auto-observes the first available field", switching freely with 前往查看): a replica of the first field
for (const f of fields) for (const pid of f.players) {
const ps = this.players.get(pid);
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;
if (first) {
for (const ps of this.order) {
if (ps.isBot || ps.left || ps.alive || this.watchers.has(ps.playerId)) continue;
for (const ps of this._viewers()) {
if (ps.alive || this.watchers.has(ps.playerId)) continue;
this.watchers.set(ps.playerId, first.fieldId);
this._sendStart(ps.playerId, first, { watch: true });
}
@@ -2273,9 +2357,8 @@ export class Match {
this.deadline = this.sched.instant ? 0 : this.sched.now() + Math.round((limit / this.gameSpeed) * 1000);
this.watchers.clear();
this._launch([f]);
// helpers and everyone else (as observers) simulate the same 联防 spec locally
for (const ps of this.order) {
if (ps.isBot || ps.left) continue;
// helpers and everyone else (as observers, spectator seats included) simulate the same 联防 spec locally
for (const ps of this._viewers()) {
this.watchers.set(ps.playerId, 'u');
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());
}
/** 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) {
for (const ps of this.order) {
if (ps.isBot || ps.left) continue;
for (const ps of this._viewers()) {
const own = fields.find((f) => f.players.includes(ps.playerId)) || null;
const f = own || fields[0];
if (!f) continue;
@@ -3065,7 +3147,8 @@ export class Match {
this.lastResultMsg = result;
this.markPublic();
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;
void t;
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
* room.loadout (a ≤ 160-entry map validated against the game data; the client debounces its edits).
* 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) 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). */
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 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 NAME_MAX_LEN = 12;
@@ -133,6 +140,7 @@ export const ERR = Object.freeze({
ALREADY: 'ALREADY',
TEMP_NOT_EMPTY: 'TEMP_NOT_EMPTY',
ELIMINATED: 'ELIMINATED',
SPECTATOR: 'SPECTATOR', // a spectator seat only watches (MAX_SPECTATORS)
INTERNAL: 'INTERNAL',
});
@@ -142,7 +150,7 @@ export const ERR_TEXT = {
WRONG_PHASE: '当前阶段无法进行该操作', NO_FUNDS: '资金不足', HAND_FULL: '整备区已满', BOARD_FULL: '已达到部署上限',
BAD_TILE: '无法部署在该位置', BAD_TARGET: '无效的目标', SOLD_OUT: '已售出', MAX_LEVEL: '调度中心已达最高等级',
NOT_YOUR_TURN: '尚未轮到你', ALREADY: '已完成该操作', TEMP_NOT_EMPTY: '临时整备区不为空', ELIMINATED: '你已被淘汰',
INTERNAL: '服务器内部错误',
SPECTATOR: '观战中无法进行该操作', INTERNAL: '服务器内部错误',
};
// ---- Emotes (交流, research 09 §4) -----------------------------------------------------------------------------
+5
View File
@@ -251,6 +251,11 @@ export const C2S = {
'room.start': {},
// operator loadout (DESIGN §16): stored per session/seat; accepted until the match leaves INFO_CHECK
'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
'g.infoReady': {},
+25
View File
@@ -1047,6 +1047,31 @@ describe('screen helpers', () => {
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 () => {
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']);
+179 -2
View File
@@ -19,7 +19,7 @@ import { sanitizeName, TokenBucket, SessionRegistry, clientAddress, normalizeIp,
import { StubMatch as Match } from '../server/match/StubMatch.js';
import { Match as RealMatch } from '../server/match/Match.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 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);
});
// 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 () => {
const host = await pool.player('Host');
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';
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) }]);
};
const okCounts = {};
@@ -910,9 +1031,20 @@ describe('websocket lobby', () => {
assert.ok(sess, 'seated session exists');
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()) {
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());
assert.equal(h.ok, true);
@@ -1012,6 +1144,8 @@ class RecordingMatch extends Match {
onDisconnect(playerId) { this.calls.push(['onDisconnect', playerId]); super.onDisconnect(playerId); }
onReconnect(playerId) { this.calls.push(['onReconnect', playerId]); super.onReconnect(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(); }
}
@@ -1063,6 +1197,49 @@ describe('lobby timers and match interface', () => {
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 () => {
const solo = await pool.player('Solo');
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 });
// 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
// the battleId prefix), data (default: real data/*.json),
// Options: mode, difficulty, humans (count) | seats (explicit), bots, spectators (spectator seat ids, opts.spectators),
// 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
// scheduler runs battles synchronously; default true), timerScale, battleContent, botRehearsal (default 0),
// 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;
}
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) => {
for (const fn of h.onSend) fn(id, msg);
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, []);
});
// 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 () => {
FakeBattle.reset();
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();
}
});
});