0.2.3: prepare follow-up fixes and client features (#407, #408, #410, #413, #431, #435, #436, #440, #444)

Add special-target activation without bypassing attack cadence, cooperative
card preferences and emotes, skill sound banks, per-operator voice choices,
text sizing, install prompts, closed-tab recovery and unanimous setup rerolls.
Retain late deployment elapsed time and resolve echo/counter pulses without
recursive hook overflow. Independent final review remains pending.

Golden: roster-016, roster-035, roster-036, roster-045;
boss-boss_1-pair, boss-boss_3-pair, boss-boss_5-pair, boss-boss_7-pair,
hidden-boss_9-pair; solo-HARD-9-boosted: Lemuen attack cadence.
diy-132: Necras extra activation targets.
coop2-NORMAL-14-standins: cooperative purchase preference and simulation fixes.
hidden-boss_10-solo: echo pulse FIFO resolution.
See DESIGN 28.13-28.24 for assumptions, sources and contributor credit.
This commit is contained in:
sganggs committed 2026-10-09 22:55:24 +08:00
1 parent 314564c139
commit f3658a0dd4
104 files changed
+3674 -741

No files matched your search

+10
View File
@@ -2,6 +2,8 @@
## 0.2.3-dev — 开发中,尚未发布
- 修复“余音”与刺玫、泡泡反击在高频次生命下递归超过事件上限的问题。
- 自选编队新增六星克莱门莎(三个技能、两项天赋、潜能与练度);黍新增「好时节」GUA-Y,乌尔比安新增「前路」CRU-Y。数值来自国服 2.7.81 官方表,机制对照 PRTS;新干员尚无来源确认的碰撞边界、攻击溅射和炮击节拍已标在 [设计记录 §28](docs/history/0.2.3.md),后续依据资料校正。联动干员不在本次新增范围。
- 蕾缪安可以用远处的通缉目标触发技能,修正通缉首次计时、多人来源和撤退清理(#428,感谢 @CurlyZhu)。
- 塞雷娅 / 黍一技能在没有敌人时也按攻击间隔治疗范围内半血及以下的友方;凯尔希·思衡托二技能可以由扩大范围内的可选敌人触发,仍能由伤员触发(#406,感谢 @ChengCC0101)。
@@ -10,6 +12,14 @@
- 修复弹窗内复制诊断信息的旧浏览器回退,复制后恢复焦点(#433,感谢 @RushFTK)。
- 素材下载会报告因磁盘缺失而略去的清单条目,新增可选严格模式(PR #421,感谢 @LimitlessHPPK);润色韩语文本(PR #414,感谢 @D-Roote),补中文 PR 模板(PR #432,感谢 @Xinglan233)。
- 死芒一、二技能可由自身召唤物阻挡的范围外目标触发;与蕾缪安通缉目标一样等待正常攻击检查,不逐帧提前开技(#440、#438、#439)。
- 设置新增四档文字大小,支持长译文换行;干员调配可逐干员选择中文、日语或跟随全局语音(#435、#436,PR #446,感谢 @7eLaryn)。
- 补齐司霆惊蛰、赤刃明霄陈等干员技能期间的攻击与命中音,保留各技能音效自己的音量与播放概率(PR #410,感谢 @Convey123)。
- AI 购买高阶卡时适当降低与队友开局盟约抢卡的倾向,并在混合对局中发送有限的表情回应(PR #407、#444,感谢 @IceCodeNew、@DDDarkstar;可用 `SP_BOT_EMOTES=0` 关闭表情)。
- 增加本机对局恢复入口,避免同一浏览器的闲置窗口妨碍恢复已关闭窗口的席位(#431)。浏览器支持时可添加到桌面,精简包也自带安装图标(PR #413,感谢 @Cromemadnd)。
- 确认页可由房主发起全体真人一致同意的开局重刷,保留房间与个人配置;取消恢复剩余确认时间,成功后重新确认(PR #408,感谢 @Siornya)。这是复刻版便捷功能。
- 修复模型加载较慢时丢失入场动画,按经过时间接续,攻击或死亡后不再补播过期入场(PR #416 提示的加载问题,感谢 @Cromemadnd)。
## 0.2.2 — 2026-10-09
0.2.1 之后的版本:干员的潜能和自持有加成改为在「干员调配」里按干员自己设置(默认满潜、精英2 60 级);新增日文语音、点选干员的语音和本机统计数据页;按官方数据和 PRTS 修正了一大批规则(被推拉的敌人进入失衡、首领场传送门、杜宾「教鞭」三选一、突袭会跳到隐匿敌人身边等),并修复了社区和 GitHub 上反馈的问题、移植了多位贡献者的 PR。战斗模拟改用与浏览器无关的数学函数,各浏览器和服务器的战斗结果逐位一致。规则对照官方数据和 PRTS 核对,详见 DESIGN §27([docs/history/0.2.2.md](docs/history/0.2.2.md));有意和官方不同的写在「有意与官方不同的调整」里。
+1
View File
@@ -121,6 +121,7 @@ npm start # 启动服务器:http://localhost:3000
| `TRUST_PROXY` | `auto` | 是否信任 `X-Forwarded-For` 等转发头:`auto` 只信任来自本机 / 内网的代理;`1` 总是;`0` 从不 |
| `DEBUG` | 空 | 设为任意值输出详细日志 |
| `SP_NO_BROWSER` | 空 | 设为 `1` 时启动脚本不自动打开浏览器 |
| `SP_BOT_EMOTES` | 空 | AI 队友的对局表情回应(战斗、整备、合成、联防、收礼时即刻的 `m.emote` 广播);设为 `0` 时整体关闭 |
设置方式:macOS / Linux `PORT=8080 npm start`;PowerShell `$env:PORT=8080; npm start`;cmd `set "PORT=8080" && npm start`。健康检查:`GET /healthz`。
+1 -1
View File
@@ -1 +1 @@
{"version":1,"hash":"997b60350583","generator":"tools/fetch-assets.mjs","stats":{"files":10688,"bytes":578013623,"chars":210,"charsWithBack":207,"enemies":252,"enemiesWithSpine":251,"tokens":58,"tokensWithSpine":13,"spineModels":673,"bonds":23,"items":59,"bands":40,"skills":525,"modules":131,"ui":602,"sfxUnits":484,"voiceChars":192,"voiceJpChars":192},"chars":{"char_003_kalts":{"avatar":"/assets/char/avatar/char_003_kalts.png","avatarE2":"/assets/char/avatar/char_003_kalts_2.png","portrait":"/assets/char/portrait/char_003_kalts_1.png","portraitE2":"/assets/char/portrait/char_003_kalts_2.png","spine":{"front":{"skel":"/assets/spine/op/char_003_kalts/front/char_003_kalts.skel","atlas":"/assets/spine/op/char_003_kalts/front/char_003_kalts.atlas","textures":["/assets/spine/op/char_003_kalts/front/char_003_kalts.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Attack","end":null},"attackDown":null,"skill":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":0,"idle":null},"die":"Die","move":null,"stun":null,"skills":{"0":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":0,"idle":null},"1":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":1,"idle":null},"2":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":2,"idle":null}}},"animations":{"Attack":1,"Default":0,"Die":1,"Idle":9,"Start":1},"events":["OnAttack","OnStart"],"hits":{"Attack":[0.433]},"bounds":{"x":-227.579,"y":-138.244,"width":532.04,"height":551.479}},"back":{"skel":"/assets/spine/op/char_003_kalts/back/char_003_kalts.skel","atlas":"/assets/spine/op/char_003_kalts/back/char_003_kalts.atlas","textures":["/assets/spine/op/char_003_kalts/back/char_003_kalts.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Attack","end":null},"attackDown":null,"skill":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":0,"idle":null},"die":null,"move":null,"stun":null,"skills":{"0":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":0,"idle":null},"1":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":1,"idle":null},"2":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":2,"idle":null}}},"animations":{"Attack":1,"Default":0,"Idle":9,"Start":1},"events":["OnAttack","OnStart"],"hits":{"Attack":[0.433]},"bounds":{"x":410.785,"y":-2.26,"width":531.785,"height":416.492}}}},"char_010_chen":{"avatar":"/assets/char/avatar/char_010_chen.png","avatarE2":"/assets/char/avatar/char_010_chen_2.png","portrait":"/assets/char/portrait/char_010_chen_1.png","portraitE2":"/assets/char/portrait/char_010_chen_2.png","spine":{"front":{"skel":"/assets/spine/op/char_010_chen/front/char_010_chen.skel","atlas":"/assets/spine/op/char_010_chen/front/char_010_chen.atlas","textures":["/assets/spine/op/char_010_chen/front/char_010_chen.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Attack","end":null},"attackDown":null,"skill":{"begin":null,"loop":"Skill","end":"Skill_End","index":0,"idle":null},"die":"Die","move":null,"stun":null,"skills":{"0":{"begin":null,"loop":"Skill","end":"Skill_End","index":0,"idle":null},"1":{"begin":null,"loop":"Skill_2","end":null,"index":1,"idle":null},"2":{"begin":null,"loop":"Skill_3","end":null,"index":2,"idle":null}}},"animations":{"Attack":1.5,"Attack_End":0.6,"Attack_Pre":0.6,"Default":0,"Die":1,"Idle":2,"Skill":1,"Skill_2":1.167,"Skill_3":2.933,"Skill_End":0.6,"Skill_End_2":0.6,"Skill_End_3":0.3,"Start":0.833},"events":["OnAttack","OnStart"],"hits":{"Attack":[0.433,1],"Skill":[0.467],"Skill_2":[0.467],"Skill_3":[0.6,0.8,1,1.2,1.4,1.6,1.8,2,2.2,2.833,2.933]},"bounds":{"x":-322.335,"y":-109.274,"width":663.549,"height":524.806}},"back":{"skel":"/assets/spine/op/char_010_chen/back/char_010_chen.skel","atlas":"/assets/spine/op/char_010_chen/back/char_010_chen.atlas","textures":["/assets/spine/op/char_010_chen/back/char_010_chen.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Attack","end":null},"attackDown":null,"skill":{"begin":null,"loop":"Skill","end":"Skill_End","index":0,"idle":null},"die":null,"move":null,"stun":null,"skills":{"0":{"begin":null,"loop":"Skill","end":"Skill_End","index":0,"idle":null},"1":{"begin":null,"loop":"Skill","end":"Skill_End","index":1,"idle":null},"2":{"begin":null,"loop":"Skill","end":"Skill_End","index":2,"idle":null}}},"animations":{"Attack":1.5,"Attack_End":0.6,"Attack_Pre":0.6,"Default":0,"Idle":2,"Skill":1,"Skill_End":0.6,"Start":0.833},"events":["OnAttack","OnStart"],"hits":{"Attack":[0.433,1],"Skill":[0.467]},"bounds":{"x":-173.082,"y":-13.829,"width":315.887,"height":404.623}}}},"char_017_huang":{"avatar":"/assets/char/avatar/char_017_huang.png","avatarE2":"/assets/char/avatar/char_017_huang_2.png","portrait":"/assets/char/portrait/char_017_huang_1.png","portraitE2":"/assets/char/portrait/char_017_huang_2.png","spine":{"front":{"skel":"/assets/spine/op/char_017_huang/frontLine truncated
{"version":1,"hash":"cf275d509d29","generator":"tools/fetch-assets.mjs","stats":{"files":10956,"bytes":581063469,"chars":210,"charsWithBack":207,"enemies":252,"enemiesWithSpine":251,"tokens":58,"tokensWithSpine":13,"spineModels":673,"bonds":23,"items":59,"bands":40,"skills":525,"modules":131,"ui":602,"sfxUnits":484,"voiceChars":192,"voiceJpChars":192},"chars":{"char_003_kalts":{"avatar":"/assets/char/avatar/char_003_kalts.png","avatarE2":"/assets/char/avatar/char_003_kalts_2.png","portrait":"/assets/char/portrait/char_003_kalts_1.png","portraitE2":"/assets/char/portrait/char_003_kalts_2.png","spine":{"front":{"skel":"/assets/spine/op/char_003_kalts/front/char_003_kalts.skel","atlas":"/assets/spine/op/char_003_kalts/front/char_003_kalts.atlas","textures":["/assets/spine/op/char_003_kalts/front/char_003_kalts.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Attack","end":null},"attackDown":null,"skill":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":0,"idle":null},"die":"Die","move":null,"stun":null,"skills":{"0":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":0,"idle":null},"1":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":1,"idle":null},"2":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":2,"idle":null}}},"animations":{"Attack":1,"Default":0,"Die":1,"Idle":9,"Start":1},"events":["OnAttack","OnStart"],"hits":{"Attack":[0.433]},"bounds":{"x":-227.579,"y":-138.244,"width":532.04,"height":551.479}},"back":{"skel":"/assets/spine/op/char_003_kalts/back/char_003_kalts.skel","atlas":"/assets/spine/op/char_003_kalts/back/char_003_kalts.atlas","textures":["/assets/spine/op/char_003_kalts/back/char_003_kalts.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Attack","end":null},"attackDown":null,"skill":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":0,"idle":null},"die":null,"move":null,"stun":null,"skills":{"0":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":0,"idle":null},"1":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":1,"idle":null},"2":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":2,"idle":null}}},"animations":{"Attack":1,"Default":0,"Idle":9,"Start":1},"events":["OnAttack","OnStart"],"hits":{"Attack":[0.433]},"bounds":{"x":410.785,"y":-2.26,"width":531.785,"height":416.492}}}},"char_010_chen":{"avatar":"/assets/char/avatar/char_010_chen.png","avatarE2":"/assets/char/avatar/char_010_chen_2.png","portrait":"/assets/char/portrait/char_010_chen_1.png","portraitE2":"/assets/char/portrait/char_010_chen_2.png","spine":{"front":{"skel":"/assets/spine/op/char_010_chen/front/char_010_chen.skel","atlas":"/assets/spine/op/char_010_chen/front/char_010_chen.atlas","textures":["/assets/spine/op/char_010_chen/front/char_010_chen.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Attack","end":null},"attackDown":null,"skill":{"begin":null,"loop":"Skill","end":"Skill_End","index":0,"idle":null},"die":"Die","move":null,"stun":null,"skills":{"0":{"begin":null,"loop":"Skill","end":"Skill_End","index":0,"idle":null},"1":{"begin":null,"loop":"Skill_2","end":null,"index":1,"idle":null},"2":{"begin":null,"loop":"Skill_3","end":null,"index":2,"idle":null}}},"animations":{"Attack":1.5,"Attack_End":0.6,"Attack_Pre":0.6,"Default":0,"Die":1,"Idle":2,"Skill":1,"Skill_2":1.167,"Skill_3":2.933,"Skill_End":0.6,"Skill_End_2":0.6,"Skill_End_3":0.3,"Start":0.833},"events":["OnAttack","OnStart"],"hits":{"Attack":[0.433,1],"Skill":[0.467],"Skill_2":[0.467],"Skill_3":[0.6,0.8,1,1.2,1.4,1.6,1.8,2,2.2,2.833,2.933]},"bounds":{"x":-322.335,"y":-109.274,"width":663.549,"height":524.806}},"back":{"skel":"/assets/spine/op/char_010_chen/back/char_010_chen.skel","atlas":"/assets/spine/op/char_010_chen/back/char_010_chen.atlas","textures":["/assets/spine/op/char_010_chen/back/char_010_chen.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Attack","end":null},"attackDown":null,"skill":{"begin":null,"loop":"Skill","end":"Skill_End","index":0,"idle":null},"die":null,"move":null,"stun":null,"skills":{"0":{"begin":null,"loop":"Skill","end":"Skill_End","index":0,"idle":null},"1":{"begin":null,"loop":"Skill","end":"Skill_End","index":1,"idle":null},"2":{"begin":null,"loop":"Skill","end":"Skill_End","index":2,"idle":null}}},"animations":{"Attack":1.5,"Attack_End":0.6,"Attack_Pre":0.6,"Default":0,"Idle":2,"Skill":1,"Skill_End":0.6,"Start":0.833},"events":["OnAttack","OnStart"],"hits":{"Attack":[0.433,1],"Skill":[0.467]},"bounds":{"x":-173.082,"y":-13.829,"width":315.887,"height":404.623}}}},"char_017_huang":{"avatar":"/assets/char/avatar/char_017_huang.png","avatarE2":"/assets/char/avatar/char_017_huang_2.png","portrait":"/assets/char/portrait/char_017_huang_1.png","portraitE2":"/assets/char/portrait/char_017_huang_2.png","spine":{"front":{"skel":"/assets/spine/op/char_017_huang/frontLine truncated
+11 -1
View File
@@ -221,7 +221,8 @@ All paths are URL paths relative to the site root, for example `/assets/char/ava
disconnect, settlementSucceed, settlementFail, settlementTeam, settlementBossSign,
goodEvaluation, load, start, matchSucceed, matchFail, matchCancel, joinRoom },
battle: { deploy, tokenDeploy, charDie, enemyDie, enemyDieHeavy, enemyHit, heal, leak, win, lose, killCoin },
units: { [charId|tokenId|enemyId]: { attack?, hit?, skill?, skills?: {[skillIndex]: url}, die?, born?,
units: { [charId|tokenId|enemyId]: { attack?, hit?, skill?, skills?: {[skillIndex]: url}, attacks?: {[skillIndex]: url}, hits?: {[skillIndex]: url}, die?, born?,
attackMix?: {[skillIndex]: {p?, vol?}}, hitMix?: {[skillIndex]: {p?, vol?}},
mix?: { [attack|hit|die|born]: { p?, vol? } } } }
}
},
@@ -452,3 +453,12 @@ The project's code is GPL-3.0-or-later (`LICENSE`); none of the items below is c
- **Novecento Wide:** © Jan Tonellato / Synthview. Free licence.
- Both are mirrored from TimWangZi/The-font-of-Arknights.
- Noto Sans SC and Noto Serif SC (SIL OFL) are loaded from Google Fonts, not self-hosted.
### Skill attack banks (0.2.3, PR #410)
`pickModeAttacks` / `pickModeHits` select a uniform d/h/s bank from the official audio table (attack/combat abilities
only). A skill's activation sound supplies its mode letter; without one, d/h/s = slots 1/2/3 is [ASSUMED]. Competing
banks prefer START over ON, then plain/numeric order; this is a deterministic approximation, not proof of every
operator's runtime ability graph. Mixed-letter banks are skipped. Preserve `mixOf` for each selected bank separately;
`attacks`/`hits` and `attackMix`/`hitMix` use matching skill keys. Regenerate the current manifest instead of importing
an older PR's full JSON, preserving JP voices and current operators. See DESIGN §28.17.
+12
View File
@@ -898,3 +898,15 @@ round was over. The official 1 s `broadcastBeginDelay` is not modelled.
* A merge completed after the prep (SETTLE effects) keeps its reward offer for the next prep; its elite goes where a prep
merge's would — onto the tile of a consumed deployed copy (PRTS 卫戍协议/帮助 "若消耗已部署至作战区的干员,则发送至作战区
对应位置"), else to the hand, overflowing into temp (temp pieces that arrive after the prep wait through the next prep).
## Opening rerolls and bot reactions (0.2.3)
`match/setupVote.js` owns the INFO_CHECK vote and transactional opening replacement; `Lobby.rerollSetup` checks host
permission for `room.rerollSetup {setupRevision}` and `room.cancelReroll {voteId}`. Participants send
`g.rerollVote {voteId, agree}`; `g.infoReady {setupRevision?}` accepts an omitted revision only before the first reroll.
`m.public` includes `setupRevision` and `rerollVote` (null or `{id, proposerId, voters, agreed}`). Old vote/revision
requests cannot affect a new opening. See DESIGN §28.21 for cancellation and timer behavior.
`botEmotes.js` is isolated from bot decisions and RNG. It sends the existing `m.emote` only in mixed matches, under
normal cooldown/whitelist rules; `SP_BOT_EMOTES=0` disables it. The cooperation score in `bot.js` and these cosmetic
choices are explicitly [ASSUMED]; DESIGN §28.15–16 records the source and validation.
+14
View File
@@ -194,3 +194,17 @@
**什么算一局**:打到结算页的对局,以及中途退出(「放弃模拟」)前至少清完 1 个回合的对局,计入以上所有统计——和官方一样,中途退出按淘汰处理,通过的回合数就是退出时已经通过的回合。**进去就退、没清完第一个回合就退的对局不计入统计**(不算一局,不影响胜率和策略通过率),但会留在「对局记录」里,标着「中途退出」和「不计入统计」。已经被淘汰后再退出的对局算(你输了这一局)。下面这些不会记录:观战;被服务器结束的对局(服务器重启、同盟解散);关掉页面后没有再回来的对局。
**存储与隐私**:最多保留最近 1000 局,所有记录加起来不超过约 0.8 MB(超出时最旧的先丢)。只有最新的 30 局保留完整的阵容、盟约和队友信息,更早的压缩成你自己的一行汇总。同盟对局的记录里有**队友的代号**,它们保存在本机,也会出现在你导出的文件里;不会发给任何人。想删掉就点「清空」(会先确认),或清除这个网站的数据。读不出来的存档(损坏的数据)会另存为备份并重新开始记录,不会让页面出错;由更新版本导出的文件会被拒绝导入,也不会被旧版本覆盖。
### 0.2.3 的便捷设置
设置中的「文字大小」有小、中、大、特大四档,默认小;棋盘尺寸不随文字放大。干员调配详情和已选自选干员卡可
单独选择中文、日语或跟随全局语音,只保存在当前浏览器,日语缺失时沿用中文回退。
同一浏览器的对局窗口意外关闭后,可以从标题或大厅的「恢复本机对局」选择保存的席位。仍被其他窗口使用的席位
不会被接管;服务器重启、对局过期或浏览器无法检查其他窗口时不能恢复。
浏览器提供安装条件时会显示「添加到桌面」。安装后仍需连接游戏服务器,不会把服务器或整套素材离线保存。
不支持页面内安装按钮的浏览器可使用其自己的菜单(是否可用由浏览器决定)。
确认开局信息时,房主可发起「重刷本局」。全体真人同意后重抽开局,保留房间和个人配置;投票暂停确认倒计时,
拒绝、取消、断线或退出则保留原开局和剩余时间。只有一名真人时立即刷新。随机结果可能重复。
+8 -3
View File
@@ -947,13 +947,13 @@ flag alters movement or 失衡.
**and** about to attack/heal **and** an enemy (heal skills: an injured ally) inside the **initial** range
(`unit.baseRangeKeys`: its own grid + its permanent rangeExtend — "攻击范围扩大" modules/talents as persist never-expiring
`rangeExtend` buffs; no skill range, no temporary extend, no extra keys) or blocked by the melee unit — or, checked
**every tick**, an enemy (flyers included) inside a content trigger range (`unit.skill.addTriggerRange(fn)`,
**every tick**, an enemy (flyers included) inside a content trigger range (`unit.skill.addTriggerRange(fn, { attackOnly: false })`,
`fn(battle, unit)` → list of ally units (their current range), tile-key arrays or `{ keys, profile }` (tile keys with
the enemy profile the effect selects by — `canHitFly` false: ground enemies only); returns an unregister fn; not for
heal skills: 海嗣 "攻击范围视为自身攻击范围的延伸", 流形, 谬因 S2's beam, and the areas a skill acts through around its
owner's standing summons — 麦哲伦 S1 (her drones' ranges), 令 S3 (each summon's x-5, ground), 电弧 S2 (赛柯's range,
ground) / S3 (桑特拉's range): the owner's larger-range rule of 2026-10-06, kits/shared/summoner.js
`summonTriggerArea`); `SKILL_RANGE` — "不通过普通攻击/治疗触发技能,仅在技能范围内存在敌人(无视其不可选中)时释放技能": any
`summonTriggerArea`). With `{ attackOnly: true }`, the extra range is checked only at the normal attack opportunity, not each tick: 蕾缪安’s own wanted targets and 死芒’s own living summons’ blocked targets (S1/S2); normal selectability and the skill’s actual effect range remain intact (§28.13). `SKILL_RANGE` — "不通过普通攻击/治疗触发技能,仅在技能范围内存在敌人(无视其不可选中)时释放技能": any
living enemy (stealthed, untargetable, flying included) on `trigger.customRangeGrid` (= the skill's rangeGrid), every
tick, no attack needed; `trigger.allies` (+ `hpAtMost`, default 1) — set by a kit, or by the data for an ally row (黍 S3's
official `TRY_SEARCH_ALLY_SKILL`, "技能范围内存在可治疗的我方单位时释放技能": tools/build-data.mjs `TRIGGER_ALLY_RULES`,
@@ -1011,7 +1011,7 @@ flag alters movement or 失衡.
`skillEnd` fires. `onAttack` ctx carries `noAmmo` (set it to true: this attack spends no bullet, no `ammoUsed` — 流明's
free heals).
- Runtime helpers on `unit.skill`: `activate(reason, {free})`, `end(reason)`, `stop()`, `addAmmo(n)`, `extend(s)`,
`addCharge(n)`, `gainSp(n, reason)`, `addTriggerRange(fn)`, `setTrigger(rule, grid)` (a kit's own rule change mid-battle —
`addCharge(n)`, `gainSp(n, reason)`, `addTriggerRange(fn, { attackOnly?: boolean })`, `setTrigger(rule, grid)` (a kit's own rule change mid-battle —
薇薇安娜 S3's ACTIVE_RANGE on 3-2 after its first cast; 0.2.0); fields `sp, spCost (= floor(base×spCostMul + spCostFlat)), spCostMul, charges,
maxCharges, active, timeLeft, ammoLeft, ammoMax, activations, kind, rule, bb` (`ammoMax`: the most bullets the running ammo skill
has held — set at activation, raised by skillStart additions and `addAmmo` above it; 0 when inactive).
@@ -1547,3 +1547,8 @@ window — `push` / `pull`; a wall-shortened slide keeps its row time, a wall at
platforms/mounds are ground obstacles that elevate operators; the generic kit maps 凋亡 to `apoptosis` (侵蚀 has no element key
yet — enemy content must pick one); 抵抗 covers the control statuses of `RESIST_STATUSES` (the official term lists 晕眩/寒冷/
冻结/恐惧/诱导…, the rest follow the operator kits that grant it) and caps at 0.95; tactical points prefer enemy path tiles.
“余音” owns a same-call pulse queue (DESIGN §28.24): reflected hits enqueue their earned pulses instead of recursively
entering another pulse. This retains the lethal-hit pulse and prevents valid high hit-count chains from tripping the
general hook guard. The queue is emptied in `finally`; it introduces no timer or simulation RNG.
+32
View File
@@ -89,3 +89,35 @@ are verified, and cleans up / restores focus on both success and failure. Failur
Push/pull slides preserve the target's pre-hit facing through the waiting and moving phases (`keepFacing`, §28.9).
Explicit false and unmarked raw displacements face the travel direction. Normal snapshot facing resumes on landing.
[ASSUMED] unrecorded push sources and reverse-facing hook pulls use the same display rule as the cited examples.
### Local preferences and recovery (§28.14, §28.18–20)
Settings persist `textSize` (sm/md/lg/xl; default sm) and `voiceOverrides` (charId → cn/jp; default empty) alongside the
existing global `voiceLang`. CSS text uses `--t`; the layout rem, camera and board geometry stay unchanged. Larger text
and translated controls wrap within the settings modal. The normal roster detail and chosen DIY cards expose each
operator's voice preference, shared across that charId's forms. An absent override follows the global preference;
missing Japanese lines retain the existing Chinese fallback. Overrides are local listening preferences, absent from
room loadouts and their exports.
The title and lobby offer explicit recovery of a previously saved local match seat. `sp.matches` stores bounded name /
room-code metadata indexed by token hash; the existing four-entry `sp.tokens` ring remains the authority. Recovery
rechecks BroadcastChannel claims before adopting the selected token, then reloads. It does not identify players by
name or IP or displace a live tab. Without BroadcastChannel it refuses shared recovery. Server restart / expiry clears
obsolete metadata on the next welcome. A spectator never registers a recoverable player seat.
`manifest.json` enables browser installation; its original fortress favicon is packaged as actual 192/512 px icons in
`public/icons/`, including lite packages. The install button appears only for a live browser install offer and hides
in standalone/fullscreen windows or after installation. Every offer is consumed before awaiting the prompt, including
cancellation or failure. No service worker, offline cache or persistent server is introduced; HTTPS or localhost and
browser support still govern installation.
### Skill-mode sound and delayed models (§28.17, §28.22)
The audio manifest can carry `attacks` / `hits` plus independent `attackMix` / `hitMix` by skill index. Only an active
skill uses a mode bank. Impacts remember the mode at the time of the attack; an end event cancels a queued start for
an unknown unit, and a late-join snapshot restores the current mode. A mode without a bank falls back to its normal
sound. A selected mode with no mix uses its own default volume/probability, not the ordinary bank's mix.
A model that arrives after an actual deployment continues Start at the elapsed game time; after the clip's duration it
shows the current base pose. A subsequent attack/death supersedes that pending entrance. No extra deployment sound
or per-phase animation schedule is added by this change.
+13
View File
@@ -189,3 +189,16 @@ and marks. Leaving clears that source's state without clearing another still-dep
凯尔希·思衡托 S2 uses its mixed attack/heal target condition for automatic activation: either an injured, healable ally
or a selectable enemy in its expanded running range; operation cooldown and other controls still apply (§28.10).
### Additional DEFAULT trigger targets (§28.13)
`addTriggerRange(fn, { attackOnly: true })` extends the DEFAULT target check at the owner's next attack opportunity.
It does not create a per-tick trigger. 蕾缪安 S1–S3 use only their own wanted targets; 死芒 S1/S2 use targets blocked by
living summons owned by that operator. Sleeping or otherwise unselectable enemies do not count. This only changes
the condition for activation: 死芒 S2 still links inside its own effect range. Other registered summon areas retain
the existing per-tick behavior. The optional flag defaults to false for compatibility.
0.2.3 echo/counter interaction (DESIGN §28.24): recursively earned “余音” pulses are drained FIFO by that echo in the
same call, retaining the earned attack value and the lethal-hit pulse. [ASSUMED] This local order avoids exhausting
the hook-depth guard on scaled hit-count HP; the guard remains unchanged for other recursive handlers.
+22
View File
@@ -117,3 +117,25 @@ Official rule (bwiki 盟约, 更新公告 5114): before a match the player canno
**自选编队 (0.2.0):** the overlay's third tab fills the four DIY slots (5阶 ×2, 6阶 ×2) with an owned 6★ outside the chess pool (any of its three skills, any module of its elite form but a 集成战略 one) or a prototype (its locked skill / module); only operators with a kit are offered (`welcome.diyKitted`). Per-browser `sp.pref.diy` = `{ v, picks }`, synced as `room.diy { picks }` by the same sync engine; 导出 / 导入 as `{ kind: 'stronghold.diy', v, exportedAt, count, picks }` (`ui/diyModel.js`, `screens/diy.js`). In the match a slotted piece is the operator for its player (name, art, class, bonds from its factions, no 特质, the pick's skill and module; the slot's tier, price and merge), sold only in that player's shop from the slot's 调度中心 level with its own stock; the shop / reward / detail cards and the own pieces carry a 「自选」 badge (`ui/gameLogic/diy.js`); the 0.2.0 自选编队 design subsection has the rules and sources.
---
### Cooperative buying, reactions and opening rerolls (§28.15–16, §28.21)
[ASSUMED] Bot purchasing applies one soft 18-point penalty to non-elite, non-DIY tier V/VI shared-pool cards whose
active bonds overlap another living teammate's opening strategy. Human and AI teammates are treated equally; it
neither reserves stock nor prevents a stronger purchase. Existing strategy presets and rehearsal counts are unchanged.
[ASSUMED] AI reactions are cosmetic social choices, enabled by default and disabled with `SP_BOT_EMOTES=0`. Only
mixed human/AI matches emit them, through the existing whitelist and cooldown. A human emote gets at most one eligible
bot reply. Settlement uses that round's results (kills then damage, stable seat tie-break), once per round; the third
chess merge reacts once and resets at settlement. Gifts are classified by trusted source playerId, never display name.
These hooks consume no gameplay RNG and schedule no game actions.
[ASSUMED] During INFO_CHECK the host may request an opening reroll; all connected human participants must explicitly
agree, including autoplay humans. Bots and spectators do not vote; one human refreshes immediately. A vote pauses the
confirmation timer and blocks Ready. Rejection, host cancellation, disconnect or leaving cancels it and restores the
remaining time / prior Ready state. Success preserves the room, seats, settings and connections, redraws the complete
opening, resets Ready, and starts the full original timer (single-human matches remain untimed). Fixed stage/bans
remain fixed and random results can repeat. A separate seed-derived stream leaves shop/draft RNG untouched; requests
have a 300 ms anti-burst guard, revision and vote identities. All new setup data and player DIY stocks are prepared
before mutation; data preparation failure leaves the old opening intact. This is a remake convenience, not an official
mode rule. Unrelated result-screen changes from PR #408 are not included.
+11 -1
View File
@@ -15,7 +15,7 @@ Platform additions (implemented): `welcome.{resumed, version}`, `welcome.diyKitt
**Spectator seats** (a remake feature — the official room has none; community report #26, owner's decision 2026-10-04; the spectator-seat subsection of §23): `room.spectate {code}` takes one of a co-op room's `MAX_SPECTATORS` (2) spectator seats, in its lobby or during its match; `room.removeSpectator {playerId}` (host, at any time — the room screen's strip and, once the match runs, the game screen's 观战席 capsule in the top bar, GitHub #120) frees one (`room.closed {reason:'kicked'}` to it, no match frame after it); `room.state.spectators: [{playerId, name, connected}]` (still pushed while the match runs). A spectator is never a player (not in `seats`, not counted for the start, never host, keeps no room alive — `room.closed {empty}` when the last human leaves), receives the room's broadcasts and watches like an eliminated player (`Match.addSpectator`: `b.start` watch / `m.field`, never `m.private`); it may only `g.watch`, leave, and store a `room.loadout` — anything else answers `error SPECTATOR`. Its seat is kept and given back on reconnect like a player's.
### 8.2 Match
C→S (all carry `rid`): `g.infoReady` · `g.band {bandId}` · `g.bandSkip` · `g.bandFocus {bandId?}` (the strategy highlighted in the draft screen — what a timed-out turn takes; absent / null clears it; `WRONG_PHASE` outside BAND_DRAFT, `ALREADY` after the pick, `BAD_TARGET` for a band not allowed; §18.2) · `g.buy {slot}` · `g.refresh` · `g.freeze` · `g.levelUp` · `g.sell {uid}` · `g.move {uid, to:{area:'board', row, col}|{area:'hand', idx}, dir?}` · `g.equip {itemUid, targetUid, replaceUid?}` (`replaceUid`: which equipped item a third one replaces, §6.2) · `g.art {itemUid, row, col, dir?}` (`dir` ∈ UP|RIGHT|DOWN|LEFT from the deploy wheel, §3; absent ⇒ `to.dir`, then RIGHT; `g.move` onto the piece's own tile re-orients it; board pieces in `m.private` / `m.field {prep:true}` carry `dir`) · `g.destroy {uid}` (hand / temp items only; equipped items are locked, §6.2) · `g.reward {idx}` · `g.choice {idx, choiceId?}` (with an ID: the recipient's PREP personal choice; without one: the public SP_DRAFT; stale or wrong IDs never fall through to the public draft) · `g.ready {ready}` · `g.emote {id}` · `g.watch {fieldId, playerId?}` (`playerId`: the player tapped — a shared 联防 / boss pair field shows two; what an eliminated viewer follows, §25) · `g.autoplay {on}` · `g.pause {on}` (solo battles only, §14 Solo pause) · `g.unitStats {seq?}` (ROUND_START / SP_DRAFT / PREP, else `WRONG_PHASE`; eliminated: `ELIMINATED`) → push `m.unitStats` (§18.5) · `g.leave`
C→S (all carry `rid`): `g.infoReady {setupRevision?}` · `g.band {bandId}` · `g.bandSkip` · `g.bandFocus {bandId?}` (the strategy highlighted in the draft screen — what a timed-out turn takes; absent / null clears it; `WRONG_PHASE` outside BAND_DRAFT, `ALREADY` after the pick, `BAD_TARGET` for a band not allowed; §18.2) · `g.buy {slot}` · `g.refresh` · `g.freeze` · `g.levelUp` · `g.sell {uid}` · `g.move {uid, to:{area:'board', row, col}|{area:'hand', idx}, dir?}` · `g.equip {itemUid, targetUid, replaceUid?}` (`replaceUid`: which equipped item a third one replaces, §6.2) · `g.art {itemUid, row, col, dir?}` (`dir` ∈ UP|RIGHT|DOWN|LEFT from the deploy wheel, §3; absent ⇒ `to.dir`, then RIGHT; `g.move` onto the piece's own tile re-orients it; board pieces in `m.private` / `m.field {prep:true}` carry `dir`) · `g.destroy {uid}` (hand / temp items only; equipped items are locked, §6.2) · `g.reward {idx}` · `g.choice {idx, choiceId?}` (with an ID: the recipient's PREP personal choice; without one: the public SP_DRAFT; stale or wrong IDs never fall through to the public draft) · `g.ready {ready}` · `g.emote {id}` · `g.watch {fieldId, playerId?}` (`playerId`: the player tapped — a shared 联防 / boss pair field shows two; what an eliminated viewer follows, §25) · `g.autoplay {on}` · `g.pause {on}` (solo battles only, §14 Solo pause) · `g.unitStats {seq?}` (ROUND_START / SP_DRAFT / PREP, else `WRONG_PHASE`; eliminated: `ELIMINATED`) → push `m.unitStats` (§18.5) · `g.leave`
S→C:
- `m.public` — full public state on every change (throttled ≤ 10/s): `{ phase, round, lastRound, deadline, serverNow, modeId, difficulty, stageId, factions, disabledBonds, bannedChess, bossId, hiddenBossId?, teamLp?, bossHp?:{hp,max}, overtimeAt? /* FA / HC: ms epoch the overtime drain starts, §4 */, paused /* solo pause, §14 */, draft?:{order,turn,picks,skipsLeft,turnDeadline,turnSeconds /* one turn's length, 0 untimed; deadline = turnDeadline (§18.2) */,untimed}, sp?:{family,cards /* bounty cards carry descRaw?, §20.6 */,turn,picks}, players:[{playerId, seat, name, isBot, connected, alive, lp, bandId, shopLevel, boardCount, ready, bonds:[{bondId,count,active,tier,layers,harmony? /* 调和's +1 is in count, §21.26 */}], fieldId, status, pendingLp? /* COMBAT / 联防 of a normal round: the LP the player's own battle will cost at settlement so far, min(lpCapPerRound, counted leaks) — in 联防 min(lpCapPerRound, uniteLeft) — omitted when 0 and in every other phase (§17.5 live LP) */, uniteLeft? /* 联防: the leaker's enemies still standing — not spawned yet + alive + counted leaks, uncapped, live (§20.6) */}] , fields:[{fieldId, kind, players:[playerId...], progress?:{killed, resolved, total, done} /* §14 顶栏胶囊: resolved = the field's own scheduled enemies knocked down or leaked, null while unknown */}] }` · `m.public.uniteResult` — SETTLE after a 联防 only, its outcome as data: `{ through /* the leakers' enemies that still got through, uncapped */, helpers:[playerId...], leakers:[playerId...], losses:{playerId: the LP settlement charged this round — every alive player} }`; absent when no 联防 resolved (the client's result box then reads the round's own battle; GitHub #235, PR #112)
- `m.private` — the recipient's full PlayerState view (§6.2 minus server-only data) on every change; in a boss round's prep also `bossMate { playerId, side, units }` — the pair partner's board on its half of the boss field (UnitInfo, mirrored on the right half), re-sent when the partner moves (0.2.0, community report item 51).
@@ -117,3 +117,13 @@ Piece = { uid, kind: 'chess'|'item'|'token', id, golden: bool, tier, items: [ {
- Emotes: 6 themes × 6 battle emotes (research 09 §4.2), image-only bubbles beside the sender avatar, 1 s cooldown, 3 s bubble; `g.emote {id}` with the official emoji id.
---
### Opening reroll messages and local seat recovery
C→S adds host-only `room.rerollSetup {setupRevision}` / `room.cancelReroll {voteId}` and participant
`g.rerollVote {voteId, agree}`. `m.public` carries `setupRevision` and nullable `rerollVote {id, proposerId, voters, agreed}`.
Ready must reference the current revision; omission means revision 0 for old clients. The server blocks Ready while
voting, refuses stale ids and cancels on disconnect/leave; DESIGN §28.21 / META.md describe the complete contract.
Explicit local recovery (§28.20) reuses saved hello tokens after a fresh live-tab claim check. Display names and room
codes are UI metadata only, never authentication. No new network message or nickname/IP matching is introduced.
+157
View File
@@ -187,3 +187,160 @@ assertion is corrected to require actual healing, and development builds retain
新增真实子进程继承 stderr 的回归,以及关闭失败/信号退出的边界检查;验收要求原异常文件单独重跑并确认正常结束。首轮异常与复测结果分别保留在本轮验收记录中。
同一轮的版本重连测试在刻意停服时,Chrome 可能报告连接拒绝、连接重置或未连接的 socket。测试只在主动停服至恢复握手的窗口内排除这些已知传输日志;正常运行期间仍检查它们,脚本异常始终保留,所有版本指纹、重连及换局刷新断言不变。
### 28.13 Special targets respect DEFAULT attack timing — #438–440
Seen: 死芒's normal attack reaches enemies blocked by his own summons, but S1/S2 did not open for those targets.
The earlier 蕾缪安 fix also registered a per-tick trigger, allowing an early cast before her attack countdown elapsed.
The [PRTS mode help](https://prts.wiki/w/卫戍协议/帮助) describes DEFAULT activation at the next ordinary attack.
`addTriggerRange` now accepts `attackOnly`; both operators use it, while existing summon-area triggers retain their
per-tick behavior. Only live owned summons / wanted marks contribute selectable targets. S2's actual link range is
unchanged. PR #438 by @YinqiMargatroid addresses the same wanted-target symptom; the existing registration API is
extended instead of introducing a second targeting flag. SIM.md and design/engine.md contain the current contract.
Tests: real summoned-bone blocking, S1/S2 and united fields, attack countdown, sleep/ownership, wanted-target S1–S3,
plus existing 令 / 麦哲伦 trigger regressions. The combined operator checks pass 27 tests; the additional area-trigger
and champagne controls also pass. Golden movement is recorded below.
### 28.14 Interface text size — #435 / PR #446
Adapted from @7eLaryn: four persisted text sizes use `--t` without changing the rem layout root. All shipped language
packs retain their current translations. Added the missing explanatory note and fixed English XL settings overflow:
segmented controls and header actions wrap within their available width. Long briefing alliance labels size and wrap
with their cards instead of overlapping neighboring names. Default sm preserves the previous sizes.
Tests: settings / text-unit checks, phone landscape growth and unchanged board/canvas/shop geometry, reload persistence,
and XL settings in Chinese, English, Japanese, Korean and Traditional Chinese. The first multilingual check exposed
English overflow; the corrected browser suite passes. Current behavior is in design/client.md and PLAYING.md.
### 28.15 Cooperative card purchasing — PR #407
Adapted from @IceCodeNew. [ASSUMED] A tier V/VI shared-pool card matching another living teammate's active opening
bond receives one 18-point buying-score penalty. It is a soft preference, not reserved stock; normal/elite and DIY
stock semantics remain unchanged. Humans and bots receive the same cooperation. This does not add persona presets.
Tests: 14 targeted bot tests; 40-seed solo:HARD and coop4:HARD before/after benchmark is part of integration evidence.
The comparison includes the special-target timing changes of §28.13, so it must not be interpreted as an isolated
measurement of the purchasing heuristic. Current rule: design/match.md §6.6 and its follow-up contract.
### 28.16 AI teammate emotes — PR #444
Event wiring from @DDDarkstar, with corrected dispatch semantics. [ASSUMED] Cosmetic reactions are enabled only in
mixed human/AI matches, under the existing emote whitelist and cooldown; `SP_BOT_EMOTES=0` disables all hooks.
Responses skip cooling bots and stop after one reply. MVP uses this round's kills and damage, with stable order for
ties; settlement reacts once per round. Only the third chess merge reacts, then resets for the next round. A gift uses
its source playerId rather than a display-name heuristic. No RNG, gameplay decisions or timers are introduced.
Tests cover whitelist/cooldown, no human impersonation or recursive AI reply, pure-AI silence, round statistics,
settlement/merge limits, gift identities, the disable switch and actual emote/acquisition hooks. README, META.md and
design/match.md document the switch and boundary.
### 28.17 Active skill attack and impact sounds — PR #410
Adapted from @Convey123's updated PR. The current official `audio_data.json` gives 司霆惊蛰 mode attack banks and
赤刃明霄陈 S3 separate swing/impact banks. The generator adds per-slot `attacks` / `hits`, each with its own optional
`attackMix` / `hitMix`. It preserves the selected bank's chance and volume rather than applying the normal attack's
mix. A late impact keeps the mode at launch; an end before spawn cancels a pending start. Snapshot restoration and
ordinary-mode fallback remain supported. JP voices and current roster assets survive regeneration.
[ASSUMED] Where no activation sound names the mode, slot 1/2/3 is inferred as d/h/s. Bank selection prefers START to
ON and then plain/numeric order; this approximates an ability graph and is not independently established for every
operator. Mixed-letter banks are rejected. Instant effects that emit no normal attack event remain outside this
mechanism. ASSETS.md and design/client.md specify the manifest/runtime contract.
Tests: 85 audio, manifest, official-mix and JP-voice checks, including independent mode gain, delayed impacts,
end-before-spawn, late join and field reset. Regeneration is additive; no existing manifest leaf was removed. Four
unresolved upstream candidate icon/avatar leaves still make strict asset fetching report failure; they are not new
sound failures or deletions of existing entries. Packaging must retain this limitation visibly.
### 28.18 Per-operator voice language — #436
Roster details and selected DIY cards offer 中文 / 日本語 / 跟随全局. Settings persist a sanitized charId map locally;
normal/elite/DIY forms of that charId share the choice. Existing profiles migrate to an empty map, so global language
continues to apply. Deleting an override restores the current global choice. Missing Japanese slots / files keep the
existing Chinese fallback. No room protocol or exported combat-loadout data changes.
Tests cover malformed/inherited entries, persistence, changing the global default, deleting an override and fallback;
a real phone-sized browser changes the control, reloads and resets it. Current contract: design/client.md / PLAYING.md.
### 28.19 Browser installation — PR #413
Based on @Cromemadnd's proposal, with a one-use install controller. Buttons appear only after a browser install offer;
standalone/fullscreen and installed sessions hide them. Accept, dismiss, failure and duplicate clicks all consume the
same offer once. See [the prompt API](https://developer.mozilla.org/en-US/docs/Web/API/BeforeInstallPromptEvent/prompt)
and [installability requirements](https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Guides/Making_PWAs_installable).
The existing original fortress favicon supplies correctly sized 192/512 px icons, shipped outside optional game art.
No service worker or offline cache is installed; the server is still required.
Tests: offer lifecycle/disposal, actual PNG dimensions, real Chrome manifest and installability checks, and a synthetic
dismissed offer through the actual button. Initial incognito rejection was a test-environment limitation; the real
installability check runs in Chrome's ordinary temporary profile. Native OS installation itself is not performed.
### 28.20 Recover a closed local match window — #431
Reproduced with an idle window retaining its own session while a second match window closes. The title/lobby now
lists local saved match seats using bounded metadata beside the existing recent-token ring. Explicit selection asks
live tabs to claim the chosen token again before adopting it and reloading. An active tab is never deliberately
replaced; names/IP are not authentication. Missing BroadcastChannel refuses recovery. A server-generated replacement
token or leaving clears obsolete metadata. No server API changes; normal automatic startup selection is preserved.
Tests: live tab protection, closed-tab recovery, simultaneous claim arbitration, no channel/unknown id, malformed
storage and expired metadata; a real two-window browser restores the identical server playerId and room after closing
the match tab. The existing identity/static suite also passes. Current contract: design/client.md and network.md.
### 28.21 Unanimous opening reroll — #77 / PR #408
Adapted from @Siornya, excluding the unrelated result-screen redesign. [ASSUMED] This remake feature is host-proposed,
requires every connected human's consent, and pauses confirmation while voting. Bots/spectators do not vote; autoplay
humans still do. Single-human matches refresh immediately. Refusal, cancellation, disconnect or leave restores the
old setup and remaining confirmation time. Success preserves room/seats/settings, resets Ready and restarts the full
normal timer; fixed stages/bans remain fixed. The random result may repeat.
A dedicated seed-derived reroll stream does not advance the original shop/draft/meta streams. A 300 ms anti-burst guard,
setup revision and vote id reject stale actions. Opening data and every player's DIY stock are prepared before any
mutation; a forced failure on the second player's stock leaves all previous state intact. Current contracts are in
META.md, design/match.md, design/network.md and PLAYING.md; all four translation packs have the new messages.
Tests: 13 match checks including real four-player sockets, permission/spectator checks, cancellation/disconnect,
reconnect, last-second and queued-Ready timers, outdated messages, one human with bots, fixed rules, fuzzed requests
and transactional failure. Final browser/integration validation supplements these targeted checks.
### 28.22 Late-loading deployment animation — part of PR #416
@Cromemadnd's proposal exposed a real missing case: onDeploy arrived before the model, so Start was never played.
The view now retains elapsed game time until the model arrives and continues the existing clip at that point. A clip
already over is not restarted; an intervening attack/death cancels the pending entrance. Existing Front/Back transfer
and single-source deployment audio remain unchanged. The proposed repeated phase/purchase sound sequence is not part
of this fix: its timing and duplication need separate visual evidence.
Tests: delayed load, simulation rate, load after clip end, attack/death supersession, destroyed view and existing
UnitView behavior, 48 checks passing. No simulation/golden change from this visual fix.
### 28.23 Follow-up golden movement
Recomputed all 285 scenarios from the integrated code; never copied PR baselines. The DEFAULT cadence correction moves
`roster-016`, `roster-035`, `roster-036`, `roster-045`; paired fields `boss-boss_1-pair`, `boss-boss_3-pair`,
`boss-boss_5-pair`, `boss-boss_7-pair`, `hidden-boss_9-pair`; and match `solo-HARD-9-boosted`. 死芒's added activation
condition moves `diy-132`. `coop2-NORMAL-14-standins` includes both cooperative purchasing and the corrected simulation.
The pulse queue correction of §28.24 additionally moves `hidden-boss_10-solo`. The bonds and standins families are unchanged. Cosmetic emotes, preferences, installation and opening rerolls that
are not requested do not intentionally alter those games. The validation record lists any outcome flips and measured
benchmark changes; the independent final review remains pending.
### 28.24 Echo / counter recursion found by the cooperative benchmark
The 40-seed benchmark exposed existing hook-depth errors in co-op seeds 3 and 29, plus seed 14 after the purchasing
change. In all three, “余音” damage pulses and 刺玫 / 泡泡 counters synchronously called each other. A scaled echo can
have 67 hit-count HP; the generic 32-hook guard cut off effects before the legitimate chain finished. The same chain
could also enter another hit while zero HP was waiting for death bookkeeping.
[PRTS “余音”](https://prts.wiki/w/“余音”) establishes the hit-count and damage-pulse behavior. [ASSUMED] The exact nested
ordering is not documented: each echo now queues recursively earned pulses and resolves them FIFO in the same call,
after the current pulse finishes. Every earned pulse retains its attack value, including the lethal hit's pulse. No
extra tick, RNG draw, global hook-depth increase or arbitrary pulse suppression is introduced. Death prevents further
counter targeting normally. This local scheduling choice needs final fidelity review.
Tests use real elite 刺玫 and 泡泡 against 67-hit echoes: all 67 pulses resolve, the echo dies, no engine error is logged,
and the queue clears. Both tests fail against the previous implementation. Together with the existing enemy/boss and
刺玫 healing suites, 273 tests pass. The benchmark now writes actual diagnostic messages beside nonzero error counts,
so failures cannot be mistaken for ordinary performance results. Recomputed golden and final benchmark results are
recorded with the candidate; do not reuse the earlier, erroneous benchmark as a clean result.
+41 -41
View File
@@ -27,7 +27,7 @@
border: 1px solid var(--btn-bd);
background: var(--btn-bg);
color: var(--btn-fg);
font-size: .18rem;
font-size: calc(.18 * var(--t));
font-weight: 700;
letter-spacing: .06em;
line-height: 1;
@@ -164,9 +164,9 @@
.btn.is-loading { cursor: default; --btn-fg: var(--text-lo); }
.btn--primary.is-loading { --btn-bg: var(--mint-700); --btn-fg: var(--text-on-mint); }
.btn--sm { height: .36rem; min-width: .36rem; padding: 0 .16rem; font-size: .15rem; gap: .07rem; }
.btn--lg { height: .64rem; min-width: .64rem; padding: 0 .34rem; font-size: .22rem; gap: .12rem; }
.btn--xl { height: .84rem; min-width: .84rem; padding: 0 .48rem; font-size: .28rem; letter-spacing: .24em; gap: .16rem; }
.btn--sm { height: .36rem; min-width: .36rem; padding: 0 .16rem; font-size: calc(.15 * var(--t)); gap: .07rem; }
.btn--lg { height: .64rem; min-width: .64rem; padding: 0 .34rem; font-size: calc(.22 * var(--t)); gap: .12rem; }
.btn--xl { height: .84rem; min-width: .84rem; padding: 0 .48rem; font-size: calc(.28 * var(--t)); letter-spacing: .24em; gap: .16rem; }
.btn--block { display: flex; width: 100%; }
.btn--square { padding: 0; width: var(--sq, .5rem); }
.btn--square.btn--sm { --sq: .36rem; }
@@ -204,7 +204,7 @@
border-bottom: 1px solid var(--line);
}
.panel__titles { display: flex; flex-direction: column; gap: .03rem; min-width: 0; }
.panel__title { font-size: .21rem; font-weight: 700; letter-spacing: .04em; }
.panel__title { font-size: calc(.21 * var(--t)); font-weight: 700; letter-spacing: .04em; }
.panel__actions { display: flex; gap: .08rem; align-items: center; }
.panel__body { position: relative; padding: .22rem; min-height: 0; }
.panel--flush .panel__body { padding: 0; }
@@ -472,9 +472,9 @@
opacity: .55;
}
.modal__head { display: flex; flex-direction: column; gap: .04rem; padding: .22rem .3rem .04rem; }
.modal__title { font-size: .26rem; font-weight: 700; letter-spacing: .04em; }
.modal__title { font-size: calc(.26 * var(--t)); font-weight: 700; letter-spacing: .04em; }
.modal__body { padding: .14rem .3rem .26rem; color: var(--text-md); overflow: auto; }
.modal__text { font-size: .18rem; line-height: 1.7; }
.modal__text { font-size: calc(.18 * var(--t)); line-height: 1.7; }
.modal__actions {
flex: none;
display: flex;
@@ -500,7 +500,7 @@
border-left: 2px solid var(--mint-500);
box-shadow: 0 .06rem .2rem rgba(0, 0, 0, .55);
color: var(--text-md);
font-size: .15rem;
font-size: calc(.15 * var(--t));
line-height: 1.55;
pointer-events: none;
opacity: 0;
@@ -515,8 +515,8 @@
/* ---- progress bar ----------------------------------------------------------------------------------- */
.pbar { --pc: var(--mint-500); --ph: .1rem; display: flex; flex-direction: column; gap: .05rem; min-width: .8rem; }
.pbar__meta { display: flex; justify-content: space-between; align-items: baseline; gap: .1rem; font-size: .14rem; color: var(--text-lo); }
.pbar__value { font-family: var(--font-num); font-weight: 700; color: var(--text-hi); font-size: .16rem; }
.pbar__meta { display: flex; justify-content: space-between; align-items: baseline; gap: .1rem; font-size: calc(.14 * var(--t)); color: var(--text-lo); }
.pbar__value { font-family: var(--font-num); font-weight: 700; color: var(--text-hi); font-size: calc(.16 * var(--t)); }
.pbar__value small { color: var(--text-dim); font-size: .8em; }
.pbar__track { position: relative; height: var(--ph); background: #080a09; border: 1px solid var(--line); overflow: hidden; }
.pbar__fill {
@@ -549,7 +549,7 @@
border: 0;
background: none;
color: var(--text-lo);
font-size: .17rem;
font-size: calc(.17 * var(--t));
font-weight: 500;
letter-spacing: .04em;
transition: color var(--t-fast) var(--ease-out), background-color var(--t-fast);
@@ -570,11 +570,11 @@
}
.tabs__tab.is-active { color: var(--mint-400); }
.tabs__tab.is-active::after { transform: scaleX(1); }
.tabs__badge { font-family: var(--font-num); font-size: .13rem; padding: .01rem .06rem; background: var(--bg-3); color: var(--text-md); }
.tabs--sm .tabs__tab { font-size: .15rem; padding: .07rem .16rem .09rem; }
.tabs__badge { font-family: var(--font-num); font-size: calc(.13 * var(--t)); padding: .01rem .06rem; background: var(--bg-3); color: var(--text-md); }
.tabs--sm .tabs__tab { font-size: calc(.15 * var(--t)); padding: .07rem .16rem .09rem; }
/* ---- spinner -------------------------------------------------------------------------------------- */
.spinner { --sp: .36rem; --sc: var(--mint-500); display: inline-flex; align-items: center; gap: .1rem; color: var(--text-lo); font-size: .15rem; }
.spinner { --sp: .36rem; --sc: var(--mint-500); display: inline-flex; align-items: center; gap: .1rem; color: var(--text-lo); font-size: calc(.15 * var(--t)); }
.spinner svg { width: var(--sp); height: var(--sp); animation: spin 1.6s linear infinite; overflow: visible; }
.spinner__track { fill: none; stroke: var(--line-2); stroke-width: 3; }
.spinner__arc { fill: none; stroke: var(--sc); stroke-width: 3; stroke-dasharray: 34 98; animation: spinner-dash 1.1s var(--ease-in-out) infinite; filter: drop-shadow(0 0 3px var(--sc)); }
@@ -582,7 +582,7 @@
.spinner--lg { --sp: .56rem; }
.spinner--gold { --sc: var(--gold); }
.spinner--amber { --sc: var(--amber); }
.spinner__label { font-family: var(--font-display); letter-spacing: .2em; font-size: .12rem; text-transform: uppercase; }
.spinner__label { font-family: var(--font-display); letter-spacing: .2em; font-size: calc(.12 * var(--t)); text-transform: uppercase; }
@keyframes spinner-dash { 0% { stroke-dashoffset: 0; } 100% { stroke-dashoffset: -132; } }
/* ---- avatar frame ---------------------------------------------------------------------------------- */
@@ -710,9 +710,9 @@
animation: pb-sweep 700ms 250ms var(--ease-out) forwards;
opacity: 0;
}
.pbanner__micro { font-family: var(--font-display); font-size: .12rem; letter-spacing: .5em; color: var(--pb); padding-left: .5em; }
.pbanner__micro { font-family: var(--font-display); font-size: calc(.12 * var(--t)); letter-spacing: .5em; color: var(--pb); padding-left: .5em; }
.pbanner__title {
font-size: .56rem;
font-size: calc(.56 * var(--t));
font-weight: 900;
letter-spacing: .16em;
padding-left: .16em;
@@ -720,7 +720,7 @@
color: #fff;
text-shadow: 0 0 .22rem var(--pb-glow), 0 2px 0 rgba(0, 0, 0, .5);
}
.pbanner__sub { font-size: .16rem; color: var(--text-lo); letter-spacing: .12em; }
.pbanner__sub { font-size: calc(.16 * var(--t)); color: var(--text-lo); letter-spacing: .12em; }
.pbanner__chev { --ch-h: .42rem; height: .42rem; flex: none; }
.pbanner--inline .pbanner__band { animation-duration: var(--t-slow); }
.pbanner.is-leaving .pbanner__band { animation: pb-close 240ms var(--ease-in-out) forwards; }
@@ -783,7 +783,7 @@
.rdialog__tick--br { bottom: 8px; right: 8px; border-left: 0; border-top: 0; }
.rdialog__micro {
font-family: var(--font-display);
font-size: .13rem;
font-size: calc(.13 * var(--t));
letter-spacing: .48em;
padding-left: .48em;
color: var(--rd);
@@ -811,7 +811,7 @@
animation: rd-sweep 780ms 300ms var(--ease-out) forwards;
}
.rdialog__title {
font-size: .62rem;
font-size: calc(.62 * var(--t));
font-weight: 900;
letter-spacing: .14em;
padding-left: .14em;
@@ -821,7 +821,7 @@
}
.rdialog__chev { --ch-h: .46rem; height: .46rem; flex: none; }
.rdialog__sub {
font-size: .18rem;
font-size: calc(.18 * var(--t));
letter-spacing: .1em;
color: var(--text-lo);
text-align: center;
@@ -834,7 +834,7 @@
@keyframes rd-sweep { 0% { left: -25%; opacity: 1; } 100% { left: 115%; opacity: 0; } }
@media (max-width: 720px) {
.rdialog__box { min-width: 0; width: 92vw; padding: .26rem .3rem .22rem; }
.rdialog__title { font-size: .42rem; }
.rdialog__title { font-size: calc(.42 * var(--t)); }
.rdialog__chev { --ch-h: .34rem; height: .34rem; }
}
@media (prefers-reduced-motion: reduce) {
@@ -854,29 +854,29 @@
border: 1px solid var(--line);
color: var(--pc);
font-family: var(--font-num);
font-size: .18rem;
font-size: calc(.18 * var(--t));
font-weight: 700;
line-height: 1;
font-variant-numeric: tabular-nums;
}
.ping__icon { width: .15rem; height: .15rem; margin-right: .03rem; }
.ping__unit { font-size: .13rem; color: var(--text-lo); font-weight: 500; }
.ping__unit { font-size: calc(.13 * var(--t)); color: var(--text-lo); font-weight: 500; }
.ping--low { --pc: var(--mint-400); }
.ping--medium { --pc: var(--amber); }
.ping--high { --pc: var(--red-premium); }
.ping--off { --pc: var(--text-dim); }
/* ---- difficulty tag ---------------------------------------------------------------------------------- */
.dtag { --d-color: var(--amber); display: inline-flex; align-items: center; gap: .06rem; color: var(--d-color); font-weight: 700; font-size: .18rem; letter-spacing: .04em; white-space: nowrap; }
.dtag { --d-color: var(--amber); display: inline-flex; align-items: center; gap: .06rem; color: var(--d-color); font-weight: 700; font-size: calc(.18 * var(--t)); letter-spacing: .04em; white-space: nowrap; }
.dtag__icon { width: 1.15em; height: 1.15em; filter: drop-shadow(0 0 4px var(--d-color)); filter: drop-shadow(0 0 4px color-mix(in srgb, var(--d-color) 65%, transparent)); }
.dicon { object-fit: contain; image-rendering: auto; }
.dtag__code { font-family: var(--font-num); font-size: .72em; font-weight: 500; color: var(--text-dim); letter-spacing: .1em; margin-left: .03rem; }
.dtag--sm { font-size: .15rem; }
.dtag--lg { font-size: .24rem; }
.dtag--sm { font-size: calc(.15 * var(--t)); }
.dtag--lg { font-size: calc(.24 * var(--t)); }
/* ---- text field ------------------------------------------------------------------------------------ */
.field { display: flex; flex-direction: column; gap: .07rem; min-width: 0; }
.field__label { display: flex; align-items: baseline; gap: .1rem; font-size: .15rem; color: var(--text-lo); font-weight: 500; letter-spacing: .04em; }
.field__label { display: flex; align-items: baseline; gap: .1rem; font-size: calc(.15 * var(--t)); color: var(--text-lo); font-weight: 500; letter-spacing: .04em; }
.field__box {
--bk-c: var(--line-3);
--bk-l: .1rem;
@@ -899,15 +899,15 @@
border: 0;
outline: none;
color: var(--text-hi);
font-size: .2rem;
font-size: calc(.2 * var(--t));
letter-spacing: .04em;
}
.field__input::placeholder { color: var(--text-dim); font-weight: 400; letter-spacing: .04em; }
.field__icon { width: .22rem; height: .22rem; color: var(--text-dim); }
.field__box:focus-within .field__icon { color: var(--mint-500); }
.field__hint { font-size: .13rem; color: var(--text-dim); }
.field__hint { font-size: calc(.13 * var(--t)); color: var(--text-dim); }
.field--lg .field__box { height: .68rem; }
.field--lg .field__input { font-size: .26rem; font-weight: 700; }
.field--lg .field__input { font-size: calc(.26 * var(--t)); font-weight: 700; }
.field--code .field__box { height: .64rem; }
.field--code .field__input { font-family: var(--font-num); font-size: .34rem; font-weight: 700; letter-spacing: .42em; text-transform: uppercase; }
.field--code .field__input::placeholder { font-family: var(--font-cjk); font-size: .18rem; letter-spacing: .06em; font-weight: 400; text-transform: none; }
@@ -943,14 +943,14 @@
border-left: 3px solid var(--tc);
box-shadow: 0 .08rem .24rem rgba(0, 0, 0, .55), -.04rem 0 .16rem transparent; box-shadow: 0 .08rem .24rem rgba(0, 0, 0, .55), -.04rem 0 .16rem color-mix(in srgb, var(--tc) 25%, transparent);
color: var(--text-hi);
font-size: .17rem;
font-size: calc(.17 * var(--t));
line-height: 1.45;
cursor: pointer;
animation: toast-in var(--t-med) var(--ease-out);
}
.toast__icon { width: .2rem; height: .2rem; fill: var(--tc); flex: none; }
.toast__text { flex: 1; }
.toast__count { font-family: var(--font-num); font-size: .14rem; color: var(--text-lo); }
.toast__count { font-family: var(--font-num); font-size: calc(.14 * var(--t)); color: var(--text-lo); }
.toast--success { --tc: var(--mint-400); }
.toast--warn { --tc: var(--amber); }
.toast--error { --tc: var(--red-premium); }
@@ -973,7 +973,7 @@
border: 1px solid rgba(232, 90, 26, .55);
box-shadow: 0 .08rem .3rem rgba(0, 0, 0, .6), 0 0 .2rem rgba(232, 90, 26, .15);
color: var(--text-hi);
font-size: .16rem;
font-size: calc(.16 * var(--t));
/* own keyframes: toast-in's transform would drop the translateX(-50%) centring mid-animation */
animation: conn-in var(--t-med) var(--ease-out);
}
@@ -990,11 +990,11 @@
.sp-in-match .conn-banner { top: calc(1.34rem + var(--sa-t, 0px)); bottom: auto; }
.sp-in-match.sp-conn .toast-host { top: calc(2.0rem + var(--sa-t, 0px)); }
.conn-banner .icon { width: .22rem; height: .22rem; color: var(--orange-hi); }
.conn-banner__sub { color: var(--text-lo); font-family: var(--font-num); font-size: .15rem; }
.conn-banner__sub { color: var(--text-lo); font-family: var(--font-num); font-size: calc(.15 * var(--t)); }
/* ---- misc shared bits ------------------------------------------------------------------------------ */
.kv { display: flex; align-items: baseline; gap: .08rem; }
.kv__k { color: var(--text-lo); font-size: .14rem; }
.kv__k { color: var(--text-lo); font-size: calc(.14 * var(--t)); }
.kv__v { font-family: var(--font-num); font-weight: 700; }
.divider { height: 1px; background: linear-gradient(90deg, transparent, var(--line-2) 15%, var(--line-2) 85%, transparent); }
.status-dot { width: .09rem; height: .09rem; border-radius: 50%; background: var(--text-dim); flex: none; display: inline-block; }
@@ -1028,16 +1028,16 @@
}
.topbar__left { justify-self: start; display: flex; align-items: center; gap: .18rem; min-width: 0; }
.topbar__center { display: flex; flex-direction: column; align-items: center; gap: .03rem; text-align: center; }
.topbar__title { display: flex; align-items: center; gap: .18rem; font-size: .3rem; font-weight: 700; letter-spacing: .1em; line-height: 1.2; }
.topbar__title { display: flex; align-items: center; gap: .18rem; font-size: calc(.3 * var(--t)); font-weight: 700; letter-spacing: .1em; line-height: 1.2; }
.topbar__sep { width: 1px; height: .28rem; background: var(--line-2); }
.topbar__right { justify-self: end; display: flex; align-items: center; gap: .16rem; min-width: 0; }
.section-label { display: flex; align-items: baseline; gap: .12rem; margin: 0 0 .14rem; font-size: .2rem; font-weight: 700; letter-spacing: .08em; }
.section-label__idx { font-size: .15rem; color: var(--mint-500); padding: 0 .06rem; border: 1px solid var(--mint-800); line-height: 1.35; letter-spacing: .06em; }
.section-label { display: flex; align-items: baseline; gap: .12rem; margin: 0 0 .14rem; font-size: calc(.2 * var(--t)); font-weight: 700; letter-spacing: .08em; }
.section-label__idx { font-size: calc(.15 * var(--t)); color: var(--mint-500); padding: 0 .06rem; border: 1px solid var(--mint-800); line-height: 1.35; letter-spacing: .06em; }
.me-chip { display: flex; align-items: center; gap: .12rem; padding: .06rem .18rem .06rem .06rem; border: 1px solid var(--line); background: rgba(0, 0, 0, .35); }
.me-chip__text { display: flex; flex-direction: column; line-height: 1.3; min-width: 0; }
.me-chip__name { font-weight: 700; font-size: .18rem; max-width: 2.4rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.me-chip__name { font-weight: 700; font-size: calc(.18 * var(--t)); max-width: 2.4rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.crash { align-items: center; justify-content: center; }
.crash__box {
@@ -1051,4 +1051,4 @@
border: 1px solid var(--line-2);
--bk-c: var(--red-premium);
}
.crash__box h2 { font-size: .3rem; }
.crash__box h2 { font-size: calc(.3 * var(--t)); }
+4 -1
View File
@@ -146,10 +146,13 @@ html:not(.sp-in-match) .toast-host { margin-top: var(--sa-t); }
.mode-cards { margin-bottom: .2rem; }
.mode-card { min-height: 0; gap: .12rem; padding: .22rem .26rem .2rem; }
.mode-card__icon { width: .66rem; height: .66rem; }
.mode-card__name { font-size: .4rem; }
.mode-card__name { font-size: calc(.4 * var(--t)); }
.mode-card__points, .diff-card__effects, .tips, .create-box__hint { display: none; }
.diff-list { gap: .1rem; }
.diff-card { padding-top: .12rem; padding-bottom: .12rem; }
.create-box { margin-top: .18rem; }
.create-box .btn--xl { height: .8rem; }
}
.rotate-hint__pwa { pointer-events: auto; margin-top: 6px; }
.rotate-hint__pwa[hidden] { display: none; }
+43 -23
View File
@@ -23,18 +23,19 @@
.stephead__left { display: flex; align-items: center; gap: .16rem; }
.stephead__meta { display: flex; flex-direction: column; gap: .06rem; align-items: flex-start; }
.stephead__center { display: flex; flex-direction: column; align-items: center; gap: .04rem; }
.stephead__title { display: flex; align-items: baseline; gap: .02rem; font-size: .34rem; font-weight: 900; letter-spacing: .12em; }
.stephead__title b { font-size: .56rem; line-height: 1; color: var(--mint-400); text-shadow: 0 0 .2rem rgba(23, 249, 183, .4); }
.stephead__of { font-size: .26rem; color: var(--text-dim); margin-right: .18rem; }
.stephead__title { display: flex; align-items: baseline; gap: .02rem; font-size: calc(.34 * var(--t)); font-weight: 900; letter-spacing: .12em; }
.stephead__title b { font-size: calc(.56 * var(--t)); line-height: 1; color: var(--mint-400); text-shadow: 0 0 .2rem rgba(23, 249, 183, .4); }
.stephead__of { font-size: calc(.26 * var(--t)); color: var(--text-dim); margin-right: .18rem; }
.stephead__right { justify-self: end; display: flex; align-items: center; gap: .24rem; }
.stephead__right .countdown { --cd-h: .64rem; }
.brief-h { display: flex; align-items: baseline; gap: .12rem; font-size: .21rem; font-weight: 700; letter-spacing: .1em; margin-bottom: .12rem; }
.brief-h { display: flex; align-items: baseline; gap: .12rem; font-size: calc(.21 * var(--t)); font-weight: 700; letter-spacing: .1em; margin-bottom: .12rem; }
.brief-h::before { content: ''; width: .06rem; height: .2rem; background: var(--mint-500); align-self: center; }
/* ---- briefing layout ---- */
.brief__main { position: relative; z-index: 1; flex: 1; min-height: 0; display: grid; grid-template-columns: 6.2rem 1fr; gap: .36rem; padding: .3rem .5rem .1rem; }
.brief__left { display: flex; flex-direction: column; gap: .2rem; min-height: 0; }
.brief__left { display: flex; flex-direction: column; gap: .2rem; min-height: 0; overflow: auto; padding-right: .06rem; }
.brief__left > * { flex-shrink: 0; }
.brief__right { display: flex; flex-direction: column; gap: .2rem; min-height: 0; overflow: auto; padding-right: .06rem; }
.brief-boss {
@@ -54,14 +55,14 @@
background: repeating-linear-gradient(0deg, rgba(0, 0, 0, .22) 0 2px, transparent 2px 4px), radial-gradient(circle, transparent 45%, rgba(40, 8, 2, .75) 100%); }
.brief-boss__q { font-size: 1rem; font-weight: 900; color: rgba(255, 110, 40, .6); }
.brief-boss__text { display: flex; flex-direction: column; gap: .08rem; padding-top: .2rem; min-width: 0; }
.brief-boss__name { font-size: .34rem; font-weight: 900; letter-spacing: .04em; line-height: 1.25; }
.brief-boss__when { font-size: .15rem; color: var(--orange-hi); }
.brief-boss__abil { margin: .04rem 0 0; padding-left: .18rem; display: flex; flex-direction: column; gap: .04rem; font-size: .14rem; line-height: 1.6; color: var(--text-md); }
.brief-boss__name { font-size: calc(.34 * var(--t)); font-weight: 900; letter-spacing: .04em; line-height: 1.25; }
.brief-boss__when { font-size: calc(.15 * var(--t)); color: var(--orange-hi); }
.brief-boss__abil { margin: .04rem 0 0; padding-left: .18rem; display: flex; flex-direction: column; gap: .04rem; font-size: calc(.14 * var(--t)); line-height: 1.6; color: var(--text-md); }
.brief-stage { display: flex; align-items: center; justify-content: space-between; gap: .2rem; padding: .12rem .18rem; background: rgba(12, 15, 14, .8); border: 1px solid var(--line); }
.brief-stage__k { display: flex; align-items: baseline; gap: .12rem; min-width: 0; }
.brief-stage__pool { font-size: .14rem; color: var(--text-lo); white-space: nowrap; }
.brief-stage__name { display: flex; align-items: center; gap: .08rem; font-size: .19rem; font-weight: 700; }
.brief-stage__pool { font-size: calc(.14 * var(--t)); color: var(--text-lo); white-space: nowrap; }
.brief-stage__name { display: flex; align-items: center; gap: .08rem; font-size: calc(.19 * var(--t)); font-weight: 700; }
.brief-stage__name .icon { width: .2rem; height: .2rem; color: var(--mint-500); }
.brief-factions { display: flex; flex-direction: column; gap: .1rem; min-height: 0; }
@@ -69,38 +70,57 @@
.brief-faction__icon { width: .72rem; height: .72rem; flex: none; display: grid; place-items: center; background: radial-gradient(circle, rgba(246, 163, 41, .16), transparent 70%); }
.brief-faction__icon img { width: 100%; height: 100%; object-fit: contain; }
.brief-faction__text { display: flex; flex-direction: column; gap: .03rem; }
.brief-faction__text b { font-size: .2rem; color: var(--amber); }
.brief-faction__text span { font-size: .15rem; color: var(--text-lo); }
.brief-faction__text b { font-size: calc(.2 * var(--t)); color: var(--amber); }
.brief-faction__text span { font-size: calc(.15 * var(--t)); color: var(--text-lo); }
.brief-bonds { padding: .16rem .2rem .2rem; background: rgba(12, 15, 14, .8); border: 1px solid var(--line); }
.brief-bonds__row { display: flex; flex-wrap: wrap; gap: .16rem .1rem; }
.brief-bond { position: relative; width: 1rem; display: flex; justify-content: center; }
.brief-bond .bond { --disc: .66rem; }
.brief-bond .bond__name { font-size: .16rem; }
.brief-bond__ban { position: absolute; top: -.04rem; right: .08rem; display: inline-flex; align-items: center; gap: .01rem; font-size: .12rem; font-weight: 700; padding: 0 .05rem; background: var(--red); color: #fff; line-height: 1.5; box-shadow: 0 0 0 1px #000; }
.brief-bond { position: relative; min-width: 1rem; width: max-content; max-width: 100%; display: flex; justify-content: center; }
.brief-bond .bond { --disc: .66rem; max-width: 100%; }
.brief-bond .bond__name { font-size: calc(.16 * var(--t)); max-width: 100%; white-space: normal; overflow-wrap: anywhere; text-align: center; }
.brief-bond__ban { position: absolute; top: -.04rem; right: .08rem; display: inline-flex; align-items: center; gap: .01rem; font-size: calc(.12 * var(--t)); font-weight: 700; padding: 0 .05rem; background: var(--red); color: #fff; line-height: 1.5; box-shadow: 0 0 0 1px #000; }
.brief-bond__ban .icon { width: .12rem; height: .12rem; }
.brief-bond.is-partial .bond__core { box-shadow: 0 0 0 2px rgba(255, 84, 84, .5); }
.brief-legend { display: flex; align-items: center; gap: .08rem; flex-wrap: wrap; font-size: .14rem; color: var(--text-dim); margin-top: -.06rem; }
.brief-legend { display: flex; align-items: center; gap: .08rem; flex-wrap: wrap; font-size: calc(.14 * var(--t)); color: var(--text-dim); margin-top: -.06rem; }
.brief-legend__off { width: .16rem; height: .16rem; border-radius: 50%; background: #3a403d; border: 2px solid #555; }
.brief-legend__ban { display: inline-grid; place-items: center; width: .2rem; height: .18rem; background: var(--red); color: #fff; }
.brief-legend__ban .icon { width: .12rem; height: .12rem; }
.brief-banned { padding: .16rem .2rem .2rem; background: rgba(12, 15, 14, .8); border: 1px solid var(--line); }
.brief-banned__n { margin-left: auto; font-size: .22rem; color: var(--red-premium); }
.brief-banned__n { margin-left: auto; font-size: calc(.22 * var(--t)); color: var(--red-premium); }
.brief-banned__grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(.6rem, 1fr)); gap: .08rem; }
.brief-banned__grid .uthumb { --ut: .6rem; }
/* 0.2.0: the player's own 自选 pieces out of the shop this match (ui/matchInfo.js DiyBannedLine; m.private.diyBanned) */
.brief-banned__diy { margin: .1rem 0 0; font-size: .14rem; line-height: 1.5; color: var(--text-dim, #b8c2bf); }
.diybanned__tag { display: inline-block; margin-right: .06rem; padding: .01rem .06rem; font-size: .12rem; font-weight: 700;
.brief-banned__diy { margin: .1rem 0 0; font-size: calc(.14 * var(--t)); line-height: 1.5; color: var(--text-dim, #b8c2bf); }
.diybanned__tag { display: inline-block; margin-right: .06rem; padding: .01rem .06rem; font-size: calc(.12 * var(--t)); font-weight: 700;
letter-spacing: .08em; background: var(--mint-500); color: var(--text-on-mint); vertical-align: .01rem; }
.brief__foot { position: relative; z-index: 2; flex: none; display: flex; align-items: center; justify-content: flex-end; gap: .4rem; padding: .18rem .5rem .3rem; }
.brief-ready { display: flex; align-items: center; gap: .16rem; }
.brief-ready__txt { font-size: .2rem; color: var(--text-lo); }
.brief-ready__txt b { font-size: .4rem; color: var(--mint-400); margin: 0 .02rem; }
.brief-ready__txt span { font-size: .24rem; color: var(--text-dim); }
.brief-ready__txt { font-size: calc(.2 * var(--t)); color: var(--text-lo); }
.brief-ready__txt b { font-size: calc(.4 * var(--t)); color: var(--mint-400); margin: 0 .02rem; }
.brief-ready__txt span { font-size: calc(.24 * var(--t)); color: var(--text-dim); }
.brief-ready__pips { display: flex; gap: .06rem; }
.brief-ready__pips i { width: .3rem; height: .3rem; display: grid; place-items: center; color: var(--text-dim); }
.brief-ready__pips i .icon { width: 100%; height: 100%; }
.brief-ready__pips i.on { color: var(--mint-400); filter: drop-shadow(0 0 .06rem rgba(23, 249, 183, .6)); }
.brief-ready__pips i.me { border-bottom: 2px solid var(--mint-700); }
/* Full setup reroll: consent stays visible alongside the information being confirmed. */
.brief-reroll {
position: relative; z-index: 2; flex: none;
display: flex; align-items: center; justify-content: space-between; gap: .2rem;
margin: .1rem .5rem 0; padding: .14rem .2rem;
background: rgba(12, 15, 14, .9); border: 1px solid var(--mint-700);
}
.brief-reroll__text { display: flex; flex-direction: column; gap: .05rem; min-width: 0; font-size: calc(.16 * var(--t)); }
.brief-reroll__text strong { font-size: calc(.2 * var(--t)); color: var(--mint-400); }
.brief-reroll__text > span { color: var(--text-lo); }
.brief-reroll__players { display: flex; flex-wrap: wrap; gap: .08rem .2rem; color: var(--text-dim); }
.brief-reroll__players .is-agreed { color: var(--mint-400); }
.brief-reroll__actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: .1rem; flex-shrink: 0; }
@media (max-width: 900px) {
.brief-reroll { margin-inline: .3rem; gap: .1rem; padding: .1rem .14rem; }
.brief-reroll__actions { flex-shrink: 1; }
.brief-reroll__actions .btn { min-height: .44rem; padding-inline: .12rem; }
}
+18 -18
View File
@@ -13,11 +13,11 @@
transition: border-color var(--t-med), background var(--t-med);
}
.dorder .pavatar { --pa: .66rem; }
.dorder__idx { font-size: .22rem; color: var(--text-dim); width: .26rem; text-align: center; }
.dorder__idx { font-size: calc(.22 * var(--t)); color: var(--text-dim); width: .26rem; text-align: center; }
.dorder__text { display: flex; flex-direction: column; gap: .02rem; min-width: 0; }
.dorder__name { font-size: .18rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; display: flex; align-items: center; gap: .06rem; }
.dorder__ai { font-family: var(--font-num); font-size: .11rem; padding: 0 .04rem; background: #a9c3d8; color: #0b1520; }
.dorder__state { font-size: .14rem; }
.dorder__name { font-size: calc(.18 * var(--t)); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; display: flex; align-items: center; gap: .06rem; }
.dorder__ai { font-family: var(--font-num); font-size: calc(.11 * var(--t)); padding: 0 .04rem; background: #a9c3d8; color: #0b1520; }
.dorder__state { font-size: calc(.14 * var(--t)); }
.dorder__state span { display: inline-flex; align-items: center; gap: .04rem; }
.dorder__state .icon { width: .16rem; height: .16rem; }
.dorder__box { position: relative; width: .6rem; height: .6rem; display: grid; place-items: center; border: 1px dashed var(--line-2); }
@@ -28,7 +28,7 @@
.dorder.is-cur { border-color: var(--gold); background: linear-gradient(90deg, rgba(255, 198, 0, .12), rgba(12, 15, 14, .82)); box-shadow: 0 0 .2rem rgba(255, 198, 0, .15); }
.dorder.is-done { border-color: var(--mint-800); }
.dorder.is-self .dorder__name { color: var(--mint-400); }
.draft-order__tip { margin-top: auto; font-size: .13rem; line-height: 1.6; color: var(--text-dim); }
.draft-order__tip { margin-top: auto; font-size: calc(.13 * var(--t)); line-height: 1.6; color: var(--text-dim); }
.draft-grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(1.36rem, 1fr)); grid-auto-rows: min-content; gap: .12rem;
@@ -41,13 +41,13 @@
}
.dband:hover { transform: translateY(-.03rem); border-color: var(--line-3); }
.dband .bandicon { --bi: 1.1rem; border: 1px solid var(--line-2); }
.dband__name { font-size: .16rem; font-weight: 700; color: var(--text-hi); white-space: nowrap; }
.dband__lp { display: inline-flex; align-items: center; gap: .04rem; font-size: .15rem; color: var(--mint-400); }
.dband__name { font-size: calc(.16 * var(--t)); font-weight: 700; color: var(--text-hi); white-space: nowrap; }
.dband__lp { display: inline-flex; align-items: center; gap: .04rem; font-size: calc(.15 * var(--t)); color: var(--mint-400); }
.dband__lp i { width: .12rem; height: .14rem; background: var(--mint-500); clip-path: polygon(0 0, 30% 0, 30% 20%, 45% 20%, 45% 0, 55% 0, 55% 20%, 70% 20%, 70% 0, 100% 0, 100% 35%, 85% 50%, 85% 80%, 100% 90%, 100% 100%, 0 100%, 0 90%, 15% 80%, 15% 50%, 0 35%); }
.dband__who { position: absolute; right: .04rem; top: .04rem; display: flex; flex-direction: column; gap: .02rem; }
.dband__who .pavatar { --pa: .3rem; border-radius: 50%; overflow: hidden; border: 2px solid var(--mint-400); }
.dband.is-sel { border-color: var(--mint-400); background: linear-gradient(180deg, rgba(78, 216, 175, .18), rgba(12, 15, 14, .85)); box-shadow: 0 0 0 1px var(--mint-400), 0 0 .24rem rgba(23, 249, 183, .2); }
.dband.is-mine::after { content: '已选择'; position: absolute; left: 0; top: 0; font-size: .11rem; font-weight: 700; padding: 0 .06rem; background: var(--mint-500); color: var(--text-on-mint); }
.dband.is-mine::after { content: '已选择'; position: absolute; left: 0; top: 0; font-size: calc(.11 * var(--t)); font-weight: 700; padding: 0 .06rem; background: var(--mint-500); color: var(--text-on-mint); }
.draft-detail {
display: flex; flex-direction: column; align-items: center; gap: .12rem; padding: .26rem .26rem .22rem; min-height: 0;
@@ -55,33 +55,33 @@
}
.draft-detail__art { position: relative; padding: .06rem; border: 1px solid var(--mint-800); background: radial-gradient(circle, rgba(78, 216, 175, .14), transparent 70%); }
.draft-detail__art .bandicon { --bi: 2rem; }
.draft-detail__hp { display: flex; align-items: center; gap: .12rem; font-size: .16rem; color: var(--text-lo); }
.draft-detail__name { font-size: .42rem; font-weight: 900; letter-spacing: .1em; }
.draft-detail__hp { display: flex; align-items: center; gap: .12rem; font-size: calc(.16 * var(--t)); color: var(--text-lo); }
.draft-detail__name { font-size: calc(.42 * var(--t)); font-weight: 900; letter-spacing: .1em; }
.draft-detail__eff { align-self: stretch; display: flex; flex-direction: column; gap: .06rem; padding: .12rem .14rem; background: rgba(0, 0, 0, .3); border-left: 3px solid var(--mint-500); flex: 1; min-height: 0; overflow: auto; }
.draft-detail__eff b { font-size: .2rem; color: var(--mint-400); }
.draft-detail__desc { font-size: .16rem; line-height: 1.7; color: var(--text-md); }
.draft-detail__eff b { font-size: calc(.2 * var(--t)); color: var(--mint-400); }
.draft-detail__desc { font-size: calc(.16 * var(--t)); line-height: 1.7; color: var(--text-md); }
.draft-detail__actions { align-self: stretch; display: flex; flex-direction: column; gap: .1rem; }
.draft-detail__status { display: flex; align-items: center; gap: .06rem; font-size: .15rem; color: var(--text-lo); }
.draft-detail__status { display: flex; align-items: center; gap: .06rem; font-size: calc(.15 * var(--t)); color: var(--text-lo); }
.draft-detail__status .icon { width: .18rem; height: .18rem; }
.draft-detail__btns { display: flex; gap: .1rem; }
.draft-detail__btns .btn { flex: 1; }
.dorder__secs { margin-left: .06rem; font-size: .15rem; color: var(--gold); }
.dorder__secs { margin-left: .06rem; font-size: calc(.15 * var(--t)); color: var(--gold); }
/* 队友已选 (research 09 §5): a strategy a teammate picked cannot be chosen again */
.dband.is-taken { cursor: not-allowed; border-color: rgba(246, 163, 41, .35); }
.dband.is-taken .bandicon, .dband.is-taken .dband__name, .dband.is-taken .dband__lp { filter: grayscale(.8) brightness(.62); }
.dband.is-taken:hover { transform: none; }
.dband__taken {
position: absolute; left: 50%; top: 42%; transform: translate(-50%, -50%) rotate(-8deg); white-space: nowrap; pointer-events: none;
padding: .02rem .12rem; font-size: .15rem; font-weight: 900; letter-spacing: .16em; color: #1d1400; background: var(--amber);
padding: .02rem .12rem; font-size: calc(.15 * var(--t)); font-weight: 900; letter-spacing: .16em; color: #1d1400; background: var(--amber);
box-shadow: 0 .03rem .1rem rgba(0, 0, 0, .6);
}
.draft-detail__status--taken { color: var(--amber); }
/* a strategy built around a bond this mode switches off (bandDraft.js BandOffTag / BandOffNote, DESIGN §21.26 — the
§21.7 本局禁用 look: dim, dashed, the bond struck through): information only, it stays selectable. Px floors keep it
readable on phones (1rem = 40 px). */
.dband__off { font-size: max(.12rem, 8px); line-height: 1.5; padding: 0 .05rem; color: var(--text-dim); border: 1px dashed rgba(255, 255, 255, .28); white-space: nowrap; }
.dband__off { font-size: max(calc(.12 * var(--t)), 8px); line-height: 1.5; padding: 0 .05rem; color: var(--text-dim); border: 1px dashed rgba(255, 255, 255, .28); white-space: nowrap; }
.draft-detail__off { align-self: stretch; display: flex; align-items: flex-start; gap: .06rem; margin: 0; padding: .06rem .1rem;
font-size: max(.14rem, 9px); line-height: 1.5; color: var(--text-lo); background: rgba(255, 255, 255, .03); border-left: 2px solid rgba(255, 255, 255, .28); }
font-size: max(calc(.14 * var(--t)), 9px); line-height: 1.5; color: var(--text-lo); background: rgba(255, 255, 255, .03); border-left: 2px solid rgba(255, 255, 255, .28); }
.draft-detail__off .icon { width: max(.16rem, 10px); height: max(.16rem, 10px); flex: none; margin-top: .02rem; }
.draft-detail__offname { color: var(--text-dim); text-decoration: line-through; text-decoration-color: rgba(255, 84, 84, .6); }
@@ -92,7 +92,7 @@
footer (the turn's countdown, 关闭) stay in view. */
.draft-order__info {
margin-top: .04rem; justify-content: flex-start; padding: 0 .16rem;
height: max(.5rem, 28px); font-size: max(.18rem, 10px);
height: max(.5rem, 28px); font-size: max(calc(.18 * var(--t)), 10px);
--btn-bd: var(--mint-800); --btn-bg: rgba(78, 216, 175, .06); --btn-bg-hover: rgba(78, 216, 175, .14);
}
.draft-order__info .btn__icon { color: var(--mint-400); }
+95 -95
View File
@@ -44,7 +44,7 @@
.bglyph { display: inline-grid; place-items: center; width: .24rem; height: .24rem; flex: none; }
.bglyph img { width: 100%; height: 100%; object-fit: contain; filter: brightness(0) invert(.86) sepia(.4) hue-rotate(110deg) saturate(2.5); }
.bglyph__txt { font-weight: 900; font-size: .15rem; color: var(--mint-400); }
.bglyph__txt { font-weight: 900; font-size: calc(.15 * var(--t)); color: var(--mint-400); }
.bandicon { --bi: .6rem; display: inline-grid; place-items: center; width: var(--bi); height: var(--bi); flex: none; overflow: hidden; background: #111513; }
.bandicon img { width: 100%; height: 100%; object-fit: cover; }
.bandicon__txt { font-weight: 900; font-size: calc(var(--bi) * .44); color: var(--mint-400); }
@@ -83,23 +83,23 @@
.dhead__icon--enemy.is-elite { border-color: var(--gold-2); }
.dhead__info { display: flex; flex-direction: column; gap: .05rem; min-width: 0; padding-top: .02rem; flex: 1; }
.dhead__chips { display: flex; align-items: center; gap: .08rem; flex-wrap: wrap; }
.dhead__name { font-size: .32rem; font-weight: 900; letter-spacing: .04em; line-height: 1.2; }
.dhead__en { font-family: var(--font-display); font-size: .12rem; letter-spacing: .2em; color: var(--text-dim); text-transform: uppercase; }
.dhead__name { font-size: calc(.32 * var(--t)); font-weight: 900; letter-spacing: .04em; line-height: 1.2; }
.dhead__en { font-family: var(--font-display); font-size: calc(.12 * var(--t)); letter-spacing: .2em; color: var(--text-dim); text-transform: uppercase; }
/* 0.2.0 补位: the replaced operator under a stand-in's name (「银灰的替补」, ui/detailPanel.js ChessDetail) */
.dhead__for { display: block; font-size: max(.13rem, 9px); letter-spacing: .04em; color: var(--ice); }
.dhead__flavor { font-size: .13rem; color: var(--text-lo); font-style: italic; }
.dhead__class { display: flex; align-items: center; gap: .06rem; flex-wrap: wrap; font-size: .15rem; color: var(--text-md); margin-top: .06rem; }
.dhead__for { display: block; font-size: max(calc(.13 * var(--t)), 9px); letter-spacing: .04em; color: var(--ice); }
.dhead__flavor { font-size: calc(.13 * var(--t)); color: var(--text-lo); font-style: italic; }
.dhead__class { display: flex; align-items: center; gap: .06rem; flex-wrap: wrap; font-size: calc(.15 * var(--t)); color: var(--text-md); margin-top: .06rem; }
.dhead__class .sep { width: 1px; height: .16rem; background: var(--line-2); }
.dhead__prof, .dhead__sub { width: .24rem; height: .24rem; object-fit: contain; }
.dhead__pos { font-size: .12rem; padding: 0 .06rem; border: 1px solid var(--line-2); color: var(--text-lo); }
.dtag-elite, .dtag-token, .dtag-kind { font-size: .13rem; font-weight: 700; padding: .01rem .08rem; letter-spacing: .08em; }
.dhead__pos { font-size: calc(.12 * var(--t)); padding: 0 .06rem; border: 1px solid var(--line-2); color: var(--text-lo); }
.dtag-elite, .dtag-token, .dtag-kind { font-size: calc(.13 * var(--t)); font-weight: 700; padding: .01rem .08rem; letter-spacing: .08em; }
.dtag-elite { background: var(--gold); color: #1d1400; }
.dtag-token { background: #a9c3d8; color: #0b1520; }
/* 0.2.0 自选编队 / 补位: the card of a 自选 piece, of a chess fielded as its stand-in (ui/detailPanel.js ChessDetail) */
.dtag-diy { font-size: .13rem; font-weight: 700; padding: .01rem .08rem; letter-spacing: .08em; background: var(--mint-500); color: var(--text-on-mint); }
.dtag-standin { font-size: .13rem; font-weight: 700; padding: .01rem .08rem; letter-spacing: .08em; background: var(--ice); color: var(--ice-900); }
.dtag-diy { font-size: calc(.13 * var(--t)); font-weight: 700; padding: .01rem .08rem; letter-spacing: .08em; background: var(--mint-500); color: var(--text-on-mint); }
.dtag-standin { font-size: calc(.13 * var(--t)); font-weight: 700; padding: .01rem .08rem; letter-spacing: .08em; background: var(--ice); color: var(--ice-900); }
.dtag-kind { border: 1px solid var(--line-2); color: var(--text-lo); font-weight: 500; }
.drank { font-size: .13rem; font-weight: 700; padding: .01rem .08rem; }
.drank { font-size: calc(.13 * var(--t)); font-weight: 700; padding: .01rem .08rem; }
.drank--normal { background: var(--bg-4); color: var(--text-md); }
.drank--elite { background: var(--gold-2); color: #1d1400; }
.drank--boss { background: var(--orange); color: #fff; }
@@ -107,7 +107,7 @@
.dhp i { position: absolute; inset: 0 auto 0 0; background: linear-gradient(90deg, #2bb65a, #56e08a); }
.dhp--enemy i { background: linear-gradient(90deg, #b5261a, #ff5a4a); }
.dhp span { position: absolute; inset: 0; display: grid; place-items: center; font-size: .12rem; color: #fff; text-shadow: 0 1px 2px #000; }
.dtrait { display: flex; gap: .08rem; align-items: flex-start; font-size: .14rem; line-height: 1.6; color: var(--text-md); padding: .08rem .1rem; background: rgba(255, 255, 255, .03); border-left: 2px solid var(--line-3); }
.dtrait { display: flex; gap: .08rem; align-items: flex-start; font-size: calc(.14 * var(--t)); line-height: 1.6; color: var(--text-md); padding: .08rem .1rem; background: rgba(255, 255, 255, .03); border-left: 2px solid var(--line-3); }
.dtrait .icon { width: .16rem; height: .16rem; margin-top: .03rem; color: var(--text-lo); flex: none; }
.dstats-wrap { display: flex; gap: .12rem; align-items: stretch; }
/* compact 4 × 2 grid (label over value): the skill stays close under the 特质 block (user playtest #3 item 8) */
@@ -115,10 +115,10 @@
.dstats { flex: none; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 1px; background: var(--line); border: 1px solid var(--line); }
.dstats-wrap > .dstats { flex: 1; }
.dstat { display: flex; flex-direction: column; justify-content: center; gap: .01rem; min-width: 0; padding: .04rem .07rem; background: #0f1311; }
.dstat__k { font-size: .12rem; color: var(--text-lo); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.dstat__k { font-size: calc(.12 * var(--t)); color: var(--text-lo); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.dstat__row { display: flex; align-items: baseline; min-width: 0; white-space: nowrap; }
.dstat__v { font-size: .17rem; line-height: 1.15; color: #fff; }
.dstat small { font-size: .11rem; color: var(--text-dim); margin-left: .04rem; }
.dstat__v { font-size: calc(.17 * var(--t)); line-height: 1.15; color: #fff; }
.dstat small { font-size: calc(.11 * var(--t)); color: var(--text-dim); margin-left: .04rem; }
/* live stats (user playtest #4 item 7): buffed values green, debuffed red (the official card), the difference beside
them; a 实时 (battle) / 开战时 (prep preview) tag over the grid's right edge */
.dstats.is-live, .dstats-wrap.is-live { margin-top: .14rem; }
@@ -128,7 +128,7 @@
.dstat.is-down .dstat__v { color: var(--red-premium); text-shadow: 0 0 .08rem rgba(255, 84, 84, .3); }
.dstat.is-down small { color: #ff8a80; }
.dstats__tag {
position: absolute; right: -1px; bottom: 100%; padding: .01rem .08rem; font-size: .11rem; font-weight: 700; letter-spacing: .1em;
position: absolute; right: -1px; bottom: 100%; padding: .01rem .08rem; font-size: calc(.11 * var(--t)); font-weight: 700; letter-spacing: .1em;
color: var(--text-on-mint); background: var(--mint-500); pointer-events: auto; cursor: help;
}
.dstats__tag.is-battle { background: var(--orange); color: #1d0c00; }
@@ -137,47 +137,47 @@
.rgrid i { width: var(--rg); height: var(--rg); border: 1px solid rgba(255, 255, 255, .06); }
.rgrid i.on { background: rgba(246, 163, 41, .25); border-color: var(--amber); }
.rgrid i.self { background: var(--mint-500); border-color: var(--mint-400); }
.rgrid-all { font-size: .16rem; font-weight: 700; color: var(--amber); padding: .02rem .06rem; border: 1px solid var(--amber); background: rgba(246, 163, 41, .25); }
.rgrid-all { font-size: calc(.16 * var(--t)); font-weight: 700; color: var(--amber); padding: .02rem .06rem; border: 1px solid var(--amber); background: rgba(246, 163, 41, .25); }
.dsec { display: flex; flex-direction: column; gap: .08rem; }
.dsec__title { display: flex; align-items: baseline; gap: .1rem; font-size: .17rem; font-weight: 700; color: var(--mint-400); padding-bottom: .04rem; border-bottom: 1px solid var(--line); }
.dtext { font-size: .15rem; line-height: 1.65; color: var(--text-md); }
.dsec__title { display: flex; align-items: baseline; gap: .1rem; font-size: calc(.17 * var(--t)); font-weight: 700; color: var(--mint-400); padding-bottom: .04rem; border-bottom: 1px solid var(--line); }
.dtext { font-size: calc(.15 * var(--t)); line-height: 1.65; color: var(--text-md); }
.dskill { display: flex; gap: .12rem; align-items: center; }
.dskill__icon { width: .62rem; height: .62rem; flex: none; object-fit: cover; border: 1px solid var(--line-3); background: #0a0d0c; }
.dskill__icon--empty { display: grid; place-items: center; }
.dskill__icon--empty b { font-family: var(--font-display); font-size: .2rem; color: var(--mint-400); letter-spacing: .02em; }
.dskill__icon--empty b { font-family: var(--font-display); font-size: calc(.2 * var(--t)); color: var(--mint-400); letter-spacing: .02em; }
.dskill__slot { display: inline-block; margin-right: .06rem; padding: 0 .05rem; vertical-align: .02rem; font-family: var(--font-display);
font-size: .12rem; line-height: 1.5; color: var(--text-lo); border: 1px solid var(--line-3); }
font-size: calc(.12 * var(--t)); line-height: 1.5; color: var(--text-lo); border: 1px solid var(--line-3); }
.dskill__meta { display: flex; flex-direction: column; gap: .05rem; min-width: 0; }
.dskill__name { font-size: .19rem; }
.dskill__name { font-size: calc(.19 * var(--t)); }
.dskill__tags { display: flex; flex-wrap: wrap; gap: .06rem; align-items: center; }
.dsp { font-size: .12rem; padding: 0 .06rem; line-height: 1.6; font-weight: 700; }
.dsp { font-size: calc(.12 * var(--t)); padding: 0 .06rem; line-height: 1.6; font-weight: 700; }
.dsp--time { background: #7fd37a; color: #0b1a0a; }
.dsp--atk { background: #ff8a3d; color: #1f0c00; }
.dsp--def { background: #f2bd3e; color: #1f1400; }
.dsp--trig { border: 1px solid var(--line-3); color: var(--text-lo); font-weight: 500; }
.dsp__num { display: inline-flex; align-items: center; gap: .03rem; font-size: .13rem; color: var(--text-lo); }
.dsp__num { display: inline-flex; align-items: center; gap: .03rem; font-size: calc(.13 * var(--t)); color: var(--text-lo); }
.dsp__num b { color: #fff; }
.dsp__num .icon { width: .14rem; height: .14rem; color: var(--gold); }
.dtalent { display: flex; flex-direction: column; gap: .02rem; }
.dtalent b { font-size: .15rem; color: var(--text-hi); }
.dtalent b { font-size: calc(.15 * var(--t)); color: var(--text-hi); }
/* the operator's own effect (特质, user playtest #3 item 8): compact, right under the header, gold accent */
.dgarrison {
display: flex; flex-direction: column; gap: .04rem; padding: .07rem .1rem .08rem; flex: none;
background: linear-gradient(90deg, rgba(242, 189, 62, .1), rgba(242, 189, 62, .02) 70%); border-left: 3px solid var(--gold-2);
}
.dgarrison__head { display: flex; align-items: center; gap: .08rem; }
.dgarrison__k { font-size: .14rem; font-weight: 900; letter-spacing: .12em; color: var(--gold-2); }
.dgarrison__type { display: inline-flex; align-items: center; gap: .04rem; font-size: .12rem; line-height: 1.5; color: var(--gold-2); padding: 0 .07rem 0 .03rem; border: 1px solid rgba(242, 189, 62, .4); }
.dgarrison__k { font-size: calc(.14 * var(--t)); font-weight: 900; letter-spacing: .12em; color: var(--gold-2); }
.dgarrison__type { display: inline-flex; align-items: center; gap: .04rem; font-size: calc(.12 * var(--t)); line-height: 1.5; color: var(--gold-2); padding: 0 .07rem 0 .03rem; border: 1px solid rgba(242, 189, 62, .4); }
.dgarrison__icon { width: .18rem; height: .18rem; object-fit: contain; }
.dgarrison__text { font-size: .15rem; line-height: 1.55; color: var(--text-hi); }
.dgarrison__text { font-size: calc(.15 * var(--t)); line-height: 1.55; color: var(--text-hi); }
.dbonds { display: flex; flex-wrap: wrap; gap: .08rem; }
.dbond { display: inline-flex; align-items: center; gap: .05rem; padding: .03rem .1rem .03rem .05rem; font-size: .15rem; background: rgba(78, 216, 175, .08); border: 1px solid var(--mint-800); color: var(--mint-400); }
.dbond { display: inline-flex; align-items: center; gap: .05rem; padding: .03rem .1rem .03rem .05rem; font-size: calc(.15 * var(--t)); background: rgba(78, 216, 175, .08); border: 1px solid var(--mint-800); color: var(--mint-400); }
/* the unit's bonds in the header's right column, right under name / class (user playtest #2 item 9): icon, name,
count / next threshold, tier pips — visible as soon as the card opens */
.dbonds--top { gap: .05rem; margin-top: .06rem; }
.dhead__info .dbonds--top .dbond { font-size: .14rem; padding: .02rem .07rem .02rem .03rem; }
.dhead__info .dbonds--top .dbond { font-size: calc(.14 * var(--t)); padding: .02rem .07rem .02rem .03rem; }
.dhead__info .dbond__icon { width: .24rem; height: .24rem; }
.dbonds--top .dbond { gap: .06rem; padding: .03rem .08rem .03rem .04rem; font: inherit; font-size: .15rem; color: var(--text-md);
.dbonds--top .dbond { gap: .06rem; padding: .03rem .08rem .03rem .04rem; font: inherit; font-size: calc(.15 * var(--t)); color: var(--text-md);
background: rgba(255, 255, 255, .03); border: 1px solid var(--line-2); cursor: default; }
.dbonds--top button.dbond { cursor: pointer; }
.dbonds--top button.dbond:hover { border-color: var(--mint-500); color: #fff; }
@@ -185,8 +185,8 @@
.dbonds--top .dbond.is-core .dbond__name { font-weight: 700; }
.dbond__icon { width: .26rem; height: .26rem; }
.dbond:not(.is-active) .dbond__icon img { filter: brightness(0) invert(.6); }
.dbond__count { font-size: .15rem; color: #fff; }
.dbond__count small { font-size: .11rem; color: var(--text-dim); }
.dbond__count { font-size: calc(.15 * var(--t)); color: #fff; }
.dbond__count small { font-size: calc(.11 * var(--t)); color: var(--text-dim); }
.dbond__count.is-max { color: var(--gold-2); }
.dbond__tiers { display: inline-flex; gap: 2px; margin-left: .02rem; }
.dbond__tiers i { width: .05rem; height: .12rem; background: rgba(255, 255, 255, .16); transform: skewX(-18deg); }
@@ -195,23 +195,23 @@
.dbonds--top .dbond.is-off { border-style: dashed; border-color: rgba(255, 255, 255, .16); background: transparent; color: var(--text-dim); }
.dbond.is-off .dbond__name { text-decoration: line-through; text-decoration-color: rgba(255, 84, 84, .6); }
.dbond.is-off .dbond__icon img { filter: grayscale(1) brightness(.5); }
.dbond__off { font-size: .12rem; color: var(--text-dim); white-space: nowrap; }
.dtag-loadout { display: inline-block; margin-left: .08rem; vertical-align: .02rem; font-size: .11rem; font-weight: 700; letter-spacing: .06em;
.dbond__off { font-size: calc(.12 * var(--t)); color: var(--text-dim); white-space: nowrap; }
.dtag-loadout { display: inline-block; margin-left: .08rem; vertical-align: .02rem; font-size: calc(.11 * var(--t)); font-weight: 700; letter-spacing: .06em;
padding: 0 .06rem; line-height: 1.6; background: var(--mint-500); color: #062019; }
.dmodule { display: flex; align-items: center; flex-wrap: wrap; gap: .08rem; font-size: .15rem; }
.dmodule { display: flex; align-items: center; flex-wrap: wrap; gap: .08rem; font-size: calc(.15 * var(--t)); }
.dmodule__name { color: var(--text-hi); }
/* official module type icon (local-client art, white glyph) on a dark tile; the type letter without it */
.dmodule__icon { flex: none; display: inline-grid; place-items: center; width: .4rem; height: .4rem; background: linear-gradient(180deg, #2a322f, #151a18); border: 1px solid var(--gold-2); border: 1px solid color-mix(in srgb, var(--gold-2) 60%, transparent); }
.dmodule__icon img { width: 80%; height: 80%; object-fit: contain; }
.dmodule__icon b { font-size: .2rem; font-weight: 700; line-height: 1; color: var(--gold-2); }
.dmodule__type { font-family: var(--font-display); font-size: .12rem; letter-spacing: .1em; padding: 0 .06rem; border: 1px solid var(--line-3); color: var(--text-lo); }
.dmodule__icon b { font-size: calc(.2 * var(--t)); font-weight: 700; line-height: 1; color: var(--gold-2); }
.dmodule__type { font-family: var(--font-display); font-size: calc(.12 * var(--t)); letter-spacing: .1em; padding: 0 .06rem; border: 1px solid var(--line-3); color: var(--text-lo); }
.dmodule.is-none .dmodule__name { color: var(--text-dim); font-weight: 500; }
.ditem { display: flex; gap: .1rem; align-items: flex-start; }
.ditem__text { display: flex; flex-direction: column; gap: .02rem; min-width: 0; }
.ditem__text b { font-size: .15rem; }
.ditem__desc { font-size: .13rem; line-height: 1.55; color: var(--text-lo); }
.dempty { font-size: .14rem; }
.dhint { display: flex; align-items: flex-start; gap: .06rem; font-size: .14rem; color: var(--text-lo); line-height: 1.6; }
.ditem__text b { font-size: calc(.15 * var(--t)); }
.ditem__desc { font-size: calc(.13 * var(--t)); line-height: 1.55; color: var(--text-lo); }
.dempty { font-size: calc(.14 * var(--t)); }
.dhint { display: flex; align-items: flex-start; gap: .06rem; font-size: calc(.14 * var(--t)); color: var(--text-lo); line-height: 1.6; }
.dhint .icon { width: .16rem; height: .16rem; margin-top: .03rem; flex: none; }
/* a merge-completing shop / reward card's 可晋升 destination (the tag's colour; touch shows no title — QA 6b) */
.dhint--merge { color: var(--gold); }
@@ -219,7 +219,7 @@
.dhint--rule { color: var(--text-md); }
/* 变形同构体's 天赋栏 (detailPanel MorphPairings, DESIGN §21.26): one line per bond — 【bond】 | the items that pair with it;
本局禁用 struck through, the pairing a wearer wears highlighted. Px floors keep it readable on phones (1rem = 40 px). */
.dmorph { display: flex; flex-direction: column; gap: .05rem; font-size: max(.14rem, 9px); line-height: 1.5; color: var(--text-md); }
.dmorph { display: flex; flex-direction: column; gap: .05rem; font-size: max(calc(.14 * var(--t)), 9px); line-height: 1.5; color: var(--text-md); }
.dmorph__lead { margin: 0; color: var(--text-hi); }
.dmorph__list { margin: 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 1px; }
.dmorph__row { display: grid; grid-template-columns: 6.2em minmax(0, 1fr); gap: .06rem; align-items: baseline; padding: .02rem .06rem;
@@ -230,20 +230,20 @@
.dmorph__row.is-off .dmorph__items { color: var(--text-dim); }
.dmorph__row.is-worn { background: rgba(78, 216, 175, .14); border-left-color: var(--mint-400); }
.dmorph__item.is-worn { color: #fff; font-weight: 700; }
.dmorph__tag { display: inline-block; margin-left: .06rem; padding: 0 .05rem; font-size: max(.11rem, 8px); line-height: 1.5; font-weight: 700; white-space: nowrap; }
.dmorph__tag { display: inline-block; margin-left: .06rem; padding: 0 .05rem; font-size: max(calc(.11 * var(--t)), 8px); line-height: 1.5; font-weight: 700; white-space: nowrap; }
.dmorph__tag.is-on { background: var(--mint-500); color: #062019; }
.dmorph__tag.is-off { border: 1px dashed rgba(255, 255, 255, .25); color: var(--text-dim); font-weight: 500; }
.dmorph__none { margin: 0; color: var(--gold-2); }
/* a bond item's line: what it gives a 变形同构体 wearer (MorphGrantLine) */
.dhint--morph { font-size: max(.14rem, 9px); color: var(--text-md); }
.dhint--morph { font-size: max(calc(.14 * var(--t)), 9px); color: var(--text-md); }
.dhint--morph.is-worn { color: var(--mint-400); }
.dhint--morph.is-off { color: var(--text-dim); }
.dactions { display: flex; justify-content: flex-end; gap: .1rem; padding-top: .06rem; border-top: 1px solid var(--line); }
.dsell { margin-left: .06rem; color: #1d1400; }
.dfactions { display: flex; flex-wrap: wrap; gap: .06rem; }
.dfaction { display: inline-flex; align-items: center; gap: .04rem; font-size: .13rem; color: var(--amber); padding: 0 .08rem 0 .02rem; border: 1px solid rgba(246, 163, 41, .45); }
.dfaction { display: inline-flex; align-items: center; gap: .04rem; font-size: calc(.13 * var(--t)); color: var(--amber); padding: 0 .08rem 0 .02rem; border: 1px solid rgba(246, 163, 41, .45); }
.dfaction img { width: .22rem; height: .22rem; object-fit: contain; }
.dabil { margin: 0; padding-left: .2rem; display: flex; flex-direction: column; gap: .05rem; font-size: .15rem; line-height: 1.6; color: var(--text-md); }
.dabil { margin: 0; padding-left: .2rem; display: flex; flex-direction: column; gap: .05rem; font-size: calc(.15 * var(--t)); line-height: 1.6; color: var(--text-md); }
.dabil li.is-off { color: var(--text-dim); }
.dabil li.is-off .rt-term { color: var(--text-dim); border-bottom-color: transparent; }
@@ -270,44 +270,44 @@
.bpop__disc.is-active { background: var(--mint-500); border-color: var(--mint-400); box-shadow: 0 0 .24rem rgba(23, 249, 183, .45); }
.bpop__disc.is-active .bglyph img { filter: brightness(0) opacity(.85); }
.bpop__titles { display: flex; flex-direction: column; gap: .02rem; min-width: 0; }
.bpop__name { font-size: .32rem; font-weight: 900; letter-spacing: .08em; }
.bpop__owner { display: inline-flex; align-items: center; gap: .06rem; font-size: .14rem; color: var(--amber); }
.bpop__name { font-size: calc(.32 * var(--t)); font-weight: 900; letter-spacing: .08em; }
.bpop__owner { display: inline-flex; align-items: center; gap: .06rem; font-size: calc(.14 * var(--t)); color: var(--amber); }
.bpop__owner b { color: #fff; font-weight: 700; max-width: 2.4rem; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.bpop.is-other .bpop__disc { box-shadow: 0 0 0 1px rgba(246, 163, 41, .55); }
.bpop__facts { display: flex; gap: .16rem; font-size: .15rem; color: var(--text-lo); }
.bpop__facts b { color: #fff; font-size: .18rem; }
.bpop__facts small { font-size: .12rem; }
.bpop__off { margin: .08rem 0 0; font-size: .14rem; color: var(--text-lo); border-left: 2px solid rgba(255, 255, 255, .2); padding-left: .08rem; }
.bpop__facts { display: flex; gap: .16rem; font-size: calc(.15 * var(--t)); color: var(--text-lo); }
.bpop__facts b { color: #fff; font-size: calc(.18 * var(--t)); }
.bpop__facts small { font-size: calc(.12 * var(--t)); }
.bpop__off { margin: .08rem 0 0; font-size: calc(.14 * var(--t)); color: var(--text-lo); border-left: 2px solid rgba(255, 255, 255, .2); padding-left: .08rem; }
.bpop__tiers { display: flex; gap: .06rem; }
.bpop__tier { display: flex; align-items: baseline; gap: .02rem; padding: .02rem .12rem; border: 1px solid var(--line-2); color: var(--text-dim); font-size: .13rem; }
.bpop__tier b { font-size: .2rem; }
.bpop__tier { display: flex; align-items: baseline; gap: .02rem; padding: .02rem .12rem; border: 1px solid var(--line-2); color: var(--text-dim); font-size: calc(.13 * var(--t)); }
.bpop__tier b { font-size: calc(.2 * var(--t)); }
.bpop__tier.is-on { background: var(--mint-500); color: var(--text-on-mint); border-color: var(--mint-400); }
.bpop__sec { display: flex; flex-direction: column; gap: .06rem; }
.bpop__sec h4 { font-size: .16rem; color: var(--mint-400); display: flex; align-items: baseline; gap: .08rem; }
.bpop__sec h4 small { font-size: .13rem; color: var(--text-lo); font-weight: 400; }
.bpop__sec h4 { font-size: calc(.16 * var(--t)); color: var(--mint-400); display: flex; align-items: baseline; gap: .08rem; }
.bpop__sec h4 small { font-size: calc(.13 * var(--t)); color: var(--text-lo); font-weight: 400; }
.bpop__sec--now { padding: .1rem .12rem; background: rgba(78, 216, 175, .06); border-left: 3px solid var(--mint-500); }
.bpop__desc { font-size: .15rem; line-height: 1.7; color: var(--text-md); }
.bpop__desc { font-size: calc(.15 * var(--t)); line-height: 1.7; color: var(--text-md); }
.bpop__members { display: grid; grid-template-columns: repeat(5, 1fr); gap: .08rem; }
.bpop__member { position: relative; display: flex; flex-direction: column; align-items: center; gap: .03rem; padding: .04rem 0; background: none; border: 1px solid transparent; }
.bpop__member:hover { border-color: var(--line-2); background: rgba(255, 255, 255, .03); }
.bpop__member.is-on .uthumb__art { border-color: var(--mint-400); box-shadow: 0 0 .1rem rgba(23, 249, 183, .45); }
.bpop__mname { font-size: .12rem; color: var(--text-lo); max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.bpop__mname { font-size: calc(.12 * var(--t)); color: var(--text-lo); max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.bpop__member.is-on .bpop__mname { color: var(--mint-400); }
.bpop__member.is-owned .bpop__mname { color: var(--text-hi); }
.bpop__ban { position: absolute; top: .02rem; right: .1rem; width: .2rem; height: .2rem; display: grid; place-items: center; background: var(--red); color: #fff; border-radius: 50%; }
.bpop__ban .icon { width: .14rem; height: .14rem; }
/* a member through 变形同构体 (the item pairing, not the operator's own bond): a gold 同构 tag on the portrait */
.bpop__iso { position: absolute; top: .02rem; right: .08rem; padding: 0 .04rem; font-size: .11rem; line-height: 1.45; font-weight: 700; color: #141414; background: var(--gold-2); }
.bpop__iso { position: absolute; top: .02rem; right: .08rem; padding: 0 .04rem; font-size: calc(.11 * var(--t)); line-height: 1.45; font-weight: 700; color: #141414; background: var(--gold-2); }
/* a 自选 pick all of whose bonds are off this match (m.private.diyBanned): its 「自选」 tag moves down to the portrait's
bottom corner (the 替补 mark's place), clear of the red ✕ and the tier chip */
.bpop__member.is-banned .bpop__diy { top: .42rem; }
.dbonds--top .dbond.is-granted { border-style: dashed; }
.dbond__iso { font-size: .11rem; font-weight: 700; color: var(--gold-2); }
.dbond__iso { font-size: calc(.11 * var(--t)); font-weight: 700; color: var(--gold-2); }
/* a count that holds 调和's +1 (the server's bond entry `harmony`, DESIGN §21.26): 在场 n(含调和 +1) and the 调和 row that
heads the member list, naming the 调和 operators on the board (px floors: readable on phones) */
.bpop__facts small.bpop__hnote { font-size: max(.12rem, 8px); color: var(--gold-2); }
.bpop__facts small.bpop__hnote { font-size: max(calc(.12 * var(--t)), 8px); color: var(--gold-2); }
.bpop__harmony { display: flex; align-items: center; gap: .08rem; width: 100%; margin: 0; padding: .05rem .1rem; text-align: left;
font: inherit; font-size: max(.14rem, 9px); line-height: 1.45; color: var(--text-md); background: rgba(242, 189, 62, .08); border: 1px dashed rgba(242, 189, 62, .5); }
font: inherit; font-size: max(calc(.14 * var(--t)), 9px); line-height: 1.45; color: var(--text-md); background: rgba(242, 189, 62, .08); border: 1px dashed rgba(242, 189, 62, .5); }
button.bpop__harmony { cursor: pointer; }
button.bpop__harmony:hover { border-style: solid; color: #fff; }
.bpop__harmony b { flex: none; color: var(--gold-2); font-weight: 700; white-space: nowrap; }
@@ -328,12 +328,12 @@ button.bpop__harmony:hover { border-style: solid; color: #fff; }
.edrawer__close { width: .34rem; height: .34rem; margin-bottom: .06rem; display: grid; place-items: center; background: none; border: 1px solid var(--line-2); color: var(--text-lo); }
.edrawer__close .icon { width: .18rem; height: .18rem; }
.edrawer__body { overflow: auto; padding: .14rem .18rem .18rem; display: flex; flex-direction: column; gap: .12rem; }
.edrawer__sum { display: flex; align-items: center; justify-content: space-between; gap: .12rem; flex-wrap: wrap; font-size: .15rem; color: var(--text-md); }
.edrawer__sum b { font-size: .19rem; }
.edrawer__empty { display: flex; align-items: center; gap: .08rem; justify-content: center; padding: .3rem; color: var(--text-dim); font-size: .16rem; }
.edrawer__sum { display: flex; align-items: center; justify-content: space-between; gap: .12rem; flex-wrap: wrap; font-size: calc(.15 * var(--t)); color: var(--text-md); }
.edrawer__sum b { font-size: calc(.19 * var(--t)); }
.edrawer__empty { display: flex; align-items: center; gap: .08rem; justify-content: center; padding: .3rem; color: var(--text-dim); font-size: calc(.16 * var(--t)); }
.edrawer__empty .icon { width: .24rem; height: .24rem; }
.fchips { display: flex; gap: .06rem; flex-wrap: wrap; }
.fchip { display: inline-flex; align-items: center; gap: .04rem; padding: 0 .1rem 0 .02rem; font-size: .13rem; color: var(--amber); border: 1px solid rgba(246, 163, 41, .45); background: rgba(246, 163, 41, .06); }
.fchip { display: inline-flex; align-items: center; gap: .04rem; padding: 0 .1rem 0 .02rem; font-size: calc(.13 * var(--t)); color: var(--amber); border: 1px solid rgba(246, 163, 41, .45); background: rgba(246, 163, 41, .06); }
.fchip__icon { width: .26rem; height: .26rem; object-fit: contain; }
.elist { display: grid; grid-template-columns: 1fr 1fr; gap: .08rem; }
.erow { display: flex; align-items: center; gap: .1rem; padding: .06rem .12rem .06rem .06rem; background: rgba(255, 255, 255, .025); border: 1px solid var(--line); text-align: left; color: inherit; transition: border-color var(--t-fast), background var(--t-fast); }
@@ -341,10 +341,10 @@ button.bpop__harmony:hover { border-style: solid; color: #fff; }
.erow--boss { border-color: rgba(255, 110, 40, .6); background: rgba(232, 90, 26, .1); }
.erow--bounty { border-color: rgba(255, 198, 0, .45); }
.erow__text { display: flex; flex-direction: column; gap: .03rem; min-width: 0; flex: 1; }
.erow__name { font-size: .16rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.erow__name { font-size: calc(.16 * var(--t)); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.erow__tags { display: flex; gap: .04rem; flex-wrap: wrap; }
.erow__count { font-size: .26rem; color: #fff; }
.etag { font-size: .11rem; line-height: 1.5; padding: 0 .05rem; font-weight: 700; }
.erow__count { font-size: calc(.26 * var(--t)); color: #fff; }
.etag { font-size: calc(.11 * var(--t)); line-height: 1.5; padding: 0 .05rem; font-weight: 700; }
.etag--boss { background: var(--orange); color: #fff; }
.etag--bounty { background: var(--gold); color: #1d1400; }
.etag--escort { background: var(--bg-4); color: var(--text-md); }
@@ -353,29 +353,29 @@ button.bpop__harmony:hover { border-style: solid; color: #fff; }
.etag--faction { border: 1px solid rgba(246, 163, 41, .5); color: var(--amber); font-weight: 500; }
.eboss { display: flex; gap: .12rem; align-items: center; padding: .1rem .12rem; border: 1px solid rgba(255, 110, 40, .45); background: linear-gradient(90deg, rgba(232, 90, 26, .14), transparent); }
.eboss > div { display: flex; flex-direction: column; gap: .02rem; }
.eboss b { font-size: .19rem; }
.eboss .t-lo { font-size: .13rem; }
.eboss b { font-size: calc(.19 * var(--t)); }
.eboss .t-lo { font-size: calc(.13 * var(--t)); }
.iband { display: flex; gap: .12rem; align-items: flex-start; padding: .1rem; background: rgba(78, 216, 175, .05); border: 1px solid var(--mint-800); }
.iband > div { display: flex; flex-direction: column; gap: .03rem; min-width: 0; }
.iband b { font-size: .19rem; }
.iband b small { font-size: .14rem; font-weight: 400; margin-left: .06rem; }
.iband__desc { font-size: .14rem; line-height: 1.6; color: var(--text-md); }
.istage { display: flex; align-items: center; gap: .06rem; font-size: .15rem; color: var(--text-lo); }
.iband b { font-size: calc(.19 * var(--t)); }
.iband b small { font-size: calc(.14 * var(--t)); font-weight: 400; margin-left: .06rem; }
.iband__desc { font-size: calc(.14 * var(--t)); line-height: 1.6; color: var(--text-md); }
.istage { display: flex; align-items: center; gap: .06rem; font-size: calc(.15 * var(--t)); color: var(--text-lo); }
.istage .icon { width: .18rem; height: .18rem; color: var(--mint-500); }
.istage b { color: var(--text-hi); }
.ihead { font-size: .16rem; color: var(--mint-400); display: flex; gap: .08rem; align-items: baseline; }
.ihead small { color: var(--text-lo); font-size: .14rem; }
.ihead { font-size: calc(.16 * var(--t)); color: var(--mint-400); display: flex; gap: .08rem; align-items: baseline; }
.ihead small { color: var(--text-lo); font-size: calc(.14 * var(--t)); }
.ibonds { display: flex; flex-wrap: wrap; gap: .06rem; }
.ibond { display: inline-flex; align-items: center; gap: .05rem; padding: .03rem .08rem .03rem .04rem; font-size: .14rem; border: 1px solid var(--line-2); color: var(--text-md); }
.ibond { display: inline-flex; align-items: center; gap: .05rem; padding: .03rem .08rem .03rem .04rem; font-size: calc(.14 * var(--t)); border: 1px solid var(--line-2); color: var(--text-md); }
.ibond.is-off { color: var(--text-dim); background: rgba(255, 255, 255, .02); text-decoration: line-through; text-decoration-color: rgba(255, 84, 84, .6); }
.ibond.is-off .bglyph img { filter: grayscale(1) brightness(.5); }
.ibond__ban { display: inline-flex; align-items: center; gap: .02rem; font-size: .12rem; padding: 0 .05rem; background: var(--red); color: #fff; text-decoration: none; }
.ibond__ban { display: inline-flex; align-items: center; gap: .02rem; font-size: calc(.12 * var(--t)); padding: 0 .05rem; background: var(--red); color: #fff; text-decoration: none; }
.ibond__ban .icon { width: .12rem; height: .12rem; }
.ibanned { display: flex; flex-wrap: wrap; gap: .06rem; }
.ibanned__one { padding: 0; background: none; border: 0; }
/* 0.2.0: the player's own 自选 pieces out of the shop this match (ui/matchInfo.js DiyBannedLine) */
.idiybanned { margin: .08rem 0 0; font-size: .13rem; line-height: 1.5; }
.idiybanned .diybanned__tag { display: inline-block; margin-right: .05rem; padding: 0 .05rem; font-size: .11rem; font-weight: 700;
.idiybanned { margin: .08rem 0 0; font-size: calc(.13 * var(--t)); line-height: 1.5; }
.idiybanned .diybanned__tag { display: inline-block; margin-right: .05rem; padding: 0 .05rem; font-size: calc(.11 * var(--t)); font-weight: 700;
background: var(--mint-500); color: var(--text-on-mint); }
/* ---- 机变 overlay ---- */
@@ -385,13 +385,13 @@ button.bpop__harmony:hover { border-style: solid; color: #fff; }
.spov__inner { position: absolute; left: .3rem; right: .5rem; top: 1.14rem; bottom: .5rem; display: flex; flex-direction: column; gap: .16rem; animation: screen-in var(--t-slow) var(--ease-out); }
.spov__head { display: flex; align-items: flex-end; justify-content: space-between; gap: .3rem; }
.spov__titles { display: flex; flex-direction: column; gap: .04rem; }
.spov__title { display: flex; align-items: baseline; gap: .14rem; font-size: .5rem; font-weight: 900; letter-spacing: .08em; color: #eafff8; text-shadow: 0 0 .2rem rgba(23, 249, 183, .35); }
.spov__title { display: flex; align-items: baseline; gap: .14rem; font-size: calc(.5 * var(--t)); font-weight: 900; letter-spacing: .08em; color: #eafff8; text-shadow: 0 0 .2rem rgba(23, 249, 183, .35); }
.spov__title.is-special { color: var(--gold-2); text-shadow: 0 0 .2rem rgba(242, 189, 62, .35); }
.spov__bar { color: var(--mint-700); font-weight: 400; }
.spov__desc { font-size: .22rem; font-weight: 400; color: var(--text-lo); letter-spacing: .04em; text-shadow: none; }
.spov__sub { font-size: .15rem; color: var(--text-dim); }
.spov__desc { font-size: calc(.22 * var(--t)); font-weight: 400; color: var(--text-lo); letter-spacing: .04em; text-shadow: none; }
.spov__sub { font-size: calc(.15 * var(--t)); color: var(--text-dim); }
.spov__turn { display: flex; align-items: center; gap: .24rem; }
.spov__turntxt { display: flex; align-items: center; gap: .08rem; font-size: .24rem; font-weight: 700; color: var(--text-md); }
.spov__turntxt { display: flex; align-items: center; gap: .08rem; font-size: calc(.24 * var(--t)); font-weight: 700; color: var(--text-md); }
.spov__turntxt .icon { width: .26rem; height: .26rem; }
.spov__turntxt.is-mine { color: var(--gold); text-shadow: 0 0 .16rem rgba(255, 198, 0, .45); animation: blink 1.2s ease-in-out infinite; }
.spov__turntxt.is-done { color: var(--mint-400); }
@@ -400,8 +400,8 @@ button.bpop__harmony:hover { border-style: solid; color: #fff; }
.spov__who.is-cur { border-color: var(--gold); background: rgba(255, 198, 0, .08); }
.spov__who.is-done { border-color: var(--mint-800); }
.spov__who.is-self .spov__wname { color: var(--mint-400); }
.spov__idx { font-size: .16rem; color: var(--text-dim); width: .2rem; text-align: center; }
.spov__wname { font-size: .15rem; max-width: 1.2rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.spov__idx { font-size: calc(.16 * var(--t)); color: var(--text-dim); width: .2rem; text-align: center; }
.spov__wname { font-size: calc(.15 * var(--t)); max-width: 1.2rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.spov__wstate { display: grid; }
.spov__wstate .icon { width: .2rem; height: .2rem; }
.spov__who.is-done .spov__wstate { color: var(--mint-400); }
@@ -624,7 +624,7 @@ button.bpop__harmony:hover { border-style: solid; color: #fff; }
border-color: rgba(255, 84, 84, .35);
}
.eqr .modal__body { display: flex; flex-direction: column; align-items: stretch; gap: .1rem; padding-bottom: .16rem; }
.eqr__lead { font-size: .17rem; line-height: 1.6; color: var(--text-md); }
.eqr__lead { font-size: calc(.17 * var(--t)); line-height: 1.6; color: var(--text-md); }
.eqr__lead b { color: var(--text-hi); }
.eqr__lead .t-red { color: var(--red-premium); }
.eqr__top { display: flex; align-items: center; justify-content: center; gap: .22rem; margin-top: .06rem; }
@@ -635,7 +635,7 @@ button.bpop__harmony:hover { border-style: solid; color: #fff; }
}
.eqr__ava::after { content: ''; position: absolute; inset: .1rem; background: var(--eqr-avaf, none) center / 100% 100% no-repeat; pointer-events: none; }
.eqr__ava .uthumb { --ut: .72rem; }
.eqr__opname { font-size: .15rem; font-weight: 700; color: var(--text-hi); max-width: 1.1rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.eqr__opname { font-size: calc(.15 * var(--t)); font-weight: 700; color: var(--text-hi); max-width: 1.1rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.eqr__swap { display: grid; place-items: center; width: .4rem; height: .32rem; color: var(--mint-400); flex: none; }
.eqr__swap img, .eqr__swap .icon { width: 100%; height: 100%; object-fit: contain; }
.eqr__incoming { display: flex; flex-direction: column; gap: .04rem; min-width: 0; flex: 0 1 4.2rem; }
@@ -649,9 +649,9 @@ button.bpop__harmony:hover { border-style: solid; color: #fff; }
.eqr__card--new .eqr__icon .uthumb { --ut: .56rem; }
.eqr__meta { grid-row: 1; grid-column: 2; display: flex; flex-direction: column; gap: .03rem; min-width: 0; }
.eqr__chips { display: flex; align-items: center; gap: .06rem; min-height: .2rem; }
.eqr__elite { font-size: .12rem; padding: 0 .06rem; background: var(--gold); color: #1b1300; font-weight: 700; }
.eqr__name { font-size: .19rem; color: var(--text-hi); line-height: 1.2; }
.eqr__desc { grid-row: 2; grid-column: 1 / -1; margin: 0; font-size: .145rem; line-height: 1.55; color: var(--text-md); max-height: 1.12rem; overflow: auto; }
.eqr__elite { font-size: calc(.12 * var(--t)); padding: 0 .06rem; background: var(--gold); color: #1b1300; font-weight: 700; }
.eqr__name { font-size: calc(.19 * var(--t)); color: var(--text-hi); line-height: 1.2; }
.eqr__desc { grid-row: 2; grid-column: 1 / -1; margin: 0; font-size: calc(.145 * var(--t)); line-height: 1.55; color: var(--text-md); max-height: 1.12rem; overflow: auto; }
.eqr__arrow { position: relative; height: .5rem; margin: 0 auto; width: 50%; }
.eqr__aline { position: absolute; left: 0; right: 0; top: .2rem; height: .2rem; border: .045rem solid rgba(242, 242, 242, .82); border-bottom: 0; }
.eqr__aline::before { content: ''; position: absolute; left: 50%; top: -.25rem; width: .045rem; height: .21rem; margin-left: -.0225rem; background: rgba(242, 242, 242, .82); }
@@ -679,12 +679,12 @@ button.bpop__harmony:hover { border-style: solid; color: #fff; }
}
.eqr__flag {
position: absolute; z-index: 2; left: 0; right: 0; bottom: 0; height: .44rem; display: flex; align-items: center; justify-content: center; gap: .08rem;
font-size: .17rem; font-weight: 700; letter-spacing: .12em; color: #fff; text-shadow: 0 1px 2px rgba(0, 0, 0, .6);
font-size: calc(.17 * var(--t)); font-weight: 700; letter-spacing: .12em; color: #fff; text-shadow: 0 1px 2px rgba(0, 0, 0, .6);
background: var(--eqr-selbar, linear-gradient(90deg, #a3141a, #d42a2a)) center / 100% 100% no-repeat;
opacity: 0; transform: translateY(100%); transition: opacity var(--t-fast), transform var(--t-med) var(--ease-out);
}
.eqr__flag-icon { width: .2rem; height: .2rem; }
.eqr__opt.is-sel .eqr__flag { opacity: 1; transform: none; }
.eqr__hint { margin: .04rem 0 0; text-align: center; font-size: .15rem; color: var(--text-lo); min-height: .24rem; }
.eqr__hint { margin: .04rem 0 0; text-align: center; font-size: calc(.15 * var(--t)); color: var(--text-lo); min-height: .24rem; }
.eqr__hint b { color: var(--text-hi); }
.eqr__ok-icon { width: .2rem; height: .2rem; margin-right: .06rem; vertical-align: -.03rem; filter: brightness(0) invert(1); }
+30 -24
View File
@@ -43,8 +43,8 @@
.spec__row.is-offline .spec__name { color: var(--text-dim); }
.spec__ico { width: .18rem; height: .18rem; flex: none; color: var(--mint-500); }
.spec__row.is-offline .spec__ico { color: var(--text-dim); }
.spec__name { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: .19rem; color: var(--text-hi); }
.spec__hint { margin: .04rem 0 0; font-size: .15rem; line-height: 1.45; }
.spec__name { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: calc(.19 * var(--t)); color: var(--text-hi); }
.spec__hint { margin: .04rem 0 0; font-size: calc(.15 * var(--t)); line-height: 1.45; }
.gtop__exit { box-shadow: 0 .06rem .2rem rgba(0, 0, 0, .5); }
.gtop__center {
--bk-c: var(--mint-700);
@@ -521,45 +521,51 @@
.settings-btn .btn__label { display: inline-flex; align-items: center; gap: .1rem; }
.settings-btn .btn__icon { color: var(--mint-500); }
.set-list { display: flex; flex-direction: column; gap: .16rem; padding-top: .06rem; }
.set-row { display: grid; grid-template-columns: 3.3rem 1fr auto; align-items: center; gap: .18rem; min-height: .44rem; }
.set-row__label { display: flex; align-items: center; gap: .1rem; font-size: .18rem; color: var(--text-hi); white-space: nowrap; }
.set-row { display: grid; grid-template-columns: minmax(0, 3.3rem) minmax(0, 1fr) auto; align-items: center; gap: .18rem; min-height: .44rem; }
/* The label column may shrink (the label wraps) instead of pushing its control out of the modal: at 设置 →「文字大小」
大 / 特大 a phone's label (a Chinese name plus its Latin micro label) is wider than 3.3rem at 1rem = 40 px. */
.set-row__label { display: flex; flex-wrap: wrap; align-items: center; gap: .1rem; font-size: calc(.18 * var(--t)); color: var(--text-hi); }
.set-row__label .icon { width: .2rem; height: .2rem; color: var(--mint-500); }
.set-row__label .micro { margin-left: .04rem; }
.set-row__val { width: .5rem; text-align: right; font-size: .2rem; color: var(--mint-400); }
/* a floor, not a fixed width: the %. value grows with the text step (20 px on a phone, 3 digits at 特大) */
.set-row__val { min-width: .5rem; text-align: right; font-size: calc(.2 * var(--t)); color: var(--mint-400); }
.set-range {
-webkit-appearance: none; appearance: none; width: 100%; height: .08rem; background: linear-gradient(90deg, var(--mint-500) var(--pct), #0a0d0c var(--pct));
border: 1px solid var(--line-2); outline: none; cursor: pointer;
}
.set-range::-webkit-slider-thumb { -webkit-appearance: none; width: .2rem; height: .26rem; background: var(--mint-400); clip-path: polygon(50% 0, 100% 30%, 100% 100%, 0 100%, 0 30%); cursor: grab; }
.set-range::-moz-range-thumb { width: .2rem; height: .26rem; background: var(--mint-400); border: 0; }
.set-toggle { grid-column: 2 / 4; justify-self: start; display: flex; align-items: center; gap: .1rem; height: .38rem; padding: 0 .14rem 0 .06rem; background: #0a0d0c; border: 1px solid var(--line-2); color: var(--text-lo); font-size: .15rem; }
.set-toggle { grid-column: 2 / 4; justify-self: start; display: flex; align-items: center; gap: .1rem; height: .38rem; padding: 0 .14rem 0 .06rem; background: #0a0d0c; border: 1px solid var(--line-2); color: var(--text-lo); font-size: calc(.15 * var(--t)); }
.set-toggle i { width: .46rem; height: .24rem; background: var(--bg-3); position: relative; transition: background var(--t-fast); }
.set-toggle i::after { content: ''; position: absolute; left: .03rem; top: .03rem; width: .18rem; height: .18rem; background: var(--text-lo); transition: transform var(--t-fast), background var(--t-fast); }
.set-toggle.is-on { color: var(--mint-400); border-color: var(--mint-700); }
.set-toggle.is-on i { background: var(--mint-800); }
.set-toggle.is-on i::after { transform: translateX(.22rem); background: var(--mint-400); }
.set-seg { grid-column: 2 / 4; justify-self: start; display: flex; border: 1px solid var(--line-2); }
.set-seg button { min-width: .8rem; height: .38rem; background: #0a0d0c; border: 0; border-right: 1px solid var(--line-2); color: var(--text-lo); font-size: .16rem; }
.set-seg { grid-column: 2 / 4; justify-self: start; display: flex; flex-wrap: wrap; max-width: 100%; border: 1px solid var(--line-2); }
.set-seg button { flex: 1 1 auto; min-width: .8rem; min-height: .38rem; height: auto; white-space: normal; background: #0a0d0c; border: 0; border-right: 1px solid var(--line-2); color: var(--text-lo); font-size: calc(.16 * var(--t)); }
/* 文字大小 (ui/settings.js): four steps, so its buttons are narrower than the two- and three-step groups' — at .8rem each
the group would be wider than the row's 1fr column on a phone (1rem = 40 px) */
.set-seg.set-textsize button { min-width: .62rem; }
.set-seg button:last-child { border-right: 0; }
.set-seg button.is-on { background: var(--mint-500); color: var(--text-on-mint); font-weight: 700; }
.set-hint { margin-top: .06rem; font-size: .14rem; color: var(--text-dim); line-height: 1.8; }
.set-hint { margin-top: .06rem; font-size: calc(.14 * var(--t)); color: var(--text-dim); line-height: 1.8; }
.set-hint kbd, .lvcard__key, .toolbtn kbd { font-family: var(--font-num); font-size: .11rem; border: 1px solid var(--line-2); padding: 0 .04rem; color: var(--text-lo); background: rgba(0, 0, 0, .3); }
/* 快捷键 (ui/settings.js HotkeySection): each shortcut with its key — click the key, press the new one; two columns,
one under 768 px (phones in landscape) */
.set-keys { display: flex; flex-direction: column; gap: .08rem; padding-top: .1rem; border-top: 1px solid var(--line); }
.set-keys__head { display: flex; align-items: center; justify-content: space-between; gap: .12rem; min-height: .44rem; }
.set-keys__head { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: .12rem; min-height: .44rem; }
.set-keys__list { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .06rem .14rem; margin: 0; padding: 0; list-style: none; }
.set-keys__row { display: flex; align-items: center; justify-content: space-between; gap: .1rem; min-height: .42rem; padding: .03rem .03rem .03rem .12rem; background: rgba(0, 0, 0, .22); border: 1px solid var(--line); }
.set-keys__name { min-width: 0; font-size: .15rem; line-height: 1.35; color: var(--text-md); overflow-wrap: anywhere; }
.set-key { flex: none; display: inline-flex; align-items: center; justify-content: center; gap: .06rem; min-width: .9rem; height: .36rem; padding: 0 .1rem; background: #0a0d0c; border: 1px solid var(--line-2); color: var(--mint-400); font-family: var(--font-num); font-size: .15rem; cursor: pointer; }
.set-keys__name { min-width: 0; font-size: calc(.15 * var(--t)); line-height: 1.35; color: var(--text-md); overflow-wrap: anywhere; }
.set-key { flex: none; display: inline-flex; align-items: center; justify-content: center; gap: .06rem; min-width: .9rem; height: .36rem; padding: 0 .1rem; background: #0a0d0c; border: 1px solid var(--line-2); color: var(--mint-400); font-family: var(--font-num); font-size: calc(.15 * var(--t)); cursor: pointer; }
.set-key kbd { font: inherit; }
.set-key:hover, .set-key:focus-visible { border-color: var(--mint-700); }
.set-key.is-waiting { border-color: var(--amber); color: var(--amber); font-family: inherit; font-size: .13rem; animation: set-key-wait 1s ease-in-out infinite alternate; }
.set-key.is-waiting { border-color: var(--amber); color: var(--amber); font-family: inherit; font-size: calc(.13 * var(--t)); animation: set-key-wait 1s ease-in-out infinite alternate; }
.set-key.is-fixed { cursor: default; color: var(--text-lo); border-style: dashed; }
.set-key.is-fixed small { font-family: inherit; font-size: .12rem; color: var(--text-dim); }
.set-keys__note { min-height: 1.5em; font-size: .14rem; line-height: 1.5; color: var(--mint-400); }
.set-key.is-fixed small { font-family: inherit; font-size: calc(.12 * var(--t)); color: var(--text-dim); }
.set-keys__note { min-height: 1.5em; font-size: calc(.14 * var(--t)); line-height: 1.5; color: var(--mint-400); }
.set-keys__note.is-warn { color: var(--amber); }
.set-keys__more > summary { padding: .06rem 0; font-size: .15rem; color: var(--mint-400); cursor: pointer; }
.set-keys__more > summary { padding: .06rem 0; font-size: calc(.15 * var(--t)); color: var(--mint-400); cursor: pointer; }
.set-keys__more[open] > summary { margin-bottom: .08rem; }
@keyframes set-key-wait { to { box-shadow: 0 0 .12rem rgba(246, 163, 41, .45); } }
/* 问题反馈 (ui/settings.js DiagSection): laid out like 快捷键 — a head with its button, a hint, the result line; the
@@ -567,10 +573,10 @@
clipboard refused it, styled like 干员调配's 导出 box (screens/loadout.css .lo-io__text) */
.set-diag { display: flex; flex-direction: column; gap: .08rem; padding-top: .1rem; border-top: 1px solid var(--line); }
.set-diag__meta { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: .04rem .2rem; }
.set-diag__stat { display: inline-flex; align-items: baseline; gap: .1rem; font-size: .14rem; color: var(--text-lo); }
.set-diag__count { font-family: var(--font-num); font-size: .2rem; line-height: 1; color: var(--mint-400); }
.set-diag__stat { display: inline-flex; align-items: baseline; gap: .1rem; font-size: calc(.14 * var(--t)); color: var(--text-lo); }
.set-diag__count { font-family: var(--font-num); font-size: calc(.2 * var(--t)); line-height: 1; color: var(--mint-400); }
.set-diag__count.is-warn { color: var(--amber); }
.set-diag__url { min-width: 0; font-size: .13rem; color: var(--text-dim); overflow-wrap: anywhere; -webkit-user-select: all; user-select: all; }
.set-diag__url { min-width: 0; font-size: calc(.13 * var(--t)); color: var(--text-dim); overflow-wrap: anywhere; -webkit-user-select: all; user-select: all; }
.set-diag__battle-hint { margin-top: -.06rem; }
.set-diag__text {
box-sizing: border-box; width: 100%; height: max(2rem, 140px); min-height: 120px; resize: vertical;
@@ -589,11 +595,11 @@
}
/* ---- exit / away ------------------------------------------------------------------------------------------------------ */
.exitm { display: flex; flex-direction: column; gap: .1rem; font-size: .17rem; line-height: 1.7; }
.exitm { display: flex; flex-direction: column; gap: .1rem; font-size: calc(.17 * var(--t)); line-height: 1.7; }
.awayov { position: fixed; inset: 0; z-index: 70; display: grid; place-items: center; background: rgba(4, 7, 6, .78); -webkit-backdrop-filter: blur(3px); backdrop-filter: blur(3px); animation: screen-in var(--t-slow) var(--ease-out); }
.awayov__box { display: flex; flex-direction: column; align-items: center; gap: .12rem; padding: .44rem .7rem; background: rgba(12, 16, 14, .96); border: 1px solid var(--line-2); }
.awayov__icon { width: .8rem; height: .8rem; color: var(--ice); filter: drop-shadow(0 0 .2rem rgba(159, 212, 255, .5)); }
.awayov__box h2 { font-size: .34rem; letter-spacing: .12em; }
.awayov__box h2 { font-size: calc(.34 * var(--t)); letter-spacing: .12em; }
/* ---- simplified (DOM) field view ---------------------------------------------------------------------------------------- */
.ff { position: absolute; inset: 0; overflow: hidden; user-select: none; }
@@ -703,7 +709,7 @@
display: flex; flex-direction: column; align-items: center; gap: .02rem; min-width: 3.6rem; padding: .16rem .6rem .2rem;
background: rgba(20, 22, 22, .9); background: var(--pause-plate, linear-gradient(90deg, transparent, rgba(12, 14, 14, .95) 18%, rgba(12, 14, 14, .95) 82%, transparent)) center / 100% 100% no-repeat;
}
.pauseov__micro { font-family: var(--font-num); font-size: .16rem; letter-spacing: .5em; color: var(--mint-400); }
.pauseov__plate h2 { font-size: .5rem; font-weight: 900; letter-spacing: .3em; color: #fff; text-shadow: 0 0 .2rem rgba(0, 0, 0, .8); }
.pauseov__note { font-size: .17rem; color: var(--text-md); text-shadow: 0 1px 2px #000; }
.pauseov__micro { font-family: var(--font-num); font-size: calc(.16 * var(--t)); letter-spacing: .5em; color: var(--mint-400); }
.pauseov__plate h2 { font-size: calc(.5 * var(--t)); font-weight: 900; letter-spacing: .3em; color: #fff; text-shadow: 0 0 .2rem rgba(0, 0, 0, .8); }
.pauseov__note { font-size: calc(.17 * var(--t)); color: var(--text-md); text-shadow: 0 1px 2px #000; }
.pauseov__btns { display: flex; gap: .2rem; }
+7 -7
View File
@@ -27,7 +27,7 @@
/* header: title | chapter tabs | close */
.guide__head { display: flex; align-items: center; gap: .3rem; min-height: .62rem; }
.guide__titles { display: flex; flex-direction: column; gap: .02rem; min-width: 2.4rem; }
.guide__title { margin: 0; font-size: .3rem; font-weight: 900; letter-spacing: .12em; color: var(--text-hi); line-height: 1.1; }
.guide__title { margin: 0; font-size: calc(.3 * var(--t)); font-weight: 900; letter-spacing: .12em; color: var(--text-hi); line-height: 1.1; }
.guide__chapters { display: flex; gap: .06rem; margin: 0 auto; }
.guide__chapter {
position: relative; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: .01rem;
@@ -38,8 +38,8 @@
}
.guide__chapter:hover { color: var(--text-hi); background: rgba(78, 216, 175, .06); }
.guide__chapter.is-on { color: var(--text-on-mint); background: var(--mint-500); border-color: var(--mint-500); }
.guide__chname { font-size: .19rem; font-weight: 700; letter-spacing: .1em; }
.guide__chmicro { font-family: var(--font-display); font-size: .1rem; letter-spacing: .24em; opacity: .75; }
.guide__chname { font-size: calc(.19 * var(--t)); font-weight: 700; letter-spacing: .1em; }
.guide__chmicro { font-family: var(--font-display); font-size: calc(.1 * var(--t)); letter-spacing: .24em; opacity: .75; }
.guide__close {
width: .56rem; height: .56rem; display: grid; place-items: center; flex: none;
background: rgba(0, 0, 0, .35); border: 1px solid var(--line-2); color: var(--text-md);
@@ -77,10 +77,10 @@
.guide__foot { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: .3rem; min-height: .44rem; }
.guide__label { display: flex; align-items: center; gap: .14rem; min-width: 0; }
.guide__chtag {
flex: none; padding: .02rem .12rem; font-size: .14rem; font-weight: 700; letter-spacing: .1em;
flex: none; padding: .02rem .12rem; font-size: calc(.14 * var(--t)); font-weight: 700; letter-spacing: .1em;
color: var(--mint-400); border: 1px solid var(--mint-800); background: var(--mint-a10);
}
.guide__ptitle { font-size: .22rem; font-weight: 700; letter-spacing: .06em; color: var(--text-hi); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.guide__ptitle { font-size: calc(.22 * var(--t)); font-weight: 700; letter-spacing: .06em; color: var(--text-hi); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.guide__dots { display: flex; align-items: center; gap: .05rem; }
.guide__dot {
width: .2rem; height: .08rem; padding: 0; border: 0; background: var(--line-2);
@@ -89,12 +89,12 @@
.guide__dot.is-first { margin-left: .14rem; }
.guide__dot:hover { background: var(--line-3); }
.guide__dot.is-on { width: .42rem; background: var(--mint-500); box-shadow: 0 0 .08rem rgba(23, 249, 183, .5); }
.guide__count { justify-self: end; font-size: .24rem; color: var(--text-md); letter-spacing: .06em; }
.guide__count { justify-self: end; font-size: calc(.24 * var(--t)); color: var(--text-md); letter-spacing: .06em; }
/* text fallback: the official tips when no tutorial page can be shown (none listed, or none of a page's copies loads) */
.guide__stage--text { width: min(92vw, 10rem); max-height: calc(100vh - 2rem); overflow: auto; padding: .1rem .06rem; }
.guide__tips ol { margin: .14rem 0 0; padding-left: .34rem; display: flex; flex-direction: column; gap: .08rem; }
.guide__tips li { font-size: .18rem; line-height: 1.6; color: var(--text-md); }
.guide__tips li { font-size: calc(.18 * var(--t)); line-height: 1.6; color: var(--text-md); }
.guide__tips li::marker { color: var(--mint-500); font-family: var(--font-num); }
/* standard trigger */
+5
View File
@@ -634,3 +634,8 @@
.lo-io__text:focus { border-color: var(--mint-700); }
.lo-io__text::placeholder { color: var(--text-lo); }
.lo-io__file { display: none; }
/* Per-operator voice, a browser-local preference in roster and DIY details. */
.lo-voice { display: flex; flex-wrap: wrap; align-items: center; gap: .12rem; padding: .14rem 0; font-size: calc(.18 * var(--t)); }
.lo-voice > small { flex-basis: 100%; color: var(--text-dim); font-size: calc(.15 * var(--t)); }
.lo-voice .lo-select { min-width: 1.6rem; max-width: 100%; }
+19 -19
View File
@@ -71,9 +71,9 @@
}
.mode-card__icon .icon { width: .44rem; height: .44rem; }
.mode-card__text { position: relative; display: flex; flex-direction: column; gap: .1rem; }
.mode-card__name { font-size: .46rem; font-weight: 900; letter-spacing: .08em; line-height: 1.1; }
.mode-card__desc { font-size: .17rem; color: var(--text-lo); line-height: 1.65; max-width: 3.6rem; }
.mode-card__points { display: flex; flex-direction: column; gap: .05rem; margin-top: .06rem; font-size: .15rem; color: var(--text-md); }
.mode-card__name { font-size: calc(.46 * var(--t)); font-weight: 900; letter-spacing: .08em; line-height: 1.1; }
.mode-card__desc { font-size: calc(.17 * var(--t)); color: var(--text-lo); line-height: 1.65; max-width: 3.6rem; }
.mode-card__points { display: flex; flex-direction: column; gap: .05rem; margin-top: .06rem; font-size: calc(.15 * var(--t)); color: var(--text-md); }
.mode-card__points span::before {
content: '';
display: inline-block;
@@ -94,7 +94,7 @@
padding: .04rem .12rem .04rem .08rem;
background: var(--mint-500);
color: var(--text-on-mint);
font-size: .14rem;
font-size: calc(.14 * var(--t));
font-weight: 700;
opacity: 0;
transform: translateY(-.06rem);
@@ -118,13 +118,13 @@
.join-row { display: flex; gap: .16rem; align-items: stretch; }
.join-row .field { flex: 1; }
.join-row .btn { height: .64rem; }
.join-foot { display: flex; align-items: center; flex-wrap: wrap; gap: .1rem; min-height: .32rem; font-size: .14rem; }
.join-foot { display: flex; align-items: center; flex-wrap: wrap; gap: .1rem; min-height: .32rem; font-size: calc(.14 * var(--t)); }
.code-chip {
padding: .03rem .12rem .03rem .16rem;
border: 1px solid var(--line-2);
background: rgba(0, 0, 0, .3);
color: var(--amber);
font-size: .17rem;
font-size: calc(.17 * var(--t));
font-weight: 700;
letter-spacing: .2em;
transition: border-color var(--t-fast), background-color var(--t-fast);
@@ -139,9 +139,9 @@
background: rgba(8, 10, 9, .55);
border: 1px solid var(--line);
}
.tips__head { display: flex; align-items: center; gap: .1rem; font-size: .16rem; font-weight: 700; color: var(--text-md); }
.tips__head { display: flex; align-items: center; gap: .1rem; font-size: calc(.16 * var(--t)); font-weight: 700; color: var(--text-md); }
.tips__head > .icon { width: .2rem; height: .2rem; color: var(--mint-500); }
.tips__idx { margin-left: auto; font-size: .16rem; color: var(--text-md); }
.tips__idx { margin-left: auto; font-size: calc(.16 * var(--t)); color: var(--text-md); }
.tips__nav {
display: grid;
place-items: center;
@@ -155,7 +155,7 @@
}
.tips__nav:hover { color: var(--mint-400); border-color: var(--mint-700); }
.tips__nav .icon { width: .18rem; height: .18rem; }
.tips__text { margin-top: .1rem; font-size: .17rem; color: var(--text-lo); line-height: 1.6; animation: tip-in var(--t-slow) var(--ease-out); }
.tips__text { margin-top: .1rem; font-size: calc(.17 * var(--t)); color: var(--text-lo); line-height: 1.6; animation: tip-in var(--t-slow) var(--ease-out); }
@keyframes tip-in { from { opacity: 0; transform: translateX(.1rem); } }
/* ---- difficulty cards -------------------------------------------------------------------------------- */
@@ -182,20 +182,20 @@
.diff-card__bar { position: absolute; left: 0; top: 0; bottom: 0; width: .06rem; background: var(--d-color); opacity: .45; transition: opacity var(--t-med), box-shadow var(--t-med); }
.diff-card__head { grid-column: 1 / -1; display: flex; align-items: center; gap: .1rem; min-width: 0; }
.diff-card__glyph { width: .28rem; height: .28rem; color: var(--d-color); filter: drop-shadow(0 0 5px var(--d-color)); filter: drop-shadow(0 0 5px color-mix(in srgb, var(--d-color) 55%, transparent)); }
.diff-card__name { font-size: .27rem; font-weight: 900; letter-spacing: .06em; color: var(--d-color); }
.diff-card__code { font-size: .14rem; color: var(--text-dim); letter-spacing: .1em; }
.diff-card__meta { margin-left: auto; display: flex; align-items: center; gap: .04rem; font-size: .14rem; color: var(--text-lo); white-space: nowrap; }
.diff-card__meta .num { color: var(--text-hi); font-weight: 700; font-size: .17rem; }
.diff-card__name { font-size: calc(.27 * var(--t)); font-weight: 900; letter-spacing: .06em; color: var(--d-color); }
.diff-card__code { font-size: calc(.14 * var(--t)); color: var(--text-dim); letter-spacing: .1em; }
.diff-card__meta { margin-left: auto; display: flex; align-items: center; gap: .04rem; font-size: calc(.14 * var(--t)); color: var(--text-lo); white-space: nowrap; }
.diff-card__meta .num { color: var(--text-hi); font-weight: 700; font-size: calc(.17 * var(--t)); }
.diff-card__hidden {
margin-left: .08rem;
padding: 0 .07rem;
border: 1px solid transparent; border: 1px solid color-mix(in srgb, var(--d-color) 45%, transparent);
color: var(--d-color); color: color-mix(in srgb, var(--d-color) 80%, #fff);
font-size: .12rem;
font-size: calc(.12 * var(--t));
line-height: 1.6;
}
.diff-card__desc { grid-column: 1; font-size: .16rem; color: var(--text-md); }
.diff-card__effects { grid-column: 1; display: flex; flex-wrap: wrap; gap: .04rem .2rem; font-size: .14rem; color: var(--text-lo); }
.diff-card__desc { grid-column: 1; font-size: calc(.16 * var(--t)); color: var(--text-md); }
.diff-card__effects { grid-column: 1; display: flex; flex-wrap: wrap; gap: .04rem .2rem; font-size: calc(.14 * var(--t)); color: var(--text-lo); }
.diff-card__effects span::before { content: '·'; margin-right: .05rem; color: var(--d-color); font-weight: 900; }
/* battlefield pool note (config.json modes[].stages): 战场固定为 战场#01 / 战场随机(共N张) — the last item of the
effects row (no extra card height: the lobby must not scroll at 1280×720 / 1920×1080) */
@@ -210,7 +210,7 @@
flex-direction: column;
align-items: center;
gap: .03rem;
font-size: .12rem;
font-size: calc(.12 * var(--t));
color: var(--text-dim);
}
.diff-card__check .icon {
@@ -236,5 +236,5 @@
/* ---- create ------------------------------------------------------------------------------------------ */
.create-box { margin-top: .3rem; display: flex; flex-direction: column; gap: .12rem; }
.create-box .btn--xl { height: .92rem; font-size: .32rem; letter-spacing: .3em; }
.create-box__hint { display: flex; align-items: center; justify-content: center; min-height: .3rem; font-size: .14rem; color: var(--text-lo); }
.create-box .btn--xl { height: .92rem; font-size: calc(.32 * var(--t)); letter-spacing: .3em; }
.create-box__hint { display: flex; align-items: center; justify-content: center; min-height: .3rem; font-size: calc(.14 * var(--t)); color: var(--text-lo); }
+18 -18
View File
@@ -11,14 +11,14 @@
.result__main { position: relative; z-index: 1; flex: 1; min-height: 0; display: grid; grid-template-columns: 5.6rem 1fr; gap: .5rem; padding: .36rem .5rem .3rem .6rem; }
.result__hero { display: flex; flex-direction: column; align-items: flex-start; gap: .16rem; padding-top: .3rem; min-height: 0; }
.result__logoimg { height: 1.1rem; width: auto; opacity: .92; }
.result__headline { font-size: .96rem; font-weight: 900; letter-spacing: .12em; line-height: 1.05; color: var(--mint-400); text-shadow: 0 0 .4rem rgba(23, 249, 183, .45); animation: head-in 600ms var(--ease-out); }
.result__headline { font-size: calc(.96 * var(--t)); font-weight: 900; letter-spacing: .12em; line-height: 1.05; color: var(--mint-400); text-shadow: 0 0 .4rem rgba(23, 249, 183, .45); animation: head-in 600ms var(--ease-out); }
.result.is-lose .result__headline { color: var(--red-premium); text-shadow: 0 0 .4rem rgba(255, 60, 60, .4); }
@keyframes head-in { from { opacity: 0; letter-spacing: .5em; } }
.result__sub { font-size: .2rem; color: var(--text-lo); letter-spacing: .1em; }
.result__sub { font-size: calc(.2 * var(--t)); color: var(--text-lo); letter-spacing: .1em; }
.result__rounds { display: flex; align-items: baseline; gap: .12rem; margin-top: .1rem; }
.result__rlabel { font-size: .22rem; color: var(--text-md); letter-spacing: .14em; }
.result__rnum { font-size: 1.5rem; line-height: .9; font-weight: 700; color: #fff; }
.result__rof { font-size: .4rem; color: var(--text-dim); }
.result__rlabel { font-size: calc(.22 * var(--t)); color: var(--text-md); letter-spacing: .14em; }
.result__rnum { font-size: calc(1.5 * var(--t)); line-height: .9; font-weight: 700; color: #fff; }
.result__rof { font-size: calc(.4 * var(--t)); color: var(--text-dim); }
.result__medals { display: flex; gap: .24rem; margin-top: .12rem; }
.medal { position: relative; width: 1.1rem; display: flex; flex-direction: column; align-items: center; gap: .08rem; }
.medal img { width: .96rem; height: .96rem; object-fit: cover; border-radius: 50%; border: 3px solid var(--line-3); background: #151a18; filter: grayscale(.7) brightness(.7); }
@@ -27,8 +27,8 @@
.medal__check { position: absolute; right: .04rem; top: .64rem; width: .32rem; height: .32rem; display: grid; place-items: center; background: var(--text-dim); color: #fff; border-radius: 50%; }
.medal.is-done .medal__check { background: var(--gold); color: #1d1400; }
.medal__check .icon { width: .2rem; height: .2rem; }
.medal__label { font-size: .14rem; color: var(--text-lo); }
.result__time { font-size: .16rem; }
.medal__label { font-size: calc(.14 * var(--t)); color: var(--text-lo); }
.result__time { font-size: calc(.16 * var(--t)); }
.result__time b { color: var(--text-hi); }
.result__players { display: flex; flex-direction: column; gap: .1rem; min-height: 0; overflow: auto; padding-right: .06rem; }
@@ -45,34 +45,34 @@
.rcard__head { display: grid; grid-template-columns: auto minmax(1.5rem, 2.1rem) minmax(0, 1fr) auto 2.3rem; gap: .18rem; align-items: center; }
.rcard__head .pavatar { --pa: .6rem; }
.rcard__who { display: flex; flex-direction: column; gap: .03rem; min-width: 0; }
.rcard__name { font-size: .22rem; display: flex; align-items: center; gap: .08rem; }
.rcard__ai, .rcard__you { font-size: .11rem; padding: 0 .05rem; font-weight: 700; }
.rcard__name { font-size: calc(.22 * var(--t)); display: flex; align-items: center; gap: .08rem; }
.rcard__ai, .rcard__you { font-size: calc(.11 * var(--t)); padding: 0 .05rem; font-weight: 700; }
.rcard__ai { background: #a9c3d8; color: #0b1520; }
.rcard__you { background: var(--mint-500); color: var(--text-on-mint); }
.rcard__band { display: inline-flex; align-items: center; gap: .06rem; font-size: .15rem; color: var(--text-lo); }
.rcard__band { display: inline-flex; align-items: center; gap: .06rem; font-size: calc(.15 * var(--t)); color: var(--text-lo); }
.rcard__round { display: flex; flex-direction: column; align-items: center; }
.rcard__round b { font-size: .34rem; line-height: 1; color: #fff; }
.rcard__gain { display: flex; gap: .08rem; margin-top: .03rem; font-size: .13rem; color: var(--gold); }
.rcard__round b { font-size: calc(.34 * var(--t)); line-height: 1; color: #fff; }
.rcard__gain { display: flex; gap: .08rem; margin-top: .03rem; font-size: calc(.13 * var(--t)); color: var(--gold); }
.rcard__gain span { display: inline-flex; align-items: center; gap: .02rem; }
.rcard__gain .icon { width: .14rem; height: .14rem; }
.rcard__gain b { font-size: .14rem; color: var(--gold); }
.rcard__gain b { font-size: calc(.14 * var(--t)); color: var(--gold); }
.rcard__title { display: flex; align-items: center; gap: .08rem; padding: .06rem .14rem .06rem .06rem; border: 1px solid rgba(255, 198, 0, .4); background: rgba(255, 198, 0, .06); min-width: 0; }
.rcard__title--none { border-style: dashed; border-color: var(--line); background: none; height: .64rem; }
.rcard__title > span { display: flex; flex-direction: column; }
.rcard__title b { font-size: .19rem; color: var(--gold); }
.rcard__title b { font-size: calc(.19 * var(--t)); color: var(--gold); }
.rcard__ticon { width: .5rem; height: .5rem; object-fit: contain; }
.rcard__title .icon { width: .3rem; height: .3rem; color: var(--gold); }
.rcard__mid { display: flex; flex-direction: column; justify-content: center; gap: .06rem; min-width: 0; }
.rcard__lineup .uthumb { --ut: .46rem; }
.rcard__lineup { display: flex; flex-wrap: wrap; gap: .05rem; }
.rcard__noinfo { font-family: var(--font-display); font-size: .14rem; letter-spacing: .3em; color: var(--text-dim); padding: .14rem .2rem; border: 1px dashed var(--line-2); }
.rcard__noinfo { font-family: var(--font-display); font-size: calc(.14 * var(--t)); letter-spacing: .3em; color: var(--text-dim); padding: .14rem .2rem; border: 1px dashed var(--line-2); }
.rcard__bonds { display: flex; gap: .05rem; flex-wrap: wrap; }
.rbond { display: inline-flex; align-items: center; gap: .04rem; padding: .01rem .07rem .01rem .03rem; border: 1px solid var(--line-2); font-size: .13rem; color: var(--text-lo); }
.rbond { display: inline-flex; align-items: center; gap: .04rem; padding: .01rem .07rem .01rem .03rem; border: 1px solid var(--line-2); font-size: calc(.13 * var(--t)); color: var(--text-lo); }
.rbond .bglyph { width: .2rem; height: .2rem; }
.rbond.is-on { border-color: var(--mint-800); color: var(--mint-400); }
.rcard__stats { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 1px; }
.rstat { display: flex; align-items: baseline; justify-content: space-between; gap: .06rem; padding: .05rem .1rem; background: #0e1210; box-shadow: 0 0 0 1px var(--line); font-size: .13rem; color: var(--text-lo); white-space: nowrap; min-width: 0; }
.rstat b { font-size: .18rem; color: #fff; }
.rstat { display: flex; align-items: baseline; justify-content: space-between; gap: .06rem; padding: .05rem .1rem; background: #0e1210; box-shadow: 0 0 0 1px var(--line); font-size: calc(.13 * var(--t)); color: var(--text-lo); white-space: nowrap; min-width: 0; }
.rstat b { font-size: calc(.18 * var(--t)); color: #fff; }
.rstat.is-best b { color: var(--gold); }
.result__foot { margin-top: auto; padding-top: .2rem; display: flex; }
.result__foot .btn { min-width: 3.4rem; }
+23 -23
View File
@@ -17,7 +17,7 @@
background: rgba(0, 0, 0, .38);
border: 1px solid var(--line);
}
.invite__label { grid-column: 1 / -1; display: flex; align-items: center; gap: .07rem; font-size: .14rem; color: var(--text-lo); }
.invite__label { grid-column: 1 / -1; display: flex; align-items: center; gap: .07rem; font-size: calc(.14 * var(--t)); color: var(--text-lo); }
.invite__label .icon { width: .16rem; height: .16rem; color: var(--mint-500); }
.invite__code { display: flex; gap: .06rem; }
.invite__code span {
@@ -28,13 +28,13 @@
background: linear-gradient(180deg, rgba(78, 216, 175, .1), rgba(78, 216, 175, .02));
border-bottom: 2px solid var(--mint-600);
color: var(--mint-400);
font-size: .44rem;
font-size: calc(.44 * var(--t));
font-weight: 700;
line-height: 1;
text-shadow: 0 0 .16rem rgba(23, 249, 183, .45);
}
.invite__btns { display: flex; flex-direction: column; gap: .06rem; }
.solo-note { display: flex; flex-direction: column; align-items: flex-end; gap: .03rem; font-size: .16rem; color: var(--text-lo); }
.solo-note { display: flex; flex-direction: column; align-items: flex-end; gap: .03rem; font-size: calc(.16 * var(--t)); color: var(--text-lo); }
/* ---- seats ------------------------------------------------------------------------------------------------ */
.seats {
@@ -82,7 +82,7 @@
transition: background var(--t-med), box-shadow var(--t-med);
}
.seat__head { align-self: stretch; display: flex; align-items: center; gap: .1rem; }
.seat__no { font-size: .22rem; font-weight: 700; color: var(--text-md); padding-left: .08rem; border-left: 3px solid var(--line-3); line-height: 1.1; }
.seat__no { font-size: calc(.22 * var(--t)); font-weight: 700; color: var(--text-md); padding-left: .08rem; border-left: 3px solid var(--line-3); line-height: 1.1; }
.seat__host {
margin-left: auto;
display: flex;
@@ -91,7 +91,7 @@
padding: .02rem .09rem;
background: var(--gold);
color: #1d1400;
font-size: .13rem;
font-size: calc(.13 * var(--t));
font-weight: 700;
}
.seat__host .icon { width: .15rem; height: .15rem; }
@@ -131,13 +131,13 @@
background: rgba(159, 212, 255, .1);
border: 1px solid rgba(159, 212, 255, .35);
color: var(--ice);
font-size: .14rem;
font-size: calc(.14 * var(--t));
font-weight: 700;
}
.seat__bot-label .icon { width: .16rem; height: .16rem; }
.seat__who { display: flex; align-items: center; gap: .08rem; max-width: 100%; }
.seat__name { font-size: .26rem; font-weight: 700; max-width: 3rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.seat__you { flex: none; padding: .01rem .07rem; background: var(--mint-500); color: var(--text-on-mint); font-size: .13rem; font-weight: 700; }
.seat__name { font-size: calc(.26 * var(--t)); font-weight: 700; max-width: 3rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.seat__you { flex: none; padding: .01rem .07rem; background: var(--mint-500); color: var(--text-on-mint); font-size: calc(.13 * var(--t)); font-weight: 700; }
.seat__foot {
align-self: stretch;
display: flex;
@@ -150,7 +150,7 @@
border-top: 1px solid var(--line);
}
.seat__foot .tt-anchor { position: absolute; right: .14rem; bottom: .14rem; }
.seat__state { display: flex; align-items: center; gap: .06rem; font-size: .18rem; font-weight: 700; letter-spacing: .06em; }
.seat__state { display: flex; align-items: center; gap: .06rem; font-size: calc(.18 * var(--t)); font-weight: 700; letter-spacing: .06em; }
.seat__state .icon { width: .22rem; height: .22rem; }
.seat__state--ready { color: var(--mint-400); }
.seat__state--waiting { color: var(--text-lo); }
@@ -185,7 +185,7 @@
animation: radar-sweep 4s linear infinite;
}
@keyframes radar-sweep { to { transform: rotate(360deg); } }
.seat__wait { font-size: .2rem; color: var(--text-lo); letter-spacing: .08em; }
.seat__wait { font-size: calc(.2 * var(--t)); color: var(--text-lo); letter-spacing: .08em; }
.seat--empty .seat__foot { border-top-style: dashed; }
.solo-brief {
@@ -202,9 +202,9 @@
border: 1px solid var(--line);
animation: seat-in var(--t-slow) 80ms var(--ease-out) backwards;
}
.solo-brief h2 { font-size: .36rem; font-weight: 900; letter-spacing: .06em; }
.solo-brief p { font-size: .18rem; color: var(--text-md); }
.solo-brief ul { margin: .06rem 0 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: .08rem; font-size: .16rem; color: var(--text-lo); }
.solo-brief h2 { font-size: calc(.36 * var(--t)); font-weight: 900; letter-spacing: .06em; }
.solo-brief p { font-size: calc(.18 * var(--t)); color: var(--text-md); }
.solo-brief ul { margin: .06rem 0 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: .08rem; font-size: calc(.16 * var(--t)); color: var(--text-lo); }
.solo-brief li::before { content: ''; display: inline-block; width: .06rem; height: .06rem; margin: 0 .12rem .02rem 0; background: var(--mint-600); transform: rotate(45deg); vertical-align: middle; }
.solo-brief b { color: var(--text-hi); }
@@ -217,7 +217,7 @@
justify-content: center;
gap: .14rem;
padding: 0 .6rem .16rem;
font-size: .15rem;
font-size: calc(.15 * var(--t));
color: var(--text-lo);
}
.specbar__label { display: flex; align-items: baseline; gap: .06rem; font-weight: 700; color: var(--text-md); }
@@ -260,10 +260,10 @@
background: linear-gradient(90deg, var(--mint-700), transparent 30%, transparent 70%, var(--mint-700));
}
.room-bar__left { display: flex; align-items: center; gap: .22rem; min-width: 0; }
.room-bar__label { display: flex; flex-direction: column; font-size: .17rem; font-weight: 700; color: var(--text-md); line-height: 1.35; }
.room-bar__label { display: flex; flex-direction: column; font-size: calc(.17 * var(--t)); font-weight: 700; color: var(--text-md); line-height: 1.35; }
.room-bar__center { display: flex; flex-direction: column; align-items: center; gap: .06rem; }
.room-bar__right { justify-self: end; display: flex; align-items: center; gap: .16rem; }
.room-bar__status { font-size: .15rem; }
.room-bar__status { font-size: calc(.15 * var(--t)); }
.room-bar__status .icon { width: .18rem; height: .18rem; margin-right: .05rem; vertical-align: -.03rem; }
.dpick { display: flex; border: 1px solid var(--line-2); background: rgba(0, 0, 0, .3); }
@@ -279,7 +279,7 @@
border-right: 1px solid var(--line);
background: transparent;
color: var(--text-lo);
font-size: .18rem;
font-size: calc(.18 * var(--t));
font-weight: 700;
letter-spacing: .08em;
transition: color var(--t-fast), background-color var(--t-med), box-shadow var(--t-med);
@@ -295,19 +295,19 @@
box-shadow: inset 0 -3px 0 var(--d-color);
}
.dpick__opt.is-active .icon { opacity: 1; filter: drop-shadow(0 0 5px var(--d-color)); }
.dpick--ro { border: 0; background: none; gap: .18rem; align-items: center; font-size: .14rem; }
.dpick--ro { border: 0; background: none; gap: .18rem; align-items: center; font-size: calc(.14 * var(--t)); }
/* 「AI 队友最后选择」 (GitHub #338, co-op): one switch styled like a difficulty option, under the difficulty picker */
.room-bar__opts { display: flex; flex-direction: column; align-items: flex-start; gap: .08rem; min-width: 0; }
.ailast { display: flex; align-items: center; gap: .12rem; font-size: .14rem; }
.ailast__opt { --d-color: var(--mint-400); height: .36rem; padding: 0 .14rem; border: 1px solid var(--line-2); background: rgba(0, 0, 0, .3); font-size: .15rem; letter-spacing: .04em; }
.ailast { display: flex; align-items: center; gap: .12rem; font-size: calc(.14 * var(--t)); }
.ailast__opt { --d-color: var(--mint-400); height: .36rem; padding: 0 .14rem; border: 1px solid var(--line-2); background: rgba(0, 0, 0, .3); font-size: calc(.15 * var(--t)); letter-spacing: .04em; }
.ailast__opt:last-child { border-right: 1px solid var(--line-2); }
.ailast__opt .icon { opacity: .25; }
.ailast__opt .icon.is-on { opacity: 1; }
.ready-count[hidden] { display: none; }
.ready-count { display: flex; align-items: baseline; gap: .06rem; font-size: .16rem; }
.ready-count b { font-size: .36rem; line-height: 1; color: var(--mint-400); text-shadow: 0 0 .14rem rgba(23, 249, 183, .45); }
.ready-count .t-dim { font-size: .22rem; }
.ready-count { display: flex; align-items: baseline; gap: .06rem; font-size: calc(.16 * var(--t)); }
.ready-count b { font-size: calc(.36 * var(--t)); line-height: 1; color: var(--mint-400); text-shadow: 0 0 .14rem rgba(23, 249, 183, .45); }
.ready-count .t-dim { font-size: calc(.22 * var(--t)); }
.ready-count__icons { display: flex; gap: .02rem; margin-left: .12rem; align-self: center; }
.ready-count__icons .icon { width: .28rem; height: .28rem; color: var(--line-3); transition: color var(--t-med); }
.ready-count__icons .icon.is-on { color: var(--mint-400); filter: drop-shadow(0 0 4px var(--mint-glow)); }
+12 -10
View File
@@ -197,7 +197,7 @@
.title-cn em { font-style: normal; color: var(--mint-500); text-shadow: 0 0 .26rem rgba(23, 249, 183, .45); }
.title-tag {
margin-top: .12rem;
font-size: .19rem;
font-size: calc(.19 * var(--t));
color: var(--text-lo);
letter-spacing: .1em;
animation: rise-in 600ms 200ms var(--ease-out) backwards;
@@ -238,21 +238,23 @@
background: rgba(246, 163, 41, .08);
border: 1px solid rgba(246, 163, 41, .35);
color: var(--amber);
font-size: .16rem;
font-size: calc(.16 * var(--t));
}
.title-invite .icon { width: .2rem; height: .2rem; }
.title-invite b { font-size: .2rem; letter-spacing: .2em; color: #ffd08a; }
.title-conn { display: flex; align-items: center; justify-content: center; gap: .1rem; min-height: .32rem; font-size: .14rem; color: var(--text-lo); }
.title-invite b { font-size: calc(.2 * var(--t)); letter-spacing: .2em; color: #ffd08a; }
.title-conn { display: flex; align-items: center; justify-content: center; gap: .1rem; min-height: .32rem; font-size: calc(.14 * var(--t)); color: var(--text-lo); }
/* 统计 (the local stats page) beside the language menu in the top-right corner */
.title-corner__tools { display: flex; align-items: center; justify-content: flex-end; gap: .1rem; margin-bottom: .1rem; }
.title-corner__tools .title-lang { margin-bottom: 0; }
/* language menu (中文 | English | …, ui/lang.js) in the top-right corner; more than four languages make it a list */
/* language menu (中文 | English | …, ui/lang.js) in the top-right corner; more than four languages make it a list.
The height is max(design, 1.4em) so the buttons still hold their text at 设置 →「文字大小」 大 / 特大 (on a phone
1rem is clamped at 40 px and .3rem is only 12 px, less than the enlarged label). */
.title-lang { display: inline-flex; margin-bottom: .1rem; }
.title-lang button { min-width: .74rem; height: .3rem; font-size: .14rem; cursor: pointer; }
.lang-select select { height: .38rem; min-width: 1.6rem; padding: 0 .12rem; background: #0a0d0c; border: 0; color: var(--text-hi); font-size: .16rem; cursor: pointer; }
.title-lang.lang-select select { height: .3rem; font-size: .14rem; }
.title-lang button { min-width: .74rem; height: max(.3rem, 1.4em); font-size: calc(.14 * var(--t)); cursor: pointer; }
.lang-select select { height: max(.38rem, 1.4em); min-width: 1.6rem; padding: 0 .12rem; background: #0a0d0c; border: 0; color: var(--text-hi); font-size: calc(.16 * var(--t)); cursor: pointer; }
.title-lang.lang-select select { height: max(.3rem, 1.4em); font-size: calc(.14 * var(--t)); }
html[data-script="alphabetic"] .title-login .btn--xl { letter-spacing: .3em; }
.title-conn .ping { height: .26rem; font-size: .15rem; }
.title-conn .ping { height: max(.26rem, 1.6em); font-size: calc(.15 * var(--t)); }
.title-foot {
position: absolute;
@@ -263,7 +265,7 @@ html[data-script="alphabetic"] .title-login .btn--xl { letter-spacing: .3em; }
display: flex;
justify-content: space-between;
align-items: flex-end;
font-size: .13rem;
font-size: calc(.13 * var(--t));
color: var(--text-dim);
}
/* a development build (shared/constants.js DEV_BUILD): an unmissable tag in the footer */
+18 -2
View File
@@ -125,6 +125,22 @@ html {
background: var(--bg-0);
}
/* ---- text root: 设置 →「文字大小」 ---------------------------------------------------------------
1rem above is the LAYOUT root, and five modules convert it to pixels with getComputedStyle:
ui/fieldHost.js hudPadding / hudBands (the prep camera's HUD clearance), ui/gameLogic/panel.js
panelSlots (which side the detail card docks), ui/underframe.js (the tile labels' pixel budget) and
ui/fallbackField.js (the DOM board) — raising it would move the board, not just the text.
--t is the TEXT root: it appears only in font-size declarations, so a player can enlarge the
interface text without touching any layout. The field itself is sized from host.clientWidth /
clientHeight (render/app.js), which no font-size can reach.
小 = 1rem reproduces the design's own sizes exactly, on every device. The other steps keep the
desktop gentle and lift the phone: 1rem there is clamped at 40 px by the rule above, which is what
made the .18rem body text 7.2 CSS px. */
:root { --t: 1rem; }
:root[data-text="md"] { --t: max(1rem, 64px); }
:root[data-text="lg"] { --t: max(calc(1rem * 1.15), 76px); }
:root[data-text="xl"] { --t: max(calc(1rem * 1.3), 88px); }
*, *::before, *::after { box-sizing: border-box; }
body {
@@ -134,7 +150,7 @@ body {
background: var(--bg-0);
color: var(--text-hi);
font-family: var(--font-cjk);
font-size: .18rem;
font-size: calc(.18 * var(--t));
line-height: 1.5;
font-weight: 400;
-webkit-font-smoothing: antialiased;
@@ -170,7 +186,7 @@ h1, h2, h3, h4, p { margin: 0; }
.display { font-family: var(--font-display); text-transform: uppercase; letter-spacing: .12em; }
.micro {
font-family: var(--font-display);
font-size: .11rem;
font-size: calc(.11 * var(--t));
line-height: 1.2;
letter-spacing: .18em;
text-transform: uppercase;
+25 -1
View File
@@ -1183,5 +1183,29 @@
"精英2": "E2",
"精英2 Lv.60": "E2 Lv.60",
"开始模拟前可调整干员携带的技能与模组,以及潜能与练度;干员的局内等级不可调整": "Before the simulation starts you can change the Skills and Modules your Operators carry, and their potential and development; Operator levels in the match cannot be changed",
"默认满潜能、精英2 Lv.60(满加成)。官方的潜能取你自己的潜能,练度是自持有加成(已持有的同名干员的养成,攻击 / 防御 / 生命单独乘算);未持有的特许干员在官方按潜能1、没有加成,要照官方打请手动设为潜能1、未精英化。": "Default: max Potential, Elite 2 Lv.60 (the full bonus). Officially, Potential is your own and development is the owned-operator bonus (your training of the same Operator: ATK / DEF / HP, a separate multiplier); a licensed Operator you do not own fights at Potential 1 with no bonus — to play it that way, set Potential 1 and Non-Elite here."
"默认满潜能、精英2 Lv.60(满加成)。官方的潜能取你自己的潜能,练度是自持有加成(已持有的同名干员的养成,攻击 / 防御 / 生命单独乘算);未持有的特许干员在官方按潜能1、没有加成,要照官方打请手动设为潜能1、未精英化。": "Default: max Potential, Elite 2 Lv.60 (the full bonus). Officially, Potential is your own and development is the owned-operator bonus (your training of the same Operator: ATK / DEF / HP, a separate multiplier); a licensed Operator you do not own fights at Potential 1 with no bonus — to play it that way, set Potential 1 and Non-Elite here.",
"文字大小": "Text Size",
"小": "Small",
"大": "Large",
"特大": "Extra Large",
"调整界面文字大小,棋盘保持原比例。": "Adjust interface text size while keeping the board at its original scale.",
"此干员语音": "Operator voice",
"跟随全局": "Use global setting",
"仅保存在此浏览器,缺失的日语语音会回退到中文。": "Saved in this browser only. Missing Japanese lines use Chinese.",
"添加到桌面": "Add to Home Screen",
"恢复本机对局": "Resume local match",
"此对局仍在其他窗口中,或当前浏览器无法安全恢复。请关闭原窗口后重试。": "This match is still open in another window, or this browser cannot safely resume it. Close the original window and retry.",
"选择已关闭窗口的对局。服务器重启或对局过期后无法恢复。": "Select a match from a closed window. Matches cannot be resumed after a server restart or expiry.",
"{name} · 同盟 {code}": "{name} · Alliance {code}",
"重刷本局": "Reroll setup",
"重刷本局投票:{agreed}/{total}": "Reroll vote: {agreed}/{total}",
"全员同意后重新随机战场、领袖、特训敌人和禁用;确认倒计时已暂停。": "With unanimous consent, reroll the field, bosses, training enemies and bans. The confirmation timer is paused.",
"保留房间和个人配置,重新随机全部开局信息。": "Keep the room and player settings, and reroll the full opening setup.",
"已同意": "Agreed",
"待同意": "Waiting",
"全体真人玩家一致同意后刷新,随机结果可能重复。": "All human players must agree. Random results may repeat.",
"请等待所有玩家连接后再发起投票": "Wait for all players to connect before starting a vote.",
"同意重刷": "Agree to reroll",
"拒绝重刷": "Reject reroll",
"取消投票": "Cancel vote"
}
+25 -1
View File
@@ -1189,5 +1189,29 @@
"精英2": "昇進2",
"精英2 Lv.60": "昇進2 Lv60",
"开始模拟前可调整干员携带的技能与模组,以及潜能与练度;干员的局内等级不可调整": "シミュレーション開始前に、オペレーターのスキルとモジュール、潜在と育成度を調整できます(局内のオペレーターのレベルは変更不可)",
"默认满潜能、精英2 Lv.60(满加成)。官方的潜能取你自己的潜能,练度是自持有加成(已持有的同名干员的养成,攻击 / 防御 / 生命单独乘算);未持有的特许干员在官方按潜能1、没有加成,要照官方打请手动设为潜能1、未精英化。": "初期値は潜在最大・昇進2 Lv60(ボーナス最大)。公式では潜在はあなた自身の潜在、育成度は所持オペレーターボーナス(同名オペレーターの育成度による攻撃・防御・HPの独立した乗算)です。未所持のプリセットオペレーターは公式では潜在1・ボーナスなしのため、公式どおりにするには潜在1・未昇進に設定してください。"
"默认满潜能、精英2 Lv.60(满加成)。官方的潜能取你自己的潜能,练度是自持有加成(已持有的同名干员的养成,攻击 / 防御 / 生命单独乘算);未持有的特许干员在官方按潜能1、没有加成,要照官方打请手动设为潜能1、未精英化。": "初期値は潜在最大・昇進2 Lv60(ボーナス最大)。公式では潜在はあなた自身の潜在、育成度は所持オペレーターボーナス(同名オペレーターの育成度による攻撃・防御・HPの独立した乗算)です。未所持のプリセットオペレーターは公式では潜在1・ボーナスなしのため、公式どおりにするには潜在1・未昇進に設定してください。",
"文字大小": "文字サイズ",
"小": "小",
"大": "大",
"特大": "特大",
"调整界面文字大小,棋盘保持原比例。": "画面の文字サイズを変更します。盤面の縮尺は変わりません。",
"此干员语音": "このオペレーターの音声",
"跟随全局": "全体設定に従う",
"仅保存在此浏览器,缺失的日语语音会回退到中文。": "このブラウザーのみに保存されます。日本語音声がない場合は中国語に切り替わります。",
"添加到桌面": "ホーム画面に追加",
"恢复本机对局": "この端末の対戦を復帰",
"此对局仍在其他窗口中,或当前浏览器无法安全恢复。请关闭原窗口后重试。": "別のウィンドウで対戦中か、このブラウザーでは安全に復帰できません。元のウィンドウを閉じて再試行してください。",
"选择已关闭窗口的对局。服务器重启或对局过期后无法恢复。": "閉じたウィンドウの対戦を選択してください。サーバー再起動後や期限切れの場合は復帰できません。",
"{name} · 同盟 {code}": "{name} · 同盟 {code}",
"重刷本局": "開始条件を再抽選",
"重刷本局投票:{agreed}/{total}": "再抽選の投票:{agreed}/{total}",
"全员同意后重新随机战场、领袖、特训敌人和禁用;确认倒计时已暂停。": "全員の同意で戦場、ボス、特訓の敵と禁止対象を再抽選します。確認の制限時間は一時停止中です。",
"保留房间和个人配置,重新随机全部开局信息。": "ルームと個人設定を維持して、開始条件をすべて再抽選します。",
"已同意": "同意済み",
"待同意": "同意待ち",
"全体真人玩家一致同意后刷新,随机结果可能重复。": "人間プレイヤー全員の同意が必要です。同じ結果になる場合があります。",
"请等待所有玩家连接后再发起投票": "全プレイヤーの接続を待ってから投票を開始してください。",
"同意重刷": "再抽選に同意",
"拒绝重刷": "再抽選を拒否",
"取消投票": "投票を取り消す"
}
+25 -1
View File
@@ -1185,5 +1185,29 @@
"精英2": "정예 2",
"精英2 Lv.60": "정예 2 Lv.60",
"开始模拟前可调整干员携带的技能与模组,以及潜能与练度;干员的局内等级不可调整": "시뮬레이션 시작 전에 오퍼레이터의 스킬과 모듈, 잠재와 육성도를 조정할 수 있습니다. 게임 내 오퍼레이터 레벨은 조정할 수 없습니다.",
"默认满潜能、精英2 Lv.60(满加成)。官方的潜能取你自己的潜能,练度是自持有加成(已持有的同名干员的养成,攻击 / 防御 / 生命单独乘算);未持有的特许干员在官方按潜能1、没有加成,要照官方打请手动设为潜能1、未精英化。": "기본값은 최대 잠재 · 2차 정예화 Lv.60(최대 보너스)입니다. 공식 규칙에서 잠재는 자신의 잠재, 육성도는 보유 오퍼레이터 보너스(같은 오퍼레이터의 육성도에 따른 공격 / 방어 / HP 별도 배율)입니다. 보유하지 않은 프리셋 오퍼레이터는 공식적으로 잠재 1·보너스 없음이므로, 공식대로 하려면 잠재 1, 비정예화로 직접 설정하세요."
"默认满潜能、精英2 Lv.60(满加成)。官方的潜能取你自己的潜能,练度是自持有加成(已持有的同名干员的养成,攻击 / 防御 / 生命单独乘算);未持有的特许干员在官方按潜能1、没有加成,要照官方打请手动设为潜能1、未精英化。": "기본값은 최대 잠재 · 2차 정예화 Lv.60(최대 보너스)입니다. 공식 규칙에서 잠재는 자신의 잠재, 육성도는 보유 오퍼레이터 보너스(같은 오퍼레이터의 육성도에 따른 공격 / 방어 / HP 별도 배율)입니다. 보유하지 않은 프리셋 오퍼레이터는 공식적으로 잠재 1·보너스 없음이므로, 공식대로 하려면 잠재 1, 비정예화로 직접 설정하세요.",
"文字大小": "글자 크기",
"小": "작게",
"大": "크게",
"特大": "아주 크게",
"调整界面文字大小,棋盘保持原比例。": "화면의 글자 크기를 조절합니다. 보드 배율은 유지됩니다.",
"此干员语音": "오퍼레이터 음성",
"跟随全局": "전체 설정 따르기",
"仅保存在此浏览器,缺失的日语语音会回退到中文。": "이 브라우저에만 저장됩니다. 일본어 음성이 없으면 중국어로 재생합니다.",
"添加到桌面": "홈 화면에 추가",
"恢复本机对局": "이 기기의 게임 복귀",
"此对局仍在其他窗口中,或当前浏览器无法安全恢复。请关闭原窗口后重试。": "다른 창에서 진행 중이거나 이 브라우저에서 안전하게 복귀할 수 없습니다. 원래 창을 닫고 다시 시도하세요.",
"选择已关闭窗口的对局。服务器重启或对局过期后无法恢复。": "닫힌 창의 게임을 선택하세요. 서버 재시작 또는 게임 만료 후에는 복귀할 수 없습니다.",
"{name} · 同盟 {code}": "{name} · 동맹 {code}",
"重刷本局": "시작 조건 다시 추첨",
"重刷本局投票:{agreed}/{total}": "다시 추첨 투표: {agreed}/{total}",
"全员同意后重新随机战场、领袖、特训敌人和禁用;确认倒计时已暂停。": "전원 동의 시 전장, 보스, 특훈 적과 금지 대상을 다시 추첨합니다. 확인 제한 시간이 일시 정지됩니다.",
"保留房间和个人配置,重新随机全部开局信息。": "방과 개인 설정을 유지하고 시작 조건 전체를 다시 추첨합니다.",
"已同意": "동의함",
"待同意": "동의 대기",
"全体真人玩家一致同意后刷新,随机结果可能重复。": "모든 실제 플레이어의 동의가 필요합니다. 같은 결과가 나올 수 있습니다.",
"请等待所有玩家连接后再发起投票": "모든 플레이어가 접속한 후 투표를 시작하세요.",
"同意重刷": "다시 추첨에 동의",
"拒绝重刷": "다시 추첨 거부",
"取消投票": "투표 취소"
}
+25 -1
View File
@@ -1187,5 +1187,29 @@
"精英2": "精英2",
"精英2 Lv.60": "精英2 Lv.60",
"开始模拟前可调整干员携带的技能与模组,以及潜能与练度;干员的局内等级不可调整": "開始模擬前可調整幹員攜帶的技能與模組,以及潛能與練度;幹員的局內等級不可調整",
"默认满潜能、精英2 Lv.60(满加成)。官方的潜能取你自己的潜能,练度是自持有加成(已持有的同名干员的养成,攻击 / 防御 / 生命单独乘算);未持有的特许干员在官方按潜能1、没有加成,要照官方打请手动设为潜能1、未精英化。": "預設滿潛能、精英2 Lv.60(滿加成)。官方的潛能取你自己的潛能,練度是自持有加成(已持有的同名幹員的養成,攻擊 / 防禦 / 生命單獨乘算);未持有的特許幹員在官方按潛能1、沒有加成,要照官方打請手動設為潛能1、未精英化。"
"默认满潜能、精英2 Lv.60(满加成)。官方的潜能取你自己的潜能,练度是自持有加成(已持有的同名干员的养成,攻击 / 防御 / 生命单独乘算);未持有的特许干员在官方按潜能1、没有加成,要照官方打请手动设为潜能1、未精英化。": "預設滿潛能、精英2 Lv.60(滿加成)。官方的潛能取你自己的潛能,練度是自持有加成(已持有的同名幹員的養成,攻擊 / 防禦 / 生命單獨乘算);未持有的特許幹員在官方按潛能1、沒有加成,要照官方打請手動設為潛能1、未精英化。",
"文字大小": "文字大小",
"小": "小",
"大": "大",
"特大": "特大",
"调整界面文字大小,棋盘保持原比例。": "調整介面文字大小,棋盤保持原比例。",
"此干员语音": "此幹員語音",
"跟随全局": "跟隨全域",
"仅保存在此浏览器,缺失的日语语音会回退到中文。": "僅儲存在此瀏覽器,缺失的日語語音會回退到中文。",
"添加到桌面": "加入主畫面",
"恢复本机对局": "恢復本機對局",
"此对局仍在其他窗口中,或当前浏览器无法安全恢复。请关闭原窗口后重试。": "此對局仍在其他視窗中,或目前瀏覽器無法安全恢復。請關閉原視窗後重試。",
"选择已关闭窗口的对局。服务器重启或对局过期后无法恢复。": "選擇已關閉視窗的對局。伺服器重啟或對局過期後無法恢復。",
"{name} · 同盟 {code}": "{name} · 同盟 {code}",
"重刷本局": "重刷本局",
"重刷本局投票:{agreed}/{total}": "重刷本局投票:{agreed}/{total}",
"全员同意后重新随机战场、领袖、特训敌人和禁用;确认倒计时已暂停。": "全員同意後重新隨機戰場、領袖、特訓敵人和禁用;確認倒數已暫停。",
"保留房间和个人配置,重新随机全部开局信息。": "保留房間和個人配置,重新隨機全部開局資訊。",
"已同意": "已同意",
"待同意": "待同意",
"全体真人玩家一致同意后刷新,随机结果可能重复。": "全體真人玩家一致同意後刷新,隨機結果可能重複。",
"请等待所有玩家连接后再发起投票": "請等待所有玩家連線後再發起投票",
"同意重刷": "同意重刷",
"拒绝重刷": "拒絕重刷",
"取消投票": "取消投票"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 886 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><rect width="24" height="24" fill="#0c0f0e"/><path fill="#4ed8af" d="M5 3h3v2h2V3h4v2h2V3h3v5l-2 2v7l2 2v2H5v-2l2-2v-7L5 8z"/></svg>

After

Width:  |  Height:  |  Size: 193 B

+2
View File
@@ -15,6 +15,7 @@
<meta name="apple-mobile-web-app-title" content="卫戍协议" />
<meta name="description" content="卫戍协议:盟约 · 网页联机复刻(非官方同人作品)" />
<title>卫戍协议:盟约 · STRONGHOLD PROTOCOL</title>
<link rel="manifest" href="/manifest.json" />
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Crect width='24' height='24' fill='%230c0f0e'/%3E%3Cpath fill='%234ed8af' d='M5 3h3v2h2V3h4v2h2V3h3v5l-2 2v7l2 2v2H5v-2l2-2v-7L5 8z'/%3E%3C/svg%3E" />
<!-- Web fonts: never render-blocking (LAN/offline play must still start); system fallbacks in theme.css. -->
@@ -69,6 +70,7 @@
<div class="rotate-hint__phone"></div>
<div class="rotate-hint__title">请将设备横屏</div>
<div>卫戍协议:盟约 需要横屏显示以获得完整体验</div>
<button type="button" hidden class="btn btn--secondary btn--sm rotate-hint__pwa" id="rotate-hint-pwa">添加到桌面</button>
<div class="rotate-hint__micro">ROTATE DEVICE</div>
</div>
+10 -1
View File
@@ -226,7 +226,11 @@ export function bgmEntry(m, kind) {
/** SFX URL: group 'ui' | 'battle', key e.g. 'buy'. */
export const sfxUrl = (m, group, key) => str(get(get(get(get(m, 'audio'), 'sfx'), str(group) || ''), str(key) || ''));
/** Per-unit SFX: kind 'attack' | 'hit' | 'skill' | 'die' | 'born'; `skillIndex` picks `skills[i]` when present. */
/**
* Per-unit SFX: kind 'attack' | 'hit' | 'skill' | 'die' | 'born'; `skillIndex` picks `skills[i]` and — for an attack or
* an impact — `attacks[i]` / `hits[i]` (the running skill's own files, tools/assets/audio.mjs pickModeAttacks /
* pickModeHits) when the manifest has them.
*/
export function unitSfxUrl(m, id, kind, skillIndex) {
const u = get(get(get(get(m, 'audio'), 'sfx'), 'units'), str(id) || '') || get(get(get(get(m, 'audio'), 'sfx'), 'units'), baseCharId(id) || '');
if (!isObj(u)) return null;
@@ -234,6 +238,11 @@ export function unitSfxUrl(m, id, kind, skillIndex) {
const s = str(u.skills[skillIndex]);
if (s) return s;
}
const per = kind === 'attack' ? u.attacks : kind === 'hit' ? u.hits : null;
if (Number.isInteger(skillIndex) && isObj(per)) {
const a = str(per[skillIndex]);
if (a) return a;
}
return str(u[kind]);
}
+44 -13
View File
@@ -43,6 +43,16 @@
// sound of that target. An operator's attack / hit sound that is a skill-mode file of its own (official names end in
// `_n` for the normal attack, `_d` / `_h` / `_s` for its skill modes — the manifest picked 纯烬艾雅法拉's S3 impact
// p_imp_gtshpbrnch_s as her `hit`) never plays for a normal attack (normalAttackSfx).
// - The attack sound of the RUNNING skill (`attacks`, 技能1 / 2 / 3 = `_d` / `_h` / `_s`): an operator whose every attack
// belongs to a skill mode has no normal attack bank at all — 司霆惊蛰 (解放者: she only attacks while a skill runs) was
// completely silent when she attacked (report 「三技能攻击没有音效」). The manifest then carries that mode's own file
// per skill index (`attacks`, tools/assets/audio.mjs pickModeAttacks) and it plays while the skill is active, from the
// `['skill', id, 1]` / `0` events; a unit with a normal bank too (能天使 S2 过载模式, 史尔特尔 S2) keeps that bank
// outside the skill. Being the official file of that very skill, it is not the normalAttackSfx case above.
// - …and its impact (`hits`): a mode's swing is not always the whole difference — 赤刃明霄陈 S3's slashes swing
// p_atk_hljdswd_s AND land p_imp_hljdswd_s, while her normal attack uses p_atk_hljdswd_n / p_imp_hljdswd_n
// (report 「开启三技能后斩击音效应该和普通攻击不一样」). Same mechanism and the same window: the `hit` of an attack
// aimed while the skill ran — the attacker's state travels with `lastAttacker`.
// - The official bank mix of a unit's own attack / hit / die / born sound (`mix`, tools/assets/audio.mjs bankMix; community
// report #30): it plays with chance `p` — 猎狗pro / 深池侦察犬's attack bank is 80 % silence, so they bark on about one
// attack in five (never replaced by the generic enemy sound) — at its base gain × `vol`, capped at 1: an official volume
@@ -55,6 +65,7 @@
// `bgmKeyFor(route, pub)` picks the track for the current screen/phase (main.js calls `audio.install()`,
// which follows the store).
import { sanitizeVoiceOverrides, voiceLangFor } from './voicePrefs.js';
import { PHASE } from '../../shared/constants.js';
import { mediaUrl } from './media.js';
@@ -514,6 +525,7 @@ export class AudioManager {
this.voiceGate = new VoiceGate();
this.voiceNode = null; // { src, gain, url, token } of the line on air
this.voiceToken = 0;
this.voiceOverrides = {};
this.voiceLang = 'cn'; // settings 语音语言: 'cn' (audio.voice) | 'jp' (audio.voiceJp, falling back to audio.voice)
this.startVoiceDone = false; // 行动出发 of this field (the first operator deployed says it)
this.uiVoices = 0;
@@ -522,7 +534,8 @@ export class AudioManager {
this.bgmToken = 0;
this.units = new Map(); // battle unit id → defId
this.pendingSkill = new Map(); // unit id → the 'skill' tuple that arrived before the unit was known (see _track)
this.lastAttacker = new Map(); // target id → { def, at } of the hostile attack last aimed at it (its impact sound)
this.lastAttacker = new Map(); // target id → { def, at, skillIndex, skillActive } of the hostile attack aimed at it (its impact sound,
// including the running skill's own `hits` — 赤刃明霄陈 S3's slash)
this.consumed = new Set(); // summons used up by their own effect (香槟炸弹 exploded): no death sound
this.installed = false;
this._unlock = this._unlock.bind(this);
@@ -660,8 +673,9 @@ export class AudioManager {
* line on air finishes in its own dub; the next one follows the setting.
* @param {string} lang
*/
setVoiceLang(lang) {
setVoiceLang(lang, overrides = this.voiceOverrides) {
this.voiceLang = lang === 'jp' ? 'jp' : 'cn';
this.voiceOverrides = sanitizeVoiceOverrides(overrides);
}
_applyVolumes() {
@@ -870,18 +884,27 @@ export class AudioManager {
* @param {string} defId charId/tokenId/enemyId (or chess id — mapped via its spine/char id by the caller)
* @param {'attack'|'hit'|'skill'|'die'|'born'} kind
* @param {number|string} unitId battle unit id (cooldown key)
* @param {number} [skillIndex] the unit's equipped skill slot (0-based)
* @param {boolean} [skillActive] whether that skill runs right now (`attacks` / `hits` need it, see header)
* @returns {boolean} whether a unit-specific sound exists
*/
unit(defId, kind, unitId, skillIndex) {
unit(defId, kind, unitId, skillIndex, skillActive = false) {
try {
const u = this.getManifest()?.audio?.sfx?.units?.[defId];
// DESIGN §16: the equipped skill's own ON_SKILL_START sound (`skills[index]`) when the manifest has it
const own = kind === 'skill' && Number.isInteger(skillIndex) && u?.skills ? u.skills[skillIndex] : null;
const url = typeof own === 'string' ? own : u?.[kind];
// the running skill's own swing / impact (header); only while it runs — otherwise 能天使 S2's file would ring on
// every normal attack of hers (exactly what normalAttackSfx keeps out). 赤刃明霄陈 S3: `attacks[2]` = the S3 swing,
// `hits[2]` = the S3 slash's impact.
const table = kind === 'attack' ? u?.attacks : kind === 'hit' ? u?.hits : null;
const mode = skillActive && Number.isInteger(skillIndex) && table ? table[skillIndex] : null;
const url = typeof own === 'string' ? own : typeof mode === 'string' ? mode : u?.[kind];
if (typeof url !== 'string') return false;
if ((kind === 'attack' || kind === 'hit') && !normalAttackSfx(defId, url)) return false;
// a skill-mode file is fine here when it IS the running skill's (mode); the gate is for normal attacks
if ((kind === 'attack' || kind === 'hit') && typeof mode !== 'string' && !normalAttackSfx(defId, url)) return false;
// the official bank's mix (header): a silent roll still counts as the unit's own sound (no generic fallback)
const mix = kind === 'skill' ? null : u?.mix?.[kind];
const mix = kind === 'skill' ? null : typeof mode === 'string'
? (kind === 'attack' ? u?.attackMix : u?.hitMix)?.[skillIndex] : u?.mix?.[kind];
if (!unitSoundPlays(mix, this.random())) return true;
this._play(url, { volume: unitGain(kind === 'attack' || kind === 'hit' ? 0.55 : 0.8, mix), limited: true, unitKey: `${unitId}:${kind}` });
return true;
@@ -907,7 +930,7 @@ export class AudioManager {
if (!this.ctx || !this.voiceGain || this.volumes.muted || this.volumes.voice <= 0) return false;
if (typeof charId !== 'string' || typeof slot !== 'string') return false;
// the chosen dub's line (settings 语音语言), with the Chinese file of the same name as its fallback (voiceLine)
const line = voiceLine(this.getManifest()?.audio, charId, slot, this.voiceLang);
const line = voiceLine(this.getManifest()?.audio, charId, slot, voiceLangFor(charId, this.voiceLang, this.voiceOverrides));
if (!line) return false;
const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
const verdict = this.voiceGate.request(slot, o.unitKey ?? null, now);
@@ -1005,9 +1028,9 @@ export class AudioManager {
_track(u) {
if (!u || typeof u !== 'object' || u.id == null) return;
// UnitInfo.spine is the model id (charId / tokenId / enemyId) — the key of sfx.units; kind/defId pick the
// official class sounds (operator vs summon vs device)
// official class sounds (operator vs summon vs device). `skillActive` follows the ['skill', id, 1] / 0 events.
this.units.set(u.id, { def: u.spine || u.defId, defId: u.defId ?? null, kind: u.kind ?? null, side: u.side, boss: !!u.boss,
skillIndex: Number.isInteger(u.skillIndex) ? u.skillIndex : null });
skillIndex: Number.isInteger(u.skillIndex) ? u.skillIndex : null, skillActive: !!u.skillActive });
// a cast that arrived before this unit was known (handleBattleEvents): it sounds now, once
const held = this.pendingSkill.get(u.id);
if (held) {
@@ -1040,18 +1063,20 @@ export class AudioManager {
if (!src) continue;
// only a hostile attack authors the target's next impact (a heal — an ally aiming at an ally — never does)
const tgt = this.units.get(e[2]);
if (tgt && tgt.side !== src.side) this.lastAttacker.set(e[2], { def: src.def, at: now });
if (!this.unit(src.def, 'attack', e[1]) && src.side === 'enemy') this.battle('enemyHit', { unitKey: `${e[1]}:atk`, volume: 0.35 });
if (tgt && tgt.side !== src.side) this.lastAttacker.set(e[2], { def: src.def, at: now, skillIndex: src.skillIndex ?? null, skillActive: !!src.skillActive });
if (!this.unit(src.def, 'attack', e[1], src.skillIndex ?? undefined, src.skillActive) && src.side === 'enemy') this.battle('enemyHit', { unitKey: `${e[1]}:atk`, volume: 0.35 });
} else if (kind === 'dmg') {
const by = this.lastAttacker.get(e[1]);
if (!by || !IMPACT_TYPES.has(e[3])) continue;
this.lastAttacker.delete(e[1]); // one impact per attack
if (now - by.at <= IMPACT_WINDOW_MS) this.unit(by.def, 'hit', `h${e[1]}`);
if (now - by.at <= IMPACT_WINDOW_MS) this.unit(by.def, 'hit', `h${e[1]}`, by.skillIndex ?? undefined, by.skillActive);
} else if (kind === 'heal') {
this.battle('heal', { unitKey: `heal:${e[1]}`, volume: 0.35 });
} else if (kind === 'skill' && e[2]) {
const u = this.units.get(e[1]);
if (u) {
// the running skill's own swing / impact (`attacks` / `hits`, header) follow this flag
u.skillActive = true;
this.unit(u.def, 'skill', e[1], u.skillIndex ?? undefined);
// 作战中N: the equipped skill's own slot (0-based; 作战中4 is the fallback of a 4th slot)
if (unitSoundClass(u) === 'char') {
@@ -1066,6 +1091,12 @@ export class AudioManager {
if (this.pendingSkill.size >= PENDING_SKILL_MAX) this.pendingSkill.delete(this.pendingSkill.keys().next().value);
this.pendingSkill.set(e[1], e);
}
} else if (kind === 'skill') {
// ['skill', id, 0] = the skill ENDED (the sim's skills.js: `b._ev(['skill', u.id, 0])`): back to the unit's own
// attack / impact. The end carries no sound of its own (the activation sound belongs to the start).
const u = this.units.get(e[1]);
if (u) u.skillActive = false;
this.pendingSkill.delete(e[1]);
} else if (kind === 'engage') {
// 行动开始: the first attack a unit makes on an enemy (the sim's ENGAGE, official ENCOUNTER_ENEMY, 3 s apart)
const u = this.units.get(e[1]);
@@ -1140,7 +1171,7 @@ export function installAudio(deps) {
try {
manifestGetter = typeof deps?.getManifest === 'function' ? deps.getManifest : manifestGetter;
audio.install();
if (deps?.settings) { audio.setVolumes(deps.settings); audio.setVoiceLang(deps.settings.voiceLang); }
if (deps?.settings) { audio.setVolumes(deps.settings); audio.setVoiceLang(deps.settings.voiceLang, deps.settings.voiceOverrides); }
if (typeof deps?.subscribe === 'function' && typeof deps?.getState === 'function') {
const sync = (s) => {
try {
+4
View File
@@ -136,6 +136,7 @@ function maybeFinishRestore() {
* follow the store themselves).
*/
function backToLobby() {
identity.rememberMatch(null);
clearTimeout(restoreTimer);
const s = store.get();
if (s.room || s.match.public) closeAllDialogs();
@@ -145,6 +146,7 @@ function backToLobby() {
function onWelcome(msg) {
identity.saveToken(msg.token);
identity.rememberMatch(null);
const prev = store.get();
const prevId = prev.me.playerId;
const name = typeof msg.name === 'string' && msg.name ? msg.name : prev.me.name;
@@ -188,6 +190,8 @@ function onRoomState(msg) {
// A (new) match starts: forget the previous match's state so stale results never show.
if (room.inMatch && !(prevRoom && prevRoom.inMatch && prevRoom.code === room.code)) store.set({ match: emptyMatch() });
store.set({ room });
identity.rememberMatch(room.inMatch && seats.some((seat) => seat?.playerId === myId)
? { name: store.get().me.name, code: room.code || '' } : null);
if (room.mode === 'coop' && typeof room.code === 'string') rememberRoom(room.code);
maybeFinishRestore();
}
+53
View File
@@ -660,6 +660,7 @@ export class Net {
const K_NAME = 'sp.name';
const K_TOKEN = 'sp.token'; // sessionStorage: this tab's token
const K_RECENT = 'sp.tokens'; // localStorage: this browser's recent tokens, most recent first
const K_MATCHES = 'sp.matches'; // local metadata for explicit recovery of another closed tab's seat
const K_ENTERED = 'sp.entered'; // sessionStorage: this tab passed the title screen
const RECENT_MAX = 4;
const TOKEN_MAX_LEN = 64; // protocol limit for hello.token
@@ -725,6 +726,7 @@ export function createIdentity(deps = {}) {
let channel = null;
let initPromise = null;
let initialized = false;
let resumeBusy = false;
let current = null; // token this tab uses (null = let the server create a session)
let resolving = null; // { hashes: Set<hash>, taken: Set<hash> } while init() waits for claims
@@ -738,6 +740,25 @@ export function createIdentity(deps = {}) {
};
const writeRecent = (list) => sset(local, K_RECENT, JSON.stringify([...new Set(list)].slice(0, RECENT_MAX)));
const readMatches = () => {
try {
const v = JSON.parse(sget(local, K_MATCHES) || '{}');
return v && typeof v === 'object' && !Array.isArray(v) ? v : {};
} catch { return {}; }
};
const rememberMatch = (info) => {
if (!current) return;
const old = readMatches(), next = {};
for (const token of readRecent()) {
const hash = tokenHash(token);
const v = token === current ? info : old[hash];
if (v && typeof v.name === 'string' && typeof v.code === 'string') {
next[hash] = { name: v.name.slice(0, 64), code: v.code.slice(0, 32) };
}
}
sset(local, K_MATCHES, JSON.stringify(next));
};
const post = (msg) => {
try { channel?.postMessage({ ...msg, from: tabId }); } catch { /* channel closed */ }
};
@@ -773,6 +794,10 @@ export function createIdentity(deps = {}) {
// Without a channel we cannot tell whether a shared token is in use: never adopt one.
const candidates = [...new Set([ownOk, ...(channel ? readRecent() : [])].filter(isToken))];
if (!channel || candidates.length === 0) return ownOk;
return queryCandidates(candidates);
}
async function queryCandidates(candidates) {
resolving = { hashes: new Set(candidates.map(tokenHash)), taken: new Set() };
post({ type: 'who', hashes: [...resolving.hashes] });
await new Promise((r) => setTimer(r, queryMs));
@@ -802,6 +827,32 @@ export function createIdentity(deps = {}) {
}
return initPromise;
},
/** Record only display metadata; the existing recent-token ring remains the authority. */
rememberMatch,
recoverable() {
const matches = readMatches();
return readRecent().filter((token) => token !== current).flatMap((token) => {
const hash = tokenHash(token), v = matches[hash];
return v && typeof v.name === 'string' && typeof v.code === 'string'
? [{ id: hash, name: v.name.slice(0, 64), code: v.code.slice(0, 32) }] : [];
});
},
/** Explicit recovery only. Recheck live claims immediately before selecting a saved seat. */
async resume(id) {
await this.init();
if (!channel || resumeBusy) return false;
const token = readRecent().find((t) => t !== current && tokenHash(t) === id);
if (!token || !this.recoverable().some((m) => m.id === id)) return false;
resumeBusy = true;
try {
const pick = await queryCandidates([token]);
if (!pick) return false;
current = pick;
sset(session, K_TOKEN, pick);
return true;
} catch { return false; }
finally { resumeBusy = false; }
},
/** @returns {string} remembered player name ('' if none) */
loadName: () => (sget(local, K_NAME) || '').slice(0, 64),
/** @param {string} name */
@@ -816,12 +867,14 @@ export function createIdentity(deps = {}) {
/** @param {string} token from `welcome` */
saveToken(token) {
if (!isToken(token)) return;
if (current !== token) rememberMatch(null);
current = token;
sset(session, K_TOKEN, token);
writeRecent([token, ...readRecent().filter((t) => t !== token)]);
},
/** Forget this tab's token (e.g. the server said the session is invalid). */
clearToken() {
rememberMatch(null);
const t = current || sget(session, K_TOKEN);
current = null;
sdel(session, K_TOKEN);
+40
View File
@@ -0,0 +1,40 @@
// Install prompts are browser-issued, one-use capabilities. No service worker or application cache is installed.
export function createPwaInstall(win) {
let prompt = null, installed = false;
const listeners = new Set();
const display = win?.matchMedia?.('(display-mode: standalone), (display-mode: fullscreen)');
const available = () => !!prompt && !installed && !display?.matches && !win?.navigator?.standalone;
const emit = () => { for (const fn of listeners) fn(available()); };
const offer = (event) => {
if (installed || display?.matches || win?.navigator?.standalone || typeof event.prompt !== 'function') return;
event.preventDefault();
prompt = event;
emit();
};
const done = () => { installed = true; prompt = null; emit(); };
win?.addEventListener?.('beforeinstallprompt', offer);
win?.addEventListener?.('appinstalled', done);
display?.addEventListener?.('change', emit);
return {
available,
subscribe(fn) { listeners.add(fn); fn(available()); return () => listeners.delete(fn); },
async request() {
if (!available()) return false;
const event = prompt;
prompt = null; // Consume before awaiting: dismissal, rejection and repeated clicks cannot reuse this event.
emit();
try {
const result = await event.prompt();
const choice = event.userChoice ? await event.userChoice : result;
return choice?.outcome === 'accepted';
} catch { return false; }
},
dispose() {
win?.removeEventListener?.('beforeinstallprompt', offer);
win?.removeEventListener?.('appinstalled', done);
display?.removeEventListener?.('change', emit);
prompt = null; listeners.clear();
},
};
}
export const pwaInstall = createPwaInstall(globalThis.window);
+7 -1
View File
@@ -530,6 +530,7 @@ export class UnitView {
this.actor = null;
this.spineReady = false;
this._modelDirty = false; // died / stood up since the last frame: update() checks Front ⇄ Back (_syncModel)
this._pendingDeployElapsed = null; // elapsed game seconds until a late model can continue Start
this._spineBusy = false; // a Spine load of this view is in flight
this._spineTries = 0; // failed loads since the last model (SPINE_RETRY_MS)
this._retryAt = 0; // when the next retry is due (ms, performance clock; 0 = none)
@@ -650,7 +651,8 @@ export class UnitView {
// no fallback diamond in between; a deploy clip it was playing goes on on the new model (an operator facing UP
// redeployed: its Back model takes over from the Front model it lay down with)
const swap = !!this.actor;
const deployed = swap && this.alive ? this.actor.deployElapsed() : null;
const deployed = swap && this.alive ? this.actor.deployElapsed() : this._pendingDeployElapsed;
this._pendingDeployElapsed = null;
if (swap) this._dropActor();
this.actor = actor;
this._actorEntry = entry;
@@ -961,6 +963,7 @@ export class UnitView {
/** An attack was made (b.ev 'atk'). `target` = view or null. */
onAttack(target, now, kind) {
if (!this.alive) return;
this._pendingDeployElapsed = null;
// a one-off cast (PROJ[kind].once: 暴鸰's bomb drop) is no attack rhythm: its clip plays once at its own speed
const once = !!PROJ[kind]?.once;
if (!once) {
@@ -1018,6 +1021,7 @@ export class UnitView {
onDeploy() {
this.fadeIn = 0;
if (!this.alive) this.revive();
this._pendingDeployElapsed = this.actor ? null : 0;
if (this.actor) this.actor.deploy();
}
@@ -1038,6 +1042,7 @@ export class UnitView {
*/
die(instant = false) {
if (!this.alive) return;
this._pendingDeployElapsed = null;
this.alive = false;
this._modelDirty = true;
this._dieForm = this._formSpec();
@@ -1105,6 +1110,7 @@ export class UnitView {
/** @param {number} dt real seconds @param {any} cam camera @param {number} t real clock */
update(dt, cam, t) {
if (this.destroyed) return;
if (this._pendingDeployElapsed != null) this._pendingDeployElapsed += dt * (this.ctx.animRate?.() || 1);
// a displacement's slide (slideTo): held where it stood until the snapshot with the destination is shown, then
// eased into the sampled position (sync keeps `lx, ly` current) under a constant deceleration
if (this.slide) {
+4 -2
View File
@@ -11,6 +11,7 @@ import { useGameData, Img, RichText } from '../ui/gameComponents.js';
import { StepHeader, ExitModal } from '../ui/matchChrome.js';
import { MatchInfo, matchInfoModel } from '../ui/matchInfo.js';
import { LoadoutButton } from './loadout.js';
import { SetupReroll } from '../ui/setupReroll.js';
import { actions } from '../ui/gameActions.js';
import { factionTypes, sortedPlayers, phaseTotalSeconds } from '../ui/gameLogic.js';
import { enemyIconUrl, factionIconUrl } from '../ui/assetUrls.js';
@@ -50,7 +51,7 @@ export function BriefingScreen() {
const ready = async () => {
if (busy || me?.ready) return;
setBusy(true);
await actions.infoReady();
await actions.infoReady(pub.setupRevision ?? 0);
setBusy(false);
};
@@ -91,6 +92,7 @@ export function BriefingScreen() {
<${MatchInfo} model=${info} />
</section>
</main>
<${SetupReroll} pub=${pub} />
<footer class="brief__foot">
<${LoadoutButton} from="briefing" size="lg" class="brief-loadout" />
<div class="brief-ready">
@@ -98,7 +100,7 @@ export function BriefingScreen() {
<span class="brief-ready__pips">${players.map((p) => html`<i key=${p.playerId} class=${cx(p.ready && 'on', p.playerId === myId && 'me')} title=${p.name}><${Icon} name="user" /></i>`)}</span>
</div>
<${Button} variant="primary" size="xl" icon=${me?.ready ? 'check' : me ? 'play' : 'eye'} active=${!!me?.ready} loading=${busy}
disabled=${!!me?.ready || !me} onClick=${ready}>${me?.ready ? t('已就绪') : me ? t('准备就绪') : t('观战中')}<//>
disabled=${!!me?.ready || !me || !!pub.rerollVote} onClick=${ready}>${me?.ready ? t('已就绪') : me ? t('准备就绪') : t('观战中')}<//>
</footer>
<${ExitModal} open=${exit} onClose=${() => setExit(false)} solo=${solo} />
</div>`;
+2
View File
@@ -16,6 +16,7 @@ import { chessAvatarUrl, chessPortraitUrl, profIconUrl, skillRecordIconUrl, modu
import { data } from '../data.js';
import { PROF_NAME, skillLabel, moduleBadge, fullTraitText } from '../ui/loadoutModel.js';
import { diySlotList, pickChoices, pickOptions, slotRecord, defaultPick } from '../ui/diyModel.js';
import { OperatorVoice } from '../ui/operatorVoice.js';
import { CultivationSelects } from './cultivation.js';
import { t } from '../../../shared/i18n.js';
@@ -77,6 +78,7 @@ function SlotCard({ m, slot, pick, illegal, onOpen, onClear, ops = {}, onOps = n
</span>
${proto ? html`<small class="diy-slot__cult t-dim">${t('原型干员没有潜能与练度')}</small>`
: onOps ? html`<span class="diy-slot__cult"><${CultivationSelects} charId=${rec.charId} ops=${ops} onSet=${onOps} /></span>` : null}
<${OperatorVoice} charId=${rec.charId} />
${illegal ? html`<p class="diy-slot__bad"><${Icon} name="warn" />${t('这项自选在当前版本不可用,开局时会被移除')}</p>` : null}
</div>
<div class="diy-slot__acts">
+2
View File
@@ -37,6 +37,7 @@ import {
opsOf, setOps, resetOps, moduleRecord,
} from '../ui/loadoutModel.js';
import { loadoutStore, openLoadout, closeLoadout, setEntries, setOpsMap, applyLoadoutEntries, setNotOwned, applyOwnershipImport, setDiyPicks, applyDiyImport } from '../ui/loadoutSync.js';
import { OperatorVoice } from '../ui/operatorVoice.js';
import { CultivationSelects, CultivationSection } from './cultivation.js';
import { cultivationCharIds } from '../../../shared/protocol.js';
import { atPotential } from '../../../shared/potential.js';
@@ -387,6 +388,7 @@ function Detail({ m, chess, golden, entries, ops = {}, onChange, onOps, onReset,
<${Button} variant="ghost" size="sm" icon="refresh" class="lo-dhead__reset" disabled=${!changed} onClick=${onReset}>${t('恢复默认')}<//>
</div>
<div class="lo-detail__body" ref=${bodyRef}>
<${OperatorVoice} charId=${chess.charId} />
<${CultivationSection} charId=${chess.charId} ops=${ops} onSet=${onOps} standIn=${notOwned} />
<${LoadoutGarrisons} chess=${level === 'elite' && golden ? golden : chess} m=${m} />
<section class="lo-sec">
+4
View File
@@ -11,6 +11,7 @@
// Texts go through t() (docs/I18N.md); the module-level tables hold msgids (N_) translated where they are shown, the
// config.json mode texts come localized from data.js.
import { ResumeMatchButton } from '../ui/resumeMatch.js';
import { useEffect, useRef, useState } from '../../vendor/hooks.module.js';
import { DIFFICULTIES, DIFFICULTY_NAMES, DIFFICULTY_COLORS, ROOM_CODE_LEN, MAX_SEATS, MAX_SPECTATORS, modeIdFor, ERR } from '../../../shared/constants.js';
import { html, Button, Icon, MicroLabel, Panel, TextField, PingPill, AvatarFrame, Tooltip, Spinner, DifficultyIcon, doctorNo } from '../ui/components.js';
@@ -18,6 +19,7 @@ import { toast, toastError } from '../ui/toasts.js';
import { GuideButton } from '../ui/guide.js';
import { openStats } from './stats.js';
import { SettingsButton } from '../ui/settings.js';
import { PwaInstallButton } from '../ui/device.js';
import { LoadoutButton } from './loadout.js';
import { net, identity } from '../net.js';
import { store, useStore, shallowEqual, loadPref, savePref } from '../store.js';
@@ -315,6 +317,8 @@ export function LobbyScreen() {
<div class="topbar__right">
<${Button} variant="secondary" size="sm" icon="chart" class="stats-entry" onClick=${openStats} title=${t('统计数据')} aria-label=${t('统计数据')}>${t('统计')}<//>
<${SettingsButton} class="lobby-settings" variant="secondary" label=${t('设置')} />
<${ResumeMatchButton} />
<${PwaInstallButton} class="lobby-pwa" />
<${GuideButton} class="lobby-guide" variant="secondary" label=${t('玩法说明')} />
<${LoadoutButton} from="lobby" size="sm" class="lobby-loadout" label=${t('干员调配')} />
<div class="me-chip">
+4 -1
View File
@@ -10,6 +10,7 @@
// entry/loading illustration names) it is layered under the CSS art; otherwise the screen is
// pure CSS/SVG (radar, ridgelines, glow), so it never issues a request that can 404.
import { ResumeMatchButton } from '../ui/resumeMatch.js';
import { useMemo, useState } from '../../vendor/hooks.module.js';
import { NAME_MAX_LEN, APP_VERSION, DEV_BUILD } from '../../../shared/constants.js';
import { html, Button, Icon, MicroLabel, TextField, PingPill } from '../ui/components.js';
@@ -19,7 +20,7 @@ import { toast } from '../ui/toasts.js';
import { net, identity } from '../net.js';
import { store, useStore, shallowEqual } from '../store.js';
import { data, useData } from '../data.js';
import { FullscreenButton, detectFeatures } from '../ui/device.js';
import { FullscreenButton, PwaInstallButton, detectFeatures } from '../ui/device.js';
import { LangToggle, useLang } from '../ui/lang.js';
import { t, N_ } from '../../../shared/i18n.js';
import { scriptOf } from '../../../shared/i18nPacks.js';
@@ -287,6 +288,8 @@ export function TitleScreen() {
<${SettingsModal} open=${settingsOpen} onClose=${() => setSettingsOpen(false)} />
<footer class="title-foot">
<${ResumeMatchButton} />
<${PwaInstallButton} />
<span>${t('非官方同人复刻 · 游戏素材版权归 上海鹰角网络 / Yostar 所有')}</span>
<${MicroLabel}>v${APP_VERSION} · WEB SIMULATION<//>
${DEV_BUILD ? html`<span class="title-dev" role="note">${t('开发版 · 不稳定,请勿用于公开服务器')}</span>` : null}
+23 -1
View File
@@ -20,7 +20,8 @@
// CSS counterpart: public/css/devices.css (safe-area insets, touch-action, overscroll, tap-target expansion).
import { useEffect, useState } from '../../vendor/hooks.module.js';
import { html, Icon } from './components.js';
import { pwaInstall } from '../pwa.js';
import { html, Icon, Button } from './components.js';
import { t } from '../../../shared/i18n.js';
/** A touch held this long without moving opens the detail (contextmenu) on DOM controls. */
@@ -162,6 +163,20 @@ export function FullscreenButton({ class: cls = '' }) {
</button>`;
}
/** Only a browser-issued install offer shows a button; installed and unsupported environments stay quiet. */
export function PwaInstallButton({ class: cls = '', size = 'sm' }) {
const [available, setAvailable] = useState(pwaInstall.available);
useEffect(() => pwaInstall.subscribe(setAvailable), []);
if (!available) return null;
return html`<${Button} variant="secondary" size=${size} icon="download" class=${cls}
data-testid="pwa-install" onClick=${() => pwaInstall.request()}>${t('添加到桌面')}<//>`;
}
export function updateRotateHintPwaI18n(doc = globalThis.document) {
const btn = doc?.getElementById?.('rotate-hint-pwa');
if (btn) btn.textContent = t('添加到桌面');
}
// ---- boot-time installation ----------------------------------------------------------------------------------------
let installed = null;
@@ -253,6 +268,13 @@ export function installDeviceSupport(win = globalThis) {
if (lp.swallowUntil && Date.now() < lp.swallowUntil) { lp.swallowUntil = 0; e.preventDefault(); e.stopPropagation(); }
}, { capture: true });
const pwaBtn = doc?.getElementById?.('rotate-hint-pwa');
if (pwaBtn) {
updateRotateHintPwaI18n(doc);
offs.push(pwaInstall.subscribe((available) => { pwaBtn.hidden = !available; }));
on(pwaBtn, 'click', () => { pwaInstall.request(); });
}
installed = () => { for (const off of offs.splice(0)) { try { off(); } catch { /* ignore */ } } clearTimeout(rotTimer); installed = null; };
return installed;
}
+4 -1
View File
@@ -48,7 +48,10 @@ export async function act(t, fields = {}, opts = {}) {
}
export const actions = {
infoReady: () => act('g.infoReady'),
infoReady: (setupRevision = 0) => act('g.infoReady', { setupRevision }),
rerollSetup: (setupRevision) => act('room.rerollSetup', { setupRevision }, { sfx: 'confirm' }),
rerollVote: (voteId, agree) => act('g.rerollVote', { voteId, agree }, { sfx: agree ? 'confirm' : 'back' }),
cancelReroll: (voteId) => act('room.cancelReroll', { voteId }, { sfx: 'back' }),
band: (bandId) => act('g.band', { bandId }),
bandSkip: () => act('g.bandSkip'),
buy: (slot) => act('g.buy', { slot }),
+1 -1
View File
@@ -40,7 +40,7 @@ export { normalizeDraft, normalizeSp, normalizePersonalChoice } from './gameLogi
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, facingEnter } from './gameLogic/shortcuts.js';
export { DEFAULT_SETTINGS, VOICE_LANGS, sanitizeSettings } from './gameLogic/settings.js';
export { DEFAULT_SETTINGS, VOICE_LANGS, TEXT_SIZES, sanitizeSettings } from './gameLogic/settings.js';
export { normalizeResult } from './gameLogic/result.js';
export { chessLoadout, unitLoadout, unitCultivation } from './gameLogic/loadout.js';
export { standInIds, fieldsStandIn, standInOf, ownStandIn, cardStandIn, memberStandIn, standInGetter, standInLoadout, deployedRecord, deployedModuleId, standInLabel, standInForText, standInTip } from './gameLogic/standIn.js';
+18 -5
View File
@@ -1,5 +1,8 @@
// ui/gameLogic/settings.js — settings defaults and sanitising. Re-exported from ../gameLogic.js.
import { VOICE_LANGS, sanitizeVoiceOverrides } from '../../voicePrefs.js';
export { VOICE_LANGS } from '../../voicePrefs.js';
import { clamp, isObj } from './shared.js';
import { DEFAULT_HOTKEYS, sanitizeHotkeys } from './shortcuts.js';
@@ -10,17 +13,25 @@ import { DEFAULT_HOTKEYS, sanitizeHotkeys } from './shortcuts.js';
* The operator voice dubs (settings 语音语言): 'cn' 中文 (`audio.voice`, the default) and 'jp' 日本語 (`audio.voiceJp`,
* falling back to the Chinese line it lacks — public/js/audio.js voiceLine). Not tied to the interface language.
*/
export const VOICE_LANGS = Object.freeze(['cn', 'jp']);
/** keys: the in-match shortcuts' key map (ui/gameLogic/shortcuts.js; settings → 快捷键). voiceLang: VOICE_LANGS. */
export const DEFAULT_SETTINGS = Object.freeze({ bgm: 0.6, sfx: 0.8, voice: 0.8, voiceLang: 'cn', muted: false, damageNumbers: true, quality: 'high', keys: DEFAULT_HOTKEYS });
/**
* The steps of 设置 →「文字大小」 (textSize): the interface text root `--t` of css/theme.css — 'sm' is the design's own
* sizes (`--t: 1rem`), the others lift the phone's 40 px root by a floor and grow the desktop gently. Text only: the
* layout root `1rem` (and with it the field camera, the detail card's side and the DOM fallback board) never moves.
*/
export const TEXT_SIZES = Object.freeze(['sm', 'md', 'lg', 'xl']);
/** keys: the in-match shortcuts' key map (ui/gameLogic/shortcuts.js; settings → 快捷键). voiceLang: VOICE_LANGS.
* textSize: TEXT_SIZES (css/theme.css `--t`, applied by ui/settings.js applyTextSize). */
export const DEFAULT_SETTINGS = Object.freeze({ bgm: 0.6, sfx: 0.8, voice: 0.8, voiceLang: 'cn', voiceOverrides: Object.freeze({}), muted: false, damageNumbers: true, quality: 'high', textSize: 'sm', keys: DEFAULT_HOTKEYS });
const QUALITIES = ['high', 'medium', 'low'];
/**
* Sanitize persisted settings.
* @param {any} raw
* @returns {{ bgm: number, sfx: number, voice: number, voiceLang: 'cn'|'jp', muted: boolean, damageNumbers: boolean, quality: 'high'|'medium'|'low',
* keys: Record<'refresh'|'freeze'|'levelUp'|'retreat'|'sell'|'ready', string> }}
* @returns {{ bgm: number, sfx: number, voice: number, voiceLang: 'cn'|'jp', voiceOverrides: Record<string, string>, muted: boolean, damageNumbers: boolean, quality: 'high'|'medium'|'low',
* textSize: 'sm'|'md'|'lg'|'xl', keys: Record<'refresh'|'freeze'|'levelUp'|'retreat'|'sell'|'ready', string> }}
*/
export function sanitizeSettings(raw) {
const r = isObj(raw) ? raw : {};
@@ -30,9 +41,11 @@ export function sanitizeSettings(raw) {
sfx: vol(r.sfx, DEFAULT_SETTINGS.sfx),
voice: vol(r.voice, DEFAULT_SETTINGS.voice),
voiceLang: VOICE_LANGS.includes(r.voiceLang) ? r.voiceLang : DEFAULT_SETTINGS.voiceLang,
voiceOverrides: sanitizeVoiceOverrides(r.voiceOverrides),
muted: typeof r.muted === 'boolean' ? r.muted : DEFAULT_SETTINGS.muted,
damageNumbers: typeof r.damageNumbers === 'boolean' ? r.damageNumbers : DEFAULT_SETTINGS.damageNumbers,
quality: QUALITIES.includes(r.quality) ? r.quality : DEFAULT_SETTINGS.quality,
textSize: TEXT_SIZES.includes(r.textSize) ? r.textSize : DEFAULT_SETTINGS.textSize,
keys: sanitizeHotkeys(r.keys),
};
}
+2
View File
@@ -23,6 +23,7 @@ import { PACKS_URL, PACK_INDEX_FILE, readPackIndex, langMetaOf } from '../../../
import { DEV_BUILD } from '../../../shared/constants.js';
import { loadPref, savePref } from '../store.js';
import { data } from '../data.js';
import { updateRotateHintPwaI18n } from './device.js';
import { html } from './components.js';
/** The switch's own label, in both languages (whoever opens it may not read the current one). */
@@ -160,6 +161,7 @@ function applyDocument(lang) {
doc.documentElement.dataset.lang = lang;
doc.documentElement.dataset.script = scriptOf(t('卫戍协议'));
doc.title = t('卫戍协议:盟约 · STRONGHOLD PROTOCOL');
updateRotateHintPwaI18n(doc);
}
// `{ dn }` params and tName(): Chinese game-data names → the current language (data/i18n/<lang>.json names)
+25
View File
@@ -0,0 +1,25 @@
// Local listening preference, shared by roster and DIY details; deliberately absent from room.loadout.
import { html } from './components.js';
import { useSettings, updateSettings } from './settings.js';
import { t } from '../../../shared/i18n.js';
const VOICE_LANG_NAMES = { cn: '中文', jp: '日本語' }; // i18n-ignore
export function OperatorVoice({ charId }) {
const settings = useSettings();
if (!charId) return null;
const value = settings.voiceOverrides[charId] || '';
const change = (lang) => {
const voiceOverrides = { ...settings.voiceOverrides };
if (lang) voiceOverrides[charId] = lang;
else delete voiceOverrides[charId];
updateSettings({ voiceOverrides });
};
return html`<label class="lo-voice" data-voice-char=${charId}>
<span>${t('此干员语音')}</span>
<span class="lo-select"><select aria-label=${t('此干员语音')} value=${value} onChange=${(e) => change(e.currentTarget.value)}>
<option value="">${t('跟随全局')}</option><option value="cn">${VOICE_LANG_NAMES.cn}</option><option value="jp">${VOICE_LANG_NAMES.jp}</option>
</select></span>
<small>${t('仅保存在此浏览器,缺失的日语语音会回退到中文。')}</small>
</label>`;
}
+38
View File
@@ -0,0 +1,38 @@
// Recover an explicitly selected local seat; nickname and IP never identify a player.
import { useEffect, useState } from '../../vendor/hooks.module.js';
import { html, Button, Modal } from './components.js';
import { identity } from '../net.js';
import { t } from '../../../shared/i18n.js';
import { toast } from './toasts.js';
export function ResumeMatchButton() {
const [items, setItems] = useState(() => identity.recoverable());
const [open, setOpen] = useState(false);
const [busy, setBusy] = useState(false);
useEffect(() => {
const refresh = () => setItems(identity.recoverable());
window.addEventListener('storage', refresh);
window.addEventListener('focus', refresh);
refresh();
return () => { window.removeEventListener('storage', refresh); window.removeEventListener('focus', refresh); };
}, []);
if (!items.length) return null;
const resume = async (item) => {
if (busy) return;
setBusy(true);
if (await identity.resume(item.id)) {
identity.saveName(item.name);
identity.setEntered(true);
location.reload();
} else {
setBusy(false);
toast(t('此对局仍在其他窗口中,或当前浏览器无法安全恢复。请关闭原窗口后重试。'), 'warn');
}
};
return html`<${Button} variant="secondary" size="sm" data-testid="resume-local-match" onClick=${() => setOpen(true)}>${t('恢复本机对局')}<//>
<${Modal} open=${open} onClose=${() => !busy && setOpen(false)} title=${t('恢复本机对局')}>
<p>${t('选择已关闭窗口的对局。服务器重启或对局过期后无法恢复。')}</p>
${items.map((item) => html`<${Button} key=${item.id} variant="secondary" block=${true} disabled=${busy} onClick=${() => resume(item)}>
${t('{name} · 同盟 {code}', { name: item.name || t('博士'), code: item.code || '—' })}<//>`)}
<//>`;
}
+50 -16
View File
@@ -1,18 +1,24 @@
// Player settings (BGM/SFX/voice volume, the voice dub 语音语言, mute, damage numbers, render quality, the shortcut keys): a
// tiny observable store persisted in localStorage (`sp.pref.settings`), applied to the audio manager on every change, plus
// the settings modal — which also holds the language switch (ui/lang.js; kept apart in `sp.pref.lang`; under it a note
// while the current language's pack is a machine translation, `_meta.machineTranslated`) and the 快捷键
// section that rebinds the in-match shortcuts (the key map: ui/gameLogic/shortcuts.js; the community request
// 「快捷键可不可以自己设置」, the owner's decision of 2026-10-07) and 问题反馈, which copies the diagnostics of this page
// for a bug report (diag.js: the error log, this browser, optionally the battle on screen; nothing is uploaded). The
// lobby and the room open it from a 设置 button next to 玩法说明 (SettingsButton, GitHub #238); the title screen and the
// match have their own gear.
// Player settings (BGM/SFX/voice volume, the voice dub 语音语言, mute, damage numbers, render quality, 文字大小, the
// shortcut keys): a tiny observable store persisted in localStorage (`sp.pref.settings`), applied to the audio manager
// and to the document on every change, plus the settings modal — which also holds the language switch (ui/lang.js; kept
// apart in `sp.pref.lang`; under it a note while the current language's pack is a machine translation,
// `_meta.machineTranslated`) and the 快捷键 section that rebinds the in-match shortcuts (the key map:
// ui/gameLogic/shortcuts.js; the community request 「快捷键可不可以自己设置」, the owner's decision of 2026-10-07) and
// 问题反馈, which copies the diagnostics of this page for a bug report (diag.js: the error log, this browser, optionally
// the battle on screen; nothing is uploaded). The lobby and the room open it from a 设置 button next to 玩法说明
// (SettingsButton, GitHub #238); the title screen and the match have their own gear.
//
// 文字大小 (textSize, applied by applyTextSize): the interface text root `--t` of css/theme.css — a phone clamps the
// layout root `1rem` at 40 px (theme.css), which left the .18rem body text at 7.2 CSS px while the browser's own font
// settings only inflate the glyphs inside fixed boxes (and page zoom is off: index.html's viewport, ui/device.js).
// Only font-size declarations read `--t`, so the board, the HUD bands the prep camera keeps clear and the detail
// card's side do not move — the field is sized from the host element's clientWidth (render/app.js).
import { useLayoutEffect, useRef, useState } from '../../vendor/hooks.module.js';
import { html, Modal, Button, Icon, MicroLabel } from './components.js';
import { GIcon } from './gameComponents.js';
import { createStore, useStore, loadPref, savePref, store } from '../store.js';
import { sanitizeSettings, HOTKEY_ACTIONS, DEFAULT_HOTKEYS, hotkeyLabel, rebindHotkey, isDefaultHotkeys, captureHotkey, VOICE_LANGS } from './gameLogic.js';
import { sanitizeSettings, HOTKEY_ACTIONS, DEFAULT_HOTKEYS, hotkeyLabel, rebindHotkey, isDefaultHotkeys, captureHotkey, VOICE_LANGS, TEXT_SIZES } from './gameLogic.js';
import { audio } from '../audio.js';
import { openGuide } from './guide.js';
import { detectFeatures } from './device.js';
@@ -23,16 +29,31 @@ import { copyText } from './clipboard.js';
const cx = (...p) => p.flat().filter(Boolean).join(' ');
/** Settings store: { bgm, sfx, voice, voiceLang, muted, damageNumbers, quality, keys }. */
/** Settings store: { bgm, sfx, voice, voiceLang, voiceOverrides, muted, damageNumbers, quality, textSize, keys }. */
export const settingsStore = createStore(sanitizeSettings(loadPref('settings', null)));
/**
* 设置 →「文字大小」: put the step on <html data-text> — css/theme.css turns it into the text root `--t`
* (`:root[data-text="md"] { --t: … }`), which every readable font-size reads; no layout value does. The attribute
* (not an inline style) keeps the default in the stylesheet: without it — no JavaScript, an old saved profile, a
* value a future version dropped — the page is the design's own sizes. Values outside TEXT_SIZES fall back to 小.
* @param {'sm'|'md'|'lg'|'xl'} v
*/
export function applyTextSize(v) {
const el = globalThis.document?.documentElement;
if (el) el.dataset.text = TEXT_SIZES.includes(v) ? v : 'sm';
}
settingsStore.subscribe((s) => {
savePref('settings', sanitizeSettings(s));
audio.setVolumes(s);
audio.setVoiceLang(s.voiceLang);
audio.setVoiceLang(s.voiceLang, s.voiceOverrides);
applyTextSize(s.textSize);
});
audio.setVolumes(settingsStore.get());
audio.setVoiceLang(settingsStore.get().voiceLang);
audio.setVoiceLang(settingsStore.get().voiceLang, settingsStore.get().voiceOverrides);
// before the first render (main.js boot renders after its imports ran): the stored step is on screen without a flash
applyTextSize(settingsStore.get().textSize);
/** @param {Partial<ReturnType<typeof sanitizeSettings>>} patch */
export function updateSettings(patch) {
@@ -68,6 +89,11 @@ function Toggle({ label, micro, value, onChange }) {
}
const QUALITY = [['high', N_('高')], ['medium', N_('中')], ['low', N_('低')]];
/**
* 文字大小: one step of TEXT_SIZES (ui/gameLogic/settings.js) with its label — 小 is the design's own sizes, the others
* raise the text root `--t` (css/theme.css) and with it every readable font-size; the board and the HUD's boxes stay.
*/
const TEXT_SCALES = [['sm', N_('小')], ['md', N_('中')], ['lg', N_('大')], ['xl', N_('特大')]];
/**
* 语音语言: each dub named in its own language, like the interface language switch (ui/lang.js) — the owner's
* 「中文 / 日本語」 (2026-10-08); VOICE_LANGS order.
@@ -216,8 +242,8 @@ export function SettingsModal({ open, onClose }) {
const [touchUi] = useState(() => detectFeatures().coarse && !detectFeatures().fine);
const mtNote = machineTranslationNote(); // a pack marked as machine translation says so under the switch
return html`<${Modal} open=${open} onClose=${onClose} title=${t('设置')} micro="SETTINGS" width="7.4rem"
actions=${html`<${Button} variant="secondary" icon="book" class="set-guide" onClick=${() => openGuide(0)}>${t('玩法说明')}<//>
<${Button} variant="primary" icon="check" onClick=${onClose}>${t('完成')}<//>`}>
actions=${html`<${Button} variant="secondary" icon="book" class="set-guide" onClick=${() => openGuide(0)}>${t('玩法说明')}<//>
<${Button} variant="primary" icon="check" onClick=${onClose}>${t('完成')}<//>`}>
<div class="set-list">
<div class="set-row">
<span class="set-row__label">${t('语言')}<${MicroLabel}>LANGUAGE<//></span>
@@ -234,7 +260,7 @@ export function SettingsModal({ open, onClose }) {
</div>
</div>
<${Slider} label=${t('音效')} micro="SFX" icon="signal" value=${s.sfx}
onInput=${(v) => { updateSettings({ sfx: v }); if (!tested) { setTested(true); setTimeout(() => setTested(false), 400); audio.sfx('click'); } }} />
onInput=${(v) => { updateSettings({ sfx: v }); if (!tested) { setTested(true); setTimeout(() => setTested(false), 400); audio.sfx('click'); } }} />
<${Toggle} label=${t('静音')} micro="MUTE" value=${s.muted} onChange=${(v) => updateSettings({ muted: v })} />
<${Toggle} label=${t('显示伤害数字')} micro="DAMAGE NUMBERS" value=${s.damageNumbers} onChange=${(v) => updateSettings({ damageNumbers: v })} />
<div class="set-row">
@@ -244,6 +270,14 @@ export function SettingsModal({ open, onClose }) {
class=${s.quality === id ? 'is-on' : ''} onClick=${() => updateSettings({ quality: id })}>${t(label)}</button>`)}
</div>
</div>
<div class="set-row">
<span class="set-row__label">${t('文字大小')}<${MicroLabel}>TEXT SIZE<//></span>
<div class="set-seg set-textsize" role="radiogroup" aria-label=${t('文字大小')} data-testid="text-size">
${TEXT_SCALES.map(([id, label]) => html`<button key=${id} type="button" role="radio" aria-checked=${s.textSize === id ? 'true' : 'false'}
class=${s.textSize === id ? 'is-on' : ''} onClick=${() => updateSettings({ textSize: id })}>${t(label)}</button>`)}
</div>
</div>
<p class="set-hint set-textsize-note">${t('调整界面文字大小,棋盘保持原比例。')}</p>
<${HotkeySection} keys=${s.keys} touchUi=${touchUi} />
<${DiagSection} />
<p class="set-hint">${touchUi ? t('触屏操作:点击单位选中(撤退 / 出售)· 长按单位或卡牌查看详情 · 拖动部署后滑动选择朝向') : t('右键查看详情')}</p>
+49
View File
@@ -0,0 +1,49 @@
// A server-owned unanimous vote. Reconnects render the current vote from m.public.
import { useRef, useState } from '../../vendor/hooks.module.js';
import { html, Button } from './components.js';
import { actions } from './gameActions.js';
import { useStore } from '../store.js';
import { t } from '../../../shared/i18n.js';
export function SetupReroll({ pub }) {
const myId = useStore((s) => s.me.playerId);
const hostId = useStore((s) => s.room?.hostId);
const online = useStore((s) => s.connection.status === 'online');
const [busy, setBusy] = useState(false);
const pending = useRef(false);
const vote = pub.rerollVote;
const host = myId === hostId;
const players = pub.players || [];
const me = players.find((p) => p.playerId === myId && p.status !== 'left');
const allConnected = players.every((p) => p.isBot || p.status === 'left' || p.connected);
const voter = !!vote?.voters.includes(myId);
const agreed = !!vote?.agreed.includes(myId);
const send = async (fn) => {
if (pending.current) return;
pending.current = true;
setBusy(true);
try { await fn(); } finally { pending.current = false; setBusy(false); }
};
if (!vote && (!host || !me)) return null;
return html`<section class="brief-reroll" aria-label=${t('重刷本局')} aria-live="polite">
<div class="brief-reroll__text">
<strong>${vote ? t('重刷本局投票:{agreed}/{total}', { agreed: vote.agreed.length, total: vote.voters.length }) : t('重刷本局')}</strong>
<span>${vote ? t('全员同意后重新随机战场、领袖、特训敌人和禁用;确认倒计时已暂停。') : t('保留房间和个人配置,重新随机全部开局信息。')}</span>
${vote ? html`<div class="brief-reroll__players">${vote.voters.map((id) => {
const p = players.find((p) => p.playerId === id);
return html`<span key=${id} class=${vote.agreed.includes(id) ? 'is-agreed' : ''}>${p?.name || id} · ${vote.agreed.includes(id) ? t('已同意') : t('待同意')}</span>`;
})}</div>` : null}
</div>
<div class="brief-reroll__actions">
${!vote ? html`<${Button} icon="refresh" size="lg" loading=${busy} disabled=${!online || !allConnected}
title=${allConnected ? t('全体真人玩家一致同意后刷新,随机结果可能重复。') : t('请等待所有玩家连接后再发起投票')}
onClick=${() => send(() => actions.rerollSetup(pub.setupRevision ?? 0))}>${t('重刷本局')}<//>` : null}
${vote && voter ? html`<${Button} variant="primary" loading=${busy} disabled=${!online || agreed}
onClick=${() => send(() => actions.rerollVote(vote.id, true))}>${agreed ? t('已同意') : t('同意重刷')}<//>
<${Button} variant="danger" disabled=${busy || !online}
onClick=${() => send(() => actions.rerollVote(vote.id, false))}>${t('拒绝重刷')}<//>` : null}
${vote && host && me ? html`<${Button} disabled=${busy || !online}
onClick=${() => send(() => actions.cancelReroll(vote.id))}>${t('取消投票')}<//>` : null}
</div>
</section>`;
}
+14
View File
@@ -0,0 +1,14 @@
// Browser-local dub preferences. Every variant of an operator shares its charId; absent entries follow the global dub.
export const VOICE_LANGS = Object.freeze(['cn', 'jp']);
export function sanitizeVoiceOverrides(raw) {
const out = {};
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return out;
for (const [id, lang] of Object.entries(raw).slice(0, 512)) {
if (/^char_[0-9]+_[a-z0-9]+$/.test(id) && id.length <= 80 && VOICE_LANGS.includes(lang)) out[id] = lang;
}
return out;
}
export function voiceLangFor(charId, globalLang, overrides) {
const own = Object.hasOwn(overrides || {}, charId) ? overrides[charId] : null;
return VOICE_LANGS.includes(own) ? own : globalLang === 'jp' ? 'jp' : 'cn';
}
+16
View File
@@ -0,0 +1,16 @@
{
"id": "/",
"name": "卫戍协议:盟约",
"short_name": "卫戍协议",
"description": "明日方舟「卫戍协议:盟约」非官方同人复刻",
"start_url": "/",
"scope": "/",
"display": "fullscreen",
"orientation": "landscape-primary",
"background_color": "#0c0f0e",
"theme_color": "#0c0f0e",
"icons": [
{ "src": "/icons/app-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
{ "src": "/icons/app-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" }
]
}
+13
View File
@@ -343,6 +343,8 @@ export class Lobby {
case 'room.removeBot': return this.removeBot(session, msg);
case 'room.kick': return this.kick(session, msg);
case 'room.start': return this.start(session);
case 'room.rerollSetup': return this.rerollSetup(session, msg);
case 'room.cancelReroll': return this.rerollSetup(session, msg, true);
case 'room.loadout': return this.loadout(session, msg);
case 'room.ownership': return this.ownership(session, msg);
case 'room.diy': return this.diy(session, msg);
@@ -619,6 +621,17 @@ export class Lobby {
return this.startMatch(room, key);
}
/** Host authorization stays in the lobby; the match owns the vote and setup. */
rerollSetup(session, msg, cancel = false) {
const room = this.roomOf(session);
if (!room) return fail(ERR.NOT_IN_ROOM);
if (room.spectatorOf(session.playerId)) return fail(ERR.SPECTATOR);
if (room.hostId !== session.playerId) return fail(ERR.NOT_HOST);
const method = cancel ? 'cancelSetupReroll' : 'requestSetupReroll';
if (!room.match || typeof room.match[method] !== 'function') return fail(ERR.WRONG_PHASE);
return this.callMatch(room, method, session.playerId, cancel ? msg.voteId : msg.setupRevision) || fail(ERR.INTERNAL);
}
/**
* room.loadout (DESIGN §16): check the operator loadout — and its per-operator 潜能 / 练度 `ops` (0.2.2) — against the
* game data, store both on the session and the seat, and — while a match runs — hand them to the match (accepted only
+7 -1
View File
@@ -191,6 +191,7 @@ import { MatchWatch } from './match/watch.js';
import { MatchIntents } from './match/intents.js';
import { MatchPause } from './match/pause.js';
import { MatchPhases } from './match/phases.js';
import { MatchSetupVote } from './match/setupVote.js';
import { MatchSpDraft } from './match/spDraft.js';
import { MatchPrep } from './match/prep.js';
import { MatchCombat } from './match/combat.js';
@@ -348,6 +349,11 @@ export class Match {
/** @type {Set<any>} */
this._timers = new Set();
this._phaseTimer = null;
this._infoAdvanceTimer = null;
this.setupRevision = 0;
this.setupVote = null;
this._setupVoteSeq = 0;
this._lastSetupVoteAt = -Infinity;
this._turnTimer = null;
this._pubDirty = false;
this._pubTimer = null;
@@ -394,7 +400,7 @@ export class Match {
}
// the method modules, in this order (a name defined twice is an error, never a silent override)
for (const part of [MatchPlatform, MatchInfra, MatchMessaging, MatchViews, MatchWatch, MatchIntents, MatchPause, MatchPhases, MatchSpDraft, MatchPrep, MatchCombat, MatchClientCombat, MatchReports, MatchUnite, MatchBoss, MatchSettle]) {
for (const part of [MatchPlatform, MatchInfra, MatchMessaging, MatchViews, MatchWatch, MatchIntents, MatchPause, MatchPhases, MatchSetupVote, MatchSpDraft, MatchPrep, MatchCombat, MatchClientCombat, MatchReports, MatchUnite, MatchBoss, MatchSettle]) {
for (const key of Reflect.ownKeys(part.prototype)) {
if (key === 'constructor') continue;
if (Object.prototype.hasOwnProperty.call(Match.prototype, key)) throw new Error(`Match.${String(key)} is defined twice`);
+5
View File
@@ -603,6 +603,11 @@ function buyScore(m, ps, id, ctx) {
if (ctx.model && !isHealer(c)) s += ARMOR_WEIGHT * 0.6 * (armorFit(ctx.model, c) - 0.6);
// the player's own 自选 piece (a slotted slot: its composed record carries diyFor) — AI 托管 of a human with picks
if (c.diyFor) s += DIY_PIECE_BONUS;
// [ASSUMED] A soft 18-point cooperation weight (PR #407), not an official rule or a reserved card.
// Leave high-tier shared chess to living teammates whose opening strategy needs its faction.
// Humans and bots count equally; a single soft penalty still allows valuable own merges.
if (c.tier >= 5 && !c.isGolden && !c.diyFor && m.pool.has(base) && m.alivePlayers().some((mate) =>
mate !== ps && gd.bandBondIds(mate.bandId).some((b) => !gd.modeInactiveBonds.has(b) && c.bonds.includes(b)))) s -= 18;
return s;
}
+69
View File
@@ -0,0 +1,69 @@
// AI teammate reactions, adapted from PR #444 by @DDDarkstar.
// [ASSUMED] Reaction choices are social heuristics, not official gameplay rules.
// Only mixed human/AI matches; no RNG, decisions, timer scheduling or battle changes.
// Enabled by default; SP_BOT_EMOTES=0 disables every entry point.
import { EMOTES, EMOTE_COOLDOWN_MS } from '../../shared/constants.js';
export const BOT_EMOTES_ON = process.env.SP_BOT_EMOTES !== '0';
const E = (name) => `autochess_battle_${name}`;
export function hasHumans(m) {
return !!m?.order?.some((p) => p && !p.isBot) && !!m?.order?.some((p) => p?.isBot);
}
export function sendEmote(m, p, id) {
if (!BOT_EMOTES_ON || !hasHumans(m) || !p?.isBot || !p.alive || !EMOTES.includes(id)) return false;
const now = m.sched.now();
if (typeof p.lastEmoteAt === 'number' && now - p.lastEmoteAt < EMOTE_COOLDOWN_MS) return false;
p.lastEmoteAt = now;
m.broadcast({ t: 'm.emote', playerId: p.playerId, id });
return true;
}
export function onHumanEmote(m, senderId, id) {
if (!BOT_EMOTES_ON || !hasHumans(m) || !m.order.some((p) => p.playerId === senderId && !p.isBot)) return false;
const replies = { [E('thanks')]: E('noproblem'), [E('sorry')]: E('noproblem'),
[E('nice_cooperate')]: E('thanks'), [E('happy')]: E('happy'), [E('call')]: E('playingcool'),
[E('playingcool')]: E('happy'), [E('sad')]: E('sad') };
const reply = replies[id];
if (!reply) return false;
return m.order.some((p) => p.isBot && sendEmote(m, p, reply)); // at most one reply, skipping cooling bots
}
export function onPickCard(m, p, card) {
return card?.kind === 'bounty' && sendEmote(m, p, E('thinking'));
}
export function onStartUnite(m, plan) {
return !!plan?.helpers?.some((p) => p.isBot && sendEmote(m, p, E('nice_cooperate')));
}
export function pickSettleEmote(m, p) {
const r = m.lastResults.get(p.playerId);
if (!r) return null;
const ranked = m.alivePlayers().filter((q) => m.lastResults.has(q.playerId)).sort((a, b) => {
const ar = m.lastResults.get(a.playerId), br = m.lastResults.get(b.playerId);
return (br.killed || 0) - (ar.killed || 0) || (br.damageDealt || 0) - (ar.damageDealt || 0);
});
if (ranked.length > 1 && ranked[0] === p && r.killed > 0) return E('call');
const leaks = (r.leaked || []).filter((l) => l && l.counted !== false).length;
if (leaks > 0) return E('scared');
if (r.perfect !== false) return E('playingcool');
if (r.coins > 0) return E('thanks');
return null;
}
export function onSettle(m) {
if (!BOT_EMOTES_ON || !hasHumans(m) || m._botEmotesSettledRound === m.round) return false;
m._botEmotesSettledRound = m.round;
let sent = false;
for (const p of m.alivePlayers()) if (p.isBot) {
if (sendEmote(m, p, pickSettleEmote(m, p))) sent = true;
}
return sent;
}
export function onMerge(m, p, info) {
if (!BOT_EMOTES_ON || !hasHumans(m) || !p?.isBot || !p.alive || info?.kind !== 'chess') return false;
const counts = m._botEmotesMerges ??= new Map();
const n = (counts.get(p.playerId) || 0) + 1;
counts.set(p.playerId, n);
return n === 3 && sendEmote(m, p, E('scared'));
}
export function onGiftTicker(m, recipient, fromPlayerId) {
// Display names are not unique identities; use the trusted source player id.
return !!m.order.some((p) => p.playerId === fromPlayerId && !p.isBot && p !== recipient)
&& sendEmote(m, recipient, E('thanks'));
}
export function resetRoundCounters(m) { m._botEmotesMerges?.clear(); }
+1 -1
View File
@@ -282,7 +282,7 @@ const EFFECT_HANDLERS = {
if (!to) { ctx.removeEffect(ref.id); return; }
if (!to.grantChess(p.chessId)) return;
ctx.removeEffect(ref.id);
to.giftTicker(ctx.name, p.chessId);
to.giftTicker(ctx.name, p.chessId, ctx.playerId);
},
},
// 整备: the next purchased item becomes advanced (golden)
+4 -1
View File
@@ -44,6 +44,7 @@ import { pieceBonds as bondsOfPiece } from './bondsMeta.js';
import { registerAllMeta } from '../sim/content/index.js';
import { registerBuiltins } from './builtinMeta.js';
import { msg, dn } from '../../shared/i18n.js';
import { onGiftTicker } from './botEmotes.js';
export const HOOKS = Object.freeze([
'onRoundStart', 'onIncome', 'onPrepStart', 'onPrepEnd', 'onGain', 'onSold', 'onRefresh', 'onPrice', 'onBuy',
@@ -684,10 +685,12 @@ export function makeCtx(m, ps, source, hook, ev = null) {
* CHAR_GIFT broadcast to this player: "{0}博士给你赠送了{1}" — named as this player sees the gift (a chess it fields as
* its 补位 stand-in by the stand-in's name: 0.2.0, the owner's recall of the official mode, 2026-10-06).
*/
giftTicker: (fromName, chessId) => {
giftTicker: (fromName, chessId, fromPlayerId = null) => {
const c = gd.chess(chessId);
const shown = c && typeof ps.fieldRecord === 'function' ? ps.fieldRecord(c) || c : c;
m.tickerFor('CHAR_GIFT', [String(fromName), shown ? shown.name : String(chessId)], { to: ps.playerId });
// the recipient bot thanks the sender when the gift was a real player's CHAR_GIFT (enabled by default; SP_BOT_EMOTES=0 silences it)
onGiftTicker(m, ps, fromPlayerId);
},
// ---- team
+6
View File
@@ -8,14 +8,18 @@ import { unitStatsEntry } from '../../../shared/protocol.js';
import { PHASE, ERR, EMOTES, EMOTE_COOLDOWN_MS, GEO } from '../../../shared/constants.js';
import { deriveSeed } from '../../sim/rng.js';
import { OK, fail } from './common.js';
import { onHumanEmote } from '../botEmotes.js';
export class MatchIntents {
_handle(ps, msg) {
switch (msg.t) {
case 'g.infoReady':
if (this.phase !== PHASE.INFO_CHECK) return fail(ERR.WRONG_PHASE);
if (this.setupVote) return fail(ERR.WRONG_PHASE, 'setup reroll vote in progress');
if ((msg.setupRevision ?? 0) !== this.setupRevision) return fail(ERR.BAD_TARGET, 'setup changed; confirm the current revision');
if (!ps.infoReady) { ps.infoReady = true; this.markPublic(); this.maybeEndInfo(); }
return OK;
case 'g.rerollVote': return this.voteSetupReroll(ps, msg.voteId, msg.agree);
case 'g.band': return this.pickBand(ps, msg.bandId);
case 'g.bandSkip': return this.skipBand(ps);
// the strategy highlighted in the draft screen (what a timed-out turn takes, timeoutBand)
@@ -55,6 +59,8 @@ export class MatchIntents {
if (now - ps.lastEmoteAt < EMOTE_COOLDOWN_MS) return fail(ERR.RATE);
ps.lastEmoteAt = now;
this.broadcast({ t: 'm.emote', playerId: ps.playerId, id });
// an AI teammate can react to a human's emote (enabled by default: server/match/botEmotes.js, SP_BOT_EMOTES=0 silences it)
onHumanEmote(this, ps.playerId, id);
return OK;
}
+5 -2
View File
@@ -23,10 +23,13 @@ export class MatchPhases {
}
maybeEndInfo() {
if (this.phase !== PHASE.INFO_CHECK) return;
if (this.phase !== PHASE.INFO_CHECK || this.setupVote || this._infoAdvanceTimer) return;
if (this.order.every((p) => p.isBot || p.left || p.infoReady)) {
this.setDeadline(0);
this.later(0, () => { if (this.phase === PHASE.INFO_CHECK) this.enterBandDraft(); });
this._infoAdvanceTimer = this.later(0, () => {
this._infoAdvanceTimer = null;
if (this.phase === PHASE.INFO_CHECK && !this.setupVote) this.enterBandDraft();
});
}
}
+3
View File
@@ -82,6 +82,8 @@ export class MatchPlatform {
if (!ps || ps.isBot || this.disposed) return;
this.guard(() => {
ps.connected = false;
// A disconnected human's silence is never consent to a new setup.
this.cancelSetupVote();
// a paused solo battle resumes (the server takes the field over; nobody is left to resume it)
this._resume();
if (this.clientCombat) this._authorityLost(ps, 'disconnect');
@@ -130,6 +132,7 @@ export class MatchPlatform {
if (!ps || ps.isBot || ps.left || this.disposed) return;
this.guard(() => {
ps.left = true;
this.cancelSetupVote();
ps.connected = false;
ps.autoplay = false;
this.watchers.delete(playerId);
+3
View File
@@ -8,6 +8,7 @@ import { uniteSurvivors } from '../unite.js';
import { buildResult } from '../results.js';
import { FLOW_TICKER_PRIORITY, DELAYS } from './common.js';
import { msg } from '../../../shared/i18n.js';
import { onSettle, resetRoundCounters } from '../botEmotes.js';
export class MatchSettle {
settle(plan, uniteResult) {
@@ -83,6 +84,7 @@ export class MatchSettle {
this.fields = [];
this.watchers.clear();
this.markPublic();
onSettle(this); // one emote per alive AI per round (enabled by default; SP_BOT_EMOTES=0 silences it)
this.setDeadline(DELAYS.SETTLE / 1000, () => this.afterSettle(), { silent: this.soloUntimed });
}
@@ -111,6 +113,7 @@ export class MatchSettle {
afterSettle() {
if (!this.alivePlayers().length) { this.finish({ victory: false, reason: 'eliminated' }); return; }
resetRoundCounters(this); // AI bot merge-counter resets each round (server/match/botEmotes.js)
this.startRound(this.round + 1);
}
+119
View File
@@ -0,0 +1,119 @@
// INFO_CHECK full setup reroll: unanimous human consent, keeping the room and player settings.
// [ASSUMED] Remake convenience: unanimous human vote, pause/resume timer, independent reroll stream and
// a 300 ms request guard. These are not official mode rules; DESIGN §28.21.
// Installed on Match.prototype like the other method containers. The lobby authorizes host-only actions.
import { PHASE, ERR } from '../../../shared/constants.js';
import { createRng, deriveSeed } from '../../sim/rng.js';
import { setupMatchWaves } from '../waves.js';
import { drawDisabledBonds, SharedPool } from '../pool.js';
import { OK, fail } from './common.js';
export class MatchSetupVote {
requestSetupReroll(playerId, revision) {
if (this.disposed || this.ended || this.phase !== PHASE.INFO_CHECK) return fail(ERR.WRONG_PHASE);
const ps = this.players.get(playerId);
if (!ps || ps.isBot || ps.left || !ps.connected) return fail(ERR.NOT_IN_ROOM);
if (revision !== this.setupRevision) return fail(ERR.BAD_TARGET, 'setup revision changed');
if (this.setupVote) return fail(ERR.WRONG_PHASE, 'setup reroll vote in progress');
const humans = this.humans();
if (humans.some((p) => !p.connected)) return fail(ERR.NOT_READY, 'all humans must be connected to vote');
const now = this.sched.now();
if (now - this._lastSetupVoteAt < 300) return fail(ERR.RATE);
this._lastSetupVoteAt = now;
this.setupVote = {
id: ++this._setupVoteSeq,
proposerId: playerId,
voters: humans.map((p) => p.playerId),
agreed: new Set([playerId]),
remainingMs: this.deadline > 0 ? Math.max(1, this.deadline - now) : 0,
};
this.cancel(this._infoAdvanceTimer);
this._infoAdvanceTimer = null;
this.setDeadline(0);
if (humans.length === 1) return this.completeSetupReroll();
this.markPublic();
this.flush(true);
return OK;
}
voteSetupReroll(ps, voteId, agree) {
if (this.phase !== PHASE.INFO_CHECK || !this.setupVote) return fail(ERR.WRONG_PHASE);
const vote = this.setupVote;
if (vote.id !== voteId) return fail(ERR.BAD_TARGET, 'setup vote changed');
if (!ps.connected || !vote.voters.includes(ps.playerId)) return fail(ERR.NOT_IN_ROOM);
if (!agree) { this.cancelSetupVote(); return OK; }
vote.agreed.add(ps.playerId);
if (vote.voters.every((id) => vote.agreed.has(id))) return this.completeSetupReroll();
this.markPublic();
this.flush(true);
return OK;
}
cancelSetupReroll(playerId, voteId) {
if (this.disposed || this.ended || this.phase !== PHASE.INFO_CHECK || !this.setupVote) return fail(ERR.WRONG_PHASE);
if (this.setupVote.id !== voteId) return fail(ERR.BAD_TARGET, 'setup vote changed');
const ps = this.players.get(playerId);
if (!ps || ps.left || !ps.connected) return fail(ERR.NOT_IN_ROOM);
this.cancelSetupVote();
return OK;
}
cancelSetupVote() {
const vote = this.setupVote;
if (!vote) return;
this.setupVote = null;
if (this.phase === PHASE.INFO_CHECK && !this.disposed && !this.ended) {
if (vote.remainingMs > 0) {
this.deadline = this.sched.now() + vote.remainingMs;
this._phaseTimer = this.later(vote.remainingMs, () => {
this._phaseTimer = null;
this.enterBandDraft();
});
}
this.maybeEndInfo();
this.markPublic();
this.flush(true);
}
}
completeSetupReroll() {
let prepared;
try {
const rng = createRng(deriveSeed(this.seed, `reroll-setup:${this.setupRevision + 1}`));
const setup = setupMatchWaves(this.gd, rng);
const stage = setup.stageId ? this.gd.stage(setup.stageId) : null;
const bans = drawDisabledBonds(this.gd, rng);
const pool = new SharedPool(this.gd, { banned: bans.banned });
if (!pool.entries.size) throw new Error('rerolled chess pool is empty');
const off = new Set([...bans.drawn, ...bans.staticOff]);
const stocks = this.order.map((ps) => ps.buildDiyStock(off));
prepared = { setup, stage, bans, pool, stocks };
} catch (e) {
this.reportError('reroll setup', e);
this.cancelSetupVote();
return fail(ERR.INTERNAL);
}
// All data-dependent work succeeds before any opening or player state changes.
const { setup, stage, bans, pool, stocks } = prepared;
this.stageId = setup.stageId;
this.stage = stage;
this.factions = setup.factions;
this.bossId = setup.bossId;
this.hiddenBossId = setup.hiddenBossId;
this.disabledBonds = bans.drawn;
this.staticInactiveBonds = bans.staticOff;
this.bannedChess = bans.banned;
this.pool = pool;
this._botPath = null;
for (const [i, ps] of this.order.entries()) {
ps.diyStock = stocks[i].stock;
ps.diyBanned = stocks[i].banned;
ps.invalidateDeployMap();
ps.infoReady = ps.botControlled;
}
this.setupRevision++;
this.setupVote = null;
this.enterInfoCheck();
return OK;
}
}
+2
View File
@@ -6,6 +6,7 @@
import { PHASE, ERR } from '../../../shared/constants.js';
import { generateDraft, applyCard, bountyBattles, isMultiRoundBounty, bountyCard } from '../choices.js';
import { onPickCard } from '../botEmotes.js';
import { weightedPick } from '../waves.js';
import { botPickCard } from '../bot.js';
import { OK, fail, DELAYS } from './common.js';
@@ -102,6 +103,7 @@ export class MatchSpDraft {
try { applyCard(this, ps, card); } catch (e) { this.reportError(`applyCard ${card.id}`, e); }
this.markPrivate(ps);
this.markPublic();
onPickCard(this, ps, card); // AI bots say "思考" when they take a bounty (enabled by default; SP_BOT_EMOTES=0 silences it)
this.startSpTurn();
}
+2
View File
@@ -10,6 +10,7 @@ import { FieldRunner, timelineAt, uniteBillBounds } from '../fields.js';
import { uniteLeft } from '../../sim/spec.js';
import { FLOW_TICKER_PRIORITY, DELAYS } from './common.js';
import { msg } from '../../../shared/i18n.js';
import { onStartUnite } from '../botEmotes.js';
export class MatchUnite {
startUnite(plan) {
@@ -23,6 +24,7 @@ export class MatchUnite {
this._defaultWatch();
this.markPublic();
this.tickerText(msg('联防阶段:{names} 迎战突破防线的敌人', { names: plan.helpers.map((p) => p.name) }), FLOW_TICKER_PRIORITY);
onStartUnite(this, plan); // an AI helper says "合作愉快" once on the open of a 联防 (enabled by default; SP_BOT_EMOTES=0 silences it)
this._uniteLeftKey = null;
this.runner = new FieldRunner(this, this.fields, {
onTick: (runner) => this._uniteTick(runner),
+7
View File
@@ -71,6 +71,13 @@ export class MatchViews {
modeId: this.modeId,
difficulty: this.difficulty,
stageId: this.stageId,
setupRevision: this.setupRevision,
rerollVote: this.setupVote ? {
id: this.setupVote.id,
proposerId: this.setupVote.proposerId,
voters: this.setupVote.voters.slice(),
agreed: [...this.setupVote.agreed],
} : null,
factions: this.factions.slice(),
disabledBonds: [...new Set([...this.disabledBonds, ...this.staticInactiveBonds])].sort(),
drawnDisabledBonds: this.disabledBonds.slice(),
+2
View File
@@ -7,6 +7,7 @@
import { pieceDir, parseDir, mergeTile } from '../board.js';
import { MAX_OFFER_SLOTS } from './common.js';
import { onMerge } from '../botEmotes.js';
export class PlayerAcquire {
/** Normal copies of a base chess currently owned (board/hand/temp). */
@@ -134,6 +135,7 @@ export class PlayerAcquire {
const shown = rec ? this.fieldRecord(rec) || rec : null;
this.m.tickerFor('GOLDEN_CHAR', [this.name, shown ? shown.name : goldenId], { playerId: this.playerId });
this.m.dispatch(this, 'onMerge', { kind: 'chess', piece: elite, baseId, consumed: consumed.map((l) => l.piece.uid), area: where });
onMerge(this.m, this, { kind: 'chess' }); // AI bot says "害怕" on its 3rd chess merge of a round (enabled by default; SP_BOT_EMOTES=0 silences it)
return elite;
}
+10 -3
View File
@@ -177,7 +177,14 @@ export class PlayerDiy {
* @param {Set<string>} off
*/
initDiyStock(off = new Set()) {
this.diyStock = new DiyStock();
const { stock, banned } = this.buildDiyStock(off);
this.diyStock = stock;
this.diyBanned = banned;
}
/** Prepare stock without mutating this player, so setup rerolls can commit all players together. */
buildDiyStock(off = new Set()) {
const stock = new DiyStock();
const banned = [];
const gd = this.m.gd;
for (const slotId of Object.keys(this.diy || {})) {
@@ -188,9 +195,9 @@ export class PlayerDiy {
if (bonds.length > 0 && bonds.every((b) => off.has(b))) { banned.push(slotId); continue; }
const cap = gd.poolCopies(slotId);
if (!(cap > 0)) continue;
this.diyStock.entries.set(slotId, { cap, left: cap, tier: gd.tierOf(slotId), shopLevel: slot.shopLevel });
stock.entries.set(slotId, { cap, left: cap, tier: gd.tierOf(slotId), shopLevel: slot.shopLevel });
}
this.diyBanned = Object.freeze(banned);
return { stock, banned: Object.freeze(banned) };
}
/** The copy accounting of base chess `baseId` for this player: its own stock for a slotted DIY slot, else the shared pool. */
+19 -9
View File
@@ -774,15 +774,25 @@ export function setEchoForm(b, echo, form) {
export function echoHit(b, echo) {
const ab = echo.mem.ab;
if (!ab || !echo.alive) return;
const atk = echo.s.atk;
b.fx('explode', { x: echo.x, y: echo.y, r: ECHO_PULSE_RADIUS, kind: 'echoPulse' });
for (const u of areaAllies(b, echo, echo.x, echo.y, ECHO_PULSE_RADIUS)) {
hurt(b, echo, u, atk * (T(ab, '3.atk_scale') ?? 0), 'arts');
elem(b, echo, u, 'apoptosis', atk * (T(ab, '3.ep_damage_ratio') ?? 0));
}
const need = ab.form === 'gold' ? T(ab, '1.hit_times_to_switch') : T(ab, '2.hit_times_to_switch');
ab.strikes = (ab.strikes ?? 0) + 1;
if (need > 0 && ab.strikes >= need) setEchoForm(b, echo, ab.form === 'gold' ? 'dark' : 'gold');
// [ASSUMED] Resolve recursively earned pulses FIFO after the current pulse. A counter can remove dozens of
// hit-count HP here; synchronous damaged → counter → pulse nesting used to trip the engine's 32-hook guard.
// Keep the pulse earned by the lethal hit, too. No timer, extra RNG or skipped damage (DESIGN §28.24).
(ab.pulses ||= []).push(echo.s.atk);
if (ab.pulsing) return;
ab.pulsing = true;
try {
for (let i = 0; i < ab.pulses.length; i++) {
const atk = ab.pulses[i];
b.fx('explode', { x: echo.x, y: echo.y, r: ECHO_PULSE_RADIUS, kind: 'echoPulse' });
for (const u of areaAllies(b, echo, echo.x, echo.y, ECHO_PULSE_RADIUS)) {
hurt(b, echo, u, atk * (T(ab, '3.atk_scale') ?? 0), 'arts');
elem(b, echo, u, 'apoptosis', atk * (T(ab, '3.ep_damage_ratio') ?? 0));
}
const need = ab.form === 'gold' ? T(ab, '1.hit_times_to_switch') : T(ab, '2.hit_times_to_switch');
ab.strikes = (ab.strikes ?? 0) + 1;
if (echo.alive && need > 0 && ab.strikes >= need) setEchoForm(b, echo, ab.form === 'gold' ? 'dark' : 'gold');
}
} finally { ab.pulses.length = 0; ab.pulsing = false; }
}
function kitEcho(ab, e) {
@@ -232,7 +232,7 @@ function lemuen(bb, chess, def) {
return keys;
};
// #428: extra attack range also opens her skill under the existing content-trigger contract.
unit.skill?.addTriggerRange(() => [{ keys: wantedKeys(), profile: unit.profile }]);
unit.skill?.addTriggerRange(() => [{ keys: wantedKeys(), profile: unit.profile }], { attackOnly: true });
let sig = null;
const refresh = () => { // update immediately when the last source leaves, before another attack can run
const keys = live(unit) ? wantedKeys() : [];
+12 -3
View File
@@ -380,12 +380,21 @@ export default {
} },
],
install(battle, unit) {
// PRTS 卫戍协议/帮助: special targeting also supplies the DEFAULT attack opportunity (#440).
// Query live owned blockers for both paths; S2's links still use its own effect range, S3 keeps SKILL_RANGE.
const blockedKeys = () => {
if (!up(unit)) return [];
const keys = [];
for (const s of skelsUp(unit)) for (const e of s.blocking) if (e.alive && e.blockedBy === s) keys.push(tileKey(Math.round(e.y), Math.round(e.x)));
return [...new Set(keys)].sort((a, b) => a - b);
};
if (unit.skill?.id === S1 || unit.skill?.id === S2) {
unit.skill.addTriggerRange(() => [{ keys: blockedKeys(), profile: unit.profile }], { attackOnly: true });
}
// trait: the enemies her summons block are her targets too
battle.on('tick', () => {
if (!up(unit)) return;
const keys = [];
for (const s of skelsUp(unit)) for (const e of s.blocking) if (e.alive && e.blockedBy === s) keys.push(tileKey(Math.round(e.y), Math.round(e.x)));
const uniq = [...new Set(keys)].sort((a, b) => a - b);
const uniq = blockedKeys();
const cur = unit.extraRangeKeys ?? [];
if (uniq.length === cur.length && uniq.every((k, i) => k === cur[i])) return;
battle.setExtraRange(unit, uniq.length ? uniq : null);
+10 -6
View File
@@ -125,6 +125,7 @@ export class SkillRuntime {
this._trigKeys = null;
this._trigSet = null;
this.triggerRanges = []; // content trigger ranges (addTriggerRange)
this.attackTriggerRanges = new Set(); // special targeting checked only at the next attack
this.noSkill = !spec; // unit without any skill spec
}
@@ -147,20 +148,23 @@ export class SkillRuntime {
* keys, or `{ keys, profile }` — tile keys with the enemy profile the effect selects by (`canHitFly` false: ground
* enemies only — the owner's larger-range rule through a summon's area, kits/shared/summoner.js summonTriggerArea;
* 0.2.0 WV, additive). A targetable enemy (flyers included unless the entry's profile says otherwise) on those tiles
* satisfies the DEFAULT rule (and unknown DEFAULT-like rules); it is checked every tick, since the unit itself may
* have nothing to attack. Returns an unregister fn.
* satisfies the DEFAULT rule (and unknown DEFAULT-like rules). Summon effect areas are checked every tick;
* { attackOnly: true } is for special targeting that must wait for the next attack (蕾缪安, 死芒).
* Returns an unregister fn.
*/
addTriggerRange(fn) {
addTriggerRange(fn, { attackOnly = false } = {}) {
if (typeof fn !== 'function') return () => {};
this.triggerRanges.push(fn);
return () => { const i = this.triggerRanges.indexOf(fn); if (i >= 0) this.triggerRanges.splice(i, 1); };
if (attackOnly) this.attackTriggerRanges.add(fn);
return () => { this.attackTriggerRanges.delete(fn); const i = this.triggerRanges.indexOf(fn); if (i >= 0) this.triggerRanges.splice(i, 1); };
}
/** An enemy inside one of the content trigger ranges. */
_extraTriggerSatisfied() {
_extraTriggerSatisfied(allowAttackOnly = true) {
const b = this.battle;
const u = this.unit;
for (const fn of this.triggerRanges) {
if (!allowAttackOnly && this.attackTriggerRanges.has(fn)) continue;
const list = b._safe(() => fn(b, u), 'skill.triggerRange', u);
if (!list || typeof list[Symbol.iterator] !== 'function') continue;
for (const x of list) {
@@ -405,7 +409,7 @@ export class SkillRuntime {
const prof = u.profile;
if (prof && (prof.noAttack || (prof.noAttackUnlessSkill && !this.active))) {
if (this._defaultCondition()) this.activate('DEFAULT');
} else if (this.triggerRanges.length && !this.healSkill && this._extraTriggerSatisfied()) this.activate('DEFAULT');
} else if (this.triggerRanges.length && !this.healSkill && this._extraTriggerSatisfied(false)) this.activate('DEFAULT');
}
}
+4 -1
View File
@@ -394,6 +394,8 @@ export const C2S = {
// host confirmed — a seat that changed hands meanwhile is refused
'room.kick': { seat: (v) => isInt(v, 0, MAX_SEATS - 1), playerId: isId },
'room.start': {},
'room.rerollSetup': { setupRevision: (v) => isInt(v, 0, 2 ** 31) },
'room.cancelReroll': { voteId: (v) => isInt(v, 1, 2 ** 31) },
// operator loadout (DESIGN §16): stored per session/seat; accepted until the match leaves INFO_CHECK — `ops` (0.2.2):
// the per-operator 潜能 / 练度 (absent = none set: every operator at 潜能 6, 精英2 Lv.60)
'room.loadout': { entries: isLoadoutEntries, ops: isLoadoutOps, $optional: ['ops'] },
@@ -410,7 +412,8 @@ export const C2S = {
'room.removeSpectator': { playerId: isId },
// match
'g.infoReady': {},
'g.infoReady': { setupRevision: (v) => isInt(v, 0, 2 ** 31), $optional: ['setupRevision'] },
'g.rerollVote': { voteId: (v) => isInt(v, 1, 2 ** 31), agree: isBool },
'g.band': { bandId: isId },
'g.bandSkip': {},
// the strategy highlighted in the draft screen (user playtest #4 item 4): a turn that runs out takes it while it is
+46
View File
@@ -1308,3 +1308,49 @@ describe('multi-device & browser compatibility (static)', () => {
assert.match(src, /--tap-min: 44px/);
});
});
describe('explicit local match recovery (#431)', () => {
test('an idle tab can reclaim a closed match tab without taking a live one', async () => {
const { createIdentity } = await mod('net.js');
const local = memStorage(), hub = channelHub(), ca = hub.create(), cb = hub.create();
const a = createIdentity({ local, session: memStorage(), tabId: 'a', channel: ca, queryMs: 5 });
const b = createIdentity({ local, session: memStorage(), tabId: 'b', channel: cb, queryMs: 5 });
await a.init(); a.saveToken('idle-token');
await b.init(); b.saveToken('match-token'); b.rememberMatch({ name: 'Doctor B', code: 'ABC123' });
const [item] = a.recoverable();
assert.equal(item.name, 'Doctor B');
assert.equal(JSON.stringify(item).includes('match-token'), false);
assert.equal(await a.resume(item.id), false, 'live original keeps its seat');
assert.equal(a.getToken(), 'idle-token');
cb.close();
assert.equal(await a.resume(item.id), true);
assert.equal(a.getToken(), 'match-token');
a.rememberMatch(null);
assert.deepEqual(JSON.parse(local.getItem('sp.matches')), {});
ca.close();
});
test('simultaneous recoveries pick one winner; absent channels and unknown ids cannot recover', async () => {
const { createIdentity } = await mod('net.js');
const local = memStorage(), hub = channelHub();
local.setItem('sp.tokens', JSON.stringify(['idle-b', 'idle-a']));
const sa = memStorage(), sb = memStorage(); sa.setItem('sp.token', 'idle-a'); sb.setItem('sp.token', 'idle-b');
const a = createIdentity({ local, session: sa, tabId: 'a', channel: hub.create(), queryMs: 5 });
const b = createIdentity({ local, session: sb, tabId: 'b', channel: hub.create(), queryMs: 5 });
await Promise.all([a.init(), b.init()]);
const closed = createIdentity({ local, session: memStorage(), channel: null });
closed.saveToken('closed-token'); closed.rememberMatch({ name: 'C', code: 'XYZ' });
const id = a.recoverable()[0].id;
assert.deepEqual(await Promise.all([a.resume(id), b.resume(id)]), [true, false]);
assert.equal(await b.resume('unknown'), false);
assert.equal(await closed.resume(id), false);
});
test('server replacement clears expired metadata and malformed storage remains safe', async () => {
const { createIdentity } = await mod('net.js');
const local = memStorage(); const a = createIdentity({ local, session: memStorage(), channel: null });
a.saveToken('old'); a.rememberMatch({ name: 'D', code: 'AAA' }); a.saveToken('new');
assert.deepEqual(a.recoverable(), []);
for (const bad of ['null', '[]', '{', '"hello"']) {
local.setItem('sp.matches', bad); assert.deepEqual(a.recoverable(), []);
}
});
});
+26
View File
@@ -0,0 +1,26 @@
// Real reflected hits against a scaled hit-count enemy must finish without recursive hook overflow.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, checkInvariants } from '../helpers/battleHarness.js';
const ECHO = 'enemy_9023_acdums';
for (const chessId of ['chess_char_1_06_b', 'chess_char_2_08_a']) {
test(`${chessId}: a 67-hit echo resolves all counter pulses including its lethal hit`, () => {
const h = makeBattle({ units: [{ chessId, row: 10, col: 4 }], autoFinish: false, captureNoisy: true, hooks: ['damaged'] });
h.step();
const u = h.unit(chessId);
h.b.addBuff(u, { key: 'test:survive', persist: true, mods: { hpFlat: 1e8 } });
u.hp = u.s.maxHp;
u.skill.sp = u.skill.spCost; u.skill.charges = 1;
assert.ok(u.skill.activate('manual'));
const echo = h.spawn(ECHO, { pos: [10, 5], mods: { hpMul: 6.7, speedMul: 0 } });
assert.equal(echo.s.maxHp, 67);
const t = h.eventsOf('fx').length;
h.b.dealDamage(u, echo, { amount: 1, type: 'arts', isAttack: true });
assert.equal(echo.alive, false);
assert.equal(h.b.errors.length, 0);
assert.equal(h.eventsOf('fx').slice(t).filter((f) => f[1] === 'explode' && f[4]?.kind === 'echoPulse').length, 67);
assert.equal(echo.mem.ab.pulsing, false);
assert.deepEqual(echo.mem.ab.pulses, []);
checkInvariants(h.b);
});
}
+8
View File
@@ -59,3 +59,11 @@ test('immediate redeployment cannot attack through a cleared wanted range', () =
h.step();assert.equal(u.stats.attacks, before, 'the allies phase sees the cleared range immediately');
done(h);
});
for (const skillIndex of [0, 1, 2]) test(`wanted S${skillIndex + 1} waits for the next attack, not the next tick`, () => {
const { h, u, e } = field({ skillIndex });
u.skill.sp = 0;h.run(8.5);assert.ok(e.findBuff('lemuen:wanted'));
u.atkCd = 0.7;u.skill.gainSp(999);
h.run(0.3);assert.equal(u.skill.activations, 0);
h.run(0.6);assert.equal(u.skill.activations, 1);done(h);
});
+44
View File
@@ -0,0 +1,44 @@
// #440: special targeting supplies the DEFAULT attack opportunity, without extending S2 links.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
import { COLS } from '../../server/sim/constants.js';
function field(skillIndex, kind = 'unite') {
const h = makeBattle({ kind, timeLimit: 90, autoFinish: false, seed: 5,
flags: { dpPerSec: 0, dpMax: 999 }, hooks: ['skillStart', 'damaged'], captureNoisy: true,
defs: { enemies: { weak: enemyRec({ key: 'weak', hp: 100, speed: 0, mass: 0 }),
walker: enemyRec({ key: 'walker', hp: 1e9, speed: 0.5, mass: 0 }) } },
units: [{ uid: 1, elite: true, row: 10, col: 3,
diy: { slot: 'chess_char_6_diy1_a', charId: 'char_450_necras', skillIndex, uniEquipId: null } }],
});
h.step();
const u = h.unit(1);u.skill.sp = 0;
const weak = h.spawn('weak', { pos: [10, 6] });h.step();h.b.kill(weak, null);h.run(0.2);
const summon = u.mem.necras.skels.find((s) => s.alive);assert.ok(summon);
const e = h.spawn('walker', { pos: [10, 9] });
assert.ok(h.runUntil(() => e.blockedBy === summon, 20));h.step(3);
const key = Math.round(e.y) * COLS + Math.round(e.x);
assert.ok(!u.baseRangeKeys.includes(key) && u.extraRangeKeys.includes(key));
return { h, u, e, summon };
}
function done(h) { checkInvariants(h.b);assert.deepEqual(h.b.errors, []); }
for (const kind of ['normal', 'unite']) for (const skillIndex of [0, 1]) {
test(`Necras S${skillIndex + 1} starts on its next attack against a summon-blocked remote target (${kind})`, () => {
const { h, u, e } = field(skillIndex, kind);
u.skill.gainSp(999);u.atkCd = 0.7;
h.run(0.3);assert.equal(u.skill.activations, 0, 'retain attack cadence');
h.run(0.6);assert.equal(u.skill.activations, 1);
if (skillIndex === 1) assert.ok(!u.mem.necrasLinks?.some((l) => l.e === e), 'remote trigger does not extend S2 effect range');
done(h);
});
}
test('remote Necras trigger needs a selectable enemy and the owning summon', () => {
const { h, u, e, summon } = field(1);
e.base.moveSpeed = 0;e.markDirty();
h.b.applyStatus(e, 'sleep', { duration: 5 });u.skill.gainSp(999);u.atkCd = 0;
h.run(1);assert.equal(u.skill.activations, 0, 'sleep cannot open DEFAULT');
h.b.retreat(summon, { reason: 'test' });
h.run(5);assert.equal(u.skill.activations, 0, 'expired block does not retain an extra trigger');
done(h);
});
+119
View File
@@ -0,0 +1,119 @@
// test/feedback6-leizi2-attack-sfx.test.js — user report 「司霆惊蛰三技能攻击没有音效」 (2026-10-08).
//
// 司霆惊蛰 (char_1043_leizi2, 自选 6★ 解放者) attacks only while a skill runs (the trait: 通常不攻击), and the official
// client has no normal-mode bank for her at all: every bank of hers is a numbered ABILITY variant whose file names the
// skill mode — excel/audio_data.json:
//
// battle.ON_ABILITY_ON.char_1043_leizi2.attack.1.2.1 → p_atk_lzxqlkl_d1 技能1
// battle.ON_ABILITY_START.char_1043_leizi2.attack.4 → p_atk_lzxqlkl_h2 技能2 (ON also carries _h)
// battle.ON_ABILITY_START.char_1043_leizi2.attack.7 → p_atk_lzxqlkl_s 技能3
//
// `pickUnitSfx` refuses skill-mode files for an operator's NORMAL attack (community report #54, 银灰 — the file of a
// skill mode must not ring on every normal attack), so she had no `attack` sound at all and her attacks were silent.
// The fix keeps that rule and adds the mode's own file per skill index: tools/assets/audio.mjs pickModeAttacks() reads
// the mode letter of each bank's file (_d / _h / _s = 技能1 / 2 / 3, the same letter an operator's activation sounds
// use), plan.mjs writes it as sfx.units[id].attacks = { '<skillIndex>': url }, and public/js/audio.js plays it while
// that skill is active (the ['skill', id, 1] / 0 events) — never outside it.
//
// Run: node --test test/feedback6-leizi2-attack-sfx.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { pickModeAttacks, skillModeLetter, SLOT_MODE_LETTER } from '../tools/assets/audio.mjs';
const P = (f) => `player/${f.startsWith('p_imp') ? 'p_imp' : 'p_atk'}/${f}.mp3`;
const LEIZI2 = 'char_1043_leizi2';
const manifest = JSON.parse(readFileSync(new URL('../data/assets.json', import.meta.url), 'utf8'));
/** 司霆惊蛰's own banks, exactly as excel/audio_data.json lists them. */
const LEIZI2_BANKS = new Map([
['ON_UNIT_BORN', [P('b_char_lzxqlkl')]],
['ON_ABILITY_ON.attack.1.2.1', [P('p_atk_lzxqlkl_d1')]],
['ON_ABILITY_HIT.attack.1.2.1', [P('p_imp_lzxqlkl_n')]],
['ON_ABILITY_HIT.attack.1.2.2', [P('p_imp_lzxqlkl_n')]],
['ON_ABILITY_HIT.attack.1.2.3', [P('p_imp_lzxqlkl_n')]],
['ON_ABILITY_START.attack.4', [P('p_atk_lzxqlkl_h2')]],
['ON_ABILITY_ON.attack.4', [P('p_atk_lzxqlkl_h')]],
['ON_ABILITY_HIT.attack.4', [P('p_imp_lzxqlkl_h')]],
['ON_ABILITY_START.attack.7', [P('p_atk_lzxqlkl_s')]],
['ON_UNIT_DEAD', [P('b_char_dead')]],
]);
test('pickModeAttacks: 司霆惊蛰 gets one attack sound per skill mode (d / h / s), the START bank first', () => {
assert.deepEqual(pickModeAttacks(LEIZI2_BANKS), {
d: [P('p_atk_lzxqlkl_d1')],
h: [P('p_atk_lzxqlkl_h2')], // ON_ABILITY_START.attack.4, not the ON_ABILITY_ON.attack.4 (_h) variant
s: [P('p_atk_lzxqlkl_s')],
});
// nothing else is taken from her banks: the _n impacts and the born / dead sounds are pickUnitSfx's business
assert.deepEqual(Object.keys(pickModeAttacks(LEIZI2_BANKS)).sort(), ['d', 'h', 's']);
});
test('pickModeAttacks: only ability banks, only skill-mode files, nothing for a normal-mode unit', () => {
// a normal attack bank (_n) is never a mode: it stays the operator's normal attack (pickUnitSfx)
assert.deepEqual(pickModeAttacks(new Map([['ON_ABILITY_START.attack', [P('p_atk_sword_n')]]])), {});
// the unsuffixed default mode of an operator whose library is normal-mode only
assert.deepEqual(pickModeAttacks(new Map([['ON_ABILITY_HIT.combat', [P('p_imp_spear_n')]]])), {});
// a mode file under a non-attack ability (a talent, a mode switch) is not an attack sound
assert.deepEqual(pickModeAttacks(new Map([['ON_ABILITY_START.T.1', [P('p_atk_x_s')]]])), {});
// empty / absent banks
assert.deepEqual(pickModeAttacks(new Map()), {});
assert.deepEqual(pickModeAttacks(undefined), {});
// ON_ABILITY_ON only (no START bank): still read
assert.deepEqual(pickModeAttacks(new Map([['ON_ABILITY_ON.attack.2', [P('p_atk_firesword_h')]]])), { h: [P('p_atk_firesword_h')] });
});
test('skillModeLetter / SLOT_MODE_LETTER: 技能1 / 2 / 3 = _d / _h / _s (+ digits), _n is the normal mode', () => {
assert.equal(skillModeLetter(P('p_atk_lzxqlkl_s')), 's');
assert.equal(skillModeLetter(P('p_atk_lzxqlkl_d1')), 'd');
assert.equal(skillModeLetter('/assets/audio/sfx/player/p_skill/p_skill_khlrftcrdfr_h.mp3'), 'h');
assert.equal(skillModeLetter(P('p_atk_sword_n')), null);
assert.equal(skillModeLetter(P('p_atk_snwlprdg_n1')), null);
assert.equal(skillModeLetter(null), null);
assert.deepEqual(SLOT_MODE_LETTER, { 0: 'd', 1: 'h', 2: 's' });
});
test('the committed manifest carries her three mode attacks under the skill indexes', () => {
const u = manifest.audio.sfx.units[LEIZI2];
assert.equal(u.attack, undefined, 'she has no normal-mode attack bank — that was the silent attack');
assert.deepEqual(u.attacks, {
0: '/assets/audio/sfx/player/p_atk/p_atk_lzxqlkl_d1.mp3',
1: '/assets/audio/sfx/player/p_atk/p_atk_lzxqlkl_h2.mp3',
2: '/assets/audio/sfx/player/p_atk/p_atk_lzxqlkl_s.mp3',
});
// her activation sounds (the other half of the chain) name the same modes: _d / _h / _s for 技能1 / 2 / 3
assert.deepEqual(u.skills, {
0: '/assets/audio/sfx/player/p_skill/p_skill_lzxqlkl_d.mp3',
1: '/assets/audio/sfx/player/p_skill/p_skill_lzxqlkl_h.mp3',
2: '/assets/audio/sfx/player/p_skill/p_skill_lzxqlkl_s.mp3',
});
assert.equal(u.hit, '/assets/audio/sfx/player/p_imp/p_imp_lzxqlkl_n.mp3', 'her normal impact is untouched');
});
test('every manifest unit with both tables agrees with the mode convention (d / h / s = 技能1 / 2 / 3)', () => {
const units = manifest.audio.sfx.units;
let both = 0;
for (const [id, u] of Object.entries(units)) {
if (!u?.attacks || !u?.skills) continue;
both++;
for (const [i, url] of Object.entries(u.attacks)) {
const a = skillModeLetter(url);
assert.ok(a, `${id}: attacks[${i}] is a skill-mode file (${url})`);
assert.equal(a, SLOT_MODE_LETTER[i], `${id}: attacks[${i}] is 技能${Number(i) + 1} (the _${a} mode)`);
}
}
assert.ok(both >= 5, `some operators carry both tables (found ${both})`);
});
test('no operator takes a skill-mode file as its normal attack or impact (community report #54 stays fixed)', () => {
const units = manifest.audio.sfx.units;
for (const [id, u] of Object.entries(units)) {
if (!id.startsWith('char_')) continue;
for (const kind of ['attack', 'hit']) {
const url = u?.[kind];
if (typeof url !== 'string') continue;
assert.equal(skillModeLetter(url), null, `${id}.${kind} = ${url} is a skill mode's file`);
}
}
});
+103
View File
@@ -0,0 +1,103 @@
// test/feedback7-chen3-s3-sfx.test.js — user report 「赤刃明霄陈开启三技能后斩击音效应该和普通攻击不一样」 (2026-10-08).
//
// 赤刃明霄陈 (char_1050_chen3) has both kinds of bank, and BOTH of her S3's sounds differ from her normal attack —
// excel/audio_data.json, her ability banks (`attack.<n>` = the mode; the mode is in the file name):
//
// battle.ON_ABILITY_START.char_1050_chen3.attack.0 → p_atk_hljdswd_n 普通攻击(默认形态)
// battle.ON_ABILITY_HIT.char_1050_chen3.attack.0 → p_imp_hljdswd_n 普通攻击的命中
// battle.ON_ABILITY_START.char_1050_chen3.attack.1 → p_atk_hljdfswd_d 技能1
// battle.ON_ABILITY_HIT.char_1050_chen3.attack.1.0 → p_imp_hljdrfswd_d 技能1 的命中
// battle.ON_ABILITY_HIT.char_1050_chen3.attack.1.1 → p_imp_hljdylfswd_d 技能1 的命中(第二个变体)
// battle.ON_ABILITY_START.char_1050_chen3.attack.3 → p_atk_hljdswd_s 技能3
// battle.ON_ABILITY_HIT.char_1050_chen3.attack.3 → p_imp_hljdswd_s 技能3 的命中 ← 这就是「斩击」
//
// `pickUnitSfx` refuses skill-mode files for the NORMAL attack / impact (社区反馈 #54, 银灰) and therefore keeps the `_n`
// pair — correct. The fix of 司霆惊蛰 (test/feedback6-…) already plays the mode's own swing while the skill runs; this
// report is the other half: without a per-mode IMPACT the S3 slash still landed with p_imp_hljdswd_n, i.e. it sounded
// like her normal attack. tools/assets/audio.mjs pickModeHits() now reads `ON_ABILITY_HIT` per mode (same mode letters,
// same slot rule), plan.mjs writes it as sfx.units[id].hits = { '<skillIndex>': url }, and public/js/audio.js plays it for
// an attack that was aimed while that skill ran (the attacker's state travels with its impact bookkeeping).
//
// Run: node --test test/feedback7-chen3-s3-sfx.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { pickModeAttacks, pickModeHits, skillModeLetter, SLOT_MODE_LETTER } from '../tools/assets/audio.mjs';
const P = (f) => `player/${f.startsWith('p_imp') ? 'p_imp' : 'p_atk'}/${f}.mp3`;
const CHEN3 = 'char_1050_chen3';
const manifest = JSON.parse(readFileSync(new URL('../data/assets.json', import.meta.url), 'utf8'));
/** 赤刃明霄陈's own banks, exactly as excel/audio_data.json lists them. */
const CHEN3_BANKS = new Map([
['ON_ABILITY_START.attack.0', [P('p_atk_hljdswd_n')]],
['ON_ABILITY_HIT.attack.0', [P('p_imp_hljdswd_n')]],
['ON_ABILITY_START.attack.1', [P('p_atk_hljdfswd_d')]],
['ON_ABILITY_HIT.attack.1.0', [P('p_imp_hljdrfswd_d')]],
['ON_ABILITY_HIT.attack.1.1', [P('p_imp_hljdylfswd_d')]],
['ON_ABILITY_START.attack.3', [P('p_atk_hljdswd_s')]],
['ON_ABILITY_HIT.attack.3', [P('p_imp_hljdswd_s')]],
]);
test('pickModeAttacks / pickModeHits: 赤刃明霄陈 S3 的挥砍与命中都和普通攻击不同(技能1 两个命中变体取第一个)', () => {
assert.deepEqual(pickModeAttacks(CHEN3_BANKS), { d: [P('p_atk_hljdfswd_d')], s: [P('p_atk_hljdswd_s')] });
assert.deepEqual(pickModeHits(CHEN3_BANKS), { d: [P('p_imp_hljdrfswd_d')], s: [P('p_imp_hljdswd_s')] });
// the normal pair (`attack.0`, files ending in `_n`) belongs to pickUnitSfx, never to a mode
assert.equal(skillModeLetter(P('p_atk_hljdswd_n')), null);
assert.equal(skillModeLetter(P('p_imp_hljdswd_n')), null);
});
test('pickModeHits: only ON_ABILITY_HIT banks, and a mode without one keeps the unit\'s normal impact', () => {
// a START bank alone is a swing, not an impact
assert.deepEqual(pickModeHits(new Map([['ON_ABILITY_START.attack.4', [P('p_atk_lzxqlkl_h2')]]])), {});
// 司霆惊蛰: 技能2 swings and lands `_h`, 技能3 has no HIT bank at all (its slash keeps the normal impact)
const leizi = new Map([
['ON_ABILITY_ON.attack.1.2.1', [P('p_atk_lzxqlkl_d1')]],
['ON_ABILITY_HIT.attack.4', [P('p_imp_lzxqlkl_h')]],
['ON_ABILITY_START.attack.7', [P('p_atk_lzxqlkl_s')]],
]);
assert.deepEqual(pickModeHits(leizi), { h: [P('p_imp_lzxqlkl_h')] });
assert.deepEqual(pickModeAttacks(leizi), { d: [P('p_atk_lzxqlkl_d1')], s: [P('p_atk_lzxqlkl_s')] });
assert.deepEqual(pickModeHits(new Map()), {});
assert.deepEqual(pickModeHits(undefined), {});
});
test('the committed manifest: her S3 swing and impact are the `_s` pair, her normal pair is untouched', () => {
const u = manifest.audio.sfx.units[CHEN3];
assert.equal(u.attack, '/assets/audio/sfx/player/p_atk/p_atk_hljdswd_n.mp3', '普通攻击的挥砍');
assert.equal(u.hit, '/assets/audio/sfx/player/p_imp/p_imp_hljdswd_n.mp3', '普通攻击的命中');
assert.equal(u.attacks?.['2'], '/assets/audio/sfx/player/p_atk/p_atk_hljdswd_s.mp3', 'S3 的挥砍');
assert.equal(u.hits?.['2'], '/assets/audio/sfx/player/p_imp/p_imp_hljdswd_s.mp3', 'S3 的斩击(命中)');
assert.equal(u.attacks?.['0'], '/assets/audio/sfx/player/p_atk/p_atk_hljdfswd_d.mp3', '技能1 的挥砍');
assert.equal(u.hits?.['0'], '/assets/audio/sfx/player/p_imp/p_imp_hljdrfswd_d.mp3', '技能1 的命中');
});
test('every manifest unit with a hits table agrees with the mode convention (d / h / s = 技能1 / 2 / 3)', () => {
const units = manifest.audio.sfx.units;
let n = 0;
for (const [id, u] of Object.entries(units)) {
for (const [i, url] of Object.entries(u?.hits || {})) {
n++;
const letter = skillModeLetter(url);
assert.ok(letter, `${id}: hits[${i}] is a skill-mode file (${url})`);
assert.equal(letter, SLOT_MODE_LETTER[i], `${id}: hits[${i}] is 技能${Number(i) + 1} (the _${letter} mode)`);
}
}
assert.ok(n >= 10, `some operators carry mode impacts (found ${n})`);
});
test('no operator takes a skill-mode file as its normal attack or impact (community report #54 stays fixed)', () => {
const units = manifest.audio.sfx.units;
let n = 0;
for (const [id, u] of Object.entries(units)) {
if (!id.startsWith('char_')) continue;
for (const kind of ['attack', 'hit']) {
const url = u?.[kind];
if (typeof url !== 'string') continue;
n++;
assert.equal(skillModeLetter(url), null, `${id}.${kind} = ${url} is a skill mode's file`);
}
}
assert.ok(n > 300, `${n} normal attack / impact entries checked`);
});
+36 -36
View File
@@ -5697,68 +5697,68 @@
"round": 13,
"seed": 326579774,
"waveId": "act1autochess_h06",
"time": 196.33,
"ticks": 5890,
"time": 197.77,
"ticks": 5933,
"reason": "cleared",
"killed": 123,
"total": 123,
"unspawned": 0,
"bossHpLeft": null,
"rngDraws": 23,
"rngDraws": 25,
"errors": {"count":0,"kinds":[]},
"players": {"p1":{"killed":123,"total":123,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":1103139,"bossDmg":0,"heal":29758,"deaths":1,"layerGains":{}}},
"players": {"p1":{"killed":123,"total":123,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":1103139,"bossDmg":0,"heal":43932,"deaths":0,"layerGains":{}}},
"units": [
["p1",1,"op","chess_char_5_diy1_b",0,"uniequip_002_necras",2070.2,1,3,0,0,0,0,0,0],
["p1",2,"op","chess_char_5_diy2_b",1,"uniequip_002_necras",2070.2,1,4,1,18891,0,0,2,1],
["p1",3,"op","chess_char_5_diy1_b",2,"uniequip_002_necras",2070.2,1,11,0,35621,0,0,33,5],
["p1",4,"op","chess_char_6_diy1_a",0,null,1103.11,1,13,2,20136,484,0,38,3],
["p1",3,"op","chess_char_5_diy1_b",2,"uniequip_002_necras",2070.2,1,11,2,28423,0,0,32,5],
["p1",4,"op","chess_char_6_diy1_a",0,null,1103.11,1,13,2,17971,484,0,35,3],
["p1",5,"op","chess_char_6_diy2_a",1,null,1587.3,1,5,1,5091,0,0,2,1],
["p1",6,"op","chess_char_6_diy1_a",2,null,1587.3,1,8,8,73989,0,0,46,7],
["p1",7,"op","chess_char_6_diy2_b",0,"none",1938.2,1,12,3,24655,0,0,37,3],
["p1",8,"op","chess_char_6_diy1_b",1,"none",1938.2,1,167,9,89706,1938,0,60,4],
["p1",9,"op","chess_char_6_diy2_b",2,"none",1938.2,1,9,20,183323,0,0,82,7],
["p1",6,"op","chess_char_6_diy1_a",2,null,1587.3,1,8,6,59953,0,0,37,6],
["p1",7,"op","chess_char_6_diy2_b",0,"none",1938.2,1,12,3,40643,0,0,34,6],
["p1",8,"op","chess_char_6_diy1_b",1,"none",133.43,1,14,17,152041,1805,0,86,7],
["p1",9,"op","chess_char_6_diy2_b",2,"none",1938.2,1,9,12,135375,0,0,57,6],
["p1",10,"op","chess_char_6_diy1_b",0,"uniequip_002_necras",2158.2,1,6,0,869,0,0,2,1],
["p1",11,"op","chess_char_6_diy2_b",1,"uniequip_002_necras",2158.2,1,7,4,26920,0,0,1,3],
["p1",12,"op","chess_char_6_diy1_b",2,"uniequip_002_necras",1932.6,1,10,9,35710,226,0,36,5],
["p1",11,"op","chess_char_6_diy2_b",1,"uniequip_002_necras",2158.2,1,7,3,26240,0,0,1,2],
["p1",12,"op","chess_char_6_diy1_b",2,"uniequip_002_necras",1932.6,1,10,6,30800,226,0,33,5],
["-","~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_10043_necras_skeltn",null,null,141.28,0,24,0,4555,5621,2399,9,null],
["p1","~1","token","token_10043_necras_skeltn",null,null,1814.64,0,24,0,2543,3948,2399,6,null],
["p1","~2","token","token_10043_necras_skeltn",null,null,3488,1,27,0,1341,2008,2008,2,null],
["p1","~3","token","token_10043_necras_skeltn",null,null,0,0,31,1,3094,3488,0,7,null],
["p1","~4","token","token_10043_necras_skeltn",null,null,0,0,32,1,6630,5232,0,4,1],
["p1","~5","token","token_10043_necras_skeltn",null,null,0,0,33,0,2913,6512,3024,8,null],
["p1","~6","token","token_10043_necras_skeltn",null,null,1374.21,1,35,38,346689,10935,426,48,8],
["p1","~6","token","token_10043_necras_skeltn",null,null,4026.44,1,35,43,391096,8283,426,48,8],
["p1","~7","token","token_10043_necras_skeltn",null,null,3488,1,40,0,0,0,0,0,null],
["p1","~8","token","token_10043_necras_skeltn",null,null,1744,0,41,0,0,0,0,0,null],
["p1","~9","token","token_10043_necras_skeltn",null,null,0,0,47,0,202,1535,0,1,0],
["p1","~10","token","token_10043_necras_skeltn",null,null,0,0,48,13,104269,9857,2930,12,2],
["p1","~11","token","token_10043_necras_skeltn",null,null,0,0,49,2,14104,7574,4334,30,null],
["p1","~12","token","token_10043_necras_skeltn",null,null,643.36,0,51,0,231,2845,0,1,0],
["p1","~13","token","token_10043_necras_skeltn",null,null,0,0,52,0,5636,3488,0,11,null],
["p1","~9","token","token_10043_necras_skeltn",null,null,3431.01,0,45,1,924,57,0,3,null],
["p1","~10","token","token_10043_necras_skeltn",null,null,0,0,48,0,202,1535,0,1,0],
["p1","~11","token","token_10043_necras_skeltn",null,null,0,0,49,13,104269,9857,2930,12,2],
["p1","~12","token","token_10043_necras_skeltn",null,null,0,0,50,2,14104,7574,4334,30,null],
["p1","~13","token","token_10043_necras_skeltn",null,null,643.36,0,52,0,231,2845,0,1,0],
["p1","~14","token","token_10043_necras_skeltn",null,null,1312.64,0,54,0,1462,1757,0,3,0],
["p1","~15","token","token_10043_necras_skeltn",null,null,1535,1,55,0,0,0,0,0,null],
["p1","~16","token","token_10043_necras_skeltn",null,null,3070,1,56,0,0,0,0,0,null],
["p1","~17","token","token_10043_necras_skeltn",null,null,3488,1,57,0,0,0,0,0,null],
["p1","~18","token","token_10043_necras_skeltn",null,null,1535,0,60,0,0,0,0,0,0],
["p1","~19","token","token_10043_necras_skeltn",null,null,3070,0,61,0,0,0,0,0,0],
["p1","~20","token","token_10043_necras_skeltn",null,null,3070,0,82,0,0,0,0,0,0],
["p1","~21","token","token_10043_necras_skeltn",null,null,3488,0,89,0,0,0,0,0,0],
["p1","~22","token","token_10043_necras_skeltn",null,null,0,0,96,0,2647,3488,0,6,null],
["p1","~23","token","token_10043_necras_skeltn",null,null,3280.31,0,102,0,7450,5000,5413,17,null],
["p1","~24","token","token_10043_necras_skeltn",null,null,13603.2,1,103,6,58696,20,26,7,3],
["p1","~25","token","token_10043_necras_skeltn",null,null,340.04,0,106,1,6379,3520,745,14,null],
["p1","~26","token","token_10043_necras_skeltn",null,null,264.69,1,124,1,3626,3223,0,8,null],
["p1","~27","token","token_10043_necras_skeltn",null,null,1672.16,0,130,1,5196,6256,4565,11,0],
["p1","~28","token","token_10043_necras_skeltn",null,null,643.36,0,146,0,231,2873,28,1,0],
["p1","~29","token","token_10043_necras_skeltn",null,null,1228.28,0,147,1,6396,5161,2902,12,0],
["p1","~30","token","token_10043_necras_skeltn",null,null,3337.3,1,157,1,5095,804,653,11,0],
["p1","~31","token","token_10043_necras_skeltn",null,null,3459.5,1,168,0,1387,210,305,4,null]
["p1","~20","token","token_10043_necras_skeltn",null,null,1072.87,0,77,1,4090,2415,0,9,null],
["p1","~21","token","token_10043_necras_skeltn",null,null,3070,0,83,0,0,0,0,0,0],
["p1","~22","token","token_10043_necras_skeltn",null,null,3488,0,90,0,0,0,0,0,0],
["p1","~23","token","token_10043_necras_skeltn",null,null,0,0,97,0,2647,3488,0,6,null],
["p1","~24","token","token_10043_necras_skeltn",null,null,2594.33,0,98,0,798,894,0,2,null],
["p1","~25","token","token_10043_necras_skeltn",null,null,3488,1,104,3,27488,17532,18152,57,null],
["p1","~26","token","token_10043_necras_skeltn",null,null,5232,1,105,0,0,0,0,0,0],
["p1","~27","token","token_10043_necras_skeltn",null,null,0,0,108,1,6842,3860,745,15,null],
["p1","~28","token","token_10043_necras_skeltn",null,null,1072.87,0,126,1,4090,2415,0,9,null],
["p1","~29","token","token_10043_necras_skeltn",null,null,0,0,130,1,3566,4074,586,8,null],
["p1","~30","token","token_10043_necras_skeltn",null,null,3488,1,145,2,8178,9328,9328,16,null],
["p1","~31","token","token_10043_necras_skeltn",null,null,2651.32,1,146,0,335,837,0,1,null]
],
"enemies": {"enemy_1019_jshoot_2":[57,57,0,9221,255302],"enemy_1299_ymkilr_2":[27,27,0,81708,492687],"enemy_1422_lrsldr_2":[39,39,0,3129,355830]},
"hooks": [1,169,5890,915,915,1387,1387,138,132,145,54,52,0,36568,14,133,123,0,1,132,0,0,0],
"events": {"atk":915,"deploy":44,"die":145,"dmg":1387,"engage":33,"fx":349,"heal":52,"skill":106,"spawn":168,"status":14},
"fx": {"aoe":166,"grow":138,"sleep":14,"summon":31},
"snaps": "90c70f41 c302dd2b 54cd7edf e58852e9 bff57786 f6e87429 f72cca13 5c878308 5dae900e 9943c059 9d89ea84 f721c49b ce40836f b3189627 f100922e 9c82ebda 12c800d9 bd8c14ef 4b6a90f7 fe7535da"
"enemies": {"enemy_1019_jshoot_2":[57,57,0,8725,255302],"enemy_1299_ymkilr_2":[27,27,0,87984,492687],"enemy_1422_lrsldr_2":[39,39,0,3746,355830]},
"hooks": [1,168,5933,918,918,1441,1441,159,132,144,54,53,0,36962,19,139,123,0,1,132,0,0,0],
"events": {"atk":918,"deploy":43,"die":144,"dmg":1441,"engage":32,"fx":355,"heal":74,"skill":107,"spawn":168,"status":19},
"fx": {"aoe":146,"grow":159,"sleep":19,"summon":31},
"snaps": "90c70f41 c302dd2b ce97444e f56da20e 7867fc43 c6389923 0e8c4561 4f741322 44a83b86 92652efc ba0316f9 978ef38d 12494606 71523afe 0ac4155c 4b9bbd7b 25d1835c 9345e5a3 94e6cb6a 4af2c8cc"
},
"diy-133": {
"about": "5_diy1_a=kalts2/S1 5_diy2_a=kalts2/S2 5_diy1_a=kalts2/S3 5_diy2_b=kalts2/S1 5_diy1_b=kalts2/S2 5_diy2_b=kalts2/S3 6_diy1_a=kalts2/S1 6_diy2_a=kalts2/S2 6_diy1_a=kalts2/S3 6_diy2_b=kalts2/S1 6_diy1_b=kalts2/S2 6_diy2_b=kalts2/S3",
+258 -256
View File
@@ -17,42 +17,42 @@
"round": 14,
"seed": 1379500996,
"waveId": "act1autochess_h07_01",
"time": 198.03,
"ticks": 5941,
"reason": "cleared",
"time": 200,
"ticks": 6000,
"reason": "forced",
"killed": 20,
"total": 16,
"unspawned": 0,
"bossHpLeft": 0,
"rngDraws": 624,
"bossHpLeft": 26388,
"rngDraws": 616,
"errors": {"count":0,"kinds":[]},
"players": {
"p1": {"killed":20,"total":16,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":2580943,"bossDmg":2141675,"heal":19370,"deaths":1,"layerGains":{}},
"p2": {"killed":0,"total":0,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":1723678,"bossDmg":1458325,"heal":61060,"deaths":0,"layerGains":{}}
"p1": {"killed":20,"total":16,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":2492630,"bossDmg":2081416,"heal":24007,"deaths":0,"layerGains":{}},
"p2": {"killed":0,"total":0,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":1785615,"bossDmg":1492196,"heal":63058,"deaths":0,"layerGains":{}}
},
"units": [
["p1",1,"op","chess_char_6_03_b",2,"uniequip_002_yu",2060.79,1,13,0,0,12547,10824,0,0],
["p1",2,"op","chess_char_6_06_b",2,"uniequip_002_pepe",1839.57,1,15,0,0,4688,524,0,0],
["p1",3,"op","chess_char_6_07_b",2,"uniequip_002_siege2",2662.34,1,17,0,0,5403,1853,0,0],
["p1",4,"op","chess_char_6_01_b",2,"uniequip_002_lemuen",709.43,1,7,8,1345935,1252,0,63,8],
["p1",5,"op","chess_char_6_02_b",2,"uniequip_002_sbell2",2126.3,1,9,0,0,1417,0,0,0],
["p1",6,"op","chess_char_6_04_b",2,"uniequip_002_skadi2",0,0,19,0,71666,12281,3597,0,4],
["p1",7,"op","chess_char_6_05_b",2,"uniequip_002_pasngr",1586.2,1,24,3,640231,1558,444,98,10],
["p1",8,"op","chess_char_6_08_b",2,"uniequip_002_reed2",1834.8,1,11,0,0,593,0,0,0],
["p1",9,"op","chess_char_6_09_b",0,"uniequip_002_cello",1454.2,1,26,3,308334,2483,51,51,51],
["p1",10,"op","chess_char_6_11_b",2,"uniequip_002_mlyss",1015.63,1,21,0,133231,1763,39,214,5],
["p1",1,"op","chess_char_6_03_b",2,"uniequip_002_yu",2665.18,1,13,0,0,12709,11440,0,0],
["p1",2,"op","chess_char_6_06_b",2,"uniequip_002_pepe",3227.66,1,15,0,0,3107,2118,0,0],
["p1",3,"op","chess_char_6_07_b",2,"uniequip_002_siege2",3015.02,1,17,0,0,6076,2611,0,0],
["p1",4,"op","chess_char_6_01_b",2,"uniequip_002_lemuen",709.43,1,7,10,1231170,1252,0,61,8],
["p1",5,"op","chess_char_6_02_b",2,"uniequip_002_sbell2",2126.3,1,9,0,0,2382,0,0,0],
["p1",6,"op","chess_char_6_04_b",2,"uniequip_002_skadi2",1341.02,1,19,0,77075,11593,3192,0,5],
["p1",7,"op","chess_char_6_05_b",2,"uniequip_002_pasngr",1586.2,1,24,5,645358,853,853,99,10],
["p1",8,"op","chess_char_6_08_b",2,"uniequip_002_reed2",1092.93,1,11,0,0,2188,0,0,0],
["p1",9,"op","chess_char_6_09_b",0,"uniequip_002_cello",1454.2,1,26,1,317350,3825,709,51,51],
["p1",10,"op","chess_char_6_11_b",2,"uniequip_002_mlyss",2082.3,1,21,0,136761,974,599,216,5],
["p1",11,"token","token_10017_skadi2_dedant",null,null,138,0,28,0,0,0,0,0,null],
["p1",12,"token","token_10030_mlyss_wtrman",null,null,0,0,22,0,22803,4807,1334,27,1],
["p2",13,"op","chess_char_6_15_b",2,"uniequip_002_qiubai",4435.2,1,33,10,787333,16947,0,190,3],
["p2",14,"op","chess_char_6_19_b",2,"uniequip_002_blkkgt",3043.7,1,16,1,75440,6403,0,0,1],
["p1",12,"token","token_10030_mlyss_wtrman",null,null,0,0,22,0,24621,3767,1334,28,1],
["p2",13,"op","chess_char_6_15_b",2,"uniequip_002_qiubai",4435.2,1,33,5,811980,16947,0,192,3],
["p2",14,"op","chess_char_6_19_b",2,"uniequip_002_blkkgt",3043.7,1,16,1,75440,2090,0,0,1],
["p2",15,"op","chess_char_5_01_b",1,"uniequip_002_excu2",2545.4,1,18,0,0,0,0,0,0],
["p2",16,"op","chess_char_6_17_b",2,"uniequip_002_nearl2",3952.3,1,23,1,218717,3293,0,133,4],
["p2",17,"op","chess_char_6_12_b",1,"uniequip_002_rosmon",2545.15,1,8,0,0,4493,0,0,0],
["p2",18,"op","chess_char_6_13_b",1,"uniequip_002_angel2",2119.76,1,10,0,48487,5750,2454,0,0],
["p2",19,"op","chess_char_6_14_b",2,"uniequip_002_lumen",3028.91,1,20,0,0,1127,12010,16,1],
["p2",20,"op","chess_char_6_16_b",2,"uniequip_002_halo2",1741.19,1,25,0,235654,1496,0,202,4],
["p2",21,"op","chess_char_6_18_b",2,"uniequip_002_whitw2",1695.1,1,12,1,343425,890,0,0,3],
["p2",22,"op","chess_char_6_20_b",2,"uniequip_002_agoat2",1828.31,1,27,0,0,0,46597,45,2],
["p2",16,"op","chess_char_6_17_b",2,"uniequip_002_nearl2",3952.3,1,23,0,229606,2042,0,134,4],
["p2",17,"op","chess_char_6_12_b",1,"uniequip_002_rosmon",3175.48,1,8,0,0,1853,0,0,0],
["p2",18,"op","chess_char_6_13_b",1,"uniequip_002_angel2",2412.14,1,10,0,64538,9447,3757,0,0],
["p2",19,"op","chess_char_6_14_b",2,"uniequip_002_lumen",3028.91,1,20,0,0,0,6776,9,1],
["p2",20,"op","chess_char_6_16_b",2,"uniequip_002_halo2",1741.19,1,25,0,242658,2644,0,204,4],
["p2",21,"op","chess_char_6_18_b",2,"uniequip_002_whitw2",804.61,1,12,1,346767,2486,0,0,3],
["p2",22,"op","chess_char_6_20_b",2,"uniequip_002_agoat2",1828.31,1,27,0,0,4344,52525,46,2],
["-","~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],
["-","~3","device","trap_1105_accrate",null,null,100,1,3,0,0,0,0,0,null],
@@ -60,30 +60,31 @@
["-","~5","device","trap_1105_accrate",null,null,100,1,5,0,0,0,0,0,null],
["-","~6","device","trap_1105_accrate",null,null,100,1,6,0,0,0,0,0,null],
["p2","~1","token","token_10019_nearl2_sword",null,null,8280,0,38,0,3661,0,0,25,null],
["p1","~1","token","token_10030_mlyss_wtrman",null,null,875.29,0,39,1,6710,1115,0,12,null],
["p1","~1","token","token_10030_mlyss_wtrman",null,null,1989.96,0,39,1,6710,0,0,12,null],
["p1","~2","token","token_10030_mlyss_wtrman",null,null,303.83,0,43,0,8824,1571,0,12,null],
["p1","~1","token","token_10017_skadi2_dedant",null,null,100,0,53,0,0,0,0,0,null],
["p2","~2","token","token_10019_nearl2_sword",null,null,6600,0,55,0,3661,0,0,25,null],
["p1","~3","token","token_10030_mlyss_wtrman",null,null,1442,1,58,0,28966,705,705,52,1],
["p1","~4","token","token_10030_mlyss_wtrman",null,null,1989.96,0,61,0,6040,0,0,12,null],
["p1","~3","token","token_10030_mlyss_wtrman",null,null,1442,1,58,2,31187,1151,1151,53,1],
["p1","~4","token","token_10030_mlyss_wtrman",null,null,1989.96,0,61,0,5369,0,0,12,null],
["p1","~2","token","token_10017_skadi2_dedant",null,null,138,0,64,0,0,0,0,0,null],
["p2","~3","token","token_10019_nearl2_sword",null,null,6600,0,65,0,3654,0,0,25,null],
["p1","~5","token","token_10030_mlyss_wtrman",null,null,1442,0,67,0,2686,0,0,12,null],
["p2","~3","token","token_10019_nearl2_sword",null,null,6600,0,65,0,3657,0,0,25,null],
["p1","~5","token","token_10030_mlyss_wtrman",null,null,1442,0,67,1,3021,0,0,12,null],
["p1","~6","token","token_10030_mlyss_wtrman",null,null,1442,0,70,0,4846,0,0,12,null],
["p1","~7","token","token_10030_mlyss_wtrman",null,null,1442,1,71,0,672,0,0,3,null],
["p2","~4","token","token_10019_nearl2_sword",null,null,6600,1,72,0,3647,0,0,4,null]
["p1","~7","token","token_10030_mlyss_wtrman",null,null,1442,1,71,0,337,0,0,4,null],
["p2","~4","token","token_10019_nearl2_sword",null,null,6600,1,72,0,3648,0,0,6,null],
["p1","~3","token","token_10017_skadi2_dedant",null,null,100,1,74,0,0,0,0,0,null]
],
"enemies": {
"enemy_1005_yokai": [4,4,0,0,13759],
"enemy_1006_shield_2": [14,14,0,19099,601974],
"enemy_1061_zomshd": [2,2,0,1052,88636],
"enemy_9013_acstmk": [1,1,0,46552,3600000],
"enemy_9016_acstmr": [8,7,0,0,251]
"enemy_9013_acstmk": [1,0,0,46337,3573612],
"enemy_9016_acstmr": [8,7,0,0,263]
},
"hooks": [1,71,5941,1319,1319,3122,3493,3694,30,42,98,94,69,41511,2716,10,29,0,1,29,0,21,0],
"events": {"atk":1625,"deploy":36,"die":42,"dmg":3192,"engage":19,"fx":854,"heal":336,"skill":192,"spawn":70,"status":607},
"fx": {"airstrike":26,"beam":54,"blink":1,"bombard":69,"bombardShell":69,"burst":21,"copy":2,"dp":5,"drone":328,"droneLock":18,"drones":3,"finale":1,"garrisonGrant":1,"lightning":129,"link":2,"lock":69,"phase":1,"radiantSword":4,"shell":8,"slash":1,"snow":5,"split":6,"storm":10,"summon":8,"talent":4,"tide":4,"wanted":5},
"snaps": "d0c7ebf7 c2a47f5c 6df72cfc f267f95e d216374a ed68fbff db7fd85b 64c3b233 8d07119c 37a755de af8e384d 37e6901f f432740d 5617ea7a bd1267d1 796410c7 47858302 d2d260f8 5c90313f 65608f5f"
"hooks": [1,72,6000,1324,1324,3177,3555,4943,28,40,99,94,69,42113,2727,11,29,0,1,28,0,21,0],
"events": {"atk":1635,"deploy":37,"die":40,"dmg":3251,"engage":19,"fx":848,"heal":347,"skill":193,"spawn":71,"status":629},
"fx": {"airstrike":27,"beam":54,"blink":1,"bombard":64,"bombardShell":65,"burst":21,"copy":2,"dp":5,"drone":334,"droneLock":17,"drones":3,"finale":1,"garrisonGrant":1,"lightning":125,"link":2,"lock":69,"phase":1,"radiantSword":4,"shell":8,"slash":1,"snow":5,"split":6,"storm":10,"summon":8,"talent":4,"tide":5,"wanted":5},
"snaps": "d0c7ebf7 c2a47f5c d51bb8c3 2d417d2a e0e24d2b 03e06f98 c1c51721 923667aa 93a574cd 45824693 fddb0f84 fda2294a 9028c7a3 85522803 4853f594 0a4870e7 bbefbbd9 c61aa475 3ca5a376 929494fd"
},
"boss-boss_1-solo": {
"about": "假想敌:胄 solo pool 1800000: chess_char_5_08_b chess_char_5_11_b chess_char_5_05_b chess_char_5_06_b chess_char_5_13_b chess_char_5_07_b chess_char_5_02_b chess_char_5_03_b chess_char_5_10_b chess_char_5_12_b",
@@ -226,110 +227,112 @@
"round": 14,
"seed": 1428555614,
"waveId": "act1autochess_h07_03",
"time": 165.03,
"ticks": 4951,
"time": 193.03,
"ticks": 5791,
"reason": "cleared",
"killed": 19,
"total": 6,
"unspawned": 0,
"bossHpLeft": 0,
"rngDraws": 1278,
"rngDraws": 1399,
"errors": {"count":0,"kinds":[]},
"players": {
"p1": {"killed":19,"total":6,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":1570654,"bossDmg":1472280,"heal":5235,"deaths":2,"layerGains":{}},
"p2": {"killed":0,"total":0,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":2613753,"bossDmg":2527720,"heal":37017,"deaths":3,"layerGains":{}}
"p1": {"killed":19,"total":6,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":1311182,"bossDmg":1211754,"heal":15185,"deaths":2,"layerGains":{}},
"p2": {"killed":0,"total":0,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":2873270,"bossDmg":2788246,"heal":46782,"deaths":3,"layerGains":{}}
},
"units": [
["p1",1,"op","chess_char_6_03_b",2,"uniequip_002_yu",4070,1,7,0,0,0,3196,0,1],
["p1",2,"op","chess_char_6_06_b",2,"uniequip_002_pepe",3232.9,1,11,0,0,0,0,0,0],
["p1",3,"op","chess_char_6_07_b",2,"uniequip_002_siege2",3411.1,1,9,1,44,1804,324,32,1],
["p1",4,"op","chess_char_6_01_b",2,"uniequip_002_lemuen",2344.65,1,13,4,902572,2208,317,53,7],
["p1",5,"op","chess_char_6_02_b",2,"uniequip_002_sbell2",2126.3,1,17,3,323222,5411,272,52,3],
["p1",6,"op","chess_char_6_04_b",2,"uniequip_002_skadi2",532.64,1,63,1,27583,12835,687,0,3],
["p1",7,"op","chess_char_6_05_b",2,"uniequip_002_pasngr",823.13,1,67,1,178714,7514,404,45,3],
["p1",8,"op","chess_char_6_08_b",2,"uniequip_002_reed2",1834.8,1,5,0,59,0,36,25,2],
["p1",9,"op","chess_char_6_09_b",0,"uniequip_002_cello",1454.2,1,15,1,59,0,0,15,15],
["p1",10,"op","chess_char_6_11_b",2,"uniequip_002_mlyss",2082.3,1,23,0,101077,0,0,170,4],
["p1",1,"op","chess_char_6_03_b",2,"uniequip_002_yu",4070,1,7,0,0,963,7372,0,1],
["p1",2,"op","chess_char_6_06_b",2,"uniequip_002_pepe",3232.9,1,11,0,0,22,0,0,0],
["p1",3,"op","chess_char_6_07_b",2,"uniequip_002_siege2",3296.85,1,9,1,54,7553,554,47,2],
["p1",4,"op","chess_char_6_01_b",2,"uniequip_002_lemuen",1668.07,1,66,3,560206,5126,838,41,5],
["p1",5,"op","chess_char_6_02_b",2,"uniequip_002_sbell2",2084.44,1,17,1,258588,5844,732,41,2],
["p1",6,"op","chess_char_6_04_b",2,"uniequip_002_skadi2",757.15,1,59,1,26013,13391,2675,0,3],
["p1",7,"op","chess_char_6_05_b",2,"uniequip_002_pasngr",2068.72,1,21,1,300324,8951,2948,93,7],
["p1",8,"op","chess_char_6_08_b",2,"uniequip_002_reed2",1834.8,1,5,1,87,2172,67,30,2],
["p1",9,"op","chess_char_6_09_b",0,"uniequip_002_cello",1454.2,1,15,2,78,2307,0,17,17],
["p1",10,"op","chess_char_6_11_b",2,"uniequip_002_mlyss",2082.3,1,23,0,121760,0,0,203,5],
["p1",11,"token","token_10017_skadi2_dedant",null,null,138,0,26,0,0,0,0,0,null],
["p2",12,"op","chess_char_6_15_b",2,"uniequip_002_qiubai",4181.76,1,31,0,465270,0,0,170,3],
["p2",13,"op","chess_char_6_19_b",2,"uniequip_002_blkkgt",3043.7,1,60,1,29,3044,0,9,1],
["p2",14,"op","chess_char_5_01_b",1,"uniequip_002_excu2",2545.4,1,58,0,16,3865,960,16,1],
["p2",15,"op","chess_char_6_17_b",2,"uniequip_002_nearl2",6916.53,1,32,0,293853,2679,0,72,3],
["p2",16,"op","chess_char_6_12_b",1,"uniequip_002_rosmon",3493.03,1,16,6,216145,3498,625,60,3],
["p2",17,"op","chess_char_6_13_b",1,"uniequip_002_angel2",2680.15,1,20,2,1433169,5683,1699,302,5],
["p2",18,"op","chess_char_6_14_b",2,"uniequip_002_lumen",2297.79,1,22,0,0,3550,4812,50,1],
["p2",19,"op","chess_char_6_16_b",2,"uniequip_002_halo2",1741.19,1,25,0,67561,1414,0,113,3],
["p2",20,"op","chess_char_6_18_b",2,"uniequip_002_whitw2",1695.1,1,59,0,105769,1695,0,21,2],
["p2",21,"op","chess_char_6_20_b",2,"uniequip_002_agoat2",1828.31,1,18,0,0,1833,28921,49,2],
["p2",12,"op","chess_char_6_15_b",2,"uniequip_002_qiubai",4181.76,1,31,1,525926,1350,73,197,3],
["p2",13,"op","chess_char_6_19_b",2,"uniequip_002_blkkgt",3043.7,1,61,0,27,3044,0,8,1],
["p2",14,"op","chess_char_5_01_b",1,"uniequip_002_excu2",2545.4,1,57,0,15,3807,900,15,1],
["p2",15,"op","chess_char_6_17_b",2,"uniequip_002_nearl2",6916.53,1,32,0,361017,2679,0,91,3],
["p2",16,"op","chess_char_6_12_b",1,"uniequip_002_rosmon",3493.03,1,16,6,251273,3571,545,71,4],
["p2",17,"op","chess_char_6_13_b",1,"uniequip_002_angel2",2680.15,1,20,2,1500382,6607,1632,344,6],
["p2",18,"op","chess_char_6_14_b",2,"uniequip_002_lumen",2297.79,1,22,0,0,3550,6766,52,1],
["p2",19,"op","chess_char_6_16_b",2,"uniequip_002_halo2",1741.19,1,25,0,85000,1414,0,136,4],
["p2",20,"op","chess_char_6_18_b",2,"uniequip_002_whitw2",1695.1,1,58,1,117690,1695,0,22,2],
["p2",21,"op","chess_char_6_20_b",2,"uniequip_002_agoat2",1828.31,1,18,0,0,1717,36867,52,2],
["-","~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],
["-","~3","device","trap_1105_accrate",null,null,100,1,3,0,0,0,0,0,null],
["-","~4","device","trap_1105_accrate",null,null,100,1,4,0,0,0,0,0,null],
["p1","~1","token","token_10030_mlyss_wtrman",null,null,1586.2,1,24,0,37309,0,0,74,1],
["p1","~1","token","token_10030_mlyss_wtrman",null,null,1586.2,1,24,0,44067,0,0,87,1],
["p2","~1","token","token_10019_nearl2_sword",null,null,8280,0,41,0,10647,0,0,0,null],
["p1","~2","token","token_10030_mlyss_wtrman",null,null,1989.96,0,42,0,0,0,0,0,null],
["p1","~2","token","token_10030_mlyss_wtrman",null,null,1989.96,0,42,0,1,0,0,1,null],
["p1","~1","token","token_10017_skadi2_dedant",null,null,100,0,49,0,0,0,0,0,null],
["p1","~3","token","token_10030_mlyss_wtrman",null,null,1586.2,0,50,0,3,0,0,3,null],
["p2","~2","token","token_10019_nearl2_sword",null,null,6600,0,52,0,10647,0,0,0,null],
["p1","~4","token","token_10030_mlyss_wtrman",null,null,1989.96,0,54,0,0,0,0,0,null],
["p1","~1","token","token_10040_siege2_vlion",null,null,2804.82,0,55,0,10,2715,0,10,null],
["p1","~2","token","token_10040_siege2_vlion",null,null,5520,0,56,0,2,0,0,2,null],
["p1","~1","token","token_10040_siege2_vlion",null,null,4000,0,53,0,0,0,0,0,null],
["p1","~4","token","token_10030_mlyss_wtrman",null,null,1989.96,0,55,0,0,0,0,0,null],
["p1","~2","token","token_10017_skadi2_dedant",null,null,100,0,60,0,0,0,0,0,null],
["p2","~3","token","token_10019_nearl2_sword",null,null,6600,0,62,0,10647,0,0,0,null],
["p1","~2","token","token_10017_skadi2_dedant",null,null,100,1,64,0,0,0,0,0,null],
["p1","~5","token","token_10030_mlyss_wtrman",null,null,1586.2,1,65,0,0,0,0,0,null]
["p1","~5","token","token_10030_mlyss_wtrman",null,null,1586.2,0,64,0,0,0,0,0,null],
["p1","~3","token","token_10017_skadi2_dedant",null,null,100,1,68,0,0,0,0,0,null],
["p1","~2","token","token_10040_siege2_vlion",null,null,4000,1,69,0,0,0,0,0,null]
],
"enemies": {
"enemy_1197_sfshu_2": [2,2,0,0,28809],
"enemy_1198_msfshu_2": [6,6,0,0,102],
"enemy_1209_sfden_2": [2,2,0,0,154793],
"enemy_1209_sfden_2": [2,2,0,1350,154793],
"enemy_1210_msfden_2": [2,2,0,0,386],
"enemy_9021_acduml": [1,1,0,0,4000000],
"enemy_9023_acdums": [8,7,0,49006,317]
"enemy_9023_acdums": [9,7,0,62774,362]
},
"hooks": [1,65,4951,1349,1349,3657,4441,2191,25,37,64,57,319,35751,1888,4,21,0,1,24,0,12,1],
"events": {"atk":1613,"deploy":40,"die":37,"dmg":4177,"engage":19,"fx":1131,"heal":431,"skill":121,"spawn":58,"status":789},
"hooks": [1,67,5791,1558,1558,4205,5122,4446,25,38,72,66,329,44235,2200,3,22,0,1,24,0,17,1],
"events": {"atk":1800,"deploy":41,"die":38,"dmg":4833,"engage":18,"fx":1197,"heal":476,"skill":138,"spawn":60,"status":867},
"fx": {
"airstrike": 115,
"airstrike": 116,
"appear": 1,
"beam": 10,
"beam": 11,
"blink": 2,
"bombard": 60,
"bombardShell": 60,
"bombard": 39,
"bombardShell": 39,
"buff": 3,
"burst": 12,
"burst": 17,
"camouflage": 1,
"copy": 1,
"disappear": 1,
"displace": 1,
"dodge": 1,
"dp": 4,
"drone": 199,
"droneLock": 21,
"dp": 5,
"drone": 208,
"droneLock": 18,
"drones": 2,
"explode": 392,
"extraAttack": 5,
"explode": 449,
"extraAttack": 4,
"finale": 1,
"firewall": 1,
"garrisonGrant": 1,
"lightning": 37,
"link": 7,
"lock": 60,
"lure": 3,
"phase": 38,
"lightning": 79,
"link": 8,
"lock": 39,
"lure": 2,
"phase": 44,
"radiantSword": 3,
"scorchBurst": 2,
"scorchBurst": 4,
"slash": 11,
"snow": 37,
"snow": 36,
"split": 4,
"steal": 5,
"storm": 3,
"summon": 15,
"steal": 6,
"storm": 7,
"summon": 16,
"sunBurst": 1,
"talent": 6,
"talent": 8,
"telegraph": 2,
"tide": 3,
"wanted": 1
"wanted": 2
},
"snaps": "68fffef1 494368df d4448d17 4e91833a 23e11a5a 8752e6c6 6f9be873 fa966775 dfb5037e cefeae7b 8ab8ba7b e65d3634 fa7102a8 076e446e 4f16e0ab 97fb5ca2 77c75b43"
"snaps": "68fffef1 494368df 4e38f532 f87d9068 690476ee 03b01e26 0bed04bd 2fd59226 69665385 05fe5c2d 998b698f 48323861 b99302e0 9efaad8f a695d151 50c5bc45 dd3e6215 601aae77 2a462704 b1188c19"
},
"boss-boss_3-solo": {
"about": "假想敌:管 solo pool 2000000: chess_char_5_08_b chess_char_5_11_b chess_char_5_05_b chess_char_5_06_b chess_char_5_13_b chess_char_5_07_b chess_char_5_02_b chess_char_5_03_b chess_char_5_10_b chess_char_5_12_b",
@@ -518,99 +521,98 @@
"round": 14,
"seed": 2302764647,
"waveId": "act2autochess_h07_05",
"time": 103.97,
"ticks": 3119,
"time": 102.27,
"ticks": 3068,
"reason": "cleared",
"killed": 18,
"killed": 16,
"total": 8,
"unspawned": 0,
"bossHpLeft": 0,
"rngDraws": 867,
"rngDraws": 874,
"errors": {"count":0,"kinds":[]},
"players": {
"p1": {"killed":18,"total":8,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":1249795,"bossDmg":929511,"heal":26462,"deaths":1,"layerGains":{}},
"p2": {"killed":0,"total":0,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":1109971,"bossDmg":630489,"heal":42330,"deaths":1,"layerGains":{}}
"p1": {"killed":16,"total":8,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":1231000,"bossDmg":925772,"heal":23117,"deaths":2,"layerGains":{}},
"p2": {"killed":0,"total":0,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":1042770,"bossDmg":634228,"heal":41304,"deaths":1,"layerGains":{}}
},
"units": [
["p1",1,"op","chess_char_6_03_b",2,"uniequip_002_yu",4070,1,7,0,42174,5551,11075,18,1],
["p1",2,"op","chess_char_6_06_b",2,"uniequip_002_pepe",3232.9,1,12,1,64021,2256,958,12,1],
["p1",3,"op","chess_char_6_07_b",2,"uniequip_002_siege2",3411.1,1,14,0,76000,4697,2195,34,1],
["p1",4,"op","chess_char_6_01_b",2,"uniequip_002_lemuen",1617,1,1,2,587890,2311,1987,32,4],
["p1",5,"op","chess_char_6_02_b",2,"uniequip_002_sbell2",2126.3,1,16,0,54457,230,21,11,2],
["p1",6,"op","chess_char_6_04_b",2,"uniequip_002_skadi2",1694.16,1,3,0,6859,6498,1793,0,3],
["p1",7,"op","chess_char_6_05_b",2,"uniequip_002_pasngr",1586.2,1,18,0,143648,2793,596,18,4],
["p1",8,"op","chess_char_6_08_b",2,"uniequip_002_reed2",1834.8,1,5,1,49062,868,4571,26,2],
["p1",9,"op","chess_char_6_09_b",0,"uniequip_002_cello",0,0,20,2,143676,6495,603,20,20],
["p1",10,"op","chess_char_6_11_b",2,"uniequip_002_mlyss",2082.3,1,9,1,53802,2964,1391,64,3],
["p1",1,"op","chess_char_6_03_b",2,"uniequip_002_yu",4070,1,7,1,8305,1825,6494,6,1],
["p1",2,"op","chess_char_6_06_b",2,"uniequip_002_pepe",0,0,12,0,25770,5629,769,5,1],
["p1",3,"op","chess_char_6_07_b",2,"uniequip_002_siege2",3411.1,1,14,0,76065,2161,834,30,1],
["p1",4,"op","chess_char_6_01_b",2,"uniequip_002_lemuen",1381.55,1,1,4,511267,991,755,31,4],
["p1",5,"op","chess_char_6_02_b",2,"uniequip_002_sbell2",2126.3,1,16,1,128892,1418,956,23,2],
["p1",6,"op","chess_char_6_04_b",2,"uniequip_002_skadi2",1757.8,1,3,0,4001,5151,305,0,2],
["p1",7,"op","chess_char_6_05_b",2,"uniequip_002_pasngr",1586.2,1,18,0,143584,3329,1053,18,5],
["p1",8,"op","chess_char_6_08_b",2,"uniequip_002_reed2",1834.8,1,5,0,74636,868,8481,45,2],
["p1",9,"op","chess_char_6_09_b",0,"uniequip_002_cello",1454.2,1,20,4,160370,6470,1752,23,23],
["p1",10,"op","chess_char_6_11_b",2,"uniequip_002_mlyss",0,0,9,0,50925,6392,964,63,2],
["p1",11,"token","token_10017_skadi2_dedant",null,null,138,0,22,0,0,0,0,0,null],
["p1",12,"token","token_10030_mlyss_wtrman",null,null,0,0,10,0,18615,2939,1271,35,1],
["p2",13,"op","chess_char_6_15_b",2,"uniequip_002_qiubai",3508.08,1,56,5,369668,10048,0,93,4],
["p2",14,"op","chess_char_6_19_b",2,"uniequip_002_blkkgt",2043.7,1,13,1,152329,5717,0,19,3],
["p2",15,"op","chess_char_5_01_b",1,"uniequip_002_excu2",2081.5,1,15,0,12767,1079,540,9,1],
["p2",16,"op","chess_char_6_17_b",2,"uniequip_002_nearl2",6504.15,1,58,2,186781,6913,0,46,2],
["p2",17,"op","chess_char_6_12_b",1,"uniequip_002_rosmon",5344.3,1,2,2,194696,4516,2646,20,2],
["p2",18,"op","chess_char_6_13_b",1,"uniequip_002_angel2",3562.16,1,17,2,73558,4871,2224,8,1],
["p2",19,"op","chess_char_6_14_b",2,"uniequip_002_lumen",1336.23,1,4,0,0,2258,9039,16,1],
["p2",20,"op","chess_char_6_16_b",2,"uniequip_002_halo2",1741.19,1,19,0,22189,174,0,27,2],
["p2",21,"op","chess_char_6_18_b",2,"uniequip_002_whitw2",0,0,6,0,88775,1695,0,12,1],
["p2",22,"op","chess_char_6_20_b",2,"uniequip_002_agoat2",1828.31,1,21,0,0,2787,27882,28,1],
["p1",12,"token","token_10030_mlyss_wtrman",null,null,607.48,1,10,0,35076,1816,755,52,1],
["p2",13,"op","chess_char_6_15_b",2,"uniequip_002_qiubai",4181.76,1,55,4,380444,9705,860,99,3],
["p2",14,"op","chess_char_6_19_b",2,"uniequip_002_blkkgt",3043.7,1,13,1,126431,2339,0,21,2],
["p2",15,"op","chess_char_5_01_b",1,"uniequip_002_excu2",1908.49,1,15,1,32435,2117,1225,22,1],
["p2",16,"op","chess_char_6_17_b",2,"uniequip_002_nearl2",6521.3,1,53,1,166515,10334,0,40,2],
["p2",17,"op","chess_char_6_12_b",1,"uniequip_002_rosmon",5557.09,1,2,0,150406,888,888,17,1],
["p2",18,"op","chess_char_6_13_b",1,"uniequip_002_angel2",3437.9,1,17,0,76446,5687,2518,8,1],
["p2",19,"op","chess_char_6_14_b",2,"uniequip_002_lumen",2088.9,1,4,0,0,2258,6052,8,1],
["p2",20,"op","chess_char_6_16_b",2,"uniequip_002_halo2",1741.19,1,19,0,17095,1031,0,18,2],
["p2",21,"op","chess_char_6_18_b",2,"uniequip_002_whitw2",0,0,6,0,83803,1695,0,12,1],
["p2",22,"op","chess_char_6_20_b",2,"uniequip_002_agoat2",1828.31,1,21,0,0,2305,29761,25,1],
["p2","~1","token","token_10019_nearl2_sword",null,null,1907.57,0,32,0,9195,6372,0,12,null],
["p1","~1","token","token_10030_mlyss_wtrman",null,null,2301.84,0,36,0,4738,0,0,12,null],
["p1","~1","token","token_10040_siege2_vlion",null,null,1631.32,0,39,0,2850,3136,0,7,null],
["p2","~2","token","token_10019_nearl2_sword",null,null,2413.5,0,45,0,13,3587,0,23,null],
["p1","~1","token","token_10030_mlyss_wtrman",null,null,2301.84,0,36,0,5259,0,0,12,null],
["p1","~1","token","token_10040_siege2_vlion",null,null,136.97,0,39,0,4275,4590,0,10,null],
["p1","~2","token","token_10030_mlyss_wtrman",null,null,0,0,45,0,2575,1668,0,7,null],
["p2","~2","token","token_10019_nearl2_sword",null,null,5742.56,0,46,0,0,857,0,0,null],
["p1","~1","token","token_10017_skadi2_dedant",null,null,100,0,48,0,0,0,0,0,null],
["p1","~2","token","token_10030_mlyss_wtrman",null,null,0,0,52,0,2003,1668,0,8,null]
["p1","~3","token","token_10030_mlyss_wtrman",null,null,1668,1,56,0,0,0,0,0,null]
],
"enemies": {
"enemy_1010_demon_2": [2,2,0,1457,128995],
"enemy_2009_csaudc": [4,4,0,1050,137594],
"enemy_2010_csdcr": [2,2,0,6581,103196],
"enemy_2016_csphtm": [2,2,0,70829,1560000],
"enemy_2017_csphts": [10,10,0,3407,429982]
"enemy_2009_csaudc": [4,4,0,2694,137594],
"enemy_2010_csdcr": [2,2,0,5885,103196],
"enemy_2016_csphtm": [2,2,0,67665,1560000],
"enemy_2017_csphts": [8,8,0,1223,343985]
},
"hooks": [1,56,3119,685,685,1889,2222,4724,24,38,60,51,43,26422,1127,30,20,0,1,23,0,17,64],
"events": {"atk":852,"deploy":36,"die":38,"dmg":2064,"engage":23,"fx":534,"heal":240,"skill":111,"spawn":48,"status":580},
"hooks": [1,54,3068,668,668,1754,2039,3872,22,35,59,52,50,26377,1286,31,18,0,1,20,0,17,81],
"events": {"atk":821,"deploy":36,"die":35,"dmg":1888,"engage":22,"fx":526,"heal":252,"skill":111,"spawn":47,"status":628},
"fx": {
"airstrike": 14,
"blink": 18,
"airstrike": 15,
"blink": 15,
"bombard": 33,
"bombardShell": 33,
"burst": 17,
"cleanse": 2,
"copy": 1,
"dodge": 64,
"dp": 3,
"displace": 4,
"dodge": 81,
"dp": 2,
"drone": 102,
"droneLock": 3,
"drones": 1,
"emergency": 2,
"explode": 29,
"extraAttack": 3,
"finale": 3,
"explode": 24,
"extraAttack": 7,
"finale": 2,
"firewall": 1,
"garrisonGrant": 1,
"lightning": 47,
"link": 4,
"lightning": 43,
"link": 3,
"lock": 33,
"lure": 2,
"palsy": 1,
"radiantSword": 2,
"scorchBurst": 1,
"shield": 13,
"slash": 33,
"snow": 21,
"split": 2,
"shield": 8,
"slash": 22,
"snow": 24,
"split": 3,
"steal": 1,
"storm": 4,
"storm": 5,
"strike": 3,
"summon": 13,
"summon": 11,
"sunBurst": 4,
"talent": 4,
"telegraph": 6,
"tide": 3,
"wanted": 7
"telegraph": 2,
"tide": 2,
"wanted": 11
},
"snaps": "d3cd88f1 0b40f2d2 1bc3ac15 f797dbfa 67a1d811 c33ee6e2 949c4a4b a8645ba6 e4cccf0c f1968adc 303367ba"
"snaps": "d3cd88f1 0b40f2d2 3fada850 9d166f76 eb1c5713 8daf7ec1 58aeec40 fd26682e 39226066 3ce9d7e2 8cf2e7c7"
},
"boss-boss_5-solo": {
"about": "卢西恩,“猩红血钻” solo pool 780000: chess_char_5_08_b chess_char_5_11_b chess_char_5_05_b chess_char_5_06_b chess_char_5_13_b chess_char_5_07_b chess_char_5_02_b chess_char_5_03_b chess_char_5_10_b chess_char_5_12_b",
@@ -811,56 +813,56 @@
"round": 14,
"seed": 120974514,
"waveId": "act1autochess_h07_07",
"time": 171.13,
"ticks": 5134,
"time": 173.63,
"ticks": 5209,
"reason": "cleared",
"killed": 16,
"total": 16,
"unspawned": 0,
"bossHpLeft": 0,
"rngDraws": 910,
"rngDraws": 918,
"errors": {"count":0,"kinds":[]},
"players": {
"p1": {"killed":16,"total":16,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":1769900,"bossDmg":1675565,"heal":25043,"deaths":0,"layerGains":{}},
"p2": {"killed":0,"total":0,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":2703080,"bossDmg":2324435,"heal":40114,"deaths":0,"layerGains":{}}
"p1": {"killed":16,"total":16,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":1730751,"bossDmg":1610239,"heal":24718,"deaths":0,"layerGains":{}},
"p2": {"killed":0,"total":0,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":2742228,"bossDmg":2389761,"heal":40810,"deaths":0,"layerGains":{}}
},
"units": [
["p1",1,"op","chess_char_6_03_b",2,"uniequip_002_yu",4070,1,5,0,0,0,0,0,0],
["p1",2,"op","chess_char_6_06_b",2,"uniequip_002_pepe",3232.9,1,7,0,0,0,0,0,0],
["p1",3,"op","chess_char_6_07_b",2,"uniequip_002_siege2",3411.1,1,11,0,0,0,0,0,0],
["p1",4,"op","chess_char_6_01_b",2,"uniequip_002_lemuen",1617,1,1,2,1131550,0,0,82,8],
["p1",4,"op","chess_char_6_01_b",2,"uniequip_002_lemuen",1617,1,1,2,1075028,0,0,78,8],
["p1",5,"op","chess_char_6_02_b",2,"uniequip_002_sbell2",2126.3,1,9,0,0,0,0,0,0],
["p1",6,"op","chess_char_6_04_b",2,"uniequip_002_skadi2",1669.91,1,17,0,41867,19445,1580,0,5],
["p1",6,"op","chess_char_6_04_b",2,"uniequip_002_skadi2",1669.91,1,17,0,43766,18917,1278,0,5],
["p1",7,"op","chess_char_6_05_b",2,"uniequip_002_pasngr",1586.2,1,3,0,0,0,0,0,0],
["p1",8,"op","chess_char_6_08_b",2,"uniequip_002_reed2",1834.8,1,13,0,161084,0,19850,120,3],
["p1",9,"op","chess_char_6_09_b",0,"uniequip_002_cello",1454.2,1,15,1,254912,0,0,44,44],
["p1",10,"op","chess_char_6_11_b",2,"uniequip_002_mlyss",2082.3,1,19,0,140358,12849,1604,170,4],
["p1",8,"op","chess_char_6_08_b",2,"uniequip_002_reed2",1834.8,1,13,0,165894,0,20171,122,3],
["p1",9,"op","chess_char_6_09_b",0,"uniequip_002_cello",1454.2,1,15,1,261942,1172,0,45,45],
["p1",10,"op","chess_char_6_11_b",2,"uniequip_002_mlyss",2082.3,1,19,1,142272,12632,1260,172,4],
["p1",11,"token","token_10017_skadi2_dedant",null,null,138,0,22,0,0,0,0,0,null],
["p1",12,"token","token_10030_mlyss_wtrman",null,null,0,0,20,0,7684,2977,0,17,1],
["p2",13,"op","chess_char_6_15_b",2,"uniequip_002_qiubai",4435.2,1,26,2,403485,0,0,134,3],
["p1",12,"token","token_10030_mlyss_wtrman",null,null,0,0,20,0,8204,2977,0,18,1],
["p2",13,"op","chess_char_6_15_b",2,"uniequip_002_qiubai",4435.2,1,26,3,408287,0,0,133,3],
["p2",14,"op","chess_char_6_19_b",2,"uniequip_002_blkkgt",3043.7,1,2,0,0,0,0,0,0],
["p2",15,"op","chess_char_5_01_b",1,"uniequip_002_excu2",2545.4,1,6,0,0,0,0,0,0],
["p2",16,"op","chess_char_6_17_b",2,"uniequip_002_nearl2",6844.55,1,31,2,137844,15939,0,41,3],
["p2",17,"op","chess_char_6_12_b",1,"uniequip_002_rosmon",2480.38,1,16,2,379931,10260,0,64,3],
["p2",18,"op","chess_char_6_13_b",1,"uniequip_002_angel2",2653.35,1,21,6,1358147,5265,2873,294,6],
["p2",16,"op","chess_char_6_17_b",2,"uniequip_002_nearl2",6916.53,1,31,1,136739,14292,0,40,3],
["p2",17,"op","chess_char_6_12_b",1,"uniequip_002_rosmon",2540.38,1,16,4,396919,10380,0,65,3],
["p2",18,"op","chess_char_6_13_b",1,"uniequip_002_angel2",2680.15,1,21,4,1362550,5319,2968,297,6],
["p2",19,"op","chess_char_6_14_b",2,"uniequip_002_lumen",2088.9,1,8,0,0,0,0,0,0],
["p2",20,"op","chess_char_6_16_b",2,"uniequip_002_halo2",1582.9,1,12,1,140177,0,0,100,4],
["p2",21,"op","chess_char_6_18_b",2,"uniequip_002_whitw2",1695.1,1,14,1,283496,0,0,77,3],
["p2",22,"op","chess_char_6_20_b",2,"uniequip_002_agoat2",1828.31,1,18,0,0,0,37241,59,2],
["p1","~1","token","token_10030_mlyss_wtrman",null,null,0,0,34,0,10512,2454,630,12,null],
["p1","~2","token","token_10030_mlyss_wtrman",null,null,0,0,41,0,5418,1593,0,9,1],
["p2",20,"op","chess_char_6_16_b",2,"uniequip_002_halo2",1582.9,1,12,0,145556,0,0,103,4],
["p2",21,"op","chess_char_6_18_b",2,"uniequip_002_whitw2",1695.1,1,14,1,292178,0,0,77,3],
["p2",22,"op","chess_char_6_20_b",2,"uniequip_002_agoat2",1828.31,1,18,0,0,1103,37842,58,2],
["p1","~1","token","token_10030_mlyss_wtrman",null,null,0,0,34,0,10960,2454,630,13,null],
["p1","~2","token","token_10030_mlyss_wtrman",null,null,0,0,41,0,5575,1593,0,9,1],
["p1","~1","token","token_10017_skadi2_dedant",null,null,100,0,42,0,0,0,0,0,null],
["p1","~3","token","token_10030_mlyss_wtrman",null,null,0,0,46,0,2005,1322,0,6,1],
["p1","~3","token","token_10030_mlyss_wtrman",null,null,0,0,46,0,2108,1322,0,6,1],
["p1","~2","token","token_10017_skadi2_dedant",null,null,138,0,48,0,0,0,0,0,null],
["p1","~4","token","token_10030_mlyss_wtrman",null,null,122.63,1,49,0,10116,2003,0,28,1],
["p1","~5","token","token_10030_mlyss_wtrman",null,null,0,0,50,0,4393,3044,1379,12,null],
["p1","~4","token","token_10030_mlyss_wtrman",null,null,122.63,1,49,0,10458,2003,0,29,1],
["p1","~5","token","token_10030_mlyss_wtrman",null,null,0,0,50,0,4545,2968,1379,12,null],
["p1","~3","token","token_10017_skadi2_dedant",null,null,100,1,51,0,0,0,0,0,null]
],
"enemies": {"enemy_1067_snslime":[8,8,0,8997,111795],"enemy_1081_sotisd":[4,4,0,0,257989],"enemy_1249_lysdb_2":[4,4,0,0,103196],"enemy_9033_acdeer":[1,1,0,43366,4000000]},
"hooks": [1,49,5134,1270,1270,3029,3331,3007,22,27,92,86,300,37421,1393,1,17,0,1,21,0,11,0],
"events": {"atk":1436,"deploy":32,"die":27,"dmg":3070,"engage":16,"fx":669,"heal":444,"skill":178,"spawn":47,"status":497},
"fx": {"airstrike":100,"beam":5,"blink":2,"bombard":69,"bombardShell":69,"burst":11,"column":45,"copy":4,"dp":4,"drone":234,"droneLock":11,"drones":3,"explode":6,"garrisonGrant":1,"link":1,"lock":69,"phase":1,"scorchBurst":2,"shieldBreak":4,"snow":5,"split":2,"steal":6,"sunBurst":4,"talent":4,"tide":5,"wanted":2},
"snaps": "e8f77b71 c1cf2cef 36ab0e73 ccc2551f 2b2e3882 dd02a2c0 0af53ff0 4dd61228 eb6652cc d394b2db 63e0edd0 aa384869 69ec5174 2cf805c7 d39727e2 8d04b1b9 23c424be cf78b290"
"enemies": {"enemy_1067_snslime":[8,8,0,11760,111795],"enemy_1081_sotisd":[4,4,0,0,257989],"enemy_1249_lysdb_2":[4,4,0,0,103196],"enemy_9033_acdeer":[1,1,0,40235,4000000]},
"hooks": [1,49,5209,1281,1281,3051,3357,2765,22,27,93,88,305,37705,1418,3,17,0,1,21,0,10,0],
"events": {"atk":1445,"deploy":32,"die":27,"dmg":3088,"engage":16,"fx":675,"heal":436,"skill":181,"spawn":47,"status":511},
"fx": {"airstrike":104,"beam":5,"blink":2,"bombard":63,"bombardShell":64,"burst":10,"column":45,"copy":4,"dp":4,"drone":245,"droneLock":15,"drones":3,"explode":6,"garrisonGrant":1,"link":1,"lock":69,"phase":1,"scorchBurst":2,"shieldBreak":4,"snow":5,"split":2,"steal":6,"sunBurst":4,"talent":4,"tide":5,"wanted":1},
"snaps": "e8f77b71 c1cf2cef a6688abd b9a6fe99 07c13245 40140044 ddfccb97 273b0a5a 3f6c7803 621a62a4 561c43b2 75e4ac28 41579a2d 3b4b24fa 4aa3b38d e05ffd35 6d9a4f36 45587dcb"
},
"boss-boss_7-solo": {
"about": "“萨米的意志” solo pool 2000000: chess_char_5_08_b chess_char_5_11_b chess_char_5_05_b chess_char_5_06_b chess_char_5_13_b chess_char_5_07_b chess_char_5_02_b chess_char_5_03_b chess_char_5_10_b chess_char_5_12_b",
@@ -1074,72 +1076,72 @@
"killed": 8,
"total": 8,
"unspawned": 0,
"bossHpLeft": 2705390,
"rngDraws": 2092,
"bossHpLeft": 2992115,
"rngDraws": 1828,
"errors": {"count":0,"kinds":[]},
"players": {
"p1": {"killed":8,"total":8,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":2847989,"bossDmg":1144813,"heal":26393,"deaths":3,"layerGains":{}},
"p2": {"killed":0,"total":0,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":4559292,"bossDmg":1700319,"heal":131365,"deaths":10,"layerGains":{}}
"p1": {"killed":8,"total":8,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":2840858,"bossDmg":1130306,"heal":26454,"deaths":3,"layerGains":{}},
"p2": {"killed":0,"total":0,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":3891982,"bossDmg":1427731,"heal":133966,"deaths":7,"layerGains":{}}
},
"units": [
["p1",1,"op","chess_char_6_03_b",2,"uniequip_002_yu",7326,1,15,0,3237,9562,15614,31,3],
["p1",1,"op","chess_char_6_03_b",2,"uniequip_002_yu",7326,1,15,0,3237,9163,15665,31,3],
["p1",2,"op","chess_char_6_06_b",2,"uniequip_002_pepe",3232.9,1,11,0,22947,281,0,23,1],
["p1",3,"op","chess_char_6_07_b",2,"uniequip_002_siege2",2302.22,1,17,0,49362,1605,0,26,2],
["p1",4,"op","chess_char_6_01_b",2,"uniequip_002_lemuen",528.27,1,7,1,2380803,4432,0,60,9],
["p1",5,"op","chess_char_6_02_b",2,"uniequip_002_sbell2",871.14,1,9,0,37700,13770,0,46,2],
["p1",6,"op","chess_char_6_04_b",2,"uniequip_002_skadi2",719.06,1,54,0,30456,11524,527,0,4],
["p1",7,"op","chess_char_6_05_b",2,"uniequip_002_pasngr",2696.54,1,13,1,70578,0,0,0,11],
["p1",8,"op","chess_char_6_08_b",2,"uniequip_002_reed2",1834.8,1,19,0,54992,0,9335,73,2],
["p1",9,"op","chess_char_6_09_b",0,"uniequip_002_cello",0,0,21,1,160062,13974,0,27,27],
["p1",10,"op","chess_char_6_11_b",2,"uniequip_002_mlyss",2082.3,1,59,0,29453,10289,306,74,2],
["p1",3,"op","chess_char_6_07_b",2,"uniequip_002_siege2",2291.42,1,17,0,49362,1605,0,26,2],
["p1",4,"op","chess_char_6_01_b",2,"uniequip_002_lemuen",913.1,1,7,1,2371886,3937,0,57,9],
["p1",5,"op","chess_char_6_02_b",2,"uniequip_002_sbell2",871.14,1,9,0,38138,13770,0,46,2],
["p1",6,"op","chess_char_6_04_b",2,"uniequip_002_skadi2",719.06,1,54,0,30456,11499,452,0,4],
["p1",7,"op","chess_char_6_05_b",2,"uniequip_002_pasngr",2696.54,1,13,1,71156,0,0,0,11],
["p1",8,"op","chess_char_6_08_b",2,"uniequip_002_reed2",1834.8,1,19,0,55032,0,9419,73,2],
["p1",9,"op","chess_char_6_09_b",0,"uniequip_002_cello",0,0,21,1,160062,14044,0,27,27],
["p1",10,"op","chess_char_6_11_b",2,"uniequip_002_mlyss",2082.3,1,59,0,30214,11261,306,74,2],
["p1",11,"token","token_10017_skadi2_dedant",null,null,138,0,28,0,0,0,0,0,null],
["p1",12,"token","token_10030_mlyss_wtrman",null,null,0,0,24,0,0,1670,348,4,1],
["p2",13,"op","chess_char_6_15_b",2,"uniequip_002_qiubai",0,0,51,4,1181207,26626,0,330,3],
["p2",14,"op","chess_char_6_19_b",2,"uniequip_002_blkkgt",0,0,56,0,619703,17069,0,79,5],
["p2",15,"op","chess_char_5_01_b",1,"uniequip_002_excu2",0,0,12,0,19399,8026,5640,64,2],
["p2",16,"op","chess_char_6_17_b",2,"uniequip_002_nearl2",0,0,64,1,633128,29140,0,72,3],
["p2",17,"op","chess_char_6_12_b",1,"uniequip_002_rosmon",0,0,8,0,905789,18226,0,72,3],
["p2",18,"op","chess_char_6_13_b",1,"uniequip_002_angel2",2314.38,1,14,0,663280,10817,6140,266,5],
["p2",19,"op","chess_char_6_14_b",2,"uniequip_002_lumen",0,0,20,0,0,3342,19329,77,1],
["p2",20,"op","chess_char_6_16_b",2,"uniequip_002_halo2",0,0,10,0,102836,1583,0,121,3],
["p2",21,"op","chess_char_6_18_b",2,"uniequip_002_whitw2",1844.61,1,27,0,352326,5115,0,25,3],
["p2",22,"op","chess_char_6_20_b",2,"uniequip_002_agoat2",1828.31,1,25,0,0,15360,100256,80,2],
["p2",13,"op","chess_char_6_15_b",2,"uniequip_002_qiubai",903.1,1,68,4,817795,32754,0,248,4],
["p2",14,"op","chess_char_6_19_b",2,"uniequip_002_blkkgt",0,0,64,0,355848,15836,0,48,4],
["p2",15,"op","chess_char_5_01_b",1,"uniequip_002_excu2",1415.78,1,66,0,123216,11064,6422,44,2],
["p2",16,"op","chess_char_6_17_b",2,"uniequip_002_nearl2",1602.9,1,65,1,499184,28500,0,76,3],
["p2",17,"op","chess_char_6_12_b",1,"uniequip_002_rosmon",1,1,8,0,1061491,19179,0,73,3],
["p2",18,"op","chess_char_6_13_b",1,"uniequip_002_angel2",2436.5,1,14,0,526339,8805,4488,259,5],
["p2",19,"op","chess_char_6_14_b",2,"uniequip_002_lumen",3342.24,1,20,0,0,0,19549,78,1],
["p2",20,"op","chess_char_6_16_b",2,"uniequip_002_halo2",1582.9,1,10,0,103786,0,0,127,3],
["p2",21,"op","chess_char_6_18_b",2,"uniequip_002_whitw2",1859.61,1,27,0,346326,5155,0,25,3],
["p2",22,"op","chess_char_6_20_b",2,"uniequip_002_agoat2",1758.31,1,25,0,0,14000,103507,78,2],
["-","~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],
["-","~3","device","trap_1105_accrate",null,null,100,1,3,0,0,0,0,0,null],
["-","~4","device","trap_1105_accrate",null,null,100,1,4,0,0,0,0,0,null],
["-","~5","device","trap_1105_accrate",null,null,100,1,5,0,0,0,0,0,null],
["-","~6","device","trap_1105_accrate",null,null,100,1,6,0,0,0,0,0,null],
["p1","~1","token","token_10030_mlyss_wtrman",null,null,0,0,45,0,2647,1882,0,21,1],
["p1","~1","token","token_10030_mlyss_wtrman",null,null,0,0,45,0,2612,1882,0,21,1],
["p2","~1","token","token_10019_nearl2_sword",null,null,1739.66,0,47,0,7453,6540,0,21,null],
["p1","~2","token","token_10030_mlyss_wtrman",null,null,0,0,48,0,390,3127,0,9,null],
["p1","~3","token","token_10030_mlyss_wtrman",null,null,0,0,49,0,613,1322,0,10,null],
["p1","~2","token","token_10030_mlyss_wtrman",null,null,0,0,48,0,363,3127,0,9,null],
["p1","~3","token","token_10030_mlyss_wtrman",null,null,0,0,49,0,645,1322,0,10,null],
["p1","~1","token","token_10040_siege2_vlion",null,null,3730,0,52,0,4750,270,0,10,null],
["p1","~1","token","token_10017_skadi2_dedant",null,null,138,0,55,0,0,0,0,0,null],
["p2","~2","token","token_10019_nearl2_sword",null,null,0,0,57,0,50543,6744,0,0,null],
["p1","~1","token","token_10017_skadi2_dedant",null,null,138,0,56,0,0,0,0,0,null],
["p2","~2","token","token_10019_nearl2_sword",null,null,6876.71,0,57,0,25272,1338,0,0,null],
["p1","~4","token","token_10030_mlyss_wtrman",null,null,1322,1,60,0,0,310,263,0,1],
["p1","~2","token","token_10017_skadi2_dedant",null,null,100,0,61,0,0,0,0,0,null],
["p1","~2","token","token_10040_siege2_vlion",null,null,3913.91,0,62,0,0,119,0,0,null],
["p2","~3","token","token_10019_nearl2_sword",null,null,205.57,0,63,0,23627,7004,0,0,null],
["p1","~3","token","token_10017_skadi2_dedant",null,null,100,1,65,0,0,0,0,0,null]
["p1","~2","token","token_10040_siege2_vlion",null,null,3916.52,0,62,0,0,115,0,0,null],
["p2","~3","token","token_10019_nearl2_sword",null,null,3182.15,0,63,0,25272,4024,0,0,null],
["p1","~3","token","token_10017_skadi2_dedant",null,null,100,1,67,0,0,0,0,0,null]
],
"enemies": {
"enemy_1267_nhpbr_2": [6,6,0,1900,141894],
"enemy_9007_acelem": [2,2,0,13271,309587],
"enemy_9017_achunt_2": [2,0,0,58763,2894610],
"enemy_9018_actrpa": [1,0,0,61321,1190947],
"enemy_9019_actrpb": [1,0,0,28448,1109516],
"enemy_9020_actrpc": [1,0,0,21621,1863552]
"enemy_9007_acelem": [2,2,0,14390,309587],
"enemy_9017_achunt_2": [2,0,0,57663,2607885],
"enemy_9018_actrpa": [1,0,0,53759,1092472],
"enemy_9019_actrpb": [1,0,0,24773,1015854],
"enemy_9020_actrpc": [1,0,0,22601,1668902]
},
"hooks": [1,63,6000,1773,1773,9719,19351,3054,26,38,96,92,314,48736,3494,21,13,0,1,33,0,18,0],
"events": {"atk":2403,"deploy":44,"die":38,"dmg":18745,"engage":22,"fx":2239,"heal":960,"skill":188,"spawn":53,"status":946},
"hooks": [1,66,6000,1649,1649,8552,17196,2968,22,35,96,88,305,47649,3685,17,13,0,1,31,0,19,0],
"events": {"atk":2230,"deploy":47,"die":35,"dmg":16662,"engage":22,"fx":2182,"heal":905,"skill":184,"spawn":53,"status":898},
"fx": {
"airstrike": 113,
"airstrike": 93,
"beam": 26,
"blink": 5,
"bombard": 78,
"bombardShell": 78,
"burst": 18,
"bombard": 69,
"bombardShell": 70,
"burst": 19,
"camouflage": 1,
"copy": 3,
"dash": 24,
@@ -1148,8 +1150,8 @@
"drone": 334,
"droneLock": 11,
"drones": 3,
"extraAttack": 23,
"finale": 5,
"extraAttack": 15,
"finale": 4,
"firewall": 3,
"garrisonGrant": 1,
"lightning": 118,
@@ -1160,20 +1162,20 @@
"radiantSword": 3,
"revive": 3,
"shieldBreak": 9,
"slash": 55,
"slash": 44,
"snow": 26,
"split": 2,
"steal": 5,
"storm": 11,
"summon": 5,
"sunBurst": 4,
"sunBurst": 3,
"talent": 4,
"tide": 4,
"undying": 1,
"wanted": 6,
"zone": 6
},
"snaps": "8b102142 7109c20e 6cb62905 cd421024 07699f86 52b1f74f 9eb6286f 13679ac3 4a76f2fe f3f7a2a9 bfaa974c 4043041c 78cb5e5f e4afd571 ee6c6d9a fd48d22e 44c582cd 445a604a 58683b3b b2f7ef86"
"snaps": "8b102142 7109c20e 6cb62905 16f4d84e db38e8d1 fb736fc0 d644e65e bf490768 454f169f 723043e7 883b6557 609aa7bb bfa05477 7d38be83 76b95dc1 8ac15f69 7c25d190 03595be2 40606ec4 e87eb589"
},
"hidden-boss_9-solo": {
"about": "假想敌:铳 solo pool 2800000: chess_char_5_08_b chess_char_5_11_b chess_char_5_05_b chess_char_5_06_b chess_char_5_13_b chess_char_5_07_b chess_char_5_02_b chess_char_5_03_b chess_char_5_10_b chess_char_5_12_b",
@@ -1332,36 +1334,36 @@
"time": 200,
"ticks": 6000,
"reason": "forced",
"killed": 12,
"killed": 11,
"total": 5,
"unspawned": 0,
"bossHpLeft": 7226192,
"rngDraws": 103,
"bossHpLeft": 7221428,
"rngDraws": 99,
"errors": {"count":0,"kinds":[]},
"players": {"p1":{"killed":12,"total":5,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":591594,"bossDmg":373808,"heal":141080,"deaths":9,"layerGains":{}}},
"players": {"p1":{"killed":11,"total":5,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":572445,"bossDmg":378572,"heal":140471,"deaths":8,"layerGains":{}}},
"units": [
["p1",1,"op","chess_char_4_17_b",1,"uniequip_002_hsguma",3864.3,1,29,2,51,3864,0,14,0],
["p1",2,"op","chess_char_4_18_b",1,"uniequip_002_mudrok",5783.26,1,25,0,17,18719,11206,15,2],
["p1",3,"op","chess_char_4_23_b",2,"uniequip_002_gvial2",2579.06,1,26,2,230272,25481,4759,191,4],
["p1",4,"op","chess_char_4_19_b",2,"uniequip_002_flamtl",3960.66,1,33,0,16,16650,0,15,0],
["p1",5,"op","chess_char_4_22_b",2,"uniequip_002_svrash",2563.92,1,22,5,198993,51878,7500,123,2],
["p1",6,"op","chess_char_4_20_b",2,"uniequip_002_fartth",1560.9,1,30,0,115397,1561,0,56,6],
["p1",7,"op","chess_char_4_21_b",1,"uniequip_002_plosis",1428.79,1,23,0,0,15153,80115,84,2],
["p1",8,"op","chess_char_4_24_b",1,"uniequip_002_billro",2395.8,1,34,3,46848,3732,0,52,3],
["p1",9,"op","chess_char_4_25_b",2,"uniequip_002_cetsyr",1404.57,1,24,0,0,31236,5310,0,2],
["p1",10,"op","chess_char_4_26_b",0,"uniequip_002_bldsk",1674.2,1,3,0,0,3378,32189,47,8]
["p1",1,"op","chess_char_4_17_b",1,"uniequip_002_hsguma",3864.3,1,29,3,56,3864,0,14,0],
["p1",2,"op","chess_char_4_18_b",1,"uniequip_002_mudrok",4803.42,1,25,0,16,18391,9981,15,2],
["p1",3,"op","chess_char_4_23_b",2,"uniequip_002_gvial2",3075.04,1,26,2,206116,24874,5090,191,4],
["p1",4,"op","chess_char_4_19_b",2,"uniequip_002_flamtl",3960.66,1,5,0,15,16060,0,14,0],
["p1",5,"op","chess_char_4_22_b",2,"uniequip_002_svrash",0,0,22,2,199997,48631,7490,117,2],
["p1",6,"op","chess_char_4_20_b",2,"uniequip_002_fartth",1560.9,1,30,0,119396,1561,0,57,6],
["p1",7,"op","chess_char_4_21_b",1,"uniequip_002_plosis",1052.5,1,23,0,0,16534,81043,85,2],
["p1",8,"op","chess_char_4_24_b",1,"uniequip_002_billro",2395.8,1,2,4,46849,3478,0,54,4],
["p1",9,"op","chess_char_4_25_b",2,"uniequip_002_cetsyr",1076.51,1,24,0,0,32684,5419,0,2],
["p1",10,"op","chess_char_4_26_b",0,"uniequip_002_bldsk",1674.2,1,3,0,0,2208,31448,46,8]
],
"enemies": {
"enemy_1005_yokai_3": [1,1,0,17270,107495],
"enemy_9010_acpupp": [2,1,0,19915,109930],
"enemy_9021_acduml_2": [1,0,0,4507,373808],
"enemy_9022_acdumm": [1,0,0,3098,0],
"enemy_9023_acdums": [10,10,0,124838,360]
"enemy_1005_yokai_3": [1,1,0,16542,107495],
"enemy_9010_acpupp": [2,2,0,18441,86026],
"enemy_9021_acduml_2": [1,0,0,4507,378572],
"enemy_9022_acdumm": [1,0,0,3215,0],
"enemy_9023_acdums": [10,8,0,123292,352]
},
"hooks": [1,34,6000,686,686,2437,2957,6444,21,21,29,28,0,9973,109,18,15,0,1,51,0,24,2],
"events": {"atk":1008,"deploy":19,"die":21,"dmg":2942,"engage":7,"fx":874,"heal":228,"skill":60,"spawn":25,"status":330},
"fx": {"appear":3,"bandage":8,"beam":21,"block":15,"burst":24,"disappear":3,"dodge":2,"ember":2,"explode":510,"featherArrow":6,"garrisonGrant":1,"healField":2,"jungleSoul":4,"mote":53,"phase":56,"redistribute":30,"revive":1,"reweave":2,"riposte":1,"rockslide":2,"sandChains":1,"sandChainsCharged":2,"shieldBreak":22,"slash":40,"spGift":7,"summon":8,"telegraph":2,"thorns":43,"truesilver":2,"undying":1},
"snaps": "66102eb2 711577a7 5b34ba48 b88d4bb5 a7c05e6b bfd5e766 4b333a83 ed69496e 06b28ff3 a3aff9fb 53f4bd37 c96d8bc9 870f8b31 27aa3bc7 c1567cdf b461354b 0d40f693 b5835929 ced76f1b fe45cd95"
"hooks": [1,32,6000,682,682,2353,2829,6095,19,19,30,29,0,10668,109,17,15,0,1,40,0,22,3],
"events": {"atk":996,"deploy":17,"die":19,"dmg":2814,"engage":7,"fx":832,"heal":226,"skill":62,"spawn":25,"status":320},
"fx": {"appear":1,"bandage":8,"beam":21,"block":11,"burst":22,"disappear":1,"dodge":3,"ember":2,"explode":484,"featherArrow":6,"garrisonGrant":1,"healField":2,"jungleSoul":4,"mote":52,"phase":54,"redistribute":30,"reweave":2,"riposte":1,"rockslide":2,"sandChains":2,"sandChainsCharged":2,"shieldBreak":22,"slash":40,"spGift":4,"summon":8,"telegraph":2,"thorns":42,"truesilver":2,"undying":1},
"snaps": "66102eb2 711577a7 5b34ba48 b88d4bb5 a7c05e6b bfd5e766 4b333a83 ed69496e 09c18528 619b7ea1 9d890c8e 85de072b 0f2ae8c9 af506320 0f8016e6 188a65ff fa7de72b e09b1905 868c7205 6205ddcc"
},
"unite-1": {
"about": "联防 1 helper(s), 11 leaked: chess_char_6_03_b chess_char_6_06_b chess_char_6_07_b chess_char_6_01_b chess_char_6_02_b chess_char_6_04_b chess_char_6_05_b chess_char_6_08_b token_10017_skadi2_dedant",
+29 -29
View File
@@ -1322,7 +1322,7 @@
"combat": "client",
"boost": {"lp":400,"layers":300},
"setup": {"stageId":"act1autochess_m02","bossId":"boss_5","hiddenBossId":"boss_10","factions":["TIMES","ELEMENT","DOT"]},
"end": {"victory":true,"roundsPassed":15,"hiddenReached":true,"hiddenCleared":true,"reason":"victory","teamLp":397,"bossPoolLeft":0,"virtualMs":113700},
"end": {"victory":true,"roundsPassed":15,"hiddenReached":true,"hiddenCleared":true,"reason":"victory","teamLp":399,"bossPoolLeft":0,"virtualMs":113700},
"rng": {"setup":25,"shop":346,"waves":0,"draft":42,"bots":10350,"meta":3},
"errors": {"engine":0,"logged":0,"sim":0,"dispatcher":0},
"rounds": [
@@ -1385,12 +1385,12 @@
"victory": true,
"roundsPassed": 15,
"eliminatedRound": null,
"lp": 397,
"lp": 399,
"bandId": "band_paganini",
"title": {"id":"comment_1","name":"卫戍之星","picId":"comment_icon_1","text":"对敌方领袖造成伤害最高(仅胜利时)"},
"trophies": 0,
"reward": 425,
"stats": {"activatedLayers":2279,"bossDamage":4580000,"dmgDealt":6596436,"gold":175,"itemsEquipped":25,"kills":376,"leaks":1,"lpLost":1,"merges":5,"perfectRounds":12,"refreshes":34},
"stats": {"activatedLayers":2279,"bossDamage":4580000,"dmgDealt":6596430,"gold":175,"itemsEquipped":25,"kills":375,"leaks":1,"lpLost":1,"merges":5,"perfectRounds":12,"refreshes":34},
"lineup": ["4_02_b@12,7[3_08_e_a+1_02_e_b]","6_01_a@12,8[3_03_e_a+3_02_e_a]","6_16_a@11,5","3_18_a@11,8[4_11_e_a+4_05_e_b]","4_10_a@10,4[1_05_e_b]","6_05_a@10,5","4_01_b@10,6[4_06_e_a+4_05_e_a]","1_08_b@9,9[4_10_e_a]"],
"bonds": ["arcaneShip 2/389","deputShip 0/300","egirShip 0/300","emptyShip 0/300","indomShip 0/308","investShip 0/300","kazimierzShip 0/308","kjeragShip 0/300","lateranoShip 1/320","maniShip 0/300","miraShip 0/300","preciShip 1/302","raidShip 0/300","sargonShip 0/300","siracusaShip 1/345","skillfulShip 0/335","soloShip 1/307","steadShip 0/300","suntShip 1/300","swiftShip 1/316","victoriaShip 0/300","visiShip 0/300","yanShip 0/300"]
}
@@ -1517,8 +1517,8 @@
"combat": "client",
"boost": null,
"setup": {"stageId":"act1autochess_m01","bossId":"boss_5","hiddenBossId":"boss_10","factions":["DOT","INVISIBLE","REFLECTION"]},
"end": {"victory":true,"roundsPassed":14,"hiddenReached":false,"hiddenCleared":false,"reason":"victory","teamLp":40,"bossPoolLeft":0,"virtualMs":116900},
"rng": {"setup":25,"shop":489,"waves":0,"draft":34,"bots":18039,"meta":11},
"end": {"victory":true,"roundsPassed":14,"hiddenReached":false,"hiddenCleared":false,"reason":"victory","teamLp":30,"bossPoolLeft":0,"virtualMs":119900},
"rng": {"setup":25,"shop":428,"waves":0,"draft":34,"bots":18039,"meta":10},
"errors": {"engine":0,"logged":0,"sim":0,"dispatcher":0},
"rounds": [
{
@@ -1563,28 +1563,28 @@
},
{
"round": 9,
"h_0": [1,28,14,0,0,4,8,"6e857f7b",102,"arcaneShip 1/11, kazimierzShip 1/20, preciShip 0/8, raidShip 1/60, skillfulShip 0/3, soloShip 1/0, suntShip 1/0, swiftShip 1/11, yanShip 0/1",23,23,0,78],
"ai_0": [1,23,12,0,2,4,8,"8aab1ba8",138,"arcaneShip 0/2, deputShip 1/16, kazimierzShip 0/8, lateranoShip 1/35, preciShip 1/17, sargonShip 0/2, skillfulShip 1/9, soloShip 0/5, suntShip 1/14, swiftShip 1/47",23,23,0,73]
"h_0": [1,28,14,0,0,4,8,"256af2e6",37,"arcaneShip 1/11, kazimierzShip 1/12, miraShip 0/3, preciShip 0/8, raidShip 0/48, skillfulShip 1/3, soloShip 1/0, suntShip 1/0, swiftShip 1/11, yanShip 0/7",22,23,1,78],
"ai_0": [1,23,12,0,0,4,8,"10cd80b7",133,"arcaneShip 0/2, deputShip 0/14, kazimierzShip 0/8, lateranoShip 1/36, preciShip 1/23, sargonShip 0/2, skillfulShip 1/7, soloShip 1/5, suntShip 1/12, swiftShip 1/50",23,23,0,72]
},
{
"round": 10,
"h_0": [1,28,12,0,0,5,8,"00fe855c",129,"arcaneShip 1/14, kazimierzShip 1/32, preciShip 0/8, raidShip 1/72, skillfulShip 0/3, soloShip 1/0, suntShip 1/0, swiftShip 1/11, yanShip 0/1",31,31,0,88],
"ai_0": [1,23,14,0,0,5,8,"b70033a0",160,"arcaneShip 0/2, deputShip 0/16, kazimierzShip 0/8, lateranoShip 1/38, preciShip 2/27, sargonShip 0/2, skillfulShip 1/14, soloShip 1/5, suntShip 1/14, swiftShip 1/62",31,31,0,88]
"h_0": [1,28,12,0,0,5,8,"904868f5",47,"arcaneShip 1/14, kazimierzShip 1/16, miraShip 0/3, preciShip 0/8, raidShip 0/48, skillfulShip 1/6, soloShip 1/0, suntShip 1/0, swiftShip 1/11, yanShip 0/7",31,31,0,88],
"ai_0": [1,23,12,0,0,5,8,"fc0a1877",163,"arcaneShip 0/2, deputShip 0/14, kazimierzShip 0/8, lateranoShip 1/39, preciShip 1/33, sargonShip 0/2, skillfulShip 1/12, soloShip 1/5, suntShip 1/12, swiftShip 1/62",31,31,0,87]
},
{
"round": 11,
"h_0": [1,28,12,0,0,5,8,"b2b9f9bd",153,"arcaneShip 1/14, kazimierzShip 1/40, miraShip 1/4, preciShip 0/8, raidShip 1/80, skillfulShip 0/3, soloShip 1/0, suntShip 1/0, swiftShip 1/15, yanShip 0/1",25,25,0,100],
"ai_0": [1,23,12,0,0,5,8,"6ec28fe0",193,"arcaneShip 0/2, deputShip 0/16, kazimierzShip 0/8, lateranoShip 1/41, preciShip 2/40, sargonShip 0/2, skillfulShip 1/19, soloShip 1/5, suntShip 1/14, swiftShip 1/74",25,25,0,101]
"h_0": [1,27,12,0,0,5,8,"0d65116f",125,"arcaneShip 1/17, kazimierzShip 1/31, miraShip 1/3, preciShip 0/8, raidShip 1/60, skillfulShip 0/9, soloShip 1/3, suntShip 1/0, swiftShip 1/11, yanShip 0/7",22,25,3,99],
"ai_0": [1,23,12,0,0,5,8,"6cafa713",213,"arcaneShip 0/2, deputShip 0/14, kazimierzShip 0/8, lateranoShip 1/49, preciShip 2/53, sargonShip 0/2, skillfulShip 1/17, soloShip 1/5, suntShip 1/12, swiftShip 1/77",25,25,0,100]
},
{
"round": 12,
"h_0": [1,28,12,0,0,6,8,"c8e0476d",188,"arcaneShip 1/17, kazimierzShip 1/52, miraShip 1/8, preciShip 0/8, raidShip 1/92, skillfulShip 0/3, soloShip 1/0, suntShip 1/0, swiftShip 1/19, yanShip 0/1",25,31,6,112],
"ai_0": [1,23,12,0,0,6,8,"54a25cd7",229,"arcaneShip 0/2, deputShip 0/16, kazimierzShip 0/8, lateranoShip 1/47, preciShip 2/50, sargonShip 0/2, skillfulShip 1/24, soloShip 1/5, suntShip 1/14, swiftShip 1/89",31,31,0,112]
"h_0": [1,27,12,0,0,6,8,"42ad03f7",80,"arcaneShip 1/20, emptyShip 1/0, kazimierzShip 1/35, miraShip 1/3, preciShip 1/8, raidShip 0/60, skillfulShip 0/9, soloShip 1/3, suntShip 1/0, swiftShip 1/11, yanShip 0/7",22,31,9,111],
"ai_0": [1,23,12,0,0,6,8,"189b8c70",273,"arcaneShip 0/2, deputShip 0/14, kazimierzShip 0/8, lateranoShip 1/67, preciShip 2/75, sargonShip 0/2, skillfulShip 1/25, soloShip 1/5, suntShip 1/12, swiftShip 1/89",31,31,0,111]
},
{
"round": 13,
"h_0": [1,18,12,0,0,6,8,"205ea1e7",227,"arcaneShip 1/20, kazimierzShip 1/64, miraShip 1/12, preciShip 0/8, raidShip 1/108, skillfulShip 0/3, soloShip 1/0, suntShip 1/0, swiftShip 1/23, yanShip 0/1",23,57,34,124],
"ai_0": [1,22,12,0,0,6,8,"dc15adde",271,"arcaneShip 0/2, deputShip 0/16, kazimierzShip 0/8, lateranoShip 1/53, preciShip 2/63, sargonShip 0/2, skillfulShip 1/29, soloShip 1/5, suntShip 1/14, swiftShip 1/107, visiShip 1/0",56,57,1,126]
"h_0": [1,17,12,0,0,6,8,"d8bc61ba",191,"arcaneShip 1/20, emptyShip 1/0, kazimierzShip 1/62, miraShip 0/3, preciShip 1/8, raidShip 1/87, skillfulShip 0/9, soloShip 1/3, suntShip 1/0, swiftShip 1/11, yanShip 0/7",28,57,29,123],
"ai_0": [1,13,12,0,0,6,8,"09231657",325,"arcaneShip 0/2, deputShip 0/14, kazimierzShip 0/8, lateranoShip 1/81, preciShip 2/93, sargonShip 0/2, skillfulShip 1/33, soloShip 1/5, suntShip 2/12, swiftShip 1/101",27,57,30,122]
}
],
"players": {
@@ -1593,28 +1593,28 @@
"victory": true,
"roundsPassed": 14,
"eliminatedRound": null,
"lp": 18,
"lp": 17,
"bandId": "band_amedic",
"title": {"id":"comment_4","name":"精英云集","picId":"comment_icon_4","text":"晋升精锐次数最多"},
"title": {"id":"comment_5","name":"万事俱备","picId":"comment_icon_5","text":"装备配发数量最多"},
"trophies": 4,
"reward": 400,
"stats": {"activatedLayers":220,"bossDamage":344186,"dmgDealt":3000543,"gold":134,"itemsEquipped":22,"kills":272,"leaks":40,"lpLost":10,"merges":3,"perfectRounds":11,"refreshes":21},
"lineup": ["6_18_a@12,3[4_11_e_a]","4_10_a@12,4","1_17_b@11,3[4_03_e_a+1_01_e_a]","5_20_a@11,8[6_05_e_a+3_06_e_a]","3_17_b@10,3","3_12_a@10,4[1_02_e_a+3_11_e_a]","5_07_a@10,7[4_02_e_b+2_01_e_b]","1_19_b@10,8[2_01_e_b+4_04_e_b]"],
"bonds": ["arcaneShip 2/24","kazimierzShip 1/64","miraShip 1/12","preciShip 0/8","raidShip 1/108","siracusaShip 1/12","skillfulShip 0/3","soloShip 1/0","suntShip 1/0","swiftShip 0/23","yanShip 0/1"]
"stats": {"activatedLayers":194,"bossDamage":206158,"dmgDealt":2761977,"gold":135,"itemsEquipped":20,"kills":267,"leaks":42,"lpLost":11,"merges":4,"perfectRounds":9,"refreshes":19},
"lineup": ["3_10_a@12,3","6_12_a@11,3[1_02_e_a+2_01_e_a]","3_09_a@11,8[1_01_e_a+6_05_e_a]","3_17_a@10,3[1_05_e_a+3_08_e_a]","3_12_b@10,4[3_04_e_a+4_04_e_a]","5_07_a@10,7","1_19_b@10,8[4_05_e_a+2_01_e_b]","1_17_b@9,8[4_03_e_a+4_02_e_b]"],
"bonds": ["arcaneShip 2/20","emptyShip 1/0","kazimierzShip 1/62","miraShip 1/3","preciShip 1/8","raidShip 1/87","skillfulShip 0/9","soloShip 1/3","suntShip 1/0","swiftShip 1/11","yanShip 0/7"]
},
"ai_0": {
"alive": true,
"victory": true,
"roundsPassed": 14,
"eliminatedRound": null,
"lp": 22,
"lp": 13,
"bandId": "band_chiave",
"title": {"id":"comment_1","name":"卫戍之星","picId":"comment_icon_1","text":"对敌方领袖造成伤害最高(仅胜利时)"},
"trophies": 4,
"reward": 400,
"stats": {"activatedLayers":302,"bossDamage":435814,"dmgDealt":3507011,"gold":140,"itemsEquipped":17,"kills":299,"leaks":1,"lpLost":1,"merges":3,"perfectRounds":12,"refreshes":22},
"lineup": ["3_21_a@12,3[4_08_e_a+6_02_e_a]","4_25_a@12,4","2_01_a@12,5[3_01_e_a]","6_12_a@11,3[1_05_e_a+3_11_e_a]","4_14_b@11,4[1_05_e_b+4_07_e_a]","1_01_b@10,3[3_08_e_a]","5_01_a@10,4[4_05_e_a]","2_13_a@9,4[4_02_e_b]"],
"bonds": ["arcaneShip 0/2","deputShip 0/16","emptyShip 1/0","kazimierzShip 0/8","lateranoShip 1/56","preciShip 2/76","sargonShip 0/2","skillfulShip 1/34","soloShip 0/5","suntShip 1/14","swiftShip 1/119","visiShip 1/3"]
"stats": {"activatedLayers":383,"bossDamage":573842,"dmgDealt":3560504,"gold":134,"itemsEquipped":20,"kills":276,"leaks":30,"lpLost":10,"merges":5,"perfectRounds":12,"refreshes":14},
"lineup": ["2_01_a@12,3[3_01_e_a+3_03_e_a]","4_14_b@12,7[4_08_e_a+3_04_e_b]","1_01_b@11,3[2_01_e_a]","4_09_a@11,8","3_21_b@10,3[6_02_e_a+5_01_e_a]","2_13_b@10,7[4_04_e_a+4_02_e_a]","6_17_a@10,8","6_01_a@9,8[3_07_e_a]"],
"bonds": ["arcaneShip 0/2","deputShip 0/14","kazimierzShip 0/8","lateranoShip 1/93","miraShip 1/0","preciShip 2/109","sargonShip 0/2","siracusaShip 0/6","skillfulShip 1/43","soloShip 1/5","suntShip 1/12","swiftShip 1/121"]
}
},
"standIns": {
@@ -1628,11 +1628,11 @@
[6,"3_12_a→char_608_acpion"],
[7,"3_12_a→char_608_acpion"],
[8,"3_12_a→char_608_acpion"],
[9,"3_12_a→char_608_acpion 5_20_a→char_614_acsupo"],
[10,"3_12_a→char_608_acpion 5_20_a→char_614_acsupo"],
[11,"3_12_a→char_608_acpion 5_20_a→char_614_acsupo"],
[12,"3_12_a→char_608_acpion 5_20_a→char_614_acsupo"],
[13,"3_12_a→char_608_acpion 5_20_a→char_614_acsupo"]
[9,"3_12_a→char_608_acpion"],
[10,"3_12_a→char_608_acpion"],
[11,"3_12_a→char_608_acpion 4_16_a→char_615_acspec"],
[12,"3_12_a→char_608_acpion"],
[13,"3_12_b→char_608_acpion 4_16_a→char_615_acspec"]
]
}
},
+54 -54
View File
@@ -1010,71 +1010,71 @@
"round": 12,
"seed": 3545615126,
"waveId": "act1autochess_h05",
"time": 226.4,
"ticks": 6792,
"reason": "cleared",
"killed": 104,
"time": 230,
"ticks": 6900,
"reason": "timeout",
"killed": 105,
"total": 126,
"unspawned": 0,
"bossHpLeft": null,
"rngDraws": 605,
"rngDraws": 633,
"errors": {"count":0,"kinds":[]},
"players": {
"p1": {
"killed": 104,
"killed": 105,
"total": 126,
"perfect": false,
"leaks": 23,
"leaks": 22,
"leaksUncounted": 0,
"coins": 10,
"dmg": 4885870,
"dmg": 5000855,
"bossDmg": 0,
"heal": 80735,
"deaths": 7,
"heal": 92236,
"deaths": 4,
"layerGains": {"preciShip":4}
}
},
"units": [
["p1",1,"op","chess_char_5_19_b",2,"uniequip_002_mlynar",4297.7,1,130,8,555859,4578,280,79,4],
["p1",2,"op","chess_char_6_03_a",2,null,2973.3,1,122,1,90690,18321,9211,67,2],
["p1",3,"op","chess_char_6_06_a",2,null,2445.3,1,135,8,553051,18507,837,91,2],
["p1",4,"op","chess_char_5_17_b",1,"uniequip_002_f12yin",3011.8,1,8,8,248468,28911,11557,331,1],
["p1",5,"op","chess_char_5_20_b",2,"uniequip_002_aglina",1520.2,1,10,2,160362,0,0,48,5],
["p1",6,"op","chess_char_5_21_b",1,"uniequip_002_sntlla",1791.9,1,85,1,369795,3362,889,125,3],
["p1",7,"op","chess_char_5_22_b",1,"uniequip_002_nymph",1772.1,1,128,1,4914,2027,255,5,3],
["p1",8,"op","chess_char_5_23_b",1,"uniequip_002_reckpr",1771,1,127,0,0,4860,52445,66,4],
["p1",9,"op","chess_char_6_01_a",2,null,1273.8,1,5,43,1148844,1462,452,98,5],
["p1",10,"op","chess_char_6_02_a",2,null,1855.7,1,7,27,1342901,1863,429,73,3],
["p1",11,"op","chess_char_6_04_a",2,null,1322.2,1,9,0,96272,9110,2272,0,4],
["p1",12,"op","chess_char_6_05_a",2,null,1336.5,1,59,3,314713,3845,2107,69,5],
["p1",1,"op","chess_char_5_19_b",2,"uniequip_002_mlynar",4297.7,1,2,18,602485,3572,935,94,5],
["p1",2,"op","chess_char_6_03_a",2,null,4459.95,1,4,1,125610,20086,15136,82,3],
["p1",3,"op","chess_char_6_06_a",2,null,2445.3,1,139,7,554612,19329,583,71,2],
["p1",4,"op","chess_char_5_17_b",1,"uniequip_002_f12yin",3011.8,1,8,9,204346,33430,14620,337,1],
["p1",5,"op","chess_char_5_20_b",2,"uniequip_002_aglina",1520.2,1,10,2,144300,0,0,44,5],
["p1",6,"op","chess_char_5_21_b",1,"uniequip_002_sntlla",1791.9,1,85,1,319504,3362,889,122,3],
["p1",7,"op","chess_char_5_22_b",1,"uniequip_002_nymph",1772.1,1,1,1,13078,1242,569,7,3],
["p1",8,"op","chess_char_5_23_b",1,"uniequip_002_reckpr",1771,1,3,0,0,1352,54601,78,4],
["p1",9,"op","chess_char_6_01_a",2,null,1273.8,1,5,38,1233629,1462,645,97,5],
["p1",10,"op","chess_char_6_02_a",2,null,1855.7,1,7,23,1379878,2121,288,76,3],
["p1",11,"op","chess_char_6_04_a",2,null,1322.2,1,9,3,102446,10462,1863,0,4],
["p1",12,"op","chess_char_6_05_a",2,null,1336.5,1,59,2,320966,3845,2107,69,5],
["p1",13,"token","token_10017_skadi2_dedant",null,null,100,0,13,0,0,0,0,0,null],
["p1","~1","token","token_10017_skadi2_dedant",null,null,100,0,60,0,0,0,0,0,null],
["p1","~2","token","token_10017_skadi2_dedant",null,null,100,0,101,0,0,0,0,0,null],
["p1","~3","token","token_10017_skadi2_dedant",null,null,100,0,144,0,0,0,0,0,null],
["p1","~4","token","token_10017_skadi2_dedant",null,null,100,1,152,0,0,0,0,0,null]
["p1","~3","token","token_10017_skadi2_dedant",null,null,100,0,141,0,0,0,0,0,null],
["p1","~4","token","token_10017_skadi2_dedant",null,null,100,1,149,0,0,0,0,0,null]
],
"enemies": {
"enemy_10045_parrot": [1,0,1,0,59936],
"enemy_10098_crhro": [1,1,0,8773,185752],
"enemy_10124_uashld_2": [12,9,3,44779,1527741],
"enemy_10045_parrot": [1,0,1,0,53667],
"enemy_10098_crhro": [1,0,1,6535,161143],
"enemy_10124_uashld_2": [12,8,3,46823,1543522],
"enemy_1019_jshoot_2": [1,1,0,1629,16718],
"enemy_1043_zomsbr": [72,61,11,66,1082509],
"enemy_1061_zomshd": [30,25,5,9861,1798745],
"enemy_1043_zomsbr": [72,62,10,101,1105813],
"enemy_1061_zomshd": [30,26,4,12386,1814925],
"enemy_1072_dlancer": [1,1,0,271,37150],
"enemy_1165_duhond": [1,1,0,0,17337],
"enemy_1165_duhond": [1,1,0,86,17337],
"enemy_1196_msfyin_2": [1,1,0,0,9],
"enemy_1209_sfden_2": [1,1,0,2344,111451],
"enemy_1210_msfden_2": [1,0,1,0,13],
"enemy_1273_stmgun_2": [1,0,1,23833,36689],
"enemy_1209_sfden_2": [1,1,0,781,111451],
"enemy_1210_msfden_2": [1,0,1,0,36],
"enemy_1273_stmgun_2": [1,1,0,26361,111451],
"enemy_1355_mrfly": [1,1,0,0,37150],
"enemy_1433_dsbasi_2": [1,1,0,0,35293],
"enemy_2001_duckmi_2": [1,1,0,0,37150],
"enemy_9006_actoxi": [1,0,1,0,5530]
"enemy_9006_actoxi": [1,0,1,0,25174]
},
"hooks": [1,151,6792,1051,1131,7506,7729,62877,111,138,41,38,29,39389,1162,40,127,23,1,120,2,9,8],
"events": {"atk":2382,"bounty":5,"deploy":24,"die":115,"dmg":7649,"engage":10,"fx":578,"heal":102,"layer":2,"leak":23,"skill":79,"spawn":144,"status":2786},
"fx": {"aoe":7,"bombard":29,"bombardShell":29,"burst":9,"crit":56,"dodge":8,"explode":29,"firewall":2,"garrison":2,"iceSpike":99,"lightning":127,"lock":29,"lure":3,"revive":1,"snow":75,"sp":2,"storm":5,"summon":1,"talent":1,"telegraph":8,"tide":4,"wanted":47,"weightless":5},
"snaps": "fe3def4e d483c136 ee2f3c0d 07a1e547 9d76e528 885dce23 cc4899ef 0f9faea8 eff043fe 32300c6f 2224c6d6 e3df2d18 9d122a08 27bee743 0d07fc4f f4421c12 c5ec3c4a fe540660 e802505f c9d74e16 6b47afbe e6a0a4fc 8246b490"
"hooks": [1,148,6900,1084,1158,7800,8054,66853,109,134,43,38,29,40854,1160,49,127,21,1,125,2,12,7],
"events": {"atk":2485,"bounty":5,"deploy":21,"die":113,"dmg":7974,"engage":10,"fx":604,"heal":118,"layer":2,"leak":21,"skill":81,"spawn":144,"status":2763},
"fx": {"aoe":8,"bombard":29,"bombardShell":29,"burst":12,"crit":66,"dodge":7,"explode":30,"firewall":3,"garrison":2,"iceSpike":99,"lightning":139,"lock":29,"lure":3,"revive":1,"snow":74,"soul":1,"sp":1,"storm":5,"summon":1,"talent":1,"telegraph":7,"tide":4,"wanted":48,"weightless":5},
"snaps": "fe3def4e 60ce0e98 68e4eff1 0ab18079 e3445176 db32929f bf23ff70 1142d3b6 bc663bbd dde23a5f 8df8a873 85d69b81 c70cdc4f 94132718 683de6ff 1eccc1af f1c61b7b 2bf8e444 50898a85 e25629e3 913e8ab6 2f3a3189 fa6f8200"
},
"roster-017": {
"about": "pass 0 band 6: chess_char_6_15_a chess_char_6_19_a chess_char_6_07_a chess_char_6_17_a chess_char_6_08_a chess_char_6_09_a chess_char_6_11_a chess_char_6_12_a chess_char_6_13_a chess_char_6_14_a chess_char_6_16_a chess_char_6_18_a",
@@ -2195,7 +2195,7 @@
"hooks": [1,142,5560,344,344,550,908,981,125,128,57,54,6,14497,442,7,125,0,1,126,2,25,0],
"events": {"atk":520,"bounty":4,"deploy":17,"die":128,"dmg":908,"engage":6,"fx":229,"heal":14,"layer":2,"skill":111,"spawn":142,"status":726},
"fx": {"bombard":2,"bombardShell":2,"burst":25,"copy":1,"expose":118,"frostNova":1,"garrison":2,"heal":3,"phase":1,"pulse":2,"shieldBreak":1,"snow":14,"soul":2,"sp":39,"talent":1,"wanted":15},
"snaps": "1fd81580 e8b29c39 8fe3be2e 22869cd5 83901ef4 36107212 007246d4 0fbda2dd d9aefee2 c69d102c cac8d601 30f7a8ad 606c1bdc d241a31d d8ce3067 1c139be5 14565097 4a7b95ab ecf99e3e"
"snaps": "1fd81580 e8b29c39 8fe3be2e 22869cd5 83901ef4 e9e63622 d75e0fd3 81f144f2 93d80511 36f4b653 603069de 9337b18e 35d5c75b f92e33e2 ae44a508 733b9e86 63b04f00 a1c2df3c f8192841"
},
"roster-036": {
"about": "pass 1 band 7: chess_char_6_03_b chess_char_6_15_a chess_char_6_19_a chess_char_6_17_a chess_char_6_12_a chess_char_6_13_a chess_char_6_14_a chess_char_6_16_a chess_char_6_18_a chess_char_6_20_a chess_char_6_01_b chess_char_6_02_b",
@@ -2262,7 +2262,7 @@
"hooks": [1,157,6221,639,638,1374,1624,8342,92,147,56,54,59,23902,3684,26,130,55,1,92,2,4,0],
"events": {"atk":680,"bounty":2,"deploy":24,"die":92,"dmg":1417,"engage":9,"fx":644,"heal":524,"layer":2,"leak":55,"skill":110,"spawn":145,"status":871},
"fx": {"airstrike":15,"aoe":3,"burst":4,"displace":2,"explode":16,"expose":460,"frostNova":17,"garrison":2,"healField":15,"link":1,"phase":1,"snow":37,"sunBurst":2,"talent":7,"telegraph":2,"wanted":60},
"snaps": "352a6117 5197ba48 10f9a2ae 33f679df 3665a8ee 49550c0d 3f360174 ce1f9cce 89c5196c 39a34bf7 31facd72 20cbae52 17caf576 03e5ee75 5a29cf38 cd4bf8d3 6a8928c3 7ccff12b 527fbb07 bf017b13 a9045a61"
"snaps": "352a6117 5197ba48 22ff25c2 16105f43 67bd4112 04c5a2b1 e4e63a95 9087d64d 41e697b3 8e3b24fc fa002205 4a75e62c 71798688 e3c0247f 3b14b2c2 04aa6c52 2d90b642 4764ab7a 541b8b1e 598e0a94 ca28421b"
},
"roster-037": {
"about": "pass 1 band 7: chess_char_6_06_b chess_char_6_15_b chess_char_6_07_b chess_char_6_04_b chess_char_6_05_b chess_char_6_08_b chess_char_6_09_b chess_char_6_11_b chess_char_6_12_b chess_char_6_13_b chess_char_6_14_b chess_char_6_16_b token_10017_skadi2_dedant token_10030_mlyss_wtrman",
@@ -2790,9 +2790,9 @@
"leaks": 0,
"leaksUncounted": 0,
"coins": 8,
"dmg": 1238376,
"dmg": 1238096,
"bossDmg": 0,
"heal": 9730,
"heal": 9576,
"deaths": 0,
"layerGains": {"lateranoShip":12,"preciShip":8}
}
@@ -2801,15 +2801,15 @@
["p1",1,"op","chess_char_6_03_a",1,null,5054.61,1,7,0,0,0,0,0,0],
["p1",2,"op","chess_char_6_06_a",1,null,2445.3,1,9,1,1,0,0,1,1],
["p1",3,"op","chess_char_6_07_a",1,null,2420,1,10,0,0,0,0,0,0],
["p1",4,"op","chess_char_6_01_a",1,null,1273.8,1,4,26,302286,0,0,36,3],
["p1",4,"op","chess_char_6_01_a",1,null,1273.8,1,4,31,302479,0,0,39,3],
["p1",5,"op","chess_char_6_02_a",1,null,1855.7,1,5,0,0,0,0,0,0],
["p1",6,"op","chess_char_6_04_a",1,null,1322.2,1,11,0,0,0,0,0,1],
["p1",7,"op","chess_char_6_05_a",1,null,1336.5,1,13,5,85739,0,0,19,2],
["p1",7,"op","chess_char_6_05_a",1,null,1336.5,1,13,5,87114,0,0,19,2],
["p1",8,"op","chess_char_6_08_a",1,null,1497.1,1,6,0,0,0,0,0,0],
["p1",9,"op","chess_char_6_09_a",2,null,1029.6,1,14,11,378876,864,864,111,2],
["p1",10,"op","chess_char_6_11_a",1,null,1634.6,1,12,6,173885,0,0,95,3],
["p1",11,"op","chess_char_6_12_a",2,null,4367.19,1,15,18,228756,5384,5403,45,2],
["p1",12,"op","chess_char_6_13_a",2,null,1892,1,8,1,68833,3462,3462,0,0],
["p1",9,"op","chess_char_6_09_a",2,null,1029.6,1,14,11,371732,711,711,110,2],
["p1",10,"op","chess_char_6_11_a",1,null,1634.6,1,12,7,173044,0,0,95,3],
["p1",11,"op","chess_char_6_12_a",2,null,4367.19,1,15,13,223002,5384,5403,46,2],
["p1",12,"op","chess_char_6_13_a",2,null,1892,1,8,0,80724,3462,3462,0,0],
["p1",13,"token","token_10017_skadi2_dedant",null,null,100,0,16,0,0,0,0,0,null],
["-","~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],
@@ -2825,18 +2825,18 @@
"enemy_10073_mpcar": [1,1,0,0,3110],
"enemy_1023_jmage": [1,1,0,1159,3421],
"enemy_1045_hammer_2": [12,12,0,6631,373248],
"enemy_1061_zomshd": [48,48,0,940,788876],
"enemy_1158_divman": [1,1,0,285,5132],
"enemy_1061_zomshd": [48,48,0,940,788596],
"enemy_1158_divman": [1,1,0,131,5132],
"enemy_1199_sfjin": [1,1,0,0,10109],
"enemy_1200_msfjin": [1,1,0,0,47],
"enemy_1270_nhstlk_2": [1,1,0,0,24883],
"enemy_1402_tgshd_2": [1,1,0,54,20218],
"enemy_2001_duckmi_2": [1,1,0,0,9331]
},
"hooks": [1,90,5499,356,338,701,1128,8573,68,74,14,12,41,25781,515,20,68,0,1,68,10,51,0],
"events": {"atk":361,"bounty":4,"deploy":19,"die":74,"dmg":945,"engage":6,"fx":193,"layer":10,"skill":26,"spawn":90,"status":1102},
"fx": {"airstrike":12,"burst":51,"crit":28,"dp":3,"expose":6,"garrison":6,"link":1,"lock":28,"snow":5,"summon":3,"summonStun":3,"talent":1,"wanted":46},
"snaps": "a85a8369 0a33b2f2 724f8cee 96cdeaea 2a60b953 251ce27e ef31d396 4fd327c7 e1fcf4f3 2413fe96 23f1c1b9 e60d68c2 414b5196 26676467 18daab40 3c5e3c0d 5fcd0834 02715784 017438eb"
"hooks": [1,90,5499,357,339,694,1119,8499,68,74,14,12,41,25781,509,19,68,0,1,68,10,50,0],
"events": {"atk":362,"bounty":4,"deploy":19,"die":74,"dmg":936,"engage":6,"fx":192,"layer":10,"skill":26,"spawn":90,"status":1088},
"fx": {"airstrike":12,"burst":50,"crit":28,"dp":3,"expose":6,"garrison":6,"link":1,"lock":28,"snow":5,"summon":3,"summonStun":3,"talent":1,"wanted":46},
"snaps": "a85a8369 6b6410a1 1af1f862 32a99902 bdbc1150 56475174 2f96ad8a 1db64d03 21d24dd9 2db9fdb3 1c302c59 053458d5 efcdeffa 11f04efa c2d50d10 498289e0 092a22da 8661ea58 38a6f6ff"
},
"roster-046": {
"about": "pass 2 band 7: chess_char_6_03_b chess_char_6_15_a chess_char_6_19_a chess_char_6_17_a chess_char_6_14_a chess_char_6_16_a chess_char_6_18_a chess_char_6_20_a chess_char_6_01_b chess_char_6_02_b chess_char_6_04_b chess_char_6_05_b token_10017_skadi2_dedant",
+98
View File
@@ -0,0 +1,98 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import * as BE from '../../server/match/botEmotes.js';
import { EMOTE_COOLDOWN_MS } from '../../shared/constants.js';
import { makeMatch, give, giveItem, DATA } from './harness.js';
const E = (name) => `autochess_battle_${name}`;
function fixture() {
const human = { playerId: 'human', isBot: false, alive: true };
const ai = { playerId: 'ai', isBot: true, alive: true, lastEmoteAt: -Infinity };
const other = { playerId: 'other', isBot: true, alive: true, lastEmoteAt: -Infinity };
const messages = [];let now = 10000;
const m = { round: 1, order: [human, ai, other], lastResults: new Map(), sched: { now: () => now },
alivePlayers: () => m.order.filter((p) => p.alive), broadcast: (x) => messages.push(x) };
return { m, human, ai, other, messages, advance: () => { now += EMOTE_COOLDOWN_MS + 1; } };
}
test('AI emotes use the existing whitelist and cooldown, never impersonate a human', () => {
const { m, ai, human, messages, advance } = fixture();
assert.equal(BE.sendEmote(m, human, E('thanks')), false);
assert.equal(BE.sendEmote(m, ai, 'bad'), false);
assert.equal(BE.sendEmote(m, ai, E('thanks')), true);
assert.equal(BE.sendEmote(m, ai, E('thanks')), false);
advance();assert.equal(BE.sendEmote(m, ai, E('thanks')), true);
ai.alive = false;advance();assert.equal(BE.sendEmote(m, ai, E('thanks')), false);
assert.equal(messages.length, 2);
});
test('one eligible AI answers a human, cooling seats are skipped; AI messages do not recurse', () => {
const { m, human, ai, other, messages } = fixture();ai.lastEmoteAt = m.sched.now();
assert.equal(BE.onHumanEmote(m, human.playerId, E('thanks')), true);
assert.deepEqual(messages.map((x) => x.playerId), [other.playerId]);
assert.equal(BE.onHumanEmote(m, ai.playerId, E('thanks')), false);
assert.equal(BE.onHumanEmote(m, 'unknown', E('thanks')), false);
});
test('pure AI and solo games stay silent at every entry point', () => {
for (const pureAI of [true, false]) {
const { m, human, ai, messages } = fixture();m.order = pureAI ? [ai] : [human];
BE.sendEmote(m, ai, E('thanks'));BE.onHumanEmote(m, human.playerId, E('thanks'));
BE.onSettle(m);BE.onPickCard(m, ai, { kind: 'bounty' });BE.onStartUnite(m, { helpers: [ai] });
BE.onGiftTicker(m, ai, human.playerId);for (let n=0;n<3;n++) BE.onMerge(m, ai, { kind: 'chess' });
assert.deepEqual(messages, []);
}
});
test('settlement uses this round, breaks kill ties by damage and cannot repeat after cooldown', () => {
const { m, ai, other, messages, advance } = fixture();
ai.stats = { kills: 999 };other.stats = { kills: 0 };
m.lastResults.set(ai.playerId, { killed: 2, damageDealt: 10, leaked: [{ counted: true }], perfect: false });
m.lastResults.set(other.playerId, { killed: 2, damageDealt: 20, leaked: [], perfect: true });
assert.equal(BE.pickSettleEmote(m, ai), E('scared'));assert.equal(BE.pickSettleEmote(m, other), E('call'));
assert.equal(BE.onSettle(m), true);advance();assert.equal(BE.onSettle(m), false);
assert.equal(messages.length, 2);m.round++;assert.equal(BE.onSettle(m), true);
});
test('third chess merge reacts once per round, not to items or every later merge', () => {
const { m, ai, messages, advance } = fixture();
BE.onMerge(m, ai, { kind: 'item' });
for(let n=0;n<8;n++) { BE.onMerge(m, ai, { kind: 'chess' });advance(); }
assert.equal(messages.length, 1);BE.resetRoundCounters(m);
for(let n=0;n<3;n++) { BE.onMerge(m, ai, { kind: 'chess' });advance(); }
assert.equal(messages.length, 2);
});
test('gift reactions identify the source player rather than an operator display name', () => {
const { m, ai, human, other, messages } = fixture();
assert.equal(BE.onGiftTicker(m, ai, '琳琅诗怀雅'), false);
assert.equal(BE.onGiftTicker(m, ai, other.playerId), false);
assert.equal(BE.onGiftTicker(m, ai, human.playerId), true);
assert.equal(messages.length, 1);
});
test('environment switch suppresses every emote entry point', () => {
const r = spawnSync(process.execPath, ['--input-type=module', '-e', `
import {sendEmote} from './server/match/botEmotes.js';
const ai={playerId:'ai',isBot:true,alive:true};
if(sendEmote({order:[ai,{isBot:false}]},ai,'autochess_battle_thanks')) process.exit(2);
`], { env: { ...process.env, SP_BOT_EMOTES: '0' }, encoding: 'utf8' });
assert.equal(r.status, 0, r.stderr);
});
test('actual match emote and merge hooks broadcast without errors', (t) => {
const h = makeMatch({ mode: 'coop', humans: 1, bots: 1, seed: 1, fake: true }).start().toPrep();t.after(() => h.m.dispose());
const [human, ai] = h.m.order;human.lastEmoteAt = ai.lastEmoteAt = -Infinity;
assert.equal(h.m.emote(human, E('thanks')).ok, true);
const c = Object.values(DATA.chess).find((c) => c.visible && !c.isGolden && c.tier === 1);
for (let n=0;n<3;n++) ai.acquireChess(c.chessId);
assert.equal(h.m._botEmotesMerges.get(ai.playerId), 1);
});
test('real beacon delivery from a human makes the receiving bot thank the correct player', (t) => {
const h = makeMatch({ mode: 'coop', humans: 2, seed: 31, fake: true }).start().toPrep();
t.after(() => h.m.dispose());
const [human, ai] = h.m.order;
ai.isBot = true; ai.lastEmoteAt = -Infinity;
const c = Object.values(DATA.chess).find((c) => c.visible && !c.isGolden && c.tier === 2 && c.bonds.length);
const carrier = give(h.m, human, c.chessId, 'hand');
const item = giveItem(h.m, human, 'chess_item_5_04_e_a');
assert.equal(h.m.handle(human.playerId, { t: 'g.equip', itemUid: item.uid, targetUid: carrier.uid }).ok, true);
h.m.round = 2;
h.m.dispatch(human, 'onRoundStart', { round: 2 });
assert.ok(h.bc.filter((e) => e.t === 'm.emote').some((e) => e.playerId === ai.playerId && e.id === E('thanks')));
assert.equal(human.effects.filter((e) => e.key === 'effect:builtin_gift').length, 0);
assert.equal(h.m.errorCount, 0);
});
+63 -1
View File
@@ -7,12 +7,74 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { PHASE } from '../../shared/constants.js';
import { botPickCard, bountyKillChance, itemTarget, arrange, botPrepBegin, botPrepEnd, bondPlan, rangeTiles, effDps, rangeRec } from '../../server/match/bot.js';
import { botPickCard, bountyKillChance, itemTarget, arrange, botPrepBegin, botPrepEnd, bondPlan, rangeTiles, effDps, rangeRec, buyScoreOf } from '../../server/match/bot.js';
import { parseKey, tileKey } from '../../server/match/board.js';
import { makeMatch, checkInvariants, give, giveItem, legalTileFor, DATA } from './harness.js';
const soloBot = (o = {}) => makeMatch({ mode: 'solo', difficulty: 'NORMAL', seats: [{ seat: 0, playerId: 'ai_0', name: 'AI', isBot: true, connected: true }], ...o });
test('user: Given a faction strategy teammate, When scoring shared high-tier chess, Then humans and bots receive the same preference even with full stock', () => {
const { m } = makeMatch({ humans: 3, seed: 11 }).start().toPrep();
const [ps, mate, other] = m.order;
try {
mate.bandId = other.bandId = null;
ps.shop.level = 6;
// 贾维 names 叙拉古; 铃兰 (V) and 荒芜拉普兰德 (VI) both belong to it.
for (const id of ['chess_char_5_10_a', 'chess_char_6_18_a']) {
mate.isBot = false;
const ordinary = buyScoreOf(m, ps, id);
mate.bandId = 'band_chiave';
const cooperative = buyScoreOf(m, ps, id);
assert.ok(cooperative < ordinary, `${m.gd.chess(id).name}: leave faction stock for the strategy`);
mate.isBot = true;
assert.equal(buyScoreOf(m, ps, id), cooperative, 'bot teammates count equally');
other.bandId = 'band_chiave';
assert.equal(buyScoreOf(m, ps, id), cooperative, 'multiple teammates do not multiply the penalty');
mate.alive = other.alive = false;
assert.equal(buyScoreOf(m, ps, id), ordinary, 'eliminated players no longer need cards');
mate.alive = other.alive = true;
other.bandId = null;
m.gd.modeInactiveBonds.add('siracusaShip');
const inactive = buyScoreOf(m, ps, id);
mate.bandId = null;
assert.equal(buyScoreOf(m, ps, id), inactive, 'inactive faction does not trigger yielding');
m.gd.modeInactiveBonds.delete('siracusaShip');
}
const unaffected = Object.values(DATA.chess).filter((c) => c.visible && !c.isGolden &&
(c.tier < 5 || !c.bonds.includes('siracusaShip')));
for (const c of unaffected) {
mate.bandId = null;
const ordinary = buyScoreOf(m, ps, c.chessId);
mate.bandId = 'band_chiave';
assert.equal(buyScoreOf(m, ps, c.chessId), ordinary, c.name);
}
} finally { m.dispose(); }
});
test('user: Given a teammate faction strategy, When buying or taking a reward, Then prefer an alternative but retain an immediate own merge', () => {
const yu = 'chess_char_6_03_a';
const nearl = 'chess_char_6_17_a';
for (const { reward = false, ownPair = false, band = 'band_duyaoy', expected } of [
{ expected: nearl },
{ reward: true, expected: nearl },
{ ownPair: true, expected: yu },
{ band: null, expected: yu },
]) {
const { m } = makeMatch({ humans: 2, seed: 11 }).start().toPrep();
const [ps, mate] = m.order;
try {
mate.bandId = band;
if (ownPair) for (let i = 0; i < 2; i++) give(m, ps, yu, 'hand');
ps.shop.level = 6;
ps.funds = reward ? 0 : 5;
ps.shop.slots = reward ? [] : [yu, nearl].map((id) => ({ kind: 'chess', id, basePrice: 5, sold: false }));
if (reward) ps.pushRewardOffer('merge', { tier: 6, ids: [yu, nearl] });
botPrepBegin(m, ps);
assert.equal(reward ? ps.allChess()[0]?.id : ps.shop.slots.find((s) => s.sold)?.id, expected);
} finally { m.dispose(); }
}
});
/** A 悬赏决策 card as the draft builds it (choices.js buildCards), by its enemy's name. */
function bountyCard(enemyName) {
const c = DATA.choices.cards.bounty.find((x) => x.draft && DATA.enemies[x.enemyKey] && DATA.enemies[x.enemyKey].name === enemyName);
+2
View File
@@ -24,6 +24,8 @@ function randomIntent(rng, m, ps) {
const uid = () => (pieces.length && rng() < 0.8 ? rng.pick(pieces) : 1 + rng.int(5000));
const to = () => (rng() < 0.6 ? { area: 'board', row: rng() < 0.8 ? 9 + rng.int(4) : rng.int(19), col: rng() < 0.8 ? 2 + rng.int(9) : rng.int(21) } : { area: 'hand', idx: rng.int(10) });
switch (t) {
case 'g.infoReady': return { t, setupRevision: m.setupRevision ?? 0 };
case 'g.rerollVote': return { t, voteId: m.setupVote?.id ?? 1 + rng.int(10), agree: rng() < 0.5 };
case 'g.band': return { t, bandId: rng() < 0.8 ? rng.pick(BANDS) : rng.pick(['constructor', '__proto__', 'band_x', 'a'.repeat(64)]) };
case 'g.buy': return { t, slot: rng.int(16) };
case 'g.sell': case 'g.destroy': return { t, uid: uid() };
+124
View File
@@ -0,0 +1,124 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { startServer } from '../../server/index.js';
import { TestClient } from '../helpers/wsClient.js';
import { ERR } from '../../shared/constants.js';
const ok = async (c, msg) => {
const r = await c.request(msg);
assert.equal(r.t, 'ok', JSON.stringify(r));
};
const err = async (c, msg, code) => {
const r = await c.request(msg);
assert.equal(r.t, 'error', JSON.stringify(r));
assert.equal(r.code, code, JSON.stringify(r));
};
const setupOf = (pub) => ({ stageId: pub.stageId, bossId: pub.bossId, hiddenBossId: pub.hiddenBossId,
factions: pub.factions, disabledBonds: pub.disabledBonds, bannedChess: pub.bannedChess, setupRevision: pub.setupRevision });
test('real four-player sockets: host-only proposal, unanimous sync, reconnect and stale requests', { timeout: 15000 }, async (t) => {
const errors = [];
const srv = await startServer({ port: 0, host: '127.0.0.1', seedFn: () => 123,
log: { info() {}, warn() {}, debug() {}, error: (...args) => errors.push(args.map(String).join(' ')) } });
const clients = [];
t.after(async () => {
await Promise.all(clients.map((c) => c.terminate()));
await srv.close();
assert.deepEqual(errors, []);
});
const player = async (name, token) => {
const c = await TestClient.connect(`ws://127.0.0.1:${srv.port}/ws`);
clients.push(c);
const w = await c.hello(name, token);
c.id = w.playerId;
c.token = w.token;
return c;
};
const host = await player('Host');
await ok(host, { t: 'room.create', mode: 'coop', difficulty: 'HARD' });
const room = await host.waitFor('room.state');
const guests = [];
for (let i = 0; i < 3; i++) {
const c = await player(`Guest${i}`);
guests.push(c);
await ok(c, { t: 'room.join', code: room.code });
await ok(c, { t: 'room.ready', ready: true });
}
const spectator = await player('Viewer');
await ok(spectator, { t: 'room.spectate', code: room.code });
await ok(host, { t: 'room.start' });
const initial = await host.waitFor('m.public', (p) => p.phase === 'INFO_CHECK');
const m = srv.lobby.rooms.get(room.code).match;
await err(guests[0], { t: 'room.rerollSetup', setupRevision: 0 }, ERR.NOT_HOST);
await err(spectator, { t: 'room.rerollSetup', setupRevision: 0 }, ERR.SPECTATOR);
await ok(guests[0], { t: 'g.infoReady', setupRevision: 0 });
await ok(host, { t: 'room.rerollSetup', setupRevision: 0 });
const voting = await host.waitFor('m.public', (p) => !!p.rerollVote);
assert.equal(voting.deadline, 0);
assert.equal(voting.rerollVote.voters.length, 4);
assert.deepEqual(voting.rerollVote.agreed, [host.id]);
const voteId = voting.rerollVote.id;
await err(spectator, { t: 'g.rerollVote', voteId, agree: true }, ERR.SPECTATOR);
await err(guests[0], { t: 'room.cancelReroll', voteId }, ERR.NOT_HOST);
await ok(guests[0], { t: 'g.rerollVote', voteId, agree: true });
await ok(guests[1], { t: 'g.rerollVote', voteId, agree: true });
assert.equal(m.setupRevision, 0, '3/4 agrees never refreshes');
await ok(guests[2], { t: 'g.rerollVote', voteId, agree: true });
const all = [host, ...guests, spectator];
const fresh = await Promise.all(all.map((c) => c.waitFor('m.public', (p) => p.setupRevision === 1)));
for (const p of fresh) {
assert.deepEqual(setupOf(p), setupOf(fresh[0]));
assert.equal(p.deadline, fresh[0].deadline, 'all players share the same new deadline');
assert.equal(Math.ceil((p.deadline - p.serverNow) / 1000), 25, 'each player sees a fresh 25-second countdown');
assert.equal(p.rerollVote, null);
assert.ok(p.players.every((ps) => !ps.ready));
}
assert.notDeepEqual(setupOf(fresh[0]), { ...setupOf(initial), setupRevision: 1 });
assert.equal(srv.lobby.rooms.get(room.code).match, m, 'room/match instance and sockets retained');
assert.ok(all.every((c) => c.isOpen));
await err(guests[0], { t: 'g.infoReady', setupRevision: 0 }, ERR.BAD_TARGET);
await err(guests[0], { t: 'g.infoReady' }, ERR.BAD_TARGET);
await err(host, { t: 'room.rerollSetup', setupRevision: 0 }, ERR.BAD_TARGET);
// Disconnect/reconnect retains this room and returns the current full setup.
const guest = guests[2];
await guest.terminate();
await host.waitFor('m.public', (p) => p.setupRevision === 1 && p.players.some((ps) => ps.playerId === guest.id && !ps.connected));
const resumed = await player('Guest2', guest.token);
assert.equal(resumed.id, guest.id);
assert.deepEqual(setupOf(await resumed.waitFor('m.public', (p) => p.setupRevision === 1)), setupOf(fresh[0]));
const restoredRoom = await resumed.waitFor('room.state');
assert.equal(restoredRoom.code, room.code);
for (const c of [host, guests[0], guests[1], resumed]) await ok(c, { t: 'g.infoReady', setupRevision: 1 });
await host.waitFor('m.public', (p) => p.phase === 'BAND_DRAFT');
await err(host, { t: 'room.rerollSetup', setupRevision: 1 }, ERR.WRONG_PHASE);
});
test('real sockets: reject/cancel/disconnect keep the setup and resume the countdown', { timeout: 15000 }, async (t) => {
const srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
const clients = [];
t.after(async () => { await Promise.all(clients.map((c) => c.terminate())); await srv.close(); });
for (const name of ['Host', 'Guest']) {
const c = await TestClient.connect(`ws://127.0.0.1:${srv.port}/ws`);
clients.push(c);
await c.hello(name);
}
const [host, guest] = clients;
await ok(host, { t: 'room.create', mode: 'coop', difficulty: 'NORMAL' });
const room = await host.waitFor('room.state');
await ok(guest, { t: 'room.join', code: room.code });
await ok(guest, { t: 'room.ready', ready: true });
await ok(host, { t: 'room.start' });
const initial = await host.waitFor('m.public', (p) => p.phase === 'INFO_CHECK');
const m = srv.lobby.rooms.get(room.code).match;
for (const finish of ['reject', 'cancel', 'disconnect']) {
await new Promise((resolve) => setTimeout(resolve, 310));
await ok(host, { t: 'room.rerollSetup', setupRevision: 0 });
const voting = await host.waitFor('m.public', (p) => !!p.rerollVote);
if (finish === 'reject') await ok(guest, { t: 'g.rerollVote', voteId: voting.rerollVote.id, agree: false });
if (finish === 'cancel') await ok(host, { t: 'room.cancelReroll', voteId: voting.rerollVote.id });
if (finish === 'disconnect') await guest.terminate();
await host.waitFor('m.public', (p) => !p.rerollVote && p.deadline > 0);
assert.equal(m.setupRevision, 0);
assert.deepEqual(setupOf(m.publicView()), setupOf(initial));
}
});
+233
View File
@@ -0,0 +1,233 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeMatch } from './harness.js';
import { ERR, PHASE } from '../../shared/constants.js';
import { validateC2S } from '../../shared/protocol.js';
import { createRng, deriveSeed } from '../../server/sim/rng.js';
import { setupMatchWaves } from '../../server/match/waves.js';
import { drawDisabledBonds } from '../../server/match/pool.js';
const snapshot = (m) => ({ stageId: m.stageId, bossId: m.bossId, hiddenBossId: m.hiddenBossId,
factions: [...m.factions], schedule: m.factions.schedule, bans: [...m.bannedChess], drawn: [...m.disabledBonds] });
const approve = (m, id, voteId = m.setupVote.id) => m.handle(id, { t: 'g.rerollVote', voteId, agree: true });
test('reroll with one second left gives everyone a fresh full countdown; old timeout cannot advance it', (t) => {
const h = makeMatch({ humans: 2 }).start();
t.after(() => h.m.dispose());
const m = h.m;
const fullMs = m.scaled(m.gd.timer('infoCheck') * 1000);
h.sched.advance(fullMs - 1000);
assert.equal(m.deadline - h.sched.now(), 1000);
assert.deepEqual(m.requestSetupReroll('p_0', 0), { ok: true });
h.sched.advance(5000);
assert.deepEqual(approve(m, 'p_1'), { ok: true });
const fresh = h.lastBc('m.public');
assert.equal(fresh.deadline - fresh.serverNow, fullMs);
assert.ok(fresh.players.every((p) => !p.ready));
h.sched.advance(fullMs - 1);
assert.equal(m.phase, PHASE.INFO_CHECK);
h.sched.advance(1);
assert.equal(m.phase, PHASE.BAND_DRAFT);
});
test('protocol requires setup revisions and vote identities; old initial ready remains supported', () => {
assert.equal(validateC2S({ t: 'room.rerollSetup', setupRevision: 0 }), null);
assert.equal(validateC2S({ t: 'room.cancelReroll', voteId: 1 }), null);
assert.equal(validateC2S({ t: 'g.rerollVote', voteId: 1, agree: false }), null);
assert.equal(validateC2S({ t: 'g.infoReady' }), null);
assert.equal(validateC2S({ t: 'g.infoReady', setupRevision: 2 }), null);
for (const msg of [{ t: 'room.rerollSetup' }, { t: 'room.rerollSetup', setupRevision: -1 },
{ t: 'room.rerollSetup', setupRevision: 0.5 }, { t: 'g.rerollVote', voteId: 0, agree: true },
{ t: 'g.rerollVote', voteId: 1 }, { t: 'g.rerollVote', voteId: 1, agree: 'yes' },
{ t: 'room.cancelReroll' }, { t: 'g.infoReady', setupRevision: -1 }]) {
assert.notEqual(validateC2S(msg), null, JSON.stringify(msg));
}
});
test('four humans must agree; rerolls the entire official setup and stock, preserves seats/settings/RNG', (t) => {
const h = makeMatch({ humans: 4, difficulty: 'HARD', seed: 123, spectators: ['viewer'] }).start();
t.after(() => h.m.dispose());
const m = h.m;
const before = snapshot(m);
const pool = m.pool;
const players = [...m.order];
const rngs = [m.rngSetup, m.rngShop, m.rngDraft, m.rngWaves, m.rngBots, m.rngMeta].map((r) => [r, r.state()]);
const p0 = h.ps('p_0');
assert.equal(p0.setDiy({ chess_char_5_diy1_a: { charId: 'char_017_huang', skillIndex: 0 } }), true);
p0.initDiyStock(new Set([...m.disabledBonds, ...m.staticInactiveBonds]));
const settings = [p0.loadout, p0.standIns, p0.diy];
const stock = p0.diyStock;
m.handle('p_1', { t: 'g.infoReady' });
h.sched.advance(500);
assert.deepEqual(m.requestSetupReroll('p_0', 0), { ok: true });
assert.deepEqual(m.setupVote.voters, ['p_0', 'p_1', 'p_2', 'p_3']);
assert.equal(m.deadline, 0);
assert.deepEqual(approve(m, 'p_1'), { ok: true });
assert.deepEqual(approve(m, 'p_2'), { ok: true });
h.sched.advance(30000);
assert.equal(m.phase, PHASE.INFO_CHECK);
assert.deepEqual(snapshot(m), before, 'nothing rerolled until everyone agrees');
assert.equal(m.handle('viewer', { t: 'g.rerollVote', voteId: m.setupVote.id, agree: true }).error, ERR.SPECTATOR);
assert.deepEqual(approve(m, 'p_3'), { ok: true });
assert.equal(m.setupRevision, 1);
assert.equal(m.setupVote, null);
const rng = createRng(deriveSeed(m.seed, 'reroll-setup:1'));
const setup = setupMatchWaves(m.gd, rng);
const bans = drawDisabledBonds(m.gd, rng);
assert.deepEqual(snapshot(m), { stageId: setup.stageId, bossId: setup.bossId, hiddenBossId: setup.hiddenBossId,
factions: [...setup.factions], schedule: setup.factions.schedule, bans: bans.banned, drawn: bans.drawn });
assert.notDeepEqual(snapshot(m), before);
assert.equal(m.stage, m.gd.stage(m.stageId));
assert.notEqual(m.pool, pool);
assert.deepEqual(m.order, players);
assert.deepEqual([p0.loadout, p0.standIns, p0.diy], settings);
assert.equal(p0.diy, settings[2]);
assert.notEqual(p0.diyStock, stock);
const off = new Set([...bans.drawn, ...bans.staticOff]);
const diy = p0.gd.chess('chess_char_5_diy1_a');
assert.equal(p0.diyBanned.includes(diy.chessId), diy.bonds.every((b) => off.has(b)));
assert.equal(p0.diyStock.has(diy.chessId), !p0.diyBanned.includes(diy.chessId));
for (const [r, state] of rngs) assert.equal(r.state(), state);
for (const id of m.gd.visibleChess) assert.equal(m.pool.has(id), !bans.banned.includes(id));
assert.ok(m.order.every((p) => !p.infoReady));
assert.equal(m.deadline, h.sched.now() + m.scaled(m.gd.timer('infoCheck') * 1000));
assert.equal(h.lastBc('m.public').setupRevision, 1);
h.invariants();
for (const p of m.order) assert.deepEqual(m.handle(p.playerId, { t: 'g.infoReady', setupRevision: 1 }), { ok: true });
h.sched.advance(0);
assert.equal(m.phase, PHASE.BAND_DRAFT);
});
test('reject/cancel restores exact remaining countdown and keeps setup and ready flags', (t) => {
const h = makeMatch({ humans: 2, timerScale: 0.5 }).start();
t.after(() => h.m.dispose());
const m = h.m;
const before = snapshot(m);
m.handle('p_1', { t: 'g.infoReady' });
h.sched.advance(1000);
const remaining = m.deadline - h.sched.now();
m.requestSetupReroll('p_0', 0);
h.sched.advance(10000);
assert.deepEqual(m.handle('p_1', { t: 'g.rerollVote', voteId: m.setupVote.id, agree: false }), { ok: true });
assert.deepEqual(snapshot(m), before);
assert.equal(m.setupRevision, 0);
assert.equal(h.ps('p_1').infoReady, true);
assert.equal(m.deadline, h.sched.now() + remaining, 'real remaining ms, no extra timer scaling');
m.requestSetupReroll('p_0', 0);
assert.deepEqual(m.cancelSetupReroll('p_0', m.setupVote.id), { ok: true });
assert.equal(m.deadline, h.sched.now() + remaining);
h.sched.advance(remaining - 1);
assert.equal(m.phase, PHASE.INFO_CHECK);
h.sched.advance(1);
assert.equal(m.phase, PHASE.BAND_DRAFT);
});
test('reroll cancels queued all-ready transition; stale ready and previous vote cannot affect a new setup', (t) => {
const h = makeMatch({ humans: 2 }).start();
t.after(() => h.m.dispose());
const m = h.m;
for (const p of m.order) m.handle(p.playerId, { t: 'g.infoReady' });
assert.ok(m._infoAdvanceTimer);
m.requestSetupReroll('p_0', 0);
const oldVote = m.setupVote.id;
assert.equal(m.handle('p_1', { t: 'g.infoReady' }).error, ERR.WRONG_PHASE);
approve(m, 'p_1');
h.sched.advance(0);
assert.equal(m.phase, PHASE.INFO_CHECK);
for (const msg of [{ t: 'g.infoReady' }, { t: 'g.infoReady', setupRevision: 0 }]) {
assert.equal(m.handle('p_1', msg).error, ERR.BAD_TARGET);
}
assert.equal(m.requestSetupReroll('p_0', 0).error, ERR.BAD_TARGET);
assert.equal(m.requestSetupReroll('p_0', 1).error, ERR.RATE);
h.sched.advance(300);
assert.deepEqual(m.requestSetupReroll('p_0', 1), { ok: true });
assert.equal(approve(m, 'p_1', oldVote).error, ERR.BAD_TARGET);
assert.equal(m.cancelSetupReroll('p_0', oldVote).error, ERR.BAD_TARGET);
assert.equal(m.setupVote.agreed.size, 1);
});
test('solo and one human with bots refresh immediately without a forced timer; fixed rules remain fixed', (t) => {
for (const mode of ['solo', 'coop']) {
const h = makeMatch({ mode, humans: 1, bots: 2, difficulty: 'FUNNY' }).start();
t.after(() => h.m.dispose());
const m = h.m;
const stage = m.stageId;
const staticOff = [...m.staticInactiveBonds];
assert.deepEqual(m.requestSetupReroll('p_0', 0), { ok: true });
assert.equal(m.stageId, stage);
assert.deepEqual(m.staticInactiveBonds, staticOff);
assert.equal(m.deadline, 0);
assert.equal(m.setupRevision, 1);
assert.equal(m.setupVote, null);
assert.equal(h.ps('p_0').infoReady, false);
assert.ok(m.order.filter((p) => p.isBot).every((p) => p.infoReady));
m.handle('p_0', { t: 'g.infoReady', setupRevision: 1 });
h.sched.advance(0);
assert.equal(m.phase, PHASE.BAND_DRAFT);
assert.equal(m.requestSetupReroll('p_0', 1).error, ERR.WRONG_PHASE);
}
});
test('disconnect and leave cancel rather than count as consent; reconnect sees current vote/setup', (t) => {
const h = makeMatch({ humans: 3 }).start();
t.after(() => h.m.dispose());
const m = h.m;
const before = snapshot(m);
m.requestSetupReroll('p_0', 0);
const id = m.setupVote.id;
m.onReconnect('p_1');
assert.equal(h.lastTo('p_1', 'm.public').rerollVote.id, id);
m.onDisconnect('p_1');
assert.equal(m.setupVote, null);
assert.deepEqual(snapshot(m), before);
h.sched.advance(300);
assert.equal(m.requestSetupReroll('p_0', 0).error, ERR.NOT_READY);
m.onReconnect('p_1');
assert.equal(h.lastTo('p_1', 'm.public').rerollVote, null);
m.requestSetupReroll('p_0', 0);
m.onLeave('p_1');
assert.equal(m.setupVote, null);
assert.equal(m.setupRevision, 0);
h.sched.advance(300);
m.requestSetupReroll('p_0', 0);
assert.deepEqual(m.setupVote.voters, ['p_0', 'p_2']);
approve(m, 'p_2');
assert.equal(m.setupRevision, 1);
});
test('autoplay humans still explicitly consent; dispose stops a paused vote and invalid actors are refused', (t) => {
const h = makeMatch({ humans: 2, bots: 1 }).start();
t.after(() => h.m.dispose());
const m = h.m;
m.handle('p_1', { t: 'g.autoplay', on: true });
assert.equal(m.requestSetupReroll('ai_0', 0).error, ERR.NOT_IN_ROOM);
assert.equal(m.requestSetupReroll('stranger', 0).error, ERR.NOT_IN_ROOM);
m.requestSetupReroll('p_0', 0);
assert.equal(m.setupVote.agreed.size, 1);
h.sched.advance(30000);
assert.equal(m.setupRevision, 0);
assert.equal(m.phase, PHASE.INFO_CHECK);
m.dispose();
assert.equal(m.requestSetupReroll('p_0', 0).error, ERR.WRONG_PHASE);
h.sched.advance(30000);
assert.equal(m.setupRevision, 0);
});
test('failed DIY stock preparation leaves every opening field and player stock intact', (t) => {
const h = makeMatch({ humans: 2, difficulty: 'HARD' }).start();
t.after(() => h.m.dispose());
const m = h.m, before = snapshot(m), pool = m.pool, stage = m.stage;
const stocks = m.order.map((ps) => ps.diyStock), banned = m.order.map((ps) => ps.diyBanned);
h.sched.advance(1000);
const remaining = m.deadline - h.sched.now();
m.requestSetupReroll('p_0', 0);
const original = h.ps('p_1').buildDiyStock;
h.ps('p_1').buildDiyStock = () => { throw Error('stock preparation failed'); };
assert.equal(approve(m, 'p_1').error, ERR.INTERNAL);
h.ps('p_1').buildDiyStock = original;
assert.deepEqual(snapshot(m), before); assert.equal(m.pool, pool); assert.equal(m.stage, stage);
for (const [i, ps] of m.order.entries()) { assert.equal(ps.diyStock, stocks[i]); assert.equal(ps.diyBanned, banned[i]); }
assert.equal(m.setupRevision, 0); assert.equal(m.setupVote, null);
assert.equal(m.deadline - h.sched.now(), remaining);
assert.equal(h.logs.error.length, 1, 'failure is reported once');
});
+7 -1
View File
@@ -50,7 +50,7 @@ const M = {
audio: {
bgm: { prep: { loop: '/bgm/prep.mp3' }, lobby: { intro: '/bgm/i.mp3', loop: '/bgm/l.mp3' } },
bossBgm: { boss_1: { loop: '/bgm/b1.mp3' } },
sfx: { ui: { buy: '/sfx/buy.mp3' }, battle: { deploy: '/sfx/dep.mp3' }, units: { char_010_chen: { attack: '/sfx/c_atk.mp3', skills: { 2: '/sfx/c_s3.mp3' }, skill: '/sfx/c_s.mp3' } } },
sfx: { ui: { buy: '/sfx/buy.mp3' }, battle: { deploy: '/sfx/dep.mp3' }, units: { char_010_chen: { attack: '/sfx/c_atk.mp3', hit: '/sfx/c_hit.mp3', skills: { 2: '/sfx/c_s3.mp3' }, skill: '/sfx/c_s.mp3', attacks: { 2: '/sfx/c_atk_s3.mp3' }, hits: { 2: '/sfx/c_hit_s3.mp3' } } } },
},
};
@@ -134,6 +134,12 @@ describe('URL helpers (synthetic manifest)', () => {
assert.equal(unitSfxUrl(M, 'char_010_chen', 'skill', 2), '/sfx/c_s3.mp3');
assert.equal(unitSfxUrl(M, 'char_010_chen', 'skill', 0), '/sfx/c_s.mp3');
assert.equal(unitSfxUrl(M, 'char_010_chen_2', 'attack'), '/sfx/c_atk.mp3');
// the running skill's own attack sound (sfx.units[id].attacks, tools/assets/audio.mjs pickModeAttacks)
assert.equal(unitSfxUrl(M, 'char_010_chen', 'attack', 2), '/sfx/c_atk_s3.mp3');
assert.equal(unitSfxUrl(M, 'char_010_chen', 'attack', 0), '/sfx/c_atk.mp3', 'no attacks[0] ⇒ the normal bank');
assert.equal(unitSfxUrl(M, 'char_010_chen', 'hit', 2), '/sfx/c_hit_s3.mp3', 'the running skill\'s own impact');
assert.equal(unitSfxUrl(M, 'char_010_chen', 'hit', 1), '/sfx/c_hit.mp3', 'no hits[1] ⇒ the normal impact');
assert.equal(unitSfxUrl(M, 'char_010_chen', 'die', 2), null, 'only an attack / an impact has a per-skill file');
assert.equal(unitSfxUrl(M, 'nobody', 'attack'), null);
});
});
+34
View File
@@ -0,0 +1,34 @@
import { test, 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, UnitView;
before(async () => { fake = installFakePixi(); ({ UnitView } = await import('../../public/js/render/units.js')); });
after(() => fake.restore());
function rig(rate = 1) {
let resolve;
const entry = { skel: '/op.skel', atlas: '/op.atlas', textures: ['/op.png'], anims: { idle: 'Idle', deploy: 'Start', die: 'Die', attack: { loop: 'Attack' } }, animations: { Idle: 1, Start: 1, Die: 1, Attack: 1 } };
const assets = { spineEntry: () => entry, hasBack: () => false, spine: { acquire: () => new Promise((r) => { resolve = r; }), release() {} } };
const cam = presetCamera('prep', { width: 1280, height: 720 });
const ctx = fakeViewCtx(fake.P, { assets, cam: () => cam }); ctx.animRate = () => rate;
const v = new UnitView(ctx, { id: 1, side: 'ally', kind: 'chess', defId: 'char_op', x: 5, y: 12, maxHp: 1000, dir: 'RIGHT' }, { prep: true });
return { v, advance: (dt) => v.update(dt, cam, dt), ready: async () => { await new Promise((r) => setImmediate(r)); resolve({ animations: [{ name: 'Idle' }, { name: 'Start' }, { name: 'Die' }, { name: 'Attack' }] }); await new Promise((r) => setImmediate(r)); } };
}
test('late model continues the current deployment at elapsed game time', async () => {
const h = rig(2); h.v.onDeploy(); h.advance(0.1); await h.ready();
assert.equal(h.v.actor.mode, 'deploy'); assert.ok(Math.abs(h.v.actor.deployElapsed() - 0.2) < 1e-6); h.v.destroy();
});
test('model arriving after Start duration does not replay the old entrance', async () => {
const h = rig(); h.v.onDeploy(); h.advance(2); await h.ready();
assert.equal(h.v.actor.mode, 'base'); assert.equal(h.v._pendingDeployElapsed, null); h.v.destroy();
});
test('an attack or death supersedes the pending entrance', async () => {
for (const action of ['attack', 'die']) {
const h = rig(); h.v.onDeploy();
if (action === 'attack') h.v.onAttack(null, 1, 'none'); else h.v.die();
await h.ready(); assert.notEqual(h.v.actor.mode, 'deploy'); assert.equal(h.v._pendingDeployElapsed, null); h.v.destroy();
}
});
test('a destroyed view never consumes a late model as a fresh entrance', async () => {
const h = rig(); h.v.onDeploy(); h.v.destroy(); await h.ready(); assert.equal(h.v.actor, null);
});
+143 -1
View File
@@ -367,7 +367,7 @@ describe('operator battle voice', () => {
assert.equal(a.voiceLang, 'cn', 'no other dub: anything but jp is 中文');
// the settings store hands the choice over (installAudio and ui/settings.js)
const settings = readFileSync(path.join(ROOT, 'public/js/ui/settings.js'), 'utf8');
assert.match(settings, /audio\.setVoiceLang\(s\.voiceLang\)/);
assert.match(settings, /audio\.setVoiceLang\(s\.voiceLang, s\.voiceOverrides\)/);
assert.match(settings, /t\('语音语言'\)/);
} finally {
globalThis.fetch = origFetch;
@@ -870,6 +870,148 @@ describe('impact sounds (user playtest #4 item 6)', () => {
});
});
// 司霆惊蛰's S3 attack had no sound: she is 解放者 (she only attacks while a skill runs) and the official client has no
// normal-mode bank of hers at all — every attack of hers is a skill mode's (`attacks`, tools/assets/audio.mjs
// pickModeAttacks). 赤刃明霄陈's S3 slashes are the other half of the same idea: the mode's IMPACT differs too (`hits`,
// pickModeHits) — without it her S3 still landed with p_imp_hljdswd_n, i.e. it sounded like her normal attack. Both
// tables play only while that skill is active (the ['skill', id, 1] / 0 events).
describe('the running skill\'s own attack / impact sound (sfx.units[id].attacks / hits)', () => {
const LEIZI2 = 'char_1043_leizi2';
const CHEN3 = 'char_1050_chen3';
const enemyId = () => Object.keys(manifest.audio.sfx.units).find((k) => k.startsWith('enemy_') && manifest.audio.sfx.units[k].attack);
async function rig(units) {
const fw = fakeWindow();
const urls = [];
const origFetch = globalThis.fetch;
globalThis.fetch = async (u) => { urls.push(u); return { ok: true, arrayBuffer: async () => new ArrayBuffer(8) }; };
const a = new AudioManager({ win: fw.win, getManifest: () => manifest });
a.install();
fw.fire('pointerdown');
a.setFieldUnits(units);
const settle = () => new Promise((r) => setTimeout(r, 5));
return { a, urls, settle, restore: () => { globalThis.fetch = origFetch; } };
}
const clear = (a) => { a.limiter.lastByUnit.clear(); a.limiter.lastByUrl.clear(); };
// …and forget the decoded buffers too: these assertions count REQUESTS, and a sound the manager already holds is
// played from the cache without a second fetch (a real battle hears it either way).
const fresh = (a) => { clear(a); a.buffers.clear(); };
/** One attack plus the impact that belongs to it (the dmg inside IMPACT_WINDOW_MS). */
const swingAndHit = (a) => a.handleBattleEvents([['atk', 1, 2, 'none'], ['dmg', 2, 120, 'phys']]);
test('司霆惊蛰 S3: her attack rings the S3 mode\'s file while the skill runs, and only then', async () => {
const u = manifest.audio.sfx.units[LEIZI2];
assert.equal(u.attack, undefined, '前提:她没有普通形态的攻击音(这正是那次「没有音效」)');
assert.ok(u.attacks['2'] && /_s\.mp3$/.test(u.attacks['2']), '前提:清单带 S3 形态的攻击音');
const { a, urls, settle, restore } = await rig([
{ id: 1, side: 'ally', kind: 'chess', spine: LEIZI2, skillIndex: 2 }, { id: 2, side: 'enemy', kind: 'enemy', spine: enemyId() },
]);
try {
// 技能没开(解放者本就不攻击,模拟里也不会发 atk;真发了也仍然是静的)
a.handleBattleEvents([['atk', 1, 2, 'none']]);
await settle();
assert.equal(askedCount(urls, u.attacks['2']), 0, '没有技能就没有她的攻击音');
// S3 开启 → ['skill', id, 1]
a.handleBattleEvents([['skill', 1, 1]]);
await settle();
const before = askedCount(urls, u.attacks['2']);
a.handleBattleEvents([['atk', 1, 2, 'none']]);
await settle();
assert.equal(askedCount(urls, u.attacks['2']), before + 1, 'S3 期间她的攻击响 S3 的形态音');
assert.ok(!asked(urls, u.skill), '技能发动音是另一条事件(这次没发),别混进攻击音');
// 重复的 atk 会被同键冷却挡(既有口径,这里确认它没被新分支绕过)
a.handleBattleEvents([['atk', 1, 2, 'none']]);
await settle();
assert.equal(askedCount(urls, u.attacks['2']), before + 1, '同一单位 160 ms 内只响一次');
// S3 结束 → ['skill', id, 0]
clear(a);
a.handleBattleEvents([['skill', 1, 0]]);
a.handleBattleEvents([['atk', 1, 2, 'none']]);
await settle();
assert.equal(askedCount(urls, u.attacks['2']), before + 1, '技能结束后又静音');
} finally { restore(); }
});
test('a unit with both banks: the mode file inside the skill, its normal attack outside it', async () => {
const id = Object.keys(manifest.audio.sfx.units).find((k) => k.startsWith('char_') && manifest.audio.sfx.units[k].attack && manifest.audio.sfx.units[k].attacks?.['2']);
const u = manifest.audio.sfx.units[id];
const { a, urls, settle, restore } = await rig([
{ id: 1, side: 'ally', kind: 'chess', spine: id, skillIndex: 2 }, { id: 2, side: 'enemy', kind: 'enemy', spine: enemyId() },
]);
try {
a.handleBattleEvents([['atk', 1, 2, 'none']]);
await settle();
assert.equal(askedCount(urls, u.attack), 1, `${id}: 技能外是普通攻击音`);
assert.equal(askedCount(urls, u.attacks['2']), 0, `${id}: 技能外不响形态音`);
clear(a);
a.handleBattleEvents([['skill', 1, 1]]);
a.handleBattleEvents([['atk', 1, 2, 'none']]);
await settle();
assert.equal(askedCount(urls, u.attacks['2']), 1, `${id}: 技能期间响形态音`);
assert.equal(askedCount(urls, u.attack), 1, `${id}: 形态音取代、而不是叠加普通攻击音`);
} finally { restore(); }
});
test('a unit whose manifest has no attacks table for that slot keeps its normal bank (and no crash)', async () => {
const id = Object.keys(manifest.audio.sfx.units).find((k) => k.startsWith('char_') && manifest.audio.sfx.units[k].attack && !manifest.audio.sfx.units[k].attacks?.['2']);
const u = manifest.audio.sfx.units[id];
const { a, urls, settle, restore } = await rig([
{ id: 1, side: 'ally', kind: 'chess', spine: id, skillIndex: 2 }, { id: 2, side: 'enemy', kind: 'enemy', spine: enemyId() },
]);
try {
a.handleBattleEvents([['skill', 1, 1]]);
a.handleBattleEvents([['atk', 1, 2, 'none']]);
await settle();
assert.equal(askedCount(urls, u.attack), 1, `${id}: 没有形态音就用普通攻击音`);
} finally { restore(); }
});
test('赤刃明霄陈 S3: 挥砍与斩击都用 S3 的 `_s` 对,技能外回到普通 `_n` 对', async () => {
const u = manifest.audio.sfx.units[CHEN3];
assert.ok(u.attacks['2'] && u.hits['2'] && /_s\.mp3$/.test(u.attacks['2']) && /_s\.mp3$/.test(u.hits['2']), '前提:清单带 S3 的挥砍与斩击');
const { a, urls, settle, restore } = await rig([
{ id: 1, side: 'ally', kind: 'chess', spine: CHEN3, skillIndex: 2 }, { id: 2, side: 'enemy', kind: 'enemy', spine: enemyId() },
]);
try {
fresh(a);
swingAndHit(a);
await settle();
assert.equal(askedCount(urls, u.attack), 1, '技能外:普通挥砍');
assert.equal(askedCount(urls, u.hit), 1, '技能外:普通命中');
fresh(a);
a.handleBattleEvents([['skill', 1, 1]]); // S3 开启
swingAndHit(a);
await settle();
assert.equal(askedCount(urls, u.attacks['2']), 1, 'S3:挥砍换成 S3 的');
assert.equal(askedCount(urls, u.hits['2']), 1, 'S3:斩击(命中)也换成 S3 的');
assert.equal(askedCount(urls, u.attack), 1, 'S3:没有叠加普通挥砍');
assert.equal(askedCount(urls, u.hit), 1, 'S3:没有叠加普通命中');
fresh(a);
a.handleBattleEvents([['skill', 1, 0]]); // S3 结束
swingAndHit(a);
await settle();
assert.equal(askedCount(urls, u.attack), 2, '技能结束:普通挥砍回来了');
assert.equal(askedCount(urls, u.hit), 2, '技能结束:普通命中回来了');
assert.equal(askedCount(urls, u.hits['2']), 1, '技能结束:不再用 S3 的斩击');
} finally { restore(); }
});
test('a mode without a hits entry keeps the unit\'s normal impact while the skill runs', async () => {
const u = manifest.audio.sfx.units[LEIZI2];
assert.ok(u.attacks['2'] && !u.hits?.['2'], '前提:她的 S3 有挥砍、没有命中 bank(官方就没有)');
const { a, urls, settle, restore } = await rig([
{ id: 1, side: 'ally', kind: 'chess', spine: LEIZI2, skillIndex: 2 }, { id: 2, side: 'enemy', kind: 'enemy', spine: enemyId() },
]);
try {
a.handleBattleEvents([['skill', 1, 1]]);
swingAndHit(a);
await settle();
assert.equal(askedCount(urls, u.attacks['2']), 1, 'S3 的挥砍');
assert.equal(askedCount(urls, u.hit), 1, '命中仍是普通的那条');
} finally { restore(); }
});
});
// =====================================================================================================================
// 漏怪 sound (user request "接下来加漏怪的音效", then "应该是原版明日方舟关卡中的怪进蓝门的音效"). The sim emits
// `['leak', id]` when an enemy reaches its goal (Battle.leak) — NOT a `die` — so until now an escape was completely
+146
View File
@@ -0,0 +1,146 @@
// Real browser / server checks for local identity recovery, per-operator voice, install offers and large text.
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);
describe('0.2.3 follow-up client features', { skip: !ENABLED }, () => {
let server, browser, base;
before(async () => {
const { startServer } = await import('../../server/index.js');
const puppeteer = (await import('puppeteer-core')).default;
server = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
base = `http://127.0.0.1:${server.port}`;
browser = await puppeteer.launch({ executablePath: CHROME, headless: true, args: ['--no-sandbox'] });
});
after(async () => { await browser?.close(); await server?.close(); });
async function pageIn(ctx, phone = false) {
const page = await ctx.newPage();
await page.setViewport({ width: phone ? 844 : 1440, height: phone ? 390 : 900 });
await page.evaluateOnNewDocument(() => { localStorage.setItem('sp.name', 'Recovery Test'); sessionStorage.setItem('sp.entered', '1'); });
await page.goto(base, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => globalThis.__SP__?.store.get().connection.status === 'online' && document.querySelector('.lobby-screen'));
return page;
}
test('closed second window resumes the same server player and match through the recovery UI', async () => {
const ctx = await browser.createBrowserContext();
try {
const a = await pageIn(ctx), b = await pageIn(ctx);
const before = await b.evaluate(async () => {
const { net, store } = globalThis.__SP__;
await net.request('room.create', { mode: 'solo', difficulty: 'NORMAL' });
await net.request('room.start', {});
return { id: store.get().me.playerId, code: store.get().room.code };
});
await b.waitForFunction(() => globalThis.__SP__.store.get().match.public?.phase === 'INFO_CHECK');
await a.waitForSelector('[data-testid="resume-local-match"]');
const refused = await a.evaluate(async () => { const { identity } = await import('/js/net.js'); return identity.resume(identity.recoverable()[0].id); });
assert.equal(refused, false, 'live window is not displaced');
assert.equal(await b.evaluate(() => globalThis.__SP__.store.get().connection.status), 'online');
await b.close();
await a.click('[data-testid="resume-local-match"]');
await a.waitForSelector('.modal .btn--block');
await Promise.all([a.waitForNavigation({ waitUntil: 'domcontentloaded' }), a.click('.modal .btn--block')]);
await a.waitForFunction(() => globalThis.__SP__?.store.get().match.public?.phase === 'INFO_CHECK');
const after = await a.evaluate(() => { const s = globalThis.__SP__.store.get(); return { id: s.me.playerId, code: s.room.code }; });
assert.deepEqual(after, before);
} finally { await ctx.close(); }
});
test('co-op opening reroll shows a unanimous vote, clears stale readiness and can be rejected', { timeout: 45000 }, async () => {
const ctx = await browser.createBrowserContext();
try {
const host = await pageIn(ctx), guest = await pageIn(ctx);
const code = await host.evaluate(async () => {
const { net, store } = globalThis.__SP__;
await net.request('room.create', { mode: 'coop', difficulty: 'NORMAL' });
return store.get().room.code;
});
await guest.evaluate(async (code) => {
const { net } = globalThis.__SP__;
await net.request('room.join', { code }); await net.request('room.ready', { ready: true });
}, code);
await host.evaluate(() => globalThis.__SP__.net.request('room.start', {}));
for (const p of [host, guest]) await p.waitForFunction(() => globalThis.__SP__.store.get().match.public?.phase === 'INFO_CHECK');
await guest.evaluate(() => globalThis.__SP__.net.request('g.infoReady', { setupRevision: 0 }));
await host.waitForSelector('.brief-reroll button');
await host.bringToFront();
await host.click('.brief-reroll button');
await guest.waitForSelector('.brief-reroll .btn--primary');
assert.equal(await guest.evaluate(() => globalThis.__SP__.store.get().match.public.deadline), 0);
await guest.bringToFront();
await guest.click('.brief-reroll .btn--primary');
for (const p of [host, guest]) await p.waitForFunction(() => globalThis.__SP__.store.get().match.public?.setupRevision === 1);
const state = await guest.evaluate(() => globalThis.__SP__.store.get().match.public);
assert.equal(state.rerollVote, null);
assert.ok(state.players.filter((p) => !p.isBot).every((p) => !p.ready));
await host.waitForFunction(() => !!document.querySelector('.brief-reroll button:not(:disabled)'));
// Wait out the documented server anti-burst interval before a second request.
await new Promise((resolve) => setTimeout(resolve, 350));
await host.bringToFront();
await host.click('.brief-reroll button');
await guest.waitForSelector('.brief-reroll .btn--danger');
await guest.bringToFront();
await guest.click('.brief-reroll .btn--danger');
await host.waitForFunction(() => !globalThis.__SP__.store.get().match.public.rerollVote);
assert.equal(await host.evaluate(() => globalThis.__SP__.store.get().match.public.setupRevision), 1);
} finally { await ctx.close(); }
});
test('operator voice selection persists across reload and follows global after reset', async () => {
const ctx = await browser.createBrowserContext();
try {
const page = await pageIn(ctx, true);
const open = async () => { await page.evaluate(async () => (await import('/js/ui/loadoutSync.js')).openLoadout()); await page.waitForSelector('.lo-card__pick'); await page.click('.lo-card__pick'); await page.waitForSelector('[data-voice-char] select', { visible: true }); };
await open();
const id = await page.$eval('[data-voice-char]', (e) => e.dataset.voiceChar);
await page.select('[data-voice-char] select', 'jp');
assert.equal(await page.evaluate(async (id) => (await import('/js/audio.js')).audio.voiceOverrides[id], id), 'jp');
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => globalThis.__SP__?.store.get().connection.status === 'online');
await open();
assert.equal(await page.$eval('[data-voice-char] select', (e) => e.value), 'jp');
await page.select('[data-voice-char] select', '');
assert.equal(await page.evaluate((id) => JSON.parse(localStorage.getItem('sp.pref.settings')).voiceOverrides[id], id), undefined);
} finally { await ctx.close(); }
});
test('install manifest is eligible and a dismissed offer is consumed through the actual button', async () => {
const ctx = browser.defaultBrowserContext();
let page;
try {
page = await pageIn(ctx);
const client = await page.createCDPSession();
const manifest = await client.send('Page.getAppManifest');
assert.deepEqual(manifest.errors, []);
const install = await client.send('Page.getInstallabilityErrors');
assert.deepEqual(install.installabilityErrors, []);
await page.evaluate(() => {
const e = new Event('beforeinstallprompt', { cancelable: true });
window.promptCalls = 0;
e.prompt = async () => { window.promptCalls++; return { outcome: 'dismissed' }; };
window.dispatchEvent(e);
});
await page.waitForSelector('[data-testid="pwa-install"]');
await page.click('[data-testid="pwa-install"]');
await page.waitForFunction(() => !document.querySelector('[data-testid="pwa-install"]'));
assert.equal(await page.evaluate(() => window.promptCalls), 1);
} finally { await page?.close(); }
});
test('extra-large settings stay within a landscape phone in every shipped interface language', async () => {
const ctx = await browser.createBrowserContext();
try {
const page = await pageIn(ctx, true);
for (const lang of ['zh-CN', 'en', 'ja', 'ko', 'zh-TW']) {
await page.evaluate(async (lang) => {
await (await import('/js/ui/lang.js')).switchLang(lang);
(await import('/js/ui/settings.js')).updateSettings({ textSize: 'xl' });
}, lang);
await page.click('[data-testid="settings-btn"]');
await page.waitForSelector('.modal .set-list');
const overflow = await page.$eval('.modal__body', (e) => e.scrollWidth - e.clientWidth);
const excess = await page.$eval('.modal__body', (e) => { const right = e.getBoundingClientRect().right; return [...e.querySelectorAll('*')].filter((x) => x.getBoundingClientRect().right > right + 1).map((x) => [x.className, x.textContent.slice(0, 50), Math.round(x.getBoundingClientRect().width)]); });
assert.ok(overflow <= 1, `${lang}: settings overflow ${overflow} ${JSON.stringify(excess)}`);
await page.keyboard.press('Escape');
await page.waitForFunction(() => !document.querySelector('.modal .set-list'));
}
} finally { await ctx.close(); }
});
});
+1 -1
View File
@@ -723,7 +723,7 @@ describe('keyboard & settings', () => {
test('sanitizeSettings', () => {
assert.deepEqual(sanitizeSettings(null), { ...DEFAULT_SETTINGS });
assert.deepEqual(sanitizeSettings({ bgm: 3, sfx: -1, voice: 2, muted: 'yes', damageNumbers: false, quality: 'ultra' }),
{ bgm: 1, sfx: 0, voice: 1, voiceLang: 'cn', muted: false, damageNumbers: false, quality: 'high', keys: { ...DEFAULT_SETTINGS.keys } },
{ bgm: 1, sfx: 0, voice: 1, voiceLang: 'cn', voiceOverrides: {}, muted: false, damageNumbers: false, quality: 'high', textSize: 'sm', keys: { ...DEFAULT_SETTINGS.keys } },
'a saved profile without `keys` (before 0.2.0) gets the default key map (test/ui/feedback5-hotkeys.test.js)');
assert.equal(sanitizeSettings({ bgm: 0.5 }).voice, DEFAULT_SETTINGS.voice, 'a saved profile without `voice` gets the default');
// 语音语言 (0.2.2): 中文 by default — a profile saved before it, or any other value, plays the Chinese dub
+54
View File
@@ -0,0 +1,54 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { createPwaInstall } from '../../public/js/pwa.js';
function rig(standalone = false) {
const win = new EventTarget(), display = new EventTarget();
display.matches = standalone;
win.matchMedia = () => display;
const pwa = createPwaInstall(win);
const offer = (outcome = 'dismissed', fail = false) => {
let calls = 0;
const e = new Event('beforeinstallprompt', { cancelable: true });
e.prompt = async () => { calls++; if (fail) throw Error('unavailable'); return { outcome }; };
win.dispatchEvent(e);
return { calls: () => calls, e };
};
return { pwa, win, display, offer };
}
test('install offer is consumed even on dismissal, duplicate clicks, or rejected prompt', async () => {
const { pwa, offer } = rig();
const states = []; const off = pwa.subscribe((x) => states.push(x));
assert.equal(pwa.available(), false);
const first = offer();
assert.equal(first.e.defaultPrevented, true);
assert.equal(pwa.available(), true);
const a = pwa.request(), b = pwa.request();
assert.equal(await a, false); assert.equal(await b, false); assert.equal(first.calls(), 1);
const broken = offer('accepted', true);
assert.equal(await pwa.request(), false); assert.equal(await pwa.request(), false); assert.equal(broken.calls(), 1);
const next = offer('accepted');
assert.equal(await pwa.request(), true); assert.equal(next.calls(), 1);
assert.deepEqual(states, [false, true, false, true, false, true, false]);
off(); pwa.dispose();
});
test('installed / standalone hides stale offers; disposing removes listeners', async () => {
const { pwa, win, display, offer } = rig();
offer(); display.matches = true; display.dispatchEvent(new Event('change'));
assert.equal(pwa.available(), false); assert.equal(await pwa.request(), false);
display.matches = false; win.dispatchEvent(new Event('appinstalled')); offer();
assert.equal(pwa.available(), false);
pwa.dispose();
assert.equal(offer().e.defaultPrevented, false);
const native = rig(true); native.offer(); assert.equal(native.pwa.available(), false); native.pwa.dispose();
});
test('manifest uses actual 192/512 icons shipped outside optional game assets', () => {
const m = JSON.parse(readFileSync(new URL('../../public/manifest.json', import.meta.url)));
assert.equal(m.start_url, '/'); assert.equal(m.scope, '/');
for (const icon of m.icons) {
assert.ok(icon.src.startsWith('/icons/'));
const bytes = readFileSync(new URL('../../public' + icon.src, import.meta.url));
const size = Number(icon.sizes.split('x')[0]);
assert.equal(bytes.readUInt32BE(16), size); assert.equal(bytes.readUInt32BE(20), size);
}
});
+59
View File
@@ -0,0 +1,59 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { AudioManager } from '../../public/js/audio.js';
import { indexAudio, pickModeAttacks } from '../../tools/assets/audio.mjs';
import { buildPlan } from '../../tools/assets/plan.mjs';
const id = 'char_1043_leizi2';
function rig() {
const entry = { attack: '/normal_n.mp3', hit: '/normal_hit_n.mp3', attacks: { 2: '/mode_s.mp3' }, hits: { 2: '/impact_s.mp3' }, mix: { attack: { p: 0 }, hit: { vol: 0.1 } }, attackMix: { 2: { vol: 0.6 } } };
const a = new AudioManager({ getManifest: () => ({ audio: { sfx: { units: { [id]: entry } } } }), random: () => 0.5 });
a.ctx = {};
const played = [];
a._play = (url, opts) => played.push({ url, ...opts });
const units = [{ id: 1, spine: id, side: 'ally', kind: 'chess', skillIndex: 2 }, { id: 2, spine: 'enemy_1_test', side: 'enemy' }];
a.setFieldUnits(units);
return { a, entry, played, units };
}
test('mode uses its own chance and volume, never the normal bank mix', () => {
const { a, entry, played } = rig();
a.unit(id, 'attack', 1, 2, true);
a.unit(id, 'hit', 1, 2, true);
assert.deepEqual(played.map((p) => [p.url, p.volume]), [['/mode_s.mp3', 0.33], ['/impact_s.mp3', 0.55]]);
entry.attackMix[2].p = 0;
a.unit(id, 'attack', 1, 2, true);
assert.equal(played.length, 2, 'silent mode does not fall back to normal');
});
test('an in-flight impact remembers the mode at attack time after skill end', () => {
const { a, played } = rig();
a.handleBattleEvents([['skill', 1, 1], ['atk', 1, 2, 'none'], ['skill', 1, 0], ['dmg', 2, 10, 'phys']]);
assert.deepEqual(played.map((p) => p.url), ['/mode_s.mp3', '/impact_s.mp3']);
assert.equal(a.units.get(1).skillActive, false);
});
test('end before spawn cancels a queued start; field changes discard stale modes', () => {
const { a, played, units } = rig();
a.setFieldUnits([]);
a.handleBattleEvents([['skill', 1, 1], ['skill', 1, 0], ['spawn', units[0]], ['atk', 1, 2, 'none']]);
assert.equal(a.pendingSkill.size, 0);
assert.equal(a.units.get(1).skillActive, false);
assert.equal(played.length, 0);
a.setFieldUnits([{ ...units[0], skillActive: true }, units[1]]);
a.handleBattleEvents([['atk', 1, 2, 'none']]);
assert.equal(played.at(-1).url, '/mode_s.mp3', 'late join snapshot restores active mode');
a.setFieldUnits(units);
assert.equal(a.lastAttacker.size, 0);
assert.equal(a.units.get(1).skillActive, false);
});
test('mixed mode banks are not guessed; the selected official bank retains its mix in the plan', () => {
assert.deepEqual(pickModeAttacks(new Map([['ON_ABILITY_START.attack', ['/a_s.mp3', '/b_h.mp3']]])), {});
const audio = indexAudio({ soundFXBanks: [{ name: `battle.ON_ABILITY_START.${id}.attack.7`, sounds: [
{ asset: 'Audio/Sound_Beta_2/Player/p_atk/test_s', weight: 1, minVolume: 0.6, maxVolume: 0.6 },
{ asset: '', weight: 1 },
] }] });
const { template } = buildPlan({ assets07: {}, ops03: {}, extraOperators: { [id]: { skills: [{ index: 2, skillId: 'test' }] } }, enemies05: {}, maps05: {}, audio, modelsData: {} });
assert.deepEqual(template.audio.sfx.units[id].attackMix, { 2: { p: 0.5, vol: 0.6 } });
});
+172
View File
@@ -0,0 +1,172 @@
// Browser check of 设置 →「文字大小」 (opt-in, needs Chrome, ~30 s):
// SP_E2E=1 CHROME_PATH=… node --test test/ui/text-scale.e2e.test.js
//
// A 844×390 landscape phone: 1rem is clamped at 40 px, so the .18rem body text is 7.2 CSS px. The setting steps the text
// root `--t` (css/theme.css) instead of the layout root: the readable text grows, while `documentElement`'s font-size,
// the modal's rem-sized box and — through the dev mock's 休整期 screen — the field host, the canvas and the shop bar stay
// exactly where they were. Static counterpart: test/ui/text-scale.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 PHONE = { width: 844, height: 390 };
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
/** The settings the mock harness reads: `sp.pref.settings` with one text step (the argument is passed into the page —
* evaluateOnNewDocument cannot see a closure). */
const seedSettings = (page, textSize) => page.evaluateOnNewDocument((ts) => {
try { localStorage.setItem('sp.pref.settings', JSON.stringify({ textSize: ts })); } catch { /* ignore */ }
}, textSize);
describe('文字大小 on a phone (headless Chrome)', { skip: !ENABLED && 'set SP_E2E=1 (and have Chrome) to run' }, () => {
let srv;
let browser;
let base;
before(async () => {
const { startServer } = await import('../../server/index.js');
const puppeteer = (await import('puppeteer-core')).default;
srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
base = `http://127.0.0.1:${srv.port}`;
browser = await puppeteer.launch({ executablePath: CHROME, headless: true, args: ['--no-sandbox', '--force-device-scale-factor=1'] });
});
after(async () => {
await browser?.close();
await srv?.close();
});
/** A page watching for console / page errors, with `sp.pref.settings` seeded before the first script runs. */
async function open(url, textSize, { seed = true } = {}) {
const page = await browser.newPage();
await page.setViewport(PHONE);
const problems = [];
page.on('console', (m) => { if (m.type() === 'error') problems.push(`console: ${m.text()}`); });
page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`));
if (seed && textSize) await seedSettings(page, textSize);
await page.goto(url, { waitUntil: 'networkidle0', timeout: 60000 });
return { page, problems };
}
/** What the settings modal shows at this step: the roots, a readable label, its Latin micro label, the box. */
const modalMetrics = (page) => page.evaluate(() => {
const px = (sel, prop = 'fontSize') => {
const el = document.querySelector(sel);
return el ? parseFloat(getComputedStyle(el)[prop]) : null;
};
const box = document.querySelector('.modal__box')?.getBoundingClientRect();
const body = document.querySelector('.modal__body');
const on = document.querySelector('[data-testid="text-size"] button.is-on');
// nothing may stick out of the modal's box (the label wraps and the four steps fit its row)
let outRight = 0;
for (const el of document.querySelectorAll('.modal__box *')) {
const r = el.getBoundingClientRect();
if (box && r.width > 0) outRight = Math.max(outRight, r.right - box.right);
}
return {
root: parseFloat(getComputedStyle(document.documentElement).fontSize),
label: px('.set-row__label'),
micro: px('.set-row__label .micro'),
modalWidth: box ? Math.round(box.width) : null,
bodyOverflowX: body ? body.scrollWidth - body.clientWidth : null,
outRight: Math.round(outRight),
step: on?.textContent ?? null,
hint: document.querySelector('.set-textsize-note')?.textContent ?? null,
};
});
test('the steps grow the readable text; the layout root and the modal box do not move', async () => {
const { page, problems } = await open(`${base}/`, null, { seed: false });
await page.waitForSelector('input', { timeout: 15000 });
await page.type('input', '文字大小');
await page.evaluate(() => [...document.querySelectorAll('button')].find((b) => /开始/.test(b.textContent))?.click());
await page.waitForSelector('.lobby-screen', { timeout: 15000 });
await page.click('.lobby-screen [data-testid="settings-btn"]');
await page.waitForSelector('.modal .set-list', { timeout: 5000 });
await sleep(200);
const small = await modalMetrics(page);
assert.equal(small.step, '小', 'the default is the design\'s own sizes');
assert.equal(small.root, 40, 'a landscape phone clamps the layout root at 40 px');
assert.ok(Math.abs(small.label - 0.18 * 40) < 0.1, `the body text starts at the reported 7.2 px (${small.label})`);
assert.ok(small.hint && /棋盘/.test(small.hint), 'the row says the layout is not touched');
const step = async (name) => {
await page.evaluate((n) => {
const btn = [...document.querySelectorAll('[data-testid="text-size"] button')].find((b) => b.textContent.trim() === n);
btn?.click();
}, name);
await sleep(200);
return modalMetrics(page);
};
const md = await step('中');
const lg = await step('大');
const xl = await step('特大');
assert.equal(md.step, '中');
assert.equal(xl.step, '特大', 'the pick sticks');
assert.ok(md.label > small.label * 1.5 && lg.label > md.label && xl.label > lg.label,
`the readable text grows step by step (${[small.label, md.label, lg.label, xl.label].join(' → ')})`);
assert.ok(Math.abs(xl.label - 0.18 * 88) < 0.2, `特大 reaches the 88 px text root (${xl.label})`);
assert.ok(xl.micro > small.micro * 1.9, `the 4.4 px micro labels follow (${small.micro} → ${xl.micro})`);
for (const m of [md, lg, xl]) {
assert.equal(m.root, 40, 'the layout root never moves: the field camera and the DOM board read 1rem');
assert.equal(m.modalWidth, small.modalWidth, 'the modal\'s box is sized in rem and does not change');
assert.ok(m.bodyOverflowX <= 1, `the rows do not overflow the modal body (${m.bodyOverflowX} px)`);
assert.ok(m.outRight <= 1, `nothing sticks out of the modal's box (${m.outRight} px): the label wraps and the four steps fit`);
}
// saved with the settings and restored on the next visit
assert.deepEqual(await page.evaluate(() => JSON.parse(localStorage.getItem('sp.pref.settings')).textSize), 'xl');
await page.reload({ waitUntil: 'networkidle0' });
await page.waitForSelector('.lobby-screen', { timeout: 15000 });
await page.click('.lobby-screen [data-testid="settings-btn"]');
await page.waitForSelector('.modal .set-list', { timeout: 5000 });
const again = await modalMetrics(page);
assert.equal(again.step, '特大', 'the choice survives a reload');
assert.equal(again.root, 40);
assert.deepEqual(problems, []);
await page.close();
});
test('the in-match field, canvas and shop bar do not move; the settings dialog in a match grows', async () => {
const measure = async (textSize) => {
const { page, problems } = await open(`${base}/dev/game-mock.html?shot=1&phase=PREP&variant=settings`, textSize);
await page.waitForFunction(() => !!document.querySelector('.screen:not(.gload)'), { timeout: 15000 });
await sleep(900);
const m = await page.evaluate(() => {
const box = (sel) => {
const el = document.querySelector(sel);
if (!el) return null;
const r = el.getBoundingClientRect();
return [Math.round(r.x), Math.round(r.y), Math.round(r.width), Math.round(r.height)];
};
return {
root: parseFloat(getComputedStyle(document.documentElement).fontSize),
field: box('.gm__field'),
canvas: box('.gm__field canvas'),
shopbar: box('.gm__hud .shopbar'),
label: (() => { const el = document.querySelector('.set-row__label'); return el ? parseFloat(getComputedStyle(el).fontSize) : null; })(),
};
});
return { ...m, problems, page };
};
const small = await measure('sm');
const big = await measure('xl');
assert.ok(small.field && small.shopbar, 'the prep screen with its HUD is on screen');
assert.equal(small.root, 40);
assert.equal(big.root, 40, 'the text step never reaches the root');
assert.deepEqual(big.field, small.field, 'the field host (host.clientWidth / clientHeight) is unchanged');
assert.deepEqual(big.canvas, small.canvas, 'and so is the canvas the renderer resizes to it');
assert.deepEqual(big.shopbar, small.shopbar, 'the shop bar is a fixed HUD box: still the design\'s size');
assert.ok(big.label > small.label * 1.9, `while the dialog's readable text grows (${small.label} → ${big.label})`);
assert.deepEqual(small.problems, []);
assert.deepEqual(big.problems, []);
await small.page.close();
await big.page.close();
});
});
+240
View File
@@ -0,0 +1,240 @@
// 设置 →「文字大小」 (the owner's decision of 2026-10-09): the interface text root `--t` of css/theme.css, stepped by
// the settings store (`sp.pref.settings` textSize), applied to <html data-text> by ui/settings.js applyTextSize.
// A phone clamps the LAYOUT root `1rem` at 40 px (theme.css), which left the .18rem body text at 7.2 CSS px, while the
// browser's own font settings only inflate glyphs inside fixed boxes and page zoom is off (index.html's viewport,
// ui/device.js). Only font-size declarations read `--t`: the five modules that turn `1rem` into pixels — ui/fieldHost.js
// hudPadding / hudBands (the prep camera's HUD clearance), ui/gameLogic/panel.js panelSlots (the detail card's side),
// ui/underframe.js (the tile labels' budget) and ui/fallbackField.js (the DOM board) — must never move with it, and the
// field itself is sized from the host element's clientWidth (render/app.js).
//
// What this file guards: the default is the design's own sizes; a bad or missing value falls back to 小; the step lands
// on <html data-text>; the layout root's clamp is untouched; and every font-size that stayed on a plain `rem` is one of
// the deliberate exclusions (the in-match HUD's fixed boxes, the board-pinned DOM text, geometry-derived sizes, the
// ≥16 px iOS-zoom guards) — a new readable size that forgets `--t` fails here, the way `tools/i18n.mjs extract 0` fails
// on an unwrapped string. Browser counterpart: test/ui/text-scale.e2e.test.js (SP_E2E=1).
import { describe, test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, readdirSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { DEFAULT_SETTINGS, TEXT_SIZES, sanitizeSettings } from '../../public/js/ui/gameLogic.js';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const read = (p) => readFileSync(path.join(ROOT, p), 'utf8');
const CSS = 'public/css';
/** Every stylesheet under public/css (index.html loads all of them, devices.css last), as { rel, src }. */
function stylesheets() {
const out = [];
const visit = (rel) => {
for (const e of readdirSync(path.join(ROOT, rel), { withFileTypes: true })) {
const r = `${rel}/${e.name}`;
if (e.isDirectory()) visit(r);
else if (r.endsWith('.css')) out.push({ rel: r, src: read(r).replace(/\/\*[\s\S]*?\*\//g, '') });
}
};
visit(CSS);
return out;
}
/** Rules as { selector, body } (flat; @media blocks are walked into, as test/client-static.test.js does). */
function cssRules(src) {
const out = [];
const re = /([^{}]+)\{([^{}]*)\}/g;
let m;
while ((m = re.exec(src))) out.push({ selector: m[1].trim(), body: m[2] });
return out;
}
/**
* The font sizes that deliberately stay on a plain `rem` (or a bare px): the in-match HUD chrome in fixed boxes, the
* DOM text pinned onto board tiles, the geometry-derived sizes and the ≥16 px iOS-zoom guards. Listed by selector, the
* same list the codemod used — a rule that stops being an exclusion (or a new one that should have used `--t`) shows up
* as a failure with its file and selector.
*/
const NOT_SCALED = [
// pinned onto board tiles by JS-computed pixels (ui/standInTags.js, ui/underframe.js)
/\.sitag/, /\.tempnote/, /\.uframe/, /\.ff-/,
// in-match HUD chrome whose box the design fixed (a later pass: shop cards, top bar, bond strip, corner buttons)
/\.gm__/, /\.roundbox/, /\.capsule/, /\.lp\b/, /\.lp--/, /\.misstag/, /\.bossbar/, /\.readywrap/,
/\.bstrip/, /\.bslot/, /\.effect/, /\.efftip/, /\.ebubble/, /\.ticker/, /\.dpbox/, /\.vswitch/,
/\.specpill/, /\.otwarn/, /\.team/, /\.enemybtn/, /\.readybtn/, /\.ewheel/, /\.fwheel/, /\.chud/, /\.toolbtn/,
/\.shopbar/, /\.scard/, /\.scr\b/,
// text inside a fixed-height bar (the HP bar of the detail card, the boss bar's fill)
/\.dhp\b/,
// the 机变 card: its grid fits 7 lines of the effect text (game-panels.css), so a bigger font would clamp text away
/\.spcard/,
// decorative wordmarks and boot/rotate overlays whose size is the composition
/\.title-cn/, /\.title-en/, /\.title-bg__target/, /\.brief-boss__q/, /\.boot__/, /\.rotate-hint/,
// the join code's letter-spaced field, the px-floored 本局信息 dialog, the 16 px diagnostics box (iOS zoom guard)
/\.field--code/, /\.minfo-dlg/, /\.set-diag__text/,
// the keyboard hints of the 快捷键 list (a shared rule with the HUD's .toolbtn kbd)
/\.set-hint kbd/,
];
const notScaled = (selector) => NOT_SCALED.some((re) => re.test(selector));
/**
* Whole stylesheets a later pass owns: the shop bar's fixed cards (their height feeds the prep camera's HUD band,
* ui/fieldHost.js HUD_REM), and the 干员调配 / 统计 screens, whose text is already px-floored (`max(.15rem, 11px)`) and
* therefore readable on a phone as it is. Scaling them is step 2/3 of the change, not a forgotten line.
*/
const NOT_SCALED_FILES = [`${CSS}/screens/game-shop.css`, `${CSS}/screens/loadout.css`, `${CSS}/screens/stats.css`];
/** A font-size value that is neither scaled nor an exclusion: a plain `rem`, or `max(<rem>, …)`. */
function plainRem(value) {
if (/var\(/.test(value)) return false; // --t, or a geometry-derived size
if (/(^|[^r])em\b|%/.test(value.replace(/rem\b/g, ''))) return false;
return /^\s*(max\(\s*)?\d*\.?\d+rem\b/.test(value);
}
describe('文字大小: the setting', () => {
test('the default is 小 (the design\'s own sizes) and a saved profile without it keeps it', () => {
assert.deepEqual([...TEXT_SIZES], ['sm', 'md', 'lg', 'xl'], 'the steps, in the settings order');
assert.equal(DEFAULT_SETTINGS.textSize, 'sm');
assert.equal(sanitizeSettings(null).textSize, 'sm');
assert.equal(sanitizeSettings({ bgm: 0.4 }).textSize, 'sm', 'a profile saved before the setting existed');
});
test('every step survives sanitising; anything else falls back to 小', () => {
for (const v of TEXT_SIZES) assert.equal(sanitizeSettings({ textSize: v }).textSize, v);
for (const bad of ['huge', 'SM', 'xl2', 'md ', 1, true, null, undefined, {}, []]) {
assert.equal(sanitizeSettings({ textSize: bad }).textSize, 'sm', String(bad));
}
assert.equal(sanitizeSettings({ textSize: 'xl', quality: 'low' }).quality, 'low', 'the other settings are untouched');
});
test('applyTextSize writes the step to <html data-text>, and 小 when it is not a step', async () => {
const { applyTextSize } = await import('../../public/js/ui/settings.js');
const el = { dataset: {} };
const prev = globalThis.document;
globalThis.document = { documentElement: el };
try {
for (const v of TEXT_SIZES) {
applyTextSize(v);
assert.equal(el.dataset.text, v);
}
for (const bad of ['', 'big', null, undefined, 42]) {
applyTextSize(bad);
assert.equal(el.dataset.text, 'sm', String(bad));
}
applyTextSize(undefined);
assert.equal(el.dataset.text, 'sm', 'the attribute is always written, never left stale');
} finally {
if (prev === undefined) delete globalThis.document; else globalThis.document = prev;
}
});
test('the store applies the step on every change, and at boot before the first render', async () => {
const { settingsStore, updateSettings } = await import('../../public/js/ui/settings.js');
const el = { dataset: {} };
const prev = globalThis.document;
globalThis.document = { documentElement: el };
try {
updateSettings({ textSize: 'lg' });
assert.equal(el.dataset.text, 'lg', 'picking a step re-renders the text immediately');
assert.equal(settingsStore.get().textSize, 'lg', 'and it is saved with the settings (sp.pref.settings)');
updateSettings({ textSize: 'sm' });
assert.equal(el.dataset.text, 'sm');
} finally {
updateSettings({ textSize: DEFAULT_SETTINGS.textSize });
if (prev === undefined) delete globalThis.document; else globalThis.document = prev;
}
});
test('the settings modal offers the four steps (data-testid text-size), below 画面质量', () => {
const src = read('public/js/ui/settings.js');
assert.match(src, /data-testid="text-size"/);
assert.match(src, /const TEXT_SCALES = \[\[?.*'sm'.*'md'.*'lg'.*'xl'/s, 'the steps with their labels');
assert.match(src, /applyTextSize\(settingsStore\.get\(\)\.textSize\)/, 'applied at module load (before main.js renders)');
assert.ok(src.indexOf("t('文字大小')") > src.indexOf("t('画面质量')"), 'the row is below the quality row');
assert.match(read('public/css/screens/game.css'), /\.set-seg\.set-textsize button \{ min-width: \.62rem; \}/,
'four steps fit the row on a phone (1rem = 40 px)');
});
});
describe('文字大小: the CSS text root', () => {
const sheets = stylesheets();
const theme = sheets.find((s) => s.rel === `${CSS}/theme.css`).src;
test('theme.css defines --t for 小 and every step, and nothing else defines it', () => {
assert.match(theme, /:root \{ --t: 1rem; \}/, '小 = the design\'s own sizes');
for (const [attr, value] of [['md', /--t: max\(1rem, 64px\)/], ['lg', /--t: max\(calc\(1rem \* 1\.15\), 76px\)/], ['xl', /--t: max\(calc\(1rem \* 1\.3\), 88px\)/]]) {
assert.match(theme, new RegExp(`:root\\[data-text="${attr}"\\] \\{ ${value.source}; \\}`), `the ${attr} step`);
}
for (const { rel, src } of sheets) {
if (rel === `${CSS}/theme.css`) continue;
assert.doesNotMatch(src, /--t\s*:/, `${rel}: --t is defined in theme.css only`);
}
});
test('the layout root 1rem is untouched — the five rem→px readers must not move', () => {
assert.match(theme, /html \{\n {2}font-size: clamp\(40px, min\(calc\(100vw \/ 19\.2\), calc\(100vh \/ 10\.8\)\), 240px\);/,
'the desktop clamp');
assert.match(theme, /font-size: clamp\(40px, min\(calc\(100vw \/ 19\.2\), calc\(100svh \/ 10\.8\)\), 240px\);/,
'the svh clamp (mobile toolbars)');
assert.doesNotMatch(theme, /font-size: clamp\([^)]*var\(--t\)/, 'the root scale never reads the text root');
// and the modules that convert it keep their own floor: a text step must not reach them
for (const f of ['public/js/ui/fieldHost.js', 'public/js/ui/gameLogic/panel.js', 'public/js/ui/underframe.js', 'public/js/ui/fallbackField.js']) {
const src = read(f);
assert.doesNotMatch(src, /--t\b|data-text/, `${f}: the layout must not read the text root`);
assert.match(src, /getComputedStyle\(document\.documentElement\)\.fontSize|rem\b/, `${f}: still converts 1rem`);
}
});
test('readable text reads --t (body, .micro, buttons, modals, panels, settings)', () => {
const want = [
[theme, /body \{[^}]*font-size: calc\(\.18 \* var\(--t\)\)/s, 'theme.css body (the 7.2 px text)'],
[theme, /\.micro \{[^}]*font-size: calc\(\.11 \* var\(--t\)\)/s, 'theme.css .micro (92 render sites)'],
[theme, /:root\[data-text="md"\]/, 'the steps'],
];
for (const [src, re, what] of want) assert.match(src, re, what);
const byRel = new Map(sheets.map((s) => [s.rel, s.src]));
const samples = [
['components.css', /\.btn \{[^}]*font-size: calc\(\.18 \* var\(--t\)\)/s],
['screens/game.css', /\.set-row__label \{[^}]*font-size: calc\(\.18 \* var\(--t\)\)/s],
['screens/game-panels.css', /\.dtext \{ font-size: calc\(\.15 \* var\(--t\)\); line-height: 1\.65;/],
['screens/guide.css', /\.guide__tips li \{ font-size: calc\(\.18 \* var\(--t\)\)/],
['screens/lobby.css', /\.mode-card__desc/, ],
];
for (const [file, re] of samples) assert.match(byRel.get(`${CSS}/${file}`), re, file);
});
test('a newer reader would be caught: every plain rem font-size is a documented exclusion', () => {
const bad = [];
let scaled = 0;
for (const { rel, src } of sheets) {
if (NOT_SCALED_FILES.includes(rel)) continue;
for (const { selector, body } of cssRules(src)) {
for (const m of body.matchAll(/font-size:([^;}]*)/g)) {
const value = m[1];
if (/var\(--t\)/.test(value)) { scaled++; continue; }
if (!plainRem(value)) continue;
if (notScaled(selector)) continue;
bad.push(`${rel}: ${selector} { font-size:${value.trim()} }`);
}
}
}
assert.deepEqual(bad, [], `not on --t and not a documented exclusion (${scaled} declarations do read --t):`);
assert.ok(scaled >= 250, `${scaled} declarations read --t — a mass revert would fail here`);
});
test('the in-match HUD, the board-pinned text and the iOS zoom guards stay as they were', () => {
const byRel = new Map(sheets.map((s) => [s.rel, s.src]));
const plain = [
['screens/game-panels.css', /\.sitag \{[^}]*font-size: max\(\.12rem, 9px\)/, '.sitag'],
['screens/game-panels.css', /\.tempnote__title \{[^}]*font-size: \.15rem/, '.tempnote__title'],
['screens/game-panels.css', /\.uframe__label \{[^}]*font-size: \.14rem/, '.uframe__label'],
['screens/game-panels.css', /\.spcard__desc \{ flex: none; font-size: \.2rem/, '.spcard__desc (7 clamped lines)'],
['screens/game-panels.css', /\.dhp span \{[^}]*font-size: \.12rem/, '.dhp span (a fixed-height HP bar)'],
['screens/game-shop.css', /\.scard__name \{[^}]*font-size: \.19rem/, 'the shop card'],
['screens/game.css', /\.bslot \.bond__name \{[^}]*font-size: \.14rem/, 'the bond strip'],
['screens/game.css', /\.bossbar__txt \{[^}]*font-size: \.14rem/, 'the boss bar'],
['screens/game.css', /\.lp--md \{ font-size: \.24rem; \}/, 'the top bar LP'],
['screens/game.css', /\.sp-coarse \.set-diag__text \{ font-size: 16px; \}/, 'the 16 px iOS-zoom guard'],
['theme.css', /\.rotate-hint \{[\s\S]*?font-size: 16px/, 'the rotate hint'],
];
for (const [file, re, what] of plain) assert.match(byRel.get(`${CSS}/${file}`), re, what);
});
});
Loaded 100 of 104 files, more files were not shown because too many files have changed in this diff. Show more