Playtest #6 follow-up (v2.5.1): leader HP review — 直接乘算, one debuff instance, 999 layer cap, 限伤; elite to the board; 假想敌:胄 kit

- co-op leaders died within seconds: leader HP is the official data (40 values pinned); bond / strategy / item /
  机变 / 特质 "+X%" bonuses now add up (PRTS 直接乘算) instead of compounding; a same-named enemy debuff keeps one
  (strongest) instance per target (奥术 across a pair field); leader parts pass damage 1:1; fixed co-op pool (the user's call)
- official limits read from the client: each bond stops at 999 layers (MAX_GARRISON_STACK); a leader hit of 300000+ in
  boss / hidden battles is cancelled (MAX_BATTLE_DAMAGE, 限伤) — shared/constants.js switches, research 11
- a merge that consumes a deployed copy puts the elite on that tile and facing (user-confirmed); gold target highlight,
  promotion effect
- 假想敌:胄 per PRTS: one shell, shells within range, any drone death costs 2 % max HP, 剑/锤 sorties and 瘫痪
- DESIGN §20.10–§20.14; 2842 unit tests, all 28 browser suites green; Chrome = Node on boss fields
This commit is contained in:
sganggs committed 2026-10-02 04:19:28 +08:00
1 parent 6464e4b46a
commit 620f88c0d8
81 files changed
+4194 -447

No files matched your search

+1 -1
View File
@@ -114,7 +114,7 @@ npm start # http://localhost:3000
## 测试 ## 测试
```bash ```bash
node --test # 单元 + 集成测试(约 2750 项;缺少素材 / 浏览器的用例会自动跳过) node --test # 单元 + 集成测试(约 2840 项;缺少素材 / 浏览器的用例会自动跳过)
SP_E2E=1 node --test test/ui/mock.e2e.test.js # 需要本机 Chrome(CHROME_PATH 可指定路径) SP_E2E=1 node --test test/ui/mock.e2e.test.js # 需要本机 Chrome(CHROME_PATH 可指定路径)
SP_REAL_E2E=1 node --test test/ui/real.e2e.test.js # 需要 Chrome + 已下载素材 SP_REAL_E2E=1 node --test test/ui/real.e2e.test.js # 需要 Chrome + 已下载素材
``` ```
+1 -1
View File
@@ -1 +1 @@
{"yanShip":{"bondId":"yanShip","name":"炎","identifier":1,"isCore":true,"bondType":"SEASON","bondOrder":2,"powerIdList":["yan","sui","lungmen","lee","lgd"],"iconId":"icon_yanShip","activeCount":3,"thresholds":[3,6,9],"maxCount":null,"thresholdTemplate":"count_threshold_upward","countMode":"BOARD","countsHand":false,"countsGoldenOnly":false,"activeType":"BATTLE","isActiveInDeck":false,"noStack":false,"weight":10,"maxInactiveBondCount":-1,"layerMilestones":[],"desc":"【炎】干员攻击力提升(受层数影响)\n<在场6名不同【炎】干员>战斗开始时召唤可自主行动的“炎佑”,其攻击力生命值分别为开战时【炎】干员攻击力生命值总和的30%,“炎佑”同时攻击3个目标,可使用“祛恶之焰”,攻击时附带攻击力一定比例的灼燃损伤且周围敌人受到一定比例的元素脆弱\n<在场9名不同【炎】干员>改为召唤2只“炎佑”,攻击力提升至1.5倍,受到的伤害-90%","descRaw":"【炎】干员<@ba.vup>攻击力</>提升<@autochess.gray>(受层数影响)</>\n<在场<@autochess.dgreen>6</>名不同【炎】干员>战斗开始时召唤可自主行动的<@ba.vup>“炎佑”</>,其攻击力生命值分别为开战时【炎】干员攻击力生命值总和的30%,“炎佑”同时攻击3个目标,可使用<@ba.vup>“祛恶之焰”</>,攻击时附带攻击力一定比例的灼燃损伤且周围敌人受到一定比例的元素脆弱\n<在场<@autochess.dgreen>9</>名不同【炎】干员>改为召唤2只<@ba.vup>“炎佑”</>,攻击力提升至<@ba.vup>1.5</>倍,受到的伤害<@ba.vup>-90%</>","effectId":"bondeffect_yan","effectName":"炎","effectDesc":"【炎】干员攻击力+{0:0%}(受层数影响)\n<在场6名不同【炎】干员>战斗开始时收集【炎】干员部分属性召唤“炎佑”(其攻击力和生命值为【炎】干员攻击力和生命值总和的30%);“炎佑”同时攻击3个目标,攻击附带20%攻击力的灼燃损伤,周围敌人受到20%的元素脆弱,周期性使用“祛恶之焰”\n<在场9名不同【炎】干员>改为召唤2只“炎佑”;“炎佑”攻击力提升至1.5倍,受到的伤害-90%","effectDescRaw":"【炎】干员攻击力<@ba.vup>+{0:0%}</><@ba.acrem>(受层数影响)</>\n<在场<@ba.vup>6</>名不同【炎】干员>战斗开始时收集【炎】干员部分属性召唤“炎佑”(其攻击力和生命值为【炎】干员攻击力和生命值总和的30%);“炎佑”同时攻击3个目标,攻击附带20%<@ba.vup>攻击力</>的<@ba.vup>灼燃损伤</>,周围敌人受到20%的<@ba.vup>元素脆弱</>,周期性使用“祛恶之焰”\n<在场<@ba.vup>9</>名不同【炎】干员>改为召唤2只“炎佑”;“炎佑”攻击力提升至1.5倍,受到的伤害-90%","effectDescParams":[{"index":0,"format":"0%","base":"base_atk","perStack":"atk_per_stack"}],"bb":{"base_atk":0.23,"atk_per_stack":0.009,"power_bond_char_cnt":6,"ex_bond_char_cnt":9,"damage_resistance":0.9,"atk":1.5},"bbStr":{"key":"act1autochess_bond_eff_yan"},"buffs":[{"key":"env_gbuff_new","bb":{"base_atk":0.23,"atk_per_stack":0.009,"power_bond_char_cnt":6,"ex_bond_char_cnt":9,"damage_resistance":0.9,"atk":1.5},"bbStr":{"key":"act1autochess_bond_eff_yan"}}],"baseParams":["base_atk"],"perStackParams":["atk_per_stack"],"members":["chess_char_1_03_a","chess_char_2_04_a","chess_char_3_03_a","chess_char_3_04_a","chess_char_4_15_a","chess_char_4_17_a","chess_char_5_03_a","chess_char_5_12_a","chess_char_5_23_a","chess_char_6_03_a","chess_char_6_15_a"],"visibleMembers":["chess_char_1_03_a","chess_char_2_04_a","chess_char_3_03_a","chess_char_3_04_a","chess_char_4_17_a","chess_char_5_03_a","chess_char_5_12_a","chess_char_5_23_a","chess_char_6_03_a","chess_char_6_15_a"],"spec":{"tiers":[{"need":"3 distinct 炎 on board","effect":"炎 members (+调和 members) ATK x(1 + 0.23 + 0.009*L)."},{"need":"6 distinct","effect":"At battle start spawn 1 \"炎佑\" (enemy_9012_acloon template, allied flying ranged unit, not blockable, moves on its own). ATK = 0.30 x sum(ATK of 炎 operators on board at battle start), maxHP = 0.30 x sum(maxHP of those operators). Template: DEF 200, RES 0, BAT 2.5 s, ASPD 100, attack range radius 2.0 tiles, magic damage, hits up to 3 targets; every hit adds 灼燃损伤 (burn elemental damage) = 20% of its ATK and applies 元素脆弱 (elemental damage taken x1.2) to enemies within 0.8 tiles of the target. Skill \"祛恶之焰\": cooldown 15 s (first cast 15 s after spawn), channels a flame on one enemy in range dealing 60% ATK magic damage per second to it and to enemies within 1.0 tile, for up to 20 s."},{"need":"9 distinct","effect":"Spawn 2 炎佑 instead; each has ATK x1.5 and takes 90% less damage (damage_resistance 0.9)."}],"formulas":{"atkMultiplier":"1 + 0.23 + 0.009*L"},"caps":"none","layerGain":"Economy-driven: 惊蛰 获得时 +shop level (no activation needed); 小满 休整期结束 +1 per operator gained this rouLine truncated {"yanShip":{"bondId":"yanShip","name":"炎","identifier":1,"isCore":true,"bondType":"SEASON","bondOrder":2,"powerIdList":["yan","sui","lungmen","lee","lgd"],"iconId":"icon_yanShip","activeCount":3,"thresholds":[3,6,9],"maxCount":null,"thresholdTemplate":"count_threshold_upward","countMode":"BOARD","countsHand":false,"countsGoldenOnly":false,"activeType":"BATTLE","isActiveInDeck":false,"noStack":false,"weight":10,"maxInactiveBondCount":-1,"layerMilestones":[],"desc":"【炎】干员攻击力提升(受层数影响)\n<在场6名不同【炎】干员>战斗开始时召唤可自主行动的“炎佑”,其攻击力生命值分别为开战时【炎】干员攻击力生命值总和的30%,“炎佑”同时攻击3个目标,可使用“祛恶之焰”,攻击时附带攻击力一定比例的灼燃损伤且周围敌人受到一定比例的元素脆弱\n<在场9名不同【炎】干员>改为召唤2只“炎佑”,攻击力提升至1.5倍,受到的伤害-90%","descRaw":"【炎】干员<@ba.vup>攻击力</>提升<@autochess.gray>(受层数影响)</>\n<在场<@autochess.dgreen>6</>名不同【炎】干员>战斗开始时召唤可自主行动的<@ba.vup>“炎佑”</>,其攻击力生命值分别为开战时【炎】干员攻击力生命值总和的30%,“炎佑”同时攻击3个目标,可使用<@ba.vup>“祛恶之焰”</>,攻击时附带攻击力一定比例的灼燃损伤且周围敌人受到一定比例的元素脆弱\n<在场<@autochess.dgreen>9</>名不同【炎】干员>改为召唤2只<@ba.vup>“炎佑”</>,攻击力提升至<@ba.vup>1.5</>倍,受到的伤害<@ba.vup>-90%</>","effectId":"bondeffect_yan","effectName":"炎","effectDesc":"【炎】干员攻击力+{0:0%}(受层数影响)\n<在场6名不同【炎】干员>战斗开始时收集【炎】干员部分属性召唤“炎佑”(其攻击力和生命值为【炎】干员攻击力和生命值总和的30%);“炎佑”同时攻击3个目标,攻击附带20%攻击力的灼燃损伤,周围敌人受到20%的元素脆弱,周期性使用“祛恶之焰”\n<在场9名不同【炎】干员>改为召唤2只“炎佑”;“炎佑”攻击力提升至1.5倍,受到的伤害-90%","effectDescRaw":"【炎】干员攻击力<@ba.vup>+{0:0%}</><@ba.acrem>(受层数影响)</>\n<在场<@ba.vup>6</>名不同【炎】干员>战斗开始时收集【炎】干员部分属性召唤“炎佑”(其攻击力和生命值为【炎】干员攻击力和生命值总和的30%);“炎佑”同时攻击3个目标,攻击附带20%<@ba.vup>攻击力</>的<@ba.vup>灼燃损伤</>,周围敌人受到20%的<@ba.vup>元素脆弱</>,周期性使用“祛恶之焰”\n<在场<@ba.vup>9</>名不同【炎】干员>改为召唤2只“炎佑”;“炎佑”攻击力提升至1.5倍,受到的伤害-90%","effectDescParams":[{"index":0,"format":"0%","base":"base_atk","perStack":"atk_per_stack"}],"bb":{"base_atk":0.23,"atk_per_stack":0.009,"power_bond_char_cnt":6,"ex_bond_char_cnt":9,"damage_resistance":0.9,"atk":1.5},"bbStr":{"key":"act1autochess_bond_eff_yan"},"buffs":[{"key":"env_gbuff_new","bb":{"base_atk":0.23,"atk_per_stack":0.009,"power_bond_char_cnt":6,"ex_bond_char_cnt":9,"damage_resistance":0.9,"atk":1.5},"bbStr":{"key":"act1autochess_bond_eff_yan"}}],"baseParams":["base_atk"],"perStackParams":["atk_per_stack"],"members":["chess_char_1_03_a","chess_char_2_04_a","chess_char_3_03_a","chess_char_3_04_a","chess_char_4_15_a","chess_char_4_17_a","chess_char_5_03_a","chess_char_5_12_a","chess_char_5_23_a","chess_char_6_03_a","chess_char_6_15_a"],"visibleMembers":["chess_char_1_03_a","chess_char_2_04_a","chess_char_3_03_a","chess_char_3_04_a","chess_char_4_17_a","chess_char_5_03_a","chess_char_5_12_a","chess_char_5_23_a","chess_char_6_03_a","chess_char_6_15_a"],"spec":{"tiers":[{"need":"3 distinct 炎 on board","effect":"炎 members (+调和 members) ATK x(1 + 0.23 + 0.009*L)."},{"need":"6 distinct","effect":"At battle start spawn 1 \"炎佑\" (enemy_9012_acloon template, allied flying ranged unit, not blockable, moves on its own). ATK = 0.30 x sum(ATK of 炎 operators on board at battle start), maxHP = 0.30 x sum(maxHP of those operators). Template: DEF 200, RES 0, BAT 2.5 s, ASPD 100, attack range radius 2.0 tiles, magic damage, hits up to 3 targets; every hit adds 灼燃损伤 (burn elemental damage) = 20% of its ATK and applies 元素脆弱 (elemental damage taken x1.2) to enemies within 0.8 tiles of the target. Skill \"祛恶之焰\": cooldown 15 s (first cast 15 s after spawn), channels a flame on one enemy in range dealing 60% ATK magic damage per second to it and to enemies within 1.0 tile, for up to 20 s."},{"need":"9 distinct","effect":"Spawn 2 炎佑 instead; each has ATK x1.5 and takes 90% less damage (damage_resistance 0.9)."}],"formulas":{"atkMultiplier":"1 + 0.23 + 0.009*L"},"caps":"none","layerGain":"Economy-driven: 惊蛰 获得时 +shop level (no activation needed); 小满 休整期结束 +1 per operator gained this rouLine truncated
+1 -1
View File
@@ -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,"coop":1,"aliveScaling":false,"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":"act1auLine 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,"coop":1,"aliveScaling":false,"aliveFull":4,"aliveAssumed":true,"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,"bossOvertimeAfLine truncated
+149 -3
View File
@@ -13,8 +13,24 @@ kinds / counts and routes must follow the official game; research 08 §6–§7,
`k` copies, no kept fly placeholders; verified against all 429 official entry × round compositions and the official `k` copies, no kept fly placeholders; verified against all 429 official entry × round compositions and the official
count distribution (同盟 险境 R3 ≤ 10 enemies, R13 ≈ 37 — the old generator averaged 28 / 81); count distribution (同盟 险境 R3 ≤ 10 enemies, R13 ≈ 37 — the old generator averaged 28 / 81);
* stats = the PRTS per-round `enemyScale` table only (`data/config.json`, 终极 ×1.15 speed from R3); * stats = the PRTS per-round `enemyScale` table only (`data/config.json`, 终极 ×1.15 speed from R3);
* leader pool = `bloodPoint[difficulty]` (co-op; one pool for the split leaders, no alive-player factor — solo keeps * leader pool = `bloodPoint[difficulty]` in co-op — one pool for every boss field, whatever the number of alive players
×0.25, flagged [ASSUMED] in data); (config `bossHpScale.aliveScaling` would make it × alive / 4; off — the user chose the fixed pool, DESIGN §20.10); solo keeps ×0.25,
flagged [ASSUMED] in data;
* leader parts and drones (DESIGN §20.10): a 剑 / 锤 / 碎铳之簧 passes every damage it takes to its leader 1:1 (PRTS "等量的
无来源生命流失"; until 2026-10-01 half, and a 剑 / 锤 only while grounded — the dive hits [ASSUMED], the handbook says
"被击落时"); a 胄 drone that dies (whoever kills it, DESIGN §20.13) costs 2 % of the leader's shown max HP = the pool (unchanged from v2.5; which "最大生命值" the
official client reads is [ASSUMED] — the review round tried the unit's data 600 000, which made drones 19–78 % of a solo
标准 fight, and went back, `bosses.js DRONE_LINK_BASE`);
* same-named effects on an enemy: one instance, the strongest — 奥术 (the two players of a pair field compete for it;
until 2026-10-01 one per player, multiplied: ×30 arts damage taken on a leader at ~250 layers each), 灵知 坚冰,
莱恩哈特 / 缄默德克萨斯 RES cuts (until 2026-10-01 one per copy of the operator);
* 坚守 thorns: 无来源 but credited to the member hit (until 2026-10-01 nobody's: up to 19 % of a co-op leader pool and
88 % of a solo one missing from the per-player boss damage; kill times unchanged);
* bond layers stop at 999 per bond (`BOND_LAYER_CAP`, official: the client's `MAX_GARRISON_STACK`, §7 / research 11; until
2026-10-01 no cap, so per-layer bonuses kept growing past anything the official game reaches);
* player-side "+X%" ATK / DEF / max HP of bonds, strategies, equipment, 机变 cards and per-layer 特质 = 直接乘算, summed
with each other and with skills (PRTS 盟约记录 / 游戏数据基础; DESIGN §20.10) — until 2026-10-01 each compounded, which
made every measurement below the 2026-10-01 note (§3) too strong for the player side (most in late rounds and leader fights);
* bounties: no extra ×0.7 in solo (the 70 % base is already in `enemyScale`), spawned with the template's first normal * bounties: no extra ×0.7 in solo (the 70 % base is already in `enemyScale`), spawned with the template's first normal
action (research 08 §5); action (research 08 §5);
* routes = the official 4-direction flow field with crates at cost 1000 (research 08 §3), act1 m02 without crates. * routes = the official 4-direction flow field with crates at cost 1000 (research 08 §3), act1 m02 without crates.
@@ -104,7 +120,9 @@ the preview being read by a human in 90 s, and losing LP early (fewer rounds to
| Faction mixing | was: every round a ground entry + a FLY entry | **official: one entry per round from a 15-slot type schedule** | | Faction mixing | was: every round a ground entry + a FLY entry | **official: one entry per round from a 15-slot type schedule** |
| `MOVE_SCALE` 0.5 | every level's `moveMultiplier` 0.5 (05 §2.2) | kept | | `MOVE_SCALE` 0.5 | every level's `moveMultiplier` 0.5 (05 §2.2) | kept |
| **Combat time limit unit** | `maxPlayTime` read as game seconds [ASSUMED] | **wrong — real seconds; fixed** (§2.1) | | **Combat time limit unit** | `maxPlayTime` read as game seconds [ASSUMED] | **wrong — real seconds; fixed** (§2.1) |
| Boss pool | bloodPoint DATA; co-op "敌方领袖的总生命值不变" | **co-op = bloodPoint, no alive scaling**; solo ×0.25 kept [ASSUMED]; the solo 标准 ×0.6 removed | | Boss pool | bloodPoint DATA = the current game data (activity_table act2autochess bossInfoDict; the local cache equals upstream ArknightsGameData master, checked 2026-10-01). PRTS 盟约记录's leader table is the older 11月18日 revision: it matches 常规 and most of 险境, but differs for 铳 险境 (450 000 vs 400 000) and 胄 / 铳 / 萨米 绝境 (1 600 000 / 870 000 / 1 800 000 vs 1 800 000 / 800 000 / 2 000 000), and has no 终极 column or 卢西恩 row; one pool for every field ("所有人将一起对敌方领袖造成伤害"); "敌方领袖的总生命值不变" = the mirrored copies of a pair field share it (notice 5114) | **co-op = bloodPoint whatever the alive count**; × alive / 4 is a config switch (`aliveScaling`, off: 巴哈姆特 12294 "聯機隊友…變少,最後boss血條也會變少" is one community note without a proportion, awaiting the user's recall); solo ×0.25 kept [ASSUMED]; the solo 标准 ×0.6 removed |
| Leader parts / 胄 drones / same-named debuffs / 坚守 thorns | PRTS 碎铳之簧, “斩胄之剑”, “破胄之锤” "受到伤害时令…受到等量的无来源生命流失"; PRTS 假想敌:胄 "该妖怪死亡时令假想敌:胄受到最大生命值2%的真实伤害" (which max HP: [ASSUMED]); PRTS 作战机制 同名buff默认只表现出一个, 巴哈姆特 12316 "共享型buff會跟對面搶"; 坚守 "伤害来源受到(850+10×L)点法术伤害" | **parts 1:1 (was ½, 剑/锤 only grounded); drone 2 % of the pool (unchanged; the unit-HP reading was tried and reverted); 奥术 / 灵知 坚冰 / 莱恩哈特 & 缄默德克萨斯 RES cut: one instance per target (was one per player / copy); thorns credited to the member (was to nobody)** — 2026-10-01, DESIGN §20.10 |
| Bonus stacking | was: each bond / strategy / item / 机变 / 特质 "+X%" its own ×(1 + x) (research 02 §2.1) | **official 直接乘算: summed with each other and with skill "+X%"** (PRTS 盟约记录 "…属性加成均为直接乘算", 游戏数据基础 D_t = Σtᵢ); 2026-10-01, DESIGN §20.10 |
| Solo bounties ×0.7 | 上半 11/18 note = the global solo base | **removed** (already in enemyScale) | | Solo bounties ×0.7 | 上半 11/18 note = the global solo base | **removed** (already in enemyScale) |
| Bounty spawn timing | was: the bounty units appended after the host action's own units | **official: inserted among them at floor((i+1)·len/(n+1)), then the list spread over the window** (client `_InsertSpActionToNormal` + `_CalculateActionPredelay`, decoded; `waves.js withBounties`) | | Bounty spawn timing | was: the bounty units appended after the host action's own units | **official: inserted among them at floor((i+1)·len/(n+1)), then the list spread over the window** (client `_InsertSpActionToNormal` + `_CalculateActionPredelay`, decoded; `waves.js withBounties`) |
| 联防 spawn timing | was: owner k at +min(0.5·k, 5) s, each owner's units over the whole window [ASSUMED] | **official (decoded `_CalculateActionPredelayConsiderUid`): owner k at +0.5·k s, unit step min(max(W/M, 0.05·W), 5 s), M = largest owner group** — a lone leaker's units now come 5 s apart instead of up to 40 s | | 联防 spawn timing | was: owner k at +min(0.5·k, 5) s, each owner's units over the whole window [ASSUMED] | **official (decoded `_CalculateActionPredelayConsiderUid`): owner k at +0.5·k s, unit step min(max(W/M, 0.05·W), 5 s), M = largest owner group** — a lone leaker's units now come 5 s apart instead of up to 40 s |
@@ -171,6 +189,14 @@ multipliers, their parts all of them): normal rounds unchanged, leader rounds an
| 同盟 绝境 | pool 1.68M · win 88 % · 99 % · 32.6 s | pool 3.42M · win 88 % · 94 % · 100.9 s | | 同盟 绝境 | pool 1.68M · win 88 % · 99 % · 32.6 s | pool 3.42M · win 88 % · 94 % · 100.9 s |
| 同盟 终极 | pool 3.70M · win 88 % · 95 % · 41.9 s | pool 6.08M · win 75 % · 87 % · 92.6 s | | 同盟 终极 | pool 3.70M · win 88 % · 95 % · 41.9 s | pool 6.08M · win 75 % · 87 % · 92.6 s |
**2026-10-01 — 假想敌:胄 audit** (content/bosses.js `kitHelm` / `kitBlade`, PRTS): one 刺胄之弹 below 20 % as well
(能力修正), shells only at operators in range (boss_1 too), any drone death costs 2 %, 剑 / 锤 sorties per PRTS (【瘫痪】
10 s instead of 20 s, 100 % of their damage passed to 胄 also during the dive instead of 50 % while grounded, a fresh copy
after every sortie, so 掷 comes back after its initial 25 / 55 s instead of the 80 s cooldown; 锤 hits 150 % ATK).
Same-seed `--boss-only` before → after: boss_1 unchanged (≤ 0.5 s); boss_8 独立 绝境 44.7 → 45.2 s, 独立 终极 win
2/3 → 3/3, 同盟 绝境 23.3 → 23.7 s, 同盟 终极 72.0 → 64.2 s (n = 3–5 per cell). The leader columns above are from an
earlier commit (a current run differs for every leader) and were not refreshed here.
Reading: the early game is easy everywhere (official R1–R3 bring 3–10 enemies); leaks concentrate on the second-half Reading: the early game is easy everywhere (official R1–R3 bring 3–10 enemies); leaks concentrate on the second-half
specials (深池逐火 TIMES embers, stealth 隐形弩手组长 / 重弩突袭者, 疯狂的逐腐兽, 掠海漂移体) and 终极 R6–R11, where co-op specials (深池逐火 TIMES embers, stealth 隐形弩手组长 / 重弩突袭者, 疯狂的逐腐兽, 掠海漂移体) and 终极 R6–R11, where co-op
boards still leak 3–6 and 联防 halves the LP cost. Solo leaders (bloodPoint × 0.25 [ASSUMED]) are the hard part of the boards still leak 3–6 and 联防 halves the LP cost. Solo leaders (bloodPoint × 0.25 [ASSUMED]) are the hard part of the
@@ -178,6 +204,97 @@ solo modes; the solo pool factor is the one open number here (research 08 §8 #2
Reproduce: `node tools/balance.mjs --mode all --difficulty ALL --tuning off [--bots 5] [--json]`. Reproduce: `node tools/balance.mjs --mode all --difficulty ALL --tuning off [--bots 5] [--json]`.
**2026-10-01 — 直接乘算 stacking and the leader pool (DESIGN §20.10).** The tables above were measured while every bond /
strategy / item "+X%" compounded. With the official summing, leader rounds of the same model (`--boss-only --boss-samples 8
--rehearsal 0`, mean kill time in **game** seconds; 2× = real): 同盟 终极 35.6 → 59.7 (隐秘核心 104.9 → 143.9, win 100 → 88 %),
同盟 绝境 25.1 → 37.0 (38.5 → 80.8), 同盟 险境 14.0 → 17.8, 独立 终极 91.1 → 128.5, 独立 绝境 42.0 → 73.7. Replaying the
same R14 lineups of 4-AI 终极 matches (seeds 1–3, every leader; the fights dumped with the old code and replayed with
both): median kill 175 → 247 real s at the bots' own layers (Σ 36–361 per player), 65 → 109 s at +100 layers per active
bond, 37 → 61 s at +200, 25 → 40 s at +400; 绝境 97 → 106, 25 → 43, 16 → 28, 10.5 → 19.7 s. Bot matches (`matchrun`, 8
seeds) pass fewer rounds: 同盟 险境 14.0 → 13.6, 绝境 12.0 → 11.4, 终极 10.1 → 9.9; 独立 险境 13.5 → 12.6 (wins 6 → 3 of 8),
绝境 12.9 → 12.6, 终极 9.5 → 9.1.
**2026-10-01 (review rounds) — 奥术 and the other same-named debuffs one instance, leader parts 1:1, fixed pool, 坚守
thorns credited.** Replays of the R14 boards of 4-AI co-op matches (seeds 7–9 × boss_1…7 × 终极 / 绝境 = 21 fights per
row, both pair fields on one pool, +L layers on every active bond; real seconds, ∞ = not killed in 200 s), v2.5 →
直接乘算 → now — measured on the boss-HP workstream's boards, **before** the elite-to-board merge (DESIGN §20.11) changed
what the bots build; the integrated build's numbers are in the 2026-10-02 table below:
| mode | +L | Σ active layers / player | median kill | fastest | kills < 10 s | kills < 20 s |
|---|---|---|---|---|---|---|
| 终极 | 0 | 251 (210–404) | ∞ → ∞ → ∞ (8 → 6 → 6 kills) | 90.7 → 126.5 → 126.5 | 0 | 0 |
| 终极 | 100 | 892 (793–1204) | 61.4 → 85.8 → 102.6 | 20.0 → 22.5 → 54.7 | 0 | 0 |
| 终极 | 200 | 1532 (1343–2004) | 30.9 → 43.4 → 56.0 | 9.2 → 9.7 → 20.1 | 1 → 1 → 0 | 9 → 4 → 0 |
| 终极 | 400 | 2813 (2443–3604) | 16.4 → 23.9 → 23.9 | 2.6 → 2.7 → 9.0 | 7 → 4 → 1 | 13 → 10 → 8 |
| 绝境 | 0 | 257 (224–423) | 66.5 → 81.3 → 81.3 | 21.7 → 41.0 → 41.0 | 0 | 0 |
| 绝境 | 100 | 900 (824–1277) | 16.0 → 23.5 → 23.5 | 10.8 → 11.7 → 11.7 | 0 | 13 → 8 → 8 |
| 绝境 | 200 | 1543 (1424–2177) | 11.7 → 14.2 → 14.2 | 7.4 → 7.8 → 8.8 | 5 → 5 → 5 | 19 → 16 → 16 |
| 绝境 | 400 | 2829 (2624–3977) | 8.4 → 9.2 → 9.2 | 2.1 → 2.0 → 2.7 | 16 → 12 → 12 | 21 |
The 奥术 fix moves the fields where both players run 奥术 (终极 seed 7: leader arts damage taken ×40 → ×7.6 at +400, kills
2.7 → 9.0 s; 9.7 → 20.1 s at +200). 胄's drones stay at 2 % of the pool (v2.5; 12–44 % of the pool damage at the bots'
layers, 2–16 % at +200): the second review round's unit-HP reading (12 000 per drone) is reverted — it made a drone 19 %
of a solo 标准 bar (solo 胄 replays, seeds 13 / 21 / 31: drones 10 → 78 % of the pool, kill 144 → 78 s); with the pool
reading the solo fights are back at v2.5 (drones 4–30 %) and only 直接乘算 slows them (标准 none / +100 / +200: kill
times = v2.5 or up to +4 s; 终极 / 绝境 +100: 16 → 25 s, 69 → 101 s, 90 → 135 s). 坚守 thorns now count for the member's
player: on the second reviewer's 28 co-op boards the unattributed share of the leader damage drops from up to 27 % (58 %
with each player's main bond at 999) to 0, a solo 标准 胄 fight's from 88 % to 0; kill times are unchanged. What still kills a 绝境 leader in
3 s at Σ ≈ 3000 layers per player is official per-layer scaling without caps (炎 +0.9 %/layer ATK, 维多利亚 ×(1.25 +
0.008 L), 精准 +1.2 %/layer, 坚守 thorns 850 + 10 L every 0.2 s) — the community reports the same in the official game
(巴哈姆特 12522: 999 谢拉格 + 301 灵巧 + 122 炎 at R14 "boss一秒死"). Since 2026-10-01 a bond stops at 999 layers
(`BOND_LAYER_CAP`, official: the client's `MAX_GARRISON_STACK`, research 11 §1 and §7 below — the game data has no such
constant, and the community never names a count above 999: 巴哈姆特 12534 "每把都能999层", "沒999層的盟約情況下"; v2.5 kept
growing). On the 42 boards of the table above it changes nothing up to
+400 (no bond passes 568); with each player's top bond at 1500 it holds that bond at 999: 终极 median 82.1 → 101 s
(16–17 of 21 killed), fastest 19.9 → 31.4 s; 绝境 median 12.2 → 19.6 s, fastest 6.7 → 8.6 s (uncapped at 2500: 终极
median 54.9 s, fastest 12.0 s; 绝境 8.2 s / 3.4 s). "限傷" (巴哈姆特 12316 "999謝絕對不要亮奧術…大初雪會被限傷
打不出來") is the official boss-hit limit, not an overflow: the client cancels a single leader hit of ≥ 300000 in boss
battles (`MAX_BATTLE_DAMAGE`, research 11 §2, `BOSS_HIT_LIMIT` — §7). An earlier reading of this note (an engine
fixed-point overflow at 2³¹, not modelled) is superseded by that binary evidence. The largest damage instance on a
leader in all the measurements here is 144 000 (a hidden 胄 drone at 终极; from an operator 141 323 at +400 layers;
245 493 with an uncapped 2500-layer bond), below the line, so the limit leaves these numbers unchanged — for these
profiles (up to +400 per bond, a top bond at 999); with two bonds or every bond at 999 it cancels hits and changes the
kill times (the 2026-10-02 table below). Hidden Core (36 fights, boss_8–10, +30 / +100 at R14): hidden 铳 kills 5 → 3 → 5 of 12
(pool dealt by 200 s 82 → 61 → 80 %; the springs' 1:1 transfer is 55–85 % of its damage); hidden 胄 10 → 6 → 9 of 12
(drones median 16–33 % of the pool damage, v2.5 12–32 %; 剑 / 锤 transfer 10–11 → 22–25 %); hidden 管 12 → 11 → 11 of 12.
**2026-10-02 — the integrated build (QA 6b, `cca11e6`: boss HP + elite to the board + official limits + the 胄 audit).**
The R14 boards of real 4-AI co-op matches, seeds 7 / 8 / 9 / 12 × boss_1…7 (28 Final Assaults per row; Hidden Core
boss_8–10, 12 per row), both pair fields on one pool, the same boards replayed on v2.5 → now. Real seconds (the QA tables
are in game seconds: ÷ 2); "cancelled" = hits stopped by 限伤 on the integrated build. Profiles: own = the bots' layers
(Σ ≈ 230 active per player), +L on every active bond (Σ ≈ 1500 / 2650–2960), top-2 = each player's two highest bonds at
999 (Σ ≈ 2050), all = every active bond at 999 (Σ ≈ 6000–7000).
| fight | profile | median kill | fastest | kills < 5 s | cancelled | biggest landed hit |
|---|---|---|---|---|---|---|
| 终极 最终攻势 | own | 206 → 258 | 88 → 114 | 0 → 0 | 0 | 72k → 72k |
| 终极 最终攻势 | +200 | 24.5 → 42.1 | 8.2 → 18.9 | 0 → 0 | 0 | 180k → 72k |
| 终极 最终攻势 | +400 | 11.3 → 16.8 | 3.7 → 9.2 | 2 → 0 | 0 | 402k → 114k |
| 终极 最终攻势 | top-2 | 28.5 → 47.8 | 4.2 → 7.5 | 2 → 0 | 6 | 828k → 274k |
| 终极 最终攻势 | all | 5.0 → 8.3 | 1.2 → 3.4 | 14 → 9 | 18 | 2.38M → 300k |
| 绝境 最终攻势 | own | 79 → 99 | 19 → 32 | 0 → 0 | 0 | 44k → 36k |
| 绝境 最终攻势 | +200 | 11.6 → 14.8 | 1.9 → 7.3 | 3 → 0 | 0 | 148k → 51k |
| 绝境 最终攻势 | +400 | 6.1 → 8.6 | 0.8 → 3.6 | 7 → 1 | 0 | 347k → 108k |
| 绝境 最终攻势 | top-2 | 9.7 → 10.0 | 1.1 → 2.6 | 9 → 7 | 7 | 398k → 267k |
| 绝境 最终攻势 | all | 2.4 → 3.7 | 0.2 → 1.7 | 26 → 20 | 10 | 1.76M → 266k |
| 终极 隐秘核心 | own | 345 → 352 | 138 → 167 | – | 0 | 144k drone |
| 终极 隐秘核心 | +200 | 49 → 80 | 25 → 42 | – | 0 | 144k drone |
| 终极 隐秘核心 | +400 | 18.6 → 27.3 | 10 → 20 | – | 0 | 144k drone |
| 终极 隐秘核心 | all | 7.2 → 14.7 | 3.7 → 8.0 | – | 77 | – |
| 绝境 隐秘核心 | +200 | 20 → 34 | – | – | 0 | – |
| 绝境 隐秘核心 | +400 | 10 → 15 | – | – | 0 | – |
| 绝境 隐秘核心 | all | 4.3 → 7.8 | 0.3 → 2.1 | – | 14 | – |
Each player's top bond requested at 1500 (held at 999 now): 终极 median 53 → 95 s, 绝境 17.5 → 33 s. Same boards: now is
1.2–2.1× slower in every row. On each version's own bot boards the medians moved only ×1.0–1.2 (终极 own 256 → 258 s,
+200 47 → 54 s; 绝境 own 81 → 99 s, +200 13 → 15 s; defeats at 终极 own 4 → 3 of 28, all boss_4 昆图斯), because the
elite-to-board rule gives the bots slightly stronger R14 boards (终极 2.12 → 2.26 elites per player, 绝境 2.14 → 2.51;
绝境 6.0 → 6.9 active bonds). Pool composition now: Final Assault drone link 3.7 % at own / 0.8 % at +400, the rest
direct, 0 % unattributed; Hidden Core drones 20.7 % / 1.8 %, 剑 / 锤 and spring transfers 16.3 % / 21.1 %. What still
kills in seconds at ≈ 2000+ layers per player is official uncapped per-layer scaling (谢拉格 vs cold ×11.34, 奥术 ×15.7,
精准 +1208.8 % ATK at 999), which 限伤 bounds per hit only. Matchrun (co-op 4 AI, rounds passed v2.5 → now): 标准 14.00 →
14.00, 险境 13.81 → 13.88, 绝境 12.41 → 12.06 (Final Assault wins 8 → 5 of 32), 终极 9.47 → 9.47.
--- ---
## 4. The tuning — removed ## 4. The tuning — removed
@@ -228,3 +345,32 @@ previous pass (old generator), kept for history. \* the bot code of this pass (
* 坚若磐石 = least LP lost (`titles.comment_3 { stat: 'lpLost', rule: 'min' }` in tuning.json; results.js supports * 坚若磐石 = least LP lost (`titles.comment_3 { stat: 'lpLost', rule: 'min' }` in tuning.json; results.js supports
`rule: 'min'` among the players still alive). `rule: 'min'` among the players still alive).
* 教鞭 / “神秘顾客” stay a random bounty, documented in docs/META.md §2.5. * 教鞭 / “神秘顾客” stay a random bounty, documented in docs/META.md §2.5.
---
## 7. Official limits: 999 layers per bond, 限伤 300000 (research 11, DESIGN §20.12)
Two official caps, now in the sim and the match (`shared/constants.js BOND_LAYER_CAP` / `BOSS_HIT_LIMIT`, 0 / Infinity =
off): a bond's layers stop at 999 (`MAX_GARRISON_STACK`); in the Final Assault / Hidden Core a single hit of ≥ 300000
on a leader is cancelled (`MAX_BATTLE_DAMAGE`: 0 damage, nothing to the pool — not a clamp).
- **Effect on the measurements above: none.** The competent board of §1.1 carries ≈ 150 core layers at R14, far below
999, and its biggest leader hits stay far under the line — probe of 2026-10-01 (`runBoss` with every leader hit
recorded, 2 seeds each): 同盟 终极 R14 competent max 37k (2481 hits), 同盟 终极 R15 strong (×1.4) max 72k (4669),
独立 终极 R14 strong max 28k (1201); no hit reached 200k, none was cancelled. §3 stays valid. The co-op replay
profiles of §3 with two bonds or every bond at 999 per player do reach the line (6–77 cancelled hits per row, the
2026-10-02 table).
- **Where they bind**: the late boards the community describes — 999-layer 谢拉格 (×11.34 vs cold / frozen), 奥术
(×11.19 arts taken, a debuff a teammate's board also applies), 炎 (ATK ×10.22) — whose single hits run from 160k to
past 300k. There the limit makes the official co-op leaders as hard to kill as the user asked: a hit past the line
deals nothing, so extra multipliers can lower the damage, the reason players avoid lighting more damage bonds at 999
layers. Pools: 200k (卢西恩 FUNNY) … 7.6M (假想敌:管 hidden ABYSS) — in the easy modes one cancelled hit would have
been the whole pool.
- Bots read the capped `ps.layers`; their boards never come near either line (§5), so their play is unchanged.
- The tools stop at the cap too: `balance.mjs applyBoard` clamps the profile-scaled layer curves (a numeric
`--profile` above ≈ 4.9 would have pushed the 170-layer R15 core curve, +20 % jitter, past 999) and
`matchrun.mjs --layers N` adds at most the room left under 999, so `--check` stays clean on a boosted run.
- **Pool size and the line**: a drone that dies (whoever kills it, DESIGN §20.13) costs 假想敌:胄 0.02 × the pool
max (【死亡集群】, boss_1 / boss_8; `bosses.js DRONE_LINK_BASE 'pool'`). Today that is at most 144000 (boss_8 ABYSS 7.2M) and lands; a pool above 14999950 would make every
drone kill a cancelled hit. Re-check this whenever the pool size changes (research 11 §6).
+9 -8
View File
@@ -66,7 +66,7 @@ Top level: `{ season, seasonName, modes, economy, lpCapPerRound, bossOvertimeAft
| `spRounds` | `[3,9,11]` | rounds whose prep opens with a 机变 draft | | `spRounds` | `[3,9,11]` | rounds whose prep opens with a 机变 draft |
| `combatTimeLimit[r]` | `{"1":45,…,"14":null}` | = `rounds[r].combatTimeLimit`: **real** seconds of the forced-2× battle (DESIGN §4) | | `combatTimeLimit[r]` | `{"1":45,…,"14":null}` | = `rounds[r].combatTimeLimit`: **real** seconds of the forced-2× battle (DESIGN §4) |
| `enemyScale[r]` | `{"atk":1.1,"hp":1.2,"speed":1,"kAtk":1,"kHp":1}` | non-boss enemy multipliers (research 01 A3): `atk = atkBase·1.1^kAtk`, `hp = hpBase·1.2^kHp·extra`, `speed` 1.15 on ABYSS from R3. Apply to level-0 stats after special-enemy replacement, also to bounty/special enemies. **Leader HP pools excluded.** | | `enemyScale[r]` | `{"atk":1.1,"hp":1.2,"speed":1,"kAtk":1,"kHp":1}` | non-boss enemy multipliers (research 01 A3): `atk = atkBase·1.1^kAtk`, `hp = hpBase·1.2^kHp·extra`, `speed` 1.15 on ABYSS from R3. Apply to level-0 stats after special-enemy replacement, also to bounty/special enemies. **Leader HP pools excluded.** |
| `bossHpScale` | multi `{"bloodPointKey":"bloodPointAbyss","coop":1,"aliveScaling":false,…}` · solo `{"bloodPointKey":"bloodPoint","solo":0.25,"soloAssumed":true,…}` | co-op pool = `bosses[id].bloodPoint[difficulty]` unscaled (split leaders share one pool, "敌方领袖的总生命值不变", no alive-player factor — research 08 §6 #7); solo = × `solo` [ASSUMED, flagged]. `GameData.bossPoolHp(bossId)` implements it | | `bossHpScale` | multi `{"bloodPointKey":"bloodPointAbyss","coop":1,"aliveScaling":false,"aliveFull":4,"aliveAssumed":true,…}` · solo `{"bloodPointKey":"bloodPoint","solo":0.25,"soloAssumed":true,…}` | one pool for every boss field: co-op = `bosses[id].bloodPoint[difficulty]` whatever the number of alive players; `aliveScaling: true` would scale it × alive / `aliveFull` (巴哈姆特 12294 "隊友變少,最後boss血條也會變少" — one community note, no proportion: off until confirmed, the proportion [ASSUMED, flagged `aliveAssumed`]); solo = × `solo` [ASSUMED, flagged]. `GameData.bossPoolHp(bossId, aliveCount)` / `bossPoolShare` implement it (DESIGN §20.10) |
| `upgradePrices` | `[5,8,11,12,13]` | base price L1→2 … L5→6 (−1 per round start, floor 0, reset after upgrade) | | `upgradePrices` | `[5,8,11,12,13]` | base price L1→2 … L5→6 (−1 per round start, floor 0, reset after upgrade) |
| `maxShopLevel` | `6` | | | `maxShopLevel` | `6` | |
| `shopSlots[level]` | `{"1":{"chess":3,"item":1},…}` | operator + item slots per shop level | | `shopSlots[level]` | `{"1":{"chess":3,"item":1},…}` | operator + item slots per shop level |
@@ -114,7 +114,7 @@ Top level: `{ season, seasonName, modes, economy, lpCapPerRound, bossOvertimeAft
|---|---|---| |---|---|---|
| `lpCapPerRound` | `10` | max LP lost per player per normal round | | `lpCapPerRound` | `10` | max LP lost per player per normal round |
| `bossOvertimeAfter`, `bossOvertimeDrainPerSec` | `150`, `1` | official `bossTurnHpReduceTime`: from 150 **real** s of a boss round the merged team LP loses 1 per whole real second (`gamedata.js bossOvertimeDue`; first point at 151 s) | | `bossOvertimeAfter`, `bossOvertimeDrainPerSec` | `150`, `1` | official `bossTurnHpReduceTime`: from 150 **real** s of a boss round the merged team LP loses 1 per whole real second (`gamedata.js bossOvertimeDue`; first point at 151 s) |
| `bossHpScale` | `{"formula":"co-op: bloodPoint[difficulty] …","coop":1,"solo":0.25,"soloAssumed":true,"aliveScaling":false,…}` | global form of the mode rule | | `bossHpScale` | `{"formula":"co-op: bloodPoint[difficulty] — one pool for every field …","coop":1,"solo":0.25,"soloAssumed":true,"aliveScaling":false,"aliveFull":4,"aliveAssumed":true,…}` | global form of the mode rule |
| `hiddenCore` | `{"single":350,"multi":1200,"minTeamLpExclusive":1,"difficulties":["NORMAL","HARD","ABYSS"],"checkedAfterRound":14}` | Σ activated layers must be **>** threshold and LP **>** 1 | | `hiddenCore` | `{"single":350,"multi":1200,"minTeamLpExclusive":1,"difficulties":["NORMAL","HARD","ABYSS"],"checkedAfterRound":14}` | Σ activated layers must be **>** threshold and LP **>** 1 |
| `dp` | `{"init":10,"perSec":1,"max":99}` | | | `dp` | `{"init":10,"perSec":1,"max":99}` | |
| `unite` | `{"maxHelpers":2,"helperOrder":"unitsOnField>activeBond>undownedUnits; pair: unitsOnField>activeBond>activeLayers>undownedUnits, first = right field (PRTS 帮助)","layerGainsEnabled":false,"templates":{"1":"act1autochess_escaped_single","2":"act1autochess_escaped_multi"},…}` | 联防; `helperOrder` documents the rule `server/match/unite.js helperOrder` implements (research 08 §5; ties → seat) | | `unite` | `{"maxHelpers":2,"helperOrder":"unitsOnField>activeBond>undownedUnits; pair: unitsOnField>activeBond>activeLayers>undownedUnits, first = right field (PRTS 帮助)","layerGainsEnabled":false,"templates":{"1":"act1autochess_escaped_single","2":"act1autochess_escaped_multi"},…}` | 联防; `helperOrder` documents the rule `server/match/unite.js helperOrder` implements (research 08 §5; ties → seat) |
@@ -248,7 +248,7 @@ everything needed to resolve a unit for `(chessId, skillIndex, moduleId)` (`simd
| `layerMilestones[]` | `[{"layer":25,"mode":"every","effect":"bond_layer_added_reward_equip"}]` | layer-based powers: `reach` (while L ≥ layer), `every` (each multiple), `first` (latched once) | | `layerMilestones[]` | `[{"layer":25,"mode":"every","effect":"bond_layer_added_reward_equip"}]` | layer-based powers: `reach` (while L ≥ layer), `every` (each multiple), `first` (latched once) |
| `desc`, `descRaw` | | bond panel text | | `desc`, `descRaw` | | bond panel text |
| `effectId`, `effectName`, `effectDesc`, `effectDescRaw` | `"bondeffect_deput"`, …, `"所有干员防御力+{0:0%}(受层数影响)…"` | in-battle text with **positional** placeholders | | `effectId`, `effectName`, `effectDesc`, `effectDescRaw` | `"bondeffect_deput"`, …, `"所有干员防御力+{0:0%}(受层数影响)…"` | in-battle text with **positional** placeholders |
| `effectDescParams[]` | `[{"index":0,"format":"0%","base":"base_def","perStack":"def_per_stack"}]` | `{i:fmt}` = `bb[base] + bb[perStack] × layers` | | `effectDescParams[]` | `[{"index":0,"format":"0%","base":"base_def","perStack":"def_per_stack"}]` | `{i:fmt}` = `bb[base] + bb[perStack] × layers` — layers never exceed 999 (the official `MAX_GARRISON_STACK`, not in any table: shared/constants.js `BOND_LAYER_CAP`, research 11) |
| `bb`, `bbStr` | `{"base_def":0.15,"def_per_stack":0.012,"respawn_time":-0.3,"layer":2,"count":3,"more_layer":4}` | all buffs merged (first wins) | | `bb`, `bbStr` | `{"base_def":0.15,"def_per_stack":0.012,"respawn_time":-0.3,"layer":2,"count":3,"more_layer":4}` | all buffs merged (first wins) |
| `buffs[]` | `[{"key":"env_gbuff_new","bb":{…},"bbStr":{"key":"act1autochess_bond_eff_deput"}}, …]` | exact official buffs (e.g. 萨尔贡 has a second buff valid only with `band_narant`) | | `buffs[]` | `[{"key":"env_gbuff_new","bb":{…},"bbStr":{"key":"act1autochess_bond_eff_deput"}}, …]` | exact official buffs (e.g. 萨尔贡 has a second buff valid only with `band_narant`) |
| `baseParams`, `perStackParams` | `["base_def"]`, `["def_per_stack"]` | official descParamBaseList / descParamPerStackList | | `baseParams`, `perStackParams` | `["base_def"]`, `["def_per_stack"]` | official descParamBaseList / descParamPerStackList |
@@ -434,13 +434,13 @@ Glyph legend (`rows`):
| Field | Example (`boss_5`) | Meaning | | Field | Example (`boss_5`) | Meaning |
|---|---|---| |---|---|---|
| `bossId`, `enemyKey`, `handbookId`, `name`, `sortId` | `"boss_5"`, `"enemy_2016_csphtm"`, …, `"卢西恩,“猩红血钻”"`, `5` | | | `bossId`, `enemyKey`, `handbookId`, `name`, `sortId` | `"boss_5"`, `"enemy_2016_csphtm"`, …, `"卢西恩,“猩红血钻”"`, `5` | `enemyKey` = activity_table `autoChessData.bossInfoDict[*].enemyId`, the official `IsBossEnemy` list: the units 限伤 applies to (one hit ≥ 300000 in a boss battle deals 0; shared/constants.js `BOSS_HIT_LIMIT`, research 11 §2.3) — the wave templates tag exactly these keys `boss` |
| `weight`, `hidden` | `10`, `false` | pick weight within its round | | `weight`, `hidden` | `10`, `false` | pick weight within its round |
| `bloodPoint` | `{"FUNNY":200000,"NORMAL":390000,"HARD":780000,"ABYSS":3000000}` | shared leader HP pool per difficulty | | `bloodPoint` | `{"FUNNY":200000,"NORMAL":390000,"HARD":780000,"ABYSS":3000000}` | shared leader HP pool per difficulty: bossInfoDict `bloodPoint` / `bloodPointNormal` / `bloodPointHard` / `bloodPointAbyss` of the current data (PRTS 盟约记录's leader table is an older revision: 铳 险境, 胄 / 铳 / 萨米 绝境 differ, no 终极, no 卢西恩); no single hit of ≥ 300000 counts toward it (限伤, `BOSS_HIT_LIMIT`) |
| `lpr` | `30` | LP cost if it leaks | | `lpr` | `30` | LP cost if it leaks |
| `templates[modeId]` | `{"round":14,"template":"act2autochess_h07_05"}` | | | `templates[modeId]` | `{"round":14,"template":"act2autochess_h07_05"}` | |
| `escortsByTemplate[templateId]` | `[{"key":"enemy_2009_csaudc","slot":null,"count":4},…]` | non-boss spawns (slot = still a placeholder) | | `escortsByTemplate[templateId]` | `[{"key":"enemy_2009_csaudc","slot":null,"count":4},…]` | non-boss spawns (slot = still a placeholder) |
| `parts[]` | `["enemy_9014_acstma","enemy_9015_acstmb"]` | boss parts (random local groups / unharmful) | | `parts[]` | `["enemy_9014_acstma","enemy_9015_acstmb"]` | boss parts (random local groups / unharmful); not leaders — 限伤 never applies to them |
| `abilities[]` | handbook texts | | | `abilities[]` | handbook texts | |
## 14. `tokens.json` — `{ [tokenId]: Token }` (22) ## 14. `tokens.json` — `{ [tokenId]: Token }` (22)
@@ -503,8 +503,9 @@ Glyph legend (`rows`):
requiresBondId/rangeGrid/flavor`, bond `spec`, E2 art availability (falls back to "char has an E2 phase"). requiresBondId/rangeGrid/flavor`, bond `spec`, E2 art availability (falls back to "char has an E2 phase").
17. [ASSUMED] content (flagged in data): 机变 family schedule and server pools, the solo leader pool factor 17. [ASSUMED] content (flagged in data): 机变 family schedule and server pools, the solo leader pool factor
(`bossHpScale.solo` 0.25), title criteria, income cap 12, per-turn band-draft timer 30 s (`timers.bandTurn`, the (`bossHpScale.solo` 0.25), title criteria, income cap 12, per-turn band-draft timer 30 s (`timers.bandTurn`, the
step's only countdown — user playtest #4), the shop-only item list (`SHOP_EXCLUDED_ITEMS`, from play). The special-enemy step's only countdown — user playtest #4), the shop-only item list (`SHOP_EXCLUDED_ITEMS`, from play), the alive / 4
generator, the co-op leader pool (`bloodPoint`, no alive-player scaling) and the 联防 timing are official. proportion of the optional co-op alive scaling (`bossHpScale.aliveAssumed`; `aliveScaling` off). The special-enemy
generator, the co-op leader pool (`bloodPoint`, one pool for every field) and the 联防 timing are official.
18. **Module parts flagged `isToken`** (伺夜, 浊心斯卡蒂, 缪尔赛思, 耀骑士临光 golden) upgrade the summon only; they are 18. **Module parts flagged `isToken`** (伺夜, 浊心斯卡蒂, 缪尔赛思, 耀骑士临光 golden) upgrade the summon only; they are
applied to `tokens.json` variants, never to the operator's talents/trait. applied to `tokens.json` variants, never to the operator's talents/trait.
19. **Undefined enemy-database fields** (`m_defined:false`): a zero `m_value` means "never set" and falls back to the 19. **Undefined enemy-database fields** (`m_defined:false`): a zero `m_value` means "never set" and falls back to the
+97 -14
View File
@@ -101,7 +101,7 @@ public/
screens/ title.js, lobby.js, room.js, briefing.js, bandDraft.js, game.js, result.js screens/ title.js, lobby.js, room.js, briefing.js, bandDraft.js, game.js, result.js
ui/ hud.js, bondStrip.js, bondPopup.js, shopBar.js, teamPanel.js, detailPanel.js, ui/ hud.js, bondStrip.js, bondPopup.js, shopBar.js, teamPanel.js, detailPanel.js,
tooltip.js, choiceOverlay.js, rewardOverlay.js, ticker.js, emotes.js, settings.js, toasts.js tooltip.js, choiceOverlay.js, rewardOverlay.js, ticker.js, emotes.js, settings.js, toasts.js
render/ app.js, projection.js, tiles.js, units.js, spine.js, fx.js, interp.js, drag.js, pick.js (API §9) render/ app.js, projection.js, tiles.js, units.js, spine.js, fx.js, interp.js, drag.js, pick.js, promote.js (API §9)
vendor/ pixi, pixi-spine, preact, hooks, htm (copied from node_modules by tools/vendor.mjs) vendor/ pixi, pixi-spine, preact, hooks, htm (copied from node_modules by tools/vendor.mjs)
assets/ downloaded art/audio (git-ignored), see docs/ASSETS.md assets/ downloaded art/audio (git-ignored), see docs/ASSETS.md
fonts/ self-hosted fonts fonts/ self-hosted fonts
@@ -198,7 +198,8 @@ b.on(event, fn, { priority=0, owner } ) / b.off(...) // hook bus (§5.4)
killed, total, // enemies attributed to this player's field/half killed, total, // enemies attributed to this player's field/half
leaked: [ { enemyKey, mods, lpr, sourcePlayerId } ], // for unite aggregation & LP leaked: [ { enemyKey, mods, lpr, sourcePlayerId } ], // for unite aggregation & LP
perfect: bool, // no leaks perfect: bool, // no leaks
layerGains: { [bondId]: n }, // IN_BATTLE gains (0 if flags.layerGainsEnabled false) layerGains: { [bondId]: n }, // IN_BATTLE gains (0 if flags.layerGainsEnabled false); what was added — capped at
// the bond's room under BOND_LAYER_CAP 999 (§6.3, §20.12)
coins: n, // bounty coins earned coins: n, // bounty coins earned
damageDealt: n, bossDamage: n, healingDone: n, deaths: n, damageDealt: n, bossDamage: n, healingDone: n, deaths: n,
unitsEnd: [ { uid, hpPct, sp, alive } ] } }, unitsEnd: [ { uid, hpPct, sp, alive } ] } },
@@ -212,7 +213,7 @@ b.on(event, fn, { priority=0, owner } ) / b.off(...) // hook bus (§5.4)
Base stats (from data): `maxHp, atk, def, res` (0–100), `aspd` (100 base), `bat` (base attack time, s), `blockCnt`, `rangeGrid` (ops) / `rangeRadius` (enemies, 0 = melee), `moveSpeed`, `massLevel`, `weight`, `lpr` (enemies), `dmgType` ('phys'|'arts'|'heal'|'true'), `motion` ('WALK'|'FLY'), `cost`, `respawnTime`, `spRecovery`. Base stats (from data): `maxHp, atk, def, res` (0–100), `aspd` (100 base), `bat` (base attack time, s), `blockCnt`, `rangeGrid` (ops) / `rangeRadius` (enemies, 0 = melee), `moveSpeed`, `massLevel`, `weight`, `lpr` (enemies), `dmgType` ('phys'|'arts'|'heal'|'true'), `motion` ('WALK'|'FLY'), `cost`, `respawnTime`, `spRecovery`.
**Aggregation** (`units.js`, recomputed lazily when buffs change): **Aggregation** (`units.js`, recomputed lazily when buffs change):
- `ATK = (base + Σflat) × (1 + Σpct) × Πmul` — same shape for `maxHp`, `def`; `res = clamp((base + Σflat) × Πmul, 0, 100)`; `aspd = clamp(100 + Σaspd + base-100, 20, 600)` (`constants.js ASPD_MIN` 20: PRTS 数值范围 "ATTACK_SPEED 默认下限 20", §20.3). - `ATK = (base + Σflat) × (1 + Σpct) × Πmul` — same shape for `maxHp`, `def`; the 卫戍 systems' "+X%" ATK / DEF / max HP (盟约, 策略, 装备, 机变 cards, per-layer 特质) are 直接乘算 and go into `Σpct` with the skills' "+X%" (`sim/constants.js DIRECT_BONUS_STACKING 'add'`, content/support `directMods`; PRTS 盟约记录 / 游戏数据基础, §20.10) — "提升至X%" effects (炎佑 ×1.5) and the char_attribute_mul 特质 stay in `Πmul`; `res = clamp((base + Σflat) × Πmul, 0, 100)`; `aspd = clamp(100 + Σaspd + base-100, 20, 600)` (`constants.js ASPD_MIN` 20: PRTS 数值范围 "ATTACK_SPEED 默认下限 20", §20.3).
- `interval = bat × (1 + ΣbatPct) × 100 / aspd` (blackboard `base_attack_time` is treated as `batPct`, e.g. −0.3 → 70%). - `interval = bat × (1 + ΣbatPct) × 100 / aspd` (blackboard `base_attack_time` is treated as `batPct`, e.g. −0.3 → 70%).
- Changing `maxHp` keeps the HP **ratio**. - Changing `maxHp` keeps the HP **ratio**.
- Stats are rounded only for display; the sim keeps floats. - Stats are rounded only for display; the sim keeps floats.
@@ -227,6 +228,7 @@ Base stats (from data): `maxHp, atk, def, res` (0–100), `aspd` (100 base), `ba
- `flags`: `stun, freeze, sleep, silence (no skills), disarm (no attacks), stealth (untargetable by enemies/ops unless blocked or revealed), camou (an ally's 迷彩: like stealth for enemy targeting and on screen, but not 隐匿 for 隐匿 conditions — 叙拉古, 家族徽章; §20.2), invulnerable, unblockable, levitate, fear (enemy: cannot attack, unblockable, runs to random tiles of the ±45° fan away from the source; no source or itself as source = inside its own tile — `fear.js`, §20.4), taunt (+aggro), cold, noHeal (no heals: 禁疗 summons and 孤立 units; an HP-regen attribute still applies — PRTS 安洁莉娜 备注), isolated (孤立: no friendly selector picks it — no heal, buff, aura or talent of an ally, `Battle.allySelectable`; §20.3)`. - `flags`: `stun, freeze, sleep, silence (no skills), disarm (no attacks), stealth (untargetable by enemies/ops unless blocked or revealed), camou (an ally's 迷彩: like stealth for enemy targeting and on screen, but not 隐匿 for 隐匿 conditions — 叙拉古, 家族徽章; §20.2), invulnerable, unblockable, levitate, fear (enemy: cannot attack, unblockable, runs to random tiles of the ±45° fan away from the source; no source or itself as source = inside its own tile — `fear.js`, §20.4), taunt (+aggro), cold, noHeal (no heals: 禁疗 summons and 孤立 units; an HP-regen attribute still applies — PRTS 安洁莉娜 备注), isolated (孤立: no friendly selector picks it — no heal, buff, aura or talent of an ally, `Battle.allySelectable`; §20.3)`.
- **Status catalogue** (`buffs.js`, keys used by content): `stun`, `freeze` (stun + res −15), `cold` (aspd −30; a second cold while cold ⇒ freeze 3 s unless immune), `sleep` (stun; ends on damage? no: in AK sleep = untargetable & unable to act; keep AK: untargetable & inactive), `slow` (moveMul), `bind` (moveMul 0), `fragile` (dmgTakenMul), `artsFragile` (artsTakenMul), `silence`, `fear` (the 恐惧 walk, `fear.js`), `burnGauge`/`necrosis`/`neural` element gauges (see damage), `stealth`, `camou` (迷彩, its own keys: 忍冬 S3 `vulpis:camou` until her next cast, 寒芒克洛丝 S1), `reveal`. - **Status catalogue** (`buffs.js`, keys used by content): `stun`, `freeze` (stun + res −15), `cold` (aspd −30; a second cold while cold ⇒ freeze 3 s unless immune), `sleep` (stun; ends on damage? no: in AK sleep = untargetable & unable to act; keep AK: untargetable & inactive), `slow` (moveMul), `bind` (moveMul 0), `fragile` (dmgTakenMul), `artsFragile` (artsTakenMul), `silence`, `fear` (the 恐惧 walk, `fear.js`), `burnGauge`/`necrosis`/`neural` element gauges (see damage), `stealth`, `camou` (迷彩, its own keys: 忍冬 S3 `vulpis:camou` until her next cast, 寒芒克洛丝 S1), `reveal`.
- Immunities from enemy data (`stunImmune`, `silenceImmune`, `sleepImmune`, `frozenImmune`, `levitateImmune`) are honoured by `applyStatus`. - Immunities from enemy data (`stunImmune`, `silenceImmune`, `sleepImmune`, `frozenImmune`, `levitateImmune`) are honoured by `applyStatus`.
- Same-named debuffs on an enemy (奥术's arts damage taken, 灵知 坚冰, the 莱恩哈特 / 缄默德克萨斯 RES cuts): one instance per target whoever applies it, the strongest wins (`Battle.applyStrongest`; PRTS 作战机制 "同名buff…只能表现出一个" — strongest rather than latest [ASSUMED], §20.10).
### 5.4 Hook bus (the only way content plugs in) ### 5.4 Hook bus (the only way content plugs in)
@@ -254,7 +256,7 @@ Base stats (from data): `maxHp, atk, def, res` (0–100), `aspd` (100 base), `ba
DamageInfo: `{ amount (pre-mitigation), type: 'phys'|'arts'|'true'|'element', element?: 'burn'|'necrosis'|'neural'|'apoptosis', atkScale, defIgnoreFlat, defIgnorePct, resIgnoreFlat, resIgnorePct, mul (multiplier), canDodge, isSkill, isSplash, tags[] }`. DamageInfo: `{ amount (pre-mitigation), type: 'phys'|'arts'|'true'|'element', element?: 'burn'|'necrosis'|'neural'|'apoptosis', atkScale, defIgnoreFlat, defIgnorePct, resIgnoreFlat, resIgnorePct, mul (multiplier), canDodge, isSkill, isSplash, tags[] }`.
Engine helpers (content must use these, never touch internals): `battle.dealDamage(source, target, dmgInfo)`, `battle.heal(source, target, amount, {overheal=false})`, `battle.applyStatus(target, key, { duration, source, value })`, `battle.addBuff(unit, buff)`, `battle.removeBuff(unit, key)`, `battle.spawnToken(ownerUnit|playerId, tokenId, row, col, opts)`, `battle.spawnEnemy(enemyKey, { routeIndex, pos, mods, tag })`, `battle.addProjectile({ from, to|target, speed, onHit, visual })`, `battle.push(e, force, { from, dir, fixed, fixedAngle, inward, effect })` / `battle.pull(e, force, { to, center, stop })` / `battle.pullToFront(e, unit, force)` / `battle.forceLevel(e, force)` / `battle.pushDistance(e, force, { effect })` (the official 力度 − 重量 tables, §20.3; `battle.displace(e, dir, distance)` is the raw mover), `battle.allySelectable(a, by)` / `battle.alliesFor(by, ownerId)` (friendly selection, skipping 孤立 units, §20.3), `battle.unitsInGrid(unit, grid)`, `battle.enemiesInRadius(x, y, r)`, `battle.alliesInRadius(x, y, r, ownerId?)`, `battle.addLayers(playerId, bondId, n, reason)` (no-op when gains disabled; records into result), `battle.addCoins(playerId, n)`, `battle.fx(kind, params)` (client VFX event), `battle.rng()` (never `Math.random`), `battle.getPlayer(playerId)` (bonds/band/effects view), `battle.redeploy(unit)`, `battle.after(seconds, fn)` (scheduled callbacks), `battle.every(seconds, fn)`. Engine helpers (content must use these, never touch internals): `battle.dealDamage(source, target, dmgInfo)`, `battle.heal(source, target, amount, {overheal=false})`, `battle.applyStatus(target, key, { duration, source, value })`, `battle.addBuff(unit, buff)`, `battle.removeBuff(unit, key)`, `battle.spawnToken(ownerUnit|playerId, tokenId, row, col, opts)`, `battle.spawnEnemy(enemyKey, { routeIndex, pos, mods, tag })`, `battle.addProjectile({ from, to|target, speed, onHit, visual })`, `battle.push(e, force, { from, dir, fixed, fixedAngle, inward, effect })` / `battle.pull(e, force, { to, center, stop })` / `battle.pullToFront(e, unit, force)` / `battle.forceLevel(e, force)` / `battle.pushDistance(e, force, { effect })` (the official 力度 − 重量 tables, §20.3; `battle.displace(e, dir, distance)` is the raw mover), `battle.allySelectable(a, by)` / `battle.alliesFor(by, ownerId)` (friendly selection, skipping 孤立 units, §20.3), `battle.unitsInGrid(unit, grid)`, `battle.enemiesInRadius(x, y, r)`, `battle.alliesInRadius(x, y, r, ownerId?)`, `battle.addLayers(playerId, bondId, n, reason)` (no-op when gains disabled; at most the room left under `BOND_LAYER_CAP` 999 — `shared/constants.js layerGainRoom`, §20.12; records into result), `battle.loseHp(target, amount, { source, from, sourceless })` (流失; `sourceless` = 无来源, `source` keeps the credit — leader parts pass damage on 1:1 with it, §20.10), `battle.addCoins(playerId, n)`, `battle.fx(kind, params)` (client VFX event), `battle.rng()` (never `Math.random`), `battle.getPlayer(playerId)` (bonds/band/effects view), `battle.redeploy(unit)`, `battle.after(seconds, fn)` (scheduled callbacks), `battle.every(seconds, fn)`.
### 5.5 Combat rules (engine) ### 5.5 Combat rules (engine)
@@ -264,7 +266,7 @@ Engine helpers (content must use these, never touch internals): `battle.dealDama
- **Operator attacks**: every `interval` s if a valid target exists; targets = up to `maxTargets` (default 1) from `unitsInGrid(rangeGrid)`, priority: (1) the enemies it blocks (every blocker, in range or not, §20.3), (2) profession/trait priorities (e.g. fly-first snipers, `trait` text parsed by build-data into `targetPriority`), (3) enemy with the **least remaining path distance** to its goal, ties → earliest spawned. Melee (no ranged trait) and ground-only attacks cannot hit air units (`Unit.isFlying`: FLY, or a 近地悬浮 / 浮空 enemy — PRTS "算作空中单位", §19.1). Stealthed enemies can't be targeted unless blocked or revealed. Ranged ops fire projectiles (speed ~12 tiles/s, instant for melee/arts-beam classes as data indicates); damage applies on impact. - **Operator attacks**: every `interval` s if a valid target exists; targets = up to `maxTargets` (default 1) from `unitsInGrid(rangeGrid)`, priority: (1) the enemies it blocks (every blocker, in range or not, §20.3), (2) profession/trait priorities (e.g. fly-first snipers, `trait` text parsed by build-data into `targetPriority`), (3) enemy with the **least remaining path distance** to its goal, ties → earliest spawned. Melee (no ranged trait) and ground-only attacks cannot hit air units (`Unit.isFlying`: FLY, or a 近地悬浮 / 浮空 enemy — PRTS "算作空中单位", §19.1). Stealthed enemies can't be targeted unless blocked or revealed. Ranged ops fire projectiles (speed ~12 tiles/s, instant for melee/arts-beam classes as data indicates); damage applies on impact.
- **Healers** (`dmgType heal`): target the ally in range with the lowest HP% below 100%, never a 禁疗 / 孤立 unit (tokens.json `abnormal`, `noHeal`; §20.3); do nothing otherwise (SP still regenerates). - **Healers** (`dmgType heal`): target the ally in range with the lowest HP% below 100%, never a 禁疗 / 孤立 unit (tokens.json `abnormal`, `noHeal`; §20.3); do nothing otherwise (SP still regenerates).
- **Enemy AI**: follow the route (checkpoints; WALK paths recomputed on the grid when devices change); when blocked stop & fight; ranged enemies (`rangeRadius > 0`) attack ops whose 0.25 collider touches their radius (centre distance ≤ `rangeRadius` + 0.25, §3, §20.4): the enemy's own target rule filters the candidates first (`profile.canTarget`: 只攻击地面单位 …), then priority blocker → special priority → highest taunt → **latest deployed** (`aggroSeq`) — PRTS 索敌 "阻挡→特殊优先级→仇恨值→最早出现"; a stealthed ally is attacked only by the enemy it blocks (§19.1); they pause movement for `ATTACK_PAUSE = 0.35 s` per attack. FLY enemies ignore ground obstacles (only blockFly units block them) and follow their checkpoint route. A feared enemy runs between random 恐惧可达地块 away from the source (PRTS 诱发移动; `fear.js`); 恐惧 outranks 诱导; the 恐惧 / 诱导 walks re-plan from where the enemy stands after a push or an obstacle change, and the route re-plans when they end (§20.4). - **Enemy AI**: follow the route (checkpoints; WALK paths recomputed on the grid when devices change); when blocked stop & fight; ranged enemies (`rangeRadius > 0`) attack ops whose 0.25 collider touches their radius (centre distance ≤ `rangeRadius` + 0.25, §3, §20.4): the enemy's own target rule filters the candidates first (`profile.canTarget`: 只攻击地面单位 …), then priority blocker → special priority → highest taunt → **latest deployed** (`aggroSeq`) — PRTS 索敌 "阻挡→特殊优先级→仇恨值→最早出现"; a stealthed ally is attacked only by the enemy it blocks (§19.1); they pause movement for `ATTACK_PAUSE = 0.35 s` per attack. FLY enemies ignore ground obstacles (only blockFly units block them) and follow their checkpoint route. A feared enemy runs between random 恐惧可达地块 away from the source (PRTS 诱发移动; `fear.js`); 恐惧 outranks 诱导; the 恐惧 / 诱导 walks re-plan from where the enemy stands after a push or an obstacle change, and the route re-plans when they end (§20.4).
- **Damage**: phys `max(A − max(0, D×(1−defIgnorePct) − defIgnoreFlat), 0.05·A)`; arts `max(A × (1 − R'/100), 0.05·A)` with `R' = max(0, R×(1−resIgnorePct) − resIgnoreFlat)`; true = A; 元素伤害 ('elemental') `max(A × (1 − 元素抗性/100), 0.05·A)`. Then × `dmgDealtMul` (source) × `dmgTakenMul` & type-specific taken multipliers (target) × `dmg.mul` — 元素伤害 takes 元素脆弱 (`elementalTakenMul`) alone, not `dmgTakenMul` (脆弱 = "受到的物理、法术、真实伤害提升"); shields absorb; dodge checks for phys/arts. Element damage fills a gauge (1000; enemy leaders 2000) × `elemTakenMul` × max(0.05, 1 − 损伤抵抗/100), 损伤抵抗 = data `epResistance` (PRTS 元素; the 5 % floor of DMG_e, PRTS 游戏数据基础). An element fill on a target with no HP left is refused (damage.js `hasHp`: no burst on a corpse). A full gauge bursts with the side-specific official effects, as **无来源** damage (no damage-dealt multiplier or penetration of the unit that filled the gauge, which keeps the kill and the stats; §19.5) (sim/constants.js `ELEMENT`; operators: 灼燃 1200 arts + RES −20, 神经 stun 10 s then 1000 true, 凋亡 15 s of 阻回 (`noSp`: no SP gain of any kind) + 静默 with −1 SP/s and 100 arts/s, 侵蚀 DEF −100 then 800 phys; enemies: the "·我方" terms). Every burst starts a **爆发冷却** = its duration (灼燃 / 神经 / 侵蚀 10 s — enemies' 侵蚀 8 s — 凋亡 15 s) during which NO element of the unit fills or can be recovered; when it ends every gauge of the unit resets (§18.3). - **Damage**: phys `max(A − max(0, D×(1−defIgnorePct) − defIgnoreFlat), 0.05·A)`; arts `max(A × (1 − R'/100), 0.05·A)` with `R' = max(0, R×(1−resIgnorePct) − resIgnoreFlat)`; true = A; 元素伤害 ('elemental') `max(A × (1 − 元素抗性/100), 0.05·A)`. Then × `dmgDealtMul` (source) × `dmgTakenMul` & type-specific taken multipliers (target) × `dmg.mul` — 元素伤害 takes 元素脆弱 (`elementalTakenMul`) alone, not `dmgTakenMul` (脆弱 = "受到的物理、法术、真实伤害提升"). Then **限伤**: on a leader (`isBoss` — the 10 bossInfoDict enemyIds and their mirrored copies; never parts, drones or escorts) in a `'boss'` / `'hidden'` battle a hit with `ceil(final) ≥ 300000` is cancelled — no HP or pool loss, no credit, no damage number, no `damaged` / kill (`shared/constants.js BOSS_HIT_LIMIT`, `damage.js leaderHitCancelled`, after every multiplier and the hit-step blocks, before HP shields; `loseHp` too — a part's 传递, a drone's death; the client's `MAX_BATTLE_DAMAGE`, §20.12). Then shields absorb; dodge checks for phys/arts. Element damage fills a gauge (1000; enemy leaders 2000) × `elemTakenMul` × max(0.05, 1 − 损伤抵抗/100), 损伤抵抗 = data `epResistance` (PRTS 元素; the 5 % floor of DMG_e, PRTS 游戏数据基础). An element fill on a target with no HP left is refused (damage.js `hasHp`: no burst on a corpse). A full gauge bursts with the side-specific official effects, as **无来源** damage (no damage-dealt multiplier or penetration of the unit that filled the gauge, which keeps the kill and the stats; §19.5) (sim/constants.js `ELEMENT`; operators: 灼燃 1200 arts + RES −20, 神经 stun 10 s then 1000 true, 凋亡 15 s of 阻回 (`noSp`: no SP gain of any kind) + 静默 with −1 SP/s and 100 arts/s, 侵蚀 DEF −100 then 800 phys; enemies: the "·我方" terms). Every burst starts a **爆发冷却** = its duration (灼燃 / 神经 / 侵蚀 10 s — enemies' 侵蚀 8 s — 凋亡 15 s) during which NO element of the unit fills or can be recovered; when it ends every gauge of the unit resets (§18.3).
- **Leak**: an enemy reaching its goal is removed, counted as leaked for its field owner (`sourcePlayerId` for unite). LP accounting is done by the match, not the battle. - **Leak**: an enemy reaching its goal is removed, counted as leaked for its field owner (`sourcePlayerId` for unite). LP accounting is done by the match, not the battle.
- **End**: all spawns done and no enemies alive ⇒ `cleared`; `time ≥ timeLimit` ⇒ `timeout` (remaining enemies = leaked). Boss battles end when the shared boss pool reaches 0 (victory) or the match forces an end (team LP 0). A pool holding less than 1 HP is empty (`constants.js BOSS_POOL_MIN_HP`: the hit, or the per-player credit, that would leave less takes the rest; §20.5). - **End**: all spawns done and no enemies alive ⇒ `cleared`; `time ≥ timeLimit` ⇒ `timeout` (remaining enemies = leaked). Boss battles end when the shared boss pool reaches 0 (victory) or the match forces an end (team LP 0). A pool holding less than 1 HP is empty (`constants.js BOSS_POOL_MIN_HP`: the hit, or the per-player credit, that would leave less takes the rest; §20.5).
@@ -352,15 +354,16 @@ 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. 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 or is sold (a sale is refused with `HAND_FULL` when there is no room for it). 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. 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 or is sold (a sale is refused with `HAND_FULL` when there is no room for it).
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 to hand + 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); purchase completing a merge allowed with full hand; full hand blocks buy; overflow to temp; 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; 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); purchase completing a merge allowed with full hand; full hand blocks buy; overflow to temp; temp blocks ready).
**Temp overflow (临时整备区, row 8; user playtest #3 item 3).** Passive gains that find the hand full (merge elites, grants, battle-result rewards, equipment returned by a merged / sold 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 always goes to the hand, overflowing into temp (also outside PREP); it takes a freed board tile of a consumed copy only when the hand and temp are both full. `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). **Temp overflow (临时整备区, row 8; user playtest #3 item 3).** Passive gains that find the hand full (merge elites, grants, battle-result rewards, equipment returned by a merged / sold 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).
### 6.3 Bonds & layers (bondsMeta.js) ### 6.3 Bonds & layers (bondsMeta.js)
- `count` = number of **distinct base chess** on the board carrying the bond (`BOARD`); `BOARD_AND_DECK` bonds also count the hand; 绝技 counts elites on board; 调和 adds +1 to each core bond count, etc. (research 02). - `count` = number of **distinct base chess** on the board carrying the bond (`BOARD`); `BOARD_AND_DECK` bonds also count the hand; 绝技 counts elites on board; 调和 adds +1 to each core bond count, etc. (research 02).
- `tier` from thresholds; `active = tier ≥ 1`. - `tier` from thresholds; `active = tier ≥ 1`.
- `layers[bondId]` persist all match; gains come from garrisons/bands/items/choices/bond effects; IN_BATTLE gains are collected from `BattleResult.layerGains` at SETTLE (disabled in unite/boss/hidden). - `layers[bondId]` persist all match; gains come from garrisons/bands/items/choices/bond effects; IN_BATTLE gains are collected from `BattleResult.layerGains` at SETTLE (disabled in unite/boss/hidden).
- A bond holds at most `BOND_LAYER_CAP` = 999 layers (the client's `MAX_GARRISON_STACK`, §20.12): every writer — `PlayerState.addLayers`, the SETTLE merge, `Battle.addLayers`, the dev tools — adds `layerGainRoom(before, n)` = min(n, 999 − before); a gain at the cap adds 0 and dispatches nothing (no onLayers, no `layerGain` hook, no 'layer' event), so the per-N milestones stop with the count.
- Σ activated layers (hidden-core check) = Σ layers over bonds that are active at the moment of the check (end of R14 prep, per research). - Σ activated layers (hidden-core check) = Σ layers over bonds that are active at the moment of the check (end of R14 prep, per research).
### 6.4 Prep-phase effect dispatcher (effectsMeta.js) ### 6.4 Prep-phase effect dispatcher (effectsMeta.js)
@@ -386,7 +389,7 @@ Each domain file `server/sim/content/{tokens,bonds,garrisons,items,bands,enemies
- garrisons: 43 effect keys (IN_BATTLE via hooks; SERVER_* via meta registry). - garrisons: 43 effect keys (IN_BATTLE via hooks; SERVER_* via meta registry).
- items: 56 equipment (+golden) + 3 Arts; 变形同构体 bond grant; VI-tier set/combos. - items: 56 equipment (+golden) + 3 Arts; 变形同构体 bond grant; VI-tier set/combos.
- bands: 40 strategies (meta + battle). - bands: 40 strategies (meta + battle).
- enemies: special-type behaviours (FLY, TIMES, ELEMENT, DOT, INVISIBLE, REFLECTION, SPECIAL) and notable individual abilities from `data/enemies.json`; bosses 1–10 scripted — the huge leaders (假想敌:胄 ×2, 假想敌:管 ×2, 盐风主教昆图斯, 阿利斯泰尔, “萨米的意志”) are 自缚 + 无法被阻挡 (PRTS 天赋; `content/bosses.js SELF_BOUND`: a persistent noMove + unblockable buff from spawn) with their hit area (§19.4); devices/terrain. - enemies: special-type behaviours (FLY, TIMES, ELEMENT, DOT, INVISIBLE, REFLECTION, SPECIAL) and notable individual abilities from `data/enemies.json`; bosses 1–10 scripted — the huge leaders (假想敌:胄 ×2, 假想敌:管 ×2, 盐风主教昆图斯, 阿利斯泰尔, “萨米的意志”) are 自缚 + 无法被阻挡 (PRTS 天赋; `content/bosses.js SELF_BOUND`: a persistent noMove + unblockable buff from spawn) with their hit area (§19.4); leader parts (斩胄之剑 / 破胄之锤 / 碎铳之簧) pass every damage they take to their leader 1:1 as 无来源 HP loss (`PART_TRANSFER` 1, PRTS "等量"), a 假想敌:胄 drone that dies costs it 2 % of the pool (`DRONE_LINK_BASE 'pool'`) (§20.10, §20.13); devices/terrain.
- choices: 机变 families (悬赏决策 bounties, 道具补给, 机密商店, 战术决策 team/personal buffs). 悬赏决策 = cards.bounty `draft` (the PRTS 敌人轮选 table: no 战术特训, no 鸭爵 set; 105 cards), drawn uniformly; bounty cards carry `descRaw`; a multi-round card lasts two battles (`choices.js MULTI_ROUND_BOUNTY_BATTLES`, §20.6). 教鞭 = a random 战术特训 card (3 drawn, 1 taken). - choices: 机变 families (悬赏决策 bounties, 道具补给, 机密商店, 战术决策 team/personal buffs). 悬赏决策 = cards.bounty `draft` (the PRTS 敌人轮选 table: no 战术特训, no 鸭爵 set; 105 cards), drawn uniformly; bounty cards carry `descRaw`; a multi-round card lasts two battles (`choices.js MULTI_ROUND_BOUNTY_BATTLES`, §20.6). 教鞭 = a random 战术特训 card (3 drawn, 1 taken).
--- ---
@@ -409,7 +412,7 @@ S→C:
- `m.field` — field meta when watching starts: `{ fieldId, kind, rect, stageId, units: [UnitInfo] }` (the units on the field, knocked-out operators waiting to redeploy included — `Battle.fieldMeta`, §18.3) - `m.field` — field meta when watching starts: `{ fieldId, kind, rect, stageId, units: [UnitInfo] }` (the units on the field, knocked-out operators waiting to redeploy included — `Battle.fieldMeta`, §18.3)
- NOTE: game time in `b.snap`/`b.ev` is carried as **`gt`** (the frame's `t` is the message type). - NOTE: game time in `b.snap`/`b.ev` is carried as **`gt`** (the frame's `t` is the message type).
- `b.snap` — `{ fieldId, gt, units: [[id, x, y, hp, maxHp, sp, spMax, flags, anim]] , dp, killed, total, down?, elem? }` 20 Hz. Optional, only when non-empty (§18.3): `down: [[id, respawnAt (game s), respawnTime (s), state]]` — knocked-out operators waiting to redeploy, `state` 0 counting | 1 timer done, waiting for DP | 2 timer done, its tile taken (sim/constants.js `DOWN_STATE`); `elem: [[id, element, fill, cooldownEnd (game s), cooldown (s)]]` — the one gauge a unit shows: the fullest (ties by the official element id 神经 < 侵蚀 < 灼燃 < 凋亡), fill with 2 decimals rounded DOWN and kept at 0.01–0.99 outside a burst (§20.8); during a 爆发冷却 the bursting element with fill 1 and the cooldown numbers (else 0, 0) - `b.snap` — `{ fieldId, gt, units: [[id, x, y, hp, maxHp, sp, spMax, flags, anim]] , dp, killed, total, down?, elem? }` 20 Hz. Optional, only when non-empty (§18.3): `down: [[id, respawnAt (game s), respawnTime (s), state]]` — knocked-out operators waiting to redeploy, `state` 0 counting | 1 timer done, waiting for DP | 2 timer done, its tile taken (sim/constants.js `DOWN_STATE`); `elem: [[id, element, fill, cooldownEnd (game s), cooldown (s)]]` — the one gauge a unit shows: the fullest (ties by the official element id 神经 < 侵蚀 < 灼燃 < 凋亡), fill with 2 decimals rounded DOWN and kept at 0.01–0.99 outside a burst (§20.8); during a 爆发冷却 the bursting element with fill 1 and the cooldown numbers (else 0, 0)
- `b.ev` — `{ fieldId, ev: [ ... ] }` event tuples: `['spawn', UnitInfo]`, `['atk', srcId, tgtId, projKind]`, `['dmg', tgtId, amount, type]`, `['heal', tgtId, amount]`, `['skill', id, on:0|1]`, `['die', id, reason]` (reason `'killed'`, `'retreat'`, `'expired'` …; only `'killed'` plays an operator's knock-down sound), `['status', id, key, on]`, `['fx', kind, x, y, extra]`, `['layer', playerId, bondId, n]`, `['bounty', playerId, coins]`, `['deploy', id]` - `b.ev` — `{ fieldId, ev: [ ... ] }` event tuples: `['spawn', UnitInfo]`, `['atk', srcId, tgtId, projKind]`, `['dmg', tgtId, amount, type]`, `['heal', tgtId, amount]`, `['skill', id, on:0|1]`, `['die', id, reason]` (reason `'killed'`, `'retreat'`, `'expired'` …; only `'killed'` plays an operator's knock-down sound), `['status', id, key, on]`, `['fx', kind, x, y, extra]` (`'hitCap' {id, n}` = a cancelled 限伤 hit, drawn as nothing, §20.12), `['layer', playerId, bondId, n]`, `['bounty', playerId, coins]`, `['deploy', id]`
- `m.toast {kind, text}` · `m.ticker {text}` · `m.emote {playerId, id}` · `m.result {...}` - `m.toast {kind, text}` · `m.ticker {text}` · `m.emote {playerId, id}` · `m.result {...}`
- `m.unitStats {seq, round, units: [unitStatsEntry]}` — the answer to `g.unitStats` (`seq` echoed): every own board operator / summon by uid with `{id, uid, defId, hp, alive, maxHp, atk, def, res, interval, blockCnt, moveSpeed, base: {…same}}` (shared/protocol.js `unitStatsEntry`) as its next battle starts (§18.5) - `m.unitStats {seq, round, units: [unitStatsEntry]}` — the answer to `g.unitStats` (`seq` echoed): every own board operator / summon by uid with `{id, uid, defId, hp, alive, maxHp, atk, def, res, interval, blockCnt, moveSpeed, base: {…same}}` (shared/protocol.js `unitStatsEntry`) as its next battle starts (§18.5)
@@ -448,7 +451,9 @@ Piece = { uid, kind: 'chess'|'item'|'token', id, golden: bool, tier, items: [ {
const view = await createFieldView(canvasHost /* HTMLElement */, { data, assets, audio }) const view = await createFieldView(canvasHost /* HTMLElement */, { data, assets, audio })
view.setStage(stage) // draw tiles (procedural), devices view.setStage(stage) // draw tiles (procedural), devices
view.setCamera(kind /* 'prep'|'normal'|'unite'|'boss' */, { rect, side }) // animates camera view.setCamera(kind /* 'prep'|'normal'|'unite'|'boss' */, { rect, side }) // animates camera
view.setPrep(privateState, { editable }) // render hand/temp/board pieces as chibis (spine or fallback); editable enables drag view.setPrep(privateState, { editable }) // render hand/temp/board pieces as chibis (spine or fallback); editable enables drag;
// a merge's new elite on the board plays fx.promote instead of fx.deploy (render/promote.js, §20.11),
// also after a battle (a merge between two preps: the last prep state is kept, §20.14)
view.enterBattle(fieldMeta) // switch to battle rendering for m.field view.enterBattle(fieldMeta) // switch to battle rendering for m.field
view.pushSnapshot(snap) ; view.pushEvents(ev) // interpolated rendering (100 ms buffer) view.pushSnapshot(snap) ; view.pushEvents(ev) // interpolated rendering (100 ms buffer)
view.highlightTiles(tiles, style) // legal/illegal placement, range preview view.highlightTiles(tiles, style) // legal/illegal placement, range preview
@@ -534,9 +539,9 @@ Audio: autochess BGM per phase, UI SFX (buy/sell/refresh/level/merge/ready/timer
- `m.public.fields[]` gains `progress: { killed, total, done }`; `m.public.combatMode`; `m.public.paused: boolean` (always present; solo pause below). - `m.public.fields[]` gains `progress: { killed, total, done }`; `m.public.combatMode`; `m.public.paused: boolean` (always present; solo pause below).
- `b.snap` / `b.ev` are **not sent for combat** in client mode. `g.watch` rules: see Spectating. - `b.snap` / `b.ev` are **not sent for combat** in client mode. `g.watch` rules: see Spectating.
**Result validation (server, `fields.js validateClientResult`).** A client result is accepted only if structurally valid and plausible for its spec: every spec player reported and nobody else; finite numbers; `killed ≤ total ≤ 4·spawns + 100`; every leaked `enemyKey` is in the spec's spawns (multiset bound) or a key the spawned enemies' data mentions (content summons/splits, bounded by the total) — boss fields excepted (their leaks cost team LP through `b.progress`); `counted: false` only for enemies that never count (data `notCountInTotal`, schedule `countInTotal: false`, boss/part entries, content spawns); 联防: leaks + `unspawned` per (enemy, leaker) ≤ what that leaker sent in (nobody's survivors are billed to a teammate); per-bond `layerGains ≤ 60 + 4·round`, none when the spec disables gains, bonds must exist and be named by the player's bond snapshot, band, effects, units or items; `coins ≤ Σ bounty coins of the spawns`; `perfect` consistent with the counted leaks; unit states only for the player's own board uids with hpPct ∈ [0, 1]. The accepted result is **rebuilt** from whitelisted fields (leak mods/tags/sources from the spec — a content-spawned enemy (split, summon) keeps the mods of the schedule entry they equal, i.e. its parent's round multipliers and bounty id, so its 联防 re-entry and bounty match the server run; unit names from server data). Otherwise (or on any exception) the server re-simulates (normal/联防); a boss field is **handed over** instead (below). `SP_VERIFY=all|sample|off` (default `off`): `all` re-simulates every accepted normal/联防 result before accepting it (the server result wins on a digest mismatch); `sample` checks ~1 battle in 8 in a later callback and logs mismatches. `Match.verifyStats = { checked, mismatches, rejected, takeovers }`. Desync between display-only clients and the authority is tolerated: the authoritative outcome is what counts and is broadcast at settlement. **Result validation (server, `fields.js validateClientResult`).** A client result is accepted only if structurally valid and plausible for its spec: every spec player reported and nobody else; finite numbers; `killed ≤ total ≤ 4·spawns + 100`; every leaked `enemyKey` is in the spec's spawns (multiset bound) or a key the spawned enemies' data mentions (content summons/splits, bounded by the total) — boss fields excepted (their leaks cost team LP through `b.progress`); `counted: false` only for enemies that never count (data `notCountInTotal`, schedule `countInTotal: false`, boss/part entries, content spawns); 联防: leaks + `unspawned` per (enemy, leaker) ≤ what that leaker sent in (nobody's survivors are billed to a teammate); per-bond `layerGains ≤ min(60 + 4·round, 999 − the bond's starting layers)` (§20.12), none when the spec disables gains, bonds must exist and be named by the player's bond snapshot, band, effects, units or items; `coins ≤ Σ bounty coins of the spawns`; `perfect` consistent with the counted leaks; unit states only for the player's own board uids with hpPct ∈ [0, 1]. The accepted result is **rebuilt** from whitelisted fields (leak mods/tags/sources from the spec — a content-spawned enemy (split, summon) keeps the mods of the schedule entry they equal, i.e. its parent's round multipliers and bounty id, so its 联防 re-entry and bounty match the server run; unit names from server data). Otherwise (or on any exception) the server re-simulates (normal/联防); a boss field is **handed over** instead (below). `SP_VERIFY=all|sample|off` (default `off`): `all` re-simulates every accepted normal/联防 result before accepting it (the server result wins on a digest mismatch); `sample` checks ~1 battle in 8 in a later callback and logs mismatches. `Match.verifyStats = { checked, mismatches, rejected, takeovers }`. Desync between display-only clients and the authority is tolerated: the authoritative outcome is what counts and is broadcast at settlement.
**Boss rounds.** The shared pool (`SharedBossPool`, co-op size = `bloodPoint[difficulty]`, see corrections) lives on the server and is fed by the authorities' `b.progress` damage deltas (attributed per player through `by`, BOSS_HIT tickers as before) and by server-run fields; a server clock (250 ms) applies the overtime drain (−`bossOvertimeDrainPerSec` team LP per whole **real** second past `bossOvertimeAfter` real s, measured on the field clock — `gamedata.js bossOvertimeDue`, §4), the silence watchdog and the end checks: pool 0 → `b.end cleared` (victory), team LP 0 → `b.end forced` (defeat) — the first one the server registers decides the verdict (`Match._finalEnding`) and after a forced end no report (an in-flight `b.progress`, the final `b.result`) credits the pool (§20.5); results are awaited ≤ 6 s (× timerScale) after a forced end. `b.pool` is the live channel for the pool and the team LP: a boss `b.progress` (4 Hz per field) and the team-LP losses never re-mark the whole public state — `m.public` (`bossHp`, `teamLp`, `fields[].progress`) and the players' merged-LP shares (`_syncTeamLp`) refresh at ~1 Hz (`Match._bossPublic`, `BOSS_PUBLIC_MS`), and at once when the round ends. A boss field's `b.result` ends it only when the shared pool is empty or after that `b.end`: any other result — `'forced'` / `'timeout'` at t = 0, `'cleared'` while the pool still holds — and any result failing validation would stop the pair's fight (and, with every field done, end the Final Assault as a defeat), so the field is **handed over** (`Match._bossHandover`) to the partner's display replica (promoted with `b.start authoritative: true`) or, without one, to the server (fast-forwarded to the field clock); the sender gets `b.end takeover` and is never that field's authority again (`f.demoted`). There is no synthetic zero result for a running boss field: a stats-only stand-in (`fields.js syntheticResult`, carrying the damage already credited, never charging LP) only fills a result that cannot be had otherwise — the Final Assault already ending (no result, or an invalid one, within the grace) or a server-run battle whose `result()` threw. **Boss rounds.** The shared pool (`SharedBossPool`, co-op size = `bloodPoint[difficulty]`, see corrections) lives on the server and is fed by the authorities' `b.progress` damage deltas (attributed per player through `by`, BOSS_HIT tickers as before) and by server-run fields; a server clock (250 ms) applies the overtime drain (−`bossOvertimeDrainPerSec` team LP per whole **real** second past `bossOvertimeAfter` real s, measured on the field clock — `gamedata.js bossOvertimeDue`, §4), the silence watchdog and the end checks: pool 0 → `b.end cleared` (victory), team LP 0 → `b.end forced` (defeat) — the first one the server registers decides the verdict (`Match._finalEnding`) and after a forced end no report (an in-flight `b.progress`, the final `b.result`) credits the pool (§20.5); results are awaited ≤ 6 s (× timerScale) after a forced end. `b.pool` is the live channel for the pool and the team LP: a boss `b.progress` (4 Hz per field) and the team-LP losses never re-mark the whole public state — `m.public` (`bossHp`, `teamLp`, `fields[].progress`) and the players' merged-LP shares (`_syncTeamLp`) refresh at ~1 Hz (`Match._bossPublic`, `BOSS_PUBLIC_MS`), and at once when the round ends. A boss field's `b.result` ends it only when the shared pool is empty or after that `b.end` — except a `'cleared'` result whose cumulative report covers what the pool still holds (within its 1 HP floor) while the plausibility budget (one field credits ≤ the pool per `BOSS_MIN_CLEAR_GS` 5 game s) has not caught up: it is **held** (`f.heldResult`; 999-layer boards kill a leader in 2–4 game s, §20.14) — the boss clock keeps crediting the report at the budget's rate, the silence watchdog and handovers skip the field, a re-sent copy is ignored, and the pool's end (`_endFinal`) completes the field with it; a forged report gains nothing a `b.progress` could not already get. Any other result — `'forced'` / `'timeout'` at t = 0, `'cleared'` while the pool still holds more than the report covers — and any result failing validation would stop the pair's fight (and, with every field done, end the Final Assault as a defeat), so the field is **handed over** (`Match._bossHandover`) to the partner's display replica (promoted with `b.start authoritative: true`) or, without one, to the server (fast-forwarded to the field clock); the sender gets `b.end takeover` and is never that field's authority again (`f.demoted`). There is no synthetic zero result for a running boss field: a stats-only stand-in (`fields.js syntheticResult`, carrying the damage already credited, never charging LP) only fills a result that cannot be had otherwise — the Final Assault already ending (no result, or an invalid one, within the grace) or a server-run battle whose `result()` threw.
**Solo pause (official `PauseUp` / `ResumeUp`; `Match.setPause`).** C→S `g.pause { on }`: solo matches only (co-op → `WRONG_PHASE 'co-op battles never pause'`), and `on: true` only while a battle runs (COMBAT / FINAL_ASSAULT / HIDDEN_CORE with a live field, not once the Final Assault is ending — else `WRONG_PHASE 'no battle running'`); `on: false` is always accepted. While `m.public.paused` is true everything of the battle is frozen: the field clock (`b.start elapsed`, the boss plausibility budgets and the overtime drain), the authority deadline and the server-run release timers, the boss clock, the HUD `deadline` / `overtimeAt` (shifted by the paused time on resume) and the server-run pacers (FieldRunner / HeadlessPacer skip their intervals); the browser's runner stops its local clock. In solo battles the top bar shows a 暂停 / 继续作战 button (`ui/hud.js PauseButton`, `ui/matchStatus.js pauseAvailable`; `Space` toggles it), a paused overlay offers 继续作战 / 放弃模拟, and the HUD clocks freeze at the pause moment. A disconnect or leave resumes (the server takes the field over — nobody is left to resume it); the battle phase ending drops the pause. **Solo pause (official `PauseUp` / `ResumeUp`; `Match.setPause`).** C→S `g.pause { on }`: solo matches only (co-op → `WRONG_PHASE 'co-op battles never pause'`), and `on: true` only while a battle runs (COMBAT / FINAL_ASSAULT / HIDDEN_CORE with a live field, not once the Final Assault is ending — else `WRONG_PHASE 'no battle running'`); `on: false` is always accepted. While `m.public.paused` is true everything of the battle is frozen: the field clock (`b.start elapsed`, the boss plausibility budgets and the overtime drain), the authority deadline and the server-run release timers, the boss clock, the HUD `deadline` / `overtimeAt` (shifted by the paused time on resume) and the server-run pacers (FieldRunner / HeadlessPacer skip their intervals); the browser's runner stops its local clock. In solo battles the top bar shows a 暂停 / 继续作战 button (`ui/hud.js PauseButton`, `ui/matchStatus.js pauseAvailable`; `Space` toggles it), a paused overlay offers 继续作战 / 放弃模拟, and the HUD clocks freeze at the pause moment. A disconnect or leave resumes (the server takes the field over — nobody is left to resume it); the battle phase ending drops the pause.
@@ -808,12 +813,15 @@ Ten reports after playing v2.3 (绝境 on desktop, 标准 on an Android phone in
--- ---
## 20. User playtest #6 (v2.5): summons, skill triggers, blocking, push force, enemies, boss pool, bounties, 机变 cards, element gauge ## 20. User playtest #6 (v2.5): summons, skill triggers, blocking, push force, enemies, boss pool, bounties, 机变 cards, element gauge; leader HP, elite to the board, official limits
Nineteen reports after playing v2.4 (#1–#3 from a friend of the user). Where each is handled: #1 凯瑟琳's device could not be placed by hand → §20.1; #2 赫默's drone could not be placed by hand → §20.1; #3 忍冬's 叙拉古 stealth never ended → §20.2; #4 the bounty enemies kept coming instead of stopping after two battles → §20.6; #5 the Final Assault leader at 0 HP kept fighting until the team LP ran out, and the result still said 胜利 → §20.5; #6 the 道具补给 cards showed no description → §20.7; #7 in 联防 the leaker's count should fall live as teammates kill (it seemed capped at 10) → §20.6; #8 新约能天使 S2's shield on teammates seemed not to work → §20.2; #9 威龙 was drawn far too large → §20.4; #10 on the phone the shop's confirm strip did not buy (only the middle of the card worked) → §20.7; #11 the element gauge seemed to fill at the wrong rate → §20.8; #12 萨卡兹枯朽战车 hit too softly → §20.4; #13 “萨科塔之翼”'s 战栗 (small random flight) was missing → §20.4; #14 enemy weight vs push force (野鬃 pushed too many enemies) → §20.3; #15 skills whose range is wider than the attack range did not fire when an enemy entered it → §20.2; #16 古米's ally heal fired only after she engaged an enemy → §20.2; #17 the front operator that took over an enemy held between two operators never attacked it → §20.3; #18 炎佑 was healed by medics → §20.3; #19 the 3-choose-1 promotion reward often offered two identical operators → §20.6. Nineteen reports after playing v2.4 (#1–#3 from a friend of the user). Where each is handled: #1 凯瑟琳's device could not be placed by hand → §20.1; #2 赫默's drone could not be placed by hand → §20.1; #3 忍冬's 叙拉古 stealth never ended → §20.2; #4 the bounty enemies kept coming instead of stopping after two battles → §20.6; #5 the Final Assault leader at 0 HP kept fighting until the team LP ran out, and the result still said 胜利 → §20.5; #6 the 道具补给 cards showed no description → §20.7; #7 in 联防 the leaker's count should fall live as teammates kill (it seemed capped at 10) → §20.6; #8 新约能天使 S2's shield on teammates seemed not to work → §20.2; #9 威龙 was drawn far too large → §20.4; #10 on the phone the shop's confirm strip did not buy (only the middle of the card worked) → §20.7; #11 the element gauge seemed to fill at the wrong rate → §20.8; #12 萨卡兹枯朽战车 hit too softly → §20.4; #13 “萨科塔之翼”'s 战栗 (small random flight) was missing → §20.4; #14 enemy weight vs push force (野鬃 pushed too many enemies) → §20.3; #15 skills whose range is wider than the attack range did not fire when an enemy entered it → §20.2; #16 古米's ally heal fired only after she engaged an enemy → §20.2; #17 the front operator that took over an enemy held between two operators never attacked it → §20.3; #18 炎佑 was healed by medics → §20.3; #19 the 3-choose-1 promotion reward often offered two identical operators → §20.6.
Eight workstreams were merged into `playtest6` (WH → WG → WD → WC → WB → WA → WF → WE); the reviews' last rounds and the cross-workstream notes were applied on top: a stocked skill summon deploys as soon as its owner is back (§20.1), the AUTO DP skills 焰尾 S1 / 凛御银灰 S1 fire on full SP (§20.2), 锏 S3's slashes give 无敌 and 晕眩 / 冻结免疫 (§20.3), the `AUTO_OP_COOLDOWN` comment and the SKILL_RANGE count (13 skills / 26 records) were corrected. The user then answered the open questions (2026-10-01); the answers are **settled decisions**, recorded in their sections and in §20.9, each with the one-line change that flips it: the placed skill summons deploy once at the battle start and 凯瑟琳's device stays a hand card (§20.1), every blocker hits what it blocks (§20.3), 萨卡兹枯朽战车 follows the re-research — ranged enemies reach an operator by its 0.25 collider (§20.4) — and multi-round bounties last two battles (§20.6). Normative lines rewritten with it: §2 (`fear.js`), §3 (the enemies' reach), §5.2 (ASPD floor 20), §5.3 (`camou`, `fear`, `noHeal`, `isolated`), §5.4 (push / pull / ally-selection helpers), §5.5 (initial deployment, blocking, operator attacks, healers, enemy AI, end), §5.6 (skill triggers, the operation cooldown), §6.1 / §6.2 (the Final Assault verdict, summon stacks, summons in the hand, the promotion reward), §7 (bounty draft, 教鞭), §8.2 / §8.3 (`uniteLeft`, `descRaw`, `counterText`, the `elem` fill, the stealth bit), §9 (gauge row, model scale), §10 (机变 card, the card tap), §14 (`left`, the pool floor, the verdict, `uniteLeft`); superseded or revised: §17.5 (the 联防 count), §18.3 (the gauge look), §18.6 #11 (skill summons), §19.2 (the melee-only blocked-first rule; 灰毫 S2), §19.5 (the gauge look). Eight workstreams were merged into `playtest6` (WH → WG → WD → WC → WB → WA → WF → WE); the reviews' last rounds and the cross-workstream notes were applied on top: a stocked skill summon deploys as soon as its owner is back (§20.1), the AUTO DP skills 焰尾 S1 / 凛御银灰 S1 fire on full SP (§20.2), 锏 S3's slashes give 无敌 and 晕眩 / 冻结免疫 (§20.3), the `AUTO_OP_COOLDOWN` comment and the SKILL_RANGE count (13 skills / 26 records) were corrected. The user then answered the open questions (2026-10-01); the answers are **settled decisions**, recorded in their sections and in §20.9, each with the one-line change that flips it: the placed skill summons deploy once at the battle start and 凯瑟琳's device stays a hand card (§20.1), every blocker hits what it blocks (§20.3), 萨卡兹枯朽战车 follows the re-research — ranged enemies reach an operator by its 0.25 collider (§20.4) — and multi-round bounties last two battles (§20.6). Normative lines rewritten with it: §2 (`fear.js`), §3 (the enemies' reach), §5.2 (ASPD floor 20), §5.3 (`camou`, `fear`, `noHeal`, `isolated`), §5.4 (push / pull / ally-selection helpers), §5.5 (initial deployment, blocking, operator attacks, healers, enemy AI, end), §5.6 (skill triggers, the operation cooldown), §6.1 / §6.2 (the Final Assault verdict, summon stacks, summons in the hand, the promotion reward), §7 (bounty draft, 教鞭), §8.2 / §8.3 (`uniteLeft`, `descRaw`, `counterText`, the `elem` fill, the stealth bit), §9 (gauge row, model scale), §10 (机变 card, the card tap), §14 (`left`, the pool floor, the verdict, `uniteLeft`); superseded or revised: §17.5 (the 联防 count), §18.3 (the gauge look), §18.6 #11 (skill summons), §19.2 (the melee-only blocked-first rule; 灰毫 S2), §19.5 (the gauge look).
**Follow-up (`playtest6b`, 2026-10-02).** Four more workstreams were merged into `playtest6b` in this order: the elite-to-board rule (§20.11), the 假想敌:胄 kit audit (§20.13), the Final Assault leader HP report (§20.10) and the official limits read from the client binary (§20.12). Reports: co-op 终极 / 绝境 leaders died within seconds although the official leader never dies that fast with such layers (§20.10); "官方就是合成精锐时,如果消耗了场上的干员,精锐会出现在场上那个位置" (§20.11, user-confirmed). The boss-HP and the limits workstreams both capped bond layers at 999 — one implementation remains (`shared/constants.js BOND_LAYER_CAP` / `layerGainRoom`, used by every writer), and the boss-HP workstream's reading of 限伤 as an engine fixed-point overflow "not modelled" is superseded by the binary evidence (`MAX_BATTLE_DAMAGE` 300000, the hit is cancelled). Normative lines rewritten with it: §2 (`promote.js`), §5.1 (`layerGains` capped), §5.2 (直接乘算 in `Σpct`), §5.3 (same-named debuffs), §5.4 (`addLayers`, `loseHp`), §5.5 (限伤 between the multipliers and the shields), §6.2 (where a merge's elite goes), §6.3 (the layer cap), §7 (leader parts, drones), §8.2 (`hitCap`), §9 (the promotion cue), §14 (the layer bound of client results); §20.9 records the decisions. Two QA verifiers then checked the integrated build; their residual issues are handled in §20.14.
### 20.1 Summons placed by hand (#1, #2) — `tools/build-data.mjs`, `match/gamedata.js placeableTokens`, `PlayerState`, `match/bot.js`, `sim/Battle.js`, `content/tokens.js`, kits tier2/3/4, `ui/detailPanel.js`, `shared/constants.js SKILL_SUMMON_START_DEPLOY` ### 20.1 Summons placed by hand (#1, #2) — `tools/build-data.mjs`, `match/gamedata.js placeableTokens`, `PlayerState`, `match/bot.js`, `sim/Battle.js`, `content/tokens.js`, kits tier2/3/4, `ui/detailPanel.js`, `shared/constants.js SKILL_SUMMON_START_DEPLOY`
- **Seen** (the friend): 凯瑟琳's device and 赫默's drone could not be placed by hand. **Cause**: v2.3 (§18.6 #11) limited hand pieces to talent summons after the user's playtest #4 report on the drone, which dropped 医疗探机 and 诅咒娃娃; 爬行号·防护单元 has no shop-state entry, so 凯瑟琳's kit placed it automatically. - **Seen** (the friend): 凯瑟琳's device and 赫默's drone could not be placed by hand. **Cause**: v2.3 (§18.6 #11) limited hand pieces to talent summons after the user's playtest #4 report on the drone, which dropped 医疗探机 and 诅咒娃娃; 爬行号·防护单元 has no shop-state entry, so 凯瑟琳's kit placed it automatically.
@@ -930,5 +938,80 @@ The user's answers of 2026-10-01 (playtest #6 follow-up):
| Multi-round bounty cards (§20.6) | last two battles like "接下来两场作战", blue text, in the draft | `server/match/choices.js MULTI_ROUND_BOUNTY_BATTLES = null` (official 每场) | | Multi-round bounty cards (§20.6) | last two battles like "接下来两场作战", blue text, in the draft | `server/match/choices.js MULTI_ROUND_BOUNTY_BATTLES = null` (official 每场) |
| 萨卡兹枯朽战车's reach (§20.4) | data range 2.2 + the operator's 0.25 collider for every ranged enemy; scales per data (100 % ranged, 200 % blocked melee, skill 100 %) | `server/sim/constants.js ALLY_COLLIDER_RADIUS = 0` | | 萨卡兹枯朽战车's reach (§20.4) | data range 2.2 + the operator's 0.25 collider for every ranged enemy; scales per data (100 % ranged, 200 % blocked melee, skill 100 %) | `server/sim/constants.js ALLY_COLLIDER_RADIUS = 0` |
| Blocking (§20.3) | every blocker hits the enemies it blocks, first, whatever its range or facing | gate `Battle.blockedTargets` / `sortEnemyTargets` blocked-first on a melee position again | | Blocking (§20.3) | every blocker hits the enemies it blocks, first, whatever its range or facing | gate `Battle.blockedTargets` / `sortEnemyTargets` blocked-first on a melee position again |
| A merge's elite (§20.11) | takes the consumed deployed copy's tile and facing, else the hand (PRTS 帮助 "若消耗已部署至作战区的干员,则发送至作战区对应位置"; the user confirmed it, 2026-10-01) | none — official; restoring the v2.5 hand-first rule means reverting `PlayerState._mergeChess` (merge `5f3842f`) |
Still [ASSUMED] (each in its section): the class rows covering every MANUAL skill (§20.2); skill-summon redeploys free and never while the owner is away (§20.1); the 1-HP pool floor and the server-order verdict (§20.5); a short promotion tier topping up from below and “神秘顾客” keeping the band bounties (§20.6); the 弹道 column for unclassified pushes and the other displacement details (§20.3); 枯朽战车's skill firing on its blocker at 100 % and content enemy skills keeping a point radius (§20.4); the gauge's refill colour and placement (§20.8); the 机变 card's phone sizes (§20.7). Official since the follow-up — read from the client binary (research 11, §20.12) or the official texts (§20.10, §20.13):
| Rule | Now | One-line flip |
|---|---|---|
| Bond layer cap (§20.12) | 999 per bond (`MAX_GARRISON_STACK`; `AddBondCount` stores min(L + n, 999)) — no longer [ASSUMED] | `shared/constants.js BOND_LAYER_CAP = 0` |
| 限伤 (§20.12) | a leader hit with ceil(d) ≥ 300000 in boss / hidden battles is cancelled (`MAX_BATTLE_DAMAGE`), not an overflow | `shared/constants.js BOSS_HIT_LIMIT = 0` |
| 直接乘算 (§20.10) | bond / strategy / item / 机变 / 特质 "+X%" are summed with each other and the skills' | `server/sim/constants.js DIRECT_BONUS_STACKING = 'multiply'` |
| Leader parts (§20.10, §20.13) | every damage a 剑 / 锤 / 碎铳之簧 takes is lost by its leader 1:1, 无来源 | `server/sim/content/bosses.js PART_TRANSFER = 0.5` (v2.5's share; v2.5 also passed a 剑 / 锤's damage only while grounded) |
Settled by the user ("保持固定血量"): the co-op leader pool does not shrink when players are eliminated — `config bossHpScale.aliveScaling` is **false** (pool = bloodPoint whatever the number of alive players; one community note, 巴哈姆特 12294, said otherwise) (§20.10); flip: `aliveScaling: true` (× alive / 4).
Still [ASSUMED] (each in its section): the class rows covering every MANUAL skill (§20.2); skill-summon redeploys free and never while the owner is away (§20.1); the 1-HP pool floor and the server-order verdict (§20.5); a short promotion tier topping up from below and “神秘顾客” keeping the band bounties (§20.6); the 弹道 column for unclassified pushes and the other displacement details (§20.3); 枯朽战车's skill firing on its blocker at 100 % and content enemy skills keeping a point radius (§20.4); the gauge's refill colour and placement (§20.8); the 机变 card's phone sizes (§20.7); the elite's tile among several deployed copies (the first in deploy order), a transformed carrier's tile and the elite's fresh summon stack (§20.11); "strongest" rather than "latest" for same-named debuffs, which "最大生命值" the 胄 drone link reads (the pool), the 剑 / 锤 dive hits passing on, the springs' split and the solo pool × 0.25 (§20.10); the scene server clamping prep gains like the client, a cancelled hit showing nothing, a passed-on loss being one hit and the order blocks → 限伤 → HP shields (§20.12); 胄's unread `max_hp` 0.5, the untargetable 初始模式 and a replacement copy inheriting only the HP (§20.13); talent debuffs keyed per source unit (two copies of one operator each apply theirs, §20.14).
### 20.10 Final Assault leader HP and damage (user report after playtest #6, 2026-10-01) — `server/sim/constants.js DIRECT_BONUS_STACKING`, `content/support directMods`, `sim/Battle.js applyStrongest / loseHp sourceless`, content bonds / bands / items / choices / garrisons / kits tier4, `content/bosses.js PART_TRANSFER / DRONE_LINK_BASE`, `match/gamedata.js bossPoolHp / bossPoolShare`, `match/finalAssault.js`, `data/config.json bossHpScale`
- **Seen**: co-op 终极 / 绝境 leaders died within seconds; with the user's layer counts the official leader never dies that fast.
- **HP and pool — official, unchanged**: `bloodPoint[difficulty]` = activity_table act2autochess bossInfoDict `bloodPoint` / `bloodPointNormal` / `bloodPointHard` / `bloodPointAbyss` of the current data (the cache equals upstream ArknightsGameData master as of 2026-10-01; `test/match/bosshp.test.js` pins all 40 values — e.g. 胄 247 500 / 675 000 / 1 800 000 / 3 600 000, hidden 胄 750 000 / 937 500 / 3 600 000 / 7 200 000). PRTS 盟约记录's leader table is an older (11月18日) revision: 铳 险境 and 胄 / 铳 / 萨米 绝境 differ, no 终极 column, no 卢西恩 row. The data has no per-player or per-field factor; leader HP ignores `enemyScale` ("领袖单位于服务器的生命值加成不受上述加成影响"). One pool for every boss field ("最终攻势中,所有人将一起对敌方领袖造成伤害"); notice 5114's "敌方领袖的总生命值不变" means the two mirrored copies of a pair field share it.
- **Pool size**: co-op = bloodPoint whatever the number of alive players — `bossHpScale.aliveScaling` (× alive / 4, one community note: 巴哈姆特 12294) stays **off** — the user chose the fixed pool (§20.9); solo × 0.25 [ASSUMED].
- **Fixed — why leaders fell too fast**:
1. **直接乘算**: "+X%" ATK / DEF / max HP from bonds, strategies, equipment, 机变 cards and per-layer 特质 are summed with each other and with the skills' "+X%" (PRTS 盟约记录 "…属性加成均为直接乘算"; 游戏数据基础 D_t = Σtᵢ, A = (A₀ + D_p)(1 + D_t)). v2.5 compounded them (×74 instead of ×12 at 600 精准 layers with four more bonuses). `DIRECT_BONUS_STACKING 'add'`, content/support `directMods`; 炎佑 ×1.5 and the char_attribute_mul 特质 stay multipliers (§5.2).
2. **Same-named enemy debuffs**: one instance per target, the strongest (PRTS 作战机制 "同名buff…只能表现出一个"; 巴哈姆特 12316 "共享型buff會跟對面搶") — 奥术, 灵知 坚冰, the 莱恩哈特 / 缄默德克萨斯 RES cuts (`Battle.applyStrongest`, §5.3). v2.5 kept one 奥术 per player, so a pair field multiplied it (×40 arts damage taken at +400 layers; ×7.6 now). "Strongest" rather than "latest" is [ASSUMED].
3. **Leader parts**: 剑 / 锤 / 碎铳之簧 pass every damage they take 1:1 as 无来源 HP loss, credited to the attacker (PRTS "受到等量的无来源生命流失"; `PART_TRANSFER` 1, `loseHp … sourceless`); v2.5 passed ½, a 剑 / 锤 only while grounded. The dive hits counting (PRTS talent "受到伤害时" vs the handbook "被击落时") and the springs' split to the other springs are [ASSUMED] (§20.13 for the 剑 / 锤 kit).
4. **坚守 thorns**: dealt 无来源 by the member who was hit and credited to that member (statistics only; up to 19 % of a co-op pool was nobody's).
5. **Bond layers**: uncapped in v2.5; now 999 per bond — official, §20.12 (this workstream first added the cap as [ASSUMED] from the community; the merge kept the limits workstream's single implementation).
- **Kept**: a 胄 drone costs 2 % of the leader's shown max HP = the pool (`DRONE_LINK_BASE 'pool'`, = v2.5; which "最大生命值" the official client reads is [ASSUMED] — the review tried the unit's data 600 000, which made drones 19–78 % of a solo 标准 fight, and went back). Under 限伤 it is one hit: 144 000 at most today (hidden 胄 终极), cancelled only above a 14 999 950 pool (§20.12).
- **Ruled out by measurement**: double crediting (client / server / replica), multi-hits through the 5×3 hit area, element bursts, %-max-HP damage on leaders (only the drone link).
- **Official, not a bug**: per-layer bonuses have no cap below 999 (谢拉格 vs cold ×(1.35 + 0.01 L) = ×11.34, 奥术 at low HP ×15.7, 精准 +1208.8 % ATK at 999 layers), so very high layers still kill fast — 绝境 with +400 on every active bond: median 8.6 s, fastest 3.6 s; 终极 under 20 s needs ≈ +400 on every bond (≈ 2 800 active layers per player; median 16.8 s); with two bonds per player at 999 (≈ 2 000 layers) 绝境 kills median 10 s, 7 of 28 under 5 s; with every active bond at 999 终极 median 8.3 s, 绝境 3.7 s — and there 限伤 (§20.12) cancels 6–77 hits per 12–28 fights. The community reports the same (巴哈姆特 12522, 999 谢拉格 "boss一秒死").
- **Measured** (real seconds — the fights run at 2×, so half the game seconds of the QA tables). Integrated build (QA 6b on `cca11e6`, after the elite-to-board merge): the R14 boards of real 4-AI co-op matches, seeds 7 / 8 / 9 / 12 × boss_1…7 = 28 Final Assaults per row, both pair fields on one pool, the same boards replayed on v2.5 → now: 终极 own layers median 206 → 258 s, +200 on every active bond 24.5 → 42.1 s (fastest 8.2 → 18.9 s), +400 11.3 → 16.8 s; 绝境 own 79 → 99 s, +200 11.6 → 14.8 s (fastest 1.9 → 7.3 s), +400 6.1 → 8.6 s; each player's top bond requested at 1500 (now held at 999): 终极 53 → 95 s, 绝境 17.5 → 33 s. On each version's own bot boards the medians moved only ×1.0–1.2 (终极 own 256 → 258 s, +200 47 → 54 s; 绝境 own 81 → 99 s, +200 13 → 15 s): the elite-to-board rule (§20.11) gives the bots slightly stronger R14 boards (终极 2.12 → 2.26 elites per player, 绝境 2.14 → 2.51; 绝境 6.0 → 6.9 active bonds), so a player whose board did not change feels the same-board numbers. No landed leader hit reached 300 000 (v2.5 landed up to 18.6 M); the largest at +400 is 144 000 (a hidden 胄 drone; 114 000 from an operator), at 999 layers 299 600. The boss-HP workstream's own measurement (42 pre-merge boards, seeds 7–9: 终极 +200 30.9 → 56.0 s, …) is kept in BALANCE §3, labelled as such; BALANCE §3 has both tables.
- Tests: `test/match/bosshp.test.js` (real Final Assault / Hidden Core matches, the 40 bloodPoint values; the 奥术 pair test runs seed 12 since the elite-to-board merge changed the bots' boards), `test/sim/bosshp_stacking.test.js`, `test/match/layercap.test.js`, `test/sim/battle.test.js` (Battle.addLayers cap), `test/content/bonds_addon.test.js`, `kits_t4.test.js`, `kits_alt_t4.test.js`, `enemies_bosses.test.js`, `items.test.js`, `garrisons_battle.test.js`, `choices.test.js`, `test/match/unitStats.test.js`, `finalAssault.test.js`, `balance.test.js`, `playtest6-bosspool.test.js`; opt-in `SIM_E2E=1 node --test test/sim/bossfield.browser.test.js` (the real boss / hidden specs of a 999-layer bot match give the same digest, pool and fx in headless Chrome as in Node; covers §20.12 and §20.13 too).
### 20.11 A merge's elite takes the deployed copy's tile (user-confirmed) — `match/PlayerState.js _mergeChess / transformChess`, `match/board.js mergeTile`, `match/audit.js`, `ui/gameLogic.js mergeProgress / mergeTarget`, `ui/shopBar.js armedSlotOf / onArm`, `screens/game.js MERGE_HL`, `render/promote.js`, `render/fx.js promote`, `render/app.js setPrep`
- **Seen** (the user, first-hand, after playtest #6): "官方就是合成精锐时,如果消耗了场上的干员,精锐会出现在场上那个位置".
- **Cause**: research 01 A1 row 7 read PRTS 帮助 as "1 elite goes to the hand, not to a board tile" (marked VERIFIED, repeated in `01-core-data.json` promotion.eliteGoesTo), so `_mergeChess` put every elite in the hand and used a consumed copy's tile only when the hand and temp were both full.
- **Official** (PRTS 卫戍协议/帮助): §干员的获得与精锐化 "…发送1名【精锐】状态的该干员至手牌区(若消耗已部署至作战区的干员,则发送至作战区对应位置)"; tip "干员晋级后已配发装备会回收至整备区,需要重新配置"; §战斗部署 "如果部署的干员拥有可手动部署的附属召唤物,则该召唤物会立刻加入手牌区", deploy order "从上到下>从左到右". The text does not say which tile wins among several deployed copies.
- **Now (server)**: when a consumed copy stood on the board the elite takes that tile and its facing, otherwise it goes to the hand, overflowing into temp (outside PREP too). Several deployed copies: the one that deploys first — row desc, then col asc, the mirrored right boss half included — `board.js mergeTile(tiles, legal)` [ASSUMED]. A deployed piece transformed into the completing copy (突变细胞, `transformChess` `fromKey` / `fromDir`) counts as deployed with its own tile [ASSUMED]. A tile the elite may not use is skipped; with the hand and temp also full the elite stays on the first deployed copy's tile rather than being lost (as `_evictIllegal`). The deploy count never grows (no BOARD_FULL path). Equipment returns to the hand, overflow temp; with both full it stays on the elite up to its `equipPerChess` (2) slots, any further item destroyed with a log warning (unchanged). The consumed copies' summons are removed; an elite on the board gets its own fresh stack (`grantTokensFor`, after the items) following the §16 loadout [ASSUMED: placed summons are not carried over]. Every path (g.buy, g.reward, `ctx.grantChess`, 画卷, transformations), every phase (a SETTLE / Final Assault merge places the elite at once, its reward offer waits for the next prep) and the boss-field prep (legality on the player's half, §19.7). Reward offer, ticker and `stats.merges` unchanged; `onMerge` carries `area` ('board' | 'hand' | 'temp'). Bots plan the elite like any owned unit.
- **Now (client)**: `gameLogic.mergeTarget(priv, chessId)` mirrors `mergeTile`; a shop or reward card armed for a merge-completing buy lights the target tile in gold (`ShopBar onArm` → `screens/game.js` highlight group `mergeTile`); the 可晋升 tag says 精锐干员将出现在作战区原位置 or 精锐干员将进入整备区; an elite card never merges (`completesChessMerge` refuses `isGolden`, `mergeProgress` reports 0 copies: no pips, tag or tile); `render/promote.js promotionsOf` recognises a merge in setPrep and the elite plays `fx.promote` (gold pillar, shockwave, hex ring, streaks from the other copies) instead of `fx.deploy` (`view.debug.promotions`).
- **Checks**: `audit.js` checks every merge — the tile is `mergeTile` of the deployed copies legal for the elite, with that copy's facing; the deploy count did not grow (a carrier `transformChess` took off the board first still counts); otherwise the elite is not on the board, except the no-room fallback. `tools/matchrun --check` sweeps are clean.
- Normative lines rewritten: §2 (`promote.js`), §6.2 (the economy line and the temp-overflow paragraph), §9 (the promotion cue).
- Tests: `test/match/playtest6-elite-to-board.test.js`, `test/match/merge.test.js`, `hardening.test.js`, `facing.test.js`, `test/ui/playtest6-elite.test.js`, `test/render/playtest6-promote.test.js`, `test/docs-consistency.test.js`; opt-in `SP_E2E=1 node --test test/ui/playtest6-elite.e2e.test.js` (real server and prep: the armed card lights the deployed copy's tile, the elite stands there after the buy, 剩余可放置角色 unchanged).
### 20.12 Official limits: 999 layers per bond, 限伤 300000 on leaders — `shared/constants.js BOND_LAYER_CAP / BOSS_HIT_LIMIT / layerGainRoom`, `match/PlayerState.addLayers`, `Match` SETTLE, `sim/Battle.addLayers / loseHp`, `sim/damage.js leaderHitCancelled`, `match/fields.js validateClientResult`, `match/invariants.js`, `render/fx.js`, `tools/matchrun.mjs`, `tools/balance.mjs`
- **Asked**: the co-op leaders as hard to kill as in the official game. Research 11 (`docs/research/11-limits-official.md`) found two official caps in the client (2.7.71 il2cpp) that the remake lacked.
- **Official** [DATA]: `AutoChessBattleConst.MAX_GARRISON_STACK = 999` — `AddBondCount` stores `min(L + n, 999)`, per bond. `MAX_BATTLE_DAMAGE = 300000` — in step mode (every boss battle outside training) `AutoChessStepModeManager._OnBossEnemyTakeDamage` runs `modifier.Cancel()` when `ceil(value) ≥ 300000` on an `IsBossEnemy` unit; `IsBossEnemy` (disassembled, research 11 §2.3) = enemyId ∈ `autoChessData.bossInfoDict[*].enemyId` = data/bosses.json `enemyKey`. [COMM] bonds stay at 999 while fed; 「一次超过30w会被卡掉」: players avoid extra multipliers at 999 layers. 限伤 is a deliberate constant, **not** an engine fixed-point overflow — the boss-HP workstream's earlier reading (2³¹, "not modelled", §20.10) is superseded.
- **Now — layers**: the single implementation is `shared/constants.js BOND_LAYER_CAP` + `layerGainRoom(before, n)` = min(n, 999 − before), never negative (the boss-HP workstream's duplicate `sim/constants.js BOND_LAYER_CAP / layerRoom` was removed in the merge). Every writer uses it: `PlayerState.addLayers` (特质, items, bands, 机变 cards, bonds), the SETTLE of `BattleResult.layerGains`, `Battle.addLayers` (the live copy, clamped after the `layerGain` hook, like the client), and the dev tools' direct writes (`matchrun --layers N`, `balance.mjs applyBoard`). A gain at the cap adds 0: no onLayers, hook or 'layer' event, so the per-N milestones (远见, 奇迹, 维多利亚 …) stop with the count. Client results: per-bond `layerGains ≤ min(60 + 4·round, 999 − starting layers)`. `invariants.js` flags a bond above 999; the UI shows the server's capped count.
- **Now — 限伤**: kinds `'boss'` / `'hidden'` only; a leader is `isBoss` (the tag-'boss' units: those 10 keys and their mirrored copies — parts, 刺胄之弹, drones and escorts never count). A hit with `ceil(final) ≥ 300000` is cancelled: checked in `dealDamage` after DEF/RES and every multiplier, before HP shields, and in `loseHp` (a part's 传递, a drone's death). A cancelled hit causes no HP or pool loss, no credit or stats, no damage number, no `damaged` / `fatal` / kill; the attack, its SP, the `hit` hook and separate element 损伤 hits stay. Element bursts and DoT ticks are checked, gauge fills are not. The sim emits fx `hitCap {id, n}`, drawn as nothing (deterministic in Node and browsers; SP_VERIFY digests agree).
- **The line vs the pool**: 【死亡集群】 drone deaths cost 0.02 × the pool max (§20.10, §20.13) — at most 144 000 today (boss_8 终极 7.2M), so it lands; a pool above 14 999 950 would cancel every drone death. An alive-scaled pool (§20.9, open) can only shrink, but any larger pool must re-check it; a real-kit test pins the line.
- **[ASSUMED]**: the scene server clamps prep gains the same way; a cancelled hit shows nothing; a loss passed on to a leader is one hit; the order hit-step blocks → 限伤 → HP shields — 阿利斯泰尔's 莫非王土 vest (`boss:vest`, 5000): a hit of 300000–304999 is cancelled and the barrier stays; 假想敌:再生's blocks on 假想敌:管 (hidden h08_03) negate a hit past the line before the limit sees it.
- **Measured**: the competent and strong balance boards never reach the line (biggest leader hits 28k–72k), so BALANCE §3's model numbers stand (BALANCE §7); the co-op replays up to +400 per bond and a top bond at 999 cancel nothing either. Two bonds per player at 999 or every active bond at 999 do reach it: 6–18 cancelled hits per 28 Final Assaults and 14–77 per 12 Hidden Cores (QA 6b), and those kills take longer than with v2.5's uncapped hits (§20.10).
- **Flip**: `BOND_LAYER_CAP = 0` and / or `BOSS_HIT_LIMIT = 0` (or Infinity).
- Tests: `test/sim/playtest6_limits.test.js` (including the real-kit vest, 再生 and 【死亡集群】 cases), `test/match/playtest6-limits.test.js` (a 999-layer board through a real Final Assault + Hidden Core, both tools at the cap), `test/render/playtest6_hitcap.test.js`, `test/sim/playtest6_bosspool.test.js` (adapted), and the boss-HP workstream's `test/match/layercap.test.js` / `test/sim/battle.test.js` cap tests on the single API.
### 20.13 假想敌:胄 kit audit (PRTS) — `content/bosses.js kitHelm / kitBlade`
- **Audit** (a separate session, 2026-10-01), against the PRTS 技能 / 天赋 / 能力修正 texts and the skill blackboards:
- 灭顶之灾 fires **one** 刺胄之弹 below 20 % too (PRTS 能力修正 原因 6 "描述与游戏实际表现不符合": no extra shell; no blackboard holds a second one), at the highest-ATK operator **within attack range** for both copies ("触发索敌和普通攻击相同"; boss_1 used the whole field).
- 死亡集群: **any** drone death costs the leader 2 % max HP (= the pool, §20.10), credited to the killing operator if one killed it; a leak costs nothing (能力修正 "该无人机单位死亡时"). Drone HP: `summon.hp_ratio` (隐秘核心) scales it; skill 2's `max_hp` 0.5 is not read [ASSUMED] — no source defines it.
- 斩胄之剑 / 破胄之锤: 初始模式 hovers invulnerable (untargetable [ASSUMED]) and attacks everything in range (锤 150 % ATK, unblockable). 掷 → 出击模式: blink to the start of the level's `<hand>_blink` route, fly at the lowest-ATK operator, no normal attack. 15 hits → 【瘫痪】: stun 10 s (its end ends 瘫痪, not the 20 s), ground unit, damage taken ×1.3. Landing or 瘫痪 ending: a new 初始模式 copy with the old HP is summoned on the `<hand>_origin` route and the old one is killed (a part: uncounted, no bounty) [ASSUMED: only the HP is inherited, so 掷 comes back after its initial 25 / 55 s instead of the 80 s cooldown].
- Every damage a 剑 / 锤 takes passes 100 % to 胄 (PRTS "等量", also during the dive) — merged with §20.10 into the one `PART_TRANSFER` 1 (`BLADE_TRANSFER` is its alias), 无来源 and credited to the attacker.
- **Measured** (same-seed `balance.mjs --boss-only`, n = 3–5 per cell): boss_1 unchanged (≤ 0.5 s); boss_8 独立 绝境 44.7 → 45.2 s, 独立 终极 win 2/3 → 3/3, 同盟 绝境 23.3 → 23.7 s, 同盟 终极 72.0 → 64.2 s (BALANCE §3).
- Tests: `test/content/enemies_bosses.test.js` (shell count and range, drone deaths and leaks, the pool-based link, the sortie / 瘫痪 / replacement cycle with the 1:1 无来源 transfer and its credit).
### 20.14 QA residuals of the integrated build (2026-10-02) — `match/Match.js _onResult / heldResult`, `render/app.js promoBase`, `ui/shopBar.js mergeHint`, `ui/detailPanel.js`, `screens/game.js dSide`
Two QA verifiers ran the integrated `playtest6b` (real 4-AI and 2-human co-op matches for every leader, Node vs headless Chrome digests, the merge UI on the user's phone profile and on desktop). Every check passed; the residual issues and what was done:
- **Fast boss kills handed over (fixed).** Under client-side combat a pair that empties the pool faster than the plausibility budget (one field credits ≤ the pool per `BOSS_MIN_CLEAR_GS` 5 game s, §14) had its `'cleared'` `b.result` rejected as implausible, the client demoted and the field handed to the partner or the server — seen at 绝境 with every bond at 999 (kills at 2.0–4.0 game s; official 999-layer play reports "boss一秒死"). Now a `'cleared'` result whose cumulative report covers what the pool holds (1 HP slack, the pool floor) is **held** (`f.heldResult`): the boss clock credits the report at the budget's rate, the silence watchdog and handovers skip the field, a re-sent copy is ignored, and the pool's end completes the field with it (`_endFinal`); a team-LP defeat first still credits nothing more. The budget itself is unchanged, so a forged report gains nothing a `b.progress` could not already get. The verdict waits at most until the budget covers the report (≤ 5 game s from the field's start). Re-run of the QA cases (绝境 seed 7 boss_1 / boss_6 with every bond pushed to 999, seed 21 with 990 early layers, 3 more at 999 / 终极): 0 rejections, 0 takeovers, every boss field kept by its client, Σ credited boss damage = the cleared pools; in headless Chrome (2 humans + 2 AI, a scratch server with a 12× slower budget so the path runs) both boss rounds held the clearing client's result and went on to the Hidden Core and the result with no takeover and no console error.
- **No promotion cue for a merge outside the prep (fixed).** A merge completed between two preps (a band / 机变 grant at ROUND_START, a SETTLE merge) placed the elite silently: `enterBattle` emptied the prep state the cue compares with. `render/app.js` keeps the last prep pieces across a battle or scouting board (`promoBase`), so the next `setPrep` plays `fx.promote` on the elite (streaks from the consumed copies' slots). Re-run of the QA case (松桐 grants the third 古米 at R4 ROUND_START): `view.debug.promotions` records the elite on the copy's tile, copies 2.
- **The 可晋升 destination on touch (fixed).** The tag's text was a native `title`, never shown on a phone. The detail card the first tap opens now says it under the header — 「可晋升:精锐干员将出现在作战区原位置」 / 「…将进入整备区」 (`shopBar.js mergeHint`, gold `.dhint--merge`; shop and reward cards).
- **The detail card covered the gold tile (fixed).** At 1920×1080 the left card covered half of a target tile in the leftmost legal column; the card now docks on the side away from the armed merge tile (`screens/game.js dSide`, the underframe's `panelSide` rule), on phones too.
- **Measurements re-taken (docs).** §20.10 / §20.12 and BALANCE §3 / §7 now give the integrated build's numbers (real seconds; the earlier boss-HP numbers are labelled as pre-merge boards) and the 999-layer profiles where 限伤 cancels hits.
- **Not changed — per-source talent debuffs [ASSUMED].** Some talents key their debuff with the source unit (`etlchi:dot:<id>`, `ines:atkSteal:<id>`, `ines:sentry:<id>`, and similar slows / DoTs in other kits), so two copies of one operator (a pair field where both players own it) each apply theirs. PRTS 作战机制 says only that same-named buffs show one instance under the *default* overlap strategy; whether these talents use it across copies is not in the data the project has, and the effect on leader kill times is small (an arts DoT, an ATK steal, a slow). The rule stays applied to the debuffs that move leader kills (奥术, 灵知 坚冰, the RES cuts — §20.10).
- **Not changed — temp items behind a full hand.** With the hand full, items overflowing into the 临时整备区 (row 8) stand behind the bench row's operator models and are hard to see; the row's red frame, the 「临时整备区 N 件道具待处理」 notice and the refused Ready still show them. A layout change of the temp row is left for a later pass.
- Tests: `test/match/clientCombat.test.js` (the held result; a 'cleared' result not covering the pool is still handed over), `test/render/playtest6-promote.test.js`, opt-in `RENDER_E2E=1 node --test test/render/browser.test.js` (a merge between two preps is cued), `test/ui/playtest6-elite.test.js` (the detail hint), opt-in `SP_E2E=1 node --test test/ui/playtest6-elite.e2e.test.js` (the hint in the real detail card).
+58 -24
View File
@@ -35,9 +35,9 @@ server/match/
LOBBY → INFO_CHECK (co-op 25 s; solo and single-human matches untimed; all humans confirmed ⇒ next) → BAND_DRAFT → BATTLE_CHECK (3 s) LOBBY → INFO_CHECK (co-op 25 s; solo and single-human matches untimed; all humans confirmed ⇒ next) → BAND_DRAFT → BATTLE_CHECK (3 s)
→ for r = 1..lastRound (+ hidden): → for r = 1..lastRound (+ hidden):
ROUND_START (2 s) income + pending coins, upgrade price −1 (r > 1, floor 0), temp NOT wiped (what overflowed after ROUND_START (2 s) income + pending coins, upgrade price −1 (r > 1, floor 0), temp NOT wiped (what overflowed after
the last prep's deadline — battle-result grants, a SETTLE merge's elite, returned equipment — is the last prep's deadline — battle-result grants, a SETTLE merge's elite with no copy deployed,
shown and usable in this prep, `PlayerState.tempDue`; reward offers earned after the last prep — returned equipment — is shown and usable in this prep, `PlayerState.tempDue`; reward offers
a SETTLE merge — are kept for this prep), earned after the last prep — a SETTLE merge — are kept for this prep),
shop reroll (frozen slots kept in place, then unfrozen), the round's wave generated (preview), shop reroll (frozen slots kept in place, then unfrozen), the round's wave generated (preview),
onRoundStart dispatch onRoundStart dispatch
[SP_DRAFT] r ∈ modes[m].spRounds (机变) [SP_DRAFT] r ∈ modes[m].spRounds (机变)
@@ -130,8 +130,9 @@ A `choice:<effectId>` registry handler overrides the default application (§2.4)
* `g.autoplay { on }` ("AI 托管"): the bot plays the seat (drafts, buying, placement, ready) until turned off. * `g.autoplay { on }` ("AI 托管"): the bot plays the seat (drafts, buying, placement, ready) until turned off.
* `onLeave` (quit / reconnect window expired): 中途退出 counts as elimination (research 00-INDEX §3, 01 §9, 06 §7 / * `onLeave` (quit / reconnect window expired): 中途退出 counts as elimination (research 00-INDEX §3, 01 §9, 06 §7 /
§10.3): every copy the seat holds returns to the shared pool at once; the seat leaves the round loop and the Final §10.3): every copy the seat holds returns to the shared pool at once; the seat leaves the round loop and the Final
Assault pairing (re-planned when it quits before the boss fight; the boss pool has no alive-player factor, so it does Assault pairing (re-planned when it quits before the boss fight; the boss pool stays bloodPoint — it would shrink to
not change); its running normal battle is force-ended; a pending band pick × alive / 4 only with config `bossHpScale.aliveScaling`, off — the user chose the fixed pool, DESIGN §20.10); its
running normal battle is force-ended; a pending band pick
becomes the default band and a 机变 turn passes on. Status `left`, LP 0, rounds passed = the rounds it had survived. becomes the default band and a 机变 turn passes on. Status `left`, LP 0, rounds passed = the rounds it had survived.
When no human is left at all the match ends immediately (`reason: 'abandoned'`); when only eliminated spectators are When no human is left at all the match ends immediately (`reason: 'abandoned'`); when only eliminated spectators are
left it ends as `'eliminated'`. left it ends as `'eliminated'`.
@@ -230,7 +231,7 @@ Every handler method is `(ctx, ev)`; `ev` is shared by all handlers of one dispa
| `onRefresh` | manual refresh | `{ slots, free, price }` (slots mutable, or use `ctx.setShopSlot`) | | `onRefresh` | manual refresh | `{ slots, free, price }` (slots mutable, or use `ctx.setShopSlot`) |
| `onPrice` | every price query of a shop slot (views + buy) — must be **pure** | `{ slot, kind, id, price }` — write `ev.price` / `ctx.setPrice` / `ctx.modifyPrice` | | `onPrice` | every price query of a shop slot (views + buy) — must be **pure** | `{ slot, kind, id, price }` — write `ev.price` / `ctx.setPrice` / `ctx.modifyPrice` |
| `onBuy` | after a purchase | `{ piece, slot, price, kind }` (`piece` = owned result, elite after a merge) | | `onBuy` | after a purchase | `{ piece, slot, price, kind }` (`piece` = owned result, elite after a merge) |
| `onMerge` | chess or item merge | `{ kind, piece, baseId|itemId, consumed:[uid] }` | | `onMerge` | chess or item merge | `{ kind, piece, baseId|itemId, consumed:[uid], area? }` — a chess merge's `area` is where its elite went: 'board' (a consumed copy's tile) \| 'hand' \| 'temp' |
| `onLevelUp` | shop level up | `{ level, price }` | | `onLevelUp` | shop level up | `{ level, price }` |
| `onSpend` | a payment's action is complete (buy / refresh / levelUp / reward / effect) | `{ amount, reason, total }` (`total` = funds spent this match) | | `onSpend` | a payment's action is complete (buy / refresh / levelUp / reward / effect) | `{ amount, reason, total }` (`total` = funds spent this match) |
| `onBattleStart` | a battle input is built (normal / unite / boss / hidden); also for the stats preview (`g.unitStats`, DESIGN §18.5) | `{ input: PlayerBattleInput, kind, round, spawns?, preview? }` — mutate/replace `ev.input`. With `ev.preview` true (no `ev.spawns`) a handler must NOT change match state or draw match rng: the preview only reads the input | | `onBattleStart` | a battle input is built (normal / unite / boss / hidden); also for the stats preview (`g.unitStats`, DESIGN §18.5) | `{ input: PlayerBattleInput, kind, round, spawns?, preview? }` — mutate/replace `ev.input`. With `ev.preview` true (no `ev.spawns`) a handler must NOT change match state or draw match rng: the preview only reads the input |
@@ -239,7 +240,7 @@ Every handler method is `(ctx, ev)`; `ev` is shared by all handlers of one dispa
| `onEquip` | `g.equip` (item handler only) | `{ item, target, golden, consumed, keep, error }` — see §2.5 | | `onEquip` | `g.equip` (item handler only) | `{ item, target, golden, consumed, keep, error }` — see §2.5 |
| `onArt` | `g.art` (item handler only) | `{ item, row, col, targets:[pieces], error, used }` | | `onArt` | `g.art` (item handler only) | `{ item, row, col, targets:[pieces], error, used }` |
| `onDestroy` | an item was destroyed (player / replaced) | `{ item, holder, reason }` | | `onDestroy` | an item was destroyed (player / replaced) | `{ item, holder, reason }` |
| `onLayers` | bond layers were added (prep or battle gains) | `{ bondId, from, to, reason }` (milestones: 维多利亚 25, 远见 10, 奇迹 100 …) | | `onLayers` | bond layers were added (prep or battle gains) | `{ bondId, from, to, reason }` (milestones: 维多利亚 25, 远见 10, 奇迹 100 …); `to` ≤ 999 (`BOND_LAYER_CAP`) — a gain at the cap dispatches nothing, so the milestones stop with the count |
Dispatch order per player: `global` → `band` → `bond` (data order) → garrisons (board in deployment order, then hand) Dispatch order per player: `global` → `band` → `bond` (data order) → garrisons (board in deployment order, then hand)
→ equipped items → EffectRefs (insertion order). `onPrice` runs the priced chess's own 特质 first (购买价格为N sets the → equipped items → EffectRefs (insertion order). `onPrice` runs the priced chess's own 特质 first (购买价格为N sets the
@@ -287,7 +288,7 @@ Writes (all validated, never throw on bad input, never make funds / pools negati
| helper | effect | | helper | effect |
|---|---| |---|---|
| `addFunds(n, reason?)` / `addPendingFunds(n)` / `spendFunds(n)` | funds now / at the next round start / pay (false when short) | | `addFunds(n, reason?)` / `addPendingFunds(n)` / `spendFunds(n)` | funds now / at the next round start / pay (false when short) |
| `addLayers(bondId, n, { requireActive })` | layer gain (`requireActive` = "使已激活的…"); returns layers added; fires onLayers | | `addLayers(bondId, n, { requireActive })` | layer gain (`requireActive` = "使已激活的…"); returns layers added — at most the room left under `BOND_LAYER_CAP` (999, shared/constants.js `layerGainRoom`; the battle gains merged at SETTLE too); fires onLayers unless it added 0 |
| `grantChess(id, { toTemp, golden, requirePool=true, fromPool=true })` | acquire a chess (takes pool copies; with `requirePool` a pool chess with no copy left fails → `null`); merges; fires onGain | | `grantChess(id, { toTemp, golden, requirePool=true, fromPool=true })` | acquire a chess (takes pool copies; with `requirePool` a pool chess with no copy left fails → `null`); merges; fires onGain |
| `grantItem(id, { toTemp })` | acquire an item (merges with an identical normal item) | | `grantItem(id, { toTemp })` | acquire an item (merges with an identical normal item) |
| `rollChess({ maxTier, tier, bond, filter })` / `rollItem({ pool, tier, maxTier })` | copy-weighted chess id from the shared pool / item id (choices.json pools) | | `rollChess({ maxTier, tier, bond, filter })` / `rollItem({ pool, tier, maxTier })` | copy-weighted chess id from the shared pool / item id (choices.json pools) |
@@ -386,14 +387,23 @@ Any `choice:` handler whose EffectRef reuses its own key must guard like this (o
Ready, during onPrepEnd, or outside PREP (COMBAT, 联防, SETTLE, ROUND_START, 机变) → the end of the NEXT prep, so it Ready, during onPrepEnd, or outside PREP (COMBAT, 联防, SETTLE, ROUND_START, 机变) → the end of the NEXT prep, so it
is shown and usable first; `setReady(false)` makes what arrived while ready due at the current prep. The round is shown and usable first; `setReady(false)` makes what arrived while ready due at the current prep. The round
start never wipes temp (DESIGN §6.2). start never wipes temp (DESIGN §6.2).
* **Merge**: 3 normal copies (风丸 2) anywhere (board/hand/temp) → elite to the hand (the incoming copy, then temp, hand, * **Merge**: 3 normal copies (风丸 2) anywhere (board/hand/temp) → 1 elite (the incoming copy, then temp, hand, board
board copies are consumed); equipment returns to the hand; summons of consumed copies are removed; a reward offer of 3 copies are consumed). Where it goes (PRTS 卫戍协议/帮助 "发送1名【精锐】状态的该干员至手牌区(若消耗已部署至作战区的干员,
则发送至作战区对应位置)", the user's playtest #6 follow-up): when a consumed copy stood on the board, onto that copy's
tile with its facing — of several, the one that deploys first (row desc, then col asc; `board.js mergeTile`,
[ASSUMED]); a deployed piece transformed into the completing copy (突变细胞, `transformChess`) counts with its own tile;
a tile the elite may not use (a stale terrain change) is skipped. It replaces a deployed copy, so the deploy count never
grows (no BOARD_FULL), and as a deployment its manually deployable summons join the hand (`grantTokensFor`, the
player's loadout). Otherwise the elite goes to the hand, overflowing into temp. Equipment returns to the hand (overflow
temp; with both full it stays on the elite, up to its 2 equip slots — any further item is destroyed); summons of
consumed copies are removed; a reward offer of 3
**different** free chess of tier min(level+1, 6) (copy-weighted from the shared pool, already-drawn ones excluded; a **different** free chess of tier min(level+1, 6) (copy-weighted from the shared pool, already-drawn ones excluded; a
short tier tops up from the tier below — user playtest #6 item 19; pick 1, expires at prep end; queued when several short tier tops up from the tier below — user playtest #6 item 19; pick 1, expires at prep end; queued when several
merges happen). A merge merges happen). The same rule holds for every way a merge completes — buy, reward pick, effect / band / choice grants
completed after the prep (SETTLE / Final Assault effects such as 突变细胞) keeps its offer for the next prep; its elite (`acquireChess`), transformations — and in every phase: a merge completed after the prep (SETTLE / Final Assault
goes to the hand like any merge's, overflowing into temp (kept through the next prep, see Hand) — it takes a freed effects such as 突变细胞) keeps its offer for the next prep; its elite takes the deployed copy's tile at once, or goes to
board tile of a consumed copy only when the hand and temp are both full (research 01 A1). the hand / temp (kept through the next prep, see Hand). In a boss round's prep the tile is read on the player's half of
the boss field (board coordinates unchanged). `onMerge` carries `area` ('board' | 'hand' | 'temp').
* **Board**: rows 9–12 × cols 2–10, legality from `stages[id].tiles` + devices (board.js); deploy cap 8 (+effects); * **Board**: rows 9–12 × cols 2–10, legality from `stages[id].tiles` + devices (board.js); deploy cap 8 (+effects);
summons don't use slots; board↔hand swaps always allowed. A terrain change (terrain 机变 cards such as 模拟战场演变· summons don't use slots; board↔hand swaps always allowed. A terrain change (terrain 机变 cards such as 模拟战场演变·
模式二 "阻隔工事变为射击台", content `setDeviceActive` / `setTileOverride`) is checked at the next `recompute()` (at the 模式二 "阻隔工事变为射击台", content `setDeviceActive` / `setTileOverride`) is checked at the next `recompute()` (at the
@@ -412,6 +422,14 @@ Any `choice:` handler whose EffectRef reuses its own key must guard like this (o
to the deploy limit, `grantTokensFor`). Battle side: SIM.md §1.1 token pieces. to the deploy limit, `grantTokensFor`). Battle side: SIM.md §1.1 token pieces.
* **Bonds**: bondsMeta.js (BOARD distinct, BOARD_AND_DECK, 绝技 elites, 调和 +1, 独行 downward, 助力 upper tiers, * **Bonds**: bondsMeta.js (BOARD distinct, BOARD_AND_DECK, 绝技 elites, 调和 +1, 独行 downward, 助力 upper tiers,
变形同构体 grants). Σ activated layers for the hidden core = Σ layers of active bonds at the boss round's prep end. 变形同构体 grants). Σ activated layers for the hidden core = Σ layers of active bonds at the boss round's prep end.
**Layer cap** (research 11 §1; the client's `MAX_GARRISON_STACK` / `AddBondCount` = min(L + n, 999)): each bond's
layers stop at `BOND_LAYER_CAP` = 999 (shared/constants.js; 0 / Infinity = off). Every writer clamps with
`layerGainRoom` — `PlayerState.addLayers` (all prep-side gains: 特质, items, bands, 机变 cards, bonds; `ctx.addLayers`),
the SETTLE of the in-battle gains and the battle's own live copy (`Battle.addLayers`, SIM.md §6); a gain at the cap
adds 0 and dispatches no onLayers. The client-result check bounds a reported gain by the room left from the bond's
starting layers (`fields.js validateClientResult`, 'layer bound'); `invariants.js` flags a bond above the cap. The
bond strip, its popup, the effect text and the detail card show the server's capped count. The dev tools' direct
writes (`tools/matchrun.mjs --layers N`, `tools/balance.mjs applyBoard`) stop at the cap too.
* **Waves**: waves.js header (stage/factions/boss per match, faction replacement per round with `k` copies, scaling by * **Waves**: waves.js header (stage/factions/boss per match, faction replacement per round with `k` copies, scaling by
`enemyScale[r]` × the tuning layer §3.1, bounties, boss templates, 联防 routing). Bounties with battles left (every one `enemyScale[r]` × the tuning layer §3.1, bounties, boss templates, 联防 routing). Bounties with battles left (every one
that lasts more than one battle; an official multi-round card lasts two, §1.2) also spawn in the Final Assault / that lasts more than one battle; an official multi-round card lasts two, §1.2) also spawn in the Final Assault /
@@ -422,13 +440,17 @@ Any `choice:` handler whose EffectRef reuses its own key must guard like this (o
default 2). Read as game seconds the rounds' own spawn schedules would not fit (R2's last flyer spawns at 43 s of default 2). Read as game seconds the rounds' own spawn schedules would not fit (R2's last flyer spawns at 43 s of
45 s, R3's at 62 s of 55 s); × 2 every limit ≈ last spawn + one flyer crossing. docs/BALANCE.md §2.1. 45 s, R3's at 62 s of 55 s); × 2 every limit ≈ last spawn + one flyer crossing. docs/BALANCE.md §2.1.
* **SETTLE**: LP −min(counted leaks, 10) (after 联防: survivors attributed to their source, same cap); IN_BATTLE layer * **SETTLE**: LP −min(counted leaks, 10) (after 联防: survivors attributed to their source, same cap); IN_BATTLE layer
gains applied; kill-bounty coins (paid by the Battle to the killer — a 联防 helper included) and perfect-bounty coins gains applied, each bond up to `BOND_LAYER_CAP` (999, `layerGainRoom`, as `PlayerState.addLayers`); kill-bounty coins (paid by the Battle to the killer — a 联防 helper included) and perfect-bounty coins
(own phase perfect) go to pending funds; bounty rounds decrement; LP ≤ 0 ⇒ eliminated (all copies back to the pool). (own phase perfect) go to pending funds; bounty rounds decrement; LP ≤ 0 ⇒ eliminated (all copies back to the pool).
* **Final Assault / Hidden Core**: finalAssault.js header. Boards are passed in board coordinates; the sim maps board * **Final Assault / Hidden Core**: finalAssault.js header. Boards are passed in board coordinates; the sim maps board
rows 9–12 onto boss rows 2–5 (`BOSS_ROW_OFFSET` −7, matching every stage's boss rows) and mirrors the right side. rows 9–12 onto boss rows 2–5 (`BOSS_ROW_OFFSET` −7, matching every stage's boss rows) and mirrors the right side.
Pool (`finalAssault.js bossPoolHp`, research 08 §6 #7): co-op = `bloodPoint[difficulty]` as is — one pool shared by Pool (`finalAssault.js bossPoolHp` → `GameData.bossPoolShare`, DESIGN §20.10): one pool shared by every boss field
every boss field, no alive-player factor ("敌方领袖的总生命值不变"); solo = × `bossHpScale.solo` (0.25 [ASSUMED]); leaders (official tip "最终攻势中,所有人将一起对敌方领袖造成伤害"); co-op = `bloodPoint[difficulty]` whatever the number of alive
are never scaled by `enemyScale`. The merged team LP loses leaks (`lpr`), the overtime drain players (notice 5114's "敌方领袖的总生命值不变" is about the mirrored copies of a pair field sharing the pool, not about that
number); `bossHpScale.aliveScaling` true (default false) would scale it × alive / `aliveFull` (4) — 巴哈姆特 12294
"聯機隊友(撤退/死掉)變少,最後boss血條也會變少" is one community note without a proportion, kept off until the user confirms
it (it would shorten the fight after eliminations, the opposite of the playtest report); solo = ×
`bossHpScale.solo` (0.25 = one player of four [ASSUMED]); leaders are never scaled by `enemyScale`. 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 (`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`) battle goes on — so 1 LP per real second from 150 real s = 300 game s on the 2× field clock; `gd.bossOvertimeDue`)
and leader "扣除目标生命" effects (the sim's `lpLoss` hook: boss_7 Doom, 斥退 …); after every change it is written back and leader "扣除目标生命" effects (the sim's `lpLoss` hook: boss_7 Doom, 斥退 …); after every change it is written back
@@ -438,9 +460,11 @@ Any `choice:` handler whose EffectRef reuses its own key must guard like this (o
credited pool damage of one field ≤ the whole pool per `BOSS_MIN_CLEAR_GS` (5) game s (20 %/s; the balance model's credited pool damage of one field ≤ the whole pool per `BOSS_MIN_CLEAR_GS` (5) game s (20 %/s; the balance model's
fastest mean kills run ≈ 5 %/s per field), its LP cost ≤ 10 + 1 per game s; reports are cumulative, so what exceeds fastest mean kills run ≈ 5 %/s per field), its LP cost ≤ 10 + 1 per game s; reports are cumulative, so what exceeds
the budget is credited later (the boss clock re-applies the latest report), never lost. A boss field's `b.result` ends the budget is credited later (the boss clock re-applies the latest report), never lost. A boss field's `b.result` ends
it only when the pool is empty or after the match's `b.end`; any other (a 'forced' result at t = 0, 'cleared' while it only when the pool is empty or after the match's `b.end`; a 'cleared' result whose report covers what the pool
the pool holds, a result failing validation) hands the field to the partner's replica or the server, and the sender is holds waits for the budget instead (`f.heldResult`: no takeover — 999-layer kills take 2–4 game s, DESIGN §20.14);
never its authority again (`f.demoted`). Every boss field run by the server (takeover, or nobody connected at the any other (a 'forced' result at t = 0, 'cleared' while the pool holds more than the report covers, a result failing
validation) hands the field to the partner's replica or the server, and the sender is never its authority again
(`f.demoted`). Every boss field run by the server (takeover, or nobody connected at the
start) publishes its own damage as `b.pool.acked[fieldId]` (its CreditPool total), so the display replica of a start) publishes its own damage as `b.pool.acked[fieldId]` (its CreditPool total), so the display replica of a
reconnecting / watching human stays in sync. reconnecting / watching human stays in sync.
End and verdict (user playtest #6 item 5): the pool never holds less than 1 HP (`sim/constants.js BOSS_POOL_MIN_HP`: End and verdict (user playtest #6 item 5): the pool never holds less than 1 HP (`sim/constants.js BOSS_POOL_MIN_HP`:
@@ -450,6 +474,11 @@ Any `choice:` handler whose EffectRef reuses its own key must guard like this (o
on until the overtime drain. The first end condition the server registers decides (`Match._finalEnding`): pool 0 → on until the overtime drain. The first end condition the server registers decides (`Match._finalEnding`): pool 0 →
victory, team LP 0 → defeat (PRTS 卫戍协议:盟约 下半 "…使目标生命值扣除至0,则无视倒计时直接失败"); after a forced end victory, team LP 0 → defeat (PRTS 卫戍协议:盟约 下半 "…使目标生命值扣除至0,则无视倒计时直接失败"); after a forced end
no report (an in-flight `b.progress`, the final `b.result` with its rounded per-player damage) credits the pool. no report (an in-flight `b.progress`, the final `b.result` with its rounded per-player damage) credits the pool.
**限伤** (research 11 §2; client `AutoChessStepModeManager._OnBossEnemyTakeDamage`): in both boss rounds a single hit
on a leader with ceil(damage) ≥ `BOSS_HIT_LIMIT` = 300000 (shared/constants.js; 0 / Infinity = off) is cancelled in the
sim (`sim/damage.js leaderHitCancelled`, SIM.md §4) — it deals 0 and nothing reaches the shared pool, the per-player
boss damage or the BOSS_HIT tickers; minions, parts, normal rounds and 联防 are unaffected. The server's own runs, the
browsers' runs and the server's verification share the rule, so digests agree.
### 3.1 Balance layer (data/tuning.json) ### 3.1 Balance layer (data/tuning.json)
`data/config.json` is generated and stays research-faithful. There is **no custom balance** any more (DESIGN §14 `data/config.json` is generated and stays research-faithful. There is **no custom balance** any more (DESIGN §14
@@ -562,7 +591,8 @@ receiver only), plus CUSTOM texts (eliminations, 联防, hidden core).
model, layout planner, rehearsal side-effect freedom, economy/bench regression). model, layout planner, rehearsal side-effect freedom, economy/bench regression).
`node --test test/match/*.test.js` `node --test test/match/*.test.js`
* `node tools/matchrun.mjs --mode coop --difficulty HARD --players 4 --seeds 20` — per-round balancing summary / aggregate * `node tools/matchrun.mjs --mode coop --difficulty HARD --players 4 --seeds 20` — per-round balancing summary / aggregate
(`--check` audit, `--errors` per-source error table, `--lp N` / `--layers N` to reach late rounds, `--rehearsal N`). (`--check` audit, `--errors` per-source error table, `--lp N` / `--layers N` to reach late rounds — the boost stops at
999 per bond —, `--rehearsal N`).
* `node tools/balance.mjs --mode multi --difficulty NORMAL` — the competent-board difficulty model (docs/BALANCE.md): * `node tools/balance.mjs --mode multi --difficulty NORMAL` — the competent-board difficulty model (docs/BALANCE.md):
per round leaks / LP after 联防 / clear time of representative boards against the real waves, boss damage by 150 s per round leaks / LP after 联防 / clear time of representative boards against the real waves, boss damage by 150 s
and kill time; `--tuning off` (research numbers), `--legacy-time`, `--profile weak|strong`, `--bots N`, `--json`. and kill time; `--tuning off` (research numbers), `--legacy-time`, `--profile weak|strong`, `--bots N`, `--json`.
@@ -579,6 +609,9 @@ receiver only), plus CUSTOM texts (eliminations, 联防, hidden core).
* Chess granted by effects need a free pool copy unless `requirePool: false` (then they hold 0 copies). * Chess granted by effects need a free pool copy unless `requirePool: false` (then they hold 0 copies).
* Boss-round `local` pack spawns (boss parts) all spawn; content scripts (bosses.js) decide their behaviour. * Boss-round `local` pack spawns (boss parts) all spawn; content scripts (bosses.js) decide their behaviour.
* The Final Assault ends as a defeat when every field finished with the boss pool above 0 (boss escaped). * The Final Assault ends as a defeat when every field finished with the boss pool above 0 (boss escaped).
* The 999 layer cap holds for every prep-side gain too (the client only shows the in-battle clamp; the scene server's
code is not in the client — research 11 §1.2). A 限伤-cancelled hit shows no number; a part's damage passed on to its
leader (Battle.loseHp) meets the limit like a hit (research 11 §2.3).
* Combat limits are real seconds (× 2 in game seconds) — see §3; research 00-INDEX §8 #10 assumed game seconds. * Combat limits are real seconds (× 2 in game seconds) — see §3; research 00-INDEX §8 #10 assumed game seconds.
* Emotes faster than 1/s answer `RATE`. * Emotes faster than 1/s answer `RATE`.
* 中途退出 = elimination at once (research); a quitter's operators already fighting in a shared field (联防, boss * 中途退出 = elimination at once (research); a quitter's operators already fighting in a shared field (联防, boss
@@ -588,5 +621,6 @@ receiver only), plus CUSTOM texts (eliminations, 联防, hidden core).
§4) with HP 0, and redeploys after its full redeploy time: no timer carry (the official setup carries only hp / tech §4) with HP 0, and redeploys after its full redeploy time: no timer carry (the official setup carries only hp / tech
per operator), forced out before `battleStart` (its timer re-read after it, so 征召's −50 % applies; 征召's row check per operator), forced out before `battleStart` (its timer re-read after it, so 征召's −50 % applies; 征召's row check
does not count it), no knock-out hooks (`kill`, 'killed' deaths) a second time. does not count it), no knock-out hooks (`kill`, 'killed' deaths) a second time.
* A merge completed after the prep (SETTLE effects) keeps its reward offer for the next prep; its elite goes to the hand, * A merge completed after the prep (SETTLE effects) keeps its reward offer for the next prep; its elite goes where a prep
overflowing into temp like a prep merge's (temp pieces that arrive after the prep wait through the next prep). merge's would — onto the tile of a consumed deployed copy (PRTS 卫戍协议/帮助 "若消耗已部署至作战区的干员,则发送至作战区
对应位置"), else to the hand, overflowing into temp (temp pieces that arrive after the prep wait through the next prep).
+8 -3
View File
@@ -55,7 +55,7 @@
- **购买点两次**:第一次点商店卡片只选中(卡片抬起并打开详情,底部出现「确认购买 · 再次点击」),再点这张卡片的**任意位置**(包括金色的确认条)才购买;点别处或按 Esc 取消。升级调度中心、晋升奖励同样两次。不能购买时(例如已准备就绪)点卡片只打开详情。 - **购买点两次**:第一次点商店卡片只选中(卡片抬起并打开详情,底部出现「确认购买 · 再次点击」),再点这张卡片的**任意位置**(包括金色的确认条)才购买;点别处或按 Esc 取消。升级调度中心、晋升奖励同样两次。不能购买时(例如已准备就绪)点卡片只打开详情。
- **公共卡池**(同盟):每名干员的副本数有限,I–VI 阶分别 12 / 14 / 18 / 16 / 8 / 5 份,所有玩家共用;持有的干员(棋盘 + 整备区)占用副本,商店里展示的不占用。一名干员被大家拿光后就刷不出来了;淘汰玩家的干员会还回卡池。 - **公共卡池**(同盟):每名干员的副本数有限,I–VI 阶分别 12 / 14 / 18 / 16 / 8 / 5 份,所有玩家共用;持有的干员(棋盘 + 整备区)占用副本,商店里展示的不占用。一名干员被大家拿光后就刷不出来了;淘汰玩家的干员会还回卡池。
- **位置上限**:棋盘最多部署 **8** 名;整备区 **10** 格;整备区满时新获得的单位(合成的精锐、奖励、退回的装备等)进入**临时整备区**(整备区前面的 5 格)。临时整备区有单位时**不能准备就绪**,棋盘上会用红色虚线框标出这一排并说明规则:把它们放进整备区或战场、配发或使用,或者出售 / 销毁。休整期中进入临时整备区的单位,若到**本休整期结束**时仍留在那里就会被自动处理(干员返还卡池、装备销毁);作战、结算期间或准备就绪之后才进入的(例如战斗奖励、结算时合成的精锐),会保留到**下一个休整期**结束,你一定有机会处理它们。 - **位置上限**:棋盘最多部署 **8** 名;整备区 **10** 格;整备区满时新获得的单位(合成的精锐、奖励、退回的装备等)进入**临时整备区**(整备区前面的 5 格)。临时整备区有单位时**不能准备就绪**,棋盘上会用红色虚线框标出这一排并说明规则:把它们放进整备区或战场、配发或使用,或者出售 / 销毁。休整期中进入临时整备区的单位,若到**本休整期结束**时仍留在那里就会被自动处理(干员返还卡池、装备销毁);作战、结算期间或准备就绪之后才进入的(例如战斗奖励、结算时合成的精锐),会保留到**下一个休整期**结束,你一定有机会处理它们。
- **晋升精锐**:3 名同名干员(无论是否部署)自动合成**精锐**(等级、技能、模组大幅提升;风丸只需 2 名),精锐不会再合成。精锐进入整备区(满了进入临时整备区),原先配发的装备退回整备区。 - **晋升精锐**:3 名同名干员(无论是否部署)自动合成**精锐**(等级、技能、模组大幅提升;风丸只需 2 名),精锐不会再合成。如果被合成的干员里有**已经部署在战场上**的,精锐会直接出现在**那名干员的位置**(保持原来的朝向,不多占部署位;有两三名都在场上时,取最先部署的那名——从上到下、从左到右);否则精锐进入整备区(满了进入临时整备区)。原先配发的装备都退回整备区,需要重新配发;被合成干员的召唤物随之移除,场上的精锐会把自己的召唤物送进整备区。点选一张能合成的商店卡片时,精锐将出现的那一格会亮起金色边框,同时打开的详情卡里也会写明去向(「可晋升:精锐干员将出现在作战区原位置 / 将进入整备区」;详情卡会自动停靠在不遮挡这一格的一侧)。在回合开始或结算时因策略、奖励等完成的合成,精锐出现时同样会播放金色的晋升特效。
- **晋级奖励**:每次晋升后获得一次特殊招募:从 3 名 **高一阶**(最高 VI)、**互不相同**的随机干员中**免费**挑 1 名(该阶剩余干员不足 3 名时由低一阶补足)。它不能刷新、不能冻结、本回合结束失效。 - **晋级奖励**:每次晋升后获得一次特殊招募:从 3 名 **高一阶**(最高 VI)、**互不相同**的随机干员中**免费**挑 1 名(该阶剩余干员不足 3 名时由低一阶补足)。它不能刷新、不能冻结、本回合结束失效。
- 每回合商店会自动刷新未冻结的格子。 - 每回合商店会自动刷新未冻结的格子。
@@ -95,14 +95,17 @@
- **核心盟约**(8 个势力:炎、萨尔贡、维多利亚、谢拉格、拉特兰、阿戈尔、叙拉古、卡西米尔):3 名激活,6 名获得强力的额外效果。 - **核心盟约**(8 个势力:炎、萨尔贡、维多利亚、谢拉格、拉特兰、阿戈尔、叙拉古、卡西米尔):3 名激活,6 名获得强力的额外效果。
- **附加盟约**(精准、迅捷、灵巧、奥术、坚守、助力、远见、奇迹、投资人、突袭、不屈、调和、协防干员、独行、绝技):多为 2 / 3 名激活;「独行」需要场上**恰好 1 名**,「绝技」按场上的精锐数量计算,没有势力盟约的干员算「协防干员」。 - **附加盟约**(精准、迅捷、灵巧、奥术、坚守、助力、远见、奇迹、投资人、突袭、不屈、调和、协防干员、独行、绝技):多为 2 / 3 名激活;「独行」需要场上**恰好 1 名**,「绝技」按场上的精锐数量计算,没有势力盟约的干员算「协防干员」。
- **炎佑**:场上 6 名以上「炎」干员时召唤炎佑(9 名时两只)。它追踪全场仇恨最高的敌人并悬停在其上方,技能「祛恶之焰」锁定 1 个目标**持续喷火**(最长 20 秒,每秒对目标周围的敌人造成法术伤害并附带灼燃,喷火时不普攻,目标死亡或被沉默时结束);场上暂时没有敌人时停在原地等待。它持有官方的「孤立」:医疗干员不会治疗它,友方的增益、光环和天赋也不会选中它(敌人照常可以攻击它)。 - **炎佑**:场上 6 名以上「炎」干员时召唤炎佑(9 名时两只)。它追踪全场仇恨最高的敌人并悬停在其上方,技能「祛恶之焰」锁定 1 个目标**持续喷火**(最长 20 秒,每秒对目标周围的敌人造成法术伤害并附带灼燃,喷火时不普攻,目标死亡或被沉默时结束);场上暂时没有敌人时停在原地等待。它持有官方的「孤立」:医疗干员不会治疗它,友方的增益、光环和天赋也不会选中它(敌人照常可以攻击它)。
- **层数**:盟约通过各种效果叠加层数,层数越高效果越强(例如「炎」攻击力随层数提升、「远见」每 10 层给资金)。**层数整局保留,但只有盟约激活时才生效**,多数情况下也只有激活的盟约能叠层。 - **层数**:盟约通过各种效果叠加层数,层数越高效果越强(例如「炎」攻击力随层数提升、「远见」每 10 层给资金)。**层数整局保留,但只有盟约激活时才生效**,多数情况下也只有激活的盟约能叠层。每个盟约最多 **999 层**(与官方相同,取自官方客户端的常量):到 999 后再获得的层数直接作废,按层数发放的奖励(如「远见」的资金)也随之停止。
- **加成怎么叠**:盟约、策略、装备、机变卡和特质给的「攻击力 / 防御力 / 生命值 +X%」与技能的「攻击力 +X%」**相加**后再乘到属性上(官方:直接乘算),例如精准 +130%、独行 +60%、一件 +40% 的装备合计 +230%,而不是 ×2.3×1.6×1.4。「伤害提升至 X%」「受到的伤害 +X%」(维多利亚、谢拉格、奥术、脆弱等)这类伤害倍率之间仍然相乘;但同名效果只算一个——同一战场两名玩家都开了「奥术」时,敌人身上只挂一层奥术易伤,取两人中较高的那一个(不再相乘);两名「灵知」的坚冰、两名「莱恩哈特」/「缄默德克萨斯」的法抗削减也一样只算最强的一个。「坚守」的反伤计入被打的那名成员(个人对领袖伤害统计里能看到)。
- 每局会随机**禁用**若干盟约(标准模拟较少),它们的干员不会出现在商店;开局的「确认本局信息」会列出。 - 每局会随机**禁用**若干盟约(标准模拟较少),它们的干员不会出现在商店;开局的「确认本局信息」会列出。
- 两件同名装备会合成一件更强的装备;特定的装备组合可以为干员赋予额外的盟约效果。 - 两件同名装备会合成一件更强的装备;特定的装备组合可以为干员赋予额外的盟约效果。
## 7. 最终攻势与隐秘核心 ## 7. 最终攻势与隐秘核心
- **最终攻势**(第 14 回合;独立标准为第 9 回合):按座位两两配对,每对共享一个战场,一人守左半边、一人守右半边(右侧玩家的棋盘左右镜像)。所有人**共同削减同一条领袖血条**,看不到另一组的战场。全队 LP 合并成一个池,不再有每回合 10 点上限。顶部的 120 秒倒计时结束后战斗**继续**(不会超时判负);从开战第 150 秒起(实际秒数)全队每秒扣 1 点 LP。领袖血量归零即胜利(血条显示 0 时领袖已倒下,战斗立即结束),LP 归零即失败——以先归零的一方为准,LP 耗尽后打出的伤害不再计入。此阶段盟约不叠层。 - **最终攻势**(第 14 回合;独立标准为第 9 回合):按座位两两配对,每对共享一个战场,一人守左半边、一人守右半边(右侧玩家的棋盘左右镜像)。所有人**共同削减同一条领袖血条**(血量 = 该难度的官方领袖生命值,取自当前版本的游戏数据,例如终极「假想敌:胄」3 600 000,不论存活几人),看不到另一组的战场。全队 LP 合并成一个池,不再有每回合 10 点上限。顶部的 120 秒倒计时结束后战斗**继续**(不会超时判负);从开战第 150 秒起(实际秒数)全队每秒扣 1 点 LP。领袖血量归零即胜利(血条显示 0 时领袖已倒下,战斗立即结束),LP 归零即失败——以先归零的一方为准,LP 耗尽后打出的伤害不再计入。此阶段盟约不叠层。
- **限伤**(最终攻势与隐秘核心,与官方相同):对领袖的**单次伤害达到 300000 或以上时整次作废**——这一下造成 0 点伤害,不计入领袖血条,也不显示伤害数字;不是截到 300000,而是一点都不算(299999 照常生效)。只针对领袖本体(含两人战场里的另一个领袖),领袖的部件、护卫、无人机和其他小怪不受影响,普通回合和联防也不受影响;部件把伤害等量转给领袖、无人机死亡令领袖失去 2% 生命时,这一下也按一次伤害检查(目前最多 14.4 万,不会触线)。层数极高时领袖仍可能几秒内倒下(例如每名玩家的两个盟约都在 999 层,或所有激活的盟约都在 999 层),这来自官方按层数计算、没有上限的伤害倍率(谢拉格、奥术、精准等),官方社区也有「boss一秒死」的说法。
- 最终攻势 / 隐秘核心的休整期按你在**关底战场上的半场**判定可部署地块(右侧玩家左右镜像),拖动时高亮的就是可放置的格子:例如「源石流发生装置」关底图中被 1×3 橙色管子围起的地块可以部署。在最终攻势放在这些格子上的干员进入隐秘核心时会留在原位。 - 最终攻势 / 隐秘核心的休整期按你在**关底战场上的半场**判定可部署地块(右侧玩家左右镜像),拖动时高亮的就是可放置的格子:例如「源石流发生装置」关底图中被 1×3 橙色管子围起的地块可以部署。在最终攻势放在这些格子上的干员进入隐秘核心时会留在原位。
- **领袖的部件**:「假想敌:胄」的无人机死亡时(不论是谁击落;漏过去不算),胄失去领袖血条总量的 2%(例如终极 3 600 000 → 每架 72 000;官方没写明这个「最大生命值」指哪一个,本作按血条总量处理,属推断);隐秘核心的「斩胄之剑 / 破胄之锤」「碎铳之簧」受到的每一点伤害都会等量扣在领袖血条上(剑 / 锤被击落后一定算;俯冲途中挨的打也算,这一点官方两处文字不一致,属推断)。剑 / 锤平时悬浮在胄身边、无敌;掷出后闪现到固定路线,冲向攻击力最低的干员,途中被打中 15 次就会【瘫痪】(晕眩 10 秒、落地、受到的伤害 ×1.3);落地或瘫痪结束后,会以剩余生命值换成一把新的回到原位。「灭顶之灾」每次只发射 1 枚刺胄之弹(低于 20% 生命也一样),只打攻击范围内攻击力最高的干员。
- **巨型领袖**(假想敌:胄 / 管、盐风主教昆图斯、阿利斯泰尔、“萨米的意志”)的受击判定是一片约 5×3 格的区域(整个管道区块):攻击范围碰到其中任意一格就能打到它;它们**自缚、无法被阻挡**,不会离开原地。溅射按敌人的中心判定,打在旁边小怪身上的溅射碰不到巨型领袖。 - **巨型领袖**(假想敌:胄 / 管、盐风主教昆图斯、阿利斯泰尔、“萨米的意志”)的受击判定是一片约 5×3 格的区域(整个管道区块):攻击范围碰到其中任意一格就能打到它;它们**自缚、无法被阻挡**,不会离开原地。溅射按敌人的中心判定,打在旁边小怪身上的溅射碰不到巨型领袖。
- **隐秘核心**(第 15 回合,仅险境及以上):打倒领袖后,若全队激活的盟约层数总和 **> 1200**(独立 > 350)且 LP > 1,进入隐秘核心,挑战更强的领袖。失败不影响本局的通关。 - **隐秘核心**(第 15 回合,仅险境及以上):打倒领袖后,若全队激活的盟约层数总和 **> 1200**(独立 > 350)且 LP > 1,进入隐秘核心,挑战更强的领袖。失败不影响本局的通关。
@@ -126,3 +129,5 @@
- LP 只剩 10 左右时(一回合最多扣 10)要优先保完美作战。 - LP 只剩 10 左右时(一回合最多扣 10)要优先保完美作战。
- 联防只让完美作战的队友上场,所以稳定清怪的阵容对全队都有价值。 - 联防只让完美作战的队友上场,所以稳定清怪的阵容对全队都有价值。
- 进入隐秘核心看的是全队的**激活层数总和**:后期可以针对性地堆层数。 - 进入隐秘核心看的是全队的**激活层数总和**:后期可以针对性地堆层数。
- 层数到 999 就满了,多余的叠层没有用,可以把资源转去别的盟约。
- 领袖战注意**限伤**:单次 30 万以上的伤害会被整次作废。盟约叠到 999 层后,再叠加额外的伤害倍率(例如 999 层谢拉格再点亮奥术)反而可能让大招打出 0 伤害;队友的奥术等减益也会放大你的伤害。
+50 -7
View File
@@ -91,6 +91,9 @@ release hooks/timers of units removed this tick (§1.4) → `time += TICK` → e
`rowOffset` on the player overrides the boss row mapping. Unit input `dir` ∈ `'UP'|'RIGHT'|'DOWN'|'LEFT'` (the board `rowOffset` on the player overrides the boss row mapping. Unit input `dir` ∈ `'UP'|'RIGHT'|'DOWN'|'LEFT'` (the board
piece's facing, research 09 §1.2; absent/junk ⇒ RIGHT; with `abs` / `coords:'field'` it is a field direction as given). piece's facing, research 09 §1.2; absent/junk ⇒ RIGHT; with `abs` / `coords:'field'` it is a field direction as given).
A merge's elite is an ordinary board piece to the sim: when the merge consumed a deployed copy it stands on that copy's
tile with its facing (PRTS 卫戍协议/帮助 "若消耗已部署至作战区的干员,则发送至作战区对应位置"; `PlayerState._mergeChess`), with
its loadout's `skillIndex` / `moduleId` and no equipment (the copies' items went back to the hand).
Token pieces: `{ kind:'token', tokenId, ownerUid, row, col, dir? }` — the manually deployable summons the player placed Token pieces: `{ kind:'token', tokenId, ownerUid, row, col, dir? }` — the manually deployable summons the player placed
(data `placeable`, PRTS 卫戍协议/帮助 §战斗部署; user playtest #6). A piece marks the tile its summon deploys on: a talent (data `placeable`, PRTS 卫戍协议/帮助 §战斗部署; user playtest #6). A piece marks the tile its summon deploys on: a talent
summon the owner holds from the start (狼群, 海嗣, 流形, 凯瑟琳's 爬行号·防护单元 — facing the operator it shields) deploys summon the owner holds from the start (狼群, 海嗣, 流形, 凯瑟琳's 爬行号·防护单元 — facing the operator it shields) deploys
@@ -139,6 +142,12 @@ and research routes (`{m, s, e, cp:[['MOVE',r,c]…]}`). `spawnsFromTemplate(wav
template into `{ routes, spawns, maxPlayTime, overrides, extraRoutes }` (non-spawn `action` entries are skipped; `unharmful` template into `{ routes, spawns, maxPlayTime, overrides, extraRoutes }` (non-spawn `action` entries are skipped; `unharmful`
and `tag:'part'` spawns don't count in `total`). and `tag:'part'` spawns don't count in `total`).
Leader parts (`tag:'part'`) pass damage to their leader with `loseHp(leader, share, { source, from, sourceless: true })`
(无来源, credited to the attacker's `bossDamage`): `PART_TRANSFER` 1 for 斩胄之剑 / 破胄之锤 (`BLADE_TRANSFER`, the same
constant) and 碎铳之簧 (PRTS "受到伤害时令假想敌:胄/铳受到等量的无来源生命流失"; DESIGN §20.10, §20.13). Content may replace a part mid-battle: every 剑/锤 sortie (content/bosses.js `kitBlade`)
ends by spawning a new 初始模式 copy on its level branch route (`left_hand_origin` / `right_hand_origin`) with the old
HP, then `kill(old, null)` — uncounted, no bounty; the client sees a `die` and a `spawn`.
WALK legs pathfind on the stage grid inside the rect with the official flow field (grid.js: 4-direction SPFA from the WALK legs pathfind on the stage grid inside the rect with the official flow field (grid.js: 4-direction SPFA from the
destination, crates cost 1000, then Bresenham line-of-sight smoothing — research 08 §3.4). Among equal-length routes the destination, crates cost 1000, then Bresenham line-of-sight smoothing — research 08 §3.4). Among equal-length routes the
one crossing the fewest non-blockable tiles (floor / gate lanes) wins, and a smoothed segment never cuts across floor one crossing the fewest non-blockable tiles (floor / gate lanes) wins, and a smoothed segment never cuts across floor
@@ -242,6 +251,8 @@ recorded for the player whose half contains the goal it reached, with `sourcePla
- `leaked[].counted = false` for `notCountInTotal`/`unharmful`/boss parts (LP rules: normal rounds count only `counted` - `leaked[].counted = false` for `notCountInTotal`/`unharmful`/boss parts (LP rules: normal rounds count only `counted`
leaks, cap 10; boss rounds use `lpr`). `perfect = no counted leak`. leaks, cap 10; boss rounds use `lpr`). `perfect = no counted leak`.
- `battleEnd {result}` fires before the result is frozen: handlers may still call `addLayers`/`addCoins`. - `battleEnd {result}` fires before the result is frozen: handlers may still call `addLayers`/`addCoins`.
- `layerGains` holds what `addLayers` actually added — never more than a bond's room under `BOND_LAYER_CAP` (999) from
its starting layers; `bossDamage` / `damageDealt` never include a leader hit cancelled by 限伤 (§4).
### 1.4 Robustness ### 1.4 Robustness
@@ -325,6 +336,19 @@ Aggregation: `ATK/DEF/maxHp = (base + Σflat) × (1 + Σpct) × Πmul`; `res = c
`aspd = clamp(base + Σaspd, 20, 600)` (floor 20: PRTS 数值范围 ATTACK_SPEED 默认下限; user playtest #6); `interval = bat × (1 + ΣbatPct) × 100 / aspd`; `moveSpeed = (base + ΣmoveFlat) × ΠmoveMul`; `aspd = clamp(base + Σaspd, 20, 600)` (floor 20: PRTS 数值范围 ATTACK_SPEED 默认下限; user playtest #6); `interval = bat × (1 + ΣbatPct) × 100 / aspd`; `moveSpeed = (base + ΣmoveFlat) × ΠmoveMul`;
tiles/s = `moveSpeed × MOVE_SCALE (0.5)`. A maxHp change keeps the HP ratio. Elite stats (module included) come from data. tiles/s = `moveSpeed × MOVE_SCALE (0.5)`. A maxHp change keeps the HP ratio. Elite stats (module included) come from data.
**Which bucket (PRTS 游戏数据基础 属性基本公式 / 作战机制, DESIGN §20.10).** `Σpct` is the official **直接乘算** class — its
values are summed (`A = (A₀ + D_p)(1 + D_t)`, D_t = t₁ + … + tₙ): a skill's or talent's "攻击力+X%" **and** every "+X%"
ATK / DEF / max HP bonus of the 卫戍 systems — 盟约, 策略 (bands), 装备, 机变 cards, the per-layer 特质 (PRTS
卫戍协议:盟约 下半/PRTS盟约记录 "盟约效果,策略效果,装备效果提供的属性加成均为直接乘算"). Content builds those with
`content/support directMods({ atk, def, hp })` (constants.js `DIRECT_BONUS_STACKING` 'add'; 'multiply' = the v2.5
per-source ×(1 + x), which compounded with layers: user report after playtest #6). `Πmul` is for 最终乘算 / "提升至X%"
effects (炎佑 ×1.5 at 9 炎, 虚弱, 停顿 …) and the char_attribute_mul 特质 ("攻击力和生命值+20%", a rune on the base
attributes). Damage multipliers (`dmgDealtMul`, "伤害提升至X%"; `*TakenMul`, 脆弱 / "受到的伤害+X%") multiply each other
(PRTS 游戏数据基础 "同种倍率间叠乘"), same-named statuses keep the strongest — catalogue statuses (§3) and the content
effects routed through `battle.applyStrongest` (§3: 奥术, 灵知 坚冰, 莱恩哈特 / 缄默德克萨斯 RES cuts), also across the two
players of a pair field or two copies of one operator. A content buff keyed per unit (`key:${unit.id}`) still stacks
per source — DoTs and slows do that on purpose; a damage-taken / DEF / RES modifier on enemies should not.
--- ---
## 3. Buffs, mods, statuses ## 3. Buffs, mods, statuses
@@ -361,6 +385,11 @@ and the 浮空 weight rule, then `statusApplied { source, target, status, durati
(`gamedata_const.termDescriptionDict`, `ba.*`). Same-key statuses refresh to the longer duration, except the (`gamedata_const.termDescriptionDict`, `ba.*`). Same-key statuses refresh to the longer duration, except the
"同名效果取最高" ones marked *strongest* below: the strongest value wins, a weaker application never overrides it and, "同名效果取最高" ones marked *strongest* below: the strongest value wins, a weaker application never overrides it and,
if it outlasts it, resumes when the strong one expires (pass `refresh` to opt out). if it outlasts it, resumes when the strong one expires (pass `refresh` to opt out).
`battle.applyStrongest(target, key, { duration, value, mods: (v) => mods, source })` gives a content effect that is not a
catalogue status the same rule (one invisible buff `key` per target whatever applies it — no immunity, 抵抗, status hooks
or icon): the engine default for two same-named buffs (PRTS 作战机制 "同名buff的默认叠加策略buff只能表现出一个"). 奥术 uses
it, so the two players of a pair field compete for one instance instead of multiplying, and so do 灵知's 坚冰 and the
莱恩哈特 / 缄默德克萨斯 RES cuts, once keyed per unit (DESIGN §20.10). "Strongest" = the largest |value|.
| key | effect | value | | key | effect | value |
|---|---|---| |---|---|---|
@@ -444,7 +473,9 @@ HP left is refused (damage.js `hasHp`: a lethal hit's `damaged` hook runs before
element?, defIgnoreFlat, defIgnorePct, resIgnoreFlat, resIgnorePct, mul=1, canDodge (phys/arts), isSkill, isSplash, element?, defIgnoreFlat, defIgnorePct, resIgnoreFlat, resIgnorePct, mul=1, canDodge (phys/arts), isSkill, isSplash,
isAttack, attackId, ignoreSleep, sourceless, tags[], cancel }` (`sourceless`: 无来源 damage — the source's stats add isAttack, attackId, ignoreSleep, sourceless, tags[], cancel }` (`sourceless`: 无来源 damage — the source's stats add
nothing and the hooks get `source: null` plus `credit` = the source, which keeps the stats and the kill; a `loseHp` whose nothing and the hooks get `source: null` plus `credit` = the source, which keeps the stats and the kill; a `loseHp` whose
`from` is 无来源 is 无来源 too) (`battle.makeDamage(d)` normalises). `attackId` is the same for every `from` is 无来源 is 无来源 too; element bursts, leader-part transfers and 坚守 thorns use it — content damage that has a
responsible unit should pass it as `source` with `sourceless: true` rather than `source: null`, which credits nobody)
(`battle.makeDamage(d)` normalises). `attackId` is the same for every
damage instance of one normal attack (all targets, splash, chain, projectile impacts; 0 for non-attack damage) — use it damage instance of one normal attack (all targets, splash, chain, projectile impacts; 0 for non-attack damage) — use it
for "本次攻击" procs that must roll once per attack. **Dodge** from several buffs rolls independently: the unit's for "本次攻击" procs that must roll once per attack. **Dodge** from several buffs rolls independently: the unit's
`s.dodgePhys` = 1 − Π(1 − pᵢ) (a single source keeps its exact value). `s.dodgePhys` = 1 − Π(1 − pᵢ) (a single source keeps its exact value).
@@ -452,14 +483,24 @@ Order: invulnerable / asleep? → **`hit`** (mutate `dmg`, set `dmg.cancel`) →
`max(A − max(0, D×(1−defIgnorePct) − defIgnoreFlat), 5 %A)`, arts `max(A×(1 − R′/100), 5 %A)`, elemental `max(A − max(0, D×(1−defIgnorePct) − defIgnoreFlat), 5 %A)`, arts `max(A×(1 − R′/100), 5 %A)`, elemental
`max(A×(1 − 元素抗性/100), 5 %A)`, true = A; source ignore mods are added) → × source `dmgDealtMul` (× phys/artsDealtMul) `max(A×(1 − 元素抗性/100), 5 %A)`, true = A; source ignore mods are added) → × source `dmgDealtMul` (× phys/artsDealtMul)
× target `dmgTakenMul` (not for elemental) × type-taken mul × `dmg.mul` (a `sourceless` hit skips every source term) → × target `dmgTakenMul` (not for elemental) × type-taken mul × `dmg.mul` (a `sourceless` hit skips every source term) →
shields → HP loss **限伤** (`leaderHitCancelled`: on a leader — `isBoss`, the tag-'boss' units: data/bosses.json `enemyKey`, the official
`IsBossEnemy` list, and their mirrored copies; never parts, escorts, drones — in a `'boss'` / `'hidden'` battle, a hit
with `ceil(final) ≥ BOSS_HIT_LIMIT` (300000, shared/constants.js) is cancelled whole: returns 0 before shields (阿利斯泰尔's
`boss:vest` barrier stays untouched; a `hit`-step block such as 假想敌:再生's aura acts earlier) [ASSUMED order], no HP /
pool loss, no credit or stats, no `dmg` event, no `damaged` / `fatal` / kill; an fx `hitCap` `{ id, n }` marks it and
draws nothing; research 11) → shields → HP loss
(boss units: routed to `sharedBoss.damage(playerId, amount)`; a pool left under 1 HP is emptied) → if HP ≤ 0: **`fatal`** (`ctx.prevented = true` keeps the (boss units: routed to `sharedBoss.damage(playerId, amount)`; a pool left under 1 HP is emptied) → if HP ≤ 0: **`fatal`** (`ctx.prevented = true` keeps the
unit at ≥ 1 HP) → **`damaged`** → SP-on-hurt / TAKE_DAMAGE → `kill` + `death`. unit at ≥ 1 HP) → **`damaged`** → SP-on-hurt / TAKE_DAMAGE → `kill` + `death`.
`battle.heal(source, target, amount, { overheal=false, self, silent })`: no-op on `noHeal` targets (unless self — 禁疗 / `battle.heal(source, target, amount, { overheal=false, self, silent })`: no-op on `noHeal` targets (unless self — 禁疗 /
孤立 summons carry the flag, §3); 孤立 summons carry the flag, §3);
× source `healingDealtMul` × target `healingTakenMul`; **`heal`** hook (mutable amount); capped at max HP; `overheal` × source `healingDealtMul` × target `healingTakenMul`; **`heal`** hook (mutable amount); capped at max HP; `overheal`
turns the excess into an `overheal` shield. `battle.loseHp(target, amount, {source})` = HP loss ignoring DEF/RES/shields/dodge (流失). turns the excess into an `overheal` shield. `battle.loseHp(target, amount, { source, from, tags, silent, sourceless })` = HP
loss ignoring DEF/RES/shields/dodge (流失); `sourceless: true` makes it 无来源 ("受到等量的无来源生命流失": hooks see no source,
`source` keeps the credit — stats and the per-player shared-pool tally), as does a 无来源 `from`. On a leader in a boss /
hidden battle a loss of ≥ `BOSS_HIT_LIMIT` (a part's 传递, a drone's death) is cancelled like a hit. Every HP-damage kind
meets the limit (phys / arts / true / 元素伤害 incl. element bursts, DoT ticks); element 损伤 (the gauge, `type: 'element'`)
removes no HP and never does.
--- ---
@@ -492,7 +533,7 @@ registration order. `battle.off(handle)` / `battle.off(name, fn)` / `battle.offO
| `enemySpawn` / `enemyLeak` | `{ enemy }` | | | `enemySpawn` / `enemyLeak` | `{ enemy }` | |
| `elementBurst` | `{ source, target, element }` | before the burst's lock/effects; same-element fills of `target` are already refused | | `elementBurst` | `{ source, target, element }` | before the burst's lock/effects; same-element fills of `target` are already refused |
| `dodge` | `{ source, target, dmg }` | an attack was dodged | | `dodge` | `{ source, target, dmg }` | an attack was dodged |
| `layerGain` | `{ playerId, bondId, n, reason, source, tile }` | mutable `n` before recording (魔王 +1 …); `tile` = `[r, c]` where `source` stands — or was knocked out this very instant ("被击倒时" gains) — else null (`addLayers` opts.tile overrides) | | `layerGain` | `{ playerId, bondId, n, reason, source, tile }` | mutable `n` before recording (魔王 +1 …), then clamped to the room left under `BOND_LAYER_CAP` (999); not emitted for a bond already at the cap; `tile` = `[r, c]` where `source` stands — or was knocked out this very instant ("被击倒时" gains) — else null (`addLayers` opts.tile overrides) |
| `merchantPay` | `{ unit, cost, cancel }` | a merchant (行商) is about to pay its periodic DP; change `cost` or set `cancel` | | `merchantPay` | `{ unit, cost, cancel }` | a merchant (行商) is about to pay its periodic DP; change `cost` or set `cancel` |
| `battleEnd` | `{ result }` | may still add layer gains / coins | | `battleEnd` | `{ result }` | may still add layer gains / coins |
@@ -509,8 +550,9 @@ or guard with a per-unit flag while dealing it. When the guard trips, the logged
| helper | notes | | helper | notes |
|---|---| |---|---|
| `dealDamage(src, tgt, dmg)`, `heal(src, tgt, amount, opts)`, `loseHp(tgt, amount, {source})` | §4 | | `dealDamage(src, tgt, dmg)`, `heal(src, tgt, amount, opts)`, `loseHp(tgt, amount, {source, from, tags, sourceless})` | §4 |
| `applyStatus(tgt, key, {duration, source, value, force, point})`, `removeStatus(tgt, key)`, `resistOf(unit)` | §3 | | `applyStatus(tgt, key, {duration, source, value, force, point})`, `removeStatus(tgt, key)`, `resistOf(unit)` | §3 |
| `applyStrongest(tgt, key, {duration, value, mods, source})` | §3 — "同名效果取最高" for a non-catalogue effect |
| `addBuff(unit, buff)`, `removeBuff(unit, key)` | §3 | | `addBuff(unit, buff)`, `removeBuff(unit, key)` | §3 |
| `spawnToken(ownerUnit | playerId, tokenId, row, col, { def, stats, hp, duration, untargetable, dir, kit, force, anySource })` | field tiles; def from data/tokens.json `variants[ownerChessId]` for the owner unit's selected skill / module (`tokenDef`); `dir` defaults to the owner unit's (else the player's: RIGHT, mirrored side LEFT; a legacy `facing` ±1 is still read); returns the token or null (tile busy; or the owner runs a **non-default** skill that does not produce the token — `producesToken` — unless `anySource`: kit install hooks written for the default skill run under every skill). `spawnDevice(key, row, col, { …, dir })` likewise | | `spawnToken(ownerUnit | playerId, tokenId, row, col, { def, stats, hp, duration, untargetable, dir, kit, force, anySource })` | field tiles; def from data/tokens.json `variants[ownerChessId]` for the owner unit's selected skill / module (`tokenDef`); `dir` defaults to the owner unit's (else the player's: RIGHT, mirrored side LEFT; a legacy `facing` ±1 is still read); returns the token or null (tile busy; or the owner runs a **non-default** skill that does not produce the token — `producesToken` — unless `anySource`: kit install hooks written for the default skill run under every skill). `spawnDevice(key, row, col, { …, dir })` likewise |
| `tokenDef(tokenId, ownerUnit | chessId)`, `producesToken(ownerUnit, tokenId)` | the token def a summon of that owner gets — `getToken(id, owner.defId, owner.def.loadout)`, exact even when two players of one field give the same chess different loadouts (prefer it over an id-only `battle.data.getToken(id, unit.defId)` for summon stats / blackboards); whether the owner's loadout makes the token (DATA.md §14 `sources` has 'skill' or 'talent'; true when the data does not tell: no own variant, player-owned summons) | | `tokenDef(tokenId, ownerUnit | chessId)`, `producesToken(ownerUnit, tokenId)` | the token def a summon of that owner gets — `getToken(id, owner.defId, owner.def.loadout)`, exact even when two players of one field give the same chess different loadouts (prefer it over an id-only `battle.data.getToken(id, unit.defId)` for summon stats / blackboards); whether the owner's loadout makes the token (DATA.md §14 `sources` has 'skill' or 'talent'; true when the data does not tell: no own variant, player-owned summons) |
@@ -522,7 +564,7 @@ or guard with a per-unit flag while dealing it. When the guard trips, the logged
| `unitsInGrid(unit, grid, {side, extend})`, `alliesInGrid(unit)`, `enemiesInRadius(x, y, r, centre?)`, `alliesInRadius(x, y, r, ownerId?)` | grid offsets are relative to facing RIGHT, rotated by `unit.dir`; enemies by their body (§2 hit areas: a huge enemy on every tile it occupies / within `r` of its rectangle; `centre` = splash around a target, a 中点判定 by position) | | `unitsInGrid(unit, grid, {side, extend})`, `alliesInGrid(unit)`, `enemiesInRadius(x, y, r, centre?)`, `alliesInRadius(x, y, r, ownerId?)` | grid offsets are relative to facing RIGHT, rotated by `unit.dir`; enemies by their body (§2 hit areas: a huge enemy on every tile it occupies / within `r` of its rectangle; `centre` = splash around a target, a 中点判定 by position) |
| `enemiesInKeys(keys, attacker, profile)`, `blockedTargets(unit, profile)` | targetable enemies whose body is on the tiles (a huge one listed once); the enemies a unit blocks — always selectable by it, a ranged operator on a melee tile included (§1.2 Blocking) | | `enemiesInKeys(keys, attacker, profile)`, `blockedTargets(unit, profile)` | targetable enemies whose body is on the tiles (a huge one listed once); the enemies a unit blocks — always selectable by it, a ranged operator on a melee tile included (§1.2 Blocking) |
| `allies(ownerId?)`, `aliveEnemies()`, `unitAt(r, c)`, `unitById(id)`, `tileInfo(r, c)`, `lowestHpAllyInRange(unit)` | | | `allies(ownerId?)`, `aliveEnemies()`, `unitAt(r, c)`, `unitById(id)`, `tileInfo(r, c)`, `lowestHpAllyInRange(unit)` | |
| `addLayers(playerId, bondId, n, reason, {source})`, `addCoins(playerId, n)` | layers are a no-op when `flags.layerGainsEnabled` is false (unite/boss) | | `addLayers(playerId, bondId, n, reason, {source})`, `addCoins(playerId, n)` | layers are a no-op when `flags.layerGainsEnabled` is false (unite/boss); a gain adds at most the room left under `BOND_LAYER_CAP` (999, shared/constants.js `layerGainRoom`: the client's `AddBondCount` min(L + n, 999)) on the live copy — or, without one, on the battle's own gains — and returns what it added (0 at the cap: no hook, no event) |
| `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 }` | | `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) | | `mapTile(ps, row, col, abs?)` / `mapDir(ps, dir, abs?)` | board → field tile / direction of a player (the FA right-side mirror) |
| `addDp(playerId, n)`, `retreat(unit, {reason, permanent})`, `relocate(unit, r, c)` | | | `addDp(playerId, n)`, `retreat(unit, {reason, permanent})`, `relocate(unit, r, c)` | |
@@ -913,7 +955,8 @@ Unknown subprofessions fall back to the profession default (test `professions.te
renderer flies it back to the thrower at `BOOMERANG_RETURN_SPEED`; an enemy's `profile.shot` may name another kind, renderer flies it back to the thrower at `BOOMERANG_RETURN_SPEED`; an enemy's `profile.shot` may name another kind,
e.g. `mortar` for 帝国炮火先兆者, which the renderer does not draw — its fx `bombardShell` is the shell), `['dmg', tgt, amount, type]` (`phys|arts|true|burn|neural|necrosis|apoptosis`), e.g. `mortar` for 帝国炮火先兆者, which the renderer does not draw — its fx `bombardShell` is the shell), `['dmg', tgt, amount, type]` (`phys|arts|true|burn|neural|necrosis|apoptosis`),
`['heal', tgt, amount]`, `['skill', id, 1|0]`, `['die', id, reason]`, `['leak', id]`, `['status', id, key, 1|0]`, `['heal', tgt, amount]`, `['skill', id, 1|0]`, `['die', id, reason]`, `['leak', id]`, `['status', id, key, 1|0]`,
`['fx', kind, x, y, extra]`, `['layer', playerId, bondId, n]`, `['bounty', playerId, coins]`. `['fx', kind, x, y, extra]` (`hitCap` `{ id, n }`: a leader's hit cancelled by 限伤 — the renderer draws nothing),
`['layer', playerId, bondId, n]` (n = the layers actually added, capped at 999), `['bounty', playerId, coins]`.
- `UnitInfo = { id, kind, side, ownerId, defId, name, tier, golden, spine, avatar, x, y, facing, dir, maxHp, motion?, boss?, uid?, skillIndex? }` (`skillIndex`: an ally's equipped skill, DESIGN §16) - `UnitInfo = { id, kind, side, ownerId, defId, name, tier, golden, spine, avatar, x, y, facing, dir, maxHp, motion?, boss?, uid?, skillIndex? }` (`skillIndex`: an ally's equipped skill, DESIGN §16)
(`dir` = the unit direction, allies meaningful, enemies 'RIGHT'; `facing` = its horizontal sign for sprite flipping) (`dir` = the unit direction, allies meaningful, enemies 'RIGHT'; `facing` = its horizontal sign for sprite flipping)
(`spine`/`avatar` are asset ids from data). (`spine`/`avatar` are asset ids from data).
+4 -2
View File
@@ -17,6 +17,7 @@ Tags: **[DATA]** client game data · **[VERIFIED]** official or wiki rule text,
| `05-enemies-levels.md` / `05-enemies.json` / `05-maps.json` | Stage terrains (8 active), 38 wave templates with routes, 326 enemies, special-enemy pools, generation algorithm, bosses, bounties, LP rules. Addendum: DP, hidden-core thresholds, multi FUNNY rounds, stat scaling. | `05-maps.json: stages, roundLevels`; `05-enemies.json: enemies, specialEntries, bosses, bountyEffects, generation, _criticAddendum` | | `05-enemies-levels.md` / `05-enemies.json` / `05-maps.json` | Stage terrains (8 active), 38 wave templates with routes, 326 enemies, special-enemy pools, generation algorithm, bosses, bounties, LP rules. Addendum: DP, hidden-core thresholds, multi FUNNY rounds, stat scaling. | `05-maps.json: stages, roundLevels`; `05-enemies.json: enemies, specialEntries, bosses, bountyEffects, generation, _criticAddendum` |
| `06-multiplayer-ux.md` | Co-op flow and state machine, 联防, shared vs individual, shared pool, cross-player effects, emotes and broadcasts, settlement, per-screen UI layout, style guide, server notes. Addendum: TL;DR fixes, banned-operator UI. | – | | `06-multiplayer-ux.md` | Co-op flow and state machine, 联防, shared vs individual, shared pool, cross-player effects, emotes and broadcasts, settlement, per-screen UI layout, style guide, server notes. Addendum: TL;DR fixes, banned-operator UI. | – |
| `07-assets.md` / `07-assets.json` | Verified URL patterns and resolved URLs for avatars, portraits, skills, bond/item/band icons, UI, Spine (ops + enemies), BGM/SFX, fonts; build plan. Addendum: spot check 40/40 OK. | `operators[*]`, `enemies[*]`, `bonds`, `items`, `bands`, `autochessUi`, `audio` | | `07-assets.md` / `07-assets.json` | Verified URL patterns and resolved URLs for avatars, portraits, skills, bond/item/band icons, UI, Spine (ops + enemies), BGM/SFX, fonts; build plan. Addendum: spot check 40/40 OK. | `operators[*]`, `enemies[*]`, `bonds`, `items`, `bands`, `autochessUi`, `audio` |
| `11-limits-official.md` | Official limits read from the client (2.7.71 il2cpp) and the community: bond layers cap at **999** per bond (`MAX_GARRISON_STACK`, `AddBondCount` = min(L + n, 999)); in boss battles outside training a single hit of **≥ 300000** on a leader (`IsBossEnemy` = an `autoChessData.bossInfoDict` enemyId) is **cancelled** ("限伤", `MAX_BATTLE_DAMAGE`); implemented in DESIGN §20.12. | `shared/constants.js BOND_LAYER_CAP / BOSS_HIT_LIMIT` |
## 2. Match structure ## 2. Match structure
@@ -72,7 +73,7 @@ Boss rounds: the battle continues past the timer. Merged team LP drains **1/s af
The visible chess per tier are 16/17/19/22/19/19. The item slot uses the same tier shares, then picks uniformly within the tier [ASSUMED]. The visible chess per tier are 16/17/19/22/19/19. The item slot uses the same tier shares, then picks uniformly within the tier [ASSUMED].
**Merge:** 3 copies of the same chess (board or hand) merge into 1 elite (`_b`), sent to the **hand**. 风丸 needs 2. Elites never merge. The equipment of the merged copies returns to the hand. Reward: the shop temporarily shows 3 operators of tier min(shopLv+1, 6) at price **0**; take 1; no refresh or freeze; gone at round end. A purchase that completes a merge is allowed with a full hand. [VERIFIED] **Merge:** 3 copies of the same chess (board or hand) merge into 1 elite (`_b`), sent to the **hand** — or, when a consumed copy was deployed, to **that copy's board position** (PRTS 卫戍协议/帮助 "若消耗已部署至作战区的干员,则发送至作战区对应位置"; 01 A1 row 7). 风丸 needs 2. Elites never merge. The equipment of the merged copies returns to the hand. Reward: the shop temporarily shows 3 operators of tier min(shopLv+1, 6) at price **0**; take 1; no refresh or freeze; gone at round end. A purchase that completes a merge is allowed with a full hand. [VERIFIED]
## 4. Board, hand, combat ## 4. Board, hand, combat
@@ -86,7 +87,7 @@ The visible chess per tier are 16/17/19/22/19/19. The item slot uses the same ti
| Skill auto-cast | Default: SP ready + about to attack/heal + an enemy in the **initial** range. ~~`skillTriggerDataList` rows with `skillIndex` 0 = skill 1 only (TANK S1 → on damage: only 古米 and 灰毫 this season)~~ — Superseded by user playtest #6 (DESIGN §20): the class rows apply to **every MANUAL skill** of the class and never to an AUTO skill (PRTS 卫戍协议/帮助 names whole classes; the 阵法术师 row must cover 薄绿's default S2, a phalanx that never attacks with its skill off); a MANUAL skill with its own 技能范围 (not an attack-range change) uses SKILL_RANGE; automatic operations have a 3 s cooldown. charId rows per 03. | VERIFIED (class-row reading superseded) | | Skill auto-cast | Default: SP ready + about to attack/heal + an enemy in the **initial** range. ~~`skillTriggerDataList` rows with `skillIndex` 0 = skill 1 only (TANK S1 → on damage: only 古米 and 灰毫 this season)~~ — Superseded by user playtest #6 (DESIGN §20): the class rows apply to **every MANUAL skill** of the class and never to an AUTO skill (PRTS 卫戍协议/帮助 names whole classes; the 阵法术师 row must cover 薄绿's default S2, a phalanx that never attacks with its skill off); a MANUAL skill with its own 技能范围 (not an attack-range change) uses SKILL_RANGE; automatic operations have a 3 s cooldown. charId rows per 03. | VERIFIED (class-row reading superseded) |
| Speed | 2× forced, no manual control | VERIFIED | | Speed | 2× forced, no manual control | VERIFIED |
| Board geometry | 19×21 stage. Player board rows 9–12, cols 2–10. Gates (9,10) and (12,10); R1–3 use only the lower one. Goal (9,2). Col 9 is a non-deployable lane. Hand row 7 cols 0–9. Temp hand row 8 cols 4–8. Boss field rows 1–5. | DATA | | Board geometry | 19×21 stage. Player board rows 9–12, cols 2–10. Gates (9,10) and (12,10); R1–3 use only the lower one. Goal (9,2). Col 9 is a non-deployable lane. Hand row 7 cols 0–9. Temp hand row 8 cols 4–8. Boss field rows 1–5. | DATA |
| Bond counting | Distinct operators on board. 远见/奇迹/投资人 also count the hand. 绝技 counts elites. Layers persist all match; **no IN_BATTLE layer gains in 联防 or boss rounds**. | DATA/VERIFIED | | Bond counting | Distinct operators on board. 远见/奇迹/投资人 also count the hand. 绝技 counts elites. Layers persist all match; **no IN_BATTLE layer gains in 联防 or boss rounds**; each bond's layers stop at **999** (research 11). | DATA/VERIFIED |
## 5. LP, 联防, Final Assault, Hidden Core ## 5. LP, 联防, Final Assault, Hidden Core
@@ -98,6 +99,7 @@ The visible chess per tier are 16/17/19/22/19/19. The item slot uses the same ti
| Elimination | LP ≤ 0: out; copies return to the pool | VERIFIED/COMM | | Elimination | LP ≤ 0: out; copies return to the pool | VERIFIED/COMM |
| Final Assault (R14) | LP of all alive players **merged**, no cap. Players in pairs, and an **odd player alone**. Movable bosses spawn one per alive player's side and share one HP pool. Overtime −1 LP/s after 150 s. Wave leaks cost `lifePointReduce`. | VERIFIED | | Final Assault (R14) | LP of all alive players **merged**, no cap. Players in pairs, and an **odd player alone**. Movable bosses spawn one per alive player's side and share one HP pool. Overtime −1 LP/s after 150 s. Wave leaks cost `lifePointReduce`. | VERIFIED |
| Boss HP pool (`bloodPoint`, F/N/H/A) | boss_1 247.5k/675k/1.8M/3.6M · boss_2 225k/400k/800k/3M · boss_3 285k/708.75k/2M/4M · boss_4 307.5k/708.75k/2.1M/4.2M · boss_5 200k/390k/780k/3M · boss_6 285k/705k/1.99M/3.98M · boss_7 277.5k/787.5k/2M/4M. Pick weights 6/6/6/5/10/5/5. | DATA | | Boss HP pool (`bloodPoint`, F/N/H/A) | boss_1 247.5k/675k/1.8M/3.6M · boss_2 225k/400k/800k/3M · boss_3 285k/708.75k/2M/4M · boss_4 307.5k/708.75k/2.1M/4.2M · boss_5 200k/390k/780k/3M · boss_6 285k/705k/1.99M/3.98M · boss_7 277.5k/787.5k/2M/4M. Pick weights 6/6/6/5/10/5/5. | DATA |
| Boss-hit limit (限伤) | Final Assault / Hidden Core: one hit of ≥ 300000 on a leader deals **0** (cancelled, not clamped); minions, normal rounds and 联防 unaffected (research 11). | DATA/COMM |
| Hidden Core (R15, 险境+) | Solo: activated layers **> 350** and LP > 1. Co-op: team sum **> 1200** and merged LP > 1. Bosses boss_8/9/10 (weights 50/40/40; HP N 937.5k/900k/1.0125M, H 3.6M/2.8M/3.8M, A 7.2M/3.95M/7.6M). Failure doesn't affect the clear. | VERIFIED/DATA | | Hidden Core (R15, 险境+) | Solo: activated layers **> 350** and LP > 1. Co-op: team sum **> 1200** and merged LP > 1. Bosses boss_8/9/10 (weights 50/40/40; HP N 937.5k/900k/1.0125M, H 3.6M/2.8M/3.8M, A 7.2M/3.95M/7.6M). Failure doesn't affect the clear. | VERIFIED/DATA |
**Enemy scaling (non-boss)**, per mode and round [VERIFIED via PRTS, user-sourced]: **Enemy scaling (non-boss)**, per mode and round [VERIFIED via PRTS, user-sourced]:
+5 -1
View File
@@ -9560,7 +9560,11 @@
"source": "official intro '多余的装备无法进行出售,可以销毁'" "source": "official intro '多余的装备无法进行出售,可以销毁'"
}, },
"promotion": { "promotion": {
"eliteGoesTo": "hand", "eliteGoesTo": "hand; when a consumed copy was deployed, that copy's board position (PRTS 卫戍协议/帮助 '发送1名【精锐】状态的该干员至手牌区(若消耗已部署至作战区的干员,则发送至作战区对应位置)'; user-confirmed after playtest #6)",
"eliteTileAmongSeveralDeployed": {
"rule": "the consumed deployed copy that deploys first (board row desc, then col asc; mirrored on the right boss half), with its facing",
"assumed": true
},
"equipmentReturnsTo": "hand", "equipmentReturnsTo": "hand",
"reward": "shop temporarily shows 3 ops of tier min(shopLevel+1,6) at price 0; take 1; no refresh/freeze; expires at round end", "reward": "shop temporarily shows 3 ops of tier min(shopLevel+1,6) at price 0; take 1; no refresh/freeze; expires at round end",
"verified": true "verified": true
+5 -1
View File
@@ -338,6 +338,10 @@ The 20-fund 绮良 / 55-fund 帕格尼尼 band thresholds count **funds spent**
- WIKI: "三位同名干员(无论是否部署)会自动合并且晋级为精锐干员". - WIKI: "三位同名干员(无论是否部署)会自动合并且晋级为精锐干员".
- `upgradeNum`=3 for 132 chess; **风丸 `chess_char_2_11_a` needs 2**. - `upgradeNum`=3 for 132 chess; **风丸 `chess_char_2_11_a` needs 2**.
- Elites have `upgradeNum`=0: **no further merging**. - Elites have `upgradeNum`=0: **no further merging**.
- **Where the elite goes:** to the hand, **unless a consumed copy was deployed — then to that copy's board position**.
PRTS 卫戍协议/帮助 §干员的获得与精锐化: "发送1名【精锐】状态的该干员至手牌区(若消耗已部署至作战区的干员,则发送至作战区对应位置)";
confirmed first-hand by the user after playtest #6 ("合成精锐时,如果消耗了场上的干员,精锐会出现在场上那个位置"). See A1 row 7
for the remake's choices where the text is silent.
- **Equipment:** anything the merged units carried returns to the bench. Tip: "干员晋级后已配发装备会回收至整备区". - **Equipment:** anything the merged units carried returns to the bench. Tip: "干员晋级后已配发装备会回收至整备区".
- **Bond traits** of "<获得时>" type: a triple stacks 1+1+2 (the elite value is double) [COMM]. - **Bond traits** of "<获得时>" type: a triple stacks 1+1+2 (the elite value is double) [COMM].
- **Stats (`shopCharChessInfoData`):** - **Stats (`shopCharChessInfoData`):**
@@ -838,7 +842,7 @@ Local copies are in `scratchpad/gd/extra/critic/` (`bw_盟约.txt`, `bw_sweep.tx
| new | – | At **combat start, all unfrozen shop contents are cleared** ("进入作战期后,栏上若未处于冻结状态则清空全部物资"). | VERIFIED | | new | – | At **combat start, all unfrozen shop contents are cleared** ("进入作战期后,栏上若未处于冻结状态则清空全部物资"). | VERIFIED |
| 4.2 / 8 | bench 10 + temp area | **Hand (手牌区/整备区) = 10 regular + 5 temporary slots**, shared by operators, **items** and hand-deployable **summons**. Several summons of one owner stack into 1 slot; summons of different owners don't stack.<br>When the regular hand is full, every action that would add a card is refused: buy, and withdraw from the board. Exception: a purchase that completes a 3-merge nets −1 card, so allow it.<br>Passive gains (merge result, band/trait/item grants, items returned by promotion) overflow into the temp slots. **While temp is non-empty the player cannot start combat.** Temp is wiped when the next phase starts.<br>A board↔hand swap is allowed even with a full hand. Cards fill hand slots **right→left**. | VERIFIED | | 4.2 / 8 | bench 10 + temp area | **Hand (手牌区/整备区) = 10 regular + 5 temporary slots**, shared by operators, **items** and hand-deployable **summons**. Several summons of one owner stack into 1 slot; summons of different owners don't stack.<br>When the regular hand is full, every action that would add a card is refused: buy, and withdraw from the board. Exception: a purchase that completes a 3-merge nets −1 card, so allow it.<br>Passive gains (merge result, band/trait/item grants, items returned by promotion) overflow into the temp slots. **While temp is non-empty the player cannot start combat.** Temp is wiped when the next phase starts.<br>A board↔hand swap is allowed even with a full hand. Cards fill hand slots **right→left**. | VERIFIED |
| 5.2 | – | **Items cannot be sold** ("多余的装备无法进行出售,可以销毁"). They can only be destroyed for 0 funds. Operators sell for 1 from board or hand. | VERIFIED | | 5.2 | – | **Items cannot be sold** ("多余的装备无法进行出售,可以销毁"). They can only be destroyed for 0 funds. Operators sell for 1 from board or hand. | VERIFIED |
| 7 | promotion reward [COMM] | Confirmed (PRTS 帮助, BWIKI, official intro). On acquiring the 3rd copy:<br>1. The 3rd copy is not created, and the 2 owned copies are destroyed wherever they are.<br>2. **1 elite goes to the hand, not to a board tile.** Their equipment returns to the hand.<br>3. The shop then temporarily shows **3 operators of tier min(shopLevel+1, 6) at price 0**. The official intro shows "II 0 ×3" after a tier-I merge at L1 and "III 0 ×3" at L2.<br>4. The player takes **1**. The offer cannot be refreshed or frozen and expires at round end. | VERIFIED | | 7 | promotion reward [COMM] | Confirmed (PRTS 帮助, BWIKI, official intro). On acquiring the 3rd copy:<br>1. The 3rd copy is not created, and the 2 owned copies are destroyed wherever they are.<br>2. **1 elite goes to the hand — or, when a consumed copy was deployed, to that copy's board position** (PRTS 帮助 "发送1名【精锐】状态的该干员至手牌区(若消耗已部署至作战区的干员,则发送至作战区对应位置)"; the user confirmed it first-hand after playtest #6 — this row used to read "not to a board tile", a misreading of the same sentence). Their equipment returns to the hand ("干员晋级后已配发装备会回收至整备区,需要重新配置"). [ASSUMED] where the text is silent: with two or three deployed copies the elite takes the tile of the one that deploys first (top→bottom, then left→right — the official deploy order) with its facing; a deployed operator transformed into the completing copy (突变细胞) counts as deployed; the copies' summons leave and the elite's own summon card joins the hand (§战斗部署 "如果部署的干员拥有可手动部署的附属召唤物,则该召唤物会立刻加入手牌区"); the deploy count never grows.<br>3. The shop then temporarily shows **3 operators of tier min(shopLevel+1, 6) at price 0**. The official intro shows "II 0 ×3" after a tier-I merge at L1 and "III 0 ×3" at L2.<br>4. The player takes **1**. The offer cannot be refreshed or frozen and expires at round end. | VERIFIED (elite to the copy's board position: PRTS + the user); the tile among several deployed copies ASSUMED |
| 2 | BAND_CHECK "3 random + reroll" [ASSUMED] | **All unlocked bands are shown.** In co-op, players pick **one at a time in a random order** shown on the left ("以随机顺序进行策略轮选"). Each co-op player may **skip once**. The step lasts 50 s; on timeout the band is 华法琳. **Duplicates are allowed:** the official illustration shows 2 teammates' picks still un-greyed in the grid. [ASSUMED] 12 s per turn inside the 50 s step. | VERIFIED except the per-turn timer | | 2 | BAND_CHECK "3 random + reroll" [ASSUMED] | **All unlocked bands are shown.** In co-op, players pick **one at a time in a random order** shown on the left ("以随机顺序进行策略轮选"). Each co-op player may **skip once**. The step lasts 50 s; on timeout the band is 华法琳. **Duplicates are allowed:** the official illustration shows 2 teammates' picks still un-greyed in the grid. [ASSUMED] 12 s per turn inside the 50 s step. | VERIFIED except the per-turn timer |
| 2 | banned bonds "1 core + 1 add-on" [ASSUMED] | Replaced by the verified rule in **A2**. | VERIFIED | | 2 | banned bonds "1 core + 1 add-on" [ASSUMED] | Replaced by the verified rule in **A2**. | VERIFIED |
| 1, 12 | one ×0.7/×0.8 multiplier per mode; `aceffect_enemy_*` mapping [ASSUMED] | Replaced by the **per-round table in A3**. | VERIFIED (PRTS, user-sourced) | | 1, 12 | one ×0.7/×0.8 multiplier per mode; `aceffect_enemy_*` mapping [ASSUMED] | Replaced by the **per-round table in A3**. | VERIFIED (PRTS, user-sourced) |
+4 -4
View File
@@ -15,7 +15,7 @@
"Each player has one integer layer counter L per bond (23 counters). All start at 0 when the simulation starts, are never reset between rounds and are never reduced (no layer-loss effect exists in the data) [ASSUMED: never reduced].", "Each player has one integer layer counter L per bond (23 counters). All start at 0 when the simulation starts, are never reset between rounds and are never reduced (no layer-loss effect exists in the data) [ASSUMED: never reduced].",
"L is kept while the bond is inactive, but bond effects only apply while the bond is active (PRTS 盟约记录).", "L is kept while the bond is inactive, but bond effects only apply while the bond is active (PRTS 盟约记录).",
"Wording contract used by every source: \"使已激活的【X】层数+N\" -> add only if bond X is active at the moment of the trigger; \"(无需激活盟约)\" -> add even if X is inactive. \"自身所属盟约\" = every bond of that operator; \"自身已激活的盟约\" = only its bonds that are active.", "Wording contract used by every source: \"使已激活的【X】层数+N\" -> add only if bond X is active at the moment of the trigger; \"(无需激活盟约)\" -> add even if X is inactive. \"自身所属盟约\" = every bond of that operator; \"自身已激活的盟约\" = only its bonds that are active.",
"No global layer cap. Caps are per source: max_add_count_per_battle (layers a single garrison instance may add in one battle), max_layer (per round, e.g. 安洁莉娜 12/round).", "No layer cap in the data. Caps are per source: max_add_count_per_battle (layers a single garrison instance may add in one battle), max_layer (per round, e.g. 安洁莉娜 12/round). The official cap is in the client, not the data: AutoChessBattleConst.MAX_GARRISON_STACK = 999, AddBondCount stores min(L + n, 999) per bond (research 11 §1). Since 2026-10-01 (DESIGN §20.12) the remake stops each bond at 999 (shared/constants.js BOND_LAYER_CAP, layerGainRoom); the community agrees (巴哈姆特 12534 \"每把都能999层\", \"沒999層的盟約情況下\"; 12316 \"999謝\").",
"Layers gained in battle persist after the battle and immediately affect formulas that read L [ASSUMED live update: recompute bond buffs whenever L changes; the reference game uses env_gbuff that reads current stacks].", "Layers gained in battle persist after the battle and immediately affect formulas that read L [ASSUMED live update: recompute bond buffs whenever L changes; the reference game uses env_gbuff that reads current stacks].",
"IN_BATTLE layer gains are disabled while fighting for a teammate in the 联防 (Unite) phase and in the leader/boss round (巴哈姆特 snA=12294; arknights.wiki.gg: \"Players cannot gain stacks via Combat-related Operator Attributes when assisting other players in the Unite Phase\").", "IN_BATTLE layer gains are disabled while fighting for a teammate in the 联防 (Unite) phase and in the leader/boss round (巴哈姆特 snA=12294; arknights.wiki.gg: \"Players cannot gain stacks via Combat-related Operator Attributes when assisting other players in the Unite Phase\").",
"魔王 (garrison_59, emptyShip): whenever the operator on the tile in front of 魔王 raises layers through its own trait during battle, that raise gets +1 extra layer (per trigger).", "魔王 (garrison_59, emptyShip): whenever the operator on the tile in front of 魔王 raises layers through its own trait during battle, that raise gets +1 extra layer (per trigger).",
@@ -36,7 +36,7 @@
"DIY (自选) chess slots chess_char_5_diy1/2 and chess_char_6_diy1/2 have bondIds [] and are filled with a 6-star operator the player owns (diyChessDict = TIER_6). Their bond is derived from the chosen operator's nationId/groupId/teamId matched against autoChessData.bondInfoDict[*].powerIdList (e.g. yan: yan,sui,lungmen,lee,lgd; victoria: victoria,glasgow; kjerag: kjerag,karlan; egir: egir,abyssal; siracusa: siracusa,chiave; kazimierz: kazimierz,pinus; sargon; laterano). No match -> constData.fallbackBondId = emptyShip (协防干员). (bwiki: operators from those nations get the nation alliance, \"others receive the 协防 designation\".)", "DIY (自选) chess slots chess_char_5_diy1/2 and chess_char_6_diy1/2 have bondIds [] and are filled with a 6-star operator the player owns (diyChessDict = TIER_6). Their bond is derived from the chosen operator's nationId/groupId/teamId matched against autoChessData.bondInfoDict[*].powerIdList (e.g. yan: yan,sui,lungmen,lee,lgd; victoria: victoria,glasgow; kjerag: kjerag,karlan; egir: egir,abyssal; siracusa: siracusa,chiave; kazimierz: kazimierz,pinus; sargon; laterano). No match -> constData.fallbackBondId = emptyShip (协防干员). (bwiki: operators from those nations get the nation alliance, \"others receive the 协防 designation\".)",
"Prototype substitution: a NORMAL chess whose operator the player does not own is fielded as its backupCharId (预备干员 / 原型干员) but keeps the chess's bonds and traits (PRTS: \"烛煌(或对应的原型干员)\").", "Prototype substitution: a NORMAL chess whose operator the player does not own is fielded as its backupCharId (预备干员 / 原型干员) but keeps the chess's bonds and traits (PRTS: \"烛煌(或对应的原型干员)\").",
"Mode filter: 标准模拟 (mode_single_funny / mode_multi_funny) enables only 13 bonds (炎 萨尔贡 维多利亚 谢拉格 叙拉古 精准 迅捷 坚守 助力 远见 不屈 调和 协防干员); 拉特兰 阿戈尔 卡西米尔 灵巧 奥术 奇迹 投资人 突袭 独行 绝技 are inactive there. Training/险境/绝境/终极 enable all 23.", "Mode filter: 标准模拟 (mode_single_funny / mode_multi_funny) enables only 13 bonds (炎 萨尔贡 维多利亚 谢拉格 叙拉古 精准 迅捷 坚守 助力 远见 不屈 调和 协防干员); 拉特兰 阿戈尔 卡西米尔 灵巧 奥术 奇迹 投资人 突袭 独行 绝技 are inactive there. Training/险境/绝境/终极 enable all 23.",
"Stat math: attribute bonuses from 盟约, strategies and equipment are \"直接乘算\" - each is a separate multiplier on the operator's already-final stat, independent of the operator's own talents/skills (PRTS 盟约记录). E.g. 炎 +50% ATK and an item +30% ATK -> final = own_final_atk x 1.5 x 1.3.", "Stat math: attribute bonuses from 盟约, strategies and equipment are \"直接乘算\" (PRTS 盟约记录 \"盟约效果,策略效果,装备效果提供的属性加成均为直接乘算,与自持有干员的属性加成独立\"). Corrected 2026-10-01 (DESIGN §20.10): 直接乘算 is PRTS's modifier class whose values are SUMMED with each other and with the skills' \"+X%\" (游戏数据基础: D_t = t1 + ... + tn, A = (A0 + D_p)(1 + D_t)); \"与自持有干员的属性加成独立\" = separate from the 自持有加成 table (an owned operator +5-10% ATK / DEF / max HP by its real promotion; not modelled). E.g. 炎 +50% ATK and an item +30% ATK -> final = base_atk x (1 + 0.5 + 0.3), not x 1.5 x 1.3 (the reading used until v2.5).",
"Core bonds (bondType SEASON, isPower) = 炎 萨尔贡 维多利亚 谢拉格 拉特兰 阿戈尔 叙拉古 卡西米尔 (8). The other 15 are add-on (REGULAR). bondOrder: 调和 = 1 (listed first in UI), others 2.", "Core bonds (bondType SEASON, isPower) = 炎 萨尔贡 维多利亚 谢拉格 拉特兰 阿戈尔 叙拉古 卡西米尔 (8). The other 15 are add-on (REGULAR). bondOrder: 调和 = 1 (listed first in UI), others 2.",
"weight (10 for most, 0 for 投资人/调和/协防干员/绝技): weight used when an effect picks a random bond (e.g. SERVER_ADD_BOND_ACTIVATED_RANDOM, random bond chess) [ASSUMED meaning]." "weight (10 for most, 0 for 投资人/调和/协防干员/绝技): weight used when an effect picks a random bond (e.g. SERVER_ADD_BOND_ACTIVATED_RANDOM, random bond chess) [ASSUMED meaning]."
], ],
@@ -5721,7 +5721,7 @@
"tiers": [ "tiers": [
{ {
"need": "2 distinct", "need": "2 distinct",
"effect": "When an 奥术 member deals magic damage, the target takes magic damage x(1.20 + 0.01*L) (i.e. +(20+L)%) for 3 s. One instance per (source unit, target); reapplying refreshes." "effect": "When an 奥术 member deals magic damage, the target takes magic damage x(1.20 + 0.01*L) (i.e. +(20+L)%) for 3 s. ONE instance per target whatever applies it: the strongest value wins, a weaker one resumes if it outlasts it; reapplying refreshes (corrected 2026-10-01: PRTS 作战机制 同名buff默认只表现出一个; 巴哈姆特 12316 the two players of a pair field compete for it)."
}, },
{ {
"need": "3 distinct", "need": "3 distinct",
@@ -5736,7 +5736,7 @@
}, },
"layerGain": "夕 +2 per operator gained; 阿罗玛 +2 per refresh (<=6); 溯光星源 +2 per 3 gold spent; 海霓 first kill +3; 获得时: 深靛 +2, 协律 +4 (hidden). 机变 \"莫斯提马的盟誓\" +10.", "layerGain": "夕 +2 per operator gained; 阿罗玛 +2 per refresh (<=6); 溯光星源 +2 per 3 gold spent; 海霓 first kill +3; 获得时: 深靛 +2, 协律 +4 (hidden). 机变 \"莫斯提马的盟誓\" +10.",
"assumed": [ "assumed": [
"instances from different 奥术 sources stack multiplicatively" "one instance per target, the strongest (the engine's default same-name rule; the two players of a pair field compete for it) — until v2.5 the remake kept one per player and they multiplied"
] ]
}, },
"members": [ "members": [
+4 -4
View File
@@ -51,7 +51,7 @@ Core (isPower, bondType SEASON): 炎 萨尔贡 维多利亚 谢拉格 拉特兰
- DIY (自选) chess slots chess_char_5_diy1/2 and chess_char_6_diy1/2 have bondIds [] and are filled with a 6-star operator the player owns (diyChessDict = TIER_6). Their bond is derived from the chosen operator's nationId/groupId/teamId matched against autoChessData.bondInfoDict[*].powerIdList (e.g. yan: yan,sui,lungmen,lee,lgd; victoria: victoria,glasgow; kjerag: kjerag,karlan; egir: egir,abyssal; siracusa: siracusa,chiave; kazimierz: kazimierz,pinus; sargon; laterano). No match -> constData.fallbackBondId = emptyShip (协防干员). (bwiki: operators from those nations get the nation alliance, "others receive the 协防 designation".) - DIY (自选) chess slots chess_char_5_diy1/2 and chess_char_6_diy1/2 have bondIds [] and are filled with a 6-star operator the player owns (diyChessDict = TIER_6). Their bond is derived from the chosen operator's nationId/groupId/teamId matched against autoChessData.bondInfoDict[*].powerIdList (e.g. yan: yan,sui,lungmen,lee,lgd; victoria: victoria,glasgow; kjerag: kjerag,karlan; egir: egir,abyssal; siracusa: siracusa,chiave; kazimierz: kazimierz,pinus; sargon; laterano). No match -> constData.fallbackBondId = emptyShip (协防干员). (bwiki: operators from those nations get the nation alliance, "others receive the 协防 designation".)
- Prototype substitution: a NORMAL chess whose operator the player does not own is fielded as its backupCharId (预备干员 / 原型干员) but keeps the chess's bonds and traits (PRTS: "烛煌(或对应的原型干员)"). - Prototype substitution: a NORMAL chess whose operator the player does not own is fielded as its backupCharId (预备干员 / 原型干员) but keeps the chess's bonds and traits (PRTS: "烛煌(或对应的原型干员)").
- Mode filter: 标准模拟 (mode_single_funny / mode_multi_funny) enables only 13 bonds (炎 萨尔贡 维多利亚 谢拉格 叙拉古 精准 迅捷 坚守 助力 远见 不屈 调和 协防干员); 拉特兰 阿戈尔 卡西米尔 灵巧 奥术 奇迹 投资人 突袭 独行 绝技 are inactive there. Training/险境/绝境/终极 enable all 23. - Mode filter: 标准模拟 (mode_single_funny / mode_multi_funny) enables only 13 bonds (炎 萨尔贡 维多利亚 谢拉格 叙拉古 精准 迅捷 坚守 助力 远见 不屈 调和 协防干员); 拉特兰 阿戈尔 卡西米尔 灵巧 奥术 奇迹 投资人 突袭 独行 绝技 are inactive there. Training/险境/绝境/终极 enable all 23.
- Stat math: attribute bonuses from 盟约, strategies and equipment are "直接乘算" - each is a separate multiplier on the operator's already-final stat, independent of the operator's own talents/skills (PRTS 盟约记录). E.g. 炎 +50% ATK and an item +30% ATK -> final = own_final_atk x 1.5 x 1.3. - Stat math: attribute bonuses from 盟约, strategies and equipment are "直接乘算" (PRTS 盟约记录 "盟约效果,策略效果,装备效果提供的属性加成均为直接乘算,与自持有干员的属性加成独立"). **Corrected 2026-10-01 (DESIGN §20.10):** 直接乘算 is PRTS's technical modifier class (游戏数据基础 属性基本公式: "直接乘算结果 D_t = t₁ + t₂ + … + tₙ", A = (A₀ + D_p)(1 + D_t); 作战机制: X₂ = X₁(1 + b₁% + b₂%), a skill's "攻击力+180%" being the example) — the values are SUMMED with each other and with the skills' "+X%"; "与自持有干员的属性加成独立" means they are separate from the **自持有加成** (PRTS 卫戍协议:盟约 下半, its own table: an operator the player really owns gets +5–10 % ATK / DEF / max HP by its real promotion — 精英1 Lv.1 +5/+5/–, 精英2 Lv.1 +10/+5/+5, 精英2 Lv.60 +10/+10/+10, the highest met applies), which the remake does not model. E.g. 炎 +50% ATK and an item +30% ATK -> final = base_atk x (1 + 0.5 + 0.3) (+ any skill %), not x 1.5 x 1.3 (the reading used until v2.5).
- Core bonds (bondType SEASON, isPower) = 炎 萨尔贡 维多利亚 谢拉格 拉特兰 阿戈尔 叙拉古 卡西米尔 (8). The other 15 are add-on (REGULAR). bondOrder: 调和 = 1 (listed first in UI), others 2. - Core bonds (bondType SEASON, isPower) = 炎 萨尔贡 维多利亚 谢拉格 拉特兰 阿戈尔 叙拉古 卡西米尔 (8). The other 15 are add-on (REGULAR). bondOrder: 调和 = 1 (listed first in UI), others 2.
- weight (10 for most, 0 for 投资人/调和/协防干员/绝技): weight used when an effect picks a random bond (e.g. SERVER_ADD_BOND_ACTIVATED_RANDOM, random bond chess) [ASSUMED meaning]. - weight (10 for most, 0 for 投资人/调和/协防干员/绝技): weight used when an effect picks a random bond (e.g. SERVER_ADD_BOND_ACTIVATED_RANDOM, random bond chess) [ASSUMED meaning].
@@ -60,7 +60,7 @@ Core (isPower, bondType SEASON): 炎 萨尔贡 维多利亚 谢拉格 拉特兰
- Each player has one integer layer counter L per bond (23 counters). All start at 0 when the simulation starts, are never reset between rounds and are never reduced (no layer-loss effect exists in the data) [ASSUMED: never reduced]. - Each player has one integer layer counter L per bond (23 counters). All start at 0 when the simulation starts, are never reset between rounds and are never reduced (no layer-loss effect exists in the data) [ASSUMED: never reduced].
- L is kept while the bond is inactive, but bond effects only apply while the bond is active (PRTS 盟约记录). - L is kept while the bond is inactive, but bond effects only apply while the bond is active (PRTS 盟约记录).
- Wording contract used by every source: "使已激活的【X】层数+N" -> add only if bond X is active at the moment of the trigger; "(无需激活盟约)" -> add even if X is inactive. "自身所属盟约" = every bond of that operator; "自身已激活的盟约" = only its bonds that are active. - Wording contract used by every source: "使已激活的【X】层数+N" -> add only if bond X is active at the moment of the trigger; "(无需激活盟约)" -> add even if X is inactive. "自身所属盟约" = every bond of that operator; "自身已激活的盟约" = only its bonds that are active.
- No global layer cap. Caps are per source: max_add_count_per_battle (layers a single garrison instance may add in one battle), max_layer (per round, e.g. 安洁莉娜 12/round). - No layer cap in the data. Caps are per source: max_add_count_per_battle (layers a single garrison instance may add in one battle), max_layer (per round, e.g. 安洁莉娜 12/round). The official cap is in the client, not the data: `AutoChessBattleConst.MAX_GARRISON_STACK = 999`, `AddBondCount` stores `min(L + n, 999)` per bond (research 11 §1). Since 2026-10-01 (DESIGN §20.12) the remake stops each bond at 999 (`shared/constants.js BOND_LAYER_CAP`, `layerGainRoom`); the community agrees (巴哈姆特 12534 "每把都能999层", "沒999層的盟約情況下"; 12316 "999謝").
- Layers gained in battle persist after the battle and immediately affect formulas that read L [ASSUMED live update: recompute bond buffs whenever L changes; the reference game uses env_gbuff that reads current stacks]. - Layers gained in battle persist after the battle and immediately affect formulas that read L [ASSUMED live update: recompute bond buffs whenever L changes; the reference game uses env_gbuff that reads current stacks].
- IN_BATTLE layer gains are disabled while fighting for a teammate in the 联防 (Unite) phase and in the leader/boss round (巴哈姆特 snA=12294; arknights.wiki.gg: "Players cannot gain stacks via Combat-related Operator Attributes when assisting other players in the Unite Phase"). - IN_BATTLE layer gains are disabled while fighting for a teammate in the 联防 (Unite) phase and in the leader/boss round (巴哈姆特 snA=12294; arknights.wiki.gg: "Players cannot gain stacks via Combat-related Operator Attributes when assisting other players in the Unite Phase").
- 魔王 (garrison_59, emptyShip): whenever the operator on the tile in front of 魔王 raises layers through its own trait during battle, that raise gets +1 extra layer (per trigger). - 魔王 (garrison_59, emptyShip): whenever the operator on the tile in front of 魔王 raises layers through its own trait during battle, that raise gets +1 extra layer (per trigger).
@@ -655,11 +655,11 @@ Members (12 chess, 11 in current shop pool; by tier in shop: {'1': 1, '2': 2, '3
**Implementable spec** **Implementable spec**
- **[2 distinct]** When an 奥术 member deals magic damage, the target takes magic damage x(1.20 + 0.01*L) (i.e. +(20+L)%) for 3 s. One instance per (source unit, target); reapplying refreshes. - **[2 distinct]** When an 奥术 member deals magic damage, the target takes magic damage x(1.20 + 0.01*L) (i.e. +(20+L)%) for 3 s. ONE instance per target whatever applies it — the strongest value wins, a weaker one resumes if it outlasts it; reapplying refreshes (corrected 2026-10-01, DESIGN §20.10: PRTS 作战机制 "同名buff的默认叠加策略buff只能表现出一个"; 巴哈姆特 12316 first-hand "共享型buff會跟對面搶 如果對面層數比你高就不需要再特別激活直接吃他的奧術buff就行" — the two players of a pair field compete for it).
- **[3 distinct]** Against enemies below 50% HP the multiplier is 1.4 x (1.20 + 0.01*L) = 1.68 + 0.014*L (i.e. +(68+1.4L)%). - **[3 distinct]** Against enemies below 50% HP the multiplier is 1.4 x (1.20 + 0.01*L) = 1.68 + 0.014*L (i.e. +(68+1.4L)%).
- Formulas: `magicTakenMul = 1.20 + 0.01*L`; `magicTakenMulLowHp(3) = 1.4*(1.20 + 0.01*L)`; `hpThreshold = 0.5`; `durationSec = 3` - Formulas: `magicTakenMul = 1.20 + 0.01*L`; `magicTakenMulLowHp(3) = 1.4*(1.20 + 0.01*L)`; `hpThreshold = 0.5`; `durationSec = 3`
- How layers are gained: 夕 +2 per operator gained; 阿罗玛 +2 per refresh (<=6); 溯光星源 +2 per 3 gold spent; 海霓 first kill +3; 获得时: 深靛 +2, 协律 +4 (hidden). 机变 "莫斯提马的盟誓" +10. - How layers are gained: 夕 +2 per operator gained; 阿罗玛 +2 per refresh (<=6); 溯光星源 +2 per 3 gold spent; 海霓 first kill +3; 获得时: 深靛 +2, 协律 +4 (hidden). 机变 "莫斯提马的盟誓" +10.
- [ASSUMED] instances from different 奥术 sources stack multiplicatively - [ASSUMED] one instance per target, the strongest (the engine's default same-name rule; the two players of a pair field compete for it). Until v2.5 this read "instances from different 奥术 sources stack multiplicatively" and the remake kept one instance per player, so two players' instances multiplied.
- Bond item (with 变形同构体 grants this bond): 奥术法阵 `chess_item_3_08_e` (1 gold: 法术抗性+20,攻击使目标失去特殊能力5秒) - Bond item (with 变形同构体 grants this bond): 奥术法阵 `chess_item_3_08_e` (1 gold: 法术抗性+20,攻击使目标失去特殊能力5秒)
- Garrisons that explicitly add layers to this bond: `garrison_119`, `garrison_121`, `garrison_122`, `garrison_131` (see section 4; plus the generic ones) - Garrisons that explicitly add layers to this bond: `garrison_119`, `garrison_121`, `garrison_122`, `garrison_131` (see section 4; plus the generic ones)
- Garrisons that scale with this bond's layers: `garrison_05` - Garrisons that scale with this bond's layers: `garrison_05`
+3 -3
View File
@@ -487,7 +487,7 @@ The gate is the start of the action's route. R1–R3 use only the lower gate. No
| 4 | `pickEntries` `inactiveIn`, `pickReplacement` filters attached keys | inactive list also filters N/E replacements | only `specialEnemyKey` is filtered | [DATA], [WIKI] 上半 note | change | | 4 | `pickEntries` `inactiveIn`, `pickReplacement` filters attached keys | inactive list also filters N/E replacements | only `specialEnemyKey` is filtered | [DATA], [WIKI] 上半 note | change |
| 5 | `data/tuning.json → modes[*].enemyHpMul / enemyAtkMul` (all 8 modes, e.g. multi 险境 R13 HP ×0.55) | custom per-round multipliers from `tools/balance.mjs` | none; only the PRTS table (`config.enemyScale`) | [WIKI] | **delete** (keep `titles.comment_3`, which is unrelated) | | 5 | `data/tuning.json → modes[*].enemyHpMul / enemyAtkMul` (all 8 modes, e.g. multi 险境 R13 HP ×0.55) | custom per-round multipliers from `tools/balance.mjs` | none; only the PRTS table (`config.enemyScale`) | [WIKI] | **delete** (keep `titles.comment_3`, which is unrelated) |
| 6 | `data/tuning.json → mode_single_funny.bossHpMul 0.6` | leader pool ×0.6 in solo 标准 | none | — | **delete** | | 6 | `data/tuning.json → mode_single_funny.bossHpMul 0.6` | leader pool ×0.6 in solo 标准 | none | — | **delete** |
| 7 | `config.bossHpScale` `bloodPoint × alive/4`, solo ×0.25 [ASSUMED] | pool scales with alive players | co-op: split leaders share HP, "敌方领袖的总生命值不变" → pool = `bloodPoint` (no alive scaling found); **solo: unknown** | [WIKI] notice 5114 / PRTS 下半 | co-op: use `bloodPoint` as is; solo: keep a config value, flagged unknown | | 7 | `config.bossHpScale` `bloodPoint × alive/4`, solo ×0.25 [ASSUMED] | pool scales with alive players | co-op: split leaders share HP, "敌方领袖的总生命值不变" → pool = `bloodPoint` (no alive scaling found); **solo: unknown**. **Re-read 2026-10-01 (DESIGN §20.10):** notice 5114's full sentence is "在联合模拟的最终攻势阶段,部分敌方领袖会同时出现在战场的左右两侧,两侧的敌方领袖共享生命值(敌方领袖的总生命值不变)" — the left / right copies of one pair field share the HP; it says nothing about the number of players. The one note on that is community (巴哈姆特 12294 "聯機隊友(撤退/死掉)變少,最後boss血條也會變少", no proportion); it is kept as the config switch `bossHpScale.aliveScaling` (× alive / 4), **off** until the user confirms it from official play — it would shorten fights after eliminations, the opposite of the playtest report | [WIKI] notice 5114 / PRTS 下半 | co-op: `bloodPoint` whatever the alive count (× alive / 4 behind the switch, [ASSUMED proportion]); solo: keep a config value, flagged unknown |
| 8 | `waves.js bountySpawns` `soloMul 0.7` for "perfect" bounties | bounty HP/ATK/DEF ×0.7 **on top of** the solo 0.7 base | no bounty-specific rule; the "70 %" note is the global solo base, already in `enemyScale` | [WIKI] 上半 11/18 log | remove `soloMul` | | 8 | `waves.js bountySpawns` `soloMul 0.7` for "perfect" bounties | bounty HP/ATK/DEF ×0.7 **on top of** the solo 0.7 base | no bounty-specific rule; the "70 %" note is the global solo base, already in `enemyScale` | [WIKI] 上半 11/18 log | remove `soloMul` |
| 9 | `bountySpawns` timing | t = 4 s, +5 s per bounty, interval 3, `routeByMotion` | attached to the template's first N / NF action window (§5) | [DATA] | change | | 9 | `bountySpawns` timing | t = 4 s, +5 s per bounty, interval 3, `routeByMotion` | attached to the template's first N / NF action window (§5) | [DATA] | change |
| 10 | `buildUniteWave` | grouped by slot class N/E/S/…, compressed into 40 % of the time limit | walkers → `lrsldr` action, flyers → `yokai`, tokens → T/TF; per-owner 0.5 s spacing | [DATA] (partial) | change | | 10 | `buildUniteWave` | grouped by slot class N/E/S/…, compressed into 40 % of the time limit | walkers → `lrsldr` action, flyers → `yokai`, tokens → T/TF; per-owner 0.5 s spacing | [DATA] (partial) | change |
@@ -536,7 +536,7 @@ Everything in `docs/BALANCE.md` §4 (the tuned tables) and the `tools/balance.mj
2. **Stat scaling and balance.** 2. **Stat scaling and balance.**
- Delete every `enemyHpMul`, `enemyAtkMul` and `bossHpMul` in `data/tuning.json`. Keep the `titles` block. - Delete every `enemyHpMul`, `enemyAtkMul` and `bossHpMul` in `data/tuning.json`. Keep the `titles` block.
- Remove the `soloMul` in `bountySpawns`. - Remove the `soloMul` in `bountySpawns`.
- `config.bossHpScale`: co-op pool = `bloodPoint[difficulty]` with no alive-player factor; solo stays a flagged config value. - `config.bossHpScale`: co-op pool = `bloodPoint[difficulty]` with no alive-player factor; solo stays a flagged config value. (2026-10-01: × alive / 4 available behind `aliveScaling`, off, see §6 #7.)
- Keep `enemyScale` (PRTS table) and the 终极 speed ×1.15 from R3. - Keep `enemyScale` (PRTS table) and the 终极 speed ×1.15 from R3.
- Re-run `tools/balance.mjs --tuning off` only to report, not to tune. - Re-run `tools/balance.mjs --tuning off` only to report, not to tune.
3. **Pathing** (`server/sim/grid.js`, `server/sim/ai.js`, `server/sim/Battle.js`): 3. **Pathing** (`server/sim/grid.js`, `server/sim/ai.js`, `server/sim/Battle.js`):
@@ -566,7 +566,7 @@ Everything in `docs/BALANCE.md` §4 (the tuned tables) and the `tools/balance.mj
| # | Question | Default | | # | Question | Default |
|---|---|---| |---|---|---|
| 1 | Do 射击台 (act1 m03 (10,3)/(10,4)) and 土石结构 block ground movement? | Block [ASSUMED] | | 1 | Do 射击台 (act1 m03 (10,3)/(10,4)) and 土石结构 block ground movement? | Block [ASSUMED] |
| 2 | Leader HP pool in solo, and in co-op with fewer than 4 alive players | co-op `bloodPoint`; solo config value (flagged) | | 2 | Leader HP pool in solo, and in co-op with fewer than 4 alive players | co-op `bloodPoint` (× alive / 4 behind `aliveScaling`, off — ask the user, 2026-10-01); solo config value (flagged) |
| 3 | Unit of `maxPlayTime` | Real seconds (×2 game) [ASSUMED] | | 3 | Unit of `maxPlayTime` | Real seconds (×2 game) [ASSUMED] |
| 4 | Exact 联防 predelay formula (`_CalculateActionPredelayConsiderUid`) | 0.5 s per owner [ASSUMED] | | 4 | Exact 联防 predelay formula (`_CalculateActionPredelayConsiderUid`) | 0.5 s per owner [ASSUMED] |
| 5 | DEF reduction in the 70 % / 80 % bases | HP/ATK only (下半 text) | | 5 | DEF reduction in the 70 % / 80 % bases | HP/ATK only (下半 text) |
+243
View File
@@ -0,0 +1,243 @@
# 11 · Official limits: the 999 bond-layer cap and the 300000 boss-hit limit ("限伤")
Written 2026-10-01 as research (no code changed then). It follows the boss-HP review (DESIGN §20.10), where
community first-hand reports pointed at two official mechanics the remake lacked: bond layers that stop at 999, and a
"限伤" that makes very large hits deal nothing. **Implemented** the same day after the user asked for the co-op leaders to
be as hard to kill as officially — §6 below and DESIGN §20.12; §2.3 (who counts as a leader) was read from the client
while implementing.
**Tags** (as in research 09)
- **[DATA]**: read from the official client. Here that is the local Windows client 2.7.71 (Unity 2021.3.39f1, files dated
2026-08-20): `global-metadata.dat` (il2cpp v29: class, field and method names, constant values) and `GameAssembly.dll`
(x86-64 machine code, disassembled with `llvm-objdump`; method addresses mapped to names through the
`Assembly-CSharp.dll` code-gen module's method-pointer table and the metadata method tokens).
- **[COMM]**: community posts (Bahamut, NGA, bilibili).
- **[ASSUMED]**: our inference or proposal.
---
## 0. TL;DR
| # | Question | Official answer | Tag |
|---|---|---|---|
| 1 | Do bond layers cap? | **Yes, at 999.** `Torappu.Battle.AutoChessBattleConst.MAX_GARRISON_STACK = 999`. The client's bond-layer counter (`AutoChessPlayerDataModel/ScenePlayerData.AddBondCount`) adds the gain and then clamps: `count = min(count + n, 999)`. | DATA + COMM |
| 2 | Is there a damage limit? | **Yes, on boss leaders in boss battles: a single hit of 300000 or more is cancelled (0 damage).** It is not a clamp and not an integer overflow. `AutoChessBattleConst.MAX_BATTLE_DAMAGE = 300000`; `AutoChessStepModeManager._OnBossEnemyTakeDamage` computes `d = ceil(hit)` and calls `modifier.Cancel()` when `d ≥ 300000`. | DATA + COMM |
| 2a | Where does it apply? | Only in **boss battles outside training** (the Final Assault and the Hidden Core: `AutoChessGameStatus.inBossBattleState && !isTrainingMode` starts "step mode"), and only to enemies that `AutoChessBattleUtil.IsBossEnemy(enemyId)` accepts (the leaders). Normal rounds, 联防, minions in boss battles and training are not affected. | DATA |
| 2b | What does a player see? | The hit does nothing: no HP loss, nothing counted toward the shared pool. Bigger multipliers therefore *lower* the damage of hits pushed past the line, which is why the community advice is to *not* light extra damage bonds on a 999-layer board. | DATA + COMM |
---
## 1. The 999 layer cap
### 1.1 Constant
`Torappu.Battle.AutoChessBattleConst` (metadata field default values; ints are compressed in v29):
| Field | Value |
|---|---|
| `MAX_GARRISON_STACK` | **999** |
| `MAX_BATTLE_DAMAGE` | **300000** |
| `MAX_BATTLE_DEPLOY_CNT` | 12 |
| `MAX_WEAR_EQUIP_CNT` | 2 |
| `SPEED_LEVEL_IN_BATTLE` | 2 |
| `BATTLE_WAIT_UI_TIME` / `HELP_BATTLE_WAIT_UI_TIME` | 1.5 / 2.5 (float) |
(`Torappu.UI.AutoChess.AutoChessCountDownView.COUNT_DOWN_MAX_NUM = 999` is an unrelated UI countdown.)
### 1.2 Where it is used
C# constants are inlined, so the use sites were found by scanning the il2cpp code section for the immediate. In every
`AutoChess` method, 999 appears as a clamp only in
`Torappu.Battle.AutoChess.AutoChessPlayerDataModel/ScenePlayerData::AddBondCount(bondId, n)`:
```
TryGetValue(bondId, out bond); if (!bond.valid) return false; // [rcx+0x20]
bond.stack += n; // add edx, edi ; [rcx+0x18]
bond.stack = bond.stack < 999 ? bond.stack : 999; // mov eax,0x3e7 ; cmp ; cmovl
return true;
```
Its only caller is `AutoChessGarrisonManager._TriggerGarrisonStatusRefresh`, the in-battle 特质 / bond layer gain on the
viewed player's data (`AutoChessDataCenter.viewPlayerData`). The prep-side gains are computed by the scene server, whose
code is not in the client; the client clamp, the constant's home (`AutoChessBattleConst`) and the community reports
(§3) say the cap is the same there. [ASSUMED: the server clamps every gain the same way, `min(L + n, 999)`.]
### 1.3 Reading
- A hard gameplay cap, not a display cap: the clamped value is the one stored (and the layer readers — `GetBondStackCount`,
`AutoChessAssignBondStackCntToBB` — read the stored value).
- Per bond: each bond's layer counter stops at 999 on its own.
- The per-battle gain limits of individual 特质 (e.g. 锏 ≤ 24, 凛御银灰 ≤ 200; blackboard `max_add…`,
`AutoChessGarrisonManager.BOND_ADD_MAX_PER_BATTLE`) are separate and unchanged.
## 2. The 300000 boss-hit limit
### 2.1 The code
`Torappu.Battle.AutoChess.AutoChessGameManager.StartBattle`:
```
if (gameStatus.inBossBattleState && !isTrainingMode) { // [this+0x18] = get_isTrainingMode
inStepMode = true;
BattleController.ReqChangeDeltaTimeFPInStepMode();
stepModeManager.Start();
}
```
`AutoChessStepModeManager.OnEnemyRegistered(enemy)`: `if (AutoChessBattleUtil.IsBossEnemy(enemy.id)) bosses[enemy.instId] = enemy`.
`AutoChessGameManager.OnEntityApplyModifier(entity, modifier)`: `if (inStepMode) stepModeManager.OnEntityApplyModifier(...)`,
which forwards to `_OnBossEnemyTakeDamage` when the entity is a registered boss.
`AutoChessStepModeManager._OnBossEnemyTakeDamage(enemy, modifier)`:
```
int d = (int)Math.Ceiling(modifier.value.AsFloat()); // roundsd …, 0xA (toward +∞)
if (enemy == null || modifier.isCancelled || !modifier.isDamage) return;
if (d <= 0) return;
if (d >= 300000) { modifier.Cancel(); return; } // cmp ebp, 0x493e0 ; jge → Modifier::Cancel(0)
bossDamage[localPlayer] += d; // TryGetValue / set
actions.Add(new StepAction { type = 3, d, killedInfo… }); // the step relayed to the server / partner
```
There is no other use of 300000 (or 299999, or the float 300000.0) in the autochess code; the other three hits of the
immediate in the binary are `HttpWebRequest` / `FtpWebRequest` timeouts.
### 2.2 Reading
- **Cancel, not clamp.** `Modifier.Cancel` is the engine's ordinary damage cancel (the same call as for invulnerability):
unless the modifier carries an ignore-cancel mask for reason 0, the hit is dropped. So a hit worth 299999 lands, a hit
worth 300000 deals **0**.
- **The value checked is the hit about to be applied** (`modifier.value` at apply time: after DEF / RES and every
multiplier). [ASSUMED: before HP shields, after hit-negating blocks.] Only two leader defences in our data are
affected by this order:
- 阿利斯泰尔 (boss_6) gets an HP shield, the 5000-HP barrier of 莫非王土's shield equipment (`boss:vest`, skill
`store` / `storeA` `dynamic` 5000 in data/enemies.json). The order decides only its hits within 5000 of the line:
with the limit first, a hit of 300000–304999 is cancelled and the barrier stays; with shields first, 5000 would be
absorbed and 295000–299999 would land. The vest's ×0.2 on hits from its side is a multiplier, so it counts before
the check.
- 假想敌:管 (boss_10, hidden template h08_03) can stand in 假想敌:再生's aura (`Aura.max_damage_block_cnt` 5), which
negates a whole phys / arts hit in the `hit` step, before the limit. So a hit past the line spends one block
instead of being cancelled by the limit; either way it deals 0.
- **Who:** the leaders only (`IsBossEnemy`, §2.3). Minions in the boss battle, every enemy in normal rounds and 联防, and
everything in training are uncapped.
- **Why it exists [ASSUMED]:** in step mode every boss hit is relayed as an action so both players of a pair and the
server keep one shared pool; the limit looks like a sanity guard on those reports. It still decides real fights:
the co-op leader pools (`bloodPoint`, data/bosses.json) run from 200000 (FUNNY) to 7600000 (ABYSS), so in the easy
modes one hit over the line would be a whole pool.
- **Not an overflow.** Nothing wraps at 2³¹; the line is a deliberate constant.
### 2.3 Who is a leader: `AutoChessBattleUtil.IsBossEnemy(enemyId)` [DATA]
Read while implementing (metadata token 0x6013192, method pointer 0x180b058e0 in the same build; `Il2CppCodeGenModule`
method-pointer table of research 08 Appendix B):
```
foreach (var kv in <activity data>.autoChessData.bossInfoDict) // Dictionary<string, AutoChessBossInfoData>
if (string.Equals(kv.Value.enemyId, enemyId)) return true; // [value+0x18]; the class's fields: bossId 0x10,
return false; // enemyId 0x18, handbookEnemyId 0x20
```
So a leader is an enemy whose id is a `bossInfoDict[*].enemyId` — the 10 keys of data/bosses.json `enemyKey`
(enemy_9013_acstmk, enemy_9017_achunt, enemy_9021_acduml, enemy_1521_dslily, enemy_2016_csphtm, enemy_9032_aclionk,
enemy_9033_acdeer and the hidden enemy_9013_acstmk_2 / enemy_9017_achunt_2 / enemy_9021_acduml_2). It matches by id, so
every instance counts — the second copy a pair field spawns (boss_battle_multi_player) included. The hidden leaders'
parts (“斩胄之剑” / “破胄之锤” enemy_9014/9015, “碎铳之簧” enemy_9018–9020, 假想敌:弦 enemy_9022 and “裂管之音” /
“断弦之音” enemy_9023), 假想敌:胄's 刺胄之弹 (enemy_9016_acstmr) and drones (enemy_1005_yokai), escorts and bounty enemies
are not in the list: hits on them are never cancelled — the share a part passes on to its leader ("被击落时受到伤害以一定
比例传递") is a hit on the leader and is checked [ASSUMED: the transfer is an ordinary damage modifier on the leader].
In the remake's wave data the tag-'boss' spawns of the boss / hidden templates are exactly those keys and nothing else
(test/sim/playtest6_limits.test.js checks it).
## 3. Community reports
Paraphrased; only key terms are quoted.
### 3.1 Layers stop at 999 [COMM]
- **Bahamut** [C.php?bsn=33651&snA=12522](https://forum.gamer.com.tw/C.php?bsn=33651&snA=12522) (2026-06-28): the text says
999 layers are usually reached within 2 rounds, and that 精准 and 萨尔贡 were stacked full at 999. Its screenshots:
R12 谢拉格 696 → R13 谢拉格 **999**, 灵巧 301, 炎 122 → R14 谢拉格 still **999** while the author kept feeding it,
灵巧 671, 炎 537 → R15 炎 / 谢拉格 / 灵巧 all **999**, 迅捷 222. Each bond stops on its own.
- **bilibili** [BV16bU7BTEX1](https://www.bilibili.com/video/BV16bU7BTEX1) (2025-11-22): 萨尔贡 filled from 0 in one round, the
surplus wasted; a commenter lists the 「层数上限」 among what holds 瑰盐 back.
- **TapTap** [moment 747932824320870144](https://www.taptap.cn/moment/747932824320870144) (2025-12-10): 谢拉格 from 100 to 999
in one round.
- More videos treating 999 as full: bilibili BV1xVdBBWEuu, BV1vUyJB4Ero ("all bonds 999"), BV1eoCSBzEde (拉特兰 999 by
R11); YouTube thG4eVPnSnM. No source shows a bond above 999.
- Not stated anywhere official: the excel tables (`activity_table` act2autochess / `autoChessData`: no cap field,
`maxInactiveBondCount = -1`), PRTS (卫戍协议:盟约, 下半, 帮助, PRTS盟约记录), BWIKI and the in-game help.
### 3.2 "限伤" [COMM]
- **Bahamut** [C.php?bsn=33651&snA=12316](https://forum.gamer.com.tw/C.php?bsn=33651&snA=12316) (【心得】衛戍協議:盟約上下差異整理,
2026-05-04): some big bond combinations make the output too high, and the 「限傷」 then keeps it from coming out; with
999 谢拉格 do not light 奥术, or 大初雪 (圣聆初雪, tier 6, S3) is limited away; 瑪 (玛恩纳) should not switch to 谢拉格
for the same reason; the same warning for 阿戈尔. No number given.
- **bilibili** 【卫戍攻略#1】维多利亚&谢拉格篇 [BV19UXZBJExQ](https://www.bilibili.com/video/BV19UXZBJExQ) (comments, 2026-04):
a high-layer 圣雪 easily hits the limit and her damage goes to zero; at 999 谢 do not light 奥术, maybe drop 灵知; a
teammate's 奥术 in the boss fight zeroes her too. Asked what 限伤 means, a reply gives the number:
「一次超过30w会被卡掉」 (a single hit over 300k is cut) — the client's `MAX_BATTLE_DAMAGE`.
- **bilibili** 每把都能999层 谢拉格 [BV1xVdBBWEuu](https://www.bilibili.com/video/BV1xVdBBWEuu): bullet comments call an on-screen
hit of 272451 "nearly at the limit" and warn that the damage "overflows to zero"; in the comments 160k hits are said
to be safe, and a player's 0 damage *on the boss* is discussed.
- **bilibili** 【卫戍攻略#4】什么叫叠到999层反而亏输出 [BV1nrdqBLERz](https://www.bilibili.com/video/BV1nrdqBLERz): comments mention
"炎999限伤" and ask whether 仇白 S3 reaches it.
- JP wikiru (堅守協定:盟約 2nd) says the damage limit wastes layers beyond a point.
- Not the main game's overflow: PRTS 定点数与浮点数 / 数值范围 — battle values are a 64-bit fixed point (the client's
`Torappu.FP`), max ≈ 2³¹; a main-game hit past 2.1 billion shows 0 (bilibili BV1bhgz6DEKS, Integrated Strategies).
That is ~7000× the 卫戍 line; PRTS 作战机制 records ordinary hits in the millions, so the 300000 rule is mode-specific.
Why the hits get that big (data blackboards): 谢拉格 ×(1.35 + 0.01·L) vs cold / frozen = ×11.34 at 999; 奥术 arts taken
×(1.2 + 0.01·L) = ×11.19 at 999, a debuff on the target (so the partner's 奥术 boosts your hits); 灵知 +27–29 % taken on
cold targets, doubled on frozen; 圣聆初雪's 特质 +1 % ATK (elite +2 %) per 3 layers of her bonds; 炎 ATK
+(0.23 + 0.009·L).
- NGA [tid=46592301](https://bbs.nga.cn/read.php?tid=46592301) "卫戍协议BUG汇总&神秘机制": every endpoint answers 403
「访客不能直接访问」 (login required); not read.
These reports fit §2 exactly: the advice is to *avoid* extra multipliers, which only makes sense if a hit past the
line is lost rather than clamped.
## 4. What the remake did before the implementation, and what it does now
| | Remake before (master 7094892) | Remake now (§6) | Official |
|---|---|---|---|
| Layers | uncapped: `PlayerState.addLayers` (prep), `Match.js` settle (`ps.layers[b] += layerGains`), `Battle.addLayers` (live in-battle copy) | per bond `min(L + n, 999)` at every writer (`layerGainRoom`) | per bond `min(L + n, 999)` |
| Boss hits | no limit (`sim/damage.js dealDamage` → `applyHpLoss` → `bossPool.damage`) | leader hit in a 'boss' / 'hidden' battle with `ceil(d) ≥ 300000` → cancelled, 0 (`leaderHitCancelled`) | leader hit with `ceil(d) ≥ 300000` → 0 |
## 5. Proposal (as written before the implementation; §6 is what was built)
- `shared/constants.js`: `BOND_LAYER_CAP = 999` and `BOSS_HIT_LIMIT = 300000` (each `0`/`Infinity` = off), the switches.
- Layers: clamp in `PlayerState.addLayers`, the settle in `Match.js`, and `Battle.addLayers` (the live copy, as the
client's `AddBondCount`); `onLayers` reports the clamped `to`; a gain at the cap adds 0.
- Boss hits: in `dealDamage`, after the multipliers and before shields, `if (target.isBoss && (battle.kind === 'boss' ||
battle.kind === 'hidden') && Math.ceil(final) >= BOSS_HIT_LIMIT) final = 0` (with a `capped` event so the client can show it) [ASSUMED: shown as
no number, like the official].
- Tests, docs (META / SIM / BALANCE / PLAYING) and a DESIGN draft.
## 6. Implementation (2026-10-01, DESIGN §20.12)
- `shared/constants.js`: `BOND_LAYER_CAP = 999` and `BOSS_HIT_LIMIT = 300000` (0 / Infinity = off), plus
`layerGainRoom(before, n)` = min(n, cap − before), never negative.
- Layers: every writer clamps with it — `PlayerState.addLayers` (every prep-side gain: 特质, items, bands, 机变 cards,
bonds), the settle of the in-battle gains (`Match.js`), `Battle.addLayers` (the live in-battle copy, after the
`layerGain` hook, as the client's `AddBondCount`); a gain at the cap adds 0 (no onLayers, no 'layer' event, no hook),
so the per-N milestones (远见, 奇迹, 维多利亚 …) stop with the count; the client-result check (`fields.js`) bounds a
reported gain by the room left from the bond's starting layers; `invariants.js` flags a bond above the cap. The dev
tools write through the same helper: `tools/matchrun.mjs --layers N` (a raw boost, no milestones) and
`tools/balance.mjs applyBoard` (any `--profile`) stop at 999 too.
- 限伤: `sim/damage.js leaderHitCancelled` in `dealDamage` (after DEF / RES and every multiplier, before shields) and in
`Battle.loseHp` (losses passed on to a leader): a leader (`isBoss`, the tag-'boss' units = §2.3) in a 'boss' / 'hidden'
battle with `ceil(amount) ≥ 300000` → the hit returns 0 before shields (阿利斯泰尔's 5000 `boss:vest` barrier stays
untouched; 再生's hit blocks act earlier, in the `hit` step, §2.2), HP / pool loss, stats, the damage number, the
`damaged` / `fatal` hooks and the kill; what ran before it (the attack, its SP, the `hit` hook, separate element 损伤
hits) stays. Element bursts (元素伤害) and DoT ticks are checked like any hit; element 损伤 (gauge fill) removes no HP
and is not. The sim emits `['fx', 'hitCap', x, y, { id, n }]`; the client draws nothing for it (no number, like the
official [ASSUMED]).
- Fixed-ratio losses on a leader scale with the pool, so the pool size decides whether they meet the line. The one that
can: 【死亡集群】 (boss_1 / boss_8 skill 2, bb `hp_ratio 0.02`): a drone killed by an operator costs the leader
0.02 × its max HP, and a leader's max HP is the shared pool's max (`Battle` syncs it). Today's largest such pool,
boss_8 ABYSS 7.2M, gives 144000, which lands. A pool above 14999950 (≈ 2.08 × that; ceil(0.02 × max) = 300000)
would turn every drone kill into a cancelled hit [ASSUMED: the official routes this loss through the same
`_OnBossEnemyTakeDamage` check]. Any change to the pool size (an alive-scaled pool, a new difficulty) should keep this
in view; a real-kit test pins the line (`test/sim/playtest6_limits.test.js`, 【死亡集群】).
+2
View File
@@ -199,6 +199,8 @@
.dempty { font-size: .14rem; } .dempty { font-size: .14rem; }
.dhint { display: flex; align-items: flex-start; gap: .06rem; font-size: .14rem; color: var(--text-lo); line-height: 1.6; } .dhint { display: flex; align-items: flex-start; gap: .06rem; font-size: .14rem; color: var(--text-lo); line-height: 1.6; }
.dhint .icon { width: .16rem; height: .16rem; margin-top: .03rem; flex: none; } .dhint .icon { width: .16rem; height: .16rem; margin-top: .03rem; flex: none; }
/* a merge-completing shop / reward card's 可晋升 destination (the tag's colour; touch shows no title — QA 6b) */
.dhint--merge { color: var(--gold); }
.dactions { display: flex; justify-content: flex-end; gap: .1rem; padding-top: .06rem; border-top: 1px solid var(--line); } .dactions { display: flex; justify-content: flex-end; gap: .1rem; padding-top: .06rem; border-top: 1px solid var(--line); }
.dsell { margin-left: .06rem; color: #1d1400; } .dsell { margin-left: .06rem; color: #1d1400; }
.dfactions { display: flex; flex-wrap: wrap; gap: .06rem; } .dfactions { display: flex; flex-wrap: wrap; gap: .06rem; }
+32 -2
View File
@@ -8,7 +8,10 @@
// and the field clear of `hud(kind, size)` = { top, bottom } px of // and the field clear of `hud(kind, size)` = { top, bottom } px of
// DOM HUD along the top / bottom edge (projection.js clearHud; user // DOM HUD along the top / bottom edge (projection.js clearHud; user
// playtest #5 item 9: the shop bar covered the bench on phones) // playtest #5 item 9: the shop bar covered the bench on phones)
// view.setPrep(privateState, { editable, canPlace }) hand/temp/board pieces; editable enables drag & drop // view.setPrep(privateState, { editable, canPlace }) hand/temp/board pieces; editable enables drag & drop; a
// new board piece flashes (fx.deploy); a merge's elite — on the tile of
// the deployed copy it replaced, or on its bench slot — gets the
// promotion cue instead (render/promote.js, fx.promote)
// view.enterBattle(fieldMeta) m.field { fieldId, kind, rect, stageId, units: [UnitInfo] } // view.enterBattle(fieldMeta) m.field { fieldId, kind, rect, stageId, units: [UnitInfo] }
// view.pushSnapshot(snap); view.pushEvents(ev | { ev, gt }) b.snap / b.ev wire frames as received (game time in // view.pushSnapshot(snap); view.pushEvents(ev | { ev, gt }) b.snap / b.ev wire frames as received (game time in
// `gt`; a numeric `t` is accepted for raw Battle snapshots / recordings) // `gt`; a numeric `t` is accepted for raw Battle snapshots / recordings)
@@ -101,6 +104,7 @@ import { AREAS, areaFor, unionAreas } from './board3d/layout.js';
import { layoutPen, penSignature } from './pen.js'; import { layoutPen, penSignature } from './pen.js';
import { IDENTITY, bossPrepField, tilesToDisp } from './prepfield.js'; import { IDENTITY, bossPrepField, tilesToDisp } from './prepfield.js';
import { pickOnTile, pickBattle, hitRectAt } from './pick.js'; import { pickOnTile, pickBattle, hitRectAt } from './pick.js';
import { promotionsOf } from './promote.js';
const VENDOR = { pixi: '/vendor/pixi.min.js', spine: '/vendor/pixi-spine.js' }; const VENDOR = { pixi: '/vendor/pixi.min.js', spine: '/vendor/pixi-spine.js' };
const PIECE_DIRS = new Set(['UP', 'RIGHT', 'DOWN', 'LEFT']); const PIECE_DIRS = new Set(['UP', 'RIGHT', 'DOWN', 'LEFT']);
@@ -427,6 +431,10 @@ export async function createFieldView(host, options = {}) {
let pendingView = null; // tile band/focus to apply when the camera transition ends let pendingView = null; // tile band/focus to apply when the camera transition ends
const views = new Map(); // key → view (prep: 'p:'+uid; battle: unit id) const views = new Map(); // key → view (prep: 'p:'+uid; battle: unit id)
let prepPieces = []; // { uid, piece, area, idx, row, col, key } let prepPieces = []; // { uid, piece, area, idx, row, col, key }
// the last prep pieces before a battle / scouting board emptied prepPieces: a merge completed between two preps (a band
// or 机变 grant at ROUND_START, a SETTLE merge) is still recognised by the next setPrep (QA 6b)
let promoBase = [];
const promotions = []; // the last merges cued by setPrep (fx.promote): { uid, id, area, row, col, idx, copies } — dev / tests
let battleMeta = null; let battleMeta = null;
const infos = new Map(); // battle unit id → UnitInfo const infos = new Map(); // battle unit id → UnitInfo
// battle ids whose view finished its death / leak fade: a snapshot may still list them for a moment (the sim keeps // battle ids whose view finished its death / leak fade: a snapshot may still list them for a moment (the sim keeps
@@ -845,6 +853,20 @@ export async function createFieldView(host, options = {}) {
addList(src.board, 'board'); addList(src.board, 'board');
const seen = new Set(); const seen = new Set();
const prevBoard = new Set(prepPieces.filter((p) => p.area === 'board').map((p) => p.uid)); const prevBoard = new Set(prepPieces.filter((p) => p.area === 'board').map((p) => p.uid));
// merges since the last state (render/promote.js): new elite uid → where its consumed copies stood (their views
// still exist until the sweep below) — the elite gets the promotion cue instead of the deploy flash
const promoFrom = new Map();
const before = prepPieces.length ? prepPieces : promoBase;
promoBase = [];
for (const [uid, copies] of promotionsOf(before, list, (id) => data.chess(id))) {
// the consumed copies' views (gone after a battle rebuilt the scene: their slots instead) — the streaks' origins
promoFrom.set(uid, copies.map((g) => {
const cv = views.get(g.key);
if (cv && !cv.destroyed) return { x: cv.x, y: cv.y, z: cv.z || 0 };
const w = slotWorld(g);
return w ? { x: w.x, y: w.y, z: w.z || 0 } : null;
}).filter(Boolean));
}
for (const e of list) { for (const e of list) {
if (seen.has(e.uid)) continue; // duplicate uid in bad state: keep the first if (seen.has(e.uid)) continue; // duplicate uid in bad state: keep the first
seen.add(e.uid); seen.add(e.uid);
@@ -863,7 +885,13 @@ export async function createFieldView(host, options = {}) {
v._sig = sig; v._sig = sig;
v.setWorld(w.x, w.y, w.z); v.setWorld(w.x, w.y, w.z);
views.set(key, v); views.set(key, v);
if (e.area === 'board' && prevBoard.size && !prevBoard.has(e.uid)) { v.onDeploy?.(); fx.deploy(v); } if (promoFrom.has(e.uid)) {
// a merge's elite: on the tile of the deployed copy it replaced, or on its bench slot
if (e.area === 'board') v.onDeploy?.();
fx.promote(v, promoFrom.get(e.uid).filter((f) => Math.abs(f.x - w.x) + Math.abs(f.y - w.y) > 1e-3));
promotions.push({ uid: e.uid, id: e.piece.id, area: e.area, row: e.row ?? null, col: e.col ?? null, idx: e.idx ?? null, copies: promoFrom.get(e.uid).length });
if (promotions.length > 20) promotions.shift();
} else if (e.area === 'board' && prevBoard.size && !prevBoard.has(e.uid)) { v.onDeploy?.(); fx.deploy(v); }
} else { } else {
const prevHome = v._home; const prevHome = v._home;
const moved = !prevHome || prevHome.x !== w.x || prevHome.y !== w.y || prevHome.z !== w.z; const moved = !prevHome || prevHome.x !== w.x || prevHome.y !== w.y || prevHome.z !== w.z;
@@ -1226,6 +1254,7 @@ export async function createFieldView(host, options = {}) {
drag.reset(); drag.reset();
endDragVisual(false); endDragVisual(false);
clearViews(); clearViews();
if (prepPieces.length) promoBase = prepPieces;
prepPieces = []; prepPieces = [];
infos.clear(); infos.clear();
gone.clear(); gone.clear();
@@ -1783,6 +1812,7 @@ export async function createFieldView(host, options = {}) {
/** Dev hooks (demo / tests). */ /** Dev hooks (demo / tests). */
debug: { debug: {
app, get cam() { return cam; }, get board3d() { return board3d; }, tiles, views, penViews, interp, fx, ctx, drag, get camKind() { return viewKind(camKind, camOpts); }, app, get cam() { return cam; }, get board3d() { return board3d; }, tiles, views, penViews, interp, fx, ctx, drag, get camKind() { return viewKind(camKind, camOpts); },
promotions,
// picking (render/pick.js) at canvas px: the prep piece / battle view / pen view there, the ground tile under it // picking (render/pick.js) at canvas px: the prep piece / battle view / pen view there, the ground tile under it
pick: { pieceAt, battleUnitAt, penUnitAt, groundTile }, pick: { pieceAt, battleUnitAt, penUnitAt, groundTile },
}, },
+30 -3
View File
@@ -212,6 +212,9 @@ export const FX_KINDS = Object.freeze({
thorns: { a: 'counter', c: 0xff9aa6 }, downed: { a: 'down', c: 0xbfeee2 }, stone: { a: 'down', c: 0xc0b8a8, smoke: 0x5a5448 }, thorns: { a: 'counter', c: 0xff9aa6 }, downed: { a: 'down', c: 0xbfeee2 }, stone: { a: 'down', c: 0xc0b8a8, smoke: 0x5a5448 },
dp: { a: 'dp', c: 0x9fd4ff }, coin: { a: 'coin', c: 0xffc600 }, steal: { a: 'coin', c: 0xffc600 }, crateBreak: { a: 'crate', c: 0xc89a5a }, dp: { a: 'dp', c: 0x9fd4ff }, coin: { a: 'coin', c: 0xffc600 }, steal: { a: 'coin', c: 0xffc600 }, crateBreak: { a: 'crate', c: 0xc89a5a },
lpLoss: { a: 'lp', c: 0xff3b30 }, lpLoss: { a: 'lp', c: 0xff3b30 },
// 限伤 (sim/damage.js leaderHitCancelled): a leader's hit of ≥ 300000 dealt nothing — the official shows no number and
// no effect [ASSUMED], so nothing is drawn (`a: 'none'`)
hitCap: { a: 'none', c: 0xffffff },
// beams // beams
beam: { a: 'beam', c: 0xff7a5a }, link: { a: 'beam', c: 0x9ff0dc }, lightning: { a: 'bolt', c: 0xc9a2ff }, tentacle: { a: 'beam', c: 0x5fe0ff }, beam: { a: 'beam', c: 0xff7a5a }, link: { a: 'beam', c: 0x9ff0dc }, lightning: { a: 'bolt', c: 0xc9a2ff }, tentacle: { a: 'beam', c: 0x5fe0ff },
sandChains: { a: 'beam', c: 0xd8c8a0 }, sandChainsCharged: { a: 'beam', c: 0xffd45a }, sandChains: { a: 'beam', c: 0xd8c8a0 }, sandChainsCharged: { a: 'beam', c: 0xffd45a },
@@ -1578,6 +1581,29 @@ export class FxSystem {
this.burst(p.x, p.y, s, 6, col, { speed: 1.6, up: 0.4, life: 0.4, tex: 'dot' }); this.burst(p.x, p.y, s, 6, col, { speed: 1.6, up: 0.4, life: 0.4, tex: 'dot' });
} }
/**
* Promotion (精锐晋升, a merge) at the elite's spot — the board tile of the consumed copy it replaced (PRTS 卫戍协议/
* 帮助 "若消耗已部署至作战区的干员,则发送至作战区对应位置") or its bench slot: a gold pillar with a white core, a shockwave
* and a hex ring on the ground, rising motes; `from` = world points of the other consumed copies (gold streaks from
* them to the elite). Counted in `promotions` (render/app.js setPrep; tests).
* @param {any} view
* @param {Array<{ x: number, y: number, z?: number }>} [from]
*/
promote(view, from = []) {
if (!view) return;
this.promotions = (this.promotions || 0) + 1;
const z = view.z || 0;
const g = this._proj(view.x, view.y, z, this._g);
const gx = g.x, gy = g.y, s = g.s;
for (const f of from) if (f && Number.isFinite(f.x) && Number.isFinite(f.y)) this.streak(f.x, f.y, view.x, view.y, Math.max(z, f.z || 0) + 0.3, SKILL_GOLD, 0.45);
this.particle('pillar', gx, gy, { tint: SKILL_GOLD, life: 0.8, s0: (s / 64) * 1.1, s1: (s / 64) * 1.4, a0: 0.95, a1: 0, sx: 0.85, ay: 1 });
this.particle('pillar', gx, gy, { tint: 0xffffff, life: 0.42, s0: (s / 64) * 0.9, s1: (s / 64) * 1.2, a0: 0.9, a1: 0, sx: 0.3, ay: 1 });
this.ring(view.x, view.y, z, 0.15, 1.6, 0xffe7a0, 0.5, 'shock');
this.ring(view.x, view.y, z, 0.3, 1.2, SKILL_GOLD, 0.7, 'hex');
const c = this._chest(view, this._q);
this.burst(c.x, c.y, s, this.rich ? 12 : 5, 0xffe28a, { speed: 1.5, up: 1.8, life: 0.8, tex: 'dot', size: 0.36 });
}
death(view) { death(view) {
if (!view) return; if (!view) return;
const p = this._chest(view); const p = this._chest(view);
@@ -1637,14 +1663,15 @@ export class FxSystem {
/** /**
* b.ev 'fx': every kind the sim / content emits has a visual (FX_KINDS archetypes: blast, shell, zone, telegraph, * b.ev 'fx': every kind the sim / content emits has a visual (FX_KINDS archetypes: blast, shell, zone, telegraph,
* heal, sp, shield, shatter, summon, vanish, blink, move, wave, mark, reticle, buff, lift, sleep, crit, dodge, * heal, sp, shield, shatter, summon, vanish, blink, move, wave, mark, reticle, buff, lift, sleep, crit, dodge,
* counter, dp, coin, crate, down, beam, bolt, strike, volley, pillar, lp, chill, element, flame); unknown kinds get a * counter, dp, coin, crate, down, beam, bolt, strike, volley, pillar, lp, chill, element, flame), except kinds whose
* generic sparkle. `extra` keys used: id (anchor unit — or the shooter of a `pt` kind), r | radius, dur | duration, * archetype is 'none' (hitCap: a leader hit cancelled by 限伤 draws nothing); unknown kinds get a generic sparkle. `extra` keys used: id (anchor unit — or the shooter of a `pt` kind), r | radius, dur | duration,
* t (shell flight, game s), src / from / to / target / targets (unit ids), fx, fy / fromX, fromY / tx, ty (positions), * t (shell flight, game s), src / from / to / target / targets (unit ids), fx, fy / fromX, fromY / tx, ty (positions),
* element, n, scale, kind, tiles. * element, n, scale, kind, tiles.
*/ */
simFx(kind, x, y, extra) { simFx(kind, x, y, extra) {
const ex = extra && typeof extra === 'object' ? extra : {}; const ex = extra && typeof extra === 'object' ? extra : {};
const spec = fxSpec(kind, ex); const spec = fxSpec(kind, ex);
if (spec.a === 'none') return; // an event the screen does not show (hitCap)
const at = spec.pt ? this._point(Number(x), Number(y)) : this._where(Number(x), Number(y), ex); const at = spec.pt ? this._point(Number(x), Number(y)) : this._where(Number(x), Number(y), ex);
if (!Number.isFinite(at.x) || !Number.isFinite(at.y)) return; if (!Number.isFinite(at.x) || !Number.isFinite(at.y)) return;
// 蕾缪安 S2: the aimed snipe ('crit' on the locked enemy, from her) ends that aim lock // 蕾缪安 S2: the aimed snipe ('crit' on the locked enemy, from her) ends that aim lock
@@ -2174,7 +2201,7 @@ export class FxSystem {
} }
get counts() { get counts() {
return { particles: this.parts.length, projectiles: this.projs.length, numbers: this.nums.length, rings: this.rings.length, auras: this.auras.size, locks: this.locks.length, flames: this.flames.length }; return { particles: this.parts.length, projectiles: this.projs.length, numbers: this.nums.length, rings: this.rings.length, auras: this.auras.size, locks: this.locks.length, flames: this.flames.length, promotions: this.promotions || 0 };
} }
destroy() { destroy() {
+34
View File
@@ -0,0 +1,34 @@
// public/js/render/promote.js — which prep pieces were just promoted (精锐晋升), for the merge cue of render/app.js setPrep.
//
// A merge replaces the consumed normal copies by ONE new elite piece in the same m.private update: on the board tile of
// the consumed copy that deploys first when a copy was deployed (PRTS 卫戍协议/帮助 "若消耗已部署至作战区的干员,则发送至
// 作战区对应位置"; server board.js mergeTile), else in the hand / temp. So a promotion = a chess piece with a new uid that is
// an elite, while normal copies of the same operator (same baseId) vanished from the previous state. The cue then plays
// on the elite (fx.promote: gold pillar + rings, streaks from the vanished copies) instead of the plain deploy flash.
/**
* @param {Array<{ uid: number, area: string, piece: any }>} prev entries of the previous setPrep (render/app.js prepPieces)
* @param {Array<{ uid: number, area: string, piece: any }>} next entries of this setPrep
* @param {(id: string) => any} chessOf chess record lookup (`baseId`, `isGolden`)
* @returns {Map<number, Array<{ uid: number, area: string, piece: any }>>} new elite uid → the vanished copies (prev entries)
*/
export function promotionsOf(prev, next, chessOf) {
const out = new Map();
if (!Array.isArray(prev) || !prev.length || !Array.isArray(next)) return out;
const before = new Set(prev.map((e) => e.uid));
const now = new Set(next.map((e) => e.uid));
const baseOf = (id) => chessOf(id)?.baseId || id;
const isElite = (p) => !!(p?.golden || chessOf(p?.id)?.isGolden);
const gone = prev.filter((e) => e.piece?.kind === 'chess' && !now.has(e.uid) && !isElite(e.piece));
if (!gone.length) return out;
const used = new Set();
for (const e of next) {
if (e.piece?.kind !== 'chess' || before.has(e.uid) || !isElite(e.piece)) continue;
const base = baseOf(e.piece.id);
const copies = gone.filter((g) => !used.has(g.uid) && baseOf(g.piece.id) === base);
if (!copies.length) continue;
for (const g of copies) used.add(g.uid);
out.set(e.uid, copies);
}
return out;
}
+31 -6
View File
@@ -44,6 +44,10 @@
// User playtest #4: the detail card shows live stats (item 7) — in battle the local sim's unit (battle/runner.js // User playtest #4: the detail card shows live stats (item 7) — in battle the local sim's unit (battle/runner.js
// unitStats), in prep an own board unit's start-of-battle stats (g.unitStats → m.unitStats); 机变 cards take two taps // unitStats), in prep an own board unit's start-of-battle stats (g.unitStats → m.unitStats); 机变 cards take two taps
// (item 2, ui/choiceOverlay.js). // (item 2, ui/choiceOverlay.js).
// Merges (user playtest #6 follow-up, PRTS 卫戍协议/帮助 "若消耗已部署至作战区的干员,则发送至作战区对应位置"): a shop /
// reward card armed for a purchase that completes a merge lights, in gold, the board tile its elite will take (the
// deployed copy that deploys first, gameLogic.mergeTarget — none when no copy is deployed: the elite goes to the hand);
// the elite then appears there with the promotion cue (render/app.js setPrep → fx.promote).
import { useCallback, useEffect, useMemo, useRef, useState } from '../../vendor/hooks.module.js'; import { useCallback, useEffect, useMemo, useRef, useState } from '../../vendor/hooks.module.js';
import { PHASE, GEO } from '../../../shared/constants.js'; import { PHASE, GEO } from '../../../shared/constants.js';
@@ -76,7 +80,7 @@ import {
phaseMode, phaseBanner, isCombatPhase, showDeadPill, isBossPhase, placementContext, canPlace, boardTargets, dropIntent, phaseMode, phaseBanner, isCombatPhase, showDeadPill, isBossPhase, placementContext, canPlace, boardTargets, dropIntent,
snapHud, activeBubbles, shortcutFor, shortcutBlocked, closesOnFieldPress, phaseTotalSeconds, homeFieldId, ownFieldId, normalizeSp, sortedPlayers, snapHud, activeBubbles, shortcutFor, shortcutBlocked, closesOnFieldPress, phaseTotalSeconds, homeFieldId, ownFieldId, normalizeSp, sortedPlayers,
countdownState, shopBlockReason, stageOverrides, effectiveStage, watchTarget, dropFailureReason, countdownState, shopBlockReason, stageOverrides, effectiveStage, watchTarget, dropFailureReason,
previewEnemyKey, prepCamera, deployFieldOf, panelSide, panelSlots, bondPopupPlace, chessLoadout, unitLoadout, previewEnemyKey, prepCamera, deployFieldOf, panelSide, panelSlots, bondPopupPlace, chessLoadout, unitLoadout, mergeTarget,
} from '../ui/gameLogic.js'; } from '../ui/gameLogic.js';
import { toast } from '../ui/toasts.js'; import { toast } from '../ui/toasts.js';
import { BriefingScreen } from './briefing.js'; import { BriefingScreen } from './briefing.js';
@@ -94,6 +98,8 @@ const cx = (...p) => p.flat().filter(Boolean).join(' ');
const HUD_HZ_MS = 200; const HUD_HZ_MS = 200;
/** Range tiles of the selected unit (its own highlight group: the wheel's 'facing' group may be up at the same time). */ /** Range tiles of the selected unit (its own highlight group: the wheel's 'facing' group may be up at the same time). */
const SEL_RANGE = Object.freeze({ group: 'selRange', color: 0xff9c33, fill: 0.3, line: 0.95 }); const SEL_RANGE = Object.freeze({ group: 'selRange', color: 0xff9c33, fill: 0.3, line: 0.95 });
/** The tile an armed merge-completing card's elite will take (its own group; gold like the promotion cue, render/fx.js). */
const MERGE_HL = Object.freeze({ group: 'mergeTile', color: 0xffd45a, fill: 0.34, line: 1 });
const STATE_EV = new Set(['spawn', 'die', 'deploy', 'status', 'skill']); const STATE_EV = new Set(['spawn', 'die', 'deploy', 'status', 'skill']);
/** Router for the in-match screens. */ /** Router for the in-match screens. */
@@ -192,6 +198,7 @@ function MatchScreen() {
const [camKind, setCamKind] = useState('prep'); // kind of the last camera request (data-camera) const [camKind, setCamKind] = useState('prep'); // kind of the last camera request (data-camera)
const [replace, setReplace] = useState(null); // equip-replace dialog: { request, resolve } const [replace, setReplace] = useState(null); // equip-replace dialog: { request, resolve }
const [pauseBusy, setPauseBusy] = useState(false); const [pauseBusy, setPauseBusy] = useState(false);
const [armedCard, setArmedCard] = useState(null); // the shop bar's armed card { kind, id } (merge tile cue)
const cc = isClientCombat(pub); const cc = isClientCombat(pub);
const battleState = useStore((s) => s.match.battle, shallowEqual); // local battle runner (client-side combat) const battleState = useStore((s) => s.match.battle, shallowEqual); // local battle runner (client-side combat)
@@ -518,6 +525,14 @@ function MatchScreen() {
// promotion (merge reward offered) and bond activation cues // promotion (merge reward offered) and bond activation cues
const hasOffer = !!priv?.shop?.rewardOffer; const hasOffer = !!priv?.shop?.rewardOffer;
useEffect(() => { if (hasOffer) audio.sfx('merge'); }, [hasOffer]); useEffect(() => { if (hasOffer) audio.sfx('merge'); }, [hasOffer]);
// a shop / reward card armed for a purchase that completes a merge lights the tile its elite will take (the deployed
// copy that deploys first — PRTS 卫戍协议/帮助 "若消耗已部署至作战区的干员,则发送至作战区对应位置", gameLogic.mergeTarget)
const mergeAt = armedCard?.kind === 'chess' && editable && showPrep ? mergeTarget(priv, armedCard.id, gd.chess) : null;
const mergeAtKey = mergeAt ? `${mergeAt.row},${mergeAt.col}` : '';
useEffect(() => {
if (!view) return;
try { view.highlightTiles(mergeAt ? [[mergeAt.row, mergeAt.col]] : [], MERGE_HL); } catch { /* cosmetic */ }
}, [view, mergeAtKey]);
const activeBonds = (priv?.bonds || []).filter((b) => b && b.active).length; const activeBonds = (priv?.bonds || []).filter((b) => b && b.active).length;
const prevActive = useRef(activeBonds); const prevActive = useRef(activeBonds);
useEffect(() => { useEffect(() => {
@@ -842,6 +857,9 @@ function MatchScreen() {
// panel anyway (css z-index), this keeps it visible too // panel anyway (css z-index), this keeps it visible too
const ufTile = selEntry && editable && showPrep ? pieceTile(selEntry) : null; const ufTile = selEntry && editable && showPrep ? pieceTile(selEntry) : null;
const ufGeo = useTileScreen(view, ufTile ? ufTile.row : null, ufTile ? ufTile.col : null); const ufGeo = useTileScreen(view, ufTile ? ufTile.row : null, ufTile ? ufTile.col : null);
// the gold tile of an armed merge card (mergeAt): the detail card the first tap opened docks away from it (QA 6b — at
// 1920×1080 the left card covered half of a target tile in the leftmost legal column)
const mergeGeo = useTileScreen(view, mergeAt ? mergeAt.row : null, mergeAt ? mergeAt.col : null);
const retreatSel = useCallback(async () => { const retreatSel = useCallback(async () => {
const L = live.current; const L = live.current;
const uid = L.sel?.uid; const uid = L.sel?.uid;
@@ -1003,9 +1021,16 @@ function MatchScreen() {
return { rem, vp }; return { rem, vp };
}; };
const dSide = (() => { const dSide = (() => {
if (!ufShown) return 'left'; if (ufShown) {
const { rem, vp } = panelFrame(); const { rem, vp } = panelFrame();
return panelSide(underframeRect(ufGeo, rem), panelSlots(vp, rem, { shopOpen })); return panelSide(underframeRect(ufGeo, rem), panelSlots(vp, rem, { shopOpen }));
}
if (mergeGeo && resolved) {
const { rem, vp } = panelFrame();
const half = (mergeGeo.s > 0 ? mergeGeo.s : 64) * 1.05; // the tile diamond's box (underframeRect's)
return panelSide({ left: mergeGeo.x - half, right: mergeGeo.x + half, top: mergeGeo.y - half, bottom: mergeGeo.y + half }, panelSlots(vp, rem, { shopOpen }));
}
return 'left';
})(); })();
// the bond popup: next to the card, never over the selected unit's underframe when a place is free (bondPopupPlace) // the bond popup: next to the card, never over the selected unit's underframe when a place is free (bondPopupPlace)
const bpPlace = (() => { const bpPlace = (() => {
@@ -1062,11 +1087,11 @@ function MatchScreen() {
${showShop ? html`<${ShopBar} priv=${priv} editable=${editable} collapsed=${collapsed} onCollapse=${setCollapsed} ${showShop ? html`<${ShopBar} priv=${priv} editable=${editable} collapsed=${collapsed} onCollapse=${setCollapsed}
barRef=${barRef} barRef=${barRef}
onBuy=${buy} onLevel=${() => actions.levelUp()} onRefresh=${() => actions.refresh()} onFreeze=${() => actions.freeze()} onBuy=${buy} onLevel=${() => actions.levelUp()} onRefresh=${() => actions.refresh()} onFreeze=${() => actions.freeze()}
onDetail=${(id, kind) => setDetail({ kind: kind === 'item' ? 'item' : 'chess', id })} onDetail=${(id, kind, hint) => setDetail({ kind: kind === 'item' ? 'item' : 'chess', id, hint: hint || null })}
onDetailClose=${() => setDetail((d) => (d?.kind === 'chess' || d?.kind === 'item' ? null : d))} onDetailClose=${() => setDetail((d) => (d?.kind === 'chess' || d?.kind === 'item' ? null : d))}
onRefuse=${(reason) => { toast(reason, 'warn'); audio.sfx('error', { volume: 0.5 }); }} onRefuse=${(reason) => { toast(reason, 'warn'); audio.sfx('error', { volume: 0.5 }); }}
reward=${phase === PHASE.PREP && !rewardMin ? priv?.shop?.rewardOffer || null : null} reward=${phase === PHASE.PREP && !rewardMin ? priv?.shop?.rewardOffer || null : null}
onReward=${(i) => actions.reward(i)} onRewardLater=${() => setRewardMin(true)} />` : null} onReward=${(i) => actions.reward(i)} onRewardLater=${() => setRewardMin(true)} onArm=${setArmedCard} />` : null}
${phase === PHASE.PREP && priv?.shop?.rewardOffer ? html`<${RewardOverlay} priv=${priv} minimized=${rewardMin || collapsed} ${phase === PHASE.PREP && priv?.shop?.rewardOffer ? html`<${RewardOverlay} priv=${priv} minimized=${rewardMin || collapsed}
onMinimize=${(m) => { setRewardMin(m); if (!m) setCollapsed(false); }} />` : null} onMinimize=${(m) => { setRewardMin(m); if (!m) setCollapsed(false); }} />` : null}
+7 -4
View File
@@ -204,7 +204,7 @@ function GarrisonBlock({ garrison, m }) {
</section>`; </section>`;
} }
export function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bonds, loadout, onBond, live = null }) { export function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bonds, loadout, onBond, live = null, hint = null }) {
const m = data.get('assets'); const m = data.get('assets');
const hp = hpOf(live, snapHp); const hp = hpOf(live, snapHp);
const lo = chessLoadout(chess, loadout, (id) => data.lookup('chess', id)); const lo = chessLoadout(chess, loadout, (id) => data.lookup('chess', id));
@@ -304,7 +304,10 @@ export function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bond
blocks.actions = piece && editable && piece.kind !== 'item' ? html`<div key="actions" class="dactions"> blocks.actions = piece && editable && piece.kind !== 'item' ? html`<div key="actions" class="dactions">
<${Button} variant="amber" icon="close" class="dpanel__sell" onClick=${() => onSell(piece, c)}>出售<span class="dsell num">+${sell}</span><//> <${Button} variant="amber" icon="close" class="dpanel__sell" onClick=${() => onSell(piece, c)}>出售<span class="dsell num">+${sell}</span><//>
</div>` : null; </div>` : null;
return CHESS_SECTIONS.map((k) => blocks[k]).filter(Boolean); const out = CHESS_SECTIONS.map((k) => blocks[k]).filter(Boolean);
// a merge-completing shop / reward card: where the elite goes (shopBar mergeHint), right under the header
if (hint) out.splice(1, 0, html`<p key="merge" class="dhint dhint--merge"><${Icon} name="info" />可晋升:${hint}</p>`);
return out;
} }
/** /**
@@ -467,7 +470,7 @@ export function resolveDetail(target, pieces) {
const c = data.lookup('chess', p.id); const c = data.lookup('chess', p.id);
return c ? { type: 'chess', chess: c, piece: p } : null; return c ? { type: 'chess', chess: c, piece: p } : null;
} }
if (target.kind === 'chess') { const c = data.lookup('chess', target.id); return c ? { type: 'chess', chess: c } : null; } if (target.kind === 'chess') { const c = data.lookup('chess', target.id); return c ? { type: 'chess', chess: c, hint: target.hint || null } : null; }
if (target.kind === 'item') { const it = data.lookup('items', target.id); return it ? { type: 'item', item: it } : null; } if (target.kind === 'item') { const it = data.lookup('items', target.id); return it ? { type: 'item', item: it } : null; }
if (target.kind === 'enemy') { const en = data.lookup('enemies', target.id); return en ? { type: 'enemy', enemy: en, count: target.count } : null; } if (target.kind === 'enemy') { const en = data.lookup('enemies', target.id); return en ? { type: 'enemy', enemy: en, count: target.count } : null; }
if (target.kind === 'token') { const t = data.lookup('tokens', target.id); return t ? { type: 'token', token: t } : null; } if (target.kind === 'token') { const t = data.lookup('tokens', target.id); return t ? { type: 'token', token: t } : null; }
@@ -518,7 +521,7 @@ export function DetailPanel({ detail, editable, snapHp, onClose, onSell, onDestr
<button type="button" class="dpanel__close" aria-label="关闭" onClick=${onClose}><${Icon} name="close" /></button> <button type="button" class="dpanel__close" aria-label="关闭" onClick=${onClose}><${Icon} name="close" /></button>
<div class="dpanel__scroll"> <div class="dpanel__scroll">
${detail.type === 'chess' ? html`<${ChessDetail} chess=${detail.chess} piece=${detail.piece} snapHp=${snapHp} editable=${editable} onSell=${sellIt} ${detail.type === 'chess' ? html`<${ChessDetail} chess=${detail.chess} piece=${detail.piece} snapHp=${snapHp} editable=${editable} onSell=${sellIt}
bonds=${bonds} loadout=${loadout} onBond=${onBond} live=${liveNow} />` : null} bonds=${bonds} loadout=${loadout} onBond=${onBond} live=${liveNow} hint=${detail.hint || null} />` : null}
${detail.type === 'item' ? html`<${ItemDetail} item=${detail.item} piece=${detail.piece} editable=${editable} onDestroy=${destroyIt} />` : null} ${detail.type === 'item' ? html`<${ItemDetail} item=${detail.item} piece=${detail.piece} editable=${editable} onDestroy=${destroyIt} />` : null}
${detail.type === 'enemy' ? html`<${EnemyDetail} enemy=${detail.enemy} snapHp=${snapHp} count=${detail.count} live=${liveNow} />` : null} ${detail.type === 'enemy' ? html`<${EnemyDetail} enemy=${detail.enemy} snapHp=${snapHp} count=${detail.count} live=${liveNow} />` : null}
${detail.type === 'token' ? html`<${TokenDetail} token=${detail.token} piece=${detail.piece} ownerId=${detail.ownerId ?? null} snapHp=${snapHp} live=${liveNow} />` : null} ${detail.type === 'token' ? html`<${TokenDetail} token=${detail.token} piece=${detail.piece} ownerId=${detail.ownerId ?? null} snapHp=${snapHp} live=${liveNow} />` : null}
+27 -1
View File
@@ -458,7 +458,9 @@ export function priceTone(slot) {
} }
/** /**
* Owned copies of a base chess (normal pieces on board/hand/temp) — shop cards show merge progress. * Owned copies of a base chess (normal pieces on board/hand/temp) — shop cards show merge progress. An elite card
* never merges (server PlayerState.completesChessMerge refuses isGolden), so it reports 0 copies: no pips, no 可晋升
* tag and no mergeTarget tile.
* @param {any} priv * @param {any} priv
* @param {string} chessId * @param {string} chessId
* @param {(id:string)=>any} [getChess] * @param {(id:string)=>any} [getChess]
@@ -468,12 +470,36 @@ export function mergeProgress(priv, chessId, getChess = () => null) {
const c = getChess(chessId); const c = getChess(chessId);
const base = c?.baseId || chessId; const base = c?.baseId || chessId;
const need = Number.isInteger(c?.upgradeNum) && c.upgradeNum > 0 ? c.upgradeNum : 3; const need = Number.isInteger(c?.upgradeNum) && c.upgradeNum > 0 ? c.upgradeNum : 3;
if (c?.isGolden) return { copies: 0, need };
let copies = 0; let copies = 0;
const pieces = [...(Array.isArray(priv?.board) ? priv.board : []), ...(Array.isArray(priv?.hand) ? priv.hand : []), ...(Array.isArray(priv?.temp) ? priv.temp : [])]; const pieces = [...(Array.isArray(priv?.board) ? priv.board : []), ...(Array.isArray(priv?.hand) ? priv.hand : []), ...(Array.isArray(priv?.temp) ? priv.temp : [])];
for (const p of pieces) if (p?.kind === 'chess' && !p.golden && (p.id === base || getChess(p.id)?.baseId === base) && !String(p.id).endsWith('_b')) copies += 1; for (const p of pieces) if (p?.kind === 'chess' && !p.golden && (p.id === base || getChess(p.id)?.baseId === base) && !String(p.id).endsWith('_b')) copies += 1;
return { copies, need }; return { copies, need };
} }
/**
* Where the elite appears when gaining one more normal copy of `chessId` completes a merge now (PRTS 卫戍协议/帮助
* "若消耗已部署至作战区的干员,则发送至作战区对应位置"; mirror of server board.js mergeTile / PlayerState._mergeChess): the
* board tile of the deployed copy that deploys first (row desc, then col asc) — `{ row, col, dir }` — or null (no
* merge — an elite card never merges, see mergeProgress — or no copy is deployed: the elite goes to the hand). The
* copies stand on legal tiles, and the elite is the same operator, so the tile needs no legality check here.
* @param {any} priv
* @param {string} chessId
* @param {(id:string)=>any} [getChess]
* @returns {{ row: number, col: number, dir: string } | null}
*/
export function mergeTarget(priv, chessId, getChess = () => null) {
const { copies, need } = mergeProgress(priv, chessId, getChess);
if (!(copies > 0 && copies + 1 >= need)) return null;
const c = getChess(chessId);
const base = c?.baseId || chessId;
const board = (Array.isArray(priv?.board) ? priv.board : []).filter((p) => p?.kind === 'chess' && !p.golden && Number.isInteger(p.row) && Number.isInteger(p.col)
&& !String(p.id).endsWith('_b') && (p.id === base || getChess(p.id)?.baseId === base));
if (!board.length) return null;
board.sort((a, b) => b.row - a.row || a.col - b.col);
return { row: board[0].row, col: board[0].col, dir: board[0].dir || 'RIGHT' };
}
/** Whether every hand slot is taken (the server refuses a purchase / reward that needs a slot: HAND_FULL). */ /** Whether every hand slot is taken (the server refuses a purchase / reward that needs a slot: HAND_FULL). */
export function handFull(priv) { export function handFull(priv) {
const hand = Array.isArray(priv?.hand) ? priv.hand : []; const hand = Array.isArray(priv?.hand) ? priv.hand : [];
+39 -8
View File
@@ -19,7 +19,7 @@
import { useEffect, useState } from '../../vendor/hooks.module.js'; import { useEffect, useState } from '../../vendor/hooks.module.js';
import { html, Icon, HexBadge, TierChip, Tooltip, MicroLabel } from './components.js'; import { html, Icon, HexBadge, TierChip, Tooltip, MicroLabel } from './components.js';
import { Img, BondGlyph, CoinGlyph, GIcon, RichText } from './gameComponents.js'; import { Img, BondGlyph, CoinGlyph, GIcon, RichText } from './gameComponents.js';
import { priceTone, mergeProgress, shopBlockReason, chessLoadout } from './gameLogic.js'; import { priceTone, mergeProgress, mergeTarget, shopBlockReason, chessLoadout } from './gameLogic.js';
import { chessPortraitUrl, itemIconUrl, profIconUrl, uiUrl, skillIconUrl, skillRecordIconUrl, moduleTypeIconUrl } from './assetUrls.js'; import { chessPortraitUrl, itemIconUrl, profIconUrl, uiUrl, skillIconUrl, skillRecordIconUrl, moduleTypeIconUrl } from './assetUrls.js';
import { data } from '../data.js'; import { data } from '../data.js';
@@ -37,6 +37,16 @@ function PriceHex({ slot, free, poor = false }) {
/** Data lookups for shopBlockReason (full-hand purchases that complete a merge stay allowed). */ /** Data lookups for shopBlockReason (full-hand purchases that complete a merge stay allowed). */
const LOOKUPS = { getChess: (id) => data.lookup('chess', id), getItem: (id) => data.lookup('items', id) }; const LOOKUPS = { getChess: (id) => data.lookup('chess', id), getItem: (id) => data.lookup('items', id) };
/**
* Where a merge-completing buy sends the elite (DESIGN §20.11), null when the card completes no merge: the 可晋升 tag's
* title, and a line of the detail card the first tap opens — a touch screen never shows a title (QA 6b).
*/
export function mergeHint(priv, chessId) {
const prog = mergeProgress(priv, chessId, LOOKUPS.getChess);
if (!(prog.copies > 0 && prog.copies + 1 >= prog.need)) return null;
return mergeTarget(priv, chessId, LOOKUPS.getChess) ? '精锐干员将出现在作战区原位置' : '精锐干员将进入整备区';
}
/** The armed (first-tapped) card's confirm strip: 确认购买 / 确认选择, or 无法购买 + why. */ /** The armed (first-tapped) card's confirm strip: 确认购买 / 确认选择, or 无法购买 + why. */
function ArmedTag({ reason, free }) { function ArmedTag({ reason, free }) {
if (reason) return html`<span class="scard__confirm is-no" role="status"><b>无法购买</b><small>${reason}</small></span>`; if (reason) return html`<span class="scard__confirm is-no" role="status"><b>无法购买</b><small>${reason}</small></span>`;
@@ -53,13 +63,14 @@ export function ChessCard({ slot, idx, priv, frozen = false, reason = null, free
const m = data.get('assets'); const m = data.get('assets');
const tier = c?.tier ?? 1; const tier = c?.tier ?? 1;
const prog = mergeProgress(priv, slot.id, (id) => data.lookup('chess', id)); const prog = mergeProgress(priv, slot.id, (id) => data.lookup('chess', id));
const willMerge = prog.copies + 1 >= prog.need && prog.copies > 0; const hint = mergeHint(priv, slot.id);
const willMerge = !!hint;
const bonds = Array.isArray(c?.bonds) ? c.bonds : []; const bonds = Array.isArray(c?.bonds) ? c.bonds : [];
const disabled = !!reason; const disabled = !!reason;
const lo = c ? chessLoadout(c, priv?.loadout, LOOKUPS.getChess) : null; const lo = c ? chessLoadout(c, priv?.loadout, LOOKUPS.getChess) : null;
const tap = () => { if (onTap) onTap(idx); else if (!disabled) onBuy(idx); else onDetail(slot.id); }; const tap = () => { if (onTap) onTap(idx); else if (!disabled) onBuy(idx); else onDetail(slot.id, 'chess', hint); };
const card = html`<button type="button" class=${cx('scard', `scard--t${tier}`, frozen && 'is-frozen', disabled && 'is-disabled', willMerge && 'is-merge', armed && 'is-armed')} const card = html`<button type="button" class=${cx('scard', `scard--t${tier}`, frozen && 'is-frozen', disabled && 'is-disabled', willMerge && 'is-merge', armed && 'is-armed')}
onClick=${tap} onContextMenu=${(e) => { e.preventDefault(); onDetail(slot.id); }} onClick=${tap} onContextMenu=${(e) => { e.preventDefault(); onDetail(slot.id, 'chess', hint); }}
aria-label=${`${c?.name || '干员'},价格 ${slot.price}${armed ? (disabled ? ',无法购买' : ',再次点击确认') : ''}`} aria-pressed=${onTap ? String(!!armed) : undefined}> aria-label=${`${c?.name || '干员'},价格 ${slot.price}${armed ? (disabled ? ',无法购买' : ',再次点击确认') : ''}`} aria-pressed=${onTap ? String(!!armed) : undefined}>
<span class="scard__bg" aria-hidden="true"></span> <span class="scard__bg" aria-hidden="true"></span>
<span class="scard__water" aria-hidden="true">${bonds[0] ? html`<${BondGlyph} bondId=${bonds[0]} />` : null}</span> <span class="scard__water" aria-hidden="true">${bonds[0] ? html`<${BondGlyph} bondId=${bonds[0]} />` : null}</span>
@@ -82,7 +93,7 @@ export function ChessCard({ slot, idx, priv, frozen = false, reason = null, free
<span>${c?.subProfessionName || ''}</span> <span>${c?.subProfessionName || ''}</span>
</span> </span>
</span> </span>
${willMerge ? html`<span class="scard__mergetag">可晋升</span>` : null} ${willMerge ? html`<span class="scard__mergetag" title=${hint}>可晋升</span>` : null}
${frozen ? html`<span class="scard__ice" aria-hidden="true"><${Icon} name="snow" /></span>` : null} ${frozen ? html`<span class="scard__ice" aria-hidden="true"><${Icon} name="snow" /></span>` : null}
${armed ? html`<${ArmedTag} reason=${reason} free=${free} />` : null} ${armed ? html`<${ArmedTag} reason=${reason} free=${free} />` : null}
</button>`; </button>`;
@@ -160,6 +171,20 @@ function LevelCard({ shop, reason, armed = false, onTap }) {
/** Key of an armed shop card: kind + slot index + the id it showed (a reroll / sale changes it ⇒ disarm). */ /** Key of an armed shop card: kind + slot index + the id it showed (a reroll / sale changes it ⇒ disarm). */
export const armKey = (kind, idx, slot) => `${kind}:${idx}:${slot?.id ?? ''}`; export const armKey = (kind, idx, slot) => `${kind}:${idx}:${slot?.id ?? ''}`;
/**
* The slot an armed key names (`c` / `i` shop slots, `r` reward slots), or null ('lv', nothing armed, a stale key).
* @param {string|null} key
* @param {any[]} slots the shop's slots
* @param {any[]|null} rewardSlots the shown reward offer's slots
*/
export function armedSlotOf(key, slots, rewardSlots = null) {
const m = typeof key === 'string' ? /^([cir]):(\d+):(.*)$/.exec(key) : null;
if (!m) return null;
const list = m[1] === 'r' ? rewardSlots : slots;
const s = Array.isArray(list) ? list[Number(m[2])] : null;
return s && !s.sold && s.id === m[3] ? s : null;
}
/** /**
* Two-tap state of the bar (first tap arms, second confirms). Disarms on a tap outside the shop / detail panel, on * Two-tap state of the bar (first tap arms, second confirms). Disarms on a tap outside the shop / detail panel, on
* Escape, when the armed card changes or disappears, and when the bar stops being editable. * Escape, when the armed card changes or disappears, and when the bar stops being editable.
@@ -210,12 +235,12 @@ function RewardCards({ offer, priv, editable, onPick, onDetail, onLater, armed,
/** /**
* The bar. * The bar.
* @param {{ priv:any, editable:boolean, collapsed:boolean, onCollapse:(c:boolean)=>void, * @param {{ priv:any, editable:boolean, collapsed:boolean, onCollapse:(c:boolean)=>void,
* onBuy:(i:number)=>void, onLevel:Function, onRefresh:Function, onFreeze:Function, onDetail:(id:string, kind?:string)=>void, * onBuy:(i:number)=>void, onLevel:Function, onRefresh:Function, onFreeze:Function, onDetail:(id:string, kind?:string, hint?:string|null)=>void,
* onDetailClose?: () => void, onRefuse?: (reason: string) => void, barRef:any, * onDetailClose?: () => void, onRefuse?: (reason: string) => void, barRef:any,
* reward?: any, onReward?: (idx:number)=>void, onRewardLater?: Function }} props * reward?: any, onReward?: (idx:number)=>void, onRewardLater?: Function }} props
*/ */
export function ShopBar({ priv, editable, collapsed, onCollapse, onBuy, onLevel, onRefresh, onFreeze, onDetail, onDetailClose, onRefuse, barRef, export function ShopBar({ priv, editable, collapsed, onCollapse, onBuy, onLevel, onRefresh, onFreeze, onDetail, onDetailClose, onRefuse, barRef,
reward = null, onReward, onRewardLater }) { reward = null, onReward, onRewardLater, onArm = null }) {
const shop = priv?.shop || {}; const shop = priv?.shop || {};
const slots = Array.isArray(shop.slots) ? shop.slots : []; const slots = Array.isArray(shop.slots) ? shop.slots : [];
const chessSlots = slots.map((s, i) => ({ s, i })).filter(({ s }) => !s || s.kind !== 'item'); const chessSlots = slots.map((s, i) => ({ s, i })).filter(({ s }) => !s || s.kind !== 'item');
@@ -237,10 +262,16 @@ export function ShopBar({ priv, editable, collapsed, onCollapse, onBuy, onLevel,
if (showReward) reward.slots.forEach((s, i) => { if (s && !s.sold) keys.add(armKey('r', i, s)); }); if (showReward) reward.slots.forEach((s, i) => { if (s && !s.sold) keys.add(armKey('r', i, s)); });
} }
const [armed, setArmed] = useTwoTap({ editable, keys }); const [armed, setArmed] = useTwoTap({ editable, keys });
// the armed card's slot (game.js lights the tile a merge's elite will take — gameLogic.mergeTarget); kept before the
// collapsed tab's early return (hook order)
const armedSlot = armedSlotOf(armed, slots, showReward ? reward.slots : null);
const armedSig = armedSlot ? `${armedSlot.kind}:${armedSlot.id}` : '';
useEffect(() => { onArm?.(armedSlot ? { kind: armedSlot.kind === 'item' ? 'item' : 'chess', id: armedSlot.id } : null); }, [armedSig]);
useEffect(() => () => onArm?.(null), []);
/** First tap arms + opens the detail; the second buys (or says why it can't). */ /** First tap arms + opens the detail; the second buys (or says why it can't). */
const tapCard = (kind, idx, slot, detailKind, reason, buy) => { const tapCard = (kind, idx, slot, detailKind, reason, buy) => {
const key = armKey(kind, idx, slot); const key = armKey(kind, idx, slot);
if (armed !== key) { setArmed(key); onDetail(slot.id, detailKind); return; } if (armed !== key) { setArmed(key); onDetail(slot.id, detailKind, detailKind === 'chess' ? mergeHint(priv, slot.id) : null); return; }
if (reason) { onRefuse?.(reason); return; } if (reason) { onRefuse?.(reason); return; }
setArmed(null); setArmed(null);
onDetailClose?.(); onDetailClose?.();
+39 -11
View File
@@ -119,7 +119,7 @@
// human is left at all the match ends ('abandoned'); when nobody alive is left it ends as 'eliminated'. // human is left at all the match ends ('abandoned'); when nobody alive is left it ends as 'eliminated'.
import { C2S, unitStatsEntry } from '../../shared/protocol.js'; import { C2S, unitStatsEntry } from '../../shared/protocol.js';
import { PHASE, ERR, EMOTES, EMOTE_COOLDOWN_MS, GEO, modeIdFor } from '../../shared/constants.js'; import { PHASE, ERR, EMOTES, EMOTE_COOLDOWN_MS, GEO, modeIdFor, layerGainRoom } from '../../shared/constants.js';
import { Battle } from '../sim/Battle.js'; import { Battle } from '../sim/Battle.js';
import { DataSource } from '../sim/simdata.js'; import { DataSource } from '../sim/simdata.js';
import { createRng, deriveSeed } from '../sim/rng.js'; import { createRng, deriveSeed } from '../sim/rng.js';
@@ -174,7 +174,8 @@ const BOSS_RESULT_GRACE_MS = 6000;
* pool per BOSS_MIN_CLEAR_GS game seconds (20 % of the pool per game second; the balance model's fastest mean kills * pool per BOSS_MIN_CLEAR_GS game seconds (20 % of the pool per game second; the balance model's fastest mean kills
* take ≈ 18–20 s at ≈ 5 %/s per field, docs/BALANCE.md §3), the credited LP cost ≤ BOSS_LP_BURST + BOSS_LP_PER_GS per * take ≈ 18–20 s at ≈ 5 %/s per field, docs/BALANCE.md §3), the credited LP cost ≤ BOSS_LP_BURST + BOSS_LP_PER_GS per
* game second (a leader's "扣除所有目标生命" comes after ≥ 200 s). Reports are cumulative: what exceeds the budget is * game second (a leader's "扣除所有目标生命" comes after ≥ 200 s). Reports are cumulative: what exceeds the budget is
* credited later as the budget grows (the boss clock re-applies the latest report), never lost. * credited later as the budget grows (the boss clock re-applies the latest report), never lost — a 'cleared' b.result
* whose report covers the pool waits for it too (`heldResult`; 999-layer kills take 2–4 game s), it is not handed over.
*/ */
const BOSS_MIN_CLEAR_GS = 5; const BOSS_MIN_CLEAR_GS = 5;
const BOSS_LP_BURST = 10; const BOSS_LP_BURST = 10;
@@ -1909,8 +1910,9 @@ export class Match {
progress: { gt: 0, killed: 0, total, leaks: 0, done: false }, lastProgressAt: this.sched.now(), progress: { gt: 0, killed: 0, total, leaks: 0, done: false }, lastProgressAt: this.sched.now(),
bossAcked: 0, bossBy: {}, lpAcked: 0, lpCum: 0, deadlineTimer: null, doneTimer: null, waitTimer: null, bossAcked: 0, bossBy: {}, lpAcked: 0, lpCum: 0, deadlineTimer: null, doneTimer: null, waitTimer: null,
// boss fields: the latest client reports (re-credited as the plausibility budget grows), the server run's // boss fields: the latest client reports (re-credited as the plausibility budget grows), the server run's
// CreditPool, humans demoted for an implausible result (never the authority of this field again) // CreditPool, humans demoted for an implausible result (never the authority of this field again), a 'cleared'
bossReported: null, lpReported: 0, credit: null, demoted: new Set(), // b.result waiting for the budget to credit the pool it emptied (`heldResult`, _onResult)
bossReported: null, lpReported: 0, credit: null, demoted: new Set(), heldResult: null,
}; };
} }
@@ -2306,7 +2308,7 @@ export class Match {
_onResult(ps, msg) { _onResult(ps, msg) {
if (!this.clientCombat) return fail(ERR.WRONG_PHASE, 'server-run combat'); if (!this.clientCombat) return fail(ERR.WRONG_PHASE, 'server-run combat');
const f = this._fieldByBattle(msg.battleId); const f = this._fieldByBattle(msg.battleId);
if (!f || f.done || f.mode !== 'client' || f.authority !== ps.playerId) return OK; if (!f || f.done || f.heldResult || f.mode !== 'client' || f.authority !== ps.playerId) return OK;
const bossLike = f.kind === 'boss' || f.kind === 'hidden'; const bossLike = f.kind === 'boss' || f.kind === 'hidden';
const v = validateClientResult(f.spec, msg.result, { gd: this.gd }); const v = validateClientResult(f.spec, msg.result, { gd: this.gd });
if (!v.ok) { if (!v.ok) {
@@ -2334,12 +2336,23 @@ export class Match {
// the pair's fight (and, with every field done, end the Final Assault as a defeat). The field is handed to the // the pair's fight (and, with every field done, end the Final Assault as a defeat). The field is handed to the
// partner's replica or the server instead, and the sender never reports it again. // partner's replica or the server instead, and the sender never reports it again.
if (!this._finalEnding && !(pool && pool.hp <= 0)) { if (!this._finalEnding && !(pool && pool.hp <= 0)) {
// 'cleared' while the budget still holds part of the client's cumulative report back: when that report covers
// what the pool holds, the client emptied the pool as it saw it, only faster than BOSS_MIN_CLEAR_GS lets the
// server credit (999-layer boards kill a 绝境 leader in 2–4 game s; official "boss一秒死", DESIGN §20.10). The
// result waits: the boss clock credits the report as the budget grows and the pool's end (_endFinal) completes
// the field with it — no takeover, nothing credited faster than the budget (a forged report gains nothing a
// b.progress could not already get). The 1 HP slack is the pool's dust floor (BOSS_POOL_MIN_HP).
const reported = Math.max(sum, f.bossReported ? f.bossReported.cum : 0);
if (result.reason === 'cleared' && pool && reported - f.bossAcked >= pool.hp - 1) {
f.heldResult = result;
return OK;
}
this.verifyStats.rejected++; this.verifyStats.rejected++;
this.log.warn?.(`[match ${this.roomCode}] ${f.fieldId}: implausible boss result from ${ps.playerId} (${result.reason} while the pool holds ${pool ? Math.round(pool.hp) : '?'}) — handed over`); this.log.warn?.(`[match ${this.roomCode}] ${f.fieldId}: implausible boss result from ${ps.playerId} (${result.reason} while the pool holds ${pool ? Math.round(pool.hp) : '?'}) — handed over`);
this._bossHandover(f, 'invalid', { demote: true }); this._bossHandover(f, 'invalid', { demote: true });
return OK; return OK;
} }
for (const pid of f.players) if (result.perPlayer[pid]) result.perPlayer[pid].bossDamage = Math.max(result.perPlayer[pid].bossDamage || 0, f.bossBy[pid] || 0); this._bossResultDamage(f, result);
} else { } else {
result = this._verifyResult(f, result); result = this._verifyResult(f, result);
} }
@@ -2350,6 +2363,11 @@ export class Match {
return OK; return OK;
} }
/** A boss field's accepted client result: each player's bossDamage is at least what the server credited them. */
_bossResultDamage(f, result) {
for (const pid of f.players) if (result.perPlayer[pid]) result.perPlayer[pid].bossDamage = Math.max(result.perPlayer[pid].bossDamage || 0, f.bossBy[pid] || 0);
}
/** /**
* SP_VERIFY: re-simulate an accepted client result ('all': now, the server's result wins on a mismatch; 'sample': * SP_VERIFY: re-simulate an accepted client result ('all': now, the server's result wins on a mismatch; 'sample':
* ~1 battle in 8 in a later callback, mismatches are only logged). * ~1 battle in 8 in a later callback, mismatches are only logged).
@@ -2555,7 +2573,7 @@ export class Match {
* its partner (a replica already running) takes over, else the server. * its partner (a replica already running) takes over, else the server.
*/ */
_bossHandover(f, why, { demote = false } = {}) { _bossHandover(f, why, { demote = false } = {}) {
if (f.done || f.mode !== 'client') return; if (f.done || f.mode !== 'client' || f.heldResult) return; // a held 'cleared' result is in: nothing left to run
const prev = f.authority; const prev = f.authority;
if (demote && prev) f.demoted.add(prev); if (demote && prev) f.demoted.add(prev);
const next = this._authorityFor(f, prev); const next = this._authorityFor(f, prev);
@@ -2581,7 +2599,7 @@ export class Match {
if (f.bossReported && f.bossReported.cum > f.bossAcked) this._creditBoss(f, f.bossReported.cum, f.bossReported.by); if (f.bossReported && f.bossReported.cum > f.bossAcked) this._creditBoss(f, f.bossReported.cum, f.bossReported.by);
if (f.lpReported > f.lpAcked) this._creditLp(f, f.lpReported, true); if (f.lpReported > f.lpAcked) this._creditLp(f, f.lpReported, true);
} }
if (f.cc && !f.done && f.mode === 'client' && now - f.lastProgressAt > BOSS_SILENCE_MS) { if (f.cc && !f.done && f.mode === 'client' && !f.heldResult && now - f.lastProgressAt > BOSS_SILENCE_MS) {
this.log.info?.(`[match ${this.roomCode}] ${f.fieldId}: no progress from ${f.authority} — handing the field over`); this.log.info?.(`[match ${this.roomCode}] ${f.fieldId}: no progress from ${f.authority} — handing the field over`);
this._bossHandover(f, 'silent'); this._bossHandover(f, 'silent');
} }
@@ -2615,6 +2633,13 @@ export class Match {
continue; // _fieldDone below (after every field was told) continue; // _fieldDone below (after every field was told)
} }
for (const pid of this._humansShowing(f)) this.sendTo(pid, { t: 'b.end', battleId: f.battleId, fieldId: f.fieldId, reason }); for (const pid of this._humansShowing(f)) this.sendTo(pid, { t: 'b.end', battleId: f.battleId, fieldId: f.fieldId, reason });
if (f.heldResult) {
// its authority already reported 'cleared' (_onResult): that result completes the field
f.result = f.heldResult;
this._bossResultDamage(f, f.result);
f.resultSource = 'client';
continue; // _fieldDone below
}
f.waitTimer = this.later(this.scaled(BOSS_RESULT_GRACE_MS), () => { f.waitTimer = this.later(this.scaled(BOSS_RESULT_GRACE_MS), () => {
f.waitTimer = null; f.waitTimer = null;
if (f.done) return; if (f.done) return;
@@ -2623,7 +2648,7 @@ export class Match {
}); });
} }
if (this.pacer) { this.pacer.stop(); this.pacer = null; } if (this.pacer) { this.pacer.stop(); this.pacer = null; }
for (const f of this.fields) if (f.cc && !f.done && f.mode === 'server') this._fieldDone(f); for (const f of this.fields) if (f.cc && !f.done && (f.mode === 'server' || f.heldResult)) this._fieldDone(f);
} }
/** b.pool { hp, max, teamLp, acked } to everyone (≤ 4 Hz; `force` skips the dedupe, never the rate). */ /** b.pool { hp, max, teamLp, acked } to everyone (≤ 4 Hz; `force` skips the dedupe, never the rate). */
@@ -2689,11 +2714,14 @@ export class Match {
if (coins > 0) { ps.pendingFunds += coins; ps.stats.fundsGained += coins; } if (coins > 0) { ps.pendingFunds += coins; ps.stats.fundsGained += coins; }
for (const b of ps.bounties) b.roundsLeft--; for (const b of ps.bounties) b.roundsLeft--;
ps.bounties = ps.bounties.filter((b) => b.roundsLeft > 0); ps.bounties = ps.bounties.filter((b) => b.roundsLeft > 0);
// IN_BATTLE layer gains (normal battles only) // IN_BATTLE layer gains (normal battles only), at most the room left under BOND_LAYER_CAP (999, as the battle's
// live copy: Battle.addLayers); a bond at the cap gains nothing and dispatches nothing
for (const [bondId, n] of Object.entries(r.layerGains || {})) { for (const [bondId, n] of Object.entries(r.layerGains || {})) {
if (!this.gd.bond(bondId) || !(n > 0)) continue; if (!this.gd.bond(bondId) || !(n > 0)) continue;
const before = ps.layers[bondId] || 0; const before = ps.layers[bondId] || 0;
ps.layers[bondId] = before + Math.floor(n); const add = layerGainRoom(before, Math.floor(n));
if (!(add > 0)) continue;
ps.layers[bondId] = before + add;
this.dispatch(ps, 'onLayers', { bondId, from: before, to: ps.layers[bondId], reason: 'battle' }); this.dispatch(ps, 'onLayers', { bondId, from: before, to: ps.layers[bondId], reason: 'battle' });
} }
this._charDamageTickers(ps, r); this._charDamageTickers(ps, r);
+47 -33
View File
@@ -21,13 +21,15 @@
// * Shop: per-level chess slots + item slot, copy-weighted rolls from the SHARED pool (pool.js); refresh 1 (free // * Shop: per-level chess slots + item slot, copy-weighted rolls from the SHARED pool (pool.js); refresh 1 (free
// refreshes first), one toggle freezes all unsold slots until the next round start (a manual refresh while frozen // refreshes first), one toggle freezes all unsold slots until the next round start (a manual refresh while frozen
// rerolls everything and the new slots stay frozen), level-up price = base − rounds elapsed (floor 0). // rerolls everything and the new slots stay frozen), level-up price = base − rounds elapsed (floor 0).
// * Merge: 3 normal copies (风丸 2) on board/hand/temp → 1 elite to the hand; equipment returns to the hand; a // * Merge: 3 normal copies (风丸 2) on board/hand/temp → 1 elite; equipment returns to the hand; a
// reward offer of 3 different free chess of tier min(level+1, 6) is queued (never one operator twice — user // reward offer of 3 different free chess of tier min(level+1, 6) is queued (never one operator twice — user
// playtest #6 item 19; a short tier tops up from the tier below; pick 1, expires at prep end; an offer earned // playtest #6 item 19; a short tier tops up from the tier below; pick 1, expires at prep end; an offer earned
// after the prep — SETTLE / Final Assault effects — is kept for the next prep). The elite goes to the hand // after the prep — SETTLE / Final Assault effects — is kept for the next prep). Where the elite goes (PRTS
// (research 01 A1 "1 elite goes to the hand, not to a board tile"), overflow temp — also outside PREP (a // 卫戍协议/帮助 "发送1名【精锐】状态的该干员至手牌区(若消耗已部署至作战区的干员,则发送至作战区对应位置)", user
// SETTLE merge's elite waits in temp for the next prep); only with the hand and temp both full does it take a // playtest #6 follow-up): when a consumed copy stood on the board, onto that copy's tile with its facing (of several,
// freed board tile of a consumed copy. // the one that deploys first — board.js mergeTile, [ASSUMED]); it replaces a deployed copy, so the deploy count
// never grows, and it gets its own summon stack (grantTokensFor). Otherwise to the hand, overflow temp — also
// outside PREP (a SETTLE merge's elite waits in temp for the next prep).
// * Items: equip max 2 (a 3rd replaces the equipped item the player picks — g.equip replaceUid, the oldest when // * Items: equip max 2 (a 3rd replaces the equipped item the player picks — g.equip replaceUid, the oldest when
// absent; equipped items are otherwise locked: g.destroy refuses them), // absent; equipped items are otherwise locked: g.destroy refuses them),
// 2 identical normal items (hand/temp/equipped) merge into the golden item in the hand, items are never sold // 2 identical normal items (hand/temp/equipped) merge into the golden item in the hand, items are never sold
@@ -35,7 +37,8 @@
// * Tokens (PRTS 卫戍协议/帮助 §战斗部署, user playtest #6): placing an owner with manually deployable summons // * Tokens (PRTS 卫戍协议/帮助 §战斗部署, user playtest #6): placing an owner with manually deployable summons
// (tokens.json `placeable`: 赫默's 医疗探机 and 巫恋's 诅咒娃娃 with their S2, 凯瑟琳's 爬行号·防护单元, 海嗣 / 狼群 / // (tokens.json `placeable`: 赫默's 医疗探机 and 巫恋's 诅咒娃娃 with their S2, 凯瑟琳's 爬行号·防护单元, 海嗣 / 狼群 /
// 流形) sends one stack (deployLimit copies — 凯瑟琳 2) to the hand, placed by hand like any piece (no deploy slot); // 流形) sends one stack (deployLimit copies — 凯瑟琳 2) to the hand, placed by hand like any piece (no deploy slot);
// withdrawing/selling/merging the owner removes its tokens, moving it on the board (also when a summon dragged onto // withdrawing/selling/merging the owner removes its tokens (an elite that takes a merged copy's tile gets a fresh
// stack of its own, like any deployment), moving it on the board (also when a summon dragged onto
// it swaps it away) sends its placed summons back onto their stack ("移动干员时,其所属召唤物全部退场并重置至手牌区"); // it swaps it away) sends its placed summons back onto their stack ("移动干员时,其所属召唤物全部退场并重置至手牌区");
// a summon stack removed from temp at a prep deadline comes back at the next round start (startRound tops every // a summon stack removed from temp at a prep deadline comes back at the next round start (startRound tops every
// board owner's summons up to the deploy limit, "干员所属召唤物会于下一回合返还"). In battle a skill's summon takes its // board owner's summons up to the deploy limit, "干员所属召唤物会于下一回合返还"). In battle a skill's summon takes its
@@ -51,9 +54,9 @@
// it during INFO_CHECK only. battleInput() resolves every chess unit to `skillIndex` + `moduleId` (resolveLoadout: // it during INFO_CHECK only. battleInput() resolves every chess unit to `skillIndex` + `moduleId` (resolveLoadout:
// normal chess → moduleId null, elite → uniEquipId | 'none'); m.private exposes `loadout`. // normal chess → moduleId null, elite → uniEquipId | 'none'); m.private exposes `loadout`.
import { ERR, GEO, PHASE } from '../../shared/constants.js'; import { ERR, GEO, PHASE, layerGainRoom } from '../../shared/constants.js';
import { checkLoadout, resolveLoadout } from '../../shared/protocol.js'; import { checkLoadout, resolveLoadout } from '../../shared/protocol.js';
import { FIELD, tileKey, parseKey, inField, canPlace, positionClass, boardOrder, freeSlot, pieceDir, parseDir } from './board.js'; import { FIELD, tileKey, parseKey, inField, canPlace, positionClass, boardOrder, freeSlot, pieceDir, parseDir, mergeTile } from './board.js';
import { offsetTile } from '../sim/dir.js'; import { offsetTile } from '../sim/dir.js';
import { computeBonds, bondList, bondSnapshot, activatedLayers } from './bondsMeta.js'; import { computeBonds, bondList, bondSnapshot, activatedLayers } from './bondsMeta.js';
import { itemKey } from './gamedata.js'; import { itemKey } from './gamedata.js';
@@ -461,15 +464,28 @@ export class PlayerState {
/** /**
* Merge `need` normal copies of `baseId` (the incoming, not yet stowed piece first, then temp, hand, board) into * Merge `need` normal copies of `baseId` (the incoming, not yet stowed piece first, then temp, hand, board) into
* the elite. Returns the elite piece (or null if the elite could not be stored). * the elite — PRTS 卫戍协议/帮助 §干员的获得与精锐化: "发送1名【精锐】状态的该干员至手牌区(若消耗已部署至作战区的干员,
* 则发送至作战区对应位置)" (the user's playtest #6 follow-up confirms it). The tile (`mergeTile`): when a consumed copy
* stood on the board the elite takes its tile and facing — of several, the one that deploys first (board reading
* order: top → bottom, then left → right) [ASSUMED]; `fromKey` / `fromDir` = the tile the incoming piece stood on (a
* transformation of a deployed operator) counts as such a copy. It replaces a deployed copy, so the deploy count never
* grows. Otherwise the elite goes to the hand, overflow temp — outside PREP too (a SETTLE merge's elite waits in temp
* through the next prep, tempDue). The copies' equipment returns to the hand ("干员晋级后已配发装备会回收至整备区";
* overflow temp; with both full it stays on the elite, up to its equipPerChess (2) slots — any further item is
* destroyed with a log warning, as before the official rule); their summons are removed, and an elite on the board
* gets its own summon stack (grantTokensFor: its loadout, like any deployment). Returns the elite piece (or null if
* the elite could not be stored).
* @param {string} baseId
* @param {any} incoming the acquired, not yet stowed copy (null: only owned copies)
* @param {{ fromKey?: string|null, fromDir?: string }} [opts]
*/ */
_mergeChess(baseId, incoming) { _mergeChess(baseId, incoming, { fromKey = null, fromDir = undefined } = {}) {
const need = this.gd.mergeCount(baseId); const need = this.gd.mergeCount(baseId);
const goldenId = this.gd.goldenIdOf(baseId); const goldenId = this.gd.goldenIdOf(baseId);
if (!(need > 1) || !goldenId) return null; if (!(need > 1) || !goldenId) return null;
const locs = this._chessLocations().filter((l) => !this.gd.isGolden(l.piece.id) && this.gd.baseIdOf(l.piece.id) === baseId); const locs = this._chessLocations().filter((l) => !this.gd.isGolden(l.piece.id) && this.gd.baseIdOf(l.piece.id) === baseId);
const consumed = []; const consumed = [];
if (incoming) consumed.push({ piece: incoming, area: 'new' }); if (incoming) consumed.push({ piece: incoming, area: 'new', key: fromKey || undefined, dir: fromDir });
for (const l of locs) { if (consumed.length >= need) break; consumed.push(l); } for (const l of locs) { if (consumed.length >= need) break; consumed.push(l); }
if (consumed.length < need) return null; if (consumed.length < need) return null;
let copies = 0; let copies = 0;
@@ -482,27 +498,21 @@ export class PlayerState {
l.piece.items = []; l.piece.items = [];
} }
const elite = this.newPiece('chess', goldenId, { poolCopies: copies }); const elite = this.newPiece('chess', goldenId, { poolCopies: copies });
const boardLoc = consumed.find((l) => l.area === 'board' && !this.board.has(l.key)); const deployed = consumed.filter((l) => l.key && !this.board.has(l.key)).map((l) => ({ key: l.key, dir: l.area === 'new' ? l.dir : pieceDir(l.piece) }));
const toBoard = () => { const toTile = (t) => { elite.dir = parseDir(t.dir) || 'RIGHT'; this.board.set(t.key, elite); return 'board'; };
// same operator as a consumed copy, so its freed tile is legal; it keeps that copy's facing const tile = mergeTile(deployed, (r, c) => this._legal(elite, r, c));
elite.dir = pieceDir(boardLoc.piece); let where = tile ? toTile(tile) : this.stow(elite, { allowTemp: true });
this.board.set(boardLoc.key, elite); // hand and temp full and no deployed tile legal for it (a terrain change not re-checked yet): it stays on the first
this.grantTokensFor(elite); // deployed copy's tile rather than being lost, like a piece _evictIllegal finds no room for
return 'board'; if (!where && deployed.length) where = toTile(mergeTile(deployed));
};
// the elite goes to the hand, overflow temp — outside PREP too (a merge completed by SETTLE / battle-result effects):
// a temp piece that arrived after the prep waits there through the next prep (tempDue)
let where = this.stow(elite, { allowTemp: true });
if (!where && boardLoc) {
// hand and temp both full ⇒ every consumed copy stood on the board: the elite takes the first freed tile
// instead of being lost
where = toBoard();
}
for (const it of items) { for (const it of items) {
if (this.stow(it, { allowTemp: true })) continue; if (this.stow(it, { allowTemp: true })) continue;
if (where && elite.items.length < this.gd.equipPerChess) { elite.items.push(it); continue; } if (where && elite.items.length < this.gd.equipPerChess) { elite.items.push(it); continue; }
this.m.log.warn?.(`[match ${this.m.roomCode}] ${this.playerId}: returned item ${it.id} destroyed (no space)`); this.m.log.warn?.(`[match ${this.m.roomCode}] ${this.playerId}: returned item ${it.id} destroyed (no space)`);
} }
// deployed like any operator placed by hand: its manually deployable summons join the hand (after the returned
// equipment, which would be lost in temp — a summon stack removed there comes back at the next round start)
if (where === 'board') this.grantTokensFor(elite);
if (!where) { if (!where) {
this.m.pool.give(baseId, copies); this.m.pool.give(baseId, copies);
this.m.toast(this, 'warn', '整备区已满,晋升的精锐干员无法放入'); this.m.toast(this, 'warn', '整备区已满,晋升的精锐干员无法放入');
@@ -514,7 +524,7 @@ export class PlayerState {
this.pushRewardOffer('merge'); this.pushRewardOffer('merge');
const rec = this.gd.chess(goldenId); const rec = this.gd.chess(goldenId);
this.m.tickerFor('GOLDEN_CHAR', [this.name, rec ? rec.name : goldenId], { playerId: this.playerId }); this.m.tickerFor('GOLDEN_CHAR', [this.name, rec ? rec.name : goldenId], { playerId: this.playerId });
this.m.dispatch(this, 'onMerge', { kind: 'chess', piece: elite, baseId, consumed: consumed.map((l) => l.piece.uid) }); this.m.dispatch(this, 'onMerge', { kind: 'chess', piece: elite, baseId, consumed: consumed.map((l) => l.piece.uid), area: where });
return elite; return elite;
} }
@@ -533,7 +543,8 @@ export class PlayerState {
/** /**
* Replace a chess piece by another chess (突变细胞 and similar). The new piece keeps the tile when legal (else goes * Replace a chess piece by another chess (突变细胞 and similar). The new piece keeps the tile when legal (else goes
* to the hand/temp) and keeps the equipment; pool copies are swapped; completes a merge when possible. * to the hand/temp) and keeps the equipment; pool copies are swapped; completes a merge when possible — a deployed
* piece's tile then counts as a consumed copy's for the elite (_mergeChess fromKey, when legal for it).
*/ */
transformChess(piece, newId) { transformChess(piece, newId) {
const loc = this.find(piece.uid); const loc = this.find(piece.uid);
@@ -549,7 +560,7 @@ export class PlayerState {
let np = this.newPiece('chess', newId, { poolCopies: taken }); let np = this.newPiece('chess', newId, { poolCopies: taken });
np.items = items; np.items = items;
if (!rec.isGolden && this.completesChessMerge(newId)) { if (!rec.isGolden && this.completesChessMerge(newId)) {
np = this._mergeChess(base, np); np = this._mergeChess(base, np, loc.area === 'board' ? { fromKey: loc.key, fromDir: pieceDir(piece) } : undefined);
if (!np) { this.recompute(); return null; } if (!np) { this.recompute(); return null; }
} else if (loc.area === 'board') { } else if (loc.area === 'board') {
const [r, c] = parseKey(loc.key); const [r, c] = parseKey(loc.key);
@@ -724,13 +735,16 @@ export class PlayerState {
this.m.dispatch(this, 'onSpend', { amount, reason, total: this.stats.gold }); this.m.dispatch(this, 'onSpend', { amount, reason, total: this.stats.gold });
} }
/** Bond layer gain (prep-side). `requireActive` = "使已激活的【X】层数+N". Returns layers added. */ /**
* Bond layer gain (prep-side). `requireActive` = "使已激活的【X】层数+N". Returns layers added: at most the room left
* under BOND_LAYER_CAP (999, shared/constants.js) — a gain at the cap adds 0 and dispatches nothing.
*/
addLayers(bondId, n, { requireActive = false, reason = '' } = {}) { addLayers(bondId, n, { requireActive = false, reason = '' } = {}) {
if (!this.gd.bond(bondId) || !Number.isFinite(n) || n <= 0) return 0; if (!this.gd.bond(bondId) || !Number.isFinite(n) || n <= 0) return 0;
if (requireActive && !(this.bonds[bondId] && this.bonds[bondId].active)) return 0; if (requireActive && !(this.bonds[bondId] && this.bonds[bondId].active)) return 0;
const add = Math.floor(n);
if (add <= 0) return 0;
const before = this.layers[bondId] || 0; const before = this.layers[bondId] || 0;
const add = layerGainRoom(before, Math.floor(n));
if (add <= 0) return 0;
this.layers[bondId] = before + add; this.layers[bondId] = before + add;
this.recompute(); this.recompute();
this.m.dispatch(this, 'onLayers', { bondId, from: before, to: before + add, reason }); this.m.dispatch(this, 'onLayers', { bondId, from: before, to: before + add, reason });
+34
View File
@@ -16,6 +16,9 @@
// freeze toggle released // freeze toggle released
// prep handlers buy / sell / refresh / levelUp pay exactly price / +sell price / refresh price (free first) / level // prep handlers buy / sell / refresh / levelUp pay exactly price / +sell price / refresh price (free first) / level
// price, the level rises by one and its price resets to the next base; Ready only with an empty temp // price, the level rises by one and its price resets to the next base; Ready only with an empty temp
// merges a merge consuming a deployed copy puts the elite on that copy's tile (of several, the first in deploy
// order legal for it — board.js mergeTile; a transformed piece's own tile counts) with its facing, else
// into the hand / temp; the deploy count never grows (PRTS 卫戍协议/帮助, user playtest #6 follow-up)
// combat start nothing overdue in temp, everyone ready, funds lost (carry bands excepted), unfrozen shop cleared, // combat start nothing overdue in temp, everyone ready, funds lost (carry bands excepted), unfrozen shop cleared,
// one field per alive player // one field per alive player
// drafts every seat holds an allowed band with LP = totalHp; 机变: one card per alive player, card ↔ picker // drafts every seat holds an allowed band with LP = totalHp; 机变: one card per alive player, card ↔ picker
@@ -38,6 +41,7 @@
import { PHASE } from '../../shared/constants.js'; import { PHASE } from '../../shared/constants.js';
import { collectViolations } from './invariants.js'; import { collectViolations } from './invariants.js';
import { mergeTile, pieceDir, canPlace, positionClass } from './board.js';
import { pairPlayers, bossPoolHp, hiddenEligible } from './finalAssault.js'; import { pairPlayers, bossPoolHp, hiddenEligible } from './finalAssault.js';
import { helperOrder } from './unite.js'; import { helperOrder } from './unite.js';
import { BAND_TURN_SECONDS } from './Match.js'; import { BAND_TURN_SECONDS } from './Match.js';
@@ -221,6 +225,36 @@ export function attachAudit(m, { invariants = true, limit = 200 } = {}) {
}); });
return res; return res;
}); });
// merges (PRTS 卫戍协议/帮助 "若消耗已部署至作战区的干员,则发送至作战区对应位置", user playtest #6 follow-up): with a
// deployed copy among the consumed ones the elite stands on the first such tile in deploy order that is legal for
// it (the incoming piece's own tile when a transformation merged it in place), with that copy's facing; else in the
// hand / temp. A merge never grows the deploy count. Every owned normal copy is consumed (merges are immediate).
wrap(ps, '_mergeChess', function (orig, baseId, incoming, opts) {
const tiles = new Map(); // tile key → facing of the copy standing there
for (const [k, p] of ps.board) if (p.kind === 'chess' && !gd.isGolden(p.id) && gd.baseIdOf(p.id) === baseId) tiles.set(k, pieceDir(p));
if (opts && opts.fromKey) tiles.set(opts.fromKey, pieceDir({ dir: opts.fromDir }));
// a transformation (transformChess) detached its deployed carrier before the merge: it still counts as deployed
const deployed0 = ps.deployCount + (opts && opts.fromKey ? 1 : 0);
const elite = orig(baseId, incoming, opts);
if (elite) check('merge', () => {
const id = ps.playerId;
const loc = ps.find(elite.uid);
if (!loc) { fail(`${id}: the elite of ${baseId} is not owned after its merge`); return; }
if (ps.deployCount > deployed0) fail(`${id}: a merge of ${baseId} grew the deploy count ${deployed0} → ${ps.deployCount}`);
// a pure read of the deploy field (Match.deployMapFor, as invariants.js): the audit must not refresh the cache
const dmap = typeof m.deployMapFor === 'function' ? m.deployMapFor(ps) : ps.deployMap();
const pos = positionClass(gd.chess(elite.id));
const want = mergeTile([...tiles.keys()].map((key) => ({ key })), (r, c) => canPlace(dmap, pos, r, c));
if (want) {
if (loc.area !== 'board' || loc.key !== want.key) fail(`${id}: the elite of ${baseId} went to ${loc.area} ${loc.key || ''}, expected the deployed copy's tile ${want.key}`);
else if (pieceDir(elite) !== tiles.get(want.key)) fail(`${id}: the elite of ${baseId} faces ${pieceDir(elite)}, its copy faced ${tiles.get(want.key)}`);
} else if (loc.area === 'board' && (!tiles.has(loc.key) || ps.hand.some((x) => x == null) || ps.temp.some((x) => x == null))) {
// only the no-room fallback (hand and temp full, no deployed tile legal for it) leaves it on a copy's tile
fail(`${id}: the elite of ${baseId} took tile ${loc.key} although no consumed copy stood on a legal tile`);
}
});
return elite;
});
wrap(ps, 'endPrep', function (orig) { wrap(ps, 'endPrep', function (orig) {
// a temp piece that got there by other means (not _putTemp) was in temp during this prep: due now // a temp piece that got there by other means (not _putTemp) was in temp during this prep: due now
for (const p of ps.temp) if (p && !dueOf(ps).has(p.uid)) dueOf(ps).set(p.uid, endedOf(ps)); for (const p of ps.temp) if (p && !dueOf(ps).has(p.uid)) dueOf(ps).set(p.uid, endedOf(ps));
+15
View File
@@ -151,6 +151,21 @@ export function boardOrder(board) {
.sort((a, b) => b.r - a.r || a.c - b.c); .sort((a, b) => b.r - a.r || a.c - b.c);
} }
/**
* Where a merge's elite stands (PRTS 卫戍协议/帮助 "若消耗已部署至作战区的干员,则发送至作战区对应位置"): of the board tiles
* the consumed copies stood on (`[{ key, dir }]`), the first in deployment order (top→bottom, left→right — the copy the
* battle deploys first; the right-hand boss half is mirrored on screen but deploys in the same board order) that
* `legal(r, c)` accepts for the elite, or null (⇒ the hand). [ASSUMED] the order: the official text names one position.
* public/js/ui/gameLogic.js mergeTarget mirrors it for the client.
* @param {Array<{ key: string, dir?: string }>} tiles
* @param {(r: number, c: number) => boolean} [legal]
* @returns {{ key: string, dir?: string, r: number, c: number } | null}
*/
export function mergeTile(tiles, legal = () => true) {
const sorted = (tiles || []).map((t) => { const [r, c] = parseKey(t.key); return { ...t, r, c }; }).sort((a, b) => b.r - a.r || a.c - b.c);
return sorted.find((t) => inField(t.r, t.c) && legal(t.r, t.c)) || null;
}
/** Index of the free slot to fill (right→left, config.economy.handFillOrder), or -1. */ /** Index of the free slot to fill (right→left, config.economy.handFillOrder), or -1. */
export function freeSlot(arr) { export function freeSlot(arr) {
for (let i = arr.length - 1; i >= 0; i--) if (arr[i] == null) return i; for (let i = arr.length - 1; i >= 0; i--) if (arr[i] == null) return i;
+3 -1
View File
@@ -12,7 +12,9 @@
// (free levels always; the −1/round discount is waited for early), then keep buying / refreshing with what is // (free levels always; the −1/round discount is waited for early), then keep buying / refreshing with what is
// left: merge progress (pairs → elites) > bond thresholds (focus core bond, add-ons 2/3) > role needs // left: merge progress (pairs → elites) > bond thresholds (focus core bond, add-ons 2/3) > role needs
// (blockers, anti-air when the wave flies, one or two healers) > tier; with a full board a buy must improve the // (blockers, anti-air when the wave flies, one or two healers) > tier; with a full board a buy must improve the
// best lineup. Items only when a deployed operator can carry them. // best lineup. Items only when a deployed operator can carry them. A merge that consumes a deployed copy leaves
// the elite on that copy's tile (PRTS 卫戍协议/帮助, PlayerState._mergeChess): nothing here assumes it in the hand —
// steps 3–4 plan it like any owned unit (kept, moved or benched).
// 3. lineup: the deployed set maximizes unit value + activated bond tiers (exact counting via computeBonds) + // 3. lineup: the deployed set maximizes unit value + activated bond tiers (exact counting via computeBonds) +
// composition (chooseLineup: greedy seed + swap hill-climbing). // composition (chooseLineup: greedy seed + swap hill-climbing).
// 4. placement (planLayout): the round's routes are traced over the own board from the enemy preview (ground // 4. placement (planLayout): the round's routes are traced over the own board from the enemy preview (ground
+13 -5
View File
@@ -30,6 +30,7 @@
// offspring bound) — the live counter's clamp // offspring bound) — the live counter's clamp
// syntheticResult(players, progress) stand-in when a boss field's client never reported // syntheticResult(players, progress) stand-in when a boss field's client never reported
import { TICK, SNAPSHOT_EVERY } from '../sim/constants.js'; import { TICK, SNAPSHOT_EVERY } from '../sim/constants.js';
import { layerGainRoom } from '../../shared/constants.js';
import { uniteLeft } from '../sim/spec.js'; import { uniteLeft } from '../sim/spec.js';
export const MAX_TICKS_PER_INTERVAL = 8; export const MAX_TICKS_PER_INTERVAL = 8;
@@ -633,7 +634,13 @@ export function specBounds(spec, gd = null) {
if (gd) for (const t of summonsOf(gd, defId)) defIds.add(t); if (gd) for (const t of summonsOf(gd, defId)) defIds.add(t);
} }
} }
players.set(p.playerId, { chess, all, defIds, bonds: gd ? layerBondsOf(p, gd) : null }); // the layers each bond starts the battle with (PlayerBattleInput.bonds): a gain never passes BOND_LAYER_CAP
const startLayers = new Map();
for (const [id, b] of Object.entries(p.bonds && typeof p.bonds === 'object' ? p.bonds : {})) {
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 });
} }
const round = Number(spec && spec.round) || 0; const round = Number(spec && spec.round) || 0;
return { return {
@@ -679,9 +686,9 @@ const sameMods = (a, b) => {
* `counted: false` only for enemies that never count (data notCountInTotal, countInTotal false, boss / part entries, * `counted: false` only for enemies that never count (data notCountInTotal, countInTotal false, boss / part entries,
* content spawns); 联防: leaks + never-spawned re-entries per (enemy, leaker) ≤ what that leaker sent in, and a split / * content spawns); 联防: leaks + never-spawned re-entries per (enemy, leaker) ≤ what that leaker sent in, and a split /
* summon only on a leaker who sent in its parent, ≤ the parents' data offspring count (offspringPerParent); per-bond layer * summon only on a leaker who sent in its parent, ≤ the parents' data offspring count (offspringPerParent); per-bond layer
* gains ≤ 60 + 4·round, only on bonds the player's lineup / band / effects / items name, none when the spec disables * gains ≤ 60 + 4·round and ≤ the room left under BOND_LAYER_CAP (999) from the bond's starting layers, only on bonds the
* gains; coins ≤ the spawns' bounty coins; perfect consistent with the counted leaks; unit states only for the * player's lineup / band / effects / items name, none when the spec disables gains; coins ≤ the spawns' bounty coins;
* player's own units, within range. * perfect consistent with the counted leaks; unit states only for the player's own units, within range.
* @returns {{ ok: true, result: object } | { ok: false, reason: string }} * @returns {{ ok: true, result: object } | { ok: false, reason: string }}
*/ */
export function validateClientResult(spec, raw, { gd = null } = {}) { export function validateClientResult(spec, raw, { gd = null } = {}) {
@@ -763,7 +770,8 @@ export function validateClientResult(spec, raw, { gd = null } = {}) {
if (typeof p.perfect !== 'boolean' || p.perfect !== (countedLeaks === 0)) return bad('perfect'); if (typeof p.perfect !== 'boolean' || p.perfect !== (countedLeaks === 0)) return bad('perfect');
const layerGains = {}; const layerGains = {};
for (const [bondId, n] of Object.entries(p.layerGains || {})) { for (const [bondId, n] of Object.entries(p.layerGains || {})) {
if (!finiteIn(n, 0, B.layerCap)) return bad('layer bound'); // ≤ 60 + 4·round, and never past BOND_LAYER_CAP from the layers the bond started with (Battle.addLayers clamps)
if (!finiteIn(n, 0, Math.min(B.layerCap, layerGainRoom(own.startLayers.get(bondId) || 0, Infinity)))) return bad('layer bound');
if (n > 0 && spec.flags && spec.flags.layerGainsEnabled === false) return bad('layers disabled'); if (n > 0 && spec.flags && spec.flags.layerGainsEnabled === false) return bad('layers disabled');
if (gd && typeof gd.bond === 'function' && !gd.bond(bondId)) return bad('bond'); if (gd && typeof gd.bond === 'function' && !gd.bond(bondId)) return bad('bond');
if (n > 0 && own.bonds && !own.bonds.has(bondId)) return bad('layer bond'); if (n > 0 && own.bonds && !own.bonds.has(bondId)) return bad('layer bond');
+15 -12
View File
@@ -8,10 +8,13 @@
// RIGHT side (the sim mirrors the right side: board col c → field col 20 − c with the piece direction RIGHT ↔ // RIGHT side (the sim mirrors the right side: board col c → field col 20 − c with the piece direction RIGHT ↔
// LEFT, UP / DOWN unchanged (DESIGN §3, research 09 §1.2 ConvertChessPositionInfoToBossMap); board rows 9–12 → // LEFT, UP / DOWN unchanged (DESIGN §3, research 09 §1.2 ConvertChessPositionInfoToBossMap); board rows 9–12 →
// boss rows 2–5, sim/constants BOSS_ROW_OFFSET). `bossFieldPlacement` gives that mapping for UIs / tools. // boss rows 2–5, sim/constants BOSS_ROW_OFFSET). `bossFieldPlacement` gives that mapping for UIs / tools.
// * Shared boss HP pool (research 08 §6 #7, DESIGN §14 corrections): co-op = bloodPoint[difficulty] as is — split // * Shared boss HP pool (DESIGN §20.10, GameData.bossPoolShare): one pool shared by every boss field (official tip
// leaders share it, "敌方领袖的总生命值不变", no alive-player factor; solo = bloodPoint × config bossHpScale.solo (0.25, // "所有人将一起对敌方领袖造成伤害"); co-op = bloodPoint[difficulty] whatever the number of alive players (notice 5114's
// flagged unknown); × the tuning bossHpMul when data/tuning.json still has one (docs/BALANCE.md); shared by every // "敌方领袖的总生命值不变" is about the mirrored copies of a pair field sharing it, not about that number); config
// boss field; bosses are never scaled by enemyScale. // bossHpScale.aliveScaling true scales it × alive / 4 (巴哈姆特 12294 "聯機隊友(撤退/死掉)變少,最後boss血條也會變少" — one
// community note, no proportion; off until the user confirms it); solo = bloodPoint × config bossHpScale.solo (0.25,
// flagged unknown); × the tuning bossHpMul when data/tuning.json still has one (docs/BALANCE.md); bosses are never
// scaled by enemyScale.
// * Overtime: bossTurnHpReduceTime counts REAL seconds like the level's 120 s maxPlayTime (which runs out first; the // * Overtime: bossTurnHpReduceTime counts REAL seconds like the level's 120 s maxPlayTime (which runs out first; the
// battle goes on): from 150 real s (300 game s on the 2× field clock) the team loses bossOvertimeDrainPerSec (1) LP // battle goes on): from 150 real s (300 game s on the 2× field clock) the team loses bossOvertimeDrainPerSec (1) LP
// per real second (gamedata.js bossOvertimeDue); m.public.deadline = the 120 s countdown, m.public.overtimeAt = the // per real second (gamedata.js bossOvertimeDue); m.public.deadline = the 120 s countdown, m.public.overtimeAt = the
@@ -51,22 +54,22 @@ export function pairPlayers(alive) {
return groups; return groups;
} }
/** Shared boss HP for a boss id. */ /** Shared boss HP for a boss id with `aliveCount` alive players (GameData.bossPoolShare; omitted ⇒ a full team). */
export function bossPoolHp(gd, bossId, aliveCount) { export function bossPoolHp(gd, bossId, aliveCount) {
const boss = gd.boss(bossId); const boss = gd.boss(bossId);
const diff = gd.difficulty; const diff = gd.difficulty;
let base = boss && boss.bloodPoint && Number.isFinite(boss.bloodPoint[diff]) ? boss.bloodPoint[diff] : null; let base = boss && boss.bloodPoint && Number.isFinite(boss.bloodPoint[diff]) ? boss.bloodPoint[diff] : null;
if (base == null && boss && boss.bloodPoint) base = Object.values(boss.bloodPoint).find((v) => Number.isFinite(v)) ?? null; if (base == null && boss && boss.bloodPoint) base = Object.values(boss.bloodPoint).find((v) => Number.isFinite(v)) ?? null;
if (base == null) base = 500000; if (base == null) base = 500000;
const scale = gd.mode.bossHpScale && typeof gd.mode.bossHpScale === 'object' ? gd.mode.bossHpScale : {};
const cfg = gd.config.bossHpScale && typeof gd.config.bossHpScale === 'object' ? gd.config.bossHpScale : {};
const tune = typeof gd.bossHpMul === 'function' ? gd.bossHpMul(bossId) : 1; const tune = typeof gd.bossHpMul === 'function' ? gd.bossHpMul(bossId) : 1;
if (gd.isSolo) { let share;
const s = Number.isFinite(scale.solo) ? scale.solo : Number.isFinite(cfg.solo) ? cfg.solo : 0.25; if (typeof gd.bossPoolShare === 'function') share = gd.bossPoolShare(aliveCount);
return Math.max(1, Math.round(base * s * tune)); else {
const scale = gd.mode.bossHpScale && typeof gd.mode.bossHpScale === 'object' ? gd.mode.bossHpScale : {};
const cfg = gd.config.bossHpScale && typeof gd.config.bossHpScale === 'object' ? gd.config.bossHpScale : {};
share = gd.isSolo ? (Number.isFinite(scale.solo) ? scale.solo : Number.isFinite(cfg.solo) ? cfg.solo : 0.25) : 1;
} }
void aliveCount; // co-op: the same pool whatever the number of alive players return Math.max(1, Math.round(base * share * tune));
return Math.max(1, Math.round(base * tune));
} }
/** /**
+26 -7
View File
@@ -97,7 +97,7 @@ export class GameData {
/** /**
* Leader HP pool multiplier — always 1 (no custom balance). Kept for callers written against the old tuning layer * Leader HP pool multiplier — always 1 (no custom balance). Kept for callers written against the old tuning layer
* (finalAssault.bossPoolHp); use `bossPoolHp` for the official pool. * (finalAssault.bossPoolHp); use `bossPoolHp` / `bossPoolShare` for the official pool.
* @deprecated * @deprecated
*/ */
bossHpMul(bossId) { // eslint-disable-line no-unused-vars bossHpMul(bossId) { // eslint-disable-line no-unused-vars
@@ -105,23 +105,42 @@ export class GameData {
} }
/** /**
* Official shared leader HP pool (research 08 §6 #7): co-op = bloodPoint[difficulty] as is — split leaders share one * Official shared leader HP pool (DESIGN §20.10): ONE pool for every boss field of the match (official tip "最终攻势中,
* pool, "敌方领袖的总生命值不变", no alive-player scaling; solo = bloodPoint × config bossHpScale.solo (0.25, * 所有人将一起对敌方领袖造成伤害"; the mirrored copies of a pair field share it — notice 5114 "两侧的敌方领袖共享生命值
* [ASSUMED], flagged in data). Leaders are never scaled by enemyScale. * (敌方领袖的总生命值不变)", which is about those copies, not about the number of players). Co-op = bloodPoint
* [difficulty]; with config bossHpScale.aliveScaling (default false) × alive / aliveFull (4) — 巴哈姆特 12294 "聯機隊友
* (撤退/死掉)變少,最後boss血條也會變少" is one community note without a proportion, kept off until confirmed (it would
* shorten fights after eliminations, the opposite of the playtest report); `aliveCount` omitted ⇒ a full team. Solo = bloodPoint ×
* bossHpScale.solo (0.25 = one player of four, [ASSUMED]). Leaders are never scaled by enemyScale ("领袖单位于服务器的
* 生命值加成不受上述加成影响").
* @param {string} bossId * @param {string} bossId
* @param {number} [aliveCount] alive players at the Final Assault / Hidden Core start (co-op)
* @returns {number} * @returns {number}
*/ */
bossPoolHp(bossId) { bossPoolHp(bossId, aliveCount) {
const boss = this.boss(bossId); const boss = this.boss(bossId);
const diff = this.difficulty; const diff = this.difficulty;
let base = boss && boss.bloodPoint && Number.isFinite(boss.bloodPoint[diff]) ? boss.bloodPoint[diff] : null; let base = boss && boss.bloodPoint && Number.isFinite(boss.bloodPoint[diff]) ? boss.bloodPoint[diff] : null;
if (base == null && boss && boss.bloodPoint) base = Object.values(boss.bloodPoint).find((v) => Number.isFinite(v)) ?? null; if (base == null && boss && boss.bloodPoint) base = Object.values(boss.bloodPoint).find((v) => Number.isFinite(v)) ?? null;
if (base == null) base = 500000; if (base == null) base = 500000;
return Math.max(1, Math.round(base * this.bossPoolShare(aliveCount)));
}
/**
* Multiplier of bloodPoint for the leader pool (see bossPoolHp): solo = bossHpScale.solo (0.25); co-op = coop (1) ×
* min(alive, aliveFull) / aliveFull when bossHpScale.aliveScaling (mode entry first, then the global one).
* @param {number} [aliveCount]
*/
bossPoolShare(aliveCount) {
const ms = this.mode.bossHpScale && typeof this.mode.bossHpScale === 'object' ? this.mode.bossHpScale : {}; const ms = this.mode.bossHpScale && typeof this.mode.bossHpScale === 'object' ? this.mode.bossHpScale : {};
const cs = this.config.bossHpScale && typeof this.config.bossHpScale === 'object' ? this.config.bossHpScale : {}; const cs = this.config.bossHpScale && typeof this.config.bossHpScale === 'object' ? this.config.bossHpScale : {};
const pick = (k, d) => (Number.isFinite(ms[k]) && ms[k] > 0 ? ms[k] : Number.isFinite(cs[k]) && cs[k] > 0 ? cs[k] : d); const pick = (k, d) => (Number.isFinite(ms[k]) && ms[k] > 0 ? ms[k] : Number.isFinite(cs[k]) && cs[k] > 0 ? cs[k] : d);
const mul = this.isSolo ? pick('solo', 0.25) : pick('coop', 1); if (this.isSolo) return pick('solo', 0.25);
return Math.max(1, Math.round(base * mul)); const scaling = typeof ms.aliveScaling === 'boolean' ? ms.aliveScaling : cs.aliveScaling === true;
const full = Math.max(1, Math.floor(pick('aliveFull', 4)));
const n = Number(aliveCount);
const alive = scaling && Number.isFinite(n) && n >= 1 ? Math.min(full, Math.floor(n)) : full;
return pick('coop', 1) * (alive / full);
} }
/** config.titles with the tuning overrides (stat / rule per title id) merged in. */ /** config.titles with the tuning overrides (stat / rule per title id) merged in. */
+8 -2
View File
@@ -8,12 +8,14 @@
// copy, an elite ≤ goldenCopies; merges are immediate (never `mergeCount` normal copies of one chess, never // copy, an elite ≤ goldenCopies; merges are immediate (never `mergeCount` normal copies of one chess, never
// two copies of a mergeable normal item); every token's owner chess is deployed // two copies of a mergeable normal item); every token's owner chess is deployed
// board tiles inside the own region and legal for the piece; no items on the board; chess count ≤ deploy cap // board tiles inside the own region and legal for the piece; no items on the board; chess count ≤ deploy cap
// bonds ps.bonds equals a fresh computeBonds() (every mutation recomputed them) // (where a merge's elite goes — a consumed deployed copy's tile, else the hand — needs the state before the
// merge: audit.js checks it per merge)
// bonds ps.bonds equals a fresh computeBonds() (every mutation recomputed them); every bond's layers 0 … BOND_LAYER_CAP
// shop slot count follows the rolled layout; ids known; banned chess never offered by the shop / rewards // shop slot count follows the rolled layout; ids known; banned chess never offered by the shop / rewards
// elim. an eliminated player owns nothing (board, hand, temp, shop, offers, bounties, funds) // elim. an eliminated player owns nothing (board, hand, temp, shop, offers, bounties, funds)
// match phase known; teamLp / boss pool within range; combat fields match the alive players // match phase known; teamLp / boss pool within range; combat fields match the alive players
import { PHASE } from '../../shared/constants.js'; import { PHASE, BOND_LAYER_CAP } from '../../shared/constants.js';
import { FIELD, canPlace, positionClass, parseKey } from './board.js'; import { FIELD, canPlace, positionClass, parseKey } from './board.js';
import { computeBonds } from './bondsMeta.js'; import { computeBonds } from './bondsMeta.js';
@@ -52,6 +54,10 @@ export function collectViolations(m, { limit = 25 } = {}) {
if (!(ps.shop.level >= 1 && ps.shop.level <= gd.maxShopLevel)) fail(`${id}: shop level ${ps.shop.level}`); if (!(ps.shop.level >= 1 && ps.shop.level <= gd.maxShopLevel)) fail(`${id}: shop level ${ps.shop.level}`);
if (!(ps.shop.upgradePrice >= 0)) fail(`${id}: upgradePrice ${ps.shop.upgradePrice}`); if (!(ps.shop.upgradePrice >= 0)) fail(`${id}: upgradePrice ${ps.shop.upgradePrice}`);
if (!Number.isInteger(ps.shop.freeRefreshes) || ps.shop.freeRefreshes < 0) fail(`${id}: freeRefreshes ${ps.shop.freeRefreshes}`); if (!Number.isInteger(ps.shop.freeRefreshes) || ps.shop.freeRefreshes < 0) fail(`${id}: freeRefreshes ${ps.shop.freeRefreshes}`);
// the official per-bond layer cap (shared/constants.js): no writer may pass it
for (const [b, v] of Object.entries(ps.layers || {})) {
if (!(Number.isFinite(v) && v >= 0 && (!(BOND_LAYER_CAP > 0) || v <= BOND_LAYER_CAP))) fail(`${id}: ${b} layers ${v}`);
}
if (!ps.alive) { if (!ps.alive) {
if (ps.board.size) fail(`${id}: eliminated but keeps ${ps.board.size} board pieces`); if (ps.board.size) fail(`${id}: eliminated but keeps ${ps.board.size} board pieces`);
+48 -13
View File
@@ -22,12 +22,12 @@
// battle continues. After MAX_INTERNAL_ERRORS the battle force-ends as a timeout. // battle continues. After MAX_INTERNAL_ERRORS the battle force-ends as a timeout.
import { TICK, ROWS, COLS, BLOCK_RADIUS_SQ, DP_DEFAULTS, DOWN_STATE, FORCED_EXIT, MAX_BATTLE_TIME, MAX_INTERNAL_ERRORS, COLD_FREEZE_DURATION, OBSTACLE_DEVICES, EVENT_BUFFER_CAP, BOSS_ROW_OFFSET, MAX_HOOK_DEPTH, MAX_ALIVE_ENEMIES, LEVITATE_HALF_WEIGHT, RESIST_DEFAULT, RESIST_PALSY_DECAY, PUSH_TILES, PUSH_TILES_EFFECT, PULL_WEAK_SHARE, PULL_CRAWL, PULL_ORIGIN, PULL_STOP_RADIUS, PUSH_DIRECTIONAL_MIN_DIST, AUTO_OP_COOLDOWN } from './constants.js'; import { TICK, ROWS, COLS, BLOCK_RADIUS_SQ, DP_DEFAULTS, DOWN_STATE, FORCED_EXIT, MAX_BATTLE_TIME, MAX_INTERNAL_ERRORS, COLD_FREEZE_DURATION, OBSTACLE_DEVICES, EVENT_BUFFER_CAP, BOSS_ROW_OFFSET, MAX_HOOK_DEPTH, MAX_ALIVE_ENEMIES, LEVITATE_HALF_WEIGHT, RESIST_DEFAULT, RESIST_PALSY_DECAY, PUSH_TILES, PUSH_TILES_EFFECT, PULL_WEAK_SHARE, PULL_CRAWL, PULL_ORIGIN, PULL_STOP_RADIUS, PUSH_DIRECTIONAL_MIN_DIST, AUTO_OP_COOLDOWN } from './constants.js';
import { GEO } from '../../shared/constants.js'; import { GEO, layerGainRoom } from '../../shared/constants.js';
import { createRng } from './rng.js'; import { createRng } from './rng.js';
import { Grid } from './grid.js'; import { Grid } from './grid.js';
import { Unit } from './units.js'; import { Unit } from './units.js';
import { makeBuff, STATUS, RESIST_STATUSES } from './buffs.js'; import { makeBuff, STATUS, RESIST_STATUSES } from './buffs.js';
import { dealDamage as pipeDamage, heal as pipeHeal, applyHpLoss, makeDamageInfo, reduceElement, palsyBuff, elementView } from './damage.js'; import { dealDamage as pipeDamage, heal as pipeHeal, applyHpLoss, makeDamageInfo, reduceElement, palsyBuff, elementView, leaderHitCancelled } from './damage.js';
import { absoluteRangeKeys, canTargetEnemy } from './targeting.js'; import { absoluteRangeKeys, canTargetEnemy } from './targeting.js';
import { bodyKeys, bodyInKeys, bodyInRadius } from './body.js'; import { bodyKeys, bodyInKeys, bodyInRadius } from './body.js';
import { normDir, mirrorDir, localOrder, localBefore } from './dir.js'; import { normDir, mirrorDir, localOrder, localBefore } from './dir.js';
@@ -1351,19 +1351,39 @@ export class Battle {
buff.data = { ...buff.data, attract: { r, c, pts: null, i: 0, ver: -1 } }; buff.data = { ...buff.data, attract: { r, c, pts: null, i: 0, ver: -1 } };
} }
/** "同名效果取最高": keep the strongest value; a weaker one that outlasts it resumes afterwards (buff.data.tail). */ /**
* "同名效果取最高" for a content effect that is not a catalogue status (no immunities, 抵抗, `beforeStatus` /
* `statusApplied` hooks or status icon): ONE instance of `key` per target whatever the number of sources — the
* strongest `value` applies, a weaker application only extends past the stronger one's end (it then resumes), an
* equal one refreshes to the longer duration. `mods(value)` builds the buff's mods. The engine's default for two
* same-named buffs (PRTS 作战机制 "同名buff的默认叠加策略buff只能表现出一个"). Returns true when applied.
* @param {object} target
* @param {string} key
* @param {{ duration: number, value: number, mods: (v: number) => object, source?: object|null }} opts
*/
applyStrongest(target, key, { duration, value, mods, source = null } = {}) {
if (!target || !target.alive || !(Number(duration) > 0) || !Number.isFinite(value) || typeof mods !== 'function') return false;
this._applyValuedStatus(target, key, { mods, valued: value, plain: true }, Number(duration), value, source);
return true;
}
/**
* "同名效果取最高": keep the strongest value; a weaker one that outlasts it resumes afterwards (buff.data.tail). A
* `plain` template (applyStrongest) is an ordinary invisible buff, not a status.
*/
_applyValuedStatus(target, key, tpl, duration, value, source) { _applyValuedStatus(target, key, tpl, duration, value, source) {
const strength = (v) => Math.abs(Number.isFinite(v) ? v : tpl.valued); const strength = (v) => Math.abs(Number.isFinite(v) ? v : tpl.valued);
const make = (v, dur, tail) => ({ const make = (v, dur, tail) => ({
...(tpl.buff || null), // extra buff fields of the status (抵抗: the 麻痹 decay tick) ...(tpl.buff || null), // extra buff fields of the status (抵抗: the 麻痹 decay tick)
key, duration: dur, refresh: 'replace', mods: tpl.mods(v), flags: tpl.flags || null, status: key, visible: true, source, key, duration: dur, refresh: 'replace', mods: tpl.mods(v), flags: tpl.flags || null, status: tpl.plain ? null : key,
visible: !tpl.plain, source,
data: { value: v, tail }, data: { value: v, tail },
onExpire: ({ battle, unit, buff }) => { onExpire: ({ battle, unit, buff }) => {
const t = buff.data.tail; const t = buff.data.tail;
if (t && t.until - battle.time > 1e-6 && unit.alive) battle.addBuff(unit, make(t.value, t.until - battle.time, null)); if (t && t.until - battle.time > 1e-6 && unit.alive) battle.addBuff(unit, make(t.value, t.until - battle.time, null));
}, },
}); });
const old = target.buffs.find((b) => b.key === key && b.status === key); const old = target.buffs.find((b) => b.key === key && (tpl.plain || b.status === key));
if (!old) { this.addBuff(target, make(value, duration, null)); return; } if (!old) { this.addBuff(target, make(value, duration, null)); return; }
const oldV = old.data && Number.isFinite(old.data.value) ? old.data.value : tpl.valued; const oldV = old.data && Number.isFinite(old.data.value) ? old.data.value : tpl.valued;
const oldEnd = this.time + old.timeLeft, newEnd = this.time + duration; const oldEnd = this.time + old.timeLeft, newEnd = this.time + duration;
@@ -1400,12 +1420,17 @@ export class Battle {
* DamageInfo this loss derives from (damage passed on to a leader, split, shared…): its tags are inherited and it is * DamageInfo this loss derives from (damage passed on to a leader, split, shared…): its tags are inherited and it is
* kept as `dmg.origin`, so `damaged` handlers that skip their own tagged damage also skip what it turned into; a loss * kept as `dmg.origin`, so `damaged` handlers that skip their own tagged damage also skip what it turned into; a loss
* derived from 无来源 damage (`from.sourceless`, element bursts) is 无来源 too (hooks see no source, `source` is credited). * derived from 无来源 damage (`from.sourceless`, element bursts) is 无来源 too (hooks see no source, `source` is credited).
* `sourceless: true` makes the loss itself 无来源 ("受到等量的无来源生命流失": hooks see no source; `source` keeps the
* credit — the stats and the per-player shared-pool tally).
* On a leader in a boss / hidden battle a loss of ≥ BOSS_HIT_LIMIT is cancelled like a hit (damage.js leaderHitCancelled).
*/ */
loseHp(target, amount, { source = null, silent = false, tags = null, from = null } = {}) { loseHp(target, amount, { source = null, silent = false, tags = null, from = null, sourceless = false } = {}) {
if (!target || !target.alive || !(amount > 0)) return 0; if (!target || !target.alive || !(amount > 0)) return 0;
// 限伤 (shared/constants.js BOSS_HIT_LIMIT): a loss passed on to a leader (parts' 传递, 无人机) is one hit too
if (leaderHitCancelled(this, target, amount)) return 0;
const t = ['hpLoss']; const t = ['hpLoss'];
for (const list of [from && from.tags, tags]) if (Array.isArray(list)) for (const x of list) if (!t.includes(x)) t.push(x); for (const list of [from && from.tags, tags]) if (Array.isArray(list)) for (const x of list) if (!t.includes(x)) t.push(x);
return applyHpLoss(this, source, target, amount, { type: 'true', tags: t, noSp: true, silent, origin: from ?? null, sourceless: !!(from && from.sourceless) }); return applyHpLoss(this, source, target, amount, { type: 'true', tags: t, noSp: true, silent, origin: from ?? null, sourceless: !!sourceless || !!(from && from.sourceless) });
} }
reduceElement(target, amount, element = null) { return reduceElement(target, amount, element); } reduceElement(target, amount, element = null) { return reduceElement(target, amount, element); }
@@ -1966,19 +1991,29 @@ export class Battle {
return ps.dp; return ps.dp;
} }
/** Record an IN_BATTLE layer gain (no-op when gains are disabled). Returns the layers added. */ /**
* Record an IN_BATTLE layer gain (no-op when gains are disabled). Returns the layers added: at most the room left
* under BOND_LAYER_CAP (999, shared/constants.js) on the live copy — the client's AddBondCount `min(L + n, 999)`; the
* `layerGain` hook (魔王's +1 …) runs first, then the clamp; a bond already at the cap gains 0 (no hook, no event).
* Without a live copy of the bond (a partial PlayerBattleInput) the battle's own gains count; the match's settle
* clamps the persistent count the same way.
*/
addLayers(playerId, bondId, n, reason = '', opts = {}) { addLayers(playerId, bondId, n, reason = '', opts = {}) {
if (!this.flags.layerGainsEnabled || !(n > 0) || !Number.isFinite(n) || playerId == null) return 0; if (!this.flags.layerGainsEnabled || !(n > 0) || !Number.isFinite(n) || playerId == null) return 0;
const pp = this._pp(playerId); const pp = this._pp(playerId);
if (!pp) return 0; if (!pp) return 0;
const ps = this.getPlayer(playerId);
const live = ps && ps.bonds[bondId] ? (ps.bonds[bondId].layers ?? 0) : (pp.layerGains[bondId] ?? 0);
if (!(layerGainRoom(live, Infinity) > 0)) return 0;
const source = opts.source ?? null; const source = opts.source ?? null;
const ctx = { playerId, bondId, n, reason, source, tile: Array.isArray(opts.tile) ? opts.tile : this._sourceTile(source) }; const ctx = { playerId, bondId, n, reason, source, tile: Array.isArray(opts.tile) ? opts.tile : this._sourceTile(source) };
if (this._hooks.layerGain) { this.emit('layerGain', ctx); if (!(ctx.n > 0) || !Number.isFinite(ctx.n)) return 0; } if (this._hooks.layerGain) { this.emit('layerGain', ctx); if (!(ctx.n > 0) || !Number.isFinite(ctx.n)) return 0; }
pp.layerGains[bondId] = (pp.layerGains[bondId] ?? 0) + ctx.n; const add = layerGainRoom(live, ctx.n);
const ps = this.getPlayer(playerId); if (!(add > 0)) return 0;
if (ps && ps.bonds[bondId]) ps.bonds[bondId].layers = (ps.bonds[bondId].layers ?? 0) + ctx.n; pp.layerGains[bondId] = (pp.layerGains[bondId] ?? 0) + add;
this._ev(['layer', playerId, bondId, ctx.n]); if (ps && ps.bonds[bondId]) ps.bonds[bondId].layers = (ps.bonds[bondId].layers ?? 0) + add;
return ctx.n; this._ev(['layer', playerId, bondId, add]);
return add;
} }
/** /**
+14
View File
@@ -200,3 +200,17 @@ export const BOSS_ROW_OFFSET = -7;
* #6 item 5). With the rule a pool always reads 0 or at least 1 HP: 0 on the HUD = the leader is down. * #6 item 5). With the rule a pool always reads 0 or at least 1 HP: 0 on the HUD = the leader is down.
*/ */
export const BOSS_POOL_MIN_HP = 1; export const BOSS_POOL_MIN_HP = 1;
/**
* How the percentage attribute bonuses (ATK / DEF / max HP "+X%") of the 卫戍 systems combine: 盟约, 策略, 装备, the
* 机变 cards and the per-layer 特质 (garrisons' 每叠加N层 …+X%). Official: PRTS 卫戍协议:盟约 下半/PRTS盟约记录 "盟约效果,
* 策略效果,装备效果提供的属性加成均为直接乘算"; 直接乘算 is the modifier class whose values are SUMMED with every other
* 直接乘算 — a skill's "攻击力+X%" included — before multiplying (PRTS 游戏数据基础 属性基本公式 "直接乘算结果 D_t = t₁ + t₂ +
* … + tₙ", A = (A₀ + D_p)(1 + D_t); PRTS 作战机制 X₂ = X₁(1 + b₁% + b₂%)). 'add' = that rule: the engine's additive
* atkPct / defPct / hpPct (content/support directMods). 'multiply' = the v2.5 reading (each source its own ×(1 + x)
* atkMul / defMul / hpMul), which compounded: at 600 精准 layers a ranged operator with four more such bonuses dealt
* ×74 instead of ×12 and Final Assault leaders fell in seconds (user report after playtest #6). "提升至X%/X倍" effects
* (炎佑 ×1.5, 攻击海怪敌人时攻击力提升至150%) and the char_attribute_mul 特质 (+20 % / +25 % on the chess itself, a rune on
* the base attributes) stay multipliers.
*/
export const DIRECT_BONUS_STACKING = 'add';
+10 -14
View File
@@ -4,8 +4,8 @@
// (numbers from the blackboard, never hard-coded). Band buffs apply in every battle kind (normal / 联防 / boss / // (numbers from the blackboard, never hard-coded). Band buffs apply in every battle kind (normal / 联防 / boss /
// hidden); IN_BATTLE layer gains are no-ops where the match disables them (support.gainLayers). // hidden); IN_BATTLE layer gains are no-ops where the match disables them (support.gainLayers).
// //
// act1autochess_band2_buff 阿米娅 众志合一 ≥ value_i active bonds (highest i) ⇒ every operator ATK × (1 + atk_i), // act1autochess_band2_buff 阿米娅 众志合一 ≥ value_i active bonds (highest i) ⇒ every operator ATK +atk_i,
// HP × (1 + max_hp_i) (直接乘算, bonds snapshot at combat start) // HP +max_hp_i (直接乘算 — support directMods —, bonds snapshot at combat start)
// act1autochess_band28_buff 埃芒加德 命结之秘 the first max_respawn_cnt operator knock-downs of the battle revive at // act1autochess_band28_buff 埃芒加德 命结之秘 the first max_respawn_cnt operator knock-downs of the battle revive at
// once at full HP (runs after every other death saver, items included) // once at full HP (runs after every other death saver, items included)
// act1autochess_band13_buff 克莱门莎 崇高牺牲 a <bond_id> operator knocked down ⇒ +its tier (等阶) <bond_id> layers // act1autochess_band13_buff 克莱门莎 崇高牺牲 a <bond_id> operator knocked down ⇒ +its tier (等阶) <bond_id> layers
@@ -18,13 +18,13 @@
// act1autochess_band18_buff 休谟斯 回收利用 a ground (地面) operator's skill ends ⇒ a random operator on its 4 // act1autochess_band18_buff 休谟斯 回收利用 a ground (地面) operator's skill ends ⇒ a random operator on its 4
// neighbouring tiles gains `sp` SP // neighbouring tiles gains `sp` SP
// act1autochess_band15_buff 卡莱莎 食腐之蝶 an operator knocked down ⇒ every other unit of the player on the field // act1autochess_band15_buff 卡莱莎 食腐之蝶 an operator knocked down ⇒ every other unit of the player on the field
// ATK × (1 + atk × n), n ≤ max_stack_cnt (+20 % … +200 %, additive stacks), // ATK +atk × n, n ≤ max_stack_cnt (+20 % … +200 %, additive stacks),
// until that unit is knocked down itself or the battle ends // until that unit is knocked down itself or the battle ends
// act1autochess_band19_buff 陈 以己之长 the player's operators' physical / arts damage becomes 弱点伤害 // act1autochess_band19_buff 陈 以己之长 the player's operators' physical / arts damage becomes 弱点伤害
// act2autochess_band12_buff 夕 墨色真颜 at combat start operators of which ≥ 2 of the same name (same base chess, // act2autochess_band12_buff 夕 墨色真颜 at combat start operators of which ≥ 2 of the same name (same base chess,
// elite or not) stand on the field get ATK × (1 + atk) // elite or not) stand on the field get ATK +atk
// act2autochess_band3_buff 伊奥莱塔 统御号令 at combat start n = elite operators on the field ⇒ every elite operator // act2autochess_band3_buff 伊奥莱塔 统御号令 at combat start n = elite operators on the field ⇒ every elite operator
// ATK × (1 + atk_per_cnt × n), HP × (1 + max_hp_per_cnt × n) // ATK +atk_per_cnt × n, HP +max_hp_per_cnt × n (all 直接乘算)
// auto_chess_change_map Touch 外勤医疗 "所有玩家场地上出现一名<预备干员-医疗>": while any alive player of the match // auto_chess_change_map Touch 外勤医疗 "所有玩家场地上出现一名<预备干员-医疗>": while any alive player of the match
// holds band_amedic, every player's field gets the map character of the // holds band_amedic, every player's field gets the map character of the
// variant whose common_condition holds for that player's elites on the field // variant whose common_condition holds for that player's elites on the field
@@ -34,7 +34,7 @@
import { import {
num, buffsOf, bandRecord, isOp, onField, isElite, tierOf, unitBonds, activeBondIds, playerOps, passiveBuff, fxOn, num, buffsOf, bandRecord, isOp, onField, isElite, tierOf, unitBonds, activeBondIds, playerOps, passiveBuff, fxOn,
matchBands, gainLayers, alliesAround, N4, baseChessId, isGroundOp, matchBands, gainLayers, alliesAround, N4, baseChessId, isGroundOp, directMods,
} from '../support/index.js'; } from '../support/index.js';
import { weaknessRetype, addShieldLayer, PRIO_REVIVE } from '../items/battle.js'; import { weaknessRetype, addShieldLayer, PRIO_REVIVE } from '../items/battle.js';
import { spawnMapChar } from '../tokens.js'; import { spawnMapChar } from '../tokens.js';
@@ -58,9 +58,7 @@ const BY_KEY = {
if (n >= num(p[`value_${i}`], Infinity)) { atk = num(p[`atk_${i}`]); hp = num(p[`max_hp_${i}`]); } if (n >= num(p[`value_${i}`], Infinity)) { atk = num(p[`atk_${i}`]); hp = num(p[`max_hp_${i}`]); }
} }
if (!(atk > 0 || hp > 0)) return; if (!(atk > 0 || hp > 0)) return;
const mods = {}; const mods = directMods({ atk, hp });
if (atk) mods.atkMul = 1 + atk;
if (hp) mods.hpMul = 1 + hp;
for (const u of playerOps(battle, ps.playerId)) passiveBuff(battle, u, keyOf(bandId), mods); for (const u of playerOps(battle, ps.playerId)) passiveBuff(battle, u, keyOf(bandId), mods);
}, },
@@ -157,7 +155,7 @@ const BY_KEY = {
if (a === d) continue; if (a === d) continue;
const cur = a.findBuff(key); const cur = a.findBuff(key);
const n = Math.min(cap, (cur && cur.data ? cur.data.n : 0) + 1); const n = Math.min(cap, (cur && cur.data ? cur.data.n : 0) + 1);
battle.addBuff(a, { key, mods: { atkMul: 1 + step * n }, refresh: 'replace', data: { n }, visible: true }); battle.addBuff(a, { key, mods: directMods({ atk: step * n }), refresh: 'replace', data: { n }, visible: true });
} }
}); });
}, },
@@ -184,7 +182,7 @@ const BY_KEY = {
} }
for (const list of groups.values()) { for (const list of groups.values()) {
if (list.length < 2) continue; if (list.length < 2) continue;
for (const u of list) passiveBuff(battle, u, keyOf(bandId), { atkMul: 1 + atk }); for (const u of list) passiveBuff(battle, u, keyOf(bandId), directMods({ atk }));
} }
}); });
}, },
@@ -197,9 +195,7 @@ const BY_KEY = {
const elites = deployedOps(battle, ps.playerId).filter(isElite); const elites = deployedOps(battle, ps.playerId).filter(isElite);
const n = elites.length; const n = elites.length;
if (!n) return; if (!n) return;
const mods = {}; const mods = directMods({ atk: a * n, hp: h * n });
if (a) mods.atkMul = 1 + a * n;
if (h) mods.hpMul = 1 + h * n;
for (const u of elites) passiveBuff(battle, u, keyOf(bandId), mods); for (const u of elites) passiveBuff(battle, u, keyOf(bandId), mods);
}); });
}, },
+38 -35
View File
@@ -1,33 +1,39 @@
// server/sim/content/bonds/addon/battle.js — battle side of the 15 add-on bonds (research 02 §3.9–§3.23). // server/sim/content/bonds/addon/battle.js — battle side of the 15 add-on bonds (research 02 §3.9–§3.23).
// //
// Every number comes from data/bonds.json (`env_gbuff_new` buff blackboards). Effects only apply to the owning // Every number comes from data/bonds.json (`env_gbuff_new` buff blackboards). ATK / DEF / max HP "+X%" are 直接乘算
// (support directMods: additive with every other percentage, PRTS 盟约记录). Effects only apply to the owning
// player's units (`unit.ownerId === playerId`): two players sharing a boss / 联防 field never buff each other. // player's units (`unit.ownerId === playerId`): two players sharing a boss / 联防 field never buff each other.
// Layer-dependent values read the LIVE layers (`battle.getPlayer(pid).bonds[id].layers`): an IN_BATTLE layer gain // Layer-dependent values read the LIVE layers (`battle.getPlayer(pid).bonds[id].layers`): an IN_BATTLE layer gain
// (engine `layerGain` hook, which fires before the layers are written) schedules one deferred recompute per player. // (engine `layerGain` hook, which fires before the layers are written) schedules one deferred recompute per player.
// //
// 精准 preciShip members ATK ×(1 + base_atk + atk_per_stack·L); tier 2 (3 distinct): members + RANGED operators, // 精准 preciShip members ATK +(base_atk + atk_per_stack·L); tier 2 (3 distinct): members + RANGED operators,
// which also ignore power_def_penetrate DEF / power_magic_resist_penetrate RES (defIgnorePct / resIgnorePct) // which also ignore power_def_penetrate DEF / power_magic_resist_penetrate RES (defIgnorePct / resIgnorePct)
// 迅捷 swiftShip member skill end → p = min(1, base_prob + prob_per_stack·L): +normal_sp SP; L ≥ power_bond_stack_cnt: // 迅捷 swiftShip member skill end → p = min(1, base_prob + prob_per_stack·L): +normal_sp SP; L ≥ power_bond_stack_cnt:
// every operator's skill end rolls p again for +power_sp (members roll both, research [ASSUMED]) // every operator's skill end rolls p again for +power_sp (members roll both, research [ASSUMED])
// 灵巧 skillfulShip aura: members + operators on their 4 (L ≥ 40: 8) adjacent tiles ASPD +(base + per·L), once per unit // 灵巧 skillfulShip aura: members + operators on their 4 (L ≥ 40: 8) adjacent tiles ASPD +(base + per·L), once per unit
// 奥术 arcaneShip member arts damage → target arts taken ×(base + per·L) for weak_duration s (one refreshing instance // 奥术 arcaneShip member arts damage → target arts taken ×(base + per·L) for weak_duration s; tier 2: ×power_weak_scale
// per player); tier 2: ×power_weak_scale when the target is below hp_ratio at application // when the target is below hp_ratio at application. ONE instance per target whatever applies it — the
// 坚守 steadShip all operators max HP ×(1 + base + per·L); tier 2: 40 % of a non-member operator's damage is borne by // two players of a pair field compete for it, the strongest wins (battle.applyStrongest, 同名效果取最高:
// PRTS 作战机制 "同名buff的默认叠加策略buff只能表现出一个"; 巴哈姆特 12316 first-hand: "共享型buff會跟對面搶
// 如果對面層數比你高就不需要再特別激活直接吃他的奧術buff"). v2.5 kept one per player, so two players'
// instances multiplied (×5.4 × ×5.6 on a leader at ~250 layers, DESIGN §20.10)
// 坚守 steadShip all operators max HP +(base + per·L); tier 2: 40 % of a non-member operator's damage is borne by
// the members on the field (split evenly, sourceless true damage — already mitigated), members’ thorns // the members on the field (split evenly, sourceless true damage — already mitigated), members’ thorns
// (base + per·L arts, sourceless, ≤ 1 per cd_duration per member) + 脆弱 ×damage_scale for weak[limit] s // (base + per·L arts, sourceless but credited to the member hit, ≤ 1 per cd_duration per member)
// 助力 deputShip all operators DEF ×(1 + base + per·L), redeploy time ×(1 + respawn_time) // + 脆弱 ×damage_scale for weak[limit] s
// 助力 deputShip all operators DEF +(base + per·L), redeploy time ×(1 + respawn_time)
// 突袭 raidShip member idle ≥ no_attack_duration s (or skill ready) with no enemy in range → "保留技力立即再部署" // 突袭 raidShip member idle ≥ no_attack_duration s (or skill ready) with no enemy in range → "保留技力立即再部署"
// next to the most advanced ground enemy: a real redeployment (retreat + free redeploy on the // next to the most advanced ground enemy: a real redeployment (retreat + free redeploy on the
// landing tile, full HP, `deploy` fires — 部署时 traits such as 史尔特尔, 突袭手雷, 卡西米尔, 叙拉古) // landing tile, full HP, `deploy` fires — 部署时 traits such as 史尔特尔, 突袭手雷, 卡西米尔, 叙拉古)
// with its SP / charges kept (engine redeploy tile + keepSp); ATK/HP ×(1 + base + per·L) // with its SP / charges kept (engine redeploy tile + keepSp); ATK/HP +(base + per·L)
// until it leaves the field; later redeploys use its board tile again; // until it leaves the field; later redeploys use its board tile again;
// L ≥ power_bond_stack_cnt: every operator ASPD +power_attack_speed // L ≥ power_bond_stack_cnt: every operator ASPD +power_attack_speed
// 不屈 indomShip ground operator knocked out → p = min(1, base + per·L) immediate free redeploy; tier 2: every // 不屈 indomShip ground operator knocked out → p = min(1, base + per·L) immediate free redeploy; tier 2: every
// operator on the field +sp SP // operator on the field +sp SP
// 协防 emptyShip all operators phys/arts taken ×(1 − damage_resistance); members dealt ×damage_scale_normal // 协防 emptyShip all operators phys/arts taken ×(1 − damage_resistance); members dealt ×damage_scale_normal
// (elite ×damage_scale_extra) // (elite ×damage_scale_extra)
// 独行 soloShip the member(s) ATK ×(1 + atk), HP ×(1 + max_hp), +sp SP on every deploy // 独行 soloShip the member(s) ATK +atk, HP +max_hp, +sp SP on every deploy
// 绝技 suntShip tier 1: elite operators ATK ×(1 + power_atk); tier 2: elites (and their summons) SP cost ×sp_ratio // 绝技 suntShip tier 1: elite operators ATK +power_atk; tier 2: elites (and their summons) SP cost ×sp_ratio
// 远见 / 奇迹 / 投资人 / 调和 have no battle effect (prep side, membership in support/index.js). // 远见 / 奇迹 / 投资人 / 调和 have no battle effect (prep side, membership in support/index.js).
// //
// Hooks registered only when a bond needs them. Priorities: `hit` −20 (坚守 redirect, after other modifiers had their // Hooks registered only when a bond needs them. Priorities: `hit` −20 (坚守 redirect, after other modifiers had their
@@ -37,7 +43,7 @@ import { absoluteRangeKeys, canTargetEnemy } from '../../../targeting.js';
import { localOrder, localBefore } from '../../../dir.js'; import { localOrder, localBefore } from '../../../dir.js';
import { import {
num, bondRecord, buffParams, bondTier, bondLayers, isMember, isElite, isGroundOp, onField, playerOps, passiveBuff, num, bondRecord, buffParams, bondTier, bondLayers, isMember, isElite, isGroundOp, onField, playerOps, passiveBuff,
fxOn, N4, N8, bodyInKeys, fxOn, N4, N8, bodyInKeys, directMods,
} from '../../support/index.js'; } from '../../support/index.js';
export const ID = Object.freeze({ export const ID = Object.freeze({
@@ -56,6 +62,9 @@ const AURA_POLL = 0.25;
const RAID_POLL = 0.25; const RAID_POLL = 0.25;
const RAID_SEARCH = 2; // landing tiles within this Chebyshev distance of the target enemy const RAID_SEARCH = 2; // landing tiles within this Chebyshev distance of the target enemy
/** 奥术 vulnerability mods for a multiplier (battle.applyStrongest). */
const arcaneMods = (v) => ({ artsTakenMul: v });
/** `env_gbuff_new` blackboard of a bond (numbers only). */ /** `env_gbuff_new` blackboard of a bond (numbers only). */
export function bondBb(bondId) { export function bondBb(bondId) {
return buffParams(bondRecord(bondId), 'env_gbuff_new') ?? {}; return buffParams(bondRecord(bondId), 'env_gbuff_new') ?? {};
@@ -90,7 +99,7 @@ function applyPassives(battle, st) {
if (t[ID.preci]) { if (t[ID.preci]) {
const bb = st.bb[ID.preci]; const bb = st.bb[ID.preci];
const wide = t[ID.preci] >= 2; const wide = t[ID.preci] >= 2;
const mods = { atkMul: 1 + num(bb.base_atk) + num(bb.atk_per_stack) * L(battle, st, ID.preci) }; const mods = directMods({ atk: num(bb.base_atk) + num(bb.atk_per_stack) * L(battle, st, ID.preci) });
if (wide) { mods.defIgnorePct = num(bb.power_def_penetrate); mods.resIgnorePct = num(bb.power_magic_resist_penetrate); } if (wide) { mods.defIgnorePct = num(bb.power_def_penetrate); mods.resIgnorePct = num(bb.power_magic_resist_penetrate); }
for (const u of st.ops) { for (const u of st.ops) {
if (st.members[ID.preci].has(u) || (wide && u.def?.position === 'RANGED')) passiveBuff(battle, u, KEY.preci, mods); if (st.members[ID.preci].has(u) || (wide && u.def?.position === 'RANGED')) passiveBuff(battle, u, KEY.preci, mods);
@@ -98,12 +107,12 @@ function applyPassives(battle, st) {
} }
if (t[ID.stead]) { if (t[ID.stead]) {
const bb = st.bb[ID.stead]; const bb = st.bb[ID.stead];
const mods = { hpMul: 1 + num(bb.base_max_hp) + num(bb.max_hp_per_stack) * L(battle, st, ID.stead) }; const mods = directMods({ hp: num(bb.base_max_hp) + num(bb.max_hp_per_stack) * L(battle, st, ID.stead) });
for (const u of st.ops) passiveBuff(battle, u, KEY.stead, mods); for (const u of st.ops) passiveBuff(battle, u, KEY.stead, mods);
} }
if (t[ID.deput]) { if (t[ID.deput]) {
const bb = st.bb[ID.deput]; const bb = st.bb[ID.deput];
const mods = { defMul: 1 + num(bb.base_def) + num(bb.def_per_stack) * L(battle, st, ID.deput), redeployMul: Math.max(0, 1 + num(bb.respawn_time)) }; const mods = directMods({ def: num(bb.base_def) + num(bb.def_per_stack) * L(battle, st, ID.deput) }, { redeployMul: Math.max(0, 1 + num(bb.respawn_time)) });
for (const u of st.ops) passiveBuff(battle, u, KEY.deput, mods); for (const u of st.ops) passiveBuff(battle, u, KEY.deput, mods);
} }
if (t[ID.raid]) { if (t[ID.raid]) {
@@ -124,8 +133,8 @@ function applyPassives(battle, st) {
if (t[ID.arcane]) { if (t[ID.arcane]) {
const bb = st.bb[ID.arcane]; const bb = st.bb[ID.arcane];
const m = num(bb.base_damage_scale, 1) + num(bb.damage_scale_per_stack) * L(battle, st, ID.arcane); const m = num(bb.base_damage_scale, 1) + num(bb.damage_scale_per_stack) * L(battle, st, ID.arcane);
st.arcaneMods = { artsTakenMul: Math.max(0, m) }; st.arcaneMul = Math.max(0, m);
st.arcaneLowMods = { artsTakenMul: Math.max(0, m * num(bb.power_weak_scale, 1)) }; st.arcaneLowMul = Math.max(0, m * num(bb.power_weak_scale, 1));
} }
if (t[ID.skillful]) { if (t[ID.skillful]) {
const bb = st.bb[ID.skillful]; const bb = st.bb[ID.skillful];
@@ -137,10 +146,10 @@ function applyPassives(battle, st) {
} }
function raidMods(bb, lv) { function raidMods(bb, lv) {
const mods = { const mods = directMods({
atkMul: 1 + num(bb.base_atk) + num(bb.atk_per_stack) * lv, atk: num(bb.base_atk) + num(bb.atk_per_stack) * lv,
hpMul: 1 + num(bb.base_max_hp) + num(bb.max_hp_per_stack) * lv, hp: num(bb.base_max_hp) + num(bb.max_hp_per_stack) * lv,
}; });
const aspd = num(bb.base_attack_speed) + num(bb.attack_speed_per_stack) * lv; const aspd = num(bb.base_attack_speed) + num(bb.attack_speed_per_stack) * lv;
if (aspd) mods.aspd = aspd; if (aspd) mods.aspd = aspd;
return mods; return mods;
@@ -161,12 +170,12 @@ function applyStatic(battle, st) {
} }
if (t[ID.solo]) { if (t[ID.solo]) {
const bb = st.bb[ID.solo]; const bb = st.bb[ID.solo];
const mods = { atkMul: 1 + num(bb.atk), hpMul: 1 + num(bb.max_hp) }; const mods = directMods({ atk: num(bb.atk), hp: num(bb.max_hp) });
for (const u of st.members[ID.solo]) passiveBuff(battle, u, KEY.solo, mods); for (const u of st.members[ID.solo]) passiveBuff(battle, u, KEY.solo, mods);
} }
if (t[ID.sunt]) { if (t[ID.sunt]) {
const bb = st.bb[ID.sunt]; const bb = st.bb[ID.sunt];
const mods = { atkMul: 1 + num(bb.power_atk) }; const mods = directMods({ atk: num(bb.power_atk) });
for (const u of st.ops) { for (const u of st.ops) {
if (!isElite(u)) continue; if (!isElite(u)) continue;
passiveBuff(battle, u, KEY.sunt, mods); passiveBuff(battle, u, KEY.sunt, mods);
@@ -438,9 +447,12 @@ export function install(battle) {
t.mem[STEAD_CD] = battle.time; t.mem[STEAD_CD] = battle.time;
const src = c.source; const src = c.source;
if (!src || src.side !== 'enemy' || !src.alive) return; if (!src || src.side !== 'enemy' || !src.alive) return;
const d = battle.makeDamage({ amount: num(bb.base_damage_value) + num(bb.damage_value_per_stack) * L(battle, st, ID.stead), type: 'arts', canDodge: false }); // "伤害来源受到(850+10×L)点法术伤害": 无来源 (no attacker multipliers, hooks see no source) but credited to the
// member hit — its player's stats and shared-pool tally count it, like an element burst (v2.5: no credit at all,
// up to 19 % of a co-op leader's pool and 88 % of a solo one went unattributed, DESIGN §20.10)
const d = battle.makeDamage({ amount: num(bb.base_damage_value) + num(bb.damage_value_per_stack) * L(battle, st, ID.stead), type: 'arts', canDodge: false, sourceless: true });
d.steadThorn = true; d.steadThorn = true;
battle.dealDamage(null, src, d); battle.dealDamage(t, src, d);
if (src.alive) battle.applyStatus(src, 'fragile', { duration: num(bb['weak[limit]'], 5), value: num(bb.damage_scale, 1) - 1, source: t }); if (src.alive) battle.applyStatus(src, 'fragile', { duration: num(bb['weak[limit]'], 5), value: num(bb.damage_scale, 1) - 1, source: t });
return; return;
} }
@@ -451,17 +463,8 @@ export function install(battle) {
if (!st || !st.tiers[ID.arcane] || !st.members[ID.arcane].has(src)) return; if (!st || !st.tiers[ID.arcane] || !st.members[ID.arcane].has(src)) return;
const bb = st.bb[ID.arcane]; const bb = st.bb[ID.arcane];
const low = st.tiers[ID.arcane] >= 2 && t.hpRatio < num(bb.hp_ratio); const low = st.tiers[ID.arcane] >= 2 && t.hpRatio < num(bb.hp_ratio);
const mods = low ? st.arcaneLowMods : st.arcaneMods; // one 奥术 instance per target, the strongest (both players of a pair field included): see the header
const key = st.arcaneKey ?? (st.arcaneKey = `${KEY.arcane}:${st.pid}`); battle.applyStrongest(t, KEY.arcane, { duration: num(bb.weak_duration, 3), value: low ? st.arcaneLowMul : st.arcaneMul, mods: arcaneMods, source: src });
const dur = num(bb.weak_duration, 3);
const b = t.findBuff(key);
if (b) {
b.timeLeft = dur;
b.duration = dur;
if (b.mods !== mods) { b.mods = mods; t.markDirty(); }
} else {
battle.addBuff(t, { key, duration: dur, mods });
}
}); });
} }
} }
+13 -12
View File
@@ -1,20 +1,21 @@
// server/sim/content/bonds/core.js — the 8 core bonds (核心盟约, research 02 §3.1–3.8), battle side + prep side. // server/sim/content/bonds/core.js — the 8 core bonds (核心盟约, research 02 §3.1–3.8), battle side + prep side.
// //
// 炎 yanShip members ATK ×(1 + base_atk + atk_per_stack·L); 6: one “炎佑” (its template ATK / max HP + 30 % // 炎 yanShip members ATK +(base_atk + atk_per_stack·L); 6: one “炎佑” (its template ATK / max HP + 30 %
// of the 炎 ATK / max HP sums at battle start — PRTS "增加…(最终加算)"); 9: two 炎佑, ATK ×atk, // of the 炎 ATK / max HP sums at battle start — PRTS "增加…(最终加算)"); 9: two 炎佑, ATK ×atk,
// damage taken ×(1 − damage_resistance) // damage taken ×(1 − damage_resistance)
// (every bond "+X%" ATK / max HP here is 直接乘算 — S.directMods, additive with the other percentages, PRTS 盟约记录)
// 萨尔贡 sargonShip member skill start → every member on the field gets an independent stack (ASPD +base_attack_speed // 萨尔贡 sargonShip member skill start → every member on the field gets an independent stack (ASPD +base_attack_speed
// for base_time + time_per_stack·L s, ≤ max_buff_stack_cnt); 6: each stack also ATK +base_atk // for base_time + time_per_stack·L s, ≤ max_buff_stack_cnt); 6: each stack also ATK +base_atk
// (additive, one atkMul); 6 + band_narant: instead lends the caster's equipment (tier ≤ // (additive, one 直接乘算 buff); 6 + band_narant: instead lends the caster's equipment (tier ≤
// filter_item_level) to the 8 surrounding operators (items.js lendItemEffects) // filter_item_level) to the 8 surrounding operators (items.js lendItemEffects)
// 维多利亚 victoriaShip members carrying equipment deal ×(base_damage_scale + damage_scale_per_stack·L); // 维多利亚 victoriaShip members carrying equipment deal ×(base_damage_scale + damage_scale_per_stack·L);
// 6: ATK +atk_normal_equip per item (+atk_golden_equip more per golden item); // 6: ATK +atk_normal_equip per item (+atk_golden_equip more per golden item; 直接乘算);
// prep: every `layer` layers → `count` item(s) from `pool` (bond_layer_added_reward_equip) // prep: every `layer` layers → `count` item(s) from `pool` (bond_layer_added_reward_equip)
// 谢拉格 kjeragShip members deal ×base_damage_scale, vs cold / frozen enemies ×(base_ex_damage_scale + // 谢拉格 kjeragShip members deal ×base_damage_scale, vs cold / frozen enemies ×(base_ex_damage_scale +
// ex_damage_scale_per_stack·L) instead; 6: cold wind (devices.js kjeragColdWind) // ex_damage_scale_per_stack·L) instead; 6: cold wind (devices.js kjeragColdWind)
// 拉特兰 lateranoShip member ammo skills start with floor(ammo × (1 + base_ammo_percent + ammo_percent_per_stack·L)); // 拉特兰 lateranoShip member ammo skills start with floor(ammo × (1 + base_ammo_percent + ammo_percent_per_stack·L));
// 6: every ammo used by a member → all members ATK +atk_per_consume (≤ max_atk_for_consume) // 6: every ammo used by a member → all members ATK +atk_per_consume (≤ max_atk_for_consume)
// 阿戈尔 egirShip members max HP ×(1 + base_max_hp + max_hp_per_stack·L); battle start devour (see devour()); // 阿戈尔 egirShip members max HP +(base_max_hp + max_hp_per_stack·L) (直接乘算); battle start devour (see devour());
// 5: the first max_free_respawn_cnt members knocked out for the first time redeploy at once (free) // 5: the first max_free_respawn_cnt members knocked out for the first time redeploy at once (free)
// 叙拉古 siracusaShip every member deployment: ASPD +(base + per·L) for (base_duration + per·L) s; 6: 隐匿 for the same // 叙拉古 siracusaShip every member deployment: ASPD +(base + per·L) for (base_duration + per·L) s; 6: 隐匿 for the same
// time, and while hidden / end_duration s after, attacks proc (PRD, nominal `prob`) base_damage + // time, and while hidden / end_duration s after, attacks proc (PRD, nominal `prob`) base_damage +
@@ -176,8 +177,8 @@ const ownTag = (dmg, tag) => !!dmg && Array.isArray(dmg.tags) && dmg.tags.includ
function installYan(battle, pid, bb, members) { function installYan(battle, pid, bb, members) {
const L = () => S.bondLayers(battle, pid, 'yanShip'); const L = () => S.bondLayers(battle, pid, 'yanShip');
const apply = () => { const apply = () => {
const mul = 1 + num(bb.base_atk) + num(bb.atk_per_stack) * L(); const mods = S.directMods({ atk: num(bb.base_atk) + num(bb.atk_per_stack) * L() });
for (const u of members) S.passiveBuff(battle, u, 'bond:yan', { atkMul: mul }); for (const u of members) S.passiveBuff(battle, u, 'bond:yan', mods);
}; };
apply(); apply();
onLayers(battle, pid, 'yanShip', apply); onLayers(battle, pid, 'yanShip', apply);
@@ -220,7 +221,7 @@ function installSargon(battle, pid, bb, members) {
if (!atkStacks) return; if (!atkStacks) return;
let n = 0; let n = 0;
for (const b of u.buffs) if (b.key === 'bond:sargon') n++; for (const b of u.buffs) if (b.key === 'bond:sargon') n++;
if (n > 0 && u.alive) battle.addBuff(u, { key: 'bond:sargon:atk', mods: { atkMul: 1 + perAtk * n } }); if (n > 0 && u.alive) battle.addBuff(u, { key: 'bond:sargon:atk', mods: S.directMods({ atk: perAtk * n }) });
else battle.removeBuff(u, 'bond:sargon:atk'); else battle.removeBuff(u, 'bond:sargon:atk');
}; };
battle.on('skillStart', ({ unit }) => { battle.on('skillStart', ({ unit }) => {
@@ -278,7 +279,7 @@ function installVictoria(battle, pid, bb, members) {
if (!ids.length) continue; if (!ids.length) continue;
let add = 0; let add = 0;
for (const id of ids) add += S.isGoldenId(id) ? golden : normal; for (const id of ids) add += S.isGoldenId(id) ? golden : normal;
if (add > 0) S.passiveBuff(battle, u, 'bond:victoria:atk', { atkMul: 1 + add }); if (add > 0) S.passiveBuff(battle, u, 'bond:victoria:atk', S.directMods({ atk: add }));
} }
} }
@@ -320,7 +321,7 @@ function installLaterano(battle, pid, bb, members) {
if (!memberSet.has(unit) || st.bonus >= cap) return; if (!memberSet.has(unit) || st.bonus >= cap) return;
st.used++; st.used++;
st.bonus = Math.min(cap, st.used * per); st.bonus = Math.min(cap, st.used * per);
for (const m of members) S.passiveBuff(battle, m, 'bond:laterano:ammo', { atkMul: 1 + st.bonus }); for (const m of members) S.passiveBuff(battle, m, 'bond:laterano:ammo', S.directMods({ atk: st.bonus }));
}); });
} }
@@ -391,8 +392,8 @@ function devour(battle, pid, bb, members) {
function installEgir(battle, pid, bb, members) { function installEgir(battle, pid, bb, members) {
const apply = () => { const apply = () => {
const mul = 1 + num(bb.base_max_hp, 0) + num(bb.max_hp_per_stack, 0) * S.bondLayers(battle, pid, 'egirShip'); const mods = S.directMods({ hp: num(bb.base_max_hp, 0) + num(bb.max_hp_per_stack, 0) * S.bondLayers(battle, pid, 'egirShip') });
for (const u of members) S.passiveBuff(battle, u, 'bond:egir', { hpMul: mul }); for (const u of members) S.passiveBuff(battle, u, 'bond:egir', mods);
}; };
apply(); apply();
onLayers(battle, pid, 'egirShip', apply); onLayers(battle, pid, 'egirShip', apply);
@@ -464,7 +465,7 @@ function installKazimierz(battle, pid, bb, members) {
const cap = num(bb.base_max_atk_when_born, 0) + num(bb.max_atk_when_born_per_stack, 0) * S.bondLayers(battle, pid, 'kazimierzShip'); const cap = num(bb.base_max_atk_when_born, 0) + num(bb.max_atk_when_born_per_stack, 0) * S.bondLayers(battle, pid, 'kazimierzShip');
const bonus = Math.max(0, Math.min(cap, st.deploys * num(bb.atk_when_born, 0))); const bonus = Math.max(0, Math.min(cap, st.deploys * num(bb.atk_when_born, 0)));
if (!(bonus > 0)) return; if (!(bonus > 0)) return;
for (const u of members) S.passiveBuff(battle, u, 'bond:kazimierz', { atkMul: 1 + bonus }); for (const u of members) S.passiveBuff(battle, u, 'bond:kazimierz', S.directMods({ atk: bonus }));
}; };
battle.on('deploy', ({ unit }) => { battle.on('deploy', ({ unit }) => {
if (!S.isOp(unit) || unit.ownerId !== pid) return; if (!S.isOp(unit) || unit.ownerId !== pid) return;
+150 -50
View File
@@ -2,8 +2,8 @@
// //
// install(battle) ensures the enemy dispatch hooks (enemies.js) and attaches a boss kit to every spawned leader / part // install(battle) ensures the enemy dispatch hooks (enemies.js) and attaches a boss kit to every spawned leader / part
// listed in BOSS_KITS. Damage dealt TO a leader routes to the shared boss pool through the engine (tag 'boss' + // listed in BOSS_KITS. Damage dealt TO a leader routes to the shared boss pool through the engine (tag 'boss' +
// sharedBoss); parts that "传递伤害" push a share of the damage they take into the pool with battle.loseHp(leader, …) // sharedBoss); parts that "传递伤害" push the damage they take into the pool with a 无来源 battle.loseHp(leader, …)
// credited to the attacker. Numbers come from the (template-overridden) talent / skill blackboards; the rest are named // credited to the attacker (PART_TRANSFER = 1, "等量"). Numbers come from the (template-overridden) talent / skill blackboards; the rest are named
// constants ([ASSUMED] or PRTS-sourced). Boss HP rules ("<20 %", "≤50 %") read the leader's HP ratio, which the engine // constants ([ASSUMED] or PRTS-sourced). Boss HP rules ("<20 %", "≤50 %") read the leader's HP ratio, which the engine
// syncs from the shared pool, so both mirrored copies switch phase together. // syncs from the shared pool, so both mirrored copies switch phase together.
// //
@@ -17,18 +17,23 @@
// (skipped if the match already spawned a second copy, or opts.bossMirror === false). // (skipped if the match already spawned a second copy, or opts.bossMirror === false).
// //
// Leaders (see each kit for details): // Leaders (see each kit for details):
// boss_1/8 假想敌:胄 random arts beam (range 8); 灭顶之灾 刺胄之弹 at the highest-ATK operator (8-hit flying shell, // boss_1/8 假想敌:胄 random arts beam (range 8); 灭顶之灾 one 刺胄之弹 at the highest-ATK operator in range (8-hit
// 3×3 stun + phys DoT on arrival); <20 %: damage ×0.5 + 2 shells; 死亡集群 drones — a drone // flying shell, 3×3 stun + phys DoT on arrival); <20 %: damage ×0.5 (no extra shell: PRTS
// killed by an operator costs the leader 2 % max HP. boss_8 adds 斩胄之剑 / 破胄之锤. // 能力修正); 死亡集群 drones — a drone that dies costs the leader 2 % of its max HP = the shared
// 斩胄之剑 / 破胄之锤 hover invulnerable next to 胄, AoE attacks; dive at the lowest-ATK operator (3×3 stun + DoT); // pool's (DRONE_LINK_BASE, [ASSUMED] reading). boss_8 adds 斩胄之剑 / 破胄之锤.
// 15 hits during the dive shoot it down: grounded 20 s, damage ×1.3, PART_TRANSFER to 胄. // 斩胄之剑 / 破胄之锤 初始模式: hover invulnerable next to 胄, AoE attacks (锤 150 % ATK). 出击模式: blink to the
// level's blink route, fly at the lowest-ATK operator (3×3 stun + DoT); 15 hits shoot it
// down (瘫痪: stun 10 s, ground unit, damage ×1.3). Either way a new 初始模式 copy with its HP
// replaces it at home. Every damage it takes costs 胄 as much (PART_TRANSFER, 无来源, 等量) —
// grounded per both texts, during the dive per the PRTS talent [ASSUMED].
// boss_2/9 假想敌:铳 unblockable; highest-DEF target in range; erosion; ASPD ramp (+80 × 5) on the same target // boss_2/9 假想敌:铳 unblockable; highest-DEF target in range; erosion; ASPD ramp (+80 × 5) on the same target
// (floor 20); 最终之罚 charge at the highest-DEF ground unit (disabled by the h07_02 override). // (floor 20); 最终之罚 charge at the highest-DEF ground unit (disabled by the h07_02 override).
// boss_9: damage ×0.2 while springs live, 盲信之誓 links (100 phys/s on the lines), 末日布道 dash. // boss_9: damage ×0.2 while springs live, 盲信之誓 links (100 phys/s on the lines), 末日布道 dash.
// 碎铳之簧 a/b/c arts barrier (absorbs arts after RES; phys ×0.1, counter phys + erosion) / element shield // 碎铳之簧 a/b/c arts barrier (absorbs arts after RES; phys ×0.1, counter phys + erosion) / element shield
// (phys+arts ×0.1, broken by its own element burst; every (spCost+1)-th attack a bouncing erosion // (phys+arts ×0.1, broken by its own element burst; every (spCost+1)-th attack a bouncing erosion
// shot) / 5-hit shield (every (spCost+1)-th attack a ten-hit combo); unblockable while shielded; // shot) / 5-hit shield (every (spCost+1)-th attack a ten-hit combo); unblockable while shielded;
// shield back 25 s after breaking; PART_TRANSFER of damage to 铳 and, split, to the other springs. // shield back 25 s after breaking; every damage taken costs 铳 as much (PART_TRANSFER, 无来源)
// and, split, the other springs.
// boss_3/10 假想敌:管 summons 余音 (dark) every 40 s (20 s < 50 %); normal attacks strike dark 余音 (their pulse hurts // boss_3/10 假想敌:管 summons 余音 (dark) every 40 s (20 s < 50 %); normal attacks strike dark 余音 (their pulse hurts
// operators); 裂管之奏 3 strikes on every dark 余音 (AoE arts + apoptosis). boss_10 + 假想敌:弦 // operators); 裂管之奏 3 strikes on every dark 余音 (AoE arts + apoptosis). boss_10 + 假想敌:弦
// (invulnerable, gold 余音, 断弦之奏 AoE + flips them dark; leaves with 管). // (invulnerable, gold 余音, 断弦之奏 AoE + flips them dark; leaves with 管).
@@ -63,8 +68,42 @@ import {
// --------------------------------------------------------------------------------------------------------------- // ---------------------------------------------------------------------------------------------------------------
// constants // constants
/** Share of the damage a grounded 剑/锤 or a 碎铳之簧 passes to its leader ("以一定比例") [ASSUMED]. */ /**
export const PART_TRANSFER = 0.5; * Share of the damage a 剑/锤 or a 碎铳之簧 takes that its leader loses: 1, 等量 — PRTS talents (“斩胄之剑” / “破胄之锤”
* "受到伤害时令假想敌:胄受到等量的无来源生命流失"; 碎铳之簧 (all three) "…令全场范围内仇恨值最高的1名假想敌:铳受到等量的无来源
* 生命流失"); the enemy data carries no ratio blackboard (the handbook's "以一定比例" is that 1). The loss is 无来源 (hooks see
* no source; the attacker keeps the credit — stats, the per-player pool tally) and follows EVERY damage taken: a
* 剑/锤 only takes damage outside its invulnerable 初始模式 (its 出击模式 dive and 【瘫痪】). The two official texts
* disagree on the dive: the PRTS talent says "受到伤害时" (any damage), the in-game handbook (data/enemies.json abilities,
* PRTS 能力) "被击落时受到伤害以一定比例传递" (only once shot down) — the dive hits passing on is [ASSUMED] (the talent
* text; ≤ 15 hits per dive). A loss passed on to a leader is one hit for 限伤 (sim/damage.js leaderHitCancelled, via
* Battle.loseHp). v2.5: 0.5 [ASSUMED], and a 剑/锤 passed damage on only while grounded (DESIGN §20.10, §20.13).
*/
export const PART_TRANSFER = 1;
/** The 斩胄之剑 / 破胄之锤 share — the same PRTS 等量 as PART_TRANSFER (kept as a name for the blade kit and its tests). */
export const BLADE_TRANSFER = PART_TRANSFER;
/** 破胄之锤 normal attack: 150 % ATK (PRTS 天赋 "普通攻击对攻击范围内的所有我方单位造成攻击力150%的物理普通伤害"; 斩胄之剑 100 %). */
const BLADE_ATK_SCALE = Object.freeze({ enemy_9015_acstmb: 1.5 });
/**
* Level branches of each part (act1autochess_h08_01 / _s): 斩胄之剑 spawns on the start of `left_hand_origin` (3,12),
* 破胄之锤 on `right_hand_origin` (3,8). `<hand>_blink` = the preset route 掷剑 blinks to (its start; PRTS "将自身路径改为
* 关卡预设路径并闪现至路径起点"), `<hand>_origin` = the route the new 初始模式 copy is summoned on ("以关卡预设路径召唤").
*/
const BLADE_HAND = Object.freeze({ enemy_9014_acstma: 'left_hand', enemy_9015_acstmb: 'right_hand' });
/**
* 死亡集群: a drone that dies (PRTS 能力修正 "该无人机单位死亡时", whoever kills it) costs 胄 `hp_ratio` (2 %) of "最大生命值"
* (PRTS 假想敌:胄 "该妖怪死亡时令假想敌:胄受到最大生命值2%的真实伤害"). WHICH max HP is not documented [ASSUMED reading]:
* 'pool' (default, = v2.5): the leader's max HP as the battle shows it — the shared pool (the engine syncs the unit's
* HP to it). It is the same HP the "自身生命值低于20%" talent reads, and that one must read the pool (the
* unit's data 600 000 is above the co-op 标准 pool 247 500 and above every solo pool, where a fixed 600 000
* would never drop below 20 %). A drone = 2 % of the leader bar in every mode and difficulty (终极 72 000).
* 'unit': the in-battle unit's data max HP (600 000 / hidden 1 200 000, 不死) — 12 000 / 24 000 per drone at every
* difficulty: 0.33 % of the 终极 bar but 19 % of the solo 标准 bar (61 875), so drones decide solo fights.
* Round 2 of the boss-HP review tried 'unit'; the review measured the solo regression, so the default is back to 'pool'.
* 限伤 (shared/constants.js BOSS_HIT_LIMIT): the loss is one hit through Battle.loseHp — it lands up to the largest pool
* today (boss_8 终极 7 200 000 → 144 000) and would be cancelled above a 14 999 950 pool (ceil(0.02 × pool) ≥ 300000).
*/
export const DRONE_LINK_BASE = 'pool';
/** 卢西恩 / 不祥幻影 AoE radius (PRTS "半径2"). */ /** 卢西恩 / 不祥幻影 AoE radius (PRTS "半径2"). */
const LUCIEN_AOE_RADIUS = 2; const LUCIEN_AOE_RADIUS = 2;
/** 余音 on-hit pulse radius (PRTS 1.6) and 合奏 radii per form (PRTS gold 0.8 / dark 1.6). */ /** 余音 on-hit pulse radius (PRTS 1.6) and 合奏 radii per form (PRTS gold 0.8 / dark 1.6). */
@@ -159,6 +198,13 @@ function toGoal(b, x, y, motion = 'WALK') {
return { motion, start: [y, x], end: best, checkpoints: [] }; return { motion, start: [y, x], end: best, checkpoints: [] };
} }
/** The extra route of a template branch's first spawn (or null). */
function branchRoute(tpl, name) {
const br = tpl && tpl.branches && tpl.branches[name];
const s = Array.isArray(br) && Array.isArray(br[0]) ? br[0][0] : null;
return (s && tpl.extraRoutes && tpl.extraRoutes[s.routeIndex]) || null;
}
/** Spawn one phase of a template branch. Returns the spawned enemies. */ /** Spawn one phase of a template branch. Returns the spawned enemies. */
function branchSpawn(b, tpl, name, phaseIdx, { fallback = null, mods = null, at = null } = {}) { function branchSpawn(b, tpl, name, phaseIdx, { fallback = null, mods = null, at = null } = {}) {
const br = tpl && tpl.branches && tpl.branches[name]; const br = tpl && tpl.branches && tpl.branches[name];
@@ -295,10 +341,10 @@ function segDist(px, py, ax, ay, bx, by) {
function kitHelm(ab, e, b, tpl) { function kitHelm(ab, e, b, tpl) {
const s1 = ab.sk['1'], s2 = ab.sk['2']; const s1 = ab.sk['1'], s2 = ab.sk['2'];
const lowRatio = T(ab, '1.hp_ratio') ?? 0, lowScale = T(ab, '1.damage_scale') ?? 1; const lowRatio = T(ab, '1.hp_ratio') ?? 0, lowScale = T(ab, '1.damage_scale') ?? 1;
const hidden = /_2$/.test(e.defId);
const P = { low: false }; const P = { low: false };
const range = () => e.base.rangeRadius || 8; const range = () => e.base.rangeRadius || 8;
const shellTargets = (b2) => opsOnly(hidden ? targetsNear(b2, e, range()) : allTargets(b2, e)); // 灭顶之灾 "触发索敌和普通攻击相同:选择攻击范围内攻击力最高的1名我方干员" (PRTS 技能, both copies)
const shellTargets = (b2) => opsOnly(targetsNear(b2, e, range()));
return [ return [
{ {
spawn(b2, e2, a, ab2) { ab2.atkType = 'arts'; }, spawn(b2, e2, a, ab2) { ab2.atkType = 'arts'; },
@@ -307,22 +353,27 @@ function kitHelm(ab, e, b, tpl) {
iv: 0.25, iv: 0.25,
tick(b2, e2) { tick(b2, e2) {
if (P.low || !(e2.hpRatio < lowRatio)) return; if (P.low || !(e2.hpRatio < lowRatio)) return;
P.low = true; // <20 %: damage taken ×0.5, extra shell P.low = true; // <20 %: damage taken ×0.5 (no extra shell: see 灭顶之灾)
b2.addBuff(e2, { key: 'boss:helmGuard', persist: true, visible: true, mods: { physTakenMul: lowScale, artsTakenMul: lowScale } }); // 物理与法术伤害降低 b2.addBuff(e2, { key: 'boss:helmGuard', persist: true, visible: true, mods: { physTakenMul: lowScale, artsTakenMul: lowScale } }); // 物理与法术伤害降低
b2.fx('phase', { x: e2.x, y: e2.y, id: e2.id, kind: 'helmLow' }); b2.fx('phase', { x: e2.x, y: e2.y, id: e2.id, kind: 'helmLow' });
}, },
}, },
s1 && { s1 && {
cd: s1.cd, icd: s1.icd, cond: (b2) => shellTargets(b2).length > 0, cd: s1.cd, icd: s1.icd, cond: (b2) => shellTargets(b2).length > 0,
fire(b2, e2) { // 【灭顶之灾】 // 【灭顶之灾】 one shell, also below 20 %: the handbook's "额外发射<刺胄之弹>" is wrong in game (PRTS 能力修正 原因 6
const n = P.low ? 2 : 1; // "描述与游戏实际表现不符合": "【灭顶之灾】不会额外发射") and no blackboard holds a second shell (skill 1 has none)
const ts = shellTargets(b2).sort((p, q) => q.s.atk - p.s.atk || aggroCmp(p, q)).slice(0, n); fire(b2, e2) {
for (const t of ts) fireShell(b2, e2, t); const t = shellTargets(b2).sort((p, q) => q.s.atk - p.s.atk || aggroCmp(p, q))[0];
if (t) fireShell(b2, e2, t);
}, },
}, },
s2 && { s2 && {
cd: s2.cd, icd: s2.icd, cd: s2.cd, icd: s2.icd,
fire(b2, e2) { // 【死亡集群】: drones; one killed by an operator costs the leader hp_ratio × max HP // 【死亡集群】: a drone on the branch route; when it dies (PRTS 能力修正 "该无人机单位死亡时", whoever kills it) the leader
// loses hp_ratio × max HP. Drone HP: `summon.hp_ratio` (隐秘核心 only) scales it [ASSUMED: the summon's max HP]; the
// `max_hp` 0.5 both copies carry is not read [ASSUMED] — no source says what it does (PRTS 技能 lists the 2 % only,
// the 妖怪 page has no summon rule, the level gives the 妖怪 no override; as an attribute key it would be +50 %).
fire(b2, e2) {
const hpMul = s2.bb['summon.hp_ratio'] > 0 ? s2.bb['summon.hp_ratio'] : null; const hpMul = s2.bb['summon.hp_ratio'] > 0 ? s2.bb['summon.hp_ratio'] : null;
const key = s2.bs.enemy_key ?? 'enemy_1005_yokai'; const key = s2.bs.enemy_key ?? 'enemy_1005_yokai';
const drones = branchSpawn(b2, tpl, s2.bs.branch_id ?? 'boss_summon_enemy', 0, { const drones = branchSpawn(b2, tpl, s2.bs.branch_id ?? 'boss_summon_enemy', 0, {
@@ -333,9 +384,10 @@ function kitHelm(ab, e, b, tpl) {
const ratio = s2.bb.hp_ratio ?? 0; const ratio = s2.bb.hp_ratio ?? 0;
for (const d of drones) attach(b2, d, [{ for (const d of drones) attach(b2, d, [{
death(c, b3) { death(c, b3) {
if (c.reason !== 'killed' || !c.killer || c.killer.side !== 'ally' || !(ratio > 0)) return; if (c.reason !== 'killed' || !(ratio > 0)) return; // a leak is no death
const boss = e2.alive ? e2 : b3.aliveEnemies().find((o) => o.isBoss && o.defId === e2.defId); const boss = e2.alive ? e2 : b3.aliveEnemies().find((o) => o.isBoss && o.defId === e2.defId);
if (boss) { b3.loseHp(boss, boss.s.maxHp * ratio, { source: c.killer }); b3.fx('beam', { x: d.x, y: d.y, from: d.id, to: boss.id, kind: 'droneLink' }); } const by = c.killer && c.killer.side === 'ally' ? c.killer : null; // credited to the killing operator
if (boss) { b3.loseHp(boss, droneLinkBase(boss) * ratio, { source: by }); b3.fx('beam', { x: d.x, y: d.y, from: d.id, to: boss.id, kind: 'droneLink' }); }
}, },
}]); }]);
}, },
@@ -343,6 +395,16 @@ function kitHelm(ab, e, b, tpl) {
]; ];
} }
/**
* Max HP the 死亡集群 drone link reads (DRONE_LINK_BASE): 'pool' = the leader's shown max HP (the shared pool), 'unit' =
* the unit's data max HP (template × spawn hpMul).
*/
export function droneLinkBase(boss, base = DRONE_LINK_BASE) {
if (base !== 'unit' || !boss.def || !(boss.def.maxHp > 0)) return boss.s.maxHp;
const m = boss.mods && Number(boss.mods.hpMul);
return boss.def.maxHp * (Number.isFinite(m) && m > 0 ? m : 1);
}
/** Launch a 刺胄之弹 from `boss` at `target`'s tile. */ /** Launch a 刺胄之弹 from `boss` at `target`'s tile. */
function fireShell(b, boss, target) { function fireShell(b, boss, target) {
const tr = target.tileR, tc = target.tileC; const tr = target.tileR, tc = target.tileC;
@@ -368,54 +430,90 @@ function kitShell(ab, e) {
}]; }];
} }
/**
* “斩胄之剑” / “破胄之锤” (PRTS 天赋 + 技能 掷剑 / 掷锤; numbers from skill 1's blackboard).
* 初始模式 ('hover'): 无敌 + 自缚 [ASSUMED: also untargetable, so operators spend no attacks on it]; AoE physical attack
* on every ally in its range (锤 150 % ATK, BLADE_ATK_SCALE).
* 掷剑 (skill 1 cd / icd, an operator on the field) → 出击模式 ('dive'): no normal attack, vulnerable; blinks to the start
* of its `<hand>_blink` route and flies at the tile of the lowest-ATK operator (无视无法选择).
* Arriving (no 【瘫痪】): `stun` s stun + `dot_damage`/s for `dot_duration` s on the 3×3 around it, then it is replaced.
* Once, after `max_hit_cnt` damage instances: 【瘫痪】 ('down') — `special_stun_duration` s, but it ends with its own
* `stun` s stun ("该晕眩结束时也会结束【瘫痪】"): ground unit, damage taken ×`damage_scale`; when it ends, replaced.
* Replaced: a new 初始模式 copy with its current HP is summoned on its `<hand>_origin` route (at home without a template),
* then it is killed (强制击杀自身; a part: no kill count, no bounty). The copy is a new unit [ASSUMED: only its HP is
* inherited] — its 掷剑 starts from the initial cooldown again, so the data's cooldown (80 s) never comes into play.
* Every damage it takes is lost by 假想敌:胄 too (BLADE_TRANSFER = PART_TRANSFER 1, 无来源, credited to the attacker).
*/
function kitBlade(ab, e, b, tpl) { function kitBlade(ab, e, b, tpl) {
const s = ab.sk['1']; const s = ab.sk['1'];
const bb = s ? s.bb : {}; const bb = s ? s.bb : {};
const hand = BLADE_HAND[e.defId];
const P = { state: 'hover', home: { x: e.x, y: e.y }, hits: 0, target: null }; const P = { state: 'hover', home: { x: e.x, y: e.y }, hits: 0, target: null };
const leader = (b2) => nearestOf(b2, e, (o) => o.isBoss && /enemy_9013_acstmk/.test(o.defId)); const leader = (b2) => nearestOf(b2, e, (o) => o.isBoss && /enemy_9013_acstmk/.test(o.defId));
const hover = (b2, on) => { const hover = (b2, on) => {
if (on) b2.addBuff(e, { key: 'boss:hover', persist: true, visible: true, flags: { invulnerable: true, untargetable: true } }); if (on) b2.addBuff(e, { key: 'boss:hover', persist: true, visible: true, flags: { invulnerable: true, untargetable: true } });
else b2.removeBuff(e, 'boss:hover'); else b2.removeBuff(e, 'boss:hover');
}; };
const replace = (b2, e2) => { // 以关卡预设路径召唤一个继承自身生命值的初始模式的 copy,随后强制击杀自身
P.state = 'gone';
e2.motion = 'FLY';
const origin = hand ? branchRoute(tpl, `${hand}_origin`) : null;
const home = [P.home.y, P.home.x];
const n = b2.spawnEnemy(e2.defId, {
route: origin || { motion: 'FLY', start: home, end: home, steps: [{ t: 'wait', s: 99999 }] }, pos: origin ? undefined : home,
tag: e2.tag, countInTotal: false, mods: e2.mods, ownerPlayerId: e2.ownerId,
});
if (n) { n.hp = Math.min(n.s.maxHp, e2.hp); b2.fx('blink', { x: n.x, y: n.y, id: n.id, fx: e2.x, fy: e2.y }); }
b2.kill(e2, null);
};
const shotDown = (b2, e2) => { // 【瘫痪】
P.state = 'down';
e2.motion = 'WALK'; // 变为地面单位(不改变寻路方式)
const stun = bb.stun ?? 0;
b2.applyStatus(e2, 'stun', { duration: stun, source: null, force: true });
b2.addBuff(e2, { key: 'boss:downed', duration: Math.min(bb.special_stun_duration ?? stun, stun), visible: true,
mods: { dmgTakenMul: bb.damage_scale ?? 1 }, onExpire: () => { if (e2.alive && P.state === 'down') replace(b2, e2); } });
b2.fx('phase', { x: e2.x, y: e2.y, id: e2.id, kind: 'shotDown' });
};
return [ return [
{ {
spawn(b2, e2) { hover(b2, true); b2.addBuff(e2, { key: 'boss:anchor', persist: true, flags: { noMove: true } }); }, spawn(b2, e2) {
hover(b2, true);
b2.addBuff(e2, { key: 'boss:anchor', persist: true, flags: { noMove: true, unblockable: true } }); // 自缚 (moved by hand) · 不可阻挡
if (BLADE_ATK_SCALE[e2.defId]) e2.profile.atkScale = BLADE_ATK_SCALE[e2.defId];
},
before(c, b2, e2) { const l = targetsNear(b2, e2, e2.base.rangeRadius || 1.6, { ranged: false }); if (l.length) c.targets = l; }, // 范围物理伤害 before(c, b2, e2) { const l = targetsNear(b2, e2, e2.base.rangeRadius || 1.6, { ranged: false }); if (l.length) c.targets = l; }, // 范围物理伤害
taken(c, b2, e2) { taken(c, b2, e2) {
if (P.state === 'dive' && ++P.hits >= (bb.max_hit_cnt ?? Infinity)) { if (c.amount > 0) { // 受到伤害时令假想敌:胄受到等量的无来源生命流失 (【瘫痪】; 出击模式 [ASSUMED], see PART_TRANSFER)
// shot down: ground unit, damage ×damage_scale, stunned special_stun_duration, then flies home
P.state = 'down';
e2.motion = 'WALK';
b2.addBuff(e2, { key: 'boss:downed', duration: bb.special_stun_duration ?? 0, visible: true, mods: { dmgTakenMul: bb.damage_scale ?? 1 },
onExpire: () => { if (e2.alive) { e2.motion = 'FLY'; P.state = 'return'; } } });
b2.applyStatus(e2, 'stun', { duration: bb.special_stun_duration ?? 0, source: null, force: true });
b2.fx('phase', { x: e2.x, y: e2.y, id: e2.id, kind: 'shotDown' });
return;
}
if (P.state === 'down' && c.source && c.source.side === 'ally' && c.amount > 0) {
const L = leader(b2); const L = leader(b2);
if (L) b2.loseHp(L, c.amount * PART_TRANSFER, { source: c.source, from: c.dmg }); // 被击落时受到伤害以一定比例传递给假想敌:胄 const src = c.source || c.credit; // a 无来源 burst still passes on (credited)
if (L) b2.loseHp(L, c.amount * BLADE_TRANSFER, { source: src && src.side === 'ally' ? src : null, from: c.dmg, sourceless: true });
} }
if (P.state === 'dive' && e2.alive && ++P.hits >= (bb.max_hit_cnt ?? Infinity)) shotDown(b2, e2); // 仅1次
}, },
tick(b2, e2, a, dt) { tick(b2, e2, a, dt) {
const sp = e2.s.moveSpeed * MOVE_SCALE * dt; if (P.state !== 'dive' || !canCast(e2)) return; // a stunned blade does not fly on
if (P.state === 'dive') { const t = P.target;
const t = P.target; if (!stepToward(e2, t.c, t.r, e2.s.moveSpeed * MOVE_SCALE * dt)) return;
if (!stepToward(e2, t.c, t.r, sp)) return; // arrival: 3×3 stun + DoT (无来源: credited to 胄, as the shell's), then the 初始模式 copy takes over
stunBlast(b2, e2, t.r, t.c, bb.stun ?? 0, bb.dot_damage ?? 0, bb.dot_duration ?? 0, 'bladeDive'); stunBlast(b2, leader(b2) || e2, t.r, t.c, bb.stun ?? 0, bb.dot_damage ?? 0, bb.dot_duration ?? 0, 'bladeDive');
P.state = 'return'; replace(b2, e2);
} else if (P.state === 'return') {
if (stepToward(e2, P.home.x, P.home.y, sp)) { P.state = 'hover'; hover(b2, true); }
}
}, },
}, },
s && { s && {
cd: s.cd, icd: s.icd, cond: (b2) => P.state === 'hover' && opsOnly(allTargets(b2, e)).length > 0, cd: s.cd, icd: s.icd, cond: (b2) => P.state === 'hover' && opsOnly(allTargets(b2, e)).length > 0, // 存在至少1名可选我方干员
fire(b2, e2) { // fly at the lowest-ATK operator fire(b2, e2) { // 【掷剑】/【掷锤】 → 出击模式
const t = opsOnly(allTargets(b2, e2)).sort((p, q) => p.s.atk - q.s.atk || aggroCmp(p, q))[0]; const t = opsOnly(b2.allies()).sort((p, q) => p.s.atk - q.s.atk || aggroCmp(p, q))[0];
if (!t) return; if (!t) return;
P.state = 'dive'; P.hits = 0; P.target = { r: t.tileR, c: t.tileC }; P.state = 'dive'; P.hits = 0; P.target = { r: t.tileR, c: t.tileC };
hover(b2, false); hover(b2, false);
b2.addBuff(e2, { key: 'boss:sortie', persist: true, flags: { disarm: true } }); // 出击模式:不进行普通攻击
const blink = hand ? branchRoute(tpl, `${hand}_blink`) : null;
if (blink && Array.isArray(blink.start)) {
const from = { x: e2.x, y: e2.y };
e2.x = blink.start[1]; e2.y = blink.start[0];
b2.fx('blink', { x: e2.x, y: e2.y, id: e2.id, fx: from.x, fy: from.y });
}
b2.fx('charge', { x: e2.x, y: e2.y, id: e2.id, tx: t.tileC, ty: t.tileR, kind: 'bladeDive' }); b2.fx('charge', { x: e2.x, y: e2.y, id: e2.id, tx: t.tileC, ty: t.tileR, kind: 'bladeDive' });
}, },
}, },
@@ -545,14 +643,16 @@ function kitSpring(ab, e) {
}, },
burst(c, b) { if (kind === 'element' && P.up) drop(b); }, // 自身元素损伤爆发时,护盾消失 burst(c, b) { if (kind === 'element' && P.up) drop(b); }, // 自身元素损伤爆发时,护盾消失
taken(c, b, e2) { taken(c, b, e2) {
const src = c.source || c.credit; // a 无来源 burst still passes on (credited) if (!(c.amount > 0)) return;
if (!src || src.side !== 'ally' || !(c.amount > 0)) return;
if (c.dmg.tags && c.dmg.tags.includes(SPRING_SHARE_TAG)) return; // a share never passes on again if (c.dmg.tags && c.dmg.tags.includes(SPRING_SHARE_TAG)) return; // a share never passes on again
// 【盲信之誓】 受到伤害时以一定比例传递给假想敌:铳 (and, split, to the other springs — 隐秘核心 text) const s0 = c.source || c.credit; // a 无来源 burst still passes on (credited)
const src = s0 && s0.side === 'ally' ? s0 : null;
// 【盲信之誓】 受到伤害时令假想敌:铳受到等量的无来源生命流失 (PRTS); the even split to the other springs follows the
// 隐秘核心 handbook ("传递给<假想敌:铳>和场上其他<“碎铳之簧”>") — not in the PRTS talent, [ASSUMED] amount
const g = gun(b); const g = gun(b);
if (g) b.loseHp(g, c.amount * PART_TRANSFER, { source: src, from: c.dmg }); if (g) b.loseHp(g, c.amount * PART_TRANSFER, { source: src, from: c.dmg, sourceless: true });
const others = b.enemies.filter((o) => o.alive && o !== e2 && isSpring(o)); const others = b.enemies.filter((o) => o.alive && o !== e2 && isSpring(o));
for (const o of others) b.loseHp(o, (c.amount * PART_TRANSFER) / others.length, { source: src, from: c.dmg, tags: [SPRING_SHARE_TAG] }); for (const o of others) b.loseHp(o, (c.amount * PART_TRANSFER) / others.length, { source: src, from: c.dmg, tags: [SPRING_SHARE_TAG], sourceless: true });
}, },
tick(b, e2, a, dt) { tick(b, e2, a, dt) {
if (!P.up && P.downAt != null && b.time - P.downAt >= regen) raise(b); if (!P.up && P.downAt != null && b.time - P.downAt >= regen) raise(b);
+10 -8
View File
@@ -48,16 +48,17 @@
// env_gbuff_new_with_verify act1autochess_debuff_3 {value} 自愈: every damage instance an own unit (operator or // env_gbuff_new_with_verify act1autochess_debuff_3 {value} 自愈: every damage instance an own unit (operator or
// summon) takes heals it `value` (not HP loss 流失, not gauge // summon) takes heals it `value` (not HP loss 流失, not gauge
// fills, not while a 禁疗 status is on) // fills, not while a 禁疗 status is on)
// env_gbuff_new_with_verify act1autochess_debuff_9 {atk, def} 无瑕: operators at full HP get ATK/DEF ×(1 + x) // env_gbuff_new_with_verify act1autochess_debuff_9 {atk, def} 无瑕: operators at full HP get ATK/DEF +x
// env_gbuff_new halfIdle_magic_resist_penetration {magic_resist_penetrate_fixed} 火力: operators ignore N RES // env_gbuff_new halfIdle_magic_resist_penetration {magic_resist_penetrate_fixed} 火力: operators ignore N RES
// env_gbuff_new halfIdle_def_penetration {def_penetrate} 锐利: operators ignore x DEF // env_gbuff_new halfIdle_def_penetration {def_penetrate} 锐利: operators ignore x DEF
// char_respawntime_mul {scale} 征召: operators' redeploy time × scale // char_respawntime_mul {scale} 征召: operators' redeploy time × scale
// enemy_attribute_mul / enemy_attribute_add {atk|def|magic_resistance|max_hp|attack_speed|move_speed} // enemy_attribute_mul / enemy_attribute_add {atk|def|magic_resistance|max_hp|attack_speed|move_speed}
// (+ bbStr enemy_level_type ELITE / BOSS) 排斥/责罚/裁决: enemies of the player's half (ownerId) // (+ bbStr enemy_level_type ELITE / BOSS) 排斥/责罚/裁决: enemies of the player's half (ownerId)
// of that rank get the debuff at spawn (all ranks without a type) // of that rank get the debuff at spawn (all ranks without a type)
// Attribute bonuses are 直接乘算 (support/index.js convention): ×(1 + x) multipliers; flat values stay flat. // Operator attribute bonuses are 直接乘算 (support directMods: additive with every other percentage, PRTS 盟约记录 /
// 游戏数据基础); flat values stay flat; the enemies' enemy_attribute_mul stays a multiplier.
import { gameData, num, buffsOf, passiveBuff, effectRecord } from './support/index.js'; import { gameData, num, buffsOf, passiveBuff, effectRecord, directMods } from './support/index.js';
import { isShopItem } from '../simdata.js'; import { isShopItem } from '../simdata.js';
// ===================================================================================================================== // =====================================================================================================================
@@ -305,9 +306,10 @@ export function registerMeta(registry) {
const RUNE = Object.freeze({ heal: 'act1autochess_debuff_3', flawless: 'act1autochess_debuff_9' }); const RUNE = Object.freeze({ heal: 'act1autochess_debuff_3', flawless: 'act1autochess_debuff_9' });
/** Operator mods of an env_gbuff blackboard (numbers only; 直接乘算 for attributes). */ /** Operator mods of an env_gbuff blackboard (numbers only; 直接乘算 for attributes — support directMods). */
function opModsOf(p) { function opModsOf(p) {
const mods = {}; const mods = {};
const direct = { atk: 0, def: 0, hp: 0 };
const add = (k, v) => { if (Number.isFinite(v) && v !== 0) mods[k] = (mods[k] ?? 0) + v; }; const add = (k, v) => { if (Number.isFinite(v) && v !== 0) mods[k] = (mods[k] ?? 0) + v; };
const mul = (k, v) => { if (Number.isFinite(v) && v !== 0) mods[k] = (mods[k] ?? 1) * (1 + v); }; const mul = (k, v) => { if (Number.isFinite(v) && v !== 0) mods[k] = (mods[k] ?? 1) * (1 + v); };
for (const [k, raw] of Object.entries(p || {})) { for (const [k, raw] of Object.entries(p || {})) {
@@ -318,15 +320,15 @@ function opModsOf(p) {
case 'magic_resist_penetrate': add('resIgnorePct', v); break; case 'magic_resist_penetrate': add('resIgnorePct', v); break;
case 'def_penetrate': add('defIgnorePct', v); break; case 'def_penetrate': add('defIgnorePct', v); break;
case 'def_penetrate_fixed': add('defIgnoreFlat', v); break; case 'def_penetrate_fixed': add('defIgnoreFlat', v); break;
case 'atk': mul('atkMul', v); break; case 'atk': direct.atk += v; break;
case 'def': mul('defMul', v); break; case 'def': direct.def += v; break;
case 'max_hp': mul('hpMul', v); break; case 'max_hp': direct.hp += v; break;
case 'attack_speed': add('aspd', Math.abs(v) < 1 ? v * 100 : v); break; case 'attack_speed': add('aspd', Math.abs(v) < 1 ? v * 100 : v); break;
case 'damage_scale': mul('dmgDealtMul', v); break; case 'damage_scale': mul('dmgDealtMul', v); break;
default: break; default: break;
} }
} }
return mods; return directMods(direct, mods);
} }
/** Enemy mods of an enemy_attribute_mul / _add blackboard. */ /** Enemy mods of an enemy_attribute_mul / _add blackboard. */
+6 -7
View File
@@ -21,7 +21,9 @@
// act1autochess_gar_event_addition_cnt (魔王) layerGain: a 'garrison' gain whose source stands on the tile in front // act1autochess_gar_event_addition_cnt (魔王) layerGain: a 'garrison' gain whose source stands on the tile in front
// of 魔王 (range_id 1-1) — or was knocked out there this instant (幽灵鲨 "被击倒时"; engine ctx.tile) // of 魔王 (range_id 1-1) — or was knocked out there this instant (幽灵鲨 "被击倒时"; engine ctx.tile)
// — gets +extra_cnt per bond (the extra does not count toward the source's per-battle cap). // — gets +extra_cnt per bond (the extra does not count toward the source's per-battle cap).
// stat traits GAIN_BUFF (atkMul / hpMul), attr_common_global_buff (spRecoveryFlat), attrByBond, // stat traits GAIN_BUFF (battleRuneKey char_attribute_mul "攻击力和生命值+20%": a rune on the chess's base
// attributes, so atkMul / hpMul), attr_common_global_buff (spRecoveryFlat; "+X%" ATK / HP would be
// 直接乘算), attrByBond ("每叠加N层…+X%": 直接乘算, support directMods),
// respawnTimeByBond, attrByBond_add_onstart, ab_damageScaleByBond — layer readers use // respawnTimeByBond, attrByBond_add_onstart, ab_damageScaleByBond — layer readers use
// floor(Σ layers of the listed ACTIVE bonds / divide_num) and are recomputed after every layer // floor(Σ layers of the listed ACTIVE bonds / divide_num) and are recomputed after every layer
// gain (research 02 §5 [ASSUMED] only active bonds count). // gain (research 02 §5 [ASSUMED] only active bonds count).
@@ -340,8 +342,7 @@ const INSTALLERS = {
for (const it of list) { for (const it of list) {
const mods = {}; const mods = {};
if (S.num(it.bb.sp_recovery_per_sec, 0)) mods.spRecoveryFlat = S.num(it.bb.sp_recovery_per_sec); if (S.num(it.bb.sp_recovery_per_sec, 0)) mods.spRecoveryFlat = S.num(it.bb.sp_recovery_per_sec);
if (S.num(it.bb.atk, 0)) mods.atkMul = 1 + S.num(it.bb.atk); Object.assign(mods, S.directMods({ atk: S.num(it.bb.atk, 0), hp: S.num(it.bb.max_hp, 0) }));
if (S.num(it.bb.max_hp, 0)) mods.hpMul = 1 + S.num(it.bb.max_hp);
if (S.num(it.bb.attack_speed, 0)) mods.aspd = S.num(it.bb.attack_speed); if (S.num(it.bb.attack_speed, 0)) mods.aspd = S.num(it.bb.attack_speed);
S.passiveBuff(battle, it.unit, `gar:${it.gid}`, mods); S.passiveBuff(battle, it.unit, `gar:${it.gid}`, mods);
} }
@@ -369,10 +370,8 @@ export function stepsOf(battle, it) {
} }
function attrMods(bb, k) { function attrMods(bb, k) {
const m = {}; // "每叠加N层,本干员攻击力+X%": a 直接乘算 bonus like every other "+X%" of the 卫戍 systems (support directMods)
if (S.num(bb.atk, 0)) m.atkMul = 1 + S.num(bb.atk) * k; const m = S.directMods({ atk: S.num(bb.atk, 0) * k, hp: S.num(bb.max_hp, 0) * k, def: S.num(bb.def, 0) * k });
if (S.num(bb.max_hp, 0)) m.hpMul = 1 + S.num(bb.max_hp) * k;
if (S.num(bb.def, 0)) m.defMul = 1 + S.num(bb.def) * k;
if (S.num(bb.attack_speed, 0)) m.aspd = S.num(bb.attack_speed) * k; if (S.num(bb.attack_speed, 0)) m.aspd = S.num(bb.attack_speed) * k;
if (S.num(bb.hp_recovery_per_sec, 0)) m.hpRegen = S.num(bb.hp_recovery_per_sec) * k; if (S.num(bb.hp_recovery_per_sec, 0)) m.hpRegen = S.num(bb.hp_recovery_per_sec) * k;
if (S.num(bb.sp_recovery_per_sec, 0)) m.spRecoveryFlat = S.num(bb.sp_recovery_per_sec) * k; if (S.num(bb.sp_recovery_per_sec, 0)) m.spRecoveryFlat = S.num(bb.sp_recovery_per_sec) * k;
+12 -12
View File
@@ -8,8 +8,9 @@
// Numbers always come from the concrete record's buffs (support.buffsOf: `{ ...bb, ...bbStr }` per buff, looked up by // Numbers always come from the concrete record's buffs (support.buffsOf: `{ ...bb, ...bbStr }` per buff, looked up by
// the buff's bbStr.key — the flattened `params` of data/items.json lose duplicate keys such as 蒸汽之心 attack_speed). // the buff's bbStr.key — the flattened `params` of data/items.json lose duplicate keys such as 蒸汽之心 attack_speed).
// //
// Stat rule (research 02 §2.1, PRTS "直接乘算"; research 04 §2 assumed additive %): every equipment percentage is its own // Stat rule (PRTS 盟约记录 "装备效果提供的属性加成均为直接乘算"; 直接乘算 = summed with every other percentage, PRTS 游戏数据
// multiplier on the final stat — atk/def/max_hp → atkMul/defMul/hpMul = 1 + x; attack_speed → aspd (additive); // 基础 — research 04 §2's additive reading; v2.5 had each item its own multiplier): atk/def/max_hp → support directMods
// (atkPct/defPct/hpPct += x; constants.js DIRECT_BONUS_STACKING); attack_speed → aspd (additive);
// magic_resistance → resFlat; respawn_time → redeployMul 1 + x; sp_recovery_per_sec → spRecoveryFlat; // magic_resistance → resFlat; respawn_time → redeployMul 1 + x; sp_recovery_per_sec → spRecoveryFlat;
// magic_resist_penetrate → resIgnorePct; taunt_level → taunt. Two different items on one unit both apply, and a // magic_resist_penetrate → resIgnorePct; taunt_level → taunt. Two different items on one unit both apply, and a
// normal + golden copy of the same item may coexist (different ids → different buff keys) and both apply. // normal + golden copy of the same item may coexist (different ids → different buff keys) and both apply.
@@ -33,7 +34,7 @@
import { import {
num, itemRecord, itemKeyOf, buffsOf, isOp, onField, unitBonds, isMember, bondActive, isGroundOp, frontTile, num, itemRecord, itemKeyOf, buffsOf, isOp, onField, unitBonds, isMember, bondActive, isGroundOp, frontTile,
alliesAround, passiveBuff, fxOn, battleStore, contentInfo, itemsOf, alliesAround, passiveBuff, fxOn, battleStore, contentInfo, itemsOf, directMods,
} from '../support/index.js'; } from '../support/index.js';
import { mitigate, hasHp } from '../../damage.js'; import { mitigate, hasHp } from '../../damage.js';
@@ -68,9 +69,8 @@ function statMods(p) {
const m = {}; const m = {};
let any = false; let any = false;
const set = (k, v) => { m[k] = v; any = true; }; const set = (k, v) => { m[k] = v; any = true; };
if (num(p.atk)) set('atkMul', 1 + num(p.atk)); // ATK / DEF / max HP "+X%" are 直接乘算 (support directMods: additive with every other percentage)
if (num(p.def)) set('defMul', 1 + num(p.def)); for (const [k, v] of Object.entries(directMods({ atk: num(p.atk), def: num(p.def), hp: num(p.max_hp) ? Math.max(-0.99, num(p.max_hp)) : 0 }))) set(k, v);
if (num(p.max_hp)) set('hpMul', Math.max(0.01, 1 + num(p.max_hp)));
if (num(p.attack_speed)) set('aspd', num(p.attack_speed)); if (num(p.attack_speed)) set('aspd', num(p.attack_speed));
if (num(p.magic_resistance)) set('resFlat', num(p.magic_resistance)); if (num(p.magic_resistance)) set('resFlat', num(p.magic_resistance));
if (num(p.respawn_time)) set('redeployMul', Math.max(0, 1 + num(p.respawn_time))); if (num(p.respawn_time)) set('redeployMul', Math.max(0, 1 + num(p.respawn_time)));
@@ -366,7 +366,7 @@ const BY_ITEM = {
if (Math.hypot(c.target.x - u.x, c.target.y - u.y) >= r - 1e-6) c.dmg.mul *= sc; if (Math.hypot(c.target.x - u.x, c.target.y - u.y) >= r - 1e-6) c.dmg.mul *= sc;
}); });
}, },
// 炎国短刀: each skill activation +atk (≤ atk_buff_cnt stacks) — one multiplier 1 + atk × stacks // 炎国短刀: each skill activation +atk (≤ atk_buff_cnt stacks) — one 直接乘算 bonus of atk × stacks
chess_item_3_04_e(battle, u, rec, S) { chess_item_3_04_e(battle, u, rec, S) {
const p = bp(rec, 'act1vautochess_equip_acarm024_global_buff'); const p = bp(rec, 'act1vautochess_equip_acarm024_global_buff');
if (!p) return; if (!p) return;
@@ -375,7 +375,7 @@ const BY_ITEM = {
S.on('skillStart', (c) => { S.on('skillStart', (c) => {
if (c.unit !== u || n >= cap || !chance(battle, num(p.prob, 1))) return; if (c.unit !== u || n >= cap || !chance(battle, num(p.prob, 1))) return;
n++; n++;
S.stat('stacks', { atkMul: 1 + num(p.atk) * n }); S.stat('stacks', directMods({ atk: num(p.atk) * n }));
}); });
}, },
// 迅捷作战粮: on deploy SP += sp_each_person × (1 + other operators sharing a bond) // 迅捷作战粮: on deploy SP += sp_each_person × (1 + other operators sharing a bond)
@@ -401,7 +401,7 @@ const BY_ITEM = {
chess_item_3_06_e(battle, u, rec, S) { chess_item_3_06_e(battle, u, rec, S) {
const p = bp(rec, 'act1autochess_equip_acarm049_global_buff'); const p = bp(rec, 'act1autochess_equip_acarm049_global_buff');
if (!p) return; if (!p) return;
if (num(p.init_max_hp)) S.stat('hp', { hpMul: 1 + num(p.init_max_hp) }); if (num(p.init_max_hp)) S.stat('hp', directMods({ hp: num(p.init_max_hp) }));
const ex = num(p.ex_max_hp); const ex = num(p.ex_max_hp);
if (!ex) return; if (!ex) return;
const key = S.key('front'); const key = S.key('front');
@@ -413,7 +413,7 @@ const BY_ITEM = {
const here = initial ? a.homeR === fr && a.homeC === fc : onField(a) && a.tileR === fr && a.tileC === fc; const here = initial ? a.homeR === fr && a.homeC === fc : onField(a) && a.tileR === fr && a.tileC === fc;
if (here) { occupied = true; break; } if (here) { occupied = true; break; }
} }
if (!occupied) S.buff(u, { key, mods: { hpMul: 1 + ex }, refresh: 'replace' }); if (!occupied) S.buff(u, { key, mods: directMods({ hp: ex }), refresh: 'replace' });
else battle.removeBuff(u, key); else battle.removeBuff(u, key);
}; };
S.on('deploy', (c) => { if (c.unit === u) check(!!c.initial); }); S.on('deploy', (c) => { if (c.unit === u) check(!!c.initial); });
@@ -675,7 +675,7 @@ const BY_ITEM = {
if (partner && carries(battle, u, partner) && c.skill && c.skill.isTimed) { if (partner && carries(battle, u, partner) && c.skill && c.skill.isTimed) {
combo = true; combo = true;
doomed = false; doomed = false;
S.buff(u, { key: comboKey, mods: { atkMul: 1 + num(p.atk) } }); S.buff(u, { key: comboKey, mods: directMods({ atk: num(p.atk) }) });
} }
}); });
S.on('skillEnd', (c) => { S.on('skillEnd', (c) => {
@@ -715,7 +715,7 @@ const BY_ITEM = {
const stealth = !!u.s.flags.stealth; const stealth = !!u.s.flags.stealth;
if (stealth && st.bonus < max) { if (stealth && st.bonus < max) {
st.bonus = Math.min(max, st.bonus + per * TICKS); st.bonus = Math.min(max, st.bonus + per * TICKS);
S.buff(u, { key, mods: { atkMul: 1 + st.bonus }, refresh: 'replace' }); S.buff(u, { key, mods: directMods({ atk: st.bonus }), refresh: 'replace' });
} }
if (st.wasStealth && !stealth && st.bonus > 0) st.armed = true; if (st.wasStealth && !stealth && st.bonus > 0) st.armed = true;
st.wasStealth = stealth; st.wasStealth = stealth;
+8 -5
View File
@@ -109,6 +109,8 @@ function inspire(battle, target, val, src, stat = 'atk') {
battle.addBuff(target, { key, mods: stat === 'hp' ? { hpFlat: flat } : { atkFlat: flat }, duration: AURA_DUR, source: src, visible: true, data: { src: src.id, val } }); battle.addBuff(target, { key, mods: stat === 'hp' ? { hpFlat: flat } : { atkFlat: flat }, duration: AURA_DUR, source: src, visible: true, data: { src: src.id, val } });
} }
/** Reveal stealthed enemies on the given tiles. */ /** Reveal stealthed enemies on the given tiles. */
/** RES cut mods for a blackboard magic_resistance value (battle.applyStrongest): |v| < 1 = ×(1 + v), else flat v. */
const resCut = (v) => (Math.abs(v) < 1 ? { resMul: Math.max(0, 1 + v) } : { resFlat: v });
const reveal = (battle, enemies) => { for (const e of enemies) if (e.s.flags.stealth) pulse(battle, e, 'aura:reveal', null, { flags: { reveal: true } }); }; const reveal = (battle, enemies) => { for (const e of enemies) if (e.s.flags.stealth) pulse(battle, e, 'aura:reveal', null, { flags: { reveal: true } }); };
/** /**
* Knock-back of 力度 `force` away from `from` (a radial push, Battle.push: the official 力度 − 重量 distance — PRTS 推与拉; * Knock-back of 力度 `force` away from `from` (a radial push, Battle.push: the official 力度 − 重量 distance — PRTS 推与拉;
@@ -1077,7 +1079,8 @@ const kits = {
for (const e of enemiesOnRange(battle, unit)) { for (const e of enemiesOnRange(battle, unit)) {
const f = e.s.flags; const f = e.s.flags;
const m = f.freeze ? num(t0.damage_scale_freeze, 1.5) : f.cold ? num(t0.damage_scale_cold, 1.25) : 0; const m = f.freeze ? num(t0.damage_scale_freeze, 1.5) : f.cold ? num(t0.damage_scale_cold, 1.25) : 0;
if (m) battle.addBuff(e, { key: `gnosis:fragile:${unit.id}`, mods: { dmgTakenMul: m }, duration: 0.15 }); // 同名效果取最高: one 坚冰 per enemy, the strongest — two 灵知 (a pair / 联防 partner's copy) never compound
if (m) battle.applyStrongest(e, 'gnosis:fragile', { duration: 0.15, value: m, mods: (v) => ({ dmgTakenMul: v }), source: unit });
} }
}); });
} }, } },
@@ -1105,10 +1108,10 @@ const kits = {
kind: (def.skill?.maxCharges ?? 1) > 1 ? 'charges' : 'instant', kind: (def.skill?.maxCharges ?? 1) > 1 ? 'charges' : 'instant',
targeting: g ? { rangeGrid: g } : undefined, targeting: g ? { rangeGrid: g } : undefined,
onStart({ battle, unit }) { onStart({ battle, unit }) {
const debuff = Math.abs(mr) < 1 ? { resMul: Math.max(0, 1 + mr) } : { resFlat: mr };
for (const e of targetsInRange(battle, unit)) { for (const e of targetsInRange(battle, unit)) {
battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.atk_scale, 1.7), type: 'arts', isSkill: true, tags: ['skill', 'burst'] }); battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.atk_scale, 1.7), type: 'arts', isSkill: true, tags: ['skill', 'burst'] });
if (e.alive && mr) battle.addBuff(e, { key: `lionhd:res:${unit.id}`, mods: debuff, duration: num(bb.duration, 6), source: unit }); // 同名效果取最高: one RES cut per enemy (the strongest), never one per 莱恩哈特
if (e.alive && mr) battle.applyStrongest(e, 'lionhd:res', { duration: num(bb.duration, 6), value: mr, mods: resCut, source: unit });
} }
battle.fx('explosion', { x: unit.x, y: unit.y, id: unit.id }); battle.fx('explosion', { x: unit.x, y: unit.y, id: unit.id });
}, },
@@ -1179,10 +1182,10 @@ const kits = {
talentAtk(battle, unit); talentAtk(battle, unit);
battle.addBuff(unit, { key: 'texas2:shower', duration: dur, mods: { atkPct: num(bb.atk) }, visible: true }); battle.addBuff(unit, { key: 'texas2:shower', duration: dur, mods: { atkPct: num(bb.atk) }, visible: true });
const mr = num(bb.magic_resistance, 0); const mr = num(bb.magic_resistance, 0);
const debuff = Math.abs(mr) < 1 ? { resMul: Math.max(0, 1 + mr) } : { resFlat: mr };
for (const e of targetsInGrid(battle, unit, g)) { for (const e of targetsInGrid(battle, unit, g)) {
battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.atk_scale, 1.5), type: 'arts', isSkill: true, tags: ['skill', 'burst'] }); battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.atk_scale, 1.5), type: 'arts', isSkill: true, tags: ['skill', 'burst'] });
if (e.alive && mr) battle.addBuff(e, { key: `texas2:resDown:${unit.id}`, mods: debuff, duration: num(bb.debuff_duration, 8), source: unit }); // 同名效果取最高: one RES cut per enemy (the strongest), never one per 缄默德克萨斯
if (e.alive && mr) battle.applyStrongest(e, 'texas2:resDown', { duration: num(bb.debuff_duration, 8), value: mr, mods: resCut, source: unit });
} }
battle.fx('swordStorm', { x: unit.x, y: unit.y, id: unit.id }); battle.fx('swordStorm', { x: unit.x, y: unit.y, id: unit.id });
}; };
+25 -4
View File
@@ -5,9 +5,11 @@
// never edit it; module-specific helpers live in the submodules. // never edit it; module-specific helpers live in the submodules.
// //
// Conventions shared by every wave-B module (details in docs/CONTENT.md): // Conventions shared by every wave-B module (details in docs/CONTENT.md):
// * Attribute bonuses from bonds, bands, equipment and 机变 cards are "直接乘算" (research 02 §2.1): each source is its // * Attribute bonuses ("+X%" ATK / DEF / max HP) from bonds, bands, equipment, 机变 cards and the per-layer 特质 are
// own multiplier on the final stat → `atkMul` / `defMul` / `hpMul` (1 + x), never the skill-style `atkPct`. // "直接乘算" (PRTS 盟约记录; PRTS 游戏数据基础: 直接乘算 values are summed with every other 直接乘算, a skill's "攻击力+X%"
// ASPD stays additive (`aspd`), damage bonuses are `dmgDealtMul`, redeploy changes `redeployMul`. // included) → `directMods({ atk, def, hp })` = the additive `atkPct` / `defPct` / `hpPct` (constants.js
// DIRECT_BONUS_STACKING; 'multiply' restores the v2.5 per-source ×(1 + x) `atkMul`). "提升至X%" effects stay
// multipliers. ASPD stays additive (`aspd`), damage bonuses are `dmgDealtMul`, redeploy changes `redeployMul`.
// * Match-long passives use `passiveBuff()` (persist + allowDead + replace): idempotent by key, survive death. // * Match-long passives use `passiveBuff()` (persist + allowDead + replace): idempotent by key, survive death.
// * Buff keys / fx `src` are namespaced: `bond:<bondId>…`, `item:<itemKey>…`, `gar:<effectKey>…`, `band:<bandId>…`, // * Buff keys / fx `src` are namespaced: `bond:<bondId>…`, `item:<itemKey>…`, `gar:<effectKey>…`, `band:<bandId>…`,
// `choice:<effectId>…`. // `choice:<effectId>…`.
@@ -16,7 +18,7 @@
// `PlayerBattleInput.contentInfo`, written by the `global:contentb_info` meta handler (support/meta.js). // `PlayerBattleInput.contentInfo`, written by the `global:contentb_info` meta handler (support/meta.js).
import { getData } from '../../../data.js'; import { getData } from '../../../data.js';
import { COLS } from '../../constants.js'; import { COLS, DIRECT_BONUS_STACKING } from '../../constants.js';
import { frontOf, offsetTile } from '../../dir.js'; import { frontOf, offsetTile } from '../../dir.js';
import { bodyInKeys, bodyDist, bodyInRadius, bodyOnTile, bodyTileReach } from '../../body.js'; import { bodyInKeys, bodyDist, bodyInRadius, bodyOnTile, bodyTileReach } from '../../body.js';
@@ -267,6 +269,25 @@ export const inRange = (u, target) => !!(u && target && u.rangeKeySet && onKeys(
// ===================================================================================================================== // =====================================================================================================================
// battle: buffs, fx, per-battle state // battle: buffs, fx, per-battle state
/**
* Buff mods of a 直接乘算 attribute bonus (bonds, bands, equipment, 机变 cards, per-layer 特质): ratios `atk` / `def` /
* `hp` (+0.3 = "+30%"; negative lowers) → `atkPct` / `defPct` / `hpPct`, summed by the engine with every other additive
* percentage (constants.js DIRECT_BONUS_STACKING 'add', the official rule); 'multiply' → `atkMul` / `defMul` / `hpMul`
* = 1 + x (the v2.5 reading). Zero / non-finite parts are left out; `extra` mods are merged in.
*/
export function directMods({ atk = 0, def = 0, hp = 0 } = {}, extra = null) {
const m = {};
const put = (pct, mul, v) => {
if (!(typeof v === 'number' && Number.isFinite(v)) || v === 0) return;
if (DIRECT_BONUS_STACKING === 'multiply') m[mul] = Math.max(0, 1 + v);
else m[pct] = v;
};
put('atkPct', 'atkMul', atk);
put('defPct', 'defMul', def);
put('hpPct', 'hpMul', hp);
return extra ? Object.assign(m, extra) : m;
}
/** Match-long passive buff (survives death/redeploy; re-adding the same key replaces it — idempotent). */ /** Match-long passive buff (survives death/redeploy; re-adding the same key replaces it — idempotent). */
export function passiveBuff(battle, u, key, mods, extra = {}) { export function passiveBuff(battle, u, key, mods, extra = {}) {
return battle.addBuff(u, { key, mods, persist: true, allowDead: true, refresh: 'replace', ...extra }); return battle.addBuff(u, { key, mods, persist: true, allowDead: true, refresh: 'replace', ...extra });
+25
View File
@@ -3,6 +3,7 @@
// dealDamage order: (element → gauge path) | invulnerable? → 'hit' hook (mutable DamageInfo, may set cancel) // dealDamage order: (element → gauge path) | invulnerable? → 'hit' hook (mutable DamageInfo, may set cancel)
// → dodge (phys/arts, canDodge) → mitigation (phys: DEF, arts: RES, true: none) // → dodge (phys/arts, canDodge) → mitigation (phys: DEF, arts: RES, true: none)
// → × source dmgDealtMul (× phys/artsDealtMul) × target dmgTakenMul (not for 元素伤害) × type-taken mul × dmg.mul // → × source dmgDealtMul (× phys/artsDealtMul) × target dmgTakenMul (not for 元素伤害) × type-taken mul × dmg.mul
// → 限伤 (leaders in boss / hidden battles: a hit of ceil(final) ≥ BOSS_HIT_LIMIT is cancelled, see leaderHitCancelled)
// → shields (hit-negating barriers first, then HP shields) → HP loss (boss pool routing) → 'damaged' hook // → shields (hit-negating barriers first, then HP shields) → HP loss (boss pool routing) → 'damaged' hook
// → SP-on-hurt / TAKE_DAMAGE trigger → fatal/kill. // → SP-on-hurt / TAKE_DAMAGE trigger → fatal/kill.
// Phys: max(A − max(0, D×(1−defIgnorePct) − defIgnoreFlat), 5 %·A); Arts: max(A×(1 − R′/100), 5 %·A) with // Phys: max(A − max(0, D×(1−defIgnorePct) − defIgnoreFlat), 5 %·A); Arts: max(A×(1 − R′/100), 5 %·A) with
@@ -45,6 +46,7 @@
// has `hitSleep` or the damage carries `ignoreSleep`. // has `hitSleep` or the damage carries `ignoreSleep`.
import { MIN_DAMAGE_RATIO, ELEMENT, ELEMENT_ORDER, PALSY_MAX } from './constants.js'; import { MIN_DAMAGE_RATIO, ELEMENT, ELEMENT_ORDER, PALSY_MAX } from './constants.js';
import { BOSS_HIT_LIMIT } from '../../shared/constants.js';
const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v); const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v);
@@ -112,6 +114,26 @@ export function mitigate(amount, type, target, ign = {}) {
return amount; return amount;
} }
/**
* 限伤 — the official boss-hit limit (shared/constants.js BOSS_HIT_LIMIT, docs/research/11-limits-official.md §2;
* client `AutoChessStepModeManager._OnBossEnemyTakeDamage`: `d = ceil(value); if (d >= 300000) modifier.Cancel()`).
* True when the hit of `amount` HP about to land on `target` is cancelled: the target is a leader (`isBoss`: the
* tag-'boss' units — data/bosses.json enemyKey, the official IsBossEnemy list — and their mirrored copies; never a part,
* escort, drone or other minion), the battle is a boss / hidden one (the client's step mode: every boss battle outside
* training) and ceil(amount) ≥ BOSS_HIT_LIMIT. The cancel is whole (no clamp): no HP / pool loss, no credit, no damage
* number. A 'hitCap' fx event `{ id, n: ceil(amount) }` marks it for the client, which draws nothing — the official
* shows no number [ASSUMED]. Every HP-damage kind is checked as the official `modifier.isDamage` (phys, arts, true,
* 元素伤害 incl. element bursts, DoT ticks — they all come through dealDamage — and losses passed on to a leader through
* Battle.loseHp); element 损伤 (gauge fill, 'element') removes no HP and is never checked. Deterministic (Math.ceil of
* the same double on every engine).
*/
export function leaderHitCancelled(battle, target, amount) {
if (!(BOSS_HIT_LIMIT > 0) || !target || !target.isBoss || (battle.kind !== 'boss' && battle.kind !== 'hidden')) return false;
if (!(Math.ceil(amount) >= BOSS_HIT_LIMIT)) return false;
battle.fx('hitCap', { x: target.x, y: target.y, id: target.id, n: Math.ceil(amount) });
return true;
}
/** Absorb damage with shields on `target`. Returns the remaining amount. */ /** Absorb damage with shields on `target`. Returns the remaining amount. */
export function absorbShields(battle, target, amount) { export function absorbShields(battle, target, amount) {
if (amount <= 0) return 0; if (amount <= 0) return 0;
@@ -196,6 +218,9 @@ export function dealDamage(battle, source, target, dmgIn) {
mul *= type === 'phys' ? ts.physTakenMul : type === 'arts' ? ts.artsTakenMul : type === 'elemental' ? ts.elementalTakenMul : ts.trueTakenMul; mul *= type === 'phys' ? ts.physTakenMul : type === 'arts' ? ts.artsTakenMul : type === 'elemental' ? ts.elementalTakenMul : ts.trueTakenMul;
final *= mul; final *= mul;
if (!(final > 0) || !Number.isFinite(final)) final = 0; if (!(final > 0) || !Number.isFinite(final)) final = 0;
// 限伤: a leader's hit of ≥ BOSS_HIT_LIMIT in a boss / hidden battle is cancelled before it reaches shields / HP — what
// ran before it (the attack, its SP, `hit` hook effects, separate element 损伤) stays; nothing after it happens
if (final > 0 && leaderHitCancelled(battle, target, final)) return 0;
final = absorbShields(battle, target, final); final = absorbShields(battle, target, final);
return applyHpLoss(battle, source, target, final, dmg); return applyHpLoss(battle, source, target, final, dmg);
} }
+38
View File
@@ -64,6 +64,44 @@ export const PIECE_KIND = Object.freeze({ CHESS: 'chess', ITEM: 'item', TOKEN: '
*/ */
export const SKILL_SUMMON_START_DEPLOY = true; export const SKILL_SUMMON_START_DEPLOY = true;
/**
* Official per-bond layer cap (docs/research/11-limits-official.md §1): the client's
* `Torappu.Battle.AutoChessBattleConst.MAX_GARRISON_STACK = 999`, and its bond counter (`AddBondCount`) stores
* `min(L + n, 999)` — each bond stops at 999 on its own; the community reports bonds sitting at 999 while fed (巴哈姆特
* 12534 "每把都能999层", 12316 "999謝"; research 02 §layers). The only implementation of the cap (DESIGN §20.12). Every
* writer of a bond's layers goes through `layerGainRoom`: the prep-side gains (server/match/PlayerState.js addLayers —
* 特质, items, bands, 机变 cards, bonds), the settle of the in-battle gains (server/match/Match.js) and the live in-battle
* copy (server/sim/Battle.js addLayers, as the client's AddBondCount) — and the dev tools' direct writes (tools/matchrun.mjs
* --layers, tools/balance.mjs applyBoard); a gain at the cap adds 0 (no onLayers, no 'layer'
* event), and the client-result check (server/match/fields.js) bounds a reported gain by the room left. Milestones paid
* per N layers (远见, 奇迹, 维多利亚 …) stop with the count. 0 / Infinity = no cap.
*/
export const BOND_LAYER_CAP = 999;
/**
* The layers a gain of `n` actually adds to a bond holding `before` under BOND_LAYER_CAP: min(n, cap − before), never
* negative (a count already at or over the cap gains 0 and is never lowered); 0 for a non-positive / non-finite `n`
* except +Infinity (= "the room left").
*/
export function layerGainRoom(before, n) {
if (!(n > 0)) return 0;
const cap = BOND_LAYER_CAP > 0 ? BOND_LAYER_CAP : Infinity;
const b = Number.isFinite(before) && before > 0 ? before : 0;
return Math.max(0, Math.min(n, cap - b));
}
/**
* Official boss-hit limit "限伤" (docs/research/11-limits-official.md §2): `AutoChessBattleConst.MAX_BATTLE_DAMAGE =
* 300000`. In a boss battle outside training — our battle kinds 'boss' (Final Assault) and 'hidden' (Hidden Core) — a
* single hit on a leader (`AutoChessBattleUtil.IsBossEnemy`: an enemyId of activity_table autoChessData.bossInfoDict
* = data/bosses.json `enemyKey`; in the sim the tag-'boss' units: those leaders and their mirrored copies, never parts,
* escorts or drones) whose `ceil(final damage)` ≥ this is CANCELLED: 0 damage, nothing credited to the shared pool
* (`AutoChessStepModeManager._OnBossEnemyTakeDamage` → `modifier.Cancel()`). It is not a clamp: a hit of 299999 lands.
* Checked in server/sim/damage.js (dealDamage after DEF / RES and every multiplier, before shields; Battle.loseHp).
* Minions, normal rounds and 联防 are unaffected. 0 / Infinity = off.
*/
export const BOSS_HIT_LIMIT = 300000;
// Snapshot unit flag bits (DESIGN §8.2) // Snapshot unit flag bits (DESIGN §8.2)
export const UF = Object.freeze({ export const UF = Object.freeze({
BLOCKED: 1, STUNNED: 2, FROZEN: 4, STEALTH: 8, SKILL: 16, SHIELD: 32, INVULN: 64, COLD: 128, SLEEP: 256, FLYING: 512, BLOCKED: 1, STUNNED: 2, FROZEN: 4, STEALTH: 8, SKILL: 16, SHIELD: 32, INVULN: 64, COLD: 128, SLEEP: 256, FLYING: 512,
+65
View File
@@ -142,6 +142,47 @@ test('奥术: member arts damage → target arts taken ×(1.2+0.01·L) for 3 s;
checkInvariants(h2.b); checkInvariants(h2.b);
}); });
test('奥术 with two players on one field: one instance per target, the strongest wins (never multiplied); a weaker one resumes after it', () => {
// DESIGN §20.10: PRTS 作战机制 "同名buff的默认叠加策略buff只能表现出一个"; 巴哈姆特 12316 "共享型buff會跟對面搶 如果對面層數比你高
// 就不需要再特別激活直接吃他的奧術buff". v2.5 kept one instance per player: ×(1.3)·×(1.7) here.
const bb = bondBb('arcaneShip');
const caster = (id) => chessRec({ id, bonds: ['arcaneShip'], profession: 'CASTER', skill: null, rangeGrid: [[0, 0]] });
const defs = { chess: { a_1: caster('a_1'), a_2: caster('a_2'), a_x: op('a_x', []) }, enemies: DUMMY };
const h = makeBattle({
kind: 'boss', defs,
players: [
{ playerId: 'p1', seat: 0, side: 'L', colOffset: 0, units: [{ uid: 1, kind: 'chess', chessId: 'a_1', row: 12, col: 3 }], bonds: { arcaneShip: bond(1, 10) } },
{ playerId: 'p2', seat: 1, side: 'R', colOffset: 0, units: [{ uid: 2, kind: 'chess', chessId: 'a_2', row: 12, col: 3 }, { uid: 3, kind: 'chess', chessId: 'a_x', row: 11, col: 3 }], bonds: { arcaneShip: bond(1, 50) } },
],
enemies: [{ key: 'enemy_addon_dummy', pos: [9, 9] }],
});
h.step(2);
const e = h.enemy('enemy_addon_dummy');
const u1 = h.b.allyUnits.find((u) => u.defId === 'a_1'), u2 = h.b.allyUnits.find((u) => u.defId === 'a_2');
const m1 = bb.base_damage_scale + bb.damage_scale_per_stack * 10, m2 = bb.base_damage_scale + bb.damage_scale_per_stack * 50;
h.b.dealDamage(u1, e, { amount: 100, type: 'arts' });
close(e.s.artsTakenMul, m1, 'p1 alone');
h.b.dealDamage(u2, e, { amount: 100, type: 'arts' });
close(e.s.artsTakenMul, m2, 'the stronger p2 instance takes over — not m1 × m2');
h.b.dealDamage(u1, e, { amount: 100, type: 'arts' });
close(e.s.artsTakenMul, m2, 'a weaker application never overrides it');
assert.equal(e.buffs.filter((b) => String(b.key).startsWith('bond:arcaneShip')).length, 1, 'one buff on the target');
// the partner's non-member arts damage benefits too (it is a debuff on the target)
const x = h.b.allyUnits.find((u) => u.defId === 'a_x');
const hp0 = e.hp;
h.b.dealDamage(x, e, { amount: 100, type: 'arts' });
close(hp0 - e.hp, 100 * m2, 'any arts damage on the target');
// p2 stops: its instance expires at 3 s, p1's later application (≈ 3 s too) resumes for the rest
h.run(1);
h.b.dealDamage(u1, e, { amount: 100, type: 'arts' });
close(e.s.artsTakenMul, m2, 'still the stronger one');
h.run(bb.weak_duration - 1 + 0.1);
close(e.s.artsTakenMul, m1, 'the stronger expired, the weaker (applied later) resumes');
h.run(1.1);
close(e.s.artsTakenMul, 1, 'both expired');
checkInvariants(h.b);
});
test('坚守: all operators HP ×(1.25+0.012·L); tier 2 redirect 40% to members, thorns + fragile, cooldown, no loops', () => { test('坚守: all operators HP ×(1.25+0.012·L); tier 2 redirect 40% to members, thorns + fragile, cooldown, no loops', () => {
const bb = bondBb('steadShip'); const bb = bondBb('steadShip');
const defs = { chess: { d_1: op('d_1', ['steadShip']), d_2: op('d_2', ['steadShip']), d_n: op('d_n', []) }, enemies: DUMMY }; const defs = { chess: { d_1: op('d_1', ['steadShip']), d_2: op('d_2', ['steadShip']), d_n: op('d_n', []) }, enemies: DUMMY };
@@ -180,6 +221,30 @@ test('坚守: all operators HP ×(1.25+0.012·L); tier 2 redirect 40% to members
checkInvariants(h.b); checkInvariants(h.b);
}); });
test('坚守 thorns: 无来源 (no attacker bonus, hooks see no source) but credited to the member — its stats and the shared-pool tally', async () => {
// DESIGN §20.10: v2.5 dealt them with no source at all, so no player's boss damage counted them (19 % of a co-op pool)
const { SharedBossPool } = await import('../../server/match/finalAssault.js');
const bb = bondBb('steadShip');
const defs = { chess: { d_1: op('d_1', ['steadShip']), d_2: op('d_2', ['steadShip']) }, enemies: DUMMY };
const pool = new SharedBossPool(1e6);
const h = makeBattle({ defs, kind: 'boss', sharedBoss: pool, units: [{ chessId: 'd_1', row: 10, col: 3 }, { chessId: 'd_2', row: 10, col: 5 }],
bonds: { steadShip: bond(2, 10) }, enemies: [{ key: 'enemy_addon_dummy', pos: [9, 9], tag: 'boss' }], hooks: ['damaged'], captureNoisy: true });
h.step(2);
const e = h.enemy('enemy_addon_dummy'), d1 = h.unit('d_1');
assert.ok(e.bossPool, 'the leader drains the pool');
h.b.addBuff(d1, { key: 'test:rage', mods: { dmgDealtMul: 2 } });
const p0 = pool.hp, dmg0 = d1.stats.dmg;
h.b.dealDamage(e, d1, { amount: 100, type: 'true' });
const thorn = bb.base_damage_value + bb.damage_value_per_stack * 10;
close(p0 - pool.hp, thorn, 'thorns hit the pool, the member\'s ×2 does not apply');
const ev = h.hooksOf('damaged').filter((c) => c.target === e);
assert.equal(ev.length, 1);
assert.equal(ev[0].source, null, '无来源: hooks see no source');
assert.equal(ev[0].credit, d1, 'credited to the member');
close(d1.stats.dmg - dmg0, thorn, 'member stats');
close(h.result().perPlayer.p1.bossDamage, thorn, 'its player\'s shared-pool tally');
});
test('坚守 tier 2: the redirected 40 % is the already-scaled damage — the attacker’s damage bonus is not applied twice', () => { test('坚守 tier 2: the redirected 40 % is the already-scaled damage — the attacker’s damage bonus is not applied twice', () => {
const defs = { chess: { d_1: op('d_1', ['steadShip']), d_2: op('d_2', ['steadShip']), d_n: op('d_n', []) }, enemies: DUMMY }; const defs = { chess: { d_1: op('d_1', ['steadShip']), d_2: op('d_2', ['steadShip']), d_n: op('d_n', []) }, enemies: DUMMY };
const units = [{ chessId: 'd_1', row: 10, col: 3 }, { chessId: 'd_2', row: 10, col: 5 }, { chessId: 'd_n', row: 12, col: 3 }]; const units = [{ chessId: 'd_1', row: 10, col: 3 }, { chessId: 'd_2', row: 10, col: 5 }, { chessId: 'd_n', row: 12, col: 3 }];
+2 -1
View File
@@ -782,7 +782,8 @@ test('battle plans come from the blackboards; gates as documented', () => {
assert.deepEqual(gateOf(EFF('allybuff_select_14')), { kind: 'sameRow', count: 3 }); assert.deepEqual(gateOf(EFF('allybuff_select_14')), { kind: 'sameRow', count: 3 });
assert.deepEqual(gateOf(EFF('allybuff_select_11')), { kind: 'always', count: 0 }); assert.deepEqual(gateOf(EFF('allybuff_select_11')), { kind: 'always', count: 0 });
assert.equal(battlePlanOf(refOf('allybuff_select_11')).heal, 50); assert.equal(battlePlanOf(refOf('allybuff_select_11')).heal, 50);
assert.deepEqual(battlePlanOf(refOf('allybuff_select_18')).flawless, { atkMul: 1.3, defMul: 1.3 }); // 无瑕 "攻击力和防御力+30%": 直接乘算 — the additive percentage bucket (constants.js DIRECT_BONUS_STACKING)
assert.deepEqual(battlePlanOf(refOf('allybuff_select_18')).flawless, { atkPct: 0.3, defPct: 0.3 });
assert.deepEqual(battlePlanOf(refOf('allybuff_select_14')).opMods, { redeployMul: 0.5 }); assert.deepEqual(battlePlanOf(refOf('allybuff_select_14')).opMods, { redeployMul: 0.5 });
assert.deepEqual(battlePlanOf(refOf('enemydebuff_select_8')).enemy, [{ rank: 'BOSS', mods: { defFlat: -200 } }]); assert.deepEqual(battlePlanOf(refOf('enemydebuff_select_8')).enemy, [{ rank: 'BOSS', mods: { defFlat: -200 } }]);
for (const id of TEAM_PREP) assert.equal(battlePlanOf(refOf(id)), null, `${id}: prep only`); for (const id of TEAM_PREP) assert.equal(battlePlanOf(refOf(id)), null, `${id}: prep only`);
+178 -46
View File
@@ -14,7 +14,7 @@ import * as bossesMod from '../../server/sim/content/bosses.js';
const E = JSON.parse(fs.readFileSync(new URL('../../data/enemies.json', import.meta.url), 'utf8')); const E = JSON.parse(fs.readFileSync(new URL('../../data/enemies.json', import.meta.url), 'utf8'));
const W = JSON.parse(fs.readFileSync(new URL('../../data/waves.json', import.meta.url), 'utf8')); const W = JSON.parse(fs.readFileSync(new URL('../../data/waves.json', import.meta.url), 'utf8'));
const { KITS, STATS_ONLY, EROSION, EROSION_BURST } = enemiesMod; const { KITS, STATS_ONLY, EROSION, EROSION_BURST } = enemiesMod;
const { BOSS_KITS, PART_TRANSFER } = bossesMod; const { BOSS_KITS, PART_TRANSFER, BLADE_TRANSFER, DRONE_LINK_BASE, droneLinkBase } = bossesMod;
const BIG = []; const BIG = [];
for (let dr = -4; dr <= 4; dr++) for (let dc = -12; dc <= 12; dc++) BIG.push([dr, dc]); for (let dr = -4; dr <= 4; dr++) for (let dc = -12; dc <= 12; dc++) BIG.push([dr, dc]);
@@ -1912,7 +1912,7 @@ test('template overrides of talents/skills are honoured (卢西恩 evade 0.2 in
const bossArena = (o = {}) => arena({ kind: 'boss', sharedBoss: pool(o.hp ?? 1e6), ...o }); const bossArena = (o = {}) => arena({ kind: 'boss', sharedBoss: pool(o.hp ?? 1e6), ...o });
const setTpl = (id) => (b) => { b.opts.templateId = id; }; const setTpl = (id) => (b) => { b.opts.templateId = id; };
test('假想敌:胄: arts ray on a random target in range; <20 % pool: damage taken ×0.5 and two 刺胄之弹', () => { test('假想敌:胄: arts ray on a random target in range; <20 % pool: damage taken ×0.5, still one 刺胄之弹 (PRTS 能力修正)', () => {
const h = bossArena({ units: [{ chessId: 't_wall', row: 10, col: 6 }, { chessId: 't_wall2', row: 12, col: 4 }] }); const h = bossArena({ units: [{ chessId: 't_wall', row: 10, col: 6 }, { chessId: 't_wall2', row: 12, col: 4 }] });
h.step(); h.step();
const e = put(h, 'enemy_9013_acstmk', [3, 10], { tag: 'boss' }); const e = put(h, 'enemy_9013_acstmk', [3, 10], { tag: 'boss' });
@@ -1925,7 +1925,8 @@ test('假想敌:胄: arts ray on a random target in range; <20 % pool: damage
approx(e.s.artsTakenMul, tb('enemy_9013_acstmk', '1.damage_scale')); approx(e.s.artsTakenMul, tb('enemy_9013_acstmk', '1.damage_scale'));
assert.equal(e.s.trueTakenMul, 1); assert.equal(e.s.trueTakenMul, 1);
h.run(skb('enemy_9013_acstmk', '1').initCooldown - 9); h.run(skb('enemy_9013_acstmk', '1').initCooldown - 9);
assert.equal(h.eventsOf('fx').filter((f) => f[1] === 'shell').length, 2); assert.equal(h.eventsOf('fx').filter((f) => f[1] === 'shell').length, 1);
assert.equal(alive(h, 'enemy_9016_acstmr').length, 1);
}); });
test('假想敌:胄 + 刺胄之弹: the shell flies to the highest-ATK operator; on arrival 3×3 stun + physical DoT; 8 hits shoot it down', () => { test('假想敌:胄 + 刺胄之弹: the shell flies to the highest-ATK operator; on arrival 3×3 stun + physical DoT; 8 hits shoot it down', () => {
@@ -1954,64 +1955,189 @@ test('假想敌:胄 + 刺胄之弹: the shell flies to the highest-ATK operato
assert.ok(!s2.alive); assert.ok(!s2.alive);
}); });
test('假想敌:胄 死亡集群: drones from the branch; a drone killed by an operator costs the leader 2 % max HP', () => { test('假想敌:胄 死亡集群: drones from the branch; a drone that dies (whoever kills it) costs the leader 2 % max HP, a leak nothing', () => {
const h = bossArena({ hp: 675000, units: [{ chessId: 't_gun', row: 12, col: 3 }], setup: setTpl('act1autochess_h07_01') }); const h = bossArena({ hp: 675000, units: [{ chessId: 't_gun', row: 12, col: 3 }], kits: { t_gun: NOATK }, setup: setTpl('act1autochess_h07_01') });
h.step(); h.step();
put(h, 'enemy_9013_acstmk', [3, 10], { tag: 'boss' }); const boss = put(h, 'enemy_9013_acstmk', [3, 10], { tag: 'boss' });
h.step(); h.step();
const s2 = skb('enemy_9013_acstmk', '2');
const loss = 675000 * s2.bb.hp_ratio;
const d = alive(h, 'enemy_1005_yokai')[0];
assert.ok(d, 'drone summoned at once (icd 0)');
assert.equal(d.s.maxHp, E.enemy_1005_yokai.stats.maxHp, 'boss_1 drones: no summon.hp_ratio — data HP (max_hp is not read)');
let before = h.b.sharedBoss.hp;
h.b.kill(d, h.unit('t_gun'));
approx(before - h.b.sharedBoss.hp, loss);
const newest = () => alive(h, 'enemy_1005_yokai').sort((a, b) => b.id - a.id)[0];
h.run(s2.cooldown + 0.5);
const d2 = newest();
assert.ok(d2);
before = h.b.sharedBoss.hp;
h.b.kill(d2, null); // 无来源: "该无人机单位死亡时"
approx(before - h.b.sharedBoss.hp, loss);
h.run(s2.cooldown);
const d3 = newest();
assert.notEqual(d3, d2);
assert.ok(d3);
before = h.b.sharedBoss.hp;
h.b.leak(d3);
assert.equal(h.b.sharedBoss.hp, before, 'a leaked drone does not die');
approx(h.result().perPlayer.p1.bossDamage, loss, 1e-6, 'only the operator kill is credited');
assert.ok(boss.alive);
});
test('假想敌:胄 死亡集群: the 2 % reads the leader\'s shown max HP (the pool)', () => {
// PRTS "该妖怪死亡时令假想敌:胄受到最大生命值2%的真实伤害" — which max HP is [ASSUMED] (DESIGN §20.10): default 'pool' = the
// HP the battle shows (synced to the shared pool, the same HP the <20 % talent reads), so 2 % of the bar in every mode:
// 72 000 with the 终极 pool (3 600 000); the 'unit' reading (data 600 000 → 12 000) stays a switch
assert.equal(DRONE_LINK_BASE, 'pool');
const h = bossArena({ hp: 3600000, units: [{ chessId: 't_gun', row: 12, col: 3 }], setup: setTpl('act1autochess_h07_01') });
h.step();
const boss = put(h, 'enemy_9013_acstmk', [3, 10], { tag: 'boss' });
h.step();
assert.equal(boss.s.maxHp, 3600000, 'the unit shows the pool');
assert.equal(droneLinkBase(boss), 3600000, 'the link reads the shown max HP (the pool)');
assert.equal(droneLinkBase(boss, 'unit'), 600000, "the 'unit' switch reads the data max HP");
const d = alive(h, 'enemy_1005_yokai')[0]; const d = alive(h, 'enemy_1005_yokai')[0];
assert.ok(d, 'drone summoned at once (icd 0)'); assert.ok(d, 'drone summoned at once (icd 0)');
const before = h.b.sharedBoss.hp; const before = h.b.sharedBoss.hp;
h.b.kill(d, h.unit('t_gun')); h.b.kill(d, h.unit('t_gun'));
approx(before - h.b.sharedBoss.hp, 675000 * skb('enemy_9013_acstmk', '2').bb.hp_ratio); approx(before - h.b.sharedBoss.hp, 3600000 * skb('enemy_9013_acstmk', '2').bb.hp_ratio);
assert.ok(h.result().perPlayer.p1.bossDamage >= 675000 * 0.02 - 1e-6); approx(before - h.b.sharedBoss.hp, 72000);
assert.ok(h.result().perPlayer.p1.bossDamage >= 72000 - 1e-6);
// a solo-sized pool (标准 247 500 × 0.25): still 2 % of the bar, never the 19 % a fixed 12 000 would take
const solo = bossArena({ hp: 61875, units: [{ chessId: 't_gun', row: 12, col: 3 }], setup: setTpl('act1autochess_h07_01') });
solo.step();
put(solo, 'enemy_9013_acstmk', [3, 10], { tag: 'boss' });
solo.step();
const d2 = alive(solo, 'enemy_1005_yokai')[0];
assert.ok(d2);
const b2 = solo.b.sharedBoss.hp;
solo.b.kill(d2, solo.unit('t_gun'));
approx(b2 - solo.b.sharedBoss.hp, 61875 * 0.02);
const hidden = bossArena({ hp: 7200000 });
hidden.step();
const hb = put(hidden, 'enemy_9013_acstmk_2', [3, 10], { tag: 'boss' });
hidden.step();
assert.equal(droneLinkBase(hb), 7200000, '隐秘核心 终极: 144 000 per drone');
assert.equal(droneLinkBase(hb, 'unit'), 1200000);
}); });
test('假想敌:胄 (隐秘核心): shells only at operators within its range', () => { test('假想敌:胄 (隐秘核心) 死亡集群: summon.hp_ratio scales the drone HP', () => {
const hi = WALL('t_wall', { atk: 900 }); const h = bossArena({ units: [{ chessId: 't_gun', row: 12, col: 3 }], setup: setTpl('act1autochess_h08_01') });
const h = bossArena({ units: [{ chessId: 't_wall', row: 10, col: 2 }, { chessId: 't_wall2', row: 12, col: 6 }], chess: { t_wall: hi } });
h.step(); h.step();
const e = put(h, 'enemy_9013_acstmk_2', [3, 12], { tag: 'boss' }); put(h, 'enemy_9013_acstmk_2', [3, 10], { tag: 'boss' }).profile.noAttack = true;
e.profile.noAttack = true; const s2 = skb('enemy_9013_acstmk_2', '2');
h.run(skb('enemy_9013_acstmk_2', '1').initCooldown + 0.1); h.run(s2.initCooldown + 0.1);
const f = h.eventsOf('fx').find((x) => x[1] === 'shell'); const d = alive(h, 'enemy_1005_yokai')[0];
assert.ok(f); assert.ok(d);
assert.equal(f[4].tx, h.unit('t_wall2').tileC, 'the high-ATK operator is out of range (col 2 vs 12, range 8)'); approx(d.s.maxHp, E.enemy_1005_yokai.stats.maxHp * s2.bb['summon.hp_ratio']);
}); });
for (const key of ['enemy_9014_acstma', 'enemy_9015_acstmb']) { for (const key of ['enemy_9013_acstmk', 'enemy_9013_acstmk_2']) {
test(`${nm(key)}: hovers invulnerable; dives at the lowest-ATK operator; ${skb(key, '1').bb.max_hit_cnt} hits shoot it down — then ×${skb(key, '1').bb.damage_scale} damage, ${PART_TRANSFER * 100} % passed to 胄`, () => { test(`${nm(key)}: shells only at operators within its range (PRTS 技能 "选择攻击范围内攻击力最高的1名我方干员")`, () => {
const h = bossArena({ hp: 1e6, units: [{ chessId: 't_wall', row: 12, col: 3 }, { chessId: 't_gun', row: 9, col: 4 }], chess: { t_gun: chessRec({ id: 't_gun', profession: 'SNIPER', projectile: 'none', stats: { atk: 5000, maxHp: 1e7, blockCnt: 0 }, rangeGrid: BIG, skill: null }) }, kits: { t_gun: NOATK } }); const hi = WALL('t_wall', { atk: 900 });
const h = bossArena({ units: [{ chessId: 't_wall', row: 10, col: 2 }, { chessId: 't_wall2', row: 12, col: 6 }], chess: { t_wall: hi } });
h.step(); h.step();
const boss = put(h, 'enemy_9013_acstmk_2', [3, 10], { tag: 'boss' }); const e = put(h, key, [3, 12], { tag: 'boss' });
boss.profile.noAttack = true; e.profile.noAttack = true;
const p = put(h, key, [3, 12], { tag: 'part' }); h.run(skb(key, '1').initCooldown + 0.1);
assert.ok(p.s.flags.invulnerable && p.s.flags.untargetable); const f = h.eventsOf('fx').find((x) => x[1] === 'shell');
const s = skb(key, '1'); assert.ok(f);
h.run(s.initCooldown + 0.1); assert.equal(f[4].tx, h.unit('t_wall2').tileC, 'the high-ATK operator is out of range (col 2 vs 12, range 8)');
assert.ok(!p.s.flags.invulnerable, 'diving');
const g = h.unit('t_gun');
for (let i = 0; i < s.bb.max_hit_cnt; i++) h.b.dealDamage(g, p, { amount: 1, type: 'true' });
assert.equal(p.motion, 'WALK');
approx(p.s.dmgTakenMul, s.bb.damage_scale);
const before = h.b.sharedBoss.hp;
const dealt = h.b.dealDamage(g, p, { amount: 1000, type: 'true' });
approx(before - h.b.sharedBoss.hp, dealt * PART_TRANSFER);
h.run(s.bb.special_stun_duration + 60);
assert.equal(p.motion, 'FLY');
}); });
} }
test('“斩胄之剑” dive that lands: 3×3 stun + physical DoT at the lowest-ATK operator', () => { const H08 = 'act1autochess_h08_01';
const h = bossArena({ units: [{ chessId: 't_wall', row: 9, col: 11 }], hooks: ['statusApplied'] }); const handRoute = (key, kind) => {
const t = W[H08];
const s = t.branches[`${key === 'enemy_9014_acstma' ? 'left' : 'right'}_hand_${kind}`][0][0];
return t.extraRoutes[s.routeIndex];
};
for (const key of ['enemy_9014_acstma', 'enemy_9015_acstmb']) {
const s = skb(key, '1');
test(`${nm(key)}: 初始模式 invulnerable; 掷 blinks to its preset route at the lowest-ATK operator, no normal attack; damage passes ${BLADE_TRANSFER * 100} % to 胄; ${s.bb.max_hit_cnt} hits → 【瘫痪】 (stun ${s.bb.stun} s, ground, ×${s.bb.damage_scale}), then a new 初始模式 copy with its HP replaces it`, () => {
const h = bossArena({ hp: 1e7, units: [{ chessId: 't_wall', row: 12, col: 3 }, { chessId: 't_gun', row: 9, col: 4 }], chess: { t_gun: chessRec({ id: 't_gun', profession: 'SNIPER', projectile: 'none', stats: { atk: 5000, maxHp: 1e7, blockCnt: 0 }, rangeGrid: BIG, skill: null }) }, kits: { t_gun: NOATK }, setup: setTpl(H08), hooks: ['statusApplied'] });
h.step();
const boss = put(h, 'enemy_9013_acstmk_2', [3, 10], { tag: 'boss' });
boss.profile.noAttack = true;
const origin = handRoute(key, 'origin'), blink = handRoute(key, 'blink');
const p = put(h, key, origin.start, { tag: 'part' }); // pinned: the dive never lands
assert.ok(p.s.flags.invulnerable && p.s.flags.untargetable && p.s.flags.unblockable);
h.run(s.initCooldown + 0.1);
assert.ok(!p.s.flags.invulnerable && !p.s.flags.untargetable, '出击模式');
assert.deepEqual([p.y, p.x], blink.start, 'blinked to the start of its blink route');
assert.ok(p.s.flags.disarm, '出击模式: no normal attack');
const g = h.unit('t_gun');
const seen = [];
h.b.on('damaged', (c) => { if (c.target === boss) seen.push(c); }, { priority: -1e9 });
let pool0 = h.b.sharedBoss.hp;
const d1 = h.b.dealDamage(g, p, { amount: 1000, type: 'true' });
approx(pool0 - h.b.sharedBoss.hp, d1 * BLADE_TRANSFER, 1e-6, 'passed on during the dive too');
for (let i = 1; i < s.bb.max_hit_cnt - 1; i++) h.b.dealDamage(g, p, { amount: 1, type: 'true' });
assert.equal(p.motion, 'FLY');
h.b.dealDamage(g, p, { amount: 1, type: 'true' }); // the 15th
assert.equal(p.motion, 'WALK', 'a ground unit');
approx(p.s.dmgTakenMul, s.bb.damage_scale);
assert.ok(p.s.flags.stun);
approx(statuses(h, p.id, 'stun')[0].duration, s.bb.stun);
pool0 = h.b.sharedBoss.hp;
const d2 = h.b.dealDamage(g, p, { amount: 1000, type: 'true' });
approx(d2, 1000 * s.bb.damage_scale);
approx(pool0 - h.b.sharedBoss.hp, d2 * BLADE_TRANSFER);
approx(pool0 - h.b.sharedBoss.hp, 1000 * s.bb.damage_scale, 1e-6, '等量: all of the ×1.3 damage');
assert.ok(seen.length && seen.every((c) => c.source === null && c.credit === g), '无来源 (hooks see no source), credited to the attacker');
assert.ok(h.result().perPlayer.p1.bossDamage >= d1 + (s.bb.max_hit_cnt - 2) + 1 + d2 - 1e-6, 'the per-player pool tally');
const hp = p.hp, killed0 = h.b.killed;
h.run(s.bb.stun - 0.2);
assert.ok(p.alive && p.motion === 'WALK', 'still 瘫痪');
h.run(0.3); // the stun ends → 【瘫痪】 ends (not the 20 s)
assert.ok(!p.alive, 'the old one is killed');
const n = alive(h, key);
assert.equal(n.length, 1);
assert.notEqual(n[0].id, p.id);
assert.deepEqual([n[0].y, n[0].x], origin.start, 'summoned on its origin route');
approx(n[0].hp, hp, 1e-9, 'inherits the HP');
assert.ok(n[0].s.flags.invulnerable && n[0].motion === 'FLY' && n[0].tag === 'part');
assert.equal(h.b.killed, killed0, 'a part: not counted');
h.run(s.initCooldown - 0.2);
assert.ok(n[0].s.flags.invulnerable, 'a new unit: 掷 waits for its initial cooldown');
h.run(0.3);
assert.ok(!n[0].s.flags.invulnerable);
});
}
test('“斩胄之剑” dive that lands: 3×3 stun + physical DoT at the lowest-ATK operator, then a new copy with its HP at home', () => {
const h = bossArena({ units: [{ chessId: 't_wall', row: 12, col: 15 }, { chessId: 't_wall2', row: 12, col: 16 }, { chessId: 't_wall3', row: 9, col: 3 }], chess: { t_wall2: WALL('t_wall2', { atk: 50 }), t_wall3: WALL('t_wall3', { atk: 100 }) }, hooks: ['statusApplied'], setup: setTpl(H08) });
h.step(); h.step();
const p = put(h, 'enemy_9014_acstma', [3, 12], { tag: 'part', move: true }); const origin = handRoute('enemy_9014_acstma', 'origin');
const p = put(h, 'enemy_9014_acstma', origin.start, { tag: 'part', move: true });
const s = skb('enemy_9014_acstma', '1'); const s = skb('enemy_9014_acstma', '1');
h.runUntil(() => statuses(h, h.unit('t_wall').id, 'stun').length > 0, s.initCooldown + 30); h.run(s.initCooldown + 0.1);
assert.ok(p.alive && !p.s.flags.invulnerable, 'flying from the blink start (5,16) to t_wall (5,15)');
p.hp -= 1000;
h.runUntil(() => statuses(h, h.unit('t_wall').id, 'stun').length > 0, 30);
approx(statuses(h, h.unit('t_wall').id, 'stun')[0].duration, s.bb.stun); approx(statuses(h, h.unit('t_wall').id, 'stun')[0].duration, s.bb.stun);
assert.equal(statuses(h, h.unit('t_wall2').id, 'stun').length, 1, 'the 3×3 around the target');
assert.equal(statuses(h, h.unit('t_wall3').id, 'stun').length, 0);
assert.ok(h.unit('t_wall').findBuff('boss:bladeDiveDot')); assert.ok(h.unit('t_wall').findBuff('boss:bladeDiveDot'));
h.run(20); assert.ok(!p.alive);
assert.ok(p.s.flags.invulnerable, 'back to hover'); const n = alive(h, 'enemy_9014_acstma')[0];
assert.ok(n && n.id !== p.id && n.s.flags.invulnerable);
assert.deepEqual([n.y, n.x], origin.start);
approx(n.hp, n.s.maxHp - 1000);
});
test('“斩胄之剑” / “破胄之锤” 初始模式 attack: every ally in range, 锤 150 % ATK (PRTS 天赋)', () => {
for (const [key, scale] of [['enemy_9014_acstma', 1], ['enemy_9015_acstmb', 1.5]]) {
const h = bossArena({ units: [{ chessId: 't_wall', row: 10, col: 9 }, { chessId: 't_wall2', row: 10, col: 7 }] });
h.step();
put(h, key, [3, 8], { tag: 'part' });
h.run(1);
approx(h.unit('t_wall').stats.taken, E[key].stats.atk * scale, 1e-6, key);
approx(h.unit('t_wall2').stats.taken, E[key].stats.atk * scale, 1e-6, key);
}
}); });
test('假想敌:铳: unblockable, targets the highest DEF in range, ASPD ramps on the same target, erosion on hit', () => { test('假想敌:铳: unblockable, targets the highest DEF in range, ASPD ramps on the same target, erosion on hit', () => {
@@ -2124,7 +2250,9 @@ test('“碎铳之簧” 频次护盾 (9020): negates 5 hits, then comes back 25
approx(w.stats.taken, n * sp.s.atk + 10 * sp.s.atk * sk.bb.atk_scale); approx(w.stats.taken, n * sp.s.atk + 10 * sp.s.atk * sk.bb.atk_scale);
}); });
test('“碎铳之簧”: damage taken passes PART_TRANSFER to 假想敌:铳 and, split, to the other springs (never passed on twice)', () => { test('“碎铳之簧”: damage taken costs 假想敌:铳 as much (等量, 无来源) and, split, the other springs (never passed on twice)', () => {
// PRTS 碎铳之簧 "受到伤害时令全场范围内仇恨值最高的1名假想敌:铳受到等量的无来源生命流失" (DESIGN §20.10; v2.5: half)
assert.equal(PART_TRANSFER, 1);
const h = bossArena({ units: [{ chessId: 't_gun', row: 12, col: 3 }], kits: { t_gun: NOATK } }); const h = bossArena({ units: [{ chessId: 't_gun', row: 12, col: 3 }], kits: { t_gun: NOATK } });
h.step(); h.step();
const g = put(h, 'enemy_9017_achunt_2', [3, 18], { tag: 'boss' }); const g = put(h, 'enemy_9017_achunt_2', [3, 18], { tag: 'boss' });
@@ -2132,12 +2260,16 @@ test('“碎铳之簧”: damage taken passes PART_TRANSFER to 假想敌:铳 a
const [a, b, c] = ['enemy_9018_actrpa', 'enemy_9019_actrpb', 'enemy_9020_actrpc'].map((k, i) => put(h, k, [1 + i * 2, 4], { tag: 'part' })); const [a, b, c] = ['enemy_9018_actrpa', 'enemy_9019_actrpb', 'enemy_9020_actrpc'].map((k, i) => put(h, k, [1 + i * 2, 4], { tag: 'part' }));
for (const x of [a, b, c]) x.profile.noAttack = true; for (const x of [a, b, c]) x.profile.noAttack = true;
h.step(); h.step();
const seen = [];
h.b.on('damaged', (x) => { if (x.target === g) seen.push(x); }, { priority: -1e9 });
const pool0 = h.b.sharedBoss.hp, b0 = b.hp, c0 = c.hp; const pool0 = h.b.sharedBoss.hp, b0 = b.hp, c0 = c.hp;
const dealt = h.b.dealDamage(h.unit('t_gun'), a, { amount: 1e5, type: 'true' }); const dealt = h.b.dealDamage(h.unit('t_gun'), a, { amount: 1e5, type: 'true' });
assert.ok(dealt > 0); assert.ok(dealt > 0);
approx(pool0 - h.b.sharedBoss.hp, dealt * PART_TRANSFER); approx(pool0 - h.b.sharedBoss.hp, dealt);
approx(b0 - b.hp, (dealt * PART_TRANSFER) / 2); approx(b0 - b.hp, dealt / 2);
approx(c0 - c.hp, (dealt * PART_TRANSFER) / 2); approx(c0 - c.hp, dealt / 2);
assert.ok(seen.length === 1 && seen[0].source === null && seen[0].credit === h.unit('t_gun'), '无来源, credited to the attacker');
assert.ok(h.result().perPlayer.p1.bossDamage >= dealt - 1e-6, 'the per-player pool tally');
}); });
test('假想敌:管: strikes dark 余音 (their pulse hurts operators); 裂管之奏 at 40 s; summons 余音 every 40 s', () => { test('假想敌:管: strikes dark 余音 (their pulse hurts operators); 裂管之奏 at 40 s; summons 余音 every 40 s', () => {
+4 -3
View File
@@ -481,9 +481,10 @@ test('attrByBond (26 ids): floor(Σ layers of the listed ACTIVE bonds / divide_n
const k = Math.floor((L * list.length) / div); const k = Math.floor((L * list.length) / div);
const m = h.unit('op').findBuff(`gar:${gid}`).mods; const m = h.unit('op').findBuff(`gar:${gid}`).mods;
const want = {}; const want = {};
if (bb.atk) want.atkMul = 1 + bb.atk * k; // "每叠加N层…+X%" is 直接乘算: the additive percentage bucket (support directMods)
if (bb.max_hp) want.hpMul = 1 + bb.max_hp * k; if (bb.atk) want.atkPct = bb.atk * k;
if (bb.def) want.defMul = 1 + bb.def * k; if (bb.max_hp) want.hpPct = bb.max_hp * k;
if (bb.def) want.defPct = bb.def * k;
if (bb.attack_speed) want.aspd = bb.attack_speed * k; if (bb.attack_speed) want.aspd = bb.attack_speed * k;
if (bb.hp_recovery_per_sec) want.hpRegen = bb.hp_recovery_per_sec * k; if (bb.hp_recovery_per_sec) want.hpRegen = bb.hp_recovery_per_sec * k;
if (bb.sp_recovery_per_sec) want.spRecoveryFlat = bb.sp_recovery_per_sec * k; if (bb.sp_recovery_per_sec) want.spRecoveryFlat = bb.sp_recovery_per_sec * k;
+7 -6
View File
@@ -77,7 +77,7 @@ const STAT_CASES = [
[A('6_11'), { spRecovery: 1.2 }], [B('6_11'), { spRecovery: 1.35 }], // 家族徽章 [A('6_11'), { spRecovery: 1.2 }], [B('6_11'), { spRecovery: 1.35 }], // 家族徽章
]; ];
test('stat equipment: every percentage is its own multiplier (normal / golden numbers from data)', () => { test('stat equipment: percentages are 直接乘算 — additive with each other (normal / golden numbers from data)', () => {
for (const [id, want] of STAT_CASES) { for (const [id, want] of STAT_CASES) {
const h = fight({ units: [{ chessId: 't_op', row: 10, col: 4, items: [id] }] }); const h = fight({ units: [{ chessId: 't_op', row: 10, col: 4, items: [id] }] });
h.step(1); h.step(1);
@@ -85,13 +85,14 @@ test('stat equipment: every percentage is its own multiplier (normal / golden nu
for (const [k, v] of Object.entries(want)) close(s[k], v, `${id} ${k}`); for (const [k, v] of Object.entries(want)) close(s[k], v, `${id} ${k}`);
cover(id); cover(id);
} }
// two items multiply (维式重锤 ×1.15 × 阿戈尔重刃 ×1.4), a normal + golden copy of one item both apply // two items add up (PRTS 盟约记录 "装备效果提供的属性加成均为直接乘算"; 直接乘算 values are summed, PRTS 游戏数据基础):
// 维式重锤 +15 % and 阿戈尔重刃 +40 % = +55 %, not ×1.15 × 1.4; a normal + golden copy of one item both apply
const h = fight({ units: [{ chessId: 't_op', row: 10, col: 4, items: [A('1_01'), A('3_07')] }] }); const h = fight({ units: [{ chessId: 't_op', row: 10, col: 4, items: [A('1_01'), A('3_07')] }] });
h.step(1); h.step(1);
close(h.unit('t_op').s.atk, 500 * 1.15 * 1.4, 'stacked'); close(h.unit('t_op').s.atk, 500 * (1 + 0.15 + 0.4), 'stacked');
const h2 = fight({ units: [{ chessId: 't_op', row: 10, col: 4, items: [A('1_01'), B('1_01')] }] }); const h2 = fight({ units: [{ chessId: 't_op', row: 10, col: 4, items: [A('1_01'), B('1_01')] }] });
h2.step(1); h2.step(1);
close(h2.unit('t_op').s.atk, 500 * 1.15 * 1.25, 'normal + golden'); close(h2.unit('t_op').s.atk, 500 * (1 + 0.15 + 0.25), 'normal + golden');
checkInvariants(h2.b); checkInvariants(h2.b);
}); });
@@ -179,8 +180,8 @@ test('歌利亚头盔: HP +25 % / +45 %, and +15 % / +25 % more when nobody stan
const ops = { t_op: op('t_op'), t_f: op('t_f') }; const ops = { t_op: op('t_op'), t_f: op('t_f') };
const free = fight({ ops, units: [{ chessId: 't_op', row: 10, col: 4, items: [id] }] }); const free = fight({ ops, units: [{ chessId: 't_op', row: 10, col: 4, items: [id] }] });
free.step(1); free.step(1);
close(free.unit('t_op').s.maxHp, 2000 * base * ex, `${id} free`); close(free.unit('t_op').s.maxHp, 2000 * (base + ex - 1), `${id} free (+25 % and +15 % add up: 直接乘算)`);
close(free.unit('t_op').hp, 2000 * base * ex, `${id} deployed at full HP`); close(free.unit('t_op').hp, 2000 * (base + ex - 1), `${id} deployed at full HP`);
const blocked = fight({ ops, units: [{ chessId: 't_op', row: 10, col: 4, items: [id] }, { chessId: 't_f', row: 10, col: 5 }] }); const blocked = fight({ ops, units: [{ chessId: 't_op', row: 10, col: 4, items: [id] }, { chessId: 't_f', row: 10, col: 5 }] });
blocked.step(1); blocked.step(1);
close(blocked.unit('t_op').s.maxHp, 2000 * base, `${id} front occupied`); close(blocked.unit('t_op').s.maxHp, 2000 * base, `${id} front occupied`);
+13
View File
@@ -625,6 +625,19 @@ test('缄默德克萨斯 S1 细雨无声: passive ATK up for its duration; hits
} }
}); });
test('缄默德克萨斯 S2: two copies cut the RES of an enemy around both once (同名效果取最高, DESIGN §20.10)', () => {
const id = 'chess_char_4_16_a', bb = D(id, 1).skill.bb;
const h = battle([U(id, 10, 4, 1), U(id, 11, 4, 1)], { enemies: { enemy_res: dummy({ key: 'enemy_res', res: 50 }) } });
h.step();
const two = h.b.allyUnits.filter((u) => u.def.id === id);
assert.equal(two.length, 2);
const e = h.spawn('enemy_res', { pos: [10, 5] });
for (const u of two) { h.b.retreat(u); assert.ok(h.b.redeploy(u)); }
assert.equal(dmgBy(h, two[0], tagged('burst')).length + dmgBy(h, two[1], tagged('burst')).length, 2, 'both bursts hit it');
assert.equal(e.buffs.filter((b) => String(b.key).startsWith('texas2:resDown')).length, 1, 'one RES cut');
approx(e.s.res, 50 * (1 + bb.magic_resistance), 1e-6, 'once, not twice');
});
test('缄默德克萨斯 S2 阵雨连绵: deploy burst atk_scale arts + RES down around her; ATK up; attacks become arts double hits', () => { test('缄默德克萨斯 S2 阵雨连绵: deploy burst atk_scale arts + RES down around her; ATK up; attacks become arts double hits', () => {
for (const id of pair('chess_char_4_16_a')) { for (const id of pair('chess_char_4_16_a')) {
const sk = D(id, 1).skill, bb = sk.bb; const sk = D(id, 1).skill, bb = sk.bb;
+36 -1
View File
@@ -494,7 +494,7 @@ test('灵知 S2: 130 % ATK arts + 2.5 s cold to all in range; fully charged cast
approx(burst[0].amount, u.s.atk * bb.atk_scale, 1e-6); approx(burst[0].amount, u.s.atk * bb.atk_scale, 1e-6);
assert.ok(a.s.flags.freeze && b.s.flags.freeze, 'charged cast: second cold ⇒ freeze'); assert.ok(a.s.flags.freeze && b.s.flags.freeze, 'charged cast: second cold ⇒ freeze');
h.run(0.2); h.run(0.2);
approx(a.findBuff(`gnosis:fragile:${u.id}`).mods.dmgTakenMul, t0.damage_scale_freeze, 1e-9, 'frozen ×2 fragile'); approx(a.findBuff('gnosis:fragile').mods.dmgTakenMul, t0.damage_scale_freeze, 1e-9, 'frozen ×2 fragile');
// next (single-charge) cast only chills once per enemy // next (single-charge) cast only chills once per enemy
assert.ok(h.runUntil(() => u.skill.activations >= 2, 20)); assert.ok(h.runUntil(() => u.skill.activations >= 2, 20));
const starts = noisy(h, 'skillStart').filter((c) => c.unit === u).map((c) => c.t); const starts = noisy(h, 'skillStart').filter((c) => c.unit === u).map((c) => c.t);
@@ -552,6 +552,41 @@ test('莱恩哈特 S2: 170 % ATK arts to all enemies in the wider skill range +
assert.ok(inR.alive); assert.ok(inR.alive);
}); });
test('同名效果取最高: two 灵知 never compound 坚冰, two 莱恩哈特 never compound the RES cut (one instance per enemy, the strongest)', () => {
// DESIGN §20.10 (the 奥术 rule): a pair / 联防 partner's copy of the same operator puts the same-named effect on the
// same enemy — one instance, the strongest (PRTS 作战机制 "同名buff…只能表现出一个"), never m × m
const gn = 'chess_char_4_13_a', t0 = D(gn).talents[0].bb;
const g = makeBattle({ defs: { enemies: { enemy_dummy: dummy() } }, units: [{ chessId: gn, row: 10, col: 3 }, { chessId: gn, row: 11, col: 3 }], timeLimit: 60, hooks: [] });
g.step();
const both = g.b.allyUnits.filter((u) => u.def.id === gn);
assert.equal(both.length, 2, 'two 灵知 on the board');
const e = g.spawn('enemy_dummy', { pos: [10, 4] });
let seen = 0;
for (let i = 0; i < 120; i++) { // 4 s: chilled / frozen by both
g.step();
const fr = e.buffs.filter((b) => String(b.key).startsWith('gnosis:fragile'));
assert.ok(fr.length <= 1, `one 坚冰 instance (${fr.map((b) => b.key)})`);
if (!fr.length) continue;
seen++;
const want = e.s.flags.freeze ? t0.damage_scale_freeze : t0.damage_scale_cold;
approx(e.s.dmgTakenMul, fr[0].mods.dmgTakenMul, 1e-9, 'the only damage-taken modifier');
assert.ok(e.s.dmgTakenMul <= t0.damage_scale_freeze + 1e-9 && fr[0].mods.dmgTakenMul >= want - 1e-9, `never ${t0.damage_scale_cold}² / ${t0.damage_scale_freeze}² (${e.s.dmgTakenMul})`);
}
assert.ok(seen > 30, `坚冰 was up (${seen})`);
const lh = 'chess_char_4_14_a', bb = D(lh).skill.bb;
const h = makeBattle({ defs: { enemies: { enemy_dummy: dummy({ res: 50 }) } }, units: [{ chessId: lh, row: 10, col: 3 }, { chessId: lh, row: 11, col: 3 }], timeLimit: 60, hooks: [] });
h.step();
const two = h.b.allyUnits.filter((u) => u.def.id === lh);
assert.equal(two.length, 2);
const x = h.spawn('enemy_dummy', { pos: [10, 5] });
h.step();
for (const u of two) assert.ok(u.skill.activate('test', { free: true }), 'S2 cast');
assert.equal(x.buffs.filter((b) => String(b.key).startsWith('lionhd:res')).length, 1, 'one RES cut');
approx(x.s.res, 50 * (1 + bb.magic_resistance), 1e-9, 'RES −8 % once, not twice');
h.run(bb.duration + 0.1);
approx(x.s.res, 50, 1e-9, 'expired');
});
test('录武官 S2: healed allies regain 80 HP whenever damaged for 10 s; 学成于聚 SP +1 & ASPD +16 when an operator in range casts', () => { test('录武官 S2: healed allies regain 80 HP whenever damaged for 10 s; 学成于聚 SP +1 & ASPD +16 when an operator in range casts', () => {
const id = 'chess_char_4_15_a', bb = D(id).skill.bb, t0 = D(id).talents[0].bb; const id = 'chess_char_4_15_a', bb = D(id).skill.bb, t0 = D(id).talents[0].bb;
const h = makeBattle({ units: [{ chessId: id, row: 10, col: 3 }, { chessId: 'chess_char_1_02_a', row: 10, col: 4 }], timeLimit: 60, hooks: ['heal'], captureNoisy: true }); const h = makeBattle({ units: [{ chessId: id, row: 10, col: 3 }, { chessId: 'chess_char_1_02_a', row: 10, col: 4 }], timeLimit: 60, hooks: ['heal'], captureNoisy: true });
+148 -5
View File
@@ -13,7 +13,9 @@
// boss-field deployment, the phone prep camera — and the normative §3 / §5.1 / §5.5 / §6.1 / §7 lines that changed; user // boss-field deployment, the phone prep camera — and the normative §3 / §5.1 / §5.5 / §6.1 / §7 lines that changed; user
// playtest #6 (DESIGN §20): summons placed by hand (start deploy), skill triggers and the operation cooldown, every // playtest #6 (DESIGN §20): summons placed by hand (start deploy), skill triggers and the operation cooldown, every
// blocker hits what it blocks, push force vs weight, the ASPD floor, the enemies' collider reach, the boss pool floor, // blocker hits what it blocks, push force vs weight, the ASPD floor, the enemies' collider reach, the boss pool floor,
// multi-round bounties lasting two battles, the 联防 counter, the element gauge — and the user's settled decisions. // multi-round bounties lasting two battles, the 联防 counter, the element gauge — and the user's settled decisions; the
// playtest6b follow-up (DESIGN §20.10–§20.13): leader HP and 直接乘算, the elite on the consumed copy's tile, the official
// 999 layer cap (one implementation) and 限伤 300000, the 假想敌:胄 kit — and the normative lines they rewrote.
import { test } from 'node:test'; import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
@@ -27,9 +29,10 @@ import { moduleTypeIconUrl } from '../public/js/ui/assetUrls.js';
import { validateC2S } from '../shared/protocol.js'; import { validateC2S } from '../shared/protocol.js';
import { ERR, PHASE } from '../shared/constants.js'; import { ERR, PHASE } from '../shared/constants.js';
import { PROJECTILE_SPEEDS, BOOMERANG_RETURN_SPEED, ELEMENT, ELEMENT_ORDER, DOWN_STATE, BLOCK_RADIUS, FORCED_EXIT, ASPD_MIN, BOSS_POOL_MIN_HP, AUTO_OP_COOLDOWN, ALLY_COLLIDER_RADIUS } from '../server/sim/constants.js'; import { PROJECTILE_SPEEDS, BOOMERANG_RETURN_SPEED, ELEMENT, ELEMENT_ORDER, DOWN_STATE, BLOCK_RADIUS, FORCED_EXIT, ASPD_MIN, BOSS_POOL_MIN_HP, AUTO_OP_COOLDOWN, ALLY_COLLIDER_RADIUS } from '../server/sim/constants.js';
import { SKILL_SUMMON_START_DEPLOY } from '../shared/constants.js'; import { SKILL_SUMMON_START_DEPLOY, BOND_LAYER_CAP, BOSS_HIT_LIMIT, layerGainRoom } from '../shared/constants.js';
import * as SIM_CONST from '../server/sim/constants.js';
import { MULTI_ROUND_BOUNTY_BATTLES } from '../server/match/choices.js'; import { MULTI_ROUND_BOUNTY_BATTLES } from '../server/match/choices.js';
import { SELF_BOUND } from '../server/sim/content/bosses.js'; import { SELF_BOUND, PART_TRANSFER, BLADE_TRANSFER, DRONE_LINK_BASE } from '../server/sim/content/bosses.js';
import * as BOARD from '../server/match/board.js'; import * as BOARD from '../server/match/board.js';
import * as DAMAGE from '../server/sim/damage.js'; import * as DAMAGE from '../server/sim/damage.js';
import { SUB } from '../server/sim/professions.js'; import { SUB } from '../server/sim/professions.js';
@@ -391,7 +394,7 @@ test('user playtest #6 (DESIGN §20): summons, skill triggers, blocking, push fo
const sub = (a, b) => DESIGN.slice(DESIGN.indexOf(`### ${a}`), DESIGN.indexOf(`### ${b}`)); const sub = (a, b) => DESIGN.slice(DESIGN.indexOf(`### ${a}`), DESIGN.indexOf(`### ${b}`));
const S20 = sec(20); const S20 = sec(20);
assert.match(DESIGN, /## 20\. User playtest #6 \(v2\.5\)/); assert.match(DESIGN, /## 20\. User playtest #6 \(v2\.5\)/);
for (let i = 1; i <= 9; i++) assert.match(S20, new RegExp(`### 20\\.${i} `), `§20.${i}`); for (let i = 1; i <= 13; i++) assert.match(S20, new RegExp(`### 20\\.${i} `), `§20.${i}`);
const intro = S20.slice(0, S20.indexOf('### 20.1')); const intro = S20.slice(0, S20.indexOf('### 20.1'));
for (let i = 1; i <= 19; i++) assert.match(intro, new RegExp(`#${i} `), `the intro maps report #${i}`); for (let i = 1; i <= 19; i++) assert.match(intro, new RegExp(`#${i} `), `the intro maps report #${i}`);
const s55 = DESIGN.slice(DESIGN.indexOf('### 5.5'), DESIGN.indexOf('### 5.6')); const s55 = DESIGN.slice(DESIGN.indexOf('### 5.5'), DESIGN.indexOf('### 5.6'));
@@ -467,5 +470,145 @@ test('user playtest #6 (DESIGN §20): summons, skill triggers, blocking, push fo
assert.match(sec(10), /a tap anywhere on the card, its confirm strip included, is the card's tap/); assert.match(sec(10), /a tap anywhere on the card, its confirm strip included, is the card's tap/);
assert.match(sec(18), /Each card shows its full effect text \(§20\.7\)/); assert.match(sec(18), /Each card shows its full effect text \(§20\.7\)/);
// README: the test count stays in the right order of magnitude // README: the test count stays in the right order of magnitude
assert.match(README, /约 27\d0 项/); assert.match(README, /约 28\d0 项/);
});
test('user playtest #6 follow-up: a merge consuming a deployed copy puts the elite on that tile (code + research + META / PLAYING / SIM agree)', () => {
const R01 = doc('docs/research/01-core-rules.md');
const INDEX = doc('docs/research/00-INDEX.md');
const PS = doc('server/match/PlayerState.js');
// code: one deployed copy + one hand copy + the acquired one → the elite on the deployed copy's tile
const h = makeMatch({ mode: 'solo', difficulty: 'NORMAL', seed: 71 }).start();
h.toPrep(1);
h.setStage('act2autochess_m04');
const m = h.m;
const ps = h.ps('p_0');
for (const p of [...ps.board.values(), ...ps.hand.filter(Boolean)]) if (p.kind === 'chess') ps.returnCopies(p);
ps.board.clear();
ps.hand.fill(null);
ps.recompute();
const id = Object.values(DATA.chess).find((c) => !c.isGolden && c.tier === 1 && c.position === 'MELEE' && m.pool.has(c.chessId) && m.gd.mergeCount(c.chessId) === 3 && !m.gd.placeableTokens(c.chessId).length).chessId;
const [r, c] = BOARD.legalTiles(ps.deployMap(), 'melee')[0];
const a = ps.newPiece('chess', id, { poolCopies: m.pool.take(id, 1) });
a.dir = 'UP';
ps.board.set(BOARD.tileKey(r, c), a);
ps.hand[0] = ps.newPiece('chess', id, { poolCopies: m.pool.take(id, 1) });
ps.recompute();
const elite = ps.acquireChess(id, { source: 'test' });
assert.equal(ps.find(elite.uid).key, BOARD.tileKey(r, c), 'the elite on the deployed copy\'s tile');
assert.equal(elite.dir, 'UP');
m.dispose();
// the research no longer says "not to a board tile"; every doc names the official sentence
const official = /若消耗已部署至作战区的干员,则发送至作战区对应位置/;
assert.ok(!/1 elite goes to the hand, not to a board tile\.\*\*/.test(R01), 'research 01 A1 row 7 is corrected');
assert.match(R01, /\| 7 \| promotion reward[^\n]*to that copy's board position/);
// its machine-readable companion too (not read by build-data; the tile among several deployed copies is assumed)
const promo = JSON.parse(doc('docs/research/01-core-data.json'))._criticAddendum.promotion;
assert.match(promo.eliteGoesTo, official, '01-core-data.json promotion.eliteGoesTo quotes PRTS');
assert.notEqual(promo.eliteGoesTo, 'hand');
assert.equal(promo.eliteTileAmongSeveralDeployed?.assumed, true);
for (const [name, text] of [['research 01', R01], ['00-INDEX', INDEX], ['META', META], ['SIM', SIM], ['PlayerState', PS]]) assert.match(text.replace(/\s+|\/\/\s/g, ''), official, `${name} quotes PRTS`);
assert.match(INDEX, /to \*\*that copy's board position\*\*/);
assert.match(META, /of several, the one that deploys first \(row desc, then col asc; `board\.js mergeTile`/);
assert.ok(!/it takes a freed\s+board tile of a consumed copy only when the hand and temp are both full/.test(META), 'META: the old fallback-only wording is gone');
assert.ok(!/only with the hand and temp both full does it take a/.test(PS), 'PlayerState header: the old fallback-only wording is gone');
assert.match(PLAYING, /精锐会直接出现在\*\*那名干员的位置\*\*/);
assert.ok(!/精锐进入整备区(满了进入临时整备区),原先配发的装备退回整备区。/.test(PLAYING), 'PLAYING: the hand-only wording is gone');
});
test('playtest6b follow-up (DESIGN §20.10–§20.13): leader HP, 直接乘算, elite to the board, one official 999 cap, 限伤 300000, the 胄 kit — code and every doc agree', () => {
const sec = (n) => DESIGN.slice(DESIGN.indexOf(`## ${n}.`), DESIGN.indexOf(`## ${n + 1}.`) > 0 ? DESIGN.indexOf(`## ${n + 1}.`) : undefined);
const S20 = sec(20);
const subsec = (n) => { const a = S20.indexOf(`### 20.${n} `); const b = S20.indexOf('\n### 20.', a + 5); return S20.slice(a, b > 0 ? b : undefined); };
const intro = S20.slice(0, S20.indexOf('### 20.1 '));
const s209 = subsec(9);
const BALANCE = doc('docs/BALANCE.md');
const R02 = doc('docs/research/02-bonds.md');
const R11 = doc('docs/research/11-limits-official.md');
// the intro names the follow-up and the four sections, in merge order
assert.match(intro, /\*\*Follow-up \(`playtest6b`, 2026-10-02\)\.\*\*/);
for (const n of [10, 11, 12, 13]) assert.match(intro, new RegExp(`§20\\.${n}`), `the intro names §20.${n}`);
// one layer cap: shared/constants.js only — the boss-HP branch's sim/constants.js copy is gone
assert.equal(BOND_LAYER_CAP, 999);
assert.equal(layerGainRoom(995, 10), 4);
assert.ok(!('BOND_LAYER_CAP' in SIM_CONST) && !('layerRoom' in SIM_CONST), 'no second cap in server/sim/constants.js');
for (const f of ['server/match/PlayerState.js', 'server/match/Match.js', 'server/sim/Battle.js']) {
const src = doc(f);
assert.match(src, /layerGainRoom/, `${f} clamps with layerGainRoom`);
assert.ok(!/layerRoom\b/.test(src), `${f}: no layerRoom`);
}
// 限伤: official constant, not an overflow — no doc keeps the boss-HP branch's "fixed-point overflow, not modelled"
assert.equal(BOSS_HIT_LIMIT, 300000);
for (const [name, text] of [['BALANCE', BALANCE], ['DATA', DATA_MD], ['PLAYING', PLAYING], ['SIM', SIM], ['META', META], ['research 02', R02], ['DESIGN', DESIGN]]) {
assert.ok(!/reads as the engine's fixed-point overflow|is \[inference\] and is not modelled|Not modelled: the largest damage/.test(text), `${name}: 限伤 is not called an unmodelled overflow`);
assert.ok(!/BOND_LAYER_CAP`?\)? ?\[ASSUMED\]|999 \[ASSUMED\]|awaiting the user's confirmation/.test(text), `${name}: the 999 cap is official, not [ASSUMED]`);
}
assert.match(BALANCE, /superseded by that binary evidence/);
assert.match(R11, /MAX_BATTLE_DAMAGE = 300000/);
assert.match(R02, /MAX_GARRISON_STACK = 999/);
// §20.9: elite settled, 999 / 限伤 official with their flips, the fixed leader pool settled (aliveScaling off in the data)
assert.match(s209, /\| A merge's elite \(§20\.11\) \| takes the consumed deployed copy's tile/);
assert.match(s209, /`shared\/constants\.js BOND_LAYER_CAP = 0`/);
assert.match(s209, /`shared\/constants\.js BOSS_HIT_LIMIT = 0`/);
assert.match(s209, /Settled by the user \("保持固定血量"\)[^\n]*aliveScaling` is \*\*false\*\*/);
assert.equal(DATA.config.bossHpScale.aliveScaling, false, 'the user chose the fixed leader pool');
assert.equal(SIM_CONST.DIRECT_BONUS_STACKING, 'add');
assert.match(s209, /DIRECT_BONUS_STACKING = 'multiply'/);
// the normative lines
const s52 = DESIGN.slice(DESIGN.indexOf('### 5.2'), DESIGN.indexOf('### 5.3'));
const s53 = DESIGN.slice(DESIGN.indexOf('### 5.3'), DESIGN.indexOf('### 5.4'));
const s55 = DESIGN.slice(DESIGN.indexOf('### 5.5'), DESIGN.indexOf('### 5.6'));
const s62 = DESIGN.slice(DESIGN.indexOf('### 6.2'), DESIGN.indexOf('### 6.3'));
const s63 = DESIGN.slice(DESIGN.indexOf('### 6.3'), DESIGN.indexOf('### 6.4'));
assert.match(s52, /are 直接乘算 and go into `Σpct`/);
assert.match(s53, /`Battle\.applyStrongest`/);
assert.match(s55, /\*\*限伤\*\*[^\n]*`ceil\(final\) ≥ 300000` is cancelled[^\n]*before HP shields/);
assert.match(s62, /elite onto the consumed deployed copy's tile, else to the hand \(§20\.11\)/);
assert.ok(!/A merge's elite always goes to the hand/.test(DESIGN), '§6.2: the hand-first wording is gone');
assert.match(s63, /`BOND_LAYER_CAP` = 999 layers \(the client's `MAX_GARRISON_STACK`/);
assert.match(sec(8), /`'hitCap' \{id, n\}` = a cancelled 限伤 hit, drawn as nothing/);
assert.match(sec(9), /fx\.promote instead of fx\.deploy/);
assert.match(sec(14), /`layerGains ≤ min\(60 \+ 4·round, 999 − the bond's starting layers\)`/);
assert.match(sec(2), /pick\.js, promote\.js/);
// leader parts and drones: one share, 1:1 (PRTS 等量); drones read the pool
assert.equal(PART_TRANSFER, 1);
assert.equal(BLADE_TRANSFER, PART_TRANSFER);
assert.equal(DRONE_LINK_BASE, 'pool');
assert.match(sec(7), /pass every damage they take to their leader 1:1 as 无来源 HP loss \(`PART_TRANSFER` 1/);
assert.match(SIM, /`PART_TRANSFER` 1 for 斩胄之剑 \/ 破胄之锤 \(`BLADE_TRANSFER`, the same\s+constant\) and 碎铳之簧/);
assert.ok(!/`PART_TRANSFER` 0\.5 \[ASSUMED\]/.test(SIM), 'SIM: no ½ spring transfer');
assert.match(PLAYING, /无人机死亡时(不论是谁击落;漏过去不算)/);
// §20.13: the 胄 audit, short
assert.match(subsec(13), /fires \*\*one\*\* 刺胄之弹 below 20 % too/);
assert.match(subsec(13), /\*\*any\*\* drone death costs the leader 2 % max HP/);
});
test('playtest6b QA residuals (DESIGN §20.14): the held boss result, the cue between two preps, the 可晋升 line — code and every doc agree', () => {
const S20 = DESIGN.slice(DESIGN.indexOf('## 20.'));
const s2014 = S20.slice(S20.indexOf('### 20.14 '));
assert.ok(s2014.length > 100, '§20.14 exists');
const intro = S20.slice(0, S20.indexOf('### 20.1 '));
assert.match(intro, /residual issues are handled in §20\.14/);
// the held 'cleared' result: Match.js = §14 = §20.14 = META
const match = doc('server/match/Match.js');
assert.match(match, /if \(result\.reason === 'cleared' && pool && reported - f\.bossAcked >= pool\.hp - 1\) \{\s*f\.heldResult = result;/);
assert.match(match, /_bossHandover\(f, why, \{ demote = false \} = \{\}\) \{\s*if \(f\.done \|\| f\.mode !== 'client' \|\| f\.heldResult\) return;/);
assert.match(match, /const BOSS_MIN_CLEAR_GS = 5;/, 'the budget itself is unchanged');
const s14 = DESIGN.slice(DESIGN.indexOf('## 14.'), DESIGN.indexOf('## 15.'));
assert.match(s14, /it is \*\*held\*\* \(`f\.heldResult`/);
assert.match(META, /waits for the budget instead \(`f\.heldResult`/);
assert.match(s2014, /\*\*Fast boss kills handed over \(fixed\)\.\*\*/);
// the promotion cue between two preps (render/app.js promoBase) and the touch line (shopBar mergeHint)
assert.match(doc('public/js/render/app.js'), /if \(prepPieces\.length\) promoBase = prepPieces;/);
assert.match(doc('public/js/ui/shopBar.js'), /export function mergeHint\(priv, chessId\)/);
assert.match(s2014, /`promoBase`/);
assert.match(s2014, /可晋升:精锐干员将出现在作战区原位置/);
assert.match(PLAYING, /详情卡里也会写明去向/);
// the measurements: real seconds, the integrated build, the pre-merge numbers labelled
const s2010 = S20.slice(S20.indexOf('### 20.10 '), S20.indexOf('### 20.11 '));
assert.match(s2010, /\*\*Measured\*\* \(real seconds/);
assert.match(s2010, /Integrated build \(QA 6b on `cca11e6`/);
const BALANCE = doc('docs/BALANCE.md');
assert.match(BALANCE, /\*\*2026-10-02 — the integrated build \(QA 6b/);
assert.match(BALANCE, /measured on the boss-HP workstream's boards, \*\*before\*\* the elite-to-board merge/);
}); });
+1
View File
@@ -47,6 +47,7 @@ export async function startRealServer(opts = {}) {
if (opts.fast.kit != null) env.SP_START_KIT = String(opts.fast.kit); if (opts.fast.kit != null) env.SP_START_KIT = String(opts.fast.kit);
if (opts.fast.chess?.length) env.SP_START_CHESS = opts.fast.chess.join(','); if (opts.fast.chess?.length) env.SP_START_CHESS = opts.fast.chess.join(',');
if (opts.fast.items?.length) env.SP_START_ITEMS = opts.fast.items.join(','); if (opts.fast.items?.length) env.SP_START_ITEMS = opts.fast.items.join(',');
if (opts.fast.shop?.length) env.SP_START_SHOP = opts.fast.shop.join(',');
if (opts.fast.idleBots) env.SP_IDLE_BOTS = '1'; if (opts.fast.idleBots) env.SP_IDLE_BOTS = '1';
} }
const child = spawn(process.execPath, [entry], { cwd: ROOT, env, stdio: ['ignore', 'pipe', 'pipe'] }); const child = spawn(process.execPath, [entry], { cwd: ROOT, env, stdio: ['ignore', 'pipe', 'pipe'] });
+6
View File
@@ -10,6 +10,8 @@
// SP_IDLE_BOTS=1: AI seats only ready up (no shop, no board) — test/ui/playtest2.real.e2e.test.js observes their battles. // SP_IDLE_BOTS=1: AI seats only ready up (no shop, no board) — test/ui/playtest2.real.e2e.test.js observes their battles.
// SP_START_ITEMS=<itemId,…>: the starter kit also puts these items into the hand (test/ui/leftovers.e2e.test.js: three // SP_START_ITEMS=<itemId,…>: the starter kit also puts these items into the hand (test/ui/leftovers.e2e.test.js: three
// distinct equipment items → the equip-replace dialog). // distinct equipment items → the equip-replace dialog).
// SP_START_SHOP=<chessId,…>: the starter kit also stocks the first shop slots with these chess at their normal price
// (test/ui/playtest6-elite.e2e.test.js: the copy that completes a merge).
// Not a test file (node --test runs it as a no-op module when NODE_TEST_CONTEXT is set). // Not a test file (node --test runs it as a no-op module when NODE_TEST_CONTEXT is set).
import { startServer } from '../../server/index.js'; import { startServer } from '../../server/index.js';
@@ -25,6 +27,7 @@ if (!process.env.NODE_TEST_CONTEXT) {
const idleBots = process.env.SP_IDLE_BOTS === '1'; const idleBots = process.env.SP_IDLE_BOTS === '1';
const kitIds = String(process.env.SP_START_CHESS || '').split(',').map((x) => x.trim()).filter(Boolean); const kitIds = String(process.env.SP_START_CHESS || '').split(',').map((x) => x.trim()).filter(Boolean);
const kitItems = String(process.env.SP_START_ITEMS || '').split(',').map((x) => x.trim()).filter(Boolean); const kitItems = String(process.env.SP_START_ITEMS || '').split(',').map((x) => x.trim()).filter(Boolean);
const kitShop = String(process.env.SP_START_SHOP || '').split(',').map((x) => x.trim()).filter(Boolean);
class FastMatch extends Match { class FastMatch extends Match {
constructor(opts) { constructor(opts) {
@@ -75,6 +78,9 @@ if (!process.env.NODE_TEST_CONTEXT) {
if (slot < 0 || !this.gd.item(itemId)) continue; if (slot < 0 || !this.gd.item(itemId)) continue;
try { ps.hand[slot] = ps.newPiece('item', itemId); } catch { /* best effort */ } try { ps.hand[slot] = ps.newPiece('item', itemId); } catch { /* best effort */ }
} }
kitShop.forEach((id, i) => {
if (i < ps.shop.slots.length && this.gd.chess(id)) ps.shop.slots[i] = { kind: 'chess', id, basePrice: this.gd.chessPrice(id), frozen: false, sold: false };
});
ps.addFunds(20, { reason: 'income' }); ps.addFunds(20, { reason: 'income' });
ps.recompute(); ps.recompute();
} }
+9 -2
View File
@@ -28,11 +28,18 @@ test('no custom balance: legacy tuning multipliers are ignored; enemy scale = th
assert.deepEqual(hard.enemyScale(r), { hpMul: e.hp, atkMul: e.atk, speedMul: e.speed }, `R${r}: the config (PRTS) table`); assert.deepEqual(hard.enemyScale(r), { hpMul: e.hp, atkMul: e.atk, speedMul: e.speed }, `R${r}: the config (PRTS) table`);
} }
assert.equal(hard.bossHpMul('boss_1'), 1); assert.equal(hard.bossHpMul('boss_1'), 1);
// co-op: one pool = bloodPoint[difficulty] whatever the number of alive players; solo: ×0.25 [ASSUMED, flagged] // co-op: one pool = bloodPoint[difficulty] whatever the alive count (× alive / 4 only with config aliveScaling, off —
// DESIGN §20.10); solo: ×0.25 [ASSUMED, flagged]
for (const [modeId, key] of [['mode_multi_funny', 'FUNNY'], ['mode_multi_normal', 'NORMAL'], ['mode_multi_hard', 'HARD'], ['mode_multi_abyss', 'ABYSS']]) { for (const [modeId, key] of [['mode_multi_funny', 'FUNNY'], ['mode_multi_normal', 'NORMAL'], ['mode_multi_hard', 'HARD'], ['mode_multi_abyss', 'ABYSS']]) {
const gd = new GameData(RAW, modeId); const gd = new GameData(RAW, modeId);
for (const b of ['boss_1', 'boss_5', 'boss_8']) assert.equal(gd.bossPoolHp(b), DATA.bosses[b].bloodPoint[key], `${modeId} ${b}`); for (const b of ['boss_1', 'boss_5', 'boss_8']) {
assert.equal(gd.bossPoolHp(b), DATA.bosses[b].bloodPoint[key], `${modeId} ${b}`);
assert.equal(gd.bossPoolHp(b, 4), DATA.bosses[b].bloodPoint[key], `${modeId} ${b} four alive`);
assert.equal(gd.bossPoolHp(b, 2), DATA.bosses[b].bloodPoint[key], `${modeId} ${b} two alive: still the data value`);
}
} }
assert.equal(DATA.config.bossHpScale.aliveScaling, false);
assert.equal(DATA.config.bossHpScale.aliveAssumed, true);
const solo = new GameData(RAW, 'mode_single_hard'); const solo = new GameData(RAW, 'mode_single_hard');
assert.equal(solo.bossPoolHp('boss_2'), Math.round(DATA.bosses.boss_2.bloodPoint.HARD * 0.25)); assert.equal(solo.bossPoolHp('boss_2'), Math.round(DATA.bosses.boss_2.bloodPoint.HARD * 0.25));
assert.equal(DATA.config.bossHpScale.soloAssumed, true); assert.equal(DATA.config.bossHpScale.soloAssumed, true);
+206
View File
@@ -0,0 +1,206 @@
// Final Assault leader HP vs the official game (user report after playtest #6: "终极 / 绝境 leaders fell within a few
// seconds; with our layers the official leader never dies that fast"). DESIGN §20.10.
// * Pool: one pool for every boss field (official tip "最终攻势中,所有人将一起对敌方领袖造成伤害"), bloodPoint[difficulty]
// of data/bosses.json (= activity_table bossInfoDict bloodPoint / Normal / Hard / Abyss of the current data; PRTS
// 盟约记录's leader table is the older 11月18日 revision, 铳 险境 and 胄 / 铳 / 萨米 绝境 differ, no 终极 column) whatever
// the number of alive players (× alive / 4 only with config bossHpScale.aliveScaling, off until confirmed).
// * Damage: the 卫戍 systems' "+X%" attribute bonuses are 直接乘算 — summed, not compounded (PRTS 盟约记录 / 游戏数据基础);
// v2.5 compounded them, which made stacked lineups kill the leaders 1.2–3× faster (more with more layers).
// Real bot matches to the Final Assault (real sim, server-run fields): every operator fighting the leader carries its
// bond / strategy / equipment bonuses in the additive bucket, both fields drain the one pool exactly once per hit.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { PHASE } from '../../shared/constants.js';
import { makeMatch, DATA } from './harness.js';
import { aggregateMods } from '../../server/sim/buffs.js';
import { bondBb } from '../../server/sim/content/bonds/addon/battle.js';
import { DataSource } from '../../server/sim/simdata.js';
import { createBattleFromSpec } from '../../server/sim/spec.js';
import { SharedBossPool } from '../../server/match/finalAssault.js';
/** 4 AI seats, co-op, LP 400 (they reach R14), +`layers` on every active bond at the boss round's prep. */
function toFinalAssault({ difficulty, seed, bossId, layers = 0 }) {
const seats = [0, 1, 2, 3].map((i) => ({ seat: i, playerId: `ai_${i}`, name: `AI${i}`, isBot: true, connected: true }));
// instant: false — the boss fields wait for the test to step them (server-run, real sim)
const h = makeMatch({ mode: 'coop', difficulty, seats, seed, captureFrames: false, instant: false });
const m = h.m;
m.bossId = bossId;
m.start();
let last = '';
h.run(() => {
const k = `${m.phase}:${m.round}`;
if (k !== last) {
last = k;
if (m.phase === PHASE.PREP && m.round === 1) for (const ps of m.players.values()) ps.lp = 400;
if (layers && m.phase === PHASE.PREP && m.round === m.gd.bossRound) {
for (const ps of m.alivePlayers()) {
for (const id of m.gd.bondIds) if (ps.bonds[id] && ps.bonds[id].active) ps.layers[id] = (ps.layers[id] || 0) + layers;
ps.recompute();
}
}
}
return h.ended != null || m.phase === PHASE.FINAL_ASSAULT;
}, { maxSteps: 8e6 });
assert.equal(m.phase, PHASE.FINAL_ASSAULT, 'reached the Final Assault');
return h;
}
// activity_table act2autochess.bossInfoDict [bloodPoint, bloodPointNormal, bloodPointHard, bloodPointAbyss] — the local
// cache and upstream ArknightsGameData master (zh_CN) were identical on 2026-10-01. PRTS 盟约记录's leader table is the
// older 11月18日 revision (铳 险境 450 000, 胄 / 铳 / 萨米 绝境 1 600 000 / 870 000 / 1 800 000, no 终极, no 卢西恩).
const OFFICIAL_BLOOD_POINT = {
boss_1: [247500, 675000, 1800000, 3600000], boss_2: [225000, 400000, 800000, 3000000],
boss_3: [285000, 708750, 2000000, 4000000], boss_4: [307500, 708750, 2100000, 4200000],
boss_5: [200000, 390000, 780000, 3000000], boss_6: [285000, 705000, 1990000, 3980000],
boss_7: [277500, 787500, 2000000, 4000000], boss_8: [750000, 937500, 3600000, 7200000],
boss_9: [675000, 900000, 2800000, 3950000], boss_10: [825000, 1012500, 3800000, 7600000],
};
test('leader HP data = the current official bossInfoDict for every leader and difficulty', () => {
assert.deepEqual(Object.keys(DATA.bosses).sort(), Object.keys(OFFICIAL_BLOOD_POINT).sort());
for (const [id, want] of Object.entries(OFFICIAL_BLOOD_POINT)) {
const bp = DATA.bosses[id].bloodPoint;
assert.deepEqual([bp.FUNNY, bp.NORMAL, bp.HARD, bp.ABYSS], want, id);
}
});
const SYSTEM_KEY = /^(bond|item|band|choice):/;
const MUL_STATS = ['atkMul', 'defMul', 'hpMul'];
test('终极 Final Assault (+200 layers per active bond): one bloodPoint pool for both fields, bonuses additive, each hit once', () => {
const h = toFinalAssault({ difficulty: 'ABYSS', seed: 2, bossId: 'boss_1', layers: 200 });
const m = h.m;
const pool = m.bossPool;
assert.equal(m.alivePlayers().length, 4);
assert.equal(pool.maxHp, DATA.bosses.boss_1.bloodPoint.ABYSS, 'full team: the data value (3 600 000)');
const fields = m.fields.filter((f) => f.battle);
assert.equal(fields.length, 2, 'two pair fields');
// one second into the fight: every operator's stats
for (const f of fields) for (let i = 0; i < 30; i++) f.battle.step();
let ops = 0, withBonds = 0;
for (const f of fields) {
for (const u of f.battle.allyUnits) {
if (u.kind !== 'op' || !u.alive) continue;
ops++;
const sys = u.buffs.filter((b) => SYSTEM_KEY.test(String(b.key)) && b.mods);
if (sys.length) withBonds++;
for (const b of sys) {
for (const k of MUL_STATS) assert.ok(!(k in b.mods), `${u.name}: ${b.key} is a 直接乘算 bonus, not ${k} (${JSON.stringify(b.mods)})`);
}
const { add, mul } = aggregateMods(u.buffs);
const want = Math.max(0, (u.base.atk + (add.atkFlat ?? 0)) * Math.max(0, 1 + (add.atkPct ?? 0)) * (mul.atkMul ?? 1));
assert.ok(Math.abs(u.s.atk - want) <= 1e-6 * Math.max(1, want), `${u.name}: ATK = (base + flat) × (1 + Σ%) × Π(提升至/runes)`);
}
}
assert.ok(ops >= 12 && withBonds >= 8, `the bots' lineups carry bond / item bonuses (${withBonds} of ${ops})`);
m.dispose();
});
test('绝境 Final Assault: both pair fields drain the one pool, every hit exactly once', () => {
const h = toFinalAssault({ difficulty: 'HARD', seed: 3, bossId: 'boss_5' });
const m = h.m;
assert.equal(m.bossPool.maxHp, DATA.bosses.boss_5.bloodPoint.HARD, 'four alive: the data value');
assert.equal(m.gd.bossPoolHp('boss_5', 2), DATA.bosses.boss_5.bloodPoint.HARD, 'two alive: the same pool (aliveScaling off)');
const pool = m.bossPool;
const fields = m.fields.filter((f) => f.battle);
assert.equal(fields.length, 2);
assert.ok(fields.every((f) => f.battle.sharedBoss === pool || f.battle.sharedBoss.pool === pool), 'one pool behind both fields');
const before = pool.hp;
const dealt = new Map();
// (element gauge fills — type 'element', 元素损伤 — fill the leader's gauge, not its HP)
for (const f of fields) f.battle.on('damaged', (c) => { if (c.target && c.target.bossPool && c.type !== 'element') dealt.set(f.fieldId, (dealt.get(f.fieldId) || 0) + c.amount); }, { priority: -1e9 });
for (let i = 0; i < 600; i++) for (const f of fields) if (!f.battle.finished) f.battle.step(); // 20 game s
const sum = [...dealt.values()].reduce((a, b) => a + b, 0);
assert.ok(dealt.get('b1') > 0 && dealt.get('b2') > 0, `both fields hit the leader (${[...dealt]})`);
assert.ok(Math.abs((before - pool.hp) - Math.min(before, sum)) <= 1e-6 * before, `the pool lost exactly the hits (${before - pool.hp} vs ${sum})`);
m.dispose();
});
test('终极 Final Assault vs 假想敌:胄 (seed 12): both players\' 奥术 never multiply on the leader; a drone costs it 2 % of the pool', () => {
// DESIGN §20.10: one 奥术 instance per target (the strongest — PRTS 作战机制 同名buff, 巴哈姆特 12316 "共享型buff會跟對面搶");
// 死亡集群's "最大生命值2%" = the leader's shown max HP, the pool (DRONE_LINK_BASE 'pool' [ASSUMED]): 72 000 at 终极
// seed 12: since the elite-to-board merge (DESIGN §20.11) the bots' boards differ; seed 7 no longer pairs two 奥术 players
const h = toFinalAssault({ difficulty: 'ABYSS', seed: 12, bossId: 'boss_1' });
const m = h.m;
const fields = m.fields.filter((f) => f.battle);
const bb = bondBb('arcaneShip');
const both = fields.find((f) => f.players.length === 2 && f.players.every((pid) => f.battle.getPlayer(pid).bonds.arcaneShip?.active));
assert.ok(both, 'precondition: a pair field where both players run 奥术 (bot lineups of seed 12)');
const allowed = [];
for (const pid of both.players) {
const b = both.battle.getPlayer(pid).bonds.arcaneShip;
const v = bb.base_damage_scale + bb.damage_scale_per_stack * b.layers;
allowed.push(v, v * bb.power_weak_scale);
}
const links = [];
for (const f of fields) {
f.battle.on('damaged', (c) => {
if (c.target && c.target.bossPool && c.type === 'true' && c.dmg && !c.dmg.origin && c.dmg.tags && c.dmg.tags.includes('hpLoss')) links.push(c.amount);
}, { priority: -1e9 });
}
let withArcane = 0;
for (let i = 0; i < 1200; i++) { // 40 game s
for (const f of fields) if (!f.battle.finished) f.battle.step();
for (const e of both.battle.enemies) {
if (!e.alive || !e.isBoss) continue;
const arc = e.buffs.filter((x) => String(x.key).startsWith('bond:arcaneShip'));
assert.ok(arc.length <= 1, `one 奥术 instance on the leader (${arc.map((x) => x.key)})`);
if (!arc.length) continue;
withArcane++;
const v = arc[0].mods.artsTakenMul;
assert.ok(allowed.some((a) => Math.abs(a - v) < 1e-9), `the 奥术 value is one player's (${v} ∉ ${allowed}), never a product`);
}
}
assert.ok(withArcane > 100, `the leader carried 奥术 (${withArcane} samples)`);
assert.ok(links.length >= 1, 'drones were shot down');
for (const x of links) assert.equal(x, DATA.bosses.boss_1.bloodPoint.ABYSS * 0.02, 'drone link = 2 % × the 3 600 000 pool');
m.dispose();
});
test('绝境 Hidden Core vs 假想敌:铳 (隐秘核心): a 碎铳之簧 passes every damage it takes to the pool 1:1, 无来源, credited', () => {
// DESIGN §20.10: PRTS 碎铳之簧 "受到伤害时令…假想敌:铳受到等量的无来源生命流失" (v2.5: half); real match → Hidden Core specs
const seats = [0, 1, 2, 3].map((i) => ({ seat: i, playerId: `ai_${i}`, name: `AI${i}`, isBot: true, connected: true }));
const h = makeMatch({ mode: 'coop', difficulty: 'HARD', seats, seed: 7, captureFrames: false, instant: false, clientCombat: true });
const m = h.m;
m.bossId = 'boss_5';
m.hiddenBossId = 'boss_9';
const specs = [];
const orig = m._ccField.bind(m);
m._ccField = (o) => { const f = orig(o); if (f.kind === 'hidden') specs.push(f.spec); return f; };
m.start();
let last = '';
h.run(() => {
const k = `${m.phase}:${m.round}`;
if (k !== last) {
last = k;
if (m.phase === PHASE.PREP && m.round === 1) for (const ps of m.players.values()) ps.lp = 400;
if (m.phase === PHASE.PREP && m.round === m.gd.bossRound) {
for (const ps of m.alivePlayers()) {
for (const id of m.gd.bondIds) if (ps.bonds[id] && ps.bonds[id].active) ps.layers[id] = (ps.layers[id] || 0) + 100;
ps.recompute();
}
}
}
return h.ended != null || m.phase === PHASE.HIDDEN_CORE;
}, { maxSteps: 8e6 });
assert.equal(m.phase, PHASE.HIDDEN_CORE, 'reached the Hidden Core');
assert.ok(specs.length >= 1);
const pool = new SharedBossPool(m.bossPool.maxHp);
assert.equal(pool.maxHp, DATA.bosses.boss_9.bloodPoint.HARD, 'the hidden 铳 pool = bloodPoint');
const b = createBattleFromSpec(specs[0], new DataSource(DATA, null), { sharedBoss: pool, recordEvents: false, quiet: true });
// the springs stand from the start, 铳 enters ≈ 10 game s later
for (let i = 0; i < 1200 && !b.enemies.some((e) => e.alive && e.isBoss); i++) b.step();
const springs = b.enemies.filter((e) => e.alive && /enemy_90(18|19|20)_actrp/.test(e.defId));
const gun = b.enemies.find((e) => e.alive && e.isBoss);
const op = b.allyUnits.find((u) => u.kind === 'op' && u.alive);
assert.ok(springs.length >= 1 && gun && op, 'springs, 铳 and an operator on the field');
const seen = [];
b.on('damaged', (c) => { if (c.target === gun) seen.push(c); }, { priority: -1e9 });
const sp = springs.find((x) => x.defId === 'enemy_9019_actrpb') || springs[0];
const pool0 = pool.hp;
const dealt = b.dealDamage(op, sp, { amount: 50000, type: 'true', canDodge: false });
assert.ok(dealt > 0, 'the spring took damage');
assert.ok(Math.abs((pool0 - pool.hp) - dealt) < 1e-6, `the pool lost exactly what the spring took (${pool0 - pool.hp} vs ${dealt})`);
assert.ok(seen.length === 1 && seen[0].source === null && seen[0].credit === op, '无来源, credited to the operator');
m.dispose();
});
+48
View File
@@ -507,6 +507,54 @@ test('Final Assault: an early boss b.result (forced at t≈0) does not end the p
m.dispose(); m.dispose();
}); });
test('Final Assault: a 999-layer kill faster than the budget — the \'cleared\' b.result whose report covers the pool waits for the budget, no takeover (QA 6b)', () => {
const { h, m, f } = faWithForger(9122);
const max = m.bossPool.maxHp;
h.sched.advance(500); // 1 game s on the field clock: the budget credits ≤ 20 % of the pool
const gt = m._fieldElapsed(f);
const by = { [f.players[0]]: max * 0.6, [f.players[1]]: max * 0.4 + 5 };
assert.deepEqual(m.handle('p_0', { t: 'b.progress', battleId: f.battleId, gt, killed: 0, total: 0, bossDmg: max + 5, by, leaks: 0 }), { ok: true });
const res = { reason: 'cleared', time: gt, killed: 0, total: 0, errors: 0,
perPlayer: Object.fromEntries(f.players.map((pid) => [pid, { killed: 0, total: 0, leaked: [], perfect: true, layerGains: {}, coins: 0, damageDealt: by[pid], bossDamage: by[pid], healingDone: 0, deaths: 0, unitsEnd: [], unitStats: [] }])) };
assert.equal(isBattleResult(res), true);
const rejected0 = m.verifyStats.rejected;
const takeovers0 = m.verifyStats.takeovers; // the muted client's earlier normal fields went to the server
assert.deepEqual(m.handle('p_0', { t: 'b.result', battleId: f.battleId, result: res }), { ok: true });
assert.ok(m.bossPool.hp > 0.7 * max, 'still only the budget is credited');
assert.equal(f.done, false, 'the field waits for the budget');
assert.equal(f.authority, 'p_0', 'no takeover');
assert.equal(f.mode, 'client');
assert.equal(m.verifyStats.rejected, rejected0, 'not counted as rejected');
assert.ok(!h.sent.some(([pid, x]) => pid === 'p_0' && x.t === 'b.end' && x.battleId === f.battleId), 'the sender is not told to stop');
assert.equal(m._finalEnding, null);
// a re-sent copy of the result changes nothing; the silence watchdog never hands the waiting field over
assert.deepEqual(m.handle('p_0', { t: 'b.result', battleId: f.battleId, result: res }), { ok: true });
h.drive(() => m._finalEnding != null || m.phase !== PHASE.FINAL_ASSAULT, { maxSteps: 2e5 });
assert.equal(m._finalEnding, 'cleared', 'the budget credited the report: the pool is empty → victory');
assert.equal(m.bossPool ? m.bossPool.hp : 0, 0);
assert.ok(m._fieldElapsed(f) <= 5 * (max + 5) / max + 1, 'within the budget\'s 5 game s');
assert.equal(f.done, true);
assert.equal(f.resultSource, 'client', 'the held client result completed the field');
assert.equal(m.verifyStats.takeovers, takeovers0);
assert.equal(m.verifyStats.rejected, rejected0);
for (const pid of f.players) assert.ok(Math.abs((f.bossBy[pid] || 0) - Math.min(by[pid], max)) <= max * 1e-9 + 5, `${pid} credited as reported (${f.bossBy[pid]} vs ${by[pid]})`);
m.dispose();
});
test('Final Assault: a \'cleared\' b.result whose report does NOT cover the pool is still handed over (the partner takes the field)', () => {
const { h, m, f } = faWithForger(9123);
const max = m.bossPool.maxHp;
h.sched.advance(500);
const res = { reason: 'cleared', time: 1, killed: 0, total: 0, errors: 0,
perPlayer: Object.fromEntries(f.players.map((pid) => [pid, { killed: 0, total: 0, leaked: [], perfect: true, layerGains: {}, coins: 0, damageDealt: 0, bossDamage: pid === 'p_0' ? max * 0.3 : 0, healingDone: 0, deaths: 0, unitsEnd: [], unitStats: [] }])) };
assert.deepEqual(m.handle('p_0', { t: 'b.result', battleId: f.battleId, result: res }), { ok: true });
assert.equal(f.heldResult, null);
assert.equal(f.authority, 'p_1', 'the partner\'s replica is promoted');
assert.equal(h.lastTo('p_0', 'b.end').reason, 'takeover');
assert.ok(m.verifyStats.rejected >= 1);
m.dispose();
});
test('Final Assault run on the server (nobody connected at its start): a human who reconnects gets b.pool with the field\'s own damage acknowledged', () => { test('Final Assault run on the server (nobody connected at its start): a human who reconnects gets b.pool with the field\'s own damage acknowledged', () => {
const h = makeMatch({ mode: 'coop', difficulty: 'FUNNY', humans: 2, seed: 9110, fake: true, clientCombat: true, instant: false, const h = makeMatch({ mode: 'coop', difficulty: 'FUNNY', humans: 2, seed: 9110, fake: true, clientCombat: true, instant: false,
script: (b) => (b.kind === 'boss' ? { bossDps: 3000 } : { duration: 2 }) }).start(); script: (b) => (b.kind === 'boss' ? { bossDps: 3000 } : { duration: 2 }) }).start();
+2 -1
View File
@@ -182,7 +182,8 @@ test('onLeave: a quitter has no place in the Final Assault pairing or the boss p
assert.ok(!alive.includes(first)); assert.ok(!alive.includes(first));
h.drive(() => m.phase === PHASE.FINAL_ASSAULT); h.drive(() => m.phase === PHASE.FINAL_ASSAULT);
assert.deepEqual(m.fields.map((f) => f.players), [alive], 'one boss field for the two players left'); assert.deepEqual(m.fields.map((f) => f.players), [alive], 'one boss field for the two players left');
assert.equal(m.bossPool.maxHp, bossPoolHp(m.gd, m.bossId, 2), 'the co-op boss pool is bloodPoint (no alive-player factor)'); assert.equal(m.bossPool.maxHp, bossPoolHp(m.gd, m.bossId, 2), 'the co-op boss pool of two alive players');
assert.equal(m.bossPool.maxHp, m.gd.boss(m.bossId).bloodPoint[m.gd.difficulty], 'bloodPoint, no alive-player factor (DESIGN §20.10)');
const end = h.runToEnd(); const end = h.runToEnd();
assert.equal(end.victory, true); assert.equal(end.victory, true);
assert.equal(end.players.find((p) => p.playerId === first).roundsPassed, 0); assert.equal(end.players.find((p) => p.playerId === first).roundsPassed, 0);
+1 -1
View File
@@ -142,7 +142,7 @@ test('effect-placed pieces: a transformation / a merge elite taking a consumed c
const np = ps.transformChess(a, other); const np = ps.transformChess(a, other);
assert.ok(np && ps.board.get(tileKey(9, 3)) === np, 'kept the tile'); assert.ok(np && ps.board.get(tileKey(9, 3)) === np, 'kept the tile');
assert.equal(np.dir, 'DOWN'); assert.equal(np.dir, 'DOWN');
// merge with a full hand and temp: the elite takes a consumed copy's freed board tile, with its dir // merge of deployed copies (PRTS: the elite goes to the consumed copy's board position), with its dir
ps.board.clear(); ps.board.clear();
ps.hand.fill(null); ps.hand.fill(null);
ps.temp.fill(null); ps.temp.fill(null);
+15 -5
View File
@@ -17,13 +17,23 @@ test('pairing by seat: (1,2), (3,4); an odd player alone', () => {
assert.deepEqual(pairPlayers([p(3), p(1), p(0), p(2)]).map((g) => g.map((x) => x.seat)), [[0, 1], [2, 3]]); assert.deepEqual(pairPlayers([p(3), p(1), p(0), p(2)]).map((g) => g.map((x) => x.seat)), [[0, 1], [2, 3]]);
}); });
test('boss pool = bloodPoint[difficulty] in co-op whatever the alive count (solo × 0.25) × tuning; shared and never negative', () => { test('boss pool = bloodPoint[difficulty] in co-op whatever the alive count (× alive / 4 only with aliveScaling; solo × 0.25) × tuning; shared and never negative', () => {
// research numbers (data/tuning.json left out); research 08 §6 #7: "敌方领袖的总生命值不变" — no alive-player factor // research numbers (data/tuning.json left out); DESIGN §20.10: notice 5114's "敌方领袖的总生命值不变" is about the
// mirrored copies, the one note on player count (巴哈姆特 12294 "聯機隊友(撤退/死掉)變少,最後boss血條也會變少") has no
// proportion — config bossHpScale.aliveScaling (off) would apply × alive / 4
const { tuning, ...RAW } = DATA; // eslint-disable-line no-unused-vars const { tuning, ...RAW } = DATA; // eslint-disable-line no-unused-vars
const gd = new GameData(RAW, 'mode_multi_hard'); const gd = new GameData(RAW, 'mode_multi_hard');
assert.equal(bossPoolHp(gd, 'boss_1', 4), 1800000); for (const n of [4, 3, 2, 1, undefined, 9]) assert.equal(bossPoolHp(gd, 'boss_1', n), 1800000, `${n} alive`);
assert.equal(bossPoolHp(gd, 'boss_1', 2), 1800000); assert.equal(gd.bossPoolHp('boss_1', 2), bossPoolHp(gd, 'boss_1', 2), 'GameData agrees');
assert.equal(bossPoolHp(gd, 'boss_1', 1), 1800000); // the flip: config bossHpScale.aliveScaling true scales the pool by alive / 4
const scaled = new GameData({ ...RAW, config: { ...RAW.config, bossHpScale: { ...RAW.config.bossHpScale, aliveScaling: true },
modes: { ...RAW.config.modes, mode_multi_hard: { ...RAW.config.modes.mode_multi_hard, bossHpScale: { ...RAW.config.modes.mode_multi_hard.bossHpScale, aliveScaling: true } } } } }, 'mode_multi_hard');
assert.equal(bossPoolHp(scaled, 'boss_1', 4), 1800000);
assert.equal(bossPoolHp(scaled, 'boss_1', 3), 1350000);
assert.equal(bossPoolHp(scaled, 'boss_1', 2), 900000);
assert.equal(bossPoolHp(scaled, 'boss_1', 1), 450000);
assert.equal(bossPoolHp(scaled, 'boss_1'), 1800000, 'no count given: a full team');
assert.equal(bossPoolHp(scaled, 'boss_1', 9), 1800000, 'never above the data value');
assert.equal(bossPoolHp(new GameData(RAW, 'mode_single_abyss'), 'boss_5', 1), 750000); assert.equal(bossPoolHp(new GameData(RAW, 'mode_single_abyss'), 'boss_5', 1), 750000);
assert.equal(bossPoolHp(new GameData(RAW, 'mode_single_funny'), 'boss_2', 1), 56250); assert.equal(bossPoolHp(new GameData(RAW, 'mode_single_funny'), 'boss_2', 1), 56250);
// the balance layer multiplies the pool (docs/BALANCE.md) // the balance layer multiplies the pool (docs/BALANCE.md)
+3 -2
View File
@@ -67,7 +67,7 @@ test('PREP: a player who un-readies right after everybody was ready keeps the pr
m.dispose(); m.dispose();
}); });
test('merge with a full hand and a full temp: the elite takes a freed board tile and keeps the returned items', () => { test('merge with a full hand and a full temp: the elite takes its deployed copy\'s tile (PRTS) and keeps the returned items', () => {
const { m, ps } = prepSolo({ seed: 903 }); const { m, ps } = prepSolo({ seed: 903 });
const id = chessOfTier(1, (c) => c.position === 'MELEE').find((c) => m.pool.has(c) && m.gd.mergeCount(c) === 3); const id = chessOfTier(1, (c) => c.position === 'MELEE').find((c) => m.pool.has(c) && m.gd.mergeCount(c) === 3);
const t1 = legalTileFor(m, ps, id); const t1 = legalTileFor(m, ps, id);
@@ -87,7 +87,8 @@ test('merge with a full hand and a full temp: the elite takes a freed board tile
checkInvariants(m); checkInvariants(m);
assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot: 0 }), { ok: true }, 'a purchase that completes a merge is allowed with a full hand'); assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot: 0 }), { ok: true }, 'a purchase that completes a merge is allowed with a full hand');
const elite = [...ps.board.values()].find((p) => p.id === m.gd.goldenIdOf(id)); const elite = [...ps.board.values()].find((p) => p.id === m.gd.goldenIdOf(id));
assert.ok(elite, 'the elite stands on a freed tile'); assert.ok(elite, 'the elite stands on a consumed copy\'s tile');
assert.equal(ps.find(elite.uid).key, `${t1[0]},${t1[1]}`, 'the copy that deploys first (legalTileFor walks the deploy order)');
assert.equal(elite.poolCopies, 3, 'it holds all three copies'); assert.equal(elite.poolCopies, 3, 'it holds all three copies');
assert.deepEqual(elite.items.map((x) => x.id).sort(), ['chess_item_1_01_e_a', 'chess_item_2_04_e_a'], 'returned items are kept on it'); assert.deepEqual(elite.items.map((x) => x.id).sort(), ['chess_item_1_01_e_a', 'chess_item_2_04_e_a'], 'returned items are kept on it');
checkInvariants(m); checkInvariants(m);
+46
View File
@@ -0,0 +1,46 @@
// Bond layer cap (user report after playtest #6, DESIGN §20.10 / §20.12): a bond holds at most BOND_LAYER_CAP — 999, the
// client's AutoChessBattleConst.MAX_GARRISON_STACK (research 11 §1; the community also never names a count above 999:
// 巴哈姆特 12534 "每把都能999层", 12316 "999謝"). v2.5 had no cap, so per-layer bonuses kept growing past anything the
// official game reaches. Prep-side gains (PlayerState.addLayers: items, choices, bands, garrisons) and the IN_BATTLE gains
// merged at settlement both stop there (shared/constants.js layerGainRoom, the one implementation).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { PHASE, BOND_LAYER_CAP } from '../../shared/constants.js';
import { MetaRegistry } from '../../server/match/effectsMeta.js';
import { registerBuiltins } from '../../server/match/builtinMeta.js';
import { DATA, makeMatch, checkInvariants } from './harness.js';
test('prep-side layer gains stop at 999; a gain at the cap adds nothing and fires no onLayers', () => {
assert.equal(BOND_LAYER_CAP, 999);
const reg = registerBuiltins(new MetaRegistry(), DATA);
const seen = [];
reg.global('watch', { onLayers: (ctx, ev) => { seen.push([ev.bondId, ev.from, ev.to]); } });
const h = makeMatch({ mode: 'solo', seed: 81, registry: reg, fake: true }).start();
h.toPrep(1);
const ps = h.ps('p_0');
ps.layers.yanShip = 990;
assert.equal(ps.addLayers('yanShip', 20, { reason: 'test' }), 9, 'only the room left under the cap');
assert.equal(ps.layers.yanShip, 999);
assert.equal(ps.addLayers('yanShip', 5, { reason: 'test' }), 0, 'at the cap: nothing');
assert.equal(ps.layers.yanShip, 999);
assert.deepEqual(seen, [['yanShip', 990, 999]], 'one onLayers, with the clamped value');
assert.equal(ps.addLayers('sargonShip', 40, { reason: 'test' }), 40, 'other bonds unaffected');
checkInvariants(h.m);
h.m.dispose();
});
test('IN_BATTLE gains merged at settlement stop at 999 too; below the cap they apply in full', () => {
const h = makeMatch({
mode: 'coop', humans: 2, seed: 82, fake: true,
script: (b) => (b.round === 1 ? { layerGains: { p_0: { yanShip: 50 }, p_1: { yanShip: 50 } } } : {}),
}).start();
const m = h.m;
h.toPrep(1);
const [a, b] = [h.ps('p_0'), h.ps('p_1')];
a.layers.yanShip = 980;
h.drive(() => m.phase === PHASE.SETTLE);
assert.equal(a.layers.yanShip, 999, '980 + 50 stops at 999');
assert.equal(b.layers.yanShip, 50);
checkInvariants(m);
m.dispose();
});
+65 -26
View File
@@ -3,6 +3,7 @@ import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { ERR, PHASE } from '../../shared/constants.js'; import { ERR, PHASE } from '../../shared/constants.js';
import { DATA, makeMatch, give, giveItem, checkInvariants, chessOfTier, legalTileFor } from './harness.js'; import { DATA, makeMatch, give, giveItem, checkInvariants, chessOfTier, legalTileFor } from './harness.js';
import { canPlace, positionClass } from '../../server/match/board.js';
function prep(seed = 21, o = {}) { function prep(seed = 21, o = {}) {
const h = makeMatch({ mode: 'solo', difficulty: 'NORMAL', seed, ...o }).start(); const h = makeMatch({ mode: 'solo', difficulty: 'NORMAL', seed, ...o }).start();
@@ -22,11 +23,12 @@ function stock(m, ps, id, slot = 0) {
ps.shop.slots[slot] = { kind: 'chess', id, basePrice: m.gd.chessPrice(id), frozen: false, sold: false }; ps.shop.slots[slot] = { kind: 'chess', id, basePrice: m.gd.chessPrice(id), frozen: false, sold: false };
} }
test('3 copies (board + hand + bought) merge into 1 elite in the hand; equipment returns; reward offer of tier level+1', () => { test('3 copies (board + hand + bought) merge into 1 elite on the board copy\'s tile; equipment returns; reward offer of tier level+1', () => {
const { m, ps } = prep(); const { m, ps } = prep();
const id = chessOfTier(1, (c) => c.position === 'MELEE').find((x) => m.pool.has(x)); const id = chessOfTier(1, (c) => c.position === 'MELEE').find((x) => m.pool.has(x));
const cap = m.pool.cap(id); const cap = m.pool.cap(id);
const a = give(m, ps, id, 'board', [9, 3]); const a = give(m, ps, id, 'board', [9, 3]);
a.dir = 'UP';
const b = give(m, ps, id); const b = give(m, ps, id);
const it1 = giveItem(m, ps, 'chess_item_1_01_e_a'); const it1 = giveItem(m, ps, 'chess_item_1_01_e_a');
const it2 = giveItem(m, ps, 'chess_item_1_02_e_a'); const it2 = giveItem(m, ps, 'chess_item_1_02_e_a');
@@ -35,9 +37,13 @@ test('3 copies (board + hand + bought) merge into 1 elite in the hand; equipment
stock(m, ps, id); stock(m, ps, id);
assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot: 0 }), { ok: true }); assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot: 0 }), { ok: true });
const golden = DATA.chess[id].goldenId; const golden = DATA.chess[id].goldenId;
assert.ok(!ps.board.has('9,3'), 'the board copy was consumed'); // PRTS 卫戍协议/帮助: "若消耗已部署至作战区的干员,则发送至作战区对应位置"
const elite = ps.hand.find((p) => p && p.id === golden); const elite = ps.board.get('9,3');
assert.ok(elite, 'elite in the hand'); assert.ok(elite && elite.id === golden, 'the elite took the consumed board copy\'s tile');
assert.notEqual(elite.uid, a.uid, 'a new piece (the copy was destroyed)');
assert.equal(elite.dir, 'UP', 'with that copy\'s facing');
assert.ok(!ps.hand.some((p) => p && p.kind === 'chess'), 'no chess left in the hand');
assert.equal(ps.deployCount, 1, 'the deploy count is unchanged');
assert.equal(elite.poolCopies, 3); assert.equal(elite.poolCopies, 3);
assert.equal(m.pool.left(id), cap - 3); assert.equal(m.pool.left(id), cap - 3);
assert.equal(ps.countCopies(id), 0); assert.equal(ps.countCopies(id), 0);
@@ -97,18 +103,44 @@ test('elites never merge; 风丸 merges with 2 copies; a full hand still buys th
m.dispose(); m.dispose();
}); });
test('merge from two board copies with a full hand overflows the elite into temp (blocks ready until resolved)', () => { test('merge from two board copies with a full hand: the elite takes the copy that deploys first (no overflow, one tile freed)', () => {
const { m, ps } = prep(23); const { m, ps } = prep(23);
const ids = chessOfTier(1, (c) => c.position === 'MELEE').filter((c) => m.pool.has(c)); const ids = chessOfTier(1, (c) => c.position === 'MELEE').filter((c) => m.pool.has(c));
const id = ids[0]; const id = ids[0];
give(m, ps, id, 'board', [9, 3]); const a = give(m, ps, id, 'board', [9, 4]);
give(m, ps, id, 'board', [9, 4]); const b = give(m, ps, id, 'board', [9, 3]);
a.dir = 'LEFT';
b.dir = 'DOWN';
const others = chessOfTier(2).filter((c) => m.pool.has(c));
for (let i = 0; ps.hand.some((p) => p == null); i++) give(m, ps, others[i]);
stock(m, ps, id);
assert.equal(ps.deployCount, 2);
assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot: 0 }), { ok: true });
// deploy order = row desc, then col asc: (9,3) before (9,4) — [ASSUMED] the copy the battle deploys first
const elite = ps.board.get('9,3');
assert.ok(elite && elite.id === DATA.chess[id].goldenId, 'the elite stands on (9,3)');
assert.equal(elite.dir, 'DOWN', 'with the facing of the copy that stood there');
assert.ok(!ps.board.has('9,4'), 'the other copy\'s tile is free');
assert.equal(ps.deployCount, 1, 'two deployed copies became one deployed elite');
assert.ok(ps.tempEmpty, 'nothing overflowed');
assert.equal(ps.hand.filter(Boolean).length, 10, 'the full hand is untouched');
assert.deepEqual(m.handle('p_0', { t: 'g.ready', ready: true }), { ok: true });
checkInvariants(m);
m.dispose();
});
test('merge whose copies are not deployed (temp) with a full hand overflows the elite into temp (blocks ready until resolved)', () => {
const { m, ps } = prep(23);
const ids = chessOfTier(1, (c) => c.position === 'MELEE').filter((c) => m.pool.has(c));
const id = ids[0];
give(m, ps, id, 'temp', 0);
give(m, ps, id, 'temp', 1);
const others = chessOfTier(2).filter((c) => m.pool.has(c)); const others = chessOfTier(2).filter((c) => m.pool.has(c));
for (let i = 0; ps.hand.some((p) => p == null); i++) give(m, ps, others[i]); for (let i = 0; ps.hand.some((p) => p == null); i++) give(m, ps, others[i]);
stock(m, ps, id); stock(m, ps, id);
assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot: 0 }), { ok: true }); assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot: 0 }), { ok: true });
assert.ok(ps.temp.some((p) => p && p.id === DATA.chess[id].goldenId), 'elite overflowed into temp'); assert.ok(ps.temp.some((p) => p && p.id === DATA.chess[id].goldenId), 'elite overflowed into temp');
assert.equal(ps.board.size, 0); assert.equal(ps.board.size, 0, 'nothing was deployed');
assert.deepEqual(m.handle('p_0', { t: 'g.ready', ready: true }), { error: ERR.TEMP_NOT_EMPTY }); assert.deepEqual(m.handle('p_0', { t: 'g.ready', ready: true }), { error: ERR.TEMP_NOT_EMPTY });
checkInvariants(m); checkInvariants(m);
m.dispose(); m.dispose();
@@ -264,10 +296,12 @@ test('item merge with a full hand AND a full temp: the golden item takes the equ
m.dispose(); m.dispose();
}); });
test('a merge completed during SETTLE (突变细胞) keeps its reward offer for the next prep; with a full hand the elite waits in temp for that prep', () => { test('a merge completed during SETTLE (突变细胞) keeps its reward offer for the next prep; the elite takes a deployed copy\'s tile (the carrier\'s counts) or goes to the hand', () => {
const X = 'chess_char_2_04_a'; const X = 'chess_char_2_04_a';
const fillers = Object.values(DATA.items).filter((i) => i.itemType === 'EQUIP' && !i.isGolden && !String(i.kind || '').startsWith('consume')).map((i) => i.itemId ?? i.id).filter(Boolean); const fillers = Object.values(DATA.items).filter((i) => i.itemType === 'EQUIP' && !i.isGolden && !String(i.kind || '').startsWith('consume')).map((i) => i.itemId ?? i.id).filter(Boolean);
for (const onBoard of [false, true]) { // where the carrier of 突变细胞 and the two copies of X are: hand/hand, board/hand, board/board
for (const [carrierAt, copiesAt] of [['hand', 'hand'], ['board', 'hand'], ['board', 'board']]) {
const label = `carrier ${carrierAt}, copies ${copiesAt}`;
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 1, bots: 1, seed: 3, fake: true }).start(); const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 1, bots: 1, seed: 3, fake: true }).start();
const m = h.m; const m = h.m;
h.toPrep(1); h.toPrep(1);
@@ -280,35 +314,40 @@ test('a merge completed during SETTLE (突变细胞) keeps its reward offer for
const saved = new Map(); const saved = new Map();
for (const [base, e] of m.pool.entries) if (e.tier === 2 && base !== X) { saved.set(base, e.left); e.left = 0; } for (const [base, e] of m.pool.entries) if (e.tier === 2 && base !== X) { saved.set(base, e.left); e.left = 0; }
const T = chessOfTier(1, (c) => !m.gd.placeableTokens(c.chessId).length).find((id) => m.pool.has(id)); const T = chessOfTier(1, (c) => !m.gd.placeableTokens(c.chessId).length).find((id) => m.pool.has(id));
const holder = give(m, ps, T, 'board', legalTileFor(m, ps, T)); const holder = carrierAt === 'board' ? give(m, ps, T, 'board', legalTileFor(m, ps, T)) : give(m, ps, T);
holder.items.push(ps.newPiece('item', 'chess_item_5_08_e_a')); holder.items.push(ps.newPiece('item', 'chess_item_5_08_e_a'));
const copies = onBoard ? [0, 1].map(() => give(m, ps, X, 'board', legalTileFor(m, ps, X))) : [give(m, ps, X), give(m, ps, X)]; const copies = copiesAt === 'board' ? [0, 1].map(() => give(m, ps, X, 'board', legalTileFor(m, ps, X))) : [give(m, ps, X), give(m, ps, X)];
const tiles = onBoard ? copies.map((p) => ps.find(p.uid).key) : []; const deployedTiles = [holder, ...copies].map((p) => ps.find(p.uid)).filter((l) => l.area === 'board').map((l) => l.key);
const deployed0 = ps.deployCount;
for (let i = 0; ps.hand.some((x) => x == null); i++) giveItem(m, ps, fillers[i]); for (let i = 0; ps.hand.some((x) => x == null); i++) giveItem(m, ps, fillers[i]);
const fillers0 = ps.hand.filter((p) => p && p.kind === 'item').length;
let atSettle = null; let atSettle = null;
const settle = m.settle.bind(m); const settle = m.settle.bind(m);
m.settle = (...a) => { const r = settle(...a); atSettle = { offers: ps.offers.map((o) => o.source), merges: ps.stats.merges }; return r; }; m.settle = (...a) => { const r = settle(...a); atSettle = { offers: ps.offers.map((o) => o.source), merges: ps.stats.merges }; return r; };
h.toPrep(2); h.toPrep(2);
for (const [base, left] of saved) m.pool.entries.get(base).left = left; for (const [base, left] of saved) m.pool.entries.get(base).left = left;
assert.deepEqual(atSettle, { offers: ['merge'], merges: 1 }, `${onBoard ? 'board' : 'hand'} copies: the transform completed a merge in SETTLE`); assert.deepEqual(atSettle, { offers: ['merge'], merges: 1 }, `${label}: the transform completed a merge in SETTLE`);
const elite = ps.allChess().find((p) => p.id === m.gd.goldenIdOf(X)); const elite = ps.allChess().find((p) => p.id === m.gd.goldenIdOf(X));
assert.ok(elite, 'the elite survived the round start'); assert.ok(elite, 'the elite survived the round start');
assert.deepEqual(ps.offers.map((o) => o.source), ['merge'], 'the promotion reward waits for this prep'); assert.deepEqual(ps.offers.map((o) => o.source), ['merge'], 'the promotion reward waits for this prep');
assert.ok(ps.privateView().shop.rewardOffer, 'and is shown'); assert.ok(ps.privateView().shop.rewardOffer, 'and is shown');
if (onBoard) { const loc = ps.find(elite.uid);
// research 01 A1: the elite goes to the hand, never straight to a board tile — the full hand overflows it into if (deployedTiles.length) {
// temp, where it stays (not wiped at the round start) and blocks Ready until the player places it // PRTS 卫戍协议/帮助: a merge consuming a deployed copy sends the elite to that copy's tile — outside PREP too; the
assert.equal(ps.find(elite.uid).area, 'temp', 'full hand outside PREP: the elite overflowed into temp'); // transformed carrier stood on the board, so its tile counts (when legal for the elite); of several, the first
assert.ok(ps.privateView().temp.some((v) => v && v.uid === elite.uid), 'shown in the temp row'); // in deploy order (row desc, col asc)
assert.equal(ps.privateView().canReady, false); const map = ps.deployMap();
assert.deepEqual(m.handle('p_0', { t: 'g.ready', ready: true }), { error: ERR.TEMP_NOT_EMPTY }); const pos = positionClass(m.gd.chess(elite.id));
const [r, c] = tiles[0].split(',').map(Number); const legal = deployedTiles.map((k) => k.split(',').map(Number)).filter(([r, c]) => canPlace(map, pos, r, c)).sort((a, b) => b[0] - a[0] || a[1] - b[1]);
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: elite.uid, to: { area: 'board', row: r, col: c } }), { ok: true }, 'placed on a freed tile'); assert.ok(legal.length, `${label}: a legal deployed tile exists`);
assert.ok(ps.tempEmpty && ps.privateView().canReady); assert.equal(loc.area, 'board', `${label}: the elite stands on the board`);
assert.equal(loc.key, `${legal[0][0]},${legal[0][1]}`, `${label}: on the first deployed tile in deploy order`);
assert.ok(ps.deployCount <= deployed0, `${label}: the deploy count did not grow (${ps.deployCount} ≤ ${deployed0})`);
} else { } else {
assert.equal(ps.find(elite.uid).area, 'hand', 'the consumed hand copies freed the slots'); assert.equal(loc.area, 'hand', `${label}: the consumed hand pieces freed the slots`);
} }
assert.ok(ps.tempEmpty); assert.equal(ps.hand.filter((p) => p && p.kind === 'item').length, fillers0, `${label}: the hand's equipment stays`);
assert.ok(ps.tempEmpty && ps.privateView().canReady, `${label}: nothing waits in temp`);
checkInvariants(m); checkInvariants(m);
// it expires at the end of that prep like any other offer // it expires at the end of that prep like any other offer
h.drive(() => m.phase === PHASE.COMBAT && m.round === 2); h.drive(() => m.phase === PHASE.COMBAT && m.round === 2);
+504
View File
@@ -0,0 +1,504 @@
// User playtest #6 follow-up — a merge's elite goes to the board when it consumed a deployed copy. The user (first-hand,
// official game): "官方就是合成精锐时,如果消耗了场上的干员,精锐会出现在场上那个位置". PRTS 卫戍协议/帮助 §干员的获得与精锐化:
// "不获得第3名干员,销毁已有的2名初始干员,发送1名【精锐】状态的该干员至手牌区(若消耗已部署至作战区的干员,则发送至作战区
// 对应位置)". Remake (server/match/PlayerState.js _mergeChess, board.js mergeTile): the elite takes the tile and facing of the
// consumed copy that deploys first (row desc, col asc — [ASSUMED] when several stood on the board); a transformed deployed
// piece's own tile counts; with no deployed copy it goes to the hand (overflow temp). Equipment returns to the hand
// ("干员晋级后已配发装备会回收至整备区"), the copies' summons are removed and the elite on the board gets its own stack
// (its loadout), the deploy count never grows, the reward offer is unchanged — for buys, rewards, effect grants, SETTLE
// merges and the boss-field prep alike. audit.js checks the rule in every audited match.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { ERR, PHASE } from '../../shared/constants.js';
import { checkLoadout } from '../../shared/protocol.js';
import { DATA, makeMatch, give, giveItem, checkInvariants, chessOfTier, legalTileFor } from './harness.js';
import { canPlace, positionClass, tileKey, mergeTile } from '../../server/match/board.js';
import { makeCtx } from '../../server/match/effectsMeta.js';
import { attachAudit } from '../../server/match/audit.js';
import { botPrep } from '../../server/match/bot.js';
const chess = (id) => (Object.hasOwn(DATA.chess, id) ? DATA.chess[id] : null);
const SILENCE = 'chess_char_2_02_a'; // 赫默: S2 (default) makes the 医疗探机 hand card, S1 none
const DRONE = 'token_10000_silent_healrb';
const VIGIL = 'chess_char_3_19_a'; // 伺夜: talent summon 狼群 (a hand card)
const WOLF = 'token_10028_vigil_wolf';
const KAZEMARU = 'chess_char_2_11_a'; // 风丸: merges with 2 copies
function prep({ seed = 41, loadout = null, stageId = 'act2autochess_m04' } = {}) {
const seats = [{ seat: 0, playerId: 'p_0', name: 'P0', isBot: false, connected: true, loadout }];
const h = makeMatch({ mode: 'solo', difficulty: 'NORMAL', seats, seed }).start();
h.toPrep(1);
h.setStage(stageId);
const ps = h.ps('p_0');
clean(ps);
ps.funds = 100;
return { h, m: h.m, ps };
}
function clean(ps) {
for (const p of [...ps.board.values(), ...ps.hand.filter(Boolean), ...ps.temp.filter(Boolean)]) if (p.kind === 'chess') ps.returnCopies(p);
ps.board.clear();
ps.hand.fill(null);
ps.temp.fill(null);
ps.recompute();
}
/** Put chess `id` into shop slot `slot` at its normal price. */
function stock(m, ps, id, slot = 0) {
ps.shop.slots[slot] = { kind: 'chess', id, basePrice: m.gd.chessPrice(id), frozen: false, sold: false };
}
/** Deploy a copy of `id` on its next legal tile (reading order, skipping `skip`) with facing `dir`. */
function deploy(m, ps, id, dir = 'RIGHT', skip = new Set()) {
const at = legalTileFor(m, ps, id, skip);
assert.ok(at, `a legal tile for ${id}`);
const p = give(m, ps, id, 'board', at);
p.dir = dir;
return p;
}
const meleeTier1 = (m) => chessOfTier(1, (c) => c.position === 'MELEE' && !m.gd.placeableTokens(c.chessId).length).filter((id) => m.pool.has(id) && m.gd.mergeCount(id) === 3);
const keyOf = (ps, p) => ps.find(p.uid)?.key ?? null;
const eliteOf = (ps, id) => ps.allChess().find((p) => p.id === chess(id).goldenId) ?? null;
const tokens = (ps, id) => [...ps.hand, ...ps.temp, ...ps.board.values()].filter((p) => p && p.kind === 'token' && (!id || p.id === id));
test('buy: one deployed copy + one hand copy → the elite takes the deployed copy\'s tile and facing; deploy count and reward offer as before', () => {
const { m, ps } = prep();
const [id] = meleeTier1(m);
const a = deploy(m, ps, id, 'DOWN');
const tile = keyOf(ps, a);
give(m, ps, id);
stock(m, ps, id);
assert.equal(ps.deployCount, 1);
assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot: 0 }), { ok: true });
const elite = ps.board.get(tile);
assert.ok(elite && elite.id === chess(id).goldenId, 'the elite stands where the deployed copy stood');
assert.equal(elite.dir, 'DOWN', 'with its facing');
assert.equal(elite.poolCopies, 3, 'it holds the three copies');
assert.equal(ps.deployCount, 1, 'the deploy count is unchanged');
assert.ok(!ps.hand.some(Boolean) && ps.tempEmpty, 'hand and temp are empty');
assert.equal(ps.stats.merges, 1);
const offer = ps.privateView().shop.rewardOffer;
assert.ok(offer && offer.slots.length === 3 && offer.tier === Math.min(ps.shop.level + 1, 6), 'the promotion reward is offered as before');
const view = ps.privateView().board.find((v) => v.uid === elite.uid);
assert.deepEqual([view.row, view.col, view.dir, view.golden], [...tile.split(',').map(Number), 'DOWN', true], 'm.private shows it on the tile');
checkInvariants(m);
m.dispose();
});
test('buy: two deployed copies → the copy that deploys first (row desc, then col asc) gives its tile and facing, in either placement order', () => {
for (const order of ['first-placed-first', 'first-placed-last']) {
const { m, ps } = prep({ seed: 42 });
const [id] = meleeTier1(m);
// two legal tiles, the first of them in deploy order
const t1 = legalTileFor(m, ps, id);
const t2 = legalTileFor(m, ps, id, new Set([tileKey(...t1)]));
const [early, late] = order === 'first-placed-first' ? [t1, t2] : [t2, t1];
const a = give(m, ps, id, 'board', early);
const b = give(m, ps, id, 'board', late);
a.dir = 'UP';
b.dir = 'LEFT';
stock(m, ps, id);
assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot: 0 }), { ok: true });
const want = mergeTile([t1, t2].map((t) => ({ key: tileKey(...t) })));
assert.equal(want.key, tileKey(...t1), 'legalTileFor walks the deploy order');
const elite = ps.board.get(want.key);
assert.ok(elite && m.gd.isGolden(elite.id), `${order}: the elite on ${want.key}`);
assert.equal(elite.dir, want.key === tileKey(...early) ? 'UP' : 'LEFT', `${order}: the facing of the copy that stood there`);
assert.ok(!ps.board.has(tileKey(...t2)), `${order}: the other copy's tile is free`);
assert.equal(ps.deployCount, 1, `${order}: two deployed copies → one deployed elite`);
checkInvariants(m);
m.dispose();
}
});
test('three deployed copies (a deployed operator transformed into the third, 突变细胞-style) → the first of the three tiles in deploy order', () => {
const { m, ps } = prep({ seed: 43 });
const [id, other] = meleeTier1(m);
const carrier = deploy(m, ps, other, 'UP');
const c1 = deploy(m, ps, id, 'LEFT');
const c2 = deploy(m, ps, id, 'DOWN');
const tiles = [carrier, c1, c2].map((p) => keyOf(ps, p));
const dirOf = { [tiles[0]]: 'UP', [tiles[1]]: 'LEFT', [tiles[2]]: 'DOWN' };
const elite = ps.transformChess(carrier, id);
assert.ok(elite && elite.id === chess(id).goldenId, 'the transformation completed the merge');
const want = mergeTile(tiles.map((key) => ({ key })));
assert.equal(keyOf(ps, elite), want.key, 'the elite takes the first deployed tile');
assert.equal(elite.dir, dirOf[want.key]);
assert.equal(ps.deployCount, 1, 'three deployed → one');
assert.equal(ps.stats.merges, 1);
checkInvariants(m);
m.dispose();
});
test('only hand copies → the elite goes to the hand (a free board slot does not pull it onto the board); 风丸 merges 2 the same way', () => {
const { m, ps } = prep({ seed: 44 });
const [id, filler] = meleeTier1(m);
deploy(m, ps, filler);
give(m, ps, id);
give(m, ps, id);
stock(m, ps, id);
assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot: 0 }), { ok: true });
const elite = eliteOf(ps, id);
assert.equal(ps.find(elite.uid).area, 'hand', 'no copy was deployed: the hand');
assert.equal(ps.deployCount, 1, 'the board is unchanged');
// 风丸: one deployed copy + the bought one
if (m.pool.has(KAZEMARU) && m.gd.mergeCount(KAZEMARU) === 2) {
const k = deploy(m, ps, KAZEMARU, 'UP');
const tile = keyOf(ps, k);
stock(m, ps, KAZEMARU);
assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot: 0 }), { ok: true });
const ke = ps.board.get(tile);
assert.ok(ke && ke.id === chess(KAZEMARU).goldenId && ke.dir === 'UP', '风丸\'s elite takes its deployed copy\'s tile');
}
checkInvariants(m);
m.dispose();
});
test('equipment: the consumed copies\' items return to the hand, the elite on the board starts with empty slots', () => {
const { m, ps } = prep({ seed: 45 });
const [id] = meleeTier1(m);
const a = deploy(m, ps, id);
const b = give(m, ps, id);
const i1 = giveItem(m, ps, 'chess_item_1_01_e_a');
const i2 = giveItem(m, ps, 'chess_item_1_02_e_a');
const i3 = giveItem(m, ps, 'chess_item_2_04_e_a');
assert.deepEqual(m.handle('p_0', { t: 'g.equip', itemUid: i1.uid, targetUid: a.uid }), { ok: true });
assert.deepEqual(m.handle('p_0', { t: 'g.equip', itemUid: i2.uid, targetUid: a.uid }), { ok: true });
assert.deepEqual(m.handle('p_0', { t: 'g.equip', itemUid: i3.uid, targetUid: b.uid }), { ok: true });
const tile = keyOf(ps, a);
stock(m, ps, id);
assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot: 0 }), { ok: true });
const elite = ps.board.get(tile);
assert.ok(elite && m.gd.isGolden(elite.id));
assert.deepEqual(elite.items, [], '"干员晋级后已配发装备会回收至整备区,需要重新配置"');
for (const it of [i1, i2, i3]) assert.equal(ps.find(it.uid)?.area, 'hand', `${it.id} is back in the hand`);
// and can be equipped again on the deployed elite
assert.deepEqual(m.handle('p_0', { t: 'g.equip', itemUid: i1.uid, targetUid: elite.uid }), { ok: true });
checkInvariants(m);
m.dispose();
});
test('equipment with the hand and temp full: the elite keeps up to its 2 equip slots, any further item is destroyed (logged)', () => {
const { m, ps } = prep({ seed: 47 });
const [id] = meleeTier1(m);
const a = deploy(m, ps, id, 'UP');
const b = deploy(m, ps, id, 'DOWN');
const ids = ['chess_item_1_01_e_a', 'chess_item_1_02_e_a', 'chess_item_1_05_e_a', 'chess_item_2_04_e_a'];
const its = ids.map((x) => giveItem(m, ps, x));
its.forEach((it, i) => assert.deepEqual(m.handle('p_0', { t: 'g.equip', itemUid: it.uid, targetUid: (i < 2 ? a : b).uid }), { ok: true }));
const fill = [2, 3].flatMap((t) => chessOfTier(t)).filter((x) => m.pool.has(x));
let n = 0;
while (ps.hand.some((p) => p == null)) give(m, ps, fill[n++]);
while (ps.temp.some((p) => p == null)) give(m, ps, fill[n++], 'temp');
const want = mergeTile([a, b].map((p) => ({ key: keyOf(ps, p) }))).key;
const warns = [];
const warn = m.log.warn;
m.log.warn = (s) => { warns.push(String(s)); };
stock(m, ps, id);
assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot: 0 }), { ok: true }, 'a merge-completing buy needs no hand slot');
m.log.warn = warn;
const elite = ps.board.get(want);
assert.ok(elite && m.gd.isGolden(elite.id), 'the elite took the first deployed copy\'s tile');
assert.equal(elite.items.length, m.gd.equipPerChess, 'it keeps 2 of the 4 returned items');
const kept = new Set(elite.items.map((it) => it.uid));
assert.ok([...kept].every((u) => its.some((it) => it.uid === u)), 'the kept items are returned ones');
assert.equal(its.filter((it) => !kept.has(it.uid) && ps.find(it.uid)).length, 0, 'the other two are nowhere (hand, temp, board)');
assert.equal(warns.filter((s) => /returned item .* destroyed \(no space\)/.test(s)).length, ids.length - m.gd.equipPerChess, 'the other two are destroyed with a log warning');
checkInvariants(m);
m.dispose();
});
test('summons: the copies\' placed summons and stacks are removed; the elite on the board gets its own full stack (伺夜 狼群)', () => {
const { m, ps } = prep({ seed: 46 });
assert.ok(m.pool.has(VIGIL), '伺夜 is in this match\'s pool');
const a = give(m, ps, VIGIL);
const at = legalTileFor(m, ps, VIGIL);
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: a.uid, to: { area: 'board', row: at[0], col: at[1] }, dir: 'UP' }), { ok: true });
const stack = ps.hand.find((p) => p && p.kind === 'token' && p.ownerUid === a.uid);
assert.ok(stack && stack.id === WOLF, 'the deployed copy\'s 狼群 card');
// one wolf placed, the rest of the stack in the hand
const wt = legalTileFor(m, ps, VIGIL);
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: stack.uid, to: { area: 'board', row: wt[0], col: wt[1] } }), { ok: true });
assert.ok(tokens(ps, WOLF).some((t) => ps.find(t.uid).area === 'board'), 'a wolf is placed');
give(m, ps, VIGIL);
stock(m, ps, VIGIL);
assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot: 0 }), { ok: true });
const elite = ps.board.get(tileKey(...at));
assert.ok(elite && elite.id === chess(VIGIL).goldenId && elite.dir === 'UP', 'the elite took the copy\'s tile');
const left = tokens(ps, WOLF);
assert.ok(left.every((t) => t.ownerUid === elite.uid), 'no summon of a consumed copy is left (placed or stacked)');
assert.ok(!ps.board.has(tileKey(...wt)), 'the placed wolf left the board');
const [{ count }] = m.gd.placeableTokens(elite.id, ps.loadoutFor(chess(elite.id)));
assert.equal(left.length, 1, 'one fresh stack');
assert.equal(ps.find(left[0].uid).area, 'hand');
assert.equal(left[0].count, count, 'deploy-limit copies of the elite');
checkInvariants(m);
m.dispose();
});
test('loadout: the deployed elite\'s summon card and battle unit follow the player\'s loadout (赫默 S2 default → drone card, S1 → none)', () => {
for (const [label, loadout, drone] of [['default S2', null, true], ['S1', checkLoadout({ [SILENCE]: { skill: 0 } }, chess).loadout, false]]) {
const { m, ps } = prep({ seed: 47, loadout });
assert.ok(m.pool.has(SILENCE), '赫默 is in this match\'s pool');
const a = deploy(m, ps, SILENCE, 'LEFT');
const tile = keyOf(ps, a);
give(m, ps, SILENCE);
stock(m, ps, SILENCE);
assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot: 0 }), { ok: true });
const elite = ps.board.get(tile);
assert.ok(elite && elite.id === chess(SILENCE).goldenId, `${label}: the elite took the tile`);
const cards = tokens(ps, DRONE);
assert.equal(cards.length, drone ? 1 : 0, `${label}: drone card`);
if (drone) assert.equal(cards[0].ownerUid, elite.uid);
const u = ps.battleInput({ side: 'L', colOffset: 0 }).units.find((x) => x.uid === elite.uid);
const lo = ps.loadoutFor(chess(elite.id));
assert.ok(u, `${label}: the elite fights`);
assert.deepEqual([u.skillIndex, u.moduleId, u.dir], [lo.skillIndex, lo.moduleId, 'LEFT'], `${label}: with the loadout's skill / module and the copy's facing`);
assert.equal(u.skillIndex, drone ? 1 : 0);
checkInvariants(m);
m.dispose();
}
});
test('deploy cap: a merge with a deployed copy at the cap is never refused and never grows the count', () => {
const { m, ps } = prep({ seed: 48 });
const ids = meleeTier1(m);
const id = ids[0];
const ranged = [1, 2].flatMap((t) => chessOfTier(t, (c) => c.position !== 'MELEE' && !m.gd.placeableTokens(c.chessId).length)).filter((x) => m.pool.has(x));
const a = deploy(m, ps, id, 'UP');
const b = deploy(m, ps, id, 'DOWN');
for (let i = 0; ps.deployCount < ps.deployCap; i++) deploy(m, ps, ranged[i]);
assert.equal(ps.deployCount, ps.deployCap, 'the board is full');
const first = mergeTile([keyOf(ps, a), keyOf(ps, b)].map((key) => ({ key })));
stock(m, ps, id);
assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot: 0 }), { ok: true }, 'no BOARD_FULL: the elite replaces a deployed copy');
assert.ok(m.gd.isGolden(ps.board.get(first.key).id));
assert.equal(ps.deployCount, ps.deployCap - 1, 'two deployed copies → one elite');
// one deployed copy at the cap: unchanged
const id2 = ids[1];
const extra = deploy(m, ps, id2, 'RIGHT');
assert.equal(ps.deployCount, ps.deployCap);
give(m, ps, id2);
stock(m, ps, id2);
assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot: 0 }), { ok: true });
assert.ok(!ps.find(extra.uid));
assert.equal(ps.deployCount, ps.deployCap, 'still at the cap');
checkInvariants(m);
m.dispose();
});
test('rewards and effect grants (g.reward, a band\'s ctx.grantChess) complete merges the same way', () => {
const { m, ps } = prep({ seed: 49 });
const [id, id2] = meleeTier1(m);
// reward pick
const a = deploy(m, ps, id, 'UP');
const tile = keyOf(ps, a);
give(m, ps, id);
ps.pushRewardOffer('effect', { ids: [id] });
assert.deepEqual(m.handle('p_0', { t: 'g.reward', idx: 0 }), { ok: true });
assert.ok(m.gd.isGolden(ps.board.get(tile)?.id), 'g.reward: the elite on the copy\'s tile');
// a band grant (ctx.grantChess → acquireChess)
const b = deploy(m, ps, id2, 'LEFT');
const tile2 = keyOf(ps, b);
give(m, ps, id2);
const ctx = makeCtx(m, ps, { kind: 'band', key: 'band:test' }, 'onRoundStart');
const got = ctx.grantChess(id2);
assert.ok(got && got.id === chess(id2).goldenId, 'the grant returned the elite');
assert.equal(ps.board.get(tile2)?.uid, got.uid, 'band grant: the elite on the copy\'s tile');
assert.equal(ps.board.get(tile2).dir, 'LEFT');
checkInvariants(m);
m.dispose();
});
test('after the prep: a SETTLE grant completing a merge puts the elite on the deployed copy\'s tile; the offer waits for the next prep', () => {
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 1, bots: 1, seed: 50, fake: true }).start();
const m = h.m;
h.toPrep(1);
h.setStage('act2autochess_m04');
const ps = h.ps('p_0');
clean(ps);
const [id] = meleeTier1(m);
const a = deploy(m, ps, id, 'DOWN');
const tile = keyOf(ps, a);
give(m, ps, id);
// fill the hand: no room is needed for the elite
const fillers = ['chess_item_1_01_e_a', 'chess_item_1_02_e_a', 'chess_item_1_03_e_a', 'chess_item_1_04_e_a', 'chess_item_1_05_e_a', 'chess_item_2_01_e_a', 'chess_item_2_02_e_a', 'chess_item_2_03_e_a', 'chess_item_2_04_e_a'];
for (let i = 0; ps.hand.some((x) => x == null); i++) giveItem(m, ps, fillers[i]);
h.drive(() => m.phase === PHASE.SETTLE);
const elite = ps.acquireChess(id, { source: 'test' });
assert.ok(elite && ps.board.get(tile) === elite, 'SETTLE: the elite took the tile at once');
assert.equal(elite.dir, 'DOWN');
assert.deepEqual(ps.offers.map((o) => o.source), ['merge']);
h.toPrep(2);
assert.equal(ps.board.get(tile)?.uid, elite.uid, 'still there in the next prep');
assert.ok(ps.privateView().shop.rewardOffer, 'the promotion reward is shown in that prep');
assert.ok(ps.tempEmpty && ps.privateView().canReady, 'nothing waits in temp');
checkInvariants(m);
m.dispose();
});
test('boss-field prep (R14, act2 m01): the elite takes a copy\'s tile that is legal only on the player\'s boss half', () => {
const STAGE = 'act2autochess_m01';
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 2, seed: 21, fake: true }).start();
h.setStage(STAGE);
h.toPrep(14);
const m = h.m;
assert.equal(m.round, m.gd.bossRound);
for (const [pid, field] of [['p_0', 'bossL'], ['p_1', 'bossR']]) {
const ps = h.ps(pid);
clean(ps);
assert.equal(m.deployFieldOf(ps), field);
const id = chessOfTier(1, (c) => c.position === 'MELEE' && !m.gd.placeableTokens(c.chessId).length).find((x) => m.pool.has(x) && m.pool.left(x) >= 3 && m.gd.mergeCount(x) === 3);
// board (11, 8): a tile_fence_bound tile on the boss half, forbidden on the normal field (DESIGN §19.7)
const normal = m.deployMapFor(ps, 'normal');
assert.equal(canPlace(normal, 'melee', 11, 8), false, 'illegal on the normal field');
const a = give(m, ps, id);
assert.deepEqual(m.handle(pid, { t: 'g.move', uid: a.uid, to: { area: 'board', row: 11, col: 8 }, dir: 'UP' }), { ok: true }, `${pid}: placed on the boss half`);
give(m, ps, id);
ps.funds = 50;
stock(m, ps, id);
assert.deepEqual(m.handle(pid, { t: 'g.buy', slot: 0 }), { ok: true });
const elite = ps.board.get('11,8');
assert.ok(elite && elite.id === chess(id).goldenId && elite.dir === 'UP', `${pid} (${field}): the elite on board (11,8) of its boss half`);
assert.ok(canPlace(ps.deployMap(), positionClass(chess(elite.id)), 11, 8));
}
checkInvariants(m);
m.dispose();
});
test('bots: a bot prep that completes a merge keeps the invariants (the elite is deployed)', () => {
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 1, bots: 1, seed: 51, fake: true }).start();
const m = h.m;
h.toPrep(1);
h.setStage('act2autochess_m04');
const bot = h.ps('ai_0');
// botPrep only runs while the bot has not readied yet
bot.ready = false;
clean(bot);
const [id] = meleeTier1(m);
deploy(m, bot, id, 'RIGHT');
give(m, bot, id);
bot.shop.slots = bot.shop.slots.map(() => null);
stock(m, bot, id);
bot.funds = 10;
botPrep(m, bot);
const elite = eliteOf(bot, id);
assert.ok(elite, 'the bot bought the third copy');
assert.equal(bot.find(elite.uid).area, 'board', 'its elite is deployed');
checkInvariants(m);
m.dispose();
});
test('audit.js flags an elite that misses the deployed copy\'s tile (and passes the engine\'s merges)', () => {
const { m, ps } = prep({ seed: 52 });
const [id, id2] = meleeTier1(m);
// a deliberately wrong merge (the pre-follow-up rule: always the hand) installed before the audit wraps it
const real = ps._mergeChess;
let broken = false;
ps._mergeChess = function (baseId, incoming, opts) {
const elite = real.call(this, baseId, incoming, opts);
if (broken && elite) {
const loc = this.find(elite.uid);
if (loc && loc.area === 'board') { this.board.delete(loc.key); this.stow(elite, { allowTemp: true }); }
}
return elite;
};
const audit = attachAudit(m, { invariants: false });
deploy(m, ps, id);
give(m, ps, id);
stock(m, ps, id);
assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot: 0 }), { ok: true });
assert.deepEqual(audit.violations, [], 'the engine\'s merge passes');
broken = true;
deploy(m, ps, id2);
give(m, ps, id2);
stock(m, ps, id2);
assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot: 0 }), { ok: true });
assert.equal(audit.violations.length, 1);
assert.match(audit.violations[0], /expected the deployed copy's tile/);
m.dispose();
});
test('audit.js passes transformations that complete a merge (突变细胞 ctx.transform; a deployed carrier is detached first), in PREP and SETTLE', () => {
// carrier / the two copies: 'board' or 'hand'
const layouts = [['board', 'hand', 'hand'], ['board', 'board', 'hand'], ['board', 'board', 'board'], ['hand', 'board', 'board'], ['hand', 'hand', 'hand']];
for (const phase of ['PREP', 'SETTLE']) {
for (const [i, [carrierAt, ...copiesAt]] of layouts.entries()) {
const label = `${phase} carrier ${carrierAt}, copies ${copiesAt.join('+')}`;
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 1, bots: 1, seed: 60 + i, fake: true }).start();
const m = h.m;
h.toPrep(1);
h.setStage('act2autochess_m04');
const ps = h.ps('p_0');
clean(ps);
const audit = attachAudit(m);
const [id, other] = meleeTier1(m);
const place = (cid, at, dir) => (at === 'board' ? deploy(m, ps, cid, dir) : give(m, ps, cid));
const carrier = place(other, carrierAt, 'UP');
const copies = copiesAt.map((at, j) => place(id, at, j ? 'DOWN' : 'LEFT'));
const dirOf = new Map([carrier, ...copies].filter((p) => keyOf(ps, p)).map((p) => [keyOf(ps, p), p.dir]));
const before = ps.deployCount;
if (phase === 'SETTLE') h.drive(() => m.phase === PHASE.SETTLE);
assert.equal(m.phase, PHASE[phase]);
const ctx = makeCtx(m, ps, { kind: 'item', key: 'item:test' }, phase === 'SETTLE' ? 'onSettle' : 'onBuy');
const got = ctx.transform(carrier.uid, id);
assert.ok(got && got.id === chess(id).goldenId, `${label}: the transformation completed the merge`);
const want = mergeTile([...dirOf.keys()].map((key) => ({ key })));
const loc = ps.find(got.uid);
if (want) {
assert.equal(loc.key, want.key, `${label}: the elite on the first deployed tile`);
assert.equal(loc.piece.dir, dirOf.get(want.key), `${label}: with that copy's facing`);
assert.equal(ps.deployCount, 1, `${label}: ${before} deployed → 1`);
} else {
assert.equal(loc.area, 'hand', `${label}: no deployed copy → the hand`);
assert.equal(ps.deployCount, 0);
}
assert.deepEqual(audit.violations, [], `${label}: no audit violation`);
m.dispose();
}
}
});
test('a deployed copy on a tile its elite may not use (terrain changed, not re-checked yet) is skipped: hand, or the tile when nothing has room', () => {
for (const full of [false, true]) {
const label = full ? 'hand and temp full' : 'room';
const { m, ps } = prep({ seed: 54 });
const [id] = meleeTier1(m);
// both owned copies deployed, both tiles then made undeployable (a content tile override, not re-checked yet)
const a = deploy(m, ps, id, 'UP');
const b = deploy(m, ps, id, 'DOWN');
const tiles = [keyOf(ps, a), keyOf(ps, b)];
if (full) {
const fill = [2, 3].flatMap((t) => chessOfTier(t)).filter((x) => m.pool.has(x));
for (let i = 0; ps.hand.some((p) => p == null); i++) give(m, ps, fill[i]);
for (let i = 0; ps.temp.some((p) => p == null); i++) give(m, ps, fill[ps.hand.length + i], 'temp');
}
for (const k of tiles) ps.tileOverrides[k] = 'none';
ps.invalidateDeployMap();
const elite = ps.acquireChess(id, { source: 'test' });
assert.ok(elite && m.gd.isGolden(elite.id), `${label}: merged`);
const loc = ps.find(elite.uid);
if (!full) {
assert.equal(loc.area, 'hand', `${label}: the illegal tiles are skipped, the elite goes to the hand`);
} else {
// never lost: it stays on the first deployed copy's tile, like a piece the eviction finds no room for
assert.equal(loc.area, 'board', `${label}: kept on the board`);
assert.equal(loc.key, mergeTile(tiles.map((key) => ({ key }))).key);
assert.equal(elite.poolCopies, 3);
}
m.dispose();
}
});
test('ERR codes unchanged: a full hand still refuses a purchase that completes no merge', () => {
const { m, ps } = prep({ seed: 53 });
const [id, other] = meleeTier1(m);
deploy(m, ps, id);
const fill = chessOfTier(2).filter((x) => m.pool.has(x));
for (let i = 0; ps.hand.some((p) => p == null); i++) give(m, ps, fill[i]);
stock(m, ps, other);
assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot: 0 }), { error: ERR.HAND_FULL });
checkInvariants(m);
m.dispose();
});
+233
View File
@@ -0,0 +1,233 @@
// Official limits on the match side (docs/research/11-limits-official.md): bond layers stop at 999 per bond
// (shared/constants.js BOND_LAYER_CAP: PlayerState.addLayers, the settle of the in-battle gains, the client-result
// check, the invariants) and the boss-hit limit 限伤 (BOSS_HIT_LIMIT 300000) in a real Final Assault / Hidden Core with a
// 999-layer board.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { PHASE, BOND_LAYER_CAP, BOSS_HIT_LIMIT } from '../../shared/constants.js';
import { Battle } from '../../server/sim/Battle.js';
import { validateClientResult } from '../../server/match/fields.js';
import { collectViolations } from '../../server/match/invariants.js';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { makeMatch, give, legalTileFor, DATA } from './harness.js';
import { planBoard, applyBoard, makeMatch as balanceMatch, withoutTuning } from '../../tools/balance.mjs';
import { buildNormalWave } from '../../server/match/waves.js';
import { createRng } from '../../server/sim/rng.js';
/** A solo match (fake battles) at PREP R1 with an empty layer table; onLayers dispatches are recorded. */
function prepMatch(seed = 71, o = {}) {
const h = makeMatch({ mode: 'solo', difficulty: 'HARD', seed, fake: true, ...o }).start();
h.toPrep(1);
const m = h.m;
const ps = h.ps('p_0');
ps.layers = {};
ps.recompute();
const onLayers = [];
const dispatch = m.dispatch.bind(m);
m.dispatch = (p, hook, ev, opts) => { if (hook === 'onLayers') onLayers.push({ ...ev }); return dispatch(p, hook, ev, opts); };
return { h, m, ps, onLayers };
}
test('PlayerState.addLayers: a gain stops at 999; onLayers reports the clamped count; a gain at the cap adds 0 and dispatches nothing', () => {
const { m, ps, onLayers } = prepMatch();
assert.equal(ps.addLayers('yanShip', 1500), BOND_LAYER_CAP);
assert.equal(ps.layers.yanShip, 999);
assert.deepEqual(onLayers.map((e) => [e.bondId, e.from, e.to]), [['yanShip', 0, 999]]);
assert.equal(ps.bonds.yanShip.layers, 999, 'the bond view shows the capped count');
assert.equal(ps.addLayers('yanShip', 5), 0);
assert.equal(onLayers.length, 1, 'no onLayers at the cap');
// each bond on its own
assert.equal(ps.addLayers('kjeragShip', 40), 40);
assert.equal(ps.layers.kjeragShip, 40);
ps.layers.lateranoShip = 996;
assert.equal(ps.addLayers('lateranoShip', 10), 3);
assert.equal(ps.layers.lateranoShip, 999);
// what the client sees: m.private bonds and the public bond list
const priv = ps.privateView();
assert.equal(priv.bonds.find((b) => b.bondId === 'yanShip').layers, 999);
assert.deepEqual(collectViolations(m), []);
m.dispose();
});
test('a real prep 特质 at the cap: 烛煌 获得时 炎 / 维多利亚 +5 adds 2 to a 997-layer 炎 and 5 to 维多利亚', () => {
const { m, ps, onLayers } = prepMatch(72);
ps.layers.yanShip = 997;
ps.recompute();
ps.acquireChess('chess_char_5_03_a', { source: 'test' });
assert.equal(ps.layers.yanShip, 999);
assert.equal(ps.layers.victoriaShip, 5);
assert.deepEqual(onLayers.filter((e) => e.bondId === 'yanShip').map((e) => [e.from, e.to]), [[997, 999]]);
ps.acquireChess('chess_char_5_03_a', { source: 'test' });
assert.equal(ps.layers.yanShip, 999, 'nothing more');
assert.equal(ps.layers.victoriaShip, 10);
assert.equal(onLayers.filter((e) => e.bondId === 'yanShip').length, 1);
m.dispose();
});
test('settle: in-battle layer gains are clamped at 999 (once at the line, then nothing — no onLayers for a capped bond)', () => {
const { h, m, ps, onLayers } = prepMatch(73, { script: (b) => (b.kind === 'normal' ? { duration: 1, layerGains: { p_0: { yanShip: 50, kjeragShip: 7 } } } : {}) });
ps.layers.yanShip = 980;
ps.recompute();
onLayers.length = 0;
h.toPrep(2);
assert.equal(ps.layers.yanShip, 999, '980 + 50 → 999');
assert.equal(ps.layers.kjeragShip, 7);
assert.deepEqual(onLayers.filter((e) => e.reason === 'battle').map((e) => [e.bondId, e.from, e.to]), [['yanShip', 980, 999], ['kjeragShip', 0, 7]]);
onLayers.length = 0;
h.toPrep(3);
assert.equal(ps.layers.yanShip, 999);
assert.equal(ps.layers.kjeragShip, 14);
assert.deepEqual(onLayers.filter((e) => e.reason === 'battle').map((e) => e.bondId), ['kjeragShip'], 'the capped bond dispatches nothing');
h.invariants();
m.dispose();
});
test('client-result check: a reported layer gain may not pass 999 from the bond\'s starting layers', () => {
const { m } = prepMatch(74);
const spec = {
kind: 'normal', round: 5, timeLimit: 60, spawns: [], flags: { layerGainsEnabled: true },
players: [{ playerId: 'p_0', units: [], bonds: { yanShip: { count: 3, active: true, tier: 1, layers: 990 }, kjeragShip: { count: 2, active: true, tier: 1, layers: 0 } } }],
};
const res = (gains) => ({ reason: 'cleared', time: 10, perPlayer: { p_0: { killed: 0, total: 0, leaked: [], perfect: true, layerGains: gains, coins: 0, unitsEnd: [], unitStats: [] } } });
const ok = validateClientResult(spec, res({ yanShip: 9, kjeragShip: 80 }), { gd: m.gd });
assert.equal(ok.ok, true, ok.reason);
assert.deepEqual(ok.result.perPlayer.p_0.layerGains, { yanShip: 9, kjeragShip: 80 });
assert.deepEqual(validateClientResult(spec, res({ yanShip: 10 }), { gd: m.gd }), { ok: false, reason: 'layer bound' }, '990 + 10 > 999');
assert.deepEqual(validateClientResult(spec, res({ kjeragShip: 81 }), { gd: m.gd }), { ok: false, reason: 'layer bound' }, 'the per-round bound 60 + 4·5 still holds');
const capped = { ...spec, players: [{ ...spec.players[0], bonds: { yanShip: { count: 3, active: true, tier: 1, layers: 999 } } }] };
assert.deepEqual(validateClientResult(capped, res({ yanShip: 1 }), { gd: m.gd }), { ok: false, reason: 'layer bound' }, 'a capped bond gains nothing');
assert.equal(validateClientResult(capped, res({ yanShip: 0 }), { gd: m.gd }).ok, true);
m.dispose();
});
test('invariants: a bond above 999 is a violation (no writer may pass the cap)', () => {
const { m, ps } = prepMatch(75);
ps.layers.yanShip = 1000;
ps.recompute();
assert.ok(collectViolations(m).some((v) => /yanShip layers 1000/.test(v)));
ps.layers.yanShip = 999;
ps.recompute();
assert.deepEqual(collectViolations(m), []);
m.dispose();
});
// ---- a real Final Assault (and Hidden Core) with a 999-layer board ----------------------------------------------
const YAN = ['chess_char_6_15_a', 'chess_char_5_12_a', 'chess_char_4_17_a', 'chess_char_3_03_a'];
/** Every enemy takes ×K damage — a stand-in for the 999-layer debuffs of a real board (谢拉格 ×11.34 vs cold / frozen,
* 奥术 ×11.19 arts taken): with 炎 at 999 (ATK ×10.22) the hits land far past 300000. Once `relentAfter` leader hits
* were cancelled the leaders lose the stand-in so the fight can finish. */
const K = 2000;
function limitBattleClass(log, relentAfter = 6) {
return class extends Battle {
constructor(opts) {
const user = opts.setup;
super({ ...opts, setup: (b) => {
if (typeof user === 'function') user(b);
if (b.kind !== 'boss' && b.kind !== 'hidden') return;
const rec = { kind: b.kind, battle: b, caps: [], leaderHits: [], credits: [], relented: false };
log.push(rec);
const fx0 = b.fx.bind(b);
b.fx = (k, p) => {
if (k === 'hitCap') {
rec.caps.push(p.n);
if (!rec.relented && rec.caps.length >= relentAfter) {
rec.relented = true;
b.after(0, () => { for (const e of b.enemies) if (e.isBoss) b.removeBuff(e, 'test:999debuffs'); });
}
}
return fx0(k, p);
};
b.on('enemySpawn', ({ enemy }) => { if (!(enemy.isBoss && rec.relented)) b.addBuff(enemy, { key: 'test:999debuffs', mods: { dmgTakenMul: K } }); });
b.on('damaged', (c) => {
if (!c.target || !c.target.isBoss || c.type === 'element') return;
rec.leaderHits.push(c.amount);
}, { priority: -1000 });
if (b.sharedBoss) {
const pool = b.sharedBoss;
const dmg = pool.damage.bind(pool);
pool.damage = (pid, amount) => { rec.credits.push(amount); return dmg(pid, amount); };
}
} });
}
};
}
test('Final Assault + Hidden Core with a 999-layer 炎 board (real sim, server-run): every leader hit of ≥ 300000 deals 0 and nothing of it reaches the pool; the hits under the line win', () => {
const h = makeMatch({ mode: 'solo', difficulty: 'HARD', seed: 76, fake: true, captureFrames: false, script: (b) => (b.kind === 'normal' ? { duration: 1 } : {}) });
const m = h.m;
m.bossId = 'boss_6'; // 阿利斯泰尔 walks into the board
m.hiddenBossId = 'boss_9'; // 假想敌:铳 (完全形态) with its three springs
m.gd.bossHpMul = () => 0.1; // pools the 4-operator board clears once the stand-in debuff leaves the leaders
h.start();
h.toPrep(14);
const ps = h.ps('p_0');
for (const p of [...ps.board.values()]) if (p.kind === 'chess') ps.returnCopies(p);
ps.board.clear();
const used = new Set();
for (const id of YAN) {
const at = legalTileFor(m, ps, id, used);
used.add(`${at[0]},${at[1]}`);
give(m, ps, id, 'board', at);
}
const before = ps.layers.yanShip || 0;
assert.equal(ps.addLayers('yanShip', 5000), 999 - before);
assert.equal(ps.layers.yanShip, 999);
ps.recompute();
assert.ok(ps.bonds.yanShip.active, '炎 is active');
ps.lp = 200;
const log = [];
m.BattleClass = limitBattleClass(log);
h.drive(() => m.phase === PHASE.FINAL_ASSAULT || h.ended != null);
assert.equal(m.phase, PHASE.FINAL_ASSAULT);
h.drive(() => h.ended != null, { maxSteps: 8e6 }); // the Final Assault, the Hidden Core's prep (999 activated layers > 350) and fight
const end = h.ended;
assert.ok(end, `the match ended (${m.phase} R${m.round})`);
assert.ok(log.length >= 1, 'a real boss field ran');
const fa = log[0];
assert.equal(fa.kind, 'boss');
const input = fa.battle.getPlayer('p_0');
assert.equal(input.bonds.yanShip.layers, 999, 'the battle got the capped count');
const op = fa.battle.allyUnits.find((u) => u.kind === 'op' && u.defId === YAN[0]);
assert.ok(op, 'the operator fought');
for (const rec of log) {
assert.ok(rec.caps.length > 0, `${rec.kind}: hits past the line were cancelled (${rec.caps.length})`);
assert.ok(rec.caps.every((n) => n >= BOSS_HIT_LIMIT));
assert.ok(rec.leaderHits.every((a) => Math.ceil(a) < BOSS_HIT_LIMIT), `${rec.kind}: no leader hit of ≥ 300000 landed (max ${Math.max(0, ...rec.leaderHits)})`);
assert.ok(rec.credits.every((a) => a < BOSS_HIT_LIMIT), `${rec.kind}: nothing ≥ 300000 reached the shared pool`);
assert.ok(rec.leaderHits.length > 0, `${rec.kind}: hits under the line landed`);
assert.equal(rec.battle.sharedBoss.hp, 0, `${rec.kind}: the pool was emptied by those`);
assert.equal(rec.battle.reason, 'cleared');
}
assert.equal(end.victory, true, 'the board still won once its hits stayed under the line');
assert.equal(m.errorCount, 0);
m.dispose();
});
// ---- the dev tools write layers too: they stop at the cap like every gain ---------------------------------------
test('tools/balance.mjs applyBoard: any --profile keeps every bond ≤ 999 (the R14 core bond at ×20 sits at the cap)', () => {
const m = balanceMatch({ data: withoutTuning(DATA), mode: 'coop', difficulty: 'HARD', seed: 9, players: 1, rehearsal: 0 });
const r = 14;
m.round = r;
m.wave = buildNormalWave(m.gd, m.rngWaves, m.factions, r);
const rng = createRng(314);
const plan = planBoard(m, r, rng, { profile: 20 });
const ps = m.order[0];
applyBoard(m, ps, plan, r, rng, { profile: 20 });
assert.ok(plan.core, 'a core bond');
assert.equal(ps.layers[plan.core], BOND_LAYER_CAP, 'core curve 150 × 20 → 999');
for (const [id, v] of Object.entries(ps.layers)) assert.ok(v > 0 && v <= BOND_LAYER_CAP, `${id} ${v}`);
assert.deepEqual(collectViolations(m).filter((v) => /layers/.test(v)), []);
m.dispose();
});
test('tools/matchrun.mjs --layers 2000 --check: the boost stops at 999 (no invariant violation through the Hidden Core)', () => {
const tool = fileURLToPath(new URL('../../tools/matchrun.mjs', import.meta.url));
const r = spawnSync(process.execPath, [tool, '--mode', 'solo', '--difficulty', 'HARD', '--lp', '500', '--layers', '2000', '--check', '--rehearsal', '0', '--seed', '1'], { encoding: 'utf8', timeout: 120000 });
assert.equal(r.status, 0, r.stdout.slice(-2000) + r.stderr.slice(-2000));
assert.match(r.stdout, /no violations/);
assert.doesNotMatch(r.stdout, /layers \d{4}/);
});
+2 -1
View File
@@ -97,7 +97,8 @@ test('g.unitStats follows the prep state: another item changes the numbers; prep
assert.deepEqual(carrier.items.map((x) => x.id).sort(), [ARM, BLADE].sort()); assert.deepEqual(carrier.items.map((x) => x.id).sort(), [ARM, BLADE].sort());
m.handle('p_0', { t: 'g.unitStats', seq: 2 }); m.handle('p_0', { t: 'g.unitStats', seq: 2 });
const after = h.lastTo('p_0', 'm.unitStats').units.find((x) => x.uid === carrier.uid); const after = h.lastTo('p_0', 'm.unitStats').units.find((x) => x.uid === carrier.uid);
assert.ok(Math.abs(after.atk - Math.round(after.base.atk * 1.4 * 1.3)) <= 1, `each item its own multiplier: ATK ${before.atk} → ${after.atk}`); // equipment percentages are 直接乘算 — they add up (PRTS 盟约记录 / 游戏数据基础; DESIGN §20.10): +40 % and +30 % = +70 %
assert.ok(Math.abs(after.atk - Math.round(after.base.atk * (1 + 0.4 + 0.3))) <= 1, `the items' percentages add up: ATK ${before.atk} → ${after.atk}`);
// outside the prep phases: WRONG_PHASE, nothing pushed // outside the prep phases: WRONG_PHASE, nothing pushed
const n = h.allTo('p_0', 'm.unitStats').length; const n = h.allTo('p_0', 'm.unitStats').length;
assert.ok(h.drive(() => m.phase === PHASE.COMBAT)); assert.ok(h.drive(() => m.phase === PHASE.COMBAT));
+37
View File
@@ -389,6 +389,43 @@ describe('render engine in headless Chrome', { skip }, () => {
assert.deepEqual(r.battle, [['UP', true, 1], ['LEFT', true, -1], ['RIGHT', false, 1]]); assert.deepEqual(r.battle, [['UP', true, 1], ['LEFT', true, -1], ['RIGHT', false, 1]]);
}); });
test('a merge completed between two preps (band grant at ROUND_START, after a battle) still plays the promotion cue (QA 6b)', async () => {
const { page, problems } = await open('scene=prep&panel=0', 1600, 900);
await wait(1500);
const r = await page.evaluate(async () => {
const v = window.__demo.view, st = window.__demo.scene.state;
const rows = await (await fetch('/data/chess.json')).json();
const chess = rows.chess || rows;
const prev = JSON.parse(JSON.stringify(st));
const b0 = prev.board.find((p) => p.kind === 'chess' && chess[p.id] && !chess[p.id].isGolden && chess[p.id].goldenId);
if (!b0) return { skip: 'no normal chess on the demo board' };
// the other copy waits in the hand
const uids = [...prev.hand, ...prev.board, ...(prev.temp || [])].filter(Boolean).map((p) => p.uid);
const hu = Math.max(...uids) + 1;
prev.hand = [{ uid: hu, kind: 'chess', id: b0.id }, ...prev.hand.filter((p) => p && p.uid !== hu)].slice(0, 9);
v.setPrep(prev, { editable: true });
const n0 = v.debug.promotions.length;
// a battle rebuilds the scene; the merge's elite takes the deployed copy's tile at the next round start
v.enterBattle({ fieldId: 'x', kind: 'normal', units: [] });
const next = JSON.parse(JSON.stringify(prev));
next.hand = next.hand.filter((p) => p.uid !== hu);
const eu = hu + 1;
next.board = next.board.map((p) => (p.uid === b0.uid ? { uid: eu, kind: 'chess', id: chess[b0.id].goldenId, golden: true, row: b0.row, col: b0.col, dir: b0.dir } : p));
v.setPrep(next, { editable: true });
const p = v.debug.promotions.slice(n0);
// a plain round rebuild (no merge) cues nothing
v.enterBattle({ fieldId: 'y', kind: 'normal', units: [] });
v.setPrep(JSON.parse(JSON.stringify(next)), { editable: true });
return { p, after: v.debug.promotions.length - n0, row: b0.row, col: b0.col, eu };
});
await page.close();
assert.deepEqual(problems, []);
assert.equal(r.skip, undefined, 'the demo board has a normal operator');
assert.equal(r.p.length, 1, JSON.stringify(r.p));
assert.deepEqual([r.p[0].uid, r.p[0].area, r.p[0].row, r.p[0].col, r.p[0].copies], [r.eu, 'board', r.row, r.col, 2], 'the elite on the copy\'s tile, both copies');
assert.equal(r.after, 1, 'no cue for a rebuild without a merge');
});
test('drops outside the stage report client coords; a refused drop tweens back home', async () => { test('drops outside the stage report client coords; a refused drop tweens back home', async () => {
const { page, problems } = await open('scene=prep&panel=0', 1600, 900); const { page, problems } = await open('scene=prep&panel=0', 1600, 900);
await wait(2000); await wait(2000);
+88
View File
@@ -0,0 +1,88 @@
// User playtest #6 follow-up — the promotion cue of a merge (render/promote.js, render/fx.js promote, render/app.js
// setPrep). A merge's elite appears on the board tile of the deployed copy it replaced (PRTS 卫戍协议/帮助 "若消耗已部署至
// 作战区的干员,则发送至作战区对应位置"), else on a bench slot; setPrep recognises it (a new elite uid while normal copies of
// the same operator vanished) and plays fx.promote there instead of the plain deploy flash. Headless fake PIXI.
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { installFakePixi, fakeViewCtx } from './fakepixi.js';
import { presetCamera } from '../../public/js/render/projection.js';
import { promotionsOf } from '../../public/js/render/promote.js';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const CHESS = JSON.parse(readFileSync(path.join(ROOT, 'data/chess.json'), 'utf8'));
const ROWS = CHESS.chess || CHESS;
const chessOf = (id) => ROWS[id] || null;
const A = 'chess_char_1_01_a', B = 'chess_char_1_02_a';
const gold = (id) => ROWS[id].goldenId;
const e = (uid, id, area, o = {}) => ({ uid, area, piece: { uid, kind: 'chess', id, golden: !!ROWS[id]?.isGolden, ...o }, key: `p:${uid}`, ...o });
describe('promotionsOf', () => {
test('a new elite whose normal copies vanished is a promotion (board or bench)', () => {
const prev = [e(1, A, 'board', { row: 10, col: 3 }), e(2, A, 'board', { row: 11, col: 5 }), e(3, B, 'hand', { idx: 9 })];
const next = [e(9, gold(A), 'board', { row: 11, col: 5 }), e(3, B, 'hand', { idx: 9 })];
const p = promotionsOf(prev, next, chessOf);
assert.deepEqual([...p.keys()], [9]);
assert.deepEqual(p.get(9).map((g) => g.uid).sort(), [1, 2]);
const hand = promotionsOf([e(1, A, 'hand', { idx: 9 }), e(2, A, 'hand', { idx: 8 })], [e(7, gold(A), 'hand', { idx: 9 })], chessOf);
assert.deepEqual([...hand.keys()], [7], 'an elite arriving in the hand is promoted too');
});
test('not a promotion: the first state, a sale, an elite gained without copies vanishing, another operator', () => {
assert.equal(promotionsOf([], [e(9, gold(A), 'board', { row: 10, col: 3 })], chessOf).size, 0, 'first setPrep');
assert.equal(promotionsOf([e(1, A, 'board')], [], chessOf).size, 0, 'a sale');
assert.equal(promotionsOf([e(1, B, 'hand')], [e(9, gold(A), 'hand')], chessOf).size, 0, 'a granted elite while another operator left');
assert.equal(promotionsOf([e(1, A, 'hand')], [e(1, A, 'board', { row: 10, col: 3 }), e(5, B, 'hand')], chessOf).size, 0, 'a move and a buy');
assert.equal(promotionsOf([e(4, gold(A), 'hand')], [e(4, gold(A), 'board', { row: 9, col: 2 })], chessOf).size, 0, 'an elite placed by hand');
});
test('two merges in one update are told apart by operator', () => {
const prev = [e(1, A, 'board'), e(2, A, 'hand'), e(3, B, 'hand'), e(4, B, 'temp')];
const next = [e(10, gold(A), 'board'), e(11, gold(B), 'hand')];
const p = promotionsOf(prev, next, chessOf);
assert.deepEqual(p.get(10).map((g) => g.uid).sort(), [1, 2]);
assert.deepEqual(p.get(11).map((g) => g.uid).sort(), [3, 4]);
});
});
describe('fx.promote', () => {
let fake, FX;
before(async () => { fake = installFakePixi(); FX = await import('../../public/js/render/fx.js'); });
after(() => fake.restore());
const cam = presetCamera('prep', { width: 1600, height: 900 });
function makeFx() {
const P = fake.P;
const ctx = fakeViewCtx(P);
return new FX.FxSystem({
P, layers: ctx.layers, cam: () => cam, heightAt: () => 0, settings: { quality: 'high', damageNumbers: true },
timeScale: () => 1, loadLevel: () => 0, subProfOf: () => null, view: () => null,
screenSize: () => ({ width: 1600, height: 900 }), fieldTop: () => 120,
});
}
const view = (x, y) => ({ id: 'p:9', x, y, z: 0, hover: 0, _headTiles: 1.2, alive: true, destroyed: false, isEnemy: false, info: { defId: gold(A) } });
const texName = (fx, sp) => Object.keys(fx.tex).find((k) => fx.tex[k] === sp.texture);
test('a gold pillar, a shockwave and a hex ring at the elite, streaks from the other copies; counted', () => {
const fx = makeFx();
const before = fx.counts;
assert.equal(before.promotions, 0);
fx.promote(view(5, 10), [{ x: 3, y: 11, z: 0 }]);
const names = fx.parts.map((p) => texName(fx, p.sp));
assert.ok(names.filter((n) => n === 'pillar').length >= 2, `pillars (${names.join(',')})`);
assert.ok(names.includes('streak'), 'a streak from the other consumed copy');
assert.ok(fx.rings.length >= 2, 'shockwave + hex ring');
assert.equal(fx.counts.promotions, 1);
fx.promote(null);
assert.equal(fx.counts.promotions, 1, 'no view: nothing');
});
});
test('render/app.js plays the promotion cue instead of the deploy flash for a merge\'s elite', () => {
const src = readFileSync(path.join(ROOT, 'public/js/render/app.js'), 'utf8');
assert.match(src, /import \{ promotionsOf \} from '\.\/promote\.js';/);
assert.match(src, /const before = prepPieces\.length \? prepPieces : promoBase;[\s\S]{0,80}promotionsOf\(before, list, \(id\) => data\.chess\(id\)\)/, 'compared with the previous state');
assert.match(src, /if \(prepPieces\.length\) promoBase = prepPieces;\s*prepPieces = \[\];/, 'a battle keeps the last prep state: a merge between two preps is cued too (QA 6b)');
assert.match(src, /if \(promoFrom\.has\(e\.uid\)\) \{[\s\S]{0,400}fx\.promote\(v,[\s\S]{0,400}\} else if \(e\.area === 'board' && prevBoard\.size && !prevBoard\.has\(e\.uid\)\) \{ v\.onDeploy\?\.\(\); fx\.deploy\(v\); \}/);
assert.match(src, /promotions,/, 'the cue log is exposed on view.debug (E2E)');
});
+36
View File
@@ -0,0 +1,36 @@
// 限伤 on screen (docs/research/11-limits-official.md §2, DESIGN §20.12): a leader's hit of ≥ 300000 in a boss battle
// is cancelled by the sim (server/sim/damage.js leaderHitCancelled), which emits ['fx', 'hitCap', x, y, { id, n }] and
// no 'dmg' event. The official shows no number for it [ASSUMED]; the renderer draws nothing for the event.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { FxSystem, FX_KINDS, fxSpec } from '../../public/js/render/fx.js';
import { presetCamera } from '../../public/js/render/projection.js';
function recordingFx() {
const cam = presetCamera('boss', { width: 1280, height: 720 });
const calls = [];
const fx = Object.create(FxSystem.prototype);
const rec = (name) => (...a) => { calls.push(name); return name === '_where' || name === '_point' ? { x: a[0], y: a[1], z: 0, v: null } : undefined; };
Object.assign(fx, {
ctx: { cam: () => cam, heightAt: () => 0, fieldRect: () => ({ r0: 0, r1: 5, c0: 0, c1: 20 }), timeScale: () => 2 },
_p: { x: 0, y: 0, s: 0, depth: 0 }, _q: { x: 0, y: 0, s: 0, depth: 0 }, locks: [],
_where: rec('_where'), _point: rec('_point'), particle: rec('particle'), ring: rec('ring'), burst: rec('burst'),
explosion: rec('explosion'), flashScreen: rec('flashScreen'), tileFlash: rec('tileFlash'),
});
fx._chest = (v, out) => Object.assign(out, cam.project(0, 0, 0.5, out));
return { fx, calls };
}
test('hitCap: listed in FX_KINDS as a kind that draws nothing', () => {
assert.equal(FX_KINDS.hitCap.a, 'none');
assert.equal(fxSpec('hitCap', { id: 3, n: 300000 }).a, 'none');
});
test('hitCap: simFx draws nothing (no number, no spark); an unknown kind still gets its generic sparkle', () => {
const { fx, calls } = recordingFx();
fx.simFx('hitCap', 10, 3, { id: 7, n: 412345 });
assert.deepEqual(calls, [], 'nothing drawn for a cancelled leader hit');
fx.simFx('zzzUnknownKind', 10, 3, {});
assert.ok(calls.includes('particle'), 'the generic sparkle still works');
});
+14 -1
View File
@@ -4,7 +4,7 @@ import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { Battle } from '../../server/sim/Battle.js'; import { Battle } from '../../server/sim/Battle.js';
import { makeBattle, chessRec, enemyRec, flatStage, hashOf, checkInvariants } from '../helpers/battleHarness.js'; import { makeBattle, chessRec, enemyRec, flatStage, hashOf, checkInvariants } from '../helpers/battleHarness.js';
import { UF, ANIM } from '../../shared/constants.js'; import { UF, ANIM, BOND_LAYER_CAP } from '../../shared/constants.js';
import { EV } from '../../shared/protocol.js'; import { EV } from '../../shared/protocol.js';
import { getDefaultSource, spawnsFromTemplate, hasGeneratedData } from '../../server/sim/simdata.js'; import { getDefaultSource, spawnsFromTemplate, hasGeneratedData } from '../../server/sim/simdata.js';
import { LocalBossPool } from '../../server/sim/spec.js'; import { LocalBossPool } from '../../server/sim/spec.js';
@@ -478,6 +478,19 @@ test('layers & coins: addLayers records gains (normal field) and emits client ev
assert.equal(h2.b.addLayers('p1', 'yanShip', 3), 0); assert.equal(h2.b.addLayers('p1', 'yanShip', 3), 0);
}); });
test('layers: an IN_BATTLE gain stops at BOND_LAYER_CAP (999, MAX_GARRISON_STACK) — the battle\'s count and the reported gain', () => {
assert.equal(BOND_LAYER_CAP, 999);
const h = makeBattle({ content: 'none', bonds: { yanShip: { count: 3, active: true, tier: 1, layers: 995 } } });
h.step();
assert.equal(h.b.addLayers('p1', 'yanShip', 3, 'test'), 3);
assert.equal(h.b.addLayers('p1', 'yanShip', 5, 'test'), 1, 'only the room left under the cap');
assert.equal(h.b.addLayers('p1', 'yanShip', 5, 'test'), 0, 'at the cap: nothing');
assert.equal(h.b.getPlayer('p1').bonds.yanShip.layers, 999);
h.b.forceEnd('forced');
assert.deepEqual(h.result().perPlayer.p1.layerGains, { yanShip: 4 });
assert.deepEqual(h.eventsOf('layer').map((e) => e[3]), [3, 1], 'the client sees what was added');
});
test('tokens: spawnToken deploys a token next to its owner; manual token pieces deploy from input', () => { test('tokens: spawnToken deploys a token next to its owner; manual token pieces deploy from input', () => {
const ds = getDefaultSource(); const ds = getDefaultSource();
const h = makeBattle({ const h = makeBattle({
+138
View File
@@ -0,0 +1,138 @@
// test/sim/bossfield.browser.test.js — the Final Assault / Hidden Core fields in the BROWSER sim (DESIGN §14: the
// clients simulate the boss fields; the server re-simulates them for SP_VERIFY and takeovers). A real bot co-op match
// (终极, seed 12: both pair fields carry two 奥术 players) is played to the Hidden Core with every active bond at the
// official 999 layers (DESIGN §20.12); its four captured boss specs (R14 假想敌:胄 boss_1, R15 隐秘核心 boss_8) run to
// the end in Node and in headless Chrome through the client's own loader (public/js/battle/runner.js loadBrowserSim)
// with the field's LocalBossPool, and must give the same result digest, the same pool and the same fx per kind.
// The fields exercise the playtest-6b sim paths: 直接乘算 bonus sums (§20.10), one 奥术 instance per target
// (applyStrongest), the drone 2 % links and the 剑 / 锤 / shell kit (§20.13), and 限伤 cancels (fx 'hitCap', §20.12).
//
// Opt-in (starts Chrome): SIM_E2E=1 node --test test/sim/bossfield.browser.test.js (or RENDER_E2E=1)
// Run browser test files one at a time. Chrome path: $CHROME_PATH or the macOS default.
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { createRequire } from 'node:module';
import { PHASE, BOND_LAYER_CAP } from '../../shared/constants.js';
import { createBattleFromSpec, resultDigest } from '../../server/sim/spec.js';
import { DataSource } from '../../server/sim/simdata.js';
import { makeMatch, DATA } from '../match/harness.js';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const enabled = (process.env.RENDER_E2E === '1' || process.env.SIM_E2E === '1') && existsSync(CHROME);
const skip = enabled ? false : 'set SIM_E2E=1 or RENDER_E2E=1 (needs Chrome)';
const MAX_SECONDS = 600; // game seconds (Battle.runToEnd): every captured field clears long before
/** The boss / hidden specs of a 4-bot co-op match (终极, seed 12, boss_1 then boss_8), every active bond at 999. */
function captureBossSpecs() {
const seats = [0, 1, 2, 3].map((i) => ({ seat: i, playerId: `ai_${i}`, name: `AI${i}`, isBot: true, connected: true }));
const h = makeMatch({ mode: 'coop', difficulty: 'ABYSS', seats, seed: 12, captureFrames: false, instant: false, clientCombat: true });
const m = h.m;
m.bossId = 'boss_1';
m.hiddenBossId = 'boss_8';
const specs = [];
const orig = m._ccField.bind(m);
m._ccField = (o) => { const f = orig(o); if (f.kind === 'boss' || f.kind === 'hidden') specs.push(f.spec); return f; };
m.start();
let last = '';
h.run(() => {
const k = `${m.phase}:${m.round}`;
if (k !== last) {
last = k;
if (m.phase === PHASE.PREP && m.round === 1) for (const ps of m.players.values()) ps.lp = 400;
if (m.phase === PHASE.PREP && m.round === m.gd.bossRound) {
for (const ps of m.alivePlayers()) {
for (const id of m.gd.bondIds) if (ps.bonds[id] && ps.bonds[id].active) ps.layers[id] = BOND_LAYER_CAP;
ps.recompute();
}
}
}
return h.ended != null || m.phase === PHASE.HIDDEN_CORE;
}, { maxSteps: 8e6 });
assert.equal(m.phase, PHASE.HIDDEN_CORE, 'the bots reached the Hidden Core');
m.dispose();
return specs;
}
/**
* Runs a spec to the end (the same code in Node and in the page: `S` = spec module, `ds` = data source) and returns
* its digest, the local pool, the fx count per kind and the leader-side checks (奥术 instances, 无来源 HP losses).
* Serialised into the page with Function#toString, so it uses nothing from this module's scope.
*/
function runField(S, ds, spec, maxSeconds) {
const b = S.createBattleFromSpec(spec, ds, { quiet: true, recordEvents: false });
const fx = {};
const fx0 = b.fx.bind(b);
b.fx = (k, p) => { fx[k] = (fx[k] || 0) + 1; return fx0(k, p); };
let hpLoss = 0;
b.on('damaged', (c) => { if (c.target && c.target.isBoss && c.dmg && c.dmg.tags && c.dmg.tags.includes('hpLoss')) hpLoss++; }, { priority: -1e9 });
let maxArcane = 0;
b.on('tick', () => {
for (const e of b.enemies) {
if (!e.alive || !e.isBoss) continue;
const n = e.buffs.filter((x) => String(x.key).startsWith('bond:arcaneShip')).length;
if (n > maxArcane) maxArcane = n;
}
});
const r = b.runToEnd(maxSeconds);
const d = S.resultDigest(r);
return { hash: d.hash, json: d.json, reason: r ? r.reason : null, poolHp: b.sharedBoss ? b.sharedBoss.hp : null, fx, hpLoss, maxArcane };
}
describe('Final Assault / Hidden Core fields in the browser sim', { skip }, () => {
let srv, browser;
before(async () => {
const require = createRequire(path.join(ROOT, 'package.json'));
const puppeteer = require('puppeteer-core');
const { startServer } = await import('../../server/index.js');
srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
browser = await puppeteer.launch({ executablePath: CHROME, headless: 'new', args: ['--no-first-run'] });
});
after(async () => {
await browser?.close();
await srv?.close();
});
test('real boss specs at 999 layers: Chrome and Node give the same digest, pool and fx; 限伤, drones, 瘫痪 and 奥术 all occur', { timeout: 180000 }, async () => {
const specs = captureBossSpecs();
assert.deepEqual(specs.map((s) => `${s.kind}:${s.bossId}`).sort(), ['boss:boss_1', 'boss:boss_1', 'hidden:boss_8', 'hidden:boss_8']);
const ds = new DataSource(DATA, null);
const node = specs.map((spec) => runField({ createBattleFromSpec, resultDigest }, ds, spec, MAX_SECONDS));
const page = await browser.newPage();
const problems = [];
page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`));
// (a failed request is reported by the response handler with its URL; the favicon 404 is not a problem)
page.on('console', (m) => { if (m.type() === 'error' && !/Failed to load resource/.test(m.text())) problems.push(`console: ${m.text()}`); });
page.on('response', (r) => { if (r.status() >= 400 && !/favicon/.test(r.url())) problems.push(`HTTP ${r.status()} ${r.url()}`); });
await page.goto(`http://127.0.0.1:${srv.port}/sim/spec.js`);
const chrome = await page.evaluate(async (all, src, maxSeconds) => {
const { loadBrowserSim } = await import('/js/battle/runner.js');
const { spec: S, ds } = await loadBrowserSim();
const run = new Function(`return (${src})`)();
return all.map((spec) => run(S, ds, spec, maxSeconds));
}, specs, runField.toString(), MAX_SECONDS);
await page.close();
assert.deepEqual(problems, []);
specs.forEach((spec, i) => {
const at = `${spec.kind} ${spec.fieldId} ${spec.bossId}`;
assert.equal(node[i].reason, 'cleared', `${at}: the 999-layer pair clears the field`);
assert.equal(chrome[i].json, node[i].json, `${at}: browser and Node results are identical`);
assert.equal(chrome[i].hash, node[i].hash, at);
assert.equal(chrome[i].poolHp, node[i].poolHp, `${at}: same local pool`);
assert.deepEqual(chrome[i].fx, node[i].fx, `${at}: same fx per kind`);
assert.equal(chrome[i].hpLoss, node[i].hpLoss, `${at}: same 无来源 HP losses on the leader`);
assert.ok(node[i].maxArcane <= 1, `${at}: one 奥术 instance on the leader at a time (${node[i].maxArcane})`);
});
const sum = (k) => node.reduce((a, r) => a + (r.fx[k] || 0), 0);
assert.ok(sum('hitCap') >= 1, `a 999-layer hit reached 300000 and was cancelled (限伤: ${sum('hitCap')})`);
assert.ok(sum('palsy') >= 1, `瘫痪 occurred (${sum('palsy')})`);
assert.ok(node.every((r) => r.hpLoss >= 1), `every field cost the leader 无来源 HP (drone links / 剑 · 锤 transfers: ${node.map((r) => r.hpLoss)})`);
assert.ok(node.some((r) => r.maxArcane === 1), 'the leader carried 奥术');
});
});
+84
View File
@@ -0,0 +1,84 @@
// "终极 leaders die in seconds" (user report after playtest #6): the percentage attribute bonuses of the 卫戍 systems
// compounded. PRTS 卫戍协议:盟约 下半/PRTS盟约记录: "盟约效果,策略效果,装备效果提供的属性加成均为直接乘算"; PRTS 游戏数据基础
// 属性基本公式: 直接乘算 values are SUMMED — D_t = t₁ + … + tₙ, A = (A₀ + D_p)(1 + D_t) — and PRTS 作战机制 X₂ = X₁(1 + b₁% +
// b₂%) with a skill's "攻击力+X%" in the same sum. v2.5 gave every bond / strategy / item its own ×(1 + x) (atkMul), so
// with many layers a ranged operator under 精准 + 独行 + 卡西米尔 + an item + a 特质 dealt ×74 instead of ×12 of its ATK.
// Now: content/support directMods → atkPct / defPct / hpPct (constants.js DIRECT_BONUS_STACKING 'add'); the 炎佑 ×1.5
// ("提升至1.5倍") and the char_attribute_mul 特质 (a rune on the chess's base attributes) stay multipliers.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, chessRec, enemyRec, flatStage, checkInvariants } from '../helpers/battleHarness.js';
import { directMods } from '../../server/sim/content/support/index.js';
import { bondBb } from '../../server/sim/content/bonds/addon/battle.js';
import { DIRECT_BONUS_STACKING } from '../../server/sim/constants.js';
import { SharedBossPool } from '../../server/match/finalAssault.js';
const close = (a, b, msg, eps = 1e-6) => assert.ok(Math.abs(a - b) <= eps * Math.max(1, Math.abs(b)), `${msg ?? ''} expected ${b}, got ${a}`);
const bond = (tier, layers = 0, count = 2) => ({ count, active: tier > 0, tier, layers });
const HAMMER = 'chess_item_1_01_e_a'; // 维式重锤 攻击力+15%
const BLADE = 'chess_item_3_07_e_a'; // 阿戈尔重刃 攻击力+40%
test('directMods: 直接乘算 ratios go to the additive percentage bucket (zeros dropped, extras merged)', () => {
assert.equal(DIRECT_BONUS_STACKING, 'add');
assert.deepEqual(directMods({ atk: 0.3, def: 0.2, hp: -0.1 }), { atkPct: 0.3, defPct: 0.2, hpPct: -0.1 });
assert.deepEqual(directMods({ atk: 0, hp: NaN }), {});
assert.deepEqual(directMods({ def: 0.5 }, { redeployMul: 0.7 }), { defPct: 0.5, redeployMul: 0.7 });
});
test('bonds + equipment + a skill ATK%: one sum (精准 L100 +130 %, 独行 +60 %, two items +55 %, skill +50 %)', () => {
const pre = bondBb('preciShip'), solo = bondBb('soloShip');
const preci = pre.base_atk + pre.atk_per_stack * 100;
const defs = { chess: { s_op: chessRec({ id: 's_op', bonds: ['preciShip', 'soloShip'], profession: 'SNIPER', skill: null }) } };
const h = makeBattle({
defs, units: [{ chessId: 's_op', row: 10, col: 3, items: [HAMMER, BLADE] }],
bonds: { preciShip: bond(1, 100), soloShip: bond(1, 0, 1) },
});
h.step();
const u = h.unit('s_op');
const sum = 1 + preci + solo.atk + 0.15 + 0.4;
close(u.s.atk, 500 * sum, 'bonds and items add up');
h.b.addBuff(u, { key: 'test:skill', mods: { atkPct: 0.5 } }); // a skill's "攻击力+50%" (直接乘算 too)
close(u.s.atk, 500 * (sum + 0.5), 'the skill joins the same sum');
const v25 = 500 * (1 + preci) * (1 + solo.atk) * 1.15 * 1.4 * 1.5; // the v2.5 product (each source its own multiplier)
assert.ok(u.s.atk < v25 / 1.8, `far below the v2.5 product ${v25}`);
close(u.s.maxHp, 2000 * (1 + solo.max_hp), '独行 HP');
checkInvariants(h.b);
});
test('strategy + bonds add up: 阿米娅 众志合一 (3 active bonds: +20 % ATK / HP) with 精准 and 绝技 (elite)', () => {
const pre = bondBb('preciShip'), sunt = bondBb('suntShip');
const defs = { chess: { e_op: chessRec({ id: 'e_op', bonds: ['preciShip'], profession: 'SNIPER', skill: null, golden: true }) } };
const h = makeBattle({
defs, bandId: 'band_amiya', units: [{ chessId: 'e_op', row: 10, col: 3 }],
bonds: { preciShip: bond(1, 50), suntShip: bond(1, 0, 2), steadShip: bond(1, 0) },
});
h.step();
const u = h.unit('e_op');
const want = 1 + pre.base_atk + pre.atk_per_stack * 50 + sunt.power_atk + 0.2;
close(u.s.atk, 500 * want, 'one 直接乘算 sum');
checkInvariants(h.b);
});
test('a leader sharing the pool takes the summed ATK: one normal hit of a stacked operator (精准 L600 + 独行 + 2 items)', () => {
const pre = bondBb('preciShip'), solo = bondBb('soloShip');
const defs = {
chess: { s_op: chessRec({ id: 's_op', bonds: ['preciShip', 'soloShip'], profession: 'SNIPER', skill: null, rangeGrid: [[0, 0], [0, 1], [0, 2], [0, 3]] }) },
enemies: { enemy_stack_leader: { ...enemyRec({ key: 'enemy_stack_leader', hp: 600000, speed: 0, dmgType: 'none' }), rank: 'BOSS', hitArea: { w: 4.95, h: 2.95, dx: 0, dy: 1 } } },
};
const pool = new SharedBossPool(3600000);
const h = makeBattle({
defs, kind: 'boss', stage: flatStage(), sharedBoss: pool, autoFinish: false,
units: [{ chessId: 's_op', row: 10, col: 6, items: [HAMMER, BLADE] }],
bonds: { preciShip: bond(1, 600), soloShip: bond(1, 0, 1) },
enemies: [{ key: 'enemy_stack_leader', pos: [3, 10], route: { motion: 'WALK', start: [3, 10], end: [3, 10], checkpoints: [] }, tag: 'boss' }],
});
const hits = [];
h.b.on('damaged', (c) => { if (c.target.bossPool && c.dmg.isAttack) hits.push(c.amount); }, { priority: -1e9 });
h.runUntil(() => hits.length >= 1, 10);
const atk = 500 * (1 + pre.base_atk + pre.atk_per_stack * 600 + solo.atk + 0.15 + 0.4);
assert.ok(hits.length >= 1, 'the operator hits the leader');
close(hits[0], atk, 'DEF 0: one hit = the summed ATK');
close(pool.maxHp - pool.hp, hits.reduce((s, x) => s + x, 0), 'the pool lost exactly the hits');
assert.ok(hits[0] < 500 * (1 + pre.base_atk + pre.atk_per_stack * 600) * (1 + solo.atk) * 1.15 * 1.4 / 2, 'v2.5 compounded this to more than twice as much');
checkInvariants(h.b);
});
+5 -2
View File
@@ -139,8 +139,11 @@ for (const bossId of BOSSES) {
const pool = new SharedBossPool(max); const pool = new SharedBossPool(max);
const { b } = bossBattle(bossId, pool); const { b } = bossBattle(bossId, pool);
const leader = b.enemies.find((e) => e.alive && e.isBoss); const leader = b.enemies.find((e) => e.alive && e.isBoss);
const dealt = b.dealDamage(null, leader, { amount: 1e9, type: 'true', mul: (max - 0.4) / 1e9 }); // the team already dealt all but 250000 (one hit of the whole pool would be cancelled by 限伤, BOSS_HIT_LIMIT 300000)
assert.ok(dealt > max - 1, `${bossId}: the hit took the whole pool (${dealt} of ${max})`); const rest = 250000;
pool.damage('p_1', max - rest);
const dealt = b.dealDamage(null, leader, { amount: 1e9, type: 'true', mul: (rest - 0.4) / 1e9 });
assert.ok(dealt > rest - 1, `${bossId}: the hit took the rest of the pool (${dealt} of ${rest})`);
assert.equal(pool.hp, 0, 'not 0.4 HP: nobody would ever see it and no hit may be needed to finish it'); assert.equal(pool.hp, 0, 'not 0.4 HP: nobody would ever see it and no hit may be needed to finish it');
for (let i = 0; i < 3 && !b.finished; i++) b.step(); for (let i = 0; i < 3 && !b.finished; i++) b.step();
assert.equal(b.finished, true); assert.equal(b.finished, true);
+462
View File
@@ -0,0 +1,462 @@
// Official limits (docs/research/11-limits-official.md; the user asked for the co-op leaders to be as hard to kill as
// officially): (1) bond layers stop at 999 per bond — the client's AutoChessBattleConst.MAX_GARRISON_STACK and
// AddBondCount `min(L + n, 999)`; (2) the boss-hit limit "限伤" — in a boss battle outside training (our kinds 'boss' /
// 'hidden'), a single hit on a leader (AutoChessBattleUtil.IsBossEnemy) whose ceil(damage) ≥ 300000
// (AutoChessBattleConst.MAX_BATTLE_DAMAGE) is cancelled: 0 damage, nothing credited to the shared pool.
//
// This file: the switches (shared/constants.js), the in-battle layer copy (Battle.addLayers / support.gainLayers) and
// the hit limit in the damage pipeline (server/sim/damage.js leaderHitCancelled: dealDamage, Battle.loseHp).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { BOND_LAYER_CAP, BOSS_HIT_LIMIT, layerGainRoom } from '../../shared/constants.js';
import { makeBattle, chessRec, enemyRec } from '../helpers/battleHarness.js';
import { gainLayers } from '../../server/sim/content/support/index.js';
import { SharedBossPool } from '../../server/match/finalAssault.js';
import { LocalBossPool, buildBattleSpec, createBattleFromSpec, resultDigest } from '../../server/sim/spec.js';
import { GameData } from '../../server/match/gamedata.js';
import { buildBossWave, setupMatchWaves } from '../../server/match/waves.js';
import { createRng } from '../../server/sim/rng.js';
import { GEO } from '../../shared/constants.js';
import { getData } from '../../server/data.js';
import { leaderHitCancelled } from '../../server/sim/damage.js';
import { abOf } from '../../server/sim/content/enemies.js';
const DATA = getData({ log: { warn() {}, error() {}, info() {} } });
const LIMIT = BOSS_HIT_LIMIT;
test('switches: BOND_LAYER_CAP = 999 (MAX_GARRISON_STACK), BOSS_HIT_LIMIT = 300000 (MAX_BATTLE_DAMAGE)', () => {
assert.equal(BOND_LAYER_CAP, 999);
assert.equal(BOSS_HIT_LIMIT, 300000);
});
test('layerGainRoom: min(n, 999 − before), never negative, never lowers a count; +Infinity = the room left', () => {
assert.equal(layerGainRoom(0, 5), 5);
assert.equal(layerGainRoom(990, 5), 5);
assert.equal(layerGainRoom(995, 10), 4, 'clamped to 999');
assert.equal(layerGainRoom(999, 1), 0, 'a gain at the cap adds 0');
assert.equal(layerGainRoom(1200, 3), 0, 'a count over the cap (a test write) gains 0 and is not lowered');
assert.equal(layerGainRoom(300, Infinity), 699);
assert.equal(layerGainRoom(undefined, 7), 7);
assert.equal(layerGainRoom(NaN, 7), 7);
for (const bad of [0, -3, NaN, null, undefined]) assert.equal(layerGainRoom(10, bad), 0);
});
// ---------------------------------------------------------------------------------------------------------------
// in-battle layer gains (the live copy, as the client's AddBondCount)
const opRec = (o = {}) => chessRec({ id: 't_op', profession: 'WARRIOR', stats: { atk: 300, maxHp: 1e6 }, skill: null, bonds: ['yanShip'], ...o });
function layerBattle(bonds, extra = {}) {
return makeBattle({
defs: { chess: { t_op: opRec() } },
players: [{ playerId: 'P', seat: 0, side: 'L', colOffset: 0, units: [{ uid: 1, chessId: 't_op', row: 10, col: 4 }], bonds, playerEffects: [] }],
content: 'none', autoFinish: false, ...extra,
});
}
test('Battle.addLayers: clamps the live copy at 999; the event and layerGains carry what was added; at the cap: 0, no hook, no event', () => {
const h = layerBattle({ yanShip: { count: 3, active: true, tier: 1, layers: 990 } });
h.step();
const b = h.b;
assert.equal(b.addLayers('P', 'yanShip', 4, 'garrison'), 4);
assert.equal(b.getPlayer('P').bonds.yanShip.layers, 994);
assert.equal(b.addLayers('P', 'yanShip', 20, 'garrison'), 5, 'only the room left (999 − 994)');
assert.equal(b.getPlayer('P').bonds.yanShip.layers, 999);
const hooks0 = h.hooksOf('layerGain').length;
assert.equal(b.addLayers('P', 'yanShip', 3, 'garrison'), 0, 'a gain at the cap adds 0');
assert.equal(h.hooksOf('layerGain').length, hooks0, 'no layerGain hook at the cap');
assert.equal(b.getPlayer('P').bonds.yanShip.layers, 999);
assert.deepEqual(h.eventsOf('layer').map((e) => e[3]), [4, 5], "'layer' events carry the clamped gains, none at the cap");
assert.equal(h.result().perPlayer.P.layerGains.yanShip, 9, 'the result reports what was added');
});
test('Battle.addLayers: a layerGain hook that raises n (魔王 +1) is clamped after the hook', () => {
const h = layerBattle({ yanShip: { count: 3, active: true, tier: 1, layers: 997 } }, {
setup: (b) => b.on('layerGain', (c) => { c.n += 5; }),
});
h.step();
assert.equal(h.b.addLayers('P', 'yanShip', 1, 'garrison'), 2, '1 + 5 asked, 2 room');
assert.equal(h.b.getPlayer('P').bonds.yanShip.layers, 999);
});
test('Battle.addLayers without a live copy of the bond (partial input): the battle\'s own gains stop at 999', () => {
const h = layerBattle({});
h.step();
assert.equal(h.b.addLayers('P', 'kjeragShip', 600, 'bond'), 600);
assert.equal(h.b.addLayers('P', 'kjeragShip', 600, 'bond'), 399);
assert.equal(h.b.addLayers('P', 'kjeragShip', 1, 'bond'), 0);
assert.equal(h.result().perPlayer.P.layerGains.kjeragShip, 999);
});
test('support.gainLayers (特质 / bond gains): returns the clamped total; a capped bond counts nothing toward the per-battle cap', () => {
const h = layerBattle({ yanShip: { count: 3, active: true, tier: 1, layers: 998 }, kjeragShip: { count: 2, active: true, tier: 1, layers: 10 } });
h.step();
assert.equal(gainLayers(h.b, { playerId: 'P', bonds: ['yanShip', 'kjeragShip'], n: 3, reason: 'garrison' }), 1 + 3);
assert.equal(gainLayers(h.b, { playerId: 'P', bonds: 'yanShip', n: 3, reason: 'garrison', cap: 24, capKey: 'g' }), 0);
const ps = h.b.getPlayer('P');
assert.deepEqual([ps.bonds.yanShip.layers, ps.bonds.kjeragShip.layers], [999, 13]);
});
test('layer gains stay disabled in boss / 联防 fields (unchanged)', () => {
const h = layerBattle({ yanShip: { count: 3, active: true, tier: 1, layers: 10 } }, { kind: 'boss' });
h.step();
assert.equal(h.b.addLayers('P', 'yanShip', 5, 'garrison'), 0);
});
// ---------------------------------------------------------------------------------------------------------------
// 限伤: the boss-hit limit
const guardRec = () => chessRec({ id: 't_guard', profession: 'WARRIOR', stats: { atk: 10, maxHp: 1e9 }, skill: null });
/** A field of `kind` with a leader (tag 'boss'), a minion and a part, all standing still; the shared pool when given. */
function hitField({ kind = 'boss', pool = null, leaderDef = 0, leaderRes = 0 } = {}) {
const h = makeBattle({
kind, sharedBoss: pool,
defs: {
chess: { t_guard: guardRec() },
enemies: {
enemy_lead: enemyRec({ key: 'enemy_lead', rank: 'BOSS', hp: 1e9, speed: 0, def: leaderDef, res: leaderRes }),
enemy_mini: enemyRec({ key: 'enemy_mini', hp: 1e9, speed: 0 }),
enemy_part: enemyRec({ key: 'enemy_part', rank: 'ELITE', hp: 1e9, speed: 0 }),
},
},
players: [{ playerId: 'P', seat: 0, side: 'L', colOffset: 0, units: [{ uid: 1, chessId: 't_guard', row: 10, col: 2 }], bonds: {}, playerEffects: [] }],
enemies: kind === 'boss' || kind === 'hidden'
? [{ key: 'enemy_lead', pos: [3, 9], tag: 'boss' }, { key: 'enemy_mini', pos: [3, 12] }, { key: 'enemy_part', pos: [4, 12], tag: 'part' }]
: [{ key: 'enemy_lead', pos: [10, 9], tag: 'boss' }, { key: 'enemy_mini', pos: [11, 9] }],
content: 'none', autoFinish: false,
});
h.step();
const leader = h.enemy('enemy_lead');
const op = h.unit(1);
assert.ok(leader && leader.isBoss, 'the leader is a boss unit');
return { h, b: h.b, leader, mini: h.enemy('enemy_mini'), part: h.enemy('enemy_part'), op };
}
const capEvents = (h) => h.eventsOf('fx').filter((e) => e[1] === 'hitCap');
test('限伤: a leader\'s hit of 299999 lands, 300000 deals 0 — nothing credited to the shared pool, no number, a hitCap event', () => {
const pool = new SharedBossPool(5e6);
const { h, b, leader, op } = hitField({ pool });
assert.equal(b.dealDamage(op, leader, { amount: LIMIT - 1, type: 'true' }), LIMIT - 1);
assert.equal(pool.hp, 5e6 - (LIMIT - 1));
assert.equal(pool.byPlayer.get('P'), LIMIT - 1);
const dmg0 = h.eventsOf('dmg').length;
const damaged0 = h.hooksOf('damaged').length;
assert.equal(b.dealDamage(op, leader, { amount: LIMIT, type: 'true' }), 0, 'cancelled, not clamped');
assert.equal(pool.hp, 5e6 - (LIMIT - 1), 'the pool is untouched');
assert.equal(pool.byPlayer.get('P'), LIMIT - 1, 'nothing credited');
assert.equal(b._pp('P').bossDamage, LIMIT - 1, 'no boss damage stat');
assert.equal(op.stats.dmg, LIMIT - 1, 'no damage stat');
assert.equal(h.eventsOf('dmg').length, dmg0, 'no damage number');
assert.equal(h.hooksOf('damaged').length, damaged0, "no 'damaged' hook (nothing was dealt)");
const ev = capEvents(h);
assert.equal(ev.length, 1);
assert.deepEqual([ev[0][4].id, ev[0][4].n], [leader.id, LIMIT]);
assert.equal(b.dealDamage(op, leader, { amount: 5e6, type: 'true' }), 0, 'a whole pool in one hit is cancelled too');
assert.ok(leader.alive);
assert.equal(pool.hp, 5e6 - (LIMIT - 1));
});
test('限伤: the check is ceil(final damage) — 299999.2 rounds up to the line and is cancelled, 299999 exactly lands', () => {
const { b, leader, op } = hitField();
const hp0 = leader.hp;
assert.equal(b.dealDamage(op, leader, { amount: 299999.2, type: 'true' }), 0);
assert.equal(leader.hp, hp0);
assert.equal(b.dealDamage(op, leader, { amount: 299999, type: 'true' }), 299999);
});
test('限伤: the value checked is the hit after DEF / RES and every multiplier (before shields)', () => {
const { b, leader, op } = hitField({ leaderDef: 100000, leaderRes: 50 });
// phys: 399999 − 100000 DEF = 299999 → lands; 400000 − 100000 = 300000 → cancelled
assert.equal(b.dealDamage(op, leader, { amount: 399999, type: 'phys' }), 299999);
assert.equal(b.dealDamage(op, leader, { amount: 400000, type: 'phys' }), 0);
// arts: 590000 × (1 − 50 %) = 295000 lands; 600000 × 50 % = 300000 cancelled
assert.equal(b.dealDamage(op, leader, { amount: 590000, type: 'arts' }), 295000);
assert.equal(b.dealDamage(op, leader, { amount: 600000, type: 'arts' }), 0);
// a multiplier pushes a small hit past the line: 150000 true × 2 (脆弱 on the leader) = 300000 → 0
b.addBuff(leader, { key: 'test:fragile', mods: { dmgTakenMul: 2 } });
assert.equal(b.dealDamage(op, leader, { amount: 149999, type: 'true' }), 299998);
assert.equal(b.dealDamage(op, leader, { amount: 150000, type: 'true' }), 0, 'bigger multipliers lower the damage of hits past the line');
// the attacker's damage-dealt multiplier and dmg.mul count too
b.removeBuff(leader, 'test:fragile');
b.addBuff(op, { key: 'test:dealt', mods: { dmgDealtMul: 3 } });
assert.equal(b.dealDamage(op, leader, { amount: 100000, type: 'true' }), 0);
assert.equal(b.dealDamage(op, leader, { amount: 99999, type: 'true' }), 299997);
assert.equal(b.dealDamage(op, leader, { amount: 50000, type: 'true', mul: 2 }), 0, '50000 × 3 × 2');
});
test('限伤: checked before shields — a cancelled hit leaves the leader\'s shield untouched; a smaller hit is absorbed as usual', () => {
const { b, leader, op } = hitField();
b.addBuff(leader, { key: 'test:shield', shield: 100000 });
const hp0 = leader.hp;
assert.equal(b.dealDamage(op, leader, { amount: 350000, type: 'true' }), 0);
assert.equal(leader.findBuff('test:shield').shield, 100000, 'shield untouched');
assert.equal(b.dealDamage(op, leader, { amount: 250000, type: 'true' }), 150000, '100000 absorbed, 150000 lands');
assert.equal(leader.hp, hp0 - 150000);
});
test('限伤: only leaders — a minion and a part in the same boss field take any hit', () => {
const { b, mini, part, op } = hitField();
assert.equal(b.dealDamage(op, mini, { amount: 1e6, type: 'true' }), 1e6);
assert.equal(b.dealDamage(op, part, { amount: 2e6, type: 'phys' }), 2e6);
assert.equal(part.isBoss, false);
});
test('限伤: only boss / hidden battles — the same leader-tagged unit takes any hit in a normal or 联防 field', () => {
for (const kind of ['normal', 'unite']) {
const { b, leader, op } = hitField({ kind });
assert.equal(b.dealDamage(op, leader, { amount: 5e5, type: 'true' }), 5e5, kind);
}
const { b, leader, op } = hitField({ kind: 'hidden' });
assert.equal(b.dealDamage(op, leader, { amount: 5e5, type: 'true' }), 0, 'the Hidden Core is a boss battle');
});
test('限伤: every HP-damage kind — 元素伤害 and element bursts, DoT ticks, losses passed on (loseHp); element 损伤 (gauge) is not HP damage', () => {
const pool = new SharedBossPool(5e7);
const { h, b, leader, op } = hitField({ pool });
const hp = () => pool.hp;
// 元素伤害
let p0 = hp();
assert.equal(b.dealDamage(op, leader, { amount: 4e5, type: 'elemental', element: 'burn' }), 0);
assert.equal(hp(), p0);
// a burn burst whose 7000 元素伤害 is pushed past the line by 元素脆弱 ×100: the gauge bursts (its lock / RES cut stay),
// the burst's hit is cancelled
b.addBuff(leader, { key: 'test:elemFragile', mods: { elementalTakenMul: 100 } });
p0 = hp();
const caps0 = capEvents(h).length;
b.dealDamage(op, leader, { amount: 1e7, type: 'element', element: 'burn' });
assert.ok(leader.findBuff('burnBurst'), 'the gauge burst (爆发冷却 running)');
assert.equal(hp(), p0, 'the burst\'s 700000 元素伤害 was cancelled');
assert.equal(capEvents(h).length, caps0 + 1);
b.removeBuff(leader, 'test:elemFragile');
// DoT ticks: each tick is a hit of its own
p0 = hp();
b.addBuff(leader, { key: 'test:dotBig', duration: 3.01, interval: 1, onTick: () => b.dealDamage(op, leader, { amount: 4e5, type: 'true', canDodge: false }) });
b.addBuff(leader, { key: 'test:dotSmall', duration: 3.01, interval: 1, onTick: () => b.dealDamage(op, leader, { amount: 1000, type: 'true', canDodge: false }) });
h.run(3.2);
assert.equal(p0 - hp(), 3000, 'only the small ticks landed');
// a loss passed on to the leader (parts' 传递, a drone's death): one hit too
p0 = hp();
assert.equal(b.loseHp(leader, 3e5, { source: op }), 0);
assert.equal(hp(), p0);
assert.equal(b.loseHp(leader, 2e5, { source: op }), 2e5);
assert.equal(hp(), p0 - 2e5);
});
test('限伤: what ran before the hit stays — the attack\'s hit hook fires; nothing after (damaged, fatal, kill)', () => {
const { h, b, leader, op } = hitField();
const hit0 = h.hooksOf('hit').length;
const dmg0 = h.hooksOf('damaged').length;
b.dealDamage(op, leader, { amount: 1e6, type: 'phys', isAttack: true });
assert.equal(h.hooksOf('hit').length, hit0 + 1, "the 'hit' hook (before mitigation) ran");
assert.equal(h.hooksOf('damaged').length, dmg0);
assert.equal(h.hooksOf('fatal').length, 0);
assert.equal(h.hooksOf('kill').length, 0);
assert.ok(leader.alive);
});
test('限伤: a browser pool (client-side combat) behaves the same; the field stays alive and deterministic', () => {
const pool = new LocalBossPool(1e6);
const { b, leader, op } = hitField({ pool });
assert.equal(b.dealDamage(op, leader, { amount: 3e5, type: 'true' }), 0);
assert.equal(pool.hp, 1e6);
assert.equal(pool.cum, 0, 'nothing to report to the server');
assert.equal(b.dealDamage(op, leader, { amount: 2e5, type: 'true' }), 2e5);
assert.equal(pool.cum, 2e5);
});
// ---------------------------------------------------------------------------------------------------------------
// the real leaders (data/bosses.json enemyKey = activity_table autoChessData.bossInfoDict enemyId, the official
// IsBossEnemy list) on real boss fields
test('IsBossEnemy ⇔ tag \'boss\': every tag-\'boss\' spawn of a boss / hidden template is a bosses.json enemyKey; parts and escorts never are', () => {
const leaders = new Set(Object.values(DATA.bosses).map((x) => x.enemyKey));
assert.equal(leaders.size, 10);
const parts = new Set(Object.values(DATA.bosses).flatMap((x) => x.parts || []));
for (const [id, t] of Object.entries(DATA.waves)) {
if (!t || !Array.isArray(t.spawns) || (t.kind !== 'boss' && t.kind !== 'hidden')) continue;
for (const s of t.spawns) {
const key = s && (s.key ?? s.enemyKey);
if (!key) continue;
if (s.tag === 'boss') assert.ok(leaders.has(key), `${id}: ${key} tagged boss is a leader`);
else assert.ok(!leaders.has(key), `${id}: ${key} is a leader but not tagged boss`);
if (s.tag === 'part') assert.ok(parts.has(key) && !leaders.has(key), `${id}: part ${key}`);
}
}
});
const gd = new GameData(DATA, 'mode_multi_hard');
const setup = setupMatchWaves(gd, createRng(3));
const PAIR = [
{ playerId: 'p_0', seat: 0, side: 'L', colOffset: 0, units: [], bonds: {}, playerEffects: [] },
{ playerId: 'p_1', seat: 1, side: 'R', colOffset: 8, units: [], bonds: {}, playerEffects: [] },
];
function realBossField(bossId, hidden, pool, players = PAIR) {
const wave = buildBossWave(gd, createRng(1), setup.factions, hidden ? 15 : 14, { bossId, solo: false });
const spec = buildBattleSpec({
battleId: `lim.${bossId}`, fieldId: 'b1', kind: hidden ? 'hidden' : 'boss', seed: 5, modeId: 'mode_multi_hard', round: hidden ? 15 : 14,
stageId: 'act2autochess_m01', rect: { ...GEO.BOSS_RECT }, timeLimit: null, players,
spawns: wave.spawns.filter((s) => s && gd.enemy(s.enemyKey)), routes: wave.routes, flags: { layerGainsEnabled: false, ...gd.dp },
enemyOverrides: wave.overrides, waveId: wave.templateId, bossId, boss: { poolHp: pool.maxHp, poolMax: pool.maxHp },
});
const b = createBattleFromSpec(spec, undefined, { sharedBoss: pool, recordEvents: true, quiet: true });
const want = gd.boss(bossId).parts?.length ? 1 + gd.boss(bossId).parts.length : 1;
for (let i = 0; i < 30 * 120 && b.enemies.filter((e) => e.alive && (e.isBoss || e.tag === 'part')).length < want; i++) b.step();
return { b, spec };
}
for (const [bossId, hidden] of [['boss_1', false], ['boss_5', false], ['boss_8', true], ['boss_9', true], ['boss_10', true]]) {
test(`限伤 on a real field: ${bossId} — every copy of the leader cancels a 300000 hit and takes a 299999 one; its parts are no leaders`, () => {
const pool = new SharedBossPool(gd.boss(bossId).bloodPoint.HARD);
const { b } = realBossField(bossId, hidden, pool);
const caps = [];
const fx0 = b.fx.bind(b);
b.fx = (k, p) => { if (k === 'hitCap') caps.push(p.id); return fx0(k, p); };
const leaders = b.enemies.filter((e) => e.alive && e.isBoss);
assert.ok(leaders.length >= 1, 'the leader spawned');
for (const L of leaders) {
assert.equal(L.defId, gd.boss(bossId).enemyKey);
const before = pool.hp;
assert.equal(b.dealDamage(null, L, { amount: LIMIT, type: 'true', ignoreSleep: true }), 0, `${L.defId} #${L.id}`);
assert.equal(pool.hp, before);
assert.ok(caps.includes(L.id), `${L.defId} #${L.id}: cancelled by the limit (not by an invulnerable phase)`);
assert.equal(b.dealDamage(null, L, { amount: LIMIT - 1, type: 'true', ignoreSleep: true }), LIMIT - 1);
assert.equal(pool.hp, before - (LIMIT - 1));
}
const parts = b.enemies.filter((e) => e.alive && e.tag === 'part');
if (hidden && bossId !== 'boss_10') assert.ok(parts.length > 0, 'the parts spawned');
for (const p of parts) {
assert.equal(p.isBoss, false);
assert.equal(leaderHitCancelled(b, p, 1e7), false, `part ${p.defId} is no leader`);
}
for (const m of b.enemies.filter((e) => e.alive && !e.isBoss && e.tag !== 'part')) assert.equal(leaderHitCancelled(b, m, 1e7), false, m.defId);
});
}
test('限伤 vs the real leader defences [ASSUMED order]: 阿利斯泰尔\'s 5000 vest barrier stays under a cancelled hit; a 再生 block acts first', () => {
// boss_6: the kit's own 莫非王土 animates a shield equipment lying on the field → the `boss:vest` HP shield (5000)
const pool6 = new SharedBossPool(gd.boss('boss_6').bloodPoint.HARD);
const { b: b6 } = realBossField('boss_6', false, pool6);
const lion = b6.enemies.find((e) => e.alive && e.isBoss);
const spot = [Math.round(lion.y), Math.round(lion.x) + 1];
assert.ok(b6.spawnEnemy('enemy_10029_vtshld', { pos: spot, route: { motion: 'FLY', start: spot, end: spot, steps: [{ t: 'wait', s: 99999 }] }, tag: 'part', countInTotal: false }));
for (let i = 0; i < 30 * 10 && !lion.findBuff('boss:vest'); i++) b6.step();
const vest = lion.findBuff('boss:vest');
assert.ok(vest, 'the kit put on the vest');
assert.equal(vest.shield, 5000, 'store.dynamic');
const caps6 = [];
const fx6 = b6.fx.bind(b6);
b6.fx = (k, p) => { if (k === 'hitCap') caps6.push(p.id); return fx6(k, p); };
const before = pool6.hp;
assert.equal(b6.dealDamage(null, lion, { amount: 302000, type: 'true', ignoreSleep: true }), 0, 'within 5000 of the line: still cancelled');
assert.equal(lion.findBuff('boss:vest')?.shield, 5000, 'the barrier is untouched (限伤 before shields)');
assert.equal(pool6.hp, before);
assert.deepEqual(caps6, [lion.id]);
assert.equal(b6.dealDamage(null, lion, { amount: 299999, type: 'true', ignoreSleep: true }), 294999, 'under the line: 5000 absorbed, the rest lands');
assert.equal(pool6.hp, before - 294999);
assert.equal(lion.findBuff('boss:vest'), null, 'the barrier is spent');
// boss_10 (hidden): a 假想敌:再生 aura block (`hit` step) negates the hit before the limit sees it
const pool10 = new SharedBossPool(gd.boss('boss_10').bloodPoint.HARD);
const { b: b10 } = realBossField('boss_10', true, pool10);
const pipe = b10.enemies.find((e) => e.alive && e.isBoss);
abOf(b10, pipe).hitShield = 1;
const caps10 = [];
const fx10 = b10.fx.bind(b10);
b10.fx = (k, p) => { if (k === 'hitCap') caps10.push(p.id); return fx10(k, p); };
const before10 = pool10.hp;
assert.equal(b10.dealDamage(null, pipe, { amount: 2e6, type: 'phys', ignoreSleep: true }), 0);
assert.equal(abOf(b10, pipe).hitShield, 0, 'the block is spent');
assert.deepEqual(caps10, [], 'the limit never saw the hit');
assert.equal(b10.dealDamage(null, pipe, { amount: 2e6, type: 'phys', ignoreSleep: true }), 0);
assert.deepEqual(caps10, [pipe.id], 'the next one is cancelled by the limit');
assert.equal(pool10.hp, before10);
});
test('限伤 vs 【死亡集群】 (real kit): a drone killed by an operator costs the leader 2 % of the pool max — a loss that lands up to a 14999950 pool and is cancelled above it (ceil)', () => {
// boss_1 / boss_8 skill 2: bb hp_ratio 0.02; the leader's max HP is the shared pool's max (Battle syncs it), so the
// loss is 0.02 × pool max. Today's largest drone pool is boss_8 ABYSS 7.2M → 144000; a pool above 14999950 (≈ 2.08 ×
// that), where ceil(0.02 × max) reaches 300000, would make every drone kill a cancelled hit — kept in view for any
// change to the pool size (research 11 §6).
assert.equal(gd.enemy('enemy_9013_acstmk').skills.find((s) => s.prefabKey === '2').bb.hp_ratio, 0.02);
assert.equal(gd.enemy('enemy_9013_acstmk_2').skills.find((s) => s.prefabKey === '2').bb.hp_ratio, 0.02);
const withOp = [{ ...PAIR[0], units: [{ uid: 1, chessId: 'chess_char_1_01_a', row: 10, col: 2 }] }, PAIR[1]];
const run = (poolMax) => {
const pool = new SharedBossPool(poolMax);
const { b } = realBossField('boss_1', false, pool, withOp);
const L = b.enemies.find((e) => e.alive && e.isBoss);
const key = gd.enemy(L.defId).skills.find((s) => s.prefabKey === '2').bbStr.enemy_key; // enemy_1005_yokai
const caps = [];
const fx0 = b.fx.bind(b);
b.fx = (k, p) => { if (k === 'hitCap') caps.push([p.id, p.n]); return fx0(k, p); };
for (let i = 0; i < 30 * 5 && !b.enemies.some((e) => e.alive && e.defId === key); i++) b.step();
const drone = b.enemies.find((e) => e.alive && e.defId === key);
assert.ok(drone, 'the kit summoned its drones (skill 2, initCooldown 0)');
const op = b.units.find((u) => u.alive && u.side === 'ally');
assert.ok(op, 'an operator to kill the drone');
assert.equal(L.s.maxHp, poolMax, 'the leader\'s max HP is the pool max');
const before = pool.hp;
b.dealDamage(op, drone, { amount: 1e9, type: 'true', ignoreSleep: true });
assert.equal(drone.alive, false, 'the drone died');
return { lost: before - pool.hp, caps, id: L.id };
};
const today = run(7200000);
assert.equal(today.lost, 144000, 'boss_8 ABYSS-sized pool: the loss lands');
assert.deepEqual(today.caps, []);
const under = run(14999950);
assert.equal(under.lost, 299999, 'just under the line: lands');
assert.deepEqual(under.caps, []);
for (const m of [14999951, 15000000]) {
const over = run(m);
assert.equal(over.lost, 0, `${m}: ceil(0.02 × max) = 300000, one hit at the line: cancelled`);
assert.deepEqual(over.caps.map((c) => c[0]), [over.id]);
}
});
test('限伤 keeps the sim deterministic: a boss field whose hits cross the line gives the same result digest twice', () => {
const run = () => {
const pool = new LocalBossPool(gd.boss('boss_1').bloodPoint.HARD);
const { b } = realBossField('boss_1', false, pool);
// a stand-in for a 999-layer board: every hit on the leader is ×40 (some land, the big ones are cancelled)
b.on('hit', (c) => { if (c.target && c.target.isBoss) c.dmg.mul *= 40; });
b.on('tick', () => {
const L = b.enemies.find((e) => e.alive && e.isBoss);
if (L && b.tickCount % 15 === 0) b.dealDamage(null, L, { amount: 2000 + (b.tickCount % 7) * 2500, type: 'true' });
});
let caps = 0;
const fx0 = b.fx.bind(b);
b.fx = (k, p) => { if (k === 'hitCap') caps++; return fx0(k, p); };
for (let i = 0; i < 30 * 20; i++) b.step();
return { d: resultDigest(b.result()), hp: pool.hp, caps };
};
const a = run(), c = run();
assert.deepEqual(a.d, c.d);
assert.equal(a.hp, c.hp);
assert.ok(a.caps > 0, 'some hits were cancelled');
});
// ---------------------------------------------------------------------------------------------------------------
// the docs state the rules (kept here, next to the code, so they move together)
test('docs: research 11 is indexed; SIM / META / DATA / PLAYING / BALANCE state the 999 cap and the 300000 cancel', async () => {
const { readFileSync } = await import('node:fs');
const doc = (p) => readFileSync(new URL(`../../${p}`, import.meta.url), 'utf8');
const R11 = doc('docs/research/11-limits-official.md');
assert.match(R11, /^# 11 · Official limits/);
assert.match(R11, /MAX_GARRISON_STACK/);
assert.match(R11, /MAX_BATTLE_DAMAGE/);
assert.match(R11, /IsBossEnemy/);
assert.match(doc('docs/research/00-INDEX.md'), /`11-limits-official\.md`/);
const SIM = doc('docs/SIM.md');
assert.match(SIM, /限伤/);
assert.match(SIM, /BOSS_HIT_LIMIT/);
assert.match(SIM, /BOND_LAYER_CAP/);
assert.match(SIM, /hitCap/);
const META = doc('docs/META.md');
assert.match(META, /BOND_LAYER_CAP/);
assert.match(META, /BOSS_HIT_LIMIT/);
assert.match(doc('docs/DATA.md'), /IsBossEnemy/);
const PLAYING = doc('docs/PLAYING.md');
assert.match(PLAYING, /999 层/);
assert.match(PLAYING, /300000/);
assert.match(doc('docs/BALANCE.md'), /限伤 300000/);
});
+106
View File
@@ -0,0 +1,106 @@
// Real-server browser E2E of the user's playtest #6 follow-up: "官方就是合成精锐时,如果消耗了场上的干员,精锐会出现在场上那个
// 位置" (PRTS 卫戍协议/帮助 "若消耗已部署至作战区的干员,则发送至作战区对应位置"). A solo prep (test/e2e/fastServer.mjs hooks
// SP_START_ROUND 2 + SP_START_CHESS: two 古米 in the hand + SP_START_SHOP: a third 古米 in the shop) is driven with real
// input: one copy goes onto the board through the direction wheel facing UP, the shop card is tapped once (armed: the
// gold merge highlight lights that copy's tile) and again (bought) — the elite appears on that tile facing UP with the
// promotion cue (render/app.js setPrep → fx.promote), the hand copy is gone, the deploy count is unchanged and the
// promotion reward shows. Zero console errors.
//
// SP_E2E=1 node --test test/ui/playtest6-elite.e2e.test.js
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync } from 'node:fs';
import path from 'node:path';
import { Client, ROOT, sleep, hasChrome, startRealServer, problemsOf } from '../e2e/client.mjs';
const ENABLED = process.env.SP_E2E === '1' && hasChrome() && existsSync(path.join(ROOT, 'public/assets'));
const GUMMY = 'chess_char_1_10_a';
async function soloToPrep(c) {
await c.open();
await c.enter('煌');
await c.click('.mode-card', '独立模拟');
await c.click('.diff-card', '险境');
await c.click('.create-box button', '开始独立模拟');
await c.waitFor((s) => !!s.room, 'solo room');
if (!(await c.st()).phase) await c.click('.room-bar__right button', '开始模拟', { timeout: 20000 });
await c.waitFor((s) => s.phase === 'INFO_CHECK', 'briefing', 30000);
await c.click('.brief__foot .btn--primary', '准备就绪');
await c.waitFor((s) => s.phase === 'BAND_DRAFT', 'band draft', 30000);
await c.click('.dband', null, { nth: 1 });
await c.click('.draft-detail__btns .btn--primary', '确认选择');
await c.waitFor((x) => x.phase === 'PREP' && !x.ready, 'prep', 60000);
await sleep(1800);
}
const privOf = (c) => c.page.evaluate(() => globalThis.__SP__.store.get().match.private);
describe('user playtest #6 follow-up: a merge\'s elite takes the deployed copy\'s tile (real server, headless Chrome)', { skip: !ENABLED && 'set SP_E2E=1 (and have Chrome + assets) to run' }, () => {
test('buying the third 古米 with one deployed: the elite stands on that tile, facing kept, with the promotion cue', { timeout: 240000 }, async () => {
const puppeteer = (await import('puppeteer-core')).default;
const srv = await startRealServer({ fast: { timerScale: 0.5, combatSpeed: 4, startRound: 2, chess: [GUMMY, GUMMY], shop: [GUMMY] } });
const c = new Client(puppeteer, srv.base, 'elite', { prefix: 'pt6' });
try {
await soloToPrep(c);
await c.hookRequests();
const hand0 = (await c.handPieces('chess')).filter((p) => p.id === GUMMY);
assert.equal(hand0.length, 2, 'two 古米 in the starter kit');
// one copy onto the board, facing UP (drag + direction wheel)
const tile = await c.freeTileFor(hand0[0].uid);
const from = await c.piecePoint(hand0[0].uid);
const to = await c.tilePoint(tile.row, tile.col);
assert.ok(from && to, 'piece and tile on screen');
await c.drag(from, to);
await c.page.waitForSelector('.fwheel__dia', { timeout: 4000 });
await c.swipe('UP');
await sleep(500);
const placed = (await privOf(c)).board.find((p) => p.uid === hand0[0].uid);
assert.ok(placed && placed.row === tile.row && placed.col === tile.col && placed.dir === 'UP', `placed facing UP (${JSON.stringify(placed)})`);
const count0 = (await c.st()).deployCount;
const promos0 = await c.page.evaluate(() => globalThis.__SP_VIEW__?.raw?.debug?.promotions?.length ?? 0);
// first tap: armed — the card says 可晋升 and the gold highlight lights the copy's tile
const name = await c.page.evaluate((id) => globalThis.__SP__.data.lookup('chess', id)?.name, GUMMY);
await c.click('.shopbar__cards .scard', name);
await sleep(400);
const armed = await c.page.evaluate(() => {
const card = document.querySelector('.shopbar__cards .scard.is-armed');
const hl = globalThis.__SP_VIEW__?.raw?.debug?.tiles?.highlights?.get('mergeTile');
const hint = document.querySelector('.dpanel .dhint--merge');
return { merge: !!card?.querySelector('.scard__mergetag'), title: card?.querySelector('.scard__mergetag')?.title || null, tiles: hl ? hl.tiles : null,
hint: hint ? hint.textContent.trim() : null, hintVisible: !!(hint && hint.getBoundingClientRect().height > 0) };
});
assert.ok(armed.merge, 'the armed card is a merge (可晋升)');
assert.equal(armed.title, '精锐干员将出现在作战区原位置');
// touch never shows a title: the detail card the first tap opened carries the same line (QA 6b)
assert.equal(armed.hint, '可晋升:精锐干员将出现在作战区原位置');
assert.ok(armed.hintVisible, 'the line is laid out in the detail card');
assert.deepEqual(armed.tiles, [[tile.row, tile.col]], 'the merge highlight lights the deployed copy\'s tile');
await c.shot('elite-armed');
// second tap: bought
await c.click('.shopbar__cards .scard.is-armed');
await c.page.waitForFunction((id) => (globalThis.__SP__.store.get().match.private?.board || []).some((p) => p.golden && p.id === id), { timeout: 8000 }, `${GUMMY.slice(0, -2)}_b`);
const priv = await privOf(c);
const elite = priv.board.find((p) => p.golden);
assert.deepEqual([elite.row, elite.col, elite.dir], [tile.row, tile.col, 'UP'], 'the elite on the copy\'s tile, facing kept');
assert.ok(!priv.hand.some((p) => p && p.kind === 'chess'), 'the hand copy was consumed');
assert.equal((await c.st()).deployCount, count0, 'the deploy count is unchanged');
assert.ok(priv.shop.rewardOffer, 'the promotion reward is offered');
assert.ok((await c.requests('g.buy')).length === 1, 'one g.buy');
await sleep(300);
const promos = await c.page.evaluate(() => (globalThis.__SP_VIEW__?.raw?.debug?.promotions || []).slice());
assert.equal(promos.length, promos0 + 1, 'one promotion cue');
assert.deepEqual([promos.at(-1).area, promos.at(-1).row, promos.at(-1).col, promos.at(-1).uid], ['board', tile.row, tile.col, elite.uid], 'played on the elite\'s tile');
const hlAfter = await c.page.evaluate(() => globalThis.__SP_VIEW__?.raw?.debug?.tiles?.highlights?.get('mergeTile') || null);
assert.equal(hlAfter, null, 'the merge highlight is gone with the purchase');
// the elite's model stands on the tile (a prep view keyed by its uid)
const view = await c.page.evaluate((uid) => { const v = globalThis.__SP_VIEW__?.raw?.debug?.views?.get('p:' + uid); return v ? { golden: !!v.info?.golden, x: v._home?.x, y: v._home?.y } : null; }, elite.uid);
assert.ok(view && view.golden, `the elite's view exists (${JSON.stringify(view)})`);
await c.shot('elite-on-board');
assert.deepEqual(problemsOf([c]), []);
} finally {
await c.close();
await srv.stop();
}
});
});
+174
View File
@@ -0,0 +1,174 @@
// User playtest #6 follow-up, client side of "合成精锐时,如果消耗了场上的干员,精锐会出现在场上那个位置" (PRTS 卫戍协议/帮助
// "若消耗已部署至作战区的干员,则发送至作战区对应位置"; server PlayerState._mergeChess / board.js mergeTile):
// * gameLogic.mergeTarget mirrors the server: the tile of the deployed copy that deploys first (row desc, col asc)
// when gaining one more copy completes a merge, null when it completes none or no copy is deployed (the hand)
// * the shop card's 可晋升 tag says where the elite goes; ShopBar reports the armed card (armedSlotOf → onArm) so the
// game screen lights that tile (screens/game.js MERGE_HL)
// * the server and client agree on the target for real merges (the engine's elite lands on mergeTarget's tile)
// * an elite card never merges (server completesChessMerge): mergeProgress reports 0 copies, so no pips / tag / tile
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const read = (p) => readFileSync(path.join(ROOT, p), 'utf8');
globalThis.fetch = async (url) => {
const name = String(url).split('/').pop();
try {
const body = readFileSync(path.join(ROOT, 'data', name), 'utf8');
return { ok: true, status: 200, json: async () => JSON.parse(body) };
} catch {
return { ok: false, status: 404, json: async () => ({}) };
}
};
const { mergeTarget, mergeProgress } = await import('../../public/js/ui/gameLogic.js');
const { ChessCard, armedSlotOf, armKey, mergeHint } = await import('../../public/js/ui/shopBar.js');
const { ChessDetail, resolveDetail } = await import('../../public/js/ui/detailPanel.js');
const { data } = await import('../../public/js/data.js');
const { DATA, makeMatch, give } = await import('../match/harness.js');
await data.loadAll('chess', 'bonds', 'assets');
const getChess = (id) => (Object.hasOwn(DATA.chess, id) ? DATA.chess[id] : null);
const A = 'chess_char_1_01_a';
const KAZE = 'chess_char_2_11_a';
const golden = (id) => DATA.chess[id].goldenId;
const onBoard = (id, row, col, dir = 'RIGHT', uid = row * 100 + col) => ({ uid, kind: 'chess', id, golden: false, row, col, dir });
const inHand = (id, uid) => ({ uid, kind: 'chess', id, golden: false });
function* walk(v) {
if (Array.isArray(v)) { for (const x of v) yield* walk(x); return; }
if (!v || typeof v !== 'object') return;
yield v;
yield* walk(v.props?.children);
}
const hasClass = (v, c) => typeof v?.props?.class === 'string' && v.props.class.split(/\s+/).includes(c);
describe('gameLogic.mergeTarget (mirror of server board.js mergeTile)', () => {
test('no merge yet / hand copies only → null; a deployed copy → its tile and facing', () => {
assert.equal(mergeTarget({ board: [], hand: [inHand(A, 1)] }, A, getChess), null, 'one copy: no merge');
assert.equal(mergeTarget({ board: [], hand: [inHand(A, 1), inHand(A, 2)] }, A, getChess), null, 'hand copies: the elite goes to the hand');
assert.deepEqual(mergeTarget({ board: [onBoard(A, 10, 4, 'UP')], hand: [inHand(A, 2)] }, A, getChess), { row: 10, col: 4, dir: 'UP' });
assert.deepEqual(mergeTarget({ board: [onBoard(A, 10, 4)], temp: [inHand(A, 2)] }, A, getChess), { row: 10, col: 4, dir: 'RIGHT' }, 'temp copies count');
});
test('two deployed copies → the one that deploys first: row desc, then col asc', () => {
const priv = { board: [onBoard(A, 9, 2, 'DOWN'), onBoard(A, 11, 7, 'LEFT')], hand: [] };
assert.deepEqual(mergeTarget(priv, A, getChess), { row: 11, col: 7, dir: 'LEFT' });
const same = { board: [onBoard(A, 10, 8, 'UP'), onBoard(A, 10, 3, 'DOWN')], hand: [] };
assert.deepEqual(mergeTarget(same, A, getChess), { row: 10, col: 3, dir: 'DOWN' });
});
test('elites and other operators are ignored; 风丸 merges with one deployed copy', () => {
const priv = { board: [{ ...onBoard(golden(A), 12, 2), golden: true }, onBoard('chess_char_1_02_a', 12, 3), onBoard(A, 9, 9)], hand: [inHand(A, 5)] };
assert.deepEqual(mergeTarget(priv, A, getChess), { row: 9, col: 9, dir: 'RIGHT' });
assert.equal(mergeProgress({ board: [onBoard(KAZE, 10, 5)] }, KAZE, getChess).need, 2);
assert.deepEqual(mergeTarget({ board: [onBoard(KAZE, 10, 5, 'UP')] }, KAZE, getChess), { row: 10, col: 5, dir: 'UP' });
assert.equal(mergeTarget({ board: [onBoard(A, 10, 5)] }, A, getChess), null, 'two of three: no merge yet');
});
test('the engine agrees: a real merge puts the elite on mergeTarget\'s tile', () => {
for (const seed of [61, 62, 63]) {
const h = makeMatch({ mode: 'solo', difficulty: 'NORMAL', seed }).start();
h.toPrep(1);
h.setStage('act2autochess_m04');
const m = h.m;
const ps = h.ps('p_0');
for (const p of [...ps.board.values(), ...ps.hand.filter(Boolean)]) if (p.kind === 'chess') ps.returnCopies(p);
ps.board.clear();
ps.hand.fill(null);
ps.recompute();
const id = Object.values(DATA.chess).find((c) => !c.isGolden && c.tier === 1 && c.position === 'MELEE' && m.pool.has(c.chessId) && m.gd.mergeCount(c.chessId) === 3 && !m.gd.placeableTokens(c.chessId).length).chessId;
const tiles = [];
const map = ps.deployMap();
for (const [k, cls] of map) if (cls === 'melee') tiles.push(k.split(',').map(Number));
// two copies on two seed-dependent melee tiles
const pick = [tiles[seed % tiles.length], tiles[(seed * 7 + 3) % tiles.length]];
if (pick[0].join() === pick[1].join()) pick[1] = tiles[(seed + 1) % tiles.length];
give(m, ps, id, 'board', pick[0]).dir = 'UP';
give(m, ps, id, 'board', pick[1]).dir = 'DOWN';
const want = mergeTarget(ps.privateView(), id, getChess);
ps.funds = 10;
ps.shop.slots[0] = { kind: 'chess', id, basePrice: m.gd.chessPrice(id), frozen: false, sold: false };
assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot: 0 }), { ok: true });
const view = ps.privateView().board.find((v) => v.golden);
assert.deepEqual([view.row, view.col, view.dir], [want.row, want.col, want.dir], `seed ${seed}: server and client agree`);
m.dispose();
}
});
});
describe('shop bar: the merge tag and the armed card', () => {
test('the 可晋升 tag says where the elite goes (the board copy\'s place / the hand)', () => {
const slot = { kind: 'chess', id: A, price: 1, basePrice: 1 };
const tagOf = (priv) => [...walk(ChessCard({ slot, idx: 0, priv, onBuy() {}, onDetail() {} }))].find((v) => hasClass(v, 'scard__mergetag'));
assert.equal(tagOf({ board: [onBoard(A, 10, 4)], hand: [inHand(A, 2)] })?.props.title, '精锐干员将出现在作战区原位置');
assert.equal(tagOf({ board: [], hand: [inHand(A, 1), inHand(A, 2)] })?.props.title, '精锐干员将进入整备区');
assert.equal(tagOf({ board: [], hand: [inHand(A, 1)] }), undefined, 'no tag before the merge');
});
test('touch shows no title: the detail card the first tap opens says where the elite goes (QA 6b)', async () => {
await data.loadAll('chess', 'garrisons', 'assets', 'bonds', 'items');
const board = { board: [onBoard(A, 10, 4)], hand: [inHand(A, 2)] };
const hand = { board: [], hand: [inHand(A, 1), inHand(A, 2)] };
assert.equal(mergeHint(board, A), '精锐干员将出现在作战区原位置');
assert.equal(mergeHint(hand, A), '精锐干员将进入整备区');
assert.equal(mergeHint({ board: [], hand: [inHand(A, 1)] }, A), null, 'no merge yet');
assert.equal(mergeHint(board, golden(A)), null, 'an elite card never merges');
// the first tap: ShopBar tapCard → onDetail(id, 'chess', mergeHint) → game.js setDetail({ …, hint }) → resolveDetail
assert.match(read('public/js/ui/shopBar.js'), /setArmed\(key\); onDetail\(slot\.id, detailKind, detailKind === 'chess' \? mergeHint\(priv, slot\.id\) : null\)/);
assert.match(read('public/js/screens/game.js'), /onDetail=\$\{\(id, kind, hint\) => setDetail\(\{ kind: kind === 'item' \? 'item' : 'chess', id, hint: hint \|\| null \}\)\}/);
const r = resolveDetail({ kind: 'chess', id: A, hint: mergeHint(board, A) }, new Map());
assert.equal(r.hint, '精锐干员将出现在作战区原位置');
const blocks = ChessDetail({ chess: r.chess, piece: null, editable: false, bonds: [], loadout: null, hint: r.hint });
assert.equal(blocks[0].key, 'head');
assert.equal(blocks[1].key, 'merge', 'right under the header');
assert.ok(hasClass(blocks[1], 'dhint'));
const text = JSON.stringify(blocks[1].props.children);
assert.ok(text.includes('可晋升:') && text.includes('精锐干员将出现在作战区原位置'), text);
assert.ok(!ChessDetail({ chess: r.chess, piece: null, editable: false, bonds: [], loadout: null }).some((b) => b.key === 'merge'), 'no hint, no line');
});
test('an elite card never merges (server completesChessMerge refuses isGolden): no pips, no tag, no target tile', () => {
const E = golden(A);
const priv = { board: [onBoard(A, 10, 4, 'UP')], hand: [inHand(A, 2)] };
assert.equal(mergeProgress(priv, A, getChess).copies, 2);
assert.equal(mergeProgress(priv, E, getChess).copies, 0);
assert.equal(mergeTarget(priv, E, getChess), null);
const card = ChessCard({ slot: { kind: 'chess', id: E, price: 3, basePrice: 3 }, idx: 0, priv, onBuy() {}, onDetail() {} });
const nodes = [...walk(card)];
assert.equal(nodes.find((v) => hasClass(v, 'scard__mergetag')), undefined, 'no 可晋升 tag');
assert.equal(nodes.find((v) => hasClass(v, 'scard__pips')), undefined, 'no 已拥有 pips');
assert.ok(!hasClass(card, 'is-merge'));
// the normal card in the same state still merges onto the deployed copy's tile
assert.deepEqual(mergeTarget(priv, A, getChess), { row: 10, col: 4, dir: 'UP' });
// and the server agrees: gaining the elite completes no merge
const h = makeMatch({ mode: 'solo', difficulty: 'NORMAL', seed: 64 }).start();
h.toPrep(1);
const ps = h.ps('p_0');
const id = Object.values(DATA.chess).find((c) => !c.isGolden && c.tier === 1 && h.m.pool.has(c.chessId) && h.m.gd.mergeCount(c.chessId) === 3).chessId;
give(h.m, ps, id);
give(h.m, ps, id);
assert.equal(ps.completesChessMerge(id), true);
assert.equal(ps.completesChessMerge(golden(id)), false);
h.m.dispose();
});
test('armedSlotOf: shop / reward slots by key; level card, stale and sold keys → null', () => {
const slots = [{ kind: 'chess', id: A }, { kind: 'chess', id: KAZE, sold: true }, { kind: 'item', id: 'chess_item_1_01_e_a' }];
const reward = [{ kind: 'chess', id: 'chess_char_1_02_a' }];
assert.equal(armedSlotOf(armKey('c', 0, slots[0]), slots), slots[0]);
assert.equal(armedSlotOf(armKey('i', 2, slots[2]), slots), slots[2]);
assert.equal(armedSlotOf(armKey('r', 0, reward[0]), slots, reward), reward[0]);
assert.equal(armedSlotOf(armKey('c', 1, slots[1]), slots), null, 'sold');
assert.equal(armedSlotOf('c:0:other', slots), null, 'rerolled');
assert.equal(armedSlotOf('lv', slots), null);
assert.equal(armedSlotOf(null, slots), null);
});
test('the game screen lights the target tile while a merge-completing card is armed', () => {
const src = read('public/js/screens/game.js');
assert.match(src, /onArm=\$\{setArmedCard\}/, 'ShopBar reports the armed card');
assert.match(src, /mergeTarget\(priv, armedCard\.id, gd\.chess\)/, 'the target comes from gameLogic.mergeTarget');
assert.match(src, /view\.highlightTiles\(mergeAt \? \[\[mergeAt\.row, mergeAt\.col\]\] : \[\], MERGE_HL\)/, 'its own highlight group (cleared with [])');
assert.match(src, /const MERGE_HL = Object\.freeze\(\{ group: 'mergeTile'/);
});
});
+8 -7
View File
@@ -7,8 +7,8 @@
// matches (stage, factions, bans, boss and lineups all vary with the seed), simulates the real wave of that round with // matches (stage, factions, bans, boss and lineups all vary with the seed), simulates the real wave of that round with
// the real Battle and FULL content (kits, talents, bonds incl. layers, IN_BATTLE garrisons, items, bands, tokens) and // the real Battle and FULL content (kits, talents, bonds incl. layers, IN_BATTLE garrisons, items, bands, tokens) and
// reports the leak distribution and clear time. Boss rounds (R14 / solo 标准 R9) and the Hidden Core (R15) run the // reports the leak distribution and clear time. Boss rounds (R14 / solo 标准 R9) and the Hidden Core (R15) run the
// real Final Assault fields (pairs, `_s` template for a lone player, shared boss HP pool = bloodPoint (co-op; solo // real Final Assault fields (pairs, `_s` template for a lone player, shared boss HP pool = bloodPoint (co-op; × alive / 4
// ×0.25 [ASSUMED]) — GameData.bossPoolHp —, team LP, overtime drain) and report the pool damage by 150 s, the kill time and the team LP spent. // only with config bossHpScale.aliveScaling; solo ×0.25 [ASSUMED]) — GameData.bossPoolHp —, team LP, overtime drain) and report the pool damage by 150 s, the kill time and the team LP spent.
// //
// The competent board of round r (curves below, profile-scaled): shop level, deployed units, elites (精锐), equipment, // The competent board of round r (curves below, profile-scaled): shop level, deployed units, elites (精锐), equipment,
// 2–3 active bonds built around a core bond (3 members early, 6 from R11 when the pool allows it) and the layers those // 2–3 active bonds built around a core bond (3 members early, 6 from R11 when the pool allows it) and the layers those
@@ -31,7 +31,8 @@
// --group N boards per multi match (default 4; 1 = no 联防) // --group N boards per multi match (default 4; 1 = no 联防)
// --boss-samples N sampled matches per boss / hidden round (default 8) // --boss-samples N sampled matches per boss / hidden round (default 8)
// --rehearsal N layout variants a board rehearses (default 5 = every bot variant; 0 = heuristic planner only) // --rehearsal N layout variants a board rehearses (default 5 = every bot variant; 0 = heuristic planner only)
// --profile curve multiplier for elites, items and layers (weak 0.6, competent 1, strong 1.4, or a number) // --profile curve multiplier for elites, items and layers (weak 0.6, competent 1, strong 1.4, or a number;
// a bond's layers stop at BOND_LAYER_CAP, 999)
// --tuning off ignore data/tuning.json (it only holds title rules now: no effect on the numbers) // --tuning off ignore data/tuning.json (it only holds title rules now: no effect on the numbers)
// --legacy-time read the combat limits as game seconds (the reading before the fix of docs/BALANCE.md §2.1) // --legacy-time read the combat limits as game seconds (the reading before the fix of docs/BALANCE.md §2.1)
// --boss-lp N team LP per alive player entering the Final Assault (default 15) // --boss-lp N team LP per alive player entering the Final Assault (default 15)
@@ -54,7 +55,7 @@ import { positionClass } from '../server/match/board.js';
import { pairPlayers, bossPoolHp, SharedBossPool } from '../server/match/finalAssault.js'; import { pairPlayers, bossPoolHp, SharedBossPool } from '../server/match/finalAssault.js';
import { planUnite, uniteBattleOpts, uniteSurvivors } from '../server/match/unite.js'; import { planUnite, uniteBattleOpts, uniteSurvivors } from '../server/match/unite.js';
import { createRng, deriveSeed } from '../server/sim/rng.js'; import { createRng, deriveSeed } from '../server/sim/rng.js';
import { GEO, PHASE } from '../shared/constants.js'; import { GEO, PHASE, layerGainRoom } from '../shared/constants.js';
import { TICK } from '../server/sim/constants.js'; import { TICK } from '../server/sim/constants.js';
export const DIFFS = ['FUNNY', 'NORMAL', 'HARD', 'ABYSS']; export const DIFFS = ['FUNNY', 'NORMAL', 'HARD', 'ABYSS'];
@@ -270,7 +271,7 @@ export function applyBoard(m, ps, plan, r, rng, { profile = 1 } = {}) {
for (const [id, b] of Object.entries(planned)) { for (const [id, b] of Object.entries(planned)) {
if (!b.active) continue; if (!b.active) continue;
const base = id === plan.core ? curve('coreLayers', r) : curve('addonLayers', r); const base = id === plan.core ? curve('coreLayers', r) : curve('addonLayers', r);
const v = Math.round(base * profile * jit()); const v = layerGainRoom(0, Math.round(base * profile * jit())); // ≤ BOND_LAYER_CAP (999) for any --profile
if (v > 0) ps.layers[id] = v; if (v > 0) ps.layers[id] = v;
} }
ps.recompute(); ps.recompute();
@@ -376,8 +377,8 @@ export function runBoss({ data, mode, difficulty, round, seed, profile = 1, boss
} }
} }
const alive = m.alivePlayers(); const alive = m.alivePlayers();
// the official pool (co-op = bloodPoint, no alive scaling) — finalAssault.bossPoolHp until the match adopts it // the official pool (co-op = bloodPoint, × alive / 4 only with aliveScaling, DESIGN §20.10; solo ×0.25) — the match's own rule
const pool = new SharedBossPool(typeof gd.bossPoolHp === 'function' ? gd.bossPoolHp(id) : bossPoolHp(gd, id, alive.length)); const pool = new SharedBossPool(bossPoolHp(gd, id, alive.length));
let teamLp = alive.reduce((s, p) => s + p.lp, 0); let teamLp = alive.reduce((s, p) => s + p.lp, 0);
const teamLp0 = teamLp; const teamLp0 = teamLp;
let leakLp = 0; let leakLp = 0;
+3 -3
View File
@@ -2580,7 +2580,7 @@ function buildConfig(ctx, waves, stages, bands) {
combatTimeLimit, enemyScale, combatTimeLimit, enemyScale,
bossHpScale: type === 'SINGLE' bossHpScale: type === 'SINGLE'
? { bloodPointKey: DIFF_KEYS[m.modeDifficulty] || null, solo: 0.25, soloAssumed: true, unaffectedByEnemyScale: true } ? { bloodPointKey: DIFF_KEYS[m.modeDifficulty] || null, solo: 0.25, soloAssumed: true, unaffectedByEnemyScale: true }
: { bloodPointKey: DIFF_KEYS[m.modeDifficulty] || null, coop: 1, aliveScaling: false, unaffectedByEnemyScale: true }, : { bloodPointKey: DIFF_KEYS[m.modeDifficulty] || null, coop: 1, aliveScaling: false, aliveFull: 4, aliveAssumed: true, unaffectedByEnemyScale: true },
upgradePrices: levels.slice(0, -1).map((l) => shopLv[l].initialUpgradePrice), upgradePrices: levels.slice(0, -1).map((l) => shopLv[l].initialUpgradePrice),
maxShopLevel: levels.length ? levels[levels.length - 1] : 6, maxShopLevel: levels.length ? levels[levels.length - 1] : 6,
shopSlots: Object.fromEntries(levels.map((l) => [l, { chess: shopLv[l].charChessCount, item: shopLv[l].itemCount }])), shopSlots: Object.fromEntries(levels.map((l) => [l, { chess: shopLv[l].charChessCount, item: shopLv[l].itemCount }])),
@@ -2645,8 +2645,8 @@ function buildConfig(ctx, waves, stages, bands) {
lpCapPerRound: act.constData.costPlayerHpLimit ?? 10, lpCapPerRound: act.constData.costPlayerHpLimit ?? 10,
bossOvertimeAfter: 150, bossOvertimeDrainPerSec: 1, bossOvertimeAfter: 150, bossOvertimeDrainPerSec: 1,
bossHpScale: { bossHpScale: {
formula: 'co-op: bloodPoint[difficulty] (one pool shared by the split leaders, no alive-player scaling — "敌方领袖的总生命值不变"); solo: bloodPoint[difficulty] × solo [ASSUMED]', formula: 'co-op: bloodPoint[difficulty] — one pool for every field ("所有人将一起对敌方领袖造成伤害"; the mirrored copies of a pair field share it, "两侧的敌方领袖共享生命值(敌方领袖的总生命值不变)"); aliveScaling true would scale it × alive players at the Final Assault / aliveFull (巴哈姆特 12294 "聯機隊友(撤退/死掉)變少,最後boss血條也會變少", one community note, no proportion: off until confirmed, the alive / 4 proportion [ASSUMED]); solo: bloodPoint[difficulty] × solo (0.25 = one player of four) [ASSUMED]',
coop: 1, solo: 0.25, soloAssumed: true, aliveScaling: false, unaffectedByEnemyScale: true, coop: 1, solo: 0.25, soloAssumed: true, aliveScaling: false, aliveFull: 4, aliveAssumed: true, unaffectedByEnemyScale: true,
}, },
hiddenCore: { single: 350, multi: 1200, minTeamLpExclusive: 1, difficulties: ['NORMAL', 'HARD', 'ABYSS'], checkedAfterRound: 14 }, hiddenCore: { single: 350, multi: 1200, minTeamLpExclusive: 1, difficulties: ['NORMAL', 'HARD', 'ABYSS'], checkedAfterRound: 14 },
dp: { init: 10, perSec: 1, max: 99 }, dp: { init: 10, perSec: 1, max: 99 },
+4 -2
View File
@@ -10,7 +10,8 @@
// --humans N the first N seats are human seats on "AI 托管" (exercises the human views / m.private paths) // --humans N the first N seats are human seats on "AI 托管" (exercises the human views / m.private paths)
// --seeds N run N consecutive seeds and print an aggregate (rounds survived, LP by round, outcomes) // --seeds N run N consecutive seeds and print an aggregate (rounds survived, LP by round, outcomes)
// --lp N override every player's starting LP (reach later rounds / the Final Assault while balancing) // --lp N override every player's starting LP (reach later rounds / the Final Assault while balancing)
// --layers N at the boss round's prep, add N layers to every bond of every player (reach the Hidden Core) // --layers N at the boss round's prep, add N layers to every bond of every player (reach the Hidden Core); a raw
// write (no onLayers milestones) that stops at BOND_LAYER_CAP (999, shared/constants.js) like every gain
// --difficulty ALL sweep all four difficulties // --difficulty ALL sweep all four difficulties
// --content sim content mode (default full) // --content sim content mode (default full)
// --rehearsal N layouts each bot rehearses per prep with the real sim (default: the Match default, 3; 0 = off, // --rehearsal N layouts each bot rehearses per prep with the real sim (default: the Match default, 3; 0 = off,
@@ -35,6 +36,7 @@ import { VirtualScheduler } from '../server/match/scheduler.js';
import { attachAudit } from '../server/match/audit.js'; import { attachAudit } from '../server/match/audit.js';
import { Battle } from '../server/sim/Battle.js'; import { Battle } from '../server/sim/Battle.js';
import { getData } from '../server/data.js'; import { getData } from '../server/data.js';
import { layerGainRoom } from '../shared/constants.js';
const argv = process.argv.slice(2); const argv = process.argv.slice(2);
const opt = {}; const opt = {};
@@ -130,7 +132,7 @@ function runOne(seed, difficulty) {
last = key; last = key;
if (lpOverride && m.phase === 'PREP' && m.round === 1) for (const ps of m.players.values()) ps.lp = lpOverride; if (lpOverride && m.phase === 'PREP' && m.round === 1) for (const ps of m.players.values()) ps.lp = lpOverride;
if (layerBoost && m.phase === 'PREP' && m.round === m.gd.bossRound) { if (layerBoost && m.phase === 'PREP' && m.round === m.gd.bossRound) {
for (const ps of m.alivePlayers()) { for (const id of m.gd.bondIds) ps.layers[id] = (ps.layers[id] || 0) + layerBoost; ps.recompute(); } for (const ps of m.alivePlayers()) { for (const id of m.gd.bondIds) { const before = ps.layers[id] || 0; ps.layers[id] = before + layerGainRoom(before, layerBoost); } ps.recompute(); }
} }
if (m.phase === 'SETTLE' || ((m.phase === 'RESULT') && (m.round === m.gd.bossRound || m.round === m.gd.hiddenRound))) { if (m.phase === 'SETTLE' || ((m.phase === 'RESULT') && (m.round === m.gd.bossRound || m.round === m.gd.hiddenRound))) {
const row = { round: m.round, unite: !!m.unitePlan, players: {} }; const row = { round: m.round, unite: !!m.unitePlan, players: {} };