feedback5: merge CB5 — community reports (match / UI): module 特性追加 shown after the base trait (text only), a shop upgrade fills its new slot at once, spectators follow the player they picked through every phase (g.watch carries the playerId — PR #189's idea), the boss shows in boss-round prep scouting, the final-boss prep shows the pair together with green avatar frames (PR #191's idea, official colour); §25.19 after the hotkeys' §25.15; golden: the 18 bot matches (the upgrade's new card), battles unchanged

This commit is contained in:
sganggs committed 2026-10-07 02:41:19 +08:00
commit 37558f0d5e
40 files changed
+1811 -603

No files matched your search

+2 -2
View File
@@ -169,7 +169,7 @@ Top level: `{ season, seasonName, modes, economy, lpCapPerRound, bossOvertimeAft
| `projectile` | `"arrow"` | `arrow` (phys ranged) / `bolt` (arts ranged) / `orb` (heal) / `none` |
| `canHitFly` | `true` | |
| `targetPriority` | `"fly"` | from trait text: `fly` (优先攻击空中单位), `lowestDef` (防御力最低), else `null` |
| `trait` | `{"desc":"优先攻击空中单位","descRaw":"…","bb":{"atk_scale":1.1},"bbStr":{},"rangeGrid":null,"moduleDesc":"攻击空中单位时攻击力提升至110%","moduleDescRaw":"…"}` | profession trait (+ golden module trait upgrade merged into `bb`; `rangeGrid` = trait-effect area, e.g. 散射手 front row — **not** the attack range) |
| `trait` | `{"desc":"优先攻击空中单位","descRaw":"…","bb":{"atk_scale":1.1},"bbStr":{},"rangeGrid":null,"moduleDesc":"攻击空中单位时攻击力提升至110%","moduleDescRaw":"…"}` | profession trait (+ golden module trait upgrade merged into `bb`; `rangeGrid` = trait-effect area, e.g. 散射手 front row — **not** the attack range). `desc` = the class trait, or the module's rewrite of it (official `overrideDescripton`); `moduleDesc` = the module's added line (official `additionalDescription`, PRTS 「特性追加」), which the client shows after `desc`, never alone (0.2.0, community report item 16.2) |
| `skill` | see below | default skill at `status.skillLevel` (normal 4, golden 7) |
| `skills[]` | `[{…skill record…, "index":0, "isDefault":false}, {…, "index":1, "isDefault":true}]` | **loadout choices** (DESIGN §16): every skill unlocked at `status` (E1 ⇒ S1–S2, E2 ⇒ S1–S3; the default is always listed), same shape as `skill` + `isDefault`, at the chess skill level, `trigger` resolved **for that skill index** (§2.2). The `isDefault` entry equals `skill` |
| `modules[]`, `statsBase`, `traitBase`, `talentsBase` | see §2.2 | golden chess with `equipLevel > 0` only: selectable modules + the no-module base they apply to |
@@ -231,7 +231,7 @@ everything needed to resolve a unit for `(chessId, skillIndex, moduleId)` (`simd
| `uniEquipId`, `name`, `typeName`, `typeIcon`, `icon` | `"uniequip_003_mlyss"`, `"落叶四季"`, `"TAC-Y"`, `"tac-y"`, `"uniequip_003_mlyss"` | |
| `isDefault`, `level` | `false`, `3` | default = `defaultUniEquipId` (= `module.id`) |
| `attr` | `{"maxHp":170,"atk":28,"def":28}` | flat stat additions (stat field names): `stats = statsBase[f] + attr[f]` (float-noise cleaned) |
| `traitOverride` | `{"desc":"…提升至165%","descRaw":…,"bb":{"atk_scale":1.65},"bbStr":{},"rangeGrid":null,"moduleDesc"?:…}` \| `null` | the full trait with this module; `null` ⇒ `traitBase` |
| `traitOverride` | `{"desc":"…提升至165%","descRaw":…,"bb":{"atk_scale":1.65},"bbStr":{},"rangeGrid":null,"moduleDesc"?:…}` \| `null` | the full trait with this module (`desc`, then the added line `moduleDesc` when present — see `trait` above); `null` ⇒ `traitBase` |
| `talentChanges[]` | `{"talentIndex":1,"name":"开源节流","desc":…,"descRaw":…,"bb":{"cost":-2,"runtime_cost":-1},"bbStr":{},"rangeGrid":null,"tokenKey":null,"hidden":false}` | talent additions/overrides (`talentIndex` −1 = new hidden data-only talent); applied to `talentsBase` with the build's merge rule (`simdata composeTalents`: override of an existing index merges blackboards, module keys win) |
- `statsBase` / `traitBase` / `talentsBase` — the golden record **without** any module (`stats` / `trait` / `talents`
+5 -2
View File
@@ -505,7 +505,8 @@ Any `choice:` handler whose EffectRef reuses its own key must guard like this (o
items by `price`, refresh 1 (free refreshes first), sell +1 (elite too; items cannot be sold, only destroyed).
* **Shop**: `shopSlots[level]` chess slots + item slot(s), copy-weighted rolls over remaining pool copies of unbanned,
visible chess with tier ≤ level; item slot: tier by the same shares, uniform item within the tier. Level-up price =
base per mode, −1 each round start (floor 0), reset to the next base after upgrading; `MAX_LEVEL` at 6. One freeze
base per mode, −1 each round start (floor 0), reset to the next base after upgrading; an upgrade opens the new level's
extra slots at once with new cards (the cards shown stay); `MAX_LEVEL` at 6. One freeze
toggle freezes every unsold slot until the next round start; a manual refresh rerolls everything (new slots stay
frozen). Unfrozen slots are cleared at combat start. Slot positions are stable (frozen slots keep their index).
* **Pool**: copies 12/14/18/16/8/5 (缪尔赛思 4); a normal piece holds 1 copy, an elite 3; displays never reserve copies;
@@ -823,7 +824,9 @@ round was over. The official 1 s `broadcastBeginDelay` is not modelled.
units > active bond > Σ active layers > standing > seat; the ranks marked 存疑 in PRTS). Unite enemies re-enter with
their original stats on the official 联防 spawn timing (waves.js `buildUniteWave`, DATA.md §15 #22).
* A manual refresh while frozen keeps the new slots frozen until the next round start.
* Level-up does not add slots until the next roll (refresh / round start).
* A level-up opens the new level's extra slots at once, each with a new card drawn at the new level, and keeps the
cards shown (0.2.0, community report item 19; the official tutorial's 「升级后将出现更多的商品栏位」); the new card
follows the freeze toggle like a manual refresh's cards.
* Buying a second copy of an equipped normal item merges into the golden item in the hand (not equipped).
* Promotions by effects (升华, 博士投影) keep the equipment; merges return it; 突变细胞's transformation returns it (the
cell included) before its new operator is gained into the 整备区 — the carrier's tile is left empty (official footage,
+4 -3
View File
@@ -48,7 +48,7 @@
| 出售干员 | 任何干员(含精锐)都只返还 **1** 资金 |
| 装备 / 法术 | 调度中心出售除特殊装备外的所有装备:4 把特殊维式重锤(战栗 / 坚固 / 加速 / 灼燃)只来自维多利亚盟约(每 25 层)和洛洛的定制品,突变细胞只来自昆图斯的策略。装备不能出售,整备区里的只能「销毁」(不返还)。已配发的装备锁定在干员身上:只能被第三件装备替换(弹出替换窗口,选择要替换的一件,它会被销毁),或在干员出售 / 合成精锐时退回整备区。**突变细胞**不会用完:战斗结束后装备者被销毁(部署在场上的话,原来的格子空出来,剩余可放置角色加 1),突变细胞和它的其他装备先退回整备区,可以再次配发给任意干员;随后获得一名高一阶(最高 VI)的随机普通干员(装备者是精锐也一样),和其他新获得的干员一样进入整备区(满了进入临时整备区),需要自己重新部署;如果它凑齐了合成所需的同名干员,照常合成精锐。**坚固维式重锤**首次受到致命伤害时锁血 8 秒,**每次部署一次**:被击倒再部署(或突袭跳跃再部署、被 M3茧甲 / 埃芒加德复活)后又能锁一次,娜仁图亚策略借来的锤子也一样;借用到期时正在锁血的会锁满 8 秒;站在阿戈尔干员身前被开场吞噬、自带源石溶剂掉血时也会用掉这一次 |
| 冻结 | 免费,保留当前所有未买的格子到下一回合;冻结状态下仍可主动刷新 |
| 升级调度中心(1→2 … 5→6) | 险境及以上 5 / 8 / 11 / 12 / 13;同盟标准 5 / 8 / 10 / 11 / 11;独立标准 1 / 1 / 5 / 8 / 10。**每过一回合价格 −1**(最低 0),升级后按下一级的价格重新计算 |
| 升级调度中心(1→2 … 5→6) | 险境及以上 5 / 8 / 11 / 12 / 13;同盟标准 5 / 8 / 10 / 11 / 11;独立标准 1 / 1 / 5 / 8 / 10。**每过一回合价格 −1**(最低 0),升级后按下一级的价格重新计算。升级到干员栏位更多的等级(险境及以上 2 级 4 个、4 级 5 个;标准 2 级 4 个、6 级 5 个)时,**新栏位立刻刷出一名新干员**,已有的卡片不变 |
| 其他收入 | 悬赏敌人、盟约「远见」「奇迹」、装备「骑士储蓄罐」、部分策略和特质 |
调度中心等级决定商店能出现的**最高阶级**和格子数(险境及以上:干员 3 / 4 / 4 / 5 / 5 / 5 格 + 装备 1 格)。
@@ -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 格的区域(整个管道区块):攻击范围碰到其中任意一格就能打到它;它们**自缚、无法被阻挡**,不会离开原地。溅射按敌人的中心判定,打在旁边小怪身上的溅射碰不到巨型领袖。
@@ -127,8 +128,8 @@
- 左下角「交流」打开表情面板:6 套主题 × 6 个表情,左右滑动(或方向键)换主题。表情以气泡形式显示在你头像旁 3 秒,发送冷却 1 秒。
- 休整期、或自己的作战结束后(显示「作战结束,等待队友完成作战」),点左侧队友头像 →「前往查看」观看他的战场;自己的作战进行中不能观战。联防和最终攻势中可以用 ‹ › 切换左半 / 全景 / 右半。
- 被淘汰后可以自由观看任何战场。
- **观战席**(本作新增,原版没有):每个同盟房间另有 2 个观战席。在大厅输入同盟密钥后点「观战」进入(模拟进行中也可以),不占博士席位、不算参战人数,也不能购买、部署、准备或发送表情;观看方式与被淘汰的玩家相同——作战时自动观看第一个战场,休整期自动显示第一位博士的阵地,点左侧成员头像可切换查看任何人。断线重连会回到观战席;创建者可以在等待室用观战者名字旁的 ✕ 将其移出;观战者在等待室有空位时可以点「入座」成为博士。
- 被淘汰后可以自由观看任何战场,而且**不用每回合重选**:你上次点「前往查看」的是谁,之后每个阶段(回合开始、机变、休整期、作战、最终攻势)都会自动跟到他的阵地或战场;没选过或他也被淘汰时,跟随座位顺序第一位还在场的博士(包括 AI 队友)。阶段中途换人立即生效,之后就跟随新选的人。
- **观战席**(本作新增,原版没有):每个同盟房间另有 2 个观战席。在大厅输入同盟密钥后点「观战」进入(模拟进行中也可以),不占博士席位、不算参战人数,也不能购买、部署、准备或发送表情;观看方式与被淘汰的玩家相同——自动跟随你上次查看的博士(没查看过则是座位顺序第一位还在场的博士):休整期显示他的阵地,作战时观看他的战场,点左侧成员头像可切换查看任何人。断线重连会回到观战席;创建者可以在等待室用观战者名字旁的 ✕ 将其移出;观战者在等待室有空位时可以点「入座」成为博士。
- **观看谁,盟约栏就显示谁的盟约**:查看队友的阵地或战场时(休整期、作战结束后、被淘汰后自动观战),左上的盟约栏换成**该队友的盟约、人数和层数**,前面有琥珀色的「👁 队友名」标签,点开的盟约详情也写着「队友名 的盟约」(成员一栏只标出他在场上的干员,整备区看不到);作战中层数实时增长(与他自己看到的一致)。联防和最终攻势里跟随 ‹ ›:镜头在队友的半场时显示他的盟约,在你自己的半场时显示你自己的;「全景」时,你在这个战场上作战就显示你自己的,你不在这个战场上(漏怪后观看联防、被淘汰后观战)则显示你点「前往查看」选中的那名队友,没选过就显示该战场的第一名玩家(1 号联防者 / 这一组的第一位),**不会显示你自己的**。在这样的战场上点某名队友的头像 →「前往查看」,镜头会直接移到他的半场,‹ › 上的「👁 队友名」与盟约栏的标签一致;‹ › 旁边有「返回战场」(被淘汰时没有)。盟约栏换人时,从盟约栏打开的盟约详情会自动关闭。点开场上某个干员的详情卡时,卡上的盟约(以及点它打开的盟约详情)属于**这个干员的主人**,与盟约栏当前显示谁无关——例如在「全景」点队友的干员,看到的是「队友名 的盟约」及他的人数和层数;在队友的半场点你自己的干员,看到的是你自己的。回到自己的阵地 / 战场后恢复成你自己的盟约。作战结束后观看队友时刷新页面 / 断线重连,仍会回到正在观看的队友战场,「👁 队友名」和「返回战场」都还在。已被淘汰的队友不再显示盟约(也无法被查看)。
## 9. 结算称号
+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).
- 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.
---
+4 -4
View File
@@ -15,10 +15,10 @@ Platform additions (implemented): `welcome.{resumed, version}`, `welcome.diyKitt
**Spectator seats** (a remake feature — the official room has none; community report #26, owner's decision 2026-10-04; the spectator-seat subsection of §23): `room.spectate {code}` takes one of a co-op room's `MAX_SPECTATORS` (2) spectator seats, in its lobby or during its match; `room.removeSpectator {playerId}` (host) frees one (`room.closed {reason:'kicked'}` to it); `room.state.spectators: [{playerId, name, connected}]`. A spectator is never a player (not in `seats`, not counted for the start, never host, keeps no room alive — `room.closed {empty}` when the last human leaves), receives the room's broadcasts and watches like an eliminated player (`Match.addSpectator`: `b.start` watch / `m.field`, never `m.private`); it may only `g.watch`, leave, and store a `room.loadout` — anything else answers `error SPECTATOR`. Its seat is kept and given back on reconnect like a player's.
### 8.2 Match
C→S (all carry `rid`): `g.infoReady` · `g.band {bandId}` · `g.bandSkip` · `g.bandFocus {bandId?}` (the strategy highlighted in the draft screen — what a timed-out turn takes; absent / null clears it; `WRONG_PHASE` outside BAND_DRAFT, `ALREADY` after the pick, `BAD_TARGET` for a band not allowed; §18.2) · `g.buy {slot}` · `g.refresh` · `g.freeze` · `g.levelUp` · `g.sell {uid}` · `g.move {uid, to:{area:'board', row, col}|{area:'hand', idx}, dir?}` · `g.equip {itemUid, targetUid, replaceUid?}` (`replaceUid`: which equipped item a third one replaces, §6.2) · `g.art {itemUid, row, col, dir?}` (`dir` ∈ UP|RIGHT|DOWN|LEFT from the deploy wheel, §3; absent ⇒ `to.dir`, then RIGHT; `g.move` onto the piece's own tile re-orients it; board pieces in `m.private` / `m.field {prep:true}` carry `dir`) · `g.destroy {uid}` (hand / temp items only; equipped items are locked, §6.2) · `g.reward {idx}` · `g.choice {idx}` · `g.ready {ready}` · `g.emote {id}` · `g.watch {fieldId}` · `g.autoplay {on}` · `g.pause {on}` (solo battles only, §14 Solo pause) · `g.unitStats {seq?}` (ROUND_START / SP_DRAFT / PREP, else `WRONG_PHASE`; eliminated: `ELIMINATED`) → push `m.unitStats` (§18.5) · `g.leave`
C→S (all carry `rid`): `g.infoReady` · `g.band {bandId}` · `g.bandSkip` · `g.bandFocus {bandId?}` (the strategy highlighted in the draft screen — what a timed-out turn takes; absent / null clears it; `WRONG_PHASE` outside BAND_DRAFT, `ALREADY` after the pick, `BAD_TARGET` for a band not allowed; §18.2) · `g.buy {slot}` · `g.refresh` · `g.freeze` · `g.levelUp` · `g.sell {uid}` · `g.move {uid, to:{area:'board', row, col}|{area:'hand', idx}, dir?}` · `g.equip {itemUid, targetUid, replaceUid?}` (`replaceUid`: which equipped item a third one replaces, §6.2) · `g.art {itemUid, row, col, dir?}` (`dir` ∈ UP|RIGHT|DOWN|LEFT from the deploy wheel, §3; absent ⇒ `to.dir`, then RIGHT; `g.move` onto the piece's own tile re-orients it; board pieces in `m.private` / `m.field {prep:true}` carry `dir`) · `g.destroy {uid}` (hand / temp items only; equipped items are locked, §6.2) · `g.reward {idx}` · `g.choice {idx}` · `g.ready {ready}` · `g.emote {id}` · `g.watch {fieldId, playerId?}` (`playerId`: the player tapped — a shared 联防 / boss pair field shows two; what an eliminated viewer follows, §25) · `g.autoplay {on}` · `g.pause {on}` (solo battles only, §14 Solo pause) · `g.unitStats {seq?}` (ROUND_START / SP_DRAFT / PREP, else `WRONG_PHASE`; eliminated: `ELIMINATED`) → push `m.unitStats` (§18.5) · `g.leave`
S→C:
- `m.public` — full public state on every change (throttled ≤ 10/s): `{ phase, round, lastRound, deadline, serverNow, modeId, difficulty, stageId, factions, disabledBonds, bannedChess, bossId, hiddenBossId?, teamLp?, bossHp?:{hp,max}, overtimeAt? /* FA / HC: ms epoch the overtime drain starts, §4 */, paused /* solo pause, §14 */, draft?:{order,turn,picks,skipsLeft,turnDeadline,turnSeconds /* one turn's length, 0 untimed; deadline = turnDeadline (§18.2) */,untimed}, sp?:{family,cards /* bounty cards carry descRaw?, §20.6 */,turn,picks}, players:[{playerId, seat, name, isBot, connected, alive, lp, bandId, shopLevel, boardCount, ready, bonds:[{bondId,count,active,tier,layers,harmony? /* 调和's +1 is in count, §21.26 */}], fieldId, status, pendingLp? /* COMBAT / 联防 of a normal round: the LP the player's own battle will cost at settlement so far, min(lpCapPerRound, counted leaks) — in 联防 min(lpCapPerRound, uniteLeft) — omitted when 0 and in every other phase (§17.5 live LP) */, uniteLeft? /* 联防: the leaker's enemies still standing — not spawned yet + alive + counted leaks, uncapped, live (§20.6) */}] , fields:[{fieldId, kind, players:[playerId...]}] }`
- `m.private` — the recipient's full PlayerState view (§6.2 minus server-only data) on every change.
- `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
@@ -75,7 +75,7 @@ Piece = { uid, kind: 'chess'|'item'|'token', id, golden: bool, tier, items: [ {
**Protocol (additions; `shared/protocol.js` is normative).**
- `battleId` = `<prefix>.<round>.<seq>.<fieldId>` (`Match._ccField`; `<prefix>` = the match seed in base 36 plus `-<matchNo>`, the room's match number the lobby passes as `opts.matchNo`; `<seq>` counts every battle of the match; the informational `.<fieldId>` is dropped when the id would exceed the protocol's 64 chars): unique for the whole match although field ids repeat (`n:<pid>` every round, `u`, `b1`/`b2` in both boss rounds), and across the matches of one room. The client runner keys its local battles by it (the same id again = the same battle, no rebuild) and the server matches reports by it, so a late `b.progress` / `b.result` of a finished battle — or one that crossed into the room's next match on the same socket — is ignored instead of being credited to another battle.
- S→C `b.start { battleId, fieldId, kind, spec, authoritative, startAt, serverNow, elapsed, speed, watch, done }` — sent at COMBAT/UNITE/FINAL_ASSAULT/HIDDEN start to every human in the field (联防: to every human; COMBAT and boss rounds: eliminated humans get the first field as a display replica — research 09 "keep-watching auto-observes the first available field"), on `g.watch`, and on reconnect/resync. `elapsed` = game seconds on the field clock (a finished field: its end time); the client fast-forwards to it. `speed` = game seconds per real second (2; tests/tools may speed up).
- S→C `b.start { battleId, fieldId, kind, spec, authoritative, startAt, serverNow, elapsed, speed, watch, done }` — sent at COMBAT/UNITE/FINAL_ASSAULT/HIDDEN start to every human in the field (联防: to every human; COMBAT and boss rounds: eliminated humans and spectator seats get the field of the player they follow as a display replica — the one they last watched with `g.watch`, else the first player still in; research 09 "keep-watching auto-observes the first available field", 0.2.0 item 56), on `g.watch`, and on reconnect/resync. `elapsed` = game seconds on the field clock (a finished field: its end time); the client fast-forwards to it. `speed` = game seconds per real second (2; tests/tools may speed up).
- C→S `b.progress { battleId, gt, killed, total, leaks?, left?, bossDmg?, by?, done? }` — authoritative clients, ~1 Hz (boss fields 4 Hz). Normal/联防: `leaks` = counted leaks so far; 联防: `left` = per leaker its enemies still standing (`spec.js uniteLeft`, clamped by the server to `fields.js uniteBillBounds`; §20.6). Boss: `bossDmg` = cumulative damage of this field to the shared pool, `by` = the same per player, `leaks` = the field's cumulative LP cost (leaks × `lpr` + leader "扣除目标生命" effects; `spec.js attachLpMeter`). The server applies deltas (never twice). Stale reports (unknown battle, not the authority, after the match ended) are ignored, never answered with an error; the platform never answers a rid-less `b.progress` with an error frame (it would surface as a toast).
- C→S `b.result { battleId, result }` — authoritative client at battle end; `result` = `compactResult(BattleResult)` (the fields the match consumes; ≤ 4 players, ≤ 400 leaks, ≤ 64 unitsEnd, ≤ 160 unitStats, ≤ 40 layer gains; `fitResult` keeps the frame ≤ 60 KB: unit statistics, then leak mods, then — boss only — leak entries go first). Structural checks in `isBattleResult`; `server/net.js` routes `b.*` frames to the match (lobby `routeGame`).
- S→C `b.pool { hp, max, teamLp, acked: { [fieldId]: cumulative damage counted } }` (boss rounds, ≤ 4 Hz; exact numbers, never rounded) — clients reconcile their `LocalBossPool`: displayed pool = server hp − (local cumulative damage − acked). Pools never hold less than 1 HP (`BOSS_POOL_MIN_HP`: `SharedBossPool` and `LocalBossPool`; a browser pool reads any finite rest below 1 as 0 — §20.5); the 'cleared'-result dust rule (a boss field whose client result is `'cleared'` while < 1 HP is left on the server empties the pool) only catches noise at that boundary.
@@ -89,7 +89,7 @@ Piece = { uid, kind: 'chess'|'item'|'token', id, golden: bool, tier, items: [ {
**Solo pause (official `PauseUp` / `ResumeUp`; `Match.setPause`).** C→S `g.pause { on }`: solo matches only (co-op → `WRONG_PHASE 'co-op battles never pause'`), and `on: true` only while a battle runs (COMBAT / FINAL_ASSAULT / HIDDEN_CORE with a live field, not once the Final Assault is ending — else `WRONG_PHASE 'no battle running'`); `on: false` is always accepted. While `m.public.paused` is true everything of the battle is frozen: the field clock (`b.start elapsed`, the boss plausibility budgets and the overtime drain), the authority deadline and the server-run release timers, the boss clock, the HUD `deadline` / `overtimeAt` (shifted by the paused time on resume) and the server-run pacers (FieldRunner / HeadlessPacer skip their intervals); the browser's runner stops its local clock. In solo battles the top bar shows a 暂停 / 继续作战 button (`ui/hud.js PauseButton`, `ui/matchStatus.js pauseAvailable`; `Space` toggles it), a paused overlay offers 继续作战 / 放弃模拟, and the HUD clocks freeze at the pause moment. A disconnect or leave resumes (the server takes the field over — nobody is left to resume it); the battle phase ending drops the pause.
**Spectating (research 09 §3.1 / §6.3).** No live spectating while your own normal battle runs (`g.watch` → `WRONG_PHASE 'own battle running'`; the UI toasts "作战中无法查看队友"). After your own battle ends you see "⌛ 作战结束,等待队友完成作战" with each teammate's live progress (`m.public.fields[].progress`); tapping a teammate's avatar expands **前往查看** → `g.watch` → `b.start` (display replica fast-forwarded to the field clock); the own row shows **返回战场**. Prep: the same flow shows the teammate's board (`m.field` with `prep: true`) and sends it again when that board changes (GitHub #87). The scout is framed by the prep camera in its shop-folded form and renders the teammate’s hand (整备区) and 临时整备区 as units on their rows — the own prep bench’s exact look (user playtest #2 item 1, the GitHub #44 suggestion; the spectator seat's scout view is the same, §23.19's never-a-private-view staying the no-`m.private` rule). While scouting, the 本局信息 drawer shows the watched player's 策略 in place of one's own (`game.js scoutPid` → `enemyDrawer.js InfoTab bandId / bandOwner`, labelled "X 的策略"; user playtest #2 item 2), and the right effects column follows the watched field too: the scout meta carries the player's effects view, a display-replica battle derives it from the spec's `playerEffects` (`battle/observe.js spectateEffects`) — undefined (empty column, never one's own) for 联防 / boss pair fields and server-run watched fields. 联防: every human receives the unite spec (helpers + observers). Final Assault: each human sees its own pair field; the other pair → `BAD_TARGET 'other group hidden'`. In 联防/FA fields the ‹ › pill switches the camera LEFT half / 全景 / RIGHT half ("你自己" / "👁 name" / "全景" / "无人在家"); there is **no** view switcher during normal combat. Eliminated players start each combat phase on the first field (auto-observe) and may watch any field.
**Spectating (research 09 §3.1 / §6.3).** No live spectating while your own normal battle runs (`g.watch` → `WRONG_PHASE 'own battle running'`; the UI toasts "作战中无法查看队友"). After your own battle ends you see "⌛ 作战结束,等待队友完成作战" with each teammate's live progress (`m.public.fields[].progress`); tapping a teammate's avatar expands **前往查看** → `g.watch` → `b.start` (display replica fast-forwarded to the field clock); the own row shows **返回战场**. Prep: the same flow shows the teammate's board (`m.field` with `prep: true`) and sends it again when that board changes (GitHub #87). In a boss round's prep that board is built on the boss field — the player's half (rows − 7, the right half mirrored with RIGHT ↔ LEFT, `server/match/finalAssault.js bossFieldPlacement`), `kind: 'boss'`, `side`, the leader in `nextEnemies` at its spawn tile — and framed by the boss-field prep camera of that side, so a watcher sees the leader as the players do (0.2.0, community report item 55). The scout is framed by the prep camera in its shop-folded form and renders the teammate’s hand (整备区) and 临时整备区 as units on their rows — the own prep bench’s exact look (user playtest #2 item 1, the GitHub #44 suggestion; the spectator seat's scout view is the same, §23.19's never-a-private-view staying the no-`m.private` rule). While scouting, the 本局信息 drawer shows the watched player's 策略 in place of one's own (`game.js scoutPid` → `enemyDrawer.js InfoTab bandId / bandOwner`, labelled "X 的策略"; user playtest #2 item 2), and the right effects column follows the watched field too: the scout meta carries the player's effects view, a display-replica battle derives it from the spec's `playerEffects` (`battle/observe.js spectateEffects`) — undefined (empty column, never one's own) for 联防 / boss pair fields and server-run watched fields. 联防: every human receives the unite spec (helpers + observers). Final Assault: each human sees its own pair field; the other pair → `BAD_TARGET 'other group hidden'`. In 联防/FA fields the ‹ › pill switches the camera LEFT half / 全景 / RIGHT half ("你自己" / "👁 name" / "全景" / "无人在家"); there is **no** view switcher during normal combat. Eliminated players and spectator seats follow a player through every phase reset (0.2.0, community report item 56; the official DeadAutoObDn { obIndex, state, preparation }; the idea of PR #189): the player they last watched with a manual `g.watch` (`Match.watchPref`; a shared field records the `playerId` tapped, else nobody), else the first player still in (seat order, AI seats included) — the round start pushes that player's prep board (`Match._followScout`, adopted by the screen like a 前往查看 tap: `battle/observe.js followedScout`; kept through 机变 and 休整期), each battle starts on that player's field, a resync / a spectator joining in prep gets the board again; they may watch any field.
**Client runner.** `public/js/battle/runner.js` owns the local Battles: fixed-step clock at the battle speed (60 ticks per real second at 2×, at most `max(8, 4·speed)` ticks per frame, bounded fast-forward of ≤ 240 ticks per frame when far behind; a new battle is caught up silently before it is shown), feeds the existing renderer through the same `snapshot()`/`drainEvents()` wire formats every frame (`view.setLocalFeed` shrinks the interpolation buffer to ~2 frames), publishes the field meta (m.field shape + `local`, `battleId`, `players`, `sides`, `speed`) into `store.match.field` and its state into `store.match.battle` (with `leaks: { [fieldId]: counted leaks so far }` of every normal field it simulates and, while the 联防 field is on screen, `uniteLeft` per leaker (§20.6), authoritative or display replica — the settle rule's count, `/sim/spec.js battleProgress`; published in the frame a count changes: the live LP of §17.5), sends `b.progress`/`b.result` when authoritative (results retried once on timeout; a result lost with the socket — the request failed `DISCONNECTED` / `OFFLINE` or timed out twice — is kept and sent again when the session is back online and whenever a `b.start` still names that finished battle `authoritative: true`; the server answers a duplicate `ok` and changes nothing; a server refusal is final), follows `m.public.paused` (solo pause: every local clock stands still, resume shifts it by the paused time), handles `b.pool`/`b.end`, fast-forwards on reconnect/observe, and survives tab backgrounding (a 250 ms interval pump keeps authoritative battles on their clock while hidden; the server deadline + takeover cover extreme cases). Catch-up frames keep the state events and every enemy form fx (`keepsState`); while hidden, the battle on screen holds them for the first frame back (`hold`; past `HELD_MAX` the superseded status / skill toggles are dropped, `compactHeld`, and only a backlog still past it re-enters the view from the field meta), and a battle that ended while hidden delivers them when the tab is shown; a catch-up frame or a hidden step drains its events every `EV_SLICE` (15) ticks, each batch stamped with its own game time (`gt`), so a late form fx reaches the render engine as late as it is; the game screen's pre-entry buffer (`keepEarly`, also while a re-sent field meta re-enters the field on screen) and the render engine's event queue (`render/interp.js isCosmeticEvent`) never drop a form fx either (§21.4). Sim content errors are logged as console warnings (the server logs its own). The sim loader fetches the ten data files (one retry each) and fails when any is missing — a battle on partial data would report a plausible wrong result — so the next `b.start` retries and the server's deadline covers the field meanwhile. `public/js/battle/observe.js` holds the observing rules and labels.
+120 -1
View File
@@ -25,7 +25,11 @@ settled (泥岩's 闭锁; 忍冬, 深靛, 阿) → §25.11; release packaging an
with no Spine model) → §25.12; round 18 — 拟态物质 at the copy cap (GitHub #207), 华法琳's 7 / 14 (GitHub #175, PR #192),
a merged elite 拉普兰德 as a new one (GitHub #169), the leader pool per player alive (PR #209), the flying lift and two
model quirks (PR #211) → §25.13; the community reports of 2026-10-06 (the 孤岛风云 prisoners drawn freed, 忍冬's trigger, a
bounty burned to death by the 活性源石, 枯朽之种 paying a bounty) → §25.14.
bounty burned to death by the 活性源石, 枯朽之种 paying a bounty) → §25.14; the second batch of community reports of
2026-10-06, stream CB5 (match flow and UI: a module's 特性追加 line after the class trait, the new shop slot filled at the
upgrade, an eliminated player following a player through every round, the boss field and its leader in a watcher's boss
prep, the pair together in the boss prep with the official green frames; PRs #189 / #191 reused, not merged; the
battle-history and hotkey requests left to the owner) → §25.19.
Decisions of the owner: 2026-10-04 — the server stays on plain `node:http` + `ws`, no web framework (§25.1);
2026-10-05 — the 补位 plan (§25.3); the 自选编队 rules, the collab operators out of the pool, ISW-A refused (§25.4);
@@ -1092,3 +1096,118 @@ fixed (W5AA's commits name every golden scenario moved); two were already fixed
`test/ui/gameLogic.test.js` (sanitizeSettings), `test/ui/hotkeys.e2e.test.js` (SP_E2E=1: 撤退 rebound to W with the
keyboard only in the mock match — Q then does nothing, W retreats, the choice survives a reload; phone width). Docs:
docs/PLAYING.md §11, README 操作, DESIGN §10 (design/client.md).
### 25.19 Community reports of 2026-10-06, stream CB5: match flow and UI — `ui/loadoutModel.js traitLines`, `player/economy.js _openLevelSlots`, `match/watch.js _followScout`, `match/views.js prepFieldMeta / bossMateView`, `render/app.js syncMates`, `ui/gameLogic/camera.js teamFrameIds`
CB5 took the items about the match flow and the screens (16.2, 19, 51, 55, 56 of the list; 22 and 48 are features the
owner has not decided — what each would take is at the end). Each was reproduced first (a script, or the real server in
headless Chrome for the screens), checked against the official data, PRTS and the official tutorial pages, then fixed in
one commit with its tests; two open community PRs were reused without merging them (§25.19.6).
#### 25.19.1 「干员调配界面很多干员的某个模组只有额外特性没有基础特性,不清楚是文本错误还是实际效果也有错」 (item 16.2): text only — the module's added line now follows the class trait — `ui/loadoutModel.js traitLines / fullTraitText`, `ui/detailPanel.js traitText`, `screens/loadout.js ModuleInfo`, `screens/diy.js ModuleRow`
- **Seen**: 114 of the 164 modules the 干员调配 screen offers (128 of all 184 elite module records) showed under 特性 only
the module's extra line — 隐现's MAR-X read 「攻击空中单位时攻击力提升至110%」 without 「优先攻击空中单位」; 局内数值, the
in-match detail card and the 自选 module row did the same.
- **Official**: a module's trait candidate either rewrites the class trait (`overrideDescripton`) or adds a line after it
(`additionalDescription`); PRTS flags the latter 「特性追加」 on every such module (e.g. 伊芙利特 BLA-X / BLA-Δ, 玛恩纳
LIB-X, 莫斯提马 SPC-X / SPC-Y).
- **Cause**: the data keeps both lines (`traitOverride.desc` = the class trait or its rewrite, `moduleDesc` = the added
line); the screens showed `moduleDesc` instead of `desc` (since 0.1.2: the review of GitHub #64 in §22 picked the
module line over a cloned record's class trait). The battle was right: for every added-line module the unit keeps the class
trait — the same trait text, targeting, attack shape and trait blackboard as 不装备 (圣约送葬人 REA-Y also rewrites it).
- **Now**: `traitLines` / `fullTraitText`: the class trait (or the rewrite), then the added line; the module card shows
them as 特性 and 特性追加 (en "Added Trait").
- Golden: unchanged (no sim change). Tests: `test/ui/feedback5-module-trait.test.js` (6; the text cases fail on the
parent, the data and sim pins pass there), `test/ui/loadout-stats.test.js` updated; SP_E2E `loadout.e2e` 9 / 9.
Docs: docs/DATA.md (`trait`, `traitOverride`).
#### 25.19.2 「升级商店获得新的商店位时用新卡补上,而不是空着」 (item 19): the upgrade opens the new slot with a new card — `player/economy.js _openLevelSlots`, `audit.js`
- **Seen**: a level-up from 1 to 2 (险境: 3 → 4 operator slots) or 3 to 4 (4 → 5; 标准 5 → 6) showed no new card until the
next refresh or round start (the shop kept its rolled layout; META listed it as an assumption).
- **Official**: the tutorial's 休整期 page (local guide art `autochess_home_4`): 「升级:消耗资金升级调度中心。升级后将出现更多
的商品栏位、可调度干员以及新装备」; PRTS 帮助 「消耗资金可以为调度中心升级,从而增加刷新栏位」.
- **Now**: `levelUp` → `_openLevelSlots`: the new level's extra slots are appended to the operator slots at once (the
item slot stays after them), each drawn at the new level; the cards shown stay in place, a bought card stays sold.
`audit.js` checks the count and the kept cards (`matchrun --check`: no violation).
- [ASSUMED]: the new card follows the freeze toggle, as a manual refresh's cards do.
- Golden: matches — all 18 scenarios (every bot levels its shop and now sees, and draws the shop RNG for, the new card at
once); the battle families unchanged. Tests: `test/match/feedback5-shop-levelslots.test.js` (3, all fail on the
parent). Docs: docs/META.md §3 / §7, docs/PLAYING.md.
#### 25.19.3 「死亡观战时每一回合结束都会被转回自己家」 (item 56): an eliminated player follows a player through every phase reset — `match/watch.js watchPref / _watchTargetOf / _followScout`, `battle/observe.js followedScout`
- **Seen** (real server, headless Chrome): every round start cleared an eliminated player's watch — in 回合开始 / 机变 /
休整期 it saw its own empty board, each battle started on the first field; a spectator seat asked for the first player
itself.
- **Official**: research 09 §3.1 — after elimination the client keeps observing (self_dead_dialog KEEP_WATCH, the
server's DeadAutoObDn { obIndex, state, preparation }, FindFirstAvailObTarget).
- **Now**: `Match.watchPref` = the player of the viewer's last manual `g.watch` (a prep scout, a single-player field, or
— new, optional — `g.watch { playerId }` naming the row tapped on a shared 联防 / boss pair field); `_watchTargetOf` = that
player while still in, else the first player still in (seat order, AI seats included). The round start pushes its prep
board (`_followScout`, kept through 机变 and 休整期), both combat modes and the boss fields start on its field, a resync or
a spectator joining in prep gets the board, a followed player who quits hands over at once. The screen adopts the pushed
board like a 前往查看 tap (`followedScout`; PR #189's stale-field fix) and keeps it through the prep phases; 返回自己 asks
the server for nothing (the viewer has no board).
- Golden: unchanged. Tests: `test/match/feedback5-watch-follow.test.js` (9; 8 fail on the parent's server),
`test/match/spectator.test.js` updated. Docs: design/network.md, docs/PLAYING.md.
#### 25.19.4 「死亡后观战视角看不到场上的关底boss」 (item 55): a watcher of a boss round's prep sees the boss field and its leader — `match/views.js prepFieldMeta`, `screens/game.js`
- **Seen** (headless Chrome, the guest eliminated at the boss round): in the Final Assault fight the watched boss field
showed the leader (enemy_9013_acstmk at 10,3); in the boss round's prep the guest saw its own empty normal board (camera
'prep', no leader) and a scout of a teammate was the normal board too — while the players prepare on their half of the
boss field with the leader standing there (§23.18, community report #12).
- **Now**: in a boss round's prep the scout is built on the boss field — `kind: 'boss'`, `BOSS_RECT`, the scouted player's
pieces through `bossFieldPlacement` (rows − 7, the right half mirrored with RIGHT ↔ LEFT), `side`, the leader's spawn in
`nextEnemies` — and framed by the boss-field prep camera of that side, which shows the leader. With §25.19.3 the
eliminated player gets it at the round start; the same run after the fix: field 'boss', camera bossPrep, leader shown.
- Golden: unchanged. Tests: `test/match/feedback5-boss-prep-scout.test.js` (2, fail on the parent); SP_E2E
`test/ui/feedback5-boss-pair.e2e.test.js` (item 55 case). Docs: design/network.md, design/client.md §15.
#### 25.19.5 「还原原版最终boss战准备阶段:像战斗中那样把会成一组的两个玩家放在一起…同组两个玩家屏幕左边的头像也像原版那样用绿框框起来」 (item 51): the pair together in the boss prep, the official green frames — `match/views.js bossMateView`, `match/messaging.js markPrivate`, `render/app.js syncMates`, `ui/teamPanel.js`, `ui/gameLogic/camera.js teamFrameIds`
- **Seen**: 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, §15) — but the partner's half stood empty and nothing framed the pair.
- **Official**: every client knows every board (ChangePositionDn { boardStatus }, research 09); the team list frames the
co-op group (the owner's recall; the client's ui/battle `bg_team_border`, a white 100×100 frame with its glow, and the
autochess green of `icon_self`, #4ed8af).
- **Now**: in a boss round's prep `m.private.bossMate { playerId, side, units }` carries the partner's board on its half,
placed as the battle will (bossFieldPlacement); a partner's change marks the player's m.private too (`markPrivate`), so
it follows live; the own prep view draws those units read-only ('m:<uid>' views, never picked or dragged) and lights the
whole boss field; a scout of either player shows both halves (`mate`). The team panel frames the pair's two avatars
(`teamFrameIds`: the players still in by seat, two by two, as `prepCamera`; the boss field in the fight) from the round
start through the fight, with the official sprite tinted green (a plain ring without the local art); a lone player, an
eliminated player and a spectator seat get none; 联防 is not framed (not asked).
- [ASSUMED]: the partner's bench is not shown (its board only); the frame's exact official geometry (one frame per avatar).
- Golden: unchanged. Tests: `test/match/feedback5-boss-pair-prep.test.js` (4, fail on the parent), SP_E2E
`test/ui/feedback5-boss-pair.e2e.test.js` (both halves, the mirrored right half, the frames in prep and fight).
Docs: design/client.md §10 / §15, design/network.md, docs/PLAYING.md.
#### 25.19.6 Overlap with the open community PRs #189 and #191 (not merged; their authors' fixes were pending)
- PR #189 (watch preference, @2321Robin) — §25.19.3 builds the same chain (a manual watch is the preference; round start,
both combat modes, the boss fields and the resync follow it; the stale-field fix of the adopted scout), on the 0.2.0
method containers. Differences: the fallback is the first player still in with AI seats included (a lone human with AI
teammates had nobody to follow — the report's case), and a shared field records the `playerId` the row tap sends (the
review's point 2) instead of nothing.
- PR #191 (same-field frames, issue #190) — §25.19.5 frames the pair as the owner asked: green, the official sprite, both
avatars (self included), the boss rounds only; the pairing is read from m.public as the prep camera already does, so no
`bossPairing` field; #191's 联防 frames and the phase banner naming the partner are not built. If the owner still wants
them, they can go on top of this.
#### 25.19.7 Not built — what the two feature requests would take (the owner has not decided)
- **22 战绩 / 分队胜率**: the result is already complete (`m.result`: per-player stats, lineup, band, bonds, titles, rounds
passed, victory — `server/match/results.js`). With no accounts the simplest is per browser: append a compact record at
`m.result` to localStorage (capped, e.g. the last 200; only matches of ≥ 2 rounds, as asked), a 战绩 screen in the lobby
(list, win rate per strategy / core bond / mode and difficulty, 导出 / 导入 like the loadout), tests and i18n — no
server change. Server-side history needs persistence and an identity the remake does not have. Decisions: browser-only
or not; what 「分队」 groups by (strategy, core bond, lineup); the sample cap.
- **48 自定义快捷键**: the keys are fixed in `ui/gameLogic/shortcuts.js shortcutFor` (R 刷新, F 冻结, D 升级, Q 撤退, X 出售,
Space 准备 / 暂停, Esc). It would take a 快捷键 section in 设置 (capture a key per action, conflicts refused, 恢复默认),
a sanitised map in the settings (`sanitizeSettings`), `shortcutFor` reading it, every `<kbd>` hint and tooltip reading it
too, i18n and tests. Decision: whether to allow it at all (the official PC client's keys are fixed as far as the data
shows: `pc_key_holder` hints).
+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); }
+2
View File
@@ -239,6 +239,7 @@
"充能": "Charges",
"不装备模组:精锐干员以基础属性、特性与天赋作战。": "No Module: the Elite Operator fights with its base stats, Trait and Talents.",
"特性": "Trait",
"特性追加": "Added Trait",
"属性": "Stats",
"无属性加成": "No stat bonus",
"天赋": "Talent",
@@ -676,6 +677,7 @@
"刷新商店 · {key}": "Refresh Shop · {key}",
"免费 ×{free}": "Free ×{free}",
"查看 {name} 的战场": "View {name}'s Battlefield",
"与你在同一战场": "On your field",
"查看自己的阵地": "View your Field",
"查看 {name} 的阵地": "View {name}'s Field",
"连接已断开": "Disconnected",
+19 -1
View File
@@ -6,7 +6,8 @@
// replica of their battle; 返回战场 goes back;
// * 联防 / 最终攻势: the ‹ › pill switches the camera LEFT half / 全景 / RIGHT half of the own field; the other pair's
// boss field is never shown to a fighting player;
// * eliminated: anything.
// * eliminated: anything — each phase reset starts on the player it follows (the server's prep scout / b.start,
// followedScout).
import { PHASE } from '../../../shared/constants.js';
import { data } from '../data.js';
@@ -82,6 +83,23 @@ export function resumedWatch(b, { pub = null, myId = '', alive = true, watching
return { seen: b.battleId, fieldId: b.fieldId };
}
/**
* The prep board the server pushed to a viewer that follows a player (an eliminated player or a spectator seat:
* Match._followScout — the player it last watched, else the first player still in; community report of 2026-10-06,
* item 56, the idea of PR #189), to adopt as the watched board like a 前往查看 tap: `field` is such a scout (m.field
* `prep`, an `n:<pid>` id other than the own) and the screen watches nothing (`watching` null — a 返回战场 this phase is
* not overridden: the caller adopts a board once per phase). Null for a living player: its phase resets keep its own
* board.
* @param {{ field?: any, watching?: string|null, alive?: boolean, spectator?: boolean, myId?: string }} o
* @returns {string|null} the fieldId to watch
*/
export function followedScout({ field = null, watching = null, alive = true, spectator = false, myId = '' } = {}) {
if (watching || (alive && !spectator) || !isObj(field) || !field.prep) return null;
const fid = field.fieldId;
if (typeof fid !== 'string' || !fid.startsWith('n:') || fid === `n:${myId}`) return null;
return fid;
}
/** Teammates' progress for the waiting pill: [{ playerId, name, killed, total, done, isBot }]. */
export function teammateProgress(pub, myId) {
const out = [];
+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.
+3 -2
View File
@@ -13,7 +13,7 @@ import { html, Icon, Button, TierChip } from '../ui/components.js';
import { Img, RichText, BondGlyph } from '../ui/gameComponents.js';
import { chessAvatarUrl, chessPortraitUrl, profIconUrl, skillRecordIconUrl, moduleTypeIconUrl } from '../ui/assetUrls.js';
import { data } from '../data.js';
import { PROF_NAME, skillLabel, moduleBadge } from '../ui/loadoutModel.js';
import { PROF_NAME, skillLabel, moduleBadge, fullTraitText } from '../ui/loadoutModel.js';
import { diySlotList, pickChoices, pickOptions, slotRecord, defaultPick } from '../ui/diyModel.js';
import { t } from '../../../shared/i18n.js';
@@ -117,7 +117,8 @@ function SkillRow({ m, s, on, locked, onPick }) {
function ModuleRow({ id, rec, on, locked, onPick }) {
const none = id == null;
const talents = rec && Array.isArray(rec.talentChanges) ? rec.talentChanges.filter((x) => x && x.desc && !x.hidden) : [];
const trait = rec?.traitOverride ? (rec.traitOverride.moduleDescRaw || rec.traitOverride.moduleDesc || rec.traitOverride.descRaw || rec.traitOverride.desc) : null;
// the trait this module gives: the class trait (or the module's rewrite), then its extra line (item 16.2)
const trait = rec?.traitOverride ? fullTraitText(rec.traitOverride) || null : null;
return html`<button type="button" role="radio" aria-checked=${on ? 'true' : 'false'} data-module=${none ? 'none' : id} disabled=${locked}
class=${cx('diy-choice', 'diy-choice--mod', on && 'is-on')} onClick=${() => onPick(id)}>
<span class="diy-choice__icon">${none ? html`<b>—</b>` : html`<${Img} src=${moduleTypeIconUrl(data.get('local'), rec.typeName)} fallback=${html`<b class="num">${moduleBadge(rec)}</b>`} />`}</span>
+41 -18
View File
@@ -106,7 +106,7 @@ import { ResultScreen } from './result.js';
import { net } from '../net.js';
import { store, useStore, shallowEqual, serverNow, isSpectating } from '../store.js';
import { battleRunner } from '../battle/runner.js';
import { isClientCombat, observeTarget, teammateProgress, cameraLayers, layerCamera, sidesOf, resumedWatch } from '../battle/observe.js';
import { isClientCombat, observeTarget, teammateProgress, cameraLayers, layerCamera, sidesOf, resumedWatch, followedScout } from '../battle/observe.js';
import { screenStrip, playerBonds, playerLayer, detailBondOwner, toggleBond, popupView } from '../ui/watchBonds.js';
import { data, getMode } from '../data.js';
import { audio, resultSpeaker, resultVoiceSlot } from '../audio.js';
@@ -251,7 +251,7 @@ function MatchScreen() {
getChess: (id) => { const c = gd.chess(id); return ownDiyRecord(c, priv, { chess: data.get('chess'), backups: data.get('backups') }) || c; },
getToken: gd.token, getItem: gd.item, getEffect: gd.effect, backups: gd.backups,
}), [priv, pub?.stageId, editable, gd.ready, deployField]);
live.current = { pub, priv, field, editable, placeCtx, watching, watchWho, home, myId, detail, drawer, bondOpen, emoteOpen, settingsOpen, exitOpen, drag, facing, sel, selBusy, pen, collapsedNow: collapsed, localDone: false, canPause: false, paused };
live.current = { pub, priv, field, editable, placeCtx, watching, watchWho, home, myId, alive, spectator, detail, drawer, bondOpen, emoteOpen, settingsOpen, exitOpen, drag, facing, sel, selBusy, pen, collapsedNow: collapsed, localDone: false, canPause: false, paused };
// ---- camera: every request goes through setCam, which remembers it for the pen's way back -----------------------
// the own prep board: the normal board, or — in the prep of a boss round — the player's half of the boss field
@@ -400,7 +400,10 @@ function MatchScreen() {
const pf = (Array.isArray(pub?.fields) ? pub.fields : []).find((f) => f && f.fieldId === field.fieldId);
const members = Array.isArray(pf?.players) ? pf.players : Array.isArray(field.players) ? field.players : [];
const sides = field.sides && typeof field.sides === 'object' ? field.sides : null;
const side = sides && sides[myId] ? sides[myId] : members.length > 1 && members.indexOf(myId) === 1 ? 'R' : 'L';
// a scouted board of a boss round's prep (Match.prepFieldMeta: the boss-field rows, `side` = the scouted player's
// half) is framed by the boss-field prep camera of that half — the leader standing at its spawn (item 55)
const side = field.prep && (field.side === 'L' || field.side === 'R') ? field.side
: sides && sides[myId] ? sides[myId] : members.length > 1 && members.indexOf(myId) === 1 ? 'R' : 'L';
// local simulation (client-side combat) feeds a frame per animation frame: no network jitter buffer
view.raw?.setLocalFeed?.({ on: !!field.local, speed: field.speed });
setLayer('ALL');
@@ -582,8 +585,15 @@ function MatchScreen() {
else if (phase === PHASE.FINAL_ASSAULT) audio.sfx(solo ? 'bossRoundSingle' : 'bossRoundTeam');
else if (phase === PHASE.HIDDEN_CORE) audio.sfx('bossRoundSecret');
else if (phase === PHASE.SP_DRAFT) audio.sfx('draft');
setWatching(null); // the server resets every watcher to its own field on phase changes
// the server resets every watcher to its own field on phase changes — but an eliminated player / spectator seat
// keeps the prep board it follows from 回合开始 through 机变 and 休整期 (Match._followScout re-points it only at the
// round start): resetting here would fly the camera home and back at every prep phase (item 56)
const keepScout = (phase === PHASE.SP_DRAFT || phase === PHASE.PREP) && (!alive || spectator)
&& typeof live.current.watching === 'string' && live.current.watching.startsWith('n:');
if (!keepScout) {
setWatching(null);
setWatchWho(null);
}
// the pen is a 休整期 view: leaving prep returns the camera (the next setCam would, too)
if (phase !== PHASE.PREP && penRef.current.on) togglePenRef.current(false);
// a battle unit's panel (live HP of a unit of the fight that just ended) never outlives its battle
@@ -699,7 +709,7 @@ function MatchScreen() {
const who = playerId ? { fieldId: fid, playerId } : null;
setWatching(fid);
setWatchWho(who);
const ok = await actions.watch(fid);
const ok = await actions.watch(fid, playerId);
if (!ok) {
setWatching((w) => (w === fid ? prev : w));
setWatchWho((w) => (w === who ? prevWho : w));
@@ -713,8 +723,11 @@ function MatchScreen() {
if (L.watching && L.watching !== L.home) {
const target = isCombatPhase(L.pub?.phase) ? L.home : ownFieldId(L.myId);
// client-side combat without an own field to go back to (a 联防 leaker, an eliminated player): the screen keeps
// the field it shows — g.watch of a field that does not exist would only be refused (an error toast)
const exists = !isClientCombat(L.pub) || !isCombatPhase(L.pub?.phase) || (Array.isArray(L.pub?.fields) && L.pub.fields.some((f) => f && f.fieldId === target));
// the field it shows — g.watch of a field that does not exist would only be refused (an error toast); an
// eliminated player / spectator seat has no prep board of its own either
const combatNow = isCombatPhase(L.pub?.phase);
const exists = combatNow ? (!isClientCombat(L.pub) || (Array.isArray(L.pub?.fields) && L.pub.fields.some((f) => f && f.fieldId === target)))
: (L.alive && !L.spectator);
if (exists) actions.watch(target);
}
setWatching(null);
@@ -733,7 +746,9 @@ function MatchScreen() {
}
const self = p.playerId === L.myId;
if (self) {
if (L.watching && L.watching !== L.home) actions.watch(isCombatPhase(L.pub?.phase) ? L.home : ownFieldId(L.myId));
// (an eliminated player / spectator seat has no prep board of its own to ask for: item 56)
const combatNow = isCombatPhase(L.pub?.phase);
if (L.watching && L.watching !== L.home && (combatNow || (L.alive && !L.spectator))) actions.watch(combatNow ? L.home : ownFieldId(L.myId));
setWatching(null);
setWatchWho(null);
return;
@@ -745,17 +760,25 @@ function MatchScreen() {
const watchField = useCallback((fid) => { requestWatch(fid); }, []);
// a spectator seat has no board of its own: in 休整期 / 机变 / round start it is shown the first player still in (as a
// tap on that row would — g.watch 'n:<pid>', the read-only board), once per phase; a row switches to another player
const scoutedRef = useRef(null);
// an eliminated player or a spectator seat follows a player through every phase reset (community report of
// 2026-10-06, item 56; the idea of PR #189): the server pushes that player's prep board (Match._followScout) — the one
// it last watched, else the first player still in — and the screen adopts it like a 前往查看 tap, once per phase and
// board (a 返回战场 this phase is not overridden; a row switches to another player, who is followed from then on).
// Until 0.2.0 every round's reset dropped an eliminated player onto its own empty board (a spectator seat asked for
// the first player itself).
const followRef = useRef(null);
useEffect(() => {
if (!spectator || watching || !pub || scoutedRef.current === phaseKey) return;
if (phase !== PHASE.PREP && phase !== PHASE.SP_DRAFT && phase !== PHASE.ROUND_START) return;
const first = players.find((p) => p.alive !== false && p.status !== 'left');
if (!first) return;
scoutedRef.current = phaseKey;
requestWatch(ownFieldId(first.playerId), first.playerId);
}, [spectator, phaseKey, watching]);
const fid = followedScout({ field, watching, alive, spectator, myId });
if (!fid || !pub) return;
const key = `${phaseKey}:${fid}`;
if (followRef.current === key) return;
followRef.current = key;
// the phase reset's render (watching null) ran the own-prep branch with this scout meta in `field` and marked it
// stale: un-mark it, or the enter effect refuses it and the board stays blank until the player moves (PR #189)
if (staleFieldRef.current === field) staleFieldRef.current = null;
setWatching(fid);
setWatchWho({ fieldId: fid, playerId: fid.slice(2) });
}, [field, pub, watching, alive, spectator, phaseKey, myId]);
// ---- view events (drag & drop, clicks) ----------------------------------------------------------------------
useEffect(() => {
+7 -5
View File
@@ -29,7 +29,7 @@ import { useStore } from '../store.js';
import { PHASE } from '../../../shared/constants.js';
import {
MODULE_NONE, PROF_ORDER, PROF_NAME, rosterOf, filterRoster, recordsOf, chessOptions, effectiveChoice, setChoice, resetChoice,
changedCount, skillLabel, moduleBadge, attrRows, skillTags, serializeExport, parseImport, LOADOUT_IMPORT_MAX_BYTES,
changedCount, skillLabel, moduleBadge, attrRows, skillTags, traitLines, serializeExport, parseImport, LOADOUT_IMPORT_MAX_BYTES,
} from '../ui/loadoutModel.js';
import { loadoutStore, openLoadout, closeLoadout, setEntries, applyLoadoutEntries, setNotOwned, applyOwnershipImport, setDiyPicks, applyDiyImport } from '../ui/loadoutSync.js';
import { setOwned, notOwnedCount, serializeOwnership, parseOwnershipImport, OWNERSHIP_IMPORT_MAX_BYTES } from '../ui/ownershipModel.js';
@@ -198,7 +198,7 @@ function SkillOption({ m, opt, on, level, onPick }) {
</button>`;
}
function ModuleInfo({ m, golden, opt }) {
export function ModuleInfo({ m, golden, opt }) {
if (!golden) return null;
const rec = opt.id === MODULE_NONE ? null : opt.rec;
if (!rec) {
@@ -209,8 +209,9 @@ function ModuleInfo({ m, golden, opt }) {
</div>`;
}
const rows = attrRows(rec.attr);
const trait = rec.traitOverride;
const traitText = trait ? (trait.moduleDescRaw || trait.moduleDesc || trait.descRaw || trait.desc) : null;
// 特性: the trait the elite fights with under this module (the class trait, or the module's rewrite of it), then the
// module's extra line under PRTS's own label 特性追加 — the line adds to the class trait (item 16.2)
const lines = traitLines(rec.traitOverride);
const talents = (Array.isArray(rec.talentChanges) ? rec.talentChanges : []).filter((t) => t && (t.name || t.desc) && !t.hidden);
return html`<div class="lo-minfo">
<div class="lo-minfo__title"><${Img} src=${moduleIconOf(m, rec)} class="lo-minfo__icon" /><span class="lo-minfo__type num">${rec.typeName || ''}</span><b>${rec.name || rec.uniEquipId}</b>
@@ -219,7 +220,8 @@ function ModuleInfo({ m, golden, opt }) {
<span class="lo-minfo__k">${t('属性')}</span>
<span class="lo-minfo__v lo-attrs">${rows.length ? rows.map((r) => html`<span key=${r.key} class=${cx('lo-attr', r.positive ? 'is-up' : 'is-down')}>${r.label}<b class="num">${r.text}</b></span>`) : html`<span class="t-dim">${t('无属性加成')}</span>`}</span>
</div>
${traitText ? html`<div class="lo-minfo__row"><span class="lo-minfo__k">${t('特性')}</span><${RichText} class="lo-minfo__v" text=${traitText} /></div>` : null}
${lines.base ? html`<div class="lo-minfo__row" data-trait="base"><span class="lo-minfo__k">${t('特性')}</span><${RichText} class="lo-minfo__v" text=${lines.base} /></div>` : null}
${lines.added ? html`<div class="lo-minfo__row" data-trait="added"><span class="lo-minfo__k">${t('特性追加')}</span><${RichText} class="lo-minfo__v" text=${lines.added} /></div>` : null}
${talents.map((tal, i) => html`<div key=${i} class="lo-minfo__row"><span class="lo-minfo__k">${t('天赋')}</span>
<span class="lo-minfo__v">${tal.name ? html`<b class="lo-minfo__tname">${tal.name}</b>` : null}<${RichText} text=${tal.descRaw || tal.desc || ''} /></span></div>`)}
</div>`;
+6 -6
View File
@@ -40,7 +40,7 @@ import { abilityRows } from './abilityLines.js';
import { data } from '../data.js';
import { attackRangeGrid } from '../../../shared/loadoutRecord.js';
import { SKILL_SUMMON_START_DEPLOY } from '../../../shared/constants.js';
import { moduleBadge } from './loadoutModel.js';
import { moduleBadge, fullTraitText } from './loadoutModel.js';
import { t, tc, N_ } from '../../../shared/i18n.js';
import { audio } from '../audio.js';
@@ -277,11 +277,11 @@ export function BondChips({ bondIds, bonds = [], onBond = null, off = null, gran
*/
export function traitText(c, golden, lo) {
const t = (lo?.record || c).trait || {};
const base = t.descRaw || t.desc || '';
if (!golden) return base;
// the record's own module line, also for a record cloned for another skill or module (a 不装备 record carries none) —
// until 0.1.2 a clone fell back to the class trait (Grok review of GitHub #64; the in-match card had it too)
return t.moduleDescRaw || base;
if (!golden) return t.descRaw || t.desc || '';
// the class trait (or the module's rewrite of it), then the record's own module line — also for a record cloned for
// another skill or module (a 不装备 record carries none). Until 0.1.2 a clone fell back to the class trait (Grok review
// of GitHub #64); until 0.2.0 the module line replaced the class trait (community report of 2026-10-06, item 16.2).
return fullTraitText(t);
}
/**
+2 -1
View File
@@ -67,7 +67,8 @@ export const actions = {
choice: (idx) => act('g.choice', { idx }),
ready: (ready) => act('g.ready', { ready }, { sfx: ready ? 'ready' : 'back' }),
emote: (id) => act('g.emote', { id }, { quiet: true }),
watch: (fieldId) => act('g.watch', { fieldId }, { sfx: 'tab' }),
// `playerId`: the player tapped in the team panel (a shared field shows two) — what an eliminated viewer follows
watch: (fieldId, playerId = null) => act('g.watch', typeof playerId === 'string' && playerId ? { fieldId, playerId } : { fieldId }, { sfx: 'tab' }),
autoplay: (on) => act('g.autoplay', { on }),
// solo battles only (ui/matchStatus.js pauseAvailable): m.public.paused follows
pause: (on) => act('g.pause', { on: !!on }, { sfx: on ? 'click' : 'confirm' }),
+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
+23
View File
@@ -325,6 +325,29 @@ export function moduleBadge(rec, id = null) {
return m ? m[1] : t.slice(-1) || '?';
}
/**
* The two lines of a trait record (data `trait` / `traitBase`, ModuleRecord `traitOverride`, DATA.md §2): `base` = the
* 特性 the unit fights with — the class trait, or the module's own wording where the module rewrites it (official
* `overrideDescripton`) — and `added` = the module's extra line (official `additionalDescription`), or null. The extra
* line comes after the class trait, never instead of it: PRTS flags it 「特性追加」 on every such module, and the sim keeps
* the class trait with the module equipped (community report of 2026-10-06, item 16.2: until 0.2.0 the 干员调配 module
* card, its 局内数值 and the detail card showed the extra line alone on 114 of the 164 modules the screen offers).
* @param {any} trait
* @returns {{ base: string, added: string|null }}
*/
export function traitLines(trait) {
if (!isObj(trait)) return { base: '', added: null };
const base = String(trait.descRaw || trait.desc || '');
const added = trait.moduleDescRaw || trait.moduleDesc || null;
return { base, added: added ? String(added) : null };
}
/** The whole 特性 text of a trait record: its base line, then the module's extra line (`\n` between; RichText breaks it). */
export function fullTraitText(trait) {
const { base, added } = traitLines(trait);
return base && added ? `${base}\n${added}` : base || added || '';
}
/**
* Module stat bonus as display rows (non-zero entries only).
* @param {Record<string, number> | null | undefined} attr
+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}
+9 -1
View File
@@ -64,7 +64,9 @@
// addSpectator(id) (optional for the platform) A spectator seat joined during the match, came back or asked
// for a resync: register it (idempotent) and resend what an ELIMINATED player watching sees —
// m.public and, while a battle runs, the b.start (watch) of the field it watches (default: the
// first field; server-run mode: m.field + b.snap), or m.result once ended. A spectator gets
// field of the player it follows — the one it last watched, else the first player still in;
// server-run mode: m.field + b.snap), in a prep phase that player's board (m.field prep), or
// m.result once ended. A spectator gets
// every broadcast through the platform, never an m.private / m.toast / m.unitStats, is never
// a field's player or authority, and is shown fields like an eliminated player in every phase.
// handle(id, msg) answers only its 'g.watch' (anything else → SPECTATOR; the platform routes
@@ -358,6 +360,12 @@ export class Match {
this.runner = null;
/** playerId → fieldId */
this.watchers = new Map();
/**
* Watch preference (community report of 2026-10-06, item 56; the idea of PR #189): viewer playerId → the player it
* last chose to watch with a manual g.watch. Every phase reset starts an eliminated human or a spectator seat on that
* player again (its prep board, its battle field) — else the first player still in — instead of its own empty board.
*/
this.watchPref = new Map();
this.lastResults = new Map();
this.unitePlan = null;
/** server-run 联防: the leakers' counts last published (_uniteTick) */
+11
View File
@@ -221,12 +221,23 @@ export function attachAudit(m, { invariants = true, limit = 200 } = {}) {
const price = ps.shop.upgradePrice;
const f0 = ps.funds;
const fx = hasSpendEffects(m, ps);
const slots0 = ps.shop.slots.slice();
const layout0 = ps.shop.layout || { chess: slots0.length, item: 0 };
const res = orig();
if (res && res.ok) check('levelUp', () => {
if (ps.shop.level !== lv + 1) fail(`${ps.playerId}: level ${lv} → ${ps.shop.level}`);
if (f0 - ps.funds !== price && !fx && !hasSpendEffects(m, ps)) fail(`${ps.playerId}: level-up paid ${f0 - ps.funds}, price ${price}`);
const next = gd.upgradeBase(ps.shop.level) ?? 0;
if (ps.shop.upgradePrice !== next) fail(`${ps.playerId}: upgrade price after level-up ${ps.shop.upgradePrice}, expected ${next}`);
// the new level's extra slots open at once (item 19 of 2026-10-06); the cards shown before stay in place
const { chess, item } = gd.shopSlots(ps.shop.level);
const want = Math.max(chess, layout0.chess) + Math.max(item, layout0.item);
if (ps.shop.slots.length !== want) fail(`${ps.playerId}: ${ps.shop.slots.length} shop slots after the level-up to ${ps.shop.level}, expected ${want}`);
const layout = ps.shop.layout || layout0;
slots0.forEach((s, i) => {
const at = i < layout0.chess ? i : layout.chess + (i - layout0.chess);
if (ps.shop.slots[at] !== s) fail(`${ps.playerId}: shop slot ${i} changed by the level-up`);
});
});
return res;
});
+5 -3
View File
@@ -92,7 +92,8 @@ export class MatchCombat {
this.deadline = this.sched.instant ? 0 : this.sched.now() + Math.round((limit / this.gameSpeed) * 1000);
this._launch(fields);
// every fighting human runs its own field; eliminated humans (and spectator seats) keep watching (research 09 §3.1
// "Keep-watching auto-observes the first available field", switching freely with 前往查看): a replica of the first field
// "Keep-watching auto-observes the first available field", switching freely with 前往查看): a replica of the field
// of the player they follow — the one they last watched, else the first player still in (item 56) — else the first
for (const f of fields) for (const pid of f.players) {
const ps = this.players.get(pid);
if (!ps || ps.isBot || ps.left) continue;
@@ -103,8 +104,9 @@ export class MatchCombat {
if (first) {
for (const ps of this._viewers()) {
if (ps.alive || this.watchers.has(ps.playerId)) continue;
this.watchers.set(ps.playerId, first.fieldId);
this._sendStart(ps.playerId, first, { watch: true });
const f = this._watchTargetField(ps, fields) || first;
this.watchers.set(ps.playerId, f.fieldId);
this._sendStart(ps.playerId, f, { watch: true });
}
}
this.markPublic();
+2 -1
View File
@@ -34,7 +34,8 @@ export class MatchIntents {
case 'g.choice': return this.pickCard(ps, msg.idx);
case 'g.ready': return ps.setReady(!!msg.ready);
case 'g.emote': return this.emote(ps, msg.id);
case 'g.watch': return this.watch(ps, msg.fieldId);
// playerId: the player tapped (a shared field names two) — the watch preference (item 56)
case 'g.watch': return this.watch(ps, msg.fieldId, msg.playerId ?? null);
case 'g.autoplay': return this.setAutoplay(ps, !!msg.on);
case 'g.pause': return this.setPause(ps, !!msg.on);
// the stats the board's units start their next battle with (the detail card in prep, user playtest #4 item 7)
+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) {
+3
View File
@@ -253,6 +253,9 @@ export class MatchPhases {
for (const ps of alive) ps.recompute();
this.setDeadline(DELAYS.ROUND_START / 1000, () => this.afterRoundStart(), { silent: this.soloUntimed });
this.markPublic();
// eliminated humans and spectator seats scout the board of the player they follow through the round's prep, not
// their own empty board (community report of 2026-10-06, item 56; MatchWatch._followScout)
for (const ps of this._viewers()) this._followScout(ps);
}
/** The boss round's fields (seat pairs of the alive players) and their templates, generated for the prep preview. */
+6 -2
View File
@@ -100,7 +100,8 @@ export class MatchPlatform {
/**
* The full state of one human (a reconnect, a resync, a spectator seat): m.public, its m.private (players only), the
* field it is on / watches — a spectator, like an eliminated player, the first field — or the result once ended.
* field it is on / watches — a spectator, like an eliminated player, the field of the player it follows (item 56),
* else the first; in a prep phase that player's board — or the result once ended.
*/
_resync(ps) {
const playerId = ps.playerId;
@@ -111,9 +112,10 @@ export class MatchPlatform {
this._sendPrivate(ps, true);
}
if (this.clientCombat) this._resendBattle(ps);
else if (!this.fields.length && this._follows(ps)) this._followScout(ps, { keep: true });
else {
let fid = this.watchers.get(playerId);
if (!fid && ps.spectator && this.fields.length) { fid = this.fields[0].fieldId; this.watchers.set(playerId, fid); }
if (!fid && ps.spectator && this.fields.length) { fid = (this._watchTargetField(ps, this.fields) || this.fields[0]).fieldId; this.watchers.set(playerId, fid); }
if (fid) this._sendField(playerId, fid);
}
} else if (this.lastResultMsg) {
@@ -180,6 +182,8 @@ export class MatchPlatform {
this._planBossWaves();
for (const p of this.alivePlayers()) p.recompute();
}
// whoever scouted the departed player's board follows the next player still in (item 56)
for (const v of this._viewers()) if (this.watchers.get(v.playerId) === `n:${ps.playerId}`) this._followScout(v);
this.markPublic();
if (this.teamLp != null) this._syncTeamLp();
if (!this.alivePlayers().length) {
+69 -13
View File
@@ -1,6 +1,7 @@
// server/match/match/views.js — Match methods: the state builders — m.public (publicView with statusOf / fieldOf, the
// fields' progress and the teammates' live pendingLp / uniteLeft), the nextEnemies preview of m.private and the prep
// scout's m.field (prepFieldMeta: board, hand and temp as units, the scouted player's effects and coming enemies).
// scout's m.field (prepFieldMeta: board, hand and temp as units, the scouted player's effects and coming enemies; in a
// boss round's prep on the player's half of the boss field).
// Installed on Match.prototype by server/match/Match.js (a method container: never instantiated; `this` is the match).
import { PHASE, GEO } from '../../../shared/constants.js';
@@ -9,6 +10,7 @@ import { bondList, offBondCounts } from '../bondsMeta.js';
import { cardView } from '../choices.js';
import { bountySpawns, previewOf } from '../waves.js';
import { timelineAt } from '../fields.js';
import { bossFieldPlacement } from '../finalAssault.js';
import { battleProgress } from '../../sim/spec.js';
export class MatchViews {
@@ -182,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) {
const units = [];
// the scouted 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) => {
/** 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 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;
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,
@@ -212,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
@@ -238,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++) {
@@ -253,6 +288,27 @@ export class MatchViews {
try { nextEnemies = this.nextEnemiesFor(ps); } catch (e) { this.reportError('nextEnemies', e); }
// the scouted player's effects column (策略 / 机变 / 悬赏 …), display-ready (user playtest #2: while scouting, the
// right column shows the watched player's effects, not one's own)
return { t: 'm.field', fieldId: `n:${ps.playerId}`, kind: 'normal', rect: { ...GEO.NORMAL_RECT }, stageId: this.stageId, units, effects: ps.effectsView(), prep: true, nextEnemies };
const meta = { t: 'm.field', fieldId: `n:${ps.playerId}`, kind: 'normal', rect: { ...GEO.NORMAL_RECT }, stageId: this.stageId, units, effects: ps.effectsView(), prep: true, nextEnemies };
// 最终攻势 / 隐秘核心 prep: the pieces stand on the player's half of the boss field — the scout shows them there (rows
// − 7, the right half mirrored with RIGHT ↔ LEFT: finalAssault.js bossFieldPlacement, as the own prep view), with the
// 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;
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). */
_onBossHalf(u, side) {
const p = bossFieldPlacement(side, u.y, u.x, u.dir || 'RIGHT');
const v = { ...u, x: p.col, y: p.row };
// a board piece keeps facing the same way relative to the leader (mirrored on the right half); a bench piece has no
// stored facing (it faces right, as the own prep bench draws it)
if (u.dir) { v.dir = p.dir; v.facing = p.dir === 'LEFT' ? -1 : 1; }
return v;
}
}
+83 -12
View File
@@ -2,7 +2,9 @@
// #26: stand-ins shown fields like an eliminated player), g.watch in both combat modes (the boss-group rule, prep
// scouting of a teammate's board), who is shown which field (the default watch, the boss fields, the resend on a
// reconnect, the spectator's spec without the funds), a field's m.field + b.snap (server-run mode) and the prep-scout
// pushes (GitHub #87).
// pushes (GitHub #87). Eliminated humans and spectator seats follow a player through every phase reset (community
// report of 2026-10-06, item 56; the official DeadAutoObDn { obIndex, state, preparation }, research 09 §3.1; the idea
// of PR #189): the player they last watched with a manual g.watch (`watchPref`), else the first player still in.
// Installed on Match.prototype by server/match/Match.js (a method container: never instantiated; `this` is the match).
import { PHASE, ERR } from '../../../shared/constants.js';
@@ -44,9 +46,13 @@ export class MatchWatch {
return out;
}
watch(ps, fieldId) {
/**
* g.watch { fieldId, playerId? } — `playerId`: the player the viewer tapped (a shared 联防 / boss pair field shows two,
* its id does not say which; recorded as the watch preference when that player is on the field).
*/
watch(ps, fieldId, playerId = null) {
if (typeof fieldId !== 'string') return fail(ERR.BAD_TARGET);
if (this.clientCombat && this.fields.length && this.fields.some((x) => x.cc)) return this._watchClient(ps, fieldId);
if (this.clientCombat && this.fields.length && this.fields.some((x) => x.cc)) return this._watchClient(ps, fieldId, playerId);
const f = this.fields.find((x) => x.fieldId === fieldId);
if (f) {
// 最终攻势 / 隐秘核心: "两名参与者会处于同一个战场,但无法查看另一组队友的战场情况" — a fighting player sees its own
@@ -54,6 +60,7 @@ export class MatchWatch {
const own = this.fieldOf(ps);
if ((f.kind === 'boss' || f.kind === 'hidden') && own && own !== f.fieldId) return fail(ERR.BAD_TARGET, 'other group hidden');
this.watchers.set(ps.playerId, fieldId);
this._watchPrefSet(ps, f, playerId);
this._sendField(ps.playerId, fieldId);
return OK;
}
@@ -66,18 +73,68 @@ export class MatchWatch {
const target = this.players.get(fieldId.slice(2));
if (!target || !target.alive) return fail(ERR.BAD_TARGET);
this.watchers.set(ps.playerId, fieldId);
if (target !== ps) this.watchPref.set(ps.playerId, target.playerId);
this._notifyPrepScouts(target, { to: ps.playerId });
return OK;
}
return fail(ERR.BAD_TARGET);
}
/**
* Record the player a manual watch of field `f` names: `playerId` when it is one of the field's players, else the
* field's only player — never the viewer itself; a shared field without `playerId` names nobody (PR #189's review).
* Only manual watches write it: the automatic assignments below read it.
*/
_watchPrefSet(ps, f, playerId = null) {
const players = Array.isArray(f?.players) ? f.players : [];
const pid = typeof playerId === 'string' && players.includes(playerId) ? playerId : players.length === 1 ? players[0] : null;
if (pid && pid !== ps.playerId) this.watchPref.set(ps.playerId, pid);
}
/**
* The player a viewer follows (an eliminated human, a spectator seat): its watch preference while that player is
* still in, else the first player still in (seat order — the official FindFirstAvailObTarget, AI teammates included:
* the remake's AI seats are players), never the viewer itself; null when nobody is left.
*/
_watchTargetOf(ps) {
const ok = (pid) => { const t = typeof pid === 'string' && pid !== ps.playerId ? this.players.get(pid) : null; return !!t && t.alive && !t.left; };
const pref = this.watchPref.get(ps.playerId);
if (ok(pref)) return pref;
return this.order.find((q) => ok(q.playerId))?.playerId ?? null;
}
/** The field of the player `ps` follows among `fields` (null when that player has none this phase). */
_watchTargetField(ps, fields) {
const pid = this._watchTargetOf(ps);
return pid ? fields.find((f) => Array.isArray(f.players) && f.players.includes(pid)) || null : null;
}
/** Does `ps` follow a player (an eliminated human or a spectator seat, still in the room)? */
_follows(ps) {
return !!ps && !ps.isBot && !ps.left && (ps.spectator || !ps.alive);
}
/**
* A prep phase (回合开始 / 机变 / 休整期): an eliminated human / spectator seat scouts the board of the player it follows
* — `keep`: a scout it already has this phase stays (a manual pick, a resync). Until 0.2.0 a phase reset dropped it
* onto its own empty board every round (community report of 2026-10-06, item 56).
*/
_followScout(ps, { keep = false } = {}) {
if (!this._follows(ps) || this.fields.length) return;
const cur = keep ? this.watchers.get(ps.playerId) : null;
const kept = typeof cur === 'string' && cur.startsWith('n:') ? this.players.get(cur.slice(2)) : null;
const target = kept && kept.alive && !kept.left ? kept : this.players.get(this._watchTargetOf(ps));
if (!target) return;
this.watchers.set(ps.playerId, `n:${target.playerId}`);
if (ps.connected) this._notifyPrepScouts(target, { to: ps.playerId });
}
/**
* g.watch under client-side combat: the watcher gets the field's spec (b.start, display only) and runs a local
* replica fast-forwarded to the field's clock. No watching while the own normal battle runs; the other pair's boss
* field is never shown to a fighting player; eliminated players may watch anything.
*/
_watchClient(ps, fieldId) {
_watchClient(ps, fieldId, playerId = null) {
const f = this.fields.find((x) => x.fieldId === fieldId) || null;
if (!f) return fail(ERR.BAD_TARGET, 'no such field');
const own = this.fields.find((x) => x.players.includes(ps.playerId)) || null;
@@ -86,17 +143,22 @@ export class MatchWatch {
if (f.kind === 'normal' && own && own !== f && own.live) return fail(ERR.WRONG_PHASE, 'own battle running');
}
this.watchers.set(ps.playerId, f.fieldId);
this._watchPrefSet(ps, f, playerId);
this.sendTo(ps.playerId, this._startMsg(f, ps.playerId, { watch: !f.players.includes(ps.playerId) }));
return OK;
}
/** Reconnect / resync: the spec of the field the player is on (fast-forwarded by the client). */
/**
* Reconnect / resync: the spec of the field the player is on (fast-forwarded by the client); an eliminated human /
* spectator seat the field it watches, else the one of the player it follows. With no client field up (a prep phase):
* the board of the player it follows (item 56; PR #189's review — a resync in prep used to show nothing).
*/
_resendBattle(ps) {
if (!this.fields.some((f) => f.cc)) return;
if (!this.fields.some((f) => f.cc)) { this._followScout(ps, { keep: true }); return; }
const fid = this.watchers.get(ps.playerId);
let f = fid ? this.fields.find((x) => x.fieldId === fid) : null;
if (!f) f = this.fields.find((x) => x.players.includes(ps.playerId)) || (this.phase === PHASE.UNITE ? this.fields[0] : null);
if (!f && !ps.alive) f = this.fields[0] || null;
if (!f && !ps.alive) f = this._watchTargetField(ps, this.fields) || this.fields[0] || null;
if (!f) return;
this.watchers.set(ps.playerId, f.fieldId);
this.sendTo(ps.playerId, this._startMsg(f, ps.playerId, { watch: !f.players.includes(ps.playerId) }));
@@ -106,18 +168,22 @@ export class MatchWatch {
this.watchers.clear();
for (const ps of this._viewers()) {
const own = this.fields.find((f) => f.players.includes(ps.playerId));
const f = own || this.fields[0];
// an eliminated human / spectator seat: the field of the player it follows (item 56), else the first
const f = own || this._watchTargetField(ps, this.fields) || this.fields[0];
if (!f) continue;
this.watchers.set(ps.playerId, f.fieldId);
if (ps.connected) this._sendField(ps.playerId, f.fieldId);
}
}
/** b.start of the boss fields: players get their own pair field, eliminated humans and spectator seats the first. */
/**
* b.start of the boss fields: players get their own pair field, eliminated humans and spectator seats the field of
* the player they follow (item 56), else the first.
*/
_watchBossFields(fields) {
for (const ps of this._viewers()) {
const own = fields.find((f) => f.players.includes(ps.playerId)) || null;
const f = own || fields[0];
const f = own || this._watchTargetField(ps, fields) || fields[0];
if (!f) continue;
this.watchers.set(ps.playerId, f.fieldId);
this._sendStart(ps.playerId, f, { watch: !own });
@@ -174,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 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;
+22
View File
@@ -92,6 +92,27 @@ export class PlayerEconomy {
this.dirty();
}
/**
* The 调度中心's upgrade opens the new level's extra slots at once, each with a new card drawn at the new level; the
* cards already shown stay where they are (chess slots keep their index, the item slot stays after them). Official: the
* tutorial's 休整期 page 「升级:…升级后将出现更多的商品栏位、可调度干员以及新装备」; the community report of 2026-10-06
* (item 19) 「升级商店获得新的商店位时用新卡补上,而不是空着」. Until 0.2.0 the extra slots waited for the next roll (a
* refresh or the round start). [ASSUMED] the new card follows the freeze toggle, as a manual refresh's cards do.
*/
_openLevelSlots() {
const { chess: nChess, item: nItem } = this.gd.shopSlots(this.shop.level);
const old = this.shop.slots;
const layout = this.shop.layout || { chess: old.length, item: 0 };
if (nChess <= layout.chess && nItem <= layout.item) return;
const chess = old.slice(0, layout.chess);
const items = old.slice(layout.chess);
const fresh = (s) => { if (s) s.frozen = this.shop.frozen; return s; };
while (chess.length < nChess) chess.push(fresh(this._rollChessSlot()));
while (items.length < nItem) items.push(fresh(this._rollItemSlot()));
this.shop.slots = [...chess, ...items];
this.shop.layout = { chess: chess.length, item: items.length };
}
/** Combat start: unfrozen slots are emptied (research 01 A1). */
clearUnfrozenShop() {
this.shop.slots = this.shop.slots.map((s) => (s && s.frozen && !s.sold ? s : null));
@@ -173,6 +194,7 @@ export class PlayerEconomy {
this.spend(price);
this.shop.level++;
this.shop.upgradePrice = this.gd.upgradeBase(this.shop.level) ?? 0;
this._openLevelSlots();
this.m.tickerFor('SHOP_LEVEL', [this.name, String(this.shop.level)], { playerId: this.playerId, param: String(this.shop.level) });
this.m.dispatch(this, 'onLevelUp', { level: this.shop.level, price });
this._afterSpend(price, 'levelUp');
+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,
+3 -1
View File
@@ -368,7 +368,9 @@ export const C2S = {
'g.choice': { idx: (v) => isInt(v, 0, 5) },
'g.ready': { ready: isBool },
'g.emote': { id: (v) => EMOTES.includes(v) },
'g.watch': { fieldId: (v) => isStr(v, 32) },
// playerId: the player tapped in the team panel (a 联防 / boss pair field shows two) — what an eliminated viewer or a
// spectator seat follows from then on (Match.watchPref; community report of 2026-10-06, item 56)
'g.watch': { fieldId: (v) => isStr(v, 32), playerId: isId, $optional: ['playerId'] },
'g.autoplay': { on: isBool },
// solo pause (official PauseUp / ResumeUp, DESIGN §14): freezes the running battle (field clock, deadlines, the
// browser's local runner) — solo matches only (co-op ⇒ WRONG_PHASE), only while a battle runs; m.public.paused
+502 -504
View File
File diff suppressed because it is too large. Load diff
+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)');
});
@@ -0,0 +1,78 @@
// Community report of 2026-10-06, item 55: 「死亡后观战视角看不到场上的关底boss」. Reproduced in the browser (an eliminated
// guest at the boss round, notes i55-repro): in the Final Assault fight the watched boss field shows the leader, but in
// the boss round's prep the eliminated player saw its own empty normal board — and a scout of a teammate (m.field
// prep) was the normal board too, while the players themselves prepare on their half of the boss field with the leader
// standing at its spawn (render/app.js setLeader, only on the boss-field prep camera). Now the scout of a boss round's
// prep is built on the boss field: the scouted player's pieces on its half (bossFieldPlacement: rows − 7, the right
// half mirrored, RIGHT ↔ LEFT), `side` for the camera, the leader in nextEnemies with its spawn tile; with item 56 an
// eliminated viewer is pushed that scout at the round start without a tap.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { GEO, PHASE } from '../../shared/constants.js';
import { makeMatch, give, legalTileFor, chessOfTier } from './harness.js';
import { bossFieldPlacement } from '../../server/match/finalAssault.js';
import { viewKind, leaderShown } from '../../public/js/render/app/view.js';
import { leaderStand } from '../../public/js/render/prepfield.js';
function bossPrep(seed) {
const h = makeMatch({ mode: 'coop', difficulty: 'FUNNY', humans: 4, seed, fake: true }).start();
const m = h.m;
h.drive(() => m.phase === PHASE.PREP && m.round === m.gd.bossRound, { ready: true });
return h;
}
test('a scout of a boss round\'s prep is the boss field: the player\'s half (mirrored on the right), its side, the leader\'s spawn', () => {
const h = bossPrep(55);
const m = h.m;
assert.deepEqual(m.bossWaves.map((w) => w.players), [['p_0', 'p_1'], ['p_2', 'p_3']]);
const p1 = h.ps('p_1');
// a board piece facing RIGHT and one in the hand
for (const k of [...p1.board.keys()]) p1.board.delete(k);
const id = chessOfTier(1)[0];
const tile = legalTileFor(m, p1, id);
const onBoard = give(m, p1, id, 'board', tile);
onBoard.dir = 'RIGHT';
give(m, p1, chessOfTier(1)[1], 'hand', 2);
h.ps('p_3').eliminate(13);
assert.deepEqual(m.handle('p_3', { t: 'g.watch', fieldId: 'n:p_1' }), { ok: true });
const meta = h.lastTo('p_3', 'm.field');
assert.deepEqual([meta.fieldId, meta.prep, meta.kind, meta.side], ['n:p_1', true, 'boss', 'R']);
assert.deepEqual(meta.rect, GEO.BOSS_RECT);
const u = meta.units.find((x) => x.uid === onBoard.uid);
const want = bossFieldPlacement('R', tile[0], tile[1], 'RIGHT');
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.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)}`);
assert.deepEqual(meta.nextEnemies, m.nextEnemiesFor(p1), 'the scouted player\'s own preview (its pair\'s wave)');
// the client frames a scout with that rect on the boss-field prep camera, which shows the leader
assert.equal(viewKind('prep', { rect: meta.rect, side: meta.side }), 'bossPrep');
assert.equal(leaderShown('bossPrep'), true);
// the left player of the pair: its own half, unmirrored
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']);
// 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();
});
test('the eliminated viewer is pushed the boss-field scout at the boss round\'s start (item 56); a normal round\'s scout stays the normal board', () => {
const h = makeMatch({ mode: 'coop', difficulty: 'FUNNY', humans: 3, seed: 56, fake: true }).start();
const m = h.m;
h.toPrep(1);
h.ps('p_2').eliminate(1);
h.toPrep(2);
const normal = h.lastTo('p_2', 'm.field');
assert.deepEqual([normal.fieldId, normal.kind, normal.prep, normal.side], ['n:p_0', 'normal', true, undefined]);
assert.deepEqual(normal.rect, GEO.NORMAL_RECT);
h.drive(() => m.phase === PHASE.PREP && m.round === m.gd.bossRound, { ready: true });
const meta = h.lastTo('p_2', 'm.field');
assert.deepEqual([meta.fieldId, meta.kind, meta.prep, meta.side], ['n:p_0', 'boss', true, 'L']);
assert.ok(leaderStand(meta.nextEnemies, () => null, (x, y) => [[y, x]]), 'the leader at its spawn');
m.dispose();
});
@@ -0,0 +1,94 @@
// Community report of 2026-10-06, item 19: 「升级商店获得新的商店位时用新卡补上,而不是空着」. The official tutorial's 休整期
// page says 「升级:消耗资金升级调度中心。升级后将出现更多的商品栏位、可调度干员以及新装备」: the upgrade itself opens the
// new level's extra slot. Until 0.2.0 the shop kept its old slot count until the next roll (a refresh or the round start),
// so a level-up from 1 to 2 (险境: 3 → 4 operator slots) or 3 to 4 (4 → 5) showed no new card. Now the upgrade adds the
// extra slots at once, each with a new card drawn at the new level, and leaves the cards already shown where they are.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeMatch, DATA } from './harness.js';
import { GameData } from '../../server/match/gamedata.js';
const ids = (slots) => slots.map((s) => (s ? `${s.kind}:${s.id}${s.sold ? '(sold)' : ''}` : null));
const tierOf = (id) => DATA.chess[id]?.tier ?? 0;
test('a level-up opens the new level\'s operator slot at once with a new card; the cards shown stay in place', () => {
const h = makeMatch({ mode: 'solo', difficulty: 'NORMAL', seed: 7, fake: true }).start();
h.toPrep(1);
const ps = h.ps('p_0');
ps.funds = 200;
assert.deepEqual(h.m.gd.shopSlots(1), { chess: 3, item: 1 });
const s1 = ps.shop.slots.slice();
assert.equal(s1.length, 4);
assert.deepEqual(h.m.handle('p_0', { t: 'g.levelUp' }), { ok: true });
// level 2: 4 operator slots + the item slot — the three cards and the item kept (the same objects), one new card
assert.equal(ps.shop.level, 2);
assert.deepEqual(ps.shop.layout, { chess: 4, item: 1 });
assert.equal(ps.shop.slots.length, 5);
for (let i = 0; i < 3; i++) assert.equal(ps.shop.slots[i], s1[i], `operator card ${i} kept`);
assert.equal(ps.shop.slots[4], s1[3], 'the item card stays after the operator cards');
const added = ps.shop.slots[3];
assert.ok(added && added.kind === 'chess' && !added.sold, `a new operator card: ${JSON.stringify(added)}`);
assert.ok(tierOf(added.id) >= 1 && tierOf(added.id) <= 2, `drawn at the new level (tier ${tierOf(added.id)})`);
assert.equal(added.frozen, false);
// the player sees it right away (m.private shop)
assert.deepEqual(ids(ps.privateView().shop.slots), ids(ps.shop.slots));
// 2 → 3 keeps 4 operator slots: nothing rerolled
const s2 = ps.shop.slots.slice();
h.m.handle('p_0', { t: 'g.levelUp' });
assert.equal(ps.shop.level, 3);
assert.deepEqual(ps.shop.slots, s2, 'no extra slot at level 3, no reroll');
// 3 → 4: the fifth operator slot
h.m.handle('p_0', { t: 'g.levelUp' });
assert.equal(ps.shop.level, 4);
assert.deepEqual(ps.shop.layout, { chess: 5, item: 1 });
for (let i = 0; i < 4; i++) assert.equal(ps.shop.slots[i], s2[i]);
assert.equal(ps.shop.slots[5], s2[4]);
assert.ok(ps.shop.slots[4]?.kind === 'chess' && tierOf(ps.shop.slots[4].id) <= 4);
h.invariants();
h.m.dispose();
});
test('a bought card stays sold; a frozen shop freezes the new card too [ASSUMED], and the round start keeps it', () => {
const h = makeMatch({ mode: 'solo', difficulty: 'NORMAL', seed: 11, fake: true }).start();
h.toPrep(1);
const ps = h.ps('p_0');
ps.funds = 200;
assert.deepEqual(h.m.handle('p_0', { t: 'g.buy', slot: 0 }), { ok: true });
assert.equal(ps.shop.slots[0].sold, true);
assert.deepEqual(h.m.handle('p_0', { t: 'g.freeze' }), { ok: true });
h.m.handle('p_0', { t: 'g.levelUp' });
assert.equal(ps.shop.slots[0].sold, true, 'the bought slot is not refilled');
const added = ps.shop.slots[3];
assert.ok(added && !added.sold && added.frozen === true, 'the new card follows the freeze toggle');
const keep = ps.shop.slots.filter((s) => s && !s.sold && s.frozen).map((s) => s.id);
assert.equal(keep.length, 4, 'two operator cards, the new one and the item');
// the next round start keeps every frozen card (in place) and rolls only the sold slot
h.drive(() => h.m.phase === 'PREP' && h.m.round === 2);
assert.deepEqual(ps.shop.slots.slice(1).map((s) => s.id), keep);
assert.equal(ps.shop.slots.length, 5);
h.invariants();
h.m.dispose();
});
test('every mode\'s slot table: a level-up adds exactly the slots the new level has more of (标准: 3,4,4,4,4,5)', () => {
for (const [mode, difficulty] of [['solo', 'FUNNY'], ['solo', 'NORMAL'], ['coop', 'FUNNY'], ['coop', 'ABYSS']]) {
const h = makeMatch({ mode, difficulty, seed: 3, fake: true, humans: 1, bots: mode === 'coop' ? 1 : 0 }).start();
h.toPrep(1);
const ps = h.ps('p_0');
const gd = h.m.gd;
ps.funds = 500;
for (let lv = 2; lv <= gd.maxShopLevel; lv++) {
const before = ps.shop.slots.slice();
assert.deepEqual(h.m.handle('p_0', { t: 'g.levelUp' }), { ok: true });
const { chess, item } = gd.shopSlots(lv);
assert.equal(ps.shop.slots.length, chess + item, `${mode} ${difficulty} L${lv}`);
const grew = chess - gd.shopSlots(lv - 1).chess;
const kept = ps.shop.slots.filter((s) => before.includes(s)).length;
assert.equal(kept, before.length, `${mode} ${difficulty} L${lv}: every shown card kept`);
assert.equal(ps.shop.slots.length - before.length, grew + (item - gd.shopSlots(lv - 1).item));
}
h.invariants();
h.m.dispose();
}
assert.deepEqual([1, 2, 3, 4, 5, 6].map((l) => new GameData(DATA, 'mode_single_funny').shopSlots(l).chess), [3, 4, 4, 4, 4, 5]);
});
+156
View File
@@ -0,0 +1,156 @@
// Community report of 2026-10-06, item 56: 「死亡观战时每一回合结束都会被转回自己家」. An eliminated human (and a spectator
// seat) now follows a player through every phase reset — the official keeps observing after death (research 09 §3.1:
// self_dead_dialog KEEP_WATCH, DeadAutoObDn { obIndex, state, preparation }, FindFirstAvailObTarget): the player it last
// watched with a manual g.watch (Match.watchPref), else the first player still in (seat order, AI seats included —
// they are players of the remake). Round start: that player's prep board (m.field prep); 各自行动 (both combat modes),
// 最终攻势 / 隐秘核心: that player's field; a resync / a spectator joining in prep: the board again. The idea and most
// cases come from PR #189 by @2321Robin (watch preference), not merged; the shared-field rule of its review is solved
// with g.watch's optional `playerId` (the tapped player). The client side: battle/observe.js followedScout.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { PHASE } from '../../shared/constants.js';
import { validateC2S } from '../../shared/protocol.js';
import { makeMatch } from './harness.js';
import { followedScout } from '../../public/js/battle/observe.js';
const S = 's_spec';
test('round start: an eliminated viewer scouts the board it last watched; a spectator without a pick the first player still in', () => {
const h = makeMatch({ mode: 'coop', humans: 2, seed: 1001, fake: true, spectators: [S] }).start();
h.toPrep(1);
const m = h.m;
assert.deepEqual(m.handle('p_0', { t: 'g.watch', fieldId: 'n:p_1' }), { ok: true });
assert.equal(m.watchPref.get('p_0'), 'p_1', 'the manual scout is the preference');
h.ps('p_0').eliminate(1);
h.toPrep(2);
assert.equal(m.watchers.get('p_0'), 'n:p_1', 'the eliminated viewer scouts its preference again');
const meta = h.lastTo('p_0', 'm.field');
assert.deepEqual([meta?.fieldId, meta?.prep], ['n:p_1', true], 'the prep board itself was pushed');
assert.equal(m.watchPref.get('p_0'), 'p_1', 'the automatic assignment leaves the preference alone');
assert.equal(m.watchers.get(S), 'n:p_1', 'the spectator (no pick) follows the first player still in: p_1 (p_0 is out)');
assert.equal(h.lastTo(S, 'm.field')?.fieldId, 'n:p_1');
m.dispose();
});
test('the item: one human with AI teammates — eliminated, it follows an AI seat\'s board every round, never its own empty one', () => {
const h = makeMatch({ mode: 'coop', humans: 1, bots: 3, seed: 1010, fake: true }).start();
h.toPrep(1);
const m = h.m;
h.ps('p_0').eliminate(1);
h.toPrep(2);
assert.equal(m.watchers.get('p_0'), 'n:ai_0', 'the first player still in');
assert.equal(h.lastTo('p_0', 'm.field')?.fieldId, 'n:ai_0');
// a row tapped: that AI seat from now on, in combat and at the next round start
assert.deepEqual(m.handle('p_0', { t: 'g.watch', fieldId: 'n:ai_2' }), { ok: true });
h.runToPhase(PHASE.COMBAT, 2);
assert.equal(m.watchers.get('p_0'), 'n:ai_2', 'its battle (server-run combat)');
h.toPrep(3);
assert.equal(m.watchers.get('p_0'), 'n:ai_2', 'its board again at the round start');
assert.equal(h.lastTo('p_0', 'm.field')?.fieldId, 'n:ai_2');
m.dispose();
});
test('a dead preference falls back to the first player still in; a followed player who quits mid-prep hands over at once', () => {
const h = makeMatch({ mode: 'coop', humans: 4, seed: 1002, fake: true }).start();
h.toPrep(1);
const m = h.m;
assert.deepEqual(m.handle('p_0', { t: 'g.watch', fieldId: 'n:p_3' }), { ok: true });
h.ps('p_0').eliminate(1);
h.ps('p_3').eliminate(1);
h.toPrep(2);
assert.equal(m.watchers.get('p_0'), 'n:p_1', 'p_3 is gone → the first player still in (p_1)');
// p_1 quits during the prep: whoever scouted it follows the next player still in at once
m.onLeave('p_1');
assert.equal(m.watchers.get('p_0'), 'n:p_2');
assert.deepEqual([h.lastTo('p_0', 'm.field')?.fieldId, h.lastTo('p_0', 'm.field')?.prep], ['n:p_2', true]);
m.dispose();
});
test('server-run combat: the default watch starts an eliminated viewer on its preference; a mid-phase switch re-records it', () => {
const h = makeMatch({ mode: 'coop', humans: 3, seed: 1003, fake: true }).start();
h.toPrep(1);
const m = h.m;
assert.deepEqual(m.handle('p_0', { t: 'g.watch', fieldId: 'n:p_2' }), { ok: true });
h.ps('p_0').eliminate(1);
h.runToPhase(PHASE.COMBAT, 1);
assert.equal(m.watchers.get('p_0'), 'n:p_2');
assert.equal(m.watchers.get('p_1'), 'n:p_1', 'a fighting player stays on its own field');
assert.equal(m.watchPref.get('p_0'), 'p_2', 'the combat assignment does not touch the preference');
assert.deepEqual(m.handle('p_0', { t: 'g.watch', fieldId: 'n:p_1' }), { ok: true });
assert.equal(m.watchers.get('p_0'), 'n:p_1');
assert.equal(m.watchPref.get('p_0'), 'p_1', 'the manual switch is the new preference');
m.dispose();
});
test('client combat: an eliminated human is started on the field of the player it last watched; a reconnect puts it back there', () => {
const h = makeMatch({ mode: 'coop', humans: 3, seed: 1004, fake: true, clientCombat: true }).start();
h.toPrep(1);
const m = h.m;
assert.deepEqual(m.handle('p_0', { t: 'g.watch', fieldId: 'n:p_2' }), { ok: true });
h.ps('p_0').eliminate(1);
h.runToPhase(PHASE.COMBAT, 1);
let start = h.lastTo('p_0', 'b.start');
assert.deepEqual([start?.watch, start?.fieldId], [true, 'n:p_2'], 'the preference\'s field, not the first seat\'s');
m.onDisconnect('p_0');
m.onReconnect('p_0');
start = h.lastTo('p_0', 'b.start');
assert.deepEqual([start?.watch, start?.fieldId], [true, 'n:p_2']);
m.dispose();
});
test('Final Assault: an eliminated viewer is started on its preference\'s boss field; a shared field records the tapped player only', () => {
const h = makeMatch({ mode: 'coop', difficulty: 'FUNNY', humans: 4, seed: 54, fake: true, instant: false, script: (b) => (b.kind === 'boss' ? { bossDps: 1 } : {}) }).start();
const m = h.m;
h.drive(() => m.phase === PHASE.PREP && m.round === 14);
h.ps('p_3').eliminate(13);
assert.deepEqual(m.handle('p_3', { t: 'g.watch', fieldId: 'n:p_2' }), { ok: true }, 'records the preference in prep');
h.drive(() => m.phase === PHASE.FINAL_ASSAULT);
assert.deepEqual(m.fields.map((f) => [f.fieldId, f.players]), [['b1', ['p_0', 'p_1']], ['b2', ['p_2']]]);
assert.equal(m.watchers.get('p_3'), 'b2', 'follows p_2 into b2 without a tap');
assert.equal(m.watchers.get('p_0'), 'b1', 'a fighting player stays on its own boss field');
// PR #189's review: a shared field's id does not say who was tapped — without `playerId` nothing is recorded
assert.deepEqual(m.handle('p_3', { t: 'g.watch', fieldId: 'b1' }), { ok: true });
assert.equal(m.watchPref.get('p_3'), 'p_2');
// with it (the client sends the row tapped), that player is the preference; a player not on the field is not
assert.deepEqual(m.handle('p_3', { t: 'g.watch', fieldId: 'b1', playerId: 'p_1' }), { ok: true });
assert.equal(m.watchPref.get('p_3'), 'p_1');
assert.deepEqual(m.handle('p_3', { t: 'g.watch', fieldId: 'b1', playerId: 'p_2' }), { ok: true });
assert.equal(m.watchPref.get('p_3'), 'p_1', 'p_2 is not on b1');
m.dispose();
});
test('a resync in prep sends the followed board: a spectator joining, an eliminated human reconnecting (client combat)', () => {
const h = makeMatch({ mode: 'coop', humans: 2, seed: 1006, fake: true, clientCombat: true }).start();
h.toPrep(1);
const m = h.m;
m.addSpectator(S);
let meta = h.lastTo(S, 'm.field');
assert.deepEqual([meta?.fieldId, meta?.prep], ['n:p_0', true], 'no pick → the first player still in');
assert.equal(m.watchers.get(S), 'n:p_0', 'registered as a scout: later board changes push again');
assert.deepEqual(m.handle('p_0', { t: 'g.watch', fieldId: 'n:p_1' }), { ok: true });
h.ps('p_0').eliminate(1);
m.onDisconnect('p_0');
const n = h.allTo('p_0', 'm.field').length;
m.onReconnect('p_0');
assert.ok(h.allTo('p_0', 'm.field').length > n, 'the reconnect pushed a board');
meta = h.lastTo('p_0', 'm.field');
assert.deepEqual([meta?.fieldId, meta?.prep], ['n:p_1', true], 'the preference survived the disconnect');
m.dispose();
});
test('g.watch\'s optional playerId passes the protocol check; junk does not', () => {
assert.equal(validateC2S({ t: 'g.watch', fieldId: 'b1' }), null);
assert.equal(validateC2S({ t: 'g.watch', fieldId: 'b1', playerId: 'p_1' }), null);
assert.notEqual(validateC2S({ t: 'g.watch', fieldId: 'b1', playerId: 7 }), null);
});
test('client: followedScout adopts the server\'s prep board for an eliminated player / spectator seat only, from home', () => {
const scout = { t: 'm.field', fieldId: 'n:p_1', prep: true };
assert.equal(followedScout({ field: scout, watching: null, alive: false, myId: 'p_0' }), 'n:p_1');
assert.equal(followedScout({ field: scout, watching: null, alive: false, spectator: true, myId: 's_x' }), 'n:p_1');
assert.equal(followedScout({ field: scout, watching: null, alive: true, myId: 'p_0' }), null, 'a living player keeps its own board');
assert.equal(followedScout({ field: scout, watching: 'n:p_2', alive: false, myId: 'p_0' }), null, 'already watching');
assert.equal(followedScout({ field: { ...scout, prep: false }, alive: false, myId: 'p_0' }), null, 'a battle field');
assert.equal(followedScout({ field: { ...scout, fieldId: 'n:p_0' }, alive: false, myId: 'p_0' }), null, 'its own board');
assert.equal(followedScout({ field: null, alive: false, myId: 'p_0' }), null);
});
+6 -4
View File
@@ -1,7 +1,8 @@
// Spectator seats, the match side (community report #26, owner's decision 2026-10-04 — a remake feature: the official
// room has no spectator seat). A spectator (opts.spectators / addSpectator, server/lobby.js spectate) is shown fields like
// an ELIMINATED player in every phase — the first field of each battle (b.start watch), any field on g.watch, the 联防
// spec, the first boss field, a prep board on g.watch 'n:<pid>' — and the settlement; it never receives an m.private
// an ELIMINATED player in every phase — the field of the player it follows in each battle (b.start watch: the one it last
// watched, else the first), any field on g.watch, the 联防 spec, that player's boss field, a prep board on g.watch
// 'n:<pid>' or of the player it follows — and the settlement; it never receives an m.private
// (shop, hand, funds …), an m.toast or m.unitStats, is never a field's player or authority, and every intent but g.watch
// is refused (SPECTATOR). The platform half (seats, cap, host removal, reconnect): test/lobby.test.js; over sockets with
// the real Match: test/match/lobby-integration.test.js.
@@ -65,8 +66,9 @@ test('a spectator seat watches a whole match (各自行动, 联防, 最终攻势
}
const kinds = new Set(starts.map((x) => x.kind));
for (const k of ['normal', 'unite', 'boss']) assert.ok(kinds.has(k), `a ${k} field was shown (${[...kinds]})`);
// what it was shown in 各自行动 is what an eliminated player is shown: the first field of the phase
for (const st of starts.filter((x) => x.kind === 'normal')) assert.equal(st.fieldId, 'n:p_0');
// what it was shown in 各自行动 is what an eliminated player is shown: the field of the player it follows — p_1, the one
// it scouted itself in prep (a manual watch is the preference: item 56 of 2026-10-06); with no pick the first field
for (const st of starts.filter((x) => x.kind === 'normal')) assert.equal(st.fieldId, 'n:p_1');
// the Final Assault's end reaches the boss field it watched, like every human shown that field
const ends = h.allTo(S, 'b.end');
assert.ok(ends.some((e) => e.fieldId === starts.filter((x) => x.kind === 'boss').pop().fieldId), 'b.end of the watched boss field');
+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();
}
});
});
+149
View File
@@ -0,0 +1,149 @@
// Community report of 2026-10-06, item 16.2: 「干员调配界面很多干员的某个模组只有额外特性没有基础特性,不清楚是文本错误还是
// 实际效果也有错」. A module's trait part either rewrites the class trait (official `overrideDescripton`) or adds a line
// after it (official `additionalDescription`, PRTS 「特性追加」). The data keeps both (`traitOverride.desc` = the class trait
// or its rewrite, `moduleDesc` = the added line) and the sim keeps the class trait with the module on; the text was wrong:
// the 干员调配 module card, its 局内数值, the detail card and the 自选 module row showed the added line alone (114 of the
// 164 modules the screen offers). Now the class trait comes first and the added line after it (the module card labels it 特性追加).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
globalThis.fetch = async (url) => {
const name = String(url).split('/').pop();
try {
const body = readFileSync(path.join(ROOT, 'data', name), 'utf8');
return { ok: true, status: 200, json: async () => JSON.parse(body) };
} catch {
return { ok: false, status: 404, json: async () => ({}) };
}
};
const { traitLines, fullTraitText } = await import('../../public/js/ui/loadoutModel.js');
const { traitText } = await import('../../public/js/ui/detailPanel.js');
const { ModuleInfo } = await import('../../public/js/screens/loadout.js');
const { DiyPickerView } = await import('../../public/js/screens/diy.js');
const { chessLoadout } = await import('../../public/js/ui/gameLogic.js');
const { getDefaultSource } = await import('../../server/sim/simdata.js');
const { resolveProfile } = await import('../../server/sim/professions.js');
const { data } = await import('../../public/js/data.js');
const { KITTED_CHARS } = await import('../../server/sim/content/kits/index.js');
await data.loadAll('chess', 'bonds', 'assets', 'garrisons', 'items', 'backups');
const CHESS = JSON.parse(readFileSync(path.join(ROOT, 'data/chess.json'), 'utf8'));
const get = (id) => CHESS[id] ?? null;
const elites = Object.values(CHESS).filter((c) => c.visible !== false && c.isGolden && Array.isArray(c.modules));
const modules = elites.flatMap((g) => g.modules.map((m) => ({ g, m })));
const added = modules.filter(({ m }) => m.traitOverride?.moduleDesc);
function* walk(v) {
if (Array.isArray(v)) { for (const x of v) yield* walk(x); return; }
if (!v || typeof v !== 'object') return;
yield v;
yield* walk(v.props?.children);
}
const hasClass = (v, c) => typeof v?.props?.class === 'string' && v.props.class.split(/\s+/).includes(c);
const textOf = (v) => {
if (v == null || typeof v === 'boolean') return '';
if (typeof v === 'string' || typeof v === 'number') return String(v);
if (Array.isArray(v)) return v.map(textOf).join('');
return typeof v === 'object' ? textOf(v.props?.children) : '';
};
const richText = (v) => String([...walk(v)].find((n) => typeof n.type === 'function' && n.type.name === 'RichText')?.props.text ?? '');
/** The module card's rows: { k, text, kind } (kind = data-trait). */
const cardRows = (tree) => [...walk(tree)].filter((n) => hasClass(n, 'lo-minfo__row')).map((row) => ({
k: textOf([...walk(row)].find((n) => hasClass(n, 'lo-minfo__k'))), text: richText(row), kind: row.props['data-trait'] ?? null,
}));
test('the data: 164 elite modules offered, 114 of them add a line after the class trait (the report\'s "only the extra trait")', () => {
assert.ok(modules.length >= 160, `${modules.length} elite module records`);
assert.ok(added.length >= 110, `${added.length} modules with an added line`);
for (const { g, m } of added) {
// the record keeps the class trait (or the module's rewrite of it) beside the added line
assert.ok(m.traitOverride.desc && m.traitOverride.desc !== m.traitOverride.moduleDesc, `${g.chessId} ${m.uniEquipId}`);
}
// 隐现's MAR-X: the official candidate is additionalDescription only, so the class trait stays
const inside = get('chess_char_1_01_b').modules.find((m) => m.typeName === 'MAR-X');
assert.equal(inside.traitOverride.desc, '优先攻击空中单位');
assert.equal(inside.traitOverride.moduleDesc, '攻击空中单位时攻击力提升至110%');
});
test('traitLines / fullTraitText: the class trait, then the added line; a rewrite or a plain trait is one line', () => {
assert.deepEqual(traitLines({ desc: 'A', descRaw: '<@ba.kw>A</>', moduleDesc: 'B', moduleDescRaw: '<@ba.kw>B</>' }), { base: '<@ba.kw>A</>', added: '<@ba.kw>B</>' });
assert.equal(fullTraitText({ desc: 'A', moduleDesc: 'B' }), 'A\nB');
assert.equal(fullTraitText({ desc: 'A' }), 'A');
assert.equal(fullTraitText({ moduleDesc: 'B' }), 'B');
assert.equal(fullTraitText(null), '');
assert.deepEqual(traitLines(undefined), { base: '', added: null });
});
test('局内数值 and the detail card (traitText): every added-line module shows the class trait first, then its line', () => {
for (const { g, m } of added) {
const lo = { [g.baseId]: { module: m.uniEquipId } };
const shown = traitText(g, true, chessLoadout(g, lo, get));
const t = m.traitOverride;
assert.equal(shown, `${t.descRaw || t.desc}\n${t.moduleDescRaw || t.moduleDesc}`, `${g.chessId} ${m.typeName}`);
}
// 不装备 keeps the bare class trait; a normal chess its own
const g = get('chess_char_1_01_b');
assert.equal(traitText(g, true, chessLoadout(g, { [g.baseId]: { module: 'none' } }, get)), g.traitBase.descRaw);
assert.equal(traitText(get('chess_char_1_01_a'), false, null), get('chess_char_1_01_a').trait.descRaw);
});
test('the 干员调配 module card: 特性 = the class trait (or the rewrite), 特性追加 = the added line', () => {
for (const { g, m } of modules) {
const rows = cardRows(ModuleInfo({ m: null, golden: g, opt: { id: m.uniEquipId, rec: m, isDefault: m.isDefault } }));
const t = m.traitOverride;
const trait = rows.filter((r) => r.kind);
if (t?.moduleDesc) {
assert.deepEqual(trait.map((r) => [r.k, r.text]), [['特性', t.descRaw || t.desc], ['特性追加', t.moduleDescRaw || t.moduleDesc]], `${g.chessId} ${m.typeName}`);
} else {
assert.deepEqual(trait.map((r) => [r.k, r.text]), [['特性', t.descRaw || t.desc]], `${g.chessId} ${m.typeName}: a rewrite is one line`);
}
}
});
test('the 自选 picker\'s module rows: the class trait, then the module\'s line', () => {
const pick = { charId: 'char_112_siege', skillIndex: 2, uniEquipId: 'uniequip_002_siege' };
const slot = { slotId: 'chess_char_5_diy1_a', tier: 5, shopLevel: 5 };
const tree = DiyPickerView({ m: null, slot, picks: { [slot.slotId]: pick }, kitted: [...KITTED_CHARS], onDone() {}, onClose() {},
filter: 'all', query: '', draft: { ...pick }, onFilter() {}, onQuery() {}, onDraft() {} });
const expand = (v, d = 6) => {
if (Array.isArray(v)) return v.map((x) => expand(x, d));
if (!v || typeof v !== 'object') return v;
if (typeof v.type === 'function' && v.type.name !== 'RichText' && d > 0) { try { return expand(v.type(v.props || {}), d - 1); } catch { return v; } }
const c = v.props?.children;
return c === undefined ? v : { ...v, props: { ...v.props, children: expand(c, d) } };
};
const rows = [...walk(expand(tree))].filter((v) => v.props?.role === 'radio' && v.props['data-module'] && v.props['data-module'] !== 'none');
assert.ok(rows.length >= 1);
const backups = data.get('backups');
const mods = Object.values(backups.units.char_112_siege.forms).flatMap((f) => f.modules || []);
for (const row of rows) {
const rec = mods.find((x) => x.uniEquipId === row.props['data-module']);
const t = rec.traitOverride;
const first = [...walk(row)].find((n) => typeof n.type === 'function' && n.type.name === 'RichText');
assert.equal(first.props.text, t.moduleDesc ? `${t.descRaw || t.desc}\n${t.moduleDescRaw || t.moduleDesc}` : (t.descRaw || t.desc));
}
});
test('the sim: an added-line module keeps the class trait (the same text, targeting and attack shape as 不装备)', () => {
const ds = getDefaultSource();
const keys = ['trait', 'targetPriority', 'attackKind', 'dmgType', 'canHitFly', 'splashRadius'];
const prof = ['attack', 'dmgType', 'canHitFly', 'maxTargets', 'hitAllBlocked', 'allInRange', 'priority', 'noAttack', 'noHeal', 'blockFly'];
let same = 0;
for (const { g, m } of added) {
const a = ds.getChess(g.chessId, { moduleId: m.uniEquipId });
const b = ds.getChess(g.chessId, { moduleId: 'none' });
assert.equal(a.trait, m.traitOverride.desc, `${g.chessId} ${m.typeName}: the battle's trait text = the card's`);
if (m.traitOverride.desc !== g.traitBase.desc) continue; // 圣约送葬人 REA-Y rewrites the class trait too
const pa = resolveProfile(a), pb = resolveProfile(b);
for (const k of keys) assert.deepEqual(a[k], b[k], `${g.chessId} ${m.typeName} ${k}`);
for (const k of prof) assert.deepEqual(pa[k], pb[k], `${g.chessId} ${m.typeName} profile.${k}`);
for (const k of Object.keys(b.traitBb || {})) assert.ok(k in a.traitBb, `${g.chessId} ${m.typeName}: keeps trait bb ${k}`);
same++;
}
assert.ok(same >= 110, `${same} added-line modules keep the class trait in battle`);
});
+5 -5
View File
@@ -196,9 +196,9 @@ test('特性 and 天赋 follow the chosen module: 隐现\'s trait upgrade is MAR
const rows = (id, level, entries) => kitRows(section(id, level, entries));
const { golden } = slot(INSIDE);
const traitOf = (rs) => rs.find((r) => r.k === '特性');
// default module: the module's upgraded trait text; 不装备: the plain class trait
assert.equal(traitOf(rows(INSIDE, 'elite', {})).text, golden.trait.moduleDescRaw);
assert.match(traitOf(rows(INSIDE, 'elite', {})).text, /110%/);
// default module: the class trait, then the module's added line (item 16.2 of 2026-10-06); 不装备: the plain class trait
assert.equal(traitOf(rows(INSIDE, 'elite', {})).text, `${golden.trait.descRaw}\n${golden.trait.moduleDescRaw}`);
assert.match(traitOf(rows(INSIDE, 'elite', {})).text, /^优先攻击空中单位\n[^\n]*110%/);
assert.equal(traitOf(rows(INSIDE, 'elite', { [INSIDE]: { module: 'none' } })).text, golden.traitBase.descRaw);
assert.equal(traitOf(rows(INSIDE, 'normal', {})).text, slot(INSIDE).base.trait.descRaw, '普通: its own trait');
// 天赋: named and not hidden, the record's list
@@ -292,8 +292,8 @@ test('特性 follows the chosen module also when the skill or the module differs
const chess = JSON.parse(fs.readFileSync(new URL('../../data/chess.json', import.meta.url), 'utf8'));
const get = (id) => chess[id];
const shown = (id, lo) => traitText(get(id), true, chessLoadout(get(id), lo, get));
// 隐现 (MAR-X): its other skill keeps the module's line, not the class trait 优先攻击空中单位
assert.match(shown('chess_char_1_01_b', { chess_char_1_01_a: { skill: 0, module: null } }), /攻击空中单位时攻击力提升至/);
// 隐现 (MAR-X): its other skill keeps the module's line after the class trait 优先攻击空中单位 (item 16.2)
assert.match(shown('chess_char_1_01_b', { chess_char_1_01_a: { skill: 0, module: null } }), /^优先攻击空中单位\n攻击空中单位时攻击力提升至/);
// 信仰搅拌机: SPT-Y shows its own line; 不装备 shows the class trait
assert.match(shown('chess_char_4_01_b', { chess_char_4_01_a: { skill: get('chess_char_4_01_b').skill.index, module: 'uniequip_003_rmixer' } }), /攻击距离\+1/);
assert.match(shown('chess_char_4_01_b', { chess_char_4_01_a: { skill: get('chess_char_4_01_b').skill.index, module: 'none' } }), /能够阻挡三个敌人/);