diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d5b9f3d..fec620e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -82,6 +82,7 @@ - 持续的范围特效(如炼金单元)叠在一起时不再越叠越亮(社区反馈)。 - 结算卡不再显示调和、协防干员、独行、绝技的层数:这几个盟约照常累计层数,官方也不显示(社区反馈)。 - 银灰的普通攻击使用自己的命中音效,不再借用三技能的挥砍和命中音效;雷蛇、初雪、圣约送葬人的普通攻击也不再用技能的命中音效(社区反馈)。 +- 每回合结算时跳出官方的「作战结束」框,只写自己这一回合被扣的目标生命值:没扣是「全员无伤!」,扣了是「生命值减少 −N」,并播放官方的三种战斗结束音效之一。联防回合的数字按服务器实际扣的点数;联防有敌人漏过去时不显示「全员无伤!」,没被扣的人只看到标题。最终攻势、隐秘核心,以及已淘汰的玩家和观战者不弹框(GitHub #235,PR #112,感谢 @Convey123)。 ### 联机与大厅 diff --git a/docs/PLAYING.md b/docs/PLAYING.md index 6bdf3ce0..33ef63b7 100644 --- a/docs/PLAYING.md +++ b/docs/PLAYING.md @@ -23,7 +23,7 @@ - **特殊地形点一下就有说明**:战场上的**活性源石**、**沼泽**、**排气格栅**、**深水区**,以及**红门 / 蓝门 / 传送点**,点一下就会弹出它自己的说明卡——名称、机制,以及**这一关自己的数值**(比如战场#04 的活性源石:每秒 70 点真实伤害、攻击力 +20%、攻击速度 +20、持续 300 秒)。休整期和作战期都能点。普通地板 / 道路 / 高台点了不会弹东西,所以点一下仍然只是「取消选中、关掉卡片」。 | 作战 | 每人在自己的战场各自作战(战斗在你的浏览器里模拟,2 倍速)。干员按**逐列从左到右、每列从上到下**的顺序(官方「从上到下>从左到右」:最左一列最先)自动部署并消耗部署费用(初始 10,+1/秒)。时限 45–115 秒(实际秒数,即屏幕上的倒计时),时间到仍存活的敌人算漏怪。有敌人进入蓝门时,顶栏的目标生命值会**立即**显示扣除后的数值(红字 + 「−N」,每回合至多 10 点),结算时确定;联防阶段标注「联防中」,队友拦下的部分最后不扣。左侧队伍栏同样显示队友的待扣生命值。被击倒的干员**留在倒下的格子上**(倒地姿势、略微变灰),之后也在这一格再部署(与官方一样;倒在其他干员或召唤物初始位置上的,会回到自己的初始位置躺下,召唤物已经消失也一样;唯一的例外是乌尔比安:三技能把他移到船锚处期间被击倒时,他回到自己的部署位躺下、再部署——这是项目维护者 2026-10-07 应社区反馈有意做出的改动,与官方「原地倒下」不同);因自身效果强制退场的干员(史尔特尔「余烬」结束、耀骑士临光 2 技能结束等)同样倒在原地、在原地再部署;倒地干员所在的格子**不会被占用**,突袭盟约的再部署、援军、召唤物都会避开它。头上的圆圈是再部署倒计时:绿色圆环 + 秒数;变成黄色「DP」表示部署费用不够。血条下方的一行是**元素损伤**(与原版一致:元素图标 + 白条):干员为带图标的彩色圆盘,敌人为小号纯色圆盘,右侧白条是剩余元素值,像血条一样从右往左减少,白条耗尽的那一下就是爆条(被大额元素损伤一次打掉一截时白条也会一次少一截,例如掠海漂移体每击约为 25%–50%);爆条后进入爆发冷却(灼燃 / 神经 / 侵蚀 10 秒,敌方侵蚀 8 秒,凋亡 15 秒),这时白条变成**元素颜色**(神经青绿、侵蚀蓝、灼燃橙红、凋亡灰紫)并在冷却期间慢慢回满、图标闪烁——彩色条表示已经爆条、正在承受爆发效果,不是还剩的元素值;期间所有元素都不会累积也无法被治疗,结束后清零。元素爆发造成的是**无来源伤害**,不吃干员的增伤、穿透和装备加成(例如深巡打海嗣时灼燃爆发固定 7000),击杀仍记在爆条的干员名下;元素脆弱只提高元素伤害,不加快元素条;脆弱不影响元素伤害。已被击杀的敌人不会再被附加元素损伤。 | | 联防 | 见第 5 节。 | -| 结算 | 扣 LP、发放悬赏资金、结算层数;LP ≤ 0 的玩家淘汰(其干员回到公共卡池)。 | +| 结算 | 扣 LP、发放悬赏资金、结算层数;LP ≤ 0 的玩家淘汰(其干员回到公共卡池)。结算开始时屏幕正中跳出官方的「**作战结束**」框(约 3 秒后自己消失,不挡操作),下面一行只写**你自己**这一回合被扣的目标生命值:没扣是「**全员无伤!**」,扣了是「**生命值减少 −N**」;同时播官方的战斗结束音效(扣了、联防里没扣、普通回合没扣各一种)。联防回合见第 5 节。最终攻势和隐秘核心没有这一框(之后直接是结算称号或隐秘核心);已淘汰的玩家和观战者不弹框。 | **断线与离开**:同盟模拟断线后 10 分钟内重新打开页面即可回到原座位;期间你的阵容照常自动作战,机变和休整期到时自动选择 / 准备,但不会替你购买(想让 AI 代打,请在「离开模拟」里选「暂离(AI 托管)」;AI 托管不会为了腾地方销毁你的道具,整备区满时它会卖掉备战的干员来放道具)。独立模拟的休整期与机变不计时,断线或关掉页面后 **24 小时内**用同一个浏览器打开即可继续;独立模拟的作战(含最终攻势 / 隐秘核心)还可以点顶栏的「暂停」(或按 Space)随时暂停,倒计时和领袖战的加时扣血一起停下(暂停中断线会自动继续,由服务器接着打完这场作战)。同盟模拟的作战不能暂停。「放弃模拟」:独立模拟立即结束本局;同盟模拟中视为你被淘汰(干员回到公共卡池),队友继续。服务器重启会结束所有对局。 @@ -93,6 +93,7 @@ - 联防在官方的**联防专用地图**上进行(与官方相同,不是本回合的战场):一整片道路,没有水池、箱子、装置等地形;干员按休整期的位置站好。一人协防时敌人从中间的入口进入左半场;两人协防时敌人从最右侧进入,先经过右半场,再穿过中间进入左半场。 - 只有完美作战的队友能参与联防;联防中**盟约不会叠加层数**。 +- 联防的结果**只在结算的「作战结束」框里报**(与官方一样,没有「联防成功 / 联防失败」之类的字幕)。框里的数字是服务器结算时**真正扣到你头上**的点数:漏怪的玩家按联防后还剩下的敌人扣,不是自己那场漏了几只。**「全员无伤!」只在联防一只都没放过去时出现**(这一轮谁都没被扣);联防没守住、而你自己没被扣(你是去帮忙的队友,或你漏的敌人都被拦下了),框里只有「作战结束」这个标题——掉血的队友并不是无伤,官方对话框里也没有别的句子。框的颜色:青色=联防全部拦下,橙色=联防没守住但你没掉血,红色=你被扣了。漏了几只仍然看顶栏的橙色「×N」。 - 联防中击杀悬赏敌人,资金归击杀者;没有干员击倒它时(无来源伤害、它自己扣血、其他敌人的伤害等),资金归它倒下处所在半场的队友。悬赏敌人在自己的战场上被地形(如活性源石)烫死,资金照样归你。 - 联防后仍然存活的敌人,按原主人扣 LP;没人完美作战时直接扣。 - 漏怪的玩家在顶栏中间(击倒数旁)看到橙色的「×N」标签:你漏过、仍在联防战场上的敌人数(含还没出场的,**不设上限**),队友每击倒一个就减一(会分裂或召唤的敌人被击倒时,分裂出的敌人也算你的,数字会相应增加);目标生命值同时显示为扣除 min(N, 10) 后的数值。左侧队伍栏里漏怪队友的行也显示 ×N(与战场上的击倒同步更新;鼠标悬停可看到是谁漏过的敌人)。 diff --git a/docs/design/client.md b/docs/design/client.md index 46c9fda7..9619259a 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 (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 不触发准备或暂停。 diff --git a/docs/design/match.md b/docs/design/match.md index 2c04622c..dfc67619 100644 --- a/docs/design/match.md +++ b/docs/design/match.md @@ -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]); 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)) - 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 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) diff --git a/docs/design/network.md b/docs/design/network.md index 250c8681..99241cbb 100644 --- a/docs/design/network.md +++ b/docs/design/network.md @@ -17,7 +17,7 @@ Platform additions (implemented): `welcome.{resumed, version}`, `welcome.diyKitt ### 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` 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.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). diff --git a/docs/history/0.2.0.md b/docs/history/0.2.0.md index 58e95a4b..28e22c3c 100644 --- a/docs/history/0.2.0.md +++ b/docs/history/0.2.0.md @@ -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 and an 进阶 set; and the pair counts once toward the 3 members. - 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. diff --git a/public/css/components.css b/public/css/components.css index 80bd2500..b2c52726 100644 --- a/public/css/components.css +++ b/public/css/components.css @@ -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-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 { --pc: var(--mint-400); diff --git a/public/dev/game-mock.html b/public/dev/game-mock.html index 72d98c78..2cfdb878 100644 --- a/public/dev/game-mock.html +++ b/public/dev/game-mock.html @@ -6,6 +6,7 @@ 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 |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) |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) diff --git a/public/dev/game-mock.js b/public/dev/game-mock.js index c4f9d624..dce22adf 100644 --- a/public/dev/game-mock.js +++ b/public/dev/game-mock.js @@ -394,6 +394,20 @@ function startCombat(phase) { 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); } + // 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 = () => { if (phase !== PHASE.UNITE) return; const pid = leakMock ? 'p1' : 'p3'; diff --git a/public/i18n/en.json b/public/i18n/en.json index 487f74e7..213394cc 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -394,6 +394,8 @@ "队友作战进度": "Teammate battle progress", "作战结束": "Battle over", "作战结束,等待队友完成作战": "Battle over. Waiting for teammates to finish", + "全员无伤!": "All unharmed!", + "生命值减少 −{n}": "LP reduced by {n}", "你已被淘汰,正在观战": "You have been eliminated. Spectating", "上一个战场": "Previous battlefield", "下一个战场": "Next battlefield", diff --git a/public/js/screens/game.js b/public/js/screens/game.js index 3065e4ef..cc01d2f9 100644 --- a/public/js/screens/game.js +++ b/public/js/screens/game.js @@ -6,7 +6,8 @@ // 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 // (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 // 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 @@ -65,7 +66,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from '../../vendor/hooks.module.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 { useFieldView } from '../ui/fieldHost.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 { phaseMode, phaseBanner, isCombatPhase, showDeadPill, isBossPhase, placementContext, canPlace, boardTargets, dropIntent, + battleOverSfx, uniteResultBox, battleResultBox, snapHud, activeBubbles, shortcutFor, shortcutBlocked, closesOnFieldPress, phaseTotalSeconds, homeFieldId, ownFieldId, normalizeSp, sortedPlayers, terrainInfo, 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 [hud, setHud] = 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 [spBusy, setSpBusy] = useState(null); const [layer, setLayer] = useState('ALL'); // 联防 / 最终攻势 camera: 'L' | 'ALL' | 'R' @@ -233,6 +236,23 @@ function MatchScreen() { uniteLeft: leaker ? uniteRemaining(localLeft, meP?.uniteLeft) : null, }); 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 const live = useRef({}); @@ -572,6 +592,7 @@ function MatchScreen() { // phase changes: banners, sounds, resets const phaseKey = `${phase}:${pub?.round}`; const prevPhase = useRef(null); + const resultSeq = useRef(0); // re-keys the result box (ResultDialog) at every SETTLE useEffect(() => { const prev = prevPhase.current; prevPhase.current = phase; @@ -585,6 +606,19 @@ function MatchScreen() { else if (phase === PHASE.FINAL_ASSAULT) audio.sfx(solo ? 'bossRoundSingle' : 'bossRoundTeam'); else if (phase === PHASE.HIDDEN_CORE) audio.sfx('bossRoundSecret'); else if (phase === PHASE.SP_DRAFT) audio.sfx('draft'); + 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 // 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) @@ -1373,6 +1407,9 @@ function MatchScreen() { ${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} + ${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} grid=${facing.grid} name=${facing.name} onPreview=${previewFacing} onCommit=${commitFacing} onCancel=${cancelFacing} />` : null} diff --git a/public/js/ui/components.js b/public/js/ui/components.js index 94a2469a..3d8bff30 100644 --- a/public/js/ui/components.js +++ b/public/js/ui/components.js @@ -2,7 +2,7 @@ // // Exports: html (bound htm), Icon, Button, Panel, MicroLabel, Chevrons, HexBadge, TierChip, // 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, // roman(), doctorNo(). // @@ -732,6 +732,39 @@ export function PhaseBanner({ title, sub, micro, tone = 'mint', mode = 'inline', `; } +/** + * 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`
+
+ + + ${micro ? html`${micro}` : null} +
+ <${Chevrons} count=${3} tone=${tone} class="rdialog__chev" /> + ${title} + <${Chevrons} count=${3} tone=${tone} dir="left" class="rdialog__chev" /> +
+ ${sub ? html`${sub}` : null} +
+
`; +} + // ---- domain helpers ---------------------------------------------------------------------------- /** diff --git a/public/js/ui/gameLogic.js b/public/js/ui/gameLogic.js index 7f7794b7..0e1a48f8 100644 --- a/public/js/ui/gameLogic.js +++ b/public/js/ui/gameLogic.js @@ -28,6 +28,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, teamFrameIds } from './gameLogic/camera.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 { sortedPlayers } from './gameLogic/shared.js'; export { ownFieldId, homeFieldId, cycleField, watchTarget, switcherLabel, fieldLabel, activeBubbles } from './gameLogic/watch.js'; diff --git a/public/js/ui/gameLogic/phases.js b/public/js/ui/gameLogic/phases.js index f7afe13d..45494fab 100644 --- a/public/js/ui/gameLogic/phases.js +++ b/public/js/ui/gameLogic/phases.js @@ -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 { 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 } | 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). */ export function prepCapsuleLabel(phase) { if (phase === PHASE.SP_DRAFT) return t('机变阶段'); diff --git a/server/match/Match.js b/server/match/Match.js index 570889c8..b95e5bb4 100644 --- a/server/match/Match.js +++ b/server/match/Match.js @@ -368,6 +368,8 @@ export class Match { this.watchPref = new Map(); this.lastResults = new Map(); 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) */ this._uniteLeftKey = null; /** 联防: { plan, bounds } — per leaker the most survivors settlement can bill (_uniteLeft's clamp) */ diff --git a/server/match/match/phases.js b/server/match/match/phases.js index 408d868c..58043952 100644 --- a/server/match/match/phases.js +++ b/server/match/match/phases.js @@ -229,6 +229,7 @@ export class MatchPhases { this.fields = []; this.watchers.clear(); this.unitePlan = null; + this.uniteResultView = null; this.sp = null; this.wave = null; this.bossWaves = null; diff --git a/server/match/match/settle.js b/server/match/match/settle.js index 1902c549..7c254e78 100644 --- a/server/match/match/settle.js +++ b/server/match/match/settle.js @@ -1,6 +1,6 @@ // 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 -// to every human still here, onEnd). +// the IN_BATTLE layer gains clamped by layerGainRoom, CHAR_DAMAGE tickers, eliminations, the 联防 outcome the SETTLE +// 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). 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 const uniteRan = !!(plan && uniteResult && !uniteResult.synthetic); 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(); for (const ps of alive) { 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 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.stats.lpLost += loss; ps.stats.leaks += counted; diff --git a/server/match/match/views.js b/server/match/match/views.js index af51ade5..bc99a2cb 100644 --- a/server/match/match/views.js +++ b/server/match/match/views.js @@ -1,7 +1,7 @@ // server/match/match/views.js — Match methods: the state builders — m.public (publicView with statusOf / fieldOf, the -// fields' progress and the teammates' live pendingLp / uniteLeft), the nextEnemies preview of m.private and the prep -// scout's m.field (prepFieldMeta: board, hand and temp as units, the scouted player's effects and coming enemies; in a -// boss round's prep on the player's half of the boss field). +// fields' progress, the teammates' live pendingLp / uniteLeft, the SETTLE uniteResult), the nextEnemies preview of +// m.private and the prep scout's m.field (prepFieldMeta: board, hand and temp as units, the scouted player's effects and +// coming enemies; in a boss round's prep on the player's half of the boss field). // Installed on Match.prototype by server/match/Match.js (a method container: never instantiated; `this` is the match). import { PHASE, GEO } from '../../../shared/constants.js'; @@ -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) }; + // 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; } diff --git a/test/match/playtest6-matchflow.test.js b/test/match/playtest6-matchflow.test.js index d42edd27..bbeb1c42 100644 --- a/test/match/playtest6-matchflow.test.js +++ b/test/match/playtest6-matchflow.test.js @@ -1,5 +1,6 @@ // 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. import { test } from 'node:test'; 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)`); 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(); +}); diff --git a/test/ui/gameLogic.test.js b/test/ui/gameLogic.test.js index 3d65c8ad..938d2c86 100644 --- a/test/ui/gameLogic.test.js +++ b/test/ui/gameLogic.test.js @@ -9,6 +9,7 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { 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, boardTargets, dropIntent, normalizeDraft, normalizeSp, groupEnemies, factionTypes, snapHud, bossFrac, attackInterval, fmtNum, rangeGridBox, shortcutFor, sanitizeSettings, DEFAULT_SETTINGS, normalizeResult, cycleField, fieldLabel, homeFieldId, @@ -16,6 +17,7 @@ import { } from '../../public/js/ui/gameLogic.js'; import { pairPlayers } from '../../server/match/finalAssault.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 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(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', () => { diff --git a/test/ui/resultbox.e2e.test.js b/test/ui/resultbox.e2e.test.js new file mode 100644 index 00000000..c005fffb --- /dev/null +++ b/test/ui/resultbox.e2e.test.js @@ -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(); + }); +});