Playtest #6 fixes: summons placed by hand, boss pool stall, bounties, 联防 leak counter, distinct offers, 机变 card text, phone buy tap, skill triggers, blocking, push force, 孤立, enemies, element bar

- #1 #2 skill / device summons (赫默's drone, 巫恋's doll, 凯瑟琳's devices) are hand cards placed by the player;
  placed summons deploy once at battle start, then a skill summon re-appears at its tile on each cast (the user's call)
- #3 叙拉古 隐匿 ends on time (32 + 0.4 × layers s); 忍冬 S3 迷彩 lasts until her next cast (official), kept apart from 隐匿
- #4 multi-round bounty cards last two battles (the user's call; MULTI_ROUND_BOUNTY_BATTLES), blue / red card text
- #5 the shared boss pool's float dust (≈3.6e-12 HP) stalled Final Assault leaders at 0 %; < 1 HP now clears the pool,
  and the team LP reaching 0 first is a defeat (PRTS)
- #6 #10 机变 cards show their official effect text; an invisible 详情 button no longer swallows the confirming tap on phones
- #7 live ×N counter of a leaker's enemies still on the 联防 field (LP loss stays capped at 10)
- #8 #15 #16 skill triggers per 技能策略: skill-range triggers, injured-ally heals (古米, 塞雷娅), tank take-damage
  triggers with a 3 s cooldown; 新约能天使 S2 shields the highest-aggro operator in range
- #9 official per-enemy model scales (威龙)
- #11 element bar under the unit's HP bar (white = 元素值 left, element-coloured refill after a burst), floor rounding
- #12 枯朽战车 kit per PRTS; every ranged enemy reaches its target's 0.25 collider
- #13 恐惧 wander (萨科塔之翼 at half HP, operator fears make enemies flee) with re-planning after pushes
- #14 push / pull distance by 力度 − 重量 (PRTS table); #17 every blocker attacks what it blocks; ASPD floor 20
- #18 炎佑 is 孤立: no heals or ally buffs
- #19 every free pick-one chess offer (promotion reward, 信标, 寻呼模块) shows different operators
- DESIGN §20 and the normative lines, SIM / META / DATA / PLAYING updated; 2747 unit tests and all 27 browser suites
  green; dev recordings regenerated
This commit is contained in:
sganggs committed 2026-10-01 19:08:23 +08:00
1 parent e6628f0cfe
commit 6464e4b46a
138 files changed
+7946 -1379

No files matched your search

+1 -1
View File
@@ -114,7 +114,7 @@ npm start # http://localhost:3000
## 测试
```bash
node --test # 单元 + 集成测试(约 2600 项;缺少素材 / 浏览器的用例会自动跳过)
node --test # 单元 + 集成测试(约 2750 项;缺少素材 / 浏览器的用例会自动跳过)
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 + 已下载素材
```
+1 -1
View File
@@ -1 +1 @@
{"chess_char_1_01_a":{"chessId":"chess_char_1_01_a","baseId":"chess_char_1_01_a","goldenId":"chess_char_1_01_b","isGolden":false,"tier":1,"identifier":0,"isHidden":false,"isDiy":false,"visible":true,"chessType":"PRESET","shopSortId":1,"charId":"char_498_inside","name":"隐现","appellation":"Insider","rarity":5,"profession":"SNIPER","subProfessionId":"fastshot","subProfessionName":"速射手","position":"RANGED","nationId":"laterano","bonds":["lateranoShip","swiftShip"],"garrisonIds":["garrison_16_a"],"price":2,"sellPrice":1,"upgradeNum":3,"upgradeChessId":"chess_char_1_01_b","status":{"phase":1,"level":55,"skillLevel":4,"equipLevel":0},"stats":{"maxHp":1123,"atk":399,"def":140,"res":0,"cost":15,"blockCnt":1,"bat":1,"aspd":100,"respawnTime":80,"spRecovery":1,"hpRecoveryPerSec":0,"moveSpeed":1,"tauntLevel":0,"massLevel":0,"deployLimit":1,"deckStack":0},"immunities":{"stun":false,"silence":false,"sleep":false,"frozen":false,"levitate":false},"rangeId":"3-3","rangeGrid":[[1,0],[1,1],[1,2],[1,3],[0,0],[0,1],[0,2],[0,3],[-1,0],[-1,1],[-1,2],[-1,3]],"dmgType":"phys","attackKind":"ranged","projectile":"arrow","canHitFly":true,"targetPriority":"fly","trait":{"desc":"优先攻击空中单位","descRaw":"优先攻击空中单位","bb":{},"bbStr":{},"rangeGrid":null},"skill":{"skillId":"skchr_inside_2","iconId":"skchr_inside_2","name":"“解决麻烦”","level":4,"desc":"攻击力+80%,攻击间隔略微缩短,优先攻击使用远程武器的敌人并且不容易成为敌人的攻击目标\n攻击装有14发弹药,打完后结束(可随时停止技能)","descRaw":"攻击力<@ba.vup>+80%</>,攻击间隔<@ba.vup>略微缩短</>,优先攻击使用远程武器的敌人并且不容易成为敌人的攻击目标\n<@ba.rem>攻击装有14发弹药,打完后结束(可随时停止技能)</>","skillType":"MANUAL","durationType":"AMMO","duration":0,"spType":"INCREASE_WITH_TIME","spCost":24,"initSp":8,"maxChargeTime":1,"increment":1,"bb":{"atk":0.8,"base_attack_time":-0.3,"attack@trigger_time":14},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skchr_inside_2","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":1,"overrideTokenKey":null},"talents":[{"index":0,"name":"火力支援","desc":"在场停留20秒后,自身弹药类技能弹药上限+2","descRaw":"在场停留20秒后,自身弹药类技能弹药上限+2","bb":{"self_ammo":2,"duration":20},"bbStr":{},"rangeGrid":null,"tokenKey":null,"hidden":false,"fromModule":false}],"tokens":[],"module":{"id":"uniequip_002_inside","name":"“最初的惊喜”","type":"MAR-X","level":0,"active":false},"assets":{"avatar":"char_498_inside","portrait":"char_498_inside_1","spine":"char_498_inside","skillIcon":"skchr_inside_2","subProfIcon":"sub_fastshot_icon"},"skills":[{"skillId":"skchr_inside_1","iconId":"skchr_inside_1","name":"“不惹麻烦”","level":4,"desc":"每次攻击造成相当于攻击力185%的物理伤害\n攻击装有4发弹药,打完后结束(可随时停止技能)","descRaw":"每次攻击造成相当于攻击力<@ba.vup>185%</>的物理伤害\n<@ba.rem>攻击装有4发弹药,打完后结束(可随时停止技能)</>","skillType":"AUTO","durationType":"AMMO","duration":0,"spType":"INCREASE_WHEN_ATTACK","spCost":14,"initSp":0,"maxChargeTime":1,"increment":1,"bb":{"attack@atk_scale":1.85,"attack@trigger_time":4},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skchr_inside_1","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":0,"overrideTokenKey":null,"isDefault":false},{"skillId":"skchr_inside_2","iconId":"skchr_inside_2","name":"“解决麻烦”","level":4,"desc":"攻击力+80%,攻击间隔略微缩短,优先攻击使用远程武器的敌人并且不容易成为敌人的攻击目标\n攻击装有14发弹药,打完后结束(可随时停止技能)","descRaw":"攻击力<@ba.vup>+80%</>,攻击间隔<@ba.vup>略微缩短</>,优先攻击使用远程武器的敌人并且不容易成为敌人的攻击目标\n<@ba.rem>攻击装有14发弹药,打完后结束(可随时停止技能)</>","skillType":"MANUAL","durationType":"AMMO","duration":0,"spType":"INCREASE_WITH_TIME","spCost":24,"initSp":8,"maxChargeTime":1,"increment":1,"bb":{"atk":0.8,"base_attack_time":-0.3,"attack@trigger_time":14},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skchr_inside_2","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":1,"overrideTokenKey":null,"isDefault":true}]},"chess_char_1_01_b":{"chessId":"chess_char_1_01_b","baseId":"chess_char_1_01_a","goldenId":"chess_char_1_01_b","isGolden":true,"tier":1,"identifier":133,"isHidden":false,"isDiy":false,"visible":true,"chessType":"PRESET","shopSortId":1,"charId":"char_498_inside","name":"隐现","appellation":"Insider","rarity":5,"profession":"SNIPER","subProfessionId":"fastshot","subProfessionName":"速射手","position":"RANGED","nationId":"laterano","bonds":["lateranoShip","swiftShip"],"garriLine truncated
{"chess_char_1_01_a":{"chessId":"chess_char_1_01_a","baseId":"chess_char_1_01_a","goldenId":"chess_char_1_01_b","isGolden":false,"tier":1,"identifier":0,"isHidden":false,"isDiy":false,"visible":true,"chessType":"PRESET","shopSortId":1,"charId":"char_498_inside","name":"隐现","appellation":"Insider","rarity":5,"profession":"SNIPER","subProfessionId":"fastshot","subProfessionName":"速射手","position":"RANGED","nationId":"laterano","bonds":["lateranoShip","swiftShip"],"garrisonIds":["garrison_16_a"],"price":2,"sellPrice":1,"upgradeNum":3,"upgradeChessId":"chess_char_1_01_b","status":{"phase":1,"level":55,"skillLevel":4,"equipLevel":0},"stats":{"maxHp":1123,"atk":399,"def":140,"res":0,"cost":15,"blockCnt":1,"bat":1,"aspd":100,"respawnTime":80,"spRecovery":1,"hpRecoveryPerSec":0,"moveSpeed":1,"tauntLevel":0,"massLevel":0,"deployLimit":1,"deckStack":0},"immunities":{"stun":false,"silence":false,"sleep":false,"frozen":false,"levitate":false},"rangeId":"3-3","rangeGrid":[[1,0],[1,1],[1,2],[1,3],[0,0],[0,1],[0,2],[0,3],[-1,0],[-1,1],[-1,2],[-1,3]],"dmgType":"phys","attackKind":"ranged","projectile":"arrow","canHitFly":true,"targetPriority":"fly","trait":{"desc":"优先攻击空中单位","descRaw":"优先攻击空中单位","bb":{},"bbStr":{},"rangeGrid":null},"skill":{"skillId":"skchr_inside_2","iconId":"skchr_inside_2","name":"“解决麻烦”","level":4,"desc":"攻击力+80%,攻击间隔略微缩短,优先攻击使用远程武器的敌人并且不容易成为敌人的攻击目标\n攻击装有14发弹药,打完后结束(可随时停止技能)","descRaw":"攻击力<@ba.vup>+80%</>,攻击间隔<@ba.vup>略微缩短</>,优先攻击使用远程武器的敌人并且不容易成为敌人的攻击目标\n<@ba.rem>攻击装有14发弹药,打完后结束(可随时停止技能)</>","skillType":"MANUAL","durationType":"AMMO","duration":0,"spType":"INCREASE_WITH_TIME","spCost":24,"initSp":8,"maxChargeTime":1,"increment":1,"bb":{"atk":0.8,"base_attack_time":-0.3,"attack@trigger_time":14},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skchr_inside_2","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":1,"overrideTokenKey":null},"talents":[{"index":0,"name":"火力支援","desc":"在场停留20秒后,自身弹药类技能弹药上限+2","descRaw":"在场停留20秒后,自身弹药类技能弹药上限+2","bb":{"self_ammo":2,"duration":20},"bbStr":{},"rangeGrid":null,"tokenKey":null,"hidden":false,"fromModule":false}],"tokens":[],"module":{"id":"uniequip_002_inside","name":"“最初的惊喜”","type":"MAR-X","level":0,"active":false},"assets":{"avatar":"char_498_inside","portrait":"char_498_inside_1","spine":"char_498_inside","skillIcon":"skchr_inside_2","subProfIcon":"sub_fastshot_icon"},"skills":[{"skillId":"skchr_inside_1","iconId":"skchr_inside_1","name":"“不惹麻烦”","level":4,"desc":"每次攻击造成相当于攻击力185%的物理伤害\n攻击装有4发弹药,打完后结束(可随时停止技能)","descRaw":"每次攻击造成相当于攻击力<@ba.vup>185%</>的物理伤害\n<@ba.rem>攻击装有4发弹药,打完后结束(可随时停止技能)</>","skillType":"AUTO","durationType":"AMMO","duration":0,"spType":"INCREASE_WHEN_ATTACK","spCost":14,"initSp":0,"maxChargeTime":1,"increment":1,"bb":{"attack@atk_scale":1.85,"attack@trigger_time":4},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skchr_inside_1","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":0,"overrideTokenKey":null,"isDefault":false},{"skillId":"skchr_inside_2","iconId":"skchr_inside_2","name":"“解决麻烦”","level":4,"desc":"攻击力+80%,攻击间隔略微缩短,优先攻击使用远程武器的敌人并且不容易成为敌人的攻击目标\n攻击装有14发弹药,打完后结束(可随时停止技能)","descRaw":"攻击力<@ba.vup>+80%</>,攻击间隔<@ba.vup>略微缩短</>,优先攻击使用远程武器的敌人并且不容易成为敌人的攻击目标\n<@ba.rem>攻击装有14发弹药,打完后结束(可随时停止技能)</>","skillType":"MANUAL","durationType":"AMMO","duration":0,"spType":"INCREASE_WITH_TIME","spCost":24,"initSp":8,"maxChargeTime":1,"increment":1,"bb":{"atk":0.8,"base_attack_time":-0.3,"attack@trigger_time":14},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skchr_inside_2","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":1,"overrideTokenKey":null,"isDefault":true}]},"chess_char_1_01_b":{"chessId":"chess_char_1_01_b","baseId":"chess_char_1_01_a","goldenId":"chess_char_1_01_b","isGolden":true,"tier":1,"identifier":133,"isHidden":false,"isDiy":false,"visible":true,"chessType":"PRESET","shopSortId":1,"charId":"char_498_inside","name":"隐现","appellation":"Insider","rarity":5,"profession":"SNIPER","subProfessionId":"fastshot","subProfessionName":"速射手","position":"RANGED","nationId":"laterano","bonds":["lateranoShip","swiftShip"],"garriLine truncated
+1 -1
View File
@@ -1 +1 @@
{"events":{"artifact_paid_1":{"id":"artifact_paid_1","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#35d8b4","family":"shop","solo":false,"training":false},"artifact_paid_1_s":{"id":"artifact_paid_1_s","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#f2bd3e","family":"shop","solo":true,"training":false},"artifact_paid_2":{"id":"artifact_paid_2","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#35d8b4","family":"shop","solo":false,"training":false},"artifact_paid_2_s":{"id":"artifact_paid_2_s","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#f2bd3e","family":"shop","solo":true,"training":false},"artifact_paid_3":{"id":"artifact_paid_3","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#35d8b4","family":"shop","solo":false,"training":false},"artifact_paid_3_s":{"id":"artifact_paid_3_s","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#f2bd3e","family":"shop","solo":true,"training":false},"artifact_paid_4":{"id":"artifact_paid_4","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#35d8b4","family":"shop","solo":false,"training":false},"artifact_paid_4_s":{"id":"artifact_paid_4_s","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#f2bd3e","family":"shop","solo":true,"training":false},"artifact_paid_5":{"id":"artifact_paid_5","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#35d8b4","family":"shop","solo":false,"training":false},"artifact_paid_5_s":{"id":"artifact_paid_5_s","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#f2bd3e","family":"shop","solo":true,"training":false},"bossInitial_1":{"id":"bossInitial_1","choiceType":"BOUNTY_HUNT","effectType":"ENEMY_GAIN","name":"悬赏决策","desc":"选定悬赏目标,获取额外奖励。","descRaw":"选定悬赏目标,获取额外奖励。","color":"#35d8b4","family":"bounty","solo":false,"training":false},"bossInitial_1_s":{"id":"bossInitial_1_s","choiceType":"BOUNTY_HUNT","effectType":"ENEMY_GAIN","name":"悬赏决策","desc":"选定悬赏目标,获取额外奖励。","descRaw":"选定悬赏目标,获取额外奖励。","color":"#35d8b4","family":"bounty","solo":true,"training":false},"bossInitial_2":{"id":"bossInitial_2","choiceType":"BOUNTY_HUNT","effectType":"ENEMY_GAIN","name":"悬赏决策","desc":"选定悬赏目标,获取额外奖励。","descRaw":"选定悬赏目标,获取额外奖励。","color":"#35d8b4","family":"bounty","solo":false,"training":false},"bossInitial_2_s":{"id":"bossInitial_2_s","choiceType":"BOUNTY_HUNT","effectType":"ENEMY_GAIN","name":"悬赏决策","desc":"选定悬赏目标,获取额外奖励。","descRaw":"选定悬赏目标,获取额外奖励。","color":"#35d8b4","family":"bounty","solo":true,"training":false},"bossInitial_3":{"id":"bossInitial_3","choiceType":"BOUNTY_HUNT","effectType":"ENEMY_GAIN","name":"悬赏决策","desc":"选定悬赏目标,获取额外奖励。","descRaw":"选定悬赏目标,获取额外奖励。","color":"#35d8b4","family":"bounty","solo":false,"training":false},"bossInitial_3_s":{"id":"bossInitial_3_s","choiceType":"BOUNTY_HUNT","effectType":"ENEMY_GAIN","name":"悬赏决策","desc":"选定悬赏目标,获取额外奖励。","descRaw":"选定悬赏目标,获取额外奖励。","color":"#35d8b4","family":"bounty","solo":true,"training":false},"bossInitial_4":{"id":"bossInitial_4","choiceType":"BOUNTY_HUNT","effectType":"ENEMY_GAIN","name":"悬赏决策","desc":"选定悬赏目标,获取额外奖励。","descRaw":"选定悬赏目标,获取额外奖励。","color":"#35d8b4","family":"bounty","solo":false,"training":false},"bossInitial_4_s":{"id":"bossInitial_4_s","choiceType":"BOUNTY_HUNT","effectType":"ENEMY_GAIN","name":"悬赏决策","desc":"Line truncated
{"events":{"artifact_paid_1":{"id":"artifact_paid_1","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#35d8b4","family":"shop","solo":false,"training":false},"artifact_paid_1_s":{"id":"artifact_paid_1_s","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#f2bd3e","family":"shop","solo":true,"training":false},"artifact_paid_2":{"id":"artifact_paid_2","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#35d8b4","family":"shop","solo":false,"training":false},"artifact_paid_2_s":{"id":"artifact_paid_2_s","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#f2bd3e","family":"shop","solo":true,"training":false},"artifact_paid_3":{"id":"artifact_paid_3","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#35d8b4","family":"shop","solo":false,"training":false},"artifact_paid_3_s":{"id":"artifact_paid_3_s","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#f2bd3e","family":"shop","solo":true,"training":false},"artifact_paid_4":{"id":"artifact_paid_4","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#35d8b4","family":"shop","solo":false,"training":false},"artifact_paid_4_s":{"id":"artifact_paid_4_s","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#f2bd3e","family":"shop","solo":true,"training":false},"artifact_paid_5":{"id":"artifact_paid_5","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#35d8b4","family":"shop","solo":false,"training":false},"artifact_paid_5_s":{"id":"artifact_paid_5_s","choiceType":"EQUIP_FREE","effectType":"EQUIP","name":"机密商店","desc":"无需消耗资金,获得装备补给。","descRaw":"无需消耗资金,获得装备补给。","color":"#f2bd3e","family":"shop","solo":true,"training":false},"bossInitial_1":{"id":"bossInitial_1","choiceType":"BOUNTY_HUNT","effectType":"ENEMY_GAIN","name":"悬赏决策","desc":"选定悬赏目标,获取额外奖励。","descRaw":"选定悬赏目标,获取额外奖励。","color":"#35d8b4","family":"bounty","solo":false,"training":false},"bossInitial_1_s":{"id":"bossInitial_1_s","choiceType":"BOUNTY_HUNT","effectType":"ENEMY_GAIN","name":"悬赏决策","desc":"选定悬赏目标,获取额外奖励。","descRaw":"选定悬赏目标,获取额外奖励。","color":"#35d8b4","family":"bounty","solo":true,"training":false},"bossInitial_2":{"id":"bossInitial_2","choiceType":"BOUNTY_HUNT","effectType":"ENEMY_GAIN","name":"悬赏决策","desc":"选定悬赏目标,获取额外奖励。","descRaw":"选定悬赏目标,获取额外奖励。","color":"#35d8b4","family":"bounty","solo":false,"training":false},"bossInitial_2_s":{"id":"bossInitial_2_s","choiceType":"BOUNTY_HUNT","effectType":"ENEMY_GAIN","name":"悬赏决策","desc":"选定悬赏目标,获取额外奖励。","descRaw":"选定悬赏目标,获取额外奖励。","color":"#35d8b4","family":"bounty","solo":true,"training":false},"bossInitial_3":{"id":"bossInitial_3","choiceType":"BOUNTY_HUNT","effectType":"ENEMY_GAIN","name":"悬赏决策","desc":"选定悬赏目标,获取额外奖励。","descRaw":"选定悬赏目标,获取额外奖励。","color":"#35d8b4","family":"bounty","solo":false,"training":false},"bossInitial_3_s":{"id":"bossInitial_3_s","choiceType":"BOUNTY_HUNT","effectType":"ENEMY_GAIN","name":"悬赏决策","desc":"选定悬赏目标,获取额外奖励。","descRaw":"选定悬赏目标,获取额外奖励。","color":"#35d8b4","family":"bounty","solo":true,"training":false},"bossInitial_4":{"id":"bossInitial_4","choiceType":"BOUNTY_HUNT","effectType":"ENEMY_GAIN","name":"悬赏决策","desc":"选定悬赏目标,获取额外奖励。","descRaw":"选定悬赏目标,获取额外奖励。","color":"#35d8b4","family":"bounty","solo":false,"training":false},"bossInitial_4_s":{"id":"bossInitial_4_s","choiceType":"BOUNTY_HUNT","effectType":"ENEMY_GAIN","name":"悬赏决策","desc":"Line truncated
+1 -1
View File
@@ -1 +1 @@
{"enemy_1000_gopro_2":{"key":"enemy_1000_gopro_2","name":"猎狗pro","level":0,"rank":"NORMAL","handbookIndex":"O2","desc":"比一般猎狗更具有作战能力,行动速度很快。","descRaw":"比一般猎狗更具有作战能力,<@eb.key>行动速度很快</>。","applyWay":"MELEE","stats":{"maxHp":1700,"atk":260,"def":0,"res":20,"moveSpeed":1.9,"bat":1.4,"aspd":100,"rangeRadius":0,"rawRangeRadius":0,"blockCnt":1,"massLevel":0,"lpr":1,"hpRecoveryPerSec":0,"elementRes":0,"elementDmgRes":0,"hitRatePhys":0,"hitRateArts":0,"dmgType":"phys","dmgTypes":["phys"],"motion":"WALK","immunities":{"stun":false,"silence":false,"sleep":false,"frozen":false,"levitate":false},"otherImmunities":[],"tauntLevel":0},"abilities":[],"talents":{"bb":{},"bbStr":{}},"skills":[],"notCountInTotal":false,"tags":["infection"],"be":3060,"beFactor":1,"attrPower":3060,"isFlyEnemy":false,"tokenOnly":false,"acTypes":["SPECIAL"],"acType":"SPECIAL","templateSlot":"T","summons":[],"inactiveIn":[],"seasonOverride":null,"iconId":"enemy_1000_gopro_2","spine":"enemy_1000_gopro_2"},"enemy_1001_bigbo":{"key":"enemy_1001_bigbo","name":"大鲍勃","level":0,"rank":"BOSS","handbookIndex":"11","desc":"整合运动的特殊近战人员,使用电锯进行攻击,兼具高耐久、攻击力和防御力。","descRaw":"整合运动的特殊近战人员,使用电锯进行攻击,兼具<@eb.danger>高耐久、攻击力和防御力</>。","applyWay":"MELEE","stats":{"maxHp":22000,"atk":1900,"def":800,"res":30,"moveSpeed":0.5,"bat":6,"aspd":100,"rangeRadius":0,"rawRangeRadius":0,"blockCnt":1,"massLevel":4,"lpr":1,"hpRecoveryPerSec":0,"elementRes":0,"elementDmgRes":0,"hitRatePhys":0,"hitRateArts":0,"dmgType":"phys","dmgTypes":["phys"],"motion":"WALK","immunities":{"stun":false,"silence":false,"sleep":false,"frozen":false,"levitate":false},"otherImmunities":[],"tauntLevel":0},"abilities":[],"talents":{"bb":{},"bbStr":{}},"skills":[],"notCountInTotal":false,"tags":[],"be":33990,"beFactor":1,"attrPower":33990,"isFlyEnemy":false,"tokenOnly":false,"acTypes":[],"acType":null,"templateSlot":null,"summons":[],"inactiveIn":[],"seasonOverride":null,"iconId":"enemy_1001_bigbo","spine":"enemy_1001_bigbo"},"enemy_1005_yokai":{"key":"enemy_1005_yokai","name":"妖怪","level":0,"rank":"NORMAL","handbookIndex":"D1","desc":"【飞行单位】敌方人员操纵的无人机,不会进行攻击。","descRaw":"<@eb.key>【飞行单位】</>敌方人员操纵的无人机,不会进行攻击。","applyWay":"NONE","stats":{"maxHp":800,"atk":0,"def":50,"res":0,"moveSpeed":0.9,"bat":2.3,"aspd":100,"rangeRadius":0,"rawRangeRadius":0,"blockCnt":1,"massLevel":0,"lpr":1,"hpRecoveryPerSec":0,"elementRes":0,"elementDmgRes":0,"hitRatePhys":0,"hitRateArts":0,"dmgType":"none","dmgTypes":["none"],"motion":"FLY","immunities":{"stun":false,"silence":false,"sleep":false,"frozen":false,"levitate":false},"otherImmunities":[],"tauntLevel":0},"abilities":[{"text":"飞行单位","textRaw":"飞行单位","format":"NORMAL"}],"talents":{"bb":{},"bbStr":{}},"skills":[],"notCountInTotal":false,"tags":["drone"],"be":950,"beFactor":1,"attrPower":950,"isFlyEnemy":true,"tokenOnly":false,"acTypes":["FLY"],"acType":"FLY","templateSlot":"NF","summons":[],"inactiveIn":[],"seasonOverride":null,"iconId":"enemy_1005_yokai","spine":"enemy_1005_yokai"},"enemy_1005_yokai_2":{"key":"enemy_1005_yokai_2","name":"妖怪MKII","level":0,"rank":"NORMAL","handbookIndex":"D2","desc":"【飞行单位】妖怪的改进版,可进行远程物理射击。","descRaw":"<@eb.key>【飞行单位】</>妖怪的改进版,可进行<@eb.key>远程物理射击</>。","applyWay":"RANGED","stats":{"maxHp":1550,"atk":220,"def":50,"res":0,"moveSpeed":0.9,"bat":3,"aspd":100,"rangeRadius":2,"rawRangeRadius":2,"blockCnt":1,"massLevel":0,"lpr":1,"hpRecoveryPerSec":0,"elementRes":0,"elementDmgRes":0,"hitRatePhys":0,"hitRateArts":0,"dmgType":"phys","dmgTypes":["phys"],"motion":"FLY","immunities":{"stun":false,"silence":false,"sleep":false,"frozen":false,"levitate":false},"otherImmunities":[],"tauntLevel":0},"abilities":[{"text":"飞行单位","textRaw":"飞行单位","format":"NORMAL"}],"talents":{"bb":{},"bbStr":{}},"skills":[],"notCountInTotal":false,"tags":["drone"],"be":4000,"beFactor":0.7,"attrPower":2800,"isFlyEnemy":true,"tokenOnly":false,"acTypes":["FLY"],"acType":"FLY","templateSlot":null,"summons":[],"inactiveIn":[],"seasonOverride":null,"iconId":"enemy_1005_yokai_2","spine":"enemy_1005_yokai_2"},"enemy_1005_yokai_3":{"key":"enemy_1005_yokai_3","name":"威龙","level":0,"rank":"ELITE","handbookIndex":"D3","desc":"【飞行单位】大型无人机,拥有更好的耐久性能,可进行远程物理射击。","descRaw":"<@eb.key>【飞行单位】</>大型无人机,拥有更好的耐久性能,可进行<@eb.key>远程物理射击</>。","applyWay":"RANGED","stats":{"maxHp":30000,"atk":660,"def":170,"res":0,"moveSpeed":0.5,"bat":3.8,"aspd":100,"rangeRadius":2,"rawRangeRadius":2,"blockCnt":1,"massLevel":0,"lpr":1,"hpRecoveryPerLine truncated
{"enemy_1000_gopro_2":{"key":"enemy_1000_gopro_2","name":"猎狗pro","level":0,"rank":"NORMAL","handbookIndex":"O2","desc":"比一般猎狗更具有作战能力,行动速度很快。","descRaw":"比一般猎狗更具有作战能力,<@eb.key>行动速度很快</>。","applyWay":"MELEE","stats":{"maxHp":1700,"atk":260,"def":0,"res":20,"moveSpeed":1.9,"bat":1.4,"aspd":100,"rangeRadius":0,"rawRangeRadius":0,"blockCnt":1,"massLevel":0,"lpr":1,"hpRecoveryPerSec":0,"elementRes":0,"elementDmgRes":0,"hitRatePhys":0,"hitRateArts":0,"dmgType":"phys","dmgTypes":["phys"],"motion":"WALK","immunities":{"stun":false,"silence":false,"sleep":false,"frozen":false,"levitate":false},"otherImmunities":[],"tauntLevel":0},"abilities":[],"talents":{"bb":{},"bbStr":{}},"skills":[],"notCountInTotal":false,"tags":["infection"],"be":3060,"beFactor":1,"attrPower":3060,"isFlyEnemy":false,"tokenOnly":false,"acTypes":["SPECIAL"],"acType":"SPECIAL","templateSlot":"T","summons":[],"inactiveIn":[],"seasonOverride":null,"iconId":"enemy_1000_gopro_2","spine":"enemy_1000_gopro_2","modelScale":0.963},"enemy_1001_bigbo":{"key":"enemy_1001_bigbo","name":"大鲍勃","level":0,"rank":"BOSS","handbookIndex":"11","desc":"整合运动的特殊近战人员,使用电锯进行攻击,兼具高耐久、攻击力和防御力。","descRaw":"整合运动的特殊近战人员,使用电锯进行攻击,兼具<@eb.danger>高耐久、攻击力和防御力</>。","applyWay":"MELEE","stats":{"maxHp":22000,"atk":1900,"def":800,"res":30,"moveSpeed":0.5,"bat":6,"aspd":100,"rangeRadius":0,"rawRangeRadius":0,"blockCnt":1,"massLevel":4,"lpr":1,"hpRecoveryPerSec":0,"elementRes":0,"elementDmgRes":0,"hitRatePhys":0,"hitRateArts":0,"dmgType":"phys","dmgTypes":["phys"],"motion":"WALK","immunities":{"stun":false,"silence":false,"sleep":false,"frozen":false,"levitate":false},"otherImmunities":[],"tauntLevel":0},"abilities":[],"talents":{"bb":{},"bbStr":{}},"skills":[],"notCountInTotal":false,"tags":[],"be":33990,"beFactor":1,"attrPower":33990,"isFlyEnemy":false,"tokenOnly":false,"acTypes":[],"acType":null,"templateSlot":null,"summons":[],"inactiveIn":[],"seasonOverride":null,"iconId":"enemy_1001_bigbo","spine":"enemy_1001_bigbo","modelScale":1.1111},"enemy_1005_yokai":{"key":"enemy_1005_yokai","name":"妖怪","level":0,"rank":"NORMAL","handbookIndex":"D1","desc":"【飞行单位】敌方人员操纵的无人机,不会进行攻击。","descRaw":"<@eb.key>【飞行单位】</>敌方人员操纵的无人机,不会进行攻击。","applyWay":"NONE","stats":{"maxHp":800,"atk":0,"def":50,"res":0,"moveSpeed":0.9,"bat":2.3,"aspd":100,"rangeRadius":0,"rawRangeRadius":0,"blockCnt":1,"massLevel":0,"lpr":1,"hpRecoveryPerSec":0,"elementRes":0,"elementDmgRes":0,"hitRatePhys":0,"hitRateArts":0,"dmgType":"none","dmgTypes":["none"],"motion":"FLY","immunities":{"stun":false,"silence":false,"sleep":false,"frozen":false,"levitate":false},"otherImmunities":[],"tauntLevel":0},"abilities":[{"text":"飞行单位","textRaw":"飞行单位","format":"NORMAL"}],"talents":{"bb":{},"bbStr":{}},"skills":[],"notCountInTotal":false,"tags":["drone"],"be":950,"beFactor":1,"attrPower":950,"isFlyEnemy":true,"tokenOnly":false,"acTypes":["FLY"],"acType":"FLY","templateSlot":"NF","summons":[],"inactiveIn":[],"seasonOverride":null,"iconId":"enemy_1005_yokai","spine":"enemy_1005_yokai","modelScale":0.7407},"enemy_1005_yokai_2":{"key":"enemy_1005_yokai_2","name":"妖怪MKII","level":0,"rank":"NORMAL","handbookIndex":"D2","desc":"【飞行单位】妖怪的改进版,可进行远程物理射击。","descRaw":"<@eb.key>【飞行单位】</>妖怪的改进版,可进行<@eb.key>远程物理射击</>。","applyWay":"RANGED","stats":{"maxHp":1550,"atk":220,"def":50,"res":0,"moveSpeed":0.9,"bat":3,"aspd":100,"rangeRadius":2,"rawRangeRadius":2,"blockCnt":1,"massLevel":0,"lpr":1,"hpRecoveryPerSec":0,"elementRes":0,"elementDmgRes":0,"hitRatePhys":0,"hitRateArts":0,"dmgType":"phys","dmgTypes":["phys"],"motion":"FLY","immunities":{"stun":false,"silence":false,"sleep":false,"frozen":false,"levitate":false},"otherImmunities":[],"tauntLevel":0},"abilities":[{"text":"飞行单位","textRaw":"飞行单位","format":"NORMAL"}],"talents":{"bb":{},"bbStr":{}},"skills":[],"notCountInTotal":false,"tags":["drone"],"be":4000,"beFactor":0.7,"attrPower":2800,"isFlyEnemy":true,"tokenOnly":false,"acTypes":["FLY"],"acType":"FLY","templateSlot":null,"summons":[],"inactiveIn":[],"seasonOverride":null,"iconId":"enemy_1005_yokai_2","spine":"enemy_1005_yokai_2","modelScale":0.8148},"enemy_1005_yokai_3":{"key":"enemy_1005_yokai_3","name":"威龙","level":0,"rank":"ELITE","handbookIndex":"D3","desc":"【飞行单位】大型无人机,拥有更好的耐久性能,可进行远程物理射击。","descRaw":"<@eb.key>【飞行单位】</>大型无人机,拥有更好的耐久性能,可进行<@eb.key>远程物理射击</>。","applyWay":"RANGED","stats":{"maxHp":30000,"atk":660,"def":170,"res":0,"moveSpeed":0.5,"bat":3.8,"aspd":100,"rangLine truncated
+1 -1
View File
@@ -1 +1 @@
{"token_10000_silent_healrb":{"tokenId":"token_10000_silent_healrb","kind":"summon","name":"医疗探机","appellation":"Medical Drone","desc":"不会受到攻击,恢复周围友方单位的生命","descRaw":"不会受到攻击,恢复周围友方单位的生命","profession":"TOKEN","subProfessionId":"notchar1","position":"ALL","displayType":"DEFAULT","placeable":false,"owners":["chess_char_2_02_a","chess_char_2_02_b"],"stats":{"maxHp":1000,"atk":85,"def":0,"res":0,"cost":5,"blockCnt":0,"bat":0.5,"aspd":100,"respawnTime":5,"spRecovery":1,"hpRecoveryPerSec":0,"moveSpeed":1,"tauntLevel":0,"massLevel":0,"deployLimit":1,"deckStack":0},"rangeGrid":[[1,-1],[1,0],[1,1],[0,-1],[0,0],[0,1],[-1,-1],[-1,0],[-1,1]],"dmgType":"heal","attackKind":"heal","projectile":"orb","canHitFly":false,"skill":{"skillId":"skcom_withdraw","bb":{}},"deployLimit":1,"count":1,"variants":{"chess_char_2_02_a":{"phase":1,"level":60,"stats":{"maxHp":1000,"atk":85,"def":0,"res":0,"cost":5,"blockCnt":0,"bat":0.5,"aspd":100,"respawnTime":5,"spRecovery":1,"hpRecoveryPerSec":0,"moveSpeed":1,"tauntLevel":0,"massLevel":0,"deployLimit":1,"deckStack":0},"immunities":{"stun":false,"silence":false,"sleep":false,"frozen":false,"levitate":false},"rangeGrid":[[1,-1],[1,0],[1,1],[0,-1],[0,0],[0,1],[-1,-1],[-1,0],[-1,1]],"trait":{"desc":"不会受到攻击,恢复周围友方单位的生命","descRaw":"不会受到攻击,恢复周围友方单位的生命","bb":{},"bbStr":{}},"dmgType":"heal","attackKind":"heal","projectile":"orb","canHitFly":false,"targetPriority":null,"skill":{"skillId":"skcom_withdraw","iconId":"skcom_withdraw","name":"自我销毁","level":1,"desc":"10秒后自动销毁","descRaw":"<@ba.vup>10</>秒后自动销毁","skillType":"PASSIVE","durationType":"NONE","duration":10,"spType":"ON_DEPLOY","spCost":0,"initSp":0,"maxChargeTime":0,"increment":0,"bb":{},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skcom_withdraw","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":1},"talents":[],"count":1,"sources":["skill","display"],"bySkill":{"0":{"skill":{"skillId":"skcom_withdraw","iconId":"skcom_withdraw","name":"自我销毁","level":1,"desc":"10秒后自动销毁","descRaw":"<@ba.vup>10</>秒后自动销毁","skillType":"PASSIVE","durationType":"NONE","duration":10,"spType":"ON_DEPLOY","spCost":0,"initSp":0,"maxChargeTime":0,"increment":0,"bb":{},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skcom_withdraw","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":0},"count":null,"sources":["display"]}}},"chess_char_2_02_b":{"phase":2,"level":55,"stats":{"maxHp":1000,"atk":114,"def":0,"res":0,"cost":5,"blockCnt":0,"bat":0.5,"aspd":100,"respawnTime":5,"spRecovery":1,"hpRecoveryPerSec":0,"moveSpeed":1,"tauntLevel":0,"massLevel":0,"deployLimit":1,"deckStack":0},"immunities":{"stun":false,"silence":false,"sleep":false,"frozen":false,"levitate":false},"rangeGrid":[[1,-1],[1,0],[1,1],[0,-1],[0,0],[0,1],[-1,-1],[-1,0],[-1,1]],"trait":{"desc":"不会受到攻击,恢复周围友方单位的生命","descRaw":"不会受到攻击,恢复周围友方单位的生命","bb":{},"bbStr":{}},"dmgType":"heal","attackKind":"heal","projectile":"orb","canHitFly":false,"targetPriority":null,"skill":{"skillId":"skcom_withdraw","iconId":"skcom_withdraw","name":"自我销毁","level":1,"desc":"10秒后自动销毁","descRaw":"<@ba.vup>10</>秒后自动销毁","skillType":"PASSIVE","durationType":"NONE","duration":10,"spType":"ON_DEPLOY","spCost":0,"initSp":0,"maxChargeTime":0,"increment":0,"bb":{},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skcom_withdraw","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":1},"talents":[],"count":1,"sources":["skill","display"],"bySkill":{"0":{"skill":{"skillId":"skcom_withdraw","iconId":"skcom_withdraw","name":"自我销毁","level":1,"desc":"10秒后自动销毁","descRaw":"<@ba.vup>10</>秒后自动销毁","skillType":"PASSIVE","durationType":"NONE","duration":10,"spType":"ON_DEPLOY","spCost":0,"initSp":0,"maxChargeTime":0,"increment":0,"bb":{},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skcom_withdraw","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":0},"count":null,"sources":["display"]}},"byModule":{"none":{"stats":{"maxHp":1000,"atk":114,"def":0,"res":0,"cost":5,"blockCnt":0,"bat":0.5,"aspd":100,"respawnTime":5,"spRecovery":1,"hpRecoveryPerSec":0,"moveSpeed":1,"tauntLevel":0,"massLevel":0,"deployLimit":1,"deckStack":0},"immunities":{"stun":false,"silence":false,"sleep":false,"frozen":false,"levitate":false},"trait":{"desc":"不会受到攻击,恢复周围友方单位的生命","descRaw":"不会受到攻击,恢复周围友方单位的生命","bb":{},"bbStr":{}},"talents":[]}}}},"assets":{"avatar":"token_10000_silent_healrb","spine":"token_10000_silent_healrb"}},"token_10006_vodfox_doll":{"tokenId":"token_10006_vodfox_doll","kind":"summon","name":"诅咒娃娃","appellatLine truncated
{"token_10000_silent_healrb":{"tokenId":"token_10000_silent_healrb","kind":"summon","name":"医疗探机","appellation":"Medical Drone","desc":"不会受到攻击,恢复周围友方单位的生命","descRaw":"不会受到攻击,恢复周围友方单位的生命","profession":"TOKEN","subProfessionId":"notchar1","position":"ALL","displayType":"DEFAULT","placeable":true,"owners":["chess_char_2_02_a","chess_char_2_02_b"],"stats":{"maxHp":1000,"atk":85,"def":0,"res":0,"cost":5,"blockCnt":0,"bat":0.5,"aspd":100,"respawnTime":5,"spRecovery":1,"hpRecoveryPerSec":0,"moveSpeed":1,"tauntLevel":0,"massLevel":0,"deployLimit":1,"deckStack":0},"rangeGrid":[[1,-1],[1,0],[1,1],[0,-1],[0,0],[0,1],[-1,-1],[-1,0],[-1,1]],"dmgType":"heal","attackKind":"heal","projectile":"orb","canHitFly":false,"skill":{"skillId":"skcom_withdraw","bb":{}},"deployLimit":1,"count":1,"abnormal":[],"variants":{"chess_char_2_02_a":{"phase":1,"level":60,"stats":{"maxHp":1000,"atk":85,"def":0,"res":0,"cost":5,"blockCnt":0,"bat":0.5,"aspd":100,"respawnTime":5,"spRecovery":1,"hpRecoveryPerSec":0,"moveSpeed":1,"tauntLevel":0,"massLevel":0,"deployLimit":1,"deckStack":0},"immunities":{"stun":false,"silence":false,"sleep":false,"frozen":false,"levitate":false},"rangeGrid":[[1,-1],[1,0],[1,1],[0,-1],[0,0],[0,1],[-1,-1],[-1,0],[-1,1]],"trait":{"desc":"不会受到攻击,恢复周围友方单位的生命","descRaw":"不会受到攻击,恢复周围友方单位的生命","bb":{},"bbStr":{}},"dmgType":"heal","attackKind":"heal","projectile":"orb","canHitFly":false,"targetPriority":null,"skill":{"skillId":"skcom_withdraw","iconId":"skcom_withdraw","name":"自我销毁","level":1,"desc":"10秒后自动销毁","descRaw":"<@ba.vup>10</>秒后自动销毁","skillType":"PASSIVE","durationType":"NONE","duration":10,"spType":"ON_DEPLOY","spCost":0,"initSp":0,"maxChargeTime":0,"increment":0,"bb":{},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skcom_withdraw","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":1},"talents":[],"count":1,"sources":["skill","display"],"bySkill":{"0":{"skill":{"skillId":"skcom_withdraw","iconId":"skcom_withdraw","name":"自我销毁","level":1,"desc":"10秒后自动销毁","descRaw":"<@ba.vup>10</>秒后自动销毁","skillType":"PASSIVE","durationType":"NONE","duration":10,"spType":"ON_DEPLOY","spCost":0,"initSp":0,"maxChargeTime":0,"increment":0,"bb":{},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skcom_withdraw","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":0},"count":null,"sources":["display"]}}},"chess_char_2_02_b":{"phase":2,"level":55,"stats":{"maxHp":1000,"atk":114,"def":0,"res":0,"cost":5,"blockCnt":0,"bat":0.5,"aspd":100,"respawnTime":5,"spRecovery":1,"hpRecoveryPerSec":0,"moveSpeed":1,"tauntLevel":0,"massLevel":0,"deployLimit":1,"deckStack":0},"immunities":{"stun":false,"silence":false,"sleep":false,"frozen":false,"levitate":false},"rangeGrid":[[1,-1],[1,0],[1,1],[0,-1],[0,0],[0,1],[-1,-1],[-1,0],[-1,1]],"trait":{"desc":"不会受到攻击,恢复周围友方单位的生命","descRaw":"不会受到攻击,恢复周围友方单位的生命","bb":{},"bbStr":{}},"dmgType":"heal","attackKind":"heal","projectile":"orb","canHitFly":false,"targetPriority":null,"skill":{"skillId":"skcom_withdraw","iconId":"skcom_withdraw","name":"自我销毁","level":1,"desc":"10秒后自动销毁","descRaw":"<@ba.vup>10</>秒后自动销毁","skillType":"PASSIVE","durationType":"NONE","duration":10,"spType":"ON_DEPLOY","spCost":0,"initSp":0,"maxChargeTime":0,"increment":0,"bb":{},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skcom_withdraw","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":1},"talents":[],"count":1,"sources":["skill","display"],"bySkill":{"0":{"skill":{"skillId":"skcom_withdraw","iconId":"skcom_withdraw","name":"自我销毁","level":1,"desc":"10秒后自动销毁","descRaw":"<@ba.vup>10</>秒后自动销毁","skillType":"PASSIVE","durationType":"NONE","duration":10,"spType":"ON_DEPLOY","spCost":0,"initSp":0,"maxChargeTime":0,"increment":0,"bb":{},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skcom_withdraw","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":0},"count":null,"sources":["display"]}},"byModule":{"none":{"stats":{"maxHp":1000,"atk":114,"def":0,"res":0,"cost":5,"blockCnt":0,"bat":0.5,"aspd":100,"respawnTime":5,"spRecovery":1,"hpRecoveryPerSec":0,"moveSpeed":1,"tauntLevel":0,"massLevel":0,"deployLimit":1,"deckStack":0},"immunities":{"stun":false,"silence":false,"sleep":false,"frozen":false,"levitate":false},"trait":{"desc":"不会受到攻击,恢复周围友方单位的生命","descRaw":"不会受到攻击,恢复周围友方单位的生命","bb":{},"bbStr":{}},"talents":[]}}}},"assets":{"avatar":"token_10000_silent_healrb","spine":"token_10000_silent_healrb"}},"token_10006_vodfox_doll":{"tokenId":"token_10006_vodfox_doll","kind":"summon","name":"诅咒娃Line truncated
+1
View File
@@ -205,6 +205,7 @@ The resolver's full precedence list is in the header of `tools/assets/anim-roles
Other renderer rules from research 07 §5.4–5.5:
- **Choosing the model:** Front when the unit faces right or down; Front mirrored when facing left; Back when facing up.
- **Attack speed:** set the attack `timeScale` to `duration / attackInterval`.
- **Model size:** every skeleton is drawn at one `UNIT.modelScale` (render/style.js, 320 skeleton units per tile), which stands for the official standard. The official client also scales each enemy model in its battle prefab: the Graphic / FaceSwitcher / Spine transforms multiply to 0.27 for most enemies and for the operators' battle skins, but not for all of them. For example, 威龙 is 0.16, 妖怪 0.20 and 青铜镜 0.6. The skeletons themselves carry no such scale, because every enemy SkeletonDataAsset uses 0.01. So an enemy is drawn × data/enemies.json `modelScale` (its prefab's product ÷ 0.27, see docs/DATA.md; user playtest #6: 威龙 used to be drawn 1.35× a 妖怪 instead of 1.08×), and its HP bar sits on that model: at its setup-pose bounds' height × the same factors, or, for a skeleton without bounds, at the chibi headroom × `modelScale` (bosses 2.2 tiles). `tools/local-extract/enemy_scales.py` reads the products from a local client, and `tools/build-data.mjs MODEL_SCALES` keeps them.
- **Enemy aliases:** `enemies[id].spineAliasOf` means the model belongs to another enemy. Two cases:
- `enemy_1305_mhslim` has no Spine anywhere, so it uses the slime model. Tint it.
- `_2` boss variants reuse the base model.
+3 -3
View File
@@ -220,9 +220,9 @@ previous pass (old generator), kept for history. \* the bot code of this pass (
## 6. Match follow-ups shipped with this pass
* Multi-round bounties ("之后的每场作战") also spawn in the Final Assault / Hidden Core, on the owner's half (route to
its goal), are shown in the boss-round preview, and the boss battle uses up one of their battles (kill coins →
pending funds for the Hidden Core prep).
* Bounties with battles left (multi-round cards — "之后的每场作战" — last two battles since playtest #6, META §1.2)
also spawn in the Final Assault / Hidden Core, on the owner's half (route to its goal), are shown in the boss-round
preview, and the boss battle uses up one of their battles (kill coins → pending funds for the Hidden Core prep).
* A 驰援 card is never offered when its bond has no chess left in the match's pool (every member banned).
* docs/META.md §2.6 example guards `ctx.source.kind === 'choice'` (its EffectRef reuses the handler's key).
* 坚若磐石 = least LP lost (`titles.comment_3 { stat: 'lpLost', rule: 'min' }` in tuning.json; results.js supports
+19 -9
View File
@@ -103,7 +103,7 @@ Top level: `{ season, seasonName, modes, economy, lpCapPerRound, bossOvertimeAft
| `equipPerChess`, `maxArtsPerRound` | `2`, `2` | |
| `poolCopies[tier]` + `poolCopiesOverrides` | `{"1":12,"2":14,"3":18,"4":16,"5":8,"6":5}`, `{"chess_char_6_11_a":4}` (缪尔赛思) | shared pool copies per base chess |
| `goldenCopies`, `mergeCount`, `mergeCountOverrides`, `itemMergeCount` | `3`, `3`, `{"chess_char_2_11_a":2}` (风丸), `2` | |
| `rewardOffer` | `{"count":3,"tierOffset":1,"maxTier":6,"price":0,…}` | merge reward: 3 free chess of tier `min(shopLevel+1,6)` |
| `rewardOffer` | `{"count":3,"tierOffset":1,"maxTier":6,"price":0,…}` | merge reward: 3 **different** free chess of tier `min(shopLevel+1,6)` (a short tier tops up from the tier below; `PlayerState.pushRewardOffer`, user playtest #6 item 19) |
| `handFillOrder` | `"rightToLeft"` | |
| `shopOdds` | `{"model":"copyWeighted",…}` | [ASSUMED] roll model |
| `borrowCount`, `fallbackBondId`, `defaultBandId`, `defaultStartLp` | `20`, `"emptyShip"`, `"band_bldsk"`, `28` | |
@@ -186,7 +186,7 @@ Top level: `{ season, seasonName, modes, economy, lpCapPerRound, bossOvertimeAft
| `bb`, `bbStr` | `{"atk":1,"base_attack_time":-0.3,"attack@trigger_time":14}` | kit input (DESIGN §5.6) |
| `rangeId`, `rangeGrid` | `null` | skill range override |
| `prefabId`, `overrideTokenKey` | | |
| `trigger` | `{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null}` | auto-cast rule. `rule` ∈ `DEFAULT`, `TAKE_DAMAGE` (古米/灰毫 S1 only), `SP_FULL` (official `ALWAYS`), `CUSTOM_RANGE` (official `CUSTOM_RANGE_SEARCH_ENEMY`, uses `customRangeGrid`), `SEARCH` (安洁莉娜), `MLYSS_WTRMAN` (缪尔赛思), `GDGLOW_SKILL_2` (荒芜拉普兰德, 纯烬艾雅法拉). Resolution per research 03 Addendum C1. |
| `trigger` | `{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null}` | auto-cast rule (the official 技能策略, PRTS 卫戍协议/帮助 §作战阶段 技能操作; §2.2). `rule` ∈ `DEFAULT` (basic strategy), `SKILL_RANGE` (a MANUAL skill with a 技能范围 of its own: `customRangeGrid` = its `rangeGrid`, `rawRule` `DEFAULT` — no official row), `TAKE_DAMAGE` (every MANUAL 重装 skill), `SP_FULL` (official `ALWAYS`: 执旗手 / 战术家 / 吟游者 MANUAL skills), `CUSTOM_RANGE` (official `CUSTOM_RANGE_SEARCH_ENEMY`, uses `customRangeGrid`), `SEARCH` (解放者 / 阵法术师 MANUAL skills, 安洁莉娜 S2/S3), `MLYSS_WTRMAN` (缪尔赛思), `GDGLOW_SKILL_2` (荒芜拉普兰德, 纯烬艾雅法拉 S3: "全场存在可选目标时释放技能"). |
### 2.1 Combat classification heuristic (`dmgType` / `attackKind` / `projectile` / `canHitFly`)
- `dmgType`: MEDIC (except `incantationmedic`) and `bard` → `heal`; trait text containing 法术伤害 or profession CASTER → `arts`; else `phys`.
@@ -201,9 +201,15 @@ elite record — `shared/protocol.js loadoutOptions`) and, for the elite, the **
everything needed to resolve a unit for `(chessId, skillIndex, moduleId)` (`simdata resolveLoadout / loadoutRecord`,
`DataSource.getChess(id, loadout)`):
- `skills[]` — see the table above. Triggers per skill: charId rows (exact `skillIndex`, or −1 = every skill) →
subProfession rows → profession rows with that exact index (official `skillIndex 0` = skill 1 only: every defender's
S1 is `TAKE_DAMAGE`, their S2/S3 `DEFAULT`); `customRangeGrid` from `skillRangeDict[skillId]`.
- `skills[]` — see the table above. Triggers per skill (`tools/build-data.mjs resolveTrigger`): charId rows (exact
`skillIndex`, or −1 = every skill) → subProfession rows → profession rows. The class rows apply to **every skill
index** — PRTS names whole classes ("重装干员", "先锋-战术家、先锋-执旗手、辅助-吟游者分支干员", "近卫-解放者、术师-阵法术师
分支干员"; a phalanx never attacks with its skill off, so the row must cover 薄绿's default S2) — but only to **MANUAL**
skills: an AUTO skill keeps its own rule (DEFAULT; kits add a base-game rule where needed, e.g. 古米 S1). Then a
MANUAL operator skill whose `rangeId` is a 技能范围 — its description is not an attack-range change ("攻击范围扩大 /
改变 / 缩小 / 缩短", "攻击距离+N / 加长 / 缩短") — is `SKILL_RANGE` with `customRangeGrid` = its `rangeGrid`; summons keep
DEFAULT. `customRangeGrid` of `CUSTOM_RANGE` from `skillRangeDict[skillId]`. (Research 03 Addendum C1 read the rows'
`skillIndex 0` as "skill 1 only" after BWIKI's transcription; superseded by user playtest #6.)
- `modules[]` (golden, `equipLevel > 0`): every **ADVANCED** `uniequip` of the character (the INITIAL `uniequip_001_*`
is "no module" = choice `'none'`), in official order, at the chess `equipLevel` (1, T6: 3):
@@ -313,7 +319,7 @@ Types: `BAND_INITIAL` 41, `BOND` 23, `EQUIP` 115, `ENEMY_GAIN` 129 (悬赏), `BU
| `events[id]` | `{"id":"enemy_initial_1","choiceType":"BOUNTY_HUNT","effectType":"ENEMY_GAIN","name":"悬赏决策","desc":"…","color":"#35d8b4","family":"bounty","solo":false,"training":false}` | 109 official choice events; `_s` = solo copies; `usedBy` on events opened by items (`hunter_band_1` ← 教鞭 / “神秘顾客”) |
| `families` | `{"bounty":{"name":"悬赏决策",…},"supply":{…},"shop":{…},"tactic":{…}}` | 悬赏决策 / 道具补给 / 机密商店 / 战术决策 |
| `format` | `{"multi":{"cards":6,"pickOrder":"random","firstPickSec":30,"otherPickSec":16,"onTimeout":"autoPickRandom","eachPlayerPicks":1},"solo":{"cards":3,"timer":null},"opensAfterIncome":true}` | |
| `cards.bounty[]` | `{"effectId":"enemyeffect_10_4","name":"悬赏·飞行I","tier":1,"coin":1,"payout":"kill","rounds":2,"multiRound":false,"enemyKey":"enemy_1005_yokai","count":1,"adds":[…]}` | `payout` `kill` (killer gets coins, 联防 helpers too) / `perfect` (chooser's own phase perfect); `rounds` = battles affected (99 = all remaining); `tier` from the I/II/III suffix (else coin value) |
| `cards.bounty[]` | `{"effectId":"enemyeffect_10_4","name":"悬赏·飞行I","tier":1,"coin":1,"payout":"kill","rounds":2,"multiRound":false,"enemyKey":"enemy_1005_yokai","count":1,"adds":[…],"draft":true,"draftExcluded":null}` | `payout` `kill` (killer gets coins, 联防 helpers too) / `perfect` (chooser's own phase perfect); `rounds` = battles affected (99 = all remaining); `tier` from the I/II/III suffix (else coin value); `draft` = offered by the 悬赏决策 draft — the PRTS 下半 记录 §机变阶段 "敌人轮选" table (105 cards: 1- and 2-battle kill bounties, 源石虫·特训, the 7 multi-round cards); else `draftExcluded` `perfect` (战术特训: "仅由法术教鞭生成" — the 教鞭 Art's cards; 20) / `hidden` (鸭爵 / 高普尼克 / 流泪小子 / 圆仔, commented out of the table; 4) — build-data `bountyDraftExclusion`, `server/match/choices.js draftBounty`, user playtest #6 item 4 |
| `cards.tactic[]` | `{"effectId":"map_m01_1","name":"模拟战场演变·模式一","kind":"terrain","stageId":"act1autochess_m01","team":false}` | `kind` `ally` / `enemyDebuff` / `terrain` (only for the match stage); `team` = also given to teammates |
| `schedule[modeId]` | `{"spRounds":[3,6,9],"rounds":{"3":{"families":[{"family":"bounty","weight":50},…],"cards":6,"supplyTiers":[1,4],"bountyTiers":[1,2],"events":{"bounty":[…],"supply":[…],"shop":[…],"tactic":[…]},"assumed":true}}}` | research 01 A4 defaults: pick a family by weight; `supplyTiers` = item tier window for 道具补给; 机密商店 draws any tier; `events` = official event ids usable as the phase header |
| `pools[poolId]` | `{"kind":"equip","items":[…]}` / `{"kind":"chess","tier":2,"rule":"shopEligible"}` | server-side reward pools referenced by effects (`pool_equip_*`, `pool_chess_shop_N_reward`, `pool_char_later`, …), all [ASSUMED]; `rule:"shopEligible"` = visible/non-hidden entries, `maxTier:"shopLevel"` = ≤ current shop level, `weighted` = `[[id, weight]]`. `pool_equip_vict` (维多利亚, every 25 layers: "获得一件带有随机特殊效果的维式重锤") and `pool_equip_rockr` (洛洛's 定制品; user playtest #4 — the research 04 §8 guess 有限加速器 / 激光发射器 / 护盾无人机 / 双模机械臂 / 蜂鸣器 is superseded) = the 4 special 维式重锤, uniform |
@@ -342,6 +348,7 @@ blackboards (transitively). Level = `randomEnemyAttributeDict[key].level` (0 for
| `summons[]`, `inactiveIn[]`, `seasonOverride` | | spawned enemies; modes banning it; overridden fields |
| `iconId`, `spine` | `"enemy_1422_lrsldr"` | asset ids |
| `hitArea` | `{"w":4.95,"h":2.95,"dx":0,"dy":1}` (`enemy_9013_acstmk`) | **huge units only** (巨型单位, 7 keys: 假想敌:胄 ×2, 假想敌:管 ×2 — the 隐秘核心 one `dx` 1 —, 盐风主教昆图斯, 阿利斯泰尔,帝国余晖, “萨米的意志”): the hit rectangle, `w` tiles along the columns × `h` along the rows, centred on the unit's position moved `dx` columns right / `dy` rows up (sim/body.js; user playtest #5). Not in the game tables (the collider lives in the prefab): `tools/build-data.mjs HIT_AREAS` by `prefabKey`, from PRTS "巨型单位:受击判定区域为长4.95、宽2.95的长方形,向上偏移1.0" and PRTS盟约记录 (the season's 阿利斯泰尔 / “萨米的意志” versions); absent = a point. All 7 are also 自缚 + 无法被阻挡 (PRTS 天赋 — not a data field either: content/bosses.js `SELF_BOUND`) |
| `modelScale` | `0.5926` (`enemy_1005_yokai_3` 威龙) | official drawn size of the enemy's Spine model relative to the standard (user playtest #6): the battle prefab's transform scale down to its Spine renderer (Graphic × FaceSwitcher × Spine; SkeletonDataAsset.scale is 0.01 for every enemy skeleton) ÷ 0.27, the standard of 1454 of the client's 2147 enemy prefabs (2080 have exactly one Spine renderer) and of the operators' battle skins. The renderer multiplies `UNIT.modelScale` by it (render/units.js `enemyModelScale`). 125 keys (122 prefabs) carry one, from 0.5926 (威龙 0.16; 妖怪 0.7407, 寒霜 0.6667) to 2.2222 (青铜镜 / 青瓷茶器 0.6); absent = 1. Not in the game tables: `tools/build-data.mjs MODEL_SCALES` by `prefabKey`, read from the local client by `tools/local-extract/enemy_scales.py` |
## 10. `factions.json` — special enemies (特训敌人)
@@ -445,11 +452,12 @@ Glyph legend (`rows`):
|---|---|---|
| `tokenId`, `kind`, `name`, `appellation`, `desc`, `descRaw` | …, `"summon"`, `"狼群"` | |
| `profession`, `subProfessionId`, `position` | `"TOKEN"`, `"notchar1"`, `"MELEE"` | |
| `displayType`, `placeable` | `"DEFAULT"`, `true` | `shopStateTokenDict` DEFAULT / HIDDEN (battle-only). `placeable` (a prep hand piece) = DEFAULT **and** a talent summon (海嗣, 狼群, 流形); skill summons (赫默's 医疗探机, 巫恋's 诅咒娃娃) are never hand pieces — their kits place them when the skill fires (user playtest #4 item 11) |
| `displayType`, `placeable` | `"DEFAULT"`, `true` | `shopStateTokenDict` DEFAULT / HIDDEN (battle-only) / `null` (not listed). `placeable` (a prep hand piece) = a manually deployable summon (PRTS 卫戍协议/帮助 "可手动部署的附属召唤物…加入手牌区"; user playtest #6): not HIDDEN and made by an owner's talent or skill — 医疗探机 (赫默 S2), 诅咒娃娃 (巫恋 S2), 海嗣, 狼群, 流形 and 爬行号·防护单元 (凯瑟琳's talent device — the only pool summon missing from `shopStateTokenDict`, read as shown: placed by hand in the base game, by the friend's report, and confirmed by the user after playtest #6, DESIGN §20); 投递坐标 (HIDDEN) is not. In battle a skill's summon deploys once at the battle start, then on its tile each time the skill gives one (SIM.md, token pieces) |
| `owners[]` | `["chess_char_3_19_a","chess_char_3_19_b"]` | |
| `stats`, `rangeGrid`, `dmgType`, `attackKind`, `projectile`, `canHitFly` | first owner's values | defaults |
| `skill` | `{"skillId":"sktok_vigil_wolf_3","bb":{…}}` | default token skill (same slot as the owner's skill) |
| `deployLimit`, `count` | `1`, `1` | `count` = copies sent to the hand / spawned (talent/skill `cnt`); `null` ⇒ use `deployLimit` |
| `abnormal[]` | `["healFree"]` | abnormal effects the summon holds from the start, no official table carries them — `tools/build-data.mjs TOKEN_ABNORMAL` from the PRTS summon pages (user playtest #6 item 18): `healFree` = 禁疗 (“小自在”, “耀阳”, 斯卡蒂的海嗣, 沙之碑, 流形, 狼群, 迷迭香的战术装备, 黄金盟誓, 保护目标(冻结状态)), `isolated` = 孤立 "无法被同阵营选中" (“炎佑”, 从不混淆的方向); `[]` otherwise. The sim sets `noHeal` / `isolated` (docs/SIM.md §3) |
| `variants[ownerChessId]` | `{"phase":2,"level":1,"stats":{…},"immunities":{…},"rangeGrid":…,"trait":{…},"dmgType":…,"skill":{full skill record},"talents":[…],"count":1,"sources":["talent","display"]}` | stats at the owner's phase/level (clamped to the token's max level) + golden module `tokenAttributeBlackboard`; the owner's module parts flagged `isToken` upgrade the variant's `trait` (+`moduleDesc`) and `talents` (伺夜's wolves, 缪尔赛思's 流形 `scale` 1, 浊心斯卡蒂's 海嗣 30 s, “耀阳” `atk_scale` 1.15). `sources` ⊆ `talent`/`skill`/`display`: how the owner produces it (`display` only = listed on the character but unused by its default skill/talents, e.g. 迷迭香 S2, 凛御银灰 eagle1/3). `count` = copies from a talent `cnt` or the default skill's `cnt` when that skill overrides this token; `null` ⇒ use `deployLimit` |
| `variants[o].bySkill[i]` | `{"skill":{…},"count":1,"sources":["talent","display"]}` | owner loadout with the non-default skill index `i` (one entry per other selectable owner skill): the token skill of that slot (伺夜's wolves, 缪尔赛思's 流形, 凛御银灰's eagles…), the count and how the chess then produces it (`sources` may be `[]`: 风丸 S1 makes no 纸偶; 赫默 / 巫恋 S1 only `display` ⇒ no hand piece). The sim resolves them for an owner loadout: `simdata getToken(id, ownerChessId, loadout)` → `def.sources` / `def.count` |
| `variants[o].byModule[m]` | `{"stats":{…},"immunities":{…},"trait":{…},"talents":[…]}` | golden owner with another module `m` or `'none'`: the token as that module makes it (module `tokenAttributeBlackboard`, `isToken` trait/talent parts) |
@@ -473,8 +481,10 @@ Glyph legend (`rows`):
(锡人, 耶拉, 录武官, 白面鸮, 百炼嘉维尔, 魔王, 华法琳, 妮芙).
5. **Every non-DIY chess (258 records: 129 normal + 129 golden) has a resolvable default skill, stats and range** — no
skill anomalies.
6. **Skill triggers**: official rows with `skillIndex 0` apply to skill 1 only (research 03 C1): only 古米 and 灰毫 get
`TAKE_DAMAGE`; bearer/tactician/bard/librator/phalanx rows match no pool chess.
6. **Skill triggers** (§2.2): the class rows cover every MANUAL skill of the class (all 重装 MANUAL skills are
`TAKE_DAMAGE`; 薄绿 / 卡涅利安 / 蜜蜡 / 玛恩纳 S2 `SEARCH`; 伺夜 / 魔王 / 浊心斯卡蒂 S3 `SP_FULL`) and no AUTO skill (古米 /
雷蛇 / 瑕光 / 塞雷娅 / 号角 / 信仰搅拌机 S1, 伺夜 S1/S2, 魔王 S1); 13 MANUAL skills (26 normal + elite records) with
their own 技能范围 are `SKILL_RANGE` (德克萨斯 S2, 凛御银灰 S2, 锏 S2/S3, 异客 S3, 忍冬 S2, 焰尾 S2 …).
7. **Trait candidate `rangeId`** (送葬人, 松果 1-3; 风丸, 归溟幽灵鲨 x-4) is the trait-effect area, exposed as
`trait.rangeGrid`, not the attack range.
8. **Passive skills** use numeric `spType 8` in skill_table → normalized to `ON_DEPLOY`.
+173 -42
View File
@@ -69,6 +69,7 @@ server/
projectiles.js projectile flight/impact
skills.js skill runtime: SP, triggers, durations, ammo, charges; interprets SkillSpec
ai.js enemy movement/attack AI, operator attack loop
fear.js 恐惧 movement of enemies (reachable-tile fan, random checkpoints, self-fear flutter; §20.4)
snapshot.js compact serialization for clients
content/
index.js installs all content into a Battle (kits, bonds, garrisons, items, bands, enemies, devices)
@@ -128,7 +129,7 @@ Ownership rule for parallel agents: **only edit files assigned to you**; if you
- Enemy preview pen rows 14–18 × cols 7–13 (row 16 empty): during prep the next round's enemies idle there (upper-gate enemies rows 17–18, lower-gate rows 14–15, ≤ 50 models, spawn-time order) — research 08 §4 / 09 §2.
- Tile semantics come from `data/stages.json` legend: height (LOW/HIGH), buildable (ALL/MELEE/RANGED/NONE), ground-passable, fly-only, special terrain (mire, smog, deepsea, infection). Melee chess may stand only on LOW tiles with buildable ALL/MELEE; ranged chess on ALL/RANGED **and** on LOW ALL/MELEE tiles ("所有行动内远程干员可部署在近战位"). Tokens follow their own `position`.
- **Facing (corrected, see research 09 §1.2):** like standard Arknights, every board piece has a direction `dir ∈ UP|RIGHT|DOWN|LEFT` chosen with the 4-direction deploy wheel after dropping it on a tile (also re-orientable in place); default `RIGHT`; persists across rounds. Range grid entries are `[dRow, dCol]` relative to facing RIGHT and are rotated: RIGHT `(dr,dc)`, UP `(dc,−dr)`, LEFT `(−dr,−dc)`, DOWN `(−dc,dr)` (row 0 = bottom); `rangeExtend` applies along +dCol before rotating. The right-side Final Assault player is mirrored col `c→20−c` with RIGHT↔LEFT (UP/DOWN unchanged).
- **Range test:** an enemy is inside a grid range if the tile containing it (`round(y), round(x)`) is one of the range tiles; a huge enemy (data `hitArea`: 巨型单位, a 4.95 × 2.95 rectangle moved 1.0 up) if any tile its hit rectangle overlaps is one (PRTS 作战机制 "巨型BOSS单位的每一个占据的格子都可以让其本身通过格子判定"; `sim/body.js`, §19.4). Radius ranges (enemies, auras, AoE) use Euclidean distance in tiles — to a huge enemy's rectangle [ASSUMED] — except splash around a struck target, a 中点判定 on positions (PRTS 作战机制 "中点判定…案例:阻挡,酒神1天赋的1.3溅射半径").
- **Range test:** an enemy is inside a grid range if the tile containing it (`round(y), round(x)`) is one of the range tiles; a huge enemy (data `hitArea`: 巨型单位, a 4.95 × 2.95 rectangle moved 1.0 up) if any tile its hit rectangle overlaps is one (PRTS 作战机制 "巨型BOSS单位的每一个占据的格子都可以让其本身通过格子判定"; `sim/body.js`, §19.4). Radius ranges (enemies, auras, AoE) use Euclidean distance in tiles — to a huge enemy's rectangle [ASSUMED] — except splash around a struck target, a 中点判定 on positions (PRTS 作战机制 "中点判定…案例:阻挡,酒神1天赋的1.3溅射半径"). A ranged enemy's normal attack takes an ally whose collider (radius 0.25, PRTS 作战机制 §碰撞体积 "我方干员碰撞体积基本均为以0.25格为半径的圆形") touches its range circle: centre distance ≤ `rangeRadius` + 0.25 (`constants.js ALLY_COLLIDER_RADIUS`, `ai.js enemyAttack`; §20.4).
- **Fields at runtime:** a `Battle` receives a **field rect** (rows/cols subset) and the full stage grid. Normal battles use rows 9–12 × cols 0–10; unite uses rows 9–12 × cols 0–20; boss uses rows 0–5 × cols 0–20 (solo boss uses the left half only). Pathfinding never leaves the rect.
- **Speed:** `tilesPerSecond = moveSpeed × MOVE_SCALE` with `MOVE_SCALE = 0.5` (in `sim/constants.js`, tunable).
@@ -211,7 +212,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`.
**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, 10, 600)`.
- `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).
- `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**.
- Stats are rounded only for display; the sim keeps floats.
@@ -223,8 +224,8 @@ Base stats (from data): `maxHp, atk, def, res` (0–100), `aspd` (100 base), `ba
`Buff = { key, source, duration /*s, Infinity*/, stacks=1, maxStacks=1, refresh: 'replace'|'extend'|'stack', mods, flags, onTick?, onExpire?, tags[] }`
- `mods` keys (all optional, additive within a key): `atkFlat, atkPct, atkMul, defFlat, defPct, defMul, hpFlat, hpPct, hpMul, resFlat, resMul, aspd, batPct, blockCnt, rangeExtend (tiles), moveMul, dmgDealtMul (multiplicative, e.g. 1.2), dmgTakenMul, physTakenMul, artsTakenMul, trueTakenMul, defIgnoreFlat, defIgnorePct, resIgnoreFlat, resIgnorePct, dodgePhys, dodgeArts, healingDealtMul, healingTakenMul, spRecoveryFlat, spRecoveryMul, maxTargets (+n), shield (absorbs damage; consumed)`.
- `flags`: `stun, freeze, sleep, silence (no skills), disarm (no attacks), stealth (untargetable by enemies/ops unless blocked or revealed), invulnerable, unblockable, levitate, fear (enemy: cannot attack), taunt (+aggro), cold`.
- **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`, `burnGauge`/`necrosis`/`neural` element gauges (see damage), `stealth`, `reveal`.
- `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`.
- Immunities from enemy data (`stunImmune`, `silenceImmune`, `sleepImmune`, `frozenImmune`, `levitateImmune`) are honoured by `applyStatus`.
### 5.4 Hook bus (the only way content plugs in)
@@ -253,25 +254,25 @@ 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[] }`.
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.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; 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)`.
### 5.5 Combat rules (engine)
- **Initial deployment** at t=0, free: operators row-major top→bottom then left→right (right-side boss player: right→left), then the summons (PRTS 卫戍协议/帮助 "按从上到下>从左到右的顺序部署。优先部署干员,随后为召唤物"; `aggroSeq` ranks every start-of-battle summon after all operators); on a shared field (联防, boss) the players' i-th operators deploy together [ASSUMED]. Deploy effects fire (`deploy` with `initial: true`). Then operators carried into 联防 knocked out (`carryState.down`) are forced out (`FORCED_EXIT`, HP 0, §19.3). Then `battleStart`, after which the forced-out operators' timers are re-read (redeploy-time effects that start with the battle, e.g. 机变 征召, cover them too).
- **Initial deployment** at t=0, free: operators row-major top→bottom then left→right (right-side boss player: right→left), then the summons (PRTS 卫戍协议/帮助 "按从上到下>从左到右的顺序部署。优先部署干员,随后为召唤物"; `aggroSeq` ranks every start-of-battle summon after all operators) — a skill's placed summon (赫默 医疗探机, 巫恋 诅咒娃娃) included, once and free ("所有手动部署的召唤物,无视所属干员的持有状态…作战开始时立即部署一次"; afterwards it re-appears on its tile each time the owner's skill gives one, §20.1); on a shared field (联防, boss) the players' i-th operators deploy together [ASSUMED]. Deploy effects fire (`deploy` with `initial: true`). Then operators carried into 联防 knocked out (`carryState.down`) are forced out (`FORCED_EXIT`, HP 0, §19.3). Then `battleStart`, after which the forced-out operators' timers are re-read (redeploy-time effects that start with the battle, e.g. 机变 征召, cover them too).
- **DP**: per player, start 10, +1/s, cap 99; used only for auto-redeploy (dead op: after `respawnTime`, if its tile is free and DP ≥ cost ⇒ redeploy with full HP, SP = initSp). A knocked-out operator stays **down** on its tile from the knock-out until it redeploys (b.snap `down`, §18.3: counting / waiting for DP / its tile taken) — also one that entered 联防 knocked out (`FORCED_EXIT`, its full redeploy timer — confirmed by the user — with the battle-start redeploy effects, §19.3); summons, devices and enemies leave at once.
- **Blocking** (contact radius, PRTS 游戏数据基础 §阻挡半径 / 作战机制 "中点判定…案例:阻挡"; `constants.js BLOCK_RADIUS`, `Battle._checkBlock`, §19.2): an operator with free capacity blocks an unblocked, blockable enemy whose position lies within its block radius of its centre — ground 0.7071 (squared 0.49999037), 0.8944 for blockFly units vs flyers (起飞), devices 0.4472 (阻隔工事 / 障碍物); air units (FLY, 近地悬浮, 浮空) only by blockFly units. The nearest such operator wins (ties: scan order [ASSUMED]). It is re-checked every tick, moving or not, so another operator in contact with room takes the enemy over when its blocker dies, is withdrawn or is stunned, and an operator whose capacity frees up grabs an overlapping enemy; one that finds no room walks on. A head-on enemy stops at contact (≈ 0.71 tile), outside the blocker's tile. Enemy uses `blockCnt` capacity (default 1). Blocked enemies stop moving and attack their blocker if melee (or anyone in range if ranged, blocker first). A **melee** blocker (data `position` MELEE — 重装要塞 / 领主 / 哨戒铁卫 included) may always target the enemies it blocks, in range or not, and targets them first ("可以选择且优先选择阻挡单位", PRTS 选择器; 索敌的概念 "我方索敌优先级:阻挡(近战限定)"; `Battle.blockedTargets`, `targeting.js meleeUnit`); a ranged operator on a melee tile blocks but attacks only what its range holds ("远程位干员通常而言不会优先攻击自己阻挡的目标", §19.2). The huge leaders are 自缚 + unblockable (§7).
- **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 (melee units only, in range or not), (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%; 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 within radius: 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.
- **Blocking** (contact radius, PRTS 游戏数据基础 §阻挡半径 / 作战机制 "中点判定…案例:阻挡"; `constants.js BLOCK_RADIUS`, `Battle._checkBlock`, §19.2): an operator with free capacity blocks an unblocked, blockable enemy whose position lies within its block radius of its centre — ground 0.7071 (squared 0.49999037), 0.8944 for blockFly units vs flyers (起飞), devices 0.4472 (阻隔工事 / 障碍物); air units (FLY, 近地悬浮, 浮空) only by blockFly units. The nearest such operator wins (ties: scan order [ASSUMED]). It is re-checked every tick, moving or not, so another operator in contact with room takes the enemy over when its blocker dies, is withdrawn or is stunned, and an operator whose capacity frees up grabs an overlapping enemy; one that finds no room walks on. A head-on enemy stops at contact (≈ 0.71 tile), outside the blocker's tile. Enemy uses `blockCnt` capacity (default 1). Blocked enemies stop moving and attack their blocker if melee (or anyone in range if ranged, blocker first). **Every blocker** — melee units (重装要塞 / 领主 / 哨戒铁卫 included), summons and a ranged operator on a melee tile alike — may always target the enemies it blocks, in range or not, whatever its facing, and targets them first ("可以选择且优先选择阻挡单位", PRTS 选择器; the user's rule after playtest #6, "阻挡了就一定要能打到": officially the collision pushes a blocked enemy to its blocker's front; `Battle.blockedTargets`, `targeting.js sortEnemyTargets`; §20.3 — it replaces the playtest #5 QA's melee-only restriction of §19.2). The huge leaders are 自缚 + unblockable (§7).
- **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).
- **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).
- **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).
- **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).
### 5.6 Skills (skills.js + kits)
SP: `spType` `INCREASE_WITH_TIME` (+`spRecovery`/s), `INCREASE_WHEN_ATTACK` (+1 per attack), `INCREASE_WHEN_TAKEN_DAMAGE` (+1 per hit taken); starts at `initSp`; no SP gain while a duration skill is active. `maxChargeTime > 1` ⇒ charges.
Auto-trigger rules (`skillTrigger.rule` in data): `DEFAULT` (SP ready AND about to attack/heal AND an enemy/injured ally inside the **initial** range), `TAKE_DAMAGE` (on taking damage), `SP_FULL` (immediately), `CUSTOM_RANGE` (enemy inside the custom grid), `SEARCH` (an enemy inside the initial range, checked every tick — no attack needed), special ids. PASSIVE skills are always on.
Auto-trigger rules (`skillTrigger.rule` in data — the official 技能策略, PRTS 卫戍协议/帮助 §作战阶段 技能操作; §20.2): `DEFAULT` (SP ready AND about to attack/heal AND an enemy/injured ally inside the **initial** range, or an enemy the unit blocks; with `trigger.allies` + `hpAtMost` an ally of the skill grid at or below that HP ratio — 塞雷娅 S1 急救, whose cast replaces that attack and is withdrawn with its charge if the ally recovers first), `SKILL_RANGE` (a MANUAL skill with a 技能范围 of its own, not an attack-range change: any living enemy, stealthed ones included, on the skill range, checked every tick — "仅在技能范围内存在敌人(无视其不可选中)时释放技能"; 13 skills / 26 records), `TAKE_DAMAGE` (on taking damage — every MANUAL 重装 skill), `SP_FULL` (immediately — 执旗手 / 战术家 / 吟游者 MANUAL skills, and AUTO DP skills such as 伺夜 S1, 焰尾 S1, 凛御银灰 S1), `CUSTOM_RANGE` (enemy inside the custom grid), `SEARCH` (an enemy inside the initial range, checked every tick — no attack needed; 解放者 / 阵法术师 MANUAL skills), `GDGLOW_SKILL_2` (a valid target anywhere on the field: 荒芜拉普兰德, 纯烬艾雅法拉 S3), special ids. The class rows cover **every MANUAL skill** of the class and never an AUTO skill (AUTO skills keep their own rule — an inference: PRTS names whole classes; the data's `skillIndex 0` / `-1` sentinel argues the other way, §20.2). **Operation cooldown**: the engine auto-casts a MANUAL skill no sooner than `AUTO_OP_COOLDOWN` (3 s) after its previous cast and after the unit's battle-start deployment ("自动操作具有3s冷却,在完成一次操作或作战开始时部署的单位将进入冷却"; `Battle._deploy` initial, `battle.flags.startOpCooldown`); kits with an automatic cast of their own check `skill.opCooling`; a pending "next attack" never takes a second charge. PASSIVE skills are always on.
**SkillSpec** (authored per chess in `sim/content/kits/tierN.js`; values come from the chess's skill blackboard at its level — normal Lv4, elite Lv7 — so a spec is a *function of the blackboard*):
```js
@@ -311,7 +312,7 @@ LOBBY(room) → INFO_CHECK (co-op 25 s; solo and any single-human match untimed
a card is picked with two taps, §18.2)
PREP (co-op: timer from config, ends when all alive humans+bots ready; solo / single human: untimed, ends on ready)
PREP_END (<休整期结束时> effects; unfrozen shop cleared; temp overflow: Ready is blocked while temp is non-empty; at the deadline
the temp pieces due at this prep are resolved (chess sold back, items / summon stacks destroyed) — pieces that arrived
the temp pieces due at this prep are resolved (chess sold back, items destroyed, summon stacks removed — they come back at the next round start, §20.1) — pieces that arrived
after Ready or during the <休整期结束时> effects wait for the next prep (§6.2 tempDue))
COMBAT (one Battle per alive player, all in parallel, 2×; watchers get snapshots; solo: pausable, §14 Solo pause)
UNITE (co-op only, if ≥1 leaked & ≥1 perfect: ≤2 perfect helpers in `unite.js helperOrder` order (research 08 §5 / PRTS 帮助:
@@ -321,7 +322,8 @@ LOBBY(room) → INFO_CHECK (co-op 25 s; solo and any single-human match untimed
PRTS "上一阶段为退场状态的干员强制退场", its full redeploy timer — confirmed by the user (§19.3))
SETTLE (LP −min(leaks,10) per player (unite survivors to their source); eliminations; layer gains applied; coins; broadcast)
R14 FINAL_ASSAULT (merged team LP; pairs by seat; shared boss pool; 120 real s countdown, no hard stop; overtime −1 team LP per
real s after 150 real s (§4, m.public.overtimeAt); no layer gains)
real s after 150 real s (§4, m.public.overtimeAt); no layer gains; team LP 0 ⇒ defeat at once (PRTS 直接失败),
pool 0 ⇒ victory — the first end the server registers decides, §20.5)
R15 HIDDEN_CORE (if difficulty ≥ NORMAL and Σ activated layers > 350 solo / 1200 co-op and team LP > 1)
→ RESULT (per-player stats, titles, rounds passed, victory/defeat)
```
@@ -346,11 +348,13 @@ Piece = { uid, kind:'chess'|'item'|'token', id /* chessId (golden id if elite) |
Intent handlers (validate → mutate → recompute bonds → push private view): `buy(slot)`, `refresh()`, `freeze()`, `levelUp()`, `sell(uid)`, `move(uid, to)`, `equip(itemUid, targetUid, replaceUid?)`, `useArt(itemUid, row, col)`, `destroy(uid)`, `pickReward(idx)`, `pickChoice(idx)`, `setReady(bool)`. Every handler rejects when the phase forbids it (only PREP allows board/shop mutations; SP_DRAFT allows `pickChoice` only; reward pick allowed in PREP). Invalid intents return `error` with a code from `shared/constants.js → ERR` — the server never throws on bad input.
Summons (§20.1): an operator on the board sends its manually deployable summons to the hand — deploy-limit copies in one stack, no deploy slot (tokens.json `placeable`, `gamedata.placeableTokens`); moving or swapping it sends its placed summons back to their stack, withdrawing or selling it removes them.
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 free chess of tier `min(level+1, 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 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).
**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 / summon stacks destroyed — 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 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).
### 6.3 Bonds & layers (bondsMeta.js)
@@ -383,7 +387,7 @@ Each domain file `server/sim/content/{tokens,bonds,garrisons,items,bands,enemies
- items: 56 equipment (+golden) + 3 Arts; 变形同构体 bond grant; VI-tier set/combos.
- 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.
- choices: 机变 families (悬赏决策 bounties, 道具补给, 机密商店, 战术决策 team/personal buffs).
- 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).
---
@@ -400,11 +404,11 @@ Platform additions (implemented): `welcome.{resumed, version}`, `room.state.inMa
### 8.2 Match
C→S (all carry `rid`): `g.infoReady` · `g.band {bandId}` · `g.bandSkip` · `g.bandFocus {bandId?}` (the strategy highlighted in the draft screen — what a timed-out turn takes; absent / null clears it; `WRONG_PHASE` outside BAND_DRAFT, `ALREADY` after the pick, `BAD_TARGET` for a band not allowed; §18.2) · `g.buy {slot}` · `g.refresh` · `g.freeze` · `g.levelUp` · `g.sell {uid}` · `g.move {uid, to:{area:'board', row, col}|{area:'hand', idx}, dir?}` · `g.equip {itemUid, targetUid, replaceUid?}` (`replaceUid`: which equipped item a third one replaces, §6.2) · `g.art {itemUid, row, col, dir?}` (`dir` ∈ UP|RIGHT|DOWN|LEFT from the deploy wheel, §3; absent ⇒ `to.dir`, then RIGHT; `g.move` onto the piece's own tile re-orients it; board pieces in `m.private` / `m.field {prep:true}` carry `dir`) · `g.destroy {uid}` (hand / temp items only; equipped items are locked, §6.2) · `g.reward {idx}` · `g.choice {idx}` · `g.ready {ready}` · `g.emote {id}` · `g.watch {fieldId}` · `g.autoplay {on}` · `g.pause {on}` (solo battles only, §14 Solo pause) · `g.unitStats {seq?}` (ROUND_START / SP_DRAFT / PREP, else `WRONG_PHASE`; eliminated: `ELIMINATED`) → push `m.unitStats` (§18.5) · `g.leave`
S→C:
- `m.public` — full public state on every change (throttled ≤ 10/s): `{ phase, round, lastRound, deadline, serverNow, modeId, difficulty, stageId, factions, disabledBonds, bannedChess, bossId, hiddenBossId?, teamLp?, bossHp?:{hp,max}, overtimeAt? /* FA / HC: ms epoch the overtime drain starts, §4 */, paused /* solo pause, §14 */, draft?:{order,turn,picks,skipsLeft,turnDeadline,turnSeconds /* one turn's length, 0 untimed; deadline = turnDeadline (§18.2) */,untimed}, sp?:{family,cards,turn,picks}, players:[{playerId, seat, name, isBot, connected, alive, lp, bandId, shopLevel, boardCount, ready, bonds:[{bondId,count,active,tier,layers}], fieldId, status, pendingLp? /* COMBAT / 联防 of a normal round: the LP the player's own battle will cost at settlement so far, min(lpCapPerRound, counted leaks) — omitted when 0 and in every other phase (§17.5 live LP) */}] , fields:[{fieldId, kind, players:[playerId...]}] }`
- `m.public` — full public state on every change (throttled ≤ 10/s): `{ phase, round, lastRound, deadline, serverNow, modeId, difficulty, stageId, factions, disabledBonds, bannedChess, bossId, hiddenBossId?, teamLp?, bossHp?:{hp,max}, overtimeAt? /* FA / HC: ms epoch the overtime drain starts, §4 */, paused /* solo pause, §14 */, draft?:{order,turn,picks,skipsLeft,turnDeadline,turnSeconds /* one turn's length, 0 untimed; deadline = turnDeadline (§18.2) */,untimed}, sp?:{family,cards /* bounty cards carry descRaw?, §20.6 */,turn,picks}, players:[{playerId, seat, name, isBot, connected, alive, lp, bandId, shopLevel, boardCount, ready, bonds:[{bondId,count,active,tier,layers}], fieldId, status, pendingLp? /* COMBAT / 联防 of a normal round: the LP the player's own battle will cost at settlement so far, min(lpCapPerRound, counted leaks) — in 联防 min(lpCapPerRound, uniteLeft) — omitted when 0 and in every other phase (§17.5 live LP) */, uniteLeft? /* 联防: the leaker's enemies still standing — not spawned yet + alive + counted leaks, uncapped, live (§20.6) */}] , fields:[{fieldId, kind, players:[playerId...]}] }`
- `m.private` — the recipient's full PlayerState view (§6.2 minus server-only data) on every change.
- `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).
- `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 0..1, cooldownEnd (game s), cooldown (s)]]` — the one gauge a unit shows: the fullest (ties by the official element id 神经 < 侵蚀 < 灼燃 < 凋亡), 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]`
- `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)
@@ -424,14 +428,14 @@ S→C:
board: [ Piece & { row, col, dir } ], // dir ∈ UP|RIGHT|DOWN|LEFT (§3; bench pieces carry none)
deployCap, deployCount,
bonds: [ { bondId, count, active, tier, layers, thresholds, countsHand } ], // sorted: active first, then layers desc
effects: [ { id, name, desc, iconKind: 'band'|'choice'|'team'|'item'|'garrison', iconId, counter? } ],
effects: [ { id, name, desc, iconKind: 'band'|'choice'|'team'|'item'|'garrison', iconId, counter?, counterText? /* a bounty: 还剩 N 场作战, §20.6 */ } ],
nextEnemies: [ { enemyKey, count, tag } ], // preview of the upcoming round's wave (after waves.js generation)
stats: { dmgDealt, kills, leaks, gold, refreshes, merges } }
Piece = { uid, kind: 'chess'|'item'|'token', id, golden: bool, tier, items: [ { uid, id } ] /* chess only */, count /* token stacks */, ownerUid /* token */ }
```
`m.public.players[].status` ∈ `'acting' | 'ready' | 'deciding' | 'combat' | 'done' | 'helping' | 'left' | 'dead'`.
`m.public.fields[]`: `{ fieldId, kind: 'normal'|'unite'|'boss'|'hidden', players: [playerId…], live: bool }` — `fieldId` is `'n:<playerId>'` for normal fields, `'u'` for the unite field, `'b1'`/`'b2'` for boss fields. Clients `g.watch` a fieldId; default = own field (or the unite/boss field they are in).
`flags` bitmask: 1 blocked, 2 stunned, 4 frozen, 8 stealth, 16 skillActive, 32 shielded, 64 invulnerable, 128 cold, 256 sleep, 512 flying.
`flags` bitmask: 1 blocked, 2 stunned, 4 frozen, 8 stealth (隐匿, or an ally's 迷彩 — §20.2), 16 skillActive, 32 shielded, 64 invulnerable, 128 cold, 256 sleep, 512 flying.
`anim` small int: 0 idle, 1 move, 2 attack, 3 skill, 4 die, 5 stun, 6 deploy.
---
@@ -460,7 +464,7 @@ view.holdPiece(uid, {row,col}|null) // keep a dropped piece on a tile w
view.setPieceDir(uid, dir) // show a prep piece facing UP|RIGHT|DOWN|LEFT (wheel preview / stored dir)
view.resize() ; view.destroy()
```
Visuals: oblique perspective board (tilted ~30°, trapezoid tiles), raised highland blocks with side faces, road/floor/forbidden styles per research 07 §7, pulsing red gates, blue objective, dashed hand pads. Units: Spine (Front/Back rule, feet anchored, shadow sprite), elite gold outline glow, tier chip, HP bar (green ally / red enemy), SP bar (skill ready glow), status icons, the element icon right of the bars (§18.3); damage numbers (phys orange-white, arts purple, true white, heal green); projectiles drawn per kind, hit sparks and melee slashes, skill activation bursts and auras (§17.3); death fade for enemies, summons and devices — a knocked-out operator stays on its tile in its held Die pose under a redeploy ring (§18.3); leak flash at the objective. Fallback when Spine missing/failed: avatar in a rarity-coloured diamond with bob/lunge tweens. 60 fps target with ≤ 120 units. Picking (which unit a press / hover / dropped item is on) and the drag target follow one rule, `render/pick.js` — the tile under the pointer (§18.1); the Spine store never hands out a skeleton whose unload is in flight (§17.1).
Visuals: oblique perspective board (tilted ~30°, trapezoid tiles), raised highland blocks with side faces, road/floor/forbidden styles per research 07 §7, pulsing red gates, blue objective, dashed hand pads. Units: Spine (Front/Back rule, feet anchored, shadow sprite), elite gold outline glow, tier chip, HP bar (green ally / red enemy), SP bar (skill ready glow), status icons, the element gauge row under the bars — icon + white bar of the remaining 元素值, element-coloured refill during a 爆发冷却 (§18.3, §20.8); enemies drawn × their official prefab factor (enemies.json `modelScale`, §20.4); damage numbers (phys orange-white, arts purple, true white, heal green); projectiles drawn per kind, hit sparks and melee slashes, skill activation bursts and auras (§17.3); death fade for enemies, summons and devices — a knocked-out operator stays on its tile in its held Die pose under a redeploy ring (§18.3); leak flash at the objective. Fallback when Spine missing/failed: avatar in a rarity-coloured diamond with bob/lunge tweens. 60 fps target with ≤ 120 units. Picking (which unit a press / hover / dropped item is on) and the drag target follow one rule, `render/pick.js` — the tile under the pointer (§18.1); the Spine store never hands out a skeleton whose unload is in flight (§17.1).
---
@@ -468,9 +472,9 @@ Visuals: oblique perspective board (tilted ~30°, trapezoid tiles), raised highl
Look & feel: research 06 §11–§12 (near-black green-grey panels, mint `#4ed8af` accents, gold prices, bracket frames, hexagon badges, bond discs, 7-segment countdown). Fonts: Noto Sans SC (Google Fonts with local fallback), Bender/Novecento self-hosted for numerals and Latin micro-labels.
Screens: **Title** (season art backdrop, 开始 → nickname), **Lobby** (独立模拟 / 同盟模拟 → create or join; recent rooms), **Room** (4 seats, invite code + copy link, difficulty picker (host), add/remove AI, ready, start), **Briefing** (INFO_CHECK: stage, factions, disabled bonds, banned chess, boss silhouette, ready x/4), **Band draft** (grid of 40 bands with icon/name/LP/effect; turn order list; skip), **Game** (Pixi field + DOM HUD: top bar with round/phase capsule/LP (live during the own battle, §17.5)/timer/ready; bond strip + popups; left team panel with LP/status/emote bubbles, click to watch; bottom shop bar (level card, 3–5 chess cards, item card, funds, refresh, freeze, collapse); detail panel on click/hover (header with bonds, then 特质 — the operator's own effect — right under it, 特性, stats (live: the battle's own numbers / the start-of-battle ones in prep, §18.5), skill, module, equipped items, talents: §17.5); enemy preview drawer (next round enemies with counts, factions); 机变 overlay (two taps, §18.2); merge reward overlay; phase banners; ticker; toasts; emote wheel; settings (volume, quality, show damage numbers)), **Result** (victory/defeat, rounds, per-player lineup, stats, titles, back to room).
Screens: **Title** (season art backdrop, 开始 → nickname), **Lobby** (独立模拟 / 同盟模拟 → create or join; recent rooms), **Room** (4 seats, invite code + copy link, difficulty picker (host), add/remove AI, ready, start), **Briefing** (INFO_CHECK: stage, factions, disabled bonds, banned chess, boss silhouette, ready x/4), **Band draft** (grid of 40 bands with icon/name/LP/effect; turn order list; skip), **Game** (Pixi field + DOM HUD: top bar with round/phase capsule/LP (live during the own battle, §17.5)/timer/ready; bond strip + popups; left team panel with LP/status/emote bubbles, click to watch; bottom shop bar (level card, 3–5 chess cards, item card, funds, refresh, freeze, collapse); detail panel on click/hover (header with bonds, then 特质 — the operator's own effect — right under it, 特性, stats (live: the battle's own numbers / the start-of-battle ones in prep, §18.5), skill, module, equipped items, talents: §17.5); enemy preview drawer (next round enemies with counts, factions); 机变 overlay (the official card: framed icon + name, the effect text — data `descRaw` — under it, the taker's round avatar at the top-right; two taps, §18.2, §20.7); merge reward overlay; phase banners; ticker; toasts; emote wheel; settings (volume, quality, show damage numbers)), **Result** (victory/defeat, rounds, per-player lineup, stats, titles, back to room).
Interactions (research 09 §1.2 / §5, which supersede the first draft): shop card, upgrade and 机变 card are two-tap (select → confirm, §18.2); a press / drop goes by the tile under the pointer (§18.1); drag bench↔board — a legal board drop (also onto the piece's own tile) opens the 4-direction deploy wheel (swipe UP/RIGHT/DOWN/LEFT with the rotated range striped orange; release in the centre, ✕ 点击取消 or Esc cancels); tap a unit's tile ⇒ underframe with **出售 +N** (and **撤退** on the board; items/Arts in the hand / temp: **销毁** — equipped items are locked, §6.2) plus its rotated range; no drag-to-sell; drag an item onto a unit's tile ⇒ equip that unit (both slots used ⇒ the replace dialog `ui/equipReplace.js` shows the incoming and the two equipped items; the player picks the one to replace — it is destroyed — `g.equip replaceUid`; cancel changes nothing); Arts are dropped on a tile and get the direction step; right-click or long-press ⇒ detail; `R` refresh, `F` freeze, `D` level-up, `Space` ready (in a solo battle: pause / resume, §14 Solo pause). All actions optimistic-free: UI waits for server state (latency is small).
Interactions (research 09 §1.2 / §5, which supersede the first draft): shop card, upgrade and 机变 card are two-tap (select → confirm, §18.2) — a tap anywhere on the card, its confirm strip included, is the card's tap (shop cards have no ⓘ corner; the armed bar sits above the detail card; a shop card that cannot be bought right now opens its detail on tap, §20.7); a press / drop goes by the tile under the pointer (§18.1); drag bench↔board — a legal board drop (also onto the piece's own tile) opens the 4-direction deploy wheel (swipe UP/RIGHT/DOWN/LEFT with the rotated range striped orange; release in the centre, ✕ 点击取消 or Esc cancels); tap a unit's tile ⇒ underframe with **出售 +N** (and **撤退** on the board; items/Arts in the hand / temp: **销毁** — equipped items are locked, §6.2) plus its rotated range; no drag-to-sell; drag an item onto a unit's tile ⇒ equip that unit (both slots used ⇒ the replace dialog `ui/equipReplace.js` shows the incoming and the two equipped items; the player picks the one to replace — it is destroyed — `g.equip replaceUid`; cancel changes nothing); Arts are dropped on a tile and get the direction step; right-click or long-press ⇒ detail (or a tap on a shop card while the bar is locked); `R` refresh, `F` freeze, `D` level-up, `Space` ready (in a solo battle: pause / resume, §14 Solo pause). All actions optimistic-free: UI waits for server state (latency is small).
Audio: autochess BGM per phase, UI SFX (buy/sell/refresh/level/merge/ready/timer), per-unit attack/hit SFX (throttled; an impact plays for a hostile attack's phys / arts / true damage only, never an operator's skill-mode file for a normal attack, ≤ 2 copies of one battle sound — §18.4), boss music in R14/15.
@@ -517,28 +521,28 @@ Audio: autochess BGM per phase, UI SFX (buy/sell/refresh/level/merge/ready/timer
**Authority per field.**
- Normal field: the owning human's client is **authoritative** (if connected).
- Unite field (联防) and boss/hidden pair fields: the lowest-seat **connected human** in that field is authoritative; every other human in (or watching) that field receives the same spec and simulates it locally **for display only**.
- Bots, departed/disconnected players, fields with no connected human: the **server** simulates. Normal and 联防 fields headlessly (`fields.js HeadlessJob`, with a `[gt, killed, total]` progress timeline) in wall-clock slices of `headlessSliceMs` (8 ms) per callback on a real host — never one blocking run per field (3 bot fields ≈ 0.2–0.5 s of CPU at COMBAT start here, seconds on a mini PC); at once in virtual time; the result is released at the battle's natural end on the field clock (so round pacing and the teammates' progress look live). Boss fields in real time without snapshots (`HeadlessPacer`); when no field of a boss round has a connected human the whole round runs server-side as before (FieldRunner with streaming off).
- Bots, departed/disconnected players, fields with no connected human: the **server** simulates. Normal and 联防 fields headlessly (`fields.js HeadlessJob`, with a `[gt, killed, total(, left)]` progress timeline — `left`: the 联防 leakers' enemies still standing, §20.6) in wall-clock slices of `headlessSliceMs` (8 ms) per callback on a real host — never one blocking run per field (3 bot fields ≈ 0.2–0.5 s of CPU at COMBAT start here, seconds on a mini PC); at once in virtual time; the result is released at the battle's natural end on the field clock (so round pacing and the teammates' progress look live). Boss fields in real time without snapshots (`HeadlessPacer`); when no field of a boss round has a connected human the whole round runs server-side as before (FieldRunner with streaming off).
- Takeover: an authority that disconnects or leaves, or misses its deadline (`timeLimit / speed` real s + 15 s grace, normal/联防) loses the field: normal/联防 → the server re-simulates the spec from t = 0 (deterministic: the same result); boss → the partner's replica is promoted (`b.start authoritative: true`), else the server runs it, fast-forwarded to the field clock through a `CreditPool` that only credits damage beyond what the client already reported (the fast-forward is spread over the pacing intervals, ≤ 240 extra ticks each). A boss authority silent for 12 s is handed over the same way. The former authority gets `b.end { reason: 'takeover' }` and keeps displaying.
**Protocol (additions; `shared/protocol.js` is normative).**
- `battleId` = `<prefix>.<round>.<seq>.<fieldId>` (`Match._ccField`; `<prefix>` = the match seed in base 36 plus `-<matchNo>`, the room's match number the lobby passes as `opts.matchNo`; `<seq>` counts every battle of the match; the informational `.<fieldId>` is dropped when the id would exceed the protocol's 64 chars): unique for the whole match although field ids repeat (`n:<pid>` every round, `u`, `b1`/`b2` in both boss rounds), and across the matches of one room. The client runner keys its local battles by it (the same id again = the same battle, no rebuild) and the server matches reports by it, so a late `b.progress` / `b.result` of a finished battle — or one that crossed into the room's next match on the same socket — is ignored instead of being credited to another battle.
- S→C `b.start { battleId, fieldId, kind, spec, authoritative, startAt, serverNow, elapsed, speed, watch, done }` — sent at COMBAT/UNITE/FINAL_ASSAULT/HIDDEN start to every human in the field (联防: to every human; COMBAT and boss rounds: eliminated humans get the first field as a display replica — research 09 "keep-watching auto-observes the first available field"), on `g.watch`, and on reconnect/resync. `elapsed` = game seconds on the field clock (a finished field: its end time); the client fast-forwards to it. `speed` = game seconds per real second (2; tests/tools may speed up).
- C→S `b.progress { battleId, gt, killed, total, leaks?, bossDmg?, by?, done? }` — authoritative clients, ~1 Hz (boss fields 4 Hz). Normal/联防: `leaks` = counted leaks so far. Boss: `bossDmg` = cumulative damage of this field to the shared pool, `by` = the same per player, `leaks` = the field's cumulative LP cost (leaks × `lpr` + leader "扣除目标生命" effects; `spec.js attachLpMeter`). The server applies deltas (never twice). Stale reports (unknown battle, not the authority, after the match ended) are ignored, never answered with an error; the platform never answers a rid-less `b.progress` with an error frame (it would surface as a toast).
- C→S `b.progress { battleId, gt, killed, total, leaks?, left?, bossDmg?, by?, done? }` — authoritative clients, ~1 Hz (boss fields 4 Hz). Normal/联防: `leaks` = counted leaks so far; 联防: `left` = per leaker its enemies still standing (`spec.js uniteLeft`, clamped by the server to `fields.js uniteBillBounds`; §20.6). Boss: `bossDmg` = cumulative damage of this field to the shared pool, `by` = the same per player, `leaks` = the field's cumulative LP cost (leaks × `lpr` + leader "扣除目标生命" effects; `spec.js attachLpMeter`). The server applies deltas (never twice). Stale reports (unknown battle, not the authority, after the match ended) are ignored, never answered with an error; the platform never answers a rid-less `b.progress` with an error frame (it would surface as a toast).
- C→S `b.result { battleId, result }` — authoritative client at battle end; `result` = `compactResult(BattleResult)` (the fields the match consumes; ≤ 4 players, ≤ 400 leaks, ≤ 64 unitsEnd, ≤ 160 unitStats, ≤ 40 layer gains; `fitResult` keeps the frame ≤ 60 KB: unit statistics, then leak mods, then — boss only — leak entries go first). Structural checks in `isBattleResult`; `server/net.js` routes `b.*` frames to the match (lobby `routeGame`).
- S→C `b.pool { hp, max, teamLp, acked: { [fieldId]: cumulative damage counted } }` (boss rounds, ≤ 4 Hz; exact numbers, never rounded) — clients reconcile their `LocalBossPool`: displayed pool = server hp − (local cumulative damage − acked). A boss field whose client result is `'cleared'` while < 1 HP is left on the server (float dust of summing both fields) empties the pool.
- S→C `b.pool { hp, max, teamLp, acked: { [fieldId]: cumulative damage counted } }` (boss rounds, ≤ 4 Hz; exact numbers, never rounded) — clients reconcile their `LocalBossPool`: displayed pool = server hp − (local cumulative damage − acked). Pools never hold less than 1 HP (`BOSS_POOL_MIN_HP`: `SharedBossPool` and `LocalBossPool`; a browser pool reads any finite rest below 1 as 0 — §20.5); the 'cleared'-result dust rule (a boss field whose client result is `'cleared'` while < 1 HP is left on the server empties the pool) only catches noise at that boundary.
- S→C `b.end { battleId, fieldId, reason }` — `'cleared'` (pool 0) / `'forced'` (team LP 0): the client calls `battle.forceEnd` and reports; `'takeover'`: stop reporting, keep displaying.
- `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.
**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.
**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); 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`: 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.
**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.
**Spectating (research 09 §3.1 / §6.3).** No live spectating while your own normal battle runs (`g.watch` → `WRONG_PHASE 'own battle running'`; the UI toasts "作战中无法查看队友"). After your own battle ends you see "⌛ 作战结束,等待队友完成作战" with each teammate's live progress (`m.public.fields[].progress`); tapping a teammate's avatar expands **前往查看** → `g.watch` → `b.start` (display replica fast-forwarded to the field clock); the own row shows **返回战场**. Prep: the same flow shows the teammate's board (`m.field` with `prep: true`, unchanged). 联防: every human receives the unite spec (helpers + observers). Final Assault: each human sees its own pair field; the other pair → `BAD_TARGET 'other group hidden'`. In 联防/FA fields the ‹ › pill switches the camera LEFT half / 全景 / RIGHT half ("你自己" / "👁 name" / "全景" / "无人在家"); there is **no** view switcher during normal combat. Eliminated players start each combat phase on the first field (auto-observe) and may watch any field.
**Client runner.** `public/js/battle/runner.js` owns the local Battles: fixed-step clock at the battle speed (60 ticks per real second at 2×, at most `max(8, 4·speed)` ticks per frame, bounded fast-forward of ≤ 240 ticks per frame when far behind; a new battle is caught up silently before it is shown), feeds the existing renderer through the same `snapshot()`/`drainEvents()` wire formats every frame (`view.setLocalFeed` shrinks the interpolation buffer to ~2 frames), publishes the field meta (m.field shape + `local`, `battleId`, `players`, `sides`, `speed`) into `store.match.field` and its state into `store.match.battle` (with `leaks: { [fieldId]: counted leaks so far }` of every normal field it simulates, authoritative or display replica — the settle rule's count, `/sim/spec.js battleProgress`; published in the frame a count changes: the live LP of §17.5), sends `b.progress`/`b.result` when authoritative (results retried once on timeout; a result lost with the socket — the request failed `DISCONNECTED` / `OFFLINE` or timed out twice — is kept and sent again when the session is back online and whenever a `b.start` still names that finished battle `authoritative: true`; the server answers a duplicate `ok` and changes nothing; a server refusal is final), follows `m.public.paused` (solo pause: every local clock stands still, resume shifts it by the paused time), handles `b.pool`/`b.end`, fast-forwards on reconnect/observe, and survives tab backgrounding (a 250 ms interval pump keeps authoritative battles on their clock while hidden; catch-up when visible again; the server deadline + takeover cover extreme cases). Sim content errors are logged as console warnings (the server logs its own). The sim loader fetches the ten data files (one retry each) and fails when any is missing — a battle on partial data would report a plausible wrong result — so the next `b.start` retries and the server's deadline covers the field meanwhile. `public/js/battle/observe.js` holds the observing rules and labels.
**Client runner.** `public/js/battle/runner.js` owns the local Battles: fixed-step clock at the battle speed (60 ticks per real second at 2×, at most `max(8, 4·speed)` ticks per frame, bounded fast-forward of ≤ 240 ticks per frame when far behind; a new battle is caught up silently before it is shown), feeds the existing renderer through the same `snapshot()`/`drainEvents()` wire formats every frame (`view.setLocalFeed` shrinks the interpolation buffer to ~2 frames), publishes the field meta (m.field shape + `local`, `battleId`, `players`, `sides`, `speed`) into `store.match.field` and its state into `store.match.battle` (with `leaks: { [fieldId]: counted leaks so far }` of every normal field it simulates and, while the 联防 field is on screen, `uniteLeft` per leaker (§20.6), authoritative or display replica — the settle rule's count, `/sim/spec.js battleProgress`; published in the frame a count changes: the live LP of §17.5), sends `b.progress`/`b.result` when authoritative (results retried once on timeout; a result lost with the socket — the request failed `DISCONNECTED` / `OFFLINE` or timed out twice — is kept and sent again when the session is back online and whenever a `b.start` still names that finished battle `authoritative: true`; the server answers a duplicate `ok` and changes nothing; a server refusal is final), follows `m.public.paused` (solo pause: every local clock stands still, resume shifts it by the paused time), handles `b.pool`/`b.end`, fast-forwards on reconnect/observe, and survives tab backgrounding (a 250 ms interval pump keeps authoritative battles on their clock while hidden; catch-up when visible again; the server deadline + takeover cover extreme cases). Sim content errors are logged as console warnings (the server logs its own). The sim loader fetches the ten data files (one retry each) and fails when any is missing — a battle on partial data would report a plausible wrong result — so the next `b.start` retries and the server's deadline covers the field meanwhile. `public/js/battle/observe.js` holds the observing rules and labels.
**Corrections from research 08/09 (normative):**
- Waves: official generator (research 08 §2): 3 of 6 types, each owns exactly 3 of the 15 round slots, SPECIAL the rest (shuffled per match); per round one type + one weighted special entry (half by round); each template SPAWN **action** becomes `clamp(roundHalfEven(n·P(t)/P(new)), 1, 5)` units over the same window; placeholder actions of the other movement class are skipped. Stage per match from the mode's list (标准 = 战场#01 only, §6.5). No custom balance multipliers (`data/tuning.json` keeps only titles); co-op boss pool = `bloodPoint[difficulty]` unscaled.
@@ -606,7 +610,7 @@ v2.2 answered "a click selected the unit one row lower" with body picking: every
### 17.5 Game HUD (#2, #3, #8, #9) — `ui/hud.js`, `teamPanel.js`, `underframe.js`, `detailPanel.js`, `data.js`
- **Live LP (#2)**: in a normal round the top bar's LP tower and the own team row show `lp − min(lpCapPerRound, counted leaks)` as soon as the own battle's enemies enter the blue gate — the number in red followed by a −N plate that pops again on every leak, clamped at 0, with a tooltip (`ui/hud.js liveLp` / `pendingLoss` / `pendingTip`). The count is `ownLeaks(local, server)` = the larger of the local runner's `state().leaks[own field]` (§14) and `m.public players[].pendingLp` (§8.2; the authority's `b.progress` leaks, the field's result, the recorded result in 联防) — both only grow within a round, so a paused display replica never freezes the number. The pending part is dropped as soon as the settled `m.private` lands (its `lp` or `stats.leaks` changed; `Match.flush` sends it before the SETTLE `m.public`), the phase leaves COMBAT / 联防 or the round changes — never subtracted twice. 联防: the own battle's count stays on show as an upper bound tagged 联防中 (teammates may still stop part of it; the settled loss can be lower). Teammates' rows use their `pendingLp` (`teamPanel.js rowLp`); server-run fields (bots, taken-over fields) report none before their result is released. Boss rounds are unchanged (the team LP moves live through `b.pool`).
- **Live LP (#2)**: in a normal round the top bar's LP tower and the own team row show `lp − min(lpCapPerRound, counted leaks)` as soon as the own battle's enemies enter the blue gate — the number in red followed by a −N plate that pops again on every leak, clamped at 0, with a tooltip (`ui/hud.js liveLp` / `pendingLoss` / `pendingTip`). The count is `ownLeaks(local, server)` = the larger of the local runner's `state().leaks[own field]` (§14) and `m.public players[].pendingLp` (§8.2; the authority's `b.progress` leaks, the field's result, the recorded result in 联防) — both only grow within a round, so a paused display replica never freezes the number. The pending part is dropped as soon as the settled `m.private` lands (its `lp` or `stats.leaks` changed; `Match.flush` sends it before the SETTLE `m.public`), the phase leaves COMBAT / 联防 or the round changes — never subtracted twice. 联防 (**revised by §20.6**, user playtest #6 item 7): a leaker's count is its enemies still standing — the local replica's while the 联防 field is on screen, else m.public `uniteLeft` — shown uncapped as ×N in the capsule and in the leakers' team rows, and the LP tower shows lp − min(10, N), tagged 联防中. Teammates' rows use their `pendingLp` (`teamPanel.js rowLp`); server-run fields (bots, taken-over fields) report none before their result is released. Boss rounds are unchanged (the team LP moves live through `b.pool`).
- **Temp row (#3, UI)**: while `m.private temp` holds pieces during the own prep, a dashed red frame surrounds the 临时整备区 row on the board and a label says how many are waiting, what to do and when they go (`underframe.js TempRowNotice` / `tempRowFrame`, placed through `view.tileScreen` — it follows the camera, the mirrored Final Assault prep and the DOM fallback; no pointer events; the label hides while dragging or placing). Its wording follows the server rule (§6.2, `tempRowRule(ready)`): not ready — "放入整备区或战场、配发或使用后才能准备;休整期结束时仍在此处的将被销毁"; ready in PREP (so whatever lies there arrived after Ready) — "已准备就绪后进入的单位保留到下个休整期,届时仍在此处的将被销毁(取消准备则在本休整期结束时销毁)". The 准备就绪 button shows "临时整备区 N 个单位待处理" under it (not only on hover; the effects column moves down a line meanwhile, `.gm.has-readywhy`) and Space toasts the reason (`hud.js TEMP_RULE` / `tempReadyReason`).
- **Detail card order (#8)** (`detailPanel.js CHESS_SECTIONS`): header (portrait, name, tier, class, bonds) → **特质** (the operator's own effect — garrison trigger chip such as 休整期结束时 + description, a compact gold block, visible without scrolling at 1920×1080, 1280×720, iPhone 14 and 640×360) → 特性 (class trait) → stats (a compact 4×2 grid) + attack range → 技能 → 模组 → 装备 → 天赋. On phones ≤ 460 px tall the portrait is shorter and the romanised name is hidden.
- **Busy indicators and texts (#9)**: every text of the game (operators, skills, bonds, items, enemies, 特质 …) is static data, `/data/*.json` loaded once per page and never fetched during a match; the "spinners" the player saw were busy indicators of requests in flight next to texts — and the 特质 chip's type icon: it was picked by the trigger, so every <休整期开始时 / 结束时> 特质 showed the spoked 特异化 glyph (it reads like a loading spinner) before its text; the chip now shows the garrison's own official `eventTypeIcon` (`detailPanel.js garrisonTypeIconKey`: crossed swords 作战能力, gold 整备能力, chain link 持续 / 单次叠加, spokes only for 特异化). Now: no `cursor: wait / progress` anywhere; a loading `Button` keeps its icon and shows a thin sweeping bar along its bottom edge after 250 ms (no spinner ring); a 机变 card in flight shows a 选择中 strip until the pick appears in `m.public` (`choiceOverlay.js pickBusy`); the 作战结束,等待队友完成作战 hourglass is still; the hexagon `Spinner` is kept for genuine loading screens only (entering the match, the field view, connecting, guide images, the first 干员调配 load, which says the data loads once per page). `main.js warmGameData` downloads every in-match file (`gameComponents GAME_FILES`) in the background once the player is in a room, and the match screen waits for them; `data.js` retries a transient failure (network error, HTTP 5xx / 408 / 429) after 600 ms and 2 s (`RETRY_DELAYS_MS`) — a 404 or invalid JSON is final.
@@ -630,7 +634,7 @@ Thirteen reports after playing v2.2. Where each is handled: #1 picking should si
### 18.2 Confirms and timers (#2, #3, #4) — `ui/choiceOverlay.js`, `server/match/Match.js`, `screens/bandDraft.js`
- **机变 cards take two taps** (like the shop's cards): the stateful `ChoiceOverlay` wraps the pure `ChoiceView`. A first tap on a pickable card (bounty = extra enemies, 道具补给 / 机密商店 items, tactics) only selects it — gold frame, a 确认选择 · 再次点击 strip, the other cards dimmed, 确认选择 in the header; a second tap on it, or 确认选择, sends `g.choice`. Another card moves the selection; Esc or a press elsewhere drops it; so do the card being taken, the turn moving on and a pick in flight. Cards stay buttons (Tab / Enter). The other picks were already two-step (晋升奖励 in the shop bar, the strategy draft's 确认选择, 确认替换).
- **机变 cards take two taps** (like the shop's cards): the stateful `ChoiceOverlay` wraps the pure `ChoiceView`. A first tap on a pickable card (bounty = extra enemies, 道具补给 / 机密商店 items, tactics) only selects it — gold frame, a 确认选择 · 再次点击 strip, the other cards dimmed, 确认选择 in the header; a second tap on it, or 确认选择, sends `g.choice`. Another card moves the selection; Esc or a press elsewhere drops it; so do the card being taken, the turn moving on and a pick in flight. Cards stay buttons (Tab / Enter). Each card shows its full effect text (§20.7). The other picks were already two-step (晋升奖励 in the shop bar, the strategy draft's 确认选择, 确认替换).
- **A single human is untimed** (`Match.loneHuman`: exactly one human seat at the start → `Match.soloUntimed`): 独立模拟 and a 同盟 room started alone or with AI teammates only publish no INFO_CHECK / BAND_DRAFT / SP_DRAFT / PREP deadline, and BATTLE_CHECK / ROUND_START / SETTLE run silently — the timers only ever made humans wait on each other. The co-op rules stay (shuffled draft order and skip, 6 机变 cards, 联防); pause stays solo-only; a disconnected lone human's match waits (the co-op reconnect window then ends it as abandoned). `Countdown` (`ui/components.js`) renders nothing without a deadline (it used to draw a `--` COUNTDOWN placeholder that only a CSS rule hid).
- **The strategy draft has ONE countdown**: every turn lasts `Match.BAND_TURN_SECONDS` = 30 s [ASSUMED] (× timerScale; `data/config.json timers.bandTurn` says the same — the official data only has the whole BAND_CHECK step, 50 s, now informational) and `m.public.deadline` IS the current turn's end (= `draft.turnDeadline`; `draft.turnSeconds` = the turn length, 0 untimed), so the step header and the picker's row show the same number. No step cap: the turns bound the step (≤ (seats + skips) × 30 s). AI seats pick at once. The draft screen reports its highlighted strategy (`g.bandFocus`); a turn that runs out takes it while it is allowed and free (`Match.timeoutBand`), else 华法琳 (`band_bldsk`), else the first free strategy (`defaultBand`, 队友已选 never). The selection starts on that default and the tip always names what a timeout gives ("每位博士有 30 秒,超时将自动选择当前选中的「X」"; untimed: "本局不限时"). `server/match/audit.js` checks it (deadline = the turn's, 30 s; nothing timed for a single human).
- Tests: `test/ui/playtest4.test.js`, `test/ui/bandDraft.test.js`, `test/match/draft.test.js`, `test/ui/gameLogic.test.js` (`phaseTotalSeconds` = the turn), `test/match/hardening.test.js` (the audit), `test/ui/mock.e2e.test.js` (two taps). Browser tests that need a timed co-op briefing now seat two humans (`test/ui/loadout.e2e.test.js`); a single human's 机变 is picked by the test itself (`test/ui/playtest2.real.e2e.test.js`), and the real-server drafts wait for BAND_DRAFT before picking (a client left in the briefing let its 30 s turns run out).
@@ -639,7 +643,7 @@ Thirteen reports after playing v2.2. Where each is handled: #1 picking should si
- **Knocked out, not gone**: `Battle.snapshot()` lists `down: [[id, respawnAt, respawnTime, state]]` (§8.2) for operators knocked out (reason `'killed'`, not withdrawn — or `FORCED_EXIT`, entering 联防 knocked out, §19.3), deployed at least once and with a finite respawn timer; `state` = sim/constants.js `DOWN_STATE` (0 counting, 1 timer done but DP < cost, 2 timer done but the home tile is taken — the official auto-redeploy rule, research 01 §4.3). `Battle.isDown(u)`; `fieldMeta()` lists them too, so a client joining mid-battle (reconnect, observer, replica) shows them. The renderer (`interp.js downAt`, `app.js syncBattle`, `units.js setDown`) keeps such an operator's view on its tile: its Spine Die clip plays once and holds its last frame (a collapsed / kneeling pose), lightly greyed (tint 0xb4b4b4, alpha 0.92), under a **redeploy ring** above the head — a dark disc with a mint arc filling as the timer runs and the seconds left (game s); timer done: a full pulsing amber ring "DP" (waiting for DP) or red "!" (its tile taken). The `deploy` event plays the Start clip and restores the normal look; a view created for an operator already down starts on the held pose; leaving the list without a redeploy fades it out. Summons, devices and enemies vanish as before. [ASSUMED] look: the official knocked-down / ring art is in no extracted autochess bundle.
- **Element gauges, official rules** (PRTS 元素 / 术语释义; `damage.js`): gain × `elemTakenMul` × (1 − 损伤抵抗 / 100), 损伤抵抗 = data `epResistance` (EP_RESISTANCE; it used to read `epDamageResistance` as a fraction). A burst's **爆发冷却** (= its duration: 灼燃 / 神经 / 侵蚀 10 s — enemies' 侵蚀 8 s — 凋亡 15 s; the operators' 侵蚀 had none) locks EVERY element of the unit — nothing fills or recovers (`reduceElement`) — and its end resets EVERY gauge (`burstLocked(unit)` takes no element). 神经 applies the stun (operators) / 3 麻痹 (enemies) before its damage, 侵蚀 its DEF cut before its damage; the operators' 凋亡 burst holds 阻回 (`noSp`: `skills.js gainSp` refuses SP of every kind — time, attack, hurt, granted) + 静默, −1 SP/s and 100 arts/s.
- **The gauge on screen** (official display: one icon, the "当前损伤元素"): `damage.js elementView(u, now)` → b.snap `elem` (§8.2): the fullest gauge, ties by the official element id (`constants.js ELEMENT_ORDER`: 神经 < 侵蚀 < 灼燃 < 凋亡); during a 爆发冷却 the bursting element, refilling over the cooldown. `interp.js` appends it to the unit's tuple; `units.js` draws it right of the HP / SP bars — operators: the element's disc with its glyph; enemies: a smaller plain disc of the element colour (official rule) — with a white ring for the remaining 元素值 (1 − fill). One procedural 512×576 atlas (`textures.js hudRings` / `ringArc`, 49 arc frames and the discs), sprites made on first use and hidden when idle. The burst's lock status (`burnBurst`, `neuralBurst` …) is not repeated in the status row while the element icon shows it (only a feed without `elem`, e.g. an older recording, shows it there). [ASSUMED] placement (the official draws it under the model, the remake's bars are above the head) and size (0.3 tile, enemies ×0.8).
- **The gauge on screen** (official display: one icon, the "当前损伤元素"): `damage.js elementView(u, now)` → b.snap `elem` (§8.2): the fullest gauge, ties by the official element id (`constants.js ELEMENT_ORDER`: 神经 < 侵蚀 < 灼燃 < 凋亡); during a 爆发冷却 the bursting element, refilling over the cooldown. `interp.js` appends it to the unit's tuple; `units.js` draws it — **superseded by §20.8**: a row under the unit's own HP / SP bars, the element disc (0.15 tile, 8–15 px; operators with the glyph, enemies ×0.8 plain colour) and a white bar of the remaining 元素值 (1 − fill), refilling in the element's colour with a pulsing disc during the 爆发冷却 (v2.3 drew a white ring right of the bars; the procedural atlas `textures.js hudRings` keeps the discs and the redeploy ring), sprites made on first use and hidden when idle. The burst's lock status (`burnBurst`, `neuralBurst` …) is not repeated in the status row while the element icon shows it (only a feed without `elem`, e.g. an older recording, shows it there). [ASSUMED] placement (the official draws it under the model, the remake's bars are above the head) and look (§20.8).
- Recordings (`tools/record-battle.mjs`) carry both keys, so the render demo shows them. Tests: `test/sim/downelem.test.js`, `test/sim/combat.test.js`, `test/render/downring.test.js`, `test/render/interp.test.js`, `test/render/downelem.browser.test.js`.
### 18.4 Battle sounds (#6) — `public/js/audio.js`, `tools/assets/audio.mjs`
@@ -658,9 +662,9 @@ Thirteen reports after playing v2.2. Where each is handled: #1 picking should si
### 18.6 Shop-only items, drones, summons, 炎佑 (#5, #10, #11, #12) — `tools/build-data.mjs`, `sim/simdata.js`, `sim/content/*`
- **#5 not sold in the shop**: the official `trapShopChessDatas` has `hideInShop` false for all 56 normal items, so an explicit sourced list (`tools/build-data.mjs SHOP_EXCLUDED_ITEMS` → items.json `shopExcluded` / `shopExcludedBy`) marks the effect-only items: 战栗 / 坚固 / 加速 / 灼燃维式重锤 (the 维多利亚 bond every 25 layers, 洛洛's 定制品) and 突变细胞 (the 昆图斯 strategy 不稳定要素; user playtest #4 first-hand). `sim/simdata.js isShopItem` is the one predicate for every shop-item draw — the shop's item slot, 道具补给 / 机密商店 cards, `pool_equip_normal` / `_shop_1` / `_kathe` / `_narant`, `Match.rollItemId` — so 51 normal items are sold (the plain 维式重锤 still is). 洛洛's 定制品 (`pool_equip_rockr`) = the same 4 special hammers as `pool_equip_vict`, uniform [ASSUMED]. The item card says 调度中心不出售 · 获取途径:… (`detailPanel.js ItemDetail`). Other strategy-granted items stay on sale (no source says otherwise; 变形同构体 / 骑士储蓄罐 / 盟约之币 appear in an official 机密商店 screenshot).
- **#10 drones** (stats and ranges = enemy_database level 0, re-derived by `test/data.test.js`; behaviour per PRTS): 暴鸰 makes no normal attack — one bomb on its target and the 8 tiles around it (100 % ATK, splash ignores camouflage), then its speed ×2 (final); 帝国炮火先兆者 / 中枢先兆者 fire a shell per attack that lands 3 s later at the target's spot and hits every ally within 1.2 for 100 % of the ATK at launch, with no source (`content/enemies.js kitShell`; new `ai.js enemyAttack` hooks `profile.deferHit` — content resolves the hit in its `attack` handler — and `profile.shot`, the `'atk'` kind: `'mortar'` draws no projectile; fx `bombardShell`); 假想敌:黑云 grabs within `range_radius` × 2.5 (3.75) at most 3 flyers (nearest first [ASSUMED], 0.5 s delay [ASSUMED]), both held 4 s, ammo capped by enemies.json `sp.maxSp` (3; new `sp` from enemy_database spData), 全弹发射 = one 130 % hit per ammo on random allies anywhere on the field; “萨科塔之眼” never attacks flying allies; 萨科塔 self-fear speed final ×1.5. Unchanged (already right): 妖怪 / 妖怪MKII / 威龙 / 寒霜 / 法术大师A1·A2 / 御4 / 护障 / 护障·P / 远眺.
- **#11 skill summons are not prep pieces**: tokens.json `placeable` = `tokenDisplayType` DEFAULT **and** made by a talent (海嗣, 狼群, 流形). 赫默's 医疗探机 and 巫恋's 诅咒娃娃 appear only when the skill fires (their kits place them); `PlayerState.grantTokensFor` just grants the placeable ones.
- **#12 炎佑** (PRTS “炎佑” 级别0(卫戍协议), `content/tokens.js yanyouKit`): it flies after the highest-aggro targetable enemy of the whole field (operator order) and hovers 0.25 tile [ASSUMED] from it (a second 炎佑 of 9 炎 0.8 row beside the first); **with no enemy on the field it stays where it is** (user playtest #4; PRTS says it then tracks a random tile). 祛恶之焰 (模式乙): fires like a normal attack when its 15 s cooldown is full, locks that attack's first target and channels up to 20 s — each second (the first at once) 60 % ATK arts to every enemy within 1.0 of the target, no normal attacks meanwhile — ending when the target is gone or 炎佑 is silenced (inferred); the cooldown then runs again [ASSUMED]. Every damage it deals adds burn = 20 % ATK (flame ticks included, official 下半 notice); 元素脆弱 aura of radius 1.5 around itself; it ignores element damage; ATK / HP = the template 600 / 12000 **plus** 30 % of the 炎 operators' sums (PRTS 最终加算; the 9-炎 ×1.5 on the whole ATK [ASSUMED]). The flame is drawn as one continuous jet (`render/fx.js _flame`): the sim emits fx `yanyouFlame {x, y, id: dragon, target, r, n, dur: 1}` once per game second, and each tick re-aims and extends the dragon's jet — a stream of fire from the dragon onto its locked target with a burning disc of radius `r` following the target — which burns `dur` + 0.2 s and goes out when no tick comes or the dragon is gone.
- **#10 drones** (stats and ranges = enemy_database level 0, re-derived by `test/data.test.js`; behaviour per PRTS): 暴鸰 makes no normal attack — one bomb on its target and the 8 tiles around it (100 % ATK, splash ignores camouflage), then its speed ×2 (final); 帝国炮火先兆者 / 中枢先兆者 fire a shell per attack that lands 3 s later at the target's spot and hits every ally within 1.2 for 100 % of the ATK at launch, with no source (`content/enemies.js kitShell`; new `ai.js enemyAttack` hooks `profile.deferHit` — content resolves the hit in its `attack` handler — and `profile.shot`, the `'atk'` kind: `'mortar'` draws no projectile; fx `bombardShell`); 假想敌:黑云 grabs within `range_radius` × 2.5 (3.75) at most 3 flyers (nearest first [ASSUMED], 0.5 s delay [ASSUMED]), both held 4 s, ammo capped by enemies.json `sp.maxSp` (3; new `sp` from enemy_database spData), 全弹发射 = one 130 % hit per ammo on random allies anywhere on the field; “萨科塔之眼” never attacks flying allies; 萨科塔 self-fear speed final ×1.5, while it flutters inside its tile (§20.4). Unchanged (already right): 妖怪 / 妖怪MKII / 威龙 / 寒霜 / 法术大师A1·A2 / 御4 / 护障 / 护障·P / 远眺 (威龙's drawn size was fixed in §20.4).
- **#11 skill summons are not prep pieces** — **superseded by §20.1**: v2.3 limited tokens.json `placeable` to `tokenDisplayType` DEFAULT summons made by a talent (海嗣, 狼群, 流形), so 赫默's 医疗探机 and 巫恋's 诅咒娃娃 appeared only when the skill fired. Since user playtest #6 every manually deployable summon is a hand piece again, and its placed piece deploys once at the battle start (PRTS), then with each cast.
- **#12 炎佑** (PRTS “炎佑” 级别0(卫戍协议), `content/tokens.js yanyouKit`; 孤立 — never healed or selected by allies, §20.3): it flies after the highest-aggro targetable enemy of the whole field (operator order) and hovers 0.25 tile [ASSUMED] from it (a second 炎佑 of 9 炎 0.8 row beside the first); **with no enemy on the field it stays where it is** (user playtest #4; PRTS says it then tracks a random tile). 祛恶之焰 (模式乙): fires like a normal attack when its 15 s cooldown is full, locks that attack's first target and channels up to 20 s — each second (the first at once) 60 % ATK arts to every enemy within 1.0 of the target, no normal attacks meanwhile — ending when the target is gone or 炎佑 is silenced (inferred); the cooldown then runs again [ASSUMED]. Every damage it deals adds burn = 20 % ATK (flame ticks included, official 下半 notice); 元素脆弱 aura of radius 1.5 around itself; it ignores element damage; ATK / HP = the template 600 / 12000 **plus** 30 % of the 炎 operators' sums (PRTS 最终加算; the 9-炎 ×1.5 on the whole ATK [ASSUMED]). The flame is drawn as one continuous jet (`render/fx.js _flame`): the sim emits fx `yanyouFlame {x, y, id: dragon, target, r, n, dur: 1}` once per game second, and each tick re-aims and extends the dragon's jet — a stream of fire from the dragon onto its locked target with a burning disc of radius `r` following the target — which burns `dur` + 0.2 s and goes out when no tick comes or the dragon is gone.
- Tests: `test/content/playtest4_summons.test.js`, `enemies_bosses.test.js`, `tokens_devices.test.js`, `bonds_core.test.js`, `choices.test.js`, `test/data.test.js`, `test/match/pool.test.js`, `test/render/fxflame.test.js`, `test/ui/playtest4.test.js` (item card).
### 18.7 The occasional giant UI flash (#13) — `render/fx.js pop`
@@ -671,16 +675,16 @@ The first in-battle layer gain of each bond (b.ev `'layer'`) pops that bond's ic
## 19. User playtest #5 (v2.4): hovering enemies, target order, blocking, 联防, elements, huge bosses, maps, phone HUD
Ten reports after playing v2.3 (绝境 on desktop, 标准 on an Android phone in landscape). Where each is handled: #1 operators that only attack ground units hit 近地悬浮 enemies → §19.1; #2 an operator lying knocked out (濒死) vanished when 联防 began instead of counting down and redeploying → §19.3; #3 element damage against the 海嗣 felt weak, audit every operator's and enemy's element amounts and gauges → §19.5 (enemies, the gauge pipeline) and §19.6 (the player side); #4 an enemy held between two operators walked off when the rear one died instead of the front one taking it over → §19.2; #5 the enemies' target order differed from the official → §19.1; #6 act2 m01's normal field had its two wind lanes but no blower machines, which showed in the boss round instead → §19.7; #7 the tiles framed by the 1×3 pipes on the boss map are deployable officially, check every map → §19.7; #8 the bottom-left settings icon was deformed → §19.8; #9 on a long phone screen the shop bar covered the bench → §19.8; #10 the huge bosses could only be hit around one tile → §19.4. A follow-up message added #11 至简 should cost 1 (his 特质 says 购买价格为1) → §19.9. Normative lines rewritten with it: §3 range test, §5.1 `carryState`, §5.5 (initial deployment, DP / down, blocking, operator attacks, enemy AI, damage), §6.1 UNITE, §7 bosses; §18.3's `isDown` now includes `FORCED_EXIT`, and §18.1's notched-phone figures are superseded by §19.8. The integration also applied the workstreams' cross-checks (hasHp in the pipeline, 脆弱 vs 元素伤害, element healing, 淤困, 塑心 S2, splash as a 中点判定); they are listed in the sections they belong to. Three QA passes over the integrated branch then left residuals, fixed in their sections: 掠海漂移体's model crawls after its drop (§19.1); blocked-first and out-of-range selection are melee-only (§19.2); a forced-out operator's timer takes the battle-start redeploy effects (§19.3); the gauge intake's 5 % floor (§19.5); the fallback board draws the boss half in the boss prep (§19.7). The user's follow-up then settled two [ASSUMED] choices: the boss field's row-6 blowers do stand under the bench in the normal views (§19.7), and a forced-out operator's redeploy timer does restart in full (§19.3).
Ten reports after playing v2.3 (绝境 on desktop, 标准 on an Android phone in landscape). Where each is handled: #1 operators that only attack ground units hit 近地悬浮 enemies → §19.1; #2 an operator lying knocked out (濒死) vanished when 联防 began instead of counting down and redeploying → §19.3; #3 element damage against the 海嗣 felt weak, audit every operator's and enemy's element amounts and gauges → §19.5 (enemies, the gauge pipeline) and §19.6 (the player side); #4 an enemy held between two operators walked off when the rear one died instead of the front one taking it over → §19.2; #5 the enemies' target order differed from the official → §19.1; #6 act2 m01's normal field had its two wind lanes but no blower machines, which showed in the boss round instead → §19.7; #7 the tiles framed by the 1×3 pipes on the boss map are deployable officially, check every map → §19.7; #8 the bottom-left settings icon was deformed → §19.8; #9 on a long phone screen the shop bar covered the bench → §19.8; #10 the huge bosses could only be hit around one tile → §19.4. A follow-up message added #11 至简 should cost 1 (his 特质 says 购买价格为1) → §19.9. Normative lines rewritten with it: §3 range test, §5.1 `carryState`, §5.5 (initial deployment, DP / down, blocking, operator attacks, enemy AI, damage), §6.1 UNITE, §7 bosses; §18.3's `isDown` now includes `FORCED_EXIT`, and §18.1's notched-phone figures are superseded by §19.8. The integration also applied the workstreams' cross-checks (hasHp in the pipeline, 脆弱 vs 元素伤害, element healing, 淤困, 塑心 S2, splash as a 中点判定); they are listed in the sections they belong to. Three QA passes over the integrated branch then left residuals, fixed in their sections: 掠海漂移体's model crawls after its drop (§19.1); blocked-first and out-of-range selection are melee-only (§19.2 — superseded by §20.3); a forced-out operator's timer takes the battle-start redeploy effects (§19.3); the gauge intake's 5 % floor (§19.5); the fallback board draws the boss half in the boss prep (§19.7). The user's follow-up then settled two [ASSUMED] choices: the boss field's row-6 blowers do stand under the bench in the normal views (§19.7), and a forced-out operator's redeploy timer does restart in full (§19.3).
### 19.1 Hovering enemies and the enemies' target order (#1, #5) — `sim/units.js`, `targeting.js`, `ai.js`, `Battle.js`, `content/enemies.js`, `bosses.js`, kits, `match/bot.js`, `render/units.js FORMS`
- **#1 cause**: `float()` only cancelled hits from non-`'ranged'` profiles and otherwise left the floater a ground unit, so 迷迭香 (攻击对小范围的地面敌人) shot and splashed it (5.2k damage in a sim run) and terrain, traps and 浮空 treated it as ground. **Official** (ba.float "无法被阻挡或近战攻击"; PRTS 术语释义 / 行动方式): the unit is unblocked and counts as a real air unit (算作空中单位) that keeps ground pathing.
- **Now** `Unit.isFlying` is the one air-unit test: `motion` FLY, or an enemy flag `float` (近地悬浮) or `levitate` (浮空); pathing, displacement tiles and 诱导 read `motion`. canHitFly / groundOnly, splash, chain, the kits' "地面敌人" filters, ground terrain and traps skip air units; 失衡免疫 is the flag `noDisplace`; 浮空 is refused only on data flyers and already-levitated units (PRTS 行动方式), so 阿罗玛 can lift a floater. Blocking follows (§19.2): only blockFly units block air units.
- **Enemy kits**: 掠海漂移体 — 晕眩 / 冻结 / 沉睡 → 爬行模式 for good (0.5 s stun), then it only attacks its blocker; 不会攻击飞行单位. Its model follows (integration QA: melee operators hitting an enemy that still looked airborne): the sim's fx `phase` `crawl` switches the view to the skeleton's crawl clips (`*_02`, after its `Change` clip) — `render/units.js FORMS`, `spine.js setForm`, kept on the unit info for a view built later (a reload mid-battle replays no events: such a view hovers until then). 吉兆飞鳞 — 晕眩 / 沉睡 → 8 s 晕眩模式 (it stays down while still asleep), a freeze grounds it when the freeze ends, its sprint is the final ×3. Of the 32 official floaters only these two appear in this mode (the bot's `HOVER_KEYS`; `fieldModel` counts them as air routes on ground paths).
- **Skill effects** (per PRTS 备注; the official per-ability targetMotion is not in the data): ground-only — 隐德来希 S2 血镰, 归溟幽灵鲨 拥抱自我, 琳琅诗怀雅 S3, 乌尔比安 S1 [ASSUMED: a 捕网 like 雪雉's]; PRTS 可对空 — 德克萨斯 S2, 忍冬 S2, 焰尾 S2, 缄默德克萨斯 S3, 凛御银灰 S2, 玛恩纳 S3; no 对空 note, [ASSUMED] to hit air — 风丸 折纸生花, 乌尔比安 S3, 缄默德克萨斯 S2, 余 S2, 见行者 S2, 耀骑士临光 不畏苦暗 and the token appear bursts (“耀阳”, 沙之碑, 迷迭香的战术装备's stun, 纸偶). A stun / freeze / sleep from any of them drops a hovering 掠海漂移体.
- **Skill effects** (per PRTS 备注; the official per-ability targetMotion is not in the data): ground-only — 隐德来希 S2 血镰, 归溟幽灵鲨 拥抱自我, 琳琅诗怀雅 S3, 乌尔比安 S1 [ASSUMED: a 捕网 like 雪雉's]; PRTS 可对空 — 德克萨斯 S2, 忍冬 S2, 焰尾 S2, 缄默德克萨斯 S3, 凛御银灰 S2, 玛恩纳 S3, 锏 S3 归于宁静 (added by §20.3); no 对空 note, [ASSUMED] to hit air — 风丸 折纸生花, 乌尔比安 S3, 缄默德克萨斯 S2, 余 S2, 见行者 S2, 耀骑士临光 不畏苦暗 and the token appear bursts (“耀阳”, 沙之碑, 迷迭香的战术装备's stun, 纸偶). A stun / freeze / sleep from any of them drops a hovering 掠海漂移体.
- **#5 official**: PRTS 索敌 (敌方) "阻挡→特殊优先级→仇恨值(更容易被攻击→…→最后部署的目标→不容易被攻击)→最早出现"; PRTS 卫戍协议/帮助 "按从上到下>从左到右的顺序部署。优先部署干员,随后为召唤物", the right-hand boss player mirrored.
- **#5 changes**: `Battle.start` deploys operators row-major, then summons; `aggroSeq` ranks every start-of-battle summon after all operators (`deploySeq` stays the deployment identity). An enemy's own rule `e.profile.canTarget` filters its candidates before the sort (萨卡兹枯朽战车 no longer idles when its preferred target stands on high ground; it, 掠海漂移体 and “萨科塔之眼” ignore flying allies). Special priorities break ties by taunt, then latest deployed (`targeting.js aggroCmp`): 铳 / 昆图斯 highest DEF, 胄 highest / lowest ATK, the 碎铳之簧 bounce, 唱沙 / 沙狱 max HP, “自在” nearest. Kept (PRTS 作战机制 §隐匿 "我方干员并不会因为阻挡而解除隐匿"; 索敌的概念): a stealthed ally (隐匿 / 迷彩 / 排气格栅) is attacked only by the enemy it blocks. [ASSUMED] "从上到下>从左到右" = row-major; on a shared field (联防, boss) the players' i-th operators deploy together, then the summons (it used to be player after player, which made the leader prefer the later player's pieces).
- **#5 changes**: `Battle.start` deploys operators row-major, then summons; `aggroSeq` ranks every start-of-battle summon after all operators (`deploySeq` stays the deployment identity). An enemy's own rule `e.profile.canTarget` filters its candidates before the sort (萨卡兹枯朽战车 no longer idles when its preferred target stands on high ground; it, 掠海漂移体 and “萨科塔之眼” ignore flying allies). Special priorities break ties by taunt, then latest deployed (`targeting.js aggroCmp`): 铳 / 昆图斯 highest DEF, 胄 highest / lowest ATK, the 碎铳之簧 bounce, 唱沙 / 沙狱 max HP, “自在” nearest. Kept (PRTS 作战机制 §隐匿 "我方干员并不会因为阻挡而解除隐匿"; 索敌的概念): a stealthed ally (隐匿 / 迷彩 / 排气格栅) is attacked only by the enemy it blocks (迷彩 is its own flag `camou`, not 隐匿 for 隐匿 conditions, §20.2). [ASSUMED] "从上到下>从左到右" = row-major; on a shared field (联防, boss) the players' i-th operators deploy together, then the summons (it used to be player after player, which made the leader prefer the later player's pieces).
- Tests: `test/sim/playtest5-targeting.test.js` (18; 13 fail on 731a01c), `test/content/enemies_bosses.test.js` (吉兆飞鳞), `test/match/bot.test.js` (a hovering wave counts as air), `test/render/forms.test.js` (the crawl clips); browser (opt-in) `test/render/forms.browser.test.js` (the real sim feeding the field view).
### 19.2 Blocking by contact radius, and the hand-over (#4) — `sim/constants.js BLOCK_RADIUS`, `Battle._checkBlock` / `blockedTargets`, `ai.js`
@@ -688,9 +692,9 @@ Ten reports after playing v2.3 (绝境 on desktop, 标准 on an Android phone in
- **Cause**: an operator blocked only an enemy standing on its own tile (§5.5's old "|dx|,|dy| ≤ 0.5"). An enemy that passed a full front operator was blocked by the rear one, on the rear tile; when the rear operator died, the front one — which the enemy still overlapped — never took it.
- **Official** (PRTS 游戏数据基础 §阻挡半径; 作战机制 "中点判定…案例:阻挡"): contact = the enemy's position within the blocker's radius of its centre, compared as squared distances — ground 0.70709997 (² 0.49999037), air (起飞 / blockFly vs flyers) 0.8944, devices (阻隔工事 / 障碍物) 0.4472.
- **Now** (§5.5 Blocking): every tick, moving or not, an unblocked blockable enemy is blocked by the nearest operator in contact that has free capacity for its weight — the front operator takes over when the blocker dies, is withdrawn or is stunned (`stun` releases its enemies); an operator whose capacity frees up grabs an overlapping enemy; an enemy that finds no room walks on. A head-on enemy stops at contact, ≈ 0.71 tile from the blocker's centre, outside its tile (the small official deceleration is not modelled). A knocked-out or undeployed operator never blocks.
- **Consequences, both official**: "可以选择且优先选择阻挡单位" (PRTS 选择器: the selector of units whose range is smaller than their block radius) — a **melee** blocker (data `position` MELEE: 要塞 / 领主 / 哨戒铁卫 included) may always target the enemies it blocks, in range or not, and targets them first (`Battle.blockedTargets` → `ai.js acquireTargets`, `targeting.js sortEnemyTargets`, the DEFAULT skill trigger — PRTS 卫戍协议/帮助 "敌人被近战干员自身阻挡"); 瑕光 S2 also sleeps the enemy she blocks (PRTS 备注). A rear operator whose range ends at the blocker's tile no longer reaches the enemy held in front of it (a regression sweep of 143 blocker × skill pairs: own-tile defenders 0.96–1.05×).
- **Ranged operators on melee tiles** (卫戍协议 "所有行动内远程干员可部署在近战位") block, but target by their range alone (PRTS 索敌的概念 "我方索敌优先级:阻挡(近战限定)"; "远程位干员通常而言不会优先攻击自己阻挡的目标;甚至如果这个目标被阻挡在该干员的身后…无法攻击到这个敌人"): no blocked-first priority, no out-of-range target, no skill cast for the enemy they hold (`targeting.js meleeUnit`). Found by the integration QA: 能天使 / 夕 facing away kept shooting the enemy held behind them (9264 / 5860 damage in 20 s).
- 灰毫 S2 专注轰击 casting (block 0, ranged only) as soon as she blocks is the basic strategy: the skill has no skill range and "敌人被近战干员自身阻挡" satisfies the attack-target condition; the 下半 重装 special strategy "受到伤害时释放技能" covers skill 1 only (BWIKI "重装职业干员 技能1,受到伤害后自动释放"; the data row has `skillIndex 0`, with `-1` as the all-skills sentinel — research 03 C1; PRTS's strategy table does not name the skill), so her S1 is TAKE_DAMAGE and S2 stays DEFAULT.
- **Consequences, both official**: "可以选择且优先选择阻挡单位" (PRTS 选择器: the selector of units whose range is smaller than their block radius) — a **melee** blocker (data `position` MELEE: 要塞 / 领主 / 哨戒铁卫 included; every blocker since §20.3) may always target the enemies it blocks, in range or not, and targets them first (`Battle.blockedTargets` → `ai.js acquireTargets`, `targeting.js sortEnemyTargets`, the DEFAULT skill trigger — PRTS 卫戍协议/帮助 "敌人被近战干员自身阻挡"); 瑕光 S2 also sleeps the enemy she blocks (PRTS 备注). A rear operator whose range ends at the blocker's tile no longer reaches the enemy held in front of it (a regression sweep of 143 blocker × skill pairs: own-tile defenders 0.96–1.05×).
- **Ranged operators on melee tiles** (卫戍协议 "所有行动内远程干员可部署在近战位") — **superseded by §20.3**: the integration QA had made them target by their range alone (PRTS 索敌的概念 "我方索敌优先级:阻挡(近战限定)"; "远程位干员通常而言不会优先攻击自己阻挡的目标;甚至如果这个目标被阻挡在该干员的身后…无法攻击到这个敌人"): no blocked-first priority, no out-of-range target, no skill cast for the enemy they held. The user's rule after playtest #6 — "阻挡了就一定要能打到" — makes every blocker hit what it blocks.
- 灰毫 S2 专注轰击 — **superseded by §20.2**: v2.4 read the 下半 重装 special strategy "受到伤害时释放技能" as skill 1 only (BWIKI "重装职业干员 技能1,受到伤害后自动释放"; the data row has `skillIndex 0`, with `-1` as the all-skills sentinel — research 03 C1) and left S2 DEFAULT. The class rows now cover every MANUAL skill of the class, so 灰毫 S2 is TAKE_DAMAGE (the counter-evidence stays recorded in §20.2).
- [ASSUMED] the nearest blocker wins, ties by scan order.
- Tests: `test/sim/playtest5_blocking.test.js` (16, incl. the user's case on act2 m01 with real operators, the ranged blocker and 银灰 as a melee-position ranged attacker), adjusted geometry in `kits_alt_t5` / `engine-requests` / `enemies_bosses`.
@@ -733,7 +737,7 @@ Ten reports after playing v2.3 (绝境 on desktop, 标准 on an Android phone in
- **Official rules** (PRTS 元素 / 游戏数据基础 / 伤害分类; gamedata_const termDescriptionDict): gauge gain = 损伤值 × max(5 %, 1 − 损伤抵抗 %) (PRTS 游戏数据基础: DMG_e "目标受到元素损伤时也可以使用该公式计算,只需要将 D 值改为目标的损伤抵抗即可"), then the 元素损伤倍率 effects; 元素伤害 = max(A(1 − 元素抗性 %), 5 %A); 元素脆弱 ("受到的元素伤害提升") raises 元素伤害 only; 脆弱 ("受到的物理、法术、真实伤害提升") never touches 元素伤害; bursts are 无来源 ("元素爆发通常造成无来源的伤害"; 无来源 = "无法被追溯伤害来源", yet the kill is traced).
- **Fixed (W3b)**: 元素脆弱 is the mod `elementalTakenMul` (元素伤害 only; it used to share the gauge rate `elemTakenMul`, so 炎佑's 20 % sped up fills instead of raising bursts); 元素抗性 reduces `'elemental'` with the 5 % floor (`expectedFinal` mirrors it); bursts carry DamageInfo `sourceless` — no damage-dealt multiplier or penetration of the filler, and the `hit` / `damaged` / `fatal` hooks see `source: null` plus `credit` (stats, kill and boss-pool share keep the filler; a real 深巡 bursting a 海嗣 dealt 10500 instead of 7000, 精准狙击镜 made a 侵蚀 burst 6500 instead of 5000); target-side reactions with no source condition read `source || credit` (sharing: 圣杯, 盲信之誓, 余音; 抵挡: 星熊 战术装甲, 拉普兰德 日晷). The enemies' 凋亡 虚弱 is 50 % × remaining ÷ 15 s each tick (it ran 1 s ahead); 麻痹免疫 is honoured (假想敌:铳, the 胄 parts); 淤困's host burst spreads 1000 (was 780); 灼藤's first attack covers the 3×3; 节日爵士乐手 channels on one locked target (20 % arts + 10 % burn every 0.5 s, 21 ticks, ends on 沉默, no re-cast mid-channel). Side effect: 隐德来希's 心烛 cancels bursts on a candle ("只受隐德来希攻击的影响").
- **Integration pass**: `damage.js hasHp` (alive, HP > 0; a boss: its pool) is the one predicate — `applyElement` refuses a target with no HP left, so no rider in a killing blow's `damaged` hook can burst a corpse (the content copies in tier5 / tier6 / items / tokens now import it); `'elemental'` damage skips `dmgTakenMul` (脆弱 and the other "受到的伤害±" effects [ASSUMED: they share that multiplier]) — only 元素脆弱 scales it; element healing (`reduceElement`) lowers **every** element type by the amount on its own (PRTS 菲莱 备注: 清除元素损伤 = "一次等同于自身最大元素值的全类型元素损伤治疗"; it healed the amount across all types in total); 淤困's "受到的元素损伤提高至130%" is an `elementHit` multiplier at priority 20 like every operator-side "受到的元素损伤±", before a 损伤屏障 (it was the `elemTakenMul` mod, applied after the barrier).
- **Verified unchanged**: the burst effects on both sides; cooldowns 10 / 10 / 15 s (enemies' 侵蚀 8 s), locking then resetting every gauge; leaders take no 侵蚀 in the Final Assault (PRTS 盟约记录); 炎佑's aura = 20 % 元素脆弱 within 1.5. **Differs, not changed**: the gauge's look (official: icon + white bar under the model; §18.3 [ASSUMED]). [ASSUMED] 爵士乐手's first tick one interval after the cast. The gauge intake's 5 % floor (`elementIntake`, integration QA) is unreachable with the data (损伤抵抗 ≤ 10).
- **Verified unchanged**: the burst effects on both sides; cooldowns 10 / 10 / 15 s (enemies' 侵蚀 8 s), locking then resetting every gauge; leaders take no 侵蚀 in the Final Assault (PRTS 盟约记录); 炎佑's aura = 20 % 元素脆弱 within 1.5. The gauge's look is the official icon + white bar since §20.8 (it differed in v2.4). [ASSUMED] 爵士乐手's first tick one interval after the cast. The gauge intake's 5 % floor (`elementIntake`, integration QA) is unreachable with the data (损伤抵抗 ≤ 10).
- Tests: `test/sim/playtest5_elements.test.js` (18), `test/content/playtest5_followups.test.js` (pipeline guard, 脆弱, element healing), `combat.test.js`, `test/content/{kits_t5,tokens_devices,playtest4_summons,enemies_bosses}`.
| 海嗣 (user's case) | Official ratio | Per hit / s at base ATK | Hits to a 1000 gauge | Time |
@@ -801,3 +805,130 @@ Ten reports after playing v2.3 (绝境 on desktop, 标准 on an Android phone in
- **Official**: two chess carry this 特质 (`effectType` SERVER_CHESS_PRICE) and both texts say 购买价格为1 — 至简 (Ⅲ, tier price 3, `price` 2) and 红豆 (Ⅰ, tier price 2, `price` 1, hidden in 下半). Only "`bb.price` is the discount off the tier price" gives the text's 1 for both (3 − 2, 2 − 1).
- **Change**: the handler lowers the price by `bb.price` (`ctx.modifyPrice(-p)`), and `EffectDispatcher.dispatch` runs the priced chess's own 特质 first on `onPrice` (before globals / band / bonds), so 远见's discount (−1, never below 1) and strategy caps act on the lowered price: 至简 stays at 1 with 远见 150 instead of dropping to 0. The shop card keeps `basePrice` 3, so the price shows in the discount tone. docs/META.md §2.2 / §2.3 say the same.
- Tests: `test/content/garrisons_meta.test.js` (every SERVER_CHESS_PRICE owner: tier price − `bb.price` = the text's price = `priceOf`; buying 至简 costs 1; a global modifier already sees 1; 远见 150 leaves 至简 at 1).
---
## 20. User playtest #6 (v2.5): summons, skill triggers, blocking, push force, enemies, boss pool, bounties, 机变 cards, element gauge
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).
### 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.
- **Official** (PRTS 卫戍协议/帮助): §战斗部署 "如果部署的干员拥有可手动部署的附属召唤物,则该召唤物会立刻加入手牌区" — deploy-limit copies ("非初始持有量"), one stack per operator, no deploy slot (token pages: 部署占用数 0); withdrawing the operator removes its summons, moving it "其所属召唤物全部退场并重置至手牌区". §手牌区: summons left in temp "会于下一回合返还". §作战阶段: placed summons deploy after the operators; "所有手动部署的召唤物,无视所属干员的持有状态…作战开始时立即部署一次(例如…赫默的医疗探机)"; one that leaves "将在满足条件后立即原地再部署1个". Token 备注: 诅咒娃娃 is withdrawn when 巫恋 leaves; a deployed 医疗探机 stays when 赫默 leaves. `shopStateTokenDict` (55 entries): 医疗探机 / 诅咒娃娃 / 狼群 / 海嗣 / 流形 DEFAULT, 投递坐标 and the battle-only tokens HIDDEN, and `token_10041_cathy_catsld` the only pool summon with no entry.
- **Now (data / prep)**: tokens.json `placeable` = not HIDDEN and made by an owner's talent or skill — 医疗探机 (赫默 S2), 诅咒娃娃 (巫恋 S2), 爬行号·防护单元, 海嗣, 狼群, 流形. `placeableTokens(chessId, loadout)` follows the equipped skill (赫默 S1: no card) and gives the deploy limit (凯瑟琳 2). A moved or swapped operator sends its placed summons back to their stack (`PlayerState._liftTokensOf`; a summon dragged onto an operator stays where it was dropped); `startRound` tops the board owners' stacks back up, so a stack removed from temp returns.
- **Now (battle)**: talent pieces (狼群, 海嗣, 流形, devices) deploy with the board, after the operators. A skill summon's piece also deploys once with the board, free (settled below), then is docked on its tile (`dockSkillSummons`, the tile stays reserved): `releaseSkillSummon` adds one to the owner's stock per cast (≤ 1) and the piece takes its tile once its 5 s redeploy time has passed, free, never while its owner is off the field — a stocked one deploys as soon as the owner is back (review round 3: a `deploy` hook on the owner; it used to wait for the next cast). The drone lasts 10 s and stays when 赫默 leaves; the doll lasts 15 s and leaves with 巫恋. Not placed: 医疗探机 / 诅咒娃娃 / devices / 海嗣 never appear; 狼群 / 流形 still come as 援军 on a tactical point. The 爬行号·防护单元 kit shields the operator on the tile it faces (range 1-1): the full shield on each new target, invulnerable, key `cathy:shield`. Bots place the cards (a device next to the best operator no device faces yet, facing it); every bot layout first lifts the placed summons and places them again, so a device never stays beside a tile its operator left (residual QA: 3.5–8.6 % of the bots' devices faced nothing). The summon card says how it takes the field (`summonDeployHint`, reading the switch) and shows the token's talent / skill with the owner's variant (golden 巫恋's doll −30 %, golden 赫默's drone ATK 114).
- **Settled by the user**: (1) the placed skill summon **deploys once at the battle start**, the PRTS rule — `SKILL_SUMMON_START_DEPLOY = true` drives the sim and the card hint (PLAYING §4 and SIM.md follow; `test/ui/playtest6_summons.test.js` checks). The playtest #4 report ("是赫默开技能释放一次,不是开局直接就部署了") had been made against v2.2, which already deployed the drone the PRTS way. Flip: `SKILL_SUMMON_START_DEPLOY = false` (only with the skill). (2) 凯瑟琳's device **stays a manually placed hand card** (no shop-state entry read as shown). Flip: dropping `token_10041_cathy_catsld` from `placeable` in `tools/build-data.mjs` only hides the card — the v2.4 automatic placement in her kit would have to come back with it.
- [ASSUMED]: skill-summon redeploys cost no DP; no deploy while the owner is off the field; a swap lifts both operators' summons; the device is invulnerable.
- Tests: `test/match/playtest6_summons.test.js`, `test/content/playtest6_summons.test.js` (start deploy, each cast, the owner's redeploy), `test/ui/playtest6_summons.test.js`, `test/content/{playtest4_summons,tokens_devices,kits_t3,summon_loadout_conflicts}`, `test/sim/{loadout-summons,docs-examples}`; opt-in `test/ui/playtest6-summons.e2e.test.js` (`SP_E2E=1`).
### 20.2 Skill triggers and buffs (#15, #16, #8, #3) — `tools/build-data.mjs resolveTrigger`, `sim/skills.js`, `sim/Battle.js`, `sim/targeting.js`, `sim/snapshot.js`, kits
- **#15 seen**: a skill whose range is wider than the attack range did not fire when an enemy entered it (德克萨斯 剑雨 13 tiles vs 2; 凛御银灰 S2; 锏 S3). **Cause**: every MANUAL skill used the basic rule, and research 03 C1 read the class rows (`skillIndex 0`) as "skill 1 only" (BWIKI).
- **Official** (PRTS 卫戍协议/帮助 §作战阶段 技能操作): basic rule "技能就绪,且即将进行普通攻击/治疗"; a skill with its own 技能范围 (not an attack-range increase) "不通过普通攻击/治疗触发技能,仅在技能范围内存在敌人(无视其不可选中)时释放技能"; class strategies — 重装 "不受技能范围影响,受到伤害时释放技能" (下半, 2026/3/14), 战术家 / 执旗手 / 吟游者 when ready, 解放者 / 阵法术师 an enemy in the initial range, 澄闪 / 纯烬 / 荒芜 S3 "全场存在可选目标时"; "自动操作具有3s冷却,在完成一次操作或作战开始时部署的单位将进入冷却".
- **Now** (§5.6): the class rows cover **every MANUAL skill** of the class and never an AUTO one — an inference (PRTS names whole classes; under the old reading the 阵法术师 row could never cover 薄绿's default S2, a phalanx never attacks with its skill off); counter-evidence kept: BWIKI's "技能1" and the data's `skillIndex 0` / `-1` sentinel. `SKILL_RANGE`: 13 skills (26 records with the elites), any living enemy on the skill range, checked every tick (attack-range changes keep the basic rule). `GDGLOW_SKILL_2`: a valid target anywhere. The 3 s operation cooldown after each automatic cast and after the battle-start deployment (`flags.startOpCooldown`; the test harness sets it to 0); kit-driven casts of MANUAL skills (波登可 S1, 雪猎 bullets, the 流形 copy) check `skill.opCooling`; AUTO skills are exempt; a pending "next attack" never takes a second charge. Players notice: every 重装 manual skill (号角 S3, 瑕光 S3, 灰毫 S2 — reversing §19.2 —, 古米 S2 …) now waits for the operator to be hit.
- **Integration follow-up (WE review round 2)**: the AUTO DP skills 焰尾 S1 迅敏直觉 and 凛御银灰 S1 周旋的谋略 ("立即获得…部署费用") are `SP_FULL` like 伺夜 S1 and 德克萨斯's 冲锋号令·γ型 — an AUTO skill takes no 技能策略 (they used to wait 12–16 s for the next attack).
- **#16**: 古米 备用军粮 (AUTO) had picked up the 重装 row. PRTS 备注 "存在生命值不满的可治疗角色时可触发;…切换至治疗模式…直至古米完成一次普通攻击的治疗": it fires on an injured ally of its x-4 area and her next attack heals the most injured ally there (`heal_scale` × ATK); S2 食粮烹制 is MANUAL, so the 重装 rule. Audit: 塞雷娅 S1 急救 — PRTS 备注 "仅在周围有符合血量条件的友方单位时可触发,触发时会替换当次攻击", 修正 "血量小于等于一半" — is checked at her attack (DEFAULT + `allies` + `hpAtMost` 0.5) and that attack becomes the heal; a cast whose ally recovered first is withdrawn with its charge.
- **#8**: 新约能天使 S2 gave the shield to the fastest attacker; official (PRTS 备注) "攻击范围内仇恨值最高的我方干员" — now `aggroCmp` (taunt, then the latest deployed), through `battle.alliesInGrid` (孤立 units skipped). The amounts were already right: 180 % (精锐 200 %) of each holder's own max HP, decaying by initial / 30 per s.
- **#3 (friend)**: the bond 隐匿 already ended on time (`bondeffect_siracusa` 32 + 0.4 × layers s). 忍冬 S3 "进入迷彩状态,直至下一次开启技能" was the generic `stealth` under the key `stealth`, which made 伪装服's timed 隐匿 permanent (or stripped it) and counted as 隐匿 for 叙拉古 / 家族徽章. Now a separate `camou` flag and status (`vulpis:camou`; 寒芒克洛丝 S1 too) that works like 隐匿 for targeting and on screen — only the enemy it blocks attacks it (PRTS 异常效果 "※迷彩与'阻挡时解除'没有直接关系"; §19.1 kept). What stays see-through after a kill is that official 迷彩, until her next cast. Declined (optional review UX item): a distinct 迷彩 look — the base game draws both see-through, and the detail card has no status row.
- [ASSUMED]: enemy skills and splash selectors skip 迷彩 like target selection; every initial deployment gets the full 3 s (the remake deploys everyone at 0 s); a token skill without a `skillType` counts as MANUAL.
- Tests: `test/sim/playtest6-skills.test.js`, kit tests (incl. `kits_alt_t4` / `kits_alt_t5` for the AUTO DP skills), `test/data.test.js`, `test/sim/docs-examples.test.js`.
| Skill (examples) | type | before → after | why |
|---|---|---|---|
| 德克萨斯 S2 剑雨, 凛御银灰 S2, 锏 S2 / S3, 异客 S3, 焰尾 S2, 忍冬 S2, 诗怀雅 S1, 山 S3, 小满 S2, 归溟幽灵鲨 S1, 崖心 S2, 见行者 S2 | MANUAL | DEFAULT (kit CUSTOM_RANGE) → SKILL_RANGE | own 技能范围 |
| 深巡 S2, 古米 S2, 折桠 S2, 瑕光 S2 / S3, 信仰搅拌机 S2 / S3, 泥岩 S3, 塞雷娅 S3, 余 S2, 角峰 / 雷蛇 / 泡泡 / 菲莱 / 蛇屠箱 S2, 灰毫 S2, 星熊 S3, 号角 S2 / S3 | MANUAL | DEFAULT → TAKE_DAMAGE | 重装 row |
| 薄绿 S2, 蜜蜡 S2, 卡涅利安 S2 / S3, 玛恩纳 S2 | MANUAL | DEFAULT → SEARCH | 阵法术师 / 解放者 row |
| 伺夜 S3, 魔王 S2 / S3, 浊心斯卡蒂 S3 | MANUAL | DEFAULT → SP_FULL | 战术家 / 吟游者 row |
| 古米 S1, 瑕光 S1, 塞雷娅 S1 | AUTO | TAKE_DAMAGE → own rule (古米: injured ally; 塞雷娅: at her attack, ally ≤ 50 %) | AUTO: no 技能策略 |
| 焰尾 S1, 凛御银灰 S1 | AUTO | DEFAULT → SP_FULL | AUTO DP skill (integration) |
| 银灰 S3, 深靛 S1, 野鬃 S2, 蒂比 S1, 断崖 S2, 忍冬 S3 … | MANUAL | DEFAULT (unchanged) | attack-range change ("非攻击距离增加") |
### 20.3 Combat rules: blocking, ASPD floor, push / pull force vs weight, 炎佑 / 孤立 (#17, #14, #18) — `targeting.js sortEnemyTargets`, `Battle.blockedTargets / push / pull / pullToFront / allySelectable / alliesFor`, `constants.js ASPD_MIN / PUSH_TILES(_EFFECT)`, kits, `tools/build-data.mjs TOKEN_ABNORMAL`
- **#17 seen**: an enemy held between two operators; after the rear one died the front one took it over (§19.2), but only the enemy attacked. **Workstream finding**: every melee hand-over hit the enemy (3360 immediate hand-overs, 215 late ones — the enemy 0.52–0.69 tile behind the front operator's centre); the cases that did not were a ranged operator on a melee tile with the enemy behind it out of range, the playtest #5 QA rule (§19.2, PRTS 索敌的概念 "我方索敌优先级:阻挡(近战限定)"; "远程位干员…甚至如果这个目标被阻挡在该干员的身后…无法攻击到这个敌人") — the likeliest reading of the report.
- **Settled by the user** — "阻挡了就一定要能打到": officially the collision pushes a blocked enemy to its blocker's front, so whatever blocks an enemy can hit it. **Now** every blocker — melee units, summons and a ranged operator on a melee tile alike — may always target the enemies it blocks, in range or not, whatever its facing, and targets them first (`Battle.blockedTargets` without the melee gate; `sortEnemyTargets` blocked-first for every attacker; the skills' DEFAULT trigger follows); `targeting.js meleeUnit` is gone. 深靛's kit (`canAttack`, the bound-target retarget) looked at her range only and now includes her blocked enemies; so do the skills that pick their own targets from the range (residual QA): 迷迭香 S3 (blocked enemies only — her own included), 荒芜拉普兰德 S2's drone locks, 佩佩 S2's random pick, 灵知 S3's unfrozen-first order and 远牙 S3's own-range filter. Two skill texts still win over the rule: 普罗旺斯 S2 "普通攻击不再以生命值高于80%的敌人作为目标" and 寒檀 S2's icicles on random tiles of her range. Healers and 停止攻击 skills (泡泡 S2, 蛇屠箱 S2, 古米 S2, 凯瑟琳 S2, 菲莱 S2, 宴 S1, 铃兰 S3, 信仰搅拌机 S3) still never hit. Flip: gate `Battle.blockedTargets` and `sortEnemyTargets`' blocked-first on a melee position again (the v2.4 `meleeUnit`).
- **ASPD floor**: 沼泽 −50 + 寒霜 −50 took ASPD to 10 (塞雷娅 swung every 12 s); officially "ATTACK_SPEED 默认下限 20" (PRTS 数值范围; 游戏数据基础 "实际被限制了下限为20") — `ASPD_MIN` 20, also in the bot's DPS estimate.
- **#14 seen**: 野鬃 S2 pushed too many enemies. **Cause**: every push / pull moved 0.5 + 0.5·力度 tiles, cut 25 % per missing weight level. **Official** (PRTS 游戏数据基础 §重量公式, 推与拉): 受力等级 = 力度 (微小力 −1, 小力 0, 中力 1, 较大力 2, 大力 3) − the current 重量等级 (失重 counts). Push, 弹道 column: ≤ −3 → 0, −2 → 0.12, −1 → 0.44, 0 → 1.7, 1 → 2.14, 2 → 2.96, ≥ 3 → 3.53 tiles; 特效 column (见行者 S1 / S2, named 特效类): 0.085 / 0.374 / 1.562 / 1.987 / 2.773 / 3.331. Pushes are radial except 推击手 / 朝部署方向; a directional push turns radial at −2 when the target is > 45° off or < 0.25 tile away (圣聆初雪 S1 waives both, 见行者 S2 the angle only). Pull: ≥ 0 all the way (拉力起点 0.5 tile ahead, 急停 0.6708), −1 → 35 %, −2 → 0.03, ≤ −3 → 0; 薄绿 S2's 拖拽 is a push towards her. **Now**: `Battle.push / pull / pullToFront / forceLevel / pushDistance`, `displace` the raw mover; every source migrated. 野鬃 S2 on weight 0 / 1 / 2 / 3 / ≥ 4: 2.14 / 1.7 / 0.44 / 0.12 / 0 tiles (light enemies now move further — official). Enemy weights equal enemy_database for all 249 enemies.
- **锏 S3** (PRTS 备注 "※可对空…※多段斩击期间,自身获得无敌,晕眩免疫,冻结免疫"): its slashes and pulls hit air units (added to §19.1's 可对空 list), and during the slashes she is invulnerable (buff `blkkgt:slashes`) and refuses stun / freeze (integration follow-up of the WF note).
- **#18**: PRTS “炎佑” 天赋 "{{特殊机制|我方单位}},{{异常效果|孤立}}"; 孤立 = ALLY_TARGET_FREE — PRTS 选择器 可选判定: a friendly selector never picks it, and selectors decide heal, buff and talent targets. 禁疗 (“小自在”, “耀阳”, 斯卡蒂的海嗣, 沙之碑, 流形, 狼群, 迷迭香的战术装备, 黄金盟誓, 保护目标(冻结状态)) blocks heals only; a 生命回复速度 attribute still applies (PRTS 安洁莉娜 备注). **Now**: tokens.json `abnormal` (`TOKEN_ABNORMAL`) gives `noHeal`, 孤立 also `isolated`; `Battle.allySelectable / alliesFor` back every ally selection (alliesInGrid(Of), auras, 全场 talents, zone / grid buffs, heal picks — a heal pick skips 禁疗 units instead of wasting the heal); enemies still target 炎佑; a summon aimed at injured allies (`findSummonTile 'ally'`) skips them too.
- [ASSUMED]: the 失衡 slide is instantaneous; unclassified pushes use the 弹道 column; a puller never moves the enemy it blocks; 薄绿's inward push stops at the 急停 radius; 溯光星源's pull stops 0.3 tile from the target; 全场 talents select through a selector (skip 孤立); no unit-specific ASPD floor.
- Tests: `test/sim/playtest6-combat.test.js` (the user's layout on act2 m01 × 4 facings; the late hand-over and a plain block for every blocking chess × 4 facings, ranged blockers and out-of-range enemies included; the skills that pick their own targets, 普罗旺斯 S2 the exception; ASPD; push / pull tables; 孤立 / 禁疗), `test/sim/playtest5_blocking.test.js` (the ranged blocker hits and casts for the enemy behind it), `kits_t6` (锏 S3), expectations moved in core, engine-requests, facing and the kit suites.
| Displacement source | Official 力度 / kind | v2.4.1 | Now |
|---|---|---|---|
| 野鬃 S2, 山 S3 (2nd hit) | 中力 radial push | 1.0 tile, −25 % per weight above 2 | 弹道: w0 2.14, w1 1.7, w2 0.44, w3 0.12, ≥ 4 0 |
| 莫斯提马 S3 | 小力 radial | 0.5 tile | w0 1.7, w1 0.44, w2 0.12, ≥ 3 0 |
| 琳琅诗怀雅 S3, 圣聆初雪 S1 | 小力 / 中力 directional (圣聆初雪 fixed, ground only) | 0.5 / 1.0 tile along the facing, flyers too | push table along the facing |
| 见行者 S1 / S2 (hidden) | 中力 推击手, 特效类 | 1.0 / 1.5 tiles | 特效 column (w0 1.987 … w3 0.085) |
| 崖心 S1 / S2, 歌蕾蒂娅 S1–S3, 百炼嘉维尔 S2, 乌尔比安 S1 | pulls 至面前 | ≤ 1 tile or all the way, scaled | pull table (w ≤ 1 all the way, w2 35 %, w3 0.03, ≥ 4 0) |
| 锏 S3 | p_force 1 / e_force 2, 可对空 | ≤ 1 / 1.5 tiles, ground only | pull table, 急停 0.6708, air units too |
| 薄绿 S2 | 小力 拖拽 = push towards her | pull ≤ 0.5 tile | push table towards her |
| ASPD floor | 20 | 10 | 20 |
### 20.4 Enemies: 萨卡兹枯朽战车, the “萨科塔” 恐惧, 威龙's size (#12, #13, #9) — `sim/fear.js`, `ai.js`, `grid.js straightClear`, `Battle.applyStatus`, `content/enemies.js` (kitTank, pollution), `constants.js ALLY_COLLIDER_RADIUS`, `tools/build-data.mjs MODEL_SCALES`, `render/units.js`
- **#13 seen**: “萨科塔之翼” below half HP should fly about at random in a small area (the user wrote 战栗) but hung still. **Cause**: every feared enemy was frozen. **Official**: the bird fears itself — PRTS "生命值首次低于50%时,对自身施加持续5s的恐惧,在5s内移动速度最终提升至150%" (之眼 / 昂首 the same); 恐惧 = "无法被阻挡并四散逃跑" (ba.fear), 战栗 = "被阻挡后无法进行普通攻击" (ba.tremble), so the behaviour described is 恐惧 — report and data agree, only the name differs. PRTS 诱发移动 §恐惧: 恐惧可达地块 = tile centres within 10 of the hit point, in the ±45° fan away from the source, passable, able to reach the goal, not the objective; next stop a random reachable tile within 5 path steps (offset in a 0.5 square), else its own tile; no source / itself as source ⇒ no reachable tile; 恐惧 outranks 诱导.
- **Now**: `fear.js`; the birds flutter within ±0.25 of their tile for 5 s at ×1.5, then fly on. Operator fears (叙拉古 6, 妮芙 S2, 荒芜拉普兰德 S3) make enemies flee into the fan instead of freezing (a visible change). The 恐惧 and 诱导 walks re-plan from where the enemy stands after a push or an obstacle change (`grid.version`; PRTS "恐惧效果结束时立刻清除【恐惧移动】"); a checkpoint on an obstacle tile counts as unreachable; an off-centre ground start steps back to its tile centre when the straight line would clip a wall (`grid.js straightClear`, shared). They used to walk through walls after a push (4–7 % of random pushes on m01–m04).
- **#12 seen**: 萨卡兹枯朽战车's damage felt low. **Already right**: stats (lv0 HP 12000 / 15000, ATK 750 / 850, DEF 750, RES 10, BAT 4.5, range 2.2, weight 4; no season override, no wave `overwrittenData`), the round multipliers, the target filter (只攻击位于低地的我方单位,且不会攻击飞行单位) and the 200 % blocked melee attack. **Fixed (WG)**: 秽蚀轰击 timing (initSp 2 = maxSp 2 ⇒ attacks 1, 4, 7 …, not 3, 6 …); the 【污染秽蚀】 radius 1.7 (was 1 [ASSUMED]); its damage "每秒受到50/25点真实普通伤害…同名效果不叠加" — true damage through `dealDamage`, one tick per second per unit (also 萨卡兹枯朽战士's death zone, r 2, 8 s; zones are now absorbed by shields, scaled by damage-taken modifiers and fire TAKE_DAMAGE skills). The skill hit is 100 % (PRTS skill text), also against a blocker [ASSUMED that it fires there]. Measured: roughly unchanged overall (first 20 s +4…+42 %, a long blocked fight −7…−9 %).
- **#12 re-researched (the user's follow-up: "远程攻击射程挺远、伤害很高")**: enemy_database (`enemy_1272_nhtank` / `_2`: rangeRadius 2.2, `PollutedRangedAtk` range_radius 2.2, atk_scale implicit 100 %, `Empty.attack@chuang_atk_scale` 2, spData 攻击回复 2 / 2), the PRTS pages (攻击方式 近战 远程; "仅半径2.2范围内存在位于低地的非飞行我方单位时可触发:对目标造成100%物理普通伤害"), activity_table act2autochess (DOT special, no stat override; the range-4 tank is 斗智场's 改装排污车, another mode) and the act1 / act2 autochess levels: **there is no higher scale and no longer data range**. The hard hits are the round multiplier (table below: 绝境 R7 998, 尖端 R13 1656 per ranged hit before DEF). What the data does support is a longer *effective* reach: PRTS 作战机制 §碰撞体积与位置识别 "我方干员碰撞体积基本均为以0.25格为半径的圆形…关于碰撞体积与远程索敌,碰撞箱碰撞是为最常用的方式,索敌抬手等均使用碰撞箱" — the remake measured enemy range from the operator's centre. **Settled**: every ranged enemy's normal attack takes an ally whose 0.25 collider touches its range circle (`ALLY_COLLIDER_RADIUS`, `ai.js enemyAttack`: centre distance ≤ rangeRadius + 0.25; content skills / zones keep their own point radius [ASSUMED]). 枯朽战车: reach 2.2 → 2.45 (13 → 21 tiles around it); a lone operator beside its lane takes 4 hits instead of 3 (绝境 R6, DEF 300: 2095 → 2793 attack damage, 2595 → 3593 with the zone); dense lineups unchanged (its 4.5 s interval is the limit). The user's memory of a *long* range is stronger than the data supports — said plainly in the user notes. Flip: `ALLY_COLLIDER_RADIUS = 0`.
- **#9 seen**: 威龙 drawn far too large. **Official**: the client scales each enemy Spine model in its battle prefab — 0.27 for 1454 of 2147 prefabs; 威龙 0.16, 妖怪 0.20, 妖怪MKII 0.22, the 岁 relics 0.4–0.6. **Now**: `MODEL_SCALES` (122 prefabs) → enemies.json `modelScale` = product / 0.27 (125 keys); `render/units.js` scales the skeleton and the bar height (enemies without setup-pose bounds: chibi headroom × `modelScale`). 威龙 is 1.28 tiles wide instead of 2.15 — 1.08 × a 妖怪, as in the game. 85 enemy skeletons still have no bounds in data/assets.json (asset pipeline, not addressed).
- Tests: `test/sim/playtest6-enemies.test.js` (flutter, determinism, operator fear, 恐惧 > 诱导, push / obstacle re-planning on m01–m04, the tank incl. its reach), `test/render/playtest6-enemies.test.js`, `test/local-extract.test.js` (`enemy_scales`), `combat.test.js`; opt-in `test/render/playtest6-enemies.browser.test.js`.
| Round | Tank | 险境 ATK · ranged hit · blocked | 绝境 | 终极 |
|---|---|---|---|---|
| R1 | 萨卡兹枯朽战车 | 600 · 600 · 1200 | 825 · 825 · 1650 | 825 · 825 · 1650 |
| R4–R7 | 萨卡兹枯朽战车 | 726 · 726 · 1452 | 998 · 998 · 1997 | 998–1208 · … · 1997–2416 |
| R8 | 尖端萨卡兹枯朽战车 | — (banned) | 1131 · 1131 · 2263 | 1369 · 1369 · 2738 |
| R13 | 尖端萨卡兹枯朽战车 | — | 1656 · 1656 · 3313 | 1656 · 1656 · 3313 |
### 20.5 Final Assault leader at 0 HP that would not die (#5) — `sim/constants.js BOSS_POOL_MIN_HP`, `match/finalAssault.js SharedBossPool`, `sim/spec.js LocalBossPool`, `match/Match.js _creditBoss / _onResult / _finishFinal`, `ui/hud.js PhaseCapsule`, `ui/gameLogic.js bossPctText`
- **Seen**: 2 humans (AI teammates eliminated), 绝境, act2 m01, client-side combat — the boss bar read "0.0%" with 95 s left, the leader fought on until the overtime drain took the team LP, and the result said 胜利. **Reproduced**: real 2-human client-combat matches (pool scaled to 5 %): on v2.4.1 11 of 40 fights stalled, every boss family; about 11 minutes of drain followed, ending 胜利 or 失败.
- **Cause**: float dust in the shared pool, not a boss script, hit area or part. `_creditBoss` subtracts each report per player and the pieces add up only in exact arithmetic (40000 − 12345.6 − 7654.3 − 20000.1 = 3.6e-12); the browser's `LocalBossPool` (server hp − (cum − acked)) absorbed every later hit (less than half an ulp of `cum`), so the leader never died, nothing new was reported, and m.public rounded the server's dust to "0 HP". When the team LP hit 0, the verdict was read from the pool after the final `b.result`, whose per-player damage is `Math.round`ed — rounding up emptied the dust (胜利).
- **Official**: PRTS 卫戍协议/帮助 §领袖作战阶段 "…计时结束后战斗仍然会继续,但每超时1秒扣除1目标生命值"; 卫戍协议:盟约 下半 "…使目标生命值扣除至0,则无视倒计时直接失败" (research 01 §10). The victory after the drain contradicted it; the user was right.
- **Now**: a shared pool never holds less than `BOSS_POOL_MIN_HP` (1) — the hit, or the per-player credit, that would leave less takes the rest (SharedBossPool, LocalBossPool), and a browser pool reads any finite rest below 1 as 0, so a pool shown at 0 is a dead leader on every field (`Battle._bossSync` / `_checkEnd`) and `b.end cleared` goes out at once. The first end the server registers decides (`Match._finalEnding`: pool 0 → victory, team LP 0 → defeat); after a forced end no report credits the pool. The boss bar prefers the live pool of the battle on screen over the ~1 Hz `m.public.bossHp` and never shows "0.0%" for a living leader ("<0.1%"). Checked and ruled out: the kill check reads the pool whatever the unit's own hp or invulnerability; mirrored and hidden-core copies share it; parts only pass damage; the Hidden Core, 4 humans and server-run mode end promptly.
- [ASSUMED]: the 1-HP floor (official HP is a float shown rounded — PRTS 作战机制 "红字在显示时会就近取整"); a client emptying the pool within its latency after the server registered team LP 0 still loses; the HUD prefers the local snapshot pool.
- Tests: `test/sim/playtest6_bosspool.test.js`, `test/match/playtest6-bosspool.test.js`, `test/ui/playtest6-bosshud.test.js`.
### 20.6 Match flow: the bounty draft, 教鞭, the 联防 leak counter, the promotion reward (#4, #7, #19) — `tools/build-data.mjs bountyDraftExclusion`, `match/choices.js` (`MULTI_ROUND_BOUNTY_BATTLES`), `content/items/meta.js`, `Match.addBounty`, `PlayerState.pushRewardOffer / effectsView`, `sim/spec.js uniteLeft`, `Match._uniteLeft`, `fields.js uniteBillBounds`, `ui/hud.js MissTag`, `ui/teamPanel.js rowLp`
- **#4 seen**: "悬赏决策的敌人…正常只会在选择之后出现两回合,现在是一直出". **Cause**: a 1–2-battle card already stopped after its battles, but the draft drew from all 129 ENEMY_GAIN cards (战术特训 and the hidden 鸭爵 set included) and every card was plain text (the server's stripped `desc` beat the rich text), so a 每场 multi-round card looked like a 下场 one.
- **Official**: PRTS 卫戍协议:盟约 下半/PRTS盟约记录 §机变阶段 "敌人轮选" lists the 1- and 2-battle bounties, 源石虫·特训 and the 7 multi-round cards and comments out the 鸭爵 set; 战术特训 sits under "※以下悬赏任务仅由法术教鞭生成"; §法术 教鞭 "于3个战术特训的悬赏任务中选择一项"; 杜宾 加练! "若自身战斗完美作战可获得资金". The card text colours the duration (`<@ba.vup>下场作战</>` / `两场作战`, `<@ba.vdown>每场</>`).
- **Now**: cards.bounty `draft` = that table (105 cards: 56 next-battle incl. 源石虫·特训, 42 two-battle, 7 multi-round), drawn uniformly, no multi-round cap; bounty cards carry `descRaw`; the effects column says 还剩 N 场作战 (`counterText`). 教鞭 draws 3 战术特训 cards and takes one at random (it used to draw the band bounties, so 战术特训 was unreachable); “神秘顾客” (granted by nothing in act2) keeps the band bounties [ASSUMED]. Not modelled: the one official draft screenshot hints at one series per draft.
- **Settled by the user** — "我不记得有过多轮悬赏": until confirmed otherwise every official multi-round card (山海众头目·多轮悬赏, 多轮悬赏·假想敌 ×6, and 法术大师A2·多轮战术特训 from 教鞭) **lasts two battles** exactly like the "接下来两场作战" cards and stays in the draft: `MULTI_ROUND_BOUNTY_BATTLES = 2` (`bountyBattles`; `bountyText` rewrites "之后的 / 后续<@ba.vdown>每场</>作战" to "接下来<@ba.vup>两场作战</>" on the card and in the effects column, 还剩 N 场作战); choices.json keeps the official `rounds` 99 / `multiRound`. Flip: `MULTI_ROUND_BOUNTY_BATTLES = null` restores the official red 每场 everywhere (no data rebuild).
- **#7 seen**: the leaker's number never fell during 联防 and seemed capped at 10. **Official**: PRTS 帮助 "防卫失败的玩家可通过上方信息栏确认自身所属敌人的剩余数量"; the capsule shows an orange runner tag ×N (research 09 `tag_miss`, drawn with ui/battle bg_miss_enemy [ASSUMED]); the 10-LP cap per round is unchanged. **Now** (revising §17.5): `uniteLeft` per leaker = not spawned yet + alive + counted leaks — it falls on kills and rises on splits / summons (children inherit `sourcePlayerId`); the server takes it from `b.progress left`, the headless timeline `[gt, killed, total, left]` or the exact `uniteSurvivors`, clamps live values to `uniteBillBounds`; m.public players[] `uniteLeft` is uncapped and `pendingLp` = min(cap, uniteLeft). ×N in the capsule and in the leakers' team rows; a client with the 联防 field on screen shows its local replica's counts there; a teammate's row tooltip names the teammate.
- **#19**: PRTS 帮助 "临时刷新3名高于调度中心等级1阶(最多6阶)的干员"; the official 玩法介绍 image shows three different operators at price 0. v2.4.1 rolled three times independently (14–21 % of offers had a duplicate). **Now** three different chess, copy-weighted; a tier short of different chess tops up from the tier below [ASSUMED]; the offer reserves no copies. The integration QA found the same repeat in the other free pick-one chess offers — 信标 (same tier), 寻呼模块 (same bond ≤ shop level, content/items/meta.js) and the engine default `builtinMeta use_equip_reward_special_goods_char_chess` — they now draw different operators too (信标 tops up from the tier below, 寻呼模块 shows fewer cards when the pool has no other one), and `pushRewardOffer` drops a repeated id from any list it is given (`test/content/items.test.js`).
- Tests: `test/match/playtest6-matchflow.test.js` (the draft, two battles for a multi-round card end to end, 联防 counts, rewards), `test/match/followups.test.js`, `test/ui/playtest6-unite.test.js`, `test/ui/playtest6-bounty.test.js`, `test/content/items.test.js` (教鞭).
### 20.7 机变 card text; the shop's confirming tap on phones (#6, #10) — `ui/choiceOverlay.js`, `css/screens/game-panels.css`, `ui/shopBar.js`, `css/screens/game-shop.css`, `css/devices.css`
- **#6 seen** (screenshot 2, Android 756×366 CSS px, 终极 co-op): the 道具补给 cards showed only icon, name and tier. **Cause**: the card was a centred column sized for 16:9; with the root font clamped at 40 px a 3×2 card was 105 px tall and showed 0.2 lines of text (217 of 223 texts cut), in all four families; at 1920×1080 nine long texts were cut at 4 lines. **Official** (research 06 §11.5; screenshots Mb2mtd9 / Wrkhtxv / SEbySVl): a framed icon at the top-left with the bold name beside it, the description below, numbers in blue, the tier chip on the icon's corner, the taker's round avatar alone at the top-right. **Now**: that layout; the text from `cardText` (the card's own `descRaw`, else the data `descRaw` while its plain form equals the server's `desc`, else the server text), clamped to 7 lines (8 at line height 1.3 on phones under 700 px wide: with Noto Sans SC — the page's web font, metrics of Android's CJK font — 海沟实验体 wraps to 8 lines at 640×360, residual QA); short screens get a tighter overlay header (no CONTINGENCY micro line) and .19rem text; the taker badge is the avatar alone (name in its tooltip and the card's aria-label) and the taken card's head reserves its width. All 223 texts (51 items, 129 bounty, 43 tactic) fit in full, taken or not, from 640×360 to 2560×1080, measured with Noto Sans SC.
- **#10 seen**: on the phone the second tap on the 确认购买 strip did nothing; only the middle of the card worked. **Cause**: the card's invisible ⓘ corner had the generic 44 px touch area (its 30 px override lost on specificity: `:is()` scores 0,3,1 against 0,2,1), covering the bottom-right quarter of the card and the right half of the strip (120 / 144 grid points); below 768 px the left-docked detail card opened by the first tap also reached over the bar's first cards. **Official** (research 09 §5): first tap = detail + 确认购买, second tap buys; the card has no info corner. **Now**: the ⓘ is removed (a tap anywhere on a card is the card's tap), `.shopbar.has-armed { z-index: 10 }` keeps the armed bar above the detail card (z 9; below `.uframe` 12 and `.spov` 20), and a card that cannot be bought right now opens its detail (not aria-disabled, help cursor). 144 / 144 points on touch at 640×360 … 932×430 and with the mouse at 1920×1080; the strip centre, its right end and the bottom-right corner buy.
- [ASSUMED]: phone text 7.6 px; the avatar inside the card's corner; the gold strip may cover the last line of the longest texts while a card is selected; the micro line hidden on phones; the armed bar hides the detail card's corner on < 768 px phones; solo 3-card sizes.
- Tests: `test/ui/playtest6-ui.test.js`; opt-in `test/ui/playtest6-ui.e2e.test.js` (`SP_E2E=1`: every text at 9 viewports, each card taken once, the hit grid by tap target and real taps); mock.e2e (detail + Esc through a card tap).
### 20.8 Element gauge on screen (#11) — `sim/damage.js elementView`, `render/units.js`, `render/textures.js`
- **Seen**: correcting playtest #5, the user said the 海嗣's element damage is right after all, but the gauge "fills at the wrong rate: far from used up when the burst already hit". **Measured** (Node sim → interp → UnitView, and headless Chrome on the real view; 14 element enemies vs 古米, the reviewer with 5 operators against a mixed 海嗣 wave): the drawn gauge follows the sim within one frame. Steady attackers burst off a 1–5 % sliver; big hits legitimately burst far up the bar (掠海漂移体 does 50 % of its ATK × the round's ATK per hit — 25 % of the gauge at base, ≈ 44 % at 绝境 R11; 萨卡兹王庭军精锐术师 死亡之眼; 昆图斯's skills).
- **Display faults, all fixed**: `elementView` rounded the fill to the nearest 1 % (a gauge 1–4 元素值 short showed empty); the v2.3 ring right of the bars sat on the next operator's tier chip (read as the neighbour's gauge, and a shrinking ring reads like a filling one); during the 爆发冷却 the refill was the same white as the 元素值 left while the burst's stun / damage hit the unit.
- **Official** (PRTS 元素): "当单位存在一项不为最大值的元素时,模型下部会显示对应的元素图标,并以白条显示剩余的元素值"; enemies get a small colour-only icon; during the 爆发冷却 "单位的元素条显示缓慢恢复至上限(动画效果)" (no colour given).
- **Now**: fill rounded DOWN to 1 %, kept at 0.01–0.99 outside a burst (1 during one) — the bar only runs out when the gauge bursts; the gauge is a row under the unit's own HP / SP bars inside their span: the element disc (0.15 tile, 8–15 px; enemies ×0.8, plain colour; light rim) and a white bar of the remaining 元素值 (SP-bar height, dark track); during the 爆发冷却 the bar refills in the element's colour (`ELEMENT_RING[el].tint`) with the disc pulsing, white again when it ends.
- [ASSUMED]: the refill colour and pulse; the geometry; placement under the above-head bars (official: under the model); the disc rim. The render-demo recordings carry fills made with the old rounding (display only).
- Tests: `test/sim/playtest6_elemgauge.test.js`, `test/render/playtest6_elembar.test.js`, `test/render/downring.test.js`; opt-in `test/render/elembar.browser.test.js`, `downelem.browser.test.js`.
### 20.9 Settled decisions and what stays [ASSUMED]
The user's answers of 2026-10-01 (playtest #6 follow-up):
| Decision | Settled rule | One-line flip |
|---|---|---|
| A placed skill summon at the battle start (§20.1) | deploys once, free, then with each cast (PRTS §作战阶段) | `shared/constants.js SKILL_SUMMON_START_DEPLOY = false` |
| 凯瑟琳's 爬行号·防护单元 (§20.1) | a manually placed hand card | drop `token_10041_cathy_catsld` from `placeable` (`tools/build-data.mjs`) — and restore the v2.4 kit placement, or the device never appears |
| 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` |
| 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 |
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).
+62 -14
View File
@@ -106,7 +106,19 @@ untimed. The UI picks a card with two taps (select → 确认选择, DESIGN §18
the mode's static `inactiveBondIds` — 标准 has no 拉特兰 / 阿戈尔 / 卡西米尔 / 奥术 … — and with chess in the pool), so
玛恩纳的盟誓 / 莫斯提马的盟誓 / 卡西米尔驰援 never show up in 标准. Card generation and the
family defaults are documented in `choices.js` (bounty / supply / shop / tactic). 机密商店 cards are **free** (official
text "无需消耗资金"). A `choice:<effectId>` registry handler overrides the default application (§2.4).
text "无需消耗资金"). 悬赏决策 offers only choices.json cards with `draft: true` (`choices.js draftBounty`, user playtest #6
item 4): the PRTS table 卫戍协议:盟约 下半/PRTS盟约记录 §机变阶段 "敌人轮选" — kill bounties for the next battle ("下场作战")
or the next two ("接下来两场作战"), 源石虫·特训, and the 7 multi-round cards ("之后 / 后续的每场作战"), but never 战术特训
(listed under "※以下悬赏任务仅由法术教鞭生成") nor the 鸭爵 / 高普尼克 / 流泪小子 / 圆仔 cards (commented out of the table) —
105 cards, drawn uniformly. Every bounty card carries the effect's official rich text `descRaw` (the battles in blue
"下场作战" / "两场作战"; the overlay and the effects column render it; the effects column also says "还剩 N 场作战").
**Multi-round cards last two battles** (`choices.js MULTI_ROUND_BOUNTY_BATTLES = 2`, `bountyBattles` / `bountyText`): the
user does not remember any multi-round bounty (playtest #6 answer, "我不记得有过多轮悬赏"), so until that is confirmed
otherwise every "之后 / 后续的每场作战" card — drafted, from 教鞭 or “神秘顾客” — lasts two battles exactly like the
"接下来两场作战" cards, and its card and effects text read "接下来两场作战" in the same blue (还剩 N 场作战).
`MULTI_ROUND_BOUNTY_BATTLES = null` restores the official red "每场" (every later battle; effects column
"之后的每场作战"). The 战术特训 cards are what the 教鞭 Art offers (§2.5).
A `choice:<effectId>` registry handler overrides the default application (§2.4).
### 1.3 Disconnects, AI takeover
* Disconnected human: the seat keeps playing its last lineup; drafts auto-resolve at their deadlines, prep auto-readies at
@@ -317,10 +329,14 @@ items), 教鞭 / “神秘顾客” (a random bounty is added).
**教鞭 / “神秘顾客” stay a random bounty (deliberate).** The official Arts open a personal 悬赏 choice
("选择一项(特殊)悬赏任务进行挑战", `choice_event hunter_band_1`). The server applies a random bounty instead: content
(server/sim/content/items/meta.js) draws 3 of the band-bounty family `enemyeffect_b_*` and takes one at random (the
built-in fallback: a random tier ≤ II bounty of `cards.bounty`) and adds it with `ctx.addBounty`. A personal choice
overlay would need its own phase / protocol message outside SP_DRAFT (a second, simultaneous draft in co-op, with
timers and AI takeover) for a rarely used Art, while the random pick keeps the risk / reward the item is about. The
(server/sim/content/items/meta.js) draws 3 cards and takes one at random — 教鞭 from the 20 战术特训 cards (payout
`perfect`: extra enemies, the card's coins when the own phase is perfect; PRTS 下半 记录 §法术 教鞭 "于3个战术特训的悬赏
任务中选择一项", §机变阶段 "※以下悬赏任务仅由法术教鞭生成", 杜宾 加练! "若自身战斗完美作战可获得资金"; user playtest #6
item 4 review), “神秘顾客” (granted by nothing in act2) from the band-bounty family `enemyeffect_b_*` [ASSUMED] — each
limited to enemies the mode can field (the built-in fallback: a random tier ≤ II bounty of `cards.bounty`), and adds it
with `ctx.addBounty`. A personal choice overlay would need its own phase / protocol message outside SP_DRAFT (a
second, simultaneous draft in co-op, with timers and AI takeover) for a rarely used Art, while the random pick keeps the
risk / reward the item is about. The
bounty then behaves like any other (next battles, 联防 payouts, Final Assault spawns). 神秘顾客's destroy clause (+1
fund, the Art passes to the next alive player) is content too (`onDestroy`).
EffectRefs: `effect:builtin_round_coin`, `effect:builtin_gift`, `effect:builtin_next_buy_golden_item` (整备),
@@ -364,15 +380,17 @@ Any `choice:` handler whose EffectRef reuses its own key must guard like this (o
* **Hand**: 10 slots filled right→left, 5 temp slots for passive overflow (merge results, grants, returned equipment);
a full hand refuses buys unless the purchase completes a merge, and withdrawals unless the withdrawn summoner's own
summon stack frees a slot (a deployed summon withdrawn with no stack of its own left to join is a new card: `HAND_FULL`,
never temp); temp blocks Ready. A temp piece is resolved (chess sold back to the pool, items / summon stacks
destroyed) at the deadline of the first prep in which its player could act on it (`PlayerState.tempDue` vs
never temp); temp blocks Ready. A temp piece is resolved (chess sold back to the pool, items destroyed, a summon stack
removed — it comes back at the next round start, see Summons) at the deadline of the first prep in which its player could act on it (`PlayerState.tempDue` vs
`prepsEnded`, recorded by `_putTemp`, user playtest #3): arrived during a prep before Ready → that prep's end; after
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
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,
board copies are consumed); equipment returns to the hand; summons of consumed copies are removed; a reward offer of 3
free chess of tier min(level+1, 6) (pick 1, expires at prep end; queued when several merges happen). A merge
**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
merges happen). A merge
completed after the prep (SETTLE / Final Assault effects such as 突变细胞) keeps its offer for the next prep; its elite
goes to the hand like any merge's, overflowing into temp (kept through the next prep, see Hand) — it takes a freed
board tile of a consumed copy only when the hand and temp are both full (research 01 A1).
@@ -382,12 +400,23 @@ Any `choice:` handler whose EffectRef reuses its own key must guard like this (o
latest when the battle input is built): a piece left on a tile it may no longer occupy — a melee operator on a new
射击台 — is withdrawn to the hand (overflow temp: re-placed during the prep; its summons leave with it), a summon back
onto its stack, with a toast (`PlayerState._evictIllegal`).
* **Summons** (PRTS 卫戍协议/帮助 §战斗部署, user playtest #6): an operator placed on the board sends its manually deployable
summons (tokens.json `placeable`: 赫默 S2 医疗探机, 巫恋 S2 诅咒娃娃, 凯瑟琳 爬行号·防护单元, 浊心斯卡蒂 海嗣, 伺夜 狼群,
缪尔赛思 流形 — only those its equipped skill / module makes, `gamedata.placeableTokens(chessId, loadout)`) to the hand as
one stack of deploy-limit copies (凯瑟琳 2 of her 3 devices); each copy is placed and turned like any piece (no deploy
slot — the PRTS token pages give 部署占用数 0), only while its owner is on the board. Withdrawing / selling / merging the
owner removes its summons; moving it to another tile (a move, a swap, or a summon dragged onto it — that summon stays
where it was dropped) sends its placed summons back onto their stack ("移动干员时,其所属召唤物全部退场并重置至手牌区";
`PlayerState._liftTokensOf`). A stack that overflowed into temp and was removed at a prep deadline comes back at the
next round start (§手牌区 "干员所属召唤物会于下一回合返还": `PlayerState.startRound` tops every board owner's summons up
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,
变形同构体 grants). Σ activated layers for the hidden core = Σ layers of active bonds at the boss round's prep end.
* **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). Multi-round bounties ("之后的每场作战")
also spawn in the Final Assault / Hidden Core, on the owner's half of the boss field (a route ending at its goal); the
boss battle then uses up one of the bounty's battles and its kill coins go to pending funds.
`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 /
Hidden Core, on the owner's half of the boss field (a route ending at its goal); the boss battle then uses up one of
the bounty's battles and its kill coins go to pending funds.
* **Combat time limit**: data `combatTimeLimit` (the level's `maxPlayTime`) counts REAL seconds of the forced 2×
battle; the Battle / 联防 limit is `gd.combatTimeLimit(r)` = 2 × that in game seconds (`config.combatTimeScale`,
default 2). Read as game seconds the rounds' own spawn schedules would not fit (R2's last flyer spawns at 43 s of
@@ -414,6 +443,13 @@ Any `choice:` handler whose EffectRef reuses its own key must guard like this (o
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
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`:
the hit — or the per-player credit of a report — that would leave less takes the rest; the browser's `LocalBossPool`
reads anything below 1 as 0), so a pool shown at 0 is a dead leader on every field and the round ends at once. Before,
crediting a report per player could leave float dust (3.6e-12) that no browser hit could remove and the leader fought
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
no report (an in-flight `b.progress`, the final `b.result` with its rounded per-player damage) credits the pool.
### 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
@@ -448,6 +484,18 @@ fielded as the board has them; `flags.layerGainsEnabled = false`; time limit = t
Every enemy still alive at the end (leaked again, or never spawned before the limit) costs its **source** player 1 LP.
A client-run 联防 result may bill a survivor only to a leaker who sent that enemy in — a split / summon only to a leaker
who sent in its parent, ≤ the parents' data offspring count (磨砻 2, 烹泉 4 …; fields.js offspringPerParent).
**Live counter** (user playtest #6 item 7; PRTS 卫戍协议/帮助 "防卫失败的玩家可通过上方信息栏确认自身所属敌人的剩余数量"):
while the 联防 runs, each leaker's `m.public players[].uniteLeft` = its enemies still standing on the field (not spawned
yet, alive, or through again) + its leaks that could not re-enter — what settlement would charge before the cap —
and `pendingLp` = min(`lpCapPerRound`, uniteLeft) (`Match._uniteLeft`). Sources: the authority's `b.progress.left`
(`{ [leakerId]: n }`, server/sim/spec.js `uniteLeft` / `battleProgress`), the server-run field's timeline samples
(`fields.js timelineSample`: `[gt, killed, total, left]`) read on the field clock, or the streamed battle itself
(legacy mode; `_uniteTick` republishes about once a game second); exact from the field's result once it is done
(`uniteSurvivors`). Live values are clamped to what settlement can bill that leaker (`fields.js uniteBillBounds`: sent
in + the offspring bound). It falls as the helpers strike them down and rises when one splits or summons (the children
carry the leaker); every client that has the 联防 field on screen shows its local replica's counts while it runs — the
leaker's own capsule / row (`ui/hud.js uniteRemaining`) and the leakers' team rows (`ui/teamPanel.js rowLp`
`uniteLocal`, the battle runner's `state().uniteLeft`) — else this value. The settled loss stays min(10, survivors).
---
@@ -457,9 +505,9 @@ who sent in its parent, ≤ the parents' data offspring count (磨砻 2, 烹泉
drawn set; `disabledBonds` = drawn ∪ the mode's static list), `hiddenBossId`, `bossRound`, `hiddenRound`, `spRound`,
`combatMode` (`'client'` | `'server'`), `fields[].progress { killed, total, done }` (teammates' progress UI), `paused`
(solo pause, §1.3a),
`players[].autoplay`, and per phase: `draft { order, turn, picks, skipsLeft, turnDeadline, turnSeconds, untimed }` (BAND_DRAFT),
`sp { family, name, desc, eventId, cards:[{ idx, kind:'bounty'|'item'|'tactic', id, name, desc, tier, coin?, payout?,
rounds?, enemyKey?, count?, price?, team?, tacticKind? }], order, turn, picks:{pid: idx}, taken:{idx: pid}, untimed }`
`players[].autoplay`, `players[].uniteLeft` (UNITE, leakers only: their enemies still standing, uncapped — §4), and per phase: `draft { order, turn, picks, skipsLeft, turnDeadline, turnSeconds, untimed }` (BAND_DRAFT),
`sp { family, name, desc, eventId, cards:[{ idx, kind:'bounty'|'item'|'tactic', id, name, desc, tier, descRaw?, coin?,
payout?, rounds?, enemyKey?, count?, price?, team?, tacticKind? }], order, turn, picks:{pid: idx}, taken:{idx: pid}, untimed }`
(SP_DRAFT), `teamLp` / `bossHp {hp,max}` (Final Assault on), `overtimeAt` (最终攻势 / 隐秘核心: ms epoch when the
overtime drain starts; `deadline` = the level's 120 s countdown), `unite { helpers, leakers }` (UNITE).
`players[].status`: INFO_CHECK ready/deciding · drafts ready (picked) / deciding (their turn) / acting (waiting) ·
+15 -8
View File
@@ -16,9 +16,9 @@
|---|---|
| 确认本局信息 | 本局地图(标准模拟固定为「战场#01」;险境从 8 张地图中随机,绝境 / 终极从不含战场#01 的 7 张中随机)、敌方派系、**被禁用的盟约及其干员**、领袖预告。所有人确认后继续;同盟模拟最多 25 秒;只有你一名玩家时(独立模拟,或同盟模拟只带 AI 队友)不限时。 |
| 选择策略 | 40 名策略(分队长)决定**初始目标生命值(20–45)**和一条永久规则。同盟:随机顺序轮流选,**每人 30 秒**,右上角和「决策中」显示同一个倒计时;AI 队友立即选完;队友已选的不能重复,每人可**跳过一次**(排到最后);超时自动选你**当前选中**的策略(已被队友选走则选华法琳)。独立:自由选择、不限时;同盟模拟只有你一名玩家时也不限时。 |
| 机变 | 部分回合开始前出现:同盟共 6 张卡轮流各取 1 张(首位 30 秒,其余 16 秒;只有你一名玩家时不限时),独立 3 选 1。卡片包括资金、干员、盟约层数、战术特训(悬赏敌人)等。选卡要**点两次**:第一次只选中(金框 +「确认选择 · 再次点击」),再点同一张或点右上的「确认选择」才生效;点别的卡换选,按 Esc 或点空白处取消。 |
| 机变 | 部分回合开始前出现:同盟共 6 张卡轮流各取 1 张(首位 30 秒,其余 16 秒;只有你一名玩家时不限时),独立 3 选 1。卡片包括装备、资金、干员、盟约层数、悬赏等;**悬赏决策**为你的作战添加悬赏敌人,卡上用蓝字标出持续多久:「下场作战」/「接下来两场作战」的只出现一场或两场,之后不再出现;官方的「多轮悬赏」(原文写「之后的每场作战」)在这里也按「接下来两场作战」处理,只出现两场。击倒它的人(联防中的队友也算)获得资金;右侧效果栏会写「还剩 N 场作战」。每张卡左上是图标和名称,下面写着完整效果(与详情卡相同的官方描述,手机上也显示);已被选走的卡变暗,右上角显示选走它的玩家头像(悬停可看名字)。选卡要**点两次**:第一次只选中(金框 +「确认选择 · 再次点击」),再点同一张或点右上的「确认选择」才生效;点别的卡换选,按 Esc 或点空白处取消。 |
| 休整期 | 购买、摆阵、配装备、冻结 / 刷新、升级调度中心;同盟有倒计时(65–215 秒,随回合增加);独立模拟、以及只有你一名玩家(只带 AI 队友)的同盟模拟不限时。所有人「准备就绪」即提前开战,全员就绪前可取消。 |
| 作战 | 每人在自己的战场各自作战(战斗在你的浏览器里模拟,2 倍速)。干员按**从上到下、从左到右**的顺序自动部署并消耗部署费用(初始 10,+1/秒)。时限 45–115 秒(实际秒数,即屏幕上的倒计时),时间到仍存活的敌人算漏怪。有敌人进入蓝门时,顶栏的目标生命值会**立即**显示扣除后的数值(红字 + 「−N」,每回合至多 10 点),结算时确定;联防阶段标注「联防中」,队友拦下的部分最后不扣。左侧队伍栏同样显示队友的待扣生命值。被击倒的干员**留在原地**(倒地姿势、略微变灰),头上的圆圈是再部署倒计时:绿色圆环 + 秒数;变成黄色「DP」表示部署费用不够,红色「!」表示原位被占。血条右侧的圆形图标是**元素损伤**:干员为带图标的彩色圆盘,敌人为小号纯色圆盘,白色圆环是剩余元素值;爆条后进入爆发冷却(灼燃 / 神经 / 侵蚀 10 秒,敌方侵蚀 8 秒,凋亡 15 秒),期间所有元素都不会累积也无法被治疗,结束后清零。元素爆发造成的是**无来源伤害**,不吃干员的增伤、穿透和装备加成(例如深巡打海嗣时灼燃爆发固定 7000),击杀仍记在爆条的干员名下;元素脆弱只提高元素伤害,不加快元素条;脆弱不影响元素伤害。已被击杀的敌人不会再被附加元素损伤。 |
| 作战 | 每人在自己的战场各自作战(战斗在你的浏览器里模拟,2 倍速)。干员按**从上到下、从左到右**的顺序自动部署并消耗部署费用(初始 10,+1/秒)。时限 45–115 秒(实际秒数,即屏幕上的倒计时),时间到仍存活的敌人算漏怪。有敌人进入蓝门时,顶栏的目标生命值会**立即**显示扣除后的数值(红字 + 「−N」,每回合至多 10 点),结算时确定;联防阶段标注「联防中」,队友拦下的部分最后不扣。左侧队伍栏同样显示队友的待扣生命值。被击倒的干员**留在原地**(倒地姿势、略微变灰),头上的圆圈是再部署倒计时:绿色圆环 + 秒数;变成黄色「DP」表示部署费用不够,红色「!」表示原位被占。血条下方的一行是**元素损伤**(与原版一致:元素图标 + 白条):干员为带图标的彩色圆盘,敌人为小号纯色圆盘,右侧白条是剩余元素值,像血条一样从右往左减少,白条耗尽的那一下就是爆条(被大额元素损伤一次打掉一截时白条也会一次少一截,例如掠海漂移体每击约为 25%–50%);爆条后进入爆发冷却(灼燃 / 神经 / 侵蚀 10 秒,敌方侵蚀 8 秒,凋亡 15 秒),这时白条变成**元素颜色**(神经青绿、侵蚀蓝、灼燃橙红、凋亡灰紫)并在冷却期间慢慢回满、图标闪烁——彩色条表示已经爆条、正在承受爆发效果,不是还剩的元素值;期间所有元素都不会累积也无法被治疗,结束后清零。元素爆发造成的是**无来源伤害**,不吃干员的增伤、穿透和装备加成(例如深巡打海嗣时灼燃爆发固定 7000),击杀仍记在爆条的干员名下;元素脆弱只提高元素伤害,不加快元素条;脆弱不影响元素伤害。已被击杀的敌人不会再被附加元素损伤。 |
| 联防 | 见第 5 节。 |
| 结算 | 扣 LP、发放悬赏资金、结算层数;LP ≤ 0 的玩家淘汰(其干员回到公共卡池)。 |
@@ -52,10 +52,11 @@
## 3. 招募、整备区与晋升
- **购买点两次**:第一次点商店卡片只选中(卡片抬起并打开详情,底部出现「确认购买 · 再次点击」),再点这张卡片的**任意位置**(包括金色的确认条)才购买;点别处或按 Esc 取消。升级调度中心、晋升奖励同样两次。不能购买时(例如已准备就绪)点卡片只打开详情。
- **公共卡池**(同盟):每名干员的副本数有限,I–VI 阶分别 12 / 14 / 18 / 16 / 8 / 5 份,所有玩家共用;持有的干员(棋盘 + 整备区)占用副本,商店里展示的不占用。一名干员被大家拿光后就刷不出来了;淘汰玩家的干员会还回卡池。
- **位置上限**:棋盘最多部署 **8** 名;整备区 **10** 格;整备区满时新获得的单位(合成的精锐、奖励、退回的装备等)进入**临时整备区**(整备区前面的 5 格)。临时整备区有单位时**不能准备就绪**,棋盘上会用红色虚线框标出这一排并说明规则:把它们放进整备区或战场、配发或使用,或者出售 / 销毁。休整期中进入临时整备区的单位,若到**本休整期结束**时仍留在那里就会被自动处理(干员返还卡池、装备销毁);作战、结算期间或准备就绪之后才进入的(例如战斗奖励、结算时合成的精锐),会保留到**下一个休整期**结束,你一定有机会处理它们。
- **晋升精锐**:3 名同名干员(无论是否部署)自动合成**精锐**(等级、技能、模组大幅提升;风丸只需 2 名),精锐不会再合成。精锐进入整备区(满了进入临时整备区),原先配发的装备退回整备区。
- **晋级奖励**:每次晋升后获得一次特殊招募:从 3 名 **高一阶**(最高 VI)的随机干员中**免费**挑 1 名。它不能刷新、不能冻结、本回合结束失效。
- **晋级奖励**:每次晋升后获得一次特殊招募:从 3 名 **高一阶**(最高 VI)、**互不相同**的随机干员中**免费**挑 1 名(该阶剩余干员不足 3 名时由低一阶补足)。它不能刷新、不能冻结、本回合结束失效。
- 每回合商店会自动刷新未冻结的格子。
## 4. 摆阵与朝向
@@ -65,9 +66,14 @@
- **点选按地上的方格**:点哪个方格就选中站在那个方格上的单位,点空方格不选任何单位(点到后一格干员画出来的头部,算作点了后面那一格)。作战中友方同样按方格;敌人在格子之间走动,点它的身体或脚下附近(0.6 格内)即可,飞行敌人点它的身体。
- 拖动时模型一直在指针 / 手指下(脚在指针下方约半格,鼠标和触屏一样),**指针所在的格子**就是落点并高亮;装备拖到哪个格子,就配发给那个格子上的干员;在商店栏上松手则放回原位。
- 点击干员显示攻击范围和底部按钮(「撤退」回到整备区、「出售 +1」);右键 / 长按查看详细信息:头像旁是盟约,下面第一项就是干员的**特质**(如「休整期结束时 …」),之后是特性、属性、攻击范围、技能、模组、装备、天赋。属性是**实时数值**:作战中每秒刷新 4 次(当前生命、攻击、防御、法抗、攻击间隔、阻挡),休整期显示棋盘上的干员**开战时**的数值(已算入装备、盟约层数、特质、策略与机变效果);高于基础值为绿色、低于为红色并标出差值(攻击间隔变短算增益),鼠标悬停可看基础值。
- 能在休整期摆放的召唤物只有天赋召唤物(海嗣、狼群、流形);赫默的医疗无人机、巫恋的诅咒娃娃这类技能召唤物只在技能发动时出现。
- **阻挡**看接触:干员阻挡中心距离约 0.7 格以内、与它接触的敌人(地面干员挡不住飞行和近地悬浮的敌人)。敌人卡在两名干员之间时,阻挡它的干员倒下、撤退或被晕眩后,另一名仍与它接触、还有空余阻挡数的干员会**立刻接替阻挡**;都没有空位时敌人才会走掉。迎面走来的敌人会停在干员格子前面。**近战干员**总能攻击自己阻挡的敌人(即使它不在攻击范围内),并优先攻击它;站在近战位的远程干员也能阻挡,但只打攻击范围内的敌人、不会优先打自己阻挡的敌人(挡在身后、范围外的敌人打不到)。
- 开战时干员按**从上到下、从左到右**的顺序部署,召唤物在所有干员之后。被阻挡的敌人先打阻挡它的干员;没被阻挡的远程敌人先打嘲讽等级高的,同级时打最后部署的(召唤物、靠下靠右的干员和刚再部署的干员更容易挨打)。隐匿、迷彩的干员只会被它阻挡的敌人攻击。
- **召唤物手动摆放**:把带可部署召唤物的干员放上棋盘后,它的召唤物会进入整备区(赫默的医疗探机、巫恋的诅咒娃娃、凯瑟琳的 2 个支援装置、浊心斯卡蒂的海嗣、伺夜的狼群、缪尔赛思的流形;同一干员的召唤物叠在一格,不占部署位),像干员一样拖到棋盘上并选方向。凯瑟琳的支援装置给**它面朝的那一格**上的干员加屏障,所以要放在干员旁边、朝向干员。撤退或出售干员时它的召唤物一起移除;**移动干员到别的格子时(包括把召唤物拖到干员身上交换位置),它已摆放的召唤物会退回整备区**,需要重新摆放。整备区满时退回的召唤物进入临时整备区,休整期结束还没处理的会在下一回合重新回到整备区。
- 作战中:海嗣、狼群、流形、支援装置开战时就在摆放的位置部署;医疗探机、诅咒娃娃开战时也在摆放的位置免费部署一次(持续 10 / 15 秒,和官方一样不需要先开技能),之后所属干员每次发动技能时再次在原位出现;所属干员被击倒、再部署回场时,技能给的那一个如果还没出现,会立刻出现。巫恋退场时诅咒娃娃随之撤退,赫默退场时已出现的医疗探机不受影响。医疗探机、诅咒娃娃、支援装置、海嗣没有摆放就不会出现;狼群、流形(战术家的援军)没有摆放时会在战术点出现。
- **阻挡**看接触:干员阻挡中心距离约 0.7 格以内、与它接触的敌人(地面干员挡不住飞行和近地悬浮的敌人)。敌人卡在两名干员之间时,阻挡它的干员倒下、撤退或被晕眩后,另一名仍与它接触、还有空余阻挡数的干员会**立刻接替阻挡**;都没有空位时敌人才会走掉。迎面走来的敌人会停在干员格子前面。**阻挡了就一定能打到**:任何干员(包括站在近战位的远程干员和召唤物)总能攻击自己阻挡的敌人,不管它在不在攻击范围内、在干员哪一侧,并且优先攻击它(医疗干员和「停止攻击」类技能期间除外;技能原文另有限制的也按原文:普罗旺斯二技能开启时不以生命值高于 80% 的敌人为目标,寒檀二技能的冰凌随机落在攻击范围内的地块上)。
- 开战时干员按**从上到下、从左到右**的顺序部署,召唤物在所有干员之后。被阻挡的敌人先打阻挡它的干员;没被阻挡的远程敌人先打嘲讽等级高的,同级时打最后部署的(召唤物、靠下靠右的干员和刚再部署的干员更容易挨打)。隐匿的干员只会被它阻挡的敌人攻击;**迷彩**(如忍冬三技能期间击倒敌人后、寒芒克洛丝一技能)同样只会被它阻挡的敌人攻击,也显示为半透明,但它不算隐匿(不触发叙拉古等「隐匿时」效果)。叙拉古的隐匿按时(32 秒 + 每层 0.4 秒)结束;忍冬的迷彩按官方持续到她下一次开技能。
- **技能自动释放**按官方「技能策略」:一般的手动技能在技能就绪、即将攻击 / 治疗且初始攻击范围内有敌人(治疗技能:有受伤的队友)时释放;**带独立技能范围**的技能(如德克萨斯「剑雨」、凛御银灰「御敌的锋锐」、锏「归于宁静」,不含「攻击范围扩大」类)在**技能范围内出现敌人**时就释放,不必先攻击;**重装干员**的手动技能在受到伤害时释放;执旗手 / 战术家 / 吟游者技能就绪即释放,解放者 / 阵法术师在初始攻击范围内有敌人时释放。**自动触发**的技能按其本身规则,例如古米「备用军粮」在身边(技能范围内)有受伤的队友时就会发动,下一次攻击改为治疗该队友(不用先接敌;她的二技能「食粮烹制」是手动技能,按重装规则在受到伤害时释放);塞雷娅「急救」在她即将攻击时、身边有生命不高于一半的队友才发动,这次攻击直接改为治疗;新约能天使二技能的护盾按官方给攻击范围内**仇恨最高**的队友(嘲讽等级高的优先,同级时给最后部署的),不一定是站在最前面挨打的那个。手动技能的自动释放有 3 秒冷却:开战部署后先等 3 秒,之后同一干员两次自动释放至少间隔 3 秒(可充能的技能不会同时放完)。
- **推开 / 拉拽**按官方「力度 − 重量」决定距离:例如野鬃二技能(中等力度)把重量 0 / 1 / 2 / 3 的敌人推开约 2.1 / 1.7 / 0.4 / 0.1 格,重量 4 以上推不动——轻型敌人反而推得更远。攻击速度最低为 20(沼泽、寒霜等减速叠满时也不会更慢)。
- **元素损伤条**在单位自己的血条正下方:左边是元素图标,白条是剩余的元素值,只有真正爆发时才会耗尽;爆发后条变成元素的颜色、图标闪烁,在冷却期间慢慢回满(这段时间不会再积累元素损伤)。
- **敌人**:远程敌人的射程按官方从干员的碰撞体积(半径 0.25 格)算起,比数据上的攻击范围半径远 0.25 格——例如萨卡兹枯朽战车(射程 2.2)能打到中心 2.45 格以内、站在低地的干员。它的第一下攻击就是「秽蚀轰击」(在目标处留下半径 1.7、持续 10 秒的污染区:低地每秒 50、高台 25 点真实伤害,同名不叠加),被阻挡时普通攻击伤害翻倍。“萨科塔之翼”等血量第一次低于一半时会在原地小范围乱飞 5 秒(官方叫「恐惧」);叙拉古、妮芙等干员造成的恐惧会让敌人朝远离干员的方向逃跑。
- 右上角 🔍▶▶ 可以查看本回合即将来袭的敌人(预览区),左上角 🔍 查看本局信息(禁用盟约、策略)。
## 5. 联防(同盟)
@@ -79,6 +85,7 @@
- 只有完美作战的队友能参与联防;联防中**盟约不会叠加层数**。
- 联防中击杀悬赏敌人,资金归击杀者。
- 联防后仍然存活的敌人,按原主人扣 LP;没人完美作战时直接扣。
- 漏怪的玩家在顶栏中间(击倒数旁)看到橙色的「×N」标签:你漏过、仍在联防战场上的敌人数(含还没出场的,**不设上限**),队友每击倒一个就减一(会分裂或召唤的敌人被击倒时,分裂出的敌人也算你的,数字会相应增加);目标生命值同时显示为扣除 min(N, 10) 后的数值。左侧队伍栏里漏怪队友的行也显示 ×N(与战场上的击倒同步更新;鼠标悬停可看到是谁漏过的敌人)。
- 每个普通回合每人**最多扣 10 点 LP**。
## 6. 盟约
@@ -87,14 +94,14 @@
- **核心盟约**(8 个势力:炎、萨尔贡、维多利亚、谢拉格、拉特兰、阿戈尔、叙拉古、卡西米尔):3 名激活,6 名获得强力的额外效果。
- **附加盟约**(精准、迅捷、灵巧、奥术、坚守、助力、远见、奇迹、投资人、突袭、不屈、调和、协防干员、独行、绝技):多为 2 / 3 名激活;「独行」需要场上**恰好 1 名**,「绝技」按场上的精锐数量计算,没有势力盟约的干员算「协防干员」。
- **炎佑**:场上 6 名以上「炎」干员时召唤炎佑(9 名时两只)。它追踪全场仇恨最高的敌人并悬停在其上方,技能「祛恶之焰」锁定 1 个目标**持续喷火**(最长 20 秒,每秒对目标周围的敌人造成法术伤害并附带灼燃,喷火时不普攻,目标死亡或被沉默时结束);场上暂时没有敌人时停在原地等待。
- **炎佑**:场上 6 名以上「炎」干员时召唤炎佑(9 名时两只)。它追踪全场仇恨最高的敌人并悬停在其上方,技能「祛恶之焰」锁定 1 个目标**持续喷火**(最长 20 秒,每秒对目标周围的敌人造成法术伤害并附带灼燃,喷火时不普攻,目标死亡或被沉默时结束);场上暂时没有敌人时停在原地等待。它持有官方的「孤立」:医疗干员不会治疗它,友方的增益、光环和天赋也不会选中它(敌人照常可以攻击它)。
- **层数**:盟约通过各种效果叠加层数,层数越高效果越强(例如「炎」攻击力随层数提升、「远见」每 10 层给资金)。**层数整局保留,但只有盟约激活时才生效**,多数情况下也只有激活的盟约能叠层。
- 每局会随机**禁用**若干盟约(标准模拟较少),它们的干员不会出现在商店;开局的「确认本局信息」会列出。
- 两件同名装备会合成一件更强的装备;特定的装备组合可以为干员赋予额外的盟约效果。
## 7. 最终攻势与隐秘核心
- **最终攻势**(第 14 回合;独立标准为第 9 回合):按座位两两配对,每对共享一个战场,一人守左半边、一人守右半边(右侧玩家的棋盘左右镜像)。所有人**共同削减同一条领袖血条**,看不到另一组的战场。全队 LP 合并成一个池,不再有每回合 10 点上限。顶部的 120 秒倒计时结束后战斗**继续**(不会超时判负);从开战第 150 秒起(实际秒数)全队每秒扣 1 点 LP。领袖血量归零即胜利,LP 归零即失败。此阶段盟约不叠层。
- **最终攻势**(第 14 回合;独立标准为第 9 回合):按座位两两配对,每对共享一个战场,一人守左半边、一人守右半边(右侧玩家的棋盘左右镜像)。所有人**共同削减同一条领袖血条**,看不到另一组的战场。全队 LP 合并成一个池,不再有每回合 10 点上限。顶部的 120 秒倒计时结束后战斗**继续**(不会超时判负);从开战第 150 秒起(实际秒数)全队每秒扣 1 点 LP。领袖血量归零即胜利(血条显示 0 时领袖已倒下,战斗立即结束),LP 归零即失败——以先归零的一方为准,LP 耗尽后打出的伤害不再计入。此阶段盟约不叠层。
- 最终攻势 / 隐秘核心的休整期按你在**关底战场上的半场**判定可部署地块(右侧玩家左右镜像),拖动时高亮的就是可放置的格子:例如「源石流发生装置」关底图中被 1×3 橙色管子围起的地块可以部署。在最终攻势放在这些格子上的干员进入隐秘核心时会留在原位。
- **巨型领袖**(假想敌:胄 / 管、盐风主教昆图斯、阿利斯泰尔、“萨米的意志”)的受击判定是一片约 5×3 格的区域(整个管道区块):攻击范围碰到其中任意一格就能打到它;它们**自缚、无法被阻挡**,不会离开原地。溅射按敌人的中心判定,打在旁边小怪身上的溅射碰不到巨型领袖。
- **隐秘核心**(第 15 回合,仅险境及以上):打倒领袖后,若全队激活的盟约层数总和 **> 1200**(独立 > 350)且 LP > 1,进入隐秘核心,挑战更强的领袖。失败不影响本局的通关。
+142 -63
View File
@@ -20,6 +20,7 @@ server/sim/
projectiles.js projectile flight & impact
skills.js SkillRuntime (SP, charges, triggers, kinds, SkillSpec)
ai.js operator attack loop, enemy route following / blocking / attacks
fear.js 恐惧 movement of enemies: reachable-tile fan away from the source, random checkpoints, self-fear flutter
professions.js default behaviour per subProfessionId
snapshot.js wire format (UnitInfo, snapshot tuples, flags, anim codes)
simdata.js data access + normalisation (data/*.json, research fallback)
@@ -60,9 +61,14 @@ b.fieldMeta(); // { fieldId, kind, rect, stageId, units: Uni
Construction creates every ally unit (not yet deployed) and installs content (kits, then domain modules). The first
`step()` (or `b.start()`) spawns stage crates, deploys all board units for free — per player the **operators top→bottom
then left→right** (right boss side: right→left in field columns), **then the summon pieces** the same way (PRTS
卫戍协议/帮助 "按从上到下>从左到右的顺序部署。优先部署干员,随后为召唤物") — firing `deploy {initial:true}` for each, forces
out the operators that enter knocked out (`carryState.down`, 联防 — §1.1), then fires `battleStart`. Every summon that came in during the initial deployment (also one an operator's deploy brought along)
then ranks after all the operators in the aggro order (`unit.aggroSeq`, §1.2). On a shared field (联防, the boss field)
卫戍协议/帮助 "按从上到下>从左到右的顺序部署。优先部署干员,随后为召唤物"; a skill's summon — 赫默's 医疗探机, 巫恋's
诅咒娃娃 — included, once, free: content/tokens.js `dockSkillSummons` / `releaseSkillSummon`, user playtest #6) — except a
summon piece content flags `deferDeploy` (one its owner's loadout does not make, e.g. 赫默 on S1; with
`SKILL_SUMMON_START_DEPLOY` off also a skill's summon, which then waits on its reserved tile until the skill gives
one) — firing `deploy {initial:true}` for
each, forces out the operators that enter knocked out (`carryState.down`, 联防 — §1.1), then fires `battleStart`. Every
summon that came in during the initial deployment (also one an operator's deploy brought along) then ranks after all
the operators in the aggro order (`unit.aggroSeq`, §1.2). On a shared field (联防, the boss field)
the players' fields deploy side by side: the i-th operators of all players come in together (in `players` order), then
the summons the same way, and the summons rank after the operators of all players **[ASSUMED]** (PRTS describes one
field — units deploy one after another with a fixed delay from the battle start — and "两处阵地将前后拼接为一处阵地"). Register hooks before the first step (e.g. `opts.setup(battle)` or right after `new Battle`).
@@ -85,7 +91,19 @@ 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
piece's facing, research 09 §1.2; absent/junk ⇒ RIGHT; with `abs` / `coords:'field'` it is a field direction as given).
Token pieces: `{ kind:'token', tokenId, ownerUid, row, col, dir? }`.
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
summon the owner holds from the start (狼群, 海嗣, 流形, 凯瑟琳's 爬行号·防护单元 — facing the operator it shields) deploys
with the board; a skill's summon (赫默 S2 医疗探机, 巫恋 S2 诅咒娃娃, "获得一个…") deploys once at the battle start, free
and regardless of the holding (PRTS §作战阶段 "所有手动部署的召唤物,无视所属干员的持有状态…作战开始时立即部署一次"), then
waits on its tile and takes the field there each time the skill gives one (stock ≤ 1, after the token's redeploy time
once it left, free; never while its owner is off the field [ASSUMED] — a stocked one deploys as soon as the owner is
back; the doll also leaves when 巫恋 leaves, the drone stays when 赫默 leaves: PRTS token 备注). A skill's summon, a
device or 海嗣 that was not placed never appears (the hidden 待部署区 deploys nothing by itself); the tacticians' 狼群 /
流形 still come as their 援军 on a tactical point without a piece (content/tokens.js `tacticalPoint`). The start deploy
is the user's call after playtest #6 (DESIGN §20); `shared/constants.js SKILL_SUMMON_START_DEPLOY = false` would bring
back the playtest #4 reading (only with the skill) in the sim and the summon card's hint (docs/PLAYING.md §4 and this
passage must follow; test/ui/playtest6_summons.test.js checks).
**Facing (DESIGN §3, `sim/dir.js`).** Every ally has `unit.dir`; `unit.fwd` = its forward vector `[dRow, dCol]` (UP
`[1,0]`, RIGHT `[0,1]`, DOWN `[−1,0]`, LEFT `[0,−1]`; row 0 is the bottom). Range grids and every relative offset
@@ -125,13 +143,26 @@ WALK legs pathfind on the stage grid inside the rect with the official flow fiel
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
the grid route does not walk (user playtest #2 item 2; test/sim/pathing-blockable.test.js). Enemies re-path whenever
an obstacle changes (`grid.version`), after a displacement and when 诱导 ends; the smoothed chain is only line-of-sight
an obstacle changes (`grid.version`), after a displacement and when 诱导 / 恐惧 ends (the 恐惧 and 诱导 walks likewise
re-plan from where the enemy stands after an obstacle change or when something else moved it — a push, or for 诱导 an
outranking 恐惧); the smoothed chain is only line-of-sight
clear from a tile centre, so an enemy re-planning off-centre first steps back to its tile centre when the straight line
to its first waypoint would cut a tile it cannot walk or a crate (a pushed enemy never clips a fence corner or breaks
the decorative crate on it — act1 m03 (11,5); test/sim/pathing.test.js). If crates cut every path the enemy walks through them, gets blocked by the crate
and destroys it. FLY legs fly straight between checkpoints. `disappear` hides
the enemy (untargetable, not in snapshots), `wait` pauses, `appear` teleports.
**恐惧 movement** (`fear.js`, user playtest #6; PRTS 诱发移动 §恐惧): a feared enemy leaves its route. Each application
(`Battle.applyStatus` stamps it on the buff) lists the 恐惧可达地块 — tile centres within 10 of where it was hit, inside the
±45° fan pointing away from the source, passable for its motion (obstacles or not), able to reach its goal, not the
objective. The enemy moves (own speed) to a random one of them, offset at random inside a 0.5-side square, then picks
again; a pick more than 5 path steps away (or on an obstacle tile, which has no path) is dropped for good and its own
tile is used instead. No source, the unit itself as source (自惧: the “萨科塔” enemies' SelfFear) or a source on the hit
point ⇒ no tile ⇒ it flutters inside its own tile. Ground units walk the flow-field waypoints to the checkpoint (back to
their tile centre first when an off-centre straight line would clip a wall, as route legs); pushed / pulled or after an
obstacle change, that walk re-plans from where the unit stands — to the same checkpoint while it stays reachable, else
to a new pick. A `wait` leg keeps counting; when the fear ends the route re-plans from where it stands. 恐惧 outranks 诱导.
**Blocking** (`Battle._checkBlock`, official contact rule — PRTS 游戏数据基础 §阻挡半径, 作战机制 "中点判定 … 案例: 阻挡";
user playtest #5 item 4): an unblocked, blockable enemy is blocked by an ally (or device) whose centre is within its
block radius of the enemy's position — `constants.js BLOCK_RADIUS`: ground 0.7071 (compared as d² < 0.49999037, the
@@ -141,24 +172,33 @@ not: an enemy that overlaps an operator when its blocker dies / is withdrawn / i
blocked enemy dies, is taken over at once; an enemy that finds no room walks on (pass-through). Several blockers in
contact → the nearest [ASSUMED]. A head-on enemy therefore stops at contact, ~0.71 tile from the blocker's centre, on the
tile in front of it (PRTS 作战机制: a blocked enemy's collider does not enter the blocker's tile; the official few
hundredths of a tile of deceleration are not modelled), and a **melee** blocker (data `position` MELEE, `targeting.js
meleeUnit`: 要塞 / 领主 / 哨戒铁卫 included) may always target the enemies it blocks, in range or not, and targets them first
("可以选择且优先选择阻挡单位", PRTS 选择器; 索敌的概念 "我方索敌优先级:阻挡(近战限定)" — `Battle.blockedTargets`, used by
`ai.js acquireTargets` and the skills' DEFAULT trigger, and `sortEnemyTargets`; "自身这格内" rules name the blocked enemies
separately — 瑕光 S2, PRTS 备注). A ranged operator standing on a melee tile blocks, but targets by its range alone: no
blocked-first priority, nothing out of range, no DEFAULT cast for the enemy it holds (PRTS 索敌的概念 "远程位干员通常而言不会
优先攻击自己阻挡的目标;甚至如果这个目标被阻挡在该干员的身后…无法攻击到这个敌人").
hundredths of a tile of deceleration are not modelled), and **every blocker** — melee units (要塞 / 领主 / 哨戒铁卫
included), summons (流形's melee copy) and a ranged operator standing on a melee tile alike — may always target the
enemies it blocks, in range or not, whatever its facing, and targets them first ("可以选择且优先选择阻挡单位", PRTS 选择器;
the user's rule after playtest #6, "阻挡了就一定要能打到": officially the collision pushes a blocked enemy to its blocker's
front — `Battle.blockedTargets`, used by `ai.js acquireTargets` and the skills' DEFAULT trigger, and
`sortEnemyTargets`; "自身这格内" rules name the blocked enemies separately — 瑕光 S2, PRTS 备注). A kit that picks its own
targets from the range (`beforeAttack`) adds `battle.blockedTargets` to its candidates (深靛, 迷迭香 S3, 荒芜拉普兰德 S2,
佩佩 S2, 灵知 S3, 远牙 S3); only skill texts that exclude targets keep their rule (普罗旺斯 S2: none above 80 % HP; 寒檀
S2: icicles on random tiles of her range). This replaces the
playtest #5 QA's melee-only reading (PRTS 索敌的概念 "我方索敌优先级:阻挡(近战限定)"; "远程位干员…无法攻击到这个敌人");
DESIGN §20 keeps the one-line flip (`Battle.blockedTargets` / `sortEnemyTargets` gated on a melee position again).
**Enemy attacks:** blocked melee enemies hit their blocker; enemies with `rangeRadius > 0` **and `applyWay` ≠ `MELEE`**
attack allies within the radius (a MELEE enemy only ever hits its blocker — data/enemies.json already zeroes their
`rangeRadius`, the engine enforces it for any source; content may set `enemy.profile.melee = false`). Target order
attack allies whose collider touches their range circle — centre distance ≤ `rangeRadius` + `ALLY_COLLIDER_RADIUS` 0.25
(PRTS 作战机制 §碰撞体积与位置识别: "我方干员碰撞体积基本均为以0.25格为半径的圆形", 索敌 uses the colliders; user playtest #6
follow-up: 萨卡兹枯朽战车's 2.2 reaches 2.45; enemy skills / zones of content keep their own point radius [ASSUMED]) (a
MELEE enemy only ever hits its blocker — data/enemies.json already zeroes their `rangeRadius`, the engine enforces it
for any source; content may set `enemy.profile.melee = false`). Target order
(targeting.js `sortAllyTargets`, PRTS 作战机制 索敌 "阻挡→特殊优先级→仇恨值(更容易被攻击→…→最后部署的目标→不容易被攻击)"):
its blocker → highest taunt level → latest deployed (`aggroSeq` = the deploy order: a redeploy or a mid-battle summon is the
latest). `enemy.profile.canTarget(ally)` (content: 萨卡兹枯朽战车 "只攻击位于低地的我方单位,且不会攻击飞行单位", 掠海漂移体 / “萨科塔之眼” 不会攻击飞行单位 …) filters the candidates before the order;
a special priority (假想敌:铳 / 昆图斯 highest DEF, 假想敌:胄 highest / lowest ATK, “自在” nearest …) sorts by its key and
breaks ties by taunt, then latest deployed (`aggroCmp`); `untargetable` / sleeping allies and devices are never targets;
a stealthed ally (隐匿 / 迷彩, 排气格栅) only for the enemy it blocks — our operators keep 隐匿 while blocking (PRTS 作战机制
§隐匿; 索敌的概念: a blocked enemy "强行无视对方可选性" attacks its blocker)
a stealthed ally (隐匿, 排气格栅) only for the enemy it blocks — our operators keep 隐匿 while blocking (PRTS 作战机制
§隐匿; 索敌的概念: a blocked enemy "强行无视对方可选性" attacks its blocker); a camouflaged one (迷彩, flag `camou`: ba.camou
"不阻挡时不成为敌方普通攻击的目标") likewise (PRTS 异常效果: neither anomaly is "阻挡时解除") [ASSUMED: enemy skills and
splash selectors skip it too]
and pause `ATTACK_PAUSE` (0.35 s) after each unblocked attack; `fear`/`disarm` stop attacks; `dmgType 'none'` enemies
never attack; `dmgType 'heal'` enemies heal the lowest-HP% enemy in their radius instead. Content can take over an
enemy's attack: `enemy.profile.deferHit` = the engine makes the attack (target, timing, the `'atk'` event) but deals no
@@ -169,6 +209,14 @@ present when data/stages.json says `active: true` (this wins over the level file
research stages without `active` use `!hidden`. Active platforms/mounds (射击台, act1 m03) [ASSUMED, DATA §15.11] are
ground obstacles, and an operator standing on one is elevated (`unit.ground = false`: never blocks).
**Enemy damage zones** (`content/enemies.js zone` / `dmgZone` / `pollution`): a zone ticks on the allies inside it
(flyers, stealthed and untargetable ones included; `alliesInRadius`) through `dealDamage`, so shields absorb a damage
tick, damage-taken modifiers scale it and it counts for 受击回复 SP and TAKE_DAMAGE skills like any hit (§4; element fills
excepted). 【污染秽蚀】 (萨卡兹枯朽战车's 秽蚀轰击,
萨卡兹枯朽战士's death) is **true** damage, 50 / 25 per second on low / high ground (PRTS "每秒受到50/25点真实普通伤害 …
同名效果不叠加", user playtest #6): a unit covered by several zones takes one tick per second (`unit.mem.pollutedAt`), so
a crowd of dying 萨卡兹枯朽战士 totals 50 / s, not 50 × n.
**Ownership** (`enemy.ownerId`, used for `killed/total` and leak attribution): `ownerPlayerId` if given, else the
player whose half contains the spawn tile (cols ≥ 11 = right half / player with colOffset 8 or side R). A leak is
recorded for the player whose half contains the goal it reached, with `sourcePlayerId` preserved for unite LP.
@@ -183,7 +231,9 @@ recorded for the player whose half contains the goal it reached, with `sourcePla
unitsEnd: [{ uid, id, defId, hpPct, sp, skillActive, alive }],
unitStats: [{ id, uid, defId, name, kind, dmg, kills, heal, taken, attacks }] } } }
```
- `cleared`: no spawns pending and no enemy alive (or the shared boss pool reached 0). A boss/hidden battle **with a
- `cleared`: no spawns pending and no enemy alive (or the shared boss pool reached 0 — a pool never holds less than
`BOSS_POOL_MIN_HP` (1): the hit that would leave less takes the rest and the browser's `LocalBossPool` reads less as
0, so float dust of the pool sync can never keep a leader alive; user playtest #6). A boss/hidden battle **with a
`sharedBoss`** ends only when the pool reaches 0 or on `forceEnd` (DESIGN §5.5) — an emptied field keeps running
(the h07_04 pair leader has no wait step and can walk into the objective: the leak costs its `lpr`, then the field
idles while another field may still empty the pool or the match's overtime drain — team LP, from 150 real s — ends it); without a pool
@@ -272,7 +322,7 @@ elementalTakenMul (元素脆弱: 元素伤害), healingDealtMul, healingTakenMul
hpRegen, shield, flags{…}`.
Aggregation: `ATK/DEF/maxHp = (base + Σflat) × (1 + Σpct) × Πmul`; `res = clamp((base + ΣresFlat) × ΠresMul, 0, 100)`;
`aspd = clamp(base + Σaspd, 10, 600)`; `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.
---
@@ -295,8 +345,13 @@ multiplicative: `atkMul defMul hpMul resMul moveMul dmgDealtMul dmgTakenMul phys
elemTakenMul elementalTakenMul healingDealtMul healingTakenMul spRecoveryMul redeployMul atkScaleMul physDealtMul artsDealtMul`.
A `rangeExtend` on a `persist` never-expiring buff is **permanent**: it also widens the initial range (§7.1).
**Flags:** `stun freeze sleep silence disarm stealth invulnerable unblockable levitate fear cold reveal bind noHeal
untargetable blockFly noMove noSp burstLock hidden attract float noDisplace` (`float` = 近地悬浮 (an air unit,
`Unit.isFlying`), `noDisplace` = 失衡免疫 (`displace()` moves nothing)). `taunt: true` as a flag counts as +1 taunt level (DESIGN §5.3).
untargetable blockFly noMove noSp burstLock hidden attract float noDisplace isolated camou` (`float` = 近地悬浮 (an air
unit, `Unit.isFlying`), `noDisplace` = 失衡免疫 (`displace()` moves nothing), `isolated` = 孤立 ("无法被同阵营选中": no ally
ability selects it — no heal, buff, aura or talent pick from another ally (`battle.allySelectable` / `alliesFor`,
`alliesInGrid`; PRTS 选择器 可选判定), enemies still target it; a summon's tokens.json `abnormal` 'isolated' also sets
`noHeal`, 'healFree' (禁疗) sets `noHeal` — Battle._setupUnit, DATA.md tokens.json; 禁疗 keeps heals off only — an HP-regen
attribute such as 安洁莉娜's 兼职工作 still applies, PRTS 异常效果), `camou` = 迷彩 (below)). `taunt: true` as a flag counts
as +1 taunt level (DESIGN §5.3).
**Statuses** — `battle.applyStatus(target, key, { duration, source, value, force, refresh, point })` (returns true if
applied); honours enemy/op immunities (`stun`, `silence`, `sleep`, `frozen`, `levitate`, `feared`) unless `force`; fires
@@ -318,14 +373,15 @@ if it outlasts it, resumes when the strong one expires (pass `refresh` to opt ou
| `bind` (束缚) | cannot move | – |
| `fragile` / `artsFragile` / `physFragile` / `elemFragile` | taken ×(1+value) (*strongest*); `elemFragile` = 元素脆弱 ("受到的元素伤害提升"): `elementalTakenMul`, 元素伤害 only — never the gauge | 0.3 / 0.3 / 0.3 / 0.2 |
| `silence` | no skill activation | – |
| `fear` (恐惧) | 无法被阻挡并四散逃跑: enemy cannot attack, is unblockable (released) and does not advance | – |
| `fear` (恐惧) | 无法被阻挡并四散逃跑: enemy cannot attack, is unblockable (released) and leaves its route: it runs between random checkpoints of the fan away from the source (§1.2 恐惧 movement; no source / itself ⇒ inside its own tile) | – |
| `tremble` (战栗) | 被阻挡后无法进行普通攻击: no normal attack **while blocked** (abilities still fire) | – |
| `palsy` (麻痹) | each stack cancels one enemy normal attack (max 3, lasts until consumed); refused by 麻痹免疫 (data `palsyImmune`) | stacks, default 1 |
| `disarm` | no normal attacks | – |
| `stealth` / `reveal` | untargetable unless blocked (an ally: only the enemy it blocks attacks it) / cancels stealth | – |
| `stealth` / `reveal` | 隐匿: untargetable unless blocked (an ally: only the enemy it blocks attacks it) / cancels stealth | – |
| `camou` (迷彩) | an ally's camouflage (ba.camou "不阻挡时不成为敌方普通攻击的目标"): like `stealth` for enemy targeting (only the enemy it blocks attacks it) and on screen (b.snap stealth bit, `snapshot.js flagsOf`), but not 隐匿 for 隐匿-conditions (叙拉古, 家族徽章) and under its own buff keys. 忍冬 S3 (key `vulpis:camou`, until her next cast), 寒芒克洛丝 S1 | – |
| `invulnerable` | ignores damage | – |
| `levitate` (浮空) | stun + unblockable (unblocks enemies) + 失衡免疫 (`noDisplace`); an air unit meanwhile (`isFlying`: melee cannot hit it); **half duration on units with (current) massLevel > 3**; refused on data flyers (`motion` FLY) and units already levitated (PRTS 异常效果 "若单位数据上为飞行单位…或是持有浮空异常则Buff取消") — a 近地悬浮 enemy is WALK in its data, so it can be levitated; 浮空 is not one of the 近地悬浮 enemies' drop triggers | – |
| `attract` (诱导) | 无法被阻挡并向目标位置移动: unblockable (released); the engine walks it (own speed, grid path re-planned on obstacle changes; flyers straight) to `opts.point` (`[r, c]` or `{x, y}`, default the source's tile, clamped to the rect) and keeps it there; stun/bind/sleep stop it; its route re-plans from where it stands when the status ends. A new application moves the point | point |
| `attract` (诱导) | 无法被阻挡并向目标位置移动: unblockable (released); the engine walks it (own speed, grid path re-planned on obstacle changes, after a push and after an outranking 恐惧; flyers straight) to `opts.point` (`[r, c]` or `{x, y}`, default the source's tile, clamped to the rect) and keeps it there; stun/bind/sleep stop it; its route re-plans from where it stands when the status ends. A new application moves the point | point |
| `resist` (抵抗) | the control statuses of `RESIST_STATUSES` (晕眩 冻结 寒冷 沉睡 恐惧 战栗 诱导 浮空 束缚 沉默 缴械 停顿 减速) applied to the unit last ×(1 − value); a resisting unit loses one 麻痹 stack every 5 s. **同名效果不叠加** — `battle.resistOf(u)` = the strongest `status: 'resist'` buff (never a product): several sources (灵知, 流明, 寒檀, enemy talents) never compound. *strongest*; a permanent one that survives death is a `persist` buff with `status: 'resist', data: { value }` | 0.5 (≤ 0.95) |
| `taunt` | taunt level +value | 1 |
| `weaken` | atkMul 1 − value (*strongest*) | 0.3 |
@@ -366,7 +422,10 @@ ends EVERY gauge of the unit resets to 0. A burst that is still resolving counts
fills of that unit are refused until the burst has resolved — an `elementBurst` handler that spreads the element to
neighbours (淤困 parasite) cannot bounce it back into a second burst. `elementView(u, now)` (damage.js) is the one gauge
a unit shows (b.snap `elem`, §9): the fullest one, ties by the official element id (`constants.js ELEMENT_ORDER`: 神经,
侵蚀, 灼燃, 凋亡, then the legacy `necrosis`); during a 爆发冷却 the bursting element with fill 1 and the cooldown's end.
侵蚀, 灼燃, 凋亡, then the legacy `necrosis`), with its fill rounded DOWN to 1 % and kept within 0.01–0.99 — the remainder
the client draws (the white bar, 1 − fill) is never less than what is left (except that a first chip of less than 1 %
already shows as 1 % taken), so it only runs out when the gauge bursts (user playtest #6); during a 爆发冷却 the bursting
element with fill 1 and the cooldown's end (the client draws that refill in the element's colour, not white).
**元素伤害** (element HP damage, e.g. "每秒受到…元素伤害") is the DamageInfo type `'elemental'` (+ optional `element` for the
client colour): no DEF/RES/dodge — 元素抗性 instead (the target's data `epDamageResistance`, `max(A × (1 − D/100), 5 %A)`,
PRTS 游戏数据基础 DMG_e; 0 on every enemy in data/enemies.json) —, × source `dmgDealtMul` × target `elementalTakenMul`
@@ -394,10 +453,11 @@ Order: invulnerable / asleep? → **`hit`** (mutate `dmg`, set `dmg.cancel`) →
`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) →
shields → HP loss
(boss units: routed to `sharedBoss.damage(playerId, amount)`) → 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`.
`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);
× 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 (流失).
@@ -458,8 +518,9 @@ or guard with a per-unit flag while dealing it. When the guard trips, the logged
| `spawnDevice(key, row, col, { hp, obstacle, blockCnt, name, def, res, atk, bat, aspd })`, `setObstacle(r, c, on)` | obstacles re-path enemies; spawnDevice returns null outside the rect or on a living unit |
| `isReservedTile(r, c)` | true when a living unit stands there or it is the home tile of an ally piece that has not deployed yet / waits to redeploy — summon tile pickers must skip these (`findTacticalPoint` does) |
| `addProjectile({ from, target | to:{x,y}, speed, onHit(ctx), visual, source, hitDead })` | homing; fizzles if the target dies unless `hitDead` |
| `allySelectable(ally, by)`, `alliesFor(by, ownerId?)` | may an ability of ally `by` select `ally` — never a 孤立 unit (炎佑, 从不混淆的方向) but `by` itself (PRTS 选择器: "若掩码中孤立为1,且选择器的阵营与目标为友好关系,则不可选中"); `allies()` without the 孤立 ones — content picks ally targets (buffs, auras, 全场 talents, heal picks) through these, `alliesInGrid` too |
| `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 melee unit blocks — always selectable by it (none for a ranged operator on a melee tile, §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)` | |
| `addLayers(playerId, bondId, n, reason, {source})`, `addCoins(playerId, n)` | layers are a no-op when `flags.layerGainsEnabled` is false (unite/boss) |
| `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 }` |
@@ -467,7 +528,8 @@ or guard with a per-unit flag while dealing it. When the guard trips, the logged
| `addDp(playerId, n)`, `retreat(unit, {reason, permanent})`, `relocate(unit, r, c)` | |
| `redeploy(unit, { free=true, tile, keepSp })` | immediate (re)deployment of a dead/retreated ally (full HP, `deploy {initial:false}`); `free: false` pays `base.cost` DP (refused without it); `tile: [r, c]` lands on that tile once (home unchanged — later redeploys use the board tile; refused when off-rect or occupied, no fallback); `keepSp` keeps SP/charges (保留技力), restored before `deploy` fires — 突袭 raids, 阿戈尔 revive in place |
| `refreshRange(unit)`, `setExtraRange(unit, keys)`, `rangeChanged(unit)` | rebuild the ranges after changing `unit.rangeGrid` (流形 copies); extra targetable tiles (absolute keys; merged into every later rebuild until set again; `null` clears; never in `baseRangeKeys`): 蕾缪安 wanted, 维娜 S3 |
| `displace(enemy, {x, y}, tiles, {force})` | push/pull along passable tiles (by `motion`: FLY in the rect, else ground-passable); heavier (massLevel) enemies move less; nothing for 失衡免疫 (`noDisplace`: 近地悬浮, 浮空) and leaders; unblocks + re-paths |
| `push(enemy, force, {from, dir, fixed, fixedAngle, inward, effect})`, `pull(enemy, force, {to, center, stop})`, `pullToFront(enemy, unit, force)`, `forceLevel(enemy, force)`, `pushDistance(enemy, force, {effect})` | the official 位移 (PRTS 游戏数据基础 §重量公式 / 推与拉; user playtest #6 item 14): 受力等级 = 力度 (微小力 −1, 小力 0, 中力 1, 较大力 2, 大力 3 …) − current 重量等级 (massLevel, 失重 counts). Push distance per level (`constants.js PUSH_TILES`, PRTS 推与拉's 弹道 column): ≤ −3 → 0, −2 → 0.12, −1 → 0.44, 0 → 1.7, 1 → 2.14, 2 → 2.96, ≥ 3 → 3.53 tiles; `effect` = a 特效 push (`PUSH_TILES_EFFECT`: −2 → 0.085, −1 → 0.374, 0 → 1.562, 1 → 1.987, 2 → 2.773, ≥ 3 → 3.331 — 见行者 S1 / S2, `PUSH_EFFECT_SKILLS`; every other pusher uses the 弹道 column [ASSUMED]); radial (away from `from`) unless `dir` (directional: 推击手, 朝部署方向 — > 45° off or < 0.25 tile ⇒ radial and level −2; `fixed` waives both (圣聆初雪 S1), `fixedAngle` only the angle (见行者 S2)); `inward` = a push towards `from` (薄绿 S2), stopping at the 急停 radius. Pull: level ≥ 0 → to `to` / the 急停 radius around `center`, −1 → 35 % of the way, −2 → 0.03, ≤ −3 → 0; `pullToFront` aims at the 拉力起点 0.5 tile ahead of the unit with the 急停 radius 0.6708 around it (never moves an enemy the unit itself blocks — nor does a pull towards an ally's centre, e.g. the 流形 S3 pulse) |
| `displace(enemy, {x, y}, tiles)` | the raw mover behind push / pull: along passable tiles (by `motion`: FLY in the rect, else ground-passable), no weight rule; nothing for 失衡免疫 (`noDisplace`: 近地悬浮, 浮空) and leaders; unblocks + re-paths |
| `after(seconds, fn, {owner})`, `every(seconds, fn, {owner, immediate})` | return `{cancel()}`; fn(battle, sched) |
| `fx(kind, params)`, `rng()` (+ `rng.int/range/chance/pick/shuffle/weighted`) | never use Math.random |
| `forceAttack(unit, targets?, { noAmmo })` | an immediate attack with the current profile (hooks, attack SP, a running ammo skill's bullet); `noAmmo: true` = spends no bullet and emits no `ammoUsed` (圣约送葬人 extra attack). Returns true when it attacked |
@@ -486,16 +548,35 @@ or guard with a per-unit flag while dealing it. When the guard trips, the logged
by the skill (the pending "next attack" of an instant/charge skill, every shot of a timed skill including the one that
ends it) recover nothing, so a cost-N skill fires every **N+1** attacks (AK). Charges (`maxChargeTime > 1`):
SP fills to cost → +1 charge (SP restarts) until charges are full (then SP stays full).
- Triggers (`skill.trigger.rule` in data): `DEFAULT` — ready **and** about to attack/heal **and** an enemy (heal skills:
an injured ally) inside the **initial** range (`unit.baseRangeKeys`: its own grid + its permanent rangeExtend —
"攻击范围扩大" modules/talents as persist never-expiring `rangeExtend` buffs; no skill range, no temporary extend, no extra
keys) — or, checked **every tick**, an enemy (flyers included) inside a content trigger range
(`unit.skill.addTriggerRange(fn)`, `fn(battle, unit)` → list of ally units (their current range) or tile-key arrays;
returns an unregister fn; not for heal skills: 海嗣 "攻击范围视为自身攻击范围的延伸", 流形); `TAKE_DAMAGE` — ready and just hit; `SP_FULL` (`ALWAYS`) — immediately;
`CUSTOM_RANGE` — an enemy inside `trigger.customRangeGrid` (rotated by the unit direction like every grid); `SEARCH` — an enemy inside the INITIAL range, checked every tick (no attack needed; BWIKI "解放者/阵法术师子职业干员 技能1,初始攻击范围内出现敌人后自动释放", research 03 Addendum C1);
`NEVER` (alias `MANUAL`) — never auto-casts: the kit calls `unit.skill.activate(reason)` itself;
`GDGLOW_SKILL_2`, `MLYSS_WTRMAN` and any unknown rule behave like DEFAULT. Units that never attack (bard, phalanx,
librator) check DEFAULT every tick. Silence blocks activation. `gainSp` is ignored while a duration/ammo/toggle skill
- Triggers (`skill.trigger.rule` in data — the official 技能策略, PRTS 卫戍协议/帮助 §作战阶段 技能操作, resolved by
`tools/build-data.mjs resolveTrigger`: charId rows by skill index; the class rows (重装 / 执旗手 / 战术家 / 吟游者 / 解放者 /
阵法术师) for **every MANUAL skill** of the class; SKILL_RANGE for a MANUAL skill with a 技能范围 of its own; AUTO skills
take no row — they keep their own rule, DEFAULT or a kit override): `DEFAULT` — the basic strategy: ready **and** about
to attack/heal **and** an enemy (heal skills: an injured ally) inside the **initial** range (`unit.baseRangeKeys`: its
own grid + its permanent rangeExtend — "攻击范围扩大" modules/talents as persist never-expiring `rangeExtend` buffs; no
skill range, no temporary extend, no extra keys) or blocked by the melee unit — or, checked **every tick**, an enemy
(flyers included) inside a content trigger range (`unit.skill.addTriggerRange(fn)`, `fn(battle, unit)` → list of ally
units (their current range) or tile-key arrays; returns an unregister fn; not for heal skills: 海嗣 "攻击范围视为自身攻击
范围的延伸", 流形); `SKILL_RANGE` — "不通过普通攻击/治疗触发技能,仅在技能范围内存在敌人(无视其不可选中)时释放技能": any
living enemy (stealthed, untargetable, flying included) on `trigger.customRangeGrid` (= the skill's rangeGrid), every
tick, no attack needed; a kit may set `trigger.allies` (+ `hpAtMost`, default 1) for an injured, healable ally of the
grid at or below that HP ratio instead (the AUTO heal skill 古米 S1: PRTS 备注 "此技能在存在生命值不满的可治疗角色时可
触发…直至古米完成一次普通攻击的治疗" — her heal mode waits for its heal); `DEFAULT` with `trigger.allies` (+ `hpAtMost`,
`grid`) = the basic rule **and** such an ally on the grid: the cast replaces the attack about to be made (塞雷娅 S1 "触发
时会替换当次攻击", ≤ half HP); a cast whose ally condition fails before that attack is withdrawn, its charge returned;
`TAKE_DAMAGE` — ready and just hit (重装: "不受技能范围影响,受到伤害时释放技能"); `SP_FULL` (`ALWAYS`) — immediately;
`CUSTOM_RANGE` — an enemy inside `trigger.customRangeGrid` (rotated by the unit direction like every grid); `SEARCH` — an
enemy inside the INITIAL range, checked every tick (no attack needed: "不受基础策略影响,在初始攻击范围内存在敌人时释放技能");
`GDGLOW_SKILL_2` — "全场存在可选目标时释放技能": a targetable enemy anywhere (a heal skill: an ally that needs healing),
every tick; `NEVER` (alias `MANUAL`) — never auto-casts: the kit calls `unit.skill.activate(reason)` itself;
`MLYSS_WTRMAN` and any unknown rule behave like DEFAULT. Units that never attack (bard, phalanx, librator) check
DEFAULT every tick. Silence blocks activation. The automatic operations cool down (`constants.js AUTO_OP_COOLDOWN` 3 s,
"自动操作具有3s冷却,在完成一次操作或作战开始时部署的单位将进入冷却"): a MANUAL skill is auto-cast no sooner than 3 s
after its previous cast (a charged skill spends its charges 3 s apart) or after its battle-start deployment
(`Battle._deploy` initial; `battle.flags.startOpCooldown`, default 3 — the test harness sets 0 unless told otherwise);
AUTO skills are exempt; kits with their own automatic cast of a MANUAL skill check `skill.opCooling` (波登可 S1, 雪猎
special bullets, 流形 copy). While a cast "next attack" (instant / charges with an attack override) waits for its
attack, no further charge is cast. `gainSp` is ignored while a duration/ammo/toggle skill
runs (its bar shows the skill), whatever the reason, and — any reason but `'init'` — while the unit has the `noSp`
flag (阻回: "停止并阻止任意形式的技力回复"; the operators' 凋亡 burst, §3).
- Kinds: `duration` (mods for `duration` s), `ammo` (mods until `ammo` attacks were made, optional duration cap),
@@ -569,13 +650,13 @@ Element conventions of the kits (user playtest #5 #3; official term dictionary:
duration, // s (duration kind; optional cap for ammo)
ammo, // attacks (ammo kind)
spCost, initSp, charges, spType: 'time'|'attack'|'hurt', // optional overrides of the data values
trigger: 'DEFAULT' | { rule, grid }, // optional override
trigger: 'DEFAULT' | { rule, grid, allies?, hpAtMost? }, // optional override (allies / hpAtMost: an injured ally condition, §7.1)
heal: bool, // heal-type skill for the DEFAULT trigger (default: unit is a healer)
mods: { …mod keys }, // buff while active (instant: only during the pending attack)
flags: { …flags },
targeting: { maxTargets, rangeGrid, rangeExtend, priority, allInRange, canHitFly },
attack: { dmgType, atkScale, healScale, splashRadius, splashScale, hits, projectile, maxTargets, dmgMul,
chain: {count, falloff, radius, sluggish}, heal: {mode, count, falloff}, onHitStatus: {key, duration, value},
chain: {count, falloff, radius, sluggish}, heal: {mode, count, falloff, hpAtMost /* targets at or below this HP ratio only */}, onHitStatus: {key, duration, value},
onHit(ctx), // once per attack (main target; target may be null for a splash landing)
onEachHit(ctx) }, // per victim: main, every splash / chain victim — ctx += { dealt, kind:'main'|'splash'|'chain', isSplash, isChain, main, attackId }
// (overrides the profession profile while active; profiles may define onEachHit(b, u, victim, hctx) too)
@@ -607,7 +688,8 @@ whose skill "恢复…友方/友军…生命" heals the most injured ally in ran
`ep_damage_ratio` + "附带…凋亡/灼燃/神经损伤" ⇒ element damage on hit (凋亡 → `apoptosis`; × damage dealt when the text says
"伤害N%的…损伤", else × ATK); "屏障" + `shield_max_hp_ratio`/`hp_ratio` ⇒ decaying self shield at start (砾, 新约能天使);
"立即流失N%当前生命" ⇒ self HP loss at start (宴, 风丸); `hp_ratio` + "恢复/回复…生命" ⇒ self heal at start; `force` ⇒ on-hit
displacement (拖拽/hookmaster pull, else push). **Passive** skills only apply stat mods (timed when the text says "N秒内":
displacement by the official 力度 − 重量 rules (拖拽/hookmaster: `pullToFront`; else `push`, directional for 朝部署方向 / 向前 /
身前方向 and 推击手, radial otherwise — §6). **Passive** skills only apply stat mods (timed when the text says "N秒内":
宴 +65 % ATK for 14 s) and the self/counter effects — their scales describe procs that need a kit. Instant skills with
mods/targeting but no attack override apply them to the next attack (the skill range is switched in for that attack).
@@ -680,35 +762,31 @@ skill: { kind: 'charges', heal: true, attack: { onHit({ battle, unit, target, bb
} } },
```
**5. Summoner — 赫默 `chess_char_2_02_a` 医疗无人机** (bb `cnt 1`: gain a medical drone that heals nearby allies for 10 s):
**5. Summoner — 赫默 `chess_char_2_02_a` 医疗无人机** (bb `cnt 1`: "获得一个医疗无人机…10秒后自动销毁"; the drone is a hand
piece the player placed — user playtest #6 —, so the skill only brings that piece onto its tile):
```js
import { releaseSkillSummon } from '../tokens.js';
chess_char_2_02_a: (bb) => ({
skill: {
kind: 'instant', trigger: 'SP_FULL',
onStart({ battle, unit }) {
for (const k of unit.rangeKeys) { // first free walkable tile in range, nearest first
const r = Math.floor(k / 21), c = k % 21;
if (!battle.unitAt(r, c) && battle.grid.canStand(r, c, { ranged: true })) {
battle.spawnToken(unit, 'token_10000_silent_healrb', r, c, { duration: 10 });
break;
}
}
},
kind: 'instant', heal: true,
onStart({ battle, unit }) { releaseSkillSummon(battle, unit, 'token_10000_silent_healrb', { cap: bb.cnt }); },
},
}),
```
(the drone's stats come from `tokens.json variants[chess_char_2_02_a]`; it is untargetable and uses the heal profile.)
(the placed piece deploys once with the board — `SKILL_SUMMON_START_DEPLOY` — and then waits on its tile for the
skill; the drone's stats come from `tokens.json variants[chess_char_2_02_a]`; it is untargetable, uses the heal profile
and its token kit withdraws it after 10 s. A kit whose summon is not a hand piece spawns it itself:
`battle.spawnToken(unit, tokenId, r, c, { duration })`.)
**6. Tank TAKE_DAMAGE — 古米 `chess_char_1_10_a` 备用军粮** (rule `TAKE_DAMAGE`, bb `heal_scale 1.15`: the next attack
heals a nearby ally for 115 % ATK):
**6. AUTO heal skill — 古米 `chess_char_1_10_a` 备用军粮** (自动触发, bb `heal_scale 1.15`; PRTS 备注: cast once a
healable ally of the skill range is injured — no enemy needed — then her next normal attack heals that ally):
```js
chess_char_1_10_a: (bb) => ({
chess_char_1_10_a: (bb, chess, def) => ({
skill: {
kind: 'instant', // trigger comes from data: TAKE_DAMAGE
attack: { onHit({ battle, unit }) {
const ally = battle.alliesInRadius(unit.x, unit.y, 1.5, unit.ownerId).sort((a, b) => a.hpRatio - b.hpRatio)[0];
if (ally) battle.heal(unit, ally, unit.s.atk * bb.heal_scale);
} },
kind: 'instant', heal: true,
trigger: { rule: 'SKILL_RANGE', grid: def.skill.rangeGrid, allies: true }, // an AUTO skill: its own rule
targeting: { rangeGrid: def.skill.rangeGrid }, // the heal-mode attack: its skill range
attack: { dmgType: 'heal', heal: { mode: 'single' }, healScale: bb.heal_scale },
},
}),
```
@@ -839,7 +917,7 @@ Unknown subprofessions fall back to the profession default (test `professions.te
- `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)
(`spine`/`avatar` are asset ids from data).
- flags: UF bits (blocked 1, stunned 2, frozen 4, stealth 8, skill 16, shield 32, invuln 64, cold 128, sleep 256, flying 512);
- flags: UF bits (blocked 1, stunned 2, frozen 4, stealth 8 — 隐匿 or an ally's 迷彩 — skill 16, shield 32, invuln 64, cold 128, sleep 256, flying 512);
anim: ANIM codes (idle 0, move 1, attack 2, skill 3, die 4, stun 5, deploy 6).
---
@@ -920,7 +998,8 @@ per-battle view (`withUnitLoadouts`: id-only `getChess(id)` / `getToken(id, owne
chess id — the first player's when two players of one field differ, `view.loadoutConflicts`); Battle itself always passes
the unit's own loadout (`_createAllyFromInput`, `tokenDef` / `spawnToken`, the dollkeeper substitute), so it is exact.
Assumptions taken here (documented choices): element burst numbers for necrosis/apoptosis; unspawned enemies at timeout
are dropped (not leaks); geek drain is non-lethal; tactician reinforcement stats; displacement distance 0.5 + 0.5·force;
are dropped (not leaks); geek drain is non-lethal; tactician reinforcement stats; displacement is instantaneous (the official 失衡 slide over ≈ 0.4–1.2 s is not
modelled — only its distance, `push` / `pull`); pushes PRTS does not classify use the 弹道 distance column (`PUSH_TILES`);
platforms/mounds are ground obstacles that elevate operators; the generic kit maps 凋亡 to `apoptosis` (侵蚀 has no element key
yet — enemy content must pick one); 抵抗 covers the control statuses of `RESIST_STATUSES` (the official term lists 晕眩/寒冷/
冻结/恐惧/诱导…, the rest follow the operator kits that grant it) and caps at 0.95; tactical points prefer enemy path tiles.
+2 -2
View File
@@ -78,12 +78,12 @@ The visible chess per tier are 16/17/19/22/19/19. The item slot uses the same ti
| Item | Value | Tag |
|---|---|---|
| Deploy cap | **8** (`characterLimit`); **9** with 人事部文档. Summons don't use a slot [ASSUMED]. | DATA/VERIFIED |
| Deploy cap | **8** (`characterLimit`); **9** with 人事部文档. Summons don't use a slot (PRTS token pages of the pool's hand summons: 部署占用数 0; user playtest #6). | DATA/VERIFIED |
| Hand (整备区) | **10 regular + 5 temporary**, shared by operators, items and summon stacks. Full ⇒ no buy or withdraw. Passive overflow goes to temp; **temp blocks Ready**; temp is wiped at the next phase. Board↔hand swap is allowed when full. | VERIFIED |
| Equipment | 2 per operator (board **or** hand). Locked; 3rd = replace, and the old one is destroyed. 2 identical normal items (equipped or not) merge into golden. | VERIFIED |
| Deployment | Free, at t=0, order **top→bottom then left→right** (right-side boss player mirrored). Ranged units may use melee tiles. | VERIFIED |
| DP | Start **10**, **+1/s**, cap **99**. Used only for auto-redeploy (original tile, respawn timer done, DP ≥ `cost`). | DATA/VERIFIED |
| 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). charId rows per 03. | VERIFIED |
| 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 |
| 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 |
+1 -1
View File
@@ -926,5 +926,5 @@ Machine-readable copy: `01-core-data.json → _criticAddendum.enemyStatMultiplie
### A5. Other resolved items
- The **华法琳** granted-trait cap should be **12 / 24 per battle**. The official 3/27 notice changed it and PRTS lists 7/14 → 12/24. The client data still shows 7/14, even though the other 3/27 changes (奇迹 18 %, 远见 80/150, 商业包装方案 8/7) are already in it. See 02.
- The skill auto-cast rows with `skillIndex` 0 mean **skill 1 only** (BWIKI "重装职业干员 技能1,受到伤害后自动释放"). See 03.
- ~~The skill auto-cast rows with `skillIndex` 0 mean skill 1 only (BWIKI "重装职业干员 技能1,受到伤害后自动释放").~~ 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. See 03 C1.
- Asset URLs: 40 random URLs from `07-assets.json` were re-checked on 2026-09-27; 40/40 returned `200`.
+2
View File
@@ -1961,6 +1961,8 @@ o..##
Written 2026-09-27 by the completeness critic. Sources: BWIKI `盟约` §作战期 (auto-cast list), PRTS `卫戍协议/帮助` §战斗部署/手牌区, and the official gameplay-intro image.
### C1. Skill auto-cast: correction to §1.4
> 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. The "skill 1 only" reading below (BWIKI) is kept for the record.
The rows in `skillTriggerDataList` with `skillIndex: 0` apply to **skill 1 only**. They are not wildcards. Evidence:
- BWIKI transcribes every row as "技能1": "重装职业干员 技能1,受到伤害后自动释放"; "吟游者/执旗手/战术家子职业干员 技能1,技力满后自动释放"; "解放者/阵法术师子职业干员 技能1,初始攻击范围内出现敌人后自动释放".
- The data has a separate sentinel `-1` for "all skills" (缪尔赛思: "全部技能,受流形影响").
+5 -4
View File
@@ -57,12 +57,12 @@ html:not(.sp-in-match) .toast-host { margin-top: var(--sa-t); }
control, no layout change. Only controls that do not use ::before themselves are listed. */
:where(.sp-coarse) :where(.tapx, .gtop__exit, .gtop__iconbtn, .enemybtn, .readybtn, .gm__gear, .fsbtn, .ewheel__btn, .ewheel__nav,
.toolbtn, .funds__collapse, .team__btn, .effect, .bslot .bond, .vswitch__arrow, .edrawer__close, .dpanel__close, .bpop__close,
.modal__close, .uf__btn, .chud__back, .tabs__tab, .btn, .pill-btn, .scard__detail, .title-guide, .stephead__guide, .set-toggle, .set-seg button) {
.modal__close, .uf__btn, .chud__back, .tabs__tab, .btn, .pill-btn, .title-guide, .stephead__guide, .set-toggle, .set-seg button) {
position: relative;
}
.sp-coarse :is(.tapx, .gtop__exit, .gtop__iconbtn, .enemybtn, .readybtn, .gm__gear, .fsbtn, .ewheel__btn, .ewheel__nav,
.toolbtn, .funds__collapse, .team__btn, .effect, .bslot .bond, .vswitch__arrow, .edrawer__close, .dpanel__close, .bpop__close,
.modal__close, .uf__btn, .chud__back, .tabs__tab, .btn, .pill-btn, .scard__detail, .title-guide, .stephead__guide, .set-toggle, .set-seg button)::before {
.modal__close, .uf__btn, .chud__back, .tabs__tab, .btn, .pill-btn, .title-guide, .stephead__guide, .set-toggle, .set-seg button)::before {
content: '';
position: absolute; left: 50%; top: 50%; z-index: 0;
width: max(100%, var(--tap-min)); height: max(100%, var(--tap-min));
@@ -113,8 +113,9 @@ html:not(.sp-in-match) .toast-host { margin-top: var(--sa-t); }
}
.sp-reduced-motion .team__status.is-gold, .sp-reduced-motion .ff-tile--gate { animation: none; }
/* the shop card's ⓘ corner: a smaller hit area (the card itself is the main target; its first tap shows the detail) */
.sp-coarse .scard__detail::before { width: 30px; height: 30px; }
/* (the shop cards have no ⓘ corner: its invisible 44 px touch area — the 30 px override here lost on specificity to the
generic rule above — covered the bottom-right quarter of a phone's 67 × 97 px card and half of its 确认购买 strip, so
the confirming tap re-opened the detail instead; user playtest #6 item 10) */
/* Narrow viewports (< 768 CSS px: the root scale is clamped at 40 px, so the layout is narrower than the 19.2 rem
design): the shop bar would cover the corner's ⚙ / 📖 / ⛶ buttons — they wrap into a row above the 交流 button. */
+57 -20
View File
@@ -340,12 +340,15 @@
.spov__who.is-done .spov__wstate { color: var(--mint-400); }
.spov__who.is-cur .spov__wstate { color: var(--gold); }
.spov__grid { flex: 1; min-height: 0; display: grid; grid-template-columns: repeat(3, 1fr); grid-template-rows: repeat(2, 1fr); gap: .2rem; }
.spov__grid--3 { grid-template-columns: repeat(3, minmax(0, 4.3rem)); grid-template-rows: minmax(0, 5rem); justify-content: center; align-content: start; gap: .36rem; margin-top: .3rem; }
.spov__grid--3 .spcard { justify-content: flex-start; padding-top: .62rem; }
.spov__grid--3 .spcard__icon { width: 1.6rem; height: 1.6rem; margin-bottom: .1rem; }
.spov__grid--3 .spcard--bounty .spcard__icon { width: 1.4rem; height: 1.4rem; }
.spov__grid--3 { grid-template-columns: repeat(3, minmax(0, 4.3rem)); grid-template-rows: minmax(0, 4.6rem); justify-content: center; align-content: start; gap: .36rem; margin-top: .3rem; }
.spov__grid--3 .spcard__icon { width: 1.3rem; height: 1.3rem; }
/* The official card (research 06 §11.5; the official 悬赏 / 机密商店 / 战术 screenshots): a framed icon at the top-left
with the bold name beside it, the effect text under both across the card, left-aligned; the tier chip on the icon's
corner, the taker's round avatar at the top-right (user playtest #6 item 6: the centred icon + name used to push the
text out of a phone-sized card). --sp-px: the card's side padding; --tk / --tk-at: the taker avatar's size / inset. */
.spcard {
position: relative; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: .12rem; padding: .3rem .3rem .24rem; overflow: hidden; text-align: center;
--sp-px: .2rem; --tk: .5rem; --tk-at: .08rem;
position: relative; display: flex; flex-direction: column; align-items: stretch; justify-content: flex-start; gap: .12rem; padding: .16rem var(--sp-px); overflow: hidden; text-align: left;
background: linear-gradient(180deg, rgba(210, 224, 219, .12), rgba(120, 140, 132, .06)); border: 1px solid rgba(200, 220, 212, .25); color: var(--text-hi);
transition: transform var(--t-fast) var(--ease-out), border-color var(--t-fast), background var(--t-fast), opacity var(--t-med);
}
@@ -357,24 +360,34 @@
.spcard.is-taken > :not(.spcard__taker) { opacity: .38; filter: grayscale(.7); }
.spcard.is-mine { border-color: var(--mint-400); box-shadow: inset 0 0 0 1px var(--mint-400); }
.spcard.is-mine > :not(.spcard__taker) { opacity: 1; filter: none; }
.spcard__tier { position: absolute; left: .12rem; top: .12rem; }
.spcard__icon { width: 1.3rem; height: 1.3rem; display: grid; place-items: center; flex: none; }
.spcard__tier { position: absolute; left: .08rem; top: .08rem; z-index: 1; }
.spcard__head { flex: none; display: flex; align-items: center; gap: .18rem; min-width: 0; }
.spcard__icon { width: 1rem; height: 1rem; display: grid; place-items: center; flex: none; padding: .04rem;
border: 2px solid rgba(214, 228, 222, .7); background: radial-gradient(circle at 50% 40%, rgba(255, 255, 255, .1), rgba(0, 0, 0, .32)); }
.spcard__icon img { width: 100%; height: 100%; object-fit: contain; filter: drop-shadow(0 .06rem .12rem rgba(0, 0, 0, .6)); }
.spcard__icon .icon { width: .7rem; height: .7rem; color: var(--mint-500); }
.spcard--bounty .spcard__icon { width: 1.1rem; height: 1.1rem; border: 2px solid rgba(255, 90, 74, .5); background: radial-gradient(circle, #3a1c18, #140d0c); }
.spcard__icon .icon { width: 60%; height: 60%; color: var(--mint-500); }
.spcard--bounty .spcard__icon { padding: 0; border-color: rgba(255, 90, 74, .55); background: radial-gradient(circle, #3a1c18, #140d0c); }
.spcard--bounty .spcard__icon img { object-fit: cover; }
.spcard__body { display: flex; flex-direction: column; align-items: center; gap: .08rem; min-height: 0; }
.spcard__name { font-size: .26rem; font-weight: 900; letter-spacing: .06em; }
.spcard__desc { font-size: .16rem; line-height: 1.65; color: var(--text-md); max-width: 4.6rem; display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden; }
.spov__grid--3 .spcard__desc { -webkit-line-clamp: 8; }
.spcard__tags { display: flex; gap: .08rem; }
.spcard__title { display: flex; flex-direction: column; align-items: flex-start; gap: .06rem; min-width: 0; }
.spcard__name { font-size: .28rem; font-weight: 900; letter-spacing: .04em; line-height: 1.25; }
/* the effect text: the official rich text (highlighted numbers, set bonuses, line breaks), clamped by lines — 7 fit a 3×2
card (the longest item, 信标, takes 7 at 16:9 and on phones narrower than ≈ 740 px); it never shrinks below them
(flex: none) */
.spcard__desc { flex: none; font-size: .2rem; line-height: 1.4; color: var(--text-md); display: -webkit-box; -webkit-line-clamp: 7; -webkit-box-orient: vertical; overflow: hidden; }
.spov__grid--3 .spcard__desc { -webkit-line-clamp: 10; }
.spcard__tags { display: flex; flex-wrap: wrap; gap: .06rem; }
.spcard__tag { font-size: .13rem; font-weight: 700; padding: .01rem .08rem; }
.spcard__tag--team { background: var(--mint-500); color: var(--text-on-mint); }
.spcard__tag--coin { background: var(--gold); color: #1d1400; }
.spcard__taker { position: absolute; right: .12rem; top: .12rem; display: flex; align-items: center; gap: .06rem; padding: .03rem .1rem .03rem .03rem;
background: rgba(8, 11, 10, .92); border: 1px solid var(--mint-500); border-radius: .3rem; font-size: .13rem; color: var(--mint-400); font-weight: 700; }
.spcard__taker .pavatar { --pa: .4rem; border-radius: 50%; overflow: hidden; border: 2px solid var(--mint-400); }
/* the taker's badge: the round avatar alone at the card's top-right corner, as in the official screenshots (the player's
name is its tooltip and part of the card's accessible name); a taken card's head keeps clear of it, so a long name
wraps before the badge instead of running under it (the avatar + name pill of v2.4.1 covered long names once the name
moved into the top row) */
.spcard__taker { position: absolute; right: var(--tk-at); top: var(--tk-at); z-index: 1; display: block; border-radius: 50%;
box-shadow: 0 0 0 1px rgba(8, 11, 10, .9), 0 .04rem .12rem rgba(0, 0, 0, .6); }
.spcard__taker .pavatar { --pa: var(--tk); border-radius: 50%; overflow: hidden; border: 2px solid var(--mint-400); }
.spcard.is-taken .spcard__taker { filter: none; }
.spcard.is-taken .spcard__head { padding-right: calc(var(--tk) + var(--tk-at) - var(--sp-px) + .08rem); }
/* the card being picked (g.choice in flight): mint frame + a 选择中 strip with the sweeping busy bar of the buttons
(components.css btn-busy) — no spinner over the card's text (user playtest #3 item 9) */
.spcard.is-busy { border-color: var(--mint-400); box-shadow: inset 0 0 0 1px var(--mint-400); }
@@ -393,13 +406,37 @@
.spov.has-armed .spcard.is-pickable:not(.is-armed) { filter: brightness(.7); }
.spov.has-armed .spcard.is-pickable:not(.is-armed):hover { filter: none; }
.spcard__confirm {
position: absolute; left: 0; right: 0; bottom: 0; z-index: 3; display: flex; flex-direction: column; align-items: center;
padding: .05rem 0 .05rem; background: linear-gradient(0deg, var(--gold), #e0a800); color: #1d1400; pointer-events: none;
position: absolute; left: 0; right: 0; bottom: 0; z-index: 3; display: flex; align-items: baseline; justify-content: center; gap: .1rem;
padding: .04rem 0 .05rem; background: linear-gradient(0deg, var(--gold), #e0a800); color: #1d1400; pointer-events: none;
animation: confirm-in var(--t-fast) var(--ease-out);
}
.spcard__confirm b { font-size: .18rem; font-weight: 900; letter-spacing: .22em; padding-left: .22em; }
.spcard__confirm small { font-size: .11rem; letter-spacing: .1em; opacity: .75; }
.spcard__confirm small { font-size: .12rem; letter-spacing: .1em; opacity: .75; }
.spov__confirm { animation: pulse-gold 1.2s var(--ease-in-out) infinite; }
/* Short landscape phones (height < 10.8rem: the root size is clamped at 40 px, theme.css, so the page is only ≈ 9rem
tall): a tighter header (no CONTINGENCY micro line, smaller margins) gives the grid ≈ .65rem more, and a smaller icon
and tighter spacing leave a 3×2 card room for 7 lines of text (≈ 4 – 5.4 × 2.9rem: 162 × 116 px at 640×360, the
smallest supported phone; 201 × 119 at 756×366, the user's Android) — every shop item, bounty and tactic text fits in
full, taken or not (test/ui/playtest6-ui.e2e.test.js). */
@media (max-height: 431.98px) {
.spov__inner { top: .84rem; bottom: .22rem; gap: .1rem; }
.spov__titles > .micro { display: none; }
.spov__grid { gap: .12rem; }
.spcard { --sp-px: .12rem; --tk: .4rem; --tk-at: .04rem; gap: .06rem; padding: .08rem var(--sp-px); }
.spcard__tier { left: .03rem; top: .03rem; }
.spcard__head { gap: .12rem; }
.spcard__icon { width: .72rem; height: .72rem; padding: .02rem; }
.spov__grid--3 .spcard__icon { width: 1rem; height: 1rem; }
.spcard__name { font-size: .23rem; }
.spcard__desc { font-size: .19rem; }
.spov__grid--3 .spcard__desc { -webkit-line-clamp: 10; }
}
/* The narrowest of them (640×360): with Noto Sans SC — the page's web font and Android's CJK font — the longest text
(海沟实验体) wraps to 8 lines in a 162 px card, one more than the clamp; a slightly tighter line height gives the 8th
line room (QA, playtest #6). */
@media (max-height: 431.98px) and (max-width: 699.98px) {
.spcard__desc { line-height: 1.3; -webkit-line-clamp: 8; }
}
/* Grid image boxes: definite 100% tracks so `height: 100%` images resolve against the box (an auto track would
let non-square art overflow its frame). */
+7 -4
View File
@@ -82,6 +82,8 @@
.lvcard.is-max { background: linear-gradient(180deg, #d9b24a, #a07a1c); color: #1d1400; cursor: default; filter: none; }
/* ---- operator / item card ---- */
/* A tap anywhere on a card is the card's tap (no ⓘ corner — user playtest #6 item 10); the 确认购买 strip takes no
pointer. The detail opens with the first tap, a right-click / long press, or a tap while the bar is not editable. */
.scard-wrap { display: flex; }
.scard {
--tc: var(--tier-1);
@@ -126,14 +128,11 @@
.scard__bond .bglyph { width: .18rem; height: .18rem; }
.scard__class { display: flex; align-items: center; gap: .05rem; font-size: .13rem; color: var(--text-lo); }
.scard__prof { width: .18rem; height: .18rem; object-fit: contain; opacity: .85; }
.scard__detail { position: absolute; right: .04rem; bottom: .04rem; z-index: 3; width: .24rem; height: .24rem; display: grid; place-items: center; color: var(--text-dim); opacity: 0; transition: opacity var(--t-fast), color var(--t-fast); cursor: help; }
.scard__detail .icon { width: .18rem; height: .18rem; }
.scard:hover .scard__detail { opacity: 1; }
.scard__detail:hover { color: var(--mint-400); }
.scard__mergetag { position: absolute; z-index: 3; right: 0; top: .38rem; font-size: .12rem; font-weight: 700; padding: .01rem .06rem; background: var(--gold); color: #1d1400; letter-spacing: .08em; }
.scard:not(.is-disabled):not(.scard--sold):hover { transform: translateY(-.08rem); border-color: var(--mint-400); box-shadow: 0 0 0 1px var(--mint-400), 0 .1rem .24rem rgba(23, 249, 183, .22); }
.scard:not(.is-disabled):not(.scard--sold):active { transform: translateY(-.04rem); }
.scard.is-disabled { cursor: not-allowed; }
.shopbar.is-locked .scard:not(.scard--sold) { cursor: help; } /* not editable: a tap opens the detail */
.scard.is-disabled .scard__art, .scard.is-disabled .scard__itemart { filter: grayscale(.7) brightness(.6); }
.scard.is-merge { border-color: var(--gold); box-shadow: 0 0 0 1px rgba(255, 198, 0, .6), 0 0 .2rem rgba(255, 198, 0, .3); }
.scard.is-frozen::after { content: ''; position: absolute; inset: 0; z-index: 2; pointer-events: none;
@@ -204,6 +203,10 @@
@keyframes pulse-gold { 50% { box-shadow: 0 0 .24rem rgba(255, 198, 0, .5); } }
/* ---- two-tap buy / upgrade (research 09 §5): the first tap lifts + enlarges the card and shows 确认购买 ---- */
/* while a card is armed the bar sits above the detail card its first tap opened (.dpanel, z-index 9): on phones narrower
than 768 px the left-docked detail reaches down over the bar's first cards (bottom 87 px from the screen's bottom,
devices.css) and took the confirming tap on their top quarter (640×360: 108 of 144 points; user playtest #6 item 10) */
.shopbar.has-armed { z-index: 10; }
.scard.is-armed {
z-index: 3; transform: translateY(-.16rem) scale(1.08) !important; border-color: var(--gold);
box-shadow: 0 0 0 2px var(--gold), 0 .14rem .32rem rgba(0, 0, 0, .65), 0 0 .3rem rgba(255, 198, 0, .35) !important;
+16
View File
@@ -116,6 +116,22 @@
.capsule__kills { font-size: .26rem; color: var(--text-lo); letter-spacing: .04em; }
.capsule__kills b { color: #fff; font-size: .3rem; }
.capsule__tag { font-size: .14rem; padding: .01rem .08rem; background: var(--orange); color: #1a0800; font-weight: 700; letter-spacing: .1em; }
/* 联防: a leaker's enemies still standing (MissTag, user playtest #6 item 7) — the official bg_miss_enemy pill (runner ×
on its left 3/5, the number on the rest) or an orange look-alike */
.misstag {
display: inline-flex; align-items: center; justify-content: flex-end; gap: .03rem; height: .26rem; min-width: .84rem;
padding: 0 .1rem 0 .08rem; border-radius: .13rem; background: #3b1a08; border: 1px solid var(--orange-hi);
color: #fff; font-weight: 700; line-height: 1; flex: none;
}
.misstag.has-art { border: 0; border-radius: 0; background: transparent no-repeat left center / 100% 100%; width: .84rem; padding-right: .12rem; }
.misstag__icon { display: inline-flex; align-items: center; gap: .01rem; margin-right: auto; color: var(--orange-hi); font-size: .16rem; }
.misstag__icon .icon { width: .16rem; height: .16rem; }
.misstag__n { font-size: .2rem; }
.misstag.is-clear .misstag__n { color: var(--mint-400); }
.team__line .misstag { height: .21rem; min-width: .68rem; padding-right: .08rem; }
.team__line .misstag.has-art { width: .68rem; }
.team__line .misstag__n { font-size: .16rem; }
.team__line .misstag__icon { font-size: .13rem; }
.capsule--combat { --cap: var(--orange-hi); }
.capsule--boss { --cap: var(--orange-hi); min-width: 4.2rem; }
.bossbar { position: relative; flex: 1; min-width: 2.2rem; height: .2rem; background: rgba(0, 0, 0, .6); border: 1px solid rgba(255, 122, 51, .5); overflow: hidden; }
+1
View File
@@ -5,6 +5,7 @@
net.request, so every phase can be developed and screenshot without the game server.
URL params: ?phase=PREP|INFO_CHECK|BAND_DRAFT|BATTLE_CHECK|SP_DRAFT|COMBAT|UNITE|SETTLE|FINAL_ASSAULT|HIDDEN_CORE|RESULT
&variant=reward|bounty|supply|tactic|temp|frozen|dead|defeat|solo|popup|detail|drawer|info|pen|emote|settings|collapsed|bond|drag
|leaker (联防: you let enemies through — the ×N counter)
|overtime|drain (boss rounds: level time over / overtime drain) |paused (solo battles)
&shot=1 (hide the switcher) &render=engine|fallback (default: the Pixi engine, DOM fallback if it fails)
Keyboard: ` toggles the switcher.
+20 -4
View File
@@ -287,7 +287,7 @@ function setPhase(phase, variant) {
let cards;
if (fam === 'supply') cards = shuffle(shopItems().filter((i) => i.tier >= 3)).slice(0, 6).map((i) => ({ itemId: i.id }));
else if (fam === 'tactic') cards = shuffle(ch.cards.tactic.filter((t) => t.kind !== 'terrain')).slice(0, 6).map((t) => ({ effectId: t.effectId }));
else cards = shuffle(ch.cards.bounty.filter((b) => !b.name.includes('战术特训'))).slice(0, 6).map((b) => ({ effectId: b.effectId }));
else cards = shuffle(ch.cards.bounty.filter((b) => b.draft !== false)).slice(0, 6).map((b) => ({ effectId: b.effectId }));
if (solo) { cards = cards.slice(0, 3); pub.deadline = 0; }
pub.sp = { family: fam, cards, order: solo ? ['p1'] : ['p4', 'p1', 'ai_2', 'p3'], turn: solo ? 'p1' : 'p1', picks: solo ? {} : { p4: 2 }, untimed: solo };
pub.players.forEach((p) => { p.status = p.playerId === 'p4' ? 'ready' : 'deciding'; });
@@ -349,6 +349,21 @@ function startCombat(phase) {
if (VARIANTS.has('paused')) pub.paused = true;
pub.players.forEach((p, i) => { p.status = phase === PHASE.SETTLE ? 'done' : i === 1 ? 'done' : 'combat'; });
if (phase === PHASE.UNITE) { pub.players[1].status = 'helping'; pub.players[0].status = 'helping'; }
// 联防 live counter (user playtest #6 item 7; server m.public unite / players[].uniteLeft / pendingLp): ?variant=leaker
// — you and 灰烬 let enemies through, the AI and Doctor·B help; otherwise Doctor·B leaked. The first leaker's count of
// enemies still standing (uncapped: the ×N tag) falls with every kill below.
const leakMock = phase === PHASE.UNITE && VARIANTS.has('leaker');
const leftOf = (pid, n) => { const p = pub.players.find((x) => x.playerId === pid); if (p) { p.uniteLeft = n; p.pendingLp = Math.min(10, n) || undefined; } };
if (phase === PHASE.UNITE) {
pub.unite = leakMock ? { helpers: ['ai_2', 'p3'], leakers: ['p1', 'p4'] } : { helpers: ['p1', 'ai_2'], leakers: ['p3'] };
if (leakMock) { leftOf('p1', 13); leftOf('p4', 3); pub.players[0].status = 'done'; pub.players[2].status = 'helping'; } else leftOf('p3', 12);
}
const countKill = () => {
if (phase !== PHASE.UNITE) return;
const pid = leakMock ? 'p1' : 'p3';
const p = S.pub.players.find((x) => x.playerId === pid);
if (p && p.uniteLeft > 0) { leftOf(pid, p.uniteLeft - 1); pushPublic(); }
};
if (VARIANTS.has('done')) pub.players[0].status = 'done';
let fields;
let rect;
@@ -435,7 +450,7 @@ function startCombat(phase) {
tgt.hp -= e2boss(tgt) ? dmg * 3 : dmg;
ev.push(['atk', a.id, tgt.id, 'arrow'], ['dmg', tgt.id, dmg, rnd() < 0.3 ? 'arts' : 'phys']);
a.sp = (a.sp + 1) % a.spMax;
if (tgt.hp <= 0 && !tgt.boss && !tgt.dead) { tgt.dead = true; killed++; ev.push(['die', tgt.id]); }
if (tgt.hp <= 0 && !tgt.boss && !tgt.dead) { tgt.dead = true; killed++; ev.push(['die', tgt.id]); countKill(); }
}
if (rnd() < 0.15) { const a = pick(allyState); a.hp = Math.min(a.maxHp, a.hp + 180); ev.push(['heal', a.id, 180]); }
const snapUnits = [
@@ -667,7 +682,8 @@ function applyUiVariants() {
}
if (VARIANTS.has('settings')) clickSel('.gm__gear', 400);
if (VARIANTS.has('collapsed')) clickSel('.funds__collapse', 400);
if (VARIANTS.has('detail')) clickSel('.scard .scard__detail', 500);
// the first tap on a shop card selects it and opens its detail (there is no ⓘ corner)
if (VARIANTS.has('detail')) clickSel('.shopbar__cards .scard:not(.scard--sold)', 500);
if (VARIANTS.has('detailpiece')) setTimeout(() => {
const el = document.querySelector('.ff-piece.is-golden') || document.querySelector('.ff-piece');
el?.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, clientX: 10, clientY: 10 }));
@@ -683,7 +699,7 @@ const SWITCH = [
['PREP + temp', PHASE.PREP, 'temp'], ['PREP frozen', PHASE.PREP, 'frozen'], ['PREP dead', PHASE.PREP, 'dead'],
['PREP boss (L)', PHASE.PREP, 'boss'], ['PREP boss (R)', PHASE.PREP, 'bossR'],
['SP bounty', PHASE.SP_DRAFT, 'bounty'], ['SP supply', PHASE.SP_DRAFT, 'supply'], ['SP tactic', PHASE.SP_DRAFT, 'tactic'], ['SP solo', PHASE.SP_DRAFT, 'solo'],
['COMBAT', PHASE.COMBAT, ''], ['COMBAT done', PHASE.COMBAT, 'done'], ['UNITE', PHASE.UNITE, ''], ['SETTLE', PHASE.SETTLE, ''],
['COMBAT', PHASE.COMBAT, ''], ['COMBAT done', PHASE.COMBAT, 'done'], ['UNITE', PHASE.UNITE, ''], ['UNITE leaker', PHASE.UNITE, 'leaker'], ['SETTLE', PHASE.SETTLE, ''],
['FINAL_ASSAULT', PHASE.FINAL_ASSAULT, ''], ['FA overtime soon', PHASE.FINAL_ASSAULT, 'overtime'], ['FA draining', PHASE.FINAL_ASSAULT, 'drain'],
['HIDDEN_CORE', PHASE.HIDDEN_CORE, ''], ['COMBAT solo (pause)', PHASE.COMBAT, 'solo'], ['FA solo paused', PHASE.FINAL_ASSAULT, 'solo,paused'],
['RESULT win', PHASE.RESULT, ''], ['RESULT lose', PHASE.RESULT, 'defeat'],
+1 -1
View File
@@ -1 +1 @@
{"version":1,"name":"boss-m02","title":"最终攻势 · 卢西恩,“猩红血钻” · R14","kind":"boss","stageId":"act2autochess_m02","modeId":"mode_multi_normal","round":14,"wave":"act2autochess_h07_05","tick":0.03333333333333333,"snapEvery":3,"duration":75,"recordedAt":"2026-09-27","field":{"fieldId":"b1","kind":"boss","rect":{"r0":0,"r1":5,"c0":0,"c1":20},"stageId":"act2autochess_m02","units":[{"id":1,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_11_b","name":"塞雷娅","tier":5,"golden":true,"spine":"char_202_demkni","avatar":"char_202_demkni_2","x":9,"y":2,"facing":1,"dir":"RIGHT","maxHp":3003,"uid":1,"skillIndex":1,"moduleId":"uniequip_002_demkni"},{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_17_b","name":"山","tier":5,"golden":true,"spine":"char_264_f12yin","avatar":"char_264_f12yin_2","x":8,"y":2,"facing":1,"dir":"RIGHT","maxHp":2738,"uid":2,"skillIndex":1,"moduleId":"uniequip_002_f12yin"},{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_07_b","name":"史尔特尔","tier":5,"golden":true,"spine":"char_350_surtr","avatar":"char_350_surtr_2","x":7,"y":2,"facing":1,"dir":"RIGHT","maxHp":2680,"uid":3,"skillIndex":2,"moduleId":"uniequip_002_surtr"},{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_12_b","name":"夕","tier":5,"golden":true,"spine":"char_2015_dusk","avatar":"char_2015_dusk_2","x":4,"y":3,"facing":1,"dir":"RIGHT","maxHp":1787,"uid":4,"skillIndex":0,"moduleId":"uniequip_002_dusk"},{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_02_a","name":"缇缇","tier":5,"golden":false,"spine":"char_4056_titi","avatar":"char_4056_titi","x":5,"y":4,"facing":1,"dir":"RIGHT","maxHp":1208,"uid":5,"skillIndex":2},{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_03_b","name":"烛煌","tier":5,"golden":true,"spine":"char_1040_blaze2","avatar":"char_1040_blaze2_2","x":6,"y":5,"facing":1,"dir":"RIGHT","maxHp":1472,"uid":6,"skillIndex":2,"moduleId":"uniequip_002_blaze2"},{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_10_a","name":"铃兰","tier":5,"golden":false,"spine":"char_358_lisa","avatar":"char_358_lisa","x":7,"y":5,"facing":1,"dir":"RIGHT","maxHp":1184,"uid":7,"skillIndex":2},{"id":8,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_6_05_a","name":"异客","tier":6,"golden":false,"spine":"char_472_pasngr","avatar":"char_472_pasngr","x":8,"y":5,"facing":1,"dir":"RIGHT","maxHp":1215,"uid":8,"skillIndex":2},{"id":9,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_6_03_b","name":"余","tier":6,"golden":true,"spine":"char_2026_yu","avatar":"char_2026_yu_2","x":11,"y":2,"facing":-1,"dir":"LEFT","maxHp":3430,"uid":101,"skillIndex":2,"moduleId":"uniequip_002_yu"},{"id":10,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_6_17_a","name":"耀骑士临光","tier":6,"golden":false,"spine":"char_1014_nearl2","avatar":"char_1014_nearl2","x":12,"y":2,"facing":-1,"dir":"LEFT","maxHp":2698,"uid":102,"skillIndex":2},{"id":11,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_6_19_b","name":"锏","tier":6,"golden":true,"spine":"char_4116_blkkgt","avatar":"char_4116_blkkgt_2","x":13,"y":2,"facing":-1,"dir":"LEFT","maxHp":2767,"uid":103,"skillIndex":2,"moduleId":"uniequip_002_blkkgt"},{"id":12,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_6_01_b","name":"蕾缪安","tier":6,"golden":true,"spine":"char_4193_lemuen","avatar":"char_4193_lemuen_2","x":16,"y":3,"facing":-1,"dir":"LEFT","maxHp":1470,"uid":104,"skillIndex":2,"moduleId":"uniequip_002_lemuen"},{"id":13,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_6_08_a","name":"焰影苇草","tier":6,"golden":false,"spine":"char_1020_reed2","avatar":"char_1020_reed2","x":15,"y":4,"facing":-1,"dir":"LEFT","maxHp":1361,"uid":105,"skillIndex":2},{"id":14,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_6_12_b","name":"迷迭香","tier":6,"golden":true,"spine":"char_391_rosmon","avatar":"char_391_rosmon_2","x":14,"y":5,"facing":-1,"dir":"LEFT","maxHp":2887,"uid":106,"skillIndex":1,"moduleId":"uniequip_002_rosmon"},{"id":15,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_6_14_a","name":"流明","tier":6,"golden":false,"spine":"char_4042_lumen","avatar":"char_4042_lumen","x":13,"y":5,"facing":-1,"dir":"LEFT","maxHp":1569,"uid":107,"skillIndex":2},{"id":16,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_6_20_a","name":"纯烬艾雅法拉","tier":6,"golden":false,"spine":"char_1016_agoat2","avatar":"char_1016_agoat2","x":12,"y":5,"facing":-1,"dir":"LEFT","maxHp":1237,"uid":108,"skillIndex":2},{"id":17,"kind":"enemy","side":"enemy","ownerId":"P1","defId":"enemy_2016_csphtm","name":"卢西恩,“猩红血钻”","tier":3,"golden":false,"spine":"enemy_2016_csphtm","avatar":"enemy_2016_csphtm","x":9.99,"y":4,"facing":1,"dir":"RIGHT","maxHp":195000,"boss":true}]},"frames":[{"snap":{"fieldId":"b1","t":0.1,"units":[[1,9,2,3003,Line truncated
{"version":1,"name":"boss-m02","title":"最终攻势 · 卢西恩,“猩红血钻” · R14","kind":"boss","stageId":"act2autochess_m02","modeId":"mode_multi_normal","round":14,"wave":"act2autochess_h07_05","tick":0.03333333333333333,"snapEvery":3,"duration":75,"recordedAt":"2026-09-27","field":{"fieldId":"b1","kind":"boss","rect":{"r0":0,"r1":5,"c0":0,"c1":20},"stageId":"act2autochess_m02","units":[{"id":1,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_11_b","name":"塞雷娅","tier":5,"golden":true,"spine":"char_202_demkni","avatar":"char_202_demkni_2","x":9,"y":2,"facing":1,"dir":"RIGHT","maxHp":3003,"uid":1,"skillIndex":1,"moduleId":"uniequip_002_demkni"},{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_17_b","name":"山","tier":5,"golden":true,"spine":"char_264_f12yin","avatar":"char_264_f12yin_2","x":8,"y":2,"facing":1,"dir":"RIGHT","maxHp":2738,"uid":2,"skillIndex":1,"moduleId":"uniequip_002_f12yin"},{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_07_b","name":"史尔特尔","tier":5,"golden":true,"spine":"char_350_surtr","avatar":"char_350_surtr_2","x":7,"y":2,"facing":1,"dir":"RIGHT","maxHp":2680,"uid":3,"skillIndex":2,"moduleId":"uniequip_002_surtr"},{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_12_b","name":"夕","tier":5,"golden":true,"spine":"char_2015_dusk","avatar":"char_2015_dusk_2","x":4,"y":3,"facing":1,"dir":"RIGHT","maxHp":1787,"uid":4,"skillIndex":0,"moduleId":"uniequip_002_dusk"},{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_02_a","name":"缇缇","tier":5,"golden":false,"spine":"char_4056_titi","avatar":"char_4056_titi","x":5,"y":4,"facing":1,"dir":"RIGHT","maxHp":1208,"uid":5,"skillIndex":2},{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_03_b","name":"烛煌","tier":5,"golden":true,"spine":"char_1040_blaze2","avatar":"char_1040_blaze2_2","x":6,"y":5,"facing":1,"dir":"RIGHT","maxHp":1472,"uid":6,"skillIndex":2,"moduleId":"uniequip_002_blaze2"},{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_10_a","name":"铃兰","tier":5,"golden":false,"spine":"char_358_lisa","avatar":"char_358_lisa","x":7,"y":5,"facing":1,"dir":"RIGHT","maxHp":1184,"uid":7,"skillIndex":2},{"id":8,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_6_05_a","name":"异客","tier":6,"golden":false,"spine":"char_472_pasngr","avatar":"char_472_pasngr","x":8,"y":5,"facing":1,"dir":"RIGHT","maxHp":1215,"uid":8,"skillIndex":2},{"id":9,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_6_03_b","name":"余","tier":6,"golden":true,"spine":"char_2026_yu","avatar":"char_2026_yu_2","x":11,"y":2,"facing":-1,"dir":"LEFT","maxHp":3430,"uid":101,"skillIndex":2,"moduleId":"uniequip_002_yu"},{"id":10,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_6_17_a","name":"耀骑士临光","tier":6,"golden":false,"spine":"char_1014_nearl2","avatar":"char_1014_nearl2","x":12,"y":2,"facing":-1,"dir":"LEFT","maxHp":2698,"uid":102,"skillIndex":2},{"id":11,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_6_19_b","name":"锏","tier":6,"golden":true,"spine":"char_4116_blkkgt","avatar":"char_4116_blkkgt_2","x":13,"y":2,"facing":-1,"dir":"LEFT","maxHp":2767,"uid":103,"skillIndex":2,"moduleId":"uniequip_002_blkkgt"},{"id":12,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_6_01_b","name":"蕾缪安","tier":6,"golden":true,"spine":"char_4193_lemuen","avatar":"char_4193_lemuen_2","x":16,"y":3,"facing":-1,"dir":"LEFT","maxHp":1470,"uid":104,"skillIndex":2,"moduleId":"uniequip_002_lemuen"},{"id":13,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_6_08_a","name":"焰影苇草","tier":6,"golden":false,"spine":"char_1020_reed2","avatar":"char_1020_reed2","x":15,"y":4,"facing":-1,"dir":"LEFT","maxHp":1361,"uid":105,"skillIndex":2},{"id":14,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_6_12_b","name":"迷迭香","tier":6,"golden":true,"spine":"char_391_rosmon","avatar":"char_391_rosmon_2","x":14,"y":5,"facing":-1,"dir":"LEFT","maxHp":2887,"uid":106,"skillIndex":1,"moduleId":"uniequip_002_rosmon"},{"id":15,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_6_14_a","name":"流明","tier":6,"golden":false,"spine":"char_4042_lumen","avatar":"char_4042_lumen","x":13,"y":5,"facing":-1,"dir":"LEFT","maxHp":1569,"uid":107,"skillIndex":2},{"id":16,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_6_20_a","name":"纯烬艾雅法拉","tier":6,"golden":false,"spine":"char_1016_agoat2","avatar":"char_1016_agoat2","x":12,"y":5,"facing":-1,"dir":"LEFT","maxHp":1237,"uid":108,"skillIndex":2},{"id":17,"kind":"enemy","side":"enemy","ownerId":"P1","defId":"enemy_2016_csphtm","name":"卢西恩,“猩红血钻”","tier":3,"golden":false,"spine":"enemy_2016_csphtm","avatar":"enemy_2016_csphtm","x":9.99,"y":4,"facing":1,"dir":"RIGHT","maxHp":195000,"boss":true}]},"frames":[{"snap":{"fieldId":"b1","t":0.1,"units":[[1,9,2,3003,Line truncated
+17 -17
View File
@@ -8,7 +8,7 @@
"frames": 550,
"maxUnits": 29,
"file": "normal-m01.json",
"bytes": 424452,
"bytes": 419568,
"result": {
"reason": "timeout",
"killed": 13,
@@ -24,7 +24,7 @@
"frames": 850,
"maxUnits": 27,
"file": "normal-m02.json",
"bytes": 634795,
"bytes": 628577,
"result": {
"reason": "timeout",
"killed": 15,
@@ -38,12 +38,12 @@
"stageId": "act2autochess_m03",
"duration": 90,
"frames": 900,
"maxUnits": 35,
"maxUnits": 38,
"file": "normal-m03.json",
"bytes": 809504,
"bytes": 831200,
"result": {
"reason": "timeout",
"killed": 12,
"killed": 13,
"total": 46
}
},
@@ -54,12 +54,12 @@
"stageId": "act2autochess_m04",
"duration": 70,
"frames": 700,
"maxUnits": 37,
"maxUnits": 36,
"file": "normal-m04.json",
"bytes": 626583,
"bytes": 623925,
"result": {
"reason": "timeout",
"killed": 21,
"killed": 22,
"total": 39
}
},
@@ -70,12 +70,12 @@
"stageId": "act2autochess_m03",
"duration": 84.23,
"frames": 843,
"maxUnits": 30,
"maxUnits": 31,
"file": "late-m03-r12.json",
"bytes": 632912,
"bytes": 641750,
"result": {
"reason": "cleared",
"killed": 12,
"killed": 11,
"total": 57
}
},
@@ -86,9 +86,9 @@
"stageId": "act2autochess_m01",
"duration": 110,
"frames": 1100,
"maxUnits": 34,
"maxUnits": 32,
"file": "late-m01-r13.json",
"bytes": 902188,
"bytes": 878821,
"result": {
"reason": "timeout",
"killed": 6,
@@ -102,12 +102,12 @@
"stageId": "act2autochess_m01",
"duration": 60,
"frames": 600,
"maxUnits": 36,
"maxUnits": 37,
"file": "unite-m01.json",
"bytes": 636396,
"bytes": 635055,
"result": {
"reason": "timeout",
"killed": 19,
"killed": 18,
"total": 40
}
},
@@ -120,7 +120,7 @@
"frames": 750,
"maxUnits": 25,
"file": "boss-m02.json",
"bytes": 703728,
"bytes": 699249,
"result": {
"reason": "forced",
"killed": 13,
+1 -1
View File
@@ -1 +1 @@
{"version":1,"name":"late-m01-r13","title":"常规作战 · 源石流发生装置 · R13(官方波次)","kind":"normal","stageId":"act2autochess_m01","modeId":"mode_multi_hard","round":13,"wave":"act1autochess_h06","tick":0.03333333333333333,"snapEvery":3,"duration":110,"recordedAt":"2026-09-27","field":{"fieldId":"n:P1","kind":"normal","rect":{"r0":9,"r1":12,"c0":0,"c1":10},"stageId":"act2autochess_m01","units":[{"id":1,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_17_b","name":"星熊","tier":4,"golden":true,"spine":"char_136_hsguma","avatar":"char_136_hsguma_2","x":9,"y":9,"facing":1,"dir":"RIGHT","maxHp":3513,"uid":1,"skillIndex":1,"moduleId":"uniequip_002_hsguma"},{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_22_b","name":"银灰","tier":4,"golden":true,"spine":"char_172_svrash","avatar":"char_172_svrash_2","x":8,"y":9,"facing":1,"dir":"RIGHT","maxHp":2569,"uid":2,"skillIndex":2,"moduleId":"uniequip_002_svrash"},{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_05_b","name":"斯卡蒂","tier":3,"golden":true,"spine":"char_263_skadi","avatar":"char_263_skadi_2","x":7,"y":9,"facing":1,"dir":"RIGHT","maxHp":3853,"uid":3,"skillIndex":2,"moduleId":"uniequip_002_skadi"},{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_02_b","name":"莫斯提马","tier":4,"golden":true,"spine":"char_213_mostma","avatar":"char_213_mostma_2","x":4,"y":10,"facing":1,"dir":"RIGHT","maxHp":1695,"uid":4,"skillIndex":2,"moduleId":"uniequip_002_mostma"},{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_21_a","name":"白面鸮","tier":4,"golden":false,"spine":"char_128_plosis","avatar":"char_128_plosis","x":4,"y":11,"facing":1,"dir":"RIGHT","maxHp":1271,"uid":5,"skillIndex":1},{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_06_b","name":"寒芒克洛丝","tier":4,"golden":true,"spine":"char_1021_kroos2","avatar":"char_1021_kroos2_2","x":4,"y":12,"facing":1,"dir":"RIGHT","maxHp":1561,"uid":6,"skillIndex":1,"moduleId":"uniequip_002_kroos2"},{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_10_a","name":"阿罗玛","tier":4,"golden":false,"spine":"char_446_aroma","avatar":"char_446_aroma","x":5,"y":12,"facing":1,"dir":"RIGHT","maxHp":1288,"uid":7,"skillIndex":1},{"id":8,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_20_b","name":"远牙","tier":4,"golden":true,"spine":"char_430_fartth","avatar":"char_430_fartth_2","x":6,"y":12,"facing":1,"dir":"RIGHT","maxHp":1419,"uid":8,"skillIndex":2,"moduleId":"uniequip_002_fartth"}]},"preview":[{"enemyKey":"enemy_1254_lypa_2","count":5,"gate":"lower","t":2,"fly":false,"elite":true,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1249_lysdb_2","count":5,"gate":"upper","t":3,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":4,"gate":"lower","t":7,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":1,"gate":"upper","t":7,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":4,"gate":"lower","t":10,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1254_lypa_2","count":5,"gate":"lower","t":10,"fly":false,"elite":true,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1249_lysdb_2","count":5,"gate":"lower","t":11,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1254_lypa_2","count":3,"gate":"upper","t":21,"fly":false,"elite":true,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1254_lypa_2","count":5,"gate":"lower","t":32,"fly":false,"elite":true,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1249_lysdb_2","count":5,"gate":"lower","t":37,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":4,"gate":"lower","t":39,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1249_lysdb_2","count":5,"gate":"lower","t":55,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":4,"gate":"lower","t":62,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null}],"frames":[{"snap":{"fieldId":"n:P1","t":0.1,"units":[[1,9,9,3513,3513,0,0,0,6],[2,8,9,2569,2569,60.1,90,0,6],[3,7,9,3853,3853,60.1,90,0,6],[4,4,10,1695,1695,62.1,110,0,6],[5,4,11,1271,1271,70.1,100,0,6],[6,4,12,1561,1561,20.1,40,0,6],[7,5,12,1288,1288,20.1,55,0,6],[8,6,12,1419,1419,7.1,24,0,6]],"dp":10,"killed":0,"total":55},"ev":[["spawn",{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_06_b","name":"寒芒克洛丝","tier":4,"golden":true,"spine":"char_1021_kroos2","avatar":"char_1021_kroos2_2","x":4,"y":12,"facing":1,"dir":"RIGHT","maxHp":1561,"uid":6,"skillIndex":1,"moduleId":"uniequip_002_kroos2"}],["deploy",6],["spawn",{"id":7,"kind":"op","side":"Line truncated
{"version":1,"name":"late-m01-r13","title":"常规作战 · 源石流发生装置 · R13(官方波次)","kind":"normal","stageId":"act2autochess_m01","modeId":"mode_multi_hard","round":13,"wave":"act1autochess_h06","tick":0.03333333333333333,"snapEvery":3,"duration":110,"recordedAt":"2026-09-27","field":{"fieldId":"n:P1","kind":"normal","rect":{"r0":9,"r1":12,"c0":0,"c1":10},"stageId":"act2autochess_m01","units":[{"id":1,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_17_b","name":"星熊","tier":4,"golden":true,"spine":"char_136_hsguma","avatar":"char_136_hsguma_2","x":9,"y":9,"facing":1,"dir":"RIGHT","maxHp":3513,"uid":1,"skillIndex":1,"moduleId":"uniequip_002_hsguma"},{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_22_b","name":"银灰","tier":4,"golden":true,"spine":"char_172_svrash","avatar":"char_172_svrash_2","x":8,"y":9,"facing":1,"dir":"RIGHT","maxHp":2569,"uid":2,"skillIndex":2,"moduleId":"uniequip_002_svrash"},{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_05_b","name":"斯卡蒂","tier":3,"golden":true,"spine":"char_263_skadi","avatar":"char_263_skadi_2","x":7,"y":9,"facing":1,"dir":"RIGHT","maxHp":3853,"uid":3,"skillIndex":2,"moduleId":"uniequip_002_skadi"},{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_02_b","name":"莫斯提马","tier":4,"golden":true,"spine":"char_213_mostma","avatar":"char_213_mostma_2","x":4,"y":10,"facing":1,"dir":"RIGHT","maxHp":1695,"uid":4,"skillIndex":2,"moduleId":"uniequip_002_mostma"},{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_21_a","name":"白面鸮","tier":4,"golden":false,"spine":"char_128_plosis","avatar":"char_128_plosis","x":4,"y":11,"facing":1,"dir":"RIGHT","maxHp":1271,"uid":5,"skillIndex":1},{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_06_b","name":"寒芒克洛丝","tier":4,"golden":true,"spine":"char_1021_kroos2","avatar":"char_1021_kroos2_2","x":4,"y":12,"facing":1,"dir":"RIGHT","maxHp":1561,"uid":6,"skillIndex":1,"moduleId":"uniequip_002_kroos2"},{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_10_a","name":"阿罗玛","tier":4,"golden":false,"spine":"char_446_aroma","avatar":"char_446_aroma","x":5,"y":12,"facing":1,"dir":"RIGHT","maxHp":1288,"uid":7,"skillIndex":1},{"id":8,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_20_b","name":"远牙","tier":4,"golden":true,"spine":"char_430_fartth","avatar":"char_430_fartth_2","x":6,"y":12,"facing":1,"dir":"RIGHT","maxHp":1419,"uid":8,"skillIndex":2,"moduleId":"uniequip_002_fartth"}]},"preview":[{"enemyKey":"enemy_1254_lypa_2","count":5,"gate":"lower","t":2,"fly":false,"elite":true,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1249_lysdb_2","count":5,"gate":"upper","t":3,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":4,"gate":"lower","t":7,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":1,"gate":"upper","t":7,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":4,"gate":"lower","t":10,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1254_lypa_2","count":5,"gate":"lower","t":10,"fly":false,"elite":true,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1249_lysdb_2","count":5,"gate":"lower","t":11,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1254_lypa_2","count":3,"gate":"upper","t":21,"fly":false,"elite":true,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1254_lypa_2","count":5,"gate":"lower","t":32,"fly":false,"elite":true,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1249_lysdb_2","count":5,"gate":"lower","t":37,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":4,"gate":"lower","t":39,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1249_lysdb_2","count":5,"gate":"lower","t":55,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":4,"gate":"lower","t":62,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null}],"frames":[{"snap":{"fieldId":"n:P1","t":0.1,"units":[[1,9,9,3513,3513,0,0,0,6],[2,8,9,2569,2569,60.1,90,0,6],[3,7,9,3853,3853,60.1,90,0,6],[4,4,10,1695,1695,62.1,110,0,6],[5,4,11,1271,1271,70.1,100,0,6],[6,4,12,1561,1561,20.1,40,0,6],[7,5,12,1288,1288,20.1,55,0,6],[8,6,12,1419,1419,7.1,24,0,6]],"dp":10,"killed":0,"total":55},"ev":[["spawn",{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_06_b","name":"寒芒克洛丝","tier":4,"golden":true,"spine":"char_1021_kroos2","avatar":"char_1021_kroos2_2","x":4,"y":12,"facing":1,"dir":"RIGHT","maxHp":1561,"uid":6,"skillIndex":1,"moduleId":"uniequip_002_kroos2"}],["deploy",6],["spawn",{"id":7,"kind":"op","side":"Line truncated
+1 -1
View File
@@ -1 +1 @@
{"version":1,"name":"late-m03-r12","title":"常规作战 · 排气格栅 · R12(官方波次)","kind":"normal","stageId":"act2autochess_m03","modeId":"mode_multi_hard","round":12,"wave":"act1autochess_h05","tick":0.03333333333333333,"snapEvery":3,"duration":84.23,"recordedAt":"2026-09-27","field":{"fieldId":"n:P1","kind":"normal","rect":{"r0":9,"r1":12,"c0":0,"c1":10},"stageId":"act2autochess_m03","units":[{"id":1,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_11_b","name":"塞雷娅","tier":5,"golden":true,"spine":"char_202_demkni","avatar":"char_202_demkni_2","x":9,"y":9,"facing":1,"dir":"RIGHT","maxHp":3003,"uid":1,"skillIndex":1,"moduleId":"uniequip_002_demkni"},{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_17_b","name":"星熊","tier":4,"golden":true,"spine":"char_136_hsguma","avatar":"char_136_hsguma_2","x":6,"y":9,"facing":1,"dir":"RIGHT","maxHp":3513,"uid":2,"skillIndex":1,"moduleId":"uniequip_002_hsguma"},{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_17_b","name":"山","tier":5,"golden":true,"spine":"char_264_f12yin","avatar":"char_264_f12yin_2","x":5,"y":9,"facing":1,"dir":"RIGHT","maxHp":2738,"uid":3,"skillIndex":1,"moduleId":"uniequip_002_f12yin"},{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_07_b","name":"史尔特尔","tier":5,"golden":true,"spine":"char_350_surtr","avatar":"char_350_surtr_2","x":4,"y":9,"facing":1,"dir":"RIGHT","maxHp":2680,"uid":4,"skillIndex":2,"moduleId":"uniequip_002_surtr"},{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_12_b","name":"夕","tier":5,"golden":true,"spine":"char_2015_dusk","avatar":"char_2015_dusk_2","x":4,"y":12,"facing":1,"dir":"RIGHT","maxHp":1787,"uid":5,"skillIndex":0,"moduleId":"uniequip_002_dusk"},{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_02_b","name":"莫斯提马","tier":4,"golden":true,"spine":"char_213_mostma","avatar":"char_213_mostma_2","x":5,"y":12,"facing":1,"dir":"RIGHT","maxHp":1695,"uid":6,"skillIndex":2,"moduleId":"uniequip_002_mostma"},{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_10_b","name":"铃兰","tier":5,"golden":true,"spine":"char_358_lisa","avatar":"char_358_lisa_2","x":6,"y":12,"facing":1,"dir":"RIGHT","maxHp":1480,"uid":7,"skillIndex":2,"moduleId":"uniequip_002_lisa"},{"id":8,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_08_b","name":"号角","tier":5,"golden":true,"spine":"char_4039_horn","avatar":"char_4039_horn_2","x":3,"y":9,"facing":1,"dir":"RIGHT","maxHp":2775,"uid":8,"skillIndex":2,"moduleId":"uniequip_002_horn"},{"id":9,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":3,"y":12,"facing":1,"dir":"RIGHT","maxHp":100},{"id":10,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":3,"y":11,"facing":1,"dir":"RIGHT","maxHp":100},{"id":11,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":11,"facing":1,"dir":"RIGHT","maxHp":100}]},"preview":[{"enemyKey":"enemy_1254_lypa_2","count":5,"gate":"lower","t":2,"fly":false,"elite":true,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":4,"gate":"lower","t":5,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":3,"gate":"upper","t":5,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":3,"gate":"lower","t":10,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1254_lypa_2","count":5,"gate":"lower","t":10,"fly":false,"elite":true,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1249_lysdb_2","count":5,"gate":"upper","t":10,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1249_lysdb_2","count":5,"gate":"lower","t":12,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1254_lypa_2","count":3,"gate":"upper","t":21,"fly":false,"elite":true,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":3,"gate":"lower","t":30,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1254_lypa_2","count":3,"gate":"lower","t":31,"fly":false,"elite":true,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":4,"gate":"lower","t":35,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1249_lysdb_2","count":5,"gate":"lower","t":37,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":4,"gate":"lower","t":51,"fly":false,"eLine truncated
{"version":1,"name":"late-m03-r12","title":"常规作战 · 排气格栅 · R12(官方波次)","kind":"normal","stageId":"act2autochess_m03","modeId":"mode_multi_hard","round":12,"wave":"act1autochess_h05","tick":0.03333333333333333,"snapEvery":3,"duration":84.23,"recordedAt":"2026-09-27","field":{"fieldId":"n:P1","kind":"normal","rect":{"r0":9,"r1":12,"c0":0,"c1":10},"stageId":"act2autochess_m03","units":[{"id":1,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_11_b","name":"塞雷娅","tier":5,"golden":true,"spine":"char_202_demkni","avatar":"char_202_demkni_2","x":9,"y":9,"facing":1,"dir":"RIGHT","maxHp":3003,"uid":1,"skillIndex":1,"moduleId":"uniequip_002_demkni"},{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_17_b","name":"星熊","tier":4,"golden":true,"spine":"char_136_hsguma","avatar":"char_136_hsguma_2","x":6,"y":9,"facing":1,"dir":"RIGHT","maxHp":3513,"uid":2,"skillIndex":1,"moduleId":"uniequip_002_hsguma"},{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_17_b","name":"山","tier":5,"golden":true,"spine":"char_264_f12yin","avatar":"char_264_f12yin_2","x":5,"y":9,"facing":1,"dir":"RIGHT","maxHp":2738,"uid":3,"skillIndex":1,"moduleId":"uniequip_002_f12yin"},{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_07_b","name":"史尔特尔","tier":5,"golden":true,"spine":"char_350_surtr","avatar":"char_350_surtr_2","x":4,"y":9,"facing":1,"dir":"RIGHT","maxHp":2680,"uid":4,"skillIndex":2,"moduleId":"uniequip_002_surtr"},{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_12_b","name":"夕","tier":5,"golden":true,"spine":"char_2015_dusk","avatar":"char_2015_dusk_2","x":4,"y":12,"facing":1,"dir":"RIGHT","maxHp":1787,"uid":5,"skillIndex":0,"moduleId":"uniequip_002_dusk"},{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_02_b","name":"莫斯提马","tier":4,"golden":true,"spine":"char_213_mostma","avatar":"char_213_mostma_2","x":5,"y":12,"facing":1,"dir":"RIGHT","maxHp":1695,"uid":6,"skillIndex":2,"moduleId":"uniequip_002_mostma"},{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_10_b","name":"铃兰","tier":5,"golden":true,"spine":"char_358_lisa","avatar":"char_358_lisa_2","x":6,"y":12,"facing":1,"dir":"RIGHT","maxHp":1480,"uid":7,"skillIndex":2,"moduleId":"uniequip_002_lisa"},{"id":8,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_08_b","name":"号角","tier":5,"golden":true,"spine":"char_4039_horn","avatar":"char_4039_horn_2","x":3,"y":9,"facing":1,"dir":"RIGHT","maxHp":2775,"uid":8,"skillIndex":2,"moduleId":"uniequip_002_horn"},{"id":9,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":3,"y":12,"facing":1,"dir":"RIGHT","maxHp":100},{"id":10,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":3,"y":11,"facing":1,"dir":"RIGHT","maxHp":100},{"id":11,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":11,"facing":1,"dir":"RIGHT","maxHp":100}]},"preview":[{"enemyKey":"enemy_1254_lypa_2","count":5,"gate":"lower","t":2,"fly":false,"elite":true,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":4,"gate":"lower","t":5,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":3,"gate":"upper","t":5,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":3,"gate":"lower","t":10,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1254_lypa_2","count":5,"gate":"lower","t":10,"fly":false,"elite":true,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1249_lysdb_2","count":5,"gate":"upper","t":10,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1249_lysdb_2","count":5,"gate":"lower","t":12,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1254_lypa_2","count":3,"gate":"upper","t":21,"fly":false,"elite":true,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":3,"gate":"lower","t":30,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1254_lypa_2","count":3,"gate":"lower","t":31,"fly":false,"elite":true,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":4,"gate":"lower","t":35,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1249_lysdb_2","count":5,"gate":"lower","t":37,"fly":false,"elite":false,"boss":false,"source":"wave","tag":null},{"enemyKey":"enemy_1251_lysyta","count":4,"gate":"lower","t":51,"fly":false,"eLine truncated
+1 -1
View File
@@ -1 +1 @@
{"version":1,"name":"normal-m01","title":"常规作战 · 源石流发生装置 · R5","kind":"normal","stageId":"act2autochess_m01","modeId":"mode_multi_normal","round":5,"wave":"act1autochess_05","tick":0.03333333333333333,"snapEvery":3,"duration":55,"recordedAt":"2026-09-27","field":{"fieldId":"n:P1","kind":"normal","rect":{"r0":9,"r1":12,"c0":0,"c1":10},"stageId":"act2autochess_m01","units":[{"id":1,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_02_a","name":"角峰","tier":1,"golden":false,"spine":"char_199_yak","avatar":"char_199_yak","x":9,"y":9,"facing":1,"dir":"RIGHT","maxHp":2216,"uid":1,"skillIndex":1},{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_08_a","name":"德克萨斯","tier":1,"golden":false,"spine":"char_102_texas","avatar":"char_102_texas","x":8,"y":9,"facing":1,"dir":"RIGHT","maxHp":1285,"uid":2,"skillIndex":1},{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_12_b","name":"艾丝黛尔","tier":1,"golden":true,"spine":"char_127_estell","avatar":"char_127_estell_2","x":7,"y":9,"facing":1,"dir":"RIGHT","maxHp":2389,"uid":3,"skillIndex":1,"moduleId":"uniequip_002_estell"},{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_01_a","name":"隐现","tier":1,"golden":false,"spine":"char_498_inside","avatar":"char_498_inside","x":4,"y":10,"facing":1,"dir":"RIGHT","maxHp":1123,"uid":4,"skillIndex":1},{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_14_a","name":"格雷伊","tier":1,"golden":false,"spine":"char_253_greyy","avatar":"char_253_greyy","x":4,"y":11,"facing":1,"dir":"RIGHT","maxHp":1260,"uid":5,"skillIndex":1},{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_2_02_a","name":"赫默","tier":2,"golden":false,"spine":"char_108_silent","avatar":"char_108_silent","x":4,"y":12,"facing":1,"dir":"RIGHT","maxHp":1400,"uid":6,"skillIndex":1},{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_01_a","name":"能天使","tier":3,"golden":false,"spine":"char_103_angel","avatar":"char_103_angel","x":5,"y":12,"facing":1,"dir":"RIGHT","maxHp":1472,"uid":7,"skillIndex":2}]},"frames":[{"snap":{"fieldId":"n:P1","t":0.1,"units":[[1,9,9,2216,2216,0.1,47,0,6],[2,8,9,1285,1285,23.1,40,0,6],[3,7,9,2389,2389,10,23,0,6],[4,4,10,1123,1123,8.1,24,0,6],[5,4,11,1260,1260,0.1,60,0,6],[6,4,12,1400,1400,0.1,26,0,6],[7,5,12,1472,1472,20.1,44,0,6]],"dp":11,"killed":0,"total":32},"ev":[["spawn",{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_2_02_a","name":"赫默","tier":2,"golden":false,"spine":"char_108_silent","avatar":"char_108_silent","x":4,"y":12,"facing":1,"dir":"RIGHT","maxHp":1400,"uid":6,"skillIndex":1}],["deploy",6],["spawn",{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_01_a","name":"能天使","tier":3,"golden":false,"spine":"char_103_angel","avatar":"char_103_angel","x":5,"y":12,"facing":1,"dir":"RIGHT","maxHp":1472,"uid":7,"skillIndex":2}],["deploy",7],["spawn",{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_14_a","name":"格雷伊","tier":1,"golden":false,"spine":"char_253_greyy","avatar":"char_253_greyy","x":4,"y":11,"facing":1,"dir":"RIGHT","maxHp":1145,"uid":5,"skillIndex":1}],["deploy",5],["spawn",{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_01_a","name":"隐现","tier":1,"golden":false,"spine":"char_498_inside","avatar":"char_498_inside","x":4,"y":10,"facing":1,"dir":"RIGHT","maxHp":1123,"uid":4,"skillIndex":1}],["deploy",4],["spawn",{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_12_b","name":"艾丝黛尔","tier":1,"golden":true,"spine":"char_127_estell","avatar":"char_127_estell_2","x":7,"y":9,"facing":1,"dir":"RIGHT","maxHp":2389,"uid":3,"skillIndex":1,"moduleId":"uniequip_002_estell"}],["deploy",3],["spawn",{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_08_a","name":"德克萨斯","tier":1,"golden":false,"spine":"char_102_texas","avatar":"char_102_texas","x":8,"y":9,"facing":1,"dir":"RIGHT","maxHp":1285,"uid":2,"skillIndex":1}],["deploy",2],["spawn",{"id":1,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_02_a","name":"角峰","tier":1,"golden":false,"spine":"char_199_yak","avatar":"char_199_yak","x":9,"y":9,"facing":1,"dir":"RIGHT","maxHp":2216,"uid":1,"skillIndex":1}],["deploy",1],["status",5,"talent:angel_bless_ally",1],["fx","buff",4,11,{"id":5,"src":7,"talent":"angel_bless"}]]},{"snap":{"fieldId":"n:P1","t":0.2,"units":[[1,9,9,2216,2216,0.2,47,0,6],[2,8,9,1285,1285,23.2,40,0,6],[3,7,9,2389,2389,10,23,0,6],[4,4,10,1123,1123,8.2,24,0,6],[5,4,11,1260,1260,0.2,60,0,6],[6,4,12,1400,1400,0.2,26,0,6],[7,5,12,1472,1472,20.2,44,0,6]],"dp":11,"killed":0,"total":32},"ev":[]},{"snap":{"fieldId":"n:P1","t":0.3,"units":[[1,9,9,2216,2216,0.3,47,0,6],[2,8,9,1285,1285,23.3,40,0,6],[3,7,9,2389,2389,10,23,0,6],[4,4,10,1123,1123,8.3,24,0,6],[5,4,11,1260,1260,0.3,60,0,6],[6,4,12,1400,1400,0.3,26,0,6],[7,5,12,1472,1472,20Line truncated
{"version":1,"name":"normal-m01","title":"常规作战 · 源石流发生装置 · R5","kind":"normal","stageId":"act2autochess_m01","modeId":"mode_multi_normal","round":5,"wave":"act1autochess_05","tick":0.03333333333333333,"snapEvery":3,"duration":55,"recordedAt":"2026-09-27","field":{"fieldId":"n:P1","kind":"normal","rect":{"r0":9,"r1":12,"c0":0,"c1":10},"stageId":"act2autochess_m01","units":[{"id":1,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_02_a","name":"角峰","tier":1,"golden":false,"spine":"char_199_yak","avatar":"char_199_yak","x":9,"y":9,"facing":1,"dir":"RIGHT","maxHp":2216,"uid":1,"skillIndex":1},{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_08_a","name":"德克萨斯","tier":1,"golden":false,"spine":"char_102_texas","avatar":"char_102_texas","x":8,"y":9,"facing":1,"dir":"RIGHT","maxHp":1285,"uid":2,"skillIndex":1},{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_12_b","name":"艾丝黛尔","tier":1,"golden":true,"spine":"char_127_estell","avatar":"char_127_estell_2","x":7,"y":9,"facing":1,"dir":"RIGHT","maxHp":2389,"uid":3,"skillIndex":1,"moduleId":"uniequip_002_estell"},{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_01_a","name":"隐现","tier":1,"golden":false,"spine":"char_498_inside","avatar":"char_498_inside","x":4,"y":10,"facing":1,"dir":"RIGHT","maxHp":1123,"uid":4,"skillIndex":1},{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_14_a","name":"格雷伊","tier":1,"golden":false,"spine":"char_253_greyy","avatar":"char_253_greyy","x":4,"y":11,"facing":1,"dir":"RIGHT","maxHp":1260,"uid":5,"skillIndex":1},{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_2_02_a","name":"赫默","tier":2,"golden":false,"spine":"char_108_silent","avatar":"char_108_silent","x":4,"y":12,"facing":1,"dir":"RIGHT","maxHp":1400,"uid":6,"skillIndex":1},{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_01_a","name":"能天使","tier":3,"golden":false,"spine":"char_103_angel","avatar":"char_103_angel","x":5,"y":12,"facing":1,"dir":"RIGHT","maxHp":1472,"uid":7,"skillIndex":2}]},"frames":[{"snap":{"fieldId":"n:P1","t":0.1,"units":[[1,9,9,2216,2216,0.1,47,0,6],[2,8,9,1285,1285,23.1,40,0,6],[3,7,9,2389,2389,10,23,0,6],[4,4,10,1123,1123,8.1,24,0,6],[5,4,11,1260,1260,0.1,60,0,6],[6,4,12,1400,1400,0.1,26,0,6],[7,5,12,1472,1472,20.1,44,0,6]],"dp":11,"killed":0,"total":32},"ev":[["spawn",{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_2_02_a","name":"赫默","tier":2,"golden":false,"spine":"char_108_silent","avatar":"char_108_silent","x":4,"y":12,"facing":1,"dir":"RIGHT","maxHp":1400,"uid":6,"skillIndex":1}],["deploy",6],["spawn",{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_01_a","name":"能天使","tier":3,"golden":false,"spine":"char_103_angel","avatar":"char_103_angel","x":5,"y":12,"facing":1,"dir":"RIGHT","maxHp":1472,"uid":7,"skillIndex":2}],["deploy",7],["spawn",{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_14_a","name":"格雷伊","tier":1,"golden":false,"spine":"char_253_greyy","avatar":"char_253_greyy","x":4,"y":11,"facing":1,"dir":"RIGHT","maxHp":1145,"uid":5,"skillIndex":1}],["deploy",5],["spawn",{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_01_a","name":"隐现","tier":1,"golden":false,"spine":"char_498_inside","avatar":"char_498_inside","x":4,"y":10,"facing":1,"dir":"RIGHT","maxHp":1123,"uid":4,"skillIndex":1}],["deploy",4],["spawn",{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_12_b","name":"艾丝黛尔","tier":1,"golden":true,"spine":"char_127_estell","avatar":"char_127_estell_2","x":7,"y":9,"facing":1,"dir":"RIGHT","maxHp":2389,"uid":3,"skillIndex":1,"moduleId":"uniequip_002_estell"}],["deploy",3],["spawn",{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_08_a","name":"德克萨斯","tier":1,"golden":false,"spine":"char_102_texas","avatar":"char_102_texas","x":8,"y":9,"facing":1,"dir":"RIGHT","maxHp":1285,"uid":2,"skillIndex":1}],["deploy",2],["spawn",{"id":1,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_02_a","name":"角峰","tier":1,"golden":false,"spine":"char_199_yak","avatar":"char_199_yak","x":9,"y":9,"facing":1,"dir":"RIGHT","maxHp":2216,"uid":1,"skillIndex":1}],["deploy",1],["status",5,"talent:angel_bless_ally",1],["fx","buff",4,11,{"id":5,"src":7,"talent":"angel_bless"}]]},{"snap":{"fieldId":"n:P1","t":0.2,"units":[[1,9,9,2216,2216,0.2,47,0,6],[2,8,9,1285,1285,23.2,40,0,6],[3,7,9,2389,2389,10,23,0,6],[4,4,10,1123,1123,8.2,24,0,6],[5,4,11,1260,1260,0.2,60,0,6],[6,4,12,1400,1400,0.2,26,0,6],[7,5,12,1472,1472,20.2,44,0,6]],"dp":11,"killed":0,"total":32},"ev":[]},{"snap":{"fieldId":"n:P1","t":0.3,"units":[[1,9,9,2216,2216,0.3,47,0,6],[2,8,9,1285,1285,23.3,40,0,6],[3,7,9,2389,2389,10,23,0,6],[4,4,10,1123,1123,8.3,24,0,6],[5,4,11,1260,1260,0.3,60,0,6],[6,4,12,1400,1400,0.3,26,0,6],[7,5,12,1472,1472,20Line truncated
+1 -1
View File
@@ -1 +1 @@
{"version":1,"name":"normal-m02","title":"常规作战 · 沼泽控制 · R8","kind":"normal","stageId":"act2autochess_m02","modeId":"mode_multi_hard","round":8,"wave":"act1autochess_h01","tick":0.03333333333333333,"snapEvery":3,"duration":85,"recordedAt":"2026-09-27","field":{"fieldId":"n:P1","kind":"normal","rect":{"r0":9,"r1":12,"c0":0,"c1":10},"stageId":"act2autochess_m02","units":[{"id":1,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_17_a","name":"星熊","tier":4,"golden":false,"spine":"char_136_hsguma","avatar":"char_136_hsguma","x":9,"y":9,"facing":1,"dir":"RIGHT","maxHp":2849,"uid":1,"skillIndex":1},{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_22_a","name":"银灰","tier":4,"golden":false,"spine":"char_172_svrash","avatar":"char_172_svrash","x":8,"y":9,"facing":1,"dir":"RIGHT","maxHp":2022,"uid":2,"skillIndex":2},{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_05_a","name":"斯卡蒂","tier":3,"golden":false,"spine":"char_263_skadi","avatar":"char_263_skadi","x":7,"y":9,"facing":1,"dir":"RIGHT","maxHp":2938,"uid":3,"skillIndex":2},{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_02_a","name":"莫斯提马","tier":4,"golden":false,"spine":"char_213_mostma","avatar":"char_213_mostma","x":4,"y":10,"facing":1,"dir":"RIGHT","maxHp":1428,"uid":4,"skillIndex":2},{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_21_a","name":"白面鸮","tier":4,"golden":false,"spine":"char_128_plosis","avatar":"char_128_plosis","x":5,"y":11,"facing":1,"dir":"RIGHT","maxHp":1271,"uid":5,"skillIndex":1},{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_06_a","name":"寒芒克洛丝","tier":4,"golden":false,"spine":"char_1021_kroos2","avatar":"char_1021_kroos2","x":6,"y":12,"facing":1,"dir":"RIGHT","maxHp":1246,"uid":6,"skillIndex":1},{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_10_a","name":"阿罗玛","tier":4,"golden":false,"spine":"char_446_aroma","avatar":"char_446_aroma","x":7,"y":12,"facing":1,"dir":"RIGHT","maxHp":1288,"uid":7,"skillIndex":1},{"id":8,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_20_a","name":"远牙","tier":4,"golden":false,"spine":"char_430_fartth","avatar":"char_430_fartth","x":8,"y":12,"facing":1,"dir":"RIGHT","maxHp":1217,"uid":8,"skillIndex":2},{"id":9,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":11,"facing":1,"dir":"RIGHT","maxHp":100},{"id":10,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":10,"facing":1,"dir":"RIGHT","maxHp":100}]},"frames":[{"snap":{"fieldId":"n:P1","t":0.1,"units":[[1,9,9,2849,2849,0,0,0,6],[2,8,9,2022,2022,55.1,90,0,6],[3,7,9,2938,2938,55.1,90,0,6],[4,4,10,1428,1428,56.1,110,0,6],[5,5,11,1271,1271,70.1,100,0,6],[6,6,12,1246,1246,15.1,45,0,6],[7,7,12,1288,1288,20.1,55,0,6],[8,8,12,1217,1217,6.1,27,0,6],[9,9,11,100,100,0,0,0,6],[10,9,10,100,100,0,0,0,6]],"dp":10,"killed":0,"total":35},"ev":[["spawn",{"id":9,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":11,"facing":1,"dir":"RIGHT","maxHp":100}],["spawn",{"id":10,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":10,"facing":1,"dir":"RIGHT","maxHp":100}],["spawn",{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_06_a","name":"寒芒克洛丝","tier":4,"golden":false,"spine":"char_1021_kroos2","avatar":"char_1021_kroos2","x":6,"y":12,"facing":1,"dir":"RIGHT","maxHp":1246,"uid":6,"skillIndex":1}],["deploy",6],["spawn",{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_10_a","name":"阿罗玛","tier":4,"golden":false,"spine":"char_446_aroma","avatar":"char_446_aroma","x":7,"y":12,"facing":1,"dir":"RIGHT","maxHp":1288,"uid":7,"skillIndex":1}],["deploy",7],["spawn",{"id":8,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_20_a","name":"远牙","tier":4,"golden":false,"spine":"char_430_fartth","avatar":"char_430_fartth","x":8,"y":12,"facing":1,"dir":"RIGHT","maxHp":1217,"uid":8,"skillIndex":2}],["deploy",8],["spawn",{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_21_a","name":"白面鸮","tier":4,"golden":false,"spine":"char_128_plosis","avatar":"char_128_plosis","x":5,"y":11,"facing":1,"dir":"RIGHT","maxHp":1271,"uid":5,"skillIndex":1}],["deploy",5],["spawn",{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_02_a","name":"莫斯提马","tier":4,"golden":false,"spine":"char_213_mostma","avatar":"char_213_mostma","x":4,"y":1Line truncated
{"version":1,"name":"normal-m02","title":"常规作战 · 沼泽控制 · R8","kind":"normal","stageId":"act2autochess_m02","modeId":"mode_multi_hard","round":8,"wave":"act1autochess_h01","tick":0.03333333333333333,"snapEvery":3,"duration":85,"recordedAt":"2026-09-27","field":{"fieldId":"n:P1","kind":"normal","rect":{"r0":9,"r1":12,"c0":0,"c1":10},"stageId":"act2autochess_m02","units":[{"id":1,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_17_a","name":"星熊","tier":4,"golden":false,"spine":"char_136_hsguma","avatar":"char_136_hsguma","x":9,"y":9,"facing":1,"dir":"RIGHT","maxHp":2849,"uid":1,"skillIndex":1},{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_22_a","name":"银灰","tier":4,"golden":false,"spine":"char_172_svrash","avatar":"char_172_svrash","x":8,"y":9,"facing":1,"dir":"RIGHT","maxHp":2022,"uid":2,"skillIndex":2},{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_05_a","name":"斯卡蒂","tier":3,"golden":false,"spine":"char_263_skadi","avatar":"char_263_skadi","x":7,"y":9,"facing":1,"dir":"RIGHT","maxHp":2938,"uid":3,"skillIndex":2},{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_02_a","name":"莫斯提马","tier":4,"golden":false,"spine":"char_213_mostma","avatar":"char_213_mostma","x":4,"y":10,"facing":1,"dir":"RIGHT","maxHp":1428,"uid":4,"skillIndex":2},{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_21_a","name":"白面鸮","tier":4,"golden":false,"spine":"char_128_plosis","avatar":"char_128_plosis","x":5,"y":11,"facing":1,"dir":"RIGHT","maxHp":1271,"uid":5,"skillIndex":1},{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_06_a","name":"寒芒克洛丝","tier":4,"golden":false,"spine":"char_1021_kroos2","avatar":"char_1021_kroos2","x":6,"y":12,"facing":1,"dir":"RIGHT","maxHp":1246,"uid":6,"skillIndex":1},{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_10_a","name":"阿罗玛","tier":4,"golden":false,"spine":"char_446_aroma","avatar":"char_446_aroma","x":7,"y":12,"facing":1,"dir":"RIGHT","maxHp":1288,"uid":7,"skillIndex":1},{"id":8,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_20_a","name":"远牙","tier":4,"golden":false,"spine":"char_430_fartth","avatar":"char_430_fartth","x":8,"y":12,"facing":1,"dir":"RIGHT","maxHp":1217,"uid":8,"skillIndex":2},{"id":9,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":11,"facing":1,"dir":"RIGHT","maxHp":100},{"id":10,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":10,"facing":1,"dir":"RIGHT","maxHp":100}]},"frames":[{"snap":{"fieldId":"n:P1","t":0.1,"units":[[1,9,9,2849,2849,0,0,0,6],[2,8,9,2022,2022,55.1,90,0,6],[3,7,9,2938,2938,55.1,90,0,6],[4,4,10,1428,1428,56.1,110,0,6],[5,5,11,1271,1271,70.1,100,0,6],[6,6,12,1246,1246,15.1,45,0,6],[7,7,12,1288,1288,20.1,55,0,6],[8,8,12,1217,1217,6.1,27,0,6],[9,9,11,100,100,0,0,0,6],[10,9,10,100,100,0,0,0,6]],"dp":10,"killed":0,"total":35},"ev":[["spawn",{"id":9,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":11,"facing":1,"dir":"RIGHT","maxHp":100}],["spawn",{"id":10,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":10,"facing":1,"dir":"RIGHT","maxHp":100}],["spawn",{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_06_a","name":"寒芒克洛丝","tier":4,"golden":false,"spine":"char_1021_kroos2","avatar":"char_1021_kroos2","x":6,"y":12,"facing":1,"dir":"RIGHT","maxHp":1246,"uid":6,"skillIndex":1}],["deploy",6],["spawn",{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_10_a","name":"阿罗玛","tier":4,"golden":false,"spine":"char_446_aroma","avatar":"char_446_aroma","x":7,"y":12,"facing":1,"dir":"RIGHT","maxHp":1288,"uid":7,"skillIndex":1}],["deploy",7],["spawn",{"id":8,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_20_a","name":"远牙","tier":4,"golden":false,"spine":"char_430_fartth","avatar":"char_430_fartth","x":8,"y":12,"facing":1,"dir":"RIGHT","maxHp":1217,"uid":8,"skillIndex":2}],["deploy",8],["spawn",{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_21_a","name":"白面鸮","tier":4,"golden":false,"spine":"char_128_plosis","avatar":"char_128_plosis","x":5,"y":11,"facing":1,"dir":"RIGHT","maxHp":1271,"uid":5,"skillIndex":1}],["deploy",5],["spawn",{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_02_a","name":"莫斯提马","tier":4,"golden":false,"spine":"char_213_mostma","avatar":"char_213_mostma","x":4,"y":1Line truncated
+1 -1
View File
@@ -1 +1 @@
{"version":1,"name":"normal-m03","title":"常规作战 · 排气格栅 · R11","kind":"normal","stageId":"act2autochess_m03","modeId":"mode_multi_hard","round":11,"wave":"act1autochess_h04","tick":0.03333333333333333,"snapEvery":3,"duration":90,"recordedAt":"2026-09-27","field":{"fieldId":"n:P1","kind":"normal","rect":{"r0":9,"r1":12,"c0":0,"c1":10},"stageId":"act2autochess_m03","units":[{"id":1,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_11_b","name":"塞雷娅","tier":5,"golden":true,"spine":"char_202_demkni","avatar":"char_202_demkni_2","x":9,"y":9,"facing":1,"dir":"RIGHT","maxHp":3003,"uid":1,"skillIndex":1,"moduleId":"uniequip_002_demkni"},{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_17_a","name":"山","tier":5,"golden":false,"spine":"char_264_f12yin","avatar":"char_264_f12yin","x":6,"y":9,"facing":1,"dir":"RIGHT","maxHp":2250,"uid":2,"skillIndex":1},{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_07_a","name":"史尔特尔","tier":5,"golden":false,"spine":"char_350_surtr","avatar":"char_350_surtr","x":5,"y":9,"facing":1,"dir":"RIGHT","maxHp":2216,"uid":3,"skillIndex":2},{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_12_a","name":"夕","tier":5,"golden":false,"spine":"char_2015_dusk","avatar":"char_2015_dusk","x":4,"y":12,"facing":1,"dir":"RIGHT","maxHp":1404,"uid":4,"skillIndex":0},{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_02_a","name":"缇缇","tier":5,"golden":false,"spine":"char_4056_titi","avatar":"char_4056_titi","x":5,"y":12,"facing":1,"dir":"RIGHT","maxHp":1208,"uid":5,"skillIndex":2},{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_03_a","name":"烛煌","tier":5,"golden":false,"spine":"char_1040_blaze2","avatar":"char_1040_blaze2","x":6,"y":12,"facing":1,"dir":"RIGHT","maxHp":1206,"uid":6,"skillIndex":2},{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_10_a","name":"铃兰","tier":5,"golden":false,"spine":"char_358_lisa","avatar":"char_358_lisa","x":7,"y":12,"facing":1,"dir":"RIGHT","maxHp":1184,"uid":7,"skillIndex":2},{"id":8,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_08_b","name":"号角","tier":5,"golden":true,"spine":"char_4039_horn","avatar":"char_4039_horn_2","x":4,"y":9,"facing":1,"dir":"RIGHT","maxHp":2775,"uid":8,"skillIndex":2,"moduleId":"uniequip_002_horn"},{"id":9,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":3,"y":12,"facing":1,"dir":"RIGHT","maxHp":100},{"id":10,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":3,"y":11,"facing":1,"dir":"RIGHT","maxHp":100},{"id":11,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":11,"facing":1,"dir":"RIGHT","maxHp":100}]},"frames":[{"snap":{"fieldId":"n:P1","t":0.1,"units":[[1,9,9,3003,3003,0.1,8,0,6],[2,6,9,2250,2250,0.1,9,0,6],[3,5,9,2216,2216,0.1,9,0,6],[4,4,12,1404,1404,0.1,7,0,6],[5,5,12,1208,1208,20.1,45,0,6],[6,6,12,1206,1206,13.1,37,8,6],[7,7,12,1184,1184,43.1,70,0,6],[8,4,9,2775,2775,25.1,40,0,6],[9,3,12,100,100,0,0,0,6],[10,3,11,100,100,0,0,0,6],[11,9,11,100,100,0,0,0,6]],"dp":10,"killed":0,"total":46},"ev":[["spawn",{"id":9,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":3,"y":12,"facing":1,"dir":"RIGHT","maxHp":100}],["spawn",{"id":10,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":3,"y":11,"facing":1,"dir":"RIGHT","maxHp":100}],["spawn",{"id":11,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":11,"facing":1,"dir":"RIGHT","maxHp":100}],["spawn",{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_12_a","name":"夕","tier":5,"golden":false,"spine":"char_2015_dusk","avatar":"char_2015_dusk","x":4,"y":12,"facing":1,"dir":"RIGHT","maxHp":1404,"uid":4,"skillIndex":0}],["deploy",4],["spawn",{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_02_a","name":"缇缇","tier":5,"golden":false,"spine":"char_4056_titi","avatar":"char_4056_titi","x":5,"y":12,"facing":1,"dir":"RIGHT","maxHp":1208,"uid":5,"skillIndex":2}],["deploy",5],["spawn",{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_03_a","name":"烛煌","tier":5,"golden":false,"spine":"char_1040_blaze2","avatar":"char_1040_blaze2","x":6,"y":12,"facing":1,"dir":"RILine truncated
{"version":1,"name":"normal-m03","title":"常规作战 · 排气格栅 · R11","kind":"normal","stageId":"act2autochess_m03","modeId":"mode_multi_hard","round":11,"wave":"act1autochess_h04","tick":0.03333333333333333,"snapEvery":3,"duration":90,"recordedAt":"2026-09-27","field":{"fieldId":"n:P1","kind":"normal","rect":{"r0":9,"r1":12,"c0":0,"c1":10},"stageId":"act2autochess_m03","units":[{"id":1,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_11_b","name":"塞雷娅","tier":5,"golden":true,"spine":"char_202_demkni","avatar":"char_202_demkni_2","x":9,"y":9,"facing":1,"dir":"RIGHT","maxHp":3003,"uid":1,"skillIndex":1,"moduleId":"uniequip_002_demkni"},{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_17_a","name":"山","tier":5,"golden":false,"spine":"char_264_f12yin","avatar":"char_264_f12yin","x":6,"y":9,"facing":1,"dir":"RIGHT","maxHp":2250,"uid":2,"skillIndex":1},{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_07_a","name":"史尔特尔","tier":5,"golden":false,"spine":"char_350_surtr","avatar":"char_350_surtr","x":5,"y":9,"facing":1,"dir":"RIGHT","maxHp":2216,"uid":3,"skillIndex":2},{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_12_a","name":"夕","tier":5,"golden":false,"spine":"char_2015_dusk","avatar":"char_2015_dusk","x":4,"y":12,"facing":1,"dir":"RIGHT","maxHp":1404,"uid":4,"skillIndex":0},{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_02_a","name":"缇缇","tier":5,"golden":false,"spine":"char_4056_titi","avatar":"char_4056_titi","x":5,"y":12,"facing":1,"dir":"RIGHT","maxHp":1208,"uid":5,"skillIndex":2},{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_03_a","name":"烛煌","tier":5,"golden":false,"spine":"char_1040_blaze2","avatar":"char_1040_blaze2","x":6,"y":12,"facing":1,"dir":"RIGHT","maxHp":1206,"uid":6,"skillIndex":2},{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_10_a","name":"铃兰","tier":5,"golden":false,"spine":"char_358_lisa","avatar":"char_358_lisa","x":7,"y":12,"facing":1,"dir":"RIGHT","maxHp":1184,"uid":7,"skillIndex":2},{"id":8,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_08_b","name":"号角","tier":5,"golden":true,"spine":"char_4039_horn","avatar":"char_4039_horn_2","x":4,"y":9,"facing":1,"dir":"RIGHT","maxHp":2775,"uid":8,"skillIndex":2,"moduleId":"uniequip_002_horn"},{"id":9,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":3,"y":12,"facing":1,"dir":"RIGHT","maxHp":100},{"id":10,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":3,"y":11,"facing":1,"dir":"RIGHT","maxHp":100},{"id":11,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":11,"facing":1,"dir":"RIGHT","maxHp":100}]},"frames":[{"snap":{"fieldId":"n:P1","t":0.1,"units":[[1,9,9,3003,3003,0.1,8,0,6],[2,6,9,2250,2250,0.1,9,0,6],[3,5,9,2216,2216,0.1,9,0,6],[4,4,12,1404,1404,0.1,7,0,6],[5,5,12,1208,1208,20.1,45,0,6],[6,6,12,1206,1206,13.1,37,8,6],[7,7,12,1184,1184,43.1,70,0,6],[8,4,9,2775,2775,25.1,40,0,6],[9,3,12,100,100,0,0,0,6],[10,3,11,100,100,0,0,0,6],[11,9,11,100,100,0,0,0,6]],"dp":10,"killed":0,"total":46},"ev":[["spawn",{"id":9,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":3,"y":12,"facing":1,"dir":"RIGHT","maxHp":100}],["spawn",{"id":10,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":3,"y":11,"facing":1,"dir":"RIGHT","maxHp":100}],["spawn",{"id":11,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":11,"facing":1,"dir":"RIGHT","maxHp":100}],["spawn",{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_12_a","name":"夕","tier":5,"golden":false,"spine":"char_2015_dusk","avatar":"char_2015_dusk","x":4,"y":12,"facing":1,"dir":"RIGHT","maxHp":1404,"uid":4,"skillIndex":0}],["deploy",4],["spawn",{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_02_a","name":"缇缇","tier":5,"golden":false,"spine":"char_4056_titi","avatar":"char_4056_titi","x":5,"y":12,"facing":1,"dir":"RIGHT","maxHp":1208,"uid":5,"skillIndex":2}],["deploy",5],["spawn",{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_5_03_a","name":"烛煌","tier":5,"golden":false,"spine":"char_1040_blaze2","avatar":"char_1040_blaze2","x":6,"y":12,"facing":1,"dir":"RILine truncated
+1 -1
View File
@@ -1 +1 @@
{"version":1,"name":"normal-m04","title":"常规作战 · 涨潮控制 · R6","kind":"normal","stageId":"act2autochess_m04","modeId":"mode_multi_normal","round":6,"wave":"act1autochess_06","tick":0.03333333333333333,"snapEvery":3,"duration":70,"recordedAt":"2026-09-27","field":{"fieldId":"n:P1","kind":"normal","rect":{"r0":9,"r1":12,"c0":0,"c1":10},"stageId":"act2autochess_m04","units":[{"id":1,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_16_a","name":"蛇屠箱","tier":3,"golden":false,"spine":"char_150_snakek","avatar":"char_150_snakek","x":9,"y":9,"facing":1,"dir":"RIGHT","maxHp":2173,"uid":1,"skillIndex":1},{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_18_a","name":"忍冬","tier":3,"golden":false,"spine":"char_4026_vulpis","avatar":"char_4026_vulpis","x":8,"y":9,"facing":1,"dir":"RIGHT","maxHp":1481,"uid":2,"skillIndex":2},{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_02_a","name":"断崖","tier":3,"golden":false,"spine":"char_294_ayer","avatar":"char_294_ayer","x":7,"y":9,"facing":1,"dir":"RIGHT","maxHp":1911,"uid":3,"skillIndex":1},{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_17_a","name":"流星","tier":3,"golden":false,"spine":"char_126_shotst","avatar":"char_126_shotst","x":3,"y":12,"facing":1,"dir":"RIGHT","maxHp":1404,"uid":4,"skillIndex":1},{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_21_a","name":"空弦","tier":3,"golden":false,"spine":"char_332_archet","avatar":"char_332_archet","x":4,"y":12,"facing":1,"dir":"RIGHT","maxHp":1364,"uid":5,"skillIndex":2},{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_2_14_a","name":"调香师","tier":2,"golden":false,"spine":"char_181_flower","avatar":"char_181_flower","x":5,"y":12,"facing":1,"dir":"RIGHT","maxHp":1232,"uid":6,"skillIndex":1},{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_14_a","name":"初雪","tier":3,"golden":false,"spine":"char_174_slbell","avatar":"char_174_slbell","x":6,"y":12,"facing":1,"dir":"RIGHT","maxHp":1251,"uid":7,"skillIndex":1},{"id":8,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":11,"facing":1,"dir":"RIGHT","maxHp":100},{"id":9,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":10,"facing":1,"dir":"RIGHT","maxHp":100}]},"frames":[{"snap":{"fieldId":"n:P1","t":0.1,"units":[[1,9,9,2173,2173,0.1,40,0,6],[2,8,9,1481,1481,6.1,21,0,6],[3,7,9,1911,1911,18.1,54,0,6],[4,3,12,1404,1404,0,19,0,6],[5,4,12,1364,1364,15,37,32,6],[6,5,12,1232,1232,20.1,60,0,6],[7,6,12,1251,1251,13.1,40,0,6],[8,9,11,100,100,0,0,0,6],[9,9,10,100,100,0,0,0,6]],"dp":10,"killed":0,"total":39},"ev":[["spawn",{"id":8,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":11,"facing":1,"dir":"RIGHT","maxHp":100}],["spawn",{"id":9,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":10,"facing":1,"dir":"RIGHT","maxHp":100}],["spawn",{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_17_a","name":"流星","tier":3,"golden":false,"spine":"char_126_shotst","avatar":"char_126_shotst","x":3,"y":12,"facing":1,"dir":"RIGHT","maxHp":1404,"uid":4,"skillIndex":1}],["deploy",4],["spawn",{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_21_a","name":"空弦","tier":3,"golden":false,"spine":"char_332_archet","avatar":"char_332_archet","x":4,"y":12,"facing":1,"dir":"RIGHT","maxHp":1364,"uid":5,"skillIndex":2}],["deploy",5],["status",5,"talent:archet_shield",1],["fx","shield",4,12,{"id":5}],["spawn",{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_2_14_a","name":"调香师","tier":2,"golden":false,"spine":"char_181_flower","avatar":"char_181_flower","x":5,"y":12,"facing":1,"dir":"RIGHT","maxHp":1232,"uid":6,"skillIndex":1}],["deploy",6],["spawn",{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_14_a","name":"初雪","tier":3,"golden":false,"spine":"char_174_slbell","avatar":"char_174_slbell","x":6,"y":12,"facing":1,"dir":"RIGHT","maxHp":1251,"uid":7,"skillIndex":1}],["deploy",7],["spawn",{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_02_a","name":"断崖","tier":3,"golden":false,"spine":"char_294_ayer","avatar":"char_294_ayer","x":7,"y":9,"facing":1,"dir":"RIGHT","maxHp":1911,"uid":3,"skillIndex":1}],["deploy",3],["spawn",{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_18_a","name":"忍冬","tier":3,"golden":false,"spine":"char_4026_vulpis","avatar":"char_4026_vulLine truncated
{"version":1,"name":"normal-m04","title":"常规作战 · 涨潮控制 · R6","kind":"normal","stageId":"act2autochess_m04","modeId":"mode_multi_normal","round":6,"wave":"act1autochess_06","tick":0.03333333333333333,"snapEvery":3,"duration":70,"recordedAt":"2026-09-27","field":{"fieldId":"n:P1","kind":"normal","rect":{"r0":9,"r1":12,"c0":0,"c1":10},"stageId":"act2autochess_m04","units":[{"id":1,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_16_a","name":"蛇屠箱","tier":3,"golden":false,"spine":"char_150_snakek","avatar":"char_150_snakek","x":9,"y":9,"facing":1,"dir":"RIGHT","maxHp":2173,"uid":1,"skillIndex":1},{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_18_a","name":"忍冬","tier":3,"golden":false,"spine":"char_4026_vulpis","avatar":"char_4026_vulpis","x":8,"y":9,"facing":1,"dir":"RIGHT","maxHp":1481,"uid":2,"skillIndex":2},{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_02_a","name":"断崖","tier":3,"golden":false,"spine":"char_294_ayer","avatar":"char_294_ayer","x":7,"y":9,"facing":1,"dir":"RIGHT","maxHp":1911,"uid":3,"skillIndex":1},{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_17_a","name":"流星","tier":3,"golden":false,"spine":"char_126_shotst","avatar":"char_126_shotst","x":3,"y":12,"facing":1,"dir":"RIGHT","maxHp":1404,"uid":4,"skillIndex":1},{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_21_a","name":"空弦","tier":3,"golden":false,"spine":"char_332_archet","avatar":"char_332_archet","x":4,"y":12,"facing":1,"dir":"RIGHT","maxHp":1364,"uid":5,"skillIndex":2},{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_2_14_a","name":"调香师","tier":2,"golden":false,"spine":"char_181_flower","avatar":"char_181_flower","x":5,"y":12,"facing":1,"dir":"RIGHT","maxHp":1232,"uid":6,"skillIndex":1},{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_14_a","name":"初雪","tier":3,"golden":false,"spine":"char_174_slbell","avatar":"char_174_slbell","x":6,"y":12,"facing":1,"dir":"RIGHT","maxHp":1251,"uid":7,"skillIndex":1},{"id":8,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":11,"facing":1,"dir":"RIGHT","maxHp":100},{"id":9,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":10,"facing":1,"dir":"RIGHT","maxHp":100}]},"frames":[{"snap":{"fieldId":"n:P1","t":0.1,"units":[[1,9,9,2173,2173,0.1,40,0,6],[2,8,9,1481,1481,6.1,21,0,6],[3,7,9,1911,1911,18.1,54,0,6],[4,3,12,1404,1404,0,19,0,6],[5,4,12,1364,1364,15,37,32,6],[6,5,12,1232,1232,20.1,60,0,6],[7,6,12,1251,1251,13.1,40,0,6],[8,9,11,100,100,0,0,0,6],[9,9,10,100,100,0,0,0,6]],"dp":10,"killed":0,"total":39},"ev":[["spawn",{"id":8,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":11,"facing":1,"dir":"RIGHT","maxHp":100}],["spawn",{"id":9,"kind":"device","side":"ally","ownerId":null,"defId":"trap_1105_accrate","name":"阻隔工事","tier":0,"golden":false,"spine":"trap_1105_accrate","avatar":"trap_1105_accrate","x":9,"y":10,"facing":1,"dir":"RIGHT","maxHp":100}],["spawn",{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_17_a","name":"流星","tier":3,"golden":false,"spine":"char_126_shotst","avatar":"char_126_shotst","x":3,"y":12,"facing":1,"dir":"RIGHT","maxHp":1404,"uid":4,"skillIndex":1}],["deploy",4],["spawn",{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_21_a","name":"空弦","tier":3,"golden":false,"spine":"char_332_archet","avatar":"char_332_archet","x":4,"y":12,"facing":1,"dir":"RIGHT","maxHp":1364,"uid":5,"skillIndex":2}],["deploy",5],["status",5,"talent:archet_shield",1],["fx","shield",4,12,{"id":5}],["spawn",{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_2_14_a","name":"调香师","tier":2,"golden":false,"spine":"char_181_flower","avatar":"char_181_flower","x":5,"y":12,"facing":1,"dir":"RIGHT","maxHp":1232,"uid":6,"skillIndex":1}],["deploy",6],["spawn",{"id":7,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_14_a","name":"初雪","tier":3,"golden":false,"spine":"char_174_slbell","avatar":"char_174_slbell","x":6,"y":12,"facing":1,"dir":"RIGHT","maxHp":1251,"uid":7,"skillIndex":1}],["deploy",7],["spawn",{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_02_a","name":"断崖","tier":3,"golden":false,"spine":"char_294_ayer","avatar":"char_294_ayer","x":7,"y":9,"facing":1,"dir":"RIGHT","maxHp":1911,"uid":3,"skillIndex":1}],["deploy",3],["spawn",{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_18_a","name":"忍冬","tier":3,"golden":false,"spine":"char_4026_vulpis","avatar":"char_4026_vulLine truncated
+1 -1
View File
@@ -1 +1 @@
{"version":1,"name":"unite-m01","title":"联防 · 源石流发生装置 · R9","kind":"unite","stageId":"act2autochess_m01","modeId":"mode_multi_normal","round":9,"wave":"act1autochess_escaped_multi","tick":0.03333333333333333,"snapEvery":3,"duration":60,"recordedAt":"2026-09-27","field":{"fieldId":"u","kind":"unite","rect":{"r0":9,"r1":12,"c0":0,"c1":20},"stageId":"act2autochess_m01","units":[{"id":1,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_17_a","name":"星熊","tier":4,"golden":false,"spine":"char_136_hsguma","avatar":"char_136_hsguma","x":9,"y":9,"facing":1,"dir":"RIGHT","maxHp":2849,"uid":1,"skillIndex":1},{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_08_a","name":"德克萨斯","tier":1,"golden":false,"spine":"char_102_texas","avatar":"char_102_texas","x":8,"y":9,"facing":1,"dir":"RIGHT","maxHp":1414,"uid":2,"skillIndex":1},{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_01_a","name":"能天使","tier":3,"golden":false,"spine":"char_103_angel","avatar":"char_103_angel","x":4,"y":10,"facing":1,"dir":"RIGHT","maxHp":1472,"uid":3,"skillIndex":2},{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_2_02_a","name":"赫默","tier":2,"golden":false,"spine":"char_108_silent","avatar":"char_108_silent","x":4,"y":11,"facing":1,"dir":"RIGHT","maxHp":1400,"uid":4,"skillIndex":1},{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_14_a","name":"格雷伊","tier":1,"golden":false,"spine":"char_253_greyy","avatar":"char_253_greyy","x":4,"y":12,"facing":1,"dir":"RIGHT","maxHp":1145,"uid":5,"skillIndex":1},{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_22_a","name":"银灰","tier":4,"golden":false,"spine":"char_172_svrash","avatar":"char_172_svrash","x":7,"y":9,"facing":1,"dir":"RIGHT","maxHp":2022,"uid":6,"skillIndex":2},{"id":7,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_5_11_a","name":"塞雷娅","tier":5,"golden":false,"spine":"char_202_demkni","avatar":"char_202_demkni","x":17,"y":9,"facing":1,"dir":"RIGHT","maxHp":2268,"uid":101,"skillIndex":1},{"id":8,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_2_16_a","name":"拉普兰德","tier":2,"golden":false,"spine":"char_140_whitew","avatar":"char_140_whitew","x":16,"y":9,"facing":1,"dir":"RIGHT","maxHp":1791,"uid":102,"skillIndex":1},{"id":9,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_4_02_a","name":"莫斯提马","tier":4,"golden":false,"spine":"char_213_mostma","avatar":"char_213_mostma","x":12,"y":10,"facing":1,"dir":"RIGHT","maxHp":1428,"uid":103,"skillIndex":2},{"id":10,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_4_21_a","name":"白面鸮","tier":4,"golden":false,"spine":"char_128_plosis","avatar":"char_128_plosis","x":12,"y":11,"facing":1,"dir":"RIGHT","maxHp":1271,"uid":104,"skillIndex":1},{"id":11,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_4_20_a","name":"远牙","tier":4,"golden":false,"spine":"char_430_fartth","avatar":"char_430_fartth","x":12,"y":12,"facing":1,"dir":"RIGHT","maxHp":1217,"uid":105,"skillIndex":2},{"id":12,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_5_02_a","name":"缇缇","tier":5,"golden":false,"spine":"char_4056_titi","avatar":"char_4056_titi","x":13,"y":12,"facing":1,"dir":"RIGHT","maxHp":1208,"uid":106,"skillIndex":2}]},"frames":[{"snap":{"fieldId":"u","t":0.1,"units":[[1,9,9,2849,2849,0,0,0,6],[2,8,9,1414,1414,23.1,40,0,6],[3,4,10,1472,1472,20.1,44,0,6],[4,4,11,1400,1400,0.1,26,0,6],[5,4,12,1145,1145,0.1,60,0,6],[6,7,9,2022,2022,55.1,90,0,6],[7,17,9,2268,2268,0.1,9,0,6],[8,16,9,1791,1791,0,27,0,6],[9,12,10,1428,1428,56.1,110,0,6],[10,12,11,1271,1271,70.1,100,0,6],[11,12,12,1217,1217,6.1,27,0,6],[12,13,12,1208,1208,20.1,45,0,6]],"dp":11,"killed":0,"total":40,"dps":{"P1":11,"P2":10}},"ev":[["spawn",{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_14_a","name":"格雷伊","tier":1,"golden":false,"spine":"char_253_greyy","avatar":"char_253_greyy","x":4,"y":12,"facing":1,"dir":"RIGHT","maxHp":1145,"uid":5,"skillIndex":1}],["deploy",5],["spawn",{"id":11,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_4_20_a","name":"远牙","tier":4,"golden":false,"spine":"char_430_fartth","avatar":"char_430_fartth","x":12,"y":12,"facing":1,"dir":"RIGHT","maxHp":1217,"uid":105,"skillIndex":2}],["deploy",11],["spawn",{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_2_02_a","name":"赫默","tier":2,"golden":false,"spine":"char_108_silent","avatar":"char_108_silent","x":4,"y":11,"facing":1,"dir":"RIGHT","maxHp":1400,"uid":4,"skillIndex":1}],["deploy",4],["spawn",{"id":12,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_5_02_a","name":"缇缇","tier":5,"golden":false,"spine":"char_4056_titi","avatar":"char_4056_titi","x":13,"y":12,"facing":1,"dir":"RIGHT","maxHp":1208,"uid":106,"skillIndex":2}],["deploy",12],["spawn",{"id":3,"kind":"op","side":"ally","ownerId":"P1","defLine truncated
{"version":1,"name":"unite-m01","title":"联防 · 源石流发生装置 · R9","kind":"unite","stageId":"act2autochess_m01","modeId":"mode_multi_normal","round":9,"wave":"act1autochess_escaped_multi","tick":0.03333333333333333,"snapEvery":3,"duration":60,"recordedAt":"2026-09-27","field":{"fieldId":"u","kind":"unite","rect":{"r0":9,"r1":12,"c0":0,"c1":20},"stageId":"act2autochess_m01","units":[{"id":1,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_17_a","name":"星熊","tier":4,"golden":false,"spine":"char_136_hsguma","avatar":"char_136_hsguma","x":9,"y":9,"facing":1,"dir":"RIGHT","maxHp":2849,"uid":1,"skillIndex":1},{"id":2,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_08_a","name":"德克萨斯","tier":1,"golden":false,"spine":"char_102_texas","avatar":"char_102_texas","x":8,"y":9,"facing":1,"dir":"RIGHT","maxHp":1414,"uid":2,"skillIndex":1},{"id":3,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_3_01_a","name":"能天使","tier":3,"golden":false,"spine":"char_103_angel","avatar":"char_103_angel","x":4,"y":10,"facing":1,"dir":"RIGHT","maxHp":1472,"uid":3,"skillIndex":2},{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_2_02_a","name":"赫默","tier":2,"golden":false,"spine":"char_108_silent","avatar":"char_108_silent","x":4,"y":11,"facing":1,"dir":"RIGHT","maxHp":1400,"uid":4,"skillIndex":1},{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_14_a","name":"格雷伊","tier":1,"golden":false,"spine":"char_253_greyy","avatar":"char_253_greyy","x":4,"y":12,"facing":1,"dir":"RIGHT","maxHp":1145,"uid":5,"skillIndex":1},{"id":6,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_4_22_a","name":"银灰","tier":4,"golden":false,"spine":"char_172_svrash","avatar":"char_172_svrash","x":7,"y":9,"facing":1,"dir":"RIGHT","maxHp":2022,"uid":6,"skillIndex":2},{"id":7,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_5_11_a","name":"塞雷娅","tier":5,"golden":false,"spine":"char_202_demkni","avatar":"char_202_demkni","x":17,"y":9,"facing":1,"dir":"RIGHT","maxHp":2268,"uid":101,"skillIndex":1},{"id":8,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_2_16_a","name":"拉普兰德","tier":2,"golden":false,"spine":"char_140_whitew","avatar":"char_140_whitew","x":16,"y":9,"facing":1,"dir":"RIGHT","maxHp":1791,"uid":102,"skillIndex":1},{"id":9,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_4_02_a","name":"莫斯提马","tier":4,"golden":false,"spine":"char_213_mostma","avatar":"char_213_mostma","x":12,"y":10,"facing":1,"dir":"RIGHT","maxHp":1428,"uid":103,"skillIndex":2},{"id":10,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_4_21_a","name":"白面鸮","tier":4,"golden":false,"spine":"char_128_plosis","avatar":"char_128_plosis","x":12,"y":11,"facing":1,"dir":"RIGHT","maxHp":1271,"uid":104,"skillIndex":1},{"id":11,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_4_20_a","name":"远牙","tier":4,"golden":false,"spine":"char_430_fartth","avatar":"char_430_fartth","x":12,"y":12,"facing":1,"dir":"RIGHT","maxHp":1217,"uid":105,"skillIndex":2},{"id":12,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_5_02_a","name":"缇缇","tier":5,"golden":false,"spine":"char_4056_titi","avatar":"char_4056_titi","x":13,"y":12,"facing":1,"dir":"RIGHT","maxHp":1208,"uid":106,"skillIndex":2}]},"frames":[{"snap":{"fieldId":"u","t":0.1,"units":[[1,9,9,2849,2849,0,0,0,6],[2,8,9,1414,1414,23.1,40,0,6],[3,4,10,1472,1472,20.1,44,0,6],[4,4,11,1400,1400,0.1,26,0,6],[5,4,12,1145,1145,0.1,60,0,6],[6,7,9,2022,2022,55.1,90,0,6],[7,17,9,2268,2268,0.1,9,0,6],[8,16,9,1791,1791,0,27,0,6],[9,12,10,1428,1428,56.1,110,0,6],[10,12,11,1271,1271,70.1,100,0,6],[11,12,12,1217,1217,6.1,27,0,6],[12,13,12,1208,1208,20.1,45,0,6]],"dp":11,"killed":0,"total":40,"dps":{"P1":11,"P2":10}},"ev":[["spawn",{"id":5,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_1_14_a","name":"格雷伊","tier":1,"golden":false,"spine":"char_253_greyy","avatar":"char_253_greyy","x":4,"y":12,"facing":1,"dir":"RIGHT","maxHp":1145,"uid":5,"skillIndex":1}],["deploy",5],["spawn",{"id":11,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_4_20_a","name":"远牙","tier":4,"golden":false,"spine":"char_430_fartth","avatar":"char_430_fartth","x":12,"y":12,"facing":1,"dir":"RIGHT","maxHp":1217,"uid":105,"skillIndex":2}],["deploy",11],["spawn",{"id":4,"kind":"op","side":"ally","ownerId":"P1","defId":"chess_char_2_02_a","name":"赫默","tier":2,"golden":false,"spine":"char_108_silent","avatar":"char_108_silent","x":4,"y":11,"facing":1,"dir":"RIGHT","maxHp":1400,"uid":4,"skillIndex":1}],["deploy",4],["spawn",{"id":12,"kind":"op","side":"ally","ownerId":"P2","defId":"chess_char_5_02_a","name":"缇缇","tier":5,"golden":false,"spine":"char_4056_titi","avatar":"char_4056_titi","x":13,"y":12,"facing":1,"dir":"RIGHT","maxHp":1208,"uid":106,"skillIndex":2}],["deploy",12],["spawn",{"id":3,"kind":"op","side":"ally","ownerId":"P1","defLine truncated
+34 -6
View File
@@ -23,14 +23,17 @@
// Live leaks (user playtest #3 item 2): every normal field simulated here keeps its counted leaks so far — the settle
// rule's count (leaked entries with counted !== false, /sim/spec.js battleProgress) — and publishes them as
// state().leaks { [fieldId]: n } whenever one changes, authoritative or display replica alike; the top bar shows the
// own field's min(lpCapPerRound, n) as the LP about to be lost (ui/hud.js liveLp).
// own field's min(lpCapPerRound, n) as the LP about to be lost (ui/hud.js liveLp). A 联防 field keeps each leaker's
// enemies still standing (/sim/spec.js uniteLeft; user playtest #6 item 7) as state().uniteLeft { [playerId]: n },
// published whenever it changes (it falls as the helpers kill them), and its authority reports it as b.progress `left`.
//
// The sim (≈ 0.2–1 ms per tick) runs on the main thread: one battle at a time is stepped for display (plus an
// authoritative one if it is not the one on screen). stats() exposes the measured cost.
//
// import { battleRunner } from './battle/runner.js' (browser singleton wired to net.js + store.js; null in Node)
// battleRunner.on('snap' | 'ev' | 'field' | 'state', fn) → off
// battleRunner.state() → { battleId, fieldId, kind, authoritative, watch, done, own, members, loading, paused, leaks } | null
// battleRunner.state() → { battleId, fieldId, kind, authoritative, watch, done, own, members, loading, paused, leaks,
// uniteLeft } | null
// battleRunner.stats() → { ticks, stepMs, avgTickMs, maxFrameMs, catchups, errors, battles }
// battleRunner.unitStats(unitId, fieldId?) → the live stats of a unit of the battle on screen (shared/protocol.js
// unitStatsEntry: current HP, effective max HP / ATK / DEF / RES / interval / block / move
@@ -167,13 +170,22 @@ export function createBattleRunner(deps) {
return out;
}
/**
* The 联防 field on screen (never an older round's kept entry, nor while a new battle is being prepared): each
* leaker's enemies still standing { [playerId]: n } (absent = none left), else null.
*/
function uniteLeftMap() {
const e = cur;
return !loading && e && e.kind === 'unite' && e.left ? { ...e.left } : null;
}
function state() {
const e = cur;
if (!e) return loading ? { loading: true, battleId: loading.battleId, fieldId: loading.fieldId, kind: loading.kind, leaks: leakMap() } : null;
if (!e) return loading ? { loading: true, battleId: loading.battleId, fieldId: loading.fieldId, kind: loading.kind, leaks: leakMap(), uniteLeft: uniteLeftMap() } : null;
return {
battleId: e.battleId, fieldId: e.fieldId, kind: e.kind, authoritative: e.authoritative, watch: e.watch,
done: e.done, own: e.own, members: e.members.slice(), loading: !!loading, speed: e.speed, paused: pausedAt != null,
leaks: leakMap(),
leaks: leakMap(), uniteLeft: uniteLeftMap(),
};
}
@@ -187,8 +199,11 @@ export function createBattleRunner(deps) {
/**
* Re-count an entry's leaks when its battle recorded a new one (Battle.leakedCount) or ended (timeout leaks, and the
* final result): the settle rule's count, leaked entries with counted !== false (/sim/spec.js battleProgress).
* 联防: each leaker's enemies still standing (/sim/spec.js uniteLeft) whenever a kill, a leak, a spawn or the end
* changed them.
*/
function noteLeaks(e) {
if (e.kind === 'unite') { noteUniteLeft(e); return; }
if (e.kind !== 'normal') return;
const b = e.battle;
const mark = `${Number(b.leakedCount) || 0}:${b.finished ? 1 : 0}`;
@@ -199,6 +214,16 @@ export function createBattleRunner(deps) {
if (n !== e.leaks) { e.leaks = n; leaksDirty = true; }
}
function noteUniteLeft(e) {
const b = e.battle;
const mark = `${Number(b.killed) || 0}:${Number(b.leakedCount) || 0}:${Number(b.total) || 0}:${b.finished ? 1 : 0}`;
if (mark === e.leakMark) return;
e.leakMark = mark;
let left = e.left;
try { left = e.sim.spec.uniteLeft(b) || left; } catch { /* keep the last count */ }
if (JSON.stringify(left) !== JSON.stringify(e.left)) { e.left = left; leaksDirty = true; }
}
/** Publish the state when a leak count changed since the last publish. */
function flushLeaks() { if (leaksDirty) publishState(); }
@@ -258,6 +283,8 @@ export function createBattleRunner(deps) {
}
} else {
msg.leaks = Math.min(1e6, p.leaks);
// 联防: the leakers' enemies still standing (shared/protocol.js b.progress `left`, ≤ 4 players)
if (p.left) msg.left = Object.fromEntries(Object.entries(p.left).slice(0, 4));
}
try { net.send('b.progress', msg); } catch { /* offline */ }
}
@@ -456,8 +483,9 @@ export function createBattleRunner(deps) {
t0: clock() - ((Number(msg.elapsed) || 0) / speed) * 1000, lastProgressAt: -Infinity, done: false, resultSent: false,
result: null, delivery: null,
meter: sim.spec.attachLpMeter(battle),
// counted leaks so far (normal fields; noteLeaks) and the Battle state they were counted at
leaks: 0, leakMark: '',
// counted leaks so far (normal fields; noteLeaks) and the Battle state they were counted at; 联防 fields: each
// leaker's enemies still standing (noteUniteLeft)
leaks: 0, leakMark: '', left: null,
};
if (lastPool && battle.sharedBoss && typeof battle.sharedBoss.sync === 'function') {
battle.sharedBoss.sync(lastPool.hp, lastPool.acked ? lastPool.acked[e.fieldId] : undefined);
+11 -2
View File
@@ -869,7 +869,7 @@ export async function createFieldView(host, options = {}) {
const moved = !prevHome || prevHome.x !== w.x || prevHome.y !== w.y || prevHome.z !== w.z;
if (dragState && dragState.key === key) {
dragState.home = w; // being dragged: only its home slot changes
} else if (pending.has(key) && !moved) {
} else if (pending.has(key) && !moved && !splitLanded(e, pending.get(key).board, list)) {
// dropped, but this state predates the server applying the move: stay at the drop target
} else {
pending.delete(key);
@@ -892,6 +892,15 @@ export async function createFieldView(host, options = {}) {
return true;
}
/**
* A summon stack of several copies (凯瑟琳's 2 devices, user playtest #6) dropped on a board tile stays in the hand
* and one copy — a new piece of the same token — lands there: the state already shows the move, the stack goes home.
*/
function splitLanded(e, tile, list) {
if (!tile || !e || e.piece?.kind !== 'token' || e.area === 'board') return false;
return list.some((x) => x.area === 'board' && x.uid !== e.uid && x.piece?.kind === 'token' && x.piece.id === e.piece.id && x.row === tile.row && x.col === tile.col);
}
function adaptCanPlace(fn) {
if (fn.length >= 3) return (piece, row, col, target) => !!fn(piece, row, col, target);
return (piece, row, col, target) => !!fn(piece.uid, target, piece);
@@ -1111,7 +1120,7 @@ export async function createFieldView(host, options = {}) {
const row = t.area === 'board' ? t.row : GEO.HAND_ROW, col = t.area === 'board' ? t.col : t.idx;
const w = boardWorld(row, col, t.area === 'hand');
v._tween = { fx: v.x, fy: v.y, fz: v.z, tx: w.x, ty: w.y, tz: w.z, t: 0 };
pending.set(dragState.key, { t: performance.now(), view: v });
pending.set(dragState.key, { t: performance.now(), view: v, board: t.area === 'board' ? { row, col } : null });
} else {
const h = dragState.home;
v._tween = { fx: v.x, fy: v.y, fz: v.z, tx: h.x, ty: h.y, tz: h.z, t: 0 };
+25 -7
View File
@@ -6,7 +6,7 @@
// renderer swaps the texture without touching geometry,
// * FX atlas (one base texture ⇒ particles batch / fit a ParticleContainer),
// * status icons, tier chips, backdrop gradients,
// * HUD rings: element gauge icons and the redeploy countdown ring (hudRings / ringArc),
// * HUD rings: the element discs of the element gauge row and the redeploy countdown ring (hudRings / ringArc),
// * avatar-in-rarity-diamond composites (cached per unit asset id) for the Spine fallback.
// Everything procedural is deterministic (seeded noise) and generated lazily on first use. Requires
// globalThis.PIXI and a DOM canvas at call time (never at import time).
@@ -1159,16 +1159,21 @@ export function itemTexture(key, img, color) {
}
// =============================================================================================================
// HUD rings (user playtest #4 items 8 and 9): the element gauge icon of a unit (PRTS 元素: "模型下部会显示对应的元素
// HUD rings (user playtest #4 items 8 and 9): the element icon of a unit's gauge row (PRTS 元素: "模型下部会显示对应的元素
// 图标,并以白条显示剩余的元素值" — operators: the element's disc with its glyph; enemies: "小尺寸图标(不显示元素图标,
// 仅根据元素种类改变背景色)") inside a white ring of the remaining 元素值, and the redeploy countdown ring above a
// knocked-out operator. One 512×576 atlas: RING_STEPS + 1 arc frames (white, clockwise from 12 o'clock) and the discs.
// 仅根据元素种类改变背景色)"; the white bar beside it — in the element's `tint` while it refills over a 爆发冷却 — is a
// plain sprite, render/units.js; since user playtest #6 the gauge is no ring any more, and the discs have a light rim),
// and the redeploy countdown ring above a knocked-out operator. One 512×576 atlas: RING_STEPS + 1 arc frames (white,
// clockwise from 12 o'clock) and the discs.
// Official element colours / glyphs after the client icons (图标 元素 sanity / water / fire / dark), drawn procedurally
// (the local-client extraction has no general battle HUD sprites).
/** Arc frames: `arcs[k]` covers k / RING_STEPS of the circle. */
export const RING_STEPS = 48;
/** Element disc colours (official icons: 神经 teal, 侵蚀 steel blue, 灼燃 rust, 凋亡 charcoal) and glyph colours. */
/**
* Element disc colours (official icons: 神经 teal, 侵蚀 steel blue, 灼燃 rust, 凋亡 charcoal) and glyph colours; `tint` =
* the element's bright colour (the gauge bar refilling over a 爆发冷却, render/units.js).
*/
export const ELEMENT_RING = Object.freeze({
neural: Object.freeze({ disc: '#13806c', glyph: '#bfe6df', tint: 0x1fae93 }),
erosion: Object.freeze({ disc: '#2d5d86', glyph: '#cfdcea', tint: 0x4a8cc4 }),
@@ -1179,8 +1184,21 @@ export const ELEMENT_RING = Object.freeze({
const RING_KEYS = Object.keys(ELEMENT_RING);
const RC = 64; // cell px
const RING_R = 26, RING_W = 6.5, DISC_R = 21.5;
/** The element disc's diameter as a share of its atlas cell (a sprite `d / HUD_DISC` px wide draws a `d` px disc). */
export const HUD_DISC = (2 * DISC_R) / RC;
let _rings = null;
/**
* The rim of an element disc: a light inner ring (about 1 px at the drawn size) inside a dark edge, so the dark discs
* (凋亡) stay readable on the dark ground (user playtest #6) [ASSUMED look].
*/
function discRim(c, cx, cy) {
c.strokeStyle = 'rgba(255,255,255,0.5)'; c.lineWidth = 3;
c.beginPath(); c.arc(cx, cy, DISC_R - 2.5, 0, Math.PI * 2); c.stroke();
c.strokeStyle = 'rgba(0,0,0,0.55)'; c.lineWidth = 1.5;
c.beginPath(); c.arc(cx, cy, DISC_R, 0, Math.PI * 2); c.stroke();
}
function drawElementGlyph(c, el, cx, cy, col) {
c.fillStyle = col; c.strokeStyle = col; c.lineCap = 'round'; c.lineJoin = 'round';
if (el === 'neural') { // concentric target
@@ -1239,10 +1257,10 @@ export function hudRings() {
RING_KEYS.forEach((el, i) => {
const x = i * RC, cx = x + RC / 2, cy = row + RC / 2;
c.fillStyle = ELEMENT_RING[el].disc; c.beginPath(); c.arc(cx, cy, DISC_R, 0, Math.PI * 2); c.fill();
c.strokeStyle = 'rgba(0,0,0,0.5)'; c.lineWidth = 1.5; c.stroke();
c.save(); c.beginPath(); c.arc(cx, cy, DISC_R - 1, 0, Math.PI * 2); c.clip();
drawElementGlyph(c, el, cx, cy, ELEMENT_RING[el].glyph);
c.restore();
discRim(c, cx, cy);
frames['a:' + el] = [x, row];
});
{
@@ -1260,7 +1278,7 @@ export function hudRings() {
RING_KEYS.forEach((el, i) => {
const x = i * RC, cx = x + RC / 2, cy = row + RC + RC / 2;
c.fillStyle = ELEMENT_RING[el].disc; c.beginPath(); c.arc(cx, cy, DISC_R, 0, Math.PI * 2); c.fill();
c.strokeStyle = 'rgba(0,0,0,0.5)'; c.lineWidth = 1.5; c.stroke();
discRim(c, cx, cy);
frames['e:' + el] = [x, row + RC];
});
const base = P.BaseTexture.from(canvas, { mipmap: P.MIPMAP_MODES.ON });
+84 -29
View File
@@ -7,7 +7,9 @@
// game never blocks on Spine). Every bar is a tinted Texture.WHITE sprite, so HUDs batch into few draw calls.
//
// Placement: feet anchored at world (x, y, z); scale = camera px-per-tile at the feet × UNIT.modelScale, so
// chibis shrink with distance like the original. Operators and summons face their deploy direction `dir`
// chibis shrink with distance like the original. An enemy's model is also scaled by its official prefab factor
// (enemies.json `modelScale`, user playtest #6 item 9: the official battle prefab shrinks e.g. 威龙 to 0.16 / 0.27 of
// the standard size — tools/build-data.mjs MODEL_SCALES; `enemyModelScale`). Operators and summons face their deploy direction `dir`
// (research 09 §1.2, DESIGN §3: UP|RIGHT|DOWN|LEFT, chosen with the direction wheel; UnitInfo / piece `dir`, else
// the legacy `facing` ±1): the Back model for UP (when one exists, research 07 §5.5), the Front model for RIGHT and
// DOWN, mirrored for LEFT; the orange ground wedge "›" of prep board pieces points along `dir` (rotated on the ground
@@ -24,10 +26,16 @@
// goes down with `die(true)`: straight to the held end of the clip, no fall.
// Enemy modes (sim fx 'phase' { id, kind } → `setForm(kind)`): 掠海漂移体 dropping to 爬行模式 (user playtest #5 item 1)
// plays its skeleton's 'Change' clip once, then the crawl set (*_02) — FORMS; a view built later keeps the mode.
// Element gauges (item 8, b.snap `elem` → sample `el` / `elFill` / `elUntil` / `elDur`): the official element icon
// beside the bars (PRTS 元素: operators the element's disc with its glyph, enemies a smaller plain disc of the
// element's colour) inside a white ring of the remaining 元素值 (1 − fill); during a 爆发冷却 the ring refills over
// the cooldown. Both rings are sprites of one atlas (textures.js hudRings), created on first use and hidden when idle.
// Element gauges (b.snap `elem` → sample `el` / `elFill` / `elUntil` / `elDur`), the official form (PRTS 元素: "模型
// 下部会显示对应的元素图标,并以白条显示剩余的元素值"; enemies "小尺寸图标(不显示元素图标,仅根据元素种类改变背景色)"): a row
// right under the unit's own HP / SP bars and inside their span — the element's disc at the left (operators with its
// glyph, enemies smaller and plain) and a white bar of the remaining 元素值 (1 − fill) that runs out right to left
// like the HP bar above it; during a 爆发冷却 the bar refills over the cooldown (PRTS: "元素条显示缓慢恢复至上限"), drawn in
// the element's colour with the disc pulsing [ASSUMED look], so a refill never reads as 元素值 still left while the
// burst's stun / damage / 凋亡 ticks hit the unit. User playtest #6 (report 11): the v2.3 ring right of the bars sat on
// the next operator's tier chip and bars (drawn under them) and read as that operator's gauge, a shrinking ring reads
// like a filling progress ring, and its refill was the same white as the 元素值 left. The disc comes from the hudRings
// atlas, the bars are tinted Texture.WHITE sprites; built on first use and hidden when idle.
//
// ItemView renders hand items as a floating icon plate; DeviceView renders battle devices (crates) as 3D boxes
// with an HP bar once damaged.
@@ -44,7 +52,7 @@
import { UF, ANIM } from '../../../shared/constants.js';
import { SpineActor } from './spine.js';
import { diamondTexture, shadowTexture, fxAtlas, tierChip, statusTexture, itemTexture, hudRings, ringArc } from './textures.js';
import { diamondTexture, shadowTexture, fxAtlas, tierChip, statusTexture, itemTexture, hudRings, ringArc, HUD_DISC, ELEMENT_RING } from './textures.js';
import { COLORS, TIER_COLORS, ENEMY_FRAME, UNIT, statusIconKey } from './style.js';
import { drawCrate, rowDepthKey, ROW_KEY, deviceBoxOf, DEVICE_BOX } from './tiles.js';
@@ -56,6 +64,14 @@ export function unitDir(info) {
if (d && DIRS.includes(d)) return d;
return info?.facing === -1 ? 'LEFT' : 'RIGHT';
}
/**
* Official size factor of an enemy's model (enemies.json `modelScale`: its battle prefab's scale / the standard 0.27;
* 1 when absent or unusable) — applied on top of UNIT.modelScale to the skeleton and to the head (bar) height.
*/
export function enemyModelScale(rec) {
const k = Number(rec && rec.modelScale);
return Number.isFinite(k) && k > 0.05 && k < 20 ? k : 1;
}
/** World step (x = col, y = row) of a direction. */
export const DIR_STEP = Object.freeze({ UP: [0, 1], RIGHT: [1, 0], DOWN: [0, -1], LEFT: [-1, 0] });
const nowMs = () => (globalThis.performance ? globalThis.performance.now() : Date.now());
@@ -75,8 +91,13 @@ export const DOWN_LOOK = Object.freeze({
ring: Object.freeze({ [DOWN_STATE.COUNTING]: 0x4ed8af, [DOWN_STATE.WAIT_DP]: 0xffc600, [DOWN_STATE.WAIT_TILE]: 0xff4b3e }),
size: 0.42, height: 1.02,
});
/** Element ring diameter in tiles (operators; enemies ×0.8) and its pixel clamp. */
const EL_RING = 0.3;
/**
* Element gauge row under the bars (see header): the disc's diameter in tiles and its pixel clamp (enemies × `enemy`),
* the gap under the bars (px). The white bar is as tall as the SP bar and fills the rest of the bars' width. During a
* 爆发冷却 the refilling bar takes the element's colour (textures.js ELEMENT_RING `tint`) and the disc's alpha pulses
* between `pulse` and 1 at `pulseHz` (real time).
*/
export const EL_BAR = Object.freeze({ icon: 0.15, min: 8, max: 15, enemy: 0.8, gap: 1, pulse: 0.45, pulseHz: 1.5 });
/**
* Enemy modes drawn with another clip set of the same skeleton (sim fx 'phase' kind → UnitView.setForm), per Spine
@@ -156,6 +177,8 @@ export class UnitView {
this._far = false; // small on screen (adaptive LOD, with hysteresis)
this.isEnemy = info.side === 'enemy';
this.isBoss = !!info.boss;
// enemies: the official prefab's size factor (1 for operators, summons and enemies at the standard size)
this.modelK = this.isEnemy ? enemyModelScale(ctx.lookupDef ? ctx.lookupDef(info) : null) : 1;
this.isToken = info.kind === 'token';
this.golden = !!info.golden;
this.tier = clamp(Number(info.tier) || 1, 1, 6);
@@ -178,9 +201,9 @@ export class UnitView {
this.dieT = 0; // seconds since die() (the Die clip's clock; a late Spine model catches up)
this.remove = false; // set when the death fade ended (owner removes the view)
this.down = null; // knocked out, waiting to redeploy: { until, total, state } (setDown) — no fade meanwhile
this.gameT = 0; // battle game time of the frame (render clock), for the rings' countdowns
this.gameT = 0; // battle game time of the frame (render clock), for the redeploy ring's countdown and the element bar's refill
this.el = null; this.elFill = 0; this.elUntil = 0; this.elDur = 0; // shown element gauge (sync)
this._elRing = null; // { track, disc, arc } sprites, built on first use
this._elBar = null; // { root, disc, bg, fill } sprites of the element gauge row, built on first use
this._downRing = null; // { disc, track, arc, text } sprites, built on first use
this.alpha = 1; this.fadeIn = this.prep ? 1 : 0;
this.lunge = 0; this.lungeDir = { x: 1, y: 0 };
@@ -648,7 +671,7 @@ export class UnitView {
if (this.swapT < 1) this.swapT = Math.min(1, this.swapT + dt * 5);
this.fallback.alpha = 1 - this.swapT;
this.fallback.visible = this.swapT < 1;
const sc = s * UNIT.modelScale;
const sc = s * UNIT.modelScale * this.modelK;
const flashK = this.flash > 0 ? this.flash : 0;
let tint = 0xffffff;
if (this.down) tint = DOWN_LOOK.tint;
@@ -725,10 +748,12 @@ export class UnitView {
this.aura.scale.set((s * 1.3) / 128, (s * 1.9) / 128);
}
// head height: operators/tokens are uniform chibis; enemies vary (setup-pose bounds, when known)
// head height: operators/tokens are uniform chibis; enemies vary (setup-pose bounds, when known; else the chibi
// headroom × their official model factor)
let headTiles = UNIT.headroom;
if (this.isEnemy && spineShown && this.actor.entry.bounds) headTiles = clamp(this.actor.height * UNIT.modelScale * 0.92, 0.55, this.isBoss ? 3.2 : 2.2);
if (this.isEnemy && spineShown && this.actor.entry.bounds) headTiles = clamp(this.actor.height * UNIT.modelScale * this.modelK * 0.92, 0.55, this.isBoss ? 3.2 : 2.2);
else if (this.isEnemy && this.isBoss) headTiles = 2.2;
else if (this.isEnemy && spineShown) headTiles = clamp(UNIT.headroom * this.modelK, 0.55, 2.2);
this._headTiles = headTiles;
this.screen.top = by - headTiles * s;
this._updateHud(dt, s, bx, by - headTiles * s, alpha, t);
@@ -829,8 +854,8 @@ export class UnitView {
ic.width = ic.height = isz;
ic.position.set(x - ((icons.length - 1) * (isz + 2)) / 2 + i * (isz + 2), iy);
}
// element gauge right of the bars (b.snap `elem`); redeploy ring above a knocked-down operator (b.snap `down`)
this._updateElementRing(showBars && !!this.el, x0 + bw, cy + (showSp ? spH / 2 : 0), s);
// element gauge row under the bars (b.snap `elem`); redeploy ring above a knocked-down operator (b.snap `down`)
this._updateElementBar(showBars && !!this.el, x0, bw, cy + bh / 2 + (showSp ? spH + 1.5 : 0) + 1, spH, s, t);
this._updateDownRing(!prep && !!this.down && !this.alive, x, this.screen.y - DOWN_LOOK.height * s, s, t);
// blocked marker at the feet (enemies held by a blocker)
const blocked = !prep && this.alive && this.isEnemy && (this.flags & UF.BLOCKED);
@@ -855,29 +880,59 @@ export class UnitView {
}
}
/** True while the shown gauge is in its 爆发冷却 (b.snap `elem` carries the cooldown's end and length). */
elementCooling() {
return !!this.el && this.elDur > 0 && this.elUntil > 0;
}
/**
* Element gauge icon (see header): the element's disc — operators with its glyph, enemies plain and smaller — in a
* white ring of the remaining 元素值 (1 − fill), which refills over a 爆发冷却; left edge at `xl`, centred on `cy`.
* Sprites of the hudRings atlas, built on the first gauge and hidden while there is none.
* The share of the element bar drawn (0..1): the remaining 元素值 (1 − fill), or during a 爆发冷却 the part of the
* cooldown already run (the bar refills to full, PRTS 元素 "元素条显示缓慢恢复至上限"). 0 without a gauge.
*/
_updateElementRing(show, xl, cy, s) {
let r = this._elRing;
elementLeft() {
if (!this.el) return 0;
if (this.elementCooling()) return clamp(1 - (this.elUntil - this.gameT) / this.elDur, 0, 1);
return clamp(1 - this.elFill, 0, 1);
}
/**
* Element gauge row (see header) under the bars spanning x0 … x0 + bw, its top at `top`: the element's disc at the
* left (operators with its glyph; enemies plain, × EL_BAR.enemy) and, beside it to the bars' right end, a bar of
* `elementLeft()` on a dark track `h` px tall — white for the 元素值 left; in the element's colour, the disc pulsing
* (clock `t`), while it refills over a 爆发冷却. Built on the first gauge, hidden while there is none.
*/
_updateElementBar(show, x0, bw, top, h, s, t = 0) {
let r = this._elBar;
if (!show) { if (r) r.root.visible = false; return; }
const tex = hudRings();
if (!r) {
const P = this.P;
const root = new P.Container();
const mk = (tx) => { const sp = new P.Sprite(tx); sp.anchor.set(0.5); root.addChild(sp); return sp; };
r = this._elRing = { root, track: mk(tex.track), disc: mk(tex.disc.burn), arc: mk(tex.arcs[0]) };
const bg = bar(P, root, COLORS.hpBack, 0.85);
const fill = bar(P, root, 0xffffff);
const disc = new P.Sprite(tex.disc.burn);
disc.anchor.set(0.5);
root.addChild(disc);
r = this._elBar = { root, disc, bg, fill };
this.hud.addChild(root);
}
const cooling = this.elDur > 0 && this.elUntil > 0;
const frac = cooling ? clamp(1 - (this.elUntil - this.gameT) / this.elDur, 0, 1) : clamp(1 - this.elFill, 0, 1);
const d = clamp(s * EL_BAR.icon, EL_BAR.min, EL_BAR.max) * (this.isEnemy ? EL_BAR.enemy : 1);
const cy = top + EL_BAR.gap + d / 2;
r.disc.texture = (this.isEnemy ? tex.discEnemy : tex.disc)[this.el] || tex.disc.burn;
r.arc.texture = ringArc(frac);
const d = clamp(s * EL_RING * (this.isEnemy ? 0.8 : 1), 13, 32);
r.root.scale.set(d / tex.size);
r.root.position.set(xl + d / 2 + 2, cy);
r.disc.width = r.disc.height = d / HUD_DISC; // the disc fills HUD_DISC of its atlas cell
r.disc.position.set(x0 + d / 2, cy);
const bx = x0 + d + 2, w = Math.max(4, x0 + bw - bx);
r.bg.position.set(bx - 1, cy); r.bg.width = w + 2; r.bg.height = h + 2;
const k = this.elementLeft();
r.fill.visible = k > 0;
r.fill.position.set(bx, cy); r.fill.width = w * k; r.fill.height = h;
if (this.elementCooling()) {
r.fill.tint = (ELEMENT_RING[this.el] || ELEMENT_RING.burn).tint;
r.disc.alpha = EL_BAR.pulse + (1 - EL_BAR.pulse) * (0.5 + 0.5 * Math.cos(t * Math.PI * 2 * EL_BAR.pulseHz));
} else {
r.fill.tint = 0xffffff;
r.disc.alpha = 1;
}
r.root.visible = true;
}
@@ -1039,7 +1094,7 @@ export class UnitView {
for (const k of this.statuses) {
if (out.length >= 4) break;
if (k === 'skill') continue;
// a burst's lock ('burnBurst', 'neuralBurst' … — the 爆发冷却) is shown by the element icon beside the bars
// a burst's lock ('burnBurst', 'neuralBurst' … — the 爆发冷却) is shown by the element gauge row under the bars
// (b.snap `elem`); only a feed without gauges (an older recording) shows it as a status
if (this.el && k.endsWith('Burst')) continue;
const icon = statusIconKey(k);
+19 -9
View File
@@ -37,9 +37,10 @@
// shows the paused overlay (继续作战 / 放弃模拟) and freezes the HUD clocks. Boss rounds: the top bar shows the level's
// 120 s countdown and the red DOT overtime warning (m.public.overtimeAt).
// User playtest #3: the own LP drops live while the own battle's enemies enter the blue gate (item 2: the runner's
// state().leaks → ui/hud.js liveLp → top bar + own team row; teammates' rows from m.public players[].pendingLp); while
// the temp overflow row (临时整备区) holds pieces it is framed and labelled on the board (ui/underframe.js
// TempRowNotice) and 准备就绪 / Space say why they are refused (item 3).
// state().leaks → ui/hud.js liveLp → top bar + own team row; teammates' rows from m.public players[].pendingLp; in 联防 a
// leaker's enemies still standing — runner state().uniteLeft / m.public players[].uniteLeft, the ×N tag — user
// playtest #6 item 7); while the temp overflow row (临时整备区) holds pieces it is framed and labelled on the board
// (ui/underframe.js TempRowNotice) and 准备就绪 / Space say why they are refused (item 3).
// 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
// (item 2, ui/choiceOverlay.js).
@@ -49,7 +50,7 @@ import { PHASE, GEO } from '../../../shared/constants.js';
import { html, Spinner, PhaseBanner, Icon, Button, MicroLabel, confirmDialog, useTicker } from '../ui/components.js';
import { useGameData, GIcon } from '../ui/gameComponents.js';
import { useFieldView } from '../ui/fieldHost.js';
import { TopBar, liveLp, ownLeaks, tempInfo, tempReadyReason } from '../ui/hud.js';
import { TopBar, liveLp, ownLeaks, uniteRemaining, tempInfo, tempReadyReason } from '../ui/hud.js';
import { BondStrip, BondPopup } from '../ui/bondStrip.js';
import { TeamPanel } from '../ui/teamPanel.js';
import { ShopBar } from '../ui/shopBar.js';
@@ -217,12 +218,18 @@ function MatchScreen() {
// or display replica) and the server's m.public players[].pendingLp, whichever is further (both only grow during a
// round; a display replica stands still while the player watches a teammate's field, the server's count then moves
// on) — cost min(lpCapPerRound, n) at settlement; shown at once until the settled m.private lands (ui/hud.js liveLp
// keeps the base between renders)
// keeps the base between renders); 联防 (user playtest #6 item 7): a leaker's enemies still standing on the 联防 field —
// the local replica's count while it runs, else m.public players[].uniteLeft (ui/hud.js uniteRemaining) — replace it;
// the teammates' rows take the same local counts (`uniteLocal`, ui/teamPanel.js rowLp)
const lpBaseRef = useRef(null);
const localLeaks = battleState && battleState.leaks ? battleState.leaks[ownFieldId(myId)] : undefined;
const uniteLocal = phase === PHASE.UNITE && battleState && battleState.uniteLeft ? battleState.uniteLeft : null;
const leaker = phase === PHASE.UNITE && Array.isArray(pub?.unite?.leakers) && pub.unite.leakers.includes(myId);
const localLeft = leaker && uniteLocal ? (uniteLocal[myId] ?? 0) : undefined;
const liveLpNow = liveLp(lpBaseRef.current, {
phase, round: pub?.round, lp: priv?.lp, statsLeaks: priv?.stats?.leaks, alive,
leaks: ownLeaks(localLeaks, meP?.pendingLp), cap: gd.config?.lpCapPerRound,
uniteLeft: leaker ? uniteRemaining(localLeft, meP?.uniteLeft) : null,
});
lpBaseRef.current = liveLpNow.base;
@@ -807,11 +814,14 @@ function MatchScreen() {
// accepted: the piece stays on the tile until m.private shows it there (or a short grace passes)
setTimeout(() => { if (heldRef.current.has(f.uid)) releaseHold(f.uid); }, 1500);
}, [view, releaseHold]);
// m.private caught up with a committed placement → stop holding it
// m.private caught up with a committed placement → stop holding it; a summon stack of several copies (凯瑟琳's 2
// devices, user playtest #6) stays in the hand and one copy — another piece — lands on the tile: release it too
useEffect(() => {
const splitLanded = (e, h) => e.piece.kind === 'token' && [...placeCtx.pieces.values()].some((x) => x.area === 'board' && x.piece.kind === 'token'
&& x.piece.id === e.piece.id && x.piece.uid !== e.piece.uid && x.row === h.row && x.col === h.col);
for (const [uid, h] of [...heldRef.current]) {
const e = placeCtx.pieces.get(uid);
if (!e || (e.area === 'board' && e.row === h.row && e.col === h.col)) releaseHold(uid);
if (!e || (e.area === 'board' && e.row === h.row && e.col === h.col) || (e.area !== 'board' && splitLanded(e, h))) releaseHold(uid);
}
}, [placeCtx]);
// the wheel closes when editing stops (ready, phase end, observing) or its piece is gone (merged, sold)
@@ -1038,8 +1048,8 @@ function MatchScreen() {
onOpen=${(id) => { setBondOpen((b) => (b === id ? null : id)); audio.sfx('click', { volume: 0.4 }); }} />
</div>
<${TeamPanel} pub=${pub} myId=${myId} watching=${watchingNow} bubbles=${bubbles} onWatch=${watchPlayer}
self=${Number.isFinite(priv?.lp) ? { lp: priv.lp, pending: liveLpNow.pending, unite: liveLpNow.unite } : null}
<${TeamPanel} pub=${pub} myId=${myId} watching=${watchingNow} bubbles=${bubbles} onWatch=${watchPlayer} cap=${gd.config?.lpCapPerRound ?? 10} uniteLocal=${uniteLocal}
self=${Number.isFinite(priv?.lp) ? { lp: priv.lp, pending: liveLpNow.pending, unite: liveLpNow.unite, left: liveLpNow.left } : null}
observe=${cc ? { canObserve: (p) => observeTarget(p, pub, myId, { observing: watchingOther, ownDone: localDone }), observing: watchingOther, onBack: backHome } : null} />
<div class="gm__effects"><${EffectsList} effects=${priv?.effects} /></div>
+44 -21
View File
@@ -1,14 +1,20 @@
// 机变 draft overlay (research 06 §4.4 / §11.5): family title | description, "倒计时结束后仍未选定将自动分配",
// whose turn ("当前轮到你决策" / "{name} 正在决策…") with countdown, pick order with ✓ / ⌛ / … / door,
// and a 3×2 grid (solo: 3 cards) of cards with icon, title, rich description, tier chip and the taker's
// avatar badge. Picking takes two taps, like buying in the shop (user playtest #4 item 2 — extra enemies, items and
// tactics were picked by a slip of the finger; research 09 §5 EventOnFirstClick → EventOnConfirm): the first tap on an
// available card selects it (it lifts with a gold frame and a 确认选择 · 再次点击 strip; the header shows 确认选择), a
// second tap on the same card — or 确认选择 — sends g.choice; a tap on another card moves the selection, a tap
// elsewhere or Esc drops it. Cards are buttons (Tab / Enter work the same way). While the pick is in flight the card
// shows a "选择中" strip with a sweeping bar (never a spinner over its text — user playtest #3 item 9), dropped as soon
// as the pick shows in m.public (spBusy itself resets when the request settles, ≤ 8 s, or the phase moves on).
// Untimed drafts (solo, a single-human match: sp.untimed) show no countdown and say so.
// and a 3×2 grid (solo: 3 cards) of cards in the official layout (the official 悬赏 / 机密商店 / 战术 screenshots): a
// framed icon at the top-left with the bold name beside it (tags under the name), the effect text across the card under
// both, left-aligned; the tier chip on the icon's corner and the taker's round avatar alone at the top-right (their
// name is its tooltip and part of the card's accessible name; a taken card's name wraps before it). The effect text
// is the official rich text of the item / effect (items.json / effects.json `descRaw` — the detail card's text — while it
// says what the server card's plain `desc` says: cardText), clamped by lines, and it fits the card on phones too (user
// playtest #6 item 6: at 756×366 it used to sit below a large centred icon, clipped away). Picking takes two taps, like
// buying in the shop (user playtest #4 item 2 — extra enemies, items and tactics were picked by a slip of the finger;
// research 09 §5 EventOnFirstClick → EventOnConfirm): the first tap on an available card selects it (it lifts with a
// gold frame and a 确认选择 · 再次点击 strip; the header shows 确认选择), a second tap on the same card — or 确认选择 —
// sends g.choice; a tap on another card moves the selection, a tap elsewhere or Esc drops it. Cards are buttons (Tab /
// Enter work the same way). While the pick is in flight the card shows a "选择中" strip with a sweeping bar (never a
// spinner over its text — user playtest #3 item 9), dropped as soon as the pick shows in m.public (spBusy itself resets
// when the request settles, ≤ 8 s, or the phase moves on). Untimed drafts (solo, a single-human match: sp.untimed) show
// no countdown and say so.
import { useEffect, useState } from '../../vendor/hooks.module.js';
import { html, Icon, TierChip, Countdown, MicroLabel, Button } from './components.js';
@@ -20,6 +26,20 @@ import { data } from '../data.js';
const cx = (...p) => p.flat().filter(Boolean).join(' ');
const bare = (t) => String(t || '').replace(/\s+/g, '');
/**
* A card's effect text: the card's own rich text, else the data's rich text (highlighted numbers, 下场作战, set bonuses,
* line breaks — the detail card's text) while it says what the server's plain `desc` says, else that plain text (a
* server-side wording the data does not have wins).
* @param {{ descRaw?: string, desc?: string }} card @param {string} rich items.json / effects.json descRaw
*/
export function cardText(card, rich) {
if (card.descRaw) return card.descRaw;
if (rich && (!card.desc || bare(richTextPlain(rich)) === bare(card.desc))) return rich;
return card.desc || rich || '';
}
/**
* Resolve an sp card to display fields.
* @param {any} card normalised card ({ idx, takenBy, effectId?, itemId?, id?, name?, desc?, descRaw?, tier?, team?, enemyKey? })
@@ -47,7 +67,7 @@ export function resolveSpCard(card, family) {
return {
kind,
name: card.name || item?.name || eff?.name || bounty?.name || tactic?.name || '机变',
desc: card.descRaw || card.desc || item?.descRaw || item?.desc || eff?.descRaw || eff?.desc || bounty?.desc || tactic?.desc || '',
desc: cardText(card, item?.descRaw || eff?.descRaw || '') || item?.desc || eff?.desc || bounty?.desc || tactic?.desc || '',
tier: Number.isFinite(card.tier) ? card.tier : item?.tier ?? bounty?.tier ?? null,
icon,
team: !!team,
@@ -190,21 +210,24 @@ export function ChoiceView({ pub, sp, myId, solo, busyIdx = null, total = null,
const can = cardPickable(sp, card, { myId, solo, busyIdx });
const busy = pickBusy(busyIdx, card, mine);
const isArmed = can && armed === card.idx;
const takerName = taker ? (card.takenBy === myId ? '你' : taker.name) : null;
return html`<button key=${card.idx} type="button" class=${cx('spcard', `spcard--${r.kind}`, card.takenBy && 'is-taken', card.takenBy === myId && 'is-mine', can && 'is-pickable', isArmed && 'is-armed', busy && 'is-busy')}
aria-busy=${busy ? 'true' : undefined} aria-pressed=${can ? String(isArmed) : undefined}
disabled=${!can} onClick=${() => can && onTap(card.idx)} aria-label=${isArmed ? `${r.name},已选中,再次点击确认` : r.name} title=${`${r.name}\n${richTextPlain(r.desc)}`}>
aria-busy=${busy ? 'true' : undefined} aria-pressed=${can ? String(isArmed) : undefined} disabled=${!can} onClick=${() => can && onTap(card.idx)}
aria-label=${isArmed ? `${r.name},已选中,再次点击确认` : takerName ? `${r.name},${takerName}已选择` : r.name} title=${`${r.name}\n${richTextPlain(r.desc)}`}>
<span class="spcard__glow" aria-hidden="true"></span>
${r.tier ? html`<${TierChip} tier=${r.tier} size="md" class="spcard__tier" />` : null}
<span class="spcard__icon"><${Img} src=${r.icon} fallback=${html`<${GIcon} name=${r.kind === 'bounty' ? 'target' : 'bolt'} />`} /></span>
<span class="spcard__body">
<b class="spcard__name">${r.name}</b>
<${RichText} text=${r.desc} class="spcard__desc" />
<span class="spcard__tags">
${r.team ? html`<span class="spcard__tag spcard__tag--team">全队获得</span>` : null}
${r.kind === 'bounty' && r.coin ? html`<span class="spcard__tag spcard__tag--coin">赏金 ${r.coin}</span>` : null}
<span class="spcard__head">
<span class="spcard__icon"><${Img} src=${r.icon} fallback=${html`<${GIcon} name=${r.kind === 'bounty' ? 'target' : 'bolt'} />`} /></span>
<span class="spcard__title">
<b class="spcard__name">${r.name}</b>
${r.team || (r.kind === 'bounty' && r.coin) ? html`<span class="spcard__tags">
${r.team ? html`<span class="spcard__tag spcard__tag--team">全队获得</span>` : null}
${r.kind === 'bounty' && r.coin ? html`<span class="spcard__tag spcard__tag--coin">赏金 ${r.coin}</span>` : null}
</span>` : null}
</span>
</span>
${taker ? html`<span class="spcard__taker" title=${`${taker.name} 已选择`}><${PlayerAvatar} player=${taker} size="sm" /><span>${card.takenBy === myId ? '你' : taker.name}</span></span>` : null}
<${RichText} text=${r.desc} class="spcard__desc" />
${r.tier ? html`<${TierChip} tier=${r.tier} size="md" class="spcard__tier" />` : null}
${taker ? html`<span class="spcard__taker" title=${`${taker.name} 已选择`}><${PlayerAvatar} player=${taker} size="sm" /></span>` : null}
${isArmed ? html`<span class="spcard__confirm" role="status"><b>确认选择</b><small>再次点击</small></span>` : null}
${busy ? html`<span class="spcard__busy" role="status">选择中</span>` : null}
</button>`;
+58 -7
View File
@@ -6,7 +6,9 @@
// compact, visible without scrolling: user playtest #3 items 8 / 9), the class trait (特性), stats + range mini-map, skill (the one chosen in the loadout, DESIGN §16: icon, SP
// info, rich description, 已调配 when not the default), elite module (模组: official type icon from the local-client
// art, else its letter), equipped items, talents (CHESS_SECTIONS); items — icon, tier,
// effect; tokens; enemies — stats, rank, faction tags, abilities. Selling / destroying is the underframe's job in the
// effect; tokens — the owner's variant (a golden owner's summon: its `_b` stats / skill), how a placed summon takes
// the field (shared/constants.js SKILL_SUMMON_START_DEPLOY), its token skill and talents; enemies — stats, rank,
// faction tags, abilities. Selling / destroying is the underframe's job in the
// match (research 09 §5, ui/underframe.js): the panel's own 出售 / 销毁 buttons only render for callers that pass
// `editable` + handlers. `side` 'right' docks the panel at the right edge (the game screen picks the side away from a
// selected unit's underframe, gameLogic panelSide).
@@ -23,6 +25,7 @@ import { attackInterval, rangeGridBox, fmtNum, tileKey, chessLoadout, nextThresh
import { chessPortraitUrl, skillIconUrl, skillRecordIconUrl, profIconUrl, subProfIconUrl, itemIconUrl, enemyIconUrl, tokenAvatarUrl, factionIconUrl, uiUrl, moduleTypeIconUrl } from './assetUrls.js';
import { data } from '../data.js';
import { attackRangeGrid } from '../../../shared/loadoutRecord.js';
import { SKILL_SUMMON_START_DEPLOY } from '../../../shared/constants.js';
import { moduleBadge } from './loadoutModel.js';
const cx = (...p) => p.flat().filter(Boolean).join(' ');
@@ -375,14 +378,59 @@ function EnemyDetail({ enemy, snapHp, count, live = null }) {
<//>` : enemy.descRaw || enemy.desc ? html`<${Section} title="说明"><${RichText} as="p" text=${enemy.descRaw || enemy.desc} class="dtext" /><//>` : null}`;
}
function TokenDetail({ token, piece, snapHp = null, live = null }) {
/**
* How a summon piece placed in the prep phase takes the field (user playtest #6; sim/content/tokens.js): a talent
* summon deploys with the board; a skill's summon (赫默's 医疗探机, 巫恋's 诅咒娃娃) once at the battle start and again
* with each skill (`startDeploy`: shared/constants.js SKILL_SUMMON_START_DEPLOY, the PRTS reading the user settled) —
* or, with the switch off, only when its owner's skill fires. null for tokens that are no hand piece.
* @param {any} token tokens.json record
* @param {boolean} [startDeploy] the sim's switch (tests pass both values)
*/
export function summonDeployHint(token, startDeploy = SKILL_SUMMON_START_DEPLOY) {
if (!token || token.kind !== 'summon' || token.placeable !== true) return null;
const talent = Object.values(token.variants || {}).some((v) => (v?.sources || []).includes('talent'));
if (talent) return '作战开始时在摆放的位置部署';
return startDeploy
? '作战开始时在摆放的位置部署一次,之后所属干员每次发动技能时再次出现(未摆放则不会出现)'
: '所属干员发动技能时才在摆放的位置出现(未摆放则不会出现)';
}
/**
* The token variant of the summon's owner (tokens.json `variants`, keyed by owner chess id): a golden owner's `_b`
* entry (精锐 赫默's drone ATK 114, 精锐 巫恋's doll −30%), else its normal `_a` entry, else the first one.
* @param {any} token tokens.json record
* @param {string|null} ownerId the owner's chess id (null: unknown, e.g. a teammate's summon)
*/
export function tokenVariantFor(token, ownerId = null) {
const vs = token?.variants || {};
if (typeof ownerId === 'string') {
const v = vs[ownerId] || vs[ownerId.replace(/_b$/, '_a')];
if (v) return v;
}
return Object.values(vs)[0] || null;
}
/** Chess id of the operator owning a token piece (`ownerUid`), from the player's own pieces (indexPieces). */
function tokenOwnerId(piece, pieces) {
if (!piece || piece.kind !== 'token' || !Number.isInteger(piece.ownerUid)) return null;
const owner = pieces?.get(piece.ownerUid)?.piece;
return owner && owner.kind === 'chess' ? owner.id : null;
}
export function TokenDetail({ token, piece, ownerId = null, snapHp = null, live = null }) {
const m = data.get('assets');
const s = token.stats || {};
// the owner's variant: its stats, talents and token skill (a golden owner's summon is stronger)
const v0 = tokenVariantFor(token, ownerId);
const s = v0?.stats || token.stats || {};
const hp = hpOf(live, snapHp);
const st = {
maxHp: liveStat(live, 'maxHp', s.maxHp), atk: liveStat(live, 'atk', s.atk), def: liveStat(live, 'def', s.def),
blockCnt: liveStat(live, 'blockCnt', s.blockCnt, (v) => String(v)),
};
// what the summon does lives in its talent (凯瑟琳's 支援装置: "使攻击范围内一名友方干员获得…屏障") or token skill (诅咒娃娃)
const talents = (v0?.talents || []).filter((t) => t && t.name && t.desc);
const skill = v0?.skill && v0.skill.desc && !/^skcom_withdraw/.test(String(v0.skill.skillId || '')) ? v0.skill : null;
const hint = piece ? summonDeployHint(token) : null;
return html`
<div class="dhead dhead--item">
<div class="dhead__icon"><${Img} src=${tokenAvatarUrl(m, token.tokenId)} fallback=${html`<${GIcon} name="target" />`} /></div>
@@ -397,7 +445,10 @@ function TokenDetail({ token, piece, snapHp = null, live = null }) {
<${Stat} k="生命上限" ...${st.maxHp} /><${Stat} k="攻击" ...${st.atk} />
<${Stat} k="防御" ...${st.def} /><${Stat} k="阻挡数" ...${st.blockCnt} />
</div>
${token.descRaw || token.desc ? html`<${Section} title="说明"><${RichText} as="p" text=${token.descRaw || token.desc} class="dtext" /><//>` : null}`;
${hint ? html`<p class="dhint"><${Icon} name="info" />${hint}</p>` : null}
${token.descRaw || token.desc ? html`<${Section} title="说明"><${RichText} as="p" text=${token.descRaw || token.desc} class="dtext" /><//>` : null}
${skill ? html`<${Section} title="技能"><p class="dtext"><b>${skill.name}</b> ${skill.desc}</p><//>` : null}
${talents.length ? html`<${Section} title="天赋">${talents.map((t, i) => html`<p class="dtext" key=${i}><b>${t.name}</b> ${t.desc}</p>`)}<//>` : null}`;
}
/**
@@ -412,7 +463,7 @@ export function resolveDetail(target, pieces) {
if (!e) return null;
const p = e.piece;
if (p.kind === 'item') { const it = data.lookup('items', p.id); return it ? { type: 'item', item: it, piece: p } : null; }
if (p.kind === 'token') { const t = data.lookup('tokens', p.id); return t ? { type: 'token', token: t, piece: p } : null; }
if (p.kind === 'token') { const t = data.lookup('tokens', p.id); return t ? { type: 'token', token: t, piece: p, ownerId: tokenOwnerId(p, pieces) } : null; }
const c = data.lookup('chess', p.id);
return c ? { type: 'chess', chess: c, piece: p } : null;
}
@@ -427,7 +478,7 @@ export function resolveDetail(target, pieces) {
const c = data.lookup('chess', u.defId);
if (c) return { type: 'chess', chess: c, piece: own?.piece || null, unitId: u.id };
const t = data.lookup('tokens', u.defId);
if (t) return { type: 'token', token: t, unitId: u.id };
if (t) return { type: 'token', token: t, unitId: u.id, ownerId: tokenOwnerId(own?.piece, pieces) };
const en = data.lookup('enemies', u.defId);
return en ? { type: 'enemy', enemy: en, unitId: u.id } : null;
}
@@ -470,7 +521,7 @@ export function DetailPanel({ detail, editable, snapHp, onClose, onSell, onDestr
bonds=${bonds} loadout=${loadout} onBond=${onBond} live=${liveNow} />` : 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 === 'token' ? html`<${TokenDetail} token=${detail.token} piece=${detail.piece} 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}
</div>
</aside>`;
}
+3 -2
View File
@@ -1,5 +1,6 @@
// Active effects list (m.private.effects: band / 机变 / team / item / garrison effects with counters):
// compact icon column at the right edge, rich-text tooltip per effect.
// compact icon column at the right edge, rich-text tooltip per effect. `counterText` (a 悬赏's "还剩 N 场作战", user
// playtest #6 item 4) replaces the bare counter in the tooltip's kind line.
import { html, Tooltip, MicroLabel } from './components.js';
import { Img, RichText, GIcon } from './gameComponents.js';
@@ -16,7 +17,7 @@ export function EffectsList({ effects }) {
return html`<div class="effects" aria-label="生效中的效果">
<${MicroLabel}>EFFECTS</${MicroLabel}>
${list.slice(0, 10).map((e, i) => html`<${Tooltip} key=${e.id ?? i} placement="bottom" text=${html`<div class="efftip">
<b>${e.name || '效果'}</b><span class="efftip__kind">${KIND[e.iconKind] || ''}${e.counter != null ? ` · ${e.counter}` : ''}</span>
<b>${e.name || '效果'}</b><span class="efftip__kind">${KIND[e.iconKind] || ''}${e.counterText ? ` · ${e.counterText}` : e.counter != null ? ` · ${e.counter}` : ''}</span>
<${RichText} as="p" text=${e.desc || ''} />
</div>`}>
<span class=${`effect effect--${e.iconKind || 'x'}`}>
+14
View File
@@ -1126,6 +1126,20 @@ export function bossFrac(bossHp) {
return clamp(hp / max, 0, 1);
}
/**
* The boss bar's percentage text for a fraction (bossFrac): whole percents from 10 %, one decimal below, and never
* "0.0%" while the leader still has HP — a sliver reads "<0.1%" (user playtest #6 item 5: a bar at 0.0 % with the
* leader still fighting read as a leader that could not die). null for an unknown fraction.
* @param {number|null} frac
*/
export function bossPctText(frac) {
if (frac == null || !Number.isFinite(frac)) return null;
const pct = clamp(frac, 0, 1) * 100;
if (pct >= 10) return `${pct.toFixed(0)}%`;
if (pct > 0 && pct < 0.05) return '<0.1%';
return `${pct.toFixed(1)}%`;
}
/** Whether a snapshot unit tuple has a flag. */
export const hasFlag = (flags, bit) => (Number(flags) & bit) !== 0;
export { UF };
+77 -19
View File
@@ -14,7 +14,12 @@
// while m.public.paused every clock here is frozen at the pause moment (`frozenAt`).
// Normal rounds (user playtest #3 item 2): the LP tower drops live as the own battle's enemies enter the blue gate —
// lp − min(lpCapPerRound, counted leaks) in red with a −N tick, 联防中 while a 联防 may still save part of it (liveLp;
// the leaks come from the local battle runner, else m.public players[].pendingLp).
// the leaks come from the local battle runner, else m.public players[].pendingLp). 联防 (user playtest #6 item 7; PRTS
// 卫戍协议/帮助 "防卫失败的玩家可通过上方信息栏确认自身所属敌人的剩余数量"): a leaker's phase capsule carries the official
// runner tag ×N (research 09 `tag_miss`; art ui/battle bg_miss_enemy [ASSUMED]) = its enemies still standing on the 联防
// field, uncapped and live — falling as the helpers kill them, rising when one splits or summons (the local 联防
// replica's runner state().uniteLeft, else m.public players[].uniteLeft); the LP tower shows lp − min(lpCapPerRound, N).
// The same MissTag sits in the leakers' team rows (ui/teamPanel.js), its tooltip naming the teammate there (missTip).
// 准备就绪 is refused while the temp overflow row (临时整备区) holds pieces: the reason shows under the button
// (user playtest #3 item 3; the row's own label is ui/underframe.js TempRowNotice).
@@ -24,7 +29,7 @@ import { html, Button, Icon, PingPill, Countdown, Tooltip, MicroLabel, Difficult
import { Sprite, LpTower, GIcon, LocalSprite } from './gameComponents.js';
import { localAsset } from '../data.js';
import { serverNow } from '../store.js';
import { isCombatPhase, isBossPhase, prepCapsuleLabel, bossFrac, fmtNum, shopBlockReason } from './gameLogic.js';
import { isCombatPhase, isBossPhase, prepCapsuleLabel, bossFrac, bossPctText, fmtNum, shopBlockReason } from './gameLogic.js';
import { overtimeState, overtimeDrainPerSec, remainAt } from './matchStatus.js';
const cx = (...p) => p.flat().filter(Boolean).join(' ');
@@ -33,17 +38,19 @@ const cx = (...p) => p.flat().filter(Boolean).join(' ');
* Phase capsule: prep label, kills n/m (combat/unite), kills + boss HP bar (boss rounds).
* @param {{ pub:any, hud:any }} props
*/
export function PhaseCapsule({ pub, hud }) {
export function PhaseCapsule({ pub, hud, miss = null }) {
const phase = pub?.phase;
if (isBossPhase(phase)) {
const boss = pub.bossHp || hud?.boss || null;
// the battle on screen first: its snapshot carries the live pool (the local simulation's own damage on top of the
// server's b.pool, or the server's 20 Hz stream); m.public.bossHp refreshes at ~1 Hz and lags the leader's death
const boss = hud?.boss || pub.bossHp || null;
const frac = bossFrac(boss);
return html`<div class="capsule capsule--boss" role="status">
<${Sprite} k="hudPanel/icon_boss" class="capsule__icon" fallback=${html`<${GIcon} name="skull" class="capsule__icon" />`} />
${hud?.total != null ? html`<span class="capsule__kills num"><b>${hud.killed ?? 0}</b>/${hud.total}</span>` : null}
<div class="bossbar" title=${boss ? `${fmtNum(boss.hp)} / ${fmtNum(boss.max)}` : '敌方领袖'}>
<div class="bossbar__fill" style=${`width:${frac == null ? 100 : frac * 100}%`}></div>
<span class="bossbar__txt num">${frac == null ? '敌方领袖' : `${(frac * 100).toFixed(frac < 0.1 ? 1 : 0)}%`}</span>
<span class="bossbar__txt num">${frac == null ? '敌方领袖' : bossPctText(frac)}</span>
</div>
</div>`;
}
@@ -53,6 +60,7 @@ export function PhaseCapsule({ pub, hud }) {
fallback=${html`<${Icon} name="sword" class="capsule__icon" />`} />
<span class="capsule__kills num"><b>${hud?.killed ?? 0}</b>/${hud?.total ?? '--'}</span>
${phase === PHASE.UNITE ? html`<span class="capsule__tag">联防</span>` : null}
${phase === PHASE.UNITE && Number.isFinite(miss) ? html`<${MissTag} n=${miss} />` : null}
</div>`;
}
return html`<div class="capsule capsule--prep" role="status">
@@ -61,6 +69,33 @@ export function PhaseCapsule({ pub, hud }) {
</div>`;
}
/**
* Tooltip of a MissTag: the own tag speaks to the player ("你漏过的…"), a teammate's row names that teammate (the viewer
* may be one of the helpers fighting those enemies).
* @param {number} n enemies still standing @param {string|null} [name] the leaker's name (null = the viewer)
*/
export function missTip(n, name = null) {
const who = name ? `${name} 漏过的敌人` : '你漏过的敌人';
return n > 0 ? `${who}还剩 ${n} 个(${name ? '联防中' : '队友正在迎战'})` : `${who}已全部被击倒`;
}
/**
* The official escaped-enemy tag (research 09: the 联防 capsule's orange runner tag ×N, node `tag_miss`; drawn with
* ui/battle `bg_miss_enemy` — [ASSUMED] that sprite is its art: an orange pill with a runner and ×, the number after it):
* during 联防 a leaker's enemies still standing on the 联防 field (user playtest #6 item 7) — the own in the phase capsule
* and the own team row, a teammate's in that teammate's row (`name`). CSS look-alike without the art.
* @param {{ n: number, name?: string|null }} props name: the leaker's name on a teammate's row (null = the viewer)
*/
export function MissTag({ n, name = null }) {
const v = Math.max(0, Math.trunc(Number(n) || 0));
const art = localAsset('ui/battle', 'bg_miss_enemy');
return html`<span class=${cx('misstag', art && 'has-art', v === 0 && 'is-clear')} data-testid="miss-tag"
title=${missTip(v, name)} aria-label=${`剩余敌人 ${v}`}
style=${art ? `background-image:url("${art}")` : null}>
${art ? null : html`<span class="misstag__icon" aria-hidden="true"><${GIcon} name="skull" />×</span>`}<b class="misstag__n num">${v}</b>
</span>`;
}
// ---- live LP of the own battle (user playtest #3 item 2) --------------------------------------------------------
/** Phases whose leaks are still to be charged at settlement (normal rounds: the own battle, then 联防). */
@@ -91,35 +126,57 @@ export function ownLeaks(local, server) {
return Math.max(n(local), n(server));
}
/**
* A leaker's enemies still standing on the 联防 field from its two sources (user playtest #6 item 7): the local 联防
* replica's count (runner state().uniteLeft[own id] — the same deterministic battle the player is watching, so the tag
* matches the field on screen) and the server's m.public players[].uniteLeft (the authority's b.progress, ~1 Hz; exact
* once the field is done). The count falls as the helpers strike enemies down and rises when one splits or summons, so
* neither source bounds the other: the replica wins while it runs, the server's value fills in without one (not loaded
* yet, not on screen). null when neither is known (not a leaker, or nothing yet).
* @param {any} local @param {any} server
* @returns {number|null}
*/
export function uniteRemaining(local, server) {
for (const v of [local, server]) if (Number.isFinite(v) && v >= 0) return Math.trunc(v);
return null;
}
/**
* The own LP while a normal round's battle runs: the loss its counted leaks will cost is shown at once (red, −N)
* instead of only at settlement. `base` — kept by the caller between renders — is the settled m.private state the
* pending loss applies to: { round, lp, statsLeaks } as first seen in the round's COMBAT / 联防. The pending part is
* dropped as soon as the settlement lands — m.private lp or stats.leaks changed (Match.flush sends m.private before the
* SETTLE m.public) — or the phase leaves COMBAT / UNITE or the round changes, so the loss is never subtracted twice.
* 联防 (UNITE): a leaker finally loses min(cap, the survivors of the 联防 battle that came from them) — never more than
* its own battle's count, which stays on show marked `unite` (联防中: teammates may still save part of it) until the
* 联防 (UNITE): a leaker finally loses min(cap, the survivors of the 联防 battle that came from them); `uniteLeft` (its
* enemies still standing, uniteRemaining — user playtest #6 item 7) replaces the own battle's count, so the loss falls
* live as the helpers kill them, marked `unite` (联防中) with `left` = that uncapped number for the ×N tag. Without
* `uniteLeft` (not a leaker, or no count yet) the own battle's count stays on show. The pending part goes when the
* settlement lands. Boss rounds are not handled here (the merged team LP moves live through b.pool / m.public.teamLp).
* @param {{ round: any, lp: number, statsLeaks: number|null } | null} base
* @param {{ phase: string, round: any, lp: any, statsLeaks?: any, leaks?: any, cap?: number, alive?: boolean }} s
* @returns {{ base: { round: any, lp: number, statsLeaks: number|null } | null, pending: number, shown: number|null, unite: boolean }}
* @param {{ phase: string, round: any, lp: any, statsLeaks?: any, leaks?: any, cap?: number, alive?: boolean, uniteLeft?: number|null }} s
* @returns {{ base: { round: any, lp: number, statsLeaks: number|null } | null, pending: number, shown: number|null, unite: boolean, left: number|null }}
*/
export function liveLp(base, { phase, round, lp, statsLeaks = null, leaks = 0, cap = 10, alive = true }) {
if (!Number.isFinite(lp)) return { base: null, pending: 0, shown: null, unite: false };
if (!LEAK_PHASES.has(phase) || alive === false) return { base: null, pending: 0, shown: lp, unite: false };
export function liveLp(base, { phase, round, lp, statsLeaks = null, leaks = 0, cap = 10, alive = true, uniteLeft = null }) {
if (!Number.isFinite(lp)) return { base: null, pending: 0, shown: null, unite: false, left: null };
if (!LEAK_PHASES.has(phase) || alive === false) return { base: null, pending: 0, shown: lp, unite: false, left: null };
const sl = Number.isFinite(statsLeaks) ? statsLeaks : null;
const b = base && base.round === round ? base : { round, lp, statsLeaks: sl };
const landed = lp !== b.lp || (sl != null && b.statsLeaks != null && sl !== b.statsLeaks);
const pending = landed ? 0 : Math.min(lp, pendingLoss(leaks, cap));
return { base: b, pending, shown: lp - pending, unite: phase === PHASE.UNITE && pending > 0 };
const left = phase === PHASE.UNITE && Number.isFinite(uniteLeft) && uniteLeft >= 0 && !landed ? Math.trunc(uniteLeft) : null;
const pending = landed ? 0 : Math.min(lp, pendingLoss(left != null ? left : leaks, cap));
return { base: b, pending, shown: lp - pending, unite: phase === PHASE.UNITE && (pending > 0 || left != null), left };
}
/**
* Tooltip of an LP tower with a pending loss (null without one).
* @param {number} lp settled LP @param {number} pending @param {{ unite?: boolean, cap?: number }} [opts]
*/
export function pendingTip(lp, pending, { unite = false, cap = 10 } = {}) {
export function pendingTip(lp, pending, { unite = false, cap = 10, left = null } = {}) {
if (!(pending > 0)) return null;
if (unite && Number.isFinite(left)) {
const each = left > cap ? `剩余不足 ${cap} 个后,队友每击倒一个少扣 1 点` : '队友每击倒一个就少扣 1 点';
return `目标生命值 ${lp}:联防中,你漏过的敌人还剩 ${left} 个,${each};按现在结算扣除 ${pending} 点(每回合至多 ${cap} 点)`;
}
return unite
? `目标生命值 ${lp}:联防中,队友正在迎战你漏过的敌人,结算时按联防后剩余的敌人扣除(至多 ${pending} 点)`
: `目标生命值 ${lp}:本回合已有 ${pending >= cap ? `${cap} 个以上` : `${pending} 个`}敌人进入蓝门,结算时扣除 ${pending} 点(每回合至多 ${cap} 点)`;
@@ -245,9 +302,10 @@ export function PauseButton({ paused, busy = false, onToggle }) {
* onReady:(r:boolean)=>void, readyBusy?:boolean, readyCount?:number, playerCount?:number,
* pen?:boolean, penAvail?:boolean, onPen?:(on:boolean)=>void, config?: any, frozenAt?: number|null,
* pause?: { show: boolean, paused: boolean, busy?: boolean, onToggle: () => void } | null,
* live?: { pending: number, unite: boolean } | null }} props
* live?: { pending: number, unite: boolean, left?: number|null } | null }} props
* frozenAt: the server time every clock shows while the solo match is paused (null = live)
* live: the own battle's pending LP loss (liveLp): the tower shows lp − pending in red with a −N tick, 联防中 during 联防
* live: the own battle's pending LP loss (liveLp): the tower shows lp − pending in red with a −N tick, 联防中 during 联防;
* `left` (a leaker in 联防): its enemies still standing — the capsule's ×N tag
*/
export function TopBar({ pub, priv, conn, hud, total, drawer, onExit, onDrawer, onReady, readyBusy, readyCount, playerCount, pen = false, penAvail = false, onPen = () => {},
config = null, frozenAt = null, pause = null, live = null }) {
@@ -289,9 +347,9 @@ export function TopBar({ pub, priv, conn, hud, total, drawer, onExit, onDrawer,
<span class="roundbox__label">回合</span>
<b class="roundbox__num num">${roundText}</b>
</div>
<${PhaseCapsule} pub=${pub} hud=${hud} />
<${PhaseCapsule} pub=${pub} hud=${hud} miss=${!boss && Number.isFinite(live?.left) ? live.left : null} />
<${LpTower} value=${lp} size="lg" tone=${lowLp ? 'danger' : boss ? 'team' : null} pending=${pending}
note=${pending > 0 && live?.unite ? '联防中' : null} tip=${pendingTip(lp, pending, { unite: !!live?.unite, cap })} />
note=${pending > 0 && live?.unite ? '联防中' : null} tip=${pendingTip(lp, pending, { unite: !!live?.unite, cap, left: live?.left ?? null })} />
<${Tooltip} text=${btn.right.tip} placement="bottom">
<${CheckBtn} sprite=${btn.right.sprite} cls=${cx('enemybtn', btn.right.grey && 'is-grey')} label=${btn.right.label}
chev=${btn.right.grey ? null : '▶▶'} disabled=${btn.right.grey && !pen} onClick=${onRight} testid="check-enemy" />
+15 -10
View File
@@ -5,8 +5,12 @@
// `EventOnUpgrade`): the first tap selects a card — it lifts and enlarges, its detail opens and it shows 确认购买
// (无法购买 + the reason when it cannot be bought) — and a second tap on the same card buys it; the LEVEL card's first
// tap arms 确认升级 and the second upgrades (已满级 at level 6). A tap anywhere else, a shop change or the end of the
// editable phase disarms. The D / R / F keys stay one-press shortcuts. There is no drag-to-sell zone: selling is the
// 出售 +N button of a tapped unit's underframe (ui/underframe.js). After a promotion the operator cards are replaced by
// editable phase disarms. A tap anywhere on a card is the card's tap — its 确认购买 strip included (it takes no
// pointer): there is no ⓘ corner (the official cards have none; user playtest #6 item 10 — on phones its invisible
// 44 px touch area covered the card's bottom-right quarter, half the strip, and a second tap there only re-opened the
// detail); a card that cannot be bought right now (the bar is not editable: ready, round start) opens its detail. The
// D / R / F keys stay one-press shortcuts. There is no drag-to-sell zone: selling is the 出售 +N button of a tapped
// unit's underframe (ui/underframe.js). After a promotion the operator cards are replaced by
// the 晋升奖励 cards (3 free operators, pick 1 — also two taps) until picked or put off (稍后 → rewardOverlay.js pill).
// Every operator card shows the skill it will fight with — the player's 干员调配 loadout (m.private.loadout, DESIGN
// §16): the skill icon above the name, mint-framed with 已调配 in its title when it is not the default skill (and the
@@ -53,11 +57,10 @@ export function ChessCard({ slot, idx, priv, frozen = false, reason = null, free
const bonds = Array.isArray(c?.bonds) ? c.bonds : [];
const disabled = !!reason;
const lo = c ? chessLoadout(c, priv?.loadout, LOOKUPS.getChess) : null;
const tap = () => { if (onTap) onTap(idx); else if (!disabled) onBuy(idx); };
const tap = () => { if (onTap) onTap(idx); else if (!disabled) onBuy(idx); else onDetail(slot.id); };
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); }}
aria-label=${`${c?.name || '干员'},价格 ${slot.price}${armed ? (disabled ? ',无法购买' : ',再次点击确认') : ''}`} aria-disabled=${disabled && !onTap ? 'true' : 'false'}
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__water" aria-hidden="true">${bonds[0] ? html`<${BondGlyph} bondId=${bonds[0]} />` : null}</span>
<${Img} src=${chessPortraitUrl(m, c)} class="scard__art" />
@@ -82,8 +85,6 @@ export function ChessCard({ slot, idx, priv, frozen = false, reason = null, free
${willMerge ? html`<span class="scard__mergetag">可晋升</span>` : null}
${frozen ? html`<span class="scard__ice" aria-hidden="true"><${Icon} name="snow" /></span>` : null}
${armed ? html`<${ArmedTag} reason=${reason} free=${free} />` : null}
<span class="scard__detail" role="button" tabindex="-1" aria-label="详情" title="查看详情"
onClick=${(e) => { e.stopPropagation(); onDetail(slot.id); }}><${Icon} name="info" /></span>
</button>`;
return reason && reason !== '已售出' && !armed ? html`<${Tooltip} text=${reason} block=${true} class="scard-wrap">${card}<//>` : card;
}
@@ -103,11 +104,16 @@ function SkillBadge({ chess, lo }) {
</span>`;
}
function ItemCard({ slot, idx, frozen, reason, armed = false, onTap = null, onBuy, onDetail }) {
/**
* Item card (the bar's item slot): the same two taps as an operator card.
* @param {{ slot:any, idx:number, frozen?:boolean, reason?:string|null, armed?:boolean, onTap?:(idx:number)=>void,
* onBuy:Function, onDetail:Function }} props
*/
export function ItemCard({ slot, idx, frozen = false, reason = null, armed = false, onTap = null, onBuy, onDetail }) {
const it = data.lookup('items', slot.id);
const m = data.get('assets');
const disabled = !!reason;
const tap = () => { if (onTap) onTap(idx); else if (!disabled) onBuy(idx); };
const tap = () => { if (onTap) onTap(idx); else if (!disabled) onBuy(idx); else onDetail(slot.id, 'item'); };
const card = html`<button type="button" class=${cx('scard', 'scard--item', frozen && 'is-frozen', disabled && 'is-disabled', armed && 'is-armed')}
onClick=${tap} onContextMenu=${(e) => { e.preventDefault(); onDetail(slot.id, 'item'); }}
aria-label=${`${it?.name || '装备'},价格 ${slot.price}${armed ? (disabled ? ',无法购买' : ',再次点击确认') : ''}`}
@@ -124,7 +130,6 @@ function ItemCard({ slot, idx, frozen, reason, armed = false, onTap = null, onBu
</span>
${frozen ? html`<span class="scard__ice" aria-hidden="true"><${Icon} name="snow" /></span>` : null}
${armed ? html`<${ArmedTag} reason=${reason} />` : null}
<span class="scard__detail" role="button" tabindex="-1" aria-label="详情" onClick=${(e) => { e.stopPropagation(); onDetail(slot.id, 'item'); }}><${Icon} name="info" /></span>
</button>`;
return reason && reason !== '已售出' && !armed ? html`<${Tooltip} text=${reason} block=${true} class="scard-wrap">${card}<//>` : card;
}
+41 -13
View File
@@ -7,7 +7,12 @@
// watches that player's field at once.
// Live LP (user playtest #3 item 2): during a normal round's battle each row's tower shows lp − the loss that player's
// leaks so far will cost (red, −N): the own row the top bar's live value (`self`, ui/hud.js liveLp), a teammate's row
// m.public players[].pendingLp (server/match/Match.js, ~1 Hz).
// m.public players[].pendingLp (server/match/Match.js, ~1 Hz). 联防 (user playtest #6 item 7): a leaker's row adds the
// runner tag ×N — its enemies still standing on the 联防 field, uncapped and live (falling as the helpers kill them,
// rising when one splits) — next to lp − min(lpCapPerRound, N): the own row the top bar's value, a teammate's row the
// local 联防 replica's count while it is on screen (`uniteLocal`: the battle runner's state().uniteLeft, the same battle
// the player watches, so the number moves with the kills on screen), else m.public players[].uniteLeft (the authority's
// report, ~1 Hz).
import { useEffect, useState } from '../../vendor/hooks.module.js';
import { PHASE } from '../../../shared/constants.js';
@@ -15,6 +20,7 @@ import { html, Icon, Tooltip } from './components.js';
import { PlayerAvatar, LpTower, GIcon, LocalSprite } from './gameComponents.js';
import { EmoteBubble } from './emotes.js';
import { STATUS_META, sortedPlayers } from './gameLogic.js';
import { MissTag, uniteRemaining } from './hud.js';
import { localAsset } from '../data.js';
const cx = (...p) => p.flat().filter(Boolean).join(' ');
@@ -24,27 +30,48 @@ const STATUS_SPRITE = { ready: 'icon_ready', deciding: 'icon_waiting', done: 'ic
/**
* A row's LP tower: the settled LP and the pending loss of the round's battle (0 outside COMBAT / UNITE). The own row
* takes the top bar's live value when there is one (its m.private lp: the same number as the top bar).
* takes the top bar's live value when there is one (its m.private lp: the same number as the top bar). `left`: during
* 联防 a leaker's enemies still standing (the ×N tag), else null — for a teammate the local 联防 replica's count when
* one is given (`uniteLocal`, the runner's state().uniteLeft: { [playerId]: n }, absent = none left; the pending loss
* is then min(cap, left)), else m.public players[].uniteLeft / pendingLp.
* @param {any} p m.public players[] entry @param {any} pub m.public
* @param {{ lp?: number|null, pending: number, unite: boolean } | null} [self] the own live value (only for the own row)
* @returns {{ lp: number|null, pending: number, unite: boolean }}
* @param {{ lp?: number|null, pending: number, unite: boolean, left?: number|null } | null} [self] the own live value (only for the own row)
* @param {{ uniteLocal?: Record<string, number> | null, cap?: number }} [opts]
* @returns {{ lp: number|null, pending: number, unite: boolean, left: number|null }}
*/
export function rowLp(p, pub, self = null) {
export function rowLp(p, pub, self = null, { uniteLocal = null, cap = 10 } = {}) {
const pubLp = Number.isFinite(p?.lp) ? p.lp : null;
if ((pub?.phase !== PHASE.COMBAT && pub?.phase !== PHASE.UNITE) || p?.alive === false) return { lp: pubLp, pending: 0, unite: false };
if ((pub?.phase !== PHASE.COMBAT && pub?.phase !== PHASE.UNITE) || p?.alive === false) return { lp: pubLp, pending: 0, unite: false, left: null };
const lp = self && Number.isFinite(self.lp) ? self.lp : pubLp;
if (lp == null) return { lp, pending: 0, unite: false };
const raw = self ? self.pending : p.pendingLp;
const leaker = pub.phase === PHASE.UNITE && Array.isArray(pub.unite?.leakers) && pub.unite.leakers.includes(p.playerId);
const local = !self && leaker && uniteLocal && typeof uniteLocal === 'object' ? uniteRemaining(uniteLocal[p.playerId] ?? 0, null) : null;
const rawLeft = pub.phase !== PHASE.UNITE ? null : self ? self.left : local != null ? local : p.uniteLeft;
const left = Number.isFinite(rawLeft) && rawLeft >= 0 ? Math.trunc(rawLeft) : null;
if (lp == null) return { lp, pending: 0, unite: false, left };
const raw = self ? self.pending : local != null ? Math.min(cap, local) : p.pendingLp;
const pending = Math.min(lp, Math.max(0, Math.trunc(Number(raw) || 0)));
return { lp, pending, unite: pending > 0 && (self ? !!self.unite : pub.phase === PHASE.UNITE) };
return { lp, pending, unite: (pending > 0 || left != null) && (self ? !!self.unite : pub.phase === PHASE.UNITE), left };
}
/**
* Tooltip of a row's LP tower with a pending loss (null without one).
* @param {{ lp: number|null, pending: number, unite: boolean, left: number|null } | null} lp rowLp(...)
* @param {number} [cap] lpCapPerRound
*/
export function rowLpTip(lp, cap = 10) {
if (!lp || !(lp.pending > 0)) return null;
if (lp.unite && lp.left != null) return `目标生命值 ${lp.lp},联防中:漏过的敌人还剩 ${lp.left} 个,按现在结算扣除 ${lp.pending} 点(每回合至多 ${cap} 点)`;
return `目标生命值 ${lp.lp},${lp.unite ? '联防中,' : ''}结算时扣除${lp.unite ? '至多' : ''} ${lp.pending} 点`;
}
/**
* @param {{ pub:any, myId:string, watching:string|null, bubbles: Map<string,{id:string,seq:number}>, onWatch:(p:any)=>void,
* compact?: boolean, teamLp?: number|null, self?: { lp?: number|null, pending: number, unite: boolean } | null,
* compact?: boolean, teamLp?: number|null, self?: { lp?: number|null, pending: number, unite: boolean, left?: number|null } | null,
* cap?: number, uniteLocal?: Record<string, number> | null,
* observe?: null | { canObserve: (p:any) => { fieldId?: string, reason?: string|null, back?: boolean }, observing: boolean, onBack: () => void } }} props
* uniteLocal: the local 联防 replica's per-leaker counts while it is on screen (battle runner state().uniteLeft), else null
*/
export function TeamPanel({ pub, myId, watching, bubbles, onWatch, compact = false, observe = null, self: selfLive = null }) {
export function TeamPanel({ pub, myId, watching, bubbles, onWatch, compact = false, observe = null, self: selfLive = null, cap = 10, uniteLocal = null }) {
const [openPid, setOpenPid] = useState(null);
const phaseKey = `${pub?.phase}:${pub?.round}`;
useEffect(() => { setOpenPid(null); }, [phaseKey, watching, observe?.observing]);
@@ -68,7 +95,7 @@ export function TeamPanel({ pub, myId, watching, bubbles, onWatch, compact = fal
const open = !!observe && openPid === p.playerId && !self;
const back = !!observe && self && observe.observing;
const title = observe ? (self ? (observe.observing ? '返回战场' : '你自己') : `查看 ${p.name} 的战场`) : (self ? '查看自己的阵地' : `查看 ${p.name} 的阵地`);
const lp = rowLp(p, pub, self ? selfLive : null);
const lp = rowLp(p, pub, self ? selfLive : null, { uniteLocal, cap });
return html`<div key=${p.playerId} class=${cx('team__row', self && 'is-self', watched && 'is-watched', p.alive === false && 'is-dead', open && 'is-open')}>
<button type="button" class="team__btn" onClick=${() => click(p, self)} title=${title} aria-expanded=${observe && !self ? String(open) : undefined}>
<${PlayerAvatar} player=${p} self=${self} />
@@ -80,7 +107,8 @@ export function TeamPanel({ pub, myId, watching, bubbles, onWatch, compact = fal
<span class="team__name">${p.name || '博士'}</span>
<div class="team__line">
<${LpTower} value=${lp.lp} size="sm" tone=${Number.isFinite(lp.lp) && lp.lp - lp.pending <= 5 ? 'danger' : null} pending=${lp.pending}
tip=${lp.pending > 0 ? `目标生命值 ${lp.lp},${lp.unite ? '联防中,' : ''}结算时扣除${lp.unite ? '至多' : ''} ${lp.pending} 点` : null} />
tip=${rowLpTip(lp, cap)} />
${lp.left != null ? html`<${MissTag} n=${lp.left} name=${self ? null : p.name || '博士'} />` : null}
<${Tooltip} text=${offline ? '连接已断开' : meta.text} placement="right">
<span class=${cx('team__status', `is-${meta.tone}`, offline && 'is-offline', (offline || STATUS_SPRITE[status]) && localAsset('ui/battle', offline ? 'icon_lost_connect' : STATUS_SPRITE[status]) && 'has-sprite')} aria-label=${meta.text}>
${offline ? html`<${LocalSprite} name="icon_lost_connect" fallback=${html`<${Icon} name="wifiOff" />`} />`
+96 -15
View File
@@ -66,7 +66,9 @@
// boss fields in real time without snapshots (they share the pool). A silent authority (deadline = time limit + 15 s)
// or a disconnected one loses the field to the server, which re-simulates the spec; an implausible b.result is
// replaced by the server's own simulation (fields.js validateClientResult). The shared boss pool, the team LP and the
// overtime drain live here (b.pool ≤ 4 Hz, b.end; m.public follows a boss fight at ~1 Hz). No b.snap / b.ev is sent in
// overtime drain live here (b.pool ≤ 4 Hz, b.end; m.public follows a boss fight at ~1 Hz); the first end condition the
// server registers decides the Final Assault (pool 0 → victory, team LP 0 → defeat; nothing reported after a defeat is
// credited — _finalEnding, user playtest #6 item 5). No b.snap / b.ev is sent in
// this mode; g.watch hands out specs. battleIds are `<seed36>[-<matchNo36>].<round>.<seq>[.<fieldId>]` (≤ 64 chars):
// unique across the matches of a room, so a report that crossed into the next match is ignored like any stale one; a
// duplicate b.result (a browser re-sending one it believes lost) is answered ok and changes nothing.
@@ -74,7 +76,11 @@
// clock and the server pacers; m.public.paused; resume shifts every clock / deadline by the pause.
// Live LP (user playtest #3 item 2): during COMBAT / 联防 m.public players[].pendingLp = min(lpCapPerRound, the counted
// leaks of the player's own battle so far) (_pendingLpView; omitted when 0) — the teammates' rows of the team panel
// show lp − pendingLp; the settlement lands with the SETTLE view, where it is gone.
// show lp − pendingLp; the settlement lands with the SETTLE view, where it is gone. 联防 (user playtest #6 item 7): a
// leaker's players[].uniteLeft = its enemies still standing on the 联防 field (not spawned yet, alive, or through again;
// uncapped, live: the authority's b.progress `left`, the server-run timeline or battle; exact once the field has its
// result — _uniteLeft) and pendingLp = min(lpCapPerRound, uniteLeft): the counter falls as the helpers kill them (and
// rises when one of them splits or summons — the children are billed to the same leaker).
// User playtest #4: a match with a single human (独立模拟, or a 同盟 room started alone / with AI teammates) times no
// phase outside its battles (soloUntimed); the co-op strategy draft has ONE countdown — BAND_TURN_SECONDS per turn,
// published as m.public.deadline — AI seats pick at once and a turn that runs out takes the highlighted strategy
@@ -124,15 +130,16 @@ import { PlayerState } from './PlayerState.js';
import { buildDeployMap, boardOrder, pieceDir } from './board.js';
import { bondList } from './bondsMeta.js';
import { EffectDispatcher, getDefaultRegistry } from './effectsMeta.js';
import { generateDraft, applyCard, cardView } from './choices.js';
import { generateDraft, applyCard, cardView, bountyBattles, isMultiRoundBounty } from './choices.js';
import { setupMatchWaves, buildNormalWave, buildBossWave, bountySpawns, withBounties, previewOf, weightedPick } from './waves.js';
import { planUnite, uniteBattleOpts, uniteSurvivors } from './unite.js';
import { pairPlayers, bossPoolHp, SharedBossPool, hiddenEligible, BOSS_HIT_STEPS } from './finalAssault.js';
import {
FieldRunner, DeadBattle, GAME_SPEED, snapFrame, runHeadless, timelineAt, HeadlessPacer, syntheticResult,
validateClientResult, RESULT_GRACE_MS, BOSS_SILENCE_MS, HARD_CAP_SECONDS, HeadlessJob, HEADLESS_SLICE_MS, CATCHUP_TICKS_PER_INTERVAL,
uniteBillBounds,
} from './fields.js';
import { buildBattleSpec, createBattleFromSpec, resultDigest, compactResult as compactForVerify, battleProgress } from '../sim/spec.js';
import { buildBattleSpec, createBattleFromSpec, resultDigest, compactResult as compactForVerify, battleProgress, uniteLeft } from '../sim/spec.js';
import { CreditPool } from './finalAssault.js';
import { buildResult } from './results.js';
import { botPrepBeginSteps, botPrepEndSteps, botPickBand, botPickCard } from './bot.js';
@@ -338,6 +345,10 @@ export class Match {
this.watchers = new Map();
this.lastResults = new Map();
this.unitePlan = null;
/** server-run 联防: the leakers' counts last published (_uniteTick) */
this._uniteLeftKey = null;
/** 联防: { plan, bounds } — per leaker the most survivors settlement can bill (_uniteLeft's clamp) */
this._uniteBounds = null;
this.teamLp = null;
this.bossPool = null;
this.hiddenLayerSum = 0;
@@ -1465,10 +1476,11 @@ export class Match {
addBounty(ps, card) {
if (!ps || !card || !this.gd.enemy(card.enemyKey)) return null;
const rounds = Math.max(1, Math.min(99, Number.isInteger(card.rounds) ? card.rounds : 1));
// a multi-round card lasts MULTI_ROUND_BOUNTY_BATTLES battles (choices.js; the user's call after playtest #6)
const rounds = bountyBattles(card);
const b = {
id: `bounty:${this.nextUid()}`,
card: { effectId: card.effectId ?? card.id ?? null, name: card.name ?? '悬赏', desc: card.desc ?? '', tier: card.tier ?? 1, coin: Math.max(0, Math.trunc(Number(card.coin) || 0)), payout: card.payout === 'perfect' ? 'perfect' : 'kill', rounds, enemyKey: card.enemyKey, count: Math.max(1, Math.min(20, Number.isInteger(card.count) ? card.count : 1)) },
card: { effectId: card.effectId ?? card.id ?? null, name: card.name ?? '悬赏', desc: card.desc ?? '', tier: card.tier ?? 1, coin: Math.max(0, Math.trunc(Number(card.coin) || 0)), payout: card.payout === 'perfect' ? 'perfect' : 'kill', rounds, multiRound: isMultiRoundBounty(card), enemyKey: card.enemyKey, count: Math.max(1, Math.min(20, Number.isInteger(card.count) ? card.count : 1)) },
roundsLeft: rounds,
};
ps.bounties.push(b);
@@ -1822,7 +1834,9 @@ export class Match {
this._defaultWatch();
this.markPublic();
this.tickerText(`联防阶段:${plan.helpers.map((p) => p.name).join('、')} 迎战突破防线的敌人`);
this._uniteLeftKey = null;
this.runner = new FieldRunner(this, this.fields, {
onTick: (runner) => this._uniteTick(runner),
onDone: (runner) => {
if (this.phase !== PHASE.UNITE) return;
const res = runner.resultOf(this.fields[0]);
@@ -1944,14 +1958,21 @@ export class Match {
* LP a player's own battle of this normal round will cost at settlement so far — settle()'s min(lpCapPerRound,
* counted leaks) — for the teammates' live LP (m.public players[].pendingLp, user playtest #3 item 2; the own client
* counts its local battle itself). COMBAT: the recorded result once every field is done, else the field's result, else
* the authority's b.progress leaks (a server-run field reports none before its result is released); 联防: the own
* battle's count — the most the 联防 can charge (settle: min(cap, survivors) ≤ min(cap, counted)). Omitted when 0 and
* in every other phase (boss rounds charge the merged team LP live).
* @returns {{ pendingLp?: number }}
* the authority's b.progress leaks (a server-run field reports none before its result is released); 联防: a leaker's
* enemies still standing on the 联防 field (_uniteLeft, uncapped in `uniteLeft`, user playtest #6 item 7), anyone
* else's own battle count (0: they were perfect). Omitted when 0 and in every other phase (boss rounds charge the
* merged team LP live).
* @returns {{ pendingLp?: number, uniteLeft?: number }}
*/
_pendingLpView(ps) {
if (!ps || !ps.alive || (this.phase !== PHASE.COMBAT && this.phase !== PHASE.UNITE)) return {};
const counted = (r) => (r && Array.isArray(r.leaked) ? r.leaked.filter((l) => l && l.counted !== false).length : 0);
// 联防: a leaker's enemies still standing on the 联防 field (uncapped), the loss capped like settle()
const left = this._uniteLeft(ps);
if (left != null) {
const loss = Math.min(this.gd.lpCapPerRound, left);
return loss > 0 ? { uniteLeft: left, pendingLp: loss } : { uniteLeft: left };
}
let n = 0;
if (this.lastResults.has(ps.playerId)) n = counted(this.lastResults.get(ps.playerId));
else if (this.phase === PHASE.COMBAT) {
@@ -1963,6 +1984,58 @@ export class Match {
return loss > 0 ? { pendingLp: loss } : {};
}
/**
* 联防 (user playtest #6 item 7; PRTS 卫戍协议/帮助 "防卫失败的玩家可通过上方信息栏确认自身所属敌人的剩余数量"): how many of
* a leaker's enemies are still standing on the 联防 field — not spawned yet, alive, or through the objective again —
* plus its leaks that could not re-enter: what settle() charges it (before the per-round cap) if the 联防 ended now.
* It falls as the helpers strike them down and rises when one splits or summons (the children carry the leaker).
* Live from the field (client run: the authority's b.progress `left`; server run: the headless timeline on the field
* clock, or the streamed battle itself), clamped to what settlement can bill that leaker (fields.js uniteBillBounds:
* sent in + the offspring bound, validateClientResult's budget); exact once the field has its result (unite.js
* uniteSurvivors; a synthetic result charges the own leaks, as settle()). null for anyone but a leaker of the running
* 联防.
* @returns {number|null}
*/
_uniteLeft(ps) {
const plan = this.unitePlan;
if (this.phase !== PHASE.UNITE || !plan || !ps || !plan.leakers.includes(ps)) return null;
const pid = ps.playerId;
const f = this.fields.find((x) => x && x.kind === 'unite') || null;
let res = null;
if (f && f.cc) res = f.done ? f.result : null;
else if (f && f.battle && f.battle.finished) { try { res = f.battle.result(); } catch { res = null; } }
if (res && res.synthetic) {
const own = this.lastResults.get(pid);
return own && Array.isArray(own.leaked) ? own.leaked.filter((l) => l && l.counted !== false).length : 0;
}
if (res) return uniteSurvivors(plan, res).get(pid) || 0;
const sent = plan.leaked.filter((l) => l.sourcePlayerId === pid).length;
let live = null;
if (f && f.cc) {
if (f.mode === 'server' && f.timeline) {
const sample = timelineAt(f.timeline, this._fieldElapsed(f));
live = sample && sample[3] && typeof sample[3] === 'object' ? sample[3] : null;
} else live = f.progress && f.progress.left && typeof f.progress.left === 'object' ? f.progress.left : null;
} else if (f && f.battle) {
try { live = uniteLeft(f.battle); } catch { live = null; }
}
if (!this._uniteBounds || this._uniteBounds.plan !== plan) this._uniteBounds = { plan, bounds: uniteBillBounds(plan.leaked, this.gd) };
const bound = this._uniteBounds.bounds.get(pid) ?? sent;
const standing = live ? Math.min(bound, Math.max(0, Math.trunc(Number(live[pid]) || 0))) : sent;
return standing + (plan.notReentered.get(pid) || 0);
}
/** Server-run 联防 (streaming mode): refresh m.public about once a game second when a leaker's count moved. */
_uniteTick(runner) {
const f = runner && runner.fields ? runner.fields[0] : null;
if (!f || !f.battle || runner.ticks % 30 !== 0) return;
let key = '';
try { key = JSON.stringify(uniteLeft(f.battle)); } catch { key = ''; }
if (key === this._uniteLeftKey) return;
this._uniteLeftKey = key;
this.markPublic();
}
/** The connected human who simulates a field: lowest seat among its players (normal: the owner). */
_authorityFor(f, exclude = null) {
let best = null;
@@ -2223,6 +2296,8 @@ export class Match {
return OK;
}
if (Number.isFinite(msg.leaks)) p.leaks = Math.max(p.leaks, msg.leaks);
// 联防: the leakers' enemies still standing (the latest report; clamped where it is read, _uniteLeft)
if (f.kind === 'unite' && msg.left && typeof msg.left === 'object') p.left = { ...msg.left };
this.markPublic();
return OK;
}
@@ -2250,9 +2325,10 @@ export class Match {
for (const pid of f.players) { const d = Number(result.perPlayer[pid] && result.perPlayer[pid].bossDamage) || 0; by[pid] = d; sum += d; }
if (sum > f.bossAcked) this._creditBoss(f, sum, by);
// the client emptied the pool as it saw it (server hp − its unacknowledged damage): what is left on the server is
// float dust from summing the fields' reports in another order — the boss is down
// float dust from summing the fields' reports in another order — the boss is down (pools never hold less than
// BOSS_POOL_MIN_HP, finalAssault.js: this only catches a noise-level disagreement at that boundary)
const pool = this.bossPool;
if (result.reason === 'cleared' && pool && pool.hp > 0 && pool.hp < 1) pool.damage(f.players[0] ?? null, pool.hp);
if (result.reason === 'cleared' && this._finalEnding !== 'forced' && pool && pool.hp > 0 && pool.hp < 1 + 1e-6) pool.damage(f.players[0] ?? null, pool.hp);
// a boss field ends only when the shared pool is empty (the client saw it reach 0) or the match forced the end
// (b.end): any other result — 'forced' / 'timeout' at t = 0, 'cleared' while the pool still holds — would stop
// the pair's fight (and, with every field done, end the Final Assault as a defeat). The field is handed to the
@@ -2399,7 +2475,9 @@ export class Match {
_creditBoss(f, cum, by) {
const pool = this.bossPool;
const reported = Number(cum);
if (!pool || !Number.isFinite(reported)) return;
// the team LP ran out first: the run failed at that moment (PRTS "…使目标生命值扣除至0,则无视倒计时直接失败"), so
// damage reported afterwards — in flight, or rounded up in the final b.result — changes nothing (user playtest #6)
if (!pool || !Number.isFinite(reported) || this._finalEnding === 'forced') return;
if (!f.bossReported || reported > f.bossReported.cum) f.bossReported = { cum: reported, by: by && typeof by === 'object' ? { ...by } : null };
const c = Math.min(reported, this._bossDmgBudget(f));
if (!(c > f.bossAcked)) return;
@@ -2700,7 +2778,8 @@ export class Match {
const wave = reuse ? this.bossWaves[i].wave : buildBossWave(this.gd, this.rngWaves, this.factions, this.round, { bossId, solo });
// one spawn list per field, shared by the field's players' onBattleStart handlers (edit it in place)
const spawns = wave.spawns.map((s) => ({ ...s, mods: s.mods ? { ...s.mods } : undefined }));
// multi-round bounties ("之后的每场作战") follow their player into the boss field, on the player's half
// bounties with battles left (a multi-round card lasts MULTI_ROUND_BOUNTY_BATTLES) follow their player into the
// boss field, on the player's half
g.forEach((ps, j) => {
for (const b of bountySpawns(this.gd, this.round, wave, ps.bounties, ps.playerId, { solo: this.isSolo, side: j === 0 ? 'L' : 'R' })) spawns.push(b);
});
@@ -2874,7 +2953,9 @@ export class Match {
if (pp && ps) this.dispatch(ps, 'onBattleResult', { result: pp, lpLoss: 0, perfect: !!pp.perfect, boss: true });
}
}
const victory = this.bossPool.hp <= 0;
// the end condition the server registered first decides (client-side combat: _endFinal — pool 0 → victory, team LP 0
// → defeat); a boss field's final result may never turn a defeat into a victory (user playtest #6 item 5)
const victory = this._finalEnding ? this._finalEnding === 'cleared' : this.bossPool.hp <= 0;
this._syncTeamLp();
this.deadline = 0;
this.overtimeAt = 0;
+80 -23
View File
@@ -5,7 +5,8 @@
// * Hand (整备区) 10 slots filled right→left, temp (临时整备区) 5 slots. A full hand refuses buys / withdrawals,
// except a purchase that completes a merge and a withdrawal whose own summon stack frees a slot. Passive gains (merge
// results, grants, returned equipment) overflow into temp; temp blocks Ready ("直到溢出情况排除才可开始进行作战").
// A temp piece is resolved (chess sold back to the pool, items / summon stacks destroyed) at the deadline of the
// A temp piece is resolved (chess sold back to the pool, items destroyed, summon stacks removed — they come back at
// the next round start, grantTokensFor) at the deadline of the
// first prep in which the player could act on it (tempDue): a piece that overflowed during a prep before Ready
// expires at that prep's end; one that arrived after Ready, at the prep end (<休整期结束时> grants), in COMBAT /
// SETTLE (battle-result grants, merges, returned equipment) or at the next round start / 机变 stays visible and
@@ -21,7 +22,8 @@
// 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).
// * Merge: 3 normal copies (风丸 2) on board/hand/temp → 1 elite to the hand; equipment returns to the hand; a
// reward offer of 3 free chess of tier min(level+1, 6) is queued (pick 1, expires at prep end; an offer earned
// 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
// after the prep — SETTLE / Final Assault effects — is kept for the next prep). The elite goes to the hand
// (research 01 A1 "1 elite goes to the hand, not to a board tile"), overflow temp — also outside PREP (a
// SETTLE merge's elite waits in temp for the next prep); only with the hand and temp both full does it take a
@@ -30,9 +32,14 @@
// 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
// (destroy for 0). consume-on-equip items resolve through the effect registry and never take a slot.
// * Tokens: placing an owner with placeable summons (tokens.json `placeable`: the talent summons 海嗣 / 狼群 / 流形)
// sends one stack (deployLimit copies) to the hand; withdrawing/selling/merging the owner removes its tokens. A
// skill's summon (赫默 医疗无人机, 巫恋 诅咒娃娃) is never a hand card: it appears in battle when the skill fires.
// * Tokens (PRTS 卫戍协议/帮助 §战斗部署, user playtest #6): placing an owner with manually deployable summons
// (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);
// withdrawing/selling/merging the owner removes its tokens, moving it on the board (also when a summon dragged onto
// 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
// board owner's summons up to the deploy limit, "干员所属召唤物会于下一回合返还"). In battle a skill's summon takes its
// tile when the skill fires (sim/content/tokens.js dockSkillSummons).
// * Facing (DESIGN §3, research 09 §1.2): every board piece has `dir` ∈ UP|RIGHT|DOWN|LEFT (server/sim/dir.js), set
// by g.move {…, dir} (absent ⇒ RIGHT) and kept across rounds. g.move onto the piece's OWN tile re-orients it in
// place; a swap keeps the occupant's dir; pieces put on the board by effects (a merge elite taking a consumed
@@ -50,6 +57,7 @@ import { FIELD, tileKey, parseKey, inField, canPlace, positionClass, boardOrder,
import { offsetTile } from '../sim/dir.js';
import { computeBonds, bondList, bondSnapshot, activatedLayers } from './bondsMeta.js';
import { itemKey } from './gamedata.js';
import { bountyText } from './choices.js';
const HAND_SIZE = GEO.HAND_SIZE;
const TEMP_SIZE = GEO.TEMP_SIZE;
@@ -352,22 +360,46 @@ export class PlayerState {
for (let i = 0; i < this.temp.length; i++) if (this.temp[i] && this.temp[i].kind === 'token' && this.temp[i].ownerUid === ownerUid) this.temp[i] = null;
}
_hasTokensOf(ownerUid, tokenId) {
for (const p of this.board.values()) if (p.kind === 'token' && p.ownerUid === ownerUid && p.id === tokenId) return true;
for (const p of this.hand) if (p && p.kind === 'token' && p.ownerUid === ownerUid && p.id === tokenId) return true;
for (const p of this.temp) if (p && p.kind === 'token' && p.ownerUid === ownerUid && p.id === tokenId) return true;
return false;
/**
* An owner that changes its board tile (moved, swapped): its summons on the board go back onto its stack (PRTS
* 卫戍协议/帮助 "移动干员时,其所属召唤物全部退场并重置至手牌区"); overflow temp when no stack or slot is left (a
* stack lost there comes back at the next round start). `keep`: a summon the player just placed (the one dragged
* onto its owner, which swapped the owner away) stays where it was put.
*/
_liftTokensOf(ownerUid, keep = null) {
for (const [k, p] of [...this.board]) {
if (p.kind !== 'token' || p.ownerUid !== ownerUid || p === keep) continue;
this.board.delete(k);
if (!this._returnToken(p, null, { allowTemp: true })) this.board.set(k, p); // nowhere to go: it stays put
}
}
/** Copies of one summon type an owner has (placed pieces + stacks in the hand / temp). */
_tokenCountOf(ownerUid, tokenId) {
const mine = (p) => !!p && p.kind === 'token' && p.ownerUid === ownerUid && p.id === tokenId;
let n = 0;
for (const p of this.board.values()) if (mine(p)) n += p.count || 1;
for (const p of this.hand) if (mine(p)) n += p.count || 1;
for (const p of this.temp) if (mine(p)) n += p.count || 1;
return n;
}
/**
* Owner placed on the board: send its placeable summons to the hand (one stack per token type). Only talent summons
* are placeable (gamedata.placeableTokens / tokens.json `placeable`); a summon a skill makes ("获得一个医疗无人机",
* 赫默 S2) appears in battle when that skill fires (user playtest #4: not on the field from the start).
* Owner on the board: send its placeable summons to the hand (one stack per token type, topped up to the deploy limit;
* gamedata.placeableTokens / tokens.json `placeable`, user playtest #6) — those its equipped skill / module makes
* (DESIGN §16: 赫默 S2 医疗无人机 a drone, 赫默 S1 none). Called when the owner is placed and at every round start, which
* returns a stack removed from temp at the last prep deadline (PRTS 卫戍协议/帮助 §手牌区 "干员所属召唤物会于下一回合
* 返还"); copies the owner still has (placed or stacked) are not granted again.
*/
grantTokensFor(owner) {
for (const { tokenId, count } of this.gd.placeableTokens(owner.id)) {
if (this._hasTokensOf(owner.uid, tokenId)) continue;
const t = this.newPiece('token', tokenId, { count, ownerUid: owner.uid });
const rec = owner && owner.kind === 'chess' ? this.gd.chess(owner.id) : null;
if (!rec) return;
for (const { tokenId, count } of this.gd.placeableTokens(owner.id, this.loadoutFor(rec))) {
const missing = count - this._tokenCountOf(owner.uid, tokenId);
if (missing <= 0) continue;
const stack = [...this.hand, ...this.temp].find((p) => p && p.kind === 'token' && p.ownerUid === owner.uid && p.id === tokenId);
if (stack) { stack.count = (stack.count || 1) + missing; continue; }
const t = this.newPiece('token', tokenId, { count: missing, ownerUid: owner.uid });
this.stow(t, { allowTemp: true });
}
}
@@ -539,16 +571,23 @@ export class PlayerState {
return true;
}
/** Queue a reward offer: `count` chess of tier min(level + offset, maxTier) at price 0 (pick 1). */
/**
* Queue a reward offer: `count` DIFFERENT chess of tier min(level + offset, maxTier) at price 0 (pick 1). Each is a
* copy-weighted roll from the shared pool excluding the ones already drawn; a tier left without another chess tops
* up from the tier below (user playtest #6 item 19: the official promotion reward never offers one operator twice —
* the user's first-hand report; the normal shop's slots may repeat). The offer reserves no copies (the pick takes one).
*/
pushRewardOffer(source = 'merge', { tier = null, ids = null } = {}) {
const ro = this.gd.rewardOffer();
const t = Number.isInteger(tier) ? tier : Math.min(this.shop.level + ro.tierOffset, ro.maxTier);
let list = Array.isArray(ids) ? ids.filter((id) => this.gd.chess(id)) : null;
// an offer never shows one operator twice, whoever built the list (user playtest #6 item 19)
let list = Array.isArray(ids) ? [...new Set(ids)].filter((id) => this.gd.chess(id)) : null;
if (!list) {
list = [];
const fresh = (id) => !list.includes(id);
for (let i = 0; i < ro.count; i++) {
let id = null;
for (let tt = t; tt >= 1 && !id; tt--) id = this.m.pool.roll(this.m.rngShop, { tier: tt });
for (let tt = t; tt >= 1 && !id; tt--) id = this.m.pool.roll(this.m.rngShop, { tier: tt, filter: fresh });
if (id) list.push(id);
}
}
@@ -899,16 +938,19 @@ export class PlayerState {
const occ = this.board.get(key) || null;
if (occ === piece) return this._reorient(piece, dir);
if (loc.area === 'board') {
// board → board: move or swap (the occupant must be legal on the source tile and keeps its own facing)
// board → board: move or swap (the occupant must be legal on the source tile and keeps its own facing); an
// operator that changes its tile takes its summons off the board (back onto their stacks, _liftTokensOf)
if (occ) {
const [sr, sc] = parseKey(loc.key);
if (!this._legal(occ, sr, sc)) return fail(ERR.BAD_TILE);
this.board.set(loc.key, occ);
if (occ.kind === 'chess') this._liftTokensOf(occ.uid);
} else {
this.board.delete(loc.key);
}
piece.dir = dir;
this.board.set(key, piece);
this._liftTokensOf(piece.uid);
this.recompute();
return OK;
}
@@ -966,6 +1008,8 @@ export class PlayerState {
const occ = this.board.get(key) || null;
if (occ === piece) return this._reorient(piece, dir);
if (loc.area === 'board') {
// board → board: move or swap; an operator swapped onto the summon's old tile changed its tile, so its other
// summons go back onto their stacks like any moved operator (_liftTokensOf; the summon just placed stays)
if (occ) {
const [sr, sc] = parseKey(loc.key);
if (!this._legal(occ, sr, sc)) return fail(ERR.BAD_TILE);
@@ -975,6 +1019,7 @@ export class PlayerState {
}
piece.dir = dir;
this.board.set(key, piece);
if (occ && occ.kind === 'chess') this._liftTokensOf(occ.uid, piece);
this.recompute();
return OK;
}
@@ -1216,8 +1261,10 @@ export class PlayerState {
/**
* Prep deadline: every temp piece due at this prep (tempDue ≤ prepsEnded) is resolved — a chess is sold back (its
* pool copies return, its summons are removed), items and summon stacks are destroyed. Pieces that overflowed after
* the player could no longer act on them (after Ready, at the prep end) are kept for the next prep.
* pool copies return, its summons are removed), items are destroyed, the summon stack of an owner still on the board
* is removed and comes back at the next round start (startRound → grantTokensFor; PRTS "干员所属召唤物会于下一回合
* 返还"). Pieces that overflowed after the player could no longer act on them (after Ready, at the prep end) are kept
* for the next prep.
*/
resolveTemp() {
let changed = false;
@@ -1252,6 +1299,8 @@ export class PlayerState {
// Likewise reward offers of the last prep already expired at its end; what is still queued was earned after it —
// a merge completed during SETTLE / the Final Assault (突变细胞, battle-result grants) — and is shown in this prep
this.ready = false;
// summon stacks removed from temp at the last prep deadline come back (PRTS 卫戍协议/帮助 §手牌区); full hand ⇒ temp
for (const p of [...this.board.values()]) if (p.kind === 'chess') this.grantTokensFor(p);
this.rollShop({ keepFrozen: true });
this.shop.frozen = false;
for (const s of this.shop.slots) if (s) s.frozen = false;
@@ -1369,7 +1418,15 @@ export class PlayerState {
out.push(v);
}
for (const b of this.bounties) {
out.push({ id: b.id, name: b.card.name || '悬赏', desc: b.card.desc || '', iconKind: 'choice', iconId: b.card.effectId || 'bounty', counter: b.roundsLeft >= 90 ? null : b.roundsLeft });
// the battles the bounty's enemies still come for (an official multi-round card: every battle, no counter) and
// the card's official rich text (blue "下场作战" / "两场作战", red "每场"; a multi-round card reads as long as it
// lasts — choices.js bountyText, MULTI_ROUND_BOUNTY_BATTLES) — user playtest #6 item 4
const left = b.roundsLeft >= 90 ? null : b.roundsLeft;
const eff = b.card.effectId ? this.gd.effect(b.card.effectId) : null;
out.push({
id: b.id, name: b.card.name || '悬赏', desc: bountyText((eff && eff.descRaw) || b.card.desc || '', b.card), iconKind: 'choice', iconId: b.card.effectId || 'bounty',
counter: left, counterText: left == null ? '之后的每场作战' : `还剩 ${left} 场作战`,
});
}
return out;
}
+55 -9
View File
@@ -31,6 +31,8 @@
// botPrepBeginSteps / botPrepEndSteps (and planLayoutSteps, arrangeSteps, createRehearsalSteps) are step
// generators that yield between whole actions (never with a transient board) — the same actions in the same
// order as the one-shot functions (runSteps), hence the same rng draws and decisions.
// The summon cards of the placed operators (赫默's 医疗探机, 伺夜's 狼群 …; user playtest #6) are placed after
// them on the best remaining tiles, 凯瑟琳's 支援装置 next to the best operator no device faces yet, facing it.
// 5. equip items on the strongest deployed damage dealers (consume-on-equip items / Arts only with a handler)
// 6. resolve the temp slots, keep one hand slot free, then Ready.
// Placement quality (tools/matchrun sweeps, research-faithful waves): the planner beats random layouts by ≈ 8 points
@@ -38,7 +40,8 @@
import { GEO } from '../../shared/constants.js';
import { deriveSeed } from '../sim/rng.js';
import { freeSlot, legalTiles, positionClass, parseKey, tileKey, FIELD, pieceDir, boardTileOf, BOSS_MIRROR_COL } from './board.js';
import { ASPD_MIN } from '../sim/constants.js';
import { freeSlot, legalTiles, canPlace, positionClass, parseKey, tileKey, FIELD, pieceDir, boardTileOf, BOSS_MIRROR_COL } from './board.js';
import { rotateOffset, normDir, mirrorDir, oppositeDir } from '../sim/dir.js';
import { itemKey } from './gamedata.js';
import { computeBonds } from './bondsMeta.js';
@@ -497,7 +500,7 @@ export function rangeTiles(rec, r, c, dir = 'RIGHT') {
function dpsOf(rec) {
const st = rec && rec.stats;
if (!st || isHealer(rec)) return 0;
const interval = Math.max(0.2, (st.bat || 1) * 100 / Math.max(10, st.aspd || 100));
const interval = Math.max(0.2, (st.bat || 1) * 100 / Math.max(ASPD_MIN, st.aspd || 100));
const d = (st.atk || 0) / interval;
return rec.attackKind === 'none' ? d * 0.4 : d;
}
@@ -920,11 +923,7 @@ export function botPrepEnd(m, ps, job = null) {
/** botPrepEnd as a step generator (see botPrepBeginSteps). */
export function* botPrepEndSteps(m, ps, job = null) {
if (!ps.alive || ps.ready) return;
if (job && job.done && job.best !== job.plans[0]) {
// summons placed around the default plan would sit on the new plan's tiles: back to their stacks, re-placed after
liftTokens(ps);
yield* applyPlanSteps(m, ps, job.chosen, job.best);
}
if (job && job.done && job.best !== job.plans[0]) yield* applyPlanSteps(m, ps, job.chosen, job.best);
// 5. temp → hand / sell / destroy; keep one hand slot free for next round's merges
resolveTemp(m, ps);
if (freeSlot(ps.hand) < 0) sellWeakestHand(m, ps);
@@ -1055,8 +1054,13 @@ export function* arrangeSteps(m, ps, { final = false, defer = false } = {}) {
return null;
}
/** Put the chosen pieces on their planned tiles, fill what is still off the board, then place summons. */
/**
* Put the chosen pieces on their planned tiles, fill what is still off the board, then place summons. Placed summons go
* back to their stacks first: they would sit on the plan's tiles (an operator moved onto one swaps it elsewhere), and a
* 凯瑟琳 device left beside a tile its operator moved away from would face nothing (QA, playtest #6).
*/
function* applyPlanSteps(m, ps, chosen, target) {
liftTokens(ps);
// move pieces onto their targets (board → board moves swap; hand → board may swap an occupant back to the hand)
for (let pass = 0; pass < 3; pass++) {
let moved = false;
@@ -1097,12 +1101,53 @@ function liftTokens(ps) {
}
}
/**
* Summons that help the operator on the tile they face (range 1-1: their own tile + the one in front) instead of
* covering the enemy path: 凯瑟琳's 爬行号·防护单元 (a hand piece since user playtest #6).
*/
const FRONT_SUPPORT_TOKENS = new Set(['token_10041_cathy_catsld']);
/** Device tile offsets around an operator (behind it — enemies come from the gates on the right —, above, below, in front) and the facing that points back at it. */
const SUPPORT_SPOTS = Object.freeze([[0, -1, 'RIGHT'], [1, 0, 'DOWN'], [-1, 0, 'UP'], [0, 1, 'LEFT']]);
/**
* A tile + direction for a front-support device: next to the most valuable operator no device faces yet (blockers
* first — they take the hits —, then DPS), pointing at it; null when none is free.
*/
function supportSpot(m, ps, p) {
const rec = m.gd.token(p.id);
if (!rec) return null;
const map = ps.deployMap();
const pos = positionClass(rec);
const faced = new Set();
for (const [k, q] of ps.board) {
if (q.kind !== 'token' || !FRONT_SUPPORT_TOKENS.has(q.id)) continue;
const [r, c] = parseKey(k);
const [dr, dc] = rotateOffset(0, 1, pieceDir(q));
faced.add(tileKey(r + dr, c + dc));
}
const ops = [...ps.board.entries()].filter(([k, q]) => q.kind === 'chess' && !faced.has(k)).map(([k, q]) => [k, m.gd.chess(q.id)]).filter(([, c]) => c)
.sort((a, b) => Number(isBlocker(b[1])) - Number(isBlocker(a[1])) || dpsOf(b[1]) - dpsOf(a[1]) || (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
for (const [k] of ops) {
const [r, c] = parseKey(k);
for (const [dr, dc, dir] of SUPPORT_SPOTS) {
const t = tileKey(r + dr, c + dc);
if (!ps.board.has(t) && canPlace(map, pos, r + dr, c + dc)) return [r + dr, c + dc, dir];
}
}
return null;
}
/** Placeable summons from the hand / temp onto the best free tiles (one per stack count). */
function* placeTokensSteps(m, ps) {
for (const p of [...ps.hand, ...ps.temp]) {
if (!p || p.kind !== 'token') continue;
for (let n = p.count || 1; n > 0; n--) {
yield;
if (FRONT_SUPPORT_TOKENS.has(p.id)) {
const spot = supportSpot(m, ps, p);
if (!spot || !tryDo(() => ps.move(p.uid, { area: 'board', row: spot[0], col: spot[1] }, spot[2]))) break;
continue;
}
const plan = yield* planLayoutSteps(m, ps, [p], LAYOUT_PARAMS, { occupied: new Set(ps.board.keys()) });
const k = plan.get(p.uid);
if (!k) break;
@@ -1156,7 +1201,8 @@ function resolveTemp(m, ps) {
tryDo(() => ps.destroy(p.uid));
}
}
// anything left (e.g. sells failed): final sweep — tokens cannot be sold, they are dropped with the temp slot
// anything left (e.g. sells failed): final sweep — tokens cannot be sold, they are dropped with the temp slot (and
// come back at the next round start, PlayerState.startRound)
for (let i = 0; i < ps.temp.length; i++) {
const p = ps.temp[i];
if (!p) continue;
+10 -4
View File
@@ -35,9 +35,9 @@ const paramsOf = (ctx, item) => {
};
/** random chess sharing at least one bond with `bonds`, tier ≤ maxTier */
function rollSameBond(ctx, bonds, maxTier) {
function rollSameBond(ctx, bonds, maxTier, exclude = null) {
const set = new Set(bonds);
return ctx.rollChess({ maxTier, filter: (id) => { const c = ctx.gd.chess(id); return !!(c && Array.isArray(c.bonds) && c.bonds.some((b) => set.has(b))); } });
return ctx.rollChess({ maxTier, filter: (id) => { const c = ctx.gd.chess(id); return !!(c && Array.isArray(c.bonds) && c.bonds.some((b) => set.has(b))) && !(exclude && exclude.includes(id)); } });
}
const ITEM_HANDLERS = {
@@ -127,7 +127,8 @@ const ITEM_HANDLERS = {
const n = Math.max(1, int(paramsOf(ctx, ev.item).refresh_cnt, 3));
const bonds = ctx.pieceBonds(ev.target.uid);
const ids = [];
for (let k = 0; k < n; k++) { const id = rollSameBond(ctx, bonds, ctx.shopLevel()); if (id) ids.push(id); }
// a pick-one offer never shows one operator twice (user playtest #6 item 19); fewer cards when the pool runs out
for (let k = 0; k < n; k++) { const id = rollSameBond(ctx, bonds, ctx.shopLevel(), ids); if (id) ids.push(id); }
if (ids.length) ctx.offerChess(ids, { source: 'item' });
},
},
@@ -141,7 +142,12 @@ const ITEM_HANDLERS = {
const original = ctx.gd.baseIdOf(target.id);
ctx.destroyPiece(target.uid);
const ids = [];
for (let k = 0; k < n; k++) { const id = ctx.rollChess({ tier }); if (id) ids.push(id); }
// different operators of the target's tier, topped up from the tier below like the promotion reward (item 19)
for (let k = 0; k < n; k++) {
let id = null;
for (let t = tier; t >= 1 && !id; t--) id = ctx.rollChess({ tier: t, filter: (x) => !ids.includes(x) });
if (id) ids.push(id);
}
if (ids.length) ctx.offerChess(ids, { source: 'item', tier });
// co-op: next prep, send the original chess to the teammate with the most members of its bonds
const mates = ctx.teammates();
+59 -7
View File
@@ -3,7 +3,13 @@
//
// Generation (generateDraft): the family is a weighted pick from choices.schedule[modeId].rounds[r].families; the card
// count is `cards` (co-op 6, solo 3):
// bounty 悬赏决策 distinct cards.bounty entries with tier ∈ bountyTiers whose enemy exists and is active in the mode
// bounty 悬赏决策 distinct cards.bounty entries with tier ∈ bountyTiers whose enemy exists and is active in the mode,
// draft cards only (`draftBounty`, choices.json `draft`: the PRTS 下半 记录 §机变阶段 "敌人轮选" table —
// never 战术特训, which only 法术教鞭 creates, nor the hidden 鸭爵 set; the 7 multi-round "之后 / 后续
// 的每场作战" cards stay in, as the table lists them); each card carries its official rich text
// `descRaw` (the battles in blue "下场作战" / "两场作战"); a multi-round card lasts
// MULTI_ROUND_BOUNTY_BATTLES battles and says so (`bountyBattles` / `bountyText`: the user's call
// after playtest #6 — "我不记得有过多轮悬赏")
// supply 道具补给 random normal EQUIP shop items with tier in supplyTiers [lo, hi] (duplicates allowed)
// shop 机密商店 random normal EQUIP shop items of any tier I–VI (duplicates allowed); FREE — the official card
// text is "无需消耗资金,获得装备补给" (research 01 A4/04 addendum), so the price is 0
@@ -32,6 +38,38 @@ import { weightedPick } from './waves.js';
export const FAMILY_NAMES = { bounty: '悬赏决策', supply: '道具补给', shop: '机密商店', tactic: '战术决策' };
/**
* Battles a multi-round bounty card lasts (data `rounds` 99, official text "之后 / 后续的<@ba.vdown>每场</>作战":
* 山海众头目·多轮悬赏, 多轮悬赏·假想敌 ×6, 法术大师A2·多轮战术特训). The user does not remember any multi-round bounty
* (playtest #6 answer, "我不记得有过多轮悬赏"): until that is confirmed otherwise every such card lasts two battles,
* exactly like the "接下来两场作战" cards, and its text says so in the same blue (`bountyText`). `null` restores the
* official "每场" (every later battle, red text) everywhere: the draft, 教鞭 / 神秘顾客, the bounty list and the effects
* column (DESIGN §20).
*/
export const MULTI_ROUND_BOUNTY_BATTLES = 2;
/** A multi-round bounty card ("之后 / 后续的每场作战"; choices.json `multiRound`, or data `rounds` ≥ 90). */
export const isMultiRoundBounty = (c) => !!c && (c.multiRound === true || Number(c.rounds) >= 90);
/** The battles a bounty card's enemies come for (MULTI_ROUND_BOUNTY_BATTLES for a multi-round card), 1–99. */
export function bountyBattles(c) {
if (isMultiRoundBounty(c) && Number.isInteger(MULTI_ROUND_BOUNTY_BATTLES)) return MULTI_ROUND_BOUNTY_BATTLES;
const r = Number(c && c.rounds);
return Math.max(1, Math.min(99, Number.isInteger(r) ? r : 1));
}
const N_ZH = ['', '一', '两', '三', '四', '五'];
/**
* A bounty text as the card lasts: a multi-round card's "之后的 / 后续每场作战" (rich `<@ba.vdown>每场</>` or plain) reads
* "接下来<@ba.vup>两场作战</>" like the official two-battle cards while MULTI_ROUND_BOUNTY_BATTLES is set; any other
* text is returned as is.
*/
export function bountyText(text, c) {
if (typeof text !== 'string' || !text || !isMultiRoundBounty(c) || !Number.isInteger(MULTI_ROUND_BOUNTY_BATTLES)) return text;
const n = MULTI_ROUND_BOUNTY_BATTLES;
const battles = `${N_ZH[n] || n}场作战`;
return text
.replace(/(?:之后的|后续的?)<@ba\.vdown>每场<\/>作战/g, `接下来<@ba.vup>${battles}</>`)
.replace(/(?:之后的|后续的?)每场作战/g, `接下来${battles}`);
}
function scheduleFor(gd, round) {
const sch = gd.choices.schedule && gd.choices.schedule[gd.modeId];
const r = sch && sch.rounds && sch.rounds[String(round)];
@@ -104,17 +142,31 @@ export function cardTargetBonds(gd, effectId) {
return out.length ? out : null;
}
/**
* A bounty card the 悬赏决策 draft may offer (choices.json cards.bounty `draft`, tools/build-data.mjs
* bountyDraftExclusion: the PRTS "敌人轮选" table — no 战术特训, no 鸭爵 set; user playtest #6 item 4). Data without the
* flag (older builds, test fixtures) falls back to the kill bounties.
*/
export function draftBounty(c) {
if (!c) return false;
if (typeof c.draft === 'boolean') return c.draft;
return c.payout !== 'perfect';
}
function buildCards(gd, rng, family, n, sch, { stageId = null, bondAvailable = null } = {}) {
const cardsData = gd.choices.cards || {};
if (family === 'bounty') {
const tiers = Array.isArray(sch.bountyTiers) && sch.bountyTiers.length ? sch.bountyTiers : [1, 2];
const pool = (Array.isArray(cardsData.bounty) ? cardsData.bounty : []).filter((c) => c && tiers.includes(c.tier) && gd.enemy(c.enemyKey) && !gd.inactiveEnemies.has(c.enemyKey));
const pool = (Array.isArray(cardsData.bounty) ? cardsData.bounty : []).filter((c) => c && draftBounty(c) && tiers.includes(c.tier) && gd.enemy(c.enemyKey) && !gd.inactiveEnemies.has(c.enemyKey));
const pick = pool.slice();
rng.shuffle(pick);
return pick.slice(0, n).map((c) => ({
kind: 'bounty', id: c.effectId, name: c.name, desc: c.desc || '', tier: c.tier, coin: c.coin, payout: c.payout,
rounds: c.rounds, enemyKey: c.enemyKey, count: c.count,
}));
return pick.slice(0, n).map((c) => {
const eff = typeof gd.effect === 'function' ? gd.effect(c.effectId) : null;
return {
kind: 'bounty', id: c.effectId, name: c.name, desc: bountyText(c.desc || '', c), descRaw: bountyText((eff && eff.descRaw) || null, c), tier: c.tier, coin: c.coin,
payout: c.payout, rounds: bountyBattles(c), enemyKey: c.enemyKey, count: c.count,
};
});
}
if (family === 'supply' || family === 'shop') {
let lo = 1;
@@ -144,7 +196,7 @@ function buildCards(gd, rng, family, n, sch, { stageId = null, bondAvailable = n
/** Public card view. */
export function cardView(c) {
const v = { idx: c.idx, kind: c.kind, id: c.id, name: c.name, desc: c.desc, tier: c.tier ?? null };
if (c.kind === 'bounty') Object.assign(v, { coin: c.coin, payout: c.payout, rounds: c.rounds, enemyKey: c.enemyKey, count: c.count });
if (c.kind === 'bounty') Object.assign(v, { descRaw: c.descRaw ?? null, coin: c.coin, payout: c.payout, rounds: c.rounds, enemyKey: c.enemyKey, count: c.count });
if (c.kind === 'item') v.price = 0;
if (c.kind === 'tactic') Object.assign(v, { team: c.team, tacticKind: c.tacticKind });
return v;
+43 -7
View File
@@ -18,14 +18,19 @@
// HeadlessJob / runHeadless(battle) step a battle to its end — in wall-clock-bounded slices (bots, takeovers
// under a real scheduler) or at once (verification, virtual time) — and keep a
// progress timeline [[gt, killed, total]] for the teammates' waiting UI
// (联防: [gt, killed, total, left] — left = spec.js uniteLeft, the leakers'
// enemies still standing, for their live counter; user playtest #6 item 7)
// HeadlessPacer real-time pacing of a dynamic set of server-run battles without snapshots
// (boss fields that share the pool while other fields run on clients); a
// takeover's fast-forward is spread over the pacing intervals
// specBounds(spec, gd) / validateClientResult(spec, result, { gd })
// a client's b.result is accepted only when it is plausible for its spec;
// the returned result is rebuilt from whitelisted fields (never the raw object)
// uniteBillBounds(spawns, gd) 联防: per leaker, the most survivors settlement can bill (sent in + the
// offspring bound) — the live counter's clamp
// syntheticResult(players, progress) stand-in when a boss field's client never reported
import { TICK, SNAPSHOT_EVERY } from '../sim/constants.js';
import { uniteLeft } from '../sim/spec.js';
export const MAX_TICKS_PER_INTERVAL = 8;
export const INTERVAL_MS = 1000 / 30;
@@ -251,20 +256,34 @@ export const HEADLESS_SLICE_MS = 8;
export const CATCHUP_TICKS_PER_INTERVAL = 240;
const perfNow = () => (globalThis.performance ? globalThis.performance.now() : Date.now());
/**
* A timeline sample of a battle: [gt, killed, total], plus — 联防 — the leakers' enemies still standing
* (spec.js uniteLeft; omitted when unknown).
*/
export function timelineSample(b) {
const s = [Number(b && b.time) || 0, Number(b && b.killed) || 0, Number(b && b.total) || 0];
if (b && b.kind === 'unite') {
let left = null;
try { left = uniteLeft(b); } catch { left = null; }
if (left) s.push(left);
}
return s;
}
/**
* A server-run battle stepped to its end, in one go or in wall-clock-bounded slices (a low-power host must not stall
* its event loop for the ~0.1–1 s a whole battle takes, several times at once when bots fight): `run(budgetMs)` steps
* until the battle ends (→ true) or the budget is used (→ false; checked every 16 ticks). `timeline` grows while it
* runs ([[gt, killed, total]] every TIMELINE_EVERY game seconds, then the final state); `output()` once done gives
* `{ battle, result, timeline, crashed }`. A throwing battle is force-ended, then replaced by a DeadBattle; the run is
* bounded by HARD_CAP_SECONDS.
* runs (timelineSample: [gt, killed, total(, left)] every TIMELINE_EVERY game seconds, then the final state);
* `output()` once done gives `{ battle, result, timeline, crashed }`. A throwing battle is force-ended, then replaced by
* a DeadBattle; the run is bounded by HARD_CAP_SECONDS.
*/
export class HeadlessJob {
constructor(battle, { onError = null, players = [] } = {}) {
this.battle = battle;
this.onError = onError;
this.players = players;
this.timeline = [[0, 0, Number(battle && battle.total) || 0]];
this.timeline = [timelineSample(battle)];
this.every = Math.max(1, Math.round(TIMELINE_EVERY / TICK));
this.cap = Math.ceil(HARD_CAP_SECONDS / TICK);
this.n = 0;
@@ -284,7 +303,7 @@ export class HeadlessJob {
b.step();
this.n++;
k++;
if (this.n % this.every === 0) this.timeline.push([Number(b.time) || 0, Number(b.killed) || 0, Number(b.total) || 0]);
if (this.n % this.every === 0) this.timeline.push(timelineSample(b));
if (timed && (k & 15) === 0 && !b.finished && now() - t0 >= budgetMs) return false;
}
if (!b.finished) b.forceEnd('timeout');
@@ -307,7 +326,7 @@ export class HeadlessJob {
for (const pid of this.players) perPlayer[pid] = emptyPerPlayer();
result = { time: Number(b.time) || 0, reason: 'forced', perPlayer, killed: 0, total: 0, errors: 1, synthetic: true };
}
this.timeline.push([Number(b.time) || 0, Number(b.killed) || 0, Number(b.total) || 0]);
this.timeline.push(timelineSample(b));
this.done = true;
this._out = { battle: b, result, timeline: this.timeline, crashed: this.crashed };
}
@@ -324,7 +343,7 @@ export function runHeadless(battle, opts = {}) {
return job.output();
}
/** Timeline sample at game time `gt`: [gt, killed, total] of the last sample ≤ gt. */
/** Timeline sample at game time `gt`: [gt, killed, total(, left)] of the last sample ≤ gt. */
export function timelineAt(timeline, gt) {
if (!Array.isArray(timeline) || !timeline.length) return [0, 0, 0];
let lo = 0, hi = timeline.length - 1;
@@ -626,6 +645,23 @@ export function specBounds(spec, gd = null) {
};
}
/**
* 联防: the most enemies settlement can bill each leaker — what it sent in plus what those enemies can leave behind
* (splits / summons within offspringPerParent, maxTotal without a data bound): validateClientResult's (key, leaker)
* budgets summed per leaker. Bounds the live counter (Match._uniteLeft; user playtest #6 item 7).
* @param {object[]} spawns the 联防 spawns (unite.js plan.leaked: { enemyKey, sourcePlayerId, … })
* @returns {Map<string, number>} leaker id → bound
*/
export function uniteBillBounds(spawns, gd = null) {
const B = specBounds({ spawns: Array.isArray(spawns) ? spawns : [] }, gd);
const out = new Map();
for (const [ks, n] of [...B.keySourceCounts, ...B.derivedSourceCounts]) {
const pid = ks.slice(ks.lastIndexOf('|') + 1);
if (pid) out.set(pid, (out.get(pid) || 0) + n);
}
return out;
}
const finiteIn = (v, lo, hi) => typeof v === 'number' && Number.isFinite(v) && v >= lo && v <= hi;
const sameMods = (a, b) => {
const ka = a && typeof a === 'object' ? Object.keys(a).filter((k) => a[k] !== undefined).sort() : [];
+14 -5
View File
@@ -15,13 +15,19 @@
// * 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
// per real second (gamedata.js bossOvertimeDue); m.public.deadline = the 120 s countdown, m.public.overtimeAt = the
// drain start. Minion / boss leaks cost their `lpr`; team LP 0 ⇒ defeat (all fields force-ended); pool 0 ⇒ victory.
// drain start. Minion / boss leaks cost their `lpr`; team LP 0 ⇒ defeat (all fields force-ended; PRTS 卫戍协议:盟约 下半
// "…使目标生命值扣除至0,则无视倒计时直接失败"); pool 0 ⇒ victory. The first of the two the server registers decides
// (Match._finalEnding): a report that arrives after the team LP ran out credits nothing (user playtest #6 item 5).
// * Pool HP is a float that never keeps float dust: the hit that would leave less than BOSS_POOL_MIN_HP (1) takes the
// rest, so the pool is 0 or ≥ 1 and a leader shown at 0 HP is down (sim/constants.js; the browser's LocalBossPool
// follows the same rule, server/sim/spec.js). Before, the per-player crediting of a report could leave 3.6e-12 that
// the browser could never deal (user playtest #6 item 5).
// * No IN_BATTLE layer gains (flags.layerGainsEnabled = false).
// * Hidden Core eligibility (after an R14 win): difficulty in hiddenCore.difficulties, the mode has a hidden round,
// Σ activated layers of the alive players measured at the end of the boss round's prep > threshold (solo 350 /
// co-op 1200) and team LP > minTeamLpExclusive (1).
import { BOSS_ROW_OFFSET, COLS } from '../sim/constants.js';
import { BOSS_ROW_OFFSET, COLS, BOSS_POOL_MIN_HP } from '../sim/constants.js';
import { mirrorDir, normDir } from '../sim/dir.js';
export const BOSS_HIT_STEPS = [0.2, 0.5, 0.8];
@@ -63,7 +69,10 @@ export function bossPoolHp(gd, bossId, aliveCount) {
return Math.max(1, Math.round(base * tune));
}
/** The one HP pool every boss field damages. `damage(playerId, amount)` is called by the sim. */
/**
* The one HP pool every boss field damages. `damage(playerId, amount)` is called by the sim (server-run fields) and by
* Match._creditBoss (client reports, per player); the hit that would leave less than BOSS_POOL_MIN_HP takes the rest.
*/
export class SharedBossPool {
constructor(hp, { onHit = null } = {}) {
this.maxHp = Math.max(1, hp);
@@ -76,8 +85,8 @@ export class SharedBossPool {
damage(playerId, amount) {
const a = Number(amount);
if (!Number.isFinite(a) || a <= 0 || this.hp <= 0) return 0;
const dealt = Math.min(this.hp, a);
this.hp -= dealt;
const dealt = this.hp - a < BOSS_POOL_MIN_HP ? this.hp : a;
this.hp -= dealt; // exactly 0 when the rest is taken (x − x): no float dust
if (playerId != null) this.byPlayer.set(playerId, (this.byPlayer.get(playerId) || 0) + dealt);
if (this.onHit) {
try { this.onHit(playerId, dealt); } catch { /* reported by the caller */ }
+16 -6
View File
@@ -440,18 +440,28 @@ export class GameData {
}
/**
* Placeable (hand) tokens a chess sends to the hand when placed on the board: [{ tokenId, count }] — its TALENT
* summons (tokens.json `placeable`: 海嗣 / 狼群 / 流形); a skill's summon (赫默's drone, 巫恋's doll) only appears in
* battle when the skill fires (tools/build-data.mjs buildTokens).
* Placeable (hand) tokens a chess sends to the hand when placed on the board: [{ tokenId, count }] — its manually
* deployable summons (tokens.json `placeable`: 医疗探机, 诅咒娃娃, 海嗣, 狼群, 流形, 爬行号·防护单元; user playtest #6)
* that the chess makes under `loadout` ({ skillIndex } from shared/protocol.js resolveLoadout; absent ⇒ its default
* skill): the owner variant's `sources` (`bySkill[skillIndex]` for a non-default skill) name a talent or a skill —
* 赫默 / 巫恋 on S1 make no drone / doll. `count` = the summon's deploy limit (PRTS 卫戍协议/帮助 "根据召唤物部署数量
* 上限(非初始持有量),发送等量召唤物至手牌区": 凯瑟琳 2 of her 3 devices).
*/
placeableTokens(chessId) {
placeableTokens(chessId, loadout = null) {
const c = this.chess(chessId);
if (!c || !Array.isArray(c.tokens)) return [];
const out = [];
for (const tid of c.tokens) {
const t = this.token(tid);
if (!t || t.kind !== 'summon' || t.displayType !== 'DEFAULT' || t.placeable !== true) continue;
const count = posIntOr(t.deployLimit, 1);
if (!t || t.kind !== 'summon' || t.placeable !== true) continue;
const vs = t.variants && typeof t.variants === 'object' ? t.variants : {};
const v = vs[chessId] ?? vs[String(chessId).replace(/_b$/, '_a')] ?? null;
if (v) {
const alt = loadout && Number.isInteger(loadout.skillIndex) && v.bySkill ? v.bySkill[loadout.skillIndex] : null;
const src = Array.isArray(alt?.sources) ? alt.sources : Array.isArray(v.sources) ? v.sources : [];
if (!src.includes('talent') && !src.includes('skill')) continue;
}
const count = posIntOr(v?.stats?.deployLimit, posIntOr(t.deployLimit, 1));
out.push({ tokenId: tid, count: Math.min(count, 9) });
}
return out;
+3 -2
View File
@@ -455,8 +455,9 @@ function hostAction(gd, wave, { fly, token = false }) {
* alone when the host spawns nothing, e.g. a walker bounty in a FLY round), then the whole list is spread over the
* action window: unit j at preDelay + j·max(W/total, 0.05·W). Returned here: the bounty specs only (a bounty's
* consecutive units share a spec); the host action's own units are NOT re-timed — `withBounties` gives the exact
* combined list. Multi-round bounties ("之后的每场作战", `rounds` > 1) spawn in every battle of the player while they
* last — the Final Assault and the Hidden Core included (`side`: the player's half of the boss field, see routeByMotion).
* combined list. Bounties of several battles (`rounds` > 1: "接下来两场作战", and the multi-round cards, which last
* choices.js MULTI_ROUND_BOUNTY_BATTLES) spawn in every battle of the player while they last — the Final Assault and the
* Hidden Core included (`side`: the player's half of the boss field, see routeByMotion).
* @param {Array<{ id: string, card: object }>} bounties
*/
export function bountySpawns(gd, round, wave, bounties, playerId, { solo = false, side = null } = {}) { // eslint-disable-line no-unused-vars
+176 -27
View File
@@ -6,9 +6,10 @@
//
// Construction creates every ally unit (undeployed) and installs content (kits + domain modules). The first
// step() (or an explicit start()) deploys everything (operators top→bottom, left→right — mirrored for the right boss
// side —, then the summon pieces the same way; the players of a shared field side by side), fires `deploy` (initial)
// for each unit, forces out the operators that enter already knocked out (`carryState.down`, 联防: constants.js
// FORCED_EXIT — down on their tile, redeploy timer running) and then fires `battleStart`.
// side —, then the summon pieces the same way — except a piece content flags `deferDeploy`, which waits on its tile;
// the players of a shared field side by side), fires `deploy` (initial) for each unit, forces out the operators that
// enter already knocked out (`carryState.down`, 联防: constants.js FORCED_EXIT — down on their tile, redeploy timer
// running) and then fires `battleStart`.
// Tick order: scheduled callbacks → spawns → DP → buffs → enemies (attack, move, block) → enemy index →
// allies (skill tick, attack) → projectiles → redeploys → boss sync → `tick` hook → release hooks of removed units →
// time += TICK → end checks. A forceEnd() requested mid-step ends the step after the current phase (docs/SIM.md §1.4).
@@ -20,17 +21,18 @@
// Robustness: every content callback and every step phase is wrapped; errors are logged once per key and the
// 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 } 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 { createRng } from './rng.js';
import { Grid } from './grid.js';
import { Unit } from './units.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 { absoluteRangeKeys, canTargetEnemy, meleeUnit } from './targeting.js';
import { absoluteRangeKeys, canTargetEnemy } from './targeting.js';
import { bodyKeys, bodyInKeys, bodyInRadius } from './body.js';
import { normDir, mirrorDir, localOrder, localBefore } from './dir.js';
import { ProjectileSystem } from './projectiles.js';
import { stampFear } from './fear.js';
import { SkillRuntime } from './skills.js';
import { updateAlly, updateEnemy, compileRoute, remainingDistance, effectiveProfile, performAttack, acquireTargets } from './ai.js';
import { resolveProfile } from './professions.js';
@@ -44,6 +46,15 @@ let schedSeq = 0;
/** Finite number or the default (content may pass undefined/NaN/Infinity/strings to helpers). */
const fin = (v, d) => { const n = typeof v === 'number' ? v : (v == null || v === '' ? NaN : Number(v)); return Number.isFinite(n) ? n : d; };
/**
* Official push distance (tiles) of a 受力等级 (constants.js PUSH_TILES: ≤ −3 → 0, ≥ 3 → the 3 value); `effect` = a 特效
* push (PRTS 推与拉's 特效 column, PUSH_TILES_EFFECT: 见行者), else the 弹道 column.
*/
export function pushTiles(level, effect = false) {
const l = Math.round(fin(level, -99));
if (l <= -3) return 0;
return (effect ? PUSH_TILES_EFFECT : PUSH_TILES)[Math.min(3, l)];
}
function clone(v) {
if (v == null || typeof v !== 'object') return v;
@@ -90,7 +101,10 @@ export class Battle {
// non-boss kinds fall back to 60 s; only boss/hidden (or an explicit Infinity) run without a limit.
const tl = Number(opts.timeLimit);
this.timeLimit = opts.timeLimit == null || !(tl > 0) ? (bossLike ? Infinity : 60) : tl;
this.flags = { layerGainsEnabled: this.kind === 'normal', ...DP_DEFAULTS, ...(opts.flags || {}) };
// startOpCooldown: the operation cooldown of the battle-start deployment (skills.js AUTO_OP_COOLDOWN)
this.flags = { layerGainsEnabled: this.kind === 'normal', ...DP_DEFAULTS, startOpCooldown: AUTO_OP_COOLDOWN, ...(opts.flags || {}) };
const soc = Number(this.flags.startOpCooldown);
this.flags.startOpCooldown = this.flags.startOpCooldown != null && Number.isFinite(soc) && soc >= 0 ? soc : AUTO_OP_COOLDOWN;
// DP knobs may arrive as undefined/null/strings (e.g. a template without `dp`): never let them poison DP with NaN.
for (const k of ['dpInit', 'dpPerSec', 'dpMax']) {
const v = Number(this.flags[k]);
@@ -302,6 +316,15 @@ export class Battle {
u.kit = kit || {};
u.profile = resolveProfile(u.def, u.kit.trait || null);
if (u.def.untargetable) this.addBuff(u, { key: 'trait:untargetable', flags: { untargetable: true }, persist: true, allowDead: true });
// abnormal effects a summon holds (tokens.json `abnormal`, PRTS; user playtest #6 item 18): 禁疗 — no heal reaches it;
// 孤立 ("无法被同阵营选中") — no ally heal or ally selection (auras over a range) reaches it
const ab = u.def.abnormal;
if (Array.isArray(ab) && ab.length) {
const flags = {};
if (ab.includes('healFree')) flags.noHeal = true;
if (ab.includes('isolated')) { flags.isolated = true; flags.noHeal = true; }
if (Object.keys(flags).length) this.addBuff(u, { key: 'trait:abnormal', flags, persist: true, allowDead: true });
}
const spec = u.kit.skill || null;
u.skill = new SkillRuntime(this, u, u.def.skill, spec, u.def.skill?.bb ?? {});
if (u.profile.install && !u._profInstalled) {
@@ -330,8 +353,11 @@ export class Battle {
const seq0 = this._deploySeq;
const lists = this.players.map((ps) => ps.units.filter((u) => u.kind === 'op' || u.kind === 'token').slice().sort((a, b) =>
b.homeR - a.homeR || (ps.mirror ? b.homeC - a.homeC : a.homeC - b.homeC) || a.id - b.id));
// a summon piece flagged `deferDeploy` by content (one its owner's loadout does not make — 赫默 on S1 with a drone
// piece —, or a skill's summon when shared/constants.js SKILL_SUMMON_START_DEPLOY is off: content/tokens.js
// dockSkillSummons) stays off the field, its tile reserved (isReservedTile), until content deploys it
for (const kind of ['op', 'token']) {
const per = lists.map((l) => l.filter((u) => u.kind === kind));
const per = lists.map((l) => l.filter((u) => u.kind === kind && !u.deferDeploy));
const n = Math.max(0, ...per.map((l) => l.length));
for (let i = 0; i < n; i++) for (const l of per) if (l[i]) this._safe(() => this._deploy(l[i], { initial: true }), 'initialDeploy', l[i]);
}
@@ -851,6 +877,8 @@ export class Battle {
if (!u.skill) this._setupUnit(u);
u.skill.reset(cs);
const sk = u.skill;
// "自动操作具有3s冷却,在完成一次操作或作战开始时部署的单位将进入冷却" (PRTS 卫戍协议/帮助; skills.js)
if (initial) sk.opReadyAt = this.time + this.flags.startOpCooldown;
if (keepSp && !sk.noSkill && sk.kind !== 'passive' && !sk.active) {
sk.charges = Math.max(0, Math.min(sk.maxCharges, Math.floor(fin(keepSp.charges, 0))));
sk.sp = Math.max(0, Math.min(sk.spCost, fin(keepSp.sp, 0)));
@@ -1072,14 +1100,15 @@ export class Battle {
}
/**
* The enemies ally `u` blocks that it may target with `profile` — always selectable by a melee blocker, inside its
* range or not ("可以选择且优先选择阻挡单位", PRTS 选择器; ai.js acquireTargets, the skills' DEFAULT trigger). A ranged
* operator on a melee tile gets none: it attacks what its range holds (targeting.js meleeUnit, PRTS 索敌的概念
* "阻挡(近战限定)").
* The enemies ally `u` blocks that it may target with `profile` — always selectable by their blocker, inside its range
* or not, whatever its facing ("可以选择且优先选择阻挡单位", PRTS 选择器; ai.js acquireTargets, the skills' DEFAULT
* trigger). That holds for a ranged operator on a melee tile too: the user's rule after playtest #6, "阻挡了就一定要能
* 打到" — officially the collision pushes a blocked enemy to its blocker's front, so whatever blocks an enemy can hit
* it (DESIGN §20; it replaces the playtest #5 QA's melee-only restriction).
*/
blockedTargets(u, profile) {
const out = [];
if (!u || !u.blocking || !u.blocking.length || !meleeUnit(u)) return out;
if (!u || !u.blocking || !u.blocking.length) return out;
for (const e of u.blocking) if (e.blockedBy === u && canTargetEnemy(u, e, profile)) out.push(e);
return out;
}
@@ -1241,7 +1270,8 @@ export class Battle {
* than LEVITATE_HALF_WEIGHT (current massLevel); 冻结's RES cut hits enemies only; 麻痹 adds stacks; "同名效果取最高"
* statuses (`valued`) keep the strongest value — a weaker application only extends past the stronger one's end (it
* then resumes); other statuses refresh to the longer duration. 诱导 (`attract`) walks the enemy to `point`
* ([r, c] or {x, y}; default the source's tile — a new application moves the point). A stunned/sleeping operator
* ([r, c] or {x, y}; default the source's tile — a new application moves the point); 恐惧 (`fear`) stamps where it
* was applied and from where (fear.js stampFear: the fan of 恐惧可达地块 its movement uses). A stunned/sleeping operator
* releases the enemies it blocks; a feared/levitated/unblockable/attracted enemy is released by its blocker.
* `statusApplied` reports the final duration and `entered` (the target did not carry the status before).
*/
@@ -1286,6 +1316,8 @@ export class Battle {
const mods = tpl.enemyOnlyMods && target.side !== 'enemy' ? null : typeof tpl.mods === 'function' ? tpl.mods(value) : (tpl.mods || null);
const b = this.addBuff(target, { key, duration, refresh: opts.refresh ?? 'extend', mods, flags: tpl.flags || null, status: key, visible: true, source });
if (tpl.attract && b) this._setAttractPoint(target, b, opts.point ?? value, source);
// 恐惧: the hit position and the source's position of every application (fear.js — the fan of reachable tiles)
if (key === 'fear' && b && target.side === 'enemy') stampFear(this, target, b, source);
}
const f = tpl.flags;
if (f && target.side === 'enemy' && (f.levitate || f.unblockable || f.fear)) this._unblock(target);
@@ -1417,6 +1449,19 @@ export class Battle {
return out;
}
/**
* A living enemy whose body is on any of `keys` — targetable or not (stealthed, untargetable, asleep, flying): the
* 技能范围 trigger "技能范围内存在敌人(无视其不可选中)" (skills.js SKILL_RANGE).
*/
anyEnemyInKeys(keys) {
if (!keys) return false;
for (let i = 0; i < keys.length; i++) {
const b = this._eb[keys[i]];
if (b && b.length) for (const e of b) if (e.alive && e.deployed) return true;
}
return false;
}
/** Allies (not devices) within tiles `keys`, sorted by HP ratio (lowest first) that need healing. */
injuredAlliesInKeys(keys, healer, includeElement = false) {
const out = [];
@@ -1453,10 +1498,25 @@ export class Battle {
return out;
}
/** Allies inside `unit`'s current range (for auras). */
/** Allies inside `unit`'s current range (for auras) — not the 孤立 ones ("无法被同阵营选中": 炎佑), `unit` aside. */
alliesInGrid(unit) {
const set = unit.rangeKeySet || new Set(unit.rangeKeys || []);
return this.allyUnits.filter((a) => a.alive && a.deployed && !a.hidden && a.kind !== 'device' && set.has(a.tileR * COLS + a.tileC));
return this.allyUnits.filter((a) => a.alive && a.deployed && !a.hidden && a.kind !== 'device' && set.has(a.tileR * COLS + a.tileC) && this.allySelectable(a, unit));
}
/**
* May an ability of ally `by` select ally `a`? Never a 孤立 unit (炎佑, 从不混淆的方向; flag `isolated`) other than `by`
* itself — PRTS 选择器: selectors decide "天赋是否能给予某个干员Buff" and every heal / buff / aura target, and their 可选判定
* rejects a 孤立 target of a friendly selector ("若掩码中孤立为1,且选择器的阵营与目标为友好关系,则不可选中"; user playtest #6
* item 18). Enemies still target it (a hostile selector). 禁疗 only keeps heals off (heal pipeline, injuredAlliesInKeys).
*/
allySelectable(a, by = null) {
return !!a && (a === by || !a.s.flags.isolated);
}
/** Deployed allies (no devices) an ability of ally `by` may select: allies(ownerId) without the 孤立 ones. */
alliesFor(by, ownerId = null) {
return this.allies(ownerId).filter((a) => this.allySelectable(a, by));
}
/**
@@ -1707,26 +1767,115 @@ export class Battle {
}
/**
* Displace an enemy (hook/push). dir = {x,y} (normalised internally), distance in tiles. Heavier enemies move
* less: effective = distance × clamp(1 − 0.25·(massLevel − force), 0, 1) (current massLevel: 失重 counts).
* 失衡免疫 (flag `noDisplace`: 近地悬浮, 浮空 — PRTS 异常效果 "不会被位移影响") ⇒ no movement. The tiles it may cross
* follow its movement (`motion`): a hovering enemy walks the ground, so it stays on ground-passable tiles.
* 受力等级 of enemy `e` under a push / pull of 力度 `force` (微小力 −1, 小力 0, 中力 1, 较大力 2, 大力 3, 大力+1 4, 特大力 5):
* force − its current 重量等级 (massLevel incl. 失重) — PRTS 游戏数据基础 §重量公式, 推与拉 (user playtest #6 item 14).
*/
displace(e, dir, distance, { force = 1 } = {}) {
if (!e || !e.alive || e.side !== 'enemy' || e.isBoss || e.s.flags.noDisplace) return 0;
const len = Math.hypot(dir.x, dir.y);
forceLevel(e, force) {
return Math.round(fin(force, 0)) - (e && e.s ? e.s.massLevel : 0);
}
/**
* Push enemy `e` with 力度 `force` (PRTS 推与拉 §推力): the official distance of its 受力等级 (constants.js PUSH_TILES:
* a 中力 push moves a weight-1 enemy 1.7 tiles, a weight-2 one 0.44, weight 3 0.12, weight ≥ 4 not at all). Radial
* (the default: away from `from`, the pusher's centre — "沿着干员向自身中心点的射线方向") or directional (`dir` = the
* pusher's direction {x, y}: 推击手, "朝部署方向" / "向前推开"); a directional push on a target more than 45° off `dir`
* or nearer than 0.25 tile becomes radial with 受力等级 −2 ("特殊修正"). `fixed` waives both corrections (PRTS 圣聆初雪 S1
* 备注 "不会因角度过大或距离过近而变化方向与力度"); `fixedAngle` only the angle one (PRTS 见行者 S2 备注 "不会因为角度过大
* 而改变推动的方向或削减力度" — the < 0.25 tile rule still applies). `inward` = a radial push towards `from` (薄绿 S2's "拖拽", PRTS 备注 "实际为
* 反方向(指向薄绿方向)的推开"), never nearer than PULL_STOP_RADIUS to its centre [ASSUMED: "至面前"]. `effect` = a 特效
* push (PRTS 推与拉: one frame less of travel than a 弹道 push — constants.js PUSH_TILES_EFFECT / PUSH_EFFECT_SKILLS).
* Returns the tiles moved.
*/
push(e, force, { from = null, dir = null, fixed = false, fixedAngle = false, inward = false, effect = false } = {}) {
if (!this._displaceable(e)) return 0;
let level = this.forceLevel(e, force);
const fx0 = fin(from?.x, e.x), fy0 = fin(from?.y, e.y);
const vx = e.x - fx0, vy = e.y - fy0, d = Math.hypot(vx, vy);
let ux = 0, uy = 0;
const dirX = dir ? fin(dir.x, 0) : 0, dirY = dir ? fin(dir.y, 0) : 0;
const dl = Math.hypot(dirX, dirY);
if (dl > 0) {
ux = dirX / dl; uy = dirY / dl;
if (from && !fixed && (d < PUSH_DIRECTIONAL_MIN_DIST || (!fixedAngle && vx * ux + vy * uy < d * Math.SQRT1_2))) {
level -= 2;
if (d > 1e-6) { ux = vx / d; uy = vy / d; }
}
} else if (d > 1e-6) { ux = vx / d; uy = vy / d; }
else if (!inward && from && Array.isArray(from.fwd)) { ux = from.fwd[1]; uy = from.fwd[0]; }
else return 0;
let dist = pushTiles(level, effect);
if (inward && !(dl > 0)) { ux = -ux; uy = -uy; dist = Math.min(dist, Math.max(0, d - PULL_STOP_RADIUS)); }
return this.displace(e, { x: ux, y: uy }, dist);
}
/**
* Pull enemy `e` with 力度 `force` towards the point `to` (PRTS 推与拉 §拉力 / §捕网): 受力等级 ≥ 0 — all the way, until it
* is within `stop` tiles of `center` (急停; `center` defaults to `to`, `stop` to PULL_STOP_RADIUS) or reaches `to`;
* −1 — PULL_WEAK_SHARE of its starting distance to `to`; −2 — PULL_CRAWL tiles; ≤ −3 — nothing. `pullToFront` aims at
* the official 拉力起点 in front of an operator. Returns the tiles moved.
*/
pull(e, force, { to, center = null, stop = PULL_STOP_RADIUS } = {}) {
if (!this._displaceable(e) || !to) return 0;
// an enemy the puller itself blocks already stands in front of it (at contact) [ASSUMED: no pull, no unblocking]
if (center && center.side === 'ally' && e.blockedBy === center) return 0;
const tx = fin(to.x, e.x), ty = fin(to.y, e.y);
const dx = tx - e.x, dy = ty - e.y, d0 = Math.hypot(dx, dy);
if (!(d0 > 1e-6)) return 0;
const ux = dx / d0, uy = dy / d0;
// travel until inside the stop circle around `center` (smaller root of |e + t·u − c| = stop), else up to `to`
let full = d0;
const cx = fin(center?.x, tx), cy = fin(center?.y, ty), r = Math.max(0, fin(stop, 0));
const wx = e.x - cx, wy = e.y - cy, wu = wx * ux + wy * uy, w2 = wx * wx + wy * wy;
if (w2 <= r * r) full = 0;
else {
const disc = wu * wu - w2 + r * r;
if (disc >= 0) { const t = -wu - Math.sqrt(disc); if (t >= 0) full = Math.min(full, t); }
}
const level = this.forceLevel(e, force);
const dist = level >= 0 ? full : level === -1 ? Math.min(full, PULL_WEAK_SHARE * d0) : level === -2 ? Math.min(full, PULL_CRAWL) : 0;
return dist > 1e-6 ? this.displace(e, { x: ux, y: uy }, dist) : 0;
}
/** Official distance (tiles) a push of 力度 `force` would move `e` on open ground (0 when it cannot be displaced). */
pushDistance(e, force, { effect = false } = {}) {
return this._displaceable(e) ? pushTiles(this.forceLevel(e, force), effect) : 0;
}
/** Pull `e` "至面前" of ally `unit` (拉力起点 PULL_ORIGIN tiles ahead along its direction, 急停 around its centre). */
pullToFront(e, unit, force) {
if (!unit) return 0;
const f = Array.isArray(unit.fwd) ? unit.fwd : [0, 1];
return this.pull(e, force, { to: { x: unit.x + f[1] * PULL_ORIGIN, y: unit.y + f[0] * PULL_ORIGIN }, center: unit, stop: PULL_STOP_RADIUS });
}
/** Can `e` be displaced at all: a living enemy, not a leader part of the boss pool, not 失衡免疫 (近地悬浮, 浮空). */
_displaceable(e) {
return !!(e && e.alive && e.side === 'enemy' && !e.isBoss && !e.s.flags.noDisplace);
}
/**
* Move an enemy `distance` tiles along `dir` = {x, y} (normalised internally) over passable tiles — the raw mover of
* push() / pull(), which apply the official 力度 − 重量 rules (content uses those; the old `force` option is gone).
* 失衡免疫 (flag `noDisplace`: 近地悬浮, 浮空 — PRTS 异常效果 "不会被位移影响") and leaders ⇒ no movement. The tiles it may
* cross follow its movement (`motion`): a hovering enemy walks the ground, so it stays on ground-passable tiles.
*/
displace(e, dir, distance) {
if (!this._displaceable(e) || !dir) return 0;
const dxv = fin(dir.x, 0), dyv = fin(dir.y, 0);
const len = Math.hypot(dxv, dyv);
if (!(len > 0)) return 0;
const eff = distance * Math.max(0, Math.min(1, 1 - 0.25 * Math.max(0, e.s.massLevel - force)));
if (eff <= 0) return 0;
const ux = dir.x / len, uy = dir.y / len;
const eff = Math.min(fin(distance, 0), 2 * COLS);
if (!(eff > 0)) return 0;
const ux = dxv / len, uy = dyv / len;
let moved = 0;
const stepLen = 0.1;
while (moved + 1e-9 < eff) {
const nx = e.x + ux * stepLen, ny = e.y + uy * stepLen;
const s = Math.min(stepLen, eff - moved); // (the last step is a partial one: 0.12 tiles moves 0.12, not 0.2)
const nx = e.x + ux * s, ny = e.y + uy * s;
const r = Math.round(ny), c = Math.round(nx);
const ok = e.motion === 'FLY' ? this.grid.inRect(r, c) : this.grid.groundPassable(r, c);
if (!ok) break;
e.x = nx; e.y = ny; moved += stepLen;
e.x = nx; e.y = ny; moved += s;
}
if (moved > 0) {
this._unblock(e);
+31 -42
View File
@@ -12,17 +12,21 @@
// but walks the ground. An unblocked enemy touching an ally with free block capacity — within its block radius (0.7071
// ground, 0.8944 air, devices 0.4472; Battle._checkBlock) — is blocked, moving or not, so an enemy overlapping an
// operator is taken over once its blocker is gone. Blocked enemies fight their blocker (ranged ones may pick anyone in
// range, blocker first); a melee blocker may always target the enemies it blocks, in range or not, a ranged operator on
// a melee tile only what its range holds (acquireTargets, targeting.js meleeUnit).
// range, blocker first); every blocker — a ranged operator on a melee tile included — may always target the enemies
// it blocks, in range or not, whatever its facing, and targets them first (acquireTargets, Battle.blockedTargets;
// user playtest #6: "阻挡了就一定要能打到").
// Unblocked ranged enemies attack allies within their radius and pause ATTACK_PAUSE seconds after each attack; the
// candidates pass the enemy's own rule (`e.profile.canTarget`) and are ordered blocker → taunt → latest deployed
// (targeting.js sortAllyTargets). Reaching the final leg's end = leak. An `attract` (诱导) status suspends the route:
// the enemy walks to the status point instead (moveAttracted) and re-plans its route when released.
// (targeting.js sortAllyTargets). Reaching the final leg's end = leak. A `fear` (恐惧) status suspends the route: the
// enemy runs between random checkpoints away from the fear's source (fear.js moveFeared; a self-inflicted fear
// flutters inside its own tile); an `attract` (诱导) status walks it to the status point instead (moveAttracted);
// both re-plan the route when released (恐惧 outranks 诱导).
import { ATTACK_PAUSE, MOVE_SCALE, PROJECTILE_SPEEDS, PROJECTILE_SPEED, BOOMERANG_RETURN_SPEED, COLS } from './constants.js';
import { ATTACK_PAUSE, ALLY_COLLIDER_RADIUS, MOVE_SCALE, PROJECTILE_SPEEDS, PROJECTILE_SPEED, BOOMERANG_RETURN_SPEED, COLS } from './constants.js';
import { sortEnemyTargets, sortAllyTargets, canTargetEnemy, canTargetAlly, tileKeyOf } from './targeting.js';
import { reduceElement } from './damage.js';
import { OB_CRATE } from './grid.js';
import { straightClear } from './grid.js';
import { moveFeared, endFear } from './fear.js';
// ---------------------------------------------------------------------------------------------------------------
// profiles
@@ -99,7 +103,9 @@ export function enforceBlockCapacity(b, u) {
/** Collect targets for an ally with profile `prof`. */
export function acquireTargets(b, u, prof) {
if (prof.heal && prof.dmgType === 'heal') {
const cands = b.injuredAlliesInKeys(u.rangeKeys, u, !!prof.heal.elementHealRatio);
let cands = b.injuredAlliesInKeys(u.rangeKeys, u, !!prof.heal.elementHealRatio);
// a heal restricted to allies at or below an HP ratio (塞雷娅 S1 急救 "血量小于等于一半")
if (prof.heal.hpAtMost > 0) cands = cands.filter((a) => a.hpRatio <= prof.heal.hpAtMost + 1e-9);
if (!cands.length) return cands;
let n = prof.heal.mode === 'multi' ? Math.max(1, prof.heal.count || 3) : 1;
if (prof.maxTargets > n) n = Math.floor(prof.maxTargets); // skill targeting override (e.g. heal 2 targets)
@@ -119,9 +125,9 @@ export function acquireTargets(b, u, prof) {
if (t.length) return t;
}
const cands = b.enemiesInKeys(u.rangeKeys, u, prof);
// "可以选择且优先选择阻挡单位" (PRTS 选择器): the enemies a melee unit blocks are always selectable by it — the block
// radius (0.7071) reaches past its own tile, so a blocked enemy may stand outside a short range (user playtest #5 item
// 4); a ranged operator on a melee tile gets none (Battle.blockedTargets: "阻挡(近战限定)")
// "可以选择且优先选择阻挡单位" (PRTS 选择器): the enemies a unit blocks are always selectable by it — the block radius
// (0.7071) reaches past its own tile, so a blocked enemy may stand outside a short range or behind its facing (user
// playtest #5 item 4); a ranged operator on a melee tile too (user playtest #6: "阻挡了就一定要能打到")
if (u.blocking.length) for (const e of b.blockedTargets(u, prof)) if (!cands.includes(e)) cands.push(e);
if (!cands.length) return cands;
if (prof.allInRange) return cands;
@@ -381,30 +387,6 @@ function planLeg(b, e, leg) {
R.suffix = suf;
}
/**
* Whether a straight move from (x, y) to the centre of tile p = {x: col, y: row} only crosses ground-walkable,
* crate-free tiles (the start and end tiles excepted). Exact grid traversal (tile (r,c) spans c ± 0.5, r ± 0.5); a
* line through a tile corner needs both side tiles clear (as the grid's Bresenham smoothing).
*/
function straightClear(g, x, y, p) {
let c = Math.round(x), r = Math.round(y);
const dx = p.x - x, dy = p.y - y;
const sc = dx > 0 ? 1 : -1, sr = dy > 0 ? 1 : -1;
const ddx = dx !== 0 ? Math.abs(1 / dx) : Infinity, ddy = dy !== 0 ? Math.abs(1 / dy) : Infinity;
let tx = dx !== 0 ? (c + 0.5 * sc - x) / dx : Infinity;
let ty = dy !== 0 ? (r + 0.5 * sr - y) / dy : Infinity;
const clear = (rr, cc) => (rr === p.y && cc === p.x) || (g.walkable(rr, cc) && !(g.obstacle[rr * COLS + cc] & OB_CRATE));
for (let guard = 4 * COLS; guard > 0 && (r !== p.y || c !== p.x); guard--) {
if (tx >= 1 && ty >= 1) break;
if (Math.abs(tx - ty) < 1e-9) {
if (!clear(r, c + sc) || !clear(r + sr, c)) return false;
c += sc; r += sr; tx += ddx; ty += ddy;
} else if (tx < ty) { c += sc; tx += ddx; } else { r += sr; ty += ddy; }
if (!clear(r, c)) return false;
}
return true;
}
/** Estimated length of legs after index `idx` (cached per grid version). */
function tailLength(b, e, idx) {
const R = e.route;
@@ -475,23 +457,27 @@ export function updateEnemy(b, e, dt) {
if (!e.hidden && b._checkBlock(e)) return;
if (b.time < e.pauseUntil) return;
if (e.s.flags.noMove) return;
// 恐惧 (ba.fear "无法被阻挡并四散逃跑"; PRTS 诱发移动: 恐惧 outranks 诱导): runs to random tiles of the fan away from
// its source — a self-inflicted fear flutters inside its own tile (fear.js); the route re-plans once it ends
if (e.s.flags.fear && !e.hidden) { moveFeared(b, e, dt); return; }
if (e.mem.fearMove) endFear(e);
// 诱导 (ba.attract "无法被阻挡并向目标位置移动"): walks to the attract point instead of following its route
if (e.s.flags.attract) { moveAttracted(b, e, dt); return; }
// 恐惧 (ba.fear "无法被阻挡并四散逃跑"): the enemy flees on the spot instead of advancing along its route
if (e.s.flags.fear) { e.moving = false; return; }
advanceRoute(b, e, dt, R);
}
/**
* 诱导: walk the enemy (own speed; ground: grid path re-planned when obstacles change, flyers: straight) to the point
* of its `attract` status (Battle._setAttractPoint) and keep it there. Its route re-plans from wherever it stands
* once the status ends (route.pts is reset whenever it moved).
* 诱导: walk the enemy (own speed; ground: grid path, flyers: straight) to the point of its `attract` status
* (Battle._setAttractPoint) and keep it there. The path re-plans from where the enemy stands when an obstacle changed
* or something else moved it since its last step (a push / pull, or a 恐惧 that outranked the 诱导 for a while) —
* a stale path would walk through walls. Its route re-plans from wherever it stands once the status ends (route.pts
* is reset whenever it moved).
*/
function moveAttracted(b, e, dt) {
let A = null;
for (const x of e.buffs) if (x.status === 'attract' && x.data && x.data.attract) { A = x.data.attract; break; }
if (!A) { e.moving = false; return; }
if (!A.pts || A.ver !== b.grid.version) {
if (!A.pts || A.ver !== b.grid.version || e.x !== A.px || e.y !== A.py) {
const sr = Math.round(e.y), sc = Math.round(e.x);
let pts = null;
if (e.motion !== 'FLY') { // (a hovering 近地悬浮 enemy is an air unit but walks the ground: motion decides the path)
@@ -517,6 +503,7 @@ function moveAttracted(b, e, dt) {
if (d <= dist) { e.x = p.x; e.y = p.y; dist -= d; A.i++; } else { e.x += (dx / d) * dist; e.y += (dy / d) * dist; dist = 0; }
moved = true;
}
A.px = e.x; A.py = e.y;
e.moving = moved;
if (moved && e.route) e.route.pts = null;
}
@@ -593,6 +580,8 @@ function enemyAttack(b, e) {
// Content may flip it with `e.profile.melee = false`.
const melee = e.profile?.melee ?? def.applyWay === 'MELEE';
const radius = melee ? 0 : e.base.rangeRadius;
// the range circle takes an ally whose 0.25 collider touches it (PRTS 作战机制 §碰撞体积: 索敌 uses the colliders)
const reach = radius > 0 ? radius + ALLY_COLLIDER_RADIUS : 0;
// `e.profile.canTarget(ally)`: the enemy's own target rule (只攻击地面单位, 不会攻击飞行单位 …; content/enemies.js),
// applied to the candidates before the priority sort and the target count
const own = e.profile && typeof e.profile.canTarget === 'function' ? e.profile.canTarget : null;
@@ -600,11 +589,11 @@ function enemyAttack(b, e) {
if (e.blockedBy) {
const bl = e.blockedBy;
if (radius > 0) {
targets = b.alliesInRadius(e.x, e.y, radius, null).filter((a) => a === bl || canTargetAlly(e, a, true));
targets = b.alliesInRadius(e.x, e.y, reach, null).filter((a) => a === bl || canTargetAlly(e, a, true));
if (!targets.includes(bl) && bl.alive) targets.push(bl);
} else if (bl.alive && bl.deployed) targets = [bl];
} else if (radius > 0) {
targets = b.alliesInRadius(e.x, e.y, radius, null).filter((a) => canTargetAlly(e, a, true));
targets = b.alliesInRadius(e.x, e.y, reach, null).filter((a) => canTargetAlly(e, a, true));
}
if (own && targets.length) targets = targets.filter((a) => own(a));
if (targets.length > 1) sortAllyTargets(e, targets);
+8 -2
View File
@@ -32,7 +32,7 @@ export const MUL_KEYS = Object.freeze([
export const FLAG_KEYS = Object.freeze([
'stun', 'freeze', 'sleep', 'silence', 'disarm', 'stealth', 'invulnerable', 'unblockable', 'levitate', 'fear',
'cold', 'reveal', 'bind', 'noHeal', 'untargetable', 'blockFly', 'noMove', 'noSp', 'burstLock', 'hidden',
'noBlock', 'tremble', 'hitCount', 'hitCountArts', 'attract', 'float', 'noDisplace',
'noBlock', 'tremble', 'hitCount', 'hitCountArts', 'attract', 'float', 'noDisplace', 'isolated', 'camou',
]);
/**
@@ -62,12 +62,18 @@ export const STATUS = Object.freeze({
// never the element gauge (元素损伤 has its own multiplier, `elemTakenMul`; damage.js)
elemFragile: { mods: (v) => ({ elementalTakenMul: 1 + (v ?? 0.2) }), valued: 0.2 },
silence: { flags: { silence: true }, immune: 'silence' },
// 恐惧: 无法被阻挡并四散逃跑 (no attacks, unblockable, stops advancing along its route)
// 恐惧: 无法被阻挡并四散逃跑 (no attacks, unblockable; leaves its route and runs to random tiles of the fan away from the
// source — fear.js, stamped by Battle.applyStatus)
fear: { flags: { fear: true, unblockable: true }, immune: 'feared' },
// 战栗: 被阻挡后无法进行普通攻击
tremble: { flags: { tremble: true }, immune: 'feared' },
disarm: { flags: { disarm: true } },
// 隐匿 (ba.invisible): 不阻挡时不成为敌方攻击的目标 (an ally: only the enemy it blocks attacks it — targeting.js)
stealth: { flags: { stealth: true } },
// 迷彩 (ba.camou): "不阻挡时不成为敌方普通攻击的目标(无法躲避溅射类攻击)" — an ally's camouflage keeps the enemies off
// it like 隐匿 (only the enemy it blocks attacks it: targeting.js canTargetAlly; shown the same way, snapshot.js), but
// it is not 隐匿 (叙拉古 / 家族徽章 read `stealth` only) and has its own buff keys
camou: { flags: { camou: true } },
reveal: { flags: { reveal: true } },
invulnerable: { flags: { invulnerable: true } },
// 浮空: 变为空中单位 (Unit.isFlying),无法移动、攻击及使用技能; 对重量大于3的单位持续时间减半; the state holds 不可阻挡 +
+59 -2
View File
@@ -15,6 +15,14 @@ export const COLS = GEO.COLS;
export const MOVE_SCALE = 0.5;
/** Ranged enemies stop moving this long after each attack (DESIGN §5.5). */
export const ATTACK_PAUSE = 0.35;
/**
* Collider radius of an allied unit (PRTS 作战机制 §碰撞体积与位置识别: "我方干员碰撞体积基本均为以0.25格为半径的圆形" —
* "关于碰撞体积与远程索敌,碰撞箱碰撞是为最常用的方式,索敌抬手等均使用碰撞箱"; the Ifrit 0.1 collider example). A ranged enemy's
* normal attack takes an ally whose collider touches its range circle: centre distance ≤ rangeRadius + this
* (ai.js enemyAttack; user playtest #6 follow-up — 萨卡兹枯朽战车's 2.2 reaches 2.45). [ASSUMED] every ally, summons
* included; enemy skills / zones of content keep their own (point) radius.
*/
export const ALLY_COLLIDER_RADIUS = 0.25;
/**
* Block contact (PRTS 游戏数据基础 §阻挡半径; 作战机制 §碰撞体积与位置识别 "中点判定 … 案例: 阻挡"): an unblocked enemy whose
* position lies within the blocker's radius of the blocker's centre touches it — compared on squared distances, as the
@@ -83,6 +91,31 @@ export const ELEMENT_ORDER = Object.freeze(['neural', 'erosion', 'burn', 'apopto
/** 麻痹 (ba.palsy): each stack cancels one normal attack of an enemy; at most 3 stacks, lasts until consumed. */
export const PALSY_MAX = 3;
/**
* Push / pull (位移; user playtest #6 item 14). The 受力等级 is the source's 力度 minus the target's current 重量等级
* (massLevel, 失重 counts) — PRTS 游戏数据基础 §重量公式 (力度: 微小力 −1, 小力 0, 中力 1, 较大力 2, 大力 3, 大力+1 4, 特大力
* 5) and PRTS 推与拉. PUSH_TILES = the official push distance per 受力等级 (游戏数据基础 "推力-位移近似对应表": ≤ −3 → 0,
* −2 → 0.12, −1 → 0.44, 0 → 1.7, 1 → 2.14, 2 → 2.96, ≥ 3 → 3.53 tiles). Pulls (拖拽 / 捕网, "拉力-位移近似对应表"):
* ≥ 0 → all the way to the pull point (必定拉至身前), −1 → PULL_WEAK_SHARE of the starting distance, −2 → PULL_CRAWL
* tiles, ≤ −3 → nothing. A pull "至面前" aims at the point PULL_ORIGIN tiles in front of the puller (拉力起点 "干员前方0.5格
* 距离处") and stops once the target is within PULL_STOP_RADIUS of the puller's centre (急停 "拖拽者中心半径0.6708").
* A directional push (推击手 / 朝部署方向) on a target more than 45° off the direction or nearer than
* PUSH_DIRECTIONAL_MIN_DIST becomes radial with 受力等级 −2 (推与拉 "特殊修正").
* PRTS 推与拉 gives two columns of 理想移动距离: 弹道 (a push carried by a projectile — "温蒂的23技能、阿消的12技能"; equal
* to the 游戏数据基础 table above) and 特效 (an effect push, one frame less of travel — "食铁兽的12技能、见行者的12技能"):
* PUSH_TILES_EFFECT, used by the skills in PUSH_EFFECT_SKILLS (见行者 S1 护身射击 / S2 惊爆射击, the only 特效 pushers of
* the pool PRTS names). [ASSUMED: every other push of the pool (野鬃, 山, 莫斯提马, 琳琅诗怀雅, 圣聆初雪, 薄绿) uses the 弹道
* column — PRTS does not classify them; the two columns differ by 7–12 %.]
*/
export const PUSH_TILES = Object.freeze({ '-2': 0.12, '-1': 0.44, 0: 1.7, 1: 2.14, 2: 2.96, 3: 3.53 });
export const PUSH_TILES_EFFECT = Object.freeze({ '-2': 0.085, '-1': 0.374, 0: 1.562, 1: 1.987, 2: 2.773, 3: 3.331 });
export const PUSH_EFFECT_SKILLS = Object.freeze(new Set(['skchr_forcer_1', 'skchr_forcer_2']));
export const PULL_WEAK_SHARE = 0.35;
export const PULL_CRAWL = 0.03;
export const PULL_ORIGIN = 0.5;
export const PULL_STOP_RADIUS = 0.6708;
export const PUSH_DIRECTIONAL_MIN_DIST = 0.25;
/** Freeze caused by cold on cold (DESIGN §5.3). */
export const COLD_FREEZE_DURATION = 3;
/** 浮空 (ba.levitate): the duration is halved on units heavier than this weight (massLevel). */
@@ -96,6 +129,15 @@ export const FREEZE_RES_DOWN = 15;
/** Default DP rules (DESIGN §5.5), overridable by Battle opts.flags. */
export const DP_DEFAULTS = Object.freeze({ dpInit: 10, dpPerSec: 1, dpMax: 99 });
/**
* Cooldown of the automatic skill operations (PRTS 卫戍协议/帮助 §作战阶段 技能操作: "自动操作具有3s冷却,在完成一次操作或作战
* 开始时部署的单位将进入冷却"), in battle seconds [ASSUMED: the game clock, like every skill timer]: the engine auto-casts
* a MANUAL skill no sooner than this after its previous cast and after the unit's battle-start deployment
* (skills.js; Battle._deploy `initial` sets `opReadyAt` from `battle.flags.startOpCooldown`, default this value). Kits
* with an automatic cast of their own check `skill.opCooling`. AUTO skills fire by their own rule and are not
* operations.
*/
export const AUTO_OP_COOLDOWN = 3;
/**
* State of a knocked-out operator waiting to redeploy on its own tile (b.snap `down` entries, Battle.snapshot): its
* respawn timer runs (COUNTING), then it waits for the player's DP to reach its cost (WAIT_DP) or for its tile to be
@@ -126,8 +168,13 @@ export const DEPLOY_ANIM_TIME = 0.5;
/** Stage devices that act as ground obstacles for pathing (阻隔工事). */
export const OBSTACLE_DEVICES = Object.freeze({ trap_1105_accrate: { hp: 100, name: '阻隔工事' } });
/** Default enemy/op aggregation clamps. */
export const ASPD_MIN = 10;
/**
* ASPD (攻击速度) clamps for every unit. The attribute's floor is 20 (PRTS 数值范围: ATTACK_SPEED 默认下限 20; 游戏数据基础
* "攻击速度属性实际被限制了下限为20" — so two −50 slows on base 100 leave ASPD 20, interval = 5 × BAT, not 10 × BAT; user
* playtest #6 item 17). The interval formula's own 10–600 clamp ("参与攻击间隔计算时会被限定在10~600以内") then only binds
* at the top.
*/
export const ASPD_MIN = 20;
export const ASPD_MAX = 600;
/** Client event buffer cap (events are dropped oldest-first beyond this when nobody drains). */
@@ -143,3 +190,13 @@ export const REGEN_EVENT_MIN = 1;
/** Row offset mapping board rows (9..12) onto boss-field rows (2..5). */
export const BOSS_ROW_OFFSET = -7;
/**
* Shared boss pool (Final Assault / Hidden Core): a pool holding less than this many HP is empty — the hit that would
* leave less takes the rest (server SharedBossPool, browser LocalBossPool) and a browser pool reading below it is 0.
* Pool HP is a float: the server subtracts each field's reported damage per player, the browser shows `server hp −
* unacknowledged local damage`, and their rounding could leave dust (3.6e-12) that no hit could remove — the local
* cumulative counter absorbed it — so the leader stood at "0 HP" until the overtime drain ended the run (user playtest
* #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;
+58 -21
View File
@@ -38,7 +38,8 @@
// create those units (death spawns, embers, blades, 再生).
// ELEMENT — element damage on hit = ATK × ep_damage_ratio into the ally's gauge. 侵蚀 (erosion) is an engine
// gauge whose burst is the official one (termDescription ba.dt.erosion: "永久降低100点防御力并受到800点物理伤害").
// DOT — HP-loss / arts damage zones (污染秽蚀, 毒雾, 燃烧区域), bleeding (removed by healing), pulsing auras.
// DOT — damage zones (污染秽蚀: true damage, one tick per second however many cover a unit; 毒雾, 燃烧区域),
// bleeding (removed by healing), pulsing auras.
// INVISIBLE — permanent `stealth` flag (engine: untargetable unless blocked or revealed; AoE still hits).
// REFLECTION — 折射 (ba.refraction "生效时,法术抗性+70"): RES +refracting.magic_resistance while NOT silenced
// (the ability line is SILENCE-flagged: silencing turns it off); 镜膜 also gets max HP +100 % while on.
@@ -70,8 +71,11 @@ export const EROSION_BURST = Object.freeze({ defDown: ELEMENT.erosion.ally.defDo
const PARASITE_SPREAD = 1000;
/** Death-explosion radius when the enemy has no official radius [ASSUMED]. */
const BOOM_RADIUS = 1.25;
/** Pollution zone radius of 萨卡兹枯朽战车's ranged 污染秽蚀 [ASSUMED]. */
const TANK_ZONE_RADIUS = 1;
/** 萨卡兹枯朽战车 秽蚀轰击: radius of the 【污染秽蚀】 it leaves at its target (PRTS 萨卡兹枯朽战车 技能 "在目标位置生成半径1.7,
* 持续10s的【污染秽蚀】" — the skill blackboard's range_radius 2.2 is its trigger range; the zone's radius is in no table). */
const TANK_ZONE_RADIUS = 1.7;
/** 【污染秽蚀】 ticks once per second; "同名效果不叠加": a unit inside several zones takes one tick per second. */
const POLLUTION_INTERVAL = 1;
/** "数个目标" of 假想敌:骨刺 while stealthed [ASSUMED]. */
const ACBUNN_TARGETS = 3;
/** 假想敌:再生 shield aura radius ("周围一定距离") [ASSUMED]. */
@@ -625,7 +629,12 @@ const float = () => ({ spawn(b, e) { setFloat(b, e, true); } });
/** The enemies whose kit spawns them hovering (float()): the match's bot counts them as air units (bot.js fieldModel). */
export const HOVER_KEYS = Object.freeze(['enemy_2025_syufo', 'enemy_10045_parrot']);
/** "生命值首次降至一半以下时,在数秒内陷入恐惧" (SelfFear). */
/**
* "生命值首次降至一半以下时,在数秒内陷入恐惧" (SelfFear; PRTS “萨科塔之翼/之眼/昂首” "生命值首次低于50%时,对自身施加持续5s的恐惧,
* 在5s内移动速度最终提升至150%"). The fear's source is the enemy itself, so it has no 恐惧可达地块: for SelfFear.fear s it
* flies to random points of its own tile (±0.25) at ×move_speed — the engine's 恐惧 movement (fear.js; user playtest #6
* item 13, "小范围乱飞") — then flies on along its route.
*/
const selfFear = (ab) => ({
taken(c, b, e, a) {
if (a.done || !(e.hpRatio < 0.5)) return;
@@ -875,10 +884,20 @@ function bleed(ab) {
};
}
/** 污染秽蚀 zone: HP loss per second, `low` on ground tiles, `high` on elevated tiles. */
/**
* 【污染秽蚀】 zone (PRTS 萨卡兹枯朽战士 / 萨卡兹枯朽战车: "范围内位于低地/高地的我方干员和召唤物每秒受到50/25点真实普通伤害
* (可对空,无视无法选择、迷彩;同名效果不叠加)"): `low` true damage per second on low ground, `high` on high ground, to every
* ally inside (flyers, stealthed and untargetable ones included); a unit inside several zones takes one tick per second
* (`mem.pollutedAt`: the last tick it took).
*/
function pollution(b, src, x, y, r, life, low, high) {
zone(b, { x, y, r, life, iv: 1, kind: 'pollution', tick(units) {
for (const u of units) { const v = u.ground ? low : high; if (v > 0) b.loseHp(u, v, { source: src }); }
zone(b, { x, y, r, life, iv: POLLUTION_INTERVAL, kind: 'pollution', tick(units) {
for (const u of units) {
const v = u.ground ? low : high;
if (!(v > 0) || b.time - (u.mem.pollutedAt ?? -Infinity) < POLLUTION_INTERVAL - 1e-6) continue;
u.mem.pollutedAt = b.time;
hurt(b, src, u, v, 'true', { tags: ['pollution'] });
}
} });
}
@@ -1104,19 +1123,37 @@ function kitParrot(ab) {
}];
}
function kitTank(ab) {
const every = (T(ab, 'Empty.sp') ?? 2) + 1, melee = T(ab, 'Empty.attack@chuang_atk_scale') ?? 1;
const s = ab.sk.PollutedRangedAtk ? ab.sk.PollutedRangedAtk.bb : {};
/**
* 萨卡兹枯朽战车 / 尖端 (PRTS; enemy_database spData "初始技力 2 / 技力上限 2 / 攻击回复"): an attack made with full SP is the
* skill 秽蚀轰击 — "对目标造成100%物理普通伤害,同时在目标位置生成半径1.7,持续10s的【污染秽蚀】" — and empties the SP; every
* other attack gains 1. Initial SP = max, so the FIRST attack is the skill, then every 3rd (attacks 1, 4, 7 …). Talent:
* "普通攻击只攻击位于低地的我方单位,且不会攻击飞行单位" (a candidate filter, ai.js enemyAttack: a FLY ally such as the 炎佑 dragon on a
* low tile is no target either); "被阻挡时进行近战攻击,造成攻击力200%的伤害" — normal attacks on its blocker ×chuang_atk_scale.
* [ASSUMED] the skill also fires on its blocker and deals the skill's own 100 % there (PRTS names no blocked exception:
* enemy SP skills fire as soon as they are ready and its condition — a low-ground non-flying ally within 2.2 — holds for
* the blocker), so a blocked tank hits 100 / 200 / 200 % … (v2.4.1: 200 % on every attack, the skill included).
* Reach: the 2.2 range circle takes an ally whose 0.25 collider touches it (ai.js enemyAttack, constants.js
* ALLY_COLLIDER_RADIUS; PRTS 作战机制 §碰撞体积) — 2.45 from the ally's centre, 21 tiles around it instead of 13 (user
* playtest #6 follow-up: the user remembers a long-reaching, hard-hitting ranged attack; the data scale stays 100 %).
*/
function kitTank(ab, e) {
const melee = T(ab, 'Empty.attack@chuang_atk_scale') ?? 1;
const sk = ab.sk.PollutedRangedAtk || null;
const s = sk ? sk.bb : {};
const spd = (e.def.raw && e.def.raw.sp) || null;
const max = Math.max(1, num(spd && spd.maxSp, 0) || (sk && sk.sp) || (T(ab, 'Empty.sp') ?? 2));
const init = Math.min(max, Math.max(0, num(spd && spd.initSp, 0)));
return [{
// 只攻击地面单位 — PRTS 萨卡兹枯朽战车 天赋 "普通攻击只攻击位于低地的我方单位,且不会攻击飞行单位" (a candidate filter,
// ai.js enemyAttack: a FLY ally such as the 炎佑 dragon on a low tile is no target either)
spawn(b, e, a) { a.n = 0; e.profile.canTarget = (u) => !!u.ground && !u.isFlying; },
hitOut(c, b, e) { if (c.dmg.isAttack && c.target === e.blockedBy) c.dmg.amount *= melee; }, // 近战攻击造成更高伤害
attack(c, b, e, a) {
a.n++;
if (a.n % every) return;
spawn(b, e2, a) { a.sp = init; a.skill = false; e2.profile.canTarget = (u) => !!u.ground && !u.isFlying; },
// SP full: this attack is 秽蚀轰击 (a blocked hit lands at once, inside this attack, while `a.skill` is set)
before(c, b, e2, a) { a.skill = a.sp >= max; },
hitOut(c, b, e2, a) { if (c.dmg.isAttack && !a.skill && c.target === e2.blockedBy) c.dmg.amount *= melee; },
attack(c, b, e2, a) {
if (!a.skill) { a.sp = Math.min(max, a.sp + 1); return; }
a.skill = false;
a.sp = 0;
const t = c.targets[0];
if (t) pollution(b, e, t.x, t.y, TANK_ZONE_RADIUS, s.projectile_life_time ?? 0, s.polluted_damage_low ?? 0, s.polluted_damage_high ?? 0);
if (t) pollution(b, e2, t.x, t.y, TANK_ZONE_RADIUS, s.projectile_life_time ?? 0, s.polluted_damage_low ?? 0, s.polluted_damage_high ?? 0);
},
}];
}
@@ -2147,11 +2184,11 @@ export const KITS = Object.freeze({
// --- DOT 持续
enemy_1234_dsubrl: kitDsubrl, // 深溟巢涌者 · pulse hits every ally in range + neural; 抵抗, immune 停顿
enemy_1234_dsubrl_2: kitDsubrl, // 富营养的巢涌者 · same
enemy_1267_nhpbr: kitPolluted, // 萨卡兹枯朽战士 · death: 污染秽蚀 HP-loss zone
enemy_1267_nhpbr: kitPolluted, // 萨卡兹枯朽战士 · death: 污染秽蚀 zone (50 / 25 true per second)
enemy_1267_nhpbr_2: kitPolluted, // 萨卡兹枯朽战士组长 · same
enemy_1270_nhstlk: (ab) => [bleed(ab)], // 逐腐兽 · bleeding (arts/s, cleared by healing)
enemy_1270_nhstlk_2: (ab) => [bleed(ab)], // 疯狂的逐腐兽 · same
enemy_1272_nhtank: kitTank, // 萨卡兹枯朽战车 · ground targets only, melee ×2, every 3rd attack a pollution zone
enemy_1272_nhtank: kitTank, // 萨卡兹枯朽战车 · ground targets only, melee ×2, 秽蚀轰击 on attacks 1, 4, 7 …
enemy_1272_nhtank_2: kitTank, // 尖端萨卡兹枯朽战车 · same
enemy_9006_actoxi: (ab) => [{ // 假想敌:蚀裂 · death: poison cloud on its killer
sil: true,
@@ -2201,7 +2238,7 @@ export const KITS = Object.freeze({
enemy_1355_mrfly_2: (ab, e) => [enemyAura(e.base.rangeRadius || 2.5, 'ab:mrfly', { resFlat: T(ab, 'magdef_add.magic_resistance') ?? 0 })], // 护障·P · RES aura
enemy_1042_frostd: (ab) => [allyAura(T(ab, 'defup.range_radius') ?? 2.5, 'ab:frost', { aspd: (T(ab, 'atkSpeedDown.attack_speed') ?? 0) * 100 })], // 寒霜 · ASPD −50 aura on operators
enemy_1040_bombd: kitBombd, // 暴鸰 · no normal attack: ONE bomb (target + 8 tiles), then ×2 speed
enemy_10083_hlbird: kitSelfFear, // “萨科塔之翼” · fear + flee below half HP
enemy_10083_hlbird: kitSelfFear, // “萨科塔之翼” · below half HP: 5 s self-fear, flutters in its tile ×1.5
enemy_10084_hlegle: kitSteal, // “萨科塔之眼” · fear below half; steals 1 ammo instead of hitting
enemy_10085_hllevi_2: kitRoar, // “萨科塔昂首” · fear below half; 祈祷邀约 global ASPD −30
enemy_1407_hummbd: kitExposeOnDeath, // 远眺 · death: exposes operators around (damage taken ×1.2)
+12 -8
View File
@@ -30,10 +30,14 @@
// "立即流失N%当前生命" + hp_ratio ⇒ self HP loss at start (宴, 风丸); hp_ratio + "恢复/回复…生命" ⇒ self heal at start.
// Passive skills only apply stat mods (for bb.duration s when the text says "N秒内": 宴) and the self/counter effects
// above — their scales describe procs (bombs, sword rain, counters) that need a hand-authored kit.
// force→onHit displacement (pull when the text says 拖拽 or for hookmasters, push otherwise; 0.5+0.5·force tiles).
// force→onHit displacement with the official 力度 − 重量 rules (Battle.push / pullToFront): a pull "至面前" when the text says
// 拖拽 or for hookmasters, else a push — along the unit's direction when the text says 朝部署方向 / 向前 / 身前方向 or for
// 推击手 (directional), otherwise away from the unit (radial); a skill of constants.js PUSH_EFFECT_SKILLS (见行者 S1) pushes
// by PRTS 推与拉's 特效 column.
import { normalizeSkill } from '../simdata.js';
import { sortEnemyTargets } from '../targeting.js';
import { PUSH_EFFECT_SKILLS } from '../constants.js';
const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : (typeof v === 'string' && v !== '' && Number.isFinite(+v) ? +v : undefined));
@@ -212,8 +216,10 @@ export function genericSkillSpec(sk, bb = sk?.bb ?? {}, def = null) {
// ---- displacement
const forceRaw = g('force');
const hasForce = !passive && forceRaw !== undefined;
const force = Math.max(0, forceRaw ?? 0);
const force = forceRaw ?? 0; // 力度 (微小力 −1 … 特大力 5)
const pull = /拖拽/.test(desc) || (!/推开|击退/.test(desc) && def && def.subProf === 'hookmaster');
const directional = !pull && (/朝部署方向|向前|身前方向/.test(desc) || (def && def.subProf === 'pusher'));
const effectPush = PUSH_EFFECT_SKILLS.has(sk.id);
const hitElement = element && !counterText;
const healAlly = (battle, unit) => {
@@ -223,7 +229,7 @@ export function genericSkillSpec(sk, bb = sk?.bb ?? {}, def = null) {
}
let best = null;
for (const a of battle.injuredAlliesInKeys(unit.rangeKeys, unit)) if (!(allyHealOthersOnly && a === unit)) { best = a; break; }
if (!best) best = battle.alliesInRadius(unit.x, unit.y, 1.5, null).filter((a) => a.hp < a.s.maxHp && !(allyHealOthersOnly && a === unit)).sort((a, b) => a.hpRatio - b.hpRatio)[0] ?? null;
if (!best) best = battle.alliesInRadius(unit.x, unit.y, 1.5, null).filter((a) => a.hp < a.s.maxHp && !(allyHealOthersOnly && a === unit) && (a === unit || !(a.s.flags.noHeal || a.profile?.noHeal))).sort((a, b) => a.hpRatio - b.hpRatio)[0] ?? null;
if (best) battle.heal(unit, best, unit.s.atk * allyHealScale);
};
const onHit = hitStatuses.length || hasForce || hitElement || allyHeal
@@ -235,11 +241,9 @@ export function genericSkillSpec(sk, bb = sk?.bb ?? {}, def = null) {
for (const s of hitStatuses) ctx.battle.applyStatus(ctx.target, s.key, { duration: s.duration, source: ctx.unit });
}
if (hasForce && ctx.target.alive && ctx.target.side === 'enemy') {
const dx = ctx.target.x - ctx.unit.x, dy = ctx.target.y - ctx.unit.y;
const len = Math.hypot(dx, dy) || 1;
const dir = pull ? { x: -dx / len, y: -dy / len } : { x: dx / len, y: dy / len };
const dist = pull ? Math.max(0, Math.min(len - 0.6, 0.5 + 0.5 * force)) : 0.5 + 0.5 * force;
if (dist > 0) ctx.battle.displace(ctx.target, dir, dist, { force: force + 1 });
const u = ctx.unit;
if (pull) ctx.battle.pullToFront(ctx.target, u, force);
else ctx.battle.push(ctx.target, force, { from: u, dir: directional && u.fwd ? { x: u.fwd[1], y: u.fwd[0] } : null, effect: effectPush });
}
}
: null;
+25 -14
View File
@@ -10,14 +10,20 @@
// golden stays in the hand (the built-in equipped the merged golden on the copy, and its live loop over
// the target's items skipped the item after a merge)
// 紧急调度券 a shop operator leaves its slot only when it was actually granted (built-in cleared the slot first)
// 寻呼模块 the special refresh shows `refresh_cnt` DIFFERENT operators when the pool allows it
// 寻呼模块 the special refresh shows `refresh_cnt` DIFFERENT operators (fewer when the pool has no more)
// 突变细胞 after the battle the carrier becomes a NORMAL random tier+1 operator; its other equipment goes back to
// the hand (built-in kept it on the new operator)
// 教鞭 / “神秘顾客” trap_create_self_choice {choice_event: hunter_band_1}: the bounty comes from the band bounty
// family `enemyeffect_b_*` (research 04 §7: "offer 3 random enemyeffect_b_* bounties … adds 1 enemy to your
// next battle, killer gets `coin` funds"); the built-in drew any tier ≤ II kill bounty (incl. 2-round and
// faction bounties). [ASSUMED simplification, engine: no PERSONAL_CHOOSE overlay] 3 are drawn and one of
// them is taken at random. Falls back to the built-in when the family is empty.
// 教鞭 trap_create_self_choice {choice_event: hunter_band_1}: the bounty is a 战术特训 card (choices.json
// cards.bounty payout `perfect`, e.g. 战术特训·飞行I "若各自行动阶段就达成完美作战,获得1资金") — PRTS
// 卫戍协议:盟约 下半/PRTS盟约记录 §法术 教鞭 "于3个战术特训的悬赏任务中选择一项", §机变阶段 "※以下悬赏任务仅由
// 法术教鞭生成", and 杜宾 加练! "<教鞭>:使用后为下场战斗添加额外敌人,若自身战斗完美作战可获得资金" (user
// playtest #6 item 4 review; the built-in drew any tier ≤ II kill bounty, and research 04 §7 [ASSUMED]
// the band family `enemyeffect_b_*`).
// “神秘顾客” the same trap_create_self_choice ("选择一项特殊悬赏任务"; no band grants it in act2, research 04): a band
// bounty `enemyeffect_b_*` (research 04 §7 [ASSUMED]: "adds 1 enemy to your next battle, killer gets `coin`
// funds").
// Both: [ASSUMED simplification, engine: no PERSONAL_CHOOSE overlay] 3 cards whose enemy can appear in the
// mode are drawn and one of them is taken at random; the built-in runs when the family is empty.
// “神秘顾客” trap_disney_special: when actively destroyed, +count funds and the Art passes to the next alive player
// (seat order, cyclic)
// 天师古鼎 equip_with_another_gain_coin_when_gain_char: a 【炎】 carrier also holding 炎国短刀 (either quality)
@@ -42,14 +48,18 @@ function pieceIsMember(ctx, piece, bondId) {
return bonds.includes('maniShip') && isCoreBond(bondId) && ctx.bondActive('maniShip') && ctx.bondActive(bondId);
}
/** Offer size of the personal bounty choice (research 04 §7, [ASSUMED]). */
/** Offer size of the personal bounty choice (PRTS 法术 教鞭 "于3个战术特训的悬赏任务中选择一项"). */
const OFFER_SIZE = 3;
/** Bounty cards of the band bounty family `enemyeffect_b_*` whose enemy can appear in this mode. */
function bandBounties(ctx) {
/** cards.bounty entries matching `test` whose enemy can appear in this mode. */
function bountyCards(ctx, test) {
const all = ctx.data && ctx.data.choices && ctx.data.choices.cards && Array.isArray(ctx.data.choices.cards.bounty) ? ctx.data.choices.cards.bounty : [];
const inactive = ctx.gd.inactiveEnemies instanceof Set ? ctx.gd.inactiveEnemies : new Set();
return all.filter((c) => c && /^enemyeffect_b_\d+$/.test(String(c.effectId)) && ctx.gd.enemy(c.enemyKey) && !inactive.has(c.enemyKey));
return all.filter((c) => c && test(c) && ctx.gd.enemy(c.enemyKey) && !inactive.has(c.enemyKey));
}
/** The 战术特训 cards (perfect payout: the chooser's own phase perfect) — what 教鞭 offers. */
const isTraining = (c) => c.payout === 'perfect';
/** The band bounty family `enemyeffect_b_*` — what “神秘顾客” offers [ASSUMED]. */
const isBandBounty = (c) => /^enemyeffect_b_\d+$/.test(String(c.effectId));
function wrap(registry, key, hooks) {
const base = registry.get(`item:${key}`) || {};
@@ -85,7 +95,8 @@ export function registerMeta(registry) {
const shares = (id) => { const c = ctx.gd.chess(id); return !!(c && Array.isArray(c.bonds) && c.bonds.some((b) => bonds.has(b))); };
const ids = [];
for (let k = 0; k < n; k++) {
const id = ctx.rollChess({ maxTier, filter: (x) => shares(x) && !ids.includes(x) }) || ctx.rollChess({ maxTier, filter: shares });
// never the same operator twice (user playtest #6 item 19): fewer cards when the pool has no other one
const id = ctx.rollChess({ maxTier, filter: (x) => shares(x) && !ids.includes(x) });
if (id) ids.push(id);
}
if (ids.length) ctx.offerChess(ids, { source: 'item' });
@@ -109,11 +120,11 @@ export function registerMeta(registry) {
},
}));
// 教鞭 / “神秘顾客” — trap_create_self_choice {choice_event}: a band bounty (enemyeffect_b_*) for the next battle
for (const key of ['chess_item_6_03_m', 'chess_item_6_01_m']) {
// 教鞭 / “神秘顾客” — trap_create_self_choice {choice_event}: 教鞭 a 战术特训 card, “神秘顾客” a band bounty (enemyeffect_b_*)
for (const [key, family] of [['chess_item_6_03_m', isTraining], ['chess_item_6_01_m', isBandBounty]]) {
wrap(registry, key, (base) => ({
onArt(ctx, ev) {
const cards = bandBounties(ctx);
const cards = bountyCards(ctx, family);
if (!cards.length) { if (typeof base.onArt === 'function') base.onArt.call(base, ctx, ev); return; }
const offer = ctx.rng.shuffle(cards.slice()).slice(0, OFFER_SIZE);
const card = ctx.rng.pick(offer);
+43 -37
View File
@@ -12,7 +12,8 @@
// Operator loadouts (DESIGN §16): every selectable non-default skill of the 16 visible chess is authored in the kit's
// `skills: { [skillId]: SkillSpec }` map from its own SkillRecord (skillRec / skillBbOf — Lv4 normal, Lv7 elite);
// talents / traits read the resolved record, so a module choice ('none' ⇒ traitBase / talentsBase, module.active
// false) is honoured. Per-skill triggers come from data (TANK S1 ⇒ TAKE_DAMAGE); the few spec overrides are documented
// false) is honoured. Per-skill triggers come from data (the official 技能策略: every MANUAL 重装 skill ⇒ TAKE_DAMAGE, a
// MANUAL skill with its own 技能范围 ⇒ SKILL_RANGE, AUTO skills keep their own rule); the few spec overrides are documented
// at the skill (冲锋号令 AUTO ⇒ SP_FULL, 花香疗法 heal-type DEFAULT). Tests: test/content/kits_alt_t1.test.js.
//
// fx kinds emitted (battle.fx(kind, {x, y, …})): aoe {radius, id, skill} · zone {radius, dur, id, skill} ·
@@ -88,10 +89,13 @@ export function enemiesInGrid(battle, unit, grid, { n = 0, priority = null, canH
sortEnemyTargets(battle, unit, list, priority ?? unit.profile?.priority ?? null);
return n > 0 && list.length > n ? list.slice(0, n) : list;
}
/** Deployed allies (no devices) whose tile is inside `grid` relative to `unit` (null ⇒ current range). */
/**
* Deployed allies (no devices) whose tile is inside `grid` relative to `unit` (null ⇒ current range) — never a 孤立 unit
* (炎佑: Battle.allySelectable), as Battle.alliesInGrid.
*/
export function alliesInGridOf(battle, unit, grid = null) {
const set = grid ? new Set(absoluteRangeKeys(grid, unit.tileR, unit.tileC, unit.dir, 0)) : (unit.rangeKeySet || new Set(unit.rangeKeys || []));
return battle.allyUnits.filter((a) => a.alive && a.deployed && !a.hidden && a.kind !== 'device' && set.has(a.tileR * COLS + a.tileC));
return battle.allyUnits.filter((a) => a.alive && a.deployed && !a.hidden && a.kind !== 'device' && set.has(a.tileR * COLS + a.tileC) && battle.allySelectable(a, unit));
}
/** Any targetable enemy in the unit's current range. */
export const enemyInRange = (battle, unit) => battle.enemiesInKeys(unit.rangeKeys, unit, { canHitFly: true }).length > 0;
@@ -133,7 +137,7 @@ export function toggleBuff(battle, unit, key, cond, mods, extra = {}) {
export function installAura(battle, unit, { key, select, mods, value = 0, interval = 0.5 }) {
battle.every(interval, () => {
if (!up(unit)) return;
for (const a of battle.allies()) {
for (const a of battle.alliesFor(unit)) {
if (!select(a)) continue;
const cur = a.findBuff(key);
if (cur && cur.source !== unit && (cur.data?.v ?? 0) > value && cur.timeLeft > 0.05) continue;
@@ -356,7 +360,7 @@ export default {
// ---------------------------------------------------------------------------------------------------------------
// 1_02 角峰 抗寒体质: HP +max_hp, DEF +def, RES ×(1+magic_resistance). 雪原卫士: RES +magic_resistance (flat).
// Alternate S1 体能强化 (TANK S1 ⇒ TAKE_DAMAGE trigger from data): HP +max_hp, +hp_recovery_per_sec HP per second
// Alternate S1 体能强化 (重装 ⇒ TAKE_DAMAGE trigger from data): HP +max_hp, +hp_recovery_per_sec HP per second
// (the 生命回复速度 attribute). Elite module PRO-Y (block 4) is a stat of the module (attr blockCnt).
chess_char_1_02_a: (bb, chess) => {
const s1 = skillBbOf(chess, 'skchr_yak_1');
@@ -392,7 +396,7 @@ export default {
// attack@max_target enemies on the line and cause attack@sluggish s of 停顿.
// 细胞活性抑制剂: attacks inflict `damage` arts per `interval` s for `duration` s (damage_seamonster vs 【海怪】).
// Elite module (SPT-X): stealth of enemies inside the range is cancelled.
// Alternate S1 侵袭破坏应对 (TANK S1 ⇒ TAKE_DAMAGE trigger from data): ATK +atk, DEF +def.
// Alternate S1 侵袭破坏应对 (重装 ⇒ TAKE_DAMAGE trigger from data): ATK +atk, DEF +def.
chess_char_1_04_a: (bb, chess, def) => {
const t = talentBb(chess, 0);
const s1 = skillBbOf(chess, 'skchr_udflow_1');
@@ -622,14 +626,18 @@ export default {
},
// ---------------------------------------------------------------------------------------------------------------
// 1_10 古米 备用军粮 (TAKE_DAMAGE, ct charges): the next attack heals the most injured ally nearby (skill grid)
// for heal_scale × ATK. Elite module (GUA-X): targets below hp_ratio HP get ×heal_scale more.
// 1_10 古米 备用军粮 (自动触发, ct charges — an AUTO skill: no 技能策略 applies, it keeps its own rule; PRTS 备注 "此技能
// 在存在生命值不满的可治疗角色时可触发;技能触发后古米将切换至治疗模式(普通攻击改为治疗技能范围内的一名友方单位),直至古米
// 完成一次普通攻击的治疗"): cast as soon as a healable ally of the skill range (x-4, herself included) is injured —
// no enemy needed (user playtest #6: it waited until she engaged) — then her next normal attack heals the most
// injured ally of the skill range for heal_scale × ATK instead of hitting an enemy; she makes no normal attack until
// that heal is done. Elite module (GUA-X): targets below hp_ratio HP get ×heal_scale more.
// 平底锅专精: each attack prob for ATK ×atk_scale and a `stun` s stun (only when that hit lands).
// Alternate S2 食粮烹制 (PRTS notes: 10 s 缴械 at the start, the real duration is disarm + 30 s, attacks become heals
// with the attack interval +130 %): cooking for `disarm` s — no attacks, DEF +def; then for the skill's `duration` s
// she heals the most injured ally inside the skill grid (x-4) for ATK per attack, ATK +atk, base attack time
// ×(1 + base_attack_time). The GUA-X heal bonus (below hp_ratio: ×heal_scale) applies to these heals too.
// Trigger: DEFAULT (data) with her own profile — an enemy in her initial range (the tile she blocks on).
// Trigger: TAKE_DAMAGE (data: the 重装 strategy "不受技能范围影响,受到伤害时释放技能").
chess_char_1_10_a: (bb, chess, def) => {
const t = talentBb(chess, 0);
const tb = traitBb(chess);
@@ -638,6 +646,7 @@ export default {
const b2 = r2?.bb ?? {};
const cook = Math.max(0, num(b2.disarm, 10));
const cookKey = 'sunbr:cook', serveKey = 'sunbr:serve';
const healGrid = def?.skill?.rangeGrid ?? null;
return {
skills: {
[S2]: {
@@ -661,9 +670,9 @@ export default {
},
},
install(battle, unit) {
if (unit.skill?.id !== S2 || tb.hp_ratio == null || num(tb.heal_scale, 1) === 1) return;
// (S1 applies the module bonus inside its own heal): her S2 heals — attack heals while it runs (herself included,
// she stands in the grid), never her natural / self regeneration
if (tb.hp_ratio == null || num(tb.heal_scale, 1) === 1) return;
// GUA-X on her skill heals (S1's heal-mode attack, S2's attack heals while it runs — herself included, she stands
// in the grid), never her natural / self regeneration
battle.on('heal', (ctx) => {
if (ctx.source !== unit || !unit.skill?.active || ctx.opts?.regen || ctx.opts?.self) return;
if (ctx.target.hpRatio < num(tb.hp_ratio)) ctx.amount *= num(tb.heal_scale, 1);
@@ -671,18 +680,11 @@ export default {
},
skill: {
kind: instantKind(def),
attack: {
onHit({ battle, unit }) {
const grid = def?.skill?.rangeGrid;
const keys = grid ? absoluteRangeKeys(grid, unit.tileR, unit.tileC, unit.dir, 0) : unit.rangeKeys;
const ally = battle.injuredAlliesInKeys(keys, unit)[0];
if (!ally) return;
let amount = unit.s.atk * num(bb.heal_scale, 1);
if (tb.hp_ratio != null && ally.hpRatio < num(tb.hp_ratio)) amount *= num(tb.heal_scale, 1);
battle.heal(unit, ally, amount, { tags: ['skill'] });
battle.fx('heal', { x: ally.x, y: ally.y, id: ally.id });
},
},
heal: true,
...(healGrid ? { trigger: { rule: 'SKILL_RANGE', grid: healGrid, allies: true }, targeting: { rangeGrid: healGrid } } : {}),
// the heal-mode attack: the most injured ally of the skill range (acquireTargets, heal profile)
attack: { dmgType: 'heal', heal: { mode: 'single' }, healScale: num(bb.heal_scale, 1), projectile: 'none' },
onHit({ battle, target }) { if (target) battle.fx('heal', { x: target.x, y: target.y, id: target.id }); },
},
talents: [{ install(battle, unit) {
// the proc is rolled per attack hit; the stun only follows a hit that landed (not dodged / cancelled)
@@ -757,7 +759,7 @@ export default {
if (unit.skill?.id !== S1) return;
battle.on('tick', () => {
const sk = unit.skill;
if (!up(unit) || !unit.canAct || !sk.ready || sk.active || unit.s.flags.silence) return;
if (!up(unit) || !unit.canAct || !sk.ready || sk.active || sk.opCooling || unit.s.flags.silence) return;
if (battle.injuredAlliesInKeys(unit.baseRangeKeys || unit.rangeKeys, unit).length) sk.activate('DEFAULT');
}, { owner: unit });
},
@@ -832,9 +834,10 @@ export default {
},
},
trait: {
// with only bound enemies in range she holds her fire (the mystic trait stores the energy meanwhile)
// with only bound enemies in range (or blocked by her — always her targets, Battle.blockedTargets) she holds her
// fire (the mystic trait stores the energy meanwhile)
canAttack(battle, u) {
const ok = battle.enemiesInKeys(u.rangeKeys, u, u.profile).some((e) => !bound(e));
const ok = battle.enemiesInKeys(u.rangeKeys, u, u.profile).some((e) => !bound(e)) || battle.blockedTargets(u, u.profile).some((e) => !bound(e));
if (!ok) u.trait.hadTarget = false;
return ok;
},
@@ -861,8 +864,11 @@ export default {
talents: [{ install(battle, unit) {
battle.on('beforeAttack', (ctx) => {
if (ctx.attacker !== unit || !ctx.targets.some(bound)) return;
const cands = battle.enemiesInKeys(unit.rangeKeys, unit, ctx.profile || unit.profile).filter((e) => !bound(e));
sortEnemyTargets(battle, unit, cands, (ctx.profile || unit.profile).priority);
const prof = ctx.profile || unit.profile;
const cands = battle.enemiesInKeys(unit.rangeKeys, unit, prof);
for (const e of battle.blockedTargets(unit, prof)) if (!cands.includes(e)) cands.push(e);
for (let i = cands.length - 1; i >= 0; i--) if (bound(cands[i])) cands.splice(i, 1);
sortEnemyTargets(battle, unit, cands, prof.priority);
ctx.targets = cands.slice(0, Math.max(1, ctx.targets.length));
}, { owner: unit });
} }],
@@ -921,7 +927,9 @@ export default {
},
// ---------------------------------------------------------------------------------------------------------------
// 1_19 野鬃 夹枪冲锋: wider range (skill grid), ATK +atk, hits push the target away (attack@force, 中等力度).
// 1_19 野鬃 夹枪冲锋: wider range (skill grid), ATK +atk, hits push the target away (attack@force 1 = 中力; radial — away
// from her centre, PRTS 推与拉: every push but the 推击手' is radial) by the official 力度 − 重量 distance (Battle.push:
// weight 0 → 2.14 tiles, 1 → 1.7, 2 → 0.44, 3 → 0.12, ≥ 4 → none; user playtest #6 item 14).
// 一致向前: after deploying (normal flag 0: first deployment only; elite flag 1: every deployment) every undeployed
// 【近卫】 operator of the player costs `value` less DP to deploy (≤ max_stack_cnt per operator until it deploys).
// Alternate S1 骑枪刺击 (PASSIVE, ON_DEPLOY): for `duration` s after every deployment ASPD +attack_speed.
@@ -946,9 +954,7 @@ export default {
attack: {
onHit({ battle, unit, target }) {
if (!target || !target.alive || target.side !== 'enemy') return;
let dx = target.x - unit.x, dy = target.y - unit.y;
if (Math.hypot(dx, dy) < 1e-6) { dx = unit.fwd[1]; dy = unit.fwd[0]; }
battle.displace(target, { x: dx, y: dy }, 0.5 + 0.5 * force, { force: force + 1 });
battle.push(target, force, { from: unit });
},
},
},
@@ -981,9 +987,9 @@ export default {
// attack@buff_prob to stun attack@stun s; afterwards 雷蛇 is stunned `stun` s.
// 战术防御: when attacked, +sp SP to herself and to one random ally in the talent grid. Elite 雷抗: RES +magic_resistance.
// Elite module (SPT-X): stealth of enemies inside the range is cancelled.
// Alternate S1 充能防御 (AUTO, hurt SP; TANK S1 ⇒ TAKE_DAMAGE from data — the hit that fills SP sets it off): PRTS
// "技能拥有8s的持续时间": for bb.duration s DEF +def, and the next damage instance taken is blocked (cancelled — a
// blocked hit "导致第一天赋无法触发": no 战术防御 SP, no hurt SP).
// Alternate S1 充能防御 (AUTO, hurt SP, "技能自动开启" — an AUTO skill takes no 技能策略: SP_FULL, so the hit that fills SP
// sets it off): PRTS "技能拥有8s的持续时间": for bb.duration s DEF +def, and the next damage instance taken is blocked
// (cancelled — a blocked hit "导致第一天赋无法触发": no 战术防御 SP, no hurt SP).
chess_char_1_20_a: (bb, chess) => {
const t = talentBb(chess, 0);
const grid = talentGrid(chess, 0);
@@ -993,7 +999,7 @@ export default {
return {
skills: {
[S1]: {
kind: 'duration', duration: num(b1.duration, 8), mods: { defPct: num(b1.def) },
kind: 'duration', duration: num(b1.duration, 8), mods: { defPct: num(b1.def) }, trigger: 'SP_FULL',
onStart({ battle, unit, skill }) {
battle.addBuff(unit, { key: blockKey, duration: skill.duration + 0.05, tags: ['skill'], visible: true });
battle.fx('shield', { x: unit.x, y: unit.y, id: unit.id });
+14 -55
View File
@@ -18,36 +18,10 @@ import { canTargetEnemy } from '../../targeting.js';
import { bodyInKeys } from '../../body.js';
import {
num, talentBb, moduleBb, traitBb, up, cheb, byEnemyAttack, onHitBy, onHitOn, onDamagedOn, enemiesInGrid,
alliesInGridOf, enemyInRange, statBuff, toggleBuff, installAura, spTimeBonus, freeTileAround, summonTileFree, instantKind,
alliesInGridOf, enemyInRange, statBuff, toggleBuff, installAura, spTimeBonus, freeTileAround, instantKind,
tinmanKit, batMod,
} from './tier1.js';
/**
* The tokens.json variant a summon of `owner` gets — its chess (else the normal `_a` sibling, else the first variant)
* with the owner's selected skill / module merged, as simdata getToken does — for fields normalizeToken drops (the
* withdraw skill's duration). Exact when two players of one field give the same chess different loadouts (DESIGN §16).
*/
function ownTokenVariant(battle, tokId, owner) {
const vs = battle.tokenDef(tokId, owner)?.raw?.variants;
if (!vs || typeof vs !== 'object') return null;
const oid = String(owner?.defId ?? '');
let v = vs[oid] ?? vs[oid.replace(/_b$/, '_a')] ?? Object.values(vs)[0] ?? null;
const lo = owner?.def?.loadout;
if (v && lo && !lo.isDefault) {
if (!lo.skillIsDefault && v.bySkill?.[lo.skillIndex]) v = { ...v, ...v.bySkill[lo.skillIndex] };
if (!lo.moduleIsDefault && v.byModule?.[lo.moduleId]) v = { ...v, ...v.byModule[lo.moduleId] };
}
return v;
}
/** Lifetime (s) of a token `owner` summons: its own withdraw skill duration, else "N秒后自动销毁" in the owner's skill text. */
function tokenLifetime(battle, tokId, owner, desc) {
const raw = battle.tokenDef(tokId, owner)?.raw;
const v = num(ownTokenVariant(battle, tokId, owner)?.skill?.duration, num(raw?.skill?.duration));
if (v > 0) return v;
const m = String(desc || '').match(/(\d+(?:\.\d+)?)秒后自动销毁/);
return m ? +m[1] : 0;
}
import { releaseSkillSummon } from '../tokens.js';
/** Largest element gauge of a unit (every element — 侵蚀 included: the bosses' attacks fill it on operators). */
const elemLoad = (a) => Math.max(a.elem.burn, a.elem.neural, a.elem.necrosis, a.elem.apoptosis, a.elem.erosion ?? 0);
@@ -92,10 +66,12 @@ export default {
}),
// ---------------------------------------------------------------------------------------------------------------
// 2_02 赫默 医疗无人机 (AUTO, heal trigger): deploy a medical drone (overrideTokenKey, stats from tokens.json variants)
// next to the most injured ally in range; it heals around itself and self-destructs after its lifetime; at most `cnt`
// drones at once. 强化注射: every 【医疗】 operator on the field ASPD +attack_speed. Elite module (PHY-Y): heals on
// ground units ×heal_scale (module 'none': no bonus).
// 2_02 赫默 医疗无人机 (AUTO, heal trigger): "获得一个医疗无人机 / 最多可库存1个无人机;无人机投入战场后治疗周围友军,
// 10秒后自动销毁" — the 医疗探机 is a hand piece the player places (user playtest #6; PRTS 卫戍协议/帮助): each cast gives
// one (stock ≤ cnt) and the placed piece takes the field on its own tile (tokens.js releaseSkillSummon: not at the
// battle start — user playtest #4; not placed ⇒ no drone); it heals around itself and self-destructs after 10 s (token
// kit). 强化注射: every 【医疗】 operator on the field ASPD +attack_speed. Elite module (PHY-Y): heals on ground units
// ×heal_scale (module 'none': no bonus).
// S1 治疗强化·γ型 (alt): ATK +atk for its duration (no drone).
chess_char_2_02_a: (bb, chess) => {
const tokId = chess?.skill?.overrideTokenKey ?? (chess?.tokens ?? [])[0] ?? 'token_10000_silent_healrb';
@@ -105,23 +81,7 @@ export default {
return {
skill: {
kind: 'instant', heal: true,
onStart({ battle, unit }) {
const focus = battle.injuredAlliesInKeys(unit.rangeKeys, unit)[0] ?? unit;
let best = null, bd = Infinity;
for (const k of unit.rangeKeys || []) {
const r = (k / COLS) | 0, c = k % COLS;
if (!summonTileFree(battle, r, c) || !battle.grid.canStand(r, c, { ranged: true })) continue;
const d = Math.max(Math.abs(r - focus.tileR), Math.abs(c - focus.tileC)) + 0.01 * (Math.abs(r - unit.tileR) + Math.abs(c - unit.tileC));
if (d < bd) { bd = d; best = [r, c]; }
}
if (!best) return;
const drones = (unit.mem.drones ?? []).filter((d) => d.alive);
while (drones.length >= cnt) battle.retreat(drones.shift(), { reason: 'expired', permanent: true });
const life = tokenLifetime(battle, tokId, unit, chess?.skill?.desc);
const d = battle.spawnToken(unit, tokId, best[0], best[1], life > 0 ? { duration: life } : {});
if (d) { drones.push(d); battle.fx('summon', { x: d.x, y: d.y, id: d.id, token: tokId }); }
unit.mem.drones = drones;
},
onStart({ battle, unit }) { releaseSkillSummon(battle, unit, tokId, { cap: cnt }); },
},
skills: { 'skcom_heal_up[3]': { kind: 'duration', heal: true, mods: { atkPct: num(bb.atk) } } },
talents: [{ install(battle, unit) {
@@ -135,8 +95,9 @@ export default {
},
// ---------------------------------------------------------------------------------------------------------------
// 2_03 崖心 (hidden) 束缚链: up to max_target enemies in the large front grid are dragged (force 1 = 中等) to the tile in front
// of her, take atk_scale × ATK true damage and are stunned `stun` s. 雪境猎手: ATK/DEF +atk/+def while not blocking.
// 2_03 崖心 (hidden) 束缚链: up to max_target enemies in the large front grid are dragged (force 1 = 中力; Battle.pullToFront:
// the official 力度 − 重量 pull — weight ≤ 1 all the way in front of her, 2 a third of the way, 3 barely, ≥ 4 not at all),
// take atk_scale × ATK true damage and are stunned `stun` s. 雪境猎手: ATK/DEF +atk/+def while not blocking.
// Elite module (HOK-X, trait value/dist): dragged enemies take `value` arts per `dist` tiles travelled.
chess_char_2_03_a: (bb, chess, def) => {
const t = talentBb(chess, 0);
@@ -147,11 +108,9 @@ export default {
kind: 'instant',
onStart({ battle, unit }) {
const foes = enemiesInGrid(battle, unit, def?.skill?.rangeGrid ?? null, { n: Math.max(1, Math.floor(num(bb.max_target, 1))) });
const fx0 = unit.tileC + unit.fwd[1], fy0 = unit.tileR + unit.fwd[0];
for (const e of foes) {
const sx = e.x, sy = e.y;
const dx = fx0 - e.x, dy = fy0 - e.y, dist = Math.hypot(dx, dy);
const moved = dist > 0.05 ? battle.displace(e, { x: dx, y: dy }, dist, { force: force + 1 }) : 0; // same force mapping as generic.js
const moved = battle.pullToFront(e, unit, force);
battle.fx('pull', { x: e.x, y: e.y, id: e.id, fromX: sx, fromY: sy });
if (moved > 0 && num(tb.value) > 0) {
battle.dealDamage(unit, e, { amount: num(tb.value) * moved / Math.max(0.01, num(tb.dist, 1)), type: 'arts', isSkill: true, canDodge: false, tags: ['drag'] });
@@ -698,7 +657,7 @@ export default {
battle.every(1, () => {
if (!up(unit)) return;
const amt = unit.s.atk * r;
for (const a of battle.allies()) if (a.hp < a.s.maxHp) battle.heal(unit, a, amt, { aura: true, tags: ['talent'] });
for (const a of battle.alliesFor(unit)) if (a.hp < a.s.maxHp) battle.heal(unit, a, amt, { aura: true, tags: ['talent'] });
}, { owner: unit });
} }],
};
+54 -67
View File
@@ -7,8 +7,9 @@
// Profession defaults (server/sim/professions.js) are kept and extended: fastshot fly bonus, instructor, reaperrange,
// hunter ammo, funnel ramp, phalanx guard, merchant drain, tactician reinforcement, underminer module weaken.
// Summons: the match hands 伺夜's 狼群 and 巫恋's 诅咒娃娃 to the player as placeable board pieces (tokens.js kits);
// these kits adopt/replace such pieces instead of adding a second pack/doll, and every summon tile avoids the home
// tile of an ally that has not (re)deployed yet (freeTile). Offensive skills whose own range differs from the attack
// 伺夜's kit adopts his pack piece instead of adding a second pack, 巫恋's S2 brings her placed doll onto its tile
// (tokens.js releaseSkillSummon), and every summon tile avoids the home tile of an ally that has not (re)deployed yet
// (freeTile). Offensive skills whose own range differs from the attack
// range trigger on that skill range (CUSTOM_RANGE: 松果 shortened range, 见行者 push row — research 03 §1.4).
//
// Client VFX (battle.fx kinds used here, all `{x, y, id, …extra}`): 'aoe' (radius, dmgType), 'strike' (special single
@@ -20,18 +21,19 @@
// selectable skill of the visible chess, each built from its OWN record at the chess's level (skillData: Lv4 normal /
// Lv7 elite blackboard, range, charges) — the loader picks the selected one. Talents / trait / install are shared: the
// parts that belong to one skill only (琳琅诗怀雅 bombs, 菲莱 counters, 雪猎 special bullets…) check the selected skill id.
// Triggers come from the data record of each skill (skills[i].trigger), except offensive skills with their own range
// (CUSTOM_RANGE on that range, research 03 §1.4) and 薄绿 S1 (阵法术师 技能1 row SEARCH = "初始攻击范围内出现敌人后自动释放",
// research 03 C1 ⇒ DEFAULT). Non-default modules: 能天使 MAR-Y (ASPD vs ground), 琳琅诗怀雅 MER-Y (ATK per payment),
// Triggers come from the data record of each skill (skills[i].trigger: tools/build-data.mjs resolveTrigger — the official
// 技能策略 incl. SKILL_RANGE for a MANUAL skill's own 技能范围 and the class rows for every MANUAL skill), except
// 薄绿 S1 (the 阵法术师 row SEARCH = "在初始攻击范围内存在敌人时" ⇒ the engine's DEFAULT for a phalanx, checked every
// tick) and the kits' own automatic casts (雪猎 special bullets). Non-default modules: 能天使 MAR-Y (ASPD vs ground), 琳琅诗怀雅 MER-Y (ATK per payment),
// 斯卡蒂 DRE-X (× vs blocked), 瑕光 GUA-X (heal × under 50 %), 伺夜 TAC-Y (×165 % trait, pack-blocked enemies taunt +1),
// 空弦 MAR-X (fly ×, profession layer). No in-battle effect here: 忍冬 SOL-Y "首次部署时部署费用-4" (the initial
// deployment is free) and the 集成战略-only ISW-A modules of 琳琅诗怀雅 / 空弦 (their stats and trait cost still apply).
import { absoluteRangeKeys, sortEnemyTargets, canTargetEnemy } from '../../targeting.js';
import { COLS } from '../../constants.js';
import { bodyDist, bodyInKeys, bodyOnTile, bodyTileReach } from '../../body.js';
import { COLS, PUSH_DIRECTIONAL_MIN_DIST } from '../../constants.js';
import { bodyDist, bodyInKeys, bodyOnTile } from '../../body.js';
import { normalizeChess, normalizeSkill } from '../../simdata.js';
import { tacticalPoint as sharedTacticalPoint } from '../tokens.js';
import { tacticalPoint as sharedTacticalPoint, releaseSkillSummon } from '../tokens.js';
// ---------------------------------------------------------------------------------------------------------------
// helpers
@@ -113,6 +115,8 @@ const copyGrid = (g) => (Array.isArray(g) && g.length ? g.map((p) => [p[0], p[1]
const NINE = [[1, -1], [1, 0], [1, 1], [0, -1], [0, 0], [0, 1], [-1, -1], [-1, 0], [-1, 1]];
/** Two enemy bodies touch within this distance (tiles) — 见行者 collision stun. */
const COLLIDE = 0.6;
/** 忍冬 S3's 迷彩 (until her next cast): its own buff key, never merged with another unit status. */
const CAMOU_KEY = 'vulpis:camou';
const CN_NUM = { 一: 1, 二: 2, 两: 2, 三: 3, 四: 4, 五: 5, 六: 6, 七: 7, 八: 8, 九: 9, 十: 10 };
/** Number captured by `re` (arabic or a single chinese numeral) in `text`, else `fallback`. */
function textNum(text, re, fallback) {
@@ -174,6 +178,7 @@ function aura(battle, unit, o) {
const list = o.side === 'ally' ? battle.allyUnits : battle.enemies;
for (const x of list) {
if (!x.alive || !x.deployed || x.hidden || x.kind === 'device') continue;
if (o.side === 'ally' && !battle.allySelectable(x, unit)) continue; // never a 孤立 unit (炎佑)
if (!onTiles(x, set) || (o.filter && !o.filter(x))) continue;
const mods = typeof o.mods === 'function' ? o.mods(x) : o.mods;
if (!mods) continue;
@@ -447,6 +452,7 @@ const KITS = {
let best = null;
for (const a of battle.allyUnits) {
if (a === unit || !alive(a) || a.hidden || a.kind === 'device' || a.hpRatio >= healRatio) continue;
if (a.s.flags.noHeal || a.profile?.noHeal) continue; // 禁疗 / 孤立: never a heal target
if (Math.max(Math.abs(a.tileR - unit.tileR), Math.abs(a.tileC - unit.tileC)) !== 1) continue; // 周围八格
if (!best || a.hpRatio < best.hpRatio || (a.hpRatio === best.hpRatio && a.deploySeq < best.deploySeq)) best = a;
}
@@ -486,7 +492,8 @@ const KITS = {
if (!e) break;
spent++;
battle.dealDamage(unit, e, { amount: unit.s.atk * cash, type: 'phys', isSkill: true, tags: ['skill', 'swire2Cash'] });
if (e.alive) battle.displace(e, { x: unit.fwd[1], y: unit.fwd[0] }, 0.5 + 0.5 * force, { force: force + 1 });
// "将目标小力地向前推开": a directional push along her direction (Battle.push, official 力度 − 重量 distance)
if (e.alive) battle.push(e, force, { from: unit, dir: { x: unit.fwd[1], y: unit.fwd[0] } });
}
fx(battle, 'coin', unit, { n: 0, spent, skill: 'swire2_3' });
},
@@ -719,10 +726,14 @@ const KITS = {
const keys = new Set(gridKeys(skillGrid ?? unit.rangeGrid, unit));
const victims = battle.enemies.filter((e) => e.alive && !e.hidden && !e.s.flags.untargetable && onTiles(e, keys));
const pushed = new Set(victims);
const dist = 0.5 + 0.5 * force; // engine displacement convention (docs/SIM.md §12)
for (const e of victims) {
const expect = e.isBoss ? 0 : dist * Math.max(0, Math.min(1, 1 - 0.25 * Math.max(0, e.s.massLevel - (force + 1))));
const moved = battle.displace(e, { x: unit.fwd[1], y: unit.fwd[0] }, dist, { force: force + 1 });
// "往身前方向…推开": a directional push whose angle is fixed — PRTS 备注 "不会因为角度过大而改变推动的方向或削减力度"
// (only the angle: a target nearer than 0.25 tile still turns radial at 受力等级 −2) — by the official
// 力度 − 重量 distance of a 特效 push (PRTS 推与拉 names 见行者's skills 特效类; Battle.push / pushDistance);
// stopped short of it ⇒ it hit a wall
const near = Math.hypot(e.x - unit.x, e.y - unit.y) < PUSH_DIRECTIONAL_MIN_DIST;
const expect = battle.pushDistance(e, near ? force - 2 : force, { effect: true });
const moved = battle.push(e, force, { from: unit, dir: { x: unit.fwd[1], y: unit.fwd[0] }, fixedAngle: true, effect: true });
const wall = expect > 0 && moved + 0.05 < expect;
battle.applyStatus(e, 'stun', { duration: wall ? sWall : sDirect, source: unit });
}
@@ -735,9 +746,8 @@ const KITS = {
}
fx(battle, 'push', unit, { skill: 'forcer_2', n: victims.length });
},
// "立即将范围内所有敌人…": fires once an enemy is inside the skill range (research 03 §1.4), not only when one
// stands on her 2-tile attack range
...(skillGrid ? { trigger: { rule: 'CUSTOM_RANGE', grid: skillGrid } } : {}),
// "立即将范围内所有敌人…": the data rule SKILL_RANGE fires it once an enemy is inside the skill range (PRTS 技能
// 策略 "仅在技能范围内存在敌人(无视其不可选中)时释放技能"), not only when one stands on her 2-tile attack range
},
talents: [{ install(battle, unit) {
battle.on('hit', (ctx) => {
@@ -758,14 +768,14 @@ const KITS = {
return kit;
},
// ---- 3_08 薄绿 · 阵法术师 — S2 聚能涡旋: each hit pulls the target in front of her (splash arts), end-of-skill burst on
// ---- 3_08 薄绿 · 阵法术师 — S2 聚能涡旋: each hit pushes the target towards her (splash arts), end-of-skill burst on
// every enemy in range; 地质学者: DEF aura (skill off) / less likely targeted (skill on);
// 精锐 module PLX-X: keeps part of the guard (DEF/RES) while the skill runs
// S1 风语: wider range (skill grid), attacks at attack@atk_scale (群体 arts splash of the trait). Auto-cast: the data
// rule SEARCH is the 阵法术师 技能1 row, officially "初始攻击范围内出现敌人后自动释放" (research 03 Addendum C1,
// BWIKI) = an enemy inside her INITIAL range — the engine's DEFAULT rule, checked every tick for a unit that does
// not attack while its skill is off — not any enemy on the field (the sim's generic SEARCH, research 03 §1.4
// [ASSUMED]): she would burn the skill on enemies that just spawned.
// rule SEARCH is the 阵法术师 row (PRTS 卫戍协议/帮助 "不受基础策略影响,在初始攻击范围内存在敌人时释放技能"; it
// covers every MANUAL skill of the class — user playtest #6) = an enemy inside her INITIAL range — the engine's
// DEFAULT rule, checked every tick for a unit that does not attack while its skill is off — not any enemy on the
// field: she would burn the skill on enemies that just spawned.
chess_char_3_08_a: (bb, chess, def) => {
const d = defOf(chess, def);
const t0 = talentBb(d, 0);
@@ -805,10 +815,9 @@ const KITS = {
atkScale: num(bb['attack@atk_scale'], 1),
onHit({ battle, unit, target }) {
if (!target || !target.alive || target.side !== 'enemy') return;
const dx = unit.tileC + unit.fwd[1] - target.x, dy = unit.tileR + unit.fwd[0] - target.y;
const len = Math.hypot(dx, dy);
if (len < 0.05) return;
if (battle.displace(target, { x: dx, y: dy }, Math.min(len, 0.5 + 0.5 * pullForce), { force: pullForce + 1 }) > 0) fx(battle, 'pull', target, { src: unit.id });
// PRTS 备注: "此技能的“拖拽”机制实际为反方向(指向薄绿方向)的推开" — a radial push towards her by the
// official 力度 − 重量 push distance (小力 vs weight 1: 0.44 tiles), never past her (Battle.push inward)
if (battle.push(target, pullForce, { from: unit, inward: true }) > 0) fx(battle, 'pull', target, { src: unit.id });
},
},
onStart: guardOn,
@@ -976,7 +985,7 @@ const KITS = {
if (sel === S2 || sel == null) {
battle.on('tick', () => {
const sk = unit.skill;
if (!alive(unit) || !unit.canAct || !sk || !sk.ready || unit.s.flags.silence || (unit.trait.ammo ?? 1) > 0) return;
if (!alive(unit) || !unit.canAct || !sk || !sk.ready || sk.opCooling || unit.s.flags.silence || (unit.trait.ammo ?? 1) > 0) return;
if (battle.enemiesInKeys(unit.baseRangeKeys || unit.rangeKeys, unit, unit.profile).length) sk.activate('DEFAULT');
}, { owner: unit });
}
@@ -994,7 +1003,7 @@ const KITS = {
// ---- 3_12 瑕光 · 守护者 — S3 先贤化身: ATK/DEF +, bonus arts per hit, heals another nearby ally per attack;
// 剑盾骑士: hurt-SP skills of the team also gain SP on attack; 仁慈: attacks sleeping enemies (first, ×atk_scale);
// 精锐 module GUA-Y: damage taken −15 %
// S1 光芒涌动 (TAKE_DAMAGE, charges): next attack ×atk_scale phys + heals the most injured ally of the 3×3 (herself
// S1 光芒涌动 (自动触发 ⇒ DEFAULT, charges): next attack ×atk_scale phys + heals the most injured ally of the 3×3 (herself
// included) for heal_scale × ATK; S2 慑敌辉光: ATK +, puts every ground enemy on her own tile and every enemy she
// blocks to sleep (PRTS 备注; for the skill's duration: no own value in the data) and heals every ally of the
// skill range by ATK × ratio each second;
@@ -1176,47 +1185,18 @@ const KITS = {
return kit;
},
// ---- 3_15 巫恋 · 削弱者 (hidden) — S2 诅咒娃娃: places the doll (auto-placed on the tile of the range covering most
// enemies; replaces any doll of hers still standing — the doll is never a prep hand card) whose 3×3 aura lowers
// enemy ATK/DEF, gone after 15 s; 溃败暗示: low-HP enemies fragile
// ---- 3_15 巫恋 · 削弱者 (hidden) — S2 诅咒娃娃 "获得一个诅咒娃娃(最多可库存1个)": the doll is a hand piece the player
// places (user playtest #6; PRTS 卫戍协议/帮助); each cast gives one and the placed piece takes the field on its
// own tile (tokens.js releaseSkillSummon: not at the battle start; not placed ⇒ no doll); its token kit keeps the
// 3×3 ATK/DEF aura (the token skill's bb) for 15 s; 溃败暗示: low-HP enemies fragile
chess_char_3_15_a: (bb, chess, def) => {
const d = defOf(chess, def);
const t0 = talentBb(d, 0);
const tokenId = chess?.skill?.overrideTokenKey ?? (d.tokens || []).find((t) => /doll/.test(String(t))) ?? 'token_10006_vodfox_doll';
const debuff = { atkPct: num(bb.atk), defPct: num(bb.def) };
const dollKit = () => ({
skill: null,
trait: { noAttack: true },
install(battle, doll) {
aura(battle, doll, { key: 'skill:vodfox_doll', side: 'enemy', interval: 0.1, tiles: () => doll.rangeKeySet, mods: debuff });
},
});
return {
skill: {
kind: 'instant',
onStart({ battle, unit }) {
const tok = battle.tokenDef(tokenId, unit); // the owner's skill / module variant (DESIGN §16)
const life = num(tok?.skill?.duration, 0) > 0 ? tok.skill.duration : textNum(d.skill?.description, /(\d+)秒后自动销毁/, 15);
const grid = tok?.rangeGrid ?? NINE;
const reach = Math.max(1, ...grid.map((p) => Math.max(Math.abs(p[0]), Math.abs(p[1]))));
// "最多可库存1个" / deploy limit 1: the new doll replaces any doll of hers still standing — withdrawn first
// so its tile is available again
for (const t of tokensOf(battle, unit, tokenId)) if (t.alive) battle.retreat(t, { reason: 'expired', permanent: true });
const foes = battle.aliveEnemies().filter((e) => !e.hidden);
let best = null, bs = -1, bd = Infinity;
for (const k of unit.rangeKeys || []) {
const r = (k / COLS) | 0, c = k % COLS;
if (!freeTile(battle, r, c) || !battle.grid.canStand(r, c, { ranged: true })) continue;
let s = 0;
for (const e of foes) if (bodyTileReach(e, r, c) <= reach) s++;
const dd = Math.abs(r - unit.tileR) + Math.abs(c - unit.tileC);
if (s > bs || (s === bs && dd < bd)) { best = [r, c]; bs = s; bd = dd; }
}
if (!best) return;
const doll = battle.spawnToken(unit, tokenId, best[0], best[1], { duration: life, untargetable: true, kit: dollKit() });
unit.mem.doll = doll;
if (doll) fx(battle, 'summon', doll, { src: unit.id, token: tokenId });
},
onStart({ battle, unit }) { releaseSkillSummon(battle, unit, tokenId); },
},
talents: [{ install(battle, unit) {
aura(battle, unit, {
@@ -1283,10 +1263,15 @@ const KITS = {
},
// ---- 3_18 忍冬 · 尖兵 — S3 隐狐之艺: +DP, range +1, ATK +, decaying ASPD, hits all blocked, 0.2 s stun per hit,
// camouflage after a kill until the next cast; 追凶: bonus arts on marked enemies; 蓄势: DP regen + out-of-combat
// after a kill during the skill 迷彩 from its end until the next cast ("进入迷彩状态,直至下一次开启技能") — the
// `camou` status under its own key (ba.camou "不阻挡时不成为敌方普通攻击的目标": only the enemy she blocks attacks
// her, as with 隐匿; it used to be the generic `stealth` = 隐匿 under the key `stealth`, shared with 伪装服's timed
// 隐匿, which the unending one then made permanent, and it counted as 隐匿 for 叙拉古 / 家族徽章 — user playtest #6
// "叙拉古阵营隐身不会结束": the bond's 隐匿 ends on time, this 迷彩 officially lasts); 追凶: bonus arts on marked
// enemies; 蓄势: DP regen + out-of-combat
// regen; 精锐 module SOL-X: ATK/DEF + while blocking
// S1 小施惩戒 (charges): next attack + extra_damage_ratio × ATK arts and +cost DP; S2 坠刃拷问 (charges, fires
// once an enemy is inside its own range 3-12): +cost DP, ≤ max_target enemies of that range take atk_scale arts and
// S1 小施惩戒 (charges): next attack + extra_damage_ratio × ATK arts and +cost DP; S2 坠刃拷问 (charges, the data rule
// SKILL_RANGE: fires once an enemy is inside its own range 3-12, charges 3 s apart): +cost DP, ≤ max_target enemies of that range take atk_scale arts and
// 停顿 — the ones already 停顿 are also stunned. (SOL-Y "首次部署时部署费用-4": the initial deployment is free.)
chess_char_3_18_a: (bb, chess, def) => {
const d = defOf(chess, def);
@@ -1300,7 +1285,7 @@ const KITS = {
mods: { atkPct: num(bb.atk) },
attack: { hitAllBlocked: true, onHitStatus: { key: 'stun', duration: num(bb['attack@stun'], 0.2) } },
onStart({ battle, unit, skill }) {
battle.removeStatus(unit, 'stealth');
battle.removeBuff(unit, CAMOU_KEY);
unit.mem.vulpisKill = false;
const dp = num(bb.cost, 0);
if (dp > 0) { battle.addDp(unit.ownerId, dp); fx(battle, 'dp', unit, { n: dp }); }
@@ -1315,7 +1300,7 @@ const KITS = {
onEnd({ battle, unit, reason }) {
battle.removeBuff(unit, 'skill:vulpis_aspd');
if (reason !== 'death' && unit.alive && unit.mem.vulpisKill) {
battle.applyStatus(unit, 'stealth', { source: unit });
battle.addBuff(unit, { key: CAMOU_KEY, flags: { camou: true }, status: 'camou', source: unit });
fx(battle, 'camouflage', unit);
}
},
@@ -1329,12 +1314,11 @@ const KITS = {
if (dp > 0) { battle.addDp(unit.ownerId, dp); fx(battle, 'dp', unit, { n: dp }); }
} },
}),
skchr_vulpis_2: (s) => { // 坠刃拷问 — air units too (PRTS 备注 "※可对空")
skchr_vulpis_2: (s) => { // 坠刃拷问 — air units too (PRTS 备注 "※可对空"); data trigger SKILL_RANGE (its 3-12 range)
const grid = copyGrid(s.rangeGrid);
const n = Math.max(1, Math.floor(num(s.bb.max_target, 6)));
return {
kind: instantKindOf(s),
...(grid ? { trigger: { rule: 'CUSTOM_RANGE', grid } } : {}),
onStart({ battle, unit }) {
const dp = num(s.bb.cost, 0);
if (dp > 0) { battle.addDp(unit.ownerId, dp); fx(battle, 'dp', unit, { n: dp }); }
@@ -1575,8 +1559,11 @@ const KITS = {
},
},
skills: altSkills(chess, d, bb, {
// (自动触发: an AUTO skill takes no 技能策略 — the 战术家 row is for MANUAL skills — and this DP skill fires as soon
// as it is ready, as before)
skchr_vigil_1: (s) => ({
kind: 'instant',
trigger: 'SP_FULL',
onStart({ battle, unit }) {
dpGain(battle, unit, num(s.bb.cost, 0));
const w = wolfOf(unit);
+54 -144
View File
@@ -3,7 +3,7 @@
// (精锐 `_b`) id: `bb` is the Lv4 / Lv7 skill blackboard, talent/trait blackboards come from `def` (module talent and
// trait upgrades of elites are already merged into the data), `def.raw.module.active` tells whether the module runs.
// Every number comes from a blackboard (skill `bb`, `def.talents[i].bb`, `def.traitBb`, token data); literals below
// are only fall-backs for missing keys or documented [ASSUMED] shapes (tornado radius, device placement …).
// are only fall-backs for missing keys or documented [ASSUMED] shapes (tornado radius …).
//
// Notes shared by several kits:
// - `base_attack_time` in skill blackboards is a FLAT change of the base attack time in seconds (白面鸮 −1.8 on 2.85 s
@@ -30,7 +30,7 @@ import { bodyDist, bodyInKeys } from '../../body.js';
import { absoluteRangeKeys, canTargetEnemy, sortEnemyTargets } from '../../targeting.js';
import { normalizeChess } from '../../simdata.js';
import { aggregateMods } from '../../buffs.js';
import { dirFromDelta, frontOf, offsetTile } from '../../dir.js';
import { CAT_SHIELD_KEY } from '../tokens.js';
const TICK_EPS = 0.01; // minimal status duration (s)
const AURA = 0.2; // aura refresh period (s)
@@ -53,24 +53,6 @@ const nationOf = (u) => u?.def?.raw?.nationId ?? null;
const isAbyssal = (u) => u?.def?.raw?.groupId === 'abyssal' || ABYSSAL.has(u?.def?.charId ?? u?.def?.raw?.charId);
const enemyHasTag = (e, tag) => !!e && e.side === 'enemy' && Array.isArray(e.def?.tags) && e.def.tags.includes(tag);
const keySet = (unit) => unit.rangeKeySet || new Set(unit.rangeKeys || []);
/**
* The tokens.json variant a summon of `owner` gets — its chess (else the normal `_a` sibling, else the first variant)
* with the owner's selected skill / module merged, as simdata getToken does — for fields normalizeToken drops
* (stats.deployLimit). Exact when two players of one field give the same chess different loadouts (DESIGN §16).
*/
function ownTokenVariant(battle, tokenId, owner) {
const vs = battle.tokenDef(tokenId, owner)?.raw?.variants;
if (!vs || typeof vs !== 'object') return null;
const oid = String(owner?.defId ?? '');
let v = vs[oid] ?? vs[oid.replace(/_b$/, '_a')] ?? Object.values(vs)[0] ?? null;
const lo = owner?.def?.loadout;
if (v && lo && !lo.isDefault) {
if (!lo.skillIsDefault && v.bySkill?.[lo.skillIndex]) v = { ...v, ...v.bySkill[lo.skillIndex] };
if (!lo.moduleIsDefault && v.byModule?.[lo.moduleId]) v = { ...v, ...v.byModule[lo.moduleId] };
}
return v;
}
/** Every living, visible enemy standing on the unit's range (ignores stealth/untargetable: auras, reveals). */
function enemiesOnRange(battle, unit, keys = null) {
const set = keys || keySet(unit);
@@ -102,7 +84,7 @@ function toggleBuff(battle, unit, key, on, mods) {
/** "技力光环(同类效果取最高)": shared key, highest value wins. */
function spAura(battle, source, value, filter) {
whileDeployed(battle, source, AURA, () => {
for (const a of battle.allies(source.ownerId)) {
for (const a of battle.alliesFor(source, source.ownerId)) {
if (!filter(a)) continue;
const cur = a.findBuff('aura:spRecovery');
if (cur && cur.source !== source && cur.source?.alive && (cur.data?.v ?? 0) > value) continue;
@@ -128,16 +110,12 @@ function inspire(battle, target, val, src, stat = 'atk') {
}
/** Reveal stealthed enemies on the given tiles. */
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 / pull (0.5 + 0.5·force tiles, like generic.js). `toward` = {x,y} point for pulls. */
function shove(battle, e, from, force, { toward = null, maxDist = Infinity } = {}) {
if (!e.alive) return 0;
const f = Math.max(0, num(force, 0));
const dx = toward ? toward.x - e.x : e.x - from.x, dy = toward ? toward.y - e.y : e.y - from.y;
const len = Math.hypot(dx, dy);
if (!(len > 1e-6)) return 0;
const dist = Math.min(0.5 + 0.5 * f, maxDist);
if (!(dist > 0)) return 0;
return battle.displace(e, { x: dx, y: dy }, dist, { force: f + 1 });
/**
* Knock-back of 力度 `force` away from `from` (a radial push, Battle.push: the official 力度 − 重量 distance — PRTS 推与拉;
* user playtest #6 item 14). Returns the tiles moved.
*/
function shove(battle, e, from, force) {
return e && e.alive ? battle.push(e, num(force, 0), { from }) : 0;
}
/** Free tile for a summon/device: in the rect, standable, empty and not the home tile of any ally (dead ones redeploy there). */
function freeTile(battle, r, c, { ranged = false, ground = false } = {}) {
@@ -203,12 +181,12 @@ function moduleRangeGrid(def) {
return null;
}
const applyModuleRange = (battle, unit, def) => { const g = moduleRangeGrid(def); if (g) { unit.rangeGrid = g; battle.refreshRange(unit); } };
/** Pull `e` towards the unit, to the tile in front of it ("拖拽至面前"); returns the distance moved. */
/**
* Pull `e` "至面前" of the unit with 力度 `force` (Battle.pullToFront: the official 拉力起点 half a tile ahead, 急停 0.6708
* around it, 力度 − 重量 — weight ≤ force all the way, one heavier a third of the way, …); returns the distance moved.
*/
function pullToFront(battle, unit, e, force) {
if (!e || !e.alive) return 0;
const d = Math.hypot(unit.x - e.x, unit.y - e.y);
if (!(d > 1 + 1e-6)) return 0;
return shove(battle, e, unit, force, { toward: { x: unit.x, y: unit.y }, maxDist: d - 1 });
return e && e.alive ? battle.pullToFront(e, unit, num(force, 0)) : 0;
}
// ---------------------------------------------------------------------------------------------------------------
@@ -216,7 +194,7 @@ function pullToFront(battle, unit, e, force) {
const kits = {
// ===== 信仰搅拌机 (shotprotector) S3 退休前布道 — ammo 30 counters; talents 扫射迎宾仪礼 / 架盾送客仪礼; module: reveal
// S1 铳骑主考官 (TAKE_DAMAGE: next attack ×3 hits + reload an adjacent 拉特兰 ammo skill), S2 八臂电锯侠 (ammo;
// S1 铳骑主考官 (自动触发 ⇒ DEFAULT: next attack ×3 hits + reload an adjacent 拉特兰 ammo skill), S2 八臂电锯侠 (ammo;
// a fatal hit is blocked for `ammo_cost` bullets); module SPT-Y 老朋友: range +1
chess_char_4_01_a: (bb, chess, def) => {
const t0 = tbb(def, 0), t1 = tbb(def, 1);
@@ -655,8 +633,7 @@ const kits = {
},
// ===== 寒芒克洛丝 (fastshot) S2 封喉 — double shot → 4 shots after 40 hits; talent 中的 (20 % ×1.5 + 0.2 s stun)
// S1 无痕 (ATK up, double shot, 迷彩 = the engine's ally `stealth`: ranged enemies cannot target her unless she
// blocks them)
// S1 无痕 (ATK up, double shot, 迷彩 = the engine's `camou` flag: only an enemy she blocks targets her)
chess_char_4_06_a: (bb, chess, def) => {
const t0 = tbb(def, 0);
const need = num(bb['attack@max_stack_count'], 40);
@@ -666,7 +643,7 @@ const kits = {
skchr_kroos2_1: () => ({
kind: 'duration',
mods: { atkPct: num(bb.atk) },
flags: { stealth: true },
flags: { camou: true },
attack: { hits: 2 },
onStart({ battle, unit }) { battle.fx('camouflage', { x: unit.x, y: unit.y, id: unit.id }); },
}),
@@ -902,108 +879,35 @@ const kits = {
},
// ===== 凯瑟琳 (craftsman) S2 战火淬炼 — stops attacking, HP/DEF up, devices give 6 %/s shields; talent 定向支援信号
// [ASSUMED] the devices are not board pieces in this mode (data `placeable: false`): at battle start (and when she
// redeploys, while stock lasts) she auto-places up to deployLimit of them on a free tile orthogonally next to her best
// blockers, each pointing at that operator's tile.
// "携带3个支援装置(最多部署2个)": the 爬行号·防护单元 are hand pieces (2 = the deploy limit) the player places and
// turns towards an operator (user playtest #6, PRTS 卫戍协议/帮助 §战斗部署); they deploy with the board and the
// token kit (tokens.js catShield) gives the shields — S2 overwrite_ratio per second while this skill runs — so
// this kit places nothing (none placed ⇒ no device).
// S1 岁月锻打 (passive: she and every other operator holding a device shield ATK/DEF +8 %/+11 %); module CRA-X
// carries one more device (talent cnt, data)
chess_char_4_11_a: (bb, chess, def) => {
const t0 = tbb(def, 0);
const tokenId = (def.talents || []).find((t) => t.tokenKey)?.tokenKey ?? (def.tokens || [])[0] ?? 'token_10041_cathy_catsld';
const carried = Math.max(0, Math.floor(num(t0.cnt, 3)));
const overwrite = num(bb.overwrite_ratio, 0.06);
const S2 = isSel(def, 'skchr_cathy_2');
return {
skills: alt(def, {
skchr_cathy_1: () => ({
kind: 'passive',
mods: { atkPct: num(bb.s1_atk), defPct: num(bb.s1_def) },
onStart({ battle, unit }) {
unit.mem.forgeAura?.cancel();
unit.mem.forgeAura = whileDeployed(battle, unit, AURA, () => {
for (const a of battle.allies(unit.ownerId)) {
if (a === unit || a.kind !== 'op' || !a.findBuff('cathy:shield')) continue;
pulse(battle, a, `cathy:forge:${unit.id}`, { atkPct: num(bb.s1_atk), defPct: num(bb.s1_def) });
}
});
},
}),
// carries one more device (talent cnt, data — a spare the hand never shows)
chess_char_4_11_a: (bb, chess, def) => ({
skills: alt(def, {
skchr_cathy_1: () => ({
kind: 'passive',
mods: { atkPct: num(bb.s1_atk), defPct: num(bb.s1_def) },
onStart({ battle, unit }) {
unit.mem.forgeAura?.cancel();
unit.mem.forgeAura = whileDeployed(battle, unit, AURA, () => {
for (const a of battle.allies(unit.ownerId)) {
if (a === unit || a.kind !== 'op' || !a.findBuff(CAT_SHIELD_KEY)) continue;
pulse(battle, a, `cathy:forge:${unit.id}`, { atkPct: num(bb.s1_atk), defPct: num(bb.s1_def) });
}
});
},
}),
skill: {
kind: 'duration',
mods: { hpPct: num(bb.max_hp), defPct: num(bb.def) },
attack: { noAttack: true },
onStart({ battle, unit }) { battle.fx('overclock', { x: unit.x, y: unit.y, id: unit.id }); },
},
install(battle, unit) {
const tok = battle.tokenDef(tokenId, unit); // the owner's skill / module variant (DESIGN §16)
const tb = tok?.talents?.[0]?.bb || {};
const cap = num(tb.max_shield_ratio, 0.2), each = num(tb.shield_ratio_each_trigger, 0.06);
const idle = num(tb.interval, 5), period = Math.max(0.1, num(tb['catsld_t_1[timer][interval].interval'], num(bb.interval, 1)));
const limit = Math.max(1, Math.floor(num(ownTokenVariant(battle, tokenId, unit)?.stats?.deployLimit, num(tok?.raw?.deployLimit, 2))));
unit.mem.devicesLeft = carried;
unit.mem.devices = [];
// a device points at ONE tile (AK: range [0,0],[0,1] turned towards the chosen operator) and shields whichever
// of her operators stands there — the same operator again after it redeploys on its tile ("装置效果不叠加":
// one device per operator)
const covered = (a) => unit.mem.devices.some((d) => d.alive && d.mem.tr === a.tileR && d.mem.tc === a.tileC);
const giveShield = (target, amount, capAmt) => {
const cur = target.findBuff('cathy:shield');
const v = Math.min(capAmt, (cur ? cur.shield : 0) + amount);
if (!cur || v > cur.shield + 1e-9) battle.addBuff(target, { key: 'cathy:shield', shield: v, visible: true, source: unit });
};
const deviceKit = (tr, tc) => ({
skill: null, talents: [], trait: { noAttack: true },
install(b, dev) {
dev.mem.tr = tr; dev.mem.tc = tc;
dev.mem.target = null;
// token trait "不会受到攻击": untargetable (spawn option) AND immune — enemy AoE abilities that sweep every ally
// in a radius would otherwise destroy the 100-HP device and silently end its shields
b.addBuff(dev, { key: 'cathy:device', flags: { invulnerable: true }, persist: true, allowDead: true });
b.every(period, () => {
if (!dev.alive) return;
const o = b.unitAt(tr, tc);
const t = o && o.kind === 'op' && o.ownerId === dev.ownerId && o.deployed ? o : null;
if (t !== dev.mem.target) { // newly connected operator: full talent shield at once
dev.mem.target = t;
if (t) { giveShield(t, unit.s.maxHp * cap, unit.s.maxHp * cap); b.fx('shield', { x: t.x, y: t.y, id: t.id }); }
return;
}
if (!t) return;
const capAmt = unit.s.maxHp * cap;
if (S2 && unit.alive && skillActive(unit)) giveShield(t, unit.s.maxHp * overwrite, capAmt);
else if (b.time - t.lastHitAt >= idle - 1e-9) giveShield(t, unit.s.maxHp * each, capAmt);
}, { owner: dev, immediate: true });
},
});
const place = () => {
if (!unit.alive || !unit.deployed) return;
unit.mem.devices = unit.mem.devices.filter((d) => d.alive);
// front-most on the player's own board first (a board position; the mirrored Final Assault side counts from
// the field's right)
const boardCol = (x) => (x.player && x.player.mirror ? -x.tileC : x.tileC);
const cands = battle.allies(unit.ownerId).filter((a) => a.kind === 'op' && !covered(a))
.sort((a, b) => (b.s.blockCnt > 0) - (a.s.blockCnt > 0) || b.s.taunt - a.s.taunt || boardCol(b) - boardCol(a) || a.id - b.id);
for (const a of cands) {
if (unit.mem.devices.length >= limit || unit.mem.devicesLeft <= 0) break;
const r = a.tileR, c = a.tileC;
// orthogonal neighbours only (a diagonal device could never have the operator in its range): behind the
// operator first (along its direction), then its two sides, then in front; each device faces the operator
const tiles = [frontOf(r, c, a.dir, -1), offsetTile(r, c, -1, 0, a.dir), offsetTile(r, c, 1, 0, a.dir), frontOf(r, c, a.dir, 1)];
const t = tiles.find(([rr, cc]) => freeTile(battle, rr, cc, { ranged: true }));
if (!t) continue;
const dev = battle.spawnToken(unit, tokenId, t[0], t[1], { kit: deviceKit(r, c), untargetable: true, dir: dirFromDelta(r - t[0], c - t[1]) });
if (!dev) continue;
unit.mem.devicesLeft--;
unit.mem.devices.push(dev);
battle.fx('device', { x: dev.x, y: dev.y, id: dev.id, target: a.id });
}
};
battle.on('battleStart', place, { owner: unit });
battle.on('deploy', (c) => { if (c.unit === unit && !c.initial) place(); }, { owner: unit });
},
};
},
}),
skill: {
kind: 'duration',
mods: { hpPct: num(bb.max_hp), defPct: num(bb.def) },
attack: { noAttack: true },
onStart({ battle, unit }) { battle.fx('overclock', { x: unit.x, y: unit.y, id: unit.id }); },
},
}),
// ===== 歌蕾蒂娅 (hookmaster) S3 缺水的碎漩狂舞 — bind a far target, tornado: slow, 85 % arts pulses + pull, final pull
// S1 缺水的大洋裂断 (charges: next attack pulls the target to her front, 150 %/180 %); S2 缺水的掌握怒海 (BAT +0.5 s,
@@ -1060,8 +964,8 @@ const kits = {
for (const e of inside) {
if (!e.alive || e.s.flags.untargetable) continue;
battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.atk_scale, 0.85), type: 'arts', isSkill: true, tags: ['skill', 'tornado'] });
const d = Math.hypot(T.x - e.x, T.y - e.y);
if (e.alive && d > 0.05) dragDmg(battle, unit, e, shove(battle, e, unit, force, { toward: T, maxDist: d }));
// "小力地拖拽至中心": a pull to the marked point (PRTS 推与拉: its 6 tornado pulls stop 0.05 from it)
if (e.alive) dragDmg(battle, unit, e, battle.pull(e, force, { to: T, stop: 0.05 }));
}
battle.fx('tornadoPulse', { x: T.x, y: T.y, id: unit.id, r: R });
},
@@ -1141,6 +1045,7 @@ const kits = {
battle.on('beforeAttack', (c) => {
if (c.attacker !== unit || !skillActive(unit)) return;
const cands = battle.enemiesInKeys(unit.rangeKeys, unit, c.profile);
for (const e of battle.blockedTargets(unit, c.profile)) if (!cands.includes(e)) cands.push(e); // DESIGN §20.3
if (cands.length <= 1) return;
sortEnemyTargets(battle, unit, cands, c.profile?.priority ?? null);
const n = Math.max(1, Math.floor((c.profile?.maxTargets || 1) + unit.s.maxTargets));
@@ -1506,8 +1411,10 @@ const kits = {
const g = grid(def.skill?.rangeGrid);
return {
skills: alt(def, {
// (自动触发: an AUTO skill takes no 技能策略 — an AUTO DP skill fires as soon as it is ready, like 伺夜 S1)
skchr_flamtl_1: () => ({
kind: 'instant',
trigger: 'SP_FULL',
onStart({ battle, unit }) {
battle.addDp(unit.ownerId, num(bb.cost, 6));
battle.addBuff(unit, { key: 'flamtl:evade', visible: true });
@@ -1526,7 +1433,7 @@ const kits = {
if (e.alive) battle.applyStatus(e, 'stun', { duration: num(bb.stun, 0.5), source: unit });
}
for (const a of battle.unitsInGrid(unit, area, { side: 'ally' })) {
if (a.kind === 'device') continue;
if (a.kind === 'device' || !battle.allySelectable(a, unit)) continue;
battle.addBuff(a, { key: 'flamtl:redPine', duration: num(bb['flamtl_s_2.duration'], 10), mods: { dodgePhys: num(bb['flamtl_s_2.prob'], 0.4) }, visible: true, source: unit });
}
battle.fx('aoe', { x: unit.x, y: unit.y, id: unit.id });
@@ -1651,7 +1558,9 @@ const kits = {
const own = new Set(absoluteRangeKeys(unit.rangeGrid, unit.tileR, unit.tileC, unit.dir, unit.s.rangeExtend));
const ok = (e) => bodyInKeys(e, own) || !!(e.blockedBy && e.blockedBy.side === 'ally' && e.blockedBy.alive);
if (c.targets.every(ok)) return;
const cands = battle.enemiesInKeys(unit.rangeKeys, unit, c.profile).filter(ok);
const cands = battle.enemiesInKeys(unit.rangeKeys, unit, c.profile);
for (const e of battle.blockedTargets(unit, c.profile)) if (!cands.includes(e)) cands.push(e); // DESIGN §20.3
for (let i = cands.length - 1; i >= 0; i--) if (!ok(cands[i])) cands.splice(i, 1);
sortEnemyTargets(battle, unit, cands, c.profile?.priority ?? null);
c.targets = cands.slice(0, Math.max(1, c.targets.length));
}, { owner: unit, priority: 5 });
@@ -1935,6 +1844,7 @@ const kits = {
skills: alt(def, {
skchr_cetsyr_1: () => ({
kind: 'toggle',
trigger: 'SP_FULL', // 自动触发 (no 技能策略: the 吟游者 row is for MANUAL skills): on as soon as it is ready
onStart({ battle, unit }) { setTrait(unit, bb['attack@atk_to_hp_recovery_ratio']); battle.fx('mote', { x: unit.x, y: unit.y, id: unit.id }); },
onEnd({ unit }) { resetTrait(unit); },
}),
+40 -55
View File
@@ -19,10 +19,13 @@
// - Non-stacking auras refresh a short buff with a fixed key every 0.25 s while the source is on the field.
// SP auras ("同类效果取最高") share the buff key `aura:spRecovery` (mods.spRecoveryFlat): the highest value wins.
// - Skills whose auto-cast needs a condition the engine rules can't express use the NEVER trigger (CUSTOM_RANGE with an
// empty grid) plus their own activation — 塞雷娅 (`autoCast`: an injured ally in the heal area of the skill; her
// initial range is her own tile), 华法琳 (heal target below half HP, checked right before the heal), 号角 S1 / 塞雷娅 S1
// (the data TAKE_DAMAGE rule, `hurtCast`: a hit arms one charge only while none is waiting for its attack). Every other kit
// keeps the data rule (DEFAULT = about to attack + an enemy in the INITIAL range, research 03 Addendum C1).
// empty grid) plus their own activation — 塞雷娅 S2 (`autoCast`: an injured ally in the heal area of the skill; her
// initial range is her own tile), 华法琳 (heal target below half HP, checked right before the heal). 塞雷娅 S1 (自动触发)
// uses the engine's DEFAULT rule with `allies` / `hpAtMost` (about to attack + an ally of the skill area at ≤ half HP:
// the heal replaces that attack). Every other
// kit keeps the data rule (tools/build-data.mjs resolveTrigger, the official 技能策略: DEFAULT = about to attack + an
// enemy in the INITIAL range; SKILL_RANGE for a MANUAL skill's own 技能范围; the class rows — 重装 TAKE_DAMAGE … — for
// every MANUAL skill; AUTO skills never take a class row).
// - fx kinds emitted (battle.fx(kind, {x, y, id, …})): 'aoe' {r, skill}, 'healAoe' {r}, 'summon' {token}, 'anchor'
// {fromX, fromY, r}, 'teleport', 'zone' {r, duration}, 'iceSpike' {r}, 'extraAttack', 'downed', 'revive' {r},
// 'overload', 'ember', 'bloodBattle', 'reborn', 'candle', 'wake' {scale}, 'mote', 'hpShare', 'knockout', 'crit',
@@ -35,7 +38,7 @@ import { frontOf, rotateOffset, toLocal } from '../../dir.js';
import { mitigate, hasHp } from '../../damage.js';
// ---- text-only constants (the official blackboards carry no key for these) --------------------------------------
/** 华法琳 S1 "只当目标生命值不满一半时才会触发"; 山 module "生命值高于50%时". */
/** 华法琳 S1 "只当目标生命值不满一半时才会触发"; 塞雷娅 S1 "血量小于等于一半"; 山 module "生命值高于50%时". */
const HALF_HP = 0.5;
/** 夕 S1 "下一次攻击溅射范围扩大" — expanded splash radius (tiles; the splash-caster default is 1.1). PRTS: "溅射半径扩大至1.7". */
const DUSK_SPLASH_RADIUS = 1.7;
@@ -118,8 +121,6 @@ const skillRange = (chess, def, extra = null) => {
const g = skillGrid(chess, def);
return g || extra ? { ...(g ? { rangeGrid: g } : {}), ...(extra || {}) } : undefined;
};
/** Push distance of an AK force level (same mapping as content/generic.js: 中等力度 = 1 → 1 tile). */
const pushTiles = (force) => 0.5 + 0.5 * force;
/** "攻击范围内存在N名及以上敌人时攻击速度+X" (modules REA-Y). */
function crowdAspd(battle, unit, key, aspd, cnt) {
if (!aspd || !(cnt > 0)) return;
@@ -179,21 +180,6 @@ function autoCast(battle, unit, cond) {
}, { owner: unit });
}
/**
* TAKE_DAMAGE auto-cast of a "下次攻击" skill with charges (号角 S1, 塞雷娅 S1 — spec trigger NEVER): a hit taken arms
* the skill (the engine rule's conditions: not an element-gauge hit, not `noSp`, able to act, not silenced) only while no
* armed charge is waiting for its attack. The engine rule re-activates a pending instant skill on every hit, so two hits
* before her next attack would spend both charges of the elite on that one attack.
*/
function hurtCast(battle, unit) {
battle.on('damaged', (c) => {
if (c.target !== unit || !c.dmg || c.dmg.noSp || c.dmg.type === 'element' || !on(unit)) return;
const sk = unit.skill;
if (!sk || sk.noSkill || sk.pending || !sk.ready || (sk.active && sk.isTimed) || !unit.canAct || unit.s.flags.silence) return;
sk.activate('TAKE_DAMAGE');
}, { owner: unit });
}
/** Periodic check while the unit is on the field. */
function whileOn(battle, unit, sec, fn) {
battle.every(sec, () => { if (on(unit)) fn(); }, { owner: unit });
@@ -203,7 +189,7 @@ function whileOn(battle, unit, sec, fn) {
function spAura(battle, unit, value, filter) {
if (!(value > 0)) return;
whileOn(battle, unit, AURA_IV, () => {
for (const a of battle.allies()) {
for (const a of battle.alliesFor(unit)) {
if (!filter(a)) continue;
const cur = a.findBuff('aura:spRecovery');
if (cur && num(cur.mods?.spRecoveryFlat) > value + 1e-9) continue;
@@ -788,12 +774,12 @@ const KITS = {
const near = battle.enemiesInRadius(main.x, main.y, RING1).filter((e) => !e.isFlying && !e.s.flags.untargetable && !e.s.flags.sleep)
.sort((a, b) => (a === main ? -1 : b === main ? 1 : 0) || dist(a, main) - dist(b, main) || a.spawnSeq - b.spawnSeq)
.slice(0, Math.max(1, num(bb.max_target, 2)));
const fr = unit.tileR + unit.fwd[0], fc = unit.tileC + unit.fwd[1];
const force = num(bb.force, 1);
battle.fx('anchor', { x: main.x, y: main.y, id: unit.id, fromX: unit.x, fromY: unit.y, r: RING1 });
for (const e of near) {
const dx = fc - e.x, dy = fr - e.y, d = Math.hypot(dx, dy);
if (d > 0.1) battle.displace(e, { x: dx, y: dy }, d, { force: force + 1 });
// "中等力度地拖拽至面前": the 捕网's pull uses the 拖拽 rules (PRTS 推与拉 §捕网 "力的大小:同拖拽") —
// Battle.pullToFront, the official 力度 − 重量 pull
battle.pullToFront(e, unit, force);
if (e.alive) battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.atk_scale), type: 'phys', isSkill: true, tags: ['skill', 'anchorPull'] });
}
},
@@ -893,7 +879,7 @@ const KITS = {
skchr_etlchi_2: () => ({
kind: 'duration', attack: { noAttack: true },
onStart({ battle, unit }) {
const other = battle.allies().filter((a) => a !== unit && a.ground && a.hp > 0)
const other = battle.alliesFor(unit).filter((a) => a !== unit && a.ground && a.hp > 0)
.sort((a, b) => aroundN(battle, b) - aroundN(battle, a) || dist(a, unit) - dist(b, unit) || a.id - b.id)[0] ?? null;
unit.mem.sickles = other ? [unit, other] : [unit];
unit.mem.sickleAcc = 0;
@@ -1136,7 +1122,7 @@ const KITS = {
// max HP/s at the end. T1 军事要塞: all Defenders ATK +20 % while she is on the field.
// T2 血战: once per deployment, lethal damage → full heal, max HP −50 %, ASPD +18, DEF +18 %.
// Module (elite): ×1.1 vs blocked enemies.
// S1 照明榴弹 (instant / 2 charges elite, TAKE_DAMAGE): next attack atk_scale × ATK; a ranged (unblocked) one also
// S1 照明榴弹 (instant / 2 charges elite, 自动触发 ⇒ DEFAULT): next attack atk_scale × ATK; a ranged (unblocked) one also
// splashes projectile_range and lights the impact for projectile_delay_time s (enemies within projectile_range lose
// 隐匿). S2 暴风号令 (ammo 10): every attack attack@s2.atk_scale × ATK phys splash; 过载 for the second half of the
// ammo [ASSUMED like S3]: + attack@s2.magic_atk_scale × ATK arts to every enemy hit (manual close never happens in the
@@ -1155,7 +1141,7 @@ const KITS = {
return {
skills: lazySkills({
skchr_horn_1: () => ({
kind: instantKind(chess, def), trigger: NEVER, // TAKE_DAMAGE (data) via hurtCast: one armed charge at a time
kind: instantKind(chess, def), // 自动触发 "下次攻击": the data rule DEFAULT (an AUTO skill takes no 技能策略)
attack: {
atkScale: num(bb.atk_scale, 1),
onHit({ battle, unit, x, y }) {
@@ -1221,7 +1207,6 @@ const KITS = {
const aspd = num(tm.attack_speed); // FOR-Y: 不阻挡敌人时…攻击速度+10
if (aspd) whileOn(battle, unit, 0.2, () => { if (!unit.blocking.length) battle.addBuff(unit, { key: 'horn:moduleY', duration: 0.3, mods: { aspd } }); });
if (sid === 'skchr_horn_1') {
hurtCast(battle, unit);
// the pending shot: a ranged (unblocked) attack widens its splash and lights the impact
battle.on('beforeAttack', (c) => {
if (c.attacker !== unit || !unit.skill?.pending || !c.profile) return;
@@ -1436,8 +1421,9 @@ const KITS = {
// 塞雷娅 — S2 药物配置 (instant, time SP): heals every ally in the skill range for heal_scale × ATK (cast when one is
// injured). T1 莱茵充能护服: every 20 s on the field ATK +5 % / DEF +4 % (×5). T2 精神回复: +1 SP to every ally she heals.
// Module (elite): heals on allies below 50 % ×1.15.
// S1 急救 (instant / 2 charges elite, TAKE_DAMAGE): her next attack heals the ally below half HP with the lowest HP
// ratio in the skill area (周围) for heal_scale × ATK. S3 钙质化 (duration): allies in the skill area heal
// S1 急救 (instant / 2 charges elite, 自动触发: cast at her attack when an ally of the skill area 周围 is at ≤ half HP):
// that attack is instead a heal of the lowest such ally for heal_scale × ATK.
// S3 钙质化 (duration; the 重装 strategy TAKE_DAMAGE): allies in the skill area heal
// attack@heal_scale × ATK per second; enemies there take arts ×demkni_s_3.damage_scale and move −60 %.
// Module GUA-Y (elite): damage taken −15 %.
chess_char_5_11_a: (bb, chess, def) => {
@@ -1454,16 +1440,15 @@ const KITS = {
return {
skills: lazySkills({
skchr_demkni_1: () => ({
kind: instantKind(chess, def), trigger: NEVER, // TAKE_DAMAGE (data) via hurtCast: one armed charge at a time
attack: {
onHit({ battle, unit }) {
const t = battle.unitsInGrid(unit, grid, { side: 'ally' }).filter((a) => healable(a, unit) && a.hpRatio < HALF_HP)
.sort((a, b) => a.hpRatio - b.hpRatio || a.id - b.id)[0];
if (!t) return;
battle.heal(unit, t, unit.s.atk * num(bb.heal_scale, 1));
battle.fx('healAoe', { x: t.x, y: t.y, id: t.id, r: 0.5 });
},
},
// 自动触发: its own rule, no 技能策略 — PRTS 备注 "此技能仅在周围有符合血量条件的友方单位时可触发,触发时会替换当次
// 攻击" and the corrected text "血量小于等于一半": checked when she is about to attack (an enemy target, the basic
// rule), an ally of the skill area at ≤ half HP casts it and that attack becomes the heal (unlike 古米's 备用军粮,
// no heal mode is left waiting: the engine withdraws a cast whose ally was healed before the attack)
kind: instantKind(chess, def),
trigger: { rule: 'DEFAULT', grid, allies: true, hpAtMost: HALF_HP },
targeting: { rangeGrid: grid },
attack: { dmgType: 'heal', heal: { mode: 'single', hpAtMost: HALF_HP }, healScale: num(bb.heal_scale, 1), projectile: 'none' },
onHit({ battle, target }) { if (target) battle.fx('healAoe', { x: target.x, y: target.y, id: target.id, r: 0.5 }); },
}),
skchr_demkni_3: () => ({
kind: 'duration',
@@ -1512,7 +1497,6 @@ const KITS = {
install(battle, unit) {
// S2 only: the NEVER-trigger skill casts itself when an ally in its area is injured
if (!sid || sid === 'skchr_demkni_2') autoCast(battle, unit, () => zone(battle, unit).some((a) => a.hp < a.s.maxHp - 1e-6));
if (sid === 'skchr_demkni_1') hurtCast(battle, unit);
lowHpHealUp(battle, unit, tb);
if (num(tb.damage_resistance) > 0) permBuff(battle, unit, 'saria:moduleY', { dmgTakenMul: 1 - num(tb.damage_resistance) });
},
@@ -1761,8 +1745,10 @@ const KITS = {
};
return {
skills: lazySkills({
// (自动触发: an AUTO skill takes no 技能策略 — an AUTO DP skill fires as soon as it is ready, like 伺夜 S1)
skchr_svash2_1: () => ({
kind: instantKind(chess, def),
trigger: 'SP_FULL',
onStart({ battle, unit }) {
battle.addDp(unit.ownerId, num(bb.cost));
const { pick } = waitingArea(battle, unit);
@@ -1916,7 +1902,7 @@ const KITS = {
const tickSpecial = (battle, unit, z, seaHits) => {
if (z.type === 'guard') {
const inZone = (a) => Math.abs(a.tileR - z.r) <= 1 && Math.abs(a.tileC - z.c) <= 1;
const allies = battle.allies().filter(inZone);
const allies = battle.alliesFor(unit).filter(inZone);
for (const a of allies) battle.addBuff(a, { key: 'thorn2:bastion', duration: AURA_DUR, mods: mods({ defFlat: num(bb.def) }) });
if (z.acc >= 1 - 1e-9) {
z.acc -= 1;
@@ -1997,7 +1983,7 @@ const KITS = {
const cnt = num(t1.cnt, 6);
const ally = num(t1.attack_speed_ally), allyX = num(t1.attack_speed_ally_extra), foe = num(t1.attack_speed_enemy), foeX = num(t1.attack_speed_enemy_extra);
whileOn(battle, unit, AURA_IV, () => {
for (const a of battle.allies()) {
for (const a of battle.alliesFor(unit)) {
const v = ally + (straightRun(battle, a.tileR, a.tileC) >= cnt ? allyX : 0);
if (v) battle.addBuff(a, { key: 'thorn2:vision', duration: AURA_DUR, mods: { aspd: v } });
}
@@ -2097,10 +2083,9 @@ const KITS = {
hits: 2,
onEachHit({ battle, unit, target, kind }) {
if (kind !== 'main' || !target || !target.alive || target.side !== 'enemy') return;
const force = num(bb['attack@force'], 1);
const dx = target.x - unit.x, dy = target.y - unit.y;
const dir = Math.hypot(dx, dy) > 0.05 ? { x: dx, y: dy } : { x: unit.fwd[1], y: unit.fwd[0] };
battle.displace(target, dir, pushTiles(force), { force: force + 1 });
// "中等力度地推动" (PRTS 备注: on the 2nd hit of each attack — onEachHit runs after both): a radial push by
// the official 力度 − 重量 distance (Battle.push)
battle.push(target, num(bb['attack@force'], 1), { from: unit });
},
},
}),
@@ -2155,9 +2140,7 @@ const KITS = {
attack: {
onHit({ battle, unit, target }) {
if (!target || !target.alive || target.side !== 'enemy' || target.blockedBy) return;
const fx0 = unit.tileC + unit.fwd[1], fy0 = unit.tileR + unit.fwd[0];
const dx = fx0 - target.x, dy = fy0 - target.y, d = Math.hypot(dx, dy);
if (d > 0.1) battle.displace(target, { x: dx, y: dy }, d, { force: force + 1 });
battle.pullToFront(target, unit, force); // the official 力度 − 重量 pull (Battle.pullToFront)
},
},
},
@@ -2311,12 +2294,14 @@ const KITS = {
talents: [
{ install(battle, unit) { // 加速力场
const aspd = num(t0.attack_speed);
if (aspd) whileOn(battle, unit, AURA_IV, () => { for (const a of battle.allies()) battle.addBuff(a, { key: 'aglina:field', duration: AURA_DUR, mods: { aspd } }); });
if (aspd) whileOn(battle, unit, AURA_IV, () => { for (const a of battle.alliesFor(unit)) battle.addBuff(a, { key: 'aglina:field', duration: AURA_DUR, mods: { aspd } }); });
} },
{ install(battle, unit) { // 兼职工作
const hp = num(t1.hp_recovery_per_sec);
// PRTS 备注: an HP-regeneration attribute ("生命回复速度"), not given to allies under 禁疗 (the noHeal status)
if (hp > 0) whileOn(battle, unit, AURA_IV, () => { if (!unit.skill?.active) for (const a of battle.allies()) if (!a.s.flags.noHeal) battle.addBuff(a, { key: 'aglina:parttime', duration: AURA_DUR, mods: { hpRegen: hp } }); });
// an HP-regeneration attribute, so 禁疗 does not stop it — PRTS 备注 "生命回复的提供方式为增加目标的'生命回复速度'属性,
// 不受治疗加成和禁疗影响" (异常效果 禁疗: "增减生命回复速度…的效果不会被识别为治疗类能力"); a 孤立 unit (炎佑) is not
// selected (Battle.alliesFor)
if (hp > 0) whileOn(battle, unit, AURA_IV, () => { if (!unit.skill?.active) for (const a of battle.alliesFor(unit)) battle.addBuff(a, { key: 'aglina:parttime', duration: AURA_DUR, mods: { hpRegen: hp } }); });
} },
],
};
+60 -29
View File
@@ -43,7 +43,7 @@
// can be copied. "被击败后25秒后自动刷新": one pending respawn at a time, only while she is on the field
// and no 流形 of hers stands (her knock-out cancels it; her redeploy re-summons it as her 援军).
// 6_12 迷迭香 "溅射范围扩大" ×1.3 (not in data); 感知稳定 picks among the owner's deployed casters (none ⇒ no buff).
// 6_13 新约能天使 steal target = ally op in range with the highest ASPD; bombardment radius 1 tile (not in data).
// 6_13 新约能天使 bombardment radius 1 tile (not in data).
// 6_14 流明 S3 heals an abnormal ally even at full HP (forced heal); 抵抗 = the engine `resist` status.
// 6_15 仇白 入隙 reads the target's sluggish/bind statuses; module adds 10 % ATK arts per hit.
// 6_16 溯光星源 link transfers the pre-mitigation arts amount × share to the other locked target(s); 能源解析 = the
@@ -52,7 +52,8 @@
// 6_18 荒芜拉普兰德 S3 drones are virtual (fx events), move at projectile_move_speed tiles/s, she stops attacking
// while they roam; 头狼 stage 2 "特殊能力失效" = silence; stage 3 = +1 drone (normal attacks hit once
// more, S3 releases one more drone). Base drone count 1.
// 6_19 锏 10 slashes every d_hit_interval, pulls every p_hit_interval, final blow (skill range) at the end.
// 6_19 锏 10 slashes every d_hit_interval, pulls every p_hit_interval, final blow (skill range) at the end;
// S3 slashes and pulls air units too (PRTS 备注 "可对空").
// 6_20 纯烬艾雅法拉 5 shots are padded by cycling targets when fewer injured allies exist.
//
// Operator loadouts (DESIGN §16): every visible chess also authors its selectable NON-default skills in `skills`
@@ -98,9 +99,9 @@
// `skill.addTriggerRange`; element-type damage skips the `hit` hook, so element (损伤) amplification uses the
// `elementHit` hook (`onElementHit`: × dmg.mul before the gauge fill).
import { absoluteRangeKeys, sortEnemyTargets, canTargetEnemy } from '../../targeting.js';
import { absoluteRangeKeys, sortEnemyTargets, canTargetEnemy, aggroCmp } from '../../targeting.js';
import { aggregateMods } from '../../buffs.js';
import { COLS, ROWS } from '../../constants.js';
import { COLS, ROWS, PULL_STOP_RADIUS } from '../../constants.js';
import { rotateOffset } from '../../dir.js';
import { bodyDist, bodyInKeys, bodyInRadius, bodyKeys } from '../../body.js';
import { hasHp } from '../../damage.js';
@@ -311,13 +312,15 @@ function ensureDeployTracker(battle) {
return S.lastOp;
}
/** Pull `e` toward `unit` (0.5 + 0.5·force tiles, never past `minDist`). */
function pullToward(battle, unit, e, force, minDist = 0.3) {
if (!e || !e.alive) return;
const dx = unit.x - e.x, dy = unit.y - e.y, len = Math.hypot(dx, dy);
if (len <= minDist) return;
const dist = Math.min(len - minDist, 0.5 + 0.5 * Math.max(0, force));
if (dist > 0) battle.displace(e, { x: dx, y: dy }, dist, { force: force + 1 });
/**
* Pull `e` with 力度 `force` towards the point / unit `unit` ("向自己中心…拖拽", "拉向目标所在位置"), stopping `stop` tiles
* from it — Battle.pull: the official 力度 − 重量 pull (PRTS 推与拉; user playtest #6 item 14). The default stop is the
* 急停 radius 0.6708 around a pulling unit. A pulling ally is also the pull's `center`, so an enemy it blocks itself
* stays where it is held (as in Battle.pullToFront) — e.g. the 流形 S3 pulse then still stuns it.
*/
function pullToward(battle, unit, e, force, stop = PULL_STOP_RADIUS) {
if (!e || !e.alive || !unit) return 0;
return battle.pull(e, num(force), { to: { x: unit.x, y: unit.y }, center: unit.side === 'ally' ? unit : null, stop });
}
/** Stat-aura pulse: short buffs refreshed every `period` s on `targets()`. */
@@ -712,14 +715,14 @@ function sbell2(bb, chess, def) {
}
const skills = {
// S1 铃音吹雪 (SEARCH trigger, 2 charges): atk_scale × ATK arts + `cold` s of 寒冷 on every enemy in range, pushed
// (force 1 = 中等) along her direction; then one snow layer spreads forward over the ground (≤ trig_cnt tiles)
// (force 1 = 中力) along her direction — PRTS 备注 "固定方向推动(不会因角度过大或距离过近而变化方向与力度),且仅对地面
// 单位产生推力" (Battle.push fixed, official 力度 − 重量 distance); then one snow layer spreads forward over the ground
// (≤ trig_cnt tiles)
skchr_sbell2_1: {
kind: instantKind(def),
// data trigger SEARCH (the 阵法术师 profession row for S1): the engine reads it as "an enemy anywhere on the field",
// which dumped both charges on an enemy spawning at the gate, far outside her range (the burst hits "范围内所有
// 敌人"). SEARCH exists for units that never attack (DEFAULT fires on an attack; CUSTOM_RANGE_SEARCH_ENEMY is its
// custom-range twin) [ASSUMED]: an enemy inside her range, checked every tick — the engine's DEFAULT for a
// phalanx (noAttackUnlessSkill)
// data trigger SEARCH (the 阵法术师 row, every MANUAL skill of the class: "在初始攻击范围内存在敌人时释放技能") =
// an enemy inside her initial range, checked every tick — what the engine's DEFAULT does for a phalanx
// (noAttackUnlessSkill); an enemy anywhere on the field would dump both charges on an enemy spawning at the gate
trigger: 'DEFAULT',
onStart({ battle, unit }) {
const [fr, fc] = unit.fwd;
@@ -728,7 +731,7 @@ function sbell2(bb, chess, def) {
battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.atk_scale, 1), type: 'arts', isSkill: true, tags: ['skill'] });
if (!e.alive) continue;
if (num(bb.cold) > 0) battle.applyStatus(e, 'cold', { duration: num(bb.cold), source: unit });
battle.displace(e, { x: fc, y: fr }, 0.5 + 0.5 * force, { force: force + 1 });
if (!e.isFlying) battle.push(e, force, { from: unit, dir: { x: fc, y: fr }, fixed: true });
}
const n = Math.max(0, Math.floor(num(bb.trig_cnt, 5)));
let laid = 0;
@@ -1210,9 +1213,10 @@ function pepe(bb, chess, def) {
}, { owner: unit });
}
if (sid === 'skchr_pepe_2') {
battle.on('beforeAttack', (ctx) => { // 随机攻击范围内的目标
battle.on('beforeAttack', (ctx) => { // 随机攻击范围内的目标 (and the enemies she blocks — Battle.blockedTargets)
if (ctx.attacker !== unit || !unit.skill?.active) return;
const c = battle.enemiesInKeys(unit.rangeKeys, unit, ctx.profile);
for (const e of battle.blockedTargets(unit, ctx.profile)) if (!c.includes(e)) c.push(e);
if (c.length) ctx.targets = [battle.rng.pick(c)];
}, { owner: unit });
}
@@ -1311,7 +1315,7 @@ function siege2(bb, chess, def) {
if (sid === 'skchr_siege2_2') {
const need = Math.max(1, Math.floor(num(bb.buff_stack_cnt, 2))), spr = num(bb.sp_recovery_per_sec);
if (spr > 0) aura(battle, unit, 0.25, () => {
const n = battle.unitsInGrid(unit, tGrid, { side: 'ally' }).filter((a) => a !== unit && a.kind !== 'device').length;
const n = battle.unitsInGrid(unit, tGrid, { side: 'ally' }).filter((a) => a !== unit && a.kind !== 'device' && battle.allySelectable(a, unit)).length;
if (n >= need) battle.addBuff(unit, { key: 'siege2:homeland', mods: { spRecoveryFlat: spr }, duration: 0.4, refresh: 'replace' });
});
}
@@ -1367,7 +1371,7 @@ function siege2(bb, chess, def) {
{ install(battle, unit) { // 诸王的叹息
const dr = num(t0.damage_resistance), atk = num(t0.atk);
aura(battle, unit, 0.25, () => {
const allies = battle.unitsInGrid(unit, tGrid, { side: 'ally' }).filter((a) => a.kind !== 'device');
const allies = battle.unitsInGrid(unit, tGrid, { side: 'ally' }).filter((a) => a.kind !== 'device' && battle.allySelectable(a, unit));
if (!allies.includes(unit)) allies.push(unit);
if (dr > 0) for (const a of allies) battle.addBuff(a, { key: 'siege2:sigh', mods: { physTakenMul: 1 - dr }, duration: 0.4, refresh: 'replace' });
const n = allies.filter((a) => a !== unit).length;
@@ -1875,7 +1879,7 @@ function mlyss(bb, chess, def) {
if (!unit.skill?.active) return;
for (const t of battle.allyUnits) {
if (!isTok(t, tokId, unit) || !live(t) || !t.mem.mlyss || t.mem.mlyss.ranged) continue;
for (const e of battle.unitsInGrid(t, AROUND8, { side: 'enemy' })) pullToward(battle, t, e, num(t.def?.skill?.bb?.force), 0.2);
for (const e of battle.unitsInGrid(t, AROUND8, { side: 'enemy' })) pullToward(battle, t, e, num(t.def?.skill?.bb?.force));
for (const e of t.blocking) if (e.alive) battle.applyStatus(e, 'stun', { duration: pulseIv + 0.1, source: unit });
battle.fx('pulse', { x: t.x, y: t.y, id: t.id });
}
@@ -1915,7 +1919,7 @@ function mlyss(bb, chess, def) {
if (unit.skill) unit.skill.addTriggerRange(() => battle.allyUnits.filter((t) => mine(t) && live(t) && t.mem.mlyss));
battle.on('tick', () => { // without a dedicated token kit the copy fires once ready and someone can be copied
for (const t of battle.allyUnits) {
if (!mine(t) || !live(t) || t.mem.mlyssClone || !t.kit?.generic || !t.skill || t.skill.active || !t.skill.ready) continue;
if (!mine(t) || !live(t) || t.mem.mlyssClone || !t.kit?.generic || !t.skill || t.skill.active || !t.skill.ready || t.skill.opCooling) continue;
if (pickCopyTarget(battle, unit, t)) t.skill.activate('MLYSS_WTRMAN');
}
}, { owner: unit });
@@ -2051,7 +2055,10 @@ function rosmon(bb, chess, def) {
if (sid !== 'skchr_rosmon_3') return;
battle.on('beforeAttack', (ctx) => { // "仅选择被阻挡的敌人作为目标"
if (ctx.attacker !== unit || !unit.skill?.active) return;
const c = battle.enemiesInKeys(unit.rangeKeys, unit, ctx.profile).filter((e) => !!e.blockedBy);
// her range, plus the enemies she blocks herself — always her targets (Battle.blockedTargets, DESIGN §20.3)
const c = battle.enemiesInKeys(unit.rangeKeys, unit, ctx.profile);
for (const e of battle.blockedTargets(unit, ctx.profile)) if (!c.includes(e)) c.push(e);
for (let i = c.length - 1; i >= 0; i--) if (!c[i].blockedBy) c.splice(i, 1);
sortEnemyTargets(battle, unit, c, ctx.profile?.priority ?? null);
ctx.targets = c.slice(0, Math.max(1, Math.floor(num(ctx.profile?.maxTargets, 2))));
}, { owner: unit });
@@ -2104,6 +2111,12 @@ function rosmon(bb, chess, def) {
// ------------------------------------------------------------------------------------------------------------------
// 新约能天使 chess_char_6_13 (怪杰) — S2 开火成瘾症; 火力电台; 铳弹协约; module 新朋友圣城生活套组
// S2 (PRTS 备注): the ally is the friendly operator of her attack range with the highest 仇恨值 ("选择的友方干员为攻击范围内
// 仇恨值最高的我方干员": highest taunt level, then the latest deployed — targeting.js aggroCmp, the order enemies attack
// in; it used to be the highest ASPD, so the shield often went to a back-row shooter instead of the operator in front);
// both barriers are shield_max_hp_ratio × the holder's OWN max HP ("获得的屏障均以自身生命上限为标准计算"), losing
// initial/shield_max_duration per second ("屏障每秒衰减量为:初始屏障量/30"); a new one replaces the old one ("重复获得此
// 屏障时,重置屏障量与衰减速度").
const AIRSTRIKE_RADIUS = 1; // [ASSUMED] bombardment splash radius (not in data)
@@ -2201,7 +2214,7 @@ function angel2(bb, chess, def) {
attack: { atkScale: num(bb['attack@atk_scale'], 1) },
onStart({ battle, unit, skill }) {
const cands = battle.alliesInGrid(unit).filter((a) => a !== unit && a.kind === 'op' && live(a));
cands.sort((a, b) => b.s.aspd - a.s.aspd || a.deploySeq - b.deploySeq);
cands.sort(aggroCmp);
const victim = steal > 0 ? cands[0] ?? null : null;
unit.mem.angelVictim = victim;
decayingShield(battle, unit, 'angel2:barrier', unit.s.maxHp * shieldRatio, shieldDur);
@@ -2278,7 +2291,7 @@ function lumen(bb, chess, def) {
if (!(sc > 0) || !(life > 0)) return;
battle.fx('healField', { x: target.x, y: target.y, id: unit.id });
for (const a of battle.alliesInRadius(target.x, target.y, 1.5, target.ownerId)) {
if (a.kind === 'device') continue;
if (a.kind === 'device' || a.s.flags.noHeal || a.profile?.noHeal) continue; // a heal: never on 禁疗 / 孤立
battle.addBuff(a, {
key: `lumen:rain:${unit.id}`, duration: life, interval: iv, refresh: 'replace', visible: true, source: unit,
onTick: ({ unit: x }) => { if (x.hp < x.s.maxHp) battle.heal(unit, x, unit.s.atk * sc, { hot: true }); },
@@ -2798,7 +2811,9 @@ function whitw2(bb, chess, def) {
battle.on('beforeAttack', (ctx) => {
const m = unit.mem.hunt;
if (ctx.attacker !== unit || !unit.skill?.active || !m) return;
// the skill range, plus the enemies she blocks (always her targets, Battle.blockedTargets — DESIGN §20.3)
const c = battle.enemiesInKeys(unit.rangeKeys, unit, ctx.profile);
for (const e of battle.blockedTargets(unit, ctx.profile)) if (!c.includes(e)) c.push(e);
if (!c.length) { ctx.targets = []; return; }
const n = 1 + Math.floor(num(bb['attack@cnt'])) + ((unit.mem.wolfStage || 0) >= 3 ? 1 : 0);
m.locks = m.locks.filter((e) => e.alive && c.includes(e)).slice(0, n);
@@ -2923,8 +2938,10 @@ function blkkgt(bb, chess, def) {
// the skill range (x-1): her range while S3 runs — also for the finisher, since onEnd runs before the engine
// removes the skill's range
const skillKeys = (unit) => unit.rangeKeys || [];
// S3 hits and pulls air units too — PRTS 锏 S3 备注 "※可对空。不会拖拽自身中心半径0.6708范围内的敌人" (her attacks and S1 /
// S2 stay ground-only: "地面敌人")
const victims = (battle, unit) => {
const c = battle.enemiesInKeys(skillKeys(unit), unit, unit.profile);
const c = battle.enemiesInKeys(skillKeys(unit), unit, { ...unit.profile, canHitFly: true, groundOnly: false });
sortEnemyTargets(battle, unit, c, null);
return c.slice(0, maxT);
};
@@ -2933,7 +2950,13 @@ function blkkgt(bb, chess, def) {
if (v.length) battle.fx('slash', { x: unit.x, y: unit.y, id: unit.id, n: v.length });
for (const e of v) battle.dealDamage(unit, e, { amount: unit.s.atk * scale, type: 'phys', isSkill: true, tags: ['skill', 'slash'] });
};
const pull = (battle, unit, force) => { for (const e of enemiesIn(battle, unit, skillKeys(unit))) if (!e.isFlying) pullToward(battle, unit, e, force); };
// "持续将敌人中等力度地拖拽至自身中心,之后…较大力地拖拽至自身": the official 力度 − 重量 pulls (PRTS 推与拉: the last one
// aims at her own tile centre, the others at the usual 拉力起点 half a tile ahead; 急停 0.6708 around her)
const pull = (battle, unit, force, last = false) => {
for (const e of enemiesIn(battle, unit, skillKeys(unit))) {
if (last) pullToward(battle, unit, e, force); else battle.pullToFront(e, unit, force);
}
};
const skills = {
// S1 纯粹的武力 (attack SP): the next attack hits up to max_target ground enemies of the 3×3 around her, each twice
// (her trait) at atk_scale_s1 × ATK
@@ -2966,6 +2989,10 @@ function blkkgt(bb, chess, def) {
skills,
install(battle, unit) {
if (penFlat > 0 || legendAtk) battle.addBuff(unit, { key: 'blkkgt:contract', mods: { defIgnoreFlat: penFlat, atkPct: legendAtk }, persist: true, allowDead: true });
// S3 slashes: 晕眩免疫, 冻结免疫 (PRTS 备注)
battle.on('beforeStatus', (c) => {
if (c.target === unit && unit.mem.dgb && (c.status === 'stun' || c.status === 'freeze')) c.cancel = true;
}, { owner: unit });
if (legendTremble > 0) {
const seen = new WeakSet();
battle.on('tick', () => {
@@ -2983,8 +3010,11 @@ function blkkgt(bb, chess, def) {
duration: slashes * iv,
...(skillGrid ? { targeting: { rangeGrid: skillGrid } } : {}),
attack: { noAttack: true },
// PRTS 备注 "※多段斩击期间,自身获得无敌,晕眩免疫,冻结免疫": invulnerable while the slashes run (removed in onEnd,
// before the finisher); stun / freeze refused by her `beforeStatus` hook (install) while `mem.dgb` is set
onStart({ battle, unit }) {
unit.mem.dgb = { n: 1, acc: 0, pacc: 0 };
battle.addBuff(unit, { key: 'blkkgt:slashes', duration: slashes * iv + 1, flags: { invulnerable: true }, visible: true, source: unit });
slash(battle, unit, num(bb.d_atk_scale, 1));
},
onTick({ battle, unit, dt }) {
@@ -2997,11 +3027,12 @@ function blkkgt(bb, chess, def) {
},
onEnd({ battle, unit, reason }) {
unit.mem.dgb = null;
battle.removeBuff(unit, 'blkkgt:slashes');
if (reason === 'death' || !unit.alive) return;
battle.fx('finale', { x: unit.x, y: unit.y, id: unit.id });
unit.mem.dgbFinale = true; // the finisher is part of the skill (talent at 100 %, module +10 %)
try { slash(battle, unit, num(bb.e_atk_scale_end, 1)); } finally { unit.mem.dgbFinale = false; }
pull(battle, unit, num(bb.e_force));
pull(battle, unit, num(bb.e_force), true);
},
},
talents: [
+153 -49
View File
@@ -6,7 +6,8 @@
// Every tokenId of data/tokens.json has a kit, so `battle.spawnToken(owner, tokenId, r, c)` works with data defaults
// even when the summoner's kit passes no options:
// 医疗探机 heal profile (data), untargetable, self-destructs after its withdraw skill time (10 s)
// 诅咒娃娃 no attack, aura: enemies in its range ATK/DEF + bb.atk/bb.def (−25 %/−30 %), 15 s (skill duration)
// 诅咒娃娃 no attack, aura: enemies in its range ATK/DEF + bb.atk/bb.def (−25 %/−30 %), 15 s (skill duration);
// leaves when 巫恋 leaves (PRTS 备注) — the drone stays when 赫默 leaves (PRTS 医疗探机 备注)
// 沙之碑 on appear: owner ATK × atk_scale arts + stun (skill range 3×3), blocks 3 (no attack), talent duration 20 s
// 战术装备 on appear: stun around (bb.stun), blocked enemies DEF + talent def (−160), talent duration 25 s
// “小自在” arts melee blocker, talent duration 25 s; kills emit `summonKill` (夕's 化境 is the 夕 kit's job)
@@ -31,9 +32,11 @@
// its blast fx carries `consumed: true` so clients play the explosion, not a death sound)
// 从不混淆的方向 untargetable marker; when the owner's skill ends it vanishes and the owner returns to its tile
// 黄金盟誓 attacks deal true damage (trait); lasts while the owner's skill runs
// 防护单元 untargetable device: shield = 凯瑟琳 max HP × max_shield_ratio on one operator in its range (effects
// do not stack), refilled by shield_ratio_each_trigger /s when the target was not hit for `interval`
// s (always while 凯瑟琳's skill runs, owner bb overwrite_ratio)
// 防护单元 untargetable, invulnerable device placed by the player (a hand piece, user playtest #6): shield =
// 凯瑟琳 max HP × max_shield_ratio on the operator in its range (range 1-1: the tile it faces; effects do
// not stack — `cathy:shield`, read by 凯瑟琳 S1 岁月锻打), in full whenever it takes a new operator,
// refilled by shield_ratio_each_trigger /s when the target was not hit for `interval` s (always while
// 凯瑟琳's timed skill runs, owner bb overwrite_ratio)
// 投递坐标 / 风雪之眼 / 保护目标 inert pieces (no attack; 风雪之眼 untargetable) — their effects belong to the owner kit
// 炎佑 (enemy_9012_acloon) flying ally: flies after the highest-aggro enemy of the field and hovers over it, stays
// put when there is none; 3-target arts + burn on every hit, 元素脆弱 aura, 祛恶之焰 channel (yanyouKit)
@@ -48,15 +51,28 @@
// sees its 援军 standing; with the engine's default reinforcement the talent token replaces the generic 援军. Without
// a board piece the token takes a tactical point: a free walkable tile of the owner's range on an enemy ground path
// first (`tacticalPoint`), then the one nearest to the owner.
// Placed summons (PRTS 卫戍协议/帮助 §作战阶段; user playtest #6): every board summon piece marks the tile its summon
// deploys on. The talent ones the owner holds from the start (狼群, 海嗣, 流形, 凯瑟琳's devices) deploy with the
// board, after the operators. A skill's summon (赫默's 医疗探机, 巫恋's 诅咒娃娃: "获得一个…") also deploys once with the
// board, for free and ignoring the holding ("在赫默的技能未开启前未持有该召唤物,但作战开始时无视持有状态自动部署1个";
// settled by the user after playtest #6 — shared/constants.js SKILL_SUMMON_START_DEPLOY, re-exported here; false =
// the playtest #4 reading, `deferDeploy`: off the field until the skill). Then it is docked on its tile
// (`dockSkillSummons`; the tile stays reserved) and takes the field there each time the owner's skill gives one
// (`releaseSkillSummon`; PRTS "若战场区初始部署有召唤物,若召唤物在战斗期间退场,将在满足条件后立即原地再部署1个"): one
// in stock at most ("最多可库存1个"), after the token's redeploy time once it left, free [ASSUMED: no DP], never while
// its owner is off the field — a stocked one deploys as soon as the owner is back. A skill's summon or a device
// (凯瑟琳) that was not placed never appears (the hidden 待部署区 deploys nothing by itself), nor does 海嗣; only the
// tacticians' 狼群 / 流形 still come as 援军 on a tactical point (above).
// Fallbacks (only while the summoner still uses the generic kit — a hand-authored kit takes over): skill summons
// (赫默/巫恋/蜜蜡/风丸/维娜/耀骑士临光/迷迭香 S3) spawn at skill start — also under a hand-authored kit when only the
// SELECTED skill runs the generic spec (a non-default skill the kit has no `skills` entry for) — 夕 spawns 小自在 on
// its first attack, 凯瑟琳 deploys her devices at battle start.
// (赫默/巫恋 through their placed pieces as above; 蜜蜡/风丸/维娜/耀骑士临光/迷迭香 S3 on a tile of their own) spawn at
// skill start — also under a hand-authored kit when only the SELECTED skill runs the generic spec (a non-default skill
// the kit has no `skills` entry for) — and 夕 spawns 小自在 on its first attack.
// Operator loadouts (DESIGN §16): variants, `sources` and counts are those of the owner's selected skill / module
// (variantOf / tokenSources: getToken(id, owner.defId, owner.def.loadout)); a skill summon runs only when that skill
// makes the token (Battle.spawnToken also refuses summons the owner's loadout does not produce).
//
// Exports for other content: spawnYanyou, spawnMapChar, findSummonTile, summonToken, tacticalPoint, wolfShadows, TOKEN_IDS.
// Exports for other content: spawnYanyou, spawnMapChar, findSummonTile, summonToken, tacticalPoint, wolfShadows,
// releaseSkillSummon, SKILL_SUMMON_START_DEPLOY, CAT_SHIELD_KEY, TOKEN_IDS.
import { COLS, ROWS, MOVE_SCALE } from '../constants.js';
import { absoluteRangeKeys, sortEnemyTargets, canTargetEnemy } from '../targeting.js';
@@ -64,10 +80,13 @@ import { bodyInKeys, bodyOnTile } from '../body.js';
import { hasHp } from '../damage.js';
import { genericKit } from './generic.js';
import { normDir, localOrder } from '../dir.js';
import { SKILL_SUMMON_START_DEPLOY } from '../../../shared/constants.js';
const num = (v, d = 0) => (typeof v === 'number' && Number.isFinite(v) ? v : (typeof v === 'string' && v.trim() !== '' && Number.isFinite(+v) ? +v : d));
const GRID_3X3 = Object.freeze([[1, -1], [1, 0], [1, 1], [0, -1], [0, 0], [0, 1], [-1, -1], [-1, 0], [-1, 1]]);
const GRID_PLUS = Object.freeze([[1, 0], [0, -1], [0, 0], [0, 1], [-1, 0]]);
/** Buff key of the 爬行号·防护单元 shield on an operator (凯瑟琳 S1 岁月锻打 buffs the operators holding one). */
export const CAT_SHIELD_KEY = 'cathy:shield';
export const TOKEN_IDS = Object.freeze({
healDrone: 'token_10000_silent_healrb',
@@ -236,6 +255,93 @@ function bindToOwnerSkill(battle, unit) {
}, { owner: unit });
}
// ---------------------------------------------------------------------------------------------------------------
// placed skill summons (user playtest #6; see the header "Placed summons")
// SKILL_SUMMON_START_DEPLOY (shared/constants.js — also read by the summon card's hint): true = the PRTS reading the
// user settled after playtest #6, the placed piece of a skill's summon also deploys once, for free, at the battle
// start; false = the playtest #4 reading (it comes with the skill). Re-exported for content and tests.
export { SKILL_SUMMON_START_DEPLOY };
/** Seconds between two tries of a docked piece whose tile is busy / whose redeploy time is still running. */
const DOCK_RETRY = 0.5;
/**
* How the owner's loadout makes a board summon piece of a manually deployable summon (owner-loadout `sources`):
* 'skill' (only its skill makes it), 'none' (the owner's skill / module makes none — 赫默 on S1 with a drone piece; the
* match never hands such a card out), else null (a talent summon, or not a placed hand piece).
*/
function pieceSource(battle, u) {
if (!u || u.kind !== 'token' || u.uid == null || !u.ownerUnit || u.ownerUnit.kind !== 'op') return null;
if (u.def?.raw?.placeable !== true) return null;
const src = tokenSources(battle, u.defId, u.ownerUnit);
if (src.includes('talent')) return null;
if (src.includes('skill')) return 'skill';
return src.length ? 'none' : null;
}
/**
* Dock the board pieces of skill summons (before the start): off the field with the board (`deferDeploy`, unless
* SKILL_SUMMON_START_DEPLOY), and whenever one leaves (10 s drone, 15 s doll) it stays its tile's piece — not removed,
* its hooks kept, Battle.isReservedTile — ready again after the token's redeploy time (data respawnTime, 5 s). A piece
* its owner's loadout does not make never deploys.
*/
function dockSkillSummons(battle) {
for (const u of battle.allyUnits) {
const how = pieceSource(battle, u);
if (how === 'none') { u.deferDeploy = true; continue; }
if (how !== 'skill') continue;
u.mem.docked = true;
u.mem.readyAt = -Infinity;
if (!SKILL_SUMMON_START_DEPLOY) u.deferDeploy = true;
battle.on('death', (ctx) => {
if (ctx.unit !== u || battle.finished) return;
u.removed = false;
u.mem.readyAt = battle.time + Math.max(0, num(u.base.respawnTime, 0));
deployDocked(battle, u);
}, { owner: u, priority: -10 });
// the owner back on the field with one in stock: the condition of the PRTS redeploy is met at once
battle.on('deploy', (ctx) => {
if (ctx.unit === u.ownerUnit && !battle.finished) deployDocked(battle, u);
}, { owner: u });
}
}
/**
* Deploy a docked piece on its tile when its owner holds one and it is ready; retried while it waits. Not while the
* owner is off the field [ASSUMED: as in the base game, a summon is deployed only while its owner stands] — the stock
* stays, and the piece deploys as soon as the owner is back (its `deploy` hook, dockSkillSummons).
*/
function deployDocked(battle, u) {
const stock = u.ownerUnit?.mem.summonStock;
if (!stock || !(stock[u.defId] > 0) || u.alive || u.removed || battle.finished || !u.ownerUnit.alive) return false;
const wait = u.mem.readyAt - battle.time;
if (wait > 1e-9 || !battle.redeploy(u, { free: true })) {
if (!u.mem.dockRetry) {
u.mem.dockRetry = true;
battle.after(Math.max(wait, DOCK_RETRY), () => { u.mem.dockRetry = false; deployDocked(battle, u); }, { owner: u });
}
return false;
}
stock[u.defId]--;
battle.fx('summon', { x: u.x, y: u.y, id: u.id, token: u.defId });
return true;
}
/**
* The owner's skill gives it one `tokenId` ("获得一个医疗无人机"; at most `cap` in stock — "最多可库存1个"): its placed
* piece takes the field on its own tile now, or as soon as it is ready (the previous one still up / just gone).
* Returns the piece deployed now, else null — also when the player placed none (the summon then never appears).
*/
export function releaseSkillSummon(battle, owner, tokenId, { cap = 1 } = {}) {
if (!owner) return null;
const pieces = battle.allyUnits.filter((t) => t.kind === 'token' && t.defId === tokenId && t.ownerUnit === owner && t.mem.docked);
if (!pieces.length) return null;
const stock = owner.mem.summonStock || (owner.mem.summonStock = {});
stock[tokenId] = Math.min(Math.max(1, Math.floor(num(cap, 1))), (stock[tokenId] || 0) + 1);
for (const p of pieces) if (deployDocked(battle, p)) return p;
return null;
}
function inRectTile(battle, r, c) { return Number.isInteger(r) && Number.isInteger(c) && battle.grid.inRect(r, c); }
/**
@@ -264,8 +370,10 @@ function countNear(list, r, c, rad = 1) {
/**
* Pick a summon tile for `owner` (null when none). placement:
* 'ally' — any standable tile in the owner's range covering the most injured allies (医疗探机)
* 'enemy' — any standable tile in range covering the most enemies (诅咒娃娃)
* 'ally' — any standable tile in the owner's range covering the most injured allies (a healing skill summon that
* is no hand piece; 医疗探机 itself is placed by the player — releaseSkillSummon)
* 'enemy' — any standable tile in range covering the most enemies (a debuff skill summon that is no hand piece;
* 诅咒娃娃 itself is placed by the player)
* 'melee' — melee-deployable tile in range, on the enemy path and near the enemies first (沙之碑)
* 'adjacent' — melee-deployable tile among the 8 surrounding tiles (纸偶, 黄金盟誓)
* 'plus' — melee-deployable tile among the 4 neighbours (“耀阳”)
@@ -282,7 +390,10 @@ export function findSummonTile(battle, owner, placement = 'melee', opts = {}) {
const melee = opts.melee ?? (placement === 'melee' || placement === 'adjacent' || placement === 'plus');
const enemies = battle.enemies.filter((e) => e.alive && !e.hidden);
const focus = opts.at ?? mostAdvancedEnemy(battle) ?? owner;
const injured = placement === 'ally' ? battle.allies(owner.ownerId).filter((a) => a.hp < a.s.maxHp - 1e-6) : null;
// injured allies a heal could reach: never 孤立 (Battle.alliesFor) nor 禁疗 units (炎佑, the 禁疗 summons)
const injured = placement === 'ally'
? battle.alliesFor(owner, owner.ownerId).filter((a) => a.hp < a.s.maxHp - 1e-6 && !(a.s.flags.noHeal || a.profile?.noHeal))
: null;
let best = null, bestS = null;
for (const k of keys) {
const r = (k / COLS) | 0, c = k % COLS;
@@ -359,7 +470,11 @@ function curseDoll(bb, raw, def) {
},
},
trait: { noAttack: true },
install(battle, unit) { onDeploy(battle, unit, () => scheduleLifetime(battle, unit, life)); },
install(battle, unit) {
onDeploy(battle, unit, () => scheduleLifetime(battle, unit, life));
// PRTS 诅咒娃娃 备注: "巫恋退场时强制撤退场上的诅咒娃娃" (knocked out or withdrawn)
battle.on('death', (ctx) => { if (ctx.unit === unit.ownerUnit && unit.alive) battle.retreat(unit, { reason: 'expired', permanent: true }); }, { owner: unit });
},
};
}
@@ -738,7 +853,7 @@ function manifold(bb, raw, def) {
if (!unit.mem.noCopy) {
battle.on('tick', () => {
const sk = unit.skill;
if (!sk || !unit.alive || !unit.deployed || sk.active || !sk.ready || !unit.canAct || unit.s.flags.silence) return;
if (!sk || !unit.alive || !unit.deployed || sk.active || !sk.ready || sk.opCooling || !unit.canAct || unit.s.flags.silence) return;
if (pickCopyTarget(battle, unit)) sk.activate('MLYSS_WTRMAN');
}, { owner: unit });
}
@@ -818,19 +933,25 @@ function goldenOath(bb, raw, def) {
};
}
/** 爬行号·防护单元 (凯瑟琳 talent 定向支援信号). */
/**
* 爬行号·防护单元 (凯瑟琳 talent 定向支援信号; a hand piece the player places and turns, user playtest #6): "使一名干员
* 获得相当于凯瑟琳生命上限20%的屏障(若目标最近5秒内未受攻击,则每秒补充…不超过初始上限),装置效果不叠加" — the operator
* on its range (1-1: its tile + the tile it faces) gets the full shield when the device takes it (deploy, or a new /
* redeployed operator there), then the refill; untargetable ("不会受到攻击") and invulnerable (enemy AoE sweeping every
* ally in a radius would otherwise destroy the 100-HP device). The buff key is 凯瑟琳 S1 岁月锻打's test.
*/
function catShield(bb, raw, def) {
const t = talentBb(def, 'max_shield_ratio');
const idle = num(t.interval, 0);
const every = num(t['catsld_t_1[timer][interval].interval'], 1);
const maxRatio = num(t.max_shield_ratio, 0);
const refill = num(t.shield_ratio_each_trigger, 0);
const KEY = 'catsld:shield';
return {
skill: null,
trait: { noAttack: true },
install(battle, unit) {
const st = stateOf(battle);
battle.addBuff(unit, { key: 'token:catDevice', flags: { invulnerable: true }, persist: true, allowDead: true });
const ownerHp = () => { const o = ownerOf(unit); return o ? o.s.maxHp : unit.s.maxHp; };
const valid = (a) => a && a.alive && a.deployed && a.kind === 'op';
const pick = () => {
@@ -851,18 +972,19 @@ function catShield(bb, raw, def) {
const give = (a, amount) => {
const cap = ownerHp() * maxRatio;
if (!(cap > 0) || !(amount > 0)) return;
const b = a.findBuff(KEY);
if (b) { b.shield = Math.min(cap, (b.shield || 0) + amount); a.markDirty(); } else battle.addBuff(a, { key: KEY, shield: Math.min(cap, amount), duration: Infinity, source: unit });
const b = a.findBuff(CAT_SHIELD_KEY);
if (b) { b.shield = Math.min(cap, (b.shield || 0) + amount); a.markDirty(); } else battle.addBuff(a, { key: CAT_SHIELD_KEY, shield: Math.min(cap, amount), duration: Infinity, visible: true, source: unit });
};
onDeploy(battle, unit, () => {
const connect = () => {
const a = pick();
if (a) { give(a, ownerHp() * maxRatio); battle.fx('catShield', { x: a.x, y: a.y, id: a.id, from: unit.id }); }
});
return a;
};
onDeploy(battle, unit, connect);
battle.every(every, () => {
if (!unit.alive || !unit.deployed) return;
let a = unit.mem.target;
if (!valid(a)) a = pick();
if (!a) return;
const a = unit.mem.target;
if (!valid(a)) { connect(); return; }
const o = ownerOf(unit);
const skillOn = !!(o && o.alive && o.skill && o.skill.active && o.skill.isTimed);
const rate = skillOn ? num(o.skill.bb?.overwrite_ratio, refill) : refill;
@@ -1170,7 +1292,7 @@ function touchKit(bb, raw, def) {
unit.mem.touchExtraAt = n;
let best = target.hp < target.s.maxHp - 1e-6 ? target : null;
for (const a of battle.alliesInRadius(target.x, target.y, 1, unit.ownerId)) {
if (a === target || a.hp >= a.s.maxHp - 1e-6) continue;
if (a === target || a.hp >= a.s.maxHp - 1e-6 || a.s.flags.noHeal || a.profile?.noHeal) continue; // (禁疗 / 孤立)
if (Math.abs(a.tileR - target.tileR) + Math.abs(a.tileC - target.tileC) !== 1) continue;
if (!best || a.hpRatio < best.hpRatio) best = a;
}
@@ -1260,7 +1382,9 @@ export default kits;
* hand-authored kit without a spec for a non-default selected skill (content/index.js selectSkillSpec, `skillSource:
* 'generic'`) — and the data says that skill makes the token (owner-loadout `sources` has 'skill'). Placement per
* token; 迷迭香 S3 “如你所愿”: "立即在攻击范围内的近战位部署两个战术装备" (the token's deployLimit — 2, 20 with
* module uniequip_003_rosmon — only caps how many stand; SKILL_SUMMON_PER_CAST).
* module uniequip_003_rosmon — only caps how many stand; SKILL_SUMMON_PER_CAST). 医疗探机 / 诅咒娃娃 are hand pieces
* (data `placeable`): their entries only mark them as skill summons — releaseSkillSummon deploys the player's piece on
* its own tile, and their 'ally' / 'enemy' placement would apply only to data without such a piece.
*/
const SKILL_SUMMONS = Object.freeze({
[TOKEN_IDS.healDrone]: 'ally',
@@ -1326,6 +1450,9 @@ function ensureReinforcement(battle, owner, tokenId) {
export function install(battle) {
const st = stateOf(battle);
// the placed pieces of skill summons wait on their tiles for the skill (user playtest #6, see the header)
dockSkillSummons(battle);
// last deployed operator per player (“耀阳”: 上一名部署干员势力为【卡西米尔】) — tokens are not operators
battle.on('deploy', (ctx) => {
const u = ctx.unit;
@@ -1383,6 +1510,8 @@ export function install(battle) {
battle.on('skillStart', (ctx) => {
if (ctx.unit !== owner) return;
for (const t of skillSummons) {
// a hand piece (医疗探机 / 诅咒娃娃) deploys where the player placed it — nowhere when not placed
if (battle.data.rawToken?.(t)?.placeable === true) { releaseSkillSummon(battle, owner, t); continue; }
for (let i = 0; i < (SKILL_SUMMON_PER_CAST[t] ?? 1); i++) if (!summonToken(battle, owner, t, SKILL_SUMMONS[t])) break;
}
}, { owner });
@@ -1403,31 +1532,6 @@ export function install(battle) {
if (d) battle.fx('duskDragon', { x: d.x, y: d.y, id: d.id });
}, { owner });
}
if (toks.includes(TOKEN_IDS.catShield)) {
const deployDevices = () => {
if (!owner.alive || !owner.deployed) return;
const probe = battle.data.getToken?.(TOKEN_IDS.catShield, owner.defId, owner.def?.loadout ?? null);
const v = ownVariant(battle.data.rawToken?.(TOKEN_IDS.catShield), owner);
const limit = Math.max(1, Math.floor(num(v?.stats?.deployLimit, 1)));
const stock = Math.max(1, Math.floor(num(v?.count ?? v?.stats?.deckStack, limit)));
const grid = probe?.rangeGrid ?? [[0, 0], [0, 1]];
let placed = battle.allyUnits.filter((t) => t.alive && t.defId === TOKEN_IDS.catShield && t.ownerUnit === owner).length;
const used = new Set();
for (const k of owner.rangeKeys || []) {
if (placed >= Math.min(limit, stock)) break;
const r = (k / COLS) | 0, c = k % COLS;
if (!tileFree(battle, r, c) || !battle.grid.canStand(r, c, { ranged: true })) continue;
// the device helps the operator in its own range (the tile in front)
const keys = absoluteRangeKeys(grid, r, c, owner.dir, 0);
const ally = keys.map((kk) => battle.unitAt((kk / COLS) | 0, kk % COLS)).find((a) => a && a.kind === 'op' && a.ownerId === owner.ownerId && !used.has(a.id));
if (!ally) continue;
if (battle.spawnToken(owner, TOKEN_IDS.catShield, r, c)) { placed++; used.add(ally.id); }
}
};
// initial deployment: wait until every board unit stands (the devices need the operator in front of them)
battle.on('battleStart', deployDevices, { owner });
battle.on('deploy', (ctx) => { if (ctx.unit === owner && battle.started && !ctx.initial) deployDevices(); }, { owner, priority: -20 });
}
}
}
+9 -5
View File
@@ -268,10 +268,13 @@ function burstLockOf(u) {
/**
* The element gauge a unit shows (b.snap `elem`, render/units.js): null when every gauge is empty and no burst
* cooldown runs. Official display (PRTS 元素): one icon, the "当前损伤元素" — the fullest gauge (ties: the lower
* official element id, constants.js ELEMENT_ORDER) — with its remaining EP; during a 爆发冷却 the bursting element,
* refilling over the cooldown. Returns `[el, fill 0..1 (2 decimals), cooldownEnd (game s), cooldown (s)]`; the two
* cooldown numbers are 0 outside a cooldown. `now` = the battle time.
* cooldown runs. Official display (PRTS 元素): one icon, the "当前损伤元素" — the fullest gauge, i.e. the least 元素值
* left (ties: the lower official element id, constants.js ELEMENT_ORDER) — with a white bar of its remaining 元素值;
* during a 爆发冷却 the bursting element, refilling over the cooldown. Returns `[el, fill, cooldownEnd (game s),
* cooldown (s)]`; the two cooldown numbers are 0 outside a cooldown. `fill` has 2 decimals and is rounded DOWN
* (0.01–0.99 while no burst runs, 1 during one), so the remainder shown (1 − fill) is never less than what is left —
* except that a first chip of less than 1 % (under 10 元素值 of 1000) already shows as 1 % taken — and the bar only
* runs out when the gauge bursts (user playtest #6). `now` = the battle time.
*/
export function elementView(u, now) {
const e = u.elem;
@@ -286,7 +289,8 @@ export function elementView(u, now) {
let best = null, bv = 0;
for (const k of ELEMENT_ORDER) { const v = e[k]; if (v > bv) { bv = v; best = k; } }
if (!best) return null;
const fill = Math.min(1, Math.max(0.01, Math.round((bv / u.gaugeMax) * 100) / 100));
// rounded down (1e-9: 290 / 1000 × 100 is 28.999…), kept inside 1–99 %: a sliver shows, an empty bar = a burst
const fill = Math.min(0.99, Math.max(0.01, Math.floor((bv / u.gaugeMax) * 100 + 1e-9) / 100));
return [best, fill, 0, 0];
}
+235
View File
@@ -0,0 +1,235 @@
// server/sim/fear.js — 恐惧 movement of enemies (DESIGN §5.5; user playtest #6 item 13).
//
// Official rule (gamedata_const termDescriptionDict ba.fear "无法被阻挡并四散逃跑"; PRTS 术语释义 恐惧 "受影响的目标大体上会
// 尝试尽可能远离效果来源"; PRTS 诱发移动 §恐惧 — the fear controller):
// * 恐惧可达地块: when a fear is applied the controller records the tiles whose CENTRE lies within 10 tiles of the hit
// position (the feared unit's position then), inside the 90° fan pointing away from the effect source (±45°), that
// can reach the unit's goal, are passable terrain (obstacles or not) and are not the protection objective (no
// 地穴 in this mode). Without a source, with the unit itself as the source (自惧 / "对自身施加…恐惧", e.g. the
// “萨科塔” enemies' SelfFear) or with the source exactly on the hit position there is NO reachable tile.
// * 恐惧移动: the unit picks a random reachable tile as a temporary checkpoint, offset at random inside a square of
// side 0.5 around the tile centre, and moves there; on arrival it picks the next one, until the fear ends. A picked
// tile farther than 5 path steps is dropped from the list for good and the unit's own tile is used instead; with
// no reachable tile the checkpoint is always its own tile (same offset) — so a self-feared unit flutters around
// inside its tile ("小范围乱飞"). A new fear refreshes the list from the current position (Battle.applyStatus
// stamps `buff.data.fear` on every application). Waiting checkpoints keep counting down meanwhile; when the fear
// ends the unit re-plans its route from where it stands ("恐惧效果结束时立刻清除【恐惧移动】" — so does a running
// 诱导, ai.js moveAttracted). 恐惧 outranks 诱导 (PRTS 诱移优先级).
// Path steps (the 5-step limit): 4-neighbour steps over the unit's passable, obstacle-free tiles, the checkpoint tile
// included (ground: walkable, blocks and crates excluded — a crate's move cost 1000 is beyond any 5-step budget and a
// block has no path; flyers: fly-passable), like the official SPFA's unit edges.
// The temporary path: ground units walk the grid's smoothed flow-field waypoints to the checkpoint's tile (stepping
// back to their tile centre first when the straight line from an off-centre position would clip a tile they cannot
// walk, as ai.js planLeg), flyers fly straight. Like every enemy path it re-plans from where the unit stands when
// something else moved it (a push / pull — Battle.displace) or an obstacle changed (grid.version): to the same
// checkpoint while it stays reachable, else to a new pick. Speed = the unit's own (move-speed buffs such as the
// SelfFear ×1.5 included). Deterministic: every draw is battle.rng().
import { MOVE_SCALE, COLS } from './constants.js';
import { straightClear } from './grid.js';
/** 半径10格 — the fan's radius (tiles). */
export const FEAR_RADIUS = 10;
/** 左右各45° — cos of the fan's half angle. */
export const FEAR_HALF_COS = Math.SQRT1_2;
/** 寻路距离5格内 — the farthest checkpoint in path steps. */
export const FEAR_REACH = 5;
/** 0.5边长正方形的随机偏移 — side of the random offset square around a checkpoint tile's centre. */
export const FEAR_JITTER = 0.5;
/**
* Stamp a fear application on its buff (Battle.applyStatus): the hit position, the source's position and whether
* the unit feared itself. Every application gets a new per-battle `seq`, which makes the mover rebuild its tiles.
*/
export function stampFear(b, target, buff, source) {
const self = !source || source === target;
b._fearSeq = (b._fearSeq || 0) + 1;
buff.data = {
...buff.data,
fear: {
seq: b._fearSeq, hx: target.x, hy: target.y, self,
sx: self ? target.x : Number(source.x), sy: self ? target.y : Number(source.y),
},
};
}
/** The latest fear stamp among the unit's fear buffs (null when none carries one — e.g. a raw addBuff). */
function stampOf(e) {
let best = null;
for (const b of e.buffs) {
const f = (b.status === 'fear' || b.key === 'fear') && b.data ? b.data.fear : null;
if (f && (!best || f.seq > best.seq)) best = f;
}
return best;
}
/** The goal tile of the enemy's route (its final leg), or null. */
function goalOf(e) {
const legs = e.route && e.route.legs;
if (!legs) return null;
for (let i = legs.length - 1; i >= 0; i--) if (legs[i].final && legs[i].r != null) return legs[i];
return null;
}
/** Passable terrain for this unit, obstacles or not ("不论该格是否有障碍物"). */
const passable = (g, fly, r, c) => (fly ? g.flyPassable(r, c) : g.walkable(r, c, true));
/**
* 恐惧可达地块 of a stamp: tile keys in row-major order (deterministic).
* @returns {number[]}
*/
export function reachableTiles(b, e, st) {
if (!st || st.self) return [];
const dx = st.hx - st.sx, dy = st.hy - st.sy, d = Math.hypot(dx, dy);
if (!(d > 1e-9)) return [];
const ux = dx / d, uy = dy / d;
const g = b.grid, R = g.rect, fly = e.motion === 'FLY';
const goal = goalOf(e);
let field = null, field2 = null;
if (!fly && goal) { field = g.flowField(goal.r, goal.c); field2 = g.flowField(goal.r, goal.c, { ignoreObstacles: true }); }
const out = [];
for (let r = R.r0; r <= R.r1; r++) {
for (let c = R.c0; c <= R.c1; c++) {
const vx = c - st.hx, vy = r - st.hy, L = Math.hypot(vx, vy);
if (L > FEAR_RADIUS + 1e-9) continue;
if (L > 1e-9 && vx * ux + vy * uy < FEAR_HALF_COS * L - 1e-9) continue;
if (!passable(g, fly, r, c) || g.tile(r, c).special === 'end') continue;
const k = r * COLS + c;
if (field && field.dist[k] < 0 && field2.dist[k] < 0) continue; // 该格可抵达其终点
out.push(k);
}
}
return out;
}
/** Whether the unit can stand on / step through tile key `k` (its passable terrain; ground: no block, no crate). */
function openTile(g, fly, k) {
const r = (k / COLS) | 0, c = k - r * COLS;
return passable(g, fly, r, c) && (fly || !g.isObstacle(r, c));
}
/**
* Path steps (4-neighbour, the unit's open tiles, `to` included) from tile key `from` to `to`, or Infinity beyond
* `limit` — an obstacle tile has no path ("寻路距离"), so a pick on one is dropped like a far one.
*/
function stepsBetween(b, e, from, to, limit) {
if (from === to) return 0;
const g = b.grid, fly = e.motion === 'FLY';
const open = (k) => openTile(g, fly, k);
if (!open(to)) return Infinity;
let frontier = [from];
const seen = new Set(frontier);
for (let d = 1; d <= limit && frontier.length; d++) {
const next = [];
for (const k of frontier) {
const r = (k / COLS) | 0, c = k - r * COLS;
for (const [dr, dc] of [[1, 0], [0, 1], [-1, 0], [0, -1]]) {
const rr = r + dr, cc = c + dc;
if (!g.inRect(rr, cc)) continue;
const kk = rr * COLS + cc;
if (seen.has(kk)) continue;
if (kk === to) return d;
seen.add(kk);
if (open(kk)) next.push(kk);
}
}
frontier = next;
}
return Infinity;
}
/** The unit's tile (row, col, key), clamped to the rect. */
function hereOf(g, e) {
const r = Math.max(g.rect.r0, Math.min(g.rect.r1, Math.round(e.y)));
const c = Math.max(g.rect.c0, Math.min(g.rect.c1, Math.round(e.x)));
return { r, c, k: r * COLS + c };
}
/**
* The temporary path from where the unit stands to its checkpoint (`m.k` tile, `m.goal` point): ground units follow
* the smoothed flow-field waypoints (null when an obstacle now cuts the tile off), flyers fly straight. Records the
* grid version; moveFeared records where each step left the unit and re-plans when either changes behind its back.
* @returns {boolean} false when a ground unit can no longer reach the checkpoint tile
*/
function planPath(b, e, m) {
const g = b.grid, h = hereOf(g, e);
const pts = [];
if (e.motion !== 'FLY' && m.k !== h.k) {
if (!openTile(g, false, m.k)) return false;
const r = (m.k / COLS) | 0, c = m.k - r * COLS;
const wp = g.waypoints(h.r, h.c, r, c);
if (!wp) return false;
for (let j = 1; j < wp.length - 1; j++) pts.push({ x: wp[j][1], y: wp[j][0] });
// the smoothed chain is line-of-sight clear from the tile CENTRE only (as ai.js planLeg)
const first = pts.length ? pts[0] : { x: c, y: r };
if ((e.x !== h.c || e.y !== h.r) && !straightClear(g, e.x, e.y, first)) pts.unshift({ x: h.c, y: h.r });
}
pts.push(m.goal);
m.pts = pts;
m.i = 0;
m.ver = g.version;
return true;
}
/** Choose the next temporary checkpoint (a random reachable tile within 5 path steps, else the own tile) and plan to it. */
function pickCheckpoint(b, e, m) {
const here = hereOf(b.grid, e).k;
let k = here;
if (m.tiles.length) {
const i = Math.floor(b.rng() * m.tiles.length);
const cand = m.tiles[i];
if (stepsBetween(b, e, here, cand, FEAR_REACH) <= FEAR_REACH) k = cand;
else m.tiles.splice(i, 1); // "…格子会永久从当次恐惧的恐惧可达地块中剔除"
}
const ox = (b.rng() - 0.5) * FEAR_JITTER, oy = (b.rng() - 0.5) * FEAR_JITTER;
const aim = (key) => {
const r = (key / COLS) | 0, c = key - r * COLS;
m.k = key;
m.goal = { x: c + ox, y: r + oy };
return planPath(b, e, m);
};
// (a pick within 5 open steps has a path unless the unit itself stands on a block; the own tile needs none)
if (!aim(k)) aim(here);
}
/**
* One tick of fear movement for enemy `e` (ai.js updateEnemy, while `e.s.flags.fear`). Sets `e.moving`.
*/
export function moveFeared(b, e, dt) {
const st = stampOf(e);
let m = e.mem.fearMove;
if (!m || (st && m.seq !== st.seq)) {
m = e.mem.fearMove = { seq: st ? st.seq : -1, tiles: reachableTiles(b, e, st), pts: null, i: 0 };
}
// pushed / pulled (Battle.displace) or an obstacle changed since the last tick: re-plan the walk from here — to the
// same checkpoint while it stays reachable, else a new pick (a stale path would walk through walls)
if (m.pts && (m.ver !== b.grid.version || e.x !== m.px || e.y !== m.py) && !planPath(b, e, m)) m.pts = null;
// a waiting checkpoint of the route keeps counting down ("停驻时间会相应扣除恐惧生效时间")
const R = e.route, leg = R && R.legs ? R.legs[R.legIdx] : null;
if (leg && leg.t === 'wait') {
if (R.waitLeft == null) R.waitLeft = leg.time;
R.waitLeft -= dt;
if (R.waitLeft <= 1e-9) { R.waitLeft = null; R.legIdx++; }
}
if (R) R.pts = null; // the route re-plans from wherever the fear leaves the unit
const speed = e.s.moveSpeed * MOVE_SCALE;
let moved = false;
if (speed > 0) {
let dist = speed * dt;
for (let guard = 8; dist > 1e-9 && guard > 0; guard--) {
if (!m.pts || m.i >= m.pts.length) pickCheckpoint(b, e, m);
const p = m.pts[m.i];
const dx = p.x - e.x, dy = p.y - e.y, d = Math.hypot(dx, dy);
if (d <= dist) { e.x = p.x; e.y = p.y; dist -= d; m.i++; } else { e.x += (dx / d) * dist; e.y += (dy / d) * dist; dist = 0; }
moved = moved || d > 1e-9;
}
}
m.px = e.x; m.py = e.y;
e.moving = moved;
}
/** The fear ended: forget the controller; the route re-plans from here (route.pts was cleared while feared). */
export function endFear(e) {
e.mem.fearMove = null;
if (e.route) e.route.pts = null;
}
+26 -1
View File
@@ -34,7 +34,8 @@
// platforms, research 08 §8 #1]) and CRATE (阻隔工事 devices — `setObstacle(r, c, on, 'crate')`: walkable at cost 1000,
// blocks line of sight; an enemy that must cross one is blocked by the device and breaks it). Any change bumps
// `version`, which invalidates the cached fields (enemies re-read their next step). `groundPassable` (placement,
// displacement, content) still treats both kinds as occupied unless `ignoreObstacles`.
// displacement, content) still treats both kinds as occupied unless `ignoreObstacles`. `straightClear` tells whether an
// off-centre unit may walk straight to a tile centre (ai.js planLeg, fear.js: else it steps back to its tile centre).
import { ROWS, COLS } from './constants.js';
@@ -427,3 +428,27 @@ export function bresenhamTiles(a, b) {
}
return out;
}
/**
* Whether a straight move from (x, y) to the centre of tile p = {x: col, y: row} only crosses ground-walkable,
* crate-free tiles (the start and end tiles excepted). Exact grid traversal (tile (r,c) spans c ± 0.5, r ± 0.5); a
* line through a tile corner needs both side tiles clear (as the grid's Bresenham smoothing).
*/
export function straightClear(g, x, y, p) {
let c = Math.round(x), r = Math.round(y);
const dx = p.x - x, dy = p.y - y;
const sc = dx > 0 ? 1 : -1, sr = dy > 0 ? 1 : -1;
const ddx = dx !== 0 ? Math.abs(1 / dx) : Infinity, ddy = dy !== 0 ? Math.abs(1 / dy) : Infinity;
let tx = dx !== 0 ? (c + 0.5 * sc - x) / dx : Infinity;
let ty = dy !== 0 ? (r + 0.5 * sr - y) / dy : Infinity;
const clear = (rr, cc) => (rr === p.y && cc === p.x) || (g.walkable(rr, cc) && !(g.obstacle[rr * COLS + cc] & OB_CRATE));
for (let guard = 4 * COLS; guard > 0 && (r !== p.y || c !== p.x); guard--) {
if (tx >= 1 && ty >= 1) break;
if (Math.abs(tx - ty) < 1e-9) {
if (!clear(r, c + sc) || !clear(r + sr, c)) return false;
c += sc; r += sr; tx += ddx; ty += ddy;
} else if (tx < ty) { c += sc; tx += ddx; } else { r += sr; ty += ddy; }
if (!clear(r, c)) return false;
}
return true;
}
+6 -3
View File
@@ -104,12 +104,13 @@ export function normalizeSkill(rec) {
let rule = normTriggerRule(typeof trig === 'string' ? trig : trig.rule);
const matchedBy = typeof trig === 'object' ? String(trig.matchedBy ?? '') : '';
const skillIndex = s.skillIndex ?? s.index ?? null;
// Research correction (03 addendum C1): profession/subprofession rows apply to skill 1 only.
if (/^(profession|subProfession)/.test(matchedBy) && skillIndex != null && skillIndex !== 0) rule = 'DEFAULT';
const skillType = String(s.skillType ?? s.type ?? 'MANUAL').toUpperCase();
// Class rows (profession / subprofession, research-shaped records carry `matchedBy`) apply to every MANUAL skill of the
// class and never to an AUTO one (PRTS 卫戍协议/帮助 技能策略; tools/build-data.mjs resolveTrigger does the same for data).
if (/^(profession|subProfession)/.test(matchedBy) && skillType !== 'MANUAL') rule = 'DEFAULT';
const trigGrid = toArrayOfPairs((typeof trig === 'object' && (trig.customRangeGrid ?? trig.rangeGrid ?? trig.grid)) || rec.skillTriggerCustomRangeGrid);
if (/^skcom_withdraw/.test(String(s.skillId ?? s.id ?? ''))) return null;
const durRaw = s.duration ?? bb.duration ?? 0;
const skillType = String(s.skillType ?? s.type ?? 'MANUAL').toUpperCase();
return {
id: s.skillId ?? s.id ?? 'skill',
name: s.name ?? '',
@@ -300,6 +301,8 @@ export function normalizeToken(id, t0, ownerChessId = null, variantOverride = nu
canHitFly: t.canHitFly ?? null,
targetPriority: t.targetPriority ?? null,
untargetable: t.untargetable ?? /不会受到攻击|不会成为/.test(desc),
// abnormal effects held from the start (tokens.json `abnormal`, PRTS summon pages): 'healFree' 禁疗, 'isolated' 孤立
abnormal: Array.isArray(t0.abnormal) ? t0.abnormal.filter((x) => typeof x === 'string') : [],
immune: immuneSet(t.immunities),
trait: desc,
traitBb: t.trait && typeof t.trait === 'object' ? { ...(t.trait.bb || {}) } : {},
+94 -16
View File
@@ -4,14 +4,30 @@
// No SP gain while a duration/ammo/toggle skill is active, while stunned, or while the unit has the noSp flag (阻回: no SP
// gain of any kind — time, attack, hurt or granted).
// Charges (maxCharges > 1): SP fills to spCost → +1 charge (SP restarts) until charges == max (SP stays full).
// Trigger rules: DEFAULT (ready + about to attack/heal + enemy / injured ally in the INITIAL range — or, checked every
// tick, an enemy inside one of the content trigger ranges added with addTriggerRange: 海嗣, 流形),
// TAKE_DAMAGE (ready + just took a hit), SP_FULL/ALWAYS (as soon as ready), CUSTOM_RANGE (enemy inside the
// custom trigger grid), SEARCH (an enemy inside the INITIAL range, checked every tick without waiting for an attack:
// BWIKI "解放者/阵法术师子职业干员 技能1,初始攻击范围内出现敌人后自动释放", research 03 Addendum C1 — not any enemy on
// the field, which burnt the skill on enemies that had just spawned), GDGLOW_SKILL_2 = DEFAULT, NEVER/MANUAL (never
// Trigger rules (the official 技能策略, PRTS 卫戍协议/帮助 §作战阶段 技能操作; data: tools/build-data.mjs resolveTrigger):
// DEFAULT — the basic strategy: ready + about to attack/heal + enemy / injured ally in the INITIAL range (or blocked by
// a melee unit) — or, checked every tick, an enemy inside one of the content trigger ranges added with
// addTriggerRange: 海嗣, 流形;
// SKILL_RANGE — a MANUAL skill with a 技能范围 of its own: "不通过普通攻击/治疗触发技能,仅在技能范围内存在敌人(无视其
// 不可选中)时释放技能": any living enemy on the trigger grid (the skill range; stealthed / untargetable / flying ones
// too), checked every tick, no attack needed. Kit option `trigger.allies` (+ `hpAtMost`, default 1): a healable,
// injured ally of the grid whose HP ratio is at most that instead (an AUTO heal skill's own rule — 古米 S1 waits in
// its heal mode until it has healed);
// TAKE_DAMAGE (ready + just took a hit: 重装 "不受技能范围影响,受到伤害时释放技能"), SP_FULL/ALWAYS (as soon as ready),
// CUSTOM_RANGE (enemy inside the custom trigger grid), SEARCH (an enemy inside the INITIAL range, checked every tick
// without waiting for an attack: "不受基础策略影响,在初始攻击范围内存在敌人时释放技能" — not any enemy on the field,
// which burnt the skill on enemies that had just spawned), GDGLOW_SKILL_2 ("全场存在可选目标时释放技能": a targetable
// enemy anywhere on the field — for a heal skill an ally that needs healing — every tick), NEVER/MANUAL (never
// auto-cast: the kit calls skill.activate() itself); any unknown rule falls back to DEFAULT. Units that never attack
// (noAttack profiles) evaluate DEFAULT every tick instead.
// (noAttack profiles) evaluate DEFAULT every tick instead. DEFAULT with `trigger.allies` (+ `hpAtMost`): the basic
// rule AND such an ally on the trigger grid — the cast replaces the attack about to be made (塞雷娅 S1 "触发时会替换当
// 次攻击"); should that ally condition fail before the attack, the cast is withdrawn and its charge returned.
// Automatic operations cool down (constants.js AUTO_OP_COOLDOWN, "自动操作具有3s冷却,在完成一次操作或作战开始时部署的单位
// 将进入冷却"): the engine auto-casts a MANUAL skill (def.skillType) no sooner than 3 s after its previous cast (so a
// charged skill spends its charges 3 s apart) or after the unit's deployment at the battle start (Battle._deploy
// `initial`). AUTO skills are exempt; a kit with its own automatic cast checks `opCooling`; a cast made by activate()
// directly is not held back (it still starts the cooldown). While a cast "next attack" waits for its attack, no
// further charge is cast.
// Kinds: duration (mods for `duration` s), ammo (mods until `ammo` attacks were made; optional duration cap),
// instant (onStart + optional one-shot attack override for the next attack), charges (= instant with charges),
// passive (always on from deployment, no SP), toggle (stays on until death once activated).
@@ -23,11 +39,14 @@
// also carries `noAmmo` (set it to true: this attack spends no ammo). onEnd runs while the skill's mods / range are
// still applied (`active` is already false); they are removed right after it (unless onEnd re-activated the skill).
import { absoluteRangeKeys } from './targeting.js';
import { absoluteRangeKeys, canTargetEnemy } from './targeting.js';
import { AUTO_OP_COOLDOWN, COLS, ROWS } from './constants.js';
const TICK_RULES = new Set(['SP_FULL', 'SEARCH', 'CUSTOM_RANGE']);
const TICK_RULES = new Set(['SP_FULL', 'SEARCH', 'CUSTOM_RANGE', 'SKILL_RANGE', 'GDGLOW_SKILL_2']);
/** Enemies that satisfy a content trigger range (any targetable enemy, flyers included). */
const TRIGGER_PROFILE = Object.freeze({ canHitFly: true });
/** Every tile of the stage (GDGLOW_SKILL_2: the whole field). */
const ALL_TILES = new Set(Array.from({ length: ROWS * COLS }, (_, i) => i));
export class SkillRuntime {
/**
@@ -60,7 +79,14 @@ export class SkillRuntime {
if (this.rule === 'MANUAL') this.rule = 'NEVER';
if (this.rule.startsWith('CUSTOM_RANGE')) this.rule = 'CUSTOM_RANGE';
this.triggerGrid = trig.grid ?? trig.rangeGrid ?? d.trigger?.grid ?? null;
// kit options: an injured, healable ally of the trigger grid with an HP ratio of at most `hpAtMost` (SKILL_RANGE:
// instead of an enemy; DEFAULT: in addition to the basic rule)
this.triggerAllies = !!trig.allies;
this.triggerHpAtMost = Number.isFinite(+trig.hpAtMost) && +trig.hpAtMost > 0 ? +trig.hpAtMost : 1;
this.healSkill = s.heal ?? (unit.profile && unit.profile.dmgType === 'heal' && !!unit.profile.heal);
// the official skill strategies automate the manual 开启: only MANUAL skills wait for the operation cooldown
this.manual = String(d.skillType ?? 'MANUAL').toUpperCase() === 'MANUAL';
this.opReadyAt = -Infinity; // no automatic cast before this battle time (AUTO_OP_COOLDOWN)
if (this.kind === 'passive') this.baseSpCost = 0;
this._spCostMul = 1; // content may set spCostMul (e.g. 绝技 ×0.7) — see the accessor below
this.sp = 0;
@@ -73,6 +99,7 @@ export class SkillRuntime {
this.lastStart = -Infinity;
this._buffKey = `skill:${unit.id}`;
this._trigKeys = null;
this._trigSet = null;
this.triggerRanges = []; // content trigger ranges (addTriggerRange)
this.noSkill = !spec; // unit without any skill spec
}
@@ -140,6 +167,8 @@ export class SkillRuntime {
this.charges = 0;
this.sp = 0;
this._trigKeys = null;
this._trigSet = null;
this.opReadyAt = -Infinity; // (Battle._deploy starts the operation cooldown of the battle-start deployment)
if (this.noSkill) return;
if (this.kind === 'passive') {
this._startPassive();
@@ -235,8 +264,16 @@ export class SkillRuntime {
const rate = u.s.spRecovery;
if (rate > 0) this.gainSp(rate * dt, 'time');
}
// a DEFAULT cast bound to an ally condition (塞雷娅 S1) replaces the attack about to be made: should the condition
// have failed before that attack (the ally healed meanwhile), the cast is withdrawn — no heal mode stays behind
if (this.pending && this.triggerAllies && this.rule !== 'SKILL_RANGE' && !this._allyTriggerSatisfied()) {
this.end('withdrawn');
this.addCharge(1);
}
if (!this.ready || u.s.flags.silence) return;
if (this.active && this.isTimed) return;
// a cast "next attack" still waits for its attack: another charge now would be spent on the same attack
if (this.pending || this._opCooling()) return;
if (TICK_RULES.has(this.rule)) {
if (this._tickRuleSatisfied()) this.activate(this.rule);
} else if (this.rule !== 'TAKE_DAMAGE' && this.rule !== 'NEVER') {
@@ -249,17 +286,55 @@ export class SkillRuntime {
}
}
/** The automatic operations of a MANUAL skill are cooling down (AUTO_OP_COOLDOWN). */
_opCooling() { return this.manual && this.battle.time < this.opReadyAt - 1e-9; }
/** Public form of the operation cooldown, for kits with their own automatic cast of a MANUAL skill. */
get opCooling() { return this._opCooling(); }
/** Absolute tile keys of the trigger grid at the unit's current tile and direction (cached, with their Set). */
_triggerKeys() {
const u = this.unit;
const tile = u.tileR * COLS + u.tileC;
if (!this._trigKeys || this._trigTile !== tile || this._trigDir !== u.dir) {
this._trigKeys = absoluteRangeKeys(this.triggerGrid, u.tileR, u.tileC, u.dir, 0);
this._trigSet = new Set(this._trigKeys);
this._trigTile = tile;
this._trigDir = u.dir;
}
return this._trigKeys;
}
/** `trigger.allies`: an injured, healable ally on the trigger grid (the unit's own range without one) ≤ hpAtMost. */
_allyTriggerSatisfied() {
const b = this.battle;
const u = this.unit;
let keys;
if (this.triggerGrid) { this._triggerKeys(); keys = this._trigSet; } else keys = u.baseRangeKeys || u.rangeKeys;
const lim = this.triggerHpAtMost + 1e-9;
return b.injuredAlliesInKeys(keys, u).some((a) => a.hpRatio <= lim);
}
_tickRuleSatisfied() {
const b = this.battle;
const u = this.unit;
if (this.rule === 'SP_FULL') return true;
// SEARCH: an enemy inside the initial attack range (research 03 Addendum C1), every tick (librators / phalanxes
// and 安洁莉娜 do not attack while the skill is off, so DEFAULT's "about to attack" never comes)
// SEARCH: an enemy inside the initial attack range, every tick (librators / phalanxes and 安洁莉娜 do not attack
// while the skill is off, so DEFAULT's "about to attack" never comes)
if (this.rule === 'SEARCH') return this._defaultCondition();
if (this.rule === 'CUSTOM_RANGE') {
if (!this.triggerGrid) return this._defaultCondition();
if (!this._trigKeys) this._trigKeys = absoluteRangeKeys(this.triggerGrid, u.tileR, u.tileC, u.dir, 0);
return b.enemiesInKeys(this._trigKeys, u, { canHitFly: true }).length > 0;
return b.enemiesInKeys(this._triggerKeys(), u, { canHitFly: true }).length > 0;
}
if (this.rule === 'SKILL_RANGE') {
if (this.triggerAllies) return this._allyTriggerSatisfied();
if (!this.triggerGrid) return this._defaultCondition();
return b.anyEnemyInKeys(this._triggerKeys());
}
// GDGLOW_SKILL_2 "全场存在可选目标时释放技能": a targetable enemy anywhere (heal skill: an ally that needs healing)
if (this.rule === 'GDGLOW_SKILL_2') {
if (this.healSkill) return b.injuredAlliesInKeys(ALL_TILES, u, !!u.profile?.heal?.elementHealRatio).length > 0;
return b.enemies.some((e) => canTargetEnemy(u, e, TRIGGER_PROFILE));
}
return false;
}
@@ -277,8 +352,8 @@ export class SkillRuntime {
if (this.healSkill) return b.injuredAlliesInKeys(keys, u).length > 0;
if (b.enemiesInKeys(keys, u, u.profile).length > 0) return true;
}
// the enemies a melee unit blocks are always its targets (Battle.blockedTargets), in range or not — PRTS 卫戍协议/帮助
// "敌人被近战干员自身阻挡" satisfies the target condition of the basic strategy
// the enemies a unit blocks are always its targets (Battle.blockedTargets), in range or not — PRTS 卫戍协议/帮助
// "敌人被近战干员自身阻挡" satisfies the target condition of the basic strategy (a ranged blocker too: user playtest #6)
if (!this.healSkill && u.blocking.length && b.blockedTargets(u, u.profile).length > 0) return true;
return !this.healSkill && this.triggerRanges.length > 0 && this._extraTriggerSatisfied();
}
@@ -288,7 +363,9 @@ export class SkillRuntime {
if (!this.ready || this.unit.s.flags.silence) return false;
if (this.active && this.isTimed) return false;
if (this.rule === 'TAKE_DAMAGE' || this.rule === 'NEVER' || TICK_RULES.has(this.rule)) return false;
if (this.pending || this._opCooling()) return false;
if (!this._defaultCondition()) return false;
if (this.triggerAllies && !this._allyTriggerSatisfied()) return false;
return this.activate('DEFAULT');
}
@@ -296,7 +373,7 @@ export class SkillRuntime {
onDamaged() {
if (this.noSkill) return;
if (this.spType === 'hurt' && !(this.active && this.isTimed)) this.gainSp(1, 'hurt');
if (this.rule === 'TAKE_DAMAGE' && this.ready && !(this.active && this.isTimed) && this.unit.canAct && !this.unit.s.flags.silence) {
if (this.rule === 'TAKE_DAMAGE' && this.ready && !this.pending && !(this.active && this.isTimed) && this.unit.canAct && !this.unit.s.flags.silence && !this._opCooling()) {
this.activate('TAKE_DAMAGE');
}
}
@@ -316,6 +393,7 @@ export class SkillRuntime {
this.activations++;
this.lastStart = this.battle.time;
const b = this.battle;
if (this.manual && reason !== 'carry') this.opReadyAt = b.time + AUTO_OP_COOLDOWN;
if (this.isTimed) {
this.active = true;
this.timeLeft = this.kind === 'duration' ? Math.max(0.01, this.duration) : (this.kind === 'ammo' && this.duration > 0 ? this.duration : Infinity);
+2 -1
View File
@@ -48,7 +48,8 @@ export function flagsOf(u) {
if (u.side === 'enemy' ? !!u.blockedBy : u.blocking.length > 0) bits |= UF.BLOCKED;
if (f.stun && !f.freeze && !f.sleep) bits |= UF.STUNNED;
if (f.freeze) bits |= UF.FROZEN;
if (f.stealth) bits |= UF.STEALTH;
// 隐匿 or 迷彩 (buffs.js camou): both shown the see-through way, blocking or not (targeting.js canTargetAlly)
if (f.stealth || f.camou) bits |= UF.STEALTH;
if (u.skill && u.skill.active && u.skill.kind !== 'passive') bits |= UF.SKILL;
if (u.s.shield > 0 || u.buffs.some((b) => b.shieldHits > 0)) bits |= UF.SHIELD;
if (f.invulnerable) bits |= UF.INVULN;
+46 -5
View File
@@ -23,6 +23,7 @@
import { Battle } from './Battle.js';
import { toDataSource, withUnitLoadouts } from './simdata.js';
import { BOSS_POOL_MIN_HP } from './constants.js';
export const SPEC_VERSION = 1;
@@ -133,10 +134,14 @@ export function createBattleFromSpec(spec, dataSource, opts = {}) {
/**
* Client-side view of the shared boss HP pool (DESIGN §14 b.pool): the server owns the pool; this field adds its own
* damage locally and shows `server hp − local damage the server has not acknowledged yet`.
* damage(playerId, amount) called by the sim; returns the damage dealt (≤ the remaining displayed hp)
* damage(playerId, amount) called by the sim; returns the damage dealt (≤ the remaining displayed hp; the hit that
* would leave less than BOSS_POOL_MIN_HP (1) takes the rest)
* sync(serverHp, ackedCum) a b.pool broadcast: the server's hp and how much of THIS field's cumulative damage
* (`cum`) it has already counted
* cum / byPlayer cumulative damage of this field (reported as b.progress.bossDmg / .by)
* `hp` below 1 reads 0 (the leader is down; user playtest #6 item 5): the difference of the server's float hp and the
* local counters can leave dust (3.6e-12) smaller than half an ulp of `cum`, which no hit could remove — `cum += dust`
* changes nothing — so the leader stood at "0 HP" and the field never ended.
*/
export class LocalBossPool {
constructor(maxHp, hp = maxHp) {
@@ -149,7 +154,10 @@ export class LocalBossPool {
this.byPlayer = {};
}
get hp() { return Math.max(0, this.serverHp - Math.max(0, this.cum - this.acked)); }
get hp() {
const h = this.serverHp - Math.max(0, this.cum - this.acked);
return h < BOSS_POOL_MIN_HP ? 0 : h;
}
/** Only used by the sim's fallback path when damage() throws. */
set hp(v) {
@@ -161,7 +169,7 @@ export class LocalBossPool {
const a = Number(amount);
const left = this.hp;
if (!Number.isFinite(a) || a <= 0 || left <= 0) return 0;
const dealt = Math.min(left, a);
const dealt = left - a < BOSS_POOL_MIN_HP ? left : a;
this.cum += dealt;
if (playerId != null) this.byPlayer[playerId] = (this.byPlayer[playerId] || 0) + dealt;
return dealt;
@@ -198,9 +206,37 @@ export function attachLpMeter(battle) {
return meter;
}
/**
* 联防 (user playtest #6 item 7): each source player's enemies still standing on a unite field — not spawned yet,
* alive, or already through the objective again — i.e. what settlement would charge them if the battle ended now
* (server/match/unite.js uniteSurvivors: the unite result's counted leaks + unspawned entries by `sourcePlayerId`;
* alive enemies become leaks on a timeout, keyed like Battle._recordLeak). It falls as the helpers strike them down and
* rises when one splits or summons (content-spawned children inherit the parent's sourcePlayerId).
* `{ [playerId]: n }` (a player with none left is absent), or null for a battle without the sim's state (a stand-in).
* @returns {Record<string, number> | null}
*/
export function uniteLeft(battle) {
if (!battle) return null;
const out = {};
const add = (pid) => { if (typeof pid === 'string' && pid) out[pid] = (out[pid] || 0) + 1; };
if (battle.finished && typeof battle.result === 'function') {
const r = battle.result();
if (!r || !r.perPlayer) return null;
for (const pp of Object.values(r.perPlayer)) for (const l of (pp && pp.leaked) || []) if (l && l.counted !== false) add(l.sourcePlayerId);
for (const u of r.unspawned || []) if (u) add(u.sourcePlayerId);
return out;
}
if (!battle._perPlayer || !Array.isArray(battle._pending) || !Array.isArray(battle.enemies)) return null;
for (const p of battle._pending) if (p) add(p.sourcePlayerId);
for (const e of battle.enemies) if (e && e.alive && e.counted) add(e.sourcePlayerId ?? e.ownerId);
for (const pp of Object.values(battle._perPlayer)) for (const l of (pp && pp.leaked) || []) if (l && l.counted !== false) add(l.sourcePlayerId);
return out;
}
/**
* Progress numbers of a battle for b.progress / the teammates' waiting UI: game time, kills, total, counted leaks
* (normal / unite) and the boss pool damage of this field.
* (normal / unite), the boss pool damage of this field and — unite fields — `left` (uniteLeft: each leaker's enemies
* still standing).
*/
export function battleProgress(battle) {
const r = battle && typeof battle.result === 'function' && battle.finished ? battle.result() : null;
@@ -209,7 +245,7 @@ export function battleProgress(battle) {
for (const k of Object.keys(pp)) for (const l of pp[k].leaked || []) if (l && l.counted !== false) leaks++;
const pool = battle && battle.sharedBoss;
const gt = Number(battle && battle.time) || 0;
return {
const out = {
gt: Math.round(gt * 1000) / 1000,
killed: Math.max(0, Math.trunc(Number(battle && battle.killed) || 0)),
total: Math.max(0, Math.trunc(Number(battle && battle.total) || 0)),
@@ -217,6 +253,11 @@ export function battleProgress(battle) {
bossDmg: pool && Number.isFinite(pool.cum) ? pool.cum : 0,
done: !!(battle && battle.finished),
};
if (battle && battle.kind === 'unite') {
const left = uniteLeft(battle);
if (left) out.left = left;
}
return out;
}
const r4 = (v) => Math.round((Number(v) || 0) * 1e4) / 1e4;
+8 -18
View File
@@ -5,7 +5,8 @@
// it (round(y), round(x)) is one of the absolute range tiles — a huge enemy (巨型单位) when any tile its hit rectangle
// occupies is (body.js; user playtest #5 item 10). `rangeExtend` (ability_range_forward_extend) adds N tiles
// past the furthest cell of every row, along +dCol BEFORE rotating (DESIGN §3).
// Operator priority: (1) enemies it blocks (melee units only: meleeUnit), (2) profile priority (fly/lowDef/…),
// Operator priority: (1) enemies it blocks (every blocker, ranged ones on melee tiles included — user playtest #6
// follow-up "阻挡了就一定要能打到"; Battle.blockedTargets), (2) profile priority (fly/lowDef/…),
// (3) higher enemy taunt, (4) least remaining path distance to the goal, (5) earliest spawned. Air units
// (Unit.isFlying: FLY, 近地悬浮, 浮空) need a profile that can hit them (`canHitFly`, never `groundOnly`). Enemy
// priority: sortAllyTargets.
@@ -49,18 +50,6 @@ export function tileKeyOf(u) {
return r * COLS + c;
}
/**
* A melee unit (近战位: data `position` MELEE — melee-tile operators and summons, 重装要塞 / 领主 / 哨戒铁卫 included):
* it may always select, and selects first, the enemies it blocks — PRTS 选择器 "可以选择且优先选择阻挡单位" (the
* BlockedOrAdvancedSelector of units whose range is smaller than their block radius), 索敌的概念 "我方索敌优先级:
* 阻挡(近战限定)". A ranged operator standing on a melee tile (卫戍协议 "所有行动内远程干员可部署在近战位") blocks, but
* targets by its range alone: "远程位干员通常而言不会优先攻击自己阻挡的目标;甚至如果这个目标被阻挡在该干员的身后,该干员
* 可能因为敌人未处于自身攻击范围而无法攻击到这个敌人" (索敌的概念). Used by Battle.blockedTargets and sortEnemyTargets.
*/
export function meleeUnit(u) {
return String(u?.def?.position ?? 'MELEE').toUpperCase() === 'MELEE';
}
/** Can `attacker` (ally) target enemy `e` at all (ignoring range)? */
export function canTargetEnemy(attacker, e, profile) {
if (!e.alive || e.hidden || !e.deployed) return false;
@@ -73,17 +62,19 @@ export function canTargetEnemy(attacker, e, profile) {
}
/**
* Can enemy `e` target ally `a`? `ranged` = the attack is a ranged (non-blocked) one. A stealthed ally (隐匿 / 迷彩, the
* Can enemy `e` target ally `a`? `ranged` = the attack is a ranged (non-blocked) one. A stealthed ally (隐匿, the
* 排气格栅 tile) is a target only for the enemy it blocks — PRTS 作战机制 §隐匿 "我方干员并不会因为阻挡而解除隐匿" and 索敌的
* 概念 "敌人会在自身被干员阻挡情况下强行无视对方可选性发动攻击" (the term text "不阻挡时…" is the short form; 异常效果:
* "隐匿与'阻挡时解除'没有直接关系"). Devices are never targets (阻隔工事: obstacles nobody can select; “双眼皮”: 迷彩 and
* off the enemy paths — PRTS).
* off the enemy paths — PRTS). 迷彩 (flag `camou`, term ba.camou "不阻挡时不成为敌方普通攻击的目标") works the same way:
* PRTS 异常效果 gives both anomalies the note "与'阻挡时解除'没有直接关系" [ASSUMED: enemy skills and splash selectors
* treat 迷彩 like target selection — officially splash and selectors without a projectile ignore it].
*/
export function canTargetAlly(e, a, ranged) {
if (!a.alive || !a.deployed || a.hidden || a.kind === 'device') return false;
const f = a.s.flags;
if (f.untargetable || f.sleep) return false;
if (ranged && f.stealth && e.blockedBy !== a) return false;
if (ranged && (f.stealth || f.camou) && e.blockedBy !== a) return false;
return true;
}
@@ -111,10 +102,9 @@ export function sortEnemyTargets(battle, attacker, cands, priority) {
if (cands.length <= 1) return cands;
const pf = priority ? (PRIORITY_FNS[priority] || (priority === 'nearest' || priority === 'farthest' ? null : null)) : null;
const ax = attacker.x, ay = attacker.y;
const melee = meleeUnit(attacker);
const keyed = cands.map((e) => ({
e,
b: melee && e.blockedBy === attacker ? 0 : 1,
b: e.blockedBy === attacker ? 0 : 1,
p: pf ? pf(e) : priority === 'nearest' ? bodyDist(e, ax, ay) : priority === 'farthest' ? -bodyDist(e, ax, ay) : 0,
t: -(e.s.taunt || 0),
d: battle.remainingDistance(e),
+1 -1
View File
@@ -3,7 +3,7 @@
// Aggregation (recomputed lazily whenever buffs change — `unit.markDirty()`):
// ATK/DEF/maxHp = (base + Σflat) × (1 + Σpct) × Πmul
// res = clamp((base + Σflat) × Πmul, 0, 100)
// aspd = clamp(base + Σaspd, 10, 600) (base is 100 for almost everyone)
// aspd = clamp(base + Σaspd, 20, 600) (base is 100 for almost everyone; floor 20 = PRTS 数值范围)
// interval = bat × (1 + ΣbatPct) × 100 / aspd (ΣbatPct floored at −0.9)
// moveSpeed = (base + ΣmoveFlat) × ΠmoveMul (tiles/s = moveSpeed × MOVE_SCALE)
// massLevel = max(0, base + ΣmassFlat) (重量: displacement, 浮空 halving; 失重 = massFlat −1)
+12
View File
@@ -52,6 +52,18 @@ export const AREA = Object.freeze({ BOARD: 'board', HAND: 'hand', TEMP: 'temp',
export const PIECE_KIND = Object.freeze({ CHESS: 'chess', ITEM: 'item', TOKEN: 'token' });
/**
* Whether the placed piece of a skill's summon (赫默's 医疗探机, 巫恋's 诅咒娃娃) also deploys once, for free, at the
* battle start. true = the PRTS reading (卫戍协议/帮助 §作战阶段: "所有手动部署的召唤物,无视所属干员的持有状态…作战开始时
* 立即部署一次", example 赫默's drone), settled by the user on 2026-10-01 after playtest #6 (DESIGN §20); false = the
* playtest #4 reading ("是赫默开技能释放一次,不是开局直接就部署了"): it takes its tile only when the owner's skill gives one.
* Either way the piece re-appears on its tile each time the owner's skill gives one.
* One switch for everything that depends on it: the sim (server/sim/content/tokens.js dockSkillSummons) and the summon
* card's hint (public/js/ui/detailPanel.js summonDeployHint). docs/PLAYING.md §4 and docs/SIM.md (token pieces) state
* the rule in prose — test/ui/playtest6_summons.test.js fails until they match the value.
*/
export const SKILL_SUMMON_START_DEPLOY = true;
// Snapshot unit flag bits (DESIGN §8.2)
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,
+5 -2
View File
@@ -274,12 +274,15 @@ export const C2S = {
'g.unitStats': { seq: (v) => isInt(v, 0, 2 ** 31), $optional: ['seq'] },
'g.leave': {},
// client-side combat (DESIGN §14): the authoritative client of a field reports its battle
// client-side combat (DESIGN §14): the authoritative client of a field reports its battle; a 联防 field adds
// `left` = { [leakerId]: its enemies still standing (unspawned, alive, or through again) } (server/sim/spec.js
// uniteLeft; user playtest #6 item 7 — the leakers' live counter)
'b.progress': {
battleId: isId, gt: (v) => isNum(v, 0, 1e5), killed: (v) => isInt(v, 0, 1e5), total: (v) => isInt(v, 0, 1e5),
leaks: (v) => isNum(v, 0, 1e6), bossDmg: (v) => isNum(v, 0, BIG),
by: (v) => isMap(v, RESULT_LIMITS.players, isId, (x) => isNum(x, 0, BIG)), done: isBool,
$optional: ['leaks', 'bossDmg', 'by', 'done'],
left: (v) => isMap(v, RESULT_LIMITS.players, isId, (x) => isInt(x, 0, 1e5)),
$optional: ['leaks', 'bossDmg', 'by', 'done', 'left'],
},
'b.result': { battleId: isId, result: isBattleResult },
};
+10 -3
View File
@@ -26,7 +26,9 @@ function dataWith(modeId, round, family, { tactic = null, bounty = null, cards =
if (cards) r.cards = cards;
const out = { ...DATA, choices: { ...ch, schedule: { ...ch.schedule, [modeId]: { ...sch, rounds: { ...sch.rounds, [String(round)]: r } } }, cards: { ...ch.cards } } };
if (tactic) out.choices.cards.tactic = ch.cards.tactic.filter((c) => tactic.includes(c.effectId));
if (bounty) out.choices.cards.bounty = ch.cards.bounty.filter((c) => bounty.includes(c.effectId));
// the listed bounty cards are offered by this draft even when the real one would not (战术特训 comes from 法术教鞭
// only — choices.json `draft`, user playtest #6 item 4): these tests drive the payouts through the draft
if (bounty) out.choices.cards.bounty = ch.cards.bounty.filter((c) => bounty.includes(c.effectId)).map((c) => ({ ...c, draft: true }));
return out;
}
@@ -628,12 +630,17 @@ test('自愈 (real sim): every damage instance an own unit takes heals 50; not H
assert.equal(u1.hp, afterPhys - 300, '禁疗');
hit(u2, 300);
assert.equal(u2.hp, 1700, 'the partner has no 自愈');
// summons are 我方单位 too
const tok = h.b.spawnToken(u1, 'token_10011_beewax_oblisk', 11, 4, { stats: { maxHp: 1000 } });
// summons are 我方单位 too (纸偶) — but not one that holds 禁疗 (沙之碑, PRTS 备注 "持有禁疗"; tokens.json abnormal)
const tok = h.b.spawnToken(u1, 'token_10022_kazema_shadow', 11, 4, { stats: { maxHp: 1000 } });
assert.ok(tok, 'summon placed');
const before = tok.hp;
hit(tok, 200);
assert.equal(tok.hp, before - 200 + val, 'summon healed');
const ob = h.b.spawnToken(u1, 'token_10011_beewax_oblisk', 12, 4, { stats: { maxHp: 1000 } });
assert.ok(ob, '沙之碑 placed');
const ob0 = ob.hp;
hit(ob, 200);
assert.equal(ob.hp, ob0 - 200, '沙之碑 (禁疗) not healed');
checkInvariants(h.b);
});
+1 -1
View File
@@ -561,7 +561,7 @@ for (const key of ['enemy_1270_nhstlk', 'enemy_1270_nhstlk_2']) {
}
for (const key of ['enemy_1272_nhtank', 'enemy_1272_nhtank_2']) {
test(`${nm(key)}: ground targets only, melee hits ×${tb(key, 'Empty.attack@chuang_atk_scale')}, every 3rd attack leaves a pollution zone`, () => {
test(`${nm(key)}: ground targets only, melee hits ×${tb(key, 'Empty.attack@chuang_atk_scale')}, 秽蚀轰击 (a pollution zone) on the 1st attack, then every 3rd`, () => {
const hi = arena({ units: [{ chessId: 't_gun', row: 10, col: 2 }] }); // (10,2) is high ground
hi.step();
put(hi, key, [10, 3]);
+26 -14
View File
@@ -184,7 +184,9 @@ test('见行者 (3_07) 惊爆射击 pushes along her direction (DOWN: the enemy
checkInvariants(h.b);
});
test('薄绿 (3_08) 聚能涡旋 pulls toward the tile in front along her direction', () => {
test('薄绿 (3_08) 聚能涡旋 pushes the target towards her centre whatever her direction (PRTS 备注: a reversed radial push)', () => {
// PRTS 薄绿 S2 备注 "此技能的“拖拽”机制实际为反方向(指向薄绿方向)的推开" / 推与拉 (radial force): towards her centre,
// the official push distance (小力 vs weight 0: 1.7 tiles), stopping at the 急停 radius in front of her [ASSUMED]
const id = 'chess_char_3_08_a';
const run = (dir) => {
const h = makeBattle({ defs: { enemies: { enemy_d: dummy('enemy_d', { mass: 0 }) } }, timeLimit: 60, units: [{ chessId: id, row: 9, col: 4, dir }], enemies: [{ key: 'enemy_d', pos: [11, 4] }] });
@@ -195,10 +197,11 @@ test('薄绿 (3_08) 聚能涡旋 pulls toward the tile in front along her direct
h.run(3);
return h.enemy('enemy_d');
};
const up = run('UP');
assert.ok(up.y < 11 - 0.2 && Math.abs(up.x - 4) < 0.05, `UP: pulled straight down toward (10,4) (${up.x}, ${up.y})`);
const right = run('RIGHT');
assert.ok(right.x > 4.2, `RIGHT: pulled toward (9,5) (${right.x}, ${right.y})`);
for (const dir of ['UP', 'RIGHT']) {
const e = run(dir);
assert.ok(e.y < 11 - 0.2 && Math.abs(e.x - 4) < 0.05, `${dir}: pushed straight down towards her (${e.x}, ${e.y})`);
assert.ok(Math.hypot(e.x - 4, e.y - 9) >= 0.6708 - 1e-6, `${dir}: stops in front of her, not on her tile (${e.y})`);
}
});
test('乌尔比安 (5_05) S3 throws the anchor straight ahead along his direction (and triggers on the rotated grid)', () => {
@@ -223,18 +226,27 @@ test('乌尔比安 (5_05) S3 throws the anchor straight ahead along his directio
assert.ok(!r.h.hooksOf('skillStart').some((c) => c.unit === r.u), 'facing RIGHT: nothing ahead, no cast');
});
test('凯瑟琳 (4_11) places a device behind her operator along ITS direction, facing the operator', () => {
test('凯瑟琳 (4_11): a placed device shields the operator on the tile IT faces (range 1-1 rotated by its direction)', REAL, () => {
const id = 'chess_char_4_11_a', ally = 'chess_char_1_02_a';
const place = (dir) => {
const h = makeBattle({ units: [{ chessId: id, row: 12, col: 2 }, { chessId: ally, row: 10, col: 5, dir }], timeLimit: 30, hooks: [] });
// the device at (10,5); operators on its four neighbours; its direction picks the one it serves (user playtest #6)
const serve = (dir) => {
const h = makeBattle({
units: [
{ chessId: id, row: 12, col: 2, uid: 1 },
{ chessId: ally, row: 10, col: 6, uid: 2 }, { chessId: ally, row: 11, col: 5, uid: 3 },
{ chessId: ally, row: 9, col: 5, uid: 4 }, { chessId: ally, row: 10, col: 4, uid: 5 },
{ kind: 'token', tokenId: 'token_10041_cathy_catsld', ownerUid: 1, row: 10, col: 5, uid: 6, dir },
],
timeLimit: 30, hooks: [],
});
h.run(0.2);
const d = h.b.allyUnits.find((x) => x.defId === 'token_10041_cathy_catsld' && x.alive && x.mem.tr === 10 && x.mem.tc === 5);
assert.ok(d, `a device serves the ${dir} operator`);
return [d.tileR, d.tileC, d.dir];
const shielded = h.b.allyUnits.filter((x) => x.kind === 'op' && x.findBuff('cathy:shield')).map((x) => `${x.tileR},${x.tileC}`);
return shielded;
};
assert.deepEqual(place('UP'), [9, 5, 'UP'], 'below an UP operator, pointing up at it');
assert.deepEqual(place('RIGHT'), [10, 4, 'RIGHT']);
assert.deepEqual(place('LEFT'), [10, 6, 'LEFT']);
assert.deepEqual(serve('RIGHT'), ['10,6']);
assert.deepEqual(serve('UP'), ['11,5'], 'row 0 is the bottom: UP faces the row above');
assert.deepEqual(serve('DOWN'), ['9,5']);
assert.deepEqual(serve('LEFT'), ['10,4']);
});
test('余 (6_03) S3 fire wall runs through his tile perpendicular to his direction (UP / DOWN: his row; RIGHT / LEFT: his column)', REAL, () => {
+38 -7
View File
@@ -881,6 +881,34 @@ test('信标: target and item destroyed, offer of 2 same-tier operators; co-op:
cover(A('5_04'), B('5_04'));
});
test('pick-one chess offers never repeat an operator — 信标, 寻呼模块, any offer list (user playtest #6 item 19)', () => {
let n = 0;
for (let seed = 1; seed <= 24; seed++) {
for (const lvl of [1, 3, 6]) {
// 寻呼模块: same-bond operators up to the shop level — fewer cards when the pool has no other one, never a repeat
let { m, ps, equip } = setup({ seed });
ps.shop.level = lvl;
const pool1 = plain((c) => c.bonds.length && c.tier <= lvl);
const cid = pool1[seed % pool1.length];
const t = give(m, ps, cid, 'hand');
assert.deepEqual(equip(giveItem(m, ps, A('4_01')), t), OK);
for (const o of ps.offers) { const ids = o.slots.map((x) => x.id); assert.equal(new Set(ids).size, ids.length, `寻呼模块 seed ${seed} lv ${lvl}: ${ids}`); n++; }
// 信标: operators of the target's tier (topped up from the tier below), never a repeat
({ m, ps, equip } = setup({ seed }));
const pool2 = plain((c) => c.tier === lvl && c.bonds.length);
const t2 = give(m, ps, pool2[seed % pool2.length], 'hand');
assert.deepEqual(equip(giveItem(m, ps, A('5_04')), t2), OK);
for (const o of ps.offers) { const ids = o.slots.map((x) => x.id); assert.equal(new Set(ids).size, ids.length, `信标 seed ${seed} tier ${lvl}: ${ids}`); n++; }
}
}
assert.ok(n >= 100, `offers checked (${n})`);
// whoever builds the list, the offer drops a repeated operator
const { ps } = setup({ seed: 5 });
const [a, b] = plain((c) => c.tier === 2);
const offer = ps.pushRewardOffer('effect', { ids: [a, a, b, a] });
assert.deepEqual(offer.slots.map((x) => x.id), [a, b]);
});
test('拟态物质: owning 2 copies gives the 3rd (→ elite); otherwise a random same-bond normal operator', () => {
let { m, ps, equip } = setup({ seed: 9 });
const cid = plain((c) => c.tier === 2 && c.bonds.length)[0];
@@ -1012,9 +1040,12 @@ test('画卷 (Art): copies the operator on the tile / in front with its elite st
cover('chess_item_6_02_m');
});
test('教鞭 / “神秘顾客” (Arts): a band bounty (enemyeffect_b_*: +1 enemy next battle, killer gets its coins); 神秘顾客 destroyed ⇒ +1 fund, passes on', () => {
test('教鞭 (Art): a 战术特训 bounty (PRTS "于3个战术特训的悬赏任务中选择一项": extra enemies, coins for a perfect own phase); “神秘顾客”: a band bounty; destroyed ⇒ +1 fund, passes on', () => {
// the 20 战术特训 cards — the ones the 悬赏决策 draft leaves out ("※以下悬赏任务仅由法术教鞭生成")
const training = new Set(DATA.choices.cards.bounty.filter((c) => c.draftExcluded === 'perfect').map((c) => c.effectId));
assert.equal(training.size, 20);
const seen = new Set();
for (let s = 0; s < 4; s++) {
for (let s = 0; s < 6; s++) {
const { m, ps } = setup({ seed: 60 + s });
for (let k = 0; k < 2; k++) {
const art = giveItem(m, ps, 'chess_item_6_03_m');
@@ -1022,14 +1053,14 @@ test('教鞭 / “神秘顾客” (Arts): a band bounty (enemyeffect_b_*: +1 ene
}
assert.equal(ps.bounties.length, 2);
for (const b of ps.bounties) {
assert.match(b.card.effectId, /^enemyeffect_b_\d+$/, 'band bounty family (research 04 §7)');
assert.equal(b.card.payout, 'kill');
assert.equal(b.card.rounds, 1);
assert.equal(b.card.count, 1);
assert.ok(training.has(b.card.effectId), `${b.card.effectId} ${b.card.name}: a 战术特训 card (user playtest #6 item 4 review)`);
assert.match(b.card.name, /战术特训/);
assert.equal(b.card.payout, 'perfect', 'pays for a perfect own phase, not per kill');
assert.ok(m.gd.enemy(b.card.enemyKey) && !m.gd.inactiveEnemies.has(b.card.enemyKey), 'its enemy can appear in the mode');
seen.add(b.card.effectId);
}
}
assert.ok(seen.size >= 4, 'random pick');
assert.ok(seen.size >= 5, 'random pick');
const { h, m } = setup({ mode: 'coop', humans: 2, seed: 7 });
const p0 = h.ps('p_0'), p1 = h.ps('p_1');
const art = giveItem(m, p0, 'chess_item_6_01_m');
+10 -6
View File
@@ -309,7 +309,8 @@ test('1_10 古米 S2 食粮烹制: disarm s cooking (no attacks, DEF +def), then
const s = rec(id, 'skchr_sunbr_2'), bb = s.bb, tb = raw(id).trait.bb;
const ally = chessRec({ id: 't_ally', profession: 'WARRIOR', rangeGrid: [[0, 0]], skill: null, stats: { maxHp: 20000 } });
const h = run({
defs: { enemies: { e: dummy('e') }, chess: { ...noGarrison(id), t_ally: ally } },
// a MANUAL 重装 skill: the TAKE_DAMAGE strategy (PRTS 卫戍协议/帮助 "不受技能范围影响,受到伤害时释放技能")
defs: { enemies: { e: dummy('e', { atk: 300, bat: 1 }) }, chess: { ...noGarrison(id), t_ally: ally } },
units: [entry(id, s.skillId, { row: 9, col: 5, carryState: READY }), { chessId: 't_ally', row: 10, col: 6 }],
enemies: [{ key: 'e', pos: [9, 5] }],
});
@@ -318,7 +319,7 @@ test('1_10 古米 S2 食粮烹制: disarm s cooking (no attacks, DEF +def), then
a.hp = a.s.maxHp * 0.3;
assert.ok(h.runUntil(() => u.skill.active, 5));
const st = started(h, u)[0].t;
assert.equal(started(h, u)[0].reason, 'DEFAULT');
assert.equal(started(h, u)[0].reason, 'TAKE_DAMAGE');
approx(u.s.def, u.base.def * (1 + bb.def), `${id} cooking DEF`);
approx(u.s.atk, u.base.atk, 'no ATK while cooking');
h.runUntil(() => h.b.time >= st + bb.disarm - 0.2, 20);
@@ -482,19 +483,20 @@ test('1_19 野鬃 S1 骑枪刺击 (PASSIVE): ASPD +attack_speed for `duration` s
}
});
test('1_20 雷蛇 S1 充能防御: TAKE_DAMAGE — DEF +def for bb.duration s; the next hit is blocked (no 战术防御 SP from it)', () => {
test('1_20 雷蛇 S1 充能防御: 自动触发 "技能自动开启" (SP_FULL, no 技能策略) — DEF +def for bb.duration s; the next hit is blocked (no 战术防御 SP from it)', () => {
for (const id of pair('20')) {
const s = rec(id, 'skchr_liskam_1'), bb = s.bb, t = tal(id);
const mate = chessRec({ id: 't_mate', profession: 'WARRIOR', rangeGrid: [[0, 0]], skill: { spType: 'INCREASE_WHEN_ATTACK', spCost: 200 } });
const h = run({
defs: { enemies: { a: dummy('a', { atk: 700, bat: 1 }) }, chess: { ...noGarrison(id), t_mate: mate } },
units: [entry(id, s.skillId, { row: 9, col: 4, carryState: READY }), { chessId: 't_mate', row: 10, col: 4 }],
enemies: [{ key: 'a', pos: [9, 4] }],
enemies: [{ key: 'a', pos: [9, 4], time: 1 }], // arrives after the skill is on (it needs no hit to start)
});
const u = sel(h, id, s.skillId), m = h.unit('t_mate');
assert.ok(h.runUntil(() => u.skill.active, 10));
const st = h.b.time;
assert.equal(started(h, u)[0].reason, 'TAKE_DAMAGE');
assert.equal(started(h, u)[0].reason, 'SP_FULL');
assert.ok(st < 0.5, 'on as soon as it is ready, before any hit');
approx(u.s.def, u.base.def * (1 + bb.def), `${id} DEF`);
const hp = u.hp;
const sp = m.skill.sp;
@@ -671,9 +673,11 @@ test('1_20 雷蛇 反击电弧: attack interval +70 % of the base (PRTS 增大(+
for (const id of pair('20')) {
const bb = raw(id).skill.bb;
assert.equal(raw(id).skill.skillId, 'skchr_liskam_2');
const h = run({ defs: { enemies: { e: dummy('e') }, chess: noGarrison(id) }, units: [{ chessId: id, row: 9, col: 4, carryState: READY }], enemies: [{ key: 'e', pos: [9, 5] }] });
// 重装 S2: TAKE_DAMAGE (the enemy has to hit her)
const h = run({ defs: { enemies: { e: dummy('e', { atk: 300, bat: 1 }) }, chess: noGarrison(id) }, units: [{ chessId: id, row: 9, col: 4, carryState: READY }], enemies: [{ key: 'e', pos: [9, 4] }] });
const u = h.unit(id);
assert.ok(h.runUntil(() => u.skill.active, 5));
assert.equal(started(h, u)[0].reason, 'TAKE_DAMAGE');
approx(u.s.interval, u.base.bat * (1 + bb.base_attack_time) * 100 / u.s.aspd, `${id} interval ×${1 + bb.base_attack_time}`);
h.runUntil(() => !u.skill.active, 30);
approx(u.s.interval, u.base.bat * 100 / u.s.aspd, 'restored');
+14 -7
View File
@@ -455,28 +455,35 @@ test('2_17 折桠 S1 绝境抵抗: TAKE_DAMAGE; DEF +def and 抵抗 (control sta
}
});
test('2_18 灰毫 S2 专注轰击: blocks nothing, ranged splash bombs only, shorter interval, ATK +atk', () => {
test('2_18 灰毫 S2 专注轰击: cast when hit (重装 TAKE_DAMAGE), then blocks nothing, ranged splash bombs only, shorter interval, ATK +atk', () => {
for (const id of both('chess_char_2_18')) {
const bb = bbAlt(id);
const enemies = { w: enemyRec({ key: 'w', hp: 1e7, speed: 2 }) };
const enemies = { w: enemyRec({ key: 'w', hp: 1e7, speed: 2, atk: 50, bat: 1 }) };
// control: the default skill blocks the walker
const c = run({ defs: { enemies }, units: [{ chessId: id, row: 9, col: 5 }], enemies: [{ key: 'w', route: 0 }] });
assert.ok(c.runUntil(() => c.enemies()[0]?.blockedBy === c.unit(id), 10), 'default: blocked');
done(c);
const h = run({ defs: { enemies }, units: [U(id, 9, 5, { carryState: READY })], enemies: [{ key: 'w', route: 0 }] });
// (a second walker comes by while the skill runs: she bombs it from range, unblocked)
const h = run({ defs: { enemies }, units: [U(id, 9, 5, { carryState: READY })], enemies: [{ key: 'w', route: 0 }, { key: 'w', route: 0, time: 5 }] });
const u = h.unit(id);
usesAlt(u, id);
h.runUntil(() => u.skill.active, 6);
assert.equal(u.skill.rule, 'TAKE_DAMAGE', 'a MANUAL 重装 skill: "不受技能范围影响,受到伤害时释放技能"');
assert.ok(h.runUntil(() => u.skill.active, 8));
const st = h.b.time;
const atk0 = h.eventsOf('atk').length;
assert.ok(h.hooksOf('damaged').some((x) => x.target === u), 'cast by the hit of the enemy she blocked');
assert.ok(u.s.flags.noBlock);
approx(u.s.atk, u.base.atk * (1 + u.findBuff('talent:ashlok').mods.atkPct + bb.atk), 'ATK (+ 炮术研习)');
approx(u.s.interval, (u.base.bat + bb.base_attack_time) * 100 / u.s.aspd, 'base attack time shortened');
const w = h.enemies()[0];
assert.ok(h.runUntil(() => w.x < 4.5, 8), 'walks past her');
const w2 = h.enemies()[1];
assert.ok(h.runUntil(() => w2 && w2.x < 4.5, 12), 'the second one walks past her too');
assert.ok(u.skill.active);
assert.ok(!h.hooksOf('damaged').some((x) => x.target === u), 'never blocked ⇒ never hit');
const shots = h.eventsOf('atk').filter((e) => e[1] === u.id);
assert.ok(shots.length > 0 && shots.every((e) => e[3] !== 'none'), 'ranged shots only');
assert.ok(!h.hooksOf('damaged').some((x) => x.target === u && x.t > st + 0.1), 'released, never blocked again ⇒ not hit again');
const shots = h.eventsOf('atk').slice(atk0).filter((e) => e[1] === u.id);
assert.ok(shots.length > 0 && shots.every((e) => e[3] !== 'none'), `ranged shots only ${JSON.stringify(shots)}`);
done(h);
}
});
+19 -12
View File
@@ -460,7 +460,10 @@ test('3_10 松果 S1 RMA长钉 (charges): an immediate extra shot ×atk_scale ig
assert.ok(sk.some((c) => c.target.defId === 'enemy_s'), 'every enemy in range');
const plain = atkHits(h, u, false).find((c) => c.target.defId === 'enemy_d');
approx(plain.amount, u.s.atk * front - 300, 1e-6, 'the ignore is for the skill shot only');
assert.ok(h.runUntil(() => u.skill.activations >= d.skill.maxCharges, 8), 'every stored charge is used, one per attack');
// every stored charge is used, one per attack — at least AUTO_OP_COOLDOWN (3 s) apart ("自动操作具有3s冷却")
assert.ok(h.runUntil(() => u.skill.activations >= d.skill.maxCharges, 15), 'every stored charge is used');
const casts = h.hooksOf('attack').filter((c) => c.attacker === u && c.isSkill).map((c) => c.t);
for (let i = 1; i < casts.length; i++) assert.ok(casts[i] - casts[i - 1] >= 3 - 1e-6, `casts ${casts[i - 1]} → ${casts[i]}`);
done(h);
}
});
@@ -490,20 +493,20 @@ test('3_11 雪猎 S1 强力击·β型: next shot ×atk_scale (× trait), 裂云
}
});
test('3_12 瑕光 S1 光芒涌动 (TAKE_DAMAGE, charges): next attack ×atk_scale + heals the most injured ally of the 3×3', () => {
test('3_12 瑕光 S1 光芒涌动 (自动触发 ⇒ DEFAULT, charges): next attack ×atk_scale + heals the most injured ally of the 3×3', () => {
for (const id of BOTH('chess_char_3_12_a')) {
const b = SB(id, 'skchr_blemsh_1');
const h = makeBattle({ defs: { enemies: { enemy_d: dummy('enemy_d') } }, timeLimit: 60, hooks: ['damaged', 'heal'], captureNoisy: true,
units: [U(id, 'skchr_blemsh_1', 10, 4), { chessId: 'chess_char_3_16_a', row: 11, col: 4 }, { chessId: 'chess_char_3_05_a', row: 12, col: 4 }],
enemies: [{ key: 'enemy_d', pos: [10, 5] }] });
const u = h.unit(id), ally = h.unit('chess_char_3_16_a'), far = h.unit('chess_char_3_05_a');
assert.equal(u.skill.rule, 'TAKE_DAMAGE');
// an AUTO skill takes no 技能策略 (the 重装 TAKE_DAMAGE row is for MANUAL skills): it fires with her next attack
assert.equal(u.skill.rule, 'DEFAULT');
h.step();
ally.hp = ally.s.maxHp * 0.6;
far.hp = far.s.maxHp * 0.3; // outside the 3×3
fill(u);
h.b.dealDamage(h.enemy('enemy_d'), u, { type: 'phys', amount: 1 });
assert.ok(u.skill.active && u.skill.pending, 'cast when hit; next attack armed');
assert.ok(h.runUntil(() => u.skill.activations >= 1, 5), 'cast with her next attack, no hit needed');
h.runUntil(() => !u.skill.active, 5);
const sk = atkHits(h, u, true);
assert.equal(sk.length, 1);
@@ -519,7 +522,8 @@ test('3_12 瑕光 S2 慑敌辉光: ATK +, ground enemies on her tile sleep for t
for (const id of BOTH('chess_char_3_12_a')) {
const b = SB(id, 'skchr_blemsh_2'), t1 = TB(id, 1);
const dur = LD(id, 'skchr_blemsh_2').skill.duration;
const h = makeBattle({ defs: { enemies: { enemy_d: dummy('enemy_d'), enemy_n: dummy('enemy_n') } }, timeLimit: 60, hooks: ['damaged', 'heal'], captureNoisy: true,
// (a MANUAL 重装 skill: TAKE_DAMAGE — the enemy she blocks hits her)
const h = makeBattle({ defs: { enemies: { enemy_d: dummy('enemy_d', { atk: 200, bat: 1 }), enemy_n: dummy('enemy_n') } }, timeLimit: 60, hooks: ['damaged', 'heal', 'skillStart'], captureNoisy: true,
units: [U(id, 'skchr_blemsh_2', 10, 4), { chessId: 'chess_char_3_16_a', row: 10, col: 6 }],
enemies: [{ key: 'enemy_d', pos: [10, 4] }, { key: 'enemy_n', pos: [10, 5] }] });
const u = h.unit(id), ally = h.unit('chess_char_3_16_a');
@@ -529,6 +533,7 @@ test('3_12 瑕光 S2 慑敌辉光: ATK +, ground enemies on her tile sleep for t
ally.hp = ally.s.maxHp * 0.3;
fill(u);
assert.ok(h.runUntil(() => u.skill.active, 3));
assert.equal(h.hooksOf('skillStart').find((c) => c.unit === u).reason, 'TAKE_DAMAGE');
h.step();
approx(u.s.atk, u.base.atk * (1 + b.atk));
assert.ok(e.s.flags.sleep, 'her tile: asleep');
@@ -557,14 +562,13 @@ test('3_12 瑕光 精锐 module GUA-X: heals on allies under 50 % HP ×1.15 (not
assert.equal(u.findBuff('trait:blemsh_guard'), null);
ally.hp = ally.s.maxHp * 0.4;
fill(u);
h.b.dealDamage(h.enemy('enemy_d'), u, { type: 'phys', amount: 1 });
h.runUntil(() => !u.skill.pending, 5);
h.runUntil(() => u.skill.activations >= 1 && !u.skill.pending, 5);
let heal = h.hooksOf('heal').filter((c) => c.source === u).at(-1);
approx(heal.amount, u.s.atk * b.heal_scale * tb.heal_scale, 1e-6, 'under 50 %');
ally.hp = ally.s.maxHp * 0.6;
const n1 = u.skill.activations;
fill(u);
h.b.dealDamage(h.enemy('enemy_d'), u, { type: 'phys', amount: 1 });
h.runUntil(() => !u.skill.pending, 5);
h.runUntil(() => u.skill.activations > n1 && !u.skill.pending, 5);
heal = h.hooksOf('heal').filter((c) => c.source === u).at(-1);
approx(heal.amount, u.s.atk * b.heal_scale, 1e-6, 'above 50 %');
done(h);
@@ -677,7 +681,9 @@ test('3_18 忍冬 S1 小施惩戒: next attack + extra arts and +DP; S2 坠刃
assert.equal(atkHits(g, v).length, 0, 'nothing in her own range');
const gdp = gp.dp;
v.skill.gainSp(v.skill.spCost - v.skill.sp + 0.01, 'test'); // one charge
assert.ok(g.runUntil(() => v.skill.activations === 1, 1), 'CUSTOM_RANGE on the skill range');
assert.equal(v.skill.rule, 'SKILL_RANGE');
assert.ok(g.runUntil(() => v.skill.activations === 1, 1), 'SKILL_RANGE: an enemy on the skill range');
const first = g.b.time;
approx(gp.dp, gdp + b2.cost);
const e1 = g.enemy('enemy_e1');
const hit = tagged(g, 'vulpisTorture', v);
@@ -687,7 +693,8 @@ test('3_18 忍冬 S1 小施惩戒: next attack + extra arts and +DP; S2 坠刃
assert.ok(!e1.s.flags.stun, 'not 停顿 before: no stun');
for (const [i, k] of keys.slice(1).entries()) g.spawn(k, { pos: [[9, 5], [11, 5], [10, 5], [10, 6], [11, 4], [9, 4]][i] });
v.skill.gainSp(v.skill.spCost + 0.01, 'test');
assert.ok(g.runUntil(() => v.skill.activations === 2, 1));
assert.ok(g.runUntil(() => v.skill.activations === 2, 4));
assert.ok(g.b.time - first >= 3 - 1e-6, 'the next automatic cast waits the 3 s operation cooldown');
const second = tagged(g, 'vulpisTorture', v).slice(1);
assert.equal(second.length, b2.max_target, '≤ max_target enemies');
if (second.some((c) => c.target === e1)) assert.ok(e1.s.flags.stun, 'already 停顿 ⇒ stunned');
+31 -13
View File
@@ -86,7 +86,7 @@ test('tier4 loadouts: every chess × selectable skill × module fights a mixed w
// ---------------------------------------------------------------------------------------------------------------
// 信仰搅拌机
test('信仰搅拌机 S1 铳骑主考官: taking a hit arms it; next attack = 3 hits × atk_scale; reloads one adjacent 拉特兰 ammo skill', () => {
test('信仰搅拌机 S1 铳骑主考官 (自动触发 ⇒ DEFAULT: hurt SP, fires with the next attack); next attack = 3 hits × atk_scale; reloads one adjacent 拉特兰 ammo skill', () => {
for (const id of pair('chess_char_4_01_a')) {
const bb = D(id, 0).skill.bb;
const h = battle([U(id, 9, 5, 0, gold(id) === id ? 'none' : null), U('chess_char_1_01_a', 10, 4)], {
@@ -95,13 +95,13 @@ test('信仰搅拌机 S1 铳骑主考官: taking a hit arms it; next attack = 3
h.step();
const u = h.unit(id), ins = h.unit('chess_char_1_01_a');
assert.equal(u.skill.id, 'skchr_rmixer_1');
assert.equal(u.skill.rule, 'TAKE_DAMAGE');
assert.equal(u.skill.rule, 'DEFAULT', 'an AUTO skill takes no 技能策略 (the 重装 row is for MANUAL skills)');
assert.ok(ins.skill.activate('test', { free: true }));
u.skill.gainSp(1000);
const ammo0 = ins.skill.ammoLeft;
h.hooksOf('attack').length = 0;
h.spawn('enemy_hitter', { pos: [9, 5] });
assert.ok(h.runUntil(() => h.hooksOf('skillStart').some((c) => c.unit === u), 10), `${id} S1 armed by a hit`);
assert.ok(h.runUntil(() => h.hooksOf('skillStart').some((c) => c.unit === u), 10), `${id} S1 cast with the next attack`);
assert.ok(h.runUntil(() => h.hooksOf('skillEnd').some((c) => c.unit === u), 10), 'the armed attack happened');
const skillHits = dmgBy(h, u, (c) => c.dmg.isAttack && c.dmg.isSkill);
assert.equal(skillHits.length, 3, `${id}: triple hit`);
@@ -311,7 +311,7 @@ test('寒芒克洛丝 S1 无痕: ATK up, every attack is a double shot, 迷彩 (
assert.ok(u.skill.activate('test', { free: true }));
assert.equal(u.skill.kind, 'duration');
approx(u.s.atk, u.base.atk * (1 + bb.atk), 1e-6, `${id} ATK`);
assert.ok(u.s.flags.stealth, '迷彩');
assert.ok(u.s.flags.camou && !u.s.flags.stealth, '迷彩 (not 隐匿)');
assert.equal(canTargetAlly(e, u, true), false, 'not a ranged target');
h.run(3);
const shots = dmgBy(h, u, (c) => c.dmg.isAttack);
@@ -320,7 +320,7 @@ test('寒芒克洛丝 S1 无痕: ATK up, every attack is a double shot, 迷彩 (
assert.ok(per.size >= 2 && [...per.values()].every((n) => n === 2), `${id}: 2 shots per attack`);
assert.equal(u.mem.kroosHits ?? 0, 0, 'S2 hit counter untouched');
h.runUntil(() => !u.skill.active, 30);
assert.ok(!u.s.flags.stealth);
assert.ok(!u.s.flags.camou);
}
});
@@ -442,10 +442,12 @@ test('阿罗玛 S1 强效清洁: charges; next blast atk_scale arts, flying vict
}
});
test('凯瑟琳 S1 岁月锻打: passive ATK/DEF up for her and every operator holding a device shield; module none: 3 devices (CRA-X 4)', () => {
test('凯瑟琳 S1 岁月锻打: passive ATK/DEF up for her and every operator holding a device shield; the hand card is her deploy limit', () => {
for (const id of pair('chess_char_4_11_a')) {
const bb = D(id, 0).skill.bb;
const h = battle([U(id, 10, 4, 0), U('chess_char_4_17_a', 10, 6), U('chess_char_1_01_a', 12, 3)]);
// her device is a placed piece (user playtest #6): facing 4_17 on its right
const dev = { kind: 'token', tokenId: 'token_10041_cathy_catsld', ownerUid: 1, row: 10, col: 5, dir: 'RIGHT' };
const h = battle([U(id, 10, 4, 0, null, { uid: 1 }), U('chess_char_4_17_a', 10, 6), U('chess_char_1_01_a', 12, 3), dev]);
h.run(1);
const u = h.unit(id), hs = h.unit('chess_char_4_17_a'), ins = h.unit('chess_char_1_01_a');
assert.equal(u.skill.kind, 'passive');
@@ -458,9 +460,10 @@ test('凯瑟琳 S1 岁月锻打: passive ATK/DEF up for her and every operator h
}
assert.ok(hs.findBuff(`cathy:forge:${u.id}`), 'the shielded blocker gets it');
}
const carried = (m) => { const h = battle([U('chess_char_4_11_b', 10, 4, 0, m)]); h.step(); const u = h.unit('chess_char_4_11_b'); return u.mem.devicesLeft + u.mem.devices.length; };
assert.equal(carried('none'), C.chess_char_4_11_b.talentsBase[0].bb.cnt);
assert.equal(carried(null), C.chess_char_4_11_b.talents[0].bb.cnt);
// the module changes how many devices she carries (none 3, CRA-X 4); the hand gets the deploy limit (2) either way
// (PRTS 卫戍协议/帮助 "根据召唤物部署数量上限(非初始持有量),发送等量召唤物至手牌区")
assert.deepEqual([C.chess_char_4_11_b.talentsBase[0].bb.cnt, C.chess_char_4_11_b.talents[0].bb.cnt], [3, 4]);
for (const id of pair('chess_char_4_11_a')) assert.equal(ds.raw.tokens.token_10041_cathy_catsld.variants[id].stats.deployLimit, 2, `${id}: deploy limit`);
});
// ---------------------------------------------------------------------------------------------------------------
@@ -481,7 +484,8 @@ test('歌蕾蒂娅 S1 缺水的大洋裂断: charges; next attack atk_scale (×1
assert.equal(sk.length, 1);
approx(sk[0].amount, u.s.atk * bb.atk_scale * t1.atk_scale, 1e-6, `${id} hit`);
assert.ok(e.x < x0 - 0.5, `${id}: pulled towards her (${x0} → ${e.x})`);
assert.ok(e.x >= u.x + 1 - 1e-6, 'never past her front tile');
// 中力 vs weight 1 (受力等级 0): "必定拉至身前" — to the 急停 radius 0.6708 around her centre (PRTS 推与拉), never past it
approx(e.x, u.x + 0.6708, 1e-6, `${id}: stops at her front`);
}
});
@@ -506,11 +510,12 @@ test('歌蕾蒂娅 S2 缺水的掌握怒海: BAT +0.5 s, wider range, 2 targets
});
test('歌蕾蒂娅 module HOK-Y 淡金坠饰: pulling a far enemy (> 2.5 tiles) to herself is one force level stronger', () => {
// a weight-2 enemy: 中力 (1) − 2 = 受力等级 −1 ⇒ 35 % of the way; +1 level (HOK-Y) ⇒ 0 ⇒ all the way (PRTS 推与拉)
const moved = (m) => {
const h = battle([U('chess_char_4_12_b', 10, 3, 0, m)]);
const h = battle([U('chess_char_4_12_b', 10, 3, 0, m)], { enemies: { enemy_heavy: dummy({ key: 'enemy_heavy', mass: 2 }) } });
h.step();
const u = h.unit('chess_char_4_12_b');
const e = h.spawn('enemy_dummy', { pos: [10, 6] });
const e = h.spawn('enemy_heavy', { pos: [10, 6] });
assert.ok(u.skill.activate('test', { free: true }));
h.runUntil(() => !u.skill.active, 10);
h.run(0.5);
@@ -763,6 +768,19 @@ test('焰尾 S1 迅敏直觉: +6 DP and the next physical attack on her is dodge
}
});
test('焰尾 S1 迅敏直觉 (AUTO): fires as soon as SP is full — no enemy, no attack needed (like 伺夜 S1; playtest #6 review)', () => {
for (const id of pair('chess_char_4_19_a')) {
const h = battle([U(id, 10, 4, 0)], { flags: { dpPerSec: 0 } });
h.step();
const u = h.unit(id);
assert.equal(u.skill.rule, 'SP_FULL');
const due = h.b.time + (u.skill.spCost - u.skill.sp) / u.s.spRecovery;
assert.ok(h.runUntil(() => u.skill.activations === 1, 40), `${id}: cast with no enemy on the field`);
const st = h.hooksOf('skillStart').find((c) => c.unit === u);
assert.ok(Math.abs(st.t - due) <= 0.05, `${id}: at full SP (${st.t} vs ${due})`);
}
});
test('焰尾 S2 “红松林”: +11/12 DP; ≤ 6 enemies around: 2 × atk_scale phys + stun; allies around +40 %/45 % phys dodge 10 s', () => {
for (const id of pair('chess_char_4_19_a')) {
const bb = D(id, 1).skill.bb;
+79 -38
View File
@@ -90,7 +90,8 @@ test('tier 5: every chess × every legal skill × every module fights with its a
const tag = `${id} S${skillIndex + 1} ${moduleId ?? 'default module'}`;
assert.equal(u.def.loadout.skillIndex, skillIndex, tag);
assert.ok(['skills', undefined].includes(u.kit.skillSource), `${tag}: ${u.kit.skillSource}`);
for (let t = 0; t < 60 && !h.b.finished; t += 5) { u.skill.gainSp(1000); h.run(5); }
const mate = h.b.allyUnits[1]; // (塞雷娅 S1 急救 casts only for an ally of its area below half HP)
for (let t = 0; t < 60 && !h.b.finished; t += 5) { if (mate.alive) mate.hp = Math.min(mate.hp, mate.s.maxHp * 0.3); u.skill.gainSp(1000); h.run(5); }
assert.ok(u.skill.activations > 0, `${tag}: cast`);
assert.deepEqual(h.b.errors.map((e) => `${e.label}: ${e.message}`), [], tag);
checkInvariants(h.b);
@@ -500,7 +501,7 @@ test('史尔特尔 module AFT-Y (旅游必需品): blocked enemies are 法术脆
// =================================================================================================================
// 号角
test('号角 S1 照明榴弹 (TAKE_DAMAGE): the next ranged shot deals atk_scale × ATK, splashes projectile_range and lights the impact', () => {
test('号角 S1 照明榴弹 (自动触发 ⇒ DEFAULT): the next ranged shot deals atk_scale × ATK, splashes projectile_range and lights the impact', () => {
for (const id of pair('08')) {
const h = run({
defs: { enemies: { enemy_dummy: dummy('enemy_dummy') } },
@@ -512,11 +513,10 @@ test('号角 S1 照明榴弹 (TAKE_DAMAGE): the next ranged shot deals atk_scale
const [e1, e2] = h.b.enemies;
h.step();
u.skill.gainSp(1000);
h.run(1);
assert.equal(u.skill.activations, 0, 'TAKE_DAMAGE: not before a hit');
h.b.dealDamage(e1, u, { amount: 10, type: 'phys' });
assert.ok(u.skill.pending, 'armed by the hit');
assert.ok(h.runUntil(() => dealt(h, u, (c) => c.dmg.isSkill && c.target === e1).length > 0, 5));
assert.equal(u.skill.rule, 'DEFAULT', 'an AUTO skill takes no 技能策略 (the 重装 row is for MANUAL skills)');
assert.ok(h.runUntil(() => dealt(h, u, (c) => c.dmg.isSkill && c.target === e1).length > 0, 5), 'her next shot, no hit needed');
u.skill.charges = 0; // (no further flare in the window below: it would light the targets again)
u.skill.spCostMul = 1000;
approx(dealt(h, u, (c) => c.dmg.isSkill && c.target === e1)[0].amount, u.s.atk * bb.atk_scale, 'atk_scale');
assert.ok(dealt(h, u, (c) => c.dmg.isSkill && c.target === e2).length > 0, `the splash reaches 1.6 tiles (${bb.projectile_range})`);
h.run(0.5);
@@ -534,7 +534,11 @@ test('号角 S2 暴风号令: 10 rounds of attack@s2.atk_scale × ATK splash; th
const bb = bbOf(u);
const e = h.b.enemies[0];
h.step();
cast(h, u);
u.skill.gainSp(1000);
h.run(1);
assert.equal(u.skill.activations, 0, '重装 TAKE_DAMAGE: not before a hit');
h.b.dealDamage(e, u, { amount: 10, type: 'phys' });
assert.equal(u.skill.activations, 1, 'cast by the hit');
assert.equal(u.skill.kind, 'ammo');
assert.equal(u.skill.ammoLeft + h.hooksOf('ammoUsed').filter((c) => c.unit === u).length, bb['attack@s2.trigger_time']);
assert.ok(h.runUntil(() => !u.skill.active, 60));
@@ -610,24 +614,30 @@ test('铃兰 module DEC-Y (孩子们): longer 停顿 (1.2 s) and no SP rider', (
// =================================================================================================================
// 塞雷娅
test('塞雷娅 S1 急救 (TAKE_DAMAGE): her next attack heals the ally below half HP around her for heal_scale × ATK', () => {
test('塞雷娅 S1 急救 (自动触发, at her attack: an ally of the area at ≤ half HP): that attack heals the lowest such ally for heal_scale × ATK instead', () => {
for (const id of pair('11')) {
// PRTS 备注 "此技能仅在周围有符合血量条件的友方单位时可触发,触发时会替换当次攻击"; "血量小于等于一半" (PRTS 修正)
const h = run({
defs: { enemies: { enemy_hitter: dummy('enemy_hitter', { atk: 50, bat: 1 }) }, chess: { t_low: ally('t_low'), t_mid: ally('t_mid') } },
defs: { enemies: { enemy_dummy: dummy('enemy_dummy') }, chess: { t_low: ally('t_low'), t_mid: ally('t_mid') } },
units: [entry(id, 'skchr_demkni_1', { row: 10, col: 4 }), { chessId: 't_low', row: 11, col: 3 }, { chessId: 't_mid', row: 9, col: 4 }],
enemies: [{ key: 'enemy_hitter', pos: [10, 4] }],
enemies: [{ key: 'enemy_dummy', pos: [10, 4] }],
});
const u = sel(h, id, 'skchr_demkni_1');
const bb = bbOf(u), tb = u.def.traitBb;
const low = h.unit('t_low'), mid = h.unit('t_mid');
const e = h.b.enemies[0];
h.step();
low.hp = low.s.maxHp * 0.3;
mid.hp = mid.s.maxHp * 0.45;
u.skill.gainSp(1000);
assert.ok(h.runUntil(() => heals(h, u, (c) => c.target === low).length > 0, 10));
assert.equal(heals(h, u, (c) => c.target === mid).length, 0, 'one ally: the lowest ratio');
approx(heals(h, u, (c) => c.target === low)[0].amount, u.s.atk * bb.heal_scale * (tb.heal_scale && low.hpRatio < tb.hp_ratio + 0.1 ? tb.heal_scale : 1), 'heal_scale (× GUA-X below 50 % on the elite)', 1e-3);
assert.equal(h.hooksOf('skillStart').filter((c) => c.unit === u)[0].reason, 'TAKE_DAMAGE');
const first = heals(h, u, (c) => c.target === low)[0];
assert.ok(first.amount > 0 && heals(h, u)[0] === first, 'the first heal goes to the lowest ratio');
approx(first.amount, u.s.atk * bb.heal_scale * (tb.heal_scale && tb.hp_ratio != null ? tb.heal_scale : 1), 'heal_scale (× GUA-X below 50 % on the elite)', 1e-3);
const start = h.hooksOf('skillStart').find((x) => x.unit === u);
assert.equal(start.reason, 'DEFAULT');
assert.equal(dealt(h, u, (c) => c.target === e && c.t === start.t).length, 0, 'the heal replaced the attack of that moment');
assert.ok(heals(h, u).every((c) => c.t >= start.t), 'only skill heals: she has no heal of her own');
done(h);
}
});
@@ -635,7 +645,7 @@ test('塞雷娅 S1 急救 (TAKE_DAMAGE): her next attack heals the ally below ha
test('塞雷娅 S3 钙质化: allies in the area heal attack@heal_scale × ATK/s; enemies there take arts ×damage_scale and move −60 %', () => {
for (const id of pair('11')) {
const h = run({
defs: { enemies: { enemy_dummy: dummy('enemy_dummy') }, chess: { t_a: ally('t_a') } },
defs: { enemies: { enemy_dummy: dummy('enemy_dummy', { atk: 100, bat: 1 }) }, chess: { t_a: ally('t_a') } }, // 重装: TAKE_DAMAGE
units: [entry(id, 'skchr_demkni_3', { row: 10, col: 4 }), { chessId: 't_a', row: 10, col: 6 }],
enemies: [{ key: 'enemy_dummy', pos: [10, 4] }, { key: 'enemy_dummy', pos: [9, 6] }, { key: 'enemy_dummy', pos: [12, 9] }],
});
@@ -878,6 +888,19 @@ test('凛御银灰 S1 周旋的谋略: +10 DP; the waiting op nearest to the eye
}
});
test('凛御银灰 S1 周旋的谋略 (AUTO): fires as soon as SP is full — no enemy, no attack needed (like 伺夜 S1; playtest #6 review)', () => {
for (const id of pair('14')) {
const h = run({ units: [entry(id, 'skchr_svash2_1', { row: 10, col: 4 })], flags: { dpPerSec: 0 } });
const u = sel(h, id, 'skchr_svash2_1');
assert.equal(u.skill.rule, 'SP_FULL');
const due = h.b.time + (u.skill.spCost - u.skill.sp) / u.s.spRecovery; // 精锐 talents start her with more SP
assert.ok(h.runUntil(() => u.skill.activations === 1, 60), `${id}: cast with no enemy on the field`);
const st = h.hooksOf('skillStart').find((c) => c.unit === u);
assert.ok(Math.abs(st.t - due) <= 0.05, `${id}: at full SP (${st.t} vs ${due})`);
done(h);
}
});
test('凛御银灰 S3 变革已至: skill range, line attacks bird_atk_scale × ATK + 脆弱, DP 5/6 then +1 per 2 s, first cast swaps waiting costs', () => {
for (const id of pair('14')) {
const h = run({
@@ -1295,36 +1318,54 @@ test('default-skill machinery stays with the default skill: 圣约送葬人 S1 n
// =================================================================================================================
// adversarial review regressions
test('号角 S1 / 塞雷娅 S1 (TAKE_DAMAGE, elite: 2 charges): hits taken while a charge is armed never spend the other one; after the shot the next hit arms it', () => {
// (号角 shoots 4 tiles ahead; 塞雷娅's range is her own tile)
for (const [id, skillId, col, ecol] of [['chess_char_5_08_b', 'skchr_horn_1', 2, 6], ['chess_char_5_11_b', 'skchr_demkni_1', 4, 4]]) {
const h = run({
defs: { enemies: { enemy_dummy: dummy('enemy_dummy') } },
units: [entry(id, skillId, { row: 10, col })],
enemies: [{ key: 'enemy_dummy', pos: [10, ecol] }],
});
const u = sel(h, id, skillId);
test('号角 S1 / 塞雷娅 S1 (自动触发, elite: 2 charges): hits never arm them; a cast "next attack" still waiting never spends the other charge', () => {
// 号角 S1: the data rule DEFAULT — each charge is cast with one of her attacks (hits taken do nothing)
{
const id = 'chess_char_5_08_b';
const h = run({ defs: { enemies: { enemy_dummy: dummy('enemy_dummy') } }, units: [entry(id, 'skchr_horn_1', { row: 10, col: 2 })], enemies: [{ key: 'enemy_dummy', pos: [10, 6] }] });
const u = sel(h, id, 'skchr_horn_1');
const e = h.b.enemies[0];
const starts = () => h.hooksOf('skillStart').filter((c) => c.unit === u).length;
const starts = () => h.hooksOf('skillStart').filter((c) => c.unit === u);
h.step();
u.atkCd = 3;
u.skill.gainSp(1000);
assert.equal(u.skill.charges, 2, `${id}: 2 charges`);
for (let i = 0; i < 3; i++) h.b.dealDamage(e, u, { amount: 10, type: 'phys' });
assert.equal(starts(), 1, `${id}: one charge armed by the first hit`);
assert.equal(u.skill.charges, 1, `${id}: the second charge stays stored`);
assert.ok(u.skill.pending, `${id}: armed`);
assert.ok(h.runUntil(() => !u.skill.pending, 10), `${id}: the armed attack is made`);
assert.equal(starts(), 1);
h.b.dealDamage(e, u, { amount: 10, type: 'phys' });
assert.equal(starts(), 2, `${id}: the next hit arms the stored charge`);
assert.ok(u.skill.pending);
// silenced / element-gauge hits never arm it (the engine TAKE_DAMAGE conditions)
assert.ok(h.runUntil(() => !u.skill.pending, 10));
assert.equal(starts().length, 0, 'hits do not arm it');
assert.ok(h.runUntil(() => starts().length >= 2, 15), 'both charges are cast');
const shots = h.hooksOf('attack').filter((c) => c.attacker === u && c.isSkill);
assert.equal(new Set(shots.slice(0, 2).map((c) => c.t)).size, 2, 'one charge per attack');
done(h);
}
// 塞雷娅 S1: DEFAULT + an ally of the area at ≤ half HP — each charge replaces one of her attacks (no heal mode waits)
{
const id = 'chess_char_5_11_b';
const h = run({ defs: { enemies: { enemy_dummy: dummy('enemy_dummy') }, chess: { t_low: ally('t_low'), t_low2: ally('t_low2') } },
units: [entry(id, 'skchr_demkni_1', { row: 10, col: 4 }), { chessId: 't_low', row: 11, col: 4 }, { chessId: 't_low2', row: 9, col: 4 }],
enemies: [{ key: 'enemy_dummy', pos: [10, 4] }] });
const u = sel(h, id, 'skchr_demkni_1');
const e = h.b.enemies[0];
const starts = () => h.hooksOf('skillStart').filter((c) => c.unit === u).length;
h.step();
u.atkCd = 2;
u.skill.gainSp(1000);
h.b.dealDamage(e, u, { type: 'element', element: 'burn', amount: 10 });
assert.equal(starts(), 2, `${id}: an element-gauge hit does not arm it`);
assert.equal(u.skill.charges, 2, `${id}: 2 charges`);
h.unit('t_low').hp = h.unit('t_low').s.maxHp * 0.3;
h.unit('t_low2').hp = h.unit('t_low2').s.maxHp * 0.3;
for (let i = 0; i < 3; i++) h.b.dealDamage(e, u, { amount: 10, type: 'phys' });
h.run(1);
assert.equal(starts(), 0, 'hits do not arm it, nor does the injured ally alone: it waits for her attack');
assert.ok(h.runUntil(() => starts() === 1, 2), 'her attack casts one charge');
assert.ok(!u.skill.pending, 'and is the heal');
assert.equal(u.skill.charges, 1, 'the second charge stays stored');
assert.ok(h.runUntil(() => starts() === 2, 5), 'her next attack heals the other one');
assert.equal(heals(h, u).length, 2);
assert.notEqual(heals(h, u)[0].target, heals(h, u)[1].target);
assert.ok(!u.skill.pending);
mute(h, u);
h.b.dealDamage(e, u, { amount: 10, type: 'phys' });
u.skill.gainSp(1000);
h.unit('t_low').hp = h.unit('t_low').s.maxHp * 0.3;
h.run(3);
assert.equal(starts(), 2, `${id}: silenced`);
done(h);
}
+11 -5
View File
@@ -164,7 +164,9 @@ test('6_01 蕾缪安 S2: a low-HP wanted target is sniped at once (ATK × scale
test('6_02 圣聆初雪 S1 铃音吹雪: 2 charges (cast with enemies in range); atk_scale × ATK arts + cold on every enemy in range, pushed along her direction, snow spreads 5 tiles forward', () => {
for (const id of both('chess_char_6_02')) {
const sid = 'skchr_sbell2_1', bb = bbOf(id, sid);
const h = run({ defs: { enemies: { e: dummy('e') } }, units: [U(id, sid, 10, 4, { carryState: READY })], enemies: [{ key: 'e', pos: [10, 5] }, { key: 'e', pos: [11, 5] }] });
// weight-2 dummies: 中力 (1) − 2 = 受力等级 −1 ⇒ the official 0.44-tile push (PRTS 游戏数据基础 §重量公式), so they stay
// in her range for the second charge
const h = run({ defs: { enemies: { e: dummy('e', { mass: 2 }) } }, units: [U(id, sid, 10, 4, { carryState: READY })], enemies: [{ key: 'e', pos: [10, 5] }, { key: 'e', pos: [11, 5] }] });
const u = h.unit(id);
usesSkill(u, sid);
assert.equal(u.skill.maxCharges, rec(id, sid).maxChargeTime);
@@ -179,11 +181,15 @@ test('6_02 圣聆初雪 S1 铃音吹雪: 2 charges (cast with enemies in range);
assert.equal(colds.length, 2);
for (const c of colds) approx(c.duration, bb.cold, 'cold');
const [e1] = h.enemies();
assert.ok(e1.x > 5.5, `pushed forward (${e1.x})`);
approx(e1.x, 5 + 0.44 * started(h, u).length, 'pushed forward along her direction by the official 0.44 tiles per cast');
approx(e1.y, 10, 'no sideways push');
// (the pushed enemies clear the snow of the tiles they leave: "首个敌人离开该地块时积雪消失")
for (let d = 3; d <= bb.trig_cnt; d++) assert.ok((u.mem.snow.get(10 * COLS + 4 + d) ?? 0) >= 1, `snow ${d} tiles ahead`);
h.run(2.5);
assert.equal(started(h, u).length, 1, 'the next automatic cast waits the 3 s operation cooldown ("自动操作具有3s冷却")');
h.run(1);
assert.equal(started(h, u).length, 2, 'the second charge fires too');
assert.ok(started(h, u)[1].t - t0 >= 3 - 1e-6);
done(h);
}
});
@@ -255,11 +261,11 @@ test('6_03 余 S1 今日做东: taunt +1 while carried; TAKE_DAMAGE cast, HP/DEF
assert.equal(d.unit('chess_char_6_03_a').s.taunt, 0);
});
test('6_03 余 S2 厚礼上宾: atk_scale × ATK arts around him, reachable ground enemies teleported onto his tile; block +2, HP/ATK +, arts attacks', () => {
test('6_03 余 S2 厚礼上宾: cast when hit (重装 TAKE_DAMAGE); atk_scale × ATK arts around him, reachable ground enemies teleported onto his tile; block +2, HP/ATK +, arts attacks', () => {
for (const id of both('chess_char_6_03')) {
const sid = 'skchr_yu_2', bb = bbOf(id, sid);
const h = run({
defs: { enemies: { e: dummy('e') } },
defs: { enemies: { e: dummy('e', { atk: 100, bat: 1 }) } },
units: [U(id, sid, 10, 4, { carryState: READY })],
enemies: [{ key: 'e', pos: [10, 4] }, { key: 'e', pos: [10, 6] }, { key: 'e', pos: [11, 5] }],
});
@@ -1153,7 +1159,7 @@ test('default-skill-only hooks do not run under an alternate skill (余 / 维娜
// 余 S2 active: 闲云隐市 is NOT given to every operator (S3 only)
{
const h = run({
defs: { chess: { o1: plain('o1'), o2: plain('o2'), o3: plain('o3') }, enemies: { e: dummy('e') } },
defs: { chess: { o1: plain('o1'), o2: plain('o2'), o3: plain('o3') }, enemies: { e: dummy('e', { atk: 100, bat: 1 }) } }, // (重装: TAKE_DAMAGE)
units: [U('chess_char_6_03_a', 'skchr_yu_2', 10, 4, { carryState: READY }), { chessId: 'o1', row: 9, col: 6 }, { chessId: 'o2', row: 11, col: 6 }, { chessId: 'o3', row: 12, col: 6 }],
enemies: [{ key: 'e', pos: [10, 4] }],
});
+25 -18
View File
@@ -82,14 +82,14 @@ test('1_01 隐现 elite: +self_ammo after `duration` s on field; a random other
done(h);
});
test('1_02 角峰: 抗寒体质 HP/DEF/RES ×(1+x); 雪原卫士 RES +flat (normal and elite)', () => {
test('1_02 角峰: 抗寒体质 (重装 TAKE_DAMAGE) HP/DEF/RES ×(1+x); 雪原卫士 RES +flat (normal and elite)', () => {
for (const id of ['chess_char_1_02_a', 'chess_char_1_02_b']) {
const bb = bbOf(id), t = tal(id);
const h = run({ defs: { enemies: { e: dummy('e') } }, units: [{ chessId: id, row: 9, col: 5, carryState: READY }], enemies: [{ key: 'e', pos: [9, 5] }] });
const h = run({ defs: { enemies: { e: dummy('e', { atk: 100, bat: 1 }) } }, units: [{ chessId: id, row: 9, col: 5, carryState: READY }], enemies: [{ key: 'e', pos: [9, 5] }] });
const u = h.unit(id);
approx(u.s.res, u.base.res + t.magic_resistance, `${id} talent RES`);
h.step();
assert.ok(u.skill.active);
assert.ok(h.runUntil(() => u.skill.active, 3), 'cast by the first hit');
assert.equal(h.hooksOf('skillStart').find((c) => c.unit === u).reason, 'TAKE_DAMAGE');
approx(u.s.maxHp, u.base.maxHp * (1 + bb.max_hp), 'HP');
approx(u.s.def, u.base.def * (1 + bb.def), 'DEF');
approx(u.s.res, (u.base.res + t.magic_resistance) * (1 + bb.magic_resistance), 'RES');
@@ -131,10 +131,12 @@ test('1_04 深巡: fin darts pierce attack@max_target enemies on the line + 1 s
enemies: [{ key: 'e_sea', pos: [9, 5] }, { key: 'e', pos: [9, 6] }, { key: 'e', pos: [9, 7] }],
});
const u = h.unit(id);
h.step();
h.b.dealDamage(h.enemies()[0], u, { amount: 10, type: 'phys' }); // 重装: TAKE_DAMAGE
h.run(3.5);
assert.ok(u.skill.active);
approx(u.s.atk, u.base.atk * (1 + bb.atk));
const firstAtk = h.hooksOf('attack').find((c) => c.attacker === u);
const firstAtk = h.hooksOf('attack').find((c) => c.attacker === u && c.isSkill);
assert.equal(firstAtk.targets.length, bb['attack@max_target'], 'hits every enemy of the line');
const sl = statuses(h, 'sluggish', (c) => c.source === u);
assert.ok(sl.length >= 3 && sl.every((c) => c.duration === bb['attack@sluggish']));
@@ -328,7 +330,7 @@ test('1_09 跃跃 回环射手: each attack is a boomerang (out 15, back 3.75 ti
done(h2);
});
test('1_10 古米: TAKE_DAMAGE 备用军粮 heals the most injured nearby ally for heal_scale × ATK; elite low-HP bonus; pan stun', () => {
test('1_10 古米: 备用军粮 (自动触发: an injured ally of the skill range) heals the most injured nearby ally for heal_scale × ATK; elite low-HP bonus; pan stun', () => {
for (const id of ['chess_char_1_10_a', 'chess_char_1_10_b']) {
const bb = bbOf(id), tb = tbOf(id), t = tal(id);
const h = run({
@@ -343,7 +345,7 @@ test('1_10 古米: TAKE_DAMAGE 备用军粮 heals the most injured nearby ally f
const hh = heals(h, u, (c) => c.target === yak)[0];
const k = tb.hp_ratio != null ? tb.heal_scale : 1;
approx(hh.amount, u.s.atk * bb.heal_scale * k, `${id} heal`);
assert.equal(h.hooksOf('skillStart').find((c) => c.unit === u).reason, 'TAKE_DAMAGE');
assert.equal(h.hooksOf('skillStart').find((c) => c.unit === u).reason, 'SKILL_RANGE');
// talent: pan procs stun the enemy t.stun s
h.runUntil(() => statuses(h, 'stun', (c) => c.source === u).length > 0, 200);
const st = statuses(h, 'stun', (c) => c.source === u)[0];
@@ -588,11 +590,12 @@ test('1_20 雷蛇: 反击电弧 arts on up to 3 enemies, self-stun `stun` s afte
const u = h.unit(id);
h.run(3);
assert.ok(u.skill.active);
assert.equal(h.hooksOf('skillStart').find((c) => c.unit === u).reason, 'TAKE_DAMAGE', '重装: cast by the hit');
approx(u.s.atk, u.base.atk * (1 + bb.atk));
approx(u.s.interval, u.base.bat * (1 + bb.base_attack_time) * 100 / u.s.aspd, 'attack interval +70 % (PRTS 增大(+70%): a ratio, not +0.7 s)');
const a0 = h.hooksOf('attack').find((c) => c.attacker === u);
const a0 = h.hooksOf('attack').find((c) => c.attacker === u && c.isSkill);
assert.equal(a0.targets.length, 3);
assert.ok(dealt(h, u, (c) => c.dmg.isAttack).every((c) => c.type === 'arts'));
assert.ok(dealt(h, u, (c) => c.dmg.isAttack && c.dmg.isSkill).every((c) => c.type === 'arts'));
h.runUntil(() => !u.skill.active, 30);
h.step();
const st = statuses(h, 'stun', (c) => c.target === u);
@@ -631,9 +634,11 @@ test('2_01 送葬人: 最终旅程 double hits and shorter interval; 终结改
done(h);
});
test('2_02 赫默: 医疗无人机 lands next to the injured ally and expires; 强化注射 MEDIC ASPD; elite ×1.15 on ground units', () => {
test('2_02 赫默: 医疗无人机 lands on the tile its piece was placed on and expires; 强化注射 MEDIC ASPD; elite ×1.15 on ground units', () => {
const id = 'chess_char_2_02_a', t = tal(id);
const h = run({ units: [{ chessId: id, row: 10, col: 4, carryState: READY }, { chessId: 'chess_char_1_02_a', row: 9, col: 6 }, { chessId: 'chess_char_2_14_a', row: 12, col: 4 }] });
// the drone is a hand piece the player placed next to the ally (user playtest #6); S2 brings it there
const h = run({ units: [{ chessId: id, row: 10, col: 4, carryState: READY, uid: 1 }, { chessId: 'chess_char_1_02_a', row: 9, col: 6 }, { chessId: 'chess_char_2_14_a', row: 12, col: 4 },
{ kind: 'token', tokenId: 'token_10000_silent_healrb', ownerUid: 1, row: 9, col: 5 }] });
const u = h.unit(id), yak = h.unit('chess_char_1_02_a'), fl = h.unit('chess_char_2_14_a');
h.step();
yak.hp = yak.s.maxHp * 0.3;
@@ -643,7 +648,7 @@ test('2_02 赫默: 医疗无人机 lands next to the injured ally and expires;
approx(yak.s.aspd, yak.base.aspd, 'non-medic unaffected');
const drone = h.b.allyUnits.find((x) => x.kind === 'token');
assert.ok(drone && drone.alive, 'drone deployed');
assert.ok(Math.max(Math.abs(drone.tileR - yak.tileR), Math.abs(drone.tileC - yak.tileC)) <= 1, 'next to the injured ally');
assert.deepEqual([drone.tileR, drone.tileC], [9, 5], 'on its placed tile, next to the injured ally');
assert.ok(drone.s.flags.untargetable);
h.run(10);
assert.equal(drone.alive, false, 'self-destructs after its lifetime');
@@ -669,7 +674,8 @@ test('2_03 崖心 (hidden): 束缚链 drags max_target enemies to the front tile
const pulled = h.enemies().filter((e) => e.x < 6);
assert.equal(pulled.length, bb.max_target);
for (const e of pulled) {
assert.ok(Math.hypot(e.x - 5, e.y - 10) < 0.3, `at the front tile (${e.x},${e.y})`);
// 中力 vs weight 1 (受力等级 0): "必定拉至身前" — to the 急停 radius 0.6708 around her centre (PRTS 推与拉)
approx(Math.hypot(e.x - 4, e.y - 10), 0.6708, `at her front (${e.x},${e.y})`, 1e-6);
const tr = dealt(h, u, (c) => c.target === e && c.type === 'true');
assert.equal(tr.length, 1);
approx(tr[0].amount, u.s.atk * bb.atk_scale);
@@ -686,7 +692,7 @@ test('2_03 崖心 (hidden): 束缚链 drags max_target enemies to the front tile
h3.step();
const drag = dealt(h3, h3.unit(idb), tagged('drag'));
assert.equal(drag.length, 1);
approx(drag[0].amount, tb.value * 2 / tb.dist, 'value per dist tiles', 0.06);
approx(drag[0].amount, tb.value * (3 - 0.6708) / tb.dist, 'value per dist tiles', 0.06);
done(h3);
});
@@ -1065,8 +1071,7 @@ test('2_17 折桠: 生存决心 trembles ground enemies around, ATK/DEF up, hits
units: [{ chessId: id, row: 9, col: 5, carryState: READY }], enemies: [{ key: 'e', pos: [9, 5] }, { key: 'r', pos: [10, 6] }, { key: 'e', pos: [9, 8] }],
});
const u = h.unit(id);
h.step();
assert.ok(u.skill.active);
assert.ok(h.runUntil(() => u.skill.active, 2), '重装 TAKE_DAMAGE: cast by the first hit');
approx(u.s.atk, u.base.atk * (1 + bb.atk));
approx(u.s.def, u.base.def * (1 + bb.def));
const [blk, adj, far] = h.enemies();
@@ -1227,8 +1232,9 @@ test('1_19 野鬃 normal: 一致向前 only on the first deployment', () => {
done(h);
});
test('2_02 赫默: the drone never lands on the home tile of a knocked-out operator', () => {
const h = run({ units: [{ chessId: 'chess_char_2_02_a', row: 10, col: 4, carryState: READY }, { chessId: 'chess_char_1_02_a', row: 10, col: 5 }, { chessId: 'chess_char_1_10_a', row: 9, col: 5 }] });
test('2_02 赫默: the drone takes its own placed tile, never the home tile of a knocked-out operator', () => {
const h = run({ units: [{ chessId: 'chess_char_2_02_a', row: 10, col: 4, carryState: READY, uid: 1 }, { chessId: 'chess_char_1_02_a', row: 10, col: 5 }, { chessId: 'chess_char_1_10_a', row: 9, col: 5 },
{ kind: 'token', tokenId: 'token_10000_silent_healrb', ownerUid: 1, row: 11, col: 5 }] });
h.step();
const yak = h.unit('chess_char_1_02_a'), gum = h.unit('chess_char_1_10_a');
gum.hp = gum.s.maxHp * 0.3;
@@ -1236,6 +1242,7 @@ test('2_02 赫默: the drone never lands on the home tile of a knocked-out opera
h.run(0.5);
const d = h.b.allyUnits.find((x) => x.kind === 'token');
assert.ok(d && d.alive, 'drone deployed');
assert.deepEqual([d.tileR, d.tileC], [11, 5], 'its placed tile');
assert.ok(!(d.tileR === yak.homeR && d.tileC === yak.homeC), 'not on the dead operator tile');
done(h);
});
+41 -22
View File
@@ -508,11 +508,12 @@ test('3_14 初雪: 自然震慑 DEF/RES shred aura; 虚弱化 fragile under 40 %
done(h);
});
test('3_15 巫恋: 诅咒娃娃 placed where enemies are, −ATK/DEF around it, gone after its lifetime; 溃败暗示 fragile', () => {
test('3_15 巫恋: S2 brings the placed 诅咒娃娃 onto its tile, −ATK/DEF around it, gone after its lifetime; 溃败暗示 fragile', () => {
const id = 'chess_char_3_15_a', bb = BB(id), t0 = TB(id, 0);
const h = makeBattle({
defs: { enemies: { enemy_a: dummy('enemy_a', { def: 200, atk: 100 }), enemy_b: dummy('enemy_b', { def: 200, atk: 100 }) } }, timeLimit: 80,
units: [{ chessId: id, row: 10, col: 3 }], enemies: [{ key: 'enemy_a', pos: [11, 5] }, { key: 'enemy_b', pos: [10, 5] }],
units: [{ chessId: id, row: 10, col: 3, uid: 1 }, { kind: 'token', tokenId: 'token_10006_vodfox_doll', ownerUid: 1, row: 10, col: 4, uid: 2 }],
enemies: [{ key: 'enemy_a', pos: [11, 5] }, { key: 'enemy_b', pos: [10, 5] }],
});
const u = h.unit(id);
h.step();
@@ -520,15 +521,20 @@ test('3_15 巫恋: 诅咒娃娃 placed where enemies are, −ATK/DEF around it,
a.hp = a.s.maxHp * (t0.hp_ratio / 2);
h.run(0.3);
approx(a.s.dmgTakenMul, t0.damage_scale);
const doll = h.unit(2);
const life = ds.getToken('token_10006_vodfox_doll', id).skill.duration;
assert.equal(doll.alive, true, 'the placed doll deploys once at the start (PRTS; shared/constants.js SKILL_SUMMON_START_DEPLOY)');
assert.ok(h.runUntil(() => !doll.alive, life + 1), 'its lifetime');
h.run(doll.base.respawnTime + 0.1);
assert.equal(doll.alive, u.skill.activations > 0, 'then only with S2');
const n0 = u.skill.activations;
fill(u);
assert.ok(h.runUntil(() => u.skill.activations === 1, 5));
const doll = u.mem.doll;
assert.ok(doll && doll.alive && doll.defId === 'token_10006_vodfox_doll');
assert.ok(Math.abs(doll.tileR - 10.5) <= 1 && Math.abs(doll.tileC - 5) <= 1, 'next to both enemies');
assert.ok(h.runUntil(() => u.skill.activations === n0 + 1 && doll.alive, 5));
assert.ok(doll.alive && doll.defId === 'token_10006_vodfox_doll');
assert.deepEqual([doll.tileR, doll.tileC], [10, 4], 'on the tile the player chose');
h.run(0.3);
approx(b.s.def, 200 * (1 + bb.def));
approx(b.s.atk, 100 * (1 + bb.atk));
const life = ds.getToken('token_10006_vodfox_doll', id).skill.duration;
h.run(life);
assert.equal(doll.alive, false, 'destroyed');
h.run(0.3);
@@ -608,7 +614,7 @@ test('3_18 忍冬: 隐狐之艺 DP, decaying ASPD, all blocked + stun, camouflag
assert.ok(h.hooksOf('attack').some((c) => c.attacker === u && c.isSkill && c.targets.length === 2), 'hits every blocked enemy');
assert.ok(h.hooksOf('statusApplied').some((c) => c.source === u && c.status === 'stun' && Math.abs(c.duration - bb['attack@stun']) < 1e-9));
h.runUntil(() => !u.skill.active, 20);
assert.ok(!u.s.flags.stealth, 'no kill ⇒ no camouflage');
assert.ok(!u.s.flags.camou, 'no kill ⇒ no camouflage');
done(h);
const c = makeBattle({
@@ -621,11 +627,11 @@ test('3_18 忍冬: 隐狐之艺 DP, decaying ASPD, all blocked + stun, camouflag
c.spawn('enemy_f', { pos: [10, 5] });
assert.ok(c.runUntil(() => z.skill.active, 5));
c.runUntil(() => !z.skill.active, 20);
assert.ok(z.s.flags.stealth, 'camouflage after a kill during the skill');
assert.ok(z.s.flags.camou && !z.s.flags.stealth, 'camouflage (迷彩, not 隐匿) after a kill during the skill');
fill(z);
c.spawn('enemy_f', { pos: [10, 5] });
assert.ok(c.runUntil(() => z.skill.activations === 2, 5));
assert.ok(!z.s.flags.stealth, 'until the next cast');
assert.ok(!z.s.flags.camou, 'until the next cast');
done(c);
const q = makeBattle({ units: [{ chessId: id, row: 10, col: 4 }], timeLimit: 30 });
@@ -937,28 +943,41 @@ test('3_10 松果: 电能过载 opens only once an enemy stands inside the short
done(h);
});
test('3_15 巫恋: a cast replaces the doll placed in the prep phase (one doll), never on a dead operator’s tile; 精锐 −30 %', () => {
test('3_15 巫恋: the doll placed in the prep phase deploys at the start, then waits for S2 and is the one doll (each cast brings it back); 精锐 −30 %', () => {
const gid = 'chess_char_3_15_b', bb = BB(gid);
const h = makeBattle({
defs: { enemies: { enemy_a: dummy('enemy_a', { def: 200, atk: 100 }), enemy_b: dummy('enemy_b', { def: 200, atk: 100 }) } }, timeLimit: 60,
defs: { enemies: { enemy_a: dummy('enemy_a', { def: 200, atk: 100 }), enemy_b: dummy('enemy_b', { def: 200, atk: 100 }) } }, timeLimit: 120,
units: [{ chessId: gid, row: 10, col: 3, uid: 1 }, { kind: 'token', tokenId: 'token_10006_vodfox_doll', ownerUid: 1, row: 10, col: 5, uid: 2 },
{ chessId: 'chess_char_3_16_a', row: 11, col: 4, uid: 3 }],
enemies: [{ key: 'enemy_a', pos: [12, 4] }, { key: 'enemy_b', pos: [11, 5] }],
});
const u = h.unit(1), piece = h.unit(2), cu = h.unit(3);
const u = h.unit(1), piece = h.unit(2);
h.step();
assert.ok(piece.alive && piece.ownerUnit === u, 'board doll deployed');
h.b.dealDamage(null, cu, { type: 'true', amount: 1e7 });
assert.ok(piece.alive && piece.ownerUnit === u, 'the board doll deploys once at the start');
assert.ok(h.runUntil(() => !piece.alive, 20), 'its lifetime');
h.run(piece.base.respawnTime + 0.1);
const n0 = u.skill.activations;
assert.equal(piece.alive, n0 > 0, 'then only with the skill');
fill(u);
assert.ok(h.runUntil(() => u.skill.activations === 1, 5));
const dolls = h.b.allyUnits.filter((t) => t.alive && t.defId === 'token_10006_vodfox_doll');
assert.equal(dolls.length, 1, 'deploy limit 1');
assert.equal(piece.alive, false, 'the prep-phase doll was replaced');
assert.notDeepEqual([dolls[0].tileR, dolls[0].tileC], [11, 4], 'not on the dead operator’s home tile');
assert.ok(h.runUntil(() => u.skill.activations === n0 + 1 && piece.alive, 5));
const dolls = () => h.b.allyUnits.filter((t) => t.alive && t.defId === 'token_10006_vodfox_doll');
assert.equal(dolls().length, 1, 'one doll');
assert.equal(dolls()[0], piece, 'the prep-phase piece itself');
assert.deepEqual([piece.tileR, piece.tileC], [10, 5]);
h.run(0.3);
const b = h.enemy('enemy_b');
approx(b.s.def, 200 * (1 + bb.def));
assert.equal(b.findBuff('skill:vodfox_doll').mods.atkPct, bb.atk, 'ATK −30 % (her module weaken stacks on top)');
assert.equal(b.findBuff('token:curseDoll').mods.atkPct, bb.atk, 'ATK −30 % (her module weaken stacks on top)');
// gone after its lifetime, back on the same tile with the next cast
assert.ok(h.runUntil(() => !piece.alive, 20));
const gone = h.b.time;
fill(u);
assert.ok(h.runUntil(() => u.skill.activations === n0 + 2, 5));
// the doll's redeploy time (data respawnTime 5 s) runs from the moment it left
assert.ok(h.runUntil(() => piece.alive, 6));
assert.ok(h.b.time - gone >= piece.base.respawnTime - 1e-6, 'after its redeploy time');
assert.ok(piece.tileR === 10 && piece.tileC === 5, 'the next cast: the same tile');
assert.equal(dolls().length, 1);
done(h);
});
@@ -1134,7 +1153,7 @@ test('忍冬 迷彩: after a kill during 隐狐之艺 ranged enemies stop target
h.spawn('enemy_f', { pos: [10, 5] });
assert.ok(h.runUntil(() => u.skill.active, 5));
h.runUntil(() => !u.skill.active, 20);
assert.ok(u.s.flags.stealth);
assert.ok(u.s.flags.camou);
h.spawn('enemy_r', { pos: [11, 6] });
h.run(4);
assert.equal(h.hooksOf('damaged').filter((c) => c.target === u && c.source?.defId === 'enemy_r').length, 0, 'not targeted while camouflaged');
+25 -12
View File
@@ -389,9 +389,17 @@ test('阿罗玛 elite module: farther targets take up to ×1.1', () => {
approx(hits[1].amount, u.s.atk * (1 + tb.damage_scale), 1e-6, 'distance 5 ≥ max_dist');
});
test('凯瑟琳: 2 support devices shield allies (20 % of her HP), 6 %/s after 5 s unhit; S2: 6 %/s regardless, no attacks', () => {
test('凯瑟琳: 2 placed support devices shield allies (20 % of her HP), 6 %/s after 5 s unhit; S2: 6 %/s regardless, no attacks', () => {
const id = 'chess_char_4_11_a', bb = D(id).skill.bb;
const h = makeBattle({ units: [{ chessId: id, row: 10, col: 5 }, { chessId: 'chess_char_1_02_a', row: 9, col: 6 }], timeLimit: 120, hooks: ['attack'], captureNoisy: true });
// the devices are hand pieces the player places and turns (user playtest #6): one faces the ally, one faces her
const h = makeBattle({
units: [
{ chessId: id, row: 10, col: 5, uid: 1 }, { chessId: 'chess_char_1_02_a', row: 9, col: 6, uid: 2 },
{ kind: 'token', tokenId: 'token_10041_cathy_catsld', ownerUid: 1, row: 9, col: 5, uid: 3, dir: 'RIGHT' },
{ kind: 'token', tokenId: 'token_10041_cathy_catsld', ownerUid: 1, row: 10, col: 6, uid: 4, dir: 'LEFT' },
],
timeLimit: 120, hooks: ['attack'], captureNoisy: true,
});
h.run(0.2);
const u = h.unit(id), ally = h.unit('chess_char_1_02_a');
const devs = h.b.allyUnits.filter((x) => x.defId === 'token_10041_cathy_catsld' && x.alive);
@@ -1103,7 +1111,8 @@ test('elite (精锐) kits read the Lv7 blackboard: skill magnitudes differ from
assert.equal(waves.size, D('chess_char_4_16_b').skill.duration);
assert.ok([...waves.values()].every((n) => n === bb.max_target));
}
// 歌蕾蒂娅: force 1 pulls twice as far per pulse as force 0
// 歌蕾蒂娅 S3 tornado pulse on a weight-1 enemy √2 from the marked point (PRTS 推与拉): elite 中力 (1) = 受力等级 0 ⇒ all the
// way to the point (its 0.05 急停); normal 小力 (0) = −1 ⇒ 35 % of the starting distance
{
const pulled = (id) => {
const [h, u] = mk(id);
@@ -1115,7 +1124,8 @@ test('elite (精锐) kits read the Lv7 blackboard: skill magnitudes differ from
h.run(D(id).skill.bb.interval + 0.1);
return Math.hypot(side.x - x0, side.y - y0);
};
approx(pulled('chess_char_4_12_b'), 2 * pulled('chess_char_4_12_a'), 0.05);
approx(pulled('chess_char_4_12_b'), Math.SQRT2 - 0.05, 0.02);
approx(pulled('chess_char_4_12_a'), 0.35 * Math.SQRT2, 0.02);
}
});
@@ -1137,18 +1147,21 @@ test('焰尾 S3 dodge stacks independently with the 卡西米尔 aura (elite 80
}
});
test('凯瑟琳: a device sits orthogonally next to its operator and re-shields it after it redeploys on that tile', () => {
test('凯瑟琳: a placed device serves the operator it faces and re-shields it after it redeploys on that tile', () => {
const id = 'chess_char_4_11_a';
const h = makeBattle({ units: [{ chessId: id, row: 10, col: 5 }, { chessId: 'chess_char_1_02_a', row: 9, col: 7 }], timeLimit: 200, hooks: [] });
const h = makeBattle({
units: [
{ chessId: id, row: 10, col: 5, uid: 1 }, { chessId: 'chess_char_1_02_a', row: 9, col: 7, uid: 2 },
{ kind: 'token', tokenId: 'token_10041_cathy_catsld', ownerUid: 1, row: 9, col: 6, uid: 3, dir: 'RIGHT' },
],
timeLimit: 200, hooks: [],
});
h.run(0.2);
const u = h.unit(id), ally = h.unit('chess_char_1_02_a');
const tb = h.b.data.getToken('token_10041_cathy_catsld', u.defId).talents[0].bb;
const devs = h.b.allyUnits.filter((x) => x.defId === 'token_10041_cathy_catsld' && x.alive);
for (const d of devs) {
const t = h.b.unitAt(d.mem.tr, d.mem.tc);
assert.equal(Math.abs(d.tileR - d.mem.tr) + Math.abs(d.tileC - d.mem.tc), 1, 'orthogonal neighbour');
assert.ok(t && t.kind === 'op');
}
const dev = h.b.allyUnits.find((x) => x.defId === 'token_10041_cathy_catsld' && x.alive);
assert.ok(dev && dev.tileR === 9 && dev.tileC === 6, 'on its placed tile');
assert.equal(dev.mem.target, ally, 'serves the operator in front of it');
h.b.dealDamage(null, ally, { amount: 1e9, type: 'true' });
assert.equal(ally.alive, false);
assert.ok(h.runUntil(() => ally.alive, 150), 'redeployed');
+17 -11
View File
@@ -376,6 +376,9 @@ test('号角 S3: ATK +25 %, BAT −1.2 s, then overload ATK +50 % with HP loss;
approx(h.unit('t_tank').s.atk, 100 * (1 + t0.atk), 1e-6, '军事要塞 on another Defender');
approx(u.s.atk, u.base.atk * (1 + t0.atk), 1e-6, '… and on herself');
u.skill.gainSp(1000);
h.run(1);
assert.equal(u.skill.activations, 0, '重装 TAKE_DAMAGE: not before a hit');
h.b.dealDamage(h.enemies()[0], u, { amount: 10, type: 'phys' });
assert.ok(h.runUntil(() => u.skill.active, 5));
const start = h.b.time;
const total = u.def.skill.duration, ov = bb['horn_s_3[overload_start].damage_duration'];
@@ -622,7 +625,9 @@ test('凛御银灰 S2: 6 front enemies in the skill range take 260 % ATK phys +
});
// ------------------------------------------------------------------------------------------------------------------
test('凛御银灰 S2 auto-cast follows DEFAULT: an enemy in his initial (melee) range, not merely in the skill range', () => {
test('凛御银灰 S2 auto-cast: SKILL_RANGE — an enemy inside the skill range casts it, no attack needed (PRTS 技能策略)', () => {
// PRTS 卫戍协议/帮助 技能操作: "携带拥有技能范围的技能(非攻击距离增加)的干员:不通过普通攻击/治疗触发技能,仅在技能范围内存在敌人
// (无视其不可选中)时释放技能" (it used to wait for an enemy in his melee range — user playtest #6 report 15)
const h = makeBattle({
defs: { enemies: { enemy_dummy: dummy() } },
units: [{ chessId: 'chess_char_5_14_a', row: 10, col: 3 }],
@@ -630,15 +635,14 @@ test('凛御银灰 S2 auto-cast follows DEFAULT: an enemy in his initial (melee)
hooks: ['damaged'], captureNoisy: true, autoFinish: false, timeLimit: 30,
});
const u = h.unit('chess_char_5_14_a');
assert.equal(u.skill.rule, 'SKILL_RANGE');
h.step();
u.skill.gainSp(1000);
h.run(3);
assert.equal(u.skill.activations, 0, 'enemies only in the skill range: no cast');
const e = h.spawn('enemy_dummy', { pos: [10, 4] });
assert.ok(h.runUntil(() => u.skill.activations > 0, 3), 'cast right before attacking the enemy in front');
h.run(0.2);
assert.equal(u.skill.activations, 1, 'enemies only in the skill range: cast');
assert.equal(h.hooksOf('damaged').filter((c) => c.source === u && c.dmg?.isAttack && !c.dmg.isSkill).length, 0, 'no attack needed');
const hits = tagged(h, 'svash2');
assert.equal(new Set(hits.map((c) => c.target.id)).size, 3, 'the burst still covers the whole skill range');
assert.ok(hits.some((c) => c.target === e));
assert.equal(new Set(hits.map((c) => c.target.id)).size, 2, 'the burst covers the skill range');
clean(h);
});
@@ -937,8 +941,8 @@ test('录武官 S2: ATK +45 %; healed allies heal 80 HP per hit taken for 10 s;
const hp = a.hp;
h.b.dealDamage(null, a, { amount: 500, type: 'true' });
approx(a.hp, hp - 500 + bb['attack@fixed_heal_value'], 1e-6);
// "治疗干员后": a summon healed by her gets no guard
const tok = h.b.spawnToken(a, 'token_10015_dusk_drgn', 11, 3, { kit: { skill: null } });
// "治疗干员后": a summon healed by her gets no guard (纸偶: a summon without 禁疗 — “小自在” holds it, PRTS)
const tok = h.b.spawnToken(a, 'token_10022_kazema_shadow', 11, 3, { kit: { skill: null } });
tok.hp = 100;
assert.ok(h.runUntil(() => tok.hp > 100, 5));
assert.ok(!tok.hasBuff('reckpr:guard'));
@@ -1236,7 +1240,7 @@ test('elite 夕 / 白面鸮: the module range is part of the initial range the D
clean(h);
});
test('安洁莉娜 T2 兼职工作: no regeneration for allies under 禁疗 (PRTS)', () => {
test('安洁莉娜 T2 兼职工作: an HP-regen attribute — allies under 禁疗 regenerate too (PRTS 备注 "不受治疗加成和禁疗影响")', () => {
const h = makeBattle({
defs: { chess: { t_a: ally('t_a'), t_b: ally('t_b') } },
units: [{ chessId: 'chess_char_5_20_a', row: 10, col: 4 }, { chessId: 't_a', row: 12, col: 4 }, { chessId: 't_b', row: 12, col: 6 }],
@@ -1248,7 +1252,9 @@ test('安洁莉娜 T2 兼职工作: no regeneration for allies under 禁疗 (PRT
a.hp = b.hp = 5000;
h.run(2);
assert.ok(a.hp > 5000);
assert.equal(b.hp, 5000, '禁疗');
assert.ok(b.hp > 5000, '禁疗 does not stop a 生命回复速度 attribute (PRTS 异常效果 禁疗)');
approx(b.hp, a.hp, 1e-6, 'same regen');
assert.equal(h.b.heal(h.unit('chess_char_5_20_a'), b, 100), 0, 'while heals still miss it');
clean(h);
});
+27
View File
@@ -760,6 +760,29 @@ test('锏: S3 = 10 slashes (180 % ×1.6 talent) on ≤5 enemies + a 300 % finish
checkInvariants(h.b);
});
test('锏 S3: invulnerable, stun- and freeze-immune during the slashes (PRTS 备注); normal again after them', () => {
const h = battle({
units: [{ chessId: 'chess_char_6_19_a', row: 10, col: 5, carryState: { sp: 34 } }],
enemies: [{ key: 'enemy_dummy', pos: [10, 6] }],
});
const u = h.unit('chess_char_6_19_a');
assert.ok(h.runUntil(() => u.skill.active, 10));
h.step();
const hp = u.hp;
h.b.dealDamage(null, u, { amount: 500, type: 'true' });
assert.equal(u.hp, hp, '无敌');
assert.equal(h.b.applyStatus(u, 'stun', { duration: 3 }), false, '晕眩免疫');
assert.equal(h.b.applyStatus(u, 'freeze', { duration: 3 }), false, '冻结免疫');
h.b.applyStatus(u, 'cold', { duration: 5 });
h.b.applyStatus(u, 'cold', { duration: 5 });
assert.ok(!u.s.flags.freeze, 'cold on cold does not freeze her either');
assert.ok(h.runUntil(() => !u.skill.active, 10));
h.b.dealDamage(null, u, { amount: 500, type: 'true' });
assert.ok(u.hp < hp, 'takes damage again after the slashes');
assert.ok(h.b.applyStatus(u, 'stun', { duration: 1 }), 'can be stunned again');
checkInvariants(h.b);
});
test('纯烬艾雅法拉: S3 heals across the whole field in 5 shots (35 %); 火山灰疗愈 max HP +6 %; 氤氲 HoT', () => {
const h = battle({
units: [
@@ -969,6 +992,10 @@ test('缪尔赛思: 流形 copies stats/range/damage type but not the attack sha
enemies: [{ key: 'enemy_dummy', pos: [10, 6] }],
});
const d = h.unit('chess_char_6_19_a');
// 锏 only lends her stats here: her S3 fires on its own 技能范围 (playtest #6 item 15) and pulls the dummy all the way
// to her (official pull, item 14), where she blocks it outside the copy's range — keep her SP empty (阻回)
h.step();
assert.ok(h.b.addBuff(d, { key: 'test:noSp', flags: { noSp: true }, persist: true }));
const tok = () => h.b.allyUnits.find((x) => x.defId === 'token_10030_mlyss_wtrman' && x.alive);
h.run(6);
const t = tok();
+15 -9
View File
@@ -1,6 +1,8 @@
// Content tests for user playtest #4 items 11 and 12 (server/sim/content/tokens.js, kits/tier2.js):
// #11 赫默's 医疗无人机 appears only when her S2 fires — never on the field at battle start (tools/build-data.mjs:
// a skill's summon is not a prep hand card; test/match/loadout.test.js covers the prep side);
// #11 赫默's 医疗无人机 comes with her S2 — since user playtest #6 it is a hand piece the player places, it deploys
// once on that tile at the battle start (PRTS 卫戍协议/帮助 §作战阶段; settled by the user after playtest #6 —
// shared/constants.js SKILL_SUMMON_START_DEPLOY) and re-appears there each time S2 fires
// (test/content/playtest6_summons.test.js, the prep side test/match/playtest6_summons.test.js);
// #12 the 炎 bond's “炎佑” (PRTS “炎佑” 级别0(卫戍协议)): follows the highest-aggro enemy of the whole field and stays
// where it is without one (no fixed return point); 祛恶之焰 [模式乙] locks one target, channels up to 20 s (no normal
// attacks meanwhile), ends when the lock is lost or on silence; burn on every damage it deals; 元素脆弱 aura
@@ -20,19 +22,23 @@ const flameRec = (h) => h.b.data.rawToken(TOKEN_IDS.yanyou).skills.find((s) => s
// =====================================================================================================================
// #11 赫默
test('#11 赫默 (full kit, default S2): no drone at battle start; the 医疗探机 appears when 医疗无人机 fires', REAL, () => {
const h = makeBattle({ defs: { chess: { test_guard: guard() } }, units: [{ chessId: 'chess_char_2_02_a', row: 10, col: 3 }, { chessId: 'test_guard', row: 10, col: 4 }], autoFinish: false, timeLimit: 90 });
test('#11 赫默 (full kit, default S2): the placed 医疗探机 deploys once at battle start (10 s), then again when 医疗无人机 fires', REAL, () => {
const h = makeBattle({
defs: { chess: { test_guard: guard() } },
units: [{ chessId: 'chess_char_2_02_a', row: 10, col: 3, uid: 1 }, { chessId: 'test_guard', row: 10, col: 4, uid: 2 }, { kind: 'token', tokenId: TOKEN_IDS.healDrone, ownerUid: 1, row: 9, col: 4, uid: 3 }],
autoFinish: false, timeLimit: 90,
});
h.step();
const hm = h.unit('chess_char_2_02_a');
assert.equal(hm.skill.id, 'skchr_silent_2');
const drones = () => h.b.allyUnits.filter((u) => u.defId === TOKEN_IDS.healDrone && u.alive);
assert.equal(drones().length, 0, 'nothing but the operators at t = 0');
assert.equal(drones().length, 1, 'the free start deploy (PRTS: "作战开始时无视持有状态自动部署1个")');
assert.deepEqual([drones()[0].tileR, drones()[0].tileC], [9, 4], 'on its piece\'s tile');
h.unit('test_guard').hp = 1000;
const cost = hm.skill.spCost;
h.run(cost - 2);
h.run(10.5);
assert.equal(hm.skill.activations, 0);
assert.equal(drones().length, 0, 'no drone before the skill is ready');
assert.ok(h.runUntil(() => hm.skill.activations >= 1, 10), 'S2 fires once its SP is full (initSp 0)');
assert.equal(drones().length, 0, 'gone after its 10 s; no second one before the skill');
assert.ok(h.runUntil(() => hm.skill.activations >= 1, 30), 'S2 fires once its SP is full (initSp 0)');
h.step();
assert.equal(drones().length, 1, 'the drone comes with the skill');
assert.equal(drones()[0].ownerUnit, hm);
+241
View File
@@ -0,0 +1,241 @@
// User playtest #6 items 1 and 2 (from a friend): 凯瑟琳's device and 赫默's drone could not be placed by hand. The
// prep side is test/match/playtest6_summons.test.js; this file is the battle side of the placed pieces
// (server/sim/content/tokens.js dockSkillSummons / releaseSkillSummon, Battle.start `deferDeploy`, kits tier2 / tier3 /
// tier4, the 爬行号·防护单元 token kit):
// * PRTS 卫戍协议/帮助 §作战阶段: the board summons deploy after the operators; "若战场区初始部署有召唤物,若召唤物在
// 战斗期间退场,将在满足条件后立即原地再部署1个" — a placed piece marks the tile its summon deploys on;
// * a skill's summon (赫默 医疗探机, 巫恋 诅咒娃娃: "获得一个…") deploys once on its tile at the battle start, for free
// ("所有手动部署的召唤物,无视所属干员的持有状态…作战开始时立即部署一次", example 赫默's drone; settled by the user
// after playtest #6 — shared/constants.js SKILL_SUMMON_START_DEPLOY), then waits on its tile and takes the field
// there each time the owner's skill gives one — also as soon as its owner is back with one in stock;
// not placed ⇒ it never appears (the hidden 待部署区 deploys nothing by itself);
// * a talent's summon the owner holds from the start (凯瑟琳 "携带3个支援装置(最多部署2个)") deploys with the board;
// * not placed: a skill's summon, a device or 海嗣 never appears; the tacticians' 狼群 / 流形 still come as their 援军 on
// a tactical point (docs/PLAYING.md §4 and docs/SIM.md say exactly this).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, chessRec, checkInvariants } from '../helpers/battleHarness.js';
import { hasGeneratedData } from '../../server/sim/simdata.js';
import { TOKEN_IDS } from '../../server/sim/content/tokens.js';
const REAL = { skip: !hasGeneratedData() };
const guard = (o = {}) => chessRec({ id: 'test_guard', profession: 'WARRIOR', skill: null, stats: { atk: 0, maxHp: 1e5, def: 0, blockCnt: 2 }, ...o });
const SILENCE = 'chess_char_2_02_a';
const CATHY = 'chess_char_4_11_a';
const SHAMARE = 'chess_char_3_15_a';
test('#2 赫默: the placed 医疗探机 deploys once at the start (10 s); each S2 brings it back onto its own tile', REAL, () => {
const h = makeBattle({
defs: { chess: { test_guard: guard() } },
units: [{ chessId: SILENCE, row: 10, col: 3, uid: 1 }, { chessId: 'test_guard', row: 10, col: 4, uid: 2 }, { kind: 'token', tokenId: TOKEN_IDS.healDrone, ownerUid: 1, row: 11, col: 4, uid: 3, dir: 'RIGHT' }],
autoFinish: false, timeLimit: 120, hooks: ['death', 'deploy'],
});
h.step();
const hm = h.unit(SILENCE);
const piece = h.b.allyUnits.find((u) => u.uid === 3);
assert.ok(piece, 'the board piece is a unit of the battle');
assert.equal(piece.alive, true, 'the free start deploy, with the board');
assert.deepEqual([piece.tileR, piece.tileC], [11, 4]);
const order = h.hooksOf('deploy').filter((c) => c.initial).map((c) => c.unit);
assert.ok(order.indexOf(piece) > order.indexOf(hm) && order.indexOf(piece) > order.indexOf(h.unit('test_guard')), 'after the operators');
assert.equal(hm.skill.activations, 0, 'before any skill: the start deploy ignores the holding');
assert.ok(h.runUntil(() => !piece.alive, 12));
assert.ok(Math.abs(piece.deathAt - 10) < 0.1, 'gone after its 10 s');
assert.equal(h.b.isReservedTile(11, 4), true, 'its tile stays reserved');
const drones = () => h.b.allyUnits.filter((u) => u.defId === TOKEN_IDS.healDrone && u.alive);
h.unit('test_guard').hp = 1000;
assert.ok(h.runUntil(() => hm.skill.activations >= 1, 40), 'S2 fires');
h.step();
assert.equal(drones().length, 1, 'the drone comes with the skill');
assert.equal(drones()[0], piece, 'it is the placed piece');
assert.deepEqual([piece.tileR, piece.tileC], [11, 4], 'on the tile the player chose');
const t0 = piece.deployedAt;
assert.ok(h.runUntil(() => !piece.alive, 12));
assert.ok(Math.abs(piece.deathAt - t0 - 10) < 0.05, 'gone after 10 s');
assert.equal(h.b.isReservedTile(11, 4), true, 'the tile is still its own');
// the next S2: back on the same tile
assert.ok(h.runUntil(() => hm.skill.activations >= 2, 40), 'S2 again');
h.step();
assert.equal(piece.alive, true);
assert.deepEqual([piece.tileR, piece.tileC], [11, 4]);
assert.equal(drones().length, 1, 'one drone at a time');
checkInvariants(h.b);
});
test('#2 赫默 without a placed drone piece: S2 fires but no drone appears (it only deploys where a piece was placed)', REAL, () => {
const h = makeBattle({ defs: { chess: { test_guard: guard() } }, units: [{ chessId: SILENCE, row: 10, col: 3 }, { chessId: 'test_guard', row: 10, col: 4 }], autoFinish: false, timeLimit: 90 });
h.step();
const hm = h.unit(SILENCE);
h.unit('test_guard').hp = 1000;
assert.ok(h.runUntil(() => hm.skill.activations >= 1, 40));
h.run(1);
assert.equal(h.b.allyUnits.filter((u) => u.defId === TOKEN_IDS.healDrone).length, 0);
checkInvariants(h.b);
});
test('巫恋: the placed 诅咒娃娃 deploys once at the start, then appears on its tile when S2 fires (15 s)', REAL, () => {
const h = makeBattle({
units: [{ chessId: SHAMARE, row: 10, col: 3, uid: 1 }, { kind: 'token', tokenId: TOKEN_IDS.curseDoll, ownerUid: 1, row: 10, col: 6, uid: 2 }],
autoFinish: false, timeLimit: 120,
});
h.step();
const sh = h.unit(SHAMARE);
const doll = h.b.allyUnits.find((u) => u.uid === 2);
assert.equal(doll.alive, true, 'the free start deploy');
assert.ok(h.runUntil(() => !doll.alive, 20));
assert.ok(Math.abs(doll.deathAt - 15) < 0.1, 'its 15 s');
h.run(6); // past the token's redeploy time: nothing without a cast
assert.equal(doll.alive, false, 'no second doll before S2');
sh.skill.sp = sh.skill.spCost;
sh.skill.activate('test', { free: true });
h.step();
assert.equal(doll.alive, true);
assert.deepEqual([doll.tileR, doll.tileC], [10, 6]);
const t0 = doll.deployedAt;
assert.ok(h.runUntil(() => !doll.alive, 20));
assert.ok(Math.abs(doll.deathAt - t0 - 15) < 0.05, '15 s');
checkInvariants(h.b);
});
test('#1 凯瑟琳: the two placed devices deploy with the board on their tiles and shield the operator each faces', REAL, () => {
const h = makeBattle({
defs: { chess: { test_guard: guard() } },
units: [
{ chessId: CATHY, row: 12, col: 3, uid: 1 },
{ chessId: 'test_guard', row: 10, col: 5, uid: 2 },
{ chessId: 'test_guard', row: 11, col: 7, uid: 3 },
{ kind: 'token', tokenId: TOKEN_IDS.catShield, ownerUid: 1, row: 10, col: 4, uid: 4, dir: 'RIGHT' }, // faces (10,5)
{ kind: 'token', tokenId: TOKEN_IDS.catShield, ownerUid: 1, row: 12, col: 7, uid: 5, dir: 'DOWN' }, // faces (11,7)
],
autoFinish: false, timeLimit: 30,
});
h.step();
const cat = h.unit(CATHY);
const devs = h.b.allyUnits.filter((u) => u.defId === TOKEN_IDS.catShield);
assert.equal(devs.length, 2, 'no device beyond the placed ones');
assert.ok(devs.every((d) => d.alive), 'both deployed at the start');
assert.deepEqual(devs.map((d) => `${d.tileR},${d.tileC}`).sort(), ['10,4', '12,7']);
const cap = cat.s.maxHp * h.b.tokenDef(TOKEN_IDS.catShield, cat).talents[0].bb.max_shield_ratio;
for (const uid of [2, 3]) {
const g = h.b.allyUnits.find((u) => u.uid === uid);
assert.ok(Math.abs(g.s.shield - cap) < 1e-6, `operator ${uid} shielded (${g.s.shield} vs ${cap})`);
}
// S1 岁月锻打 reads the device shield: the operators holding one are recognised
assert.ok(h.b.allyUnits.find((u) => u.uid === 2).findBuff('cathy:shield'));
checkInvariants(h.b);
});
test('#1 凯瑟琳 with no device placed: no device in battle', REAL, () => {
const h = makeBattle({ defs: { chess: { test_guard: guard() } }, units: [{ chessId: CATHY, row: 12, col: 3 }, { chessId: 'test_guard', row: 10, col: 5 }], autoFinish: false, timeLimit: 20 });
h.run(2);
assert.equal(h.b.allyUnits.filter((u) => u.defId === TOKEN_IDS.catShield).length, 0);
checkInvariants(h.b);
});
test('not placed: 赫默 / 巫恋 / 凯瑟琳 / 浊心斯卡蒂 summon nothing; 伺夜 / 缪尔赛思 still bring their 援军 on a tactical point', REAL, () => {
const run = (chessId, cast = false) => {
const h = makeBattle({ units: [{ chessId, row: 10, col: 3, uid: 1 }], autoFinish: false, timeLimit: 20 });
h.run(2);
if (cast) { // the skill that gives the summon fires: still nothing without a piece
const u = h.unit(chessId);
u.skill.sp = u.skill.spCost;
u.skill.activate('test', { free: true });
h.run(1);
assert.ok(u.skill.activations >= 1, `${chessId} cast its skill`);
}
const toks = h.b.allyUnits.filter((u) => u.kind === 'token' && u.alive);
checkInvariants(h.b);
return toks;
};
for (const id of [SILENCE, SHAMARE]) assert.equal(run(id, true).length, 0, id);
for (const id of [CATHY, 'chess_char_6_04_a']) assert.equal(run(id).length, 0, id);
const wolf = run('chess_char_3_19_a');
assert.deepEqual(wolf.map((u) => u.defId), [TOKEN_IDS.wolfPack], '伺夜: her 狼群 as 援军');
const mf = run('chess_char_6_11_a');
assert.deepEqual(mf.map((u) => u.defId), [TOKEN_IDS.manifold], '缪尔赛思: her 流形 as 援军');
});
test('巫恋 leaves ⇒ her doll leaves (PRTS 诅咒娃娃 备注); 赫默 leaves ⇒ her drone stays (PRTS 医疗探机 备注)', REAL, () => {
const cast = (h, id) => { const u = h.unit(id); u.skill.sp = u.skill.spCost; u.skill.activate('test', { free: true }); h.step(); return u; };
// 巫恋 knocked out while her doll is up: the doll is withdrawn, and it does not come back while she is down
const h = makeBattle({
units: [{ chessId: SHAMARE, row: 10, col: 3, uid: 1 }, { kind: 'token', tokenId: TOKEN_IDS.curseDoll, ownerUid: 1, row: 10, col: 6, uid: 2 }],
autoFinish: false, timeLimit: 120,
});
h.step();
const sh = cast(h, SHAMARE);
const doll = h.b.allyUnits.find((u) => u.uid === 2);
assert.equal(doll.alive, true);
cast(h, SHAMARE); // a second doll in stock while the first one stands
h.b.dealDamage(null, sh, { amount: 1e9, type: 'true' });
h.step();
assert.equal(sh.alive, false);
assert.equal(doll.alive, false, 'the doll leaves with 巫恋');
h.run(10);
assert.equal(doll.alive, false, 'no doll while 巫恋 is off the field (the stocked one waits)');
assert.equal(h.b.isReservedTile(10, 6), true, 'the piece keeps its tile');
checkInvariants(h.b);
// 赫默 knocked out while her drone is up: the drone keeps healing until its 10 s are over
const g = makeBattle({
defs: { chess: { test_guard: guard() } },
units: [{ chessId: SILENCE, row: 10, col: 3, uid: 1 }, { chessId: 'test_guard', row: 10, col: 4, uid: 2 }, { kind: 'token', tokenId: TOKEN_IDS.healDrone, ownerUid: 1, row: 11, col: 4, uid: 3 }],
autoFinish: false, timeLimit: 120,
});
g.step();
const hm = cast(g, SILENCE);
const drone = g.b.allyUnits.find((u) => u.uid === 3);
assert.equal(drone.alive, true);
const t0 = drone.deployedAt;
g.b.dealDamage(null, hm, { amount: 1e9, type: 'true' });
g.step();
assert.equal(hm.alive, false);
assert.equal(drone.alive, true, 'the drone stays when 赫默 leaves');
assert.ok(g.runUntil(() => !drone.alive, 12));
assert.ok(Math.abs(drone.deathAt - t0 - 10) < 0.05, 'until its own 10 s end');
checkInvariants(g.b);
});
test('a stocked summon takes the field as soon as its owner is back (PRTS "满足条件后立即原地再部署1个"; playtest #6 review)', REAL, () => {
const cast = (h, id) => { const u = h.unit(id); u.skill.sp = u.skill.spCost; u.skill.activate('test', { free: true }); h.step(); return u; };
// 赫默: S2 while the drone stands ⇒ one in stock; knocked out, the drone runs out; she redeploys ⇒ the drone at once
const g = makeBattle({
defs: { chess: { test_guard: guard() } },
units: [{ chessId: SILENCE, row: 10, col: 3, uid: 1 }, { chessId: 'test_guard', row: 10, col: 4, uid: 2 }, { kind: 'token', tokenId: TOKEN_IDS.healDrone, ownerUid: 1, row: 11, col: 4, uid: 3 }],
autoFinish: false, timeLimit: 120,
});
g.step();
const drone = g.b.allyUnits.find((u) => u.uid === 3);
assert.equal(drone.alive, true, 'the start deploy');
const hm = cast(g, SILENCE);
assert.equal(hm.mem.summonStock[TOKEN_IDS.healDrone], 1, 'the S2 drone waits in stock while the first one stands');
g.b.dealDamage(null, hm, { amount: 1e9, type: 'true' });
g.step();
assert.ok(g.runUntil(() => !drone.alive, 12), 'the drone runs out its 10 s');
g.run(8);
assert.equal(drone.alive, false, 'nothing while 赫默 is down');
assert.ok(g.b.redeploy(hm, { free: true }), '赫默 back on her tile');
g.step();
assert.equal(drone.alive, true, 'the stocked drone deploys at once');
assert.deepEqual([drone.tileR, drone.tileC], [11, 4]);
assert.equal(hm.mem.summonStock[TOKEN_IDS.healDrone], 0, 'stock used');
checkInvariants(g.b);
// 巫恋: her doll leaves with her; the stocked one comes back with her
const h = makeBattle({
units: [{ chessId: SHAMARE, row: 10, col: 3, uid: 1 }, { kind: 'token', tokenId: TOKEN_IDS.curseDoll, ownerUid: 1, row: 10, col: 6, uid: 2 }],
autoFinish: false, timeLimit: 120,
});
h.step();
const doll = h.b.allyUnits.find((u) => u.uid === 2);
const sh = cast(h, SHAMARE);
assert.equal(doll.alive, true);
h.b.dealDamage(null, sh, { amount: 1e9, type: 'true' });
h.step();
assert.equal(doll.alive, false, 'the doll leaves with 巫恋');
h.run(8);
assert.ok(h.b.redeploy(sh, { free: true }));
h.step();
assert.equal(doll.alive, true, 'the stocked doll deploys as soon as 巫恋 is back');
checkInvariants(h.b);
});
+14 -5
View File
@@ -80,7 +80,14 @@ test('赫默: the medical drone lives its owner\'s withdraw duration (module var
const life = (lo) => (lo === NONE ? 16 : base.rawToken(DRONE).variants[HEMO].skill.duration);
assert.equal(life(DEF), 10);
for (const lo of ORDERS(DEF, NONE)) {
const { h, ops } = duel({ chessId: HEMO, lo, tokens });
// the drone is a hand piece the player places; it deploys once with the board (SKILL_SUMMON_START_DEPLOY), then
// S2 brings it back onto its tile (user playtest #6) — both lifetimes are checked on the start deploy and on a cast
const { h, ops } = duel({ chessId: HEMO, lo, tokens, extra: [{ kind: 'token', tokenId: DRONE, ownerUid: 1, row: 11, col: 4 }] });
const start = ops.map((u) => summons(h, u, DRONE)[0]);
assert.ok(start.every((d) => d && d.alive), 'both start deploys');
assertLifetimes(h, start, lo.map(life));
h.run(6); // past the token's redeploy time
assert.ok(ops.every((u) => u.skill.activations === 0), 'no cast yet');
const drones = castAndCollect(h, ops, DRONE);
assertLifetimes(h, drones, lo.map(life));
done(h);
@@ -146,7 +153,7 @@ test('巫恋: the 诅咒娃娃 lasts its owner\'s module variant of the token sk
const life = (lo) => want(tokens, DOLL, VOD, lo).skill.duration;
assert.deepEqual([life(DEF), life(NONE)], [15, 5]);
for (const lo of ORDERS(DEF, NONE)) {
const { h, ops } = duel({ chessId: VOD, lo, tokens });
const { h, ops } = duel({ chessId: VOD, lo, tokens, extra: [{ kind: 'token', tokenId: DOLL, ownerUid: 1, row: 11, col: 4 }] });
const dolls = castAndCollect(h, ops, DOLL);
assertLifetimes(h, dolls, lo.map(life));
done(h);
@@ -176,14 +183,16 @@ test('凯瑟琳: device shield cap and deploy limit follow the owner\'s module v
const limit = (lo) => (lo === NONE ? 1 : base.rawToken(CATSLD).variants[CATHY].stats.deployLimit);
assert.deepEqual([cap(DEF), cap(NONE), limit(DEF)], [0.2, 0.5, 2]);
for (const lo of ORDERS(DEF, NONE)) {
// one more operator each, so the default limit (2) and the patched one (1) differ in devices placed
const { h, ops } = duel({ chessId: CATHY, lo, tokens, extra: [{ chessId: 'chess_char_1_02_a', row: 11, col: 4 }] });
// two placed devices each (hand pieces, user playtest #6), facing her and one more operator: the default limit (2)
// keeps both, the patched one (1) withdraws the first deployed
const dev = (row, col) => ({ kind: 'token', tokenId: CATSLD, ownerUid: 1, row, col });
const { h, ops } = duel({ chessId: CATHY, lo, tokens, extra: [{ chessId: 'chess_char_1_02_a', row: 11, col: 4 }, dev(10, 3), dev(11, 3)] });
h.run(0.5);
ops.forEach((u, i) => {
const devs = summons(h, u, CATSLD).filter((d) => d.alive);
assert.equal(devs.length, limit(lo[i]), `${u.player.playerId} devices (deploy limit)`);
for (const d of devs) {
const t = h.b.unitAt(d.mem.tr, d.mem.tc);
const t = d.mem.target;
assert.ok(t && t.kind === 'op' && t.ownerId === u.ownerId, `${u.player.playerId} device points at its operator`);
approx(t.findBuff('cathy:shield')?.shield ?? 0, u.s.maxHp * cap(lo[i]), `${u.player.playerId} shield`);
}
+23 -10
View File
@@ -512,15 +512,21 @@ test('band map characters: spawnMapChar puts 预备干员-医疗 at its stage sl
checkInvariants(h.b);
});
test('fallbacks for generic summoners: 赫默 drone on skill, 夕 小自在 on the first attack, 凯瑟琳 devices at battle start', REAL, () => {
// 赫默 (generic kit): S2 fires on an injured ally → drone
const h = makeBattle({ defs: { chess: { test_guard: guard({ stats: { maxHp: 1e5, atk: 0 } }) } }, kits: { chess_char_2_02_a: genericKit }, units: [{ chessId: 'chess_char_2_02_a', row: 10, col: 3 }, { chessId: 'test_guard', row: 10, col: 4 }], autoFinish: false, timeLimit: 60 });
test('fallbacks for generic summoners: 赫默 placed drone on skill, 夕 小自在 on the first attack; 凯瑟琳 places nothing herself', REAL, () => {
// 赫默 (generic kit): S2 fires on an injured ally → the placed drone piece takes the field on its tile
const h = makeBattle({
defs: { chess: { test_guard: guard({ stats: { maxHp: 1e5, atk: 0 } }) } }, kits: { chess_char_2_02_a: genericKit },
units: [{ chessId: 'chess_char_2_02_a', row: 10, col: 3, uid: 1 }, { chessId: 'test_guard', row: 10, col: 4, uid: 2 }, { kind: 'token', tokenId: TOKEN_IDS.healDrone, ownerUid: 1, row: 11, col: 3, uid: 3 }],
autoFinish: false, timeLimit: 60,
});
h.step();
h.unit('test_guard').hp = 500;
const hm = h.unit('chess_char_2_02_a');
assert.equal(h.unit(3).alive, true, 'the free start deploy (PRTS; shared/constants.js SKILL_SUMMON_START_DEPLOY)');
assert.ok(h.runUntil(() => !h.unit(3).alive, 12), 'its 10 s');
h.unit('test_guard').hp = 500;
assert.ok(h.runUntil(() => hm.skill.activations >= 1, 40));
h.step();
assert.ok(h.b.allyUnits.some((u) => u.defId === TOKEN_IDS.healDrone && u.alive && u.ownerUnit === hm), 'drone deployed');
assert.ok(h.b.allyUnits.some((u) => u.defId === TOKEN_IDS.healDrone && u.alive && u.ownerUnit === hm && u.tileR === 11 && u.tileC === 3), 'drone deployed on its tile');
// 夕 (generic): 小自在 near the first target
const h2 = makeBattle({ defs: { enemies: { enemy_dummy: dummy() } }, kits: { chess_char_5_12_a: genericKit }, units: [{ chessId: 'chess_char_5_12_a', row: 10, col: 3 }], enemies: [{ key: 'enemy_dummy', pos: [10, 5] }], autoFinish: false, timeLimit: 30 });
assert.ok(h2.runUntil(() => h2.b.allyUnits.some((u) => u.defId === TOKEN_IDS.duskDragon), 10));
@@ -528,13 +534,20 @@ test('fallbacks for generic summoners: 赫默 drone on skill, 夕 小自在 on t
assert.ok(Math.abs(dd.tileC - 5) + Math.abs(dd.tileR - 10) <= 1, 'at the target');
h2.runUntil(() => !dd.alive, 30);
approx(dd.deathAt - dd.deployedAt, 25, 0.05);
// 凯瑟琳 (generic): devices in front of her allies at battle start (≤ deployLimit)
// 凯瑟琳 (generic): her devices are hand pieces (user playtest #6) — none placed, none in battle; a placed one serves
const h3 = makeBattle({ defs: { chess: { test_guard: guard() } }, kits: { chess_char_4_11_a: genericKit }, units: [{ chessId: 'chess_char_4_11_a', row: 10, col: 3 }, { chessId: 'test_guard', row: 10, col: 5 }, { chessId: 'test_guard', row: 11, col: 5, uid: 9 }], autoFinish: false, timeLimit: 10 });
h3.step();
const devs = h3.b.allyUnits.filter((u) => u.defId === TOKEN_IDS.catShield && u.alive);
assert.ok(devs.length >= 1 && devs.length <= 2, `devices ${devs.length}`);
assert.ok(h3.b.allyUnits.filter((u) => u.defId === 'test_guard').some((g) => g.s.shield > 0));
h3.run(1);
assert.equal(h3.b.allyUnits.filter((u) => u.defId === TOKEN_IDS.catShield).length, 0, 'no device of her own');
const h4 = makeBattle({
defs: { chess: { test_guard: guard() } }, kits: { chess_char_4_11_a: genericKit },
units: [{ chessId: 'chess_char_4_11_a', row: 10, col: 3, uid: 1 }, { chessId: 'test_guard', row: 10, col: 5, uid: 2 }, { kind: 'token', tokenId: TOKEN_IDS.catShield, ownerUid: 1, row: 10, col: 4, uid: 3, dir: 'RIGHT' }],
autoFinish: false, timeLimit: 10,
});
h4.step();
assert.ok(h4.unit(3).alive, 'the placed device deploys with the board');
assert.ok(h4.unit(2).s.shield > 0, 'and shields the operator it faces');
checkInvariants(h3.b);
checkInvariants(h4.b);
});
// =================================================================================================================
+24 -10
View File
@@ -416,16 +416,17 @@ test('chess/tokens: talent tokens resolve and every token variant says where it
assert.deepEqual(tokens.token_10057_svash2_eagle1.variants.chess_char_5_14_a.sources, ['display']);
// 夕's skill "cnt" is a charge count, not a token count.
assert.equal(tokens.token_10015_dusk_drgn.variants.chess_char_5_12_a.count, null);
// Placeable (hand) tokens = the TALENT summons the shop state shows (tokenDisplayType DEFAULT); a skill's summon
// ("获得一个医疗无人机" 赫默, "获得一个诅咒娃娃" 巫恋) appears when the skill fires, never at battle start (user
// playtest #4 item 11).
// Placeable (hand) tokens = the manually deployable summons (PRTS 卫戍协议/帮助 §战斗部署; user playtest #6): shop
// state DEFAULT or no shop-state entry (凯瑟琳's device), never HIDDEN, made by some owner loadout (talent or skill) —
// 赫默's 医疗探机 and 巫恋's 诅咒娃娃 included (in battle they wait on their tile for the skill).
const makes = (list) => list.includes('talent') || list.includes('skill');
for (const t of Object.values(tokens)) {
if (t.kind !== 'summon') continue;
const talent = Object.values(t.variants).some((v) => v.sources.includes('talent') || Object.values(v.bySkill || {}).some((b) => b.sources.includes('talent')));
assert.equal(t.placeable, t.displayType === 'DEFAULT' && talent, `${t.tokenId} (${t.name}): placeable`);
const made = Object.values(t.variants).some((v) => makes(v.sources) || Object.values(v.bySkill || {}).some((b) => makes(b.sources)));
assert.equal(t.placeable, t.displayType !== 'HIDDEN' && made, `${t.tokenId} (${t.name}): placeable`);
}
assert.deepEqual(Object.values(tokens).filter((t) => t.placeable).map((t) => t.name).sort(), ['斯卡蒂的海嗣', '流形', '狼群'].sort());
for (const id of ['token_10000_silent_healrb', 'token_10006_vodfox_doll']) assert.equal(tokens[id].placeable, false, `${id}: skill summon`);
assert.deepEqual(Object.values(tokens).filter((t) => t.placeable).map((t) => t.name).sort(),
['医疗探机', '诅咒娃娃', '斯卡蒂的海嗣', '流形', '狼群', '爬行号·防护单元'].sort());
assert.equal(tokens.enemy_9012_acloon.stats.deployLimit, tokens.enemy_9012_acloon.deployLimit);
});
@@ -456,11 +457,24 @@ test('chess: skills[] = every skill unlocked at the status, at the chess skill l
}
if (c.tier <= 2 && !c.isGolden) assert.ok(idx.length <= 2, `${c.chessId}: E1 chess has no S3`);
}
// Triggers are resolved per skill index: the TANK row (skillIndex 0) applies to S1 only.
// Triggers per skill (PRTS 卫戍协议/帮助 技能策略; tools/build-data.mjs resolveTrigger): the class rows cover every MANUAL
// skill of the class and no AUTO one; a MANUAL skill with a 技能范围 of its own (not an attack-range change) is SKILL_RANGE.
const rules = (id) => chess[id].skills.map((s) => s.trigger.rule);
assert.deepEqual(rules('chess_char_1_02_a'), ['TAKE_DAMAGE', 'DEFAULT']); // 角峰
assert.deepEqual(rules('chess_char_1_10_b'), ['TAKE_DAMAGE', 'DEFAULT']); // 古米
assert.deepEqual(rules('chess_char_1_02_a'), ['TAKE_DAMAGE', 'TAKE_DAMAGE']); // 角峰 (重装, both MANUAL)
assert.deepEqual(rules('chess_char_1_10_b'), ['DEFAULT', 'TAKE_DAMAGE']); // 古米 (S1 自动触发)
assert.deepEqual(rules('chess_char_3_08_a'), ['SEARCH', 'SEARCH']); // 薄绿 (阵法术师: the default S2 too)
assert.deepEqual(rules('chess_char_3_19_a'), ['DEFAULT', 'DEFAULT', 'SP_FULL']); // 伺夜 (战术家: S1/S2 are AUTO)
assert.deepEqual(rules('chess_char_6_11_b'), ['MLYSS_WTRMAN', 'MLYSS_WTRMAN', 'MLYSS_WTRMAN']); // 缪尔赛思 (charId row −1)
assert.deepEqual(rules('chess_char_1_08_a'), ['DEFAULT', 'SKILL_RANGE']); // 德克萨斯 S2 剑雨: 对周围所有敌人
assert.deepEqual(chess.chess_char_1_08_a.skills[1].trigger.customRangeGrid, chess.chess_char_1_08_a.skills[1].rangeGrid);
assert.deepEqual(rules('chess_char_4_22_a'), ['DEFAULT', 'DEFAULT', 'DEFAULT']); // 银灰: "攻击范围缩小 / 扩大" = attack range
assert.deepEqual(rules('chess_char_3_18_a'), ['DEFAULT', 'SKILL_RANGE', 'DEFAULT']); // 忍冬: S2 对周围…, S3 攻击距离+1
for (const c of Object.values(chess)) {
for (const s of c.skills || []) {
if (s.skillType !== 'MANUAL') assert.ok(!['TAKE_DAMAGE', 'SEARCH', 'SKILL_RANGE'].includes(s.trigger.rule), `${c.chessId} ${s.skillId}: an AUTO / PASSIVE skill takes no strategy row`);
if (s.trigger.rule === 'SKILL_RANGE') assert.ok(s.rangeGrid && s.skillType === 'MANUAL' && !/攻击(范围|距离)(与溅射范围)?(扩大|改变|缩小|缩短|加长|增加|\+)/.test(s.desc), `${c.chessId} ${s.skillId}: SKILL_RANGE only for a 技能范围`);
}
}
assert.deepEqual(chess.chess_char_1_01_a.skills.map((s) => [s.skillId, s.level, s.spCost, s.isDefault]),
[['skchr_inside_1', 4, 14, false], ['skchr_inside_2', 4, 24, true]]); // 隐现 Lv4 (default S2)
const sw = chess.chess_char_3_04_b; // 琳琅诗怀雅: S2 makes the 香槟
+98 -11
View File
@@ -10,7 +10,10 @@
// dragged model held under it, a single human untimed, the strategy draft's one countdown, 机变 two taps, knocked-out
// operators and the official element gauges, live stats, the shop-only items, skill summons, 炎佑; user playtest #5
// (DESIGN §19): blocking by contact radius, 联防 forced exit, huge-boss hit areas and 自缚, the element pipeline rules,
// boss-field deployment, the phone prep camera — and the normative §3 / §5.1 / §5.5 / §6.1 / §7 lines that changed.
// 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
// 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.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
@@ -23,7 +26,9 @@ import { SOLO_RECONNECT_FALLBACK_SEC } from '../server/lobby.js';
import { moduleTypeIconUrl } from '../public/js/ui/assetUrls.js';
import { validateC2S } from '../shared/protocol.js';
import { ERR, PHASE } from '../shared/constants.js';
import { PROJECTILE_SPEEDS, BOOMERANG_RETURN_SPEED, ELEMENT, ELEMENT_ORDER, DOWN_STATE, BLOCK_RADIUS, FORCED_EXIT } 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 { MULTI_ROUND_BOUNTY_BATTLES } from '../server/match/choices.js';
import { SELF_BOUND } from '../server/sim/content/bosses.js';
import * as BOARD from '../server/match/board.js';
import * as DAMAGE from '../server/sim/damage.js';
@@ -303,8 +308,9 @@ test('user playtest #4 (DESIGN §18): picking by tile, timers, 机变 two taps,
assert.ok(special.shopExcluded && special.shopExcludedBy, '灼燃维式重锤 is never sold');
assert.match(dataRow('shopExcluded`, `shopExcludedBy'), /isShopItem/);
assert.match(PLAYING, /突变细胞只来自昆图斯的策略/);
assert.equal(DATA.tokens.token_10000_silent_healrb.placeable, false, '赫默\'s drone is no hand piece');
assert.match(dataRow('displayType`, `placeable'), /DEFAULT \*\*and\*\* a talent summon/);
// (#11 revisited by user playtest #6: the drone is a hand piece again, still only on the field when her skill fires)
assert.equal(DATA.tokens.token_10000_silent_healrb.placeable, true, '赫默\'s drone is a hand piece (user playtest #6)');
assert.match(dataRow('displayType`, `placeable'), /manually deployable summon/);
assert.ok(!/ATK\/HP are \*\*replaced\*\*/.test(DATA_MD), 'DATA: 炎佑 stats are added (最终加算)');
assert.match(DESIGN, /with no enemy on the field it stays where it is/);
});
@@ -361,13 +367,10 @@ test('user playtest #5 (DESIGN §19): blocking, 联防 forced exit, huge bosses,
assert.match(PLAYING, /\*\*立刻接替阻挡\*\*/);
assert.match(PLAYING, /召唤物在所有干员之后/);
assert.match(PLAYING, /近地悬浮/);
// integration QA residuals: blocked-first / out-of-range selection is melee-only (code = §5.5 = §19.2 = SIM = PLAYING);
// the forced-out timer is re-read after battleStart; the gauge intake's 5 % floor; the boss field's devices
assert.match(s55, /\(1\) the enemies it blocks \(melee units only, in range or not\)/);
assert.match(s55, /a ranged operator on a melee tile blocks but attacks only what its range holds/);
assert.ok(!/every blocker, ranged ones on melee tiles included/.test(DESIGN), '§19.2: the old [ASSUMED] is gone');
assert.match(SIM, /A ranged operator standing on a melee tile blocks, but targets by its range alone/);
assert.match(PLAYING, /站在近战位的远程干员也能阻挡/);
// integration QA residuals (the melee-only blocked-first rule is superseded by §20.3 — the user's "阻挡了就一定要能打到",
// checked in the §20 test); the forced-out timer is re-read after battleStart; the gauge intake's 5 % floor; the boss
// field's devices
assert.match(S19, /Ranged operators on melee tiles[^\n]*superseded by §20\.3/);
assert.match(s55, /after which the forced-out operators' timers are re-read/);
assert.match(SIM, /Right after `battleStart`\s+its timer is re-read/);
assert.match(s55, /max\(0\.05, 1 − 损伤抵抗\/100\)/);
@@ -382,3 +385,87 @@ test('user playtest #5 (DESIGN §19): blocking, 联防 forced exit, huge bosses,
assert.match(S19, /进联防复活时间确实是重新算/);
assert.ok(!/its full redeploy timer \[ASSUMED\]/.test(DESIGN), 'the full 联防 timer is no longer [ASSUMED]');
});
test('user playtest #6 (DESIGN §20): summons, skill triggers, blocking, push force, enemies, boss pool, bounties, element gauge — code and every doc agree', () => {
const sec = (n) => DESIGN.slice(DESIGN.indexOf(`## ${n}.`), DESIGN.indexOf(`## ${n + 1}.`) > 0 ? DESIGN.indexOf(`## ${n + 1}.`) : undefined);
const sub = (a, b) => DESIGN.slice(DESIGN.indexOf(`### ${a}`), DESIGN.indexOf(`### ${b}`));
const S20 = sec(20);
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}`);
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}`);
const s55 = DESIGN.slice(DESIGN.indexOf('### 5.5'), DESIGN.indexOf('### 5.6'));
const s56 = DESIGN.slice(DESIGN.indexOf('### 5.6'), DESIGN.indexOf('## 6.'));
const s209 = S20.slice(S20.indexOf('### 20.9'));
// the user's settled decisions (2026-10-01): code = §20 = §20.9 table (with the one-line flips) = SIM / PLAYING / META
assert.equal(SKILL_SUMMON_START_DEPLOY, true, '#1/#2: a placed skill summon deploys once at the battle start (PRTS)');
assert.match(s209, /SKILL_SUMMON_START_DEPLOY = false/);
assert.match(s55, /a skill's placed summon \(赫默 医疗探机, 巫恋 诅咒娃娃\) included, once and free/);
assert.match(SIM, /deploys once at the battle start/);
assert.match(PLAYING, /医疗探机、诅咒娃娃开战时也在摆放的位置免费部署一次/);
assert.match(sec(18), /#11 skill summons are not prep pieces\*\* — \*\*superseded by §20\.1/);
assert.match(s209, /token_10041_cathy_catsld/, '凯瑟琳\'s device stays a hand card');
for (const [name, text] of [['build-data', readFileSync(join(ROOT, 'tools/build-data.mjs'), 'utf8')], ['DATA.md', DATA_MD]]) {
assert.ok(!/question to the user|awaits the user's confirmation/.test(text), `${name}: the 凯瑟琳 question is settled`);
}
assert.equal(MULTI_ROUND_BOUNTY_BATTLES, 2, '#4: multi-round bounties last two battles (the user\'s call)');
assert.match(s209, /MULTI_ROUND_BOUNTY_BATTLES = null/);
assert.match(META, /MULTI_ROUND_BOUNTY_BATTLES = null` restores the official red "每场"/);
assert.match(PLAYING, /官方的「多轮悬赏」(原文写「之后的每场作战」)在这里也按「接下来两场作战」处理/);
assert.ok(!/红字「每场」的是多轮悬赏/.test(PLAYING), 'PLAYING: no 每场 card any more');
assert.equal(ALLY_COLLIDER_RADIUS, 0.25, '#12: ranged enemies reach an operator by its 0.25 collider');
assert.match(s209, /ALLY_COLLIDER_RADIUS = 0/);
assert.match(sec(3), /centre distance ≤ `rangeRadius` \+ 0\.25/);
assert.match(s55, /whose 0\.25 collider touches their radius/);
assert.match(SIM, /centre distance ≤ `rangeRadius` \+ `ALLY_COLLIDER_RADIUS` 0\.25/);
assert.match(S20, /reach 2\.2 → 2\.45 \(13 → 21 tiles around it\)/);
// #17: every blocker hits what it blocks (code: no melee gate; §5.5 = §19.2 superseded = §20.3 = SIM = PLAYING)
assert.match(s209, /gate `Battle\.blockedTargets` \/ `sortEnemyTargets` blocked-first on a melee position again/);
assert.match(s55, /\*\*Every blocker\*\* — melee units/);
assert.match(s55, /\(1\) the enemies it blocks \(every blocker, in range or not, §20\.3\)/);
assert.ok(!/melee units only, in range or not|a ranged operator on a melee tile blocks but attacks only what its range holds/.test(DESIGN), '§5.5: the melee-only rule is gone');
assert.match(SIM, /\*\*every blocker\*\* — melee units/);
assert.ok(!/targets by its range alone: no/.test(SIM), 'SIM: the melee-only rule is gone');
assert.match(PLAYING, /\*\*阻挡了就一定能打到\*\*/);
const battleSrc = readFileSync(join(ROOT, 'server/sim/Battle.js'), 'utf8');
assert.ok(!/meleeUnit/.test(battleSrc) && !/export function meleeUnit/.test(readFileSync(join(ROOT, 'server/sim/targeting.js'), 'utf8')), 'no melee gate in the engine');
// #15 / #16: skill triggers and the 3 s operation cooldown (code = §5.6 = §19.2 superseded)
assert.equal(AUTO_OP_COOLDOWN, 3);
assert.match(s56, /`SKILL_RANGE` \(a MANUAL skill with a 技能范围 of its own/);
assert.match(s56, /\*\*every MANUAL skill\*\* of the class and never an AUTO skill/);
assert.match(s56, /`AUTO_OP_COOLDOWN` \(3 s\) after its previous cast and after the unit's battle-start deployment/);
assert.match(sec(19), /灰毫 S2 专注轰击 — \*\*superseded by §20\.2\*\*/);
assert.ok(!/so her S1 is TAKE_DAMAGE and S2 stays DEFAULT/.test(DESIGN), '§19.2: 灰毫 S2 is TAKE_DAMAGE now');
assert.ok(!/the battle-start part is left out/.test(readFileSync(join(ROOT, 'server/sim/constants.js'), 'utf8')), 'AUTO_OP_COOLDOWN comment');
assert.match(DATA_MD, /13 MANUAL skills \(26 normal \+ elite records\)/);
assert.match(sub('20.2', '20.3'), /180 % \(精锐 200 %\)/, '#8: the elite shield ratio');
// #14 / #17: push force vs weight, ASPD floor, 孤立 (code = §5.2 / §5.3 / §5.4 = §20.3)
assert.equal(ASPD_MIN, 20);
assert.match(sec(5), /`aspd = clamp\(100 \+ Σaspd \+ base-100, 20, 600\)`/);
assert.match(sec(5), /isolated \(孤立: no friendly selector picks it/);
assert.match(sec(5), /`battle\.push\(e, force, \{ from, dir, fixed, fixedAngle, inward, effect \}\)`/);
assert.match(sec(19), /锏 S3 归于宁静 \(added by §20\.3\)/);
// #5: the boss pool floor and the verdict (code = §5.5 = §14 = §6.1)
assert.equal(BOSS_POOL_MIN_HP, 1);
assert.match(s55, /A pool holding less than 1 HP is empty \(`constants\.js BOSS_POOL_MIN_HP`/);
assert.match(sec(14), /the first one the server registers decides the verdict \(`Match\._finalEnding`\)/);
assert.match(sec(6), /team LP 0 ⇒ defeat at once \(PRTS 直接失败\)/);
// #7 / #19: the 联防 counter (§17.5 revised, §8.2, §14) and the promotion reward (§6.2)
assert.match(sec(17), /\*\*revised by §20\.6\*\*/);
assert.ok(!/the own battle's count stays on show as an upper bound tagged 联防中/.test(DESIGN), '§17.5: the frozen 联防 count is gone');
assert.match(sec(8), /uniteLeft\? \/\* 联防: the leaker's enemies still standing/);
assert.match(sec(14), /`b\.progress \{ battleId, gt, killed, total, leaks\?, left\?, bossDmg\?, by\?, done\? \}`/);
assert.match(sec(6), /3 \*\*different\*\* free chess of tier `min\(level\+1, 6\)`/);
assert.match(S20, /105 cards: 56 next-battle incl\. 源石虫·特训, 42 two-battle, 7 multi-round/);
// #11 / #9 / #13: the gauge look (§18.3 / §19.5 superseded, §8.2 fill), model scale and fear (§9, §5.3, §2)
assert.match(sec(18), /superseded by §20\.8/);
assert.match(sec(19), /The gauge's look is the official icon \+ white bar since §20\.8/);
assert.match(sec(8), /rounded DOWN and kept at 0\.01–0\.99 outside a burst/);
assert.match(sec(9), /enemies\.json `modelScale`/);
assert.match(sec(2), /fear\.js +恐惧 movement of enemies/);
// the 机变 card and the card tap (§10 = §18.2 = §20.7)
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\)/);
// README: the test count stays in the right order of magnitude
assert.match(README, /约 27\d0 项/);
});
+15 -1
View File
@@ -1063,13 +1063,27 @@ describe('browser E2E against the real server', { skip: !ENABLED && 'needs Chrom
await c.page.waitForSelector('.dband.is-taken .dband__taken', { timeout: 4000 });
if (!shotDraft.has('taken')) {
shotDraft.add('taken');
// the strategy grid scrolls and the AI's weighted pick can sit below the fold: scroll it into view
// like a player would (a 15 s "nothing clickable" flake whenever the taken band was out of view)
await c.page.evaluate(() => document.querySelector('.dband.is-taken')?.scrollIntoView({ block: 'center' }));
await sleep(150);
await c.click('.dband.is-taken', null, { any: true });
await sleep(250);
assert.equal(await c.exists('.draft-detail__btns .btn--primary:not([disabled])'), false, '队友已选: confirm disabled');
await c.shot('draft-taken');
}
}
await c.click('.dband:not(.is-taken)', null, { nth: c === host ? 3 : 7 });
// the nth free strategy, skipping 老鲤 (【得闲饮茶】 holds the funds of rounds 1–2 back until round 3: a human on
// it has nothing to deploy in the first battles, and the round-1 facing chain needs board operators)
const bandName = await c.page.evaluate((nth) => {
const free = [...document.querySelectorAll('.dband:not(.is-taken)')].filter((el) => el.querySelector('.dband__name')?.textContent.trim() !== '老鲤');
const el = free[nth] || free[0];
el?.scrollIntoView({ block: 'nearest' });
return el?.querySelector('.dband__name')?.textContent.trim() || null;
}, c === host ? 3 : 7);
assert.ok(bandName, `${c.label}: a free strategy to pick`);
await sleep(150);
await c.click('.dband:not(.is-taken)', bandName);
await sleep(250);
if (!shotDraft.has(`${c.label}-sel`)) { shotDraft.add(`${c.label}-sel`); await c.shot('draft-selected'); }
await c.click('.draft-detail__btns .btn--primary', '确认选择');
+5 -2
View File
@@ -22,7 +22,10 @@
// kits { [baseChessId]: (bb, chess, def) => Kit } injected kits (take precedence over kits/tier*.js)
// extraContent [{ install(battle) }] extra content modules installed after the domain modules (content tests)
// defs { chess: {id: record}, enemies: {key: record}, tokens: {id: record} } extra/override data records
// flags, sharedBoss, modeId, round, setup(battle), recordEvents (default true)
// flags, sharedBoss, modeId, round, setup(battle), recordEvents (default true). The harness sets
// flags.startOpCooldown = 0 unless given: the official 3 s operation cooldown of the battle-start
// deployment (Battle default AUTO_OP_COOLDOWN) would hold back every test that fills a skill's SP at
// t ≈ 0 to stand for a skill that became ready mid-fight; tests of that rule pass it explicitly
// autoFinish end when all enemies are dead (default: true when any spawn is scheduled, else false so a
// battle without enemies keeps running until its time limit)
// hooks list of hook names to capture (default: all DESIGN events)
@@ -187,7 +190,7 @@ export function makeBattle(opts = {}) {
const battle = new Battle({
seed: opts.seed ?? 1, kind, modeId: opts.modeId ?? 'mode_multi_normal', round: opts.round ?? 1, stage, rect: opts.rect,
timeLimit: timeLimit ?? (kind === 'boss' || kind === 'hidden' ? Infinity : 60), players, spawns, routes,
sharedBoss: opts.sharedBoss ?? null, flags: opts.flags, fieldId: opts.fieldId ?? 'test', data,
sharedBoss: opts.sharedBoss ?? null, flags: { startOpCooldown: 0, ...opts.flags }, fieldId: opts.fieldId ?? 'test', data,
content: opts.content ?? 'full', recordEvents: opts.recordEvents !== false, logger: opts.logger ?? quietLogger(opts.verbose),
quiet: opts.verbose ? false : true, devices: opts.devices,
autoFinish: opts.autoFinish ?? spawns.length > 0, kits: opts.kits, extraContent: opts.extraContent,
+15 -2
View File
@@ -1,7 +1,7 @@
// tools/local-extract/extract.py (DESIGN §13 / §15): the job table, the manifest merge of --only, and the helpers that
// summarise Materials (map/<theme>/materials.json) and GameObjects (mesh/<bundle>/prefab.json) for the official 3D
// board. The Python helpers run without UnityPy (duck-typed fakes); the real-output checks skip when nothing was
// extracted on this machine.
// board, and enemy_scales.py's table of the official enemy model sizes (build-data MODEL_SCALES). The Python helpers
// run without UnityPy (duck-typed fakes); the real-output checks skip when nothing was extracted on this machine.
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
@@ -161,3 +161,16 @@ describe('extracted board-scene files (when a local client was extracted)', { sk
assert.deepEqual(bkg.map((n) => n.name), ['S_Background_common', 'S_Background_shadow']);
});
});
describe('enemy_scales.py (official enemy model sizes → build-data MODEL_SCALES, user playtest #6 item 9)', { skip: !PY && 'no python3' }, () => {
test('group_table: prefabs off the 0.27 standard, grouped by value, "enemy_" dropped; build-data carries the same values', () => {
const src = `import sys, json\nsys.path.insert(0, ${JSON.stringify(TOOL)})\nimport enemy_scales as s\n`
+ `print(json.dumps(s.group_table({'enemy_1005_yokai_3': 0.16, 'enemy_1005_yokai': 0.2, 'enemy_1040_bombd': 0.2, 'enemy_10083_hlbird': 0.27, 'enemy_x': None})))`;
const r = spawnSync(PY, ['-c', src], { encoding: 'utf8', env: ENV });
assert.equal(r.status, 0, r.stderr);
const lines = JSON.parse(r.stdout);
assert.deepEqual(lines, [" [0.16, ['1005_yokai_3']],", " [0.2, ['1005_yokai', '1040_bombd']],"]);
const bd = readFileSync(path.join(ROOT, 'tools/build-data.mjs'), 'utf8');
assert.ok(bd.includes(lines[0].trim()), 'build-data MODEL_SCALES: 威龙 0.16');
});
});
Loaded 100 of 138 files, more files were not shown because too many files have changed in this diff. Show more