feedback7: merge fb7-late — #389 / PR #390 画卷 keeps the items to copy across a merge, #385 / PR #387 spectator bond details count the hand like the own popup, #394 / PR #395 Enter during facing selection follows the focus, #281 the attack range drawn on a tapped operator, #388 引星棘刺 S1 DEF stacks, PR #386 谢拉格 寒风 blows bottom-up (owner)
- Golden union OK (roster-033 / roster-034 moved on both sides: #298 and #388); broad node --test 5598 (5596 pass, 0 fail, 2 skipped); lint 0 errors.
This commit is contained in:
commit
2492966c4f
32 files changed
+913
-138
No files matched your search
+3
-3
@@ -74,7 +74,7 @@
|
||||
- 想只改朝向:把干员拖回它自己所在的格子再选方向。朝向会一直保留到下一回合。
|
||||
- **点选按地上的方格**:点哪个方格就选中站在那个方格上的单位,点空方格不选任何单位(点到后一格干员画出来的头部,算作点了后面那一格)。作战中友方同样按方格;敌人在格子之间走动,点它的身体或脚下附近(0.6 格内)即可,飞行敌人点它的身体。
|
||||
- 拖动时模型一直在指针 / 手指下(脚在指针下方约半格,鼠标和触屏一样),**指针所在的格子**就是落点并高亮;装备拖到哪个格子,就配发给那个格子上的干员;在商店栏上松手则放回原位。
|
||||
- 点击干员显示攻击范围和底部按钮(「撤退」回到整备区、「出售 +1」);右键 / 长按查看详细信息:头像旁是盟约,下面第一项就是干员的**特质**(如「休整期结束时 …」),之后是特性、属性、攻击范围、技能、模组、装备、天赋。属性是**实时数值**:作战中每秒刷新 4 次(当前生命、攻击、防御、法抗、攻击间隔、阻挡),休整期显示棋盘上的干员**开战时**的数值(已算入装备、盟约层数、特质、策略与机变效果);高于基础值为绿色、低于为红色并标出差值(攻击间隔变短算增益),鼠标悬停可看基础值。基础值是官方数据在该阶级的等级、技能等级(和精锐的模组)下、**满潜能**、0 信赖的数值:所有干员(精锐、补位、自选也一样)都带上全部潜能加成——部署费用、再部署时间、攻击力 / 生命 / 防御 / 法抗 / 攻速,以及「天赋效果增强」,召唤物的天赋也按召唤者的满潜能(维护者 2026-10-07 的决定,GitHub #252:官方赛事视频里的刺玫正是满潜能的攻击 435、费用 15)。
|
||||
- 点击干员显示攻击范围和底部按钮(「撤退」回到整备区、「出售 +1」);右键 / 长按查看详细信息。作战中、查看队友的阵地时,点开场上我方干员或召唤物(包括队友的)的详情卡,棋盘上也会画出它**当前的攻击范围**(按它现在的位置、朝向和技能,例如开着扩大攻击范围的技能时就是扩大后的范围),关掉详情卡、拖动或选方向时消失;敌人、整备区和临时整备区里的单位不画。详情卡里:头像旁是盟约,下面第一项就是干员的**特质**(如「休整期结束时 …」),之后是特性、属性、攻击范围、技能、模组、装备、天赋。属性是**实时数值**:作战中每秒刷新 4 次(当前生命、攻击、防御、法抗、攻击间隔、阻挡),休整期显示棋盘上的干员**开战时**的数值(已算入装备、盟约层数、特质、策略与机变效果);高于基础值为绿色、低于为红色并标出差值(攻击间隔变短算增益),鼠标悬停可看基础值。基础值是官方数据在该阶级的等级、技能等级(和精锐的模组)下、**满潜能**、0 信赖的数值:所有干员(精锐、补位、自选也一样)都带上全部潜能加成——部署费用、再部署时间、攻击力 / 生命 / 防御 / 法抗 / 攻速,以及「天赋效果增强」,召唤物的天赋也按召唤者的满潜能(维护者 2026-10-07 的决定,GitHub #252:官方赛事视频里的刺玫正是满潜能的攻击 435、费用 15)。
|
||||
- **召唤物手动摆放**:把带可部署召唤物的干员放上棋盘后,它的召唤物会进入整备区(赫默的医疗探机、巫恋的诅咒娃娃、凯瑟琳的 2 个支援装置、浊心斯卡蒂的海嗣、伺夜的狼群、缪尔赛思的流形;同一干员的召唤物叠在一格,不占部署位),像干员一样拖到棋盘上并选方向。凯瑟琳的支援装置给**它面朝的那一格**上的干员加屏障,所以要放在干员旁边、朝向干员。**战术家的援军**(伺夜的狼群、缪尔赛思的流形,也就是选战术点)和官方一样**只能放在召唤者的攻击范围内**:拖动时只有这些格子会亮起;之后在原地给战术家换朝向时,不在新攻击范围里的援军会退回整备区(还在范围里的保持不动;整备区和临时整备区都满、援军无处可退时,这次换朝向不生效并提示「整备区已满」)。撤退或出售干员时它的召唤物一起移除;**移动干员到别的格子时(包括把召唤物拖到干员身上交换位置),它已摆放的召唤物会退回整备区**,需要重新摆放。整备区满时退回的召唤物进入临时整备区,休整期结束还没处理的会在下一回合重新回到整备区。
|
||||
- 作战中:海嗣、狼群、流形、支援装置开战时就在摆放的位置部署;医疗探机、诅咒娃娃开战时也在摆放的位置免费部署一次(持续 10 / 15 秒,和官方一样不需要先开技能),之后所属干员每次发动技能时再次在原位出现;所属干员被击倒、再部署回场时,技能给的那一个如果还没出现,会立刻出现。巫恋退场时诅咒娃娃随之撤退,赫默退场时已出现的医疗探机不受影响。医疗探机、诅咒娃娃、海嗣(以及自选干员的工程蓄水炮、沙地兽、打字机、本能的召唤)这类不会受到攻击、到时间就离场的召唤物不受任何伤害(活性源石也一样)、也不会被治疗,它们的血条就是倒计时:随时间减少,空了就离场。琳琅诗怀雅二技能的香槟炸弹也不会掉血(活性源石和各种伤害都不会)、不会被治疗,不会因为生命值而消失,只在「见面礼」炸到碰到它的第一个地面敌人后消失(维护者 2026-10-08 的决定);隐匿(没有被反隐、也没有被阻挡)或沉睡的敌人选不中,不会触发它。医疗探机、诅咒娃娃、支援装置、海嗣没有摆放就不会出现;狼群、流形(战术家的援军)没有摆放时会在战术点出现。狼群最后一只狼影被击倒后进入战术点形态:暂时离开战斗(不阻挡、不攻击、不会被攻击,格子仍然占着),经过狼影的恢复时间(25 秒)后在原地满血、带着 1 只狼影回来,之后重新开始每 25 秒加 1 只狼影;伺夜退场时狼群随之撤退,伺夜再部署回场时狼群重新带着 2 只狼影出现。
|
||||
- **阻挡**看接触:干员阻挡中心距离约 0.7 格以内、与它接触的敌人(地面干员挡不住飞行和近地悬浮的敌人)。敌人卡在两名干员之间时,阻挡它的干员倒下、撤退或被晕眩后,另一名仍与它接触、还有空余阻挡数的干员会**立刻接替阻挡**;都没有空位时敌人才会走掉。被冻结或晕眩的敌人同样会被接触到的干员挡住:干员再部署到它身边就会阻挡它,隐匿的敌人也随之破隐(沉睡、浮空、恐惧的敌人仍然挡不住)。迎面走来的敌人会停在干员格子前面。**阻挡了就一定能打到**:任何干员(包括站在近战位的远程干员和召唤物)总能攻击自己阻挡的敌人,不管它在不在攻击范围内、在干员哪一侧,并且优先攻击它——只有速射手例外:攻击范围内有飞行敌人时先打飞行敌人(特性「优先攻击空中单位」,维护者 2026-10-08 的决定,GitHub #220)(医疗干员除外:咒愈师以外的医疗挡住敌人时照常治疗队友,不会攻击它;「停止攻击」类技能期间也除外;技能原文另有限制的也按原文:普罗旺斯二技能开启时不以生命值高于 80% 的敌人为目标,寒檀二技能的冰凌随机落在攻击范围内的地块上)。
|
||||
@@ -137,7 +137,7 @@
|
||||
- 休整期、或自己的作战结束后(显示「作战结束,等待队友完成作战」),点左侧队友头像 →「前往查看」观看他的战场;自己的作战进行中不能观战。联防和最终攻势中可以用 ‹ › 切换左半 / 全景 / 右半。
|
||||
- 被淘汰后可以自由观看任何战场,而且**不用每回合重选**:你上次点「前往查看」的是谁,之后每个阶段(回合开始、机变、休整期、作战、最终攻势)都会自动跟到他的阵地或战场;没选过或他也被淘汰时,跟随座位顺序第一位还在场的博士(包括 AI 队友)。阶段中途换人立即生效,之后就跟随新选的人。
|
||||
- **观战席**(本作新增,原版没有):每个同盟房间另有 2 个观战席。在大厅输入同盟密钥后点「观战」进入(模拟进行中也可以),不占博士席位、不算参战人数,也不能购买、部署、准备或发送表情;观看方式与被淘汰的玩家相同——自动跟随你上次查看的博士(没查看过则是座位顺序第一位还在场的博士):休整期显示他的阵地,作战时观看他的战场,点左侧成员头像可切换查看任何人。断线重连会回到观战席;创建者可以在等待室用观战者名字旁的 ✕ 将其移出;模拟开始后,顶栏延迟旁会出现一个「👁 人数」的小胶囊(没有观战者时不显示),点开是观战者名单(离线的有标记),创建者可以在名单里用 ✕ 把观战者移出,被移出的人立刻回到大厅、不再收到战场画面(观战席上限仍是 2 个,不能调整);观战者在等待室有空位时可以点「入座」成为博士。
|
||||
- **观看谁,盟约栏就显示谁的盟约**:查看队友的阵地或战场时(休整期、作战结束后、被淘汰后自动观战),左上的盟约栏换成**该队友的盟约、人数和层数**,前面有琥珀色的「👁 队友名」标签,点开的盟约详情也写着「队友名 的盟约」(成员一栏只标出他在场上的干员,整备区看不到);作战中层数实时增长(与他自己看到的一致)。联防和最终攻势里跟随 ‹ ›:镜头在队友的半场时显示他的盟约,在你自己的半场时显示你自己的;「全景」时,你在这个战场上作战就显示你自己的,你不在这个战场上(漏怪后观看联防、被淘汰后观战)则显示你点「前往查看」选中的那名队友,没选过就显示该战场的第一名玩家(1 号联防者 / 这一组的第一位),**不会显示你自己的**。在这样的战场上点某名队友的头像 →「前往查看」,镜头会直接移到他的半场,‹ › 上的「👁 队友名」与盟约栏的标签一致;‹ › 旁边有「返回战场」(被淘汰时没有)。盟约栏换人时,从盟约栏打开的盟约详情会自动关闭。点开场上某个干员的详情卡时,卡上的盟约(以及点它打开的盟约详情)属于**这个干员的主人**,与盟约栏当前显示谁无关——例如在「全景」点队友的干员,看到的是「队友名 的盟约」及他的人数和层数;在队友的半场点你自己的干员,看到的是你自己的。回到自己的阵地 / 战场后恢复成你自己的盟约。作战结束后观看队友时刷新页面 / 断线重连,仍会回到正在观看的队友战场,「👁 队友名」和「返回战场」都还在。已被淘汰的队友不再显示盟约(也无法被查看)。
|
||||
- **观看谁,盟约栏就显示谁的盟约**:查看队友的阵地或战场时(休整期、作战结束后、被淘汰后自动观战),左上的盟约栏换成**该队友的盟约、人数和层数**,前面有琥珀色的「👁 队友名」标签,点开的盟约详情也写着「队友名 的盟约」(成员一栏标出他在场上的干员;休整期查看时他整备区、临时整备区里的干员也会列出、标为「整备区」,和他自己看到的一样计数:投资人、远见、奇迹这类含整备区的盟约把整备区的算进成员人数,临时整备区的哪个盟约都不算;作战中只列出场上的);作战中层数实时增长(与他自己看到的一致)。联防和最终攻势里跟随 ‹ ›:镜头在队友的半场时显示他的盟约,在你自己的半场时显示你自己的;「全景」时,你在这个战场上作战就显示你自己的,你不在这个战场上(漏怪后观看联防、被淘汰后观战)则显示你点「前往查看」选中的那名队友,没选过就显示该战场的第一名玩家(1 号联防者 / 这一组的第一位),**不会显示你自己的**。在这样的战场上点某名队友的头像 →「前往查看」,镜头会直接移到他的半场,‹ › 上的「👁 队友名」与盟约栏的标签一致;‹ › 旁边有「返回战场」(被淘汰时没有)。盟约栏换人时,从盟约栏打开的盟约详情会自动关闭。点开场上某个干员的详情卡时,卡上的盟约(以及点它打开的盟约详情)属于**这个干员的主人**,与盟约栏当前显示谁无关——例如在「全景」点队友的干员,看到的是「队友名 的盟约」及他的人数和层数;在队友的半场点你自己的干员,看到的是你自己的。回到自己的阵地 / 战场后恢复成你自己的盟约。作战结束后观看队友时刷新页面 / 断线重连,仍会回到正在观看的队友战场,「👁 队友名」和「返回战场」都还在。已被淘汰的队友不再显示盟约(也无法被查看)。
|
||||
|
||||
## 9. 结算称号
|
||||
|
||||
@@ -171,7 +171,7 @@
|
||||
| `Space` | 准备就绪 / 取消准备;独立模拟的作战中暂停 / 继续 |
|
||||
| `Esc` | 取消 / 关闭:依次关闭表情面板、敌人预览、盟约浮层、详情卡、本局信息 / 敌方情报(固定,不能修改) |
|
||||
|
||||
刷新、冻结、升级、撤退、出售和准备只在休整期有效,撤退 / 出售要先点选一名干员。方向轮盘(方向键预览、`Enter` 确认、`Esc` 取消)、「玩法说明」和「干员调配」的翻页键是这些界面自己的按键,不能修改。
|
||||
刷新、冻结、升级、撤退、出售和准备只在休整期有效,撤退 / 出售要先点选一名干员。方向轮盘(方向键预览、`Enter` 确认、`Esc` 取消;按 Tab 先到轮盘上的「点击取消」,在它上面按 `Enter` 是取消,Tab 到其他按钮上按 `Enter` 没有反应)、「玩法说明」和「干员调配」的翻页键是这些界面自己的按键,不能修改。
|
||||
|
||||
**修改快捷键**:标题画面的「设置」按钮、大厅和房间顶栏的「设置」(玩法说明左边)或对局左下角的「设置」→「快捷键」。点一下要改的按键(只用键盘时按 Tab 移到它上面再按 Enter),出现「按下新按键…」后按新的键;按 `Esc` 取消,设置窗口不会关闭。可以用字母、数字、空格、标点符号键和 Delete / Insert / Home / End / PageUp / PageDown;`Esc`、`Tab`、`Enter`、方向键和 F1–F12 留给界面使用,也不能搭配 Ctrl / Alt / ⌘。新按键已被另一个快捷键使用时两者互换,列表下方会写明(例如把「撤退选中干员」改成 `X`,「出售选中干员」就换成原来的 `Q`)。「恢复默认」改回上表的按键。修改立即生效,商店栏、准备按钮和单位底部「撤退 / 出售」按钮上显示的按键也跟着变;设置和音量等一起保存在当前浏览器里(换浏览器或清除网站数据后回到默认)。按键按键帽上印的字符识别(AZERTY 等键盘按印着的字母),俄文等非拉丁布局按键位识别。手机等纯触屏设备要连接键盘才能用快捷键,设置里的快捷键列表默认折叠。
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ Screens: **Title** (season art backdrop, 开始 → nickname), **Lobby** (独立
|
||||
|
||||
A bond the mode never activates (标准: official `inactiveBondIdList`) reads 本局禁用 on shop / reward cards (struck through), detail chips and the popup — no 在场 count, no 当前效果 (§21.7); a strategy built around one carries a 本局禁用 tag and note in the strategy draft and stays selectable (bands.json `bondIds`, §21.26).
|
||||
|
||||
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).
|
||||
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 — the range follows the open detail card (`screens/game/range.js inspectRange`, GitHub PR #281): an own board piece in prep, and in battle or on a scouted board any ally operator / summon on the field with its current range (the live stats' `range` and `dir`, the snapshot's tile), never an enemy or a bench unit (UnitInfo `area` hand / temp); drag, the wheel and the pen hide it; 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; 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.
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ S→C:
|
||||
interpolate linearly; nothing is inferred from the attack animation.
|
||||
- `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)
|
||||
- `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}}` and `dir?` (shared/protocol.js `unitStatsEntry`; `range` = an ally's live range grid, facing RIGHT, never a target-selection grid, §21.16; `dir` = the unit's facing now, UP|RIGHT|DOWN|LEFT — the snapshot tuples carry none and UnitInfo `dir` is the facing at send time — which the detail card's range overlay rotates by, GitHub PR #281) as its next battle starts (§18.5)
|
||||
|
||||
`UnitInfo = { id, kind, side, ownerId, defId, name, tier, golden, spine, avatar, x, y, facing, dir, maxHp, skillIndex?, moduleId?, items?, form? }` (`dir` = the ally's deploy direction; the renderer picks the model (Back for UP, mirrored for LEFT) and the ground wedge from it; `skillIndex` = the ally's equipped skill (§16): the renderer plays that skill's Spine clip (`anims.skills[i]`) and the audio its own ON_SKILL_START sound; `moduleId` = an elite ally's equipped module (uniEquipId | 'none'); `items` = an ally operator's equipped item ids (absent without any; never on enemies or summons): the bond popup lists a 变形同构体 wearer from them and the detail card shows its 同构 chip and 装备 (§21.11); all three also on the prep-scouting `m.field` units (`Match.prepFieldMeta`, the held pieces included), so a teammate's unit shows its owner's loadout in the detail card and its equipped items as item pips on the unit (prep surfaces; user playtest #2 item 3); `form` = the unit's current model form (an enemy's mode §21.4, a 傀儡师's 替身 §22.11; a view built mid-battle starts in it)).
|
||||
|
||||
|
||||
@@ -515,6 +515,8 @@ button.bpop__harmony:hover { border-style: solid; color: #fff; }
|
||||
|
||||
/* ---- direction wheel (ui/facingWheel.js, research 09 §1.2): white diamond, 4 chevrons, "✕ 点击取消", orange range ---- */
|
||||
.fwheel { position: fixed; inset: 0; z-index: 6; touch-action: none; user-select: none; -webkit-user-select: none; cursor: crosshair; }
|
||||
/* the wheel takes the keyboard focus when it opens (Enter confirms there, GitHub #394): no ring around the whole screen */
|
||||
.fwheel:focus-visible { outline: none; }
|
||||
.fwheel__stripes { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; pointer-events: none; }
|
||||
.fwheel__dia { position: absolute; transform: translate(-50%, -50%); pointer-events: none; animation: fwheel-in 160ms var(--ease-out); }
|
||||
@keyframes fwheel-in { from { opacity: 0; transform: translate(-50%, -50%) scale(.82); } }
|
||||
|
||||
@@ -41,6 +41,8 @@ export function renderInfo(u) {
|
||||
// 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,
|
||||
// Scouted hand and temp units have details but no field attack range.
|
||||
area: typeof u.area === 'string' ? u.area : 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
|
||||
// unit takes its items from the piece instead, so only other players' boards ever read this field
|
||||
|
||||
@@ -101,7 +101,12 @@ export class FxSim {
|
||||
else this.zone(at.x, at.y, at.z, r, col, d, 'ring', true);
|
||||
break;
|
||||
}
|
||||
case 'chill': this.flashScreen(col, 0.35, 0.8); this.snowfall(col); break;
|
||||
// 盟约寒风 (谢拉格's field-wide wind — the device emits it with no anchor unit): a blizzard up the field and a
|
||||
// stronger cold flash (PR #386); an operator's own cold (灵知 S3's start, 麦哲伦 S1's pulse every 3 s: `id` = the
|
||||
// unit) keeps the light flurry and the softer flash — a full-screen blizzard every pulse would bury the field
|
||||
case 'chill':
|
||||
if (ex.id == null) { this.flashScreen(col, 0.45, 1); this.snowfall(col, true); } else { this.flashScreen(col, 0.35, 0.8); this.snowfall(col); }
|
||||
break;
|
||||
case 'heal': this.heal(at.v || { x: at.x, y: at.y, z: at.z }, 0); break;
|
||||
case 'healAoe': {
|
||||
this.ring(at.x, at.y, at.z, 0.2, r, col, 0.6);
|
||||
|
||||
@@ -180,11 +180,32 @@ export class FxZones {
|
||||
this.tintSprite.tint = tint;
|
||||
}
|
||||
|
||||
/** A short flurry of snow over the field (cold wind). */
|
||||
snowfall(tint) {
|
||||
/**
|
||||
* Snow over the field (cold wind). `gust` — 盟约寒风, 谢拉格's field-wide wind: a blizzard sweeping the screen from the
|
||||
* bottom up, the official direction (the owner's confirmation of 2026-10-08; PR #386 by @leiming2333 after the
|
||||
* community report 「谢拉格的效果特性不明显,暴风雪应该从下向上刮」): snowflakes rise off the bottom edge on a crosswind
|
||||
* with gust streaks racing along, so it reads as wind driving up the field, not quiet snowfall (flake / streak counts,
|
||||
* speeds and lives are a visual choice [ASSUMED]; screen y grows downward, so a negative vy rises). Without `gust` (an
|
||||
* operator's own cold: 灵知 S3, 麦哲伦 S1's pulses) a short light flurry drifting down, as before.
|
||||
*/
|
||||
snowfall(tint, gust = false) {
|
||||
const size = this.ctx.screenSize();
|
||||
for (let i = 0; i < (this.quality === 'low' ? 10 : 26); i++) {
|
||||
this.particle('dot', Math.random() * size.width, Math.random() * size.height * 0.7, { tint, vx: 30 + Math.random() * 40, vy: 60 + Math.random() * 60, life: 1 + Math.random() * 0.6, s0: 0.25 + Math.random() * 0.3, s1: 0.1, a0: 0.8, a1: 0, fadeIn: 0.2 });
|
||||
const W = size.width, H = size.height, low = this.quality === 'low';
|
||||
if (!gust) {
|
||||
for (let i = 0; i < (low ? 10 : 26); i++) {
|
||||
this.particle('dot', Math.random() * W, Math.random() * H * 0.7, { tint, vx: 30 + Math.random() * 40, vy: 60 + Math.random() * 60, life: 1 + Math.random() * 0.6, s0: 0.25 + Math.random() * 0.3, s1: 0.1, a0: 0.8, a1: 0, fadeIn: 0.2 });
|
||||
}
|
||||
return;
|
||||
}
|
||||
// snowflakes: lifted off the bottom edge (a share starts below it), blown up-right and tumbled by the gust
|
||||
for (let i = 0; i < (low ? 22 : 48); i++) {
|
||||
const vy = -H * (0.55 + Math.random() * 0.5), vx = W * (0.04 + Math.random() * 0.1);
|
||||
this.particle('dot', Math.random() * W, H * (0.55 + Math.random() * 0.55), { tint, vx, vy, life: 1.4 + Math.random() * 0.9, s0: 0.22 + Math.random() * 0.34, s1: 0.08, a0: 0.9, a1: 0, fadeIn: 0.12, spin: (Math.random() - 0.5) * 6 });
|
||||
}
|
||||
// gust streaks: thin wind lines racing up through the field along their velocity
|
||||
for (let i = 0; i < (low ? 4 : 10); i++) {
|
||||
const vy = -H * (0.9 + Math.random() * 0.6), vx = W * (0.1 + Math.random() * 0.12);
|
||||
this.particle('streak', Math.random() * W, H * (0.6 + Math.random() * 0.5), { tint, vx, vy, life: 0.7 + Math.random() * 0.4, s0: 0.04 + Math.random() * 0.03, s1: 0.02, sx: 6 + Math.random() * 6, a0: 0.55, a1: 0, fadeIn: 0.08, rot: Math.atan2(vy, vx) });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+23
-12
@@ -117,6 +117,7 @@ import { useDocClass, FullscreenButton } from '../ui/device.js';
|
||||
import { HUD_HZ_MS, MERGE_HL, SEL_RANGE, cx } from './game/marks.js';
|
||||
import { keepEarly, audioEarly } from './game/early.js';
|
||||
import { MatchEnded, PausedOverlay } from './game/overlays.js';
|
||||
import { inspectRange } from './game/range.js';
|
||||
import { t, tParts } from '../../../shared/i18n.js';
|
||||
|
||||
/** Router for the in-match screens. */
|
||||
@@ -477,6 +478,9 @@ function MatchScreen() {
|
||||
}
|
||||
if (earlySnap) {
|
||||
view.pushSnapshot(earlySnap);
|
||||
if (Array.isArray(earlySnap.units)) {
|
||||
snapUnitsRef.current = new Map(earlySnap.units.filter(Array.isArray).map((t) => [t[0], t]));
|
||||
}
|
||||
hudRef.current = snapHud(earlySnap);
|
||||
setHud(hudRef.current);
|
||||
}
|
||||
@@ -1045,10 +1049,6 @@ function MatchScreen() {
|
||||
}, []);
|
||||
|
||||
// ---- direction step (research 09 §1.2) and the selected piece's underframe ------------------------------------
|
||||
// DESIGN §16: previews show the range the unit fights with under the player's loadout (an elite's module grid); a
|
||||
// chess the player does not own the range of its stand-in (0.2.0 补位, gameLogic deployedRecord)
|
||||
const lookups = useMemo(() => ({ getChess: ownChess, getToken: gd.token, getItem: gd.item,
|
||||
chessRecord: (rec) => deployedRecord(rec, live.current.priv, ownChess, gd.backups) }), [gd.ready]);
|
||||
const heldRef = useRef(new Map()); // uid → { row, col, t } committed placements awaiting m.private
|
||||
const releaseHold = useCallback((uid) => {
|
||||
heldRef.current.delete(uid);
|
||||
@@ -1100,16 +1100,10 @@ function MatchScreen() {
|
||||
useEffect(() => {
|
||||
if (facing && (!editable || !placeCtx.pieces.get(facing.uid))) cancelFacing();
|
||||
}, [editable, placeCtx, facing]);
|
||||
// the selected piece: gone / not editable → deselect; on the board its range tiles show (rotated to its facing)
|
||||
// the selected piece: gone / not editable → deselect (its range tiles: the detail card's, cardRange below)
|
||||
const selEntry = sel ? placeCtx.pieces.get(sel.uid) || null : null;
|
||||
live.current.showPrep = showPrep;
|
||||
useEffect(() => { if (sel && (!selEntry || !editable || !showPrep)) setSel(null); }, [sel, selEntry, editable, showPrep]);
|
||||
const selRangeKey = selEntry && selEntry.area === 'board' ? `${selEntry.piece.uid}:${selEntry.row},${selEntry.col}:${pieceDir(selEntry.piece)}` : '';
|
||||
useEffect(() => {
|
||||
if (!view || !selRangeKey) return undefined;
|
||||
showRange(view, previewGrid(lookups, selEntry.piece), selEntry.row, selEntry.col, pieceDir(selEntry.piece), SEL_RANGE);
|
||||
return () => showRange(view, null, 0, 0, null, SEL_RANGE);
|
||||
}, [view, selRangeKey]);
|
||||
// the selected piece's underframe on screen: the detail card docks on the side away from it (user playtest #2
|
||||
// item 8 — at some aspect ratios a bench unit's 出售 sat under the left card); the underframe is drawn above every
|
||||
// panel anyway (css z-index), this keeps it visible too
|
||||
@@ -1296,7 +1290,7 @@ function MatchScreen() {
|
||||
}, [strip.ownerId]);
|
||||
// the popup shows the player it was opened for (ui/watchBonds.js popupView): their entry + live layers, their name,
|
||||
// and as members your pieces or their operators on the field on screen — under client-side combat the battle's own
|
||||
// (the runner's field meta is taken before they deploy); their hand is never sent
|
||||
// (the runner's field meta is taken before they deploy); a battle sends no bench (a prep scout does: ownerBoard)
|
||||
const popOps = cc && battleRunner && field?.local && bondOpen && bondOpen.ownerId !== myId ? battleRunner.ownerOps(bondOpen.ownerId, field.fieldId) : null;
|
||||
const bondPop = popupView({ open: bondOpen, pub, priv, myId, field, units: popOps, live: liveLayers });
|
||||
const openBond = (id, ownerId, from) => { setBondOpen((b) => toggleBond(b, id, ownerId, from)); audio.sfx('click', { volume: 0.4 }); };
|
||||
@@ -1350,6 +1344,23 @@ function MatchScreen() {
|
||||
}
|
||||
return priv?.loadout ?? null; // own pieces, shop / reward / bond-member cards
|
||||
})();
|
||||
// the open detail card's unit draws its attack range on the field (screens/game/range.js inspectRange, GitHub PR #281):
|
||||
// an own board piece in prep (its loadout / stand-in record, its facing); in battle or on a scouted board an ally
|
||||
// operator / summon on the field — the range it fights with now (the live stats' `range` and `dir`, the snapshot's
|
||||
// tile) — never an enemy, a dead unit or a bench unit (UnitInfo `area` hand / temp); drag, the wheel and the pen
|
||||
// draw their own
|
||||
const cardRange = !drag && !facing && !pen ? inspectRange({
|
||||
target: detailTarget, detail: resolved, pieces: placeCtx.pieces, showPrep, field,
|
||||
snapshot: snapUnitsRef.current.get(resolved?.unitId),
|
||||
live: typeof liveStats === 'function' ? liveStats() : liveStats,
|
||||
loadout: detailLoadout, getChess: gd.chess, backups: gd.backups,
|
||||
}) : null;
|
||||
const cardRangeKey = cardRange ? JSON.stringify(cardRange) : '';
|
||||
useEffect(() => {
|
||||
if (!view) return undefined;
|
||||
showRange(view, cardRange?.grid, cardRange?.row, cardRange?.col, cardRange?.dir, SEL_RANGE);
|
||||
return () => showRange(view, null, 0, 0, null, SEL_RANGE);
|
||||
}, [view, cardRangeKey, showPrep, field]);
|
||||
// the card's bond chips — and the popup a chip opens: a battle / scouted unit's OWNER's bonds (yours, or that
|
||||
// teammate's — m.public + live layers), your own piece's yours, a popup member card the popup's player; shop / reward
|
||||
// cards read the strip's (ui/watchBonds.js detailBondOwner)
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
// screens/game/range.js — the attack range the open detail card's unit draws on the field (GitHub PR #281 by
|
||||
// @IceCodeNew; the field-detail range idea after @BBleae's fork): see game.js cardRange.
|
||||
import { attackRangeGrid } from '../../../../shared/loadoutRecord.js';
|
||||
import { chessLoadout, standInLoadout } from '../../ui/gameLogic.js';
|
||||
import { tokenVariantFor } from '../../ui/gameLogic/loadout.js';
|
||||
import { normDir } from '../../ui/facing.js';
|
||||
|
||||
/** Resolve the inspected field unit without granting placement or sale actions. */
|
||||
export function inspectRange({ target, detail, pieces, showPrep = false, field, snapshot, live, loadout, getChess, backups }) {
|
||||
if (!detail || (detail.type !== 'chess' && detail.type !== 'token')) return null;
|
||||
let row, col, dir;
|
||||
if (target?.kind === 'piece' && showPrep) {
|
||||
const e = pieces?.get(target.uid);
|
||||
if (!e || e.area !== 'board') return null;
|
||||
row = e.row; col = e.col; dir = normDir(e.piece.dir);
|
||||
} else if (target?.kind === 'unit' && !showPrep) {
|
||||
const unit = target.unit;
|
||||
if (!unit || unit.side !== 'ally' || (unit.kind !== 'op' && unit.kind !== 'token')) return null;
|
||||
if (unit.area != null && unit.area !== 'board') return null;
|
||||
// Snapshots exclude undeployed, hidden and removed units. Dead units remain briefly for their death animation.
|
||||
if (!field?.prep && (!snapshot || snapshot[3] <= 0)) return null;
|
||||
row = Math.round(snapshot ? snapshot[2] : unit.y);
|
||||
col = Math.round(snapshot ? snapshot[1] : unit.x);
|
||||
dir = normDir(live?.dir, null) || normDir(unit.dir, null) || (unit.facing === -1 ? 'LEFT' : 'RIGHT');
|
||||
} else return null;
|
||||
if (!Number.isFinite(row) || !Number.isFinite(col)) return null;
|
||||
const record = detail.type === 'token' ? { ...detail.token, ...tokenVariantFor(detail.token, detail.ownerId) }
|
||||
: detail.standIn ? standInLoadout(detail.standIn, getChess, backups)?.record || detail.standIn
|
||||
: chessLoadout(detail.chess, detail.diy ? null : loadout, getChess)?.record || detail.chess;
|
||||
const grid = Array.isArray(live?.range) ? live.range : attackRangeGrid(record);
|
||||
return grid?.length ? { grid, row, col, dir } : null;
|
||||
}
|
||||
@@ -17,7 +17,9 @@
|
||||
// already did.
|
||||
// Watching a teammate (DESIGN §20.15, ui/watchBonds.js) the strip and the popup show THAT player's bonds and layers:
|
||||
// the strip carries an amber "👁 name" tag (`owner`, the observing pill's spelling, research 09 §3.1) and amber rings,
|
||||
// the popup a "👁 name 的盟约" line; its member list reads the teammate's operators on the field (no hand: never sent).
|
||||
// the popup a "👁 name 的盟约" line; its member list reads the teammate's operators on the field — in prep their bench
|
||||
// too, counted like their own popup counts it (ui/watchBonds.js ownerBoard: hand / temp by the scout's `area`, GitHub
|
||||
// #385); a battle sends no bench.
|
||||
// 0.2.0 补位: a member the player fields as its stand-in (the own m.private.standIns; a teammate's unit that says so —
|
||||
// UnitInfo `standInFor`) is drawn as the stand-in — avatar, name, a small 「替补」 mark on the thumbnail — and its row
|
||||
// opens the stand-in's card (gameLogic memberStandIn; the owner's recall of the official mode, 2026-10-06).
|
||||
|
||||
@@ -46,6 +46,7 @@ import { SKILL_SUMMON_START_DEPLOY } from '../../../shared/constants.js';
|
||||
import { moduleBadge, fullTraitText } from './loadoutModel.js';
|
||||
import { t, tc, N_ } from '../../../shared/i18n.js';
|
||||
import { audio } from '../audio.js';
|
||||
import { tokenVariantFor } from './gameLogic/loadout.js';
|
||||
|
||||
const cx = (...p) => p.flat().filter(Boolean).join(' ');
|
||||
|
||||
@@ -580,21 +581,6 @@ export function summonDeployHint(token, startDeploy = SKILL_SUMMON_START_DEPLOY)
|
||||
: t('所属干员发动技能时才在摆放的位置出现(未摆放则不会出现)');
|
||||
}
|
||||
|
||||
/**
|
||||
* The token variant of the summon's owner (tokens.json `variants`, keyed by owner chess id): a golden owner's `_b`
|
||||
* entry (精锐 赫默's drone ATK 114, 精锐 巫恋's doll −30%), else its normal `_a` entry, else the first one.
|
||||
* @param {any} token tokens.json record
|
||||
* @param {string|null} ownerId the owner's chess id (null: unknown, e.g. a teammate's summon)
|
||||
*/
|
||||
export function tokenVariantFor(token, ownerId = null) {
|
||||
const vs = token?.variants || {};
|
||||
if (typeof ownerId === 'string') {
|
||||
const v = vs[ownerId] || vs[ownerId.replace(/_b$/, '_a')];
|
||||
if (v) return v;
|
||||
}
|
||||
return Object.values(vs)[0] || null;
|
||||
}
|
||||
|
||||
/** Chess id of the operator owning a token piece (`ownerUid`), from the player's own pieces (indexPieces). */
|
||||
function tokenOwnerId(piece, pieces) {
|
||||
if (!piece || piece.kind !== 'token' || !Number.isInteger(piece.ownerUid)) return null;
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
// LEFT, facing.js boardDir): the chevrons follow the finger, the range / model / intent use the board direction;
|
||||
// * release outside the centre commits (g.move {uid, to, dir} / g.art {…, dir}); release inside the centre, a tap
|
||||
// on ✕, a press outside the diamond or Esc cancels (the piece returns to where it came from). Arrow keys preview a
|
||||
// direction and Enter commits it (keyboard alternative). Mouse, pen and touch all use pointer events.
|
||||
// direction and Enter commits it (keyboard alternative) — Enter on a focused ✕ cancels, on any other focused
|
||||
// button does nothing while the wheel is open (GitHub #394). Mouse, pen and touch all use pointer events.
|
||||
//
|
||||
// View bridge: the field view (render/app.js or the DOM fallback) may implement `tileScreen(row, col)`,
|
||||
// `holdPiece(uid, tile|null)` and `setPieceDir(uid, dir)` (render/app.js does, DESIGN §9); a view without them falls
|
||||
@@ -25,7 +26,7 @@ import { useEffect, useLayoutEffect, useRef, useState } from '../../vendor/hooks
|
||||
import { html } from './components.js';
|
||||
import { LocalSprite } from './gameComponents.js';
|
||||
import { DIRS, DIR_LABEL, DEAD_ZONE_TILES, dirFromDelta, dirFromKey, rangeTiles, normDir, boardDir, viewMirrored } from './facing.js';
|
||||
import { facingSwallows } from './gameLogic.js';
|
||||
import { facingSwallows, facingEnter } from './gameLogic.js';
|
||||
import { settingsStore } from './settings.js';
|
||||
import { t } from '../../../shared/i18n.js';
|
||||
|
||||
@@ -236,24 +237,36 @@ export function FacingWheel({ view, row, col, grid, name = '', onPreview, onComm
|
||||
};
|
||||
const onPointerCancel = () => { setDrag(null); setDir(null); };
|
||||
|
||||
// keyboard: arrows preview, Enter commits, Esc cancels (capture: the game's own shortcuts must not see them)
|
||||
// keyboard: arrows preview, Enter commits, Esc cancels (capture: the game's own shortcuts must not see them). Enter
|
||||
// follows the focus (gameLogic facingEnter, GitHub #394): on ✕ it cancels, on any other button (Tab reaches the HUD
|
||||
// behind the wheel) it does nothing, elsewhere it commits the previewed direction. The wheel takes the focus when it
|
||||
// opens, so a button focused before the drop (a click leaves it focused) cannot swallow the confirming Enter; Tab then
|
||||
// reaches ✕ first. Nothing gives the focus back when it closes (a restored 准备 would take the next Enter / Space).
|
||||
const rootRef = useRef(null);
|
||||
useEffect(() => {
|
||||
const onKey = (e) => {
|
||||
const L = live.current;
|
||||
if (e.key === 'Escape') { e.preventDefault(); e.stopImmediatePropagation(); L.onCancel(); return; }
|
||||
const k = dirFromKey(e.key);
|
||||
if (k) { e.preventDefault(); e.stopImmediatePropagation(); setDir(k); return; }
|
||||
if (e.key === 'Enter' && L.dir) { e.preventDefault(); e.stopImmediatePropagation(); L.onCommit(L.bdir); return; }
|
||||
const enter = facingEnter(e, !!L.dir);
|
||||
if (enter) {
|
||||
e.preventDefault(); e.stopImmediatePropagation();
|
||||
if (enter === 'cancel') L.onCancel();
|
||||
else if (enter === 'commit') L.onCommit(L.bdir);
|
||||
return;
|
||||
}
|
||||
// no ready / shop while choosing: Space and every key of the player's map (设置 → 快捷键)
|
||||
if (facingSwallows(e, settingsStore.get().keys)) { e.preventDefault(); e.stopImmediatePropagation(); }
|
||||
};
|
||||
window.addEventListener('keydown', onKey, true);
|
||||
try { rootRef.current?.focus({ preventScroll: true }); } catch { /* no focus: Enter still follows whatever has it */ }
|
||||
return () => window.removeEventListener('keydown', onKey, true);
|
||||
}, []);
|
||||
|
||||
const outside = !!drag && !!dir;
|
||||
const box = half * 2;
|
||||
return html`<div class=${cx('fwheel', drag && 'is-pressed', dir && `is-${dir.toLowerCase()}`)} role="dialog" aria-label=${name ? t('选择「{name}」的朝向', { name }) : t('选择朝向')}
|
||||
return html`<div ref=${rootRef} tabIndex="-1" class=${cx('fwheel', drag && 'is-pressed', dir && `is-${dir.toLowerCase()}`)} role="dialog" aria-label=${name ? t('选择「{name}」的朝向', { name }) : t('选择朝向')}
|
||||
onPointerDown=${onDown} onPointerMove=${onMove} onPointerUp=${onUp} onPointerCancel=${onPointerCancel}
|
||||
onContextMenu=${(e) => { e.preventDefault(); onCancel(); }}>
|
||||
${g ? html`<${Stripes} tiles=${tiles} view=${view} row=${row} col=${col} />` : null}
|
||||
|
||||
@@ -39,7 +39,7 @@ export { terrainInfo, deviceInfo, deviceTipAt, noteDeviceUnits } from './gameLog
|
||||
export { normalizeDraft, normalizeSp } from './gameLogic/draft.js';
|
||||
export { groupEnemies, PEN, penZoneTiles, penPlacement, previewEnemyKey, factionTypes } from './gameLogic/enemies.js';
|
||||
export { snapHud, bossFrac, bossPctText, hasFlag, UF, attackInterval, fmtNum, rangeGridBox } from './gameLogic/format.js';
|
||||
export { shortcutFor, closesOnFieldPress, shortcutBlocked, HOTKEY_ACTIONS, DEFAULT_HOTKEYS, isBindableCode, hotkeyLabel, sanitizeHotkeys, rebindHotkey, isDefaultHotkeys, hotkeyOf, actionForKey, captureHotkey, facingSwallows } from './gameLogic/shortcuts.js';
|
||||
export { shortcutFor, closesOnFieldPress, shortcutBlocked, HOTKEY_ACTIONS, DEFAULT_HOTKEYS, isBindableCode, hotkeyLabel, sanitizeHotkeys, rebindHotkey, isDefaultHotkeys, hotkeyOf, actionForKey, captureHotkey, facingSwallows, facingEnter } from './gameLogic/shortcuts.js';
|
||||
export { DEFAULT_SETTINGS, VOICE_LANGS, sanitizeSettings } from './gameLogic/settings.js';
|
||||
export { normalizeResult } from './gameLogic/result.js';
|
||||
export { chessLoadout, unitLoadout } from './gameLogic/loadout.js';
|
||||
|
||||
@@ -5,6 +5,15 @@ import { loadoutRecord, resolveRecordLoadout } from '../../../../shared/loadoutR
|
||||
import { isObj } from './shared.js';
|
||||
import { t } from '../../../../shared/i18n.js';
|
||||
|
||||
/** The summon owner's variant: exact chess id, normal sibling, then the first available variant. */
|
||||
export function tokenVariantFor(token, ownerId = null) {
|
||||
const vs = token?.variants || {};
|
||||
if (typeof ownerId === 'string') {
|
||||
const v = vs[ownerId] || vs[ownerId.replace(/_b$/, '_a')];
|
||||
if (v) return v;
|
||||
}
|
||||
return Object.values(vs)[0] || null;
|
||||
}
|
||||
|
||||
// ---- operator loadout (DESIGN §16) ----------------------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -140,8 +140,8 @@ export function captureHotkey(e) {
|
||||
* Map a keydown to a game shortcut under the player's key map (defaults: R refresh, F freeze, D level-up, Q retreat,
|
||||
* X sell, Space ready) or Esc (close; fixed). A shortcut key means its action even while a HUD button has focus (a
|
||||
* mouse click leaves the shop card / 刷新 focused, and Space must not re-trigger it); the caller prevents the button's
|
||||
* own activation. Enter still activates buttons (it cannot be a shortcut). Nothing while Ctrl / ⌘ / Alt is held or a
|
||||
* text field has the focus.
|
||||
* own activation. Enter still activates buttons (it cannot be a shortcut; behind the facing wheel only its ✕:
|
||||
* facingEnter). Nothing while Ctrl / ⌘ / Alt is held or a text field has the focus.
|
||||
* @param {{ key?: string, code?: string, ctrlKey?: boolean, metaKey?: boolean, altKey?: boolean, repeat?: boolean, target?: any }} e
|
||||
* @param {any} [keys] the player's map (settings `keys`)
|
||||
* @returns {'refresh'|'freeze'|'levelUp'|'retreat'|'sell'|'ready'|'escape'|null}
|
||||
@@ -164,6 +164,27 @@ export function shortcutFor(e, keys = DEFAULT_HOTKEYS) {
|
||||
*/
|
||||
export const facingSwallows = (e, keys = DEFAULT_HOTKEYS) => e?.key === ' ' || actionForKey(e, keys) != null;
|
||||
|
||||
// what a focused element is when Enter would activate it (a button, a link, a form field, an ARIA control)
|
||||
const CONTROL = 'button, a[href], input, select, textarea, summary, [contenteditable=""], [contenteditable="true"], '
|
||||
+ '[role="button"], [role="link"], [role="checkbox"], [role="switch"], [role="tab"], [role="menuitem"], [role="option"]';
|
||||
|
||||
/**
|
||||
* What Enter does while the facing wheel is open (GitHub #394, PR #395): on the wheel's own ✕ button it cancels; on
|
||||
* any other control (准备, 刷新, 设置, 玩法说明, a shop card … — Tab reaches them behind the wheel) nothing happens — the
|
||||
* wheel holds the keyboard as it holds Space and the shortcuts; with the focus anywhere else (the wheel itself, the
|
||||
* page) it confirms the previewed direction, and does nothing while none is previewed. null: not Enter.
|
||||
* @param {{ key?: string, target?: any }} e @param {boolean} chosen a direction is previewed
|
||||
* @returns {'cancel'|'commit'|'swallow'|null}
|
||||
*/
|
||||
export function facingEnter(e, chosen) {
|
||||
if (e?.key !== 'Enter') return null;
|
||||
const t = e.target;
|
||||
const closest = (sel) => (t && typeof t.closest === 'function' ? t.closest(sel) : null);
|
||||
if (closest('.fwheel__cancel')) return 'cancel';
|
||||
if (closest(CONTROL)) return 'swallow';
|
||||
return chosen ? 'commit' : 'swallow';
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a press on the field closes the open detail card: a card opened from the field itself (an own piece — tap,
|
||||
* right-click or long press — or a battle / teammate unit). Shop, reward, bond-member and intel (enemy) cards stay.
|
||||
|
||||
+19
-16
@@ -151,27 +151,30 @@ export function withLiveLayers(bonds, live) {
|
||||
* A teammate's board as far as the screen shows it, in the m.private shape the bond popup's member list reads
|
||||
* (gameLogic bondMembers): their operators on the field meta on screen (the prep scouting board, a server-run battle
|
||||
* field's units) and, under client-side combat, the battle on screen's (`extra` = battle/runner.js ownerOps: the meta
|
||||
* the runner publishes is taken before the operators deploy). Their hand is never sent — members count as owned only
|
||||
* when they are on the field. Their operators keep the items the unit info carries (UnitInfo `items`), so a 变形同构体
|
||||
* wearer is listed as a member of the bond it grants (gameLogic grantedBonds), and a 补位 unit its `standInFor` (the
|
||||
* popup draws the stand-in). null without a field or battle.
|
||||
* @param {any} field @param {string} ownerId @param {any[]|null} [extra] UnitInfo-like { kind, ownerId, defId, items?, standInFor? }
|
||||
* the runner publishes is taken before the operators deploy). A prep scout also carries their bench, each unit tagged
|
||||
* with its `area` (server prepFieldMeta): a 'hand' operator goes to the hand list, a 'temp' one to the temp list, so the
|
||||
* popup counts them as the player's own popup does (GitHub #385, PR #387) — owned, not 在场; a hand operator counts for
|
||||
* the bonds that count the 整备区 (投资人 远见 奇迹: memberHeadCount), the 5 temporary slots for none. A unit without
|
||||
* an area (a battle's, an older server's) is on the board; a battle sends no bench. Their operators keep the items the
|
||||
* unit info carries (UnitInfo `items`), so a 变形同构体 wearer is listed as a member of the bond it grants (gameLogic
|
||||
* grantedBonds), and a 补位 unit its `standInFor` (the popup draws the stand-in). null without a field or battle.
|
||||
* @param {any} field @param {string} ownerId @param {any[]|null} [extra] UnitInfo-like { kind, ownerId, defId, area?, items?, standInFor? }
|
||||
*/
|
||||
export function ownerBoard(field, ownerId, extra = null) {
|
||||
const fromField = isObj(field) && Array.isArray(field.units) ? field.units : null;
|
||||
const fromBattle = Array.isArray(extra) ? extra : null;
|
||||
if (!fromField && !fromBattle) return null;
|
||||
const board = [...(fromField || []), ...(fromBattle || [])]
|
||||
.filter((u) => isObj(u) && u.ownerId === ownerId && u.kind === 'op' && typeof u.defId === 'string')
|
||||
.map((u) => {
|
||||
const p = Array.isArray(u.items) && u.items.length ? { kind: 'chess', id: u.defId, items: u.items.filter((x) => typeof x === 'string') } : { kind: 'chess', id: u.defId };
|
||||
// 0.2.0 自选编队: a DIY slot's unit names its operator (UnitInfo diy) — the popup counts it for that operator's bonds
|
||||
if (isObj(u.diy) && typeof u.diy.charId === 'string') p.diy = u.diy;
|
||||
// 0.2.0 补位: a unit fighting as its stand-in says so (UnitInfo standInFor) — the popup draws that member as the stand-in
|
||||
if (typeof u.standInFor === 'string' && u.standInFor) p.standInFor = u.standInFor;
|
||||
return p;
|
||||
});
|
||||
return { board, hand: [], temp: [] };
|
||||
const out = { board: /** @type {any[]} */ ([]), hand: /** @type {any[]} */ ([]), temp: /** @type {any[]} */ ([]) };
|
||||
for (const u of [...(fromField || []), ...(fromBattle || [])]) {
|
||||
if (!isObj(u) || u.ownerId !== ownerId || u.kind !== 'op' || typeof u.defId !== 'string') continue;
|
||||
const p = Array.isArray(u.items) && u.items.length ? { kind: 'chess', id: u.defId, items: u.items.filter((x) => typeof x === 'string') } : { kind: 'chess', id: u.defId };
|
||||
// 0.2.0 自选编队: a DIY slot's unit names its operator (UnitInfo diy) — the popup counts it for that operator's bonds
|
||||
if (isObj(u.diy) && typeof u.diy.charId === 'string') p.diy = u.diy;
|
||||
// 0.2.0 补位: a unit fighting as its stand-in says so (UnitInfo standInFor) — the popup draws that member as the stand-in
|
||||
if (typeof u.standInFor === 'string' && u.standInFor) p.standInFor = u.standInFor;
|
||||
out[u.area === 'hand' ? 'hand' : u.area === 'temp' ? 'temp' : 'board'].push(p);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -225,7 +225,8 @@ export class MatchViews {
|
||||
return p ? { charId: p.charId, skillIndex: p.skillIndex, uniEquipId: p.uniEquipId } : undefined;
|
||||
}
|
||||
|
||||
/** UnitInfo of a player's board pieces on their (board) tiles — a prep scout's board, the boss partner's (bossMateView). */
|
||||
/** UnitInfo of a player's board pieces on their (board) tiles — a prep scout's board, the boss partner's (bossMateView).
|
||||
* `area: 'board'` (a bench unit says 'hand' / 'temp': prepFieldMeta). */
|
||||
_prepBoardUnits(ps) {
|
||||
const units = [];
|
||||
// the player's own view of the data (0.2.0 自选编队: its slotted DIY slots are its operators — player/diy.js)
|
||||
@@ -241,6 +242,7 @@ export class MatchViews {
|
||||
const lo = piece.kind === 'chess' && chess ? ps.loadoutFor(chess) : null;
|
||||
units.push({
|
||||
id: piece.uid, uid: piece.uid, kind: piece.kind === 'token' ? 'token' : 'op', side: 'ally', ownerId: ps.playerId, defId: piece.id,
|
||||
area: 'board',
|
||||
name: rec ? rec.name : piece.id, tier: rec && Number.isInteger(rec.tier) ? rec.tier : 1, golden: !!(rec && rec.isGolden),
|
||||
spine: assets.spine || (rec && rec.charId) || piece.id, avatar: assets.avatar || (rec && rec.charId) || piece.id,
|
||||
x: c, y: r, dir: pieceDir(piece), facing: pieceDir(piece) === 'LEFT' ? -1 : 1, maxHp: rec && rec.stats && Number.isFinite(rec.stats.maxHp) ? rec.stats.maxHp : 1,
|
||||
@@ -292,7 +294,11 @@ export class MatchViews {
|
||||
// (test/match/spectator.test.js). User playtest #2 item 1 (GitHub #44).
|
||||
// (a held chess the player fields as its stand-in is the stand-in there too — name, art, max HP — and carries
|
||||
// `standInFor`, like a board piece: the owner's recall of the official mode, 2026-10-06, the hand shows the stand-in)
|
||||
const benchUnit = (piece, i, y) => {
|
||||
// Each unit says where it waits (`area` 'hand' | 'temp'; a board unit 'board'): the bond popup of a watched player
|
||||
// counts it like the player's own (ui/watchBonds.js ownerBoard — a hand operator is owned and counts for 投资人 远见
|
||||
// 奇迹, a temp one counts for nothing; GitHub #385, PR #387). The tag survives the boss-half remap (_onBossHalf), the
|
||||
// row does not.
|
||||
const benchUnit = (piece, i, y, area) => {
|
||||
const rec = piece.kind === 'item' ? gd.item(piece.id) : piece.kind === 'token' ? gd.token(piece.id) : gd.chess(piece.id);
|
||||
const standIn = piece.kind === 'chess' && rec && ps.fieldsStandIn(rec) ? this.gd.standIn(rec.chessId) : null;
|
||||
const body = standIn || rec;
|
||||
@@ -300,7 +306,7 @@ export class MatchViews {
|
||||
const lo = piece.kind === 'chess' && rec ? ps.loadoutFor(rec) : null;
|
||||
units.push({
|
||||
id: piece.uid, uid: piece.uid, kind: piece.kind === 'token' ? 'token' : piece.kind === 'item' ? 'item' : 'op',
|
||||
side: 'ally', ownerId: ps.playerId, defId: piece.id,
|
||||
side: 'ally', ownerId: ps.playerId, defId: piece.id, area,
|
||||
name: body ? body.name : piece.id, tier: rec && Number.isInteger(rec.tier) ? rec.tier : 1, golden: !!(rec && rec.isGolden),
|
||||
spine: assets.spine || (body && body.charId) || piece.id, avatar: assets.avatar || (body && body.charId) || piece.id,
|
||||
x: i, y, maxHp: body && body.stats && Number.isFinite(body.stats.maxHp) ? body.stats.maxHp : 1,
|
||||
@@ -312,10 +318,10 @@ export class MatchViews {
|
||||
});
|
||||
};
|
||||
for (let i = 0; i < ps.hand.length; i++) {
|
||||
if (ps.hand[i]) benchUnit(ps.hand[i], i, GEO.HAND_ROW);
|
||||
if (ps.hand[i]) benchUnit(ps.hand[i], i, GEO.HAND_ROW, 'hand');
|
||||
}
|
||||
for (let i = 0; i < ps.temp.length; i++) {
|
||||
if (ps.temp[i]) benchUnit(ps.temp[i], GEO.TEMP_C0 + i, GEO.TEMP_ROW);
|
||||
if (ps.temp[i]) benchUnit(ps.temp[i], GEO.TEMP_C0 + i, GEO.TEMP_ROW, 'temp');
|
||||
}
|
||||
// `nextEnemies`: the scouted player's coming enemies — their preview pen shows on the scouting board too (research 09
|
||||
// §2.2 "Teammates"; render/app.js enterBattle({ prep: true, nextEnemies }))
|
||||
|
||||
@@ -119,17 +119,23 @@ export function registerMeta(registry) {
|
||||
// 画卷 — trap_copy_front_char: copy the operator in range (elite status included) with its equipment. A copied
|
||||
// normal item that completes a pair with an owned one merges at once and the golden stays in the hand (research 04
|
||||
// addendum "两个同名道具(无论是否被装备)会自动合并…并自动返回整备区"); the built-in equipped that golden on the copy.
|
||||
// A copy that completes a three-copy merge (GitHub #389, PR #390): the elite wears nothing — the promotion returns the
|
||||
// copies' equipment to the hand (PRTS 帮助 "在失去该干员(…合并等)…时自动卸除") — and the copied items stay in the hand
|
||||
// too (PRTS 画卷 备注 "获得的装备为未装备状态"), a copied normal item merging with the returned original.
|
||||
wrap(registry, 'chess_item_6_02_m', () => ({
|
||||
onArt(ctx, ev) {
|
||||
const target = (ev.targets || []).find((p) => p && p.kind === 'chess');
|
||||
if (!target) { ev.error = 'BAD_TARGET'; ev.detail = 'no operator in range'; return; }
|
||||
// snapshot before the gain: a gain that completes a merge consumes the target and empties `target.items`, and an
|
||||
// item merge detaches the original's copy from `target.items` while we iterate (the built-in's live loop then
|
||||
// skipped the next item)
|
||||
const itemIds = (target.items || []).map((it) => it.id);
|
||||
const copy = ctx.grantChess(target.id, { requirePool: false, source: 'item:chess_item_6_02_m' });
|
||||
if (!copy) { ev.error = 'HAND_FULL'; return; }
|
||||
// snapshot first: a merge detaches the original's copy of the item from `target.items` while we iterate
|
||||
// (the built-in's live loop then skipped the next item)
|
||||
for (const itemId of (target.items || []).map((it) => it.id)) {
|
||||
const promoted = copy.id !== target.id;
|
||||
for (const itemId of itemIds) {
|
||||
const got = ctx.grantItem(itemId, { source: 'item:chess_item_6_02_m' });
|
||||
const holder = got ? ctx.piece(copy.uid) : null;
|
||||
const holder = got && !promoted ? ctx.piece(copy.uid) : null;
|
||||
if (got && got.id === itemId && holder && holder.kind === 'chess') ctx.equipDirect(got.uid, holder.uid);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -67,7 +67,10 @@ export default {
|
||||
if (z.type === 'guard') {
|
||||
const inZone = (a) => Math.abs(a.tileR - z.r) <= 1 && Math.abs(a.tileC - z.c) <= 1;
|
||||
const allies = battle.alliesFor(unit).filter(inZone);
|
||||
for (const a of allies) battle.addBuff(a, { key: 'thorn2:bastion', duration: AURA_DUR, mods: mods({ defFlat: num(bb.def) }) });
|
||||
// one DEF buff per alchemy unit, so two units on one ally add up (+60 +60; PRTS 度算浪波 备注 「效果均可叠加」,
|
||||
// GitHub #388 — one shared key let the last unit's buff replace the other's)
|
||||
z.defKey ??= `thorn2:bastion:${unit.id}:${unit.mem.bastionSeq = (unit.mem.bastionSeq ?? 0) + 1}`;
|
||||
for (const a of allies) battle.addBuff(a, { key: z.defKey, duration: AURA_DUR, mods: mods({ defFlat: num(bb.def) }) });
|
||||
if (z.acc >= 1 - 1e-9) {
|
||||
z.acc -= 1;
|
||||
const regen = unit.s.atk * num(bb.hp_recovery_per_sec_ratio); // 生命回复速度 (see the header)
|
||||
|
||||
+5
-3
@@ -282,13 +282,14 @@ const statView = (x) => ({
|
||||
* runner's live battle stats (public/js/battle/runner.js unitStats). An ally with a range also carries `range`: the grid
|
||||
* (`[dRow, dCol]`, facing RIGHT) it attacks with now — a running skill's range, rangeExtend included, not a kit's
|
||||
* target-selection grid (the sim's `unit.liveRangeGrid`, Battle._refreshRange; community report E1 after 0.1.0: 烛煌
|
||||
* S3's 4-11 never reached the card).
|
||||
* @param {{ id?: number, uid?: number|null, defId?: string, hp?: number, alive?: boolean, base?: any, liveRangeGrid?: any } | null} u
|
||||
* S3's 4-11 never reached the card). `dir`: the unit's facing now (UP / RIGHT / DOWN / LEFT; UnitInfo.dir is the facing
|
||||
* at send time and the snapshot tuples carry none) — the detail card's range overlay rotates by it (GitHub PR #281).
|
||||
* @param {{ id?: number, uid?: number|null, defId?: string, hp?: number, alive?: boolean, base?: any, liveRangeGrid?: any, dir?: string } | null} u
|
||||
* @param {any} [s] aggregated stats (missing ⇒ the base)
|
||||
* @returns {{ id: number|null, uid: number|null, defId: string|null, hp: number, alive: boolean, maxHp: number, atk: number,
|
||||
* def: number, res: number, interval: number|null, blockCnt: number, moveSpeed: number,
|
||||
* base: { maxHp: number, atk: number, def: number, res: number, interval: number|null, blockCnt: number, moveSpeed: number },
|
||||
* range?: Array<[number, number]> }}
|
||||
* range?: Array<[number, number]>, dir?: string }}
|
||||
*/
|
||||
export function unitStatsEntry(u, s = null) {
|
||||
const base = u && u.base && typeof u.base === 'object' ? u.base : {};
|
||||
@@ -305,6 +306,7 @@ export function unitStatsEntry(u, s = null) {
|
||||
...statView(cur),
|
||||
base: statView(base),
|
||||
...(range ? { range } : {}),
|
||||
...(isDir(u?.dir) ? { dir: u.dir } : {}),
|
||||
// the enemy card greys a SILENCE-format line (折射) from this; absent flags ⇒ not silenced
|
||||
silenced: !!(cur.flags && cur.flags.silence),
|
||||
};
|
||||
|
||||
@@ -1031,6 +1031,36 @@ test('引星棘刺 S1 度算浪波: an alchemy unit on the lowest-HP ally: DEF +
|
||||
}
|
||||
});
|
||||
|
||||
test('引星棘刺 S1 度算浪波: two alchemy units on one ally add their DEF (PRTS 备注 「效果均可叠加」, GitHub #388)', () => {
|
||||
// two 引星棘刺 (a normal and an elite: +60 and +75) throw at the same lowest-HP ally; one shared buff key used to let the
|
||||
// last unit's DEF replace the other's (+75 instead of +135) while both heals already landed
|
||||
const [na, el] = pair('15');
|
||||
const h = run({
|
||||
defs: { chess: { t_low: ally('t_low', { stats: { def: 100 } }) } },
|
||||
units: [entry(na, 'skchr_thorn2_1', { row: 10, col: 3 }), entry(el, 'skchr_thorn2_1', { row: 11, col: 3 }), { chessId: 't_low', row: 10, col: 5 }],
|
||||
enemies: [],
|
||||
});
|
||||
const a = sel(h, na, 'skchr_thorn2_1'), b = sel(h, el, 'skchr_thorn2_1');
|
||||
const low = h.unit('t_low');
|
||||
h.step();
|
||||
low.hp = low.s.maxHp * 0.3;
|
||||
for (const u of [a, b]) u.skill.gainSp(1000);
|
||||
h.runUntil(() => [a, b].every((u) => (u.mem.zones || []).some((z) => z.type === 'guard')), 10);
|
||||
h.run(1.1);
|
||||
const [za, zb] = [a, b].map((u) => u.mem.zones.find((z) => z.type === 'guard'));
|
||||
assert.deepEqual([za.r, za.c, zb.r, zb.c], [10, 5, 10, 5], 'both on the lowest-HP ally');
|
||||
assert.equal(low.s.def, 100 + bbOf(a).def + bbOf(b).def, '+60 +75');
|
||||
assert.ok(low.hp > low.s.maxHp * 0.3, 'and they restore its HP');
|
||||
// one unit runs out: the other's DEF stays
|
||||
mute(h, a); mute(h, b);
|
||||
h.run(Math.max(0, za.dur - za.t) + 0.6);
|
||||
assert.ok(!a.mem.zones.includes(za) && b.mem.zones.includes(zb), 'the elite\'s unit (7 s) outlasts the normal one (6 s)');
|
||||
assert.equal(low.s.def, 100 + bbOf(b).def, 'its DEF stays alone');
|
||||
h.run(zb.dur);
|
||||
assert.equal(low.s.def, 100, 'no DEF bonus afterwards');
|
||||
done(h);
|
||||
});
|
||||
|
||||
test('引星棘刺 S1 度算浪波 (AUTO): fires as soon as its SP is full, no enemy needed (GitHub #124)', () => {
|
||||
for (const id of pair('15')) {
|
||||
const h = run({
|
||||
|
||||
+55
-55
@@ -2017,64 +2017,64 @@
|
||||
"round": 12,
|
||||
"seed": 3754088384,
|
||||
"waveId": "act1autochess_h05",
|
||||
"time": 219.47,
|
||||
"ticks": 6584,
|
||||
"time": 214.93,
|
||||
"ticks": 6448,
|
||||
"reason": "cleared",
|
||||
"killed": 35,
|
||||
"killed": 54,
|
||||
"total": 167,
|
||||
"unspawned": 0,
|
||||
"bossHpLeft": null,
|
||||
"rngDraws": 182,
|
||||
"rngDraws": 203,
|
||||
"errors": {"count":0,"kinds":[]},
|
||||
"players": {
|
||||
"p1": {
|
||||
"killed": 35,
|
||||
"killed": 54,
|
||||
"total": 167,
|
||||
"perfect": false,
|
||||
"leaks": 132,
|
||||
"leaks": 113,
|
||||
"leaksUncounted": 0,
|
||||
"coins": 2,
|
||||
"dmg": 366322,
|
||||
"dmg": 579384,
|
||||
"bossDmg": 0,
|
||||
"heal": 45493,
|
||||
"deaths": 31,
|
||||
"layerGains": {"indomShip":35}
|
||||
"heal": 69092,
|
||||
"deaths": 26,
|
||||
"layerGains": {"indomShip":75}
|
||||
}
|
||||
},
|
||||
"units": [
|
||||
["p1",1,"op","chess_char_5_11_a",0,null,0,0,7,3,18161,12816,11936,37,6],
|
||||
["p1",2,"op","chess_char_5_19_a",0,null,0,0,181,1,24090,10511,2980,9,1],
|
||||
["p1",3,"op","chess_char_5_13_a",0,null,0,0,182,4,69847,16642,1033,31,1],
|
||||
["p1",4,"op","chess_char_5_14_a",0,null,0,0,200,0,2553,6628,1432,9,2],
|
||||
["p1",5,"op","chess_char_5_06_b",0,"uniequip_003_etlchi",0,0,88,14,152980,19394,11582,36,8],
|
||||
["p1",6,"op","chess_char_5_17_a",0,null,0,0,198,0,16224,20093,192,30,7],
|
||||
["p1",7,"op","chess_char_5_12_a",1,null,0,0,3,3,14164,3395,1991,13,2],
|
||||
["p1",8,"op","chess_char_5_15_a",0,null,0,0,201,5,18715,1950,98,75,20],
|
||||
["p1",9,"op","chess_char_5_20_a",0,null,0,0,184,0,7091,3502,178,15,1],
|
||||
["p1",10,"op","chess_char_5_21_a",0,null,0,0,189,0,5581,7990,180,9,0],
|
||||
["p1",11,"op","chess_char_5_22_a",0,null,0,0,193,5,34367,5558,1385,49,2],
|
||||
["p1",12,"op","chess_char_5_23_a",0,null,1422,1,202,0,0,6488,11768,20,7],
|
||||
["p1",1,"op","chess_char_5_11_a",0,null,0,0,185,6,24682,19544,18577,52,11],
|
||||
["p1",2,"op","chess_char_5_19_a",0,null,3241,1,200,8,50200,14729,7170,23,3],
|
||||
["p1",3,"op","chess_char_5_13_a",0,null,681.15,1,202,6,179758,34866,5612,46,1],
|
||||
["p1",4,"op","chess_char_5_14_a",0,null,1331.1,1,203,0,4188,13059,3998,14,2],
|
||||
["p1",5,"op","chess_char_5_06_b",0,"uniequip_003_etlchi",2992,1,204,15,169257,19787,11958,43,10],
|
||||
["p1",6,"op","chess_char_5_17_a",0,null,723.48,1,193,1,20066,17784,192,37,9],
|
||||
["p1",7,"op","chess_char_5_12_a",1,null,1404,1,3,3,15355,4263,3154,15,3],
|
||||
["p1",8,"op","chess_char_5_15_a",0,null,769.65,1,4,7,30505,713,557,126,34],
|
||||
["p1",9,"op","chess_char_5_20_a",0,null,0,0,188,0,7281,3522,198,16,1],
|
||||
["p1",10,"op","chess_char_5_21_a",0,null,0,0,191,0,5581,7990,180,9,0],
|
||||
["p1",11,"op","chess_char_5_22_a",0,null,1237,1,5,8,69963,3136,3136,103,4],
|
||||
["p1",12,"op","chess_char_5_23_a",0,null,1422,1,205,0,0,8541,13622,21,7],
|
||||
["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null],
|
||||
["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null],
|
||||
["p1","~1","token","token_10015_dusk_drgn",null,null,1947.38,0,38,0,2547,787,737,8,null]
|
||||
],
|
||||
"enemies": {
|
||||
"enemy_1005_yokai_3": [1,0,1,5891,11900],
|
||||
"enemy_1009_lurker": [72,17,55,921,93670],
|
||||
"enemy_1005_yokai_3": [1,0,1,5415,17555],
|
||||
"enemy_1009_lurker": [72,19,53,995,136536],
|
||||
"enemy_10159_mntrjn": [1,0,1,0,25020],
|
||||
"enemy_1019_jshoot_2": [51,12,39,19173,94135],
|
||||
"enemy_1019_jshoot_2": [51,24,27,18021,164779],
|
||||
"enemy_1118_lidbox_2": [1,1,0,39,14774],
|
||||
"enemy_1196_msfyin": [1,1,0,0,3],
|
||||
"enemy_1252_lysytb_2": [1,0,1,338,11250],
|
||||
"enemy_1364_spnaxe_2": [1,0,1,5266,7966],
|
||||
"enemy_1404_msnip": [36,4,32,77659,149942],
|
||||
"enemy_1516_jakill": [1,0,1,6133,5143],
|
||||
"enemy_9011_acrefr": [1,0,1,336,2479]
|
||||
"enemy_1364_spnaxe_2": [1,0,1,4584,15996],
|
||||
"enemy_1404_msnip": [36,9,27,111370,227827],
|
||||
"enemy_1516_jakill": [1,0,1,7554,10944],
|
||||
"enemy_9011_acrefr": [1,0,1,408,6100]
|
||||
},
|
||||
"hooks": [1,202,6584,853,853,3205,3222,10125,66,199,57,57,0,18157,181,97,167,132,1,69,7,0,8],
|
||||
"events": {"atk":955,"bounty":1,"deploy":33,"die":67,"dmg":3218,"engage":12,"fx":68,"heal":56,"layer":7,"leak":132,"skill":114,"spawn":182,"status":191},
|
||||
"fx": {"aoe":2,"crit":4,"dodge":8,"dollEnd":3,"garrison":7,"healAoe":6,"hpShare":1,"palsy":1,"reborn":2,"revive":6,"sp":4,"substitute":3,"summon":1,"zone":20},
|
||||
"snaps": "0092f82c 0dd327ad 53a3d810 58ba1139 90f0fe1e 37f52c37 cd5a8c36 c2f3acff ca099967 25de291a 2f88cb0f 5966eb8a c429d9ee 2513f9c2 c0f223dd 17c04486 0f59775e 7a41457d 4c1669d7 078e9615 676431e7 b6538f95"
|
||||
"hooks": [1,205,6448,1165,1165,4391,4433,16853,80,194,85,83,0,27673,241,133,167,113,1,86,15,0,6],
|
||||
"events": {"atk":1276,"bounty":1,"deploy":36,"die":81,"dmg":4429,"engage":12,"fx":107,"heal":69,"layer":15,"leak":113,"skill":168,"spawn":182,"status":237},
|
||||
"fx": {"aoe":2,"crit":6,"dodge":6,"dollEnd":3,"garrison":15,"healAoe":11,"hpShare":1,"palsy":1,"reborn":2,"revive":11,"sp":5,"substitute":6,"summon":1,"swap":3,"zone":34},
|
||||
"snaps": "0092f82c 0dd327ad 53a3d810 58ba1139 90f0fe1e cb512e37 ceb2cf74 a613cd55 5ce6b6e5 d58de777 28df897a f1e4ae5f b4781ed7 3af8405e 2c626d36 ce72c9fa e19c7d08 6db15d3a b2ed2de3 dadece28 8a66c30e ce8ff9d7"
|
||||
},
|
||||
"roster-034": {
|
||||
"about": "pass 1 band 6: chess_char_5_08_b chess_char_5_11_b chess_char_5_19_b chess_char_5_13_b chess_char_5_14_b chess_char_5_07_b chess_char_5_17_b chess_char_5_10_b chess_char_5_12_b chess_char_5_15_b chess_char_5_20_b chess_char_5_21_b",
|
||||
@@ -2088,26 +2088,26 @@
|
||||
"time": 230,
|
||||
"ticks": 6900,
|
||||
"reason": "timeout",
|
||||
"killed": 86,
|
||||
"killed": 90,
|
||||
"total": 104,
|
||||
"unspawned": 0,
|
||||
"bossHpLeft": null,
|
||||
"rngDraws": 621,
|
||||
"rngDraws": 565,
|
||||
"errors": {"count":0,"kinds":[]},
|
||||
"players": {"p1":{"killed":86,"total":104,"perfect":false,"leaks":18,"leaksUncounted":0,"coins":8,"dmg":5013885,"bossDmg":0,"heal":163673,"deaths":1,"layerGains":{}}},
|
||||
"players": {"p1":{"killed":90,"total":104,"perfect":false,"leaks":14,"leaksUncounted":0,"coins":8,"dmg":5110704,"bossDmg":0,"heal":120360,"deaths":1,"layerGains":{}}},
|
||||
"units": [
|
||||
["p1",1,"op","chess_char_5_08_b",0,"uniequip_003_horn",3000,1,7,25,1690098,32737,709,96,86],
|
||||
["p1",2,"op","chess_char_5_11_b",0,"uniequip_003_demkni",4176.51,1,10,0,29905,12253,69243,182,38],
|
||||
["p1",3,"op","chess_char_5_19_b",0,"none",3682,1,11,3,184370,9411,9828,136,5],
|
||||
["p1",4,"op","chess_char_5_13_b",0,"uniequip_003_ghost2",3050.48,1,13,3,99196,55309,54515,210,5],
|
||||
["p1",5,"op","chess_char_5_14_b",0,"none",3261,1,15,0,16840,26781,26785,217,15],
|
||||
["p1",6,"op","chess_char_5_07_b",0,"uniequip_003_surtr",1804.11,1,8,6,337400,27088,605,137,60],
|
||||
["p1",7,"op","chess_char_5_17_b",0,"uniequip_003_f12yin",214.49,1,97,4,324747,5435,193,119,54],
|
||||
["p1",8,"op","chess_char_5_10_b",0,"uniequip_003_lisa",1470,1,4,4,161523,0,0,118,6],
|
||||
["p1",9,"op","chess_char_5_12_b",1,"uniequip_003_dusk",1667,1,5,33,1577956,0,0,89,5],
|
||||
["p1",10,"op","chess_char_5_15_b",0,"none",1907.5,1,12,0,47882,0,0,166,89],
|
||||
["p1",11,"op","chess_char_5_20_b",0,"uniequip_003_aglina",1352,1,14,3,205107,1794,1794,138,7],
|
||||
["p1",12,"op","chess_char_5_21_b",0,"none",3330.35,1,6,5,338859,0,0,67,3],
|
||||
["p1",1,"op","chess_char_5_08_b",0,"uniequip_003_horn",1512.22,1,7,28,1726631,34150,947,96,84],
|
||||
["p1",2,"op","chess_char_5_11_b",0,"uniequip_003_demkni",3477.62,1,10,0,39384,11346,66820,184,36],
|
||||
["p1",3,"op","chess_char_5_19_b",0,"none",3682,1,11,4,178330,6313,6313,129,5],
|
||||
["p1",4,"op","chess_char_5_13_b",0,"uniequip_003_ghost2",3448.92,1,13,3,109842,29514,29300,210,5],
|
||||
["p1",5,"op","chess_char_5_14_b",0,"none",3261,1,15,0,16840,13747,13949,217,20],
|
||||
["p1",6,"op","chess_char_5_07_b",0,"uniequip_003_surtr",2680,1,8,7,313834,25889,788,121,56],
|
||||
["p1",7,"op","chess_char_5_17_b",0,"uniequip_003_f12yin",538.04,1,97,6,252445,5368,450,89,42],
|
||||
["p1",8,"op","chess_char_5_10_b",0,"uniequip_003_lisa",1470,1,4,5,160605,0,0,120,6],
|
||||
["p1",9,"op","chess_char_5_12_b",1,"uniequip_003_dusk",1667,1,5,32,1794727,0,0,91,6],
|
||||
["p1",10,"op","chess_char_5_15_b",0,"none",1907.5,1,12,1,57355,0,0,166,82],
|
||||
["p1",11,"op","chess_char_5_20_b",0,"uniequip_003_aglina",1352,1,14,2,213658,1794,1794,138,6],
|
||||
["p1",12,"op","chess_char_5_21_b",0,"none",3330.35,1,6,2,247052,0,0,57,3],
|
||||
["-","~1","device","trap_1105_accrate",null,null,100,0,1,0,0,0,0,0,null],
|
||||
["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null],
|
||||
["-","~3","device","trap_1105_accrate",null,null,100,1,3,0,0,0,0,0,null]
|
||||
@@ -2119,16 +2119,16 @@
|
||||
"enemy_1165_duhond_2": [1,1,0,114,24767],
|
||||
"enemy_1204_msfhu": [1,1,0,0,12],
|
||||
"enemy_1288_duskls": [1,1,0,1594,30964],
|
||||
"enemy_1381_winman": [27,23,4,6312,906059],
|
||||
"enemy_1381_winman_2": [39,29,9,26620,1665434],
|
||||
"enemy_1387_winshd": [30,26,2,114701,2095022],
|
||||
"enemy_1381_winman": [27,25,2,4083,938899],
|
||||
"enemy_1381_winman_2": [39,31,7,24737,1719910],
|
||||
"enemy_1387_winshd": [30,26,2,76127,2104525],
|
||||
"enemy_1425_lrcmra_2": [1,1,0,11329,154793],
|
||||
"enemy_2009_csaudc": [1,1,0,2690,49534]
|
||||
},
|
||||
"hooks": [1,120,6900,2623,2623,4011,4006,10250,87,103,373,370,0,31826,6617,100,104,15,1,88,0,0,14],
|
||||
"events": {"atk":3112,"bounty":4,"deploy":13,"die":88,"dmg":3995,"engage":12,"fx":345,"heal":38,"leak":15,"skill":743,"spawn":119,"status":810},
|
||||
"fx": {"aoe":15,"crit":21,"dodge":14,"ember":1,"healAoe":38,"hpShare":5,"sp":140,"zone":111},
|
||||
"snaps": "de3bd2e2 efefe0a2 43519431 531ae1eb aa084bc8 507c87eb 0f5120b4 9a46e524 080c06c4 5b040f54 0c7a6be0 c2043b05 ae95d0ab e2cd74c3 5ca51241 aaf4ddc9 9f2d19ce dd8d93e5 7dbbf487 3f8ce0c6 325e24cf 4a98b418 a0f8abef"
|
||||
"hooks": [1,120,6900,2558,2558,3995,3996,8433,91,103,351,349,0,31469,6479,104,104,11,1,92,0,0,8],
|
||||
"events": {"atk":3070,"bounty":4,"deploy":13,"die":92,"dmg":3985,"engage":12,"fx":326,"heal":36,"leak":11,"skill":700,"spawn":119,"status":807},
|
||||
"fx": {"aoe":20,"crit":14,"dodge":8,"ember":1,"healAoe":36,"hpShare":5,"sp":139,"zone":103},
|
||||
"snaps": "de3bd2e2 23f28d96 7a63c0f2 28a9a6fe e6984bca 8eae402d 2b24991d c8788f23 5a001b21 0fed151a 87c013c7 b38057af 123bb336 e5b070fc 7fac01fd fb1d6e4d 2f2bc32b d3c63947 a34e6643 97d460d1 3c1349d8 783047e8 2de5f6f2"
|
||||
},
|
||||
"roster-035": {
|
||||
"about": "pass 1 band 6: chess_char_6_03_a chess_char_6_06_a chess_char_6_07_a chess_char_5_22_b chess_char_5_23_b chess_char_6_01_a chess_char_6_02_a chess_char_6_04_a chess_char_6_05_a chess_char_6_08_a chess_char_6_09_a chess_char_6_11_a token_10017_skadi2_dedant token_10030_mlyss_wtrman",
|
||||
|
||||
@@ -56,6 +56,7 @@ test('a move of the partner reaches the player\'s m.private and a scout of eithe
|
||||
const h = bossPrep(4, 5102);
|
||||
const m = h.m;
|
||||
const b = place(h, 'p_1', chessOfTier(1)[1]);
|
||||
const held = give(m, h.ps('p_0'), chessOfTier(1)[2]); // into p_0's hand
|
||||
m.flush(true);
|
||||
// p_3 (eliminated) scouts p_0: the scout shows p_0's half and p_1's board on the other half
|
||||
h.ps('p_3').eliminate(13);
|
||||
@@ -63,6 +64,9 @@ test('a move of the partner reaches the player\'s m.private and a scout of eithe
|
||||
let scout = h.lastTo('p_3', 'm.field');
|
||||
assert.deepEqual([scout.kind, scout.side, scout.mate], ['boss', 'L', { playerId: 'p_1', side: 'R' }]);
|
||||
assert.ok(scout.units.some((u) => u.uid === b.piece.uid && u.x >= 10), 'the partner\'s piece on the right half');
|
||||
// the area tags survive the boss-half remap (the rows do not): the bond popup tells the bench from the board (GitHub #385)
|
||||
assert.equal(scout.units.find((u) => u.uid === b.piece.uid).area, 'board');
|
||||
assert.equal(scout.units.find((u) => u.uid === held.uid).area, 'hand', 'a held operator stays tagged as held');
|
||||
// p_1 moves its piece: p_0's m.private and p_3's scout of p_0 follow
|
||||
const ps1 = h.ps('p_1');
|
||||
const to = legalTileFor(m, ps1, b.piece.id, new Set([`${b.tile[0]},${b.tile[1]}`]));
|
||||
|
||||
@@ -37,6 +37,7 @@ test('#44 prep scout: held pieces are units on the hand row; deploying moves the
|
||||
const u = first.units.find((x) => x.uid === piece.uid);
|
||||
assert.ok(u, `${pid}: the held operator is a unit of the scout`);
|
||||
assert.deepEqual([u.x, u.y], [0, GEO.HAND_ROW], `${pid}: standing on the first hand slot`);
|
||||
assert.equal(u.area, 'hand', 'tagged with its area: the bond popup counts it as held, not in play (GitHub #385)');
|
||||
assert.equal(u.kind, 'op');
|
||||
assert.equal(u.items, undefined, 'no items equipped yet');
|
||||
}
|
||||
@@ -48,6 +49,7 @@ test('#44 prep scout: held pieces are units on the hand row; deploying moves the
|
||||
const last = h.lastTo('p_0', 'm.field');
|
||||
const u = last.units.find((x) => x.uid === piece.uid);
|
||||
assert.deepEqual([u.x, u.y], [c, r], 'deployed onto the watched board');
|
||||
assert.equal(u.area, 'board');
|
||||
assert.equal(last.units.some((x) => x.uid === piece.uid && x.y === GEO.HAND_ROW), false, 'and gone from the hand row');
|
||||
m.dispose();
|
||||
});
|
||||
@@ -74,6 +76,7 @@ test('#44 prep scout: a held item is a kind-item unit on the hand row; equipped
|
||||
assert.ok(iu, 'the held item is a unit of the scout');
|
||||
assert.equal(iu.kind, 'item');
|
||||
assert.equal(iu.y, GEO.HAND_ROW);
|
||||
assert.equal(iu.area, 'hand');
|
||||
// equip the item onto the operator (still in the hand): the operator's unit carries the item id
|
||||
assert.deepEqual(m.handle('p_1', { t: 'g.equip', itemUid: item.uid, targetUid: op.uid }), { ok: true });
|
||||
const after = h.lastTo('p_0', 'm.field');
|
||||
@@ -109,7 +112,10 @@ test("PR #129 review: moving a hand piece to another slot is a scout change (x f
|
||||
const first = h.lastTo('p_0', 'm.field');
|
||||
const tu = first.units.find((x) => x.uid === c.uid);
|
||||
assert.deepEqual([tu.x, tu.y], [GEO.TEMP_C0, GEO.TEMP_ROW], 'the temp piece scouts on the temp row (first slot, col 4)');
|
||||
assert.equal(first.units.find((x) => x.uid === a.uid).x, 0);
|
||||
assert.equal(tu.area, 'temp');
|
||||
const hu = first.units.find((x) => x.uid === a.uid);
|
||||
assert.equal(hu.x, 0);
|
||||
assert.equal(hu.area, 'hand');
|
||||
|
||||
// a hand piece moved to another slot keeps uid and id — the slot must be in the signature for the push to fire
|
||||
assert.deepEqual(m.handle('p_1', { t: 'g.move', uid: a.uid, to: { area: 'hand', idx: 3 } }), { ok: true });
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
// A Picture Scroll (画卷) gain can consume its own target in an immediate three-copy promotion (GitHub #389, PR #390).
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { makeMatch, legalTileFor } from './harness.js';
|
||||
|
||||
const OP = 'chess_char_1_06_a'; // 刺玫, the operator of the report
|
||||
const SCROLL = 'chess_item_6_02_m';
|
||||
const HAMMER = 'chess_item_1_01_e_a';
|
||||
const SHIELD = 'chess_item_1_02_e_a';
|
||||
const FILLER = 'chess_item_6_09_e_a'; // non-mergeable
|
||||
const OK = { ok: true };
|
||||
|
||||
function prep(t, { copies = 2, golden = false, items = [HAMMER] } = {}) {
|
||||
const h = makeMatch({ mode: 'solo', difficulty: 'FUNNY', seed: 8 }).start();
|
||||
t.after(() => h.m.dispose());
|
||||
h.toPrep(1, { band: 'band_dusk' });
|
||||
const m = h.m, ps = h.ps('p_0');
|
||||
assert.equal(m.gd.mergeCount(OP), 3);
|
||||
const id = golden ? m.gd.goldenIdOf(OP) : OP;
|
||||
const target = ps.acquireChess(id);
|
||||
const [row, col] = legalTileFor(m, ps, id);
|
||||
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: target.uid, to: { area: 'board', row, col }, dir: 'RIGHT' }), OK);
|
||||
for (let i = 1; i < copies; i++) ps.acquireChess(id);
|
||||
for (const itemId of items) {
|
||||
const item = ps.acquireItem(itemId);
|
||||
assert.deepEqual(m.handle('p_0', { t: 'g.equip', itemUid: item.uid, targetUid: target.uid }), OK);
|
||||
}
|
||||
const art = ps.hand.find((p) => p?.id === SCROLL);
|
||||
assert.ok(art, 'Dusk grants the scroll in round 1');
|
||||
h.invariants();
|
||||
const cast = () => m.handle('p_0', { t: 'g.art', itemUid: art.uid, row, col, dir: 'RIGHT' });
|
||||
return { h, m, ps, target, art, row, col, cast };
|
||||
}
|
||||
|
||||
function itemIds(ps) {
|
||||
return [...ps.hand, ...ps.temp, ...ps.board.values()].filter(Boolean)
|
||||
.flatMap((p) => p.kind === 'item' ? [p.id] : (p.items || []).map((it) => it.id)).sort();
|
||||
}
|
||||
|
||||
test('Picture Scroll: a third normal copy preserves the target equipment through promotion', (t) => {
|
||||
const { h, m, ps, target, art, row, col, cast } = prep(t);
|
||||
const funds = ps.funds;
|
||||
assert.deepEqual(cast(), OK);
|
||||
const elite = ps.board.get(`${row},${col}`);
|
||||
assert.equal(elite.id, m.gd.goldenIdOf(OP));
|
||||
assert.equal(elite.dir, 'RIGHT');
|
||||
assert.equal(ps.find(target.uid), null, 'the original target was consumed');
|
||||
assert.equal(ps.find(art.uid), null, 'the scroll was consumed');
|
||||
assert.deepEqual(itemIds(ps), [m.gd.item(HAMMER).goldenId]);
|
||||
assert.ok(ps.hand.some((p) => p?.id === m.gd.item(HAMMER).goldenId), 'the original and copied hammer merge into an advanced hammer in hand');
|
||||
assert.deepEqual(elite.items, []);
|
||||
assert.equal(ps.stats.merges, 1);
|
||||
assert.equal(ps.stats.itemMerges, 1);
|
||||
assert.equal(ps.funds, funds);
|
||||
h.invariants();
|
||||
});
|
||||
|
||||
for (const advanced of [false, true]) {
|
||||
test(`Picture Scroll: promotion copies both ${advanced ? 'advanced' : 'normal'} equipped items, none of them worn by the elite`, (t) => {
|
||||
const items = [HAMMER, SHIELD].map((id) => advanced ? id.replace(/_a$/, '_b') : id);
|
||||
const { h, m, ps, row, col, cast } = prep(t, { items });
|
||||
assert.deepEqual(cast(), OK);
|
||||
const elite = ps.board.get(`${row},${col}`);
|
||||
const upgraded = items.map((id) => m.gd.item(id).goldenId || id).sort();
|
||||
// advanced items do not merge: the returned originals and the copies all wait in the hand ("获得的装备为未装备状态")
|
||||
assert.deepEqual(itemIds(ps), advanced ? [...items, ...items].sort() : upgraded);
|
||||
assert.deepEqual(elite.items, [], 'the promotion unequips, and the copies are not put back on the elite');
|
||||
assert.equal([...ps.hand, ...ps.temp].filter((p) => p?.kind === 'item').length, advanced ? 4 : 2);
|
||||
h.invariants();
|
||||
});
|
||||
}
|
||||
|
||||
for (const golden of [false, true]) {
|
||||
test(`Picture Scroll: one ${golden ? 'elite' : 'normal'} target still copies its equipment without promotion`, (t) => {
|
||||
const { h, m, ps, target, cast } = prep(t, { copies: 1, golden });
|
||||
assert.deepEqual(cast(), OK);
|
||||
assert.equal(ps.find(target.uid)?.piece, target);
|
||||
assert.equal(ps.allChess().length, 2);
|
||||
assert.equal(ps.allChess().filter((p) => p.id === target.id).length, 2);
|
||||
assert.deepEqual(itemIds(ps), [m.gd.item(HAMMER).goldenId]);
|
||||
assert.equal(ps.stats.merges, 0);
|
||||
h.invariants();
|
||||
});
|
||||
}
|
||||
|
||||
test('Picture Scroll: a third copy without equipment still promotes normally', (t) => {
|
||||
const { h, m, ps, row, col, cast } = prep(t, { items: [] });
|
||||
assert.deepEqual(cast(), OK);
|
||||
assert.equal(ps.board.get(`${row},${col}`).id, m.gd.goldenIdOf(OP));
|
||||
assert.deepEqual(itemIds(ps), []);
|
||||
h.invariants();
|
||||
});
|
||||
|
||||
test('Picture Scroll: a promotion at full hand and temp still copies the hammer', (t) => {
|
||||
const { h, m, ps, cast } = prep(t);
|
||||
while (ps.hand.some((p) => p == null) || ps.temp.some((p) => p == null)) assert.ok(ps.acquireItem(FILLER));
|
||||
h.invariants();
|
||||
assert.deepEqual(cast(), OK);
|
||||
assert.deepEqual(itemIds(ps).filter((id) => id !== FILLER), [m.gd.item(HAMMER).goldenId]);
|
||||
assert.equal(itemIds(ps).filter((id) => id === FILLER).length, 13);
|
||||
h.invariants();
|
||||
});
|
||||
|
||||
test('Picture Scroll: a full hand and temp without promotion keeps the refused scroll and target equipment', (t) => {
|
||||
const { h, ps, target, art, cast } = prep(t, { copies: 1 });
|
||||
while (ps.hand.some((p) => p == null) || ps.temp.some((p) => p == null)) assert.ok(ps.acquireItem(FILLER));
|
||||
const before = ps.privateView();
|
||||
assert.equal(cast().error, 'HAND_FULL');
|
||||
assert.ok(ps.find(art.uid));
|
||||
assert.deepEqual(target.items.map((p) => p.id), [HAMMER]);
|
||||
assert.deepEqual(ps.privateView(), before);
|
||||
h.invariants();
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
// test/render/fxchill.test.js — the 'chill' fx of coldWind (render/fx/simfx.js + zones.js snowfall), PR #386 by
|
||||
// @leiming2333: 谢拉格's 盟约寒风 blows a blizzard UP the field (the official direction, the owner's confirmation of
|
||||
// 2026-10-08) with a stronger cold flash; an operator's own coldWind (灵知 S3, 麦哲伦 S1's pulse every 3 s — emitted with
|
||||
// the unit's `id`) keeps the light downward flurry and the softer flash. Against the headless fake PIXI
|
||||
// (test/render/fakepixi.js), like fxchen3wave.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';
|
||||
|
||||
let fake, FX;
|
||||
before(async () => {
|
||||
fake = installFakePixi();
|
||||
FX = await import('../../public/js/render/fx.js');
|
||||
});
|
||||
after(() => fake.restore());
|
||||
|
||||
const W = 1600, H = 900;
|
||||
const cam = presetCamera('normal', { width: W, height: H });
|
||||
|
||||
function makeFx(quality = 'high') {
|
||||
const P = fake.P;
|
||||
const ctx = fakeViewCtx(P);
|
||||
const unit = { id: 7, x: 5, y: 10, z: 0, hover: 0, _headTiles: 1.2, alive: true, destroyed: false, info: { defId: 'x' }, onHit() {} };
|
||||
return new FX.FxSystem({
|
||||
P, layers: ctx.layers, cam: () => cam, heightAt: () => 0, settings: { quality, damageNumbers: true },
|
||||
timeScale: () => 1, loadLevel: () => 0, subProfOf: () => null, view: (id) => (id === unit.id ? unit : null),
|
||||
screenSize: () => ({ width: W, height: H }), fieldTop: () => 120,
|
||||
});
|
||||
}
|
||||
const live = (fx) => fx.parts.filter((p) => p.sp && p.sp.visible !== false);
|
||||
|
||||
describe('coldWind: 谢拉格\'s 盟约寒风 blows up the field, an operator\'s own cold keeps the light flurry', () => {
|
||||
test('the bond\'s gust (the device emits no anchor unit): 48 flakes and 10 streaks rising from the bottom, the stronger flash', () => {
|
||||
const fx = makeFx();
|
||||
// server/sim/content/devices.js startColdWind: the field centre, the player, the cold stacks, the cold duration
|
||||
fx.simFx('coldWind', 8, 9, { x: 8, y: 9, playerId: 'p1', n: 3, duration: 5 });
|
||||
const ps = live(fx);
|
||||
assert.equal(ps.length, 58, '48 flakes + 10 streaks at quality high');
|
||||
assert.ok(ps.every((p) => p.vy < 0), 'every particle rises (screen y grows downward)');
|
||||
assert.ok(ps.every((p) => p.vx > 0), 'on a crosswind');
|
||||
assert.ok(ps.every((p) => p.y >= H * 0.55 - 1e-9), 'lifted off the lower part of the screen (some start below it)');
|
||||
assert.equal(ps.filter((p) => p.sp.texture === fx.tex.streak).length, 10, 'gust streaks');
|
||||
assert.deepEqual([fx.tintA, fx.tintDur], [0.45, 1]);
|
||||
const low = makeFx('low');
|
||||
low.simFx('coldWind', 8, 9, { x: 8, y: 9, playerId: 'p1', n: 3, duration: 5 });
|
||||
assert.equal(live(low).length, 26, 'quality low: 22 flakes + 4 streaks');
|
||||
});
|
||||
|
||||
test('an operator\'s own coldWind (灵知 S3, 麦哲伦\'s pulse: `id` = the unit): the light flurry drifting down, the softer flash', () => {
|
||||
const fx = makeFx();
|
||||
fx.simFx('coldWind', 5, 10, { x: 5, y: 10, id: 7, n: 2 });
|
||||
const ps = live(fx);
|
||||
assert.equal(ps.length, 26);
|
||||
assert.ok(ps.every((p) => p.vy > 0), 'falling as before');
|
||||
assert.equal(ps.filter((p) => p.sp.texture === fx.tex.streak).length, 0, 'no gust streaks');
|
||||
assert.deepEqual([fx.tintA, fx.tintDur], [0.35, 0.8]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
// The facing wheel's keyboard in the mock match (headless Chrome, the DOM field — no art / WebGL needed): GitHub #394 /
|
||||
// PR #395 (the drag / Tab flow of this file is the PR's, by @Xinglan233). Enter follows the focus while the wheel is
|
||||
// open — the wheel takes the focus when it opens, so arrows + Enter confirm (also with a HUD button focused before the
|
||||
// drop); Tab reaches ✕ first and Enter on it cancels; Enter on any other button (准备, 设置 …) does nothing; Esc cancels.
|
||||
// Unit side: test/ui/feedback5-hotkeys.test.js (facingEnter).
|
||||
// SP_E2E=1 CHROME_PATH=/path/to/chrome node --test test/ui/facing.e2e.test.js
|
||||
import { test, describe, before, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { existsSync } from 'node:fs';
|
||||
|
||||
const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
|
||||
const ENABLED = process.env.SP_E2E === '1' && existsSync(CHROME);
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
describe('facing wheel keyboard (mock match, headless Chrome)', { skip: !ENABLED && 'set SP_E2E=1 (and have Chrome) to run' }, () => {
|
||||
let srv, browser;
|
||||
before(async () => {
|
||||
const { startServer } = await import('../../server/index.js');
|
||||
const puppeteer = (await import('puppeteer-core')).default;
|
||||
srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
|
||||
browser = await puppeteer.launch({ executablePath: CHROME, headless: true, args: ['--no-sandbox', '--no-proxy-server'] });
|
||||
});
|
||||
after(async () => { await browser?.close(); await srv?.close(); });
|
||||
|
||||
async function setup(t) {
|
||||
const page = await browser.newPage();
|
||||
t.after(() => page.close());
|
||||
await page.setViewport({ width: 1920, height: 1080 });
|
||||
const errors = [];
|
||||
page.on('pageerror', (e) => errors.push(e.message));
|
||||
await page.goto(`http://127.0.0.1:${srv.port}/dev/game-mock.html?shot=1&render=fallback&phase=PREP`, { waitUntil: 'networkidle0' });
|
||||
await page.waitForSelector('.ff-piece');
|
||||
const state = () => page.evaluate(() => JSON.parse(JSON.stringify(globalThis.__MOCK__.S().priv)));
|
||||
const moves = () => page.evaluate(() => globalThis.__MOCK__.S().requests.filter(([type]) => type === 'g.move'));
|
||||
const sent = () => page.evaluate(() => globalThis.__MOCK__.S().requests.map(([type]) => type));
|
||||
const initial = await state();
|
||||
// room under the deploy cap (sell a board operator through the underframe), then a free legal tile
|
||||
const sell = initial.board.find((p) => p.kind === 'chess' && !p.golden);
|
||||
await page.click(`.ff-piece[data-uid="${sell.uid}"]`);
|
||||
await page.waitForSelector('.uframe__btn--sell');
|
||||
await page.click('.uframe__btn--sell');
|
||||
await page.waitForFunction((uid) => !globalThis.__MOCK__.S().priv.board.some((p) => p.uid === uid), {}, sell.uid);
|
||||
const ready = await state();
|
||||
const piece = ready.hand.find((p) => p?.kind === 'chess');
|
||||
const tile = [[9, 9], [9, 8], [12, 6], [11, 7], [10, 7], [9, 7]].find(([r, c]) => !ready.board.some((p) => p.row === r && p.col === c));
|
||||
assert.ok(piece && tile, 'an operator and a free legal tile exist');
|
||||
const center = (sel) => page.$eval(sel, (el) => { const r = el.getBoundingClientRect(); return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; });
|
||||
const open = async () => {
|
||||
const from = await center(`.ff-piece[data-uid="${piece.uid}"]`);
|
||||
const to = await center(`.ff-tile[data-row="${tile[0]}"][data-col="${tile[1]}"]`);
|
||||
await page.mouse.move(from.x, from.y); await page.mouse.down();
|
||||
await page.mouse.move(from.x + 20, from.y, { steps: 4 }); // a drag also when the piece re-orients on its own tile
|
||||
await page.mouse.move(to.x, to.y, { steps: 12 }); await sleep(80); await page.mouse.up();
|
||||
await page.waitForSelector('.fwheel__dia');
|
||||
await sleep(200);
|
||||
};
|
||||
const preview = async (key) => {
|
||||
await page.keyboard.press(key);
|
||||
await page.waitForSelector(`.fwheel.is-${key.slice(5).toLowerCase()}`);
|
||||
};
|
||||
const active = () => page.evaluate(() => {
|
||||
const a = document.activeElement;
|
||||
return { wheel: !!a?.matches('.fwheel'), cancel: !!a?.closest('.fwheel__cancel'), button: a?.tagName === 'BUTTON' && !a.closest('.fwheel'), cls: a?.className || a?.tagName };
|
||||
});
|
||||
const tabUntil = async (want) => {
|
||||
for (let i = 0; i < 80; i++) {
|
||||
if (want(await active())) return;
|
||||
await page.keyboard.press('Tab');
|
||||
}
|
||||
assert.fail('Tab never reached the wanted element');
|
||||
};
|
||||
const isOpen = () => page.evaluate(() => !!document.querySelector('.fwheel'));
|
||||
const closed = async () => { await page.waitForFunction(() => !document.querySelector('.fwheel')); await sleep(150); };
|
||||
return { page, state, moves, sent, errors, ready, piece, tile, open, preview, active, tabUntil, isOpen, closed };
|
||||
}
|
||||
|
||||
test('the wheel takes the focus: arrows + Enter confirm once in all four directions', async (t) => {
|
||||
const h = await setup(t);
|
||||
for (const dir of ['UP', 'RIGHT', 'DOWN', 'LEFT']) {
|
||||
await h.open();
|
||||
assert.equal((await h.active()).wheel, true, 'the wheel has the focus when it opens');
|
||||
await h.page.keyboard.press('Enter');
|
||||
await sleep(150);
|
||||
assert.equal(await h.isOpen(), true, 'Enter with no direction previewed does nothing');
|
||||
await h.preview(`Arrow${dir[0] + dir.slice(1).toLowerCase()}`);
|
||||
const count = (await h.moves()).length;
|
||||
await h.page.keyboard.press('Enter');
|
||||
await h.closed();
|
||||
assert.equal((await h.moves()).length, count + 1, 'one intent per confirmation');
|
||||
assert.deepEqual((await h.moves()).at(-1)[1].dir, dir);
|
||||
const board = (await h.state()).board.filter((p) => p.uid === h.piece.uid);
|
||||
assert.deepEqual([board.length, board[0].row, board[0].col, board[0].dir], [1, ...h.tile, dir]);
|
||||
}
|
||||
assert.deepEqual(h.errors, []);
|
||||
});
|
||||
|
||||
test('Tab reaches ✕ first and Enter on it cancels; Esc cancels; Space on ✕ stays swallowed', async (t) => {
|
||||
const h = await setup(t);
|
||||
await h.open();
|
||||
await h.preview('ArrowRight');
|
||||
await h.page.keyboard.press('Tab');
|
||||
assert.equal((await h.active()).cancel, true, 'the first Tab from the wheel lands on ✕');
|
||||
await h.page.keyboard.press('Space');
|
||||
await sleep(150);
|
||||
assert.equal(await h.isOpen(), true, 'Space does not activate the focused ✕ (no ready / shop keys while choosing)');
|
||||
await h.page.keyboard.press('Enter');
|
||||
await h.closed();
|
||||
assert.deepEqual(await h.moves(), [], 'cancelling sends no deployment');
|
||||
assert.deepEqual(await h.state(), h.ready, 'the operator stays where it was');
|
||||
await h.open();
|
||||
await h.preview('ArrowLeft');
|
||||
await h.page.keyboard.press('Escape');
|
||||
await h.closed();
|
||||
assert.deepEqual(await h.moves(), []);
|
||||
assert.deepEqual(h.errors, []);
|
||||
});
|
||||
|
||||
test('Enter on another focused button (准备, 设置 …) does nothing while the wheel is open', async (t) => {
|
||||
const h = await setup(t);
|
||||
await h.open();
|
||||
await h.preview('ArrowRight');
|
||||
const before = await h.sent();
|
||||
// walk the focus past ✕ to the HUD behind the wheel: every button reached gets an Enter
|
||||
const seen = new Set();
|
||||
for (let i = 0; i < 80 && seen.size < 6; i++) {
|
||||
await h.page.keyboard.press('Tab');
|
||||
const a = await h.active();
|
||||
if (!a.button || seen.has(a.cls)) continue;
|
||||
seen.add(a.cls);
|
||||
await h.page.keyboard.press('Enter');
|
||||
await sleep(120);
|
||||
assert.equal(await h.isOpen(), true, `Enter on ${a.cls} keeps the wheel open`);
|
||||
assert.equal(await h.page.evaluate(() => !!document.querySelector('.modal')), false, `Enter on ${a.cls} opens nothing`);
|
||||
}
|
||||
assert.ok(seen.size >= 2, `Tab reached HUD buttons behind the wheel (${[...seen].join(' | ')})`);
|
||||
assert.ok([...seen].some((c) => /readybtn|gm__gear/.test(c)), '准备 or 设置 among them');
|
||||
assert.deepEqual(await h.sent(), before, 'no intent of any kind (no g.move, no ready, no shop)');
|
||||
assert.equal((await h.state()).ready, h.ready.ready, 'still not ready');
|
||||
await h.page.keyboard.press('Escape');
|
||||
await h.closed();
|
||||
assert.deepEqual(await h.moves(), []);
|
||||
assert.deepEqual(h.errors, []);
|
||||
});
|
||||
|
||||
test('a HUD button focused before the drop does not swallow the confirming Enter', async (t) => {
|
||||
const h = await setup(t);
|
||||
await h.page.focus('.gm__gear');
|
||||
await h.open();
|
||||
assert.equal((await h.active()).wheel, true);
|
||||
await h.preview('ArrowDown');
|
||||
await h.page.keyboard.press('Enter');
|
||||
await h.closed();
|
||||
assert.equal((await h.moves()).length, 1);
|
||||
assert.equal(await h.page.evaluate(() => !!document.querySelector('.modal')), false, 'the settings did not open');
|
||||
assert.deepEqual(h.errors, []);
|
||||
});
|
||||
});
|
||||
@@ -11,7 +11,7 @@ import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
HOTKEY_ACTIONS, DEFAULT_HOTKEYS, isBindableCode, hotkeyLabel, sanitizeHotkeys, rebindHotkey, isDefaultHotkeys, hotkeyOf,
|
||||
actionForKey, captureHotkey, facingSwallows, shortcutFor, shortcutBlocked, sanitizeSettings, DEFAULT_SETTINGS,
|
||||
actionForKey, captureHotkey, facingSwallows, facingEnter, shortcutFor, shortcutBlocked, sanitizeSettings, DEFAULT_SETTINGS,
|
||||
} from '../../public/js/ui/gameLogic.js';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
@@ -201,6 +201,59 @@ describe('the one lookup (actionForKey) and shortcutFor', () => {
|
||||
assert.equal(facingSwallows({ key: ' ', code: 'Space' }, { ...DEFAULT_HOTKEYS, ready: 'KeyE' }), true, 'Space always: a focused button must not activate');
|
||||
assert.equal(facingSwallows({ key: 'z', code: 'KeyZ' }), false);
|
||||
});
|
||||
|
||||
// GitHub #394 / PR #395: Enter followed the previewed direction whatever had the focus — Tab to 准备 / 设置 / ✕ and
|
||||
// Enter deployed. A tiny element tree whose closest() understands the selector forms facingEnter uses.
|
||||
const el = (tag, attrs = {}, parent = null) => {
|
||||
const self = {
|
||||
tagName: tag.toUpperCase(), attrs, parent,
|
||||
matches(sel) {
|
||||
return sel.split(',').map((s) => s.trim()).some((s) => {
|
||||
const m = /^([a-z]*)((?:\.[\w-]+)*)((?:\[[\w-]+(?:="[^"]*")?\])*)$/.exec(s);
|
||||
if (!m) return false;
|
||||
if (m[1] && m[1].toUpperCase() !== self.tagName) return false;
|
||||
const classes = String(attrs.class || '').split(/\s+/);
|
||||
if (m[2] && !m[2].slice(1).split('.').every((c) => classes.includes(c))) return false;
|
||||
for (const [, k, v] of m[3].matchAll(/\[([\w-]+)(?:="([^"]*)")?\]/g)) {
|
||||
if (!(k in attrs) || (v !== undefined && String(attrs[k]) !== v)) return false;
|
||||
}
|
||||
return !!(m[1] || m[2] || m[3]);
|
||||
});
|
||||
},
|
||||
closest(sel) { for (let n = self; n; n = n.parent) if (n.matches(sel)) return n; return null; },
|
||||
};
|
||||
return self;
|
||||
};
|
||||
test('the facing wheel\'s Enter follows the focus: ✕ cancels, another control does nothing, elsewhere it commits', () => {
|
||||
const body = el('body');
|
||||
const wheel = el('div', { class: 'fwheel', role: 'dialog', tabindex: '-1' }, body);
|
||||
const cancel = el('button', { class: 'fwheel__cancel' }, el('div', { class: 'fwheel__dia' }, wheel));
|
||||
const cancelText = el('span', {}, cancel);
|
||||
const ready = el('button', { class: 'ready' }, el('header', {}, body));
|
||||
const gear = el('button', { class: 'gm__gear' }, body);
|
||||
const card = el('div', { role: 'button', tabindex: '0' }, body);
|
||||
const link = el('a', { href: '#' }, body);
|
||||
const field = el('input', { type: 'text' }, body);
|
||||
for (const chosen of [true, false]) {
|
||||
assert.equal(facingEnter({ key: 'Enter', target: cancel }, chosen), 'cancel', 'Enter on ✕ cancels (a direction previewed or not)');
|
||||
assert.equal(facingEnter({ key: 'Enter', target: cancelText }, chosen), 'cancel');
|
||||
for (const t of [ready, gear, card, link, field]) assert.equal(facingEnter({ key: 'Enter', target: t }, chosen), 'swallow', 'another control does nothing');
|
||||
}
|
||||
assert.equal(facingEnter({ key: 'Enter', target: wheel }, true), 'commit', 'the wheel has the focus when it opens: Enter confirms');
|
||||
assert.equal(facingEnter({ key: 'Enter', target: body }, true), 'commit');
|
||||
assert.equal(facingEnter({ key: 'Enter', target: null }, true), 'commit');
|
||||
assert.equal(facingEnter({ key: 'Enter', target: wheel }, false), 'swallow', 'nothing to confirm yet');
|
||||
assert.equal(facingEnter({ key: ' ', target: cancel }, true), null, 'not Enter: Space stays with facingSwallows');
|
||||
assert.equal(facingEnter({ key: 'ArrowUp', target: body }, true), null);
|
||||
assert.equal(facingEnter(null, true), null);
|
||||
});
|
||||
test('the wheel wires it: Enter through facingEnter, the wheel focusable and focused when it opens', () => {
|
||||
const src = read('public/js/ui/facingWheel.js');
|
||||
assert.match(src, /const enter = facingEnter\(e, !!L\.dir\);/);
|
||||
assert.doesNotMatch(src, /e\.key === 'Enter' && L\.dir/, 'no Enter that ignores the focus');
|
||||
assert.match(src, /<div ref=\$\{rootRef\} tabIndex="-1" class=\$\{cx\('fwheel'/);
|
||||
assert.match(src, /rootRef\.current\?\.focus\(\{ preventScroll: true \}\)/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('capturing a new key (the settings wait for a key)', () => {
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { startServer } from '../../server/index.js';
|
||||
import { makeBattle } from '../helpers/battleHarness.js';
|
||||
import { spawnMapChar } from '../../server/sim/content/tokens.js';
|
||||
|
||||
const chrome = process.env.CHROME_PATH;
|
||||
const enabled = process.env.SP_E2E === '1' && chrome && existsSync(chrome);
|
||||
|
||||
test('user Given a real battle with strategy medics When clicking units and closing details Then attack tiles follow selection',
|
||||
{ skip: !enabled && 'Set SP_E2E=1 and CHROME_PATH', timeout: 60000 }, async () => {
|
||||
const server = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
|
||||
const puppeteer = (await import('puppeteer-core')).default;
|
||||
const browser = await puppeteer.launch({ executablePath: chrome, headless: true, args: ['--no-sandbox'] });
|
||||
try {
|
||||
for (const medicId of ['char_613_acmedc', 'char_605_cmedic']) {
|
||||
const h = makeBattle({ stageId: 'act2autochess_m01', units: [{ chessId: 'chess_char_1_01_a', row: 10, col: 4 }], autoFinish: false });
|
||||
h.step();
|
||||
assert.ok(spawnMapChar(h.b, h.b.players[0].playerId, medicId));
|
||||
h.step();
|
||||
const field = h.b.fieldMeta();
|
||||
const snapshot = h.b.snapshot();
|
||||
assert.equal(field.units.length, 2);
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1280, height: 720, deviceScaleFactor: 2 });
|
||||
const errors = [];
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
await page.goto(`http://127.0.0.1:${server.port}/dev/game-mock.html?render=fallback&shot=1&phase=PREP`);
|
||||
await page.waitForSelector('.ff-piece');
|
||||
await page.evaluate(async ({ field, snapshot }) => {
|
||||
const { store } = await import('/js/store.js');
|
||||
const { net } = await import('/js/net.js');
|
||||
const pub = store.get().match.public;
|
||||
store.patch('match', { public: { ...pub, phase: 'COMBAT', fields: [{ fieldId: field.fieldId, players: [pub.players[0].playerId] }] }, field });
|
||||
// The local runner emits the snapshot synchronously, before the screen's field-entry effect.
|
||||
net._emit('b.snap', snapshot);
|
||||
}, { field, snapshot });
|
||||
await page.waitForSelector('.ff-unit');
|
||||
for (let i = 0; i < field.units.length; i++) {
|
||||
const units = await page.$$('.ff-unit');
|
||||
await units[i].click();
|
||||
await page.waitForSelector('.dpanel');
|
||||
await page.waitForSelector('.ff-tile.is-range');
|
||||
const lit = await page.$$eval('.ff-tile.is-range', (els) => els.map((e) => [Number(e.dataset.row), Number(e.dataset.col)]));
|
||||
const u = field.units[i];
|
||||
if (u.defId === medicId) {
|
||||
assert.ok(lit.some(([r, c]) => r === 10 && c === 5), 'medic reaches three columns right from the stage tile');
|
||||
assert.ok(!lit.some(([, c]) => c === 6), 'inactive skill does not extend the base range');
|
||||
}
|
||||
// A new wire snapshot must refresh the open overlay, not just the unit sprite.
|
||||
await page.evaluate(async ({ snapshot, id }) => {
|
||||
const { net } = await import('/js/net.js');
|
||||
net._emit('b.snap', { ...snapshot, units: snapshot.units.map((v) => v[0] === id ? [v[0], v[1] + 1, ...v.slice(2)] : v) });
|
||||
}, { snapshot, id: u.id });
|
||||
await page.waitForFunction((before) => {
|
||||
const now = [...document.querySelectorAll('.ff-tile.is-range')].map((e) => [Number(e.dataset.row), Number(e.dataset.col)]);
|
||||
return now.length === before.length && now.every(([r, c]) => before.some(([br, bc]) => br === r && bc + 1 === c));
|
||||
}, {}, lit);
|
||||
await page.evaluate(async () => {
|
||||
const { store } = await import('/js/store.js');
|
||||
store.patch('match', { public: { ...store.get().match.public, phase: 'SETTLE' } });
|
||||
// Allow Preact's after-paint phase effects to run before asserting retained UI.
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
});
|
||||
assert.ok(await page.$('.dpanel'), 'same-field settlement keeps the inspected card');
|
||||
assert.ok(await page.$('.ff-tile.is-range'), 'same-field settlement keeps its range');
|
||||
await page.click('.dpanel__close');
|
||||
await page.waitForFunction(() => !document.querySelector('.ff-tile.is-range'));
|
||||
await page.evaluate(async (snapshot) => {
|
||||
const { net } = await import('/js/net.js');
|
||||
net._emit('b.snap', snapshot);
|
||||
}, snapshot);
|
||||
}
|
||||
await (await page.$$('.ff-unit'))[0].click();
|
||||
await page.waitForSelector('.ff-tile.is-range');
|
||||
await page.evaluate(async ({ field, snapshot }) => {
|
||||
const { store } = await import('/js/store.js');
|
||||
const { net } = await import('/js/net.js');
|
||||
const fieldId = `${field.fieldId}:next`;
|
||||
store.patch('match', { field: { ...field, fieldId } });
|
||||
net._emit('b.snap', { ...snapshot, fieldId });
|
||||
}, { field, snapshot });
|
||||
await page.waitForFunction(() => !document.querySelector('.dpanel') && !document.querySelector('.ff-tile.is-range'));
|
||||
assert.deepEqual(errors, []);
|
||||
await page.close();
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { inspectRange } from '../../public/js/screens/game/range.js';
|
||||
import { renderInfo } from '../../public/js/render/app/info.js';
|
||||
import { rangeTiles } from '../../public/js/ui/facing.js';
|
||||
import { unitStatsEntry } from '../../shared/protocol.js';
|
||||
import { makeBattle } from '../helpers/battleHarness.js';
|
||||
|
||||
const read = (name) => JSON.parse(readFileSync(new URL(`../../data/${name}.json`, import.meta.url), 'utf8'));
|
||||
const chess = read('chess'), tokens = read('tokens');
|
||||
const getChess = (id) => chess[id];
|
||||
const medic = (id = 'char_613_acmedc', extra = {}) => renderInfo({ id: 71, defId: id, kind: 'token', side: 'ally', x: 5, y: 9, dir: 'UP', ...extra });
|
||||
const args = (unit, extra = {}) => ({ target: { kind: 'unit', unit }, detail: { type: 'token', token: tokens[unit.defId] },
|
||||
getChess, field: { prep: true }, ...extra });
|
||||
|
||||
test('user Given Jessica inspected beside her shield When it expires Then her open range turns back with her', () => {
|
||||
const h = makeBattle({ autoFinish: false, units: [
|
||||
{ uid: 1, diy: { slot: 'chess_char_5_diy1_a', charId: 'char_1034_jesca2', skillIndex: 0 }, row: 10, col: 4 },
|
||||
{ uid: 2, kind: 'token', tokenId: 'token_10032_jesca2_jckshd', ownerUid: 1, row: 11, col: 4 },
|
||||
] });
|
||||
h.step();
|
||||
const u = h.unit(1);
|
||||
const unit = h.b.fieldMeta().units.find((v) => v.id === u.id);
|
||||
const input = { target: { kind: 'unit', unit }, detail: { type: 'chess', chess: u.def.raw, diy: true }, getChess, field: {} };
|
||||
const tiles = () => {
|
||||
const r = inspectRange({ ...input, live: unitStatsEntry(u, u.s), snapshot: h.b.snapshot().units.find((v) => v[0] === u.id) });
|
||||
return rangeTiles(r.grid, r.row, r.col, r.dir);
|
||||
};
|
||||
assert.deepEqual(tiles(), [[10, 4], [11, 4], [12, 4]]);
|
||||
h.b.retreat(h.unit(2), { reason: 'expired', permanent: true });
|
||||
h.run(0.5);
|
||||
assert.deepEqual(tiles(), [[10, 4], [10, 5], [10, 6]]);
|
||||
});
|
||||
|
||||
test('user Given strategy medics When inspecting them Then their healing tiles rotate on the field', () => {
|
||||
for (const id of ['char_613_acmedc', 'char_605_cmedic']) {
|
||||
const r = inspectRange(args(medic(id)));
|
||||
assert.equal(r.grid.length, 12);
|
||||
const tiles = rangeTiles(r.grid, r.row, r.col, r.dir);
|
||||
assert.equal(tiles.length, 12);
|
||||
assert.ok(tiles.some(([row, col]) => row === 12 && col === 4));
|
||||
assert.ok(!tiles.some(([row]) => row === 8));
|
||||
}
|
||||
});
|
||||
|
||||
test('user Given a moving battle medic When its current range changes Then the overlay follows live range and position', () => {
|
||||
const r = inspectRange(args(medic(), { field: { prep: false }, snapshot: [71, 8, 10, 120, 200], live: { range: tokens.char_613_acmedc.skill.rangeGrid } }));
|
||||
assert.equal(r.grid.length, 18);
|
||||
assert.deepEqual([r.row, r.col, r.dir], [10, 8, 'UP']);
|
||||
const tiles = rangeTiles(r.grid, r.row, r.col, r.dir);
|
||||
assert.ok(tiles.some(([row, col]) => row === 15 && col === 9));
|
||||
assert.ok(!tiles.some(([, col]) => col === 5));
|
||||
});
|
||||
|
||||
test('user Given an enemy, scouted hand or absent battle unit When inspecting Then no attack tiles appear', () => {
|
||||
for (const extra of [{ side: 'enemy' }, { area: 'hand' }, { area: 'temp' }, { kind: 'device' }]) {
|
||||
assert.equal(inspectRange(args(medic(undefined, extra))), null);
|
||||
}
|
||||
assert.equal(inspectRange(args(medic(), { field: { prep: false } })), null);
|
||||
assert.equal(inspectRange(args(medic(), { field: {}, snapshot: [71, 5, 9, 0, 200] })), null);
|
||||
assert.equal(inspectRange(args(medic(), { showPrep: true })), null);
|
||||
assert.equal(inspectRange(args(medic(), { live: { range: [] } })), null);
|
||||
});
|
||||
|
||||
test('user Given an own board operator When its details open Then its range uses the board direction and clears off board', () => {
|
||||
const piece = { kind: 'chess', id: 'chess_char_1_01_a', uid: 5, dir: 'LEFT' };
|
||||
const input = { target: { kind: 'piece', uid: 5 }, detail: { type: 'chess', chess: chess[piece.id], piece },
|
||||
pieces: new Map([[5, { piece, area: 'board', row: 11, col: 2 }]]), showPrep: true, getChess };
|
||||
const r = inspectRange(input);
|
||||
const tiles = rangeTiles(r.grid, r.row, r.col, r.dir);
|
||||
assert.ok(tiles.every(([, col]) => col >= 0 && col <= 2));
|
||||
assert.ok(tiles.some(([, col]) => col === 0));
|
||||
assert.equal(inspectRange({ ...input, pieces: new Map() }), null);
|
||||
assert.equal(inspectRange({ ...input, target: { kind: 'chess', id: piece.id } }), null);
|
||||
assert.equal(inspectRange({ ...input, showPrep: false }), null);
|
||||
});
|
||||
|
||||
test('a scouted prep board (the server\'s m.field): the board operator draws its range, the bench (area hand / temp) none', async () => {
|
||||
const { makeMatch, give, giveItem, legalTileFor, chessOfTier } = await import('../match/harness.js');
|
||||
const h = makeMatch({ mode: 'coop', humans: 2, seed: 44, fake: true }).start();
|
||||
h.toPrep(1);
|
||||
const m = h.m;
|
||||
const b = h.ps('p_1');
|
||||
m.handle('p_1', { t: 'g.ready', ready: false });
|
||||
for (const p of [...b.board.values(), ...b.hand.filter(Boolean), ...b.temp.filter(Boolean)]) if (p.kind === 'chess') b.returnCopies(p);
|
||||
b.board.clear(); b.hand.fill(null); b.temp.fill(null); b.recompute();
|
||||
const [id1, id2, id3] = chessOfTier(1, (c) => c.position === 'MELEE').filter((x) => m.pool.has(x));
|
||||
const deployed = give(m, b, id1);
|
||||
const [r, c] = legalTileFor(m, b, id1);
|
||||
m.handle('p_1', { t: 'g.move', uid: deployed.uid, to: { area: 'board', row: r, col: c }, dir: 'UP' });
|
||||
const held = give(m, b, id2);
|
||||
while (b.hand.some((x) => x == null)) giveItem(m, b, 'chess_item_6_09_e_a'); // a full hand keeps the temp piece in temp
|
||||
const waiting = give(m, b, id3, 'temp');
|
||||
m.handle('p_0', { t: 'g.watch', fieldId: 'n:p_1' });
|
||||
const field = h.lastTo('p_0', 'm.field');
|
||||
const range = (piece) => {
|
||||
const unit = renderInfo(field.units.find((u) => u.uid === piece.uid));
|
||||
return inspectRange({ target: { kind: 'unit', unit }, detail: { type: 'chess', chess: chess[piece.id] }, field, getChess });
|
||||
};
|
||||
const own = range(deployed);
|
||||
assert.deepEqual([own.row, own.col, own.dir], [r, c, 'UP']);
|
||||
assert.equal(range(held), null, 'a held operator (area hand) has no field range');
|
||||
assert.equal(range(waiting), null, 'nor one in the 临时整备区 (area temp)');
|
||||
m.dispose();
|
||||
});
|
||||
@@ -23,7 +23,8 @@ globalThis.fetch = async (url) => {
|
||||
}
|
||||
};
|
||||
|
||||
const { TokenDetail, summonDeployHint, resolveDetail, tokenVariantFor } = await import('../../public/js/ui/detailPanel.js');
|
||||
const { TokenDetail, summonDeployHint, resolveDetail } = await import('../../public/js/ui/detailPanel.js');
|
||||
const { tokenVariantFor } = await import('../../public/js/ui/gameLogic/loadout.js');
|
||||
const { indexPieces } = await import('../../public/js/ui/gameLogic.js');
|
||||
const { SKILL_SUMMON_START_DEPLOY } = await import('../../shared/constants.js');
|
||||
const simTokens = await import('../../server/sim/content/tokens.js');
|
||||
|
||||
@@ -281,16 +281,45 @@ describe('§20.15 the bonds: views + live layers', () => {
|
||||
assert.deepEqual(mate.bonds.map((b) => [b.bondId, b.layers]), [['sargonShip', 44], ['kjeragShip', 7]], 'THEIR bonds, THEIR live count');
|
||||
assert.deepEqual(playerBonds({ pub, priv: PRIV, myId: ME, ownerId: 'p3', live }).map((b) => b.layers), [120]);
|
||||
});
|
||||
test('ownerBoard: the teammate\'s operators on the field on screen feed the popup\'s member list (no hand)', () => {
|
||||
test('ownerBoard: the teammate\'s operators on the field on screen feed the popup\'s member list; a scout\'s bench goes to hand / temp', () => {
|
||||
const field = { fieldId: 'n:p2', prep: true, units: [
|
||||
{ id: 1, kind: 'op', side: 'ally', ownerId: 'p2', defId: 'chess_char_1_19_a' },
|
||||
{ id: 2, kind: 'token', side: 'ally', ownerId: 'p2', defId: 'tok' },
|
||||
{ id: 1, kind: 'op', side: 'ally', ownerId: 'p2', defId: 'chess_char_1_19_a', area: 'board' },
|
||||
{ id: 2, kind: 'token', side: 'ally', ownerId: 'p2', defId: 'tok', area: 'board' },
|
||||
{ id: 3, kind: 'op', side: 'ally', ownerId: 'p3', defId: 'chess_other' },
|
||||
{ id: 4, kind: 'enemy', side: 'enemy', ownerId: null, defId: 'enemy_1' },
|
||||
{ id: 5, kind: 'op', side: 'ally', ownerId: 'p2', defId: 'chess_char_1_03_a', area: 'hand' },
|
||||
{ id: 6, kind: 'op', side: 'ally', ownerId: 'p2', defId: 'chess_char_2_04_a', area: 'temp' },
|
||||
{ id: 7, kind: 'item', side: 'ally', ownerId: 'p2', defId: 'chess_item_1_01_e_a', area: 'hand' },
|
||||
] };
|
||||
assert.deepEqual(ownerBoard(field, 'p2'), { board: [{ kind: 'chess', id: 'chess_char_1_19_a' }], hand: [], temp: [] });
|
||||
assert.deepEqual(ownerBoard(field, 'p2'), {
|
||||
board: [{ kind: 'chess', id: 'chess_char_1_19_a' }], hand: [{ kind: 'chess', id: 'chess_char_1_03_a' }], temp: [{ kind: 'chess', id: 'chess_char_2_04_a' }],
|
||||
});
|
||||
// a unit without an area (a battle's, an older server's prep scout) is on the board
|
||||
assert.deepEqual(ownerBoard({ units: [{ kind: 'op', ownerId: 'p2', defId: 'chess_char_1_19_a' }] }, 'p2').board, [{ kind: 'chess', id: 'chess_char_1_19_a' }]);
|
||||
assert.equal(ownerBoard(null, 'p2'), null);
|
||||
});
|
||||
test('GitHub #385: a scouted bench counts in the popup\'s 成员 header as in the player\'s own (hand for 远见 / 奇迹 / 投资人, temp never)', () => {
|
||||
// 远见 (BOARD_AND_DECK, countsHand: the server's 在场 is 2 — 赫默 on the board, 初雪 in the hand; 伊内丝 waits in temp)
|
||||
// 炎 (BOARD: 在场 1 — 小满; 琳琅诗怀雅 in the hand, 烛煌 in temp)
|
||||
const u = (id, defId, area) => ({ id, kind: 'op', side: 'ally', ownerId: 'p2', defId, area });
|
||||
const field = { fieldId: 'n:p2', prep: true, units: [
|
||||
u(1, 'chess_char_2_02_a', 'board'), u(2, 'chess_char_2_04_a', 'board'),
|
||||
u(3, 'chess_char_3_14_a', 'hand'), u(4, 'chess_char_3_04_a', 'hand'),
|
||||
u(5, 'chess_char_4_04_a', 'temp'), u(6, 'chess_char_5_03_a', 'temp'),
|
||||
] };
|
||||
const head = (bondId) => {
|
||||
const v = BondPopup({ bondId, entry: bond(bondId, 0, 0, 0), priv: ownerBoard(field, 'p2'), owner: '阿米娅', onClose() {} });
|
||||
const h4 = [...walk(v)].find((x) => x.type === 'h4' && /成员/.test(textOf(x)));
|
||||
const members = [...walk(v)].filter((x) => hasClass(x, 'bpop__member'));
|
||||
return { head: textOf(h4).replace(/\s+/g, ''), on: members.filter((x) => hasClass(x, 'is-on')).length, owned: members.filter((x) => hasClass(x, 'is-owned')).length };
|
||||
};
|
||||
const visi = head('visiShip');
|
||||
assert.match(visi.head, /^成员2\//, '远见: the board member and the hand member (含整备区), not the temp one');
|
||||
assert.deepEqual([visi.on, visi.owned], [1, 2], 'only 赫默 is 在场; 初雪 and 伊内丝 are owned');
|
||||
const yan = head('yanShip');
|
||||
assert.match(yan.head, /^成员1\//, '炎: the board member only');
|
||||
assert.deepEqual([yan.on, yan.owned], [1, 2]);
|
||||
});
|
||||
test('ownerBoard: under client-side combat the battle on screen\'s operators join (the runner\'s meta predates their deploy)', () => {
|
||||
const meta = { fieldId: 'b1', local: true, units: [{ id: 90, kind: 'enemy', side: 'enemy', ownerId: null, defId: 'boss' }] };
|
||||
const ops = [{ kind: 'op', ownerId: 'p2', defId: 'chess_char_1_09_a' }, { kind: 'op', ownerId: 'p2', defId: 'chess_char_1_03_a' }];
|
||||
@@ -405,7 +434,7 @@ describe('§20.15 the popup of a card\'s bond chip: the UNIT owner\'s bond (a sh
|
||||
assert.deepEqual([v.entry.bondId, v.entry.count, v.entry.active, v.entry.layers], ['sargonShip', 2, true, 55], 'the partner\'s count / tier, their live layers');
|
||||
assert.notEqual(v.priv, PRIV, 'not the viewer\'s pieces');
|
||||
assert.deepEqual(v.priv.board.map((x) => x.id).sort(), ['chess_char_1_09_a', 'chess_char_1_12_a'], 'the partner\'s operators on the field on screen');
|
||||
assert.deepEqual([v.priv.hand, v.priv.temp], [[], []], 'their hand is never sent');
|
||||
assert.deepEqual([v.priv.hand, v.priv.temp], [[], []], 'a battle field sends no bench');
|
||||
});
|
||||
test('popupView: your own bond (m.private entry with thresholds, your pieces, no name); a bond not listed → no entry; nothing open → null', () => {
|
||||
const own = popupView({ open: { id: 'yanShip', ownerId: ME, from: 'detail' }, pub, priv: PRIV, myId: ME, field: b1, live: null });
|
||||
|
||||
Reference in new issue
Block a user