From 8ae891f1f39c5cca4bcd8f236e124e109b0065d7 Mon Sep 17 00:00:00 2001 From: IceCodeNew Date: Thu, 8 Oct 2026 19:04:43 +0800 Subject: [PATCH 1/5] =?UTF-8?q?feedback7=20R22-HUD:=20the=20HP=20bar=20sho?= =?UTF-8?q?ws=20the=20ammo=20skill's=20rounds=20as=20yellow=20cells=20(?= =?UTF-8?q?=E9=9A=90=E7=8E=B0=20S2:=2014),=20=E4=BC=BA=E5=A4=9C's=20?= =?UTF-8?q?=E7=8B=BC=E5=BD=B1=20left=20as=20diamonds=20and=20=E6=96=A9?= =?UTF-8?q?=E4=B8=9A=E6=98=9F=E7=86=8A's=20=E6=88=91=E6=89=A7=20pool=20as?= =?UTF-8?q?=20a=20red=20bar=20=E2=80=94=20one=20snapshot=20change=20for=20?= =?UTF-8?q?PR=20#303=20and=20#378=20by=20@IceCodeNew=20and=20PR=20#383=20b?= =?UTF-8?q?y=20@Convey123;=20golden:=20unchanged?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three PRs patched the same snapshot and HUD lines (Grok's review: do not merge three diffs), so this is one design built from their code and tests; IceCodeNew is the commit author (#303, #378), Convey123 co-author (#383). - b.snap gains three optional, display-only lists beside the unchanged 9-field tuples (snapshot.js ammoView / wolfView / negView, called from battle/events.js snapshot(); a living, deployed, visible ally, only when it has one): ammo [[id, rounds left, magazine]] (whole rounds; the magazine is the activation's real total that unitTuple's SP fraction runs on, extras and refills included), wolves [[id, 狼影 left, talent maximum]] (mem.shadows of the new mem.wolfCapacity, set by tokens.js wolfPack and the managed vigil kit), neg [[id, fill]] (unit.negFill(), a function op-hsgma2.js sets: the pool is read when the snapshot is taken, no per-tick sync). UnitInfo.ammoSkill (an ally whose skill is an ammo magazine) is known from the first appearance: fx events run before the frame's sample, so the aura cannot wait for the first ammo row. - render/interp.js keeps the lists per snapshot (malformed rows / unknown ids dropped) and sample() hands them out from the older snapshot like flags (they step with the skill flag); render/units.js draws them: the ammo magazine replaces the SP bar (a cell per round, thin cuts, exact fill, no cuts under 2 px a cell), a row of diamonds under the bars for the wolves (the element gauge row moves below), the red bar over the drained HP bar for the pool. An ammo skill draws no standing aura (rings.js _aura; the activation burst stays). - PR #378's wolf-head avatars are NOT taken: the count is data (the talent text 至多3只, mem.shadows), the HUD art of the official counter is in no game data we have, so the pips are plain diamonds [ASSUMED look]. The PRs' own tests are folded in and extended (test/sim/hud-readouts.test.js, test/render/hud-readouts.test.js, op_hsgma2.test.js). - Docs: network.md, SIM.md §9, client.md, PLAYING.md, types/core.js. - Golden: unchanged (283 scenarios; the canon hashes units / down / elem only, no sim state moved). - Tests: 4 + 14 new + 1 in op_hsgma2 (12), all fail on the parent; 344 related tests pass; lint 0 errors, typecheck, check:imports, docs tests. Co-authored-by: Convey123 --- docs/PLAYING.md | 1 + docs/SIM.md | 8 +- docs/design/client.md | 2 +- docs/design/network.md | 2 +- public/js/render/app/info.js | 3 + public/js/render/fx/rings.js | 7 +- public/js/render/interp.js | 54 ++- public/js/render/style.js | 5 + public/js/render/units.js | 104 +++++- server/sim/battle/events.js | 19 +- .../content/kits/ops/chess_char_3_19-vigil.js | 1 + server/sim/content/kits/ops/op-hsgma2.js | 3 + server/sim/content/tokens.js | 1 + server/sim/snapshot.js | 45 ++- server/sim/units.js | 3 + test/content/op_hsgma2.test.js | 27 ++ test/render/hud-readouts.test.js | 349 ++++++++++++++++++ test/sim/hud-readouts.test.js | 97 +++++ types/core.js | 18 +- 19 files changed, 721 insertions(+), 28 deletions(-) create mode 100644 test/render/hud-readouts.test.js create mode 100644 test/sim/hud-readouts.test.js diff --git a/docs/PLAYING.md b/docs/PLAYING.md index f357981a..90ecfaf8 100644 --- a/docs/PLAYING.md +++ b/docs/PLAYING.md @@ -80,6 +80,7 @@ - **推开 / 拉拽**按官方「力度 − 重量」决定距离:例如野鬃二技能(中等力度)把重量 0 / 1 / 2 / 3 的敌人推开约 2.1 / 1.7 / 0.4 / 0.1 格,重量 4 以上推不动——轻型敌人反而推得更远。野鬃二技能沿她的朝向推开:正前方 45° 以内按上面的距离,身后、侧面或贴身的敌人只被轻推一下(约 0.4 格以内);琳琅诗怀雅三技能的金币则从她身边向外推开,落在她前方 2–4 格的范围(官方「范围2-4」)内的地面敌人和她阻挡的敌人身上。攻击速度最低为 20(沼泽、寒霜等减速叠满时也不会更慢)。 - **生命回复不是治疗**(与官方一致):吟游者的特性(魔王、浊心斯卡蒂及其海嗣)、调香师的天赋、瑕光二技能、铃兰三技能、锡人炼金单元给友军的「每秒恢复生命」是提高对方的**生命回复速度**,不是治疗:收割者 / 不屈者 / 武者(如隐德来希、圣约送葬人、折桠)这类「无法被友方治疗」的干员和处于禁疗的干员也能吃到,治疗量加成(如「受到的治疗效果提升」)也不会放大它;铃兰三技能开启后的第一秒没有回复。直接治疗(医疗干员的治疗、技能的「恢复…生命」)仍然对这些干员无效。 - **元素损伤条**在单位自己的血条正下方:左边是元素图标,白条是剩余的元素值,只有真正爆发时才会耗尽;爆发后条变成元素的颜色、图标闪烁,在冷却期间慢慢回满(这段时间不会再积累元素损伤)。 +- **弹药、狼影和「我执」**也画在血条上(0.2.2):弹药类技能(如隐现的二技能,14 发)开启时,血条下方的技力条换成**分格的黄色弹药条**——一格一发,打一发少一格,补弹时回来;这类技能开启期间脚下不再有持续的金色光环。伺夜的狼群在血条下方有一排**菱形**,亮的是剩下的狼影,暗的是已用掉的空位(最多 3 个),进入战术点形态时消失。斩业星熊进入「我执」后,绿色血条停在 1 点,超出的伤害涨成血条上的**红条**,红条满了才会倒下。这些只是显示,不改变任何战斗数值。 - **敌人**:远程敌人的射程按官方从干员的碰撞体积(半径 0.25 格)算起,比数据上的攻击范围半径远 0.25 格——例如萨卡兹枯朽战车(射程 2.2)能打到中心 2.45 格以内、站在低地的干员。没被阻挡的远程敌人每次攻击都会站定、播完攻击动作,两次攻击之间才继续前进。它的第一下攻击就是「秽蚀轰击」(在目标处留下半径 1.7、持续 10 秒的污染区:低地每秒 50、高台 25 点真实伤害,同名不叠加),被阻挡时普通攻击伤害翻倍。“萨科塔之翼”等血量第一次低于一半时会在原地小范围乱飞 5 秒(官方叫「恐惧」);叙拉古、妮芙等干员造成的恐惧会让敌人朝远离干员的方向逃跑。隐匿的敌人只有被阻挡(或被揭示)时才能被选为攻击目标,溅射、技能的圆形范围伤害也打不到没被挡住的隐匿敌人(和官方一样);阻挡解除后 3 秒内它仍然可以被选中(官方页面注明 0 秒或 1 秒的敌人按页面),之后才重新隐匿:深池逐火战士每次被击倒后先重生 1 秒,然后变成继续往前走的隐匿「余烬」(需要 5 次伤害击倒,护卫的「火灰」10 次),一般要用干员挡住它才能选中(挡住时它不再半透明;被阻挡时被击倒的,重生结束后约 2 秒内也能用远程或范围伤害补刀),10 秒内没打掉就满血站起来;敌人重生时身上的减速、恐惧等状态会被清空、技能冷却从头开始,被恐惧的逐火战士变成余烬后也能被挡住;转译基底·α 原始形态受到的伤害全部无效,受到第 4 次物理伤害、第 4 次法术伤害或被阻挡时花 2 秒变成寻仇者、特战术师或幽灵,之后才能被击倒。 - **复活**(策略「埃芒加德」的前 3 次、M3茧甲)与官方一致:算作一次被击倒,紧接着在倒下的位置免费立即再部署——干员「被击倒时」和「部署时」的效果都会触发(例如砾的不屈 / 卡西米尔层数、瑕光的层数、卡西米尔盟约的部署攻击力),技力和身上的增益像普通再部署一样重置。 - **策略「鸭爵」(“神秘顾客”)**:从第 5 回合起,你和队友的每场作战(**最终攻势和隐秘核心也算**,联防不另外替换)有 0–2 名地面敌人(不含领袖及其部件、悬赏敌人)被替换为特殊版本的鸭爵、高普尼克、流泪小子或圆仔,出现在被替换敌人的时间和路线上(两人同场的领袖战里,各自只替换走向自己保护目标点的敌人);击倒者获得 1 资金(联防中的队友也算),漏进保护目标点照常扣 1 点目标生命值。鸭爵和圆仔**不攻击、无法被阻挡**:鸭爵受到伤害后移动速度 +300%;圆仔始终面向我方干员较多的一侧,来自正面的物理 / 法术伤害 −80%。流泪小子隐匿(被阻挡时才能被选中),攻击使目标晕眩 3.5 秒,被晕眩的干员挡不住它(官方规则);它被放开后要过 1 秒才重新隐匿(官方是 0 秒,本模式有意放宽,维护者 2026-10-07 的决定);高普尼克免疫晕眩、冻结和浮空。 diff --git a/docs/SIM.md b/docs/SIM.md index 08a53a3f..094ee03b 100644 --- a/docs/SIM.md +++ b/docs/SIM.md @@ -1296,7 +1296,7 @@ Unknown subprofessions fall back to the profession default (test `professions.te ## 9. Wire format (snapshot.js, DESIGN §8.2) -- `snapshot()` → `{ fieldId, t, units: [[id, x, y, hp, maxHp, sp, spMax, flags, anim]], dp, killed, total, dps?, boss?, down?, elem? }`. +- `snapshot()` → `{ fieldId, t, units: [[id, x, y, hp, maxHp, sp, spMax, flags, anim]], dp, killed, total, dps?, boss?, down?, elem?, ammo?, wolves?, neg? }`. `sp/spMax` show remaining duration/ammo as a draining bar while a timed skill is active (ammo: `ammoLeft / ammoMax`, the activation's real total — 拉特兰's and 逃犯引渡手续's extra bullets included, community report #35). Units in DIE state stay 0.8 s. Active finite zero-SP duration skills display `timeLeft/duration` using `duration` as `spMax`; after end they show @@ -1307,7 +1307,11 @@ Unknown subprofessions fall back to the profession default (test `professions.te respawn timer; `state` = constants.js `DOWN_STATE`: 0 counting, 1 timer done / DP short, 2 timer done / its tile taken — a safeguard: no ally deploys on a knocked-out operator's tile, §1; `row, col` = the tile it lies on and comes back on, `unit.body`); `elem: [[id, element, fill, - cooldownEnd, cooldown]]` (only when non-empty) = `elementView` of every unit with a gauge or a running 爆发冷却 (§3). + cooldownEnd, cooldown]]` (only when non-empty) = `elementView` of every unit with a gauge or a running 爆发冷却 (§3); + `ammo: [[id, left, magazine]]`, `wolves: [[id, left, max]]` and `neg: [[id, fill]]` (each only when non-empty; display only) = the HP-bar + readouts of `snapshot.js` `ammoView` (an ally's running ammo skill: whole rounds, the magazine = `ammoMax`), `wolfView` (伺夜's 狼群: + `mem.shadows` of `mem.wolfCapacity`) and `negView` (`unit.negFill()`, set by 斩业星熊's T1 业火: the 我执 pool as a share of its cap); + UnitInfo `ammoSkill` marks an ally whose skill is an ammo magazine (DESIGN §8.2). `fieldMeta()` lists the knocked-out operators too (a client joining mid-battle shows them; DESIGN §18.3). - `drainEvents()` tuples: `['spawn', UnitInfo]` (first appearance), `['deploy', id]` (every (re)deploy and 【移动】 — the client plays the deploy and its interpolation snaps instead of sliding), `['atk', src, tgt, projKind]` (`none|arrow|bolt|bomb|lob|orb|drone|enemy|boomerang|droneBomb|chain|chainHeal`; a boomerang's way back has no event — the diff --git a/docs/design/client.md b/docs/design/client.md index 5a3db6c4..1d9e476a 100644 --- a/docs/design/client.md +++ b/docs/design/client.md @@ -29,7 +29,7 @@ view.holdPiece(uid, {row,col}|null) // keep a dropped piece on a tile w view.setPieceDir(uid, dir) // show a prep piece facing UP|RIGHT|DOWN|LEFT (wheel preview / stored dir) view.resize() ; view.destroy() ``` -Visuals: oblique perspective board (tilted ~30°, trapezoid tiles), raised highland blocks with side faces, road/floor/forbidden styles per research 07 §7, pulsing red gates, blue objective, dashed hand pads. Units: Spine (Front/Back rule, feet anchored — flying units hover `FLY_HOVER` 1.3 tiles up, the client's 0.35 in character space, research 12, §25.13.5 — an enemy flyer from the road whatever tile it crosses (GitHub #277) —, shadow sprite on the ground), elite gold outline glow, tier chip, HP bar (green ally / red enemy), SP bar (skill ready glow), status icons, the element gauge row under the bars — icon + white bar of the remaining 元素值, element-coloured refill during a 爆发冷却 (§18.3, §20.8); enemies drawn × their official prefab factor (enemies.json `modelScale`, §20.4); an enemy's mode — the `form` of a sim fx (`shared/protocol.js fxForm`), or UnitInfo `form` for a view built later through `render/app/info.js renderInfo` — switches clip sets (`render/units.js FORMS`: 掠海漂移体 crawl, 暴鸰 bombed, 转译基底's forms, the 逐火 embers, the leaders' 重生, 守墓石像, the 孤岛风云 prisoners' warning and 解放; closing clips timed from the fx `dur`), a one-off cast (`PROJ[kind].once`) plays once, and a local-only model's web alias is tinted (`ALIAS_TINT`: 灼热 / 炽焰源石虫, §21.14); damage numbers (phys orange-white, arts purple, true white, heal green); projectiles drawn per kind, hit sparks and melee slashes, skill activation bursts and auras (§17.3); death fade for enemies, summons and devices — a knocked-out operator stays on its tile in its held Die pose under a redeploy ring (§18.3); leak flash at the objective. Fallback when Spine missing/failed: avatar in a rarity-coloured diamond with bob/lunge tweens — a failed or timed-out model is loaded again (bounded), and a view re-resolves its model and avatar when the asset manifest arrives late or the tab is shown again (§21.27). 60 fps target with ≤ 120 units. Picking (which unit a press / hover / dropped item is on) and the drag target follow one rule, `render/pick.js` — the tile under the pointer (§18.1); the Spine store never hands out a skeleton whose unload is in flight (§17.1). +Visuals: oblique perspective board (tilted ~30°, trapezoid tiles), raised highland blocks with side faces, road/floor/forbidden styles per research 07 §7, pulsing red gates, blue objective, dashed hand pads. Units: Spine (Front/Back rule, feet anchored — flying units hover `FLY_HOVER` 1.3 tiles up, the client's 0.35 in character space, research 12, §25.13.5 — an enemy flyer from the road whatever tile it crosses (GitHub #277) —, shadow sprite on the ground), elite gold outline glow, tier chip, HP bar (green ally / red enemy), SP bar (skill ready glow; while an ammo skill runs it is the magazine instead — a yellow cell per round, emptying from the right, `b.snap ammo`, and the skill draws no standing aura, UnitInfo `ammoSkill`), 斩业星熊's 我执 pool as a red bar over her drained HP bar (`b.snap neg`), 伺夜's 狼群 count as a row of diamonds under the bars, the lit ones the 狼影 left (`b.snap wolves`; 0.2.2, GitHub PR #303 / #378 / #383), status icons, the element gauge row under the bars — icon + white bar of the remaining 元素值, element-coloured refill during a 爆发冷却 (§18.3, §20.8); enemies drawn × their official prefab factor (enemies.json `modelScale`, §20.4); an enemy's mode — the `form` of a sim fx (`shared/protocol.js fxForm`), or UnitInfo `form` for a view built later through `render/app/info.js renderInfo` — switches clip sets (`render/units.js FORMS`: 掠海漂移体 crawl, 暴鸰 bombed, 转译基底's forms, the 逐火 embers, the leaders' 重生, 守墓石像, the 孤岛风云 prisoners' warning and 解放; closing clips timed from the fx `dur`), a one-off cast (`PROJ[kind].once`) plays once, and a local-only model's web alias is tinted (`ALIAS_TINT`: 灼热 / 炽焰源石虫, §21.14); damage numbers (phys orange-white, arts purple, true white, heal green); projectiles drawn per kind, hit sparks and melee slashes, skill activation bursts and auras (§17.3); death fade for enemies, summons and devices — a knocked-out operator stays on its tile in its held Die pose under a redeploy ring (§18.3); leak flash at the objective. Fallback when Spine missing/failed: avatar in a rarity-coloured diamond with bob/lunge tweens — a failed or timed-out model is loaded again (bounded), and a view re-resolves its model and avatar when the asset manifest arrives late or the tab is shown again (§21.27). 60 fps target with ≤ 120 units. Picking (which unit a press / hover / dropped item is on) and the drag target follow one rule, `render/pick.js` — the tile under the pointer (§18.1); the Spine store never hands out a skeleton whose unload is in flight (§17.1). --- diff --git a/docs/design/network.md b/docs/design/network.md index 384b3c9c..fb1af4b7 100644 --- a/docs/design/network.md +++ b/docs/design/network.md @@ -23,7 +23,7 @@ S→C: - NOTE: game time in `b.snap`/`b.ev` is carried as **`gt`** (the frame's `t` is the message type). - Active finite zero-SP timed skills display `sp=timeLeft`, `spMax=duration` and `UF.SKILL`; after end they display `0/0`. Snapshot display capacity does not change runtime SP/cost. -- `b.snap` — `{ fieldId, gt, units: [[id, x, y, hp, maxHp, sp, spMax, flags, anim]] , dp, killed, total, down?, elem? }` 20 Hz (a countdown summon's `hp` is maxHp × the share of its life left, §25.16.5). Optional, only when non-empty (§18.3): `down: [[id, respawnAt (game s), respawnTime (s), state, row, col]]` — knocked-out operators waiting to redeploy, `state` 0 counting | 1 timer done, waiting for DP | 2 timer done, its tile taken — a safeguard, no ally deploys there (sim/constants.js `DOWN_STATE`); `row, col` = the tile it lies on and comes back on (§21.24); `elem: [[id, element, fill, cooldownEnd (game s), cooldown (s)]]` — the one gauge a unit shows: the fullest (ties by the official element id 神经 < 侵蚀 < 灼燃 < 凋亡), fill with 2 decimals rounded DOWN and kept at 0.01–0.99 outside a burst (§20.8); during a 爆发冷却 the bursting element with fill 1 and the cooldown numbers (else 0, 0) +- `b.snap` — `{ fieldId, gt, units: [[id, x, y, hp, maxHp, sp, spMax, flags, anim]] , dp, killed, total, down?, elem?, ammo?, wolves?, neg? }` 20 Hz (a countdown summon's `hp` is maxHp × the share of its life left, §25.16.5). Optional, only when non-empty (§18.3): `down: [[id, respawnAt (game s), respawnTime (s), state, row, col]]` — knocked-out operators waiting to redeploy, `state` 0 counting | 1 timer done, waiting for DP | 2 timer done, its tile taken — a safeguard, no ally deploys there (sim/constants.js `DOWN_STATE`); `row, col` = the tile it lies on and comes back on (§21.24); `elem: [[id, element, fill, cooldownEnd (game s), cooldown (s)]]` — the one gauge a unit shows: the fullest (ties by the official element id 神经 < 侵蚀 < 灼燃 < 凋亡), fill with 2 decimals rounded DOWN and kept at 0.01–0.99 outside a burst (§20.8); during a 爆发冷却 the bursting element with fill 1 and the cooldown numbers (else 0, 0); the three HP-bar readouts (0.2.2, GitHub PR #303 / #378 / #383, display only — no sim state reads them, the nine-field tuples and the SP fraction an ammo skill still puts in them are unchanged; `render/interp.js` keeps them per snapshot and `sample()` hands them out from the older snapshot like `flags`, so they step with the skill flag; a malformed entry or an id the snapshot does not list is dropped, a snapshot without the list clears the readout): `ammo: [[id, rounds left, rounds in the magazine]]` — an ally's running ammo skill, whole rounds, the magazine being the activation's real total (extra bullets and refills included; 隐现's S2 holds 14, skill_table `attack@trigger_time`) — the client draws yellow cells in place of the SP bar (the count, not a pixel copy of the official magazine widget — its art is in no game data we have), and UnitInfo `ammoSkill: true` (an ally whose skill is an ammo magazine, known from its first appearance) tells it to draw no standing skill aura; `wolves: [[id, 狼影 left, the talent's maximum]]` — 伺夜's 狼群 (`mem.shadows` / `mem.wolfCapacity`, both the placed and the automatic pack), one diamond per slot under the HP bar (plain diamonds: no official counter art is known, the portrait of PR #378 was not taken), nothing in the 战术点形态; `neg: [[id, fill]]` — the share (0.01–1) of its cap a negative-HP pool holds (斩业星熊's 我执, `unit.negFill`), drawn as the red bar over her drained HP bar. These show only for a living, deployed, visible unit and only when it has one - `b.ev` — `{ fieldId, ev: [ ... ] }` event tuples: `['spawn', UnitInfo]`, `['atk', srcId, tgtId, projKind]`, `['dmg', tgtId, amount, type]`, `['heal', tgtId, amount]`, `['skill', id, on:0|1]`, `['die', id, reason]` (reason `'killed'`, `'retreat'`, `'expired'` …; only `'killed'` plays an operator's knock-down sound), `['status', id, key, on]`, `['fx', kind, x, y, extra]` (`'hitCap' {id, n}` = a cancelled 限伤 hit, drawn as nothing, §20.12; `extra.form` = the unit's model form from then on (an enemy's mode, a 傀儡师's 替身 §22.11) — `shared/protocol.js fxForm`, state like a spawn: never dropped by the client, §21.4), `['layer', playerId, bondId, n]`, `['bounty', playerId, coins]`, `['deploy', id]` - `m.toast {kind, text}` · `m.ticker {text, id, type, priority, playerId}` (the strip plays the highest priority first, §21.10) · `m.emote {playerId, id}` · `m.result {...}` - `m.unitStats {seq, round, units: [unitStatsEntry]}` — the answer to `g.unitStats` (`seq` echoed): every own board operator / summon by uid with `{id, uid, defId, hp, alive, maxHp, atk, def, res, interval, blockCnt, moveSpeed, range?, base: {…same}}` (shared/protocol.js `unitStatsEntry`; `range` = an ally's live range grid, facing RIGHT, never a target-selection grid, §21.16) as its next battle starts (§18.5) diff --git a/public/js/render/app/info.js b/public/js/render/app/info.js index 1eaa8306..51885de7 100644 --- a/public/js/render/app/info.js +++ b/public/js/render/app/info.js @@ -37,6 +37,9 @@ export function renderInfo(u) { // DESIGN §16 loadout of an ally (UnitInfo.skillIndex / moduleId): the Spine actor plays that skill's clip, and a // tap hands them to the detail card (a teammate's unit shows its owner's skill / module) skillIndex: Number.isInteger(u.skillIndex) ? u.skillIndex : undefined, + // an ally whose skill is an ammo magazine (UnitInfo.ammoSkill): its rounds are the cells under the HP bar, so no sustained + // skill aura is drawn for it (render/fx/rings.js _aura) + ammoSkill: u.ammoSkill === true ? true : undefined, moduleId: typeof u.moduleId === 'string' ? u.moduleId : undefined, // the ally's equipped item ids (UnitInfo.items, DESIGN §16 / §21.11): the detail card needs them for a teammate's // unit (resolveDetail `unitItems` → the read-only 装备 section and the 变形同构体 pairing chips); the owner's own diff --git a/public/js/render/fx/rings.js b/public/js/render/fx/rings.js index 11f2907c..3c0fe892 100644 --- a/public/js/render/fx/rings.js +++ b/public/js/render/fx/rings.js @@ -74,8 +74,13 @@ export class FxRings { this._aura(view, true); } - /** Active-skill aura: a soft gold glow and a slowly turning hex on the ground under the unit (fading in / out). */ + /** + * Active-skill aura: a soft gold glow and a slowly turning hex on the ground under the unit (fading in / out). An ammo + * skill (UnitInfo.ammoSkill) has none: its magazine is the yellow cells under the HP bar, which shrink with every round + * — a standing glow would read as a buff that never changes. The activation burst (skill()) is the same for all. + */ _aura(view, on) { + if (on && view.info?.ammoSkill) return; const P = this.P; let a = this.auras.get(view.id); if (on) { diff --git a/public/js/render/interp.js b/public/js/render/interp.js index a3d99c8a..ad54a7bd 100644 --- a/public/js/render/interp.js +++ b/public/js/render/interp.js @@ -20,14 +20,21 @@ // game s behind) are dropped by the caller's choice (`isCosmeticEvent`); state events — an enemy's form fx // included — are always delivered, and a full queue sheds only cosmetic ones. // -// Snapshot tuple layout (DESIGN §8.2): [id, x, y, hp, maxHp, sp, spMax, flags, anim]. Two optional lists ride along -// (server/sim/battle/events.js snapshot, user playtest #4 items 8 / 9): +// Snapshot tuple layout (DESIGN §8.2): [id, x, y, hp, maxHp, sp, spMax, flags, anim]. Optional lists ride along +// (server/sim/battle/events.js snapshot; the first two from user playtest #4 items 8 / 9): // * `elem` [[id, element, fill, cooldownEnd, cooldown]] — the element gauge a unit shows: appended to that unit's // normalised tuple (EL…EL_DUR) and handed out by sample() as `el`, `elFill`, `elUntil`, `elDur` (from the older // snapshot, like flags); // * `down` [[id, respawnAt, respawnTime, state, row?, col?]] — knocked-out operators waiting to redeploy (they are no // longer in `units`) and the tile they lie on (where they fell, or their home — sim Battle._layBody; kept only when -// both are integers): downAt(time) returns the list of the snapshot at `time`. +// both are integers): downAt(time) returns the list of the snapshot at `time`; +// * three HP-bar readouts, kept per snapshot beside the tuples (the tuple layout is unchanged) and handed out by sample() +// from the older snapshot like flags, so they step with the skill flag instead of sliding: +// `ammo` [[id, rounds left, rounds in the magazine]] → sample().ammo [left, magazine] | null (whole numbers only) +// `wolves` [[id, 狼影 left, the talent's maximum]] → sample().wolves [left, maximum] | null +// `neg` [[id, fill]] (0.01–1: the negative-HP pool's share of its cap) → sample().neg number (0: none) +// An entry that is malformed, or names a unit the snapshot does not list, is dropped; a snapshot without the list +// clears the readout (render/units.js: the segmented ammo bar, the wolf pips, the red bar of 斩业星熊's 我执). // Game times in both (`cooldownEnd`, `respawnAt`) are on the snapshots' clock, so a view compares them with renderT. import { fxForm } from '../../../shared/protocol.js'; @@ -66,8 +73,9 @@ export function frameTime(msg) { /** * Validate & normalise a b.snap payload. Returns `{ t, units: Map, down: [[id, respawnAt, respawnTime, - * state, row?, col?]] | null, raw }` or null when unusable. Tuples with a non-finite id/x/y are skipped; other numbers default - * to 0; a unit's `elem` entry (see header) is appended to its tuple; malformed `elem` / `down` entries are dropped. + * state, row?, col?]] | null, ammo, wolves: Map id → [left, max] | null, neg: Map id → fill | null, raw }` or null when unusable. + * Tuples with a non-finite id/x/y are skipped; other numbers default to 0; a unit's `elem` entry (see header) is appended to + * its tuple; malformed `elem` / `down` / `ammo` / `wolves` / `neg` entries are dropped. */ export function normalizeSnapshot(snap) { if (!snap || typeof snap !== 'object') return null; @@ -90,6 +98,14 @@ export function normalizeSnapshot(snap) { if (tu && tu.length === 9) tu.push(e[1], clamp(finite(e[2]), 0, 1), finite(e[3]), Math.max(0, finite(e[4]))); } } + const ammo = countList(snap.ammo, units), wolves = countList(snap.wolves, units); + let neg = null; + if (Array.isArray(snap.neg)) { + for (const e of snap.neg) { + if (!Array.isArray(e) || !units.has(e[0]) || !(typeof e[1] === 'number' && e[1] > 0)) continue; + (neg || (neg = new Map())).set(e[0], Math.min(1, e[1])); + } + } let down = null; if (Array.isArray(snap.down)) { for (const d of snap.down) { @@ -99,7 +115,22 @@ export function normalizeSnapshot(snap) { (down || (down = [])).push(e); } } - return { t, units, down, raw: snap }; + return { t, units, down, ammo, wolves, neg, raw: snap }; +} + +/** + * `[[id, left, max]]` → Map id → [left, max] for the ids in `units`: whole numbers with 0 ≤ left ≤ max and max ≥ 1 (the + * `ammo` and `wolves` lists); null without a valid entry. + */ +function countList(list, units) { + if (!Array.isArray(list)) return null; + let m = null; + for (const e of list) { + if (!Array.isArray(e) || !units.has(e[0]) || !Number.isSafeInteger(e[1]) || !Number.isSafeInteger(e[2]) + || e[1] < 0 || e[2] < 1 || e[1] > e[2]) continue; + (m || (m = new Map())).set(e[0], [e[1], e[2]]); + } + return m; } export class SnapshotBuffer { @@ -265,8 +296,9 @@ export class SnapshotBuffer { /** * Interpolated state at `time` (default renderT). Fills and returns `out` (a Map id → sample object reused - * across calls: `{ id, x, y, hp, maxHp, sp, spMax, flags, anim, vx, vy, seen, el, elFill, elUntil, elDur }` — - * `el` = the shown element gauge (null: none), see header). Samples of units no longer present are deleted from `out`. + * across calls: `{ id, x, y, hp, maxHp, sp, spMax, flags, anim, vx, vy, seen, el, elFill, elUntil, elDur, ammo, wolves, + * neg }` — `el` = the shown element gauge (null: none), `ammo` / `wolves` / `neg` the HP-bar readouts (null / null / 0: + * none), see header). Samples of units no longer present are deleted from `out`. */ sample(time = this.renderT, out = new Map()) { const s = this.snaps; @@ -282,7 +314,7 @@ export class SnapshotBuffer { const stamp = A.t; for (const [id, a] of A.units) { let o = out.get(id); - if (!o) { o = { id, x: 0, y: 0, hp: 0, maxHp: 0, sp: 0, spMax: 0, flags: 0, anim: 0, vx: 0, vy: 0, seen: 0, el: null, elFill: 0, elUntil: 0, elDur: 0 }; out.set(id, o); } + if (!o) { o = { id, x: 0, y: 0, hp: 0, maxHp: 0, sp: 0, spMax: 0, flags: 0, anim: 0, vx: 0, vy: 0, seen: 0, el: null, elFill: 0, elUntil: 0, elDur: 0, ammo: null, wolves: null, neg: 0 }; out.set(id, o); } const b = B ? B.units.get(id) : null; if (b) { const dx = b[1] - a[1], dy = b[2] - a[2]; @@ -317,6 +349,10 @@ export class SnapshotBuffer { o.flags = a[7]; o.anim = a[8]; if (a.length > 9) { o.el = a[9]; o.elFill = a[10]; o.elUntil = a[11]; o.elDur = a[12]; } else if (o.el !== null) { o.el = null; o.elFill = 0; o.elUntil = 0; o.elDur = 0; } + // the HP-bar readouts come with the older snapshot, like flags (whole rounds step with the skill flag) + o.ammo = A.ammo ? A.ammo.get(id) || null : null; + o.wolves = A.wolves ? A.wolves.get(id) || null : null; + o.neg = A.neg ? A.neg.get(id) || 0 : 0; o.seen = stamp; } for (const id of out.keys()) if (!A.units.has(id)) out.delete(id); diff --git a/public/js/render/style.js b/public/js/render/style.js index e93cb397..c0dd24b9 100644 --- a/public/js/render/style.js +++ b/public/js/render/style.js @@ -60,9 +60,14 @@ export const COLORS = Object.freeze({ hpBoss: 0xff2d55, hpGhost: 0xfff0c8, hpBack: 0x0c0f0e, + // the red bar of a negative-HP pool (斩业星熊's 我执, b.snap `neg`) — the same red as an enemy's HP + hpNeg: 0xff4b3e, sp: 0x6fd3ff, spReady: 0xffe066, spActive: 0xffb347, + // the ammo bar's yellow cells (b.snap `ammo`) and 伺夜's 狼影 pips (b.snap `wolves`) + ammo: 0xffd04a, + wolf: 0xe8f0ff, shield: 0xdfe8ff, }); diff --git a/public/js/render/units.js b/public/js/render/units.js index 4e38aaf7..b385fe42 100644 --- a/public/js/render/units.js +++ b/public/js/render/units.js @@ -184,6 +184,17 @@ export const ALIAS_TINT = Object.freeze({ enemy_1305_mhslim: 0xffc48a, enemy_130 * between `pulse` and 1 at `pulseHz` (real time). */ export const EL_BAR = Object.freeze({ icon: 0.15, min: 8, max: 15, enemy: 0.8, gap: 1, pulse: 0.45, pulseHz: 1.5 }); +/** + * The HP-bar readouts under the bars (b.snap `ammo`, `wolves`, `neg`; render/interp.js header): + * - AMMO_BAR — the ammo skill's segmented bar replaces the SP bar: a cell per round, `height` of the HP bar's thickness + * (the SP bar's is 0.6), cells drawn only while they are at least `minCell` px wide; + * - WOLF_PIPS — 伺夜's 狼影 count as `size` (tiles) wide diamonds, clamped to `min`…`max` px, `gap` px apart, `pad` px + * below the rows above; a lit diamond is a 狼影 left, a dim one a spent slot (`dim` alpha). + * [ASSUMED] the look: the count is data (skill_table `attack@trigger_time` 14 for 隐现's S2; the talent text 「至多3只」), the + * official HUD art of neither is in the game data. + */ +export const AMMO_BAR = Object.freeze({ height: 0.85, minCell: 2 }); +export const WOLF_PIPS = Object.freeze({ size: 0.1, min: 5, max: 9, gap: 2, pad: 1.5, dim: 0.25 }); /** * Enemy modes drawn with another clip set of the same skeleton (the `form` of a sim fx — shared/protocol.js fxForm — @@ -407,6 +418,9 @@ export class UnitView { this.visFacing = this.isEnemy ? -1 : this.facing; this.hp = Number(info.maxHp) || 1; this.maxHp = Number(info.maxHp) || 1; this.ghostHp = this.hp; this.sp = 0; this.spMax = 0; + this.ammo = null; // [rounds left, rounds in the magazine] while an ammo skill runs (sync; b.snap `ammo`) + this.wolves = null; // [狼影 left, the talent's maximum] of a 狼群 (sync; b.snap `wolves`) + this.neg = 0; // the share of its cap a negative-HP pool holds, 0 = none (sync; b.snap `neg`, 斩业星熊's 我执) this.flags = 0; this.anim = ANIM.IDLE; this.statuses = new Set(); this.alive = true; @@ -417,6 +431,8 @@ export class UnitView { this.gameT = 0; // battle game time of the frame (render clock), for the redeploy ring's countdown and the element bar's refill this.el = null; this.elFill = 0; this.elUntil = 0; this.elDur = 0; // shown element gauge (sync) this._elBar = null; // { root, disc, bg, fill } sprites of the element gauge row, built on first use + this._wolfPips = null; // { root, back[], lit[] } diamonds of the 狼影 row, built on first use + this.ammoCuts = []; // the thin separators between the ammo bar's cells (_updateAmmoCuts) this._downRing = null; // { disc, track, arc, text } sprites, built on first use this.alpha = 1; this.fadeIn = this.prep ? 1 : 0; this.lunge = 0; this.lungeDir = { x: 1, y: 0 }; @@ -649,6 +665,8 @@ export class UnitView { this.hpBg = bar(P, h, COLORS.hpBack, 0.85); this.hpGhost = bar(P, h, COLORS.hpGhost, 0.9); this.hpFill = bar(P, h, this.isEnemy ? (this.isBoss ? COLORS.hpBoss : COLORS.hpEnemy) : COLORS.hpAlly); + // a negative-HP pool (斩业星熊's 我执), drawn over the drained HP bar as the red bar + this.negFill = bar(P, h, COLORS.hpNeg); this.shieldBar = bar(P, h, COLORS.shield, 0.95); this.spBg = bar(P, h, COLORS.hpBack, 0.85); this.spFill = bar(P, h, COLORS.sp); @@ -658,7 +676,7 @@ export class UnitView { this.spGlow.blendMode = P.BLEND_MODES.ADD; h.addChild(this.spGlow); // nothing shows until the first HUD update decides (a culled or prep view never draws bars) - for (const b of [this.hpBg, this.hpGhost, this.hpFill, this.shieldBar, this.spBg, this.spFill, this.spGlow]) b.visible = false; + for (const b of [this.hpBg, this.hpGhost, this.hpFill, this.negFill, this.shieldBar, this.spBg, this.spFill, this.spGlow]) b.visible = false; this.chip = null; // operators only: summon tokens have no tier (hand and field alike) if (!this.isEnemy && !this.isToken && this.info.kind !== 'device') { @@ -744,6 +762,7 @@ export class UnitView { if (hp < this.hp - 0.5 && this.isBoss) this.shake = 0.25; this.hp = hp; this.sp = s.sp; this.spMax = s.spMax; + this.ammo = s.ammo || null; this.wolves = s.wolves || null; this.neg = s.neg > 0 ? Math.min(1, s.neg) : 0; const prevFlags = this.flags; this.flags = s.flags | 0; this.anim = s.anim | 0; @@ -1174,24 +1193,32 @@ export class UnitView { this.hpFill.position.set(x0, cy); this.hpFill.width = bw * k; this.hpFill.height = bh; if (!this.isEnemy) this.hpFill.tint = k < 0.3 ? COLORS.hpAllyLow : COLORS.hpAlly; } + // 业火 我执: her HP sits on the 1-HP floor and the damage past it fills a pool — the red bar over the drained green one + const neg = showHp ? this.neg : 0; + this.negFill.visible = neg > 0; + if (neg > 0) { this.negFill.position.set(x0, cy); this.negFill.width = bw * neg; this.negFill.height = bh; } const shielded = showHp && (this.flags & UF.SHIELD); this.shieldBar.visible = !!shielded; if (shielded) { this.shieldBar.position.set(x0, cy - bh / 2 - 1); this.shieldBar.width = bw; this.shieldBar.height = Math.max(1.5, bh * 0.35); } - // SP - const showSp = showBars && !this.isEnemy && this.spMax > 0; + // SP — or, while an ammo skill runs, its magazine: yellow cells, one per round, emptying from the right (b.snap `ammo`; + // the SP bar's own fraction, ammoLeft / ammoMax, is the same width). Without an `ammo` row the bar is the plain SP bar. + const ammo = !this.isEnemy ? this.ammo : null; + const showSp = showBars && !this.isEnemy && (this.spMax > 0 || !!ammo); this.spBg.visible = this.spFill.visible = showSp; - const spH = Math.max(2, bh * 0.6); + const spH = Math.max(2, bh * (ammo ? AMMO_BAR.height : 0.6)); let ready = false; + let sy = 0; if (showSp) { const active = !!(this.flags & UF.SKILL); - const k = clamp(this.sp / this.spMax, 0, 1); + const k = ammo ? ammo[0] / ammo[1] : clamp(this.sp / this.spMax, 0, 1); ready = !active && k >= 0.999; - const sy = cy + bh / 2 + spH / 2 + 1.5; + sy = cy + bh / 2 + spH / 2 + 1.5; this.spBg.position.set(x0 - 1, sy); this.spBg.width = bw + 2; this.spBg.height = spH + 2; this.spFill.position.set(x0, sy); this.spFill.width = bw * k; this.spFill.height = spH; - this.spFill.tint = active ? COLORS.spActive : ready ? COLORS.spReady : COLORS.sp; + this.spFill.tint = ammo ? COLORS.ammo : active ? COLORS.spActive : ready ? COLORS.spReady : COLORS.sp; this._spY = sy; } + this._updateAmmoCuts(showSp && !!ammo, ammo, x0, sy, bw, spH); this.spGlow.visible = ready; if (ready) { const pulse = 0.55 + 0.35 * Math.sin(t * 6); @@ -1222,8 +1249,11 @@ export class UnitView { ic.width = ic.height = isz; ic.position.set(x - ((icons.length - 1) * (isz + 2)) / 2 + i * (isz + 2), iy); } - // element gauge row under the bars (b.snap `elem`); redeploy ring above a knocked-down operator (b.snap `down`) - this._updateElementBar(showBars && !!this.el, x0, bw, cy + bh / 2 + (showSp ? spH + 1.5 : 0) + 1, spH, s, t); + // 狼影 pips under the bars (b.snap `wolves`), then the element gauge row under them (b.snap `elem`); redeploy ring above a + // knocked-down operator (b.snap `down`) + const rowsTop = cy + bh / 2 + (showSp ? spH + 1.5 : 0); + const wolfH = this._updateWolfPips(showBars && !this.isEnemy && !!this.wolves, x0, rowsTop, s); + this._updateElementBar(showBars && !!this.el, x0, bw, rowsTop + wolfH + 1, spH, s, t); this._updateDownRing(!prep && !!this.down && !this.alive, x, this.screen.y - DOWN_LOOK.height * s, s, t); // blocked marker at the feet (enemies held by a blocker) const blocked = !prep && this.alive && this.isEnemy && (this.flags & UF.BLOCKED); @@ -1248,6 +1278,62 @@ export class UnitView { } } + /** + * The thin dark separators between the ammo bar's cells (see _updateHud): one at every round boundary of the `n`-round + * magazine across the bar `x0` … `x0 + bw` at height `y`, `h` tall; none while a cell would be under AMMO_BAR.minCell px + * (the fill keeps its exact width). Sprites are built as the magazines need them and hidden when not in use. + */ + _updateAmmoCuts(show, ammo, x0, y, bw, h) { + const cuts = this.ammoCuts; + const n = show ? ammo[1] : 0; + const step = n > 1 ? bw / n : 0; + const want = step >= AMMO_BAR.minCell ? n - 1 : 0; + while (cuts.length < want) cuts.push(bar(this.P, this.hud, COLORS.hpBack, 0.9)); + for (let i = 0; i < cuts.length; i++) { + const c = cuts[i]; + c.visible = i < want; + if (c.visible) { c.position.set(x0 + (i + 1) * step - 0.5, y); c.width = 1; c.height = h; } + } + } + + /** + * 伺夜's 狼群 count (b.snap `wolves`, [left, maximum]): a row of diamonds from `x0`, its top at `top`, one per 狼影 the + * talent allows, the first `left` of them lit. Built on the first count, hidden without one. Returns the height the row + * takes (0 while hidden) so the element gauge row can sit below it. + */ + _updateWolfPips(show, x0, top, s) { + let r = this._wolfPips; + if (!show) { if (r) r.root.visible = false; return 0; } + const P = this.P; + const [left, max] = this.wolves; + if (!r) { + r = this._wolfPips = { root: new P.Container(), back: [], lit: [] }; + this.hud.addChild(r.root); + } + const mk = (list, color, alpha) => { + const d = new P.Sprite(white()); + d.anchor.set(0.5); d.rotation = Math.PI / 4; d.tint = color; d.alpha = alpha; + r.root.addChild(d); + list.push(d); + }; + while (r.lit.length < max) { mk(r.back, COLORS.hpBack, 0.85); mk(r.lit, COLORS.wolf, 1); } + const d = clamp(s * WOLF_PIPS.size, WOLF_PIPS.min, WOLF_PIPS.max); // a diamond's diagonal + const side = d / Math.SQRT2; + const cy = top + WOLF_PIPS.pad + d / 2 + 1; + for (let i = 0; i < r.lit.length; i++) { + const on = i < max; + const b = r.back[i], l = r.lit[i]; + b.visible = l.visible = on; + if (!on) continue; + const cx = x0 + d / 2 + 1 + i * (d + 2 + WOLF_PIPS.gap); + b.position.set(cx, cy); b.width = b.height = side + 2; + l.position.set(cx, cy); l.width = l.height = side; + l.alpha = i < left ? 1 : WOLF_PIPS.dim; + } + r.root.visible = true; + return d + 2 + WOLF_PIPS.pad; + } + /** True while the shown gauge is in its 爆发冷却 (b.snap `elem` carries the cooldown's end and length). */ elementCooling() { return !!this.el && this.elDur > 0 && this.elUntil > 0; diff --git a/server/sim/battle/events.js b/server/sim/battle/events.js index 690b4c54..e083b719 100644 --- a/server/sim/battle/events.js +++ b/server/sim/battle/events.js @@ -4,7 +4,7 @@ import { EVENT_BUFFER_CAP } from '../constants.js'; import { elementView } from '../damage.js'; -import { unitInfo, snapshotUnits } from '../snapshot.js'; +import { unitInfo, snapshotUnits, ammoView, wolfView, negView } from '../snapshot.js'; export class BattleEvents { fx(kind, params = {}) { @@ -37,7 +37,11 @@ export class BattleEvents { * down: [[id, respawnAt, respawnTime, state, row, col]] — operators that left the field waiting to redeploy (isDown): the * game time their respawn timer ends, its length (s), constants.js DOWN_STATE and the tile they lie on (and * come back on: _layBody — where they fell, or their home); - * elem: [[id, element, fill, cooldownEnd, cooldown]] — the element gauge each unit shows (damage.js elementView). + * elem: [[id, element, fill, cooldownEnd, cooldown]] — the element gauge each unit shows (damage.js elementView); + * ammo: [[id, rounds left, rounds in the magazine]] — a running ammo skill, whole rounds (snapshot.js ammoView): the segmented bar; + * wolves: [[id, 狼影 left, the talent's maximum]] — 伺夜's 狼群 (snapshot.js wolfView): the pips under the HP bar; + * neg: [[id, fill]] — the share of its cap a negative-HP pool holds (snapshot.js negView; 斩业星熊's 我执): the red bar. + * The last three are display only: no sim state reads them, and the nine-field unit tuples are unchanged. */ snapshot() { const snap = { @@ -60,13 +64,22 @@ export class BattleEvents { (down || (down = [])).push([u.id, r2(u.respawnAt), r2(Math.max(0, u.respawnAt - u.deathAt)), this._downState(u), ...this.restTile(u)]); } if (down) snap.down = down; - let elem = null; + let elem = null, ammo = null, wolves = null, neg = null; for (const u of this.units) { if (!u.alive || !u.deployed || u.hidden) continue; const v = elementView(u, this.time); if (v) (elem || (elem = [])).push([u.id, v[0], v[1], v[2], v[3]]); + const am = ammoView(u); + if (am) (ammo || (ammo = [])).push([u.id, am[0], am[1]]); + const wv = wolfView(u); + if (wv) (wolves || (wolves = [])).push([u.id, wv[0], wv[1]]); + const ng = negView(u); + if (ng) (neg || (neg = [])).push([u.id, ng]); } if (elem) snap.elem = elem; + if (ammo) snap.ammo = ammo; + if (wolves) snap.wolves = wolves; + if (neg) snap.neg = neg; return snap; } diff --git a/server/sim/content/kits/ops/chess_char_3_19-vigil.js b/server/sim/content/kits/ops/chess_char_3_19-vigil.js index 6295c5b1..7bbe88ae 100644 --- a/server/sim/content/kits/ops/chess_char_3_19-vigil.js +++ b/server/sim/content/kits/ops/chess_char_3_19-vigil.js @@ -43,6 +43,7 @@ export default { skill: null, trait: { hitsFn: (b, w) => Math.max(1, w.mem.wolves || 1) }, install(battle, w) { + w.mem.wolfCapacity = maxWolves; // (b.snap `wolves`, snapshot.js wolfView: the pips under the HP bar) const tal = w.def.talents || []; const wb = tal[0]?.bb ?? {}; const per = num(wb.block_cnt, num(wb['vigil_wolf_t_1_enhance[trigger].block_cnt'], 1)); diff --git a/server/sim/content/kits/ops/op-hsgma2.js b/server/sim/content/kits/ops/op-hsgma2.js index dcd1c29c..8f067ca8 100644 --- a/server/sim/content/kits/ops/op-hsgma2.js +++ b/server/sim/content/kits/ops/op-hsgma2.js @@ -180,6 +180,9 @@ export default { const capOf = () => num(t0.max_minus_hp_ratio, 2) * unit.s.maxHp; const quiet = num(t0['hsgma2_t_1[heal].interval'], 5); const regen = num(t0['hsgma2_t_1[heal].hp_recovery_per_sec_by_max_hp_ratio']); + // the pool as a share of its cap, read when a snapshot is taken (b.snap `neg`, snapshot.js negView): the green HP bar + // sits on the 1-HP floor in 我执, so the client draws the pool as the red bar — display only + unit.negFill = () => (unit.mem.hsEgo && unit.mem.hsEgo.pool > 0 ? unit.mem.hsEgo.pool / Math.max(1e-9, capOf()) : 0); const enter = (pool) => { unit.mem.hsEgo = { pool: Math.max(0, pool), lastHurt: battle.time }; battle.addBuff(unit, { key: EGO, status: 'healFree', flags: { noHeal: true, healFree: true }, tags: ['talent'] }); diff --git a/server/sim/content/tokens.js b/server/sim/content/tokens.js index 77f8a7c4..1dc3f510 100644 --- a/server/sim/content/tokens.js +++ b/server/sim/content/tokens.js @@ -773,6 +773,7 @@ function wolfPack(bb, raw, def) { skill: null, trait: { hitsFn: (b, u) => Math.max(1, u.mem.shadows ?? 1) }, install(battle, unit) { + unit.mem.wolfCapacity = maxShadows; // (b.snap `wolves`, snapshot.js wolfView: the pips under the HP bar) // initial shadows: 伺夜's talent text ("初始两只"), else one below the maximum const ot = (ownerOf(unit)?.def?.talents || []).map((t) => t.description || '').join(' '); const mi = ot.match(/初始(两|二|\d+)只/); diff --git a/server/sim/snapshot.js b/server/sim/snapshot.js index 8c1794bb..04217dd5 100644 --- a/server/sim/snapshot.js +++ b/server/sim/snapshot.js @@ -3,7 +3,8 @@ // b.snap = { fieldId, t, units: [[id, x, y, hp, maxHp, sp, spMax, flags, anim]], dp, killed, total } // (hp of a countdown summon — unit.countdown, content/tokens.js startCountdown — is maxHp × the share of its life left) // UnitInfo = { id, kind, side, ownerId, defId, name, tier, golden, spine, avatar, x, y, facing, dir, maxHp, motion?, boss?, uid?, -// form?, skillIndex?, moduleId?, items?, standInFor?, diy? } (standInFor = the replaced operator's charId of a 补位 +// form?, skillIndex?, ammoSkill?, moduleId?, items?, standInFor?, diy? } (ammoSkill = an ally whose skill is an ammo +// magazine: the HP bar shows its rounds as cells, b.snap `ammo`; standInFor = the replaced operator's charId of a 补位 // stand-in; diy = a 自选 piece's pick { charId, skillIndex, uniEquipId } — defId is its slot, spine / avatar the operator's; // form = the unit's current model form — an enemy's, content/enemies/helpers.js setForm: // 掠海漂移体 'crawl', 暴鸰 'bombed', 转译基底·α's forms …; a 傀儡师 fighting as its 替身 'doll', professions.js — a view built @@ -48,6 +49,9 @@ export function unitInfo(u) { uid: u.uid ?? undefined, // DESIGN §16: the equipped skill's index (the renderer / audio pick that skill's Spine clip and sound) skillIndex: u.side === 'ally' && Number.isInteger(d.skill?.index) ? d.skill.index : undefined, + // an ally whose skill is an ammo magazine: the renderer shows it as the segmented bar under the HP bar (b.snap `ammo`) and + // draws no sustained skill aura for it — known from the unit's first appearance, before any snapshot or skill event + ammoSkill: u.side === 'ally' && u.skill?.kind === 'ammo' ? true : undefined, // DESIGN §16: an elite ally's equipped module (uniEquipId | 'none'; display only — a teammate's unit in a shared // field shows its owner's module in the detail card) moduleId: u.side === 'ally' && d.golden && typeof d.loadout?.moduleId === 'string' ? d.loadout.moduleId : undefined, @@ -118,6 +122,45 @@ export function unitTuple(u, t) { return [u.id, r2(u.x), r2(u.y), hp, maxHp, r1(sp), spMax, flagsOf(u), animOf(u, t)]; } +/** + * The three optional HUD readouts of b.snap (events.js snapshot(): `ammo`, `wolves`, `neg`; each only for a living, + * deployed, visible unit and only when it has one). They ride beside the 9-field tuples — the tuple, and the SP + * fraction the ammo skill still puts in it, stay as they are. + * + * `ammoView(u)` = `[rounds left, rounds in the magazine]` of a running ammo skill (whole numbers; the magazine is the + * activation's real total — extra bullets and refills included — that unitTuple's SP fraction runs on): the segmented + * yellow bar under the HP bar (隐现's S2 holds 14 rounds, skill_table skchr_inside_2 `attack@trigger_time`). + */ +export function ammoView(u) { + const sk = u.skill; + if (u.side !== 'ally' || !sk || !sk.active || sk.kind !== 'ammo') return null; + const left = Math.max(0, Math.ceil(sk.ammoLeft - 1e-9)); + return [left, Math.max(1, Math.ceil((sk.ammoMax || sk.ammo || 0) - 1e-9), left)]; +} + +/** + * `wolfView(u)` = `[狼影 left, the talent's maximum]` of 伺夜's 狼群 pack (content/tokens.js wolfPack and the managed kit + * chess_char_3_19-vigil.js keep the count in `mem.shadows` and the maximum in `mem.wolfCapacity`): the pips under the HP + * bar. A pack in its 战术点形态 is not alive and shows nothing. + */ +export function wolfView(u) { + const cap = u.mem?.wolfCapacity, n = u.mem?.shadows; + if (u.side !== 'ally' || !(cap >= 1) || !Number.isFinite(n)) return null; + const max = Math.round(cap); + return [Math.max(0, Math.min(max, Math.round(n))), max]; +} + +/** + * `negView(u)` = the share (0.01–1) of its cap that a unit's negative-HP pool holds, null without a pool: 斩业星熊's 我执 + * (kits/ops/op-hsgma2.js sets `unit.negFill`, the pool read when the snapshot is taken) — the red bar over the drained HP + * bar. The pool itself, and the 1-HP floor she stands on, are the sim's and are not touched. + */ +export function negView(u) { + if (typeof u.negFill !== 'function') return null; + const v = u.negFill(); + return v > 0 ? Math.min(1, Math.max(0.01, r2(v))) : null; +} + /** Units included in a snapshot: deployed & visible, plus recently dead ones (DIE animation). */ export function snapshotUnits(units, t) { const out = []; diff --git a/server/sim/units.js b/server/sim/units.js index 42412ab4..a948cabc 100644 --- a/server/sim/units.js +++ b/server/sim/units.js @@ -84,6 +84,9 @@ export class Unit { // a countdown summon's life on the field ({ from, until } battle times; content/tokens.js startCountdown, cleared by // every deployment): its bar shows the time left (snapshot.js unitTuple), its HP never moves (无敌 + 禁疗) this.countdown = null; + // a unit with a negative-HP pool (斩业星熊's 我执, kits/ops/op-hsgma2.js) sets this to a function returning the share of the + // pool's cap it holds (0–1): b.snap's `neg` list (snapshot.js negView) draws it as the red bar; display only + this.negFill = null; // knocked out, the unit lies — and redeploys — on its home tile instead of where it fell (Battle._layBody) while content // holds this: 乌尔比安 moved by his S3 (the owner's decision of 2026-10-07, a deviation from PRTS's "where it fell"); // every deployment clears it (battle/deploy.js _deploy) diff --git a/test/content/op_hsgma2.test.js b/test/content/op_hsgma2.test.js index a5e1e713..f5167ae9 100644 --- a/test/content/op_hsgma2.test.js +++ b/test/content/op_hsgma2.test.js @@ -300,6 +300,33 @@ test('S3 临死模式 (a 主动关闭 only — no strategy of this mode closes i } }); +// 社区反馈 (PR #383 by @Convey123): 「绿条被打空以后受到伤害就会涨红条,这里看不到红条」 — the official HP bar shows the 我执 pool as a red bar +// growing over the drained green one. The sim has kept the pool since 0.2.0 (unit.mem.hsEgo); the snapshot now lists its share of the +// cap (b.snap `neg`, snapshot.js negView — display only) and render/units.js draws it (test/render/hud-readouts.test.js). +test('T1 业火 我执: the pool reaches the client as b.snap `neg` — its share of the cap (2 × max HP) from the first lethal hit, growing with the damage, gone with 我执', () => { + const { h, u } = field({ tier: 6, elite: true, skill: 0, others: [{ uid: 2, chessId: TEXAS, row: 12, col: 9 }] }); + const e = h.spawn('enemy_dummy', { pos: [10, 8] }); + h.step(); + const max = u.s.maxHp; + const negOf = () => (h.b.snapshot().neg || []).find((x) => x[0] === u.id); + assert.equal(h.b.snapshot().neg, undefined, 'not in 我执: no list'); + h.b.dealDamage(e, u, { amount: u.hp + 300, type: 'true' }); + h.step(); + assert.ok(u.mem.hsEgo, '已进入 我执'); + const cap = 2 * max; // max_minus_hp_ratio + approx(negOf()[1], 300 / cap, 'the share of the cap', 0.02); + h.b.dealDamage(e, u, { amount: 400, type: 'true' }); + h.step(); + approx(negOf()[1], 700 / cap, 'it grew with the damage', 0.02); + assert.ok(negOf()[1] < 1, 'still below a full pool'); + assert.ok(u.hp / max < 0.01, 'her green bar sits on the 1-HP floor: the red bar is the whole story'); + assert.equal(h.b.snapshot().units.find((t) => t[0] === u.id).length, 9, 'the tuple is not widened'); + assert.ok(h.runUntil(() => !u.mem.hsEgo, 60), 'the quiet-time regeneration clears the pool'); + h.step(); + assert.equal(h.b.snapshot().neg, undefined, 'no pool, no red bar'); + done(h); +}); + test('T1 业火 我执: a lethal hit leaves her on the field (HP floor) with the excess as negative HP; later damage goes there (受击回复 SP still), 禁疗 meanwhile; 200 % of max HP knocks her out; 4 s without damage ⇒ 生命回复速度 5 %/s clears it and she leaves 我执', () => { for (const [tier, elite] of [[5, false], [6, true]]) { const t0 = formOf(tier, elite).talents.find((t) => t.index === 0).bb; diff --git a/test/render/hud-readouts.test.js b/test/render/hud-readouts.test.js new file mode 100644 index 00000000..086581c7 --- /dev/null +++ b/test/render/hud-readouts.test.js @@ -0,0 +1,349 @@ +// test/render/hud-readouts.test.js — the HP-bar readouts, client side (R22-HUD; PR #303 and #378 by @IceCodeNew, PR #383 +// by @Convey123, folded into one change): render/interp.js carries b.snap `ammo` [[id, left, magazine]], `wolves` +// [[id, left, max]] and `neg` [[id, fill]] beside the unit tuples and samples them from the older snapshot; render/units.js +// draws the ammo skill's segmented yellow bar in place of the SP bar, 伺夜's 狼影 as a row of diamonds and the 我执 pool +// as the red bar over the drained HP bar; an ammo skill has no standing aura (headless fake PIXI, test/render/fakepixi.js). +// The server side is test/sim/hud-readouts.test.js and test/content/op_hsgma2.test.js. +// Run: node --test test/render/hud-readouts.test.js + +import { test, describe, before, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { installFakePixi, fakeViewCtx } from './fakepixi.js'; +import { presetCamera } from '../../public/js/render/projection.js'; +import { SnapshotBuffer, normalizeSnapshot } from '../../public/js/render/interp.js'; +import { renderInfo } from '../../public/js/render/app/info.js'; +import { UF } from '../../shared/constants.js'; +import { makeBattle, enemyRec } from '../helpers/battleHarness.js'; +import { unitInfo } from '../../server/sim/snapshot.js'; + +let fake, FX, UnitView, COLORS, AMMO_BAR, WOLF_PIPS; +before(async () => { + fake = installFakePixi(); + FX = await import('../../public/js/render/fx.js'); + ({ UnitView, AMMO_BAR, WOLF_PIPS } = await import('../../public/js/render/units.js')); + ({ COLORS } = await import('../../public/js/render/style.js')); +}); +after(() => { FX?.setSimProjectileSpeeds(null); fake.restore(); }); + +const cam = () => presetCamera('normal', { width: 1280, height: 720 }); +const tuple = (id, hp = 1000, extra = {}) => [id, 5, 10, hp, 1000, extra.sp ?? 0, extra.spMax ?? 0, extra.flags ?? 0, 0]; +const view = (info = {}, extra = {}) => new UnitView(fakeViewCtx(fake.P, { cam, ...extra }), + { id: 1, side: 'ally', kind: 'op', defId: 'char_x', tier: 3, x: 5, y: 10, maxHp: 1000, dir: 'RIGHT', ...info }); +const sample = (o = {}) => ({ x: 5, y: 10, hp: 1000, maxHp: 1000, sp: 0, spMax: 0, flags: 0, anim: 0, vx: 0, vy: 0, ...o }); +const frames = (v, n = 2) => { for (let i = 0; i < n; i++) v.update(1 / 60, cam(), i / 60); }; +const barWidth = (v) => v.hpBg.width - 2; + +describe('interp: ammo / wolves / neg', () => { + test('sampled from the older snapshot (they step at snapshot boundaries) and cleared by a snapshot without them', () => { + const buf = new SnapshotBuffer({ delay: 0 }); + buf.push({ t: 1, units: [tuple(1), tuple(2)], ammo: [[1, 13, 14]], wolves: [[2, 2, 3]], neg: [[2, 0.25]] }, 0); + buf.push({ t: 2, units: [tuple(1), tuple(2)], ammo: [[1, 12, 14]], wolves: [[2, 1, 3]], neg: [[2, 0.5]] }, 0.5); + buf.push({ t: 3, units: [tuple(1), tuple(2)] }, 1); + // between the first two snapshots nothing slides: a round, a 狼影 or a share of the pool is not halfway + const out = buf.sample(1.99); + assert.deepEqual([out.get(1).ammo, out.get(1).wolves, out.get(1).neg], [[13, 14], null, 0]); + assert.deepEqual([out.get(2).ammo, out.get(2).wolves, out.get(2).neg], [null, [2, 3], 0.25]); + buf.sample(2, out); + assert.deepEqual([out.get(1).ammo, out.get(2).wolves, out.get(2).neg], [[12, 14], [1, 3], 0.5]); + // a snapshot without the lists takes the readouts off the reused sample objects + buf.sample(3, out); + assert.deepEqual([out.get(1).ammo, out.get(2).wolves, out.get(2).neg], [null, null, 0]); + }); + + test('malformed rows are dropped: fractions, negatives, a count above its maximum, a maximum under 1, unknown ids, junk', () => { + const s = normalizeSnapshot({ + t: 1, units: [tuple(1), tuple(2), tuple(3)], + ammo: [[1, 13, 14], [2, -1, 14], [2, 2, 1], [2, 1.5, 4], [9, 1, 2], [3, 15, 14], null, 'x', [3, 0, 0]], + wolves: [[2, 3, 3], [1, 2.5, 3], [1, 4, 3], [3, 1, 0], [9, 1, 3]], + neg: [[1, 0.4], [2, 0], [3, -1], [3, 'x'], [9, 0.5], [2, 5], null], + }); + assert.deepEqual([...s.ammo], [[1, [13, 14]]]); + assert.deepEqual([...s.wolves], [[2, [3, 3]]]); + assert.deepEqual([...s.neg], [[1, 0.4], [2, 1]], 'a share above 1 is a full pool; 0 / negative / non-numbers are no pool'); + const none = normalizeSnapshot({ t: 1, units: [tuple(1)] }); + assert.deepEqual([none.ammo, none.wolves, none.neg], [null, null, null]); + assert.equal(none.units.get(1).length, 9, 'the tuples are never widened by the readouts'); + }); +}); + +describe('UnitView: the ammo skill\'s segmented bar', () => { + test('replaces the SP bar while the magazine runs: a yellow cell per round, the fill is rounds left / magazine, a thin cut at every boundary', () => { + const v = view(); + v.sync(sample({ flags: UF.SKILL, sp: 22.3, spMax: 24, ammo: [13, 14] })); + frames(v); + const bw = barWidth(v); + assert.equal(v.spBg.visible && v.spFill.visible, true); + assert.equal(v.spFill.tint, COLORS.ammo); + assert.ok(Math.abs(v.spFill.width - bw * 13 / 14) < 1e-9, 'rounds left over the magazine, exactly (not the 0.1-rounded SP)'); + const cuts = v.ammoCuts.filter((c) => c.visible); + assert.equal(cuts.length, 13, 'one cut per boundary of 14 cells'); + const step = bw / 14, x0 = v.spFill.position.x; + cuts.forEach((c, i) => { + assert.ok(Math.abs(c.position.x - (x0 + (i + 1) * step - 0.5)) < 1e-9, `cut ${i + 1} on the boundary`); + assert.equal(c.width, 1); + assert.equal(c.height, v.spFill.height); + assert.equal(c.position.y, v.spFill.position.y); + assert.equal(c.tint, COLORS.hpBack); + }); + assert.ok(v.spFill.height > v.hpFill.height * 0.6, 'taller than the plain SP bar (the HP bar\'s thickness × AMMO_BAR.height)'); + assert.ok(Math.abs(v.spFill.height - Math.max(2, v.hpFill.height * AMMO_BAR.height)) < 1e-9); + // a round is spent: the fill loses 1/14 and the cuts stay + v.sync(sample({ flags: UF.SKILL, sp: 20.6, spMax: 24, ammo: [12, 14] })); + frames(v); + assert.ok(Math.abs(v.spFill.width - bw * 12 / 14) < 1e-9); + assert.equal(v.ammoCuts.filter((c) => c.visible).length, 13); + // the last round, then the magazine ends: the plain SP bar is back (cyan, no cuts) + v.sync(sample({ flags: UF.SKILL, ammo: [0, 14], sp: 0, spMax: 24 })); + frames(v); + assert.equal(v.spFill.width, 0); + v.sync(sample({ flags: 0, sp: 6, spMax: 24, ammo: null })); + frames(v); + assert.equal(v.ammoCuts.filter((c) => c.visible).length, 0, 'no cuts without a magazine'); + assert.notEqual(v.spFill.tint, COLORS.ammo); + assert.ok(Math.abs(v.spFill.width - bw * 6 / 24) < 1e-9, 'the SP fraction of the tuple'); + v.destroy(); + }); + + test('a magazine needs no SP cost to show; a dense one keeps its exact fill and draws no cuts under AMMO_BAR.minCell px; an enemy never has the bar', () => { + const v = view(); + v.sync(sample({ flags: UF.SKILL, ammo: [20, 40] })); // spMax 0: an auto skill with no SP cost + frames(v); + const bw = barWidth(v); + assert.equal(v.spFill.visible, true, 'the bar shows without an SP cost'); + assert.ok(Math.abs(v.spFill.width - bw / 2) < 1e-9); + const cell = bw / 40; + assert.equal(v.ammoCuts.filter((c) => c.visible).length, cell >= AMMO_BAR.minCell ? 39 : 0, `${cell.toFixed(2)} px cells`); + v.sync(sample({ flags: UF.SKILL, ammo: [20, 400] })); + frames(v); + assert.equal(v.ammoCuts.filter((c) => c.visible).length, 0, 'under 2 px a cell: no cuts'); + assert.ok(Math.abs(v.spFill.width - bw * 20 / 400) < 1e-9, 'the fill is still exact'); + v.destroy(); + const e = view({ id: 2, side: 'enemy', kind: 'enemy', defId: 'enemy_x' }); + e.sync(sample({ hp: 500, flags: UF.SKILL, ammo: [3, 4] })); + frames(e); + assert.equal(e.spFill.visible, false, 'enemies have no SP bar, and so no magazine'); + assert.equal(e.ammoCuts.filter((c) => c.visible).length, 0); + e.destroy(); + }); + + test('the prep view draws no bars, so no cells either', () => { + const v = view({}, {}); + v.prep = true; + v.sync(sample({ flags: UF.SKILL, ammo: [5, 14] })); + frames(v); + assert.equal(v.spFill.visible, false); + assert.equal(v.ammoCuts.filter((c) => c.visible).length, 0); + v.destroy(); + }); +}); + +describe('UnitView: 伺夜\'s 狼影 pips', () => { + const pack = (extra = {}) => view({ kind: 'token', defId: 'token_10028_vigil_wolf', ...extra }); + + test('one diamond per 狼影 the talent allows under the HP bar, the first ones lit for the shadows left', () => { + const v = pack(); + v.sync(sample({ wolves: [2, 3] })); + frames(v); + const r = v._wolfPips; + assert.equal(r.root.visible, true); + assert.equal(r.lit.length, 3); + assert.deepEqual(r.lit.map((d) => d.alpha), [1, 1, WOLF_PIPS.dim]); + assert.ok(r.lit.every((d) => d.visible && d.tint === COLORS.wolf && Math.abs(d.rotation - Math.PI / 4) < 1e-9), 'pale diamonds'); + assert.ok(r.back.every((d) => d.visible && d.tint === COLORS.hpBack), 'on a dark backing'); + assert.ok(r.lit[0].position.y > v.hpBg.position.y, 'below the HP bar'); + assert.ok(r.lit[0].position.x > v.hpFill.position.x && r.lit[2].position.x < v.hpFill.position.x + barWidth(v), 'inside the bar\'s span'); + assert.ok(r.lit[1].position.x > r.lit[0].position.x && r.lit[2].position.x > r.lit[1].position.x, 'left to right'); + assert.equal(v.spFill.visible, false, 'not an SP bar or a skill indicator'); + // a 狼影 is spent: the last lit one dims; one is gained: it lights again + v.sync(sample({ wolves: [1, 3] })); + frames(v); + assert.deepEqual(r.lit.map((d) => d.alpha), [1, WOLF_PIPS.dim, WOLF_PIPS.dim]); + v.sync(sample({ wolves: [3, 3] })); + frames(v); + assert.deepEqual(r.lit.map((d) => d.alpha), [1, 1, 1]); + // a smaller maximum hides the surplus diamonds + v.sync(sample({ wolves: [1, 2] })); + frames(v); + assert.equal(r.lit.filter((d) => d.visible).length, 2); + v.destroy(); + }); + + test('hidden without a count (an older snapshot cannot leave stale pips), in the prep view, and for enemies', () => { + const v = pack(); + v.sync(sample({ wolves: [2, 3] })); + frames(v); + assert.equal(v._wolfPips.root.visible, true); + v.sync(sample({})); + frames(v); + assert.equal(v._wolfPips.root.visible, false, 'no row in the snapshot: no pips'); + v.sync(sample({ wolves: [2, 3] })); + v.prep = true; + frames(v); + assert.equal(v._wolfPips.root.visible, false, 'the prep board draws no bars'); + v.destroy(); + const e = view({ id: 2, side: 'enemy', kind: 'enemy', defId: 'enemy_x' }); + e.sync(sample({ hp: 500, wolves: [2, 3] })); + frames(e); + assert.equal(e._wolfPips, null, 'only allies carry the row'); + e.destroy(); + }); + + test('the element gauge row moves down below the pips (and below the SP bar when there is one)', () => { + const plain = view(); + plain.sync(sample({ el: 'burn', elFill: 0.4, elUntil: 0, elDur: 0 })); + frames(plain); + const v = pack(); + v.sync(sample({ wolves: [2, 3], el: 'burn', elFill: 0.4, elUntil: 0, elDur: 0 })); + frames(v); + const pipsBottom = v._wolfPips.back[0].position.y + (v._wolfPips.back[0].width * Math.SQRT2) / 2; + assert.ok(v._elBar.bg.position.y - v._elBar.bg.height / 2 >= pipsBottom - 1e-6, 'the gauge row starts under the pips'); + assert.ok(v._elBar.bg.position.y > plain._elBar.bg.position.y + 4, 'and so sits lower than without pips'); + plain.destroy(); + v.destroy(); + }); +}); + +describe('UnitView: the 我执 pool as the red bar', () => { + const ego = (extra = {}) => view({ defId: 'char_1044_hsgma2', tier: 6, ...extra }); + + test('drawn over the drained HP bar from its left edge: width = the bar × the pool\'s share, the pool\'s red, gone with the pool', () => { + const v = ego(); + v.sync(sample({ hp: 1 })); + frames(v); + assert.equal(v.negFill.visible, false, 'no pool, no red bar'); + assert.equal(v.negFill.tint, COLORS.hpNeg); + v.sync(sample({ hp: 1, neg: 0.5 })); + frames(v); + assert.equal(v.negFill.visible, true); + assert.ok(Math.abs(v.negFill.width - barWidth(v) * 0.5) < 1e-9, 'half the pool: half the bar'); + assert.equal(v.negFill.position.x, v.hpFill.position.x, 'from the bar\'s left edge'); + assert.equal(v.negFill.position.y, v.hpFill.position.y); + assert.equal(v.negFill.height, v.hpFill.height); + assert.ok(v.hpFill.width < 1, 'the green bar is drained meanwhile'); + v.sync(sample({ hp: 1, neg: 1 })); + frames(v); + assert.ok(Math.abs(v.negFill.width - barWidth(v)) < 1e-9, 'a full pool fills the bar'); + v.sync(sample({ hp: 600, neg: 0 })); + frames(v); + assert.equal(v.negFill.visible, false, 'she left 我执'); + v.destroy(); + }); + + test('not in the prep view, and gone once she falls', () => { + const p = ego(); + p.prep = true; + p.sync(sample({ hp: 1, neg: 0.6 })); + frames(p); + assert.equal(p.negFill.visible, false); + p.destroy(); + const v = ego(); + v.sync(sample({ hp: 1, neg: 1 })); + frames(v); + assert.equal(v.negFill.visible, true); + v.die(); + for (let i = 0; i < 300; i++) v.update(1 / 60, cam(), i / 60); + assert.equal(v.alive, false); + assert.equal(v.negFill.visible, false, 'no bars for a knocked-out operator'); + v.destroy(); + }); +}); + +describe('an ammo skill has no standing aura', () => { + const unit = (id, info) => ({ id, x: 3, y: 10, z: 0, hover: 0, _headTiles: 1.2, alive: true, destroyed: false, isEnemy: false, maxHp: 3000, info, onHit() {} }); + const makeFx = (views) => { + const ctx = fakeViewCtx(fake.P); + const map = new Map(views.map((v) => [v.id, v])); + const c = presetCamera('normal', { width: 1600, height: 900 }); + return new FX.FxSystem({ + P: fake.P, layers: ctx.layers, cam: () => c, heightAt: () => 0, settings: { quality: 'high', damageNumbers: true }, + timeScale: () => 2, loadLevel: () => 0, subProfOf: () => null, view: (id) => map.get(id) || null, + screenSize: () => ({ width: 1600, height: 900 }), fieldTop: () => 120, + }); + }; + + test('UnitInfo.ammoSkill: the activation burst plays, the ground glow and hex do not (a plain skill keeps both)', () => { + const magazine = unit(1, { defId: 'char_x', ammoSkill: true }), plain = unit(2, { defId: 'char_y' }); + const fx = makeFx([magazine, plain]); + fx.skill(magazine, true); + assert.ok(fx.parts.length > 0 && fx.rings.length > 0, 'the cast itself is still shown'); + assert.equal(fx.auras.size, 0, 'no glow and hex under a magazine'); + fx.skill(magazine, false); + fx.skill(plain, true); + assert.equal(fx.auras.size, 1, 'a plain skill keeps its aura'); + fx.destroy(); + }); + + test('the flag travels: unitInfo (ammo skills of allies only) → renderInfo', () => { + const h = makeBattle({ + defs: { enemies: { enemy_dummy: enemyRec({ key: 'enemy_dummy', hp: 1e7, speed: 0 }) } }, timeLimit: 30, seed: 7, + units: [{ chessId: 'chess_char_1_01_a', row: 10, col: 3 }, { chessId: 'chess_char_1_02_a', row: 11, col: 3 }], + enemies: [{ key: 'enemy_dummy', pos: [10, 5] }], + }); + h.run(0.5); + assert.equal(renderInfo(unitInfo(h.unit('chess_char_1_01_a'))).ammoSkill, true); + assert.equal(renderInfo(unitInfo(h.unit('chess_char_1_02_a'))).ammoSkill, undefined); + assert.equal(renderInfo(unitInfo(h.enemy('enemy_dummy'))).ammoSkill, undefined); + assert.equal(renderInfo({ id: 1, ammoSkill: 'yes' }).ammoSkill, undefined, 'only a real true counts'); + }); +}); + +describe('the chain: sim → snapshot → SnapshotBuffer → UnitView', () => { + test('隐现 S2: 14 cells, 13 after the first round, one more gone per attack; no aura; the plain SP bar after the last round', () => { + const h = makeBattle({ + defs: { enemies: { enemy_dummy: enemyRec({ key: 'enemy_dummy', hp: 1e7, speed: 0 }) } }, + units: [{ chessId: 'chess_char_1_01_a', row: 10, col: 3 }], + enemies: [{ key: 'enemy_dummy', pos: [10, 5] }], + timeLimit: 200, seed: 7, + }); + const u = h.unit('chess_char_1_01_a'); + assert.ok(h.runUntil(() => u.skill.active, 80)); + const v = new UnitView(fakeViewCtx(fake.P, { cam }), renderInfo(unitInfo(u))); + const buffer = new SnapshotBuffer({ delay: 0 }); + const show = () => { + const snap = h.b.snapshot(); + buffer.push(snap, snap.t); + v.sync(buffer.sample(snap.t).get(u.id), snap.t); + v.update(1 / 60, cam(), h.b.time); + }; + show(); + const bw = barWidth(v); + assert.equal(u.skill.ammoLeft, 13, 'the activation tick already fired the first round'); + assert.ok(Math.abs(v.spFill.width - bw * 13 / 14) < 1e-9); + assert.equal(v.ammoCuts.filter((c) => c.visible).length, 13); + const left = u.skill.ammoLeft; + assert.ok(h.runUntil(() => u.skill.ammoLeft < left && u.skill.active, 10)); + show(); + assert.ok(Math.abs(v.spFill.width - bw * 12 / 14) < 1e-9, 'one attack removes exactly one round'); + assert.ok(h.runUntil(() => !u.skill.active, 60)); + show(); + show(); + assert.equal(v.ammoCuts.filter((c) => c.visible).length, 0, 'the magazine is spent: the plain SP bar is back'); + assert.notEqual(v.spFill.tint, COLORS.ammo); + v.destroy(); + }); + + test('伺夜\'s placed pack: the pips follow 2 → 3 → 2 and are gone when the pack falls', () => { + const h = makeBattle({ + units: [{ chessId: 'chess_char_3_19_a', row: 12, col: 3, uid: 1 }, { kind: 'token', tokenId: 'token_10028_vigil_wolf', ownerUid: 1, row: 11, col: 5, uid: 2 }], + timeLimit: 120, defs: { enemies: { enemy_d: enemyRec({ key: 'enemy_d', hp: 1e9, speed: 0, atk: 0 }) } }, enemies: [{ key: 'enemy_d', pos: [1, 1] }], + }); + h.run(0.2); + const w = h.b.allyUnits.find((x) => x.defId === 'token_10028_vigil_wolf'); + const v = new UnitView(fakeViewCtx(fake.P, { cam }), renderInfo(unitInfo(w))); + const buffer = new SnapshotBuffer({ delay: 0 }); + const lit = () => { + const snap = h.b.snapshot(); + buffer.push(snap, snap.t); + v.sync(buffer.sample(snap.t).get(w.id), snap.t); + v.update(1 / 60, cam(), h.b.time); + return v._wolfPips ? v._wolfPips.lit.filter((d) => d.visible && d.alpha === 1).length : 0; + }; + assert.equal(lit(), 2); + h.run(25); + assert.equal(lit(), 3); + h.b.dealDamage(null, w, { amount: 1e9, type: 'true' }); + h.step(); // (a snapshot of the same game time as the last one would be dropped as a duplicate) + assert.equal(lit(), 2); + v.destroy(); + }); +}); diff --git a/test/sim/hud-readouts.test.js b/test/sim/hud-readouts.test.js new file mode 100644 index 00000000..68358dcb --- /dev/null +++ b/test/sim/hud-readouts.test.js @@ -0,0 +1,97 @@ +// test/sim/hud-readouts.test.js — the optional b.snap lists behind the HP-bar readouts (R22-HUD; PR #303 and #378 by +// @IceCodeNew, PR #383 by @Convey123): `ammo` (a running ammo skill's rounds — 隐现's S2 holds 14 in the data), `wolves` +// (伺夜's 狼群 count) and, in test/content/op_hsgma2.test.js, `neg` (斩业星熊's 我执 pool). All three are display only: the +// nine-field unit tuples are unchanged and no sim state reads them. The client side is test/render/hud-readouts.test.js. +// Run: node --test test/sim/hud-readouts.test.js + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js'; +import { unitInfo, unitTuple } from '../../server/sim/snapshot.js'; + +const DUMMY = { enemies: { enemy_dummy: enemyRec({ key: 'enemy_dummy', hp: 1e7, speed: 0 }) } }; +const INSIDE = 'chess_char_1_01_a'; // 隐现: the chess skill index 1 is skchr_inside_2 (AMMO, attack@trigger_time 14) + +test('隐现 S2: b.snap `ammo` carries the whole rounds left and the 14-round magazine of the data; the tuple keeps its SP fraction', () => { + const h = makeBattle({ + defs: DUMMY, timeLimit: 200, seed: 7, + units: [{ chessId: INSIDE, row: 10, col: 3 }], + enemies: [{ key: 'enemy_dummy', pos: [10, 5] }], + }); + const u = h.unit(INSIDE); + const row = () => (h.b.snapshot().ammo || []).find((e) => e[0] === u.id); + assert.equal(h.b.snapshot().ammo, undefined, 'no skill running: no list at all'); + assert.equal(unitInfo(u).ammoSkill, true, 'UnitInfo says the skill is an ammo magazine from the first appearance'); + assert.ok(h.runUntil(() => u.skill.active, 80)); + assert.equal(u.skill.kind, 'ammo'); + assert.equal(u.skill.ammoMax, 14, 'skill_table skchr_inside_2 attack@trigger_time at the chess skill level'); + // the activation tick already fired the first round + assert.deepEqual(row(), [u.id, 13, 14]); + const [, , , , , sp, spMax] = unitTuple(u, h.b.time); + assert.ok(Math.abs(sp / spMax - 13 / 14) < 0.01, 'the 9-field tuple still runs the SP bar on the same fraction'); + assert.equal(h.b.snapshot().units.find((t) => t[0] === u.id).length, 9, 'the tuple is not widened'); + // every attack spends one round + const before = u.skill.ammoLeft; + assert.ok(h.runUntil(() => u.skill.ammoLeft < before, 10)); + assert.deepEqual(row(), [u.id, u.skill.ammoLeft, 14]); + // a refill above the magazine raises it (拉特兰's extras, 逃犯引渡手续, refills: the activation's real total) + u.skill.addAmmo(20); + assert.deepEqual(row(), [u.id, u.skill.ammoLeft, u.skill.ammoMax]); + assert.ok(u.skill.ammoMax > 14 && row()[1] === row()[2], 'full again at the new mark'); + // the skill ends with its last round: the list goes, the plain SP bar is back + assert.ok(h.runUntil(() => !u.skill.active, 60)); + assert.equal(h.b.snapshot().ammo, undefined); + checkInvariants(h.b); + assert.deepEqual(h.b.errors, []); +}); + +test('ammo rows: whole numbers (a fractional count rounds up), allies only, living deployed units only; an operator without an ammo skill is no ammoSkill', () => { + const h = makeBattle({ + defs: DUMMY, timeLimit: 200, seed: 7, + units: [{ chessId: INSIDE, row: 10, col: 3 }, { chessId: 'chess_char_1_02_a', row: 11, col: 3 }], + enemies: [{ key: 'enemy_dummy', pos: [10, 5] }], + }); + const u = h.unit(INSIDE); + h.run(0.5); + assert.equal(unitInfo(h.unit('chess_char_1_02_a')).ammoSkill, undefined, 'a duration skill is not a magazine'); + assert.equal(unitInfo(h.enemy('enemy_dummy')).ammoSkill, undefined); + assert.ok(h.runUntil(() => u.skill.active, 80)); + u.skill.ammoLeft = 6.2; // (a kit that spends a share of a round) + const e = h.b.snapshot().ammo.find((r) => r[0] === u.id); + assert.deepEqual(e, [u.id, 7, 14]); + assert.ok(Number.isInteger(e[1]) && Number.isInteger(e[2])); + u.deployed = false; // off the field: no row (the unit is not in the snapshot either) + assert.equal(h.b.snapshot().ammo, undefined); + u.deployed = true; +}); + +for (const placed of [false, true]) { + test(`伺夜's ${placed ? 'placed' : 'automatic'} pack: b.snap \`wolves\` follows the 狼影 count (2 of 3, +1 per 25 s, one lost per lethal hit) and goes with the pack`, () => { + const units = [{ chessId: 'chess_char_3_19_a', row: 12, col: 3, uid: 1 }]; + if (placed) units.push({ kind: 'token', tokenId: 'token_10028_vigil_wolf', ownerUid: 1, row: 11, col: 5, uid: 2 }); + const h = makeBattle({ + units, timeLimit: 120, + defs: { enemies: { enemy_d: enemyRec({ key: 'enemy_d', hp: 1e9, speed: 0, atk: 0 }) } }, + enemies: [{ key: 'enemy_d', pos: [1, 1] }], + }); + h.run(0.2); + const w = h.b.allyUnits.find((u) => u.defId === 'token_10028_vigil_wolf'); + const count = () => (h.b.snapshot().wolves || []).find((e) => e[0] === w.id); + assert.deepEqual(count(), [w.id, 2, 3], '初始两只, at most 3 (the talent text)'); + assert.equal(h.b.snapshot().wolves.length, 1, 'only the pack has the row: 伺夜 herself does not'); + h.run(25); + assert.deepEqual(count(), [w.id, 3, 3], '+1 every 25 s'); + for (const left of [2, 1]) { + h.b.dealDamage(null, w, { amount: 1e9, type: 'true' }); + assert.deepEqual(count(), [w.id, left, 3], 'a lethal hit spends one 狼影 and refills the HP'); + } + h.b.dealDamage(null, w, { amount: 1e9, type: 'true' }); + assert.equal(w.alive, false, 'the last 狼影: 战术点形态 (the pack is out of the fight)'); + assert.equal(count(), undefined, 'a pack in that form shows no pips'); + h.run(26); + assert.deepEqual(count(), [w.id, 1, 3], 'back after the 狼影 interval with one 狼影'); + h.b.retreat(h.unit('chess_char_3_19_a'), { reason: 'expired', permanent: true }); + assert.equal(h.b.snapshot().wolves, undefined, 'the pack leaves with its owner'); + assert.deepEqual(h.b.errors, []); + }); +} diff --git a/types/core.js b/types/core.js index effe5f3a..95c23f3a 100644 --- a/types/core.js +++ b/types/core.js @@ -88,10 +88,23 @@ * @typedef {[number, string, number, number, number]} ElemSnap */ +/** + * One HP-bar readout of a unit: `[id, left, max]` — the rounds left of a running ammo skill and its magazine (`ammo`), or the + * 狼影 left of 伺夜's 狼群 and the talent's maximum (`wolves`). Whole numbers. + * + * @typedef {[number, number, number]} CountSnap + */ + +/** + * A negative-HP pool as a share of its cap (`neg`, 斩业星熊's 我执): `[id, fill]`, fill 0.01–1. + * + * @typedef {[number, number]} NegSnap + */ + /** * `snapshot()` (SIM §9). On the wire this object is the payload of `b.snap`, * and `t` is sent as `gt` because the frame's `t` is the message type (DESIGN §8.2). - * `dp` is one number. `down` and `elem` are omitted when empty. + * `dp` is one number. `down`, `elem`, `ammo`, `wolves` and `neg` are omitted when empty. * * @typedef {Object} FieldSnapshot * @property {string} fieldId @@ -104,6 +117,9 @@ * @property {Object} [boss] * @property {DownSnap[]} [down] * @property {ElemSnap[]} [elem] + * @property {CountSnap[]} [ammo] + * @property {CountSnap[]} [wolves] + * @property {NegSnap[]} [neg] */ /** From 030d0a293870734ac11c5f262da3ac30e0806dc5 Mon Sep 17 00:00:00 2001 From: siruimei07 Date: Thu, 8 Oct 2026 19:13:05 +0800 Subject: [PATCH 2/5] =?UTF-8?q?feedback7=20R22-HUD:=20a=20renderer=20that?= =?UTF-8?q?=20loses=20the=2012=20s=20race=20no=20longer=20mounts=20its=20c?= =?UTF-8?q?anvas=20late=20=E2=80=94=20the=20attempt=20is=20aborted,=20a=20?= =?UTF-8?q?view=20that=20still=20comes=20back=20is=20destroyed,=20the=20ho?= =?UTF-8?q?st=20is=20no=20longer=20wiped=20(PR=20#302=20by=20@siruimei07);?= =?UTF-8?q?=20golden:=20unchanged?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applied unchanged after review (Grok static review: MERGE; the diff read in full: no network, no new dependency, no shipped eval). The leak was real on feedback7: mountFieldView raced createFieldView against a timeout and, on failure, removed every child of the host while the factory kept running — Pixi finished later and appended its canvas over the simplified view (taking its clicks); the host wipe could also delete a newer view mounted by the same hook. - ui/fieldHost.js: mountFieldView(host, { signal }) owns an AbortController; the timeout, a failed import or an invalid view aborts it, a view a factory returns after that is destroyed, an outer abort (useFieldView's cleanup) rejects without a fallback; nothing clears the host's children any more. - render/app.js createFieldView takes the signal: the Pixi load / manifest / font waits end on abort, the synchronous build registers its disposables (setupCleanup) so a throw releases the app, tiles, fx, impostors, listeners and observers it made, the board-art and 3D-board waits moved after the view exists (aborting them is view.destroy(), idempotent), destroy() also removes the canvas. render/app/host.js withTimeout(p, ms, signal). - Tests: test/ui/fieldHost-lifecycle.test.js (5, node vm, fake timers: all fail on the parent) and the opt-in real-browser test/render/initialization.browser.test.js (6 scenarios in headless Chrome, 5 fail on the parent: a late Pixi load covering the fallback — with a factory that honours the signal and one that ignores it —, art timeout, a setup exception, an unmount racing a newer mount); test/ui/playtest2.test.js's source pin takes the new indentation. fieldview / browser suites in Chrome unchanged (the two 4× throttled fps tests flake when two Chrome files run at once, pass alone). - The createFieldView body stays unindented inside its new try (the PR's choice, to keep the diff reviewable). - Docs: design/client.md §9. Golden: unchanged (client lifecycle only). --- docs/design/client.md | 3 + public/js/render/app.js | 94 ++++-- public/js/render/app/host.js | 18 +- public/js/ui/fieldHost.js | 75 ++++- test/render/initialization.browser.test.js | 343 +++++++++++++++++++++ test/ui/fieldHost-lifecycle.test.js | 158 ++++++++++ test/ui/playtest2.test.js | 2 +- 7 files changed, 654 insertions(+), 39 deletions(-) create mode 100644 test/render/initialization.browser.test.js create mode 100644 test/ui/fieldHost-lifecycle.test.js diff --git a/docs/design/client.md b/docs/design/client.md index 1d9e476a..44b812de 100644 --- a/docs/design/client.md +++ b/docs/design/client.md @@ -29,6 +29,9 @@ view.holdPiece(uid, {row,col}|null) // keep a dropped piece on a tile w view.setPieceDir(uid, dir) // show a prep piece facing UP|RIGHT|DOWN|LEFT (wheel preview / stored dir) view.resize() ; view.destroy() ``` + +`createFieldView(host, { …, signal })` takes an `AbortSignal` (0.2.2, GitHub PR #302 by @siruimei07): an abort during the build — the Pixi load, the manifest, the fonts, the board art and the 3D board waits — destroys what the attempt made (the canvas, the ticker, the listeners, the observers) and rejects, a failure while it is built releases the same partial resources, and `view.destroy()` is idempotent. `ui/fieldHost.js mountFieldView(host, { signal })` aborts that signal when the engine fails, times out (12 s) or the owner unmounts, disposes a view a factory returns after that, and never clears the other children of `host` — the simplified view and a newer mount share it. + Visuals: oblique perspective board (tilted ~30°, trapezoid tiles), raised highland blocks with side faces, road/floor/forbidden styles per research 07 §7, pulsing red gates, blue objective, dashed hand pads. Units: Spine (Front/Back rule, feet anchored — flying units hover `FLY_HOVER` 1.3 tiles up, the client's 0.35 in character space, research 12, §25.13.5 — an enemy flyer from the road whatever tile it crosses (GitHub #277) —, shadow sprite on the ground), elite gold outline glow, tier chip, HP bar (green ally / red enemy), SP bar (skill ready glow; while an ammo skill runs it is the magazine instead — a yellow cell per round, emptying from the right, `b.snap ammo`, and the skill draws no standing aura, UnitInfo `ammoSkill`), 斩业星熊's 我执 pool as a red bar over her drained HP bar (`b.snap neg`), 伺夜's 狼群 count as a row of diamonds under the bars, the lit ones the 狼影 left (`b.snap wolves`; 0.2.2, GitHub PR #303 / #378 / #383), status icons, the element gauge row under the bars — icon + white bar of the remaining 元素值, element-coloured refill during a 爆发冷却 (§18.3, §20.8); enemies drawn × their official prefab factor (enemies.json `modelScale`, §20.4); an enemy's mode — the `form` of a sim fx (`shared/protocol.js fxForm`), or UnitInfo `form` for a view built later through `render/app/info.js renderInfo` — switches clip sets (`render/units.js FORMS`: 掠海漂移体 crawl, 暴鸰 bombed, 转译基底's forms, the 逐火 embers, the leaders' 重生, 守墓石像, the 孤岛风云 prisoners' warning and 解放; closing clips timed from the fx `dur`), a one-off cast (`PROJ[kind].once`) plays once, and a local-only model's web alias is tinted (`ALIAS_TINT`: 灼热 / 炽焰源石虫, §21.14); damage numbers (phys orange-white, arts purple, true white, heal green); projectiles drawn per kind, hit sparks and melee slashes, skill activation bursts and auras (§17.3); death fade for enemies, summons and devices — a knocked-out operator stays on its tile in its held Die pose under a redeploy ring (§18.3); leak flash at the objective. Fallback when Spine missing/failed: avatar in a rarity-coloured diamond with bob/lunge tweens — a failed or timed-out model is loaded again (bounded), and a view re-resolves its model and avatar when the asset manifest arrives late or the tab is shown again (§21.27). 60 fps target with ≤ 120 units. Picking (which unit a press / hover / dropped item is on) and the drag target follow one rule, `render/pick.js` — the tile under the pointer (§18.1); the Spine store never hands out a skeleton whose unload is in flight (§17.1). --- diff --git a/public/js/render/app.js b/public/js/render/app.js index 32784604..0753abe3 100644 --- a/public/js/render/app.js +++ b/public/js/render/app.js @@ -148,14 +148,17 @@ export { releaseGl } from './app/host.js'; /** * Create the battlefield view inside `host` (an element sized by CSS; the canvas fills it). * @param {HTMLElement} host - * @param {{ data?: any, assets?: any, audio?: any, settings?: { damageNumbers?: boolean, quality?: string }, + * @param {{ data?: any, assets?: any, audio?: any, settings?: { damageNumbers?: boolean, quality?: string }, signal?: AbortSignal, * padding?: object|((kind:string, size:{width:number,height:number}) => object), * hud?: {top:number,bottom:number}|((kind:'prep'|'bossPrep', size:{width:number,height:number}, o:{shop:boolean}) => {top:number,bottom:number}|null) }} [opts] */ export async function createFieldView(host, options = {}) { if (!host || typeof host.appendChild !== 'function') throw new TypeError('createFieldView: host element required'); const opts = options && typeof options === 'object' ? options : {}; - const P = await ensurePixi(); + const signal = opts.signal; + signal?.throwIfAborted(); + const P = await withTimeout(ensurePixi(), null, signal); + signal?.throwIfAborted(); const assets = resolveAssets(opts.assets); const data = makeData(opts.data); const settings = { damageNumbers: true, quality: 'high', ...(opts.settings || {}) }; @@ -164,19 +167,28 @@ export async function createFieldView(host, options = {}) { const want3d = boardPref !== '2d' && webgl2Available(boardPref === '3d'); // three.js (~2 MB) is fetched only when the local-art manifest lists the board atlas (in parallel with the art) const artListed = want3d ? boardArtListed(assets).catch(() => false) : Promise.resolve(false); - const threePromise = artListed.then((ok) => (ok ? loadThree() : null)); - const packPromise = artListed.then((ok) => (ok ? Promise.resolve(assets.ready ? assets.ready() : null).catch(() => null).then(() => loadBoardPack(assets)) : null)); + const threePromise = artListed.then((ok) => (ok ? loadThree() : null)).catch(() => null); + const packPromise = artListed.then((ok) => (ok ? Promise.resolve(assets.ready ? assets.ready() : null).catch(() => null).then(() => loadBoardPack(assets)) : null)).catch(() => null); // the manifest, and the optional local-art manifest in parallel: unit views pick an enemy's local-client model by it // (assets.js spineEntry, DESIGN §13 — 灼热源石虫 / 炽焰源石虫); absent or slow, they draw the web models await withTimeout(Promise.all([assets.ready ? assets.ready() : null, assets.local ? assets.local() : null] - .map((p) => Promise.resolve(p).catch(() => {}))), 4000); + .map((p) => Promise.resolve(p).catch(() => {}))), 4000, signal); + signal?.throwIfAborted(); // web fonts for the bitmap damage numbers / tier chips (never block long) - try { if (document.fonts?.load) await withTimeout(Promise.all([document.fonts.load('700 40px Bender'), document.fonts.load('700 40px Oxanium')]), 1500); } catch { /* ignore */ } + try { if (document.fonts?.load) await withTimeout(Promise.all([document.fonts.load('700 40px Bender'), document.fonts.load('700 40px Oxanium')]), 1500, signal); } catch { /* optional fonts */ } + signal?.throwIfAborted(); const size = () => ({ width: Math.max(1, host.clientWidth || 1), height: Math.max(1, host.clientHeight || 1) }); const dpr = () => Math.min(globalThis.devicePixelRatio || 1, QUALITY_RES[settings.quality] || 2); const boardDpr = () => Math.min(globalThis.devicePixelRatio || 1, BOARD_RES[settings.quality] || 2); const s0 = size(); + // Construction is synchronous until the complete view can own cancellation. If setup throws, + // dispose only the resources acquired by this attempt, never another view's host contents. + const setupCleanup = []; + let destroyed = false; + let view = null; + let onAbort; + try { const app = new P.Application({ // MSAA only where it pays: dense (DPR ≥ 1.5) screens are sharp enough without it and it would cost 4× the fill // transparent: the 3D board canvas shows through (the 2D board paints an opaque backdrop itself) @@ -184,6 +196,11 @@ export async function createFieldView(host, options = {}) { resolution: dpr(), autoDensity: true, powerPreference: 'high-performance', }); const canvas = app.view; + setupCleanup.push(() => { + releaseGl(app.renderer); + try { app.destroy(true, { children: true, texture: false, baseTexture: false }); } + finally { canvas.remove(); } + }); canvas.style.display = 'block'; canvas.style.width = '100%'; canvas.style.height = '100%'; @@ -240,7 +257,6 @@ export async function createFieldView(host, options = {}) { for (const fn of [...set]) { try { fn(payload); } catch (err) { console.error(`[render] ${name} listener failed`, err); } } }; - let destroyed = false; let mode = 'idle'; // 'idle' | 'prep' | 'battle' let stageRec = null; let cam = presetCamera('prep', { width: s0.width, height: s0.height, padding: defaultPadding('prep', s0) }, { hud: hudBands('prep', s0) }); @@ -306,6 +322,7 @@ export async function createFieldView(host, options = {}) { surfaceLayer: (row) => tiles.surfaceLayer(row), }; const tiles = new TileField({ ground: layers.ground, overlay: layers.overlay, props: layers.units, anim: layers.anim }); + setupCleanup.push(() => tiles.destroy()); const fx = new FxSystem({ P, layers, cam: () => cam, settings, assets, heightAt, redVignette: bg.red, surfaceLayer: ctx.surfaceLayer, timeScale: () => ctx.timeScale(), @@ -320,10 +337,12 @@ export async function createFieldView(host, options = {}) { return Math.max(80, Math.min(size().height * 0.4, p.y)); }, }); + setupCleanup.push(() => fx.destroy()); ctx.fx = fx; // battle devices (crates / turrets as sim units): the official crate mesh in the 3D scene, else a Pixi box ctx.createBox = () => switchableBox({ board: () => board3d, pixi: () => tiles.createBox() }); const impostors = new ImpostorAtlas(app.renderer); + setupCleanup.push(() => impostors.destroy()); ctx.impostors = impostors; tiles.setView(bandFor('prep'), camRect(), fieldRows('prep')); // the real board art of the local client (optional): wait briefly so the first frame already uses it; a late @@ -332,7 +351,6 @@ export async function createFieldView(host, options = {}) { if (art && !destroyed) { tiles.setArt(art); tiles.project(cam, true); } return art; }, () => null); - await withTimeout(artPromise, 2500); // ---- 3D board layer (render/board3d, DESIGN §15) ----------------------------------------------------------- let board3d = null; // BoardScene while the 3D board is on @@ -340,6 +358,7 @@ export async function createFieldView(host, options = {}) { let board3dError = null; // lost-context recovery: the THREE module + art pack of the last 3D board, the pending retry, failures so far const recover = { THREE: null, pack: null, timer: 0, tries: 0, since: 0, off: false, count: 0 }; + setupCleanup.push(() => { clearTimeout(recover.timer); disable3d(); }); function enable3d(THREE, pack) { if (destroyed || board3d || !THREE || !pack) return false; recover.THREE = THREE; recover.pack = pack; @@ -409,13 +428,13 @@ export async function createFieldView(host, options = {}) { if (!enable3d(recover.THREE, recover.pack)) scheduleRecover(); }, delay); } - if (want3d) { - const ready = Promise.all([threePromise, packPromise]).then(([THREE, pack]) => (THREE && pack ? enable3d(THREE, pack) : false), () => false); - await withTimeout(ready, 6000); - } + // Attach the board only after synchronous setup completes; enable3d also guards late arrivals. + const boardReady = Promise.all([threePromise, packPromise]).then(([THREE, pack]) => (THREE && pack ? enable3d(THREE, pack) : false), () => false); // the official soft shadow sprite replaces the procedural one once loaded (may already be cached; asked again when the // manifest arrives late) let shadowAsked = false; + let offShadow = null; + setupCleanup.push(() => offShadow?.()); function loadShadow() { const shadowUrl = !shadowAsked && assets.ui ? assets.ui('battle/sprite_shadow') : null; if (!shadowUrl) return; @@ -428,7 +447,11 @@ export async function createFieldView(host, options = {}) { for (const v of views.values()) if (v.shadow && !v.destroyed) v.shadow.texture = t; for (const v of penViews.values()) if (v.shadow && !v.destroyed) v.shadow.texture = t; }; - if (t.baseTexture.valid) use(); else t.baseTexture.once('loaded', use); + if (t.baseTexture.valid) use(); + else { + t.baseTexture.once('loaded', use); + offShadow = () => t.baseTexture.off('loaded', use); + } } catch { /* optional */ } } loadShadow(); @@ -1206,6 +1229,17 @@ export async function createFieldView(host, options = {}) { // the 收起 toggle of PR #149 moved the discs one button to the right) opened the bond popup instead of selecting the // unit. As a click target that holds the finger's point the canvas wins: a tap on the board stays on the tile under it. const onTapTarget = () => {}; + const removeInput = () => { + canvas.removeEventListener('pointerdown', onPointerDown); + canvas.removeEventListener('pointermove', onPointerMove); + canvas.removeEventListener('pointerup', onPointerUp); + canvas.removeEventListener('pointercancel', onPointerCancel); + canvas.removeEventListener('pointerleave', onPointerLeave); + canvas.removeEventListener('contextmenu', onContext); + canvas.removeEventListener('touchend', onTouchEnd); + canvas.removeEventListener('click', onTapTarget); + }; + setupCleanup.push(removeInput); canvas.addEventListener('pointerdown', onPointerDown); canvas.addEventListener('pointermove', onPointerMove); canvas.addEventListener('pointerup', onPointerUp); @@ -1655,6 +1689,7 @@ export async function createFieldView(host, options = {}) { let ro = null; if (typeof ResizeObserver === 'function') { ro = new ResizeObserver(() => resize()); + setupCleanup.push(() => ro.disconnect()); ro.observe(host); } // a DPR change without a size change (window dragged to another monitor, zoom) must re-rasterise too; polled @@ -1677,6 +1712,7 @@ export async function createFieldView(host, options = {}) { leader?.view.retryAssets?.(); } const offAssets = typeof assets.onChange === 'function' ? assets.onChange(onAssets) : null; + setupCleanup.push(() => offAssets?.()); const onVisible = () => { if (destroyed || globalThis.document?.visibilityState !== 'visible') return; if (assets.loaded === false && typeof assets.ready === 'function') assets.ready(); @@ -1685,10 +1721,11 @@ export async function createFieldView(host, options = {}) { leader?.view.retryAssets?.(); }; globalThis.document?.addEventListener?.('visibilitychange', onVisible); + setupCleanup.push(() => globalThis.document?.removeEventListener?.('visibilitychange', onVisible)); // ---- public API --------------------------------------------------------------------------------------------- - const view = { + view = { setStage, setCamera, setPrep, @@ -1804,14 +1841,9 @@ export async function createFieldView(host, options = {}) { try { ro?.disconnect(); } catch { /* ignore */ } try { offAssets?.(); } catch { /* ignore */ } globalThis.document?.removeEventListener?.('visibilitychange', onVisible); - canvas.removeEventListener('pointerdown', onPointerDown); - canvas.removeEventListener('pointermove', onPointerMove); - canvas.removeEventListener('pointerup', onPointerUp); - canvas.removeEventListener('pointercancel', onPointerCancel); - canvas.removeEventListener('pointerleave', onPointerLeave); - canvas.removeEventListener('contextmenu', onContext); - canvas.removeEventListener('touchend', onTouchEnd); - canvas.removeEventListener('click', onTapTarget); + removeInput(); + offShadow?.(); + signal?.removeEventListener('abort', onAbort); app.ticker.remove(frame); app.ticker.remove(preRender); app.ticker.remove(postRender); @@ -1829,6 +1861,7 @@ export async function createFieldView(host, options = {}) { try { board3dCanvas?.remove(); } catch { /* ignore */ } releaseGl(app.renderer); try { app.destroy(true, { children: true, texture: false, baseTexture: false }); } catch { /* ignore */ } + canvas.remove(); }, stats() { return { @@ -1848,5 +1881,22 @@ export async function createFieldView(host, options = {}) { pick: { pieceAt, battleUnitAt, penUnitAt, groundTile }, }, }; + // All teardown dependencies now exist. Aborting during either asset wait immediately releases + // the renderer, ticker and listeners; late shared asset results see destroyed and do nothing. + setupCleanup.length = 0; + onAbort = () => view.destroy(); + signal?.addEventListener('abort', onAbort, { once: true }); + signal?.throwIfAborted(); + await withTimeout(artPromise, 2500, signal); + if (want3d) await withTimeout(boardReady, 6000, signal); + signal?.throwIfAborted(); return view; + } catch (err) { + if (view) view.destroy(); + else { + destroyed = true; + for (const dispose of setupCleanup.reverse()) { try { dispose(); } catch { /* continue cleanup */ } } + } + throw err; + } } diff --git a/public/js/render/app/host.js b/public/js/render/app/host.js index fee19919..79d38ff4 100644 --- a/public/js/render/app/host.js +++ b/public/js/render/app/host.js @@ -4,8 +4,22 @@ import { createAssets, assets as defaultAssets } from '../../assets.js'; import { standInRecord } from '../../../../shared/standIn.js'; import { diyRecordOf } from '../../../../shared/diy.js'; -function withTimeout(p, ms) { - return Promise.race([p, new Promise((resolve) => setTimeout(resolve, ms))]); +/** Wait for optional assets, or stop waiting when this view is cancelled. Shared loads keep running. */ +function withTimeout(p, ms, signal) { + let timer; + let onAbort; + const stopped = new Promise((resolve, reject) => { + if (ms != null) timer = setTimeout(resolve, ms); + if (signal) { + onAbort = () => reject(signal.reason); + signal.addEventListener('abort', onAbort, { once: true }); + if (signal.aborted) onAbort(); + } + }); + return Promise.race([p, stopped]).finally(() => { + clearTimeout(timer); + if (onAbort) signal.removeEventListener('abort', onAbort); + }); } /** Wrap whatever the caller passed as `assets` into the store API of public/js/assets.js. */ diff --git a/public/js/ui/fieldHost.js b/public/js/ui/fieldHost.js index eeec6551..f40e4db4 100644 --- a/public/js/ui/fieldHost.js +++ b/public/js/ui/fieldHost.js @@ -132,8 +132,32 @@ function renderPref() { return globalThis.__SP_RENDER__ === 'fallback' ? 'fallback' : 'auto'; } -function withTimeout(p, ms, what) { - return Promise.race([p, new Promise((_, rej) => setTimeout(() => rej(new Error(`${what} timed out`)), ms))]); +function abortError(signal) { + return signal?.reason ?? new DOMException('Field view initialization cancelled', 'AbortError'); +} + +function checkActive(signal) { + if (signal?.aborted) throw abortError(signal); +} + +function withTimeout(p, ms, what, signal) { + return new Promise((resolve, reject) => { + let settled = false; + let timer; + const finish = (done, value) => { + if (settled) return; + settled = true; + clearTimeout(timer); + signal?.removeEventListener('abort', onAbort); + done(value); + }; + const onAbort = () => finish(reject, abortError(signal)); + signal?.addEventListener('abort', onAbort, { once: true }); + timer = setTimeout(() => finish(reject, new Error(`${what} timed out`)), ms); + // Keep a rejection handler attached even when cancellation wins before this promise settles. + Promise.resolve(p).then((value) => finish(resolve, value), (err) => finish(reject, err)); + if (signal?.aborted) onAbort(); + }); } /** @@ -186,34 +210,55 @@ export function seedAssets(store) { /** * Create a field view in `host`: the render engine when available, else the DOM fallback. * @param {HTMLElement} host + * @param {{ signal?: AbortSignal }} [options] Cancels a pending mount without creating a fallback. * @returns {Promise>} */ -export async function mountFieldView(host) { +export async function mountFieldView(host, { signal } = {}) { + checkActive(signal); const pref = renderPref(); const opts = { data, assets: data.get('assets'), audio, settings: settingsStore.get(), padding: hudPadding, hud: hudBands }; if (pref !== 'fallback') { + const controller = new AbortController(); + const onAbort = () => controller.abort(abortError(signal)); + signal?.addEventListener('abort', onAbort, { once: true }); + if (signal?.aborted) onAbort(); + let engineView = null; + const dispose = (view) => { try { view?.destroy?.(); } catch { /* ignore */ } }; try { + checkActive(controller.signal); // the shared asset store (public/js/assets.js) keeps its Spine cache across remounts (next match, reconnect) - const am = await withTimeout(import('../assets.js'), LOAD_TIMEOUT_MS, 'asset store import').catch(() => null); + const am = await withTimeout(import('../assets.js'), LOAD_TIMEOUT_MS, 'asset store import', controller.signal).catch(() => { + checkActive(controller.signal); + return null; + }); + checkActive(controller.signal); if (am?.assets && typeof am.assets.ready === 'function') { opts.assets = am.assets; seedAssets(am.assets); } - const mod = await withTimeout(import('../render/app.js'), LOAD_TIMEOUT_MS, 'render engine import'); + const mod = await withTimeout(import('../render/app.js'), LOAD_TIMEOUT_MS, 'render engine import', controller.signal); + checkActive(controller.signal); if (typeof mod?.createFieldView !== 'function') throw new Error('createFieldView missing'); - const view = await withTimeout(Promise.resolve(mod.createFieldView(host, opts)), LOAD_TIMEOUT_MS, 'createFieldView'); + // A factory that ignores cancellation still belongs to this attempt when it eventually returns. + const pending = Promise.resolve(mod.createFieldView(host, { ...opts, signal: controller.signal })).then((view) => { + if (controller.signal.aborted) dispose(view); else engineView = view; + return view; + }); + const view = await withTimeout(pending, LOAD_TIMEOUT_MS, 'createFieldView', controller.signal); + checkActive(controller.signal); const missing = METHODS.filter((k) => typeof view?.[k] !== 'function'); - if (missing.length) { - try { view?.destroy?.(); } catch { /* ignore */ } - throw new Error(`view lacks ${missing.join(', ')}`); - } + if (missing.length) throw new Error(`view lacks ${missing.join(', ')}`); return guardView(view, 'engine'); } catch (err) { + controller.abort(); + dispose(engineView); + checkActive(signal); console.warn('[field] render engine unavailable, using the simplified view:', err?.message || err); - // anything the engine left behind in the host goes - try { while (host.firstChild) host.removeChild(host.firstChild); } catch { /* ignore */ } + } finally { + signal?.removeEventListener('abort', onAbort); } } + checkActive(signal); return guardView(createFallbackView(host, { ...opts, assets: data.get('assets') }), 'fallback'); } @@ -228,17 +273,19 @@ export function useFieldView(hostRef) { let dead = false; const host = hostRef.current; if (!host) return undefined; - mountFieldView(host).then((view) => { + const controller = new AbortController(); + mountFieldView(host, { signal: controller.signal }).then((view) => { if (dead) { view.destroy(); return; } viewRef.current = view; globalThis.__SP_VIEW__ = view; // dev / E2E introspection (view.raw.stats?.()) setState({ view, kind: view.kind }); - }, (err) => console.error('[field] mount failed', err)); + }, (err) => { if (!dead && !controller.signal.aborted) console.error('[field] mount failed', err); }); const unsub = settingsStore.subscribe((s) => viewRef.current?.setSettings?.(s)); const onResize = () => viewRef.current?.resize(); window.addEventListener('resize', onResize); return () => { dead = true; + controller.abort(); unsub(); window.removeEventListener('resize', onResize); // the dev hook must not keep the destroyed view — and through its host the whole detached match screen — alive diff --git a/test/render/initialization.browser.test.js b/test/render/initialization.browser.test.js new file mode 100644 index 00000000..b8ba3d17 --- /dev/null +++ b/test/render/initialization.browser.test.js @@ -0,0 +1,343 @@ +// Real renderer startup and cancellation, without downloaded art. +// RENDER_E2E=1 node --test test/render/initialization.browser.test.js +import { test, describe, before, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { existsSync, mkdirSync, readFileSync } 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 skip = process.env.RENDER_E2E === '1' && existsSync(CHROME) ? false : 'set RENDER_E2E=1 (needs Chrome)'; +const HTML = ` +
`; + +// These wrappers keep the real browser/Pixi behavior and record resources owned by each view. +function trackBrowserResources() { + const state = window.__initialization = { apps: [], observers: [], listeners: [], calls: 0, settled: 0 }; + const timers = new Map(); + const set = window.setTimeout.bind(window), clear = window.clearTimeout.bind(window); + window.setTimeout = (fn, ms, ...args) => { + const id = set(() => { timers.delete(id); fn(...args); }, ms); + if (ms === 12000) timers.set(id, () => fn(...args)); + return id; + }; + window.clearTimeout = (id) => { timers.delete(id); clear(id); }; + state.expire = () => { + for (const [id, fn] of [...timers]) { clear(id); timers.delete(id); fn(); } + }; + const NativeObserver = window.ResizeObserver; + window.ResizeObserver = class extends NativeObserver { + constructor(fn) { super(fn); this.targets = new Set(); state.observers.push(this); } + observe(target, ...args) { this.targets.add(target); return super.observe(target, ...args); } + unobserve(target) { this.targets.delete(target); return super.unobserve(target); } + disconnect() { this.targets.clear(); return super.disconnect(); } + }; + const add = EventTarget.prototype.addEventListener, remove = EventTarget.prototype.removeEventListener; + const tracked = (target, type) => target instanceof HTMLCanvasElement || + (target === document && type === 'visibilitychange') || (target === window && type === 'resize'); + const capture = (opts) => typeof opts === 'boolean' ? opts : !!opts?.capture; + EventTarget.prototype.addEventListener = function (type, fn, opts) { + if (tracked(this, type) && !state.listeners.some((r) => r.target === this && r.type === type && r.fn === fn && r.capture === capture(opts))) { + state.listeners.push({ target: this, type, fn, capture: capture(opts) }); + } + return add.call(this, type, fn, opts); + }; + EventTarget.prototype.removeEventListener = function (type, fn, opts) { + state.listeners = state.listeners.filter((r) => !(r.target === this && r.type === type && r.fn === fn && r.capture === capture(opts))); + return remove.call(this, type, fn, opts); + }; + state.snapshot = () => ({ + apps: state.apps.map(({ app, ticker, canvas }) => ({ destroyed: app.renderer === null, ticking: ticker.started, attached: canvas.isConnected })), + observers: state.observers.filter((r) => r.targets.size).length, + listeners: state.listeners.map((r) => r.type), + canvases: document.querySelectorAll('#host canvas').length, + fallbacks: document.querySelectorAll('#host .ff').length, + }); +} + +function trackPixiApplications() { + const Application = PIXI.Application; + PIXI.Application = class extends Application { + constructor(...args) { + super(...args); + window.__initialization.apps.push({ app: this, ticker: this.ticker, canvas: this.view }); + } + }; +} + +const FACTORY_WRAPPER = `export * from '/js/render/app.js?implementation'; +import { createFieldView as create } from '/js/render/app.js?implementation'; +export async function createFieldView(...args) { + window.__initialization.calls++; + if (window.__initialization.ignoreSignal) args[1] = { ...args[1], signal: undefined }; + try { return await create(...args); } + finally { window.__initialization.settled++; } +}`; + +describe('field initialization in headless Chrome', { skip }, () => { + let srv, browser, pixi; + before(async () => { + const puppeteer = (await import('puppeteer-core')).default; + const { startServer } = await import('../../server/index.js'); + srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true }); + browser = await puppeteer.launch({ executablePath: CHROME, headless: true, args: ['--no-first-run'] }); + pixi = readFileSync(path.join(ROOT, 'public/vendor/pixi.min.js'), 'utf8') + `\n;(${trackPixiApplications.toString()})();`; + }); + after(async () => { await browser?.close(); await srv?.close(); }); + + async function open(t, { holdPixi = false } = {}) { + const page = await browser.newPage(); + t.after(() => page.close()); + const errors = []; + page.on('pageerror', (err) => errors.push(err.message)); + await page.setViewport({ width: 1100, height: 750 }); + await page.evaluateOnNewDocument(trackBrowserResources); + await page.setRequestInterception(true); + let releasePixi; + const requested = new Promise((resolve) => { + page.on('request', (request) => { + const url = new URL(request.url()); + if (url.pathname === '/initialization-test') return request.respond({ status: 200, contentType: 'text/html', body: HTML }); + if (url.pathname === '/js/render/app.js' && !url.search) return request.respond({ status: 200, contentType: 'text/javascript', body: FACTORY_WRAPPER }); + if (url.pathname === '/vendor/pixi.min.js') { + releasePixi = () => request.respond({ status: 200, contentType: 'text/javascript', body: pixi }); + resolve(); + if (!holdPixi) return releasePixi(); + return; + } + return request.continue(); + }); + }); + await page.goto(`http://127.0.0.1:${srv.port}/initialization-test?board=2d`); + // Empty manifests prevent optional local art requests while preserving the real asset store. + await page.evaluate(async () => { + const { assets } = await import('/js/assets.js'); + assets.seed({ version: 1, chars: {}, enemies: {}, tokens: {}, items: {} }); + assets.seedLocal({ version: 1, files: {} }); + const { data } = await import('/js/data.js'); + await data.load('stages'); + window.__initialization.stage = data.list('stages')[0]; + window.__initialization.host = document.getElementById('host'); + }); + return { page, errors, requested, release: () => releasePixi() }; + } + + async function start(page) { + await page.evaluate(async () => { + const { mountFieldView } = await import('/js/ui/fieldHost.js'); + const s = window.__initialization; + s.mount = mountFieldView(s.host).then((view) => { + s.view = view; + view.setStage(s.stage); + view.setPrep({ board: [], hand: [], temp: [] }, { editable: true }); + s.clicks = []; + view.on('tileClick', (click) => s.clicks.push([click.row, click.col])); + }); + }); + } + + async function pixiFinished(page) { + await page.evaluate(async () => { + const { ensurePixi } = await import('/js/render/app/pixi.js'); + await ensurePixi(); + await new Promise(requestAnimationFrame); + }); + } + + async function clickFallback(page) { + await page.waitForSelector('#host .ff-tile--floor'); + const tile = await page.$eval('#host .ff-tile--floor', (element) => { + const r = element.getBoundingClientRect(); + return { x: r.x + r.width / 2, y: r.y + r.height / 2, row: +element.dataset.row, col: +element.dataset.col }; + }); + await page.mouse.click(tile.x, tile.y); + return [tile.row, tile.col]; + } + + test('successful startup owns one running app and destroy releases its browser resources', async (t) => { + const { page, errors } = await open(t); + await start(page); + await page.waitForFunction(() => window.__initialization.view?.kind === 'engine'); + const mounted = await page.evaluate(() => window.__initialization.snapshot()); + assert.equal(mounted.canvases, 1); + assert.deepEqual(mounted.apps, [{ destroyed: false, ticking: true, attached: true }]); + assert.equal(mounted.observers, 1); + assert.ok(mounted.listeners.includes('pointerdown')); + assert.ok(mounted.listeners.includes('visibilitychange')); + const destroyed = await page.evaluate(() => { + const s = window.__initialization; + s.view.destroy(); s.view.destroy(); + return s.snapshot(); + }); + assert.deepEqual(destroyed, { apps: [{ destroyed: true, ticking: false, attached: false }], observers: 0, listeners: [], canvases: 0, fallbacks: 0 }); + assert.deepEqual(errors, []); + }); + + for (const ignoreSignal of [false, true]) test(`a timed-out Pixi load cannot cover the clickable fallback (${ignoreSignal ? 'legacy factory ignores cancellation' : 'cancellable factory'})`, async (t) => { + const { page, errors, requested, release } = await open(t, { holdPixi: true }); + await page.evaluate((value) => { window.__initialization.ignoreSignal = value; }, ignoreSignal); + await start(page); + await requested; + await page.evaluate(() => window.__initialization.expire()); + await page.waitForFunction(() => window.__initialization.view?.kind === 'fallback'); + const target = await clickFallback(page); + assert.deepEqual(await page.evaluate(() => window.__initialization.clicks), [target]); + await release(); + await pixiFinished(page); + await page.waitForFunction(() => window.__initialization.settled === 1); + await clickFallback(page); + const late = await page.evaluate(() => ({ ...window.__initialization.snapshot(), clicks: window.__initialization.clicks })); + assert.deepEqual(late.clicks, [target, target], 'real pointer input still reaches the fallback after Pixi finishes'); + assert.equal(late.canvases, 0, 'the abandoned engine must not append an orphan canvas'); + assert.equal(late.fallbacks, 1); + assert.ok(late.apps.every((app) => app.destroyed && !app.ticking && !app.attached)); + assert.deepEqual(late.listeners, []); + assert.equal(late.observers, 1, 'only the fallback observes the host'); + if (!ignoreSignal) { + mkdirSync(OUT, { recursive: true }); + await page.screenshot({ path: path.join(OUT, 'render-initialization-fallback.png') }); + } + const destroyed = await page.evaluate(() => { window.__initialization.view.destroy(); return window.__initialization.snapshot(); }); + assert.equal(destroyed.observers, 0); + assert.equal(destroyed.canvases, 0); + assert.equal(destroyed.fallbacks, 0); + assert.deepEqual(errors, []); + }); + + test('timeout during board-art startup immediately releases the allocated app and permits a fresh engine', async (t) => { + const { page, errors } = await open(t); + await page.evaluate(async () => { + const { assets } = await import('/js/assets.js'); + const s = window.__initialization, local = assets.local.bind(assets); + assets.local = () => { + if (!s.apps.length) return local(); + assets.local = local; + s.artBlocked = true; + return new Promise((resolve) => { s.releaseArt = async () => resolve(await local()); }); + }; + }); + await start(page); + await page.waitForFunction(() => window.__initialization.artBlocked); + const allocated = await page.evaluate(() => window.__initialization.snapshot()); + assert.deepEqual(allocated.apps, [{ destroyed: false, ticking: true, attached: true }]); + await page.evaluate(() => window.__initialization.expire()); + await page.waitForFunction(() => window.__initialization.view?.kind === 'fallback'); + const cancelled = await page.evaluate(() => window.__initialization.snapshot()); + assert.deepEqual(cancelled.apps, [{ destroyed: true, ticking: false, attached: false }], 'cancellation frees the real renderer before the delayed art resolves'); + assert.equal(cancelled.canvases, 0); + assert.equal(cancelled.observers, 1); + assert.deepEqual(cancelled.listeners, []); + const target = await clickFallback(page); + assert.deepEqual(await page.evaluate(() => window.__initialization.clicks), [target]); + await page.evaluate(async () => { + const s = window.__initialization; + await s.releaseArt(); + s.view.destroy(); + s.view = null; + }); + await page.waitForFunction(() => window.__initialization.settled === 1); + await start(page); + await page.waitForFunction(() => window.__initialization.view?.kind === 'engine'); + const replacement = await page.evaluate(async () => { + await new Promise(requestAnimationFrame); + const s = window.__initialization, app = s.view.raw.debug.app; + app.renderer.render(app.stage); + return s.snapshot(); + }); + assert.deepEqual(replacement.apps, [ + { destroyed: true, ticking: false, attached: false }, + { destroyed: false, ticking: true, attached: true }, + ], 'a replacement engine can render with the surviving shared caches'); + assert.equal(replacement.canvases, 1); + const destroyed = await page.evaluate(() => { window.__initialization.view.destroy(); return window.__initialization.snapshot(); }); + assert.ok(destroyed.apps.every((app) => app.destroyed && !app.ticking && !app.attached)); + assert.equal(destroyed.observers, 0); + assert.deepEqual(destroyed.listeners, []); + assert.equal(destroyed.canvases, 0); + assert.deepEqual(errors, []); + }); + + test('a setup exception releases partial resources without removing another host child', async (t) => { + const { page, errors } = await open(t); + await page.evaluate(async () => { + const { assets } = await import('/js/assets.js'); + const s = window.__initialization, subscribe = assets.onChange.bind(assets); + s.sibling = document.createElement('span'); + s.sibling.textContent = 'Existing host content'; + s.host.appendChild(s.sibling); + // Fail after input and resize resources exist, while the real view is still being built. + assets.onChange = (fn) => { + if (s.observers.some((observer) => observer.targets.has(s.host))) { + assets.onChange = subscribe; + throw new Error('asset subscription unavailable'); + } + return subscribe(fn); + }; + }); + await start(page); + await page.waitForFunction(() => window.__initialization.view?.kind === 'fallback'); + const failed = await page.evaluate(() => ({ ...window.__initialization.snapshot(), sibling: window.__initialization.sibling.isConnected })); + assert.deepEqual(failed.apps, [{ destroyed: true, ticking: false, attached: false }]); + assert.equal(failed.sibling, true, 'failed initialization only cleans up its own resources'); + assert.equal(failed.canvases, 0); + assert.equal(failed.observers, 1); + assert.deepEqual(failed.listeners, []); + const target = await clickFallback(page); + assert.deepEqual(await page.evaluate(() => window.__initialization.clicks), [target]); + const destroyed = await page.evaluate(() => { window.__initialization.view.destroy(); return window.__initialization.snapshot(); }); + assert.equal(destroyed.observers, 0); + assert.equal(destroyed.fallbacks, 0); + assert.deepEqual(errors, []); + }); + + test('unmount during startup cannot erase a newer hook mount on the same host', async (t) => { + const { page, errors, requested, release } = await open(t, { holdPixi: true }); + await page.evaluate(async () => { + const { h, render } = await import('/vendor/preact.module.js'); + const { useFieldView } = await import('/js/ui/fieldHost.js'); + const s = window.__initialization; + function Field() { s.hook = useFieldView({ current: s.host }); return null; } + s.renderHook = (key) => render(key ? h(Field, { key }) : null, document.getElementById('hooks')); + s.renderHook('first'); + }); + await requested; + await page.evaluate(() => { + const s = window.__initialization; + s.renderHook(null); + window.__SP_RENDER__ = 'fallback'; + s.renderHook('second'); + }); + await page.waitForFunction(() => window.__initialization.hook?.kind === 'fallback'); + await page.evaluate(() => { + const s = window.__initialization; + s.current = s.hook.view; + s.current.setStage(s.stage); + s.current.setPrep({ board: [], hand: [], temp: [] }, { editable: true }); + s.clicks = []; + s.current.on('tileClick', (click) => s.clicks.push([click.row, click.col])); + s.expire(); + }); + await release(); + await pixiFinished(page); + await page.waitForFunction(() => window.__initialization.settled === 1); + const late = await page.evaluate(() => ({ ...window.__initialization.snapshot(), current: window.__SP_VIEW__ === window.__initialization.current })); + assert.equal(late.fallbacks, 1, 'the old mount must not clear the newer view from the shared host'); + assert.equal(late.canvases, 0); + assert.equal(late.current, true); + assert.ok(late.apps.every((app) => app.destroyed && !app.ticking && !app.attached)); + const target = await clickFallback(page); + assert.deepEqual(await page.evaluate(() => window.__initialization.clicks), [target]); + const destroyed = await page.evaluate(() => { + window.__initialization.renderHook(null); + return { ...window.__initialization.snapshot(), current: window.__SP_VIEW__ }; + }); + assert.equal(destroyed.current, null); + assert.equal(destroyed.observers, 0); + assert.equal(destroyed.canvases, 0); + assert.equal(destroyed.fallbacks, 0); + assert.deepEqual(destroyed.listeners, []); + assert.deepEqual(errors, []); + }); +}); diff --git a/test/ui/fieldHost-lifecycle.test.js b/test/ui/fieldHost-lifecycle.test.js new file mode 100644 index 00000000..721d56d2 --- /dev/null +++ b/test/ui/fieldHost-lifecycle.test.js @@ -0,0 +1,158 @@ +// Field-host ownership under cancellation and timeout. Browser tests cover the actual renderer; this fixture runs +// the production orchestration with controlled imports and timers, without a DOM or GPU dependency. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { getEventListeners } from 'node:events'; +import vm from 'node:vm'; + +const sourcePath = new URL('../../public/js/ui/fieldHost.js', import.meta.url); +const source = readFileSync(sourcePath, 'utf8') + .replace(/^import .*;$/gm, '').replace(/^export /gm, '') + .replace("import('../assets.js')", 'importAssets()') + .replace("import('../render/app.js')", 'importApp()'); +const METHODS = ['setStage', 'setCamera', 'setPrep', 'enterBattle', 'pushSnapshot', 'pushEvents', 'highlightTiles', 'on', 'resize']; +const flush = async () => { for (let i = 0; i < 16; i++) await Promise.resolve(); }; + +function fixture() { + const timers = new Map(), requests = [], fallbacks = [], warnings = []; + let timerId = 0; + const unrelated = { name: 'unrelated' }; + const host = { + children: [unrelated], + get firstChild() { return this.children[0] || null; }, + appendChild(node) { this.children.push(node); }, + removeChild(node) { const i = this.children.indexOf(node); if (i >= 0) this.children.splice(i, 1); }, + }; + function view(name, valid = true) { + const node = { name }; + host.appendChild(node); + return { + ...(valid ? Object.fromEntries(METHODS.map((k) => [k, () => {}])) : {}), + node, destroyCalls: 0, + destroy() { this.destroyCalls++; host.removeChild(node); }, + }; + } + const context = vm.createContext({ + AbortController, DOMException, URLSearchParams, Promise, + console: { warn: (...args) => warnings.push(args) }, + audio: {}, data: { get() { return null; } }, settingsStore: { get() { return {}; } }, + setTimeout(fn) { timers.set(++timerId, fn); return timerId; }, + clearTimeout(id) { timers.delete(id); }, + importAssets: async () => null, + importApp: async () => ({ + createFieldView(_host, opts) { + // Deliberately ignore cancellation: the host must still dispose a factory's late return value. + return new Promise((resolve, reject) => requests.push({ resolve, reject, signal: opts.signal })); + }, + }), + createFallbackView() { + const fallback = view('fallback'); + fallbacks.push({ view: fallback, rendererAborted: requests.at(-1)?.signal?.aborted }); + return fallback; + }, + }); + vm.runInContext(source + '\nglobalThis.mount = mountFieldView;', context, { filename: sourcePath.pathname }); + return { + host, unrelated, timers, requests, fallbacks, warnings, view, + mount: (signal) => context.mount(host, { signal }), + timeout() { const fn = [...timers.values()].at(-1); assert.ok(fn, 'initialization has a deadline'); fn(); }, + }; +} + +function settled(promise) { + const result = { state: 'pending', value: null }; + promise.then((value) => { result.state = 'fulfilled'; result.value = value; }, (error) => { result.state = 'rejected'; result.value = error; }); + return result; +} + +test('successful initialization releases deadlines and cancellation listeners', async () => { + const f = fixture(), controller = new AbortController(); + const pending = f.mount(controller.signal); + await flush(); + const raw = f.view('engine'); + f.requests[0].resolve(raw); + const mounted = await pending; + assert.equal(mounted.kind, 'engine'); + assert.equal(f.timers.size, 0); + assert.equal(getEventListeners(controller.signal, 'abort').length, 0); + assert.equal(getEventListeners(f.requests[0].signal, 'abort').length, 0); + mounted.destroy(); + assert.equal(raw.destroyCalls, 1); +}); + +test('cancelling a pending mount rejects without fallback and disposes a late result', async () => { + const f = fixture(), controller = new AbortController(); + const result = settled(f.mount(controller.signal)); + await flush(); + controller.abort(); + await flush(); + assert.equal(result.state, 'rejected'); + assert.equal(result.value.name, 'AbortError'); + assert.equal(f.fallbacks.length, 0); + assert.equal(f.warnings.length, 0); + assert.equal(f.timers.size, 0); + assert.equal(getEventListeners(controller.signal, 'abort').length, 0); + const late = f.view('late'); + f.requests[0].resolve(late); + await flush(); + assert.equal(late.destroyCalls, 1); + assert.deepEqual(f.host.children, [f.unrelated]); +}); + +test('cancellation between factory resolution and mount continuation disposes the resolved view', async () => { + const f = fixture(), controller = new AbortController(); + const result = settled(f.mount(controller.signal)); + await flush(); + const raw = f.view('settling'); + f.requests[0].resolve(raw); + await Promise.resolve(); + controller.abort(); + await flush(); + assert.equal(result.state, 'rejected'); + assert.equal(result.value.name, 'AbortError'); + assert.equal(raw.destroyCalls, 1); + assert.equal(f.fallbacks.length, 0); + assert.equal(f.timers.size, 0); + assert.deepEqual(f.host.children, [f.unrelated]); +}); + +test('an invalid renderer API is disposed and aborted before fallback mounts', async () => { + const f = fixture(); + const pending = f.mount(); + await flush(); + const invalid = f.view('invalid', false); + f.requests[0].resolve(invalid); + const fallback = await pending; + assert.equal(fallback.kind, 'fallback'); + assert.equal(invalid.destroyCalls, 1); + assert.equal(f.fallbacks[0].rendererAborted, true); + assert.equal(f.timers.size, 0); + assert.deepEqual(f.host.children, [f.unrelated, f.fallbacks[0].view.node]); + fallback.destroy(); +}); + +test('a timed-out factory returning late cannot remove a replacement view or unrelated children', async () => { + const f = fixture(); + const first = f.mount(); + await flush(); + f.timeout(); + const fallback = await first; + assert.equal(fallback.kind, 'fallback'); + fallback.destroy(); + const replacementPending = f.mount(); + await flush(); + const replacement = f.view('replacement'); + f.requests[1].resolve(replacement); + const mounted = await replacementPending; + const late = f.view('late'); + f.requests[0].resolve(late); + await flush(); + assert.equal(late.destroyCalls, 1); + assert.equal(replacement.destroyCalls, 0); + assert.equal(f.fallbacks[0].rendererAborted, true); + assert.equal(f.timers.size, 0); + assert.deepEqual(f.host.children, [f.unrelated, replacement.node]); + mounted.destroy(); +}); diff --git a/test/ui/playtest2.test.js b/test/ui/playtest2.test.js index 8b5e2f16..7cff96b6 100644 --- a/test/ui/playtest2.test.js +++ b/test/ui/playtest2.test.js @@ -126,7 +126,7 @@ describe('8: the detail card never covers the selected unit\'s underframe', () = const { readFileSync } = await import('node:fs'); const app = readFileSync(new URL('../../public/js/render/app.js', import.meta.url), 'utf8'); assert.match(app, /\n {2}canvas\.addEventListener\('click', onTapTarget\);\n/, 'registered with the other canvas listeners'); - assert.match(app, /\n {6}canvas\.removeEventListener\('click', onTapTarget\);\n/, 'dropped on destroy'); + assert.match(app, /\n +canvas\.removeEventListener\('click', onTapTarget\);\n/, 'dropped on destroy'); assert.match(app, /const onTapTarget = \(\) => \{\};/, 'a no-op: the press itself stays with the pointer events'); }); From 3484178e2ea4a64154c08b8b3540d77c8df8a0d3 Mon Sep 17 00:00:00 2001 From: LimitlessHPPK Date: Thu, 8 Oct 2026 19:16:08 +0800 Subject: [PATCH 3/5] =?UTF-8?q?feedback7=20R22-HUD:=20a=20skill=20cue=20th?= =?UTF-8?q?at=20reaches=20the=20client=20before=20its=20unit=20is=20held?= =?UTF-8?q?=20and=20sounds=20once=20when=20the=20unit=20is=20tracked=20?= =?UTF-8?q?=E2=80=94=20the=20first=20cast=20of=20a=20deploy-time=20skill?= =?UTF-8?q?=20was=20silent=20(PR=20#292=20by=20@LimitlessHPPK,=20without?= =?UTF-8?q?=20handing=20the=20whole=20early=20buffer=20to=20the=20sound);?= =?UTF-8?q?=20golden:=20unchanged?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seen (Grok's review, reproduced here with the real sim): the sim's deploy emits a deployment-activated skill's ['skill', id, 1] before the unit's ['spawn', unitInfo] (宴: three events earlier) and audio.js dropped a skill event of an unknown unit, so that one cast made no sound while every later one did. - audio.js: pendingSkill — a 'skill' ON for an unknown unit is held (one per unit, 64 at most, the oldest go first); _track plays it once when the unit's spawn arrives; a skill OFF is no cast and is not held; setFieldUnits drops the holds. Changed from the PR: setFieldUnits clears the holds BEFORE it tracks the new field's units (the PR cleared after, so a hold left over from the previous field would have sounded for a unit of the new field that shares its id — ids are per battle). - screens/game.js: NOT the PR's change. The pre-entry buffer (keepEarly: every spawn, die, deploy, status and skill since the field's m.field, up to 1500 of them) is handed to the sound as its 'spawn' tuples only (new early.js audioEarly): the whole list would play every old death, deploy line and cast at once on entering. The live path (onEv) already forwards the full list, so the hold alone fixes the live silent first cast; a deploy-tick cast that falls into the short pre-entry gap stays unheard (a deliberate gap — replaying "only the casts before their spawn" would still sound a minute-old cast when a field was buffered while another one was on screen). - Tests: test/ui/audio.test.js (3: the held cast in one batch and across batches, once, no hold for a skill OFF; a hold does not outlive its field and is bounded; 宴's real deploy: the sim's cast comes before the spawn and sounds exactly once — all fail on the parent) and test/match/feedback1-runner-forms.test.js (the audio gets the spawn tuples only: silent replay that still teaches the units, the next live cast sounds; the whole list would have sounded old cues). Docs: design/client.md. - Golden: unchanged (client audio only). --- docs/design/client.md | 2 +- public/js/audio.js | 24 ++++++- public/js/screens/game.js | 4 +- public/js/screens/game/early.js | 9 +++ test/match/feedback1-runner-forms.test.js | 52 ++++++++++++++ test/ui/audio.test.js | 83 +++++++++++++++++++++++ 6 files changed, 170 insertions(+), 4 deletions(-) diff --git a/docs/design/client.md b/docs/design/client.md index 44b812de..63765d80 100644 --- a/docs/design/client.md +++ b/docs/design/client.md @@ -48,7 +48,7 @@ A bond the mode never activates (标准: official `inactiveBondIdList`) reads Interactions (research 09 §1.2 / §5, which supersede the first draft): shop card, upgrade and 机变 card are two-tap (select → confirm, §18.2) — a tap anywhere on the card, its confirm strip included, is the card's tap (shop cards have no ⓘ corner; the armed bar sits above the detail card; a shop card that cannot be bought right now opens its detail on tap, §20.7); a press / drop goes by the tile under the pointer (§18.1); drag bench↔board — a legal board drop (also onto the piece's own tile) opens the 4-direction deploy wheel (swipe UP/RIGHT/DOWN/LEFT with the rotated range striped orange; release in the centre, ✕ 点击取消 or Esc cancels); tap a unit's tile ⇒ underframe with **出售 +N** (and **撤退** on the board; items/Arts in the hand / temp: **销毁** — equipped items are locked, §6.2) plus its rotated range; no drag-to-sell; drag an item onto a unit's tile ⇒ equip that unit (both slots used ⇒ the replace dialog `ui/equipReplace.js` shows the incoming and the two equipped items; the player picks the one to replace — it is destroyed — `g.equip replaceUid`; cancel changes nothing); Arts are dropped on a tile and get the direction step; right-click or long-press ⇒ detail (or a tap on a shop card while the bar is locked); keyboard shortcuts — the defaults, which each player rebinds in 设置 → 快捷键 (`ui/gameLogic/shortcuts.js`, saved with the settings; §25.15) — `R` refresh, `F` freeze, `D` level-up, `Q` retreat / `X` sell the selected operator, `Space` ready (in a solo battle: pause / resume, §14 Solo pause); `Esc` closes (fixed); none acts while a text field has the focus or a dialog is open. All actions optimistic-free: UI waits for server state (latency is small). -Audio: autochess BGM per phase, UI SFX (buy/sell/refresh/level/merge/ready/timer), per-unit attack/hit SFX (throttled; an impact plays for a hostile attack's phys / arts / true damage only, never an operator's skill-mode file for a normal attack, ≤ 2 copies of one battle sound — §18.4), boss music in R14/15. +Audio: autochess BGM per phase, UI SFX (buy/sell/refresh/level/merge/ready/timer), per-unit attack/hit SFX (throttled; an impact plays for a hostile attack's phys / arts / true damage only, never an operator's skill-mode file for a normal attack, ≤ 2 copies of one battle sound — §18.4; a skill cue that reaches the client before its unit — the sim emits a deploy-tick cast ahead of the unit's `['spawn']` — is held and sounds once when the unit is tracked, `AudioManager.pendingSkill`, GitHub PR #292 by @LimitlessHPPK; entering a field late hands the sound the buffered `spawn` tuples only, `screens/game/early.js audioEarly`, so no old death, deploy or cast replays), boss music in R14/15. --- diff --git a/public/js/audio.js b/public/js/audio.js index f7a8784a..72d2ce26 100644 --- a/public/js/audio.js +++ b/public/js/audio.js @@ -25,7 +25,10 @@ // channel over — the official scheduling of `audio_data.json battleVoice.voiceTypeOptions`. // - Battle SFX from `b.ev` tuples (`handleBattleEvents`): at most MAX_VOICES concurrent unit sounds, at most // MAX_PER_URL overlapping copies of one sound (the official banks' maxSoundAllowed 2), a per-unit cooldown and a -// per-URL minimum gap (SfxLimiter), so a 60-unit fight stays listenable. +// per-URL minimum gap (SfxLimiter), so a 60-unit fight stays listenable. A `['skill', id, 1]` that arrives before its +// unit is known is held and played once the unit is tracked (`pendingSkill`): a unit that casts inside its own deploy +// tick emits that first cue before its `['spawn', unitInfo]`, and dropping it left the one cast silent while every +// later one played (GitHub PR #292 by @LimitlessHPPK). // - Impact sounds (user playtest #4 item 6): a 'dmg' plays the `hit` sound of the unit whose hostile attack ('atk' on a // unit of the other side) aimed at the target — once, within IMPACT_WINDOW_MS, and only for phys / arts / true damage. // A heal "attack" ('atk' of a healer on an ally, chain heals) never makes the healer the author of the next damage @@ -76,6 +79,8 @@ const CHAIN_KINDS = new Set(['chain', 'chainHeal']); const IMPACT_TYPES = new Set(['phys', 'arts', 'true']); /** A 'dmg' later than this (real ms) after the attack aimed at the target is not that attack's impact. */ const IMPACT_WINDOW_MS = 2500; +/** Casts held for a unit that is not tracked yet (`pendingSkill`): a hold per unit of a field, never more than this. */ +const PENDING_SKILL_MAX = 64; /** Official operator sound files of a skill mode: `…_d` / `…_h` / `…_s` (+ digits) — the normal attack's end in `_n`. */ const SKILL_MODE_FILE = /_(d|h|s)\d*\.mp3$/i; @@ -463,6 +468,7 @@ export class AudioManager { this.bgm = null; // { key, loopUrl, nodes: [{src, gain}], gain } this.bgmToken = 0; this.units = new Map(); // battle unit id → defId + this.pendingSkill = new Map(); // unit id → the 'skill' tuple that arrived before the unit was known (see _track) this.lastAttacker = new Map(); // target id → { def, at } of the hostile attack last aimed at it (its impact sound) this.consumed = new Set(); // summons used up by their own effect (香槟炸弹 exploded): no death sound this.installed = false; @@ -912,6 +918,9 @@ export class AudioManager { /** Reset the unit map for a new field (m.field.units = UnitInfo[]). */ setFieldUnits(units) { this.units.clear(); + // unit ids belong to one battle: a cast still held for a unit of the field left must not sound for a unit of the new + // one that happens to share its id + this.pendingSkill.clear(); this.lastAttacker.clear(); this.consumed.clear(); // a new field is a new battle: the first operator deployed says 行动出发 again and no cooldown carries over @@ -926,6 +935,12 @@ export class AudioManager { // official class sounds (operator vs summon vs device) this.units.set(u.id, { def: u.spine || u.defId, defId: u.defId ?? null, kind: u.kind ?? null, side: u.side, boss: !!u.boss, skillIndex: Number.isInteger(u.skillIndex) ? u.skillIndex : null }); + // a cast that arrived before this unit was known (handleBattleEvents): it sounds now, once + const held = this.pendingSkill.get(u.id); + if (held) { + this.pendingSkill.delete(u.id); + this.handleBattleEvents([held]); + } } /** Play a resolved battle sound for a unit event, limited like unit sounds. */ @@ -970,6 +985,13 @@ export class AudioManager { const n = Number.isInteger(u.skillIndex) ? Math.min(4, u.skillIndex + 1) : 1; this.voice(u.def, `skill${n}`, { unitKey: e[1] }); } + } else { + // The unit is not tracked yet. The sim emits the cast of a deployment that fires inside its own first tick + // (`initSp` already at `spCost`, 宴's deploy-timed skill) BEFORE the unit's `['spawn', unitInfo]` — the same + // batch or the next one — so the cue waits here and `_track` plays it when the spawn arrives. Only a skill + // turning ON is a cast; a hold for a unit that never appears goes with the field (`setFieldUnits`). + if (this.pendingSkill.size >= PENDING_SKILL_MAX) this.pendingSkill.delete(this.pendingSkill.keys().next().value); + this.pendingSkill.set(e[1], e); } } else if (kind === 'engage') { // 行动开始: the first attack a unit makes on an enemy (the sim's ENGAGE, official ENCOUNTER_ENEMY, 3 s apart) diff --git a/public/js/screens/game.js b/public/js/screens/game.js index 52a79eb0..5a406697 100644 --- a/public/js/screens/game.js +++ b/public/js/screens/game.js @@ -115,7 +115,7 @@ import { audio, resultSpeaker, resultVoiceSlot } from '../audio.js'; import { useDocClass, FullscreenButton } from '../ui/device.js'; // MatchEnded, PausedOverlay, the highlight styles and keepEarly live in ./game/*.js. import { HUD_HZ_MS, MERGE_HL, SEL_RANGE, cx } from './game/marks.js'; -import { keepEarly } from './game/early.js'; +import { keepEarly, audioEarly } from './game/early.js'; import { MatchEnded, PausedOverlay } from './game/overlays.js'; import { t, tParts } from '../../../shared/i18n.js'; @@ -434,7 +434,7 @@ function MatchScreen() { if (early && early.length) { // replay state-bearing events only (a burst of stale hit sparks / damage numbers would look wrong) view.pushEvents(early); - audio.handleBattleEvents(early.filter((e) => e[0] === 'spawn')); + audio.handleBattleEvents(audioEarly(early)); // (the 'spawn' tuples only: the sound would replay every old death, deploy and cast) } if (!earlySnap && (field.prep || !combat) && Array.isArray(field.units)) { // prep scouting: no battle snapshots follow. A later m.field for this board (the teammate moved) re-enters diff --git a/public/js/screens/game/early.js b/public/js/screens/game/early.js index 62f3c282..b5daedd3 100644 --- a/public/js/screens/game/early.js +++ b/public/js/screens/game/early.js @@ -7,3 +7,12 @@ export const STATE_EV = new Set(['spawn', 'die', 'deploy', 'status', 'skill']); /** Event tuples replayed when a field is entered late: the state-bearing kinds and the fx that change an enemy's model * form (shared/protocol.js fxForm — the field meta's UnitInfo `form` predates them). */ export const keepEarly = (e) => Array.isArray(e) && (STATE_EV.has(e[0]) || fxForm(e) !== undefined); + +/** + * What the SOUND gets of that replay: the 'spawn' tuples alone, which teach the audio who is on the field. The buffered + * list is every spawn, die, deploy, status and skill since the field's m.field — up to 1500 of them, while the view was not + * showing it — so handing it to the audio would play the old deaths, deploy lines and skill cues all at once on entering + * (GitHub PR #292 by @LimitlessHPPK handed over the whole list; its hold of a cast that arrives before its unit, + * audio.js `pendingSkill`, is what the live path needs, and `onEv` already forwards the full list). + */ +export const audioEarly = (early) => (Array.isArray(early) ? early.filter((e) => Array.isArray(e) && e[0] === 'spawn') : []); diff --git a/test/match/feedback1-runner-forms.test.js b/test/match/feedback1-runner-forms.test.js index 4d6336c8..fa4825ab 100644 --- a/test/match/feedback1-runner-forms.test.js +++ b/test/match/feedback1-runner-forms.test.js @@ -17,6 +17,8 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { createBattleRunner, keepsState, compactHeld, HELD_MAX, EV_SLICE } from '../../public/js/battle/runner.js'; import { SnapshotBuffer } from '../../public/js/render/interp.js'; +import { keepEarly, audioEarly } from '../../public/js/screens/game/early.js'; +import { AudioManager } from '../../public/js/audio.js'; import { createStore, initialState } from '../../public/js/store.js'; import * as specMod from '../../server/sim/spec.js'; import { DataSource } from '../../server/sim/simdata.js'; @@ -243,6 +245,56 @@ test('screens/game.js buffers the form fx with the state-bearing events it repla assert.match(src, /lastFieldRef\.current = field\.fieldId;\s+reentryRef\.current = null;/); }); +// GitHub PR #292 (by @LimitlessHPPK) wanted the whole pre-entry buffer handed to the sound as well, so that a deploy-tick cast +// ahead of its unit's spawn would be heard. The review kept the hold of such a cast (audio.js `pendingSkill`, which the live +// path needs: `onEv` already forwards every list) and refused the replay: the buffer is every spawn, die, deploy, status and +// skill since the field's m.field (up to 1500 of them), so the sound would play the field's old deaths, deploy lines and casts +// all at once on entering. +test('entering a field late: the view replays every buffered state event, the sound only the spawn tuples that teach it who is there', () => { + const src = readFileSync(path.join(ROOT, 'public/js/screens/game.js'), 'utf8'); + assert.match(src, /view\.pushEvents\(early\);/, 'the view gets the buffered list'); + assert.match(src, /audio\.handleBattleEvents\(audioEarly\(early\)\);/, 'the sound gets audioEarly of it'); + assert.ok(!/audio\.handleBattleEvents\(early\)/.test(src), 'never the whole list'); + const stream = [ + ['atk', 1, 2, 'none'], // stale cosmetics: dropped by keepEarly + ['dmg', 2, 40, 'phys'], + ['skill', 1, 1], // a deploy-tick cast, ahead of its unit's spawn + ['spawn', { id: 1, side: 'ally', kind: 'op', spine: 'char_a', skillIndex: 2 }], + ['spawn', { id: 2, side: 'enemy', kind: 'enemy', spine: 'enemy_x' }], + ['deploy', 1], + ['skill', 1, 0], + ['fx', 'form', 2, 3, { id: 2, form: 'translator_youling' }], + ['die', 2, 'killed'], + ]; + const early = stream.filter(keepEarly); + assert.deepEqual(early.map((e) => e[0]), ['skill', 'spawn', 'spawn', 'deploy', 'skill', 'fx', 'die'], 'the pre-entry buffer keeps every state-bearing kind'); + assert.deepEqual(audioEarly(early).map((e) => e[0]), ['spawn', 'spawn'], 'the sound is told who is on the field, nothing else'); + assert.deepEqual(audioEarly(null), [], 'no buffer, nothing'); + // through a real AudioManager: the replay makes no sound at all (no old death, deploy line or cast) but tracks the units, + // so the first live cast after entering sounds + const vm = { audio: { sfx: { ui: {}, battle: {}, units: { char_a: { skill: '/s/a_skill.mp3', born: '/s/a_born.mp3' }, enemy_x: { die: '/s/x_die.mp3' } } }, voice: {} } }; + const a = new AudioManager({ win: null, getManifest: () => vm }); + a.ctx = {}; + const played = []; + a._play = (url) => { played.push(url); }; + a.voice = () => { played.push('voice'); return true; }; + a.setFieldUnits([]); + a.handleBattleEvents(audioEarly(early)); + assert.deepEqual(played, [], 'the replay is silent'); + assert.equal(a.units.size, 2, 'and has taught the sound both units'); + a.handleBattleEvents([['skill', 1, 1]]); + assert.ok(played.includes('/s/a_skill.mp3'), 'the next live cast of a known unit sounds'); + // (the contrast: the whole list would have played the buffered death, the deploy and the cast on entering) + const b = new AudioManager({ win: null, getManifest: () => vm }); + b.ctx = {}; + const heard = []; + b._play = (url) => { heard.push(url); }; + b.voice = () => true; + b.setFieldUnits([]); + b.handleBattleEvents(early); + assert.ok(heard.length >= 2, `the whole list would sound ${heard.length} old cues at once`); +}); + /** * The view's side too (render/app.js): the runner's frames go into the render engine's SnapshotBuffer, whose clock * follows the snapshots, and processEvents takes the due events with the 1.5 game s stale-cosmetic window — a form fx diff --git a/test/ui/audio.test.js b/test/ui/audio.test.js index addb8c96..d931f2f2 100644 --- a/test/ui/audio.test.js +++ b/test/ui/audio.test.js @@ -577,6 +577,89 @@ describe('AudioManager', () => { } finally { globalThis.fetch = origFetch; } } }); + + // 「技能音效有时候不触发」 (PR #292 by @LimitlessHPPK): the sim emits the cast of a deployment that fires inside its own first + // tick (`initSp` already at `spCost`, 宴's deploy-timed skill, 银灰 S3 真银斩 …) before the unit's `['spawn', unitInfo]` — the + // same batch, or the next — so `handleBattleEvents` used to skip the cue for good: that first cast was silent and every + // later one played. The cue is held and answered once, when the unit is tracked (`_track`); it never outlives its field. + test('a cast that arrives before its unit is known is held and plays once when the unit is tracked', () => { + const vm = { audio: { sfx: { ui: {}, battle: {}, units: { char_a: { skill: '/s/a_skill.mp3' } } }, + voice: { char_a: { skill3: '/v/a_s3.mp3' } } } }; + const a = new AudioManager({ win: null, getManifest: () => vm }); + a.ctx = {}; // unlocked: every path below is the real one + const played = [], voiced = []; + a._play = (url) => { played.push(url); }; // the cue itself, and the 作战中 line that goes with it + a.voice = (charId, slot, o) => { voiced.push([charId, slot, o.unitKey]); return true; }; + /** UnitInfo of the operator `char_a` (spine = the sfx.units key; skillIndex picks its 作战中N slot). */ + const info = (id, skillIndex) => ({ id, side: 'ally', spine: 'char_a', kind: 'op', skillIndex }); + + // (1) the sim's order inside one batch: the cast, then the unit's spawn — the cast waits, then sounds once + a.handleBattleEvents([['skill', 8, 1]]); + assert.deepEqual(played, [], 'nothing to play yet: the unit is unknown'); + a.handleBattleEvents([['spawn', info(8, 2)]]); + assert.deepEqual(played, ['/s/a_skill.mp3'], 'the held cast plays as soon as the unit is tracked'); + assert.deepEqual(voiced, [['char_a', 'skill3', 8]], 'with the 作战中N line its skillIndex picks'); + a.handleBattleEvents([['skill', 7, 1], ['atk', 7, 8, 'none'], ['spawn', info(7, 0)]]); + assert.equal(played.length, 2, 'the same batch: the cast waits for the spawn of its own batch'); + assert.deepEqual(voiced.at(-1), ['char_a', 'skill1', 7]); + // (2) once: a unit that is told about again (a repeated spawn) never replays it; a later cast plays directly + a.handleBattleEvents([['spawn', info(8, 2)], ['spawn', info(7, 0)]]); + assert.equal(played.length, 2, 'no second cue for the held cast'); + a.handleBattleEvents([['skill', 8, 1]]); + assert.equal(played.length, 3, 'a later cast of a known unit plays through'); + assert.deepEqual(voiced.at(-1), ['char_a', 'skill3', 8], 'and sounds the same as the replayed one'); + // (3) skill off (`['skill', id, 0]`) is no cast: nothing is held for it + a.handleBattleEvents([['skill', 9, 0]]); + a.handleBattleEvents([['spawn', info(9, 0)]]); + assert.equal(played.length, 3, 'the off event holds nothing'); + assert.equal(a.pendingSkill.size, 0); + }); + + test('a held cast does not outlive its field: a unit of the next field that shares its id does not sound it', () => { + const vm = { audio: { sfx: { ui: {}, battle: {}, units: { char_a: { skill: '/s/a_skill.mp3' } } }, voice: {} } }; + const a = new AudioManager({ win: null, getManifest: () => vm }); + a.ctx = {}; + const played = []; + a._play = (url) => { played.push(url); }; + a.voice = () => true; + const info = (id) => ({ id, side: 'ally', spine: 'char_a', kind: 'op' }); + a.handleBattleEvents([['skill', 9, 1]]); // a cast whose unit never appeared on this field + assert.equal(a.pendingSkill.size, 1); + a.setFieldUnits([info(9)]); // another battle: unit ids are per battle, 9 is a different unit + assert.equal(a.pendingSkill.size, 0, 'the field change drops the hold'); + a.handleBattleEvents([['spawn', info(9)]]); + assert.deepEqual(played, [], 'the old field\'s cast never sounds in the new one'); + // the hold is bounded: casts of units that never appear cannot pile up + for (let id = 100; id < 400; id++) a.handleBattleEvents([['skill', id, 1]]); + assert.ok(a.pendingSkill.size <= 64, `${a.pendingSkill.size} holds`); + assert.ok(a.pendingSkill.has(399) && !a.pendingSkill.has(100), 'the oldest holds go first'); + }); + + test('a real battle: the deploy-tick cast of a deployment-activated skill is heard, once', () => { + // The reported shape in 0.2.0's own sim: 宴's kit activates its skill on deployment (activateOnDeploy, PR #109), and + // `sim/battle/deploy.js` runs `skill.reset()` (→ `activate` → `['skill', id, 1]`) BEFORE it emits + // `['spawn', unitInfo]` — three events earlier in 宴's case (a status and an fx come between). Fed to the audio as + // the socket delivers it, that cast used to be dropped for good. + const chessId = 'chess_char_1_18_a'; // 宴 (char_337_utage): a deploy-timed skill with a skill sound + const h = makeBattle({ seed: 7, autoFinish: false, timeLimit: 5, units: [{ chessId, row: 9, col: 5 }] }); + h.step(3); + const ev = h.events; + const iCast = ev.findIndex((e) => e[0] === 'skill' && e[2]); + const iSpawn = ev.findIndex((e) => e[0] === 'spawn'); + assert.ok(iCast >= 0 && iSpawn > iCast, `the sim emits the deploy-tick cast before the unit info (${iCast} < ${iSpawn})`); + // the cast's sound, resolved as `unit()` does (the equipped skill's own ON_SKILL_START file, else its `skill`) + const info = ev[iSpawn][1]; + const rec = manifest.audio.sfx.units[info.spine]; + const url = (Number.isInteger(info.skillIndex) && rec?.skills ? rec.skills[info.skillIndex] : null) ?? rec?.skill; + assert.ok(typeof url === 'string', `前提:${info.spine} 有技能音效`); + const played = []; + const a = new AudioManager({ win: null, getManifest: () => manifest }); + a.ctx = {}; + a._play = (u) => { played.push(u); }; + a.voice = () => true; + a.handleBattleEvents(ev); + assert.equal(played.filter((u) => u === url).length, 1, 'the cast of the deployment is heard, exactly once'); + }); }); // user playtest #4 item 6: 纯烬艾雅法拉's skill sound rang outside her skill — her manifest `hit` is her S3 impact From bc11c0d61ab655e1564bc6c617b5e9a05b70a0bf Mon Sep 17 00:00:00 2001 From: Cloudnyco Date: Thu, 8 Oct 2026 19:16:52 +0800 Subject: [PATCH 4/5] =?UTF-8?q?feedback7=20R22-HUD:=20the=20cached-atlas?= =?UTF-8?q?=20refreshes=20of=20a=20crowded=20battle=20take=20their=20turns?= =?UTF-8?q?=20from=20this=20frame's=20update=20order,=20not=20random=20pha?= =?UTF-8?q?ses=20=E2=80=94=20each=20frame=20refreshes=20=E2=8C=8An/k?= =?UTF-8?q?=E2=8C=8B=20or=20=E2=8C=88n/k=E2=8C=89=20of=20n=20units=20(step?= =?UTF-8?q?=201=20of=20GitHub=20#327,=20PR=20#334=20by=20@Cloudnyco);=20go?= =?UTF-8?q?lden:=20unchanged?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applied unchanged after review (Grok static review: MERGE; the diff read in full: no network, no dependency). UnitView._updateImpostor refreshed a unit when (frame + phase) % interval === 0 with a random phase per unit, so the refreshes of a frame were binomial: reproduced on the parent with 7 pen figures at interval 3 — 5 1 1 5 1 1 … and 2 4 1 2 4 1 … refreshes per frame instead of 2 / 3. Now render/app.js hands every impostor unit the next slot of the frame (ctx.impostorSlot, a counter reset in frameBody), the unit refreshes when (frame + slot) % interval === 0, and a unit whose slot keeps moving with the frame (the units before it culled on and off in step) is still refreshed after 2 intervals (imp.last); a context without slots keeps the random phase. Each unit's rate and the total work are unchanged; no sim or pixel-as-state change. Step 2 of #327 (a time budget by priority) is not part of this and still needs a decision. - Tests: test/render/unitview.test.js 'impostor refresh slots' (3: slots in update order — fails on the parent, the starvation cap, the no-slots context); unit view / render suites unchanged. - Docs: design/client.md. Golden: unchanged (client only). --- docs/design/client.md | 2 +- public/js/render/app.js | 6 +++ public/js/render/units.js | 10 ++++- test/render/unitview.test.js | 71 ++++++++++++++++++++++++++++++++++++ 4 files changed, 86 insertions(+), 3 deletions(-) diff --git a/docs/design/client.md b/docs/design/client.md index 63765d80..783c9ea4 100644 --- a/docs/design/client.md +++ b/docs/design/client.md @@ -32,7 +32,7 @@ view.resize() ; view.destroy() `createFieldView(host, { …, signal })` takes an `AbortSignal` (0.2.2, GitHub PR #302 by @siruimei07): an abort during the build — the Pixi load, the manifest, the fonts, the board art and the 3D board waits — destroys what the attempt made (the canvas, the ticker, the listeners, the observers) and rejects, a failure while it is built releases the same partial resources, and `view.destroy()` is idempotent. `ui/fieldHost.js mountFieldView(host, { signal })` aborts that signal when the engine fails, times out (12 s) or the owner unmounts, disposes a view a factory returns after that, and never clears the other children of `host` — the simplified view and a newer mount share it. -Visuals: oblique perspective board (tilted ~30°, trapezoid tiles), raised highland blocks with side faces, road/floor/forbidden styles per research 07 §7, pulsing red gates, blue objective, dashed hand pads. Units: Spine (Front/Back rule, feet anchored — flying units hover `FLY_HOVER` 1.3 tiles up, the client's 0.35 in character space, research 12, §25.13.5 — an enemy flyer from the road whatever tile it crosses (GitHub #277) —, shadow sprite on the ground), elite gold outline glow, tier chip, HP bar (green ally / red enemy), SP bar (skill ready glow; while an ammo skill runs it is the magazine instead — a yellow cell per round, emptying from the right, `b.snap ammo`, and the skill draws no standing aura, UnitInfo `ammoSkill`), 斩业星熊's 我执 pool as a red bar over her drained HP bar (`b.snap neg`), 伺夜's 狼群 count as a row of diamonds under the bars, the lit ones the 狼影 left (`b.snap wolves`; 0.2.2, GitHub PR #303 / #378 / #383), status icons, the element gauge row under the bars — icon + white bar of the remaining 元素值, element-coloured refill during a 爆发冷却 (§18.3, §20.8); enemies drawn × their official prefab factor (enemies.json `modelScale`, §20.4); an enemy's mode — the `form` of a sim fx (`shared/protocol.js fxForm`), or UnitInfo `form` for a view built later through `render/app/info.js renderInfo` — switches clip sets (`render/units.js FORMS`: 掠海漂移体 crawl, 暴鸰 bombed, 转译基底's forms, the 逐火 embers, the leaders' 重生, 守墓石像, the 孤岛风云 prisoners' warning and 解放; closing clips timed from the fx `dur`), a one-off cast (`PROJ[kind].once`) plays once, and a local-only model's web alias is tinted (`ALIAS_TINT`: 灼热 / 炽焰源石虫, §21.14); damage numbers (phys orange-white, arts purple, true white, heal green); projectiles drawn per kind, hit sparks and melee slashes, skill activation bursts and auras (§17.3); death fade for enemies, summons and devices — a knocked-out operator stays on its tile in its held Die pose under a redeploy ring (§18.3); leak flash at the objective. Fallback when Spine missing/failed: avatar in a rarity-coloured diamond with bob/lunge tweens — a failed or timed-out model is loaded again (bounded), and a view re-resolves its model and avatar when the asset manifest arrives late or the tab is shown again (§21.27). 60 fps target with ≤ 120 units. Picking (which unit a press / hover / dropped item is on) and the drag target follow one rule, `render/pick.js` — the tile under the pointer (§18.1); the Spine store never hands out a skeleton whose unload is in flight (§17.1). +Visuals: oblique perspective board (tilted ~30°, trapezoid tiles), raised highland blocks with side faces, road/floor/forbidden styles per research 07 §7, pulsing red gates, blue objective, dashed hand pads. Units: Spine (Front/Back rule, feet anchored — flying units hover `FLY_HOVER` 1.3 tiles up, the client's 0.35 in character space, research 12, §25.13.5 — an enemy flyer from the road whatever tile it crosses (GitHub #277) —, shadow sprite on the ground), elite gold outline glow, tier chip, HP bar (green ally / red enemy), SP bar (skill ready glow; while an ammo skill runs it is the magazine instead — a yellow cell per round, emptying from the right, `b.snap ammo`, and the skill draws no standing aura, UnitInfo `ammoSkill`), 斩业星熊's 我执 pool as a red bar over her drained HP bar (`b.snap neg`), 伺夜's 狼群 count as a row of diamonds under the bars, the lit ones the 狼影 left (`b.snap wolves`; 0.2.2, GitHub PR #303 / #378 / #383), status icons, the element gauge row under the bars — icon + white bar of the remaining 元素值, element-coloured refill during a 爆发冷却 (§18.3, §20.8); enemies drawn × their official prefab factor (enemies.json `modelScale`, §20.4); an enemy's mode — the `form` of a sim fx (`shared/protocol.js fxForm`), or UnitInfo `form` for a view built later through `render/app/info.js renderInfo` — switches clip sets (`render/units.js FORMS`: 掠海漂移体 crawl, 暴鸰 bombed, 转译基底's forms, the 逐火 embers, the leaders' 重生, 守墓石像, the 孤岛风云 prisoners' warning and 解放; closing clips timed from the fx `dur`), a one-off cast (`PROJ[kind].once`) plays once, and a local-only model's web alias is tinted (`ALIAS_TINT`: 灼热 / 炽焰源石虫, §21.14); damage numbers (phys orange-white, arts purple, true white, heal green); projectiles drawn per kind, hit sparks and melee slashes, skill activation bursts and auras (§17.3); death fade for enemies, summons and devices — a knocked-out operator stays on its tile in its held Die pose under a redeploy ring (§18.3); leak flash at the objective. Fallback when Spine missing/failed: avatar in a rarity-coloured diamond with bob/lunge tweens — a failed or timed-out model is loaded again (bounded), and a view re-resolves its model and avatar when the asset manifest arrives late or the tab is shown again (§21.27). 60 fps target with ≤ 120 units (a crowd draws its Spine units through the cached atlas, `render/impostor.js`, each refreshed every k-th frame: the refresh turns follow this frame's update order — `ctx.impostorSlot()`, a counter reset at the start of every frame — so a frame refreshes ⌊n/k⌋ or ⌈n/k⌉ of n units instead of whatever random per-unit phases gave, and a unit not refreshed for 2k frames is refreshed regardless; GitHub PR #334 by @Cloudnyco, issue #327 step 1 — its step 2, a time budget by priority, is not done). Picking (which unit a press / hover / dropped item is on) and the drag target follow one rule, `render/pick.js` — the tile under the pointer (§18.1); the Spine store never hands out a skeleton whose unload is in flight (§17.1). --- diff --git a/public/js/render/app.js b/public/js/render/app.js index 0753abe3..51474794 100644 --- a/public/js/render/app.js +++ b/public/js/render/app.js @@ -316,6 +316,7 @@ export async function createFieldView(host, options = {}) { renderer: app.renderer, frameNo: () => frameNo, impostorInterval: () => impInterval, + impostorSlot: () => impSlot++, clipAllowed: () => clipAllowed, viewport: () => vp, loadLevel: () => loadLevel, @@ -1565,6 +1566,10 @@ export async function createFieldView(host, options = {}) { let cpuMs = 0; let frameNo = 0; let impInterval = 0; + // Each frame the impostor units take slots 0, 1, 2 … in update order (units.js _updateImpostor): a unit refreshes when + // (frame + slot) % interval === 0, so a frame refreshes ⌊n/k⌋ or ⌈n/k⌉ of n units. Random per-unit phases left the + // busiest frame 20–66% above that (16–48 units, intervals 2–6) and set the frame-time peaks of a crowded battle. + let impSlot = 0; let vp = { width: s0.width, height: s0.height }; // viewport (CSS px) of this frame: unit culling let culledCount = 0; // Adaptive load level 0–3: a device that cannot hold the frame rate with the current work switches crowds to @@ -1605,6 +1610,7 @@ export async function createFieldView(host, options = {}) { } function frameBody(now) { frameNo++; + impSlot = 0; if (frameNo % 30 === 1) { impInterval = pickImpostorInterval(); clipAllowed = pickClipping(); culledCount = 0; diff --git a/public/js/render/units.js b/public/js/render/units.js index b385fe42..9e9b8819 100644 --- a/public/js/render/units.js +++ b/public/js/render/units.js @@ -1457,16 +1457,22 @@ export class UnitView { if (this.actor.spine.parent) this.actor.spine.parent.removeChild(this.actor.spine); if (atlas) atlas.park(this.actor.spine); this.body.addChild(sprite); - this.imp = { sprite, slot: null, rt: null, sc: 0, acc: 0, phase: (Math.random() * 64) | 0, dirty: true }; + this.imp = { sprite, slot: null, rt: null, sc: 0, acc: 0, phase: (Math.random() * 64) | 0, dirty: true, last: 0 }; } const imp = this.imp; imp.acc += animDt; const frame = this.ctx.frameNo ? this.ctx.frameNo() : 0; - const due = imp.dirty || interval <= 1 || (frame + imp.phase) % interval === 0 || Math.abs(sc - imp.sc) > imp.sc * 0.12; + // the view's slot in this frame's update order (app.js impostorSlot) spreads the refreshes evenly over the interval; + // a context without slots keeps the random phase. A unit whose slot keeps moving with the frame (the units before it + // culled on and off in step) is still refreshed after 2 intervals at the latest. + const turn = this.ctx.impostorSlot ? this.ctx.impostorSlot() : imp.phase; + const due = imp.dirty || interval <= 1 || (frame + turn) % interval === 0 || frame - imp.last >= 2 * interval + || Math.abs(sc - imp.sc) > imp.sc * 0.12; if (due) { this.actor.update(imp.acc); imp.acc = 0; imp.dirty = false; + imp.last = frame; this._renderImpostor(sc, atlas); } const k = imp.sc > 0 ? sc / imp.sc : 1; diff --git a/test/render/unitview.test.js b/test/render/unitview.test.js index 26e426b9..6fd882ea 100644 --- a/test/render/unitview.test.js +++ b/test/render/unitview.test.js @@ -279,6 +279,77 @@ describe('enemy preview pen figures (lod idle)', () => { }); }); +// The impostor refreshes of a frame are spread evenly over the interval (app.js impostorSlot: each frame the impostor +// units take slots 0, 1, 2 … in update order): a frame refreshes ⌊n/k⌋ or ⌈n/k⌉ of n units. With a random phase per +// unit, the busiest frame did 20–66% more refreshes than that (16–48 units, intervals 2–6) — the frame-time peaks of a +// crowded battle on a slow device. +describe('impostor refresh slots', () => { + /** n pen figures (lod idle: interval 3) in one context; `slotOf(frame, i)` gives view i its slot, or null: no slots. */ + async function crowd(n, slotOf) { + let frame = 0; + let i = 0; + const atlas = { + alloc: (w, h) => ({ w, h, tex: new fake.P.Texture(), clip: false }), + free() {}, park(o) { o.visible = false; }, unpark(o) { o.visible = true; }, draw() {}, + }; + const ctx = fakeViewCtx(fake.P, { + assets: store({ spine: true }), cam, frameNo: () => frame, impostors: atlas, + renderer: { resolution: 1, render() {} }, + ...(slotOf ? { impostorSlot: () => slotOf(frame, i) } : {}), + }); + const views = []; + for (let k = 0; k < n; k++) { + views.push(new UnitView(ctx, { id: `e:${k}`, side: 'enemy', kind: 'enemy', defId: 'enemy_1007_slime', tier: 1, x: 9, y: 15, maxHp: 1, facing: -1 }, { prep: true, lod: 'idle' })); + } + await tick(); await tick(); + const refreshed = views.map(() => []); + views.forEach((v, k) => { + assert.ok(v.spineReady, 'spine ready'); + const upd = v.actor.update.bind(v.actor); + v.actor.update = (dt) => { refreshed[k].push(frame); return upd(dt); }; + }); + const step = () => { views.forEach((v, k) => { i = k; v.update(1 / 60, cam(), frame / 60); }); frame++; }; + return { step, refreshed, frames: () => frame }; + } + const perFrame = (refreshed, from, to) => { + const c = new Map(); + for (const list of refreshed) for (const f of list) if (f >= from && f < to) c.set(f, (c.get(f) || 0) + 1); + return Array.from({ length: to - from }, (_, j) => c.get(from + j) || 0); + }; + + test('slots in update order: every frame refreshes ⌊n/3⌋ or ⌈n/3⌉ units, each unit every 3rd frame', async () => { + for (const n of [7, 16, 24]) { + const { step, refreshed } = await crowd(n, (frame, k) => k); + for (let f = 0; f < 31; f++) step(); + // frame 0: every new impostor is drawn once (dirty); from frame 1 on, the interval rules + const counts = perFrame(refreshed, 1, 31); + assert.ok(counts.every((c) => c === Math.floor(n / 3) || c === Math.ceil(n / 3)), `${n} units: ${counts.join(' ')}`); + for (const list of refreshed) { + const after = list.filter((f) => f >= 1); + assert.equal(after.length, 10, `each unit refreshed 10 times in 30 frames (${after.join(',')})`); + assert.ok(after.every((f, j) => j === 0 || f - after[j - 1] === 3), 'exactly every 3rd frame'); + } + } + }); + + test('a slot that moves with the frame never starves a unit: refreshed at least every 2 intervals', async () => { + // (frame + slot) % 3 is never 0: the turn alone would never come + const { step, refreshed } = await crowd(3, (frame) => 4 - (frame % 3)); + for (let f = 0; f < 31; f++) step(); + for (const list of refreshed) { + const after = list.filter((f) => f >= 1); + assert.ok(after.length >= 5, `refreshed ${after.length} times`); + assert.ok([0, ...after].every((f, j, a) => j === 0 || f - a[j - 1] <= 6), `gaps ≤ 6 frames (${after.join(',')})`); + } + }); + + test('without slots (a context that has none) the random phase still refreshes every 3rd frame', async () => { + const { step, refreshed } = await crowd(5, null); + for (let f = 0; f < 31; f++) step(); + for (const list of refreshed) assert.equal(list.filter((f) => f >= 1).length, 10); + }); +}); + // Player report 2026-10-05: 「无人机等飞行单位贴图位置明显偏低」, and the follow-up "绝对不止 0.35" with an official // screenshot of 帝国炮火先兆者 over a tile (PR #211 by @xcdoge; the owner's decision of 2026-10-06). The lift is the // official client's own single constant — Vector3(0, 0.35, 0) written by Torappu.Battle.CharacterAnimator's constructor From 16df0bf466bf580bf9e50071f437d484244867ce Mon Sep 17 00:00:00 2001 From: sganggs Date: Thu, 8 Oct 2026 19:19:35 +0800 Subject: [PATCH 5/5] =?UTF-8?q?feedback7=20R22-HUD:=20the=20card=20?= =?UTF-8?q?=E6=A2=93=E5=85=B0's=20=E7=8C=8E=E5=A4=B4=E9=A1=BE=E9=97=AE=20f?= =?UTF-8?q?reezes=20shows=20its=20frost=20=E2=80=94=20every=20shop=20card?= =?UTF-8?q?=20draws=20its=20own=20slot's=20freeze,=20not=20the=20toggle's?= =?UTF-8?q?=20(GitHub=20#354=20by=20@DuolaCCCCat);=20golden:=20unchanged?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seen (reproduced on the parent, notes repro-354.mjs): band_orchid's active refresh copies the top-tier operator into another slot and freezes the copy (bands/meta.js band_shop_refresh_copy_max_lv_char, slot.frozen = true) while shop.frozen — the toggle — stays false; m.private sends both (views.js: shop.frozen and slots[i].frozen). The shop bar passed the toggle (`frozen = !!shop.frozen`) to every ChessCard / ItemCard, so no card showed the frost: the server kept the card to the next prep, the player could not see which one. Now: shopBar.js slotFrozen(slot, toggle) = the slot's own flag, or the toggle (a frame without per-slot flags — the dev mock — and the old behaviour for the 冻结 button, which still copies the toggle onto every unsold slot); both card kinds use it. The bar's frame (is-frozen) and the 冻结 / 解冻 button stay on the toggle. The rule is not touched (bands.test.js, and the new test): a second manual refresh re-rolls the frozen card too — like the toggle, "再手动刷新会重抽" — and freezes one of the new pair; with no further refresh the frozen card stays in the next prep (the flags are cleared then, round.js startRound). - Tests: test/ui/feedback7-orchid-freeze.test.js (4: the wire — exactly one frozen card of the pair, toggle off — and the cards drawn from it; the toggle still freezes every slot; the rule unchanged across two refreshes and a round; the ShopBar source pin); fails on the parent (no slotFrozen, the cards passed `frozen`). - Docs: PLAYING.md (冻结 row), design/network.md (m.private slot.frozen). Golden: unchanged (client render only). --- docs/PLAYING.md | 2 +- docs/design/network.md | 2 +- public/js/ui/shopBar.js | 12 ++- test/ui/feedback7-orchid-freeze.test.js | 109 ++++++++++++++++++++++++ 4 files changed, 121 insertions(+), 4 deletions(-) create mode 100644 test/ui/feedback7-orchid-freeze.test.js diff --git a/docs/PLAYING.md b/docs/PLAYING.md index 90ecfaf8..69b3cc0b 100644 --- a/docs/PLAYING.md +++ b/docs/PLAYING.md @@ -47,7 +47,7 @@ | 刷新 | 1 资金 | | 出售干员 | 任何干员(含精锐)都只返还 **1** 资金 | | 装备 / 法术 | 调度中心出售除特殊装备外的所有装备:4 把特殊维式重锤(战栗 / 坚固 / 加速 / 灼燃)只来自维多利亚盟约(每 25 层)和洛洛的定制品,突变细胞只来自昆图斯的策略。装备不能出售,整备区里的只能「销毁」(不返还)。已配发的装备锁定在干员身上:只能被第三件装备替换(弹出替换窗口,选择要替换的一件,它会被销毁;盟约之币、博士投影等装备即销毁的道具也一样,先销毁选中的那件再生效,干员留下一个空栏位,精锐干员不能使用博士投影,此时什么都不销毁),或在干员出售 / 合成精锐时退回整备区。**突变细胞**不会用完:战斗结束后装备者被销毁(部署在场上的话,原来的格子空出来,剩余可放置角色加 1),突变细胞和它的其他装备先退回整备区,可以再次配发给任意干员;随后获得一名高一阶(最高 VI)的随机普通干员(装备者是精锐也一样),和其他新获得的干员一样进入整备区(满了进入临时整备区),需要自己重新部署;如果它凑齐了合成所需的同名干员,照常合成精锐。**坚固维式重锤**首次受到致命伤害时锁血 8 秒,**每次部署一次**:被击倒再部署(或突袭跳跃再部署、被 M3茧甲 / 埃芒加德复活)后又能锁一次,娜仁图亚策略借来的锤子也一样;借用到期时正在锁血的会锁满 8 秒;站在阿戈尔干员身前被开场吞噬、自带源石溶剂掉血时也会用掉这一次 | -| 冻结 | 免费,保留当前所有未买的格子到下一回合;冻结状态下仍可主动刷新 | +| 冻结 | 免费,保留当前所有未买的格子到下一回合;冻结状态下仍可主动刷新。策略「梓兰」的每次主动刷新会单独冻结送来的那张同名干员:只有这一张卡显示冰霜(上方的冻结按钮不亮),没有再刷新就留到下一回合,再手动刷新会连它一起重抽 | | 升级调度中心(1→2 … 5→6) | 险境及以上 5 / 8 / 11 / 12 / 13;同盟标准 5 / 8 / 10 / 11 / 11;独立标准 1 / 1 / 5 / 8 / 10。**每过一回合价格 −1**(最低 0),升级后按下一级的价格重新计算。升级到干员栏位更多的等级(险境及以上 2 级 4 个、4 级 5 个;标准 2 级 4 个、6 级 5 个)时,**新栏位立刻刷出一名新干员**,已有的卡片不变 | | 其他收入 | 悬赏敌人、盟约「远见」「奇迹」、装备「骑士储蓄罐」、部分策略和特质 | diff --git a/docs/design/network.md b/docs/design/network.md index fb1af4b7..09fb61fd 100644 --- a/docs/design/network.md +++ b/docs/design/network.md @@ -36,7 +36,7 @@ S→C: ```js { playerId, seat, alive, lp, funds, bandId, ready, canReady /* false while temp non-empty */, shop: { level, maxLevel: 6, upgradePrice, refreshPrice /* effective (0 when a free refresh is available) */, freeRefreshes, - frozen, slots: [ { kind: 'chess'|'item', id, price, basePrice, sold } | null ], + frozen, slots: [ { kind: 'chess'|'item', id, price, basePrice, sold, frozen } | null ], // slot.frozen: the 冻结 toggle copies onto every unsold slot, and 梓兰's 猎头顾问 freezes ONE card of an active refresh with the toggle off — every card draws its own flag (GitHub #354) rewardOffer: null | { tier, source: 'merge'|'special', label, queued, slots: [ { kind: 'chess'|'item', id, price: 0, sold } ] } }, // §21.2 hand: [ Piece | null ], // length 10, index = hand slot temp: [ Piece | null ], // length 5 diff --git a/public/js/ui/shopBar.js b/public/js/ui/shopBar.js index e8a275c2..d8f59592 100644 --- a/public/js/ui/shopBar.js +++ b/public/js/ui/shopBar.js @@ -57,6 +57,14 @@ export function mergeHint(priv, chessId) { return mergeTarget(priv, chessId, LOOKUPS.getChess) ? t('精锐干员将出现在作战区原位置') : t('精锐干员将进入整备区'); } +/** + * Whether a shop card shows its frost: its own slot's `frozen` (m.private shop.slots[i].frozen) — the 冻结 toggle copies onto + * every unsold slot, and 梓兰's 猎头顾问 freezes ONE copied card with every active refresh while the toggle stays off + * (GitHub #354: the bar drew only the toggle, so the frozen card showed no frost) — or the toggle itself (a frame without + * per-slot flags). The 冻结 button and the bar's frame keep following the toggle alone. + */ +export const slotFrozen = (slot, toggle = false) => !!slot?.frozen || !!toggle; + /** The armed (first-tapped) card's confirm strip: 确认购买 / 确认选择, or 无法购买 + why. */ function ArmedTag({ reason, free }) { if (reason) return html`${t('无法购买')}${reason}`; @@ -348,7 +356,7 @@ export function ShopBar({ priv, editable, collapsed, onCollapse, onBuy, onLevel, ${chessSlots.map(({ s, i }) => { if (!s || s.sold) return html`<${SoldCard} key=${`s${i}`} />`; const reason = shopBlockReason('buy', { priv, editable, slot: s, ...LOOKUPS }); - return html`<${ChessCard} key=${`c${i}:${s.id}`} slot=${s} idx=${i} priv=${priv} frozen=${frozen} onBuy=${onBuy} onDetail=${onDetail} offBonds=${offBonds} + return html`<${ChessCard} key=${`c${i}:${s.id}`} slot=${s} idx=${i} priv=${priv} frozen=${slotFrozen(s, frozen)} onBuy=${onBuy} onDetail=${onDetail} offBonds=${offBonds} reason=${reason} armed=${armed === armKey('c', i, s)} onTap=${editable ? (idx) => tapCard('c', idx, s, 'chess', reason, onBuy) : null} />`; })} `} @@ -357,7 +365,7 @@ export function ShopBar({ priv, editable, collapsed, onCollapse, onBuy, onLevel, ? itemSlots.map(({ s, i }) => { if (s.sold) return html`<${SoldCard} key=${`is${i}`} item=${true} />`; const reason = shopBlockReason('buy', { priv, editable, slot: s, ...LOOKUPS }); - return html`<${ItemCard} key=${`i${i}:${s.id}`} slot=${s} idx=${i} frozen=${frozen} onBuy=${onBuy} onDetail=${onDetail} reason=${reason} + return html`<${ItemCard} key=${`i${i}:${s.id}`} slot=${s} idx=${i} frozen=${slotFrozen(s, frozen)} onBuy=${onBuy} onDetail=${onDetail} reason=${reason} armed=${armed === armKey('i', i, s)} onTap=${editable ? (idx) => tapCard('i', idx, s, 'item', reason, onBuy) : null} />`; }) : html`<${SoldCard} item=${true} />`} diff --git a/test/ui/feedback7-orchid-freeze.test.js b/test/ui/feedback7-orchid-freeze.test.js new file mode 100644 index 00000000..a3127934 --- /dev/null +++ b/test/ui/feedback7-orchid-freeze.test.js @@ -0,0 +1,109 @@ +// test/ui/feedback7-orchid-freeze.test.js — GitHub #354 (DuolaCCCCat): 「梓兰的主动刷新不会触发主动冻结」. 梓兰's 猎头顾问 (band_orchid) makes +// every active refresh a special one: two operators of the same name, and ONE of them frozen — kept to the next prep. The +// server did freeze it (slot.frozen, sent in m.private shop.slots[i].frozen) while the shop toggle stayed off; the shop bar +// drew the frost from the toggle alone, so the player never saw which card was frozen. Each card now reads its own slot. +// The rule itself (a second manual refresh re-rolls the frozen card too, like the toggle) is unchanged. + +import { test, describe } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { makeMatch, DATA } from '../match/harness.js'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +// the browser data store reads the real data files from disk +globalThis.fetch = async (url) => { + const name = String(url).split('/').pop(); + try { + const body = readFileSync(path.join(ROOT, 'data', name), 'utf8'); + return { ok: true, status: 200, json: async () => JSON.parse(body) }; + } catch { + return { ok: false, status: 404, json: async () => ({}) }; + } +}; +const { ChessCard, ItemCard, slotFrozen } = await import('../../public/js/ui/shopBar.js'); +const { data } = await import('../../public/js/data.js'); +await data.loadAll('items', 'effects', 'choices', 'assets', 'chess', 'bonds', 'garrisons'); + +/** Every vnode of a preact tree (htm output), depth first. */ +function* walk(v) { + if (Array.isArray(v)) { for (const x of v) yield* walk(x); return; } + if (!v || typeof v !== 'object') return; + yield v; + yield* walk(v.props?.children); +} +const hasClass = (v, c) => typeof v?.props?.class === 'string' && v.props.class.split(/\s+/).includes(c); +const iced = (card) => hasClass(card, 'is-frozen') && [...walk(card)].some((n) => hasClass(n, 'scard__ice')); +const bare = (card) => !hasClass(card, 'is-frozen') && ![...walk(card)].some((n) => hasClass(n, 'scard__ice')); + +/** A solo match in its first prep with 梓兰's strategy, level 4 shop, funds for a few refreshes. */ +function orchid(seed = 12) { + const h = makeMatch({ mode: 'solo', seed, fake: true }).start(); + h.toPrep(1); + const ps = h.ps('p_0'); + ps.bandId = 'band_orchid'; + ps.shop.level = 4; + ps.funds = 50; + return { h, ps }; +} + +describe('#354: the card the 猎头顾问 freezes shows its frost', () => { + test('the wire: the active refresh freezes one copied card and leaves the shop toggle off; the cards read their own slot', () => { + const { h, ps } = orchid(); + assert.deepEqual(h.m.handle('p_0', { t: 'g.refresh' }), { ok: true }); + const priv = ps.privateView(); + assert.equal(priv.shop.frozen, false, 'the 冻结 toggle is off'); + const chess = priv.shop.slots.filter((s) => s && s.kind === 'chess'); + const frozen = chess.filter((s) => s.frozen); + assert.equal(frozen.length, 1, 'exactly one card is frozen'); + assert.equal(chess.filter((s) => s.id === frozen[0].id).length, 2, 'one of the two operators of the same name'); + // the bar's frame and the button follow the toggle (off), each card follows its own slot + for (const s of priv.shop.slots.filter(Boolean)) { + const props = { slot: s, idx: 0, priv, frozen: slotFrozen(s, priv.shop.frozen), onBuy() {}, onDetail() {} }; + const card = s.kind === 'item' ? ItemCard(props) : ChessCard(props); + assert.equal(s.frozen ? iced(card) : bare(card), true, `${s.id}: ${s.frozen ? 'frost' : 'no frost'}`); + } + h.m.dispose(); + }); + + test('the toggle still freezes every unsold card, and the helper reads the slot and the toggle', () => { + const { h, ps } = orchid(13); + h.m.handle('p_0', { t: 'g.freeze' }); + const priv = ps.privateView(); + assert.equal(priv.shop.frozen, true); + assert.ok(priv.shop.slots.filter(Boolean).every((s) => s.frozen), 'every slot copies the toggle'); + assert.equal(slotFrozen(priv.shop.slots[0], priv.shop.frozen), true); + assert.equal(slotFrozen({ frozen: false }, true), true, 'a frame without per-slot flags: the toggle'); + assert.equal(slotFrozen({ frozen: true }, false), true, 'a slot of its own: its flag'); + assert.equal(slotFrozen({ frozen: false }, false), false); + assert.equal(slotFrozen(null), false); + h.m.dispose(); + }); + + test('the rule stays as it was: the next manual refresh re-rolls the frozen card too and freezes one of the new pair; one refresh then the next prep keeps that card', () => { + const { h, ps } = orchid(); + h.m.handle('p_0', { t: 'g.refresh' }); + const first = ps.shop.slots.find((s) => s && s.frozen); + h.m.handle('p_0', { t: 'g.refresh' }); + const frozenNow = ps.shop.slots.filter((s) => s && s.frozen); + assert.equal(frozenNow.length, 1, 'again exactly one frozen card'); + assert.equal(ps.shop.frozen, false); + assert.ok(first && frozenNow[0], 'both refreshes froze a card'); + const kept = frozenNow[0].id; + h.toPrep(2); + assert.ok(ps.shop.slots.some((s) => s && s.id === kept), 'the frozen card is still in the shop in the next prep'); + assert.equal(ps.shop.frozen, false); + h.m.dispose(); + }); + + test('ShopBar passes slotFrozen(slot, toggle) to the operator and item cards; the frame and the 冻结 button stay on the toggle', () => { + const src = readFileSync(path.join(ROOT, 'public/js/ui/shopBar.js'), 'utf8'); + assert.match(src, /<\$\{ChessCard\} key=\$\{`c\$\{i\}:\$\{s\.id\}`\} slot=\$\{s\} idx=\$\{i\} priv=\$\{priv\} frozen=\$\{slotFrozen\(s, frozen\)\}/); + assert.match(src, /<\$\{ItemCard\} key=\$\{`i\$\{i\}:\$\{s\.id\}`\} slot=\$\{s\} idx=\$\{i\} frozen=\$\{slotFrozen\(s, frozen\)\}/); + assert.match(src, /const frozen = !!shop\.frozen;/); + assert.match(src, /cx\('shopbar', frozen && 'is-frozen'/, 'the bar\'s frame is the toggle'); + assert.match(src, /cx\('toolbtn', 'toolbtn--ice', frozen && 'is-on'\)/, 'the button is the toggle'); + assert.ok(DATA.bands.band_orchid, 'the band exists in the data'); + }); +});