0.2.4-dev: round-29 community fixes and the official shared card pool
- Shared card pool and item stock as in the official mode (the owner's request, from the 路标档案馆 video and public notes): solo low-tier exceptions, co-op operators x2, shared item stock with special-item exceptions, normal / advanced occupancy 1 / 2, re-check on buy, 机变 exhaustion, release on consume / destroy / elimination; 整备 never exceeds stock; invariant held <= cap. - 突袭: one taunt-first reselect right after a revive (the owner's rule); ordinary polls and redeploys keep the 0.2.3 rule; a revive on an enemy's tile stays. #475: online boss / hidden-boss Raid may land on the empty half (owner-limited scope). - 浊心斯卡蒂 S1 x 坚守 no longer bounce redirected damage, and the two shares conserve the hit (idea of PR #464 by @HappyTanuki). - Operator fixes adapted from PRs by @Xinglan233 (#459 哈洛德 S2 elemental trigger, #460 涤火杰西卡 shield on the four adjacent tiles, #461 家族徽章 first hit after stealth, #462 松果 S2 regrow per deploy); 克莱门莎 S2 splash 1.1 / 100% ATK and air auto-trigger (#467); 白面 copying 歌蕾蒂娅 / 余 skips the three-member condition (#473, the owner's video). - 香槟炸弹 is not chosen by friendly auras / heals (PR #468 / #476 by @Siornya); 琳琅诗怀雅 coin balance (PR #450 by @IceCodeNew); Touch prep display (PR #451 by @YinqiMargatroid); lobby header (PR #471 by @YuriRestia); Japanese UI terms (PR #448 by @scutkane). - AI-only boss countdown grace, render-failure cache / recovery without restarting a battle, device units kept on a same-field re-entry, DIY detail / skill previews, large-text result buttons. - golden: battle families equal 0.2.3 except diy-135 / diy-136 (克莱门莎); all 18 release matches move through the shared pool, every flip named in docs/history/0.2.4.md §29.12; two coverage scenarios added.
This commit is contained in:
1 parent
1db8e51023
commit
6bfc08270c
129 files changed
+3477
-1011
No files matched your search
@@ -1,5 +1,17 @@
|
||||
# 更新记录
|
||||
|
||||
## 0.2.4-dev — 未发布
|
||||
|
||||
- 按路标档案馆实验补齐单人/同盟干员库存差异与装备共享库存;商业包装方案为同时持有 2 份,不是整局限购两次。拟态物质按符合条件的干员身份抽取,超库存获得仍计入占用。
|
||||
- 克莱门莎二技能在扩展范围只有可选飞行目标时也会自动开启;补齐外勤医疗策略的准备阶段预备医疗显示,初始仇恨不变。
|
||||
|
||||
- 修复松果二技能重部署成长、哈洛德元素损伤开技、家族徽章首笔伤害,以及浊心斯卡蒂与坚守伤害转移循环。
|
||||
- 按确认规则修正突袭复活选敌、白面鸮复制余/歌蕾蒂娅、杰西卡盾牌相邻四格;Boss 额外扣血宽限仅保留给有 AI 队友的队伍。
|
||||
- 联机 Boss/隐藏 Boss 战中,突袭可以跳到空半场的正常部署格;手牌区、临时区、单人模式及单人联防的空半场仍禁止落点(#475)。
|
||||
- 根据更新后的资料修正克莱门莎二技能溅射;补齐香槟炸弹原皮与琳琅诗怀雅技能金币显示。
|
||||
- 自选编队复用调配详情,优化大厅标题、大字结算页、日语词汇,并修正自选/补位干员的伤害公告名称。
|
||||
- 临时资源加载失败后可尝试恢复渲染;保留主动选择的平面模式。
|
||||
|
||||
## 0.2.3 — 2026-10-10
|
||||
|
||||
### 新内容
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
《明日方舟》季节性自走棋塔防玩法「卫戍协议:盟约」的**非官方同人复刻**:浏览器即开即玩,单人或 1–4 人联机合作。
|
||||
|
||||

|
||||
> 开发版(dev 分支):不稳定,请勿用于公开服务器。正式版请从 Releases 获取。
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
@@ -37,7 +39,7 @@ English summary: [below](#english).
|
||||
|
||||
- **独立模拟**(单人)与**同盟模拟**(1–4 人**合作**,没有 PvP;空位可以加 AI 队友)。
|
||||
- 服务器是一个 Node.js 程序,**战斗在各玩家的浏览器里模拟**(和官方一样),服务器只管经济与回合,一台低功耗小主机就能开服。
|
||||
- 当前版本 0.2.3:自选编队新增六星克莱门莎,黍和乌尔比安新增模组;新增文字大小、逐干员语音语言、添加到桌面、开局重刷和恢复本机对局,并修复了 0.2.2 发布后玩家和 GitHub 上反馈的问题,详见 [CHANGELOG.md](CHANGELOG.md)。仍有少数规则按推断实现,与官方不一致的地方欢迎在 Issue 里反馈。
|
||||
- 当前开发版 0.2.4-dev:社区修复与界面改进待审核。最近发布版 0.2.3:自选编队新增六星克莱门莎,黍和乌尔比安新增模组;新增文字大小、逐干员语音语言、添加到桌面、开局重刷和恢复本机对局,并修复了 0.2.2 发布后玩家和 GitHub 上反馈的问题,详见 [CHANGELOG.md](CHANGELOG.md)。仍有少数规则按推断实现,与官方不一致的地方欢迎在 Issue 里反馈。
|
||||
|
||||
## 功能一览
|
||||
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"version":1,"hash":"cf275d509d29","generator":"tools/fetch-assets.mjs","stats":{"files":10956,"bytes":581063469,"chars":210,"charsWithBack":207,"enemies":252,"enemiesWithSpine":251,"tokens":58,"tokensWithSpine":13,"spineModels":673,"bonds":23,"items":59,"bands":40,"skills":525,"modules":131,"ui":602,"sfxUnits":484,"voiceChars":192,"voiceJpChars":192},"chars":{"char_003_kalts":{"avatar":"/assets/char/avatar/char_003_kalts.png","avatarE2":"/assets/char/avatar/char_003_kalts_2.png","portrait":"/assets/char/portrait/char_003_kalts_1.png","portraitE2":"/assets/char/portrait/char_003_kalts_2.png","spine":{"front":{"skel":"/assets/spine/op/char_003_kalts/front/char_003_kalts.skel","atlas":"/assets/spine/op/char_003_kalts/front/char_003_kalts.atlas","textures":["/assets/spine/op/char_003_kalts/front/char_003_kalts.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Attack","end":null},"attackDown":null,"skill":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":0,"idle":null},"die":"Die","move":null,"stun":null,"skills":{"0":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":0,"idle":null},"1":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":1,"idle":null},"2":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":2,"idle":null}}},"animations":{"Attack":1,"Default":0,"Die":1,"Idle":9,"Start":1},"events":["OnAttack","OnStart"],"hits":{"Attack":[0.433]},"bounds":{"x":-227.579,"y":-138.244,"width":532.04,"height":551.479}},"back":{"skel":"/assets/spine/op/char_003_kalts/back/char_003_kalts.skel","atlas":"/assets/spine/op/char_003_kalts/back/char_003_kalts.atlas","textures":["/assets/spine/op/char_003_kalts/back/char_003_kalts.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Attack","end":null},"attackDown":null,"skill":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":0,"idle":null},"die":null,"move":null,"stun":null,"skills":{"0":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":0,"idle":null},"1":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":1,"idle":null},"2":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":2,"idle":null}}},"animations":{"Attack":1,"Default":0,"Idle":9,"Start":1},"events":["OnAttack","OnStart"],"hits":{"Attack":[0.433]},"bounds":{"x":410.785,"y":-2.26,"width":531.785,"height":416.492}}}},"char_010_chen":{"avatar":"/assets/char/avatar/char_010_chen.png","avatarE2":"/assets/char/avatar/char_010_chen_2.png","portrait":"/assets/char/portrait/char_010_chen_1.png","portraitE2":"/assets/char/portrait/char_010_chen_2.png","spine":{"front":{"skel":"/assets/spine/op/char_010_chen/front/char_010_chen.skel","atlas":"/assets/spine/op/char_010_chen/front/char_010_chen.atlas","textures":["/assets/spine/op/char_010_chen/front/char_010_chen.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Attack","end":null},"attackDown":null,"skill":{"begin":null,"loop":"Skill","end":"Skill_End","index":0,"idle":null},"die":"Die","move":null,"stun":null,"skills":{"0":{"begin":null,"loop":"Skill","end":"Skill_End","index":0,"idle":null},"1":{"begin":null,"loop":"Skill_2","end":null,"index":1,"idle":null},"2":{"begin":null,"loop":"Skill_3","end":null,"index":2,"idle":null}}},"animations":{"Attack":1.5,"Attack_End":0.6,"Attack_Pre":0.6,"Default":0,"Die":1,"Idle":2,"Skill":1,"Skill_2":1.167,"Skill_3":2.933,"Skill_End":0.6,"Skill_End_2":0.6,"Skill_End_3":0.3,"Start":0.833},"events":["OnAttack","OnStart"],"hits":{"Attack":[0.433,1],"Skill":[0.467],"Skill_2":[0.467],"Skill_3":[0.6,0.8,1,1.2,1.4,1.6,1.8,2,2.2,2.833,2.933]},"bounds":{"x":-322.335,"y":-109.274,"width":663.549,"height":524.806}},"back":{"skel":"/assets/spine/op/char_010_chen/back/char_010_chen.skel","atlas":"/assets/spine/op/char_010_chen/back/char_010_chen.atlas","textures":["/assets/spine/op/char_010_chen/back/char_010_chen.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Attack","end":null},"attackDown":null,"skill":{"begin":null,"loop":"Skill","end":"Skill_End","index":0,"idle":null},"die":null,"move":null,"stun":null,"skills":{"0":{"begin":null,"loop":"Skill","end":"Skill_End","index":0,"idle":null},"1":{"begin":null,"loop":"Skill","end":"Skill_End","index":1,"idle":null},"2":{"begin":null,"loop":"Skill","end":"Skill_End","index":2,"idle":null}}},"animations":{"Attack":1.5,"Attack_End":0.6,"Attack_Pre":0.6,"Default":0,"Idle":2,"Skill":1,"Skill_End":0.6,"Start":0.833},"events":["OnAttack","OnStart"],"hits":{"Attack":[0.433,1],"Skill":[0.467]},"bounds":{"x":-173.082,"y":-13.829,"width":315.887,"height":404.623}}}},"char_017_huang":{"avatar":"/assets/char/avatar/char_017_huang.png","avatarE2":"/assets/char/avatar/char_017_huang_2.png","portrait":"/assets/char/portrait/char_017_huang_1.png","portraitE2":"/assets/char/portrait/char_017_huang_2.png","spine":{"front":{"skel":"/assets/spine/op/char_017_huang/frontLine truncated
|
||||
{"version":1,"hash":"2424fdc0a6b5","generator":"tools/fetch-assets.mjs","stats":{"files":10956,"bytes":581063469,"chars":210,"charsWithBack":207,"enemies":252,"enemiesWithSpine":251,"tokens":58,"tokensWithSpine":13,"spineModels":673,"bonds":23,"items":59,"bands":40,"skills":525,"modules":131,"ui":602,"sfxUnits":484,"voiceChars":192,"voiceJpChars":192},"chars":{"char_003_kalts":{"avatar":"/assets/char/avatar/char_003_kalts.png","avatarE2":"/assets/char/avatar/char_003_kalts_2.png","portrait":"/assets/char/portrait/char_003_kalts_1.png","portraitE2":"/assets/char/portrait/char_003_kalts_2.png","spine":{"front":{"skel":"/assets/spine/op/char_003_kalts/front/char_003_kalts.skel","atlas":"/assets/spine/op/char_003_kalts/front/char_003_kalts.atlas","textures":["/assets/spine/op/char_003_kalts/front/char_003_kalts.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Attack","end":null},"attackDown":null,"skill":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":0,"idle":null},"die":"Die","move":null,"stun":null,"skills":{"0":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":0,"idle":null},"1":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":1,"idle":null},"2":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":2,"idle":null}}},"animations":{"Attack":1,"Default":0,"Die":1,"Idle":9,"Start":1},"events":["OnAttack","OnStart"],"hits":{"Attack":[0.433]},"bounds":{"x":-227.579,"y":-138.244,"width":532.04,"height":551.479}},"back":{"skel":"/assets/spine/op/char_003_kalts/back/char_003_kalts.skel","atlas":"/assets/spine/op/char_003_kalts/back/char_003_kalts.atlas","textures":["/assets/spine/op/char_003_kalts/back/char_003_kalts.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Attack","end":null},"attackDown":null,"skill":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":0,"idle":null},"die":null,"move":null,"stun":null,"skills":{"0":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":0,"idle":null},"1":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":1,"idle":null},"2":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":2,"idle":null}}},"animations":{"Attack":1,"Default":0,"Idle":9,"Start":1},"events":["OnAttack","OnStart"],"hits":{"Attack":[0.433]},"bounds":{"x":410.785,"y":-2.26,"width":531.785,"height":416.492}}}},"char_010_chen":{"avatar":"/assets/char/avatar/char_010_chen.png","avatarE2":"/assets/char/avatar/char_010_chen_2.png","portrait":"/assets/char/portrait/char_010_chen_1.png","portraitE2":"/assets/char/portrait/char_010_chen_2.png","spine":{"front":{"skel":"/assets/spine/op/char_010_chen/front/char_010_chen.skel","atlas":"/assets/spine/op/char_010_chen/front/char_010_chen.atlas","textures":["/assets/spine/op/char_010_chen/front/char_010_chen.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Attack","end":null},"attackDown":null,"skill":{"begin":null,"loop":"Skill","end":"Skill_End","index":0,"idle":null},"die":"Die","move":null,"stun":null,"skills":{"0":{"begin":null,"loop":"Skill","end":"Skill_End","index":0,"idle":null},"1":{"begin":null,"loop":"Skill_2","end":null,"index":1,"idle":null},"2":{"begin":null,"loop":"Skill_3","end":null,"index":2,"idle":null}}},"animations":{"Attack":1.5,"Attack_End":0.6,"Attack_Pre":0.6,"Default":0,"Die":1,"Idle":2,"Skill":1,"Skill_2":1.167,"Skill_3":2.933,"Skill_End":0.6,"Skill_End_2":0.6,"Skill_End_3":0.3,"Start":0.833},"events":["OnAttack","OnStart"],"hits":{"Attack":[0.433,1],"Skill":[0.467],"Skill_2":[0.467],"Skill_3":[0.6,0.8,1,1.2,1.4,1.6,1.8,2,2.2,2.833,2.933]},"bounds":{"x":-322.335,"y":-109.274,"width":663.549,"height":524.806}},"back":{"skel":"/assets/spine/op/char_010_chen/back/char_010_chen.skel","atlas":"/assets/spine/op/char_010_chen/back/char_010_chen.atlas","textures":["/assets/spine/op/char_010_chen/back/char_010_chen.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Attack","end":null},"attackDown":null,"skill":{"begin":null,"loop":"Skill","end":"Skill_End","index":0,"idle":null},"die":null,"move":null,"stun":null,"skills":{"0":{"begin":null,"loop":"Skill","end":"Skill_End","index":0,"idle":null},"1":{"begin":null,"loop":"Skill","end":"Skill_End","index":1,"idle":null},"2":{"begin":null,"loop":"Skill","end":"Skill_End","index":2,"idle":null}}},"animations":{"Attack":1.5,"Attack_End":0.6,"Attack_Pre":0.6,"Default":0,"Idle":2,"Skill":1,"Skill_End":0.6,"Start":0.833},"events":["OnAttack","OnStart"],"hits":{"Attack":[0.433,1],"Skill":[0.467]},"bounds":{"x":-173.082,"y":-13.829,"width":315.887,"height":404.623}}}},"char_017_huang":{"avatar":"/assets/char/avatar/char_017_huang.png","avatarE2":"/assets/char/avatar/char_017_huang_2.png","portrait":"/assets/char/portrait/char_017_huang_1.png","portraitE2":"/assets/char/portrait/char_017_huang_2.png","spine":{"front":{"skel":"/assets/spine/op/char_017_huang/frontLine truncated
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"season":"act2autochess","seasonName":"卫戍协议:盟约","modes":{"mode_training_1":{"modeId":"mode_training_1","name":"入门协议","code":"AC-TR-1","sortId":1,"type":"LOCAL","difficulty":"TRAINING","inScope":false,"color":"#4ed8af","iconId":"mode_training_icon","backgroundId":"background_training","desc":"用以熟悉协议内容的模式","effectDescList":["·仅可使用预置的调配干员","·无奖励","·无法用于解锁策略"],"unlockText":null,"specialPhaseTime":150,"activeBondIds":["yanShip","sargonShip","victoriaShip","kjeragShip","lateranoShip","egirShip","siracusaShip","kazimierzShip","preciShip","swiftShip","skillfulShip","arcaneShip","steadShip","deputShip","visiShip","miraShip","investShip","raidShip","indomShip","maniShip","emptyShip","soloShip","suntShip"],"inactiveBondIds":[],"inactiveEnemyKeys":[],"lastRound":4,"bossRound":4,"hiddenRound":null,"rounds":{"1":{"template":"act1autochess_tr01","bossTemplates":null,"combatTimeLimit":40,"levelMaxPlayTime":40,"prepTime":999,"prepTimeData":999,"isSpPrepare":false,"isBoss":false,"isHidden":false,"bossOvertimeAfter":null},"2":{"template":"act1autochess_tr02","bossTemplates":null,"combatTimeLimit":45,"levelMaxPlayTime":45,"prepTime":999,"prepTimeData":999,"isSpPrepare":false,"isBoss":false,"isHidden":false,"bossOvertimeAfter":null},"3":{"template":"act1autochess_tr02","bossTemplates":null,"combatTimeLimit":45,"levelMaxPlayTime":45,"prepTime":999,"prepTimeData":999,"isSpPrepare":true,"isBoss":false,"isHidden":false,"bossOvertimeAfter":null},"4":{"template":null,"bossTemplates":{"boss_7":"act1autochess_tr04"},"combatTimeLimit":null,"levelMaxPlayTime":180,"prepTime":999,"prepTimeData":999,"isSpPrepare":false,"isBoss":true,"isHidden":false,"bossOvertimeAfter":150}},"spRounds":[3],"combatTimeLimit":{"1":40,"2":45,"3":45,"4":null},"enemyScale":{"1":{"atk":1,"hp":1,"speed":1,"assumed":true},"2":{"atk":1,"hp":1,"speed":1,"assumed":true},"3":{"atk":1,"hp":1,"speed":1,"assumed":true},"4":{"atk":1,"hp":1,"speed":1,"assumed":true}},"bossHpScale":{"bloodPointKey":null,"unaffectedByEnemyScale":true},"upgradePrices":[4,4,6,9,12],"maxShopLevel":6,"shopSlots":{"1":{"chess":3,"item":0},"2":{"chess":3,"item":0},"3":{"chess":3,"item":0},"4":{"chess":3,"item":0},"5":{"chess":3,"item":1},"6":{"chess":3,"item":1}},"levelTagColors":{"1":"#434343","2":"#626654","3":"#445760","4":"#615B74","5":"#6C5E41","6":"#5d341e"},"stages":["act1autochess_m01"],"bossWeights":{"boss_7":5},"hiddenBossWeights":{}},"mode_single_funny":{"modeId":"mode_single_funny","name":"标准模拟","code":"AC-1","sortId":2,"type":"SINGLE","difficulty":"FUNNY","inScope":true,"color":"#f6a329","iconId":"mode_funny_icon","backgroundId":"background_funny","desc":"时长较短的模拟训练","effectDescList":["·可以快速完成作战","·常规奖励"],"unlockText":"通关【入门协议】后解锁","specialPhaseTime":150,"activeBondIds":["yanShip","sargonShip","victoriaShip","kjeragShip","siracusaShip","preciShip","swiftShip","steadShip","deputShip","visiShip","indomShip","maniShip","emptyShip"],"inactiveBondIds":["lateranoShip","egirShip","kazimierzShip","skillfulShip","arcaneShip","miraShip","investShip","raidShip","soloShip","suntShip"],"inactiveEnemyKeys":["enemy_1112_emppnt","enemy_1207_sfji","enemy_1275_dwlock_2","enemy_1234_dsubrl_2","enemy_10122_uacann_2","enemy_1272_nhtank_2","enemy_9008_acbunn","enemy_1174_duholy_2","enemy_1355_mrfly_2","enemy_1197_sfshu_2","enemy_10067_ftsjc","enemy_1270_nhstlk_2","enemy_1283_sgkill","enemy_1175_dushdo_2","enemy_1254_lypa_2","enemy_1425_lrcmra_2","enemy_1329_cbshld_2","enemy_1072_dlancer"],"lastRound":9,"bossRound":9,"hiddenRound":null,"rounds":{"1":{"template":"act1autochess_01","bossTemplates":null,"combatTimeLimit":45,"levelMaxPlayTime":45,"prepTime":null,"prepTimeData":300,"isSpPrepare":false,"isBoss":false,"isHidden":false,"bossOvertimeAfter":null},"2":{"template":"act1autochess_02","bossTemplates":null,"combatTimeLimit":45,"levelMaxPlayTime":45,"prepTime":null,"prepTimeData":300,"isSpPrepare":false,"isBoss":false,"isHidden":false,"bossOvertimeAfter":null},"3":{"template":"act1autochess_03","bossTemplates":null,"combatTimeLimit":55,"levelMaxPlayTime":55,"prepTime":null,"prepTimeData":300,"isSpPrepare":false,"isBoss":false,"isHidden":false,"bossOvertimeAfter":null},"4":{"template":"act1autochess_04","bossTemplates":null,"combatTimeLimit":55,"levelMaxPlayTime":55,"prepTime":null,"prepTimeData":300,"isSpPrepare":false,"isBoss":false,"isHidden":false,"bossOvertimeAfter":null},"5":{"template":"act1autochess_05","bossTemplates":null,"combatTimeLimit":55,"levelMaxPlayTime":55,"prepTime":null,"prepTimeData":300,"isSpPrepare":false,"isBoss":false,"isHidden":false,"bossOvertimeAfter":null},"6":{"template":"act1autochess_06","bossTemplates":null,"combatTimeLimit":70,"levelMaxPlayTime":70,"prepTime":null,"prepTimeData":300,"isSpPrepare":false,"isBoss":false,"isHidden":false,"bossOvertimeAfter":null},"7":{"template":"act1autochess_07","bossTemplates":nuLine truncated
|
||||
{"season":"act2autochess","seasonName":"卫戍协议:盟约","modes":{"mode_training_1":{"modeId":"mode_training_1","name":"入门协议","code":"AC-TR-1","sortId":1,"type":"LOCAL","difficulty":"TRAINING","inScope":false,"color":"#4ed8af","iconId":"mode_training_icon","backgroundId":"background_training","desc":"用以熟悉协议内容的模式","effectDescList":["·仅可使用预置的调配干员","·无奖励","·无法用于解锁策略"],"unlockText":null,"specialPhaseTime":150,"activeBondIds":["yanShip","sargonShip","victoriaShip","kjeragShip","lateranoShip","egirShip","siracusaShip","kazimierzShip","preciShip","swiftShip","skillfulShip","arcaneShip","steadShip","deputShip","visiShip","miraShip","investShip","raidShip","indomShip","maniShip","emptyShip","soloShip","suntShip"],"inactiveBondIds":[],"inactiveEnemyKeys":[],"lastRound":4,"bossRound":4,"hiddenRound":null,"rounds":{"1":{"template":"act1autochess_tr01","bossTemplates":null,"combatTimeLimit":40,"levelMaxPlayTime":40,"prepTime":999,"prepTimeData":999,"isSpPrepare":false,"isBoss":false,"isHidden":false,"bossOvertimeAfter":null},"2":{"template":"act1autochess_tr02","bossTemplates":null,"combatTimeLimit":45,"levelMaxPlayTime":45,"prepTime":999,"prepTimeData":999,"isSpPrepare":false,"isBoss":false,"isHidden":false,"bossOvertimeAfter":null},"3":{"template":"act1autochess_tr02","bossTemplates":null,"combatTimeLimit":45,"levelMaxPlayTime":45,"prepTime":999,"prepTimeData":999,"isSpPrepare":true,"isBoss":false,"isHidden":false,"bossOvertimeAfter":null},"4":{"template":null,"bossTemplates":{"boss_7":"act1autochess_tr04"},"combatTimeLimit":null,"levelMaxPlayTime":180,"prepTime":999,"prepTimeData":999,"isSpPrepare":false,"isBoss":true,"isHidden":false,"bossOvertimeAfter":150}},"spRounds":[3],"combatTimeLimit":{"1":40,"2":45,"3":45,"4":null},"enemyScale":{"1":{"atk":1,"hp":1,"speed":1,"assumed":true},"2":{"atk":1,"hp":1,"speed":1,"assumed":true},"3":{"atk":1,"hp":1,"speed":1,"assumed":true},"4":{"atk":1,"hp":1,"speed":1,"assumed":true}},"bossHpScale":{"bloodPointKey":null,"unaffectedByEnemyScale":true},"upgradePrices":[4,4,6,9,12],"maxShopLevel":6,"shopSlots":{"1":{"chess":3,"item":0},"2":{"chess":3,"item":0},"3":{"chess":3,"item":0},"4":{"chess":3,"item":0},"5":{"chess":3,"item":1},"6":{"chess":3,"item":1}},"levelTagColors":{"1":"#434343","2":"#626654","3":"#445760","4":"#615B74","5":"#6C5E41","6":"#5d341e"},"stages":["act1autochess_m01"],"bossWeights":{"boss_7":5},"hiddenBossWeights":{}},"mode_single_funny":{"modeId":"mode_single_funny","name":"标准模拟","code":"AC-1","sortId":2,"type":"SINGLE","difficulty":"FUNNY","inScope":true,"color":"#f6a329","iconId":"mode_funny_icon","backgroundId":"background_funny","desc":"时长较短的模拟训练","effectDescList":["·可以快速完成作战","·常规奖励"],"unlockText":"通关【入门协议】后解锁","specialPhaseTime":150,"activeBondIds":["yanShip","sargonShip","victoriaShip","kjeragShip","siracusaShip","preciShip","swiftShip","steadShip","deputShip","visiShip","indomShip","maniShip","emptyShip"],"inactiveBondIds":["lateranoShip","egirShip","kazimierzShip","skillfulShip","arcaneShip","miraShip","investShip","raidShip","soloShip","suntShip"],"inactiveEnemyKeys":["enemy_1112_emppnt","enemy_1207_sfji","enemy_1275_dwlock_2","enemy_1234_dsubrl_2","enemy_10122_uacann_2","enemy_1272_nhtank_2","enemy_9008_acbunn","enemy_1174_duholy_2","enemy_1355_mrfly_2","enemy_1197_sfshu_2","enemy_10067_ftsjc","enemy_1270_nhstlk_2","enemy_1283_sgkill","enemy_1175_dushdo_2","enemy_1254_lypa_2","enemy_1425_lrcmra_2","enemy_1329_cbshld_2","enemy_1072_dlancer"],"lastRound":9,"bossRound":9,"hiddenRound":null,"rounds":{"1":{"template":"act1autochess_01","bossTemplates":null,"combatTimeLimit":45,"levelMaxPlayTime":45,"prepTime":null,"prepTimeData":300,"isSpPrepare":false,"isBoss":false,"isHidden":false,"bossOvertimeAfter":null},"2":{"template":"act1autochess_02","bossTemplates":null,"combatTimeLimit":45,"levelMaxPlayTime":45,"prepTime":null,"prepTimeData":300,"isSpPrepare":false,"isBoss":false,"isHidden":false,"bossOvertimeAfter":null},"3":{"template":"act1autochess_03","bossTemplates":null,"combatTimeLimit":55,"levelMaxPlayTime":55,"prepTime":null,"prepTimeData":300,"isSpPrepare":false,"isBoss":false,"isHidden":false,"bossOvertimeAfter":null},"4":{"template":"act1autochess_04","bossTemplates":null,"combatTimeLimit":55,"levelMaxPlayTime":55,"prepTime":null,"prepTimeData":300,"isSpPrepare":false,"isBoss":false,"isHidden":false,"bossOvertimeAfter":null},"5":{"template":"act1autochess_05","bossTemplates":null,"combatTimeLimit":55,"levelMaxPlayTime":55,"prepTime":null,"prepTimeData":300,"isSpPrepare":false,"isBoss":false,"isHidden":false,"bossOvertimeAfter":null},"6":{"template":"act1autochess_06","bossTemplates":null,"combatTimeLimit":70,"levelMaxPlayTime":70,"prepTime":null,"prepTimeData":300,"isSpPrepare":false,"isBoss":false,"isHidden":false,"bossOvertimeAfter":null},"7":{"template":"act1autochess_07","bossTemplates":nuLine truncated
|
||||
+5
-3
@@ -159,7 +159,7 @@ The research JSONs in `docs/research/` (03, 05, 07) define **which** ids are nee
|
||||
| Token Spine | fexli: the default model, or else the first skin variant (`spine/{tokenId}/{variant}/Spine/`) | `spine/token/{tokenId}/{stem}.*` |
|
||||
| Enemy Spine (PC build, premultiplied alpha) | isHarryh/Ark-Models `models_enemies/{key}/`, file names from `models_data.json` | `spine/enemy/{enemyId}/{stem}.*` |
|
||||
| Enemy Spine that no dump carries (灼热源石虫 / 炽焰源石虫) | the local client only (`tools/local-extract/extract.py ENEMY_SPINES`, optional); never downloaded and never required: an overlay of the web alias (`enemies[id].spineLocal`) | `local/spine/enemy/{enemyId}/{stem}.*` (listed in `data/local-assets.json`) |
|
||||
| Token Spine that no dump carries (39 summons: most 自选 summons, 凯瑟琳's 爬行号·防护单元, 凛御银灰's 风雪之眼) | the local client only (`tools/local-extract/extract.py TOKEN_SPINES`, optional); never downloaded and never required: an overlay (`tokens[id].spineLocal`; without it the avatar diamond) | `local/spine/token/{tokenId}/{stem}.*` (listed in `data/local-assets.json`) |
|
||||
| Original token Spine overlays (40 summons: most 自选 summons, 凯瑟琳's 爬行号·防护单元, 凛御银灰's 风雪之眼, 香槟炸弹) | local-client extraction (`tools/local-extract/extract.py TOKEN_SPINES`, optional); the original overlay is preferred, with any listed web model retained as fallback | `local/spine/token/{tokenId}/{stem}.*` (listed in `data/local-assets.json`) |
|
||||
| BGM | AA2 `voice` branch `audio/sound_beta_2/music/**` (大厅/休整期 `act1autochess`, 开战 `act13side/m_bat_kazimierz2_{1,2}` — 骑士之日 / 无畏者; the 开战 track follows the round: `_2` 无畏者 rounds 1–7, `_1` 骑士之日 from round 8) | `audio/bgm/{file}.mp3` |
|
||||
| SFX (UI, battle, per unit) | AA2 `voice` `audio/sound_beta_2/**`, mapped from `audio_data.json` banks | `audio/sfx/{same sub-path}.mp3` |
|
||||
| 干员战斗语音 | AA2 `voice` `audio/sound_beta_2/voice_cn/{charId}/cn_nn.mp3` (Chinese, `audio.voice`) and `voice/{charId}/cn_nn.mp3` (Japanese, `audio.voiceJp`: the same file names) — the lines `charword_table.json` lists (`placeType` = when the game plays one, `voiceAsset` = the path); `--voice-lang=jp|en|kr` fills `audio.voice` from `voice/`, `voice_en/`, `voice_kr/` instead | `audio/voice/cn/{charId}/{cn_nn}.mp3`, `audio/voice/jp/{charId}/{cn_nn}.mp3` (`audio/voice/{lang}/…` for another `--voice-lang`) |
|
||||
@@ -418,7 +418,7 @@ Other renderer rules from research 07 §5.4–5.5:
|
||||
"missing skel"), nor of 凯瑟琳's 爬行号·防护单元 and 凛御银灰's 风雪之眼, so they were drawn as the avatar diamond. The
|
||||
local client has them in its battle token packs (`pkgrps/btl_pfb_tokens_*.ab`, the Windows build carries all of them;
|
||||
the iOS build lacks `btl_pfb_tokens_0` and has ASTC pages), the same overlay as the enemies above:
|
||||
- `tools/local-extract/extract.py TOKEN_SPINES` (39 ids; `--only spine/token`) reads each token's battle prefab
|
||||
- `tools/local-extract/extract.py TOKEN_SPINES` (40 ids; `--only spine/token`) reads each token's battle prefab
|
||||
(`dyn/battle/prefabs/[uc]tokens/<id>.prefab`), takes the skeleton of its Front renderer — a directional token has
|
||||
Front / Back (/ Down) renderers, each with its own skeleton of the same name; the web tokens use Front too — and
|
||||
writes it to `public/assets/local/spine/token/{tokenId}/` like an enemy model (skeleton, sized `pma: true` atlas,
|
||||
@@ -441,7 +441,7 @@ Other renderer rules from research 07 §5.4–5.5:
|
||||
|
||||
### Other fallbacks
|
||||
|
||||
- **Emotes and 玩法说明 pages** (`public/js/data.js artUrls / nextArtUrl`, `ui/guide.js guideStage`): the local-client picture (`data/local-assets.json`) first, then the mirror copy (`ui['emoticon/…']`, `ui['guide/…']`), each tried in turn when one fails to load; when none is left — none listed, or every copy failed (for example data/assets.json lists the downloaded pages but the files are not on disk yet: a `git pull` and restart without setup) — the neutral emote glyph, and for a page the official tips text (`config.tips`). The rest of the local-client art (the 3D board, the official HUD sprites, module type icons, the two enemy models and the 39 token models above) is not downloaded: the client looks it up in `data/local-assets.json` only (most of the HUD sprites are on the mirror too, DESIGN §22.5); docs/DEPLOY.md §6 lists what falls back without it.
|
||||
- **Emotes and 玩法说明 pages** (`public/js/data.js artUrls / nextArtUrl`, `ui/guide.js guideStage`): the local-client picture (`data/local-assets.json`) first, then the mirror copy (`ui['emoticon/…']`, `ui['guide/…']`), each tried in turn when one fails to load; when none is left — none listed, or every copy failed (for example data/assets.json lists the downloaded pages but the files are not on disk yet: a `git pull` and restart without setup) — the neutral emote glyph, and for a page the official tips text (`config.tips`). The rest of the local-client art (the 3D board, the official HUD sprites, module type icons, the two enemy models and the 40 token models above) is not downloaded: the client looks it up in `data/local-assets.json` only (most of the HUD sprites are on the mirror too, DESIGN §22.5); docs/DEPLOY.md §6 lists what falls back without it.
|
||||
- **Tokens:**
|
||||
- Without an avatar, use `chars[owner].avatar` with a 召唤物 badge, or `prof.battlecard.token` — except 圣聆初雪's 保护目标(冻结状态) (PRTS 无头像; the frozen gate), drawn as a procedural ice diamond (`render/units.js ICE_TOKENS`).
|
||||
- Without a Spine (and without its local-client model, "Token models from the local client"), draw the avatar sprite with a bob tween.
|
||||
@@ -511,3 +511,5 @@ banks prefer START over ON, then plain/numeric order; this is a deterministic ap
|
||||
operator's runtime ability graph. Mixed-letter banks are skipped. Preserve `mixOf` for each selected bank separately;
|
||||
`attacks`/`hits` and `attackMix`/`hitMix` use matching skill keys. Regenerate the current manifest instead of importing
|
||||
an older PR's full JSON, preserving JP voices and current operators. See DESIGN §28.17.
|
||||
|
||||
The 40th local token model is the original champagne bomb (`token_10031_swire2_gdtrap`, §29.5), extracted from the official `btl_pfb_tokens_0.ab` through `TOKEN_SPINES`. Its web fallback is retained. Failed board-pack/image requests can retry after connectivity/visibility recovers; a failed three.js module gets at most two fresh-URL retries per page because browser module failures are cached. Successful resources remain cached.
|
||||
+2
-1
@@ -109,7 +109,8 @@ Top level: `{ season, seasonName, modes, economy, lpCapPerRound, bossOvertimeAft
|
||||
| `itemSellable`, `itemDestroyRefund` | `false`, `0` | items can only be destroyed |
|
||||
| `benchSize`, `tempSize`, `deployCap`, `storeCntMax` | `10`, `5`, `8`, `6` | |
|
||||
| `equipPerChess`, `maxArtsPerRound` | `2`, `2` | |
|
||||
| `poolCopies[tier]` + `poolCopiesOverrides` | `{"1":12,"2":14,"3":18,"4":16,"5":8,"6":5}`, `{"chess_char_6_11_a":4}` (缪尔赛思) | shared pool copies per base chess |
|
||||
| `poolCopies[tier]` + `poolCopiesOverrides` | 12/14/18/16/8/5 plus measured low-tier identity exceptions (DESIGN §29.8) | single-player copies; `coopPoolMultiplier: 2` doubles shared operators, not private DIY stock |
|
||||
| `itemPoolCopies[tier]` + `itemPoolCopiesOverrides` | 4/6/7/8/7/3 plus eight measured exceptions (DESIGN §29.8) | shared shop-equipment ownership: normal 1, upgraded 2; non-shop effect-only equipment unlimited |
|
||||
| `goldenCopies`, `mergeCount`, `mergeCountOverrides`, `itemMergeCount` | `3`, `3`, `{"chess_char_2_11_a":2}` (风丸), `2` | |
|
||||
| `rewardOffer` | `{"count":3,"tierOffset":1,"maxTier":6,"price":0,…}` | merge reward: 3 **different** free chess of tier `min(shopLevel+1,6)` (a short tier tops up from the tier below; `PlayerState.pushRewardOffer`, user playtest #6 item 19) |
|
||||
| `handFillOrder` | `"rightToLeft"` | |
|
||||
|
||||
+2
-1
@@ -4,7 +4,7 @@ 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, 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.
|
||||
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-dev** adds the subsequent community fixes — §29, the current development 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.
|
||||
|
||||
---
|
||||
|
||||
@@ -35,3 +35,4 @@ normative lines it rewrote).
|
||||
| §26 | [history/0.2.1.md](history/0.2.1.md) | 0.2.1 — after the 0.2.0 release |
|
||||
| §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 — development after 0.2.3 |
|
||||
+26
-4
@@ -85,7 +85,7 @@ legal from R14 into R15. The client mirrors it
|
||||
| 机变 first / other pickers | `spFirst` 30 / `spTurn` 16 (co-op; solo / single human untimed) |
|
||||
| PREP | `modes[m].rounds[r].prepTime` (co-op; solo / single human untimed) |
|
||||
| COMBAT / 联防 | `modes[m].rounds[r].combatTimeLimit` (= the level's `maxPlayTime`) real seconds = 2× that in game seconds |
|
||||
| 最终攻势 / 隐秘核心 | no hard stop: countdown `rounds[r].levelMaxPlayTime` 120 real s; overtime drain from `bossOvertimeAfter` 150 real s, 1 team LP per real s (§3) |
|
||||
| 最终攻势 / 隐秘核心 | no hard stop: countdown `rounds[r].levelMaxPlayTime` 120 real s; overtime drain at 120 real s for all-human teams, configured 150 real s with a non-departed AI teammate; 1 team LP per real s (§3) |
|
||||
| ROUND_START / after combat / SETTLE | 2 / 1.5 / 3 (presentation delays, `Match.DELAYS`) |
|
||||
| bot action delay | 0.9 s (+0.35 s per seat) |
|
||||
|
||||
@@ -95,7 +95,7 @@ band draft / 机变 / PREP deadline, and BATTLE_CHECK / ROUND_START / SETTLE run
|
||||
(draft order and skip, 6 机变 cards, 联防) stay.
|
||||
`m.public.deadline` is the absolute end of the current timer (ms epoch, 0 = untimed; combat: estimated end at 2×;
|
||||
最终攻势 / 隐秘核心: the boss level's `levelMaxPlayTime` countdown, 120 real s — the battle goes on past it — with
|
||||
`m.public.overtimeAt` = when the overtime drain starts, 150 real s; both on the field clock).
|
||||
`m.public.overtimeAt` = when the overtime drain starts: 120 real s for all-human teams, 150 real s with an AI teammate at fight start; both on the field clock).
|
||||
|
||||
### 1.1 Band draft
|
||||
Co-op: random order (all seats, bots included), one pick per turn, ONE countdown: `BAND_TURN_SECONDS` 50 s per turn,
|
||||
@@ -682,8 +682,9 @@ Any `choice:` handler whose EffectRef reuses its own key must guard like this (o
|
||||
which replaces the fixed pool of 「保持固定血量」 (config `bossHpScale.perPlayer: false` with `solo: 0.25` restores it;
|
||||
its optional × alive / 4, `aliveScaling`, stays off); leaders are never scaled by `enemyScale`, their parts and escorts
|
||||
keep their own HP. The merged team LP loses leaks (`lpr`), the overtime drain
|
||||
(`bossTurnHpReduceTime` 150 counts REAL seconds, like the boss level's 120 s maxPlayTime that runs out first — the
|
||||
battle goes on — so 1 LP per real second from 150 real s = 300 game s on the 2× field clock; `gd.bossOvertimeDue`)
|
||||
(the boss level's 120 real-second countdown for all-human teams; `bossTurnHpReduceTime` 150 real seconds only with
|
||||
a non-departed AI teammate at fight start. The first deduction follows one complete real second after that threshold;
|
||||
`gd.bossOvertimeDue` receives the fight's frozen threshold — DESIGN §29.1)
|
||||
and leader "扣除目标生命" effects (the sim's `lpLoss` hook: boss_7 Doom, 斥退 …); after every change it is written back
|
||||
to the alive players as shares of the LP each brought in (`lpAtFinal`, largest remainder), so `lp` in m.public /
|
||||
m.private / m.result is what is left (Σ = team LP).
|
||||
@@ -915,3 +916,24 @@ requests cannot affect a new opening. See DESIGN §28.21 for cancellation and ti
|
||||
`botEmotes.js` is isolated from bot decisions and RNG. It sends the existing `m.emote` only in mixed matches, under
|
||||
normal cooldown/whitelist rules; `SP_BOT_EMOTES=0` disables it. The cooperation score in `bot.js` and these cosmetic
|
||||
choices are explicitly [ASSUMED]; DESIGN §28.15–16 records the source and validation.
|
||||
|
||||
### Community follow-up contracts (DESIGN §29)
|
||||
|
||||
- `triggerGarrisons` includes `source.copiedFrom` for copied effects. Only Ptilopsis copying Yu/Gladiia at SERVER_PREP_START skips the three-in-row prerequisite, as confirmed by the original-game recording; Saria and other copies keep existing conditions.
|
||||
- Jessica's placeable shield uses `shared/summonPlacement.js` for the four orthogonal neighbors, independent of her selected skill, facing or module. Client preview and server moves/swaps use the same grid.
|
||||
- Client result statistics resolve DIY and stand-in names from the authoritative BattleSpec. A client cannot replace those names.
|
||||
|
||||
### Shared inventory after 0.2.3 (DESIGN §29.8)
|
||||
|
||||
`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.
|
||||
整备 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.
|
||||
+11
-3
@@ -463,7 +463,7 @@ recorded for the player whose half contains the goal it reached, with `sourcePla
|
||||
0, so float dust of the pool sync can never keep a leader alive; user playtest #6). A boss/hidden battle **with a
|
||||
`sharedBoss`** ends only when the pool reaches 0 or on `forceEnd` (DESIGN §5.5) — an emptied field keeps running
|
||||
(the h07_04 pair leader has no wait step and can walk into the objective: the leak costs its `lpr`, then the field
|
||||
idles while another field may still empty the pool or the match's overtime drain — team LP, from 150 real s — ends it); without a pool
|
||||
idles while another field may still empty the pool or the match's overtime drain — team LP, from 120 real s for all-human teams / 150 with an AI teammate — ends it); without a pool
|
||||
(sandboxes, tools) it still ends `cleared` when empty. `timeout`: `time ≥ timeLimit` —
|
||||
every living non-boss enemy becomes a leak; spawns that never happened are dropped from `total` and listed in `unspawned`.
|
||||
- `leaked[].counted = false` for `notCountInTotal`/`unharmful`/boss parts (LP rules: normal rounds count only `counted`
|
||||
@@ -907,7 +907,7 @@ instance) and skip `'counter'` / `'reflect'` damage. When the guard trips, the l
|
||||
| `getPlayer(playerId)` | `{ playerId, seat, side, colOffset, mirror, dir (default unit direction: RIGHT, mirrored side LEFT), facing (its sign), bonds (live copy, layers updated by addLayers), bandId, playerEffects, lpForBoss, dp, units }` |
|
||||
| `mapTile(ps, row, col, abs?)` / `mapDir(ps, dir, abs?)` | board → field tile / direction of a player (the FA right-side mirror) |
|
||||
| `onOwnBoard(ps, r, c)` | whether a field tile lies on the player's own board — GEO.FIELD (rows 9–12, cols 2–10) mapped onto this field: the 联防 right-hand helper's +8 columns, the boss field's rows 2–5 (never its hand / 临时整备区 rows 0–1), the mirrored right half; 乌尔比安's S3 【移动】 takes only such tiles (DESIGN §25.17.2) |
|
||||
| `onFieldBoard(r, c)` | whether a field tile lies on the board of a player of this field — `onOwnBoard` of any of `players` (the players whose units the battle holds; an eliminated or absent teammate is not one): both halves of the two-helper 联防 field and of a Final Assault / Hidden Core pair field, the own half only for a lone 联防 helper and on a solo boss field, never a boss field's hand / 临时整备区 rows; the 突袭 landing takes only such tiles (DESIGN §25.18, §26.1) |
|
||||
| `onFieldBoard(r, c)` | whether a field tile lies on the board of a player of this field — `onOwnBoard` of any of `players` (the players whose units the battle holds; an eliminated or absent teammate is not one): both halves of the two-helper 联防 field and of a Final Assault / Hidden Core pair field, the own half only for a lone helper/player, never a boss field's hand / 临时整备区 rows. Raid additionally admits the empty deployment half only in `mode_multi_*` boss/hidden fights (rows 2–5, cols 2–18), without changing this general method or terrain/reservation checks (DESIGN §29.13, #475) |
|
||||
| `addDp(playerId, n)`, `retreat(unit, {reason, permanent, dying})`, `relocate(unit, r, c)` | `relocate` only changes the tile (state kept, no event) |
|
||||
| `moveRedeploy(unit, r, c, { clearSp })` | a 【移动】 (PRTS 术语释义: "不退场,以当前血量在目标位置部署", a special retreat + redeploy): `relocate`'s checks (false = refused, nothing changed), then a new deployment (`deploySeq` / `aggroSeq` / `deployedAt`) and `deploy {initial:false, move:true}` (deploy effects fire again); no `die` / `death`, timer or cost (不屈 / 阿戈尔's revive never see it); HP, buffs and a running skill are kept (owner's deviation, DESIGN §22.3); `clearSp` empties the SP before the deploy handlers run — 乌尔比安 S3's move (kits/ops/chess_char_5_05-ulpia.js) and 【返回】 (`clearSp`; also the 从不混淆的方向 fallback, content/tokens.js) |
|
||||
| `redeploy(unit, { free=true, tile, keepSp })` | immediate (re)deployment of a dead/retreated ally (full HP, `deploy {initial:false}`); `free: false` pays `base.cost` DP (refused without it); without `tile` it lands on the unit's rest tile (`restTile`: where a knocked-out operator lies, else home); `tile: [r, c]` lands on that tile once (home unchanged; refused when off-rect, occupied or a knocked-out operator's tile, no fallback); `keepSp` keeps SP/charges (保留技力), restored before `deploy` fires — but not for a skill that costs no SP, which the reset re-arms as at every deployment (伊内丝 S3 casts again on a 突袭 landing) — 突袭 raids, 阿戈尔 / 不屈 revives where the unit lies |
|
||||
@@ -1381,7 +1381,7 @@ Unknown subprofessions fall back to the profession default (test `professions.te
|
||||
|
||||
## 9. Wire format (snapshot.js, DESIGN §8.2)
|
||||
|
||||
- `snapshot()` → `{ fieldId, t, units: [[id, x, y, hp, maxHp, sp, spMax, flags, anim]], dp, killed, total, resolved, dps?, boss?, down?, elem?, ammo?, wolves?, neg?, stand?, standCut? }`.
|
||||
- `snapshot()` → `{ fieldId, t, units: [[id, x, y, hp, maxHp, sp, spMax, flags, anim]], dp, killed, total, resolved, dps?, boss?, down?, elem?, ammo?, coins?, wolves?, neg?, stand?, standCut? }`.
|
||||
`killed` counts every `counted` knock-out (§1.3) and `total` only the enemies this field scheduled (`inTotal`), so it may
|
||||
exceed it; `resolved = min(total, killedInTotal + leakedInTotal)` is the HUD capsule's numerator (DESIGN §14 顶栏胶囊).
|
||||
`sp/spMax` show remaining duration/ammo as a draining bar while a timed skill is active (ammo: `ammoLeft / ammoMax`, the
|
||||
@@ -1561,3 +1561,11 @@ yet — enemy content must pick one); 抵抗 covers the control statuses of `RES
|
||||
“余音” owns a same-call pulse queue (DESIGN §28.24): reflected hits enqueue their earned pulses instead of recursively
|
||||
entering another pulse. This retains the lethal-hit pulse and prevents valid high hit-count chains from tripping the
|
||||
general hook guard. The queue is emptied in `finally`; it introduces no timer or simulation RNG.
|
||||
|
||||
### Community follow-up contracts (DESIGN §29)
|
||||
|
||||
- Optional snapshot `coins: [[id, balance, cap]]` comes from `snapshot.js coinView`: a live allied Swire skill's nonnegative integer balance and blackboard cap, including zero. Invalid/missing entries are omitted; interpolation clears absent values.
|
||||
- Raid's one reselect after a revive orders by taunt, then remaining route distance, then id, and jumps while the operator is not blocking (contact on the current tile is refreshed first). Ordinary polls keep remaining-distance order and the empty-range prerequisite.
|
||||
- Healing skill activation includes elemental-only injury when the operator's heal profile can cure it. Pinecone S2 growth is deployment-local. Skadi S1 and Stead preserve redirect ancestry and take both shares from the pre-redirect hit, so the total damage is conserved.
|
||||
|
||||
`champagneHold` includes the internal `isolated` ally-selection flag (§29.11): device-type champagne bombs are excluded from ordinary allied auras/inspiration in both creation paths. Other unhealable summons retain their own selection behavior.
|
||||
@@ -63,7 +63,7 @@ Audio: autochess BGM per phase, UI SFX (buy/sell/refresh/level/merge/ready/timer
|
||||
- `guide/autochess_{home,shop,handbook}_N.png` — the 19 official tutorial pages; stored squashed to 1024² — **display at 16:9** (the mirror copies too, §22.5).
|
||||
- `projectiles/*` — battle projectile sprites.
|
||||
- `spine/enemy/<id>/` — enemy models no web dump carries (灼热 / 炽焰源石虫, `extract.py ENEMY_SPINES`): manifest `enemies[id].spineLocal` (metadata from the committed `tools/assets/local-enemy-spines.json`); drawn instead of the tinted web alias when every file is listed (§21.14).
|
||||
- `spine/token/<id>/` — token (summon) models no web dump carries (39: most 自选 summons, 凯瑟琳's 爬行号·防护单元, 凛御银灰's 风雪之眼; `extract.py TOKEN_SPINES`, the Front renderer's skeleton of each battle token prefab): manifest `tokens[id].spineLocal` (metadata from the committed `tools/assets/local-token-spines.json`); drawn instead of the avatar diamond when every file is listed (docs/ASSETS.md "Token models from the local client").
|
||||
- `spine/token/<id>/` — token (summon) models no web dump carries (40: most 自选 summons, 凯瑟琳's 爬行号·防护单元, 凛御银灰's 风雪之眼; `extract.py TOKEN_SPINES`, the Front renderer's skeleton of each battle token prefab): manifest `tokens[id].spineLocal` (metadata from the committed `tools/assets/local-token-spines.json`); drawn instead of the avatar diamond when every file is listed (docs/ASSETS.md "Token models from the local client").
|
||||
- `module/<TYPE>` — the official module (uniequip) TYPE icons (white glyphs; keys are the client's mixed-case file names, e.g. `PRI-X`, `mar-x`, `isw-a`), matched **case-insensitively** against a ModuleRecord's `typeName` by `assetUrls.moduleTypeIconUrl(local, typeName)` (Greek type letters map to the file's Latin letter: `ISW-α` → `isw-a`) — shown on the 干员调配 module cards / module info, the detail panel's 模组 row and the shop card's module tag; lettered tiles / type text without them.
|
||||
|
||||
---
|
||||
@@ -141,5 +141,12 @@ shows the current base pose. A subsequent attack/death supersedes that pending e
|
||||
or per-phase animation schedule is added by this change.
|
||||
|
||||
The simplified-view notice describes a renderer load failure, rather than declaring the device incapable of 3D.
|
||||
On same-battle metadata refresh, remembered once-spawned device records also seed the renderer's new entry (§29.14).
|
||||
This preserves device tile picking and live-HP cards; records explicitly supplied by the refresh take precedence,
|
||||
and a different field never inherits the previous field's devices.
|
||||
The same fallback covers import failure, timeout and WebGL initialization errors; a reload can retry.
|
||||
It does not diagnose a particular user's GPU or persist a disabled renderer preference (§28.25).
|
||||
|
||||
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.
|
||||
@@ -224,3 +224,12 @@ the hook-depth guard on scaled hit-count HP; the guard remains unchanged for oth
|
||||
as well as by its aura tick, including the first hit and immediately after the last companion leaves.
|
||||
|
||||
Sources and the feedback items requiring no simulation change are recorded in [§28.25](../history/0.2.3.md#2825-community-feedback-of-2026-10-10).
|
||||
|
||||
Community corrections in §29 are normative: Raid's one reselect after a revive uses taunt then route distance and jumps while unblocked, after refreshing contact on the current tile; ordinary polls stay on route distance and the empty-range rule. Healing cast conditions include curable elemental injury; Pinecone S2 growth restarts per deployment; crest consumption is checked on damage. Mixed Skadi/Stead redirects preserve ancestry and both take their share from the pre-redirect hit, so the victim keeps the remainder and the total is conserved. Clementia S2 secondary splash is 100% ATK within a 1.1 collision radius (four additional ground targets), while its main hit and 1.5-radius vortex keep their own coefficients. Final-mode leaders retain their erosion immunity, explicitly stated in the PRTS mode tips. 整备 does not upgrade a purchase when the golden's second copy would exceed shared item stock, and it does not spend its charge in that case.
|
||||
|
||||
Raid alone may land on an empty deployment half in multiplayer (`mode_multi_*`) Final Assault / Hidden Core battles
|
||||
(#475, maintainer-approved, §29.13): rows 2–5, cols 2–18, subject to the existing field rect, terrain, occupied/downed
|
||||
tile and attack-reach checks. Hand/temp rows, empty halves in single-player mode or lone-helper 联防 stay excluded.
|
||||
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.
|
||||
@@ -28,7 +28,7 @@ LOBBY(room) → INFO_CHECK (co-op 25 s; solo and any single-human match untimed
|
||||
SETTLE (LP −min(leaks,10) per player (unite survivors to their source); eliminations; layer gains applied; coins; broadcast;
|
||||
after a 联防 the outcome and every player's charge ride the SETTLE m.public as `uniteResult`, GitHub #235)
|
||||
R14 FINAL_ASSAULT (merged team LP; pairs by seat; shared boss pool; 120 real s countdown, no hard stop; overtime −1 team LP per
|
||||
real s after 150 real s (§4, m.public.overtimeAt); no layer gains; team LP 0 ⇒ defeat at once (PRTS 直接失败),
|
||||
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)
|
||||
→ RESULT (per-player stats, titles, rounds passed, victory/defeat)
|
||||
@@ -60,7 +60,7 @@ Summons (§20.1): an operator on the board sends its manually deployable summons
|
||||
|
||||
Equipment (research 04 §2 + addendum, 09 §1.2): 2 slots per chess; a third item on a full carrier replaces the equipped item `replaceUid` names (the replace dialog's pick, official `UseEquipUp.unloadInstId`; absent ⇒ the oldest; a uid not equipped on the target ⇒ `BAD_TARGET`, nothing changes) and the replaced item is destroyed. A consume-on-equip item (盟约之币, 博士投影 …) on a full carrier replaces the same way before it resolves, so the carrier is left with a free slot (the normal 博士投影 takes it until the next round start); a target its effect refuses (博士投影 on an elite) keeps both items — PRTS 卫戍协议/帮助 "达到上限强行佩戴会改为替换装备" names no exception (GitHub #263; the client opens the replace dialog for it, `equipReplaces`). Equipped items are otherwise **locked**: `destroy` (销毁) accepts hand / temp items only (`BAD_TARGET 'equipped items are locked'`); equipment goes back to the hand only when its carrier merges, is sold, or is destroyed by 突变细胞's transformation (the cell itself returns too; PRTS 帮助 "在失去该干员(干员出售、销毁、合并等)…时自动卸除", §21.1) — a sale is refused with `HAND_FULL` when there is no room for it; returned equipment is stowed before the operator a transformation gains and before a merge elite's summon cards, and every path that hands the player an item — returned equipment included — ends with the auto-merge, so no player holds two identical mergeable normal items (§21.7).
|
||||
|
||||
Economy/shop/pool/merge/hand rules: exactly as research 00-INDEX §3–§4 (income `min(3+r,12)`; prices by tier; refresh 1; freeze toggles all; sell +1; items not sellable; upgrade prices per mode with −1/round; shared pool copies 12/14/18/16/8/5 (缪尔赛思 4) across all players; elite = 3 copies; odds = copy-weighted draw over unbanned visible chess with tier ≤ level; merge 3 (风丸 2) → elite onto the consumed deployed copy's tile, else to the hand (§20.11) + reward offer of 3 **different** free chess of tier `min(level+1, 6)` (copy-weighted; a tier short of different chess tops up from the tier below [ASSUMED], §20.6); full hand blocks every buy / reward pick, a merge-completing one too (PRTS 卫戍协议/帮助 §手牌区, GitHub #82); overflow to temp; a free hand slot pulls the temp pieces back in at once, right → left (`PlayerState._fillHandFromTemp`); temp blocks ready).
|
||||
Economy/shop/pool/merge/hand rules: exactly as research 00-INDEX §3–§4 (income `min(3+r,12)`; prices by tier; refresh 1; freeze toggles all; sell +1; items not sellable; upgrade prices per mode with −1/round; single-player pool copies 12/14/18/16/8/5 plus measured exceptions; co-op shared operators use twice those counts, while equipment has separate shared stock (DESIGN §29.8); elite = 3 copies; odds = copy-weighted draw over unbanned visible chess with tier ≤ level; merge 3 (风丸 2) → elite onto the consumed deployed copy's tile, else to the hand (§20.11) + reward offer of 3 **different** free chess of tier `min(level+1, 6)` (copy-weighted; a tier short of different chess tops up from the tier below [ASSUMED], §20.6); full hand blocks every buy / reward pick, a merge-completing one too (PRTS 卫戍协议/帮助 §手牌区, GitHub #82); overflow to temp; a free hand slot pulls the temp pieces back in at once, right → left (`PlayerState._fillHandFromTemp`); temp blocks ready).
|
||||
|
||||
**Temp overflow (临时整备区, row 8; user playtest #3 item 3).** Passive gains that find the hand full (merge elites, grants — the operator a 突变细胞 transformation gains included, §21.1 —, battle-result rewards, equipment returned by a merged / sold / transformed carrier) overflow into the 5 temp slots, and Ready is refused while any is occupied (official "直到溢出情况排除才可开始进行作战"). A temp piece is resolved — a chess sold back to the pool with its summons removed, items destroyed, a summon stack removed (it comes back at the next round start, PRTS "干员所属召唤物会于下一回合返还", §20.1) — only at the deadline of the **first prep in which its player could act on it** (`PlayerState.tempDue(piece)` vs `prepsEnded`; every write into a temp slot goes through `_putTemp`, which records it): a piece that arrived during a prep before Ready is due at that prep's end; one that arrived after Ready or during the `<休整期结束时>` effects, or outside PREP (COMBAT, 联防, SETTLE — battle-result grants, a SETTLE merge's elite, returned equipment —, the next ROUND_START, 机变) is kept, visible and usable (move to a free hand slot, place, equip, use, destroy, sell) through the **next** prep. Un-readying (`setReady(false)`) makes whatever arrived while ready due at the current prep. ROUND_START no longer wipes temp (it used to destroy what SETTLE had put there before the player ever saw it). A merge's elite whose merge consumed a deployed copy takes that copy's tile and facing (§20.11); otherwise it goes to the hand, overflowing into temp (also outside PREP). `server/match/audit.js` (`tools/matchrun.mjs --check`) tracks the arrivals itself and flags any temp piece that outlives its due prep. The UI frames the row on the board with the rule (§17.5).
|
||||
|
||||
@@ -139,3 +139,5 @@ remain fixed and random results can repeat. A separate seed-derived stream leave
|
||||
have a 300 ms anti-burst guard, revision and vote identities. All new setup data and player DIY stocks are prepared
|
||||
before mutation; data preparation failure leaves the old opening intact. This is a remake convenience, not an official
|
||||
mode rule. Unrelated result-screen changes from PR #408 are not included.
|
||||
|
||||
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.
|
||||
@@ -30,7 +30,7 @@ S→C:
|
||||
- NOTE: game time in `b.snap`/`b.ev` is carried as **`gt`** (the frame's `t` is the message type).
|
||||
- Active finite zero-SP timed skills display `sp=timeLeft`, `spMax=duration` and `UF.SKILL`; after end they display
|
||||
`0/0`. Snapshot display capacity does not change runtime SP/cost.
|
||||
- `b.snap` — `{ fieldId, gt, units: [[id, x, y, hp, maxHp, sp, spMax, flags, anim]] , dp, killed, total, resolved, down?, elem?, ammo?, wolves?, neg?, stand?, standCut? }` 20 Hz (a countdown summon's `hp` is maxHp × the share of its life left, §25.16.5). Optional, only when non-empty (§18.3): `down: [[id, respawnAt (game s), respawnTime (s), state, row, col]]` — knocked-out operators waiting to redeploy, `state` 0 counting | 1 timer done, waiting for DP | 2 timer done, its tile taken — a safeguard, no ally deploys there (sim/constants.js `DOWN_STATE`); `row, col` = the tile it lies on and comes back on (§21.24); `elem: [[id, element, fill, cooldownEnd (game s), cooldown (s)]]` — the one gauge a unit shows: the fullest (ties by the official element id 神经 < 侵蚀 < 灼燃 < 凋亡), fill with 2 decimals rounded DOWN and kept at 0.01–0.99 outside a burst (§20.8); during a 爆发冷却 the bursting element with fill 1 and the cooldown numbers (else 0, 0); the three HP-bar readouts (0.2.2, GitHub PR #303 / #378 / #383, display only — no sim state reads them, the nine-field tuples and the SP fraction an ammo skill still puts in them are unchanged; `render/interp.js` keeps them per snapshot and `sample()` hands them out from the older snapshot like `flags`, so they step with the skill flag; a malformed entry or an id the snapshot does not list is dropped, a snapshot without the list clears the readout): `ammo: [[id, rounds left, rounds in the magazine]]` — an ally's running ammo skill, whole rounds, the magazine being the activation's real total (extra bullets and refills included; 隐现's S2 holds 14, skill_table `attack@trigger_time`) — the client draws yellow cells in place of the SP bar (the count, not a pixel copy of the official magazine widget — its art is in no game data we have), and UnitInfo `ammoSkill: true` (an ally whose skill is an ammo magazine, known from its first appearance) tells it to draw no standing skill aura; `wolves: [[id, 狼影 left, the talent's maximum]]` — 伺夜's 狼群 (`mem.shadows` / `mem.wolfCapacity`, both the placed and the automatic pack), one diamond per slot under the HP bar (plain diamonds: no official counter art is known, the portrait of PR #378 was not taken), nothing in the 战术点形态; `neg: [[id, fill]]` — the share (0.01–1) of its cap a negative-HP pool holds (斩业星熊's 我执, `unit.negFill`), drawn as the red bar over her drained HP bar. These show only for a living, deployed, visible unit and only when it has one
|
||||
- `b.snap` — `{ fieldId, gt, units: [[id, x, y, hp, maxHp, sp, spMax, flags, anim]] , dp, killed, total, resolved, down?, elem?, ammo?, coins?, wolves?, neg?, stand?, standCut? }` 20 Hz (a countdown summon's `hp` is maxHp × the share of its life left, §25.16.5). Optional, only when non-empty (§18.3): `down: [[id, respawnAt (game s), respawnTime (s), state, row, col]]` — knocked-out operators waiting to redeploy, `state` 0 counting | 1 timer done, waiting for DP | 2 timer done, its tile taken — a safeguard, no ally deploys there (sim/constants.js `DOWN_STATE`); `row, col` = the tile it lies on and comes back on (§21.24); `elem: [[id, element, fill, cooldownEnd (game s), cooldown (s)]]` — the one gauge a unit shows: the fullest (ties by the official element id 神经 < 侵蚀 < 灼燃 < 凋亡), fill with 2 decimals rounded DOWN and kept at 0.01–0.99 outside a burst (§20.8); during a 爆发冷却 the bursting element with fill 1 and the cooldown numbers (else 0, 0); the three HP-bar readouts (0.2.2, GitHub PR #303 / #378 / #383, display only — no sim state reads them, the nine-field tuples and the SP fraction an ammo skill still puts in them are unchanged; `render/interp.js` keeps them per snapshot and `sample()` hands them out from the older snapshot like `flags`, so they step with the skill flag; a malformed entry or an id the snapshot does not list is dropped, a snapshot without the list clears the readout): `ammo: [[id, rounds left, rounds in the magazine]]` — an ally's running ammo skill, whole rounds, the magazine being the activation's real total (extra bullets and refills included; 隐现's S2 holds 14, skill_table `attack@trigger_time`) — the client draws yellow cells in place of the SP bar (the count, not a pixel copy of the official magazine widget — its art is in no game data we have), and UnitInfo `ammoSkill: true` (an ally whose skill is an ammo magazine, known from its first appearance) tells it to draw no standing skill aura; `wolves: [[id, 狼影 left, the talent's maximum]]` — 伺夜's 狼群 (`mem.shadows` / `mem.wolfCapacity`, both the placed and the automatic pack), one diamond per slot under the HP bar (plain diamonds: no official counter art is known, the portrait of PR #378 was not taken), nothing in the 战术点形态; `neg: [[id, fill]]` — the share (0.01–1) of its cap a negative-HP pool holds (斩业星熊's 我执, `unit.negFill`), drawn as the red bar over her drained HP bar. These show only for a living, deployed, visible unit and only when it has one
|
||||
Optional, only when non-empty (PR #381; display metadata, SIM §9): `stand: [[id, until]]` — the game time an enemy's
|
||||
attack recovery ends (`atkStandUntil`; enemies alive, deployed, visible, neither feared nor stunned): when it falls
|
||||
between two snapshots the renderer holds the position until then and interpolates the rest (HP / SP unchanged), and
|
||||
@@ -134,3 +134,9 @@ voting, refuses stale ids and cancels on disconnect/leave; DESIGN §28.21 / META
|
||||
|
||||
Explicit local recovery (§28.20) reuses saved hello tokens after a fresh live-tab claim check. Display names and room
|
||||
codes are UI metadata only, never authentication. No new network message or nickname/IP matching is introduced.
|
||||
|
||||
Optional `coins: [[id, balance, cap]]` is an integer skill-currency readout (§29.6), separate from ammo. It includes zero, is stepped between snapshots and clears when omitted. Result-validation names for board operators are resolved from the server's DIY/stand-in selections, never a supplied display name.
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,276 @@
|
||||
# DESIGN §29 — 0.2.4 (development)
|
||||
|
||||
Part of [DESIGN.md](../DESIGN.md). This chapter records changes after the 0.2.3 release, not a released version.
|
||||
|
||||
## 29. Community follow-up
|
||||
|
||||
### 29.1 Boss overtime and Raid revival
|
||||
|
||||
- The maintainer requested the extra Final Assault / Hidden Core overtime grace only for teams with an AI teammate.
|
||||
At fight start, a non-departed AI seat preserves the configured 150 real seconds; otherwise the threshold is the
|
||||
visible level countdown (120 real seconds). The decision stays fixed during that fight. The first deduction is at
|
||||
the following whole real second, at one team LP per real second. `overtimeAt` uses that same threshold.
|
||||
- Ordinary Raid polls keep the 0.2.3 order: own field first, then remaining route distance and stable id, and they
|
||||
still jump only when the skill is ready or the member has been idle long enough and the range is empty.
|
||||
The maintainer confirmed one reselect immediately after a revive. That reselect alone orders by taunt, then
|
||||
remaining route distance, then id, and it jumps while the operator is not blocking, even if another enemy is
|
||||
already in range. Standing on an enemy's tile is blocking: the 0.25 s poll runs before that tick's block pass,
|
||||
so the reselect refreshes contact before it decides to stay. A retreat, a move, a redeploy that waited out its
|
||||
timer, the initial deployment and a Raid jump do not arm it. [ASSUMED] A revive is recognised as a knock-out
|
||||
redeploy that returns before `respawnAt`; the bond text does not name that timer split.
|
||||
- Regression: `boss-ai-grace.test.js` covers both final fights with/without AI; `community-round29.test.js` covers
|
||||
Texas S2 with local enemies, remote taunt/route priority, blocked revival and no repeated self-trigger.
|
||||
|
||||
### 29.2 Operator, item and sharing corrections
|
||||
|
||||
- #462, Pinecone S2: PRTS's growth is per deployment. A deployment-local use count resets on redeploy; the runtime's
|
||||
lifetime activation sequence is unchanged. Normal and elite variants are covered.
|
||||
- #459, Harold S2: a healer that can heal elemental injury considers elemental-only injury when deciding to cast.
|
||||
Full HP plus an elemental gauge can trigger; a wholly uninjured ally cannot.
|
||||
- #461, family crest: the first damage after stealth ends consumes the accumulated bonus immediately, including
|
||||
damage before the next growth poll. A synchronous equipment combo cannot consume it repeatedly; the current hit
|
||||
and combo retain the attack value that triggered them.
|
||||
- #464, Skadi S1 / Stead sharing: redirected damage carries both existing redirect ancestry markers, so neither
|
||||
mechanism revisits its own output and the transfer tree stays bounded. Both shares are taken from the pre-redirect
|
||||
hit and the victim keeps `1 − R − S` (1000 true with the official 0.4 and 0.5 leaves the victim 100, the three
|
||||
members 400 together and Skadi 500, including the one cross-application the ancestry tags already allow). A single
|
||||
effect is unchanged: Stead alone leaves 600 + 400, Skadi alone leaves 500 + 500. [ASSUMED] The two ratios add as
|
||||
shares of the original hit; no official source says they multiply. The same split does not depend on which
|
||||
damaged handler runs first. Hook registration order remains otherwise unchanged.
|
||||
- Sources: [PRTS 松果](https://prts.wiki/w/松果), [哈洛德](https://prts.wiki/w/哈洛德), mode item descriptions and the
|
||||
existing sharing contracts. `community-round29.test.js` includes before/after reproductions.
|
||||
|
||||
### 29.3 Ptilopsis copying Yu / Gladiia (#473)
|
||||
|
||||
The [original-game recording](https://www.bilibili.com/video/BV1XJSoBzEFB/) around 2:30–2:38 shows and explains that
|
||||
Ptilopsis's copies of Yu and Gladiia bypass the three-in-row prerequisite. The maintainer approved reproducing
|
||||
**only these two exceptions**. Preparation-start gifts from these copied traits skip the row condition; original
|
||||
owners, Saria, other traits and other events retain their conditions. The dispatcher records the copied origin in
|
||||
`source.copiedFrom`; eight original/copier-row combinations are tested in `garrisons_meta.test.js`.
|
||||
|
||||
### 29.4 Clementia S2 and final-boss erosion
|
||||
|
||||
[PRTS 克莱门莎](https://prts.wiki/w/克莱门莎), checked 2026-10-10, now specifies S2 ordinary-attack splash: radius
|
||||
1.1 with collision testing, at most four extra ground targets, each taking 100% ATK physical damage. This replaces
|
||||
the launch-day assumed 1.5 centre radius and copied main-hit multiplier. The primary target retains the skill's
|
||||
own attack multiplier; the vortex remains radius 1.5. Normal/elite numeric, radius and large-body regressions live in
|
||||
`op_clemnt.test.js`. The unresolved launch-day carriage/bomb timing assumptions remain documented in §28.1.
|
||||
|
||||
The [PRTS mode record's tips](https://prts.wiki/w/卫戍协议:盟约/PRTS盟约记录) explicitly state that Final Assault
|
||||
leaders do not receive erosion injury. The existing mode-specific immunity remains. All ten final-leader definitions
|
||||
were compared against all five injury types: erosion was blocked, the other four could burst. Main-game constants
|
||||
give leaders a 2,000 erosion threshold, so main-game boss identity alone is not general erosion immunity; that does
|
||||
not override the mode tip or prove every scripted boss phase susceptible. Pith's current three named injury types
|
||||
are unchanged. No unannounced future-season behavior is inferred.
|
||||
|
||||
### 29.5 Shield placement and original champagne art
|
||||
|
||||
- #460: Jessica's placeable shield uses the four orthogonally adjacent tiles stated by her talent. Preview, server
|
||||
validation, owner movement and swaps use a shared placement grid, independent of skill, facing or module.
|
||||
`jessica-placement.test.js` covers legal/illegal moves, swaps, bots and other summons.
|
||||
- #468: the original champagne-bomb model is extracted from the local official token bundle through the existing
|
||||
extraction/build pipeline. There are now 40 local token metadata records. Only this token's `spineLocal` and the
|
||||
generated asset hash change; the web fallback remains. No game art enters Git.
|
||||
|
||||
### 29.6 UI and recovery
|
||||
|
||||
- DIY selection reuses the roster's `LoadoutStats` component, showing the chosen ordinary/elite stats, attack range,
|
||||
trait, talents, potential and cultivation. Skill descriptions/SP labels follow the same preview level. The list
|
||||
uses aligned roster-style rows; list and details scroll independently, leaving Confirm reachable.
|
||||
- #450: Swire's skill coins show as current/cap, including zero. Optional `coins: [[id, balance, cap]]` comes from
|
||||
valid live-unit state, is stepped between snapshots and clears when absent. It changes no earning/spending rule.
|
||||
- #471: lobby tools are grouped at the left; the player chip opens statistics. Responsive title allocation prevents
|
||||
overlap with long names, translated text, resume and install controls; a narrow header puts its title on a new row.
|
||||
- #458: settlement summary scrolls independently from its return button. Enlarged text no longer pushes the button
|
||||
outside the viewport.
|
||||
- Transient failed images and board packs can retry on a later useful event. Three.js failed imports use two bounded
|
||||
fresh URLs because browsers cache module failures. A failed field engine may remount on online/visibility events,
|
||||
with cleanup and at most two consecutive retries; explicit fallback/2D choices remain respected. Real Chrome
|
||||
tests abort actual resource requests and verify recovery, single canvas ownership and unmount cleanup. This does
|
||||
not establish the cause of every reported device/GPU failure.
|
||||
- #469's repeated “甄选干员干员” was a result validator replacing a DIY operator's name with its empty slot's name.
|
||||
It now resolves DIY and stand-in names from the authoritative BattleSpec; client-supplied names remain ignored.
|
||||
- #448: Japanese UI terms are corrected, including 自然回復 and 完璧な作戦, consistent with official Japanese data.
|
||||
|
||||
### 29.7 Verified reports and pending proposals
|
||||
|
||||
Muelsyse's deployed Harmony correctly adds one member to existing core bonds, without counting a benched copy or
|
||||
stacking duplicate traits. Six Kazimierz stuns nearby hovering enemies and makes them land; the radius remains 0.8.
|
||||
Targeted real-socket tests cover eliminated host/guest departures in prep/combat/unite and survivor reconnection.
|
||||
These checks did not reproduce the reported generic Harmony/room failures; no speculative rule change was made.
|
||||
|
||||
The plugin groundwork remains the existing content-pack manifest/language implementation; a runtime plugin API is
|
||||
not added. Additional dub languages remain deferred until supported resources exist. Other rule/AI proposals
|
||||
require explicit decisions and evidence; open PRs are not blanket approval for their implementations.
|
||||
|
||||
Validation and intended golden moves are recorded in §29.12.
|
||||
|
||||
### 29.8 Shared inventory (#466, #430; approved after reviewing the source video)
|
||||
|
||||
The maintainer requested the measured rules in 路标档案馆's original
|
||||
[路标月报 #2](https://www.bilibili.com/video/BV1eLXXBqEgF/) (2026-03-29): single-player operators around
|
||||
0:30–1:00, equipment around 1:30, co-op operators around 2:00, ownership/special grants around 2:30–4:00.
|
||||
Official client tables supply identities, tiers and merge recipes;
|
||||
[PRTS mode help](https://prts.wiki/w/卫戍协议/帮助) supplies equipment return/merge and hand rules.
|
||||
|
||||
| Stock | Base counts by tier I–VI | Exceptions |
|
||||
|---|---|---|
|
||||
| Single-player operators | 12 / 14 / 18 / 16 / 8 / 5 | I: 普罗旺斯、德克萨斯、跃跃、古米、格雷伊 = 10; II: 风丸 = 8, 赫默、休谟斯、砾、蒂比、调香师 = 12 |
|
||||
| Co-op shared operators | twice the single-player counts | exceptions doubled too; independent of seat count |
|
||||
| Shared shop equipment | 4 / 6 / 7 / 8 / 7 / 3 | 简易通讯机 = 5; 蜂鸣器、防暴盾、浓缩嗅盐 = 6; 寻呼模块、伪装服、拉特兰桥夹 = 7; 商业包装方案 = 2 |
|
||||
|
||||
- This explicit decision supersedes the old blanket shared counts and Muelsyse override. Private DIY stock keeps its
|
||||
per-player 8/5 capacities; the video's multiplier describes the shared pool.
|
||||
- Normal/elite operators occupy 1/3 copies by every gain route. Kazemaru still merges two normals, but the elite
|
||||
occupies three. Promotion also occupies the full three. Grants beyond capacity keep a negative internal balance:
|
||||
selling one copy cannot expose stock while ownership remains above the cap. Public availability and sampling are
|
||||
clamped at zero; `raw balance + owned copies = capacity` remains invariant.
|
||||
- Mimic samples eligible identities rather than remaining copies and can exceed stock, including the third-copy
|
||||
branch. This supersedes #207's old empty-pool failure. Other pool-weighted grants keep their selection rules.
|
||||
- Equipment across every player's hand, temporary area and equipped slots is counted live: normal 1, upgraded 2.
|
||||
Shop/choice displays reserve nothing. Exhausted purchases fail before charging or marking the offer taken; draft
|
||||
duplicates become unavailable when an earlier choice empties stock, and automatic picks skip them. Equipment
|
||||
returned from a sold/merged operator still occupies stock in the hand. 整备 upgrades the bought item only when
|
||||
another copy is still free (the purchase already took one, and a golden takes two). Otherwise the item stays
|
||||
normal and the 整备 charge is not spent. An uncapped item may still be upgraded. Held copies of every capped
|
||||
shop item stay within that cap.
|
||||
- [ASSUMED] Consumption/destruction/removal by elimination ends ownership and therefore releases equipment stock;
|
||||
the video does not separately document every removal path. A third lifetime purchase is possible after a prior
|
||||
copy leaves ownership. Non-shop effect-only equipment has no shared shop cap, consistent with unlimited special
|
||||
gifts. This never becomes a two-appearance or two-purchase limit for commercial packaging.
|
||||
- Stock counts alone do not establish probabilities. Existing shop tier shares, uniform item selection and explicit
|
||||
effect-pool weights remain, filtered for availability. No new probability curve is inferred from the table.
|
||||
- `shared-stock.test.js` covers exceptions, shared ownership, stale offers, reacquisition after destruction, upgraded
|
||||
occupancy, consumption, duplicate draft exhaustion and Mimic overdraw/release.
|
||||
|
||||
### 29.9 Ember proposal (#465) — current behavior preserved
|
||||
|
||||
The history in §22.8 and the 0.1.2 changelog records the chosen compromise for queued 深池逐火 enemies. Comparing
|
||||
the current code with the proposed phase-order swap reproduces that risk: after a blocker takes the next warrior,
|
||||
the three husk types can be finished by ranged hits in the current reveal window; the proposal hides them throughout
|
||||
that window and they stand up again. Reveal or spare blocking capacity defeats them in either version. This is not
|
||||
absolute invulnerability, but it removes the previously chosen counterplay window. The proposal is not adopted;
|
||||
the existing phase-order assumption remains pending a better official comparison.
|
||||
|
||||
### 29.10 Approved scope additions
|
||||
|
||||
- #467: Clementia S2 includes selectable airborne targets in the expanded skill attack grid when deciding to cast,
|
||||
like Chen S3. Normal attacks stay ground-only. [ASSUMED] No mode recording establishes a separate trigger shape
|
||||
for this new operator. Tier/form, range, stealth/sleep, stun and operation-cooldown regressions cover it.
|
||||
- #451: the reserve medic appears in preparation, scouting and boss-half views as a read-only map character,
|
||||
including fallback rendering. Touch replaces it when battle starts. Initial aggro order remains unchanged.
|
||||
|
||||
### 29.11 Champagne selection follow-up (#476, replaces #468)
|
||||
|
||||
[PRTS 香槟炸弹](https://prts.wiki/w/香槟炸弹) identifies it as a device, normally excluded from ordinary allied selection. Both bomb creation paths now share the internal `isolated` selection barrier: Angelina auras and Skadi inspiration no longer select it, while ordinary unhealable summons remain selectable. HP protection, no-heal behavior and detonation retain the previous rules. The new source is PR #476 by Siornya; its original-art portion was first proposed in #468. Both creation paths, ordinary-summon controls and existing aged/contact detonations are tested.
|
||||
|
||||
### 29.12 Validation and golden changes
|
||||
|
||||
Full regeneration after the post-revive Raid reselect, the shared-damage split and the 整备 stock check. The corpus is
|
||||
287 scenarios. No release scenario was removed. Two scenarios were added.
|
||||
|
||||
265 of the 285 release scenarios keep the 0.2.3 digest (`1db8e510`): roster 49/49, bonds 46/46, fields 22/22,
|
||||
stand-ins 10/10, and 138 of 140 DIY battles. The taunt order applies only to the one reselect after a revive, and
|
||||
these battle digests stay on the release result.
|
||||
|
||||
`diy-135` and `diy-136` move because their fields include Clementia. `op-clemnt.js` is the only operator kit on
|
||||
either field that differs from 0.2.3; the Mon3tr kit, and the defender, guard and sniper kits on `diy-136`, are the
|
||||
release kits. The splash and airborne-cast changes are §29.4 and §29.10.
|
||||
|
||||
- `diy-135` (Clementia S1–S3 and Mon3tr): `cleared` at 198.03 s, 150 kills, 0 leaks, perfect, 1 death becomes
|
||||
`timeout` at 230 s (5,941 → 6,900 ticks), 149 kills, 1 leak, not perfect, 0 deaths. Damage dealt 1,661,420 →
|
||||
1,648,194. The first unit row that moves is Clementia S1 (`chess_char_5_diy1_a`), kills 1 → 0.
|
||||
- `diy-136` (Clementia S1–S3 with defenders, guards and snipers): still `cleared` at 212.4 s, still not perfect.
|
||||
Kills 13 → 12, leaks 110 → 111, damage dealt 651,626 → 540,205. Clementia S2 (`chess_char_6_diy2_a`) kills
|
||||
7 → 6 and damage 383,594 → 320,372.
|
||||
|
||||
All 18 release matches move. Every one changes its shop-draw count, and the first settle row that differs changes
|
||||
the board hash (operator, tile and equipment). That is the shared-stock lineup change in §29.8. `solo-HARD-9-boosted`
|
||||
is the only match whose first differing row also changes kills and leaks; its board and bonds change on that same row.
|
||||
|
||||
The ending class changes:
|
||||
|
||||
- `solo-FUNNY-2`: defeat at round 8, team LP 0, boss HP left 17,162 becomes victory at round 9, team LP 2, boss
|
||||
cleared. Shop draws 123 → 154. The round-1 board changes and gains `yanShip 0/1`.
|
||||
- `solo-HARD-1`: victory at round 14, team LP 12, boss cleared becomes defeat at round 13, team LP 0, boss HP left
|
||||
341,377. Shop draws 201 → 215. The round-3 board changes.
|
||||
- `solo-HARD-2`: defeat at round 13, team LP 0, boss HP left 66,239 becomes victory at round 14, team LP 28, boss
|
||||
cleared. Shop draws 279 → 283. The round-1 board changes.
|
||||
- `solo-ABYSS-1`: eliminated in round 13 after 12 rounds becomes defeat at round 13, still alive, team LP 0, boss HP
|
||||
left 2,832,671, hidden boss unreached. Shop draws 138 → 215. The round-3 board changes.
|
||||
- `solo-ABYSS-2`: eliminated in round 12 after 11 rounds becomes defeat at round 13, still alive, team LP 0, boss HP
|
||||
left 2,823,834, hidden boss unreached. Shop draws 179 → 280. The round-1 board changes.
|
||||
- `coop3-ABYSS-5`: eliminated at round 11 becomes defeat at round 13, team LP 0, boss HP left 2,616,799, hidden boss
|
||||
unreached. `ai_0` stays eliminated in round 10. `ai_1` is no longer eliminated in round 12 and passes 13 rounds.
|
||||
`ai_2` is eliminated in round 11 after 10 rounds, previously round 10 after 9. Shop draws 531 → 506. The round-2
|
||||
board changes.
|
||||
- `coop4-ABYSS-7`: eliminated at round 12 becomes defeat at round 13, team LP 0, boss HP left 3,486,896, hidden boss
|
||||
unreached. `ai_0` is no longer eliminated in round 13 and passes 13 rounds. `ai_1` passes 8 → 11 rounds and is
|
||||
eliminated in round 12 instead of 9. `ai_2` passes 11 → 12 rounds and is eliminated in round 13 instead of 12.
|
||||
`ai_3` stays eliminated in round 8 after 7 rounds. Shop draws 539 → 645. Round-4 boards, bonds, pending gold and
|
||||
layer counts change.
|
||||
|
||||
Same victory and reason, team LP changes:
|
||||
|
||||
- `solo-FUNNY-1`: victory at round 9, team LP 27 → 16. Shop draws 142 → 87. The round-1 board drops `sargonShip`.
|
||||
- `solo-NORMAL-2`: victory at round 14, team LP 25 → 15. Shop draws 227 → 226. The round-1 board changes.
|
||||
- `coop2-NORMAL-3`: victory at round 14, team LP 68 → 51 (`ai_0` 41 → 34, `ai_1` 27 → 17). Shop draws 449 → 487.
|
||||
The round-3 `ai_1` board changes.
|
||||
- `coop2-NORMAL-8-serverrun`: victory at round 14, team LP 58 → 26 (`ai_0` 30 → 10, `ai_1` 28 → 16). Shop draws
|
||||
485 → 482. The round-1 `ai_1` board changes and `preciShip` goes from tier 1 to tier 0.
|
||||
- `coop2-NORMAL-14-standins`: victory at round 14, team LP 30 → 31 (human 17 → 18). Shop draws 428 → 403. The
|
||||
round-4 boards change and the human's `raidShip 1/8` is gone. Stand-ins were fielded in 10 of 13 settle rows at
|
||||
0.2.3 and in 0 of 13 now.
|
||||
- `coop2-ABYSS-10-boosted`: victory at round 15 with the hidden boss cleared, team LP 630 → 704 (`ai_0` 315 → 354,
|
||||
`ai_1` 315 → 350). Shop draws 591 → 649. The round-1 boards change.
|
||||
|
||||
Same victory, reason, rounds and team LP:
|
||||
|
||||
- `solo-NORMAL-1`: defeat at round 13, team LP 0. Boss HP left 884 → 50,897. Shop draws 207 → 159. The round-3
|
||||
board changes.
|
||||
- `solo-HARD-9-boosted`: victory at round 15, team LP 399, hidden boss cleared. Shop draws 346 → 343. Round 8
|
||||
changes the board, the bonds, gold, kills 18 → 19 and leaks 1 → 0 together; that row's LP is 399 → 400 and the
|
||||
final team LP stays 399.
|
||||
- `coop2-HARD-4`: eliminated at round 12. Shop draws 419 → 417. The round-3 boards and bonds change (`ai_0` gains
|
||||
`arcaneShip` and `preciShip`; `ai_1` loses `sargonShip`).
|
||||
- `coop2-NORMAL-70-diy`: victory at round 14, team LP 55. Shop draws 503 → 586. The round-3 boards change and the
|
||||
human's bonds become `miraShip` and `yanShip`. DIY operators were fielded in 6 of 13 settle rows at 0.2.3 and in
|
||||
0 of 13 now. Virtual duration 121,400 → 118,400 ms.
|
||||
- `coop4-FUNNY-6`: victory at round 14, team LP 130. Shop draws 832 → 952. The round-1 `ai_2` board, layers and
|
||||
bonds change. Virtual duration 130,000 → 127,000 ms.
|
||||
|
||||
Added coverage, both victories at round 14:
|
||||
|
||||
- `coop2-NORMAL-1-coverage-standins`: team LP 37 (human 11, bot 26). Stand-ins are fielded in 6 of 13 settle rows.
|
||||
- `coop2-NORMAL-5-coverage-diy`: team LP 30 (human 23, bot 7). DIY operators are fielded in 5 of 13 settle rows.
|
||||
|
||||
Seeds 14 and 70 keep their outcome rows and, under these lineups, field no stand-in and no DIY operator. The two
|
||||
added seeds carry those paths.
|
||||
|
||||
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. A refresh of the same field whose metadata lists no device keeps crates and turrets already noted from their one spawn event; metadata that names devices replaces that list. A different field does not keep the previous battle's devices.
|
||||
|
||||
### 29.13 Multiplayer boss Raid landing (#475)
|
||||
|
||||
The maintainer approved a narrow revision to §26.1 on 2026-10-10 after the report of three remaining multiplayer
|
||||
seats leaving one boss field unpaired. Only Raid in `mode_multi_*` Final Assault / Hidden Core battles may use the
|
||||
empty half's normal deployment area (rows 2–5, cols 2–18). This is an explicit maintainer decision on the community
|
||||
report, not a claim of independently verified official footage. Hand row 0, temporary row 1, single-player mode's
|
||||
empty boss half and lone-helper 联防's empty half stay closed. Terrain, field bounds, living/downed reservations,
|
||||
target ordering and attack reach retain their checks. The exception is local to Raid; general board membership,
|
||||
Ulpian S3 movement, summons and preparation placement do not expand.
|
||||
|
||||
`feedback5-raid-own-board.test.js` reproduces the eight left/right, melee/ranged, boss/hidden blocked cases before
|
||||
the fix, and checks actual solo modes, hand/temp/side margins, terrain and living/downed reservations afterward.
|
||||
All 287 golden scenarios were regenerated and verified with no additional digest changes. A fresh 80-match sample
|
||||
also had zero errors and unchanged wins, completed rounds and final LP versus the preceding candidate. Two co-op
|
||||
HARD runs changed round-14 counters: seed 33's unpaired player killed 22 enemies instead of 26; seed 39's unpaired
|
||||
player killed 5 instead of 6 and leaked 1 instead of 0. Opening more landing tiles does not guarantee better outcomes.
|
||||
|
||||
### 29.14 Device cards after field refresh
|
||||
|
||||
Re-entering the same battle with metadata that omits once-spawned devices now restores the remembered device records
|
||||
to the renderer as well as to the detail-card lookup. Otherwise an HP snapshot recreated the crate as an unknown
|
||||
enemy: picking emitted a unit click with no definition, instead of the tile click needed by the device card. The
|
||||
engine-canvas regression retains the card/100 HP assertions and also covers another field refresh, live HP loss and
|
||||
closing the card when the crate is destroyed. Device metadata supplied by the new entry still wins; changing fields
|
||||
does not carry old devices across. Gameplay and allied targeting rules are unchanged.
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "stronghold-protocol-alliance",
|
||||
"version": "0.2.3",
|
||||
"version": "0.2.4-dev",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "stronghold-protocol-alliance",
|
||||
"version": "0.2.3",
|
||||
"version": "0.2.4-dev",
|
||||
"hasInstallScript": true,
|
||||
"license": "GPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "stronghold-protocol-alliance",
|
||||
"version": "0.2.3",
|
||||
"version": "0.2.4-dev",
|
||||
"private": true,
|
||||
"description": "明日方舟「卫戍协议:盟约」非官方同人联机网页复刻(1–4 人合作,Node.js 服务器 + 浏览器客户端)",
|
||||
"license": "GPL-3.0-or-later",
|
||||
|
||||
@@ -1030,14 +1030,43 @@
|
||||
.topbar__center { display: flex; flex-direction: column; align-items: center; gap: .03rem; text-align: center; }
|
||||
.topbar__title { display: flex; align-items: center; gap: .18rem; font-size: calc(.3 * var(--t)); font-weight: 700; letter-spacing: .1em; line-height: 1.2; }
|
||||
.topbar__sep { width: 1px; height: .28rem; background: var(--line-2); }
|
||||
/* the lobby's ⚙ / 📖 / install squares: a size up from `sm` and spaced apart; on touch screens at least 40 px each with
|
||||
a 10 px gap, so their 44 px hit areas (css/devices.css) never overlap and a tap lands on the intended one */
|
||||
.topbar__tools { display: flex; align-items: center; gap: .14rem; }
|
||||
.topbar__tools .btn--square.btn--sm { --sq: .46rem; height: var(--sq); min-width: var(--sq); }
|
||||
.sp-coarse .topbar__tools { gap: max(.16rem, 10px); }
|
||||
.sp-coarse .topbar__tools .btn--square.btn--sm { --sq: max(.56rem, 40px); }
|
||||
.topbar__right { justify-self: end; display: flex; align-items: center; gap: .16rem; min-width: 0; }
|
||||
|
||||
/* Optional resume/install controls and translated XL titles must share actual space instead of overlapping the
|
||||
centered 1fr/auto/1fr title. On narrow displays the title has its own row; the controls stay in their two groups. */
|
||||
.lobby-screen .topbar { grid-template-columns: auto minmax(0, 1fr) auto; height: auto; min-height: 1.1rem; padding-block: .12rem; }
|
||||
.lobby-screen .topbar__center { min-width: 0; }
|
||||
.lobby-screen .topbar__center .micro { max-width: 100%; overflow: hidden; text-overflow: ellipsis; }
|
||||
.lobby-screen .topbar__title { justify-content: center; overflow-wrap: anywhere; }
|
||||
@media (max-width: 1000px) {
|
||||
.lobby-screen .topbar { grid-template-columns: minmax(0, 1fr) auto; gap: .1rem .2rem; }
|
||||
.lobby-screen .topbar__center { grid-column: 1 / -1; grid-row: 2; }
|
||||
.lobby-screen .topbar__center .micro { display: none; }
|
||||
.lobby-screen .topbar__right { grid-column: 2; grid-row: 1; }
|
||||
.lobby-screen .me-chip__name { max-width: 1.5rem; }
|
||||
}
|
||||
|
||||
.section-label { display: flex; align-items: baseline; gap: .12rem; margin: 0 0 .14rem; font-size: calc(.2 * var(--t)); font-weight: 700; letter-spacing: .08em; }
|
||||
.section-label__idx { font-size: calc(.15 * var(--t)); color: var(--mint-500); padding: 0 .06rem; border: 1px solid var(--mint-800); line-height: 1.35; letter-spacing: .06em; }
|
||||
|
||||
.me-chip { display: flex; align-items: center; gap: .12rem; padding: .06rem .18rem .06rem .06rem; border: 1px solid var(--line); background: rgba(0, 0, 0, .35); }
|
||||
.me-chip__text { display: flex; flex-direction: column; line-height: 1.3; min-width: 0; }
|
||||
.me-chip__name { font-weight: 700; font-size: calc(.18 * var(--t)); max-width: 2.4rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
/* the lobby's chip doubles as the 统计数据 entry */
|
||||
.me-chip--btn {
|
||||
font: inherit; color: inherit; text-align: left; cursor: pointer;
|
||||
transition: border-color var(--t-fast) var(--ease-out), background-color var(--t-fast) var(--ease-out);
|
||||
}
|
||||
.me-chip--btn:hover { border-color: var(--mint-700); background: rgba(34, 42, 38, .6); }
|
||||
.me-chip--btn:active { transform: translateY(1px); }
|
||||
.me-chip__stats { flex: none; width: .2rem; height: .2rem; margin-left: .04rem; color: var(--mint-500); opacity: .7; }
|
||||
.me-chip--btn:hover .me-chip__stats { opacity: 1; }
|
||||
|
||||
.crash { align-items: center; justify-content: center; }
|
||||
.crash__box {
|
||||
|
||||
@@ -520,6 +520,9 @@
|
||||
inside the label, so the label lays glyph and text out like the button itself */
|
||||
.settings-btn .btn__label { display: inline-flex; align-items: center; gap: .1rem; }
|
||||
.settings-btn .btn__icon { color: var(--mint-500); }
|
||||
/* square (the lobby's gear alone): the label box drops out so the gear sizes and centres against the button, like an `icon` */
|
||||
.settings-btn.btn--square .btn__label { display: contents; }
|
||||
.settings-btn.btn--square .btn__icon { width: 62%; height: 62%; } /* the gear glyph is drawn smaller in its box than the book */
|
||||
.set-list { display: flex; flex-direction: column; gap: .16rem; padding-top: .06rem; }
|
||||
.set-row { display: grid; grid-template-columns: minmax(0, 3.3rem) minmax(0, 1fr) auto; align-items: center; gap: .18rem; min-height: .44rem; }
|
||||
/* The label column may shrink (the label wraps) instead of pushing its control out of the modal: at 设置 →「文字大小」
|
||||
|
||||
@@ -455,44 +455,44 @@
|
||||
padding: .1rem; font: inherit; color: var(--text-md); background: none; border: 0; cursor: pointer; touch-action: manipulation;
|
||||
}
|
||||
.diy-slot__fill:hover { background: rgba(255, 255, 255, .05); color: var(--text-hi); }
|
||||
.diy-slot__fill b { font-size: max(.16rem, 11px); }
|
||||
.diy-slot__fill small { font-size: max(.12rem, 9px); color: var(--text-lo); }
|
||||
.diy-slot__fill b { font-size: max(calc(.16 * var(--t)), 11px); }
|
||||
.diy-slot__fill small { font-size: max(calc(.12 * var(--t)), 9px); color: var(--text-lo); }
|
||||
.diy-slot__plus { display: grid; place-items: center; width: max(.4rem, 26px); height: max(.4rem, 26px); border: 1px solid var(--line-3); border-radius: 50%; }
|
||||
.diy-slot__plus .icon { width: 60%; height: 60%; }
|
||||
.diy-slot__art { display: grid; place-items: center; width: max(.9rem, 56px); height: max(1.3rem, 84px); overflow: hidden; background: #0a0d0c; border: 1px solid var(--line-2); }
|
||||
.diy-slot__art img { width: 100%; height: 100%; object-fit: cover; object-position: top; }
|
||||
.diy-slot__art b { font-size: max(.3rem, 18px); color: var(--text-lo); }
|
||||
.diy-slot__art b { font-size: max(calc(.3 * var(--t)), 18px); color: var(--text-lo); }
|
||||
.diy-slot__body { display: flex; flex-direction: column; gap: .03rem; min-width: 0; }
|
||||
.diy-slot__head { display: flex; align-items: center; gap: .05rem; }
|
||||
.diy-slot__label { font-size: max(.12rem, 9px); color: var(--text-lo); letter-spacing: .06em; }
|
||||
.diy-slot__name { font-size: max(.17rem, 12px); font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.diy-slot__class { font-size: max(.12rem, 9px); color: var(--text-lo); }
|
||||
.diy-slot__kit { display: flex; align-items: center; gap: .05rem; min-width: 0; font-size: max(.13rem, 9px); color: var(--text-md); }
|
||||
.diy-slot__label { font-size: max(calc(.12 * var(--t)), 9px); color: var(--text-lo); letter-spacing: .06em; }
|
||||
.diy-slot__name { font-size: max(calc(.17 * var(--t)), 12px); font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.diy-slot__class { font-size: max(calc(.12 * var(--t)), 9px); color: var(--text-lo); }
|
||||
.diy-slot__kit { display: flex; align-items: center; gap: .05rem; min-width: 0; font-size: max(calc(.13 * var(--t)), 9px); color: var(--text-md); }
|
||||
.diy-slot__kit > span { min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.diy-slot__sicon, .diy-slot__micon { flex: none; display: inline-grid; place-items: center; width: max(.24rem, 16px); height: max(.24rem, 16px); object-fit: contain; font-size: .8em; color: var(--text-lo); }
|
||||
.diy-slot__micon--none { border: 1px solid var(--line-2); }
|
||||
.diy-slot__acts { display: flex; flex-direction: column; gap: .04rem; }
|
||||
/* 0.2.2: an owned pick's 潜能 / 练度 (screens/cultivation.js) — a prototype has neither (a dim line instead) */
|
||||
.diy-slot__cult { display: block; margin-top: .03rem; font-size: max(.12rem, 9px); }
|
||||
.diy-slot__cult { display: block; margin-top: .03rem; font-size: max(calc(.12 * var(--t)), 9px); }
|
||||
.diy-slot__cult .lo-cult { max-width: max(2.6rem, 200px); }
|
||||
.diy-slot__bad { display: flex; align-items: center; gap: .04rem; margin: .02rem 0 0; font-size: max(.12rem, 9px); color: var(--amber); }
|
||||
.diy-slot__bad { display: flex; align-items: center; gap: .04rem; margin: .02rem 0 0; font-size: max(calc(.12 * var(--t)), 9px); color: var(--amber); }
|
||||
.diy-slot.is-bad { border-color: rgba(255, 196, 0, .45); }
|
||||
.diy-tag { display: inline-flex; align-items: center; height: 1.5em; padding: 0 .4em; font-size: max(.11rem, 8px); font-weight: 700; border-radius: 1em; white-space: nowrap; }
|
||||
.diy-tag { display: inline-flex; align-items: center; height: 1.5em; padding: 0 .4em; font-size: max(calc(.11 * var(--t)), 8px); font-weight: 700; border-radius: 1em; white-space: nowrap; }
|
||||
.diy-tag--proto { color: var(--ice-900); background: var(--ice); }
|
||||
.diy-tag--own { color: var(--text-on-mint); background: var(--mint-500); }
|
||||
.diy-bonds { display: flex; flex-wrap: wrap; gap: .03rem .06rem; }
|
||||
.diy-bond { display: inline-flex; align-items: center; gap: .03rem; font-size: max(.12rem, 9px); color: var(--text-md); }
|
||||
.diy-bond { display: inline-flex; align-items: center; gap: .03rem; font-size: max(calc(.12 * var(--t)), 9px); color: var(--text-md); }
|
||||
.diy-bond .bglyph, .diy-bond img { width: max(.16rem, 11px); height: max(.16rem, 11px); }
|
||||
/* the picker covers the tab: the list of the slot's tier on the left, the chosen operator's skill / module on the right */
|
||||
.diy-pick { position: absolute; inset: 0; z-index: 3; display: flex; flex-direction: column; gap: .08rem; padding: .12rem .3rem .14rem; background: var(--bg-1, #0d1110); }
|
||||
.diy-pick__head { flex: none; display: flex; align-items: center; gap: .08rem; font-size: max(.16rem, 11px); }
|
||||
.diy-pick__note { flex: 1; min-width: 0; font-size: max(.12rem, 9px); color: var(--text-lo); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.diy-pick__head { flex: none; display: flex; align-items: center; gap: .08rem; font-size: max(calc(.16 * var(--t)), 11px); }
|
||||
.diy-pick__note { flex: 1; min-width: 0; font-size: max(calc(.12 * var(--t)), 9px); color: var(--text-lo); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.diy-pick__x { display: grid; place-items: center; width: max(.4rem, 28px); height: max(.4rem, 28px); color: var(--text-md); background: none; border: 1px solid var(--line-2); cursor: pointer; }
|
||||
.diy-pick__bar { flex: none; display: flex; align-items: center; gap: .1rem; flex-wrap: wrap; }
|
||||
.diy-pick__search { flex: 1 1 2rem; min-width: 120px; height: max(.36rem, 26px); padding: 0 .08rem; font: inherit; font-size: max(.13rem, 10px); color: var(--text-hi); background: rgba(255, 255, 255, .04); border: 1px solid var(--line-2); }
|
||||
.diy-pick__search { flex: 1 1 2rem; min-width: 120px; height: max(.36rem, 26px); padding: 0 .08rem; font: inherit; font-size: max(calc(.13 * var(--t)), 10px); color: var(--text-hi); background: rgba(255, 255, 255, .04); border: 1px solid var(--line-2); }
|
||||
.diy-pick__main { flex: 1 1 auto; min-height: 0; display: grid; grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr); gap: .12rem; }
|
||||
.diy-pick__list, .diy-pick__detail { min-height: 0; overflow: auto; -webkit-overflow-scrolling: touch; overscroll-behavior: contain; scrollbar-width: thin; scrollbar-color: var(--line-3) transparent; }
|
||||
.diy-pick__list { display: grid; grid-template-columns: repeat(auto-fill, minmax(max(3.2rem, 200px), 1fr)); align-content: start; gap: .06rem; }
|
||||
.diy-pick__list { display: grid; grid-template-columns: 1fr; align-content: start; gap: .06rem; }
|
||||
.diy-opt {
|
||||
position: relative; display: flex; align-items: center; gap: .07rem; min-width: 0; padding: .06rem; font: inherit; color: var(--text-hi); text-align: left;
|
||||
background: rgba(255, 255, 255, .03); border: 1px solid var(--line); cursor: pointer; touch-action: manipulation;
|
||||
@@ -504,19 +504,19 @@
|
||||
.diy-opt__ava img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.diy-opt__txt { display: flex; flex-direction: column; gap: .02rem; min-width: 0; }
|
||||
.diy-opt__top { display: flex; align-items: center; gap: .04rem; min-width: 0; }
|
||||
.diy-opt__name { font-size: max(.15rem, 11px); font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.diy-opt__class { display: inline-flex; align-items: center; gap: .03rem; font-size: max(.11rem, 8px); color: var(--text-lo); }
|
||||
.diy-opt__name { font-size: max(calc(.15 * var(--t)), 11px); font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.diy-opt__class { display: inline-flex; align-items: center; gap: .03rem; font-size: max(calc(.11 * var(--t)), 8px); color: var(--text-lo); }
|
||||
.diy-opt__prof { width: max(.14rem, 10px); height: max(.14rem, 10px); }
|
||||
.diy-opt__taken { position: absolute; right: .04rem; bottom: .03rem; font-size: max(.1rem, 8px); color: var(--amber); }
|
||||
.diy-opt__taken { position: absolute; right: .04rem; bottom: .03rem; font-size: max(calc(.1 * var(--t)), 8px); color: var(--amber); }
|
||||
.diy-pick__detail { display: flex; flex-direction: column; gap: .06rem; padding: .06rem; background: rgba(255, 255, 255, .02); border: 1px solid var(--line); }
|
||||
.diy-pick__who { display: flex; align-items: center; gap: .08rem; }
|
||||
.diy-pick__ava { display: grid; place-items: center; width: max(.6rem, 40px); height: max(.6rem, 40px); overflow: hidden; background: #0a0d0c; border: 1px solid var(--line-2); }
|
||||
.diy-pick__ava img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.diy-pick__wtxt { display: flex; flex-direction: column; flex: 1; min-width: 0; }
|
||||
.diy-pick__wtxt b { font-size: max(.17rem, 12px); }
|
||||
.diy-pick__wtxt small { font-size: max(.12rem, 9px); color: var(--text-lo); }
|
||||
.diy-pick__sec { display: flex; align-items: baseline; gap: .06rem; margin: .04rem 0 0; font-size: max(.14rem, 10px); color: var(--text-md); }
|
||||
.diy-pick__sec small { font-size: max(.11rem, 8px); color: var(--text-lo); font-weight: 400; }
|
||||
.diy-pick__wtxt b { font-size: max(calc(.17 * var(--t)), 12px); }
|
||||
.diy-pick__wtxt small { font-size: max(calc(.12 * var(--t)), 9px); color: var(--text-lo); }
|
||||
.diy-pick__sec { display: flex; align-items: baseline; gap: .06rem; margin: .04rem 0 0; font-size: max(calc(.14 * var(--t)), 10px); color: var(--text-md); }
|
||||
.diy-pick__sec small { font-size: max(calc(.11 * var(--t)), 8px); color: var(--text-lo); font-weight: 400; }
|
||||
.diy-pick__choices { display: flex; flex-direction: column; gap: .04rem; }
|
||||
.diy-choice {
|
||||
display: flex; align-items: flex-start; gap: .07rem; padding: .05rem; font: inherit; color: var(--text-hi); text-align: left;
|
||||
@@ -527,8 +527,8 @@
|
||||
.diy-choice__icon { flex: none; display: grid; place-items: center; width: max(.36rem, 24px); height: max(.36rem, 24px); color: var(--text-lo); }
|
||||
.diy-choice__icon img { width: 100%; height: 100%; object-fit: contain; }
|
||||
.diy-choice__body { display: flex; flex-direction: column; gap: .02rem; min-width: 0; }
|
||||
.diy-choice__head { display: flex; align-items: center; gap: .05rem; flex-wrap: wrap; font-size: max(.14rem, 10px); }
|
||||
.diy-choice__desc { font-size: max(.12rem, 9px); line-height: 1.45; color: var(--text-md); }
|
||||
.diy-choice__head { display: flex; align-items: center; gap: .05rem; flex-wrap: wrap; font-size: max(calc(.14 * var(--t)), 10px); }
|
||||
.diy-choice__desc { font-size: max(calc(.12 * var(--t)), 9px); line-height: 1.45; color: var(--text-md); }
|
||||
.diy-pick__foot { flex: none; display: flex; justify-content: flex-end; gap: .08rem; }
|
||||
/* 0.2.0 自选编队: the slots the player filled (the entry button's chip) */
|
||||
.lo-entry__diy { color: var(--text-on-mint); background: var(--mint-500); }
|
||||
@@ -590,7 +590,7 @@
|
||||
.lo-body.is-detail .lo-detail-wrap { display: flex; }
|
||||
.lo-detail-back {
|
||||
display: inline-flex; align-self: flex-start; align-items: center; gap: .04rem; margin-bottom: .08rem; padding: .04rem .12rem .04rem .04rem;
|
||||
font: inherit; font-size: max(.15rem, 11px); color: var(--mint-400); background: transparent; border: 1px solid var(--mint-800); cursor: pointer;
|
||||
font: inherit; font-size: max(calc(.15 * var(--t)), 11px); color: var(--mint-400); background: transparent; border: 1px solid var(--mint-800); cursor: pointer;
|
||||
}
|
||||
.lo-detail-back .icon { width: .2rem; height: .2rem; }
|
||||
}
|
||||
@@ -605,10 +605,10 @@
|
||||
}
|
||||
/* the shared Button / TextField scale with the root (40 px/rem on phones ⇒ 6–7 px text, 14–20 px tall): the overlay's
|
||||
own controls (返回, 全部恢复默认, 恢复默认, the search field) keep a readable size and a tappable height (review fix) */
|
||||
.lo .lo-back { height: max(.5rem, 32px); padding: 0 max(.2rem, 10px) 0 max(.12rem, 6px); font-size: max(.18rem, 12px); }
|
||||
.lo .lo-top__right .btn, .lo .lo-dhead__reset { height: max(.36rem, 28px); padding: 0 max(.16rem, 8px); font-size: max(.15rem, 11px); }
|
||||
.lo .lo-back { height: max(.5rem, 32px); padding: 0 max(.2rem, 10px) 0 max(.12rem, 6px); font-size: max(calc(.18 * var(--t)), 12px); }
|
||||
.lo .lo-top__right .btn, .lo .lo-dhead__reset { height: max(.36rem, 28px); padding: 0 max(.16rem, 8px); font-size: max(calc(.15 * var(--t)), 11px); }
|
||||
.lo .lo-search .field__box { height: max(.56rem, 28px); padding: 0 max(.16rem, 6px); }
|
||||
.lo .lo-search .field__input { font-size: max(.2rem, 12px); }
|
||||
.lo .lo-search .field__input { font-size: max(calc(.2 * var(--t)), 12px); }
|
||||
.lo .lo-search .field__icon { width: max(.22rem, 14px); height: max(.22rem, 14px); }
|
||||
.sp-coarse .lo-chip, .sp-coarse .lo-toggle, .sp-coarse .lo-select { min-height: 30px; }
|
||||
.sp-coarse .lo-seg button { min-height: 28px; }
|
||||
@@ -617,15 +617,15 @@
|
||||
/* 导出 / 导入 (干员调配 presets): the dialog is the shared Modal, rendered NEXT TO `.lo` (a sibling) rather than inside
|
||||
it — so components.css `.modal { position: fixed }` and its z-index above the overlay apply as they do everywhere
|
||||
else, and `.lo > *` needs no exception for it. Below: the payload textarea + the hidden input behind 选择文件. */
|
||||
.lo-io__hint { margin: 0 0 max(.1rem, 8px); font-size: max(.18rem, 12px); line-height: 1.55; color: var(--text-md); }
|
||||
.lo-io__hint b { color: var(--mint-400); font-size: max(.22rem, 13px); margin: 0 .02rem; }
|
||||
.lo-io__hint { margin: 0 0 max(.1rem, 8px); font-size: max(calc(.18 * var(--t)), 12px); line-height: 1.55; color: var(--text-md); }
|
||||
.lo-io__hint b { color: var(--mint-400); font-size: max(calc(.22 * var(--t)), 13px); margin: 0 .02rem; }
|
||||
.lo-io__hint strong { color: var(--gold); }
|
||||
.lo-io__text {
|
||||
box-sizing: border-box; width: 100%; height: max(2.4rem, 160px); min-height: 120px; resize: vertical;
|
||||
padding: max(.1rem, 8px) max(.12rem, 10px);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
/* 16 px: iOS zooms into a focused field below that (same reason as ui/clipboard.js) */
|
||||
font-size: max(.2rem, 16px); line-height: 1.45;
|
||||
font-size: max(calc(.2 * var(--t)), 16px); line-height: 1.45;
|
||||
color: var(--text-hi); background: rgba(4, 10, 8, .55);
|
||||
border: 1px solid var(--line-2); border-radius: 2px; outline: none;
|
||||
/* wrap instead of a horizontal scrollbar: phones are narrow and the payload is meant to be read/copied whole */
|
||||
@@ -639,3 +639,14 @@
|
||||
.lo-voice { display: flex; flex-wrap: wrap; align-items: center; gap: .12rem; padding: .14rem 0; font-size: calc(.18 * var(--t)); }
|
||||
.lo-voice > small { flex-basis: 100%; color: var(--text-dim); font-size: calc(.15 * var(--t)); }
|
||||
.lo-voice .lo-select { min-width: 1.6rem; max-width: 100%; }
|
||||
|
||||
/* DIY uses the same aligned rows and detail sections as operator loadout. */
|
||||
.diy-pick__list .diy-opt { min-height: 64px; padding: .1rem; }
|
||||
.diy-pick__list .diy-opt__txt { flex: 1; display: grid; grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr) minmax(0, 1fr); align-items: center; gap: .1rem; }
|
||||
.diy-pick__list .diy-opt__top { flex-wrap: wrap; }
|
||||
.diy-pick__detail > .lo-sec { flex: none; }
|
||||
@media (max-width: 900px) {
|
||||
.diy-pick__list .diy-opt__txt { display: flex; flex-direction: column; align-items: flex-start; gap: .04rem; }
|
||||
}
|
||||
|
||||
.diy-choice__tags { display: flex; flex-wrap: wrap; gap: .08rem; color: var(--text-lo); font-size: max(calc(.12 * var(--t)), 10px); }
|
||||
@@ -10,6 +10,7 @@
|
||||
-webkit-mask-image: radial-gradient(70% 70% at 25% 50%, #000, transparent); mask-image: radial-gradient(70% 70% at 25% 50%, #000, transparent); }
|
||||
.result__main { position: relative; z-index: 1; flex: 1; min-height: 0; display: grid; grid-template-columns: 5.6rem 1fr; gap: .5rem; padding: .36rem .5rem .3rem .6rem; }
|
||||
.result__hero { display: flex; flex-direction: column; align-items: flex-start; gap: .16rem; padding-top: .3rem; min-height: 0; }
|
||||
.result__summary { display: flex; flex-direction: column; align-items: flex-start; gap: .16rem; min-height: 0; width: 100%; overflow: auto; overscroll-behavior: contain; scrollbar-width: thin; }
|
||||
.result__logoimg { height: 1.1rem; width: auto; opacity: .92; }
|
||||
.result__headline { font-size: calc(.96 * var(--t)); font-weight: 900; letter-spacing: .12em; line-height: 1.05; color: var(--mint-400); text-shadow: 0 0 .4rem rgba(23, 249, 183, .45); animation: head-in 600ms var(--ease-out); }
|
||||
.result.is-lose .result__headline { color: var(--red-premium); text-shadow: 0 0 .4rem rgba(255, 60, 60, .4); }
|
||||
@@ -74,5 +75,5 @@
|
||||
.rstat { display: flex; align-items: baseline; justify-content: space-between; gap: .06rem; padding: .05rem .1rem; background: #0e1210; box-shadow: 0 0 0 1px var(--line); font-size: calc(.13 * var(--t)); color: var(--text-lo); white-space: nowrap; min-width: 0; }
|
||||
.rstat b { font-size: calc(.18 * var(--t)); color: #fff; }
|
||||
.rstat.is-best b { color: var(--gold); }
|
||||
.result__foot { margin-top: auto; padding-top: .2rem; display: flex; }
|
||||
.result__foot { flex: 0 0 auto; margin-top: auto; padding-top: .2rem; display: flex; }
|
||||
.result__foot .btn { min-width: 3.4rem; }
|
||||
+6
-1
@@ -1207,5 +1207,10 @@
|
||||
"请等待所有玩家连接后再发起投票": "Wait for all players to connect before starting a vote.",
|
||||
"同意重刷": "Agree to reroll",
|
||||
"拒绝重刷": "Reject reroll",
|
||||
"取消投票": "Cancel vote"
|
||||
"取消投票": "Cancel vote",
|
||||
"只能部署在召唤者相邻四格": "Can only be deployed on the four tiles adjacent to the summoner",
|
||||
"{names}只能部署在召唤者相邻四格,已退回整备区": "{names} can only be deployed on the four tiles adjacent to the summoner and returned to the Bench",
|
||||
"{names}只能部署在召唤者相邻四格,整备区已满,下回合返还": "{names} can only be deployed on the four tiles adjacent to the summoner; the Bench is full, so they come back next round",
|
||||
"装备库存不足,未获得该装备": "Equipment stock is insufficient; no equipment was received",
|
||||
"库存不足": "Out of stock"
|
||||
}
|
||||
+14
-9
@@ -200,7 +200,7 @@
|
||||
"该队友当前没有战场": "この同盟者には現在戦場がありません",
|
||||
"队友与你在同一战场,使用 ‹ › 切换视角": "同盟者はあなたと同じ戦場にいます。‹ › で視点を切り替えます",
|
||||
"无法查看另一组队友的战场": "もう一組の同盟者の戦場は確認できません",
|
||||
"作战中无法查看队友,作战结束后可前往查看": "戦闘中は同盟者を確認できません。戦闘終了後に確認できます",
|
||||
"作战中无法查看队友,作战结束后可前往查看": "戦闘中は同盟者を確認できません。作戦終了後に確認できます",
|
||||
"无人在家": "不在",
|
||||
"你自己": "自分",
|
||||
"全景": "全体",
|
||||
@@ -362,7 +362,7 @@
|
||||
"{owner} 的盟约": "{owner}の盟約",
|
||||
"我的盟约": "自分の盟約",
|
||||
"(含调和 +{harmony})": "(調和 +{harmony} を含む)",
|
||||
"层数叠加已禁用": "加算数は増加しません",
|
||||
"层数叠加已禁用": "盟約加算無効化中",
|
||||
"调和": "調和",
|
||||
"层数": "加算数",
|
||||
"盟约效果": "盟約効果",
|
||||
@@ -408,8 +408,8 @@
|
||||
"再次点击": "もう一度タップ",
|
||||
"选择中": "選択中",
|
||||
"队友作战进度": "同盟者の戦闘状況",
|
||||
"作战结束": "戦闘終了",
|
||||
"作战结束,等待队友完成作战": "戦闘終了。同盟者の戦闘終了を待っています",
|
||||
"作战结束": "作戦終了",
|
||||
"作战结束,等待队友完成作战": "作戦終了。同盟者の作戦終了を待っています",
|
||||
"你已被淘汰,正在观战": "あなたは脱落しました。観戦中",
|
||||
"上一个战场": "前の戦場",
|
||||
"下一个战场": "次の戦場",
|
||||
@@ -442,7 +442,7 @@
|
||||
"辅助": "補助",
|
||||
"特种": "特殊",
|
||||
"召唤物": "召喚物",
|
||||
"自动回复": "自動回復",
|
||||
"自动回复": "自然回復",
|
||||
"攻击回复": "攻撃回復",
|
||||
"受击回复": "被撃回復",
|
||||
"被动": "パッシブ",
|
||||
@@ -518,7 +518,7 @@
|
||||
"团队增益": "連合バフ",
|
||||
"生效中的效果": "発動中の効果",
|
||||
"表情": "スタンプ",
|
||||
"交流": "通信",
|
||||
"交流": "交流",
|
||||
"上一组表情": "前のスタンプ",
|
||||
"下一组表情": "次のスタンプ",
|
||||
"表情主题": "スタンプのテーマ",
|
||||
@@ -573,7 +573,7 @@
|
||||
"被源石侵蚀的假想敌": "源石に侵食された仮想敵",
|
||||
"回合开始": "ラウンド開始",
|
||||
"回合结算": "ラウンド集計",
|
||||
"休息一下": "ひと休み",
|
||||
"休息一下": "休憩タイム",
|
||||
"找不到该单位": "ユニットが見つかりません",
|
||||
"无法放置在该位置": "この位置には置けません",
|
||||
"位置未变化": "位置が変わっていません",
|
||||
@@ -1098,7 +1098,7 @@
|
||||
"导入失败:游戏数据没有载入,未做任何改动。请刷新页面;仍不行时,请检查广告拦截插件和网络": "インポート失敗:ゲームデータが読み込まれていないため、何も変更していません。ページを更新してください。それでも解決しない場合は、広告ブロッカーとネットワークを確認してください",
|
||||
"游戏数据没有载入": "ゲームデータが読み込まれていません",
|
||||
"{files} 没有下载成功,这一页无法显示;已保存的设置不受影响。请刷新页面;仍不行时,请检查广告拦截插件和网络。": "{files}をダウンロードできなかったため、このページを表示できません。保存済みの設定に影響はありません。ページを更新してください。それでも解決しない場合は、広告ブロッカーとネットワークを確認してください。",
|
||||
"全员无伤!": "全員無傷!",
|
||||
"全员无伤!": "完璧な作戦!",
|
||||
"生命值减少 −{n}": "耐久値減少 −{n}",
|
||||
"当前语言的界面文字为机器翻译,可能不够准确,欢迎在 GitHub 上指正。": "この言語の画面表示は機械翻訳のため、不正確な場合があります。GitHubでの修正提案を歓迎します。",
|
||||
"统计数据": "統計データ",
|
||||
@@ -1213,5 +1213,10 @@
|
||||
"请等待所有玩家连接后再发起投票": "全プレイヤーの接続を待ってから投票を開始してください。",
|
||||
"同意重刷": "再抽選に同意",
|
||||
"拒绝重刷": "再抽選を拒否",
|
||||
"取消投票": "投票を取り消す"
|
||||
"取消投票": "投票を取り消す",
|
||||
"只能部署在召唤者相邻四格": "召喚者に隣接する上下左右の4マスにのみ配置可能",
|
||||
"{names}只能部署在召唤者相邻四格,已退回整备区": "{names}は召喚者に隣接する上下左右の4マスにのみ配置可能なため、ベンチに戻りました",
|
||||
"{names}只能部署在召唤者相邻四格,整备区已满,下回合返还": "{names}は召喚者に隣接する上下左右の4マスにのみ配置可能です。ベンチが満員のため、次のラウンドに返却されます",
|
||||
"装备库存不足,未获得该装备": "装備の在庫が不足しているため、獲得できませんでした",
|
||||
"库存不足": "在庫不足"
|
||||
}
|
||||
+6
-1
@@ -1209,5 +1209,10 @@
|
||||
"请等待所有玩家连接后再发起投票": "모든 플레이어가 접속한 후 투표를 시작하세요.",
|
||||
"同意重刷": "다시 추첨에 동의",
|
||||
"拒绝重刷": "다시 추첨 거부",
|
||||
"取消投票": "투표 취소"
|
||||
"取消投票": "투표 취소",
|
||||
"只能部署在召唤者相邻四格": "소환자와 상하좌우로 인접한 4칸에만 배치할 수 있습니다",
|
||||
"{names}只能部署在召唤者相邻四格,已退回整备区": "{names}은(는) 소환자와 상하좌우로 인접한 4칸에만 배치할 수 있어 정비 구역으로 돌아갔습니다",
|
||||
"{names}只能部署在召唤者相邻四格,整备区已满,下回合返还": "{names}은(는) 소환자와 상하좌우로 인접한 4칸에만 배치할 수 있습니다. 정비 구역이 가득 차 다음 라운드에 반환됩니다",
|
||||
"装备库存不足,未获得该装备": "장비 재고가 부족하여 획득하지 못했습니다",
|
||||
"库存不足": "재고 부족"
|
||||
}
|
||||
@@ -1211,5 +1211,10 @@
|
||||
"请等待所有玩家连接后再发起投票": "請等待所有玩家連線後再發起投票",
|
||||
"同意重刷": "同意重刷",
|
||||
"拒绝重刷": "拒絕重刷",
|
||||
"取消投票": "取消投票"
|
||||
"取消投票": "取消投票",
|
||||
"只能部署在召唤者相邻四格": "只能部署在召喚者相鄰四格",
|
||||
"{names}只能部署在召唤者相邻四格,已退回整备区": "{names}只能部署在召喚者相鄰四格,已退回整備區",
|
||||
"{names}只能部署在召唤者相邻四格,整备区已满,下回合返还": "{names}只能部署在召喚者相鄰四格,整備區已滿,下回合返還",
|
||||
"装备库存不足,未获得该装备": "裝備庫存不足,未獲得該裝備",
|
||||
"库存不足": "庫存不足"
|
||||
}
|
||||
+2
-1
@@ -840,10 +840,11 @@ export function createAssets(options) {
|
||||
});
|
||||
|
||||
/** Image element (cached; failures resolve to null). */
|
||||
function image(u) {
|
||||
function image(u, { retry = false } = {}) {
|
||||
const s = str(u);
|
||||
if (!s) return Promise.resolve(null);
|
||||
let e = images.get(s);
|
||||
if (retry && e?.done && !e.value) { images.delete(s); e = null; }
|
||||
if (!e) {
|
||||
e = { value: null, done: false, promise: null };
|
||||
e.promise = Promise.resolve().then(() => loadImage(s)).then(
|
||||
|
||||
+22
-6
@@ -432,6 +432,13 @@ export async function createFieldView(host, options = {}) {
|
||||
}
|
||||
// Attach the board only after synchronous setup completes; enable3d also guards late arrivals.
|
||||
const boardReady = Promise.all([threePromise, packPromise]).then(([THREE, pack]) => (THREE && pack ? enable3d(THREE, pack) : false), () => false);
|
||||
let boardRetry = null;
|
||||
function retryBoard() {
|
||||
if (!want3d || destroyed || board3d || recover.off || recover.timer || boardRetry) return;
|
||||
boardRetry = Promise.all([loadThree(), loadBoardPack(assets)])
|
||||
.then(([THREE, pack]) => enable3d(THREE, pack), () => false)
|
||||
.finally(() => { boardRetry = null; });
|
||||
}
|
||||
// the official soft shadow sprite replaces the procedural one once loaded (may already be cached; asked again when the
|
||||
// manifest arrives late)
|
||||
let shadowAsked = false;
|
||||
@@ -797,8 +804,16 @@ export async function createFieldView(host, options = {}) {
|
||||
if (e.area === 'board' && info.dir && !held.has(e.uid) && typeof v.setDir === 'function') v.setDir(info.dir);
|
||||
}
|
||||
for (const k of [...views.keys()]) if (String(k).startsWith('p:') && !seen.has(Number(String(k).slice(2)))) dropView(k);
|
||||
// the boss round's prep: the pair partner's board on its half of the boss field (item 51; display only)
|
||||
syncMates(prepXf.kind === 'bossPrep' && src.bossMate && Array.isArray(src.bossMate.units) ? src.bossMate.units : []);
|
||||
// The 外勤医疗 reserve medic is visible before battle but not a movable board piece. Map its board-space slot to
|
||||
// the displayed boss half exactly as for the player's own pieces; the partner's units already use field coordinates.
|
||||
const mapChars = Array.isArray(src.prepMapChars) ? src.prepMapChars.map((u) => {
|
||||
if (!u || !Number.isInteger(u.x) || !Number.isInteger(u.y)) return null;
|
||||
const p = prepXf.toDisp(u.y, u.x);
|
||||
const dir = prepXf.dirToDisp(u.dir);
|
||||
return { ...u, x: p.col, y: p.row, dir, facing: dir === 'LEFT' ? -1 : 1 };
|
||||
}).filter(Boolean) : [];
|
||||
const mate = prepXf.kind === 'bossPrep' && src.bossMate && Array.isArray(src.bossMate.units) ? src.bossMate.units : [];
|
||||
syncReadOnlyPrepUnits([...mapChars, ...mate]);
|
||||
prepPieces = list.filter((e) => e.key && views.has(e.key));
|
||||
if (dragState && !views.has(dragState.key)) { drag.reset(); endDragVisual(false); }
|
||||
holdScene(false); // the prep pieces reference their models now (a battle's hold ends here)
|
||||
@@ -806,11 +821,10 @@ export async function createFieldView(host, options = {}) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The pair partner's pieces in the Final Assault / Hidden Core prep (m.private bossMate units: UnitInfo in boss-field
|
||||
* coordinates, facing as the battle will place them): read-only 'm:<uid>' views — not prep pieces, so they are never
|
||||
* picked, dragged or swept with the own ones; a view is rebuilt when its body, tile or facing changes.
|
||||
* The reserve medic and a boss pair's partner pieces: read-only 'm:<uid>' views. These are not prep pieces, so they
|
||||
* are never picked, dragged or swept with the own board; a view is rebuilt when its body, tile or facing changes.
|
||||
*/
|
||||
function syncMates(units) {
|
||||
function syncReadOnlyPrepUnits(units) {
|
||||
const keep = new Set();
|
||||
for (const u of units) {
|
||||
const info = u && Number.isInteger(u.uid) ? renderInfo({ ...u, id: `m:${u.uid}` }) : null;
|
||||
@@ -1747,6 +1761,7 @@ export async function createFieldView(host, options = {}) {
|
||||
// load it again at once (their bounded retries never run while hidden: no frames).
|
||||
function onAssets() {
|
||||
if (destroyed) return;
|
||||
retryBoard();
|
||||
loadShadow();
|
||||
loadMountains();
|
||||
if (mode === 'prep' && lastPrep) setPrep(lastPrep.ps, lastPrep.o);
|
||||
@@ -1758,6 +1773,7 @@ export async function createFieldView(host, options = {}) {
|
||||
setupCleanup.push(() => offAssets?.());
|
||||
const onVisible = () => {
|
||||
if (destroyed || globalThis.document?.visibilityState !== 'visible') return;
|
||||
retryBoard();
|
||||
if (assets.loaded === false && typeof assets.ready === 'function') assets.ready();
|
||||
for (const v of views.values()) v.retryAssets?.();
|
||||
for (const v of penViews.values()) v.retryAssets?.();
|
||||
|
||||
@@ -16,10 +16,20 @@ import { parseObj } from './obj.js';
|
||||
export const THREE_URL = '/vendor/three.module.js';
|
||||
|
||||
let threePromise = null;
|
||||
let threeFailures = 0;
|
||||
/** Dynamic import of the vendored three.js ESM build (browser only); null when unavailable. */
|
||||
export function loadThree(url = THREE_URL) {
|
||||
if (!threePromise) {
|
||||
threePromise = import(/* @vite-ignore */ url).then((m) => (m && m.WebGLRenderer ? m : null), () => null);
|
||||
// Browsers also cache failed module imports. Two bounded retry URLs permit a transient download failure to
|
||||
// recover; clearing only our promise would keep receiving that same cached rejection without another request.
|
||||
if (threeFailures >= 3) return Promise.resolve(null);
|
||||
const source = new URL(url, import.meta.url);
|
||||
if (threeFailures) source.searchParams.set('sp-retry', String(threeFailures));
|
||||
const pending = import(/* @vite-ignore */ source.href).then((m) => (m && m.WebGLRenderer ? m : null), () => null).then((m) => {
|
||||
if (!m && threePromise === pending) { threePromise = null; threeFailures++; }
|
||||
return m;
|
||||
});
|
||||
threePromise = pending;
|
||||
}
|
||||
return threePromise;
|
||||
}
|
||||
@@ -95,7 +105,7 @@ async function fetchJson(url) {
|
||||
*/
|
||||
export function loadBoardPack(assets) {
|
||||
if (cached) return cached;
|
||||
cached = (async () => {
|
||||
const pending = (async () => {
|
||||
if (!assets || typeof assets.local !== 'function' || typeof assets.localUrl !== 'function' || typeof assets.image !== 'function') return null;
|
||||
const manifest = await assets.local().catch(() => null);
|
||||
if (!isObj(manifest)) return null;
|
||||
@@ -106,7 +116,7 @@ export function loadBoardPack(assets) {
|
||||
await Promise.all(Object.entries(PACK_IMAGES).map(async ([k, [g, n]]) => {
|
||||
const u = url(g, n);
|
||||
if (!u) return;
|
||||
const img = await assets.image(u).catch(() => null);
|
||||
const img = await assets.image(u, { retry: true }).catch(() => null);
|
||||
if (img && (img.width || img.naturalWidth) > 0) images[k] = img;
|
||||
}));
|
||||
if (!images.D) return null;
|
||||
@@ -138,9 +148,15 @@ export function loadBoardPack(assets) {
|
||||
images, meshes, tiles: isObj(tiles) ? tiles : null, uv: resolveUvTable(isObj(tiles) ? tiles : null),
|
||||
materials: { theme: isObj(theme) ? theme : null, fx: isObj(fxMats) ? fxMats : null },
|
||||
};
|
||||
})().catch((err) => { console.warn('[board3d] art load failed', err); return null; });
|
||||
return cached;
|
||||
})().catch((err) => { console.warn('[board3d] art load failed', err); return null; }).then((pack) => {
|
||||
// A timeout / failed atlas is not proof the host lacks 3D art. A later mount or visibility change may retry.
|
||||
// An older attempt finishing after reset must not discard a newer cached request.
|
||||
if (!pack && cached === pending) cached = null;
|
||||
return pack;
|
||||
});
|
||||
cached = pending;
|
||||
return pending;
|
||||
}
|
||||
|
||||
/** Forget the cached pack (tests / hot reload). */
|
||||
export function resetBoardPack() { cached = null; threePromise = null; }
|
||||
/** Forget the cached pack and the three.js import attempt (tests / hot reload). */
|
||||
export function resetBoardPack() { cached = null; threePromise = null; threeFailures = 0; }
|
||||
@@ -116,7 +116,7 @@ export function normalizeSnapshot(snap) {
|
||||
if (tu && tu.length === 9) tu.push(e[1], clamp(finite(e[2]), 0, 1), finite(e[3]), Math.max(0, finite(e[4])));
|
||||
}
|
||||
}
|
||||
const ammo = countList(snap.ammo, units), wolves = countList(snap.wolves, units);
|
||||
const ammo = countList(snap.ammo, units), wolves = countList(snap.wolves, units), coins = countList(snap.coins, units);
|
||||
let neg = null;
|
||||
if (Array.isArray(snap.neg)) {
|
||||
for (const e of snap.neg) {
|
||||
@@ -147,7 +147,7 @@ export function normalizeSnapshot(snap) {
|
||||
(standCut || (standCut = new Map())).set(e[0], e[1]);
|
||||
}
|
||||
}
|
||||
return { t, units, down, ammo, wolves, neg, stand, standCut, raw: snap };
|
||||
return { t, units, down, ammo, wolves, coins, neg, stand, standCut, raw: snap };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -402,6 +402,7 @@ export class SnapshotBuffer {
|
||||
// the HP-bar readouts come with the older snapshot, like flags (whole rounds step with the skill flag)
|
||||
o.ammo = A.ammo ? A.ammo.get(id) || null : null;
|
||||
o.wolves = A.wolves ? A.wolves.get(id) || null : null;
|
||||
o.coins = A.coins?.get(id) ?? null;
|
||||
o.neg = A.neg ? A.neg.get(id) || 0 : 0;
|
||||
o.seen = stamp;
|
||||
}
|
||||
|
||||
@@ -477,6 +477,8 @@ export class UnitView {
|
||||
this.sp = 0; this.spMax = 0;
|
||||
this.ammo = null; // [rounds left, rounds in the magazine] while an ammo skill runs (sync; b.snap `ammo`)
|
||||
this.wolves = null; // [狼影 left, the talent's maximum] of a 狼群 (sync; b.snap `wolves`)
|
||||
this.coins = null; // [balance, cap] of 琳琅诗怀雅's skill purse, including zero
|
||||
this.coinHud = null;
|
||||
this.neg = 0; // the share of its cap a negative-HP pool holds, 0 = none (sync; b.snap `neg`, 斩业星熊's 我执)
|
||||
this.flags = 0; this.anim = ANIM.IDLE;
|
||||
this.statuses = new Set();
|
||||
@@ -825,6 +827,7 @@ export class UnitView {
|
||||
this.hp = hp;
|
||||
this.sp = s.sp; this.spMax = s.spMax;
|
||||
this.ammo = s.ammo || null; this.wolves = s.wolves || null; this.neg = s.neg > 0 ? Math.min(1, s.neg) : 0;
|
||||
this.coins = s.coins || null;
|
||||
const prevFlags = this.flags;
|
||||
this.flags = s.flags | 0;
|
||||
this.anim = s.anim | 0;
|
||||
@@ -1412,7 +1415,8 @@ export class UnitView {
|
||||
// knocked-down operator (b.snap `down`)
|
||||
const rowsTop = cy + bh / 2 + (showSp ? spH + 1.5 : 0);
|
||||
const wolfH = this._updateWolfPips(showBars && !this.isEnemy && !!this.wolves, x0, rowsTop, s);
|
||||
this._updateElementBar(showBars && !!this.el, x0, bw, rowsTop + wolfH + 1, spH, s, t);
|
||||
const coinH = this._updateCoins(showBars && !this.isEnemy && !!this.coins, x0, rowsTop + wolfH, s);
|
||||
this._updateElementBar(showBars && !!this.el, x0, bw, rowsTop + wolfH + coinH + 1, spH, s, t);
|
||||
this._updateDownRing(!prep && !!this.down && !this.alive, x, this.screen.y - DOWN_LOOK.height * s, s, t);
|
||||
// blocked marker at the feet (enemies held by a blocker)
|
||||
const blocked = !prep && this.alive && this.isEnemy && (this.flags & UF.BLOCKED);
|
||||
@@ -1493,6 +1497,26 @@ export class UnitView {
|
||||
return d + 2 + WOLF_PIPS.pad;
|
||||
}
|
||||
|
||||
/** Coin icon and balance/cap, read from the current snapshot rather than transient FX. */
|
||||
_updateCoins(show, x, top, s) {
|
||||
let r = this.coinHud;
|
||||
if (!show) { if (r) r.root.visible = false; return 0; }
|
||||
if (!r) {
|
||||
const P = this.P, root = new P.Container(), icon = new P.Sprite(fxAtlas().tex.coin);
|
||||
icon.width = icon.height = 16;
|
||||
const text = new P.Text('', { fontFamily: 'Bender, Oxanium, sans-serif', fontSize: 16, fontWeight: '700', fill: '#ffd04a', stroke: '#0b0f0e', strokeThickness: 3 });
|
||||
text.position.set(19, -2); root.addChild(icon, text); this.hud.addChild(root);
|
||||
r = this.coinHud = { root, text, label: '' };
|
||||
}
|
||||
const label = `${this.coins[0]}/${this.coins[1]}`;
|
||||
if (r.label !== label) { r.label = label; r.text.text = label; }
|
||||
const size = clamp(s * 0.2, 10, 16);
|
||||
r.root.scale.set(size / 16); r.root.position.set(x, top + 3); r.root.visible = true;
|
||||
// Text includes font metrics and stroke beyond its nominal 16 px size; reserve the measured row so the next
|
||||
// elemental gauge cannot overlap it (especially with a substituted browser font).
|
||||
return Math.max(16, r.root.getLocalBounds?.().height || 24) * (size / 16) + 8;
|
||||
}
|
||||
|
||||
/** True while the shown gauge is in its 爆发冷却 (b.snap `elem` carries the cooldown's end and length). */
|
||||
elementCooling() {
|
||||
return !!this.el && this.elDur > 0 && this.elUntil > 0;
|
||||
|
||||
@@ -14,9 +14,10 @@ import { html, Icon, Button, TierChip } from '../ui/components.js';
|
||||
import { Img, RichText, BondGlyph } from '../ui/gameComponents.js';
|
||||
import { chessAvatarUrl, chessPortraitUrl, profIconUrl, skillRecordIconUrl, moduleTypeIconUrl } from '../ui/assetUrls.js';
|
||||
import { data } from '../data.js';
|
||||
import { PROF_NAME, skillLabel, moduleBadge, fullTraitText } from '../ui/loadoutModel.js';
|
||||
import { PROF_NAME, skillLabel, moduleBadge, fullTraitText, skillTags } from '../ui/loadoutModel.js';
|
||||
import { diySlotList, pickChoices, pickOptions, slotRecord, defaultPick } from '../ui/diyModel.js';
|
||||
import { OperatorVoice } from '../ui/operatorVoice.js';
|
||||
import { LoadoutStats } from '../ui/loadoutStats.js';
|
||||
import { CultivationSelects } from './cultivation.js';
|
||||
import { t } from '../../../shared/i18n.js';
|
||||
|
||||
@@ -107,14 +108,16 @@ function OptionCard({ m, opt, selected, onSel }) {
|
||||
}
|
||||
|
||||
/** A skill choice of an owned pick (or the locked skill of a prototype). */
|
||||
function SkillRow({ m, s, on, locked, onPick }) {
|
||||
const rec = s.elite || s.normal;
|
||||
function SkillRow({ m, s, on, locked, onPick, level }) {
|
||||
const rec = level === 'elite' ? s.elite || s.normal : s.normal || s.elite;
|
||||
const tags = skillTags(rec);
|
||||
return html`<button type="button" role="radio" aria-checked=${on ? 'true' : 'false'} data-skill=${s.index} disabled=${locked}
|
||||
class=${cx('diy-choice', on && 'is-on')} onClick=${() => onPick(s.index)}>
|
||||
<span class="diy-choice__icon"><${Img} src=${skillRecordIconUrl(m, rec, { empty: false })} fallback=${html`<b class="num">${skillLabel(s.index)}</b>`} /></span>
|
||||
<span class="diy-choice__body">
|
||||
<span class="diy-choice__head"><b class="num">${skillLabel(s.index)}</b><b>${rec?.name || ''}</b>${on ? html`<span class="lo-badge lo-badge--on"><${Icon} name="check" />${locked ? t('锁定') : t('已选择')}</span>` : null}</span>
|
||||
<${RichText} as="span" class="diy-choice__desc" text=${s.normal?.descRaw || s.normal?.desc || ''} />
|
||||
<span class="diy-choice__tags"><span>${tags.sp}</span>${!tags.passive ? html`<span>${t('初始')} ${tags.init} · ${t('消耗')} ${tags.cost}</span>` : null}${tags.duration ? html`<span>${tags.duration}</span>` : null}</span>
|
||||
<${RichText} as="span" class="diy-choice__desc" text=${rec?.descRaw || rec?.desc || ''} />
|
||||
</span>
|
||||
</button>`;
|
||||
}
|
||||
@@ -143,6 +146,7 @@ function ModuleRow({ id, rec, on, locked, onPick }) {
|
||||
* @param {{ m: any, slot: any, picks: Record<string, any>, kitted: string[]|null, onDone: (pick: any) => void, onClose: () => void }} props
|
||||
*/
|
||||
export function DiyPicker(props) {
|
||||
const [previewLevel, setPreviewLevel] = useState('elite');
|
||||
const cur = props.picks[props.slot.slotId] || null;
|
||||
const [filter, setFilter] = useState('all');
|
||||
const [query, setQuery] = useState('');
|
||||
@@ -161,14 +165,14 @@ export function DiyPicker(props) {
|
||||
window.addEventListener('keydown', onKey, true);
|
||||
return () => window.removeEventListener('keydown', onKey, true);
|
||||
}, [props.onClose]);
|
||||
return DiyPickerView({ ...props, filter, query, draft, onFilter: setFilter, onQuery: setQuery, onDraft: setDraft });
|
||||
return DiyPickerView({ ...props, previewLevel, onPreviewLevel: setPreviewLevel, filter, query, draft, onFilter: setFilter, onQuery: setQuery, onDraft: setDraft });
|
||||
}
|
||||
|
||||
/**
|
||||
* The picker's view (no hooks: the tests draw it): `filter` 'all' | 'proto' | 'owned', `query`, `draft` the pick being
|
||||
* made (null = none chosen yet) and their setters.
|
||||
*/
|
||||
export function DiyPickerView({ m, slot, picks, kitted, onDone, onClose, filter, query, draft, onFilter: setFilter, onQuery: setQuery, onDraft: setDraft }) {
|
||||
export function DiyPickerView({ m, slot, picks, kitted, onDone, onClose, filter, query, draft, onFilter: setFilter, onQuery: setQuery, onDraft: setDraft, ops = {}, onOps = null, previewLevel = 'elite', onPreviewLevel = () => {} }) {
|
||||
const D = diyData();
|
||||
const cur = picks[slot.slotId] || null;
|
||||
const options = pickOptions(slot.slotId, picks, D, kitted);
|
||||
@@ -185,6 +189,11 @@ export function DiyPickerView({ m, slot, picks, kitted, onDone, onClose, filter,
|
||||
const modOn = ch ? (ch.proto ? lk?.uniEquipId ?? null : draft.uniEquipId ?? null) : null;
|
||||
const unit = draft ? D.backups?.units?.[draft.charId] : null;
|
||||
const label = t('{tier}阶 自选', { tier: slot.tier });
|
||||
const potential = ops[draft?.charId]?.potential ?? 6;
|
||||
const base = draft ? slotRecord(slot.slotId, draft, D, { potential }) : null;
|
||||
const golden = draft ? slotRecord(slot.slotId, draft, D, { elite: true, potential }) : null;
|
||||
const previewEntries = base ? { [base.baseId || base.chessId]: { skill: skillOn, module: modOn || 'none' } } : {};
|
||||
const previewChess = (id) => id === golden?.chessId ? golden : base;
|
||||
return html`<section class="diy-pick" role="dialog" aria-label=${t('{slot}:选择干员', { slot: label })} data-testid="diy-picker" data-slot=${slot.slotId}>
|
||||
<header class="diy-pick__head">
|
||||
<${TierChip} tier=${slot.tier} size="sm" /><b>${label}</b>
|
||||
@@ -212,9 +221,12 @@ export function DiyPickerView({ m, slot, picks, kitted, onDone, onClose, filter,
|
||||
<span class="diy-pick__wtxt"><b>${unit.name}</b><small>${classLine(unit)}</small></span>
|
||||
<${KindTag} proto=${ch.proto} />
|
||||
</div>
|
||||
${!ch.proto && onOps ? html`<section class="lo-sec"><header class="lo-sec__head"><h3>${t('潜能与练度')}</h3></header><${CultivationSelects} charId=${draft.charId} ops=${ops} onSet=${onOps} /></section>` : null}
|
||||
<${LoadoutStats} base=${base} golden=${golden} entries=${previewEntries} level=${previewLevel}
|
||||
onLevel=${onPreviewLevel} getChess=${previewChess} ops=${ch.proto ? null : ops} />
|
||||
<h4 class="diy-pick__sec">${t('技能')}${ch.proto ? html`<small>${t('原型干员的技能与补位时一致,不可更改')}</small>` : null}</h4>
|
||||
<div role="radiogroup" aria-label=${t('选择技能')} class="diy-pick__choices">
|
||||
${ch.skills.filter((s) => !ch.proto || s.index === skillOn).map((s) => html`<${SkillRow} key=${s.index} m=${m} s=${s} on=${s.index === skillOn} locked=${ch.proto}
|
||||
${ch.skills.filter((s) => !ch.proto || s.index === skillOn).map((s) => html`<${SkillRow} key=${s.index} m=${m} s=${s} level=${previewLevel} on=${s.index === skillOn} locked=${ch.proto}
|
||||
onPick=${(i) => setDraft({ ...draft, skillIndex: i })} />`)}
|
||||
</div>
|
||||
<h4 class="diy-pick__sec">${t('模组')}<small>${t('精锐时生效 · 模组等级 {stage}', { stage: ch.stage })}</small></h4>
|
||||
@@ -264,7 +276,7 @@ export function DiyPanelView({ m, picks, legal, kitted, onSet, picking, onPickin
|
||||
</div>
|
||||
</section>`)}
|
||||
</div>
|
||||
${slot ? html`<${DiyPicker} m=${m} slot=${slot} picks=${picks} kitted=${kitted} onClose=${() => setPicking(null)}
|
||||
${slot ? html`<${DiyPicker} m=${m} slot=${slot} picks=${picks} kitted=${kitted} ops=${ops} onOps=${onOps} onClose=${() => setPicking(null)}
|
||||
onDone=${(pick) => { onSet(slot.slotId, pick); setPicking(null); }} />` : null}
|
||||
</main>`;
|
||||
}
|
||||
@@ -96,7 +96,7 @@ import {
|
||||
phaseMode, phaseBanner, isCombatPhase, showDeadPill, isBossPhase, placementContext, canPlace, boardTargets, dropIntent,
|
||||
battleOverSfx, uniteResultBox, battleResultBox,
|
||||
snapHud, activeBubbles, shortcutFor, shortcutBlocked, closesOnFieldPress, phaseTotalSeconds, homeFieldId, ownFieldId, normalizeSp, normalizePersonalChoice, sortedPlayers,
|
||||
terrainInfo, deviceInfo, deviceTipAt, noteDeviceUnits,
|
||||
terrainInfo, deviceInfo, deviceTipAt, noteDeviceUnits, deviceUnitsForEntry,
|
||||
countdownState, shopBlockReason, stageOverrides, effectiveStage, watchTarget, dropFailureReason,
|
||||
previewEnemyKey, prepCamera, prepCameraFor, foldCamera, deployFieldOf, fieldTile, panelSide, panelSlots, bondPopupPlace, unitLoadout, deployedRecord,
|
||||
mergeTarget, modeOffBonds, readyFundsPrompt, readyShopFold, ownerBandId, ownDiyRecord, ownStandIn,
|
||||
@@ -408,8 +408,22 @@ function MatchScreen() {
|
||||
live.current.deviceBattle = !!shownField && !shownField.prep;
|
||||
const staleFieldRef = useRef(null);
|
||||
const enteredFieldRef = useRef(null);
|
||||
const enteredViewRef = useRef(null);
|
||||
const pressSel = useRef(null); // the selected piece when the current field press began
|
||||
useEffect(() => {
|
||||
// Read before a view change clears the refs. A later refresh of this same field (g.watch / reshow replaces the
|
||||
// field object) must still know which battle the device units already noted belong to.
|
||||
const previousFieldId = lastFieldRef.current;
|
||||
const previousDevices = deviceUnitsRef.current;
|
||||
if (enteredViewRef.current !== view) {
|
||||
// A replacement engine has no field, even when the match's m.field object did not change. While it loads,
|
||||
// buffer frames instead of sending them to a null/destroyed view; its subscription below asks for fresh meta.
|
||||
enteredViewRef.current = view;
|
||||
enteredFieldRef.current = null;
|
||||
lastFieldRef.current = null;
|
||||
viewModeRef.current = null;
|
||||
reentryRef.current = null;
|
||||
}
|
||||
if (!view) return;
|
||||
if (showPrep) {
|
||||
if (field?.prep) staleFieldRef.current = field; // a prep scouting board is never a battle to enter
|
||||
@@ -442,10 +456,18 @@ function MatchScreen() {
|
||||
reentryRef.current = null;
|
||||
viewModeRef.current = 'battle';
|
||||
snapUnitsRef.current = new Map();
|
||||
view.enterBattle(field);
|
||||
const early = evBufRef.current.get(field.fieldId);
|
||||
evBufRef.current.delete(field.fieldId);
|
||||
deviceUnitsRef.current = noteDeviceUnits(new Map(), { units: field.units, events: early });
|
||||
// Crates and turrets are absent from a start-of-battle meta and arrive once, as spawn events. Re-entering the
|
||||
// same field from a refresh that repeats that meta (the subscription below asks for one) must not drop them.
|
||||
const noted = noteDeviceUnits(new Map(), { units: field.units, events: early });
|
||||
deviceUnitsRef.current = deviceUnitsForEntry(previousDevices, noted, previousFieldId != null && previousFieldId === field.fieldId);
|
||||
// The renderer clears its unit metadata on entry too. Restore known devices there as well as in the card lookup;
|
||||
// otherwise their next HP tuple becomes an unknown enemy whose pieceClick consumes the device's tileClick.
|
||||
const units = Array.isArray(field.units) ? [...field.units] : [];
|
||||
const ids = new Set(units.map((u) => u?.id));
|
||||
for (const [id, info] of deviceUnitsRef.current) if (!ids.has(id)) units.push(info);
|
||||
view.enterBattle({ ...field, units });
|
||||
const earlySnap = snapBufRef.current.get(field.fieldId);
|
||||
snapBufRef.current.delete(field.fieldId);
|
||||
// a scouted prep board frames like the own prep with the shop folded (the bench row included, app.js camRect);
|
||||
@@ -565,6 +587,15 @@ function MatchScreen() {
|
||||
};
|
||||
const offs = [net.on('m.field', onFieldMeta), net.on('b.snap', onSnap), net.on('b.ev', onEv)];
|
||||
if (battleRunner) offs.push(battleRunner.on('field', onFieldMeta), battleRunner.on('snap', onSnap), battleRunner.on('ev', onEv), battleRunner.on('result', onResult));
|
||||
// Metadata must include enemies/tokens spawned before this renderer existed, not only events buffered during
|
||||
// loading. Subscribe first so the reshow's metadata and snapshot take the ordinary buffered re-entry path.
|
||||
if (view && !showPrep && field?.fieldId) {
|
||||
if (field.local) battleRunner?.reshow();
|
||||
else if (combat || watchingOther) {
|
||||
const who = watchWho?.fieldId === field.fieldId ? watchWho.playerId : null;
|
||||
net.request('g.watch', { fieldId: field.fieldId, ...(who ? { playerId: who } : {}) }).catch(() => {});
|
||||
}
|
||||
}
|
||||
return () => { for (const off of offs) { try { off(); } catch { /* ignore */ } } clearTimeout(pending); };
|
||||
}, [view]);
|
||||
|
||||
|
||||
@@ -26,8 +26,7 @@ import { useEffect, useMemo, useRef, useState } from '../../vendor/hooks.module.
|
||||
import { html, Icon, MicroLabel, Button, TierChip, TextField, Countdown, Spinner, confirmDialog, hasDeadline, Modal, Fragment } from '../ui/components.js';
|
||||
import { Img, RichText, UnitThumb } from '../ui/gameComponents.js';
|
||||
import { chessAvatarUrl, chessPortraitUrl, subProfIconUrl, bondIconUrl, moduleTypeIconUrl } from '../ui/assetUrls.js';
|
||||
import { chessStatsBlock, traitText, chessTalents, GarrisonBlock } from '../ui/detailPanel.js';
|
||||
import { chessLoadout } from '../ui/gameLogic.js';
|
||||
import { GarrisonBlock } from '../ui/detailPanel.js';
|
||||
import { data, useData, localAsset, DATA_FILES } from '../data.js';
|
||||
import { useStore } from '../store.js';
|
||||
import { PHASE } from '../../../shared/constants.js';
|
||||
@@ -44,6 +43,8 @@ import { atPotential } from '../../../shared/potential.js';
|
||||
import { setOwned, notOwnedCount, serializeOwnership, parseOwnershipImport, OWNERSHIP_IMPORT_MAX_BYTES } from '../ui/ownershipModel.js';
|
||||
import { OwnershipPanel, useOwnershipRoster } from './ownership.js';
|
||||
import { DiyPanel, diyData } from './diy.js';
|
||||
import { LoadoutStats } from '../ui/loadoutStats.js';
|
||||
export { LoadoutStats, statsPreview } from '../ui/loadoutStats.js';
|
||||
import { diyCount, sanitizeDiyPicks, setPick, serializeDiy, parseDiyImport, DIY_IMPORT_MAX_BYTES } from '../ui/diyModel.js';
|
||||
import { t, tParts, N_ } from '../../../shared/i18n.js';
|
||||
import { copyText } from '../ui/clipboard.js';
|
||||
@@ -301,61 +302,9 @@ export function ModuleInfo({ m, golden, opt, potential = null }) {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
const getChessRec = (id) => data.lookup('chess', id);
|
||||
/** The name of a chess's 补位 stand-in (data/backups.json), or null. */
|
||||
const standInName = (chess) => data.get('backups')?.units?.[chess?.backup?.charId]?.name ?? null;
|
||||
|
||||
/**
|
||||
* What 局内数值 shows (GitHub issue #64): the chess variant — the 精锐 record when asked for and the chess has one, else
|
||||
* the normal one — as the stored loadout makes it. chessLoadout (ui/gameLogic.js) resolves the skill and module the way
|
||||
* the in-match detail card and the sim do (shared/loadoutRecord.js): the elite's chosen module's stats / 特性 / talents
|
||||
* (不装备: the base ones), the chosen skill's passive range; a normal chess has no module, the skill does not change its
|
||||
* stats. Nothing is recomputed here.
|
||||
* @param {any} base normal chess record @param {any} golden its elite record or null
|
||||
* @param {Record<string, any>} entries the stored loadout @param {'normal'|'elite'} level
|
||||
* @param {(id: string) => any} getChess
|
||||
* @param {Record<string, any>|null} [ops] the stored 潜能 / 练度 (0.2.2: the numbers at the operator's settings — the
|
||||
* 练度 multiplier needs effects.json; none set = 潜能 6, 精英2 Lv.60)
|
||||
* @returns {{ elite: boolean, chess: any, lo: any, record: any, trait: string, talents: any[] } | null} null without a record
|
||||
*/
|
||||
export function statsPreview(base, golden, entries, level, getChess, ops = null) {
|
||||
const elite = level === 'elite' && !!golden;
|
||||
const chess = elite ? golden : base;
|
||||
if (!chess) return null;
|
||||
const lo = chessLoadout(chess, entries, getChess, { ops, effects: data.get('effects') });
|
||||
const record = lo?.record || chess;
|
||||
// (the card's own rule: the 特性 line exists when the chess has one; its text follows the chosen module)
|
||||
return { elite, chess, lo, record, trait: chess.trait?.desc ? traitText(chess, !!chess.isGolden, lo) || '' : '', talents: chessTalents(record) };
|
||||
}
|
||||
|
||||
/**
|
||||
* 局内数值: the stats, 攻击范围, 特性 and 天赋 of the selected chess under its chosen skill and module — the detail
|
||||
* card's stats block (ui/detailPanel.js chessStatsBlock) without live numbers, so what a player reads here is what the
|
||||
* shop / board card shows before a battle (not the equipment, bond or skill-cast changes of a running match). The
|
||||
* toggle picks the 普通 or the 精锐 record; 精锐 is the default because the module only exists there.
|
||||
* @param {{ base: any, golden: any, entries: Record<string, any>, level: 'normal'|'elite', onLevel: (l: 'normal'|'elite') => void, getChess?: (id: string) => any }} props
|
||||
*/
|
||||
export function LoadoutStats({ base, golden, entries, level, onLevel, getChess = getChessRec, ops = null }) {
|
||||
const pv = statsPreview(base, golden, entries, level, getChess, ops);
|
||||
if (!pv) return null;
|
||||
// (the caption names 潜能 / 练度 when the numbers carry both — the 练度 multiplier needs effects.json)
|
||||
return html`<section class="lo-sec lo-sec--stats" aria-label=${t('局内数值')} data-variant=${pv.elite ? 'elite' : 'normal'}>
|
||||
<header class="lo-sec__head">
|
||||
<h3>${t('局内数值')}<${MicroLabel}>STATS<//></h3>
|
||||
<div class="lo-seg" role="tablist" aria-label=${t('数值版本')}>
|
||||
<button type="button" role="tab" aria-selected=${pv.elite ? 'false' : 'true'} class=${cx(!pv.elite && 'is-on')} data-variant="normal" onClick=${() => onLevel('normal')}>${t('普通')}</button>
|
||||
<button type="button" role="tab" aria-selected=${pv.elite ? 'true' : 'false'} class=${cx(pv.elite && 'is-on')} data-variant="elite" disabled=${!golden} onClick=${() => onLevel('elite')}>${t('精锐')}</button>
|
||||
</div>
|
||||
</header>
|
||||
${chessStatsBlock({ rec: pv.record, chess: pv.chess })}
|
||||
${pv.trait || pv.talents.length ? html`<div class="lo-minfo lo-minfo--kit">
|
||||
${pv.trait ? html`<div class="lo-minfo__row"><span class="lo-minfo__k">${t('特性')}</span><${RichText} class="lo-minfo__v" text=${pv.trait} /></div>` : null}
|
||||
${pv.talents.map((tal, i) => html`<div key=${i} class="lo-minfo__row"><span class="lo-minfo__k">${t('天赋')}</span>
|
||||
<span class="lo-minfo__v"><b class="lo-minfo__tname">${tal.name}</b><${RichText} text=${tal.descRaw || tal.desc || ''} /></span></div>`)}
|
||||
</div>` : null}
|
||||
<p class="lo-stats__cap">${pv.elite ? t('数值含所选模组;') : golden ? t('普通干员没有模组,所选模组在「精锐」中生效;') : ''}${pv.lo?.cultivation && data.get('effects') ? t('含潜能与练度;') : ''}${t('不含技能发动、装备、盟约等局内加成')}</p>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
function Detail({ m, chess, golden, entries, ops = {}, onChange, onOps, onReset, locked, level = 'normal', onLevel, notOwned = false }) {
|
||||
const [statLevel, setStatLevel] = useState('elite'); // 局内数值: the 精锐 shows the chosen module's effect
|
||||
|
||||
@@ -309,25 +309,28 @@ export function LobbyScreen() {
|
||||
<div class="topbar__left">
|
||||
<${Button} variant="ghost" size="sm" icon="chevronLeft" onClick=${backToTitle} title=${t('返回标题')}>${t('返回')}<//>
|
||||
<${PingPill} ms=${conn.ping} online=${online} />
|
||||
<span class="topbar__sep" aria-hidden="true"></span>
|
||||
<div class="topbar__tools">
|
||||
<${SettingsButton} class="lobby-settings" variant="secondary" square=${true} />
|
||||
<${GuideButton} class="lobby-guide" variant="secondary" square=${true} />
|
||||
<${PwaInstallButton} class="lobby-pwa" square=${true} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="topbar__center">
|
||||
<${MicroLabel} tone="mint">SIMULATION PROTOCOL SELECT<//>
|
||||
<h1 class="topbar__title">${t('选择模拟协议')}</h1>
|
||||
</div>
|
||||
<div class="topbar__right">
|
||||
<${Button} variant="secondary" size="sm" icon="chart" class="stats-entry" onClick=${openStats} title=${t('统计数据')} aria-label=${t('统计数据')}>${t('统计')}<//>
|
||||
<${ResumeMatchButton} />
|
||||
<${PwaInstallButton} class="lobby-pwa" />
|
||||
<${SettingsButton} class="lobby-settings" variant="secondary" label=${t('设置')} />
|
||||
<${GuideButton} class="lobby-guide" variant="secondary" label=${t('玩法说明')} />
|
||||
<${LoadoutButton} from="lobby" size="sm" class="lobby-loadout" label=${t('干员调配')} />
|
||||
<div class="me-chip">
|
||||
<button type="button" class="me-chip me-chip--btn stats-entry" onClick=${openStats} title=${t('统计数据')} aria-label=${t('统计数据')}>
|
||||
<${AvatarFrame} size="sm" name=${me.name} seat=${0} self=${true} />
|
||||
<div class="me-chip__text">
|
||||
<span class="me-chip__text">
|
||||
<span class="me-chip__name">${me.name || t('博士')}</span>
|
||||
<${MicroLabel}>${me.playerId != null ? `DOCTOR #${doctorNo(me.playerId)}` : 'DOCTOR'}<//>
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
<${Icon} name="chart" class="me-chip__stats" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -140,6 +140,7 @@ export function ResultView({ res, pub = null, myId = null, backLabel, onBack, qu
|
||||
<div class="result__grid" aria-hidden="true"></div>
|
||||
<main class="result__main">
|
||||
<section class="result__hero">
|
||||
<div class="result__summary">
|
||||
<div class="result__logo"><${Sprite} k="entry/season_logo_settle" class="result__logoimg" fallback=${html`<${MicroLabel} tone="mint">STRONGHOLD PROTOCOL</${MicroLabel}>`} /></div>
|
||||
${r.difficulty ? html`<${DifficultyTag} difficulty=${r.difficulty} size="lg" />` : null}
|
||||
<h1 class="result__headline">${r.victory ? t('模拟完成') : t('模拟失败')}</h1>
|
||||
@@ -158,6 +159,7 @@ export function ResultView({ res, pub = null, myId = null, backLabel, onBack, qu
|
||||
<span class="medal__label">${t('隐秘核心')}</span></div>` : null}
|
||||
</div>
|
||||
${mins ? html`<p class="result__time t-lo">${tParts('本局耗时 {n} 分钟', { n: html`<b class="num">${mins}</b>`, mins })}</p>` : null}
|
||||
</div>
|
||||
<footer class="result__foot">
|
||||
<${Button} variant="primary" size="xl" icon="chevronLeft" onClick=${onBack}>${backLabel}<//>
|
||||
</footer>
|
||||
|
||||
@@ -91,13 +91,13 @@ export function pickBusy(busyIdx, card, mine) {
|
||||
|
||||
/**
|
||||
* Whether I may pick a card right now: my turn (solo: always), no pick of mine yet, the card free and no pick in flight.
|
||||
* @param {any} sp normalizeSp(...) @param {{ idx: number, takenBy?: string|null } | null | undefined} card
|
||||
* @param {any} sp normalizeSp(...) @param {{ idx: number, takenBy?: string|null, soldOut?: boolean } | null | undefined} card
|
||||
* @param {{ myId: string, solo: boolean, busyIdx?: number|null }} o
|
||||
*/
|
||||
export function cardPickable(sp, card, { myId, solo, busyIdx = null }) {
|
||||
if (!sp || !card) return false;
|
||||
const myTurn = solo || sp.turnPid === myId;
|
||||
return myTurn && sp.pickOf.get(myId) == null && !card.takenBy && busyIdx == null;
|
||||
return myTurn && sp.pickOf.get(myId) == null && !card.takenBy && !card.soldOut && busyIdx == null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -218,7 +218,7 @@ export function ChoiceView({ pub, sp, myId, solo, personal = false, busyIdx = nu
|
||||
const busy = pickBusy(busyIdx, card, mine);
|
||||
const isArmed = can && armed === card.idx;
|
||||
const takerName = taker ? (card.takenBy === myId ? t('你') : taker.name) : null;
|
||||
return html`<button key=${card.idx} type="button" class=${cx('spcard', `spcard--${r.kind}`, card.takenBy && 'is-taken', card.takenBy === myId && 'is-mine', can && 'is-pickable', isArmed && 'is-armed', busy && 'is-busy')}
|
||||
return html`<button key=${card.idx} type="button" class=${cx('spcard', `spcard--${r.kind}`, (card.takenBy || card.soldOut) && 'is-taken', card.takenBy === myId && 'is-mine', can && 'is-pickable', isArmed && 'is-armed', busy && 'is-busy')}
|
||||
aria-busy=${busy ? 'true' : undefined} aria-pressed=${can ? String(isArmed) : undefined} disabled=${!can} onClick=${() => can && onTap(card.idx)}
|
||||
aria-label=${isArmed ? t('{name},已选中,再次点击确认', { name: r.name }) : takerName ? t('{name},{takerName}已选择', { name: r.name, takerName }) : r.name} title=${`${r.name}\n${richTextPlain(r.desc)}`}>
|
||||
<span class="spcard__glow" aria-hidden="true"></span>
|
||||
@@ -233,6 +233,7 @@ export function ChoiceView({ pub, sp, myId, solo, personal = false, busyIdx = nu
|
||||
</span>
|
||||
</span>
|
||||
<${RichText} text=${r.desc} class="spcard__desc" />
|
||||
${card.soldOut && !taker ? html`<span class="spcard__busy" role="status">${t('库存不足')}</span>` : null}
|
||||
${r.tier ? html`<${TierChip} tier=${r.tier} size="md" class="spcard__tier" />` : null}
|
||||
${taker ? html`<span class="spcard__taker" title=${t('{name} 已选择', { name: taker.name })}><${PlayerAvatar} player=${taker} size="sm" /></span>` : null}
|
||||
${isArmed ? html`<span class="spcard__confirm" role="status"><b>${t('确认选择')}</b><small>${t('再次点击')}</small></span>` : null}
|
||||
|
||||
@@ -76,6 +76,8 @@ export const ICONS = {
|
||||
wifiOff: { d: 'M2 17h3v4H2zm6-4h3v8H8zm6-4h3v12h-3zm6-4h3v16h-3zM2.4 1 23 21.6 21.6 23 1 2.4z' },
|
||||
expand: { d: 'M3 3h7v2H5v5H3zm11 0h7v7h-2V5h-5zM3 14h2v5h5v2H3zm16 0h2v7h-7v-2h5z' },
|
||||
collapse: { d: 'M8 3h2v7H3V8h5zm6 0h2v5h5v2h-7zM3 14h7v7H8v-5H3zm11 0h7v2h-5v5h-2z' },
|
||||
// 添加到桌面 (add to home screen): a house outline with a + inside
|
||||
homePlus: { d: 'M12 2 22 10.5V21H2V10.5zm0 2.6-8 6.8V19h16v-7.6zM11 10h2v2.5h2.5v2H13V17h-2v-2.5H8.5v-2H11z', eo: true },
|
||||
rotate: { d: 'M7 2h8a2 2 0 0 1 2 2v7h-2V4H7v16h4v2H7a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2zm8 12h5a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2h-5a2 2 0 0 1-2-2v-4a2 2 0 0 1 2-2zm0 2v4h5v-4z', eo: true },
|
||||
book: { d: 'M2 4h7.5A3.5 3.5 0 0 1 12 5.1 3.5 3.5 0 0 1 14.5 4H22v16h-7.5a1.5 1.5 0 0 0-1.5 1.5h-2A1.5 1.5 0 0 0 9.5 20H2zm2 2v12h5.5c.5 0 1 .1 1.5.3V7.5A1.5 1.5 0 0 0 9.5 6zm10.5 0A1.5 1.5 0 0 0 13 7.5v10.8c.5-.2 1-.3 1.5-.3H20V6z', eo: true },
|
||||
// 观战 (spectator seats; the same eye as gameComponents.js GIcon 'eye')
|
||||
|
||||
@@ -163,13 +163,17 @@ export function FullscreenButton({ class: cls = '' }) {
|
||||
</button>`;
|
||||
}
|
||||
|
||||
/** Only a browser-issued install offer shows a button; installed and unsupported environments stay quiet. */
|
||||
export function PwaInstallButton({ class: cls = '', size = 'sm' }) {
|
||||
/**
|
||||
* Only a browser-issued install offer shows a button; installed and unsupported environments stay quiet.
|
||||
* `square`: the glyph alone (the lobby's top bar, beside ⚙ / 📖), the name stays title / aria-label.
|
||||
*/
|
||||
export function PwaInstallButton({ class: cls = '', size = 'sm', square = false }) {
|
||||
const [available, setAvailable] = useState(pwaInstall.available);
|
||||
useEffect(() => pwaInstall.subscribe(setAvailable), []);
|
||||
if (!available) return null;
|
||||
return html`<${Button} variant="secondary" size=${size} icon="download" class=${cls}
|
||||
data-testid="pwa-install" onClick=${() => pwaInstall.request()}>${t('添加到桌面')}<//>`;
|
||||
return html`<${Button} variant="secondary" size=${size} icon="homePlus" square=${square} class=${cls}
|
||||
title=${t('添加到桌面')} aria-label=${t('添加到桌面')}
|
||||
data-testid="pwa-install" onClick=${() => pwaInstall.request()}>${square ? null : t('添加到桌面')}<//>`;
|
||||
}
|
||||
|
||||
export function updateRotateHintPwaI18n(doc = globalThis.document) {
|
||||
|
||||
@@ -147,9 +147,9 @@ export function pickOptions(slotId, picks, data, kitted) {
|
||||
* The record a slot shows for a pick (shared/diy.js diyRecord: the operator at the slot's normal form, or the elite).
|
||||
* @param {string} slotId @param {any} pick @param {any} data @param {{ elite?: boolean }} [opts]
|
||||
*/
|
||||
export function slotRecord(slotId, pick, data, { elite = false } = {}) {
|
||||
export function slotRecord(slotId, pick, data, { elite = false, potential = null } = {}) {
|
||||
if (!isObj(pick)) return null;
|
||||
try { return diyRecord(slotId, pick, { elite, data }); } catch { return null; }
|
||||
try { return diyRecord(slotId, pick, { elite, data, potential }); } catch { return null; }
|
||||
}
|
||||
|
||||
/** Set (or with null, clear) one slot. Returns a new roster (the input is not changed). */
|
||||
|
||||
@@ -332,6 +332,16 @@ export function createFallbackView(host, opts = {}) {
|
||||
const now = performance.now();
|
||||
if (st.mode === 'prep' && st.priv) {
|
||||
const heldPos = (p) => { const t = st.held.get(p.uid); return t ? tilePos(L, t.row, t.col) : null; };
|
||||
for (const u of Array.isArray(st.priv.prepMapChars) ? st.priv.prepMapChars : []) {
|
||||
if (!u || !Number.isInteger(u.x) || !Number.isInteger(u.y) || typeof u.defId !== 'string') continue;
|
||||
const pos = tilePos(L, u.y, u.x);
|
||||
const src = tokenAvatarUrl(m(), u.defId);
|
||||
pieces.push(html`<div key=${`map:${u.uid}`} class="ff-piece ff-piece--token ff-piece--map-char"
|
||||
style=${`transform:translate(${pos.x}px,${pos.y}px);width:${L.tile}px;height:${L.tile}px;pointer-events:none`}
|
||||
title=${u.name || u.defId}>
|
||||
<div class="ff-piece__art">${src ? html`<img src=${src} alt="" draggable=${false} />` : html`<span>${[...(u.name || '?')][0]}</span>`}</div>
|
||||
</div>`);
|
||||
}
|
||||
for (const p of Array.isArray(st.priv.board) ? st.priv.board : []) {
|
||||
if (!p || !Number.isInteger(p.row)) continue;
|
||||
const pos = heldPos(p) || tilePos(L, p.row, p.col);
|
||||
|
||||
@@ -268,31 +268,46 @@ export async function mountFieldView(host, { signal } = {}) {
|
||||
*/
|
||||
export function useFieldView(hostRef) {
|
||||
const [state, setState] = useState({ view: null, kind: 'loading' });
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
const retries = useRef(0);
|
||||
const viewRef = useRef(null);
|
||||
useEffect(() => {
|
||||
let dead = false;
|
||||
let dead = false, retrying = false;
|
||||
const host = hostRef.current;
|
||||
if (!host) return undefined;
|
||||
setState({ view: null, kind: 'loading' });
|
||||
const controller = new AbortController();
|
||||
mountFieldView(host, { signal: controller.signal }).then((view) => {
|
||||
if (dead) { view.destroy(); return; }
|
||||
viewRef.current = view;
|
||||
if (view.kind === 'engine') retries.current = 0;
|
||||
globalThis.__SP_VIEW__ = view; // dev / E2E introspection (view.raw.stats?.())
|
||||
setState({ view, kind: view.kind });
|
||||
}, (err) => { if (!dead && !controller.signal.aborted) console.error('[field] mount failed', err); });
|
||||
const unsub = settingsStore.subscribe((s) => viewRef.current?.setSettings?.(s));
|
||||
const onResize = () => viewRef.current?.resize();
|
||||
// A temporary network/vendor failure must not pin this mounted match to fallback forever. Retry on a useful
|
||||
// user event, at most twice in succession; the effect cleanup disposes the old view before creating its successor.
|
||||
const onRetry = () => {
|
||||
if (dead || retrying || viewRef.current?.kind !== 'fallback' || renderPref() === 'fallback'
|
||||
|| document.visibilityState !== 'visible' || globalThis.navigator?.onLine === false || retries.current >= 2) return;
|
||||
retrying = true; retries.current++; setAttempt((n) => n + 1);
|
||||
};
|
||||
window.addEventListener('resize', onResize);
|
||||
window.addEventListener('online', onRetry);
|
||||
document.addEventListener('visibilitychange', onRetry);
|
||||
return () => {
|
||||
dead = true;
|
||||
controller.abort();
|
||||
unsub();
|
||||
window.removeEventListener('resize', onResize);
|
||||
window.removeEventListener('online', onRetry);
|
||||
document.removeEventListener('visibilitychange', onRetry);
|
||||
// the dev hook must not keep the destroyed view — and through its host the whole detached match screen — alive
|
||||
if (viewRef.current && globalThis.__SP_VIEW__ === viewRef.current) globalThis.__SP_VIEW__ = null;
|
||||
viewRef.current?.destroy();
|
||||
viewRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
}, [attempt]);
|
||||
return state;
|
||||
}
|
||||
@@ -35,7 +35,7 @@ export { ownFieldId, homeFieldId, cycleField, watchTarget, switcherLabel, fieldL
|
||||
export { sortBonds, bondTier, nextThreshold, grantedBonds, pieceBondIds, morphPairings, HARMONY_BOND, harmonyMembers, bondMembers, memberHeadCount, bannedPerBond, disabledBondSets, briefingBondTip, modeOffBonds, bandOffBonds, bandOffLine } from './gameLogic/bonds.js';
|
||||
export { priceTone, mergeProgress, mergeTarget, handFull, completesMerge, offerHeader, shopBlockReason, readyFundsPrompt, readyShopFold } from './gameLogic/shop.js';
|
||||
export { deploySets, stageOverrides, deployMap, effectiveStage, indexPieces, placementContext, piecePosition, tileAllows, summonRange, summonExcluded, canPlace, equipReplaces, equipMerges, boardTargets, dropIntent, dropFailureReason } from './gameLogic/placement.js';
|
||||
export { terrainInfo, deviceInfo, deviceTipAt, noteDeviceUnits } from './gameLogic/terrain.js';
|
||||
export { terrainInfo, deviceInfo, deviceTipAt, noteDeviceUnits, deviceUnitsForEntry } from './gameLogic/terrain.js';
|
||||
export { normalizeDraft, normalizeSp, normalizePersonalChoice } from './gameLogic/draft.js';
|
||||
export { groupEnemies, PEN, penZoneTiles, penPlacement, previewEnemyKey, factionTypes } from './gameLogic/enemies.js';
|
||||
export { snapHud, bossFrac, bossPctText, hasFlag, UF, attackInterval, fmtNum, rangeGridBox } from './gameLogic/format.js';
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { GEO } from '../../../../shared/constants.js';
|
||||
import { attackRangeGrid } from '../../../../shared/loadoutRecord.js';
|
||||
import { meleeOnHighGround } from '../../../../shared/highGround.js';
|
||||
import { summonPlacementGrid } from '../../../../shared/summonPlacement.js';
|
||||
import { pieceDir, rangeTiles } from '../facing.js';
|
||||
import { isObj, tileKey } from './shared.js';
|
||||
import { boardTileOf, fieldTile } from './camera.js';
|
||||
@@ -250,15 +251,18 @@ export function tileAllows(ctx, piece, row, col) {
|
||||
/**
|
||||
* Board tiles ('r,c') where a summon whose text reads "只能部署在召唤者攻击范围内" may stand (tokens.json `ownerRange`:
|
||||
* 伺夜's 狼群, 缪尔赛思's 流形 — the tactical point; player report #9 after 0.1.0): its owner's attack range (the
|
||||
* loadout's grid, shared/loadoutRecord.js attackRangeGrid, rotated by the owner's facing around its tile). Null when
|
||||
* loadout's grid, shared/loadoutRecord.js attackRangeGrid, rotated by the owner's facing around its tile).
|
||||
* Jessica's shield uses the four-adjacent-tile grid instead, independent of facing/skill/module. Null when
|
||||
* the piece is not range-bound or its owner is not on the board. `owner` ({ row, col, piece }) replaces the owner's
|
||||
* current position (a summon swapped with its own owner). Mirror of server/match/PlayerState.js summonRange.
|
||||
* @param {ReturnType<typeof placementContext>} ctx
|
||||
* @returns {Set<string> | null}
|
||||
*/
|
||||
export function summonRange(ctx, piece, owner = null) {
|
||||
if (!isObj(piece) || piece.kind !== 'token' || ctx?.getToken?.(piece.id)?.ownerRange !== true) return null;
|
||||
return ownerRangeOf(ctx, piece, owner);
|
||||
if (!isObj(piece) || piece.kind !== 'token') return null;
|
||||
const grid = summonPlacementGrid(piece.id);
|
||||
if (!grid && ctx?.getToken?.(piece.id)?.ownerRange !== true) return null;
|
||||
return ownerRangeOf(ctx, piece, owner, grid);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -273,14 +277,14 @@ export function summonExcluded(ctx, piece, owner = null) {
|
||||
return ownerRangeOf(ctx, piece, owner);
|
||||
}
|
||||
|
||||
/** The attack-range tiles of a summon piece's owner on the board (summonRange / summonExcluded), or null. */
|
||||
function ownerRangeOf(ctx, piece, owner = null) {
|
||||
/** The attack or fixed placement range of a summon piece's owner on the board, or null. */
|
||||
function ownerRangeOf(ctx, piece, owner = null, placementGrid = null) {
|
||||
let at = owner;
|
||||
if (!at) for (const e of ctx.boardAt.values()) if (e.piece.uid === piece.ownerUid && e.piece.kind === 'chess') { at = e; break; }
|
||||
const rec = at && ctx.getChess(at.piece.id);
|
||||
if (!rec) return null;
|
||||
let grid = null;
|
||||
try { grid = attackRangeGrid(deployedRecord(rec, ctx.priv, ctx.getChess, ctx.backups) || rec); } catch { /* the data grid */ }
|
||||
try { grid = placementGrid || attackRangeGrid(deployedRecord(rec, ctx.priv, ctx.getChess, ctx.backups) || rec); } catch { /* the data grid */ }
|
||||
return new Set(rangeTiles(grid || rec.rangeGrid, at.row, at.col, pieceDir(at.piece)).map(([r, c]) => tileKey(r, c)));
|
||||
}
|
||||
|
||||
@@ -367,7 +371,7 @@ export function canPlace(ctx, uid, target) {
|
||||
// dropped onto its own owner (the two swap); an outside-bound one (战术锚点) outside it
|
||||
const ownerSwap = src.area === 'board' && occ && occ.piece.uid === piece.ownerUid ? { row: src.row, col: src.col, piece: occ.piece } : null;
|
||||
const range = summonRange(ctx, piece, ownerSwap);
|
||||
if (range && !range.has(tileKey(row, col))) return no('BAD_TILE', t('只能部署在召唤者攻击范围内'));
|
||||
if (range && !range.has(tileKey(row, col))) return no('BAD_TILE', summonPlacementGrid(piece.id) ? t('只能部署在召唤者相邻四格') : t('只能部署在召唤者攻击范围内'));
|
||||
const out = summonExcluded(ctx, piece, ownerSwap);
|
||||
if (out && out.has(tileKey(row, col))) return no('BAD_TILE', t('只能部署在召唤者攻击范围外'));
|
||||
if (src.area === 'board') {
|
||||
|
||||
@@ -296,3 +296,16 @@ export function noteDeviceUnits(map, { units = null, events = null } = {}) {
|
||||
for (const e of Array.isArray(events) ? events : []) if (Array.isArray(e) && e[0] === 'spawn' && isObj(e[1]) && e[1].kind === 'device' && e[1].id != null) map.set(e[1].id, e[1]);
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* The device-unit map after entering a field. A refresh of the same field whose new meta names no device
|
||||
* (the start-of-battle list: crates and turrets arrive as `spawn` and are not repeated) keeps the units
|
||||
* already noted. A meta that names devices replaces the map. A different field does not keep the previous one.
|
||||
* @param {Map<number, any>|null|undefined} previous
|
||||
* @param {Map<number, any>} noted
|
||||
* @param {boolean} sameField
|
||||
*/
|
||||
export function deviceUnitsForEntry(previous, noted, sameField) {
|
||||
if (!(noted instanceof Map) || noted.size > 0 || !sameField) return noted instanceof Map ? noted : new Map();
|
||||
return previous instanceof Map && previous.size > 0 ? previous : noted;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// Shared operator stat preview used by roster loadout and DIY selection.
|
||||
import { html, MicroLabel } from './components.js';
|
||||
import { RichText } from './gameComponents.js';
|
||||
import { chessStatsBlock, traitText, chessTalents } from './detailPanel.js';
|
||||
import { chessLoadout } from './gameLogic.js';
|
||||
import { data } from '../data.js';
|
||||
import { t } from '../../../shared/i18n.js';
|
||||
const cx = (...parts) => parts.filter(Boolean).join(' ');
|
||||
const getChessRec = (id) => data.lookup('chess', id);
|
||||
|
||||
/**
|
||||
* What 局内数值 shows (GitHub issue #64): the chess variant — the 精锐 record when asked for and the chess has one, else
|
||||
* the normal one — as the stored loadout makes it. chessLoadout (ui/gameLogic.js) resolves the skill and module the way
|
||||
* the in-match detail card and the sim do (shared/loadoutRecord.js): the elite's chosen module's stats / 特性 / talents
|
||||
* (不装备: the base ones), the chosen skill's passive range; a normal chess has no module, the skill does not change its
|
||||
* stats. Nothing is recomputed here.
|
||||
* @param {any} base normal chess record @param {any} golden its elite record or null
|
||||
* @param {Record<string, any>} entries the stored loadout @param {'normal'|'elite'} level
|
||||
* @param {(id: string) => any} getChess
|
||||
* @param {Record<string, any>|null} [ops] the stored 潜能 / 练度 (0.2.2: the numbers at the operator's settings — the
|
||||
* 练度 multiplier needs effects.json; none set = 潜能 6, 精英2 Lv.60)
|
||||
* @returns {{ elite: boolean, chess: any, lo: any, record: any, trait: string, talents: any[] } | null} null without a record
|
||||
*/
|
||||
export function statsPreview(base, golden, entries, level, getChess, ops = null) {
|
||||
const elite = level === 'elite' && !!golden;
|
||||
const chess = elite ? golden : base;
|
||||
if (!chess) return null;
|
||||
const lo = chessLoadout(chess, entries, getChess, { ops, effects: data.get('effects') });
|
||||
const record = lo?.record || chess;
|
||||
// (the card's own rule: the 特性 line exists when the chess has one; its text follows the chosen module)
|
||||
return { elite, chess, lo, record, trait: chess.trait?.desc ? traitText(chess, !!chess.isGolden, lo) || '' : '', talents: chessTalents(record) };
|
||||
}
|
||||
|
||||
/**
|
||||
* 局内数值: the stats, 攻击范围, 特性 and 天赋 of the selected chess under its chosen skill and module — the detail
|
||||
* card's stats block (ui/detailPanel.js chessStatsBlock) without live numbers, so what a player reads here is what the
|
||||
* shop / board card shows before a battle (not the equipment, bond or skill-cast changes of a running match). The
|
||||
* toggle picks the 普通 or the 精锐 record; 精锐 is the default because the module only exists there.
|
||||
* @param {{ base: any, golden: any, entries: Record<string, any>, level: 'normal'|'elite', onLevel: (l: 'normal'|'elite') => void, getChess?: (id: string) => any }} props
|
||||
*/
|
||||
export function LoadoutStats({ base, golden, entries, level, onLevel, getChess = getChessRec, ops = null }) {
|
||||
const pv = statsPreview(base, golden, entries, level, getChess, ops);
|
||||
if (!pv) return null;
|
||||
// (the caption names 潜能 / 练度 when the numbers carry both — the 练度 multiplier needs effects.json)
|
||||
return html`<section class="lo-sec lo-sec--stats" aria-label=${t('局内数值')} data-variant=${pv.elite ? 'elite' : 'normal'}>
|
||||
<header class="lo-sec__head">
|
||||
<h3>${t('局内数值')}<${MicroLabel}>STATS<//></h3>
|
||||
<div class="lo-seg" role="tablist" aria-label=${t('数值版本')}>
|
||||
<button type="button" role="tab" aria-selected=${pv.elite ? 'false' : 'true'} class=${cx(!pv.elite && 'is-on')} data-variant="normal" onClick=${() => onLevel('normal')}>${t('普通')}</button>
|
||||
<button type="button" role="tab" aria-selected=${pv.elite ? 'true' : 'false'} class=${cx(pv.elite && 'is-on')} data-variant="elite" disabled=${!golden} onClick=${() => onLevel('elite')}>${t('精锐')}</button>
|
||||
</div>
|
||||
</header>
|
||||
${chessStatsBlock({ rec: pv.record, chess: pv.chess })}
|
||||
${pv.trait || pv.talents.length ? html`<div class="lo-minfo lo-minfo--kit">
|
||||
${pv.trait ? html`<div class="lo-minfo__row"><span class="lo-minfo__k">${t('特性')}</span><${RichText} class="lo-minfo__v" text=${pv.trait} /></div>` : null}
|
||||
${pv.talents.map((tal, i) => html`<div key=${i} class="lo-minfo__row"><span class="lo-minfo__k">${t('天赋')}</span>
|
||||
<span class="lo-minfo__v"><b class="lo-minfo__tname">${tal.name}</b><${RichText} text=${tal.descRaw || tal.desc || ''} /></span></div>`)}
|
||||
</div>` : null}
|
||||
<p class="lo-stats__cap">${pv.elite ? t('数值含所选模组;') : golden ? t('普通干员没有模组,所选模组在「精锐」中生效;') : ''}${pv.lo?.cultivation && data.get('effects') ? t('含潜能与练度;') : ''}${t('不含技能发动、装备、盟约等局内加成')}</p>
|
||||
</section>`;
|
||||
}
|
||||
@@ -289,11 +289,12 @@ export function SettingsModal({ open, onClose }) {
|
||||
* The 设置 button of the lobby and the room (GitHub #238 — before it the settings were reachable only from the title
|
||||
* screen and a running match): the twin of the 玩法说明 button (ui/guide.js GuideButton), with the settings modal behind it
|
||||
* (mounted only while open). The same modal as the title screen's and the match's: nothing in it is match-only.
|
||||
* @param {{ class?: string, size?: 'sm'|'md'|'lg'|'xl', variant?: string, label?: string }} props
|
||||
* `square`: the gear alone (the lobby's top bar), the name stays title / aria-label — like GuideButton's.
|
||||
* @param {{ class?: string, size?: 'sm'|'md'|'lg'|'xl', variant?: string, label?: string, square?: boolean }} props
|
||||
*/
|
||||
export function SettingsButton({ class: cls, size = 'sm', variant = 'ghost', label = t('设置') }) {
|
||||
export function SettingsButton({ class: cls, size = 'sm', variant = 'ghost', label = t('设置'), square = false }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return html`<${Button} variant=${variant} size=${size} class=${cx('settings-btn', cls)} onClick=${() => setOpen(true)}
|
||||
title=${t('设置')} aria-label=${t('设置')} data-testid="settings-btn"><${GIcon} name="gear" class="btn__icon" />${label}<//>
|
||||
return html`<${Button} variant=${variant} size=${size} square=${square} class=${cx('settings-btn', cls)} onClick=${() => setOpen(true)}
|
||||
title=${t('设置')} aria-label=${t('设置')} data-testid="settings-btn"><${GIcon} name="gear" class="btn__icon" />${square ? null : label}<//>
|
||||
${open ? html`<${SettingsModal} open=${true} onClose=${() => setOpen(false)} />` : null}`;
|
||||
}
|
||||
@@ -178,7 +178,7 @@ import { DataSource } from '../sim/simdata.js';
|
||||
import { createRng, deriveSeed } from '../sim/rng.js';
|
||||
import { GameData } from './gamedata.js';
|
||||
import { RealScheduler } from './scheduler.js';
|
||||
import { SharedPool, drawDisabledBonds } from './pool.js';
|
||||
import { SharedPool, SharedItemPool, drawDisabledBonds } from './pool.js';
|
||||
import { PlayerState } from './PlayerState.js';
|
||||
import { EffectDispatcher, getDefaultRegistry } from './effectsMeta.js';
|
||||
import { setupMatchWaves } from './waves.js';
|
||||
@@ -332,6 +332,7 @@ export class Match {
|
||||
this.staticInactiveBonds = bans.staticOff;
|
||||
this.bannedChess = bans.banned;
|
||||
this.pool = new SharedPool(this.gd, { banned: bans.banned });
|
||||
this.itemPool = new SharedItemPool(this.gd, () => this.order);
|
||||
// 自选编队 (0.2.0): each human's slotted DIY pieces get their own stock — none for one whose bonds are all off this
|
||||
// match (player/diy.js initDiyStock); no randomness is drawn here
|
||||
const off = new Set([...bans.drawn, ...bans.staticOff]);
|
||||
|
||||
@@ -143,19 +143,20 @@ const ITEM_HANDLERS = {
|
||||
},
|
||||
},
|
||||
// 拟态物质 「若已拥有至少2名该初始干员,则再获得1名该初始干员;否则随机获得1名同盟约初始干员」: the 否则 is the owned < 2
|
||||
// case only. With 2 copies owned and none left in the pool (an elite holds 3 of a Ⅵ阶's 5) the grant fails and the
|
||||
// item gives nothing (research 06 §7: some effects fail at the copy cap) — it never falls back to a same-bond operator
|
||||
// (GitHub #207).
|
||||
// case only. The original experiment BV1eLXXBqEgF (3:00) distinguishes this item from pool-weighted grants:
|
||||
// it samples eligible operator identities and can grant beyond stock. All obtained copies still occupy the pool.
|
||||
use_equip_reward_char_chess: {
|
||||
onEquip(ctx, ev) {
|
||||
const base = ctx.gd.baseIdOf(ev.target.id);
|
||||
const owned = [...ctx.board(), ...ctx.hand(), ...ctx.temp()].filter((p) => p && p.kind === 'chess' && !p.golden && ctx.gd.baseIdOf(p.id) === base).length;
|
||||
if (owned >= 2) {
|
||||
if (!ctx.grantChess(base)) toastNothing(ctx, ev, ctx.gd.chess(base)?.name || null);
|
||||
if (!ctx.grantChess(base, { requirePool: false })) toastNothing(ctx, ev);
|
||||
return;
|
||||
}
|
||||
const id = rollSameBond(ctx, ctx.pieceBonds(ev.target.uid), 6);
|
||||
if (!id || !ctx.grantChess(id)) toastNothing(ctx, ev);
|
||||
const bonds = new Set(ctx.pieceBonds(ev.target.uid));
|
||||
const id = ctx.rollChess({ maxTier: 6, ignoreCounts: true,
|
||||
filter: cid => ctx.gd.chess(cid)?.bonds?.some(b => bonds.has(b)) });
|
||||
if (!id || !ctx.grantChess(id, { requirePool: false })) toastNothing(ctx, ev);
|
||||
},
|
||||
},
|
||||
use_equip_reward_special_goods_char_chess: {
|
||||
@@ -285,7 +286,8 @@ const EFFECT_HANDLERS = {
|
||||
to.giftTicker(ctx.name, p.chessId, ctx.playerId);
|
||||
},
|
||||
},
|
||||
// 整备: the next purchased item becomes advanced (golden)
|
||||
// 整备: the next purchased item becomes advanced (golden). upgradeItem leaves it normal, and this
|
||||
// charge unspent, when the shared stock has no room for the golden's second copy.
|
||||
builtin_next_buy_golden_item: {
|
||||
onBuy(ctx, ev) {
|
||||
const ref = ctx.source.ref;
|
||||
|
||||
+10
-7
@@ -136,12 +136,12 @@ function itemCard(gd, id) {
|
||||
* Build the draft cards for an SP round.
|
||||
* @returns {{ family: string, name: string, desc: string, eventId: string|null, cards: object[] } | null}
|
||||
*/
|
||||
export function generateDraft(gd, rng, round, { stageId = null, bondAvailable = null } = {}) {
|
||||
export function generateDraft(gd, rng, round, { stageId = null, bondAvailable = null, itemAvailable = null } = {}) {
|
||||
const sch = scheduleFor(gd, round);
|
||||
const fams = Array.isArray(sch.families) && sch.families.length ? sch.families.map((f) => [f.family, f.weight]) : [['supply', 1]];
|
||||
let family = weightedPick(rng, fams) || 'supply';
|
||||
const n = Number.isInteger(sch.cards) && sch.cards > 0 ? Math.min(sch.cards, 6) : formatCount(gd);
|
||||
const opts = { stageId, bondAvailable, round };
|
||||
const opts = { stageId, bondAvailable, round, itemAvailable };
|
||||
let cards = buildCards(gd, rng, family, n, sch, opts);
|
||||
if (!cards.length && family !== 'supply') { family = 'supply'; cards = buildCards(gd, rng, family, n, sch, opts); }
|
||||
if (!cards.length) return null;
|
||||
@@ -328,16 +328,17 @@ function bountyDraftCards(gd, rng, n, sch, round) {
|
||||
* the nearest lower one, then any) — so one item can fill two slots. Positions shuffled; `n` < the slots (solo) keeps
|
||||
* `n` of them. Null without a usable `shopDraft` or at a round its `rounds` (when given) does not list.
|
||||
*/
|
||||
export function shopDraftCards(gd, rng, n, round = null) {
|
||||
export function shopDraftCards(gd, rng, n, round = null, itemAvailable = null) {
|
||||
const spec = gd.choices.shopDraft;
|
||||
const slots = spec && Array.isArray(spec.slots) ? spec.slots.filter((x) => x && typeof x === 'object') : [];
|
||||
if (!slots.length) return null;
|
||||
if (Array.isArray(spec.rounds) && !spec.rounds.includes(round)) return null;
|
||||
const w = spec.itemWeights && typeof spec.itemWeights === 'object' ? spec.itemWeights : {};
|
||||
const coin = typeof spec.coin === 'string' && gd.item(spec.coin) ? spec.coin : null;
|
||||
const free = list => itemAvailable ? list.filter(itemAvailable) : list;
|
||||
const ofTier = (t) => {
|
||||
for (let k = t; k >= 1; k--) if ((gd.shopItemsByTier[k] || []).length) return gd.shopItemsByTier[k];
|
||||
return eligibleItems(gd, 1, 6);
|
||||
for (let k = t; k >= 1; k--) { const list = free(gd.shopItemsByTier[k] || []); if (list.length) return list; }
|
||||
return free(eligibleItems(gd, 1, 6));
|
||||
};
|
||||
const out = [];
|
||||
for (const slot of slots) {
|
||||
@@ -382,10 +383,10 @@ export function tacticDraftCards(gd, rng, n, { stageId = null, bondAvailable = n
|
||||
return out;
|
||||
}
|
||||
|
||||
function buildCards(gd, rng, family, n, sch, { stageId = null, bondAvailable = null, round = 1 } = {}) {
|
||||
function buildCards(gd, rng, family, n, sch, { stageId = null, bondAvailable = null, round = 1, itemAvailable = null } = {}) {
|
||||
if (family === 'bounty') return bountyDraftCards(gd, rng, n, sch, round);
|
||||
if (family === 'shop') {
|
||||
const cards = shopDraftCards(gd, rng, n, round);
|
||||
const cards = shopDraftCards(gd, rng, n, round, itemAvailable);
|
||||
if (cards) return cards;
|
||||
}
|
||||
if (family === 'supply' || family === 'shop') {
|
||||
@@ -393,7 +394,9 @@ function buildCards(gd, rng, family, n, sch, { stageId = null, bondAvailable = n
|
||||
let hi = 6;
|
||||
if (family === 'supply' && Array.isArray(sch.supplyTiers) && sch.supplyTiers.length === 2) [lo, hi] = sch.supplyTiers;
|
||||
let list = eligibleItems(gd, lo, hi);
|
||||
if (itemAvailable) list = list.filter(itemAvailable);
|
||||
if (!list.length) list = eligibleItems(gd, 1, 6);
|
||||
if (itemAvailable) list = list.filter(itemAvailable);
|
||||
const out = [];
|
||||
for (let i = 0; i < n && list.length; i++) out.push(itemCard(gd, list[Math.floor(rng() * list.length)]));
|
||||
return out;
|
||||
|
||||
@@ -354,7 +354,8 @@ export class EffectDispatcher {
|
||||
const repeat = hook === 'onGain' ? this.investRepeat(ps) : 1;
|
||||
const loc = ps.find(self.uid);
|
||||
const where = loc ? (loc.area === 'board' ? 'board' : loc.area) : 'trigger';
|
||||
this._call(ps, key, h, hook, { kind: 'garrison', key, piece: self, garrisonId: gid, garrison: g, bb: g.bb || {}, bbStr: g.bbStr || {}, where, trigger: true, triggeredBy }, ev, repeat);
|
||||
this._call(ps, key, h, hook, { kind: 'garrison', key, piece: self, copiedFrom: asPiece ? piece : null,
|
||||
garrisonId: gid, garrison: g, bb: g.bb || {}, bbStr: g.bbStr || {}, where, trigger: true, triggeredBy }, ev, repeat);
|
||||
n++;
|
||||
}
|
||||
} finally {
|
||||
@@ -582,7 +583,7 @@ export function makeCtx(m, ps, source, hook, ev = null) {
|
||||
return typeof opts.filter === 'function' ? !!opts.filter(id) : true;
|
||||
};
|
||||
const extra = typeof ps.diyStockEntries === 'function' ? ps.diyStockEntries() : null;
|
||||
return m.pool.roll(m.rngMeta, { maxTier: Number.isInteger(opts.maxTier) ? opts.maxTier : 6, tier: Number.isInteger(opts.tier) ? opts.tier : null, filter: f, extra });
|
||||
return m.pool.roll(m.rngMeta, { maxTier: Number.isInteger(opts.maxTier) ? opts.maxTier : 6, tier: Number.isInteger(opts.tier) ? opts.tier : null, filter: f, extra, ignoreCounts: opts.ignoreCounts === true });
|
||||
},
|
||||
rollItem: (opts = {}) => m.rollItemId(opts),
|
||||
/**
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
import { TICK, SNAPSHOT_EVERY } from '../sim/constants.js';
|
||||
import { layerGainRoom } from '../../shared/constants.js';
|
||||
import { uniteLeft } from '../sim/spec.js';
|
||||
import { diyTokenOwner } from '../../shared/diy.js';
|
||||
import { diyTokenOwner, diyRecordOf } from '../../shared/diy.js';
|
||||
|
||||
export const MAX_TICKS_PER_INTERVAL = 8;
|
||||
export const INTERVAL_MS = 1000 / 30;
|
||||
@@ -679,6 +679,7 @@ export function specBounds(spec, gd = null) {
|
||||
if (!p || typeof p.playerId !== 'string') continue;
|
||||
const chess = new Map();
|
||||
const all = new Map();
|
||||
const names = new Map();
|
||||
// unit types this player can field: its board's chess / tokens, their summons, and the ownerless summons of
|
||||
// bonds / bands (炎佑, 预备干员) — the only names a statistic of a unit created in battle (no board uid) may carry
|
||||
const defIds = new Set(gd ? ownerlessSummons(gd) : []);
|
||||
@@ -686,6 +687,11 @@ export function specBounds(spec, gd = null) {
|
||||
if (!u || !Number.isInteger(u.uid)) continue;
|
||||
const defId = u.kind === 'token' ? u.tokenId : u.chessId;
|
||||
all.set(u.uid, defId);
|
||||
if (gd) {
|
||||
const base = gd.chess?.(defId) || gd.token?.(defId);
|
||||
const rec = u.standIn === true ? gd.standIn?.(defId) : u.diy && base?.isDiy ? diyRecordOf(base, u.diy, gd.raw) : base;
|
||||
if (typeof rec?.name === 'string') names.set(u.uid, rec.name);
|
||||
}
|
||||
if (typeof defId === 'string') defIds.add(defId);
|
||||
if (u.kind !== 'token') {
|
||||
chess.set(u.uid, defId);
|
||||
@@ -699,7 +705,7 @@ export function specBounds(spec, gd = null) {
|
||||
const v = Number(b && b.layers);
|
||||
if (Number.isFinite(v) && v > 0) startLayers.set(id, v);
|
||||
}
|
||||
players.set(p.playerId, { chess, all, defIds, bonds: gd ? layerBondsOf(p, gd) : null, startLayers, layerAllow: gd ? layerAllowanceOf(p, gd) : new Map() });
|
||||
players.set(p.playerId, { chess, all, names, defIds, bonds: gd ? layerBondsOf(p, gd) : null, startLayers, layerAllow: gd ? layerAllowanceOf(p, gd) : new Map() });
|
||||
}
|
||||
const round = Number(spec && spec.round) || 0;
|
||||
return {
|
||||
@@ -875,7 +881,7 @@ export function validateClientResult(spec, raw, { gd = null } = {}) {
|
||||
const rec = gd ? (gd.chess?.(defId) || gd.token?.(defId)) : null;
|
||||
if (gd && !rec) continue;
|
||||
unitStats.push({
|
||||
uid: Number.isInteger(u.uid) ? u.uid : null, defId, name: rec && typeof rec.name === 'string' ? rec.name : defId, kind: u.kind === 'token' ? 'token' : 'op',
|
||||
uid: Number.isInteger(u.uid) ? u.uid : null, defId, name: (onBoard && own.names.get(u.uid)) || (typeof rec?.name === 'string' ? rec.name : defId), kind: u.kind === 'token' ? 'token' : 'op',
|
||||
dmg: Math.max(0, Number(u.dmg) || 0), kills: Math.max(0, Math.trunc(Number(u.kills) || 0)), heal: Math.max(0, Number(u.heal) || 0),
|
||||
taken: Math.max(0, Number(u.taken) || 0), attacks: Math.max(0, Math.trunc(Number(u.attacks) || 0)),
|
||||
});
|
||||
|
||||
@@ -295,11 +295,21 @@ export class GameData {
|
||||
/** Copies of a base chess in the shared pool. */
|
||||
poolCopies(baseId) {
|
||||
const ov = this.economy.poolCopiesOverrides;
|
||||
if (ov && typeof ov === 'object' && Number.isInteger(ov[baseId]) && ov[baseId] >= 0) return ov[baseId];
|
||||
const tier = this.tierOf(baseId);
|
||||
const pc = this.economy.poolCopies;
|
||||
const v = pc && typeof pc === 'object' ? pc[tier] : undefined;
|
||||
return Number.isInteger(v) && v >= 0 ? v : (DEFAULTS.poolCopies[tier] ?? 10);
|
||||
const v = ov && Number.isInteger(ov[baseId]) && ov[baseId] >= 0 ? ov[baseId] : pc?.[tier];
|
||||
const base = Number.isInteger(v) && v >= 0 ? v : (DEFAULTS.poolCopies[tier] ?? 10);
|
||||
// The measured doubled pool is shared by the whole co-op team, independent of team size. DIY is private stock.
|
||||
const multiplier = this.isSolo || this.chess(baseId)?.isDiy ? 1 : posIntOr(this.economy.coopPoolMultiplier, 1);
|
||||
return base * multiplier;
|
||||
}
|
||||
|
||||
/** Normal equipment copies in the shared pool; effect-only items have no shop stock limit. */
|
||||
itemPoolCopies(id) {
|
||||
const base = this.baseIdOf(id), rec = this.item(base);
|
||||
if (!rec || !isShopItem(rec)) return null;
|
||||
const count = this.economy.itemPoolCopiesOverrides?.[base] ?? this.economy.itemPoolCopies?.[rec.tier];
|
||||
return Number.isInteger(count) && count >= 0 ? count : null;
|
||||
}
|
||||
|
||||
/** Copies needed to merge (0 = never merges: golden chess). */
|
||||
@@ -443,8 +453,8 @@ export class GameData {
|
||||
* Team LP the overtime drain has taken when a boss field clock reads `gt` game seconds: bossOvertimeDrainReal per
|
||||
* whole REAL second past bossOvertimeAfterReal (the first point at 151 real s).
|
||||
*/
|
||||
bossOvertimeDue(gt) {
|
||||
const over = (Number(gt) || 0) / this.combatTimeScale - this.bossOvertimeAfterReal;
|
||||
bossOvertimeDue(gt, afterReal = this.bossOvertimeAfterReal) {
|
||||
const over = (Number(gt) || 0) / this.combatTimeScale - afterReal;
|
||||
return over >= 1 ? Math.floor(over) * this.bossOvertimeDrainReal : 0;
|
||||
}
|
||||
get dp() {
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
// (test/match/harness.js checkInvariants asserts the list is empty) and by tools/matchrun.mjs --check sweeps.
|
||||
//
|
||||
// collectViolations(m) → string[] (empty when every invariant holds):
|
||||
// pool 0 ≤ left ≤ cap and left + Σ copies held by pieces == cap per base chess; non-pool chess hold 0 copies;
|
||||
// pool raw balance ≤ cap and raw balance + Σ copies held by pieces == cap (public availability is clamped at zero) per base chess; non-pool chess hold 0 copies;
|
||||
// every capped shop item is held by at most its cap (normal occupies 1, upgraded 2; effect-only items have no cap)
|
||||
// a player's 自选 stock (0.2.0, player/diy.js) the same against its own pieces of each slotted slot — a DIY
|
||||
// piece is always a slotted slot of its owner's, and a DIY shop / reward card one of its stocked slots
|
||||
// economy funds / pendingFunds non-negative integers, LP finite, shop level in range, prices ≥ 0
|
||||
@@ -186,7 +187,7 @@ export function collectViolations(m, { limit = 25 } = {}) {
|
||||
}
|
||||
// 自选 stock accounting (player/diy.js): left + held == cap per slotted slot; a slot without stock holds nothing
|
||||
for (const [base, e] of ps.diyStock ? ps.diyStock.entries : []) {
|
||||
if (!(e.left >= 0 && e.left <= e.cap)) fail(`${id}: 自选 stock ${base}: left ${e.left} cap ${e.cap}`);
|
||||
if (!(Number.isInteger(e.left) && e.left <= e.cap)) fail(`${id}: 自选 stock ${base}: balance ${e.left} cap ${e.cap}`);
|
||||
const h = diyHeld.get(base) || 0;
|
||||
if (e.left + h !== e.cap) fail(`${id}: 自选 stock ${base}: left ${e.left} + held ${h} != cap ${e.cap}`);
|
||||
}
|
||||
@@ -195,12 +196,23 @@ export function collectViolations(m, { limit = 25 } = {}) {
|
||||
|
||||
// shared pool accounting
|
||||
for (const [base, e] of m.pool.entries) {
|
||||
if (!(e.left >= 0 && e.left <= e.cap)) fail(`pool ${base}: left ${e.left} cap ${e.cap}`);
|
||||
if (!(Number.isInteger(e.left) && e.left <= e.cap)) fail(`pool ${base}: balance ${e.left} cap ${e.cap}`);
|
||||
const h = held.get(base) || 0;
|
||||
if (e.left + h !== e.cap) fail(`pool ${base}: left ${e.left} + held ${h} != cap ${e.cap}`);
|
||||
}
|
||||
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.
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
|
||||
// combat fields
|
||||
if (m.phase === PHASE.COMBAT) {
|
||||
// one field per alive player (+ the force-ended field of a player who quit during this combat)
|
||||
|
||||
@@ -120,8 +120,13 @@ export class MatchBoss {
|
||||
// overtime drain starts (150 real s), both on the field clock
|
||||
const onClock = (realS) => this.sched.now() + Math.round(((realS * this.gd.combatTimeScale) / this.gameSpeed) * 1000);
|
||||
const levelTime = this.gd.bossLevelTime(this.round);
|
||||
// Maintainer 2026-10-10: only teams with an AI teammate keep the extra grace after the visible countdown.
|
||||
// Freeze this choice for the fight; a disconnect / host transfer cannot change its LP clock midway.
|
||||
const team = this.order.filter((p) => !p.left);
|
||||
this.bossOvertimeStartReal = team.length > 1 && team.some((p) => p.isBot)
|
||||
? this.gd.bossOvertimeAfterReal : (levelTime || this.gd.bossOvertimeAfterReal);
|
||||
this.deadline = this.sched.instant || !levelTime ? 0 : onClock(levelTime);
|
||||
this.overtimeAt = this.sched.instant ? 0 : onClock(this.gd.bossOvertimeAfterReal);
|
||||
this.overtimeAt = this.sched.instant ? 0 : onClock(this.bossOvertimeStartReal);
|
||||
if (this.clientCombat) { this._startFinalClient(hidden); return; }
|
||||
this._defaultWatch();
|
||||
this.markPublic();
|
||||
@@ -480,7 +485,7 @@ export class MatchBoss {
|
||||
* (bossTurnHpReduceTime, gamedata.js bossOvertimeDue).
|
||||
*/
|
||||
_applyOvertime(gt) {
|
||||
const due = this.gd.bossOvertimeDue(gt);
|
||||
const due = this.gd.bossOvertimeDue(gt, this.bossOvertimeStartReal);
|
||||
if (due > this.overtimeApplied) {
|
||||
const loss = due - this.overtimeApplied;
|
||||
this.overtimeApplied = due;
|
||||
|
||||
@@ -16,7 +16,7 @@ const PERSONAL_OFFER_SIZE = 3;
|
||||
|
||||
export class MatchSpDraft {
|
||||
enterSpDraft() {
|
||||
const draft = generateDraft(this.gd, this.rngDraft, this.round, { stageId: this.stageId, bondAvailable: (bondId) => this.bondLive(bondId) });
|
||||
const draft = generateDraft(this.gd, this.rngDraft, this.round, { stageId: this.stageId, bondAvailable: (bondId) => this.bondLive(bondId), itemAvailable: id => this.itemPool.canGain(id) });
|
||||
const alive = this.alivePlayers();
|
||||
if (!draft || !alive.length) { this.enterPrep(); return; }
|
||||
this.phase = PHASE.SP_DRAFT;
|
||||
@@ -38,6 +38,8 @@ export class MatchSpDraft {
|
||||
return s.order[s.idx] ?? null;
|
||||
}
|
||||
|
||||
spCardAvailable(card) { return !!card && this.sp?.taken[card.idx] == null && (card.kind !== 'item' || this.itemPool.canGain(card.id)); }
|
||||
|
||||
startSpTurn() {
|
||||
const s = this.sp;
|
||||
this.cancel(this._turnTimer);
|
||||
@@ -47,7 +49,7 @@ export class MatchSpDraft {
|
||||
if (ps && ps.alive && s.picks[ps.playerId] == null) break;
|
||||
s.idx++;
|
||||
}
|
||||
const available = s.cards.map((c) => c.idx).filter((i) => s.taken[i] == null);
|
||||
const available = s.cards.filter(c => this.spCardAvailable(c)).map(c => c.idx);
|
||||
if (s.idx >= s.order.length || !available.length) { this.setDeadline(0); this.later(0, () => this.finishSpDraft()); return; }
|
||||
const token = ++this._turnToken;
|
||||
if (!s.untimed) {
|
||||
@@ -57,7 +59,7 @@ export class MatchSpDraft {
|
||||
if (this.phase !== PHASE.SP_DRAFT || token !== this._turnToken) return;
|
||||
const ps = this.players.get(this.spTurn());
|
||||
if (!ps) return;
|
||||
const avail = s.cards.map((c) => c.idx).filter((i) => s.taken[i] == null);
|
||||
const avail = s.cards.filter(c => this.spCardAvailable(c)).map(c => c.idx);
|
||||
if (!avail.length) { this.finishSpDraft(); return; }
|
||||
this._applyCard(ps, avail[Math.floor(this.rngDraft() * avail.length)]);
|
||||
});
|
||||
@@ -76,7 +78,7 @@ export class MatchSpDraft {
|
||||
if (this.phase !== PHASE.SP_DRAFT || token !== this._turnToken || !this.sp) return;
|
||||
const ps = this.players.get(this.spTurn());
|
||||
if (!ps || !ps.botControlled) return;
|
||||
const avail = this.sp.cards.map((c) => c.idx).filter((i) => this.sp.taken[i] == null);
|
||||
const avail = this.sp.cards.filter(c => this.spCardAvailable(c)).map(c => c.idx);
|
||||
if (!avail.length) return;
|
||||
this._applyCard(ps, botPickCard(this, ps, this.sp.cards, avail));
|
||||
});
|
||||
@@ -88,14 +90,14 @@ export class MatchSpDraft {
|
||||
if (this.sp.picks[ps.playerId] != null) return fail(ERR.ALREADY);
|
||||
if (this.spTurn() !== ps.playerId) return fail(ERR.NOT_YOUR_TURN);
|
||||
if (!Number.isInteger(idx) || idx < 0 || idx >= this.sp.cards.length) return fail(ERR.BAD_TARGET);
|
||||
if (this.sp.taken[idx] != null) return fail(ERR.SOLD_OUT);
|
||||
if (!this.spCardAvailable(this.sp.cards[idx])) return fail(ERR.SOLD_OUT);
|
||||
this._applyCard(ps, idx);
|
||||
return OK;
|
||||
}
|
||||
|
||||
_applyCard(ps, idx) {
|
||||
const s = this.sp;
|
||||
if (!s || s.picks[ps.playerId] != null || s.taken[idx] != null) return;
|
||||
if (!s || s.picks[ps.playerId] != null || !this.spCardAvailable(s.cards[idx])) return;
|
||||
const card = s.cards[idx];
|
||||
if (!card) return;
|
||||
s.picks[ps.playerId] = idx;
|
||||
@@ -188,24 +190,20 @@ export class MatchSpDraft {
|
||||
const tierList = (lo, hi) => { const out = []; for (let t = lo; t <= hi; t++) for (const id of this.gd.shopItemsByTier[t] || []) out.push(id); return out; };
|
||||
if (p && p.kind === 'equip') {
|
||||
if (Array.isArray(p.weighted) && p.weighted.length) {
|
||||
const pairs = p.weighted.filter((x) => Array.isArray(x) && this.gd.item(x[0]));
|
||||
let total = 0;
|
||||
for (const [, w] of pairs) total += Math.max(0, Number(w) || 0);
|
||||
let r = rng() * total;
|
||||
for (const [id, w] of pairs) { r -= Math.max(0, Number(w) || 0); if (r < 0) return id; }
|
||||
return pairs.length ? pairs[pairs.length - 1][0] : null;
|
||||
const pairs = p.weighted.filter((x) => Array.isArray(x) && this.gd.item(x[0]) && this.itemPool.canGain(x[0]));
|
||||
return this.itemPool.pick(rng, pairs.map(x => x[0]), new Map(pairs));
|
||||
}
|
||||
if (Array.isArray(p.items) && p.items.length) {
|
||||
const items = p.items.filter((id) => this.gd.item(id));
|
||||
return items.length ? items[Math.floor(rng() * items.length)] : null;
|
||||
return this.itemPool.pick(rng, items);
|
||||
}
|
||||
let list;
|
||||
if (Array.isArray(p.tiers) && p.tiers.length) list = p.tiers.flatMap((t) => this.gd.shopItemsByTier[t] || []);
|
||||
else list = tierList(1, p.maxTier === 'shopLevel' ? Math.max(1, Math.min(6, shopLevel)) : 6);
|
||||
return list.length ? list[Math.floor(rng() * list.length)] : null;
|
||||
return this.itemPool.pick(rng, list);
|
||||
}
|
||||
const list = Number.isInteger(tier) ? tierList(tier, tier) : tierList(1, Math.max(1, Math.min(6, maxTier)));
|
||||
return list.length ? list[Math.floor(rng() * list.length)] : null;
|
||||
return this.itemPool.pick(rng, list);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -138,7 +138,8 @@ export class MatchViews {
|
||||
if (this.phase === PHASE.SP_DRAFT && this.sp) {
|
||||
const s = this.sp;
|
||||
v.sp = {
|
||||
family: s.family, name: s.name, desc: s.desc, eventId: s.eventId, cards: s.cards.map(cardView), order: s.order.slice(),
|
||||
family: s.family, name: s.name, desc: s.desc, eventId: s.eventId,
|
||||
cards: s.cards.map(c => ({ ...cardView(c), soldOut: c.kind === 'item' && !this.itemPool.canGain(c.id) })), order: s.order.slice(),
|
||||
turn: this.spTurn(), picks: { ...s.picks }, taken: { ...s.taken }, untimed: !!s.untimed,
|
||||
};
|
||||
}
|
||||
@@ -243,10 +244,29 @@ export class MatchViews {
|
||||
return p ? { charId: p.charId, skillIndex: p.skillIndex, uniEquipId: p.uniEquipId } : undefined;
|
||||
}
|
||||
|
||||
/** The 外勤医疗 reserve medic already standing on each player's field in preparation. Touch replaces it only at
|
||||
* 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 [];
|
||||
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)));
|
||||
const rec = this.gd.token(id);
|
||||
if (!slot || !rec) return [];
|
||||
const dir = slot.dir || 'RIGHT';
|
||||
return [{
|
||||
id: -ps.seat - 1, uid: -ps.seat - 1, kind: 'token', side: 'ally', ownerId: ps.playerId, defId: id,
|
||||
area: 'board', name: rec.name || id, tier: 1, golden: false,
|
||||
spine: rec.assets?.spine || id, avatar: rec.assets?.avatar || id,
|
||||
x: slot.pos[1], y: slot.pos[0], dir, facing: dir === 'LEFT' ? -1 : 1, maxHp: rec.stats?.maxHp || 1,
|
||||
}];
|
||||
}
|
||||
|
||||
/** UnitInfo of a player's board pieces on their (board) tiles — a prep scout's board, the boss partner's (bossMateView).
|
||||
* `area: 'board'` (a bench unit says 'hand' / 'temp': prepFieldMeta). */
|
||||
_prepBoardUnits(ps) {
|
||||
const units = [];
|
||||
const units = this.prepMapChars(ps);
|
||||
// the player's own view of the data (0.2.0 自选编队: its slotted DIY slots are its operators — player/diy.js)
|
||||
const gd = ps.gd || this.gd;
|
||||
for (const { r, c, piece } of boardOrder(ps.board)) {
|
||||
|
||||
@@ -43,7 +43,7 @@ export class PlayerAcquire {
|
||||
const base = this.gd.baseIdOf(chessId);
|
||||
const need = rec.isGolden ? this.gd.goldenCopies : 1;
|
||||
// the shared pool, or this player's stock of a slotted 自选 piece (player/diy.js poolOf)
|
||||
const taken = fromPool ? this.poolOf(base).take(base, need) : 0;
|
||||
const taken = fromPool ? this.poolOf(base).take(base, need, { overdraw: true }) : 0;
|
||||
const piece = this.newPiece('chess', chessId, { poolCopies: taken });
|
||||
this.round.gainedChess++;
|
||||
let owned = piece;
|
||||
@@ -102,6 +102,7 @@ export class PlayerAcquire {
|
||||
// a new piece: this round's per-piece counters start at 0, none is carried over from the copies — a newly merged
|
||||
// elite 拉普兰德 is a new 拉普兰德 and fires +8 on its own first manual refresh this round (GitHub #169; the owner's
|
||||
// decision of 2026-10-06; pieceRoundCount)
|
||||
copies += this.poolOf(baseId).take(baseId, Math.max(0, this.gd.goldenCopies - copies), { overdraw: true });
|
||||
const elite = this.newPiece('chess', goldenId, { poolCopies: copies });
|
||||
const deployed = consumed.filter((l) => l.key && !this.board.has(l.key)).map((l) => ({ key: l.key, dir: pieceDir(l.piece) }));
|
||||
const toTile = (t) => { elite.dir = parseDir(t.dir) || 'RIGHT'; this.board.set(t.key, elite); return 'board'; };
|
||||
@@ -146,7 +147,7 @@ export class PlayerAcquire {
|
||||
if (!goldenId) return false;
|
||||
const base = this.gd.baseIdOf(piece.id);
|
||||
const extra = Math.max(0, this.gd.goldenCopies - (piece.poolCopies || 0));
|
||||
piece.poolCopies = (piece.poolCopies || 0) + this.poolOf(base).take(base, extra);
|
||||
piece.poolCopies = (piece.poolCopies || 0) + this.poolOf(base).take(base, extra, { overdraw: true });
|
||||
piece.id = goldenId;
|
||||
this.recompute();
|
||||
// an elite on the board tops its summon stacks up to the elite's deploy limit, like an elite merged onto a tile
|
||||
@@ -196,12 +197,14 @@ export class PlayerAcquire {
|
||||
return np;
|
||||
}
|
||||
|
||||
/** Normal item → golden version in place (整备). */
|
||||
/** Normal item → golden version in place (整备). A golden occupies two shared copies and this piece
|
||||
* already occupies one, so the upgrade waits when no second copy is free. The caller keeps its charge. */
|
||||
upgradeItem(piece) {
|
||||
const rec = this.gd.item(piece.id);
|
||||
if (!rec || rec.isGolden) return false;
|
||||
const gid = rec.upgradeChessId || rec.goldenId;
|
||||
if (!gid || !this.gd.item(gid)) return false;
|
||||
if (this.m.itemPool.left(piece.id) < 1) return false;
|
||||
piece.id = gid;
|
||||
this.recompute();
|
||||
return true;
|
||||
@@ -241,7 +244,7 @@ export class PlayerAcquire {
|
||||
* 'item' under its `label` (the effect's name; player report #6 after 0.1.0).
|
||||
*/
|
||||
pushItemOffer(ids, { source = 'effect', tier = null, label = null } = {}) {
|
||||
const list = [...new Set(Array.isArray(ids) ? ids : [])].filter((id) => this.gd.item(id)).slice(0, MAX_OFFER_SLOTS);
|
||||
const list = [...new Set(Array.isArray(ids) ? ids : [])].filter((id) => this.gd.item(id) && this.m.itemPool.canGain(id)).slice(0, MAX_OFFER_SLOTS);
|
||||
if (!list.length) return null;
|
||||
const offer = { tier: Number.isInteger(tier) ? tier : null, source, label: typeof label === 'string' && label ? label : null, slots: list.map((id) => ({ kind: 'item', id, price: 0, sold: false })) };
|
||||
this.offers.push(offer);
|
||||
@@ -285,6 +288,10 @@ export class PlayerAcquire {
|
||||
acquireItem(itemId, { source = 'grant', toTemp = false, silent = false, deferMerge = false } = {}) {
|
||||
const rec = this.gd.item(itemId);
|
||||
if (!rec) return null;
|
||||
if (!this.m.itemPool.canGain(itemId)) {
|
||||
this.m.toast(this, 'warn', '装备库存不足,未获得该装备');
|
||||
return null;
|
||||
}
|
||||
let piece = this.newPiece('item', itemId);
|
||||
// A prep-end grant may sit beside an identical copy until the next prep. The invariant counts only copies
|
||||
// without this mark, so the fight that is about to start is not reported as a missed merge.
|
||||
|
||||
@@ -53,13 +53,13 @@ export class DiyStock {
|
||||
|
||||
has(baseId) { return this.entries.has(baseId); }
|
||||
cap(baseId) { return this.entries.get(baseId)?.cap ?? 0; }
|
||||
left(baseId) { return this.entries.get(baseId)?.left ?? 0; }
|
||||
left(baseId) { return Math.max(0, this.entries.get(baseId)?.left ?? 0); }
|
||||
|
||||
/** Take up to n copies; returns the number taken (0 for a base id without stock). */
|
||||
take(baseId, n = 1) {
|
||||
take(baseId, n = 1, { overdraw = false } = {}) {
|
||||
const e = this.entries.get(baseId);
|
||||
if (!e || !(n > 0)) return 0;
|
||||
const k = Math.min(e.left, Math.floor(n));
|
||||
const k = overdraw ? Math.floor(n) : Math.min(Math.max(0, e.left), Math.floor(n));
|
||||
e.left -= k;
|
||||
return k;
|
||||
}
|
||||
@@ -76,7 +76,7 @@ export class DiyStock {
|
||||
/** { baseId: left } (tests / diagnostics). */
|
||||
snapshot() {
|
||||
const o = {};
|
||||
for (const [id, e] of this.entries) o[id] = e.left;
|
||||
for (const [id, e] of this.entries) o[id] = Math.max(0, e.left);
|
||||
return o;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ export class PlayerEconomy {
|
||||
}
|
||||
|
||||
_rollItemSlot() {
|
||||
const id = this.m.pool.rollItem(this.m.rngShop, this.shop.level);
|
||||
const id = this.m.pool.rollItem(this.m.rngShop, this.shop.level, this.m.itemPool);
|
||||
return id ? { kind: 'item', id, basePrice: this.gd.itemPrice(id), frozen: false, sold: false } : null;
|
||||
}
|
||||
|
||||
@@ -151,6 +151,7 @@ export class PlayerEconomy {
|
||||
piece = this.acquireChess(slot.id, { source: 'buy' });
|
||||
} else {
|
||||
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);
|
||||
this.spend(price);
|
||||
slot.sold = true;
|
||||
|
||||
@@ -8,6 +8,7 @@ import { ERR } from '../../../shared/constants.js';
|
||||
import { msg, dn } from '../../../shared/i18n.js';
|
||||
import { tileKey, parseKey, inField, canPlace, placeClass, freeSlot, pieceDir, parseDir, ownerRangeKeys } from '../board.js';
|
||||
import { attackRangeGrid, loadoutRecord, resolveRecordLoadout } from '../../../shared/loadoutRecord.js';
|
||||
import { summonPlacementGrid } from '../../../shared/summonPlacement.js';
|
||||
import { OK, fail } from './common.js';
|
||||
|
||||
export class PlayerPlacement {
|
||||
@@ -46,7 +47,8 @@ export class PlayerPlacement {
|
||||
* Where piece may stand on (r, c): the deploy map of its position class (board.js canPlace) and, for a summon whose
|
||||
* text reads "只能部署在召唤者攻击范围内" (tokens.json `ownerRange`: 伺夜's 狼群, 缪尔赛思's 流形, Mon3tr's 重构体), a tile of
|
||||
* its owner's attack range (summonRange); for one marked `ownerRangeOutside` (凯尔希·思衡托's 战术锚点), a tile outside it
|
||||
* (summonExcluded). `owner` = the owner's position after the move being checked ({ key, piece, dir }: a summon swapped
|
||||
* (summonExcluded). Jessica's shield instead uses the fixed four-adjacent-tile grid (shared/summonPlacement.js).
|
||||
* `owner` = the owner's position after the move being checked ({ key, piece, dir }: a summon swapped
|
||||
* with its own owner).
|
||||
*/
|
||||
_legal(piece, r, c, owner = null) {
|
||||
@@ -62,15 +64,18 @@ export class PlayerPlacement {
|
||||
* The 'r,c' keys of the attack range of a range-bound summon's owner (player report #9 after 0.1.0: 伺夜's tactical
|
||||
* point could be placed anywhere; PRTS 狼群 特性 "只能部署在召唤者攻击范围内"): the owner's loadout-resolved range grid
|
||||
* (shared/loadoutRecord.js attackRangeGrid — what the deploy wheel previews) rotated by its facing around its board
|
||||
* tile (board.js ownerRangeKeys). Null when the piece is not range-bound or its owner is not on the board (the other
|
||||
* tile (board.js ownerRangeKeys). Jessica's shield uses its four-adjacent-tile placement grid, independent of her
|
||||
* facing, skill or module. Null when the piece is not range-bound or its owner is not on the board (the other
|
||||
* rules refuse such a placement).
|
||||
* @param {any} piece
|
||||
* @param {{ key: string, piece: any, dir: string } | null} [owner] the owner's position to use instead of its current one
|
||||
* @returns {Set<string> | null}
|
||||
*/
|
||||
summonRange(piece, owner = null) {
|
||||
if (!piece || piece.kind !== 'token' || this.gd.token(piece.id)?.ownerRange !== true) return null;
|
||||
return this._ownerRangeOf(piece, owner);
|
||||
if (!piece || piece.kind !== 'token') return null;
|
||||
const grid = summonPlacementGrid(piece.id);
|
||||
if (!grid && this.gd.token(piece.id)?.ownerRange !== true) return null;
|
||||
return this._ownerRangeOf(piece, owner, grid);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -86,14 +91,14 @@ export class PlayerPlacement {
|
||||
return this._ownerRangeOf(piece, owner);
|
||||
}
|
||||
|
||||
/** The owner's attack-range keys of a summon piece (summonRange / summonExcluded), or null when its owner is off the board. */
|
||||
_ownerRangeOf(piece, owner = null) {
|
||||
/** The owner's attack or fixed placement range of a summon, or null when its owner is off the board. */
|
||||
_ownerRangeOf(piece, owner = null, placementGrid = null) {
|
||||
let at = owner;
|
||||
if (!at) for (const [key, p] of this.board) if (p.uid === piece.ownerUid && p.kind === 'chess') { at = { key, piece: p, dir: pieceDir(p) }; break; }
|
||||
const chess = at && this.gd.chess(at.piece.id);
|
||||
if (!chess) return null;
|
||||
const rec = this.fieldRecord(chess); // 0.2.0 补位: a stand-in's own range (its backup selection)
|
||||
const grid = attackRangeGrid(loadoutRecord(rec, resolveRecordLoadout(rec, this.loadoutFor(chess)))) || rec.rangeGrid;
|
||||
const grid = placementGrid || attackRangeGrid(loadoutRecord(rec, resolveRecordLoadout(rec, this.loadoutFor(chess)))) || rec.rangeGrid;
|
||||
const [r, c] = parseKey(at.key);
|
||||
return ownerRangeKeys(grid, r, c, at.dir);
|
||||
}
|
||||
@@ -108,7 +113,7 @@ export class PlayerPlacement {
|
||||
* battle. Returns the number taken off the board; a toast names them.
|
||||
*/
|
||||
_liftOutOfRange() {
|
||||
const back = [], gone = [], backOut = [], goneOut = [];
|
||||
const back = [], gone = [], backOut = [], goneOut = [], backAdjacent = [], goneAdjacent = [];
|
||||
for (const [k, p] of [...this.board]) {
|
||||
if (p.kind !== 'token') continue;
|
||||
const range = this.summonRange(p);
|
||||
@@ -117,14 +122,17 @@ export class PlayerPlacement {
|
||||
if (!inside && (!range || range.has(k))) continue;
|
||||
this.board.delete(k);
|
||||
const ok = this._returnToken(p, null, { allowTemp: true });
|
||||
(inside ? (ok ? backOut : goneOut) : (ok ? back : gone)).push(this.gd.token(p.id)?.name || p.id);
|
||||
const returned = inside ? (ok ? backOut : goneOut) : summonPlacementGrid(p.id) ? (ok ? backAdjacent : goneAdjacent) : (ok ? back : gone);
|
||||
returned.push(this.gd.token(p.id)?.name || p.id);
|
||||
}
|
||||
if (back.length) this.m.toast(this, 'warn', msg('{names}只能部署在召唤者攻击范围内,已退回整备区', { names: back.map(dn) }));
|
||||
if (gone.length) this.m.toast(this, 'warn', msg('{names}只能部署在召唤者攻击范围内,整备区已满,下回合返还', { names: gone.map(dn) }));
|
||||
// an outside-bound summon (战术锚点) its owner's new range now covers
|
||||
if (backOut.length) this.m.toast(this, 'warn', msg('{names}只能部署在召唤者攻击范围外,已退回整备区', { names: backOut.map(dn) }));
|
||||
if (goneOut.length) this.m.toast(this, 'warn', msg('{names}只能部署在召唤者攻击范围外,整备区已满,下回合返还', { names: goneOut.map(dn) }));
|
||||
return back.length + gone.length + backOut.length + goneOut.length;
|
||||
if (backAdjacent.length) this.m.toast(this, 'warn', msg('{names}只能部署在召唤者相邻四格,已退回整备区', { names: backAdjacent.map(dn) }));
|
||||
if (goneAdjacent.length) this.m.toast(this, 'warn', msg('{names}只能部署在召唤者相邻四格,整备区已满,下回合返还', { names: goneAdjacent.map(dn) }));
|
||||
return back.length + gone.length + backOut.length + goneOut.length + backAdjacent.length + goneAdjacent.length;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -31,6 +31,7 @@ export class PlayerPrep {
|
||||
const handFull = freeSlot(this.hand) < 0;
|
||||
if (slot.kind === 'item') {
|
||||
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 {
|
||||
const rec = this.gd.chess(slot.id);
|
||||
|
||||
@@ -88,6 +88,8 @@ export class PlayerViews {
|
||||
hand: this.hand.map((p) => (p ? this.pieceView(p) : null)),
|
||||
temp: this.temp.map((p) => (p ? this.pieceView(p) : null)),
|
||||
board,
|
||||
// The stage's 外勤医疗 reserve medic is present during prep, but is not a draggable board piece.
|
||||
prepMapChars: this.m.prepMapChars(this),
|
||||
deployCap: this.deployCap,
|
||||
deployCount: this.deployCount,
|
||||
// + the mode-off bonds it has members of (`off: true`, the strip's grey 本局禁用 discs — bondsMeta.offBondCounts)
|
||||
|
||||
+54
-15
@@ -3,12 +3,13 @@
|
||||
//
|
||||
// Model:
|
||||
// * Every visible (non-hidden, non-DIY) base chess that is not banned this match has `cap` copies
|
||||
// (config.economy.poolCopies[tier], overrides e.g. 缪尔赛思 4). `left[baseId]` = copies not owned by anyone.
|
||||
// (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.
|
||||
// * 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 (tests): 0 ≤ left ≤ cap and left + Σ held copies == cap for every base chess.
|
||||
// * Invariant: raw left ≤ cap and raw left + Σ held copies == cap. Mimic can exceed stock, but its copies still occupy it.
|
||||
//
|
||||
// Rolls: each chess slot draws ONE copy uniformly from all remaining copies of eligible chess with tier ≤ shop level
|
||||
// ("copy-weighted"; duplicates within a roll allowed). The item slot picks a tier with the same tier shares, then a
|
||||
@@ -72,13 +73,13 @@ export class SharedPool {
|
||||
/** Whether a base chess is part of this match's pool (visible, not banned). */
|
||||
has(baseId) { return this.entries.has(baseId); }
|
||||
cap(baseId) { return this.entries.get(baseId)?.cap ?? 0; }
|
||||
left(baseId) { return this.entries.get(baseId)?.left ?? 0; }
|
||||
left(baseId) { return Math.max(0, this.entries.get(baseId)?.left ?? 0); }
|
||||
|
||||
/** Take up to n copies; returns the number actually taken (0 when not in the pool / empty). */
|
||||
take(baseId, n = 1) {
|
||||
take(baseId, n = 1, { overdraw = false } = {}) {
|
||||
const e = this.entries.get(baseId);
|
||||
if (!e || !(n > 0)) return 0;
|
||||
const k = Math.min(e.left, Math.floor(n));
|
||||
const k = overdraw ? Math.floor(n) : Math.min(Math.max(0, e.left), Math.floor(n));
|
||||
e.left -= k;
|
||||
return k;
|
||||
}
|
||||
@@ -96,14 +97,14 @@ export class SharedPool {
|
||||
* Remaining copies of eligible chess (tier ≤ maxTier, or exactly `tier`): the pool's entries, then `extra` ([id, entry]
|
||||
* pairs of the same shape — a player's 自选 stock) under the same filters.
|
||||
*/
|
||||
_eligible({ maxTier = 6, tier = null, filter = null, extra = null } = {}) {
|
||||
_eligible({ maxTier = 6, tier = null, filter = null, extra = null, ignoreCounts = false } = {}) {
|
||||
const out = [];
|
||||
const scan = (list) => {
|
||||
for (const [id, e] of list) {
|
||||
if (e.left <= 0) continue;
|
||||
if (!ignoreCounts && e.left <= 0) continue;
|
||||
if (tier != null ? e.tier !== tier : e.tier > maxTier) continue;
|
||||
if (filter && !filter(id, e)) continue;
|
||||
out.push([id, e.left]);
|
||||
out.push([id, ignoreCounts ? 1 : e.left]);
|
||||
}
|
||||
};
|
||||
scan(this.entries);
|
||||
@@ -145,7 +146,7 @@ export class SharedPool {
|
||||
* Item roll for the shop's item slot: tier by the chess tier shares at this level, uniform item within the tier,
|
||||
* falling back to lower tiers when a tier has no item. Returns an item id or null.
|
||||
*/
|
||||
rollItem(rng, maxTier) {
|
||||
rollItem(rng, maxTier, stock = null) {
|
||||
const shares = this.tierShares(maxTier);
|
||||
const tiers = Object.keys(shares).map(Number).sort((a, b) => a - b);
|
||||
let tier = null;
|
||||
@@ -157,12 +158,12 @@ export class SharedPool {
|
||||
tier = 1 + Math.floor(rng() * Math.max(1, maxTier));
|
||||
}
|
||||
for (let t = tier; t >= 1; t--) {
|
||||
const list = this.gd.shopItemsByTier[t];
|
||||
if (list && list.length) return list[Math.floor(rng() * list.length)];
|
||||
const list = (this.gd.shopItemsByTier[t] || []).filter(id => !stock || stock.canGain(id));
|
||||
if (list.length) return stock ? stock.pick(rng, list) : list[Math.floor(rng() * list.length)];
|
||||
}
|
||||
for (let t = tier + 1; t <= 6; t++) {
|
||||
const list = this.gd.shopItemsByTier[t];
|
||||
if (list && list.length) return list[Math.floor(rng() * list.length)];
|
||||
const list = (this.gd.shopItemsByTier[t] || []).filter(id => !stock || stock.canGain(id));
|
||||
if (list.length) return stock ? stock.pick(rng, list) : list[Math.floor(rng() * list.length)];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -170,13 +171,51 @@ export class SharedPool {
|
||||
/** { baseId: left } snapshot (tests / diagnostics). */
|
||||
snapshot() {
|
||||
const o = {};
|
||||
for (const [id, e] of this.entries) o[id] = e.left;
|
||||
for (const [id, e] of this.entries) o[id] = Math.max(0, e.left);
|
||||
return o;
|
||||
}
|
||||
|
||||
totalLeft() {
|
||||
let n = 0;
|
||||
for (const e of this.entries.values()) n += e.left;
|
||||
for (const e of this.entries.values()) n += Math.max(0, e.left);
|
||||
return n;
|
||||
}
|
||||
}
|
||||
|
||||
/** Shared equipment stock, derived from actual ownership. Merges, equips and returns cannot lose a reservation:
|
||||
* normal equipment occupies one, upgraded equipment two. Shop/choice displays reserve nothing. Source: the original
|
||||
* experiment BV1eLXXBqEgF, 1:30 and 2:30. [ASSUMED] An item that is consumed/destroyed or removed by elimination no
|
||||
* longer occupies stock, consistently with the video's ownership model; this is not a per-match purchase limit. */
|
||||
export class SharedItemPool {
|
||||
constructor(gd, players) { this.gd = gd; this.players = players; }
|
||||
cap(id) { return this.gd.itemPoolCopies(id); }
|
||||
need(id) { return this.gd.isGolden(id) ? 2 : 1; }
|
||||
held(id) {
|
||||
const base = this.gd.baseIdOf(id);
|
||||
let count = 0;
|
||||
const see = p => {
|
||||
if (!p) return;
|
||||
if (p.kind === 'item' && this.gd.baseIdOf(p.id) === base) count += this.need(p.id);
|
||||
for (const item of p.items || []) if (this.gd.baseIdOf(item.id) === base) count += this.need(item.id);
|
||||
};
|
||||
for (const ps of this.players()) {
|
||||
for (const p of ps.hand) see(p);
|
||||
for (const p of ps.temp) see(p);
|
||||
for (const p of ps.board.values()) see(p);
|
||||
}
|
||||
return count;
|
||||
}
|
||||
left(id) { const cap = this.cap(id); return cap == null ? Infinity : Math.max(0, cap - this.held(id)); }
|
||||
canGain(id) { return this.left(id) >= this.need(id); }
|
||||
/** Stock gates eligibility, not probability: retain the existing uniform item draw or explicit data weights.
|
||||
* The measured stock table alone does not establish shop probabilities. */
|
||||
pick(rng, ids, weights = null) {
|
||||
const list = [...new Set(ids)].filter(id => this.canGain(id));
|
||||
const pairs = list.map(id => [id, weights ? Math.max(0, Number(weights.get(id)) || 0) : 1]);
|
||||
const total = pairs.reduce((n, p) => n + Math.max(0, p[1]), 0);
|
||||
if (!(total > 0)) return null;
|
||||
let r = rng() * total;
|
||||
for (const [id, weight] of pairs) if ((r -= Math.max(0, weight)) < 0) return id;
|
||||
return pairs.at(-1)?.[0] || null;
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import { EVENT_BUFFER_CAP } from '../constants.js';
|
||||
import { elementView } from '../damage.js';
|
||||
import { unitInfo, snapshotUnits, ammoView, wolfView, negView } from '../snapshot.js';
|
||||
import { unitInfo, snapshotUnits, ammoView, wolfView, negView, coinView } from '../snapshot.js';
|
||||
|
||||
export class BattleEvents {
|
||||
fx(kind, params = {}) {
|
||||
@@ -102,6 +102,8 @@ export class BattleEvents {
|
||||
if (wv) (wolves || (wolves = [])).push([u.id, wv[0], wv[1]]);
|
||||
const ng = negView(u);
|
||||
if (ng) (neg || (neg = [])).push([u.id, ng]);
|
||||
const purse = coinView(u);
|
||||
if (purse) (snap.coins || (snap.coins = [])).push([u.id, ...purse]);
|
||||
}
|
||||
if (elem) snap.elem = elem;
|
||||
if (ammo) snap.ammo = ammo;
|
||||
|
||||
@@ -85,7 +85,7 @@ export class BattlePlayers {
|
||||
* rows 2–5, the mirrored right half of a pair (whatever coordinates the player's input uses). Never the hand / 临时
|
||||
* 整备区 rows of a boss field (rows 0–1, inside BOSS_RECT) nor the other half of a 联防 or boss field. Moves that must
|
||||
* stay where the player could deploy read it (乌尔比安's S3 【移动】, kits/ops/chess_char_5_05-ulpia.js); the 突袭
|
||||
* landing reads onFieldBoard (any player of this field).
|
||||
* landing reads onFieldBoard (any player of this field), with a Raid-only multiplayer boss/hidden exception (§29.13).
|
||||
*/
|
||||
onOwnBoard(ps, r, c) {
|
||||
if (!ps) return false;
|
||||
@@ -104,7 +104,8 @@ export class BattlePlayers {
|
||||
* the players whose units this battle holds (a teammate eliminated before it or playing another field is not one).
|
||||
* Both halves on the two-helper 联防 field and on a Final Assault / Hidden Core pair field; the own half only for a
|
||||
* lone 联防 helper and on a solo boss field; never a boss field's hand / 临时整备区 rows. The 突袭 landing reads it
|
||||
* (bonds/addon/battle.js raidTile — the owner's decision of 2026-10-07, DESIGN §26.1).
|
||||
* (bonds/addon/battle.js raidTile — the owner's decision of 2026-10-07, DESIGN §26.1), then separately admits the
|
||||
* empty deployment half in multiplayer boss/hidden battles only (§29.13); this general method is unchanged.
|
||||
*/
|
||||
onFieldBoard(r, c) {
|
||||
for (const ps of this.players) if (this.onOwnBoard(ps, r, c)) return true;
|
||||
|
||||
@@ -37,8 +37,9 @@
|
||||
// in its range counts for 「范围内没有敌人」 (the owner's decision of 2026-10-08 from the official game,
|
||||
// GitHub #316; asleep, untargetable and flying ones stay out: raidGroundEnemy): on a free tile of a board of its field
|
||||
// (Battle.onFieldBoard: its own or a teammate's on the same field — the two-helper 联防 field and a
|
||||
// pair field open both halves, the owner's decision of 2026-10-07; never a boss field's hand /
|
||||
// 临时整备区 rows, never the empty other half of a lone 联防 helper or a solo boss field — community
|
||||
// pair field open both halves, the owner's decision of 2026-10-07; #475 also opens an empty half
|
||||
// for multiplayer boss/hidden Raid only, approved 2026-10-10; never a boss field's hand /
|
||||
// 临时整备区 rows, never the empty other half of a lone 联防 helper or a single-mode boss field — community
|
||||
// reports of 2026-10-06, items 40 and 16.3) its position may be deployed
|
||||
// on from which its range covers that enemy (GitHub issue #51 [ASSUMED]: the first of the 8 most
|
||||
// advanced that has such a tile; none → it stays and the next poll looks again, never a jump that
|
||||
@@ -52,7 +53,13 @@
|
||||
// ATK/HP +(base + per·L) until it leaves the field; knocked out after a jump it lies where it fell
|
||||
// and comes back there (the engine's rest tile, Battle._layBody — PRTS 卫戍协议/帮助 "原地留下一个
|
||||
// “倒地干员”…自动部署至该位置"; its own home when it fell on another board piece's home);
|
||||
// L ≥ power_bond_stack_cnt: every operator ASPD +power_attack_speed
|
||||
// L ≥ power_bond_stack_cnt: every operator ASPD +power_attack_speed.
|
||||
// Ordinary polls order candidates by remaining path distance, then id (the 0.2.3 rule). The one
|
||||
// reselect right after a revive — knocked out, and back before the respawn timer (不屈 / 阿戈尔 /
|
||||
// 复活, or the same kill + immediate redeploy) — orders by taunt, then that distance, and jumps
|
||||
// while the operator is not blocking. An enemy already standing on its tile counts: the 0.25 s
|
||||
// poll runs before that tick's block pass, so the reselect refreshes contact first. A retreat,
|
||||
// a move, a redeploy that waited out its timer, and Raid's own jump do not arm that reselect.
|
||||
// 不屈 indomShip ground operator knocked out → p = min(1, base + per·L) immediate free redeploy where it lies
|
||||
// (the engine's rest tile); tier 2: every operator on the field +sp SP
|
||||
// 协防 emptyShip all operators phys/arts taken ×(1 − damage_resistance); members dealt ×damage_scale_normal
|
||||
@@ -67,7 +74,9 @@
|
||||
import { canTargetEnemy, enemyStealthed, extendedGrid } from '../../../targeting.js';
|
||||
import { normDir, rotateOffset, localOrder, localBefore } from '../../../dir.js';
|
||||
import { bodyKeys, bodyInKeys } from '../../../body.js';
|
||||
import { isHpLoss } from '../../../damage.js';
|
||||
import { applyRedirectCut, isHpLoss, redirectShare } from '../../../damage.js';
|
||||
import { BOSS_ROW_OFFSET } from '../../../constants.js';
|
||||
import { GEO } from '../../../../../shared/constants.js';
|
||||
import {
|
||||
num, bondRecord, buffParams, bondTier, bondLayers, isMember, isElite, isGroundOp, onField, playerOps, passiveBuff,
|
||||
fxOn, N4, N8, directMods, COLS,
|
||||
@@ -272,15 +281,26 @@ function raidReach(u) {
|
||||
return out;
|
||||
}
|
||||
|
||||
/** #475: multiplayer boss/hidden Raid may use an absent partner's deployment half (maintainer-approved, §29.13).
|
||||
* Keep this exception local to Raid: onFieldBoard/onOwnBoard still describe actual players' deployment regions.
|
||||
*/
|
||||
function raidBoard(battle, r, c) {
|
||||
if (battle.onFieldBoard(r, c)) return true;
|
||||
return battle.modeId?.startsWith('mode_multi_') && (battle.kind === 'boss' || battle.kind === 'hidden')
|
||||
&& r >= GEO.FIELD.r0 + BOSS_ROW_OFFSET && r <= GEO.FIELD.r1 + BOSS_ROW_OFFSET
|
||||
&& c >= GEO.FIELD.c0 && c <= COLS - 1 - GEO.FIELD.c0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Landing tile [row, col] of a jump to enemy `e`, or null: a tile from which the member's range (`reach`, raidReach)
|
||||
* covers the enemy's body (a huge enemy: any tile it occupies — body.js), within RAID_SEARCH tiles (Chebyshev) of the
|
||||
* enemy, inside the field rect and on a board of its field (Battle.onFieldBoard — "再部署" goes where a player of the
|
||||
* field deploys: its own board or a teammate's half when both halves are taken, the two-helper 联防 field and a pair
|
||||
* field — the owner's decision of 2026-10-07, DESIGN §26.1; never a boss field's hand row 0 or 临时整备区 row 1, both
|
||||
* inside BOSS_RECT and buildable high ground, nor the empty other half of the one-helper 联防 map or a solo boss field;
|
||||
* inside BOSS_RECT and buildable high ground, nor the empty other half of the one-helper 联防 map or a single-mode boss field;
|
||||
* community reports of 2026-10-06, items 40 and 16.3: a ranged member landed on the 临时整备区 row of a solo leader
|
||||
* round, and on the right half of the one-helper 联防 map), that the member's
|
||||
* round, and on the right half of the one-helper 联防 map). #475 additionally opens the empty deployment half only
|
||||
* in multiplayer boss/hidden battles (raidBoard, approved 2026-10-10, §29.13). The tile must be one that the member's
|
||||
* position may be deployed on (grid.canStand: never the 深水区 —
|
||||
* player report #3 after 0.1.0 —, and for a melee member low ground only, never a 高台: GitHub #148) and that is free
|
||||
* (Battle.isReservedTile: no living unit, no knocked-out operator's body — player report F5 —, no waiting piece's tile).
|
||||
@@ -305,7 +325,7 @@ function raidTile(battle, u, e, reach) {
|
||||
for (let i = 0; i < reach.length; i += 2) {
|
||||
const r = br - reach[i], c = bc - reach[i + 1], dr = r - er, dc = c - ec;
|
||||
if (Math.abs(dr) > RAID_SEARCH || Math.abs(dc) > RAID_SEARCH) continue;
|
||||
if (!battle.grid.inRect(r, c) || !battle.onFieldBoard(r, c) || !battle.grid.canStand(r, c, { ranged }) || battle.isReservedTile(r, c)) continue;
|
||||
if (!battle.grid.inRect(r, c) || !raidBoard(battle, r, c) || !battle.grid.canStand(r, c, { ranged }) || battle.isReservedTile(r, c)) continue;
|
||||
const t = r * COLS + c;
|
||||
const p = path && battle.grid.tile(r, c).pass === 'ALL' && (path.has(t) || body.includes(t)) ? 0 : 1; // its block applies
|
||||
const d = Math.max(Math.abs(dr), Math.abs(dc)) + 0.01 * (Math.abs(dr) + Math.abs(dc));
|
||||
@@ -341,12 +361,12 @@ function raidStealthInRange(battle, u) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Jump candidates of player `pid`, in priority order: the ground enemies of raidGroundEnemy (visible, or 隐匿; not
|
||||
* flying, asleep or untargetable) — those of the player's own field (`ownerId`), the others only when it has none —,
|
||||
* the most advanced first (least remaining path distance, then the earliest spawned) [ASSUMED, research 02 §3.18].
|
||||
* The same list for every member of the player (the checks read the enemy, not the attacker).
|
||||
* Jump candidates of player `pid`: the ground enemies of raidGroundEnemy (visible, or 隐匿; not flying, asleep or
|
||||
* untargetable) — those of the player's own field (`ownerId`), the others only when it has none. Ordinary polls sort
|
||||
* by remaining path distance, then stable id. `taunt` is only the one reselect after a revive (maintainer 2026-10-10):
|
||||
* highest taunt, then that distance, then id. The two orders are not one shared list.
|
||||
*/
|
||||
function raidTargets(battle, u, pid) {
|
||||
function raidTargets(battle, u, pid, { taunt = false } = {}) {
|
||||
const own = [], other = [];
|
||||
for (const e of battle.enemies) {
|
||||
if (!raidGroundEnemy(u, e)) continue;
|
||||
@@ -354,16 +374,38 @@ function raidTargets(battle, u, pid) {
|
||||
}
|
||||
const list = own.length ? own : other;
|
||||
const dist = new Map(list.map((e) => [e, num(battle.remainingDistance ? battle.remainingDistance(e) : 0)]));
|
||||
list.sort((a, b) => dist.get(a) - dist.get(b) || a.id - b.id);
|
||||
list.sort((a, b) => (taunt ? num(b.s.taunt) - num(a.s.taunt) : 0) || dist.get(a) - dist.get(b) || a.id - b.id);
|
||||
return list;
|
||||
}
|
||||
|
||||
/**
|
||||
* The block pass has not run yet this tick (the poll is in the scheduled phase, and `_deploy` clears `blocking`).
|
||||
* Refresh contact the same way `updateEnemy` does before an enemy moves: an enemy already on this tile is blocked
|
||||
* now, so a revive does not treat that tile as empty and jump away.
|
||||
*/
|
||||
function raidRefreshContact(battle, u) {
|
||||
for (const e of battle.enemies) {
|
||||
if (!e.alive || e.blockedBy || e.hidden) continue;
|
||||
if (Math.abs(Math.round(e.y) - u.tileR) > 1 || Math.abs(Math.round(e.x) - u.tileC) > 1) continue;
|
||||
battle._checkBlock(e);
|
||||
}
|
||||
}
|
||||
|
||||
function raidPoll(battle, st) {
|
||||
const bb = st.bb[ID.raid];
|
||||
const idle = num(bb.no_attack_duration, 10);
|
||||
let targets = null; // the player's candidates (raidTargets), shared by its members until a jump changes the field
|
||||
// Ordinary and post-revive orders stay separate: one poll can hold a member of each.
|
||||
let plain = null, taunted = null;
|
||||
for (const u of st.members[ID.raid]) {
|
||||
if (!onField(u) || !u.canAct) continue;
|
||||
// Maintainer 2026-10-10: one fresh target check after revival when unblocked, even with enemies in attack range.
|
||||
// Ordinary later polls retain their original empty-range rule and distance order. A Raid jump cannot arm another check.
|
||||
const revived = u.mem.raidReviveSeq === u.deploySeq;
|
||||
if (revived) {
|
||||
u.mem.raidReviveSeq = 0;
|
||||
raidRefreshContact(battle, u);
|
||||
}
|
||||
if (revived && u.blocking.some((e) => e.alive && e.blockedBy === u)) continue;
|
||||
const since = Math.max(u.lastAttackAt ?? -Infinity, u.deployedAt ?? -Infinity, u.mem[KEY.raid] ?? -Infinity);
|
||||
// 技能就绪: a charged skill, or a passive skill that is on (GitHub #49: skills.js `ready` is false for every passive,
|
||||
// so 缄默德克萨斯 / 宴 … only ever jumped on the idle trigger; the reporter's footage of the official game shows
|
||||
@@ -377,10 +419,10 @@ function raidPoll(battle, st) {
|
||||
const deploySkillOn = !!(sk && sk.active && (sk.kind === 'passive' || sk.spType === 'none'));
|
||||
const ready = !!(sk && !sk.noSkill && ((sk.ready && !(sk.active && sk.isTimed)) || deploySkillOn));
|
||||
const idleOk = battle.time - since >= idle - 1e-9;
|
||||
if (!(ready || idleOk)) continue;
|
||||
if (!(revived || ready || idleOk)) continue;
|
||||
// 「若范围内没有敌人」: a targetable enemy, or a 隐匿 ground one (the owner's decision of 2026-10-08, raidGroundEnemy)
|
||||
if (battle.enemiesInKeys(u.rangeKeys || [], u, u.profile).length || raidStealthInRange(battle, u)) continue;
|
||||
const list = (targets ??= raidTargets(battle, u, st.pid));
|
||||
if (!revived && (battle.enemiesInKeys(u.rangeKeys || [], u, u.profile).length || raidStealthInRange(battle, u))) continue;
|
||||
const list = revived ? (taunted ??= raidTargets(battle, u, st.pid, { taunt: true })) : (plain ??= raidTargets(battle, u, st.pid));
|
||||
if (!list.length) continue;
|
||||
// either trigger: raidTile only offers tiles with the target in range (without that a ready skill that finds no
|
||||
// target would redeploy — firing every 部署时 effect — at every poll; the idle trigger, which lacked it up to
|
||||
@@ -389,8 +431,9 @@ function raidPoll(battle, st) {
|
||||
for (let i = 0; i < list.length && i < RAID_TARGETS; i++) {
|
||||
const tile = raidTile(battle, u, list[i], reach);
|
||||
if (!tile) continue;
|
||||
targets = null; // the retreat / redeploy handlers (部署时 effects) may change the enemies: the next member re-sorts
|
||||
plain = taunted = null; // the retreat / redeploy handlers (部署时 effects) may change the enemies: the next member re-sorts
|
||||
const res = raidRedeploy(battle, u, tile[0], tile[1]);
|
||||
u.mem.raidReviveSeq = 0;
|
||||
if (!res) continue;
|
||||
u.mem[KEY.raid] = battle.time; // with deployedAt: the idle time starts again from the landing
|
||||
if (res === 'raid') {
|
||||
@@ -459,6 +502,13 @@ export function install(battle) {
|
||||
if (has(ID.raid)) {
|
||||
const raid = states.filter((st) => st.tiers[ID.raid] && st.members[ID.raid].size);
|
||||
if (raid.length) {
|
||||
battle.on('deploy', (c) => {
|
||||
const u = c.unit;
|
||||
// Only a revive: the previous exit was a knock-out and the operator is back before its respawn timer.
|
||||
// A retreat (宴 / 史尔特尔 / 伊内丝 / 耀骑士临光), a move, a timer redeploy and the initial deploy stay on the old poll.
|
||||
if (!u || c.initial || c.move || u.removeReason !== 'killed' || !(u.respawnAt > battle.time + 1e-9)) return;
|
||||
if (raid.some((st) => st.members[ID.raid].has(u))) u.mem.raidReviveSeq = u.deploySeq;
|
||||
});
|
||||
battle.every(RAID_POLL, () => { for (const st of raid) raidPoll(battle, st); });
|
||||
}
|
||||
}
|
||||
@@ -542,7 +592,8 @@ export function install(battle) {
|
||||
if (stead) {
|
||||
battle.on('hit', (c) => {
|
||||
const t = c.target, dmg = c.dmg;
|
||||
if (!t || t.side !== 'ally' || t.kind !== 'op' || !dmg || dmg.cancel || dmg.steadShare || dmg.steadCut || dmg.type === 'element') return;
|
||||
if (!t || t.side !== 'ally' || t.kind !== 'op' || !dmg || dmg.cancel || dmg.steadShare || dmg.steadCut
|
||||
|| dmg.tags?.includes('bond:stead:share') || dmg.type === 'element') return;
|
||||
const st = byPid[t.ownerId];
|
||||
if (!st || (st.tiers[ID.stead] ?? 0) < 2 || st.members[ID.stead].has(t)) return;
|
||||
let any = false;
|
||||
@@ -550,7 +601,8 @@ export function install(battle) {
|
||||
if (!any) return;
|
||||
const ratio = Math.max(0, Math.min(1, num(st.bb[ID.stead].damage_resistance)));
|
||||
if (!(ratio > 0) || ratio >= 1) return;
|
||||
dmg.mul *= 1 - ratio;
|
||||
// Both redirects take their share from the pre-redirect hit (applyRedirectCut). The victim keeps the rest.
|
||||
applyRedirectCut(dmg, ratio);
|
||||
dmg.steadCut = ratio;
|
||||
}, { priority: -20 });
|
||||
}
|
||||
@@ -565,14 +617,16 @@ export function install(battle) {
|
||||
if (dmg.steadCut) {
|
||||
const ratio = dmg.steadCut;
|
||||
dmg.steadCut = 0;
|
||||
const share = (num(c.amount) / (1 - ratio)) * ratio;
|
||||
const share = redirectShare(num(c.amount), ratio, dmg.redirectCuts);
|
||||
if (!(share > 0)) return;
|
||||
const on = [];
|
||||
for (const m of st.members[ID.stead]) if (onField(m)) on.push(m);
|
||||
// the share is already mitigated and already carries the attacker's damage multipliers: deal it sourceless
|
||||
// (the member's own damage-taken modifiers and shields still apply), never re-scaled by the attacker
|
||||
for (const m of on) {
|
||||
const d = battle.makeDamage({ amount: share / on.length, type: 'true', canDodge: false, tags: ['bond:stead:share'] });
|
||||
// Preserve sharing ancestry across Skadi's redirect, so neither sharing effect revisits its own output.
|
||||
const d = battle.makeDamage({ amount: share / on.length, type: 'true', canDodge: false,
|
||||
tags: ['bond:stead:share', ...(dmg.tags?.includes('transfer') ? ['transfer'] : [])] });
|
||||
d.steadShare = true;
|
||||
battle.dealDamage(null, m, d);
|
||||
}
|
||||
|
||||
@@ -76,6 +76,12 @@ function sameRowCount(ctx, me) {
|
||||
}
|
||||
function rowCondition(ctx, me) {
|
||||
const { bb, bbStr } = ctx.source;
|
||||
// Maintainer 2026-10-10, #473's official-game recording: Ptilopsis's copied Gladiia / Yu prep-start gifts skip
|
||||
// the three-in-a-row gate. This is not a general change to copying or to either original operator's condition.
|
||||
const from = ctx.source.copiedFrom;
|
||||
if (from && ctx.source.garrison?.eventType === 'SERVER_PREP_START'
|
||||
&& ctx.chessRecord(me?.id)?.charId === 'char_128_plosis'
|
||||
&& ['char_474_glady', 'char_2026_yu'].includes(ctx.chessRecord(from.id)?.charId)) return true;
|
||||
if (bbStr.conditionkey === 'character_same_row') return sameRowCount(ctx, me) >= num(bb.check_count, 0);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -810,30 +810,30 @@ const BY_ITEM = {
|
||||
const partner = partnerOf(p);
|
||||
const key = S.key('stealth');
|
||||
const per = num(p.atk_per_sec), max = num(p.max_atk, 1);
|
||||
const st = { bonus: 0, wasStealth: false, armed: false, seq: -1 };
|
||||
const st = { bonus: 0, seq: -1 };
|
||||
const TICKS = 0.25;
|
||||
S.every(TICKS, () => {
|
||||
if (u.deploySeq !== st.seq) { st.seq = u.deploySeq; st.bonus = 0; st.armed = false; st.wasStealth = false; }
|
||||
if (u.deploySeq !== st.seq) { st.seq = u.deploySeq; st.bonus = 0; }
|
||||
if (!onField(u) || !memberOf(battle, u, 'siracusaShip')) return;
|
||||
const stealth = !!u.s.flags.stealth;
|
||||
if (stealth && st.bonus < max) {
|
||||
st.bonus = Math.min(max, st.bonus + per * TICKS);
|
||||
S.buff(u, { key, mods: directMods({ atk: st.bonus }), refresh: 'replace' });
|
||||
}
|
||||
if (st.wasStealth && !stealth && st.bonus > 0) st.armed = true;
|
||||
st.wasStealth = stealth;
|
||||
});
|
||||
S.on('damaged', (c) => {
|
||||
if (c.source !== u || !st.armed || isProc(c.dmg) || !(c.amount > 0)) return;
|
||||
st.armed = false;
|
||||
// A hit may follow stealth ending before the next growth poll. Decide at the hit, and consume once before
|
||||
// the combo deals synchronous damage; the ATK buff still supplies that combo's attack value.
|
||||
if (c.source !== u || isProc(c.dmg) || !(c.amount > 0) || st.bonus <= 0
|
||||
|| u.deploySeq !== st.seq || !onField(u) || u.s.flags.stealth) return;
|
||||
st.bonus = 0;
|
||||
if (partner && carries(battle, u, partner) && c.target && c.target.alive && c.target.side === 'enemy') {
|
||||
battle.dealDamage(u, c.target, { amount: u.s.atk * num(p.atk_scale), type: 'true', canDodge: false, tags: procTags('crest') });
|
||||
battle.fx('strike', { x: c.target.x, y: c.target.y, id: c.target.id, src: 'item:chess_item_6_11_e', key: rec.id, from: u.id });
|
||||
}
|
||||
st.bonus = 0;
|
||||
battle.removeBuff(u, key);
|
||||
});
|
||||
S.on('death', (c) => { if (c.unit === u) { st.bonus = 0; st.armed = false; } });
|
||||
S.on('death', (c) => { if (c.unit === u) st.bonus = 0; });
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -15,8 +15,10 @@ export default {
|
||||
const kit = {
|
||||
skill: {
|
||||
kind: 'duration',
|
||||
onStart({ battle, unit, skill }) {
|
||||
const atk = steps[Math.min(steps.length - 1, Math.max(0, skill.activations - 1))];
|
||||
onStart({ battle, unit }) {
|
||||
const uses = unit.mem.pinecnS2Uses ?? 0;
|
||||
unit.mem.pinecnS2Uses = uses + 1;
|
||||
const atk = steps[Math.min(steps.length - 1, uses)];
|
||||
battle.addBuff(unit, { key: 'skill:pinecn_atk', mods: { atkPct: atk } });
|
||||
},
|
||||
onEnd({ battle, unit }) { battle.removeBuff(unit, 'skill:pinecn_atk'); },
|
||||
@@ -33,7 +35,10 @@ export default {
|
||||
}),
|
||||
talents: [{ install(battle, unit) {
|
||||
battle.on('deploy', (ctx) => {
|
||||
if (ctx.unit === unit) battle.addBuff(unit, { key: 'talent:pinecn_power', duration: num(t0.duration, 60), mods: { spRecoveryFlat: num(t0.sp_recovery_per_sec) } });
|
||||
if (ctx.unit !== unit) return;
|
||||
// PRTS 松果: count skill uses since this deployment; the runtime's activation identity stays cumulative.
|
||||
unit.mem.pinecnS2Uses = 0;
|
||||
battle.addBuff(unit, { key: 'talent:pinecn_power', duration: num(t0.duration, 60), mods: { spRecoveryFlat: num(t0.sp_recovery_per_sec) } });
|
||||
}, { owner: unit });
|
||||
} }],
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// Conventions of the tier-6 kits: ../shared/tier6.js; kit contract and rules: ../README.md.
|
||||
|
||||
import { aggregateMods } from '../../../buffs.js';
|
||||
import { applyRedirectCut, redirectShare } from '../../../damage.js';
|
||||
import { COLS } from '../../../constants.js';
|
||||
import { bardRegen } from '../../../professions.js';
|
||||
import { startCountdown } from '../../tokens.js';
|
||||
@@ -123,12 +124,15 @@ function skadi2(bb, chess, def) {
|
||||
if (!(share > 0) || !t || t === unit || t.side !== 'ally' || t.kind === 'device' || !live(unit) || !unit.skill?.active) return;
|
||||
if (d.type === 'element' || d.type === 'elemental' || d.skadiShare || d.tags?.includes('transfer') || !inCover(battle, unit, t)) return;
|
||||
d.skadiShare = { share, tag: TAG }; // 同类效果取最高: one transfer per damage instance
|
||||
d.mul *= 1 - share;
|
||||
applyRedirectCut(d, share); // her share of the pre-redirect hit; the victim keeps the rest
|
||||
}, { owner: unit, priority: -20 });
|
||||
battle.on('damaged', (ctx) => {
|
||||
const x = ctx.dmg?.skadiShare;
|
||||
if (!x || x.tag !== TAG || !(ctx.amount > 0) || !live(unit)) return;
|
||||
battle.dealDamage(ctx.source, unit, { amount: ctx.amount * x.share / Math.max(1e-6, 1 - x.share), type: 'true', canDodge: false, tags: ['transfer'] });
|
||||
// Keep the earlier 坚守 redirect's marker. Rebuilding damage without it lets the same two effects bounce
|
||||
// the hit forever; each existing sharing effect still gets its first application along this path.
|
||||
battle.dealDamage(ctx.source, unit, { amount: redirectShare(ctx.amount, x.share, ctx.dmg.redirectCuts), type: 'true', canDodge: false,
|
||||
tags: ['transfer', ...(ctx.dmg.tags?.includes('bond:stead:share') ? ['bond:stead:share'] : [])] });
|
||||
}, { owner: unit });
|
||||
}
|
||||
// module 新生代: "自身技能期间,攻击范围内的友军获得30点物理与法术伤害减免" (flat reduction per hit)
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
// 克莱门莎 (char_4231_clemnt), 本源近卫. Official CN tables 2026-10-08 (client 2.7.81),
|
||||
// PRTS 克莱门莎, 2026-10-09: all skills at ranks 4/7 and both potential-sensitive talents.
|
||||
// https://prts.wiki/w/克莱门莎 . No module was released with her.
|
||||
// PRTS still marks several launch-day details unknown: [ASSUMED] S2's ordinary splash radius 1.5;
|
||||
// carriage's unresolved 放逐 combination = cannot walk/attack/be blocked, but remains damageable;
|
||||
// PRTS still marks several launch-day details unknown: [ASSUMED] carriage's unresolved 放逐 combination =
|
||||
// cannot walk/attack/be blocked, but remains damageable;
|
||||
// its boundary clearance 0.4, carriage updates once per sim tick, vortex ends with the skill;
|
||||
// S3 first bombardment at 0.5 s, then twice at 1 s intervals, launched shots survive skill end.
|
||||
// These pending confirmations are listed in DESIGN §28.1; keep each assumption local to this kit.
|
||||
|
||||
import { num, talentBb, skillRec, up } from '../shared/tier1.js';
|
||||
import { canTargetEnemy } from '../../../targeting.js';
|
||||
import { absoluteRangeKeys, canTargetEnemy } from '../../../targeting.js';
|
||||
import { bodyInKeys, hitRect } from '../../../body.js';
|
||||
import { dirVec } from '../../../dir.js';
|
||||
import { COLS } from '../../../constants.js';
|
||||
@@ -18,7 +18,7 @@ const S1 = 'skchr_clemnt_1', S2 = 'skchr_clemnt_2', S3 = 'skchr_clemnt_3';
|
||||
const GROUND = Object.freeze({ canHitFly: false, groundOnly: true });
|
||||
const AIR = Object.freeze({ canHitFly: true });
|
||||
const SPEED = 2, DISTANCE = 3, CATCH_HALF = 0.55, VORTEX_RADIUS = 1.5;
|
||||
const SPLASH_RADIUS = 1.5; // [ASSUMED] PRTS has not published S2's normal attack splash radius.
|
||||
const SPLASH_RADIUS = 1.1; // PRTS 2026-10-10: collision test; four secondary targets take 100% ATK.
|
||||
const FIRST_BOMB = 0.5; // [ASSUMED] Neither PRTS nor the edited official demo settles the first landing offset.
|
||||
const CAST = 'clemnt:cast', CARRY = 'clemnt:carry:';
|
||||
const skillBb = (raw, id) => skillRec(raw, id)?.bb ?? {};
|
||||
@@ -159,11 +159,11 @@ export default {
|
||||
if (kind !== 'main' || !target) return;
|
||||
// Main victim plus at most four neighbours, never every unit in the radius.
|
||||
// [ASSUMED] Ties among splash victims follow the engine's stable spawn order.
|
||||
const near = battle.foesInRadius(target.x, target.y, SPLASH_RADIUS, true)
|
||||
const near = battle.foesInRadius(target.x, target.y, SPLASH_RADIUS)
|
||||
.filter((e) => e !== target && canTargetEnemy(unit, e, GROUND))
|
||||
.slice(0, Math.max(0, num(b2['attack@max_target'], 5) - 1));
|
||||
for (const e of near) battle.dealDamage(unit, e, {
|
||||
amount: unit.s.atk * unit.s.atkScaleMul * num(b2['attack@aoe_atk_scale'], 1), type: 'phys',
|
||||
amount: unit.s.atk * unit.s.atkScaleMul, type: 'phys',
|
||||
isAttack: true, isSplash: true, isSkill: true, attackId, tags: ['skill', 'clemnt:splash'],
|
||||
});
|
||||
} },
|
||||
@@ -212,6 +212,15 @@ export default {
|
||||
} },
|
||||
],
|
||||
install(battle, unit) {
|
||||
if (unit.skill?.id === S2) {
|
||||
// Maintainer 2026-10-10 (#467): follow Chen S3 automation. The vortex hits air while normal attacks do not.
|
||||
// [ASSUMED] No mode footage establishes a separate auto-cast shape for this new operator; use the expanded
|
||||
// skill attack grid with permanent extensions, retaining shared operation cooldown/control checks.
|
||||
unit.skill.addTriggerRange(() => [{
|
||||
keys: absoluteRangeKeys(skillRec(raw, S2)?.rangeGrid, unit.tileR, unit.tileC, unit.dir, unit.s.baseRangeExtend || 0),
|
||||
profile: AIR,
|
||||
}]);
|
||||
}
|
||||
battle.on('elementBurst', ({ target, element }) => {
|
||||
if (element !== 'erosion' || !up(unit) || !unit.skill?.active || unit.skill.id !== S3
|
||||
|| !canTargetEnemy(unit, target, GROUND)) return;
|
||||
|
||||
@@ -1046,7 +1046,9 @@ function manifold(bb, raw, def) {
|
||||
* and a medic healed a hurt one.
|
||||
*/
|
||||
export function champagneHold(battle, bomb) {
|
||||
battle.addBuff(bomb, { key: 'token:champagneHold', flags: { noHeal: true, healFree: true }, persist: true, allowDead: true });
|
||||
// PRTS 香槟炸弹: entity type 装置, not selected by ordinary auras/talents. The internal selection barrier has no
|
||||
// visible status icon; it does not change the owner's explosion or the established HP protection (#476).
|
||||
battle.addBuff(bomb, { key: 'token:champagneHold', flags: { noHeal: true, healFree: true, isolated: true }, persist: true, allowDead: true });
|
||||
battle.on('hit', (ctx) => { if (ctx.target === bomb) ctx.dmg.cancel = true; }, { owner: bomb, priority: 1000 });
|
||||
battle.on('elementHit', (ctx) => { if (ctx.target === bomb) ctx.dmg.cancel = true; }, { owner: bomb, priority: 1000 });
|
||||
battle.on('damaged', (ctx) => { if (ctx.target === bomb && bomb.alive) bomb.hp = bomb.s.maxHp; }, { owner: bomb, priority: 1000 });
|
||||
|
||||
@@ -122,6 +122,48 @@ export function isHpLoss(dmg) {
|
||||
return !!dmg && Array.isArray(dmg.tags) && dmg.tags.includes('hpLoss');
|
||||
}
|
||||
|
||||
/**
|
||||
* Record one redirect ratio of the pre-redirect hit and leave the victim the rest.
|
||||
* Each recorded ratio is a share of that hit; the victim keeps `1 − sum`. Two redirects therefore
|
||||
* add up with the part the victim still takes. [ASSUMED] 浊心斯卡蒂 S1 and 坚守 do not say how they
|
||||
* combine; multiplying the two remainders drops the product of the ratios. A sum of 1 or more leaves
|
||||
* the victim with none of this hit (the same loss a single 100 % redirect already has, because the
|
||||
* HP that arrives is 0 and no share is dealt).
|
||||
* @param {object} dmg normalised DamageInfo
|
||||
* @param {number} ratio
|
||||
*/
|
||||
export function applyRedirectCut(dmg, ratio) {
|
||||
const r = ratio < 0 ? 0 : ratio > 1 ? 1 : Number(ratio) || 0;
|
||||
if (!(r > 0) || !dmg) return 0;
|
||||
const prev = Array.isArray(dmg.redirectCuts) ? dmg.redirectCuts : (dmg.redirectCuts = []);
|
||||
let prevSum = 0;
|
||||
for (const x of prev) prevSum += x;
|
||||
const prevKeep = prevSum >= 1 ? 0 : 1 - prevSum;
|
||||
prev.push(r);
|
||||
const nextSum = prevSum + r;
|
||||
const nextKeep = nextSum >= 1 ? 0 : 1 - nextSum;
|
||||
if (prevKeep > 1e-12) dmg.mul *= nextKeep / prevKeep;
|
||||
else dmg.mul *= 0;
|
||||
return r;
|
||||
}
|
||||
|
||||
/**
|
||||
* This redirect's share of the HP that reached the victim, taken from the pre-redirect amount.
|
||||
* @param {number} amount HP removed from the victim after every redirect cut
|
||||
* @param {number} own this redirect's ratio
|
||||
* @param {number[]|undefined} cuts every ratio recorded on the hit
|
||||
*/
|
||||
export function redirectShare(amount, own, cuts) {
|
||||
const list = Array.isArray(cuts) ? cuts : [];
|
||||
let sum = 0;
|
||||
for (const x of list) sum += x;
|
||||
const o = own > 0 ? own : 0;
|
||||
if (!(amount > 0) || !(o > 0) || !(sum > 0)) return 0;
|
||||
const keep = 1 - sum;
|
||||
if (keep > 1e-12) return amount / keep * o;
|
||||
return amount * (o / sum);
|
||||
}
|
||||
|
||||
/** 沉睡 (ba.sleep "无敌且无法行动"): only attackers whose profile has `hitSleep` (or `ignoreSleep` damage) reach a sleeper. */
|
||||
function sleepBlocks(target, source, dmg) {
|
||||
return !!target.s.flags.sleep && !dmg.ignoreSleep && !(source && source.profile && source.profile.hitSleep);
|
||||
|
||||
@@ -489,7 +489,7 @@ export class SkillRuntime {
|
||||
if (b.rangeChanged(u)) b._refreshRange(u);
|
||||
const keys = range || u.baseRangeKeys || u.rangeKeys;
|
||||
if (keys) {
|
||||
if (this.healSkill) return b.injuredAlliesInKeys(keys, u).length > 0
|
||||
if (this.healSkill) return b.injuredAlliesInKeys(keys, u, !!u.profile?.heal?.elementHealRatio).length > 0
|
||||
|| (this.triggerEnemies && b.enemiesInKeys(keys, u, TRIGGER_PROFILE).length > 0);
|
||||
if (b.enemiesInKeys(keys, u, u.profile).length > 0 || this._allyTargetIn(keys)) return true;
|
||||
}
|
||||
|
||||
@@ -142,6 +142,13 @@ export function ammoView(u) {
|
||||
return [left, Math.max(1, Math.ceil((sk.ammoMax || sk.ammo || 0) - 1e-9), left)];
|
||||
}
|
||||
|
||||
/** 琳琅诗怀雅's skill purse: zero is a balance; no purse is null. Display only. */
|
||||
export function coinView(u) {
|
||||
const n = u.mem?.coins, max = u.skill?.bb?.sp;
|
||||
if (u.side !== 'ally' || !Number.isSafeInteger(n) || n < 0 || !Number.isSafeInteger(max) || max < 1) return null;
|
||||
return [Math.min(n, max), max];
|
||||
}
|
||||
|
||||
/**
|
||||
* `wolfView(u)` = `[狼影 left, the talent's maximum]` of 伺夜's 狼群 pack (content/tokens.js wolfPack and the managed kit
|
||||
* chess_char_3_19-vigil.js keep the count in `mem.shadows` and the maximum in `mem.wolfCapacity`): the pips under the HP
|
||||
|
||||
+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.3';
|
||||
export const APP_VERSION = '0.2.4-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);
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
// Placement ranges that are independent of the summoner's attack range. Jessica's T1 in backups.json and PRTS
|
||||
// 涤火杰西卡 / 天赋 says her shield may only stand on the four tiles next to her; op-jesca2.js already treats this as
|
||||
// a prep placement rule. This symmetric grid uses the same rotation/translation helpers as owner-range summons.
|
||||
const ADJACENT = Object.freeze([[0, 1], [1, 0], [0, -1], [-1, 0]].map((p) => Object.freeze(p)));
|
||||
|
||||
/** @returns {ReadonlyArray<ReadonlyArray<number>> | null} A fixed placement grid, or null to use the token's flags. */
|
||||
export function summonPlacementGrid(tokenId) {
|
||||
return tokenId === 'token_10032_jesca2_jckshd' ? ADJACENT : null;
|
||||
}
|
||||
@@ -406,6 +406,9 @@ test('机密商店 E2E (co-op): two identical cards are two cards — both can b
|
||||
// six 变形同构体: every slot draws the one item `coin` names
|
||||
const one = Object.values(DATA.items).find((i) => i.name === '变形同构体' && !i.isGolden).id;
|
||||
const data = dataWith('mode_multi_hard', 3, 'shop');
|
||||
// This case isolates card-index ownership with sufficient stock; shared-stock.test.js covers exhaustion.
|
||||
data.config = { ...data.config, economy: { ...data.config.economy,
|
||||
itemPoolCopiesOverrides: { ...data.config.economy.itemPoolCopiesOverrides, [one]: 8 } } };
|
||||
data.choices = { ...data.choices, shopDraft: { ...data.choices.shopDraft, rounds: [3], slots: Array.from({ length: 6 }, () => ({ coin: 1 })), coin: one } };
|
||||
const h = makeMatch({ mode: 'coop', difficulty: 'HARD', humans: 2, bots: 2, seed: 43, data, registry: REG, fake: true });
|
||||
const m = h.m;
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { makeBattle, chessRec, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
|
||||
|
||||
const done = (h) => { checkInvariants(h.b); assert.deepEqual(h.b.errors, []); };
|
||||
test('six Kazimierz stuns a nearby hovering enemy while blocking ground enemies (#463)', () => {
|
||||
const h = makeBattle({ autoFinish: false, hooks: ['status'],
|
||||
defs: { chess: { wall: chessRec({ id: 'wall', bonds: ['kazimierzShip'], skill: null, stats: { maxHp: 1e7, atk: 10, block: 3 } }) },
|
||||
enemies: { dummy: enemyRec({ key: 'dummy', hp: 1e9, atk: 0, speed: 0 }) } },
|
||||
bonds: { kazimierzShip: { count: 6, active: true, tier: 3, layers: 1 } },
|
||||
units: [{ uid: 1, chessId: 'wall', row: 10, col: 4 }] });
|
||||
h.step(); const u = h.unit(1);
|
||||
const ground = h.spawn('dummy', { pos: [10, 4], routeIndex: 0 });
|
||||
const near = h.spawn('enemy_2025_syufo', { pos: [10, 4.5], routeIndex: 0 });
|
||||
const far = h.spawn('enemy_2025_syufo', { pos: [10, 7], routeIndex: 0 });
|
||||
for (const e of [near, far]) h.b.addBuff(e, { key: 'test:stationary', flags: { noMove: true, disarm: true } });
|
||||
assert.ok(near.s.flags.unblockable);
|
||||
h.step(2); assert.equal(ground.blockedBy, u);
|
||||
assert.ok(h.runUntil(() => near.s.flags.stun, 4), 'the pulse can stun hovering targets');
|
||||
assert.ok(!near.s.flags.unblockable, 'hovering ends while stunned');
|
||||
assert.ok(!far.s.flags.stun, '周围 is the official 0.8 radius, not the whole field');
|
||||
done(h);
|
||||
});
|
||||
test('a Raid revive inside the tick hook stays on the enemy tile instead of the taunt', () => {
|
||||
const h = makeBattle({ autoFinish: false,
|
||||
defs: { enemies: { dummy: enemyRec({ key: 'dummy', hp: 1e9, atk: 0, speed: 0 }) } },
|
||||
bonds: { raidShip: { count: 2, active: true, tier: 1, layers: 5 } },
|
||||
units: [{ uid: 1, chessId: 'chess_char_4_16_a', skillIndex: 1, row: 10, col: 4 }] });
|
||||
h.step(); const u = h.unit(1);
|
||||
const local = h.spawn('dummy', { pos: [10, 4], routeIndex: 0 });
|
||||
const far = h.spawn('dummy', { pos: [10, 8], routeIndex: 0 });
|
||||
h.b.addBuff(far, { key: 'test:taunt', mods: { taunt: 10 } });
|
||||
let did = false;
|
||||
h.b.on('tick', () => {
|
||||
if (did || h.b.time < 7 / 30 - 1e-9 || u.blocking.length < 1) return;
|
||||
did = true;
|
||||
h.b.kill(u, null);
|
||||
assert.ok(h.b.redeploy(u, { free: true }));
|
||||
assert.equal(u.blocking.length, 0, 'redeploy clears the block list before the next tick');
|
||||
});
|
||||
h.step(8);
|
||||
assert.equal(did, true);
|
||||
h.step(1);
|
||||
assert.deepEqual([u.tileR, u.tileC], [10, 4], 'the enemy is still on this tile when the revive poll runs');
|
||||
assert.equal(local.blockedBy, u);
|
||||
done(h);
|
||||
});
|
||||
test('an ordinary Raid poll prefers the nearer gate over a farther taunt', () => {
|
||||
const probe = chessRec({ id: 'probe', position: 'MELEE', bonds: ['raidShip'], rangeGrid: [[0, 0]], skill: null, stats: { maxHp: 5000, atk: 10, block: 2, def: 0 } });
|
||||
const h = makeBattle({ autoFinish: false,
|
||||
defs: { chess: { probe }, enemies: { dummy: enemyRec({ key: 'dummy', hp: 1e9, atk: 0, speed: 0 }) } },
|
||||
bonds: { raidShip: { count: 2, active: true, tier: 1, layers: 5 } },
|
||||
units: [{ uid: 1, chessId: 'probe', row: 10, col: 5 }] });
|
||||
h.step(); const u = h.unit(1);
|
||||
const near = h.spawn('dummy', { pos: [9, 3], routeIndex: 0 });
|
||||
const far = h.spawn('dummy', { pos: [9, 8], routeIndex: 0 });
|
||||
h.b.addBuff(far, { key: 'test:taunt', mods: { taunt: 10 } });
|
||||
assert.ok(h.b.remainingDistance(near) < h.b.remainingDistance(far));
|
||||
h.run(10.5);
|
||||
assert.ok(h.b.enemiesInKeys(u.rangeKeys, u, u.profile).includes(near), 'ordinary order is remaining distance, then id');
|
||||
assert.ok(!h.b.enemiesInKeys(u.rangeKeys, u, u.profile).includes(far));
|
||||
done(h);
|
||||
});
|
||||
test('a retreat redeploy does not arm the Raid revive reselect', () => {
|
||||
const wide = chessRec({ id: 'wide', position: 'RANGED', bonds: ['raidShip'], rangeGrid: [[0, 0], [0, 1], [0, -1]], skill: null, stats: { maxHp: 5000, atk: 10, block: 0 } });
|
||||
const h = makeBattle({ autoFinish: false,
|
||||
defs: { chess: { wide }, enemies: { dummy: enemyRec({ key: 'dummy', hp: 1e9, atk: 0, speed: 0 }) } },
|
||||
bonds: { raidShip: { count: 2, active: true, tier: 1, layers: 5 } },
|
||||
units: [{ uid: 1, chessId: 'wide', row: 10, col: 5 }] });
|
||||
h.step(); const u = h.unit(1);
|
||||
const e = h.spawn('dummy', { pos: [10, 6], routeIndex: 0 });
|
||||
h.run(0.4);
|
||||
assert.ok(h.b.enemiesInKeys(u.rangeKeys, u, u.profile).includes(e));
|
||||
const tile = [u.tileR, u.tileC];
|
||||
h.b.retreat(u, { reason: 'retreat' });
|
||||
assert.ok(h.b.redeploy(u, { free: true }));
|
||||
const seq = u.deploySeq;
|
||||
h.run(0.3);
|
||||
assert.deepEqual([u.tileR, u.tileC], tile, 'an enemy already in range keeps the old poll from jumping');
|
||||
assert.equal(u.deploySeq, seq);
|
||||
done(h);
|
||||
});
|
||||
test('a revived Raid operator that has already blocked an enemy stays in place', () => {
|
||||
const h = makeBattle({ autoFinish: false,
|
||||
defs: { enemies: { dummy: enemyRec({ key: 'dummy', hp: 1e9, atk: 0, speed: 0 }) } },
|
||||
bonds: { raidShip: { count: 2, active: true, tier: 1, layers: 5 } },
|
||||
units: [{ uid: 1, chessId: 'chess_char_4_16_a', skillIndex: 1, row: 10, col: 4 }] });
|
||||
h.step(); const u = h.unit(1);
|
||||
const local = h.spawn('dummy', { pos: [10, 4], routeIndex: 0 });
|
||||
const far = h.spawn('dummy', { pos: [10, 8], routeIndex: 0 });
|
||||
h.b.addBuff(far, { key: 'test:taunt', mods: { taunt: 10 } });
|
||||
h.b.kill(u, null); assert.ok(h.b.redeploy(u, { free: true }));
|
||||
h.step(2); assert.equal(local.blockedBy, u);
|
||||
h.run(0.3); assert.deepEqual([u.tileR, u.tileC], [10, 4]); done(h);
|
||||
});
|
||||
for (const taunt of [false, true]) test(`revived unblocked Texas S2 reselects a Raid target by taunt then remaining route (${taunt})`, () => {
|
||||
const h = makeBattle({ autoFinish: false, hooks: ['deploy'],
|
||||
defs: { enemies: { dummy: enemyRec({ key: 'dummy', hp: 1e9, atk: 0, speed: 0 }) } },
|
||||
bonds: { raidShip: { count: 2, active: true, tier: 1, layers: 5 } },
|
||||
units: [{ uid: 1, chessId: 'chess_char_4_16_a', skillIndex: 1, row: 10, col: 4 }] });
|
||||
h.step(); const u = h.unit(1);
|
||||
h.spawn('dummy', { pos: [10, 5.2], routeIndex: 0 });
|
||||
const nearGate = h.spawn('dummy', { pos: [9, 3], routeIndex: 0 });
|
||||
const far = h.spawn('dummy', { pos: [10, 8], routeIndex: 0 });
|
||||
if (taunt) h.b.addBuff(far, { key: 'test:taunt', mods: { taunt: 10 } });
|
||||
h.run(0.3); assert.deepEqual([u.tileR, u.tileC], [10, 4], 'initial deployment still uses the ordinary range gate');
|
||||
h.b.kill(u, null); assert.ok(h.b.redeploy(u, { free: true }));
|
||||
assert.equal(u.blocking.length, 0);
|
||||
h.run(0.3);
|
||||
const target = taunt ? far : nearGate;
|
||||
assert.ok(h.b.enemiesInKeys(u.rangeKeys, u, u.profile).includes(target), 'the higher-priority target is in the new range');
|
||||
assert.notDeepEqual([u.tileR, u.tileC], [10, 4]);
|
||||
const count = u.deploySeq; h.run(0.3); assert.equal(u.deploySeq, count, 'Raid itself cannot continuously rearm the revival check');
|
||||
done(h);
|
||||
});
|
||||
test('Skadi S1 and 坚守 sharing retain transfer ancestry and do not revisit either sharing effect', () => {
|
||||
const op = (id, bonds) => chessRec({ id, bonds, skill: null, stats: { maxHp: 1e7, atk: 0, def: 0 } });
|
||||
const h = makeBattle({ autoFinish: false, defs: { chess: {
|
||||
a: op('a', ['steadShip']), b: op('b', ['steadShip']), c: op('c', ['steadShip']), d: op('d', []),
|
||||
} }, bonds: { steadShip: { count: 3, active: true, tier: 2, layers: 10 } },
|
||||
units: [{ uid: 1, chessId: 'chess_char_6_04_a', row: 10, col: 4, skillIndex: 0 },
|
||||
{ chessId: 'a', row: 9, col: 4 }, { chessId: 'b', row: 11, col: 4 },
|
||||
{ chessId: 'c', row: 10, col: 3 }, { chessId: 'd', row: 10, col: 5 }] });
|
||||
h.step(); assert.ok(h.unit(1).skill.activate('test', { free: true }));
|
||||
let attempts = 0;
|
||||
// Fail fast on the broken implementation without letting the branching recursion freeze the test worker.
|
||||
h.b.on('hit', (c) => { if (++attempts > 64) c.dmg.cancel = true; }, { priority: 1000 });
|
||||
h.b.dealDamage(null, h.unit('d'), { amount: 1000, type: 'true' });
|
||||
assert.ok(attempts <= 12, `bounded transfer tree, got ${attempts} hits`);
|
||||
assert.ok(h.unit('d').hp < h.unit('d').s.maxHp); done(h);
|
||||
});
|
||||
for (const mode of ['both', 'stead', 'skadi']) test(`Skadi S1 and 坚守 conserve a 1000 true hit (${mode})`, () => {
|
||||
const op = (id, bonds) => chessRec({ id, bonds, skill: null, stats: { maxHp: 1e7, atk: 0, def: 0 } });
|
||||
const both = mode === 'both';
|
||||
const units = [];
|
||||
if (mode !== 'stead') units.push({ uid: 1, chessId: 'chess_char_6_04_a', row: 10, col: 4, skillIndex: 0 });
|
||||
units.push({ chessId: 'a', row: 9, col: 4 }, { chessId: 'b', row: 11, col: 4 }, { chessId: 'c', row: 10, col: 3 }, { chessId: 'd', row: 10, col: 5 });
|
||||
const h = makeBattle({ autoFinish: false, defs: { chess: {
|
||||
a: op('a', ['steadShip']), b: op('b', ['steadShip']), c: op('c', ['steadShip']), d: op('d', []),
|
||||
} }, bonds: mode === 'skadi' ? {} : { steadShip: { count: 3, active: true, tier: 2, layers: 10 } }, units });
|
||||
h.step();
|
||||
if (mode !== 'stead') assert.ok(h.unit(1).skill.activate('test', { free: true }));
|
||||
let attempts = 0;
|
||||
h.b.on('hit', (c) => { if (++attempts > 64) c.dmg.cancel = true; }, { priority: 1000 });
|
||||
const loss = (id) => { const u = h.unit(id); return u.s.maxHp - u.hp; };
|
||||
h.b.dealDamage(null, h.unit('d'), { amount: 1000, type: 'true' });
|
||||
const parts = [loss('d'), loss('a'), loss('b'), loss('c')];
|
||||
if (mode !== 'stead') parts.push(loss(1));
|
||||
const sum = parts.reduce((s, n) => s + n, 0);
|
||||
const near = (got, want) => Math.abs(got - want) < 1e-3;
|
||||
assert.ok(near(sum, 1000), `total ${sum}`);
|
||||
if (both) {
|
||||
assert.ok(near(loss('d'), 100), `victim ${loss('d')}`);
|
||||
for (const id of ['a', 'b', 'c']) assert.ok(near(loss(id), 400 / 3), `${id} ${loss(id)}`);
|
||||
assert.ok(near(loss(1), 500), `skadi ${loss(1)}`);
|
||||
assert.ok(attempts <= 12, `bounded transfer tree, got ${attempts} hits`);
|
||||
const before = attempts;
|
||||
h.b.dealDamage(null, h.unit('d'), { amount: 1000, type: 'true' });
|
||||
assert.ok(attempts - before <= 12, `second hit stayed bounded, got ${attempts - before}`);
|
||||
assert.ok(near(loss('d'), 200) && near(loss(1), 1000), 'the second hit conserves the same split');
|
||||
} else if (mode === 'stead') {
|
||||
assert.ok(near(loss('d'), 600), `victim ${loss('d')}`);
|
||||
for (const id of ['a', 'b', 'c']) assert.ok(near(loss(id), 400 / 3), `${id} ${loss(id)}`);
|
||||
assert.ok(attempts <= 4, `stead-only hits ${attempts}`);
|
||||
} else {
|
||||
assert.ok(near(loss('d'), 500) && near(loss(1), 500));
|
||||
assert.ok(near(loss('a'), 0) && near(loss('b'), 0) && near(loss('c'), 0));
|
||||
assert.ok(attempts <= 2, `skadi-only hits ${attempts}`);
|
||||
}
|
||||
done(h);
|
||||
});
|
||||
for (const combo of [false, true]) test(`家族徽章 consumes its bonus on the first damage immediately after stealth ends (combo=${combo})`, () => {
|
||||
const op = chessRec({ id: 'op', bonds: ['siracusaShip'], skill: null, rangeGrid: [[0, 0]], stats: { atk: 1000 } });
|
||||
const h = makeBattle({ autoFinish: false, defs: { chess: { op }, enemies: { dummy: enemyRec({ key: 'dummy', hp: 1e9, atk: 0, speed: 0 }) } },
|
||||
units: [{ uid: 1, chessId: 'op', row: 10, col: 4, items: ['chess_item_6_11_e_b', ...(combo ? ['chess_item_3_01_e_a'] : [])] }] });
|
||||
h.step(); const u = h.unit(1), base = u.s.atk;
|
||||
h.b.addBuff(u, { key: 'test:hidden', flags: { stealth: true } }); h.run(2);
|
||||
const boosted = u.s.atk; assert.ok(boosted > base);
|
||||
h.b.removeBuff(u, 'test:hidden'); const e = h.spawn('dummy', { pos: [10, 9] }); const hp = e.hp;
|
||||
h.b.dealDamage(u, e, { amount: 100, type: 'true', isAttack: true });
|
||||
assert.equal(u.s.atk, base, 'no waiting for the next 0.25-second growth poll');
|
||||
assert.ok(Math.abs(hp - e.hp - (100 + (combo ? boosted * 8 : 0))) < 1e-6);
|
||||
const hp2 = e.hp; h.b.dealDamage(u, e, { amount: 100, type: 'true', isAttack: true });
|
||||
assert.equal(hp2 - e.hp, 100, 'bonus consumed once'); done(h);
|
||||
});
|
||||
for (const elite of [false, true]) test(`松果 S2 growth restarts on redeployment without resetting the lifetime activation sequence (${elite})`, () => {
|
||||
const id = `chess_char_3_10_${elite ? 'b' : 'a'}`;
|
||||
const h = makeBattle({ autoFinish: false, units: [{ uid: 1, chessId: id, skillIndex: 1, row: 10, col: 4 }] });
|
||||
h.step(); const u = h.unit(1), values = [];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
assert.ok(u.skill.activate('test', { free: true })); values.push(u.findBuff('skill:pinecn_atk').mods.atkPct); u.skill.end('test');
|
||||
}
|
||||
assert.ok(values[2] > values[0]); const activations = u.skill.activations;
|
||||
h.b.retreat(u, { reason: 'test' }); assert.ok(h.b.redeploy(u, { free: true }));
|
||||
assert.ok(u.skill.activate('test', { free: true }));
|
||||
assert.equal(u.findBuff('skill:pinecn_atk').mods.atkPct, values[0]);
|
||||
assert.equal(u.skill.activations, activations + 1); done(h);
|
||||
});
|
||||
|
||||
for (const elite of [false, true]) test(`哈洛德 S2 starts for an ally at full HP with only elemental injury (${elite})`, () => {
|
||||
const wall = chessRec({ id: 'wall', skill: null, stats: { maxHp: 1e6, atk: 0 } });
|
||||
const h = makeBattle({ autoFinish: false, defs: { chess: { wall } },
|
||||
units: [{ uid: 1, chessId: `chess_char_2_05_${elite ? 'b' : 'a'}`, skillIndex: 1, row: 10, col: 4 },
|
||||
{ uid: 2, chessId: 'wall', row: 10, col: 5 }] });
|
||||
h.step(); const u = h.unit(1), a = h.unit(2); assert.equal(u.skill.id, 'skchr_harold_2');
|
||||
u.skill.gainSp(999, 'init'); h.run(0.5); assert.equal(u.skill.activations, 0, 'no injury means no cast');
|
||||
h.b.dealDamage(null, a, { type: 'element', element: 'burn', amount: 600 }); assert.equal(a.hp, a.s.maxHp);
|
||||
assert.ok(h.runUntil(() => u.skill.activations > 0, 4)); assert.ok(a.elem.burn < 600); done(h);
|
||||
});
|
||||
@@ -10,6 +10,26 @@ import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.
|
||||
|
||||
const TRAP = 'token_10031_swire2_gdtrap';
|
||||
const SW = 'chess_char_3_04_a';
|
||||
for (const path of ['owner', 'token']) test(`香槟炸弹 (${path}) rejects ordinary allied auras and inspiration without isolating normal summons (#476)`, () => {
|
||||
const { h, bomb: ownBomb } = herBomb({ units: [
|
||||
{ uid: 2, chessId: 'chess_char_5_20_a', row: 10, col: 4, skillIndex: 0 },
|
||||
{ uid: 3, chessId: 'chess_char_6_04_a', row: 10, col: 5, skillIndex: 1 },
|
||||
] });
|
||||
const angelina = h.unit(2), skadi = h.unit(3);
|
||||
if (path === 'token') h.b.retreat(ownBomb, { reason: 'expired', permanent: true });
|
||||
const bomb = path === 'owner' ? ownBomb : h.b.spawnToken(h.unit(1), TRAP, 9, 6);
|
||||
const control = h.b.spawnToken(skadi, 'token_10017_skadi2_dedant', 9, 4);
|
||||
assert.ok(bomb && control);
|
||||
assert.ok(skadi.skill.activate('test', { free: true })); h.run(0.7);
|
||||
assert.equal(h.b.allySelectable(bomb, angelina), false);
|
||||
assert.equal(bomb.s.aspd, bomb.base.aspd);
|
||||
assert.equal(bomb.s.hpRegen, 0);
|
||||
assert.equal(bomb.findBuff('inspire'), null);
|
||||
assert.equal(bomb.findBuff('inspire:def'), null);
|
||||
assert.ok(h.b.allySelectable(control, angelina), 'a normal unhealable summon is still selectable');
|
||||
assert.ok(control.s.aspd > control.base.aspd, 'Angelina still affects the ordinary summon');
|
||||
assert.ok(bomb.alive && bomb.hp === bomb.s.maxHp); checkInvariants(h.b);
|
||||
});
|
||||
/** 琳琅诗怀雅 on (9,5) of a field whose only free x-6 tile is (9,6) — 活性源石 when `infected`; she throws one bomb there. */
|
||||
function herBomb({ infected = false, units = [], enemies = [] } = {}) {
|
||||
const row9 = infected ? '##ERRriRrrSrrrrrrrS##' : '##ERRrrRrrSrrrrrrrS##';
|
||||
|
||||
@@ -839,6 +839,26 @@ test('塞雷娅 99 / 白面鸮 86: the copier runs the front operator\'s prep-en
|
||||
assert.equal(w.ps.shop.freeRefreshes, 3);
|
||||
});
|
||||
|
||||
// Maintainer-confirmed replay of the linked official-game footage (#473): only Ptilopsis copying these two gifts.
|
||||
for (const targetId of ['chess_char_4_12_a', 'chess_char_6_03_a']) {
|
||||
for (const ownRow of [false, true]) for (const sourceRow of [false, true]) {
|
||||
test(`白面 copies ${targetId} gift without either row condition (own=${ownRow}, source=${sourceRow})`, () => {
|
||||
const s = setup(11);
|
||||
const white = give(s.m, s.ps, 'chess_char_4_21_a', 'board', [11, 4]); white.dir = 'DOWN';
|
||||
give(s.m, s.ps, targetId, 'board', [10, 4]);
|
||||
const fillers = plain(c => c.position === 'RANGED').slice(0, 4); let filler = 0;
|
||||
for (const [r, on] of [[11, ownRow], [10, sourceRow]]) if (on) {
|
||||
give(s.m, s.ps, fillers[filler++], 'board', [r, 3]);
|
||||
give(s.m, s.ps, fillers[filler++], 'board', [r, 5]);
|
||||
}
|
||||
s.ps.recompute(); const before = s.ps.round.gainedChess;
|
||||
s.roundStart();
|
||||
assert.equal(s.ps.round.gainedChess - before, 1 + (sourceRow ? 1 : 0), 'copier always gives one; original still checks its own row');
|
||||
s.h.invariants();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test('triggerGainEffects export (band 铃兰): re-runs 获得时 garrisons ×投资人 and returns the effects run', () => {
|
||||
const s = setup();
|
||||
const p = give(s.m, s.ps, 'chess_char_5_03_a', 'board', [10, 4]); // 烛煌 炎/维多利亚 +5
|
||||
|
||||
+12
-13
@@ -954,7 +954,7 @@ test('拟态物质: owning 2 copies gives the 3rd (→ elite); otherwise a rando
|
||||
cover(A('5_05'), B('5_05'));
|
||||
});
|
||||
|
||||
test('拟态物质 with 2 copies owned and none left in the pool gives nothing — never a same-bond operator; with copies left the 3rd still merges (GitHub #207)', () => {
|
||||
test('拟态物质 grants the third copy even when stock is empty; the gain still merges and occupies stock (measured pool rule)', () => {
|
||||
// the report: an elite 溯光星源 (3 of the Ⅵ阶's 5 copies) and 2 normal ones — the pool is empty, the item text's 否则
|
||||
// (a random same-bond operator) is only for fewer than 2 owned
|
||||
const cid = 'chess_char_6_16_a';
|
||||
@@ -966,18 +966,17 @@ test('拟态物质 with 2 copies owned and none left in the pool gives nothing
|
||||
const t = give(m, ps, cid, 'hand');
|
||||
give(m, ps, cid, 'hand');
|
||||
assert.equal(m.pool.left(cid), 0, 'elite 3 + 2 normal = the pool cap 5');
|
||||
const before = chessIn(ps);
|
||||
const got = spyGrants(ps);
|
||||
const warns = spyWarns(m);
|
||||
const it = giveItem(m, ps, item);
|
||||
assert.deepEqual(equip(it, t), OK, item);
|
||||
assert.deepEqual(got, [], `${item}: nothing granted`);
|
||||
assert.deepEqual(chessIn(ps), before, `${item}: no other operator, no second elite`);
|
||||
assert.deepEqual(got, [cid], `${item}: same operator granted despite empty stock`);
|
||||
assert.deepEqual(chessIn(ps), [elite, elite], `${item}: the third normal copy merges`);
|
||||
assert.ok(!ps.find(it.uid), `${item}: consumed`);
|
||||
// …and says why (GitHub #401, the owner's OK of 2026-10-09)
|
||||
assert.deepEqual(warns, [{ msgid: '{who}:卡池中已没有{name}', who: '拟态物质', name: '溯光星源' }], `${item}: the toast`);
|
||||
assert.deepEqual(warns, [], `${item}: a successful grant must not report an empty-pool failure`);
|
||||
assert.equal(m.pool.entries.get(cid).left, -1, 'the sixth owned copy still occupies the pool');
|
||||
}
|
||||
// 2 normal copies, no elite, the pool drained by other players: nothing either
|
||||
// 2 normal copies, no elite, the pool drained by other players: the third copy is still granted.
|
||||
{
|
||||
const { m, ps, equip } = setup({ seed: 9 });
|
||||
const t = give(m, ps, cid, 'hand');
|
||||
@@ -986,9 +985,9 @@ test('拟态物质 with 2 copies owned and none left in the pool gives nothing
|
||||
const got = spyGrants(ps);
|
||||
const warns = spyWarns(m);
|
||||
assert.deepEqual(equip(giveItem(m, ps, A('5_05')), t), OK);
|
||||
assert.deepEqual(got, []);
|
||||
assert.deepEqual(chessIn(ps), [cid, cid]);
|
||||
assert.deepEqual(warns.map((w) => w.msgid), ['{who}:卡池中已没有{name}']);
|
||||
assert.deepEqual(got, [cid]);
|
||||
assert.deepEqual(chessIn(ps), [elite]);
|
||||
assert.deepEqual(warns, []);
|
||||
}
|
||||
// 2 normal copies with copies left: the 3rd merges into the elite
|
||||
{
|
||||
@@ -1004,7 +1003,7 @@ test('拟态物质 with 2 copies owned and none left in the pool gives nothing
|
||||
}
|
||||
});
|
||||
|
||||
test('拟态物质 with fewer than 2 copies and no same-bond operator left gives nothing and says so (GitHub #401)', () => {
|
||||
test('拟态物质 with fewer than 2 copies samples eligible identities independently of remaining stock', () => {
|
||||
// 缪尔赛思's only bond is 调和 and she is its only member: one copy owned, the rest of her pool taken by others
|
||||
const cid = 'chess_char_6_11_a';
|
||||
const { m, ps, equip } = setup({ seed: 9 });
|
||||
@@ -1013,8 +1012,8 @@ test('拟态物质 with fewer than 2 copies and no same-bond operator left gives
|
||||
const got = spyGrants(ps);
|
||||
const warns = spyWarns(m);
|
||||
assert.deepEqual(equip(giveItem(m, ps, A('5_05')), t), OK);
|
||||
assert.deepEqual(got, [], 'nothing granted');
|
||||
assert.deepEqual(warns, [{ msgid: '{who}:没有可获得的同盟约干员', who: '拟态物质', name: null }]);
|
||||
assert.deepEqual(got, [cid], 'the only eligible identity remains available even with no stock');
|
||||
assert.deepEqual(warns, []);
|
||||
});
|
||||
|
||||
test('博士投影: golden promotes at once; normal stays equipped and promotes at the next round start', () => {
|
||||
|
||||
@@ -11,8 +11,8 @@ const approx = (a, b, label = '') => assert.ok(Math.abs(a - b) < 1e-5 * Math.max
|
||||
const enemy = (key, opts = {}) => enemyRec({ key, hp: 1e8, atk: 0, speed: 0, ...opts });
|
||||
const defs = { enemies: { dummy: enemy('dummy'), light: enemy('light', { mass: 3 }), mid: enemy('mid', { mass: 4 }), heavy: enemy('heavy', { mass: 9 }),
|
||||
fly: enemy('fly', { motion: 'FLY' }), zero: enemy('zero', { mass: 0 }), armored: enemy('armored', { def: 100 }) } };
|
||||
function field({ tier = 6, elite = true, skill = 0, potential = 6, dir = 'RIGHT', row = 10, col = 3 } = {}) {
|
||||
const h = makeBattle({ seed: 23, autoFinish: false, timeLimit: 180, defs,
|
||||
function field({ tier = 6, elite = true, skill = 0, potential = 6, dir = 'RIGHT', row = 10, col = 3, flags = {} } = {}) {
|
||||
const h = makeBattle({ seed: 23, autoFinish: false, timeLimit: 180, defs, flags,
|
||||
hooks: ['damaged', 'attack', 'elementBurst', 'ammoUsed'], captureNoisy: true,
|
||||
units: [{ uid: 1, diy: { slot: tier, charId: C, skillIndex: skill }, elite, potential, row, col, dir }] });
|
||||
h.step(); return { h, u: h.unit(1) };
|
||||
@@ -21,6 +21,26 @@ function done(h) { checkInvariants(h.b); assert.deepEqual(h.b.errors, []); }
|
||||
function start(u) { u.skill.gainSp(999, 'init'); assert.equal(u.skill.activate('test'), true); }
|
||||
const hits = (h, tag) => h.hooksOf('damaged').filter((c) => c.dmg?.tags?.includes(tag));
|
||||
|
||||
for (const tier of [5, 6]) for (const elite of [false, true]) test(`风暴潮 air-only automation retains ground-only normal attacks (${tier}/${elite})`, () => {
|
||||
const { h, u } = field({ tier, elite, skill: 1, flags: { startOpCooldown: 3 } });
|
||||
const e = h.spawn('fly', { pos: [10, 6] }); u.skill.gainSp(999, 'init');
|
||||
h.run(2); assert.equal(u.skill.activations, 0, 'operation cooldown still applies');
|
||||
assert.ok(h.runUntil(() => u.skill.active, 2)); h.run(3);
|
||||
const damaged = h.hooksOf('damaged').filter(c => c.source === u && c.target === e);
|
||||
assert.ok(damaged.length > 0, 'the vortex hits the air-only target');
|
||||
assert.ok(damaged.every(c => !c.dmg.isAttack), 'normal attacks remain ground-only'); done(h);
|
||||
});
|
||||
test('风暴潮 auto-cast rejects distant/hidden/sleeping flyers and waits through stun', () => {
|
||||
const { h, u } = field({ skill: 1 }); u.skill.gainSp(999, 'init');
|
||||
h.spawn('fly', { pos: [12, 12] }); h.run(0.3); assert.equal(u.skill.activations, 0);
|
||||
const e = h.spawn('fly', { pos: [10, 6] }); h.b.addBuff(e, { key: 'test:hide', flags: { stealth: true } });
|
||||
h.run(0.3); assert.equal(u.skill.activations, 0);
|
||||
h.b.removeBuff(e, 'test:hide'); h.b.applyStatus(e, 'sleep', { duration: 0.5 });
|
||||
h.run(0.3); assert.equal(u.skill.activations, 0);
|
||||
h.b.applyStatus(u, 'stun', { duration: 0.6 }); h.run(0.4); assert.equal(u.skill.activations, 0);
|
||||
h.run(0.5); assert.equal(u.skill.activations, 1); done(h);
|
||||
});
|
||||
|
||||
test('克莱门莎 is selectable with authored skills in both slots, every form and potential', () => {
|
||||
assert.ok(KITTED_CHARS.includes(C));
|
||||
for (const tier of [5, 6]) {
|
||||
@@ -186,6 +206,22 @@ test('风暴潮 normal attack hits five ground enemies in total, never all neigh
|
||||
assert.equal(victims.size, 5); assert.ok(!victims.has(fly)); done(h);
|
||||
});
|
||||
|
||||
for (const elite of [false, true]) test(`风暴潮 splash uses the published 1.1 collision radius and 100% ATK (${elite})`, () => {
|
||||
const { h, u } = field({ skill: 1, elite }); start(u); h.run(1.6); h.b.rng = () => 0.99;
|
||||
const main = h.spawn('dummy', { pos: [10, 4] });
|
||||
const near = h.spawn('dummy', { pos: [10, 5] });
|
||||
const outside = h.spawn('dummy', { pos: [10, 5.2] });
|
||||
const body = h.spawn('dummy', { pos: [10, 5.8] });
|
||||
body.hitArea = { w: 1.6, h: 1, dx: 0, dy: 0 };
|
||||
const hp = [main, near, outside, body].map(e => e.hp);
|
||||
performAttack(h.b, u, effectiveProfile(u), [main]);
|
||||
approx(hp[0] - main.hp, u.s.atk * u.skill.bb['attack@aoe_atk_scale']);
|
||||
approx(hp[1] - near.hp, u.s.atk);
|
||||
assert.equal(outside.hp, hp[2], 'a point outside radius 1.1 is not splashed');
|
||||
approx(hp[3] - body.hp, u.s.atk, 'a large collision body overlapping radius 1.1 is splashed');
|
||||
done(h);
|
||||
});
|
||||
|
||||
test('与海为敌 normal attacks hit three and spend no ammunition; remote erosion bursts fire three cross-shaped mixed hits', () => {
|
||||
const { h, u } = field({ skill: 2 }); start(u); h.b.rng = () => 0.99;
|
||||
const close = Array.from({ length: 3 }, () => h.spawn('dummy', { pos: [10, 4] }));
|
||||
|
||||
+3
-1
@@ -272,7 +272,9 @@ test('config: modes, rounds and templates', () => {
|
||||
assert.equal(config.economy.benchSize, 10);
|
||||
assert.equal(config.economy.deployCap, 8);
|
||||
assert.equal(config.economy.mergeCountOverrides.chess_char_2_11_a, 2);
|
||||
assert.equal(config.economy.poolCopiesOverrides.chess_char_6_11_a, 4);
|
||||
assert.equal(config.economy.poolCopiesOverrides.chess_char_2_11_a, 8);
|
||||
assert.equal(config.economy.coopPoolMultiplier, 2);
|
||||
assert.equal(config.economy.itemPoolCopiesOverrides.chess_item_5_07_e_a, 2);
|
||||
assert.deepEqual(config.hiddenCore.single, 350);
|
||||
assert.deepEqual(config.hiddenCore.multi, 1200);
|
||||
assert.equal(config.titles.length, 6);
|
||||
|
||||
@@ -11,7 +11,7 @@ const MATCHES = read('./golden/matches.json');
|
||||
const BACKUPS = read('../data/backups.json');
|
||||
|
||||
test('matches: the 自选 match\'s human seat draws its 自选 pieces in its shop and fields 推进之王 in battle', () => {
|
||||
const [id, dg] = Object.entries(MATCHES.scenarios).find(([k]) => /-diy$/.test(k)) || [];
|
||||
const [id, dg] = Object.entries(MATCHES.scenarios).find(([k]) => /-coverage-diy$/.test(k)) || [];
|
||||
assert.ok(dg, 'scenario present');
|
||||
assert.deepEqual(dg.errors, { engine: 0, logged: 0, sim: 0, dispatcher: 0 }, id);
|
||||
assert.deepEqual(Object.keys(dg.diy.picks).sort(), Object.keys(BACKUPS.diy.slots).sort(), 'all four slots filled');
|
||||
|
||||
@@ -52,7 +52,7 @@ test('standins: every active skill a chess names for its stand-in is cast; passi
|
||||
});
|
||||
|
||||
test('matches: the 补位 match\'s human seat fields stand-ins (listed per round) and the match ends without errors', () => {
|
||||
const dg = MATCHES.scenarios['coop2-NORMAL-14-standins'];
|
||||
const dg = MATCHES.scenarios['coop2-NORMAL-1-coverage-standins'];
|
||||
assert.ok(dg, 'scenario present');
|
||||
assert.deepEqual(dg.errors, { engine: 0, logged: 0, sim: 0, dispatcher: 0 });
|
||||
const fielded = dg.standIns.rounds.filter(([, s]) => s).flatMap(([, s]) => s.split(' '));
|
||||
|
||||
@@ -17,7 +17,7 @@ through.
|
||||
| `roster` | 49 battles: every visible chess record (normal and elite) with every selectable skill and module (DESIGN §16), 12 operators per battle on a real stage (all 11 in turn) against the round's real wave three times over, every non-leader enemy kind of `data/enemies.json` as extra spawns (half of them bounties), placeable summons on the board, every equipment item, band, battle-side 机变 card and stage map card in turn, bonds from the board |
|
||||
| `bonds` | 46 battles: every bond at its activation threshold (1 layer) and at its top tier (999 layers) |
|
||||
| `fields` | 22 battles: every Final Assault / Hidden Core leader on a pair and a solo template (shared pool, 200 s cap) and the 联防 field with 1 and 2 helpers on the round's stage, both halves (carried HP / SP, a knocked-out operator, two leakers' enemies) |
|
||||
| `matches` | 18 matches to the end in virtual time: 16 bot-only (solo 标准 / 险境 / 绝境 / 终极 × 2 seeds, co-op 2 / 3 / 4, one server-run combat match, two runs boosted to the Hidden Core), one co-op match whose human seat (AI 托管, offline) does not own 9 NORMAL chess — they fight as their 补位 stand-ins (its digest lists them per round, `standIns`) — and one whose human seat slots 自选 picks (推进之王 and prototypes) with its 调度中心 at level 5 from the first prep: its own shop draws them and its AI fields 推进之王 (its digest lists per round the 自选 shop draws and the fielded pieces, `diy`) |
|
||||
| `matches` | 20 matches to the end in virtual time: 16 bot-only (solo 标准 / 险境 / 绝境 / 终极 × 2 seeds, co-op 2 / 3 / 4, one server-run combat match, two runs boosted to the Hidden Core), one co-op match whose human seat (AI 托管, offline) does not own 9 NORMAL chess — they fight as their 补位 stand-ins (its digest lists them per round, `standIns`) — and one whose human seat slots 自选 picks (推进之王 and prototypes) with its 调度中心 at level 5 from the first prep: its own shop draws them and its AI fields 推进之王 (its digest lists per round the 自选 shop draws and the fielded pieces, `diy`) |
|
||||
| `standins` | 10 battles: every NORMAL chess record (normal and elite, 110) fielded as its 补位 stand-in (`standIn: true`: the stand-in's body, backup skill / module and kit — all 17 stand-ins, every skill a chess names for them), 12 per battle by strength band, laid out by the stand-in's position on a real stage, against the round's real wave three times over plus 8 ground enemy kinds (melee stand-ins always meet an enemy), an item each, bonds from the board |
|
||||
| `diy` | battles of 自选 pieces (a DIY slot with its `diy` pick, `shared/diy.js`): every owned 6★ with an operator kit (`kits/index.js OPERATOR_KITS`) in each form of tiers 5 and 6 (normal; elite with no module and with each module) under each skill, then every prototype pick with a kit at its locked selection, normal and elite, 12 per battle against the round's real wave; a new operator kit adds its scenarios (the battle count grows with the kits) |
|
||||
|
||||
@@ -58,3 +58,5 @@ node tools/golden.mjs --twice # determinism: the corpus twice
|
||||
- A new engine hook, event or chess record does not change the digests of the existing scenarios by itself — the
|
||||
hook list is frozen in the tool and the corpus is regenerated only on `golden:update` — but new data records do join
|
||||
the corpus there (the roster family enumerates `data/chess.json`).
|
||||
|
||||
After the measured shared-stock update (DESIGN §29.8), the original stand-in / DIY seeds remain as outcome regressions even when they field no such operator. Companion seeds `coop2-NORMAL-1-coverage-standins` and `coop2-NORMAL-5-coverage-diy` exercise those full paths; the corpus now contains 287 scenarios.
|
||||
+39
-39
@@ -5850,38 +5850,38 @@
|
||||
"round": 13,
|
||||
"seed": 3994643046,
|
||||
"waveId": "act1autochess_h06",
|
||||
"time": 198.03,
|
||||
"ticks": 5941,
|
||||
"reason": "cleared",
|
||||
"killed": 150,
|
||||
"time": 230,
|
||||
"ticks": 6900,
|
||||
"reason": "timeout",
|
||||
"killed": 149,
|
||||
"total": 150,
|
||||
"unspawned": 0,
|
||||
"bossHpLeft": null,
|
||||
"rngDraws": 1626,
|
||||
"rngDraws": 1637,
|
||||
"errors": {"count":0,"kinds":[]},
|
||||
"players": {"p1":{"killed":150,"total":150,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":1661420,"bossDmg":0,"heal":56685,"deaths":1,"layerGains":{}}},
|
||||
"players": {"p1":{"killed":149,"total":150,"perfect":false,"leaks":1,"leaksUncounted":0,"coins":0,"dmg":1648194,"bossDmg":0,"heal":65699,"deaths":0,"layerGains":{}}},
|
||||
"units": [
|
||||
["p1",1,"op","chess_char_5_diy1_a",0,null,2244,1,5,1,6436,260,0,9,2],
|
||||
["p1",2,"op","chess_char_5_diy2_a",1,null,2244,1,7,29,321186,4202,0,103,5],
|
||||
["p1",3,"op","chess_char_5_diy1_a",2,null,2244,1,10,42,323043,24114,0,196,2],
|
||||
["p1",4,"op","chess_char_5_diy2_b",0,"none",2740.1,1,11,0,17637,2190,0,32,8],
|
||||
["p1",5,"op","chess_char_5_diy1_b",1,"none",2740.1,1,13,54,636268,3391,0,191,6],
|
||||
["p1",6,"op","chess_char_5_diy2_b",2,"none",1833.1,1,145,24,356851,9329,0,109,2],
|
||||
["p1",1,"op","chess_char_5_diy1_a",0,null,2244,1,5,0,795,0,0,2,0],
|
||||
["p1",2,"op","chess_char_5_diy2_a",1,null,2244,1,7,17,177136,4652,0,104,5],
|
||||
["p1",3,"op","chess_char_5_diy1_a",2,null,2244,1,10,26,292321,14630,0,171,2],
|
||||
["p1",4,"op","chess_char_5_diy2_b",0,"none",2740.1,1,11,1,23925,219,0,21,5],
|
||||
["p1",5,"op","chess_char_5_diy1_b",1,"none",2740.1,1,13,44,563489,2024,0,171,6],
|
||||
["p1",6,"op","chess_char_5_diy2_b",2,"none",2677.81,1,15,61,590529,13446,0,162,2],
|
||||
["p1",7,"op","chess_char_6_diy1_b",0,"none",1818.3,1,8,0,0,0,0,0,0],
|
||||
["p1",8,"op","chess_char_6_diy2_b",1,"none",1818.3,1,12,0,0,0,0,0,0],
|
||||
["p1",9,"op","chess_char_6_diy1_b",2,"none",1818.3,1,14,0,0,0,0,0,0],
|
||||
["p1",10,"op","chess_char_6_diy2_b",0,"uniequip_002_monstr",1818.3,1,4,0,0,0,24672,66,16],
|
||||
["p1",11,"op","chess_char_6_diy1_b",1,"uniequip_002_monstr",1818.3,1,6,0,0,0,22077,56,2],
|
||||
["p1",12,"op","chess_char_6_diy2_b",2,"uniequip_002_monstr",1818.3,1,9,0,0,16846,9935,34,0],
|
||||
["p1",10,"op","chess_char_6_diy2_b",0,"uniequip_002_monstr",1818.3,1,4,0,0,0,38426,75,18],
|
||||
["p1",11,"op","chess_char_6_diy1_b",1,"uniequip_002_monstr",1818.3,1,6,0,0,0,17126,50,2],
|
||||
["p1",12,"op","chess_char_6_diy2_b",2,"uniequip_002_monstr",1818.3,1,9,0,0,30791,10147,31,0],
|
||||
["-","~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]
|
||||
],
|
||||
"enemies": {"enemy_1249_lysdb_2":[60,60,0,22385,671846],"enemy_1251_lysyta":[51,51,0,170,261740],"enemy_1329_cbshld":[39,39,0,37777,727834]},
|
||||
"hooks": [1,166,5941,1054,1054,2053,2565,473,151,151,43,39,24,10083,0,64,150,0,1,151,0,47,0],
|
||||
"events": {"atk":1558,"deploy":13,"die":151,"dmg":2565,"engage":6,"fx":855,"heal":194,"skill":82,"spawn":165,"status":97},
|
||||
"fx": {"anchor":11,"blast":72,"burst":47,"lock":24,"shieldBreak":60,"teleport":630,"tide":11},
|
||||
"snaps": "b5491880 3d470ed3 e24470a9 9efdac52 682ba7c7 5053e7b1 372bc538 4f6990b6 78e9810f 2e63af8e c036a7f6 85430b6a f0f306e0 83b41054 a23bd5e0 216b4871 ad7483be 0e1ae0c5 b71814eb 25d90322"
|
||||
"enemies": {"enemy_1249_lysdb_2":[60,60,0,13932,671846],"enemy_1251_lysyta":[51,51,0,261,261740],"enemy_1329_cbshld":[39,38,0,51568,714608]},
|
||||
"hooks": [1,165,6900,1038,1038,2096,2633,480,149,149,40,38,30,12173,0,68,150,0,1,149,0,46,0],
|
||||
"events": {"atk":1587,"deploy":12,"die":149,"dmg":2633,"engage":6,"fx":973,"heal":194,"skill":78,"spawn":165,"status":93},
|
||||
"fx": {"anchor":11,"blast":90,"burst":46,"lock":30,"shieldBreak":60,"teleport":725,"tide":11},
|
||||
"snaps": "d4a56f82 64456d40 69d788a6 1a64a697 4c406731 aced5db8 c80b4ecb b75d0c6b d00450e6 c01e3305 a7cf048f b62cb632 1d43a004 027135f0 c43aadd9 edcbe0cc f2c9091e 7ca44db3 1842ebec c818f908 8b4dc80f 94959613 0d364901"
|
||||
},
|
||||
"diy-136": {
|
||||
"about": "5_diy1_a=cdfend 5_diy2_b=cdfend 6_diy1_a=clemnt/S1 6_diy2_a=clemnt/S2 6_diy1_a=clemnt/S3 6_diy2_b=clemnt/S1 6_diy1_b=clemnt/S2 6_diy2_b=clemnt/S3 5_diy1_a=cguard 5_diy2_b=cguard 5_diy1_a=csnipe 5_diy2_b=csnipe",
|
||||
@@ -5895,35 +5895,35 @@
|
||||
"time": 212.4,
|
||||
"ticks": 6372,
|
||||
"reason": "cleared",
|
||||
"killed": 13,
|
||||
"killed": 12,
|
||||
"total": 123,
|
||||
"unspawned": 0,
|
||||
"bossHpLeft": null,
|
||||
"rngDraws": 358,
|
||||
"rngDraws": 336,
|
||||
"errors": {"count":0,"kinds":[]},
|
||||
"players": {"p1":{"killed":13,"total":123,"perfect":false,"leaks":110,"leaksUncounted":0,"coins":0,"dmg":651626,"bossDmg":0,"heal":0,"deaths":20,"layerGains":{}}},
|
||||
"players": {"p1":{"killed":12,"total":123,"perfect":false,"leaks":111,"leaksUncounted":0,"coins":0,"dmg":540205,"bossDmg":0,"heal":0,"deaths":20,"layerGains":{}}},
|
||||
"units": [
|
||||
["p1",1,"op","chess_char_5_diy1_a",2,null,0,0,107,0,28,4256,0,2,1],
|
||||
["p1",2,"op","chess_char_5_diy2_b",2,"none",0,0,127,0,702,5816,0,6,1],
|
||||
["p1",3,"op","chess_char_6_diy1_a",0,null,0,0,136,0,7472,4488,0,8,1],
|
||||
["p1",4,"op","chess_char_6_diy2_a",1,null,0,0,147,7,383594,4488,0,38,1],
|
||||
["p1",5,"op","chess_char_6_diy1_a",2,null,0,0,86,0,1871,4488,0,7,0],
|
||||
["p1",6,"op","chess_char_6_diy2_b",0,"none",0,0,82,1,26868,5480,0,25,5],
|
||||
["p1",7,"op","chess_char_6_diy1_b",1,"none",0,0,79,5,136419,5480,0,15,1],
|
||||
["p1",8,"op","chess_char_6_diy2_b",2,"none",0,0,74,0,11047,5480,0,15,0],
|
||||
["p1",1,"op","chess_char_5_diy1_a",2,null,0,0,107,0,41,4256,0,3,1],
|
||||
["p1",2,"op","chess_char_5_diy2_b",2,"none",0,0,127,0,686,5816,0,5,1],
|
||||
["p1",3,"op","chess_char_6_diy1_a",0,null,0,0,136,0,5820,4488,0,8,1],
|
||||
["p1",4,"op","chess_char_6_diy2_a",1,null,0,0,147,6,320372,4488,0,38,1],
|
||||
["p1",5,"op","chess_char_6_diy1_a",2,null,0,0,87,0,2545,4488,0,10,0],
|
||||
["p1",6,"op","chess_char_6_diy2_b",0,"none",0,0,82,1,7708,5480,0,16,3],
|
||||
["p1",7,"op","chess_char_6_diy1_b",1,"none",0,0,79,5,121380,5480,0,14,1],
|
||||
["p1",8,"op","chess_char_6_diy2_b",2,"none",0,0,74,0,9062,5480,0,15,0],
|
||||
["p1",9,"op","chess_char_5_diy1_a",2,null,0,0,91,0,6214,5128,0,11,1],
|
||||
["p1",10,"op","chess_char_5_diy2_b",2,"none",3334,1,149,0,469,6668,0,8,1],
|
||||
["p1",11,"op","chess_char_5_diy1_a",2,null,1271,1,4,0,36941,0,0,186,3],
|
||||
["p1",12,"op","chess_char_5_diy2_b",2,"none",1510,1,5,0,40002,0,0,191,3],
|
||||
["p1",10,"op","chess_char_5_diy2_b",2,"none",3334,1,149,0,494,6668,0,8,2],
|
||||
["p1",11,"op","chess_char_5_diy1_a",2,null,1271,1,4,0,31537,0,0,186,3],
|
||||
["p1",12,"op","chess_char_5_diy2_b",2,"none",1510,1,5,0,34346,0,0,191,3],
|
||||
["-","~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]
|
||||
],
|
||||
"enemies": {"enemy_1006_shield_3":[21,0,21,21703,200406],"enemy_1043_zomsbr":[66,13,53,7416,374461],"enemy_1402_tgshd_2":[36,0,36,20624,172729]},
|
||||
"hooks": [1,149,6372,612,612,2274,2370,18794,33,143,18,18,0,15658,0,43,123,110,1,33,0,12,0],
|
||||
"events": {"atk":612,"deploy":23,"die":33,"dmg":2370,"engage":12,"fx":298,"leak":110,"skill":36,"spawn":138,"status":104},
|
||||
"fx": {"anchor":2,"burst":12,"expose":254,"teleport":28,"tide":2},
|
||||
"snaps": "86776e30 c1bad963 559e3264 46aa7ee9 3425ff8c 70c737e8 aa3c0e2a 1d8b3ed4 c6110eba 814d43d6 e77ad147 812eda91 095ac019 ef11e0a6 6c647700 5b7d036c ee81eb83 3567cf14 ae42e2ad 3b143a2e 94d57690 805579e9"
|
||||
"enemies": {"enemy_1006_shield_3":[21,0,21,23335,174202],"enemy_1043_zomsbr":[66,12,54,6877,334513],"enemy_1402_tgshd_2":[36,0,36,19209,128930]},
|
||||
"hooks": [1,149,6372,593,593,2269,2376,18508,32,143,17,17,0,15626,0,43,123,111,1,32,0,16,0],
|
||||
"events": {"atk":593,"deploy":23,"die":32,"dmg":2376,"engage":12,"fx":302,"leak":111,"skill":34,"spawn":138,"status":112},
|
||||
"fx": {"anchor":2,"burst":16,"expose":254,"teleport":28,"tide":2},
|
||||
"snaps": "c41bdf1e ba9689e9 95a94805 6a5c07f5 6a39faef baaf84d6 c789d836 720ae281 580554e9 16c94931 4134a0d2 14068077 5c33c2f3 7c5d0f33 12b17543 7301d5e3 93e46c21 91620dab e2816266 67997963 515c8b4e b7f90d0f"
|
||||
},
|
||||
"diy-137": {
|
||||
"about": "5_diy1_a=acfend 5_diy2_b=acfend 5_diy1_a=acpion 5_diy2_b=acpion 5_diy1_a=acguad 5_diy2_b=acguad 5_diy1_a=ccast 5_diy2_b=ccast 5_diy1_a=cmedic 5_diy2_b=cmedic 5_diy1_a=csuppo 5_diy2_b=csuppo",
|
||||
|
||||
+795
-569
File diff suppressed because it is too large.
Load diff
@@ -50,7 +50,7 @@ describe('extract.py helpers (no UnityPy needed)', { skip: !PY && 'no python3' }
|
||||
assert.equal(enemySpines.sub, 'spine/enemy');
|
||||
// every listed id is a token of the game data without a web model (data/assets.json tokens[id].spine), sorted, unique
|
||||
const ids = tokenSpines.ids;
|
||||
assert.equal(ids.length, 39);
|
||||
assert.equal(ids.length, 40);
|
||||
assert.deepEqual([...ids].sort(), ids);
|
||||
assert.equal(new Set(ids).size, ids.length);
|
||||
const tokens = JSON.parse(readFileSync(path.join(ROOT, 'data/tokens.json'), 'utf8'));
|
||||
@@ -58,7 +58,8 @@ describe('extract.py helpers (no UnityPy needed)', { skip: !PY && 'no python3' }
|
||||
const web = JSON.parse(readFileSync(path.join(ROOT, 'data/assets.json'), 'utf8')).tokens;
|
||||
for (const id of ids) {
|
||||
assert.ok(tokens[id] || backups.tokens[id], `${id}: a token of the game data`);
|
||||
assert.equal(web[id]?.spine, undefined, `${id}: no web model`);
|
||||
if (id === 'token_10031_swire2_gdtrap') assert.ok(web[id]?.spine, 'champagne has a web skin fallback');
|
||||
else assert.equal(web[id]?.spine, undefined, `${id}: no web model`);
|
||||
}
|
||||
// the 自选 summons whose prefab draws nothing (an EmptyAnimator: displayType HIDDEN / an effect) are left out
|
||||
for (const id of ['token_10039_ulpia_block', 'token_10055_phatm2_mndclv', 'token_10058_sbell2_icetgt', 'token_10065_demetr_dmtpos',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// test/local-token-models.test.js — the token (summon) models only the local client has (0.2.0): 39 summons — most of the
|
||||
// test/local-token-models.test.js — the token (summon) models only the local client has (0.2.0): 40 summon overlays — most of the
|
||||
// 自选 picks' (data/backups.json `tokens`), 凯瑟琳's 爬行号·防护单元 and 凛御银灰's 风雪之眼 — have no battle Spine in
|
||||
// any community dump (fetch-assets reported "missing skel"), so the client drew their avatar diamond. Their official
|
||||
// skeletons come from the battle token packs of the local client (tools/local-extract/extract.py TOKEN_SPINES →
|
||||
@@ -55,7 +55,7 @@ function localManifest(ids, drop = null) {
|
||||
|
||||
describe('the 自选 summon models from the local client are an optional overlay', () => {
|
||||
test('the committed metadata: the TOKEN_SPINES of extract.py, each a skeleton + same-stem atlas + pages, its roles on its own clips', () => {
|
||||
assert.equal(IDS.length, 39);
|
||||
assert.equal(IDS.length, 40);
|
||||
if (TOKEN_SPINES) assert.deepEqual(IDS, [...TOKEN_SPINES].sort(), 'one entry per extract.py TOKEN_SPINES id');
|
||||
for (const id of IDS) {
|
||||
const m = COMMITTED[id];
|
||||
@@ -116,11 +116,13 @@ describe('the 自选 summon models from the local client are an optional overlay
|
||||
assert.deepEqual(withLocal, IDS);
|
||||
for (const id of IDS) {
|
||||
assert.deepEqual(MANIFEST.tokens[id].spineLocal, { group: `spine/token/${id}`, ...COMMITTED[id] }, `${id}: the committed metadata`);
|
||||
assert.equal(MANIFEST.tokens[id].spine, undefined, `${id}: no web model (it was the avatar diamond)`);
|
||||
assert.equal(spineEntry(MANIFEST, id), null, `${id}: without the local art the client keeps the diamond`);
|
||||
const web = MANIFEST.tokens[id].spine;
|
||||
if (id === 'token_10031_swire2_gdtrap') assert.ok(validSpine(web), 'champagne keeps its web skin fallback');
|
||||
else assert.equal(web, undefined, `${id}: no web model`);
|
||||
assert.deepEqual(spineEntry(MANIFEST, id), web || null, `${id}: without local art keep the existing fallback`);
|
||||
}
|
||||
// the tokens that always had a web model keep it, without an overlay
|
||||
for (const [id, t] of Object.entries(MANIFEST.tokens)) if (t.spine) { assert.ok(validSpine(t.spine), id); assert.equal(t.spineLocal, undefined, id); }
|
||||
for (const [id, t] of Object.entries(MANIFEST.tokens)) if (t.spine) { assert.ok(validSpine(t.spine), id); if (id !== 'token_10031_swire2_gdtrap') assert.equal(t.spineLocal, undefined, id); }
|
||||
});
|
||||
|
||||
test('the committed metadata is what the extracted models parse to', { skip: !IDS.every((id) => existsSync(path.join(ASSETS, 'local/spine/token', id))) && 'token models not extracted (tools/local-extract)' }, async () => {
|
||||
@@ -166,10 +168,10 @@ describe('client: the official summon model only when data/local-assets.json lis
|
||||
assert.equal(e.pma, true);
|
||||
assert.deepEqual(e.anims, m.anims);
|
||||
assert.equal(e.local, true);
|
||||
assert.equal(e.fallback, null, 'no web model to fall back to');
|
||||
assert.equal(e.fallback, MANIFEST.tokens[id].spine || null, 'retain the existing web model as fallback');
|
||||
assert.equal(spineEntry(MANIFEST, id, { local }), e, 'one entry object per manifest pair');
|
||||
assert.equal(spineEntry(MANIFEST, id, { local: localManifest([]) }), null, 'not extracted → no model');
|
||||
assert.equal(spineEntry(MANIFEST, id, { local: localManifest([id], m.textures[0]) }), null, 'a page missing → no model');
|
||||
assert.deepEqual(spineEntry(MANIFEST, id, { local: localManifest([]) }), MANIFEST.tokens[id].spine || null, 'not extracted → original fallback');
|
||||
assert.deepEqual(spineEntry(MANIFEST, id, { local: localManifest([id], m.textures[0]) }), MANIFEST.tokens[id].spine || null, 'missing local page → original fallback');
|
||||
}
|
||||
assert.equal(spineEntry(MANIFEST, 'token_10028_vigil_wolf', { local }), MANIFEST.tokens.token_10028_vigil_wolf.spine, 'a web token unchanged');
|
||||
const store = createAssets({ manifest: MANIFEST, localManifest: local });
|
||||
|
||||
@@ -254,8 +254,10 @@ test('withdrawing a deployed summon into a full hand: HAND_FULL like any other c
|
||||
assert.ok(drone && drone.count === 1, 'one 狼群');
|
||||
// (9,6): inside 伺夜's range ("只能部署在召唤者攻击范围内", player report #9 after 0.1.0)
|
||||
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: drone.uid, to: { area: 'board', row: 9, col: 6 } }), { ok: true });
|
||||
// golden items never merge with each other: a plain full hand
|
||||
for (let i = 0; i < ps.hand.length; i++) if (!ps.hand[i]) giveItem(m, ps, 'chess_item_1_02_e_b', 'hand', i);
|
||||
// One distinct golden per empty slot: goldens do not merge, and each base stays within its shared cap.
|
||||
const fillers = Object.values(DATA.items).filter((r) => r.isGolden && r.itemType === 'EQUIP' && (m.gd.itemPoolCopies(r.id) ?? 0) >= 2).map((r) => r.id);
|
||||
let n = 0;
|
||||
for (let i = 0; i < ps.hand.length; i++) if (!ps.hand[i]) giveItem(m, ps, fillers[n++], 'hand', i);
|
||||
assert.ok(ps.hand.every(Boolean) && ps.tempEmpty);
|
||||
assert.equal(m.handle('p_0', { t: 'g.move', uid: drone.uid, to: { area: 'hand', idx: 0 } }).error, ERR.HAND_FULL, 'a new card for a full hand');
|
||||
assert.equal(ps.board.get('9,6'), drone, 'the 狼群 stays on the board');
|
||||
|
||||
@@ -68,6 +68,21 @@ test('调和: +1 to core bonds with ≥ 1 real member on the board while active'
|
||||
assert.equal(s.yanShip.count, 2);
|
||||
});
|
||||
|
||||
test('actual normal/elite 缪尔赛思 activates every available core bond at two real members; duplicates add only one', () => {
|
||||
for (const id of gd.bondIds.filter(id => gd.bond(id).isCore && !gd.modeInactiveBonds.has(id))) {
|
||||
const two = members(id, 2, c => !c.bonds.includes('maniShip'));
|
||||
assert.equal(two.length, 2, id);
|
||||
for (const mu of ['chess_char_6_11_a', 'chess_char_6_11_b']) {
|
||||
const s = computeBonds(gd, state({ board: [piece(mu), ...two.map(id => piece(id))] }));
|
||||
assert.equal(s[id].count, 3, `${id}: two operators plus ${mu}`); assert.ok(s[id].active);
|
||||
}
|
||||
const dup = computeBonds(gd, state({ board: [piece('chess_char_6_11_a'), piece('chess_char_6_11_b'), ...two.map(id => piece(id))] }));
|
||||
assert.equal(dup[id].count, 3, `${id}: duplicate harmony is not +2`);
|
||||
const hand = computeBonds(gd, state({ board: two.map(id => piece(id)), hand: [piece('chess_char_6_11_a')] }));
|
||||
assert.equal(hand[id].count, 2, `${id}: harmony needs deployment`);
|
||||
}
|
||||
});
|
||||
|
||||
test('独行 (downward): active only while exactly 1 distinct 独行 operator is on the board', () => {
|
||||
const solo = members('soloShip', 2);
|
||||
assert.equal(tierFor(DATA.bonds.soloShip, 1), 1);
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { makeMatch } from './harness.js';
|
||||
|
||||
test('a solo bot used for rehearsal has no AI teammate and gets no extra grace', () => {
|
||||
const h = makeMatch({ mode: 'solo', seats: [{ seat: 0, playerId: 'ai_0', name: 'AI', isBot: true }], fake: true, instant: false }).start();
|
||||
const m = h.m; m.setDeadline(0); m.order[0].lp = 40; m.round = m.gd.bossRound;
|
||||
m.startFinalAssault(false);
|
||||
assert.equal(m.bossOvertimeStartReal, m.gd.bossLevelTime(m.round)); m.dispose();
|
||||
});
|
||||
|
||||
for (const hidden of [false, true]) for (const bots of [0, 1]) {
|
||||
test(`boss overtime follows the visible clock unless an AI teammate is present (hidden=${hidden}, bots=${bots})`, () => {
|
||||
const h = makeMatch({ mode: 'coop', humans: 2, bots, fake: true, instant: false }).start();
|
||||
const m = h.m;
|
||||
m.setDeadline(0);
|
||||
for (const p of m.order) p.lp = 40;
|
||||
m.round = hidden ? m.gd.hiddenRound : m.gd.bossRound;
|
||||
if (hidden) m.teamLp = 100;
|
||||
m.startFinalAssault(hidden);
|
||||
const level = m.gd.bossLevelTime(m.round);
|
||||
const after = bots ? m.gd.bossOvertimeAfterReal : level;
|
||||
assert.equal(m.overtimeAt, m.sched.now() + after * m.gd.combatTimeScale / m.gameSpeed * 1000);
|
||||
const lp = m.teamLp;
|
||||
m._applyOvertime((after + 0.9) * m.gd.combatTimeScale); assert.equal(m.teamLp, lp);
|
||||
m._applyOvertime((after + 1) * m.gd.combatTimeScale); assert.equal(m.teamLp, lp - 1);
|
||||
m._applyOvertime((after + 1) * m.gd.combatTimeScale); assert.equal(m.teamLp, lp - 1, 'no repeated deduction');
|
||||
m._applyOvertime((after + 3) * m.gd.combatTimeScale); assert.equal(m.teamLp, lp - 3);
|
||||
m.dispose();
|
||||
});
|
||||
}
|
||||
@@ -22,6 +22,23 @@ import { DATA, makeMatch, checkInvariants } from './harness.js';
|
||||
|
||||
const gd = new GameData(DATA, 'mode_multi_hard');
|
||||
|
||||
test('client result names DIY and stand-in operators from the authoritative lineup, never the client or empty slot (#469)', () => {
|
||||
const ordinary = Object.values(DATA.chess).find(c => c.visible && !c.isGolden && gd.standIn(c.chessId));
|
||||
const cases = [
|
||||
{ chessId: 'chess_char_6_diy1_a', diy: { charId: 'char_4231_clemnt', skillIndex: 2, uniEquipId: null }, expected: '克莱门莎' },
|
||||
{ chessId: ordinary.chessId, standIn: true, expected: gd.standIn(ordinary.chessId).name },
|
||||
];
|
||||
for (const { expected, ...unit } of cases) {
|
||||
const spec = buildBattleSpec({ battleId: 'names', fieldId: 'n:p', kind: 'normal', seed: 1, round: 3, timeLimit: 60,
|
||||
players: [{ playerId: 'p', units: [{ uid: 7, kind: 'chess', row: 10, col: 3, items: [], ...unit }], bonds: {} }],
|
||||
spawns: [{ enemyKey: plainKey, count: 1, time: 1 }], flags: { layerGainsEnabled: true } });
|
||||
const raw = { reason: 'cleared', time: 20, killed: 1, total: 1, errors: 0,
|
||||
perPlayer: { p: perPlayer({ killed: 1, total: 1, unitStats: [{ uid: 7, defId: 'forged', name: 'forged', kind: 'op', dmg: 1e6 }] }) } };
|
||||
const v = validateClientResult(spec, raw, { gd }); assert.ok(v.ok, v.reason);
|
||||
assert.equal(v.result.perPlayer.p.unitStats[0].name, expected);
|
||||
}
|
||||
});
|
||||
|
||||
test('eliminated humans auto-observe the first field at COMBAT start (display replica) and may switch freely', () => {
|
||||
const h = makeMatch({ mode: 'coop', humans: 3, bots: 1, seed: 9301, fake: true, clientCombat: true, instant: false, pace: 'paced',
|
||||
script: () => ({ duration: 6 }) }).start();
|
||||
|
||||
@@ -286,7 +286,7 @@ test('Final Assault: two client-run pair fields share the server pool (b.progres
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('Final Assault: leaks and leader LP effects from b.progress drain the team LP; overtime after 150 real s at 1 LP/real s; team LP 0 → b.end → defeat', () => {
|
||||
test('Final Assault: leaks and leader LP effects from b.progress drain the team LP; all-human overtime after 120 real s at 1 LP/real s; team LP 0 → b.end → defeat', () => {
|
||||
const h = makeMatch({ mode: 'coop', difficulty: 'FUNNY', humans: 2, seed: 9109, fake: true, clientCombat: true, instant: false,
|
||||
script: (b) => (b.kind === 'boss' ? { bossDps: 0, leakEvents: [{ at: 2, lpr: 3 }], lpLossEvents: [{ at: 3, amount: 2 }] } : { duration: 2 }) }).start();
|
||||
const m = h.m;
|
||||
@@ -296,14 +296,14 @@ test('Final Assault: leaks and leader LP effects from b.progress drain the team
|
||||
h.sched.advance(4000);
|
||||
assert.equal(m.teamLp, lp0 - 5, 'leak lpr 3 + leader effect 2');
|
||||
const lp1 = m.teamLp;
|
||||
// overtime (bossTurnHpReduceTime): 150 REAL s like the level's 120 s countdown, then 1 LP per real second
|
||||
// All-human teams start overtime at the visible level countdown, without the AI team's extra 30 seconds.
|
||||
const t0 = m._bossStartAt;
|
||||
const pub = () => h.bc.filter((x) => x.t === 'm.public').pop();
|
||||
m.flush(true);
|
||||
assert.equal(pub().deadline, t0 + 120000, 'the HUD counts the boss level\'s 120 s down');
|
||||
assert.equal(pub().overtimeAt, t0 + 150000, 'and knows when the drain starts');
|
||||
h.sched.advance(t0 + 150000 - m.sched.now() + 500);
|
||||
assert.equal(m.teamLp, lp1, 'no drain before 150 real s (the countdown ran out at 120 s, the battle went on)');
|
||||
assert.equal(pub().overtimeAt, t0 + 120000, 'and knows when the drain starts');
|
||||
h.sched.advance(t0 + 120000 - m.sched.now() + 500);
|
||||
assert.equal(m.teamLp, lp1, 'the first deduction is at the next whole real second');
|
||||
h.sched.advance(5000);
|
||||
assert.ok(m.teamLp <= lp1 - 4 && m.teamLp >= lp1 - 6, `overtime drain ≈ 5 LP over 5 real s (${lp1} → ${m.teamLp})`);
|
||||
const end = h.runToEnd();
|
||||
|
||||
@@ -191,9 +191,11 @@ test('#9 hand and temp full: a re-orientation that would push 狼群 out is refu
|
||||
const toasts = [];
|
||||
const toast = m.toast.bind(m);
|
||||
m.toast = (p, kind, text) => { toasts.push(renderMessage(text)); return toast(p, kind, text); }; // string or msg()
|
||||
const golden = Object.keys(DATA.items).find((id) => DATA.items[id].isGolden && DATA.items[id].itemType === 'EQUIP');
|
||||
while (ps.hand.includes(null)) giveItem(m, ps, golden);
|
||||
while (ps.temp.includes(null)) giveItem(m, ps, golden, 'temp');
|
||||
// One distinct golden per empty slot: goldens do not merge, and each base stays within its shared cap.
|
||||
const fillers = Object.values(DATA.items).filter((r) => r.isGolden && r.itemType === 'EQUIP' && (m.gd.itemPoolCopies(r.id) ?? 0) >= 2).map((r) => r.id);
|
||||
let n = 0;
|
||||
while (ps.hand.includes(null)) giveItem(m, ps, fillers[n++]);
|
||||
while (ps.temp.includes(null)) giveItem(m, ps, fillers[n++], 'temp');
|
||||
// facing LEFT would leave (11,5) outside her range and the wolf has nowhere to go: refused, nothing changes
|
||||
assert.equal(move(m, vigil.uid, board(10, 4), 'LEFT').error, ERR.HAND_FULL);
|
||||
assert.equal(ps.board.get('10,4').dir, 'RIGHT');
|
||||
|
||||
@@ -77,6 +77,7 @@ for (const n of [1, 2, 3, 4]) {
|
||||
assert.equal(pool.maxHp, bossPoolHp(m.gd, m.bossId, n));
|
||||
for (const f of fields) {
|
||||
assert.deepEqual(f.opts.rect, GEO.BOSS_RECT);
|
||||
assert.equal(f.opts.modeId, 'mode_multi_funny', '#475: an unpaired field retains multiplayer mode despite its solo wave template');
|
||||
assert.equal(f.opts.timeLimit, Infinity);
|
||||
assert.equal(f.opts.flags.layerGainsEnabled, false);
|
||||
assert.deepEqual(f.opts.flags.enemyScale, m.gd.enemyScale(14), 'the round\'s enemy effects for the leader\'s summons (PR #272)');
|
||||
@@ -109,7 +110,7 @@ for (const n of [1, 2, 3, 4]) {
|
||||
});
|
||||
}
|
||||
|
||||
test('overtime: −1 team LP per REAL second after 150 real s (300 game s at 2×); team LP 0 ends every field → defeat (13 rounds passed)', () => {
|
||||
test('all-human overtime: −1 team LP per REAL second after 120 real s (240 game s at 2×); team LP 0 ends every field → defeat (13 rounds passed)', () => {
|
||||
const h = makeMatch({ mode: 'coop', difficulty: 'FUNNY', humans: 2, seed: 50, fake: true, script: (b) => (b.kind === 'boss' ? { bossDps: 1 } : {}) }).start();
|
||||
const m = h.m;
|
||||
h.drive(() => m.phase === PHASE.PREP && m.round === 14);
|
||||
@@ -120,7 +121,7 @@ test('overtime: −1 team LP per REAL second after 150 real s (300 game s at 2×
|
||||
const end = h.runToEnd();
|
||||
assert.equal(end.victory, false);
|
||||
assert.equal(end.roundsPassed, 13);
|
||||
assert.ok(Math.abs(f.time - 340) < 2.5, `ended ≈ 300 + 2 × 20 game s (${f.time})`);
|
||||
assert.ok(Math.abs(f.time - 280) < 2.5, `ended ≈ 240 + 2 × 20 game s (${f.time})`);
|
||||
assert.equal(m.teamLp, 0);
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
@@ -222,7 +222,8 @@ function legacyInvariants(m) {
|
||||
}
|
||||
}
|
||||
for (const [base, e] of pool.entries) {
|
||||
assert.ok(e.left >= 0 && e.left <= e.cap, `pool ${base} left ${e.left} cap ${e.cap}`);
|
||||
assert.ok(Number.isInteger(e.left) && e.left <= e.cap, `pool ${base} balance ${e.left} cap ${e.cap}`);
|
||||
assert.equal(pool.left(base), Math.max(0, e.left), 'overdrawn ownership never becomes a negative shop supply');
|
||||
assert.equal(e.left + (held.get(base) || 0), e.cap, `pool accounting ${base}: left ${e.left} + held ${held.get(base) || 0} != cap ${e.cap}`);
|
||||
}
|
||||
for (const [base, n] of held) if (!pool.has(base)) assert.equal(n, 0, `non-pool chess ${base} holds copies`);
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
// Jessica's shield placement uses the real Match actions and the client's drag-preview mirror. The only fixture
|
||||
// grant is the selected operator; its shield is generated by normal deployment. No battle/UI is simulated here.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { makeMatch } from './harness.js';
|
||||
import { placementContext, canPlace, boardTargets } from '../../public/js/ui/gameLogic/placement.js';
|
||||
import { planLayout } from '../../server/match/bot.js';
|
||||
|
||||
const SLOT = 'chess_char_5_diy1_a';
|
||||
const JESCA = 'char_1034_jesca2';
|
||||
const SHIELD = 'token_10032_jesca2_jckshd';
|
||||
const board = (row, col) => ({ area: 'board', row, col });
|
||||
const move = (h, uid, to, dir) => h.m.handle('p_0', { t: 'g.move', uid, to, ...(dir ? { dir } : {}) });
|
||||
const stackOf = (ps, owner) => [...ps.hand, ...ps.temp].find((p) => p?.kind === 'token' && p.ownerUid === owner.uid);
|
||||
const ctx = (h, ps) => placementContext({ priv: ps.privateView(), stage: h.m.stage, editable: true,
|
||||
getChess: (id) => ps.gd.chess(id), getToken: (id) => ps.gd.token(id), backups: ps.gd.raw.backups });
|
||||
|
||||
function prep(t, { row = 10, col = 4, skillIndex = 0, uniEquipId = null } = {}) {
|
||||
const h = makeMatch({ mode: 'solo', seed: 11, seats: [{ seat: 0, playerId: 'p_0', name: 'P0', isBot: false,
|
||||
connected: true, diy: { [SLOT]: { charId: JESCA, skillIndex, uniEquipId } } }] }).start();
|
||||
t.after(() => h.m.dispose());
|
||||
h.toPrep(1);
|
||||
assert.equal(h.m.stageId, 'act2autochess_m04');
|
||||
const ps = h.ps('p_0');
|
||||
const owner = ps.acquireChess(SLOT, { source: 'grant' });
|
||||
assert.ok(owner);
|
||||
assert.deepEqual(move(h, owner.uid, board(row, col)), { ok: true });
|
||||
assert.equal(stackOf(ps, owner)?.id, SHIELD);
|
||||
return { h, ps, owner };
|
||||
}
|
||||
|
||||
test('Jessica: hand shield refuses diagonal and distant tiles, with unchanged state and matching drag highlights', (t) => {
|
||||
const { h, ps, owner } = prep(t, { row: 9, col: 5 });
|
||||
const shield = stackOf(ps, owner);
|
||||
for (const [row, col] of [[9, 7], [10, 4], [11, 5], [12, 3]]) {
|
||||
const before = structuredClone(ps.privateView());
|
||||
assert.equal(canPlace(ctx(h, ps), shield.uid, board(row, col)).code, 'BAD_TILE', `client ${row},${col}`);
|
||||
assert.equal(move(h, shield.uid, board(row, col)).error, 'BAD_TILE', `server ${row},${col}`);
|
||||
assert.deepEqual(ps.privateView(), before, 'rejected placement changes no inventory or board state');
|
||||
}
|
||||
assert.deepEqual(boardTargets(ctx(h, ps), shield.uid).legal.sort(), [[9, 4], [9, 6], [10, 5]].sort());
|
||||
h.invariants();
|
||||
});
|
||||
|
||||
test('Jessica: the server rejects distant and diagonal placements even when the client sends them directly', (t) => {
|
||||
const { h, ps, owner } = prep(t, { row: 9, col: 5 });
|
||||
const shield = stackOf(ps, owner);
|
||||
for (const [row, col] of [[9, 7], [10, 4]]) {
|
||||
assert.equal(move(h, shield.uid, board(row, col)).error, 'BAD_TILE');
|
||||
assert.equal(stackOf(ps, owner), shield);
|
||||
assert.ok(!ps.board.has(`${row},${col}`));
|
||||
}
|
||||
h.invariants();
|
||||
});
|
||||
|
||||
test('Jessica: all four orthogonal tiles are legal regardless of her facing, skill or selected module', (t) => {
|
||||
for (const [skillIndex, uniEquipId] of [[0, null], [1, 'uniequip_002_jesca2'], [2, 'uniequip_003_jesca2']]) {
|
||||
const { h, ps, owner } = prep(t, { skillIndex, uniEquipId });
|
||||
for (const dir of ['UP', 'RIGHT', 'DOWN', 'LEFT']) {
|
||||
assert.deepEqual(move(h, owner.uid, board(10, 4), dir), { ok: true });
|
||||
for (const [row, col] of [[9, 4], [11, 4], [10, 3], [10, 5]]) {
|
||||
const shield = stackOf(ps, owner);
|
||||
assert.equal(canPlace(ctx(h, ps), shield.uid, board(row, col)).ok, true);
|
||||
assert.deepEqual(move(h, shield.uid, board(row, col)), { ok: true });
|
||||
assert.deepEqual(move(h, ps.board.get(`${row},${col}`).uid, { area: 'hand', idx: ps.hand.findIndex((p) => !p) }), { ok: true });
|
||||
}
|
||||
}
|
||||
h.invariants();
|
||||
}
|
||||
});
|
||||
|
||||
test('Jessica: a deployed shield cannot move or be swapped to a distant tile; adjacent moves and owner swaps work', (t) => {
|
||||
const { h, ps, owner } = prep(t);
|
||||
assert.deepEqual(move(h, stackOf(ps, owner).uid, board(10, 5)), { ok: true });
|
||||
const shield = ps.board.get('10,5');
|
||||
for (const [row, col] of [[11, 5], [9, 7]]) {
|
||||
assert.equal(canPlace(ctx(h, ps), shield.uid, board(row, col)).code, 'BAD_TILE');
|
||||
assert.equal(move(h, shield.uid, board(row, col)).error, 'BAD_TILE');
|
||||
assert.equal(ps.board.get('10,5'), shield);
|
||||
}
|
||||
const other = ps.acquireChess('chess_char_1_02_a', { source: 'grant' });
|
||||
assert.deepEqual(move(h, other.uid, board(9, 7)), { ok: true });
|
||||
for (const [uid, target] of [[shield.uid, board(9, 7)], [other.uid, board(10, 5)]]) {
|
||||
assert.equal(canPlace(ctx(h, ps), uid, target).code, 'BAD_TILE');
|
||||
assert.equal(move(h, uid, target).error, 'BAD_TILE');
|
||||
}
|
||||
assert.equal(canPlace(ctx(h, ps), shield.uid, board(11, 4)).ok, true);
|
||||
assert.deepEqual(move(h, shield.uid, board(11, 4)), { ok: true });
|
||||
assert.equal(canPlace(ctx(h, ps), shield.uid, board(10, 4)).ok, true);
|
||||
assert.deepEqual(move(h, shield.uid, board(10, 4)), { ok: true });
|
||||
assert.equal(ps.board.get('11,4'), owner);
|
||||
assert.equal(ps.board.get('10,4'), shield);
|
||||
h.invariants();
|
||||
});
|
||||
|
||||
test('Jessica: owner reorientation keeps the shield; moving her returns it and withdrawal removes it', (t) => {
|
||||
const { h, ps, owner } = prep(t);
|
||||
assert.deepEqual(move(h, stackOf(ps, owner).uid, board(10, 5)), { ok: true });
|
||||
const shield = ps.board.get('10,5');
|
||||
assert.equal(canPlace(ctx(h, ps), shield.uid, board(10, 5)).action, 'orient');
|
||||
assert.deepEqual(move(h, shield.uid, board(10, 5), 'LEFT'), { ok: true });
|
||||
assert.deepEqual(move(h, owner.uid, board(10, 4), 'UP'), { ok: true });
|
||||
assert.equal(ps.board.get('10,5'), shield);
|
||||
assert.deepEqual(move(h, owner.uid, board(9, 4)), { ok: true });
|
||||
assert.ok(!ps.board.has('10,5'));
|
||||
const returned = stackOf(ps, owner);
|
||||
assert.equal(returned.count, 1);
|
||||
assert.equal(canPlace(ctx(h, ps), returned.uid, board(10, 5)).code, 'BAD_TILE');
|
||||
assert.equal(move(h, returned.uid, board(10, 5)).error, 'BAD_TILE');
|
||||
assert.deepEqual(move(h, returned.uid, board(9, 5)), { ok: true });
|
||||
assert.deepEqual(move(h, owner.uid, { area: 'hand', idx: ps.hand.findIndex((p) => !p) }), { ok: true });
|
||||
assert.ok(![...ps.board.values(), ...ps.hand, ...ps.temp].some((p) => p?.ownerUid === owner.uid));
|
||||
h.invariants();
|
||||
});
|
||||
|
||||
test('Jessica: bots plan her shield on an orthogonal tile', (t) => {
|
||||
const { h, ps, owner } = prep(t);
|
||||
const shield = stackOf(ps, owner);
|
||||
const plan = planLayout(h.m, ps, [shield], undefined, { occupied: new Set(ps.board.keys()) });
|
||||
assert.ok(['9,4', '11,4', '10,3', '10,5'].includes(plan.get(shield.uid)));
|
||||
h.invariants();
|
||||
});
|
||||
|
||||
test('other summons keep their own placement rules, including a medical drone far from its owner', (t) => {
|
||||
const { h, ps } = prep(t);
|
||||
const silence = ps.acquireChess('chess_char_2_02_a', { source: 'grant' });
|
||||
assert.deepEqual(move(h, silence.uid, board(9, 3)), { ok: true });
|
||||
const drone = stackOf(ps, silence);
|
||||
assert.ok(drone);
|
||||
assert.equal(canPlace(ctx(h, ps), drone.uid, board(12, 8)).ok, true);
|
||||
assert.deepEqual(move(h, drone.uid, board(12, 8)), { ok: true });
|
||||
h.invariants();
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { startServer } from '../../server/index.js';
|
||||
import { TestClient } from '../helpers/wsClient.js';
|
||||
import { PHASE, EMOTES } from '../../shared/constants.js';
|
||||
|
||||
const ok = async (c, msg) => {
|
||||
const r = await c.request(msg);
|
||||
assert.equal(r.t, 'ok', JSON.stringify(r));
|
||||
};
|
||||
|
||||
// Exercise the real lobby/session routing after the match has eliminated a leaker. Only the battle outcome is
|
||||
// supplied directly: these tests target room ownership, not a particular operator composition or wave duration.
|
||||
for (const leaverSeat of [0, 1]) for (const phase of [PHASE.PREP, PHASE.COMBAT, PHASE.UNITE]) {
|
||||
test(`an eliminated ${leaverSeat === 0 ? 'host' : 'guest'} leaving ${phase} cannot evict the survivor`, async (t) => {
|
||||
const errors = [], clients = [];
|
||||
const srv = await startServer({ port: 0, host: '127.0.0.1', seedFn: () => 123,
|
||||
log: { info() {}, warn() {}, debug() {}, error: (...a) => errors.push(a.map(String).join(' ')) } });
|
||||
t.after(async () => { await Promise.all(clients.map(c => c.terminate())); await srv.close(); assert.deepEqual(errors, []); });
|
||||
for (const name of ['Host', 'Guest']) {
|
||||
const c = await TestClient.connect(`ws://127.0.0.1:${srv.port}/ws`);
|
||||
clients.push(c); const w = await c.hello(name); c.id = w.playerId; c.token = w.token;
|
||||
}
|
||||
const [host, guest] = clients;
|
||||
await ok(host, { t: 'room.create', mode: 'coop', difficulty: 'NORMAL' });
|
||||
const state = await host.waitFor('room.state');
|
||||
await ok(guest, { t: 'room.join', code: state.code });
|
||||
await ok(guest, { t: 'room.ready', ready: true });
|
||||
await ok(host, { t: 'room.start' });
|
||||
await host.waitFor('m.public', p => p.phase === PHASE.INFO_CHECK);
|
||||
const room = srv.lobby.rooms.get(state.code), m = room.match;
|
||||
// Stop phase progression without replacing the actual match/lobby/session implementation.
|
||||
m.setDeadline(0); m.phase = phase; m.round = 2;
|
||||
for (const ps of m.order) ps.lp = 20;
|
||||
const leaver = clients[leaverSeat], survivor = clients[1 - leaverSeat];
|
||||
const lost = m.players.get(leaver.id); lost.lp = 0; lost.eliminate(2);
|
||||
await ok(leaver, { t: 'g.leave' });
|
||||
const leftAgain = await leaver.request({ t: 'room.leave' });
|
||||
assert.equal(leftAgain.code, 'NOT_IN_ROOM', 'the UI tolerates this only on the departing client');
|
||||
const retained = await survivor.waitFor('room.state', r => r.code === state.code && !r.seats.some(s => s?.playerId === leaver.id && s.connected));
|
||||
assert.ok(retained.seats.some(s => s?.playerId === survivor.id && s.connected));
|
||||
assert.equal(room.seatOf(leaver.id).left, true);
|
||||
assert.equal(retained.hostId, survivor.id);
|
||||
assert.equal(srv.lobby.registry.byId(survivor.id).roomCode, state.code);
|
||||
assert.equal(room.match, m); assert.equal(m.ended, false);
|
||||
await ok(survivor, { t: 'g.emote', id: EMOTES[0] });
|
||||
assert.equal(survivor.log.some(x => x.t === 'room.closed'), false);
|
||||
await survivor.terminate();
|
||||
const restored = await TestClient.connect(`ws://127.0.0.1:${srv.port}/ws`); clients.push(restored);
|
||||
const w = await restored.hello('Survivor', survivor.token); assert.equal(w.playerId, survivor.id);
|
||||
assert.equal((await restored.waitFor('room.state')).code, state.code);
|
||||
await ok(restored, { t: 'g.autoplay', on: false });
|
||||
});
|
||||
}
|
||||
Loaded 100 of 129 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user