0.2.5-dev: community round 30 and follow-ups
- Stock: fixed trait gifts (缪尔赛思 / 变形者集群 变形同构体, 耶拉 不融冰, 蜜蜡 / 卡涅利安 迅捷作战粮, 诗怀雅 盟约之币) still occupy stock but are granted past the cap; an operator card already shown in the shop or a reward can be taken after the pool runs out and overdraws (GitHub #498, the owner's decision); item buys and item rewards keep the stock check. 洛洛 / 焰尾 sub-pools use the measured weights (PR #497 by @2321Robin; the occupancy idea of PR #496 by @2321Robin re-implemented differently). - Boss pairs are drawn at random once per match and fixed to battle start; the Hidden Core reuses them; a leaver does not split a full pair (the owner's decision). - 阿戈尔 in 联防 no longer re-applies the opening devour HP loss or a false revive; Touch's team medic stays for the match after its holder leaves (owner-approved, official lifetime unverified). - Auto-skill ranges: 司霆惊蛰 S2, 斩业星熊 S3 against air, 予愿安洁莉娜 S3; 自在's post-rebirth invulnerability is also untargetable (#510); Horn / Clementia / catapult / 忍冬 first hit (PR #502 by @Xinglan233); 不融冰 / tear gas trigger on real damage. - Bond layer-up sound in round start / draft / prep / battle (#482): one channel, a new cue replaces the old, no extra cooldown; a prep update that adds layers and activates a bond plays one cue. - Client: multi-swing fix (#506), refresh single-flight (PR #507 by @robll-v1), LP tower keeps the settled value with a −N tick (PR #504 by @robll-v1), export with only 潜能 / 练度 changes (PR #503 by @Xinglan233), spectator rosters list DIY members (PR #505 by @Xinglan233), banned members beside bond tooltips (PR #494 by @YuriRestia), recovery action above the away overlay (PR #511 by @RainoutOvO), the /dev/sim-check.html consistency page (PR #492 by @Cloudnyco), Korean (PR #490 by @D-Roote) and English Victoria wording (PR #499 by @YuriRestia). - golden: 45 of 287 scenarios move against 0.2.4 (roster 17, bonds 3, fields 6, matches 17, standins 2), each attributed in docs/history/0.2.5.md §30.9 / §30.16.
This commit is contained in:
1 parent
bc50cac01f
commit
221e6b8dd2
134 files changed
+4586
-1197
No files matched your search
@@ -43,3 +43,5 @@ desktop.ini
|
||||
.idea/
|
||||
.vscode/
|
||||
.claude/
|
||||
# tools/sim-check.mjs: the corpus and its Node traces for /dev/sim-check.html
|
||||
public/dev/simcheck/data/
|
||||
@@ -1,5 +1,22 @@
|
||||
# 更新记录
|
||||
|
||||
## 0.2.5-dev — 未发布
|
||||
|
||||
- 补齐司霆惊蛰二技能、斩业星熊三技能对空及予愿安洁莉娜三技能周身范围的自动开技判定。
|
||||
- 首领配对改为每局随机,准备期至开战固定,隐藏首领沿用同局分组;客户端棋盘和队友框同步实际配对。
|
||||
- 修复联防阿戈尔继承残血后再次结算开场吞噬、误触发复活的问题,保留准确生命比例和技力。
|
||||
- Touch 外勤医疗按本局团队权益保留,持有者淘汰或退出后不再使下一场医疗消失(维护者已确认保留,原作寿命规则仍待实测确证)。
|
||||
- 补齐回合开始、机变、休整期和战斗中的盟约叠层提示音;同类最多播放一路,新触发替换旧声音,重连和结算回填不补播。
|
||||
- 修复固定特质赠品被共享库存阻断:仍计入库存,达到上限也会继续发放;补齐洛洛、焰尾的实测子池。
|
||||
- 已展示的干员商店牌和奖励牌不再因随后库存耗尽而领取失败;道具购买与奖励保留库存检查。
|
||||
- 修复克莱门莎三技能被友方侵蚀爆条触发、号角不攻击脚下不可阻挡敌人、自制投石机溅射半径,以及忍冬首次追凶附伤。
|
||||
- 谢拉格不融冰、催泪瓦斯改为造成伤害时判定,包含阿斯卡纶的持续伤害。
|
||||
- 支持只修改自选潜能/练度时导出调配,补齐观战自选干员盟约名单;刷新请求在途时防止重复发送。
|
||||
- 生命值在结算前保留当前数值并标待扣;盟约提示补本局禁用干员名单,增加开发用模拟一致性检查页。
|
||||
- 修正韩语界面和维多利亚英文按装备逐件加成的表述。
|
||||
- 修复干员一次攻击后模型继续循环、多挥一次的问题,保留迟到消息的现有前摇同步方式。
|
||||
- 自在重生后的无敌期不再被普攻或需要可选目标的自动技能锁定;修复 AI 托管遮罩挡住“在此页面继续”按钮。
|
||||
|
||||
## 0.2.4 — 2026-10-11
|
||||
|
||||
### 规则(按官方)
|
||||
|
||||
@@ -45,6 +45,13 @@ RENDER_E2E=1 node --test 'test/render/*.browser.test.js' # 渲染
|
||||
SIM_E2E=1 node --test 'test/sim/*.browser.test.js' # 浏览器里的战斗模拟
|
||||
```
|
||||
|
||||
改了模拟(`server/sim/`)之后,可以检查各浏览器算出的战斗和服务器是否仍然逐位一致:`node tools/sim-check.mjs`
|
||||
在 Node 里记录黄金结果的整套战斗,然后在任意设备(包括手机)的浏览器里打开服务器的 `/dev/sim-check.html` 重放比较;
|
||||
加 `--engines chromium,firefox,webkit` 直接在无界面的浏览器里跑(Firefox 和 WebKit 需要先装 playwright)。不一致时
|
||||
页面指出首次检测到差异的区间,以及结果、抽样快照或已记录状态的差别。`--diff` 可定位导出的状态向量字段;仅快照或深层状态差异不保证能定位字段。
|
||||
参考数据含运行代码、数据与场景内容指纹,修改后必须重新生成;空筛选、未完成或运行异常均不算通过。细节见 docs/SIM.md
|
||||
「11. Tools」。
|
||||
|
||||
**黄金结果**([test/golden/README.md](test/golden/README.md)):只重构、不改玩法的提交不能改变
|
||||
`test/golden/*.json`;有意改变玩法时运行 `npm run golden:update`,把新文件和改动放在同一个提交里,并在提交说明里写清
|
||||
哪些场景变了、为什么。
|
||||
@@ -110,6 +117,8 @@ SIM_E2E=1 node --test 'test/sim/*.browser.test.js' # 浏览器里
|
||||
are opt-in: `SP_E2E=1`, `SP_REAL_E2E=1` (needs downloaded art), `RENDER_E2E=1`, `SIM_E2E=1` (a local Chrome,
|
||||
`CHROME_PATH`). A refactor never changes `test/golden/*.json`; an intended gameplay change runs
|
||||
`npm run golden:update` and commits the digests with the change, naming the scenarios that moved.
|
||||
After a sim change, `node tools/sim-check.mjs` checks that browsers still give the server's bits: it traces the golden
|
||||
battle corpus in Node; `/dev/sim-check.html` replays it on any device (`--engines chromium,firefox,webkit` headless).
|
||||
- **Fidelity**: the official tables (`.cache/gamedata/excel/`) and PRTS (including the 备注 notes) are the sources;
|
||||
numbers come from the blackboards. Anything no source settles is marked `[ASSUMED]` in the code and the pull
|
||||
request; owner decisions are cited with their date; deliberate deviations are the owner's call and go into the design
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
# 卫戍协议:盟约 · Stronghold Protocol: Alliance
|
||||
|
||||
> **开发版(dev 分支):不稳定,请勿用于公开服务器**。当前为 0.2.5-dev,尚未发布;稳定版请使用 Releases 中的 0.2.4。
|
||||
|
||||
《明日方舟》季节性自走棋塔防玩法「卫戍协议:盟约」的**非官方同人复刻**:浏览器即开即玩,单人或 1–4 人联机合作。
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
> 当前为 **0.2.5 开发版**,尚未发布;稳定版仍为 0.2.4。
|
||||
|
||||
## 声明
|
||||
|
||||
> [!IMPORTANT]
|
||||
@@ -37,7 +41,7 @@ English summary: [below](#english).
|
||||
|
||||
- **独立模拟**(单人)与**同盟模拟**(1–4 人**合作**,没有 PvP;空位可以加 AI 队友)。
|
||||
- 服务器是一个 Node.js 程序,**战斗在各玩家的浏览器里模拟**(和官方一样),服务器只管经济与回合,一台低功耗小主机就能开服。
|
||||
- 当前版本 0.2.4:按官方补齐共享卡池与装备库存,修正突袭复活选敌、克莱门莎、浊心斯卡蒂与坚守等规则,联机首领战突袭可以跳到空半场,并修复了 0.2.3 发布后玩家和 GitHub 上反馈的问题,详见 [CHANGELOG.md](CHANGELOG.md)。仍有少数规则按推断实现,与官方不一致的地方欢迎在 Issue 里反馈。
|
||||
- 当前开发版 0.2.5-dev(未发布):首领每局随机配对、已展示的干员牌库存耗尽后仍可领取、固定赠品不再被库存阻断、盟约叠层提示音等社区修复;最新发布版 0.2.4,见 [CHANGELOG.md](CHANGELOG.md)。仍有少数规则按推断实现,与官方不一致的地方欢迎在 Issue 里反馈。
|
||||
|
||||
## 功能一览
|
||||
|
||||
@@ -54,7 +58,7 @@ English summary: [below](#english).
|
||||
- **最终攻势与隐秘核心**:两人共享一个战场,全队共同削减同一条领袖血条;10 个敌方领袖,巨型领袖约 5×3 格的受击范围,以及官方的限伤规则。
|
||||
- **结算称号**:卫戍之星、不朽盟约、坚若磐石等 6 个称号。
|
||||
- **断线重连**:同盟模拟断线后 10 分钟内重新打开页面即可回到原座位,掉线期间按原阵容自动作战,也可以「暂离」交给 AI 托管;独立模拟 24 小时内可以回来继续(同一个浏览器)。
|
||||
- **交互细节**:漏怪时顶栏的目标生命值实时减少(结算时确定);点选、拖放和配发装备都按地上的方格;购买、升级和机变选卡都需要点两次确认;只有一名玩家时除作战外不计时。
|
||||
- **交互细节**:漏怪时顶栏用红字 + 「−N」标记待扣的目标生命值(结算时才扣除);点选、拖放和配发装备都按地上的方格;购买、升级和机变选卡都需要点两次确认;只有一名玩家时除作战外不计时。
|
||||
- **画面与声音**:真实 Spine 小人、官方 BGM 与音效、表情(6 套 × 6 个)、作战特效;可选的官方 3D 棋盘(需要从本机客户端提取贴图)。
|
||||
- **手机与电脑**:触摸拖拽、长按查看详情,推荐横屏;设置里可以调低画质。
|
||||
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"events":{"artifact_paid_1":{"id":"artifact_paid_1","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#35d8b4","family":"shop","solo":false,"training":false},"artifact_paid_1_s":{"id":"artifact_paid_1_s","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#f2bd3e","family":"shop","solo":true,"training":false},"artifact_paid_2":{"id":"artifact_paid_2","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#35d8b4","family":"shop","solo":false,"training":false},"artifact_paid_2_s":{"id":"artifact_paid_2_s","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#f2bd3e","family":"shop","solo":true,"training":false},"artifact_paid_3":{"id":"artifact_paid_3","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#35d8b4","family":"shop","solo":false,"training":false},"artifact_paid_3_s":{"id":"artifact_paid_3_s","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#f2bd3e","family":"shop","solo":true,"training":false},"artifact_paid_4":{"id":"artifact_paid_4","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#35d8b4","family":"shop","solo":false,"training":false},"artifact_paid_4_s":{"id":"artifact_paid_4_s","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#f2bd3e","family":"shop","solo":true,"training":false},"artifact_paid_5":{"id":"artifact_paid_5","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#35d8b4","family":"shop","solo":false,"training":false},"artifact_paid_5_s":{"id":"artifact_paid_5_s","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#f2bd3e","family":"shop","solo":true,"training":false},"bossInitial_1":{"id":"bossInitial_1","choiceType":"BOUNTY_HUNT","effectType":"ENEMY_GAIN","name":"悬赏决策","desc":"选定悬赏目标,获取额外奖励。","descRaw":"选定悬赏目标,获取额外奖励。","color":"#35d8b4","family":"bounty","solo":false,"training":false},"bossInitial_1_s":{"id":"bossInitial_1_s","choiceType":"BOUNTY_HUNT","effectType":"ENEMY_GAIN","name":"悬赏决策","desc":"选定悬赏目标,获取额外奖励。","descRaw":"选定悬赏目标,获取额外奖励。","color":"#35d8b4","family":"bounty","solo":true,"training":false},"bossInitial_2":{"id":"bossInitial_2","choiceType":"BOUNTY_HUNT","effectType":"ENEMY_GAIN","name":"悬赏决策","desc":"选定悬赏目标,获取额外奖励。","descRaw":"选定悬赏目标,获取额外奖励。","color":"#35d8b4","family":"bounty","solo":false,"training":false},"bossInitial_2_s":{"id":"bossInitial_2_s","choiceType":"BOUNTY_HUNT","effectType":"ENEMY_GAIN","name":"悬赏决策","desc":"选定悬赏目标,获取额外奖励。","descRaw":"选定悬赏目标,获取额外奖励。","color":"#35d8b4","family":"bounty","solo":true,"training":false},"bossInitial_3":{"id":"bossInitial_3","choiceType":"BOUNTY_HUNT","effectType":"ENEMY_GAIN","name":"悬赏决策","desc":"选定悬赏目标,获取额外奖励。","descRaw":"选定悬赏目标,获取额外奖励。","color":"#35d8b4","family":"bounty","solo":false,"training":false},"bossInitial_3_s":{"id":"bossInitial_3_s","choiceType":"BOUNTY_HUNT","effectType":"ENEMY_GAIN","name":"悬赏决策","desc":"选定悬赏目标,获取额外奖励。","descRaw":"选定悬赏目标,获取额外奖励。","color":"#35d8b4","family":"bounty","solo":true,"training":false},"bossInitial_4":{"id":"bossInitial_4","choiceType":"BOUNTY_HUNT","effectType":"ENEMY_GAIN","name":"悬赏决策","desc":"选定悬赏目标,获取额外奖励。","descRaw":"选定悬赏目标,获取额外奖励。","color":"#35d8b4","family":"bounty","solo":false,"training":false},"bossInitial_4_s":{"id":"bossInitial_4_s","choiceType":"BOUNTY_HUNT","effectType":"ENEMY_GAIN","name":"悬赏决策","desc":"Line truncated
|
||||
{"events":{"artifact_paid_1":{"id":"artifact_paid_1","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#35d8b4","family":"shop","solo":false,"training":false},"artifact_paid_1_s":{"id":"artifact_paid_1_s","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#f2bd3e","family":"shop","solo":true,"training":false},"artifact_paid_2":{"id":"artifact_paid_2","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#35d8b4","family":"shop","solo":false,"training":false},"artifact_paid_2_s":{"id":"artifact_paid_2_s","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#f2bd3e","family":"shop","solo":true,"training":false},"artifact_paid_3":{"id":"artifact_paid_3","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#35d8b4","family":"shop","solo":false,"training":false},"artifact_paid_3_s":{"id":"artifact_paid_3_s","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#f2bd3e","family":"shop","solo":true,"training":false},"artifact_paid_4":{"id":"artifact_paid_4","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#35d8b4","family":"shop","solo":false,"training":false},"artifact_paid_4_s":{"id":"artifact_paid_4_s","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#f2bd3e","family":"shop","solo":true,"training":false},"artifact_paid_5":{"id":"artifact_paid_5","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#35d8b4","family":"shop","solo":false,"training":false},"artifact_paid_5_s":{"id":"artifact_paid_5_s","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#f2bd3e","family":"shop","solo":true,"training":false},"bossInitial_1":{"id":"bossInitial_1","choiceType":"BOUNTY_HUNT","effectType":"ENEMY_GAIN","name":"悬赏决策","desc":"选定悬赏目标,获取额外奖励。","descRaw":"选定悬赏目标,获取额外奖励。","color":"#35d8b4","family":"bounty","solo":false,"training":false},"bossInitial_1_s":{"id":"bossInitial_1_s","choiceType":"BOUNTY_HUNT","effectType":"ENEMY_GAIN","name":"悬赏决策","desc":"选定悬赏目标,获取额外奖励。","descRaw":"选定悬赏目标,获取额外奖励。","color":"#35d8b4","family":"bounty","solo":true,"training":false},"bossInitial_2":{"id":"bossInitial_2","choiceType":"BOUNTY_HUNT","effectType":"ENEMY_GAIN","name":"悬赏决策","desc":"选定悬赏目标,获取额外奖励。","descRaw":"选定悬赏目标,获取额外奖励。","color":"#35d8b4","family":"bounty","solo":false,"training":false},"bossInitial_2_s":{"id":"bossInitial_2_s","choiceType":"BOUNTY_HUNT","effectType":"ENEMY_GAIN","name":"悬赏决策","desc":"选定悬赏目标,获取额外奖励。","descRaw":"选定悬赏目标,获取额外奖励。","color":"#35d8b4","family":"bounty","solo":true,"training":false},"bossInitial_3":{"id":"bossInitial_3","choiceType":"BOUNTY_HUNT","effectType":"ENEMY_GAIN","name":"悬赏决策","desc":"选定悬赏目标,获取额外奖励。","descRaw":"选定悬赏目标,获取额外奖励。","color":"#35d8b4","family":"bounty","solo":false,"training":false},"bossInitial_3_s":{"id":"bossInitial_3_s","choiceType":"BOUNTY_HUNT","effectType":"ENEMY_GAIN","name":"悬赏决策","desc":"选定悬赏目标,获取额外奖励。","descRaw":"选定悬赏目标,获取额外奖励。","color":"#35d8b4","family":"bounty","solo":true,"training":false},"bossInitial_4":{"id":"bossInitial_4","choiceType":"BOUNTY_HUNT","effectType":"ENEMY_GAIN","name":"悬赏决策","desc":"选定悬赏目标,获取额外奖励。","descRaw":"选定悬赏目标,获取额外奖励。","color":"#35d8b4","family":"bounty","solo":false,"training":false},"bossInitial_4_s":{"id":"bossInitial_4_s","choiceType":"BOUNTY_HUNT","effectType":"ENEMY_GAIN","name":"悬赏决策","desc":"Line truncated
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"version":1,"lang":"en","meta":{"generator":"tools/build-i18n.mjs","source":{"id":"assets","label":"ArknightsAssets/ArknightsGamedata (en)","home":"https://github.com/ArknightsAssets/ArknightsGamedata","season":"act2autochess"},"fallback":"tools/i18n/fallback-remake.json (the remake), tools/i18n/fallback-pr70.json (PR #70 by @YuriRestia), public/i18n/en.json","coverage":{"bands":{"texts":186,"translated":186,"official":186,"pct":100},"bonds":{"texts":138,"translated":138,"official":138,"pct":100},"bosses":{"texts":66,"translated":66,"official":66,"pct":100},"choices":{"texts":679,"translated":679,"official":679,"pct":100},"config":{"texts":110,"translated":110,"official":103,"pct":100},"effects":{"texts":1083,"translated":1083,"official":1083,"pct":100},"emotes":{"texts":41,"translated":41,"official":3,"pct":100},"enemies":{"texts":1734,"translated":1734,"official":1734,"pct":100},"factions":{"texts":14,"translated":14,"official":14,"pct":100},"garrisons":{"texts":747,"translated":747,"official":747,"pct":100},"items":{"texts":582,"translated":582,"official":570,"pct":100},"modules":{"texts":2498,"translated":2449,"official":2431,"pct":98},"operators":{"texts":876,"translated":854,"official":846,"pct":97.5},"skills":{"texts":5673,"translated":5439,"official":5439,"pct":95.9},"stages":{"texts":1124,"translated":1124,"official":1122,"pct":100},"talents":{"texts":5996,"translated":5688,"official":5680,"pct":94.9},"tokens":{"texts":458,"translated":458,"official":457,"pct":100},"traits":{"texts":1876,"translated":1860,"official":1860,"pct":99.1}},"totals":{"texts":23881,"translated":23252,"official":23158,"pct":97.4}},"names":{"\"炎佑\"":"Yan's Protection","Dor-1号失败品":"Dor-1 Failure","Dor-α号失败品":"Dor-α Failure","Dor-β号复制体":"Dor-β Replica","M3茧甲":"M3 Cocoon Shell","W·悬赏":"W/Bounty","“变形者集群”":"'Damazti Cluster'","“墓碑”":"'Gravestone'","“墓碑”·悬赏":"'Gravestone'/Bounty","“复仇者”":"'Hateful Avenger'","“复仇者”·悬赏":"'Hateful Avenger'/Bounty","“小自在”":"'Freeling'","“巨大的丑东西”":"'The Big Ugly Thing'","“巨大的丑东西”·悬赏":"'The Big Ugly Thing'/Bounty","“帝国的甲胄”":"'Armor of the Empire'","“庞贝”":"'Pompeii'","“庞贝”·悬赏":"'Pompeii'/Bounty","“斩胄之剑”":"'Armor Cleaver'","“灵幛”":"Sudaram","“独轮车玩具”":"'Unicycle Toy'","“破胄之锤”":"'Armor Crusher'","“碎铳之簧”":"'Gunshatter'","“神秘顾客”":"'Mystery Customer'","“耀阳”":"'Blazing Sun'","“自在”":"'Free'","“自在”·悬赏":"'Free'/Bounty","“萨科塔之眼”":"'Eyes of the Sankta'","“萨科塔之眼”·悬赏":"'Eyes of the Sankta'/Bounty","“萨科塔之翼”":"'Wings of the Sankta'","“萨科塔昂首”":"'Pride of the Sankta'","“萨米的意志”":"'Samivilinn,' the Resolution of the Very North","“裂管之音”/“断弦之音”":"'Echoes of Cracked Pipe'/'Echoes of Broken Strings'","“越长尘”":"'Sand Voyager'","“遗弃者”":"'Forsaken One'","“遗弃者”·悬赏":"'Forsaken One'/Bounty","“邪魔的利刃”":"'Demon's Blade'","“邪魔的利刃”·悬赏":"'Demon's Blade'/Bounty","“鼠王”·悬赏":"'Rat King'/Bounty","不屈":"Resilient","不屈弹射器":"Tough Launcher","不祥幻影":"Ominous Apparition","不稳定要素":"Unstable Element","业余竞演者":"Amateur Performer","业务指标":"Work Quota","临时收音师":"Audio Technician","主角阵营角色":"Hero's Sidekick","乌尔比安":"Ulpianus","乌顶巨角卢鲁":"Blackhorn Rhuul","九流之缘":"Streetwise Ties","人事部文档":"HR File","人才盲盒":"Talent Blind Box","仇白":"Qiubai","从不混淆的方向":"Undeviating Course","令":"Ling","以己之长":"Personal Power","伊内丝":"Ines","伊利昂的木驮兽":"Wooden Burdenbeast of Ilion","伊奥莱塔·罗素":"Ioleta Russell","伊芙利特":"Ifrit","休眠子裔":"Dormant Heir","休谟斯":"Humus","休露丝":"Sciurus","众志合一":"Wills As One","优等生":"Top Student","传奇重犯":"Infamous Recidivist","伪装服":"Disguise Outfit","伺夜":"Vigil","余":"Yu","佩佩":"Pepe","俗心":"Vernacularism","保护目标(冻结状态)":"Protection Objective (Frozen)","信仰搅拌机":"Sankta Miksaparato","信标":"Transmitter","假想敌:再生":"OpFor: Rebirth","假想敌:弦":"OpFor: String","假想敌:淤困":"OpFor: Quagmire","假想敌:管":"OpFor: Pipe","假想敌:胄":"OpFor: Armor","假想敌:蚀裂":"OpFor: Fissure","假想敌:铳":"OpFor: Gun","假想敌:镜膜":"OpFor: Mirror","假想敌:骨刺":"OpFor: Bonespur","假想敌:黑云":"OpFor: BlackCloud","假日威龙陈":"Ch'en the Holungday","傀影":"Phantom","催泪瓦斯":"Teargas Can","元核孽生者":"Nucleic Maleficent","元核孽生者·悬赏":"Nucleic Maleficient/Bounty","克莱门莎":"Clementia","冒失的小弟":"Careless Underling","军用猎狗":"Military Hound","冰爆源石虫":"ILine truncated
|
||||
{"version":1,"lang":"en","meta":{"generator":"tools/build-i18n.mjs","source":{"id":"assets","label":"ArknightsAssets/ArknightsGamedata (en)","home":"https://github.com/ArknightsAssets/ArknightsGamedata","season":"act2autochess"},"fallback":"tools/i18n/corrections-en.json (before the official texts), tools/i18n/fallback-remake.json (the remake), tools/i18n/fallback-pr70.json (PR #70 by @YuriRestia), public/i18n/en.json","coverage":{"bands":{"texts":186,"translated":186,"official":186,"pct":100},"bonds":{"texts":138,"translated":138,"official":136,"pct":100},"bosses":{"texts":66,"translated":66,"official":66,"pct":100},"choices":{"texts":679,"translated":679,"official":679,"pct":100},"config":{"texts":110,"translated":110,"official":103,"pct":100},"effects":{"texts":1083,"translated":1083,"official":1081,"pct":100},"emotes":{"texts":41,"translated":41,"official":3,"pct":100},"enemies":{"texts":1734,"translated":1734,"official":1734,"pct":100},"factions":{"texts":14,"translated":14,"official":14,"pct":100},"garrisons":{"texts":747,"translated":747,"official":747,"pct":100},"items":{"texts":582,"translated":582,"official":570,"pct":100},"modules":{"texts":2498,"translated":2449,"official":2431,"pct":98},"operators":{"texts":876,"translated":854,"official":846,"pct":97.5},"skills":{"texts":5673,"translated":5439,"official":5439,"pct":95.9},"stages":{"texts":1124,"translated":1124,"official":1122,"pct":100},"talents":{"texts":5996,"translated":5688,"official":5680,"pct":94.9},"tokens":{"texts":458,"translated":458,"official":457,"pct":100},"traits":{"texts":1876,"translated":1860,"official":1860,"pct":99.1}},"totals":{"texts":23881,"translated":23252,"official":23154,"pct":97.4}},"names":{"\"炎佑\"":"Yan's Protection","Dor-1号失败品":"Dor-1 Failure","Dor-α号失败品":"Dor-α Failure","Dor-β号复制体":"Dor-β Replica","M3茧甲":"M3 Cocoon Shell","W·悬赏":"W/Bounty","“变形者集群”":"'Damazti Cluster'","“墓碑”":"'Gravestone'","“墓碑”·悬赏":"'Gravestone'/Bounty","“复仇者”":"'Hateful Avenger'","“复仇者”·悬赏":"'Hateful Avenger'/Bounty","“小自在”":"'Freeling'","“巨大的丑东西”":"'The Big Ugly Thing'","“巨大的丑东西”·悬赏":"'The Big Ugly Thing'/Bounty","“帝国的甲胄”":"'Armor of the Empire'","“庞贝”":"'Pompeii'","“庞贝”·悬赏":"'Pompeii'/Bounty","“斩胄之剑”":"'Armor Cleaver'","“灵幛”":"Sudaram","“独轮车玩具”":"'Unicycle Toy'","“破胄之锤”":"'Armor Crusher'","“碎铳之簧”":"'Gunshatter'","“神秘顾客”":"'Mystery Customer'","“耀阳”":"'Blazing Sun'","“自在”":"'Free'","“自在”·悬赏":"'Free'/Bounty","“萨科塔之眼”":"'Eyes of the Sankta'","“萨科塔之眼”·悬赏":"'Eyes of the Sankta'/Bounty","“萨科塔之翼”":"'Wings of the Sankta'","“萨科塔昂首”":"'Pride of the Sankta'","“萨米的意志”":"'Samivilinn,' the Resolution of the Very North","“裂管之音”/“断弦之音”":"'Echoes of Cracked Pipe'/'Echoes of Broken Strings'","“越长尘”":"'Sand Voyager'","“遗弃者”":"'Forsaken One'","“遗弃者”·悬赏":"'Forsaken One'/Bounty","“邪魔的利刃”":"'Demon's Blade'","“邪魔的利刃”·悬赏":"'Demon's Blade'/Bounty","“鼠王”·悬赏":"'Rat King'/Bounty","不屈":"Resilient","不屈弹射器":"Tough Launcher","不祥幻影":"Ominous Apparition","不稳定要素":"Unstable Element","业余竞演者":"Amateur Performer","业务指标":"Work Quota","临时收音师":"Audio Technician","主角阵营角色":"Hero's Sidekick","乌尔比安":"Ulpianus","乌顶巨角卢鲁":"Blackhorn Rhuul","九流之缘":"Streetwise Ties","人事部文档":"HR File","人才盲盒":"Talent Blind Box","仇白":"Qiubai","从不混淆的方向":"Undeviating Course","令":"Ling","以己之长":"Personal Power","伊内丝":"Ines","伊利昂的木驮兽":"Wooden Burdenbeast of Ilion","伊奥莱塔·罗素":"Ioleta Russell","伊芙利特":"Ifrit","休眠子裔":"Dormant Heir","休谟斯":"Humus","休露丝":"Sciurus","众志合一":"Wills As One","优等生":"Top Student","传奇重犯":"Infamous Recidivist","伪装服":"Disguise Outfit","伺夜":"Vigil","余":"Yu","佩佩":"Pepe","俗心":"Vernacularism","保护目标(冻结状态)":"Protection Objective (Frozen)","信仰搅拌机":"Sankta Miksaparato","信标":"Transmitter","假想敌:再生":"OpFor: Rebirth","假想敌:弦":"OpFor: String","假想敌:淤困":"OpFor: Quagmire","假想敌:管":"OpFor: Pipe","假想敌:胄":"OpFor: Armor","假想敌:蚀裂":"OpFor: Fissure","假想敌:铳":"OpFor: Gun","假想敌:镜膜":"OpFor: Mirror","假想敌:骨刺":"OpFor: Bonespur","假想敌:黑云":"OpFor: BlackCloud","假日威龙陈":"Ch'en the Holungday","傀影":"Phantom","催泪瓦斯":"Teargas Can","元核孽生者":"Nucleic Maleficent","元核孽生者·悬赏":"Nucleic Maleficient/Bounty","克莱门莎":"Clementia","冒失的小弟":"Careless UndLine truncated
|
||||
@@ -3,6 +3,11 @@
|
||||
Owner: `tools/fetch-assets.mjs` and `tools/assets/*`. Research background: `docs/research/07-assets.md`.
|
||||
|
||||
All official game art, Spine models and audio are **downloaded at install time**. They are never committed; `public/assets/` is git-ignored.
|
||||
|
||||
The UI sound `audio.sfx.ui.bondUp` is the official `ON_ACT1AUTOCHESS_ADD_BOND` bank's
|
||||
`act1autochess_b_ui_buffup`. Live prep/battle layer gains use it as well as bond activation. Its bank settings are
|
||||
`maxSoundAllowed: 1` and `popOldest: true`: one dedicated SFX channel, replacing the previous sound without an extra
|
||||
cooldown. It respects the SFX/mute settings; a stale asynchronous decode cannot restart a replaced cue (#482).
|
||||
All downloaded game assets the client needs are listed in **`data/assets.json`**. Game-art requests must use that manifest.
|
||||
The original project icons below are shipped static files, not downloaded game assets.
|
||||
|
||||
|
||||
@@ -737,3 +737,12 @@ the DIY stock (per player in the match, `PlayerState.diyStock`: `config.economy.
|
||||
field), 助战 borrows (`borrowCount` 20), and a player's potential / 练度 per operator (the 干员调配 settings sent with
|
||||
`room.loadout`; the forms are built at full potential and carry the lower ranks, §2.3; 练度 = effects.json
|
||||
`aceffect_char_1…4`).
|
||||
|
||||
### Acquisition subsets corrected in 0.2.5-dev (§30.2)
|
||||
|
||||
`tools/build-data.mjs` owns the measured Rockrock/Flametail subset weights from
|
||||
[路标月报#2](https://www.bilibili.com/video/BV1eLXXBqEgF/). Rockrock: ordinary hammer 10; tremble, undying, haste,
|
||||
burn hammers 2 each; limited accelerator 1; shield drone and laser emitter 4 each. Flametail: Wild Mane 10,
|
||||
Ashlock 10, Fartooth 3. Rebuild `choices.json` offline; these weights do not exempt draws from shared availability.
|
||||
The runtime derives unlimited fixed-gift families from `SERVER_GAIN_EQUIP`; this is a grant-source exemption,
|
||||
not a zero-occupancy flag or a manually maintained second list in generated data.
|
||||
+5
-1
@@ -4,7 +4,10 @@ This is the **single source of truth** for every implementer. Research lives in
|
||||
|
||||
Language: player-facing text is **Simplified Chinese** by default, with an English switch since 0.2.0 (§25.2, docs/I18N.md: UI strings through `t('中文')`, game texts from the official Chinese data or the official EN client's). Code, comments and identifiers are English.
|
||||
|
||||
Versions: the first public release was **0.1.0** (2026-10-02, the state of §0–§20.15); the releases after it add the player feedback and GitHub reports — 0.1.1 §21, 0.1.2 §22, 0.1.3 §23, 0.1.4 §24 — and **0.2.0** (2026-10-07; `CHANGELOG.md`) the maintainability refactor, two languages, 补位, 自选编队 and their fidelity work — §25 — **0.2.1** (2026-10-07) the 联防 battlefield restored, full potential and the GitHub fixes after it — §26 — and **0.2.2** (2026-10-09) per-operator 潜能 / 练度, Japanese voices, the statistics page, 失衡, whole-frame attack timing and the GitHub fixes after 0.2.1 — §27. **0.2.3** (2026-10-10; `package.json`, `shared/constants.js APP_VERSION`) 克莱门莎 and new modules, community fixes, text size, per-operator voices, installation, the opening reroll and local match recovery — §28. **0.2.4** (2026-10-11) the official shared card pool and item stock, Raid revival and #475, and the community fixes after 0.2.3 — §29, the state described by this document. The labels v1 / v2 / v2.1–v2.5.2 in §0, §14–§20 and in the BALANCE / SIM comparisons name the design generations and the private playtest builds that came before it; they are kept as history.
|
||||
Current development state: **0.2.5-dev**, §30, the state described by this document. Its fixed gifts, already displayed operator offers, combat corrections
|
||||
and client/tool changes supersede the corresponding 0.2.4 rules below. It is not yet released.
|
||||
|
||||
Versions: the first public release was **0.1.0** (2026-10-02, the state of §0–§20.15); the releases after it add the player feedback and GitHub reports — 0.1.1 §21, 0.1.2 §22, 0.1.3 §23, 0.1.4 §24 — and **0.2.0** (2026-10-07; `CHANGELOG.md`) the maintainability refactor, two languages, 补位, 自选编队 and their fidelity work — §25 — **0.2.1** (2026-10-07) the 联防 battlefield restored, full potential and the GitHub fixes after it — §26 — and **0.2.2** (2026-10-09) per-operator 潜能 / 练度, Japanese voices, the statistics page, 失衡, whole-frame attack timing and the GitHub fixes after 0.2.1 — §27. **0.2.3** (2026-10-10; `package.json`, `shared/constants.js APP_VERSION`) 克莱门莎 and new modules, community fixes, text size, per-operator voices, installation, the opening reroll and local match recovery — §28. **0.2.4** (2026-10-11) the official shared card pool and item stock, Raid revival and #475, and the community fixes after 0.2.3 — §29. The labels v1 / v2 / v2.1–v2.5.2 in §0, §14–§20 and in the BALANCE / SIM comparisons name the design generations and the private playtest builds that came before it; they are kept as history.
|
||||
|
||||
---
|
||||
|
||||
@@ -36,3 +39,4 @@ normative lines it rewrote).
|
||||
| §27 | [history/0.2.2.md](history/0.2.2.md) | 0.2.2 — after the 0.2.1 release (2026-10-09) |
|
||||
| §28 | [history/0.2.3.md](history/0.2.3.md) | 0.2.3 — after the 0.2.2 release (2026-10-10) |
|
||||
| §29 | [history/0.2.4.md](history/0.2.4.md) | 0.2.4 — after the 0.2.3 release (2026-10-11) |
|
||||
| §30 | [history/0.2.5.md](history/0.2.5.md) | 0.2.5-dev — after the 0.2.4 release |
|
||||
@@ -156,6 +156,11 @@ rebuilt.
|
||||
the source on the record's own ids, then the 下半 season (`act2autochess`). Rich-text tags and placeholders are the
|
||||
same as in Chinese, so `ui/richText.js` renders the English unchanged (a few official EN texts leave a tag unclosed;
|
||||
the parser runs it to the end of the text, as the game does).
|
||||
- **Corrections**: `tools/i18n/corrections-<lang>.json` (the pack's code: `en`, `ja`, `ko`, `zh-TW`; optional) is
|
||||
tried before the official data of that language, for an official translation that contradicts the Chinese text and
|
||||
the engine (exact match on the raw Chinese text; the plain twin is stripped from it; every entry carries a `_why:`
|
||||
note; `_`-keys are never written to the overlay). Only `corrections-en.json` exists. Counted as `correction` in the
|
||||
coverage report.
|
||||
- **Fallbacks**, in order: `tools/i18n/fallback-remake.json` (the remake's own strings: stage-name parts, item notes,
|
||||
the escaped levels' map names, the season name), `tools/i18n/fallback-pr70.json` (PR #70's translations of texts the EN client
|
||||
lacks: modules of CN-only operators, the 甄选 slots, 结算称号 criteria, emote labels), then `public/i18n/en.json`;
|
||||
|
||||
+24
-8
@@ -64,7 +64,7 @@ LOBBY → INFO_CHECK (co-op 25 s; solo and single-human matches untimed; all hum
|
||||
```
|
||||
|
||||
**Deploy field (user playtest #5 item 7).** From the ROUND_START of a boss round (最终攻势 / 隐秘核心) a player deploys on
|
||||
its half of the boss field (`Match.deployFieldOf` → `'bossL'`, or `'bossR'` for the second player of a seat pair — the
|
||||
its half of the boss field (`Match.deployFieldOf` → `'bossL'`, or `'bossR'` for the second player of a drawn pair — the
|
||||
same pairing as the fields, `bossWaves` / `Match.bossGroupOf`, planned in `startRound` BEFORE the players' round start
|
||||
so R14 → R15 never re-checks a boss-half board against the normal field, and re-paired — with an immediate re-check —
|
||||
when a teammate quits before the fight): `board.js buildDeployMap`
|
||||
@@ -411,14 +411,14 @@ keeps it; `PlayerState.pieceRoundCount`; prefix keys with your module too) —
|
||||
first manual refresh that copy witnesses (player feedback after 0.1.0: "获得该干员后该回合的首次刷新" also stacks; a copy
|
||||
bought after selling one this round is a new copy and fires on its own first refresh [ASSUMED]).
|
||||
|
||||
Writes (all validated, never throw on bad input, never make funds / pools negative):
|
||||
Writes (validated, never throw on bad input; funds and available stock stay non-negative; authorized operator overdraw records an internal deficit):
|
||||
|
||||
| helper | effect |
|
||||
|---|---|
|
||||
| `addFunds(n, reason?)` / `addPendingFunds(n)` / `spendFunds(n)` | funds now / at the next round start / pay (false when short) |
|
||||
| `addLayers(bondId, n, { requireActive })` | layer gain (`requireActive` = "使已激活的…"); returns layers added — at most the room left under `BOND_LAYER_CAP` (999, shared/constants.js `layerGainRoom`; the battle gains merged at SETTLE too); fires onLayers unless it added 0 |
|
||||
| `addLayers(bondId, n, { requireActive })` | layer gain (`requireActive` = "使已激活的…"); returns layers added — at most the room left under `BOND_LAYER_CAP` (999, shared/constants.js `layerGainRoom`; the battle gains merged at SETTLE too); fires onLayers unless it added 0. During ROUND_START / SP_DRAFT / PREP, a positive gain also sends the owner an ephemeral `m.layer {playerId, bondId, add}` audio notification; no replay on resync or SETTLE backfill |
|
||||
| `grantChess(id, { toTemp, golden, requirePool=true, fromPool=true })` | acquire a chess (takes pool copies; with `requirePool` a pool chess with no copy left fails → `null`); merges; fires onGain. `toTemp` puts it into temp, but a free hand slot pulls it in at the next recompute (temp holds overflow only, `PlayerState._fillHandFromTemp`) |
|
||||
| `grantItem(id, { toTemp })` | acquire an item (merges with an identical normal item); `toTemp` as for grantChess |
|
||||
| `grantItem(id, { toTemp, requirePool=true })` | acquire an item (merges with an identical normal item); `toTemp` as for grantChess. `requirePool:false` bypasses stock only for fixed `SERVER_GAIN_EQUIP` gift families; every copy still counts in occupancy (§30.1). |
|
||||
| `rollChess({ maxTier, tier, bond, filter })` / `rollItem({ pool, tier, maxTier })` | copy-weighted chess id from the shared pool / item id (choices.json pools) |
|
||||
| `grantFreeRefresh(n)` | free refreshes (stack) |
|
||||
| `modifyPrice(delta)` / `setPrice(v)` | onPrice only: edit `ev.price` |
|
||||
@@ -436,6 +436,7 @@ Writes (all validated, never throw on bad input, never make funds / pools negati
|
||||
| `offerBountyChoice(cards, sourceItemId)` | 教鞭 only: open a PERSONAL choice of up to three of `cards` (cards.bounty entries) for this player — shuffled once with the meta rng, kept in `m.private.personalChoice` until the owner confirms one (`g.choice { idx, choiceId }`) or the prep's deadline / an AI seat picks (§2.5); returns the intent result (`{ ok }`, or `{ error, detail }` for a phase that is not PREP, a Ready player, a choice already open or no card — before any rng draw or id, so the Art stays in the hand) |
|
||||
| `toast(text, kind)` / `ticker(text)` / `giftTicker(fromName, chessId)` | messages |
|
||||
| `teammates()` / `player(playerId)` | ctx objects of other alive players (team effects) |
|
||||
| `teamMedicGranted()` | Whether this match has a confirmed Touch selection, including an eliminated/departed holder; only the retained 外勤医疗 grant uses this exception ([ASSUMED], §30.14) |
|
||||
|
||||
### 2.5 Items: consume-on-equip and Arts
|
||||
Items whose data `kind` starts with `consume_on_equip` resolve through their `item:` handler's `onEquip` and are
|
||||
@@ -674,6 +675,9 @@ Any `choice:` handler whose EffectRef reuses its own key must guard like this (o
|
||||
(own phase perfect) go to pending funds; bounty rounds decrement; LP ≤ 0 ⇒ eliminated (all copies back to the pool).
|
||||
* **Final Assault / Hidden Core**: finalAssault.js header. Boards are passed in board coordinates; the sim maps board
|
||||
rows 9–12 onto boss rows 2–5 (`BOSS_ROW_OFFSET` −7, matching every stage's boss rows) and mirrors the right side.
|
||||
Pairing uses a separate seeded shuffle once per match, before Final Assault prep; `bossPairs` retains partners and
|
||||
sides into Hidden Core. A departure preserves intact pairs and joins the remaining single players. The published
|
||||
`m.public.bossPairs` follows the preparation plan; camera, placement and green team frames use it, never new draws.
|
||||
Pool (`finalAssault.js bossPoolHp` → `GameData.bossPoolShare` → `gamedata.js bossPoolShareOf`, DESIGN §20.10,
|
||||
§25.13.4): one pool shared by every boss field (official tip "最终攻势中,所有人将一起对敌方领袖造成伤害"; notice 5114's
|
||||
"敌方领袖的总生命值不变" is about the mirrored copies of a pair field sharing it) = `bloodPoint[difficulty]` × the players
|
||||
@@ -731,6 +735,12 @@ smallest stat (坚若磐石 "目标生命值损失最少" = least `lpLost`). doc
|
||||
---
|
||||
|
||||
## 4. 联防 (Unite)
|
||||
|
||||
HP/SP restoration is completed after deployment and all `battleStart` effects. Living carry ratios remain exact,
|
||||
including below 1% or fractional HP under a smaller new maximum. As approved for the reported Egir bug, opening
|
||||
devour rebuilds attributes/mark chains without repeating HP loss or death/revive effects on a carried survivor.
|
||||
Forced-down carry still participates in attribute chains and stays down without using a revive slot. Fresh units
|
||||
without carry and other battle kinds retain normal devour damage; later redeploys do not reuse the old carry.
|
||||
Planned after the normal combats, at the end of the COMBAT_END pause from the players still in (`Match._afterCombat`, so a
|
||||
player who quit during the last field or the pause is neither a helper nor a leaker whose enemies re-enter; unite.js):
|
||||
helpers = `unite.js helperOrder` (research 08 §5, PRTS 卫戍协议/帮助 §联防阶段): ≤ `unite.maxHelpers` (2) perfect players
|
||||
@@ -927,13 +937,19 @@ choices are explicitly [ASSUMED]; DESIGN §28.15–16 records the source and val
|
||||
|
||||
`SharedItemPool` derives equipment availability from current ownership across all players: ordinary 1, upgraded 2.
|
||||
Equips, merges and returns cannot lose reservations. Consumptions/destruction release ownership [ASSUMED removal
|
||||
interpretation]. Shop/reward displays reserve nothing; rejected exhausted purchases retain funds and offers.
|
||||
interpretation]. Shop/reward displays reserve nothing; rejected exhausted **item** purchases retain funds and offers.
|
||||
整备 upgrades the purchased item only when `left` still has the golden's second copy; otherwise the item stays
|
||||
normal and the charge remains.
|
||||
`m.public.sp.cards[].soldOut` disables duplicates when a preceding choice used the final copy; timer/bot choices
|
||||
use the same availability. Non-shop effect-only equipment is unlimited.
|
||||
|
||||
Operator capacities use measured single-player exceptions and twice those counts in co-op; DIY remains private.
|
||||
`take(..., { overdraw: true })` records full occupation of a granted/promoted elite or a Mimic copy beyond capacity;
|
||||
normal takes remain bounded. Negative internal balance is a deficit, never negative available stock. Existing
|
||||
item selection probabilities and explicit grant weights remain unchanged.
|
||||
`take(..., { overdraw: true })` records full occupation, including authorized acquisitions beyond capacity.
|
||||
Already displayed operator shop offers remain purchasable after exhaustion (路标月报#2 2:50–2:57, #498);
|
||||
operator reward offers follow the same rule by the owner's 2026-10-11 decision. Future rolls still use available stock.
|
||||
Negative internal balance is a deficit, never negative available stock; selling first repays the deficit.
|
||||
|
||||
Fixed equipment gifts from `SERVER_GAIN_EQUIP` (卡涅利安、蜜蜡、缪尔赛思、耶拉、诗怀雅) may exceed the pool,
|
||||
including the same families awarded by 变形者. They still occupy ordinary/advanced 1/2 copies and can leave availability
|
||||
at zero after a removal. Ordinary shop, reward and random grants retain their limits. `requirePool:false` is not a
|
||||
universal equipment bypass. Rockrock/Flametail's measured weighted subsets remain subject to shared availability (§30.2).
|
||||
+5
-3
@@ -24,7 +24,7 @@
|
||||
| 休整期 | 购买、摆阵、配装备、冻结 / 刷新、升级调度中心;同盟有倒计时(65–215 秒,随回合增加);独立模拟、以及只有你一名玩家(只带 AI 队友)的同盟模拟不限时。所有人「准备就绪」即提前开战,全员就绪前可取消;点「准备就绪」时商店自动收起、棋盘放大到全场,取消准备时商店再展开(资金旁的「收起」和商店标签「展开商店」随时可以手动切换)。 |
|
||||
- **特殊地形点一下就有说明**:战场上的**活性源石**、**沼泽**、**排气格栅**、**深水区**,以及**红门 / 蓝门 / 传送点**,点一下就会弹出它自己的说明卡——名称、机制,以及**这一关自己的数值**(比如战场#04 的活性源石:每秒 70 点真实伤害、攻击力 +20%、攻击速度 +20、持续 300 秒)。休整期和作战期都能点。普通地板 / 道路 / 高台点了不会弹东西,所以点一下仍然只是「取消选中、关掉卡片」。
|
||||
- **场地装置点一下也有说明**:**阻隔工事**(挡路:地面敌人绕着走,无路可走才撞碎它;作战中说明卡上有它的实时血量,被摧毁后点不出来)、**“双眼皮”炮台**(自动法术攻击、命中附加脆弱,按当前层数最高的盟约强化;还没被机械援助或机变卡启用的炮台不显示、点不出来)、**射击台**(远程位可部署的高台位)和**源石流发生装置**(吹风方向,以及对干员攻击力、敌人移动速度的加减)点一下同样弹出说明卡,数值都取自所在关卡的装置数据,装置名称按当前语言显示官方译名;被机变卡移除的箱子同样不显示。休整期和作战期都能点(GitHub #228,PR #229,感谢 @rickylxw)。
|
||||
| 作战 | 每人在自己的战场各自作战(战斗在你的浏览器里模拟,2 倍速)。干员按**逐列从左到右、每列从上到下**的顺序(官方「从上到下>从左到右」:最左一列最先)自动部署并消耗部署费用(初始 10,+1/秒)。时限 45–115 秒(实际秒数,即屏幕上的倒计时),时间到仍存活的敌人算漏怪。顶栏中间的胶囊是官方的「已解决 / 本回合敌人数」:打死一个、漏掉一个都算已解决(开局 0/3,漏一个 1/3,打死一个 2/3,打死会分裂的那只 3/3);分裂出来的小怪、召唤物和领袖召唤的敌人不进分子也不进分母,但它们漏掉时仍然各自扣目标生命值、也会破坏完美作战(4 只磨砻全被打死、分裂出的 8 只全漏进蓝门:胶囊 4/4,扣 8 点),所以会召唤的领袖关胶囊也能填满。有敌人进入蓝门时,顶栏的目标生命值会**立即**显示扣除后的数值(红字 + 「−N」,每回合至多 10 点),结算时确定;联防阶段标注「联防中」,队友拦下的部分最后不扣。左侧队伍栏同样显示队友的待扣生命值。被击倒的干员**留在倒下的格子上**(倒地姿势、略微变灰),之后也在这一格再部署(与官方一样;倒在其他干员或召唤物初始位置上的,会回到自己的初始位置躺下,召唤物已经消失也一样;唯一的例外是乌尔比安:三技能把他移到船锚处期间被击倒时,他回到自己的部署位躺下、再部署——这是项目维护者 2026-10-07 应社区反馈有意做出的改动,与官方「原地倒下」不同);因自身效果强制退场的干员(史尔特尔「余烬」结束、耀骑士临光 2 技能结束等)同样倒在原地、在原地再部署;倒地干员所在的格子**不会被占用**,突袭盟约的再部署、援军、召唤物都会避开它。头上的圆圈是再部署倒计时:绿色圆环 + 秒数;变成黄色「DP」表示部署费用不够。血条下方的一行是**元素损伤**(与原版一致:元素图标 + 白条):干员为带图标的彩色圆盘,敌人为小号纯色圆盘,右侧白条是剩余元素值,像血条一样从右往左减少,白条耗尽的那一下就是爆条(被大额元素损伤一次打掉一截时白条也会一次少一截,例如掠海漂移体每击约为 25%–50%);爆条后进入爆发冷却(灼燃 / 神经 / 侵蚀 10 秒,敌方侵蚀 8 秒,凋亡 15 秒),这时白条变成**元素颜色**(神经青绿、侵蚀蓝、灼燃橙红、凋亡灰紫)并在冷却期间慢慢回满、图标闪烁——彩色条表示已经爆条、正在承受爆发效果,不是还剩的元素值;期间所有元素都不会累积也无法被治疗,结束后清零。元素爆发造成的是**无来源伤害**,不吃干员的增伤、穿透和装备加成(例如深巡打海嗣时灼燃爆发固定 7000),击杀仍记在爆条的干员名下;元素脆弱只提高元素伤害,不加快元素条;脆弱不影响元素伤害。已被击杀的敌人不会再被附加元素损伤。 |
|
||||
| 作战 | 每人在自己的战场各自作战(战斗在你的浏览器里模拟,2 倍速)。干员按**逐列从左到右、每列从上到下**的顺序(官方「从上到下>从左到右」:最左一列最先)自动部署并消耗部署费用(初始 10,+1/秒)。时限 45–115 秒(实际秒数,即屏幕上的倒计时),时间到仍存活的敌人算漏怪。顶栏中间的胶囊是官方的「已解决 / 本回合敌人数」:打死一个、漏掉一个都算已解决(开局 0/3,漏一个 1/3,打死一个 2/3,打死会分裂的那只 3/3);分裂出来的小怪、召唤物和领袖召唤的敌人不进分子也不进分母,但它们漏掉时仍然各自扣目标生命值、也会破坏完美作战(4 只磨砻全被打死、分裂出的 8 只全漏进蓝门:胶囊 4/4,扣 8 点),所以会召唤的领袖关胶囊也能填满。有敌人进入蓝门时,顶栏的目标生命值**先不变**(红字 + 「−N」标记待扣的点数,每回合至多 10 点),结算时才显示扣除后的数值(与官方一致,GitHub #479;0.2.4 及之前是立即显示扣除后的数值);联防阶段标注「联防中」,队友拦下的部分最后不扣。左侧队伍栏同样显示队友的待扣生命值。被击倒的干员**留在倒下的格子上**(倒地姿势、略微变灰),之后也在这一格再部署(与官方一样;倒在其他干员或召唤物初始位置上的,会回到自己的初始位置躺下,召唤物已经消失也一样;唯一的例外是乌尔比安:三技能把他移到船锚处期间被击倒时,他回到自己的部署位躺下、再部署——这是项目维护者 2026-10-07 应社区反馈有意做出的改动,与官方「原地倒下」不同);因自身效果强制退场的干员(史尔特尔「余烬」结束、耀骑士临光 2 技能结束等)同样倒在原地、在原地再部署;倒地干员所在的格子**不会被占用**,突袭盟约的再部署、援军、召唤物都会避开它。头上的圆圈是再部署倒计时:绿色圆环 + 秒数;变成黄色「DP」表示部署费用不够。血条下方的一行是**元素损伤**(与原版一致:元素图标 + 白条):干员为带图标的彩色圆盘,敌人为小号纯色圆盘,右侧白条是剩余元素值,像血条一样从右往左减少,白条耗尽的那一下就是爆条(被大额元素损伤一次打掉一截时白条也会一次少一截,例如掠海漂移体每击约为 25%–50%);爆条后进入爆发冷却(灼燃 / 神经 / 侵蚀 10 秒,敌方侵蚀 8 秒,凋亡 15 秒),这时白条变成**元素颜色**(神经青绿、侵蚀蓝、灼燃橙红、凋亡灰紫)并在冷却期间慢慢回满、图标闪烁——彩色条表示已经爆条、正在承受爆发效果,不是还剩的元素值;期间所有元素都不会累积也无法被治疗,结束后清零。元素爆发造成的是**无来源伤害**,不吃干员的增伤、穿透和装备加成(例如深巡打海嗣时灼燃爆发固定 7000),击杀仍记在爆条的干员名下;元素脆弱只提高元素伤害,不加快元素条;脆弱不影响元素伤害。已被击杀的敌人不会再被附加元素损伤。 |
|
||||
| 联防 | 见第 5 节。 |
|
||||
| 结算 | 扣 LP、发放悬赏资金、结算层数;LP ≤ 0 的玩家淘汰(其干员回到公共卡池)。结算开始时屏幕正中跳出官方的「**作战结束**」框(约 3 秒后自己消失,不挡操作),下面一行只写**你自己**这一回合被扣的目标生命值:没扣是「**全员无伤!**」,扣了是「**生命值减少 −N**」;同时播官方的战斗结束音效(扣了、联防里没扣、普通回合没扣各一种)。联防回合见第 5 节。最终攻势和隐秘核心没有这一框(之后直接是结算称号或隐秘核心);已淘汰的玩家和观战者不弹框。 |
|
||||
|
||||
@@ -59,7 +59,9 @@
|
||||
## 3. 招募、整备区与晋升
|
||||
|
||||
- **购买点两次**:第一次点商店卡片只选中(卡片抬起并打开详情,底部出现「确认购买 · 再次点击」),再点这张卡片的**任意位置**(包括金色的确认条)才购买;点别处或按 Esc 取消。升级调度中心、晋升奖励同样两次;策略或装备带来的免费挑选(如凯瑟琳【定向投放】升级后刷新的 3 件装备、娜仁图亚【见者有份】的 2 件装备、寻呼模块的 3 名干员)也一样出现在商店卡片的位置,左侧标题写着它的来源,装备显示为装备卡(图标、名称、说明、FREE),选 1 个免费获得;同时有多个免费挑选时依次出现,标题下和「稍后」后的提示按钮会写还有几项(如「+1」)。不能购买时(例如已准备就绪)点卡片只打开详情。
|
||||
- **公共卡池**(同盟):每名干员的副本数有限,I–VI 阶分别 12 / 14 / 18 / 16 / 8 / 5 份,所有玩家共用;持有的干员(棋盘 + 整备区)占用副本,商店里展示的不占用。一名干员被大家拿光后就刷不出来了;淘汰玩家的干员会还回卡池。
|
||||
- **刷新等待**:按下「刷新」后按钮会变暗,本次请求结束(成功或失败)后恢复可点;等待期间再点按钮或按 R 都不会重复发送,避免高延迟时连点多次扣费(GitHub #501)。
|
||||
- **公共卡池**:独立模拟 I–VI 阶的基础副本数分别为 12 / 14 / 18 / 16 / 8 / 5,少数干员有例外;同盟的公共干员库存是独立的两倍,自选干员仍用各自的私人库存。持有的普通/精锐干员占 1 / 3 份,商店展示不占份数。拿光后不能再刷到,但已经展示的干员商店牌和奖励牌仍可领取;超额部分也占库,卖出先抵消超额,淘汰时归还所持份数。
|
||||
- **装备库存**:普通/进阶装备通常占共享库存 1 / 2 份,道具商店和奖励领取时仍检查余量。缪尔赛思、耶拉等固定特质赠品仍占份数,但不会因库存已满停止赠送;拥有量降回上限以下后商店才有余量。商业包装方案的 2 份是同时库存,不是整局限买两次。
|
||||
- **位置上限**:棋盘最多部署 **8** 名;整备区 **10** 格。整备区满时**不能购买或领取**任何干员、装备(包括能凑齐 3 名合成精锐的那一张,和官方一样),先出售、部署或配发腾出格子;新获得的单位(合成的精锐、奖励、退回的装备等)进入**临时整备区**(整备区前面的 5 格),整备区一空出格子,临时整备区的单位就**自动补进去**(从右往左)。临时整备区有单位时**不能准备就绪**,棋盘上会用红色虚线框标出这一排并说明规则:把它们放进整备区或战场、配发或使用,或者出售 / 销毁。休整期中进入临时整备区的单位,若到**本休整期结束**时仍留在那里就会被自动处理(干员返还卡池、装备销毁);作战、结算期间或准备就绪之后才进入的(例如战斗奖励、结算时合成的精锐),会保留到**下一个休整期**结束,你一定有机会处理它们。
|
||||
- **晋升精锐**:3 名同名干员(无论是否部署)自动合成**精锐**(等级、技能、模组大幅提升;风丸只需 2 名),精锐不会再合成。如果被合成的干员里有**已经部署在战场上**的,精锐会直接出现在**那名干员的位置**(保持原来的朝向,不多占部署位;有两三名都在场上时,取最先部署的那名——最左一列优先,同一列从上到下);否则精锐进入整备区(满了进入临时整备区)。原先配发的装备都退回整备区,需要重新配发;被合成干员的召唤物随之移除,场上的精锐会把自己的召唤物送进整备区。点选一张能合成的商店卡片时,精锐将出现的那一格会亮起金色边框,同时打开的详情卡里也会写明去向(「可晋升:精锐干员将出现在作战区原位置 / 将进入整备区」;详情卡会自动停靠在不遮挡这一格的一侧)。在回合开始或结算时因策略、奖励等完成的合成,精锐出现时同样会播放金色的晋升特效。
|
||||
- **晋级奖励**:每次晋升后获得一次特殊招募:从 3 名 **高一阶**(最高 VI)、**互不相同**的随机干员中**免费**挑 1 名(该阶剩余干员不足 3 名时由低一阶补足)。它不能刷新、不能冻结、本回合结束失效。
|
||||
@@ -124,7 +126,7 @@
|
||||
|
||||
## 7. 最终攻势与隐秘核心
|
||||
|
||||
- **最终攻势**(第 14 回合;独立标准为第 9 回合):按座位两两配对,每对共享一个战场,一人守左半边、一人守右半边(右侧玩家的棋盘左右镜像)。所有人**共同削减同一条领袖血条**(血量 = 该难度的官方领袖生命值 × 开战时存活的玩家人数:领袖生命值取自当前版本的游戏数据,例如终极「假想敌:胄」3 600 000,4 人存活时为 14 400 000、2 人为 7 200 000;AI 队友和掉线托管的玩家都算,已被淘汰或中途退出的不算;独立模拟按 1 人计。这是作者 2026-10-06 采纳社区 PR #209 的决定,取代之前「保持固定血量」的做法),看不到另一组的战场。全队 LP 合并成一个池,不再有每回合 10 点上限。顶部的 120 秒倒计时结束后战斗**继续**(不会超时判负);从开战第 150 秒起(实际秒数)全队每秒扣 1 点 LP。领袖血量归零即胜利(血条显示 0 时领袖已倒下,战斗立即结束),LP 归零即失败——以先归零的一方为准,LP 耗尽后打出的伤害不再计入。此阶段盟约不叠层。
|
||||
- **最终攻势**(第 14 回合;独立标准为第 9 回合):每局开始时随机两两配对(准备期到开战固定,隐秘核心沿用同一分组),每对共享一个战场,一人守左半边、一人守右半边(右侧玩家的棋盘左右镜像)。所有人**共同削减同一条领袖血条**(血量 = 该难度的官方领袖生命值 × 开战时存活的玩家人数:领袖生命值取自当前版本的游戏数据,例如终极「假想敌:胄」3 600 000,4 人存活时为 14 400 000、2 人为 7 200 000;AI 队友和掉线托管的玩家都算,已被淘汰或中途退出的不算;独立模拟按 1 人计。这是作者 2026-10-06 采纳社区 PR #209 的决定,取代之前「保持固定血量」的做法),看不到另一组的战场。全队 LP 合并成一个池,不再有每回合 10 点上限。顶部的 120 秒倒计时结束后战斗**继续**(不会超时判负);超过开战 120 秒后(实际秒数)全队每秒扣 1 点 LP;仅开战时有 AI 队友的队伍延后至超过 150 秒。领袖血量归零即胜利(血条显示 0 时领袖已倒下,战斗立即结束),LP 归零即失败——以先归零的一方为准,LP 耗尽后打出的伤害不再计入。此阶段盟约不叠层。
|
||||
- **限伤**(最终攻势与隐秘核心,与官方相同):对领袖的**单次伤害达到 300000 或以上时整次作废**——这一下造成 0 点伤害,不计入领袖血条,也不显示伤害数字;不是截到 300000,而是一点都不算(299999 照常生效)。只针对领袖本体(含两人战场里的另一个领袖),领袖的部件、护卫、无人机和其他小怪不受影响,普通回合和联防也不受影响;部件把伤害等量转给领袖时,这一下也按一次伤害检查;无人机死亡令领袖失去的 2% 血条不算一次伤害,不受限伤影响(隐秘核心终极「假想敌:胄」3 或 4 人存活时每架为 43.2 万 / 57.6 万,按限伤会整次作废,本作仍然计入,属推断)。层数极高时领袖仍可能几秒内倒下(例如每名玩家的两个盟约都在 999 层,或所有激活的盟约都在 999 层),这来自官方按层数计算、没有上限的伤害倍率(谢拉格、奥术、精准等),官方社区也有「boss一秒死」的说法。
|
||||
- 最终攻势 / 隐秘核心的休整期和战斗中一样,**同组两人在同一个关底战场上**:你在自己的半场摆位(右侧玩家看到自己在右边,摆位左右镜像,干员相对领袖的朝向不变),队友当前的阵容显示在另一半场(只能看,随他调整实时更新);左侧成员栏里同组两人的头像带**绿框**,从回合开始一直到战斗结束。观战者查看其中一人时也会看到两人的阵容和站在关底的领袖。
|
||||
- 最终攻势 / 隐秘核心的休整期按你在**关底战场上的半场**判定可部署地块(右侧玩家左右镜像),拖动时高亮的就是可放置的格子:例如「源石流发生装置」关底图中被 1×3 橙色管子围起的地块可以部署。在最终攻势放在这些格子上的干员进入隐秘核心时会留在原位。
|
||||
|
||||
+40
-2
@@ -164,14 +164,18 @@ the old tile-key order), so a rotated layout plays the same (test/sim/facing-inv
|
||||
directions on an open field). 余 S3's fire wall runs through his tile perpendicular to his direction (his column facing
|
||||
RIGHT / LEFT, his row facing UP / DOWN; fx `firewall.axis` = `'col'|'row'`).
|
||||
`carryState: { hpPct, sp }` restores unite helpers' operators (HP ratio, and the SP = the official 技力, stored charges
|
||||
included: `unitsEnd` reports `skill.spTotal`, reset rebuilds the charges from it, and `skill.setSpTotal` sets it again after
|
||||
the `deploy` hook — PRTS "部署完成后…技力修改至与上一阶段结束时相同", so 独行 / 黄沙罗盘's deploy-time SP does not add to it;
|
||||
included: `unitsEnd` reports `skill.spTotal`; after all initial deployments and `battleStart` hooks, HP is restored
|
||||
against the new maximum and `skill.setSpTotal` restores the charges — PRTS "部署完成后…技力修改至与上一阶段结束时相同",
|
||||
so 独行 / 黄沙罗盘's opening SP does not add to it. Positive ratios are not raised to 1% or 1 HP;
|
||||
later redeploys keep those gifts); `carryState: { sp }` a board summon
|
||||
piece's SP only (PRTS "召唤物仅修改技力"). Nothing else is carried: a skill running at the end of the own combat enters
|
||||
switched off with the SP it had left — spent at its activation, 0 for a one-charge skill (`unitsEnd.skillActive` is
|
||||
reported, never carried; community report #34 — it used to restart for free).
|
||||
Timed deployment skills (`activateOnDeploy`) trigger once from the new deployment, consuming their deployment charge;
|
||||
they do not resume the previous skill state.
|
||||
The approved Egir carry policy rebuilds opening devour attributes while skipping its repeated HP loss on a living
|
||||
carry target; this prevents artificial knock-outs and revival-slot consumption before restoration. A fresh target
|
||||
without a previous state still takes that opening damage. Later combat, HP loss and redeploys follow the normal rules.
|
||||
`carryState: { down: true }` = an operator knocked out at the end of the helper's own combat (PRTS 卫戍协议/帮助 "上一阶段为
|
||||
退场状态的干员强制退场", user playtest #5 item 2): `start()` deploys it with everyone (initial `deploy` fires), then — before
|
||||
`battleStart` — withdraws it with reason `FORCED_EXIT` (constants.js `'forcedExit'`) and HP 0 (the end-of-phase HP ratio, as after
|
||||
@@ -1065,6 +1069,11 @@ has the generic spec. Summons: `battle.tokenDef(tokenId, unit)` / `battle.spawnT
|
||||
(token skill of that slot: `variants[owner].bySkill[i]`, module attributes: `.byModule[id]`). Coverage of the selectable
|
||||
skills: `node tools/kit-coverage.mjs --missing [--tier N] [--strict]`.
|
||||
|
||||
忍冬's 追凶 records the per-enemy start time and applies its arts addition in the same `damaged` callback,
|
||||
including the first instance (PRTS talent remarks; DESIGN §30.3). It does not refresh the ten-second window on
|
||||
repeated hits. Its `vulpisHunt` / `addition` tags and re-entrancy guard prevent the rider from feeding itself.
|
||||
`test/content/vulpis-first-hit.test.js` checks actual HP loss and damage events with naturally ready skills.
|
||||
|
||||
Timed deployment kits: 宴 S2 uses `bb.duration` and skill ATK mods, loses current HP once on landing, and converts normal
|
||||
physical attacks to arts only while S2 is active; S1 has no such conversion. 斯卡蒂 S2 uses its own record's
|
||||
`s.bb.duration` / `s.bb.atk`; DRE-Y revival in place does not restart it. 野鬃 S1 uses its record's `duration` /
|
||||
@@ -1486,6 +1495,11 @@ objects), and call `checkInvariants` at the end. Run: `node --test test/sim/*.te
|
||||
|
||||
## 11. Tools
|
||||
|
||||
Equipment/fortress corrections (§30.3): Kjerag Ice and Tear Gas use actual enemy damage events, including DoT,
|
||||
not attack declarations or element-gauge/HP-loss events. Fortress fallback targeting and skill automation include
|
||||
the operator's own tile after blocked/ranged candidates; only explicitly enabled skills retain melee splash.
|
||||
`performAttack` captures each shot's `fortressMelee` flag for impact resolution, independent of later target changes.
|
||||
|
||||
`node tools/simrun.mjs --mode mode_multi_normal --round 5 --stage act2autochess_m01 --lineup "角峰@9,7 隐现@10,4 艾丝黛尔*@12,5" --ascii 10`
|
||||
prints a per-unit damage/DPS/kills/heal table (+ ASCII frames: letters = operators, digits = ground enemies per tile,
|
||||
`^` = flyers, `■` = crates). `--wave <template>`, `--seed`, `--content generic`, `--hpMul/--atkMul/--speedMul`,
|
||||
@@ -1496,6 +1510,30 @@ skill × module, every bond at its threshold and at 999 layers, every leader fie
|
||||
reduced to digests in `test/golden/*.json`; `test/golden.test.js` fails, naming the scenario and the field, when a
|
||||
digest moves. A refactor must leave them unchanged; `npm run golden:update` records an intended gameplay change.
|
||||
|
||||
Cross-engine check (`tools/sim-check.mjs`, `public/dev/sim-check.html`): a browser simulates its battles and the server
|
||||
re-simulates them (SP_VERIFY, takeovers), so every engine must give the bits Node gives. `node tools/sim-check.mjs`
|
||||
builds the golden battle corpus (roster, bonds, fields, standins, diy — 267 BattleSpecs) and traces it in Node into
|
||||
`public/dev/simcheck/data/` (git-ignored, about a minute): after every tick the state (battle time, RNG state and per
|
||||
unit alive / removed / deployed, x, y, hp, atkCd, SP, the five element gauges, damage dealt / taken, healing, blocking
|
||||
count) hashed to the exact IEEE-754 bits, one hash per 30 ticks; every 30 ticks `b.snapshot()` (what viewers are sent);
|
||||
every 150 ticks a bounded traversal of unit-reachable state (depth 8; definitions, profile/kit, battle pointers, functions,
|
||||
accessors and `seq` left out); and `resultDigest(b.result())`. `/dev/sim-check.html` — on any device that reaches the server, a phone
|
||||
included — runs the corpus (or its fast subset, or a family) with the client's own loader (`loadBrowserSim`) and
|
||||
compares block by block: a battle that parts is reported with the first interval where a difference was detected, the level (the outcome, the
|
||||
snapshot, the state, or the hidden state only), and a download of that block's state vectors;
|
||||
`node tools/sim-check.mjs --diff <file>` replays the same ticks in Node and names differing recorded vector entries
|
||||
(with both values' bits). It cannot locate view-only/deep-only differences absent from those vectors.
|
||||
Special float values, including negative zero, survive JSON export. `--engines chromium,firefox,webkit [--subset fast|all|<family>]` runs the page headless after
|
||||
the trace (Firefox and WebKit through the `playwright` package when it is installed, `npx playwright install firefox
|
||||
webkit`; Chrome through puppeteer-core otherwise) and exits 1 on a difference. The tracing, comparison and page
|
||||
(`public/dev/simcheck/core.js`, `ui.js`, `node.mjs`) have no dependency on this repository; `stronghold.js` is the part
|
||||
that knows the sim. `--engines chrome` uses the local executable selected by `CHROME_PATH` (including Edge).
|
||||
The reference, corpus and dump share a content identity over scenarios, served sim/shared code and data. Dirty changes
|
||||
invalidate the reference. The page rechecks inputs before runs/dumps; `--diff` rejects stale or mismatched inputs.
|
||||
An empty/unknown selection, max-tick truncation or runtime exception is an error, never a zero-battle success.
|
||||
All-green means the recorded fields, sampled snapshots and result hashes agree: it does not prove equality of every
|
||||
internal field, event, projectile queue or rendered frame. Verification results belong to the tested revision.
|
||||
|
||||
## 12. Data notes (simdata.js)
|
||||
|
||||
Defs are normalised from data/*.json (DATA.md) with research JSON as a fallback. Chess: `stats {maxHp, atk, def, res, aspd,
|
||||
|
||||
@@ -4,6 +4,12 @@ Part of [DESIGN.md](../DESIGN.md) (the index; section numbers are global).
|
||||
|
||||
## 9. Client rendering contract (public/js/render)
|
||||
|
||||
Bond-layer audio (#482): live own preparation gains arrive as `m.layer`; the displayed battle's `layer` tuples
|
||||
play the same `bondUp` cue. A gain of several layers plays once, including gains to inactive bonds. The official
|
||||
`ON_ACT1AUTOCHESS_ADD_BOND` bank allows one sound and replaces the oldest; there is no added cooldown. Existing
|
||||
bond activation cues use this same channel. State snapshots, settlement backfill and buffered field-entry events
|
||||
do not generate layer cues. Field entry, leaving the game and muting cancel playing or still-decoding layer audio.
|
||||
|
||||
`import { createFieldView } from './render/app.js'`
|
||||
|
||||
```js
|
||||
@@ -150,3 +156,17 @@ It does not diagnose a particular user's GPU or persist a disabled renderer pref
|
||||
Community UI and recovery (§29.6): DIY reuses roster stats and skill previews; Swire's coins show current/cap. The lobby player chip opens statistics and the responsive header keeps optional controls from overlapping its title. Settlement's summary scrolls separately from Return. Failed board art can retry, and failed three.js imports use two bounded fresh URLs. Failed engine initialization can remount on online/visibility, at most twice consecutively, cleaning up each previous view; explicit fallback/2D stays selected.
|
||||
|
||||
Replacing a field renderer also resets the game screen's entered-view identity. Frames are buffered while the replacement loads; after subscribing, the local runner re-shows full current metadata/snapshot, or the server resends the watched field. Real local and server-streamed battles preserve the same simulation object and clock through recovery, including enemies spawned before the new renderer existed.
|
||||
|
||||
§30.4: the HP tower retains current HP until settlement and shows pending loss separately. Shop refresh is single
|
||||
flight per client until its promise settles (success or error); the button and R key share that state. DIY-only
|
||||
potential/cultivation changes are exportable; spectator member resolution includes the owner's DIY selection.
|
||||
Bond tooltips show banned visible members in one clamped scrollable compound box, stacked on narrow screens.
|
||||
An open list updates on member changes; long-press lists persist for reading and close on outside tap/Escape.
|
||||
The developer simulation checker and its bounded verification claims are specified in SIM §11 and history §30.5.
|
||||
|
||||
Operator attack clips play once per actual attack (§30.7), retaining their interval-based speed. A prepared wind-up
|
||||
is consumed without replay; a later queued attack may start during the previous recovery. Without another attack
|
||||
the actor returns to base/skill idle. Late operator events still start at the strike frame; no sim timing changes.
|
||||
|
||||
Connection recovery sits above the AI-away overlay and below global modals (§30.8), so a replaced session can
|
||||
be reclaimed by pointer while away. This does not change the stacking behavior of modals nested inside a screen.
|
||||
@@ -4,6 +4,15 @@ Part of [DESIGN.md](../DESIGN.md) (the index; section numbers are global).
|
||||
|
||||
## 5. Simulation (server/sim) — engine contract
|
||||
|
||||
联防 restoration (§30.13): the initial setup establishes buffs and devour attributes before restoring living
|
||||
operators' exact HP ratio and total SP; summons restore SP only. Opening Egir devour does not damage a carried
|
||||
survivor a second time. Forced-down carry stays out without spending an Egir revival; subsequent real knock-outs
|
||||
and redeploys are unchanged. No 1% / 1-HP floor is added to a positive carried ratio.
|
||||
|
||||
Expanded automatic skill areas (§30.11) include 司霆惊蛰 S2's current low-terrain flood when it strictly contains the
|
||||
initial area, 斩业星熊 S3's selectable air targets, and 予愿安洁莉娜 S3's initial 3-9 ∪ x-4.
|
||||
Permanent range extension, control/operation cooldown and targetability still apply; normal attacks are unchanged.
|
||||
|
||||
### 5.1 Battle API (implemented by the sim-core owner; consumed by match & content)
|
||||
|
||||
```js
|
||||
@@ -172,6 +181,8 @@ Profession traits (default behaviours implemented once in the engine, keyed by `
|
||||
Each domain file `server/sim/content/{tokens,bonds,garrisons,items,bands,enemies,bosses,devices,choices}.js` exports `install(battle)` (battle side; called once per Battle by `content/index.js → installContent`) and `registerMeta(registry)` (prep side; called once at server boot by `match/effectsMeta.js`, API documented in `docs/META.md`). Kits live one per file in `content/kits/ops/` (`export default { [baseChessId]: (bb, chess, def) => Kit }`), listed in `content/kits/index.js` (guide: `content/kits/README.md`). Content must be **data-driven from blackboards** (numbers never hard-coded when a blackboard key exists). Every effect gets at least one unit test in `test/content/*.test.js` using the `test/helpers/battleHarness.js` harness (provided by sim-core).
|
||||
|
||||
- kits: all 112 visible chess + hidden ones used by effects (盟约·辅助干员), default skill + talents + trait specifics; tokens. The 72 non-collaboration 6★ DIY operators include 克莱门莎 (§28.1); her launch-day provisional mechanics are local to her kit. 黍 GUA-Y and 乌尔比安 CRU-Y follow the generated module blackboards and existing module-level rules (§28.2).
|
||||
忍冬's 追凶 adds arts damage on the first qualifying instance too; the per-enemy window starts on that instance,
|
||||
and repeated hits do not extend it. The addition keeps its own tag and re-entrancy guard (§30.3).
|
||||
克莱门莎 S2 removes dead, hidden, self-bound / teleport-immune and unreachable passengers before checking new
|
||||
boarding in the same tick. Each released passenger returns its current weight to the cabin's remaining budget
|
||||
and frees its slot (§28.27); neither corpses nor failed carries reserve capacity.
|
||||
@@ -233,3 +244,14 @@ tile and attack-reach checks. Hand/temp rows, empty halves in single-player mode
|
||||
The general `onOwnBoard`/`onFieldBoard` contracts, other movement, summons and preparation placement remain unchanged.
|
||||
|
||||
香槟炸弹 also rejects ordinary allied auras and inspiration as a device, through its shared internal selection barrier (§29.11). This does not isolate other unhealable summons or alter its detonation.
|
||||
|
||||
§30.3 corrections: fortress targeting is blocked → ordinary range → own tile (selectable ground targets only).
|
||||
Automatic skill checks include that extra tile. Melee attacks are single-target unless the skill explicitly gives
|
||||
melee splash (Horn S2). Each shot retains its launch-time melee mode while flying. The bounty catapult's normal
|
||||
triple attack splashes at radius 1.5; 0.5 belongs to its 入戏 override. Clementia S3 accepts enemy erosion bursts only.
|
||||
Kjerag Ice and Tear Gas proc on actual carrier damage to enemies, including DoT/skill/splash; elemental gauge fill,
|
||||
HP loss, bare attack events and zero damage do not qualify. These changes do not alter other items' attack hooks.
|
||||
|
||||
Xi alone opts into hostile selection immunity during its ten-second post-rebirth invincibility (§30.8).
|
||||
The generic `invulnerable` flag remains damage protection only; other rebirth kits retain their existing choices.
|
||||
Presence-only and SP-only skill triggers are not suppressed merely because their enemies are unselectable.
|
||||
+10
-1
@@ -27,7 +27,7 @@ LOBBY(room) → INFO_CHECK (co-op 25 s; solo and any single-human match untimed
|
||||
PRTS "上一阶段为退场状态的干员强制退场", its full redeploy timer — confirmed by the user (§19.3))
|
||||
SETTLE (LP −min(leaks,10) per player (unite survivors to their source); eliminations; layer gains applied; coins; broadcast;
|
||||
after a 联防 the outcome and every player's charge ride the SETTLE m.public as `uniteResult`, GitHub #235)
|
||||
R14 FINAL_ASSAULT (merged team LP; pairs by seat; shared boss pool; 120 real s countdown, no hard stop; overtime −1 team LP per
|
||||
R14 FINAL_ASSAULT (merged team LP; random pairs fixed for this match; shared boss pool; 120 real s countdown, no hard stop; overtime −1 team LP per
|
||||
real s after 120 real s, or 150 with an AI teammate at fight start (§29.1, m.public.overtimeAt); no layer gains; team LP 0 ⇒ defeat at once (PRTS 直接失败),
|
||||
pool 0 ⇒ victory — the first end the server registers decides, §20.5)
|
||||
R15 HIDDEN_CORE (if difficulty ≥ NORMAL and Σ activated layers > 350 solo / 1200 co-op and team LP > 1)
|
||||
@@ -113,6 +113,9 @@ Official rule (bwiki 盟约, 更新公告 5114): before a match the player canno
|
||||
**UI:** a 干员调配 screen reachable from the lobby, room and briefing (INFO_CHECK): the roster is one list, a row per operator with aligned columns under a head row — portrait / name / bonds (opens the detail), the skills and the elite's modules to tap (不装备 first; the quick choices of PR #301 by @farridge, laid out as one list instead of its three columns of cards, the owner's call of 2026-10-08), the 潜能 / 练度 selects (`screens/loadout.js RosterRow`, `screens/cultivation.js`); the detail beside it (below 1000 px wide it slides over the list, a quick choice keeps the list) opens with the 潜能与练度 section (潜能 1–6, the four official 练度 tiers with their effect texts); the detail then shows the operator's 特质 — the in-match `GarrisonBlock` / RichText of the form its 普通 / 精锐 skill toggle shows, triggers such as 「<进入休整期时>」 kept (0.2.2, PR #301 by @farridge); filter by tier/bond/class, per chess choose skill (icon, name, SP info, desc at Lv4 and Lv7) and elite module (official type icon from the local-client art when extracted (§13), else a lettered tile; name/type, stat bonus, trait/talent changes), 恢复默认, persisted; a 局内数值 section between the skills and the modules (普通 / 精锐 toggle, 精锐 first) draws the detail card's own stats block, 特性 and 天赋 for the chosen skill / module (§22.16); the chosen skill/module shown on shop cards and in the detail panel (a teammate's unit — 前往查看 in prep, 联防 / 最终攻势 fields, an observed battle — shows **its owner's** skill/module from its UnitInfo `skillIndex` / `moduleId`, `gameLogic.js unitLoadout`) — which also shows the stats, 特性, talents and attack range the unit fights with (`shared/loadoutRecord.js` `loadoutRecord` / `attackRangeGrid`, the same composition the sim's `getChess(id, loadout)` uses; the deploy wheel's range preview too) — the range it is deployed with: a selected passive range skill (引星棘刺 S3 3-9) and the 特性's permanent 攻击距离 (信仰搅拌机 SPT-Y +1) included, over all 1284 loadouts equal to the battle's range at deployment; in battle and prep the card draws the live `range` (§21.16). In battle the renderer plays the equipped skill's own Spine clip and ON_SKILL_START sound (`UnitInfo.skillIndex`; `data/assets.json` lists icons, clips and sounds for every skill index).
|
||||
|
||||
**导出 / 导入 a loadout:** 导出 hands the current loadout out as a versioned envelope `{ kind: 'stronghold.loadout', v, exportedAt, count, entries, ops }` (`ops`: the 潜能 / 练度 settings, 0.2.2 — an import without them leaves the current settings alone) — copied through `ui/clipboard.js copyText` or downloaded as a file. 导入 accepts that envelope, the stored `{ v, entries }` form and a bare `{ [chessId]: { skill, module } }` map (serialised text too), and refuses another tool's `kind`, junk, an empty result and a **newer** `v` (`parseImport` — a newer payload is never mis-read). Both go through `loadoutSync.applyLoadoutEntries` → `sanitizeEntries`, so a preset from another build never sends the server an entry it would refuse, an import that keeps nothing changes nothing, and the result is reported as N imported / M not (`ui/loadoutModel.js`, `screens/loadout.js`).
|
||||
Export remains available for non-default `ops` alone, including operators outside the normal roster. The roster's
|
||||
adjusted count and reset scope stay limited to its own operators; the DIY export still carries picks only.
|
||||
|
||||
**干员持有 (0.2.0 补位):** the overlay's second tab marks operators as not owned (the 55 NORMAL chess; per-browser `sp.pref.ownership` = `{ v, notOwned }`, synced as `room.ownership { notOwned }` by the same sync engine as the loadout, `ui/loadoutSync.js installPrefSync`; 导出 / 导入 as `{ kind: 'stronghold.ownership', v, exportedAt, count, notOwned }`). A not-owned chess fights as its official stand-in with the backup skill / module — the loadout never applies to it ("对于补位干员其技能不可更改") — and is shown as the stand-in wherever the player's copy appears (shop / reward cards, hand and 临时整备区, board, detail card, result lineup, a teammate's scouting and bond popups: portrait, name, model, with a small 「替补」 mark; the owner's recall of the official mode, 2026-10-06), while its bonds, 特质, tier, price and merge stay the chess's; the 0.2.0 补位 design subsections have the rules and sources.
|
||||
**自选编队 (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.
|
||||
|
||||
@@ -141,3 +144,9 @@ before mutation; data preparation failure leaves the old opening intact. This is
|
||||
mode rule. Unrelated result-screen changes from PR #408 are not included.
|
||||
|
||||
The shared DIY detail preview (stats, range, trait, talents, potential, cultivation and matching normal/elite skill descriptions) follows roster loadout resolution. Jessica shield placement is limited to her four orthogonal neighbors. The two Ptilopsis prep-gift copy exceptions are defined in §29.3 and META; no generic change to all copied conditions is implied.
|
||||
|
||||
Shared-stock corrections (§30.1–2): fixed `SERVER_GAIN_EQUIP` gifts still occupy ordinary/advanced 1/2 copies but
|
||||
may exceed capacity; `requirePool:false` bypasses equipment limits only for these fixed families. Already displayed
|
||||
operator shop and reward offers remain valid after exhaustion and record the deficit. Future rolls exclude empty
|
||||
stock; selling repays the deficit first. Item shop/reward claims and 整备 remain stock-limited. Rockrock and Flametail
|
||||
use the measured weighted subsets described in DATA and §30.2.
|
||||
@@ -40,6 +40,9 @@ S→C:
|
||||
interpolate linearly; nothing is inferred from the attack animation.
|
||||
- `b.ev` — `{ fieldId, ev: [ ... ] }` event tuples: `['spawn', UnitInfo]`, `['atk', srcId, tgtId, projKind]`, `['dmg', tgtId, amount, type]`, `['heal', tgtId, amount]`, `['skill', id, on:0|1]`, `['die', id, reason]` (reason `'killed'`, `'retreat'`, `'expired'` …; only `'killed'` plays an operator's knock-down sound), `['status', id, key, on]`, `['fx', kind, x, y, extra]` (`'hitCap' {id, n}` = a cancelled 限伤 hit, drawn as nothing, §20.12; `extra.form` = the unit's model form from then on (an enemy's mode, a 傀儡师's 替身 §22.11) — `shared/protocol.js fxForm`, state like a spawn: never dropped by the client, §21.4), `['layer', playerId, bondId, n]`, `['bounty', playerId, coins]`, `['deploy', id]`
|
||||
- `m.toast {kind, text}` · `m.ticker {text, id, type, priority, playerId}` (the strip plays the highest priority first, §21.10) · `m.emote {playerId, id}` · `m.result {...}`
|
||||
- `m.layer {playerId, bondId, add}` — a positive prep layer gain, sent only to that player in ROUND_START / SP_DRAFT /
|
||||
PREP for audio feedback. No replay on reconnect/resync and no settlement backfill; battle audio uses `layer` tuples
|
||||
from the displayed field. Older clients can ignore this notification without changing gameplay.
|
||||
- `m.unitStats {seq, round, units: [unitStatsEntry]}` — the answer to `g.unitStats` (`seq` echoed): every own board operator / summon by uid with `{id, uid, defId, hp, alive, maxHp, atk, def, res, interval, blockCnt, moveSpeed, range?, base: {…same}}` and `dir?` (shared/protocol.js `unitStatsEntry`; `range` = an ally's live range grid, facing RIGHT, never a target-selection grid, §21.16; `dir` = the unit's facing now, UP|RIGHT|DOWN|LEFT — the snapshot tuples carry none and UnitInfo `dir` is the facing at send time — which the detail card's range overlay rotates by, GitHub PR #281) as its next battle starts (§18.5)
|
||||
|
||||
`UnitInfo = { id, kind, side, ownerId, defId, name, tier, golden, spine, avatar, x, y, facing, dir, maxHp, skillIndex?, moduleId?, items?, form? }` (`dir` = the ally's deploy direction; the renderer picks the model (Back for UP, mirrored for LEFT) and the ground wedge from it; `skillIndex` = the ally's equipped skill (§16): the renderer plays that skill's Spine clip (`anims.skills[i]`) and the audio its own ON_SKILL_START sound; `moduleId` = an elite ally's equipped module (uniEquipId | 'none'); `items` = an ally operator's equipped item ids (absent without any; never on enemies or summons): the bond popup lists a 变形同构体 wearer from them and the detail card shows its 同构 chip and 装备 (§21.11); all three also on the prep-scouting `m.field` units (`Match.prepFieldMeta`, the held pieces included), so a teammate's unit shows its owner's loadout in the detail card and its equipped items as item pips on the unit (prep surfaces; user playtest #2 item 3); `form` = the unit's current model form (an enemy's mode §21.4, a 傀儡师's 替身 §22.11; a view built mid-battle starts in it)).
|
||||
@@ -140,3 +143,15 @@ Optional `coins: [[id, balance, cap]]` is an integer skill-currency readout (§2
|
||||
Preparation state carries read-only `prepMapChars` for 外勤医疗. Boss-mate and scouting metadata include them in
|
||||
displayed field coordinates. Draft cards expose optional `soldOut` when equipment stock is unavailable; the client
|
||||
disables picking and the server checks again before consumption.
|
||||
|
||||
`m.public.bossPairs: [[playerId, playerId?], …] | null` publishes the preparation pairing, first member left and
|
||||
second right. It is drawn once per match and retained for Hidden Core; intact pairs survive departures, singles
|
||||
pair up. Clients use it for preparation camera/placement/team frames; only older servers lacking the field use
|
||||
the legacy seat-order fallback. Viewing or reconnecting never draws a new pair.
|
||||
|
||||
[ASSUMED] Touch's confirmed initial team grant persists for this match after its holder is eliminated or leaves.
|
||||
The maintainer explicitly approved retaining this policy on 2026-10-12; its official-game lifetime remains unverified.
|
||||
The preparation medic and future battle's medic/Touch use the same entitlement; the receiving player must still be
|
||||
alive. A mere draft focus grants nothing; another match starts without it. Only `band_amedic` is retained in
|
||||
`contentInfo.matchBands`; `teammates` and other team strategies still filter eliminated players. The official text
|
||||
and initial/unlimited-round data do not explicitly settle the holder-elimination case (§30.14).
|
||||
@@ -0,0 +1,273 @@
|
||||
# DESIGN §30 — 0.2.5-dev
|
||||
|
||||
Part of [DESIGN.md](../DESIGN.md). Community reports after 0.2.4.
|
||||
|
||||
## 30. Community follow-up
|
||||
|
||||
Unreleased. Sources and decisions below describe the candidate behavior, not a published release.
|
||||
|
||||
### 30.1 Fixed gifts and already displayed operator offers
|
||||
|
||||
Reports #488/#491/#495, PR #496 by @2321Robin, and the owner's Muelsyse/Yela report: the shared equipment
|
||||
cap added in 0.2.4 incorrectly prevented fixed acquisition gifts. [路标档案馆:路标月报#2](https://www.bilibili.com/video/BV1eLXXBqEgF/)
|
||||
states both that equipment from any source occupies stock and that these operators' fixed gifts are unlimited.
|
||||
The owner confirmed on 2026-10-11: gifts still occupy stock, but exhaustion must never prevent the gift.
|
||||
|
||||
`GameData.isUnlimitedGiftItem` derives the families from `SERVER_GAIN_EQUIP`. The actual gift passes
|
||||
`requirePool:false` through `effectsMeta` to `acquireItem`; only those families bypass availability. Ordinary and
|
||||
advanced copies still count as 1/2. This covers Carnelian, Beeswax, Muelsyse, Kjera and Swire, and the same fixed
|
||||
family awarded by the Damazti strategy. It does not make every strategy's equipment unlimited. Shop and item
|
||||
reward purchases, random grants and 整备 upgrades keep their availability checks. When ownership exceeds the cap,
|
||||
removing one copy need not immediately reopen stock. The proposed PR's zero-occupancy model was not adopted.
|
||||
|
||||
#498: the same video's approximately 2:50–2:57 example describes nine owned 溯光星源 plus one
|
||||
already displayed card in each of four shops, allowing thirteen owned copies. Shop operator purchases therefore
|
||||
do not recheck availability; `acquireChess` records the full deficit and later rolls still exclude exhausted stock.
|
||||
The owner explicitly extended this behavior to already displayed **operator reward** offers; that extension is a
|
||||
maintainer decision, not footage directly establishing every reward type. Item purchases/rewards remain bounded.
|
||||
Funds, full-hand checks, per-offer sold flags, merge accounting and selling returns retain their ordinary rules.
|
||||
|
||||
Tests: `test/match/community-round30.test.js`, `shared-stock.test.js`, `garrisons_meta.test.js`.
|
||||
Normative rules: META shared inventory and helper contract; design/match economy.
|
||||
|
||||
### 30.2 Measured acquisition subsets
|
||||
|
||||
PR #497 by @2321Robin, same original research video's rule table: Rockrock's subset weights are ordinary hammer
|
||||
10; tremble/undying/haste/burn hammer 2 each; limited accelerator 1; shield drone 4; laser emitter 4. Flametail's
|
||||
subset is Wild Mane 10, Ashlock 10, Fartooth 3. These are weighted subsets, still constrained by shared stock.
|
||||
The generator owns these tables; `data/choices.json` is regenerated offline. Tests cover the table and exhausted
|
||||
eligibility rather than asserting that subset draws ignore ownership.
|
||||
|
||||
### 30.3 Damage and target-selection corrections
|
||||
|
||||
- Clementia S3's erosion-burst listener now requires an enemy target. The generic targetability helper does not
|
||||
check allegiance, so a friendly erosion burst previously scheduled a shot. Enemy bursts retain the same behavior;
|
||||
the mode's existing boss erosion immunity is unchanged.
|
||||
- Horn follows [PRTS 号角](https://prts.wiki/w/号角): blocked targets first, ordinary ranged targets next, then a
|
||||
selectable ground target on her own tile. Her skills' automatic target check includes that tile. Other adjacent
|
||||
blind tiles do not become valid targets. Ordinary/S1/S3 melee attacks are single-target; S2 explicitly retains
|
||||
melee splash. Each shot captures its melee mode, so switching targets cannot remove splash from a shell in flight.
|
||||
- The bounty [自制投石机](https://prts.wiki/w/自制投石机) has normal splash radius 1.5 and three hits. The old
|
||||
radius 0.5 came from its 入戏 override, not its ordinary attack. Tests cover adjacent/diagonal allies and an outside
|
||||
target. No claim is made here about unrelated attack cadence.
|
||||
- PR #502 by @Xinglan233: Vulpisfoglia's first qualifying damage instance marks the enemy and also deals 追凶's arts
|
||||
addition. Repeated damage does not extend the original window. Source: [PRTS 忍冬](https://prts.wiki/w/忍冬).
|
||||
- #489: [PRTS 盟约记录](https://prts.wiki/w/卫戍协议:盟约_下半/PRTS盟约记录) corrects both Kjerag Ice and Tear Gas
|
||||
from “攻击时” to “造成伤害时”. Only these handlers move to actual carrier damage, including DoT, skill and splash
|
||||
instances; bare attack events, elemental gauge fill, HP loss, zero damage and friendly targets do not trigger them.
|
||||
Existing probabilities/durations remain data-driven. Regression uses real Ascalon poison while she is disarmed.
|
||||
|
||||
Tests: `community-round30.test.js`, `item-damage-procs.test.js`, `vulpis-first-hit.test.js` under test/content,
|
||||
plus existing item/operator/fortress suites. Normative rules: SIM and design/engine.
|
||||
|
||||
### 30.4 Client information and input
|
||||
|
||||
- PR #503 by @Xinglan233: a loadout containing only operator potential/cultivation changes is exportable.
|
||||
- PR #505 by @Xinglan233: spectator `ownerOps` includes resolved DIY selection, so bond members have correct identity.
|
||||
- #479 / PR #504 by @robll-v1, owner approved: before settlement, the HP tower shows the current HP and a separate
|
||||
pending-loss marker; settlement updates the number. This supersedes the earlier live pre-deduction display choice.
|
||||
- #501 / PR #507 by @robll-v1, owner approved: a refresh request in flight disables another button/R request until
|
||||
it settles, including errors. This is a client interaction guard, not server-wide request idempotency.
|
||||
- #484 / PR #494 by @YuriRestia, owner approved: bond tooltips list this match's banned visible members. Main text
|
||||
and list share one viewport-clamped, scrollable container; narrow screens stack the sections. Dynamic member
|
||||
changes update an open tooltip even if the displayed count stays equal. Long-press lists persist for reading and
|
||||
close on outside tap or Escape; ordinary tooltips keep their short behavior.
|
||||
- PR #490 by @D-Roote: Korean UI corrections; the obsolete 3D failure key from the proposal is excluded in favor of
|
||||
the current recovery message. PR #499 by @YuriRestia: English Victoria text explicitly applies its bonus per item,
|
||||
through a narrow correction layer in the translation generator, retaining all placeholders.
|
||||
|
||||
Tests: refresh unit/E2E, loadout export E2E, match-info, watch-bonds, round30-tools E2E and i18n checks.
|
||||
Normative rules: PLAYING, I18N and design/client.
|
||||
|
||||
### 30.5 Developer simulation checker
|
||||
|
||||
PR #492 by @Cloudnyco, owner approved: `/dev/sim-check.html` replays generated BattleSpecs with the real browser
|
||||
loader against Node traces. The corpus is the golden battle families, excluding full matches. The CLI generates
|
||||
local ignored reference data and can run browser engines or compare an exported state block.
|
||||
|
||||
Review repairs: reject unknown/empty selections and unfinished runs; recover controls after start/step/result/dump
|
||||
errors; preserve negative zero and other special floats across JSON; verify shared content identity for specs,
|
||||
reference, runtime and data; reject stale inputs; escape reference text; allow long mobile result lists to scroll.
|
||||
Reports state the first **detected interval**, not an exact first divergent frame. State dumps can diagnose only
|
||||
their recorded fields; snapshot/deep-only differences may not expose a differing field. The bounded unit-state
|
||||
traversal and sampled snapshots do not prove equality of all memory, events or rendered frames. See SIM §11.
|
||||
|
||||
### 30.6 Rechecked reports without a gameplay change
|
||||
|
||||
Real browser loading verifies seven different Yan operators plus ordinary/elite Muelsyse carrying the converter
|
||||
and Yan item: count 9, tier 3 and two actual 炎佑. All 36 normal/advanced conversion-item pairings retain battle
|
||||
membership. The preparation/combat conversion feedback was not reproduced in these cases; this does not exclude
|
||||
an unreported configuration. Normal/hidden Helm missile tests confirm the existing shell and its 3×3 stun.
|
||||
|
||||
Lost Colossus and Zaaro exist in the source catalog, but the fixed bounty draw groups remain based on the earlier
|
||||
22-match evidence. The owner chose to retain those groups until direct grouping evidence is found; catalog
|
||||
presence alone is insufficient to assign a group or probability. No AI formation change from #452, WebRTC #487
|
||||
or built-in HTTPS #478 is included.
|
||||
|
||||
### 30.7 One visual clip per operator attack
|
||||
|
||||
#506: a single attack formerly kept an operator's animation looping for 1.4 attack intervals, allowing a second
|
||||
visual swing without another simulated attack. The owner approved playing one clip per real attack, retaining
|
||||
operator rhythm scaling, enemy speed rules, skill selection and damage timing. A queued wind-up can restart during
|
||||
the preceding attack's recovery; its actual attack consumes that preparation rather than replaying the clip.
|
||||
After recovery the actor returns to its base/skill-idle clip. With no look-ahead, operators still start at the strike
|
||||
frame: completing a late wind-up would delay the visual hit, so that separate policy is deliberately unchanged.
|
||||
Tests cover single/continuous fast attacks, real Provence metadata, skill idle and attack interpolation.
|
||||
|
||||
### 30.8 Xi invincibility and session recovery
|
||||
|
||||
#510: [PRTS “自在”](https://prts.wiki/w/“自在”) specifies ten seconds of invincibility after its five-second rebirth;
|
||||
the linked status also excludes hostile selection. Xi opts into `reborn.invincibleUntargetable` for that same
|
||||
window. The general `invulnerable` flag and other enemies' rebirth policies are unchanged. Attacks and
|
||||
target-requiring automatic skills wait; presence-only `SKILL_RANGE` and SP-only skills retain their existing
|
||||
trigger rules. In-flight damage and elemental fill still deal zero during invincibility. Tests cover all four
|
||||
trigger types, projectile arrival and resumed attacks after expiry.
|
||||
|
||||
PR #511 by @RainoutOvO: the connection recovery banner now sits above the AI-away overlay, so “continue here”
|
||||
remains clickable after another page replaces the session. It remains below global modals. Tests use actual
|
||||
pointer hit-testing and clicks, including a global confirmation dialog over the recovery button.
|
||||
|
||||
### 30.9 Golden changes
|
||||
|
||||
Before the follow-up changes below (§30.11 onward), all 287 scenarios were regenerated, with no additions or deletions;
|
||||
45 differed from 0.2.4. This table records that initial candidate. Follow-up changes are recorded separately below.
|
||||
Each cause below was checked by an isolated counterfactual rerun: reverting the named change or combination
|
||||
restores the old digest exactly. Shared RNG and later economy/target choices can amplify a local correction.
|
||||
The two co-op paths involving Rockrock can mask a damage-item effect when each is reverted alone; their combined
|
||||
rollback, not mere operator presence, establishes the attribution.
|
||||
|
||||
| Cause / combination | Changed scenarios |
|
||||
|---|---|
|
||||
| catapult normal splash radius | `roster-013`, `standin-06` |
|
||||
| catapult normal splash radius; Kjerag Ice / Tear Gas damage triggers and resulting RNG order | `roster-040` |
|
||||
| Kjerag Ice / Tear Gas damage triggers and resulting RNG order | `bond-maniShip-high`, `boss-boss_2-pair`, `boss-boss_3-pair`, `boss-boss_5-pair`, `coop2-ABYSS-10-boosted`, `coop2-NORMAL-70-diy`, `hidden-boss_8-pair`, `hidden-boss_9-solo`, `roster-005`, `roster-012`, `roster-019`, `roster-033`, `roster-047`, `solo-ABYSS-1`, `solo-FUNNY-1`, `solo-FUNNY-2`, `solo-HARD-1`, `solo-NORMAL-1`, `solo-NORMAL-2`, `standin-07` |
|
||||
| Kjerag Ice / Tear Gas damage triggers and resulting RNG order; Fortress own-tile targeting / melee splash | `coop2-HARD-4` |
|
||||
| Kjerag Ice / Tear Gas damage triggers and resulting RNG order; Fortress own-tile targeting / melee splash; Rockrock acquisition-subset weights; Vulpisfoglia first-hit addition | `coop3-ABYSS-5`, `coop4-ABYSS-7` |
|
||||
| Kjerag Ice / Tear Gas damage triggers and resulting RNG order; Rockrock acquisition-subset weights | `coop2-NORMAL-8-serverrun`, `coop4-FUNNY-6` |
|
||||
| Kjerag Ice / Tear Gas damage triggers and resulting RNG order; Rockrock acquisition-subset weights; Vulpisfoglia first-hit addition | `coop2-NORMAL-1-coverage-standins`, `coop2-NORMAL-3` |
|
||||
| Kjerag Ice / Tear Gas damage triggers and resulting RNG order; Vulpisfoglia first-hit addition | `coop2-NORMAL-5-coverage-diy`, `roster-026` |
|
||||
| Fortress own-tile targeting / melee splash (including Horn and Ashlock) | `boss-boss_5-solo`, `roster-004`, `roster-023`, `roster-032`, `roster-034`, `roster-043`, `solo-HARD-9-boosted` |
|
||||
| Vulpisfoglia first-hit addition | `bond-soloShip-low`, `bond-yanShip-low`, `roster-007`, `roster-009`, `roster-039` |
|
||||
| Xi post-rebirth hostile selection immunity | `roster-011` |
|
||||
|
||||
The fixed-gift, displayed-offer overdraw, Flametail-subset and Clementia changes do not move these corpus
|
||||
digests individually; their dedicated regressions cover the reported cases. UI, translation and developer-tool
|
||||
changes do not alter simulation rules. The 140 DIY-only corpus cases retain their previous digests.
|
||||
|
||||
### 30.10 Bond-layer audio
|
||||
|
||||
#482: the client only sounded an increase in active bond count, missing actual layer gains in prep and battle.
|
||||
The official audio table maps `ON_ACT1AUTOCHESS_ADD_BOND` to `act1autochess_b_ui_buffup`, with
|
||||
`maxSoundAllowed=1` and `popOldest=true`. The supplied [match recording](https://www.bilibili.com/video/BV1VxwNzZEqK/)
|
||||
also contains the cue around Matterhorn acquisition and battle layer gains. The maintainer approved that bank's
|
||||
single-channel replacement policy with no extra cooldown; the recording alone does not settle every timing edge.
|
||||
|
||||
Own live prep gains now send an ephemeral `m.layer`; the displayed battle's existing `layer` tuples use the same
|
||||
audio channel. One multi-layer gain is one cue, inactive bonds included; capped/zero gains are silent. New cues stop
|
||||
the previous source and invalidate its pending decode. Field entry, exit and mute cancel pending/playing cues.
|
||||
Snapshots, resync and settlement bookkeeping do not synthesize gains. Existing activation audio shares the channel.
|
||||
The change does not affect layer amounts, simulation events or RNG. Tests cover server unicast/phase/cap behavior,
|
||||
asynchronous replacement and cancellation, muted/missing audio and late field entry.
|
||||
|
||||
### 30.11 Automatic skill-area follow-up
|
||||
|
||||
Reports described Kroos the Keen Glint and Degenbrecher waiting for enemies to reach a smaller range. With full SP,
|
||||
normal/elite forms and all four facings, Kroos S1/S2 cast at the outer normal range and Degenbrecher S2/S3 cast in
|
||||
their own skill range outside normal attack range. The supplied still image does not establish SP, cooldown or
|
||||
the running client version, so those reports are not marked fixed. The documented operation cooldown and Kroos's
|
||||
normal-attack timing still apply.
|
||||
|
||||
The broader real-record audit covered 1,850 manual-skill configurations and 2,232 directional trigger cases.
|
||||
It found three independent omissions under the previously approved expanded auto-cast policy:
|
||||
|
||||
- 司霆惊蛰 S2 has a terrain-derived range with no static rangeId. Its extra trigger now uses the same low-tile
|
||||
flood and permanent range extension as the running skill, only when it strictly contains the initial range.
|
||||
High ground, field bounds, normal attacks and its SEARCH rule remain unchanged.
|
||||
- 斩业星熊 S3 can attack flyers while active, but its trigger inherited the inactive ground-only profile. Its
|
||||
actual expanded area now accepts selectable flyers, like the existing 陈 S3 exception.
|
||||
- 予愿安洁莉娜 S3's opening area is 3-9 plus the surrounding x-4. Its ACTIVE_RANGE trigger now includes that same
|
||||
union, without extending it to a future flight destination or changing its anchored post-move attack area.
|
||||
|
||||
These changes preserve SP, control and operation cooldown gates and do not admit stealth/sleep/untargetable enemies
|
||||
to an ACTIVE_RANGE condition. Tests cover all DIY forms, facings, ground/air targets, permanent extension and gates.
|
||||
|
||||
### 30.12 Random boss partners, retained for Hidden Core
|
||||
|
||||
The maintainer requested random pairing each match and explicitly chose to retain partners for the hidden boss.
|
||||
A separate seeded shuffle assigns pairs and left/right sides before Final Assault preparation. The same plan
|
||||
drives prep previews, legal deployment maps and the actual battle. Hidden Core retains it; a departure keeps intact
|
||||
pairs and pairs up the remaining single players. A new Match starts a fresh draw; a repeated seed remains reproducible.
|
||||
|
||||
`m.public.bossPairs` supplies the actual plan to the client camera, placement and green partner frames; seat-order
|
||||
inference survives only as compatibility with older servers missing this field. The audit checks coverage, saved
|
||||
pair stability and actual fields instead of recomputing seat pairs. Lowest-seat connected-human authority and
|
||||
other-pair watch restrictions are unchanged. Regression includes 2/3/4 players, departures, hidden continuation,
|
||||
mirror coordinates, reconnects and real-browser prep-to-battle views.
|
||||
|
||||
### 30.13 联防 HP/SP restoration and Egir opening devour
|
||||
|
||||
The report was reproduced through normal BattleResult → `uniteBattleOpts` → a new battle: an Egir survivor's
|
||||
carried HP was charged for opening devour again. Some survivors died and revived at full HP, consuming a revival
|
||||
slot and resetting SP; others lost HP twice. Two 1% clamps also raised legal low HP, and a 1-HP floor changed the
|
||||
ratio when the new maximum was smaller.
|
||||
|
||||
The maintainer approved reconstructing Egir marks/base-ATK/block attributes without repeating opening devour HP
|
||||
loss on a carried survivor. Deployment and battle-start effects establish the new stats, then the living operator's
|
||||
exact positive HP ratio and total SP are restored. Forced-down carry still contributes to the attribute chain and
|
||||
stays down without spending a revival slot. Summons carry SP only; previous active skills are not resumed. Normal,
|
||||
boss and fresh no-carry units keep their usual devour damage, and later knock-outs/redeploys work normally.
|
||||
|
||||
[PRTS 帮助](https://prts.wiki/w/卫戍协议/帮助) specifies restoring HP ratio and SP after deployment. The detailed
|
||||
suppression of repeated opening death/revive effects is the approved implementation of that contract, not a claim
|
||||
that the wiki documents each internal callback. Tests include real carry construction, prior full-health food that
|
||||
would otherwise die anew, remaining revival slots, values below 1%/1 HP, and ordinary/boss behavior.
|
||||
|
||||
### 30.14 Touch's team medic after its holder is eliminated
|
||||
|
||||
On 2026-10-12 the maintainer explicitly approved retaining the implementation, including both elimination and
|
||||
voluntary departure after a confirmed selection. This resolves the local policy choice, not the official-evidence gap.
|
||||
|
||||
The observed loss happens in newly constructed fields and the next preparation, not by removing an already spawned
|
||||
medic from a teammate's ongoing battle. Both prep and battle eligibility formerly searched only living holders.
|
||||
|
||||
[ASSUMED] A confirmed Touch selection grants the team medic for this match even after that player is eliminated
|
||||
or leaves. Official data marks the effect initial and unlimited in rounds and describes a medic on every player's
|
||||
field, but does not explicitly document the holder's elimination. This local candidate adopts the persistent grant
|
||||
under the maintainer's request to make evidence-based local choices pending review. A draft focus is insufficient;
|
||||
a confirmed choice followed by departure counts. New matches have no inherited grant. Recipients must be alive.
|
||||
|
||||
Only Touch is retained in `contentInfo.matchBands`; ordinary teammate contexts and other eliminated strategies
|
||||
stay excluded. Prep still displays the reserve medic, with the battle replacing it by Touch at the existing elite
|
||||
threshold. Tests cover LP elimination, departure, both medic variants, draft confirmation, fresh matches and the
|
||||
normal/unite/boss/hidden BattleSpec paths.
|
||||
|
||||
### 30.15 Other reports rechecked without changing their rules
|
||||
|
||||
- 联防 order: [PRTS 帮助](https://prts.wiki/w/卫戍协议/帮助) includes downed operators in deployed headcount. The
|
||||
implementation reads deployed count, not deployment capacity. Nine deployed with casualties may rank ahead of
|
||||
eight survivors; the active-bond and layer tie-breaks retain their existing documented uncertainty.
|
||||
- 乌尔比安 S3: [PRTS 乌尔比安](https://prts.wiki/w/乌尔比安) gives the next tile beyond an occupied anchor landing
|
||||
as a fallback before his own tile. The pictured report alone does not contradict this; his existing own-board,
|
||||
occupied/downed reservation and return rules remain unchanged.
|
||||
- 叙拉古: fixed-layer cases last exactly `32 + 0.4L` game seconds, which is half as many real seconds at 2×. The
|
||||
formula tooltip reads current layers while an already deployed buff retains its initial duration/ASPD; a new
|
||||
deployment reads the new layers. No direct source settled whether official in-battle gains should extend the old
|
||||
buff, so that separate uncertainty is retained; durations are not doubled globally.
|
||||
- 塑心: hidden-layer bonds can still receive layers under the documented PRTS rule (§25.21.6). The old exclusion
|
||||
was specific to 余's strategy (§27.21), not a global ban. Selecting the highest hidden layer count is retained.
|
||||
- Push/pull: 176 force/mass cases and real Degenbrecher boss cases did not reproduce a weight bypass. Mode leaders
|
||||
retain their existing displacement guard; an ordinary `rank=BOSS` enemy is not automatically the same as a
|
||||
protected mode leader. Weight, static-body and immunity checks remain. 余 S2 can teleport 铳 in one step under its
|
||||
separate teleport rules; this is a possible source of the reported movement, not an identification of the screenshot.
|
||||
Boss trigger-edge tests distinguish a large rendered model from its actual hit area; the still image alone does
|
||||
not establish a missed auto-cast.
|
||||
|
||||
### 30.16 Follow-up golden changes
|
||||
|
||||
All 287 scenarios were recomputed after §30.11–30.14. Only five match digests change from the preceding candidate:
|
||||
`coop2-NORMAL-3`, `coop4-FUNNY-6`, `coop2-ABYSS-10-boosted`, `coop2-NORMAL-70-diy`, and
|
||||
`coop2-NORMAL-5-coverage-diy`. In each, restoring only the old ordered boss pairing in an isolated process reproduces
|
||||
the preceding digest exactly; the changed partner/side also changes preparation choices and later combat totals.
|
||||
All five already differed from 0.2.4, so the final release-relative total remains 45 scenarios (§30.9 plus these
|
||||
additional pairing effects). There are no new corpus cases or removals. The other follow-up fixes do not move this
|
||||
corpus; their dedicated regressions cover the reported and newly found cases.
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "stronghold-protocol-alliance",
|
||||
"version": "0.2.4",
|
||||
"version": "0.2.5-dev",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "stronghold-protocol-alliance",
|
||||
"version": "0.2.4",
|
||||
"version": "0.2.5-dev",
|
||||
"hasInstallScript": true,
|
||||
"license": "GPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "stronghold-protocol-alliance",
|
||||
"version": "0.2.4",
|
||||
"version": "0.2.5-dev",
|
||||
"private": true,
|
||||
"description": "明日方舟「卫戍协议:盟约」非官方同人联机网页复刻(1–4 人合作,Node.js 服务器 + 浏览器客户端)",
|
||||
"license": "GPL-3.0-or-later",
|
||||
|
||||
@@ -508,6 +508,15 @@
|
||||
white-space: pre-line;
|
||||
}
|
||||
.tooltip.is-shown { opacity: 1; }
|
||||
.tooltip--aside { max-width: 3.2rem; white-space: normal; }
|
||||
.tooltip--compound { display: flex; gap: .16rem; max-width: min(8rem, calc(100vw - 16px)); max-height: calc(100dvh - 16px); overflow: auto; pointer-events: auto; overscroll-behavior: contain; }
|
||||
.tooltip--compound .tooltip__main { flex: 1 1 2.2rem; min-width: 0; }
|
||||
.tooltip--compound .tooltip--aside { flex: 1 1 2.2rem; min-width: 0; border-left: 1px solid var(--line-2); padding-left: .12rem; }
|
||||
@media (max-width: 600px) {
|
||||
.tooltip--compound { flex-direction: column; }
|
||||
.tooltip--compound .tooltip__main, .tooltip--compound .tooltip--aside { flex: none; max-width: 100%; width: 100%; }
|
||||
.tooltip--compound .tooltip--aside { border-left: 0; border-top: 1px solid var(--line-2); padding: .1rem 0 0; }
|
||||
}
|
||||
.tooltip--top.is-shown { animation: tip-up var(--t-fast) var(--ease-out); }
|
||||
.tooltip--bottom.is-shown { animation: tip-down var(--t-fast) var(--ease-out); }
|
||||
@keyframes tip-up { from { transform: translateY(4px); } }
|
||||
|
||||
@@ -81,6 +81,15 @@
|
||||
.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); }
|
||||
/* the box beside a bond's tooltip (matchInfo.js BondBannedAside): its banned members with avatar, name and bonds */
|
||||
.brief-bantip { display: flex; flex-direction: column; gap: .06rem; }
|
||||
.brief-bantip__h { font-size: calc(.12 * var(--t)); font-weight: 700; letter-spacing: .06em; color: var(--red); }
|
||||
.brief-bantip__op { display: flex; align-items: center; gap: .08rem; }
|
||||
.brief-bantip__op .uthumb { flex: none; }
|
||||
.brief-bantip__txt { display: flex; flex-direction: column; min-width: 0; line-height: 1.3; }
|
||||
.brief-bantip__txt b { font-size: calc(.14 * var(--t)); color: var(--text-hi); }
|
||||
.brief-bantip__txt > span { font-size: calc(.12 * var(--t)); color: var(--text-lo); }
|
||||
.brief-bantip__txt .is-this { color: var(--mint-400); }
|
||||
.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; }
|
||||
|
||||
@@ -95,7 +95,7 @@
|
||||
/* layers */
|
||||
--z-screen: 1;
|
||||
--z-banner: 40;
|
||||
--z-conn: 60;
|
||||
--z-conn: 75;
|
||||
--z-modal: 80;
|
||||
--z-toast: 90;
|
||||
--z-tooltip: 95;
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<title>Sim check · 卫戍协议:盟约</title>
|
||||
<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" />
|
||||
<link rel="stylesheet" href="/fonts/fonts.css" />
|
||||
<link rel="stylesheet" href="/css/theme.css" />
|
||||
<style>
|
||||
/* The check (simcheck/ui.js) in the game's tokens (css/theme.css): green-grey panels, mint, gold / red for the
|
||||
differences. A long scrolling page, fit for a phone. */
|
||||
html, body { margin: 0; height: auto; min-height: 100%; overflow-y: auto; background: var(--bg-0); }
|
||||
select { min-width: 0; max-width: 100%; }
|
||||
#app { --sc-bg: var(--bg-0); --sc-panel: var(--bg-1); --sc-fg: var(--text-hi); --sc-muted: var(--text-lo); --sc-line: var(--line);
|
||||
--sc-accent: var(--mint-500); --sc-ok: var(--mint-500); --sc-warn: var(--gold-2); --sc-bad: var(--red-premium);
|
||||
--sc-font: var(--font-cjk); --sc-mono: var(--font-mono); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main id="app"><p style="color:var(--text-lo);padding:16px">正在载入战斗模拟…</p></main>
|
||||
<script type="module" src="./sim-check.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,68 @@
|
||||
// The cross-engine check page (docs/SIM.md「Cross-engine check」): this browser runs the golden battle corpus with the
|
||||
// client's own sim loader (public/js/battle/runner.js loadBrowserSim) and compares every tick with the Node reference
|
||||
// that `node tools/sim-check.mjs` wrote to /dev/simcheck/data/ — any device on the LAN can open it from the server.
|
||||
import { mountCheck } from './simcheck/ui.js';
|
||||
import { strongholdAdapter, scenariosOf } from './simcheck/stronghold.js';
|
||||
import { VERSION, textFingerprint } from './simcheck/core.js';
|
||||
|
||||
const app = document.getElementById('app');
|
||||
globalThis.__simcheck = { status: 'loading', results: [], summary: null, error: null };
|
||||
const get = async (url) => {
|
||||
const r = await fetch(url, { cache: 'no-cache' });
|
||||
if (!r.ok) throw Object.assign(new Error(`${r.status} ${url}`), { missing: r.status === 404 });
|
||||
return r.json();
|
||||
};
|
||||
|
||||
try {
|
||||
const [doc, reference, { loadBrowserSim }, { APP_VERSION }] = await Promise.all([
|
||||
get('/dev/simcheck/data/specs.json'), get('/dev/simcheck/data/reference.json'), import('/js/battle/runner.js'), import('/shared/constants.js'),
|
||||
]);
|
||||
if (doc.simcheck !== VERSION || reference.simcheck !== VERSION || !Array.isArray(doc.files) || !doc.files.length
|
||||
|| doc.identity !== reference.identity || doc.identity !== textFingerprint(JSON.stringify({ specs: doc.specs, files: doc.files }))) {
|
||||
throw new Error('参考与场景文件不匹配,请重新生成参考数据。');
|
||||
}
|
||||
const verifiedData = new Map();
|
||||
const validate = async () => {
|
||||
for (let i = 0; i < doc.files.length; i += 8) await Promise.all(doc.files.slice(i, i + 8).map(async ({ url, hash }) => {
|
||||
if (typeof url !== 'string' || !/^\/(?:sim\/|shared\/|data\/|data\.js$|js\/battle\/runner\.js$|dev\/simcheck\/)/.test(url) || url.includes('..')) throw new Error('invalid reference input path');
|
||||
const response = await fetch(url, { cache: 'no-cache' });
|
||||
if (!response.ok) throw new Error(`参考输入不可用:${url}`);
|
||||
const text = await response.text();
|
||||
if (textFingerprint(text) !== hash) throw new Error(`参考数据已过期:${url}。请重新生成参考并刷新页面。`);
|
||||
if (url.startsWith('/data/')) verifiedData.set(url, text);
|
||||
}));
|
||||
};
|
||||
await validate();
|
||||
const { spec, ds } = await loadBrowserSim({ fetchFn: url => {
|
||||
const text = verifiedData.get(url);
|
||||
if (text == null) throw new Error(`unverified data input: ${url}`);
|
||||
return Promise.resolve(new Response(text, { status: 200, headers: { 'Content-Type': 'application/json' } }));
|
||||
} });
|
||||
const scenarios = scenariosOf(doc);
|
||||
const FAMILIES = [['roster', '干员'], ['bonds', '盟约'], ['fields', '首领战场与联防'], ['standins', '补位'], ['diy', '自选']];
|
||||
if (reference.made?.version !== APP_VERSION) throw new Error('参考游戏版本已过期,请重新生成。');
|
||||
mountCheck(app, {
|
||||
project: 'Stronghold Protocol',
|
||||
title: '模拟一致性检查',
|
||||
subtitle: '逐帧比较已记录的单位状态与 Node 参考,并抽样比较快照和部分内部状态。输入文件按内容指纹核对;通过不代表全部画面、事件流或内部内存均已覆盖。状态下载仅定位状态向量字段。',
|
||||
validate,
|
||||
adapter: strongholdAdapter({ spec, ds, doc }),
|
||||
scenarios,
|
||||
subsets: [
|
||||
{ id: 'fast', label: '快速(黄金结果的快速子集)', pick: (sc) => sc.fast },
|
||||
{ id: 'all', label: '全部', pick: () => true },
|
||||
...FAMILIES.map(([id, label]) => ({ id, label, pick: (sc) => sc.family === id })),
|
||||
],
|
||||
reference,
|
||||
info: [['游戏版本', APP_VERSION]],
|
||||
});
|
||||
} catch (e) {
|
||||
globalThis.__simcheck = { status: 'error', results: [], summary: null, error: String(e.message || e) };
|
||||
app.innerHTML = '';
|
||||
const p = document.createElement('p');
|
||||
p.style.cssText = 'color:var(--text-md);padding:16px;max-width:760px;margin:0 auto;font:14px/1.6 var(--font-cjk)';
|
||||
p.textContent = e.missing
|
||||
? '还没有参考数据:请在服务器的目录里运行 node tools/sim-check.mjs(生成 public/dev/simcheck/data/),然后刷新这个页面。'
|
||||
: `载入失败:${e && e.message || e}`;
|
||||
app.appendChild(p);
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
// simcheck — whether a deterministic simulation gives the same bits in every JavaScript engine: the server's Node and
|
||||
// the players' browsers (Chrome / Edge, Firefox, Safari, on desktops and phones). A run of one scenario is traced:
|
||||
// state the simulation's observable numbers after every tick (the adapter's state(): positions, HP, timers …),
|
||||
// hashed to their exact IEEE-754 bits; each block of `every` ticks keeps one hash of its ticks' hashes
|
||||
// view what a viewer is shown (the adapter's view(): a wire snapshot), hashed at the end of every block and at the
|
||||
// end of the run
|
||||
// deep the hidden state (the adapter's deep(): every value reachable from the units), hashed every `deepEvery`
|
||||
// ticks (a multiple of `every`; the walk is costly) and at the end of the run
|
||||
// result the outcome's digest (the adapter's result())
|
||||
// A run elsewhere is compared with a reference run (Node) block by block: the first block that differs, at any level,
|
||||
// says when the two parted; a dump of that block's state vectors from both sides then names the first field that
|
||||
// differs (diffDumps).
|
||||
//
|
||||
// Integer operations only (Math.imul, shifts) over the numbers' bits: the hashes are the same in every engine. NaN is
|
||||
// hashed as one value (engines may keep different NaN payloads).
|
||||
//
|
||||
// Written for Stronghold Protocol (GPL-3.0-or-later) and Duel Channel (AGPL-3.0-or-later) by the same author, and kept
|
||||
// the same in both. No dependencies; Node and browsers.
|
||||
|
||||
const DV = new DataView(new ArrayBuffer(8));
|
||||
const rotl = (x, r) => (x << r) | (x >>> (32 - r));
|
||||
// murmur3's block mix and finaliser
|
||||
function mix(h, k) {
|
||||
k = Math.imul(k, 0xcc9e2d51); k = rotl(k, 15); k = Math.imul(k, 0x1b873593);
|
||||
h ^= k; h = rotl(h, 13);
|
||||
return (Math.imul(h, 5) + 0xe6546b64) | 0;
|
||||
}
|
||||
function fmix(h) {
|
||||
h ^= h >>> 16; h = Math.imul(h, 0x85ebca6b); h ^= h >>> 13; h = Math.imul(h, 0xc2b2ae35); h ^= h >>> 16;
|
||||
return h >>> 0;
|
||||
}
|
||||
// the kind of an object by its tag, not instanceof: a simulation may run in another realm (a Node VM context) than the
|
||||
// hashing code, with its own Map / Set constructors
|
||||
const tag = (v) => Object.prototype.toString.call(v);
|
||||
const isList = (v) => Array.isArray(v) || (ArrayBuffer.isView(v) && tag(v) !== '[object DataView]');
|
||||
const now = () => (typeof performance !== 'undefined' ? performance.now() : Date.now());
|
||||
|
||||
/** A 32-bit hash fed with words, numbers (their bits), strings and plain values. */
|
||||
class Hash {
|
||||
constructor(seed = 0x9747b28c) { this.h = seed | 0; this.n = 0; }
|
||||
word(w) { this.h = mix(this.h, w | 0); this.n++; return this; }
|
||||
num(x) {
|
||||
if (x !== x) return this.word(0x7ff80000).word(0);
|
||||
DV.setFloat64(0, x, true);
|
||||
return this.word(DV.getUint32(0, true)).word(DV.getUint32(4, true));
|
||||
}
|
||||
str(s) { this.word(0x5354 ^ s.length); for (let i = 0; i < s.length; i++) this.word(s.charCodeAt(i)); return this; }
|
||||
// null / undefined / booleans / numbers / strings / arrays / plain objects (keys in their own order, which is the
|
||||
// order the same code created them in, in any engine)
|
||||
value(v, depth = 0) {
|
||||
if (v === null || v === undefined) return this.word(v === null ? 0x6e756c6c : 0x756e6466);
|
||||
switch (typeof v) {
|
||||
case 'number': return this.num(v);
|
||||
case 'string': return this.str(v);
|
||||
case 'boolean': return this.word(v ? 0x74727565 : 0x66616c73);
|
||||
case 'bigint': return this.str(v.toString());
|
||||
case 'object': break;
|
||||
default: return this.word(0x66756e63);
|
||||
}
|
||||
if (depth > 32) return this.word(0x64656570);
|
||||
if (isList(v)) { this.word(0x41525259 ^ v.length); for (const x of v) this.value(x, depth + 1); return this; }
|
||||
const keys = Object.keys(v);
|
||||
this.word(0x4f424a54 ^ keys.length);
|
||||
for (const k of keys) { this.str(k); this.value(v[k], depth + 1); }
|
||||
return this;
|
||||
}
|
||||
done() { return fmix(this.h ^ this.n); }
|
||||
}
|
||||
|
||||
const hex = (u) => (u >>> 0).toString(16).padStart(8, '0');
|
||||
/** The hash of a list of numbers (a state vector). */
|
||||
function hashNumbers(v) { const h = new Hash(); for (let i = 0; i < v.length; i++) h.num(v[i]); return h.done(); }
|
||||
/** The hash of any plain value. */
|
||||
function hashValue(v) { return new Hash().value(v).done(); }
|
||||
/** Portable content identity for local diagnostic inputs (not a security signature). */
|
||||
function textFingerprint(s) { return hex(new Hash().str(s).done()) + hex(new Hash(0x53a9d61f).str(s).done()); }
|
||||
function selectScenarios(scenarios, subsets, id, only = null) {
|
||||
const subset = subsets.find(s => s.id === id);
|
||||
if (!subset) throw new Error(`unknown subset: ${id}`);
|
||||
const names = only == null ? null : new Set(String(only).split(',').map(s => s.trim()).filter(Boolean));
|
||||
if (names) for (const name of names) if (!scenarios.some(s => s.id === name)) throw new Error(`unknown scenario: ${name}`);
|
||||
const list = scenarios.filter(s => subset.pick(s) && (!names || names.has(s.id)));
|
||||
if (!list.length) throw new Error('no scenarios selected');
|
||||
return { subset, list };
|
||||
}
|
||||
|
||||
/**
|
||||
* The hash of everything reachable from roots: own properties (non-enumerable too, data properties only), arrays,
|
||||
* typed arrays, Maps, Sets; another root met on the way counts as its index, an object met twice counts once, the keys
|
||||
* in `skip` are left out (references back to shared definitions, process-wide counters …).
|
||||
* @param {object[]} roots @param {{ skip?: Iterable<string>, maxDepth?: number }} [o]
|
||||
*/
|
||||
function deepHash(roots, { skip = [], maxDepth = 8 } = {}) {
|
||||
const idx = new Map(roots.map((r, i) => [r, i])), seen = new Set(), skipped = new Set(skip), h = new Hash();
|
||||
const walk = (v, depth) => {
|
||||
if (v === null || v === undefined) { h.word(7); return; }
|
||||
const t = typeof v;
|
||||
if (t === 'number') { h.num(v); return; }
|
||||
if (t === 'string') { h.str(v); return; }
|
||||
if (t === 'boolean') { h.word(v ? 3 : 5); return; }
|
||||
if (t !== 'object' || depth > maxDepth) return;
|
||||
if (depth > 0 && idx.has(v)) { h.word(0x55550000 + idx.get(v)); return; }
|
||||
if (seen.has(v)) { h.word(11); return; }
|
||||
seen.add(v);
|
||||
if (isList(v)) { h.word(v.length); for (const x of v) walk(x, depth + 1); return; }
|
||||
const kind = tag(v);
|
||||
if (kind === '[object Map]') { h.word(v.size); for (const [k, x] of v) { walk(k, depth + 1); walk(x, depth + 1); } return; }
|
||||
if (kind === '[object Set]') { h.word(v.size); for (const x of v) walk(x, depth + 1); return; }
|
||||
for (const k of Object.getOwnPropertyNames(v)) {
|
||||
if (skipped.has(k)) continue;
|
||||
const d = Object.getOwnPropertyDescriptor(v, k);
|
||||
if (!d || !('value' in d) || typeof d.value === 'function') continue;
|
||||
h.str(k); walk(d.value, depth + 1);
|
||||
}
|
||||
};
|
||||
for (const r of roots) walk(r, 0);
|
||||
return h.done();
|
||||
}
|
||||
|
||||
/**
|
||||
* A run of one scenario, traced as it goes; advance() runs it for a time budget (a page stays responsive), finish()
|
||||
* gives the trace. The adapter:
|
||||
* start(scenario) → run; step(run) → true when a tick was simulated, false when the run is over;
|
||||
* state(run) → number[] (after every tick); labels?(run) → string[] (the names of state's entries, for dumps);
|
||||
* view?(run) → plain value; deep?(run) → uint32; result(run) → string; maxTicks? (a runaway guard)
|
||||
* dump: [first, last] ticks whose state vectors (and labels) are kept.
|
||||
*/
|
||||
function tracer(adapter, scenario, { every = 30, deepEvery = every * 5, dump = null } = {}) {
|
||||
const max = adapter.maxTicks || 1e6;
|
||||
if (!Number.isInteger(every) || every < 1 || !Number.isInteger(deepEvery) || deepEvery < every || deepEvery % every) throw new Error('invalid sample interval');
|
||||
if (dump && (!Array.isArray(dump) || dump.length !== 2 || !dump.every(Number.isInteger) || dump[0] < 1 || dump[1] < dump[0] || dump[1] > max)) throw new Error('invalid dump window');
|
||||
const run = adapter.start(scenario);
|
||||
let t = 0, block = new Hash(), over = false;
|
||||
const blocks = [], views = [], deeps = [], dumps = {};
|
||||
const sample = (end) => {
|
||||
if (adapter.view) views.push(hashValue(adapter.view(run)));
|
||||
if (adapter.deep && (end || t % deepEvery === 0)) deeps.push(adapter.deep(run) >>> 0);
|
||||
};
|
||||
return {
|
||||
get ticks() { return t; },
|
||||
get done() { return over; },
|
||||
advance(budgetMs = Infinity) {
|
||||
const until = budgetMs === Infinity ? Infinity : now() + budgetMs;
|
||||
while (!over) {
|
||||
if (!adapter.step(run)) {
|
||||
over = true;
|
||||
if (t === 0) sample(true);
|
||||
if (t % every) { blocks.push(block.done()); sample(true); } else if (adapter.deep && t % deepEvery) deeps.push(adapter.deep(run) >>> 0);
|
||||
break;
|
||||
}
|
||||
if (t >= max) throw new Error(`scenario ${scenario.id}: maxTicks ${max} reached before completion`);
|
||||
t++;
|
||||
const s = adapter.state(run);
|
||||
block.word(hashNumbers(s));
|
||||
if (dump && t >= dump[0] && t <= dump[1]) dumps[t] = { state: Array.from(s, encodeNumber), labels: adapter.labels ? adapter.labels(run) : null };
|
||||
if (t % every === 0) { blocks.push(block.done()); block = new Hash(); sample(false); }
|
||||
if ((t & 31) === 0 && now() > until) break;
|
||||
}
|
||||
return over;
|
||||
},
|
||||
finish() {
|
||||
while (!over) this.advance();
|
||||
return { ticks: t, blocks: blocks.map(hex).join(''), views: views.map(hex).join(''), deeps: deeps.map(hex).join(''), result: String(adapter.result(run)), ...(dump ? { dumps } : {}) };
|
||||
},
|
||||
};
|
||||
}
|
||||
/** A whole run's trace (see tracer). */
|
||||
function trace(adapter, scenario, opts) { return tracer(adapter, scenario, opts).finish(); }
|
||||
|
||||
const firstDiff = (a = '', b = '') => {
|
||||
const n = Math.max(a.length, b.length);
|
||||
for (let i = 0; i < n; i += 8) if (a.slice(i, i + 8) !== b.slice(i, i + 8)) return i / 8;
|
||||
return -1;
|
||||
};
|
||||
/**
|
||||
* A trace against its reference. ok, or: `at` — where they first parted (level 'state' / 'view' / 'deep', the block,
|
||||
* its ticks [from, to]); `result` — the outcomes differ; `ticks` — the runs' lengths differ. The level whose difference
|
||||
* shows first is the one reported — by the tick it is seen at (a block's or a sample's last tick; a deep sample covers
|
||||
* deepEvery ticks), the finer level on a tie; a run whose state never differs but whose view does still counts.
|
||||
*/
|
||||
function compare(ref, got, every = 30, deepEvery = every * 5) {
|
||||
if (!ref) return { ok: false, missing: true };
|
||||
let at = null;
|
||||
for (const level of ['state', 'view', 'deep']) {
|
||||
const key = level === 'state' ? 'blocks' : level === 'view' ? 'views' : 'deeps', span = level === 'deep' ? deepEvery : every;
|
||||
const i = firstDiff(ref[key], got[key]);
|
||||
if (i < 0) continue;
|
||||
const to = Math.min((i + 1) * span, Math.max(ref.ticks, got.ticks));
|
||||
if (!at || to < at.to) at = { level, block: i, from: i * span + 1, to };
|
||||
}
|
||||
const result = ref.result !== got.result, ticks = ref.ticks !== got.ticks;
|
||||
return { ok: !at && !result && !ticks, at, result, ticks: ticks ? [ref.ticks, got.ticks] : null };
|
||||
}
|
||||
/** How bad a comparison is, for sorting and summaries: 3 the outcome differs, 2 what viewers see, 1 only inside. */
|
||||
function severity(c) {
|
||||
if (c.ok) return 0;
|
||||
if (c.missing || c.result || c.ticks) return 3;
|
||||
return c.at && c.at.level !== 'deep' ? 2 : 1;
|
||||
}
|
||||
|
||||
const SPECIAL_NUMBERS = new Map([['NaN', NaN], ['Infinity', Infinity], ['-Infinity', -Infinity], ['-0', -0]]);
|
||||
const encodeNumber = (x) => Number.isNaN(x) ? 'NaN' : x === Infinity ? 'Infinity' : x === -Infinity ? '-Infinity' : Object.is(x, -0) ? '-0' : x;
|
||||
const bitsOf = (value) => {
|
||||
const x = typeof value === 'string' && SPECIAL_NUMBERS.has(value) ? SPECIAL_NUMBERS.get(value) : value;
|
||||
if (typeof x !== 'number') throw new Error('invalid numeric state entry');
|
||||
if (Number.isNaN(x)) return 'NaN';
|
||||
DV.setFloat64(0, x, true);
|
||||
return hex(DV.getUint32(4, true)) + hex(DV.getUint32(0, true));
|
||||
};
|
||||
/**
|
||||
* Two dumps of the same ticks (tracer `dump`) compared field by field: the differences in tick order, each with the
|
||||
* tick, the entry's index and label, both values and their bits. Stops after `limit`.
|
||||
*/
|
||||
function diffDumps(a, b, limit = 20) {
|
||||
const out = [];
|
||||
const ticks = [...new Set([...Object.keys(a || {}), ...Object.keys(b || {})])].map(Number).sort((x, y) => x - y);
|
||||
for (const t of ticks) {
|
||||
const x = a[t], y = b[t];
|
||||
if (!x || !y) { out.push({ tick: t, index: -1, label: x ? 'missing on the other side' : 'missing in the reference' }); if (out.length >= limit) break; continue; }
|
||||
const n = Math.max(x.state.length, y.state.length);
|
||||
for (let i = 0; i < n && out.length < limit; i++) {
|
||||
const p = x.state[i], q = y.state[i];
|
||||
if (Object.is(p, q) || (p !== undefined && q !== undefined && bitsOf(p) === bitsOf(q))) continue;
|
||||
out.push({ tick: t, index: i, label: (x.labels && x.labels[i]) || (y.labels && y.labels[i]) || `#${i}`, ref: p, got: q, refBits: p === undefined ? '' : bitsOf(p), gotBits: q === undefined ? '' : bitsOf(q) });
|
||||
}
|
||||
if (out.length >= limit) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const VERSION = 2;
|
||||
export { VERSION, Hash, hex, hashNumbers, hashValue, textFingerprint, selectScenarios, deepHash, tracer, trace, compare, severity, diffDumps };
|
||||
@@ -0,0 +1,104 @@
|
||||
// simcheck's Node side: the reference (every scenario traced in Node, core.js), headless runs of the check page in
|
||||
// several engines, and the field-by-field comparison of a dump a browser downloaded with Node's run of the same ticks.
|
||||
// Engines: Playwright's chromium / firefox / webkit when the `playwright` package is installed (its browsers fetched
|
||||
// with `npx playwright install firefox webkit`), else a local Chrome through puppeteer-core ($CHROME_PATH or the usual
|
||||
// install paths).
|
||||
//
|
||||
// Written for Stronghold Protocol (GPL-3.0-or-later) and Duel Channel (AGPL-3.0-or-later) by the same author, and kept
|
||||
// the same in both.
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { tracer, diffDumps, VERSION } from './core.js';
|
||||
|
||||
/**
|
||||
* Trace every scenario in Node: the reference a page compares with.
|
||||
* @param {object} adapter @param {{ id: string }[]} scenarios
|
||||
* @param {{ every?: number, project: string, made?: object, onProgress?: (i: number, n: number, id: string) => void }} o
|
||||
*/
|
||||
export function buildReference(adapter, scenarios, { every = 30, deepEvery = every * 5, project, made = {}, onProgress } = {}) {
|
||||
const out = {};
|
||||
scenarios.forEach((sc, i) => {
|
||||
if (onProgress) onProgress(i, scenarios.length, sc.id);
|
||||
const { ticks, blocks, views, deeps, result } = tracer(adapter, sc, { every, deepEvery }).finish();
|
||||
out[sc.id] = { ticks, blocks, views, deeps, result };
|
||||
});
|
||||
return { simcheck: VERSION, project, every, deepEvery, made: { engine: `Node ${process.version}`, date: new Date().toISOString().slice(0, 10), ...made }, scenarios: out };
|
||||
}
|
||||
|
||||
const CHROMES = [process.env.CHROME_PATH, 'C:/Program Files/Google/Chrome/Application/chrome.exe', 'C:/Program Files (x86)/Google/Chrome/Application/chrome.exe',
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', '/usr/bin/chromium', '/usr/bin/chromium-browser'];
|
||||
async function tryImport(name) { try { return await import(name); } catch { return null; } }
|
||||
|
||||
/**
|
||||
* Open the check page (url, with ?auto=1 and the subset) in each engine and wait for its results.
|
||||
* @param {string} url @param {string[]} engines 'chromium' | 'firefox' | 'webkit' | 'chrome' (a local Chrome)
|
||||
* @returns {Promise<Record<string, { version?: string, status: string, summary?: object, results?: object[], report?: string, error?: string }>>}
|
||||
*/
|
||||
export async function runEngines(url, engines, { timeoutMs = 30 * 60000, log = () => {} } = {}) {
|
||||
const pw = await tryImport('playwright'), pp = pw ? null : await tryImport('puppeteer-core');
|
||||
const out = {};
|
||||
for (const name of engines) {
|
||||
let browser = null;
|
||||
try {
|
||||
if (pw && pw[name === 'chrome' ? 'chromium' : name]) {
|
||||
const type = pw[name === 'chrome' ? 'chromium' : name], exe = CHROMES.find((p) => p && existsSync(p));
|
||||
// chromium / chrome: Playwright's own build when it is installed, else the local Chrome
|
||||
try { browser = await type.launch(name === 'chrome' && exe ? { executablePath: exe } : {}); } catch (e) {
|
||||
if (type !== pw.chromium || !exe) throw e;
|
||||
browser = await type.launch({ executablePath: exe });
|
||||
}
|
||||
} else if (pp && (name === 'chrome' || name === 'chromium')) {
|
||||
const exe = CHROMES.find((p) => p && existsSync(p));
|
||||
if (!exe) throw new Error('no Chrome found: set CHROME_PATH');
|
||||
browser = await (pp.default || pp).launch({ executablePath: exe, headless: true });
|
||||
} else throw new Error(pw ? `playwright has no ${name}` : `${name} needs the playwright package (npm i -D playwright && npx playwright install ${name})`);
|
||||
const page = await browser.newPage();
|
||||
const errors = [];
|
||||
page.on('pageerror', (e) => errors.push(String(e.message || e)));
|
||||
await page.goto(url, { timeout: 120000 });
|
||||
const t0 = Date.now();
|
||||
let last = -1;
|
||||
for (;;) {
|
||||
const st = await page.evaluate(() => { const s = globalThis.__simcheck; return s ? { status: s.status, n: s.results.length } : null; });
|
||||
if (st && ['done', 'stopped', 'error'].includes(st.status)) break;
|
||||
if (errors.length) throw new Error(errors.join('; '));
|
||||
if (st && st.n !== last && st.n % 25 === 0) { log(` ${name}: ${st.n}`); last = st.n; }
|
||||
if (Date.now() - t0 > timeoutMs) throw new Error('timed out');
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
}
|
||||
const res = await page.evaluate(() => { const s = globalThis.__simcheck; return { status: s.status, expected: s.expected, completed: s.completed, error: s.error, summary: s.summary, results: s.results, report: s.report }; });
|
||||
out[name] = { version: browser.version ? await browser.version() : '', ...res, errors };
|
||||
} catch (e) {
|
||||
out[name] = { status: 'error', error: String(e && e.message || e) };
|
||||
} finally { if (browser) await browser.close().catch(() => {}); }
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* A dump a browser downloaded (the page's 下载这一段的状态) against Node's run of the same ticks: the differing entries,
|
||||
* first ones first, as printable lines.
|
||||
* @param {string} file @param {object} adapter @param {{ id: string }[]} scenarios
|
||||
*/
|
||||
export function diffDumpFile(file, adapter, scenarios, { every = 30, deepEvery = every * 5, limit = 20, identity = null } = {}) {
|
||||
const d = JSON.parse(readFileSync(file, 'utf8'));
|
||||
if (d.simcheck !== VERSION || !d.dumps || typeof d.dumps !== 'object' || !Array.isArray(d.window)) throw new Error('invalid dump format');
|
||||
if (identity && d.identity !== identity) throw new Error('dump and reference inputs differ');
|
||||
const sc = scenarios.find((s) => s.id === d.id);
|
||||
if (!sc) throw new Error(`no scenario ${d.id} here`);
|
||||
const ref = tracer(adapter, sc, { every, deepEvery, dump: d.window }).finish();
|
||||
const diffs = diffDumps(ref.dumps, d.dumps, limit);
|
||||
const lines = [`${d.id}, ticks ${d.window[0]}–${d.window[1]}: Node ${process.version} vs ${d.engine}`];
|
||||
if (!diffs.length) lines.push(' the same in every entry of these ticks (the difference is in what the state vector does not hold: view / deep)');
|
||||
for (const x of diffs) lines.push(x.index < 0 ? ` tick ${x.tick}: ${x.label}` : ` tick ${x.tick} ${x.label}: ${x.ref} (${x.refBits}) vs ${x.got} (${x.gotBits})`);
|
||||
return lines;
|
||||
}
|
||||
|
||||
/** One line per engine for a console. */
|
||||
export function formatEngines(res) {
|
||||
return Object.entries(res).map(([name, r]) => {
|
||||
if (r.status === 'error') return `${name}: error — ${r.error}`;
|
||||
const s = r.summary || {};
|
||||
const bad = (r.results || []).filter((x) => !x.c.ok).map((x) => x.id);
|
||||
return `${name} ${r.version || ''}: ${s.ok}/${s.n} the same${bad.length ? ` — different: ${bad.slice(0, 12).join(', ')}${bad.length > 12 ? ' …' : ''}` : ''}${r.errors && r.errors.length ? ` (page errors: ${r.errors.length})` : ''}`;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// Stronghold Protocol's battles for simcheck (core.js): the golden battle corpus — tools/golden.mjs's families roster,
|
||||
// bonds, fields, standins and diy (its matches are not single battles) — as BattleSpecs (written by
|
||||
// tools/sim-check.mjs), each built with /sim/spec.js createBattleFromSpec as the browser runner does
|
||||
// (public/js/battle/runner.js) and stepped tick by tick; a field battle stops at its golden cap like the corpus.
|
||||
// state after every tick: the battle time, the RNG state and per unit FIELDS (exact bits)
|
||||
// view b.snapshot(): what viewers are sent
|
||||
// deep every value reachable from the units, the definitions and the process-wide sequence numbers left out
|
||||
// result resultDigest(b.result()).hash: what the server's SP_VERIFY compares
|
||||
// The page (sim-check.js) and the tool (tools/sim-check.mjs) both use it, with the sim and its data loaded their way.
|
||||
import { deepHash } from './core.js';
|
||||
|
||||
/** The data files the sim reads: public/js/battle/runner.js SIM_DATA_FILES (test/sim/simcheck.test.js keeps them equal). */
|
||||
export const SIM_DATA_FILES = Object.freeze(['chess', 'enemies', 'tokens', 'stages', 'waves', 'bonds', 'items', 'garrisons', 'bands', 'effects', 'backups']);
|
||||
/** Per unit in the state vector. */
|
||||
export const FIELDS = Object.freeze(['alive', 'removed', 'deployed', 'x', 'y', 'hp', 'atkCd', 'sp', 'burn', 'neural', 'necrosis', 'apoptosis', 'erosion',
|
||||
'dmg', 'taken', 'heal', 'blocking']);
|
||||
// left out of the deep state: references back to definitions and the battle, and buffs.js's process-wide `seq` (it
|
||||
// depends on what ran before in the same page or process)
|
||||
const DEEP_SKIP = Object.freeze(['def', 'profile', 'kit', 'battle', 'b', 'seq']);
|
||||
const QUIET = Object.freeze({ error() {}, warn() {}, info() {}, debug() {} });
|
||||
|
||||
/** The scenario list of a specs document: id, golden family, in the fast subset. */
|
||||
export const scenariosOf = (doc) => doc.specs.map((s) => ({ id: s.id, family: s.fam, fast: s.fast }));
|
||||
|
||||
/**
|
||||
* @param {{ spec: { createBattleFromSpec: Function, resultDigest: Function }, ds: object, doc: { specs: object[] }, logger?: object }} o
|
||||
* spec: the /sim/spec.js module; ds: a simdata DataSource over SIM_DATA_FILES; doc: the specs document
|
||||
*/
|
||||
export function strongholdAdapter({ spec, ds, doc, logger = QUIET }) {
|
||||
const byId = new Map(doc.specs.map((s) => [s.id, s]));
|
||||
return {
|
||||
maxTicks: 200000,
|
||||
start(sc) {
|
||||
const s = byId.get(sc.id);
|
||||
return { b: spec.createBattleFromSpec(s.spec, ds, { logger }), cap: s.cap ?? null };
|
||||
},
|
||||
step(r) {
|
||||
const b = r.b;
|
||||
if (b.finished) return false;
|
||||
if (r.cap != null && b.time >= r.cap - 1e-9) { b.forceEnd('forced'); return false; }
|
||||
b.step();
|
||||
return true;
|
||||
},
|
||||
state(r) {
|
||||
const b = r.b, v = [b.time, b.rng.state() >>> 0];
|
||||
for (const u of b.units) {
|
||||
const e = u.elem || {}, s = u.stats || {};
|
||||
v.push(u.alive ? 1 : 0, u.removed ? 1 : 0, u.deployed ? 1 : 0, u.x, u.y, u.hp, u.atkCd, u.skill && u.skill.sp != null ? u.skill.sp : -1,
|
||||
e.burn ?? 0, e.neural ?? 0, e.necrosis ?? 0, e.apoptosis ?? 0, e.erosion ?? 0, s.dmg ?? 0, s.taken ?? 0, s.heal ?? 0, (u.blocking || []).length);
|
||||
}
|
||||
return v;
|
||||
},
|
||||
labels(r) {
|
||||
const out = ['time', 'rng'];
|
||||
for (const u of r.b.units) { const name = `${u.side === 'enemy' ? 'enemy' : 'ally'} ${u.defId}#${u.id}`; for (const f of FIELDS) out.push(`${name}.${f}`); }
|
||||
return out;
|
||||
},
|
||||
view(r) { return r.b.snapshot(); },
|
||||
deep(r) { return deepHash(r.b.units, { skip: DEEP_SKIP }); },
|
||||
result(r) { return spec.resultDigest(r.b.result()).hash; },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
// simcheck's page: runs a corpus of scenarios in this browser, compares every run with the reference made in Node
|
||||
// (core.js compare) and says which ones parted, when, and how badly — the outcome, what viewers see, or only the
|
||||
// hidden state. For a run that parted, the state vectors of the block where it parted can be downloaded and compared
|
||||
// field by field with Node's (the project's tool, `--diff`). The summary can be copied as a report.
|
||||
// Plain DOM, no framework; colours from CSS variables (--sc-*), so a page can wear its project's theme. Headless
|
||||
// drivers read window.__simcheck ({ status, results, summary, report }) and may pass ?subset=<id>&auto=1 (&only=a,b).
|
||||
//
|
||||
// Written for Stronghold Protocol (GPL-3.0-or-later) and Duel Channel (AGPL-3.0-or-later) by the same author, and kept
|
||||
// the same in both.
|
||||
import { tracer, compare, severity, selectScenarios, VERSION } from './core.js';
|
||||
|
||||
const TEXT = {
|
||||
run: '开始检查', stop: '停止', subset: '范围', scenarios: (n) => `${n} 场`,
|
||||
idle: '准备好了。检查只在本机进行,不会上传任何数据。',
|
||||
running: (i, n, id) => `正在检查 ${i} / ${n}:${id}`, stopped: '已停止', done: (n, t) => `完成:${n} 场,用时 ${t}`,
|
||||
ok: '一致', bad: '不一致', result: '结果不同', view: '画面不同', state: '状态不同', deep: '仅内部状态不同', ticks: '长度不同', missing: '参考里没有',
|
||||
where: (c, hz) => `首次检测到差异的区间:${(c.at.from / hz).toFixed(1)}–${(c.at.to / hz).toFixed(1)} 秒(第 ${c.at.from}–${c.at.to} 帧),${({ state: '状态', view: '快照', deep: '内部状态' })[c.at.level]}不同`,
|
||||
outcome: '结果不同', length: (a, b) => `长度 ${a} → ${b} 帧`,
|
||||
dump: '下载这一段的状态', dumping: '正在记录…',
|
||||
copy: '复制报告', copied: '已复制报告', copyFail: '无法写入剪贴板,请手动复制下面的内容',
|
||||
allOk: '全部选中场景的已记录状态、抽样快照与结果一致。',
|
||||
engine: '这个浏览器', reference: '参考', none: '没有不一致的场次。', error: '运行出错',
|
||||
};
|
||||
|
||||
const CSS = `
|
||||
.sc { --_bg: var(--sc-bg, #101413); --_panel: var(--sc-panel, #171d1b); --_fg: var(--sc-fg, #e6ece9); --_muted: var(--sc-muted, #93a19b);
|
||||
--_line: var(--sc-line, #2a3532); --_accent: var(--sc-accent, #4ed8af); --_ok: var(--sc-ok, #4ed8af); --_warn: var(--sc-warn, #f2c14e);
|
||||
--_bad: var(--sc-bad, #ff6b5e); --_font: var(--sc-font, system-ui, sans-serif); --_mono: var(--sc-mono, ui-monospace, monospace);
|
||||
box-sizing: border-box; max-width: 760px; margin: 0 auto; padding: 16px; color: var(--_fg); font: 14px/1.55 var(--_font); }
|
||||
.sc *, .sc *::before, .sc *::after { box-sizing: inherit; }
|
||||
.sc h1 { margin: 0 0 4px; font-size: 18px; }
|
||||
.sc-sub { margin: 0 0 12px; color: var(--_muted); }
|
||||
.sc-info { display: grid; grid-template-columns: max-content 1fr; gap: 2px 12px; margin: 0 0 14px; padding: 10px 12px; background: var(--_panel);
|
||||
border: 1px solid var(--_line); font-size: 13px; }
|
||||
.sc-info dt { color: var(--_muted); } .sc-info dd { margin: 0; overflow-wrap: anywhere; }
|
||||
.sc-controls { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin: 0 0 12px; }
|
||||
.sc button, .sc select { font: inherit; color: var(--_fg); background: var(--_panel); border: 1px solid var(--_line); min-height: 40px; padding: 6px 12px; cursor: pointer; }
|
||||
.sc button.sc-primary { border-color: var(--_accent); color: var(--_accent); }
|
||||
.sc button:disabled { opacity: .5; cursor: default; }
|
||||
.sc-bar { height: 6px; background: var(--_panel); border: 1px solid var(--_line); margin: 0 0 6px; }
|
||||
.sc-bar i { display: block; height: 100%; width: 0; background: var(--_accent); transition: width .2s; }
|
||||
.sc-status { margin: 0 0 12px; color: var(--_muted); min-height: 1.55em; overflow-wrap: anywhere; }
|
||||
.sc-sum { display: flex; flex-wrap: wrap; gap: 6px 16px; margin: 0 0 12px; }
|
||||
.sc-sum b { font: 600 18px/1 var(--_mono); margin-left: 4px; }
|
||||
.sc-sum .is-ok b { color: var(--_ok); } .sc-sum .is-warn b { color: var(--_warn); } .sc-sum .is-bad b { color: var(--_bad); }
|
||||
.sc-list { list-style: none; margin: 0 0 12px; padding: 0; display: grid; gap: 6px; }
|
||||
.sc-list li { padding: 8px 10px; background: var(--_panel); border-left: 3px solid var(--_warn); overflow-wrap: anywhere; }
|
||||
.sc-list li.is-bad { border-left-color: var(--_bad); }
|
||||
.sc-list code { font: 13px var(--_mono); }
|
||||
.sc-list button { min-height: 32px; padding: 3px 10px; margin-top: 6px; font-size: 13px; }
|
||||
.sc-note { color: var(--_muted); font-size: 13px; }
|
||||
.sc-allok { color: var(--_ok); }
|
||||
.sc textarea { width: 100%; min-height: 160px; margin-top: 8px; background: var(--_panel); color: var(--_fg); border: 1px solid var(--_line);
|
||||
font: 12px/1.5 var(--_mono); padding: 8px; }
|
||||
`;
|
||||
|
||||
const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
|
||||
const clock = (ms) => { const s = Math.round(ms / 1000); return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`; };
|
||||
// a task boundary that background tabs do not throttle like timers
|
||||
const yieldTask = () => new Promise((r) => { const ch = new MessageChannel(); ch.port1.onmessage = () => r(); ch.port2.postMessage(0); });
|
||||
|
||||
/** This browser, briefly: the engine family and version. */
|
||||
function engineName() {
|
||||
const ua = (typeof navigator !== 'undefined' && navigator.userAgent) || '';
|
||||
const m = (re) => (ua.match(re) || [])[1];
|
||||
if (m(/Edg\/(\d+)/)) return `Edge ${m(/Edg\/(\d+)/)} (Blink)`;
|
||||
if (m(/Firefox\/(\d+)/)) return `Firefox ${m(/Firefox\/(\d+)/)} (Gecko)`;
|
||||
if (/AppleWebKit/.test(ua) && !/Chrome|Chromium|CriOS/.test(ua)) return `Safari ${m(/Version\/([\d.]+)/) || ''} (WebKit)`.replace(' ', ' ');
|
||||
if (m(/CriOS\/(\d+)/)) return `Chrome iOS ${m(/CriOS\/(\d+)/)} (WebKit)`;
|
||||
if (m(/Chrom(?:e|ium)\/(\d+)/)) return `Chrome ${m(/Chrom(?:e|ium)\/(\d+)/)} (Blink)`;
|
||||
return ua || 'unknown';
|
||||
}
|
||||
|
||||
async function copyText(text) {
|
||||
try { if (navigator.clipboard && globalThis.isSecureContext) { await navigator.clipboard.writeText(text); return true; } } catch { /* the textarea */ }
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text; ta.setAttribute('readonly', ''); ta.style.cssText = 'position:fixed;left:-9999px;top:0;font-size:16px';
|
||||
document.body.appendChild(ta);
|
||||
try { ta.focus(); ta.select(); ta.setSelectionRange(0, text.length); return document.activeElement === ta && !!document.execCommand('copy'); } catch { return false; } finally { ta.remove(); }
|
||||
}
|
||||
function download(name, text) {
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(new Blob([text], { type: 'application/json' }));
|
||||
a.download = name;
|
||||
document.body.appendChild(a); a.click(); a.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(a.href), 10000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount the check into root.
|
||||
* @param {HTMLElement} root
|
||||
* @param {{ project: string, title: string, subtitle?: string, adapter: object, scenarios: { id: string, family?: string }[],
|
||||
* subsets: { id: string, label: string, pick: (sc: object) => boolean }[], reference: { every: number, made?: object,
|
||||
* scenarios: Record<string, object> }, hz?: number, info?: [string, string][], text?: object }} o
|
||||
*/
|
||||
function mountCheck(root, o) {
|
||||
const T = { ...TEXT, ...(o.text || {}) }, every = o.reference.every || 30, deepEvery = o.reference.deepEvery || every * 5, hz = o.hz || 30;
|
||||
const q = new URLSearchParams(location.search);
|
||||
if (!document.getElementById('sc-style')) { const st = document.createElement('style'); st.id = 'sc-style'; st.textContent = CSS; document.head.appendChild(st); }
|
||||
const engine = engineName(), made = o.reference.made || {};
|
||||
const info = [[T.engine, engine], [T.reference, [made.engine, made.commit, made.date].filter(Boolean).join(' · ') || '—'], ...(o.info || [])];
|
||||
root.classList.add('sc');
|
||||
root.innerHTML = `<h1>${esc(o.title)}</h1>${o.subtitle ? `<p class="sc-sub">${esc(o.subtitle)}</p>` : ''}
|
||||
<dl class="sc-info">${info.map(([k, v]) => `<dt>${esc(k)}</dt><dd>${esc(v)}</dd>`).join('')}</dl>
|
||||
<div class="sc-controls"><label>${esc(T.subset)} <select class="sc-subset">${o.subsets.map((s) =>
|
||||
`<option value="${esc(s.id)}">${esc(s.label)} · ${esc(T.scenarios(o.scenarios.filter(s.pick).length))}</option>`).join('')}</select></label>
|
||||
<button class="sc-primary sc-run">${esc(T.run)}</button><button class="sc-stop" hidden>${esc(T.stop)}</button></div>
|
||||
<div class="sc-bar"><i></i></div><p class="sc-status" role="status">${esc(T.idle)}</p>
|
||||
<div class="sc-sum"></div><ol class="sc-list"></ol>
|
||||
<div class="sc-report" hidden><button class="sc-copy">${esc(T.copy)}</button> <span class="sc-note"></span><textarea readonly hidden></textarea></div>`;
|
||||
const $ = (s) => root.querySelector(s);
|
||||
const sel = $('.sc-subset'), runBtn = $('.sc-run'), stopBtn = $('.sc-stop'), bar = $('.sc-bar i'), status = $('.sc-status');
|
||||
if (q.get('subset') && o.subsets.some((s) => s.id === q.get('subset'))) sel.value = q.get('subset');
|
||||
sel.onchange = () => q.set('subset', sel.value);
|
||||
const state = { status: 'idle', results: [], summary: null, report: '', expected: 0, completed: 0 };
|
||||
globalThis.__simcheck = state;
|
||||
let stop = false;
|
||||
|
||||
const summarize = (list) => {
|
||||
const s = { n: list.length, ok: 0, result: 0, view: 0, state: 0, deep: 0 };
|
||||
for (const r of list) {
|
||||
if (r.c.ok) { s.ok++; continue; }
|
||||
if (r.c.missing || r.c.result || r.c.ticks) s.result++;
|
||||
else s[r.c.at.level]++;
|
||||
}
|
||||
return s;
|
||||
};
|
||||
const describe = (r) => {
|
||||
const c = r.c, parts = [];
|
||||
if (c.missing) return T.missing;
|
||||
if (c.error) return `${T.error}:${c.error}`;
|
||||
if (c.at) parts.push(T.where(c, hz));
|
||||
if (c.result) parts.push(T.outcome);
|
||||
if (c.ticks) parts.push(T.length(c.ticks[0], c.ticks[1]));
|
||||
return parts.join(';');
|
||||
};
|
||||
const render = (list, total, elapsed, current) => {
|
||||
const s = summarize(list);
|
||||
bar.style.width = `${total ? (100 * list.length) / total : 0}%`;
|
||||
$('.sc-sum').innerHTML = [[T.ok, s.ok, 'is-ok'], [T.result, s.result, 'is-bad'], [T.view, s.view, 'is-warn'], [T.state, s.state, 'is-warn'], [T.deep, s.deep, 'is-warn']]
|
||||
.map(([k, v, cls]) => `<span class="${v && cls !== 'is-ok' ? cls : cls === 'is-ok' ? cls : ''}">${esc(k)}<b>${v}</b></span>`).join('');
|
||||
const bad = list.filter((r) => !r.c.ok).sort((a, b) => severity(b.c) - severity(a.c));
|
||||
$('.sc-list').innerHTML = bad.map((r, i) => `<li class="${severity(r.c) === 3 ? 'is-bad' : ''}"><code>${esc(r.id)}</code>${r.family ? ` <span class="sc-note">${esc(r.family)}</span>` : ''}<br>${esc(describe(r))}
|
||||
${r.c.at ? `<br><button data-i="${i}">${esc(T.dump)}</button>` : ''}</li>`).join('');
|
||||
$('.sc-list').querySelectorAll('button[data-i]').forEach((b) => { b.onclick = () => dumpOne(bad[Number(b.dataset.i)], b); });
|
||||
if (current) status.textContent = T.running(list.length + 1, total, current);
|
||||
state.summary = s;
|
||||
return s;
|
||||
};
|
||||
const reportText = (list, subset, elapsed) => {
|
||||
const s = summarize(list), lines = [
|
||||
`simcheck ${VERSION} · ${o.project}`, `${T.engine}:${engine}`, `UA:${(navigator && navigator.userAgent) || ''}`,
|
||||
`${T.reference}:${info[1][1]}`, `${T.subset}:${subset.label} · ${T.scenarios(list.length)} · ${clock(elapsed)}`,
|
||||
`${T.ok} ${s.ok} · ${T.result} ${s.result} · ${T.view} ${s.view} · ${T.state} ${s.state} · ${T.deep} ${s.deep}`];
|
||||
for (const r of list.filter((x) => !x.c.ok).sort((a, b) => severity(b.c) - severity(a.c))) lines.push(`- ${r.id}:${describe(r)}`);
|
||||
return lines.join('\n');
|
||||
};
|
||||
const dumpOne = async (r, btn) => {
|
||||
const old = btn.textContent;
|
||||
btn.disabled = true; btn.textContent = T.dumping;
|
||||
try {
|
||||
await o.validate?.();
|
||||
await yieldTask();
|
||||
const tr = tracer(o.adapter, o.scenarios.find((x) => x.id === r.id), { every, deepEvery, dump: [r.c.at.from, r.c.at.to] });
|
||||
while (!tr.advance(25)) await yieldTask();
|
||||
const got = tr.finish();
|
||||
download(`simcheck-${String(o.project).replace(/[^\w\u4e00-\u9fff]+/g, '-').toLowerCase()}-${r.id}-${r.c.at.from}.json`, JSON.stringify({ simcheck: VERSION, project: o.project, identity: o.reference.identity, id: r.id, window: [r.c.at.from, r.c.at.to],
|
||||
engine, ua: navigator.userAgent, dumps: got.dumps }));
|
||||
} catch (e) { status.textContent = `${T.error}:${String(e.message || e)}`; }
|
||||
finally { btn.disabled = false; btn.textContent = old; }
|
||||
};
|
||||
|
||||
const run = async () => {
|
||||
stop = false; runBtn.disabled = true; sel.disabled = true; stopBtn.hidden = false; $('.sc-report').hidden = true;
|
||||
state.status = 'running'; state.results = [];
|
||||
state.expected = 0; state.completed = 0; state.error = null;
|
||||
const t0 = performance.now();
|
||||
try {
|
||||
await o.validate?.();
|
||||
if (q.has('subset') && !o.subsets.some(s => s.id === q.get('subset'))) throw new Error(`unknown subset: ${q.get('subset')}`);
|
||||
const { subset, list } = selectScenarios(o.scenarios, o.subsets, sel.value, q.get('only'));
|
||||
state.expected = list.length;
|
||||
for (const sc of list) {
|
||||
if (stop) break;
|
||||
render(state.results, list.length, performance.now() - t0, sc.id);
|
||||
await yieldTask();
|
||||
const t1 = performance.now(), tr = tracer(o.adapter, sc, { every, deepEvery });
|
||||
while (!tr.advance(25)) { await yieldTask(); if (stop) break; }
|
||||
if (stop) break;
|
||||
const got = tr.finish();
|
||||
state.results.push({ id: sc.id, family: sc.family, c: compare(o.reference.scenarios[sc.id], got, every, deepEvery), ms: performance.now() - t1 });
|
||||
state.completed = state.results.length;
|
||||
}
|
||||
const elapsed = performance.now() - t0, s = render(state.results, list.length, elapsed, null);
|
||||
const complete = s.n > 0 && state.completed === state.expected;
|
||||
status.innerHTML = stop ? esc(T.stopped) : complete && s.ok === s.n ? `<span class="sc-allok">${esc(T.allOk)}</span> ${esc(T.done(s.n, clock(elapsed)))}` : esc(T.done(s.n, clock(elapsed)));
|
||||
if (complete && !state.results.some(r => !r.c.ok)) $('.sc-list').innerHTML = `<li class="sc-note">${esc(T.none)}</li>`;
|
||||
state.report = reportText(state.results, subset, elapsed);
|
||||
state.status = stop ? 'stopped' : 'done';
|
||||
} catch (e) {
|
||||
state.status = 'error'; state.error = String(e.message || e); state.report = `${T.error}:${state.error}`;
|
||||
status.textContent = state.report;
|
||||
} finally { $('.sc-report').hidden = false; runBtn.disabled = false; sel.disabled = false; stopBtn.hidden = true; }
|
||||
};
|
||||
runBtn.onclick = run;
|
||||
stopBtn.onclick = () => { stop = true; };
|
||||
$('.sc-copy').onclick = async () => {
|
||||
const ok = await copyText(state.report), ta = $('.sc-report textarea');
|
||||
$('.sc-report .sc-note').textContent = ok ? T.copied : T.copyFail;
|
||||
ta.hidden = ok; if (!ok) { ta.value = state.report; ta.focus(); ta.select(); }
|
||||
};
|
||||
if (q.get('auto') === '1') run();
|
||||
return state;
|
||||
}
|
||||
|
||||
export { mountCheck, engineName };
|
||||
@@ -707,6 +707,7 @@
|
||||
"冻结商店(下回合保留) · {key}": "Freeze Shop (kept next round) · {key}",
|
||||
"解冻": "Unfreeze",
|
||||
"刷新商店 · {key}": "Refresh Shop · {key}",
|
||||
"正在刷新…": "Refreshing…",
|
||||
"免费 ×{free}": "Free ×{free}",
|
||||
"查看 {name} 的战场": "View {name}'s Battlefield",
|
||||
"与你在同一战场": "On your field",
|
||||
|
||||
@@ -708,6 +708,7 @@
|
||||
"冻结商店(下回合保留) · {key}": "管理センターを凍結(次のラウンドに持ち越し) · {key}",
|
||||
"解冻": "解除",
|
||||
"刷新商店 · {key}": "管理センターを更新 · {key}",
|
||||
"正在刷新…": "更新中…",
|
||||
"免费 ×{free}": "無料 ×{free}",
|
||||
"查看 {name} 的战场": "{name}の戦場を見る",
|
||||
"与你在同一战场": "あなたと同じ戦場",
|
||||
|
||||
+52
-51
@@ -108,7 +108,7 @@
|
||||
"由创建者选择": "방장이 선택",
|
||||
"由创建者设置": "방장이 설정",
|
||||
"AI 队友最后选择": "AI 팀원은 마지막에 선택",
|
||||
"策略与机变轮选时,所有博士先于 AI 队友选择": "전략과 기회 순서 선택에서 모든 박사가 AI 팀원보다 먼저 선택합니다",
|
||||
"策略与机变轮选时,所有博士先于 AI 队友选择": "전략과 특수 선택 순서에서 AI 팀원보다 먼저 선택합니다.",
|
||||
"连接中断,请稍候重试": "연결이 끊어졌습니다. 잠시 후 다시 시도해 주세요.",
|
||||
"离开同盟": "동맹 나가기",
|
||||
"你是同盟的创建者,离开后创建者身份将移交或同盟解散。确定离开吗?": "이 동맹의 방장입니다. 나가면 방장 권한이 다른 참가자에게 넘어가거나 동맹이 해산됩니다. 나가시겠습니까?",
|
||||
@@ -241,7 +241,6 @@
|
||||
"继续作战": "전투 재개",
|
||||
"正在载入模拟数据…": "시뮬레이션 데이터를 불러오는 중…",
|
||||
"正在进入模拟…": "시뮬레이션에 진입하는 중…",
|
||||
"渲染器暂时未能加载,已切换为简化视图(功能不受影响,可刷新重试)": "렌더러를 불러오지 못해 간소화 화면으로 전환했습니다(모든 기능을 사용할 수 있으며 새로고침하면 다시 시도합니다).",
|
||||
"已配发的装备无法销毁": "장착한 장비는 파기할 수 없습니다.",
|
||||
"销毁道具": "아이템 파기",
|
||||
"道具": "아이템",
|
||||
@@ -387,9 +386,9 @@
|
||||
"倒计时结束后仍未选定将自动分配": "제한 시간이 끝날 때까지 선택하지 않으면 자동으로 배정됩니다.",
|
||||
"教鞭选择": "교편 선택",
|
||||
"教鞭 · 战术特训": "교편 · 전술 특훈",
|
||||
"请选择一项战术特训": "전술 특훈 1개를 선택해 주세요",
|
||||
"请先完成教鞭选择": "먼저 교편 선택을 완료해 주세요",
|
||||
"休整期结束时未选择将自动选定": "선택하지 않으면 휴식 기간 종료 시 자동으로 선택됩니다",
|
||||
"请选择一项战术特训": "전술 특훈 1개를 선택해 주세요.",
|
||||
"请先完成教鞭选择": "먼저 교편 선택을 완료해 주세요.",
|
||||
"休整期结束时未选择将自动选定": "선택하지 않으면 휴식 기간 종료 시 자동으로 선택됩니다.",
|
||||
"从候选中选择一项战术特训": "후보 중 전술 특훈 1개 선택",
|
||||
"选择一项(无时间限制)": "하나를 선택하세요(시간 제한 없음).",
|
||||
" · 点击卡牌选中,再次点击确认": " · 카드를 탭해 선택하고 한 번 더 탭해 확인",
|
||||
@@ -688,7 +687,7 @@
|
||||
" · 未装备模组": " · 모듈 미장착",
|
||||
"已购买": "구매 완료",
|
||||
"已招募": "모집 완료",
|
||||
"空栏位:刷新或下回合开始时补满": "빈 슬롯: 갱신하거나 다음 라운드가 시작되면 채워집니다",
|
||||
"空栏位:刷新或下回合开始时补满": "빈 슬롯: 갱신하거나 다음 라운드가 시작되면 채워집니다.",
|
||||
"再次点击确认升级({price} 资金)": "한 번 더 눌러 승급 확인 (자금 {price})",
|
||||
"升级调度中心({price} 资金) · {key}": "보급센터 승급 (자금 {price}) · {key}",
|
||||
"已满级": "최대 레벨",
|
||||
@@ -704,6 +703,7 @@
|
||||
"冻结商店(下回合保留) · {key}": "상점 동결 (다음 라운드까지 유지) · {key}",
|
||||
"解冻": "동결 해제",
|
||||
"刷新商店 · {key}": "상점 갱신 · {key}",
|
||||
"正在刷新…": "갱신 중…",
|
||||
"免费 ×{free}": "무료 ×{free}",
|
||||
"查看 {name} 的战场": "{name}의 전장 보기",
|
||||
"与你在同一战场": "같은 전장",
|
||||
@@ -816,14 +816,14 @@
|
||||
"已恢复默认快捷键": "기본 단축키를 복원했습니다.",
|
||||
"快捷键需要实体键盘;触屏设备连接键盘后可用": "단축키는 키보드가 필요합니다. 터치 기기에서도 키보드를 연결하면 사용할 수 있습니다.",
|
||||
"查看 / 更改快捷键": "단축키 보기 / 변경",
|
||||
"问题反馈": "문제 신고",
|
||||
"问题反馈": "문제 제보",
|
||||
"复制诊断信息": "진단 정보 복사",
|
||||
"遇到问题时,复制诊断信息并粘贴到 GitHub issue 中,开发者就能看到出错时的情况。诊断信息只在本机生成,不会自动上传;玩家名和房间号会被替换。": "문제가 생기면 진단 정보를 복사해 GitHub issue에 붙여 넣어 주세요. 개발자가 오류가 났을 때의 상황을 확인할 수 있습니다. 진단 정보는 이 기기에서만 만들어지며 자동으로 업로드되지 않습니다. 플레이어 이름과 방 번호는 다른 값으로 바뀝니다.",
|
||||
"遇到问题时,复制诊断信息并粘贴到 GitHub issue 中,开发者就能看到出错时的情况。诊断信息只在本机生成,不会自动上传;玩家名和房间号会被替换。": "문제가 생기면 진단 정보를 복사해 GitHub issue에 붙여 넣어 주세요. 개발자가 오류 발생 당시의 상황을 확인할 수 있습니다. 진단 정보는 이 기기에서만 만들어지며 자동으로 업로드되지 않습니다. 플레이어 이름과 방 번호는 다른 값으로 치환됩니다.",
|
||||
"已记录的错误": "기록된 오류",
|
||||
"附上本场战斗": "이 전투 첨부",
|
||||
"开发者可以用附上的战斗数据重现这场战斗。": "첨부한 전투 데이터로 개발자가 이 전투를 재현할 수 있습니다.",
|
||||
"已复制诊断信息,可以粘贴到 GitHub issue 中": "진단 정보를 복사했습니다. GitHub issue에 붙여 넣어 주세요",
|
||||
"无法写入剪贴板,请手动复制下面的内容": "클립보드에 쓸 수 없습니다. 아래 내용을 직접 복사해 주세요",
|
||||
"附上本场战斗": "이번 전투 첨부",
|
||||
"开发者可以用附上的战斗数据重现这场战斗。": "첨부한 전투 데이터로 개발자가 해당 전투를 재현할 수 있습니다.",
|
||||
"已复制诊断信息,可以粘贴到 GitHub issue 中": "진단 정보를 복사했습니다. GitHub issue에 붙여 넣어 주세요.",
|
||||
"无法写入剪贴板,请手动复制下面的内容": "클립보드에 복사할 수 없습니다. 아래 내용을 직접 복사해 주세요.",
|
||||
"诊断信息": "진단 정보",
|
||||
"观战中无法进行该操作": "관전 중에는 할 수 없습니다.",
|
||||
"标准": "표준",
|
||||
@@ -832,8 +832,8 @@
|
||||
"终极": "초월",
|
||||
"rounds::回合": "라운드",
|
||||
"{who}:获得{name}": "{who}: {name} 획득",
|
||||
"{who}:卡池中已没有{name}": "{who}: 공유 풀에 {name}이(가) 남아 있지 않습니다",
|
||||
"{who}:没有可获得的同盟约干员": "{who}: 획득할 수 있는 같은 맹약의 오퍼레이터가 없습니다",
|
||||
"{who}:卡池中已没有{name}": "{who}: 공유 풀에 {name}이(가) 남아 있지 않습니다.",
|
||||
"{who}:没有可获得的同盟约干员": "{who}: 획득할 수 있는 같은 맹약의 오퍼레이터가 없습니다.",
|
||||
"获得{name}": "{name} 획득",
|
||||
"联防阶段:{names} 迎战突破防线的敌人": "연합 방어 단계: {names} 님이 방어선을 돌파한 적을 맞아 싸웁니다.",
|
||||
"地形变化:{names}无法停留在原位置,已撤回整备区": "지형 변화: {names} 오퍼레이터가 원래 위치에 머무를 수 없어 정비 구역으로 돌아갔습니다.",
|
||||
@@ -857,7 +857,7 @@
|
||||
"(锁定)": "(고정)",
|
||||
"(精锐时生效)": "(정예화 시 적용)",
|
||||
"更换": "변경",
|
||||
"清空": "비우기",
|
||||
"清空": "초기화",
|
||||
"已在{tier}阶自选中": "이미 {tier}티어 선발에 포함됨",
|
||||
"锁定": "고정",
|
||||
"6★ 原型干员与持有的 6★ 干员,另有 4★ 原型干员(先锋、特种除外)": "6★ 프리셋 오퍼레이터와 보유한 6★ 오퍼레이터, 그리고 4★ 프리셋 오퍼레이터(뱅가드, 스페셜리스트 제외)",
|
||||
@@ -1103,59 +1103,59 @@
|
||||
"总局数": "총 판수",
|
||||
"胜场": "승리",
|
||||
"胜率": "승률",
|
||||
"到达隐秘核心": "숨겨진 핵심 도달",
|
||||
"通关隐秘核心": "숨겨진 핵심 클리어",
|
||||
"最远回合": "최고 라운드",
|
||||
"到达隐秘核心": "히든 코어 개방",
|
||||
"通关隐秘核心": "히든 코어 격파",
|
||||
"最远回合": "최고 도달 라운드",
|
||||
"累计时长": "누적 플레이 시간",
|
||||
"{n} 分": "{n}분",
|
||||
"难度": "난이도",
|
||||
"局数": "판수",
|
||||
"隐秘核心通关": "숨겨진 핵심 클리어",
|
||||
"隐秘核心通关": "히든 코어 격파",
|
||||
"使用局数": "사용 판수",
|
||||
"通过局数": "클리어 판수",
|
||||
"通过率": "클리어율",
|
||||
"称号": "평가",
|
||||
"战斗累计": "전투 누적",
|
||||
"称号": "칭호",
|
||||
"战斗累计": "전투 통계",
|
||||
"时间": "시간",
|
||||
"模式": "모드",
|
||||
"结果": "결과",
|
||||
"点击查看该局结算": "이 판의 정산 보기",
|
||||
"点击查看该局结算": "해당 판의 정산 보기",
|
||||
"单人": "독립",
|
||||
"同盟": "동맹",
|
||||
"胜": "승",
|
||||
"负": "패",
|
||||
"已导出 {n} 条对局记录": "기록 {n}건을 내보냈습니다",
|
||||
"已导入 {added} 条记录{tail}": "기록 {added}건을 가져왔습니다{tail}",
|
||||
"已导出 {n} 条对局记录": "기록 {n}건을 내보냈습니다.",
|
||||
"已导入 {added} 条记录{tail}": "기록 {added}건을 가져왔습니다{tail}.",
|
||||
"(跳过重复 {skipped} 条)": " (중복 {skipped}건 건너뜀)",
|
||||
"没有新记录(全部与现有数据重复)": "새 기록이 없습니다(모두 기존 데이터와 중복)",
|
||||
"导入失败:不是有效的统计导出文件": "가져오기 실패: 올바른 통계 내보내기 파일이 아닙니다",
|
||||
"已清空本机统计数据": "이 기기의 통계 데이터를 삭제했습니다",
|
||||
"本机数据 · 仅记录本浏览器中你参与的对局": "이 기기의 데이터 · 이 브라우저에서 참여한 판만 기록됩니다",
|
||||
"没有新记录(全部与现有数据重复)": "새 기록이 없습니다(모두 기존 데이터와 중복).",
|
||||
"导入失败:不是有效的统计导出文件": "가져오기 실패: 올바른 통계 내보내기 파일이 아닙니다.",
|
||||
"已清空本机统计数据": "해당 기기의 통계 데이터를 삭제했습니다.",
|
||||
"本机数据 · 仅记录本浏览器中你参与的对局": "해당 기기의 데이터 · 브라우저에서 참여한 판만 기록됩니다.",
|
||||
"检测到更新版本的统计数据,请先升级客户端再查看,以免覆盖数据。": "더 최신 버전의 통계 데이터가 감지되었습니다. 데이터를 덮어쓰지 않도록 클라이언트를 업데이트한 뒤 확인하세요.",
|
||||
"还没有记录 —— 完成一局对局后,这里会开始积累。": "아직 기록이 없습니다 — 한 판을 마치면 여기에 쌓이기 시작합니다.",
|
||||
"未知": "알 수 없음",
|
||||
"对局记录": "판 기록",
|
||||
"对局记录": "전적",
|
||||
"{n} 小时": "{n}시간",
|
||||
"计入统计:打到结算的对局,以及中途退出前至少清完 1 个回合的对局;更早退出的对局只留在对局记录里。": "집계 대상: 정산까지 진행한 판, 그리고 1라운드 이상 클리어한 뒤 중도 포기한 판. 그보다 일찍 나간 판은 판 기록에만 남습니다.",
|
||||
"另有 {n} 局未计入统计": "집계에 포함되지 않은 판이 {n}건 더 있습니다",
|
||||
"没有清完第一个回合就退出,不计入统计": "첫 라운드를 클리어하기 전에 나가서 집계에 포함되지 않습니다",
|
||||
"中途退出的对局没有结算可看": "중도 포기한 판은 볼 수 있는 정산이 없습니다",
|
||||
"较早的对局只保留汇总,不能回看结算": "오래된 판은 요약만 저장되어 정산을 볼 수 없습니다",
|
||||
"计入统计:打到结算的对局,以及中途退出前至少清完 1 个回合的对局;更早退出的对局只留在对局记录里。": "집계 대상: 정산까지 진행한 판, 1라운드 이상 클리어한 판. 그 외의 기록은 '전적'에만 남습니다.",
|
||||
"另有 {n} 局未计入统计": "집계에 포함되지 않은 판이 {n}건 있습니다.",
|
||||
"没有清完第一个回合就退出,不计入统计": "첫 라운드를 클리어하기 전에 나가서 집계에 포함되지 않습니다.",
|
||||
"中途退出的对局没有结算可看": "중도 포기한 판은 정산을 볼 수 없습니다.",
|
||||
"较早的对局只保留汇总,不能回看结算": "오래된 판은 요약만 저장되어 정산을 볼 수 없습니다.",
|
||||
"中途退出": "중도 퇴장",
|
||||
"不计入统计": "집계 제외",
|
||||
"显示更多(还有 {n} 局)": "더 보기 (남은 {n}건)",
|
||||
"点击一行,回看该局结算;中途退出的对局只留记录。": "행을 누르면 해당 판의 정산을 다시 볼 수 있습니다. 중도 포기한 판은 기록만 남습니다.",
|
||||
"最多保留最近 {max} 局,只有最新的 {full} 局能回看结算。": "최근 {max}판까지만 보관하며, 정산을 다시 볼 수 있는 것은 최신 {full}판뿐입니다.",
|
||||
"返回统计": "통계로 돌아가기",
|
||||
"文件过大,无法导入": "파일이 너무 커서 가져올 수 없습니다",
|
||||
"无法写入本机存储,可能已满": "로컬 저장소에 쓰지 못했습니다. 공간이 가득 찼을 수 있습니다",
|
||||
"文件过大,无法导入": "파일이 너무 커서 가져올 수 없습니다.",
|
||||
"无法写入本机存储,可能已满": "로컬 저장소에 쓰지 못했습니다. 공간이 가득 찼을 수 있습니다.",
|
||||
"({n} 条无法读取)": " (읽을 수 없는 기록 {n}건)",
|
||||
"导入失败:这份文件由更新版本的客户端导出,请先升级客户端": "가져오기 실패: 더 최신 버전 클라이언트에서 내보낸 파일입니다. 먼저 클라이언트를 업데이트하세요",
|
||||
"清空统计数据": "통계 데이터 비우기",
|
||||
"导入失败:这份文件由更新版本的客户端导出,请先升级客户端": "가져오기 실패: 더 최신 버전 클라이언트에서 내보낸 파일입니다. 먼저 클라이언트를 업데이트하세요.",
|
||||
"清空统计数据": "통계 데이터 초기화",
|
||||
"将删除本机保存的 {n} 条对局记录,无法恢复。": "이 기기에 저장된 기록 {n}건을 삭제합니다. 되돌릴 수 없습니다.",
|
||||
"导出为文件(可在别的设备上导入)": "파일로 내보내기 (다른 기기에서 가져올 수 있습니다)",
|
||||
"导出为文件(可在别的设备上导入)": "파일로 내보내기 (다른 기기에서 가져올 수 있습니다.)",
|
||||
"从导出的文件导入(与现有记录合并)": "내보낸 파일에서 가져오기 (기존 기록과 병합)",
|
||||
"清空本机的统计数据": "이 기기의 통계 데이터 비우기",
|
||||
"清空本机的统计数据": "이 기기의 통계 데이터 삭제",
|
||||
"本机数据:只保存在这个浏览器里,不会上传到服务器;同盟对局里队友的代号也会存在本机(导出文件里也有)。": "이 기기의 데이터: 이 브라우저에만 저장되며 서버로 전송되지 않습니다. 동맹 시뮬레이션에서 함께한 동료의 이름도 이 기기에 저장됩니다(내보낸 파일에도 포함).",
|
||||
"本机保存的统计数据无法读取,已另存为备份,从现在起重新记录。": "이 기기에 저장된 통계 데이터를 읽을 수 없습니다. 백업으로 따로 보관했으며 지금부터 새로 기록합니다.",
|
||||
"有 {n} 条记录已损坏,已忽略。": "손상된 기록 {n}건을 무시했습니다.",
|
||||
@@ -1174,13 +1174,13 @@
|
||||
"练度": "육성도",
|
||||
"潜能与练度": "잠재와 육성도",
|
||||
"满潜能 · 精英2 Lv.60": "최대 잠재 · 2차 정예화 Lv.60",
|
||||
"干员持有中标记为未持有:替补干员没有潜能与练度;这里的设置在改回「持有」后生效": "오퍼레이터 보유에서 미보유로 표시됨: 대체 오퍼레이터는 잠재와 육성도가 없습니다. 이 설정은 다시 '보유'로 바꾸면 적용됩니다",
|
||||
"原型干员没有潜能与练度": "프로토타입 오퍼레이터는 잠재와 육성도가 없습니다",
|
||||
"模组仅在精锐形态生效": "모듈은 정예화 형태에서만 적용됩니다",
|
||||
"未持有(干员持有):由替补干员上场,替补干员没有潜能与练度": "미보유(오퍼레이터 보유): 대체 오퍼레이터가 출전하며 잠재와 육성도가 없습니다",
|
||||
"干员持有中标记为未持有:替补干员没有潜能与练度;这里的设置在改回「持有」后生效": "오퍼레이터 보유에서 미보유로 표시됨: 대체 오퍼레이터는 잠재와 육성도가 없습니다. 이 설정은 다시 '보유'로 바꾸면 적용됩니다.",
|
||||
"原型干员没有潜能与练度": "프리셋 오퍼레이터는 잠재와 육성도가 없습니다.",
|
||||
"模组仅在精锐形态生效": "모듈은 정예화 형태에서만 적용됩니다.",
|
||||
"未持有(干员持有):由替补干员上场,替补干员没有潜能与练度": "미보유(오퍼레이터 보유): 대체 오퍼레이터가 출전하며 잠재와 육성도가 없습니다.",
|
||||
"含潜能与练度;": "잠재와 육성도 포함. ",
|
||||
"将 {nChanged} 名干员的技能、模组、潜能与练度恢复为默认配置?": "오퍼레이터 {nChanged}명의 스킬, 모듈, 잠재, 육성도를 기본 설정으로 되돌릴까요?",
|
||||
"已导入 {n} 名干员的潜能与练度": "오퍼레이터 {n}명의 잠재와 육성도를 가져왔습니다",
|
||||
"已导入 {n} 名干员的潜能与练度": "오퍼레이터 {n}명의 잠재와 육성도를 가져왔습니다.",
|
||||
"精英1": "정예 1",
|
||||
"精英2": "정예 2",
|
||||
"精英2 Lv.60": "정예 2 Lv.60",
|
||||
@@ -1195,7 +1195,7 @@
|
||||
"跟随全局": "전체 설정 따르기",
|
||||
"仅保存在此浏览器,缺失的日语语音会回退到中文。": "이 브라우저에만 저장됩니다. 일본어 음성이 없으면 중국어로 재생합니다.",
|
||||
"添加到桌面": "홈 화면에 추가",
|
||||
"恢复本机对局": "이 기기의 게임 복귀",
|
||||
"恢复本机对局": "해당 게임 복귀",
|
||||
"此对局仍在其他窗口中,或当前浏览器无法安全恢复。请关闭原窗口后重试。": "다른 창에서 진행 중이거나 이 브라우저에서 안전하게 복귀할 수 없습니다. 원래 창을 닫고 다시 시도하세요.",
|
||||
"选择已关闭窗口的对局。服务器重启或对局过期后无法恢复。": "닫힌 창의 게임을 선택하세요. 서버 재시작 또는 게임 만료 후에는 복귀할 수 없습니다.",
|
||||
"{name} · 同盟 {code}": "{name} · 동맹 {code}",
|
||||
@@ -1210,9 +1210,10 @@
|
||||
"同意重刷": "다시 추첨에 동의",
|
||||
"拒绝重刷": "다시 추첨 거부",
|
||||
"取消投票": "투표 취소",
|
||||
"只能部署在召唤者相邻四格": "소환자와 상하좌우로 인접한 4칸에만 배치할 수 있습니다",
|
||||
"{names}只能部署在召唤者相邻四格,已退回整备区": "{names}은(는) 소환자와 상하좌우로 인접한 4칸에만 배치할 수 있어 정비 구역으로 돌아갔습니다",
|
||||
"{names}只能部署在召唤者相邻四格,整备区已满,下回合返还": "{names}은(는) 소환자와 상하좌우로 인접한 4칸에만 배치할 수 있습니다. 정비 구역이 가득 차 다음 라운드에 반환됩니다",
|
||||
"装备库存不足,未获得该装备": "장비 재고가 부족하여 획득하지 못했습니다",
|
||||
"库存不足": "재고 부족"
|
||||
"只能部署在召唤者相邻四格": "소환자와 상하좌우로 인접한 4칸에만 배치할 수 있습니다.",
|
||||
"{names}只能部署在召唤者相邻四格,已退回整备区": "{names}은(는) 소환자와 상하좌우로 인접한 4칸에만 배치할 수 있어 정비 구역으로 돌아갔습니다.",
|
||||
"{names}只能部署在召唤者相邻四格,整备区已满,下回合返还": "{names}은(는) 소환자와 상하좌우로 인접한 4칸에만 배치할 수 있습니다. 정비 구역이 가득 차 다음 라운드에 반환됩니다.",
|
||||
"装备库存不足,未获得该装备": "장비 재고가 부족하여 획득하지 못했습니다.",
|
||||
"库存不足": "재고 부족",
|
||||
"渲染器暂时未能加载,已切换为简化视图(功能不受影响,可刷新重试)": "렌더러를 불러오지 못해 간소화 화면으로 전환했습니다(모든 기능을 사용할 수 있으며 새로고침하면 다시 시도합니다)."
|
||||
}
|
||||
@@ -706,6 +706,7 @@
|
||||
"冻结商店(下回合保留) · {key}": "凍結商店(下回合保留) · {key}",
|
||||
"解冻": "解凍",
|
||||
"刷新商店 · {key}": "更新商店 · {key}",
|
||||
"正在刷新…": "正在更新…",
|
||||
"免费 ×{free}": "免費 ×{free}",
|
||||
"查看 {name} 的战场": "查看 {name} 的戰場",
|
||||
"与你在同一战场": "與你在同一戰場",
|
||||
|
||||
+70
-3
@@ -525,6 +525,9 @@ export class AudioManager {
|
||||
this.voiceGate = new VoiceGate();
|
||||
this.voiceNode = null; // { src, gain, url, token } of the line on air
|
||||
this.voiceToken = 0;
|
||||
this.bondCueNode = null;
|
||||
this.bondCueToken = 0;
|
||||
this.bondCueScope = null;
|
||||
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)
|
||||
@@ -666,6 +669,7 @@ export class AudioManager {
|
||||
muted: typeof v?.muted === 'boolean' ? v.muted : this.volumes.muted,
|
||||
};
|
||||
this._applyVolumes();
|
||||
if (this.volumes.muted || this.volumes.sfx <= 0) this.stopBondCue();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -834,6 +838,58 @@ export class AudioManager {
|
||||
|
||||
// ---- SFX ------------------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* True while a cue of `scope` started less than `ms` ago. One preparation update can both add layers (m.layer → layerGain)
|
||||
* and raise the active-bond count (screens/game.js): the second trigger of that same update keeps the playing cue
|
||||
* instead of cutting it off and starting it again. A later gain still replaces the cue at once (no cooldown).
|
||||
*/
|
||||
bondCueRecent(scope, ms = 400) {
|
||||
if (this.bondCueScope !== scope || typeof performance === 'undefined') return false;
|
||||
return performance.now() - (this.bondCueAt || 0) < ms;
|
||||
}
|
||||
|
||||
/** The official ADD_BOND bank has maxSoundAllowed=1 / popOldest=true. No extra cooldown. */
|
||||
stopBondCue(scope = null) {
|
||||
if (scope && this.bondCueScope !== scope) return;
|
||||
this.bondCueToken++;
|
||||
this.bondCueScope = null;
|
||||
const cur = this.bondCueNode;
|
||||
this.bondCueNode = null;
|
||||
if (!cur) return;
|
||||
try { cur.src.stop(); } catch { /* already ended */ }
|
||||
cur.end();
|
||||
}
|
||||
|
||||
_playBondCue(url, volume, scope = 'prep') {
|
||||
this.stopBondCue();
|
||||
const token = this.bondCueToken, ctx = this.ctx;
|
||||
if (!ctx || !url || this.volumes.muted || this.volumes.sfx <= 0) return;
|
||||
this.bondCueScope = scope;
|
||||
this.bondCueAt = typeof performance !== 'undefined' ? performance.now() : 0;
|
||||
this._buffer(url).then((buf) => {
|
||||
if (!buf || token !== this.bondCueToken || ctx !== this.ctx || this.volumes.muted || this.volumes.sfx <= 0) return;
|
||||
let src, gain;
|
||||
const end = () => {
|
||||
try { src?.disconnect(); gain?.disconnect(); } catch { /* ignore */ }
|
||||
if (this.bondCueNode?.src === src) this.bondCueNode = null;
|
||||
};
|
||||
try {
|
||||
src = ctx.createBufferSource(); gain = ctx.createGain();
|
||||
src.buffer = buf;
|
||||
gain.gain.value = Math.max(0, Math.min(1.5, volume));
|
||||
src.connect(gain); gain.connect(this.sfxGain);
|
||||
src.onended = end;
|
||||
this.bondCueNode = { src, gain, end };
|
||||
src.start();
|
||||
} catch { end(); }
|
||||
}, () => {});
|
||||
}
|
||||
|
||||
/** One cue per real gain, including several layers at once or an inactive bond. */
|
||||
layerGain(bondId, add, scope = 'prep') {
|
||||
if (typeof bondId === 'string' && bondId && Number.isFinite(add) && add > 0) this.sfx('bondUp', { volume: 0.7, scope });
|
||||
}
|
||||
|
||||
_play(url, { volume = 1, rate = 1, limited = false, unitKey = null } = {}) {
|
||||
if (!this.ctx || !url || this.volumes.muted || this.volumes.sfx <= 0) return;
|
||||
const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
||||
@@ -862,12 +918,15 @@ export class AudioManager {
|
||||
/**
|
||||
* UI sound by name (sfx.ui keys). Unknown names are ignored.
|
||||
* @param {string} name
|
||||
* @param {{ volume?: number }} [o]
|
||||
* @param {{ volume?: number, scope?: string }} [o]
|
||||
*/
|
||||
sfx(name, o = {}) {
|
||||
try {
|
||||
const url = this.getManifest()?.audio?.sfx?.ui?.[name];
|
||||
if (typeof url === 'string') this._play(url, { volume: o.volume ?? 0.9 });
|
||||
if (typeof url === 'string') {
|
||||
if (name === 'bondUp') this._playBondCue(url, o.volume ?? 0.9, o.scope);
|
||||
else this._play(url, { volume: o.volume ?? 0.9 });
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
@@ -1013,6 +1072,7 @@ export class AudioManager {
|
||||
|
||||
/** Reset the unit map for a new field (m.field.units = UnitInfo[]). */
|
||||
setFieldUnits(units) {
|
||||
this.stopBondCue();
|
||||
this.units.clear();
|
||||
// unit ids belong to one battle: a cast still held for a unit of the field left must not sound for a unit of the new
|
||||
// one that happens to share its id
|
||||
@@ -1056,7 +1116,9 @@ export class AudioManager {
|
||||
if (!Array.isArray(e)) continue;
|
||||
const kind = e[0];
|
||||
if (kind === 'spawn') { this._track(e[1]); continue; }
|
||||
if (kind === 'atk') {
|
||||
if (kind === 'layer') {
|
||||
this.layerGain(e[2], e[3], 'battle');
|
||||
} else if (kind === 'atk') {
|
||||
// a chain bounce: its first id is the previous target, whose attack sound this is not (see header)
|
||||
if (CHAIN_KINDS.has(e[3])) { this.lastAttacker.delete(e[2]); continue; }
|
||||
const src = this.units.get(e[1]);
|
||||
@@ -1176,6 +1238,11 @@ export function installAudio(deps) {
|
||||
const sync = (s) => {
|
||||
try {
|
||||
const pub = s.match?.public ?? null;
|
||||
if (deps.selectRoute(s) !== 'game') audio.stopBondCue();
|
||||
else if (![PHASE.COMBAT, PHASE.UNITE, PHASE.FINAL_ASSAULT, PHASE.HIDDEN_CORE].includes(pub?.phase)) {
|
||||
// A new prep gain can precede its m.public phase update. Cancel only the previous battle's cue.
|
||||
audio.stopBondCue('battle');
|
||||
}
|
||||
// 开战 BGM: the round's own track out of bgm.combatAlts (骑士之日 / 无畏者 are fixed per round, not drawn),
|
||||
// so every client of a room hears the same one, a mid-fight re-render (or a teammate view) never switches,
|
||||
// and the next round moves on by the table. 联防 shares its round ⇒ same key ⇒ the loop keeps playing.
|
||||
|
||||
@@ -61,9 +61,10 @@
|
||||
// 0.1.0). Read-only: it takes the stats the sim computed last (`unit._s`) and the range grid it
|
||||
// keeps, and never makes the unit recompute them, so looking never changes the battle's floats.
|
||||
// battleRunner.unitIdOf(uid, ownerId, fieldId?) → the id of an own board piece's unit in that battle | null
|
||||
// battleRunner.ownerOps(ownerId, fieldId?) → [{ kind: 'op', ownerId, defId, items?, standInFor? }] that player's
|
||||
// battleRunner.ownerOps(ownerId, fieldId?) → [{ kind: 'op', ownerId, defId, items?, standInFor?, diy? }] that player's
|
||||
// operators in the battle on screen with their equipment (a teammate's bond popup: the members in
|
||||
// play, DESIGN §20.15, 变形同构体 wearers included; a 补位 stand-in names the replaced charId) | []
|
||||
// play, DESIGN §20.15, 变形同构体 wearers included; a 补位 stand-in names the replaced charId,
|
||||
// a 自选 operator carries its pick like UnitInfo) | []
|
||||
//
|
||||
// createBattleRunner(deps) builds an instance with injectable net / store / clock / frame scheduler / sim loader
|
||||
// (test/match/runner.test.js and test/match/runner-pending.test.js drive it under Node).
|
||||
@@ -883,7 +884,8 @@ export function createBattleRunner(deps) {
|
||||
.map((u) => {
|
||||
const o = Array.isArray(u.items) && u.items.length ? { kind: 'op', ownerId, defId: u.defId, items: [...u.items] } : { kind: 'op', ownerId, defId: u.defId };
|
||||
const si = u.def && typeof u.def.standInFor === 'string' ? u.def.standInFor : null;
|
||||
return si ? { ...o, standInFor: si } : o;
|
||||
const diy = u.def?.diyFor ? u.def.loadout?.diy : null;
|
||||
return { ...o, ...(si ? { standInFor: si } : {}), ...(diy ? { diy: { ...diy } } : {}) };
|
||||
});
|
||||
},
|
||||
/** Re-show the current battle (the game screen remounted). */
|
||||
|
||||
+5
-1
@@ -44,7 +44,7 @@ import { TitleScreen, sanitizeName } from './screens/title.js';
|
||||
import { LobbyScreen, rememberRoom, parseRoomParam } from './screens/lobby.js';
|
||||
import { RoomScreen } from './screens/room.js';
|
||||
import { GameScreen } from './screens/game.js';
|
||||
import { installAudio } from './audio.js';
|
||||
import { audio, installAudio } from './audio.js';
|
||||
import { settingsStore } from './ui/settings.js';
|
||||
import { GuideHost } from './ui/guide.js';
|
||||
import { installDeviceSupport } from './ui/device.js';
|
||||
@@ -229,6 +229,10 @@ function wireNet() {
|
||||
});
|
||||
net.on('m.public', (msg) => { matchAt = Date.now(); store.patch('match', { public: payload(msg) }); maybeFinishRestore(); });
|
||||
net.on('m.private', (msg) => { matchAt = Date.now(); store.patch('match', { private: payload(msg) }); });
|
||||
net.on('m.layer', (msg) => {
|
||||
const s = store.get();
|
||||
if (selectRoute(s) === 'game' && msg.playerId === s.me?.playerId) audio.layerGain(msg.bondId, msg.add);
|
||||
});
|
||||
net.on('m.field', (msg) => store.patch('match', { field: payload(msg) }));
|
||||
net.on('m.result', (msg) => {
|
||||
const res = payload(msg);
|
||||
|
||||
+16
-19
@@ -6,8 +6,8 @@
|
||||
//
|
||||
// Driving (units.js calls these; the sim is authoritative, the actor only visualises):
|
||||
// setBase('idle'|'move'|'stun') the resting state from the snapshot anim code
|
||||
// attack(interval, once) one attack happened now (b.ev 'atk'): plays begin→loop, re-phases the loop
|
||||
// so its OnAttack frame lands now, timeScale = loopDuration / interval;
|
||||
// attack(interval, once) one attack happened now (b.ev 'atk'): consumes its wind-up or starts at
|
||||
// OnAttack, once; operator timeScale = loopDuration / interval;
|
||||
// `once` (a one-off cast, style.js PROJ[kind].once — 暴鸰's bomb drop): the
|
||||
// clip plays once at its own speed, then base; `clipPerAttack` (enemies,
|
||||
// GitHub #58): every attack plays the clip once — at its own speed, faster
|
||||
@@ -33,8 +33,7 @@
|
||||
// update(dt) advances the skeleton (autoUpdate is off: one clock for everything)
|
||||
// poseHeld() true while the pose cannot change (render/units.js then neither re-poses nor
|
||||
// redraws the model, only advances `clock`)
|
||||
// Attack mode lasts until ~1.4 attack intervals without a new attack (a `once` cast and every attack of a
|
||||
// `clipPerAttack` actor: to the end of its clip), then the end clip (if any) and base — except the attacks of a skill
|
||||
// Each attack plays its clip once, then the end clip (if any) and base — except the attacks of a skill
|
||||
// with its own idle clip, which go straight back to that idle: the skill's end clip closes the skill, not each spell of
|
||||
// attacks while it runs.
|
||||
|
||||
@@ -158,10 +157,10 @@ export class SpineActor {
|
||||
/**
|
||||
* Enemies (render/units.js, GitHub #58): each attack plays the attack clip once — at its own speed, faster only when
|
||||
* the attacks come quicker than the clip — and then the resting state; the sim stands the enemy for exactly that
|
||||
* clip (server/sim/ai.js attackStand). Off (operators): the clip loops over the attack rhythm.
|
||||
* clip (server/sim/ai.js attackStand). Off (operators): retain rhythm scaling, but still play each attack once.
|
||||
*/
|
||||
this.clipPerAttack = false;
|
||||
this.wound = false; // clipPerAttack: wound up for the coming attack (windUp → attack)
|
||||
this.wound = false; // wound up for the coming attack (windUp → attack)
|
||||
this._applied = false; // an update has posed the skeleton since its clip last changed (poseHeld)
|
||||
this._appliedTint = undefined; // the tint that update put on the slots (pixi-spine applies `tint` in update)
|
||||
this._play(this._idleName(), true);
|
||||
@@ -366,9 +365,8 @@ export class SpineActor {
|
||||
if (this.dead || this.mode === 'stun' || this.mode === 'die' || this.mode === 'change' || !(lead >= 0)) return false;
|
||||
const clip = this._attackClip();
|
||||
if (!clip) return false;
|
||||
const single = once || this.clipPerAttack;
|
||||
// in rhythm: attack() re-phases; a clip-per-attack actor starts each attack's clip anew unless already wound up for it
|
||||
if (this.mode === 'attack' && this.current === clip.loop && (!this.clipPerAttack || once || this.wound)) return false;
|
||||
// One queued attack owns one wind-up. A later attack may begin while the previous recovery is still playing.
|
||||
if (this.mode === 'attack' && this.current === clip.loop && (once || this.wound)) return false;
|
||||
const loopDur = this.dur(clip.loop);
|
||||
// a one-off cast plays at the clip's own speed (one loop per clip length), whatever the attack rhythm
|
||||
const iv = once ? loopDur : clampN(Number.isFinite(interval) && interval > 0 ? interval : this.interval, 0.08, 8);
|
||||
@@ -378,8 +376,8 @@ export class SpineActor {
|
||||
if (!(hit > 0) || lead * plan.ts > hit + 1e-6) return false;
|
||||
if (!once) this.interval = iv;
|
||||
this.mode = 'attack';
|
||||
this.attackUntil = this.clock + lead + (single ? Math.max(0, loopDur - hit) / plan.ts : Math.max(0.45, iv * 1.4));
|
||||
this._play(clip.loop, !single, { timeScale: plan.tsWind, start: plan.start, mix: 0.06 });
|
||||
this.attackUntil = this.clock + lead + Math.max(0, loopDur - hit) / plan.ts;
|
||||
this._play(clip.loop, false, { timeScale: plan.tsWind, start: plan.start, mix: 0.06 });
|
||||
this.windTs = plan.ts;
|
||||
this.windUntil = this.clock + lead;
|
||||
this.wound = true;
|
||||
@@ -397,14 +395,13 @@ export class SpineActor {
|
||||
if (!clip) return;
|
||||
const loopDur = this.dur(clip.loop);
|
||||
const per = this.clipPerAttack && !once;
|
||||
const single = once || per;
|
||||
// clip per attack: its own speed (ts 1), faster only when the attacks come quicker than the clip
|
||||
const ts = once ? 1 : per ? clampN(loopDur / Math.min(this.interval, loopDur), 1, 4) : clampN(loopDur / this.interval, 0.35, 4);
|
||||
const hit = this._hitTime(clip.loop, loopDur);
|
||||
// (a clip-per-attack actor still playing the previous attack's clip was not wound up for this one)
|
||||
const wasAttacking = this.mode === 'attack' && this.current === clip.loop && (!per || this.wound);
|
||||
const wasAttacking = this.mode === 'attack' && this.current === clip.loop && this.wound;
|
||||
this.mode = 'attack';
|
||||
this.attackUntil = this.clock + (single ? Math.max(0, loopDur - hit) / ts : Math.max(0.45, this.interval * 1.4));
|
||||
this.attackUntil = this.clock + Math.max(0, loopDur - hit) / ts;
|
||||
this.windUntil = null;
|
||||
this.wound = false;
|
||||
if (!wasAttacking) {
|
||||
@@ -414,15 +411,14 @@ export class SpineActor {
|
||||
// lifts before the slam; #246 by @TsangAsuna, accepted by the owner on 2026-10-07)
|
||||
const whole = per && this.interval >= loopDur;
|
||||
if (whole) this.attackUntil = this.clock + loopDur / ts;
|
||||
this._play(clip.loop, !single, { timeScale: ts, start: whole ? 0 : hit, mix: 0.06 });
|
||||
} else if (single) {
|
||||
const e = this.spine.state.tracks[0];
|
||||
if (e) e.timeScale = ts;
|
||||
this._play(clip.loop, false, { timeScale: ts, start: whole ? 0 : hit, mix: 0.06 });
|
||||
} else {
|
||||
const e = this.spine.state.tracks[0];
|
||||
if (e) {
|
||||
e.timeScale = ts;
|
||||
// re-phase gently so the strike frame lines up with this attack
|
||||
// Preserve operator hit-phase correction when a coarse/batched update crossed windUntil.
|
||||
// This adjusts the prepared single clip; it does not start another visual swing.
|
||||
if (!once && !per) {
|
||||
const t = e.trackTime % loopDur;
|
||||
let d = hit - t;
|
||||
if (d > loopDur / 2) d -= loopDur; else if (d < -loopDur / 2) d += loopDur;
|
||||
@@ -430,6 +426,7 @@ export class SpineActor {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_attackClip() {
|
||||
const sk = this.roles.skill;
|
||||
|
||||
@@ -84,7 +84,7 @@ import { CombatHud } from '../ui/combatHud.js';
|
||||
import { SettingsModal, settingsStore } from '../ui/settings.js';
|
||||
import { ExitModal, AwayOverlay, awayStore } from '../ui/matchChrome.js';
|
||||
import { openGuide } from '../ui/guide.js';
|
||||
import { actions } from '../ui/gameActions.js';
|
||||
import { actions, onRefreshBusy } from '../ui/gameActions.js';
|
||||
import { FacingWheel, holdPiece, setPieceDir, syncPieceDirs, showRange, useTileScreen } from '../ui/facingWheel.js';
|
||||
import { Underframe, underframeRect, TempRowNotice } from '../ui/underframe.js';
|
||||
import { StandInTags } from './game/standInTags.js';
|
||||
@@ -183,6 +183,10 @@ function MatchScreen() {
|
||||
// deals duplicates) carry the same chess id and no piece, and each must say its 选中干员 (detailPanel selectVoiceKey)
|
||||
const cardTap = useRef(0);
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
// GitHub #501: a g.refresh is still waiting for its reply — the shop's 刷新 button dims while it is (gameActions.js
|
||||
// single-flights the intent; the R hotkey is dropped by the same guard)
|
||||
const [refreshBusy, setRefreshBusy] = useState(false);
|
||||
useEffect(() => onRefreshBusy(setRefreshBusy), []);
|
||||
const [rewardMin, setRewardMin] = useState(false);
|
||||
const [emoteOpen, setEmoteOpen] = useState(false);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
@@ -428,6 +432,7 @@ function MatchScreen() {
|
||||
if (showPrep) {
|
||||
if (field?.prep) staleFieldRef.current = field; // a prep scouting board is never a battle to enter
|
||||
if (viewModeRef.current !== 'prep') {
|
||||
audio.stopBondCue('battle');
|
||||
// the battle we just left (or whatever was stored before mount) must not be re-entered next combat;
|
||||
// an m.field that arrives during prep (the upcoming battle) is a new object and will be entered
|
||||
staleFieldRef.current = field;
|
||||
@@ -759,7 +764,8 @@ function MatchScreen() {
|
||||
const activeBonds = (priv?.bonds || []).filter((b) => b && b.active).length;
|
||||
const prevActive = useRef(activeBonds);
|
||||
useEffect(() => {
|
||||
if (activeBonds > prevActive.current && phase === PHASE.PREP) audio.sfx('bondUp', { volume: 0.7 });
|
||||
// a layer gain of the same update already started the cue (audio.bondCueRecent): keep it, don't restart it
|
||||
if (activeBonds > prevActive.current && phase === PHASE.PREP && !audio.bondCueRecent('prep')) audio.sfx('bondUp', { volume: 0.7, scope: 'prep' });
|
||||
prevActive.current = activeBonds;
|
||||
}, [activeBonds]);
|
||||
|
||||
@@ -1468,7 +1474,7 @@ function MatchScreen() {
|
||||
${spectator ? null : html`<${Button} size="sm" variant="primary" icon="back" onClick=${() => watchPlayer({ playerId: myId })}>${t('返回自己')}<//>`}
|
||||
</div>` : null}
|
||||
|
||||
${showShop ? html`<${ShopBar} priv=${priv} editable=${editable} collapsed=${collapsed} onCollapse=${setCollapsed}
|
||||
${showShop ? html`<${ShopBar} priv=${priv} editable=${editable} collapsed=${collapsed} refreshBusy=${refreshBusy} onCollapse=${setCollapsed}
|
||||
barRef=${barRef} offBonds=${offBonds}
|
||||
onBuy=${buy} onLevel=${() => actions.levelUp()} onRefresh=${() => actions.refresh()} onFreeze=${() => actions.freeze()}
|
||||
onDetail=${(id, kind, hint) => setDetail({ kind: kind === 'item' ? 'item' : 'chess', id, hint: hint || null, tap: ++cardTap.current })}
|
||||
|
||||
@@ -675,7 +675,7 @@ function LoadoutScreen({ st }) {
|
||||
${inMatch && hasDeadline(infoDeadline) ? html`<${Countdown} deadline=${infoDeadline} size="sm" gauge=${false} label=${t('调配截止')} class="lo-deadline" />` : null}
|
||||
${syncText ? html`<span class=${cx('lo-sync', syncCls)} role="status">${t(syncText)}</span>` : null}
|
||||
<span class="lo-count">${t('已调整')} <b class="num">${nChanged}</b><span class="num t-dim">/${roster.length}</span></span>
|
||||
<${Button} variant="ghost" size="sm" data-testid="loadout-export" disabled=${!nChanged} onClick=${openExport} title=${t('导出当前调配(可复制或下载)')}>${t('导出')}<//>
|
||||
<${Button} variant="ghost" size="sm" data-testid="loadout-export" disabled=${!nChanged && !Object.keys(st.ops || {}).length} onClick=${openExport} title=${t('导出当前调配(可复制或下载)')}>${t('导出')}<//>
|
||||
<${Button} variant="ghost" size="sm" data-testid="loadout-import" disabled=${!ready} onClick=${openImport} title=${t('导入调配(粘贴或选择文件)')}>${t('导入')}<//>
|
||||
<${Button} variant="secondary" size="sm" icon="refresh" disabled=${!nChanged} onClick=${resetAll}>${t('全部恢复默认')}<//>
|
||||
</div>`}
|
||||
|
||||
+35
-12
@@ -534,24 +534,27 @@ export function DialogHost() {
|
||||
|
||||
// ---- Tooltip -----------------------------------------------------------------------------------
|
||||
|
||||
let tipState = null; // { id, content, rect, placement }
|
||||
let tipState = null; // { id, content, aside, rect, placement }
|
||||
let tipSeq = 0;
|
||||
const tipListeners = new Set();
|
||||
const setTip = (s) => { tipState = s; for (const fn of [...tipListeners]) fn(); };
|
||||
|
||||
/**
|
||||
* Hover/focus tooltip around its children. Content renders in <TooltipLayer/> (fixed, viewport-clamped).
|
||||
* @param {{ text: any, placement?: 'top'|'bottom', delay?: number, block?: boolean, class?: string, children?: any }} props
|
||||
* `aside`: a second section in the same clamped, scrollable box; stacked on narrow screens.
|
||||
* @param {{ text: any, aside?: any, placement?: 'top'|'bottom', delay?: number, block?: boolean, class?: string, children?: any }} props
|
||||
*/
|
||||
const TOUCH_TIP_MS = 450;
|
||||
|
||||
export function Tooltip({ text, placement = 'top', delay = 120, block = false, class: cls, children }) {
|
||||
export function Tooltip({ text, aside = null, placement = 'top', delay = 120, block = false, class: cls, children }) {
|
||||
const ref = useRef(null);
|
||||
const idRef = useRef(0);
|
||||
const timer = useRef(null);
|
||||
const textRef = useRef(text);
|
||||
const asideRef = useRef(aside);
|
||||
const pressed = useRef(false); // pointer pressed inside: the focus that follows is not a keyboard focus
|
||||
textRef.current = text;
|
||||
asideRef.current = aside;
|
||||
if (!idRef.current) idRef.current = ++tipSeq;
|
||||
const show = () => {
|
||||
clearTimeout(timer.current);
|
||||
@@ -560,7 +563,8 @@ export function Tooltip({ text, placement = 'top', delay = 120, block = false, c
|
||||
const el = ref.current;
|
||||
const content = textRef.current; // the text of the render current when the delay ends (a click may change it)
|
||||
if (!el || !el.isConnected || content == null || content === '') return;
|
||||
setTip({ id: idRef.current, content, rect: el.getBoundingClientRect(), placement });
|
||||
setTip({ id: idRef.current, content, aside: asideRef.current, rect: el.getBoundingClientRect(), placement,
|
||||
hold: () => clearTimeout(timer.current), hide });
|
||||
}, delay);
|
||||
};
|
||||
const hide = () => {
|
||||
@@ -570,7 +574,7 @@ export function Tooltip({ text, placement = 'top', delay = 120, block = false, c
|
||||
// A click hides the tooltip until the pointer leaves: the focus it gives the button must not bring it back
|
||||
// (with the text of the click's closure) over whatever the click opened. Keyboard focus still shows it.
|
||||
// Touch: a tap never shows it (the compatibility mouseenter that follows a tap is ignored); a long press
|
||||
// (≥ 450 ms) does, and it goes away shortly after the finger lifts.
|
||||
// (≥ 450 ms) does. Ordinary tips expire after release; compound lists stay open for scrolling until dismissed.
|
||||
const touchAt = useRef(0);
|
||||
const longTimer = useRef(null);
|
||||
const onPointerDown = (e) => {
|
||||
@@ -586,18 +590,24 @@ export function Tooltip({ text, placement = 'top', delay = 120, block = false, c
|
||||
if (!e || e.pointerType === 'mouse') return;
|
||||
touchAt.current = Date.now();
|
||||
clearTimeout(longTimer.current);
|
||||
if (tipState?.id === idRef.current) { clearTimeout(timer.current); timer.current = setTimeout(hide, 1600); }
|
||||
if (tipState?.id === idRef.current && !asideRef.current) { clearTimeout(timer.current); timer.current = setTimeout(hide, 1600); }
|
||||
};
|
||||
const onMouseEnter = () => { if (Date.now() - touchAt.current > 900) show(); };
|
||||
const onFocusIn = () => { if (!pressed.current && Date.now() - touchAt.current > 900) show(); };
|
||||
const onLeave = () => { pressed.current = false; if (Date.now() - touchAt.current > 900) hide(); };
|
||||
const onLeave = () => {
|
||||
pressed.current = false;
|
||||
if (Date.now() - touchAt.current > 900) {
|
||||
if (asideRef.current) { clearTimeout(timer.current); timer.current = setTimeout(hide, 250); }
|
||||
else hide();
|
||||
}
|
||||
};
|
||||
useEffect(() => () => { clearTimeout(longTimer.current); hide(); }, []);
|
||||
// Keep the visible tooltip's content fresh when `text` changes (and hide it when text goes away).
|
||||
useEffect(() => {
|
||||
if (tipState?.id !== idRef.current) return;
|
||||
if (text == null || text === '') setTip(null);
|
||||
else setTip({ ...tipState, content: text });
|
||||
}, [text]);
|
||||
else setTip({ ...tipState, content: text, aside: asideRef.current });
|
||||
}, [text, aside]);
|
||||
return html`<span ref=${ref} class=${cx('tt-anchor', block && 'tt-anchor--block', cls)}
|
||||
onMouseEnter=${onMouseEnter} onMouseLeave=${onLeave} onFocusIn=${onFocusIn} onFocusOut=${onLeave} onPointerDown=${onPointerDown}
|
||||
onPointerUp=${onPointerEnd} onPointerCancel=${onPointerEnd} onContextMenu=${(e) => { if (Date.now() - touchAt.current < 900) e.preventDefault(); }}>
|
||||
@@ -612,7 +622,12 @@ export function TooltipLayer() {
|
||||
const [pos, setPos] = useState(null);
|
||||
useEffect(() => {
|
||||
tipListeners.add(force);
|
||||
return () => tipListeners.delete(force);
|
||||
const outside = (e) => { if (tipState?.aside && !e.target?.closest?.('.tooltip')) tipState.hide?.(); };
|
||||
const escape = (e) => { if (e.key === 'Escape') tipState?.hide?.(); };
|
||||
document.addEventListener('pointerdown', outside);
|
||||
document.addEventListener('keydown', escape);
|
||||
window.addEventListener('resize', force);
|
||||
return () => { tipListeners.delete(force); document.removeEventListener('pointerdown', outside); document.removeEventListener('keydown', escape); window.removeEventListener('resize', force); };
|
||||
}, []);
|
||||
useLayoutEffect(() => {
|
||||
const el = boxRef.current;
|
||||
@@ -628,13 +643,21 @@ export function TooltipLayer() {
|
||||
if (place === 'bottom' && top + b.height > vh - m) { place = 'top'; top = Math.max(m, r.top - b.height - m); }
|
||||
let left = r.left + r.width / 2 - b.width / 2;
|
||||
left = Math.max(m, Math.min(vw - b.width - m, left));
|
||||
top = Math.max(m, Math.min(vh - b.height - m, top));
|
||||
const next = { top: Math.round(top), left: Math.round(left), place, id: tipState.id };
|
||||
if (!pos || pos.top !== next.top || pos.left !== next.left || pos.id !== next.id || pos.place !== next.place) setPos(next);
|
||||
});
|
||||
if (!tipState) return null;
|
||||
const ready = pos && pos.id === tipState.id;
|
||||
return html`<div ref=${boxRef} class=${cx('tooltip', ready && 'is-shown', ready && `tooltip--${pos.place}`)} role="tooltip"
|
||||
style=${ready ? `top:${pos.top}px;left:${pos.left}px` : 'top:-9999px;left:-9999px'}>${tipState.content}</div>`;
|
||||
const hasAside = tipState.aside != null && tipState.aside !== '';
|
||||
const off = 'top:-9999px;left:-9999px';
|
||||
return html`<div ref=${boxRef} class=${cx('tooltip', hasAside && 'tooltip--compound', ready && 'is-shown', ready && `tooltip--${pos.place}`)} role="tooltip"
|
||||
tabIndex=${hasAside ? 0 : undefined} onMouseEnter=${() => tipState?.hold?.()} onMouseLeave=${() => tipState?.hide?.()}
|
||||
onFocusIn=${() => tipState?.hold?.()} onPointerDown=${() => tipState?.hold?.()}
|
||||
onFocusOut=${(e) => { if (!e.currentTarget.contains(e.relatedTarget)) tipState?.hide?.(); }}
|
||||
style=${ready ? `top:${pos.top}px;left:${pos.left}px` : off}>
|
||||
${hasAside ? html`<div class="tooltip__main">${tipState.content}</div><div class="tooltip--aside">${tipState.aside}</div>` : tipState.content}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ---- ProgressBar, Tabs, Spinner ----------------------------------------------------------------
|
||||
|
||||
@@ -20,6 +20,22 @@ const emitBusy = () => { for (const fn of [...busyListeners]) { try { fn(infligh
|
||||
/** Subscribe to the number of in-flight intents (for subtle busy indicators). */
|
||||
export function onBusy(fn) { busyListeners.add(fn); return () => busyListeners.delete(fn); }
|
||||
|
||||
// Single-flight shop refresh (GitHub #501, single-ptilopsis): under a high RTT the shop has not answered yet while the
|
||||
// player taps 刷新 again — every extra g.refresh the client sends is a paid refresh (server/match intents.js → ps.refresh).
|
||||
// A tap while the previous request is still on the wire is dropped; the button dims and the R hotkey stays inert (both
|
||||
// call actions.refresh) until the request settles (success or failure). This is not server-side cancellation or
|
||||
// idempotency: a timed-out request may already have executed. The shop bar receives onRefreshBusy via the game screen.
|
||||
let refreshPending = false;
|
||||
const refreshBusyListeners = new Set();
|
||||
const emitRefreshBusy = () => { for (const fn of [...refreshBusyListeners]) { try { fn(refreshPending); } catch { /* ignore */ } } };
|
||||
|
||||
/**
|
||||
* Subscribe to the shop refresh's in-flight state — fires once with the current state.
|
||||
* @param {(busy: boolean) => void} fn
|
||||
* @returns {() => void} unsubscribe
|
||||
*/
|
||||
export function onRefreshBusy(fn) { refreshBusyListeners.add(fn); try { fn(refreshPending); } catch { /* ignore */ } return () => refreshBusyListeners.delete(fn); }
|
||||
|
||||
/**
|
||||
* Send an intent. Resolves true on `ok`, false on error (already toasted).
|
||||
* @param {string} t
|
||||
@@ -55,7 +71,13 @@ export const actions = {
|
||||
band: (bandId) => act('g.band', { bandId }),
|
||||
bandSkip: () => act('g.bandSkip'),
|
||||
buy: (slot) => act('g.buy', { slot }),
|
||||
refresh: () => act('g.refresh'),
|
||||
// a tap while the previous g.refresh is still on the wire is dropped (GitHub #501) — see onRefreshBusy above
|
||||
refresh: () => {
|
||||
if (refreshPending) return Promise.resolve(false);
|
||||
refreshPending = true;
|
||||
emitRefreshBusy();
|
||||
return act('g.refresh').finally(() => { refreshPending = false; emitRefreshBusy(); });
|
||||
},
|
||||
freeze: () => act('g.freeze'),
|
||||
levelUp: () => act('g.levelUp'),
|
||||
sell: (uid) => act('g.sell', { uid }),
|
||||
|
||||
@@ -201,8 +201,9 @@ export function GIcon({ name, class: cls, title }) {
|
||||
|
||||
/**
|
||||
* Green LP tower + number. `pending` (> 0): the loss the running battle's leaks will cost at settlement (user
|
||||
* playtest #3 item 2) — the number shows value − pending in red followed by a −N tick that pops again on every change
|
||||
* (keyed); `note` a small tag after it (联防中); `tip` the title text.
|
||||
* playtest #3 item 2) — the number keeps the settled LP (owner-approved #479 / #504),
|
||||
* shown in red with a −N tick that pops again on every change (keyed); `note` a
|
||||
* small tag after it (联防中); `tip` the title text.
|
||||
* @param {{ value: any, size?: 'sm'|'md'|'lg', class?: string, tone?: string|null, pending?: number, note?: string|null, tip?: string|null }} props
|
||||
*/
|
||||
export function LpTower({ value, size = 'md', class: cls, tone, pending = 0, note = null, tip = null }) {
|
||||
@@ -211,7 +212,7 @@ export function LpTower({ value, size = 'md', class: cls, tone, pending = 0, not
|
||||
return html`<span class=${cx('lp', `lp--${size}`, tone && `lp--${tone}`, p > 0 && 'is-pending', cls)} title=${tip || tr('目标生命值')}
|
||||
data-pending=${p > 0 ? p : null}>
|
||||
<${Sprite} k="hudPanel/icon_hp" class="lp__icon" fallback=${html`<${Icon} name="rook" class="lp__icon" />`} />
|
||||
<b class="num lp__val">${ok ? Math.max(0, value - p) : '--'}</b>
|
||||
<b class="num lp__val">${ok ? Math.max(0, value) : '--'}</b>
|
||||
${p > 0 ? html`<span key=${p} class="lp__pend num" aria-label=${tr('结算时扣除 {p}', { p })}>−${p}</span>` : null}
|
||||
${note ? html`<span class="lp__note">${note}</span>` : null}
|
||||
</span>`;
|
||||
|
||||
@@ -4,12 +4,23 @@ import { GEO, PHASE } from '../../../../shared/constants.js';
|
||||
import { BOSS_ROW_SHIFT, MAX_COL } from '../../render/prepfield.js';
|
||||
import { int, isObj } from './shared.js';
|
||||
|
||||
/** Server-owned random pairs. Seat inference is only for an older server without the field. */
|
||||
function prepPair(pub, myId) {
|
||||
const alive = (Array.isArray(pub?.players) ? pub.players : []).filter(p => isObj(p) && p.alive !== false);
|
||||
if (!alive.some(p => p.playerId === myId)) return [];
|
||||
if (pub.bossPairs !== undefined) {
|
||||
return (Array.isArray(pub.bossPairs) ? pub.bossPairs : []).find(g => Array.isArray(g) && g.includes(myId)) || [];
|
||||
}
|
||||
const ids = alive.sort((a, b) => int(a.seat, 99) - int(b.seat, 99)).map(p => p.playerId);
|
||||
const i = ids.indexOf(myId);
|
||||
return ids.slice(i - i % 2, i - i % 2 + 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Camera of the own prep board (research 09 §1.2 "Final Assault, right side", DESIGN §15): in the prep of a boss round
|
||||
* (最终攻势 / 隐秘核心: `pub.round` is `bossRound` or `hiddenRound`) the pieces stand on the player's half of the shared
|
||||
* boss field — `{ kind: 'bossPrep', opts: { side } }`; the side follows the server's pairing (server/match/
|
||||
* finalAssault.js pairPlayers: the alive players in seat order, two by two; the second of a pair is on the mirrored
|
||||
* finalAssault.js pairPlayers, sent in m.public.bossPairs; the second of a pair is on the mirrored
|
||||
* right half 'R', a lone player on 'L'). Any other prep: the own board `{ kind: 'prep', opts: { rect, side: 'L' } }`.
|
||||
* @param {any} pub m.public
|
||||
* @param {string|null} myId
|
||||
@@ -19,9 +30,7 @@ export function prepCamera(pub, myId) {
|
||||
const normal = { kind: 'prep', opts: { rect: { ...GEO.NORMAL_RECT }, side: 'L' } };
|
||||
const r = pub?.round;
|
||||
if (!Number.isInteger(r) || r <= 0 || !(r === pub.bossRound || r === pub.hiddenRound)) return normal;
|
||||
const alive = (Array.isArray(pub.players) ? pub.players : []).filter((p) => isObj(p) && p.alive !== false)
|
||||
.sort((a, b) => int(a.seat, 99) - int(b.seat, 99));
|
||||
const idx = alive.findIndex((p) => p.playerId === myId);
|
||||
const idx = prepPair(pub, myId).indexOf(myId);
|
||||
if (idx < 0) return normal; // eliminated / unknown: no board of its own on the boss field
|
||||
return { kind: 'bossPrep', opts: { side: idx % 2 === 1 ? 'R' : 'L' } };
|
||||
}
|
||||
@@ -32,7 +41,7 @@ const BOSS_PREP_PHASES = new Set([PHASE.ROUND_START, PHASE.SP_DRAFT, PHASE.PREP]
|
||||
* The players framed in green on the team panel (community report of 2026-10-06, item 51: 「同组两个玩家屏幕左边的头像也像原版
|
||||
* 那样用绿框框起来」 — the official co-op frame, ui/battle `bg_team_border`): in a boss round (最终攻势 / 隐秘核心) the viewer
|
||||
* and its pair partner — from the round's start (回合开始 / 机变 / 休整期: the pairing `prepCamera` reads, the players still
|
||||
* in by seat, two by two — server/match/finalAssault.js pairPlayers) through the fight (the boss field listing the
|
||||
* in the server's saved pairs — server/match/finalAssault.js pairPlayers) through the fight (the boss field listing the
|
||||
* viewer). Empty for a lone player, an eliminated player or a spectator seat (no pair of its own) and in every other
|
||||
* round. PR #191 framed the partner alone, in gold (not merged).
|
||||
* @param {any} pub m.public @param {string|null} myId
|
||||
@@ -51,9 +60,7 @@ export function teamFrameIds(pub, myId) {
|
||||
return f && f.players.length > 1 ? new Set(f.players) : none;
|
||||
}
|
||||
if (!bossRound || !BOSS_PREP_PHASES.has(pub.phase)) return none;
|
||||
const alive = players.filter((p) => p.alive !== false).sort((a, b) => int(a.seat, 99) - int(b.seat, 99)).map((p) => p.playerId);
|
||||
const idx = alive.indexOf(myId);
|
||||
const pair = alive.slice(idx - (idx % 2), idx - (idx % 2) + 2);
|
||||
const pair = prepPair(pub, myId);
|
||||
return pair.length > 1 ? new Set(pair) : none;
|
||||
}
|
||||
|
||||
|
||||
+9
-7
@@ -12,13 +12,14 @@
|
||||
// overtimeState): "NN 秒后全队生命值开始流失" once the level time ran out, then a live "生命值 −1/秒" indicator while the
|
||||
// merged team LP drains (the LP tower turns red). Solo battles: a pause / resume button beside the countdown (g.pause);
|
||||
// while m.public.paused every clock here is frozen at the pause moment (`frozenAt`).
|
||||
// Normal rounds (user playtest #3 item 2): the LP tower drops live as the own battle's enemies enter the blue gate —
|
||||
// lp − min(lpCapPerRound, counted leaks) in red with a −N tick, 联防中 while a 联防 may still save part of it (liveLp;
|
||||
// Normal rounds (user playtest #3 item 2): the LP tower marks the own battle's leaks live as its enemies enter the blue
|
||||
// gate — the settled lp stays until settlement, in red with a −min(lpCapPerRound, counted leaks) tick (GitHub #479: the
|
||||
// official keeps the current value until settlement), 联防中 while a 联防 may still save part of it (liveLp;
|
||||
// the leaks come from the local battle runner, else m.public players[].pendingLp). 联防 (user playtest #6 item 7; PRTS
|
||||
// 卫戍协议/帮助 "防卫失败的玩家可通过上方信息栏确认自身所属敌人的剩余数量"): a leaker's phase capsule carries the official
|
||||
// runner tag ×N (research 09 `tag_miss`; art ui/battle bg_miss_enemy [ASSUMED]) = its enemies still standing on the 联防
|
||||
// field, uncapped and live — falling as the helpers kill them, rising when one splits or summons (the local 联防
|
||||
// replica's runner state().uniteLeft, else m.public players[].uniteLeft); the LP tower shows lp − min(lpCapPerRound, N).
|
||||
// replica's runner state().uniteLeft, else m.public players[].uniteLeft); the LP tower marks lp with −min(lpCapPerRound, N).
|
||||
// The same MissTag sits in the leakers' team rows (ui/teamPanel.js), its tooltip naming the teammate there (missTip).
|
||||
// 准备就绪 is refused while the temp overflow row (临时整备区) holds pieces: the reason shows under the button
|
||||
// (user playtest #3 item 3; the row's own label is ui/underframe.js TempRowNotice).
|
||||
@@ -183,9 +184,10 @@ export function uniteRemaining(local, server) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The own LP while a normal round's battle runs: the loss its counted leaks will cost is shown at once (red, −N)
|
||||
* instead of only at settlement. `base` — kept by the caller between renders — is the settled m.private state the
|
||||
* pending loss applies to: { round, lp, statsLeaks } as first seen in the round's COMBAT / 联防. The pending part is
|
||||
* The own LP while a normal round's battle runs: the loss its counted leaks will cost is marked at once (red, −N) instead
|
||||
* of only at settlement — the number itself keeps the settled LP until settlement (GitHub #479: the official shows the
|
||||
* current value, not the deduction, until the round settles). `base` — kept by the caller between renders — is the
|
||||
* settled m.private state the pending loss applies to: { round, lp, statsLeaks } as first seen in the round's COMBAT / 联防. The pending part is
|
||||
* dropped as soon as the settlement lands — m.private lp or stats.leaks changed (Match.flush sends m.private before the
|
||||
* SETTLE m.public) — or the phase leaves COMBAT / UNITE or the round changes, so the loss is never subtracted twice.
|
||||
* 联防 (UNITE): a leaker finally loses min(cap, the survivors of the 联防 battle that came from them); `uniteLeft` (its
|
||||
@@ -352,7 +354,7 @@ export function PauseButton({ paused, busy = false, onToggle }) {
|
||||
* spectators?: any[]|null, myId?: any, isHost?: boolean, onRemoveSpectator?: ((playerId: any) => any)|null }} props
|
||||
* spectators: the room's spectator seats (room.state) — the 观战席 capsule beside the latency (SpectatorPill; the host removes)
|
||||
* frozenAt: the server time every clock shows while the solo match is paused (null = live)
|
||||
* live: the own battle's pending LP loss (liveLp): the tower shows lp − pending in red with a −N tick, 联防中 during 联防;
|
||||
* live: the own battle's pending LP loss (liveLp): the tower keeps the settled lp and adds a red −N tick, 联防中 during 联防;
|
||||
* `left` (a leaker in 联防): its enemies still standing — the capsule's ×N tag
|
||||
*/
|
||||
export function TopBar({ pub, priv, conn, hud, total, drawer, onExit, onDrawer, onReady, readyBusy, readyCount, playerCount, pen = false, penAvail = false, onPen = () => {},
|
||||
|
||||
@@ -69,14 +69,38 @@ export function DiyBannedLine({ model, class: cls = 'brief-banned__diy' }) {
|
||||
${t('{names}的盟约本局全部禁用,不会出现在你的商店', { names: list.map((x) => dn(x.name)) })}</p>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The box beside a bond's tooltip: its banned members (the badge's count), each with avatar, name and bonds (the hovered
|
||||
* one highlighted); null when none are banned.
|
||||
* @param {any} bond @param {MatchInfoModel} model @param {Set<string>} banned
|
||||
*/
|
||||
function BondBannedAside(bond, model, banned) {
|
||||
const ids = (Array.isArray(bond.visibleMembers) ? bond.visibleMembers : []).filter((id) => banned.has(id));
|
||||
if (!ids.length) return null;
|
||||
const nameOf = new Map(model.bonds.map((b) => [b.bondId, b.name]));
|
||||
return html`<div class="brief-bantip">
|
||||
<div class="brief-bantip__h">${t('本局禁用干员')}</div>
|
||||
${ids.map((id) => {
|
||||
const c = data.lookup('chess', id);
|
||||
const bonds = (Array.isArray(c?.bonds) ? c.bonds : []).filter((b) => nameOf.has(b));
|
||||
return html`<div key=${id} class="brief-bantip__op" data-chess=${id}>
|
||||
<${UnitThumb} kind="chess" id=${id} size="xs" showTier=${false} />
|
||||
<div class="brief-bantip__txt"><b>${c?.name || id}</b>
|
||||
<span>${bonds.map((b, i) => html`${i ? ' · ' : ''}<span class=${cx(b === bond.bondId && 'is-this')}>${nameOf.get(b)}</span>`)}</span></div>
|
||||
</div>`;
|
||||
})}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* One row of bond discs (核心盟约 / 附加盟约): a greyed disc (✕) for a bond in the drawn set D or one the mode never
|
||||
* activates, the red badge with its banned members, a red ring on an enabled bond that lost members; the tooltip says
|
||||
* which (briefingBondTip).
|
||||
* which (briefingBondTip). A bond with banned members lists them in a second box beside the tooltip (BondBannedAside).
|
||||
* @param {{ title: string, micro: string, bonds: any[], model: MatchInfoModel }} props
|
||||
*/
|
||||
export function MatchBondRow({ title, micro, bonds, model }) {
|
||||
const m = data.get('assets');
|
||||
const banned = new Set(model.banned);
|
||||
return html`<div class="brief-bonds">
|
||||
<h3 class="brief-h"><span>${title}</span><${MicroLabel}>${micro}</${MicroLabel}></h3>
|
||||
<div class="brief-bonds__row">
|
||||
@@ -84,7 +108,8 @@ export function MatchBondRow({ title, micro, bonds, model }) {
|
||||
const state = model.stateOf(b.bondId);
|
||||
const off = !!state;
|
||||
const bannedN = model.perBond.get(b.bondId) || 0;
|
||||
return html`<${Tooltip} key=${b.bondId} text=${briefingBondTip(b.name, state, bannedN)}>
|
||||
return html`<${Tooltip} key=${b.bondId} text=${briefingBondTip(b.name, state, bannedN)}
|
||||
aside=${bannedN > 0 ? BondBannedAside(b, model, banned) : null}>
|
||||
<div class=${cx('brief-bond', off && 'is-off', state === 'drawn' && 'is-incomplete', !off && bannedN > 0 && 'is-partial')} data-bond=${b.bondId}>
|
||||
<${BondDisc} name=${b.name} icon=${bondIconUrl(m, b.bondId)} active=${!off} disabled=${off} tier=${off ? 0 : (b.thresholds?.length || 1)}
|
||||
maxTier=${Math.max(1, b.thresholds?.length || 1)} size="md" />
|
||||
|
||||
@@ -10,7 +10,9 @@
|
||||
// pointer): there is no ⓘ corner (the official cards have none; user playtest #6 item 10 — on phones its invisible
|
||||
// 44 px touch area covered the card's bottom-right quarter, half the strip, and a second tap there only re-opened the
|
||||
// detail); a card that cannot be bought right now (the bar is not editable: ready, round start) opens its detail. The
|
||||
// D / R / F keys stay one-press shortcuts. There is no drag-to-sell zone: selling is the 出售 +N button of a tapped
|
||||
// D / R / F keys stay one-press shortcuts — a 刷新 whose request is still on the wire dims the button and swallows
|
||||
// further taps and the R key until the reply lands (GitHub #501; the single-flight lives in ui/gameActions.js
|
||||
// onRefreshBusy, ui/shopBar.js only draws it). There is no drag-to-sell zone: selling is the 出售 +N button of a tapped
|
||||
// unit's underframe (ui/underframe.js). After a promotion the operator cards are replaced by
|
||||
// the 晋升奖励 cards (3 free operators, pick 1 — also two taps) until picked or put off (稍后 → rewardOverlay.js pill);
|
||||
// a special refresh (凯瑟琳 定向投放's 3 items, 娜仁图亚, 寻呼模块, 信标, 松果) takes the same place under its own name, an
|
||||
@@ -286,13 +288,13 @@ export function RewardCards({ offer, priv, editable, onPick, onDetail, onLater,
|
||||
|
||||
/**
|
||||
* The bar.
|
||||
* @param {{ priv:any, editable:boolean, collapsed:boolean, onCollapse:(c:boolean)=>void,
|
||||
* @param {{ priv:any, editable:boolean, collapsed:boolean, refreshBusy?:boolean, onCollapse:(c:boolean)=>void,
|
||||
* onBuy:(i:number)=>void, onLevel:Function, onRefresh:Function, onFreeze:Function, onDetail:(id:string, kind?:string, hint?:string|null)=>void,
|
||||
* onDetailClose?: () => void, onRefuse?: (reason: string) => void, barRef:any,
|
||||
* reward?: any, onReward?: (idx:number)=>void, onRewardLater?: Function, offBonds?: Set<string>|null }} props — offBonds:
|
||||
* the bonds this mode never activates (gameLogic modeOffBonds), struck through on the operator cards
|
||||
*/
|
||||
export function ShopBar({ priv, editable, collapsed, onCollapse, onBuy, onLevel, onRefresh, onFreeze, onDetail, onDetailClose, onRefuse, barRef,
|
||||
export function ShopBar({ priv, editable, collapsed, refreshBusy = false, onCollapse, onBuy, onLevel, onRefresh, onFreeze, onDetail, onDetailClose, onRefuse, barRef,
|
||||
reward = null, onReward, onRewardLater, onArm = null, offBonds = null }) {
|
||||
const shop = priv?.shop || {};
|
||||
const slots = Array.isArray(shop.slots) ? shop.slots : [];
|
||||
@@ -354,10 +356,10 @@ export function ShopBar({ priv, editable, collapsed, onCollapse, onBuy, onLevel,
|
||||
<${Img} src=${uiUrl(data.get('assets'), frozen ? 'shopPanel/frozen_icon2' : 'shopPanel/frozen_icon')} class="toolbtn__img" fallback=${html`<${Icon} name="snow" />`} />
|
||||
<span>${frozen ? t('解冻') : t('冻结')}</span><kbd>${hk.freeze}</kbd>
|
||||
</button>
|
||||
<button type="button" class="toolbtn toolbtn--amber" disabled=${!!refReason} onClick=${onRefresh} title=${refReason || t('刷新商店 · {key}', { key: hk.refresh })}>
|
||||
<button type="button" class="toolbtn toolbtn--amber" disabled=${!!refReason || refreshBusy} onClick=${onRefresh} title=${refReason || (refreshBusy ? t('正在刷新…') : t('刷新商店 · {key}', { key: hk.refresh }))}>
|
||||
<${Img} src=${uiUrl(data.get('assets'), 'shopPanel/refresh_icon')} class="toolbtn__img" fallback=${html`<${Icon} name="refresh" />`} />
|
||||
<span>${t('刷新')}</span>
|
||||
${free > 0 ? html`<span class="toolbtn__free">${t('免费 ×{free}', { free })}</span>` : html`<${HexBadge} value=${shop.refreshPrice ?? 1} tone=${refReason ? 'dark' : 'gold'} size="sm" />`}
|
||||
${free > 0 ? html`<span class="toolbtn__free">${t('免费 ×{free}', { free })}</span>` : html`<${HexBadge} value=${shop.refreshPrice ?? 1} tone=${refReason || refreshBusy ? 'dark' : 'gold'} size="sm" />`}
|
||||
<kbd>${hk.refresh}</kbd>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -373,6 +373,7 @@ export class Match {
|
||||
this.sp = null;
|
||||
this.wave = null;
|
||||
this.bossWaves = null;
|
||||
this.bossPairs = null; // drawn once per match; the hidden round retains partners and sides
|
||||
this.fields = [];
|
||||
this.runner = null;
|
||||
/** playerId → fieldId */
|
||||
|
||||
+12
-4
@@ -30,7 +30,7 @@
|
||||
// units > seat, research 08 §5); leakers = players with counted leaks
|
||||
// settle no unite ⇒ loss = min(cap, counted leaks); after 联防 a leaker loses ≤ cap (its leaked enemies'
|
||||
// offspring count too), everybody else ≤ min(cap, own counted leaks); LP ≤ 0 ⇔ eliminated
|
||||
// final assault fields pair alive players by seat, team LP = Σ alive LP, boss pool = bossPoolHp(); hidden core only
|
||||
// final assault fields follow the saved per-match pairs, team LP = Σ alive LP, boss pool = bossPoolHp(); hidden core only
|
||||
// after a win when hiddenEligible() holds
|
||||
// result each title ≤ once, ≤ 1 title per player, onlyOnWin titles only on a win, roundsPassed per player,
|
||||
// Σ alive players' LP = the merged team LP after the Final Assault
|
||||
@@ -44,7 +44,7 @@
|
||||
import { PHASE } from '../../shared/constants.js';
|
||||
import { collectViolations } from './invariants.js';
|
||||
import { mergeTile, pieceDir, canPlace, placeClass } from './board.js';
|
||||
import { pairPlayers, bossPoolHp, hiddenEligible } from './finalAssault.js';
|
||||
import { bossPoolHp, hiddenEligible } from './finalAssault.js';
|
||||
import { helperOrder } from './unite.js';
|
||||
import { BAND_TURN_SECONDS } from './Match.js';
|
||||
|
||||
@@ -449,15 +449,23 @@ export function attachAudit(m, { invariants = true, limit = 200 } = {}) {
|
||||
});
|
||||
wrap(m, 'startFinalAssault', function (orig, hidden) {
|
||||
const alive = m.alivePlayers();
|
||||
const before = m.bossPairs?.map(g => g.slice()) ?? null;
|
||||
const lpSum = alive.reduce((s, p) => s + Math.max(0, p.lp), 0);
|
||||
const teamLp0 = m.teamLp;
|
||||
const res = orig(hidden);
|
||||
runInvariants();
|
||||
check('final assault', () => {
|
||||
if (!alive.length) return;
|
||||
const groups = pairPlayers(alive).map((g) => g.map((p) => p.playerId).join(','));
|
||||
const pairs = m.bossPairs || [];
|
||||
const memberIds = pairs.flat();
|
||||
if (pairs.length !== Math.ceil(alive.length / 2) || pairs.some(g => g.length < 1 || g.length > 2)
|
||||
|| new Set(memberIds).size !== memberIds.length
|
||||
|| JSON.stringify([...memberIds].sort()) !== JSON.stringify(alive.map(p => p.playerId).sort())) fail('boss pairing does not cover every alive player once');
|
||||
if (before && before.flat().every(id => alive.some(p => p.playerId === id)) && before.flat().length === alive.length
|
||||
&& JSON.stringify(before) !== JSON.stringify(pairs)) fail('boss pairing changed after preparation');
|
||||
const groups = pairs.map(g => g.join(','));
|
||||
const fields = m.fields.map((f) => f.players.join(','));
|
||||
if (JSON.stringify(groups) !== JSON.stringify(fields)) fail(`boss fields ${fields.join(' | ')} != seat pairs ${groups.join(' | ')}`);
|
||||
if (JSON.stringify(groups) !== JSON.stringify(fields)) fail(`boss fields ${fields.join(' | ')} != saved pairs ${groups.join(' | ')}`);
|
||||
m.fields.forEach((f, i) => { if (f.fieldId !== `b${i + 1}`) fail(`boss field id ${f.fieldId}`); });
|
||||
if (!hidden && m.teamLp !== lpSum) fail(`team LP ${m.teamLp} != Σ alive LP ${lpSum}`);
|
||||
if (hidden && m.teamLp !== teamLp0) fail(`hidden core changed team LP ${teamLp0} → ${m.teamLp}`);
|
||||
|
||||
@@ -480,6 +480,7 @@ export function makeCtx(m, ps, source, hook, ev = null) {
|
||||
lp: () => ps.lp,
|
||||
alive: () => ps.alive,
|
||||
bandId: () => ps.bandId,
|
||||
teamMedicGranted: () => m.teamMedicGranted(),
|
||||
shopLevel: () => ps.shop.level,
|
||||
bond: (id) => (ps.bonds[id] ? { ...ps.bonds[id] } : null),
|
||||
bonds: () => Object.fromEntries(Object.entries(ps.bonds).map(([k, v]) => [k, { ...v }])),
|
||||
@@ -569,6 +570,7 @@ export function makeCtx(m, ps, source, hook, ev = null) {
|
||||
source: opts.source || source.key || 'effect',
|
||||
toTemp: !!opts.toTemp,
|
||||
deferMerge: ps._deferItemMerge > 0,
|
||||
requirePool: opts.requirePool !== false,
|
||||
});
|
||||
return p ? view(p) : null;
|
||||
},
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
//
|
||||
// * Merged LP: teamLp = Σ LP of the alive players at the start of the Final Assault (no cap); the Hidden Core
|
||||
// continues with what is left.
|
||||
// * Pairing: alive players by seat → (1,2), (3,4); an odd player is alone on its own field with the `_s` template
|
||||
// * Pairing: shuffled once per match before Final Assault prep; Hidden Core keeps those pairs. If players leave,
|
||||
// intact pairs stay together and the remaining single players pair up. An odd player uses the `_s` template
|
||||
// (solo modes always use `_s`). Field ids 'b1', 'b2'. In a pair the first player is the LEFT side, the second the
|
||||
// RIGHT side (the sim mirrors the right side: board col c → field col 20 − c with the piece direction RIGHT ↔
|
||||
// LEFT, UP / DOWN unchanged (DESIGN §3, research 09 §1.2 ConvertChessPositionInfoToBossMap); board rows 9–12 →
|
||||
@@ -55,11 +56,22 @@ export function bossFieldPlacement(side, row, col, dir = 'RIGHT') {
|
||||
return side === 'R' ? { row: r, col: COLS - 1 - col, dir: mirrorDir(d) } : { row: r, col, dir: d };
|
||||
}
|
||||
|
||||
/** Pair alive players by seat: [[a, b], [c, d]] / [[a, b], [c]] / [[a]]. */
|
||||
export function pairPlayers(alive) {
|
||||
/** Seeded initial pairing; `previous` carries player-id pairs through prep, combat and Hidden Core. */
|
||||
export function pairPlayers(alive, rng, previous = null) {
|
||||
const sorted = alive.slice().sort((a, b) => a.seat - b.seat);
|
||||
const groups = [];
|
||||
for (let i = 0; i < sorted.length; i += 2) groups.push(sorted.slice(i, i + 2));
|
||||
let singles;
|
||||
if (Array.isArray(previous)) {
|
||||
const remaining = new Map(sorted.map(p => [p.playerId, p]));
|
||||
singles = [];
|
||||
for (const ids of previous) {
|
||||
const group = [];
|
||||
for (const id of ids) if (remaining.has(id)) { group.push(remaining.get(id)); remaining.delete(id); }
|
||||
if (group.length === 2) groups.push(group); else singles.push(...group);
|
||||
}
|
||||
singles.push(...remaining.values());
|
||||
} else singles = sorted.length > 1 ? rng.shuffle(sorted) : sorted;
|
||||
for (let i = 0; i < singles.length; i += 2) groups.push(singles.slice(i, i + 2));
|
||||
return groups;
|
||||
}
|
||||
|
||||
|
||||
@@ -100,6 +100,10 @@ export class GameData {
|
||||
const chess = this.raw.chess && typeof this.raw.chess === 'object' ? this.raw.chess : {};
|
||||
this._chess = chess;
|
||||
this._items = this.raw.items && typeof this.raw.items === 'object' ? this.raw.items : {};
|
||||
// Fixed operator gifts are unlimited outputs, but every produced normal/advanced item still occupies 1/2 copies
|
||||
// (card-pool experiment BV1eLXXBqEgF, maintainer-confirmed 2026-10-11). Random subset gifts are not exempt.
|
||||
this._fixedGiftItems = new Set(Object.values(this.raw.garrisons || {})
|
||||
.filter(g => g.effectKey === 'SERVER_GAIN_EQUIP').map(g => g.bbStr?.chess).filter(id => this._items[id]));
|
||||
this._bonds = this.raw.bonds && typeof this.raw.bonds === 'object' ? this.raw.bonds : {};
|
||||
/** visible, shop-eligible base (normal) chess ids */
|
||||
this.visibleChess = Object.keys(chess).filter((id) => {
|
||||
@@ -312,6 +316,9 @@ export class GameData {
|
||||
return Number.isInteger(count) && count >= 0 ? count : null;
|
||||
}
|
||||
|
||||
/** Whether a fixed trait gift may be produced beyond stock (including the advanced version of that item). */
|
||||
isUnlimitedGiftItem(id) { return this._fixedGiftItems.has(this.baseIdOf(id)); }
|
||||
|
||||
/** Copies needed to merge (0 = never merges: golden chess). */
|
||||
mergeCount(id) {
|
||||
const c = this.chess(id);
|
||||
|
||||
@@ -202,14 +202,15 @@ export function collectViolations(m, { limit = 25 } = {}) {
|
||||
}
|
||||
for (const [base, n] of held) if (!m.pool.has(base) && n !== 0) fail(`non-pool chess ${base} holds ${n} copies`);
|
||||
|
||||
// Shared shop equipment. The chess pool above may go negative (Mimic). Item stock may not: held ≤ cap.
|
||||
// Fixed operator gifts may overdraw equipment stock; ownership still counts and keeps shop availability at zero.
|
||||
// Other item families retain their cap. The chess pool above also permits explicit overdraw (Mimic).
|
||||
if (m.itemPool && gd.raw.items) {
|
||||
for (const [id, rec] of Object.entries(gd.raw.items)) {
|
||||
if (!rec || rec.isGolden) continue;
|
||||
const cap = m.itemPool.cap(id);
|
||||
if (!Number.isInteger(cap)) continue;
|
||||
const h = m.itemPool.held(id);
|
||||
if (h > cap) fail(`item ${id}: held ${h} > cap ${cap}`);
|
||||
if (h > cap && !gd.isUnlimitedGiftItem(id)) fail(`item ${id}: held ${h} > cap ${cap}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
// Installed on Match.prototype by server/match/Match.js (a method container: never instantiated; `this` is the match).
|
||||
|
||||
import { PHASE, GEO } from '../../../shared/constants.js';
|
||||
import { deriveSeed } from '../../sim/rng.js';
|
||||
import { createRng, deriveSeed } from '../../sim/rng.js';
|
||||
import { buildBossWave, bountySpawns } from '../waves.js';
|
||||
import { pairPlayers, bossPoolHp, SharedBossPool, CreditPool, hiddenEligible, BOSS_HIT_STEPS } from '../finalAssault.js';
|
||||
import { FieldRunner, HeadlessPacer, syntheticResult, BOSS_SILENCE_MS, CATCHUP_TICKS_PER_INTERVAL } from '../fields.js';
|
||||
@@ -66,7 +66,8 @@ export class MatchBoss {
|
||||
},
|
||||
});
|
||||
this.bossPool = pool;
|
||||
const groups = pairPlayers(alive);
|
||||
const groups = pairPlayers(alive, createRng(deriveSeed(this.seed, 'boss-pairs')), this.bossPairs);
|
||||
this.bossPairs = groups.map(g => g.map(p => p.playerId));
|
||||
const reuse = this.bossWaves && this.bossWaves.length === groups.length && this.bossWaves.every((w, i) => w.players.join() === groups[i].map((p) => p.playerId).join());
|
||||
this.fields = groups.map((g, i) => {
|
||||
const solo = this.isSolo || g.length === 1;
|
||||
|
||||
@@ -88,8 +88,11 @@ export class MatchInfra {
|
||||
}
|
||||
humans() { return this.order.filter((p) => !p.isBot && !p.left); }
|
||||
|
||||
/** [ASSUMED] Touch's initial, match-long team grant survives its holder's elimination/quit; focus alone grants none. */
|
||||
teamMedicGranted() { return this.order.some(p => p.bandId === 'band_amedic'); }
|
||||
|
||||
/**
|
||||
* The boss-round group of a player (`bossWaves`: the seat pairs of finalAssault.js pairPlayers, planned in startRound
|
||||
* The boss-round group of a player (`bossWaves`: the per-match random pairs, planned in startRound
|
||||
* before the players' round start) and its side — 'L', or 'R' for the second player of a pair (the mirrored right
|
||||
* half) — or null outside a boss round / for a player without a field.
|
||||
* @returns {{ wave: object, players: string[], side: 'L'|'R' } | null}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import { PHASE, ERR } from '../../../shared/constants.js';
|
||||
import { buildNormalWave, buildBossWave } from '../waves.js';
|
||||
import { pairPlayers } from '../finalAssault.js';
|
||||
import { createRng, deriveSeed } from '../../sim/rng.js';
|
||||
import { botPickBand } from '../bot.js';
|
||||
import { OK, fail, DELAYS, BAND_TURN_SECONDS } from './common.js';
|
||||
|
||||
@@ -288,11 +289,14 @@ export class MatchPhases {
|
||||
for (const ps of this._viewers()) this._followScout(ps);
|
||||
}
|
||||
|
||||
/** The boss round's fields (seat pairs of the alive players) and their templates, generated for the prep preview. */
|
||||
/** Stable per-match pairs and their templates, generated before the prep preview / board validation. */
|
||||
_planBossWaves() {
|
||||
const r = this.round;
|
||||
const bossId = r === this.gd.hiddenRound && r !== this.gd.bossRound ? this.hiddenBossId : this.bossId;
|
||||
this.bossWaves = pairPlayers(this.alivePlayers()).map((g) => ({
|
||||
// Pairing uses an independent seeded draw, consuming no shop, wave, draft or battle randomness.
|
||||
const groups = pairPlayers(this.alivePlayers(), createRng(deriveSeed(this.seed, 'boss-pairs')), this.bossPairs);
|
||||
this.bossPairs = groups.map(g => g.map(p => p.playerId));
|
||||
this.bossWaves = groups.map((g) => ({
|
||||
players: g.map((p) => p.playerId),
|
||||
wave: buildBossWave(this.gd, this.rngWaves, this.factions, r, { bossId, solo: this.isSolo || g.length === 1 }),
|
||||
}));
|
||||
|
||||
@@ -86,6 +86,7 @@ export class MatchViews {
|
||||
hiddenBossId: this.hiddenBossId,
|
||||
bossRound: this.gd.bossRound,
|
||||
hiddenRound: this.gd.hiddenRound,
|
||||
bossPairs: this.bossWaves ? this.bossWaves.map(w => w.players.slice()) : this.bossPairs ? this.bossPairs.map(ids => ids.slice()) : null,
|
||||
spRound: this.gd.spRounds().includes(this.round),
|
||||
// DESIGN §14: 'client' = battles are simulated by the browsers (b.start specs), 'server' = legacy streaming
|
||||
combatMode: this.clientCombat ? 'client' : 'server',
|
||||
@@ -248,7 +249,7 @@ export class MatchViews {
|
||||
* battleStart, so the prep view always uses the reserve medic, regardless of the current elite count. The stage's
|
||||
* normal-board slot is transformed to the correct half of a boss field by _onBossHalf / the client prep camera. */
|
||||
prepMapChars(ps) {
|
||||
if (!ps?.alive || this.phase !== PHASE.PREP || !this.order.some((p) => p.alive && p.bandId === 'band_amedic')) return [];
|
||||
if (!ps?.alive || this.phase !== PHASE.PREP || !this.teamMedicGranted()) return [];
|
||||
const id = 'char_605_cmedic';
|
||||
const slot = this.stage?.mapChars?.find((m) => m?.key === id && Array.isArray(m.pos) && m.pos[0] >= 9 && m.pos[0] <= 12
|
||||
&& m.pos[1] >= 0 && m.pos[1] <= 10 && !/multi_only/.test(String(m.alias)));
|
||||
|
||||
@@ -285,10 +285,13 @@ export class PlayerAcquire {
|
||||
* already equipped is taken off for the fight about to start. Hand and temp both full keeps the 「整备区已满,获得的装备已销毁」
|
||||
* outcome. [ASSUMED] every item granted at 休整期结束, not only 维多利亚's 战栗维式重锤 (owner's decision 2026-10-04).
|
||||
*/
|
||||
acquireItem(itemId, { source = 'grant', toTemp = false, silent = false, deferMerge = false } = {}) {
|
||||
acquireItem(itemId, { source = 'grant', toTemp = false, silent = false, deferMerge = false, requirePool = true } = {}) {
|
||||
const rec = this.gd.item(itemId);
|
||||
if (!rec) return null;
|
||||
if (!this.m.itemPool.canGain(itemId)) {
|
||||
// Only the fixed operator-gift families can overdraw. Several unrelated strategy grants also pass
|
||||
// requirePool:false; they must retain their stock limit (for example commercial packaging).
|
||||
const unlimitedGift = !requirePool && this.gd.isUnlimitedGiftItem(itemId);
|
||||
if (!unlimitedGift && !this.m.itemPool.canGain(itemId)) {
|
||||
this.m.toast(this, 'warn', '装备库存不足,未获得该装备');
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// Installed on PlayerState.prototype by server/match/PlayerState.js (a method container: never instantiated; `this` is
|
||||
// the player state).
|
||||
|
||||
import { ERR, layerGainRoom } from '../../../shared/constants.js';
|
||||
import { ERR, PHASE, layerGainRoom } from '../../../shared/constants.js';
|
||||
import { freeSlot } from '../board.js';
|
||||
import { OK, fail } from './common.js';
|
||||
|
||||
@@ -47,6 +47,10 @@ export class PlayerEconomy {
|
||||
if (add <= 0) return 0;
|
||||
this.layers[bondId] = before + add;
|
||||
this.recompute();
|
||||
// A live prep gain, not a view delta: reconnects and combat-result backfill must not replay the cue.
|
||||
if ([PHASE.ROUND_START, PHASE.SP_DRAFT, PHASE.PREP].includes(this.m.phase)) {
|
||||
this.m.sendTo(this.playerId, { t: 'm.layer', playerId: this.playerId, bondId, add });
|
||||
}
|
||||
this.m.dispatch(this, 'onLayers', { bondId, from: before, to: before + add, reason });
|
||||
return add;
|
||||
}
|
||||
@@ -141,10 +145,9 @@ export class PlayerEconomy {
|
||||
const rec = this.gd.chess(slot.id);
|
||||
// a DIY slot sells only as this player's own 自选 piece (a slotted slot's record carries `diyFor`)
|
||||
if (!rec || (rec.isDiy && !rec.diyFor)) return fail(ERR.BAD_TARGET);
|
||||
const base = this.gd.baseIdOf(slot.id);
|
||||
const need = rec.isGolden ? this.gd.goldenCopies : 1;
|
||||
const pool = this.poolOf(base);
|
||||
if (pool.has(base) && pool.left(base) < need) return fail(ERR.SOLD_OUT);
|
||||
// Already displayed operators stay purchasable after stock runs out (#498; 路标月报#2,
|
||||
// BV1eLXXBqEgF 2:50–2:57: nine owned + four shop offers => thirteen owned).
|
||||
// acquireChess records every copy, including the deficit; future rolls still exclude empty stock.
|
||||
if (handFull) return fail(ERR.HAND_FULL);
|
||||
this.spend(price);
|
||||
slot.sold = true;
|
||||
|
||||
@@ -33,15 +33,13 @@ export class PlayerPrep {
|
||||
if (!this.gd.item(slot.id)) return fail(ERR.BAD_TARGET);
|
||||
if (!this.m.itemPool.canGain(slot.id)) return fail(ERR.SOLD_OUT);
|
||||
if (handFull) return fail(ERR.HAND_FULL);
|
||||
} else {
|
||||
} else if (slot.kind === 'chess') {
|
||||
const rec = this.gd.chess(slot.id);
|
||||
if (!rec || (rec.isDiy && !rec.diyFor)) return fail(ERR.BAD_TARGET);
|
||||
const base = this.gd.baseIdOf(slot.id);
|
||||
const need = rec.isGolden ? this.gd.goldenCopies : 1;
|
||||
const pool = this.poolOf(base); // the shared pool, or this player's 自选 stock
|
||||
if (pool.has(base) && pool.left(base) < need) return fail(ERR.SOLD_OUT);
|
||||
// Owner decision 2026-10-11 (#498): already displayed operator rewards survive stock exhaustion.
|
||||
// acquireChess records the deficit. Item rewards above still require itemPool.canGain.
|
||||
if (handFull) return fail(ERR.HAND_FULL);
|
||||
}
|
||||
} else return fail(ERR.BAD_TARGET);
|
||||
const price = Number.isFinite(slot.price) && slot.price > 0 ? Math.trunc(slot.price) : 0;
|
||||
if (price > this.funds) return fail(ERR.NO_FUNDS);
|
||||
slot.sold = true;
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
// (config.economy.poolCopies[tier], measured identity overrides; co-op shared stock doubled). `left` is the
|
||||
// signed capacity minus occupation; the public left() and shop sampling clamp a deficit to zero.
|
||||
// * Owning a piece takes copies: a normal piece holds 1, an elite holds 3 (merge of 3 normals). Shop displays
|
||||
// do NOT reserve copies; buying fails (SOLD_OUT) when left = 0.
|
||||
// do NOT reserve copies. An item buy fails (SOLD_OUT) when left = 0; an operator card already shown in the shop or a
|
||||
// reward can still be taken and overdraws (a negative raw left, the debt — GitHub #498, the owner's decision).
|
||||
// * Pieces remember how many copies they hold (`piece.poolCopies`), so selling / elimination / temp wipes return
|
||||
// exactly what was taken — chess granted by effects while the pool is empty (or hidden/banned chess) hold 0.
|
||||
// * Invariant: raw left ≤ cap and raw left + Σ held copies == cap. Mimic can exceed stock, but its copies still occupy it.
|
||||
|
||||
@@ -139,7 +139,7 @@ export function uniteBattleOpts(m, plan, timeLimit) {
|
||||
if (!u.alive) { carry.set(u.uid, { down: true }); continue; }
|
||||
// HP ratio and SP only: a skill still running at the end is not carried (unitsEnd `skillActive` stays unused —
|
||||
// community report #34, GitHub #82)
|
||||
carry.set(u.uid, { hpPct: Number.isFinite(u.hpPct) ? Math.max(0.01, Math.min(1, u.hpPct)) : 1, sp });
|
||||
carry.set(u.uid, { hpPct: Number.isFinite(u.hpPct) ? Math.max(0, Math.min(1, u.hpPct)) : 1, sp });
|
||||
}
|
||||
// 2 helpers: the first one meets the enemies first on the right-hand field (escaped_multi enters at col 18)
|
||||
const colOffset = plan.helpers.length > 1 && i === 0 ? 8 : 0;
|
||||
|
||||
+13
-2
@@ -171,6 +171,16 @@ export function acquireTargets(b, u, prof) {
|
||||
if (u.blocking.length) for (const e of b.blockedTargets(u, prof)) if (!cands.includes(e)) cands.push(e);
|
||||
// an ally target (Battle.setAllyTarget: 白铁's 铁钳号·原型机, an enemy-camp summon with 嘲讽等级 −2) after every enemy
|
||||
const extra = b._allyTargets && b._allyTargets.size ? b.allyTargetsInKeys(u.rangeKeys, u) : null;
|
||||
// Fortress has an extra melee tile even against enemies it cannot block (PRTS branch / 号角). Its ordinary
|
||||
// ranged targets still take priority; this does not fill the other tiles in its ranged blind spot.
|
||||
if (prof.fortress && !cands.length && !extra?.length) {
|
||||
const here = [u.tileR * COLS + u.tileC];
|
||||
const near = b.enemiesInKeys(here, u, prof);
|
||||
if (!near.length && b._allyTargets?.size) near.push(...b.allyTargetsInKeys(here, u));
|
||||
prof._fortressMelee = near.length > 0;
|
||||
sortEnemyTargets(b, u, near, prof.priority);
|
||||
return near.slice(0, 1);
|
||||
}
|
||||
if (extra && extra.length) {
|
||||
if (prof.allInRange) return cands.concat(extra);
|
||||
sortEnemyTargets(b, u, cands, prof.priority);
|
||||
@@ -225,7 +235,8 @@ export function performAttack(b, u, prof, targets, opts = null) {
|
||||
const t = targets[i];
|
||||
b._ev(['atk', u.id, t.id, vis]);
|
||||
if (isHeal) { doHeal(b, u, prof, t); continue; }
|
||||
const info = { isSkill, index: i, attackId, energy: i === 0 ? energy : 0 };
|
||||
// Target acquisition mutates the fortress profile. Keep this shot's mode while its shell is in flight.
|
||||
const info = { isSkill, index: i, attackId, fortressMelee: !!prof._fortressMelee, energy: i === 0 ? energy : 0 };
|
||||
const foe = t.side === 'enemy' || b.isAllyTarget(t);
|
||||
if (ranged && foe && prof.projectile === 'boomerang') {
|
||||
throwBoomerang(b, u, prof, t, info);
|
||||
@@ -323,7 +334,7 @@ export function resolveHit(b, u, prof, target, info, x, y) {
|
||||
x = target.x; y = target.y;
|
||||
}
|
||||
// splash: a 中点判定 radius around the target's position — a huge enemy counts by its 判定中心 (Battle.enemiesInRadius)
|
||||
if (prof.splashRadius > 0) {
|
||||
if (prof.splashRadius > 0 && (!info.fortressMelee || prof.fortressMeleeSplash)) {
|
||||
const r = prof.splashRadius;
|
||||
const sc = prof.splashScale ?? 1;
|
||||
for (const e of b.foesInRadius(x, y, r, true)) {
|
||||
|
||||
@@ -46,13 +46,15 @@ export class BattleDeploy {
|
||||
u.markDirty();
|
||||
u.hp = u.s.maxHp;
|
||||
// 联防 carry (PRTS 卫戍协议/帮助 §联防阶段 "将对应单位的生命比例、技力修改至与上一阶段结束时相同(召唤物仅修改技力…)"):
|
||||
// an operator's HP ratio here, the SP in skill.reset and again after the `deploy` hook (below) — a summon's SP only
|
||||
// an operator restores HP/SP, a summon SP only; actual 联防 opening restoration is deferred until Battle.start ends
|
||||
const cs = initial || (first && this._startDeploying) ? (carry ?? u.carry) : null;
|
||||
if (cs && u.kind === 'op' && Number.isFinite(cs.hpPct)) u.hp = Math.max(1, u.s.maxHp * Math.max(0.01, Math.min(1, cs.hpPct)));
|
||||
// 联防 reconstructs deployment/battle-start effects first, then restores HP/SP once in Battle.start.
|
||||
const restoreLater = this.kind === 'unite' && this._startDeploying;
|
||||
if (!restoreLater && cs && u.kind === 'op' && Number.isFinite(cs.hpPct) && cs.hpPct > 0) u.hp = u.s.maxHp * Math.min(1, cs.hpPct);
|
||||
this._occ[k] = u;
|
||||
this._refreshRange(u); // also rebuilds baseRangeKeys (initial range incl. permanent rangeExtend)
|
||||
if (!u.skill) this._setupUnit(u);
|
||||
u.skill.reset(cs);
|
||||
u.skill.reset(restoreLater ? null : cs);
|
||||
const sk = u.skill;
|
||||
// "自动操作具有3s冷却,在完成一次操作或作战开始时部署的单位将进入冷却" (PRTS 卫戍协议/帮助; skills.js)
|
||||
if (initial) sk.opReadyAt = this.time + this.flags.startOpCooldown;
|
||||
@@ -68,9 +70,9 @@ export class BattleDeploy {
|
||||
if (first) this._ev(['spawn', unitInfo(u)]);
|
||||
this._ev(['deploy', u.id]);
|
||||
if (this._hooks.deploy) this.emit('deploy', { unit: u, initial });
|
||||
// 联防: "部署完成后,将对应单位的…技力修改至与上一阶段结束时相同" — the carried SP is set again once the deployment is
|
||||
// done, so a deploy-time SP gift (独行, 黄沙罗盘 …) does not come on top of it; later redeploys keep those gifts
|
||||
if (cs && Number.isFinite(cs.sp) && u.alive && u.skill) u.skill.setSpTotal(cs.sp);
|
||||
// Explicit carry outside the 联防 opening follows this one deployment. The opening itself restores at the end
|
||||
// of Battle.start, so deployment/battle-start gifts do not come on top of it; later redeploys keep their gifts.
|
||||
if (!restoreLater && cs && Number.isFinite(cs.sp) && u.alive && u.skill) u.skill.setSpTotal(cs.sp);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -50,6 +50,15 @@ export class BattleLifecycle {
|
||||
}
|
||||
}
|
||||
this.emit('battleStart', {});
|
||||
// 联防 restores the previous phase's state after all opening effects have established the new stats. In
|
||||
// particular, Egir rebuilds devour attributes without charging carried targets for its opening damage again.
|
||||
// Keep fractional HP: a legal survivor below 1% (or below 1 HP after max-HP changes) must not be healed by carry.
|
||||
if (this.kind === 'unite') for (const u of this.allyUnits) {
|
||||
const cs = u.carry;
|
||||
if (!cs || cs.down || !u.alive || !u.deployed || u.deploySeq <= seq0) continue;
|
||||
if (u.kind === 'op' && Number.isFinite(cs.hpPct) && cs.hpPct > 0) u.hp = u.s.maxHp * Math.min(1, cs.hpPct);
|
||||
if (Number.isFinite(cs.sp) && u.skill) u.skill.setSpTotal(cs.sp);
|
||||
}
|
||||
// redeploy-time effects that start with the battle (机变 征召 "所有干员的再部署时间-50%", added by a battleStart
|
||||
// handler) cover the operators forced out above too: their timer is re-read with them, as a later knock-out's is
|
||||
for (const u of this.allyUnits) {
|
||||
|
||||
@@ -31,8 +31,8 @@
|
||||
// elite or not) stand on the field get ATK +atk
|
||||
// act2autochess_band3_buff 伊奥莱塔 统御号令 at combat start n = elite operators on the field ⇒ every elite operator
|
||||
// ATK +atk_per_cnt × n, HP +max_hp_per_cnt × n (all 直接乘算)
|
||||
// auto_chess_change_map Touch 外勤医疗 "所有玩家场地上出现一名<预备干员-医疗>": while any alive player of the match
|
||||
// holds band_amedic, every player's field gets the map character of the
|
||||
// auto_chess_change_map Touch 外勤医疗 "所有玩家场地上出现一名<预备干员-医疗>": once selected in this match, its
|
||||
// retained team grant gives every player's field the map character of the
|
||||
// variant whose common_condition holds for that player's elites on the field
|
||||
// (condition_golden_chess_le cnt → 预备干员-医疗, _ge cnt → Touch), spawned at
|
||||
// combat start on its stage slot (tokens.spawnMapChar)
|
||||
|
||||
@@ -457,7 +457,11 @@ function devour(battle, pid, bb, members) {
|
||||
// start, resolves nothing (`knocked`: it is off the field)
|
||||
if (knocked(t)) continue;
|
||||
S.fxOn(battle, 'devour', t, 'bond:egirShip', 'devour', { from: m.id });
|
||||
if (amount > 0) battle.loseHp(t, mitigate(amount, 'phys', t.s), { source: m, tags: ['bond:egir:devour'] });
|
||||
// The maintainer's 2026-10-11 联防 carry decision: reconstruct marks/attributes, but do not apply a second
|
||||
// opening HP loss (or trigger deaths/revives) to an operator restored from the previous field. Battle.start
|
||||
// applies its exact HP ratio and SP after the opening hooks. Fresh units and all other battle kinds still pay.
|
||||
const restored = battle.kind === 'unite' && Number.isFinite(t.carry?.hpPct) && t.carry.hpPct > 0;
|
||||
if (amount > 0 && !restored) battle.loseHp(t, mitigate(amount, 'phys', t.s), { source: m, tags: ['bond:egir:devour'] });
|
||||
if (!layered.has(t)) {
|
||||
layered.add(t);
|
||||
S.gainLayers(battle, { playerId: pid, bonds: 'egirShip', n: S.tierOf(t), source: m, reason: 'bond' });
|
||||
|
||||
@@ -337,10 +337,11 @@ function rebirthCleanse(b, e) {
|
||||
* (no move / attack / skill).
|
||||
* `onKo(b, e, a)` runs at once (self-destruct, freeing prisoners …), `during(b, e, a, elapsed)` every tick of it; then the
|
||||
* enemy stands up with `hpRatio` of its max HP, `onReborn(b, e, a)` switches the form and `invincible` s of 无敌 follow.
|
||||
* `invincibleUntargetable` opts a verified enemy into hostile selection immunity for that same window (Xi, #510).
|
||||
* `a.state`: undefined → 'reborn' → 'form2'. The 'telegraph' fx of the knock-out carries `form: 'reborn'` and the 'revive'
|
||||
* fx `form: 'form2'`: models with those clip sets (render/units.js FORMS — 锏, 扎罗, “复仇者”, 杰斯顿) play them.
|
||||
*/
|
||||
function reborn({ dur = 0, hpRatio = 1, invincible = 0, onKo = null, during = null, onReborn = null, key = 'ab:reborn' } = {}) {
|
||||
function reborn({ dur = 0, hpRatio = 1, invincible = 0, invincibleUntargetable = false, onKo = null, during = null, onReborn = null, key = 'ab:reborn' } = {}) {
|
||||
const finish = (b, e, a) => {
|
||||
if (!e.alive || a.state !== 'reborn') return;
|
||||
a.state = 'form2';
|
||||
@@ -348,7 +349,8 @@ function reborn({ dur = 0, hpRatio = 1, invincible = 0, onKo = null, during = nu
|
||||
e.profile.noAttack = a.noAtk;
|
||||
if (onReborn) safe(b, e, () => onReborn(b, e, a));
|
||||
e.hp = Math.max(1, e.s.maxHp * Math.max(0, Math.min(1, hpRatio)));
|
||||
if (invincible > 0) b.addBuff(e, { key: `${key}:inv`, duration: invincible, visible: true, flags: { invulnerable: true } });
|
||||
if (invincible > 0) b.addBuff(e, { key: `${key}:inv`, duration: invincible, visible: true,
|
||||
flags: { invulnerable: true, ...(invincibleUntargetable ? { untargetable: true } : {}) } });
|
||||
if (e.route) e.route.pts = null;
|
||||
e.atkCd = 0;
|
||||
rebirthCooldowns(e);
|
||||
|
||||
@@ -473,6 +473,9 @@ function kitXi(ab) {
|
||||
};
|
||||
const re = reborn({
|
||||
dur: T(ab, 'reborn.duration') ?? 0, invincible: T(ab, 'reborn.invincible') ?? 0,
|
||||
// #510: Xi's post-rebirth 无敌 also prevents hostile selection (PRTS “自在” / 无敌).
|
||||
// Explicit opt-in: the engine's general invulnerable flag remains damage protection only.
|
||||
invincibleUntargetable: true,
|
||||
onKo(b, e) { b.removeBuff(e, 'ab:xiShield'); },
|
||||
onReborn(b, e) {
|
||||
P.form2 = true;
|
||||
|
||||
@@ -412,7 +412,9 @@ export const SPECIAL_KITS = Object.freeze({
|
||||
// report of 2026-10-06, item 24); it picks by 仇恨值, not its blocker first
|
||||
spawn(b, e) { e.profile.blockFree = true; },
|
||||
dealt(c, b, e) {
|
||||
const n = T(ab, 'Attack.attack@times') ?? 1, r = T(ab, 'Attack.attack@projectile_range') ?? 0;
|
||||
// PRTS 自制投石机: normal stones splash in radius 1.5. The talent's projectile_range (0.5) belongs to
|
||||
// the separate 入戏 random-offset attack, not its ordinary volley in this mode.
|
||||
const n = T(ab, 'Attack.attack@times') ?? 1, r = 1.5;
|
||||
const t = c.target;
|
||||
for (let i = 1; i < n; i++) hurt(b, e, t, e.s.atk, 'phys');
|
||||
// the stone's splash ("碰撞无视迷彩"): an area selection — no unblocking 隐匿 ally
|
||||
|
||||
@@ -239,7 +239,7 @@ H.SERVER_GAIN_EQUIP = {
|
||||
run(ctx) {
|
||||
const { bb, bbStr } = ctx.source;
|
||||
if (!ctx.gd.item(bbStr.chess)) return;
|
||||
for (let i = 0; i < Math.min(10, num(bb.count, 1)); i++) ctx.grantItem(bbStr.chess);
|
||||
for (let i = 0; i < Math.min(10, num(bb.count, 1)); i++) ctx.grantItem(bbStr.chess, { requirePool: false });
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
// 0.2.0 both revived in place (`fatal` savers at −101 / −110): no 被击倒时 / 部署时 effect fired (community report
|
||||
// 「像砾和瑕光这种死亡和部署的叠层效果,如果有艾芒加德的3次复活似乎是无法触发」).
|
||||
// 骑士戒律's free in-skill undying runs early (20). Flat damage reduction 'hit' −10 (after the other damage modifiers).
|
||||
// Proc damage dealt by items carries the tag 'item' and never re-triggers item procs.
|
||||
// Proc damage dealt by items carries the tag 'item'; damage-producing item riders exclude it to avoid recursion.
|
||||
//
|
||||
// 蒸汽之心 (Victoria carrier) — LIVE model: the hammer types (灼燃/坚固/加速/战栗维式重锤) carried by the owner's operators
|
||||
// that are on the field right now (cached ≤ 0.5 s, dropped whenever an operator deploys or leaves) are granted to the
|
||||
@@ -389,6 +389,15 @@ function onAttackEnemies(S, u, fn) {
|
||||
});
|
||||
}
|
||||
|
||||
/** Actual carrier damage to enemies, including DoT/skill/splash. Element gauge fill and HP loss are not damage. */
|
||||
function onDamageEnemies(S, u, fn) {
|
||||
S.on('damaged', (c) => {
|
||||
if (c.source !== u || c.type === 'element' || isHpLoss(c.dmg) || !(c.amount > 0)
|
||||
|| !c.target || c.target.side !== 'enemy' || !hasHp(c.target)) return;
|
||||
fn(c.target, c);
|
||||
});
|
||||
}
|
||||
|
||||
/** Generic buff-key behaviours (shared by several items). */
|
||||
const BY_BUFF = {
|
||||
// 源石溶剂: the text's "每秒流失 damage 点生命值" is officially 每秒受到 damage 点真实伤害 (PRTS 盟约记录 修正 "并非流失", 备注
|
||||
@@ -636,18 +645,19 @@ const BY_ITEM = {
|
||||
fxOn(battle, 'revive', u, 'item:chess_item_4_12_e', rec.id, { left: max - used });
|
||||
}, PRIO_RESPAWN); // a knock-out: after every 不死 (the hammer's lock), whatever the equip order (header)
|
||||
},
|
||||
// 催泪瓦斯: on attack prob ⇒ 1 麻痹 stack
|
||||
// PRTS 盟约记录 corrects both items' "攻击时" to "造成伤害时" (#489); no attack event is required.
|
||||
// 催泪瓦斯: on damage prob ⇒ 1 麻痹 stack
|
||||
chess_item_5_01_e(battle, u, rec, S) {
|
||||
const p = bp(rec, 'act1autochess_equip_acarm058_global_buff');
|
||||
const pr = p ? num(p.prob) : 0;
|
||||
if (pr > 0) onAttackEnemies(S, u, (t) => { if (chance(battle, pr)) battle.applyStatus(t, 'palsy', { value: 1, source: u }); });
|
||||
if (pr > 0) onDamageEnemies(S, u, (t) => { if (chance(battle, pr)) battle.applyStatus(t, 'palsy', { value: 1, source: u }); });
|
||||
},
|
||||
// 谢拉格不融冰: on attack prob ⇒ 寒冷 `cold` s
|
||||
// 谢拉格不融冰: on damage prob ⇒ 寒冷 `cold` s
|
||||
chess_item_5_02_e(battle, u, rec, S) {
|
||||
const p = bp(rec, 'act1vautochess_equip_acarm003_global_buff');
|
||||
if (!p) return;
|
||||
const pr = num(p.prob), d = num(p.cold);
|
||||
if (pr > 0 && d > 0) onAttackEnemies(S, u, (t) => { if (chance(battle, pr)) battle.applyStatus(t, 'cold', { duration: d, source: u }); });
|
||||
if (pr > 0 && d > 0) onDamageEnemies(S, u, (t) => { if (chance(battle, pr)) battle.applyStatus(t, 'cold', { duration: d, source: u }); });
|
||||
},
|
||||
// 双模机械臂: physical/arts damage becomes 弱点伤害 (whichever type the target resists less)
|
||||
chess_item_5_03_e(battle, u, rec, S) {
|
||||
|
||||
@@ -94,8 +94,9 @@ export default {
|
||||
battle.on('damaged', (ctx) => {
|
||||
const e = ctx.target;
|
||||
if (busy || ctx.source !== unit || e.side !== 'enemy' || ctx.type === 'element' || ctx.dmg?.tags?.includes('vulpisHunt')) return;
|
||||
const t = marks.get(e.id);
|
||||
if (t == null) { marks.set(e.id, battle.time); return; }
|
||||
let t = marks.get(e.id);
|
||||
// PRTS talent remarks: the first damage instance also receives the arts addition.
|
||||
if (t == null) { t = battle.time; marks.set(e.id, t); }
|
||||
if (battle.time - t <= num(t0.interval, 10) + 1e-9 && e.alive) {
|
||||
busy = true;
|
||||
// PRTS 备注 "伤害类型为法术附加伤害": tag addition (no 叙拉古 6 roll)
|
||||
|
||||
@@ -50,7 +50,7 @@ export default {
|
||||
}),
|
||||
skchr_horn_2: () => ({
|
||||
kind: 'ammo', ammo: s2Ammo,
|
||||
attack: { atkScale: num(bb['attack@s2.atk_scale'], 1) },
|
||||
attack: { atkScale: num(bb['attack@s2.atk_scale'], 1), fortressMeleeSplash: true },
|
||||
onStart({ unit }) { unit.mem.hornS2Over = false; },
|
||||
onEnd({ unit }) { unit.mem.hornS2Over = false; },
|
||||
}),
|
||||
|
||||
@@ -229,6 +229,8 @@ export default {
|
||||
},
|
||||
[S3]: {
|
||||
kind: 'ammo',
|
||||
// The skill's initial attack area includes the eight surrounding tiles as well as its 3-9 grid.
|
||||
trigger: { rule: 'ACTIVE_RANGE', grid: g39x4 },
|
||||
ammo: Math.max(1, Math.floor(num(b3['attack@trigger_time'], 1))),
|
||||
mods: { atkPct: num(b3.atk) }, flags: LIFTOFF,
|
||||
targeting: s3Targeting,
|
||||
|
||||
@@ -222,7 +222,7 @@ export default {
|
||||
}]);
|
||||
}
|
||||
battle.on('elementBurst', ({ target, element }) => {
|
||||
if (element !== 'erosion' || !up(unit) || !unit.skill?.active || unit.skill.id !== S3
|
||||
if (element !== 'erosion' || target?.side !== 'enemy' || !up(unit) || !unit.skill?.active || unit.skill.id !== S3
|
||||
|| !canTargetEnemy(unit, target, GROUND)) return;
|
||||
unit.mem.clemntBombs?.marks.set(target, battle.time + num(b3.ep_break_duration, 1));
|
||||
}, { owner: unit });
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
// 帮助 lists the 上半 row that did as removed in 下半, so in a fight the skill simply runs its `duration`.
|
||||
|
||||
import { num, talentBb, traitBb, skillRec, up, byEnemyAttack } from '../shared/tier1.js';
|
||||
import { canTargetEnemy } from '../../../targeting.js';
|
||||
import { absoluteRangeKeys, canTargetEnemy } from '../../../targeting.js';
|
||||
import { bodyInRadius } from '../../../body.js';
|
||||
import { hasHp } from '../../../damage.js';
|
||||
import { COLS } from '../../../constants.js';
|
||||
@@ -256,6 +256,11 @@ export default {
|
||||
} },
|
||||
],
|
||||
install(battle, unit) {
|
||||
// S3 attacks air as well: its expanded auto-cast area must not inherit the inactive ground-only profile.
|
||||
if (unit.skill?.id === S3) unit.skill.addTriggerRange(() => [{
|
||||
keys: absoluteRangeKeys(skillRec(chess, S3)?.rangeGrid ?? X2, unit.tileR, unit.tileC, unit.dir, unit.s.baseRangeExtend || 0),
|
||||
profile: { canHitFly: true },
|
||||
}]);
|
||||
// S1 恶业苦果: every enemy damage instance she takes (the official hsgma2_s_1 on ON_TAKE_DAMAGE, not its attacks only:
|
||||
// tier1 byEnemyAttack) ⇒ atk_scale × ATK arts back to its source
|
||||
battle.on('damaged', (c) => {
|
||||
|
||||
@@ -294,6 +294,17 @@ export default {
|
||||
],
|
||||
install(battle, unit) {
|
||||
const id = unit.skill?.id;
|
||||
if (id === S2) {
|
||||
// The owner's larger-range auto-cast rule also covers this terrain-dependent range (no static rangeId).
|
||||
// Keep SEARCH when the actual running area does not strictly contain her initial range.
|
||||
unit.skill.addTriggerRange(() => {
|
||||
const extend = unit.s.baseRangeExtend || 0;
|
||||
const keys = absoluteRangeKeys(floodGrid(battle, unit), unit.tileR, unit.tileC, unit.dir, extend);
|
||||
const initial = absoluteRangeKeys(unit.def.rangeGrid, unit.tileR, unit.tileC, unit.dir, extend);
|
||||
const set = new Set(keys);
|
||||
return set.size > initial.length && initial.every(k => set.has(k)) ? [{ keys, profile: AIR }] : [];
|
||||
});
|
||||
}
|
||||
if (id === S1) {
|
||||
// 充能耗尽前特性不重置: the trait resets the ramp on skillEnd (priority 0) — keep it while a charge remains
|
||||
battle.on('skillEnd', (c) => {
|
||||
|
||||
@@ -129,7 +129,7 @@ export function contentInfo(battle, pid) {
|
||||
const ci = player(battle, pid)?.input?.contentInfo;
|
||||
return ci && typeof ci === 'object' ? ci : {};
|
||||
}
|
||||
/** Band ids of every alive player of the match (own first); falls back to this battle's players. */
|
||||
/** Alive players' bands (own first), plus a retained Touch team grant; falls back to this battle's players. */
|
||||
export function matchBands(battle, pid) {
|
||||
const mb = contentInfo(battle, pid).matchBands;
|
||||
if (Array.isArray(mb) && mb.length) return mb;
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
// handUnits pieces in the 整备区 (hand, 10 slots; operators, items and summon stacks),
|
||||
// handChess operators in the hand,
|
||||
// roundStats { refreshes, buys, sells, spent, gainedChess, arts } (this round's prep, ps.round),
|
||||
// matchBands band ids of every alive player (this player first),
|
||||
// matchBands band ids of every alive player (this player first), plus a retained Touch team grant,
|
||||
// teammates [{ playerId, bandId }] of the other alive players }
|
||||
// Global handlers run first in every dispatch, so band / bond / garrison / item handlers of the same onBattleStart can
|
||||
// read or extend `ev.input.contentInfo`.
|
||||
@@ -26,6 +26,10 @@ export function registerSupportMeta(registry) {
|
||||
const hand = ctx.hand();
|
||||
const rs = ctx.roundStats();
|
||||
const mates = ctx.teammates();
|
||||
const matchBands = [ctx.bandId(), ...mates.map(t => t.bandId())].filter(Boolean);
|
||||
// [ASSUMED] 外勤医疗 is a team grant for this match, not an aura revoked when its holder is eliminated.
|
||||
// Other team strategies and the teammate contexts still require a living player.
|
||||
if (ctx.teamMedicGranted?.() && !matchBands.includes('band_amedic')) matchBands.push('band_amedic');
|
||||
ev.input.contentInfo = {
|
||||
round: ctx.round,
|
||||
kind: ev.kind ?? null,
|
||||
@@ -37,7 +41,7 @@ export function registerSupportMeta(registry) {
|
||||
refreshes: rs.refreshes | 0, buys: rs.buys | 0, sells: rs.sells | 0, spent: rs.spent | 0,
|
||||
gainedChess: rs.gainedChess | 0, arts: rs.arts | 0,
|
||||
},
|
||||
matchBands: [ctx.bandId(), ...mates.map((t) => t.bandId())].filter(Boolean),
|
||||
matchBands,
|
||||
teammates: mates.map((t) => ({ playerId: t.playerId, bandId: t.bandId() })),
|
||||
};
|
||||
},
|
||||
|
||||
@@ -493,6 +493,11 @@ export class SkillRuntime {
|
||||
|| (this.triggerEnemies && b.enemiesInKeys(keys, u, TRIGGER_PROFILE).length > 0);
|
||||
if (b.enemiesInKeys(keys, u, u.profile).length > 0 || this._allyTargetIn(keys)) return true;
|
||||
}
|
||||
if (!this.healSkill && u.profile?.fortress) {
|
||||
// The branch's own-tile melee range also satisfies the basic cast condition for unblockable fragments.
|
||||
const here = [u.tileR * COLS + u.tileC];
|
||||
if (b.enemiesInKeys(here, u, u.profile).length > 0 || this._allyTargetIn(here)) return true;
|
||||
}
|
||||
// the enemies a unit blocks are always its targets (Battle.blockedTargets), in range or not — PRTS 卫戍协议/帮助
|
||||
// "敌人被近战干员自身阻挡" satisfies the target condition of the basic strategy (a ranged blocker too: user playtest #6)
|
||||
if (!this.healSkill && u.blocking.length && b.blockedTargets(u, u.profile).length > 0) return true;
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ import { N_ } from './i18n.js';
|
||||
export const PROTOCOL_VERSION = 1;
|
||||
/** Release version shown to players (title screen, server banner, /healthz). Kept equal to package.json "version"
|
||||
* (test/version.test.js); PROTOCOL_VERSION above is the separate wire-format number. */
|
||||
export const APP_VERSION = '0.2.4';
|
||||
export const APP_VERSION = '0.2.5-dev';
|
||||
/** A development build (the public `dev` branch): the title screen, the boot banner and the README say so, and
|
||||
* tools/package.mjs refuses to build a release zip from it without --allow-dev. */
|
||||
export const DEV_BUILD = /-dev$/.test(APP_VERSION);
|
||||
|
||||
@@ -472,6 +472,8 @@ export const S2C = [
|
||||
'welcome', 'ok', 'error', 'pong',
|
||||
'room.state', 'room.closed',
|
||||
'm.public', 'm.private', 'm.field', 'm.toast', 'm.ticker', 'm.emote', 'm.result',
|
||||
// m.layer { playerId, bondId, add } — own live prep layer gain; ephemeral, never replayed on resync.
|
||||
'm.layer',
|
||||
// m.unitStats { seq, round, units: [unitStatsEntry] } — the answer to g.unitStats (the requester only)
|
||||
'm.unitStats',
|
||||
// client-side combat (DESIGN §14): b.start { battleId, fieldId, kind, spec, authoritative, startAt, serverNow, elapsed,
|
||||
|
||||
@@ -544,11 +544,12 @@ test('阿戈尔 5: a member devoured by several 阿戈尔 spends one slot at mos
|
||||
});
|
||||
|
||||
test('阿戈尔 5: an operator\'s own save or revive uses no slot (斯卡蒂\'s DRE-Y, M3茧甲, 埃芒加德 [ASSUMED for the band]); the slot waits for its first unanswered knock-out', () => {
|
||||
// 斯卡蒂 (elite, module DRE-Y by default) enters 联防 at half HP in front of 乌尔比安: his mark is lethal, DRE-Y saves her
|
||||
// 斯卡蒂 (elite, module DRE-Y by default) starts a normal field at half HP in front of 乌尔比安: his mark is lethal,
|
||||
// DRE-Y saves her. Half HP is a fixture here; a restored 联防 target no longer pays opening devour damage twice.
|
||||
// (PRTS: once per deployment, full HP with max HP −60 %) — no knock-out, so no slot
|
||||
const SKADI = 'chess_char_3_05_b', ULPIA = 'chess_char_5_05_a', GHOST = 'chess_char_2_07_a', HN = 'chess_char_3_09_a', DEEP = 'chess_char_1_04_a';
|
||||
const h = makeBattle({
|
||||
kind: 'unite', autoFinish: false, timeLimit: 60, hooks: ['damaged', 'death', 'deploy'], captureNoisy: true,
|
||||
kind: 'normal', autoFinish: false, timeLimit: 60, hooks: ['damaged', 'death', 'deploy'], captureNoisy: true,
|
||||
units: [
|
||||
{ chessId: ULPIA, row: 10, col: 3 }, { chessId: SKADI, row: 10, col: 4, dir: 'UP', carryState: { hpPct: 0.5 } },
|
||||
{ chessId: GHOST, row: 12, col: 3 }, { chessId: HN, row: 12, col: 5 }, { chessId: DEEP, row: 12, col: 7 },
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { makeBattle, chessRec, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
|
||||
|
||||
test('Clementia S3 ignores allied erosion bursts but still marks enemy bursts', () => {
|
||||
const h = makeBattle({ autoFinish: false, hooks: ['elementBurst'],
|
||||
units: [
|
||||
{ uid: 1, diy: { slot: 6, charId: 'char_4231_clemnt', skillIndex: 2 }, elite: true, row: 10, col: 3 },
|
||||
{ uid: 2, chessId: 'chess_char_1_02_a', row: 10, col: 4 },
|
||||
], defs: { enemies: { target: enemyRec({ key: 'target', hp: 1e7, atk: 0, speed: 0 }) } },
|
||||
});
|
||||
h.step();
|
||||
const u = h.unit(1), ally = h.unit(2);
|
||||
u.skill.gainSp(999, 'test'); assert.ok(u.skill.activate('test'));
|
||||
h.b.dealDamage(null, ally, { type: 'element', element: 'erosion', amount: 2000 });
|
||||
assert.equal(h.hooksOf('elementBurst').filter(c => c.target === ally).length, 1, 'a real allied burst occurred');
|
||||
assert.equal(u.mem.clemntBombs.marks.size, 0, 'friendly injury must not schedule a shot');
|
||||
const enemy = h.spawn('target', { pos: [10, 6] });
|
||||
h.b.dealDamage(u, enemy, { type: 'element', element: 'erosion', amount: 2000 });
|
||||
assert.ok(u.mem.clemntBombs.marks.has(enemy), 'enemy erosion still schedules the bombardment');
|
||||
checkInvariants(h.b); assert.deepEqual(h.b.errors, []);
|
||||
});
|
||||
|
||||
for (const skillIndex of [0, 1, 2]) test(`Horn S${skillIndex + 1} can attack an unblockable fragment on her own tile`, () => {
|
||||
const h = makeBattle({ autoFinish: false, hooks: ['damaged'], captureNoisy: true,
|
||||
units: [{ uid: 1, chessId: 'chess_char_5_08_a', skillIndex, row: 10, col: 3 }],
|
||||
defs: { enemies: { fragment: enemyRec({ key: 'fragment', hp: 1e7, atk: 0, speed: 0 }) } },
|
||||
});
|
||||
h.step();
|
||||
const u = h.unit(1), e = h.spawn('fragment', { pos: [10, 3] });
|
||||
h.b.addBuff(e, { key: 'test:fragment', persist: true, flags: { unblockable: true } });
|
||||
u.skill.gainSp(999, 'test'); h.run(4);
|
||||
assert.equal(u.blocking.length, 0, 'the target cannot be blocked');
|
||||
assert.ok(e.hp < e.s.maxHp, 'her extra melee range includes her own tile');
|
||||
assert.ok(u.skill.activations > 0, 'a target on that tile also satisfies skill automation');
|
||||
checkInvariants(h.b); assert.deepEqual(h.b.errors, []);
|
||||
});
|
||||
|
||||
test('bounty catapult normal triple attack splashes adjacent and diagonal allies within 1.5 tiles', () => {
|
||||
const rec = (id, tauntLevel = 0) => chessRec({ id, stats: { maxHp: 1e6, atk: 0, def: 0, blockCnt: 0, tauntLevel }, skill: null });
|
||||
const h = makeBattle({ autoFinish: false, flags: { dpInit: 999 },
|
||||
defs: { chess: { target: rec('target', 5), bystander: rec('bystander') } },
|
||||
units: [
|
||||
{ uid: 1, chessId: 'target', row: 10, col: 5 },
|
||||
{ uid: 2, chessId: 'bystander', row: 10, col: 6 },
|
||||
{ uid: 3, chessId: 'bystander', row: 11, col: 6 },
|
||||
{ uid: 4, chessId: 'bystander', row: 12, col: 7 },
|
||||
],
|
||||
});
|
||||
h.step();
|
||||
const e = h.spawn('enemy_10162_mnctpt', { pos: [10, 8], mods: { speedMul: 0 } });
|
||||
assert.ok(h.runUntil(() => h.unit(1).hp < h.unit(1).s.maxHp, 10), 'the primary attack reaches its target');
|
||||
for (const uid of [1, 2, 3]) assert.equal(h.unit(uid).s.maxHp - h.unit(uid).hp, e.s.atk * 3, `unit ${uid}: all three stones`);
|
||||
assert.equal(h.unit(4).hp, h.unit(4).s.maxHp, 'beyond the splash');
|
||||
checkInvariants(h.b); assert.deepEqual(h.b.errors, []);
|
||||
});
|
||||
|
||||
for (const skillIndex of [0, 1, 2]) test(`Horn S${skillIndex + 1} retains melee splash only for S2 and leaves adjacent blind tiles untargeted`, () => {
|
||||
const h = makeBattle({ autoFinish: false, units: [{ uid: 1, chessId: 'chess_char_5_08_a', skillIndex, row: 10, col: 3 }],
|
||||
defs: { enemies: { fragment: enemyRec({ key: 'fragment', hp: 1e7, atk: 0, speed: 0 }) } },
|
||||
});
|
||||
h.step(); const u = h.unit(1);
|
||||
const own = h.spawn('fragment', { pos: [10, 3] }), near = h.spawn('fragment', { pos: [10, 4] });
|
||||
for (const e of [own, near]) h.b.addBuff(e, { key: 'test:fragment', persist: true, flags: { unblockable: true } });
|
||||
u.skill.gainSp(999, 'test'); h.run(4);
|
||||
assert.ok(own.hp < own.s.maxHp);
|
||||
assert.equal(near.hp < near.s.maxHp, skillIndex === 1, 'only S2 grants splash to melee attacks');
|
||||
h.b.kill(own, null); const hp = near.hp; h.run(4);
|
||||
assert.equal(near.hp, hp, 'the adjacent blind tile does not become an attack target');
|
||||
checkInvariants(h.b); assert.deepEqual(h.b.errors, []);
|
||||
});
|
||||
|
||||
test('Horn prioritizes her ordinary ranged targets over an unblockable enemy on her own tile', () => {
|
||||
const h = makeBattle({ autoFinish: false, units: [{ uid: 1, chessId: 'chess_char_5_08_a', row: 10, col: 3 }],
|
||||
defs: { enemies: { fragment: enemyRec({ key: 'fragment', hp: 1e7, atk: 0, speed: 0 }) } },
|
||||
});
|
||||
h.step();
|
||||
const own = h.spawn('fragment', { pos: [10, 3] }), far = h.spawn('fragment', { pos: [10, 6] });
|
||||
h.b.addBuff(own, { key: 'test:fragment', persist: true, flags: { unblockable: true } });
|
||||
h.run(4);
|
||||
assert.ok(far.hp < far.s.maxHp); assert.equal(own.hp, own.s.maxHp);
|
||||
checkInvariants(h.b); assert.deepEqual(h.b.errors, []);
|
||||
});
|
||||
|
||||
test('Horn changing to melee does not remove splash from an already flying shell', () => {
|
||||
const h = makeBattle({ autoFinish: false, hooks: ['attack'], captureNoisy: true, units: [{ uid: 1, chessId: 'chess_char_5_08_a', skillIndex: 2, row: 10, col: 3 }],
|
||||
defs: { enemies: { dummy: enemyRec({ key: 'dummy', hp: 1e7, atk: 0, speed: 0 }) } },
|
||||
});
|
||||
h.step(); const u = h.unit(1);
|
||||
h.b.addBuff(u, { key: 'test:fast', persist: true, mods: { aspd: 500 } });
|
||||
u.skill.gainSp(999, 'test'); assert.ok(u.skill.activate('test'));
|
||||
const far = h.spawn('dummy', { pos: [10, 8] }), side = h.spawn('dummy', { pos: [11, 8] });
|
||||
assert.ok(h.runUntil(() => h.hooksOf('attack').some(c => c.attacker === u), 1));
|
||||
h.spawn('dummy', { pos: [10, 3] }); h.run(0.4);
|
||||
assert.ok(h.hooksOf('attack').filter(c => c.attacker === u).length >= 2, 'a melee attack follows before the shell lands');
|
||||
assert.ok(far.hp < far.s.maxHp); assert.equal(side.s.maxHp - side.hp, far.s.maxHp - far.hp);
|
||||
checkInvariants(h.b); assert.deepEqual(h.b.errors, []);
|
||||
});
|
||||
|
||||
for (const hidden of [false, true]) test(`${hidden ? 'hidden' : 'final'} Helm actually fires a shell which stuns the target's 3×3 area`, () => {
|
||||
const rec = (id, atk) => chessRec({ id, stats: { atk, maxHp: 1e7, blockCnt: 0 }, skill: null });
|
||||
const h = makeBattle({ kind: hidden ? 'hidden' : 'boss', autoFinish: false, timeLimit: 90, flags: { dpInit: 999 },
|
||||
hooks: ['statusApplied'], defs: { chess: { main: rec('main', 900), bystander: rec('bystander', 0) } },
|
||||
kits: { main: () => ({ trait: { noAttack: true } }), bystander: () => ({ trait: { noAttack: true } }) },
|
||||
units: [{ uid: 1, chessId: 'main', row: 10, col: 6 }, { uid: 2, chessId: 'bystander', row: 9, col: 5 },
|
||||
{ uid: 3, chessId: 'bystander', row: 11, col: 7 }, { uid: 4, chessId: 'bystander', row: 12, col: 3 }],
|
||||
});
|
||||
h.step();
|
||||
const e = h.spawn(hidden ? 'enemy_9013_acstmk_2' : 'enemy_9013_acstmk', { pos: [3, 10], tag: 'boss', mods: { speedMul: 0 } });
|
||||
e.profile.noAttack = true;
|
||||
assert.ok(h.runUntil(() => h.b.enemies.some(e => e.defId === 'enemy_9016_acstmr'), 60), 'shell was fired');
|
||||
assert.ok(h.runUntil(() => h.hooksOf('statusApplied').some(c => c.target === h.unit(1) && c.status === 'stun'), 25), 'shell arrived');
|
||||
const stunned = new Set(h.hooksOf('statusApplied').filter(c => c.status === 'stun').map(c => c.target.uid));
|
||||
for (const uid of [1, 2, 3]) assert.ok(stunned.has(uid), `3×3 includes ${uid}`);
|
||||
assert.ok(!stunned.has(4), 'outside the 3×3'); checkInvariants(h.b); assert.deepEqual(h.b.errors, []);
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { makeBattle, chessRec, checkInvariants } from '../helpers/battleHarness.js';
|
||||
import { makeMatch } from '../match/harness.js';
|
||||
import { uniteBattleOpts } from '../../server/match/unite.js';
|
||||
import { FORCED_EXIT } from '../../server/sim/constants.js';
|
||||
|
||||
const egir = (layers = 0, count = 5) => ({ count, active: true, tier: count >= 5 ? 2 : 1, layers });
|
||||
const close = (a, b) => assert.ok(Math.abs(a - b) < 1e-8, `${a} != ${b}`);
|
||||
const defs = { chess: Object.fromEntries([['A', 10000], ['B', 7000], ['C', 20000], ['D', 10000], ['E', 10000]]
|
||||
.map(([id, maxHp]) => [id, chessRec({ id, bonds: ['egirShip'], stats: { atk: 100, def: 0, maxHp, blockCnt: 1 },
|
||||
skill: { spCost: 30, initSp: 5, duration: 10 } })])) };
|
||||
const units = [['A', 10, 3], ['B', 10, 4], ['C', 10, 5], ['D', 12, 3], ['E', 12, 5]]
|
||||
.map(([chessId, row, col], i) => ({ uid: i + 1, kind: 'chess', chessId, row, col, dir: 'RIGHT' }));
|
||||
|
||||
function carryInput(t, own, lineup, bonds) {
|
||||
const m = makeMatch({ mode: 'coop', humans: 3, fake: true }).m;
|
||||
t.after(() => m.dispose());
|
||||
const unitsEnd = own.result().perPlayer.p1.unitsEnd;
|
||||
const ps = { playerId: 'p1', board: new Map(lineup.map(u => [u.uid, { uid: u.uid, kind: 'chess' }])),
|
||||
battleInput: ({ side, colOffset, carry }) => ({ playerId: 'p1', seat: 0, side, colOffset, bonds,
|
||||
units: lineup.map(u => ({ ...u, carryState: carry.get(u.uid) })) }) };
|
||||
const { players } = uniteBattleOpts({ gd: m.gd, round: 3, lastResults: new Map([['p1', { unitsEnd }]]), dispatch() {} },
|
||||
{ helpers: [ps], leaked: [] }, 60);
|
||||
return { players, unitsEnd };
|
||||
}
|
||||
|
||||
test('normal result → unite rebuilds Egir attributes but preserves every living HP ratio and SP', t => {
|
||||
const own = makeBattle({ defs, units, bonds: { egirShip: egir() }, autoFinish: false });
|
||||
own.step(2); own.b.forceEnd();
|
||||
const { players, unitsEnd } = carryInput(t, own, units, { egirShip: egir(own.b.getPlayer('p1').bonds.egirShip.layers) });
|
||||
const next = makeBattle({ kind: 'unite', defs, players, autoFinish: false }); next.b.start();
|
||||
for (const old of unitsEnd) {
|
||||
const u = next.unit(old.uid);
|
||||
assert.equal(u.alive, old.alive);
|
||||
close(u.hpRatio, old.hpPct); close(u.skill.spTotal, old.sp);
|
||||
assert.deepEqual(u.findBuff('bond:egir:devour')?.mods, own.unit(old.uid).findBuff('bond:egir:devour')?.mods);
|
||||
}
|
||||
assert.equal(next.b.result().perPlayer.p1.deaths, 0);
|
||||
assert.deepEqual(next.b.result().perPlayer.p1.layerGains, {});
|
||||
checkInvariants(next.b);
|
||||
});
|
||||
|
||||
test('a previous full-health survivor cannot be newly killed by opening devour during carry restoration', t => {
|
||||
const weak = { chess: { ...defs.chess, B: { ...defs.chess.B, stats: { ...defs.chess.B.stats, maxHp: 2000 } } } };
|
||||
const three = units.slice(0, 3).map(u => u.uid === 3 ? { ...u, row: 12, col: 7 } : u);
|
||||
const own = makeBattle({ defs: weak, units: three, bonds: { egirShip: egir(0, 3) }, autoFinish: false, timeLimit: 120 });
|
||||
own.run(80); own.b.forceEnd();
|
||||
assert.equal(own.unit(2).hpRatio, 1, 'food redeployed before its old battle ended');
|
||||
const { players } = carryInput(t, own, three, { egirShip: egir(own.b.getPlayer('p1').bonds.egirShip.layers, 3) });
|
||||
const next = makeBattle({ kind: 'unite', defs: weak, players, autoFinish: false }); next.b.start();
|
||||
assert.equal(next.unit(2).alive, true); assert.equal(next.unit(2).hpRatio, 1);
|
||||
assert.equal(next.b.result().perPlayer.p1.deaths, 0);
|
||||
close(next.unit(1).findBuff('bond:egir:devour').mods.atkFinal, 100);
|
||||
});
|
||||
|
||||
test('forced-down carry still contributes devour attributes without consuming any of the three later revives', () => {
|
||||
const lineup = units.map(u => ({ ...u, carryState: u.uid === 2 ? { down: true } : { hpPct: 0.4, sp: 17 } }));
|
||||
const next = makeBattle({ kind: 'unite', defs, players: [{ playerId: 'p1', units: lineup, bonds: { egirShip: egir() } }], autoFinish: false });
|
||||
next.b.start();
|
||||
assert.equal(next.unit(2).alive, false); assert.equal(next.unit(2).removeReason, FORCED_EXIT);
|
||||
close(next.unit(1).findBuff('bond:egir:devour').mods.atkFinal, 200);
|
||||
const survived = [];
|
||||
for (const uid of [4, 5, 1, 3]) { next.b.loseHp(next.unit(uid), 1e9); survived.push(next.unit(uid).alive); }
|
||||
assert.deepEqual(survived, [true, true, true, false]);
|
||||
checkInvariants(next.b);
|
||||
});
|
||||
|
||||
test('carry preserves positive ratios below 1% and fractional HP when the new max HP is smaller', t => {
|
||||
const lineup = [{ ...units[0], dir: 'UP' }];
|
||||
const own = makeBattle({ defs, units: lineup, bonds: { egirShip: egir() }, autoFinish: false });
|
||||
own.b.start(); own.unit(1).hp = 1; own.b.forceEnd();
|
||||
const { players, unitsEnd } = carryInput(t, own, lineup, {});
|
||||
close(players[0].units[0].carryState.hpPct, unitsEnd[0].hpPct);
|
||||
const small = { chess: { A: { ...defs.chess.A, stats: { ...defs.chess.A.stats, maxHp: 1000 } } } };
|
||||
const next = makeBattle({ kind: 'unite', defs: small, players, autoFinish: false }); next.b.start();
|
||||
assert.ok(next.unit(1).alive && next.unit(1).hp > 0 && next.unit(1).hp < 1);
|
||||
close(next.unit(1).hpRatio, unitsEnd[0].hpPct);
|
||||
});
|
||||
|
||||
test('ordinary/boss devour and fresh unite units still receive opening devour damage', () => {
|
||||
for (const kind of ['normal', 'unite', 'boss', 'hidden']) {
|
||||
const h = makeBattle({ kind, defs, units, bonds: { egirShip: egir() }, autoFinish: false }); h.b.start();
|
||||
assert.ok(h.unit(2).hpRatio < 0.5, kind);
|
||||
assert.ok(h.unit(3).hpRatio < 0.7, kind);
|
||||
checkInvariants(h.b);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { makeBattle, enemyRec, chessRec, checkInvariants } from '../helpers/battleHarness.js';
|
||||
import { DREAD_TAG } from '../../server/sim/content/kits/ops/op-ascln.js';
|
||||
|
||||
for (const [item, status] of [['5_01', 'palsy'], ['5_02', 'cold']]) for (const quality of ['a', 'b']) {
|
||||
const id = `chess_item_${item}_e_${quality}`;
|
||||
test(`${id}: Ascalon poison still procs ${status} while she cannot attack`, () => {
|
||||
const h = makeBattle({ autoFinish: false, hooks: ['damaged', 'attack', 'statusApplied'], captureNoisy: true,
|
||||
units: [{ uid: 1, diy: { slot: 6, charId: 'char_4132_ascln', skillIndex: 0 }, row: 10, col: 5, items: [id] }],
|
||||
defs: { enemies: { dummy: enemyRec({ key: 'dummy', hp: 1e7, atk: 0, speed: 0 }) } },
|
||||
});
|
||||
h.step(); const u = h.unit(1), e = h.spawn('dummy', { pos: [10, 6] });
|
||||
const rng = h.b.rng; h.b.rng = Object.assign(() => 0.999, rng);
|
||||
assert.ok(h.runUntil(() => h.hooksOf('damaged').some(c => c.source === u && c.dmg.isAttack), 5));
|
||||
assert.equal(e.findBuff(status), null, 'the attack did not roll the item');
|
||||
const attacks = h.hooksOf('attack').length;
|
||||
h.b.addBuff(u, { key: 'test:noAttack', flags: { disarm: true }, duration: 5 });
|
||||
h.b.rng = Object.assign(() => 0, rng);
|
||||
assert.ok(h.runUntil(() => h.hooksOf('damaged').some(c => c.dmg.tags?.includes(DREAD_TAG)), 2));
|
||||
assert.equal(h.hooksOf('attack').length, attacks, 'only the already-applied poison deals damage');
|
||||
assert.ok(e.findBuff(status), 'a real poison damage tick triggers the item');
|
||||
checkInvariants(h.b); assert.deepEqual(h.b.errors, []);
|
||||
});
|
||||
|
||||
test(`${id}: only carrier damage to enemies rolls ${status}; gauge, HP loss and bare attacks do not`, () => {
|
||||
const h = makeBattle({ autoFinish: false, defs: {
|
||||
chess: { quiet: chessRec({ id: 'quiet', stats: { atk: 0 }, skill: null }) },
|
||||
enemies: { dummy: enemyRec({ key: 'dummy', hp: 1e7, atk: 0, speed: 0 }) },
|
||||
}, units: [{ uid: 1, chessId: 'quiet', row: 10, col: 3, items: [id] }, { uid: 2, chessId: 'quiet', row: 10, col: 4 }] });
|
||||
h.step(); const u = h.unit(1), ally = h.unit(2), e = h.spawn('dummy', { pos: [10, 8] });
|
||||
h.b.rng = Object.assign(() => 0, h.b.rng);
|
||||
h.b.emit('attack', { attacker: u, targets: [e] });
|
||||
h.b.dealDamage(u, e, { amount: 1, type: 'element', element: 'erosion' });
|
||||
h.b.loseHp(e, 1, { source: u });
|
||||
h.b.dealDamage(null, e, { amount: 1, type: 'true' });
|
||||
h.b.dealDamage(ally, e, { amount: 1, type: 'true' });
|
||||
h.b.dealDamage(u, ally, { amount: 1, type: 'true' });
|
||||
h.b.dealDamage(u, e, { amount: 0, type: 'true' });
|
||||
assert.equal(e.findBuff(status), null); assert.equal(ally.findBuff(status), null);
|
||||
h.b.dealDamage(u, e, { amount: 1, type: 'arts', canDodge: false, tags: ['skill', 'dot'] });
|
||||
assert.ok(e.findBuff(status));
|
||||
checkInvariants(h.b); assert.deepEqual(h.b.errors, []);
|
||||
});
|
||||
}
|
||||
@@ -1249,7 +1249,7 @@ test('忍冬 追凶 never feeds on itself: a hit turned into HP loss credited to
|
||||
const e = h.enemy('enemy_times');
|
||||
const atks = u.stats.attacks;
|
||||
assert.ok(atks >= 3);
|
||||
// first attack marks the enemy (1 HP), every later attack: 1 HP + one 追凶 bonus instance (1 HP)
|
||||
assert.equal(e.s.maxHp - e.hp, 1 + 2 * (atks - 1));
|
||||
// Every attack, the first included: 1 HP + one 追凶 bonus instance (1 HP), never recursive bonuses.
|
||||
assert.equal(e.s.maxHp - e.hp, 2 * atks);
|
||||
done(h);
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
|
||||
import { rotateOffset } from '../../server/sim/dir.js';
|
||||
|
||||
const CASES = [
|
||||
['char_1043_leizi2', 1, [0, 3]],
|
||||
['char_1044_hsgma2', 2, [0, 2]],
|
||||
['char_1015_aglna2', 2, [1, -1]],
|
||||
];
|
||||
function field(charId, skillIndex, { tier = 6, elite = true, dir = 'RIGHT', cooldown = 0 } = {}) {
|
||||
const h = makeBattle({ units: [{ uid: 1, diy: { slot: tier, charId, skillIndex }, elite, row: 10, col: 5, dir }],
|
||||
defs: { enemies: { dummy: enemyRec({ key: 'dummy', hp: 1e9, atk: 0, speed: 0 }),
|
||||
flyer: enemyRec({ key: 'flyer', hp: 1e9, atk: 0, speed: 0, motion: 'FLY' }) } },
|
||||
flags: { startOpCooldown: cooldown }, autoFinish: false, timeLimit: 90 });
|
||||
h.step();
|
||||
return { h, u: h.unit(1) };
|
||||
}
|
||||
|
||||
for (const [charId, skillIndex, delta] of CASES) {
|
||||
test(`${charId}: all DIY forms cast for a target in the actual skill area outside normal targeting`, () => {
|
||||
for (const tier of [5, 6]) for (const elite of [false, true]) for (const dir of ['UP', 'RIGHT', 'DOWN', 'LEFT']) {
|
||||
for (const key of ['dummy', 'flyer']) {
|
||||
const { h, u } = field(charId, skillIndex, { tier, elite, dir });
|
||||
// Leizi's terrain flood and Hoshiguma's x-2 are symmetric; Angelina's rear diagonal follows her facing.
|
||||
const [dr, dc] = charId === 'char_1015_aglna2' ? rotateOffset(...delta, dir) : delta;
|
||||
h.spawn(key, { pos: [10 + dr, 5 + dc] });
|
||||
u.skill.addCharge(1); h.step();
|
||||
assert.equal(u.skill.activations, 1, `${charId}/${tier}/${elite}/${dir}/${key}`);
|
||||
assert.equal(h.b.errors.length, 0); checkInvariants(h.b);
|
||||
}
|
||||
}
|
||||
});
|
||||
test(`${charId}: expanded auto-cast keeps targetability and operation cooldown`, () => {
|
||||
for (const flag of ['stealth', 'untargetable', 'sleep']) {
|
||||
const { h, u } = field(charId, skillIndex);
|
||||
const e = h.spawn('flyer', { pos: [10 + delta[0], 5 + delta[1]] });
|
||||
h.b.addBuff(e, { key: 'test:hidden', flags: { [flag]: true } });
|
||||
u.skill.addCharge(1); h.run(0.5);
|
||||
assert.equal(u.skill.activations, 0, flag);
|
||||
h.b.removeBuff(e, 'test:hidden'); h.step();
|
||||
assert.equal(u.skill.activations, 1, `${flag} removed`);
|
||||
}
|
||||
const { h, u } = field(charId, skillIndex, { cooldown: 3 });
|
||||
h.spawn('flyer', { pos: [10 + delta[0], 5 + delta[1]] });
|
||||
u.skill.addCharge(1); h.run(1);
|
||||
assert.equal(u.skill.activations, 0);
|
||||
h.runUntil(() => u.skill.activations > 0, 3);
|
||||
assert.equal(u.skill.activations, 1);
|
||||
assert.ok(u.skill.lastStart >= 3);
|
||||
});
|
||||
}
|
||||
|
||||
test('Leizi S2 uses low-terrain reachability and permanent range extension, without changing ordinary range', () => {
|
||||
for (const extended of [false, true]) {
|
||||
const { h, u } = field('char_1043_leizi2', 1);
|
||||
if (extended) h.b.addBuff(u, { key: 'test:range', mods: { rangeExtend: 1 }, persist: true });
|
||||
h.spawn('flyer', { pos: [10, 9] }); u.skill.addCharge(1); h.step();
|
||||
assert.equal(u.skill.activations, extended ? 1 : 0);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
// 追凶's first damage instance also carries its arts addition (PRTS 忍冬, talent remarks).
|
||||
// Real full-content battles with controlled durable enemies; no HP/SP/ATK writes or forced casts.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
|
||||
|
||||
const NORMAL = 'chess_char_3_18_a';
|
||||
const ELITE = 'chess_char_3_18_b';
|
||||
const ENEMY = 'enemy_vulpis_probe';
|
||||
const approx = (a, b) => assert.ok(Math.abs(a - b) < 1e-6, `${a} vs ${b}`);
|
||||
const hunt = (c) => c.dmg?.tags?.includes('vulpisHunt');
|
||||
const finished = (h) => { assert.deepEqual(h.b.errors, []); checkInvariants(h.b); };
|
||||
function scenario(entry = {}, enemy = {}) {
|
||||
return makeBattle({
|
||||
units: [{ chessId: NORMAL, row: 10, col: 4, ...entry }],
|
||||
defs: { enemies: { [ENEMY]: enemyRec({ key: ENEMY, hp: 1e7, speed: 0, ...enemy }) } },
|
||||
autoFinish: false, timeLimit: 40, flags: { startOpCooldown: 3 }, hooks: ['damaged'], captureNoisy: true,
|
||||
});
|
||||
}
|
||||
const variants = [
|
||||
{ label: 'normal', chessId: NORMAL },
|
||||
{ label: 'elite without module', chessId: ELITE, moduleId: 'none' },
|
||||
{ label: 'elite SOL-X', chessId: ELITE, moduleId: 'uniequip_002_vulpis' },
|
||||
{ label: 'elite SOL-Y', chessId: ELITE, moduleId: 'uniequip_003_vulpis' },
|
||||
];
|
||||
|
||||
for (const { label, ...entry } of variants) for (const skillIndex of [0, 1, 2]) {
|
||||
test(`追凶: first ordinary hit includes one arts addition (${label}, S${skillIndex + 1})`, () => {
|
||||
const h = scenario({ ...entry, skillIndex });
|
||||
h.step();
|
||||
const u = h.unit(entry.chessId);
|
||||
if (entry.moduleId) assert.equal(u.def.loadout.moduleId, entry.moduleId, 'the requested module is equipped');
|
||||
const e = h.spawn(ENEMY, { pos: [10, 5] });
|
||||
const hp = e.hp;
|
||||
assert.ok(h.runUntil(() => h.hooksOf('damaged').some((c) => c.source === u), 1));
|
||||
const hits = h.hooksOf('damaged').filter((c) => c.source === u);
|
||||
const main = hits.filter((c) => !hunt(c));
|
||||
assert.equal(main.length, 1, 'one ordinary attack');
|
||||
assert.equal(main[0].dmg.isSkill, false, 'no skill at the first hit');
|
||||
assert.equal(main[0].type, 'phys');
|
||||
assert.ok(e.alive && e.hp > 0, 'the first attack did not kill the target');
|
||||
const riders = hits.filter(hunt);
|
||||
assert.equal(riders.length, 1, 'the very first hit carries 追凶');
|
||||
assert.equal(riders[0].t, main[0].t);
|
||||
assert.equal(riders[0].type, 'arts');
|
||||
assert.ok(riders[0].dmg.tags.includes('addition'));
|
||||
approx(riders[0].amount, u.s.atk * u.def.talents[0].bb.atk_scale);
|
||||
approx(hp - e.hp, main[0].amount + riders[0].amount);
|
||||
assert.equal(h.eventsOf('dmg').filter((c) => c[1] === e.id).length, 2, 'both actual damage events exist');
|
||||
finished(h);
|
||||
});
|
||||
}
|
||||
|
||||
for (const chessId of [NORMAL, ELITE]) for (const skillIndex of [0, 1, 2]) {
|
||||
test(`追凶: first skill damage carries its addition (${chessId}, S${skillIndex + 1})`, () => {
|
||||
const h = scenario({ chessId, skillIndex });
|
||||
h.run(16); // every selected skill is naturally ready, with no enemies yet
|
||||
const u = h.unit(chessId);
|
||||
const e = h.spawn(ENEMY, { pos: [10, skillIndex === 1 ? 7 : skillIndex === 2 ? 6 : 5] });
|
||||
const hp = e.hp;
|
||||
assert.ok(h.runUntil(() => h.hooksOf('damaged').some((c) => c.source === u && c.dmg.isSkill), 2));
|
||||
const hits = h.hooksOf('damaged').filter((c) => c.source === u && c.target === e);
|
||||
const main = hits.filter((c) => !hunt(c));
|
||||
assert.ok(main.some((c) => c.dmg.isSkill));
|
||||
// S1 has a physical attack and a separate arts skill instance; S2/S3 have one main instance each.
|
||||
assert.equal(main.length, skillIndex === 0 ? 2 : 1);
|
||||
const riders = hits.filter(hunt);
|
||||
assert.equal(riders.length, main.length, 'one addition per qualifying damage instance, including the first');
|
||||
for (const c of riders) approx(c.amount, u.s.atk * u.def.talents[0].bb.atk_scale);
|
||||
approx(hp - e.hp, hits.reduce((s, c) => s + c.amount, 0));
|
||||
assert.ok(e.alive && e.hp > 0);
|
||||
finished(h);
|
||||
});
|
||||
}
|
||||
|
||||
test('追凶: each enemy starts its own fixed window; repeated damage does not extend it', () => {
|
||||
const h = scenario();
|
||||
h.step();
|
||||
const u = h.unit(NORMAL);
|
||||
const e = h.spawn(ENEMY, { pos: [10, 5] });
|
||||
h.run(12);
|
||||
const main = h.hooksOf('damaged').filter((c) => c.source === u && c.target === e && !hunt(c));
|
||||
const riders = h.hooksOf('damaged').filter((c) => c.source === u && c.target === e && hunt(c));
|
||||
assert.ok(main.some((c) => c.t > main[0].t + 10));
|
||||
assert.equal(riders.length, main.filter((c) => c.t - main[0].t <= 10 + 1e-9).length);
|
||||
assert.ok(riders.every((c) => c.t - main[0].t <= 10 + 1e-9));
|
||||
const another = h.spawn(ENEMY, { pos: [10, 6] });
|
||||
// Naturally ready S3 can reach both enemies; the new target gets its own first-hit addition.
|
||||
assert.ok(h.runUntil(() => h.hooksOf('damaged').some((c) => c.target === another), 10));
|
||||
const first = h.hooksOf('damaged').filter((c) => c.target === another);
|
||||
assert.equal(first.filter(hunt).length, 1);
|
||||
assert.equal(first.filter(hunt)[0].t, first.find((c) => !hunt(c)).t);
|
||||
finished(h);
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { makeBattle, chessRec, checkInvariants } from '../helpers/battleHarness.js';
|
||||
import * as enemiesMod from '../../server/sim/content/enemies.js';
|
||||
import { canTargetEnemy } from '../../server/sim/targeting.js';
|
||||
|
||||
const grid = [[0, 0], [0, 1], [0, 2], [0, 3], [0, 4]];
|
||||
for (const rule of ['DEFAULT', 'ACTIVE_RANGE', 'SKILL_RANGE', 'SP_FULL']) test(`Xi post-rebirth invincibility prevents attacks; ${rule} retains its own trigger semantics`, () => {
|
||||
const h = makeBattle({ content: 'generic', extraContent: [enemiesMod], autoFinish: false, captureNoisy: true,
|
||||
defs: { chess: { probe: chessRec({ id: 'probe', profession: 'SNIPER', projectile: 'none',
|
||||
stats: { atk: 10, maxHp: 1e7, bat: 1, blockCnt: 0 }, rangeGrid: grid,
|
||||
skill: { spCost: 2, initSp: 0, duration: 0, trigger: { rule, grid } } }) } },
|
||||
kits: { probe: () => ({ skill: { duration: 0, spCost: 2, initSp: 0, trigger: { rule, grid }, onStart() {} } }) },
|
||||
units: [{ chessId: 'probe', row: 10, col: 4 }], enemies: [{ key: 'enemy_1517_xi', pos: [10, 7], mods: { speedMul: 0 } }],
|
||||
});
|
||||
h.step(); const u = h.unit('probe'), e = h.enemy('enemy_1517_xi');
|
||||
h.b.kill(e, null); h.run(4.8);
|
||||
let arrival = null;
|
||||
h.b.addProjectile({ from: u, target: e, speed: 3, onHit: ({ battle, target }) => {
|
||||
arrival = battle.dealDamage(u, target, { type: 'true', amount: 1000 });
|
||||
} });
|
||||
h.run(0.4);
|
||||
assert.equal(e.form, 'form2'); assert.ok(e.s.flags.invulnerable);
|
||||
assert.equal(canTargetEnemy(u, e, u.profile), false);
|
||||
const attacks = u.stats.attacks, casts = u.skill.activations, hp = e.hp;
|
||||
h.run(3);
|
||||
assert.equal(u.stats.attacks, attacks);
|
||||
if (rule === 'DEFAULT' || rule === 'ACTIVE_RANGE') assert.equal(u.skill.activations, casts);
|
||||
else assert.ok(u.skill.activations > casts, 'presence-only and SP-only skills are not forcibly suppressed');
|
||||
assert.equal(arrival, 0, 'an already flying projectile still does no damage');
|
||||
assert.equal(h.b.dealDamage(u, e, { type: 'arts', amount: 1000 }), 0);
|
||||
assert.equal(h.b.dealDamage(u, e, { type: 'element', element: 'neural', amount: 1000 }), 0);
|
||||
assert.equal(e.hp, hp);
|
||||
h.run(7.2);
|
||||
assert.equal(canTargetEnemy(u, e, u.profile), true); assert.ok(u.stats.attacks > attacks);
|
||||
checkInvariants(h.b); assert.deepEqual(h.b.errors, []);
|
||||
});
|
||||
@@ -290,8 +290,8 @@ test('lost models, live LP, detail card order, static game data (user playtest #
|
||||
assert.match(DESIGN, /official `eventTypeIcon` \(`detailPanel\.js garrisonTypeIconKey`/);
|
||||
assert.match(DESIGN, /pendingLp\? \/\* COMBAT \/ 联防 of a normal round/);
|
||||
assert.match(DESIGN, /`ownLeaks\(local, server\)`/);
|
||||
assert.match(PLAYING, /顶栏的目标生命值会\*\*立即\*\*显示扣除后的数值/);
|
||||
assert.match(README, /漏怪时顶栏的目标生命值实时减少/);
|
||||
assert.match(PLAYING, /顶栏的目标生命值\*\*先不变\*\*.*结算时才显示扣除后的数值.*GitHub #479/);
|
||||
assert.match(README, /漏怪时顶栏用红字 \+ 「−N」标记待扣的目标生命值/);
|
||||
});
|
||||
|
||||
test('user playtest #4 (DESIGN §18): picking by tile, timers, 机变 two taps, down / element state, content — code and every doc agree', () => {
|
||||
|
||||
+37
-38
@@ -762,38 +762,37 @@
|
||||
"round": 10,
|
||||
"seed": 2327237136,
|
||||
"waveId": "act1autochess_h03",
|
||||
"time": 82.1,
|
||||
"ticks": 2463,
|
||||
"time": 81.13,
|
||||
"ticks": 2434,
|
||||
"reason": "cleared",
|
||||
"killed": 23,
|
||||
"total": 23,
|
||||
"unspawned": 0,
|
||||
"bossHpLeft": null,
|
||||
"rngDraws": 178,
|
||||
"rngDraws": 189,
|
||||
"errors": {"count":0,"kinds":[]},
|
||||
"players": {"p1":{"killed":23,"total":23,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":169484,"bossDmg":0,"heal":734,"deaths":0,"layerGains":{}}},
|
||||
"players": {"p1":{"killed":23,"total":23,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":169484,"bossDmg":0,"heal":350,"deaths":0,"layerGains":{}}},
|
||||
"units": [
|
||||
["p1",1,"op","chess_char_5_19_b",2,"uniequip_002_mlynar",4297.7,1,5,3,18276,0,0,14,2],
|
||||
["p1",2,"op","chess_char_5_17_b",1,"uniequip_002_f12yin",3011.8,1,7,4,24,0,0,23,1],
|
||||
["p1",3,"op","chess_char_6_11_b",2,"uniequip_002_mlyss",2082.3,1,8,1,19826,0,0,61,2],
|
||||
["p1",4,"op","chess_char_1_03_a",1,null,1145.1,1,11,2,21914,0,0,31,1],
|
||||
["p1",5,"op","chess_char_2_04_a",1,null,1074.7,1,12,1,18374,0,0,41,2],
|
||||
["p1",6,"op","chess_char_5_15_b",1,"uniequip_002_thorn2",1287,1,4,7,53929,0,0,13,6],
|
||||
["p1",7,"op","chess_char_5_20_b",2,"uniequip_002_aglina",1520.2,1,6,1,3661,0,0,10,2],
|
||||
["p1",8,"op","chess_char_5_21_b",1,"uniequip_002_sntlla",1791.9,1,10,0,25279,0,0,77,2],
|
||||
["p1",9,"token","token_10030_mlyss_wtrman",null,null,1041,1,9,3,4736,245,245,31,1],
|
||||
["p1",1,"op","chess_char_5_19_b",2,"uniequip_002_mlynar",4297.7,1,5,1,17717,0,0,14,2],
|
||||
["p1",2,"op","chess_char_5_17_b",1,"uniequip_002_f12yin",3011.8,1,7,4,23,0,0,22,1],
|
||||
["p1",3,"op","chess_char_6_11_b",2,"uniequip_002_mlyss",2082.3,1,8,1,17037,0,0,58,2],
|
||||
["p1",4,"op","chess_char_1_03_a",1,null,1145.1,1,11,1,21954,0,0,29,1],
|
||||
["p1",5,"op","chess_char_2_04_a",1,null,1074.7,1,12,2,18244,0,0,41,1],
|
||||
["p1",6,"op","chess_char_5_15_b",1,"uniequip_002_thorn2",1287,1,4,12,54201,0,0,13,6],
|
||||
["p1",7,"op","chess_char_5_20_b",2,"uniequip_002_aglina",1520.2,1,6,0,2254,0,0,9,2],
|
||||
["p1",8,"op","chess_char_5_21_b",1,"uniequip_002_sntlla",1791.9,1,10,0,29583,0,0,74,2],
|
||||
["p1",9,"token","token_10030_mlyss_wtrman",null,null,1041,1,9,2,4897,0,0,29,1],
|
||||
["-","~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],
|
||||
["p1","~1","token","token_10030_mlyss_wtrman",null,null,1041,0,24,0,1753,490,490,12,null],
|
||||
["p1","~2","token","token_10030_mlyss_wtrman",null,null,1041,0,33,0,1710,0,0,10,null],
|
||||
["p1","~3","token","token_10030_mlyss_wtrman",null,null,1041,1,39,1,2,0,0,2,null]
|
||||
["p1","~1","token","token_10030_mlyss_wtrman",null,null,1041,0,24,0,2227,350,350,12,null],
|
||||
["p1","~2","token","token_10030_mlyss_wtrman",null,null,1041,0,35,0,1347,0,0,10,null]
|
||||
],
|
||||
"enemies": {"enemy_1288_duskls":[11,11,0,0,63415],"enemy_1288_duskls_2":[7,7,0,0,48419],"enemy_1292_duskld":[5,5,0,734,57650]},
|
||||
"hooks": [1,38,2463,265,296,481,481,171,23,25,19,15,0,11359,190,29,23,0,1,51,0,0,0],
|
||||
"events": {"atk":317,"deploy":12,"die":25,"dmg":481,"engage":12,"fx":204,"skill":34,"spawn":38,"status":380},
|
||||
"fx": {"aoe":2,"copy":1,"crit":4,"dp":2,"ember":23,"iceSpike":63,"palsy":1,"sleep":3,"split":3,"weightless":2,"zone":100},
|
||||
"snaps": "24b90f66 39c55e4f 51797d0d 8b80519c f3d7297e 8f1b0295 2c03cb7c ef7c22a9 47e39061"
|
||||
"enemies": {"enemy_1288_duskls":[11,11,0,105,63415],"enemy_1288_duskls_2":[7,7,0,0,48419],"enemy_1292_duskld":[5,5,0,245,57650]},
|
||||
"hooks": [1,37,2434,253,289,488,488,139,23,25,18,15,0,11300,203,28,23,0,1,50,0,0,0],
|
||||
"events": {"atk":305,"deploy":11,"die":25,"dmg":488,"engage":11,"fx":204,"skill":33,"spawn":37,"status":416},
|
||||
"fx": {"aoe":2,"copy":1,"crit":7,"dp":2,"ember":23,"iceSpike":60,"palsy":3,"sleep":3,"split":2,"weightless":2,"zone":99},
|
||||
"snaps": "24b90f66 4246b2db ffc0a48a e5761418 331d10dd 3e937d45 939ef84f ca1dd79a ff0a278a"
|
||||
},
|
||||
"bond-miraShip-low": {
|
||||
"about": "奇迹 low: tier 1 layers 1; chess_char_4_01_a chess_char_2_11_a chess_char_3_18_a chess_char_3_01_a chess_char_3_17_a chess_char_3_20_a chess_char_3_21_a chess_char_4_02_a",
|
||||
@@ -1285,20 +1284,20 @@
|
||||
["p1",1,"op","chess_char_3_16_a",1,null,2987.88,1,3,0,0,0,0,0,0],
|
||||
["p1",2,"op","chess_char_4_01_a",2,null,4077.11,1,5,2,8508,566,0,10,1],
|
||||
["p1",3,"op","chess_char_1_08_a",1,null,0,0,6,1,7992,2615,0,13,1],
|
||||
["p1",4,"op","chess_char_3_18_a",2,null,0,0,10,5,39236,2036,0,38,1],
|
||||
["p1",4,"op","chess_char_3_18_a",2,null,0,0,10,5,40927,2036,0,38,1],
|
||||
["p1",5,"op","chess_char_3_19_a",2,null,1978.63,1,7,2,10333,0,0,21,1],
|
||||
["p1",6,"op","chess_char_3_20_a",1,null,1541.38,1,9,2,682,0,0,8,1],
|
||||
["p1",7,"op","chess_char_3_21_a",2,null,1875.5,1,11,1,19449,0,0,23,1],
|
||||
["p1",8,"op","chess_char_4_02_a",2,null,1963.5,1,4,4,38728,0,0,7,1],
|
||||
["p1",8,"op","chess_char_4_02_a",2,null,1963.5,1,4,4,37037,0,0,7,1],
|
||||
["p1",9,"token","token_10028_vigil_wolf",null,null,852.9,1,8,0,1216,27,0,2,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]
|
||||
],
|
||||
"enemies": {"enemy_1116_liprr":[11,11,0,41,69696],"enemy_1116_liprr_2":[4,4,0,297,34560],"enemy_1118_lidbox_2":[2,2,0,4906,21888]},
|
||||
"hooks": [1,28,1502,150,150,266,266,0,19,19,7,4,3,6742,32,17,17,0,1,23,2,0,0],
|
||||
"events": {"atk":187,"deploy":9,"die":19,"dmg":264,"engage":8,"fx":39,"layer":2,"skill":11,"spawn":28,"status":98},
|
||||
"fx": {"aoe":1,"camouflage":1,"counter":3,"displace":17,"dp":2,"garrison":2,"liberate":2,"phase":3,"ripple":4,"sermon":1,"shield":2,"wolfShadow":1},
|
||||
"snaps": "a9326071 61ede413 67d5b134 ed466837 32fec6af"
|
||||
"hooks": [1,28,1502,150,150,266,266,0,19,19,7,4,3,6742,29,17,17,0,1,20,2,0,0],
|
||||
"events": {"atk":183,"deploy":9,"die":19,"dmg":264,"engage":8,"fx":38,"layer":2,"skill":11,"spawn":28,"status":92},
|
||||
"fx": {"aoe":1,"camouflage":1,"counter":3,"displace":16,"dp":2,"garrison":2,"liberate":2,"phase":3,"ripple":4,"sermon":1,"shield":2,"wolfShadow":1},
|
||||
"snaps": "a9326071 ba5f4858 674158f2 668d5d48 ab9377c5"
|
||||
},
|
||||
"bond-soloShip-high": {
|
||||
"about": "独行 high: tier 1 layers 999; chess_char_5_19_b chess_char_1_08_b chess_char_5_13_b chess_char_5_14_b chess_char_5_17_b chess_char_5_12_b chess_char_5_15_b chess_char_5_20_b",
|
||||
@@ -1744,8 +1743,8 @@
|
||||
"round": 5,
|
||||
"seed": 1009948961,
|
||||
"waveId": "act1autochess_05",
|
||||
"time": 54.6,
|
||||
"ticks": 1638,
|
||||
"time": 54.17,
|
||||
"ticks": 1625,
|
||||
"reason": "cleared",
|
||||
"killed": 22,
|
||||
"total": 22,
|
||||
@@ -1753,15 +1752,15 @@
|
||||
"bossHpLeft": null,
|
||||
"rngDraws": 0,
|
||||
"errors": {"count":0,"kinds":[]},
|
||||
"players": {"p1":{"killed":22,"total":22,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":91008,"bossDmg":0,"heal":38,"deaths":0,"layerGains":{}}},
|
||||
"players": {"p1":{"killed":22,"total":22,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":91008,"bossDmg":0,"heal":3,"deaths":0,"layerGains":{}}},
|
||||
"units": [
|
||||
["p1",1,"op","chess_char_3_16_a",1,null,2390.3,1,8,0,0,0,0,0,0],
|
||||
["p1",2,"op","chess_char_3_03_a",1,null,1515.8,1,9,3,14449,0,0,22,0],
|
||||
["p1",3,"op","chess_char_3_18_a",2,null,583.3,1,10,7,26209,1084,38,38,2],
|
||||
["p1",4,"op","chess_char_1_03_a",1,null,1145.1,1,4,1,3119,0,0,4,1],
|
||||
["p1",2,"op","chess_char_3_03_a",1,null,1515.8,1,9,4,13532,0,0,21,0],
|
||||
["p1",3,"op","chess_char_3_18_a",2,null,548.01,1,10,5,26735,1084,3,36,2],
|
||||
["p1",4,"op","chess_char_1_03_a",1,null,1145.1,1,4,2,2971,0,0,4,1],
|
||||
["p1",5,"op","chess_char_2_04_a",1,null,1074.7,1,5,0,0,0,0,0,0],
|
||||
["p1",6,"op","chess_char_3_14_a",1,null,1376.1,1,11,3,6509,0,0,28,1],
|
||||
["p1",7,"op","chess_char_3_17_a",1,null,2470.6,1,12,8,40722,0,0,46,3],
|
||||
["p1",6,"op","chess_char_3_14_a",1,null,1376.1,1,11,3,6548,0,0,28,1],
|
||||
["p1",7,"op","chess_char_3_17_a",1,null,2470.6,1,12,8,41222,0,0,46,3],
|
||||
["p1",8,"op","chess_char_3_19_a",2,null,1582.9,1,6,0,0,0,0,0,1],
|
||||
["p1",9,"token","token_10028_vigil_wolf",null,null,880,1,7,0,0,0,0,0,null],
|
||||
["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null],
|
||||
@@ -1769,10 +1768,10 @@
|
||||
["-","~3","device","trap_1105_accrate",null,null,100,1,3,0,0,0,0,0,null]
|
||||
],
|
||||
"enemies": {"enemy_1166_dusbr":[15,15,0,54,51840],"enemy_1169_duphlx":[5,5,0,0,23040],"enemy_1170_dushld":[2,2,0,1030,16128]},
|
||||
"hooks": [1,34,1638,145,145,225,225,14,22,22,8,6,0,6587,37,4,22,0,1,28,0,0,0],
|
||||
"events": {"atk":181,"deploy":9,"die":22,"dmg":225,"engage":5,"fx":8,"skill":14,"spawn":34,"status":107},
|
||||
"hooks": [1,34,1625,142,142,233,233,1,22,22,8,6,0,6560,35,4,22,0,1,26,0,0,0],
|
||||
"events": {"atk":177,"deploy":9,"die":22,"dmg":233,"engage":5,"fx":8,"skill":14,"spawn":34,"status":102},
|
||||
"fx": {"camouflage":1,"dp":3,"volley":3,"wolfShadow":1},
|
||||
"snaps": "f3cb4a09 d9ee8095 8cf05251 475fb772 f816b385 6d26de1c"
|
||||
"snaps": "f3cb4a09 ead3ba17 97f1f28e 6f24aae2 b498c568 eb22abcb"
|
||||
},
|
||||
"bond-yanShip-high": {
|
||||
"about": "炎 high: tier 3 layers 999; chess_char_4_17_b chess_char_6_03_b chess_char_3_03_b chess_char_3_04_b chess_char_1_03_b chess_char_2_04_b chess_char_5_03_b chess_char_5_12_b chess_char_5_23_b",
|
||||
|
||||
+207
-209
@@ -144,42 +144,42 @@
|
||||
"killed": 8,
|
||||
"total": 8,
|
||||
"unspawned": 0,
|
||||
"bossHpLeft": 5119551,
|
||||
"rngDraws": 825,
|
||||
"bossHpLeft": 5151915,
|
||||
"rngDraws": 834,
|
||||
"errors": {"count":0,"kinds":[]},
|
||||
"players": {
|
||||
"p1": {"killed":8,"total":8,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":825320,"bossDmg":458231,"heal":21045,"deaths":1,"layerGains":{}},
|
||||
"p2": {"killed":0,"total":0,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":917367,"bossDmg":419879,"heal":6188,"deaths":2,"layerGains":{}}
|
||||
"p1": {"killed":8,"total":8,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":798821,"bossDmg":425815,"heal":21045,"deaths":1,"layerGains":{}},
|
||||
"p2": {"killed":0,"total":0,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":911503,"bossDmg":419932,"heal":6270,"deaths":2,"layerGains":{}}
|
||||
},
|
||||
"units": [
|
||||
["p1",1,"op","chess_char_5_19_b",2,"uniequip_002_mlynar",4297.7,1,9,0,266158,2455,463,74,3],
|
||||
["p1",2,"op","chess_char_4_01_b",2,"uniequip_002_rmixer",6030.59,1,11,0,8108,15611,8378,34,1],
|
||||
["p1",3,"op","chess_char_5_14_b",1,"none",2391.4,1,7,0,35553,997,272,17,13],
|
||||
["p1",4,"op","chess_char_5_17_b",1,"uniequip_002_f12yin",3011.8,1,13,0,6024,0,0,20,1],
|
||||
["p1",5,"op","chess_char_5_15_b",1,"uniequip_002_thorn2",1287,1,1,0,182518,0,0,72,14],
|
||||
["p1",1,"op","chess_char_5_19_b",2,"uniequip_002_mlynar",4297.7,1,9,0,266158,2455,745,74,3],
|
||||
["p1",2,"op","chess_char_4_01_b",2,"uniequip_002_rmixer",6030.59,1,11,0,8108,15611,5431,34,1],
|
||||
["p1",3,"op","chess_char_5_14_b",1,"none",2391.4,1,7,0,25560,997,272,17,10],
|
||||
["p1",4,"op","chess_char_5_17_b",1,"uniequip_002_f12yin",3011.8,1,13,0,10666,0,0,20,1],
|
||||
["p1",5,"op","chess_char_5_15_b",1,"uniequip_002_thorn2",1287,1,1,0,147722,0,0,72,11],
|
||||
["p1",6,"op","chess_char_5_20_b",2,"uniequip_002_aglina",2660.35,1,3,0,89986,0,0,44,4],
|
||||
["p1",7,"op","chess_char_5_21_b",1,"uniequip_002_sntlla",3046.23,1,17,1,59635,1709,1709,103,3],
|
||||
["p1",8,"op","chess_char_5_22_b",1,"uniequip_002_nymph",1772.1,1,43,1,128594,2045,273,50,16],
|
||||
["p1",9,"op","chess_char_5_23_b",1,"uniequip_002_reckpr",1771,1,5,0,0,0,9950,16,3],
|
||||
["p1",7,"op","chess_char_5_21_b",1,"uniequip_002_sntlla",3046.23,1,17,1,60191,1709,1709,103,3],
|
||||
["p1",8,"op","chess_char_5_22_b",1,"uniequip_002_nymph",1772.1,1,43,1,141685,2045,273,44,16],
|
||||
["p1",9,"op","chess_char_5_23_b",1,"uniequip_002_reckpr",1771,1,5,0,0,0,12615,16,2],
|
||||
["p1",10,"op","chess_char_4_02_b",2,"uniequip_002_mostma",1864.5,1,15,0,48745,0,0,34,2],
|
||||
["p2",11,"op","chess_char_4_11_b",1,"uniequip_002_cathy",1093.92,1,49,0,0,6969,1080,0,1],
|
||||
["p2",12,"op","chess_char_4_04_b",1,"uniequip_002_ines",3900.44,1,48,2,298987,0,0,273,7],
|
||||
["p2",13,"op","chess_char_4_07_b",1,"uniequip_002_bpipe",1393.95,1,8,0,6851,2741,1513,24,8],
|
||||
["p2",14,"op","chess_char_4_16_b",2,"uniequip_002_texas2",2117.31,1,51,3,134890,3198,1245,171,12],
|
||||
["p2",13,"op","chess_char_4_07_b",1,"uniequip_002_bpipe",1393.95,1,8,0,6240,2741,1513,24,8],
|
||||
["p2",14,"op","chess_char_4_16_b",2,"uniequip_002_texas2",2198.65,1,51,3,134890,3198,1326,171,12],
|
||||
["p2",15,"op","chess_char_4_09_b",1,"uniequip_002_mizuki",2752.8,1,10,0,103788,1136,833,77,5],
|
||||
["p2",16,"op","chess_char_4_06_b",1,"uniequip_002_kroos2",1717.1,1,2,1,192768,0,0,245,3],
|
||||
["p2",16,"op","chess_char_4_06_b",1,"uniequip_002_kroos2",1717.1,1,2,1,183825,0,0,248,3],
|
||||
["p2",17,"op","chess_char_4_10_b",1,"uniequip_002_aroma",1830.4,1,4,0,0,0,0,0,0],
|
||||
["p2",18,"op","chess_char_4_12_b",2,"uniequip_002_glady",2455.2,1,18,0,59278,1515,1515,50,3],
|
||||
["p2",19,"op","chess_char_4_13_b",1,"uniequip_002_gnosis",2187.9,1,40,0,54119,2190,2,60,16],
|
||||
["p2",18,"op","chess_char_4_12_b",2,"uniequip_002_glady",2455.2,1,18,0,69310,1515,1515,56,3],
|
||||
["p2",19,"op","chess_char_4_13_b",1,"uniequip_002_gnosis",2187.9,1,40,0,47776,2190,2,53,14],
|
||||
["p2",20,"op","chess_char_4_14_b",1,"uniequip_002_lionhd",1788.6,1,6,0,66688,0,0,47,13],
|
||||
["p2",21,"token","token_10041_cathy_catsld",null,null,100,1,21,0,0,0,0,0,null],
|
||||
["p2",22,"token","token_10041_cathy_catsld",null,null,100,1,22,0,0,0,0,0,null]
|
||||
],
|
||||
"enemies": {"enemy_1005_yokai_2":[6,6,0,3548,62190],"enemy_1430_lrrook":[2,2,0,0,802449],"enemy_9017_achunt":[2,0,0,37019,880449]},
|
||||
"hooks": [1,49,6000,1363,1401,2938,2977,4532,13,25,128,122,22,37234,997,0,10,0,1,13,0,11,0],
|
||||
"events": {"atk":1400,"deploy":39,"die":25,"dmg":2967,"engage":17,"fx":786,"heal":36,"skill":250,"spawn":32,"status":502},
|
||||
"fx": {"aoe":34,"appear":2,"blink":14,"burst":11,"catShield":1,"charge":8,"counter":22,"crit":92,"dilemma":5,"disappear":2,"explosion":13,"frostNova":16,"garrisonGrant":1,"iceSpike":89,"overclock":1,"pull":3,"quadShot":2,"ripple":20,"sentry":5,"sermon":1,"shadowWeave":9,"shield":5,"soul":3,"sp":14,"stealth":7,"swordRain":92,"swordStorm":12,"tornado":3,"tornadoPulse":15,"weightless":4,"zone":280},
|
||||
"snaps": "48098e21 84cc8d09 9e22eede 8777b0de 5b2c7c4f 4969a87a 300e3231 8f2aae8d 594d2add 27a55abf 76ee1392 de169eb6 deea8c8f 23e8c816 27aabce9 ef0f6997 a7a322da 22b337fc 0b0aabbd f290764e"
|
||||
"enemies": {"enemy_1005_yokai_2":[6,6,0,3548,62190],"enemy_1430_lrrook":[2,2,0,0,802449],"enemy_9017_achunt":[2,0,0,37019,848085]},
|
||||
"hooks": [1,49,6000,1359,1398,2874,2913,4613,13,25,119,114,22,36850,972,0,10,0,1,13,0,11,0],
|
||||
"events": {"atk":1396,"deploy":39,"die":25,"dmg":2903,"engage":17,"fx":722,"heal":36,"skill":233,"spawn":32,"status":511},
|
||||
"fx": {"aoe":30,"appear":2,"blink":14,"burst":11,"catShield":1,"charge":8,"counter":22,"crit":101,"dilemma":5,"disappear":2,"explosion":13,"frostNova":14,"garrisonGrant":1,"iceSpike":89,"overclock":1,"pull":3,"quadShot":2,"ripple":20,"sentry":5,"sermon":1,"shadowWeave":9,"shield":5,"soul":3,"sp":7,"stealth":7,"swordRain":92,"swordStorm":12,"tornado":3,"tornadoPulse":15,"weightless":4,"zone":220},
|
||||
"snaps": "48098e21 84cc8d09 9e22eede 8777b0de 5b2c7c4f 4969a87a 300e3231 8f2aae8d 4263d20f ac9beb19 ef6d231a cef39f5d 4a5af757 e2b11181 12bc00c2 a0d267f9 d1c2531d 0871c5d6 0022566b f70f7ef5"
|
||||
},
|
||||
"boss-boss_2-solo": {
|
||||
"about": "假想敌:铳 solo pool 3000000: chess_char_4_17_b chess_char_4_18_b chess_char_4_23_b chess_char_4_19_b chess_char_4_22_b chess_char_4_20_b chess_char_4_21_b chess_char_4_24_b chess_char_4_25_b chess_char_4_26_b",
|
||||
@@ -227,112 +227,109 @@
|
||||
"round": 14,
|
||||
"seed": 1428555614,
|
||||
"waveId": "act1autochess_h07_03",
|
||||
"time": 193.03,
|
||||
"ticks": 5791,
|
||||
"time": 169.2,
|
||||
"ticks": 5076,
|
||||
"reason": "cleared",
|
||||
"killed": 19,
|
||||
"killed": 18,
|
||||
"total": 6,
|
||||
"unspawned": 0,
|
||||
"bossHpLeft": 0,
|
||||
"rngDraws": 1399,
|
||||
"rngDraws": 1416,
|
||||
"errors": {"count":0,"kinds":[]},
|
||||
"players": {
|
||||
"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":{}}
|
||||
"p1": {"killed":18,"total":6,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":1516502,"bossDmg":1398136,"heal":10352,"deaths":1,"layerGains":{}},
|
||||
"p2": {"killed":0,"total":0,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":2667879,"bossDmg":2601864,"heal":41836,"deaths":3,"layerGains":{}}
|
||||
},
|
||||
"units": [
|
||||
["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",1,"op","chess_char_6_03_b",2,"uniequip_002_yu",4070,1,7,0,0,0,6873,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,29,1184,0,29,1],
|
||||
["p1",4,"op","chess_char_6_01_b",2,"uniequip_002_lemuen",2344.65,1,13,5,815234,3173,0,52,7],
|
||||
["p1",5,"op","chess_char_6_02_b",2,"uniequip_002_sbell2",2106.51,1,17,0,248050,6003,751,41,2],
|
||||
["p1",6,"op","chess_char_6_04_b",2,"uniequip_002_skadi2",162.95,1,59,1,26550,16018,1505,0,3],
|
||||
["p1",7,"op","chess_char_6_05_b",2,"uniequip_002_pasngr",1664.48,1,21,1,284438,9843,1204,82,7],
|
||||
["p1",8,"op","chess_char_6_08_b",2,"uniequip_002_reed2",1834.8,1,5,0,30,0,19,13,1],
|
||||
["p1",9,"op","chess_char_6_09_b",0,"uniequip_002_cello",1454.2,1,15,0,37,0,0,9,9],
|
||||
["p1",10,"op","chess_char_6_11_b",2,"uniequip_002_mlyss",2082.3,1,23,0,103926,1501,0,178,4],
|
||||
["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,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],
|
||||
["p2",12,"op","chess_char_6_15_b",2,"uniequip_002_qiubai",4181.76,1,31,1,476210,0,0,176,3],
|
||||
["p2",13,"op","chess_char_6_19_b",2,"uniequip_002_blkkgt",3043.7,1,62,1,28,3044,0,9,1],
|
||||
["p2",14,"op","chess_char_5_01_b",1,"uniequip_002_excu2",2545.4,1,56,0,14,3710,780,14,1],
|
||||
["p2",15,"op","chess_char_6_17_b",2,"uniequip_002_nearl2",6916.53,1,32,0,279951,2036,0,70,3],
|
||||
["p2",16,"op","chess_char_6_12_b",1,"uniequip_002_rosmon",3493.03,1,16,4,223610,3571,443,62,3],
|
||||
["p2",17,"op","chess_char_6_13_b",1,"uniequip_002_angel2",2680.15,1,20,3,1466393,5212,1901,316,5],
|
||||
["p2",18,"op","chess_char_6_14_b",2,"uniequip_002_lumen",2297.79,1,22,0,0,3550,6225,52,1],
|
||||
["p2",19,"op","chess_char_6_16_b",2,"uniequip_002_halo2",1741.19,1,25,1,72989,1414,0,121,4],
|
||||
["p2",20,"op","chess_char_6_18_b",2,"uniequip_002_whitw2",1695.1,1,58,1,122067,1695,0,22,2],
|
||||
["p2",21,"op","chess_char_6_20_b",2,"uniequip_002_agoat2",1828.31,1,18,0,0,1775,32488,54,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,44067,0,0,87,1],
|
||||
["p1","~1","token","token_10030_mlyss_wtrman",null,null,1586.2,1,24,0,38205,0,0,76,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,1,0,0,1,null],
|
||||
["p1","~2","token","token_10030_mlyss_wtrman",null,null,1989.96,0,42,0,0,0,0,0,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],
|
||||
["p2","~2","token","token_10019_nearl2_sword",null,null,6600,0,51,0,10647,0,0,0,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","~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]
|
||||
["p2","~3","token","token_10019_nearl2_sword",null,null,6600,0,61,0,5323,0,0,0,null],
|
||||
["p1","~5","token","token_10030_mlyss_wtrman",null,null,1586.2,1,64,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,1350,154793],
|
||||
"enemy_1209_sfden_2": [2,2,0,1501,154793],
|
||||
"enemy_1210_msfden_2": [2,2,0,0,386],
|
||||
"enemy_9021_acduml": [1,1,0,0,4000000],
|
||||
"enemy_9023_acdums": [9,7,0,62774,362]
|
||||
"enemy_9023_acdums": [8,6,0,51353,291]
|
||||
},
|
||||
"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},
|
||||
"hooks": [1,63,5076,1385,1385,3756,4577,2685,23,35,61,57,333,38781,1915,4,21,0,1,22,0,8,1],
|
||||
"events": {"atk":1650,"deploy":38,"die":35,"dmg":4308,"engage":17,"fx":1132,"heal":400,"skill":118,"spawn":57,"status":797},
|
||||
"fx": {
|
||||
"airstrike": 116,
|
||||
"airstrike": 109,
|
||||
"appear": 1,
|
||||
"beam": 11,
|
||||
"beam": 10,
|
||||
"blink": 2,
|
||||
"bombard": 39,
|
||||
"bombardShell": 39,
|
||||
"bombard": 56,
|
||||
"bombardShell": 57,
|
||||
"buff": 3,
|
||||
"burst": 17,
|
||||
"camouflage": 1,
|
||||
"burst": 8,
|
||||
"copy": 1,
|
||||
"disappear": 1,
|
||||
"displace": 1,
|
||||
"dodge": 1,
|
||||
"dp": 5,
|
||||
"drone": 208,
|
||||
"dp": 4,
|
||||
"drone": 211,
|
||||
"droneLock": 18,
|
||||
"drones": 2,
|
||||
"explode": 449,
|
||||
"extraAttack": 4,
|
||||
"explode": 368,
|
||||
"extraAttack": 3,
|
||||
"finale": 1,
|
||||
"firewall": 1,
|
||||
"garrisonGrant": 1,
|
||||
"lightning": 79,
|
||||
"link": 8,
|
||||
"lock": 39,
|
||||
"lightning": 76,
|
||||
"link": 7,
|
||||
"lock": 60,
|
||||
"lure": 2,
|
||||
"phase": 44,
|
||||
"phase": 35,
|
||||
"radiantSword": 3,
|
||||
"scorchBurst": 4,
|
||||
"scorchBurst": 1,
|
||||
"slash": 11,
|
||||
"snow": 36,
|
||||
"snow": 35,
|
||||
"split": 4,
|
||||
"steal": 6,
|
||||
"steal": 5,
|
||||
"storm": 7,
|
||||
"summon": 16,
|
||||
"summon": 14,
|
||||
"sunBurst": 1,
|
||||
"talent": 8,
|
||||
"talent": 6,
|
||||
"telegraph": 2,
|
||||
"tide": 3,
|
||||
"wanted": 2
|
||||
"wanted": 1
|
||||
},
|
||||
"snaps": "68fffef1 494368df 4e38f532 f87d9068 690476ee 03b01e26 0bed04bd 2fd59226 69665385 05fe5c2d 998b698f 48323861 b99302e0 9efaad8f a695d151 50c5bc45 dd3e6215 601aae77 2a462704 b1188c19"
|
||||
"snaps": "b66f8306 b821cb0a 25c089ba 9c4aeabf ff5b59a1 042e449b f5822070 07445b3e ac69ddc0 37b52b7d c59632d4 a8b2eca1 6bbfab6e afcc528d e64bf70f bd7aa0ef 2bd32f6f"
|
||||
},
|
||||
"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",
|
||||
@@ -521,37 +518,37 @@
|
||||
"round": 14,
|
||||
"seed": 2302764647,
|
||||
"waveId": "act2autochess_h07_05",
|
||||
"time": 102.27,
|
||||
"ticks": 3068,
|
||||
"time": 102.9,
|
||||
"ticks": 3087,
|
||||
"reason": "cleared",
|
||||
"killed": 16,
|
||||
"total": 8,
|
||||
"unspawned": 0,
|
||||
"bossHpLeft": 0,
|
||||
"rngDraws": 874,
|
||||
"rngDraws": 908,
|
||||
"errors": {"count":0,"kinds":[]},
|
||||
"players": {
|
||||
"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":{}}
|
||||
"p1": {"killed":16,"total":8,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":1233427,"bossDmg":928200,"heal":24829,"deaths":1,"layerGains":{}},
|
||||
"p2": {"killed":0,"total":0,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":1040342,"bossDmg":631800,"heal":41304,"deaths":1,"layerGains":{}}
|
||||
},
|
||||
"units": [
|
||||
["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",4,"op","chess_char_6_01_b",2,"uniequip_002_lemuen",1447.37,1,1,3,514889,991,821,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",6,"op","chess_char_6_04_b",2,"uniequip_002_skadi2",1757.8,1,3,0,4001,5151,438,0,2],
|
||||
["p1",7,"op","chess_char_6_05_b",2,"uniequip_002_pasngr",1586.2,1,18,0,141216,3329,1053,18,5],
|
||||
["p1",8,"op","chess_char_6_08_b",2,"uniequip_002_reed2",1834.8,1,5,0,74259,868,10288,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",10,"op","chess_char_6_11_b",2,"uniequip_002_mlyss",2082.3,1,9,0,52150,6246,961,65,3],
|
||||
["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,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],
|
||||
["p1",12,"token","token_10030_mlyss_wtrman",null,null,1668,1,10,0,35402,874,464,52,1],
|
||||
["p2",13,"op","chess_char_6_15_b",2,"uniequip_002_qiubai",4181.76,1,55,4,378608,9705,860,100,3],
|
||||
["p2",14,"op","chess_char_6_19_b",2,"uniequip_002_blkkgt",2069.61,1,13,2,128245,3313,0,22,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",16,"op","chess_char_6_17_b",2,"uniequip_002_nearl2",6521.3,1,53,1,164109,10334,0,40,2],
|
||||
["p2",17,"op","chess_char_6_12_b",1,"uniequip_002_rosmon",5557.09,1,2,0,150406,888,888,18,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],
|
||||
@@ -569,11 +566,11 @@
|
||||
"enemy_1010_demon_2": [2,2,0,1457,128995],
|
||||
"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_2016_csphtm": [2,2,0,67552,1560000],
|
||||
"enemy_2017_csphts": [8,8,0,1223,343985]
|
||||
},
|
||||
"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},
|
||||
"hooks": [1,54,3087,673,673,1759,2039,3851,21,34,60,52,50,26592,1290,30,18,0,1,19,0,17,86],
|
||||
"events": {"atk":826,"deploy":36,"die":34,"dmg":1888,"engage":22,"fx":534,"heal":254,"skill":112,"spawn":47,"status":632},
|
||||
"fx": {
|
||||
"airstrike": 15,
|
||||
"blink": 15,
|
||||
@@ -582,8 +579,8 @@
|
||||
"burst": 17,
|
||||
"copy": 1,
|
||||
"displace": 4,
|
||||
"dodge": 81,
|
||||
"dp": 2,
|
||||
"dodge": 86,
|
||||
"dp": 3,
|
||||
"drone": 102,
|
||||
"droneLock": 3,
|
||||
"drones": 1,
|
||||
@@ -592,7 +589,7 @@
|
||||
"finale": 2,
|
||||
"firewall": 1,
|
||||
"garrisonGrant": 1,
|
||||
"lightning": 43,
|
||||
"lightning": 45,
|
||||
"link": 3,
|
||||
"lock": 33,
|
||||
"lure": 2,
|
||||
@@ -612,7 +609,7 @@
|
||||
"tide": 2,
|
||||
"wanted": 11
|
||||
},
|
||||
"snaps": "d3cd88f1 0b40f2d2 3fada850 9d166f76 eb1c5713 8daf7ec1 58aeec40 fd26682e 39226066 3ce9d7e2 8cf2e7c7"
|
||||
"snaps": "d3cd88f1 0b40f2d2 3fada850 9d166f76 eb1c5713 8daf7ec1 58aeec40 fd26682e adab14c8 5be402ff eb823ff1"
|
||||
},
|
||||
"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",
|
||||
@@ -629,21 +626,21 @@
|
||||
"killed": 9,
|
||||
"total": 5,
|
||||
"unspawned": 0,
|
||||
"bossHpLeft": 342977,
|
||||
"rngDraws": 352,
|
||||
"bossHpLeft": 354904,
|
||||
"rngDraws": 347,
|
||||
"errors": {"count":0,"kinds":[]},
|
||||
"players": {"p1":{"killed":9,"total":5,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":587221,"bossDmg":437023,"heal":43949,"deaths":0,"layerGains":{}}},
|
||||
"players": {"p1":{"killed":9,"total":5,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":575294,"bossDmg":425096,"heal":43949,"deaths":0,"layerGains":{}}},
|
||||
"units": [
|
||||
["p1",1,"op","chess_char_5_08_b",2,"uniequip_002_horn",794.96,1,2,1,117029,5141,3331,60,3],
|
||||
["p1",1,"op","chess_char_5_08_b",2,"uniequip_002_horn",794.96,1,2,1,94937,5141,3331,60,3],
|
||||
["p1",2,"op","chess_char_5_11_b",1,"uniequip_002_demkni",5285.28,1,1,0,0,0,10953,0,24],
|
||||
["p1",3,"op","chess_char_5_05_b",2,"uniequip_002_ulpia",6419.33,1,26,1,54171,17435,14580,8,1],
|
||||
["p1",4,"op","chess_char_5_06_b",2,"uniequip_002_etlchi",3626.7,1,6,4,113584,499,499,53,4],
|
||||
["p1",4,"op","chess_char_5_06_b",2,"uniequip_002_etlchi",3626.7,1,6,4,121029,499,499,53,4],
|
||||
["p1",5,"op","chess_char_5_13_b",1,"uniequip_002_ghost2",3579.42,1,9,0,0,1773,1478,0,0],
|
||||
["p1",6,"op","chess_char_5_07_b",2,"uniequip_002_surtr",1,0,10,0,54445,10291,5283,15,1],
|
||||
["p1",6,"op","chess_char_5_07_b",2,"uniequip_002_surtr",2509.97,1,27,0,53528,10729,5283,15,1],
|
||||
["p1",7,"op","chess_char_5_02_b",2,"uniequip_002_titi",1582.9,1,4,0,0,0,0,0,0],
|
||||
["p1",8,"op","chess_char_5_03_b",2,"uniequip_002_blaze2",1619.2,1,7,3,161964,5642,803,143,5],
|
||||
["p1",9,"op","chess_char_5_10_b",2,"uniequip_002_lisa",1628,1,5,0,15095,4433,89,34,3],
|
||||
["p1",10,"op","chess_char_5_12_b",0,"uniequip_002_dusk",615.02,1,8,0,67722,6725,5374,40,32],
|
||||
["p1",8,"op","chess_char_5_03_b",2,"uniequip_002_blaze2",1619.2,1,7,3,155416,5642,803,143,5],
|
||||
["p1",9,"op","chess_char_5_10_b",2,"uniequip_002_lisa",1628,1,5,0,14741,4433,89,34,3],
|
||||
["p1",10,"op","chess_char_5_12_b",0,"uniequip_002_dusk",615.02,1,8,0,78261,6725,5374,40,32],
|
||||
["p1","~1","token","token_10015_dusk_drgn",null,null,1218.29,0,13,0,3212,2338,1559,10,null],
|
||||
["p1","~1","token","token_10039_ulpia_block",null,null,3500,0,23,0,0,0,0,0,null]
|
||||
],
|
||||
@@ -651,14 +648,14 @@
|
||||
"enemy_1249_lysdb_2": [2,2,0,532,19907],
|
||||
"enemy_2009_csaudc": [2,2,0,1153,26542],
|
||||
"enemy_2010_csdcr": [1,1,0,1406,19907],
|
||||
"enemy_2016_csphtm": [1,0,0,23772,437023],
|
||||
"enemy_2016_csphtm": [1,0,0,24210,425096],
|
||||
"enemy_2017_csphts": [5,4,0,868,78127],
|
||||
"enemy_5601_entlec": [2,2,0,0,5716]
|
||||
},
|
||||
"hooks": [1,27,6000,392,392,718,1128,4548,11,14,73,72,105,19224,954,15,13,0,1,57,0,4,56],
|
||||
"events": {"atk":402,"deploy":14,"die":14,"dmg":799,"engage":8,"fx":235,"heal":243,"skill":145,"spawn":25,"status":183},
|
||||
"fx": {"anchor":1,"aoe":1,"blink":5,"bloodBattle":1,"burst":4,"candle":2,"dodge":56,"ember":1,"explode":22,"healAoe":24,"overload":3,"shieldBreak":2,"splash":105,"summon":6,"teleport":2},
|
||||
"snaps": "8fce255c d8151793 2376ccec 5d7e1fa7 b2d32b1e 41f8ace8 e6a36358 5fa51831 3946c3c8 ef05c1d4 1eb5ed0b a122bbb0 0fef027b 0b3562c8 cc3d5a85 aa3da3a3 38f45804 c569d14b ac5ec1fb 323cea5a"
|
||||
"hooks": [1,28,6000,392,392,705,1121,4548,11,14,73,72,105,19344,955,15,13,0,1,57,0,4,52],
|
||||
"events": {"atk":402,"deploy":15,"die":14,"dmg":792,"engage":8,"fx":232,"heal":243,"skill":145,"spawn":25,"status":179},
|
||||
"fx": {"anchor":1,"aoe":1,"blink":5,"bloodBattle":1,"burst":4,"candle":2,"dodge":52,"ember":1,"explode":22,"healAoe":24,"overload":3,"revive":1,"shieldBreak":2,"splash":105,"summon":6,"teleport":2},
|
||||
"snaps": "8fce255c d8151793 2376ccec 5d7e1fa7 b2d32b1e 4e4a64b4 a2af501d fcaa66ec dfb4e7aa 691d5be9 f60f849e f96b690a d0668215 7175bcde 4b94143d f0657105 cd976d36 a98aa464 7667ab5f 9cd2b6c6"
|
||||
},
|
||||
"boss-boss_6-pair": {
|
||||
"about": "阿利斯泰尔,帝国余晖 pair pool 7960000: chess_char_5_19_b chess_char_4_01_b chess_char_5_14_b chess_char_5_17_b chess_char_5_15_b chess_char_5_20_b chess_char_5_21_b chess_char_5_22_b chess_char_5_23_b chess_char_4_02_b | chess_char_4_11_b chess_char_4_04_b chess_char_4_07_b chess_char_4_16_b chess_char_4_09_b chess_char_4_06_b chess_char_4_10_b chess_char_4_12_b chess_char_4_13_b chess_char_4_14_b token_10041_cathy_catsld token_10041_cathy_catsld",
|
||||
@@ -922,94 +919,95 @@
|
||||
"time": 200,
|
||||
"ticks": 6000,
|
||||
"reason": "forced",
|
||||
"killed": 15,
|
||||
"killed": 18,
|
||||
"total": 14,
|
||||
"unspawned": 0,
|
||||
"bossHpLeft": 12404101,
|
||||
"rngDraws": 801,
|
||||
"bossHpLeft": 12470327,
|
||||
"rngDraws": 722,
|
||||
"errors": {"count":0,"kinds":[]},
|
||||
"players": {
|
||||
"p1": {"killed":15,"total":14,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":2215476,"bossDmg":1070309,"heal":58998,"deaths":14,"layerGains":{}},
|
||||
"p2": {"killed":0,"total":0,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":1279788,"bossDmg":924060,"heal":27987,"deaths":25,"layerGains":{}}
|
||||
"p1": {"killed":18,"total":14,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":2276269,"bossDmg":985262,"heal":41403,"deaths":15,"layerGains":{}},
|
||||
"p2": {"killed":0,"total":0,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":1191003,"bossDmg":943816,"heal":23084,"deaths":21,"layerGains":{}}
|
||||
},
|
||||
"units": [
|
||||
["p1",1,"op","chess_char_5_19_b",2,"uniequip_002_mlynar",3979.46,1,68,3,59812,22848,1042,28,3],
|
||||
["p1",2,"op","chess_char_4_01_b",2,"uniequip_002_rmixer",7781.4,1,88,0,597,10499,997,4,1],
|
||||
["p1",3,"op","chess_char_5_14_b",1,"none",4782.8,1,71,3,362707,14245,6320,0,15],
|
||||
["p1",4,"op","chess_char_5_17_b",1,"uniequip_002_f12yin",6023.6,1,69,0,39722,30151,8288,63,2],
|
||||
["p1",5,"op","chess_char_5_15_b",1,"uniequip_002_thorn2",709.08,1,76,2,921735,6082,1643,103,13],
|
||||
["p1",6,"op","chess_char_5_20_b",2,"uniequip_002_aglina",0,0,83,1,191630,18072,3781,36,4],
|
||||
["p1",7,"op","chess_char_5_21_b",1,"uniequip_002_sntlla",881.8,1,82,0,3590,6716,430,5,1],
|
||||
["p1",8,"op","chess_char_5_22_b",1,"uniequip_002_nymph",0,0,80,1,180009,7537,448,19,7],
|
||||
["p1",9,"op","chess_char_5_23_b",1,"uniequip_002_reckpr",3001.6,1,62,0,15030,9354,35036,28,3],
|
||||
["p1",10,"op","chess_char_4_02_b",2,"uniequip_002_mostma",3204,1,78,3,440644,11570,1014,26,2],
|
||||
["p2",11,"op","chess_char_4_11_b",1,"uniequip_002_cathy",0,0,73,0,0,9019,3,0,2],
|
||||
["p2",12,"op","chess_char_4_04_b",1,"uniequip_002_ines",2082.13,1,87,1,33223,15101,6191,110,5],
|
||||
["p2",13,"op","chess_char_4_07_b",1,"uniequip_002_bpipe",4587.28,1,92,0,28848,13491,4316,26,8],
|
||||
["p2",14,"op","chess_char_4_16_b",2,"uniequip_002_texas2",0,0,77,0,155229,20706,7082,45,8],
|
||||
["p2",15,"op","chess_char_4_09_b",1,"uniequip_002_mizuki",4392.08,1,86,0,89084,10204,1419,30,3],
|
||||
["p2",16,"op","chess_char_4_06_b",1,"uniequip_002_kroos2",0,0,84,2,612597,3434,0,158,3],
|
||||
["p2",17,"op","chess_char_4_10_b",1,"uniequip_002_aroma",1830.4,1,93,0,77261,6468,4637,71,3],
|
||||
["p2",18,"op","chess_char_4_12_b",2,"uniequip_002_glady",0,0,75,0,54940,8803,3893,26,2],
|
||||
["p2",19,"op","chess_char_4_13_b",1,"uniequip_002_gnosis",2187.9,1,91,0,33124,4455,79,38,11],
|
||||
["p2",20,"op","chess_char_4_14_b",1,"uniequip_002_lionhd",0,0,64,0,195483,3942,365,54,13],
|
||||
["p1",1,"op","chess_char_5_19_b",2,"uniequip_002_mlynar",2289.94,1,53,1,100479,15599,698,44,3],
|
||||
["p1",2,"op","chess_char_4_01_b",2,"uniequip_002_rmixer",8085.64,1,76,0,4934,11577,653,8,2],
|
||||
["p1",3,"op","chess_char_5_14_b",1,"none",4782.8,1,11,5,429323,20994,17859,40,23],
|
||||
["p1",4,"op","chess_char_5_17_b",1,"uniequip_002_f12yin",6023.6,1,87,0,60190,23370,6042,27,2],
|
||||
["p1",5,"op","chess_char_5_15_b",1,"uniequip_002_thorn2",0,0,80,1,733079,5578,430,69,8],
|
||||
["p1",6,"op","chess_char_5_20_b",2,"uniequip_002_aglina",0,0,70,1,461840,11328,243,29,3],
|
||||
["p1",7,"op","chess_char_5_21_b",1,"uniequip_002_sntlla",0,0,75,0,8961,7168,0,18,2],
|
||||
["p1",8,"op","chess_char_5_22_b",1,"uniequip_002_nymph",886.2,1,74,5,224545,6202,0,36,10],
|
||||
["p1",9,"op","chess_char_5_23_b",1,"uniequip_002_reckpr",3542,1,88,0,16232,9141,15477,12,2],
|
||||
["p1",10,"op","chess_char_4_02_b",2,"uniequip_002_mostma",3729,1,85,1,236686,7458,0,26,3],
|
||||
["p2",11,"op","chess_char_4_11_b",1,"uniequip_002_cathy",2014.55,1,73,0,0,5402,0,0,1],
|
||||
["p2",12,"op","chess_char_4_04_b",1,"uniequip_002_ines",1319.07,1,63,1,35286,12845,5536,125,6],
|
||||
["p2",13,"op","chess_char_4_07_b",1,"uniequip_002_bpipe",4577.28,1,90,0,4436,11556,2372,1,1],
|
||||
["p2",14,"op","chess_char_4_16_b",2,"uniequip_002_texas2",987.72,1,89,1,167433,19491,3293,64,11],
|
||||
["p2",15,"op","chess_char_4_09_b",1,"uniequip_002_mizuki",1500.55,1,62,0,138625,10173,2890,51,4],
|
||||
["p2",16,"op","chess_char_4_06_b",1,"uniequip_002_kroos2",805.46,1,71,2,602554,4129,1500,171,3],
|
||||
["p2",17,"op","chess_char_4_10_b",1,"uniequip_002_aroma",1830.4,1,94,0,83393,5856,4026,77,3],
|
||||
["p2",18,"op","chess_char_4_12_b",2,"uniequip_002_glady",0,0,79,0,28501,6850,1940,11,1],
|
||||
["p2",19,"op","chess_char_4_13_b",1,"uniequip_002_gnosis",2187.9,1,65,0,42006,3559,1371,54,17],
|
||||
["p2",20,"op","chess_char_4_14_b",1,"uniequip_002_lionhd",288.45,1,84,0,88770,5234,157,35,6],
|
||||
["p2",21,"token","token_10041_cathy_catsld",null,null,100,1,21,0,0,0,0,0,null],
|
||||
["p2",22,"token","token_10041_cathy_catsld",null,null,100,1,22,0,0,0,0,0,null]
|
||||
],
|
||||
"enemies": {
|
||||
"enemy_10027_vtsk": [6,3,0,149405,1092909],
|
||||
"enemy_10027_vtsk": [6,6,0,122226,1203674],
|
||||
"enemy_1005_yokai": [4,4,0,0,10699],
|
||||
"enemy_1229_darmy_2": [8,8,0,3936,294231],
|
||||
"enemy_9013_acstmk_2": [1,0,0,58642,1995899],
|
||||
"enemy_9014_acstma": [5,4,0,5260,53368],
|
||||
"enemy_9015_acstmb": [3,2,0,15454,71943],
|
||||
"enemy_9016_acstmr": [8,7,0,0,207]
|
||||
"enemy_1229_darmy_2": [8,8,0,4099,294231],
|
||||
"enemy_9013_acstmk_2": [1,0,0,55096,1929673],
|
||||
"enemy_9014_acstma": [6,5,0,8751,27362],
|
||||
"enemy_9015_acstmb": [3,2,0,13340,21281],
|
||||
"enemy_9016_acstmr": [8,6,0,0,182]
|
||||
},
|
||||
"hooks": [1,91,6000,981,982,3131,3322,9373,67,69,109,108,4,28792,692,18,35,0,1,60,0,4,3],
|
||||
"events": {"atk":1134,"deploy":56,"die":69,"dmg":3309,"engage":17,"fx":717,"heal":46,"skill":217,"spawn":57,"status":920},
|
||||
"hooks": [1,92,6000,989,994,3078,3208,8581,68,69,111,105,5,28677,1173,11,36,0,1,57,0,6,9],
|
||||
"events": {"atk":1147,"deploy":56,"die":69,"dmg":3196,"engage":18,"fx":675,"heal":44,"skill":216,"spawn":58,"status":984},
|
||||
"fx": {
|
||||
"aoe": 33,
|
||||
"appear": 9,
|
||||
"beam": 48,
|
||||
"blink": 14,
|
||||
"burst": 4,
|
||||
"charge": 14,
|
||||
"counter": 4,
|
||||
"crit": 87,
|
||||
"dilemma": 3,
|
||||
"disappear": 9,
|
||||
"dodge": 3,
|
||||
"explode": 8,
|
||||
"explosion": 13,
|
||||
"frostNova": 11,
|
||||
"iceSpike": 4,
|
||||
"overclock": 2,
|
||||
"palsy": 2,
|
||||
"phase": 4,
|
||||
"pull": 2,
|
||||
"quadShot": 3,
|
||||
"revive": 2,
|
||||
"ripple": 10,
|
||||
"sentry": 4,
|
||||
"sermon": 1,
|
||||
"shadowWeave": 10,
|
||||
"aoe": 57,
|
||||
"appear": 5,
|
||||
"beam": 45,
|
||||
"blink": 16,
|
||||
"burst": 6,
|
||||
"charge": 8,
|
||||
"counter": 5,
|
||||
"crit": 75,
|
||||
"dilemma": 4,
|
||||
"disappear": 5,
|
||||
"displace": 4,
|
||||
"dodge": 9,
|
||||
"explode": 11,
|
||||
"explosion": 6,
|
||||
"frostNova": 17,
|
||||
"iceSpike": 14,
|
||||
"overclock": 1,
|
||||
"palsy": 1,
|
||||
"phase": 2,
|
||||
"pull": 1,
|
||||
"quadShot": 2,
|
||||
"revive": 1,
|
||||
"ripple": 15,
|
||||
"sentry": 3,
|
||||
"sermon": 2,
|
||||
"shadowWeave": 12,
|
||||
"shell": 8,
|
||||
"shield": 4,
|
||||
"shield": 3,
|
||||
"slippery": 3,
|
||||
"soul": 4,
|
||||
"sp": 10,
|
||||
"spGain": 10,
|
||||
"stealth": 5,
|
||||
"soul": 6,
|
||||
"sp": 8,
|
||||
"spGain": 2,
|
||||
"stealth": 6,
|
||||
"summon": 4,
|
||||
"swordRain": 113,
|
||||
"swordStorm": 8,
|
||||
"swordRain": 130,
|
||||
"swordStorm": 11,
|
||||
"telegraph": 6,
|
||||
"tornado": 2,
|
||||
"tornadoPulse": 10,
|
||||
"weightless": 4,
|
||||
"zone": 222
|
||||
"tornado": 1,
|
||||
"tornadoPulse": 5,
|
||||
"weightless": 3,
|
||||
"zone": 152
|
||||
},
|
||||
"snaps": "d02c996b 56caaf8f 98ada11d caf314f0 6d1e9373 60b5c4bc c0eeef76 df8917c9 ab98ad25 ff44ca0f 82bc2afa d48662e7 63ccec80 984bb208 6b10ba88 ccb1051b cca27d8b ba01f70a f5c4f257 da196656"
|
||||
"snaps": "d02c996b bf196ad8 6d4d8184 70be9a2b 4bb3bf6a aa3de044 80e4d293 1ec6c7a8 38342de7 2db02069 4ad38abc 4dd8ef16 5e433b8a 0f842362 4ae4d533 100f2e9e 8fbb7d4c 5f28d85e a6990f85 440ef24a"
|
||||
},
|
||||
"hidden-boss_8-solo": {
|
||||
"about": "假想敌:胄 solo pool 7200000: chess_char_4_17_b chess_char_4_18_b chess_char_4_23_b chess_char_4_19_b chess_char_4_22_b chess_char_4_20_b chess_char_4_21_b chess_char_4_24_b chess_char_4_25_b chess_char_4_26_b",
|
||||
@@ -1192,36 +1190,36 @@
|
||||
"killed": 3,
|
||||
"total": 3,
|
||||
"unspawned": 0,
|
||||
"bossHpLeft": 1849908,
|
||||
"rngDraws": 78,
|
||||
"bossHpLeft": 1836987,
|
||||
"rngDraws": 90,
|
||||
"errors": {"count":0,"kinds":[]},
|
||||
"players": {"p1":{"killed":3,"total":3,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":3002745,"bossDmg":950092,"heal":61752,"deaths":3,"layerGains":{}}},
|
||||
"players": {"p1":{"killed":3,"total":3,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":3042226,"bossDmg":963013,"heal":64860,"deaths":3,"layerGains":{}}},
|
||||
"units": [
|
||||
["p1",1,"op","chess_char_5_08_b",2,"uniequip_002_horn",3052.5,1,1,0,476949,9572,0,109,4],
|
||||
["p1",2,"op","chess_char_5_11_b",1,"uniequip_002_demkni",5285.28,1,3,0,769,1695,18201,17,32],
|
||||
["p1",3,"op","chess_char_5_05_b",2,"uniequip_002_ulpia",8327.72,1,27,0,197729,21991,14245,30,3],
|
||||
["p1",4,"op","chess_char_5_06_b",2,"uniequip_002_etlchi",4121.7,1,7,3,1449449,10784,10860,189,5],
|
||||
["p1",5,"op","chess_char_5_13_b",1,"uniequip_002_ghost2",3793.81,1,23,0,90247,12807,2014,38,2],
|
||||
["p1",6,"op","chess_char_5_07_b",2,"uniequip_002_surtr",8329.03,1,30,0,47939,16562,10068,16,3],
|
||||
["p1",7,"op","chess_char_5_02_b",2,"uniequip_002_titi",1582.9,1,5,0,173222,0,1141,26,1],
|
||||
["p1",8,"op","chess_char_5_03_b",2,"uniequip_002_blaze2",1570.62,1,6,0,261744,1667,1618,126,5],
|
||||
["p1",9,"op","chess_char_5_10_b",2,"uniequip_002_lisa",1628,1,19,0,11649,6861,3605,32,4],
|
||||
["p1",10,"op","chess_char_5_12_b",0,"uniequip_002_dusk",1965.7,1,2,0,293047,0,0,55,43],
|
||||
["p1","~1","token","token_10039_ulpia_block",null,null,3445,0,25,0,0,55,0,0,null]
|
||||
["p1",1,"op","chess_char_5_08_b",2,"uniequip_002_horn",3052.5,1,1,0,485565,9572,0,110,4],
|
||||
["p1",2,"op","chess_char_5_11_b",1,"uniequip_002_demkni",5285.28,1,3,0,976,1715,18804,21,33],
|
||||
["p1",3,"op","chess_char_5_05_b",2,"uniequip_002_ulpia",8327.72,1,27,0,194374,22994,14665,30,3],
|
||||
["p1",4,"op","chess_char_5_06_b",2,"uniequip_002_etlchi",4121.7,1,7,3,1502013,10613,10689,188,5],
|
||||
["p1",5,"op","chess_char_5_13_b",1,"uniequip_002_ghost2",3224.69,1,23,0,90135,13221,2565,39,2],
|
||||
["p1",6,"op","chess_char_5_07_b",2,"uniequip_002_surtr",8408.39,1,30,0,41860,16626,11246,15,3],
|
||||
["p1",7,"op","chess_char_5_02_b",2,"uniequip_002_titi",1582.9,1,5,0,164732,0,1669,26,1],
|
||||
["p1",8,"op","chess_char_5_03_b",2,"uniequip_002_blaze2",1570.62,1,6,0,258919,1667,1618,126,5],
|
||||
["p1",9,"op","chess_char_5_10_b",2,"uniequip_002_lisa",1628,1,19,0,3326,6861,3605,32,4],
|
||||
["p1",10,"op","chess_char_5_12_b",0,"uniequip_002_dusk",1965.7,1,2,0,300325,0,0,54,42],
|
||||
["p1","~1","token","token_10039_ulpia_block",null,null,3450,0,25,0,0,50,0,0,null]
|
||||
],
|
||||
"enemies": {
|
||||
"enemy_10067_ftsjc": [1,1,0,1161,21565],
|
||||
"enemy_5601_entlec": [6,5,0,0,195393],
|
||||
"enemy_5601_entlec": [6,5,0,0,192872],
|
||||
"enemy_9007_acelem": [2,2,0,843,119439],
|
||||
"enemy_9017_achunt_2": [1,0,0,26511,950092],
|
||||
"enemy_9018_actrpa": [1,0,0,13528,521917],
|
||||
"enemy_9019_actrpb": [1,0,0,11378,495636],
|
||||
"enemy_9020_actrpc": [1,0,0,9816,698702]
|
||||
"enemy_9017_achunt_2": [1,0,0,27050,963013],
|
||||
"enemy_9018_actrpa": [1,0,0,14222,535267],
|
||||
"enemy_9019_actrpb": [1,0,0,10981,495224],
|
||||
"enemy_9020_actrpc": [1,0,0,10459,714846]
|
||||
},
|
||||
"hooks": [1,31,6000,736,736,2859,7301,4071,11,14,102,98,89,23761,2136,8,13,0,1,60,0,7,0],
|
||||
"events": {"atk":1173,"deploy":18,"die":14,"dmg":6843,"engage":10,"fx":824,"heal":298,"skill":200,"spawn":24,"status":370},
|
||||
"fx": {"anchor":3,"aoe":3,"beam":21,"burst":7,"candle":6,"charge":7,"dash":12,"dollEnd":1,"downed":1,"ember":2,"explode":1,"healAoe":32,"link":597,"meltdown":2,"overload":4,"phase":11,"revive":1,"shieldBreak":10,"splash":89,"substitute":2,"swap":1,"teleport":2,"wake":9},
|
||||
"snaps": "a908aa8e 92af617b 47569ab8 b6349ee0 6a67acc6 34c7150b e9dd0fa5 d0b8a47d dbb7a067 d4bfa101 8208a605 2015078d a23dde6d e5181a02 01a93ffe 4842918b 44122ec7 02bb5829 0ec1885e faaabcfa"
|
||||
"hooks": [1,31,6000,737,737,2895,7352,4277,11,14,102,98,89,23822,2082,10,13,0,1,58,0,6,0],
|
||||
"events": {"atk":1180,"deploy":18,"die":14,"dmg":6900,"engage":10,"fx":825,"heal":308,"skill":200,"spawn":24,"status":362},
|
||||
"fx": {"anchor":3,"aoe":3,"beam":22,"burst":6,"candle":6,"charge":7,"dash":12,"dollEnd":1,"downed":1,"ember":2,"explode":1,"healAoe":33,"link":597,"meltdown":1,"overload":4,"palsy":1,"phase":11,"revive":1,"shieldBreak":10,"splash":89,"substitute":2,"swap":1,"teleport":2,"wake":9},
|
||||
"snaps": "a908aa8e 47086a5b ae11e298 9cf2c3e8 58fb69ef 2137945c 95f527fb e578cf06 1d999f5b 03ef509d 922f4e89 45ae69ae d5aee084 f310ee44 51e2ba61 1e0729b8 bca314e5 c2dd8505 976be9bc be1a9f9f"
|
||||
},
|
||||
"hidden-boss_10-pair": {
|
||||
"about": "假想敌:管 pair pool 15200000: chess_char_5_19_b chess_char_4_01_b chess_char_5_14_b chess_char_5_17_b chess_char_5_15_b chess_char_5_20_b chess_char_5_21_b chess_char_5_22_b chess_char_5_23_b chess_char_4_02_b | chess_char_4_11_b chess_char_4_04_b chess_char_4_07_b chess_char_4_16_b chess_char_4_09_b chess_char_4_06_b chess_char_4_10_b chess_char_4_12_b chess_char_4_13_b chess_char_4_14_b token_10041_cathy_catsld token_10041_cathy_catsld",
|
||||
|
||||
+226
-226
File diff suppressed because it is too large.
Load diff
+378
-378
File diff suppressed because it is too large.
Load diff
+57
-57
@@ -303,49 +303,49 @@
|
||||
"round": 10,
|
||||
"seed": 1442657168,
|
||||
"waveId": "act1autochess_h03",
|
||||
"time": 187.4,
|
||||
"ticks": 5622,
|
||||
"time": 188.1,
|
||||
"ticks": 5643,
|
||||
"reason": "cleared",
|
||||
"killed": 30,
|
||||
"killed": 26,
|
||||
"total": 98,
|
||||
"unspawned": 0,
|
||||
"bossHpLeft": null,
|
||||
"rngDraws": 315,
|
||||
"rngDraws": 260,
|
||||
"errors": {"count":0,"kinds":[]},
|
||||
"players": {
|
||||
"p1": {"killed":30,"total":98,"perfect":false,"leaks":68,"leaksUncounted":0,"coins":6,"dmg":472224,"bossDmg":0,"heal":15707,"deaths":31,"layerGains":{"raidShip":24}}
|
||||
"p1": {"killed":26,"total":98,"perfect":false,"leaks":72,"leaksUncounted":0,"coins":2,"dmg":365014,"bossDmg":0,"heal":14451,"deaths":30,"layerGains":{"raidShip":26}}
|
||||
},
|
||||
"units": [
|
||||
["p1",1,"op","chess_char_3_12_b",2,"none",1904,1,139,4,33527,4810,222,6,1],
|
||||
["p1",2,"op","chess_char_4_09_b",0,"none",0,0,6,0,13369,3510,0,5,1],
|
||||
["p1",3,"op","chess_char_3_05_b",2,"none",0,0,90,1,32273,11344,0,20,2],
|
||||
["p1",4,"op","chess_char_3_18_b",2,"none",0,0,12,1,32236,5744,2553,22,1],
|
||||
["p1",5,"op","chess_char_4_04_b",1,"none",0,0,138,0,13918,12430,0,38,9],
|
||||
["p1",6,"op","chess_char_4_07_b",1,"none",0,0,93,0,13096,7538,0,19,1],
|
||||
["p1",7,"op","chess_char_4_16_b",1,"none",0,0,137,3,45194,14837,0,51,13],
|
||||
["p1",8,"op","chess_char_3_01_b",2,"none",0,0,132,6,26811,3020,0,27,1],
|
||||
["p1",9,"op","chess_char_3_04_b",2,"none",0,0,4,0,0,3135,12932,15,1],
|
||||
["p1",10,"op","chess_char_3_21_b",2,"none",0,0,113,10,201155,6858,0,55,1],
|
||||
["p1",11,"op","chess_char_4_02_b",1,"none",0,0,2,2,47025,1483,0,25,1],
|
||||
["p1",12,"op","chess_char_4_13_b",1,"none",0,0,101,3,13621,2704,0,23,1]
|
||||
["p1",1,"op","chess_char_3_12_b",2,"none",0,0,141,4,34546,8030,567,13,2],
|
||||
["p1",2,"op","chess_char_4_09_b",0,"none",0,0,6,1,4723,3510,0,2,1],
|
||||
["p1",3,"op","chess_char_3_05_b",2,"none",0,0,95,2,10329,8035,0,8,1],
|
||||
["p1",4,"op","chess_char_3_18_b",2,"none",0,0,107,1,28674,9956,3574,31,2],
|
||||
["p1",5,"op","chess_char_4_04_b",1,"none",1181,1,143,0,23896,8829,0,32,9],
|
||||
["p1",6,"op","chess_char_4_07_b",1,"none",0,0,92,0,10794,10491,0,13,1],
|
||||
["p1",7,"op","chess_char_4_16_b",1,"none",1370,1,142,5,49422,13987,0,51,16],
|
||||
["p1",8,"op","chess_char_3_01_b",2,"none",0,0,126,3,12357,3020,0,28,1],
|
||||
["p1",9,"op","chess_char_3_04_b",2,"none",0,0,4,0,0,1374,10311,10,1],
|
||||
["p1",10,"op","chess_char_3_21_b",2,"none",0,0,5,8,161347,7970,0,41,1],
|
||||
["p1",11,"op","chess_char_4_02_b",1,"none",0,0,2,1,21755,1483,0,24,1],
|
||||
["p1",12,"op","chess_char_4_13_b",1,"none",0,0,93,1,7170,2704,0,14,1]
|
||||
],
|
||||
"enemies": {
|
||||
"enemy_10162_mnctpt": [1,0,1,12236,28023],
|
||||
"enemy_1019_jshoot": [1,1,0,769,4479],
|
||||
"enemy_1019_jshoot_2": [1,1,0,1779,6718],
|
||||
"enemy_1021_bslime": [1,1,0,2095,6121],
|
||||
"enemy_1023_jmage": [1,1,0,2403,5474],
|
||||
"enemy_1025_reveng": [1,1,0,0,12442],
|
||||
"enemy_1026_aghost": [1,1,0,0,9456],
|
||||
"enemy_10162_mnctpt": [1,0,1,24498,31642],
|
||||
"enemy_1019_jshoot": [1,1,0,1057,4479],
|
||||
"enemy_1019_jshoot_2": [1,1,0,1489,6718],
|
||||
"enemy_1021_bslime": [1,1,0,0,6121],
|
||||
"enemy_1023_jmage": [1,0,1,1739,4719],
|
||||
"enemy_1025_reveng": [1,0,1,0,0],
|
||||
"enemy_1026_aghost": [1,0,1,0,0],
|
||||
"enemy_1043_zomsbr": [1,0,1,0,0],
|
||||
"enemy_1251_lysyta_2": [57,18,39,8755,173039],
|
||||
"enemy_1252_lysytb_2": [21,5,16,29535,174396],
|
||||
"enemy_1329_cbshld_2": [12,1,11,19842,52076]
|
||||
"enemy_1251_lysyta_2": [57,19,38,10100,171673],
|
||||
"enemy_1252_lysytb_2": [21,3,18,22220,98462],
|
||||
"enemy_1329_cbshld_2": [12,1,11,18287,41199]
|
||||
},
|
||||
"hooks": [1,139,5622,474,474,678,694,42,61,138,33,33,0,6117,24,44,98,68,1,66,3,0,7],
|
||||
"events": {"atk":549,"bounty":3,"deploy":41,"die":70,"dmg":694,"engage":11,"fx":35,"heal":40,"layer":3,"leak":68,"skill":66,"spawn":110,"status":83},
|
||||
"fx": {"blink":9,"camouflage":1,"dodge":8,"dp":5,"explode":1,"garrison":3,"slash":8},
|
||||
"snaps": "be6d1821 33db37f0 a6ec1fa2 487058c3 2aef7534 379bf51a d87bcaba 6953291e ea8de47f e0cfe2ef c5d6d6f0 9fd5858c 99048d37 dfd46fb1 d86de307 637bf595 72c11ba9 adb364dc bb18d903"
|
||||
"hooks": [1,143,5643,434,434,611,621,53,56,141,37,36,0,5387,16,38,98,72,1,76,4,0,5],
|
||||
"events": {"atk":484,"bounty":1,"deploy":45,"die":69,"dmg":621,"engage":11,"fx":47,"heal":49,"layer":4,"leak":72,"skill":73,"spawn":110,"status":72},
|
||||
"fx": {"blink":13,"camouflage":1,"dodge":6,"dp":6,"explode":1,"garrison":4,"slash":16},
|
||||
"snaps": "be6d1821 a39efdfb 21249099 b87f9e0b e72ea2bf 19eea832 a489c558 7e2006d5 2678e482 1cfd1c39 6932b88d 1754c6ee bf334ae2 ab95ad5f 981485c7 305e6e06 6a8a10b3 af7b8b0c c92211d8"
|
||||
},
|
||||
"standin-07": {
|
||||
"about": "elite band 6 as stand-ins: chess_char_4_17_b→char_610_acfend chess_char_4_18_b→char_602_cdfend chess_char_5_05_b→char_602_cdfend chess_char_4_19_b→char_608_acpion chess_char_5_01_b→char_600_cpione chess_char_4_22_b→char_609_acguad chess_char_4_23_b→char_609_acguad chess_char_5_06_b→char_609_acguad chess_char_4_20_b→char_603_csnipe chess_char_4_24_b→char_604_ccast chess_char_5_02_b→char_604_ccast chess_char_5_03_b→char_612_accast",
|
||||
@@ -359,7 +359,7 @@
|
||||
"time": 223.2,
|
||||
"ticks": 6696,
|
||||
"reason": "cleared",
|
||||
"killed": 10,
|
||||
"killed": 11,
|
||||
"total": 80,
|
||||
"unspawned": 0,
|
||||
"bossHpLeft": null,
|
||||
@@ -367,13 +367,13 @@
|
||||
"errors": {"count":0,"kinds":[]},
|
||||
"players": {
|
||||
"p1": {
|
||||
"killed": 10,
|
||||
"killed": 11,
|
||||
"total": 80,
|
||||
"perfect": false,
|
||||
"leaks": 70,
|
||||
"leaks": 69,
|
||||
"leaksUncounted": 0,
|
||||
"coins": 4,
|
||||
"dmg": 144421,
|
||||
"dmg": 165203,
|
||||
"bossDmg": 0,
|
||||
"heal": 0,
|
||||
"deaths": 25,
|
||||
@@ -381,38 +381,38 @@
|
||||
}
|
||||
},
|
||||
"units": [
|
||||
["p1",1,"op","chess_char_4_17_b",1,"none",0,0,3,0,189,3211,0,1,1],
|
||||
["p1",2,"op","chess_char_4_18_b",1,"none",0,0,86,0,60,9306,0,2,1],
|
||||
["p1",1,"op","chess_char_4_17_b",1,"none",0,0,3,0,210,3211,0,2,1],
|
||||
["p1",2,"op","chess_char_4_18_b",1,"none",0,0,86,0,404,9306,0,2,1],
|
||||
["p1",3,"op","chess_char_5_05_b",2,"none",0,0,100,0,572,5816,0,1,1],
|
||||
["p1",4,"op","chess_char_4_19_b",1,"none",1847.63,1,110,1,466,3864,0,8,1],
|
||||
["p1",4,"op","chess_char_4_19_b",1,"none",1847.63,1,110,0,1427,3864,0,9,1],
|
||||
["p1",5,"op","chess_char_5_01_b",2,"none",1036.53,1,108,0,2197,3985,0,5,3],
|
||||
["p1",6,"op","chess_char_4_22_b",1,"none",3191,1,112,0,1090,14040,0,8,1],
|
||||
["p1",7,"op","chess_char_4_23_b",1,"none",0,0,71,0,3484,6865,0,8,1],
|
||||
["p1",6,"op","chess_char_4_22_b",1,"none",3191,1,112,0,1918,14040,0,9,1],
|
||||
["p1",7,"op","chess_char_4_23_b",1,"none",0,0,71,0,4833,6856,0,9,1],
|
||||
["p1",8,"op","chess_char_5_06_b",2,"uniequip_002_acguad",0,0,10,0,0,3820,0,0,0],
|
||||
["p1",9,"op","chess_char_4_20_b",1,"none",1255.06,1,109,2,15866,4785,0,30,1],
|
||||
["p1",10,"op","chess_char_4_24_b",1,"none",1007.59,1,111,1,5309,3072,0,18,1],
|
||||
["p1",11,"op","chess_char_5_02_b",2,"none",0,0,105,1,8684,4080,0,19,1],
|
||||
["p1",12,"op","chess_char_5_03_b",2,"uniequip_002_accast",0,0,4,5,106504,1749,0,10,1],
|
||||
["p1",9,"op","chess_char_4_20_b",1,"none",1255.06,1,109,2,17110,4785,0,31,1],
|
||||
["p1",10,"op","chess_char_4_24_b",1,"none",1007.59,1,111,0,5710,3072,0,20,1],
|
||||
["p1",11,"op","chess_char_5_02_b",2,"none",0,0,105,1,8810,4080,0,19,1],
|
||||
["p1",12,"op","chess_char_5_03_b",2,"uniequip_002_accast",0,0,4,8,122012,1749,0,10,1],
|
||||
["-","~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]
|
||||
],
|
||||
"enemies": {
|
||||
"enemy_1005_yokai_2": [18,4,14,3323,11182],
|
||||
"enemy_1041_lazerd_2": [24,3,21,42917,34367],
|
||||
"enemy_1042_frostd": [30,1,29,0,33047],
|
||||
"enemy_1045_hammer": [1,1,0,2515,15552],
|
||||
"enemy_1045_hammer_2": [1,0,1,5062,16522],
|
||||
"enemy_1046_agent": [1,1,0,57,3888],
|
||||
"enemy_1050_lslime": [1,0,1,8323,7921],
|
||||
"enemy_1061_zomshd": [1,0,1,61,13892],
|
||||
"enemy_1062_rager_2": [1,0,1,2335,17050],
|
||||
"enemy_1005_yokai_2": [18,4,14,3014,9642],
|
||||
"enemy_1041_lazerd_2": [24,4,20,41823,46999],
|
||||
"enemy_1042_frostd": [30,1,29,0,34310],
|
||||
"enemy_1045_hammer": [1,1,0,1699,15552],
|
||||
"enemy_1045_hammer_2": [1,0,1,4725,19083],
|
||||
"enemy_1046_agent": [1,1,0,72,3888],
|
||||
"enemy_1050_lslime": [1,0,1,9832,11882],
|
||||
"enemy_1061_zomshd": [1,0,1,92,15423],
|
||||
"enemy_1062_rager_2": [1,0,1,3328,18424],
|
||||
"enemy_1067_snslime": [1,0,1,0,0],
|
||||
"enemy_1071_dftman": [1,0,1,0,0]
|
||||
},
|
||||
"hooks": [1,112,6696,302,302,560,551,566,35,105,13,13,0,9244,8,13,80,70,1,35,2,0,9],
|
||||
"events": {"atk":326,"bounty":2,"deploy":30,"die":35,"dmg":551,"engage":11,"fx":25,"layer":2,"leak":70,"skill":26,"spawn":94,"status":113},
|
||||
"fx": {"aoe":1,"dodge":9,"dp":7,"garrison":2,"garrisonGrant":1,"palsy":2,"revive":3},
|
||||
"snaps": "801e90ec c1cca6cb 81d33420 f867f133 edacd763 1628b012 6c63447d 731a52ce e4d1fee0 1aa30026 5afb0d3a 3292f917 f65a695f 7f9749da 215a493b 0a35d6fd 8300297d 71eb2c83 2ae65721 93a77bf5 f413d9ea 3b2f4b8c ef24d1bf"
|
||||
"hooks": [1,112,6696,313,313,621,613,584,36,105,13,13,0,9350,10,16,80,69,1,36,2,0,8],
|
||||
"events": {"atk":340,"bounty":2,"deploy":30,"die":36,"dmg":613,"engage":11,"fx":22,"layer":2,"leak":69,"skill":26,"spawn":94,"status":119},
|
||||
"fx": {"aoe":1,"dodge":8,"dp":6,"garrison":2,"garrisonGrant":1,"palsy":1,"revive":3},
|
||||
"snaps": "801e90ec 5b00cfa7 02fcc988 d17a26a7 7d9490e3 7c20747e 258b5555 d69fd966 78a9129e f1c1c23d 005c3248 a96fccfa 482d2cd2 45b5fc41 ee00fc07 df8408ae 69422636 2d376e7f 1809c675 8e61a10d 5a971160 902deae3 ff470735"
|
||||
},
|
||||
"standin-08": {
|
||||
"about": "elite band 6 as stand-ins: chess_char_5_08_b→char_610_acfend chess_char_5_14_b→char_608_acpion chess_char_5_19_b→char_617_sharp2 chess_char_5_07_b→char_601_cguard chess_char_5_13_b→char_609_acguad chess_char_5_17_b→char_615_acspec chess_char_5_10_b→char_606_csuppo chess_char_5_11_b→char_613_acmedc chess_char_5_12_b→char_614_acsupo chess_char_5_15_b→char_603_csnipe chess_char_5_18_b→char_611_acnipe chess_char_5_20_b→char_614_acsupo",
|
||||
|
||||
@@ -91,6 +91,29 @@ test('build-i18n matching: exact, blackboard templates, plain from raw, composit
|
||||
assert.equal(shapeKey('攻击力<@ba.vup>+{atk:0%}</>'), shapeKey('攻击力+80%'));
|
||||
});
|
||||
|
||||
test('build-i18n corrections: tried before the official text, the plain twin is stripped from the corrected raw one', () => {
|
||||
const zh = [{ t: { a: '攻击力<@ba.vup>+50%</>' } }];
|
||||
const en = [{ t: { a: 'ATK <@ba.vup>+50%</> flat' } }];
|
||||
const data = { effects: { e1: { descRaw: '攻击力<@ba.vup>+50%</>', desc: '攻击力+50%' } } };
|
||||
const { overlay, report } = buildOverlay({ zh, en, data, corrections: { _about: 'x', '攻击力<@ba.vup>+50%</>': 'ATK <@ba.vup>+50%</> per item' } });
|
||||
const e = applyRecordOverlay(data.effects.e1, overlay.files.effects.e1).value;
|
||||
assert.equal(e.descRaw, 'ATK <@ba.vup>+50%</> per item');
|
||||
assert.equal(e.desc, 'ATK +50% per item');
|
||||
assert.equal(report.coverage.effects.correction, 2);
|
||||
assert.equal(report.coverage.effects.translated, 2);
|
||||
// another language reads its own corrections the same way (build-i18n loads tools/i18n/corrections-<lang>.json)
|
||||
const ko = buildOverlay({ zh, en: [{ t: { a: '공격력 <@ba.vup>+50%</>' } }], data, lang: 'ko', corrections: { '攻击力<@ba.vup>+50%</>': '장비 1개당 공격력 <@ba.vup>+50%</>' } });
|
||||
assert.equal(applyRecordOverlay(data.effects.e1, ko.overlay.files.effects.e1).value.descRaw, '장비 1개당 공격력 <@ba.vup>+50%</>');
|
||||
assert.equal(ko.report.coverage.effects.correction, 2);
|
||||
});
|
||||
|
||||
test('data/i18n/en.json: the 维多利亚 6-member ATK bonus reads per item, as installVictoria applies it (corrections-en.json)', () => {
|
||||
const en = JSON.parse(readFileSync(path.join(ROOT, 'data', 'i18n', 'en.json'), 'utf8'));
|
||||
for (const t of [en.files.bonds.victoriaShip.effectDesc, en.files.bonds.victoriaShip.effectDescRaw, en.files.effects.bondeffect_victoria.desc]) {
|
||||
assert.match(t, /\+50%(?:<\/>)? ATK per equipment carried, or (?:<@ba\.vup>)?\+80%(?:<\/>)? per upgraded equipment/);
|
||||
}
|
||||
});
|
||||
|
||||
test('data/i18n/en.json: shape, the 盟约 season of the EN build, coverage and the names map', () => {
|
||||
assert.equal(OVERLAY.version, 1);
|
||||
assert.equal(OVERLAY.lang, 'en');
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { makeMatch } from './harness.js';
|
||||
import { BOND_LAYER_CAP, PHASE } from '../../shared/constants.js';
|
||||
|
||||
const cues = h => h.sent.filter(([, msg]) => msg.t === 'm.layer');
|
||||
function prep(t) {
|
||||
const h = makeMatch({ mode: 'coop', humans: 2, fake: true }).start().toPrep(1);
|
||||
t.after(() => h.m.dispose());
|
||||
h.sent.length = 0;
|
||||
return h;
|
||||
}
|
||||
|
||||
test('prep gains unicast the actual increase once, even for an inactive bond', t => {
|
||||
const h = prep(t), p = h.ps('p_0');
|
||||
assert.equal(p.bonds.sargonShip?.active ?? false, false);
|
||||
assert.equal(p.addLayers('sargonShip', 3), 3);
|
||||
assert.deepEqual(cues(h), [['p_0', { t: 'm.layer', playerId: 'p_0', bondId: 'sargonShip', add: 3 }]]);
|
||||
assert.equal(h.bc.some(msg => msg.t === 'm.layer'), false);
|
||||
p.layers.sargonShip = BOND_LAYER_CAP - 1;
|
||||
p.addLayers('sargonShip', 10);
|
||||
assert.equal(cues(h).at(-1)[1].add, 1);
|
||||
const n = cues(h).length;
|
||||
for (const v of [1, 0, -2, NaN, Infinity]) p.addLayers('sargonShip', v);
|
||||
p.addLayers('not-a-bond', 2);
|
||||
p.addLayers('kjeragShip', 2, { requireActive: true });
|
||||
assert.equal(cues(h).length, n);
|
||||
});
|
||||
|
||||
test('only live prep phases emit, never settlement/backfill or resynchronization views', t => {
|
||||
const h = prep(t), p = h.ps('p_0');
|
||||
for (const phase of Object.values(PHASE)) {
|
||||
h.m.phase = phase; h.sent.length = 0;
|
||||
p.addLayers('sargonShip', 1);
|
||||
assert.equal(cues(h).length, [PHASE.ROUND_START, PHASE.SP_DRAFT, PHASE.PREP].includes(phase) ? 1 : 0, phase);
|
||||
}
|
||||
h.m.phase = PHASE.PREP; h.sent.length = 0;
|
||||
p.recompute(); p.privateView(); h.m.publicView();
|
||||
assert.equal(cues(h).length, 0);
|
||||
});
|
||||
@@ -264,9 +264,11 @@ test('Final Assault: two client-run pair fields share the server pool (b.progres
|
||||
h.drive(() => m.phase === PHASE.FINAL_ASSAULT);
|
||||
const [b1, b2] = fields(h);
|
||||
assert.deepEqual([b1.fieldId, b2.fieldId], ['b1', 'b2']);
|
||||
assert.deepEqual([b1.mode, b1.authority, b2.mode, b2.authority], ['client', 'p_0', 'client', 'p_2'], 'lowest seat of each pair');
|
||||
const authorities = [b1, b2].map(f => [...f.players].sort((a, b) => h.ps(a).seat - h.ps(b).seat)[0]);
|
||||
assert.deepEqual([b1.mode, b1.authority, b2.mode, b2.authority], ['client', authorities[0], 'client', authorities[1]], 'lowest seat of each pair');
|
||||
const startMsgs = h.sent.filter(([, x]) => x.t === 'b.start' && x.kind === 'boss');
|
||||
assert.deepEqual(startMsgs.map(([pid, x]) => `${pid}:${x.fieldId}:${x.authoritative ? 'A' : 'r'}`).sort(), ['p_0:b1:A', 'p_1:b1:r', 'p_2:b2:A', 'p_3:b2:r']);
|
||||
assert.deepEqual(startMsgs.map(([pid, x]) => `${pid}:${x.fieldId}:${x.authoritative ? 'A' : 'r'}`).sort(),
|
||||
[b1, b2].flatMap((f, i) => f.players.map(pid => `${pid}:${f.fieldId}:${pid === authorities[i] ? 'A' : 'r'}`)).sort());
|
||||
assert.equal(startMsgs[0][1].spec.boss.poolMax, m.bossPool.maxHp);
|
||||
const max = m.bossPool.maxHp;
|
||||
h.sched.advance(3000);
|
||||
@@ -597,7 +599,7 @@ test('boss bounty eligibility: forced real results on a team victory qualify; sy
|
||||
function bossPaid(seed, { leaks = 0, leaksBy = null, leaked = {} }) {
|
||||
const { h, m, f } = faWithForger(seed);
|
||||
const card = DATA.choices.cards.bounty.find((c) => c.payout === 'perfect' && m.gd.enemy(c.enemyKey));
|
||||
const ps = f.players.map((pid) => h.ps(pid));
|
||||
const ps = f.players.map((pid) => h.ps(pid)).sort((a, b) => a.seat - b.seat); // return payouts by player, not random side
|
||||
for (const p of ps) m.addBounty(p, { ...card, rounds: 2, multiRound: false });
|
||||
const before = ps.map((p) => p.pendingFunds);
|
||||
const lp0 = m.teamLp;
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { DATA, makeMatch, give, checkInvariants } from './harness.js';
|
||||
import { GameData } from '../../server/match/gamedata.js';
|
||||
import { computeBonds } from '../../server/match/bondsMeta.js';
|
||||
|
||||
function emptyPrep() {
|
||||
const h = makeMatch({ mode: 'solo', difficulty: 'HARD', seed: 11, fake: true }).start().toPrep(1);
|
||||
const p = h.ps('p_0');
|
||||
for (const x of p.allChess()) p.returnCopies(x);
|
||||
p.board.clear(); p.hand.fill(null); p.temp.fill(null); p.offers.length = 0; p.bandId = null; p.recompute();
|
||||
return { h, m: h.m, p };
|
||||
}
|
||||
|
||||
for (const g of Object.values(DATA.garrisons).filter(g => g.effectKey === 'SERVER_GAIN_EQUIP')) {
|
||||
for (const owner of g.owners) test(`fixed gift ${g.garrisonId}/${owner} is produced past stock and still occupies it`, () => {
|
||||
const { m, p } = emptyPrep(), id = g.bbStr.chess, cap = m.itemPool.cap(id);
|
||||
assert.ok(Number.isInteger(cap));
|
||||
for (let i = 0; i < cap; i++) assert.ok(p.acquireItem(id));
|
||||
assert.equal(m.itemPool.left(id), 0);
|
||||
assert.ok(p.acquireChess(owner, { fromPool: false }));
|
||||
assert.equal(m.itemPool.held(id), cap + g.bb.count, 'the trait must finish all grants, including merges');
|
||||
assert.equal(m.itemPool.left(id), 0, 'overstock prevents new shop draws');
|
||||
assert.equal(p.acquireItem(id), null, 'an ordinary stock-backed grant is still refused');
|
||||
checkInvariants(m); m.dispose();
|
||||
});
|
||||
}
|
||||
|
||||
test('unlimited fixed-item gifts do not exempt unrelated strategy equipment from stock', () => {
|
||||
const { m, p } = emptyPrep(), id = 'chess_item_5_07_e_a';
|
||||
assert.ok(p.acquireItem(id)); assert.ok(p.acquireItem(id));
|
||||
assert.equal(p.acquireItem(id, { requirePool: false }), null, 'commercial packaging is not a fixed operator gift');
|
||||
assert.equal(m.itemPool.held(id), 2); checkInvariants(m); m.dispose();
|
||||
});
|
||||
|
||||
for (const invest of [false, true]) test(`repeated Muelsyse acquisition and elite gifts remain unlimited (investor=${invest})`, () => {
|
||||
const { m, p } = emptyPrep(), id = 'chess_item_6_09_e_a';
|
||||
if (invest) {
|
||||
const members = Object.values(DATA.chess).filter(c => c.visible && !c.isGolden && c.bonds.includes('investShip')).slice(0, 3);
|
||||
for (const c of members) give(m, p, c.chessId);
|
||||
}
|
||||
assert.equal(m.dispatcher.investRepeat(p), invest ? 2 : 1);
|
||||
const expected = invest ? [2, 4, 8, 10, 12] : [1, 2, 4, 5, 6];
|
||||
for (const n of expected) {
|
||||
assert.ok(p.acquireChess('chess_char_6_11_a'));
|
||||
assert.equal(m.itemPool.held(id), n); checkInvariants(m);
|
||||
// Stow the non-merging isomorphs on holders so this stock test does not exhaust the player's inventory.
|
||||
for (const item of [...p.hand, ...p.temp].filter(x => x?.kind === 'item')) {
|
||||
const holder = p.allChess().find(c => c.items.length < m.gd.equipPerChess);
|
||||
if (holder) assert.ok(p.equip(item.uid, holder.uid).ok);
|
||||
}
|
||||
}
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('Damazti strategy grants its fixed item at rounds 5/10/15 past cap; releasing overflow does not free stock early', () => {
|
||||
const { m, p } = emptyPrep(), id = 'chess_item_6_09_e_a';
|
||||
for (let i = 0; i < 3; i++) assert.ok(p.acquireItem(id));
|
||||
p.bandId = 'band_damaztic'; p.recompute();
|
||||
for (const [i, round] of [5, 10, 15].entries()) {
|
||||
m.round = round; m.dispatch(p, 'onRoundStart', { round });
|
||||
assert.equal(m.itemPool.held(id), 4 + i); checkInvariants(m);
|
||||
}
|
||||
const copy = () => [...p.hand, ...p.temp].find(x => x?.kind === 'item' && x.id === id);
|
||||
for (let i = 0; i < 3; i++) { assert.ok(p.destroy(copy().uid).ok); assert.equal(m.itemPool.left(id), 0); }
|
||||
assert.ok(p.destroy(copy().uid).ok);
|
||||
assert.equal(m.itemPool.held(id), 2); assert.equal(m.itemPool.left(id), 1);
|
||||
checkInvariants(m); m.dispose();
|
||||
});
|
||||
|
||||
for (const mu of ['chess_char_6_11_a', 'chess_char_6_11_b']) test(`seven distinct Yan operators plus Yan-converted ${mu} activate nine Yan`, () => {
|
||||
const gd = new GameData(DATA, 'mode_multi_hard');
|
||||
const seven = Object.values(DATA.chess).filter(c => c.visible && !c.isGolden && c.bonds.includes('yanShip')).slice(0, 7);
|
||||
assert.equal(seven.length, 7);
|
||||
const pieces = seven.map((c, i) => ({ uid: i + 1, kind: 'chess', id: c.chessId, items: [] }));
|
||||
pieces.push({ uid: 8, kind: 'chess', id: mu, items: [{ id: 'chess_item_6_09_e_a' }, { id: 'chess_item_3_04_e_a' }] });
|
||||
const state = { board: new Map(pieces.map((p, i) => [`${9 + i % 4},${3 + Math.floor(i / 4)}`, p])), hand: [], layers: {} };
|
||||
const b = computeBonds(gd, state);
|
||||
assert.equal(b.yanShip.count, 9); assert.equal(b.yanShip.tier, 3); assert.equal(b.yanShip.harmony, 1);
|
||||
pieces[7].items.pop();
|
||||
const withoutConversion = computeBonds(gd, state);
|
||||
assert.equal(withoutConversion.yanShip.count, 8); assert.equal(withoutConversion.yanShip.tier, 2);
|
||||
});
|
||||
Loaded 100 of 134 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user