feedback5 CB5: the boss round's prep shows both players of a pair together — the partner's pieces on the other half, as the battle places them — and frames the pair's two avatars in the official green (community report of 2026-10-06, item 51)

The own half was already the official one (the right-hand player on the right half with the
right_boss_*_camera_param camera, its layout mirrored col c -> 20 - c with RIGHT <-> LEFT, so its operators
face the leader as before: research 09 §1.2, DESIGN §15); the partner's half stood empty and nothing framed
the pair.
Server: in a boss round's prep m.private.bossMate { playerId, side, units } = the partner's board as UnitInfo
on its half (Match.bossMateView, finalAssault.js bossFieldPlacement); markPrivate marks the partner's
m.private with the mover's, so it follows live; a scout of either player (Match.prepFieldMeta) carries both
halves (`mate`), and its signature covers the partner's board.
Client: render/app.js syncMates draws the partner's units read-only ('m:<uid>' views: never picked, dragged
or swept with the own pieces) and the whole boss field is lit; the team panel frames the pair's avatars
(gameLogic teamFrameIds: the players still in by seat, two by two, as prepCamera — the boss field in the
fight) from the round start through the Final Assault / Hidden Core, with the official ui/battle
bg_team_border tinted the autochess green (#4ed8af; a plain ring without the local art); none for a lone
player, an eliminated player, a spectator seat, or 联防.
Overlap with PR #191 (same-field frames, issue #190; not merged): it published m.public bossPairing and
framed the partner alone in gold, 联防 included, plus a banner naming the partner; here the pairing is read
as the prep camera already does, both avatars get the official green frame, boss rounds only.
[ASSUMED] the partner's bench is not shown; one frame per avatar.

Golden unchanged. Tests: test/match/feedback5-boss-pair-prep.test.js (new, 4), the item 55 test now checks
the scouted player's own pieces (the partner's stand on the other half); SP_E2E
test/ui/feedback5-boss-pair.e2e.test.js (new, 2: both halves and the frames in prep and fight; item 55's
eliminated guest on the boss field with the leader). Docs: design/client.md §10 / §15, design/network.md,
PLAYING.md.
This commit is contained in:
sganggs committed 2026-10-07 02:29:49 +08:00
1 parent f14f03a59f
commit ef10164773
16 files changed
+436 -33

No files matched your search

+1
View File
@@ -118,6 +118,7 @@
- **最终攻势**(第 14 回合;独立标准为第 9 回合):按座位两两配对,每对共享一个战场,一人守左半边、一人守右半边(右侧玩家的棋盘左右镜像)。所有人**共同削减同一条领袖血条**(血量 = 该难度的官方领袖生命值 × 开战时存活的玩家人数:领袖生命值取自当前版本的游戏数据,例如终极「假想敌:胄」3 600 000,4 人存活时为 14 400 000、2 人为 7 200 000;AI 队友和掉线托管的玩家都算,已被淘汰或中途退出的不算;独立模拟按 1 人计。这是作者 2026-10-06 采纳社区 PR #209 的决定,取代之前「保持固定血量」的做法),看不到另一组的战场。全队 LP 合并成一个池,不再有每回合 10 点上限。顶部的 120 秒倒计时结束后战斗**继续**(不会超时判负);从开战第 150 秒起(实际秒数)全队每秒扣 1 点 LP。领袖血量归零即胜利(血条显示 0 时领袖已倒下,战斗立即结束),LP 归零即失败——以先归零的一方为准,LP 耗尽后打出的伤害不再计入。此阶段盟约不叠层。
- **限伤**(最终攻势与隐秘核心,与官方相同):对领袖的**单次伤害达到 300000 或以上时整次作废**——这一下造成 0 点伤害,不计入领袖血条,也不显示伤害数字;不是截到 300000,而是一点都不算(299999 照常生效)。只针对领袖本体(含两人战场里的另一个领袖),领袖的部件、护卫、无人机和其他小怪不受影响,普通回合和联防也不受影响;部件把伤害等量转给领袖时,这一下也按一次伤害检查;无人机死亡令领袖失去的 2% 血条不算一次伤害,不受限伤影响(隐秘核心终极「假想敌:胄」3 或 4 人存活时每架为 43.2 万 / 57.6 万,按限伤会整次作废,本作仍然计入,属推断)。层数极高时领袖仍可能几秒内倒下(例如每名玩家的两个盟约都在 999 层,或所有激活的盟约都在 999 层),这来自官方按层数计算、没有上限的伤害倍率(谢拉格、奥术、精准等),官方社区也有「boss一秒死」的说法。
- 最终攻势 / 隐秘核心的休整期和战斗中一样,**同组两人在同一个关底战场上**:你在自己的半场摆位(右侧玩家看到自己在右边,摆位左右镜像,干员相对领袖的朝向不变),队友当前的阵容显示在另一半场(只能看,随他调整实时更新);左侧成员栏里同组两人的头像带**绿框**,从回合开始一直到战斗结束。观战者查看其中一人时也会看到两人的阵容和站在关底的领袖。
- 最终攻势 / 隐秘核心的休整期按你在**关底战场上的半场**判定可部署地块(右侧玩家左右镜像),拖动时高亮的就是可放置的格子:例如「源石流发生装置」关底图中被 1×3 橙色管子围起的地块可以部署。在最终攻势放在这些格子上的干员进入隐秘核心时会留在原位。
- **领袖的部件**:「假想敌:胄」的无人机死亡时(不论是谁击落;漏过去不算),胄失去领袖血条总量的 2%(例如终极 4 人存活 14 400 000 → 每架 288 000;官方没写明这个「最大生命值」指哪一个,本作按血条总量处理,属推断);隐秘核心的「斩胄之剑 / 破胄之锤」「碎铳之簧」受到的每一点伤害都会等量扣在领袖血条上(剑 / 锤被击落后一定算;俯冲途中挨的打也算,这一点官方两处文字不一致,属推断)。剑 / 锤平时悬浮在胄身边、无敌;掷出后闪现到固定路线,冲向攻击力最低的干员,途中被打中 15 次就会【瘫痪】(晕眩 10 秒、落地、受到的伤害 ×1.3);落地或瘫痪结束后,会以剩余生命值换成一把新的回到原位。「灭顶之灾」每次只发射 1 枚刺胄之弹(低于 20% 生命也一样),只打攻击范围内攻击力最高的干员。
- **巨型领袖**(假想敌:胄 / 管、盐风主教昆图斯、阿利斯泰尔、“萨米的意志”)的受击判定是一片约 5×3 格的区域(整个管道区块):攻击范围碰到其中任意一格就能打到它;它们**自缚、无法被阻挡**,不会离开原地。溅射按敌人的中心判定,打在旁边小怪身上的溅射碰不到巨型领袖。
+2 -2
View File
@@ -37,7 +37,7 @@ Visuals: oblique perspective board (tilted ~30°, trapezoid tiles), raised highl
Look & feel: research 06 §11–§12 (near-black green-grey panels, mint `#4ed8af` accents, gold prices, bracket frames, hexagon badges, bond discs, 7-segment countdown). Fonts: Noto Sans SC (Google Fonts with local fallback), Bender/Novecento self-hosted for numerals and Latin micro-labels.
Screens: **Title** (season art backdrop, 开始 → nickname), **Lobby** (独立模拟 / 同盟模拟 → create or join; recent rooms), **Room** (4 seats, invite code + copy link, difficulty picker (host), add/remove AI, ready, start), **Briefing** (INFO_CHECK: stage, factions, disabled bonds, banned chess, boss silhouette, ready x/4), **Band draft** (grid of 40 bands with icon/name/LP/effect; turn order list; skip; 查看禁用盟约与干员 under the order list opens the briefing's bond rows, legend and banned operators again in a read-only dialog — one component, `ui/matchInfo.js`, §21.28), **Game** (Pixi field + DOM HUD: top bar with round/phase capsule/LP (live during the own battle, §17.5)/timer/ready; bond strip + popups; left team panel with LP/status/emote bubbles, click to watch; bottom shop bar (level card, 3–5 chess cards, item card, funds, refresh, freeze, collapse); detail panel on click/hover (header with bonds, then 特质 — the operator's own effect — right under it, 特性, stats (live: the battle's own numbers / the start-of-battle ones in prep, §18.5), skill, module, equipped items, talents: §17.5); enemy preview drawer (next round enemies with counts, factions; its 本局信息 tab lists the briefing's disabled bonds and banned operators, §21.28); 机变 overlay (the official card: framed icon + name, the effect text — data `descRaw` — under it, the taker's round avatar at the top-right; two taps, §18.2, §20.7); merge reward overlay (the promotion reward, or a special offer — 凯瑟琳's 定向投放 … — as item / chess cards under its source's label, with the count of offers queued behind it, §21.2); phase banners; ticker (by broadcast priority, highest first; a leader-damage line plays only in its round and a player's newer one replaces the older, §21.10); toasts; emote wheel; settings (volume, quality, show damage numbers)), **Result** (victory/defeat, rounds, per-player lineup, stats, titles, back to room).
Screens: **Title** (season art backdrop, 开始 → nickname), **Lobby** (独立模拟 / 同盟模拟 → create or join; recent rooms), **Room** (4 seats, invite code + copy link, difficulty picker (host), add/remove AI, ready, start), **Briefing** (INFO_CHECK: stage, factions, disabled bonds, banned chess, boss silhouette, ready x/4), **Band draft** (grid of 40 bands with icon/name/LP/effect; turn order list; skip; 查看禁用盟约与干员 under the order list opens the briefing's bond rows, legend and banned operators again in a read-only dialog — one component, `ui/matchInfo.js`, §21.28), **Game** (Pixi field + DOM HUD: top bar with round/phase capsule/LP (live during the own battle, §17.5)/timer/ready; bond strip + popups; left team panel with LP/status/emote bubbles, click to watch (a boss round frames the viewer's pair in green, §15); bottom shop bar (level card, 3–5 chess cards, item card, funds, refresh, freeze, collapse); detail panel on click/hover (header with bonds, then 特质 — the operator's own effect — right under it, 特性, stats (live: the battle's own numbers / the start-of-battle ones in prep, §18.5), skill, module, equipped items, talents: §17.5); enemy preview drawer (next round enemies with counts, factions; its 本局信息 tab lists the briefing's disabled bonds and banned operators, §21.28); 机变 overlay (the official card: framed icon + name, the effect text — data `descRaw` — under it, the taker's round avatar at the top-right; two taps, §18.2, §20.7); merge reward overlay (the promotion reward, or a special offer — 凯瑟琳's 定向投放 … — as item / chess cards under its source's label, with the count of offers queued behind it, §21.2); phase banners; ticker (by broadcast priority, highest first; a leader-damage line plays only in its round and a player's newer one replaces the older, §21.10); toasts; emote wheel; settings (volume, quality, show damage numbers)), **Result** (victory/defeat, rounds, per-player lineup, stats, titles, back to room).
盟约栏支持「收起/展开盟约」(GitHub #142):收起时隐藏完整列表并关闭由列表打开的浮层,让原图标区域的指针事件到达棋盘,只保留至少 44×44 CSS px 的恢复按钮。折叠状态在本局阶段变化、观看队友时保留;展开后仍按当前观看对象显示最新盟约。按钮支持 Enter / Space,聚焦按钮时 Space 不触发准备或暂停。
@@ -72,6 +72,6 @@ Target: a **real 3D board layer** rendered with three.js (vendored ESM, no CDN)
- One camera model drives both layers: `public/js/render/projection.js` keeps its public API (`worldToScreen`, `screenToWorld`/tile picking, presets, transitions) but derives it from a three.js `PerspectiveCamera` initialised from the official configBlackBoard framing, so Spine units, FX, drag/drop and hit-testing stay aligned with the 3D board.
- Must degrade gracefully: when local-client art or WebGL2 features are missing, fall back to the current 2D atlas board; keep 60 fps (static geometry merged into few draw calls; textures mipmapped; DPR-aware).
- Implemented (`public/js/render/board3d/*`, `render/app.js`): three.js is downloaded only when the local-art manifest lists the board atlas; the built 3D area, the drawn 2D rows and the lit rect follow `viewKind(kind, {rect})` (the field the camera actually shows: a 'prep' camera on rows ≤ 6 = `bossPrep`); the background plane is extended with mirrored copies and the clear colour matches the fog (no visible edge at any aspect); battle crates live in their own group (survive scene rebuilds) and follow the board mode (`switchableBox`: 3D mesh ⇄ 2D box on a mode switch or a lost WebGL context); quality 'low' drops shadows; `?board=2d|3d` overrides. The direction wheel's range stripes sit between the board canvas and the unit canvas.
- Done: Final Assault / Hidden Core prep takes place on your own half of the boss field (research 09 §1.2): pieces shown at boss-field positions (row − 7; right-hand player mirrored col → 20 − col, RIGHT ↔ LEFT), drop targets and wheel directions mapped back to board coordinates (`prepCamera` → `bossPrep` in public/js/ui/gameLogic.js; covered by the playtest2 browser tests). A scout of such a board (`m.field` prep of a boss round) comes in boss-field coordinates with the player's `side` and is framed the same way, the leader standing at its spawn (0.2.0, community report item 55).
- Done: Final Assault / Hidden Core prep takes place on your own half of the boss field (research 09 §1.2): pieces shown at boss-field positions (row − 7; right-hand player mirrored col → 20 − col, RIGHT ↔ LEFT), drop targets and wheel directions mapped back to board coordinates (`prepCamera` → `bossPrep` in public/js/ui/gameLogic.js; covered by the playtest2 browser tests). A scout of such a board (`m.field` prep of a boss round) comes in boss-field coordinates with the player's `side` and is framed the same way, the leader standing at its spawn (0.2.0, community report item 55). Both players of a pair are shown together, as in the battle (0.2.0, community report item 51): the partner's board stands on the other half (`m.private.bossMate` units, already in boss-field coordinates: mirrored on the right half, RIGHT ↔ LEFT; read-only `m:<uid>` views of `render/app.js syncMates`), the whole boss field is lit, a scout carries both halves (`mate`), and the team panel frames the pair's avatars in the official green (`ui/battle bg_team_border` tinted, a plain ring without the local art; `gameLogic teamFrameIds`) from the round's start through the fight.
---
+1 -1
View File
@@ -18,7 +18,7 @@ Platform additions (implemented): `welcome.{resumed, version}`, `welcome.diyKitt
C→S (all carry `rid`): `g.infoReady` · `g.band {bandId}` · `g.bandSkip` · `g.bandFocus {bandId?}` (the strategy highlighted in the draft screen — what a timed-out turn takes; absent / null clears it; `WRONG_PHASE` outside BAND_DRAFT, `ALREADY` after the pick, `BAD_TARGET` for a band not allowed; §18.2) · `g.buy {slot}` · `g.refresh` · `g.freeze` · `g.levelUp` · `g.sell {uid}` · `g.move {uid, to:{area:'board', row, col}|{area:'hand', idx}, dir?}` · `g.equip {itemUid, targetUid, replaceUid?}` (`replaceUid`: which equipped item a third one replaces, §6.2) · `g.art {itemUid, row, col, dir?}` (`dir` ∈ UP|RIGHT|DOWN|LEFT from the deploy wheel, §3; absent ⇒ `to.dir`, then RIGHT; `g.move` onto the piece's own tile re-orients it; board pieces in `m.private` / `m.field {prep:true}` carry `dir`) · `g.destroy {uid}` (hand / temp items only; equipped items are locked, §6.2) · `g.reward {idx}` · `g.choice {idx}` · `g.ready {ready}` · `g.emote {id}` · `g.watch {fieldId, playerId?}` (`playerId`: the player tapped — a shared 联防 / boss pair field shows two; what an eliminated viewer follows, §25) · `g.autoplay {on}` · `g.pause {on}` (solo battles only, §14 Solo pause) · `g.unitStats {seq?}` (ROUND_START / SP_DRAFT / PREP, else `WRONG_PHASE`; eliminated: `ELIMINATED`) → push `m.unitStats` (§18.5) · `g.leave`
S→C:
- `m.public` — full public state on every change (throttled ≤ 10/s): `{ phase, round, lastRound, deadline, serverNow, modeId, difficulty, stageId, factions, disabledBonds, bannedChess, bossId, hiddenBossId?, teamLp?, bossHp?:{hp,max}, overtimeAt? /* FA / HC: ms epoch the overtime drain starts, §4 */, paused /* solo pause, §14 */, draft?:{order,turn,picks,skipsLeft,turnDeadline,turnSeconds /* one turn's length, 0 untimed; deadline = turnDeadline (§18.2) */,untimed}, sp?:{family,cards /* bounty cards carry descRaw?, §20.6 */,turn,picks}, players:[{playerId, seat, name, isBot, connected, alive, lp, bandId, shopLevel, boardCount, ready, bonds:[{bondId,count,active,tier,layers,harmony? /* 调和's +1 is in count, §21.26 */}], fieldId, status, pendingLp? /* COMBAT / 联防 of a normal round: the LP the player's own battle will cost at settlement so far, min(lpCapPerRound, counted leaks) — in 联防 min(lpCapPerRound, uniteLeft) — omitted when 0 and in every other phase (§17.5 live LP) */, uniteLeft? /* 联防: the leaker's enemies still standing — not spawned yet + alive + counted leaks, uncapped, live (§20.6) */}] , fields:[{fieldId, kind, players:[playerId...]}] }`
- `m.private` — the recipient's full PlayerState view (§6.2 minus server-only data) on every change.
- `m.private` — the recipient's full PlayerState view (§6.2 minus server-only data) on every change; in a boss round's prep also `bossMate { playerId, side, units }` — the pair partner's board on its half of the boss field (UnitInfo, mirrored on the right half), re-sent when the partner moves (0.2.0, community report item 51).
- `m.field` — field meta when watching starts: `{ fieldId, kind, rect, stageId, units: [UnitInfo], effects? }` (the units on the field, knocked-out operators waiting to redeploy included — `Battle.fieldMeta`, §18.3). The prep-scouting field (`Match.prepFieldMeta`) puts the scouted player's **hand (整备区) and 临时整备区 into `units` as well**: held pieces as units on the hand row (row 7, col = hand slot) and temp pieces on the temp row (row 8, cols 4..8 = the temp slots; PRTS 帮助 counts the temp area with the hand; a held item is a `kind: 'item'` unit the client draws as its floating plate) — the scout renders exactly like the own prep bench, for every watcher alike, the spectator seat's copy equal to a teammate's (§23.19's "never a private view" stays the no-`m.private` rule); the scout signature `_prepScoutSig` carries the hand / temp slots, so a piece moved to another slot is a change — and carries `effects`: the scouted player's effects view (策略 / 机变 / 悬赏 …), the watched player's effects column ready-made
- NOTE: game time in `b.snap`/`b.ev` is carried as **`gt`** (the frame's `t` is the message type).
- Active finite zero-SP timed skills display `sp=timeLeft`, `spMax=duration` and `UF.SKILL`; after end they display
+9
View File
@@ -307,6 +307,15 @@
.team__row.is-self .pavatar { box-shadow: 0 0 0 2px var(--mint-500), 0 0 .18rem rgba(23, 249, 183, .35); }
.team__row.is-watched .pavatar { box-shadow: 0 0 0 2px var(--amber), 0 0 .18rem rgba(246, 163, 41, .45); }
.team__row.is-dead .team__name { color: var(--text-dim); text-decoration: line-through; }
/* 最终攻势 / 隐秘核心: the viewer's pair framed in the official green from the round's start (item 51) — the official
ui/battle bg_team_border (a white frame with its glow) tinted through a mask; a plain green ring without the local art.
It replaces the own row's mint ring; a watched row keeps its amber one inside the frame. */
.team__frame {
position: absolute; inset: -.07rem; pointer-events: none; z-index: 1;
background: var(--mint-500); -webkit-mask: var(--frame) center / 100% 100% no-repeat; mask: var(--frame) center / 100% 100% no-repeat;
}
.team__frame--plain { inset: -.04rem; background: none; -webkit-mask: none; mask: none; border: 2px solid var(--mint-500); box-shadow: 0 0 .14rem rgba(78, 216, 175, .65); }
.team__row.is-self.is-team .pavatar { box-shadow: none; }
.team__bubble { position: absolute; left: calc(100% + .06rem); top: 50%; transform: translateY(-50%); width: max-content; max-width: 3.2rem; }
/* 前往查看 / 返回战场 (research 09 §3.1: tapping a teammate's avatar expands a mint button under it) */
.team__row.is-open .pavatar { box-shadow: 0 0 0 2px var(--mint-400), 0 0 .2rem rgba(23, 249, 183, .45); }
+1
View File
@@ -677,6 +677,7 @@
"刷新商店 · R": "Refresh Shop · R",
"免费 ×{free}": "Free ×{free}",
"查看 {name} 的战场": "View {name}'s Battlefield",
"与你在同一战场": "On your field",
"查看自己的阵地": "View your Field",
"查看 {name} 的阵地": "View {name}'s Field",
"连接已断开": "Disconnected",
+36 -2
View File
@@ -78,7 +78,10 @@
// boss rows 2–5, bench 7 → 0, temp 8 → 1, side 'R' mirrored col c → 20 − c with RIGHT ↔ LEFT. Every public
// coordinate stays in BOARD space (pieceDrop targets, canPlace, highlightTiles, tileScreen, holdPiece, setPieceDir,
// stored `dir`); `view.prepField()` → { kind, side, mirror } and `tileScreen(...).mirror` tell the direction wheel
// that a screen-right swipe means board LEFT on the mirrored half.
// that a screen-right swipe means board LEFT on the mirrored half. The pair partner's board stands on the other half
// (`priv.bossMate` units, already in boss-field coordinates — server/match/match/views.js bossMateView), read-only
// ('m:<uid>' views: never picked or dragged), and the lit rect is the whole boss field: both players of a pair are
// shown together, as in the battle (community report of 2026-10-06, item 51).
// Lost WebGL context of the 3D board: the 2D board takes over at once and the 3D board is rebuilt on a fresh context
// a moment later (up to 3 tries; a context lost again right away counts as a failure; `setBoardMode('2d')` stops it).
//
@@ -458,7 +461,8 @@ export async function createFieldView(host, options = {}) {
if (k === 'pen') return { r0: 14, r1: 18, c0: 7, c1: 13 };
// prep lights the bench (hand row 7 / temp row 8) with the field, like the official prep view
if (camOpts.rect) { const r = normRect(camOpts.rect); return k === 'prep' ? { ...r, r0: Math.min(r.r0, GEO.HAND_ROW) } : r; }
if (k === 'bossPrep') return camOpts.side === 'R' ? { r0: 0, r1: 5, c0: 10, c1: 20 } : { r0: 0, r1: 5, c0: 0, c1: 10 };
// the boss round's prep lights the whole boss field: the pair partner's half is shown with its pieces (item 51)
if (k === 'bossPrep') return { ...GEO.BOSS_RECT };
return k === 'boss' ? { ...GEO.BOSS_RECT } : k === 'unite' ? { ...GEO.UNITE_RECT } : k === 'prep' ? { r0: 7, r1: 12, c0: 0, c1: 10 } : { ...GEO.NORMAL_RECT };
}
@@ -768,12 +772,42 @@ export async function createFieldView(host, options = {}) {
if (e.area === 'board' && info.dir && !held.has(e.uid) && typeof v.setDir === 'function') v.setDir(info.dir);
}
for (const k of [...views.keys()]) if (String(k).startsWith('p:') && !seen.has(Number(String(k).slice(2)))) dropView(k);
// the boss round's prep: the pair partner's board on its half of the boss field (item 51; display only)
syncMates(prepXf.kind === 'bossPrep' && src.bossMate && Array.isArray(src.bossMate.units) ? src.bossMate.units : []);
prepPieces = list.filter((e) => e.key && views.has(e.key));
if (dragState && !views.has(dragState.key)) { drag.reset(); endDragVisual(false); }
holdScene(false); // the prep pieces reference their models now (a battle's hold ends here)
return true;
}
/**
* The pair partner's pieces in the Final Assault / Hidden Core prep (m.private bossMate units: UnitInfo in boss-field
* coordinates, facing as the battle will place them): read-only 'm:<uid>' views — not prep pieces, so they are never
* picked, dragged or swept with the own ones; a view is rebuilt when its body, tile or facing changes.
*/
function syncMates(units) {
const keep = new Set();
for (const u of units) {
const info = u && Number.isInteger(u.uid) ? renderInfo({ ...u, id: `m:${u.uid}` }) : null;
if (!info) continue;
keep.add(info.id);
const sig = `${info.defId}|${info.golden ? 1 : 0}|${info.spine || ''}|${info.x},${info.y}|${info.dir || ''}|${(info.items || []).join(',')}`;
let v = views.get(info.id);
if (v && v._sig !== sig) { dropView(info.id); v = null; }
if (v) continue;
v = new UnitView(ctx, { ...info, maxHp: 1 }, { prep: true });
v._sig = sig;
v.setWorld(info.x, info.y, heightAt(info.y, info.x));
if (info.dir && typeof v.setDir === 'function') v.setDir(info.dir);
v._showFacing = true;
if (v.setItems && Array.isArray(info.items) && info.items.length) {
v.setItems(info.items.map((it) => { const r = data.item(it); return assets.itemIcon ? assets.itemIcon(r ? { trapId: r.trapId, iconId: r.iconId } : it) : null; }));
}
views.set(info.id, v);
}
for (const k of [...views.keys()]) if (String(k).startsWith('m:') && !keep.has(k)) dropView(k);
}
/**
* A summon stack of several copies (凯瑟琳's 2 devices, user playtest #6) dropped on a board tile stays in the hand
* and one copy — a new piece of the same token — lands there: the state already shows the move, the stack goes home.
+1 -1
View File
@@ -26,7 +26,7 @@
export { clamp, tileKey } from './gameLogic/shared.js';
export { phaseMode, isCombatPhase, showDeadPill, isBossPhase } from './gameLogic/phases.js';
export { prepCamera, prepCameraFor, foldCamera, deployFieldOf, fieldTile, boardTileOf, ownerBandId } from './gameLogic/camera.js';
export { prepCamera, prepCameraFor, foldCamera, deployFieldOf, fieldTile, boardTileOf, ownerBandId, teamFrameIds } from './gameLogic/camera.js';
export { phaseBanner, prepCapsuleLabel, countdownState, phaseTotalSeconds } from './gameLogic/phases.js';
export { STATUS_META } from './gameLogic/format.js';
export { sortedPlayers } from './gameLogic/shared.js';
+32 -1
View File
@@ -1,6 +1,6 @@
// ui/gameLogic/camera.js — prep camera, boss-field tile mapping, owner band. Re-exported from ../gameLogic.js.
import { GEO } from '../../../../shared/constants.js';
import { GEO, PHASE } from '../../../../shared/constants.js';
import { BOSS_ROW_SHIFT, MAX_COL } from '../../render/prepfield.js';
import { int, isObj } from './shared.js';
@@ -26,6 +26,37 @@ export function prepCamera(pub, myId) {
return { kind: 'bossPrep', opts: { side: idx % 2 === 1 ? 'R' : 'L' } };
}
const BOSS_PREP_PHASES = new Set([PHASE.ROUND_START, PHASE.SP_DRAFT, PHASE.PREP]);
/**
* The players framed in green on the team panel (community report of 2026-10-06, item 51: 「同组两个玩家屏幕左边的头像也像原版
* 那样用绿框框起来」 — the official co-op frame, ui/battle `bg_team_border`): in a boss round (最终攻势 / 隐秘核心) the viewer
* and its pair partner — from the round's start (回合开始 / 机变 / 休整期: the pairing `prepCamera` reads, the players still
* in by seat, two by two — server/match/finalAssault.js pairPlayers) through the fight (the boss field listing the
* viewer). Empty for a lone player, an eliminated player or a spectator seat (no pair of its own) and in every other
* round. PR #191 framed the partner alone, in gold (not merged).
* @param {any} pub m.public @param {string|null} myId
* @returns {Set<string>}
*/
export function teamFrameIds(pub, myId) {
const none = new Set();
if (!pub || !myId) return none;
const players = Array.isArray(pub.players) ? pub.players.filter(isObj) : [];
const me = players.find((p) => p.playerId === myId);
if (!me || me.alive === false) return none;
const r = pub.round;
const bossRound = Number.isInteger(r) && r > 0 && (r === pub.bossRound || r === pub.hiddenRound);
if (pub.phase === PHASE.FINAL_ASSAULT || pub.phase === PHASE.HIDDEN_CORE) {
const f = (Array.isArray(pub.fields) ? pub.fields : []).find((x) => isObj(x) && (x.kind === 'boss' || x.kind === 'hidden') && Array.isArray(x.players) && x.players.includes(myId));
return f && f.players.length > 1 ? new Set(f.players) : none;
}
if (!bossRound || !BOSS_PREP_PHASES.has(pub.phase)) return none;
const alive = players.filter((p) => p.alive !== false).sort((a, b) => int(a.seat, 99) - int(b.seat, 99)).map((p) => p.playerId);
const idx = alive.indexOf(myId);
const pair = alive.slice(idx - (idx % 2), idx - (idx % 2) + 2);
return pair.length > 1 ? new Set(pair) : none;
}
/**
* The own prep board's camera request with the shop bar's state (public issue #5 "准备阶段,收起商店界面时,界面并不会进行
* 缩放"): `prepCamera` plus `shop: !folded` — a folded shop (收起) takes the official shop-collapsed camera
+11 -4
View File
@@ -1,6 +1,7 @@
// Left team panel (research 06 §11.1, research 09 §3.1): one row per seat — avatar (band icon once picked), name, LP
// tower, status glyph (… acting / ✓ ready / ⌛ deciding / ⚔ combat / door left / ✕ dead), AI badge, "you" marker, the
// field being watched (eye badge), and emote bubbles.
// field being watched (eye badge), and emote bubbles. In a boss round the viewer's pair is framed in green from the
// round's start (gameLogic teamFrameIds; community report of 2026-10-06, item 51).
// Observing (client-side combat, `observe` prop — the official flow): tapping a teammate's avatar expands a mint
// "前往查看" button under the row (when that teammate can be observed now; otherwise the reason is toasted through
// onWatch); while observing, the own row shows a "返回战场" button. Without `observe` (server-run combat) a click
@@ -19,7 +20,7 @@ import { PHASE } from '../../../shared/constants.js';
import { html, Icon, Tooltip } from './components.js';
import { PlayerAvatar, LpTower, GIcon, LocalSprite } from './gameComponents.js';
import { EmoteBubble } from './emotes.js';
import { STATUS_META, sortedPlayers } from './gameLogic.js';
import { STATUS_META, sortedPlayers, teamFrameIds } from './gameLogic.js';
import { MissTag, uniteRemaining } from './hud.js';
import { localAsset } from '../data.js';
import { t } from '../../../shared/i18n.js';
@@ -79,6 +80,10 @@ export function TeamPanel({ pub, myId, watching, bubbles, onWatch, compact = fal
useEffect(() => { setOpenPid(null); }, [phaseKey, watching, observe?.observing]);
const players = sortedPlayers(pub);
if (!players.length) return null;
// a boss round: the viewer's pair framed in green from the round's start (item 51 — the official bg_team_border,
// tinted like the official green; a plain green ring without the local art)
const team = teamFrameIds(pub, myId);
const frameArt = team.size ? localAsset('ui/battle', 'bg_team_border') : null;
const click = (p, self) => {
if (!observe) { onWatch(p); return; }
if (self) { if (observe.observing) observe.onBack(); setOpenPid(null); return; }
@@ -98,9 +103,11 @@ export function TeamPanel({ pub, myId, watching, bubbles, onWatch, compact = fal
const back = !!observe && self && observe.observing;
const title = observe ? (self ? (observe.observing ? t('返回战场') : t('你自己')) : t('查看 {name} 的战场', { name: p.name })) : (self ? t('查看自己的阵地') : t('查看 {name} 的阵地', { name: p.name }));
const lp = rowLp(p, pub, self ? selfLive : null, { uniteLocal, cap });
return html`<div key=${p.playerId} class=${cx('team__row', self && 'is-self', watched && 'is-watched', p.alive === false && 'is-dead', open && 'is-open')}>
<button type="button" class="team__btn" onClick=${() => click(p, self)} title=${title} aria-expanded=${observe && !self ? String(open) : undefined}>
const inTeam = team.has(p.playerId);
return html`<div key=${p.playerId} class=${cx('team__row', self && 'is-self', inTeam && 'is-team', watched && 'is-watched', p.alive === false && 'is-dead', open && 'is-open')}>
<button type="button" class="team__btn" onClick=${() => click(p, self)} title=${inTeam && !self ? `${title} · ${t('与你在同一战场')}` : title} aria-expanded=${observe && !self ? String(open) : undefined}>
<${PlayerAvatar} player=${p} self=${self} />
${inTeam ? html`<span class=${cx('team__frame', !frameArt && 'team__frame--plain')} style=${frameArt ? `--frame:url("${frameArt}")` : undefined} aria-hidden="true"></span>` : null}
<span class="team__seat num">P${(p.seat ?? 0) + 1}</span>
${p.isBot ? html`<span class="team__ai">AI</span>` : null}
${self ? html`<span class="team__you"><${Icon} name="user" /></span>` : null}
+11 -2
View File
@@ -52,8 +52,17 @@ export class MatchMessaging {
}
markPublic() { this._pubDirty = true; }
/** A player's state changed: its m.private and (throttled, deduplicated) m.public (level, board, bonds…). */
markPrivate(ps) { if (ps) { this._privDirty.add(ps); this._pubDirty = true; } }
/**
* A player's state changed: its m.private and (throttled, deduplicated) m.public (level, board, bonds…) — in a boss
* round's prep also its pair partner's m.private, which shows this board on its other half (bossMate, item 51).
*/
markPrivate(ps) {
if (!ps) return;
this._privDirty.add(ps);
this._pubDirty = true;
const m = this._bossMateOf(ps);
if (m) this._privDirty.add(m.mate);
}
/** Send pending m.private (per player, only when changed) and m.public (throttled ≤ 10/s). */
flush(forcePublic = false) {
+49 -13
View File
@@ -184,17 +184,17 @@ export class MatchViews {
return previewOf([...this.wave.spawns, ...bounty]);
}
/** UnitInfo list of a player's board and hand (prep scouting): board pieces on their tiles, held pieces on the
* hand row (row 7) — the scout renders like the own prep bench. */
prepFieldMeta(ps) {
/** A 自选 piece's pick (like the sim's UnitInfo.diy): a scout's card composes the operator from it (shared/diy.js). */
_diyInfo(ps, piece) {
const p = piece.kind === 'chess' && typeof ps.diyPickOf === 'function' ? ps.diyPickOf(piece.id) : null;
return p ? { charId: p.charId, skillIndex: p.skillIndex, uniEquipId: p.uniEquipId } : undefined;
}
/** UnitInfo of a player's board pieces on their (board) tiles — a prep scout's board, the boss partner's (bossMateView). */
_prepBoardUnits(ps) {
const units = [];
// the scouted player's own view of the data (0.2.0 自选编队: its slotted DIY slots are its operators — player/diy.js)
// the player's own view of the data (0.2.0 自选编队: its slotted DIY slots are its operators — player/diy.js)
const gd = ps.gd || this.gd;
// a 自选 piece's pick (like the sim's UnitInfo.diy): the scout's card composes the operator from it (shared/diy.js)
const diyOf = (piece) => {
const p = piece.kind === 'chess' && typeof ps.diyPickOf === 'function' ? ps.diyPickOf(piece.id) : null;
return p ? { charId: p.charId, skillIndex: p.skillIndex, uniEquipId: p.uniEquipId } : undefined;
};
for (const { r, c, piece } of boardOrder(ps.board)) {
const chess = piece.kind === 'token' ? null : gd.chess(piece.id);
// 0.2.0 补位: a chess this player fields as its stand-in is deployed with the stand-in's body — name, art, max HP,
@@ -214,9 +214,42 @@ export class MatchViews {
// the equipped items (like the sim's UnitInfo): a 变形同构体 wearer shows as a member of the bond it grants
items: piece.kind === 'chess' && Array.isArray(piece.items) && piece.items.length ? piece.items.map((it) => it.id) : undefined,
standInFor: rec && rec.standInFor ? rec.standInFor : undefined,
diy: diyOf(piece),
diy: this._diyInfo(ps, piece),
});
}
return units;
}
/**
* A boss round's prep (最终攻势 / 隐秘核心: the pairing planned, no field up yet): the other player of `ps`'s pair as
* { mate: PlayerState, side: its half 'L' | 'R' }, else null (a lone player, any other phase).
*/
_bossMateOf(ps) {
if (!ps || !this.bossWaves || this.fields.length) return null;
const g = this.bossGroupOf(ps);
const pid = g && g.players.length > 1 ? g.players.find((x) => x !== ps.playerId) : null;
const mate = pid ? this.players.get(pid) : null;
return mate ? { mate, side: g.side === 'R' ? 'L' : 'R' } : null;
}
/**
* m.private `bossMate` (community report of 2026-10-06, item 51): in a boss round's prep the partner's board on its
* half of the boss field, as the battle will place it (bossFieldPlacement: the right half mirrored, RIGHT ↔ LEFT) — the
* own prep view draws it beside the own half, read-only, as the official prep shows both players of a pair together
* (the official client knows every board: ChangePositionDn { boardStatus }, research 09). Null otherwise.
* @returns {{ playerId: string, side: 'L'|'R', units: object[] } | null}
*/
bossMateView(ps) {
const m = ps && ps.alive ? this._bossMateOf(ps) : null;
if (!m) return null;
return { playerId: m.mate.playerId, side: m.side, units: this._prepBoardUnits(m.mate).map((u) => this._onBossHalf(u, m.side)) };
}
/** UnitInfo list of a player's board and hand (prep scouting): board pieces on their tiles, held pieces on the
* hand row (row 7) — the scout renders like the own prep bench. */
prepFieldMeta(ps) {
const units = this._prepBoardUnits(ps);
const gd = ps.gd || this.gd;
// the hand (整备区) and the 临时整备区 scout exactly like the own prep bench renders them: pieces as units on
// their rows (hand row 7, col = hand slot; temp row 8, cols 4..8 = temp slots; no dir — bench pieces face right),
// items included (the client draws their floating plates). PRTS 帮助 counts the temp area with the hand (review of
@@ -240,7 +273,7 @@ export class MatchViews {
moduleId: lo && typeof lo.moduleId === 'string' ? lo.moduleId : undefined,
items: piece.kind === 'chess' && Array.isArray(piece.items) && piece.items.length ? piece.items.map((it) => it.id) : undefined,
standInFor: standIn && standIn.standInFor ? standIn.standInFor : undefined,
diy: diyOf(piece),
diy: this._diyInfo(ps, piece),
});
};
for (let i = 0; i < ps.hand.length; i++) {
@@ -261,9 +294,12 @@ export class MatchViews {
// round's leader at its spawn tile (nextEnemies `start`), framed by the boss-field prep camera of the player's side
// (`side`). Until 0.2.0 an eliminated player or a spectator seat scouting a player then saw the normal board and no
// leader at all (community report of 2026-10-06, item 55).
// The pair's other player stands on the other half (item 51: both players of a pair together, as in the battle).
const g = this.fields.length ? null : this.bossGroupOf(ps);
if (g) return { ...meta, kind: 'boss', rect: { ...GEO.BOSS_RECT }, side: g.side, units: units.map((u) => this._onBossHalf(u, g.side)) };
return meta;
if (!g) return meta;
const mate = this.bossMateView(ps);
const own = units.map((u) => this._onBossHalf(u, g.side));
return { ...meta, kind: 'boss', rect: { ...GEO.BOSS_RECT }, side: g.side, units: mate ? [...own, ...mate.units] : own, ...(mate ? { mate: { playerId: mate.playerId, side: mate.side } } : {}) };
}
/** A prep UnitInfo (board, bench or temp row) placed on side 'L' | 'R' of the boss field (bossFieldPlacement). */
+10 -5
View File
@@ -240,13 +240,18 @@ export class MatchWatch {
/** Board signature of a prep scout view (board, hand and temp rows: a shop or funds change is not a board change).
* Hand / temp entries carry their slot — `prepFieldMeta` draws x from it, so a piece moved to another slot is a
* change (review of PR #129). */
* change (review of PR #129). In a boss round's prep the pair partner's board is part of the view (item 51). */
_prepScoutSig(ps) {
const parts = [];
for (const { r, c, piece } of boardOrder(ps.board)) {
const items = piece.kind === 'chess' && Array.isArray(piece.items) ? piece.items.map((it) => `${it.uid}:${it.id}`).join(',') : '';
parts.push(`${piece.uid}:${piece.id}@${r},${c}:${pieceDir(piece)}:${items}`);
}
const boardSig = (q) => {
for (const { r, c, piece } of boardOrder(q.board)) {
const items = piece.kind === 'chess' && Array.isArray(piece.items) ? piece.items.map((it) => `${it.uid}:${it.id}`).join(',') : '';
parts.push(`${piece.uid}:${piece.id}@${r},${c}:${pieceDir(piece)}:${items}`);
}
};
boardSig(ps);
const m = this._bossMateOf(ps);
if (m) { parts.push(`|mate:${m.mate.playerId}:${m.side}`); boardSig(m.mate); }
for (let i = 0; i < ps.hand.length; i++) {
const piece = ps.hand[i];
if (!piece) continue;
+6
View File
@@ -55,6 +55,8 @@ export class PlayerViews {
const free = this.shop.freeRefreshes > 0;
const board = [];
for (const { r, c, piece } of boardOrder(this.board)) board.push(this.pieceView(piece, [r, c]));
// a boss round's prep: the partner's board on its half of the boss field (Match.bossMateView, item 51)
const bossMate = typeof this.m.bossMateView === 'function' ? this.m.bossMateView(this) : null;
return {
t: 'm.private',
playerId: this.playerId,
@@ -98,6 +100,10 @@ export class PlayerViews {
// switched off)
diy: this.diy,
diyBanned: this.diyBanned,
// 最终攻势 / 隐秘核心 prep (community report of 2026-10-06, item 51): `{ playerId, side, units }` — the pair partner's
// board pieces as UnitInfo on its half of the boss field (mirrored on the right half, as the battle places them);
// the own prep view draws them beside the own half, read-only. Absent otherwise.
...(bossMate ? { bossMate } : {}),
stats: {
dmgDealt: Math.round(this.stats.dmgDealt), kills: this.stats.kills, leaks: this.stats.leaks, gold: this.stats.gold,
refreshes: this.stats.refreshes, merges: this.stats.merges,
+116
View File
@@ -0,0 +1,116 @@
// Community report of 2026-10-06, item 51: 「还原原版最终boss战准备阶段:像战斗中那样把会成一组的两个玩家放在一起(靠右边的
// 那个他自己的视角就是靠右,他之前的摆位也镜像过去,干员位置面向相对boss是不变的),同组两个玩家屏幕左边的头像也像原版那样用绿框
// 框起来」. The own half (mirrored for the right-hand player, RIGHT ↔ LEFT) and its camera were already the official ones
// (research 09 §1.2, DESIGN §15); missing were the partner's pieces on the other half and the frames. Now m.private
// carries `bossMate` in a boss round's prep — the partner's board on its half as the battle places it — kept current
// when the partner moves (the partner's m.private is marked with the mover's), a scout of either player shows both
// halves, and the team panel frames the pair (gameLogic teamFrameIds). Overlap: PR #191 (same-field frames, issue #190)
// published the pairing as m.public bossPairing and framed the partner alone in gold — not merged; here the client reads
// the pairing as prepCamera already does, and both avatars get the official green frame (ui/battle bg_team_border).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { PHASE } from '../../shared/constants.js';
import { makeMatch, give, legalTileFor, chessOfTier } from './harness.js';
import { bossFieldPlacement } from '../../server/match/finalAssault.js';
import { teamFrameIds } from '../../public/js/ui/gameLogic.js';
function bossPrep(humans, seed) {
const h = makeMatch({ mode: 'coop', difficulty: 'FUNNY', humans, seed, fake: true }).start();
const m = h.m;
h.drive(() => m.phase === PHASE.PREP && m.round === m.gd.bossRound, { ready: true });
for (const ps of m.players.values()) { ps.ready = false; for (const k of [...ps.board.keys()]) ps.board.delete(k); ps.recompute(); }
m.flush(true);
return h;
}
const place = (h, pid, id) => {
const ps = h.ps(pid);
const tile = legalTileFor(h.m, ps, id);
const piece = give(h.m, ps, id, 'board', tile);
piece.dir = 'RIGHT';
ps.recompute();
return { piece, tile };
};
test('m.private bossMate: each player of a pair gets the other\'s board on its half — mirrored on the right, facing kept', () => {
const h = bossPrep(4, 5101);
const m = h.m;
assert.deepEqual(m.bossWaves.map((w) => w.players), [['p_0', 'p_1'], ['p_2', 'p_3']]);
const a = place(h, 'p_0', chessOfTier(1)[0]);
const b = place(h, 'p_1', chessOfTier(1)[1]);
m.flush(true);
const v0 = h.ps('p_0').privateView();
const v1 = h.ps('p_1').privateView();
assert.deepEqual([v0.bossMate.playerId, v0.bossMate.side], ['p_1', 'R']);
assert.deepEqual([v1.bossMate.playerId, v1.bossMate.side], ['p_0', 'L']);
const onR = v0.bossMate.units.find((u) => u.uid === b.piece.uid);
const wantR = bossFieldPlacement('R', b.tile[0], b.tile[1], 'RIGHT');
assert.deepEqual([onR.y, onR.x, onR.dir, onR.facing, onR.ownerId], [wantR.row, wantR.col, 'LEFT', -1, 'p_1'], 'the right half: mirrored, still facing the leader');
const onL = v1.bossMate.units.find((u) => u.uid === a.piece.uid);
const wantL = bossFieldPlacement('L', a.tile[0], a.tile[1], 'RIGHT');
assert.deepEqual([onL.y, onL.x, onL.dir, onL.ownerId], [wantL.row, wantL.col, 'RIGHT', 'p_0'], 'the left half as it is');
assert.equal(v0.bossMate.units.length, 1, 'the partner\'s board only (its bench stays its own)');
m.dispose();
});
test('a move of the partner reaches the player\'s m.private and a scout of either player at once', () => {
const h = bossPrep(4, 5102);
const m = h.m;
const b = place(h, 'p_1', chessOfTier(1)[1]);
m.flush(true);
// p_3 (eliminated) scouts p_0: the scout shows p_0's half and p_1's board on the other half
h.ps('p_3').eliminate(13);
assert.deepEqual(m.handle('p_3', { t: 'g.watch', fieldId: 'n:p_0' }), { ok: true });
let scout = h.lastTo('p_3', 'm.field');
assert.deepEqual([scout.kind, scout.side, scout.mate], ['boss', 'L', { playerId: 'p_1', side: 'R' }]);
assert.ok(scout.units.some((u) => u.uid === b.piece.uid && u.x >= 10), 'the partner\'s piece on the right half');
// p_1 moves its piece: p_0's m.private and p_3's scout of p_0 follow
const ps1 = h.ps('p_1');
const to = legalTileFor(m, ps1, b.piece.id, new Set([`${b.tile[0]},${b.tile[1]}`]));
const before = h.allTo('p_3', 'm.field').length;
assert.deepEqual(m.handle('p_1', { t: 'g.move', uid: b.piece.uid, to: { area: 'board', row: to[0], col: to[1] }, dir: 'UP' }), { ok: true });
m.flush(true);
const want = bossFieldPlacement('R', to[0], to[1], 'UP');
const u0 = h.lastTo('p_0', 'm.private').bossMate.units.find((u) => u.uid === b.piece.uid);
assert.deepEqual([u0.y, u0.x, u0.dir], [want.row, want.col, 'UP']);
assert.ok(h.allTo('p_3', 'm.field').length > before, 'the scout of p_0 was pushed again');
scout = h.lastTo('p_3', 'm.field');
const us = scout.units.find((u) => u.uid === b.piece.uid);
assert.deepEqual([us.y, us.x, us.dir], [want.row, want.col, 'UP']);
m.dispose();
});
test('no bossMate for a lone player, outside a boss round\'s prep, or once the fight is on', () => {
const h = bossPrep(3, 5103);
const m = h.m;
assert.deepEqual(m.bossWaves.map((w) => w.players), [['p_0', 'p_1'], ['p_2']]);
assert.equal(h.ps('p_2').privateView().bossMate, undefined, 'the lone player of a 3-player Final Assault');
assert.ok(h.ps('p_0').privateView().bossMate, 'the pair has one');
h.drive(() => m.phase === PHASE.FINAL_ASSAULT);
assert.equal(h.ps('p_0').privateView().bossMate, undefined, 'the fight: the boss field itself is shown');
m.dispose();
const h2 = makeMatch({ mode: 'coop', humans: 2, seed: 5104, fake: true }).start();
h2.toPrep(1);
assert.equal(h2.ps('p_0').privateView().bossMate, undefined, 'a normal round');
h2.m.dispose();
});
test('client: teamFrameIds frames the viewer\'s pair in a boss round (prep and fight), nobody otherwise', () => {
const players = [0, 1, 2, 3].map((i) => ({ playerId: `p_${i}`, seat: i, alive: true }));
const pub = (o) => ({ round: 14, bossRound: 14, hiddenRound: 15, phase: PHASE.PREP, players, fields: [], ...o });
const ids = (s) => [...s].sort();
assert.deepEqual(ids(teamFrameIds(pub({}), 'p_0')), ['p_0', 'p_1']);
assert.deepEqual(ids(teamFrameIds(pub({}), 'p_3')), ['p_2', 'p_3']);
assert.deepEqual(ids(teamFrameIds(pub({ phase: PHASE.ROUND_START }), 'p_2')), ['p_2', 'p_3'], 'from the round start');
assert.deepEqual(ids(teamFrameIds(pub({ phase: PHASE.SP_DRAFT, round: 15 }), 'p_1')), ['p_0', 'p_1'], 'the Hidden Core prep');
// seat 1 out: the players still in pair by seat (p_0, p_2), p_3 alone
const out = players.map((p) => (p.playerId === 'p_1' ? { ...p, alive: false } : p));
assert.deepEqual(ids(teamFrameIds(pub({ players: out }), 'p_2')), ['p_0', 'p_2']);
assert.deepEqual(ids(teamFrameIds(pub({ players: out }), 'p_3')), [], 'a lone player: no frame');
assert.deepEqual(ids(teamFrameIds(pub({ players: out }), 'p_1')), [], 'an eliminated viewer: no pair');
assert.deepEqual(ids(teamFrameIds(pub({ round: 13 }), 'p_0')), [], 'a normal round');
assert.deepEqual(ids(teamFrameIds(pub({}), 's_spec')), [], 'a spectator seat');
const fight = pub({ phase: PHASE.FINAL_ASSAULT, fields: [{ fieldId: 'b1', kind: 'boss', players: ['p_0', 'p_1'] }, { fieldId: 'b2', kind: 'boss', players: ['p_2'] }] });
assert.deepEqual(ids(teamFrameIds(fight, 'p_1')), ['p_0', 'p_1'], 'the fight: the boss field listing the viewer');
assert.deepEqual(ids(teamFrameIds(fight, 'p_2')), [], 'a lone boss field');
assert.deepEqual(ids(teamFrameIds(pub({ phase: PHASE.UNITE, round: 12 }), 'p_0')), [], '联防 is not framed (the owner asked for the boss rounds)');
});
+4 -2
View File
@@ -43,7 +43,7 @@ test('a scout of a boss round\'s prep is the boss field: the player\'s half (mir
assert.deepEqual([u.y, u.x, u.dir, u.facing], [want.row, want.col, 'LEFT', -1], 'mirrored onto the right half, still facing the leader');
const bench = meta.units.find((x) => x.y === 0);
assert.ok(bench && bench.x === 20 - 2, `the hand row on the half's bench row (boss row 0, mirrored): ${JSON.stringify(bench)}`);
for (const x of meta.units) assert.ok(x.y >= 0 && x.y <= 5 && x.x >= 10 && x.x <= 20, `on the right half: ${x.y},${x.x}`);
for (const x of meta.units.filter((x) => x.ownerId === 'p_1')) assert.ok(x.y >= 0 && x.y <= 5 && x.x >= 10 && x.x <= 20, `on the right half: ${x.y},${x.x}`);
// the leader stands at its spawn tile: the client's leaderStand finds it in the scout's nextEnemies
const stand = leaderStand(meta.nextEnemies, () => null, (x, y) => [[y, x]]);
assert.ok(stand && stand.entry.boss && stand.row >= 0 && stand.row <= 5, `the leader with its spawn tile: ${JSON.stringify(stand?.entry)}`);
@@ -55,7 +55,9 @@ test('a scout of a boss round\'s prep is the boss field: the player\'s half (mir
assert.deepEqual(m.handle('p_3', { t: 'g.watch', fieldId: 'n:p_0' }), { ok: true });
const left = h.lastTo('p_3', 'm.field');
assert.deepEqual([left.kind, left.side], ['boss', 'L']);
for (const x of left.units) assert.ok(x.x <= 10, 'the left half');
// its own pieces on the left half (since item 51 the partner p_1's board stands on the right half beside them)
for (const x of left.units.filter((x) => x.ownerId === 'p_0')) assert.ok(x.x <= 10, 'the left half');
assert.ok(left.units.some((x) => x.ownerId === 'p_1' && x.uid === onBoard.uid && x.x === want.col), 'p_1\'s piece on the right half');
m.dispose();
});
+146
View File
@@ -0,0 +1,146 @@
// Real-server browser E2E of the community reports of 2026-10-06, items 51 / 55 (the boss round's prep). A co-op match
// jumped to the boss round (fastServer SP_START_ROUND=boss), 2 humans (seats 0 and 3) + 2 AI seats, every board placed
// by the jump (SP_AUTO_PLACE), so the pairs are (host, AI 1) and (AI 2, guest) — the guest is the right-hand player.
// * item 51: each human's prep shows its own pieces on its half (the guest's mirrored onto the right half) and its
// partner's pieces on the other half (read-only 'm:<uid>' views from m.private bossMate, facing as the battle will
// place them), the leader standing on the boss field; the team panel frames the pair's two avatars (green, the
// official bg_team_border or a plain ring) from the prep through the Final Assault;
// * item 55: with the guest eliminated at the jump, its prep shows the followed player's half of the boss field (the
// prep scout of item 56, kind 'boss' on the boss-field prep camera) with the leader — it used to be its own empty
// normal board.
// Unit counterparts: test/match/feedback5-boss-pair-prep.test.js, feedback5-boss-prep-scout.test.js,
// feedback5-watch-follow.test.js.
//
// SP_E2E=1 node --test test/ui/feedback5-boss-pair.e2e.test.js
//
// Screenshots: test/e2e/out/boss-pair-*.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';
const ENABLED = process.env.SP_E2E === '1' && hasChrome() && existsSync(path.join(ROOT, 'public/assets'));
const HOST_KIT = ['chess_char_4_23_a', 'chess_char_4_24_a', 'chess_char_3_06_a', 'chess_char_3_13_a'];
const GUEST_KIT = ['chess_char_1_03_a', 'chess_char_1_09_a', 'chess_char_2_01_a', 'chess_char_3_21_a'];
const BOT_KIT = ['chess_char_4_01_a', 'chess_char_4_02_a', 'chess_char_3_01_a', 'chess_char_2_06_a'];
/** What the view shows in the boss round's prep: own pieces, the partner's ('m:'), the leader, the framed rows. */
const prepView = (c) => c.page.evaluate(() => {
const s = globalThis.__SP__.store.get();
const v = globalThis.__SP_VIEW__?.raw;
const entries = v?.debug?.views ? [...v.debug.views.entries()] : [];
const pick = (pre) => entries.filter(([k]) => String(k).startsWith(pre)).map(([, x]) => ({ id: x.info?.defId, owner: x.info?.ownerId ?? null, x: Math.round(x.x), y: Math.round(x.y), dir: x.info?.dir ?? null }));
return {
me: s.me.playerId, phase: s.match.public?.phase, camKind: v?.debug?.camKind ?? null, mode: v?.mode ?? null,
field: s.match.field ? { fieldId: s.match.field.fieldId, kind: s.match.field.kind, prep: !!s.match.field.prep, side: s.match.field.side ?? null } : null,
own: pick('p:'), mates: pick('m:'), scout: entries.filter(([k]) => typeof k === 'number' || /^\d+$/.test(String(k))).length,
leader: v?.debug?.leader ? { key: v.debug.leader.key, shown: !!v.debug.leader.view?.root?.visible } : null,
framed: [...document.querySelectorAll('.team__row.is-team .team__name')].map((n) => n.textContent),
names: Object.fromEntries((s.match.public?.players || []).map((p) => [p.playerId, p.name])),
bossMate: s.match.private?.bossMate ? { playerId: s.match.private.bossMate.playerId, side: s.match.private.bossMate.side } : null,
};
});
async function coopAtBoss(P, base, prefix) {
const host = new Client(P, base, 'host', { prefix: `${prefix}-host`, w: 1280, h: 720 });
const guest = new Client(P, base, 'guest', { prefix: `${prefix}-guest`, w: 1280, h: 720 });
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;
for (let i = 0; i < 2; i++) { await host.click('button', '添加 AI 队友'); await sleep(600); }
await guest.open(`?room=${room.code}`);
await guest.enter('阿米娅');
await guest.waitFor((s) => s.room?.code === room.code, 'guest joined');
for (let i = 0; i < 6; i++) { await guest.click('.room-bar__right button', '准备就绪', { optional: true, timeout: 3000 }); await sleep(500); }
await host.click('.room-bar__right button', '开始模拟', { timeout: 20000 });
for (const c of [host, guest]) await c.waitFor((s) => s.phase === 'INFO_CHECK', 'briefing', 30000);
for (const c of [host, guest]) await c.click('.brief__foot .btn--primary', '准备就绪');
for (const c of [host, guest]) await c.waitFor((s) => s.phase !== 'INFO_CHECK', 'band draft', 40000);
const picked = new Set();
const t0 = Date.now();
while (picked.size < 2 && Date.now() - t0 < 120000) {
for (const c of [host, guest]) {
const s = await c.st();
if (s.phase !== 'BAND_DRAFT') { picked.add(c.label); continue; }
if (picked.has(c.label) || s.draft?.turn !== s.me) continue;
await c.click('.dband:not(.is-taken)', null, { nth: c === host ? 2 : 5 });
picked.add(c.label);
}
await sleep(250);
}
return { host, guest };
}
describe('the boss round\'s prep: the pair together, the frames, a watcher\'s boss field (items 51 / 55, real server)', { skip: !ENABLED && 'set SP_E2E=1 (Chrome + public/assets)' }, () => {
test('item 51: own half (mirrored on the right) + the partner\'s pieces on the other half + the pair framed, prep and fight', { timeout: 6 * 60 * 1000 }, async () => {
const srv = await startRealServer({ fast: { timerScale: 1, combatSpeed: 2, startRound: 'boss', idleBots: true, autoPlace: true, kits: [HOST_KIT, GUEST_KIT], botChess: BOT_KIT } });
const P = (await import('puppeteer-core')).default;
let host = null;
let guest = null;
try {
({ host, guest } = await coopAtBoss(P, srv.base, 'boss-pair'));
await host.waitFor((s) => s.phase === 'PREP' && !s.ready, 'boss-round prep', 90000);
await host.page.waitForFunction(() => [...(globalThis.__SP_VIEW__?.raw?.debug?.views?.keys() || [])].some((k) => String(k).startsWith('m:')), { timeout: 20000 });
await guest.page.waitForFunction(() => [...(globalThis.__SP_VIEW__?.raw?.debug?.views?.keys() || [])].some((k) => String(k).startsWith('m:')), { timeout: 20000 });
await sleep(800);
const hv = await prepView(host);
const gv = await prepView(guest);
await host.shot('prep');
await guest.shot('prep');
// the host holds the left half, its AI partner the right one; the guest the right half, its AI partner the left
assert.equal(hv.camKind, 'bossPrep');
assert.equal(gv.camKind, 'bossPrep');
assert.deepEqual([hv.bossMate?.side, gv.bossMate?.side], ['R', 'L'], JSON.stringify({ hv: hv.bossMate, gv: gv.bossMate }));
assert.ok(hv.own.length >= 4 && hv.own.every((u) => u.x <= 10), `host's pieces on the left half: ${JSON.stringify(hv.own)}`);
assert.ok(gv.own.length >= 4 && gv.own.every((u) => u.x >= 10), `guest's pieces mirrored onto the right half: ${JSON.stringify(gv.own)}`);
assert.ok(hv.mates.length >= 4 && hv.mates.every((u) => u.x >= 10 && u.owner === hv.bossMate.playerId), `the AI partner's pieces on the right half: ${JSON.stringify(hv.mates)}`);
assert.ok(gv.mates.length >= 4 && gv.mates.every((u) => u.x <= 10 && u.owner === gv.bossMate.playerId), `the AI partner's pieces on the left half: ${JSON.stringify(gv.mates)}`);
assert.ok(hv.mates.some((u) => u.dir === 'LEFT'), 'mirrored onto the right half: RIGHT reads LEFT');
assert.ok(hv.leader?.shown && gv.leader?.shown, 'the leader stands on the boss field for both');
assert.deepEqual(hv.framed.sort(), [hv.names[hv.me], hv.names[hv.bossMate.playerId]].sort(), 'the host and its partner framed');
assert.deepEqual(gv.framed.sort(), [gv.names[gv.me], gv.names[gv.bossMate.playerId]].sort(), 'the guest and its partner framed');
// the fight keeps the frames
await host.click('.readybtn');
await guest.click('.readybtn');
for (const c of [host, guest]) await c.waitFor((s) => s.phase === 'FINAL_ASSAULT', 'Final Assault', 90000);
await sleep(1500);
assert.deepEqual((await prepView(host)).framed.sort(), hv.framed.sort(), 'the fight: the same pair framed');
assert.deepEqual(problemsOf([host, guest]), []);
} finally {
for (const c of [host, guest]) if (c?.problems.length) console.log(c.label, c.problems.slice(0, 20).join('\n'));
await host?.close();
await guest?.close();
await srv.stop();
}
});
test('item 55: an eliminated guest\'s prep shows the followed player\'s half of the boss field with the leader', { timeout: 6 * 60 * 1000 }, async () => {
const srv = await startRealServer({ fast: { timerScale: 1, combatSpeed: 2, startRound: 'boss', idleBots: true, autoPlace: true, kits: [HOST_KIT, []], botChess: BOT_KIT, eliminate: [1] } });
const P = (await import('puppeteer-core')).default;
let host = null;
let guest = null;
try {
({ host, guest } = await coopAtBoss(P, srv.base, 'boss-scout'));
await host.waitFor((s) => s.phase === 'PREP' && !s.ready, 'boss-round prep', 90000);
await guest.page.waitForFunction(() => globalThis.__SP__.store.get().match.field?.kind === 'boss', { timeout: 20000 });
await sleep(1500);
const gv = await prepView(guest);
await guest.shot('scout');
assert.equal((await guest.st()).alive, false, 'the guest is eliminated');
assert.deepEqual([gv.field?.prep, gv.field?.kind, gv.camKind], [true, 'boss', 'bossPrep'], JSON.stringify(gv.field));
assert.equal(gv.field.fieldId, `n:${Object.keys(gv.names)[0]}`, 'following the first player still in (the host)');
assert.ok(gv.leader?.shown, `the leader on the boss field: ${JSON.stringify(gv.leader)}`);
assert.ok(await guest.exists('.gm__watching'), 'the read-only watching banner');
assert.deepEqual(problemsOf([host, guest]), []);
} finally {
for (const c of [host, guest]) if (c?.problems.length) console.log(c.label, c.problems.slice(0, 20).join('\n'));
await host?.close();
await guest?.close();
await srv.stop();
}
});
});