feedback7 UIBACK: the match's top bar lists the spectator seats — a 观战席 capsule (eye + count) beside the latency opens the roster, the host removes a spectator with its ✕ while the match runs (PR #120 by @salt-fishes, the part the owner accepted: the cap stays 2, no per-room spectator option); the server already took room.removeSpectator at any time — test/match/lobby-integration.test.js now covers the host removing one mid-match (room.closed kicked, the other spectator and the host told, no frame after it, the seat refilled), test/ui/spectator-roster.e2e.test.js the capsule; one new UI string in the four packs; the dev mock seats spectators on ?variant=spectators; golden: unchanged (client only)
This commit is contained in:
1 parent
7fb41f4ff6
commit
d7c665dbf6
13 files changed
+273
-9
No files matched your search
+1
-1
@@ -133,7 +133,7 @@
|
|||||||
- 左下角「交流」打开表情面板:6 套主题 × 6 个表情,左右滑动(或方向键)换主题。表情以气泡形式显示在你头像旁 3 秒,发送冷却 1 秒。
|
- 左下角「交流」打开表情面板:6 套主题 × 6 个表情,左右滑动(或方向键)换主题。表情以气泡形式显示在你头像旁 3 秒,发送冷却 1 秒。
|
||||||
- 休整期、或自己的作战结束后(显示「作战结束,等待队友完成作战」),点左侧队友头像 →「前往查看」观看他的战场;自己的作战进行中不能观战。联防和最终攻势中可以用 ‹ › 切换左半 / 全景 / 右半。
|
- 休整期、或自己的作战结束后(显示「作战结束,等待队友完成作战」),点左侧队友头像 →「前往查看」观看他的战场;自己的作战进行中不能观战。联防和最终攻势中可以用 ‹ › 切换左半 / 全景 / 右半。
|
||||||
- 被淘汰后可以自由观看任何战场,而且**不用每回合重选**:你上次点「前往查看」的是谁,之后每个阶段(回合开始、机变、休整期、作战、最终攻势)都会自动跟到他的阵地或战场;没选过或他也被淘汰时,跟随座位顺序第一位还在场的博士(包括 AI 队友)。阶段中途换人立即生效,之后就跟随新选的人。
|
- 被淘汰后可以自由观看任何战场,而且**不用每回合重选**:你上次点「前往查看」的是谁,之后每个阶段(回合开始、机变、休整期、作战、最终攻势)都会自动跟到他的阵地或战场;没选过或他也被淘汰时,跟随座位顺序第一位还在场的博士(包括 AI 队友)。阶段中途换人立即生效,之后就跟随新选的人。
|
||||||
- **观战席**(本作新增,原版没有):每个同盟房间另有 2 个观战席。在大厅输入同盟密钥后点「观战」进入(模拟进行中也可以),不占博士席位、不算参战人数,也不能购买、部署、准备或发送表情;观看方式与被淘汰的玩家相同——自动跟随你上次查看的博士(没查看过则是座位顺序第一位还在场的博士):休整期显示他的阵地,作战时观看他的战场,点左侧成员头像可切换查看任何人。断线重连会回到观战席;创建者可以在等待室用观战者名字旁的 ✕ 将其移出;观战者在等待室有空位时可以点「入座」成为博士。
|
- **观战席**(本作新增,原版没有):每个同盟房间另有 2 个观战席。在大厅输入同盟密钥后点「观战」进入(模拟进行中也可以),不占博士席位、不算参战人数,也不能购买、部署、准备或发送表情;观看方式与被淘汰的玩家相同——自动跟随你上次查看的博士(没查看过则是座位顺序第一位还在场的博士):休整期显示他的阵地,作战时观看他的战场,点左侧成员头像可切换查看任何人。断线重连会回到观战席;创建者可以在等待室用观战者名字旁的 ✕ 将其移出;模拟开始后,顶栏延迟旁会出现一个「👁 人数」的小胶囊(没有观战者时不显示),点开是观战者名单(离线的有标记),创建者可以在名单里用 ✕ 把观战者移出,被移出的人立刻回到大厅、不再收到战场画面(观战席上限仍是 2 个,不能调整);观战者在等待室有空位时可以点「入座」成为博士。
|
||||||
- **观看谁,盟约栏就显示谁的盟约**:查看队友的阵地或战场时(休整期、作战结束后、被淘汰后自动观战),左上的盟约栏换成**该队友的盟约、人数和层数**,前面有琥珀色的「👁 队友名」标签,点开的盟约详情也写着「队友名 的盟约」(成员一栏只标出他在场上的干员,整备区看不到);作战中层数实时增长(与他自己看到的一致)。联防和最终攻势里跟随 ‹ ›:镜头在队友的半场时显示他的盟约,在你自己的半场时显示你自己的;「全景」时,你在这个战场上作战就显示你自己的,你不在这个战场上(漏怪后观看联防、被淘汰后观战)则显示你点「前往查看」选中的那名队友,没选过就显示该战场的第一名玩家(1 号联防者 / 这一组的第一位),**不会显示你自己的**。在这样的战场上点某名队友的头像 →「前往查看」,镜头会直接移到他的半场,‹ › 上的「👁 队友名」与盟约栏的标签一致;‹ › 旁边有「返回战场」(被淘汰时没有)。盟约栏换人时,从盟约栏打开的盟约详情会自动关闭。点开场上某个干员的详情卡时,卡上的盟约(以及点它打开的盟约详情)属于**这个干员的主人**,与盟约栏当前显示谁无关——例如在「全景」点队友的干员,看到的是「队友名 的盟约」及他的人数和层数;在队友的半场点你自己的干员,看到的是你自己的。回到自己的阵地 / 战场后恢复成你自己的盟约。作战结束后观看队友时刷新页面 / 断线重连,仍会回到正在观看的队友战场,「👁 队友名」和「返回战场」都还在。已被淘汰的队友不再显示盟约(也无法被查看)。
|
- **观看谁,盟约栏就显示谁的盟约**:查看队友的阵地或战场时(休整期、作战结束后、被淘汰后自动观战),左上的盟约栏换成**该队友的盟约、人数和层数**,前面有琥珀色的「👁 队友名」标签,点开的盟约详情也写着「队友名 的盟约」(成员一栏只标出他在场上的干员,整备区看不到);作战中层数实时增长(与他自己看到的一致)。联防和最终攻势里跟随 ‹ ›:镜头在队友的半场时显示他的盟约,在你自己的半场时显示你自己的;「全景」时,你在这个战场上作战就显示你自己的,你不在这个战场上(漏怪后观看联防、被淘汰后观战)则显示你点「前往查看」选中的那名队友,没选过就显示该战场的第一名玩家(1 号联防者 / 这一组的第一位),**不会显示你自己的**。在这样的战场上点某名队友的头像 →「前往查看」,镜头会直接移到他的半场,‹ › 上的「👁 队友名」与盟约栏的标签一致;‹ › 旁边有「返回战场」(被淘汰时没有)。盟约栏换人时,从盟约栏打开的盟约详情会自动关闭。点开场上某个干员的详情卡时,卡上的盟约(以及点它打开的盟约详情)属于**这个干员的主人**,与盟约栏当前显示谁无关——例如在「全景」点队友的干员,看到的是「队友名 的盟约」及他的人数和层数;在队友的半场点你自己的干员,看到的是你自己的。回到自己的阵地 / 战场后恢复成你自己的盟约。作战结束后观看队友时刷新页面 / 断线重连,仍会回到正在观看的队友战场,「👁 队友名」和「返回战场」都还在。已被淘汰的队友不再显示盟约(也无法被查看)。
|
||||||
|
|
||||||
## 9. 结算称号
|
## 9. 结算称号
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ S→C: `welcome {playerId, token, name, serverNow}` · `room.state {code, hostId
|
|||||||
|
|
||||||
Platform additions (implemented): `welcome.{resumed, version}`, `welcome.diyKitted` (0.2.0: the operators a 自选 slot may field — server/sim/content/kits/index.js `KITTED_CHARS`, the picker's list), `room.state.inMatch`, `error.detail`; `room.closed.reason ∈ 'timeout'|'kicked'|'empty'|'shutdown'` (`kicked`: room.kick / room.removeSpectator; `empty`: a spectator whose room lost its last player); WebSocket close codes 4001 (session taken over by another socket — client must not auto-reconnect), 4002 (no hello in time), 1008 (flooding), 1001 (shutdown). The host's `room.start` counts as the host being ready. After any (re)`hello` the server pushes `room.state` and, if a match runs, the match resends `m.public`/`m.private` (+`m.field`, or the field's `b.start` under client-side combat). **Reconnect window:** a dropped session stays resumable for 10 min (`net.js NET_DEFAULTS.reconnectWindowMs`; co-op seats keep playing their last lineup meanwhile, §6.6); a session that drops while its **solo** room's match runs stays resumable for the official `config.constants.singleReconnectTime` (86 400 s = 24 h; lobby option `soloReconnectWindowMs`; research 01 §1 "24小时内随时返回") — the untimed solo run simply waits. Only when the window expires does the drop become `match.onLeave` (co-op: 中途退出 = elimination; solo: the run ends `'abandoned'`). The client keeps its token in sessionStorage plus a recent-token list in localStorage, so reopening the tab resumes. Everything lives in server memory: a server restart ends every room and run. The lobby⇄match interface (constructor opts, `start/handle/onDisconnect/onReconnect/onLeave/dispose`) is documented verbatim at the top of `server/match/Match.js` and is normative. `server/data.js` provides `getData()` + index getters (`getChess`, `getBond`, `getGarrison`, `getItem`, `getBand`, `getEffect`, `getEnemy`, `getWave`, `getStage`, `getBoss`, `getToken`, `getConfig`, `getMode`). Note: the skill trigger rule lives at `chess.skill.trigger.rule` in data.
|
Platform additions (implemented): `welcome.{resumed, version}`, `welcome.diyKitted` (0.2.0: the operators a 自选 slot may field — server/sim/content/kits/index.js `KITTED_CHARS`, the picker's list), `room.state.inMatch`, `error.detail`; `room.closed.reason ∈ 'timeout'|'kicked'|'empty'|'shutdown'` (`kicked`: room.kick / room.removeSpectator; `empty`: a spectator whose room lost its last player); 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.
|
**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, at any time — the room screen's strip and, once the match runs, the game screen's 观战席 capsule in the top bar, GitHub #120) frees one (`room.closed {reason:'kicked'}` to it, no match frame after it); `room.state.spectators: [{playerId, name, connected}]` (still pushed while the match runs). A spectator is never a player (not in `seats`, not counted for the start, never host, keeps no room alive — `room.closed {empty}` when the last human leaves), receives the room's broadcasts and watches like an eliminated player (`Match.addSpectator`: `b.start` watch / `m.field`, never `m.private`); it may only `g.watch`, leave, and store a `room.loadout` — anything else answers `error SPECTATOR`. Its seat is kept and given back on reconnect like a player's.
|
||||||
|
|
||||||
### 8.2 Match
|
### 8.2 Match
|
||||||
C→S (all carry `rid`): `g.infoReady` · `g.band {bandId}` · `g.bandSkip` · `g.bandFocus {bandId?}` (the strategy highlighted in the draft screen — what a timed-out turn takes; absent / null clears it; `WRONG_PHASE` outside BAND_DRAFT, `ALREADY` after the pick, `BAD_TARGET` for a band not allowed; §18.2) · `g.buy {slot}` · `g.refresh` · `g.freeze` · `g.levelUp` · `g.sell {uid}` · `g.move {uid, to:{area:'board', row, col}|{area:'hand', idx}, dir?}` · `g.equip {itemUid, targetUid, replaceUid?}` (`replaceUid`: which equipped item a third one replaces, §6.2) · `g.art {itemUid, row, col, dir?}` (`dir` ∈ UP|RIGHT|DOWN|LEFT from the deploy wheel, §3; absent ⇒ `to.dir`, then RIGHT; `g.move` onto the piece's own tile re-orients it; board pieces in `m.private` / `m.field {prep:true}` carry `dir`) · `g.destroy {uid}` (hand / temp items only; equipped items are locked, §6.2) · `g.reward {idx}` · `g.choice {idx}` · `g.ready {ready}` · `g.emote {id}` · `g.watch {fieldId, playerId?}` (`playerId`: the player tapped — a shared 联防 / boss pair field shows two; what an eliminated viewer follows, §25) · `g.autoplay {on}` · `g.pause {on}` (solo battles only, §14 Solo pause) · `g.unitStats {seq?}` (ROUND_START / SP_DRAFT / PREP, else `WRONG_PHASE`; eliminated: `ELIMINATED`) → push `m.unitStats` (§18.5) · `g.leave`
|
C→S (all carry `rid`): `g.infoReady` · `g.band {bandId}` · `g.bandSkip` · `g.bandFocus {bandId?}` (the strategy highlighted in the draft screen — what a timed-out turn takes; absent / null clears it; `WRONG_PHASE` outside BAND_DRAFT, `ALREADY` after the pick, `BAD_TARGET` for a band not allowed; §18.2) · `g.buy {slot}` · `g.refresh` · `g.freeze` · `g.levelUp` · `g.sell {uid}` · `g.move {uid, to:{area:'board', row, col}|{area:'hand', idx}, dir?}` · `g.equip {itemUid, targetUid, replaceUid?}` (`replaceUid`: which equipped item a third one replaces, §6.2) · `g.art {itemUid, row, col, dir?}` (`dir` ∈ UP|RIGHT|DOWN|LEFT from the deploy wheel, §3; absent ⇒ `to.dir`, then RIGHT; `g.move` onto the piece's own tile re-orients it; board pieces in `m.private` / `m.field {prep:true}` carry `dir`) · `g.destroy {uid}` (hand / temp items only; equipped items are locked, §6.2) · `g.reward {idx}` · `g.choice {idx}` · `g.ready {ready}` · `g.emote {id}` · `g.watch {fieldId, playerId?}` (`playerId`: the player tapped — a shared 联防 / boss pair field shows two; what an eliminated viewer follows, §25) · `g.autoplay {on}` · `g.pause {on}` (solo battles only, §14 Solo pause) · `g.unitStats {seq?}` (ROUND_START / SP_DRAFT / PREP, else `WRONG_PHASE`; eliminated: `ELIMINATED`) → push `m.unitStats` (§18.5) · `g.leave`
|
||||||
|
|||||||
@@ -30,6 +30,21 @@
|
|||||||
.gtop > * { pointer-events: auto; }
|
.gtop > * { pointer-events: auto; }
|
||||||
.gtop__left { display: flex; align-items: center; gap: .16rem; }
|
.gtop__left { display: flex; align-items: center; gap: .16rem; }
|
||||||
.gtop__meta { display: flex; flex-direction: column; align-items: flex-start; gap: .06rem; }
|
.gtop__meta { display: flex; flex-direction: column; align-items: flex-start; gap: .06rem; }
|
||||||
|
/* the latency pill and, beside it, the 观战席 capsule (ui/hud.js SpectatorPill: an eye + how many are watching; opens the roster) */
|
||||||
|
.gtop__net { display: flex; align-items: center; gap: .08rem; }
|
||||||
|
.specpill { --btn-bg: rgba(0, 0, 0, .5); --btn-bg-hover: rgba(0, 0, 0, .66); --btn-bd: var(--line); height: .32rem; min-width: 0; padding: 0 .12rem; gap: .05rem; border-radius: 99px; }
|
||||||
|
.specpill .btn__icon { width: .18rem; height: .18rem; color: var(--mint-500); }
|
||||||
|
.specpill__num { font-size: .18rem; font-weight: 700; color: var(--mint-400); }
|
||||||
|
.specpill.is-me { --btn-bd: var(--mint-600); }
|
||||||
|
/* the roster inside its modal */
|
||||||
|
.spec__roster { display: flex; flex-direction: column; gap: .06rem; margin: 0 0 .1rem; padding: 0; list-style: none; }
|
||||||
|
.spec__row { display: flex; align-items: center; gap: .1rem; min-height: .44rem; padding: .04rem .1rem; background: rgba(255, 255, 255, .03); border: 1px solid transparent; }
|
||||||
|
.spec__row.is-me { border-color: var(--mint-700); background: rgba(45, 212, 191, .08); }
|
||||||
|
.spec__row.is-offline .spec__name { color: var(--text-dim); }
|
||||||
|
.spec__ico { width: .18rem; height: .18rem; flex: none; color: var(--mint-500); }
|
||||||
|
.spec__row.is-offline .spec__ico { color: var(--text-dim); }
|
||||||
|
.spec__name { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: .19rem; color: var(--text-hi); }
|
||||||
|
.spec__hint { margin: .04rem 0 0; font-size: .15rem; line-height: 1.45; }
|
||||||
.gtop__exit { box-shadow: 0 .06rem .2rem rgba(0, 0, 0, .5); }
|
.gtop__exit { box-shadow: 0 .06rem .2rem rgba(0, 0, 0, .5); }
|
||||||
.gtop__center {
|
.gtop__center {
|
||||||
--bk-c: var(--mint-700);
|
--bk-c: var(--mint-700);
|
||||||
|
|||||||
+14
-1
@@ -724,6 +724,15 @@ async function mockRequest(t, f = {}) {
|
|||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
case 'g.autoplay': case 'g.leave': case 'room.leave': return {};
|
case 'g.autoplay': case 'g.leave': case 'room.leave': return {};
|
||||||
|
// the host frees a spectator seat (server/lobby.js removeSpectator; ?variant=spectators seats two, ?variant=notHost makes
|
||||||
|
// another player the host)
|
||||||
|
case 'room.removeSpectator': {
|
||||||
|
const room = store.get().room;
|
||||||
|
if (room?.hostId !== ME) fail('NOT_HOST');
|
||||||
|
if (!(room.spectators || []).some((x) => x.playerId === f.playerId)) fail('BAD_TARGET');
|
||||||
|
store.set({ room: { ...room, spectators: room.spectators.filter((x) => x.playerId !== f.playerId) } });
|
||||||
|
return {};
|
||||||
|
}
|
||||||
default: return {};
|
default: return {};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -798,7 +807,11 @@ async function boot() {
|
|||||||
me: { playerId: ME, name: '凯尔希', token: null },
|
me: { playerId: ME, name: '凯尔希', token: null },
|
||||||
connection: { status: 'online', ping: 42, attempt: 0, retryAt: 0, lastError: null, everOnline: true },
|
connection: { status: 'online', ping: 42, attempt: 0, retryAt: 0, lastError: null, everOnline: true },
|
||||||
clock: { offset: 0, rtt: 20, synced: true },
|
clock: { offset: 0, rtt: 20, synced: true },
|
||||||
room: { code: 'MOCK', hostId: ME, mode: 'coop', difficulty: 'HARD', inMatch: true, seats: PLAYERS.map((p) => ({ seat: p.seat, playerId: p.playerId, name: p.name, isBot: p.isBot, ready: true, connected: true })) },
|
room: {
|
||||||
|
code: 'MOCK', hostId: VARIANTS.has('notHost') ? 'ai_2' : ME, mode: 'coop', difficulty: 'HARD', inMatch: true,
|
||||||
|
seats: PLAYERS.map((p) => ({ seat: p.seat, playerId: p.playerId, name: p.name, isBot: p.isBot, ready: true, connected: true })),
|
||||||
|
...(VARIANTS.has('spectators') ? { spectators: [{ playerId: 'sp_1', name: '观战者甲', connected: true }, { playerId: 'sp_2', name: '观战者乙', connected: false }] } : {}),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
await data.loadAll(GAME_FILES);
|
await data.loadAll(GAME_FILES);
|
||||||
installDeviceSupport();
|
installDeviceSupport();
|
||||||
|
|||||||
@@ -777,6 +777,7 @@
|
|||||||
"观战席": "Spectators",
|
"观战席": "Spectators",
|
||||||
"移出观战者 {name}": "Remove spectator {name}",
|
"移出观战者 {name}": "Remove spectator {name}",
|
||||||
"移出该观战者": "Remove this spectator",
|
"移出该观战者": "Remove this spectator",
|
||||||
|
"观战者不占博士席位,只能观看;创建者可以把观战者移出,被移出的人不再收到战场画面。": "Spectators take no Doctor seat and can only watch; the Host can remove a spectator, who then no longer receives the battlefield.",
|
||||||
"入座": "Take a Seat",
|
"入座": "Take a Seat",
|
||||||
"移出同盟": "Remove from Alliance",
|
"移出同盟": "Remove from Alliance",
|
||||||
"确定将「{name}」移出同盟吗?对方可以凭同盟密钥重新加入。": "Remove \"{name}\" from the Alliance? They can rejoin with the Alliance Key.",
|
"确定将「{name}」移出同盟吗?对方可以凭同盟密钥重新加入。": "Remove \"{name}\" from the Alliance? They can rejoin with the Alliance Key.",
|
||||||
|
|||||||
@@ -778,6 +778,7 @@
|
|||||||
"观战席": "観戦席",
|
"观战席": "観戦席",
|
||||||
"移出观战者 {name}": "観戦者{name}を除外",
|
"移出观战者 {name}": "観戦者{name}を除外",
|
||||||
"移出该观战者": "この観戦者を除外",
|
"移出该观战者": "この観戦者を除外",
|
||||||
|
"观战者不占博士席位,只能观看;创建者可以把观战者移出,被移出的人不再收到战场画面。": "観戦者はドクター枠を使わず、観戦のみ可能です。ホストは観戦者を除外でき、除外された人には戦場の映像が届かなくなります。",
|
||||||
"入座": "着席",
|
"入座": "着席",
|
||||||
"移出同盟": "同盟から除外",
|
"移出同盟": "同盟から除外",
|
||||||
"确定将「{name}」移出同盟吗?对方可以凭同盟密钥重新加入。": "「{name}」を同盟から除外しますか?相手は同盟コードで再参加できます。",
|
"确定将「{name}」移出同盟吗?对方可以凭同盟密钥重新加入。": "「{name}」を同盟から除外しますか?相手は同盟コードで再参加できます。",
|
||||||
|
|||||||
@@ -774,6 +774,7 @@
|
|||||||
"观战席": "관전석",
|
"观战席": "관전석",
|
||||||
"移出观战者 {name}": "관전자 {name} 내보내기",
|
"移出观战者 {name}": "관전자 {name} 내보내기",
|
||||||
"移出该观战者": "이 관전자 내보내기",
|
"移出该观战者": "이 관전자 내보내기",
|
||||||
|
"观战者不占博士席位,只能观看;创建者可以把观战者移出,被移出的人不再收到战场画面。": "관전자는 박사 자리를 차지하지 않고 관전만 할 수 있으며, 방장은 관전자를 내보낼 수 있습니다. 내보내진 사람은 더 이상 전장 화면을 받지 않습니다.",
|
||||||
"入座": "착석",
|
"入座": "착석",
|
||||||
"移出同盟": "동맹에서 내보내기",
|
"移出同盟": "동맹에서 내보내기",
|
||||||
"确定将「{name}」移出同盟吗?对方可以凭同盟密钥重新加入。": "{name} 님을 동맹에서 내보내시겠습니까? 동맹 코드로 다시 참가할 수 있습니다.",
|
"确定将「{name}」移出同盟吗?对方可以凭同盟密钥重新加入。": "{name} 님을 동맹에서 내보내시겠습니까? 동맹 코드로 다시 참가할 수 있습니다.",
|
||||||
|
|||||||
@@ -776,6 +776,7 @@
|
|||||||
"观战席": "觀戰席",
|
"观战席": "觀戰席",
|
||||||
"移出观战者 {name}": "移出觀戰者 {name}",
|
"移出观战者 {name}": "移出觀戰者 {name}",
|
||||||
"移出该观战者": "移出該觀戰者",
|
"移出该观战者": "移出該觀戰者",
|
||||||
|
"观战者不占博士席位,只能观看;创建者可以把观战者移出,被移出的人不再收到战场画面。": "觀戰者不佔博士席位,只能觀看;建立者可以把觀戰者移出,被移出的人不再收到戰場畫面。",
|
||||||
"入座": "入座",
|
"入座": "入座",
|
||||||
"移出同盟": "移出同盟",
|
"移出同盟": "移出同盟",
|
||||||
"确定将「{name}」移出同盟吗?对方可以凭同盟密钥重新加入。": "確定將「{name}」移出同盟嗎?對方可以憑同盟金鑰重新加入。",
|
"确定将「{name}」移出同盟吗?对方可以凭同盟密钥重新加入。": "確定將「{name}」移出同盟嗎?對方可以憑同盟金鑰重新加入。",
|
||||||
|
|||||||
@@ -159,6 +159,12 @@ function MatchScreen() {
|
|||||||
const emotes = useStore((s) => s.emotes);
|
const emotes = useStore((s) => s.emotes);
|
||||||
const roomSolo = useStore((s) => s.room?.mode === 'solo');
|
const roomSolo = useStore((s) => s.room?.mode === 'solo');
|
||||||
const spectator = useStore((s) => isSpectating(s.room, s.me.playerId));
|
const spectator = useStore((s) => isSpectating(s.room, s.me.playerId));
|
||||||
|
// the room's spectator seats for the top bar's 观战席 capsule (ui/hud.js SpectatorPill; GitHub #120): room.state keeps coming
|
||||||
|
// during the match, the list is replaced only when a seat changes — shallowEqual keeps an unrelated push from re-rendering
|
||||||
|
const specFacts = useStore((s) => ({
|
||||||
|
list: Array.isArray(s.room?.spectators) ? s.room.spectators : null,
|
||||||
|
isHost: !!s.room && s.room.hostId === s.me.playerId,
|
||||||
|
}), shallowEqual);
|
||||||
const gd = useGameData();
|
const gd = useGameData();
|
||||||
|
|
||||||
const hostRef = useRef(null);
|
const hostRef = useRef(null);
|
||||||
@@ -717,6 +723,7 @@ function MatchScreen() {
|
|||||||
|
|
||||||
// ---- actions ----------------------------------------------------------------------------------------------
|
// ---- actions ----------------------------------------------------------------------------------------------
|
||||||
const buy = useCallback((i) => actions.buy(i), []);
|
const buy = useCallback((i) => actions.buy(i), []);
|
||||||
|
const removeSpectator = useCallback((playerId) => actions.removeSpectator(playerId), []);
|
||||||
// 准备 with funds left asks first: the prep's end wipes them (community report #4; not 坎诺特, not at 0 funds, not
|
// 准备 with funds left asks first: the prep's end wipes them (community report #4; not 坎诺特, not at 0 funds, not
|
||||||
// under AI 托管 — gameLogic.readyFundsPrompt). The button and Space both come here.
|
// under AI 托管 — gameLogic.readyFundsPrompt). The button and Space both come here.
|
||||||
const askingReady = useRef(false);
|
const askingReady = useRef(false);
|
||||||
@@ -1363,7 +1370,8 @@ function MatchScreen() {
|
|||||||
readyBusy=${readyBusy} readyCount=${readyCount} playerCount=${solo ? 1 : aliveCount}
|
readyBusy=${readyBusy} readyCount=${readyCount} playerCount=${solo ? 1 : aliveCount}
|
||||||
pen=${pen} penAvail=${penAvail} onPen=${togglePen} config=${gd.config} frozenAt=${frozenAt}
|
pen=${pen} penAvail=${penAvail} onPen=${togglePen} config=${gd.config} frozenAt=${frozenAt}
|
||||||
pause=${canPause || paused ? { show: canPause, paused, busy: pauseBusy, onToggle: () => togglePause(!paused) } : null}
|
pause=${canPause || paused ? { show: canPause, paused, busy: pauseBusy, onToggle: () => togglePause(!paused) } : null}
|
||||||
live=${liveLpNow} spectator=${spectator} />
|
live=${liveLpNow} spectator=${spectator}
|
||||||
|
spectators=${specFacts.list} myId=${myId} isHost=${specFacts.isHost} onRemoveSpectator=${removeSpectator} />
|
||||||
|
|
||||||
<div class="gm__bonds">
|
<div class="gm__bonds">
|
||||||
<button type="button" class="bonds-toggle" aria-expanded=${!bondsCollapsed} aria-controls="match-bond-strip"
|
<button type="button" class="bonds-toggle" aria-expanded=${!bondsCollapsed} aria-controls="match-bond-strip"
|
||||||
|
|||||||
@@ -72,4 +72,7 @@ export const actions = {
|
|||||||
autoplay: (on) => act('g.autoplay', { on }),
|
autoplay: (on) => act('g.autoplay', { on }),
|
||||||
// solo battles only (ui/matchStatus.js pauseAvailable): m.public.paused follows
|
// solo battles only (ui/matchStatus.js pauseAvailable): m.public.paused follows
|
||||||
pause: (on) => act('g.pause', { on: !!on }, { sfx: on ? 'click' : 'confirm' }),
|
pause: (on) => act('g.pause', { on: !!on }, { sfx: on ? 'click' : 'confirm' }),
|
||||||
|
// room-level intent (NOT g.*): the host frees a spectator seat while the match runs — the server takes room.removeSpectator at
|
||||||
|
// any time (server/lobby.js removeSpectator), the game screen had no entry for it (ui/hud.js SpectatorPill; GitHub #120)
|
||||||
|
removeSpectator: (playerId) => act('room.removeSpectator', { playerId }, { sfx: 'back' }),
|
||||||
};
|
};
|
||||||
+50
-5
@@ -23,9 +23,9 @@
|
|||||||
// 准备就绪 is refused while the temp overflow row (临时整备区) holds pieces: the reason shows under the button
|
// 准备就绪 is refused while the temp overflow row (临时整备区) holds pieces: the reason shows under the button
|
||||||
// (user playtest #3 item 3; the row's own label is ui/underframe.js TempRowNotice).
|
// (user playtest #3 item 3; the row's own label is ui/underframe.js TempRowNotice).
|
||||||
|
|
||||||
import { useRef } from '../../vendor/hooks.module.js';
|
import { useEffect, useRef, useState } from '../../vendor/hooks.module.js';
|
||||||
import { PHASE } from '../../../shared/constants.js';
|
import { PHASE } from '../../../shared/constants.js';
|
||||||
import { html, Button, Icon, PingPill, Countdown, Tooltip, MicroLabel, DifficultyTag, useTicker } from './components.js';
|
import { html, Button, Icon, PingPill, Countdown, Tooltip, MicroLabel, DifficultyTag, Modal, useTicker } from './components.js';
|
||||||
import { Sprite, LpTower, GIcon, LocalSprite } from './gameComponents.js';
|
import { Sprite, LpTower, GIcon, LocalSprite } from './gameComponents.js';
|
||||||
import { localAsset } from '../data.js';
|
import { localAsset } from '../data.js';
|
||||||
import { serverNow } from '../store.js';
|
import { serverNow } from '../store.js';
|
||||||
@@ -36,6 +36,45 @@ import { t, tParts, N_ } from '../../../shared/i18n.js';
|
|||||||
|
|
||||||
const cx = (...p) => p.flat().filter(Boolean).join(' ');
|
const cx = (...p) => p.flat().filter(Boolean).join(' ');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 观战席 capsule of the in-match top bar (GitHub #120, PR #120 by @salt-fishes; the spectator seats of community report #26):
|
||||||
|
* the room's spectators, which the room screen lists in a strip but the game screen — the route after 开始 — used to hide
|
||||||
|
* completely, so a spectator could only be removed before the match or after it. A small eye + the count beside the latency;
|
||||||
|
* a tap opens the roster, with the host's ✕ on every row (room.removeSpectator: the server takes it at any time; the removed
|
||||||
|
* seat gets room.closed {kicked} and no more frames). Renders nothing while no spectator seat is taken.
|
||||||
|
* @param {{ spectators: any[]|null, myId: any, isHost: boolean, onRemove: ((playerId: any) => any)|null }} props
|
||||||
|
*/
|
||||||
|
export function SpectatorPill({ spectators, myId, isHost, onRemove }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [busy, setBusy] = useState(null);
|
||||||
|
const list = Array.isArray(spectators) ? spectators.filter((s) => s && typeof s === 'object') : [];
|
||||||
|
// the roster closes with its last seat (the pill is gone: it must not pop up again for the next spectator)
|
||||||
|
useEffect(() => { if (!list.length) setOpen(false); }, [list.length]);
|
||||||
|
if (!list.length) return null;
|
||||||
|
const remove = async (playerId) => {
|
||||||
|
if (!onRemove) return;
|
||||||
|
setBusy(playerId);
|
||||||
|
try { await onRemove(playerId); } finally { setBusy(null); }
|
||||||
|
};
|
||||||
|
return html`<${Button} variant="ghost" size="sm" icon="eye" class=${cx('specpill', list.some((s) => s.playerId === myId) && 'is-me')}
|
||||||
|
onClick=${() => setOpen(true)} data-testid="spectators" title=${t('观战席')} aria-label=${t('观战席')}>
|
||||||
|
<span class="specpill__num num">${list.length}</span>
|
||||||
|
<//>
|
||||||
|
${open ? html`<${Modal} open=${true} title=${t('观战席')} micro="SPECTATORS" width="6.4rem" onClose=${() => setOpen(false)}
|
||||||
|
actions=${html`<${Button} variant="secondary" onClick=${() => setOpen(false)}>${t('关闭')}<//>`}>
|
||||||
|
<ul class="spec__roster" data-testid="spectator-roster">
|
||||||
|
${list.map((s) => html`<li key=${s.playerId} class=${cx('spec__row', s.playerId === myId && 'is-me', s.connected === false && 'is-offline')}>
|
||||||
|
<${Icon} name=${s.connected === false ? 'wifiOff' : 'eye'} class="spec__ico" />
|
||||||
|
<span class="spec__name">${s.name || t('博士')}</span>
|
||||||
|
${s.playerId === myId ? html`<span class="seat__you">${t('你')}</span>` : null}
|
||||||
|
${isHost && onRemove ? html`<${Button} variant="ghost" size="sm" square=${true} icon="close" loading=${busy === s.playerId} data-testid="spectator-remove"
|
||||||
|
onClick=${() => remove(s.playerId)} aria-label=${t('移出观战者 {name}', { name: s.name || '' })} title=${t('移出该观战者')} />` : null}
|
||||||
|
</li>`)}
|
||||||
|
</ul>
|
||||||
|
<p class="spec__hint t-lo">${t('观战者不占博士席位,只能观看;创建者可以把观战者移出,被移出的人不再收到战场画面。')}</p>
|
||||||
|
<//>` : null}`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Phase capsule: prep label, kills n/m (combat/unite), kills + boss HP bar (boss rounds).
|
* Phase capsule: prep label, kills n/m (combat/unite), kills + boss HP bar (boss rounds).
|
||||||
* @param {{ pub:any, hud:any }} props
|
* @param {{ pub:any, hud:any }} props
|
||||||
@@ -306,13 +345,16 @@ export function PauseButton({ paused, busy = false, onToggle }) {
|
|||||||
* onReady:(r:boolean)=>void, readyBusy?:boolean, readyCount?:number, playerCount?:number,
|
* onReady:(r:boolean)=>void, readyBusy?:boolean, readyCount?:number, playerCount?:number,
|
||||||
* pen?:boolean, penAvail?:boolean, onPen?:(on:boolean)=>void, config?: any, frozenAt?: number|null,
|
* pen?:boolean, penAvail?:boolean, onPen?:(on:boolean)=>void, config?: any, frozenAt?: number|null,
|
||||||
* pause?: { show: boolean, paused: boolean, busy?: boolean, onToggle: () => void } | null,
|
* pause?: { show: boolean, paused: boolean, busy?: boolean, onToggle: () => void } | null,
|
||||||
* live?: { pending: number, unite: boolean, left?: number|null } | null }} props
|
* live?: { pending: number, unite: boolean, left?: number|null } | null,
|
||||||
|
* spectators?: any[]|null, myId?: any, isHost?: boolean, onRemoveSpectator?: ((playerId: any) => any)|null }} props
|
||||||
|
* spectators: the room's spectator seats (room.state) — the 观战席 capsule beside the latency (SpectatorPill; the host removes)
|
||||||
* frozenAt: the server time every clock shows while the solo match is paused (null = live)
|
* frozenAt: the server time every clock shows while the solo match is paused (null = live)
|
||||||
* live: the own battle's pending LP loss (liveLp): the tower shows lp − pending in red with a −N tick, 联防中 during 联防;
|
* live: the own battle's pending LP loss (liveLp): the tower shows lp − pending in red with a −N tick, 联防中 during 联防;
|
||||||
* `left` (a leaker in 联防): its enemies still standing — the capsule's ×N tag
|
* `left` (a leaker in 联防): its enemies still standing — the capsule's ×N tag
|
||||||
*/
|
*/
|
||||||
export function TopBar({ pub, priv, conn, hud, total, drawer, onExit, onDrawer, onReady, readyBusy, readyCount, playerCount, pen = false, penAvail = false, onPen = () => {},
|
export function TopBar({ pub, priv, conn, hud, total, drawer, onExit, onDrawer, onReady, readyBusy, readyCount, playerCount, pen = false, penAvail = false, onPen = () => {},
|
||||||
config = null, frozenAt = null, pause = null, live = null, spectator = false }) {
|
config = null, frozenAt = null, pause = null, live = null, spectator = false,
|
||||||
|
spectators = null, myId = null, isHost = false, onRemoveSpectator = null }) {
|
||||||
const phase = pub?.phase;
|
const phase = pub?.phase;
|
||||||
const boss = isBossPhase(phase);
|
const boss = isBossPhase(phase);
|
||||||
const lp = boss && Number.isFinite(pub?.teamLp) ? pub.teamLp : Number.isFinite(priv?.lp) ? priv.lp : null;
|
const lp = boss && Number.isFinite(pub?.teamLp) ? pub.teamLp : Number.isFinite(priv?.lp) ? priv.lp : null;
|
||||||
@@ -338,7 +380,10 @@ export function TopBar({ pub, priv, conn, hud, total, drawer, onExit, onDrawer,
|
|||||||
<div class="gtop__left">
|
<div class="gtop__left">
|
||||||
<${Button} variant="danger" size="lg" square=${true} icon="exit" onClick=${onExit} aria-label=${t('离开')} title=${t('离开 / 暂离')} class="gtop__exit tapx" />
|
<${Button} variant="danger" size="lg" square=${true} icon="exit" onClick=${onExit} aria-label=${t('离开')} title=${t('离开 / 暂离')} class="gtop__exit tapx" />
|
||||||
<div class="gtop__meta">
|
<div class="gtop__meta">
|
||||||
<${PingPill} ms=${conn?.ping} online=${conn?.status === 'online'} />
|
<div class="gtop__net">
|
||||||
|
<${PingPill} ms=${conn?.ping} online=${conn?.status === 'online'} />
|
||||||
|
<${SpectatorPill} spectators=${spectators} myId=${myId} isHost=${isHost} onRemove=${onRemoveSpectator} />
|
||||||
|
</div>
|
||||||
${pub?.difficulty ? html`<${DifficultyTag} difficulty=${pub.difficulty} size="sm" />` : null}
|
${pub?.difficulty ? html`<${DifficultyTag} difficulty=${pub.difficulty} size="sm" />` : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -202,6 +202,69 @@ test('co-op spectator over websockets: watches the real match like an eliminated
|
|||||||
assert.deepEqual(errors, []);
|
assert.deepEqual(errors, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// GitHub #120 (PR #120 by @salt-fishes — the in-match 观战席 list, ui/hud.js SpectatorPill): the host frees a spectator seat WHILE
|
||||||
|
// the match runs. The server took room.removeSpectator at any time (server/lobby.js removeSpectator; its header says "any
|
||||||
|
// time") but the lobby tests only removed one in the lobby: this is the running match.
|
||||||
|
test('co-op spectator removed by the host while the match runs: room.closed {kicked}, the seat freed and refilled, no frame after it, the other spectator watches on', async () => {
|
||||||
|
FakeBattle.reset();
|
||||||
|
FakeBattle.script = () => ({ duration: 1 });
|
||||||
|
const watcher = async (name) => {
|
||||||
|
const srvNow = await server();
|
||||||
|
const c = await TestClient.connect(`ws://127.0.0.1:${srvNow.port}/ws`);
|
||||||
|
clients.push(c);
|
||||||
|
const w = await c.hello(name);
|
||||||
|
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 s1 = await watcher('Spec1');
|
||||||
|
const s2 = await watcher('Spec2');
|
||||||
|
await ok(s1, { t: 'room.spectate', code: st.code });
|
||||||
|
await ok(s2, { t: 'room.spectate', code: st.code });
|
||||||
|
await ok(a, { t: 'room.start' });
|
||||||
|
await s1.waitFor('m.public', (p) => p.phase === 'INFO_CHECK');
|
||||||
|
await ok(a, { t: 'g.infoReady' });
|
||||||
|
await pickCoopBands([a]);
|
||||||
|
await a.waitFor('m.public', (p) => p.phase === 'PREP' && p.round === 1, 10000);
|
||||||
|
const running = await a.waitFor('room.state', (x) => x.inMatch && x.spectators.length === 2);
|
||||||
|
assert.deepEqual(running.spectators.map((x) => x.playerId), [s1.id, s2.id], 'both seats are listed while the match runs: what the game screen\'s capsule shows');
|
||||||
|
|
||||||
|
// who may remove: the host only, and only a spectator of this room
|
||||||
|
assert.equal((await s1.request({ t: 'room.removeSpectator', playerId: s2.id })).code, 'NOT_HOST', 'a spectator cannot remove another');
|
||||||
|
assert.equal((await a.request({ t: 'room.removeSpectator', playerId: 'nobody' })).code, 'BAD_TARGET');
|
||||||
|
assert.equal((await a.request({ t: 'room.removeSpectator', playerId: a.id })).code, 'BAD_TARGET', 'a player is no spectator');
|
||||||
|
|
||||||
|
// the host removes the first one: told room.closed {kicked}; the room.state of the match now lists the other only
|
||||||
|
await ok(a, { t: 'room.removeSpectator', playerId: s1.id });
|
||||||
|
assert.equal((await s1.waitFor('room.closed', () => true, 5000)).reason, 'kicked');
|
||||||
|
const after = await a.waitFor('room.state', (x) => x.inMatch && x.spectators.length === 1);
|
||||||
|
assert.deepEqual(after.spectators.map((x) => x.playerId), [s2.id]);
|
||||||
|
assert.equal((await s2.waitFor('room.state', (x) => x.inMatch && x.spectators.length === 1)).spectators[0].playerId, s2.id, 'the other spectator is told too');
|
||||||
|
assert.equal((await a.request({ t: 'room.removeSpectator', playerId: s1.id })).code, 'BAD_TARGET', 'a seat already freed');
|
||||||
|
assert.equal((await s1.request({ t: 'g.watch', fieldId: `n:${a.id}` })).code, 'NOT_IN_ROOM', 'out of the room: no more watching');
|
||||||
|
|
||||||
|
// the match runs on: the removed seat gets no frame of it any more, the other spectator and the player do
|
||||||
|
const mark = s1.log.length;
|
||||||
|
s2.log.length = 0;
|
||||||
|
await ok(a, { t: 'g.ready', ready: true });
|
||||||
|
await s2.waitFor('b.start', (x) => x.kind === 'normal', 10000);
|
||||||
|
await a.waitFor('m.public', (p) => p.phase === 'PREP' && p.round === 2, 15000);
|
||||||
|
assert.deepEqual(s1.log.slice(mark).filter((x) => x.t !== 'pong').map((x) => x.t), [], 'nothing reaches the removed spectator');
|
||||||
|
assert.ok(s2.log.some((x) => x.t === 'm.public'), 'the other spectator still gets the match');
|
||||||
|
|
||||||
|
// the freed seat can be taken again (no ban list: the key is all it takes)
|
||||||
|
const s3 = await watcher('Spec3');
|
||||||
|
await ok(s3, { t: 'room.spectate', code: st.code });
|
||||||
|
const refilled = await a.waitFor('room.state', (x) => x.inMatch && x.spectators.length === 2);
|
||||||
|
assert.deepEqual(refilled.spectators.map((x) => x.playerId), [s2.id, s3.id]);
|
||||||
|
assert.deepEqual(errors, []);
|
||||||
|
await ok(a, { t: 'g.leave' });
|
||||||
|
});
|
||||||
|
|
||||||
test('server-run fallback (SP_COMBAT=server): the match simulates every field and streams m.field + b.snap', async () => {
|
test('server-run fallback (SP_COMBAT=server): the match simulates every field and streams m.field + b.snap', async () => {
|
||||||
FakeBattle.reset();
|
FakeBattle.reset();
|
||||||
FakeBattle.script = () => ({ duration: 1 });
|
FakeBattle.script = () => ({ duration: 1 });
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
// Browser check of the in-match 观战席 capsule (GitHub #120, PR #120 by @salt-fishes; opt-in, ~10 s) on the in-match mock
|
||||||
|
// harness (public/dev/game-mock.html):
|
||||||
|
// SP_E2E=1 CHROME_PATH=… node --test test/ui/spectator-roster.e2e.test.js
|
||||||
|
//
|
||||||
|
// The room screen lists the spectator seats and lets the host remove one, but the route after 开始 is the game screen: a
|
||||||
|
// running match showed nothing of them. Now the top bar carries a capsule beside the latency — an eye and how many are
|
||||||
|
// watching, nothing at all while the room has no spectator; a tap opens the roster (names, an offline mark, the host's ✕ on
|
||||||
|
// every row: room.removeSpectator). Asserted: no capsule without spectators (and none in the default mock), the count and
|
||||||
|
// the rows, the ✕ only for the host, the request the ✕ sends, the roster and the count following the room.state that
|
||||||
|
// comes back, the capsule gone with the last seat, and the roster closing with Esc / 关闭. No console error. The server side
|
||||||
|
// (the seat freed at any time, room.closed {kicked}, no frame after it) is test/lobby.test.js and test/match/lobby-integration.test.js.
|
||||||
|
|
||||||
|
import { test, describe, before, after } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
|
||||||
|
const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
|
||||||
|
const ENABLED = process.env.SP_E2E === '1' && existsSync(CHROME);
|
||||||
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
describe('the 观战席 capsule of the match top bar', { skip: !ENABLED && 'set SP_E2E=1 (and have Chrome) to run' }, () => {
|
||||||
|
let srv;
|
||||||
|
let browser;
|
||||||
|
let base;
|
||||||
|
before(async () => {
|
||||||
|
const { startServer } = await import('../../server/index.js');
|
||||||
|
const puppeteer = (await import('puppeteer-core')).default;
|
||||||
|
srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
|
||||||
|
base = `http://127.0.0.1:${srv.port}`;
|
||||||
|
browser = await puppeteer.launch({ executablePath: CHROME, headless: true, args: ['--no-sandbox', '--force-device-scale-factor=1'] });
|
||||||
|
});
|
||||||
|
after(async () => {
|
||||||
|
await browser?.close();
|
||||||
|
await srv?.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function open(query) {
|
||||||
|
const page = await browser.newPage();
|
||||||
|
await page.setViewport({ width: 1600, height: 900 });
|
||||||
|
const problems = [];
|
||||||
|
page.on('console', (m) => { if (m.type() === 'error') problems.push(`console: ${m.text()}`); });
|
||||||
|
page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`));
|
||||||
|
await page.goto(`${base}/dev/game-mock.html?render=fallback&${query}`, { waitUntil: 'networkidle0', timeout: 60000 });
|
||||||
|
await page.waitForSelector('.gtop', { timeout: 15000 });
|
||||||
|
await sleep(400);
|
||||||
|
return { page, problems };
|
||||||
|
}
|
||||||
|
const pill = (page) => page.evaluate(() => {
|
||||||
|
const el = document.querySelector('[data-testid="spectators"]');
|
||||||
|
return el ? { text: el.textContent.trim(), me: el.classList.contains('is-me') } : null;
|
||||||
|
});
|
||||||
|
const roster = (page) => page.evaluate(() => {
|
||||||
|
const ul = document.querySelector('[data-testid="spectator-roster"]');
|
||||||
|
if (!ul) return null;
|
||||||
|
return [...ul.querySelectorAll('li')].map((li) => ({
|
||||||
|
name: li.querySelector('.spec__name')?.textContent.trim(), offline: li.classList.contains('is-offline'), remove: !!li.querySelector('[data-testid="spectator-remove"]'),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
const requests = (page) => page.evaluate(() => globalThis.__MOCK__.S().requests.filter((r) => r[0] === 'room.removeSpectator'));
|
||||||
|
|
||||||
|
test('no spectator, no capsule: the default match top bar is as it was', async () => {
|
||||||
|
const { page, problems } = await open('phase=COMBAT');
|
||||||
|
assert.equal(await pill(page), null);
|
||||||
|
assert.ok(await page.$('.gtop__net .ping'), 'the latency pill is still there');
|
||||||
|
assert.deepEqual(problems, []);
|
||||||
|
await page.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the host: the capsule counts the seats, the roster lists them with a ✕ each, removing one updates both, the last one removes the capsule', async () => {
|
||||||
|
const { page, problems } = await open('phase=COMBAT&variant=spectators');
|
||||||
|
assert.deepEqual(await pill(page), { text: '2', me: false }, 'an eye and the count');
|
||||||
|
assert.ok(await page.$('.gtop__net [data-testid="spectators"]'), 'beside the latency pill');
|
||||||
|
assert.equal(await roster(page), null, 'closed until asked');
|
||||||
|
await page.click('[data-testid="spectators"]');
|
||||||
|
await page.waitForSelector('[data-testid="spectator-roster"]', { timeout: 5000 });
|
||||||
|
assert.deepEqual(await roster(page), [
|
||||||
|
{ name: '观战者甲', offline: false, remove: true }, { name: '观战者乙', offline: true, remove: true },
|
||||||
|
]);
|
||||||
|
// ✕ on the first row: room.removeSpectator { playerId } goes out, the room.state that follows takes the row away
|
||||||
|
await page.click('[data-testid="spectator-roster"] li:first-child [data-testid="spectator-remove"]');
|
||||||
|
await page.waitForFunction(() => document.querySelectorAll('[data-testid="spectator-roster"] li').length === 1, { timeout: 5000 });
|
||||||
|
assert.deepEqual(await requests(page), [['room.removeSpectator', { playerId: 'sp_1' }]]);
|
||||||
|
assert.deepEqual((await roster(page)).map((r) => r.name), ['观战者乙']);
|
||||||
|
assert.equal((await pill(page)).text, '1');
|
||||||
|
// Esc closes the roster, the capsule stays
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await sleep(250);
|
||||||
|
assert.equal(await roster(page), null);
|
||||||
|
assert.equal((await pill(page)).text, '1');
|
||||||
|
// the last seat: the open roster and the capsule go with it
|
||||||
|
await page.click('[data-testid="spectators"]');
|
||||||
|
await page.waitForSelector('[data-testid="spectator-roster"]', { timeout: 5000 });
|
||||||
|
await page.click('[data-testid="spectator-remove"]');
|
||||||
|
await page.waitForFunction(() => !document.querySelector('[data-testid="spectators"]') && !document.querySelector('.modal'), { timeout: 5000 });
|
||||||
|
assert.deepEqual(problems, []);
|
||||||
|
await page.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a player who is not the host sees the roster but no ✕; 关闭 closes it', async () => {
|
||||||
|
const { page, problems } = await open('phase=COMBAT&variant=spectators,notHost');
|
||||||
|
assert.equal((await pill(page)).text, '2');
|
||||||
|
await page.click('[data-testid="spectators"]');
|
||||||
|
await page.waitForSelector('[data-testid="spectator-roster"]', { timeout: 5000 });
|
||||||
|
assert.deepEqual((await roster(page)).map((r) => r.remove), [false, false], 'only the host removes');
|
||||||
|
assert.match(await page.evaluate(() => document.querySelector('.modal')?.textContent ?? ''), /创建者可以把观战者移出/);
|
||||||
|
await page.evaluate(() => [...document.querySelectorAll('.modal button')].find((b) => /关闭/.test(b.textContent))?.click());
|
||||||
|
await sleep(250);
|
||||||
|
assert.equal(await roster(page), null);
|
||||||
|
assert.deepEqual(await requests(page), []);
|
||||||
|
assert.deepEqual(problems, []);
|
||||||
|
await page.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user