diff --git a/docs/DATA.md b/docs/DATA.md index b1fb47a4..511b34ce 100644 --- a/docs/DATA.md +++ b/docs/DATA.md @@ -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` diff --git a/docs/META.md b/docs/META.md index 3db3e3f0..b5cd6288 100644 --- a/docs/META.md +++ b/docs/META.md @@ -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, diff --git a/docs/PLAYING.md b/docs/PLAYING.md index 65fec808..45bb2b7c 100644 --- a/docs/PLAYING.md +++ b/docs/PLAYING.md @@ -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. 结算称号 diff --git a/docs/design/client.md b/docs/design/client.md index d23e588a..46c9fda7 100644 --- a/docs/design/client.md +++ b/docs/design/client.md @@ -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:` 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. --- diff --git a/docs/design/network.md b/docs/design/network.md index a24aea7a..0d8a2748 100644 --- a/docs/design/network.md +++ b/docs/design/network.md @@ -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` = `...` (`Match._ccField`; `` = the match seed in base 36 plus `-`, the room's match number the lobby passes as `opts.matchNo`; `` counts every battle of the match; the informational `.` is dropped when the id would exceed the protocol's 64 chars): unique for the whole match although field ids repeat (`n:` 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. diff --git a/docs/history/0.2.0.md b/docs/history/0.2.0.md index c986e7b3..51510f18 100644 --- a/docs/history/0.2.0.md +++ b/docs/history/0.2.0.md @@ -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:' 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 `` 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). diff --git a/public/css/screens/game.css b/public/css/screens/game.css index 642dc7be..09b26f62 100644 --- a/public/css/screens/game.css +++ b/public/css/screens/game.css @@ -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); } diff --git a/public/i18n/en.json b/public/i18n/en.json index 01b2db31..3779fccd 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -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", diff --git a/public/js/battle/observe.js b/public/js/battle/observe.js index f18708bd..99ab8602 100644 --- a/public/js/battle/observe.js +++ b/public/js/battle/observe.js @@ -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:` 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 = []; diff --git a/public/js/render/app.js b/public/js/render/app.js index a1d4ad3c..2a3237e3 100644 --- a/public/js/render/app.js +++ b/public/js/render/app.js @@ -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:' 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:' 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. diff --git a/public/js/screens/diy.js b/public/js/screens/diy.js index 00c8a7aa..59c35ef2 100644 --- a/public/js/screens/diy.js +++ b/public/js/screens/diy.js @@ -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``; } -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 }) { `; } 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`
<${Img} src=${moduleIconOf(m, rec)} class="lo-minfo__icon" />${rec.typeName || ''}${rec.name || rec.uniEquipId} @@ -219,7 +220,8 @@ function ModuleInfo({ m, golden, opt }) { ${t('属性')} ${rows.length ? rows.map((r) => html`${r.label}${r.text}`) : html`${t('无属性加成')}`}
- ${traitText ? html`
${t('特性')}<${RichText} class="lo-minfo__v" text=${traitText} />
` : null} + ${lines.base ? html`
${t('特性')}<${RichText} class="lo-minfo__v" text=${lines.base} />
` : null} + ${lines.added ? html`
${t('特性追加')}<${RichText} class="lo-minfo__v" text=${lines.added} />
` : null} ${talents.map((tal, i) => html`
${t('天赋')} ${tal.name ? html`${tal.name}` : null}<${RichText} text=${tal.descRaw || tal.desc || ''} />
`)}
`; diff --git a/public/js/ui/detailPanel.js b/public/js/ui/detailPanel.js index 18b826ef..32b887b2 100644 --- a/public/js/ui/detailPanel.js +++ b/public/js/ui/detailPanel.js @@ -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); } /** diff --git a/public/js/ui/gameActions.js b/public/js/ui/gameActions.js index 08205f51..fc7ae50f 100644 --- a/public/js/ui/gameActions.js +++ b/public/js/ui/gameActions.js @@ -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' }), diff --git a/public/js/ui/gameLogic.js b/public/js/ui/gameLogic.js index d95b47a4..7f7794b7 100644 --- a/public/js/ui/gameLogic.js +++ b/public/js/ui/gameLogic.js @@ -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'; diff --git a/public/js/ui/gameLogic/camera.js b/public/js/ui/gameLogic/camera.js index 1d0d7f11..ce0a59ac 100644 --- a/public/js/ui/gameLogic/camera.js +++ b/public/js/ui/gameLogic/camera.js @@ -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} + */ +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 diff --git a/public/js/ui/loadoutModel.js b/public/js/ui/loadoutModel.js index ab6b1ab0..1981a711 100644 --- a/public/js/ui/loadoutModel.js +++ b/public/js/ui/loadoutModel.js @@ -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 | null | undefined} attr diff --git a/public/js/ui/teamPanel.js b/public/js/ui/teamPanel.js index 8ef00f23..54c0a396 100644 --- a/public/js/ui/teamPanel.js +++ b/public/js/ui/teamPanel.js @@ -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`
-