feedback2: merge WH — 荒芜拉普兰德 S3's drones attack (GitHub #32 item 7); 余 S2 pulls leaders, no pull effect when nobody moves, and casts with an enemy on its x-1 (owner's deliberate deviation; GitHub #32 item 1)
# Conflicts: # data/chess.json # docs/PLAYING.md
This commit is contained in:
commit
50c9b5b1b6
11 files changed
+600
-57
No files matched your search
+1
-1
@@ -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
@@ -189,7 +189,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) |
|
| `bb`, `bbStr` | `{"atk":1,"base_attack_time":-0.3,"attack@trigger_time":14}` | kit input (DESIGN §5.6) |
|
||||||
| `rangeId`, `rangeGrid` | `null` | skill range override |
|
| `rangeId`, `rangeGrid` | `null` | skill range override |
|
||||||
| `prefabId`, `overrideTokenKey` | | |
|
| `prefabId`, `overrideTokenKey` | | |
|
||||||
| `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 but six: 深巡 / 雷蛇 S2, 号角 S2 / S3 and 灰毫 S1 / S2 are `DEFAULT` with `rawRule` `TAKE_DAMAGE` — a deliberate deviation, `tools/build-data.mjs TRIGGER_DEVIATIONS`, DESIGN §21.29), `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: "全场存在可选目标时释放技能"). |
|
| `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 but seven: 深巡 / 雷蛇 S2, 号角 S2 / S3 and 灰毫 S1 / S2 are `DEFAULT` with `rawRule` `TAKE_DAMAGE` — a deliberate deviation, `tools/build-data.mjs TRIGGER_DEVIATIONS`, DESIGN §21.29 — and 余 S2 is `SKILL_RANGE` with `rawRule` `TAKE_DAMAGE` and `customRangeGrid` its x-1, DESIGN §22.10), `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`)
|
### 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`.
|
- `dmgType`: MEDIC (except `incantationmedic`) and `bard` → `heal`; trait text containing 法术伤害 or profession CASTER → `arts`; else `phys`.
|
||||||
|
|||||||
+1
-1
@@ -71,7 +71,7 @@
|
|||||||
- 作战中:海嗣、狼群、流形、支援装置开战时就在摆放的位置部署;医疗探机、诅咒娃娃开战时也在摆放的位置免费部署一次(持续 10 / 15 秒,和官方一样不需要先开技能),之后所属干员每次发动技能时再次在原位出现;所属干员被击倒、再部署回场时,技能给的那一个如果还没出现,会立刻出现。巫恋退场时诅咒娃娃随之撤退,赫默退场时已出现的医疗探机不受影响。医疗探机、诅咒娃娃、支援装置、海嗣没有摆放就不会出现;狼群、流形(战术家的援军)没有摆放时会在战术点出现。
|
- 作战中:海嗣、狼群、流形、支援装置开战时就在摆放的位置部署;医疗探机、诅咒娃娃开战时也在摆放的位置免费部署一次(持续 10 / 15 秒,和官方一样不需要先开技能),之后所属干员每次发动技能时再次在原位出现;所属干员被击倒、再部署回场时,技能给的那一个如果还没出现,会立刻出现。巫恋退场时诅咒娃娃随之撤退,赫默退场时已出现的医疗探机不受影响。医疗探机、诅咒娃娃、支援装置、海嗣没有摆放就不会出现;狼群、流形(战术家的援军)没有摆放时会在战术点出现。
|
||||||
- **阻挡**看接触:干员阻挡中心距离约 0.7 格以内、与它接触的敌人(地面干员挡不住飞行和近地悬浮的敌人)。敌人卡在两名干员之间时,阻挡它的干员倒下、撤退或被晕眩后,另一名仍与它接触、还有空余阻挡数的干员会**立刻接替阻挡**;都没有空位时敌人才会走掉。迎面走来的敌人会停在干员格子前面。**阻挡了就一定能打到**:任何干员(包括站在近战位的远程干员和召唤物)总能攻击自己阻挡的敌人,不管它在不在攻击范围内、在干员哪一侧,并且优先攻击它(医疗干员除外:咒愈师以外的医疗挡住敌人时照常治疗队友,不会攻击它;「停止攻击」类技能期间也除外;技能原文另有限制的也按原文:普罗旺斯二技能开启时不以生命值高于 80% 的敌人为目标,寒檀二技能的冰凌随机落在攻击范围内的地块上)。
|
- **阻挡**看接触:干员阻挡中心距离约 0.7 格以内、与它接触的敌人(地面干员挡不住飞行和近地悬浮的敌人)。敌人卡在两名干员之间时,阻挡它的干员倒下、撤退或被晕眩后,另一名仍与它接触、还有空余阻挡数的干员会**立刻接替阻挡**;都没有空位时敌人才会走掉。迎面走来的敌人会停在干员格子前面。**阻挡了就一定能打到**:任何干员(包括站在近战位的远程干员和召唤物)总能攻击自己阻挡的敌人,不管它在不在攻击范围内、在干员哪一侧,并且优先攻击它(医疗干员除外:咒愈师以外的医疗挡住敌人时照常治疗队友,不会攻击它;「停止攻击」类技能期间也除外;技能原文另有限制的也按原文:普罗旺斯二技能开启时不以生命值高于 80% 的敌人为目标,寒檀二技能的冰凌随机落在攻击范围内的地块上)。
|
||||||
- 开战时干员按**从上到下、从左到右**的顺序部署,召唤物在所有干员之后。被阻挡的敌人先打阻挡它的干员;没被阻挡的远程敌人先打嘲讽等级高的,同级时打最后部署的(召唤物、靠下靠右的干员和刚再部署的干员更容易挨打)。隐匿的干员只会被它阻挡的敌人攻击,没被它挡住的敌人的溅射、爆炸、范围技能、留下的伤害区域(包括假想敌:蚀裂被击倒后喷出的毒雾)以及深池伙友卫队、扎罗的减攻速光环也落不到它身上(和官方一样;写明「无视无法选择」的效果如污染秽蚀照常能打到,寒霜的减攻速光环和“墓碑”这类地图效果照常生效);**迷彩**(如忍冬三技能期间击倒敌人后、寒芒克洛丝一技能)同样只会被它阻挡的敌人攻击,也显示为半透明,但躲不开溅射、爆炸和范围伤害(同时打范围内所有单位的普通攻击——如“斩胄之剑”“破胄之锤”“灵幛”——打不到它),也不算隐匿(不触发叙拉古等「隐匿时」效果)。叙拉古的隐匿按时(32 秒 + 每层 0.4 秒)结束;忍冬的迷彩按官方持续到她下一次开技能。**蒂比起飞**期间只挡飞行敌人,地面敌人不会选她为目标(普通攻击、溅射、技能范围、燃烧区域和要选中目标的减益都落不到她身上);飞行和悬浮敌人、写明「无视无法选择」的效果(如污染秽蚀)照常能打到她;地面敌人的光环和全场效果(如“墓碑”让全场攻击力、防御力减半)、她攻击敌人时招来的反制(如重装侦察兵的暴露)照常生效,起飞前已挂上的持续伤害也照常跳。
|
- 开战时干员按**从上到下、从左到右**的顺序部署,召唤物在所有干员之后。被阻挡的敌人先打阻挡它的干员;没被阻挡的远程敌人先打嘲讽等级高的,同级时打最后部署的(召唤物、靠下靠右的干员和刚再部署的干员更容易挨打)。隐匿的干员只会被它阻挡的敌人攻击,没被它挡住的敌人的溅射、爆炸、范围技能、留下的伤害区域(包括假想敌:蚀裂被击倒后喷出的毒雾)以及深池伙友卫队、扎罗的减攻速光环也落不到它身上(和官方一样;写明「无视无法选择」的效果如污染秽蚀照常能打到,寒霜的减攻速光环和“墓碑”这类地图效果照常生效);**迷彩**(如忍冬三技能期间击倒敌人后、寒芒克洛丝一技能)同样只会被它阻挡的敌人攻击,也显示为半透明,但躲不开溅射、爆炸和范围伤害(同时打范围内所有单位的普通攻击——如“斩胄之剑”“破胄之锤”“灵幛”——打不到它),也不算隐匿(不触发叙拉古等「隐匿时」效果)。叙拉古的隐匿按时(32 秒 + 每层 0.4 秒)结束;忍冬的迷彩按官方持续到她下一次开技能。**蒂比起飞**期间只挡飞行敌人,地面敌人不会选她为目标(普通攻击、溅射、技能范围、燃烧区域和要选中目标的减益都落不到她身上);飞行和悬浮敌人、写明「无视无法选择」的效果(如污染秽蚀)照常能打到她;地面敌人的光环和全场效果(如“墓碑”让全场攻击力、防御力减半)、她攻击敌人时招来的反制(如重装侦察兵的暴露)照常生效,起飞前已挂上的持续伤害也照常跳。
|
||||||
- **技能自动释放**按官方「技能策略」:一般的手动技能在技能就绪、即将攻击 / 治疗且初始攻击范围内有敌人(治疗技能:有受伤的队友)时释放;**带独立技能范围**的技能(如德克萨斯「剑雨」、凛御银灰「御敌的锋锐」、锏「归于宁静」,不含「攻击范围扩大」类)在**技能范围内出现敌人**时就释放,不必先攻击;**重装干员**的手动技能在受到伤害时释放(例外:深巡、雷蛇的二技能,号角的二、三技能,灰毫的一、二技能按玩家反馈改为攻击范围内有敌人时就释放,这是本作有意与官方不同的地方;深巡一技能仍要受到伤害);执旗手 / 战术家 / 吟游者技能就绪即释放,解放者 / 阵法术师在初始攻击范围内有敌人时释放。**自动触发**的技能按其本身规则,例如古米「备用军粮」在身边(技能范围内)有受伤的队友时就会发动,下一次攻击改为治疗该队友(不用先接敌;她的二技能「食粮烹制」是手动技能,按重装规则在受到伤害时释放);塞雷娅「急救」在她即将攻击时、身边有生命不高于一半的队友才发动,这次攻击直接改为治疗;新约能天使二技能的护盾按官方给攻击范围内**仇恨最高**的队友(嘲讽等级高的优先,同级时给最后部署的),不一定是站在最前面挨打的那个。手动技能的自动释放有 3 秒冷却:开战部署后先等 3 秒,之后同一干员两次自动释放至少间隔 3 秒(可充能的技能不会同时放完)。
|
- **技能自动释放**按官方「技能策略」:一般的手动技能在技能就绪、即将攻击 / 治疗且初始攻击范围内有敌人(治疗技能:有受伤的队友)时释放;**带独立技能范围**的技能(如德克萨斯「剑雨」、凛御银灰「御敌的锋锐」、锏「归于宁静」,不含「攻击范围扩大」类)在**技能范围内出现敌人**时就释放,不必先攻击;**重装干员**的手动技能在受到伤害时释放(例外:深巡、雷蛇的二技能,号角的二、三技能,灰毫的一、二技能按玩家反馈改为攻击范围内有敌人时就释放,余的二技能「厚礼上宾」改为周围两格(技能范围)内有敌人时就释放,这是本作有意与官方不同的地方;深巡一技能仍要受到伤害);执旗手 / 战术家 / 吟游者技能就绪即释放,解放者 / 阵法术师在初始攻击范围内有敌人时释放。**自动触发**的技能按其本身规则,例如古米「备用军粮」在身边(技能范围内)有受伤的队友时就会发动,下一次攻击改为治疗该队友(不用先接敌;她的二技能「食粮烹制」是手动技能,按重装规则在受到伤害时释放);塞雷娅「急救」在她即将攻击时、身边有生命不高于一半的队友才发动,这次攻击直接改为治疗;新约能天使二技能的护盾按官方给攻击范围内**仇恨最高**的队友(嘲讽等级高的优先,同级时给最后部署的),不一定是站在最前面挨打的那个。手动技能的自动释放有 3 秒冷却:开战部署后先等 3 秒,之后同一干员两次自动释放至少间隔 3 秒(可充能的技能不会同时放完)。
|
||||||
- **推开 / 拉拽**按官方「力度 − 重量」决定距离:例如野鬃二技能(中等力度)把重量 0 / 1 / 2 / 3 的敌人推开约 2.1 / 1.7 / 0.4 / 0.1 格,重量 4 以上推不动——轻型敌人反而推得更远。野鬃二技能沿她的朝向推开:正前方 45° 以内按上面的距离,身后、侧面或贴身的敌人只被轻推一下(约 0.4 格以内);琳琅诗怀雅三技能的金币则从她身边向外推开,落在她前方 2–4 格的范围(官方「范围2-4」)内的地面敌人和她阻挡的敌人身上。攻击速度最低为 20(沼泽、寒霜等减速叠满时也不会更慢)。
|
- **推开 / 拉拽**按官方「力度 − 重量」决定距离:例如野鬃二技能(中等力度)把重量 0 / 1 / 2 / 3 的敌人推开约 2.1 / 1.7 / 0.4 / 0.1 格,重量 4 以上推不动——轻型敌人反而推得更远。野鬃二技能沿她的朝向推开:正前方 45° 以内按上面的距离,身后、侧面或贴身的敌人只被轻推一下(约 0.4 格以内);琳琅诗怀雅三技能的金币则从她身边向外推开,落在她前方 2–4 格的范围(官方「范围2-4」)内的地面敌人和她阻挡的敌人身上。攻击速度最低为 20(沼泽、寒霜等减速叠满时也不会更慢)。
|
||||||
- **元素损伤条**在单位自己的血条正下方:左边是元素图标,白条是剩余的元素值,只有真正爆发时才会耗尽;爆发后条变成元素的颜色、图标闪烁,在冷却期间慢慢回满(这段时间不会再积累元素损伤)。
|
- **元素损伤条**在单位自己的血条正下方:左边是元素图标,白条是剩余的元素值,只有真正爆发时才会耗尽;爆发后条变成元素的颜色、图标闪烁,在冷却期间慢慢回满(这段时间不会再积累元素损伤)。
|
||||||
- **敌人**:远程敌人的射程按官方从干员的碰撞体积(半径 0.25 格)算起,比数据上的攻击范围半径远 0.25 格——例如萨卡兹枯朽战车(射程 2.2)能打到中心 2.45 格以内、站在低地的干员。它的第一下攻击就是「秽蚀轰击」(在目标处留下半径 1.7、持续 10 秒的污染区:低地每秒 50、高台 25 点真实伤害,同名不叠加),被阻挡时普通攻击伤害翻倍。“萨科塔之翼”等血量第一次低于一半时会在原地小范围乱飞 5 秒(官方叫「恐惧」);叙拉古、妮芙等干员造成的恐惧会让敌人朝远离干员的方向逃跑。隐匿的敌人只有被阻挡(或被揭示)时才能被选为攻击目标,溅射、技能的圆形范围伤害也打不到没被挡住的隐匿敌人(和官方一样);阻挡解除后 3 秒内它仍然可以被选中(官方页面注明 0 秒或 1 秒的敌人按页面),之后才重新隐匿:深池逐火战士每次被击倒后先重生 1 秒,然后变成继续往前走的隐匿「余烬」(需要 5 次伤害击倒,护卫的「火灰」10 次),一般要用干员挡住它才能选中(挡住时它不再半透明;被阻挡时被击倒的,重生结束后约 2 秒内也能用远程或范围伤害补刀),10 秒内没打掉就满血站起来;敌人重生时身上的减速、恐惧等状态会被清空、技能冷却从头开始,被恐惧的逐火战士变成余烬后也能被挡住;转译基底·α 原始形态受到的伤害全部无效,受到第 4 次物理伤害、第 4 次法术伤害或被阻挡时花 2 秒变成寻仇者、特战术师或幽灵,之后才能被击倒。
|
- **敌人**:远程敌人的射程按官方从干员的碰撞体积(半径 0.25 格)算起,比数据上的攻击范围半径远 0.25 格——例如萨卡兹枯朽战车(射程 2.2)能打到中心 2.45 格以内、站在低地的干员。它的第一下攻击就是「秽蚀轰击」(在目标处留下半径 1.7、持续 10 秒的污染区:低地每秒 50、高台 25 点真实伤害,同名不叠加),被阻挡时普通攻击伤害翻倍。“萨科塔之翼”等血量第一次低于一半时会在原地小范围乱飞 5 秒(官方叫「恐惧」);叙拉古、妮芙等干员造成的恐惧会让敌人朝远离干员的方向逃跑。隐匿的敌人只有被阻挡(或被揭示)时才能被选为攻击目标,溅射、技能的圆形范围伤害也打不到没被挡住的隐匿敌人(和官方一样);阻挡解除后 3 秒内它仍然可以被选中(官方页面注明 0 秒或 1 秒的敌人按页面),之后才重新隐匿:深池逐火战士每次被击倒后先重生 1 秒,然后变成继续往前走的隐匿「余烬」(需要 5 次伤害击倒,护卫的「火灰」10 次),一般要用干员挡住它才能选中(挡住时它不再半透明;被阻挡时被击倒的,重生结束后约 2 秒内也能用远程或范围伤害补刀),10 秒内没打掉就满血站起来;敌人重生时身上的减速、恐惧等状态会被清空、技能冷却从头开始,被恐惧的逐火战士变成余烬后也能被挡住;转译基底·α 原始形态受到的伤害全部无效,受到第 4 次物理伤害、第 4 次法术伤害或被阻挡时花 2 秒变成寻仇者、特战术师或幽灵,之后才能被击倒。
|
||||||
|
|||||||
+2
-1
@@ -792,7 +792,8 @@ or guard with a per-unit flag while dealing it. When the guard trips, the logged
|
|||||||
`grid`) = the basic rule **and** such an ally on the grid: the cast replaces the attack about to be made (塞雷娅 S1 "触发
|
`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;
|
时会替换当次攻击", ≤ half HP); a cast whose ally condition fails before that attack is withdrawn, its charge returned;
|
||||||
`TAKE_DAMAGE` — ready and just hit (重装: "不受技能范围影响,受到伤害时释放技能"; in the data every MANUAL 重装 skill but
|
`TAKE_DAMAGE` — ready and just hit (重装: "不受技能范围影响,受到伤害时释放技能"; in the data every MANUAL 重装 skill but
|
||||||
the six of DESIGN §21.29 — 深巡 / 雷蛇 S2, 号角 S2 / S3, 灰毫 S1 / S2 — which are `DEFAULT`, a deliberate deviation);
|
the six of DESIGN §21.29 — 深巡 / 雷蛇 S2, 号角 S2 / S3, 灰毫 S1 / S2 — which are `DEFAULT`, a deliberate deviation, and 余
|
||||||
|
S2 厚礼上宾, `SKILL_RANGE` on its own x-1 since DESIGN §22.10);
|
||||||
`SP_FULL` (`ALWAYS`) — immediately;
|
`SP_FULL` (`ALWAYS`) — immediately;
|
||||||
`CUSTOM_RANGE` — an enemy inside `trigger.customRangeGrid` (rotated by the unit direction like every grid); `SEARCH` — an
|
`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: "不受基础策略影响,在初始攻击范围内存在敌人时释放技能");
|
enemy inside the INITIAL range, checked every tick (no attack needed: "不受基础策略影响,在初始攻击范围内存在敌人时释放技能");
|
||||||
|
|||||||
@@ -51,9 +51,12 @@
|
|||||||
// 6_16 溯光星源 link transfers the pre-mitigation arts amount × share to the other locked target(s); 能源解析 = the
|
// 6_16 溯光星源 link transfers the pre-mitigation arts amount × share to the other locked target(s); 能源解析 = the
|
||||||
// 脆弱 status (同名效果取最高).
|
// 脆弱 status (同名效果取最高).
|
||||||
// 6_17 耀骑士临光 "上一名部署干员" = the op of the same owner deployed right before her (deploy order).
|
// 6_17 耀骑士临光 "上一名部署干员" = the op of the same owner deployed right before her (deploy order).
|
||||||
// 6_18 荒芜拉普兰德 S3 drones are virtual (fx events), move at projectile_move_speed tiles/s, she stops attacking
|
// 6_18 荒芜拉普兰德 S3 drones are virtual (fx events) flying PRTS's 技能流程 (spread attack@times s, chase 2.0 → 4.0
|
||||||
// while they roam; 头狼 stage 2 "特殊能力失效" = silence; stage 3 = +1 drone (normal attacks hit once
|
// tiles/s; [ASSUMED] the turn rate is not modelled: straight at the target); every drone is out, so she
|
||||||
// more, S3 releases one more drone). Base drone count 1.
|
// makes no normal attack herself, while each drone on its target attacks like a normal drone (her attack
|
||||||
|
// interval, ATK × its own funnel ramp; neither attack nor skill damage — PRTS 备注); 头狼 stage 2
|
||||||
|
// "特殊能力失效" = silence; stage 3 = +1 drone (normal attacks hit once more; S3 releases one more drone,
|
||||||
|
// mid-skill too). 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 备注 "可对空"; a 静态刚体 — every drone of the mode — is hit
|
// S3 slashes and pulls air units too (PRTS 备注 "可对空"; a 静态刚体 — every drone of the mode — is hit
|
||||||
// but stays put: Battle._displaceable).
|
// but stays put: Battle._displaceable).
|
||||||
@@ -72,7 +75,8 @@
|
|||||||
// any enemy on the field wasted both charges); the snow spreads along her facing line (≤ trig_cnt tiles);
|
// any enemy on the field wasted both charges); the snow spreads along her facing line (≤ trig_cnt tiles);
|
||||||
// S2 "目标点变为冻结状态" = the frozen 保护目标 token (icetgt, one at a time) on the tile reaching max snow, whose
|
// S2 "目标点变为冻结状态" = the frozen 保护目标 token (icetgt, one at a time) on the tile reaching max snow, whose
|
||||||
// snow is used up; spreads go to the thinnest 4-neighbour; the 20 % DoT ticks once per second.
|
// snow is used up; spreads go to the thinnest 4-neighbour; the 20 % DoT ticks once per second.
|
||||||
// 6_03 S2 teleport = the ground-reachable (grid path) enemies of the skill grid moved onto his tile.
|
// 6_03 S2 teleport = the ground-reachable (grid path) enemies of the skill grid moved onto his tile, in the cast's
|
||||||
|
// tick (PRTS: 0.13 s after the damage), leaders included unless 自缚.
|
||||||
// 6_04 S1 transfer: the ally takes ×(1 − share), she takes the rest as true damage from the attacker; module 新生代
|
// 6_04 S1 transfer: the ally takes ×(1 − share), she takes the rest as true damage from the attacker; module 新生代
|
||||||
// "30点伤害减免" = +30 effective DEF (physical) / −30 ÷ (1 − RES) before mitigation (arts).
|
// "30点伤害减免" = +30 effective DEF (physical) / −30 ÷ (1 − RES) before mitigation (arts).
|
||||||
// 6_06 S2 the ASPD stacks last the whole battle; S1 "异常状态时可以释放" = cleanse + cast (tick check).
|
// 6_06 S2 the ASPD stacks last the whole battle; S1 "异常状态时可以释放" = cleanse + cast (tick check).
|
||||||
@@ -106,7 +110,7 @@ import { absoluteRangeKeys, sortEnemyTargets, canTargetEnemy, aggroCmp } from '.
|
|||||||
import { aggregateMods } from '../../buffs.js';
|
import { aggregateMods } from '../../buffs.js';
|
||||||
import { COLS, ROWS, PULL_STOP_RADIUS, CHAIN_RADIUS } from '../../constants.js';
|
import { COLS, ROWS, PULL_STOP_RADIUS, CHAIN_RADIUS } from '../../constants.js';
|
||||||
import { rotateOffset } from '../../dir.js';
|
import { rotateOffset } from '../../dir.js';
|
||||||
import { bodyDist, bodyInKeys, bodyInRadius, bodyKeys } from '../../body.js';
|
import { bodyDist, bodyInKeys, bodyKeys } from '../../body.js';
|
||||||
import { hasHp } from '../../damage.js';
|
import { hasHp } from '../../damage.js';
|
||||||
import { summonToken, TOKEN_IDS } from '../tokens.js';
|
import { summonToken, TOKEN_IDS } from '../tokens.js';
|
||||||
|
|
||||||
@@ -199,12 +203,15 @@ function cleanseAbnormal(battle, u) {
|
|||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* "传送至自身位置": a ground enemy that can reach `unit`'s tile on the ground grid is moved onto it (unblocked, its
|
* "传送至自身位置": a ground enemy that can reach `unit`'s tile on the ground grid is moved onto it (unblocked, its
|
||||||
* route re-planned from there — the engine then blocks it on that tile when capacity allows). Flyers / bosses stay, and
|
* route re-planned from there — the engine then blocks it on that tile when capacity allows). Flyers stay, and so does a
|
||||||
* so does a 自缚 unit (flag `selfBound`, e.g. 守墓石像's 转换模式): PRTS 余 S2 备注 "处于消失状态的/持有自缚的单位不视为可达
|
* 自缚 unit (flag `selfBound`: 守墓石像's 转换模式, the 7 huge leaders, the 胄 parts' anchor): PRTS 余 S2 备注 "处于消失状态的/
|
||||||
* 目标" (束缚 alone does not exempt it, so not `noMove`).
|
* 持有自缚的单位不视为可达目标" (束缚 alone does not exempt it, so not `noMove`). Being a leader is no exemption: PRTS gives
|
||||||
|
* 卢西恩 and 假想敌:铳 (both sections) 传送抗性 无 — they are teleported like any ground enemy and walk on from his tile (a
|
||||||
|
* patrol keeps looping: content/bosses.js patrolLoop). 静态刚体 forbids physical movement, not a teleport (the 备注 names
|
||||||
|
* only 消失 / 自缚 / 免疫传送); every 静态刚体 of the mode flies or is 自缚 anyway.
|
||||||
*/
|
*/
|
||||||
function teleportEnemy(battle, unit, e) {
|
function teleportEnemy(battle, unit, e) {
|
||||||
if (!e || !e.alive || e.isFlying || e.isBoss || e.s.flags.selfBound) return false;
|
if (!e || !e.alive || e.isFlying || e.s.flags.selfBound) return false;
|
||||||
const r = unit.tileR, c = unit.tileC;
|
const r = unit.tileR, c = unit.tileC;
|
||||||
if (!battle.grid.groundPassable(r, c)) return false;
|
if (!battle.grid.groundPassable(r, c)) return false;
|
||||||
const er = Math.round(e.y), ec = Math.round(e.x);
|
const er = Math.round(e.y), ec = Math.round(e.x);
|
||||||
@@ -811,9 +818,12 @@ function yu(bb, chess, def) {
|
|||||||
// S1 今日做东 (TAKE_DAMAGE, hurt SP): passive taunt +taunt_level; active: HP / DEF +, every attack taken ⇒
|
// S1 今日做东 (TAKE_DAMAGE, hurt SP): passive taunt +taunt_level; active: HP / DEF +, every attack taken ⇒
|
||||||
// ep_damage_ratio × ATK 灼燃损伤 on the attacker (install below)
|
// ep_damage_ratio × ATK 灼燃损伤 on the attacker (install below)
|
||||||
skchr_yu_1: { kind: 'duration', mods: { hpPct: num(bb.max_hp), defPct: num(bb.def) } },
|
skchr_yu_1: { kind: 'duration', mods: { hpPct: num(bb.max_hp), defPct: num(bb.def) } },
|
||||||
// S2 厚礼上宾: atk_scale × ATK arts on every enemy of the skill range + the ground-reachable ones teleported onto his
|
// S2 厚礼上宾 (cast with an enemy on its x-1: the data's SKILL_RANGE, a deliberate deviation from the 重装 TAKE_DAMAGE
|
||||||
// tile; block +block_cnt, HP / ATK +, normal attacks deal arts damage. The burst hits air units too [ASSUMED: no 对空
|
// row — tools/build-data.mjs TRIGGER_DEVIATIONS, DESIGN §22.10): atk_scale × ATK arts on every enemy of the skill range
|
||||||
// note on PRTS]; the teleport takes ground units only ("地面可达目标")
|
// + the ground-reachable ones teleported onto his tile (leaders too, unless 自缚: teleportEnemy); block +block_cnt, HP /
|
||||||
|
// ATK +, normal attacks deal arts damage. The burst hits air units too [ASSUMED: no 对空 note on PRTS]; the teleport
|
||||||
|
// takes ground units only ("地面可达目标"). The 'pull' fx only when someone was teleported (each one also gets its own
|
||||||
|
// 'teleport' fx). PRTS's 0.13 s between the damage and the teleport is not modelled (same tick).
|
||||||
skchr_yu_2: {
|
skchr_yu_2: {
|
||||||
kind: 'duration',
|
kind: 'duration',
|
||||||
mods: { hpPct: num(bb.max_hp), atkPct: num(bb.atk), blockCnt: num(bb.block_cnt) },
|
mods: { hpPct: num(bb.max_hp), atkPct: num(bb.atk), blockCnt: num(bb.block_cnt) },
|
||||||
@@ -824,7 +834,7 @@ function yu(bb, chess, def) {
|
|||||||
for (const e of foes) battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.atk_scale, 1), type: 'arts', isSkill: true, tags: ['skill'] });
|
for (const e of foes) battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.atk_scale, 1), type: 'arts', isSkill: true, tags: ['skill'] });
|
||||||
let n = 0;
|
let n = 0;
|
||||||
for (const e of foes) if (teleportEnemy(battle, unit, e)) n++;
|
for (const e of foes) if (teleportEnemy(battle, unit, e)) n++;
|
||||||
battle.fx('pull', { x: unit.x, y: unit.y, id: unit.id, n });
|
if (n > 0) battle.fx('pull', { x: unit.x, y: unit.y, id: unit.id, n });
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -2754,14 +2764,118 @@ function nearl2(bb, chess, def) {
|
|||||||
// ------------------------------------------------------------------------------------------------------------------
|
// ------------------------------------------------------------------------------------------------------------------
|
||||||
// 荒芜拉普兰德 chess_char_6_18 (驭械术师) — S3 终幕·浩劫; 头狼; 叙拉古的荣幸
|
// 荒芜拉普兰德 chess_char_6_18 (驭械术师) — S3 终幕·浩劫; 头狼; 叙拉古的荣幸
|
||||||
|
|
||||||
|
/**
|
||||||
|
* S3 终幕·浩劫's drone flight — PRTS 荒芜拉普兰德 S3 备注 "技能流程": ① for `attack@times` (1.3) s after the cast, or after
|
||||||
|
* a drone is added ("补充浮游单元"), the drones spread evenly outward from her, one along her facing ("散开的方向始终包括
|
||||||
|
* 自身的朝向"): 初速度 0.1, 加速度 1.9, 最大速度 2.0 — [ASSUMED] after an addition (头狼 stage 3 mid-skill) only the added
|
||||||
|
* drone spreads, along her facing, while the others carry on; ② then each picks the target nearest to itself, ties
|
||||||
|
* nearest to her ("距离自身最近>距离本体最近"), anywhere on the field, and flies at it: 初速度 2.0, 加速度 1.0, 最大速度
|
||||||
|
* 4.0, restarting at 2.0 whenever its target leaves or turns unselectable on the way; ③ once there it stays on the target
|
||||||
|
* and attacks it like a normal drone ("此状态下的攻击行为同正常浮游单元"); when that target leaves / turns unselectable it
|
||||||
|
* reappears at a random point of the 1.5-side square around it and picks again (②). With no selectable target it circles
|
||||||
|
* (radius 0.9, 1.0 tiles/s, counter-clockwise) with its heading as the tangent, the circle on its left. Speeds in tiles/s.
|
||||||
|
*/
|
||||||
|
const WHITW2_SPREAD = Object.freeze({ v0: 0.1, acc: 1.9, max: 2 });
|
||||||
|
const WHITW2_CHASE = Object.freeze({ v0: 2, acc: 1, max: 4 });
|
||||||
|
const WHITW2_REAPPEAR_SIDE = 1.5;
|
||||||
|
const WHITW2_ORBIT = Object.freeze({ r: 0.9, v: 1 });
|
||||||
|
|
||||||
function whitw2(bb, chess, def) {
|
function whitw2(bb, chess, def) {
|
||||||
const t0 = tbb(def, 0), t1 = tbb(def, 1);
|
const t0 = tbb(def, 0), t1 = tbb(def, 1);
|
||||||
const sid = selectedSkill(chess, def);
|
const sid = selectedSkill(chess, def);
|
||||||
const speed = Math.max(0.1, num(bb['attack@projectile_move_speed'], 1)), R = num(bb['attack@range_radius'], 0.9);
|
const spreadTime = Math.max(0, num(bb['attack@times'], 1.3)), R = num(bb['attack@range_radius'], 0.9);
|
||||||
const dmgScale = num(bb['attack@magic_atk_scale'], 1), fear = num(bb['attack@fear']), slow = num(bb['attack@move_speed']);
|
const dmgScale = num(bb['attack@magic_atk_scale'], 1), fear = num(bb['attack@fear']), slow = num(bb['attack@move_speed']);
|
||||||
// "非移动敌人": blocked, or not walking (stunned, bound, asleep, waiting, speed 0 …)
|
// "非移动敌人": blocked, or not walking (stunned, bound, asleep, waiting, speed 0 …)
|
||||||
const still = (e) => !!e.blockedBy || e.moving === false || !(e.s.moveSpeed > 0) || !!(e.s.flags.stun || e.s.flags.freeze || e.s.flags.bind || e.s.flags.sleep || e.s.flags.noMove);
|
const still = (e) => !!e.blockedBy || e.moving === false || !(e.s.moveSpeed > 0) || !!(e.s.flags.stun || e.s.flags.freeze || e.s.flags.bind || e.s.flags.sleep || e.s.flags.noMove);
|
||||||
const skillGridW = skillGridOf(def);
|
const skillGridW = skillGridOf(def);
|
||||||
|
|
||||||
|
// ---- S3 drones (virtual: positions in unit.mem.drones, fx events for the client) --------------------------------
|
||||||
|
const droneCount = (unit) => 1 + Math.floor(num(bb['attack@cnt'])) + ((unit.mem.wolfStage || 0) >= 3 ? 1 : 0);
|
||||||
|
// ① `k` drones leave her evenly spread, the first along her facing (row 0 is the bottom row: angles in the (col, row)
|
||||||
|
// plane, counter-clockwise)
|
||||||
|
const releaseDrones = (unit, k) => {
|
||||||
|
const a0 = Math.atan2(unit.fwd[0], unit.fwd[1]);
|
||||||
|
for (let i = 0; i < k; i++) {
|
||||||
|
const a = a0 + (2 * Math.PI * i) / k;
|
||||||
|
unit.mem.drones.push({ x: unit.x, y: unit.y, hx: Math.cos(a), hy: Math.sin(a), v: WHITW2_SPREAD.v0, age: 0, phase: 'spread', t: null, cd: 0, rampId: null, ramp: 0, orbit: null });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// speed v → v + acc·dt (capped); the distance covered at the mean of the two (exact under constant acceleration)
|
||||||
|
const accelerate = (d, lim, dt) => { const v1 = Math.min(lim.max, d.v + lim.acc * dt), s = ((d.v + v1) / 2) * dt; d.v = v1; return s; };
|
||||||
|
// ② the selectable enemy nearest to the drone, ties broken by the one nearest to her. [ASSUMED] distances are measured
|
||||||
|
// to every enemy's position (a huge enemy's centre, its 判定中心) — the owner's decision of 2026-10-04: the centre, so
|
||||||
|
// leader rounds stay close to 0.1.1; the sim's general convention for operator-side distance picks, the hit rectangle
|
||||||
|
// (body.js bodyDist: targeting.js sortEnemyTargets 'nearest', tier3 enemiesAround, 异客 / 溯光星源's chains), was
|
||||||
|
// considered — it made a huge leader (胄, 管) the nearest enemy of every drone around it (DESIGN §22.9)
|
||||||
|
const pickTarget = (battle, unit, d, ok) => {
|
||||||
|
let best = null, bd = Infinity, bh = Infinity;
|
||||||
|
for (const e of battle.enemies) {
|
||||||
|
if (!ok(e)) continue;
|
||||||
|
const de = Math.hypot(e.x - d.x, e.y - d.y), dh = Math.hypot(e.x - unit.x, e.y - unit.y);
|
||||||
|
if (de < bd - 1e-9 || (de <= bd + 1e-9 && dh < bh - 1e-9)) { best = e; bd = de; bh = dh; }
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
};
|
||||||
|
// nothing selectable: circle counter-clockwise on its left, its heading the tangent
|
||||||
|
const circle = (d, dt) => {
|
||||||
|
const { r, v } = WHITW2_ORBIT;
|
||||||
|
if (!d.orbit) d.orbit = { cx: d.x - d.hy * r, cy: d.y + d.hx * r };
|
||||||
|
const a = Math.atan2(d.y - d.orbit.cy, d.x - d.orbit.cx) + (v / r) * dt;
|
||||||
|
d.x = d.orbit.cx + r * Math.cos(a); d.y = d.orbit.cy + r * Math.sin(a);
|
||||||
|
d.hx = -Math.sin(a); d.hy = Math.cos(a);
|
||||||
|
};
|
||||||
|
// one tick of one drone: spread → (pick) → chase → on the target, attacking like a normal drone. [ASSUMED] its attack
|
||||||
|
// clock runs all the time (one attack per interval of hers at most, whatever it chased in between) and its first hit
|
||||||
|
// lands as it arrives when the clock is ready; [ASSUMED] a drone on a target sits at its position (a huge enemy's
|
||||||
|
// centre, its 判定中心), and it reaches a huge enemy when it touches the hit rectangle. [ASSUMED] the turn rate (PRTS
|
||||||
|
// 转向速度 1/6 per frame = attack@projectile_turn_speed × 1/30 s) is not modelled: a chasing drone heads straight at
|
||||||
|
// its target. [ASSUMED] the fear's source is her (the enemy flees from her, not from the drone).
|
||||||
|
const flyDrone = (battle, unit, d, dt, ok) => {
|
||||||
|
d.cd = Math.max(0, d.cd - dt);
|
||||||
|
if (d.phase === 'spread') {
|
||||||
|
const s = accelerate(d, WHITW2_SPREAD, dt);
|
||||||
|
d.x += d.hx * s; d.y += d.hy * s;
|
||||||
|
d.age += dt;
|
||||||
|
if (d.age + 1e-9 < spreadTime) return;
|
||||||
|
d.phase = 'seek';
|
||||||
|
}
|
||||||
|
if (d.phase === 'lock' && !ok(d.t)) {
|
||||||
|
// ③ its target left / is no longer selectable: it reappears at a random point of the square around that spot (the
|
||||||
|
// draw taken in her facing-RIGHT frame, so a battle turned with her direction plays the same)
|
||||||
|
const h = WHITW2_REAPPEAR_SIDE / 2;
|
||||||
|
const [ar, ac] = rotateOffset(battle.rng.range(-h, h), battle.rng.range(-h, h), unit.dir);
|
||||||
|
d.x = d.t.x + ac;
|
||||||
|
d.y = d.t.y + ar;
|
||||||
|
d.t = null; d.phase = 'seek';
|
||||||
|
} else if (d.phase === 'chase' && !ok(d.t)) { d.t = null; d.phase = 'seek'; }
|
||||||
|
if (d.phase === 'seek') {
|
||||||
|
const t = pickTarget(battle, unit, d, ok);
|
||||||
|
if (!t) { circle(d, dt); return; }
|
||||||
|
d.t = t; d.phase = 'chase'; d.v = WHITW2_CHASE.v0; d.orbit = null;
|
||||||
|
battle.fx('droneLock', { x: t.x, y: t.y, id: t.id, src: unit.id }); // PRTS: the red wolf-eye mark over the target
|
||||||
|
}
|
||||||
|
const t = d.t;
|
||||||
|
if (d.phase === 'chase') {
|
||||||
|
const s = accelerate(d, WHITW2_CHASE, dt);
|
||||||
|
if (bodyDist(t, d.x, d.y) > s + 1e-9) {
|
||||||
|
const dx = t.x - d.x, dy = t.y - d.y, L = Math.hypot(dx, dy);
|
||||||
|
if (L > 1e-9) { d.hx = dx / L; d.hy = dy / L; }
|
||||||
|
d.x += d.hx * s; d.y += d.hy * s;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
d.phase = 'lock'; // reached: "追上时使目标恐惧…并锁定其攻击"
|
||||||
|
if (fear > 0) battle.applyStatus(t, 'fear', { duration: fear, source: unit });
|
||||||
|
}
|
||||||
|
d.x = t.x; d.y = t.y;
|
||||||
|
if (d.cd > 1e-9 || !ok(t) || !unit.alive) return; // (the fear's hooks could have knocked her out)
|
||||||
|
d.cd = unit.s.interval;
|
||||||
|
const f = unit.profile?.funnel || { init: 0.2, delta: 0.15, max: 1.1 };
|
||||||
|
d.ramp = d.rampId === t.id ? Math.min(f.max, d.ramp + f.delta) : f.init;
|
||||||
|
d.rampId = t.id;
|
||||||
|
battle.fx('drone', { x: d.x, y: d.y, id: unit.id });
|
||||||
|
battle.dealDamage(unit, t, { amount: unit.s.atk * unit.s.atkScaleMul * d.ramp, type: 'arts', tags: ['droneAttack'] });
|
||||||
|
};
|
||||||
|
|
||||||
const skills = {
|
const skills = {
|
||||||
// S1 慵怠者悲鸣: passive 浮游单元+1 (trait: one more hit per attack); toggled on: ATK +atk and the drones lock a random
|
// S1 慵怠者悲鸣: passive 浮游单元+1 (trait: one more hit per attack); toggled on: ATK +atk and the drones lock a random
|
||||||
// non-moving enemy anywhere on the field (re-locking when it moves or falls; install), else her range. The whole-field
|
// non-moving enemy anywhere on the field (re-locking when it moves or falls; install), else her range. The whole-field
|
||||||
@@ -2836,51 +2950,54 @@ function whitw2(bb, chess, def) {
|
|||||||
}, { owner: unit });
|
}, { owner: unit });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
// S3 终幕·浩劫: ATK +atk; 1 + attack@cnt drones (+1 from 头狼 stage 3) fly the PRTS 技能流程 (WHITW2_* above). Every
|
||||||
|
// drone is out, so she makes no normal attack of her own (`noAttack`); a drone on its target attacks it every attack
|
||||||
|
// interval of hers (her live ASPD; [ASSUMED] the first hit as it arrives) for ATK × its OWN funnel ramp — the trait's
|
||||||
|
// init, +delta per hit on the same target, the cap (头狼 stage 1 raises it), back to init on a new target (PRTS 分支特性
|
||||||
|
// 信息 驭械术师 "浮游单元攻击不同目标…时,上述的伤害立刻恢复至初始值"). That damage is arts and neither a normal attack
|
||||||
|
// nor skill damage (PRTS S3 备注 "该技能释放的浮游单元造成的伤害不属于普通攻击/技能直接伤害", which for this skill
|
||||||
|
// overrides the branch note "通过技能释放的浮游单元造成技能直接伤害": no 'attack' hook, isAttack / isSkill false — the
|
||||||
|
// 叙拉古 6 assassin proc and the on-attack items skip it), and 缴械 does not stop it (PRTS 驭械术师 "…不受缴械类效果
|
||||||
|
// 制约"); [ASSUMED] nor do her stun, freeze or silence (PRTS names only 缴械) — the skill ticks on and so do the drones.
|
||||||
|
// Around every drone (attack@range_radius): move speed attack@move_speed and, once per second, attack@magic_atk_scale
|
||||||
|
// × ATK arts (不叠加: one hit per enemy whatever the number of drones); [ASSUMED] that area hit keeps `isSkill` (a skill
|
||||||
|
// DoT — the 备注 speaks of 直接伤害). A knocked-out / withdrawn wolf (onEnd cleared the drones mid-tick, e.g. from a
|
||||||
|
// kill hook) deals nothing more in that tick.
|
||||||
skill: {
|
skill: {
|
||||||
kind: 'duration',
|
kind: 'duration',
|
||||||
mods: { atkPct: num(bb.atk) },
|
mods: { atkPct: num(bb.atk) },
|
||||||
attack: { noAttack: true },
|
attack: { noAttack: true },
|
||||||
onStart({ battle, unit }) {
|
onStart({ battle, unit }) {
|
||||||
const n = 1 + Math.floor(num(bb['attack@cnt'])) + ((unit.mem.wolfStage || 0) >= 3 ? 1 : 0);
|
unit.mem.drones = [];
|
||||||
unit.mem.drones = Array.from({ length: n }, () => ({ x: unit.x, y: unit.y, t: null, locked: null }));
|
|
||||||
unit.mem.droneAcc = 0;
|
unit.mem.droneAcc = 0;
|
||||||
battle.fx('drones', { x: unit.x, y: unit.y, id: unit.id, n });
|
releaseDrones(unit, droneCount(unit));
|
||||||
|
battle.fx('drones', { x: unit.x, y: unit.y, id: unit.id, n: unit.mem.drones.length });
|
||||||
},
|
},
|
||||||
onTick({ battle, unit, dt }) {
|
onTick({ battle, unit, dt }) {
|
||||||
const D = unit.mem.drones;
|
const D = unit.mem.drones;
|
||||||
if (!D) return;
|
if (!D) return;
|
||||||
|
const gone = () => !unit.alive || unit.mem.drones !== D;
|
||||||
|
// 头狼 stage 3 reached while the skill runs: the extra drone is released (PRTS ① "补充浮游单元"; [ASSUMED] only it spreads)
|
||||||
|
const add = droneCount(unit) - D.length;
|
||||||
|
if (add > 0) { releaseDrones(unit, add); battle.fx('drones', { x: unit.x, y: unit.y, id: unit.id, n: add }); }
|
||||||
const ok = (e) => e && e.alive && !e.hidden && canTargetEnemy(unit, e, ANY);
|
const ok = (e) => e && e.alive && !e.hidden && canTargetEnemy(unit, e, ANY);
|
||||||
const taken = new Set(D.filter((d) => ok(d.t)).map((d) => d.t));
|
|
||||||
for (const d of D) {
|
for (const d of D) {
|
||||||
if (!ok(d.t)) {
|
if (gone()) return;
|
||||||
let best = null, bd = Infinity, bestFree = null, bfd = Infinity;
|
flyDrone(battle, unit, d, dt, ok);
|
||||||
for (const e of battle.enemies) {
|
|
||||||
if (!ok(e)) continue;
|
|
||||||
const dist = bodyDist(e, d.x, d.y);
|
|
||||||
if (dist < bd - 1e-9) { bd = dist; best = e; }
|
|
||||||
if (!taken.has(e) && dist < bfd - 1e-9) { bfd = dist; bestFree = e; }
|
|
||||||
}
|
|
||||||
d.t = bestFree ?? best;
|
|
||||||
d.locked = null;
|
|
||||||
if (d.t) taken.add(d.t);
|
|
||||||
}
|
|
||||||
if (!d.t) continue;
|
|
||||||
const dx = d.t.x - d.x, dy = d.t.y - d.y, dist = Math.hypot(dx, dy), step = speed * dt;
|
|
||||||
if (dist <= step) { d.x = d.t.x; d.y = d.t.y; } else { d.x += (dx / dist) * step; d.y += (dy / dist) * step; }
|
|
||||||
if (d.locked !== d.t && bodyInRadius(d.t, d.x, d.y, R)) {
|
|
||||||
d.locked = d.t;
|
|
||||||
if (fear > 0) battle.applyStatus(d.t, 'fear', { duration: fear, source: unit });
|
|
||||||
battle.fx('droneLock', { x: d.x, y: d.y, id: d.t.id, src: unit.id });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
if (gone()) return;
|
||||||
const near = new Set();
|
const near = new Set();
|
||||||
for (const d of D) for (const e of battle.foesInRadius(d.x, d.y, R)) if (ok(e)) near.add(e);
|
for (const d of D) for (const e of battle.foesInRadius(d.x, d.y, R)) if (ok(e)) near.add(e);
|
||||||
if (slow) for (const e of near) battle.addBuff(e, { key: 'whitw2:slow', duration: 0.2, refresh: 'replace', mods: { moveMul: Math.max(0, 1 + slow) }, source: unit });
|
if (slow) for (const e of near) battle.addBuff(e, { key: 'whitw2:slow', duration: 0.2, refresh: 'replace', mods: { moveMul: Math.max(0, 1 + slow) }, source: unit });
|
||||||
unit.mem.droneAcc += dt;
|
unit.mem.droneAcc += dt;
|
||||||
if (unit.mem.droneAcc + 1e-9 >= 1) {
|
if (unit.mem.droneAcc + 1e-9 >= 1) {
|
||||||
unit.mem.droneAcc -= 1;
|
unit.mem.droneAcc -= 1;
|
||||||
for (const e of near) if (e.alive) battle.dealDamage(unit, e, { amount: unit.s.atk * dmgScale, type: 'arts', isSkill: true, tags: ['skill', 'drone'] });
|
for (const e of near) {
|
||||||
for (const d of D) battle.fx('drone', { x: d.x, y: d.y, id: unit.id });
|
if (gone()) return;
|
||||||
|
if (e.alive) battle.dealDamage(unit, e, { amount: unit.s.atk * dmgScale, type: 'arts', isSkill: true, tags: ['skill', 'drone'] });
|
||||||
|
}
|
||||||
|
// (a drone on its target already pulses with each of its attacks)
|
||||||
|
for (const d of D) if (d.phase !== 'lock') battle.fx('drone', { x: d.x, y: d.y, id: unit.id });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onEnd({ unit }) { unit.mem.drones = null; },
|
onEnd({ unit }) { unit.mem.drones = null; },
|
||||||
|
|||||||
@@ -261,7 +261,7 @@ 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);
|
assert.equal(d.unit('chess_char_6_03_a').s.taunt, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
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', () => {
|
test('6_03 余 S2 厚礼上宾: cast with an enemy on its x-1 (SKILL_RANGE — a deliberate deviation from the 重装 TAKE_DAMAGE, DESIGN §22.10); 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')) {
|
for (const id of both('chess_char_6_03')) {
|
||||||
const sid = 'skchr_yu_2', bb = bbOf(id, sid);
|
const sid = 'skchr_yu_2', bb = bbOf(id, sid);
|
||||||
const h = run({
|
const h = run({
|
||||||
@@ -272,6 +272,7 @@ test('6_03 余 S2 厚礼上宾: cast when hit (重装 TAKE_DAMAGE); atk_scale ×
|
|||||||
const u = h.unit(id);
|
const u = h.unit(id);
|
||||||
usesSkill(u, sid);
|
usesSkill(u, sid);
|
||||||
assert.ok(h.runUntil(() => u.skill.active, 10));
|
assert.ok(h.runUntil(() => u.skill.active, 10));
|
||||||
|
assert.equal(started(h, u)[0].reason, 'SKILL_RANGE');
|
||||||
const t0 = started(h, u)[0].t;
|
const t0 = started(h, u)[0].t;
|
||||||
const burst = dealt(h, u, (c) => c.t === t0 && c.dmg.isSkill && !c.dmg.isAttack);
|
const burst = dealt(h, u, (c) => c.t === t0 && c.dmg.isSkill && !c.dmg.isAttack);
|
||||||
assert.equal(burst.length, 3);
|
assert.equal(burst.length, 3);
|
||||||
@@ -1160,7 +1161,7 @@ test('default-skill-only hooks do not run under an alternate skill (余 / 维娜
|
|||||||
// 余 S2 active: 闲云隐市 is NOT given to every operator (S3 only)
|
// 余 S2 active: 闲云隐市 is NOT given to every operator (S3 only)
|
||||||
{
|
{
|
||||||
const h = run({
|
const h = run({
|
||||||
defs: { chess: { o1: plain('o1'), o2: plain('o2'), o3: plain('o3') }, enemies: { e: dummy('e', { atk: 100, bat: 1 }) } }, // (重装: TAKE_DAMAGE)
|
defs: { chess: { o1: plain('o1'), o2: plain('o2'), o3: plain('o3') }, enemies: { e: dummy('e', { atk: 100, bat: 1 }) } }, // (S2: an enemy on x-1)
|
||||||
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 }],
|
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] }],
|
enemies: [{ key: 'e', pos: [10, 4] }],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -715,9 +715,11 @@ test('耀骑士临光: S3 summons 耀阳 (90 % true + stun 3 s around it), true
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('荒芜拉普兰德: S3 drones chase enemies anywhere, fear on catch, 100 % ATK arts per second around them', () => {
|
test('荒芜拉普兰德: S3 drones chase enemies anywhere, fear on catch, 100 % ATK arts per second around them', () => {
|
||||||
|
// (one enemy, far outside her range: every drone picks the enemy nearest to itself — PRTS S3 备注 ② — so a second,
|
||||||
|
// nearer enemy would draw all of them; the full flight is in test/sim/feedback2-whitw2-yu.test.js)
|
||||||
const h = battle({
|
const h = battle({
|
||||||
units: [{ chessId: 'chess_char_6_18_a', row: 10, col: 3, carryState: { sp: 68 } }],
|
units: [{ chessId: 'chess_char_6_18_a', row: 10, col: 3, carryState: { sp: 68 } }],
|
||||||
enemies: [{ key: 'enemy_dummy', pos: [10, 4] }, { key: 'enemy_dummy2', pos: [12, 9] }],
|
enemies: [{ key: 'enemy_dummy2', pos: [12, 9] }],
|
||||||
});
|
});
|
||||||
const u = h.unit('chess_char_6_18_a');
|
const u = h.unit('chess_char_6_18_a');
|
||||||
const bb = u.def.skill.bb;
|
const bb = u.def.skill.bb;
|
||||||
|
|||||||
+9
-2
@@ -488,9 +488,16 @@ test('chess: skills[] = every skill unlocked at the status, at the chess skill l
|
|||||||
assert.deepEqual(rules(id), ['DEFAULT', 'DEFAULT']);
|
assert.deepEqual(rules(id), ['DEFAULT', 'DEFAULT']);
|
||||||
assert.deepEqual(raws(id), ['TAKE_DAMAGE', 'TAKE_DAMAGE']);
|
assert.deepEqual(raws(id), ['TAKE_DAMAGE', 'TAKE_DAMAGE']);
|
||||||
}
|
}
|
||||||
|
// 余 S2 厚礼上宾 (DESIGN §22.10, the owner's decision of 2026-10-04): SKILL_RANGE on its own x-1, S1 keeps the row, S3 its
|
||||||
|
// official charId row
|
||||||
|
for (const id of ['chess_char_6_03_a', 'chess_char_6_03_b']) {
|
||||||
|
assert.deepEqual(rules(id), ['TAKE_DAMAGE', 'SKILL_RANGE', 'CUSTOM_RANGE']);
|
||||||
|
assert.deepEqual(raws(id), ['TAKE_DAMAGE', 'TAKE_DAMAGE', 'CUSTOM_RANGE_SEARCH_ENEMY']);
|
||||||
|
assert.deepEqual(chess[id].skills[1].trigger.customRangeGrid, chess[id].skills[1].rangeGrid);
|
||||||
|
}
|
||||||
const deviated = Object.values(chess).flatMap((c) => (c.skills || []).filter((s) => s.trigger.rawRule === 'TAKE_DAMAGE' && s.trigger.rule !== 'TAKE_DAMAGE').map((s) => `${c.baseId} ${s.skillId}`));
|
const deviated = Object.values(chess).flatMap((c) => (c.skills || []).filter((s) => s.trigger.rawRule === 'TAKE_DAMAGE' && s.trigger.rule !== 'TAKE_DAMAGE').map((s) => `${c.baseId} ${s.skillId}`));
|
||||||
assert.equal(deviated.length, 12, 'exactly the six skills, normal + elite');
|
assert.equal(deviated.length, 14, 'exactly the six skills of §21.29 and 余 S2, normal + elite');
|
||||||
assert.deepEqual([...new Set(deviated)].sort(), ['chess_char_1_04_a skchr_udflow_2', 'chess_char_1_20_a skchr_liskam_2', 'chess_char_2_18_a skchr_ashlok_2', 'chess_char_2_18_a skcom_atk_up[3]', 'chess_char_5_08_a skchr_horn_2', 'chess_char_5_08_a skchr_horn_3']);
|
assert.deepEqual([...new Set(deviated)].sort(), ['chess_char_1_04_a skchr_udflow_2', 'chess_char_1_20_a skchr_liskam_2', 'chess_char_2_18_a skchr_ashlok_2', 'chess_char_2_18_a skcom_atk_up[3]', 'chess_char_5_08_a skchr_horn_2', 'chess_char_5_08_a skchr_horn_3', 'chess_char_6_03_a skchr_yu_2']);
|
||||||
for (const c of Object.values(chess)) {
|
for (const c of Object.values(chess)) {
|
||||||
for (const s of c.skills || []) {
|
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.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`);
|
||||||
|
|||||||
@@ -4,6 +4,9 @@
|
|||||||
// and S3 终极防线, 灰毫 S1 攻击力强化·γ型 and S2 专注轰击. It lives in the data (tools/build-data.mjs TRIGGER_DEVIATIONS,
|
// and S3 终极防线, 灰毫 S1 攻击力强化·γ型 and S2 专注轰击. It lives in the data (tools/build-data.mjs TRIGGER_DEVIATIONS,
|
||||||
// keyed per chess: 灰毫 S1 is the generic skcom_atk_up[3] other chess carry too), `rawRule` keeps the official TAKE_DAMAGE.
|
// keyed per chess: 灰毫 S1 is the generic skcom_atk_up[3] other chess carry too), `rawRule` keeps the official TAKE_DAMAGE.
|
||||||
// Every other MANUAL 重装 skill — 深巡 S1 侵袭破坏应对 among them — still waits for a hit.
|
// Every other MANUAL 重装 skill — 深巡 S1 侵袭破坏应对 among them — still waits for a hit.
|
||||||
|
// One more since 2026-10-04 (the owner's decision after GitHub issue #32 item 1, DESIGN §22.10): 余 S2 厚礼上宾 casts with an
|
||||||
|
// enemy on its own 技能范围 x-1 — SKILL_RANGE, customRangeGrid its range (his attack range is his own tile, so the basic
|
||||||
|
// strategy could not see an enemy he can pull); `rawRule` keeps TAKE_DAMAGE.
|
||||||
//
|
//
|
||||||
// The battle check is real: the chess records of data/chess.json with their kits, the stage 战场#01(下半)
|
// The battle check is real: the chess records of data/chess.json with their kits, the stage 战场#01(下半)
|
||||||
// (act2autochess_m01), a real enemy (萨卡兹枯朽前锋, melee, 0.8 tiles/s) walking the lane from the gate (9,10) to the
|
// (act2autochess_m01), a real enemy (萨卡兹枯朽前锋, melee, 0.8 tiles/s) walking the lane from the gate (9,10) to the
|
||||||
@@ -15,6 +18,7 @@ import fs from 'node:fs';
|
|||||||
import { makeBattle, checkInvariants } from '../helpers/battleHarness.js';
|
import { makeBattle, checkInvariants } from '../helpers/battleHarness.js';
|
||||||
import { getDefaultSource } from '../../server/sim/simdata.js';
|
import { getDefaultSource } from '../../server/sim/simdata.js';
|
||||||
import { bodyInKeys } from '../../server/sim/body.js';
|
import { bodyInKeys } from '../../server/sim/body.js';
|
||||||
|
import { absoluteRangeKeys } from '../../server/sim/targeting.js';
|
||||||
|
|
||||||
const CHESS = JSON.parse(fs.readFileSync(new URL('../../data/chess.json', import.meta.url), 'utf8'));
|
const CHESS = JSON.parse(fs.readFileSync(new URL('../../data/chess.json', import.meta.url), 'utf8'));
|
||||||
const ds = getDefaultSource();
|
const ds = getDefaultSource();
|
||||||
@@ -28,8 +32,10 @@ const DEVIATED = [
|
|||||||
['chess_char_2_18_a', 'skcom_atk_up[3]'], // 灰毫 S1 攻击力强化·γ型
|
['chess_char_2_18_a', 'skcom_atk_up[3]'], // 灰毫 S1 攻击力强化·γ型
|
||||||
['chess_char_2_18_a', 'skchr_ashlok_2'], // 灰毫 S2 专注轰击
|
['chess_char_2_18_a', 'skchr_ashlok_2'], // 灰毫 S2 专注轰击
|
||||||
];
|
];
|
||||||
|
/** 余 S2 厚礼上宾: SKILL_RANGE on its own x-1 (DESIGN §22.10). */
|
||||||
|
const SKILL_RANGE_DEVIATED = [['chess_char_6_03_a', 'skchr_yu_2']];
|
||||||
const both = (normalId) => [normalId, CHESS[normalId].goldenId];
|
const both = (normalId) => [normalId, CHESS[normalId].goldenId];
|
||||||
const isDeviated = (c, skillId) => DEVIATED.some(([id, s]) => id === c.baseId && s === skillId);
|
const isDeviated = (c, skillId) => [...DEVIATED, ...SKILL_RANGE_DEVIATED].some(([id, s]) => id === c.baseId && s === skillId);
|
||||||
const ENEMY = 'enemy_1422_lrsldr';
|
const ENEMY = 'enemy_1422_lrsldr';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -46,7 +52,7 @@ function firstCast(chessId, skillId, { spawnAt = 0, seconds = 40 } = {}) {
|
|||||||
});
|
});
|
||||||
const u = h.unit(chessId);
|
const u = h.unit(chessId);
|
||||||
assert.equal(u.skill.id, skillId, `${chessId} runs ${skillId}`);
|
assert.equal(u.skill.id, skillId, `${chessId} runs ${skillId}`);
|
||||||
const out = { rule: u.skill.rule, cast: null, reason: null, enemyInRange: false, blocking: false, hurt: null };
|
const out = { rule: u.skill.rule, cast: null, reason: null, enemyInRange: false, enemyInSkillRange: false, blocking: false, hurt: null };
|
||||||
h.b.on('damaged', (ctx) => { if (ctx.target === u && out.hurt == null) out.hurt = h.b.time; }, { priority: 1000 });
|
h.b.on('damaged', (ctx) => { if (ctx.target === u && out.hurt == null) out.hurt = h.b.time; }, { priority: 1000 });
|
||||||
h.b.on('skillStart', (ctx) => {
|
h.b.on('skillStart', (ctx) => {
|
||||||
if (ctx.unit !== u || out.cast != null) return;
|
if (ctx.unit !== u || out.cast != null) return;
|
||||||
@@ -54,6 +60,8 @@ function firstCast(chessId, skillId, { spawnAt = 0, seconds = 40 } = {}) {
|
|||||||
out.reason = ctx.reason;
|
out.reason = ctx.reason;
|
||||||
const keys = new Set(u.baseRangeKeys || u.rangeKeys);
|
const keys = new Set(u.baseRangeKeys || u.rangeKeys);
|
||||||
out.enemyInRange = h.b.enemies.some((e) => e.alive && e.deployed && bodyInKeys(e, keys));
|
out.enemyInRange = h.b.enemies.some((e) => e.alive && e.deployed && bodyInKeys(e, keys));
|
||||||
|
const sk = u.skill.triggerGrid ? new Set(absoluteRangeKeys(u.skill.triggerGrid, u.tileR, u.tileC, u.dir, 0)) : null;
|
||||||
|
out.enemyInSkillRange = !!sk && h.b.enemies.some((e) => e.alive && e.deployed && bodyInKeys(e, sk));
|
||||||
out.blocking = u.blocking.length > 0;
|
out.blocking = u.blocking.length > 0;
|
||||||
}, { priority: 1000 });
|
}, { priority: 1000 });
|
||||||
h.runUntil(() => out.cast != null && out.hurt != null, seconds);
|
h.runUntil(() => out.cast != null && out.hurt != null, seconds);
|
||||||
@@ -62,7 +70,7 @@ function firstCast(chessId, skillId, { spawnAt = 0, seconds = 40 } = {}) {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
test('data: the six deviated 重装 skills are DEFAULT on the normal and the elite record, rawRule the official TAKE_DAMAGE', () => {
|
test('data: the six deviated 重装 skills are DEFAULT and 余 S2 is SKILL_RANGE on its x-1, on the normal and the elite record, rawRule the official TAKE_DAMAGE', () => {
|
||||||
let n = 0;
|
let n = 0;
|
||||||
for (const [normalId, skillId] of DEVIATED) {
|
for (const [normalId, skillId] of DEVIATED) {
|
||||||
for (const id of both(normalId)) {
|
for (const id of both(normalId)) {
|
||||||
@@ -76,6 +84,17 @@ test('data: the six deviated 重装 skills are DEFAULT on the normal and the eli
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
assert.equal(n, 12);
|
assert.equal(n, 12);
|
||||||
|
for (const [normalId, skillId] of SKILL_RANGE_DEVIATED) {
|
||||||
|
for (const id of both(normalId)) {
|
||||||
|
const s = CHESS[id].skills.find((x) => x.skillId === skillId);
|
||||||
|
assert.equal(CHESS[id].profession, 'TANK');
|
||||||
|
assert.ok(s.rangeGrid?.length, `${id} ${skillId}: a 技能范围 of its own`);
|
||||||
|
assert.deepEqual(s.trigger, { rule: 'SKILL_RANGE', rawRule: 'TAKE_DAMAGE', customRangeGrid: s.rangeGrid }, `${id} ${skillId}`);
|
||||||
|
const def = ds.getChess(id, { skillIndex: s.index });
|
||||||
|
assert.equal(def.skill.trigger.rule, 'SKILL_RANGE', `${id} ${skillId} loadout def`);
|
||||||
|
assert.deepEqual(def.skill.trigger.grid, s.rangeGrid, `${id} ${skillId}: the trigger grid is its x-1`);
|
||||||
|
}
|
||||||
|
}
|
||||||
// nothing else changed: every other MANUAL 重装 skill keeps the row (深巡 S1 included), and the generic
|
// nothing else changed: every other MANUAL 重装 skill keeps the row (深巡 S1 included), and the generic
|
||||||
// skcom_atk_up[3] keeps each other carrier's own rule (the deviation is keyed per chess, never per skill id)
|
// skcom_atk_up[3] keeps each other carrier's own rule (the deviation is keyed per chess, never per skill id)
|
||||||
for (const c of Object.values(CHESS)) {
|
for (const c of Object.values(CHESS)) {
|
||||||
@@ -91,9 +110,9 @@ test('data: the six deviated 重装 skills are DEFAULT on the normal and the eli
|
|||||||
for (const c of others) assert.deepEqual(c.skills.find((s) => s.skillId === 'skcom_atk_up[3]').trigger, { rule: 'DEFAULT', rawRule: 'DEFAULT', customRangeGrid: null }, c.chessId);
|
for (const c of others) assert.deepEqual(c.skills.find((s) => s.skillId === 'skcom_atk_up[3]').trigger, { rule: 'DEFAULT', rawRule: 'DEFAULT', customRangeGrid: null }, c.chessId);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('PR #12\'s kit lines agree with the data: 深巡 / 雷蛇 S2 specs say DEFAULT, the other four deviated skills read the data', async () => {
|
test('PR #12\'s kit lines agree with the data: 深巡 / 雷蛇 S2 specs say DEFAULT, the other deviated skills read the data', async () => {
|
||||||
const { KITS } = await import('../../server/sim/content/index.js');
|
const { KITS } = await import('../../server/sim/content/index.js');
|
||||||
for (const [normalId, skillId] of DEVIATED) {
|
for (const [normalId, skillId] of [...DEVIATED, ...SKILL_RANGE_DEVIATED]) {
|
||||||
for (const id of both(normalId)) {
|
for (const id of both(normalId)) {
|
||||||
const rec = CHESS[id];
|
const rec = CHESS[id];
|
||||||
const s = rec.skills.find((x) => x.skillId === skillId);
|
const s = rec.skills.find((x) => x.skillId === skillId);
|
||||||
@@ -122,6 +141,22 @@ test('real battle: each deviated skill casts with an enemy in range before anyth
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('real battle: 余 S2 casts once the walker steps onto its x-1, before anything hits him (normal + elite)', () => {
|
||||||
|
for (const [normalId, skillId] of SKILL_RANGE_DEVIATED) {
|
||||||
|
for (const id of both(normalId)) {
|
||||||
|
const r = firstCast(id, skillId, { spawnAt: 3 });
|
||||||
|
const where = `${id} ${skillId}: ${JSON.stringify(r)}`;
|
||||||
|
assert.equal(r.rule, 'SKILL_RANGE', where);
|
||||||
|
assert.ok(r.cast != null && r.cast >= 3, `cast once an enemy is on the field — ${where}`);
|
||||||
|
assert.equal(r.reason, 'SKILL_RANGE', where);
|
||||||
|
// (skillStart comes after the cast's own teleport: the walker already stands on his tile then, but he has not
|
||||||
|
// blocked anyone yet — it was pulled in from x-1, it did not walk in)
|
||||||
|
assert.ok(r.enemyInSkillRange && !r.blocking, `an enemy on x-1, pulled in rather than walked in — ${where}`);
|
||||||
|
assert.ok(r.hurt == null || r.hurt > r.cast, `cast before the first hit — ${where}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('real battle: 深巡 S1 and every other MANUAL 重装 skill still wait for a hit (TAKE_DAMAGE)', () => {
|
test('real battle: 深巡 S1 and every other MANUAL 重装 skill still wait for a hit (TAKE_DAMAGE)', () => {
|
||||||
let n = 0;
|
let n = 0;
|
||||||
for (const c of Object.values(CHESS)) {
|
for (const c of Object.values(CHESS)) {
|
||||||
|
|||||||
@@ -0,0 +1,370 @@
|
|||||||
|
// GitHub issue #32 (after v0.1.1), items 7 and 1 — DESIGN §22.9 / §22.10.
|
||||||
|
// - Item 7 「易拉三技能浮游单元无法造成伤害,只能造成三技能自带dot伤害」: 荒芜拉普兰德 S3 终幕·浩劫. PRTS 荒芜拉普兰德 S3 备注
|
||||||
|
// "技能流程": ① for attack@times (1.3) s the drones spread evenly from her, one along her facing (0.1 → 2.0 tiles/s at
|
||||||
|
// 1.9/s²); ② each then picks the target nearest to itself (ties: nearest to her) and chases it (2.0 → 4.0 at 1.0/s²);
|
||||||
|
// ③ on the target it attacks like a normal drone ("此状态下的攻击行为同正常浮游单元"), a target lost → it reappears in
|
||||||
|
// the 1.5-side square around it and picks again; nothing selectable → it circles (radius 0.9, 1.0 tiles/s, counter-
|
||||||
|
// clockwise). The drones' damage is neither a normal attack nor skill damage ("该技能释放的浮游单元造成的伤害不属于普通
|
||||||
|
// 攻击/技能直接伤害"); every drone is out, so she makes no attack of her own. Before 0.1.2 the skill only dealt its
|
||||||
|
// once-per-second aura.
|
||||||
|
// - Item 1 「余二技能有伤害和特效,但不能拉怪」: 余 S2 厚礼上宾 teleports the ground-reachable enemies of x-1 onto his tile —
|
||||||
|
// leaders too unless 自缚 (PRTS 卢西恩 / 假想敌:铳 "传送抗性=无"; it skipped every leader), and its 'pull' fx plays only
|
||||||
|
// when someone was teleported (it played with nobody pulled).
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
|
||||||
|
import { TICK } from '../../server/sim/constants.js';
|
||||||
|
import { rotateOffset, toLocal } from '../../server/sim/dir.js';
|
||||||
|
|
||||||
|
const BOTH = ['chess_char_6_18_a', 'chess_char_6_18_b'];
|
||||||
|
const dummy = (key, o = {}) => enemyRec({ key, hp: 1e7, speed: 0, ...o });
|
||||||
|
const approx = (a, b, msg = '', eps = 1e-6) => assert.ok(Math.abs(a - b) <= eps * Math.max(1, Math.abs(b)), `${msg} ${a} ≈ ${b}`);
|
||||||
|
const done = (h) => { checkInvariants(h.b); assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0])); };
|
||||||
|
/** 荒芜拉普兰德 on (10,4) facing RIGHT with S3 (her default) ready; `enemies` parked. */
|
||||||
|
function wolf(id, enemies, o = {}) {
|
||||||
|
return makeBattle({
|
||||||
|
seed: 3, timeLimit: 400, autoFinish: false, captureNoisy: true, hooks: ['damaged', 'attack', 'statusApplied', 'death', 'skillStart'],
|
||||||
|
defs: { enemies: Object.fromEntries(enemies.map((e) => [e.key, e.rec ?? dummy(e.key)])) },
|
||||||
|
units: [{ chessId: id, row: 10, col: 4, dir: 'RIGHT', carryState: o.carry ?? { sp: 999 } }],
|
||||||
|
enemies: enemies.map((e) => ({ key: e.key, pos: e.pos })), ...o.battle,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const droneHits = (h, u, f = () => true) => h.hooksOf('damaged').filter((c) => c.source === u && (c.dmg?.tags || []).includes('droneAttack') && f(c));
|
||||||
|
/** Each drone's attacks, seen from its attack clock (reset to her interval when it attacks): [[{ t, ramp, target }]]. */
|
||||||
|
function watchDrones(h, u) {
|
||||||
|
const log = [];
|
||||||
|
let last = [];
|
||||||
|
h.b.on('tick', () => {
|
||||||
|
const D = u.mem.drones || [];
|
||||||
|
D.forEach((d, i) => {
|
||||||
|
log[i] ??= [];
|
||||||
|
if (d.cd > (last[i] ?? 0) + 1e-9) log[i].push({ t: h.b.time, ramp: d.ramp, target: d.t });
|
||||||
|
});
|
||||||
|
last = D.map((d) => d.cd);
|
||||||
|
});
|
||||||
|
return log;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =====================================================================================================================
|
||||||
|
// item 7 — 荒芜拉普兰德 S3 终幕·浩劫
|
||||||
|
|
||||||
|
test('荒芜拉普兰德 S3 ① ②: the drones spread attack@times s (evenly, one along her facing, 0.1 → 2.0 tiles/s), then chase at 2.0 → 4.0 tiles/s', () => {
|
||||||
|
for (const id of BOTH) {
|
||||||
|
// 联防 field (21 columns): room for a chase long enough to reach 4.0
|
||||||
|
const h = wolf(id, [{ key: 'far', pos: [10, 17] }], { battle: { kind: 'unite' } });
|
||||||
|
const u = h.unit(id), bb = u.def.skill.bb;
|
||||||
|
assert.ok(h.runUntil(() => u.skill.active, 5), 'S3 fires with an enemy anywhere on the field (GDGLOW_SKILL_2)');
|
||||||
|
const t0 = h.b.time;
|
||||||
|
const D = u.mem.drones;
|
||||||
|
assert.equal(D.length, 1 + bb['attack@cnt'], '浮游单元+attack@cnt (one drone of her own)');
|
||||||
|
for (let i = 0; i < D.length; i++) {
|
||||||
|
const a = (2 * Math.PI * i) / D.length; // RIGHT = +col; row 0 is the bottom row, so the angles run counter-clockwise
|
||||||
|
approx(D[i].hx, Math.cos(a), `drone ${i} heading x`, 1e-12);
|
||||||
|
approx(D[i].hy, Math.sin(a), `drone ${i} heading y`, 1e-12);
|
||||||
|
}
|
||||||
|
const ticks = Math.round(bb['attack@times'] / TICK);
|
||||||
|
assert.equal(ticks, 39, 'attack@times = 1.3 s (PRTS ① "1.3s内")');
|
||||||
|
h.step(ticks - 1);
|
||||||
|
const t = (ticks - 1) * TICK;
|
||||||
|
const spread = t <= 1 ? 0.1 * t + 0.95 * t * t : 1.05 + 2 * (t - 1); // v = min(2.0, 0.1 + 1.9 t)
|
||||||
|
for (const d of D) {
|
||||||
|
assert.equal(d.phase, 'spread');
|
||||||
|
approx(Math.hypot(d.x - u.x, d.y - u.y), spread, 'spread distance', 1e-9);
|
||||||
|
}
|
||||||
|
h.step(1);
|
||||||
|
const far = h.enemies()[0];
|
||||||
|
for (const d of D) assert.ok(d.phase === 'chase' && d.t === far, 'then every drone chases the one enemy');
|
||||||
|
const d0 = D[0];
|
||||||
|
approx(d0.v, 2 + TICK, 'chase: 初速度 2.0, 加速度 1.0', 1e-9);
|
||||||
|
h.step(Math.round(2 / TICK));
|
||||||
|
approx(d0.v, 4, '最大速度 4.0', 1e-12);
|
||||||
|
// drone 0 flies straight on: 1.65 tiles of spread, then 11.35 more to the enemy (2 s to reach 4.0 covering 6 tiles,
|
||||||
|
// 5.35 at 4.0) → it lands its first hit ≈ 1.3 + 2 + 1.3375 s after the cast
|
||||||
|
assert.ok(h.runUntil(() => droneHits(h, u).length > 0, 5));
|
||||||
|
const first = droneHits(h, u)[0];
|
||||||
|
assert.ok(Math.abs(first.t - t0 - 4.6375) <= 2 * TICK, `first drone hit at ${(first.t - t0).toFixed(3)} s`);
|
||||||
|
done(h);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('荒芜拉普兰德 S3 ③: a drone on its target attacks every attack interval of hers for ATK × its own funnel ramp; she makes no attack; neither attack nor skill damage; 缴械 does not stop it', () => {
|
||||||
|
for (const id of BOTH) {
|
||||||
|
const h = wolf(id, [{ key: 'e', pos: [10, 6] }]);
|
||||||
|
const u = h.unit(id), bb = u.def.skill.bb;
|
||||||
|
const log = watchDrones(h, u);
|
||||||
|
assert.ok(h.runUntil(() => u.skill.active, 5));
|
||||||
|
const t0 = h.b.time;
|
||||||
|
h.run(6);
|
||||||
|
h.b.applyStatus(u, 'disarm', { duration: 3, source: null });
|
||||||
|
assert.ok(u.s.flags.disarm, 'disarmed');
|
||||||
|
const td = h.b.time;
|
||||||
|
h.run(8);
|
||||||
|
assert.ok(u.skill.active);
|
||||||
|
const f = u.profile.funnel, e = h.enemies()[0];
|
||||||
|
assert.equal(h.hooksOf('attack').filter((c) => c.attacker === u && c.t >= t0).length, 0, 'no normal attack of hers during S3');
|
||||||
|
const hits = droneHits(h, u);
|
||||||
|
assert.ok(hits.length >= 3 * 8, `${hits.length} drone hits in 14 s`);
|
||||||
|
for (const c of hits) {
|
||||||
|
assert.equal(c.type, 'arts');
|
||||||
|
assert.equal(c.target, e);
|
||||||
|
assert.ok(!c.dmg.isAttack && !c.dmg.isSkill, 'PRTS: 不属于普通攻击/技能直接伤害');
|
||||||
|
}
|
||||||
|
const scale = (c) => c.amount / (u.s.atk * u.s.dmgDealtMul);
|
||||||
|
assert.equal(hits.filter((c) => Math.abs(scale(c) - f.init) < 1e-6).length, u.mem.drones.length, 'each drone starts its own ramp at init');
|
||||||
|
assert.ok(hits.every((c) => scale(c) <= f.max + 1e-9) && hits.some((c) => Math.abs(scale(c) - f.max) < 1e-6), 'up to the trait cap');
|
||||||
|
assert.ok(hits.some((c) => c.t > td && c.t < td + 3), '缴械 does not stop the drones');
|
||||||
|
// per drone: init, +delta per hit (capped), one hit per attack interval of hers (rounded up to whole ticks)
|
||||||
|
const gap = Math.ceil(u.s.interval / TICK - 1e-9) * TICK;
|
||||||
|
assert.equal(log.length, 1 + bb['attack@cnt']);
|
||||||
|
for (const L of log) {
|
||||||
|
assert.ok(L.length >= 8);
|
||||||
|
L.forEach((a, k) => {
|
||||||
|
approx(a.ramp, Math.min(f.max, f.init + k * f.delta), `hit ${k} ramp`, 1e-9);
|
||||||
|
if (k) approx(a.t - L[k - 1].t, gap, `hit ${k} gap`, 1e-6);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// the aura stays: attack@magic_atk_scale × ATK arts once per second on the enemies around the drones
|
||||||
|
const aura = h.hooksOf('damaged').filter((c) => c.source === u && (c.dmg?.tags || []).includes('drone'));
|
||||||
|
assert.ok(aura.length >= 10 && aura.every((c) => Math.abs(c.amount - u.s.atk * u.s.dmgDealtMul * bb['attack@magic_atk_scale']) < 1e-6));
|
||||||
|
assert.ok(h.hooksOf('statusApplied').filter((c) => c.status === 'fear' && c.target === e).length >= 1, '恐惧 on the catch');
|
||||||
|
done(h);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('荒芜拉普兰德 S3: every drone picks the enemy nearest to itself; a dead target sends it to the 1.5 square around it, the next lock restarts its ramp; nothing left → it circles (0.9, 1 tile/s, counter-clockwise)', () => {
|
||||||
|
for (const id of BOTH) {
|
||||||
|
const h = wolf(id, [{ key: 'weak', pos: [10, 6], rec: dummy('weak', { hp: 900 }) }, { key: 'e', pos: [12, 9] }]);
|
||||||
|
const u = h.unit(id);
|
||||||
|
const log = watchDrones(h, u);
|
||||||
|
let deadAt = null, deadPos = null;
|
||||||
|
h.b.on('death', (c) => { if (c.unit.defId === 'enemy_weak' || c.unit.defId === 'weak') { deadAt = h.b.time; deadPos = { x: c.unit.x, y: c.unit.y }; } });
|
||||||
|
assert.ok(h.runUntil(() => u.skill.active, 5));
|
||||||
|
const D = u.mem.drones;
|
||||||
|
const weak = h.enemies().find((x) => /weak/.test(x.defId)), far = h.enemies().find((x) => !/weak/.test(x.defId));
|
||||||
|
h.step(Math.round(u.def.skill.bb['attack@times'] / TICK));
|
||||||
|
assert.ok(D.every((d) => d.t === weak), 'PRTS ②: the nearest to each drone — all three take the weak one (no spreading over free targets)');
|
||||||
|
assert.ok(h.runUntil(() => deadAt != null, 20), 'the weak one falls');
|
||||||
|
h.step(1);
|
||||||
|
const f = u.profile.funnel;
|
||||||
|
for (const d of D) {
|
||||||
|
assert.equal(d.t, far, 'then the remaining enemy');
|
||||||
|
// reappeared in the square around the fallen target, then one chase step (< 4 tiles/s × 1 tick)
|
||||||
|
assert.ok(Math.max(Math.abs(d.x - deadPos.x), Math.abs(d.y - deadPos.y)) <= 0.75 + 4 * TICK + 1e-9, `drone at (${d.y.toFixed(2)}, ${d.x.toFixed(2)})`);
|
||||||
|
}
|
||||||
|
assert.ok(h.runUntil(() => log.every((L) => L.some((a) => a.target === far)), 20));
|
||||||
|
for (const L of log) approx(L.find((a) => a.target === far).ramp, f.init, 'a new target: back to init', 1e-9);
|
||||||
|
// nothing selectable: circle on its left
|
||||||
|
h.b.dealDamage(null, far, { amount: 1e9, type: 'true' });
|
||||||
|
h.step(2);
|
||||||
|
for (const d of D) {
|
||||||
|
assert.ok(d.orbit && d.phase === 'seek');
|
||||||
|
const a0 = Math.atan2(d.y - d.orbit.cy, d.x - d.orbit.cx);
|
||||||
|
approx(Math.hypot(d.x - d.orbit.cx, d.y - d.orbit.cy), 0.9, 'radius', 1e-9);
|
||||||
|
h.step(3);
|
||||||
|
const a1 = Math.atan2(d.y - d.orbit.cy, d.x - d.orbit.cx);
|
||||||
|
const turn = ((a1 - a0) % (2 * Math.PI) + 3 * Math.PI) % (2 * Math.PI) - Math.PI;
|
||||||
|
approx(turn, (3 * TICK * 1) / 0.9, 'counter-clockwise at 1.0 tiles/s', 1e-9);
|
||||||
|
}
|
||||||
|
done(h);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('荒芜拉普兰德 S3: 头狼 stage 3 reached while the skill runs releases one more drone (PRTS ① "补充浮游单元"), which spreads along her facing and attacks too', () => {
|
||||||
|
const id = 'chess_char_6_18_a';
|
||||||
|
const h = wolf(id, [{ key: 'e', pos: [10, 6] }], { carry: {} });
|
||||||
|
const u = h.unit(id), bb = u.def.skill.bb;
|
||||||
|
// hold her SP until 头狼 stage 2 (2 × interval s on the field), then cast
|
||||||
|
let armed = false;
|
||||||
|
h.b.on('tick', () => {
|
||||||
|
if (armed || !u.skill || u.skill.active) return;
|
||||||
|
if ((u.mem.wolfStage || 0) >= 2) { armed = true; u.skill.gainSp(u.skill.spCost, 'init', true); } else u.skill.sp = 0;
|
||||||
|
});
|
||||||
|
const iv = u.def.talents[0].bb.interval;
|
||||||
|
assert.ok(h.runUntil(() => u.skill.active, 2 * iv + 3));
|
||||||
|
assert.equal(u.mem.drones.length, 1 + bb['attack@cnt']);
|
||||||
|
assert.ok(h.runUntil(() => u.mem.wolfStage === 3, iv + 2));
|
||||||
|
h.step(1);
|
||||||
|
assert.ok(u.skill.active, 'stage 3 inside the skill');
|
||||||
|
const D = u.mem.drones;
|
||||||
|
assert.equal(D.length, 2 + bb['attack@cnt'], '数量+1');
|
||||||
|
const nd = D.at(-1);
|
||||||
|
assert.equal(nd.phase, 'spread');
|
||||||
|
approx(nd.hx, 1, 'along her facing', 1e-12);
|
||||||
|
assert.ok(Math.hypot(nd.x - u.x, nd.y - u.y) < 0.1, 'from her');
|
||||||
|
const tn = h.b.time;
|
||||||
|
assert.ok(h.runUntil(() => nd.rampId != null, 10), 'the new drone reaches the enemy and attacks');
|
||||||
|
assert.ok(droneHits(h, u, (c) => c.t >= tn).length >= 1);
|
||||||
|
done(h);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('荒芜拉普兰德 S3: knocked out in the middle of a tick (here by a kill hook), she deals nothing more in that tick', () => {
|
||||||
|
// the weak one dies to a drone hit with the strong one on the same spot; its death hook knocks her out at once
|
||||||
|
const h = wolf('chess_char_6_18_a', [{ key: 'w', pos: [10, 7], rec: dummy('w', { hp: 300 }) }, { key: 'e', pos: [10, 7] }]);
|
||||||
|
const u = h.unit('chess_char_6_18_a');
|
||||||
|
let ko = false;
|
||||||
|
const late = [];
|
||||||
|
h.b.on('damaged', (c) => { if (c.source === u && !u.alive) late.push((c.dmg?.tags || []).join('+')); });
|
||||||
|
h.b.on('death', (c) => { if (c.unit.side === 'enemy' && !ko) { ko = true; h.b.dealDamage(null, u, { amount: 1e9, type: 'true' }); } });
|
||||||
|
assert.ok(h.runUntil(() => ko, 30), 'a drone hit kills the weak one');
|
||||||
|
assert.equal(u.alive, false);
|
||||||
|
assert.equal(u.mem.drones, null, 'the skill ended with her');
|
||||||
|
assert.deepEqual(late, [], 'no drone / area hit from the knocked-out wolf');
|
||||||
|
done(h);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('荒芜拉普兰德 S3 facing UP / LEFT / DOWN plays like facing RIGHT (enemies at turned offsets; a target falls, so the drones reappear)', () => {
|
||||||
|
const OPEN = { id: 'open', name: 'open', rows: Array.from({ length: 19 }, () => '#' + 'r'.repeat(19) + '#'), devices: [] };
|
||||||
|
const R0 = 9, C0 = 9;
|
||||||
|
const OFFS = [[0, 2, 'weak'], [1, 4, 'e'], [-2, 3, 'e'], [3, -1, 'e']]; // facing-RIGHT offsets, no distance ties
|
||||||
|
const run = (dir) => {
|
||||||
|
const h = makeBattle({
|
||||||
|
stage: OPEN, rect: { r0: 0, r1: 18, c0: 0, c1: 20 }, seed: 5, autoFinish: false, timeLimit: 60, hooks: [],
|
||||||
|
routes: [{ motion: 'FLY', start: [18, 20], end: [18, 19], checkpoints: [] }],
|
||||||
|
defs: { enemies: { weak: dummy('weak', { hp: 900 }), e: dummy('e') } },
|
||||||
|
units: [{ chessId: 'chess_char_6_18_a', row: R0, col: C0, dir, abs: true, carryState: { sp: 999 } }],
|
||||||
|
enemies: OFFS.map(([dr, dc, key]) => {
|
||||||
|
const [ar, ac] = rotateOffset(dr, dc, dir), pos = [R0 + ar, C0 + ac];
|
||||||
|
return { key, pos, route: { motion: 'WALK', start: pos, checkpoints: [{ type: 'WAIT', time: 1e4 }], end: pos } };
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const u = h.unit('chess_char_6_18_a');
|
||||||
|
h.step(1);
|
||||||
|
const foes = h.b.enemies.slice(); // in OFFS order (a fallen one leaves battle.enemies)
|
||||||
|
assert.equal(foes.length, OFFS.length);
|
||||||
|
assert.ok(h.runUntil(() => u.skill.active, 5));
|
||||||
|
h.run(12);
|
||||||
|
done(h);
|
||||||
|
return { hp: foes.map((e) => (e.alive ? e.hp : 0)), drones: u.mem.drones.map((d) => toLocal(d.y - R0, d.x - C0, dir)) };
|
||||||
|
};
|
||||||
|
const base = run('RIGHT');
|
||||||
|
assert.equal(base.hp[0], 0, 'the weak one fell: its drones reappeared around it and picked again');
|
||||||
|
for (const dir of ['UP', 'LEFT', 'DOWN']) {
|
||||||
|
const o = run(dir);
|
||||||
|
base.hp.forEach((hp, i) => approx(o.hp[i], hp, `${dir}: enemy ${OFFS[i]} HP`, 1e-9));
|
||||||
|
base.drones.forEach(([lr, lc], i) => {
|
||||||
|
approx(o.drones[i][0], lr, `${dir}: drone ${i} local row`, 1e-6);
|
||||||
|
approx(o.drones[i][1], lc, `${dir}: drone ${i} local col`, 1e-6);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// =====================================================================================================================
|
||||||
|
// item 1 — 余 S2 厚礼上宾
|
||||||
|
|
||||||
|
/** 余 on (10,5) with S2 (`sp` carried in); parked enemies (`[key, row, col, tag?, motion?]`). */
|
||||||
|
function yuS2(chessId, foes, sp = 999, defs = undefined) {
|
||||||
|
const hold = (r, c, motion = 'WALK') => ({ motion, start: [r, c], end: [9, 2], checkpoints: [{ type: 'WAIT', time: 99 }] });
|
||||||
|
const h = makeBattle({
|
||||||
|
seed: 5, timeLimit: 120, autoFinish: false, defs,
|
||||||
|
units: [{ chessId, row: 10, col: 5, dir: 'RIGHT', skillIndex: 1, carryState: { sp } }],
|
||||||
|
enemies: foes.map(([key, r, c, tag, motion]) => ({ key, route: hold(r, c, motion), time: 0, tag })),
|
||||||
|
});
|
||||||
|
h.step(1);
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
const castOf = (h, u) => h.hooksOf('skillStart').find((c) => c.unit === u);
|
||||||
|
|
||||||
|
test('余 S2 (deliberate deviation, DESIGN §22.10): it fires with a ground enemy two tiles away and nobody hitting him — SKILL_RANGE on x-1 — and the enemy lands on his tile', () => {
|
||||||
|
for (const id of ['chess_char_6_03_a', 'chess_char_6_03_b']) {
|
||||||
|
const h = yuS2(id, [['e', 10, 7]], 999, { enemies: { e: enemyRec({ key: 'e', hp: 1e6, speed: 0, atk: 0 }) } });
|
||||||
|
const yu = h.unit(id);
|
||||||
|
assert.equal(yu.skill.rule, 'SKILL_RANGE', 'the data rule (rawRule TAKE_DAMAGE)');
|
||||||
|
const e = h.b.enemies[0];
|
||||||
|
assert.ok(yu.skill.active, 'cast in the first tick');
|
||||||
|
assert.equal(castOf(h, yu).reason, 'SKILL_RANGE');
|
||||||
|
assert.equal(yu.stats.taken, 0, 'nothing hit him');
|
||||||
|
assert.deepEqual([e.y, e.x], [yu.tileR, yu.tileC], 'pulled onto his tile');
|
||||||
|
h.step(1);
|
||||||
|
assert.equal(e.blockedBy, yu, 'and blocked there');
|
||||||
|
done(h);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('余 S2: a ranged enemy hitting him from outside x-1 no longer casts it (it was the official TAKE_DAMAGE); it fires once someone stands on x-1', () => {
|
||||||
|
// 深池暗影术师 (range 2.5) on (9,7): outside x-1 (|Δrow| + |Δcol| = 3) but in reach of him
|
||||||
|
const h = yuS2('chess_char_6_03_a', [['enemy_1168_dumage', 9, 7]]);
|
||||||
|
const yu = h.unit('chess_char_6_03_a');
|
||||||
|
assert.ok(h.runUntil(() => yu.stats.taken > 0, 10), 'it hits him');
|
||||||
|
h.run(3);
|
||||||
|
assert.equal(yu.skill.activations, 0, 'no cast from the hit');
|
||||||
|
h.spawn('enemy_1422_lrsldr', { pos: [10, 6] });
|
||||||
|
h.step(2);
|
||||||
|
assert.equal(yu.skill.activations, 1, 'an enemy steps onto x-1: cast');
|
||||||
|
done(h);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('余 S2: a leader inside x-1 is teleported onto his tile like any ground enemy (PRTS 卢西恩 / 假想敌:铳 传送抗性 无); a 自缚 leader stays', () => {
|
||||||
|
for (const id of ['chess_char_6_03_a', 'chess_char_6_03_b']) {
|
||||||
|
// (S2 empty at first: it casts the tick its SP is filled, the three leaders already on x-1)
|
||||||
|
const h = yuS2(id, [['enemy_2016_csphtm', 10, 7, 'boss'], ['enemy_9017_achunt', 11, 6, 'boss'], ['enemy_9033_acdeer', 9, 4, 'boss']], 0);
|
||||||
|
const yu = h.unit(id);
|
||||||
|
assert.ok(!yu.skill.active && !yu.skill.ready);
|
||||||
|
const [lucien, gun, sami] = ['enemy_2016_csphtm', 'enemy_9017_achunt', 'enemy_9033_acdeer'].map((k) => h.b.enemies.find((e) => e.defId === k));
|
||||||
|
assert.ok(lucien.isBoss && gun.isBoss && sami.isBoss, 'three leaders');
|
||||||
|
assert.ok(sami.s.flags.selfBound && !lucien.s.flags.selfBound && !gun.s.flags.selfBound, '“萨米的意志” is 自缚 (content/bosses.js SELF_BOUND)');
|
||||||
|
const samiAt = [sami.x, sami.y];
|
||||||
|
const fx0 = h.eventsOf('fx').length;
|
||||||
|
yu.skill.gainSp(yu.skill.spCost, 'init', true);
|
||||||
|
h.step(1);
|
||||||
|
assert.ok(yu.skill.active && yu.skill.id === 'skchr_yu_2' && castOf(h, yu).reason === 'SKILL_RANGE', 'S2 cast (SKILL_RANGE)');
|
||||||
|
for (const e of [lucien, gun]) assert.deepEqual([e.y, e.x], [yu.tileR, yu.tileC], `${e.name} teleported onto 余`);
|
||||||
|
assert.deepEqual([sami.x, sami.y], samiAt, '自缚: not a reachable target (PRTS 余 S2 备注)');
|
||||||
|
const fx = h.eventsOf('fx').slice(fx0);
|
||||||
|
assert.deepEqual(fx.filter((e) => e[1] === 'teleport').map((e) => e[4].id).sort(), [lucien.id, gun.id].sort());
|
||||||
|
assert.deepEqual(fx.filter((e) => e[1] === 'pull').map((e) => e[4].n), [2], 'one pull fx: two teleported');
|
||||||
|
h.step(1);
|
||||||
|
assert.equal(lucien.blockedBy, yu, '卢西恩 (blockable) is blocked on his tile; 铳 is 不可阻挡');
|
||||||
|
assert.equal(gun.blockedBy, null);
|
||||||
|
done(h);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('余 S2 with nobody to pull (a flyer on x-1 sets it off): the burst hits it, no teleport and no pull fx', () => {
|
||||||
|
const h = yuS2('chess_char_6_03_a', [['fly', 10, 6, null, 'FLY']], 999, { enemies: { fly: enemyRec({ key: 'fly', hp: 1e6, speed: 0, motion: 'FLY' }) } });
|
||||||
|
const yu = h.unit('chess_char_6_03_a');
|
||||||
|
const fly = h.b.enemies[0];
|
||||||
|
assert.ok(fly.isFlying && yu.skill.active, 'cast: SKILL_RANGE counts any enemy on x-1');
|
||||||
|
assert.ok(fly.hp < fly.s.maxHp, 'the burst hits air units [ASSUMED]');
|
||||||
|
const fx = h.eventsOf('fx').filter((e) => e[1] === 'teleport' || e[1] === 'pull');
|
||||||
|
assert.deepEqual(fx, [], 'a flyer is no 地面可达目标: no teleport, no pull fx');
|
||||||
|
assert.ok(!(Math.round(fly.y) === yu.tileR && Math.round(fly.x) === yu.tileC), 'it stays');
|
||||||
|
done(h);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('余 S2 on the real leader rounds: 假想敌:铳 teleported onto 余 walks on to its next patrol checkpoint; 卢西恩 is blocked there, then resumes its loop', () => {
|
||||||
|
const W = JSON.parse(fs.readFileSync(new URL('../../data/waves.json', import.meta.url), 'utf8'));
|
||||||
|
const pool = (hp) => ({ hp, maxHp: hp, damage(pid, a) { this.hp = Math.max(0, this.hp - a); } });
|
||||||
|
const X1 = [[2, 0], [1, -1], [1, 0], [1, 1], [0, -2], [0, -1], [0, 1], [0, 2], [-1, -1], [-1, 0], [-1, 1], [-2, 0]];
|
||||||
|
for (const [tplId, key, board] of [['act1autochess_h07_02', 'enemy_9017_achunt', [11, 5]], ['act2autochess_h07_05', 'enemy_2016_csphtm', [10, 7]]]) {
|
||||||
|
const tpl = W[tplId];
|
||||||
|
const h = makeBattle({ kind: 'boss', stageId: 'act2autochess_m01', waveTemplate: tpl, sharedBoss: pool(3e6), seed: 9, autoFinish: false,
|
||||||
|
units: [{ chessId: 'chess_char_6_03_a', row: board[0], col: board[1], dir: 'RIGHT', skillIndex: 1 }], setup(b) { b.enemyOverrides = tpl.overrides; } });
|
||||||
|
h.step(1);
|
||||||
|
const yu = h.unit('chess_char_6_03_a');
|
||||||
|
const L = () => h.b.enemies.find((e) => e.defId === key && e.alive);
|
||||||
|
const inX1At = (y, x) => X1.some(([dr, dc]) => Math.round(y) === yu.tileR + dr && Math.round(x) === yu.tileC + dc);
|
||||||
|
// well inside x-1 (0.1 tiles of margin: the next tick's step cannot take it out before the cast)
|
||||||
|
const deep = (e) => [[0, 0], [0.1, 0], [-0.1, 0], [0, 0.1], [0, -0.1]].every(([a, b]) => inX1At(e.y + a, e.x + b));
|
||||||
|
h.b.on('tick', () => { if (!yu.skill.active && !L()?.mem.yuTest) yu.skill.sp = 0; }); // keep S2 for the leader
|
||||||
|
assert.ok(h.runUntil(() => L() && deep(L()), 90), `${key} walks into x-1`);
|
||||||
|
const e = L();
|
||||||
|
assert.ok(e.isBoss && !e.s.flags.selfBound && e.route.legs.length > 1, 'a patrolling leader');
|
||||||
|
e.mem.yuTest = true;
|
||||||
|
const leg0 = e.route.legIdx;
|
||||||
|
yu.skill.gainSp(yu.skill.spCost, 'init', true);
|
||||||
|
h.step(1);
|
||||||
|
assert.ok(yu.skill.active, 'S2 (SKILL_RANGE)');
|
||||||
|
assert.deepEqual([e.y, e.x], [yu.tileR, yu.tileC], 'teleported onto 余');
|
||||||
|
assert.equal(e.route.pts, null, 'its path is re-planned from there');
|
||||||
|
if (key === 'enemy_9017_achunt') {
|
||||||
|
assert.ok(h.runUntil(() => e.route.legIdx !== leg0, 25), '不可阻挡: walks on to its next patrol checkpoint');
|
||||||
|
} else {
|
||||||
|
h.step(1);
|
||||||
|
assert.equal(e.blockedBy, yu, 'blocked by 余 (block +2)');
|
||||||
|
assert.ok(h.runUntil(() => e.route.legIdx !== leg0 && !(Math.round(e.y) === yu.tileR && Math.round(e.x) === yu.tileC), 30), 'blinks past and resumes its loop');
|
||||||
|
}
|
||||||
|
assert.ok(e.alive && !h.b.enemies.some((x) => x === e && x.leaked), 'never leaks');
|
||||||
|
done(h);
|
||||||
|
}
|
||||||
|
});
|
||||||
+13
-3
@@ -434,12 +434,17 @@ const ATTACK_RANGE_CHANGE = /攻击(?:范围|距离)(?:与溅射范围)?(?:扩
|
|||||||
* carry too (惊蛰, 幽灵鲨, 耶拉, 莫斯提马, 莱恩哈特). The record keeps the official row in `rawRule` (TAKE_DAMAGE) for
|
* carry too (惊蛰, 幽灵鲨, 耶拉, 莫斯提马, 莱恩哈特). The record keeps the official row in `rawRule` (TAKE_DAMAGE) for
|
||||||
* traceability; validateAll fails the build when an entry no longer meets a TAKE_DAMAGE row or its skill. 深巡 S1
|
* traceability; validateAll fails the build when an entry no longer meets a TAKE_DAMAGE row or its skill. 深巡 S1
|
||||||
* 侵袭破坏应对 keeps TAKE_DAMAGE.
|
* 侵袭破坏应对 keeps TAKE_DAMAGE.
|
||||||
|
* 余 S2 厚礼上宾 joined on 2026-10-04 (the owner's decision after GitHub issue #32 item 1, DESIGN §22.10: players saw it fire
|
||||||
|
* from a ranged hit with nobody to pull). His attack range is his own tile (0-1), so the basic strategy would not see an
|
||||||
|
* enemy he could pull: his S2 takes SKILL_RANGE on its own 技能范围 (x-1) — the official strategy of a MANUAL skill with a
|
||||||
|
* 技能范围 when no class row applies, "仅在技能范围内存在敌人(无视其不可选中)时释放技能" — `customRangeGrid` = that range.
|
||||||
*/
|
*/
|
||||||
const TRIGGER_DEVIATIONS = Object.freeze({
|
const TRIGGER_DEVIATIONS = Object.freeze({
|
||||||
chess_char_1_04_a: { skchr_udflow_2: 'DEFAULT' }, // 深巡 S2 行动能力剥夺
|
chess_char_1_04_a: { skchr_udflow_2: 'DEFAULT' }, // 深巡 S2 行动能力剥夺
|
||||||
chess_char_1_20_a: { skchr_liskam_2: 'DEFAULT' }, // 雷蛇 S2 反击电弧
|
chess_char_1_20_a: { skchr_liskam_2: 'DEFAULT' }, // 雷蛇 S2 反击电弧
|
||||||
chess_char_2_18_a: { 'skcom_atk_up[3]': 'DEFAULT', skchr_ashlok_2: 'DEFAULT' }, // 灰毫 S1 攻击力强化·γ型, S2 专注轰击
|
chess_char_2_18_a: { 'skcom_atk_up[3]': 'DEFAULT', skchr_ashlok_2: 'DEFAULT' }, // 灰毫 S1 攻击力强化·γ型, S2 专注轰击
|
||||||
chess_char_5_08_a: { skchr_horn_2: 'DEFAULT', skchr_horn_3: 'DEFAULT' }, // 号角 S2 暴风号令, S3 终极防线
|
chess_char_5_08_a: { skchr_horn_2: 'DEFAULT', skchr_horn_3: 'DEFAULT' }, // 号角 S2 暴风号令, S3 终极防线
|
||||||
|
chess_char_6_03_a: { skchr_yu_2: 'SKILL_RANGE' }, // 余 S2 厚礼上宾 (its x-1)
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -456,7 +461,7 @@ const TRIGGER_DEVIATIONS = Object.freeze({
|
|||||||
* "不通过普通攻击/治疗触发技能,仅在技能范围内存在敌人(无视其不可选中)时释放技能", customRangeGrid = the skill range;
|
* "不通过普通攻击/治疗触发技能,仅在技能范围内存在敌人(无视其不可选中)时释放技能", customRangeGrid = the skill range;
|
||||||
* - else DEFAULT (the basic strategy: ready + about to attack / heal);
|
* - else DEFAULT (the basic strategy: ready + about to attack / heal);
|
||||||
* - last, the deliberate deviations (TRIGGER_DEVIATIONS, per chess and skill): `rule` from the table, `rawRule` the
|
* - last, the deliberate deviations (TRIGGER_DEVIATIONS, per chess and skill): `rule` from the table, `rawRule` the
|
||||||
* official row.
|
* official row (a SKILL_RANGE deviation takes the skill's own range as `customRangeGrid`).
|
||||||
* @param {object} skill record from buildSkill (skillId, skillType, desc, rangeGrid)
|
* @param {object} skill record from buildSkill (skillId, skillType, desc, rangeGrid)
|
||||||
* @param {{operator?: boolean, chessId?: string}} opts operator = a chess (the 技能范围 strategy is written for 干员;
|
* @param {{operator?: boolean, chessId?: string}} opts operator = a chess (the 技能范围 strategy is written for 干员;
|
||||||
* summons keep DEFAULT); chessId = the chess's NORMAL id (TRIGGER_DEVIATIONS key)
|
* summons keep DEFAULT); chessId = the chess's NORMAL id (TRIGGER_DEVIATIONS key)
|
||||||
@@ -474,6 +479,10 @@ function resolveTrigger(ctx, char, charId, skillIdx, skill, { operator = false,
|
|||||||
}
|
}
|
||||||
const rawRule = pick ? pick.skillTriggerType : 'DEFAULT';
|
const rawRule = pick ? pick.skillTriggerType : 'DEFAULT';
|
||||||
const deviation = chessId ? TRIGGER_DEVIATIONS[chessId]?.[skill.skillId] : null;
|
const deviation = chessId ? TRIGGER_DEVIATIONS[chessId]?.[skill.skillId] : null;
|
||||||
|
if (deviation === 'SKILL_RANGE') {
|
||||||
|
if (!skill.rangeGrid) warn(`trigger deviation ${chessId} ${skill.skillId}: SKILL_RANGE without a 技能范围`);
|
||||||
|
return { rule: deviation, rawRule, customRangeGrid: skill.rangeGrid ? skill.rangeGrid.map((p) => p.slice()) : null };
|
||||||
|
}
|
||||||
if (deviation) return { rule: deviation, rawRule, customRangeGrid: null };
|
if (deviation) return { rule: deviation, rawRule, customRangeGrid: null };
|
||||||
const rule = TRIGGER_RENAME[rawRule] || rawRule;
|
const rule = TRIGGER_RENAME[rawRule] || rawRule;
|
||||||
let customRangeGrid = null;
|
let customRangeGrid = null;
|
||||||
@@ -3116,8 +3125,8 @@ function validateAll(f) {
|
|||||||
// DESIGN §22.6: only a MELEE operator is widened to every deployable tile
|
// DESIGN §22.6: only a MELEE operator is widened to every deployable tile
|
||||||
if (c.placement !== undefined && (c.placement !== 'all' || c.position !== 'MELEE')) err(`chess ${c.chessId}: placement ${c.placement} on position ${c.position}`);
|
if (c.placement !== undefined && (c.placement !== 'all' || c.position !== 'MELEE')) err(`chess ${c.chessId}: placement ${c.placement} on position ${c.position}`);
|
||||||
}
|
}
|
||||||
// the deliberate trigger deviations (DESIGN §21.29) still override an official TAKE_DAMAGE row, on the normal chess
|
// the deliberate trigger deviations (DESIGN §21.29, §22.10) still override an official TAKE_DAMAGE row, on the normal
|
||||||
// and its elite alike
|
// chess and its elite alike
|
||||||
for (const [baseId, skillsOf] of Object.entries(TRIGGER_DEVIATIONS)) {
|
for (const [baseId, skillsOf] of Object.entries(TRIGGER_DEVIATIONS)) {
|
||||||
const recs = Object.values(chess).filter((c) => c.baseId === baseId);
|
const recs = Object.values(chess).filter((c) => c.baseId === baseId);
|
||||||
if (recs.length !== 2) err(`trigger deviation ${baseId}: expected the normal and the elite record, got ${recs.length}`);
|
if (recs.length !== 2) err(`trigger deviation ${baseId}: expected the normal and the elite record, got ${recs.length}`);
|
||||||
@@ -3126,6 +3135,7 @@ function validateAll(f) {
|
|||||||
const s = (c.skills || []).find((x) => x.skillId === skillId);
|
const s = (c.skills || []).find((x) => x.skillId === skillId);
|
||||||
if (!s) err(`trigger deviation ${c.chessId}: no skill ${skillId}`);
|
if (!s) err(`trigger deviation ${c.chessId}: no skill ${skillId}`);
|
||||||
else if (s.trigger.rawRule !== 'TAKE_DAMAGE' || s.trigger.rule !== rule) err(`trigger deviation ${c.chessId} ${skillId}: ${s.trigger.rawRule} → ${s.trigger.rule}, expected TAKE_DAMAGE → ${rule}`);
|
else if (s.trigger.rawRule !== 'TAKE_DAMAGE' || s.trigger.rule !== rule) err(`trigger deviation ${c.chessId} ${skillId}: ${s.trigger.rawRule} → ${s.trigger.rule}, expected TAKE_DAMAGE → ${rule}`);
|
||||||
|
else if (rule === 'SKILL_RANGE' && (!s.rangeGrid?.length || JSON.stringify(s.trigger.customRangeGrid) !== JSON.stringify(s.rangeGrid))) err(`trigger deviation ${c.chessId} ${skillId}: SKILL_RANGE needs the skill's own range as customRangeGrid`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user