feedback5 R19C: the official 「作战结束」 result box and the three battle-over sounds at settlement (GitHub #235; PR #112 by @Convey123, approved and never merged, ported by hand onto the 0.2.0 modules) — the box says only the viewer's own LP, 「全员无伤!」 or 「生命值减少 −N」; after a 联防 the number is the server's own per-player charge (m.public.uniteResult in the SETTLE view) and 「全员无伤!」 only when nothing got through; no box for spectator seats, eliminated players or the boss rounds; golden: unchanged

This commit is contained in:
sganggs committed 2026-10-07 11:21:24 +08:00
1 parent 0b306baa49
commit f661a47773
21 files changed
+706 -14

No files matched your search

+1
View File
@@ -82,6 +82,7 @@
- 持续的范围特效(如炼金单元)叠在一起时不再越叠越亮(社区反馈)。 - 持续的范围特效(如炼金单元)叠在一起时不再越叠越亮(社区反馈)。
- 结算卡不再显示调和、协防干员、独行、绝技的层数:这几个盟约照常累计层数,官方也不显示(社区反馈)。 - 结算卡不再显示调和、协防干员、独行、绝技的层数:这几个盟约照常累计层数,官方也不显示(社区反馈)。
- 银灰的普通攻击使用自己的命中音效,不再借用三技能的挥砍和命中音效;雷蛇、初雪、圣约送葬人的普通攻击也不再用技能的命中音效(社区反馈)。 - 银灰的普通攻击使用自己的命中音效,不再借用三技能的挥砍和命中音效;雷蛇、初雪、圣约送葬人的普通攻击也不再用技能的命中音效(社区反馈)。
- 每回合结算时跳出官方的「作战结束」框,只写自己这一回合被扣的目标生命值:没扣是「全员无伤!」,扣了是「生命值减少 −N」,并播放官方的三种战斗结束音效之一。联防回合的数字按服务器实际扣的点数;联防有敌人漏过去时不显示「全员无伤!」,没被扣的人只看到标题。最终攻势、隐秘核心,以及已淘汰的玩家和观战者不弹框(GitHub #235,PR #112,感谢 @Convey123)。
### 联机与大厅 ### 联机与大厅
+2 -1
View File
@@ -23,7 +23,7 @@
- **特殊地形点一下就有说明**:战场上的**活性源石**、**沼泽**、**排气格栅**、**深水区**,以及**红门 / 蓝门 / 传送点**,点一下就会弹出它自己的说明卡——名称、机制,以及**这一关自己的数值**(比如战场#04 的活性源石:每秒 70 点真实伤害、攻击力 +20%、攻击速度 +20、持续 300 秒)。休整期和作战期都能点。普通地板 / 道路 / 高台点了不会弹东西,所以点一下仍然只是「取消选中、关掉卡片」。 - **特殊地形点一下就有说明**:战场上的**活性源石**、**沼泽**、**排气格栅**、**深水区**,以及**红门 / 蓝门 / 传送点**,点一下就会弹出它自己的说明卡——名称、机制,以及**这一关自己的数值**(比如战场#04 的活性源石:每秒 70 点真实伤害、攻击力 +20%、攻击速度 +20、持续 300 秒)。休整期和作战期都能点。普通地板 / 道路 / 高台点了不会弹东西,所以点一下仍然只是「取消选中、关掉卡片」。
| 作战 | 每人在自己的战场各自作战(战斗在你的浏览器里模拟,2 倍速)。干员按**逐列从左到右、每列从上到下**的顺序(官方「从上到下>从左到右」:最左一列最先)自动部署并消耗部署费用(初始 10,+1/秒)。时限 45–115 秒(实际秒数,即屏幕上的倒计时),时间到仍存活的敌人算漏怪。有敌人进入蓝门时,顶栏的目标生命值会**立即**显示扣除后的数值(红字 + 「−N」,每回合至多 10 点),结算时确定;联防阶段标注「联防中」,队友拦下的部分最后不扣。左侧队伍栏同样显示队友的待扣生命值。被击倒的干员**留在倒下的格子上**(倒地姿势、略微变灰),之后也在这一格再部署(与官方一样;倒在其他干员或召唤物初始位置上的,会回到自己的初始位置躺下,召唤物已经消失也一样;唯一的例外是乌尔比安:三技能把他移到船锚处期间被击倒时,他回到自己的部署位躺下、再部署——这是项目维护者 2026-10-07 应社区反馈有意做出的改动,与官方「原地倒下」不同);因自身效果强制退场的干员(史尔特尔「余烬」结束、耀骑士临光 2 技能结束等)同样倒在原地、在原地再部署;倒地干员所在的格子**不会被占用**,突袭盟约的再部署、援军、召唤物都会避开它。头上的圆圈是再部署倒计时:绿色圆环 + 秒数;变成黄色「DP」表示部署费用不够。血条下方的一行是**元素损伤**(与原版一致:元素图标 + 白条):干员为带图标的彩色圆盘,敌人为小号纯色圆盘,右侧白条是剩余元素值,像血条一样从右往左减少,白条耗尽的那一下就是爆条(被大额元素损伤一次打掉一截时白条也会一次少一截,例如掠海漂移体每击约为 25%–50%);爆条后进入爆发冷却(灼燃 / 神经 / 侵蚀 10 秒,敌方侵蚀 8 秒,凋亡 15 秒),这时白条变成**元素颜色**(神经青绿、侵蚀蓝、灼燃橙红、凋亡灰紫)并在冷却期间慢慢回满、图标闪烁——彩色条表示已经爆条、正在承受爆发效果,不是还剩的元素值;期间所有元素都不会累积也无法被治疗,结束后清零。元素爆发造成的是**无来源伤害**,不吃干员的增伤、穿透和装备加成(例如深巡打海嗣时灼燃爆发固定 7000),击杀仍记在爆条的干员名下;元素脆弱只提高元素伤害,不加快元素条;脆弱不影响元素伤害。已被击杀的敌人不会再被附加元素损伤。 | | 作战 | 每人在自己的战场各自作战(战斗在你的浏览器里模拟,2 倍速)。干员按**逐列从左到右、每列从上到下**的顺序(官方「从上到下>从左到右」:最左一列最先)自动部署并消耗部署费用(初始 10,+1/秒)。时限 45–115 秒(实际秒数,即屏幕上的倒计时),时间到仍存活的敌人算漏怪。有敌人进入蓝门时,顶栏的目标生命值会**立即**显示扣除后的数值(红字 + 「−N」,每回合至多 10 点),结算时确定;联防阶段标注「联防中」,队友拦下的部分最后不扣。左侧队伍栏同样显示队友的待扣生命值。被击倒的干员**留在倒下的格子上**(倒地姿势、略微变灰),之后也在这一格再部署(与官方一样;倒在其他干员或召唤物初始位置上的,会回到自己的初始位置躺下,召唤物已经消失也一样;唯一的例外是乌尔比安:三技能把他移到船锚处期间被击倒时,他回到自己的部署位躺下、再部署——这是项目维护者 2026-10-07 应社区反馈有意做出的改动,与官方「原地倒下」不同);因自身效果强制退场的干员(史尔特尔「余烬」结束、耀骑士临光 2 技能结束等)同样倒在原地、在原地再部署;倒地干员所在的格子**不会被占用**,突袭盟约的再部署、援军、召唤物都会避开它。头上的圆圈是再部署倒计时:绿色圆环 + 秒数;变成黄色「DP」表示部署费用不够。血条下方的一行是**元素损伤**(与原版一致:元素图标 + 白条):干员为带图标的彩色圆盘,敌人为小号纯色圆盘,右侧白条是剩余元素值,像血条一样从右往左减少,白条耗尽的那一下就是爆条(被大额元素损伤一次打掉一截时白条也会一次少一截,例如掠海漂移体每击约为 25%–50%);爆条后进入爆发冷却(灼燃 / 神经 / 侵蚀 10 秒,敌方侵蚀 8 秒,凋亡 15 秒),这时白条变成**元素颜色**(神经青绿、侵蚀蓝、灼燃橙红、凋亡灰紫)并在冷却期间慢慢回满、图标闪烁——彩色条表示已经爆条、正在承受爆发效果,不是还剩的元素值;期间所有元素都不会累积也无法被治疗,结束后清零。元素爆发造成的是**无来源伤害**,不吃干员的增伤、穿透和装备加成(例如深巡打海嗣时灼燃爆发固定 7000),击杀仍记在爆条的干员名下;元素脆弱只提高元素伤害,不加快元素条;脆弱不影响元素伤害。已被击杀的敌人不会再被附加元素损伤。 |
| 联防 | 见第 5 节。 | | 联防 | 见第 5 节。 |
| 结算 | 扣 LP、发放悬赏资金、结算层数;LP ≤ 0 的玩家淘汰(其干员回到公共卡池)。 | | 结算 | 扣 LP、发放悬赏资金、结算层数;LP ≤ 0 的玩家淘汰(其干员回到公共卡池)。结算开始时屏幕正中跳出官方的「**作战结束**」框(约 3 秒后自己消失,不挡操作),下面一行只写**你自己**这一回合被扣的目标生命值:没扣是「**全员无伤!**」,扣了是「**生命值减少 −N**」;同时播官方的战斗结束音效(扣了、联防里没扣、普通回合没扣各一种)。联防回合见第 5 节。最终攻势和隐秘核心没有这一框(之后直接是结算称号或隐秘核心);已淘汰的玩家和观战者不弹框。 |
**断线与离开**:同盟模拟断线后 10 分钟内重新打开页面即可回到原座位;期间你的阵容照常自动作战,机变和休整期到时自动选择 / 准备,但不会替你购买(想让 AI 代打,请在「离开模拟」里选「暂离(AI 托管)」;AI 托管不会为了腾地方销毁你的道具,整备区满时它会卖掉备战的干员来放道具)。独立模拟的休整期与机变不计时,断线或关掉页面后 **24 小时内**用同一个浏览器打开即可继续;独立模拟的作战(含最终攻势 / 隐秘核心)还可以点顶栏的「暂停」(或按 Space)随时暂停,倒计时和领袖战的加时扣血一起停下(暂停中断线会自动继续,由服务器接着打完这场作战)。同盟模拟的作战不能暂停。「放弃模拟」:独立模拟立即结束本局;同盟模拟中视为你被淘汰(干员回到公共卡池),队友继续。服务器重启会结束所有对局。 **断线与离开**:同盟模拟断线后 10 分钟内重新打开页面即可回到原座位;期间你的阵容照常自动作战,机变和休整期到时自动选择 / 准备,但不会替你购买(想让 AI 代打,请在「离开模拟」里选「暂离(AI 托管)」;AI 托管不会为了腾地方销毁你的道具,整备区满时它会卖掉备战的干员来放道具)。独立模拟的休整期与机变不计时,断线或关掉页面后 **24 小时内**用同一个浏览器打开即可继续;独立模拟的作战(含最终攻势 / 隐秘核心)还可以点顶栏的「暂停」(或按 Space)随时暂停,倒计时和领袖战的加时扣血一起停下(暂停中断线会自动继续,由服务器接着打完这场作战)。同盟模拟的作战不能暂停。「放弃模拟」:独立模拟立即结束本局;同盟模拟中视为你被淘汰(干员回到公共卡池),队友继续。服务器重启会结束所有对局。
@@ -93,6 +93,7 @@
- 联防在官方的**联防专用地图**上进行(与官方相同,不是本回合的战场):一整片道路,没有水池、箱子、装置等地形;干员按休整期的位置站好。一人协防时敌人从中间的入口进入左半场;两人协防时敌人从最右侧进入,先经过右半场,再穿过中间进入左半场。 - 联防在官方的**联防专用地图**上进行(与官方相同,不是本回合的战场):一整片道路,没有水池、箱子、装置等地形;干员按休整期的位置站好。一人协防时敌人从中间的入口进入左半场;两人协防时敌人从最右侧进入,先经过右半场,再穿过中间进入左半场。
- 只有完美作战的队友能参与联防;联防中**盟约不会叠加层数**。 - 只有完美作战的队友能参与联防;联防中**盟约不会叠加层数**。
- 联防的结果**只在结算的「作战结束」框里报**(与官方一样,没有「联防成功 / 联防失败」之类的字幕)。框里的数字是服务器结算时**真正扣到你头上**的点数:漏怪的玩家按联防后还剩下的敌人扣,不是自己那场漏了几只。**「全员无伤!」只在联防一只都没放过去时出现**(这一轮谁都没被扣);联防没守住、而你自己没被扣(你是去帮忙的队友,或你漏的敌人都被拦下了),框里只有「作战结束」这个标题——掉血的队友并不是无伤,官方对话框里也没有别的句子。框的颜色:青色=联防全部拦下,橙色=联防没守住但你没掉血,红色=你被扣了。漏了几只仍然看顶栏的橙色「×N」。
- 联防中击杀悬赏敌人,资金归击杀者;没有干员击倒它时(无来源伤害、它自己扣血、其他敌人的伤害等),资金归它倒下处所在半场的队友。悬赏敌人在自己的战场上被地形(如活性源石)烫死,资金照样归你。 - 联防中击杀悬赏敌人,资金归击杀者;没有干员击倒它时(无来源伤害、它自己扣血、其他敌人的伤害等),资金归它倒下处所在半场的队友。悬赏敌人在自己的战场上被地形(如活性源石)烫死,资金照样归你。
- 联防后仍然存活的敌人,按原主人扣 LP;没人完美作战时直接扣。 - 联防后仍然存活的敌人,按原主人扣 LP;没人完美作战时直接扣。
- 漏怪的玩家在顶栏中间(击倒数旁)看到橙色的「×N」标签:你漏过、仍在联防战场上的敌人数(含还没出场的,**不设上限**),队友每击倒一个就减一(会分裂或召唤的敌人被击倒时,分裂出的敌人也算你的,数字会相应增加);目标生命值同时显示为扣除 min(N, 10) 后的数值。左侧队伍栏里漏怪队友的行也显示 ×N(与战场上的击倒同步更新;鼠标悬停可看到是谁漏过的敌人)。 - 漏怪的玩家在顶栏中间(击倒数旁)看到橙色的「×N」标签:你漏过、仍在联防战场上的敌人数(含还没出场的,**不设上限**),队友每击倒一个就减一(会分裂或召唤的敌人被击倒时,分裂出的敌人也算你的,数字会相应增加);目标生命值同时显示为扣除 min(N, 10) 后的数值。左侧队伍栏里漏怪队友的行也显示 ×N(与战场上的击倒同步更新;鼠标悬停可看到是谁漏过的敌人)。
+1 -1
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. 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 (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). 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; the round's result box at SETTLE (the official 「作战结束」 + 「全员无伤!」 / 「生命值减少 −N」 for the viewer's own LP — after a 联防 the server's charge, `m.public.uniteResult.losses` — with the matching BATTLEOVER sound; none in the boss rounds or for a viewer not in the round; GitHub #235, PR #112); 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 不触发准备或暂停。 盟约栏支持「收起/展开盟约」(GitHub #142):收起时隐藏完整列表并关闭由列表打开的浮层,让原图标区域的指针事件到达棋盘,只保留至少 44×44 CSS px 的恢复按钮。折叠状态在本局阶段变化、观看队友时保留;展开后仍按当前观看对象显示最新盟约。按钮支持 Enter / Space,聚焦按钮时 Space 不触发准备或暂停。
+2 -1
View File
@@ -24,7 +24,8 @@ LOBBY(room) → INFO_CHECK (co-op 25 s; solo and any single-human match untimed
the layers their own combat reached (the round's pending gains, PRTS "以其阵地当前的状态" [ASSUMED]); the layers their own combat reached (the round's pending gains, PRTS "以其阵地当前的状态" [ASSUMED]);
a helper's operator knocked out at the end of its own combat enters down — carryState { down: true }, a helper's operator knocked out at the end of its own combat enters down — carryState { down: true },
PRTS "上一阶段为退场状态的干员强制退场", its full redeploy timer — confirmed by the user (§19.3)) PRTS "上一阶段为退场状态的干员强制退场", its full redeploy timer — confirmed by the user (§19.3))
SETTLE (LP −min(leaks,10) per player (unite survivors to their source); eliminations; layer gains applied; coins; broadcast) SETTLE (LP −min(leaks,10) per player (unite survivors to their source); eliminations; layer gains applied; coins; broadcast;
after a 联防 the outcome and every player's charge ride the SETTLE m.public as `uniteResult`, GitHub #235)
R14 FINAL_ASSAULT (merged team LP; pairs by seat; shared boss pool; 120 real s countdown, no hard stop; overtime −1 team LP per R14 FINAL_ASSAULT (merged team LP; pairs by seat; shared boss pool; 120 real s countdown, no hard stop; overtime −1 team LP per
real s after 150 real s (§4, m.public.overtimeAt); no layer gains; team LP 0 ⇒ defeat at once (PRTS 直接失败), real s after 150 real s (§4, m.public.overtimeAt); no layer gains; team LP 0 ⇒ defeat at once (PRTS 直接失败),
pool 0 ⇒ victory — the first end the server registers decides, §20.5) pool 0 ⇒ victory — the first end the server registers decides, §20.5)
+1 -1
View File
@@ -17,7 +17,7 @@ Platform additions (implemented): `welcome.{resumed, version}`, `welcome.diyKitt
### 8.2 Match ### 8.2 Match
C→S (all carry `rid`): `g.infoReady` · `g.band {bandId}` · `g.bandSkip` · `g.bandFocus {bandId?}` (the strategy highlighted in the draft screen — what a timed-out turn takes; absent / null clears it; `WRONG_PHASE` outside BAND_DRAFT, `ALREADY` after the pick, `BAD_TARGET` for a band not allowed; §18.2) · `g.buy {slot}` · `g.refresh` · `g.freeze` · `g.levelUp` · `g.sell {uid}` · `g.move {uid, to:{area:'board', row, col}|{area:'hand', idx}, dir?}` · `g.equip {itemUid, targetUid, replaceUid?}` (`replaceUid`: which equipped item a third one replaces, §6.2) · `g.art {itemUid, row, col, dir?}` (`dir` ∈ UP|RIGHT|DOWN|LEFT from the deploy wheel, §3; absent ⇒ `to.dir`, then RIGHT; `g.move` onto the piece's own tile re-orients it; board pieces in `m.private` / `m.field {prep:true}` carry `dir`) · `g.destroy {uid}` (hand / temp items only; equipped items are locked, §6.2) · `g.reward {idx}` · `g.choice {idx}` · `g.ready {ready}` · `g.emote {id}` · `g.watch {fieldId, playerId?}` (`playerId`: the player tapped — a shared 联防 / boss pair field shows two; what an eliminated viewer follows, §25) · `g.autoplay {on}` · `g.pause {on}` (solo battles only, §14 Solo pause) · `g.unitStats {seq?}` (ROUND_START / SP_DRAFT / PREP, else `WRONG_PHASE`; eliminated: `ELIMINATED`) → push `m.unitStats` (§18.5) · `g.leave` C→S (all carry `rid`): `g.infoReady` · `g.band {bandId}` · `g.bandSkip` · `g.bandFocus {bandId?}` (the strategy highlighted in the draft screen — what a timed-out turn takes; absent / null clears it; `WRONG_PHASE` outside BAND_DRAFT, `ALREADY` after the pick, `BAD_TARGET` for a band not allowed; §18.2) · `g.buy {slot}` · `g.refresh` · `g.freeze` · `g.levelUp` · `g.sell {uid}` · `g.move {uid, to:{area:'board', row, col}|{area:'hand', idx}, dir?}` · `g.equip {itemUid, targetUid, replaceUid?}` (`replaceUid`: which equipped item a third one replaces, §6.2) · `g.art {itemUid, row, col, dir?}` (`dir` ∈ UP|RIGHT|DOWN|LEFT from the deploy wheel, §3; absent ⇒ `to.dir`, then RIGHT; `g.move` onto the piece's own tile re-orients it; board pieces in `m.private` / `m.field {prep:true}` carry `dir`) · `g.destroy {uid}` (hand / temp items only; equipped items are locked, §6.2) · `g.reward {idx}` · `g.choice {idx}` · `g.ready {ready}` · `g.emote {id}` · `g.watch {fieldId, playerId?}` (`playerId`: the player tapped — a shared 联防 / boss pair field shows two; what an eliminated viewer follows, §25) · `g.autoplay {on}` · `g.pause {on}` (solo battles only, §14 Solo pause) · `g.unitStats {seq?}` (ROUND_START / SP_DRAFT / PREP, else `WRONG_PHASE`; eliminated: `ELIMINATED`) → push `m.unitStats` (§18.5) · `g.leave`
S→C: 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.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.public.uniteResult` — SETTLE after a 联防 only, its outcome as data: `{ through /* the leakers' enemies that still got through, uncapped */, helpers:[playerId...], leakers:[playerId...], losses:{playerId: the LP settlement charged this round — every alive player} }`; absent when no 联防 resolved (the client's result box then reads the round's own battle; GitHub #235, PR #112)
- `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.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 - `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). - NOTE: game time in `b.snap`/`b.ev` is carried as **`gt`** (the frame's `t` is the message type).
+34
View File
@@ -1856,3 +1856,37 @@ are.
intents too (+3 layers, ATK, block). Two NORMAL copies of one item merge (the official rule), so two 空弦 need a normal intents too (+3 layers, ATK, block). Two NORMAL copies of one item merge (the official rule), so two 空弦 need a normal
and an 进阶 set; and the pair counts once toward the 3 members. and an 进阶 set; and the pair counts once toward the 3 members.
- Pinned: `test/content/feedback5-cb1-pins.test.js`. - Pinned: `test/content/feedback5-cb1-pins.test.js`.
#### 25.22.4 「提问:联防结算播报没了」 (GitHub #235; PR #112 by @Convey123, approved, never merged): the official 「作战结束」 box and the three BATTLEOVER sounds at settlement — `match/settle.js`, `match/views.js`, `ui/gameLogic/phases.js`, `ui/components.js ResultDialog`, `screens/game.js`
- **Seen**: 0.1.4 and 0.2.0 have no result box and never play the battle-over sounds. A co-op 联防 round run to SETTLE
through the match harness: the SETTLE m.public carries nothing about the 联防; `public/js` names none of the three
sounds, though `data/assets.json` `audio.sfx.ui` lists all three and they are on disk (so the full zip, which packs
every file the manifest lists, already carries them). PR #112 targeted 0.1.4's layout; the owner approved it on
2026-10-05 once the remake's own 「联防成功 / 联防失败:还有 N 只突破防线」 line went (the author removed it, then made
「全员无伤!」 true-only).
- **Official**: research 09 §3.1 "Round result dialog": 「作战结束」 + 「全员无伤!」, or 「生命值减少」 and the LP lost; the
client's audio data names `ON_ACT1AUTOCHESS_BATTLEOVER_REDUCE / _NOREDUCE / _NORMAL`, not when each plays. No EN client
text for the three lines in `data/i18n/en.json`: plain translations (Battle over — the existing msgid —, All unharmed!,
LP reduced by {n}).
- **Now** (ported by hand onto the 0.2.0 modules, every line reviewed): `settle()` keeps `uniteResultView` — `through`
(the leakers' enemies that still got through), helpers, leakers and `losses` (every alive player's own charge, the very
`loss` deducted) — which the SETTLE m.public carries as `uniteResult` (cleared at the round start; design/network.md
§8.2). At SETTLE (the moment is [ASSUMED] — the sources do not time the dialog, and a leaker's number exists only
after the 联防) the screen pops `ResultDialog` for 2.8 s (inside SETTLE's 3 s; click-through, under the phase banner):
「作战结束」 and the viewer's own line — after a 联防 from `losses`: charged → 「生命值减少 −N」 (red); nothing got through
→ 「全员无伤!」 (mint, true for everyone); the 联防 leaked but the viewer paid nothing (a helper, a spared leaker) → the
title alone (orange); without a 联防, the own battle's `min(cap, leaks)` as kept while it ran (`roundLossRef`, the
live LP's own inputs). Sound: Reduce when charged, NoReduce when a 联防 ran and the viewer paid 0, Normal otherwise
[ASSUMED mapping, PR #112's; the owner called the three right]. No ticker line for the outcome.
- **Who sees it**: solo — every normal round (no 联防 there) [ASSUMED: the same dialog]; bots — nothing to show, listed
in `losses`; a spectator seat or a player eliminated earlier — no box and no sound in any round, where PR #112's last
commit gave them the title alone after a 联防 although its own rule gave them nothing in an ordinary round (and no sound
either) [ASSUMED]; 最终攻势 / 隐秘核心 — no SETTLE phase (`_finishFinal` → the result screen or the Hidden Core round)
and the merged team LP has no per-player loss, so no box [ASSUMED]; a reconnect landing on SETTLE — the 联防 box (the
server's figure) without sound, nothing in an ordinary round.
- Left out of the PR: ResultDialog's unused `icon` prop and gold / ice tones. Golden: unchanged (283 scenarios). Tests:
`test/ui/gameLogic.test.js` (5 new: sounds, loss, words in zh and en, 联防 cases, no-联防 box; the file does not load on
the parent), `test/match/playtest6-matchflow.test.js` #235 (2; the 联防 one fails on the parent: losses = the LP
settled, no result ticker, gone in the next phase), SP_E2E `test/ui/resultbox.e2e.test.js` (3, mock variants
`unite[,through][,helper|,dead]`). Docs: PLAYING.md §1 / §5, design/network.md, design/client.md, design/match.md.
+111
View File
@@ -730,6 +730,117 @@
@keyframes pb-wipe { from { clip-path: inset(0 100% 0 0); } to { clip-path: inset(0 0 0 0); } } @keyframes pb-wipe { from { clip-path: inset(0 100% 0 0); } to { clip-path: inset(0 0 0 0); } }
@keyframes pb-sweep { 0% { left: -30%; opacity: 1; } 100% { left: 120%; opacity: 0; } } @keyframes pb-sweep { 0% { left: -30%; opacity: 1; } 100% { left: 120%; opacity: 0; } }
/* ---- result box (ResultDialog: the round's 作战结束 dialog; GitHub #235, PR #112) ---------------------------------- */
/* A centred framed plate in the tone's colour — four corner ticks, chevrons either side of the title, one light sweep.
Under the phase banner (z-index) so a phase change and a result never fight over the same pixels; pointer-events:
none — the settlement stays clickable. */
.rdialog {
--rd: var(--mint-500);
--rd-glow: rgba(23, 249, 183, .35);
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
z-index: calc(var(--z-banner) - 1);
pointer-events: none;
}
.rdialog--orange { --rd: var(--orange-hi); --rd-glow: rgba(255, 122, 51, .38); }
.rdialog--red { --rd: var(--red-premium); --rd-glow: rgba(255, 84, 84, .38); }
.rdialog__box {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: .1rem;
min-width: 5.6rem;
max-width: 88vw;
padding: .34rem .5rem .3rem;
background:
repeating-linear-gradient(0deg, rgba(255, 255, 255, .022) 0 1px, transparent 1px 3px),
linear-gradient(180deg, rgba(9, 13, 12, .96), rgba(6, 9, 8, .97));
border: 1px solid var(--rd);
box-shadow: 0 0 .5rem var(--rd-glow), inset 0 0 .7rem rgba(0, 0, 0, .8), 0 .1rem .5rem rgba(0, 0, 0, .6);
overflow: hidden;
animation: rd-open 320ms var(--ease-out) backwards;
}
.rdialog__box::before, .rdialog__box::after {
/* hairlines inside the frame, so the plate reads as a plate and not a card */
content: '';
position: absolute;
left: 6%;
right: 6%;
height: 1px;
background: linear-gradient(90deg, transparent, var(--rd) 20%, var(--rd) 80%, transparent);
opacity: .8;
}
.rdialog__box::before { top: 4px; }
.rdialog__box::after { bottom: 4px; }
.rdialog__tick { position: absolute; width: .16rem; height: .16rem; border: 2px solid var(--rd); opacity: .9; }
.rdialog__tick--tl { top: 8px; left: 8px; border-right: 0; border-bottom: 0; }
.rdialog__tick--tr { top: 8px; right: 8px; border-left: 0; border-bottom: 0; }
.rdialog__tick--bl { bottom: 8px; left: 8px; border-right: 0; border-top: 0; }
.rdialog__tick--br { bottom: 8px; right: 8px; border-left: 0; border-top: 0; }
.rdialog__micro {
font-family: var(--font-display);
font-size: .13rem;
letter-spacing: .48em;
padding-left: .48em;
color: var(--rd);
animation: rd-wipe 300ms 60ms var(--ease-out) backwards;
}
.rdialog__row {
position: relative;
display: flex;
align-items: center;
justify-content: center;
gap: .22rem;
animation: rd-wipe 340ms 110ms var(--ease-out) backwards;
}
.rdialog__row::after {
/* one-shot light sweep across the title */
content: '';
position: absolute;
top: -30%;
bottom: -30%;
left: -25%;
width: 18%;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, .26), transparent);
transform: skewX(-20deg);
opacity: 0;
animation: rd-sweep 780ms 300ms var(--ease-out) forwards;
}
.rdialog__title {
font-size: .62rem;
font-weight: 900;
letter-spacing: .14em;
padding-left: .14em;
line-height: 1.05;
color: #fff;
text-shadow: 0 0 .26rem var(--rd-glow), 0 2px 0 rgba(0, 0, 0, .55);
}
.rdialog__chev { --ch-h: .46rem; height: .46rem; flex: none; }
.rdialog__sub {
font-size: .18rem;
letter-spacing: .1em;
color: var(--text-lo);
text-align: center;
animation: rd-wipe 340ms 180ms var(--ease-out) backwards;
}
.rdialog.is-leaving .rdialog__box { animation: rd-close 260ms var(--ease-in-out) forwards; }
@keyframes rd-open { from { transform: scale(.9, .78); opacity: 0; } }
@keyframes rd-close { to { transform: scale(.96, .86); opacity: 0; } }
@keyframes rd-wipe { from { clip-path: inset(0 100% 0 0); } to { clip-path: inset(0 0 0 0); } }
@keyframes rd-sweep { 0% { left: -25%; opacity: 1; } 100% { left: 115%; opacity: 0; } }
@media (max-width: 720px) {
.rdialog__box { min-width: 0; width: 92vw; padding: .26rem .3rem .22rem; }
.rdialog__title { font-size: .42rem; }
.rdialog__chev { --ch-h: .34rem; height: .34rem; }
}
@media (prefers-reduced-motion: reduce) {
.rdialog__box, .rdialog__row, .rdialog__row::after, .rdialog__micro, .rdialog__sub { animation: none; }
}
/* ---- ping pill ------------------------------------------------------------------------------------- */ /* ---- ping pill ------------------------------------------------------------------------------------- */
.ping { .ping {
--pc: var(--mint-400); --pc: var(--mint-400);
+1
View File
@@ -6,6 +6,7 @@
URL params: ?phase=PREP|INFO_CHECK|BAND_DRAFT|BATTLE_CHECK|SP_DRAFT|COMBAT|UNITE|SETTLE|FINAL_ASSAULT|HIDDEN_CORE|RESULT URL params: ?phase=PREP|INFO_CHECK|BAND_DRAFT|BATTLE_CHECK|SP_DRAFT|COMBAT|UNITE|SETTLE|FINAL_ASSAULT|HIDDEN_CORE|RESULT
&variant=reward|bounty|supply|shop|tactic|temp|frozen|dead|defeat|solo|popup|detail|drawer|info|pen|emote|settings|collapsed|bond|drag &variant=reward|bounty|supply|shop|tactic|temp|frozen|dead|defeat|solo|popup|detail|drawer|info|pen|emote|settings|collapsed|bond|drag
|leaker (联防: you let enemies through — the ×N counter) |leaker (联防: you let enemies through — the ×N counter)
|unite[,through][,helper|,dead] (SETTLE: the result box after a 联防 — your charge from m.public.uniteResult)
|overtime|drain (boss rounds: level time over / overtime drain) |paused (solo battles) |overtime|drain (boss rounds: level time over / overtime drain) |paused (solo battles)
|funny (标准: 本局禁用 bonds) |morph (变形同构体 in the hand / shop, a wearer on the board) |harmony (缪尔赛思 on the board) |funny (标准: 本局禁用 bonds) |morph (变形同构体 in the hand / shop, a wearer on the board) |harmony (缪尔赛思 on the board)
&shot=1 (hide the switcher) &render=engine|fallback (default: the Pixi engine, DOM fallback if it fails) &shot=1 (hide the switcher) &render=engine|fallback (default: the Pixi engine, DOM fallback if it fails)
+14
View File
@@ -394,6 +394,20 @@ function startCombat(phase) {
pub.unite = leakMock ? { helpers: ['ai_2', 'p3'], leakers: ['p1', 'p4'] } : { helpers: ['p1', 'ai_2'], leakers: ['p3'] }; pub.unite = leakMock ? { helpers: ['ai_2', 'p3'], leakers: ['p1', 'p4'] } : { helpers: ['p1', 'ai_2'], leakers: ['p3'] };
if (leakMock) { leftOf('p1', 13); leftOf('p4', 3); pub.players[0].status = 'done'; pub.players[2].status = 'helping'; } else leftOf('p3', 12); if (leakMock) { leftOf('p1', 13); leftOf('p4', 3); pub.players[0].status = 'done'; pub.players[2].status = 'helping'; } else leftOf('p3', 12);
} }
// the result box after a 联防 (server settle → m.public.uniteResult, ui/gameLogic/phases.js uniteResultBox; GitHub #235):
// ?phase=SETTLE&variant=unite — you (p1) leaked and the helpers stopped every enemy: nobody paid → 全员无伤!;
// `unite,through` — 3 got through and you were charged 3 → 生命值减少 −3; `unite,through,helper` — you HELPED, a
// teammate leaked and paid → the official title alone (全员无伤! would be false for that teammate); `unite,dead` — you
// were eliminated before the round, so `losses` (alive players only, like the server) does not list you → no box.
// Without `unite` the SETTLE view has no uniteResult: the round's own battle result box takes over (battleResultBox).
if (phase === PHASE.SETTLE && VARIANTS.has('unite')) {
const helpers = ['ai_2', 'p3'], leakers = ['p1', 'p4'];
pub.uniteResult = VARIANTS.has('through')
? { through: 3, helpers, leakers, losses: { p1: 3, p4: 0, ai_2: 0, p3: 0 } }
: { through: 0, helpers, leakers, losses: { p1: 0, p4: 0, ai_2: 0, p3: 0 } };
if (VARIANTS.has('helper')) pub.uniteResult = { through: 3, helpers: ['p1', 'ai_2'], leakers: ['p3'], losses: { p1: 0, ai_2: 0, p3: 4, p4: 0 } };
if (VARIANTS.has('dead')) delete pub.uniteResult.losses.p1;
}
const countKill = () => { const countKill = () => {
if (phase !== PHASE.UNITE) return; if (phase !== PHASE.UNITE) return;
const pid = leakMock ? 'p1' : 'p3'; const pid = leakMock ? 'p1' : 'p3';
+2
View File
@@ -394,6 +394,8 @@
"队友作战进度": "Teammate battle progress", "队友作战进度": "Teammate battle progress",
"作战结束": "Battle over", "作战结束": "Battle over",
"作战结束,等待队友完成作战": "Battle over. Waiting for teammates to finish", "作战结束,等待队友完成作战": "Battle over. Waiting for teammates to finish",
"全员无伤!": "All unharmed!",
"生命值减少 −{n}": "LP reduced by {n}",
"你已被淘汰,正在观战": "You have been eliminated. Spectating", "你已被淘汰,正在观战": "You have been eliminated. Spectating",
"上一个战场": "Previous battlefield", "上一个战场": "Previous battlefield",
"下一个战场": "Next battlefield", "下一个战场": "Next battlefield",
+39 -2
View File
@@ -6,7 +6,8 @@
// with the DOM HUD layered on top: top bar, bond strip + popup, team panel (click = g.watch), effects, // with the DOM HUD layered on top: top bar, bond strip + popup, team panel (click = g.watch), effects,
// shop bar (prep), merge-reward and 机变 overlays, detail panel, enemy / match-info drawer, combat HUD // shop bar (prep), merge-reward and 机变 overlays, detail panel, enemy / match-info drawer, combat HUD
// (DP, "作战结束,等待队友完成作战" + teammates' progress, observing pill, 联防 / 最终攻势 ‹ › camera halves), // (DP, "作战结束,等待队友完成作战" + teammates' progress, observing pill, 联防 / 最终攻势 ‹ › camera halves),
// phase banners, ticker, emote wheel + bubbles, // phase banners, the round's result box at SETTLE (官方「作战结束」 + 「全员无伤!」 / 「生命值减少 −N」 and the BATTLEOVER sound;
// GitHub #235, PR #112), ticker, emote wheel + bubbles,
// settings, exit flow. Drag & drop from the view: pieceDrop on a board tile (also the piece's own tile) opens the // settings, exit flow. Drag & drop from the view: pieceDrop on a board tile (also the piece's own tile) opens the
// direction wheel (ui/facingWheel.js, research 09 §1.2) and its release sends g.move {uid, to, dir} (g.art {…, dir} // direction wheel (ui/facingWheel.js, research 09 §1.2) and its release sends g.move {uid, to, dir} (g.art {…, dir}
// for 画卷); pieceDrop on a bench slot → g.move; items → g.equip (confirm when replacing) / g.art. Equipment // for 画卷); pieceDrop on a bench slot → g.move; items → g.equip (confirm when replacing) / g.art. Equipment
@@ -65,7 +66,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from '../../vendor/hooks.module.js'; import { useCallback, useEffect, useMemo, useRef, useState } from '../../vendor/hooks.module.js';
import { PHASE, GEO } from '../../../shared/constants.js'; import { PHASE, GEO } from '../../../shared/constants.js';
import { html, Spinner, PhaseBanner, Icon, Button, confirmDialog, closeAllDialogs, useTicker } from '../ui/components.js'; import { html, Spinner, PhaseBanner, ResultDialog, Icon, Button, confirmDialog, closeAllDialogs, useTicker } from '../ui/components.js';
import { useGameData, GIcon } from '../ui/gameComponents.js'; import { useGameData, GIcon } from '../ui/gameComponents.js';
import { useFieldView } from '../ui/fieldHost.js'; import { useFieldView } from '../ui/fieldHost.js';
import { TopBar, liveLp, ownLeaks, uniteRemaining, tempInfo, tempReadyReason } from '../ui/hud.js'; import { TopBar, liveLp, ownLeaks, uniteRemaining, tempInfo, tempReadyReason } from '../ui/hud.js';
@@ -93,6 +94,7 @@ import { pauseAvailable, isPaused, frozenNow } from '../ui/matchStatus.js';
import { pieceTile } from '../render/drag.js'; import { pieceTile } from '../render/drag.js';
import { import {
phaseMode, phaseBanner, isCombatPhase, showDeadPill, isBossPhase, placementContext, canPlace, boardTargets, dropIntent, phaseMode, phaseBanner, isCombatPhase, showDeadPill, isBossPhase, placementContext, canPlace, boardTargets, dropIntent,
battleOverSfx, uniteResultBox, battleResultBox,
snapHud, activeBubbles, shortcutFor, shortcutBlocked, closesOnFieldPress, phaseTotalSeconds, homeFieldId, ownFieldId, normalizeSp, sortedPlayers, snapHud, activeBubbles, shortcutFor, shortcutBlocked, closesOnFieldPress, phaseTotalSeconds, homeFieldId, ownFieldId, normalizeSp, sortedPlayers,
terrainInfo, terrainInfo,
countdownState, shopBlockReason, stageOverrides, effectiveStage, watchTarget, dropFailureReason, countdownState, shopBlockReason, stageOverrides, effectiveStage, watchTarget, dropFailureReason,
@@ -179,6 +181,7 @@ function MatchScreen() {
const [holdSeq, setHoldSeq] = useState(0); // bumped when a held piece is released (re-apply the prep state) const [holdSeq, setHoldSeq] = useState(0); // bumped when a held piece is released (re-apply the prep state)
const [hud, setHud] = useState(null); const [hud, setHud] = useState(null);
const [banner, setBanner] = useState(null); const [banner, setBanner] = useState(null);
const [resultBox, setResultBox] = useState(null); // the round's result box (ResultDialog), shown at SETTLE
const [readyBusy, setReadyBusy] = useState(false); const [readyBusy, setReadyBusy] = useState(false);
const [spBusy, setSpBusy] = useState(null); const [spBusy, setSpBusy] = useState(null);
const [layer, setLayer] = useState('ALL'); // 联防 / 最终攻势 camera: 'L' | 'ALL' | 'R' const [layer, setLayer] = useState('ALL'); // 联防 / 最终攻势 camera: 'L' | 'ALL' | 'R'
@@ -233,6 +236,23 @@ function MatchScreen() {
uniteLeft: leaker ? uniteRemaining(localLeft, meP?.uniteLeft) : null, uniteLeft: leaker ? uniteRemaining(localLeft, meP?.uniteLeft) : null,
}); });
lpBaseRef.current = liveLpNow.base; lpBaseRef.current = liveLpNow.base;
// what this round's battle cost me, kept while it runs for the result box and the 战斗结束 sound at SETTLE (GitHub
// #235, PR #112 by @Convey123): by then the settlement has landed (Match.flush sends m.private before the SETTLE
// m.public) and the live count reads 0. A per-round MAXIMUM — leaks only grow within a round, and a frame that lands
// after the settlement must not wipe a real count — and only for a player in the round (a spectator seat or an
// eliminated player fights nothing and gets no box, ui/gameLogic/phases.js battleResultBox). `unite`: a 联防 ran.
const roundLossRef = useRef(null);
if (isCombatPhase(phase) && alive) {
const prev = roundLossRef.current;
const same = !!prev && prev.round === pub?.round;
const leaks = ownLeaks(localLeaks, meP?.pendingLp);
roundLossRef.current = {
round: pub?.round,
leaks: same ? Math.max(prev.leaks, leaks) : leaks,
cap: gd.config?.lpCapPerRound,
unite: phase === PHASE.UNITE || (same && prev.unite),
};
}
// latest values for event handlers bound once // latest values for event handlers bound once
const live = useRef({}); const live = useRef({});
@@ -572,6 +592,7 @@ function MatchScreen() {
// phase changes: banners, sounds, resets // phase changes: banners, sounds, resets
const phaseKey = `${phase}:${pub?.round}`; const phaseKey = `${phase}:${pub?.round}`;
const prevPhase = useRef(null); const prevPhase = useRef(null);
const resultSeq = useRef(0); // re-keys the result box (ResultDialog) at every SETTLE
useEffect(() => { useEffect(() => {
const prev = prevPhase.current; const prev = prevPhase.current;
prevPhase.current = phase; prevPhase.current = phase;
@@ -585,6 +606,19 @@ function MatchScreen() {
else if (phase === PHASE.FINAL_ASSAULT) audio.sfx(solo ? 'bossRoundSingle' : 'bossRoundTeam'); else if (phase === PHASE.FINAL_ASSAULT) audio.sfx(solo ? 'bossRoundSingle' : 'bossRoundTeam');
else if (phase === PHASE.HIDDEN_CORE) audio.sfx('bossRoundSecret'); else if (phase === PHASE.HIDDEN_CORE) audio.sfx('bossRoundSecret');
else if (phase === PHASE.SP_DRAFT) audio.sfx('draft'); else if (phase === PHASE.SP_DRAFT) audio.sfx('draft');
else if (phase === PHASE.SETTLE) {
// the official 战斗结束 sound and result box (GitHub #235, PR #112 by @Convey123; ui/gameLogic/phases.js): the LP this
// round cost ME — after a 联防 the authority's own charge (m.public.uniteResult.losses: a leaker pays for the
// survivors, not for its own battle's leaks), else the own battle's as kept while it ran (roundLossRef)
const kept = roundLossRef.current;
roundLossRef.current = null;
const cost = kept && kept.round === pub?.round ? kept : null;
const sfx = battleOverSfx(cost, pub?.uniteResult?.losses?.[myId]);
if (sfx) audio.sfx(sfx);
const box = uniteResultBox(pub?.uniteResult, myId) || battleResultBox(cost);
resultSeq.current += 1;
if (box) setResultBox({ ...box, key: `result:${pub?.round}:${resultSeq.current}` });
}
// the server resets every watcher to its own field on phase changes — but an eliminated player / spectator seat // 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 // 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) // round start): resetting here would fly the camera home and back at every prep phase (item 56)
@@ -1373,6 +1407,9 @@ function MatchScreen() {
${banner ? html`<${PhaseBanner} key=${banner.key} mode="overlay" title=${banner.title} sub=${banner.sub} micro=${banner.micro} ${banner ? html`<${PhaseBanner} key=${banner.key} mode="overlay" title=${banner.title} sub=${banner.sub} micro=${banner.micro}
tone=${banner.tone} duration=${banner.duration || 1500} onDone=${() => setBanner(null)} />` : null} tone=${banner.tone} duration=${banner.duration || 1500} onDone=${() => setBanner(null)} />` : null}
${resultBox ? html`<${ResultDialog} key=${resultBox.key} title=${resultBox.title} sub=${resultBox.sub} micro=${resultBox.micro}
tone=${resultBox.tone} duration=${resultBox.duration} onDone=${() => setResultBox(null)} />` : null}
${facing && view ? html`<${FacingWheel} key=${`${facing.uid}:${facing.row},${facing.col}`} view=${view} row=${facing.row} col=${facing.col} ${facing && view ? html`<${FacingWheel} key=${`${facing.uid}:${facing.row},${facing.col}`} view=${view} row=${facing.row} col=${facing.col}
grid=${facing.grid} name=${facing.name} onPreview=${previewFacing} onCommit=${commitFacing} onCancel=${cancelFacing} />` : null} grid=${facing.grid} name=${facing.name} onPreview=${previewFacing} onCommit=${commitFacing} onCancel=${cancelFacing} />` : null}
+34 -1
View File
@@ -2,7 +2,7 @@
// //
// Exports: html (bound htm), Icon, Button, Panel, MicroLabel, Chevrons, HexBadge, TierChip, // Exports: html (bound htm), Icon, Button, Panel, MicroLabel, Chevrons, HexBadge, TierChip,
// BondDisc, SevenSeg, Countdown, Modal, confirmDialog/alertDialog + DialogHost, Tooltip + // BondDisc, SevenSeg, Countdown, Modal, confirmDialog/alertDialog + DialogHost, Tooltip +
// TooltipLayer, ProgressBar, Tabs, Spinner, AvatarFrame, PhaseBanner, PingPill, DifficultyTag, // TooltipLayer, ProgressBar, Tabs, Spinner, AvatarFrame, PhaseBanner, ResultDialog, PingPill, DifficultyTag,
// DifficultyIcon, TextField, UiHosts (mount once: dialogs + tooltips), useTicker, secondsLeft/hasDeadline, // DifficultyIcon, TextField, UiHosts (mount once: dialogs + tooltips), useTicker, secondsLeft/hasDeadline,
// roman(), doctorNo(). // roman(), doctorNo().
// //
@@ -732,6 +732,39 @@ export function PhaseBanner({ title, sub, micro, tone = 'mint', mode = 'inline',
</div>`; </div>`;
} }
/**
* The round's result box (the official round result dialog, shown at settlement; GitHub #235, PR #112 by @Convey123):
* a centred framed plate — tone-coloured frame and corner ticks, chevrons either side of the title — that opens, holds
* `duration` ms and closes by itself. Purely presentational: ui/gameLogic/phases.js (roundResultBox / uniteResultBox /
* battleResultBox) picks the words, screens/game.js the moment (SETTLE). `pointer-events: none`: it never takes a click.
* @param {{ title: any, sub?: any, micro?: string, tone?: 'mint'|'orange'|'red', duration?: number, onDone?: Function }} props
* duration (ms) > 0 auto-hides then calls onDone. Re-key to replay.
*/
export function ResultDialog({ title, sub, micro, tone = 'mint', duration = 2800, onDone }) {
const [leaving, setLeaving] = useState(false);
const [gone, setGone] = useState(false);
useEffect(() => {
if (!(duration > 0)) return undefined;
const t1 = setTimeout(() => setLeaving(true), duration);
const t2 = setTimeout(() => { setGone(true); onDone?.(); }, duration + 300);
return () => { clearTimeout(t1); clearTimeout(t2); };
}, [duration]);
if (gone) return null;
return html`<div class=${cx('rdialog', `rdialog--${tone}`, leaving && 'is-leaving')} role="status" aria-live="polite">
<div class="rdialog__box">
<span class="rdialog__tick rdialog__tick--tl"></span><span class="rdialog__tick rdialog__tick--tr"></span>
<span class="rdialog__tick rdialog__tick--bl"></span><span class="rdialog__tick rdialog__tick--br"></span>
${micro ? html`<span class="rdialog__micro">${micro}</span>` : null}
<div class="rdialog__row">
<${Chevrons} count=${3} tone=${tone} class="rdialog__chev" />
<span class="rdialog__title">${title}</span>
<${Chevrons} count=${3} tone=${tone} dir="left" class="rdialog__chev" />
</div>
${sub ? html`<span class="rdialog__sub">${sub}</span>` : null}
</div>
</div>`;
}
// ---- domain helpers ---------------------------------------------------------------------------- // ---- domain helpers ----------------------------------------------------------------------------
/** /**
+1
View File
@@ -28,6 +28,7 @@ export { clamp, tileKey } from './gameLogic/shared.js';
export { phaseMode, isCombatPhase, showDeadPill, isBossPhase } from './gameLogic/phases.js'; export { phaseMode, isCombatPhase, showDeadPill, isBossPhase } from './gameLogic/phases.js';
export { prepCamera, prepCameraFor, foldCamera, deployFieldOf, fieldTile, boardTileOf, ownerBandId, teamFrameIds } 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 { phaseBanner, prepCapsuleLabel, countdownState, phaseTotalSeconds } from './gameLogic/phases.js';
export { RESULT_BOX_MS, ownRoundLoss, battleOverSfx, roundResultBox, uniteResultBox, battleResultBox } from './gameLogic/phases.js';
export { STATUS_META } from './gameLogic/format.js'; export { STATUS_META } from './gameLogic/format.js';
export { sortedPlayers } from './gameLogic/shared.js'; export { sortedPlayers } from './gameLogic/shared.js';
export { ownFieldId, homeFieldId, cycleField, watchTarget, switcherLabel, fieldLabel, activeBubbles } from './gameLogic/watch.js'; export { ownFieldId, homeFieldId, cycleField, watchTarget, switcherLabel, fieldLabel, activeBubbles } from './gameLogic/watch.js';
+99 -1
View File
@@ -1,4 +1,5 @@
// ui/gameLogic/phases.js — phase families, banners and countdowns. Re-exported from ../gameLogic.js. // ui/gameLogic/phases.js — phase families, banners, the round's result box and 战斗结束 sound, countdowns. Re-exported
// from ../gameLogic.js.
import { PHASE } from '../../../../shared/constants.js'; import { PHASE } from '../../../../shared/constants.js';
import { bossLevelSeconds } from '../matchStatus.js'; import { bossLevelSeconds } from '../matchStatus.js';
@@ -61,6 +62,103 @@ export function phaseBanner(phase, pub) {
} }
} }
// ---- the round's result box and 战斗结束 sound (GitHub #235; PR #112 by @Convey123) ------------------------------------
// The official round result dialog (research 09 §3.1 "Round result dialog"): 「作战结束」 with 「全员无伤!」, or
// 「生命值减少」 and the LP lost — official words only (the owner's review of PR #112, 2026-10-05: the
// remake's own 「联防成功 / 联防失败:还有 N 只突破防线」 line is not in the official dialog). screens/game.js shows it
// (ui/components.js ResultDialog) and plays the matching BATTLEOVER sound when SETTLE starts — the moment is [ASSUMED]:
// the sources do not time the dialog, and a 联防 leaker's number exists only after the 联防. Boss rounds have no SETTLE
// (最终攻势 / 隐秘核心 go to the result screen or the Hidden Core round), so they get neither [ASSUMED].
/** How long a result box (ui/components.js ResultDialog) stays up, ms — inside SETTLE's 3 s (server DELAYS.SETTLE). */
export const RESULT_BOX_MS = 2800;
/**
* What this round's own battle cost the viewer: `min(cap, leaks)`, exactly what settlement charges outside a 联防.
* `cost` is what screens/game.js `roundLossRef` kept while the battle ran — `leaks` = counted enemies that got through
* (the highest the round showed: the settled state reads 0 again and must not wipe it), `cap` = the per-round LP cap.
* @param {{ leaks?: number, cap?: number } | null | undefined} cost
* @returns {number|null} null when this round's battle was never seen (not in the round, a reconnect landing on SETTLE)
*/
export function ownRoundLoss(cost) {
if (!cost) return null;
const leaks = Number.isFinite(cost.leaks) ? Math.max(0, Math.trunc(cost.leaks)) : null;
if (leaks == null) return null;
const cap = Number.isFinite(cost.cap) && cost.cap > 0 ? Math.trunc(cost.cap) : 10; // no cap known → the official 10
return Math.min(cap, leaks);
}
/**
* 战斗结束: one of the official `BATTLEOVER_*` sounds (all three in data/assets.json audio.sfx.ui) — `battleOverReduce`
* when the round cost the viewer LP, `battleOverNoReduce` when a 联防 ran and the viewer was charged nothing,
* `battleOverNormal` otherwise; null when this round's battle was never seen. The client data names the three but not
* their triggers: the mapping is [ASSUMED] (PR #112; the owner's review called the three sounds right).
* The loss is the authority's per-player charge when a 联防 resolved (`uniteLoss`, m.public.uniteResult.losses — a
* leaker whose enemies the helpers stopped paid 0), else the own battle's (`ownRoundLoss`).
* @param {{ leaks?: number, cap?: number, unite?: boolean } | null | undefined} cost
* @param {number|null} [uniteLoss]
* @returns {'battleOverReduce'|'battleOverNoReduce'|'battleOverNormal'|null}
*/
export function battleOverSfx(cost, uniteLoss = null) {
if (!cost) return null;
// `uniteLoss == null` means "no 联防 figure" (Number(null) would read as 0)
const authority = uniteLoss == null ? NaN : Number(uniteLoss);
const loss = Number.isFinite(authority) ? Math.max(0, Math.trunc(authority)) : ownRoundLoss(cost);
if (loss == null) return null;
if (loss > 0) return 'battleOverReduce';
return cost.unite ? 'battleOverNoReduce' : 'battleOverNormal';
}
/**
* The official round result dialog for the viewer's own LP charge `loss`: 「作战结束」 + 「全员无伤!」 (mint) at 0, else
* 「生命值减少 −N」 (red).
* @param {number} loss
* @returns {{ title: string, micro: string, tone: string, sub: string, duration: number }}
*/
export function roundResultBox(loss) {
const n = Math.max(0, Math.trunc(Number(loss) || 0));
return n === 0
? { title: t('作战结束'), micro: 'BATTLE OVER', tone: 'mint', sub: t('全员无伤!'), duration: RESULT_BOX_MS }
: { title: t('作战结束'), micro: 'BATTLE OVER', tone: 'red', sub: t('生命值减少 −{n}', { n }), duration: RESULT_BOX_MS };
}
/**
* The result box of a round whose 联防 resolved: the same official dialog, its number the authority's charge for the
* viewer (m.public.uniteResult.losses — in a 联防 a leaker pays for the survivors, not for its own battle's leaks, so the
* client cannot work it out). The official words only, and only when they are true:
* 生命值减少 −N the round charged the viewer N (red);
* 全员无伤! nothing got through (`through` 0): nobody paid, true for every participant (mint);
* title alone the 联防 leaked but the viewer was not charged (a helper, a spared leaker — 全员无伤! would claim the
* teammate who paid was unharmed too, and the official dialog has no other line; orange). Also the
* reading of a view without `losses`, which never falls back to the own battle's leaks.
* A viewer `losses` does not list — a spectator seat, a player eliminated before the round — gets no box, as in a
* round without 联防, where a viewer without a battle gets none (and no sound) [ASSUMED].
* @param {{ through?: number, losses?: Record<string, number> } | null | undefined} res m.public.uniteResult
* @param {string|null|undefined} selfId the viewer's playerId
* @returns {{ title: string, micro: string, tone: string, sub: string, duration: number } | null} null: no 联防 resolved, or not in the round
*/
export function uniteResultBox(res, selfId) {
const through = Number(res?.through);
if (!isObj(res) || !Number.isFinite(through) || through < 0) return null;
const losses = isObj(res.losses) ? res.losses : null;
if (losses && (selfId == null || !Object.hasOwn(losses, selfId))) return null;
const raw = losses ? Number(losses[selfId]) : NaN;
const loss = Number.isFinite(raw) ? Math.max(0, Math.trunc(raw)) : null;
if (loss != null && loss > 0) return roundResultBox(loss);
if (loss != null && through === 0) return roundResultBox(0);
return { title: t('作战结束'), micro: 'BATTLE OVER', tone: 'orange', sub: '', duration: RESULT_BOX_MS };
}
/**
* The result box of a round without 联防: the own battle's cost as kept while it ran (`ownRoundLoss`; the live pending
* value is 0 again by the time SETTLE renders). null without a battle seen this round.
* @param {{ leaks?: number, cap?: number } | null | undefined} cost
*/
export function battleResultBox(cost) {
const loss = ownRoundLoss(cost);
return loss == null ? null : roundResultBox(loss);
}
/** Label of the prep capsule ("休息一下" in the original). */ /** Label of the prep capsule ("休息一下" in the original). */
export function prepCapsuleLabel(phase) { export function prepCapsuleLabel(phase) {
if (phase === PHASE.SP_DRAFT) return t('机变阶段'); if (phase === PHASE.SP_DRAFT) return t('机变阶段');
+2
View File
@@ -368,6 +368,8 @@ export class Match {
this.watchPref = new Map(); this.watchPref = new Map();
this.lastResults = new Map(); this.lastResults = new Map();
this.unitePlan = null; this.unitePlan = null;
/** 联防 outcome for the SETTLE view: { through, helpers, leakers, losses } (settle()), null when no 联防 resolved */
this.uniteResultView = null;
/** server-run 联防: the leakers' counts last published (_uniteTick) */ /** server-run 联防: the leakers' counts last published (_uniteTick) */
this._uniteLeftKey = null; this._uniteLeftKey = null;
/** 联防: { plan, bounds } — per leaker the most survivors settlement can bill (_uniteLeft's clamp) */ /** 联防: { plan, bounds } — per leaker the most survivors settlement can bill (_uniteLeft's clamp) */
+1
View File
@@ -229,6 +229,7 @@ export class MatchPhases {
this.fields = []; this.fields = [];
this.watchers.clear(); this.watchers.clear();
this.unitePlan = null; this.unitePlan = null;
this.uniteResultView = null;
this.sp = null; this.sp = null;
this.wave = null; this.wave = null;
this.bossWaves = null; this.bossWaves = null;
+15 -2
View File
@@ -1,6 +1,6 @@
// server/match/match/settle.js — Match methods: SETTLE (the LP loss — a leaker's 联防 survivors —, stats, bounty coins, // server/match/match/settle.js — Match methods: SETTLE (the LP loss — a leaker's 联防 survivors —, stats, bounty coins,
// the IN_BATTLE layer gains clamped by layerGainRoom, CHAR_DAMAGE tickers, eliminations) and RESULT (finish: m.result // the IN_BATTLE layer gains clamped by layerGainRoom, CHAR_DAMAGE tickers, eliminations, the 联防 outcome the SETTLE
// to every human still here, onEnd). // view carries) and RESULT (finish: m.result to every human still here, onEnd).
// Installed on Match.prototype by server/match/Match.js (a method container: never instantiated; `this` is the match). // Installed on Match.prototype by server/match/Match.js (a method container: never instantiated; `this` is the match).
import { PHASE, layerGainRoom } from '../../../shared/constants.js'; import { PHASE, layerGainRoom } from '../../../shared/constants.js';
@@ -20,11 +20,24 @@ export class MatchSettle {
// a 联防 battle that could not run at all (synthetic result) must not wipe the leakers' losses: charge their own leaks // a 联防 battle that could not run at all (synthetic result) must not wipe the leakers' losses: charge their own leaks
const uniteRan = !!(plan && uniteResult && !uniteResult.synthetic); const uniteRan = !!(plan && uniteResult && !uniteResult.synthetic);
const survivors = uniteRan ? uniteSurvivors(plan, uniteResult) : null; const survivors = uniteRan ? uniteSurvivors(plan, uniteResult) : null;
// The 联防's outcome as data for the SETTLE view (m.public.uniteResult, views.js; GitHub #235, PR #112 by @Convey123):
// each client pops the official result box from it (ui/gameLogic/phases.js uniteResultBox) — `through` = the leakers'
// enemies that still got through (uncapped; only decides whether 「全员无伤!」 is true), `losses` = every alive
// player's own LP charge of this round, the same `loss` deducted below, so the box and the LP bar never disagree. A
// leaker's own battle leaks are not its charge in a 联防 round, which is why the client cannot work the number out
// itself. No ticker line: the official reports the outcome in the one dialog. null when no 联防 resolved.
this.uniteResultView = uniteRan && plan.leakers.length ? {
through: plan.leakers.reduce((n, lk) => n + Math.max(0, survivors.get(lk.playerId) || 0), 0),
helpers: plan.helpers.map((p) => p.playerId),
leakers: plan.leakers.map((p) => p.playerId),
losses: {},
} : null;
const alive = this.alivePlayers(); const alive = this.alivePlayers();
for (const ps of alive) { for (const ps of alive) {
const r = this.lastResults.get(ps.playerId) || { leaked: [], perfect: true, coins: 0, layerGains: {}, killed: 0, damageDealt: 0 }; const r = this.lastResults.get(ps.playerId) || { leaked: [], perfect: true, coins: 0, layerGains: {}, killed: 0, damageDealt: 0 };
const counted = (r.leaked || []).filter((l) => l && l.counted !== false).length; const counted = (r.leaked || []).filter((l) => l && l.counted !== false).length;
const loss = uniteRan && plan.leakers.includes(ps) ? Math.min(cap, survivors.get(ps.playerId) || 0) : Math.min(cap, counted); const loss = uniteRan && plan.leakers.includes(ps) ? Math.min(cap, survivors.get(ps.playerId) || 0) : Math.min(cap, counted);
if (this.uniteResultView) this.uniteResultView.losses[ps.playerId] = loss;
ps.lp -= loss; ps.lp -= loss;
ps.stats.lpLost += loss; ps.stats.lpLost += loss;
ps.stats.leaks += counted; ps.stats.leaks += counted;
+10 -3
View File
@@ -1,7 +1,7 @@
// server/match/match/views.js — Match methods: the state builders — m.public (publicView with statusOf / fieldOf, the // 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 // fields' progress, the teammates' live pendingLp / uniteLeft, the SETTLE uniteResult), the nextEnemies preview of
// scout's m.field (prepFieldMeta: board, hand and temp as units, the scouted player's effects and coming enemies; in a // m.private and the prep scout's m.field (prepFieldMeta: board, hand and temp as units, the scouted player's effects and
// boss round's prep on the player's half of the boss field). // 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). // 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'; import { PHASE, GEO } from '../../../shared/constants.js';
@@ -117,6 +117,13 @@ export class MatchViews {
}; };
} }
if (this.phase === PHASE.UNITE && this.unitePlan) v.unite = { helpers: this.unitePlan.helpers.map((p) => p.playerId), leakers: this.unitePlan.leakers.map((p) => p.playerId) }; if (this.phase === PHASE.UNITE && this.unitePlan) v.unite = { helpers: this.unitePlan.helpers.map((p) => p.playerId), leakers: this.unitePlan.leakers.map((p) => p.playerId) };
// SETTLE after a 联防: its outcome as data (settle.js uniteResultView; GitHub #235, PR #112) — { through, helpers,
// leakers, losses: { playerId: the LP settlement charged this round } } — the client's result box reads the viewer's
// own charge from it; absent when no 联防 resolved (the client then shows the round's own battle result)
if (this.phase === PHASE.SETTLE && this.uniteResultView) {
const ur = this.uniteResultView;
v.uniteResult = { through: ur.through, helpers: ur.helpers.slice(), leakers: ur.leakers.slice(), losses: { ...ur.losses } };
}
return v; return v;
} }
+62 -1
View File
@@ -1,5 +1,6 @@
// User playtest #6, match flow (workstream WC): #4 悬赏决策 bounty enemies keep coming back, #19 the merge reward offers // User playtest #6, match flow (workstream WC): #4 悬赏决策 bounty enemies keep coming back, #19 the merge reward offers
// the same operator twice, #7 the 联防 leak counter (server side; the HUD helpers are in test/ui/playtest6-unite.test.js). // the same operator twice, #7 the 联防 leak counter (server side; the HUD helpers are in test/ui/playtest6-unite.test.js);
// GitHub #235, the 联防 outcome in the SETTLE view (the result box's words: test/ui/gameLogic.test.js).
// Real match paths (the match harness in virtual time), real data. // Real match paths (the match harness in virtual time), real data.
import { test } from 'node:test'; import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
@@ -439,3 +440,63 @@ test('#7 a leaked enemy that splits (磨砻: DeadSpawn ×2) raises the counter
assert.equal(lp - leaker.lp, Math.min(10, last), `settled LP loss = min(10, ${last} left)`); assert.equal(lp - leaker.lp, Math.min(10, last), `settled LP loss = min(10, ${last} left)`);
h.m.dispose(); h.m.dispose();
}); });
// =====================================================================================================================
// GitHub #235 (PR #112 by @Convey123): the official result box at settlement. The 联防's outcome rides the SETTLE
// m.public as data (`uniteResult`), each player's own LP charge in `losses` — the number the client's 「生命值减少 −N」
// shows (ui/gameLogic/phases.js uniteResultBox) — and that box is the only announcement: no 联防 result ticker line.
test('#235 联防 outcome: the SETTLE view carries every player\'s own charge (= the LP settled), no result line is broadcast, and the next phase drops it', () => {
const run = (survivors) => {
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 3, seed: 6060, fake: true, clientCombat: true, instant: false,
script: (b) => (b.kind === 'normal'
? (b.players[0] === 'p_0' ? { duration: 2, leaks: { p_0: 3 } } : { duration: 3 })
: { duration: 5, survivors }) }).start();
const m = h.m;
h.autoHumans();
const before = h.bc.length;
let lpBefore = null;
h.run(() => {
if (m.phase === PHASE.UNITE && !lpBefore) lpBefore = Object.fromEntries([...m.players.values()].map((ps) => [ps.playerId, ps.lp]));
return m.phase === PHASE.SETTLE || h.ended != null;
}, { maxSteps: 5e6 });
const tickers = h.bc.slice(before).filter((x) => x.t === 'm.ticker').map((x) => x.text);
const view = m.publicView();
assert.equal(view.phase, PHASE.SETTLE);
assert.ok(lpBefore, 'a 联防 ran this round');
const through = Object.values(survivors).reduce((a, b) => a + b, 0);
const losses = { p_0: Math.min(m.gd.lpCapPerRound, through) };
for (const id of view.uniteResult.helpers) losses[id] = 0;
assert.deepEqual(view.uniteResult, { through, helpers: view.uniteResult.helpers, leakers: ['p_0'], losses }, 'the 联防 outcome rides the SETTLE view');
assert.ok(view.uniteResult.helpers.length > 0 && !view.uniteResult.helpers.includes('p_0'), 'the helpers are the perfect players');
// the box's number is the LP settlement really charged — never the leaker's own battle leaks (3)
for (const ps of m.players.values()) assert.equal(lpBefore[ps.playerId] - ps.lp, view.uniteResult.losses[ps.playerId], `${ps.playerId}: losses = the LP settled`);
checkInvariants(m);
// the next phase carries no stale outcome
h.run(() => m.phase !== PHASE.SETTLE || h.ended != null, { maxSteps: 5e6 });
assert.equal(m.publicView().uniteResult, undefined, `no uniteResult in ${m.phase}`);
m.dispose();
return tickers;
};
const cleared = run({});
// the 联防's opening line is still broadcast (the official 「联防阶段」 banner while it runs) …
assert.ok(cleared.some((t) => t.startsWith('联防阶段')), `expected the 联防阶段 line, got ${JSON.stringify(cleared)}`);
// … but the outcome is box-only: no 「联防成功」 / 「联防结束」 line beside it
assert.ok(!cleared.some((t) => /^联防(成功|结束|失败)/.test(t)), `no result line may be broadcast, got ${JSON.stringify(cleared)}`);
const through = run({ p_0: 2 });
assert.ok(!through.some((t) => /^联防(成功|结束|失败)/.test(t)), `no result line may be broadcast, got ${JSON.stringify(through)}`);
// more survivors than the per-round cap: the charge (and the box) stops at the cap
run({ p_0: 14 });
});
test('#235 no 联防 this round: the SETTLE view carries no uniteResult (the client then shows the round\'s own battle result)', () => {
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 2, seed: 6061, fake: true, clientCombat: true, instant: false,
script: () => ({ duration: 3 }) }).start();
const m = h.m;
h.autoHumans();
h.run(() => m.phase === PHASE.SETTLE || h.ended != null, { maxSteps: 5e6 });
assert.equal(m.phase, PHASE.SETTLE);
assert.equal(m.publicView().uniteResult, undefined);
checkInvariants(m);
m.dispose();
});
+102
View File
@@ -9,6 +9,7 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { import {
phaseMode, phaseBanner, isCombatPhase, isBossPhase, countdownState, phaseTotalSeconds, sortBonds, bondTier, nextThreshold, phaseMode, phaseBanner, isCombatPhase, isBossPhase, countdownState, phaseTotalSeconds, sortBonds, bondTier, nextThreshold,
battleOverSfx, ownRoundLoss, uniteResultBox, battleResultBox, roundResultBox, RESULT_BOX_MS,
bondMembers, memberHeadCount, bannedPerBond, priceTone, mergeProgress, shopBlockReason, deploySets, indexPieces, placementContext, canPlace, bondMembers, memberHeadCount, bannedPerBond, priceTone, mergeProgress, shopBlockReason, deploySets, indexPieces, placementContext, canPlace,
boardTargets, dropIntent, normalizeDraft, normalizeSp, groupEnemies, factionTypes, snapHud, bossFrac, attackInterval, fmtNum, boardTargets, dropIntent, normalizeDraft, normalizeSp, groupEnemies, factionTypes, snapHud, bossFrac, attackInterval, fmtNum,
rangeGridBox, shortcutFor, sanitizeSettings, DEFAULT_SETTINGS, normalizeResult, cycleField, fieldLabel, homeFieldId, rangeGridBox, shortcutFor, sanitizeSettings, DEFAULT_SETTINGS, normalizeResult, cycleField, fieldLabel, homeFieldId,
@@ -16,6 +17,7 @@ import {
} from '../../public/js/ui/gameLogic.js'; } from '../../public/js/ui/gameLogic.js';
import { pairPlayers } from '../../server/match/finalAssault.js'; import { pairPlayers } from '../../server/match/finalAssault.js';
import { PHASE, GEO } from '../../shared/constants.js'; import { PHASE, GEO } from '../../shared/constants.js';
import { setLang, setMessages } from '../../shared/i18n.js';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const load = (f) => JSON.parse(readFileSync(path.join(ROOT, 'data', f), 'utf8')); const load = (f) => JSON.parse(readFileSync(path.join(ROOT, 'data', f), 'utf8'));
@@ -72,6 +74,106 @@ describe('phases', () => {
assert.equal(phaseBanner(PHASE.SETTLE, {}), null); assert.equal(phaseBanner(PHASE.SETTLE, {}), null);
assert.equal(prepCapsuleLabel(PHASE.PREP), '休息一下'); assert.equal(prepCapsuleLabel(PHASE.PREP), '休息一下');
}); });
// GitHub #235 (PR #112 by @Convey123): the official round result dialog and its 战斗结束 sound at SETTLE
test('战斗结束 sound: the official BATTLEOVER_* variant for the round', () => {
// 掉血 → _REDUCE;联防里自己没被扣 → _NOREDUCE;普通回合没扣 → _NORMAL;本回合没打过(不在本回合 / 重连落在结算)→ 不响
// `cost` is what screens/game.js roundLossRef keeps — { round, leaks, cap, unite }; it has no `pending` (a field the
// object never carried is how the Reduce variant was once dead code, review on PR #112)
const cost = (o) => ({ round: 3, leaks: 0, cap: 10, unite: false, ...o });
assert.equal(battleOverSfx(cost({ leaks: 3 })), 'battleOverReduce');
assert.equal(battleOverSfx(cost({ leaks: 1, unite: true })), 'battleOverReduce');
assert.equal(battleOverSfx(cost({ unite: true })), 'battleOverNoReduce');
assert.equal(battleOverSfx(cost({})), 'battleOverNormal');
// the cap bounds it like settlement's charge (14 leaks with cap 10 is still a loss)
assert.equal(battleOverSfx(cost({ leaks: 14, cap: 10 })), 'battleOverReduce');
assert.equal(battleOverSfx(cost({ leaks: 14, cap: undefined })), 'battleOverReduce', 'no cap known → the official 10');
// a 联防 round: the authority's charge decides, not the own battle's leaks — a leaker whose enemies the helpers
// stopped paid 0 and hears NoReduce
assert.equal(battleOverSfx(cost({ leaks: 3, unite: true }), 0), 'battleOverNoReduce');
assert.equal(battleOverSfx(cost({ leaks: 3, unite: true }), 3), 'battleOverReduce');
assert.equal(battleOverSfx(cost({ leaks: 0, unite: true }), 2), 'battleOverReduce');
assert.equal(battleOverSfx(cost({ leaks: 2, unite: true }), null), 'battleOverReduce', 'no 联防 figure → the own battle decides');
assert.equal(battleOverSfx(cost({ leaks: 2, unite: true }), undefined), 'battleOverReduce');
// this round's battle never seen (a spectator seat, an eliminated player, a reconnect landing on SETTLE)
assert.equal(battleOverSfx(null), null);
assert.equal(battleOverSfx(undefined, 0), null);
});
test('ownRoundLoss: min(cap, leaks) — what settlement charges outside a 联防', () => {
assert.equal(ownRoundLoss({ leaks: 3, cap: 10 }), 3);
assert.equal(ownRoundLoss({ leaks: 14, cap: 10 }), 10);
assert.equal(ownRoundLoss({ leaks: 14 }), 10);
assert.equal(ownRoundLoss({ leaks: 2.9, cap: 10 }), 2);
assert.equal(ownRoundLoss({ leaks: 0 }), 0);
assert.equal(ownRoundLoss(null), null);
assert.equal(ownRoundLoss({ cap: 10 }), null, 'no leaks seen → no figure at all');
});
test('round result box: the official words — 作战结束 + 全员无伤! (mint) / 生命值减少 −N (red)', () => {
assert.equal(RESULT_BOX_MS, 2800, 'inside SETTLE\'s 3 s (server DELAYS.SETTLE)');
assert.deepEqual(roundResultBox(0), { title: '作战结束', micro: 'BATTLE OVER', tone: 'mint', sub: '全员无伤!', duration: RESULT_BOX_MS });
assert.deepEqual(roundResultBox(4), { title: '作战结束', micro: 'BATTLE OVER', tone: 'red', sub: '生命值减少 −4', duration: RESULT_BOX_MS });
assert.equal(roundResultBox(undefined).sub, '全员无伤!', 'an unknown loss reads as no loss');
assert.equal(roundResultBox(-2).sub, '全员无伤!');
assert.equal(roundResultBox(2.7).sub, '生命值减少 −2');
// through t(): the English pack (public/i18n/en.json — no official EN wording in the data, a plain translation)
try {
setMessages('en', JSON.parse(readFileSync(path.join(ROOT, 'public/i18n/en.json'), 'utf8')));
setLang('en');
assert.deepEqual([roundResultBox(0).title, roundResultBox(0).sub, roundResultBox(3).sub], ['Battle over', 'All unharmed!', 'LP reduced by 3']);
} finally { setLang('zh'); setMessages('en', {}); }
});
test('联防 result box: the viewer\'s own charge from the authority, the official words only when true', () => {
const res = { through: 3, helpers: ['p_1', 'p_2'], leakers: ['p_0'], losses: { p_0: 3, p_1: 0, p_2: 0 } };
// the leaker reads the LP settlement actually charged — word for word an ordinary round's box
assert.deepEqual(uniteResultBox(res, 'p_0'), roundResultBox(3));
assert.equal(uniteResultBox(res, 'p_0').sub, '生命值减少 −3');
// a helper was spared, but the 联防 leaked and a teammate paid: 全员无伤! would claim that teammate was unharmed, and
// the official dialog has no other line (the remake's 「联防失败:还有 N 只突破防线」 was dropped in the owner's review
// of PR #112) — the official title alone, orange (player report 2026-10-06 on PR #112)
const helper = uniteResultBox(res, 'p_1');
assert.equal(helper.title, '作战结束');
assert.equal(helper.tone, 'orange');
assert.equal(helper.sub, '', 'no 全员无伤! while a teammate was charged');
assert.ok(!/联防成功|联防失败|突破防线/.test(JSON.stringify(helper)), 'no 联防 verdict line');
// nothing got through: nobody paid, so 全员无伤! is true for leaker and helper alike — the only 联防 case that says it
const cleared = { through: 0, helpers: ['p_1', 'p_2'], leakers: ['p_0'], losses: { p_0: 0, p_1: 0, p_2: 0 } };
assert.deepEqual(uniteResultBox(cleared, 'p_0'), roundResultBox(0));
assert.deepEqual(uniteResultBox(cleared, 'p_1'), roundResultBox(0));
// two leakers, one spared by the 联防, one charged: only the charged one reads 生命值减少
const spared = { through: 3, helpers: ['p_1'], leakers: ['p_0', 'p_2'], losses: { p_0: 0, p_2: 2, p_1: 0 } };
assert.equal(uniteResultBox(spared, 'p_0').sub, '', 'spared leaker, but the 联防 leaked: no claim');
assert.equal(uniteResultBox(spared, 'p_2').sub, '生命值减少 −2');
assert.equal(uniteResultBox(spared, 'p_1').sub, '', 'the helper, whose teammate paid');
// not in the round — a spectator seat, a player eliminated before it (settle lists alive players only): no box,
// as in a round without 联防 where a viewer without a battle gets none (and no sound) [ASSUMED]
assert.equal(uniteResultBox(res, 'nobody'), null);
assert.equal(uniteResultBox(cleared, 'nobody'), null);
assert.equal(uniteResultBox(res, undefined), null);
assert.equal(uniteResultBox(res, null), null);
// no 联防 this round: no box from here (the own battle's takes over)
assert.equal(uniteResultBox(null, 'p_0'), null);
assert.equal(uniteResultBox(undefined, 'p_0'), null);
assert.equal(uniteResultBox({}, 'p_0'), null);
assert.equal(uniteResultBox({ through: -1 }, 'p_0'), null);
// a view without `losses` makes no LP claim — and never falls back to the own battle's leaks, which a 联防 removed
const noLosses = uniteResultBox({ through: 3, helpers: ['p_1'], leakers: ['p_0'] }, 'p_0');
assert.equal(noLosses.title, '作战结束');
assert.equal(noLosses.sub, '', 'no LP known → no LP claim');
assert.equal(noLosses.tone, 'orange', 'no LP known → not read as a loss');
assert.equal(uniteResultBox({ through: 0, helpers: ['p_1'], leakers: ['p_0'] }, 'p_1').sub, '');
});
test('作战 result box (no 联防): 全员无伤! without a leak, min(cap, leaks) otherwise, nothing without a battle', () => {
assert.deepEqual(battleResultBox({ leaks: 0, cap: 10 }), roundResultBox(0));
const bad = battleResultBox({ leaks: 3, cap: 10 });
assert.equal(bad.title, '作战结束');
assert.equal(bad.tone, 'red');
assert.equal(bad.sub, '生命值减少 −3');
// the per-round LP cap (config.lpCapPerRound): the loss shown is min(cap, leaks), never the leaked count
assert.equal(battleResultBox({ leaks: 14, cap: 10 }).sub, '生命值减少 −10');
assert.equal(battleResultBox({ leaks: 14 }).sub, '生命值减少 −10', 'no cap known → the official 10');
assert.equal(battleResultBox({ leaks: 2, cap: 5 }).sub, '生命值减少 −2');
assert.equal(battleResultBox(null), null);
assert.equal(battleResultBox({}), null, 'no battle seen this round (a spectator, a reconnect) → no box');
});
}); });
describe('countdown', () => { describe('countdown', () => {
+172
View File
@@ -0,0 +1,172 @@
// Browser checks of the round's result box at settlement (ResultDialog; GitHub #235, PR #112 by @Convey123) on the
// in-match mock harness (public/dev/game-mock.html):
// SP_E2E=1 node --test test/ui/resultbox.e2e.test.js → screenshots in test/e2e/out/resultbox-*.png
//
// The box wears the official dialog's words only (the owner's review of PR #112): title 作战结束, then the LP THIS
// player was charged — 全员无伤! / 生命值减少 −N — after a 联防 the authority's own per-player figure
// (m.public.uniteResult.losses). Asserted: `?phase=SETTLE&variant=unite` pops it for a leaker the helpers saved
// (losses.p1 = 0, nothing got through → 全员无伤!), `unite,through` for the same leaker charged 3 (→ 生命值减少 −3, not
// its own battle's leaks), `unite,through,helper` for a HELPER whose teammate paid (→ the official title alone),
// `unite,dead` for a player not in the round (not in `losses` → no box at all); driving the harness' own COMBAT →
// SETTLE switcher pops the round's own battle's box; the box is big, centred and click-through, closes by itself (and
// leaves the DOM), and no scenario logs a console error. The words are unit-tested in test/ui/gameLogic.test.js, the
// per-player charge server-side in test/match/playtest6-matchflow.test.js (#235).
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, mkdirSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const OUT = path.join(ROOT, 'test/e2e/out');
const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const ENABLED = process.env.SP_E2E === '1' && existsSync(CHROME);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
describe('settlement result box in the browser', { skip: !ENABLED && 'set SP_E2E=1 (and have Chrome) to run' }, () => {
let srv;
let browser;
let base;
before(async () => {
const { startServer } = await import('../../server/index.js');
const puppeteer = (await import('puppeteer-core')).default;
srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
base = `http://127.0.0.1:${srv.port}`;
browser = await puppeteer.launch({ executablePath: CHROME, headless: true, args: ['--no-sandbox', '--force-device-scale-factor=1'] });
mkdirSync(OUT, { recursive: true });
});
after(async () => {
await browser?.close();
await srv?.close();
});
/** A mock page; `seen()` tells whether a result box was EVER in the DOM (it closes by itself after ~3 s). */
async function open(query, { w = 1920, h = 1080 } = {}) {
const page = await browser.newPage();
await page.setViewport({ width: w, height: h });
const problems = [];
page.on('console', (m) => { if (m.type() === 'error') problems.push(`console: ${m.text()}`); });
page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`));
page.on('requestfailed', (r) => { if (r.failure()?.errorText !== 'net::ERR_ABORTED') problems.push(`requestfailed: ${r.url()} ${r.failure()?.errorText}`); });
page.on('response', (r) => { if (r.status() >= 400) problems.push(`http ${r.status()}: ${r.url()}`); });
await page.evaluateOnNewDocument(() => {
globalThis.__rdSeen = 0;
new MutationObserver(() => { if (document.querySelector('.rdialog')) globalThis.__rdSeen++; })
.observe(document, { subtree: true, childList: true });
});
// not networkidle0: the box opens when the screen mounts and is gone ~3 s later, art may still be loading then
await page.goto(`${base}/dev/game-mock.html?render=fallback&${query}`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => !!document.querySelector('.screen:not(.gload)'), { timeout: 15000 });
return { page, problems, seen: () => page.evaluate(() => globalThis.__rdSeen > 0) };
}
/** The box as the player sees it: words + where it sits + how it behaves. */
const boxState = (page) => page.evaluate(() => {
const el = document.querySelector('.rdialog');
if (!el) return null;
const box = el.querySelector('.rdialog__box');
const b = box.getBoundingClientRect();
const vw = document.documentElement.clientWidth;
const vh = document.documentElement.clientHeight;
return {
classes: el.className,
title: el.querySelector('.rdialog__title').textContent,
sub: el.querySelector('.rdialog__sub')?.textContent ?? '',
micro: el.querySelector('.rdialog__micro')?.textContent ?? '',
chevrons: el.querySelectorAll('.rdialog__chev').length,
ticks: el.querySelectorAll('.rdialog__tick').length,
titlePx: parseFloat(getComputedStyle(el.querySelector('.rdialog__title')).fontSize),
pointerEvents: getComputedStyle(el).pointerEvents,
centerX: Math.round(b.left + b.width / 2 - vw / 2),
centerY: Math.round(b.top + b.height / 2 - vh / 2),
visible: b.width > 300 && b.height > 90,
};
});
test('the official dialog: 作战结束 + this player\'s own LP after a 联防 — and none for a player not in the round', async () => {
const { page, problems } = await open('shot=1&phase=SETTLE&variant=unite');
await page.waitForSelector('.rdialog', { timeout: 10000 });
const ok = await boxState(page);
// the mock's player p1 leaked and the helpers stopped every enemy: nothing got through, nobody was charged
assert.equal(ok.title, '作战结束', JSON.stringify(ok));
assert.equal(ok.sub, '全员无伤!', 'the official line for this player\'s own LP, and nothing else');
assert.match(ok.micro, /BATTLE OVER/);
assert.ok(ok.classes.includes('rdialog--mint'), `the held tone: ${ok.classes}`);
assert.equal(ok.chevrons, 2);
assert.equal(ok.ticks, 4);
assert.ok(ok.titlePx >= 40, `a BIG box, not a line of small print (title ${ok.titlePx}px)`);
assert.ok(ok.visible, 'a real plate, not a hairline');
assert.ok(Math.abs(ok.centerX) <= 2 && Math.abs(ok.centerY) <= 2, `centred (off by ${ok.centerX},${ok.centerY})`);
assert.equal(ok.pointerEvents, 'none', 'the settlement stays clickable under it');
await sleep(700); // the opening wipe done: the whole plate on the screenshot
await page.screenshot({ path: path.join(OUT, 'resultbox-unite-held.png') });
assert.deepEqual(problems, []);
await page.close();
// 3 got through and p1 was charged 3: the LP settlement charged (not its own battle's leaks), no verdict line
const t = await open('shot=1&phase=SETTLE&variant=unite,through');
await t.page.waitForSelector('.rdialog', { timeout: 10000 });
const bad = await boxState(t.page);
assert.equal(bad.title, '作战结束');
assert.equal(bad.sub, '生命值减少 −3', 'this player\'s own LP, from the authority');
assert.ok(bad.classes.includes('rdialog--red'), `the loss tone: ${bad.classes}`);
await sleep(700); // the opening wipe done: the whole plate on the screenshot
await t.page.screenshot({ path: path.join(OUT, 'resultbox-unite-through.png') });
assert.deepEqual(t.problems, []);
await t.page.close();
// p1 HELPED: the 联防 leaked and a teammate was charged 4 while p1 paid nothing — 全员无伤! would be false for that
// teammate and the official dialog has no other line, so the box keeps the official title alone
const hp = await open('shot=1&phase=SETTLE&variant=unite,through,helper');
await hp.page.waitForSelector('.rdialog', { timeout: 10000 });
const spared = await boxState(hp.page);
assert.equal(spared.title, '作战结束');
assert.equal(spared.sub, '', 'no 全员无伤! while a teammate was charged');
assert.ok(spared.classes.includes('rdialog--orange'), `spared tone: ${spared.classes}`);
await sleep(700); // the opening wipe done: the whole plate on the screenshot
await hp.page.screenshot({ path: path.join(OUT, 'resultbox-unite-helper-spared.png') });
assert.deepEqual(hp.problems, []);
await hp.page.close();
// p1 was eliminated before the round: not in `losses` → no box (and no sound), as in a round without 联防
const d = await open('shot=1&phase=SETTLE&variant=unite,dead');
await sleep(1500);
assert.equal(await d.seen(), false, 'no result box for a player not in the round');
assert.deepEqual(d.problems, []);
await d.page.close();
});
test('every battle gets one: COMBAT → SETTLE through the harness switcher pops the official dialog', async () => {
const { page, problems } = await open('phase=COMBAT');
await sleep(600);
assert.equal(await page.$('.rdialog'), null, 'no box while the battle runs');
const clicked = await page.evaluate(() => {
const btn = [...document.querySelectorAll('#mockbar button')].find((b) => b.textContent.trim() === 'SETTLE');
if (!btn) return false;
btn.click();
return true;
});
assert.ok(clicked, 'the switcher has a SETTLE button');
await page.waitForSelector('.rdialog', { timeout: 8000 });
const own = await boxState(page);
assert.equal(own.title, '作战结束', `the round's own battle, no 联防: ${JSON.stringify(own)}`);
assert.equal(own.sub, '全员无伤!');
assert.ok(own.classes.includes('rdialog--mint'));
await sleep(700); // the opening wipe done: the whole plate on the screenshot
await page.screenshot({ path: path.join(OUT, 'resultbox-own-battle.png') });
assert.deepEqual(problems, []);
await page.close();
});
test('it closes by itself: leaving, then gone (it never blocks the next phase)', async () => {
const { page, problems } = await open('shot=1&phase=SETTLE&variant=unite');
await page.waitForSelector('.rdialog', { timeout: 10000 });
await page.waitForSelector('.rdialog.is-leaving', { timeout: 6000 });
await page.waitForFunction(() => !document.querySelector('.rdialog'), { timeout: 8000 });
assert.equal(await page.$('.rdialog'), null, 'and it leaves the DOM after ~3 s');
assert.deepEqual(problems, []);
await page.close();
});
});