feedback5 WK: merge feedback5 (O2–O8 自选 operator kits and their engine hooks; 焰狐龙梓兰 out of the 自选 pool) — no conflict; golden unchanged (the 自选 match and the 52 diy scenarios match)

This commit is contained in:
sganggs committed 2026-10-06 00:25:35 +08:00
commit 335f3f6045
68 files changed
+14722 -148

No files matched your search

+1 -1
View File
@@ -1 +1 @@
{"units":{"char_600_cpione":{"charId":"char_600_cpione","name":"预备干员-先锋","appellation":"Reserve Operator - Vanguard","rarity":4,"profession":"PIONEER","subProfessionId":"pioneer","subProfessionName":"尖兵","position":"MELEE","nationId":null,"isNotObtainable":true,"assets":{"avatar":"char_600_cpione","avatarGolden":"char_600_cpione","portrait":"char_600_cpione_1","portraitGolden":"char_600_cpione_1","spine":"char_600_cpione","subProfIcon":"sub_pioneer_icon"},"moduleNames":{},"standsIn":["chess_char_5_01_a"],"forms":{"2/1/4/0":{"status":{"phase":2,"level":1,"skillLevel":4,"equipLevel":0},"stats":{"maxHp":1225,"atk":320,"def":272,"res":0,"cost":11,"blockCnt":2,"bat":1.05,"aspd":100,"respawnTime":70,"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":"1-1","rangeGrid":[[0,0],[0,1]],"dmgType":"phys","attackKind":"melee","projectile":"none","canHitFly":false,"targetPriority":null,"trait":{"desc":"能够阻挡两个敌人","descRaw":"能够阻挡两个敌人","bb":{},"bbStr":{},"rangeGrid":null},"skills":[{"skillId":"skcom_charge_cost[1]","iconId":"skcom_charge_cost[1]","name":"冲锋号令·α型","level":4,"desc":"立即获得6点部署费用","descRaw":"立即获得<@ba.vup>6</>点部署费用","skillType":"AUTO","durationType":"NONE","duration":0,"spType":"INCREASE_WITH_TIME","spCost":27,"initSp":3,"maxChargeTime":1,"increment":1,"bb":{"cost":6},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skcom_charge_cost","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":0,"overrideTokenKey":null},{"skillId":"skcom_charge_cost[2]","iconId":"skcom_charge_cost[2]","name":"冲锋号令·β型","level":4,"desc":"立即获得9点部署费用","descRaw":"立即获得<@ba.vup>9</>点部署费用","skillType":"AUTO","durationType":"NONE","duration":0,"spType":"INCREASE_WITH_TIME","spCost":36,"initSp":9,"maxChargeTime":1,"increment":1,"bb":{"cost":9},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skcom_charge_cost","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":1,"overrideTokenKey":null},{"skillId":"skcom_charge_cost[3]","iconId":"skcom_charge_cost[3]","name":"冲锋号令·γ型","level":4,"desc":"立即获得12点部署费用","descRaw":"立即获得<@ba.vup>12</>点部署费用","skillType":"AUTO","durationType":"NONE","duration":0,"spType":"INCREASE_WITH_TIME","spCost":41,"initSp":12,"maxChargeTime":1,"increment":1,"bb":{"cost":12},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skcom_charge_cost","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":2,"overrideTokenKey":null}],"talents":[{"index":0,"name":"攻击提升","desc":"攻击力+8%","descRaw":"攻击力+8%","bb":{"atk":0.08},"bbStr":{},"rangeGrid":null,"tokenKey":null,"hidden":false,"fromModule":false}],"displayTokens":[],"tokens":[]},"2/60/7/1":{"status":{"phase":2,"level":60,"skillLevel":7,"equipLevel":1},"stats":{"maxHp":1674,"atk":380,"def":322,"res":0,"cost":11,"blockCnt":2,"bat":1.05,"aspd":100,"respawnTime":70,"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":"1-1","rangeGrid":[[0,0],[0,1]],"dmgType":"phys","attackKind":"melee","projectile":"none","canHitFly":false,"targetPriority":null,"trait":{"desc":"能够阻挡两个敌人","descRaw":"能够阻挡两个敌人","bb":{},"bbStr":{},"rangeGrid":null},"skills":[{"skillId":"skcom_charge_cost[1]","iconId":"skcom_charge_cost[1]","name":"冲锋号令·α型","level":7,"desc":"立即获得6点部署费用","descRaw":"立即获得<@ba.vup>6</>点部署费用","skillType":"AUTO","durationType":"NONE","duration":0,"spType":"INCREASE_WITH_TIME","spCost":25,"initSp":6,"maxChargeTime":1,"increment":1,"bb":{"cost":6},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skcom_charge_cost","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":0,"overrideTokenKey":null},{"skillId":"skcom_charge_cost[2]","iconId":"skcom_charge_cost[2]","name":"冲锋号令·β型","level":7,"desc":"立即获得9点部署费用","descRaw":"立即获得<@ba.vup>9</>点部署费用","skillType":"AUTO","durationType":"NONE","duration":0,"spType":"INCREASE_WITH_TIME","spCost":33,"initSp":10,"maxChargeTime":1,"increment":1,"bb":{"cost":9},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skcom_charge_cost","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":1,"overrideTokenKey":null},{"skillId":"skcom_charge_cost[3]","iconId":"skcom_charge_cost[3]","name":"冲锋号令·γ型","level":7,"desc":"立即获得12点部署费用","descRaw":"立即获得<@ba.vup>12</>点部署费用","skillType":"AUTO","durationType":"NONE","duration":0,"spType":"INCREASE_WILine truncated
{"units":{"char_600_cpione":{"charId":"char_600_cpione","name":"预备干员-先锋","appellation":"Reserve Operator - Vanguard","rarity":4,"profession":"PIONEER","subProfessionId":"pioneer","subProfessionName":"尖兵","position":"MELEE","nationId":null,"isNotObtainable":true,"assets":{"avatar":"char_600_cpione","avatarGolden":"char_600_cpione","portrait":"char_600_cpione_1","portraitGolden":"char_600_cpione_1","spine":"char_600_cpione","subProfIcon":"sub_pioneer_icon"},"moduleNames":{},"standsIn":["chess_char_5_01_a"],"forms":{"2/1/4/0":{"status":{"phase":2,"level":1,"skillLevel":4,"equipLevel":0},"stats":{"maxHp":1225,"atk":320,"def":272,"res":0,"cost":11,"blockCnt":2,"bat":1.05,"aspd":100,"respawnTime":70,"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":"1-1","rangeGrid":[[0,0],[0,1]],"dmgType":"phys","attackKind":"melee","projectile":"none","canHitFly":false,"targetPriority":null,"trait":{"desc":"能够阻挡两个敌人","descRaw":"能够阻挡两个敌人","bb":{},"bbStr":{},"rangeGrid":null},"skills":[{"skillId":"skcom_charge_cost[1]","iconId":"skcom_charge_cost[1]","name":"冲锋号令·α型","level":4,"desc":"立即获得6点部署费用","descRaw":"立即获得<@ba.vup>6</>点部署费用","skillType":"AUTO","durationType":"NONE","duration":0,"spType":"INCREASE_WITH_TIME","spCost":27,"initSp":3,"maxChargeTime":1,"increment":1,"bb":{"cost":6},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skcom_charge_cost","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":0,"overrideTokenKey":null},{"skillId":"skcom_charge_cost[2]","iconId":"skcom_charge_cost[2]","name":"冲锋号令·β型","level":4,"desc":"立即获得9点部署费用","descRaw":"立即获得<@ba.vup>9</>点部署费用","skillType":"AUTO","durationType":"NONE","duration":0,"spType":"INCREASE_WITH_TIME","spCost":36,"initSp":9,"maxChargeTime":1,"increment":1,"bb":{"cost":9},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skcom_charge_cost","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":1,"overrideTokenKey":null},{"skillId":"skcom_charge_cost[3]","iconId":"skcom_charge_cost[3]","name":"冲锋号令·γ型","level":4,"desc":"立即获得12点部署费用","descRaw":"立即获得<@ba.vup>12</>点部署费用","skillType":"AUTO","durationType":"NONE","duration":0,"spType":"INCREASE_WITH_TIME","spCost":41,"initSp":12,"maxChargeTime":1,"increment":1,"bb":{"cost":12},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skcom_charge_cost","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":2,"overrideTokenKey":null}],"talents":[{"index":0,"name":"攻击提升","desc":"攻击力+8%","descRaw":"攻击力+8%","bb":{"atk":0.08},"bbStr":{},"rangeGrid":null,"tokenKey":null,"hidden":false,"fromModule":false}],"displayTokens":[],"tokens":[]},"2/60/7/1":{"status":{"phase":2,"level":60,"skillLevel":7,"equipLevel":1},"stats":{"maxHp":1674,"atk":380,"def":322,"res":0,"cost":11,"blockCnt":2,"bat":1.05,"aspd":100,"respawnTime":70,"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":"1-1","rangeGrid":[[0,0],[0,1]],"dmgType":"phys","attackKind":"melee","projectile":"none","canHitFly":false,"targetPriority":null,"trait":{"desc":"能够阻挡两个敌人","descRaw":"能够阻挡两个敌人","bb":{},"bbStr":{},"rangeGrid":null},"skills":[{"skillId":"skcom_charge_cost[1]","iconId":"skcom_charge_cost[1]","name":"冲锋号令·α型","level":7,"desc":"立即获得6点部署费用","descRaw":"立即获得<@ba.vup>6</>点部署费用","skillType":"AUTO","durationType":"NONE","duration":0,"spType":"INCREASE_WITH_TIME","spCost":25,"initSp":6,"maxChargeTime":1,"increment":1,"bb":{"cost":6},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skcom_charge_cost","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":0,"overrideTokenKey":null},{"skillId":"skcom_charge_cost[2]","iconId":"skcom_charge_cost[2]","name":"冲锋号令·β型","level":7,"desc":"立即获得9点部署费用","descRaw":"立即获得<@ba.vup>9</>点部署费用","skillType":"AUTO","durationType":"NONE","duration":0,"spType":"INCREASE_WITH_TIME","spCost":33,"initSp":10,"maxChargeTime":1,"increment":1,"bb":{"cost":9},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skcom_charge_cost","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":1,"overrideTokenKey":null},{"skillId":"skcom_charge_cost[3]","iconId":"skcom_charge_cost[3]","name":"冲锋号令·γ型","level":7,"desc":"立即获得12点部署费用","descRaw":"立即获得<@ba.vup>12</>点部署费用","skillType":"AUTO","durationType":"NONE","duration":0,"spType":"INCREASE_WILine truncated
+1 -1
View File
@@ -1 +1 @@
{"version":1,"lang":"en","meta":{"generator":"tools/build-i18n.mjs","source":{"id":"assets","label":"ArknightsAssets/ArknightsGamedata (en)","home":"https://github.com/ArknightsAssets/ArknightsGamedata","season":"act2autochess"},"fallback":"tools/i18n/fallback-remake.json (the remake), tools/i18n/fallback-pr70.json (PR #70 by @YuriRestia), public/i18n/en.json","coverage":{"bands":{"texts":186,"translated":186,"official":186,"pct":100},"bonds":{"texts":138,"translated":138,"official":138,"pct":100},"bosses":{"texts":66,"translated":66,"official":66,"pct":100},"choices":{"texts":679,"translated":679,"official":679,"pct":100},"config":{"texts":110,"translated":110,"official":103,"pct":100},"effects":{"texts":1083,"translated":1083,"official":1083,"pct":100},"emotes":{"texts":41,"translated":41,"official":3,"pct":100},"enemies":{"texts":1734,"translated":1734,"official":1734,"pct":100},"factions":{"texts":14,"translated":14,"official":14,"pct":100},"garrisons":{"texts":747,"translated":747,"official":747,"pct":100},"items":{"texts":582,"translated":582,"official":570,"pct":100},"modules":{"texts":2494,"translated":2445,"official":2427,"pct":98},"operators":{"texts":876,"translated":852,"official":844,"pct":97.3},"skills":{"texts":5673,"translated":5439,"official":5439,"pct":95.9},"stages":{"texts":1124,"translated":1124,"official":1122,"pct":100},"talents":{"texts":4318,"translated":4107,"official":4099,"pct":95.1},"tokens":{"texts":442,"translated":442,"official":441,"pct":100},"traits":{"texts":1876,"translated":1860,"official":1860,"pct":99.1}},"totals":{"texts":22183,"translated":21649,"official":21555,"pct":97.6}},"names":{"\"炎佑\"":"Yan's Protection","Dor-1号失败品":"Dor-1 Failure","Dor-α号失败品":"Dor-α Failure","Dor-β号复制体":"Dor-β Replica","M3茧甲":"M3 Cocoon Shell","W·悬赏":"W/Bounty","“变形者集群”":"'Damazti Cluster'","“墓碑”":"'Gravestone'","“墓碑”·悬赏":"'Gravestone'/Bounty","“复仇者”":"'Hateful Avenger'","“复仇者”·悬赏":"'Hateful Avenger'/Bounty","“小自在”":"'Freeling'","“巨大的丑东西”":"'The Big Ugly Thing'","“巨大的丑东西”·悬赏":"'The Big Ugly Thing'/Bounty","“帝国的甲胄”":"'Armor of the Empire'","“庞贝”":"'Pompeii'","“庞贝”·悬赏":"'Pompeii'/Bounty","“斩胄之剑”":"'Armor Cleaver'","“灵幛”":"Sudaram","“独轮车玩具”":"'Unicycle Toy'","“破胄之锤”":"'Armor Crusher'","“碎铳之簧”":"'Gunshatter'","“神秘顾客”":"'Mystery Customer'","“耀阳”":"'Blazing Sun'","“自在”":"'Free'","“自在”·悬赏":"'Free'/Bounty","“萨科塔之眼”":"'Eyes of the Sankta'","“萨科塔之眼”·悬赏":"'Eyes of the Sankta'/Bounty","“萨科塔之翼”":"'Wings of the Sankta'","“萨科塔昂首”":"'Pride of the Sankta'","“萨米的意志”":"'Samivilinn,' the Resolution of the Very North","“裂管之音”/“断弦之音”":"'Echoes of Cracked Pipe'/'Echoes of Broken Strings'","“越长尘”":"'Sand Voyager'","“遗弃者”":"'Forsaken One'","“遗弃者”·悬赏":"'Forsaken One'/Bounty","“邪魔的利刃”":"'Demon's Blade'","“邪魔的利刃”·悬赏":"'Demon's Blade'/Bounty","“鼠王”·悬赏":"'Rat King'/Bounty","不屈":"Resilient","不屈弹射器":"Tough Launcher","不祥幻影":"Ominous Apparition","不稳定要素":"Unstable Element","业余竞演者":"Amateur Performer","业务指标":"Work Quota","临时收音师":"Audio Technician","主角阵营角色":"Hero's Sidekick","乌尔比安":"Ulpianus","乌顶巨角卢鲁":"Blackhorn Rhuul","九流之缘":"Streetwise Ties","人事部文档":"HR File","人才盲盒":"Talent Blind Box","仇白":"Qiubai","从不混淆的方向":"Undeviating Course","令":"Ling","以己之长":"Personal Power","伊内丝":"Ines","伊利昂的木驮兽":"Wooden Burdenbeast of Ilion","伊奥莱塔·罗素":"Ioleta Russell","伊芙利特":"Ifrit","休眠子裔":"Dormant Heir","休谟斯":"Humus","休露丝":"Sciurus","众志合一":"Wills As One","优等生":"Top Student","传奇重犯":"Infamous Recidivist","伪装服":"Disguise Outfit","伺夜":"Vigil","余":"Yu","佩佩":"Pepe","俗心":"Vernacularism","保护目标(冻结状态)":"Protection Objective (Frozen)","信仰搅拌机":"Sankta Miksaparato","信标":"Transmitter","假想敌:再生":"OpFor: Rebirth","假想敌:弦":"OpFor: String","假想敌:淤困":"OpFor: Quagmire","假想敌:管":"OpFor: Pipe","假想敌:胄":"OpFor: Armor","假想敌:蚀裂":"OpFor: Fissure","假想敌:铳":"OpFor: Gun","假想敌:镜膜":"OpFor: Mirror","假想敌:骨刺":"OpFor: Bonespur","假想敌:黑云":"OpFor: BlackCloud","假日威龙陈":"Ch'en the Holungday","傀影":"Phantom","催泪瓦斯":"Teargas Can","元核孽生者":"Nucleic Maleficent","元核孽生者·悬赏":"Nucleic Maleficient/Bounty","克莱门莎":"Clementia","冒失的小弟":"Careless Underling","军用猎狗":"Military Hound","冰爆源石虫":"ILine truncated
{"version":1,"lang":"en","meta":{"generator":"tools/build-i18n.mjs","source":{"id":"assets","label":"ArknightsAssets/ArknightsGamedata (en)","home":"https://github.com/ArknightsAssets/ArknightsGamedata","season":"act2autochess"},"fallback":"tools/i18n/fallback-remake.json (the remake), tools/i18n/fallback-pr70.json (PR #70 by @YuriRestia), public/i18n/en.json","coverage":{"bands":{"texts":186,"translated":186,"official":186,"pct":100},"bonds":{"texts":138,"translated":138,"official":138,"pct":100},"bosses":{"texts":66,"translated":66,"official":66,"pct":100},"choices":{"texts":679,"translated":679,"official":679,"pct":100},"config":{"texts":110,"translated":110,"official":103,"pct":100},"effects":{"texts":1083,"translated":1083,"official":1083,"pct":100},"emotes":{"texts":41,"translated":41,"official":3,"pct":100},"enemies":{"texts":1734,"translated":1734,"official":1734,"pct":100},"factions":{"texts":14,"translated":14,"official":14,"pct":100},"garrisons":{"texts":747,"translated":747,"official":747,"pct":100},"items":{"texts":582,"translated":582,"official":570,"pct":100},"modules":{"texts":2482,"translated":2437,"official":2419,"pct":98.2},"operators":{"texts":873,"translated":851,"official":843,"pct":97.5},"skills":{"texts":5646,"translated":5439,"official":5439,"pct":96.3},"stages":{"texts":1124,"translated":1124,"official":1122,"pct":100},"talents":{"texts":4297,"translated":4107,"official":4099,"pct":95.6},"tokens":{"texts":442,"translated":442,"official":441,"pct":100},"traits":{"texts":1870,"translated":1854,"official":1854,"pct":99.1}},"totals":{"texts":22114,"translated":21634,"official":21540,"pct":97.8}},"names":{"\"炎佑\"":"Yan's Protection","Dor-1号失败品":"Dor-1 Failure","Dor-α号失败品":"Dor-α Failure","Dor-β号复制体":"Dor-β Replica","M3茧甲":"M3 Cocoon Shell","W·悬赏":"W/Bounty","“变形者集群”":"'Damazti Cluster'","“墓碑”":"'Gravestone'","“墓碑”·悬赏":"'Gravestone'/Bounty","“复仇者”":"'Hateful Avenger'","“复仇者”·悬赏":"'Hateful Avenger'/Bounty","“小自在”":"'Freeling'","“巨大的丑东西”":"'The Big Ugly Thing'","“巨大的丑东西”·悬赏":"'The Big Ugly Thing'/Bounty","“帝国的甲胄”":"'Armor of the Empire'","“庞贝”":"'Pompeii'","“庞贝”·悬赏":"'Pompeii'/Bounty","“斩胄之剑”":"'Armor Cleaver'","“灵幛”":"Sudaram","“独轮车玩具”":"'Unicycle Toy'","“破胄之锤”":"'Armor Crusher'","“碎铳之簧”":"'Gunshatter'","“神秘顾客”":"'Mystery Customer'","“耀阳”":"'Blazing Sun'","“自在”":"'Free'","“自在”·悬赏":"'Free'/Bounty","“萨科塔之眼”":"'Eyes of the Sankta'","“萨科塔之眼”·悬赏":"'Eyes of the Sankta'/Bounty","“萨科塔之翼”":"'Wings of the Sankta'","“萨科塔昂首”":"'Pride of the Sankta'","“萨米的意志”":"'Samivilinn,' the Resolution of the Very North","“裂管之音”/“断弦之音”":"'Echoes of Cracked Pipe'/'Echoes of Broken Strings'","“越长尘”":"'Sand Voyager'","“遗弃者”":"'Forsaken One'","“遗弃者”·悬赏":"'Forsaken One'/Bounty","“邪魔的利刃”":"'Demon's Blade'","“邪魔的利刃”·悬赏":"'Demon's Blade'/Bounty","“鼠王”·悬赏":"'Rat King'/Bounty","不屈":"Resilient","不屈弹射器":"Tough Launcher","不祥幻影":"Ominous Apparition","不稳定要素":"Unstable Element","业余竞演者":"Amateur Performer","业务指标":"Work Quota","临时收音师":"Audio Technician","主角阵营角色":"Hero's Sidekick","乌尔比安":"Ulpianus","乌顶巨角卢鲁":"Blackhorn Rhuul","九流之缘":"Streetwise Ties","人事部文档":"HR File","人才盲盒":"Talent Blind Box","仇白":"Qiubai","从不混淆的方向":"Undeviating Course","令":"Ling","以己之长":"Personal Power","伊内丝":"Ines","伊利昂的木驮兽":"Wooden Burdenbeast of Ilion","伊奥莱塔·罗素":"Ioleta Russell","伊芙利特":"Ifrit","休眠子裔":"Dormant Heir","休谟斯":"Humus","休露丝":"Sciurus","众志合一":"Wills As One","优等生":"Top Student","传奇重犯":"Infamous Recidivist","伪装服":"Disguise Outfit","伺夜":"Vigil","余":"Yu","佩佩":"Pepe","俗心":"Vernacularism","保护目标(冻结状态)":"Protection Objective (Frozen)","信仰搅拌机":"Sankta Miksaparato","信标":"Transmitter","假想敌:再生":"OpFor: Rebirth","假想敌:弦":"OpFor: String","假想敌:淤困":"OpFor: Quagmire","假想敌:管":"OpFor: Pipe","假想敌:胄":"OpFor: Armor","假想敌:蚀裂":"OpFor: Fissure","假想敌:铳":"OpFor: Gun","假想敌:镜膜":"OpFor: Mirror","假想敌:骨刺":"OpFor: Bonespur","假想敌:黑云":"OpFor: BlackCloud","假日威龙陈":"Ch'en the Holungday","傀影":"Phantom","催泪瓦斯":"Teargas Can","元核孽生者":"Nucleic Maleficent","元核孽生者·悬赏":"Nucleic Maleficient/Bounty","克莱门莎":"Clementia","冒失的小弟":"Careless Underling","军用猎狗":"Military Hound","冰爆源石虫":Line truncated
+3 -3
View File
@@ -567,7 +567,7 @@ Glyph legend (`rows`):
`chess 266 (112 visible; 283 selectable skills over the visible chess, 184 module choices over 129 goldens; 74 PRESET / 55 NORMAL / 4 DIY base chess)`, `bonds 23`, `garrisons 249 (43 effect keys)`, `items 115`, `bands 40`, `effects 361`,
`enemies 249`, `factions 67 entries`, `waves 38`, `stages 11 (8 active)`, `bosses 10`, `tokens 22`, `choice events 109`,
`bounty cards 129`, `tactic cards 43`, `backups: 89 units (17 stand-ins, 72 owned-6★ picks; 259 forms), 38 自选 summons, 4 DIY slots, 15 / 9 prototype picks (tier 5 / 6), 72 owned-6★ picks (6 collab operators excluded)`.
`bounty cards 129`, `tactic cards 43`, `backups: 88 units (17 stand-ins, 71 owned-6★ picks; 256 forms), 38 自选 summons, 4 DIY slots, 15 / 9 prototype picks (tier 5 / 6), 71 owned-6★ picks (7 collab operators excluded)`.
## 17. Integrity guarantees (checked by the builder and `test/data.test.js`)
@@ -618,7 +618,7 @@ server/match/player/diy.js, docs/META.md §3). The rules in the data (activity_t
character takes S2 on one chess and S3 on another), module `backup.uniEquipId` (null at tiers 3–4 and for every 4★, so
even their elite form has none) and potential 0 (`potRank` 0 on all 55).
`units[charId]` — first the 17 stand-ins, then the 72 owned-6★ 自选 picks (`diy.ownedPool`); no unit for a PRESET or DIY
`units[charId]` — first the 17 stand-ins, then the 71 owned-6★ 自选 picks (`diy.ownedPool`); no unit for a PRESET or DIY
chess:
| Field | Example (`char_611_acnipe`) | Meaning |
@@ -681,7 +681,7 @@ operator (自选)"). (`shared/standIn.js diyRecord(slot, charId, backups, sel)`
| `prototypes[tier]` | `{"5":[…15],"6":[…9]}` | the legal prototype picks: the nine 6★ at both tiers, at tier 5 also the six 4★ that are not 先锋 / 特种 ("第5阶可额外从6名四星原型干员(先锋、特种职业除外)中选取"; `DIY_EXTRA_PROTOTYPES`). A prototype may fill a tier-5 and a tier-6 slot ("原型干员可于5、6阶之间重复选取") |
| `locked[tier][charId]` | `{"skillIndex":2,"uniEquipId":"uniequip_002_acguad","from":["chess_char_5_06_a","chess_char_5_13_a"]}` | the skill and module a prototype carries in a slot of that tier — "技能携带规则与系统补位时一致" (PRTS 卫戍协议), read as [ASSUMED] (the owner's decision of 2026-10-05) the selection of its 补位 rows at that tier (`from`): the eight 6★ elites S3 with their own module, 领主·Sharp S1, the reserves S3 without a module; 预备干员-医疗 has no tier-5 row: S3 by analogy (`from` `[]`, `DIY_PROTOTYPE_FALLBACK_SKILL`) |
| `ownedPool[]` | 72 charIds | the owned 6★ a player may slot: obtainable, rarity = the requirement, and no chess names it — hidden chess included ("不可甄选加入已在名单中的固定干员"); each at most once per roster ("玩家已拥有干员不可重复选取"). Not the collab operators (`excluded`) |
| `excluded[]` | `["char_456_ash","char_1029_yato2","char_4123_ela","char_4141_marcil","char_4182_oblvns","char_4217_makoto"]` | the 6 obtainable 6★ outside the pool whose `mainPower` / `subPower` team is a collab (`DIY_EXCLUDED_TEAMS`: rainbow, action4, mujica, sees, laios — 灰烬, 麒麟R夜刀, 艾拉, 玛露西尔, 丰川祥子, 结城理): left out of the data and the pool by the owner's decision of 2026-10-05 (copyright); the excel does not exclude them |
| `excluded[]` | `["char_456_ash","char_1029_yato2","char_1048_orchd2","char_4123_ela","char_4141_marcil","char_4182_oblvns","char_4217_makoto"]` | the 7 obtainable 6★ outside the pool that come from a 联动寻访 — a collab team in `mainPower` / `subPower` (`DIY_EXCLUDED_TEAMS`: rainbow, action4, mujica, sees, laios — 灰烬, 麒麟R夜刀, 艾拉, 玛露西尔, 丰川祥子, 结城理) or a collab series in `displayNumber` (`DIY_EXCLUDED_NUMBER_PREFIXES` MH / RS / AM / PS / DD — also 焰狐龙梓兰 MH05, whose team reserve6 names no collab): left out of the data and the pool by the owner's decision of 2026-10-05 (copyright); the excel does not exclude them |
| `operators[charId]` | `{"name":"煌","rarity":6,"profession":"WARRIOR","subProfessionId":"centurion","obtainable":true,"powers":["rhodes","elite","yan","victoria"],"bonds":["yanShip","victoriaShip"]}` | every pick (owned pool + prototypes): `powers` = the `nationId` / `groupId` / `teamId` of `mainPower` and of every `subPower` (隐藏势力); `bonds` = the core bonds whose `powerIdList` meets them — one or several — else `economy.fallbackBondId` 协防干员 ("甄选加入的干员会根据其实际阵营所属分配核心盟约,若没有可匹配的则改为分配协防干员盟约"); every prototype gets `["emptyShip"]` |
`tokens[tokenId]` — the summons of the owned picks (38 tokens of 27 operators): a tokens.json record (§14: name, text,
+1 -1
View File
@@ -112,7 +112,7 @@ player sees the current Chinese text, never an outdated number).
with `_h`, a 2-character hash of each replaced Chinese string; `names` maps every record-level Chinese name to English
(server messages and ticker lines name operators / items / bonds in Chinese).
- **Coverage** (build of 2026-10-05 with the 自选 data, 22 183 texts): 97.6 % translated, 97.2 % from the official EN
data. What stays Chinese is CN-only content the EN client does not have yet: some of the 72 owned 6★ of the 自选
data. What stays Chinese is CN-only content the EN client does not have yet: some of the 71 owned 6★ of the 自选
pool (names, skills, talents) and some newer modules (before the 自选 data: 14 931 texts, 99.9 %). Per kind in `.cache/build-i18n-report.json` (also `missing`:
every untranslated text) and in `meta.coverage`.
- **Client**: `data.setLocale(lang)` downloads the overlay once (~1.5 MB, ~190 KB gzipped) and from then on `get`,
+20 -8
View File
@@ -509,12 +509,14 @@ per source — DoTs and slows do that on purpose; a damage-taken / DEF / RES mod
## 3. Buffs, mods, statuses
`battle.addBuff(unit, { key, duration=Infinity, refresh='replace'|'extend'|'stack'|'independent'|'keep', stacks, maxStacks,
mods, flags, onTick(ctx), interval, onExpire(ctx), onRemove(ctx), tags, shield, shieldHits, persist, visible, data, allowDead })`
mods, flags, onTick(ctx), interval, onExpire(ctx), onRemove(ctx), tags, shield, shieldHits, shieldType, persist, visible, data, allowDead })`
- `replace`: new instance replaces the old; `extend`: keep the longer remaining time, take the new mods; `stack`: +stacks
up to maxStacks, timer reset; `independent`: separate timers, at most `maxStacks` alive (oldest dropped); `keep`: ignore.
- Additive mods scale with stacks (`value × stacks`), `*Mul` mods multiply (`value ^ stacks`).
- `onTick({battle, unit, buff, dt})` every tick, or every `interval` s. `persist: true` survives death/redeploy.
- `shield` = HP absorbed (consumed, buff removed when empty); `shieldHits` = number of damage instances fully negated.
- `shield` = HP absorbed (consumed, buff removed when empty); `shieldHits` = number of damage instances fully negated;
`shieldType` ('phys' | 'arts' | 'true' | 'elemental') = a 屏障 that absorbs that damage type only (夜莺 S2 法术护盾 "能吸收…
法术伤害"); none = every type (PRTS 术语释义 屏障 "若无特殊说明,屏障可吸收全种类伤害"). Shields are spent oldest first.
- `visible: true` emits `['status', id, key, 1/0]` client events. `battle.removeBuff(unit, key|buff)`.
**Mod keys** — additive: `atkFlat atkPct defFlat defPct hpFlat hpPct resFlat aspd batPct blockCnt rangeExtend
@@ -586,7 +588,7 @@ of coverage per 3 s), kept because the current wording no longer says so (feedba
| `silence` | no skill activation | – |
| `fear` (恐惧) | 无法被阻挡并四散逃跑: enemy cannot attack, is unblockable (released) and leaves its route: it runs between random checkpoints of the fan away from the source (§1.2 恐惧 movement; no source / itself ⇒ inside its own tile) | – |
| `tremble` (战栗) | 被阻挡后无法进行普通攻击: no normal attack **while blocked** (abilities still fire) | – |
| `palsy` (麻痹) | each stack cancels one enemy normal attack (max 3, lasts until consumed); refused by 麻痹免疫 (data `palsyImmune`) | stacks, default 1 |
| `palsy` (麻痹) | each stack cancels one enemy normal attack (max 3, lasts until consumed); refused by 麻痹免疫 (data `palsyImmune`); each cancel fires the hook `palsyTrigger` (§5) | stacks, default 1 |
| `disarm` | no normal attacks | – |
| `stealth` / `reveal` | 隐匿: untargetable unless blocked (an ally: only the enemy it blocks attacks it, and an enemy's area effects and buff auras skip it even when it blocks that enemy — `targeting.js areaSelectable` / `auraSelectable`, GitHub #97, owner 2026-10-04; 0.1.2 made an exception for that blocker) / cancels stealth. An enemy's 隐匿 also stays off after a block: each block's end (`Battle._stealthSwitch`, every release path) switches each of its 隐匿 sources off for `STEALTH_RESTORE` (3) s — PRTS 作战机制 §隐匿 "不被阻挡的3秒后重新进入隐匿" — or the source's own "(解除阻挡N秒后恢复)" (buff `data.stealthRestore`: 0 s for 业余竞演者, 节日爵士乐手, 假想敌:骨刺, 流泪小子, 访问团强攻冠军 and 清明's veil, 1 s for the 家族灭迹人); a new block inside it lifts it again and its end restarts the window; our operators' 隐匿 / 迷彩 never lift by blocking (DESIGN §22.8). `targeting.js enemyStealthed` is the one test: the b.snap stealth bit is set only while its 隐匿 is on (drawn solid otherwise); an operator's radius area damage (`foesInRadius`) skips it too (PRTS 作战机制 §AOE伤害判定 "对攻击范围内的每个可以被选中的敌人进行判定"; until 0.1.1 the splash still hit it) | – |
| `camou` (迷彩) | an ally's camouflage (ba.camou "不阻挡时不成为敌方普通攻击的目标(无法躲避溅射类攻击)"): like `stealth` for enemy targeting (only the enemy it blocks attacks it) — but an enemy's splash and other area effects still hit it (`areaSelectable` does not check it) — and on screen (b.snap stealth bit, `snapshot.js flagsOf`), but not 隐匿 for 隐匿-conditions (叙拉古, 家族徽章) and under its own buff keys. 忍冬 S3 (key `vulpis:camou`, until her next cast), 寒芒克洛丝 S1 | – |
@@ -678,7 +680,9 @@ PRTS 异常效果: 无法选择 effects "仅在选择时生效"); checked before
with `ceil(final) ≥ BOSS_HIT_LIMIT` (300000, shared/constants.js) is cancelled whole: returns 0 before shields (阿利斯泰尔's
`boss:vest` barrier stays untouched; a `hit`-step block such as 假想敌:再生's aura acts earlier) [ASSUMED order], no HP /
pool loss, no credit or stats, no `dmg` event, no `damaged` / `fatal` / kill; an fx `hitCap` `{ id, n }` marks it and
draws nothing; research 11) → shields → HP loss
draws nothing; research 11) → shields (a typed one — buff `shieldType` — only for its damage type) → **`hpDamage`** (what
passed the shields; a handler may only lower `amount` — the 伤判效果 that act after a barrier: 煌's 紧急除颤 HP floor, PRTS
备注 "该伤害减少(伤害值-煌当前生命值+煌最大生命值×50%)点"; 左乐's 庇护 re-applied after his 行险 barrier; never for a 流失) → HP loss
(boss units: routed to `sharedBoss.damage(playerId, amount)`; a pool left under 1 HP is emptied) → if HP ≤ 0: **`fatal`** (`ctx.prevented = true` keeps the
unit at ≥ 1 HP) → **`damaged`** → SP-on-hurt / TAKE_DAMAGE → `kill` + `death`.
@@ -735,6 +739,7 @@ registration order. `battle.off(handle)` / `battle.off(name, fn)` / `battle.offO
| `hit` | `{ source, target, dmg, credit }` | before mitigation; mutate `dmg` (not fired for gauge fills — see `elementHit`). `source` may be null (terrain; 无来源 `dmg.sourceless` bursts, whose `credit` names the unit credited) |
| `elementHit` | `{ source, target, dmg }` | before a gauge fill (`dmg.type === 'element'`); mutate `dmg.amount`/`dmg.mul`, set `dmg.cancel` |
| `damaged` | `{ source, target, amount, type, dmg, credit }` | after application (`amount` may be 0 when shielded); element fills too (with their source); 无来源: `source` null, `credit` set |
| `hpDamage` | `{ source, target, amount, dmg, credit }` | a damage instance after shields, before the HP loss (§4; not a 流失): lower `amount` only (a raise is ignored) — HP floors / reductions ordered after a barrier (kits/ops/op-huang.js, op-zuole.js) |
| `heal` | `{ source, target, amount, opts }` | mutable `amount` |
| `fatal` | `{ unit, source, credit, dmg, amount, prevented }` | HP would reach 0 — set `prevented` (substitutes, kit savers, 不死 / 复活 items, 埃芒加德; 不屈 is a `death` hook). Fired by every HP loss of a unit without a boss pool — hits of any type, element bursts, 无来源 damage, `loseHp` 流失. Order: kits' own savers (10 … −60) → items' 不死 (坚固维式重锤 — once per deployment: `items/battle.js deploymentOf`, a key every deploy changes and an in-place 复活 changes too; one battle-level hook holds the running windows (`holdsUndying`), so a window outlasts a lend, DESIGN §21.21 — the lock `PRIO_REVIVE` −100 after the substitutes (−100, registered first), the running windows `PRIO_UNDYING_HELD` −99 before them: a 傀儡师 holding 不死 does not switch, PRTS 分支特性信息 傀儡师 "未持有不死的情况下", DESIGN §22.11) → items' 复活 (M3茧甲, `PRIO_RESPAWN` −101: PRTS "复活" acts on a knock-out, which a 不死 prevents) → 埃芒加德 (−110); both 复活 revive in place and call `revivedInPlace` (a new deployment for the lock) |
| `dollSwitch` | `{ unit, reason, done }` | content switches a 傀儡师 to its <替身> now (归溟幽灵鲨 S2 "技能结束后立刻切换为<替身>": no lethal HP loss); its trait does it unless it already is one or is not on the field, and sets `done` |
@@ -749,7 +754,9 @@ registration order. `battle.off(handle)` / `battle.off(name, fn)` / `battle.offO
| `blocked` | `{ blocker, enemy }` | enemy became blocked |
| `enemySpawn` / `enemyLeak` | `{ enemy }` | |
| `elementBurst` | `{ source, target, element }` | before the burst's lock/effects; same-element fills of `target` are already refused |
| `palsyTrigger` | `{ enemy, buff, keep }` | a 麻痹 stack interrupts the enemy's normal attack ("触发麻痹"), before the stack is used: set `keep` to leave it (真言 噤声限域); a handler may kill the enemy (its turn ends) |
| `dodge` | `{ source, target, dmg }` | an attack was dodged |
| `boomerangCaught` | `{ unit, attackId, isSkill, x, y }` | a 回环射手 boomerang came back to its thrower (`ai.js throwBoomerang`; `isSkill` = thrown by a skill attack): 娜仁图亚 LPS-Y "每回收5次回旋投射物", S3 "投射物全部回收时" |
| `layerGain` | `{ playerId, bondId, n, reason, source, tile }` | mutable `n` before recording (魔王 +1 …), then clamped to the room left under `BOND_LAYER_CAP` (999); not emitted for a bond already at the cap; `tile` = `[r, c]` where `source` stands — or was knocked out this very instant ("被击倒时" gains) — else null (`addLayers` opts.tile overrides) |
| `merchantPay` | `{ unit, cost, cancel }` | a merchant (行商) is about to pay its periodic DP; change `cost` or set `cancel` |
| `battleEnd` | `{ result }` | may still add layer gains / coins |
@@ -1141,9 +1148,14 @@ S3 未照耀的荣光 — its CUSTOM_RANGE trigger also counts flyers). A stun /
3.75 back = `BOOMERANG_RETURN_SPEED`, PRTS 跃跃; droneBomb 5 = 暴鸰's bomb, the official projectile_bombd); melee/`none` hits are
instant, and so are `'beam'` hits (a 锁定攻击范围 AoE without a projectile — `rangeAoe` profiles: "在攻击前摇结束时选取范围内的全体目标,同时造成伤害", PRTS 作战机制). Kit-settable profile flags beyond the
table: `hitSleep` (targets and damages sleeping enemies — "可以攻击沉睡的敌人"), `onEachHit(b, u, victim, hctx)`, `dmgMul`,
`afterHit`, `afterAttack`, `canAttack`, `hitsFn`, `priority`, `blockFly`, `noHeal`, `boomerang` (the projectile stays
`'boomerang'` whatever the data's generic ranged projectile says), `rangeAoe` (applied after every override: sets
`allInRange` and, on a ranged profile, the instant `'beam'`) (see the header of professions.js).
`afterHit`, `afterAttack`, `canAttack`, `hitsFn`, `priority` (targeting.js PRIORITY_FNS — `'heaviest'`: the 攻城手 trait
"优先攻击重量最重的敌人", the highest current 重量等级 first: 早露 / 提丰), `blockFly`, `noHeal`, `skipEnemy(e)` (an enemy the unit never
selects — its attacks, the enemies it blocks and its skill-trigger targets: targeting.js canTargetEnemy; 嵯峨 "不攻击重伤
单位"), `boomerang` (the projectile stays
`'boomerang'` whatever the data's generic ranged projectile says), `boomerangOnward(ctx)` (a boomerang's flight after its
first hit belongs to content: ctx `hit(target, x, y)` / `comeBack(x, y)` — 娜仁图亚 S1's bounces, S2's dash; the loopshooter
row), `rangeAoe` (applied after every override: sets `allInRange` and, on a ranged profile, the instant `'beam'`) (see the
header of professions.js).
| sub | behaviour |
|---|---|
@@ -1154,7 +1166,7 @@ table: `hitSleep` (targets and damages sleeping enemies — "可以攻击沉睡
| blastcaster | `rangeAoe`: every selectable enemy on its line at once, the same damage near and far, instant (`'beam'`) — "超远距离的群体法术伤害" is the whole line, not a splash (primary: PRTS 作战机制 §AOE伤害判定 names 伊芙利特's 炎爆 a 锁定攻击范围 AoE, and 炎爆 is her next-attack skill "下次攻击造成…" (PRTS 伊芙利特 S2), so a 轰击术师 normal attack; secondary: Terra Wiki, Blast Caster; supporting: PRTS 溅射半径一览 documents no splash radius for it; community report E3). A stealthed enemy is not struck unless it is revealed or blocked (a 锁定范围 AoE cannot hit a 隐匿 unit, PRTS 作战机制) |
| bombarder | ground-only splash 0.9 (PRTS 溅射半径一览: 投掷手 0.9; 1.0 until 0.1.1) + aftershock(s) at 50 % ATK (bb append_atk_scale / times); 迷迭香's S2 末梢阻断 1.5 (the same table) |
| hunter | 8 bullets (bb value), ×1.2 ATK (bb atk_scale), reloads 1/s after 1 s without attacking; can't attack when empty |
| loopshooter | 回环射手 (user playtest #3): every attack throws a boomerang (`ai.js throwBoomerang`, projectile `'boomerang'`) out to the target at 15 tiles/s — it hits on arrival — and back to the thrower's current position at 3.75 tiles/s without damage (PRTS 跃跃 "投射物飞行速度15,返回时飞行速度3.75"); attacks only while holding it (every boomerang thrown caught — "必须回收全部回旋投掷物才可以进行下一次攻击", `unit.trait.boomerangsOut`) and with the attack cooldown ready, so the real interval is the longer of the two; a target dead mid-flight is not hit (it still flies to the last position and back); knocked out / withdrawn ⇒ lost, a redeployed thrower holds a fresh one; 跃跃 S2's extra boomerangs share the one flight (cnt hits) |
| loopshooter | 回环射手 (user playtest #3): every attack throws a boomerang (`ai.js throwBoomerang`, projectile `'boomerang'`) out to the target at 15 tiles/s — it hits on arrival — and back to the thrower's current position at 3.75 tiles/s without damage (PRTS 跃跃 "投射物飞行速度15,返回时飞行速度3.75"); attacks only while holding it (every boomerang thrown caught — "必须回收全部回旋投掷物才可以进行下一次攻击", `unit.trait.boomerangsOut`) and with the attack cooldown ready, so the real interval is the longer of the two; a target dead mid-flight is not hit (it still flies to the last position and back); knocked out / withdrawn ⇒ lost, a redeployed thrower holds a fresh one; 跃跃 S2's extra boomerangs share the one flight (cnt hits); each catch fires `boomerangCaught`, and an attack profile's `boomerangOnward(ctx)` flies it on after the first hit before it turns back — ctx `hit` (a hit of the same attack), `comeBack(x, y)` once (a content error sends it back from the hit point): 娜仁图亚 S1 bounces, S2 dashes on and hits on its way back (kits/ops/op-narant.js) |
| reaperrange | hits every enemy in range; ×1.5 (bb atk_scale) on the trait front grid (or its own line ahead) — both along its direction |
| chain | chain N (trait text/bb max_target) with −15 % per jump (bb chain.atk_scale), 1.7-tile jumps (constants.js CHAIN_RADIUS, PRTS 溅射半径一览: 链术师 1.7; 1.8 until 0.1.1), sluggish on each hit |
| funnel | drone damage 20 % → +15 %/hit on the same target → 110 % (bb init/delta/max) |
+32 -5
View File
@@ -185,19 +185,39 @@ export function performAttack(b, u, prof, targets, opts = null) {
* current position at BOOMERANG_RETURN_SPEED, dealing nothing on the way back, and is caught (u.trait.boomerangsOut −1:
* the thrower attacks again once every boomerang is back). A thrower knocked out / withdrawn meanwhile loses it — nothing
* returns to a unit off the field or to a later deployment of it (the deploy hook hands it a fresh one).
* Content: a catch fires the hook `boomerangCaught` { unit, attackId, isSkill, x, y } (娜仁图亚 LPS-Y "每回收5次回旋投射物",
* S3 "投射物全部回收时"); an attack profile with `boomerangOnward(ctx)` (a skill's attack override: 娜仁图亚 S1's bounces,
* S2's dash) takes the flight over after the first hit — ctx { battle, unit, profile, target, x, y, attackId, isSkill,
* home(), hit(target, x, y) (resolveHit with this attack's profile), comeBack(x, y) (sends it back from there, once;
* returns the return projectile or null) } — and a content error there sends it back from the hit point.
*/
function throwBoomerang(b, u, prof, t, info) {
const seq = u.deploySeq;
u.trait.boomerangsOut = (u.trait.boomerangsOut || 0) + 1;
const home = () => u.alive && u.deployed && u.deploySeq === seq;
let sent = false;
const comeBack = (x, y) => {
if (sent || !home()) return null;
sent = true;
// hitDead: flies on to the thrower's last position even while it is hidden, caught there when it is still home
return b.addProjectile({ from: { x, y }, target: u, speed: BOOMERANG_RETURN_SPEED, visual: 'boomerangReturn', source: u, hitDead: true,
onHit: (r) => {
if (!home() || !(u.trait.boomerangsOut > 0)) return;
u.trait.boomerangsOut--;
if (b._hooks.boomerangCaught) b.emit('boomerangCaught', { unit: u, attackId: info.attackId ?? 0, isSkill: !!info.isSkill, x: r.x, y: r.y });
} });
};
b.addProjectile({ from: u, target: t, speed: PROJECTILE_SPEEDS.boomerang, visual: 'boomerang', source: u, hitDead: true,
onHit: (c) => {
// (guarded on its own: a content error in the hit must not cost the thrower its boomerang for the battle)
if (c.target || prof.splashRadius > 0) b._safe(() => resolveHit(b, u, prof, c.target, info, c.x, c.y), 'boomerang.hit', u);
if (!home()) return;
// hitDead: flies on to the thrower's last position even while it is hidden, caught there when it is still home
b.addProjectile({ from: { x: c.x, y: c.y }, target: u, speed: BOOMERANG_RETURN_SPEED, visual: 'boomerangReturn', source: u, hitDead: true,
onHit: () => { if (home() && u.trait.boomerangsOut > 0) u.trait.boomerangsOut--; } });
if (typeof prof.boomerangOnward === 'function') {
const ctx = { battle: b, unit: u, profile: prof, target: c.target, x: c.x, y: c.y, attackId: info.attackId ?? 0, isSkill: !!info.isSkill,
home, comeBack, hit: (tgt, x, y) => b._safe(() => resolveHit(b, u, prof, tgt, info, x, y), 'boomerang.hit', u) };
if (b._safe(() => { prof.boomerangOnward(ctx); return true; }, 'boomerang.onward', u) === true) return;
}
comeBack(c.x, c.y);
} });
}
@@ -656,10 +676,17 @@ function enemyAttack(b, e) {
if (own && targets.length) targets = targets.filter((a) => own(a));
if (targets.length > 1) sortAllyTargets(e, targets);
if (!targets.length) return false;
// 麻痹 (ba.palsy): each stack interrupts one normal attack
// 麻痹 (ba.palsy): each stack interrupts one normal attack. The `palsyTrigger` hook { enemy, buff, keep } fires as it
// does (PRTS 真言 备注: "触发麻痹时" = the attack interrupted by 麻痹, not a stack gained): a handler may set `keep` — the
// stack is not consumed (真言 噤声限域 "触发麻痹时有N%概率不消耗麻痹层数") — or deal damage (the enemy may die there)
const palsy = e.buffs.length ? e.findBuff('palsy') : null;
if (palsy) {
if (--palsy.stacks <= 0) b.removeBuff(e, palsy); else e.markDirty();
let keep = false;
if (b._hooks.palsyTrigger) {
keep = !!b.emit('palsyTrigger', { enemy: e, buff: palsy, keep: false }).keep;
if (!e.alive) return false;
}
if (!keep) { if (--palsy.stacks <= 0) b.removeBuff(e, palsy); else e.markDirty(); }
e.atkCd = e.s.interval;
// the interrupted attack ends its clip: the old short stand after it (PRTS 异常效果 麻痹: 0.5 s 麻痹震颤 — not modelled)
if (!e.blockedBy && radius > 0 && !(e.profile?.attackMoves ?? def.attackMoves)) e.atkStandUntil = b.time + ATTACK_PAUSE;
+3
View File
@@ -155,6 +155,9 @@ export function makeBuff(b) {
tags: b.tags ?? [],
shield: b.shield ?? (b.mods && b.mods.shield) ?? 0,
shieldHits: b.shieldHits ?? 0,
// a 屏障 that absorbs one damage type only (damage.js absorbShields): 'phys' | 'arts' | 'true' | 'elemental'; null =
// every type (PRTS 术语释义 屏障 "若无特殊说明,屏障可吸收全种类伤害") — 夜莺 S2 法术护盾 "能吸收…法术伤害"
shieldType: b.shieldType ?? null,
persist: !!b.persist,
status: b.status ?? null,
visible: b.visible ?? false,
+1 -1
View File
@@ -143,7 +143,7 @@ or a prototype (DATA.md §18, `shared/diy.js`). The prototypes run their stand-i
an owned 6★ can be picked only once it has a kit of its own: one file per operator, the same rules as every kit above.
Contributions are welcome — one operator per pull request is easiest to review.
**Who.** The owned-6★ picks are `data/backups.json diy.ownedPool` (72 operators; the collab operators are not included,
**Who.** The owned-6★ picks are `data/backups.json diy.ownedPool` (71 operators; the collab operators are not included,
the owner's decision of 2026-10-05). The ones still without a kit:
```sh
+26 -1
View File
@@ -78,7 +78,32 @@ export const STANDIN_KIT_FILES = Object.freeze(['standin-acguad.js', 'standin-sh
* The 自选 operator kit files (`ops/op-<codename>.js`, registry key = the operator's charId, an owned-6★ pick of
* data/backups.json; a new file is appended). An operator without a file is not offered as a 自选 pick (KITTED_CHARS).
*/
export const OPERATOR_KIT_FILES = Object.freeze(['op-siege.js']);
export const OPERATOR_KIT_FILES = Object.freeze([
'op-siege.js',
'op-chen.js',
'op-irene.js',
'op-helage.js',
'op-shining.js',
'op-cgbird.js',
'op-saga.js',
'op-amgoat.js',
'op-cerber.js',
'op-heyak.js',
'op-poca.js',
'op-typhon.js',
'op-narant.js',
'op-logos.js',
'op-mantra.js',
'op-veen.js',
'op-zuole.js',
'op-chyue.js',
'op-huang.js',
'op-shwaz.js',
'op-bgsnow.js',
'op-ifrit.js',
'op-aphris.js',
'op-lin.js',
]);
/**
* The 预备干员 whose every skill the generic kit covers exactly (冲锋号令, 攻击力 / 防御力 / 治疗强化, 战术咏唱, 一击即退 —
+192
View File
@@ -0,0 +1,192 @@
// server/sim/content/kits/ops/op-amgoat.js — 艾雅法拉 (char_180_amgoat) 自选 operator kit: 6★ 中坚术师 (术师), an owned-6★
// pick of the tier-5 and tier-6 自选 slots; every skill, both talents, the trait and both modules at every form.
// Kit contract and the 自选 rules: ../README.md ("How to add an operator (自选)").
//
// Forms (data/backups.json units.char_180_amgoat, the DIY slot statuses): normal = E2 Lv1, skills at rank 4, no module;
// elite = E2 Lv60, rank 7, the picked module at stage 1 (tier 5) or 3 (tier 6). Potential 0 [ASSUMED: no account].
// Sources: character_table / skill_table / battle_equip_table (zh_CN, as built into backups.json; every module part is
// valid outside 集成战略 — no `validInGameTag`) and PRTS 艾雅法拉 (S1 备注 "每次部署艾雅法拉时,重新计算该技能的使用次数";
// S2 备注 "爆炸范围为半径1.5", "技能实际效果为先削减主攻击目标的法术抗性,再对爆炸范围内所有敌人造成½倍率的伤害(包括主攻击目标),
// 最后对主攻击目标额外造成一次½倍率的伤害", 法术抗性 "{{**|80%|-20%}}" = a multiplier; S3 "攻击间隔大幅度缩短(-1.1)";
// 乱火's hidden 备注 "在最小值与最大值之间随机生成一个浮点数后向下取整").
// - Trait (中坚术师) "攻击造成法术伤害": the profession default (ranged arts, hits air, block 1). Module CCR-X “错过的声音”
// adds "无视目标10点法术抗性" (trait bb magic_resist_penetrate_fixed: a permanent resIgnoreFlat), CCR-Y “宠物大赛第一名”
// "普通攻击命中精英或领袖敌人时获得1点技力" (trait bb sp: per hit of one of her attacks on an elite / leader — the S2
// explosion's splash and its second ½ are not further 命中 [ASSUMED]; a running timed skill takes no SP, as always).
// - T1 炎息 "在场时,所有友方【术师】职业干员的攻击力+14%": an aura on every 【术师】 operator of the field (her included) while
// she is deployed (installAura, the kits' convention for field-wide talents; several sources keep the strongest). CCR-X
// stage 2+ turns it into "携带时…+22%" (the module talent's bb): the same aura whether she is on the field or not — she
// is carried while she belongs to the battle, knocked out or waiting to redeploy included.
// - T2 乱火 "部署后立即随机获得7~15点技力" (bb sp_min 7, sp_max 16): floor(uniform [sp_min, sp_max)) SP at every deployment
// (PRTS hidden 备注). CCR-Y stage 3 adds "并随机提升6~15的攻击速度" (attack_speed_min / _max, the same draw) for that
// deployment [ASSUMED: no duration given — until she leaves the field], and with `factor` "部署后范围内存在精英或领袖敌人时,
// 获得的技力和提升的攻击速度取最大值": an elite or leader enemy in her attack range at that deployment gives the text's
// maxima, 15 SP / 15 ASPD — the blackboard maxima are the exclusive bounds of the draw (stage 2's attack_speed_max 1
// with no ASPD in its text says so) [ASSUMED: 取最大值 = the largest value the draw yields; checked at the deployment
// instant — at the battle start no enemy is on the field yet].
// - S1 二重咏唱 (MANUAL, 25 s, data DEFAULT): ASPD +[a].attack_speed; from the second cast of a deployment on, ASPD
// +[b].attack_speed and ATK +[b].atk (PRTS 备注: the count restarts at every deployment).
// - S2 点燃 (AUTO, 可充能 2, data DEFAULT): the next attack, on impact — the main target's RES ×(1 + magic_resistance) for
// `duration` s first, then an explosion of radius 1.5 around it (a 中点判定 splash) dealing atk_scale × ATK arts to
// every enemy in it, the main target included, each of the others then getting the same RES cut, and last
// atk_scale_2 × ATK arts to the main target again (the PRTS 备注's order; it names the main target's cut only as
// coming first, so the others' cut follows their hit [ASSUMED]; the cut is "同名效果取最高" through applyStrongest).
// The explosion reaches air units and skips enemies an area effect cannot select (foesInRadius); like the engine's
// profession splash it can be dodged.
// - S3 火山 (MANUAL, 15 s): ATK +atk, attack interval base_attack_time (−1.1 s flat on her 1.6 s), the attack range x-3
// while it runs, each attack throws lava at attack@max_target enemies of that range drawn at random (all of them when
// fewer) — one projectile each, no splash [ASSUMED: the text names none]. Trigger: the data's SKILL_RANGE on
// x-3 — its text says "攻击范围增大", which tools/build-data.mjs ATTACK_RANGE_CHANGE does not list, so the owner's
// ACTIVE_RANGE rule did not reach it (reported; the kit keeps the data rule).
// - No summon (the forms list no token).
import { num, talentBb, traitBb, skillRec, statBuff, installAura, batMod, giveSp, onHitBy } from '../shared/tier1.js';
import { hasHp } from '../../../damage.js';
import { canTargetEnemy } from '../../../targeting.js';
import { bodyInKeys } from '../../../body.js';
const S1 = 'skchr_amgoat_1';
const S2 = 'skchr_amgoat_2';
const S3 = 'skchr_amgoat_3';
/** S2 点燃: the explosion radius (PRTS 备注 "爆炸范围为半径1.5"; no blackboard key). */
const IGNITE_RADIUS = 1.5;
/** S3 火山's 技能范围 x-3 when the data carries no grid (range_table x-3: the tiles within 3 steps). */
const X3 = Object.freeze([[3, 0], [2, -1], [2, 0], [2, 1], [1, -2], [1, -1], [1, 0], [1, 1], [1, 2], [0, -3], [0, -2], [0, -1],
[0, 0], [0, 1], [0, 2], [0, 3], [-1, -2], [-1, -1], [-1, 0], [-1, 1], [-1, 2], [-2, -1], [-2, 0], [-2, 1], [-3, 0]]);
/** Damage of the S2 explosion and its second ½ (not a further 命中 of the attack: CCR-Y counts the first). */
const IGNITE_TAG = 'amgoat:ignite';
const AURA_KEY = 'talent:amgoat:casters';
const AURA_EVERY = 0.5;
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
/** A 【术师】 operator (summons are never operators). */
const isCaster = (a) => !!a && a.kind === 'op' && a.def?.profession === 'CASTER';
/** An elite or leader enemy (精英或领袖敌人). */
const isEliteEnemy = (e) => !!e && e.side === 'enemy' && (e.isBoss || e.def?.rank === 'ELITE' || e.def?.rank === 'BOSS');
const tagged = (d, tag) => !!(d && d.tags && d.tags.includes(tag));
/** A module trait that works 在集成战略中 only (ISW modules): nothing of it applies in this mode. */
const roguelikeOnly = (chess) => /集成战略/.test(String(chess?.trait?.moduleDesc ?? ''));
/** Named talent `i` of the record (its desc tells the module stage's wording). */
const talentRec = (chess, i) => (chess?.talents ?? []).filter((t) => t && t.index !== -1)[i] ?? null;
/** "携带时": the aura of installAura, held while she belongs to the battle — on the field or not. */
function carriedAura(battle, unit, { key, select, mods, value }) {
battle.every(AURA_EVERY, () => {
for (const a of battle.alliesFor(unit)) {
if (!select(a)) continue;
const cur = a.findBuff(key);
if (cur && cur.source !== unit && (cur.data?.v ?? 0) > value && cur.timeLeft > 0.05) continue;
battle.addBuff(a, { key, duration: AURA_EVERY + 0.1, mods, source: unit, data: { v: value }, tags: ['aura'] });
}
}, { owner: unit, immediate: true });
}
export default {
char_180_amgoat: (bb, chess) => {
const t0 = talentBb(chess, 0), t1 = talentBb(chess, 1);
const carried = /携带时/.test(String(talentRec(chess, 0)?.desc ?? ''));
const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3);
const s2 = skillRec(chess, S2), s3 = skillRec(chess, S3);
const mr = num(b2.magic_resistance), resDur = num(b2.duration);
const resCut = (battle, unit, e) => {
if (e && e.alive && mr) battle.applyStrongest(e, 'amgoat:ignite', { duration: resDur, value: mr, mods: (v) => ({ resMul: Math.max(0, 1 + v) }), source: unit });
};
const maxLava = Math.max(1, Math.floor(num(b3['attack@max_target'], 1)));
return {
skills: {
[S1]: {
kind: 'duration',
onStart({ battle, unit }) {
// the casts of this deployment (PRTS 备注: "每次部署…重新计算该技能的使用次数")
const n = unit.mem.amgoatDuetSeq === unit.deploySeq ? num(unit.mem.amgoatDuetN) + 1 : 1;
unit.mem.amgoatDuetSeq = unit.deploySeq;
unit.mem.amgoatDuetN = n;
const mods = n >= 2
? { aspd: num(b1['amgoat_s_1[b].attack_speed']), atkPct: num(b1['amgoat_s_1[b].atk']) }
: { aspd: num(b1['amgoat_s_1[a].attack_speed']) };
battle.addBuff(unit, { key: 'skill:amgoat:duet', mods, tags: ['skill'] });
},
onEnd({ battle, unit }) { battle.removeBuff(unit, 'skill:amgoat:duet'); },
},
[S2]: {
kind: num(s2?.maxChargeTime, 1) > 1 ? 'charges' : 'instant',
attack: {
// the engine's hit on the main target is its share of the explosion (½); the RES cut came first (install)
atkScale: num(b2.atk_scale, 1),
onEachHit({ battle, unit, target, kind }) {
if (kind !== 'main' || !target) return;
const atk = unit.s.atk, x = target.x, y = target.y;
for (const e of battle.foesInRadius(x, y, IGNITE_RADIUS, true)) {
if (e === target) continue;
battle.dealDamage(unit, e, { amount: atk * num(b2.atk_scale, 1), type: 'arts', isAttack: true, isSplash: true, isSkill: true, tags: [IGNITE_TAG] });
resCut(battle, unit, e);
}
if (hasHp(target)) battle.dealDamage(unit, target, { amount: atk * num(b2.atk_scale_2, num(b2.atk_scale, 1)), type: 'arts', isAttack: true, isSkill: true, tags: [IGNITE_TAG] });
battle.fx('explosion', { x, y, id: unit.id, r: IGNITE_RADIUS, dmgType: 'arts' });
},
},
},
[S3]: {
kind: 'duration',
mods: { atkPct: num(b3.atk), batPct: batMod(b3.base_attack_time, chess) },
// every enemy of x-3 is a candidate; install draws the lava targets among them
targeting: { rangeGrid: s3?.rangeGrid ?? X3, allInRange: true },
},
},
talents: [
{ install(battle, unit) { // 炎息: the field's 【术师】 ATK +atk — while she is deployed, or (CCR-X stage 2+) carried
const a = num(t0.atk);
if (!(a > 0)) return;
const opts = { key: AURA_KEY, value: a, select: isCaster, mods: { atkPct: a } };
if (carried) carriedAura(battle, unit, opts);
else installAura(battle, unit, { ...opts, interval: AURA_EVERY });
} },
{ install(battle, unit) { // 乱火: SP (and with CCR-Y stage 3 ASPD) at every deployment; the maxima with an elite in range
const spLo = num(t1.sp_min), spHi = num(t1.sp_max), asLo = num(t1.attack_speed_min), asHi = num(t1.attack_speed_max);
const maxRule = num(t1.factor) > 0;
battle.on('deploy', (ctx) => {
if (ctx.unit !== unit || !unit.alive) return;
// (a scan of the enemies: the tile index of enemiesInKeys is the last tick's)
const top = maxRule && battle.enemies.some((e) => isEliteEnemy(e) && canTargetEnemy(unit, e, unit.profile) && bodyInKeys(e, unit.rangeKeySet));
// floor of a uniform draw in [lo, hi); "取最大值" = the largest value the draw yields (hi − 1 for integer bounds)
const draw = (lo, hi) => (hi > lo ? (top ? Math.ceil(hi) - 1 : Math.floor(battle.rng.range(lo, hi))) : 0);
const sp = draw(spLo, spHi);
if (sp > 0) giveSp(unit, sp);
const as = draw(asLo, asHi);
if (as > 0) battle.addBuff(unit, { key: 'talent:amgoat:wildfire', mods: { aspd: as }, tags: ['talent'] });
}, { owner: unit });
} },
],
install(battle, unit) {
// module traits (none on the normal form): CCR-X 无视目标10点法术抗性, CCR-Y SP per hit on an elite / leader
if (!roguelikeOnly(chess)) {
const tb = traitBb(chess);
const pen = num(tb.magic_resist_penetrate_fixed);
if (pen > 0) statBuff(battle, unit, 'trait:amgoat:resIgnore', { resIgnoreFlat: pen });
const sp = num(tb.sp);
if (sp > 0) {
battle.on('damaged', (ctx) => {
const d = ctx.dmg;
if (ctx.source !== unit || !d || !d.isAttack || d.isSplash || tagged(d, IGNITE_TAG) || !isEliteEnemy(ctx.target)) return;
giveSp(unit, sp, 'trait');
}, { owner: unit });
}
}
const sid = unit.skill?.id;
if (sid === S2) {
// 点燃: "先削减主攻击目标的法术抗性" — the main target's RES cut lands before the first damage of the attack
onHitBy(battle, unit, (ctx) => {
const d = ctx.dmg;
if (d.isAttack && d.isSkill && !d.isSplash && !tagged(d, IGNITE_TAG)) resCut(battle, unit, ctx.target);
}, 50);
} else if (sid === S3) {
// 火山: "随机对攻击范围内至多N个敌人发射熔岩" — a random draw among every enemy of the range
battle.on('beforeAttack', (ctx) => {
if (ctx.attacker !== unit || !unit.skill?.active || ctx.targets.length <= maxLava) return;
ctx.targets = battle.rng.shuffle(ctx.targets.slice()).slice(0, maxLava);
}, { owner: unit });
}
},
};
},
};
+294
View File
@@ -0,0 +1,294 @@
// server/sim/content/kits/ops/op-aphris.js — 谬因 (char_4229_aphris) 自选 operator kit: 6★ 轰击术师 (术师), an owned-6★ pick
// of the tier-5 and tier-6 自选 slots; every skill, both talents, the trait, her module and her summon “中继器” at every form.
// Kit contract and the 自选 rules: ../README.md ("How to add an operator (自选)").
//
// Forms (data/backups.json units.char_4229_aphris, the DIY slot statuses): normal = E2 Lv1, skills at rank 4, no module;
// elite = E2 Lv60, rank 7, the picked module at stage 1 (tier 5) or 3 (tier 6). Potential 0 [ASSUMED: no account].
// Summon: tokens.token_10070_aphris_pc “中继器” (talent 链路协议; a hand piece the player places with a facing — data
// `placeable`, a talent summon: it deploys with the board, docs/SIM.md §1.1 token pieces).
// Sources: character_table / skill_table / battle_equip_table (zh_CN, as built into backups.json); PRTS 谬因 (链路协议 备注:
// her range "以自己所在格为起点向部署方向延伸,直至达到延伸距离上限" — 5 tiles from E1 —, "若延伸到了自身的中继器所在的地块,会令延伸
// 距离上限增加(并非增加谬因的攻击距离属性)并改变接下来的延伸方向", "尝试延伸到已延伸到的地块或地图边界外时延伸会提前终止", "索敌与攻击
// 始终采用格子判定", "攻击力增加效果不可叠加"; 取样优化 备注 "无视法抗效果为增加受益者的法术穿透属性(直接加算)"; 连续映射 备注
// "中继器持续时间结束时不再进行自动撤退"; 临界瞬爆 备注 "技能持续期间,自身的攻击范围可无限延伸直至无法继续延伸", "持有孤立的我方干员
// 也可令谬因攻击范围延伸效果转向"; 混沌的本质 备注 "技能开启后3秒内持有强制缴械", "“停顿时间延长”指的是增加本次攻击对该敌人施加的
// 停顿的时间", "技能开始/结束时强制撤回所有中继器", 修正 "清空再部署"); PRTS 中继器 (备注: 无敌, 禁疗, 孤立, 阻挡数 0, 再部署策略
// 队列); PRTS 卫戍协议/帮助 §作战阶段 ("若战场区初始部署有召唤物,若召唤物在战斗期间退场,将在满足条件后立即原地再部署1个"); the client's
// battle data (charpack char_4229_aphris — the range selectors, the talent abilities; battle/prefabs [uc]skills skchr_aphris_1/2/3;
// buff_template_data aphris_token_add_atk_tohost, aphris_pc_t[with-draw], aphris_sk1(_token), aphris_sk2_switch_mode4,
// aphris_sk3 / [token-withdraw] / [on_finish], die_to_kill_token, charge_token[born]).
// - Range (链路协议; her selectors `_extendable`, `_extendValidator` = the 中继器, `_extendLengthEveryValidateChar` 2): a path,
// not her data grid — from her tile along her facing, one tile at a time, up to her 5-1 length; reaching her own 中继器
// adds 2 to the length still allowed and turns the path to that piece's facing; it stops at a tile it already holds or at
// the map's edge. The path is her range (`unit.rangeGrid`, rebuilt when a piece or an operator comes or goes, at her
// skill's start / end, and checked every tick): her attacks, the DEFAULT trigger, 取样优化 and the detail card all read it.
// [ASSUMED] a huge enemy counts on every tile it covers (the engine's selection; PRTS: 格子判定 by its position), her hits
// land at once (the client spaces them `_hitInterval` 0.05 s along the beam) and a 攻击距离 bonus (none exists for her in
// this mode) would extend it as the engine extends any range.
// - Trait (轰击术师) "攻击造成超远距离的群体法术伤害": the profession's `rangeAoe` — every selectable enemy of her path at once,
// air units too. Module BLA-Y “第三相态”: 部署费用 −8 (the attribute; stats 34 → 26), HP / ATK in the stats, no trait change;
// stage 3 changes T1 (35 s piece, 再部署时间变短 — the piece's byModule respawnTime 30 → 20 —, ATK +35 %).
// - T1 链路协议: one 中继器 lasting the token talent's duration (25 s; BLA-Y stage 3: its hidden module talent, 35 s) from each
// deployment (aphris_pc_t[with-draw]: it withdraws when that time is up unless it holds aphris_sk1_token — given to every
// piece of hers when 连续映射 starts, and to a piece taking the field while 连续映射 or 混沌的本质 runs). Back on its tile its
// respawnTime (30 / 20 s) after it left, paying its 3 DP — the 卫戍 auto redeploy of a placed summon, as 斯卡蒂的海嗣 —, and
// only while 谬因 stands: her leaving the field withdraws it (die_to_kill_token), her (re)deployment makes it ready at once
// (charge_token[born]). ATK +atk while one of hers stands (aphris_token_add_atk_tohost: one buff aphris_t1[atk], "不可叠加").
// The piece "不会受到攻击" (data: untargetable) and holds 无敌, 禁疗 and 孤立 (PRTS 中继器; [ASSUMED] given by this kit — the
// data's `abnormal` lacks them).
// - T2 取样优化 "攻击范围内的友方干员攻击时无视敌方10点法术抗性" (aphris[t2]: attribute 27, 法术穿透, on allied operators — profession
// mask 639 — of her attack range): resIgnoreFlat +magic_resist_penetrate_fixed on every allied operator standing on her path,
// her own tile included [ASSUMED: the selector does not drop its owner], refreshed every AURA_IV s; several sources keep the
// strongest.
// - S1 连续映射 (AUTO, 持续时间无限): ATK +atk (aphris_sk1) and the 中继器 marks above. An AUTO skill acting on herself fires at
// full SP (`trigger: 'SP_FULL'`, the owner's AUTO rule, kits/README.md checklist 5); `toggle`.
// - S2 临界瞬爆 (MANUAL, data DEFAULT, 8 s): mode 4 (aphris_sk2_switch_mode4) — her normal attack is replaced by the beam
// ability (`_preDelay` 0.167 = the Skill_2_Begin clip, then `_triggerDelta` 0.5; MAGICAL atk_scale): from BEAM_FIRST on,
// every `interval` (0.5) s, every selectable enemy on her path takes atk_scale × ATK arts (16 hits in the 8 s). The path
// meanwhile has no length limit (`_infinite`) and turns at her 中继器 and at every allied
// operator it reaches (validators: the piece, allies of profession mask 639 — every operator profession; a 孤立 one too),
// to that unit's facing. The data resolves DEFAULT: the unbounded range is no data range (no rangeId), so build-data's
// ACTIVE_RANGE rule cannot see it (reported, not hand-coded).
// - S3 混沌的本质 (MANUAL, data DEFAULT, 20 发弹药 — `ammo`, attack@trigger_time): for charge_time (3) s from the cast 强制缴械
// (aphris_sk3[sheltering], abnormal flag disarmed) and aphris_sk3[token-withdraw]: every 中继器 of hers withdrawn and its
// redeploy time cleared (ResetTokenRespawnTime — the placed piece is back on its tile at once; the two extra pieces the
// skill allows have no piece to place in the auto battle [ASSUMED: N/A]); attack interval +base_attack_time (−1.1 s on
// 2.9 s), ATK +atk, each attack attack@atk_scale × ATK on her path, and on every enemy it hits the buff aphris_sk3: 停顿
// origin_sluggish + extra_sluggish × n s and n hits of extra_atk_scale × ATK arts, n = the 中继器 the path passed before the
// enemy, at most max_times (1). It ends with its bullets; aphris_sk3[on_finish] withdraws every 中继器 and clears their
// redeploy time again (so the piece is back at once, now with its normal lifetime).
import { num, talentBb, skillRec, up, batMod, toggleBuff } from '../shared/tier1.js';
import { COLS } from '../../../constants.js';
import { dirVec, toLocal } from '../../../dir.js';
const S1 = 'skchr_aphris_1';
const S2 = 'skchr_aphris_2';
const S3 = 'skchr_aphris_3';
/** Her summon “中继器” (talent 链路协议). */
const RELAY = 'token_10070_aphris_pc';
/** Extra path length per 中继器 (the selectors' `_extendLengthEveryValidateChar`; "使该次攻击距离+2"). */
const RELAY_REACH = 2;
/** Talent-2 aura refresh period and buff length (the non-stacking aura convention of the kits). */
const AURA_IV = 0.25;
const AURA_DUR = 0.4;
/** S2 临界瞬爆: the first beam hit (skchr_aphris_2 `_preDelay`, the Skill_2_Begin clip's length), then every `interval` s. */
const BEAM_FIRST = 0.167;
/** How often a withdrawn 中继器 tries to come back once ready (her on the field, its tile free, DP paid). */
const RELAY_RETRY = 0.1;
const LINK_KEY = 'aphris:link';
const SAMPLING_KEY = 'aphris:sampling';
const CHARGE_KEY = 'aphris:charge';
const RELAY_ABNORMAL_KEY = 'aphris:relayAbnormal';
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
/** A 中继器 of `unit`. */
const isRelayOf = (t, unit) => !!t && t.kind === 'token' && t.defId === RELAY && t.ownerUnit === unit;
const relaysOf = (battle, unit) => battle.allyUnits.filter((t) => isRelayOf(t, unit));
/** 连续映射 / 混沌的本质 running: a 中继器 taking the field now gets the no-withdraw mark (aphris_pc_t[with-draw]). */
const marksRelays = (unit) => !!(unit.skill && unit.skill.active && (unit.skill.id === S1 || unit.skill.id === S3));
/** Her own path length: the farthest forward tile of her data grid (5 on her E2 5-1). */
function baseReach(unit) {
let n = 0;
for (const p of unit.def?.rangeGrid ?? []) if (Array.isArray(p) && p[0] === 0 && p[1] > n) n = p[1];
return n;
}
/** A 中继器's lifetime: the last `duration` of its talents (the BLA-Y stage-3 hidden talent after the base one). */
function relayLife(t) {
let life = 0;
for (const x of t.def?.talents ?? []) if (x && x.bb && x.bb.duration != null) life = num(x.bb.duration, life);
return life;
}
/**
* Her 延伸 path (header): absolute tiles in order from her own, with `index` (tile key → position) and `relays` (the
* positions of the 中继器 tiles it passed). `beam` = S2: no length limit, every allied operator turns it too.
*/
function aphrisPath(battle, unit, beam = false) {
const tiles = [[unit.tileR, unit.tileC]];
const index = new Map([[unit.tileR * COLS + unit.tileC, 0]]);
const relays = [];
let [dr, dc] = dirVec(unit.dir);
let reach = beam ? Infinity : baseReach(unit);
let r = unit.tileR, c = unit.tileC, steps = 0;
while (steps < reach) {
const nr = r + dr, nc = c + dc;
const k = nr * COLS + nc;
if (!battle.grid.inBounds(nr, nc) || index.has(k)) break;
index.set(k, tiles.length);
tiles.push([nr, nc]);
r = nr; c = nc; steps++;
const o = battle.unitAt(nr, nc);
if (o && isRelayOf(o, unit)) {
reach += RELAY_REACH;
[dr, dc] = dirVec(o.dir);
relays.push(tiles.length - 1);
} else if (beam && o && o !== unit && o.kind === 'op' && o.side === 'ally') [dr, dc] = dirVec(o.dir);
}
return { tiles, index, relays };
}
/** Rebuild her range from the path when it changed (the relative grid of the path in her facing-RIGHT frame). */
function refreshPath(battle, unit) {
if (!up(unit)) return;
const beam = !!(unit.skill && unit.skill.active && unit.skill.id === S2);
const p = aphrisPath(battle, unit, beam);
const sig = p.tiles.map(([r, c]) => r * COLS + c).join(',');
unit.mem.aphrisPath = p;
if (unit.mem.aphrisSig === sig) return;
unit.mem.aphrisSig = sig;
unit.rangeGrid = p.tiles.map(([r, c]) => toLocal(r - unit.tileR, c - unit.tileC, unit.dir));
battle.refreshRange(unit);
}
/** How many 中继器 her path passed before `e`'s tile (S3 aphris_sk3 "extendTimes"). */
function relaysBefore(unit, e) {
const p = unit.mem.aphrisPath;
if (!p || !p.relays.length) return 0;
const i = p.index.get(Math.round(e.y) * COLS + Math.round(e.x));
return i == null ? 0 : p.relays.filter((j) => j < i).length;
}
/** Withdraw every 中继器 of hers that stands; `clear` = their redeploy time cleared (ResetTokenRespawnTime). */
function withdrawRelays(battle, unit, clear) {
for (const t of relaysOf(battle, unit)) {
if (t.alive) {
t.mem.relayCleared = clear;
battle.retreat(t, { reason: 'expired', permanent: true });
} else if (clear && !t.removed) t.mem.relayReadyAt = battle.time;
}
}
/** A 中继器 of hers took the field: its abnormal flags and redeploy rule once, its lifetime and mark every time. */
function relayDeployed(battle, unit, t) {
if (!t.mem.aphrisRelay) {
t.mem.aphrisRelay = true;
battle.addBuff(t, { key: RELAY_ABNORMAL_KEY, flags: { invulnerable: true, isolated: true, noHeal: true }, persist: true, allowDead: true });
// back on its tile its respawnTime after it left (at once when its redeploy time was cleared), while she stands,
// paying its DP — a token is otherwise removed for good: the piece is kept (`removed = false`) so its hooks survive
// and `battle.redeploy` accepts it
battle.on('death', (c) => {
if (c.unit !== t || battle.finished) return;
t.removed = false;
t.mem.relayReadyAt = battle.time + (t.mem.relayCleared ? 0 : Math.max(0, num(t.base.respawnTime)));
t.mem.relayCleared = false;
if (t.mem.relayRetry) return;
t.mem.relayRetry = battle.every(RELAY_RETRY, (b, sched) => {
const stop = () => { sched.cancel(); t.mem.relayRetry = null; };
if (t.alive || t.removed || b.finished) { stop(); return; }
if (!up(unit) || b.time + 1e-9 < t.mem.relayReadyAt) return;
if (b.redeploy(t, { free: false })) stop();
}, { owner: t });
}, { owner: t, priority: -10 });
}
t.mem.relayKept = marksRelays(unit); // aphris_pc_t[with-draw] ON_BUFF_START: aphris_sk1_token
const life = relayLife(t);
if (!(life > 0)) return;
const seq = t.deploySeq;
battle.after(life, () => {
if (!t.alive || t.deploySeq !== seq || t.mem.relayKept) return; // 连续映射's mark: "不再进行自动撤退"
battle.retreat(t, { reason: 'expired', permanent: true });
}, { owner: t });
}
export default {
char_4229_aphris: (bb, chess) => {
const t0 = talentBb(chess, 0), t1 = talentBb(chess, 1);
const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3);
return {
skills: {
[S1]: {
kind: 'toggle', trigger: 'SP_FULL', mods: { atkPct: num(b1.atk) },
onStart({ battle, unit }) { for (const t of relaysOf(battle, unit)) if (t.alive) t.mem.relayKept = true; }, // aphris_sk1 → aphris_sk1_token
},
[S2]: {
kind: 'duration',
attack: { noAttack: true },
onStart({ battle, unit }) {
unit.mem.beamAt = 0;
unit.mem.beamNext = BEAM_FIRST;
battle.fx('beam', { x: unit.x, y: unit.y, id: unit.id, skill: 'aphris:beam' });
},
onTick({ battle, unit, skill, dt }) {
const iv = num(b2.interval, 0.5);
if (!(iv > 0)) return;
const t = (unit.mem.beamAt = (unit.mem.beamAt ?? 0) + dt);
while (t >= unit.mem.beamNext - 1e-6 && skill.active && unit.alive) {
unit.mem.beamNext += iv;
for (const e of battle.enemiesInKeys(unit.rangeKeys, unit, unit.profile)) {
battle.dealDamage(unit, e, { amount: unit.s.atk * num(b2.atk_scale), type: 'arts', isSkill: true, tags: ['skill', 'aphris:beam'] });
}
}
},
},
[S3]: {
kind: 'ammo', ammo: num(b3['attack@trigger_time'], 20),
mods: { atkPct: num(b3.atk), batPct: batMod(b3.base_attack_time, chess) },
attack: {
atkScale: num(b3['attack@atk_scale'], 1),
onEachHit({ battle, unit, target }) { // aphris_sk3 on every enemy the attack hits
if (!target || target.side !== 'enemy' || !target.alive) return;
const max = num(b3['attack@max_times'], 1);
const n = max >= 0 ? Math.min(max, relaysBefore(unit, target)) : relaysBefore(unit, target);
const slow = num(b3['attack@origin_sluggish']) + num(b3['attack@extra_sluggish']) * n;
if (slow > 0) battle.applyStatus(target, 'sluggish', { duration: slow, source: unit });
const ex = num(b3['attack@extra_atk_scale']);
for (let i = 0; i < n && target.alive && ex > 0; i++) battle.dealDamage(unit, target, { amount: unit.s.atk * ex, type: 'arts', isSkill: true, tags: ['skill', 'aphris:relay'] });
},
},
onStart({ battle, unit }) {
const ct = num(b3.charge_time);
if (ct > 0) battle.addBuff(unit, { key: CHARGE_KEY, duration: ct, flags: { disarm: true }, tags: ['skill'] }); // 强制缴械
withdrawRelays(battle, unit, true); // aphris_sk3[token-withdraw]
},
onEnd({ battle, unit }) {
battle.removeBuff(unit, CHARGE_KEY);
withdrawRelays(battle, unit, true); // aphris_sk3[on_finish]
},
},
},
talents: [
{ install(battle, unit) { // 链路协议: ATK +atk while one of her 中继器 stands (one buff however many)
const a = num(t0.atk);
if (a) toggleBuff(battle, unit, LINK_KEY, () => relaysOf(battle, unit).some((t) => t.alive && t.deployed), { atkPct: a });
// die_to_kill_token: her pieces leave with her; charge_token[born]: her (re)deployment makes a waiting one ready
battle.on('death', (c) => { if (c.unit === unit) withdrawRelays(battle, unit, false); }, { owner: unit });
battle.on('deploy', (c) => {
if (c.unit !== unit) return;
for (const t of relaysOf(battle, unit)) if (!t.alive && !t.removed) t.mem.relayReadyAt = battle.time;
}, { owner: unit });
} },
{ install(battle, unit) { // 取样优化: 法术穿透 +v on the allied operators of her path
const v = num(t1.magic_resist_penetrate_fixed);
if (!(v > 0)) return;
battle.every(AURA_IV, () => {
if (!up(unit) || !unit.rangeKeySet) return;
for (const a of battle.alliesFor(unit)) {
if (a.kind !== 'op' || !unit.rangeKeySet.has(a.tileR * COLS + a.tileC)) continue;
const cur = a.findBuff(SAMPLING_KEY);
if (cur && cur.source !== unit && (cur.data?.v ?? 0) > v && cur.timeLeft > 0.05) continue;
battle.addBuff(a, { key: SAMPLING_KEY, duration: AURA_DUR, mods: { resIgnoreFlat: v }, source: unit, data: { v }, tags: ['aura'] });
}
}, { owner: unit, immediate: true });
} },
],
install(battle, unit) {
// the 延伸 path: rebuilt on any ally's arrival / departure (her 中继器, an operator S2 bends on), her skill's
// start / end, and checked every tick
battle.on('deploy', (c) => {
const t = c.unit;
if (t === unit) unit.mem.aphrisSig = null;
else if (isRelayOf(t, unit)) relayDeployed(battle, unit, t);
if (t && t.side === 'ally') refreshPath(battle, unit);
}, { owner: unit });
battle.on('death', (c) => { if (c.unit && c.unit.side === 'ally' && c.unit !== unit) refreshPath(battle, unit); }, { owner: unit });
battle.on('skillStart', (c) => { if (c.unit === unit) refreshPath(battle, unit); }, { owner: unit });
battle.on('skillEnd', (c) => { if (c.unit === unit) refreshPath(battle, unit); }, { owner: unit });
battle.on('tick', () => refreshPath(battle, unit), { owner: unit });
// S3's 强制缴械 from the cast on: the attack loop goes on to the attack the cast was made for (ai.js updateAlly
// checks `disarm` before the cast only) — no target, no attack, no bullet spent
battle.on('beforeAttack', (c) => { if (c.attacker === unit && unit.findBuff(CHARGE_KEY)) c.targets = []; }, { owner: unit });
},
};
},
};
+226
View File
@@ -0,0 +1,226 @@
// server/sim/content/kits/ops/op-bgsnow.js — 鸿雪 (char_4055_bgsnow) 自选 operator kit: 6★ 重射手 (狙击), an owned-6★ pick of
// the tier-5 and tier-6 自选 slots; every skill, both talents, the trait, both modules (ARC-Y 打字机色带, ARC-X 仪式感) at
// every form, and the kit of her summon “打字机” (token_10026_bgsnow_subbow). Kit contract and the 自选 rules: ../README.md
// ("How to add an operator (自选)").
//
// Forms (data/backups.json units.char_4055_bgsnow): normal = E2 Lv1, skills at rank 4, no module; elite = E2 Lv60, rank 7,
// the picked module at stage 1 (tier 5) or 3 (tier 6) — the owner's decision of 2026-10-05. Potential 0 [ASSUMED: no
// account]. Sources: character_table / skill_table / battle_equip_table / token_table (zh_CN, as built into backups.json)
// and PRTS 鸿雪 + “打字机” (点题 备注; the {{**|82%|下降18%}} marks = a final ×0.82; PRTS 伤害分类: "鸿雪2技能三段伤害的后2段"
// are 持续伤害).
// - Trait (重射手) "高精度的近距离射击": ranged physical arrows, 3-6, can hit air units (PRTS 分支特性信息 重射手 "可对空"),
// blocks 1, ground enemies target her (no flag).
// - Module ARC-Y “打字机色带” trait "攻击正前方的敌人时攻击力提升至105%且无视其物理闪避" (trait bb atk_scale): her damage on
// an enemy whose body is on her straight line ahead ×atk_scale, not dodgeable — 点题's two 持续伤害 hits included
// [ASSUMED: they are two of its "3次攻击"]; 正前方 = local row 0, column ≥ 0 (her own tile included, as the 3-2 "前方3格"
// lines count it) [ASSUMED]. Stage 2+ "属性更强的打字机": the typewriter's stats of the data (token module attributes).
// - Module ARC-X “仪式感”: "再部署时间减少" (attribute respawn_time −25, in the stats); stage 2+ upgrades 弱点速记 (below).
// - T1 自动打字机 "可使用一个持续25秒的“打字机”,“打字机”拥有和自己一样的技能,拥有独立的再部署时间": the typewriter is a
// hand piece the player places (the mode's talent summons, content/tokens.js header): it deploys with the board, leaves
// after 25 s (the token's hidden talent `interval`), and comes back on its tile after its own redeploy time (data 40 s; ×
// respawn_time while she carries S2), paying its cost (5 DP), while she is on the field [ASSUMED: as the 海嗣]. Its skill
// is the copy of hers the data gives (bySkill: sktok_bgsnow_1 / 2 / 3 — the 卫戍 strategy force-opens a summon's manual
// skill, PRTS 卫戍协议/帮助): free (spCost 0), once per deployment, by the data's DEFAULT. It is untargetable ("不会受到
// 攻击"), blocks nothing, hits air units. content/tokens.js has no kit for it: her install gives her pieces this one.
// - T2 弱点速记 "“打字机”的攻击会使命中目标的防御力下降18%,持续4秒;若“打字机”放在鸿雪周围四格则效果提升至23%": every
// damage the typewriter's attacks land cuts the target's DEF by bgsnow_token[def_down]_1 (_2 while the typewriter stands
// on one of the 4 tiles next to her — while she is on the field [ASSUMED]) for `duration` s: the catalogue `defDown`
// (final ×, 同名 keeps the strongest). ARC-X stage 2+: 28 % / 33 % and "周围八格" (the talent text names the ring).
// - S1 抑扬格 (AUTO, attack SP, data DEFAULT — an AUTO attack buff waits for her next attack): ATK +atk until she leaves the
// field ("持续时间无限": a toggle), and every attack meanwhile `prob` to deal ×atk_scale (one roll per attack).
// - S2 点题 (MANUAL, 2 charges, data SKILL_RANGE on its 3-1): passive — the typewriter's redeploy time ×respawn_time;
// active — 3 attacks at once ("立即": the cast takes the next attack now) on one target of the 3-1, atk_scale × ATK each:
// the first a physical 普通伤害 attack, the other two physical 持续伤害 (PRTS 伤害分类: tags 'dot', not dodgeable).
// PRTS 备注 "仅在干员自身范围内有可攻击目标时可释放": the data's SKILL_RANGE fires for any enemy of the 3-1, untargetable
// ones too; the cast then waits (pending) for an attackable target — reported as an open question (the 备注 reading
// of the strategy would be the basic one on her own range).
// - S3 锐笔速写 (MANUAL, data ACTIVE_RANGE on its 3-1): range 3-1 while it runs, attack interval base_attack_time s (−0.6 on
// 1.6 s), every attack atk_scale × ATK, bgsnow_s_3[atk_up].atk_scale × ATK on the 正前方3格 (the 3-2 line, her tile
// included [ASSUMED]).
import { num, up, traitBb, skillRec, batMod } from '../shared/tier1.js';
import { absoluteRangeKeys } from '../../../targeting.js';
import { bodyKeys, bodyInKeys } from '../../../body.js';
import { toLocal } from '../../../dir.js';
import { hasHp } from '../../../damage.js';
import { COLS } from '../../../constants.js';
const S1 = 'skchr_bgsnow_1';
const S2 = 'skchr_bgsnow_2';
const S3 = 'skchr_bgsnow_3';
export const TYPEWRITER = 'token_10026_bgsnow_subbow';
const TS1 = 'sktok_bgsnow_1';
const TS2 = 'sktok_bgsnow_2';
const TS3 = 'sktok_bgsnow_3';
/** 锐笔速写's 正前方3格: the 3-2 line (range_table) — her tile and the three ahead. */
const FRONT_3 = Object.freeze([[0, 0], [0, 1], [0, 2], [0, 3]]);
/** 点题's two 持续伤害 hits (PRTS 伤害分类 attackType BUFF). */
const TAG_PRECIS = 'bgsnow:precis';
/** 自动打字机: the typewriter's life when neither the token nor the talent carries it (PRTS: 持续25秒 at E2). */
const LIFE_FALLBACK = 25;
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
/** First talent of a normalised token def holding `key` (token talents keep no index). */
const tokTalent = (def, key) => (def?.talents ?? []).find((t) => t && t.bb && t.bb[key] != null) ?? null;
/** ARC-Y 正前方: the enemy's body touches the unit's straight line ahead (local row 0, local column ≥ 0). */
function inFrontLine(unit, e) {
for (const k of bodyKeys(e)) {
const [lr, lc] = toLocal(Math.floor(k / COLS) - unit.tileR, (k % COLS) - unit.tileC, unit.dir);
if (lr === 0 && lc >= 0) return true;
}
return false;
}
/** 点题 / its copy: the two 持续伤害 hits after the attack's first (普通伤害) hit, on the same target. */
function precisFollowUps(battle, unit, target, scale) {
for (let i = 0; i < 2 && target && target.alive; i++) {
battle.dealDamage(unit, target, { amount: unit.s.atk * scale, type: 'phys', isSkill: true, canDodge: false, tags: ['skill', 'dot', TAG_PRECIS] });
}
}
/** 抑扬格 / its copy: while `on(unit)`, every attack `prob` to deal ×scale (one roll per attack). */
function installIamb(battle, unit, prob, scale, on) {
if (!(prob > 0) || !(scale > 0) || scale === 1) return;
battle.on('hit', (ctx) => {
const d = ctx.dmg, t = ctx.target;
if (ctx.source !== unit || !t || t.side !== 'enemy' || !d.isAttack || d.isSplash || d.type === 'element' || !on(unit)) return;
const m = unit.mem;
if (!d.attackId || m.iambRollId !== d.attackId) {
m.iambRollId = d.attackId;
m.iambProc = battle.rng.chance(prob);
if (m.iambProc) battle.fx('crit', { x: t.x, y: t.y, id: unit.id });
}
if (m.iambProc) d.amount *= scale;
}, { owner: unit });
}
/**
* The typewriter's kit: its skill (the copy of her skill the data gives), 弱点速记, its life and its redeploys.
* `owner` = 鸿雪, `chess` = her composed record.
*/
function typewriterKit(t, owner, chess) {
const def = t.def;
const sk = def.skill ?? null;
const tb = sk?.bb ?? {};
const weak = tokTalent(def, 'duration');
const wb = weak?.bb ?? {};
const cut1 = -num(wb['bgsnow_token[def_down]_1.def']), cut2 = -num(wb['bgsnow_token[def_down]_2.def'], -cut1);
const ring8 = /八格/.test(String(weak?.description ?? ''));
const ownTalent = (chess?.talents ?? []).find((x) => x && x.index === 0)?.bb ?? {};
const life = num(tokTalent(def, 'interval')?.bb?.interval, num(ownTalent.duration, LIFE_FALLBACK));
const respawnMul = owner.skill?.id === S2 ? num(bbOf(chess, S2).respawn_time, 1) : 1;
const near = () => {
if (!up(owner)) return false;
const dr = Math.abs(t.tileR - owner.tileR), dc = Math.abs(t.tileC - owner.tileC);
return ring8 ? Math.max(dr, dc) === 1 : dr + dc === 1;
};
const skillOn = (u) => !!(u.skill && u.skill.active);
let skill = null;
if (sk?.id === TS1) skill = { kind: 'toggle', mods: { atkPct: num(tb.atk) } };
else if (sk?.id === TS2) {
const scale = num(tb.atk_scale, 1);
skill = { kind: 'instant', attack: { atkScale: scale, onHit({ battle, unit, target }) { precisFollowUps(battle, unit, target, scale); } } };
} else if (sk?.id === TS3) {
skill = {
kind: 'duration',
targeting: { rangeGrid: sk.rangeGrid ?? [[0, 0], [0, 1], [0, 2], [0, 3], [0, 4]] },
mods: { batPct: batMod(tb.base_attack_time, { stats: def.stats }) },
attack: { atkScale: num(tb['attack@atk_scale'], 1) },
};
}
return {
skill,
talents: [{ install(battle, unit) { // 弱点速记: every damage of its attacks cuts the target's DEF (near her: more)
const dur = num(wb.duration, 4);
if (!(cut1 > 0) || !(dur > 0)) return;
battle.on('damaged', (ctx) => {
if (ctx.source !== unit || !ctx.target || ctx.target.side !== 'enemy' || !hasHp(ctx.target) || ctx.dmg?.type === 'element') return;
battle.applyStatus(ctx.target, 'defDown', { duration: dur, value: near() ? cut2 : cut1, source: unit });
}, { owner: unit });
} }],
install(battle, unit) {
if (sk?.id === TS1) installIamb(battle, unit, num(tb.prob), num(tb.atk_scale, 1), skillOn);
// 持续25秒: leaves `life` s after each deployment
battle.on('deploy', (ctx) => {
if (ctx.unit !== unit || !(life > 0)) return;
const seq = unit.deploySeq;
battle.after(life, () => { if (unit.alive && unit.deploySeq === seq) battle.retreat(unit, { reason: 'expired', permanent: true }); }, { owner: unit });
}, { owner: unit });
// 拥有独立的再部署时间: back on its tile after its redeploy time, paying its cost, while 鸿雪 stands (the piece is kept:
// `removed` false, as content/tokens.js enableRespawn)
battle.on('death', (ctx) => {
if (ctx.unit !== unit || battle.finished) return;
unit.removed = false;
const at = battle.time + Math.max(0, num(unit.base.respawnTime) * respawnMul);
unit.mem.respawnAt = at;
battle.every(0.25, (b, sched) => {
if (unit.alive || unit.removed) { sched.cancel(); return; }
if (b.time + 1e-9 < at || !up(owner)) return;
if (b.redeploy(unit, { free: false })) sched.cancel();
}, { owner: unit });
}, { owner: unit, priority: -10 });
},
};
}
export default {
char_4055_bgsnow: (bb, chess) => {
const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3);
const s2 = skillRec(chess, S2), s3 = skillRec(chess, S3);
const frontScale = num(traitBb(chess).atk_scale, 1);
const s2Scale = num(b2.atk_scale, 1);
const s3Scale = num(b3.atk_scale, 1), s3Front = num(b3['bgsnow_s_3[atk_up].atk_scale'], s3Scale);
return {
skills: {
[S1]: { kind: 'toggle', mods: { atkPct: num(b1.atk) } },
[S2]: {
kind: num(s2?.maxChargeTime, 1) > 1 ? 'charges' : 'instant',
// the 3-1 selects the target; her attack range stays her own (a 技能范围, not a range change)
targeting: { rangeGrid: s2?.rangeGrid ?? null, showOwnRange: true },
attack: { atkScale: s2Scale, onHit({ battle, unit, target }) { precisFollowUps(battle, unit, target, s2Scale); } },
onStart({ unit }) { unit.atkCd = 0; }, // "立即": the pending attack goes off now when a target is there
},
[S3]: {
kind: 'duration',
targeting: { rangeGrid: s3?.rangeGrid ?? null },
mods: { batPct: batMod(b3.base_attack_time, chess) },
attack: { atkScale: s3Scale },
},
},
talents: [
// 自动打字机 (and 弱点速记, the typewriter's own talent): her typewriter pieces run the typewriter's kit — set up
// here, before the battle starts (Battle._setupUnit with a kit, as spawnToken's `kit` option)
{ install(battle, unit) {
for (const t of battle.allyUnits) {
if (t.kind !== 'token' || t.defId !== TYPEWRITER || t.ownerUnit !== unit || t.alive || t.deployed) continue;
if (t.kit) battle.offOwner(t); // a piece set up before her: drop its generic kit's hooks
battle._setupUnit(t, typewriterKit(t, unit, chess));
}
} },
],
install(battle, unit) {
const sid = unit.skill?.id;
if (sid === S1) installIamb(battle, unit, num(b1.prob), num(b1.atk_scale, 1), (u) => !!u.skill?.active);
// 锐笔速写: 正前方3格 ⇒ atk_up's scale instead of the skill's
if (sid === S3 && s3Front !== s3Scale && s3Scale > 0) {
battle.on('hit', (ctx) => {
const d = ctx.dmg, t = ctx.target;
if (ctx.source !== unit || !t || t.side !== 'enemy' || !d.isAttack || !unit.skill?.active) return;
if (bodyInKeys(t, absoluteRangeKeys(FRONT_3, unit.tileR, unit.tileC, unit.dir, 0))) d.amount *= s3Front / s3Scale;
}, { owner: unit });
}
// ARC-Y 打字机色带: 攻击正前方的敌人时攻击力提升至105%且无视其物理闪避
if (frontScale > 0 && frontScale !== 1) {
battle.on('hit', (ctx) => {
const d = ctx.dmg, t = ctx.target;
if (ctx.source !== unit || !t || t.side !== 'enemy' || d.type === 'element') return;
if (!(d.isAttack || d.tags?.includes(TAG_PRECIS)) || !inFrontLine(unit, t)) return;
d.amount *= frontScale;
d.canDodge = false;
}, { owner: unit });
}
},
};
},
};
+180
View File
@@ -0,0 +1,180 @@
// server/sim/content/kits/ops/op-cerber.js — 刻俄柏 (char_2013_cerber) 自选 operator kit: 6★ 中坚术师 (术师), an owned-6★
// pick of the tier-5 and tier-6 自选 slots; every skill, both talents, the trait and both modules at every form.
// Kit contract and the 自选 rules: ../README.md ("How to add an operator (自选)").
//
// Forms (data/backups.json units.char_2013_cerber, the DIY slot statuses): normal = E2 Lv1, skills at rank 4, no module;
// elite = E2 Lv60, rank 7, the picked module at stage 1 (tier 5) or 3 (tier 6). Potential 0 [ASSUMED: no account].
// Sources: character_table / skill_table / battle_equip_table (zh_CN, as built into backups.json; every module part is
// valid outside 集成战略 — no `validInGameTag`) and PRTS 刻俄柏 (S2 "攻击间隔缩短(*0.6)" / "较大幅度缩短(*0.4)": a ratio of the
// interval; 剥壳 备注 "处理敌人防御力时不考虑物理穿透", "<X模组>每次攻击与上一次攻击相同的目标时,将提升5%的比例值(第五次后达到
// 最大值,于剥壳伤害前处理);攻击不同目标时比例值立刻重置"; 独行长路 能力生效范围 x-5 and 备注 "天赋判定范围包括刻俄柏所在格,不判定
// 目标的可选性、实体类型等;天赋生效期间检测到敌人类我方单位时不会主动失效,但失效期间检测到敌人类我方单位后,直到该敌人类我方单位退场前
// 无法重新生效", "<Y模组>本天赋不再通过原本的判定方式生效/失效…部署时检测到敌人类我方单位也会失效").
// - Trait (中坚术师) "攻击造成法术伤害": the profession default (ranged arts, hits air, block 1). Module CCR-X “很干的面包”
// adds "无视目标10点法术抗性" (trait bb magic_resist_penetrate_fixed: a permanent resIgnoreFlat), CCR-Y “我打的刀”
// "普通攻击命中精英或领袖敌人时获得1点技力" (trait bb sp: per hit of one of her attacks on an elite / leader; a running timed
// skill takes no SP, as always).
// - T1 剥壳 "攻击时对目标额外造成相当于其防御力40%的法术伤害": each hit of her attacks (S3's physical spear too) is followed by
// atk_scale × the target's DEF (its current DEF, her own penetration ignored — she has none) as arts damage, a damage
// instance of the talent (not an attack: no further on-hit effects). A target with no DEF takes none (PRTS: she "tries"
// and deals nothing; no flat arts bonus exists in this mode that a zero hit could carry). CCR-X stage 2+ (bb
// basic_atk_scale / delta_atk_scale / max_atk_scale): 50 % +5 % per consecutive hit on the same target, ≤ 75 %, back to
// 50 % on another target (PRTS 备注), counted from the start of each deployment [ASSUMED].
// - T2 独行长路 "当周围四格内没有其他友方单位时,攻击力+8%,攻击速度+8": while no other ally unit (operator, summon or device —
// PRTS: no selectability / entity check) stands on her tile or the four next to it (x-5), with the 敌人类我方单位 rule
// of the 备注 (炎佑: an ally built from an enemy record; it does not switch the talent off, but one seen while it is off
// keeps it off until that unit leaves the field). CCR-Y stage 2+ ("部署时若周围四格内没有其他友方单位,攻击力+15%,攻击速度
// +15"): checked once per deployment — an ally of either kind there switches it off for that deployment; the battle's
// initial deployment counts as one moment, every board piece already placed [ASSUMED: the pieces of the mode are on the
// board together before the battle starts; the official deploys one at a time].
// - S1 “很冰的斧” (AUTO, 可充能 1 / 2, data DEFAULT): the next attack at atk_scale × ATK, on an enemy that nobody blocks first
// (the usual order among those, then the blocked ones), binding (束缚) it for `duration` s.
// - S2 “很热的刀” (MANUAL, 33 / 36 s, data DEFAULT): attack interval × base_attack_time (0.6 / 0.4: PRTS "(*0.4)"), the
// highest-DEF enemy first.
// - S3 “很重的枪” (MANUAL, 56 / 57 s, data ACTIVE_RANGE on 3-3): attack range 3-3 while it runs, ATK +atk, physical damage,
// the lowest-DEF enemy first, each hit silences (失去特殊能力) for attack@silence s.
// - No summon (the forms list no token).
import { num, talentBb, traitBb, skillRec, statBuff, toggleBuff, batMod, giveSp, up } from '../shared/tier1.js';
import { sortEnemyTargets } from '../../../targeting.js';
import { offsetTile } from '../../../dir.js';
import { hasHp } from '../../../damage.js';
const S1 = 'skchr_cerber_1';
const S2 = 'skchr_cerber_2';
const S3 = 'skchr_cerber_3';
/** 独行长路's area: her tile and the four next to it (PRTS 能力生效范围 x-5; the data carries no talent grid). */
const X5 = Object.freeze([[0, 0], [1, 0], [-1, 0], [0, 1], [0, -1]]);
/** S3's 3-3 when the data carries no grid. */
const R33 = Object.freeze([[1, 0], [1, 1], [1, 2], [1, 3], [0, 0], [0, 1], [0, 2], [0, 3], [-1, 0], [-1, 1], [-1, 2], [-1, 3]]);
/** 剥壳's extra damage (a talent instance: never itself followed by 剥壳). */
const SHELL_TAG = 'cerber:shell';
const LONE_KEY = 'talent:cerber:lone';
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
/** An elite or leader enemy (精英或领袖敌人). */
const isEliteEnemy = (e) => !!e && e.side === 'enemy' && (e.isBoss || e.def?.rank === 'ELITE' || e.def?.rank === 'BOSS');
const tagged = (d, tag) => !!(d && d.tags && d.tags.includes(tag));
/** A module trait that works 在集成战略中 only (ISW modules): nothing of it applies in this mode. */
const roguelikeOnly = (chess) => /集成战略/.test(String(chess?.trait?.moduleDesc ?? ''));
/** Named talent `i` of the record (its desc tells the module stage's wording). */
const talentRec = (chess, i) => (chess?.talents ?? []).filter((t) => t && t.index !== -1)[i] ?? null;
/** PRTS's 敌人类我方单位: an ally built from an enemy record (炎佑 enemy_9012_acloon, content/items/battle.js). */
const enemyLike = (a) => /^enemy_/.test(String(a?.defId ?? a?.def?.id ?? ''));
/** The other ally units on her x-5 (any kind — PRTS: "不判定目标的可选性、实体类型等"). */
function othersAround(battle, unit) {
const out = [];
for (const [dr, dc] of X5) {
const [r, c] = offsetTile(unit.tileR, unit.tileC, dr, dc, unit.dir);
const o = battle.grid.inBounds(r, c) ? battle.unitAt(r, c) : null;
if (o && o !== unit && o.side === 'ally' && !out.includes(o)) out.push(o);
}
return out;
}
export default {
char_2013_cerber: (bb, chess) => {
const t0 = talentBb(chess, 0), t1 = talentBb(chess, 1);
const onDeploy = /部署时/.test(String(talentRec(chess, 1)?.desc ?? '')); // CCR-Y stage 2+
const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3);
const s1 = skillRec(chess, S1), s2 = skillRec(chess, S2), s3 = skillRec(chess, S3);
return {
skills: {
[S1]: {
kind: num(s1?.maxChargeTime, 1) > 1 ? 'charges' : 'instant',
attack: { atkScale: num(b1.atk_scale, 1), onHitStatus: { key: 'bind', duration: num(b1.duration) } },
},
[S2]: {
kind: 'duration',
mods: { batPct: batMod(b2.base_attack_time, chess, s2?.desc) },
targeting: { priority: 'highDef' },
},
[S3]: {
kind: 'duration',
mods: { atkPct: num(b3.atk) },
targeting: { rangeGrid: s3?.rangeGrid ?? R33, priority: 'lowDef' },
attack: { dmgType: 'phys', onHitStatus: { key: 'silence', duration: num(b3['attack@silence']) } },
},
},
talents: [
{ install(battle, unit) { // 剥壳: DEF × ratio arts after each hit; CCR-X stage 2+: the ratio climbs on the same target
const base = num(t0.basic_atk_scale, num(t0.atk_scale)), delta = num(t0.delta_atk_scale), top = num(t0.max_atk_scale, base);
if (!(base > 0)) return;
battle.on('deploy', (ctx) => { if (ctx.unit === unit) { unit.mem.cerberShellTarget = null; unit.mem.cerberShellScale = base; } }, { owner: unit });
battle.on('damaged', (ctx) => {
const t = ctx.target, d = ctx.dmg;
if (ctx.source !== unit || !t || t.side !== 'enemy' || !d || !d.isAttack || d.isSplash || tagged(d, SHELL_TAG)) return;
let sc = base;
if (delta > 0) {
// "于剥壳伤害前处理": the same target as her previous hit +delta (≤ max), another one back to the base
sc = unit.mem.cerberShellTarget === t ? Math.min(top, num(unit.mem.cerberShellScale, base) + delta) : base;
unit.mem.cerberShellTarget = t;
unit.mem.cerberShellScale = sc;
}
const def = t.s.def;
if (def > 0 && hasHp(t)) battle.dealDamage(unit, t, { amount: def * sc, type: 'arts', tags: [SHELL_TAG] });
}, { owner: unit });
} },
{ install(battle, unit) { // 独行长路
const mods = { atkPct: num(t1.atk), aspd: num(t1.attack_speed) };
if (!mods.atkPct && !mods.aspd) return;
if (onDeploy) {
// CCR-Y stage 2+: the deployment's check, for the whole deployment
const check = () => {
if (!up(unit)) return;
if (othersAround(battle, unit).length) battle.removeBuff(unit, LONE_KEY);
else battle.addBuff(unit, { key: LONE_KEY, mods, tags: ['talent'] });
};
battle.on('battleStart', check, { owner: unit });
battle.on('deploy', (ctx) => { if (ctx.unit === unit && battle.started) check(); }, { owner: unit });
return;
}
// the base talent, checked every tick: an ordinary ally switches it off; an enemy-like one seen while it is off
// keeps it off until that unit leaves the field
let on = false;
const locks = new Set();
battle.on('tick', () => {
if (!up(unit)) { on = false; locks.clear(); return; }
const near = othersAround(battle, unit);
if (on && near.some((a) => !enemyLike(a))) on = false;
if (!on) {
for (const a of near) if (enemyLike(a)) locks.add(a);
for (const a of locks) if (!a.alive || !a.deployed) locks.delete(a);
if (!near.length && !locks.size) on = true;
}
}, { owner: unit, priority: 1 });
toggleBuff(battle, unit, LONE_KEY, () => on, mods);
} },
],
install(battle, unit) {
// module traits (none on the normal form): CCR-X 无视目标10点法术抗性, CCR-Y SP per hit on an elite / leader
if (!roguelikeOnly(chess)) {
const tb = traitBb(chess);
const pen = num(tb.magic_resist_penetrate_fixed);
if (pen > 0) statBuff(battle, unit, 'trait:cerber:resIgnore', { resIgnoreFlat: pen });
const sp = num(tb.sp);
if (sp > 0) {
battle.on('damaged', (ctx) => {
const d = ctx.dmg;
if (ctx.source !== unit || !d || !d.isAttack || d.isSplash || !isEliteEnemy(ctx.target)) return;
giveSp(unit, sp, 'trait');
}, { owner: unit });
}
}
if (unit.skill?.id === S1) {
// “很冰的斧” "优先攻击没有被阻挡的目标": the pending attack re-picks its target, unblocked enemies first
battle.on('beforeAttack', (ctx) => {
if (ctx.attacker !== unit || !ctx.isSkill || !ctx.targets.length) return;
const prof = ctx.profile;
const cands = battle.enemiesInKeys(unit.rangeKeys, unit, prof);
for (const e of battle.blockedTargets(unit, prof)) if (!cands.includes(e)) cands.push(e);
if (!cands.length) return;
sortEnemyTargets(battle, unit, cands, prof.priority);
ctx.targets = [cands.find((e) => !e.blockedBy) ?? cands[0]];
}, { owner: unit });
}
},
};
},
};
+158
View File
@@ -0,0 +1,158 @@
// server/sim/content/kits/ops/op-cgbird.js — 夜莺 (char_179_cgbird) 自选 operator kit: 6★ 群愈师 (医疗), an owned-6★ pick of
// the tier-5 and tier-6 自选 slots; every skill, both talents, the trait, both modules at every form and her summon 幻影.
// Kit contract and the 自选 rules: ../README.md ("How to add an operator (自选)").
//
// Forms (data/backups.json units.char_179_cgbird, the DIY slot statuses): normal = E2 Lv1, skills at rank 4, no module;
// elite = E2 Lv60, rank 7, the picked module at stage 1 (tier 5) or 3 (tier 6). Potential 0 [ASSUMED: no account].
// Sources: character_table / skill_table / battle_equip_table (zh_CN, as built into backups.json; the 幻影 token record
// backups.json tokens.token_10003_cgbird_bird with its owner-form / module variants) and PRTS 夜莺 (S2 备注
// "多个屏障吸收量可叠加,持续时间独立计算,优先消耗先生成的屏障,法术抗性增加效果不可叠加"; 转瞬即逝的幻影 备注 "幻影的持有上限为3个,
// 每次部署夜莺时补充2个幻影 / Y模组2级时…补充3个 / 夜莺离场时,已部署的幻影会留在场上"); PRTS 卫戍协议/帮助 §作战阶段 (summons:
// "所有手动部署的召唤物,无视所属干员的持有状态,不消耗持有数量,作战开始时立即部署一次", "若战场区初始部署有召唤物,若召唤物在
// 战斗期间退场,将在满足条件后立即原地再部署1个").
// - Trait (群愈师) "同时恢复三个友方单位的生命": the profession default (3 heals). Module RIN-Y “莺歌” "同时恢复四个友方单位的
// 生命" (hidden module talent attack@max_target / skill@max_target 4): 4 heals (kit trait, as 调香师's RIN-Y). RIN-X
// “封闭的希望” "攻击范围扩大": her range becomes the module's own grid (its range-only talent change, talentIndex −1: y-2
// plus the centre tile [0,3] — PRTS y-3), as shared/loadoutRecord.js attackRangeGrid draws it; 圣域 keeps its own y-4.
// - T1 白恶魔的庇护 "攻击范围内的友方单位法术抗性+15": every ally (operators and summons) standing in her current attack range
// — her own tile included — RES +magic_resistance (flat). RIN-X stage 2+ "…且受到的治疗效果提升5%" (talent change
// heal_scale): also healing received ×heal_scale there. Refreshed every 0.2 s; two sources keep the strongest.
// - T2 转瞬即逝的幻影 (summon token_10003_cgbird_bird: no attack, blocks nothing, RES 75, taunt level 1 — its stats — and
// its talent "30%的物理闪避…每秒流失3%的最大生命": dodgePhys prob, a 流失 of hp_ratio × its max HP every second; RIN-Y
// stage 3 "40%的物理闪避": the module variant's talent). The 幻影 is a hand piece (the match's per-player shop): a placed
// one deploys with the board at the battle start for free and without using her stock. Each deployment of 夜莺 adds
// cnt (2; RIN-Y stage 2+: 3) to her stock, at most PHANTOM_HOLD_CAP; a placed 幻影 that falls comes back on its own tile
// once its redeploy time (respawnTime) has passed and she holds one — using one, paying its DP cost as a deployment
// does [ASSUMED: "满足条件" = a 幻影 held, its redeploy time, the DP], whether 夜莺 is on the field or not (deployed
// 幻影 stay when she leaves: PRTS 备注). No tokens.js kit: its talent and its return are this kit's (owner-coupled,
// as tokens.js "Managed mode"), applied at each of its deployments.
// - S1 治疗强化·γ型 (MANUAL, 30 s, data DEFAULT — heal skill): ATK +atk.
// - S2 法术护盾 (AUTO, 2 charges, data DEFAULT — a "next heal" waits for her heal, as 闪灵's S2): "下次治疗使所有目标获得一个
// 持续N秒的屏障,屏障能吸收相当于夜莺攻击力X%的法术伤害,同时使目标法术抗性+Y" — every target of that heal (3 / 4) gets a
// 屏障 of atk_scale × her ATK for duration s that absorbs arts damage only (buff shieldType 'arts', damage.js
// absorbShields) and RES +magic_resistance (flat) while one of her barriers lasts (op-shining.js grantBarrier: each
// barrier its own timer, older ones first, the RES bonus not stacking) [ASSUMED: the bonus goes with the last barrier].
// - S3 圣域 (MANUAL, 60 s, data ACTIVE_RANGE on its y-4 — the owner's rule: an injured ally inside it): range y-4 while it
// runs, ATK +atk; every ally in that range RES +magic_resistance (×(1 + v): "法术抗性+105%") and dodgeArts prob
// ("获得20%的法术闪避"), refreshed every 0.2 s.
// Medic: heals only (dmgType heal), ranged, no anti-air question; ground enemies target her normally (no 起飞 / 迷彩 / 隐匿).
import { num, talentBb, moduleBb, moduleOn, skillRec } from '../shared/tier1.js';
import { rangeAura, grantBarrier } from './op-shining.js';
const S1 = 'skcom_heal_up[3]';
const S2 = 'skchr_cgbird_2';
const S3 = 'skchr_cgbird_3';
export const PHANTOM = 'token_10003_cgbird_bird';
/** PRTS 转瞬即逝的幻影 备注 "幻影的持有上限为3个" (no blackboard key). */
export const PHANTOM_HOLD_CAP = 3;
/** Seconds between two tries of a fallen 幻影 to come back (tile busy, DP short, none held). */
const RETRY = 0.25;
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
/**
* RIN-X "攻击范围扩大": the picked module's own range grid (its range-only talent change, talentIndex −1), or null — the
* rule of shared/loadoutRecord.js attackRangeGrid (the card / board range) and shared/tier5.js moduleRangeUp.
*/
function moduleRange(chess) {
if (!chess?.isGolden || !moduleOn(chess) || !/攻击范围扩大/.test(String(chess?.trait?.moduleDesc ?? ''))) return null;
const rec = (chess.modules ?? []).find((m) => m && m.uniEquipId === chess.module.id);
const g = (rec?.talentChanges ?? []).find((t) => t && t.talentIndex === -1 && Array.isArray(t.rangeGrid) && t.rangeGrid.length)?.rangeGrid;
return g ? g.map((p) => [p[0], p[1]]) : null;
}
/**
* The 幻影's own talent blackboard (`prob`, `hp_ratio`) from its def at the owner's form and module: the last talent
* holding `prob` — a module's upgrade (RIN-Y stage 3: 40 %) follows the base talent in the variant data.
* @param {object} def normalised token def
*/
export function phantomTalent(def) {
let out = {};
for (const t of def?.talents ?? []) if (t && t.bb && t.bb.prob != null) out = t.bb;
return out;
}
export default {
char_179_cgbird: (bb, chess) => {
const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3);
const s2 = skillRec(chess, S2), s3 = skillRec(chess, S3);
const t0 = talentBb(chess, 0); // 白恶魔的庇护: magic_resistance (RIN-X stage 2+: heal_scale)
const t1 = talentBb(chess, 1); // 转瞬即逝的幻影: cnt (RIN-Y stage 2+: 3)
const hidden = moduleBb(chess); // RIN-Y: attack@max_target
const heals = Math.floor(num(hidden['attack@max_target']));
const grid = moduleRange(chess);
const picked = chess?.skill?.skillId ?? null;
const tokenId = (chess?.talents ?? []).find((t) => t && t.tokenKey)?.tokenKey ?? PHANTOM;
return {
trait: heals > 0 ? { heal: { mode: 'multi', count: heals } } : undefined,
skills: {
[S1]: { kind: 'duration', heal: true, mods: { atkPct: num(b1.atk) } },
[S2]: {
kind: num(s2?.maxChargeTime, 1) > 1 ? 'charges' : 'instant',
heal: true,
attack: { healScale: 1 }, // the next heal (ai.js doHeal calls onHit for every heal target)
onHit({ battle, unit, target }) {
if (!target || target.side !== 'ally') return;
grantBarrier(battle, unit, target, {
key: 'cgbird:barrier', statKey: 'cgbird:barrierRes', shield: unit.s.atk * num(b2.atk_scale),
duration: num(b2.duration), mods: { resFlat: num(b2.magic_resistance) }, shieldType: 'arts',
});
},
},
[S3]: { kind: 'duration', heal: true, mods: { atkPct: num(b3.atk) }, targeting: s3?.rangeGrid ? { rangeGrid: s3.rangeGrid } : undefined },
},
talents: [
{ install(battle, unit) { // 白恶魔的庇护: allies in her attack range RES +magic_resistance; RIN-X stage 2+: healing ×heal_scale
const r = num(t0.magic_resistance), hs = num(t0.heal_scale, 1);
if (!(r > 0) && hs === 1) return;
const mods = { ...(r ? { resFlat: r } : null), ...(hs !== 1 ? { healingTakenMul: hs } : null) };
rangeAura(battle, unit, { key: 'talent:cgbird:res', value: r + (hs - 1) * 100, mods });
} },
{ install(battle, unit) { // 转瞬即逝的幻影: the 幻影's talent, her stock, a fallen 幻影's return
const cnt = Math.max(0, Math.floor(num(t1.cnt)));
if (!(cnt > 0)) return;
unit.mem.phantomStock = 0;
const mine = (t) => !!t && t.kind === 'token' && t.defId === tokenId && t.ownerUnit === unit;
battle.on('deploy', (ctx) => {
const u = ctx.unit;
if (u === unit) {
unit.mem.phantomStock = Math.min(PHANTOM_HOLD_CAP, (unit.mem.phantomStock ?? 0) + cnt);
return;
}
if (!mine(u)) return;
const tal = phantomTalent(u.def);
const p = num(tal.prob), hr = num(tal.hp_ratio);
if (p > 0) battle.addBuff(u, { key: 'cgbird:phantomDodge', mods: { dodgePhys: p }, tags: ['talent'] });
if (hr > 0) {
battle.addBuff(u, {
key: 'cgbird:phantomDrain', interval: 1, tags: ['talent'],
onTick: ({ unit: t }) => battle.loseHp(t, t.s.maxHp * hr, { silent: true, tags: ['cgbird:phantom'] }),
});
}
}, { owner: unit });
battle.on('death', (ctx) => {
const t = ctx.unit;
if (!mine(t) || ctx.reason !== 'killed' || battle.finished) return;
t.removed = false; // the piece stays (Battle.redeploy accepts it; its hooks are kept)
const at = battle.time + Math.max(0, num(t.base.respawnTime));
battle.every(RETRY, (b, sched) => {
if (t.alive || t.removed || b.finished) { sched.cancel(); return; }
if (b.time + 1e-9 < at || !((unit.mem.phantomStock ?? 0) > 0)) return;
if (b.redeploy(t, { free: false })) { unit.mem.phantomStock--; sched.cancel(); }
}, { owner: unit });
}, { owner: unit });
} },
],
install(battle, unit) {
// RIN-X: the module's range is her own (the initial range of the DEFAULT trigger too); 圣域 runs its y-4
if (grid) { unit.rangeGrid = grid; battle.refreshRange(unit); }
// 圣域: allies in her (y-4) range RES ×(1 + magic_resistance), arts dodge prob, while the skill runs
if (picked === S3) {
const mr = num(b3.magic_resistance), p = num(b3.prob);
if (mr > 0 || p > 0) rangeAura(battle, unit, { key: 'skill:cgbird:sanctuary', value: mr, mods: { resMul: 1 + mr, dodgeArts: p }, on: () => !!unit.skill?.active });
}
},
};
},
};
+199
View File
@@ -0,0 +1,199 @@
// server/sim/content/kits/ops/op-chen.js — 陈 (char_010_chen) 自选 operator kit: 6★ 剑豪 (近卫), an owned-6★ pick of the
// tier-5 and tier-6 自选 slots; every skill, both talents, the trait and both modules at every form.
// Kit contract and the 自选 rules: ../README.md ("How to add an operator (自选)").
//
// Forms (data/backups.json units.char_010_chen, the DIY slot statuses): normal = E2 Lv1, skills at rank 4, no module;
// elite = E2 Lv60, rank 7, the picked module at stage 1 (tier 5) or 3 (tier 6). Potential 0 [ASSUMED: no account].
// Sources: character_table / skill_table / battle_equip_table (zh_CN, as built into backups.json), PRTS 陈 (S2 备注
// "可对空;先造成法术伤害,后造成物理伤害"; S3 备注 "不可对空 / 技能生效期间,持有效果:无敌,无法阻挡,眩晕免疫,冻结免疫 / 若斩击次数
// 未到10次之前技能范围内没有目标,立刻中止技能"; SWO-X 特性 修正 "技能期间造成的伤害提升10%", 原因 6 "描述与游戏实际表现不符合"),
// PRTS 分支特性信息 §剑豪, her battle skeleton char_010_chen.skel (front; the client's own spine parser: Skill 1 OnAttack,
// Skill_2 1 OnAttack, Skill_3 2.933 s with OnAttack at 0.6 + 0.2k (k = 0…8), 2.833 and 2.933).
// - Trait (剑豪) "普通攻击连续造成两次伤害": the profession default (hits 2; professions.js `sword`), ground-only melee
// (canHitFly false) on her 1-1 — PRTS 分支 "可以且优先攻击自身阻挡的单位(即使…为飞行单位)" is the engine's blocked-first rule.
// Module SWO-X “罗德岛制式剑” adds "技能造成的伤害提升10%" (trait bb damage_scale) — PRTS corrects it to 技能期间: every damage
// she deals while her skill runs (the S1 strike, the S2 cut, the S3 slashes: all of them her skill's) ×1.1; SWO-Y
// “往昔时光” adds "攻击时无视敌人70点的防御力" (def_penetrate_fixed): defIgnoreFlat on all her damage (as 锏's SWO-Y).
// - T1 呵斥 "在场时每4秒回复全场友方角色1点攻击/受击技力": every `interval` s from each deployment, +sp SP to every ally of the
// field (her included, a teammate's in a shared field too; Battle.alliesFor: no 孤立 unit) whose skill recovers by
// attacking or by being hit — not a time-SP skill, nobody whose timed skill runs (giveSp), nobody under 阻回. SWO-X
// stage 2+ "每3秒" (the module talent's interval); stage 3 "自身额外回复1点技力": the hidden module talent part
// (chen_equip_1_3_p2, its own interval / sp) — +1 more for her, read from the module's talentChanges (its bb merges
// into the visible talent's with the same keys). [ASSUMED] two 陈 of a shared field (two players) both give theirs.
// - T2 持刀格斗术 "攻击力+5%,防御力+5%,物理闪避+10%": a permanent ATK / DEF / 物理闪避 buff; SWO-Y stage 3: +15 % / +15 % /
// 18 % (the module talent change replaces the numbers).
// - S1 鞘击 (AUTO, attack SP): the next attack is one sheath strike (her skeleton's Skill clip: one OnAttack — not the
// trait's two) for atk_scale × ATK physical that stuns the target `stun` s; the data's DEFAULT trigger.
// - S2 赤霄·拔刀 (MANUAL, attack SP, 技能范围 3-12, data SKILL_RANGE): at once, up to max_target enemies of the skill range —
// flyers too (PRTS 备注 可对空) — each take atk_scale × ATK arts, then atk_scale × ATK physical (PRTS: 先法术后物理);
// the official target order (blocked first, then nearest the goal). Its range never becomes her attack range.
// - S3 赤霄·绝影 (MANUAL, attack SP, 技能范围 x-1, data SKILL_RANGE): she seeks the nearest ground enemy of the skill range
// and slashes it `times` (10) times for atk_scale × ATK physical each, the last stunning it `stun` s; a target that falls
// or leaves is replaced by the next nearest; with no target left in the skill range the skill ends at once (PRTS 备注).
// The slashes fall at her skeleton's Skill_3 OnAttack times (0.6 … 2.2 s every 0.2 s, then 2.833 s) and the skill lasts
// the clip (2.933 s) [ASSUMED: the first ten of its eleven OnAttack events are the ten slashes, the eleventh marks the
// clip's end]. Meanwhile (PRTS 备注): 无敌 (flag invulnerable), 无法阻挡 (noBlock: her blocked enemies walk on), stun and
// freeze immunity, no normal attack. Ground only (不可对空): no flyer, no levitated enemy is slashed. The skill cannot be
// opened while its range holds no ground enemy at all [ASSUMED: PRTS 艾丽妮 S3's 备注 "技能范围内仅存在飞行单位时,该技能
// 也可开启" marks the opposite as the rule for a skill that strikes ground enemies only; the strategy's SKILL_RANGE still
// counts unselectable ones, "无视其不可选中", so a stealthed ground enemy alone opens it and it ends with nobody to cut].
import { num, talentBb, traitBb, moduleOn, skillRec, statBuff, giveSp, onHitBy } from '../shared/tier1.js';
import { absoluteRangeKeys, sortEnemyTargets } from '../../../targeting.js';
const S1 = 'skchr_chen_1';
const S2 = 'skchr_chen_2';
const S3 = 'skchr_chen_3';
/** char_010_chen.skel (front) Skill_3: the slashes' OnAttack times from the cast (s) and the clip's length. */
const S3_SLASH_AT = Object.freeze([0.6, 0.8, 1.0, 1.2, 1.4, 1.6, 1.8, 2.0, 2.2, 2.833]);
const S3_CLIP = 2.933;
/** The skill ranges when a record carries none (data: 3-12 and x-1). */
const R3_12 = Object.freeze([[1, 0], [1, 1], [0, 0], [0, 1], [0, 2], [0, 3], [-1, 0], [-1, 1]]);
const X_1 = Object.freeze([[2, 0], [1, -1], [1, 0], [1, 1], [0, -2], [0, -1], [0, 0], [0, 1], [0, 2], [-1, -1], [-1, 0], [-1, 1], [-2, 0]]);
const AIR = Object.freeze({ canHitFly: true });
const GROUND = Object.freeze({ canHitFly: false });
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
/** The module the record fights with (its active one), or null. */
function activeModule(chess) {
if (!moduleOn(chess)) return null;
return (chess.modules ?? []).find((m) => m && m.uniEquipId === chess.module.id) ?? null;
}
/** Absolute tile keys of a skill range at the unit's tile and facing (a 技能范围 ignores 攻击距离: no extend). */
const keysOf = (unit, grid) => absoluteRangeKeys(grid, unit.tileR, unit.tileC, unit.dir, 0);
/**
* A skill that strikes ground enemies only cannot be opened while its range holds no ground enemy: every activation path
* (the strategy's SKILL_RANGE, content) is refused and the charge stays — the pattern of 莎草 S2 (ops/chess_char_2_06-papyrs.js,
* non-enumerable: never serialised).
*/
function guardActivation(battle, unit, grid) {
const sk = unit.skill;
if (!sk || Object.prototype.hasOwnProperty.call(sk, 'activate')) return;
const activate = sk.activate;
const ok = () => battle.unitsInGrid(unit, grid, { side: 'enemy' }).some((e) => !e.isFlying);
Object.defineProperty(sk, 'activate', {
configurable: true, writable: true, enumerable: false,
value(reason, opts) { return ok() ? activate.call(this, reason, opts) : false; },
});
}
export default {
char_010_chen: (bb, chess) => {
const t0 = talentBb(chess, 0), t1 = talentBb(chess, 1);
const mod = activeModule(chess);
// SWO-X stage 3 "自身额外回复1点技力": the hidden part of the 呵斥 change (its own interval / sp)
const self0 = mod?.talentChanges?.find((t) => t && t.talentIndex === 0 && t.hidden)?.bb ?? {};
const tb = traitBb(chess);
const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3);
const s2 = skillRec(chess, S2), s3 = skillRec(chess, S3);
const grid2 = s2?.rangeGrid ?? R3_12, grid3 = s3?.rangeGrid ?? X_1;
const times3 = Math.min(S3_SLASH_AT.length, Math.max(1, Math.floor(num(b3.times, 10))));
/** S3: the slash target — the current one while it stays a ground target of the skill range, else the nearest. */
const slashTarget = (battle, unit, m) => {
const list = battle.enemiesInKeys(keysOf(unit, grid3), unit, GROUND);
if (m.target && list.includes(m.target)) return m.target;
if (!list.length) return null;
sortEnemyTargets(battle, unit, list, 'nearest');
m.target = list[0];
return m.target;
};
return {
skills: {
[S1]: {
kind: num(skillRec(chess, S1)?.maxChargeTime, 1) > 1 ? 'charges' : 'instant',
attack: {
atkScale: num(b1.atk_scale, 1),
hits: 1, // one sheath strike (Skill clip: one OnAttack), not the trait's two
onHit({ battle, unit, target }) {
if (target && target.alive && target.side === 'enemy' && num(b1.stun) > 0) battle.applyStatus(target, 'stun', { duration: num(b1.stun), source: unit });
},
},
},
[S2]: {
kind: num(s2?.maxChargeTime, 1) > 1 ? 'charges' : 'instant',
onStart({ battle, unit }) {
const list = battle.enemiesInKeys(keysOf(unit, grid2), unit, AIR);
sortEnemyTargets(battle, unit, list, null);
const v = list.slice(0, Math.max(1, Math.floor(num(b2.max_target, 1))));
battle.fx('slash', { x: unit.x, y: unit.y, id: unit.id, n: v.length, skill: 'chen:draw' });
const amount = () => unit.s.atk * num(b2.atk_scale, 1);
for (const e of v) {
// "先造成法术伤害,后造成物理伤害" (PRTS 备注)
battle.dealDamage(unit, e, { amount: amount(), type: 'arts', isSkill: true, tags: ['skill'] });
if (e.alive) battle.dealDamage(unit, e, { amount: amount(), type: 'phys', isSkill: true, tags: ['skill'] });
}
},
},
[S3]: {
kind: 'duration',
duration: S3_CLIP,
flags: { invulnerable: true, noBlock: true },
attack: { noAttack: true },
onStart({ battle, unit }) {
battle.releaseBlocked(unit); // 无法阻挡
unit.mem.chenS3 = { t: 0, n: 0, target: null };
battle.fx('blink', { x: unit.x, y: unit.y, id: unit.id, skill: 'chen:shadowless' });
},
onTick({ battle, unit, skill, dt }) {
const m = unit.mem.chenS3;
if (!m) return;
m.t += dt;
// "若斩击次数未到10次之前技能范围内没有目标,立刻中止技能" (after the last slash the clip runs out)
if (m.n < times3 && !slashTarget(battle, unit, m)) { skill.end('noTarget'); return; }
while (m.n < times3 && m.t + 1e-9 >= S3_SLASH_AT[m.n]) {
const e = slashTarget(battle, unit, m);
if (!e) { skill.end('noTarget'); return; }
m.n++;
battle.fx('slash', { x: e.x, y: e.y, id: unit.id, n: 1, skill: 'chen:shadowless' });
battle.dealDamage(unit, e, { amount: unit.s.atk * num(b3.atk_scale, 1), type: 'phys', isSkill: true, tags: ['skill', 'slash'] });
if (m.n === times3 && e.alive && num(b3.stun) > 0) battle.applyStatus(e, 'stun', { duration: num(b3.stun), source: unit });
if (!unit.alive || !skill.active) return;
}
},
onEnd({ unit }) { unit.mem.chenS3 = null; },
},
},
talents: [
{ install(battle, unit) { // 呵斥: every `interval` s on the field, +sp attack / hurt SP to every ally; SWO-X stage 3: +1 more for her
const iv = num(t0.interval), sp = num(t0.sp);
const selfIv = num(self0.interval, iv), selfSp = num(self0.sp);
if (!(iv > 0) || !(sp > 0)) return;
const mates = (b) => b.alliesFor(unit).filter((a) => a.skill && !a.skill.noSkill && (a.skill.spType === 'attack' || a.skill.spType === 'hurt'));
battle.on('deploy', (ctx) => {
if (ctx.unit !== unit) return;
const seq = unit.deploySeq;
const here = () => unit.alive && unit.deployed && unit.deploySeq === seq;
battle.every(iv, (b, sc) => {
if (!here()) { sc.cancel(); return; }
for (const a of mates(b)) giveSp(a, sp);
}, { owner: unit });
if (selfSp > 0 && selfIv > 0) {
battle.every(selfIv, (b, sc) => {
if (!here()) { sc.cancel(); return; }
giveSp(unit, selfSp);
}, { owner: unit });
}
}, { owner: unit });
} },
{ install(battle, unit) { // 持刀格斗术: ATK / DEF / 物理闪避 (SWO-Y stage 3: the module numbers)
statBuff(battle, unit, 'talent:chen:knife', { atkPct: num(t1.atk), defPct: num(t1.def), dodgePhys: num(t1.prob) });
} },
],
install(battle, unit) {
// SWO-Y “往昔时光”: 攻击时无视敌人70点的防御力
statBuff(battle, unit, 'trait:chen:pierce', { defIgnoreFlat: num(tb.def_penetrate_fixed) });
// SWO-X “罗德岛制式剑”: 技能期间造成的伤害提升10% (PRTS 修正)
const skillMul = num(tb.damage_scale, 1);
if (skillMul !== 1) onHitBy(battle, unit, ({ dmg }) => { if (dmg.isSkill || unit.skill?.active) dmg.mul *= skillMul; });
// S3: 眩晕免疫, 冻结免疫 while the slashes run
battle.on('beforeStatus', (c) => {
if (c.target === unit && unit.mem.chenS3 && (c.status === 'stun' || c.status === 'freeze')) c.cancel = true;
}, { owner: unit });
if (unit.skill?.id === S3) guardActivation(battle, unit, grid3);
},
};
},
};
+210
View File
@@ -0,0 +1,210 @@
// server/sim/content/kits/ops/op-chyue.js — 重岳 (char_2024_chyue) 自选 operator kit: 6★ 斗士 (近卫), an owned-6★ pick of
// the tier-5 and tier-6 自选 slots; every skill, both talents, the trait and both modules at every form.
// Kit contract and the 自选 rules: ../README.md ("How to add an operator (自选)").
//
// Forms (data/backups.json units.char_2024_chyue, the DIY slot statuses): normal = E2 Lv1, skills at rank 4, no module;
// elite = E2 Lv60, rank 7, the picked module at stage 1 (tier 5) or 3 (tier 6). Potential 0 [ASSUMED: no account].
// Sources: character_table / skill_table / battle_equip_table (zh_CN, as built into backups.json); PRTS 重岳 (talent and
// skill 备注); the client's buff templates (buff_template_data `chyue_t_1` / `_passive`, `chyue_t_2` / `_sp`,
// `chyue_e_002_t[unkill]`, `chyue_s_1_enhance_judge`, `chyue_s_2_levitate` / `_finish_levitate` / `_atk_scale`,
// `chyue_s_3_enhance` / `_trigger` / `_end_check`); Arknights Terra Wiki "Chongyue".
// - Trait (斗士) "能够阻挡一个敌人": the profession default (block 1, melee, ground only). Module FGT-X "拥有15%的物理闪避"
// (trait bb prob): a permanent physical dodge. Module FGT-Y "生命值高于50%时攻击速度+10" (trait bb attack_speed /
// hp_ratio): ASPD while HP > hp_ratio.
// - T1 止戈 "对目标普通攻击时,有23%的概率使重岳2.5秒内对其造成的伤害提升65%" (bb prob / damage_scale / up_duration):
// chyue_t_1 rolls on every damage instance a normal attack outputs (PRTS 备注 "于普通攻击每次造成伤害时独立触发,重复施加
// 时仅刷新持续时间" — after it lands, so not for that instance) and marks the target for up_duration s (his own mark:
// independentCharacterSource); chyue_t_1_passive: every damage of his to a marked enemy × damage_scale (DamageScale, a
// final multiplier — skills included). FGT-Y stage 3: damage_scale 1.75. S2's second segment marks at 100 %.
// - T2 万象为宾 "若重岳释放一次技能击倒不少于一个敌人,则回复3点技力" (bb sp): PRTS 备注 — only a kill by skill damage counts,
// judged when the skill (or an ability of it) finishes; the SP ignores 阻回 (ModifySp forceFlag). FGT-X stage 3:
// 4 SP, and "未击倒敌人时变为回复1点技力" (the hidden part's sp: chyue_e_002_t[unkill]). S2's second segment is judged on
// its own (PRTS: "第二段攻击打出时恢复一次SP…只与这第二段攻击是否击杀了敌人有关").
// - S1 冲盈 (MANUAL, attack SP, 可充能 3 次; data DEFAULT): the next attack strikes its target for atk_scale × ATK; cast
// from a full stack (蓄力, chyue_s_1_enhance_judge: ≥ 3 charges) it spends every charge (ClearCharacterSp) and strikes
// `times` times.
// - S2 拂尘 (MANUAL, 可充能 2 次; data SKILL_RANGE on its x-4): at once up to max_target enemies around him (PRTS 备注
// "可对空,优先选择自身第一天赋生效中的>自身阻挡的>仇恨值最高的敌人") take atk_scale × ATK physical, and those he marked are
// 浮空 for 2 s (chyue_s_2_levitate: ground enemies only — the engine refuses data flyers); then the second segment
// (SecAttack, outside the skill — 0.5 s later: his skeleton's Skill_2_Begin hits at 0.367 s of its 0.833 s, Skill_2_End
// at 0.033 s): every 浮空 enemy around him loses every 浮空 buff (chyue_s_2_finish_levitate), takes atk_scale_down × ATK
// physical and gets his mark (100 %, after that damage). The first segment lands at the cast [ASSUMED: the kits'
// instant convention — the clip's 0.367 s wind-up is not modelled].
// - S3 我无 (MANUAL, attack SP; data DEFAULT): the next attack strikes its target and the ground enemies within 0.8 of it
// (PRTS 备注 "溅射半径0.8"; the skill cannot hit air) for atk_scale × ATK. Its cast_cnt-th cast of a deployment turns him
// (【变身】, chyue_s_3_end_check — "第五次开启本技能时【变身】(每次部署单独计算)"; that cast keeps its single strike, the
// text's "累计使用五次技能后"): his range becomes x-6, every normal attack hits twice and gives 1 more SP ignoring 阻回
// (chyue_s_3_enhance), the skill fires by itself the moment its SP is full and a normal-attack target exists
// (chyue_s_3_trigger TriggerSkill, checked every frame: not the 3 s automatic-operation cooldown) and its strike lands
// twice. The fixed animation (不受攻击速度影响) is modelled as one attack interval [ASSUMED].
import { num, talentBb, moduleBb, traitBb, up, onHitBy, statBuff, toggleBuff, skillRec } from '../shared/tier1.js';
import { absoluteRangeKeys, sortEnemyTargets } from '../../../targeting.js';
import { hasHp, isHpLoss } from '../../../damage.js';
const S1 = 'skchr_chyue_1';
const S2 = 'skchr_chyue_2';
const S3 = 'skchr_chyue_3';
/** S2's 技能范围 when the data carries none (x-4: the 8 tiles around him and his own). */
const X4 = Object.freeze([[1, -1], [1, 0], [1, 1], [0, -1], [0, 0], [0, 1], [-1, -1], [-1, 0], [-1, 1]]);
/** His range after the S3 【变身】 (PRTS 备注 "攻击范围扩大至 x-6"; range_table x-6). */
const X6 = Object.freeze([[2, 0], [1, 0], [0, -2], [0, -1], [0, 0], [0, 1], [0, 2], [-1, 0], [-2, 0]]);
/** S3 "溅射半径0.8" (PRTS 备注). */
const S3_SPLASH = 0.8;
/** S2 浮空 "持续2s" (PRTS 备注; chyue_s_2_levitate lifeTime 2). */
const S2_LEVITATE = 2;
/** S2's second segment after the first: the skeleton's OnAttack events (Skill_2_Begin 0.367 / 0.833 s, Skill_2_End 0.033 s). */
const S2_SECOND_DELAY = 0.833 + 0.033 - 0.367;
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
const markKey = (unit) => `talent:chyue:mark#${unit.id}`;
const marked = (unit, e) => !!e.findBuff(markKey(unit));
export default {
char_2024_chyue: (bb, chess, def) => {
const tb = traitBb(chess);
const t0 = talentBb(chess, 0), t1 = talentBb(chess, 1);
const hb = moduleBb(chess); // FGT-X stage 3: sp when the skill killed nobody
const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3);
const s2 = skillRec(chess, S2);
const picked = def?.skill?.id ?? chess?.skill?.skillId ?? null;
const castCnt = Math.max(1, Math.floor(num(b3.cast_cnt, 5)));
const mark = (battle, unit, e) => {
if (hasHp(e)) battle.addBuff(e, { key: markKey(unit), duration: num(t0.up_duration, 2.5), refresh: 'replace', source: unit, tags: ['talent'] });
};
/** 万象为宾: the SP of a finished skill / segment (a kill by his skill damage: sp, else the FGT-X stage 3 part). */
const settle = (unit) => {
const killed = !!unit.mem.chyueKilled;
unit.mem.chyueKilled = false;
const n = killed ? num(t1.sp) : num(hb.sp);
if (n > 0 && up(unit) && unit.skill) unit.skill.gainSp(n, 'init'); // ModifySp forceFlag: 阻回 does not stop it
};
const s2Keys = (unit) => absoluteRangeKeys(s2?.rangeGrid?.length ? s2.rangeGrid : X4, unit.tileR, unit.tileC, unit.dir, 0);
/** S2's second segment (SecAttack). */
const secondSegment = (battle, unit, seq) => {
if (!up(unit) || unit.deploySeq !== seq) return;
unit.mem.chyueKilled = false;
const atk = unit.s.atk;
const lifted = battle.enemiesInKeys(s2Keys(unit), unit, { canHitFly: true }).filter((e) => e.s.flags.levitate);
if (lifted.length) battle.fx('aoe', { x: unit.x, y: unit.y, radius: 1.5, id: unit.id, skill: 'chyue:down' });
for (const e of lifted) {
for (const b of e.buffs.slice()) if (b.flags && b.flags.levitate) battle.removeBuff(e, b);
if (!e.alive) continue;
battle.dealDamage(unit, e, { amount: atk * num(b2.atk_scale_down, 1), type: 'phys', isSkill: true, tags: ['skill'] });
mark(battle, unit, e);
}
settle(unit);
};
/** After the S3 【变身】: x-6, double hits, +1 SP per attack, the self-cast. */
const awaken = (battle, unit) => {
unit.mem.chyueAwake = true;
unit.rangeGrid = X6;
battle.refreshRange(unit);
battle.fx('buff', { x: unit.x, y: unit.y, id: unit.id, kind: 'atk' });
};
return {
skills: {
[S1]: {
kind: 'charges',
attack: { atkScale: num(b1.atk_scale, 1), hitsFn: (b, u) => u.mem.chyueS1Hits || 1 },
onStart({ unit, skill }) {
// 蓄力: the cast took one charge of a full stack ⇒ the rest goes too and the strike lands `times` times
const full = skill.maxCharges > 1 && skill.charges === skill.maxCharges - 1;
unit.mem.chyueS1Hits = full ? Math.max(1, Math.floor(num(b1.times, skill.maxCharges))) : 1;
if (full) { skill.charges = 0; skill.sp = 0; }
},
onEnd({ unit }) { unit.mem.chyueS1Hits = 1; settle(unit); },
},
[S2]: {
kind: num(s2?.maxChargeTime, 1) > 1 ? 'charges' : 'instant',
onStart({ battle, unit }) {
unit.mem.chyueKilled = false;
const foes = battle.enemiesInKeys(s2Keys(unit), unit, { canHitFly: true });
sortEnemyTargets(battle, unit, foes, null); // the ones he blocks first, then aggro
foes.sort((a, b) => (marked(unit, b) ? 1 : 0) - (marked(unit, a) ? 1 : 0)); // his marked ones before them (stable)
const atk = unit.s.atk;
battle.fx('aoe', { x: unit.x, y: unit.y, radius: 1.5, id: unit.id, skill: 'chyue:whisk' });
for (const e of foes.slice(0, Math.max(1, Math.floor(num(b2.max_target, 4))))) {
if (!e.alive) continue;
const lift = marked(unit, e);
battle.dealDamage(unit, e, { amount: atk * num(b2.atk_scale, 1), type: 'phys', isSkill: true, tags: ['skill'] });
if (lift && e.alive) battle.applyStatus(e, 'levitate', { duration: S2_LEVITATE, source: unit });
}
const seq = unit.deploySeq;
battle.after(S2_SECOND_DELAY, () => secondSegment(battle, unit, seq), { owner: unit });
},
onEnd({ unit }) { settle(unit); },
},
[S3]: {
kind: 'instant',
attack: {
atkScale: num(b3.atk_scale, 1), splashRadius: S3_SPLASH, hitsFn: () => 1,
onHit({ battle, unit, target, x, y }) {
if (!unit.mem.chyueAwake) return;
// after the 【变身】 the strike lands once more: its target and the ground enemies within 0.8 of it
const amount = unit.s.atk * num(b3.atk_scale, 1);
const cx = target ? target.x : x, cy = target ? target.y : y;
if (target && target.alive) battle.dealDamage(unit, target, { amount, type: 'phys', isSkill: true, tags: ['skill'] });
for (const e of battle.foesInRadius(cx, cy, S3_SPLASH, true)) {
if (e === target || !e.alive || e.isFlying || e.s.flags.untargetable) continue;
battle.dealDamage(unit, e, { amount, type: 'phys', isSkill: true, isSplash: true, tags: ['skill'] });
}
},
},
onStart({ unit }) { unit.mem.chyueCasts = (unit.mem.chyueCasts || 0) + 1; },
onEnd({ battle, unit }) {
settle(unit);
if (!unit.mem.chyueAwake && unit.mem.chyueCasts >= castCnt && up(unit)) awaken(battle, unit);
},
},
},
talents: [
{ install(battle, unit) { // 止戈
const p = num(t0.prob), scale = num(t0.damage_scale, 1);
if (scale !== 1) onHitBy(battle, unit, ({ target, dmg }) => { if (marked(unit, target)) dmg.mul *= scale; });
if (!(p > 0)) return;
battle.on('damaged', (c) => {
const e = c.target, d = c.dmg;
if (c.source !== unit || !e || e.side !== 'enemy' || !d || !d.isAttack || d.isSkill || isHpLoss(d) || c.type === 'element' || !hasHp(e)) return;
if (battle.rng.chance(p)) mark(battle, unit, e);
}, { owner: unit });
} },
{ install(battle, unit) { // 万象为宾: a kill by his skill damage, for the running skill / segment (settle)
battle.on('damaged', (c) => {
if (c.source === unit && c.dmg && c.dmg.isSkill && c.target && c.target.side === 'enemy' && !hasHp(c.target)) unit.mem.chyueKilled = true;
}, { owner: unit });
} },
],
// the S3 【变身】's double hit (normal attacks only: S1 / S3 strikes set their own hits)
trait: picked === S3 ? { hitsFn: (b, u) => (u.mem.chyueAwake ? 2 : 1) } : undefined,
install(battle, unit) {
// FGT-X 物理闪避 / FGT-Y 生命值高于50%时攻击速度+10
if (num(tb.prob) > 0) statBuff(battle, unit, 'trait:chyue:dodge', { dodgePhys: num(tb.prob) });
if (num(tb.attack_speed)) toggleBuff(battle, unit, 'trait:chyue:aspd', () => unit.hpRatio > num(tb.hp_ratio, 0.5), { aspd: num(tb.attack_speed) });
// every deployment starts untransformed with no cast counted (每次部署单独计算)
battle.on('deploy', ({ unit: u }) => {
if (u !== unit) return;
unit.mem.chyueCasts = 0;
unit.mem.chyueAwake = false;
unit.mem.chyueS1Hits = 1;
unit.mem.chyueKilled = false;
const own = unit.def?.rangeGrid;
if (own && unit.rangeGrid !== own) { unit.rangeGrid = own; battle.refreshRange(unit); }
}, { owner: unit });
if (picked !== S3) return;
// 【变身】: +1 SP per normal attack (阻回 ignored) and the self-cast the moment SP and a target are there
battle.on('attack', (c) => {
if (c.attacker === unit && !c.isSkill && unit.mem.chyueAwake && unit.skill) unit.skill.gainSp(1, 'init');
}, { owner: unit });
battle.on('tick', () => {
const sk = unit.skill;
if (!unit.mem.chyueAwake || !sk || !up(unit) || !unit.canAct || !sk.ready || sk.pending || unit.s.flags.silence) return;
const prof = battle.effectiveProfile(unit);
if (!battle.enemiesInKeys(unit.rangeKeys, unit, prof).length && !battle.blockedTargets(unit, prof).length) return;
if (!sk.activate('awake')) return;
if (battle.forceAttack(unit)) unit.atkCd = Math.max(unit.atkCd, unit.s.interval);
}, { owner: unit });
},
};
},
};
+124
View File
@@ -0,0 +1,124 @@
// server/sim/content/kits/ops/op-helage.js — 赫拉格 (char_188_helage) 自选 operator kit: 6★ 武者 (近卫), an owned-6★ pick of
// the tier-5 and tier-6 自选 slots; every skill, both talents, the trait and both modules at every form.
// Kit contract and the 自选 rules: ../README.md ("How to add an operator (自选)").
//
// Forms (data/backups.json units.char_188_helage, the DIY slot statuses): normal = E2 Lv1, skills at rank 4, no module;
// elite = E2 Lv60, rank 7, the picked module at stage 1 (tier 5) or 3 (tier 6). Potential 0 [ASSUMED: no account].
// Sources: character_table / skill_table / battle_equip_table (zh_CN, as built into backups.json), PRTS 赫拉格 (talents,
// skills, modules: no 备注), PRTS 分支特性信息 §武者 ("常态持有禁疗;通过自身特性/天赋/技能产生的作用于自身的治疗效果会无视自身的
// 禁疗"; "特性治疗于干员每次输出伤害时触发(不局限于攻击)"), gamedata_const ba.berserk 坚忍 ("根据已损失的生命值获得相应比例的
// 属性加成,损失一定比例时达最大加成(同类属性取最高)") and ba.protect 庇护 ("受到的物理和法术伤害降低相应比例(同名效果取最高)"), his
// battle skeleton char_188_helage.skel (Skill: two OnAttack, Skill_2: two, Skill_3_Loop: one).
// - Trait (武者) "不成为其他角色的治疗目标,每次攻击到敌人后回复自身70生命": the profession default (professions.js `musha`: no
// heal from others, value HP per enemy an attack hits), ground-only melee on his 1-1, block 1. The branch heals on every
// damage instance he deals: the engine heals once per enemy an attack strikes, so the second hit of S1 / S2's 二连击 on
// an enemy heals once more here (the trait's `value`, a self heal).
// - T1 月盈星亏 "在场时,自身获得最高+100攻击速度的坚忍(损失70%生命值时达到最大加成)": ASPD + min_attack_speed × the share of
// the HP lost up to 1 − min_hp_ratio (linear, 坚忍), refreshed every tick. SBL-X stage 3: +130 at 50 % lost (the module
// talent change).
// - T2 运筹帷幄 "未阻挡敌人时每秒回复60生命": hp_recovery_per_sec is the 生命回复速度 attribute (the stat hpRecoveryPerSec;
// SIM.md §4: an hpRegen buff, not a heal — no 治疗加成, never stopped by 禁疗), on while he blocks nobody [ASSUMED: PRTS
// has no 备注 on it; the blackboard key is the attribute's, as 角峰 S1 / 宴 S1 in their kits — the 0.2.0 生命回复速度 audit
// lists such 「每秒回复」 + hp_recovery_per_sec effects as open; a heal would reach him too, 武者's own heals ignore his
// 禁疗, and only 治疗加成 would differ]. SBL-Y stage 3
// "未阻挡敌人或生命值低于30%时每秒回复80生命": 80 / s and the hidden module talent (index 2, hp_ratio / hp_recovery_per_sec):
// the same regeneration below hp_ratio while he blocks [ASSUMED: the text's 或 — 80 / s when either holds, never both
// parts at once].
// - Module SBL-X “藏锋” "生命值低于50%时,获得25%的庇护" (a display trait part; the effect is the hidden talent part hp_ratio /
// damage_resistance, merged into talent 0's blackboard): below hp_ratio of his max HP the physical and arts damage he takes
// ×(1 − damage_resistance) (as 宴's elite module).
// - Module SBL-Y “热的雪” "被击倒时不撤退且回复30%生命(单次部署只触发1次)" (trait bb hp_ratio): the first lethal blow of a
// deployment leaves him on the field at hp_ratio of his max HP (a kit saver, priority −50 as 斯卡蒂's module).
// - S1 新月 (AUTO, attack SP, DEFAULT): the next attack at atk_scale × ATK, twice (Skill clip: two OnAttack).
// - S2 弦月 (MANUAL, time SP, DEFAULT): `duration` s — ATK +atk, every attack two hits (二连击), 物理闪避 +prob.
// - S3 满月 (MANUAL, time SP, data ACTIVE_RANGE on the running 1-1 + 1): `duration` s — ATK +atk, attack range +1 tile
// forward (ability_range_forward_extend), up to attack@max_target targets at once; back to 1-1 after.
import { num, traitBb, skillRec, toggleBuff, up, onHitOn } from '../shared/tier1.js';
const S1 = 'skchr_helage_1';
const S2 = 'skchr_helage_2';
const S3 = 'skchr_helage_3';
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
/** The blackboard of the talent with data index `i` ({} when absent; module parts with a new index included). */
const talentAt = (chess, i) => (chess?.talents ?? []).find((t) => t && t.index === i)?.bb ?? {};
export default {
char_188_helage: (bb, chess) => {
const t0 = talentAt(chess, 0), t1 = talentAt(chess, 1), t2 = talentAt(chess, 2);
const tb = traitBb(chess);
const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3);
return {
skills: {
[S1]: {
kind: num(skillRec(chess, S1)?.maxChargeTime, 1) > 1 ? 'charges' : 'instant',
attack: { atkScale: num(b1.atk_scale, 1), hits: 2 },
},
[S2]: {
kind: 'duration',
mods: { atkPct: num(b2.atk), dodgePhys: num(b2.prob) },
attack: { hits: 2 },
},
[S3]: {
kind: 'duration',
mods: { atkPct: num(b3.atk) },
targeting: { rangeExtend: num(b3.ability_range_forward_extend), maxTargets: Math.max(1, Math.floor(num(b3['attack@max_target'], 1))) },
},
},
talents: [
{ install(battle, unit) { // 月盈星亏: 坚忍 ASPD up to min_attack_speed, reached at min_hp_ratio HP (linear in the HP lost)
const maxAs = num(t0.min_attack_speed), minHp = num(t0.min_hp_ratio);
if (!(maxAs > 0) || !(minHp < 1)) return;
const key = 'talent:helage:berserk';
battle.on('tick', () => {
const cur = unit.findBuff(key);
if (!up(unit)) { if (cur) battle.removeBuff(unit, key); return; }
const v = maxAs * Math.max(0, Math.min(1, (1 - unit.hpRatio) / (1 - minHp)));
if (cur && Math.abs((cur.data?.v ?? 0) - v) < 0.05) return;
if (v < 0.05) { if (cur) battle.removeBuff(unit, key); return; }
battle.addBuff(unit, { key, mods: { aspd: v }, data: { v }, tags: ['talent'] });
}, { owner: unit });
} },
{ install(battle, unit) { // 运筹帷幄: 生命回复速度 while blocking nobody; SBL-Y stage 3: or below hp_ratio HP
const regen = num(t1.hp_recovery_per_sec);
if (regen > 0) toggleBuff(battle, unit, 'talent:helage:regen', () => unit.blocking.length === 0, { hpRegen: regen });
const lowHp = num(t2.hp_ratio), lowRegen = num(t2.hp_recovery_per_sec);
if (lowRegen > 0 && lowHp > 0) {
toggleBuff(battle, unit, 'talent:helage:regenLow', () => unit.blocking.length > 0 && unit.hpRatio < lowHp, { hpRegen: lowRegen });
}
} },
],
install(battle, unit) {
// 武者: the trait heal on every damage instance — the engine heals once per enemy an attack strikes, so each further
// hit of the same attack on that enemy (S1, S2's 二连击) heals once more
battle.on('damaged', (c) => {
const d = c.dmg;
if (c.source !== unit || !unit.alive || !d || !d.isAttack || !d.attackId || !c.target || c.target.side !== 'enemy') return;
const m = unit.mem;
if (m.helageAttackId !== d.attackId) { m.helageAttackId = d.attackId; m.helageStruck = new Set(); }
if (!m.helageStruck.has(c.target.id)) { m.helageStruck.add(c.target.id); return; }
battle.heal(unit, unit, num(unit.profile?.selfHeal, num(tb.value, 70)), { self: true });
}, { owner: unit, priority: -10 });
// SBL-X “藏锋”: 生命值低于50%时,获得25%的庇护 (physical and arts damage taken)
const dr = num(t0.damage_resistance), drBelow = num(t0.hp_ratio);
if (dr > 0 && drBelow > 0) {
onHitOn(battle, unit, ({ dmg }) => {
if ((dmg.type === 'phys' || dmg.type === 'arts') && unit.hpRatio < drBelow) dmg.mul *= 1 - dr;
});
}
// SBL-Y “热的雪”: 被击倒时不撤退且回复30%生命(单次部署只触发1次)
const rise = num(tb.hp_ratio);
if (rise > 0) {
battle.on('fatal', (ctx) => {
if (ctx.unit !== unit || ctx.prevented || unit.mem.helageRoseSeq === unit.deploySeq) return;
unit.mem.helageRoseSeq = unit.deploySeq;
ctx.prevented = true;
unit.hp = Math.max(1, unit.s.maxHp * rise);
battle.fx('undying', { x: unit.x, y: unit.y, id: unit.id });
}, { owner: unit, priority: -50 });
}
},
};
},
};
+275
View File
@@ -0,0 +1,275 @@
// server/sim/content/kits/ops/op-heyak.js — 霍尔海雅 (char_4027_heyak) 自选 operator kit: 6★ 中坚术师 (术师), an owned-6★
// pick of the tier-5 and tier-6 自选 slots; every skill, both talents, the trait and the three modules at every form.
// Kit contract and the 自选 rules: ../README.md ("How to add an operator (自选)").
//
// Forms (data/backups.json units.char_4027_heyak, the DIY slot statuses): normal = E2 Lv1, skills at rank 4, no module;
// elite = E2 Lv60, rank 7, the picked module at stage 1 (tier 5) or 3 (tier 6). Potential 0 [ASSUMED: no account].
// Sources: character_table / skill_table / battle_equip_table (zh_CN, as built into backups.json) and PRTS 霍尔海雅
// (传承终焉 备注 "技能触发的浮空效果于本天赋结算前进行结算"; S1 备注 "“仅攻击到1名目标”仅判定实际发射的弹道数"; S3 备注 "攻击以自身格
// 中心处为准,于此处、左1.0、右1.0格在0~0.3s内随机延迟后生成三股旋风", "移动速度1.0,碰撞范围为1×1的正方形,旋风移动至5格远处中心点
// 自然消失,造成伤害时实时应用霍尔海雅的当前攻击力", "旋风在生成后1.77s内,攻击倍率由最低值线性递增至最大值", "霍尔海雅离场时已存在的旋风
// 不会因此立刻消失,但其命中敌人时不享受模组加成,不触发第一天赋").
// - Trait (中坚术师) "攻击造成法术伤害": the profession default (ranged arts, hits air, block 1). Module CCR-X “图书馆” adds
// "无视目标10点法术抗性" (trait bb magic_resist_penetrate_fixed: a permanent resIgnoreFlat), CCR-Y “羽蛇的时间博物馆”
// "普通攻击命中精英或领袖敌人时获得1点技力" (trait bb sp: per hit of one of her attacks on an elite / leader; a running timed
// skill takes no SP). ISW-A “霍尔海雅特限证章”: its trait "在集成战略中,无视目标10点法术抗性,每个敌人首次进入目标点所在地块时,恢复
// 自身30%的技力" and its hidden talents (首次部署 1 点临时目标生命值, the goal-tile 浮空 15 s, stage 3's radius-1.1 skill
// splash "自身技能会对敌人及其周围的目标同时造成浮空效果和伤害效果") are battle_equip_table parts with `validInGameTag:
// roguelike` — N/A here; what applies is its stats and, at stage 2+, 传承终焉's new numbers (a TALENT_DATA_ONLY part with
// no game tag: 145 % / 5 s at stage 3).
// - T1 传承终焉 "攻击空中目标时攻击力提升至120%,并使其特殊能力失效3秒": every hit of her attacks on an air unit (flying, 近地悬浮
// or 浮空) at the hit is × atk_scale and silences it for `silence` s. Her skills' 浮空 of that very hit resolve first (PRTS
// 备注), so a target her S1 / S2 / S3 lifts takes the ×atk_scale of that hit: the S1 / S2 lifts land in the hit's `hit`
// step, ahead of this talent (a dodged hit has lifted already [ASSUMED]). CCR-X stage 3: 135 % / 5 s, ISW-A stage 3:
// 145 % / 5 s (the talent's merged bb).
// - T2 曾有羽翼 "攻击范围内所有生命值高于80%的敌人失重": every enemy of her current attack range (a running S3's included) above
// hp_ratio of its HP is 失重 (weight −1 level, 同名效果不可叠加: none of hers while another source's 失重 is on it), off
// again at once below it or outside the range. CCR-Y stage 3 lowers it to 50 % and adds "生命值低于50%的敌人获得20%的
// 【法术脆弱】" (its hidden talent: damage_scale 1.2, hp_ratio 0.5): the 法术脆弱 status, renewed every 0.2 s for 0.3 s
// (as 史尔特尔 AFT-Y's).
// - S1 但为求索 (AUTO, 可充能 1 / 2, data DEFAULT): the next attack hits one more target (2), atk_scale × ATK arts each; when
// it fires at one target only it lifts it (浮空) `levitate` s — counted at the shot (PRTS 备注).
// - S2 群星逶迤 (MANUAL, 16 s, data DEFAULT): each attack becomes SHOTS (9, the text's "9连发"; no blackboard key) shots of
// attack@atk_scale × ATK, each at a random enemy of her range (independent draws), each with attack@prob to lift it
// attack@levitate s [ASSUMED: the nine leave with the attack; the burst's spacing is not modelled].
// - S3 博览者的狂语 (MANUAL, 45 s, data ACTIVE_RANGE on 3-10): attack range 3-10 while it runs, attack interval +1.4 s
// (base_attack_time, flat); each attack (an enemy in that range, her attack timer) releases three whirlwinds from the
// centre of her tile and the tiles left and right of it, each after its own random 0–0.3 s delay. A whirlwind flies
// straight forward at 1 tile/s over 5 tiles (PRTS; a 1×1 square), hits the first enemy it touches — an enemy her
// attacks can select, air units too [ASSUMED] — lifting it attack@levitate s and then dealing (min + (max − min) ×
// min(1, age / 1.77 s)) × her current ATK arts (attack@min_atk_scale → attack@max_atk_scale), and is gone; the nearest
// of several touched at once is hit first [ASSUMED]. A whirlwind out when she leaves the field flies on, without 传承终焉
// and without her module's RES ignore [ASSUMED: "模组加成" = the module's trait; its attributes stay in her ATK].
// (wiki.gg reads the end of the flight as the far end of her range, 4 tiles; PRTS's 5 is kept.)
// - No summon (the forms list no token).
import { num, talentBb, traitBb, skillRec, statBuff, batMod, giveSp, onHitBy, up } from '../shared/tier1.js';
import { canTargetEnemy } from '../../../targeting.js';
import { dirVec, rotateOffset } from '../../../dir.js';
import { bodyDist, hitRect } from '../../../body.js';
const S1 = 'skchr_heyak_1';
const S2 = 'skchr_heyak_2';
const S3 = 'skchr_heyak_3';
/** S1 "额外攻击一个目标": the next attack's targets (no blackboard key; `cnt` is the charge count). */
const S1_TARGETS = 2;
/** S2 "9连发": shots per attack when the text gives no number (no blackboard key). */
const SHOTS = 9;
/** S3 whirlwinds (PRTS 备注): lanes (facing-RIGHT row offsets: her row, "左1.0", "右1.0"), spawn delay, speed, flight, box. */
const WIND_LANES = Object.freeze([0, 1, -1]);
const WIND_DELAY = 0.3;
const WIND_SPEED = 1;
const WIND_RANGE = 5;
const WIND_HALF = 0.5;
/** "旋风在生成后1.77s内,攻击倍率由最低值线性递增至最大值". */
const WIND_RAMP = 1.77;
/** How often a flying whirlwind is drawn again (fx 'tornado'). */
const WIND_FX_EVERY = 0.5;
/** S3's 3-10 when the data carries no grid. */
const R310 = Object.freeze([[1, 0], [1, 1], [1, 2], [1, 3], [1, 4], [0, 0], [0, 1], [0, 2], [0, 3], [0, 4], [-1, 0], [-1, 1], [-1, 2], [-1, 3], [-1, 4]]);
/** A whirlwind hit after she left the field: no 传承终焉, no module trait (PRTS 备注). */
const AWAY_TAG = 'heyak:away';
const WEIGHTLESS_KEY = 'heyak:weightless';
const FRAGILE_EVERY = 0.2;
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
/** An elite or leader enemy (精英或领袖敌人). */
const isEliteEnemy = (e) => !!e && e.side === 'enemy' && (e.isBoss || e.def?.rank === 'ELITE' || e.def?.rank === 'BOSS');
const tagged = (d, tag) => !!(d && d.tags && d.tags.includes(tag));
/** A module trait that works 在集成战略中 only (ISW-A): nothing of it applies in this mode. */
const roguelikeOnly = (chess) => /集成战略/.test(String(chess?.trait?.moduleDesc ?? ''));
/** The record's active module, or null. */
const activeModule = (chess) => (chess?.module?.active ? (chess.modules ?? []).find((m) => m && m.uniEquipId === chess.module.id) ?? null : null);
/** Does the enemy's body touch the 1×1 square centred on (x, y)? */
function inBox(e, x, y) {
const R = hitRect(e);
if (!R) return Math.abs(e.x - x) <= WIND_HALF + 1e-9 && Math.abs(e.y - y) <= WIND_HALF + 1e-9;
return R.x0 <= x + WIND_HALF && R.x1 >= x - WIND_HALF && R.y0 <= y + WIND_HALF && R.y1 >= y - WIND_HALF;
}
export default {
char_4027_heyak: (bb, chess) => {
const t0 = talentBb(chess, 0), t1 = talentBb(chess, 1);
const isw = roguelikeOnly(chess);
const mod = activeModule(chess);
// CCR-Y's hidden part (the 【法术脆弱】 rider); an ISW module's hidden parts are 集成战略-only
const rider = isw || !mod ? {} : Object.assign({}, ...(mod.talentChanges ?? []).filter((t) => t && t.talentIndex === -1).map((t) => t.bb ?? {}));
const tb = isw ? {} : traitBb(chess);
const pen = num(tb.magic_resist_penetrate_fixed);
const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3);
const s1 = skillRec(chess, S1), s2 = skillRec(chess, S2), s3 = skillRec(chess, S3);
const shots = Math.max(1, Math.floor(num(+(/(\d+)连发/.exec(String(s2?.desc ?? ''))?.[1]), SHOTS)));
/** S3: release one attack's three whirlwinds. */
function releaseWinds(battle, unit, winds) {
const seq = unit.deploySeq;
const [fr, fc] = dirVec(unit.dir);
const x0 = unit.tileC, y0 = unit.tileR;
for (const lane of WIND_LANES) {
const [dr, dc] = rotateOffset(lane, 0, unit.dir);
const delay = battle.rng.range(0, WIND_DELAY);
battle.after(delay, () => {
// a whirlwind not yet out when she left is never released [ASSUMED: "已存在的旋风" only fly on]
if (!(unit.alive && unit.deployed && unit.deploySeq === seq)) return;
const w = { x: x0 + dc, y: y0 + dr, dx: fc, dy: fr, left: WIND_RANGE, age: 0, fxAge: 0, seq };
battle.fx('tornado', { x: w.x, y: w.y, id: unit.id, r: WIND_HALF, dur: WIND_FX_EVERY + 0.1 });
if (!windHit(battle, unit, w)) winds.push(w);
}, { owner: unit });
}
}
/** The first enemy the whirlwind touches, struck: 浮空 first (PRTS: before 传承终焉), then the damage. True when it hit. */
function windHit(battle, unit, w) {
let best = null, bd = Infinity;
for (const e of battle.enemies) {
if (!e.alive || !inBox(e, w.x, w.y) || !canTargetEnemy(unit, e, unit.profile)) continue;
const d = bodyDist(e, w.x, w.y);
if (d < bd - 1e-9 || (Math.abs(d - bd) <= 1e-9 && best && e.spawnSeq < best.spawnSeq)) { bd = d; best = e; }
}
if (!best) return false;
const away = !(unit.alive && unit.deployed && unit.deploySeq === w.seq);
const lev = num(b3['attack@levitate']);
if (lev > 0) battle.applyStatus(best, 'levitate', { duration: lev, source: unit });
const lo = num(b3['attack@min_atk_scale']), hi = num(b3['attack@max_atk_scale'], lo);
const scale = lo + (hi - lo) * Math.min(1, w.age / WIND_RAMP);
if (best.alive) {
battle.dealDamage(unit, best, {
amount: unit.s.atk * scale, type: 'arts', isAttack: true, isSkill: true,
...(away ? { resIgnoreFlat: -pen, tags: [AWAY_TAG] } : null),
});
}
battle.fx('tornadoPulse', { x: w.x, y: w.y, id: unit.id, r: WIND_HALF });
return true;
}
return {
skills: {
[S1]: {
kind: num(s1?.maxChargeTime, 1) > 1 ? 'charges' : 'instant',
targeting: { maxTargets: S1_TARGETS },
attack: { atkScale: num(b1.atk_scale, 1) },
},
[S2]: {
kind: 'duration',
attack: { atkScale: num(b2['attack@atk_scale'], 1) },
},
[S3]: {
kind: 'duration',
mods: { batPct: batMod(b3.base_attack_time, chess) },
targeting: { rangeGrid: s3?.rangeGrid ?? R310 },
// the attack becomes the whirlwinds (onTick): her attack timer, an enemy of the skill's range, no projectile
attack: { noAttack: true },
onTick({ battle, unit }) {
if (unit.atkCd > 0 || !unit.canAct || unit.s.flags.disarm) return;
const foes = battle.enemiesInKeys(unit.rangeKeys, unit, unit.profile);
if (!foes.length) return;
unit.atkCd = unit.s.interval;
unit.lastAttackAt = battle.time;
unit.stats.attacks++;
releaseWinds(battle, unit, unit.mem.heyakWinds);
// an attack was performed ("攻击时" content), the whirlwinds still on their way
if (battle.hasHook('attack')) battle.emit('attack', { attacker: unit, targets: foes.slice(0, 1), isSkill: true });
},
},
},
talents: [
{ install(battle, unit) { // 传承终焉: × atk_scale and 沉默 on an air unit hit by her attack
const sc = num(t0.atk_scale, 1), sil = num(t0.silence);
onHitBy(battle, unit, (ctx) => {
const d = ctx.dmg, t = ctx.target;
if (!d.isAttack || tagged(d, AWAY_TAG) || !t.isFlying) return;
if (sc !== 1) d.amount *= sc;
if (sil > 0 && t.alive) battle.applyStatus(t, 'silence', { duration: sil, source: unit });
});
} },
{ install(battle, unit) { // 曾有羽翼: 失重 above hp_ratio in her range; CCR-Y stage 3: 【法术脆弱】 below its hp_ratio
const hiRatio = num(t1.hp_ratio, 1);
battle.on('tick', () => {
const on = up(unit);
const inRange = on ? new Set(battle.enemiesInKeys(unit.rangeKeys, unit, { canHitFly: true })) : null;
for (const e of battle.enemies) {
if (!e.alive) continue;
// (another 霍尔海雅's 失重 carries the same key: it is hers to keep or drop)
const mine = e.findBuff(WEIGHTLESS_KEY);
const want = on && inRange.has(e) && e.hpRatio > hiRatio + 1e-9
&& !e.buffs.some((b) => b.status === 'weightless' && b.key !== WEIGHTLESS_KEY);
if (want && !mine) battle.addBuff(e, { key: WEIGHTLESS_KEY, status: 'weightless', visible: true, mods: { massFlat: -1 }, source: unit });
else if (!want && mine && mine.source === unit) battle.removeBuff(e, mine);
}
}, { owner: unit });
const fragile = Math.round((num(rider.damage_scale, 1) - 1) * 1e6) / 1e6, loRatio = num(rider.hp_ratio); // 1.2 → 0.2
if (fragile > 0) {
battle.every(FRAGILE_EVERY, () => {
if (!up(unit)) return;
for (const e of battle.enemiesInKeys(unit.rangeKeys, unit, { canHitFly: true })) {
if (e.hpRatio < loRatio - 1e-9) battle.applyStatus(e, 'artsFragile', { duration: FRAGILE_EVERY + 0.1, value: fragile, source: unit });
}
}, { owner: unit, immediate: true });
}
} },
],
install(battle, unit) {
// module traits (none on the normal form, nothing of ISW-A's): CCR-X RES ignore, CCR-Y SP per hit on an elite / leader
if (pen > 0) statBuff(battle, unit, 'trait:heyak:resIgnore', { resIgnoreFlat: pen });
const sp = num(tb.sp);
if (sp > 0) {
battle.on('damaged', (ctx) => {
const d = ctx.dmg;
if (ctx.source !== unit || !d || !d.isAttack || d.isSplash || tagged(d, AWAY_TAG) || !isEliteEnemy(ctx.target)) return;
giveSp(unit, sp, 'trait');
}, { owner: unit });
}
const sid = unit.skill?.id;
if (sid === S1) {
// 但为求索: the shot count decides the 浮空 (PRTS: the shots fired, whatever happens in flight)
const lev = num(b1.levitate);
battle.on('beforeAttack', (ctx) => {
if (ctx.attacker !== unit || !ctx.isSkill) return;
unit.mem.heyakLift = ctx.targets.length === 1 ? ctx.targets[0] : null;
}, { owner: unit, priority: -10 });
onHitBy(battle, unit, (ctx) => { // before 传承终焉 (priority 0): the lift is settled first
const d = ctx.dmg;
if (!d.isAttack || !d.isSkill || unit.mem.heyakLift !== ctx.target) return;
unit.mem.heyakLift = null;
if (lev > 0) battle.applyStatus(ctx.target, 'levitate', { duration: lev, source: unit });
}, 10);
} else if (sid === S2) {
// 群星逶迤: the nine shots, each at a random enemy of her range, each rolling its 浮空 before 传承终焉
const prob = num(b2['attack@prob']), lev = num(b2['attack@levitate']);
battle.on('beforeAttack', (ctx) => {
if (ctx.attacker !== unit || !unit.skill?.active || !ctx.targets.length) return;
const cands = battle.enemiesInKeys(unit.rangeKeys, unit, ctx.profile);
for (const e of battle.blockedTargets(unit, ctx.profile)) if (!cands.includes(e)) cands.push(e);
if (!cands.length) return;
const out = [];
for (let i = 0; i < shots; i++) out.push(battle.rng.pick(cands));
ctx.targets = out;
}, { owner: unit });
onHitBy(battle, unit, (ctx) => {
const d = ctx.dmg;
if (!d.isAttack || !d.isSkill || !(prob > 0) || !(lev > 0)) return;
if (battle.rng.chance(prob)) battle.applyStatus(ctx.target, 'levitate', { duration: lev, source: unit });
}, 10);
} else if (sid === S3) {
// 博览者的狂语: the whirlwinds in flight (also after she left the field)
const winds = (unit.mem.heyakWinds = []);
battle.on('tick', ({ dt }) => {
for (let i = winds.length - 1; i >= 0; i--) {
const w = winds[i];
const step = Math.min(WIND_SPEED * dt, w.left);
w.x += w.dx * step;
w.y += w.dy * step;
w.left -= step;
w.age += dt;
if (windHit(battle, unit, w) || w.left <= 1e-9) { winds.splice(i, 1); continue; }
w.fxAge += dt;
if (w.fxAge + 1e-9 >= WIND_FX_EVERY) {
w.fxAge = 0;
battle.fx('tornado', { x: w.x, y: w.y, id: unit.id, r: WIND_HALF, dur: WIND_FX_EVERY + 0.1 });
}
}
}, { owner: unit });
}
},
};
},
};
+209
View File
@@ -0,0 +1,209 @@
// server/sim/content/kits/ops/op-huang.js — 煌 (char_017_huang) 自选 operator kit: 6★ 强攻手 (近卫), an owned-6★ pick of
// the tier-5 and tier-6 自选 slots; every skill, both talents, the trait and both modules at every form.
// Kit contract and the 自选 rules: ../README.md ("How to add an operator (自选)").
//
// Forms (data/backups.json units.char_017_huang, the DIY slot statuses): normal = E2 Lv1, skills at rank 4, no module;
// elite = E2 Lv60, rank 7, the picked module at stage 1 (tier 5) or 3 (tier 6). Potential 0 [ASSUMED: no account].
// Sources: character_table / skill_table / battle_equip_table (zh_CN, as built into backups.json); PRTS 煌 (talent and
// skill 备注, the CEN-X note); the client's buff templates (buff_template_data `huang_s_3`, `huang_t_1[heal]` / `[lock]`,
// `huang_e_003[lock]` / `[heal]`, `huang_t_2…`); Arknights Terra Wiki "Blaze" (the S3 notes).
// - Trait (强攻手) "同时攻击阻挡的所有敌人": the profession default (centurion `hitAllBlocked`, block 3). Module CEN-X
// "攻击被阻挡的敌人攻击力提升至110%" (trait bb atk_scale): PRTS "于每次计算伤害时检查目标是否处于阻挡/被阻挡状态…提升效果
// 为攻击力倍率提升" — every damage instance of hers whose target is blocked (by anyone) takes ×atk_scale on its ATK-based
// amount (before DEF): normal attacks, S1, the S3 cuts and burst. Module CEN-Y "生命值高于50%时受到的物理伤害降低20%"
// (trait bb hp_ratio / damage_resistance): physical damage taken × (1 − damage_resistance) while HP > hp_ratio.
// - T1 紧急除颤 "在生命值低于25%时,仅一次回复50%的生命值并在6秒内使生命值不低于50%" (bb hp_ratio,
// huang_t_1[heal].hp_ratio, huang_t_1[lock].min_hp_ratio / .duration). PRTS 备注: every deployment she holds 不死 until
// the first time her HP ratio is ≤ hp_ratio; then the heal (max HP × [heal].hp_ratio, a heal — huang_t_1[heal]
// HealViaMaxHpRatio) and for [lock].duration s every damage that would take her below [lock].min_hp_ratio × max HP is
// cut to what is left above it ("该伤害减少(伤害值-煌当前生命值+煌最大生命值×50%)点": the `hpDamage` hook after the
// shields — a 伤判效果, so a 流失 such as her S3's is not held). CEN-Y stage 3 (talent hp_ratio 0.5 + the hidden
// huang_e_003[lock] part): "在生命值首次低于25%、50%时…8秒内…": the talent fires at 50 %, and a second trigger at
// check_hp_ratio (25 %) with its own 不死 (huang_e_003[undead]) fires only once the first is spent ("生命值首次低于50%
// 始终先于…25%生效"), re-checking the HP after the first one's heal [ASSUMED: both read the current HP]; its heal is
// skipped while the first lock runs (huang_e_003[heal] "CheckContainsBuff huang_t_1[lock] → IfNot → heal"). The hidden
// part's hp_ratio / def_penetrate_fixed (CEN-Y stage 2+): "生命值高于50%时攻击无视目标150点防御力" — a defIgnoreFlat
// buff while HP > hp_ratio (every damage of hers).
// - T2 严酷训练 "在战场停留15秒后获得抵抗" (bb one_minus_status_resistance, interval): `interval` s after each deployment
// the engine 抵抗 (huang_t_2 creates status_resistance[inf]: control statuses last half as long) until she leaves.
// CEN-X stage 3 adds "30秒后攻击力+6%,45秒后攻速+12" (huang_t_2[e_002_atk].atk / .interval, [e_002_atk_speed]
// .attack_speed / .interval: an ATK MULTIPLIER and an ATTACK_SPEED ADDITION, INFINITY lifetime).
// - S1 强力击·γ型 (AUTO, attack SP): the next attack at atk_scale × ATK — every blocked enemy it hits (her trait); the
// data's DEFAULT trigger (an AUTO "next attack" waits for her attack).
// - S2 链锯延伸模块 (AUTO, 持续时间无限): ATK +atk, DEF +def and the skill range 2-2 for the rest of the deployment (PRTS 备注
// "攻击距离加长…使用技能提供的攻击范围(非攻击距离+1)"); an AUTO skill acting on herself only fires as soon as its SP is
// full (the owner's AUTO rule, kits/README.md checklist 5).
// - S3 沸腾爆裂 (MANUAL, data DEFAULT): a 维持技能状态 (PRTS 游戏数据基础: no normal attack while it runs; its cadence
// follows the animation — no attack speed change touches it — and a hard control (晕眩 / 冻结 …) ends it early).
// PRTS 备注 + huang_s_3: every second for the first 8 s the ATK / DEF bonus is updated, then one physical cut hits every
// ground enemy of her attack range ("对前方一格内的敌方单位进行切割" — her own 1-1 and the enemies she blocks; 不可对空;
// 100 % ATK [ASSUMED: no blackboard key — her attack's own damage]); the bonus is atk / def × min(1, t ÷ (duration −
// 1.05)) at the t-th update (huang_s_3 RemainingRatioToAttributeModifier `_endTime` 1.05: full at 8.95 s; the Terra
// Wiki's "reaches maximum after 8.95 seconds"); at 9 s it reaches the maximum and the finish follows (PRTS "9s时…";
// Terra "triggered at the 9 seconds mark"; her skeleton, whose Skill_2_* clips are S3's: the 3 s Skill_2_Loop_End's
// last OnAttack falls at 9 s of the 10 s run — Begin 1.4 s + the 0.4 s loops to 7 s): the huang_s_3 ON_SKILL_FINISH part, she loses hp_ratio ×
// MAX HP (DamageViaMaxHpRatio, undeadable — never below 1 HP; PRTS calls it 流失) and then every enemy of the 3 × 3
// area ahead (PRTS "前方3×3区域 3-6…碰撞判定,可对空": air units too) takes damage_by_atk_scale × her ATK at that
// moment, physical; the state runs on to its 10 s. A run cut short by a hard control finishes at once
// [ASSUMED: ON_SKILL_FINISH fires on any end but her knock-out]. The range the game shows while it runs is the
// burst's 3-6 (PRTS); the data has no skill range and the cuts use her own, so the card keeps her 1-1 and the trigger
// stays DEFAULT.
import { num, talentBb, moduleBb, traitBb, up, onHitBy, onHitOn, toggleBuff, skillRec } from '../shared/tier1.js';
import { absoluteRangeKeys } from '../../../targeting.js';
const S1 = 'skchr_huang_1';
const S2 = 'skchr_huang_2';
const S3 = 'skchr_huang_3';
/** S3's final area "前方3×3区域" (PRTS 备注 range 3-6; range_table): her tile, the two ahead and the rows beside them. */
const GRID_3_6 = Object.freeze([[1, 0], [1, 1], [1, 2], [0, 0], [0, 1], [0, 2], [-1, 0], [-1, 1], [-1, 2]]);
/** huang_s_3 RemainingRatioToAttributeModifier `_endTime`: the ramp is full this long before the run's nominal end. */
const S3_RAMP_END = 1.05;
/** S3 updates: eight cuts (1 s apart), then the finish at the ninth (PRTS "9s时"). */
const S3_CYCLES = 9;
const S3_BUFF = 'skill:huang:ramp';
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
export default {
char_017_huang: (bb, chess, def) => {
const tb = traitBb(chess);
const t0 = talentBb(chess, 0), t1 = talentBb(chess, 1);
const hb = moduleBb(chess); // CEN-Y stage 2+: hp_ratio / def_penetrate_fixed; stage 3: huang_e_003[lock].*
const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3);
const s2 = skillRec(chess, S2);
const ownGrid = chess?.rangeGrid ?? def?.rangeGrid ?? [[0, 0], [0, 1]];
/** The ground enemies her own range holds and the ones she blocks (the S3 cuts). */
const cutTargets = (battle, unit) => {
const keys = absoluteRangeKeys(unit.rangeGrid || ownGrid, unit.tileR, unit.tileC, unit.dir, unit.s.rangeExtend || 0);
const list = battle.enemiesInKeys(keys, unit, { canHitFly: false });
for (const e of battle.blockedTargets(unit, { canHitFly: false })) if (!list.includes(e)) list.push(e);
return list;
};
/** S3's finish (huang_s_3 ON_SKILL_FINISH): the 流失 first (undeadable), then the burst at her ATK of that moment. */
const finish = (battle, unit, s) => {
s.done = true;
const loss = Math.min(unit.s.maxHp * num(b3.hp_ratio), unit.hp - 1);
if (loss > 0) battle.loseHp(unit, loss, { source: unit });
if (!up(unit)) return;
const atk = unit.s.atk;
const keys = absoluteRangeKeys(GRID_3_6, unit.tileR, unit.tileC, unit.dir, 0);
battle.fx('aoe', { x: unit.x, y: unit.y, radius: 1.5, id: unit.id, skill: 'huang:burst' });
for (const e of battle.enemiesInKeys(keys, unit, { canHitFly: true })) {
if (e.alive) battle.dealDamage(unit, e, { amount: atk * num(b3.damage_by_atk_scale, 1), type: 'phys', isSkill: true, tags: ['skill'] });
}
};
const setRamp = (battle, unit, r) => {
const b = unit.findBuff(S3_BUFF);
if (!b) return;
b.mods = { atkPct: num(b3.atk) * r, defPct: num(b3.def) * r };
unit.markDirty();
};
return {
skills: {
[S1]: { kind: 'instant', attack: { atkScale: num(b1.atk_scale, 1) } },
[S2]: {
kind: 'toggle', trigger: 'SP_FULL',
mods: { atkPct: num(b2.atk), defPct: num(b2.def) },
...(s2?.rangeGrid?.length ? { targeting: { rangeGrid: s2.rangeGrid } } : {}),
onStart({ battle, unit }) { battle.fx('buff', { x: unit.x, y: unit.y, id: unit.id, kind: 'atk' }); },
},
[S3]: {
kind: 'duration',
attack: { noAttack: true },
onStart({ battle, unit }) {
unit.mem.huangS3 = { t: 0, k: 0 };
battle.addBuff(unit, { key: S3_BUFF, mods: { atkPct: 0, defPct: 0 }, tags: ['skill'], visible: true });
},
onTick({ battle, unit, skill, dt }) {
const s = unit.mem.huangS3;
if (!s) return;
// 维持技能状态: a hard control ends it early (PRTS 游戏数据基础 "眩晕、冻结等硬性控制会提前结束")
if (!unit.canAct || unit.s.flags.sleep) { skill.end('interrupted'); return; }
s.t += dt;
while (s.k < S3_CYCLES && s.t >= s.k + 1 - 1e-9) {
s.k++;
setRamp(battle, unit, Math.min(1, s.k / Math.max(1e-6, skill.duration - S3_RAMP_END)));
if (s.k >= S3_CYCLES) { finish(battle, unit, s); return; }
const foes = cutTargets(battle, unit);
if (foes.length) battle.fx('aoe', { x: unit.x, y: unit.y, radius: 0.6, id: unit.id, skill: 'huang:cut' });
for (const e of foes) if (e.alive) battle.dealDamage(unit, e, { amount: unit.s.atk, type: 'phys', isSkill: true, tags: ['skill'] });
}
},
onEnd({ battle, unit, reason }) {
const s = unit.mem.huangS3;
unit.mem.huangS3 = null;
if (s && !s.done && reason !== 'death' && up(unit)) finish(battle, unit, s); // cut short: it finishes at once
battle.removeBuff(unit, S3_BUFF);
},
},
},
talents: [
{ install(battle, unit) { // 紧急除颤 (+ CEN-Y stage 3's second trigger and stage 2+'s 防御穿透)
const trig = num(t0.hp_ratio, 0.25);
const healA = num(t0['huang_t_1[heal].hp_ratio'], 0.5);
const floorA = num(t0['huang_t_1[lock].min_hp_ratio'], 0.5), durA = num(t0['huang_t_1[lock].duration'], 6);
const second = hb['huang_e_003[lock].check_hp_ratio'] != null;
const trigB = num(hb['huang_e_003[lock].check_hp_ratio'], 0.25);
const healB = num(hb['huang_e_003[lock].hp_ratio'], 0.5);
const floorB = num(hb['huang_e_003[lock].min_hp_ratio'], 0.5), durB = num(hb['huang_e_003[lock].duration'], 8);
const st = { undeadA: false, undeadB: false, lockA: -Infinity, lockB: -Infinity };
unit.mem.huangDefib = st;
battle.on('deploy', ({ unit: u }) => {
if (u !== unit) return;
st.undeadA = true;
st.undeadB = second;
st.lockA = st.lockB = -Infinity;
}, { owner: unit });
// 不死 while a trigger is unspent: a lethal hit leaves her at 1 HP (the trigger check below follows)
battle.on('fatal', (c) => { if (c.unit === unit && !c.prevented && (st.undeadA || st.undeadB)) c.prevented = true; }, { owner: unit });
battle.on('hpDamage', (c) => {
if (c.target !== unit) return;
const t = battle.time;
const floor = Math.max(t < st.lockA ? floorA : 0, t < st.lockB ? floorB : 0);
if (floor > 0) c.amount = Math.min(c.amount, Math.max(0, unit.hp - floor * unit.s.maxHp));
}, { owner: unit });
battle.on('damaged', (c) => {
if (c.target !== unit || !up(unit)) return;
if (st.undeadA && unit.hpRatio <= trig + 1e-9) {
st.undeadA = false;
st.lockA = battle.time + durA;
battle.heal(unit, unit, unit.s.maxHp * healA, { self: true, tags: ['talent'] });
battle.fx('heal', { x: unit.x, y: unit.y, id: unit.id });
}
if (st.undeadB && !st.undeadA && unit.hpRatio <= trigB + 1e-9) {
st.undeadB = false;
st.lockB = battle.time + durB;
if (!(battle.time < st.lockA)) battle.heal(unit, unit, unit.s.maxHp * healB, { self: true, tags: ['talent'] });
battle.fx('heal', { x: unit.x, y: unit.y, id: unit.id });
}
}, { owner: unit });
const pen = num(hb.def_penetrate_fixed);
if (pen > 0) toggleBuff(battle, unit, 'talent:huang:defPen', () => unit.hpRatio > num(hb.hp_ratio, 0.5), { defIgnoreFlat: pen });
} },
{ install(battle, unit) { // 严酷训练 (+ CEN-X stage 3: ATK after 30 s, ASPD after 45 s)
const resAt = num(t1['huang_t_2.interval'], num(t1.interval, 15));
const res = Math.min(0.95, Math.max(0, -num(t1['huang_t_2.one_minus_status_resistance'], num(t1.one_minus_status_resistance))));
const atk = num(t1['huang_t_2[e_002_atk].atk']), atkAt = num(t1['huang_t_2[e_002_atk].interval']);
const as = num(t1['huang_t_2[e_002_atk_speed].attack_speed']), asAt = num(t1['huang_t_2[e_002_atk_speed].interval']);
battle.on('deploy', ({ unit: u }) => {
if (u !== unit) return;
const seq = unit.deploySeq;
const at = (s, fn) => battle.after(s, () => { if (up(unit) && unit.deploySeq === seq) fn(); }, { owner: unit });
if (res > 0) at(resAt, () => battle.applyStatus(unit, 'resist', { value: res, source: unit }));
if (atk) at(atkAt, () => battle.addBuff(unit, { key: 'talent:huang:atk', mods: { atkPct: atk }, tags: ['talent'] }));
if (as) at(asAt, () => battle.addBuff(unit, { key: 'talent:huang:aspd', mods: { aspd: as }, tags: ['talent'] }));
}, { owner: unit });
} },
],
install(battle, unit) {
// CEN-X: ×atk_scale on her ATK-based damage to a blocked enemy (every damage calculation)
const blockedMul = num(tb.atk_scale, 1);
if (blockedMul !== 1) onHitBy(battle, unit, ({ target, dmg }) => { if (target.blockedBy) dmg.amount *= blockedMul; });
// CEN-Y: 生命值高于50%时受到的物理伤害降低20%
const dr = num(tb.damage_resistance);
if (dr > 0) onHitOn(battle, unit, ({ dmg }) => { if (dmg.type === 'phys' && unit.hpRatio > num(tb.hp_ratio, 0.5)) dmg.mul *= 1 - dr; });
},
};
},
};
+215
View File
@@ -0,0 +1,215 @@
// server/sim/content/kits/ops/op-ifrit.js — 伊芙利特 (char_134_ifrit) 自选 operator kit: 6★ 轰击术师 (术师), an owned-6★ pick
// of the tier-5 and tier-6 自选 slots; every skill, both talents, the trait and both modules at every form.
// Kit contract and the 自选 rules: ../README.md ("How to add an operator (自选)").
//
// Forms (data/backups.json units.char_134_ifrit, the DIY slot statuses): normal = E2 Lv1, skills at rank 4, no module;
// elite = E2 Lv60, rank 7, the picked module at stage 1 (tier 5) or 3 (tier 6). Potential 0 [ASSUMED: no account].
// Sources: character_table / skill_table / battle_equip_table (zh_CN, as built into backups.json); PRTS 伊芙利特 (炎爆 备注
// "灼伤:目标每秒受到伊芙利特当前攻击力33%的法术持续伤害,伊芙利特离场后也会生效" and "<模组相关>于伊芙利特在场时受模组基础效果影响";
// 灼地 备注 "属于维持技能状态,时间模式为跟随动画", "降低法术抗性的效果持续1秒"; 精神融解 备注 "<Δ模组>造成的元素伤害为持续伤害";
// “同熔” 特性 "目标距离自身0格或以上时应用加成,按距离线性提升,目标距离自身4格或以上时达到最大值"); PRTS 游戏数据基础 §维持技能状态;
// PRTS 分支特性信息 轰击术师 ("可对空", "普通攻击同时攻击范围内的所有敌人"); the client's battle data (battle/prefabs [uc]skills
// skchr_ifrit_1/2/3, [uc]equips ifrit_equip_*, charpack char_134_ifrit, buff_template_data: burn, atk_scale_by_distance,
// periodic_damage_by_hp_ratio[skip_modifier_fix], ifrit_e_002[sp] / [dice_sp], ifrit_e_003_tr, ifrit_e_003_ele) and her
// battle skeleton (Skill_2_Begin's OnAttack event).
// - Trait (轰击术师) "攻击造成超远距离的群体法术伤害": the profession's `rangeAoe` — every selectable enemy of her 5-1 line at
// once, air units too, the same damage near and far (professions.js blastcaster).
// - Module BLA-X “同熔” (trait min_dist 0 / max_dist 4 / damage_scale 0.1): "距离越远伤害越高,最高达到110%" — ×(1 + 0.1 ×
// clamp((d − 0) / (4 − 0), 0, 1)) by the Euclidean distance d between her and the target, on EVERY damage she outputs
// while on the field (the module's buff atk_scale_by_distance: ON_OUTPUT_DAMAGE, no damage mask) — her attacks, the 灼地
// ticks and the 炎爆 灼伤 (PRTS: "于伊芙利特在场时受模组基础效果影响"). Stage 3 also changes T2 (below).
// - Module BLA-D “热成形记忆” (trait ep_damage_ratio 0.08): "造成法术伤害时附带相当于8%伤害的灼燃损伤" (ifrit_e_003_tr: ON_AFTER_
// OUTPUT_DAMAGE, every MAGICAL damage of hers) — 8 % of the HP damage dealt as 灼燃损伤 (`type: 'element'`, docs/SIM.md §7.2
// element conventions), the 灼伤 too while she stands. Stage 3 also changes T1 (below).
// - T1 精神融解 "攻击范围内的敌军法术抗性-40%" (magic_resistance −0.4: a ratio; charpack ifrit_t_1, removed when the enemy leaves):
// RES ×0.6 on every enemy on her range, refreshed every AURA_IV s; several 伊芙利特 keep one (applyStrongest — the mode's
// "同名效果取最高" convention; [ASSUMED] the client's independentCharacterSource flag would let two stack, which a lone
// base-game 伊芙利特 never shows). BLA-D stage 2+ "若攻击目标处于灼燃损伤爆发期间,攻击对其额外造成相当于攻击力50%的元素伤害" (the
// module's hidden talent element_atk_scale; ifrit_e_003_ele: IsTargetInEPBreakRecovery FIRE ⇒ ELEMENT damage, attack type
// BUFF, 持续伤害): every hit of her attack abilities — normal attacks and the 灼地 ticks (the equip adds it to the abilities of
// `_targetFamilyMask` 31, [ASSUMED] every family: 1 / 3 mark the narrower equips) — on an enemy in its 灼燃 burst (its
// `burnBurst` lock) adds 50 % ATK 元素伤害
// (`type: 'elemental'`), tagged 'dot' (PRTS: "<Δ模组>造成的元素伤害为持续伤害"); never the 灼伤 (a buff's damage).
// - T2 莱茵回路 "每6秒额外回复2点技力" (periodic_sp; BLA-X stage 2+ ifrit_e_002[sp]): +sp SP every `interval` s of a deployment
// (the first one interval in: waitFirstTriggerInterval; a gift during a running 狂热 / 灼地 is lost — no SP during a
// skill). BLA-X stage 2+ "有30%概率额外回复5点技力" (ifrit_e_002[dice_sp] prob / interval / sp): one roll per its own interval.
// - S1 狂热 (MANUAL, data DEFAULT, 20 s): ATK +atk, ASPD +attack_speed.
// - S2 炎爆 (AUTO, 可充能 2 / 3 次, data DEFAULT — the "next attack" waits for her attack): the next attack hits her whole line
// (ground and air: its selector's targetMotion ALL) for atk_scale × ATK; every enemy it hits gets the buff ifrit_s_2
// (template burn): DEF +def (−100 / −200, "同名效果取最高") for `duration` (3.01) s and the 灼伤 — burn.atk_scale (33 %) × her
// CURRENT ATK arts every second of it (triggerInterval 1, the first a second in: 3 ticks), a 持续伤害 (no dodge) that keeps
// ticking after she leaves the field. A new 炎爆 on the same enemy restarts both.
// - S3 灼地 (MANUAL, data DEFAULT, 20 s): a 维持技能状态 — no normal attack. The ability (skchr_ifrit_3: `_triggerDelta` 1,
// ground-only selector) strikes first at the attack event of its Begin clip (SCORCH_FIRST: 0.4 s; facing UP / DOWN the
// back / down clips, 0.333 s) and then every S3_CADENCE (1) s — a fixed cadence no ASPD change touches: every ground enemy
// on her range takes atk_scale × ATK arts and the active buff ifrit_s_3, RES +magic_resistance (−7 / −10, flat) for 1 s
// (PRTS 备注) — the cut before the damage [ASSUMED order], so the tick it comes with counts it. Her own periodic_damage_by_
// hp_ratio buff takes hp_ratio (2 %) of her max HP every second of the skill (the first a second in; a 流失 — it ignores
// for SP and skips the damage events —, it can knock her out; PRTS "实际的生命流失量略低于标注值" gives no number [ASSUMED:
// the stated 2 %]). 晕眩 / 冻结 (any state that stops her acting) ends it at once (PRTS 维持技能状态).
import { num, talentBb, moduleBb, traitBb, skillRec, up, giveSp } from '../shared/tier1.js';
import { bodyInKeys } from '../../../body.js';
import { hasHp } from '../../../damage.js';
const S1 = 'skchr_ifrit_1';
const S2 = 'skchr_ifrit_2';
const S3 = 'skchr_ifrit_3';
/** T1 aura refresh period and buff length (the non-stacking aura convention of the kits). */
const AURA_IV = 0.25;
const AURA_DUR = 0.4;
/** S3 灼地: the ability's `_triggerDelta` (s) — one damage tick per second, "每秒". */
const S3_CADENCE = 1;
/** S3 灼地: the first tick at the OnAttack event of Skill_2_Begin (front 0.4 s; the back clip and Skill_Down_2_Begin 0.333 s). */
const SCORCH_FIRST = Object.freeze({ side: 0.4, vertical: 0.333 });
/** S3 灼地 RES cut length (ifrit_s_3 lifeTime 1; PRTS 备注 "降低法术抗性的效果持续1秒"). */
const S3_RES_TIME = 1;
/** S3 灼地 self 流失 period (periodic_damage_by_hp_ratio triggerInterval 1). */
const S3_LOSS_IV = 1;
const MELT_KEY = 'ifrit:meltdown';
const S2_DEF_KEY = 'ifrit:pyroDef';
const S2_BURN_KEY = 'ifrit:pyroBurn';
const S3_RES_KEY = 'ifrit:scorchRes';
const SCORCH_TAG = 'ifrit:scorch';
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
/** RES cut mods for a blackboard magic_resistance value (applyStrongest): |v| < 1 = ×(1 + v), else flat v. */
const resCut = (v) => (Math.abs(v) < 1 ? { resMul: Math.max(0, 1 + v) } : { resFlat: v });
const defCut = (v) => ({ defFlat: v });
/** S2 炎爆 on one enemy its attack hit: DEF cut and the 灼伤 for `duration` s. */
function pyroclasm(battle, unit, e, b2) {
if (!e || !e.alive || e.side !== 'enemy') return;
const dur = num(b2.duration, 3);
const d = num(b2.def);
if (d) battle.applyStrongest(e, S2_DEF_KEY, { duration: dur, value: d, mods: defCut, source: unit });
const sc = num(b2['burn.atk_scale']);
if (!(sc > 0)) return;
battle.addBuff(e, {
key: S2_BURN_KEY, duration: dur, interval: 1, source: unit, tags: ['dot'],
// "伊芙利特当前攻击力": her ATK at each tick; it ticks on after she leaves the field (PRTS 备注)
onTick: ({ battle: b, unit: tgt }) => b.dealDamage(unit, tgt, { amount: unit.s.atk * sc, type: 'arts', isSkill: true, canDodge: false, tags: ['dot', 'skill', 'ifrit:burn'] }),
});
}
/** S3 灼地 tick: every ground enemy on her range — the RES cut, then the damage. */
function scorch(battle, unit, b3) {
const scale = num(b3.atk_scale), mr = num(b3.magic_resistance);
const victims = battle.enemiesInKeys(unit.rangeKeys, unit, { canHitFly: false });
for (const e of victims) {
if (mr) battle.applyStrongest(e, S3_RES_KEY, { duration: S3_RES_TIME, value: mr, mods: resCut, source: unit });
if (e.alive && scale > 0) battle.dealDamage(unit, e, { amount: unit.s.atk * scale, type: 'arts', isSkill: true, tags: ['skill', SCORCH_TAG] });
}
battle.fx('flame', { x: unit.x, y: unit.y, id: unit.id, n: victims.length, skill: SCORCH_TAG });
}
export default {
char_134_ifrit: (bb, chess) => {
const t0 = talentBb(chess, 0), t1 = talentBb(chess, 1);
const hidden = moduleBb(chess); // BLA-D stage 2+: element_atk_scale
const tb = traitBb(chess); // BLA-X: min_dist / max_dist / damage_scale; BLA-D: ep_damage_ratio
const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3);
const s2 = skillRec(chess, S2);
return {
skills: {
[S1]: { kind: 'duration', mods: { atkPct: num(b1.atk), aspd: num(b1.attack_speed) } },
[S2]: {
kind: num(s2?.maxChargeTime, 1) > 1 ? 'charges' : 'instant',
attack: {
atkScale: num(b2.atk_scale, 1),
onEachHit({ battle, unit, target }) { pyroclasm(battle, unit, target, b2); },
},
onStart({ battle, unit }) { battle.fx('aoe', { x: unit.x, y: unit.y, radius: 0.5, id: unit.id, skill: 'ifrit:pyroclasm' }); },
},
[S3]: {
kind: 'duration',
attack: { noAttack: true },
onStart({ unit }) {
unit.mem.scorchAt = 0;
unit.mem.scorchNext = unit.dir === 'UP' || unit.dir === 'DOWN' ? SCORCH_FIRST.vertical : SCORCH_FIRST.side;
unit.mem.scorchLoss = 0;
},
onTick({ battle, unit, skill, dt }) {
// 维持技能状态: a hard control (晕眩 / 冻结 / 浮空 / 睡眠 — the unit cannot act) ends it at once
if (!unit.canAct) { skill.end('interrupted'); return; }
const t = (unit.mem.scorchAt = (unit.mem.scorchAt ?? 0) + dt);
while (t >= unit.mem.scorchNext - 1e-6 && unit.alive && skill.active) {
unit.mem.scorchNext += S3_CADENCE;
scorch(battle, unit, b3);
}
const ratio = num(b3.hp_ratio);
while (ratio > 0 && t >= (unit.mem.scorchLoss + 1) * S3_LOSS_IV - 1e-6 && unit.alive) {
unit.mem.scorchLoss++;
battle.loseHp(unit, unit.s.maxHp * ratio, { source: unit, tags: ['skill', SCORCH_TAG] });
}
},
},
},
talents: [
{ install(battle, unit) { // 精神融解: RES −40 % on her range; BLA-D stage 2+: +50 % ATK 元素伤害 on a bursting target
const v = num(t0.magic_resistance);
if (v) {
battle.every(AURA_IV, () => {
if (!up(unit) || !unit.rangeKeySet) return;
for (const e of battle.enemies) {
if (!e.alive || e.hidden || !bodyInKeys(e, unit.rangeKeySet)) continue;
battle.applyStrongest(e, MELT_KEY, { duration: AURA_DUR, value: v, mods: resCut, source: unit });
}
}, { owner: unit, immediate: true });
}
const ex = num(hidden.element_atk_scale);
if (ex > 0) {
battle.on('damaged', (c) => {
const e = c.target, d = c.dmg;
if (c.source !== unit || !d || !(d.isAttack || d.tags?.includes(SCORCH_TAG)) || !e || e.side !== 'enemy' || !hasHp(e) || !e.findBuff('burnBurst')) return;
battle.dealDamage(unit, e, { amount: unit.s.atk * ex, type: 'elemental', element: 'burn', canDodge: false, tags: ['dot', 'module', 'ifrit:meltdown'] });
}, { owner: unit });
}
} },
{ install(battle, unit) { // 莱茵回路: +sp SP every interval s; BLA-X stage 2+: prob to +5 SP more
const sp = num(t1.sp), iv = num(t1.interval, 6);
const dProb = num(t1['ifrit_e_002[dice_sp].prob']), dSp = num(t1['ifrit_e_002[dice_sp].sp']);
const dIv = num(t1['ifrit_e_002[dice_sp].interval'], iv);
if (!(sp > 0 && iv > 0) && !(dProb > 0 && dSp > 0 && dIv > 0)) return;
battle.on('deploy', (c) => { if (c.unit === unit) { unit.mem.rhineAcc = 0; unit.mem.rhineDice = 0; } }, { owner: unit });
battle.on('tick', ({ dt }) => {
if (!up(unit)) return;
if (sp > 0 && iv > 0) {
unit.mem.rhineAcc = (unit.mem.rhineAcc ?? 0) + dt;
while (unit.mem.rhineAcc >= iv - 1e-6) { unit.mem.rhineAcc -= iv; giveSp(unit, sp); }
}
if (dProb > 0 && dSp > 0 && dIv > 0) {
unit.mem.rhineDice = (unit.mem.rhineDice ?? 0) + dt;
while (unit.mem.rhineDice >= dIv - 1e-6) {
unit.mem.rhineDice -= dIv;
if (battle.rng.chance(dProb)) giveSp(unit, dSp);
}
}
}, { owner: unit });
} },
],
install(battle, unit) {
// BLA-X “同熔”: farther targets take more of every damage she outputs (up to ×1 + damage_scale at max_dist tiles)
const ds = num(tb.damage_scale);
if (ds > 0 && tb.max_dist != null) {
const lo = num(tb.min_dist), hi = num(tb.max_dist, 4);
battle.on('hit', (c) => {
const e = c.target;
if (c.source !== unit || !e || e.side !== 'enemy' || c.dmg.type === 'element' || !up(unit)) return;
const d = Math.hypot(e.x - unit.x, e.y - unit.y);
c.dmg.mul *= 1 + ds * (hi > lo ? Math.max(0, Math.min(1, (d - lo) / (hi - lo))) : 1);
}, { owner: unit });
}
// BLA-D “热成形记忆”: 8 % of each arts damage she deals as 灼燃损伤
const ep = num(tb.ep_damage_ratio);
if (ep > 0) {
battle.on('damaged', (c) => {
const e = c.target;
if (c.source !== unit || c.type !== 'arts' || !(c.amount > 0) || !e || e.side !== 'enemy' || !hasHp(e) || !up(unit)) return;
battle.dealDamage(unit, e, { type: 'element', element: 'burn', amount: c.amount * ep, tags: ['module', 'ifrit:thermal'] });
}, { owner: unit });
}
},
};
},
};
+205
View File
@@ -0,0 +1,205 @@
// server/sim/content/kits/ops/op-irene.js — 艾丽妮 (char_4009_irene) 自选 operator kit: 6★ 剑豪 (近卫), an owned-6★ pick of
// the tier-5 and tier-6 自选 slots; every skill, both talents, the trait and all three modules at every form.
// Kit contract and the 自选 rules: ../README.md ("How to add an operator (自选)").
//
// Forms (data/backups.json units.char_4009_irene, the DIY slot statuses): normal = E2 Lv1, skills at rank 4, no module;
// elite = E2 Lv60, rank 7, the picked module at stage 1 (tier 5) or 3 (tier 6). Potential 0 [ASSUMED: no account].
// Sources: character_table / skill_table / battle_equip_table (zh_CN, as built into backups.json), PRTS 艾丽妮 (审判之火
// 备注: "计算伤害前有概率(地面)/必定(空中单位)获取永久的物理穿透(百分比)Buff(不可叠加,直接加算),该效果仅在成功造成伤害后
// 消耗", popup "对空中单位造成物理伤害时,不会占用随机数"; S3 备注 "技能范围内仅存在飞行单位时,该技能也可开启 / 技能动画为固定时长
// (3.5秒),不受攻击速度影响 / 快速轰击可对空,溅射半径1.1"), PRTS 分支特性信息 §剑豪, gamedata_const ba.levitate ("变为空中单位").
// - Trait (剑豪) "普通攻击连续造成两次伤害": the profession default (hits 2), ground-only melee on her 1-1. SWO-X “审判官口粮”
// "技能造成的伤害提升10%" (trait bb damage_scale): her skill damage ×1.1; SWO-Y “厚重的经卷” "攻击时无视敌人70点的防御力"
// (def_penetrate_fixed): defIgnoreFlat on all her damage; ISW-A “艾丽妮特限证章”: only its attributes act here — its
// trait text and every talent part are 集成战略-only (battle_equip_table validInGameTag roguelike: 再部署 −60 %, +8 SP on
// deploy, the stage-3 range effects) ⇒ N/A; its stage-3 审判之火 text keeps the base 50 % / 50 %.
// - T1 审判之火 "对敌人造成物理伤害时有50%概率无视其50%防御力,对空中单位概率提升至100%": per PRTS 备注, before each physical
// damage instance she deals (normal or skill) she holds a 物理穿透 charge or draws one — always against an air unit (no
// random draw; 浮空 enemies are air units, ba.levitate), with `prob` against a ground one — and the charge adds
// def_penetrate to that instance's defIgnorePct; it is spent only by a damage instance that lands (a dodge or a cancel
// keeps it, and while she holds one she draws nothing: 不可叠加); a buff on her, so a new deployment starts without one.
// SWO-X stage 3: 55 % and "技能期间若击倒空中单位,技能结束时获得6点技力" — an air unit she knocks out while her skill
// is active (S1's strike, S2's cut, S3's 3.5 s) gives her +sp SP once that skill ends.
// - T2 净化之剑 "攻击速度+18,场上有【海怪】敌人时效果翻倍": ASPD +attack_speed; SWO-Y stage 3 adds ATK +5 % (the module talent
// change's atk); both doubled while a living 【海怪】 enemy (data tag seamonster) is on the field.
// - S1 起风 (AUTO, attack SP, DEFAULT): the next attack strikes its target once for atk_scale × ATK physical and levitates it
// `levitate` s, then hits it again for atk_scale × ATK physical (her skeleton's Skill_1 clip: two OnAttack) — the second
// hit meets an air unit when the levitation took.
// - S2 裂潮 (MANUAL, attack SP, 2 charges, 技能范围 3-12, data SKILL_RANGE): at once, up to max_target ground enemies of the
// skill range take atk_scale × ATK physical, and those of 重量 ≤ value (3) are levitated `levitate` s. It cannot be opened
// while its range holds no ground enemy at all [ASSUMED: PRTS's S3 note "仅存在飞行单位时,该技能也可开启" marks the opposite
// as the rule for this ground-only skill; the strategy's SKILL_RANGE still counts unselectable ones, "无视其不可选中"].
// - S3 判决 (MANUAL, attack SP, 技能范围 x-1, data SKILL_RANGE — PRTS: opens with only flyers in range): every ground enemy of
// the skill range takes atk_scale × ATK physical and is levitated `levitate` s, then multi_times (10) bombardments, one
// every multi_hit_interval (0.3) s, each on a random enemy of the skill range (flyers included) hitting every enemy within
// 1.1 of it (中点判定; PRTS 溅射半径1.1, 可对空) for multi_atk_scale × ATK physical; the skill runs PRTS's fixed 3.5 s with
// no normal attack [ASSUMED: the first bombardment one interval after the opening strike; a bombardment with no enemy in
// the skill range is skipped].
import { num, talentBb, traitBb, skillRec, statBuff, toggleBuff, giveSp, onHitBy } from '../shared/tier1.js';
import { absoluteRangeKeys, sortEnemyTargets } from '../../../targeting.js';
const S1 = 'skchr_irene_1';
const S2 = 'skchr_irene_2';
const S3 = 'skchr_irene_3';
/** PRTS S3 备注: "技能动画为固定时长(3.5秒),不受攻击速度影响". */
const S3_DURATION = 3.5;
/** PRTS S3 备注: "快速轰击可对空,溅射半径1.1". */
const S3_SPLASH = 1.1;
/** The skill ranges when a record carries none (data: 3-12 and x-1). */
const R3_12 = Object.freeze([[1, 0], [1, 1], [0, 0], [0, 1], [0, 2], [0, 3], [-1, 0], [-1, 1]]);
const X_1 = Object.freeze([[2, 0], [1, -1], [1, 0], [1, 1], [0, -2], [0, -1], [0, 0], [0, 1], [0, 2], [-1, -1], [-1, 0], [-1, 1], [-2, 0]]);
const AIR = Object.freeze({ canHitFly: true });
const GROUND = Object.freeze({ canHitFly: false });
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
/** Absolute tile keys of a skill range at the unit's tile and facing (a 技能范围 ignores 攻击距离: no extend). */
const keysOf = (unit, grid) => absoluteRangeKeys(grid, unit.tileR, unit.tileC, unit.dir, 0);
/** A living 【海怪】 enemy on the field. */
const seaMonsterOnField = (battle) => battle.enemies.some((e) => e.alive && !e.hidden && Array.isArray(e.def?.tags) && e.def.tags.includes('seamonster'));
/**
* A skill that strikes ground enemies only cannot be opened while its range holds no ground enemy: every activation path
* (the strategy's SKILL_RANGE, content) is refused and the charge stays — the pattern of 莎草 S2 (ops/chess_char_2_06-papyrs.js,
* non-enumerable: never serialised).
*/
function guardActivation(battle, unit, grid) {
const sk = unit.skill;
if (!sk || Object.prototype.hasOwnProperty.call(sk, 'activate')) return;
const activate = sk.activate;
const ok = () => battle.unitsInGrid(unit, grid, { side: 'enemy' }).some((e) => !e.isFlying);
Object.defineProperty(sk, 'activate', {
configurable: true, writable: true, enumerable: false,
value(reason, opts) { return ok() ? activate.call(this, reason, opts) : false; },
});
}
export default {
char_4009_irene: (bb, chess) => {
const t0 = talentBb(chess, 0), t1 = talentBb(chess, 1);
const tb = traitBb(chess);
const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3);
const s2 = skillRec(chess, S2), s3 = skillRec(chess, S3);
const grid2 = s2?.rangeGrid ?? R3_12, grid3 = s3?.rangeGrid ?? X_1;
const levitate = (battle, unit, e, s) => { if (e.alive && s > 0) battle.applyStatus(e, 'levitate', { duration: s, source: unit }); };
return {
skills: {
[S1]: {
kind: num(skillRec(chess, S1)?.maxChargeTime, 1) > 1 ? 'charges' : 'instant',
attack: {
atkScale: num(b1.atk_scale, 1),
hits: 1, // the first strike; the follow-up below (Skill_1 clip: two OnAttack)
onEachHit({ battle, unit, target, kind, attackId }) {
if (kind !== 'main' || !target || target.side !== 'enemy' || !target.alive) return;
levitate(battle, unit, target, num(b1.levitate));
if (!target.alive || !unit.alive) return;
battle.dealDamage(unit, target, { amount: unit.s.atk * num(b1.atk_scale, 1) * unit.s.atkScaleMul, type: 'phys', isAttack: true, isSkill: true, attackId, tags: ['skill'] });
},
},
},
[S2]: {
kind: num(s2?.maxChargeTime, 1) > 1 ? 'charges' : 'instant',
onStart({ battle, unit }) {
const list = battle.enemiesInKeys(keysOf(unit, grid2), unit, GROUND);
sortEnemyTargets(battle, unit, list, null);
const v = list.slice(0, Math.max(1, Math.floor(num(b2.max_target, 1))));
battle.fx('slash', { x: unit.x, y: unit.y, id: unit.id, n: v.length, skill: 'irene:tide' });
for (const e of v) {
battle.dealDamage(unit, e, { amount: unit.s.atk * num(b2.atk_scale, 1), type: 'phys', isSkill: true, tags: ['skill'] });
// "使其中重量小于等于3的目标浮空"
if (e.alive && e.weight <= num(b2.value, 3)) levitate(battle, unit, e, num(b2.levitate));
}
},
},
[S3]: {
kind: 'duration',
duration: S3_DURATION,
attack: { noAttack: true },
onStart({ battle, unit }) {
unit.mem.ireneS3 = { t: 0, n: 0 };
const v = battle.enemiesInKeys(keysOf(unit, grid3), unit, GROUND);
battle.fx('aoe', { x: unit.x, y: unit.y, radius: 1.5, id: unit.id, skill: 'irene:judgment' });
for (const e of v) {
battle.dealDamage(unit, e, { amount: unit.s.atk * num(b3.atk_scale, 1), type: 'phys', isSkill: true, tags: ['skill'] });
levitate(battle, unit, e, num(b3.levitate));
}
},
onTick({ battle, unit, dt }) {
const m = unit.mem.ireneS3;
if (!m) return;
m.t += dt;
const iv = Math.max(0.05, num(b3.multi_hit_interval, 0.3)), times = Math.max(0, Math.floor(num(b3.multi_times, 10)));
while (m.n < times && m.t + 1e-9 >= (m.n + 1) * iv) {
m.n++;
const cands = battle.enemiesInKeys(keysOf(unit, grid3), unit, AIR);
if (!cands.length) continue;
const c = battle.rng.pick(cands);
battle.fx('aoe', { x: c.x, y: c.y, radius: S3_SPLASH, id: unit.id, skill: 'irene:judgment' });
for (const e of battle.foesInRadius(c.x, c.y, S3_SPLASH, true)) {
battle.dealDamage(unit, e, { amount: unit.s.atk * num(b3.multi_atk_scale, 1), type: 'phys', isSkill: true, isSplash: e !== c, tags: ['skill'] });
}
if (!unit.alive || !unit.skill?.active) return;
}
},
onEnd({ unit }) { unit.mem.ireneS3 = null; },
},
},
talents: [
{ install(battle, unit) { // 审判之火: a 物理穿透 charge per physical instance (air: always, ground: prob), spent on landing
const prob = num(t0.prob), pen = num(t0.def_penetrate), sp = num(t0.sp);
// the charge is a buff on her: a new deployment starts without one (an operator leaving the field loses its buffs)
battle.on('deploy', (ctx) => {
if (ctx.unit !== unit) return;
unit.mem.ireneCharge = false;
unit.mem.ireneChargeDmg = null;
unit.mem.ireneAirKill = false;
}, { owner: unit });
if (pen > 0) {
onHitBy(battle, unit, (ctx) => {
const d = ctx.dmg, m = unit.mem;
if (d.type !== 'phys') return;
if (!m.ireneCharge) m.ireneCharge = ctx.target.isFlying || (prob > 0 && battle.rng() < prob);
if (!m.ireneCharge) return;
d.defIgnorePct += pen;
m.ireneChargeDmg = d;
});
battle.on('damaged', (c) => {
if (c.source !== unit || !c.dmg || c.dmg !== unit.mem.ireneChargeDmg) return;
unit.mem.ireneCharge = false;
unit.mem.ireneChargeDmg = null;
}, { owner: unit });
}
// SWO-X stage 3: an air unit knocked out during her skill ⇒ +sp SP when the skill ends
if (sp > 0) {
battle.on('death', (c) => {
if (c.reason === 'killed' && c.killer === unit && c.unit?.side === 'enemy' && c.unit.isFlying && unit.skill?.active) unit.mem.ireneAirKill = true;
}, { owner: unit });
battle.on('skillEnd', (c) => {
if (c.unit !== unit || !unit.mem.ireneAirKill) return;
unit.mem.ireneAirKill = false;
if (unit.alive) giveSp(unit, sp);
}, { owner: unit });
}
} },
{ install(battle, unit) { // 净化之剑: ASPD (SWO-Y stage 3: ATK too), doubled while a 【海怪】 enemy is on the field
const mods = { aspd: num(t1.attack_speed), atkPct: num(t1.atk) };
statBuff(battle, unit, 'talent:irene:sword', mods);
if (mods.aspd || mods.atkPct) {
const extra = {};
for (const [k, v] of Object.entries(mods)) if (v) extra[k] = v;
toggleBuff(battle, unit, 'talent:irene:seamonster', () => seaMonsterOnField(battle), extra);
}
} },
],
install(battle, unit) {
// SWO-Y “厚重的经卷”: 攻击时无视敌人70点的防御力
statBuff(battle, unit, 'trait:irene:pierce', { defIgnoreFlat: num(tb.def_penetrate_fixed) });
// SWO-X “审判官口粮”: 技能造成的伤害提升10%
const skillMul = num(tb.damage_scale, 1);
if (skillMul !== 1) onHitBy(battle, unit, ({ dmg }) => { if (dmg.isSkill) dmg.mul *= skillMul; });
if (unit.skill?.id === S2) guardActivation(battle, unit, grid2);
},
};
},
};
+228
View File
@@ -0,0 +1,228 @@
// server/sim/content/kits/ops/op-lin.js — 林 (char_4080_lin) 自选 operator kit: 6★ 阵法术师 (术师), an owned-6★ pick of the
// tier-5 and tier-6 自选 slots; every skill, both talents, the trait and both modules at every form.
// Kit contract and the 自选 rules: ../README.md ("How to add an operator (自选)").
//
// Forms (data/backups.json units.char_4080_lin, the DIY slot statuses): normal = E2 Lv1, skills at rank 4, no module;
// elite = E2 Lv60, rank 7, the picked module at stage 1 (tier 5) or 3 (tier 6). Potential 0 [ASSUMED: no account].
// Sources: character_table / skill_table / battle_equip_table (zh_CN, as built into backups.json); PRTS 林 (计出万全 备注:
// "通常情况下琉璃璧生效优先级为+1000", "琉璃璧破碎效果于满足条件的伤害应用前瞬间触发" — so a lethal hit still sets it off —,
// "其破碎时的影响范围以持有该效果的单位为中心,与林的当前攻击范围一致,先造成晕眩后造成伤害", "可造成琉璃璧破碎的伤害结算时,伤害量减少
// 相当于琉璃璧吸收伤害上限的数值"; 玲珑 备注 "非初始状态下…技能开启时的效果均会生效,但此状态下并不实际属于“技能期间”"; 荫庇 备注
// "每个干员单独获得琉璃壁,单独计算琉璃壁破碎与恢复时间", "伤害计算使用林的攻击力,伤害来源为林自身"; 流光乍裂 备注 "第一天赋生效的
// 优先级从+1000变为±0,琉璃璧破碎效果于满足条件的伤害应用后瞬间触发", "仅自身普通攻击造成击杀时可触发琉璃璧破碎/生成效果,单次普攻
// 最多触发1次效果"); PRTS 卫戍协议/帮助 §技能操作 ("携带状态切换类技能的干员…每次部署后仅开启一次技能"; 阵法术师 row "在初始攻击范围内
// 存在敌人时释放技能"; 林【流光乍裂】 x-2 "在自定义范围内存在敌人时释放技能"); PRTS 分支特性信息 阵法术师 ("攻击时可对空"); the client's
// battle data (charpack char_4080_lin, battle/prefabs [uc]skills skchr_lin_1/2/3, buff_template_data lin_t_1 / lin_t_1[damage]
// / lin_t_1_s_3 / lin_t_1_s_3[refresh] / lin_t_2 / lin_e_002[half_attribute] / lin_e_002_trait).
// - Trait (阵法术师) "通常时不攻击且防御力和法术抗性大幅度提升,技能开启时攻击造成群体法术伤害": the profession (professions.js
// phalanx — DEF +def ×, RES +magic_resistance while no skill runs; while one runs, `rangeAoe`: every selectable enemy of
// her range at once, air units too). Module PLX-X “七窍玲珑” overrides it with "更大幅度提升" (def 2.15 / magic_resistance
// 25, the profession's trait tunables) and "技能开启时保留部分效果" (lin_e_002[buff] def 1 / magic_resistance 10): DEF +100 %
// and RES +10 while any skill runs — 玲珑's switched state too (PRTS: "技能开启时的效果均会生效"). Module PLX-Y “情与义”:
// "范围内敌人越多造成的伤害越高(最高提升15%)" — the hidden module talent: +damage_scale (3 %) per enemy on her range, at most
// max_valid_stack_cnt (5), on every damage she deals (the 琉璃璧 bursts too), counted like 卡涅利安's PLX-Y (every living
// enemy standing on her current range).
// - T1 计出万全 (lin_t_1, HIGH_PRIORITY: ON_TAKE_DAMAGE → BlockDamage of a fixed `value`, a break when the damage left it
// negative): a 琉璃璧 on her from every deployment — an HP shield of `value` (200) that every damage instance refills
// (`absorbShields` absorbs it after DEF / RES and the damage multipliers): a hit of at most 200 is absorbed whole; a hit
// over 200 ("单次超过200点伤害") goes through less 200 (PRTS: "伤害量减少相当于琉璃璧吸收伤害上限的数值") and breaks it — the
// burst (lin_t_1[damage] on the targets of Lin's "Attack" selector, i.e. her CURRENT attack range, centred on the holder):
// stun `stun` s on every enemy there, then atk_scale × Lin's ATK arts (source Lin) — and lin_t_1[disable] brings it back
// `interval` s later (8 s; PLX-Y stage 3: 110 %, 6 s). A 流失 or an element 损伤 never touches it. [ASSUMED] the shield sits
// in the buff order (HIGH_PRIORITY puts it first; no other shield usually stands on her), and the burst comes right after
// the hit has landed — before a knock-out, as PRTS's lethal-hit note says — in the 流光乍裂 order too (PRTS: "伤害应用后").
// - T2 韬光 "受到攻击时,有50%几率回复1点技力" (PLX-X stage 3: 75 %, 2 SP): the client's lin_t_2 (ON_TAKE_DAMAGE → IsIgnoreForSp →
// Dice prob → ModifySp sp, no damage-value filter): one roll per damage instance she takes that could give 受击回复 SP — a
// hit the 琉璃璧 absorbs whole included (it is still taken, at 0); not a 流失 or an element 损伤. No SP while a skill runs
// (玲珑's state included: it stays on).
// - S1 玲珑 (MANUAL, data SEARCH, a 状态切换类 skill: switched on once per deployment and kept — the official rule, `toggle`):
// attack interval +base_attack_time (+1.0 s on her 2.0 s), ATK +atk, every enemy each attack hits 停顿 attack@sluggish s.
// PRTS's "并不实际属于技能期间" changes nothing here: the state is never switched back in the auto battle.
// - S2 荫庇 (MANUAL, data SEARCH, 25 s): ASPD +attack_speed, taunt +taunt_level (−1: "自身不容易受到敌人攻击"); every allied
// unit on her range (the client's validator: ground allies — every ally of this mode but the 孤立 “炎佑”, which no ally
// ability selects) gets its own lin_t_1 — the plain T1 (threshold `value`, Lin's ATK and source, its own break and
// `interval` regrowth; independentCharacterSource, not on Lin herself) — while the skill runs (removed when the ability
// detaches), checked every SHELTER_IV s; an ally that leaves the field loses it (its buffs go).
// - S3 流光乍裂 (MANUAL, data CUSTOM_RANGE on its x-2, 26 / 28 s): ATK +atk; range x-2 (the burst range follows: it is her
// current range: lin_t_1_s_3_selector); her own 琉璃璧 threshold ×talent_scale (2 / 2.5) while it runs (lin_t_1_s_3);
// each kill by her "Attack" ability (ON_TARGET_KILLED, one per attack) runs lin_t_1_s_3[refresh]: a standing 琉璃璧 bursts
// and stays, a broken one comes back at once. The auto battle never switches it off early.
import { num, talentBb, moduleBb, traitBb, skillRec, up, giveSp, batMod } from '../shared/tier1.js';
import { COLS } from '../../../constants.js';
import { absoluteRangeKeys } from '../../../targeting.js';
import { bodyInKeys } from '../../../body.js';
import { isHpLoss } from '../../../damage.js';
const S1 = 'skchr_lin_1';
const S2 = 'skchr_lin_2';
const S3 = 'skchr_lin_3';
/** The 琉璃璧 shield buff (one per holder: Lin's own, and the S2 copies on her allies). */
const GLASS_KEY = 'lin:glass';
/** PLX-X: the part of the trait kept while a skill runs. */
const KEEP_KEY = 'lin:plxKeep';
/** S2 荫庇: how often the allies on her range are checked for a 琉璃璧. */
const SHELTER_IV = 0.1;
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
const s3On = (lin) => !!(lin.skill && lin.skill.active && lin.skill.id === S3);
export default {
char_4080_lin: (bb, chess) => {
const t0 = talentBb(chess, 0), t1 = talentBb(chess, 1);
const hidden = moduleBb(chess); // PLX-Y: damage_scale per enemy, max_valid_stack_cnt
const tb = traitBb(chess); // PLX-X: lin_e_002[buff] def / magic_resistance
const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3);
const g3 = skillRec(chess, S3)?.rangeGrid ?? null;
const glass = { value: num(t0.value, 200), atkScale: num(t0.atk_scale, 1), stun: num(t0.stun, 1), interval: num(t0.interval, 8) };
const s3Scale = num(b3.talent_scale, 1);
/** Threshold (= shield) of a holder's 琉璃璧: Lin's own ×talent_scale while 流光乍裂 runs, the plain value otherwise. */
const thresholdOf = (lin, holder) => (holder === lin && s3On(lin) ? glass.value * s3Scale : glass.value);
/** (Re)raise the 琉璃璧 of `holder`, full. `mods: {}` keeps the buff when an instance drains it exactly (absorbShields). */
const raise = (battle, lin, holder) => {
if (!holder.alive) return;
const r = holder.mem.linGlassRegen;
if (r) { r.cancel(); holder.mem.linGlassRegen = null; }
battle.addBuff(holder, { key: GLASS_KEY, shield: thresholdOf(lin, holder), mods: {}, source: lin, tags: ['talent'] });
battle.fx('shield', { x: holder.x, y: holder.y, id: holder.id, skill: 'lin:glass' });
};
/** The break: Lin's current attack range centred on the holder — stun first, then the damage (PRTS 备注). */
const burst = (battle, lin, holder) => {
const grid = lin.liveRangeGrid || lin.rangeGrid || [[0, 0]];
const keys = absoluteRangeKeys(grid, holder.tileR, holder.tileC, lin.dir, 0);
const victims = battle.enemiesInKeys(keys, lin, { canHitFly: true });
if (glass.stun > 0) for (const e of victims) battle.applyStatus(e, 'stun', { duration: glass.stun, source: lin });
for (const e of victims) {
if (e.alive && glass.atkScale > 0) battle.dealDamage(lin, e, { amount: lin.s.atk * glass.atkScale, type: 'arts', tags: ['talent', 'lin:glass'] });
}
battle.fx('aoe', { x: holder.x, y: holder.y, radius: 1.5, id: lin.id, skill: 'lin:glass' });
};
/** Break `holder`'s 琉璃璧; it grows back `interval` s later (an S2 copy only while 荫庇 still covers it), or at once. */
const shatter = (battle, lin, holder, now = false) => {
battle.removeBuff(holder, GLASS_KEY);
burst(battle, lin, holder);
if (now) { raise(battle, lin, holder); return; }
const seq = holder.deploySeq;
holder.mem.linGlassRegen = battle.after(glass.interval, () => {
holder.mem.linGlassRegen = null;
if (!holder.alive || holder.deploySeq !== seq || holder.findBuff(GLASS_KEY)) return;
if (holder !== lin && !lin.mem.shelter?.has(holder.id)) return;
raise(battle, lin, holder);
}, { owner: lin });
};
/** Drop an S2 copy (the ally left her range, or 荫庇 ended). */
const dropCopy = (battle, holder) => {
const g = holder.findBuff(GLASS_KEY);
if (g) battle.removeBuff(holder, g);
const r = holder.mem.linGlassRegen;
if (r) { r.cancel(); holder.mem.linGlassRegen = null; }
};
/** S2: the allied units (not Lin, not devices) on her range get / keep / lose their 琉璃璧. */
const shelterTick = (battle, lin) => {
const st = lin.mem.shelter;
if (!st || !lin.rangeKeySet) return;
const now = new Set();
for (const a of battle.alliesFor(lin)) {
if (a === lin || a.kind === 'device' || !lin.rangeKeySet.has(a.tileR * COLS + a.tileC)) continue;
now.add(a.id);
if (!st.has(a.id)) { st.add(a.id); raise(battle, lin, a); }
}
for (const id of [...st]) {
if (now.has(id)) continue;
st.delete(id);
const a = battle.unitById(id);
if (a) dropCopy(battle, a);
}
};
const endShelter = (battle, lin) => {
const st = lin.mem.shelter;
lin.mem.shelter = null;
for (const id of st || []) { const a = battle.unitById(id); if (a) dropCopy(battle, a); }
};
return {
skills: {
[S1]: {
kind: 'toggle',
mods: { atkPct: num(b1.atk), batPct: batMod(b1.base_attack_time, chess) },
attack: { onHitStatus: { key: 'sluggish', duration: num(b1['attack@sluggish']) } },
},
[S2]: {
kind: 'duration',
mods: { aspd: num(b2.attack_speed), taunt: num(b2.taunt_level) },
onStart({ battle, unit }) { unit.mem.shelter = new Set(); shelterTick(battle, unit); },
onTick({ battle, unit, dt }) {
unit.mem.shelterAcc = (unit.mem.shelterAcc ?? 0) + dt;
if (unit.mem.shelterAcc + 1e-9 < SHELTER_IV) return;
unit.mem.shelterAcc = 0;
shelterTick(battle, unit);
},
onEnd({ battle, unit }) { unit.mem.shelterAcc = 0; endShelter(battle, unit); },
},
[S3]: {
kind: 'duration',
mods: { atkPct: num(b3.atk) },
targeting: g3 ? { rangeGrid: g3 } : undefined,
onStart({ battle, unit }) { // the threshold ×talent_scale: a standing 琉璃璧 grows to it
const g = unit.findBuff(GLASS_KEY);
if (g) { g.shield = thresholdOf(unit, unit); unit.markDirty(); }
battle.fx('aoe', { x: unit.x, y: unit.y, radius: 2, id: unit.id, skill: 'lin:s3' });
},
onAttack({ battle, unit, targets }) { // a knock-out by this attack: break and raise again, once per attack
if (!(targets || []).some((t) => t && t.side === 'enemy' && !t.alive)) return;
if (unit.findBuff(GLASS_KEY)) shatter(battle, unit, unit, true);
else raise(battle, unit, unit);
},
},
},
talents: [
{ install(battle, unit) { // 计出万全: her 琉璃璧 (and the S2 copies — the same hook: any holder of one she gave)
battle.on('deploy', (c) => { if (c.unit === unit) raise(battle, unit, unit); }, { owner: unit });
battle.on('damaged', (c) => {
const h = c.target;
const g = h && h.findBuff(GLASS_KEY);
if (!g || g.source !== unit || c.type === 'element' || isHpLoss(c.dmg)) return;
if (g.shield > 1e-9 || !(c.amount > 0)) { // absorbed whole: full again for the next instance
g.shield = thresholdOf(unit, h);
h.markDirty();
return;
}
if (h === unit || unit.alive) shatter(battle, unit, h);
}, { owner: unit, priority: 50 });
battle.on('skillEnd', (c) => { // 流光乍裂 over: a standing 琉璃璧 shrinks to the plain threshold
if (c.unit !== unit || c.skill.id !== S3) return;
const g = unit.findBuff(GLASS_KEY);
if (g) { g.shield = thresholdOf(unit, unit); unit.markDirty(); }
}, { owner: unit });
} },
{ install(battle, unit) { // 韬光: prob to +sp SP per damage instance taken (absorbed or not) that could give 受击回复 SP
const p = num(t1.prob), sp = num(t1.sp);
if (!(p > 0 && sp > 0)) return;
battle.on('damaged', (c) => {
if (c.target !== unit || !c.dmg || c.dmg.noSp || c.type === 'element' || isHpLoss(c.dmg) || !up(unit)) return;
if (battle.rng.chance(p)) giveSp(unit, sp);
}, { owner: unit });
} },
],
install(battle, unit) {
// PLX-X “七窍玲珑”: 技能开启时保留部分效果 (any skill; 玲珑's switched state included)
const keepDef = num(tb['lin_e_002[buff].def']), keepRes = num(tb['lin_e_002[buff].magic_resistance']);
if (keepDef || keepRes) {
battle.on('skillStart', (c) => { if (c.unit === unit) battle.addBuff(unit, { key: KEEP_KEY, mods: { defPct: keepDef, resFlat: keepRes }, tags: ['module'] }); }, { owner: unit });
battle.on('skillEnd', (c) => { if (c.unit === unit) battle.removeBuff(unit, KEEP_KEY); }, { owner: unit });
}
// PLX-Y “情与义”: +damage_scale per enemy on her range (≤ max_valid_stack_cnt), every damage she deals
const per = num(hidden.damage_scale), cap = Math.max(0, Math.floor(num(hidden.max_valid_stack_cnt, 5)));
if (per > 0 && cap > 0) {
battle.on('hit', (c) => {
if (c.source !== unit || !c.target || c.target.side !== 'enemy' || c.dmg.type === 'element' || !unit.rangeKeySet) return;
let n = 0;
for (const e of battle.enemies) if (e.alive && !e.hidden && bodyInKeys(e, unit.rangeKeySet) && ++n >= cap) break;
if (n > 0) c.dmg.mul *= 1 + per * n;
}, { owner: unit });
}
},
};
},
};
+279
View File
@@ -0,0 +1,279 @@
// server/sim/content/kits/ops/op-logos.js — 逻各斯 (char_4133_logos) 自选 operator kit: 6★ 中坚术师 (术师), an owned-6★ pick
// of the tier-5 and tier-6 自选 slots; every skill, both talents, the trait and both modules (CCR-Δ, CCR-Y) at every form.
// Kit contract and the 自选 rules: ../README.md ("How to add an operator (自选)").
//
// Forms (data/backups.json units.char_4133_logos, the DIY slot statuses): normal = E2 Lv1, skills at rank 4, no module;
// elite = E2 Lv60, rank 7, the picked module at stage 1 (tier 5) or 3 (tier 6). Potential 0 [ASSUMED: no account].
// Sources: character_table / skill_table / battle_equip_table (zh_CN, as built into backups.json) and PRTS 逻各斯 (the
// 备注 quoted below). Every number is read from the record (`chess` / the skill blackboards); the few constants below exist
// only in the PRTS 备注.
// - Trait (中坚术师) "攻击造成法术伤害": the profession default (ranged arts bolt, hits air units, targetable by ground
// enemies). CCR-Δ “来自河谷的笔盒” adds "造成法术伤害时附带相当于8%伤害的凋亡损伤" (hidden module talent
// `ep_damage_ratio`): every arts damage he deals — attacks, the talent's extra hits, 殁亡's follow-up — fills 8 % of the
// HP it removed as 凋亡 损伤 (the kits' element convention, docs/SIM.md §7.2). CCR-Y “《语义范式百科》” adds
// "普通攻击命中精英或领袖敌人时获得1点技力" (trait bb `sp`): +sp SP per normal-attack hit on an elite or leader enemy
// (none while a timed skill runs — AK) and, per the 语汇演化 备注 "<Y模组>…可触发模组提供的额外特性", per talent hit too.
// - T1 语汇演化 "对一个目标发起攻击时,有40%几率额外对攻击范围内一个随机目标造成相当于攻击力60%的法术伤害并使其停顿0.8秒"
// (bb prob / atk_scale / sluggish): rolled for every target of each of his attacks and after 殁亡's follow-up hit (S1 备注
// "可触发第一天赋"); the extra hit lands at once on a random targetable enemy of his current range [ASSUMED: the attacked
// target can be drawn], then 停顿 (the engine `sluggish`) for `sluggish` s. 备注 "该天赋的触发不依赖普通攻击,不受缴械类效果
// 制约". CCR-Δ stage 2+: prob 0.5 / 0.6 and, on a target in its 凋亡 burst, `element_atk_scale` × ATK 元素伤害 after the arts
// hit (备注 "先造成法术伤害,后造成元素伤害"). CCR-Y stage 2+: `emit_count` 2 — 备注 "本质上是将该天赋重复执行2次": two
// independent executions (each its own roll and random target).
// - T2 剜魂具辞 "攻击使目标在5秒内法术抗性-10且受到的法术伤害提高150点" (bb duration / magic_resistance / atk_addition):
// each of his attack hits gives the target, for `duration` s, RES `magic_resistance` and +`atk_addition` on every arts
// damage it takes (备注 "在计算攻击倍率后、计算法术抗性前增加当次伤害", "可对当次伤害生效", "即使法术伤害的攻击力为0也可
// 生效"): added to the hit's pre-mitigation amount, the hit that applies it included. One instance per enemy whoever
// applies it (同名效果: applyStrongest).
// - S1 殁亡 (AUTO, 持续时间无限 ⇒ toggle; the data's DEFAULT trigger — it acts on enemies): range 3-3, ATK +atk, and an aura
// (备注 "光环效果,不依赖攻击行为"): every enemy of his range whose HP is below attack@kill_atk_scale × his ATK takes
// attack@kill_damage (9999999) 无来源 true damage (备注 "无来源真实斩杀伤害"; no dodge), then another random enemy of his
// range takes arts damage equal to the HP the first had left (备注: his T2 bonus applies, T1 rolls). At most one try per
// enemy per entry into the range, and none ever again on an enemy that survived one (备注; a 频次 unit takes 1 HP, a
// leader in a boss battle loses nothing to 限伤 — 备注 "卫戍协议的最终攻势Boss…只能以常规的“当前生命值”处理斩杀的触发").
// - S2 提喻 (MANUAL, 20 s, data DEFAULT): RES +magic_resistance; the attack locks one target (an instant hit, no bolt
// [ASSUMED: a lock-on beam]) every attack@cooldown s whatever the attack speed (备注 "攻击间隔固定为0.5s"), each hit
// attack@atk_scale_base + attack@atk_scale_delta × n of his ATK and the target's movement speed × (1 + attack@move_speed
// × n), n = the hits made on it so far, at most attack@max_stack_cnt (备注 "线性提升" — 3× and 40 % after 5 s). A stun /
// freeze, the target's fall or no attack for 0.1 s past the cadence (备注 "每0.1s,若自身未处于攻击状态,则退出锁定状态")
// resets the lock and its slow; the next attack locks anew.
// - S3 延异视阈 (MANUAL, 30 s, data ACTIVE_RANGE on its 3-4): ATK +atk, attack@max_target targets, and every enemy
// projectile whose position is on his range flies at projectile_move_scale × its speed (备注 "降低至0.05倍"; leaving the
// range restores it) and is removed when the skill ends — not when he is disarmed then (备注 "受缴械类效果制约") nor when
// he is knocked out [ASSUMED]. Every enemy projectile counts as 可变速 / 可消除 [ASSUMED]. The sim's projectiles live in
// `battle.projectiles.list` (ProjectileSystem, server/sim/projectiles.js): the kit adjusts `speed` / `maxAge` there.
import { num, talentBb, moduleBb, traitBb, skillRec, up, giveSp, once } from '../shared/tier1.js';
import { isElite, elementDmg } from '../shared/tier6.js';
import { hasHp } from '../../../damage.js';
import { COLS, ROWS } from '../../../constants.js';
const S1 = 'skchr_logos_1';
const S2 = 'skchr_logos_2';
const S3 = 'skchr_logos_3';
const ANY = Object.freeze({ canHitFly: true });
/** 剜魂具辞: the debuff on an enemy (one instance whoever applies it). */
const T2_KEY = 'logos:soulRend';
/** 提喻 备注 "每0.1s,若自身未处于攻击状态,则退出锁定状态": the lock ends 0.1 s after a missed attack. */
const LOCK_IDLE = 0.1;
/** Enemy projectiles slowed by a running 延异视阈 (projectile → { orig speed, by: Set of unit ids }); per projectile. */
const SLOWED = new WeakMap();
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
const tileOf = (p) => {
const r = Math.round(p.y), c = Math.round(p.x);
return r >= 0 && r < ROWS && c >= 0 && c < COLS ? r * COLS + c : -1;
};
/** Enemy projectiles on the field (ProjectileSystem list: the sim's only projectile store). */
const enemyShots = (battle) => (battle.projectiles?.list ?? []).filter((p) => p && p.source && p.source.side === 'enemy');
/** 延异视阈: slow the enemy projectiles on `unit`'s range, restore those that left it. */
function slowShots(battle, unit, scale) {
const set = unit.rangeKeySet;
for (const p of enemyShots(battle)) {
let s = SLOWED.get(p);
if (set && set.has(tileOf(p))) {
if (!s) { s = { orig: p.speed, by: new Set() }; SLOWED.set(p, s); }
s.by.add(unit.id);
p.speed = s.orig * scale;
p.maxAge = Math.max(p.maxAge, p.age + 1); // a slowed shot never lands by age while it is held
} else if (s && s.by.delete(unit.id) && !s.by.size) {
p.speed = s.orig;
SLOWED.delete(p);
}
}
}
/** 延异视阈 ends: clear the enemy projectiles on the range (unless disarmed / knocked out), release the rest. */
function endShots(battle, unit, clear) {
const set = unit.rangeKeySet;
let n = 0;
if (clear && set && battle.projectiles) {
const keep = [];
for (const p of battle.projectiles.list) {
if (p && p.source && p.source.side === 'enemy' && set.has(tileOf(p))) { n++; SLOWED.delete(p); } else keep.push(p);
}
battle.projectiles.list = keep;
}
for (const p of enemyShots(battle)) {
const s = SLOWED.get(p);
if (s && s.by.delete(unit.id) && !s.by.size) { p.speed = s.orig; SLOWED.delete(p); }
}
if (n) battle.fx('bulletClear', { x: unit.x, y: unit.y, id: unit.id, n });
}
export default {
char_4133_logos: (bb, chess) => {
const t0 = talentBb(chess, 0);
const t1 = talentBb(chess, 1);
const hidden = moduleBb(chess); // CCR-Δ: ep_damage_ratio
const tb = traitBb(chess); // CCR-Y: sp
const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3);
const s1 = skillRec(chess, S1), s3 = skillRec(chess, S3);
const lex = {
prob: num(t0.prob), scale: num(t0.atk_scale), sluggish: num(t0.sluggish),
elemScale: num(t0.element_atk_scale), emit: Math.max(1, Math.floor(num(t0.emit_count, 1))),
};
/** 语汇演化: `emit` executions, each a `prob` roll for a random enemy of his range. */
function lexicon(battle, unit) {
if (!(lex.prob > 0) || !(lex.scale > 0)) return;
for (let k = 0; k < lex.emit; k++) {
if (!up(unit) || !battle.rng.chance(lex.prob)) continue;
const t = battle.rng.pick(battle.enemiesInKeys(unit.rangeKeys, unit, ANY));
if (!t) continue;
battle.fx('logosLexicon', { x: t.x, y: t.y, id: unit.id, target: t.id });
battle.dealDamage(unit, t, { amount: unit.s.atk * lex.scale, type: 'arts', tags: ['talent', 'logosLexicon'] });
if (t.alive && lex.sluggish > 0) battle.applyStatus(t, 'sluggish', { duration: lex.sluggish, source: unit });
if (lex.elemScale > 0 && hasHp(t) && t.findBuff('apoptosisBurst')) {
battle.dealDamage(unit, t, { amount: unit.s.atk * lex.elemScale, type: 'elemental', element: 'apoptosis', canDodge: false, tags: ['talent', 'logosLexicon'] });
}
}
}
/** 殁亡's execution of `e` (HP below the threshold), then the follow-up arts hit on another random enemy of his range. */
function execute(battle, unit, e, st) {
const left = e.hp;
battle.fx('logosExecute', { x: e.x, y: e.y, id: unit.id, target: e.id });
battle.dealDamage(unit, e, { amount: num(b1['attack@kill_damage'], 9999999), type: 'true', canDodge: false, sourceless: true, isSkill: true, tags: ['skill', 'execute'] });
if (e.alive) st.spared.add(e.id); // survived (不死, 频次, 限伤 …): never tried again
const t = battle.rng.pick(battle.enemiesInKeys(unit.rangeKeys, unit, ANY).filter((x) => x !== e));
if (!t || !(left > 0)) return;
battle.dealDamage(unit, t, { amount: left, type: 'arts', isSkill: true, tags: ['skill', 'logosPerish'] });
lexicon(battle, unit);
}
/** 提喻: end the lock (its slow leaves the target). */
function unlock(battle, unit) {
const L = unit.mem.logosLock;
if (L && L.target) battle.removeBuff(L.target, `logos:lockSlow:${unit.id}`);
unit.mem.logosLock = null;
}
const lockIv = num(b2['attack@cooldown'], 0.5);
const base2 = num(b2['attack@atk_scale_base'], 1), delta2 = num(b2['attack@atk_scale_delta']);
const maxN = Math.max(0, Math.floor(num(b2['attack@max_stack_cnt'])));
const s2On = (unit) => !!(unit.skill && unit.skill.active && unit.skill.id === S2);
return {
skills: {
[S1]: {
kind: 'toggle',
mods: { atkPct: num(b1.atk) },
targeting: { rangeGrid: s1?.rangeGrid ?? null },
onStart({ unit }) { unit.mem.logosExec = { tried: new Set(), spared: unit.mem.logosExec?.spared ?? new Set() }; },
onTick({ battle, unit }) {
const st = unit.mem.logosExec;
if (!st || !up(unit)) return;
const inRange = battle.enemiesInKeys(unit.rangeKeys, unit, ANY);
const ids = new Set(inRange.map((e) => e.id));
for (const id of st.tried) if (!ids.has(id)) st.tried.delete(id); // left the range: a new entry may try again
const thr = num(b1['attack@kill_atk_scale']) * unit.s.atk;
for (const e of inRange) {
if (st.tried.has(e.id) || st.spared.has(e.id) || !e.alive || !hasHp(e) || !(e.hp < thr)) continue;
st.tried.add(e.id);
execute(battle, unit, e, st);
if (!up(unit)) return;
}
},
},
[S2]: {
kind: 'duration',
mods: { resFlat: num(b2.magic_resistance) },
attack: {
projectile: 'beam',
atkScale: base2,
// the hit's scale: base + delta × n (n = the hits already made on the locked target)
dmgMul: (battle, unit, target) => {
const L = unit.mem.logosLock;
const n = L && L.target === target ? L.n : 0;
return base2 > 0 ? (base2 + delta2 * n) / base2 : 1;
},
onEachHit({ battle, unit, target, kind }) {
const L = unit.mem.logosLock;
if (kind !== 'main' || !L || L.target !== target || !target.alive) return;
L.n = Math.min(maxN, L.n + 1);
const mul = Math.max(0, 1 + num(b2['attack@move_speed']) * L.n);
battle.addBuff(target, { key: `logos:lockSlow:${unit.id}`, duration: lockIv + LOCK_IDLE + 0.2, source: unit, mods: { moveMul: mul } });
},
},
onStart({ battle, unit }) { unlock(battle, unit); unit.mem.logosLockAt = null; },
onAttack({ battle, unit }) { unit.mem.logosLockAt = battle.time; },
onTick({ battle, unit }) {
const L = unit.mem.logosLock;
// a stun / freeze breaks the lock; so does no attack for LOCK_IDLE past the cadence
if (L && (!unit.canAct || !L.target.alive || battle.time - unit.mem.logosLockAt > lockIv + LOCK_IDLE + 1e-9)) unlock(battle, unit);
if (!unit.canAct) return;
// 攻击间隔固定为0.5s: the next attack exactly lockIv after the last one (the engine counts atkCd down right after)
const at = unit.mem.logosLockAt;
if (at == null) { unit.atkCd = Math.min(unit.atkCd, lockIv); return; }
const left = at + lockIv - battle.time;
unit.atkCd = left > 1e-6 ? left + battle.dt : 0;
},
onEnd({ battle, unit }) { unlock(battle, unit); },
},
[S3]: {
kind: 'duration',
mods: { atkPct: num(b3.atk) },
targeting: { rangeGrid: s3?.rangeGrid ?? null, maxTargets: num(b3['attack@max_target'], 1) },
onTick({ battle, unit }) { slowShots(battle, unit, num(b3.projectile_move_scale, 1)); },
onEnd({ battle, unit, reason }) { endShots(battle, unit, reason !== 'death' && !unit.s.flags.disarm); },
},
},
talents: [
{ install(battle, unit) { // 语汇演化: every target of each of his attacks
battle.on('attack', (ctx) => {
if (ctx.attacker !== unit || !up(unit)) return;
for (let i = 0; i < ctx.targets.length; i++) lexicon(battle, unit);
}, { owner: unit });
} },
{ install(battle, unit) { // 剜魂具辞: his attack hits mark the target (before mitigation: the hit itself counts)
const dur = num(t1.duration), res = num(t1.magic_resistance), add = num(t1.atk_addition);
if (!(dur > 0) || !(add > 0 || res)) return;
battle.on('hit', (ctx) => {
if (ctx.source !== unit || !ctx.dmg?.isAttack || !ctx.target || ctx.target.side !== 'enemy') return;
battle.applyStrongest(ctx.target, T2_KEY, { duration: dur, value: add, source: unit, mods: () => (res ? { resFlat: res } : {}) });
}, { owner: unit, priority: 10 });
// "受到的法术伤害提高N点": every arts damage on a marked enemy, after the attack multiplier, before its RES
once(battle, 'logos:artsAddition', () => battle.on('hit', (ctx) => {
const d = ctx.dmg;
if (!d || d.type !== 'arts' || !ctx.target || ctx.target.side !== 'enemy') return;
const m = ctx.target.findBuff(T2_KEY);
if (m && m.data && m.data.value > 0) d.amount += m.data.value;
}, { priority: -50 }));
} },
],
install(battle, unit) {
// 提喻: keep attacking the locked target while it stays a target; a new lock otherwise
battle.on('beforeAttack', (ctx) => {
if (ctx.attacker !== unit || !s2On(unit) || !ctx.targets.length) return;
const L = unit.mem.logosLock;
const prof = ctx.profile || unit.profile;
if (L && L.target.alive) {
const cands = battle.enemiesInKeys(unit.rangeKeys, unit, prof);
if (cands.includes(L.target) || battle.blockedTargets(unit, prof).includes(L.target)) { ctx.targets = [L.target]; return; }
}
unlock(battle, unit);
unit.mem.logosLock = { target: ctx.targets[0], n: 0 };
ctx.targets = [ctx.targets[0]];
}, { owner: unit });
// CCR-Δ: 8 % of every arts damage as 凋亡 损伤
const ep = num(hidden.ep_damage_ratio);
if (ep > 0) {
battle.on('damaged', (ctx) => {
if (ctx.source !== unit || ctx.type !== 'arts' || !(ctx.amount > 0) || !ctx.target || ctx.target.side !== 'enemy') return;
elementDmg(battle, unit, ctx.target, 'apoptosis', ctx.amount * ep, ['trait', 'logosCcrd']);
}, { owner: unit });
}
// CCR-Y: a normal-attack (or 语汇演化) hit on an elite / leader enemy gives SP
const sp = num(tb.sp);
if (sp > 0) {
battle.on('damaged', (ctx) => {
const d = ctx.dmg;
if (ctx.source !== unit || !d || ctx.type === 'element' || !ctx.target || ctx.target.side !== 'enemy') return;
if ((d.isAttack || (d.tags || []).includes('logosLexicon')) && isElite(ctx.target)) giveSp(unit, sp, 'trait');
}, { owner: unit });
}
},
};
},
};
+315
View File
@@ -0,0 +1,315 @@
// server/sim/content/kits/ops/op-mantra.js — 真言 (char_4204_mantra) 自选 operator kit: 6★ 本源术师 (术师), an owned-6★ pick of
// the tier-5 and tier-6 自选 slots; every skill, both talents, the trait and the module (PRI-X) at every form.
// Kit contract and the 自选 rules: ../README.md ("How to add an operator (自选)").
//
// Forms (data/backups.json units.char_4204_mantra, the DIY slot statuses): normal = E2 Lv1, skills at rank 4, no module;
// elite = E2 Lv60, rank 7, PRI-X at stage 1 (tier 5) or 3 (tier 6). Potential 0 [ASSUMED: no account].
// Sources: character_table / skill_table / battle_equip_table (zh_CN, as built into backups.json) and PRTS 真言 (the 备注
// quoted below). 麻痹 (ba.palsy, PRTS 术语释义): each stack interrupts one normal attack of an enemy, at most 3, kept until
// used — the engine `palsy` status; the moment a stack interrupts an attack is the engine hook `palsyTrigger` (ai.js).
// - Trait (本源术师) "攻击造成法术伤害,可以造成元素伤害": the profession default (ranged arts bolt, hits air units,
// targetable by ground enemies); 元素伤害 = the DamageInfo type 'elemental', 神经 损伤 = element 'neural' (SIM.md §3). PRI-X
// “中枢神经探测模块” "对处于元素爆发期间的敌人造成的伤害提升至110%" (trait bb damage_scale): ×damage_scale on every damage
// she deals to an enemy in an element burst (shared/tier5.js burstDamageUp, as 烛煌 / 妮芙).
// - T1 噤声限域 "场上敌人触发麻痹效果时立即受到相当于真言攻击力135%的元素伤害,且触发麻痹时有10%概率不消耗麻痹层数" (bb
// atk_scale / prob; PRI-X stage 3: 1.6 / 0.15): 备注 "触发麻痹时指的是敌人的攻击被麻痹打断时(而非获得麻痹时)", "造成的元素
// 伤害为元素持续伤害" (tagged 'dot'), "全场光环存在0.5秒检测周期,单位无法被真言选择的情况下将失去效果" (every 0.5 s the
// enemies she can select — not untargetable, 隐匿 or asleep; "三技能期间,此天赋的检测无视隐匿与沉睡" — carry her mark),
// "麻痹不消耗效果在同类效果中取概率最高" (two 真言: one roll at the higher chance; each deals her own hit [ASSUMED]).
// - T2 全局洞悉 "距离真言最近的一个侵入点出场的敌人出现时立刻获得1层麻痹": at each deployment she picks the 侵入点 (the
// stage's 'start' tiles) nearest by Manhattan distance, a tie drawn at random (备注 "优先选取距离自身曼哈顿距离最近的侵入点,
// 相同距离下随机选取"); every enemy spawning on it gets 1 麻痹 0.1 s later (备注 "【延迟麻痹】:0.1s后获得1层麻痹;付与选取
// 无视隐匿").
// - S1 共鸣溃缩 (AUTO, attack SP, data DEFAULT): the next attack deals atk_scale × ATK arts, then ep_damage_ratio of the
// damage as 神经 损伤, then — on a target in its 神经 burst — element_atk_scale × ATK 元素伤害 (S2 备注 "伤害顺序为:法术伤害 →
// 神经损伤 → 元素伤害", the same riders).
// - S2 意识联协 (MANUAL, 25 s, data DEFAULT): attack interval base_attack_time (a flat −0.7 / −0.6 s); each attack deals
// attack@atk_scale × ATK arts and jumps to chain_times other enemies (attack@chain.atk_scale × ATK arts each), every hit
// with the S1 riders at attack@ep_damage_ratio / attack@element_atk_scale. 备注 "跳跃半径2.0,首次跳跃开始后弹道速度降低至
// 3.2": each jump flies at 3.2 tiles/s to the nearest not-yet-hit enemy within 2.0 tiles [ASSUMED: nearest, as the
// 链术师]. The first shot keeps the engine's bolt (11 tiles/s; 备注 10).
// - S3 无言为真 (MANUAL, 40 s, data ACTIVE_RANGE on its 3-17): ATK +atk, attack@max_target targets; on her range (备注:
// 【反隐】 / the overflow by 碰撞判定 — an enemy's body —, 【麻痹付与】 by the enemy's tile) enemies lose 隐匿 (reveal) and
// their 麻痹 cap falls to 2 (text "降为2"; no blackboard key): stacks above it, held when the cap falls or gained later, become
// "溢出麻痹" (麻痹免疫 enemies count too — 备注 "仅为令麻痹失效": the stacks 真言 gives them are kept here); each one turns
// into an 元素伤害 bounce of atk_scale × ATK — on the holder, then (chain_times) to the nearest other enemy within 2.0 tiles
// at 3.2 tiles/s, max_target hits — one at once when gained and one every interval_projectile_trigger s after, all of them
// at once when the holder leaves the range, falls or the skill ends (备注). 【麻痹付与】: 0.1 s after the cast and whenever
// another operator on her range starts a skill, every enemy on her range gets per_active 麻痹, max_trigger_cnt times per
// cast, a grant with no enemy there kept (备注 "在范围内不存在可选敌方单位时保留可付与次数").
import { num, talentBb, traitBb, skillRec, batMod, up, once } from '../shared/tier1.js';
import { elementDmg } from '../shared/tier6.js';
import { burstDamageUp } from '../shared/tier5.js';
import { hasHp } from '../../../damage.js';
import { canTargetEnemy, enemyStealthed } from '../../../targeting.js';
import { bodyInKeys } from '../../../body.js';
import { COLS, PALSY_MAX } from '../../../constants.js';
const S1 = 'skchr_mantra_1';
const S2 = 'skchr_mantra_2';
const S3 = 'skchr_mantra_3';
const ANY = Object.freeze({ canHitFly: true });
/** 意识联协 / 无言为真 备注: "跳跃半径2.0", "速度3.2,弹射半径2.0". */
const JUMP_RADIUS = 2.0;
const JUMP_SPEED = 3.2;
/** 无言为真 "麻痹层数上限降为2" (text only). */
const S3_CAP = 2;
/** 无言为真 备注 "【麻痹付与】效果在自身技能开启后的0.1s触发一次". */
const GRANT_DELAY = 0.1;
/** 噤声限域 备注 "全场光环存在0.5秒检测周期". */
const MARK_IV = 0.5;
const MARK = 'mantra:hush:';
/** 全局洞悉 备注 "0.1s后获得1层麻痹". */
const SPAWN_DELAY = 0.1;
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
const palsyImmune = (e) => !!e?.def?.immune?.has?.('palsy');
/** 麻痹 stacks of an enemy — a 麻痹免疫 one keeps the ineffective stacks 真言 gave it in `mem.mantraPalsy`. */
const palsyOf = (e) => (palsyImmune(e) ? (e.mem.mantraPalsy ?? 0) : (e.findBuff('palsy')?.stacks ?? 0));
function setPalsy(battle, e, n) {
if (palsyImmune(e)) { e.mem.mantraPalsy = n; return; }
const b = e.findBuff('palsy');
if (!b) return;
if (n <= 0) battle.removeBuff(e, b);
else { b.stacks = n; e.markDirty(); }
}
const tileIn = (e, set) => set.has(Math.round(e.y) * COLS + Math.round(e.x));
/** The S1 / S2 riders: share × the damage dealt as 神经 损伤, then elemScale × ATK 元素伤害 on a target in its 神经 burst. */
function riders(battle, unit, t, dealt, share, elemScale) {
if (!t || t.side !== 'enemy') return;
if (share > 0 && dealt > 0) elementDmg(battle, unit, t, 'neural', dealt * share, ['skill', 'mantra']);
if (elemScale > 0 && hasHp(t) && t.findBuff('neuralBurst')) {
battle.dealDamage(unit, t, { amount: unit.s.atk * elemScale, type: 'elemental', element: 'neural', canDodge: false, isSkill: true, tags: ['skill', 'mantra'] });
}
}
/** The nearest enemy within `r` of (x, y) that `unit` can select, none of `skip`. */
function nearestFoe(battle, unit, x, y, r, skip) {
let best = null, bd = Infinity;
for (const e of battle.enemiesInRadius(x, y, r)) {
if (skip.has(e) || !canTargetEnemy(unit, e, ANY)) continue;
const d = Math.hypot(e.x - x, e.y - y);
if (d < bd - 1e-9 || (Math.abs(d - bd) <= 1e-9 && best && e.spawnSeq < best.spawnSeq)) { bd = d; best = e; }
}
return best;
}
export default {
char_4204_mantra: (bb, chess) => {
const t0 = talentBb(chess, 0);
const tb = traitBb(chess); // PRI-X: damage_scale
const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3);
const s3 = skillRec(chess, S3);
const s3On = (unit) => !!(unit.skill && unit.skill.active && unit.skill.id === S3 && unit.mem.mantraS3);
/** 意识联协: `left` jumps from `from`, each to the nearest not-yet-hit enemy, at JUMP_SPEED. */
function chain(battle, unit, from, hit, left, attackId) {
if (left <= 0) return;
const next = nearestFoe(battle, unit, from.x, from.y, JUMP_RADIUS, hit);
if (!next) return;
hit.add(next);
battle.addProjectile({
from: { x: from.x, y: from.y }, target: next, speed: JUMP_SPEED, visual: 'bolt', source: unit, hitDead: true,
onHit: ({ target, x, y }) => {
if (target && target.alive) {
const d = battle.dealDamage(unit, target, { amount: unit.s.atk * num(b2['attack@chain.atk_scale'], 1), type: 'arts', isAttack: true, isSkill: true, tags: ['chain'], attackId });
riders(battle, unit, target, d, num(b2['attack@ep_damage_ratio']), num(b2['attack@element_atk_scale']));
}
chain(battle, unit, target && target.alive ? target : { x, y }, hit, left - 1, attackId);
},
});
}
/** 无言为真: one 溢出麻痹 → an 元素伤害 bounce on `holder`, then to the nearest other enemy (chain_times jumps). */
function bounce(battle, unit, holder) {
const amount = unit.s.atk * num(b3.atk_scale);
if (!(amount > 0)) return;
const hit = new Set([holder]);
const elem = (t) => battle.dealDamage(unit, t, { amount, type: 'elemental', element: 'neural', canDodge: false, isSkill: true, tags: ['skill', 'mantraOverflow'] });
let left = Math.max(1, Math.floor(num(b3.max_target, 2)));
if (holder.alive && hasHp(holder)) { elem(holder); left--; }
battle.fx('mantraArc', { x: holder.x, y: holder.y, id: unit.id, target: holder.id });
const jump = (from, jumps) => {
if (jumps <= 0 || left <= 0) return;
const next = nearestFoe(battle, unit, from.x, from.y, JUMP_RADIUS, hit);
if (!next) return;
hit.add(next);
left--;
battle.addProjectile({
from: { x: from.x, y: from.y }, target: next, speed: JUMP_SPEED, visual: 'bolt', source: unit,
onHit: ({ target }) => { if (target && target.alive) elem(target); if (target) jump(target, jumps - 1); },
});
};
jump(holder, Math.max(0, Math.floor(num(b3.chain_times, 1))));
}
/** 溢出麻痹 gained by `e`: one turns into a bounce at once, the next one interval later. */
function overflow(battle, unit, st, e, rec, n) {
if (!(n > 0)) return;
rec.over += n;
rec.over--;
rec.next = battle.time + num(b3.interval_projectile_trigger, 1.5);
bounce(battle, unit, e);
}
/** `e` leaves the overflow (range, fall, skill end): every 溢出麻痹 left bounces at once. */
function flush(battle, unit, e, rec) {
while (rec.over > 0) { rec.over--; bounce(battle, unit, e); }
}
/** 真言's own 麻痹 (S3 grants, 全局洞悉): a 麻痹免疫 enemy keeps them (ineffective) for the overflow count. */
function givePalsy(battle, unit, e, n) {
if (!e.alive || !(n > 0)) return;
if (!palsyImmune(e)) { battle.applyStatus(e, 'palsy', { value: n, source: unit }); return; }
const owner = battle.allyUnits.find((a) => s3On(a) && a.mem.mantraS3.map.has(e));
const cap = owner ? S3_CAP : PALSY_MAX;
const cur = palsyOf(e);
const add = Math.min(n, Math.max(0, cap - cur));
e.mem.mantraPalsy = cur + add;
if (owner && n > add) overflow(battle, owner, owner.mem.mantraS3, e, owner.mem.mantraS3.map.get(e), n - add);
}
/** 【麻痹付与】: per_active 麻痹 to every enemy on her range (by its tile); kept when there is none. */
function grant(battle, unit) {
const st = unit.mem.mantraS3;
if (!st || st.grants <= 0 || !s3On(unit)) return;
const set = unit.rangeKeySet;
const foes = battle.enemies.filter((e) => e.alive && !e.hidden && set && tileIn(e, set));
if (!foes.length) return;
st.grants--;
battle.fx('mantraGrant', { x: unit.x, y: unit.y, id: unit.id, n: foes.length });
for (const e of foes) givePalsy(battle, unit, e, Math.max(1, Math.floor(num(b3.per_active, 1))));
}
return {
skills: {
[S1]: {
kind: num(skillRec(chess, S1)?.maxChargeTime, 1) > 1 ? 'charges' : 'instant',
attack: {
atkScale: num(b1.atk_scale, 1),
onHit({ battle, unit, target, dealt }) { riders(battle, unit, target, dealt, num(b1.ep_damage_ratio), num(b1.element_atk_scale)); },
},
},
[S2]: {
kind: 'duration',
mods: { batPct: batMod(b2.base_attack_time, chess) },
attack: {
atkScale: num(b2['attack@atk_scale'], 1),
onEachHit({ battle, unit, target, dealt, kind, attackId }) {
if (kind !== 'main' || !target) return;
riders(battle, unit, target, dealt, num(b2['attack@ep_damage_ratio']), num(b2['attack@element_atk_scale']));
chain(battle, unit, target, new Set([target]), Math.max(0, Math.floor(num(b2.chain_times))), attackId);
},
},
},
[S3]: {
kind: 'duration',
mods: { atkPct: num(b3.atk) },
targeting: { rangeGrid: s3?.rangeGrid ?? null, maxTargets: num(b3['attack@max_target'], 1) },
onStart({ battle, unit, skill }) {
const n = skill.activations;
unit.mem.mantraS3 = { grants: Math.max(0, Math.floor(num(b3.max_trigger_cnt))), map: new Map() };
battle.after(GRANT_DELAY, () => { if (skill.activations === n) grant(battle, unit); });
},
onTick({ battle, unit, dt }) {
const st = unit.mem.mantraS3;
const set = unit.rangeKeySet;
if (!st || !set) return;
const seen = new Set();
for (const e of battle.enemies) {
if (!e.alive || e.hidden || !bodyInKeys(e, set)) continue;
seen.add(e);
// 【反隐】 (隐匿免疫) on her range
if (e.s.flags.stealth) battle.applyStatus(e, 'reveal', { duration: 2 * dt + 0.05, source: unit });
let rec = st.map.get(e);
if (!rec) {
rec = { over: 0, next: Infinity };
st.map.set(e, rec);
const cur = palsyOf(e);
if (cur > S3_CAP) { setPalsy(battle, e, S3_CAP); overflow(battle, unit, st, e, rec, cur - S3_CAP); }
} else if (rec.over > 0 && battle.time >= rec.next - 1e-9) {
rec.over--;
rec.next = battle.time + num(b3.interval_projectile_trigger, 1.5);
bounce(battle, unit, e);
}
}
for (const [e, rec] of [...st.map]) if (!seen.has(e)) { st.map.delete(e); flush(battle, unit, e, rec); }
},
onEnd({ battle, unit }) {
const st = unit.mem.mantraS3;
unit.mem.mantraS3 = null;
if (st) for (const [e, rec] of st.map) flush(battle, unit, e, rec);
},
},
},
talents: [
{ install(battle, unit) { // 噤声限域: her mark on every enemy she can select (0.5 s); a 麻痹 interrupt hits it
const scale = num(t0.atk_scale), prob = num(t0.prob);
if (!(scale > 0) && !(prob > 0)) return;
battle.every(MARK_IV, () => {
if (!up(unit)) return;
const all = s3On(unit); // 三技能期间: 隐匿 / 沉睡 do not hide an enemy from the check
for (const e of battle.enemies) {
if (!e.alive || e.hidden || !e.deployed || e.s.flags.untargetable) continue;
if (!all && (e.s.flags.sleep || (e.s.flags.stealth && enemyStealthed(e)))) continue;
battle.addBuff(e, { key: MARK + unit.id, duration: MARK_IV + 0.1, source: unit, data: { scale, prob } });
}
}, { owner: unit, immediate: true });
once(battle, 'mantra:palsyTrigger', () => battle.on('palsyTrigger', (ctx) => {
const e = ctx.enemy;
let p = 0;
for (const m of e.buffs.slice()) {
if (typeof m.key !== 'string' || !m.key.startsWith(MARK) || !m.data) continue;
const src = m.source;
if (!up(src)) continue;
if (m.data.scale > 0 && hasHp(e)) {
battle.dealDamage(src, e, { amount: src.s.atk * m.data.scale, type: 'elemental', element: 'neural', canDodge: false, tags: ['talent', 'dot', 'mantraHush'] });
}
p = Math.max(p, m.data.prob);
if (!e.alive) return;
}
if (p > 0 && battle.rng.chance(p)) ctx.keep = true;
}));
} },
{ install(battle, unit) { // 全局洞悉: the nearest 侵入点; its spawns get 1 麻痹 0.1 s later
battle.on('deploy', (ctx) => {
if (ctx.unit !== unit) return;
let best = [], bd = Infinity;
for (const [r, c] of battle.grid.specialTiles('start')) {
const d = Math.abs(r - unit.tileR) + Math.abs(c - unit.tileC);
if (d < bd) { bd = d; best = [[r, c]]; } else if (d === bd) best.push([r, c]);
}
unit.mem.mantraGate = best.length > 1 ? battle.rng.pick(best) : best[0] ?? null;
if (unit.mem.mantraGate) battle.fx('mantraGate', { x: unit.mem.mantraGate[1], y: unit.mem.mantraGate[0], id: unit.id });
}, { owner: unit });
battle.on('enemySpawn', ({ enemy: e }) => {
const g = unit.mem.mantraGate;
if (!g || !up(unit) || Math.round(e.spawnY) !== g[0] || Math.round(e.spawnX) !== g[1]) return;
battle.after(SPAWN_DELAY, () => givePalsy(battle, unit, e, 1));
}, { owner: unit });
} },
],
install(battle, unit) {
// PRI-X: ×damage_scale on enemies in an element burst
burstDamageUp(battle, unit, num(tb.damage_scale));
// 无言为真: the 麻痹 cap 2 on her range; the stacks above it become 溢出麻痹
battle.on('beforeStatus', (c) => {
if (c.status !== 'palsy' || !s3On(unit) || !c.target || c.target.side !== 'enemy') return;
const st = unit.mem.mantraS3;
const rec = st.map.get(c.target);
if (!rec) return;
const n = Math.max(1, Math.floor(Number.isFinite(c.value) ? c.value : 1));
const room = Math.max(0, S3_CAP - palsyOf(c.target));
if (n <= room) return;
if (room > 0) c.value = room; else c.cancel = true;
overflow(battle, unit, st, c.target, rec, n - room);
}, { owner: unit });
// 【麻痹付与】 when another operator on her range starts a skill
battle.on('skillStart', (ctx) => {
const u = ctx.unit;
if (u === unit || !u || u.kind !== 'op' || !s3On(unit) || !unit.rangeKeySet?.has(u.tileR * COLS + u.tileC)) return;
grant(battle, unit);
}, { owner: unit });
},
};
},
};
+276
View File
@@ -0,0 +1,276 @@
// server/sim/content/kits/ops/op-narant.js — 娜仁图亚 (char_4138_narant) 自选 operator kit: 6★ 回环射手 (狙击), an owned-6★ pick
// of the tier-5 and tier-6 自选 slots; every skill, both talents, the trait and both modules at every form.
// Kit contract and the 自选 rules: ../README.md ("How to add an operator (自选)").
//
// Forms (data/backups.json units.char_4138_narant, the DIY slot statuses): normal = E2 Lv1, skills at rank 4, no module;
// elite = E2 Lv60, rank 7, the picked module at stage 1 (tier 5) or 3 (tier 6). Potential 0 [ASSUMED: no account].
// Sources: character_table / skill_table / battle_equip_table (zh_CN, as built into backups.json); PRTS 娜仁图亚 (备注 of 旋刃
// and 恶魇); PRTS 分支特性信息 回环射手; gamedata_const ba.steal 偷取; PRTS 命中率.
// - Trait (回环射手) "持有回旋投射物时才能够攻击(投射物需要时间回收)": the profession's boomerang (professions.js loopshooter,
// ai.js throwBoomerang: out 15, back 3.75 tiles/s; she attacks again once every boomerang is caught); hits air units.
// - Module LPS-X “吹尽狂沙”: trait "攻击周围8格的敌人时攻击力提升至110%" (trait bb atk_scale; the hidden module talent repeats it):
// ×atk_scale on her attack hits on an enemy within one tile (3 × 3, as 跃跃's LPS-X). Stage 2+ “我见,我得” (below).
// - Module LPS-Y “致我们的老大”: trait "每回收5次回旋投射物时获得1点技力" (come_back_cnt / sp): every come_back_cnt boomerangs caught
// (hook boomerangCaught; an S3 flight carries cnt) give sp SP — none while a skill of hers runs (the AK rule: giveSp)
// [ASSUMED: the catches count all the same]. Stage 2+ 婀娜虚影 (below).
// - T1 “我见,我得” "在场时,每次攻击到敌人偷取其25点攻击力(最高250点)与20点防御力(最高200点)": every attack hit of hers that lands
// (normal attacks, S1 bounces, S2 hits and its way back, each S3 boomerang — not S3's recall blast [ASSUMED]) steals:
// the enemy loses steal_atk ATK / steal_def DEF of its base (flat, at most steal_atk_max / steal_def_max in all) and she
// gains as much (flat, the same caps) — ba.steal "减少目标的基础属性作为自身加成,目标减少和自身加成的属性不超过指定上限(同类属性
// 取最高)": one loss per enemy, the largest of the 娜仁图亚 that stole from it. Both last while she is on the field (在场时)
// [ASSUMED: they end when she leaves]; each steal adds to both sides, each capped on its own [ASSUMED]. LPS-X stage 2+ (caps
// 275 / 220 → 300 / 240) "攻击到周围8格的敌人时偷取触发2次": the count is parsed from the talent text (no blackboard key).
// - T2 婀娜虚影 "获得35%的物理与法术闪避,周围8格内敌人的物理与法术命中率-20%": dodgePhys / dodgeArts +prob; every physical / arts
// attack of an enemy within one tile of her misses with damage_hitrate_* (PRTS 命中率: one roll when the attack's first
// damage instance comes — as Raidian's 诱引, standin-acsupo.js —, a miss cancels every instance and its riders; two
// 娜仁图亚 never add up). LPS-Y stage 3 −30 % and "6秒内未受到伤害时,攻击力+15%" (hidden atk / interval): ATK +atk while she
// has taken no damage (流失 aside) for `interval` s — from her deployment on, as 远牙's 专注 [ASSUMED].
// - S1 旋刃 (切换: a toggle that stays on until she leaves — the kits' reading, 银灰 S2 [ASSUMED]): her range loses its far
// column (攻击距离-1: ability_range_forward_extend −1; the engine's rangeExtend only grows), her boomerang hits for
// attack@atk_scale × ATK and then bounces up to attack@times times (PRTS 备注: radius BOUNCE_RADIUS from the boomerang,
// an enemy this attack has not hit first, then the nearest; BOUNCE_SPEED tiles/s), each bounce a hit of the same attack;
// then it flies back. The data's DEFAULT trigger (the running range is smaller).
// - S2 恶魇 (30 s, attack SP): each hit attack@atk_scale × ATK + 停顿 attack@sluggish s; the boomerang then flies on
// move_ahead_time s at DASH_SPEED tiles/s (PRTS 备注 "抵达目标所在位置后以1.0的速度再向前运动0.5s,随后返回") and on its way
// back hits every enemy within attack@projectile_range (备注 "投射物碰撞半径1.0") once for attack@atk_scale_comeback × ATK.
// - S3 吞日 (20 s): each attack throws cnt boomerangs at the target (each attack@atk_scale × ATK; they fly together, as
// 跃跃's S2 [ASSUMED: the text names no other targets]); when they are caught she deals atk_scale_aoe × ATK physical to at
// most attack@aoe.max_target enemies within one tile (her order) and 停顿 `sluggish` s — a skill hit, not an attack.
import { num, talentBb, moduleBb, traitBb, skillRec, statBuff, toggleBuff, onHitBy, enemiesInGrid, cheb, up, giveSp } from '../shared/tier1.js';
import { canTargetEnemy } from '../../../targeting.js';
import { isHpLoss } from '../../../damage.js';
const S1 = 'skchr_narant_1';
const S2 = 'skchr_narant_2';
const S3 = 'skchr_narant_3';
/** PRTS 旋刃 备注: "弹射半径1.7…弹射期间投射物移动速度6.0". */
const BOUNCE_RADIUS = 1.7;
const BOUNCE_SPEED = 6;
/** PRTS 恶魇 备注: "以1.0的速度再向前运动0.5s" (the time is the blackboard's move_ahead_time). */
const DASH_SPEED = 1;
/** "周围8格": her tile and the eight around it. */
const AROUND = Object.freeze([[-1, -1], [-1, 0], [-1, 1], [0, -1], [0, 0], [0, 1], [1, -1], [1, 0], [1, 1]].map(Object.freeze));
/** 婀娜虚影's battle-wide `hit` handler runs before the enemy content's own riders (200), as Raidian's 诱引. */
const MISS_PRIORITY = 300;
const LOOT_KEY = 'talent:narant:loot';
const STOLEN_KEY = 'narant:stolen';
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
const count = (v, d) => Math.max(1, Math.floor(num(v, d)));
/** The named talent record `i` (data index ≥ 0) of the composed record. */
const talentRec = (chess, i) => (chess?.talents ?? []).filter((t) => t && t.index !== -1)[i] ?? null;
/** A range grid whose rows lose their `n` farthest tiles ("攻击距离-n"). */
function shortenGrid(grid, n) {
const far = new Map();
for (const [dr, dc] of grid) far.set(dr, Math.max(far.get(dr) ?? -Infinity, dc));
return grid.filter(([dr, dc]) => dc <= far.get(dr) - n).map(([dr, dc]) => [dr, dc]);
}
// ---- T1 偷取: the losses of the enemies, one battle-wide table (同类属性取最高 over several thieves) ----------------------
/** battle → Map(enemy → Map(thief id → { a, d })). */
const STOLEN = new WeakMap();
const stolenOf = (battle) => { let m = STOLEN.get(battle); if (!m) STOLEN.set(battle, (m = new Map())); return m; };
function applyLoss(battle, e, per) {
let a = 0, d = 0;
for (const v of per.values()) { a = Math.max(a, v.a); d = Math.max(d, v.d); }
if (!(a > 0) && !(d > 0)) { battle.removeBuff(e, STOLEN_KEY); return; }
battle.addBuff(e, { key: STOLEN_KEY, mods: { atkFlat: -a, defFlat: -d }, tags: ['steal'] });
}
// ---- T2 命中率: one battle-wide handler over every 娜仁图亚 of the battle -------------------------------------------------
/** battle → { srcs: [{ unit, phys, arts }], last: WeakMap(enemy → { id, miss }) }. */
const MISS = new WeakMap();
function missCut(st, e, type) {
let cut = 0;
for (const s of st.srcs) {
const v = type === 'arts' ? s.arts : s.phys;
if (v > cut && up(s.unit) && cheb(s.unit, e) <= 1) cut = v;
}
return Math.min(1, cut);
}
function missHit(battle, st, ctx) {
const e = ctx.source, d = ctx.dmg;
if (!e || e.side !== 'enemy' || !d || !d.isAttack || d.cancel || (d.type !== 'phys' && d.type !== 'arts')) return;
// one roll per attack: every damage instance of it shares the first instance's result
const prev = d.attackId ? st.last.get(e) : null;
let miss;
if (prev && prev.id === d.attackId) miss = prev.miss;
else {
const cut = missCut(st, e, d.type);
miss = cut > 0 && battle.rng() < cut;
if (d.attackId) st.last.set(e, { id: d.attackId, miss });
}
if (!miss) return;
d.cancel = true;
ctx.stopPropagation = true;
if (ctx.target) battle.fx('dodge', { x: ctx.target.x, y: ctx.target.y, id: ctx.target.id });
}
function installMiss(battle, unit, phys, arts) {
if (!(phys > 0) && !(arts > 0)) return;
let st = MISS.get(battle);
if (!st) {
st = { srcs: [], last: new WeakMap() };
MISS.set(battle, st);
// no owner: it serves every 娜仁图亚 of the battle, whichever is on the field
battle.on('hit', (ctx) => missHit(battle, st, ctx), { priority: MISS_PRIORITY });
}
st.srcs.push({ unit, phys, arts });
}
export default {
char_4138_narant: (bb, chess) => {
const t0 = talentBb(chess, 0), t1 = talentBb(chess, 1);
const tb = traitBb(chess);
const hidden = moduleBb(chess);
const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3);
const cnt = count(b3.cnt, 3);
/** S1: the boomerang bounces from (ctx.x, ctx.y) — at most attack@times jumps — then flies back. */
const bounce = (ctx) => {
const { battle, unit } = ctx;
const hit = new Set(ctx.target ? [ctx.target] : []);
let on = ctx.target, x = ctx.x, y = ctx.y, left = count(b1['attack@times'], 3);
const next = () => {
if (left <= 0 || !ctx.home()) { ctx.comeBack(x, y); return; }
const cands = battle.foesInRadius(x, y, BOUNCE_RADIUS).filter((e) => e !== on && canTargetEnemy(unit, e, ctx.profile));
if (!cands.length) { ctx.comeBack(x, y); return; }
const d2 = (e) => (e.x - x) ** 2 + (e.y - y) ** 2;
cands.sort((a, b) => (hit.has(a) ? 1 : 0) - (hit.has(b) ? 1 : 0) || d2(a) - d2(b) || a.spawnSeq - b.spawnSeq);
const tgt = cands[0];
left--;
battle.addProjectile({ from: { x, y }, target: tgt, speed: BOUNCE_SPEED, visual: 'boomerang', source: unit, hitDead: true,
onHit: (c) => {
x = c.x; y = c.y;
on = c.target;
if (c.target) { hit.add(c.target); ctx.hit(c.target, c.x, c.y); }
next();
} });
battle.fx('bounce', { x, y, id: unit.id, target: tgt.id });
};
next();
};
/** S2: the boomerang dashes on, then hits every enemy it passes on its way back (once each). */
const dash = (ctx) => {
const { battle, unit } = ctx;
let dx = ctx.x - unit.x, dy = ctx.y - unit.y;
const len = Math.hypot(dx, dy);
if (len > 1e-9) { dx /= len; dy /= len; } else { const [fr, fc] = unit.fwd; dx = fc; dy = fr; }
const ahead = DASH_SPEED * Math.max(0, num(b2['attack@move_ahead_time'], 0.5));
battle.addProjectile({ from: { x: ctx.x, y: ctx.y }, to: { x: ctx.x + dx * ahead, y: ctx.y + dy * ahead }, speed: DASH_SPEED, visual: 'boomerang', source: unit, hitDead: true,
onHit: (c) => {
const back = ctx.comeBack(c.x, c.y);
if (!back) return;
const r = num(b2['attack@projectile_range'], 1), scale = num(b2['attack@atk_scale_comeback'], 1);
const passed = new Set();
const h = battle.on('tick', () => {
for (const e of battle.foesInRadius(back.x, back.y, r)) {
if (passed.has(e) || !canTargetEnemy(unit, e, ctx.profile)) continue;
passed.add(e);
battle.dealDamage(unit, e, { amount: unit.s.atk * scale, type: 'phys', isAttack: true, isSkill: true, attackId: ctx.attackId, tags: ['skill', 'narant:comeback'] });
}
if ((back.x === back.tx && back.y === back.ty) || !ctx.home()) battle.off(h);
}, { owner: unit });
} });
};
/** S3: the recall blast on at most attack@aoe.max_target enemies around her. */
const blast = (battle, unit) => {
if (!up(unit)) return;
const n = count(b3['attack@aoe.max_target'], 3), scale = num(b3.atk_scale_aoe, 1), slug = num(b3.sluggish);
battle.fx('aoe', { x: unit.x, y: unit.y, radius: 1, id: unit.id, skill: 'narant:sunswallower' });
for (const e of enemiesInGrid(battle, unit, AROUND, { n, canHitFly: true })) {
battle.dealDamage(unit, e, { amount: unit.s.atk * scale, type: 'phys', isSkill: true, tags: ['skill', 'narant:blast'] });
if (e.alive && slug > 0) battle.applyStatus(e, 'sluggish', { duration: slug, source: unit });
}
};
return {
skills: {
[S1]: {
kind: 'toggle',
targeting: { rangeGrid: shortenGrid(chess.rangeGrid ?? [[0, 1]], Math.max(0, -Math.round(num(b1.ability_range_forward_extend, -1)))) },
attack: { atkScale: num(b1['attack@atk_scale'], 1), boomerangOnward: bounce },
},
[S2]: {
kind: 'duration',
attack: { atkScale: num(b2['attack@atk_scale'], 1), onHitStatus: { key: 'sluggish', duration: num(b2['attack@sluggish'], 1) }, boomerangOnward: dash },
},
[S3]: { kind: 'duration', attack: { atkScale: num(b3['attack@atk_scale'], 1), hits: cnt } },
},
talents: [
{ install(battle, unit) { // “我见,我得”: each landing attack hit steals ATK / DEF (LPS-X stage 2+: twice within one tile)
const sa = num(t0['attack@steal_atk']), sd = num(t0['attack@steal_def']);
if (!(sa > 0) && !(sd > 0)) return;
const capA = num(t0['attack@steal_atk_max'], Infinity), capD = num(t0['attack@steal_def_max'], Infinity);
const m = /周围8格的敌人时偷取触发(\d+)次/.exec(String(talentRec(chess, 0)?.desc ?? ''));
const nearTimes = m ? Math.max(1, +m[1]) : 1;
const losses = stolenOf(battle);
const victims = new Set();
const reset = () => { unit.mem.narantLoot = { a: 0, d: 0 }; };
reset();
battle.on('deploy', (c) => { if (c.unit === unit) reset(); }, { owner: unit });
battle.on('damaged', (c) => {
const e = c.target;
if (c.source !== unit || !c.dmg?.isAttack || !e || e.side !== 'enemy' || !up(unit)) return;
const times = nearTimes > 1 && cheb(unit, e) <= 1 ? nearTimes : 1;
const me = unit.mem.narantLoot;
let per = losses.get(e);
if (!per) losses.set(e, (per = new Map()));
const loss = per.get(unit.id) ?? { a: 0, d: 0 };
for (let i = 0; i < times; i++) {
me.a = Math.min(capA, me.a + sa); me.d = Math.min(capD, me.d + sd);
loss.a = Math.min(capA, loss.a + sa); loss.d = Math.min(capD, loss.d + sd);
}
per.set(unit.id, loss);
victims.add(e);
battle.addBuff(unit, { key: LOOT_KEY, mods: { atkFlat: me.a, defFlat: me.d }, tags: ['talent'] });
if (e.alive) applyLoss(battle, e, per);
}, { owner: unit });
// 在场时: off the field she keeps nothing (her buff goes with the removal) and the enemies get back what she took
battle.on('death', (c) => {
if (c.unit !== unit) return;
for (const e of victims) {
const per = losses.get(e);
if (!per || !per.delete(unit.id)) continue;
if (e.alive) applyLoss(battle, e, per);
}
victims.clear();
}, { owner: unit });
} },
{ install(battle, unit) { // 婀娜虚影: dodge; enemies within one tile miss; LPS-Y stage 3: ATK + while unhurt
const p = num(t1.prob);
statBuff(battle, unit, 'talent:narant:dodge', { dodgePhys: p, dodgeArts: p });
installMiss(battle, unit, Math.abs(num(t1.damage_hitrate_physical)), Math.abs(num(t1.damage_hitrate_magical)));
const atk = num(hidden.atk), iv = num(hidden.interval);
if (!(atk > 0) || !(iv > 0)) return;
unit.mem.narantHurt = -Infinity;
battle.on('damaged', (c) => { if (c.target === unit && c.amount > 0 && !isHpLoss(c.dmg)) unit.mem.narantHurt = battle.time; }, { owner: unit });
battle.on('deploy', (c) => { if (c.unit === unit) unit.mem.narantHurt = -Infinity; }, { owner: unit });
toggleBuff(battle, unit, 'talent:narant:calm', () => battle.time - unit.mem.narantHurt >= iv - 1e-9, { atkPct: atk });
} },
],
install(battle, unit) {
// LPS-X: ×atk_scale on attack hits within one tile
const near = num(tb.atk_scale, 1);
if (near !== 1) onHitBy(battle, unit, ({ target, dmg }) => { if (dmg.isAttack && cheb(unit, target) <= 1) dmg.amount *= near; });
// LPS-Y: sp SP every come_back_cnt boomerangs caught; S3: the recall blast
const every = Math.floor(num(tb.come_back_cnt)), sp = num(tb.sp);
const s3 = unit.skill?.id === S3;
if (!(every > 0 && sp > 0) && !s3) return;
unit.mem.narantCaught = 0;
battle.on('boomerangCaught', (c) => {
if (c.unit !== unit) return;
const flightS3 = s3 && c.isSkill;
if (every > 0 && sp > 0) {
unit.mem.narantCaught += flightS3 ? cnt : 1;
while (unit.mem.narantCaught >= every) { unit.mem.narantCaught -= every; giveSp(unit, sp, 'module'); }
}
if (flightS3) blast(battle, unit);
}, { owner: unit });
},
};
},
};
+170
View File
@@ -0,0 +1,170 @@
// server/sim/content/kits/ops/op-poca.js — 早露 (char_197_poca) 自选 operator kit: 6★ 攻城手 (狙击), an owned-6★ pick of the
// tier-5 and tier-6 自选 slots; every skill, both talents, the trait and both modules at every form.
// Kit contract and the 自选 rules: ../README.md ("How to add an operator (自选)").
//
// Forms (data/backups.json units.char_197_poca, the DIY slot statuses): normal = E2 Lv1, skills at rank 4, no module;
// elite = E2 Lv60, rank 7, the picked module at stage 1 (tier 5) or 3 (tier 6). Potential 0 [ASSUMED: no account].
// Sources: character_table / skill_table / battle_equip_table (zh_CN, as built into backups.json); PRTS 早露 (备注 of
// 深入骨髓, 学生楷模 and 雪崩击); PRTS 分支特性信息 攻城手 ("可对空。攻击范围不包含自身所在地块").
// - Trait (攻城手) "优先攻击重量最重的敌人": target priority 'heaviest' (targeting.js: the highest current 重量等级 first, then
// the usual order); ranged physical arrows that hit air units (data canHitFly, PRTS "可对空"), range 4-3.
// - Module SIE-X 攻城器械训练装置: trait "攻击重量较重(重量等级大于等于3)的敌人时,攻击力提升至115%" — the hidden module talent
// (atk_scale / value, merged into 深入骨髓's blackboard): ×atk_scale on her attacks (normal attacks and the S3 strikes, the
// profile dmgMul — the kits' "攻击…时攻击力提升至X%" convention, 斯卡蒂 DRE-X) on an enemy of 重量等级 ≥ value. Stage 2+ 深入骨髓
// "…并额外造成60%攻击力的物理伤害" (extra_atk_scale): every attack hit that lands on such an enemy is followed by a physical hit
// of extra_atk_scale × ATK (which 深入骨髓's 物理穿透 also gets).
// - Module SIE-Y “心”: trait "攻击越远的敌人造成的伤害越高(最高提升12%)" (trait bb min_dist / max_dist / damage_scale): her attack
// damage × (1 + damage_scale × clamp((d − min_dist) / (max_dist − min_dist), 0, 1)), d = her distance to the target in tiles —
// the kits' reading of these keys (远牙, 协律, 芳汀 modules) [ASSUMED linear: PRTS states no formula]. Stage 2+: 学生楷模 below.
// - T1 深入骨髓 "攻击重量较重(重量等级大于等于3)的敌人时,无视其防御力的60%": every damage she deals to an enemy of 重量等级 ≥ value
// ignores def_penetrate of its DEF (DamageInfo defIgnorePct, added — PRTS 备注 "临时获取永久的物理穿透(百分比)Buff(不可叠加,
// 直接加算)").
// - T2 学生楷模 "编入队伍时,所有【乌萨斯学生自治团】干员攻击力+8%": every 【乌萨斯学生自治团】 operator of her player's team ATK +atk
// for the whole battle, deployed or not (the kits' 编入队伍时 convention, 斯卡蒂 深海掠食者). SIE-Y stage 3: +12 % and
// "每有一名【乌萨斯学生自治团】干员处于技能期间时,【乌萨斯学生自治团】干员的攻击力额外+15%(最多+45%)" (hidden init_atk /
// max_atk): the number of those operators on the field whose skill runs, re-read every STUDENT_TICK s and at every skill start
// (PRTS 备注 "每0.3秒及每次开启技能时更新一次自身的额外加成") [ASSUMED: her team's operators only, as the base talent].
// - S1 攻击力强化·γ型 (30 s): ATK +atk. S2 分裂射击 (60 s): ATK +atk, attack@max_target targets.
// - S3 雪崩击 (6 / 7 s): ATK +atk; at the cast, harpoons link the max_target heaviest enemies of her range (her own target
// order); each linked enemy is 束缚 (bind) while linked and takes one attack every hit_interval s, hit_duration /
// hit_interval attacks in all, the first as the harpoons land (the tick after the cast) [ASSUMED] — her own attacks (Battle.forceAttack:
// every attack rider of hers applies), instant along the links; she makes no other attack meanwhile [ASSUMED: the
// strikes are what "每秒受到一次攻击" names]. PRTS 备注: a link breaks when its enemy is knocked out, 离地 (OUT_OF_GROUND —
// no enemy of the mode holds it) or 消失 (hidden), every link at once when she is stunned / frozen / silenced, and the
// skill ends as soon as no link is left. The binds end with their links.
import { num, talentBb, moduleBb, moduleOn, traitBb, skillRec, statBuff, onHitBy, enemiesInGrid, up } from '../shared/tier1.js';
import { hasHp } from '../../../damage.js';
const S1 = 'skcom_atk_up[3]';
const S2 = 'skchr_poca_2';
const S3 = 'skchr_poca_3';
/** 【乌萨斯学生自治团】: character_table teamId `student` (古米, 凛冬, 烈夏, 苦艾, 真理, 早露, 怒潮凛冬); data/chess.json has no team id. */
const STUDENTS = new Set(['char_196_sunbr', 'char_115_headbr', 'char_194_leto', 'char_405_absin', 'char_195_glassb',
'char_197_poca', 'char_1051_headb2']);
/** PRTS 学生楷模 备注: the SIE-Y extra is re-read every 0.3 s (and at every skill start). */
const STUDENT_TICK = 0.3;
const STUDENT_KEY = 'talent:poca:students';
const DRIVE_KEY = 'talent:poca:studentSkills';
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
const count = (v, d) => Math.max(1, Math.floor(num(v, d)));
const isStudent = (a) => !!a && a.kind === 'op' && STUDENTS.has(a.def?.charId);
/** A running skill of `a` (a passive is no 技能期间). */
const inSkill = (a) => !!(a.skill && a.skill.active && a.skill.kind !== 'passive');
export default {
char_197_poca: (bb, chess) => {
const t0 = talentBb(chess, 0), t1 = talentBb(chess, 1);
const heavyAt = num(t0.value, 3);
const heavy = (e) => !!e && e.side === 'enemy' && e.weight >= heavyAt;
// SIE-X (hidden part on 深入骨髓's index: atk_scale / value; stage 2+ extra_atk_scale), SIE-Y (trait bb; hidden
// init_atk / max_atk at stage 3) — chess is the loadout-resolved record, so no module ⇒ none of these keys
const xScale = moduleOn(chess) ? num(t0.atk_scale, 1) : 1;
const extra = num(t0.extra_atk_scale);
const tb = traitBb(chess);
const hidden = moduleBb(chess);
const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3);
/** S3: the links of the running cast (unit.mem.pocaS3 = { links: Set, left, timer }). */
const unbind = (battle, unit, e) => {
const b = e.findBuff('bind');
if (b && b.source === unit) battle.removeBuff(e, b);
};
const linkBroken = (e) => !e.alive || !e.deployed || e.hidden || !!e.s.flags.outOfGround;
const strike = (battle, unit, st) => {
const targets = [...st.links].filter((e) => !linkBroken(e));
if (!targets.length || !unit.canAct || st.left <= 0) return;
st.left--;
battle.forceAttack(unit, targets);
};
return {
skills: {
[S1]: { kind: 'duration', mods: { atkPct: num(b1.atk) } },
[S2]: { kind: 'duration', mods: { atkPct: num(b2.atk) }, targeting: { maxTargets: count(b2['attack@max_target'], 2) } },
[S3]: {
kind: 'duration',
mods: { atkPct: num(b3.atk) },
// no attack of her own while the links hold; the strikes are instant along them ('beam')
attack: { noAttack: true, projectile: 'beam' },
onStart({ battle, unit, skill }) {
const links = enemiesInGrid(battle, unit, null, { n: count(b3.max_target, 3), priority: 'heaviest', canHitFly: true });
const st = { links: new Set(links), left: count(num(b3.hit_duration, skill.duration) / Math.max(0.1, num(b3.hit_interval, 1)), 6), timer: null };
unit.mem.pocaS3 = st;
for (const e of links) {
battle.applyStatus(e, 'bind', { duration: skill.timeLeft, source: unit });
battle.fx('harpoon', { x: e.x, y: e.y, id: unit.id, target: e.id });
}
// the harpoons land on the next tick (after the cast's skillStart handlers — 学生楷模's re-read), then every
// hit_interval s
st.timer = battle.after(0, () => {
if (unit.mem.pocaS3 !== st) return;
strike(battle, unit, st);
st.timer = battle.every(Math.max(0.1, num(b3.hit_interval, 1)), (b, sc) => {
if (unit.mem.pocaS3 !== st || st.left <= 0) { sc.cancel(); return; }
strike(battle, unit, st);
}, { owner: unit });
}, { owner: unit });
},
onTick({ battle, unit, skill }) {
const st = unit.mem.pocaS3;
if (!st) return;
const f = unit.s.flags;
if (f.stun || f.freeze || f.silence) { skill.end('interrupted'); return; }
for (const e of [...st.links]) if (linkBroken(e)) { st.links.delete(e); unbind(battle, unit, e); }
if (!st.links.size) skill.end('unlinked');
},
onEnd({ battle, unit }) {
const st = unit.mem.pocaS3;
unit.mem.pocaS3 = null;
if (!st) return;
if (st.timer) st.timer.cancel();
for (const e of st.links) unbind(battle, unit, e);
},
},
},
talents: [
{ install(battle, unit) { // 深入骨髓: +def_penetrate 物理穿透 on heavy enemies; SIE-X stage 2+: the extra physical hit
const pen = num(t0.def_penetrate);
if (pen > 0) onHitBy(battle, unit, ({ target, dmg }) => { if (heavy(target)) dmg.defIgnorePct += pen; });
if (extra > 0) {
battle.on('damaged', (c) => {
if (c.source !== unit || !c.dmg?.isAttack || !heavy(c.target) || !hasHp(c.target)) return;
battle.dealDamage(unit, c.target, { amount: unit.s.atk * extra, type: 'phys', tags: ['talent', 'poca:extra'] });
}, { owner: unit });
}
} },
{ install(battle, unit) { // 学生楷模: every student of her team ATK +atk; SIE-Y stage 3: +init_atk per student in skill
const team = battle.allyUnits.filter((a) => a.ownerId === unit.ownerId && isStudent(a));
for (const a of team) statBuff(battle, a, STUDENT_KEY, { atkPct: num(t1.atk) });
const per = num(hidden.init_atk), cap = num(hidden.max_atk, Infinity);
if (!(per > 0)) return;
const update = () => {
const v = Math.min(cap, per * team.filter((a) => up(a) && inSkill(a)).length);
for (const a of team) {
const cur = a.findBuff(DRIVE_KEY);
if (!(v > 0)) { if (cur) battle.removeBuff(a, cur); continue; }
if (!cur || cur.mods?.atkPct !== v) battle.addBuff(a, { key: DRIVE_KEY, mods: { atkPct: v }, persist: true, allowDead: true, tags: ['talent'], source: unit });
}
};
battle.every(STUDENT_TICK, update, { owner: unit, immediate: true });
battle.on('skillStart', (c) => { if (team.includes(c.unit)) update(); }, { owner: unit });
} },
],
// 攻城手: heaviest first; SIE-X ×atk_scale on her attacks against heavy enemies
trait: { priority: 'heaviest', ...(xScale !== 1 ? { dmgMul: (b, u, target) => (heavy(target) ? xScale : 1) } : null) },
install(battle, unit) {
// SIE-Y: the farther the target, the more damage (attacks only)
const ds = num(tb.damage_scale), lo = num(tb.min_dist, 1), hi = num(tb.max_dist, 4.5);
if (ds > 0) {
onHitBy(battle, unit, ({ target, dmg }) => {
if (!dmg.isAttack) return;
const d = Math.hypot(target.x - unit.x, target.y - unit.y);
dmg.mul *= 1 + ds * Math.max(0, Math.min(1, (d - lo) / Math.max(1e-6, hi - lo)));
});
}
},
};
},
};
+231
View File
@@ -0,0 +1,231 @@
// server/sim/content/kits/ops/op-saga.js — 嵯峨 (char_362_saga) 自选 operator kit: 6★ 尖兵 (先锋), an owned-6★ pick of the
// tier-5 and tier-6 自选 slots; every skill, both talents, the trait and both modules at every form.
// Kit contract and the 自选 rules: ../README.md ("How to add an operator (自选)").
//
// Forms (data/backups.json units.char_362_saga, the DIY slot statuses): normal = E2 Lv1, skills at rank 4, no module;
// elite = E2 Lv60, rank 7, the picked module at stage 1 (tier 5) or 3 (tier 6). Potential 0 [ASSUMED: no account].
// Sources: character_table / skill_table / battle_equip_table (zh_CN, as built into backups.json); gamedata_const ba.dying
// "重伤: 移速下降且无法被阻挡,10秒后自然死亡,被击杀后使击杀者回复数点技力"; PRTS 嵯峨 (劝善 备注 "本天赋第一部分实际效果为:
// 自身造成的伤害不会致命(至多使目标生命值降至1点),成功造成伤害后若目标生命值小于等于1则为目标附加重伤"; 清明 备注 "嵯峨每次
// 部署都可以触发一次本天赋"; S2 备注 "可选择重伤单位", "攻击附加延迟检测Buff:0.7s后进行检测,强制击杀持有重伤的目标(击杀来源为
// 嵯峨自身),随后解除目标的重伤"; S3 备注 "造成伤害前判断目标生命值"); PRTS 术语释义 重伤 ("重伤Buff包含:移动速度-80%,禁疗、
// 禁用近战,阻挡数、生命回复速度、生命回复速度(百分比)归零,从消失状态变为出现的瞬间立刻死亡(消失状态期间不会因重伤自然死亡)",
// "重伤造成的自然死亡为强制死亡(也即可以杀死持有不死的单位),视为无来源击杀", "重伤期间的单位无法再被施加重伤"); PRTS 异常效果
// 禁用近战 "敌人类单位无法进行阻挡攻击,无法被阻挡".
// - Trait (尖兵) "能够阻挡两个敌人": the profession default (block 2). SOL-X 何处寻驮? "阻挡敌人时攻击力和防御力各+8%"
// (trait bb atk / def): +atk / +def while she blocks an enemy. SOL-Y 身向云泥 "首次部署时部署费用-4" (hidden
// runtime_cost −4): the first deployment of a battle costs nothing in this mode ⇒ no effect (as 推进之王's SOL-Y).
// - T1 劝善: her damage is never lethal to an enemy (a `fatal` hook keeps it at 1 HP — every damage of hers: attacks, 除恶;
// not a 无来源 one, which has no source); after a damage of hers an enemy at ≤ 1 HP becomes 重伤 (the DYING buff, once:
// none while it holds one): move speed ×(1 + move_speed) (−80 %), unblockable (禁用近战: released by its blocker, no
// block attack), 禁疗 (healFree / noHeal), its HP regeneration zeroed; it dies `interval` s later (10) — a forced, 无来源
// death (battle.kill with no killer: no SP, 不死 does not hold it) — not while it is 消失 (hidden), and at once when it
// comes back from 消失. Whoever kills a 重伤 enemy gets `sp` SP (2; "击杀者技力+2", giveSp: not during a timed skill).
// "嵯峨不攻击重伤单位": the kit trait `skipEnemy` (targeting.js canTargetEnemy) — no normal attack, block target or
// trigger target of hers is a 重伤 enemy. SOL-X stage 2+ "对生命值低于一半的单位造成的伤害提升15%" (hidden hp_ratio /
// damage_scale): ×damage_scale on every damage of hers whose target is below hp_ratio before it.
// A shared-pool leader (boss rounds: its HP is the pool's, damage.js runs no `fatal` step for it) is outside 劝善
// [ASSUMED: her damage there is ordinary].
// - T2 清明 "生命值低于40%时,仅一次获得70%物理闪避和每秒回复5%的最大生命,持续15秒": once per deployment (PRTS 备注), the first
// time a damage leaves her below hp_ratio of her max HP: dodgePhys prob and hpRegenRatio for `duration` s — the
// blackboard key hp_recovery_per_sec_by_max_hp_ratio is the 生命回复速度(百分比) attribute (PRTS 异常效果 禁疗 / 术语释义
// 重伤 name it), as 宴 S1 / 山 / 幽灵鲨 / 蜜蜡 / 烛煌 apply it [ASSUMED: no 备注 of 清明 says so; 余's 闲云隐市 with the same
// key is still open, DESIGN 0.2.0.l3]. SOL-Y stage 2+: the talent change's numbers — at stage 3 the text reads 6 % (its
// potential-5 text 7 %) while the blackboard is 0.065: the game applies the blackboard, so 6.5 % per second.
// - S1 冲锋号令·γ型 (AUTO, no target): +cost DP at once; an AUTO skill acting on nobody fires as soon as its SP is full
// (`trigger: 'SP_FULL'`, as 推进之王's / 德克萨斯's kits cast the same skill — the owner's AUTO rule).
// - S2 除恶 (MANUAL, 2 charges, data SKILL_RANGE on its x-6 cross: any enemy there, 无视其不可选中): +cost DP; the up to
// S2_MAX (6, the text's — no blackboard key) selectable ground enemies of the cross ("地面敌人"; a 重伤 one included:
// "可选择重伤单位"), best targets first (her priority) [ASSUMED order], take atk_scale × ATK physical (a skill damage;
// 劝善 applies); S2_CHECK s later each of them still 重伤 is killed (killer 嵯峨: she gets its SP, stats and bounty; a
// forced kill — battle.kill, no `fatal` step), then its 重伤 is lifted (should a kill handler have revived it).
// - S3 怒目 (MANUAL, 20 s, data ACTIVE_RANGE on her 1-1 + 1): +cost DP every interval s while it runs (value in all; the
// rest of value at its natural end), attack interval +base_attack_time s (+0.5 on her 1.05), 攻击距离 +1
// (targeting.rangeExtend), ATK +atk, every enemy she blocks at once (hitAllBlocked; one target in range while she
// blocks none), and one more attack on each target that was below attack@hp_ratio before the hit (PRTS 备注: an HP
// snapshot at beforeAttack; the extra attack is a forced one a tick later, no 重伤 target).
// Melee physical, ground-only (canHitFly false), ground enemies target her normally (no 起飞 / 迷彩 / 隐匿).
import { num, talentBb, moduleBb, traitBb, skillRec, toggleBuff, batMod, giveSp, up } from '../shared/tier1.js';
import { absoluteRangeKeys, canTargetEnemy, sortEnemyTargets } from '../../../targeting.js';
const S1 = 'skcom_charge_cost[3]';
const S2 = 'skchr_saga_2';
const S3 = 'skchr_saga_3';
/** The 重伤 buff (gamedata_const ba.dying): one per enemy — "重伤期间的单位无法再被施加重伤". */
export const DYING = 'saga:dying';
/** S2 除恶 备注 "攻击附加延迟检测Buff:0.7s后进行检测" (no blackboard key). */
export const S2_CHECK = 0.7;
/** S2 除恶 "对十字范围内最多6名地面敌人" — the text's number when it cannot be read (no blackboard key). */
const S2_MAX = 6;
/** The x-6 cross (skill range) when the data carries no grid. */
const X6 = Object.freeze([[2, 0], [1, 0], [0, -2], [0, -1], [0, 0], [0, 1], [0, 2], [-1, 0], [-2, 0]]);
/** 除恶's selection: ground enemies only ("地面敌人"), 重伤 ones included (no skipEnemy). */
const GROUND = Object.freeze({ canHitFly: false, groundOnly: true });
/** Is `e` 重伤 (whoever put it there)? */
export const isDying = (e) => !!(e && typeof e.findBuff === 'function' && e.findBuff(DYING));
/**
* 重伤 on enemy `e` from `saga` (PRTS 术语释义 重伤): move speed ×(1 + ms), unblockable (禁用近战), 禁疗, no HP regeneration;
* a forced, 无来源 death `life` s later — never while 消失, at once when it comes back from 消失. The killer of a 重伤
* enemy gets `sp` SP (the talent's kill hook reads `data.sp`). Nothing when it already holds one.
*/
function applyDying(battle, saga, e, { sp, life, ms }) {
if (!e || !e.alive || isDying(e)) return;
battle.addBuff(e, {
key: DYING, source: saga, visible: true,
mods: { moveMul: Math.max(0, 1 + ms), hpRegen: -1e9 },
flags: { unblockable: true, healFree: true, noHeal: true },
data: { sp, until: battle.time + life, hidden: !!e.hidden },
onTick: ({ battle: b, unit: x, buff }) => {
const d = buff.data;
if ((d.hidden && !x.hidden) || (!x.hidden && b.time + 1e-9 >= d.until)) { b.kill(x, null); return; }
d.hidden = !!x.hidden;
},
});
battle.fx('dying', { x: e.x, y: e.y, id: e.id, from: saga.id });
}
export default {
char_362_saga: (bb, chess) => {
const b1 = skillRec(chess, S1)?.bb ?? {}, b2 = skillRec(chess, S2)?.bb ?? {}, b3 = skillRec(chess, S3)?.bb ?? {};
const s2 = skillRec(chess, S2);
const t0 = talentBb(chess, 0); // 劝善: move_speed, sp, interval
const t1 = talentBb(chess, 1); // 清明: hp_ratio, hp_recovery_per_sec_by_max_hp_ratio, prob, duration
const hidden = moduleBb(chess); // SOL-X stage 2+: hp_ratio, damage_scale; SOL-Y: runtime_cost (no effect)
const tb = traitBb(chess); // SOL-X: atk, def while blocking
const picked = chess?.skill?.skillId ?? null;
const maxHit = Math.max(1, Math.floor(num(/最多(\d+)名/.exec(String(s2?.desc ?? ''))?.[1], S2_MAX)));
return {
// 劝善 "嵯峨不攻击重伤单位"
trait: { skipEnemy: isDying },
skills: {
[S1]: {
kind: 'instant', trigger: 'SP_FULL',
onStart({ battle, unit }) {
const n = num(b1.cost);
battle.addDp(unit.ownerId, n);
battle.fx('dp', { x: unit.x, y: unit.y, n, id: unit.id });
},
},
[S2]: {
kind: num(s2?.maxChargeTime, 1) > 1 ? 'charges' : 'instant',
onStart({ battle, unit }) {
const n = num(b2.cost);
battle.addDp(unit.ownerId, n);
battle.fx('dp', { x: unit.x, y: unit.y, n, id: unit.id });
const keys = absoluteRangeKeys(s2?.rangeGrid ?? X6, unit.tileR, unit.tileC, unit.dir, 0);
const list = battle.enemiesInKeys(keys, unit, GROUND);
sortEnemyTargets(battle, unit, list, unit.profile?.priority ?? null);
const hit = list.slice(0, maxHit);
battle.fx('aoe', { x: unit.x, y: unit.y, radius: 2, id: unit.id, skill: 'saga:cleanse', n: hit.length });
for (const e of hit) {
battle.dealDamage(unit, e, { amount: unit.s.atk * num(b2.atk_scale, 1), type: 'phys', isSkill: true, tags: ['saga:s2'] });
if (!e.alive) continue;
battle.addBuff(e, {
key: 'saga:s2check', refresh: 'independent', maxStacks: 99, duration: S2_CHECK, source: unit,
onExpire: ({ battle: b, unit: x }) => {
if (!x.alive || !isDying(x)) return;
b.kill(x, unit);
if (x.alive) b.removeBuff(x, DYING);
},
});
}
},
},
[S3]: {
kind: 'duration',
mods: { atkPct: num(b3.atk), batPct: batMod(b3.base_attack_time, chess) },
targeting: { rangeExtend: num(b3.ability_range_forward_extend) },
attack: { hitAllBlocked: true },
onStart({ unit }) { unit.mem.sagaDp = { acc: 0, got: 0 }; },
onTick({ battle, unit, dt }) {
const st = unit.mem.sagaDp;
const iv = num(b3.interval, 1), n = num(b3.cost), total = num(b3.value);
if (!st || !(iv > 0) || !(n > 0)) return;
st.acc += dt;
while (st.acc + 1e-9 >= iv && st.got + n <= total + 1e-9) {
st.acc -= iv;
st.got += n;
battle.addDp(unit.ownerId, n);
}
},
onEnd({ battle, unit, reason }) {
const st = unit.mem.sagaDp;
unit.mem.sagaDp = null;
// the last grant falls on the very end of its 20 s
if (st && reason === 'duration' && num(b3.value) - st.got > 1e-9) battle.addDp(unit.ownerId, num(b3.value) - st.got);
},
},
},
talents: [
{ install(battle, unit) { // 劝善
const sp = num(t0.sp), life = num(t0.interval, 10), ms = num(t0.move_speed, -0.8);
// "自身造成的伤害不会致命(至多使目标生命值降至1点)" — before any other saver: it never was lethal
battle.on('fatal', (c) => {
if (c.source === unit && c.unit && c.unit.side === 'enemy' && !c.prevented) c.prevented = true;
}, { owner: unit, priority: 100 });
// "成功造成伤害后若目标生命值小于等于1则为目标附加重伤"
battle.on('damaged', (c) => {
const e = c.target;
if (c.source !== unit || !e || e.side !== 'enemy' || !e.alive || e.bossPool || c.type === 'element' || e.hp > 1) return;
applyDying(battle, unit, e, { sp, life, ms });
}, { owner: unit });
// "被击杀后使击杀者回复数点技力" — the killer of a 重伤 enemy she marked (none for the 无来源 natural death)
battle.on('kill', (c) => {
const d = c.victim && typeof c.victim.findBuff === 'function' ? c.victim.findBuff(DYING) : null;
if (!d || d.source !== unit || !c.killer || c.killer.side !== 'ally') return;
giveSp(c.killer, num(d.data?.sp));
}, { owner: unit });
// SOL-X stage 2+: "对生命值低于一半的单位造成的伤害提升15%"
const hr = num(hidden.hp_ratio), ds = num(hidden.damage_scale, 1);
if (hr > 0 && ds !== 1) {
battle.on('hit', (c) => {
if (c.source === unit && c.target && c.target.side === 'enemy' && c.target.hpRatio < hr) c.dmg.mul *= ds;
}, { owner: unit });
}
} },
{ install(battle, unit) { // 清明: once per deployment below hp_ratio — dodgePhys prob, hpRegenRatio, `duration` s
const hr = num(t1.hp_ratio), p = num(t1.prob), regen = num(t1.hp_recovery_per_sec_by_max_hp_ratio), dur = num(t1.duration);
if (!(hr > 0) || !(dur > 0)) return;
battle.on('damaged', (c) => {
if (c.target !== unit || !up(unit) || unit.mem.qingmingAt === unit.deploySeq) return;
if (!(unit.hp < unit.s.maxHp * hr)) return;
unit.mem.qingmingAt = unit.deploySeq;
battle.addBuff(unit, { key: 'talent:saga:qingming', duration: dur, mods: { dodgePhys: p, hpRegenRatio: regen }, visible: true, tags: ['talent'] });
battle.fx('buff', { x: unit.x, y: unit.y, id: unit.id, kind: 'saga:qingming' });
}, { owner: unit });
} },
],
install(battle, unit) {
// SOL-X 何处寻驮?: 阻挡敌人时攻击力和防御力各+8%
const ma = num(tb.atk), md = num(tb.def);
if (ma || md) toggleBuff(battle, unit, 'trait:saga:block', () => unit.blocking.length > 0, { atkPct: ma, defPct: md });
// 怒目: one more attack on each target below attack@hp_ratio (checked before the hit) — a forced attack a tick later
if (picked !== S3) return;
const hp3 = num(b3['attack@hp_ratio']);
if (!(hp3 > 0)) return;
let half = null;
battle.on('beforeAttack', (c) => {
if (c.attacker !== unit || unit.mem.sagaExtra || !unit.skill?.active) return;
half = (c.targets || []).filter((e) => e && e.side === 'enemy' && e.hpRatio < hp3);
}, { owner: unit, priority: -100 });
battle.on('attack', (c) => {
if (c.attacker !== unit || unit.mem.sagaExtra) return;
const list = half;
half = null;
if (!list || !list.length) return;
const dep = unit.deploySeq;
battle.after(0, () => {
if (!up(unit) || unit.deploySeq !== dep || !unit.canAct || unit.s.flags.disarm) return;
const t = list.filter((e) => e.alive && canTargetEnemy(unit, e, unit.profile));
if (!t.length) return;
unit.mem.sagaExtra = true;
try { battle.forceAttack(unit, t, { noAmmo: true }); } finally { unit.mem.sagaExtra = false; }
}, { owner: unit });
}, { owner: unit });
},
};
},
};
+142
View File
@@ -0,0 +1,142 @@
// server/sim/content/kits/ops/op-shining.js — 闪灵 (char_147_shining) 自选 operator kit: 6★ 医师 (医疗), an owned-6★ pick of
// the tier-5 and tier-6 自选 slots; every skill, both talents, the trait and both modules at every form.
// Kit contract and the 自选 rules: ../README.md ("How to add an operator (自选)").
//
// Forms (data/backups.json units.char_147_shining, the DIY slot statuses): normal = E2 Lv1, skills at rank 4, no module;
// elite = E2 Lv60, rank 7, the picked module at stage 1 (tier 5) or 3 (tier 6). Potential 0 [ASSUMED: no account].
// Sources: character_table / skill_table / battle_equip_table (zh_CN, as built into backups.json) and PRTS 闪灵 (S2 备注
// "多个屏障吸收量可叠加,持续时间独立计算,优先消耗先生成的屏障,防御力提升效果不可叠加"; PRTS 术语释义 屏障 "若无特殊说明,
// 屏障可吸收全种类伤害").
// - Trait (医师) "恢复友方单位生命": the profession default (one heal on the lowest-HP% injured ally of her 3-3 range).
// Module PHY-Y 干枯剑鞘 "治疗地面单位时治疗量提升15%" (trait bb heal_scale): ×heal_scale on a heal whose target stands on
// a 地面 tile (unit.ground, as 赫默's PHY-Y); PHY-X “使徒” "治疗生命值低于50%的友方单位时治疗量提升15%" (heal_scale,
// hp_ratio): ×heal_scale when the target is below hp_ratio of its max HP before the heal (as 录武官's / 华法琳's PHY-X).
// - T1 黑恶魔的庇护 "攻击范围内的友方单位防御力+60": every ally (operators and summons: 友方单位) standing in her current
// attack range — her own tile included — DEF +def (flat). PHY-Y stage 2+ "防御力+100,地面单位防御力额外+40" (talent
// change def / def_lowland): def_lowland more on a 地面 tile. Refreshed every AURA s; two sources keep the strongest.
// - T2 法典 "攻击速度+10": ASPD +attack_speed. PHY-X stage 2+ "攻击速度+15,装备技能2时获得+25%攻击力,装备技能3时技力自然
// 回复速度+0.6/秒" (talent change attack_speed; the hidden module talent's atk / sp_recovery_per_sec): ATK +atk while 自动
// 掩护 is her picked skill, SP +sp_recovery_per_sec per second while 教条力场 is.
// - S1 信条 (MANUAL, 20 s, data DEFAULT — a heal skill: cast as she is about to heal an injured ally in range): ATK +atk,
// ASPD +attack_speed.
// - S2 自动掩护 (AUTO, 1 charge at rank 4 / 2 at rank 7, data DEFAULT): "下次治疗使目标获得一个持续N秒的屏障,屏障可以吸收相当于
// 闪灵攻击力X%的伤害,同时使目标防御力+Y%" — a "next heal" waits for her heal, as 推进之王's S2 waits for his attack (the
// owner's SP_FULL rule is for AUTO skills that act on nobody). The heal's target gets a 屏障 of atk_scale × her ATK for
// duration s (every damage type) and DEF +def while it holds: each barrier is its own buff (its own timer; older ones
// absorb first — buff order), the DEF bonus one buff per target, kept while one of her barriers lasts [ASSUMED: it goes
// with the last barrier, broken or expired — the 备注 only says it does not stack].
// - S3 教条力场 (MANUAL, 60 s, data DEFAULT): ATK +atk; every ally in her attack range DEF +def (%), refreshed every AURA s.
// Medic: heals only (dmgType heal), ranged, no anti-air question; ground enemies target her normally (no 起飞 / 迷彩 / 隐匿).
import { num, talentBb, moduleBb, traitBb, moduleOn, skillRec, statBuff, up } from '../shared/tier1.js';
const S1 = 'skchr_shining_1';
const S2 = 'skchr_shining_2';
const S3 = 'skchr_shining_3';
const PHY_Y = 'uniequip_002_shining';
const PHY_X = 'uniequip_003_shining';
/** Aura refresh period and buff life (s): an ally leaving the range loses the buff within one refresh (tier-4 AURA). */
const AURA = 0.2;
const AURA_DUR = 0.25;
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
const pickedSkill = (chess) => chess?.skill?.skillId ?? null;
/**
* Keep buff `key` on every ally standing in `unit`'s current attack range (operators and summons, its own tile included;
* never a 孤立 unit — Battle.alliesInGrid) while `on()` holds, refreshed every AURA s; it lapses AURA_DUR s after the
* source stops refreshing it. Several sources of the same effect keep the strongest `value` (same-named buffs).
* Shared with 夜莺's kit (op-cgbird.js).
* @param {object} battle
* @param {object} unit the source
* @param {{ key: string, value: number, mods: object|((ally: object) => object), on?: () => boolean }} o
*/
export function rangeAura(battle, unit, { key, value, mods, on = () => true }) {
battle.every(AURA, () => {
if (!up(unit) || !on()) return;
for (const a of battle.alliesInGrid(unit)) {
const cur = a.findBuff(key);
if (cur && cur.source !== unit && cur.source?.alive && (cur.data?.v ?? 0) > value && cur.timeLeft > 0.05) continue;
battle.addBuff(a, { key, duration: AURA_DUR, mods: typeof mods === 'function' ? mods(a) : mods, source: unit, data: { v: value }, tags: ['aura'] });
}
}, { owner: unit, immediate: true });
}
/**
* A 屏障 on `target` (闪灵 S2 自动掩护, 夜莺 S2 法术护盾): `shield` HP for `duration` s — of `shieldType` damage only when
* given (damage.js absorbShields), else every type — plus the buff `statKey` (`mods`) while any barrier `key` lasts. PRTS
* 备注 of both skills: "多个屏障吸收量可叠加,持续时间独立计算,优先消耗先生成的屏障,…效果不可叠加" — each barrier is its own
* buff (refresh 'independent': its own timer; absorbShields walks the buffs oldest first), the stat bonus one buff whose
* life follows the barriers (it ends when the last one breaks or expires). Shared with 夜莺's kit (op-cgbird.js).
* @param {object} battle
* @param {object} source the operator
* @param {object} target
* @param {{ key: string, statKey: string, shield: number, duration: number, mods: object, shieldType?: string|null }} o
*/
export function grantBarrier(battle, source, target, { key, statKey, shield, duration, mods, shieldType = null }) {
if (!target || !target.alive || !(shield > 0) || !(duration > 0)) return;
const sync = () => { if (!target.buffs.some((b) => b.key === key)) battle.removeBuff(target, statKey); };
battle.addBuff(target, {
key, refresh: 'independent', maxStacks: 99, duration, shield, shieldType, source, visible: true, onRemove: sync, onExpire: sync,
});
battle.addBuff(target, { key: statKey, refresh: 'extend', duration, mods, source });
battle.fx('shield', { x: target.x, y: target.y, id: target.id, from: source.id });
}
export default {
char_147_shining: (bb, chess) => {
const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3);
const s2 = skillRec(chess, S2);
const t0 = talentBb(chess, 0); // 黑恶魔的庇护: def (PHY-Y stage 2+: def 100, def_lowland)
const t1 = talentBb(chess, 1); // 法典: attack_speed (PHY-X stage 2+: 15)
const hidden = moduleBb(chess); // PHY-X stage 2+: atk (with S2 picked), sp_recovery_per_sec (with S3 picked)
const tb = traitBb(chess); // PHY-Y: heal_scale; PHY-X: heal_scale, hp_ratio
const mod = moduleOn(chess) ? chess.module.id : null;
const picked = pickedSkill(chess);
return {
skills: {
[S1]: { kind: 'duration', heal: true, mods: { atkPct: num(b1.atk), aspd: num(b1.attack_speed) } },
[S2]: {
kind: num(s2?.maxChargeTime, 1) > 1 ? 'charges' : 'instant',
heal: true,
attack: { healScale: 1 }, // the next heal (ai.js doHeal calls onHit per heal target)
onHit({ battle, unit, target }) {
if (!target || target.side !== 'ally') return;
grantBarrier(battle, unit, target, {
key: 'shining:barrier', statKey: 'shining:barrierDef', shield: unit.s.atk * num(b2.atk_scale),
duration: num(b2.duration), mods: { defPct: num(b2.def) },
});
},
},
[S3]: { kind: 'duration', heal: true, mods: { atkPct: num(b3.atk) } },
},
talents: [
{ install(battle, unit) { // 黑恶魔的庇护: allies in her attack range DEF +def; PHY-Y stage 2+: 地面 units +def_lowland
const d = num(t0.def), low = num(t0.def_lowland);
if (!(d > 0) && !(low > 0)) return;
rangeAura(battle, unit, { key: 'talent:shining:def', value: d + low, mods: (a) => ({ defFlat: d + (a.ground ? low : 0) }) });
} },
{ install(battle, unit) { // 法典: ASPD +attack_speed; PHY-X stage 2+: ATK +atk with S2, SP +0.6/s with S3
statBuff(battle, unit, 'talent:shining:aspd', { aspd: num(t1.attack_speed) });
if (picked === S2) statBuff(battle, unit, 'talent:shining:s2', { atkPct: num(hidden.atk) });
if (picked === S3) statBuff(battle, unit, 'talent:shining:s3', { spRecoveryFlat: num(hidden.sp_recovery_per_sec) });
} },
],
install(battle, unit) {
// 教条力场: allies in her attack range DEF +def % while the skill runs
if (picked === S3 && num(b3.def) > 0) {
rangeAura(battle, unit, { key: 'skill:shining:def', value: num(b3.def), mods: { defPct: num(b3.def) }, on: () => !!unit.skill?.active });
}
// module traits: PHY-Y heals on 地面 units, PHY-X heals on allies below hp_ratio, ×heal_scale
const hs = num(tb.heal_scale, 1);
if (!mod || hs === 1) return;
const boosted = mod === PHY_X ? (t) => t.hpRatio < num(tb.hp_ratio) : mod === PHY_Y ? (t) => !!t.ground : null;
if (!boosted) return;
battle.on('heal', (ctx) => {
if (ctx.source !== unit || ctx.opts?.regen || !ctx.target || !boosted(ctx.target)) return;
ctx.amount *= hs;
}, { owner: unit });
},
};
},
};
+162
View File
@@ -0,0 +1,162 @@
// server/sim/content/kits/ops/op-shwaz.js — 黑 (char_340_shwaz) 自选 operator kit: 6★ 重射手 (狙击), an owned-6★ pick of the
// tier-5 and tier-6 自选 slots; every skill, both talents, the trait and both modules (ARC-X 定制弩配件套装, ARC-Y 老剃刀) at
// every form. Kit contract and the 自选 rules: ../README.md ("How to add an operator (自选)").
//
// Forms (data/backups.json units.char_340_shwaz): normal = E2 Lv1, skills at rank 4, no module; elite = E2 Lv60, rank 7,
// the picked module at stage 1 (tier 5) or 3 (tier 6) — the owner's decision of 2026-10-05. Potential 0 [ASSUMED: no
// account]. Sources: character_table / skill_table / battle_equip_table (zh_CN, as built into backups.json) and PRTS 黑
// (破甲箭头 备注 "攻击力提升效果为提升攻击力倍率"; 战术的终结 "攻击间隔略微增大(+0.4)"; the {{**|80%|下降20%}} marks of the
// DEF cut = a final ×0.8).
// - Trait (重射手) "高精度的近距离射击": the plain closerange profile — ranged physical arrows, 3-6, can hit air units
// (PRTS 分支特性信息 重射手 "可对空"), blocks 1, ground enemies target her (no flag).
// - T1 破甲箭头 "攻击时,20%几率当次攻击的攻击力提升至160%,并使命中目标的防御力下降20%,持续5秒": one roll per attack
// (`prob`); on a proc the attack's damage ×atk_scale (an ATK-scale multiplier, PRTS 备注, so it multiplies the skills'
// own scales) and, once the hit landed (命中: a dodged hit does nothing), the target's DEF ×(1 + def) for
// defdown_duration s — the catalogue `defDown` (final ×0.8, PRTS; 同名 keeps the strongest). The skills raise the chance:
// S1's next attack to S1 talent@prob, S2 / S3's attacks while they run to their talent@prob (every attack a skill makes
// carries dmg.isSkill — S2's empty attack override marks its attacks for that). ARC-Y stage 2+ upgrades it (stage 3:
// 170 %, DEF −25 %) — read from the base talent and the module's NAMED change: ARC-Y's hidden talent part (talentIndex 0,
// isHideTalent, atk_scale 1.05, prefabKey "10") is the trait's 105 % as a talent of its own in the game, which the
// data's merge by talentIndex folds into 破甲箭头 at stage 1 (composed atk_scale 1.05) — reported, not used.
// - T2 交叉火力 "场上存在黑和另外至少一名【狙击】干员时,所有【狙击】干员的攻击力+8%": while she is on the field and another
// 【狙击】 operator is too, every 【狙击】 operator of the field (her included; a partner's in a shared field too [ASSUMED,
// as 推进之王's 万兽之王]) ATK +atk, refreshed every 0.5 s (lapses within 0.6 s when the condition ends); several sources
// keep the strongest. ARC-X stage 2+ "携带黑和另外至少一名【狙击】干员时…+13%": 携带 = in the squad — the player's own
// pieces of the battle, deployed or not — so with another 【狙击】 piece in her player's battle every 【狙击】 operator of
// that player ATK +13 % for the whole battle (persist; a partner's operators are not in her squad [ASSUMED]).
// - Module ARC-X “定制弩配件套装”: "再部署时间减少" (attribute respawn_time −25, in the stats: 70 → 45 s) + HP / ATK.
// - Module ARC-Y “老剃刀” trait "攻击正前方的敌人时攻击力提升至105%且无视其物理闪避" (trait bb atk_scale): her attack damage
// on an enemy whose body is on her straight line ahead ×atk_scale and not dodgeable (canDodge false — her damage is
// physical only). 正前方 = local row 0, column ≥ 0 in her facing — her own tile included, as S3's 3-2 "前方3格" counts
// it [ASSUMED: no source draws the line].
// - S1 强弩 (AUTO, attack SP 4, data DEFAULT): the next attack ×atk_scale (and the T1 chance above).
// - S2 暮眼锐瞳 (MANUAL, data DEFAULT): ATK +atk for 33 / 36 s, T1 chance talent@prob.
// - S3 战术的终结 (MANUAL): range → 3-2 (her tile + 3 ahead) while it runs, attack interval +base_attack_time s (a flat
// +0.4 on her 1.6 s, PRTS), ATK +atk, T1 chance 100 %. Trigger: the data's DEFAULT (the basic strategy) — "攻击范围改为前方
// 3格" is an attack-range change, not a 技能范围 (tools/build-data.mjs ATTACK_RANGE_CHANGE has "改为" since O6's report).
import { num, up, traitBb, moduleOn, skillRec, batMod } from '../shared/tier1.js';
import { bodyKeys } from '../../../body.js';
import { toLocal } from '../../../dir.js';
import { hasHp } from '../../../damage.js';
import { COLS } from '../../../constants.js';
const S1 = 'skchr_shwaz_1';
const S2 = 'skchr_shwaz_2';
const S3 = 'skchr_shwaz_3';
/** 交叉火力's buff (the field aura and the squad version share it: one effect per operator, the strongest). */
const CROSSFIRE = 'talent:shwaz:crossfire';
/** 交叉火力's refresh interval (s), as installAura. */
const AURA_EVERY = 0.5;
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
/** A 【狙击】 operator (summons are never operators). */
const isSniperOp = (a) => !!a && a.kind === 'op' && a.def?.profession === 'SNIPER';
/** The module the record fights with (its active one), or null. */
function activeModule(chess) {
if (!moduleOn(chess)) return null;
return (chess.modules ?? []).find((m) => m && m.uniEquipId === chess.module.id) ?? null;
}
/**
* 破甲箭头's blackboard: the base talent (no module) with the module's NAMED changes of talent 0 only — never the hidden
* trait part (see the header).
*/
function pierceBb(chess) {
const base = (chess?.talentsBase ?? chess?.talents ?? []).find((t) => t && t.index === 0)?.bb ?? {};
const ups = (activeModule(chess)?.talentChanges ?? []).filter((t) => t && t.talentIndex === 0 && !t.hidden && t.name);
return Object.assign({}, base, ...ups.map((t) => t.bb || {}));
}
/** The record's 交叉火力 (talent 1, the module's change included): its blackboard and whether it reads 携带 (squad). */
function crossfireOf(chess) {
const t = (chess?.talents ?? []).find((x) => x && x.index === 1) ?? null;
return { atk: num(t?.bb?.atk), squad: /携带/.test(String(t?.desc ?? '')) };
}
/** ARC-Y 正前方: the enemy's body touches the unit's straight line ahead (local row 0, local column ≥ 0). */
function inFrontLine(unit, e) {
for (const k of bodyKeys(e)) {
const [lr, lc] = toLocal(Math.floor(k / COLS) - unit.tileR, (k % COLS) - unit.tileC, unit.dir);
if (lr === 0 && lc >= 0) return true;
}
return false;
}
export default {
char_340_shwaz: (bb, chess) => {
const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3);
const s3 = skillRec(chess, S3);
const t0 = pierceBb(chess);
const cross = crossfireOf(chess);
const frontScale = num(traitBb(chess).atk_scale, 1);
return {
skills: {
[S1]: { kind: 'instant', attack: { atkScale: num(b1.atk_scale, 1) } },
// (an empty attack override: the attacks made while it runs carry dmg.isSkill — 破甲箭头's raised chance)
[S2]: { kind: 'duration', mods: { atkPct: num(b2.atk) }, attack: {} },
[S3]: {
kind: 'duration',
targeting: { rangeGrid: s3?.rangeGrid ?? [[0, 0], [0, 1], [0, 2], [0, 3]] },
mods: { atkPct: num(b3.atk), batPct: batMod(b3.base_attack_time, chess) },
},
},
talents: [
{ install(battle, unit) { // 破甲箭头: one roll per attack; ×atk_scale on a proc, then DEF cut on the target hit
const scale = num(t0.atk_scale, 1), cut = -num(t0.def), dur = num(t0.defdown_duration, 5), base = num(t0.prob);
const sid = unit.skill?.id;
const raised = num(bbOf(chess, sid)['talent@prob'], base);
battle.on('hit', (ctx) => {
const d = ctx.dmg, t = ctx.target;
if (ctx.source !== unit || !t || t.side !== 'enemy' || !d.isAttack || d.isSplash || d.type === 'element') return;
const m = unit.mem;
if (!d.attackId || m.shwazRollId !== d.attackId) {
m.shwazRollId = d.attackId;
const p = d.isSkill ? raised : base;
m.shwazProc = p > 0 && battle.rng.chance(p);
if (m.shwazProc) battle.fx('crit', { x: t.x, y: t.y, id: unit.id });
}
if (!m.shwazProc) return;
d.amount *= scale;
d.shwazPierce = true;
}, { owner: unit });
battle.on('damaged', (ctx) => {
if (ctx.source !== unit || !ctx.dmg?.shwazPierce || !(cut > 0) || !hasHp(ctx.target) || ctx.target.side !== 'enemy') return;
battle.applyStatus(ctx.target, 'defDown', { duration: dur, value: cut, source: unit });
}, { owner: unit });
} },
{ install(battle, unit) { // 交叉火力: field aura, or (ARC-X stage 2+) the squad's snipers for the whole battle
const v = cross.atk;
if (!(v > 0)) return;
const give = (a, extra) => {
const cur = a.findBuff(CROSSFIRE);
if (cur && cur.source !== unit && (cur.data?.v ?? 0) > v && cur.timeLeft > 0.05) return;
battle.addBuff(a, { key: CROSSFIRE, mods: { atkPct: v }, source: unit, data: { v }, ...extra });
};
if (cross.squad) {
const squad = battle.allyUnits.filter((a) => isSniperOp(a) && a.ownerId === unit.ownerId);
if (squad.some((a) => a !== unit)) for (const a of squad) give(a, { persist: true, allowDead: true, tags: ['talent'] });
return;
}
battle.every(AURA_EVERY, () => {
if (!up(unit)) return;
const snipers = battle.alliesFor(unit).filter(isSniperOp);
if (!snipers.some((a) => a !== unit)) return;
for (const a of snipers) give(a, { duration: AURA_EVERY + 0.1, tags: ['aura'] });
}, { owner: unit, immediate: true });
} },
],
install(battle, unit) {
// ARC-Y 老剃刀: 攻击正前方的敌人时攻击力提升至105%且无视其物理闪避
if (!(frontScale > 0) || frontScale === 1) return;
battle.on('hit', (ctx) => {
const d = ctx.dmg, t = ctx.target;
if (ctx.source !== unit || !t || t.side !== 'enemy' || !d.isAttack || d.type === 'element' || !inFrontLine(unit, t)) return;
d.amount *= frontScale;
d.canDodge = false;
}, { owner: unit });
},
};
},
};
+212
View File
@@ -0,0 +1,212 @@
// server/sim/content/kits/ops/op-typhon.js — 提丰 (char_2012_typhon) 自选 operator kit: 6★ 攻城手 (狙击), an owned-6★ pick of
// the tier-5 and tier-6 自选 slots; every skill, both talents, the trait and all three modules at every form.
// Kit contract and the 自选 rules: ../README.md ("How to add an operator (自选)").
//
// Forms (data/backups.json units.char_2012_typhon, the DIY slot statuses): normal = E2 Lv1, skills at rank 4, no module;
// elite = E2 Lv60, rank 7, the picked module at stage 1 (tier 5) or 3 (tier 6). Potential 0 [ASSUMED: no account].
// Sources: character_table / skill_table / battle_equip_table (zh_CN, as built into backups.json); PRTS 提丰 (备注 of 锐如兽牙,
// 重如沼泥, 冰原秩序 and “永恒狩猎”); PRTS 分支特性信息 攻城手 ("可对空").
// - Trait (攻城手) "优先攻击重量最重的敌人": target priority 'heaviest' (targeting.js); ranged physical arrows that hit air units,
// range 4-3 (as 早露, kits/ops/op-poca.js).
// - Module SIE-X 自然的包容: trait "攻击重量较重(重量等级大于等于3)的敌人时,攻击力提升至115%" (the hidden module talent's atk_scale /
// value on 锐如兽牙's index): ×atk_scale on her attacks against an enemy of 重量等级 ≥ value (profile dmgMul); stage 2+ 锐如兽牙's
// numbers (12 % a stack, 10 s) come with the composed talent.
// - Module SIE-Y 冰原的影子: trait "攻击越远的敌人造成的伤害越高(最高提升12%)" — as 早露's SIE-Y (her attack damage, linear between
// min_dist and max_dist [ASSUMED]); ASPD in the stats; stage 2+ 重如沼泥 "前两次…攻击力提升至220%" (max_stack_cnt / atk_scale).
// - Module ISW-A 提丰特限证章: its trait and talent parts say "在集成战略中" — no effect in this mode (the hidden damage_scale /
// arrow-rain blackboards are ignored); its HP / ATK are in the stats.
// - T1 锐如兽牙 "连续攻击时逐渐无视敌人的防御力,最高无视其防御力的50%(每次攻击提升10%的无视防御比例),8秒内未攻击则失去加成": every
// attack she makes adds a stack (max_stack_cnt) of +def_penetrate 物理穿透 (defIgnorePct, added — PRTS 备注 "增加自身物理穿透(百分
// 比)属性(直接加算)") before its damage ("获得的加成可应用于当次伤害"), all of them lost `duration` s after her last attack.
// - T2 重如沼泥 "技能期间对每个敌人首次造成伤害时,攻击力提升至160%并使目标停顿3秒": while a skill of hers runs, her first (SIE-Y stage
// 2+: first max_stack_cnt) damage instances on each enemy deal ×atk_scale (攻击力倍率) and, when they land, 停顿 `sluggish` s —
// every damage of hers, the S3 arrow rain included ("造成伤害"); the marks are cleared when the skill ends or she leaves
// (PRTS 备注 "非首次标记…自身技能结束时或自身离场时移除"). A dodged instance spends its mark [ASSUMED: "于计算伤害前触发"].
// - S1 迅捷打击·γ型 (35 s): ATK +atk, ASPD +attack_speed.
// - S2 冰原秩序: ATK +atk; every attack shoots two arrows, at two different enemies when there are two (targeting 2), else both
// at the one (PRTS "优先攻击不同目标"); each arrow attack@prob to stun attack@stun s. first_duration s on its first cast of a
// deployment, then "持续时间无限" until she leaves (a toggle the kit ends after first_duration s; PRTS 备注 "每次部署提丰时,
// 重新计算该技能的使用次数").
// - S3 “永恒狩猎” (ammo attack@s3_trigger_time): base attack time +base_attack_time s (flat: batMod). At the cast she marks the first
// enemy of her own range (未开启技能时的攻击范围: the initial range, her heaviest-first order); the mark area (radius MARK_RADIUS,
// 碰撞判定 — foesInRadius) follows that enemy until it leaves the field, then stays; its enemies are re-read every MARK_SCAN s.
// Her range is the whole field (the card shows 全场) but she attacks only while an enemy is in the mark area; each attack
// deals nothing itself and drops a round of s3_max_hit_num arrows, RAIN_GAP s apart, each on a random enemy of the area: a
// physical skill hit (not an attack: PRTS "不属于普通攻击/技能攻击的直接伤害", so the SIE-X / SIE-Y attack riders do not apply
// [ASSUMED]) of s3_atk_scale × ATK, then 晕眩 s3_stun s once that damage landed.
// Auto-cast: the data's DEFAULT (the whole-field range exists only in PRTS's note; the mark is picked from her own range).
import { num, talentBb, moduleOn, traitBb, skillRec, batMod, onHitBy, skillBusy, up } from '../shared/tier1.js';
import { canTargetEnemy, sortEnemyTargets } from '../../../targeting.js';
import { ROWS, COLS } from '../../../constants.js';
const S1 = 'skcom_quickattack[3]';
const S2 = 'skchr_typhon_2';
const S3 = 'skchr_typhon_3';
/** PRTS S3 备注: "标记区域半径1.3(碰撞判定),每0.2s更新范围内的敌人", "箭雨的每支箭间隔0.1s且分别索敌". */
const MARK_RADIUS = 1.3;
const MARK_SCAN = 0.2;
const RAIN_GAP = 0.1;
/** S3 "攻击范围扩大至全场": every offset that reaches the whole 19 × 21 field whatever her direction. */
const FIELD = (() => {
const m = Math.max(ROWS, COLS) - 1;
const g = [];
for (let dr = -m; dr <= m; dr++) for (let dc = -m; dc <= m; dc++) g.push(Object.freeze([dr, dc]));
return Object.freeze(g);
})();
const FANG_KEY = 'talent:typhon:fang';
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
const count = (v, d) => Math.max(1, Math.floor(num(v, d)));
export default {
char_2012_typhon: (bb, chess) => {
const t0 = talentBb(chess, 0), t1 = talentBb(chess, 1);
const tb = traitBb(chess);
// SIE-X: the hidden part merged into 锐如兽牙's blackboard (no module / SIE-Y / ISW-A ⇒ no atk_scale there)
const xScale = moduleOn(chess) ? num(t0.atk_scale, 1) : 1;
const heavyAt = num(t0.value, 3);
const heavy = (e) => !!e && e.side === 'enemy' && e.weight >= heavyAt;
const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3);
/** S3: the enemies of the mark area (last scan) her attacks may still select. */
const areaFoes = (unit, st) => st.inArea.filter((e) => e.alive && e.deployed && !e.hidden && canTargetEnemy(unit, e, unit.profile));
const follow = (st) => { const t = st.target; if (t && t.alive && t.deployed && !t.hidden) { st.x = t.x; st.y = t.y; } };
const scan = (battle, unit, st) => { st.inArea = battle.foesInRadius(st.x, st.y, MARK_RADIUS).filter((e) => canTargetEnemy(unit, e, unit.profile)); };
const rain = (battle, unit) => {
const st = unit.mem.typhonMark;
if (!st) return;
battle.fx('arrowRain', { x: st.x, y: st.y, radius: MARK_RADIUS, id: unit.id });
const scale = num(b3['attack@s3_atk_scale'], 1), stun = num(b3['attack@s3_stun']);
const arrow = () => {
const pool = areaFoes(unit, st);
if (!pool.length) return;
const e = battle.rng.pick(pool);
const dealt = battle.dealDamage(unit, e, { amount: unit.s.atk * scale, type: 'phys', isSkill: true, tags: ['skill', 'typhon:rain'] });
if (dealt > 0 && e.alive && stun > 0) battle.applyStatus(e, 'stun', { duration: stun, source: unit });
};
arrow();
for (let k = 1; k < count(b3['attack@s3_max_hit_num'], 4); k++) battle.after(RAIN_GAP * k, arrow, { owner: unit });
};
return {
skills: {
[S1]: { kind: 'duration', mods: { atkPct: num(b1.atk), aspd: num(b1.attack_speed) } },
[S2]: {
kind: 'toggle',
mods: { atkPct: num(b2.atk) },
targeting: { maxTargets: 2 },
attack: {
onHit({ battle, unit, target }) {
if (target && target.alive && battle.rng.chance(num(b2['attack@prob']))) battle.applyStatus(target, 'stun', { duration: num(b2['attack@stun'], 1), source: unit });
},
},
onStart({ unit, skill }) {
unit.mem.typhonS2Uses = (unit.mem.typhonS2Uses ?? 0) + 1;
skill.timeLeft = unit.mem.typhonS2Uses > 1 ? Infinity : Math.max(0.01, num(b2.first_duration, skill.duration));
},
onTick({ skill, dt }) {
if (!Number.isFinite(skill.timeLeft)) return;
skill.timeLeft -= dt;
if (skill.timeLeft <= 1e-9) skill.end('duration');
},
},
[S3]: {
kind: 'ammo',
ammo: count(b3['attack@s3_trigger_time'], 8),
mods: { batPct: batMod(b3.base_attack_time, chess) },
targeting: { rangeGrid: FIELD },
attack: {
projectile: 'beam',
hitsFn: () => 0,
canAttack: (battle, unit) => !!unit.mem.typhonMark && areaFoes(unit, unit.mem.typhonMark).length > 0,
onHit({ battle, unit }) { rain(battle, unit); },
},
onStart({ battle, unit }) {
const cands = battle.enemiesInKeys(unit.baseRangeKeys ?? unit.rangeKeys, unit, unit.profile);
sortEnemyTargets(battle, unit, cands, 'heaviest');
const t = cands[0] ?? null;
const st = { target: t, x: t ? t.x : unit.x, y: t ? t.y : unit.y, inArea: [], timer: null };
unit.mem.typhonMark = st;
scan(battle, unit, st);
st.timer = battle.every(MARK_SCAN, (b, sc) => {
if (unit.mem.typhonMark !== st) { sc.cancel(); return; }
follow(st);
scan(battle, unit, st);
}, { owner: unit });
battle.fx('mark', { x: st.x, y: st.y, radius: MARK_RADIUS, id: unit.id, target: t ? t.id : null });
},
onTick({ unit }) { const st = unit.mem.typhonMark; if (st) follow(st); },
onEnd({ unit }) {
const st = unit.mem.typhonMark;
unit.mem.typhonMark = null;
if (st && st.timer) st.timer.cancel();
},
},
},
talents: [
{ install(battle, unit) { // 锐如兽牙: +def_penetrate 物理穿透 a stack per attack (max_stack_cnt), gone `duration` s after the last
const per = num(t0.def_penetrate);
if (!(per > 0)) return;
const max = count(t0.max_stack_cnt, 5), dur = num(t0.duration, 8);
battle.on('beforeAttack', (ctx) => {
if (ctx.attacker !== unit || !up(unit)) return;
battle.addBuff(unit, { key: FANG_KEY, duration: dur, refresh: 'stack', maxStacks: max, mods: { defIgnorePct: per }, tags: ['talent'] });
}, { owner: unit });
} },
{ install(battle, unit) { // 重如沼泥: during a skill, the first max_stack_cnt damages on each enemy ×atk_scale + 停顿
const scale = num(t1.atk_scale, 1), slug = num(t1.sluggish), max = count(t1.max_stack_cnt, 1);
const marked = new WeakSet();
battle.on('skillStart', (c) => { if (c.unit === unit) unit.mem.typhonMud = new Map(); }, { owner: unit });
battle.on('skillEnd', (c) => { if (c.unit === unit) unit.mem.typhonMud = null; }, { owner: unit });
battle.on('deploy', (c) => { if (c.unit === unit) unit.mem.typhonMud = null; }, { owner: unit });
onHitBy(battle, unit, ({ target, dmg }) => {
const marks = unit.mem.typhonMud;
if (!marks || !skillBusy(unit)) return;
const n = marks.get(target.id) ?? 0;
if (n >= max) return;
marks.set(target.id, n + 1);
dmg.amount *= scale;
marked.add(dmg);
});
battle.on('damaged', (c) => {
if (c.source !== unit || !marked.has(c.dmg) || !c.target.alive || !(slug > 0)) return;
battle.applyStatus(c.target, 'sluggish', { duration: slug, source: unit });
}, { owner: unit });
} },
],
// 攻城手: heaviest first; SIE-X ×atk_scale on her attacks against heavy enemies
trait: { priority: 'heaviest', ...(xScale !== 1 ? { dmgMul: (b, u, target) => (heavy(target) ? xScale : 1) } : null) },
install(battle, unit) {
// S2: two arrows per attack — both at the one enemy when it is the only target
if (unit.skill?.id === S2) {
battle.on('beforeAttack', (ctx) => {
if (ctx.attacker !== unit || !unit.skill.active || ctx.targets.length !== 1) return;
ctx.targets = [ctx.targets[0], ctx.targets[0]];
}, { owner: unit });
}
// S3: the round is aimed at the marked enemy (else another one of the area): its 'atk' line points there
if (unit.skill?.id === S3) {
battle.on('beforeAttack', (ctx) => {
const st = unit.mem.typhonMark;
if (ctx.attacker !== unit || !unit.skill.active || !st) return;
const pool = areaFoes(unit, st);
const t = pool.includes(st.target) ? st.target : pool[0];
if (t) ctx.targets = [t];
}, { owner: unit });
}
// S2 counts its casts per deployment
battle.on('deploy', (c) => { if (c.unit === unit) unit.mem.typhonS2Uses = 0; }, { owner: unit });
// SIE-Y: the farther the target, the more damage (attacks only)
const ds = num(tb.damage_scale), lo = num(tb.min_dist, 1), hi = num(tb.max_dist, 4.5);
if (ds > 0) {
onHitBy(battle, unit, ({ target, dmg }) => {
if (!dmg.isAttack) return;
const d = Math.hypot(target.x - unit.x, target.y - unit.y);
dmg.mul *= 1 + ds * Math.max(0, Math.min(1, (d - lo) / Math.max(1e-6, hi - lo)));
});
}
},
};
},
};
+239
View File
@@ -0,0 +1,239 @@
// server/sim/content/kits/ops/op-veen.js — 维伊 (char_4226_veen) 自选 operator kit: 6★ 秘术师 (术师), an owned-6★ pick of the
// tier-5 and tier-6 自选 slots; every skill, both talents, the trait and the module (MSC-Y) at every form.
// Kit contract and the 自选 rules: ../README.md ("How to add an operator (自选)").
//
// Forms (data/backups.json units.char_4226_veen, the DIY slot statuses): normal = E2 Lv1, skills at rank 4, no module;
// elite = E2 Lv60, rank 7, MSC-Y at stage 1 (tier 5) or 3 (tier 6). Potential 0 [ASSUMED: no account].
// Sources: character_table / skill_table / battle_equip_table (zh_CN, as built into backups.json) and PRTS 维伊 (the 备注
// quoted below).
// - Trait (秘术师) "攻击造成法术伤害,在找不到攻击目标时可以将攻击能量储存起来之后一齐发射" (ranged arts, hits air units,
// targetable by ground enemies). PRTS 分支特性信息 秘术师: "能量储存与攻击占用相同的攻击间隔:在进行攻击判定时,若范围内存在
// 有效目标,则进行普通攻击;若无有效目标且能量储存数未满,则改为储存一份攻击能量(属于攻击行为)" — when her attack is ready
// and she has no valid target she stores one energy and her attack interval starts again (this kit replaces the
// engine's mystic profile, which stores one interval after the attack is ready); the energies fly with her next attack,
// at its target ("由储存能量形成的弹道造成攻击力100%的法术普通伤害").
// T1 “在挥刀之前” "特性储存的能量达到3个时,合成为1个转置能量(至多储存等同于9倍的能量)" (trait bb merge_cnt 3, times 9):
// 3 stored make 1 转置能量, at most times / merge_cnt of those, then no more storing (备注 "转置能量数量达到上限时,停止储存
// 攻击能量"); a 转置能量 hits for merge_cnt × ATK (备注 "攻击力300%的法术普通伤害"). Every shot uses the ATK it left with
// (备注 "所有攻击、储存能量、转置能量的弹道均使用脱手时的缓存攻击力"), released 转置能量 → main attack → stored energies (S2
// 备注 "弹道创建顺序"); one volley flies to one target and lands at once, so the kit resolves all its hits as the main bolt
// lands (the engine's own hit makes none: `hitsFn` 0), with that attack's attackId, the later ones only on a target still
// alive. [ASSUMED] a redeployment holds no energy.
// - T1 “在挥刀之前” "拥有转置能量时攻击力+10%,未拥有转置能量时攻击速度+15" (bb atk / attack_speed): two toggles on her
// stored state (checked every tick — the 备注's "攻击力加成失效存在短暂延迟": the volley that spends the 转置能量 keeps it).
// - T2 战争技艺 "攻击和储存的能量使目标在5秒内每秒受到90点法术伤害(至多叠加3次,发射转置能量可以叠加3次),维伊不以该效果影响的
// 敌人为攻击目标" (bb attack@duration / attack@value / attack@max_stack_cnt; MSC-Y stage 3: 120 / 4): each hit adds 1
// stack (a 转置能量 3, an S3 bounce 1 — S3 备注 "相当于一发储存能量"), each stack attack@value arts per second, the 5 s
// restarting with every stack [ASSUMED]; she never picks a marked enemy (canAttack / beforeAttack, as 深靛 skips bound
// enemies — with only marked enemies in range she stores energy).
// - MSC-Y “军械库” "拥有已储存的攻击能量时,攻击速度+30" (hidden module talent attack_speed): while she holds any energy.
// - S1 “自呼号生发” (AUTO, 18 s): ASPD +attack_speed — an AUTO skill acting on herself fires at full SP (the owner's rule,
// kits/README.md checklist 5: `trigger: 'SP_FULL'`).
// - S2 “以鲜血洗去” (MANUAL, 28 s, data DEFAULT): attack interval base_attack_time (a flat −0.5 s on 3.0 s); every released
// main attack or stored energy adds a stack (a 转置能量 3), at most …max_stack_cnt: ATK +…atk and ASPD +…attack_speed each,
// until the skill ends; a shot leaving later in the same volley already has the stacks of those before it (备注).
// - S3 “用赤铁铭记” (MANUAL, 21 / 18 bullets, data DEFAULT): ASPD +attack_speed; 备注 "技能期间发射的储存能量与转置能量不会
// 产生伤害,维伊仅在每次攻击时根据当前储存能量与转置能量消耗相应数量的弹药(不考虑是否足够),并令本次攻击获得相应弹射次数":
// an attack spends 1 bullet per stored energy and merge_cnt per 转置能量 (none without) and its main hit
// (attack@base_atk_scale × ATK) bounces that many times, every 0.5 s within 1.7 tiles (备注 "弹射间隔0.5,弹射半径1.7"),
// preferring an enemy not hit yet by it, else another one, else the same (text "优先不同目标" [ASSUMED order]), each
// attack@bounce_atk_scale × the cached ATK arts. The skill ends when the bullets run out.
import { num, talentBb, moduleBb, traitBb, skillRec, batMod, up, toggleBuff } from '../shared/tier1.js';
import { canTargetEnemy, sortEnemyTargets } from '../../../targeting.js';
const S1 = 'skchr_veen_1';
const S2 = 'skchr_veen_2';
const S3 = 'skchr_veen_3';
const ANY = Object.freeze({ canHitFly: true });
/** 用赤铁铭记 备注 "弹射间隔0.5,弹射半径1.7". */
const BOUNCE_IV = 0.5;
const BOUNCE_R = 1.7;
const S2_STACK = 'veen:s2stack';
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
const warKey = (unit) => `veen:war:${unit.id}`;
const marked = (unit, e) => !!e.findBuff(warKey(unit));
const skillOn = (unit, id) => !!(unit.skill && unit.skill.active && unit.skill.id === id);
/**
* Her stored energies (per deployment): `s` stored, `t` 转置能量, `q` the volleys in flight per target, `use` the S3 bullets of
* the attack just made, `k` the S2 stacks.
*/
const stateOf = (unit) => unit.trait.veen ?? (unit.trait.veen = { s: 0, t: 0, q: new Map(), use: 0, k: 0 });
/** Enemies she may pick: in her range (or blocked by her), none under her 战争技艺. */
function validTargets(battle, unit, prof) {
const cands = battle.enemiesInKeys(unit.rangeKeys, unit, prof);
for (const e of battle.blockedTargets(unit, prof)) if (!cands.includes(e)) cands.push(e);
return cands.filter((e) => !marked(unit, e));
}
export default {
char_4226_veen: (bb, chess) => {
const t0 = talentBb(chess, 0);
const t1 = talentBb(chess, 1);
const tb = traitBb(chess);
const hidden = moduleBb(chess); // MSC-Y: attack_speed
const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3);
const merge = Math.max(1, Math.floor(num(tb.merge_cnt, 3)));
const tMax = Math.max(1, Math.floor(num(tb.times, 9) / merge));
const war = { dur: num(t1['attack@duration']), value: num(t1['attack@value']), max: Math.max(1, Math.floor(num(t1['attack@max_stack_cnt'], 1))) };
const s2 = { atk: num(b2['attack@veen_s_2_buff[stack].atk']), aspd: num(b2['attack@veen_s_2_buff[stack].attack_speed']), max: Math.max(0, Math.floor(num(b2['attack@veen_s_2_buff[stack].max_stack_cnt']))) };
/** 战争技艺: `n` stacks on `e`. */
function warOn(battle, unit, e, n) {
if (!e || !e.alive || !(war.value > 0) || !(war.dur > 0) || !(n > 0)) return;
battle.addBuff(e, {
key: warKey(unit), duration: war.dur, refresh: 'stack', stacks: Math.min(n, war.max), maxStacks: war.max, source: unit,
interval: 1, data: { value: war.value },
onTick: ({ battle: b, unit: t, buff }) => { b.dealDamage(unit, t, { amount: buff.data.value * buff.stacks, type: 'arts', canDodge: false, tags: ['talent', 'dot', 'veenWar'] }); },
});
}
/** S2: `n` more stacks (a released shot). */
function s2Stack(battle, unit, n) {
if (!skillOn(unit, S2) || !(s2.max > 0)) return;
const v = stateOf(unit);
v.k = Math.min(s2.max, v.k + n);
battle.addBuff(unit, { key: S2_STACK, mods: { atkPct: s2.atk * v.k, aspd: s2.aspd * v.k }, tags: ['skill'] });
}
/** One hit of the volley: `atk` (cached at release) × `scale` arts, then `stacks` of 战争技艺. */
function hitOnce(battle, unit, t, atk, scale, stacks, attackId, isSkill) {
battle.dealDamage(unit, t, { amount: atk * scale * unit.s.atkScaleMul, type: 'arts', isAttack: true, isSkill, attackId, tags: ['veen'] });
warOn(battle, unit, t, stacks);
}
/** S3: `n` bounces from `from`, every BOUNCE_IV s, each bounce × the cached `atk`. */
function bounces(battle, unit, from, n, atk, attackId) {
const hit = new Set([from]);
let cur = from;
const step = (left) => {
if (left <= 0) return;
battle.after(BOUNCE_IV, () => {
const near = battle.enemiesInRadius(cur.x, cur.y, BOUNCE_R).filter((e) => canTargetEnemy(unit, e, ANY));
let pool = near.filter((e) => !hit.has(e));
if (!pool.length) pool = near.filter((e) => e !== cur);
if (!pool.length) pool = near;
let next = null, bd = Infinity;
for (const e of pool) {
const d = Math.hypot(e.x - cur.x, e.y - cur.y);
if (d < bd - 1e-9 || (Math.abs(d - bd) <= 1e-9 && next && e.spawnSeq < next.spawnSeq)) { bd = d; next = e; }
}
if (!next) return; // nobody within reach: the bounces left are lost
battle.fx('veenBounce', { x: cur.x, y: cur.y, id: unit.id, target: next.id });
hit.add(next);
cur = next;
battle.dealDamage(unit, next, { amount: atk * num(b3['attack@bounce_atk_scale'], 1) * unit.s.atkScaleMul, type: 'arts', isAttack: true, attackId, tags: ['veen', 'veenBounce'] });
warOn(battle, unit, next, 1);
step(left - 1);
});
};
step(n);
}
/** The main bolt of an attack lands on `victim`: every hit of its volley (released in order), then the S3 bounces. */
function land(battle, unit, victim, attackId, isSkill) {
const v = stateOf(unit);
const list = v.q.get(victim.id);
const rec = list ? list.shift() : null;
if (list && !list.length) v.q.delete(victim.id);
if (!rec) { hitOnce(battle, unit, victim, unit.s.atk, 1, 1, attackId, isSkill); return; }
for (const p of rec.pre) { if (!victim.alive) return; hitOnce(battle, unit, victim, p.atk, p.scale, p.stacks, attackId, isSkill); }
if (!victim.alive) return;
hitOnce(battle, unit, victim, rec.main.atk, rec.main.scale, 1, attackId, isSkill);
if (rec.main.bounces > 0) bounces(battle, unit, victim, rec.main.bounces, rec.main.atk, attackId);
for (const p of rec.post) { if (!victim.alive) return; hitOnce(battle, unit, victim, p.atk, p.scale, p.stacks, attackId, isSkill); }
}
return {
trait: {
hitsFn: () => 0, // the volley's hits are the kit's (land)
onEachHit(battle, unit, victim, hc) { if (hc.kind === 'main' && victim && victim.side === 'enemy') land(battle, unit, victim, hc.attackId, !!hc.isSkill); },
// only enemies under her 战争技艺 in range: she holds her fire (and stores energy)
canAttack(battle, u) {
const ok = validTargets(battle, u, u.profile).length > 0;
if (!ok) u.trait.hadTarget = false;
return ok;
},
install(battle, unit) {
battle.on('deploy', (ctx) => { if (ctx.unit === unit) unit.trait.veen = null; }, { owner: unit });
// the mystic store: her attack ready and no valid target ⇒ one energy, an attack action (the interval restarts);
// 3 merge into a 转置能量 (the attack loop ran first this tick: a valid target was attacked instead)
battle.on('tick', () => {
const v = stateOf(unit);
if (!up(unit) || !unit.canAct || unit.s.flags.disarm || unit.atkCd > 0 || v.t >= tMax) return;
if (validTargets(battle, unit, unit.profile).length) return;
if (++v.s >= merge) { v.s -= merge; v.t++; }
unit.atkCd = unit.s.interval;
}, { owner: unit });
battle.on('death', (ctx) => { if (ctx.unit && ctx.unit.side === 'enemy') stateOf(unit).q.delete(ctx.unit.id); }, { owner: unit });
},
},
skills: {
[S1]: { kind: 'duration', trigger: { rule: 'SP_FULL' }, mods: { aspd: num(b1.attack_speed) } },
[S2]: {
kind: 'duration',
mods: { batPct: batMod(b2.base_attack_time, chess) },
onStart({ battle, unit }) { stateOf(unit).k = 0; battle.removeBuff(unit, S2_STACK); },
onEnd({ battle, unit }) { stateOf(unit).k = 0; battle.removeBuff(unit, S2_STACK); },
},
[S3]: {
kind: 'ammo',
ammo: num(b3['attack@trigger_time'], 1),
mods: { aspd: num(b3.attack_speed) },
// bullets: 1 per stored energy, merge_cnt per 转置能量 the attack released (none without)
onAttack(ctx) {
ctx.noAmmo = true;
const { battle, unit, skill } = ctx;
const v = stateOf(unit);
const n = v.use;
v.use = 0;
for (let i = 0; i < n && skill.active; i++) {
skill.ammoLeft--;
battle.emit('ammoUsed', { unit, left: skill.ammoLeft, skill });
if (skill.ammoLeft <= 0) skill.end('ammo');
}
},
},
},
talents: [
{ install(battle, unit) { // “在挥刀之前”: ATK while holding a 转置能量, ASPD while holding none
const atk = num(t0.atk), aspd = num(t0.attack_speed);
if (atk) toggleBuff(battle, unit, 'talent:veen:merged', () => stateOf(unit).t > 0, { atkPct: atk });
if (aspd) toggleBuff(battle, unit, 'talent:veen:unmerged', () => stateOf(unit).t === 0, { aspd });
} },
{ install(battle, unit) { // 战争技艺: the stacks come with every hit (warOn); she never picks a marked enemy
battle.on('beforeAttack', (ctx) => {
if (ctx.attacker !== unit || ctx.targets.every((e) => !marked(unit, e))) return;
const prof = ctx.profile || unit.profile;
const cands = validTargets(battle, unit, prof);
sortEnemyTargets(battle, unit, cands, prof.priority);
ctx.targets = cands.slice(0, Math.max(1, ctx.targets.length));
}, { owner: unit });
} },
],
install(battle, unit) {
// MSC-Y: ASPD while she holds stored energy
const msc = num(hidden.attack_speed);
if (msc) toggleBuff(battle, unit, 'trait:veen:stored', () => { const v = stateOf(unit); return v.s + v.t > 0; }, { aspd: msc });
// the volley leaves with the attack: 转置能量 → main → stored, each with the ATK of its release (S2 stacks between)
battle.on('attack', (ctx) => {
if (ctx.attacker !== unit || !ctx.targets.length) return;
const v = stateOf(unit);
const t = v.t, s = v.s;
v.t = 0; v.s = 0;
const s3 = skillOn(unit, S3);
const pre = [], post = [];
for (let i = 0; i < t; i++) { if (!s3) pre.push({ atk: unit.s.atk, scale: merge, stacks: merge }); s2Stack(battle, unit, merge); }
const main = { atk: unit.s.atk, scale: s3 ? num(b3['attack@base_atk_scale'], 1) : 1, bounces: s3 ? s + merge * t : 0 };
s2Stack(battle, unit, 1);
for (let i = 0; i < s; i++) { if (!s3) post.push({ atk: unit.s.atk, scale: 1, stacks: 1 }); s2Stack(battle, unit, 1); }
v.use = s3 ? s + merge * t : 0;
ctx.targets.forEach((tg, i) => {
const list = v.q.get(tg.id) ?? [];
list.push(i === 0 ? { pre, main, post } : { pre: [], main: { atk: main.atk, scale: main.scale, bounces: 0 }, post: [] });
v.q.set(tg.id, list);
});
if (t || s) battle.fx('veenVolley', { x: unit.x, y: unit.y, id: unit.id, target: ctx.targets[0].id, stored: s, merged: t });
}, { owner: unit });
},
};
},
};
+234
View File
@@ -0,0 +1,234 @@
// server/sim/content/kits/ops/op-zuole.js — 左乐 (char_4121_zuole) 自选 operator kit: 6★ 武者 (近卫), an owned-6★ pick of
// the tier-5 and tier-6 自选 slots; every skill, both talents, the trait and both modules at every form.
// Kit contract and the 自选 rules: ../README.md ("How to add an operator (自选)").
//
// Forms (data/backups.json units.char_4121_zuole, the DIY slot statuses): normal = E2 Lv1, skills at rank 4, no module;
// elite = E2 Lv60, rank 7, the picked module at stage 1 (tier 5) or 3 (tier 6). Potential 0 [ASSUMED: no account].
// Sources: character_table / skill_table / battle_equip_table (zh_CN, as built into backups.json); PRTS 左乐 (S2 / S3
// 备注); the client's buff templates (buff_template_data `zuole_trait`, `zuole_t_2`, `zuole_s_2[shield]` /
// `[shield_core]`, `zuole_s_3` / `[shield]`, `zuole_e_002[resistance]`, `zuole_e_003_talent`, `zuole_e_003_trait`);
// Arknights Terra Wiki "Zuo Le" (the barrier caps count each skill's own barrier only).
// - Trait (武者) "不成为其他角色的治疗目标,每次攻击到敌人后回复自身70生命": the profession default (musha: no heal from
// others, `value` per enemy hit; zuole_trait heals on every damage he outputs — the profession heals a normal attack
// per target and any other damage per instance, so S1's extra strikes are separate damage instances).
// - T1 秉烛照影 "在场时,自身获得最高+50攻击速度和技力自然回复速度+2/秒的坚忍(损失70%生命值时达到最大加成)" (bb
// min_attack_speed / min_sp_recovery_per_sec / min_hp_ratio): 坚忍 (ba.berserk "根据已损失的生命值获得相应比例的属性
// 加成") — ASPD and SP recovery × min(1, lost HP ÷ (1 − min_hp_ratio)) while he is deployed. SBL-X stage 3: +70 /
// +2.3 / at 50 % lost (the module talent's bb).
// - T2 守正自明 "攻击时有20%的概率获得1点技力,生命低于50%时概率变为70%" (bb prob_1 / prob_2 / hp_ratio / sp): zuole_t_2
// rolls on every damage he outputs (ON_OUTPUT_DAMAGE — skill damage too), prob_2 below hp_ratio. SBL-Y stage 3
// (zuole_e_003_talent, ON_CALCULATE_DAMAGE): "…且当次攻击的攻击力提升至120%…80%": a success also scales that damage
// instance by atk_scale.
// - Module SBL-X “岂苦夜长” (trait moduleDesc "生命值低于50%时,获得25%的庇护"; the hidden part merged into the first talent's
// bb: hp_ratio / damage_resistance): 庇护 (ba.protect "受到的物理和法术伤害降低相应比例") while HP < hp_ratio
// (zuole_e_002[resistance]: the 庇护 buff is created when HP drops below it and finished when it is back). PRTS S2 备注:
// 行险 resets it, it comes back 0.1 s later "并因此导致庇护的BUFF顺序落后于本技能的屏障" — a 庇护 created after his
// barrier cuts only what passes the barrier (the `hpDamage` hook), one created before it cuts the hit first.
// - Module SBL-Y “秉烛人的记录” (trait bb hp_ratio): "被击倒时不撤退且回复30%生命(单次部署只触发1次)" — the first lethal
// hit of a deployment is prevented and he heals hp_ratio × max HP (zuole_e_003_trait[heal] HealViaMaxHpRatio, 无视禁疗);
// not while a 不死 already holds him ("_dontConsumeWhenUndeadable": 坚固维式重锤's window, items/battle.js holdsUndying).
// - S1 破虏 (AUTO, 可充能 2 次): the next attack at atk_scale × ATK; "自身生命低于80%时额外攻击1次,低于50%时额外攻击2次"
// (hp_ratio_double / hp_ratio_tripple): one / two more strikes on that target at the same scale, separate damage
// instances [ASSUMED: the same target — nothing when it fell]. The data's DEFAULT trigger (an AUTO "next attack").
// - S2 行险 (MANUAL, 12 s): "立即流失50%当前生命" (zuole_s_2[shield] DamageViaCurHpRatio, undeadable: never below 1 HP;
// a 流失), then a barrier of scale × max HP (ba.barrier "可以吸收一定数值的伤害") added to the one he still has, capped at
// max_scale × max HP at each gain (PRTS "上限于每次获取屏障时实时发生变化"; Terra: the cap counts this skill's barrier
// only); ATK +atk, block +block_cnt, every blocked enemy struck at once. Once the skill is over the barrier loses
// −shield_decrease every second (PRTS "技能结束时屏障以每秒-200点的速度流失"; zuole_s_2[shield_core] ON_BUFF_TRIGGER).
// - S3 佑序有炎 (MANUAL, data SKILL_RANGE on its 3-2): 7 slashes ("立刻…": all in the cast instant [ASSUMED: the kits'
// instant convention — his skeleton's 2 s Skill_3 clip spreads its 7 OnAttack events over 0.2 … 1.2 s, and the engine
// models no animation lock]), each on at most 3 enemies of the skill range (the 3 is the text's: no
// blackboard key; air units too [ASSUMED: no 对空 note — docs/SIM.md §8]) for atk_scale × ATK physical, the last
// ×last_atk_bonus and 晕眩 `stun` s (zuole_s_3: AtkScaleUp once spell_times reaches times). Meanwhile the trait heals
// nothing (zuole_trait "CheckContainsBuff zuole_s_3 → IfNot → heal") and every damage instance he outputs adds
// value × shield_scale to a barrier (zuole_s_3 ON_OUTPUT_DAMAGE; PRTS "屏障获取不经过治疗流程,不受治疗倍率等影响"), capped
// at max_scale × max HP at each gain, lasting shield_duration s from the latest gain (PRTS "每次获取屏障时重置剩余持续
// 时间为15s").
import { num, talentBb, traitBb, up, onHitBy, onHitOn, giveSp, skillRec } from '../shared/tier1.js';
import { absoluteRangeKeys, sortEnemyTargets } from '../../../targeting.js';
import { isHpLoss } from '../../../damage.js';
import { holdsUndying } from '../../items/battle.js';
const S1 = 'skchr_zuole_1';
const S2 = 'skchr_zuole_2';
const S3 = 'skchr_zuole_3';
/** S3 "每次对最多3名敌人" — in the text only (no blackboard key). */
const S3_TARGETS = 3;
/** PRTS S2 备注 "庇护BUFF于技能开启0.1s后重新判定并生效". */
const PROTECT_RESET = 0.1;
/** zuole_s_2[shield_core] decays once per trigger: "每秒-200点" (PRTS S2 备注). */
const S2_DECAY_EVERY = 1;
const S2_BARRIER = 'skill:zuole:barrier2';
const S3_BARRIER = 'skill:zuole:barrier3';
const BERSERK = 'talent:zuole:berserk';
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
/** The barrier of his skill he holds (one skill per pick), or null. */
const barrierOf = (u) => u.findBuff(S2_BARRIER) || u.findBuff(S3_BARRIER);
export default {
char_4121_zuole: (bb, chess) => {
const tb = traitBb(chess);
const t0 = talentBb(chess, 0), t1 = talentBb(chess, 1);
const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3);
const s1 = skillRec(chess, S1), s3 = skillRec(chess, S3);
const healValue = num(tb.value, 70);
// SBL-X 庇护 (merged into the first talent's bb) — its state is shared with S2's reset
const protectRatio = num(t0.hp_ratio), protectCut = t0.hp_ratio != null ? num(t0.damage_resistance) : 0;
const P = { since: null, blockedUntil: -Infinity };
const s2Decay = ({ battle, unit, buff }) => {
const sk = unit.skill;
if (sk && sk.active && sk.id === S2) return;
buff.shield = Math.max(0, buff.shield + num(b2.shield_decrease, -200));
unit.markDirty();
if (!(buff.shield > 0)) battle.removeBuff(unit, buff);
};
const gainS3Barrier = (battle, unit) => {
const cap = unit.s.maxHp * num(b3.max_scale, 2);
const cur = unit.findBuff(S3_BARRIER);
const val = Math.min(cap, (cur ? cur.shield : 0) + healValue * num(b3.shield_scale));
if (!(val > 0)) return;
battle.addBuff(unit, { key: S3_BARRIER, shield: val, duration: num(b3.shield_duration, 15), refresh: 'replace', visible: true,
tags: ['skill'], data: { createdAt: cur?.data?.createdAt ?? battle.time } });
};
return {
skills: {
[S1]: {
kind: num(s1?.maxChargeTime, 1) > 1 ? 'charges' : 'instant',
attack: {
atkScale: num(b1.atk_scale, 1),
onHit({ battle, unit, target }) {
const r = unit.hpRatio;
const extra = (r < num(b1.hp_ratio_double, 0.8) ? 1 : 0) + (r < num(b1.hp_ratio_tripple, 0.5) ? 1 : 0);
for (let i = 0; i < extra && target && target.alive; i++) {
battle.dealDamage(unit, target, { amount: unit.s.atk * num(b1.atk_scale, 1), type: 'phys', isSkill: true, tags: ['skill'] });
}
},
},
},
[S2]: {
kind: 'duration',
mods: { atkPct: num(b2.atk), blockCnt: num(b2.block_cnt) },
attack: { hitAllBlocked: true },
onStart({ battle, unit }) {
const loss = Math.min(unit.hp * num(b2.hp_ratio), unit.hp - 1);
if (loss > 0) battle.loseHp(unit, loss, { source: unit });
const cap = unit.s.maxHp * num(b2.max_scale, 2);
const add = Math.min(cap, unit.s.maxHp * num(b2.scale));
const cur = unit.findBuff(S2_BARRIER);
if (cur) { cur.shield = Math.min(cap, cur.shield + add); unit.markDirty(); }
else if (add > 0) {
battle.addBuff(unit, { key: S2_BARRIER, shield: add, interval: S2_DECAY_EVERY, onTick: s2Decay, visible: true, tags: ['skill'],
data: { createdAt: battle.time } });
}
battle.fx('shield', { x: unit.x, y: unit.y, id: unit.id });
// SBL-X: 行险 resets the 庇护, which is judged again PROTECT_RESET s later (after the barrier)
P.since = null;
P.blockedUntil = battle.time + PROTECT_RESET;
},
},
[S3]: {
kind: 'instant',
onStart({ battle, unit }) {
const grid = s3?.rangeGrid?.length ? s3.rangeGrid : [[0, 0], [0, 1], [0, 2], [0, 3]];
const keys = absoluteRangeKeys(grid, unit.tileR, unit.tileC, unit.dir, 0);
const times = Math.max(1, Math.floor(num(b3.times, 7)));
battle.fx('aoe', { x: unit.x + 1.5 * unit.fwd.x, y: unit.y + 1.5 * unit.fwd.y, radius: 1, id: unit.id, skill: 'zuole:slash' });
unit.mem.zuoleS3 = true;
try {
for (let i = 1; i <= times && up(unit); i++) {
const last = i === times;
const foes = battle.enemiesInKeys(keys, unit, { canHitFly: true });
sortEnemyTargets(battle, unit, foes, null);
const amount = unit.s.atk * num(b3.atk_scale, 1) * (last ? num(b3.last_atk_bonus, 1) : 1);
for (const e of foes.slice(0, S3_TARGETS)) {
if (!e.alive) continue;
battle.dealDamage(unit, e, { amount, type: 'phys', isSkill: true, tags: ['skill'] });
if (last && e.alive) battle.applyStatus(e, 'stun', { duration: num(b3.stun), source: unit });
}
}
} finally {
unit.mem.zuoleS3 = false;
}
},
},
},
talents: [
{ install(battle, unit) { // 秉烛照影 (坚忍)
const maxAs = num(t0.min_attack_speed), maxSp = num(t0.min_sp_recovery_per_sec), full = num(t0.min_hp_ratio, 0.3);
if (!(maxAs > 0 || maxSp > 0) || !(full < 1)) return;
let last = -1;
const update = () => {
if (!up(unit)) { last = -1; return; }
const f = Math.max(0, Math.min(1, (1 - unit.hpRatio) / (1 - full)));
if (Math.abs(f - last) < 1e-9) return;
last = f;
const cur = unit.findBuff(BERSERK);
if (!(f > 0)) { if (cur) battle.removeBuff(unit, cur); return; }
const mods = { aspd: maxAs * f, spRecoveryFlat: maxSp * f };
if (cur) { cur.mods = mods; unit.markDirty(); } else battle.addBuff(unit, { key: BERSERK, mods, tags: ['talent'] });
};
battle.on('deploy', ({ unit: u }) => { if (u === unit) { last = -1; update(); } }, { owner: unit });
battle.on('damaged', (c) => { if (c.target === unit) update(); }, { owner: unit });
battle.on('tick', update, { owner: unit });
} },
{ install(battle, unit) { // 守正自明 (+ SBL-Y stage 3: ×atk_scale on a success)
const p1 = num(t1.prob_1), p2 = num(t1.prob_2), thr = num(t1.hp_ratio, 0.5), sp = num(t1.sp), scale = num(t1.atk_scale, 1);
if (!(sp > 0)) return;
const roll = () => battle.rng.chance(unit.hpRatio < thr ? p2 : p1);
if (scale !== 1) {
onHitBy(battle, unit, ({ dmg }) => { if (dmg.type !== 'element' && roll()) { giveSp(unit, sp); dmg.amount *= scale; } });
} else {
battle.on('damaged', (c) => {
if (c.source !== unit || !c.target || c.target.side !== 'enemy' || isHpLoss(c.dmg) || c.type === 'element') return;
if (roll()) giveSp(unit, sp);
}, { owner: unit });
}
} },
],
install(battle, unit) {
unit.mem.zuoleProtect = P;
// S3: the trait's heal turns into barrier while the slashes run (every damage instance he outputs)
battle.on('heal', (c) => {
if (unit.mem.zuoleS3 && c.source === unit && c.target === unit && c.opts && c.opts.self && !c.opts.tags) c.amount = 0;
}, { owner: unit });
battle.on('damaged', (c) => {
if (!unit.mem.zuoleS3 || c.source !== unit || !c.target || c.target.side !== 'enemy' || isHpLoss(c.dmg) || c.type === 'element') return;
gainS3Barrier(battle, unit);
}, { owner: unit });
// SBL-X 庇护: active while HP < protectRatio, judged again PROTECT_RESET s after 行险
if (protectCut > 0) {
const active = () => {
const now = battle.time;
if (!up(unit) || now < P.blockedUntil - 1e-9) { P.since = null; return false; }
if (unit.hpRatio < protectRatio) { if (P.since == null) P.since = now; return true; }
P.since = null;
return false;
};
const afterBarrier = new WeakSet();
battle.on('tick', active, { owner: unit });
onHitOn(battle, unit, ({ dmg }) => {
if ((dmg.type !== 'phys' && dmg.type !== 'arts') || !active()) return;
const bar = barrierOf(unit);
if (bar && bar.shield > 0 && (bar.data?.createdAt ?? Infinity) < P.since) afterBarrier.add(dmg);
else dmg.mul *= 1 - protectCut;
});
battle.on('hpDamage', (c) => { if (c.target === unit && afterBarrier.has(c.dmg)) c.amount *= 1 - protectCut; }, { owner: unit });
}
// SBL-Y: 被击倒时不撤退且回复30%生命(单次部署只触发1次)
const revive = num(tb.hp_ratio);
if (revive > 0) {
let usedSeq = -1;
battle.on('fatal', (c) => {
if (c.unit !== unit || c.prevented || usedSeq === unit.deploySeq || holdsUndying(battle, unit)) return;
c.prevented = true;
usedSeq = unit.deploySeq;
battle.heal(unit, unit, unit.s.maxHp * revive, { self: true, ignoreHealFree: true, tags: ['trait'] });
battle.fx('revive', { x: unit.x, y: unit.y, id: unit.id });
}, { owner: unit, priority: -60 });
}
},
};
},
};
+21 -8
View File
@@ -5,8 +5,10 @@
// → dodge (phys/arts, canDodge) → mitigation (phys: DEF, arts: RES, true: none)
// → × source dmgDealtMul (× phys/artsDealtMul) × target dmgTakenMul (not for 元素伤害) × type-taken mul × dmg.mul
// → 限伤 (leaders in boss / hidden battles: a hit of ceil(final) ≥ BOSS_HIT_LIMIT is cancelled, see leaderHitCancelled)
// → shields (hit-negating barriers first, then HP shields) → HP loss (boss pool routing) → 'damaged' hook
// → SP-on-hurt / TAKE_DAMAGE trigger → fatal/kill.
// → shields (hit-negating barriers first, then HP shields; a typed one — buff `shieldType` — only its damage type)
// → 'hpDamage' hook (what passed the shields; handlers may only lower `amount`: the 伤判效果 that act after a
// barrier — 煌's 紧急除颤 HP floor, 左乐's 庇护 re-applied after his 行险 barrier) → HP loss (boss pool routing)
// → 'damaged' hook → SP-on-hurt / TAKE_DAMAGE trigger → fatal/kill.
// Damage-dealt stats (the source's `stats.dmg`, the player's `damageDealt`) count only HP removed from the other side:
// self and friendly damage (a 源石溶剂 drain, an operator's own 流失) is the target's `taken` and keeps the kill credit.
// Phys: max(A − max(0, D×(1−defIgnorePct) − defIgnoreFlat), 5 %·A); Arts: max(A×(1 − R′/100), 5 %·A) with
@@ -180,14 +182,19 @@ export function leaderHitCancelled(battle, target, amount) {
return true;
}
/** Absorb damage with shields on `target`. Returns the remaining amount. */
export function absorbShields(battle, target, amount) {
/**
* Absorb damage with shields on `target`. Returns the remaining amount. `type` = the damage type: a shield buff with a
* `shieldType` absorbs only that type (夜莺 S2 "屏障能吸收…法术伤害"); one without absorbs every type (PRTS 术语释义 屏障
* "若无特殊说明,屏障可吸收全种类伤害"). Older shields first (buff order: "优先消耗先生成的屏障").
*/
export function absorbShields(battle, target, amount, type = null) {
if (amount <= 0) return 0;
let changed = false;
let rest = amount;
const absorbs = (b) => !b.shieldType || b.shieldType === type;
for (let i = 0; i < target.buffs.length && rest > 0; i++) {
const b = target.buffs[i];
if (b.shieldHits > 0) {
if (b.shieldHits > 0 && absorbs(b)) {
b.shieldHits--;
rest = 0;
if (b.shieldHits <= 0 && !(b.shield > 0) && !b.mods && !b.flags) { battle._removeBuffAt(target, i); i--; }
@@ -197,7 +204,7 @@ export function absorbShields(battle, target, amount) {
}
for (let i = 0; i < target.buffs.length && rest > 0; i++) {
const b = target.buffs[i];
if (b.shield > 0) {
if (b.shield > 0 && absorbs(b)) {
const take = Math.min(b.shield, rest);
b.shield -= take;
rest -= take;
@@ -247,7 +254,7 @@ export function dealDamage(battle, source, target, dmgIn) {
// recognise their own (tagged) damage — never re-create such a loss with a fresh loseHp.
if (ts.flags.hitCount || ts.flags.hitCountArts) {
const counts = !(ts.flags.hitCountArts && !ts.flags.hitCount && type === 'phys');
return applyHpLoss(battle, source, target, absorbShields(battle, target, counts ? 1 : 0), dmg);
return applyHpLoss(battle, source, target, absorbShields(battle, target, counts ? 1 : 0, type), dmg);
}
// 无来源 damage (element bursts) takes nothing from its source's stats; the source still gets the credit below
const ss = source && source.s && !dmg.sourceless ? source.s : null;
@@ -267,7 +274,13 @@ export function dealDamage(battle, source, target, dmgIn) {
// 限伤: a leader's hit of ≥ BOSS_HIT_LIMIT in a boss / hidden battle is cancelled before it reaches shields / HP — what
// ran before it (the attack, its SP, `hit` hook effects, separate element 损伤) stays; nothing after it happens
if (final > 0 && leaderHitCancelled(battle, target, final)) return 0;
final = absorbShields(battle, target, final);
final = absorbShields(battle, target, final, type);
// 伤判效果 after the barriers (header): a handler may lower what reaches the HP — never raise it (a 流失 skips this)
if (final > 0 && battle._hooks.hpDamage) {
const hctx = { source: hs, target, amount: final, dmg, credit: source };
battle.emit('hpDamage', hctx);
if (Number.isFinite(hctx.amount)) final = Math.max(0, Math.min(final, hctx.amount));
}
return applyHpLoss(battle, source, target, final, dmg);
}
+1
View File
@@ -21,6 +21,7 @@
// noHeal bool (cannot be healed by others) blockFly bool onHitStatus {key, duration, value}
// dmgMul(battle, unit, target) → number afterHit(battle, unit, target, {dealt,x,y})
// canAttack(battle, unit) → bool afterAttack(battle, unit, targets)
// skipEnemy(enemy) → bool (an enemy the unit never selects — targeting.js canTargetEnemy; 嵯峨 "不攻击重伤单位")
// hitsFn(battle, unit) → n install(battle, unit) — per-unit hooks, called once at setup
// dollNoAttack bool (傀儡师: its <替身> makes no normal attack and casts no skill — 归溟幽灵鲨, kit trait)
// tb — the unit's trait blackboard (data `trait.bb`), used for tunables (module upgrades included on elites)
+9 -1
View File
@@ -74,7 +74,11 @@ export function enemyStealthed(e) {
return false;
}
/** Can `attacker` (ally) target enemy `e` at all (ignoring range)? */
/**
* Can `attacker` (ally) target enemy `e` at all (ignoring range)? A profile's own exclusion `skipEnemy(e)` (a kit trait:
* 嵯峨 劝善 "嵯峨不攻击重伤单位") keeps such an enemy out of the unit's normal targets, the enemies it blocks
* (blockedTargets) and the target condition of its skill triggers (they pass `unit.profile`).
*/
export function canTargetEnemy(attacker, e, profile) {
if (!e.alive || e.hidden || !e.deployed) return false;
const f = e.s.flags;
@@ -82,6 +86,7 @@ export function canTargetEnemy(attacker, e, profile) {
if (f.stealth && enemyStealthed(e)) return false;
if (e.isFlying && !(profile && profile.canHitFly)) return false;
if (profile && profile.groundOnly && e.isFlying) return false;
if (profile && typeof profile.skipEnemy === 'function' && profile.skipEnemy(e)) return false;
return true;
}
@@ -178,6 +183,9 @@ const PRIORITY_FNS = {
boss: (e) => (e.isBoss ? 0 : 1),
notBurst: (e) => (e.s.flags.burstLock ? 1 : 0),
ground: (e) => (e.isFlying ? 1 : 0),
// 攻城手 trait "优先攻击重量最重的敌人" (早露 / 提丰: kits/ops/op-poca.js, op-typhon.js): the highest current 重量等级
// (Unit.weight: massLevel with 失重 etc.) first, the usual order after that
heaviest: (e) => -e.weight,
};
/**
+9 -8
View File
@@ -1,7 +1,7 @@
// test/backups.test.js — the data of 补位 (stand-ins) and 自选 (DIY slots): chess.json `backup` and data/backups.json
// (docs/DATA.md §18; tools/build-data.mjs buildBackups; shared/standIn.js, shared/diy.js). These tests pin the data the
// gameplay reads: the 133 base chess and their types, the 17 stand-in characters and their forms, every NORMAL chess
// resolving to its stand-in, the 4 DIY slots, the legal picks (the 72 owned 6★ with their forms and summons, the
// resolving to its stand-in, the 4 DIY slots, the legal picks (the 71 owned 6★ with their forms and summons, the
// prototypes and their locked selections) and the faction bonds.
// With the official-data cache (.cache/gamedata) the backup fields, the stand-in numbers and the bond derivation are
// re-derived from the raw tables.
@@ -234,9 +234,9 @@ test('DIY: prototype picks — the 9 elites at tiers 5 and 6, six 4★ reserves
assert.equal(diyRecord(chess.chess_char_5_01_a, 'char_608_acpion', backups, { skillIndex: 2 }), null, 'not a DIY slot');
});
test('DIY: the 72 owned 6★ picks — a form at every slot status with all three skills, every module at stage 1 and 3, their summons', () => {
test('DIY: the 71 owned 6★ picks — a form at every slot status with all three skills, every module at stage 1 and 3, their summons', () => {
const { ownedPool } = backups.diy;
assert.equal(ownedPool.length, 72);
assert.equal(ownedPool.length, 71);
let summoners = 0;
for (const id of ownedPool) {
const u = backups.units[id];
@@ -308,7 +308,7 @@ test('DIY: prototype picks carry the skill / module of their 补位 rows at the
test('DIY: the owned-6★ pool and the faction → bond rule (mainPower + every subPower vs powerIdList, else 协防干员)', () => {
const { ownedPool, operators, prototypes } = backups.diy;
const roster = new Set(base.map((c) => c.charId).filter(Boolean));
assert.equal(ownedPool.length, 72);
assert.equal(ownedPool.length, 71);
for (const id of ownedPool) {
assert.ok(!roster.has(id), `${id}: a roster operator is never a pick`);
assert.deepEqual([operators[id].rarity, operators[id].obtainable], [6, true], id);
@@ -322,11 +322,11 @@ test('DIY: the owned-6★ pool and the faction → bond rule (mainPower + every
}
const tally = {};
for (const id of ownedPool) { const k = operators[id].bonds.join('+'); tally[k] = (tally[k] || 0) + 1; }
assert.deepEqual(tally, { emptyShip: 39, yanShip: 14, victoriaShip: 8, sargonShip: 4, siracusaShip: 4, lateranoShip: 1, kazimierzShip: 1, 'yanShip+victoriaShip': 1 });
assert.deepEqual(tally, { emptyShip: 38, yanShip: 14, victoriaShip: 8, sargonShip: 4, siracusaShip: 4, lateranoShip: 1, kazimierzShip: 1, 'yanShip+victoriaShip': 1 });
// the collab operators are out of the data and the pool (the owner's decision of 2026-10-05: copyright)
assert.deepEqual(backups.diy.excluded.map((id) => [id, backups.units[id], operators[id]]),
['char_456_ash', 'char_1029_yato2', 'char_4123_ela', 'char_4141_marcil', 'char_4182_oblvns', 'char_4217_makoto'].map((id) => [id, undefined, undefined]),
'灰烬, 麒麟R夜刀, 艾拉, 玛露西尔, 丰川祥子, 结城理');
['char_456_ash', 'char_1029_yato2', 'char_1048_orchd2', 'char_4123_ela', 'char_4141_marcil', 'char_4182_oblvns', 'char_4217_makoto'].map((id) => [id, undefined, undefined]),
'灰烬, 麒麟R夜刀, 焰狐龙梓兰 (MH05: a 联动寻访, by its display number), 艾拉, 玛露西尔, 丰川祥子, 结城理');
assert.deepEqual(operators.char_017_huang.bonds, ['yanShip', 'victoriaShip'], '煌: 炎 + 维多利亚 from her subPower (mainPower rhodes / elite)');
for (const id of [...prototypes['5'], ...prototypes['6']]) assert.deepEqual(operators[id].bonds, ['emptyShip'], `${id}: no faction`);
});
@@ -335,7 +335,8 @@ test('DIY: powers and the owned pool re-derived from character_table', { skip: !
const CT = raw('character_table');
const act = raw('activity_table').activity.AUTOCHESS_SEASON.act2autochess;
const roster = new Set(Object.values(act.charShopChessDatas).map((r) => r.charId).filter(Boolean));
const collab = (c) => [c.mainPower, ...(c.subPower || [])].some((p) => ['rainbow', 'action4', 'mujica', 'sees', 'laios'].includes(p?.teamId));
const collab = (c) => [c.mainPower, ...(c.subPower || [])].some((p) => ['rainbow', 'action4', 'mujica', 'sees', 'laios'].includes(p?.teamId))
|| /^(?:MH|RS|AM|PS|DD)\d/.test(c.displayNumber || '');
const legal = Object.entries(CT).filter(([id, c]) => id.startsWith('char_') && c.rarity === 'TIER_6' && !['TOKEN', 'TRAP'].includes(c.profession) && !c.isNotObtainable && !roster.has(id));
assert.equal(legal.length, 78, 'the excel\'s 78 obtainable 6★ outside the chess pool');
assert.deepEqual([...backups.diy.ownedPool].sort(), legal.filter(([, c]) => !collab(c)).map(([id]) => id).sort());
+10 -4
View File
@@ -13,10 +13,13 @@ import { getDefaultSource, DataSource } from '../../server/sim/simdata.js';
import { buildBattleSpec, createBattleFromSpec } from '../../server/sim/spec.js';
import { unitInfo } from '../../server/sim/snapshot.js';
import { isDiyDef, kitOf } from '../../server/sim/content/index.js';
import { KITTED_CHARS } from '../../server/sim/content/kits/index.js';
const load = (f) => JSON.parse(readFileSync(new URL(`../../data/${f}.json`, import.meta.url), 'utf8'));
const BACKUPS = load('backups');
const SIEGE = 'char_112_siege';
/** An owned 6★ without an operator kit yet (the generic kit's case) — the first of diy.ownedPool, null once all have one. */
const KITLESS = BACKUPS.diy.ownedPool.find((id) => !KITTED_CHARS.includes(id)) ?? null;
const T5 = 'chess_char_5_diy1_a', T6 = 'chess_char_6_diy1_a';
const dummy = (key, o = {}) => enemyRec({ key, hp: 1e9, speed: 0, mass: 0, ...o });
const ENEMIES = { enemy_dummy: dummy('enemy_dummy') };
@@ -54,18 +57,21 @@ test('a battle fields 自选 pieces by their kit: an owned 6★\'s operator kit
{ diy: { slot: 5, charId: SIEGE, skillIndex: 1 }, row: 10, col: 3 },
{ diy: { slot: 'chess_char_5_diy2_a', charId: 'char_609_acguad' }, elite: true, row: 10, col: 5 },
{ diy: { slot: T6, charId: 'char_617_sharp2' }, elite: true, row: 10, col: 7 },
{ diy: { slot: 'chess_char_6_diy2_a', charId: 'char_017_huang', skillIndex: 2 }, row: 11, col: 3 },
...(KITLESS ? [{ diy: { slot: 'chess_char_6_diy2_a', charId: KITLESS, skillIndex: 2 }, row: 11, col: 3 }] : []),
{ diy: { slot: 'chess_char_6_diy2_a', charId: 'char_601_cguard' }, row: 11, col: 5 }, // illegal at tier 6: never fielded
], { kits: { [SIEGE]: siegeKit, chess_char_5_diy1_a: () => { throw new Error('a 自选 piece never runs a kit of its slot id'); } } });
h.step();
const [siege, sharp, sharp2, huang] = [1, 2, 3, 4].map((uid) => h.unit(uid));
assert.equal(h.unit(5), null, 'an illegal pick fields nothing');
const [siege, sharp, sharp2, kitless] = [1, 2, 3, 4].map((uid) => h.unit(uid));
assert.equal(h.unit(KITLESS ? 5 : 4), null, 'an illegal pick fields nothing');
assert.deepEqual([siege.defId, siege.def.charId, siege.skill.id, siege.kit.diyProbe, siege.kit.skillSource], [T5, SIEGE, 'skchr_siege_2', true, 'skills']);
assert.equal(kitOf(siege.def, { [T5]: 'slot', chess_char_5_diy1: 'bare', [SIEGE]: 'mine' }), 'mine', 'kitOf: by charId only');
assert.equal(kitOf(siege.def, { [T5]: 'slot' }), undefined);
assert.deepEqual([sharp.defId, sharp.def.charId, sharp.skill.id, !!sharp.kit.generic, sharp.kit.skillSource, sharp.def.raw.module.level], ['chess_char_5_diy2_b', 'char_609_acguad', 'skchr_acguad_3', false, 'skills', 1]);
assert.deepEqual([sharp2.skill.id, !!sharp2.kit.generic, sharp2.def.raw.module.level, sharp2.def.raw.module.active], ['skchr_sharp2_1', false, 3, true], '领主·Sharp at tier 6: S1 + LOR-X stage 3');
assert.deepEqual([huang.def.charId, huang.skill.id, !!huang.kit.generic, huang.def.bonds], ['char_017_huang', 'skchr_huang_3', true, ['yanShip', 'victoriaShip']], '煌 has no kit yet: the generic kit');
if (KITLESS) {
assert.deepEqual([kitless.def.charId, kitless.skill.id, !!kitless.kit.generic, kitless.def.bonds],
[KITLESS, BACKUPS.units[KITLESS].forms['2/1/4/0'].skills[2].skillId, true, BACKUPS.diy.operators[KITLESS].bonds], `${KITLESS} has no kit yet: the generic kit`);
}
// the elite reserve uses the generic kit with its exact stat talents (as a 补位 stand-in does)
const h2 = battle([{ diy: { slot: 5, charId: 'char_601_cguard' }, elite: true, row: 10, col: 3 }]);
h2.step();
+330
View File
@@ -0,0 +1,330 @@
// test/content/op_amgoat.test.js — the 自选 operator kit of 艾雅法拉 (char_180_amgoat, 6★ 中坚术师; kit
// server/sim/content/kits/ops/op-amgoat.js), fielded the production way (a DIY slot + its `diy` pick, simdata getDiy) in
// every form: tiers 5 / 6, normal (E2 Lv1, skill rank 4, no module) and elite (E2 Lv60, rank 7) with no module, CCR-X
// “错过的声音” or CCR-Y “宠物大赛第一名” at stage 1 (tier 5) / 3 (tier 6). Every number is read back from data/backups.json
// (the form of that slot status); the fidelity checklist of kits/README.md item by item.
// Run: node --test test/content/op_amgoat.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
import { KITTED_CHARS, OPERATOR_KITS, KITS } from '../../server/sim/content/kits/index.js';
import { diyPool, validateDiyPicks } from '../../shared/diy.js';
const load = (f) => JSON.parse(readFileSync(new URL(`../../data/${f}.json`, import.meta.url), 'utf8'));
const CHESS = load('chess');
const BACKUPS = load('backups');
const ID = 'char_180_amgoat';
const FORMS = BACKUPS.units[ID].forms;
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
const CCRX = 'uniequip_002_amgoat', CCRY = 'uniequip_003_amgoat';
const S1 = 'skchr_amgoat_1', S2 = 'skchr_amgoat_2', S3 = 'skchr_amgoat_3';
const formOf = (tier, elite) => FORMS[elite ? (tier === 5 ? '2/60/7/1' : '2/60/7/3') : '2/1/4/0'];
const skillOf = (tier, elite, id) => formOf(tier, elite).skills.find((s) => s.skillId === id);
const modOf = (tier, mod) => (mod ? formOf(tier, true).modules.find((m) => m.uniEquipId === mod) : null);
const approx = (a, b, msg, eps = 1e-6) => assert.ok(Math.abs(a - b) <= eps * Math.max(1, Math.abs(b)), `${msg}: ${a} vs ${b}`);
const dummy = (key, o = {}) => enemyRec({ key, hp: 1e9, speed: 0, mass: 0, res: 50, ...o });
const ENEMIES = {
enemy_dummy: dummy('enemy_dummy'), enemy_fly: dummy('enemy_fly', { motion: 'FLY' }),
enemy_elite: dummy('enemy_elite', { rank: 'ELITE' }), enemy_boss: dummy('enemy_boss', { rank: 'BOSS' }),
};
/** Every 自选 form: [tier, elite, module]. */
const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, CCRX, CCRY].map((m) => [t, true, m]))];
const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`;
/** A battle with 艾雅法拉 as uid 1 at (row, col) facing RIGHT, plus `others`. */
function field({ tier = 5, elite = false, mod = null, skill = 2, row = 10, col = 5, others = [], seed = 5, setup = null } = {}) {
const h = makeBattle({
defs: { enemies: ENEMIES }, timeLimit: 900, autoFinish: false, seed,
flags: { dpPerSec: 0, dpMax: 999 }, hooks: ['damaged', 'skillStart', 'skillEnd', 'statusApplied', 'spGain', 'deploy'], captureNoisy: true,
units: [{ uid: 1, diy: { slot: SLOT[tier], charId: ID, skillIndex: skill, uniEquipId: mod }, elite, row, col }, ...others],
setup,
});
h.step();
return { h, u: h.unit(1) };
}
const hitsBy = (h, u) => h.hooksOf('damaged').filter((c) => c.source === u);
function done(h) {
checkInvariants(h.b);
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
}
/** Arts damage of `amount` on RES `res` with `ignore` flat RES ignored. */
const arts = (amount, res, ignore = 0) => Math.max(amount * (1 - Math.max(0, res - ignore) / 100), amount * 0.05);
test('艾雅法拉 in every 自选 form: her operator kit (all three skills authored), the form\'s stats + module attributes, 3-1 range, ranged arts that hits air, block 1, 1.6 s, 空 bonds, no 特质', () => {
assert.equal(OPERATOR_KITS[ID], KITS[ID]);
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
for (const skill of [0, 1, 2]) {
const { h, u } = field({ tier, elite, mod, skill });
const form = formOf(tier, elite), m = elite ? modOf(tier, mod) : null;
assert.deepEqual([u.def.charId, u.def.diyFor, u.skill.id, !!u.kit.generic, u.kit.skillSource], [ID, SLOT[tier], form.skills[skill].skillId, false, 'skills'], label(f));
assert.deepEqual([u.base.maxHp, u.base.atk, u.base.def, u.base.res], [form.stats.maxHp + (m?.attr.maxHp ?? 0), form.stats.atk + (m?.attr.atk ?? 0), form.stats.def + (m?.attr.def ?? 0), 20], `${label(f)}: stats`);
assert.deepEqual([u.s.blockCnt, u.profile.attack, u.profile.dmgType, u.profile.canHitFly, u.profile.groundOnly, u.base.bat], [1, 'ranged', 'arts', true, false, 1.6], `${label(f)}: 中坚术师`);
assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: 3-1`);
assert.deepEqual([u.def.bonds, u.def.raw.garrisonIds, u.def.raw.tokens], [['emptyShip'], [], []], `${label(f)}: bonds / 特质 / no summon`);
assert.ok(!u.s.flags.liftoff && !u.s.flags.camou && !u.s.flags.stealth && !u.s.flags.untargetable, `${label(f)}: ground enemies target her`);
done(h);
}
}
// the numbers of the forms (zh_CN): E2 Lv1 1359 / 535 / 109, E2 Lv60 1614 / 608 / 118; CCR-X +130 / +40 → +180 / +65,
// CCR-Y +180 / +25 → +230 / +50 HP / ATK
assert.deepEqual([FORMS['2/1/4/0'].stats.maxHp, FORMS['2/1/4/0'].stats.atk, FORMS['2/60/7/1'].stats.maxHp, FORMS['2/60/7/1'].stats.atk], [1359, 535, 1614, 608]);
assert.deepEqual([modOf(5, CCRX).attr, modOf(6, CCRX).attr, modOf(5, CCRY).attr, modOf(6, CCRY).attr], [{ maxHp: 130, atk: 40 }, { maxHp: 180, atk: 65 }, { maxHp: 180, atk: 25 }, { maxHp: 230, atk: 50 }]);
});
test('a 自选 pick: 艾雅法拉 is offered at tiers 5 and 6 (she has a kit) and a roster with her passes validateDiyPicks', () => {
const data = { chess: CHESS, backups: BACKUPS };
assert.ok(KITTED_CHARS.includes(ID));
for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(ID), `tier ${t}`);
assert.deepEqual(validateDiyPicks({ [SLOT[6]]: { charId: ID, skillIndex: 2, uniEquipId: CCRY } }, { data, kitted: KITTED_CHARS }),
{ ok: true, picks: { [SLOT[6]]: { charId: ID, skillIndex: 2, uniEquipId: CCRY } } });
});
test('S1 二重咏唱 (MANUAL, data DEFAULT, 25 s): ASPD +38 / +45 on the first cast of a deployment; ASPD and ATK +38 % / +45 % from the second; the count restarts after a redeploy', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S1);
const { h, u } = field({ tier, elite, skill: 0 });
const a = sk.bb['amgoat_s_1[a].attack_speed'], b = sk.bb['amgoat_s_1[b].attack_speed'], atk = sk.bb['amgoat_s_1[b].atk'];
assert.deepEqual([u.skill.rule, sk.duration, sk.spCost, sk.initSp, a, b, atk], ['DEFAULT', 25, elite ? 39 : 42, elite ? 20 : 15, elite ? 45 : 38, elite ? 45 : 38, elite ? 0.45 : 0.38], `T${tier}`);
h.run(0.6);
const aspd0 = u.s.aspd, atk0 = u.s.atk;
u.skill.gainSp(999);
h.run(2);
assert.equal(u.skill.activations, 0, `T${tier}: no enemy, no cast`);
h.spawn('enemy_dummy', { pos: [10, 6] });
assert.ok(h.runUntil(() => u.skill.active, 3), `T${tier}: cast with an enemy in her range`);
approx(u.skill.timeLeft, 25, `T${tier}: 25 s`, 0.01);
assert.deepEqual(u.findBuff('skill:amgoat:duet')?.mods, { aspd: a }, `T${tier}: the first cast`);
approx(u.s.aspd, aspd0 + a, `T${tier}: ASPD`);
approx(u.s.atk, atk0, `T${tier}: no ATK on the first cast`);
assert.ok(h.runUntil(() => !u.skill.active, 26));
assert.equal(u.findBuff('skill:amgoat:duet'), null, `T${tier}: gone after it`);
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 5), `T${tier}: the second cast`);
assert.deepEqual(u.findBuff('skill:amgoat:duet')?.mods, { aspd: b, atkPct: atk }, `T${tier}: the second cast`);
approx(u.s.atk, u.base.atk * (1 + 0.14 + atk), `T${tier}: ATK with 炎息`);
h.b.retreat(u);
h.b.redeploy(u);
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 5), `T${tier}: a cast after the redeploy`);
assert.deepEqual(u.findBuff('skill:amgoat:duet')?.mods, { aspd: a }, `T${tier}: the count restarted (PRTS 备注)`);
done(h);
}
});
test('S2 点燃 (AUTO, 2 charges, data DEFAULT): main target RES ×0.85 / ×0.8 first, ½ explosion on every enemy within 1.5 (flyers too), then ½ again on the main target; the others\' RES cut after their hit, 6 s', () => {
for (const [tier, elite, mod] of [[5, false, null], [6, true, null], [6, true, CCRX]]) {
const sk = skillOf(tier, elite, S2);
const { h, u } = field({ tier, elite, mod, skill: 1 });
assert.deepEqual([u.skill.rule, u.skill.kind, u.skill.maxCharges, sk.spCost, sk.bb.atk_scale, sk.bb.atk_scale_2, sk.bb.magic_resistance, sk.bb.duration],
['DEFAULT', 'charges', 2, elite ? 6 : 7, elite ? 1.55 : 1.375, elite ? 1.55 : 1.375, elite ? -0.2 : -0.15, 6], `T${tier}`);
const ign = mod === CCRX ? 10 : 0;
h.run(0.6);
// 乱火 filled 1–2 charges at the deployment: spent before the scene is set
u.skill.charges = 0;
u.skill.sp = 0;
// the main target alone in her range (its tip 0,3); three around it outside the range, one beyond the explosion
const main = h.spawn('enemy_dummy', { pos: [10, 8] });
const n1 = h.spawn('enemy_dummy', { pos: [10, 9] }), n2 = h.spawn('enemy_dummy', { pos: [11, 9] }), fly = h.spawn('enemy_fly', { pos: [9, 9] });
const far = h.spawn('enemy_dummy', { pos: [10, 10] });
u.skill.gainSp(999);
assert.equal(u.skill.charges, 2);
const n0 = hitsBy(h, u).length;
assert.ok(h.runUntil(() => hitsBy(h, u).slice(n0).filter((c) => c.target === main).length >= 2, 4), `T${tier}: the ignited attack lands`);
h.step();
const hits = hitsBy(h, u).slice(n0);
const atk = u.s.atk;
const cut = 50 * (1 + sk.bb.magic_resistance);
const onMain = hits.filter((c) => c.target === main);
assert.equal(onMain.length, 2, `T${tier}: two ½ hits on the main target`);
for (const c of onMain) approx(c.amount, arts(atk * sk.bb.atk_scale, cut, ign), `T${tier}: ½ on the main target after its RES cut`);
for (const e of [n1, n2, fly]) {
const hs = hits.filter((c) => c.target === e);
assert.equal(hs.length, 1, `T${tier}: one explosion hit on ${e.tileR},${e.tileC}`);
approx(hs[0].amount, arts(atk * sk.bb.atk_scale, 50, ign), `T${tier}: ½ before its own RES cut`);
assert.deepEqual([hs[0].type, hs[0].dmg.isSplash, hs[0].dmg.isSkill], ['arts', true, true]);
}
assert.ok(!hits.some((c) => c.target === far), `T${tier}: 2 tiles away — outside the 1.5 explosion`);
for (const e of [main, n1, n2, fly]) approx(e.s.res, cut, `T${tier}: RES ×${1 + sk.bb.magic_resistance}`);
approx(far.s.res, 50, `T${tier}: untouched`);
assert.equal(u.skill.charges, 1, `T${tier}: one charge spent`);
// the next attack spends the second charge; the cut stays one (同名效果取最高) and lasts 6 s
assert.ok(h.runUntil(() => u.skill.charges === 0, 4));
h.run(0.5);
approx(main.s.res, cut, `T${tier}: one RES cut, not two`);
h.b.retreat(u); // no further ignition: the cut runs out
h.run(6.2);
approx(main.s.res, 50, `T${tier}: back after 6 s`);
done(h);
}
});
test('S3 火山 (MANUAL, data ACTIVE_RANGE on x-3 — 「攻击范围增大」 is an attack-range change, owner rule 2026-10-05): 15 s, ATK +70 % / +85 %, interval 1.6 − 1.1 = 0.5 s, range x-3 while on, lava on 4 / 5 random different enemies of it per attack (all when fewer); back after', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S3);
const { h, u } = field({ tier, elite, skill: 2, seed: 9 });
const n = sk.bb['attack@max_target'];
assert.deepEqual([u.skill.rule, sk.trigger.rawRule, sk.rangeId, sk.duration, sk.bb.atk, sk.bb.base_attack_time, n, sk.spCost, sk.initSp],
['ACTIVE_RANGE', 'DEFAULT', 'x-3', 15, elite ? 0.85 : 0.7, -1.1, elite ? 5 : 4, 80, elite ? 40 : 33], `T${tier}`);
assert.deepEqual(u.skill.triggerGrid, sk.rangeGrid, `T${tier}: the trigger grid is x-3`);
h.run(0.6);
const atk0 = u.s.atk;
approx(u.s.interval, 1.6, `T${tier}: own interval`);
u.skill.gainSp(999);
h.run(1);
assert.equal(u.skill.activations, 0, `T${tier}: nobody on x-3`);
// seven enemies: x-3 holds (8,5) / (12,5) / (10,2), none of them in her own 3-1
const spots = [[10, 6], [9, 7], [11, 7], [10, 8], [8, 5], [12, 5], [10, 2]];
const foes = spots.map((p) => h.spawn('enemy_dummy', { pos: p }));
assert.ok(h.runUntil(() => u.skill.active, 2), `T${tier}: cast`);
approx(u.skill.timeLeft, 15, `T${tier}: 15 s`, 0.05);
assert.deepEqual(u.liveRangeGrid, sk.rangeGrid, `T${tier}: x-3 while on`);
approx(u.s.interval, 0.5, `T${tier}: 0.5 s`);
approx(u.s.atk, atk0 + u.base.atk * sk.bb.atk, `T${tier}: ATK +${sk.bb.atk * 100} %`);
const n0 = hitsBy(h, u).length;
h.run(6);
assert.ok(u.skill.active);
u.skill.end('test');
h.run(0.5); // the last lava lands
const hits = hitsBy(h, u).slice(n0).filter((c) => c.dmg.isAttack && c.dmg.isSkill);
const byAttack = new Map();
for (const c of hits) byAttack.set(c.dmg.attackId, [...(byAttack.get(c.dmg.attackId) ?? []), c.target]);
assert.ok(byAttack.size >= 11, `T${tier}: ${byAttack.size} attacks in 6 s (one per 0.5 s)`);
const counts = new Map(foes.map((e) => [e, 0]));
for (const [, ts] of byAttack) {
assert.equal(ts.length, n, `T${tier}: ${n} lava per attack`);
assert.equal(new Set(ts).size, n, `T${tier}: different enemies`);
for (const t of ts) counts.set(t, counts.get(t) + 1);
}
for (const [e, k] of counts) assert.ok(k > 0, `T${tier}: ${e.tileR},${e.tileC} drew lava (random draw)`);
for (const c of hits.slice(0, 4)) approx(c.amount, arts(atk0 + u.base.atk * sk.bb.atk, 50), `T${tier}: 100 % of her S3 ATK, arts`);
assert.deepEqual(u.liveRangeGrid, formOf(tier, elite).rangeGrid, `T${tier}: back to 3-1`);
approx(u.s.interval, 1.6, `T${tier}: interval back`);
done(h);
}
// two enemies of x-3 only: both every attack
const { h, u } = field({ tier: 6, elite: true, skill: 2, seed: 4 });
const two = [h.spawn('enemy_dummy', { pos: [10, 6] }), h.spawn('enemy_dummy', { pos: [10, 8] })];
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 2));
const n0 = hitsBy(h, u).length;
h.run(3);
const byAttack = new Map();
for (const c of hitsBy(h, u).slice(n0).filter((x) => x.dmg.isSkill)) byAttack.set(c.dmg.attackId, [...(byAttack.get(c.dmg.attackId) ?? []), c.target]);
[...byAttack.keys()].slice(0, -1).forEach((k) => assert.deepEqual(new Set(byAttack.get(k)), new Set(two), 'fewer enemies than lava: each of them'));
assert.ok(byAttack.size >= 5);
done(h);
});
test('T1 炎息: every 【术师】 operator of the field ATK +14 % while she is deployed (惊蛰 yes, 德克萨斯 no); CCR-X stage 3 "携带时" +22 %, kept while she is knocked out', () => {
const others = [{ uid: 2, chessId: 'chess_char_1_03_a', row: 12, col: 3 }, { uid: 3, chessId: 'chess_char_1_08_a', row: 12, col: 8 }];
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
const { h, u } = field({ tier, elite, mod, skill: 0, others });
h.run(0.6);
const leizi = h.unit(2), texas = h.unit(3);
const carried = elite && tier === 6 && mod === CCRX;
const v = carried ? 0.22 : 0.14;
const t0 = u.def.raw.talents.find((t) => t.index === 0);
assert.equal(t0.bb.atk, v, `${label(f)}: the data`);
assert.match(t0.desc, carried ? /^携带时/ : /^在场时/, `${label(f)}: the text`);
assert.deepEqual(leizi.findBuff('talent:amgoat:casters')?.mods, { atkPct: v }, `${label(f)}: 惊蛰 (术师)`);
assert.deepEqual(u.findBuff('talent:amgoat:casters')?.mods, { atkPct: v }, `${label(f)}: herself`);
assert.equal(texas.findBuff('talent:amgoat:casters'), null, `${label(f)}: 德克萨斯 (先锋) gets nothing`);
h.b.kill(u, null);
h.run(1.2);
assert.equal(!!leizi.findBuff('talent:amgoat:casters'), carried, `${label(f)}: after she is knocked out`);
done(h);
}
});
test('T2 乱火: 7–15 SP at every deployment (a floored draw); CCR-Y stage 3 also ASPD +6–15, both the maxima (15 / 15) with an elite or leader in range at the deployment; stage 1 and no module: no ASPD, no maximum rule', () => {
const seen = new Set(), seenAs = new Set();
for (let seed = 1; seed <= 40; seed++) {
const gifts = [];
const setup = (b) => b.on('deploy', (c) => { if (c.unit.uid === 1) gifts.push([c.unit.skill.sp, c.unit.findBuff('talent:amgoat:wildfire')?.mods?.aspd ?? 0]); }, { priority: -100 });
const y3 = seed % 2 === 0;
const { h } = field({ tier: 6, elite: true, mod: y3 ? CCRY : null, skill: 2, seed, setup });
const sk = skillOf(6, true, S3);
const [sp, as] = gifts[0];
const k = sp - sk.initSp;
assert.ok(Number.isInteger(k) && k >= 7 && k <= 15, `seed ${seed}: +${k} SP`);
seen.add(k);
if (y3) { assert.ok(Number.isInteger(as) && as >= 6 && as <= 15, `seed ${seed}: ASPD +${as}`); seenAs.add(as); } else assert.equal(as, 0);
done(h);
}
assert.ok(seen.size >= 6 && seenAs.size >= 4, `a spread of draws: SP ${[...seen].sort((a, b) => a - b)}, ASPD ${[...seenAs].sort((a, b) => a - b)}`);
// an elite / leader in range at a deployment: the maxima with CCR-Y stage 3; a draw otherwise
for (const [tier, mod, rank] of [[6, CCRY, 'enemy_elite'], [6, CCRY, 'enemy_boss'], [5, CCRY, 'enemy_elite'], [6, null, 'enemy_elite'], [6, CCRY, 'enemy_dummy']]) {
const lab = `T${tier} ${mod ?? 'none'} ${rank}`;
const top = tier === 6 && mod === CCRY && rank !== 'enemy_dummy';
let maxed = 0;
for (let seed = 1; seed <= 6; seed++) {
const gifts = [];
const setup = (b) => b.on('deploy', (c) => { if (c.unit.uid === 1) gifts.push([c.unit.skill.sp, c.unit.findBuff('talent:amgoat:wildfire')?.mods?.aspd ?? 0]); }, { priority: -100 });
const { h, u } = field({ tier, elite: true, mod, skill: 2, seed, setup });
const sk = skillOf(tier, true, S3);
h.spawn(rank, { pos: [10, 6] });
h.b.retreat(u);
h.b.redeploy(u);
const [sp, as] = gifts[1];
if (top) assert.deepEqual([sp - sk.initSp, as], [15, 15], `${lab} seed ${seed}: the maxima`);
else {
assert.ok(sp - sk.initSp >= 7 && sp - sk.initSp <= 15, lab);
if (sp - sk.initSp === 15) maxed++;
if (mod !== CCRY || tier === 5) assert.equal(as, 0, `${lab}: no ASPD`);
}
done(h);
}
if (!top) assert.ok(maxed < 6, `${lab}: not always the maximum`);
}
// the talent's text and blackboard (stage 3 CCR-Y)
const t1 = formOf(6, true).modules.find((m) => m.uniEquipId === CCRY).talentChanges.find((t) => t.talentIndex === 1);
assert.deepEqual(t1.bb, { sp_min: 7, sp_max: 16, attack_speed_min: 6, attack_speed_max: 16, factor: 1 });
assert.match(t1.desc, /随机获得7~15点技力并随机提升6~15的攻击速度/);
});
test('modules: CCR-X "无视目标10点法术抗性" at stages 1 and 3 (her normal attack on RES 50 deals × 0.6); CCR-Y "普通攻击命中精英或领袖敌人时获得1点技力" — not on a normal enemy, not from S2\'s explosion', () => {
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
const { h, u } = field({ tier, elite, mod, skill: 2 });
const x = elite && mod === CCRX, y = elite && mod === CCRY;
assert.equal(u.s.resIgnoreFlat, x ? 10 : 0, `${label(f)}: RES ignore`);
if (x) assert.equal(u.def.raw.trait.moduleDesc, '无视目标10点法术抗性');
if (y) assert.deepEqual(u.def.raw.trait.bb, { sp: 1 });
const foe = h.spawn('enemy_dummy', { pos: [10, 6] });
h.run(0.6);
const n0 = hitsBy(h, u).length;
h.run(4);
const hits = hitsBy(h, u).slice(n0).filter((c) => c.target === foe && c.dmg.isAttack);
assert.ok(hits.length >= 2, label(f));
approx(hits[0].amount, arts(u.s.atk, 50, x ? 10 : 0), `${label(f)}: arts on RES 50`);
const talentSp = () => h.hooksOf('spGain').filter((c) => c.unit === u && c.reason === 'trait').length;
assert.equal(talentSp(), 0, `${label(f)}: no SP from a normal enemy`);
h.b.kill(foe, null);
const el = h.spawn('enemy_elite', { pos: [10, 6] });
const k0 = talentSp(), a0 = hitsBy(h, u).filter((c) => c.target === el && c.dmg.isAttack).length;
h.run(4);
const hitsE = hitsBy(h, u).filter((c) => c.target === el && c.dmg.isAttack).length - a0;
assert.ok(hitsE >= 2, label(f));
const gained = talentSp() - k0;
assert.equal(gained, y ? hitsE : 0, `${label(f)}: +1 SP per hit on an elite`);
if (y) for (const c of h.hooksOf('spGain').filter((s) => s.unit === u && s.reason === 'trait')) assert.equal(c.amount, 1);
done(h);
}
// S2's explosion and second ½ are no further 命中 (CCR-Y stage 3): one SP per ignited attack on an elite
const { h, u } = field({ tier: 6, elite: true, mod: CCRY, skill: 1 });
h.run(0.6);
u.skill.charges = 0;
u.skill.sp = 0;
const el = h.spawn('enemy_elite', { pos: [10, 8] });
h.spawn('enemy_elite', { pos: [10, 9] });
u.skill.gainSp(999);
const k0 = h.hooksOf('spGain').filter((c) => c.unit === u && c.reason === 'trait').length;
assert.ok(h.runUntil(() => hitsBy(h, u).filter((c) => c.target === el && c.dmg.isSkill).length >= 2, 4));
h.step();
assert.equal(h.hooksOf('spGain').filter((c) => c.unit === u && c.reason === 'trait').length - k0, 1, 'one SP for the ignited attack');
done(h);
});
+277
View File
@@ -0,0 +1,277 @@
// test/content/op_aphris.test.js — the 自选 operator kit of 谬因 (char_4229_aphris, 6★ 轰击术师; kit
// server/sim/content/kits/ops/op-aphris.js) and her summon “中继器” (token_10070_aphris_pc), fielded the production way (a DIY
// slot + its `diy` pick, simdata getDiy; the 中继器 as a token piece of hers) in every form: tiers 5 / 6, normal (E2 Lv1,
// skill rank 4, no module) and elite (E2 Lv60, rank 7) with no module or BLA-Y “第三相态” at stage 1 (tier 5) / 3 (tier 6).
// Every number is read back from data/backups.json (the form of that slot status, the token's variant); the fidelity
// checklist of kits/README.md item by item.
// Run: node --test test/content/op_aphris.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
import { KITTED_CHARS, OPERATOR_KITS, KITS } from '../../server/sim/content/kits/index.js';
import { diyPool, validateDiyPicks } from '../../shared/diy.js';
const load = (f) => JSON.parse(readFileSync(new URL(`../../data/${f}.json`, import.meta.url), 'utf8'));
const CHESS = load('chess');
const BACKUPS = load('backups');
const APHRIS = 'char_4229_aphris';
const RELAY = 'token_10070_aphris_pc';
const FORMS = BACKUPS.units[APHRIS].forms;
const TOKEN = BACKUPS.tokens[RELAY];
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
const BLAY = 'uniequip_002_aphris';
const S1 = 'skchr_aphris_1', S2 = 'skchr_aphris_2', S3 = 'skchr_aphris_3';
const statusKey = (tier, elite) => (elite ? (tier === 5 ? '2/60/7/1' : '2/60/7/3') : '2/1/4/0');
const formOf = (tier, elite) => FORMS[statusKey(tier, elite)];
const skillOf = (tier, elite, id) => formOf(tier, elite).skills.find((s) => s.skillId === id);
const modOf = (tier, mod) => (mod ? formOf(tier, true).modules.find((m) => m.uniEquipId === mod) : null);
const approx = (a, b, msg, eps = 1e-6) => assert.ok(Math.abs(a - b) <= eps * Math.max(1, Math.abs(b)), `${msg}: ${a} vs ${b}`);
const dummy = (key, o = {}) => enemyRec({ key, hp: 1e9, speed: 0, mass: 0, ...o });
const ENEMIES = { enemy_dummy: dummy('enemy_dummy'), enemy_fly: dummy('enemy_fly', { motion: 'FLY' }), enemy_hitter: dummy('enemy_hitter', { atk: 500, range: 3, bat: 1 }) };
const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, BLAY].map((m) => [t, true, m]))];
const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`;
const K = (r, c) => r * 21 + c;
const sortN = (a) => [...a].sort((x, y) => x - y);
/** 谬因 at (9, 2) facing RIGHT; her own line (9, 2) … (9, 7). */
const OWN = [[9, 2], [9, 3], [9, 4], [9, 5], [9, 6], [9, 7]];
/** With her 中继器 on (9, 4) facing UP: two steps to it, then 5 more (5 + 2) turned up. */
const BENT = [[9, 2], [9, 3], [9, 4], [10, 4], [11, 4], [12, 4], [13, 4], [14, 4]];
/** A battle with 谬因 as uid 1 at (9, 2) facing RIGHT (`relay`: her piece as uid 2 at relay.row / col / dir), plus `others`. */
function field({ tier = 5, elite = false, mod = null, skill = 1, relay = null, others = [], seed = 5, dp = 99 } = {}) {
const units = [{ uid: 1, diy: { slot: SLOT[tier], charId: APHRIS, skillIndex: skill, uniEquipId: mod }, elite, row: 9, col: 2 }];
if (relay) units.push({ uid: 2, kind: 'token', tokenId: RELAY, ownerUid: 1, row: relay.row, col: relay.col, dir: relay.dir });
const h = makeBattle({
defs: { enemies: ENEMIES }, timeLimit: 900, autoFinish: false, seed,
flags: { dpInit: dp, dpPerSec: 0, dpMax: 999 }, hooks: ['damaged', 'skillStart', 'skillEnd', 'statusApplied', 'death', 'deploy'], captureNoisy: true,
units: [...units, ...others],
});
h.step();
return { h, u: h.unit(1), r: relay ? h.unit(2) : null };
}
const UP4 = { row: 9, col: 4, dir: 'UP' };
const atkHits = (h, u) => h.hooksOf('damaged').filter((c) => c.source === u && c.dmg?.isAttack);
const tagged = (h, u, tag) => h.hooksOf('damaged').filter((c) => c.source === u && c.dmg?.tags?.includes(tag));
function done(h) {
checkInvariants(h.b);
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
}
test('谬因 in every 自选 form: her operator kit (all three skills authored), the form\'s stats + module attributes (BLA-Y: 部署费用 −8), 5-1 line without a 中继器, 轰击术师, no 特质', () => {
assert.equal(OPERATOR_KITS[APHRIS], KITS[APHRIS]);
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
for (const skill of [0, 1, 2]) {
const { h, u } = field({ tier, elite, mod, skill });
const form = formOf(tier, elite), m = elite ? modOf(tier, mod) : null;
assert.deepEqual([u.def.charId, u.def.diyFor, u.skill.id, !!u.kit.generic, u.kit.skillSource], [APHRIS, SLOT[tier], form.skills[skill].skillId, false, 'skills'], label(f));
assert.deepEqual([u.base.maxHp, u.base.atk, u.base.def, u.base.cost], [form.stats.maxHp + (m?.attr.maxHp ?? 0), form.stats.atk + (m?.attr.atk ?? 0), form.stats.def, form.stats.cost + (m?.attr.cost ?? 0)], `${label(f)}: stats`);
assert.deepEqual([u.profile.dmgType, u.profile.canHitFly, u.profile.allInRange, u.profile.projectile], ['arts', true, true, 'beam'], `${label(f)}: 轰击术师`);
assert.deepEqual(sortN(u.rangeKeys), sortN(OWN.map(([a, b]) => K(a, b))), `${label(f)}: her own 5-1`);
assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: the card shows 5-1`);
assert.deepEqual([u.def.bonds, u.def.raw.garrisonIds, u.def.tokens], [BACKUPS.diy.operators[APHRIS].bonds, [], [RELAY]], `${label(f)}: bonds / 特质 / summon`);
assert.ok(!u.s.flags.liftoff && !u.s.flags.camou && !u.s.flags.stealth, `${label(f)}: enemies target her`);
done(h);
}
}
// E2 Lv1 1261 / 727, E2 Lv60 1526 / 826, cost 34; BLA-Y −8 cost, +120 / +45 → +200 / +70 HP / ATK
assert.deepEqual([FORMS['2/1/4/0'].stats.maxHp, FORMS['2/1/4/0'].stats.atk, FORMS['2/60/7/1'].stats.maxHp, FORMS['2/60/7/1'].stats.atk, FORMS['2/60/7/1'].stats.cost], [1261, 727, 1526, 826, 34]);
assert.deepEqual([modOf(5, BLAY).attr, modOf(6, BLAY).attr], [{ cost: -8, maxHp: 120, atk: 45 }, { cost: -8, maxHp: 200, atk: 70 }]);
});
test('a 自选 pick: 谬因 is offered at tiers 5 and 6 (she has a kit) and a roster with her passes validateDiyPicks', () => {
const data = { chess: CHESS, backups: BACKUPS };
assert.ok(KITTED_CHARS.includes(APHRIS));
for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(APHRIS), `tier ${t}`);
assert.deepEqual(validateDiyPicks({ [SLOT[6]]: { charId: APHRIS, skillIndex: 1, uniEquipId: BLAY } }, { data, kitted: KITTED_CHARS }),
{ ok: true, picks: { [SLOT[6]]: { charId: APHRIS, skillIndex: 1, uniEquipId: BLAY } } });
});
test('链路协议 range: her 中继器 turns the path to its facing and allows 2 more tiles; one attack strikes every enemy of the bent path (air too), none of the straight line past it; the card shows the bent path', () => {
for (const f of FORMS_ALL) {
const { h, u, r } = field({ tier: f[0], elite: f[1], mod: f[2], skill: 1, relay: UP4 });
assert.deepEqual([r.alive, r.dir, r.ownerUnit === u, r.def.id], [true, 'UP', true, RELAY], `${label(f)}: the piece`);
assert.deepEqual(sortN(u.rangeKeys), sortN(BENT.map(([a, b]) => K(a, b))), `${label(f)}: bent path`);
assert.deepEqual(sortN(u.baseRangeKeys), sortN(BENT.map(([a, b]) => K(a, b))), `${label(f)}: the DEFAULT trigger reads it too`);
assert.equal(u.liveRangeGrid.length, BENT.length, `${label(f)}: the card`);
const before = h.spawn('enemy_dummy', { pos: [9, 3] }), past = h.spawn('enemy_fly', { pos: [12, 4] }), straight = h.spawn('enemy_dummy', { pos: [9, 6] });
h.runUntil(() => atkHits(h, u).length >= 2, 4);
const hits = atkHits(h, u);
const first = hits.filter((c) => c.dmg.attackId === hits[0].dmg.attackId);
assert.deepEqual(first.map((c) => c.target.id).sort(), [before.id, past.id].sort(), `${label(f)}: the bent path`);
assert.ok(!hits.some((c) => c.target === straight), `${label(f)}: not the straight line past the piece`);
done(h);
}
});
test('the 中继器 (T1 链路协议): ATK +25 % (T6 BLA-Y +35 %) while it stands; it withdraws after 25 s (T6 BLA-Y 35 s) and comes back on its tile 30 s (T6 BLA-Y 20 s) later for 3 DP; untargetable, 无敌, 禁疗, 孤立, blocks nothing', () => {
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
const { h, u, r } = field({ tier, elite, mod, skill: 1, relay: UP4 });
const y3 = elite && tier === 6 && mod === BLAY;
const v = TOKEN.variants[`${APHRIS}@${statusKey(tier, elite)}`];
const stats = (mod && v.byModule?.[mod]?.stats) || v.stats;
const life = y3 ? 35 : 25, back = y3 ? 20 : 30, atk = y3 ? 0.35 : 0.25;
assert.deepEqual([stats.respawnTime, stats.cost, r.base.respawnTime, r.base.blockCnt], [back, 3, back, 0], `${label(f)}: token data`);
assert.equal(u.def.raw.talents.find((t) => t.index === 0).bb.atk, atk, `${label(f)}: talent`);
assert.deepEqual([r.s.flags.untargetable, r.s.flags.invulnerable, r.s.flags.isolated, r.s.flags.noHeal], [true, true, true, true], `${label(f)}: flags`);
h.run(0.1);
approx(u.s.atk, u.base.atk * (1 + atk), `${label(f)}: ATK +${atk * 100} %`);
h.run(life - 0.3);
assert.ok(r.alive, `${label(f)}: still there before ${life} s`);
const dp0 = h.b.players[0].dp;
h.run(0.4);
assert.ok(!r.alive && !r.removed, `${label(f)}: withdrawn after ${life} s, waiting`);
h.step();
approx(u.s.atk, u.base.atk, `${label(f)}: no bonus without it`);
assert.deepEqual(sortN(u.rangeKeys), sortN(OWN.map(([a, b]) => K(a, b))), `${label(f)}: the straight line again`);
h.run(back - 0.4);
assert.ok(!r.alive, `${label(f)}: not before ${back} s`);
h.run(0.6);
assert.ok(r.alive && r.tileR === 9 && r.tileC === 4 && r.dir === 'UP', `${label(f)}: back on its tile`);
approx(dp0 - h.b.players[0].dp, 3, `${label(f)}: 3 DP`);
h.step();
approx(u.s.atk, u.base.atk * (1 + atk), `${label(f)}: the bonus again`);
done(h);
}
});
test('T2 取样优化: every allied operator standing on her path — her own tile too — ignores 10 RES (法术穿透); none off it', () => {
for (const f of FORMS_ALL) {
const others = [{ uid: 3, chessId: 'chess_char_1_02_a', row: 10, col: 4 }, { uid: 4, chessId: 'chess_char_1_02_a', row: 9, col: 6 }];
const { h, u } = field({ tier: f[0], elite: f[1], mod: f[2], skill: 1, relay: UP4, others });
assert.equal(u.def.raw.talents.find((t) => t.index === 1).bb.magic_resist_penetrate_fixed, 10, label(f));
h.run(0.3);
assert.deepEqual([u.s.resIgnoreFlat, h.unit(3).s.resIgnoreFlat, h.unit(4).s.resIgnoreFlat], [10, 10, 0], `${label(f)}: herself, the one on the bent path, not the one on the straight line`);
done(h);
}
});
test('S1 连续映射 (AUTO, 持续时间无限 — the kit\'s SP_FULL: no enemy needed): at full SP (45 / 40); ATK +90 % / +120 %; a 中继器 whose time runs out stays', () => {
for (const [tier, elite, mod] of [[5, false, null], [6, true, BLAY]]) {
const sk = skillOf(tier, elite, S1);
const { h, u, r } = field({ tier, elite, mod, skill: 0, relay: UP4 });
assert.deepEqual([u.skill.rule, u.skill.kind, u.skill.spCost, sk.bb.atk], ['SP_FULL', 'toggle', elite ? 40 : 45, elite ? 1.2 : 0.9], `T${tier}`);
h.run(sk.spCost - 1);
assert.equal(u.skill.activations, 0, `T${tier}: not before full SP`);
assert.ok(h.runUntil(() => u.skill.active, 2), `T${tier}: cast at full SP, no enemy on the field`);
approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `T${tier}: ATK (no 中继器 bonus: it withdrew at its time)`);
// the piece back (cooldown), then its time runs out while 连续映射 runs: it stays
h.runUntil(() => r.alive, 40);
h.run((elite && tier === 6 ? 35 : 25) + 5);
assert.ok(r.alive, `T${tier}: the 中继器 stays`);
approx(u.s.atk, u.base.atk * (1 + sk.bb.atk + (elite && tier === 6 ? 0.35 : 0.25)), `T${tier}: both bonuses`);
h.run(60);
assert.deepEqual([u.skill.active, u.skill.activations], [true, 1], `T${tier}: infinite`);
done(h);
}
});
test('S2 临界瞬爆 (MANUAL, data DEFAULT, 8 s): no normal attack; from 0.167 s every 0.5 s the beam hits every enemy along an unbounded path — turned by her 中继器 and by any allied operator it reaches — for 90 % / 120 % ATK arts; 16 hits; her range is back after', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S2);
const others = [{ uid: 3, chessId: 'chess_char_1_02_a', row: 11, col: 4, dir: 'RIGHT' }];
const { h, u } = field({ tier, elite, skill: 1, relay: UP4, others });
assert.deepEqual([u.skill.rule, sk.duration, sk.bb.atk_scale, sk.bb.interval, u.skill.spCost], ['DEFAULT', 8, elite ? 1.2 : 0.9, 0.5, elite ? 20 : 22], `T${tier}`);
const inPath = h.spawn('enemy_dummy', { pos: [10, 4] }), afterOp = h.spawn('enemy_fly', { pos: [11, 9] });
const pastOp = h.spawn('enemy_dummy', { pos: [12, 4] }), straight = h.spawn('enemy_dummy', { pos: [9, 6] });
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 4), `T${tier}: cast`);
// the beam: (9,2) → (9,4) 中继器 UP → (11,4) 角峰 RIGHT → the field's edge
for (const [a, b] of [[10, 4], [11, 4], [11, 5], [11, 10], [11, 20]]) assert.ok(u.rangeKeySet.has(K(a, b)), `T${tier}: (${a}, ${b}) on the beam`);
for (const [a, b] of [[12, 4], [9, 5], [9, 6]]) assert.ok(!u.rangeKeySet.has(K(a, b)), `T${tier}: (${a}, ${b}) off it`);
const n0 = atkHits(h, u).length, cast = h.hooksOf('skillStart').at(-1).t;
h.runUntil(() => !u.skill.active, 10);
const endT = h.hooksOf('skillEnd').at(-1).t;
const beam = tagged(h, u, 'aphris:beam');
approx(beam[0].t - cast, 0.167, `T${tier}: the first hit after the Begin clip (0.167 s)`, 0.2);
approx(beam.filter((c) => c.target === inPath)[1].t - beam[0].t, 0.5, `T${tier}: then every 0.5 s`, 0.07);
for (const e of [inPath, afterOp]) {
const mine = beam.filter((c) => c.target === e);
assert.equal(mine.length, 16, `T${tier}: 16 beam hits on ${e.defId}`);
approx(mine[0].amount, u.s.atk * sk.bb.atk_scale, `T${tier}: ${sk.bb.atk_scale * 100} % ATK`);
assert.deepEqual([mine[0].type, mine[0].dmg.isAttack], ['arts', false], `T${tier}: arts, no normal attack`);
}
assert.ok(!beam.some((c) => c.target === pastOp || c.target === straight), `T${tier}: nothing off the beam`);
assert.equal(atkHits(h, u).filter((c) => c.t < endT).length, n0, `T${tier}: no normal attack meanwhile`);
assert.deepEqual(sortN(u.rangeKeys), sortN(BENT.map(([a, b]) => K(a, b))), `T${tier}: the bent 5-1 path again`);
done(h);
}
});
test('S3 混沌的本质 (MANUAL, data DEFAULT, 20 bullets): 3 s 强制缴械 from the cast, every 中继器 withdrawn and back at once; interval 2.9 → 1.8 s, ATK +95 % / +115 %, 140 % / 155 % + 停顿 0.2 / 0.4 s; past a 中继器 +25 % / +30 % ATK and 停顿 +0.2 / +0.4 s; no 中继器 withdraws until it ends, all do then and come back at once', () => {
for (const [tier, elite, mod] of [[5, false, null], [6, true, BLAY]]) {
const sk = skillOf(tier, elite, S3);
const { h, u, r } = field({ tier, elite, mod, skill: 2, relay: UP4 });
const atk = elite && tier === 6 ? 0.35 : 0.25;
assert.deepEqual([u.skill.rule, u.skill.kind, u.skill.ammo, sk.bb.charge_time, sk.bb.base_attack_time, sk.bb.atk, sk.bb['attack@atk_scale'], sk.bb['attack@origin_sluggish'], sk.bb['attack@extra_atk_scale'], sk.bb['attack@extra_sluggish']],
['DEFAULT', 'ammo', 20, 3, -1.1, elite ? 1.15 : 0.95, elite ? 1.55 : 1.4, elite ? 0.4 : 0.2, elite ? 0.3 : 0.25, elite ? 0.4 : 0.2], `T${tier}`);
const before = h.spawn('enemy_dummy', { pos: [9, 3] }), past = h.spawn('enemy_dummy', { pos: [11, 4] }), off = h.spawn('enemy_dummy', { pos: [9, 6] });
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 4), `T${tier}: cast`);
const t0 = h.hooksOf('skillStart').at(-1).t, n0 = atkHits(h, u).length;
assert.ok(h.hooksOf('death').some((c) => c.unit === r && Math.abs(c.t - t0) < 1e-9), `T${tier}: the 中继器 withdrawn at the cast`);
assert.ok(h.runUntil(() => r.alive, 0.5), `T${tier}: and back at once (redeploy time cleared)`);
h.run(2.8 - (h.b.time - t0));
assert.equal(atkHits(h, u).length, n0, `T${tier}: 强制缴械 3 s`);
approx(u.s.interval, 1.8, `T${tier}: 1.8 s`);
approx(u.s.atk, u.base.atk * (1 + atk + sk.bb.atk), `T${tier}: ATK`);
h.runUntil(() => atkHits(h, u).length > n0, 1);
const hits = atkHits(h, u).slice(n0);
assert.ok(h.b.time - t0 >= 3 - 1e-6, `T${tier}: the first attack after 3 s`);
assert.deepEqual(hits.map((c) => c.target.id).sort(), [before.id, past.id].sort(), `T${tier}: the bent path`);
for (const c of hits) { approx(c.amount, u.s.atk * sk.bb['attack@atk_scale'], `T${tier}: ${sk.bb['attack@atk_scale'] * 100} %`); assert.equal(c.type, 'arts'); }
assert.deepEqual(sortN(u.rangeKeys), sortN(BENT.map(([a, b]) => K(a, b))), `T${tier}: the bent 5-1 path while it runs`);
const extra = tagged(h, u, 'aphris:relay');
assert.deepEqual(extra.map((c) => c.target.id), [past.id], `T${tier}: the extra hit past the 中继器 only`);
approx(extra[0].amount, u.s.atk * sk.bb['attack@extra_atk_scale'], `T${tier}: +${sk.bb['attack@extra_atk_scale'] * 100} % ATK`);
const slow = (e) => h.hooksOf('statusApplied').filter((c) => c.source === u && c.status === 'sluggish' && c.target === e).at(-1)?.duration;
approx(slow(before), sk.bb['attack@origin_sluggish'], `T${tier}: 停顿 before it`);
approx(slow(past), sk.bb['attack@origin_sluggish'] + sk.bb['attack@extra_sluggish'], `T${tier}: 停顿 past it`);
assert.ok(!hits.some((c) => c.target === off));
// its time runs out during the skill: it stays; all go when the skill ends — and come back at once (redeploy time
// cleared again), with their normal time
h.runUntil(() => !u.skill.active, 60);
assert.equal(atkHits(h, u).filter((c) => c.dmg.isSkill).length, 2 * 20, `T${tier}: 20 bullets (2 targets each)`);
const end = h.hooksOf('skillEnd').at(-1).t;
assert.ok(end - t0 > (elite && tier === 6 ? 35 : 25), `T${tier}: it outlasted the piece's time`);
assert.ok(h.hooksOf('death').some((c) => c.unit === r && Math.abs(c.t - end) < 1e-9), `T${tier}: withdrawn at the end`);
assert.ok(h.runUntil(() => r.alive, 0.5), `T${tier}: back at once`);
const back = h.b.time;
h.run((elite && tier === 6 ? 35 : 25) + 0.5);
assert.ok(h.hooksOf('death').some((c) => c.unit === r && c.t > back), `T${tier}: and withdraws at its time again`);
done(h);
}
});
test('BLA-Y “第三相态” (stages 1 and 3): the 部署费用 attribute (34 → 26); stage 3 only: 35 s 中继器, its redeploy time 30 → 20 s (再部署时间变短), ATK +35 %', () => {
for (const tier of [5, 6]) {
for (const mod of [null, BLAY]) {
const { h, u, r } = field({ tier, elite: true, mod, relay: UP4 });
assert.equal(u.base.cost, mod ? 26 : 34, `T${tier} ${mod}: cost`);
const y3 = tier === 6 && mod === BLAY;
assert.deepEqual(r.def.talents.map((t) => t.bb.duration), y3 ? [25, 35] : [25], `T${tier} ${mod}: the piece's talents`);
assert.equal(r.base.respawnTime, y3 ? 20 : 30, `T${tier} ${mod}: redeploy time`);
if (y3) assert.match(u.def.raw.talents.find((t) => t.index === 0).desc, /35秒.*再部署时间变短.*攻击力\+35%/);
done(h);
}
}
});
test('链路协议: her 中继器 leaves the field with her (die_to_kill_token) and is back on its tile as soon as she is (charge_token[born]), not before', () => {
const { h, u, r } = field({ tier: 6, elite: true, mod: BLAY, skill: 1, relay: UP4 });
h.run(2);
assert.ok(r.alive);
const e = h.spawn('enemy_dummy', { pos: [9, 3] });
h.b.dealDamage(e, u, { amount: 1e7, type: 'true' });
assert.equal(u.alive, false, 'knocked out');
assert.ok(!r.alive && !r.removed, 'the piece withdrawn with her');
h.run(40);
assert.ok(!r.alive, 'not while she is down (its redeploy time is long over)');
h.runUntil(() => u.alive, 60);
assert.ok(h.runUntil(() => r.alive, 0.5), 'back with her');
assert.ok(r.tileR === 9 && r.tileC === 4 && r.dir === 'UP', 'on its tile');
done(h);
});
+306
View File
@@ -0,0 +1,306 @@
// test/content/op_bgsnow.test.js — the 自选 operator kit of 鸿雪 (char_4055_bgsnow, 6★ 重射手; kit
// server/sim/content/kits/ops/op-bgsnow.js) and of her summon “打字机” (token_10026_bgsnow_subbow), fielded the production
// way (a DIY slot + its `diy` pick, simdata getDiy; the typewriter as the placed hand piece of her player) in every form:
// tiers 5 / 6, normal (E2 Lv1, skill rank 4, no module) and elite (E2 Lv60, rank 7) with no module, ARC-Y “打字机色带” or
// ARC-X “仪式感” at stage 1 (tier 5) / 3 (tier 6). Numbers from data/backups.json; the fidelity checklist of kits/README.md.
// Run: node --test test/content/op_bgsnow.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
import { KITTED_CHARS, OPERATOR_KITS, KITS } from '../../server/sim/content/kits/index.js';
import { diyPool, validateDiyPicks } from '../../shared/diy.js';
const load = (f) => JSON.parse(readFileSync(new URL(`../../data/${f}.json`, import.meta.url), 'utf8'));
const CHESS = load('chess');
const BACKUPS = load('backups');
const SNOW = 'char_4055_bgsnow';
const TOK = 'token_10026_bgsnow_subbow';
const FORMS = BACKUPS.units[SNOW].forms;
const TOKREC = BACKUPS.tokens[TOK];
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
const Y = 'uniequip_002_bgsnow', X = 'uniequip_003_bgsnow';
const S1 = 'skchr_bgsnow_1', S2 = 'skchr_bgsnow_2', S3 = 'skchr_bgsnow_3';
const statusOf = (tier, elite) => (elite ? (tier === 5 ? '2/60/7/1' : '2/60/7/3') : '2/1/4/0');
const formOf = (tier, elite) => FORMS[statusOf(tier, elite)];
const skillOf = (tier, elite, id) => formOf(tier, elite).skills.find((s) => s.skillId === id);
const modOf = (tier, mod) => (mod ? formOf(tier, true).modules.find((m) => m.uniEquipId === mod) : null);
/** The typewriter's variant of a form, with the pick's skill / module (getDiyToken). */
const tokOf = (tier, elite, skill, mod) => {
let v = TOKREC.variants[`${SNOW}@${statusOf(tier, elite)}`];
if (v.bySkill?.[skill]) v = { ...v, ...v.bySkill[skill] };
if (mod && v.byModule?.[mod]) v = { ...v, ...v.byModule[mod] };
return v;
};
const approx = (a, b, msg, eps = 1e-6) => assert.ok(Math.abs(a - b) <= eps * Math.max(1, Math.abs(b)), `${msg}: ${a} vs ${b}`);
const dummy = (key, o = {}) => enemyRec({ key, hp: 1e9, speed: 0, mass: 0, ...o });
const ENEMIES = { enemy_dummy: dummy('enemy_dummy'), enemy_fly: dummy('enemy_fly', { motion: 'FLY' }) };
const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, Y, X].map((m) => [t, true, m]))];
/** 鸿雪 as uid 1 at (10, 5) facing RIGHT; `tok` = the typewriter piece's tile (uid 2) or null. */
function field({ tier = 5, elite = false, mod = null, skill = 0, tok = null, tokFirst = false, seed = 5, others = [] } = {}) {
const op = { uid: 1, diy: { slot: SLOT[tier], charId: SNOW, skillIndex: skill, uniEquipId: mod }, elite, row: 10, col: 5 };
const piece = tok ? [{ uid: 2, kind: 'token', tokenId: TOK, ownerUid: 1, row: tok[0], col: tok[1] }] : [];
const h = makeBattle({
defs: { enemies: ENEMIES }, timeLimit: 900, autoFinish: false, seed,
flags: { dpPerSec: 0, dpMax: 999 }, hooks: ['damaged', 'skillStart', 'skillEnd', 'statusApplied', 'death', 'deploy', 'attack', 'dodge'], captureNoisy: true,
units: tokFirst ? [...piece, op, ...others] : [op, ...piece, ...others],
});
h.step();
return { h, u: h.unit(1), t: h.b.allyUnits.find((a) => a.kind === 'token' && a.defId === TOK) ?? null };
}
const from = (h, src) => h.hooksOf('damaged').filter((c) => c.source === src);
function done(h) {
checkInvariants(h.b);
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
}
const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`;
test('鸿雪 in every 自选 form: her kit, the form\'s stats + module attributes, 3-6, ranged physical, hits air, blocks 1, ground-targetable, the data triggers; offered as a pick', () => {
assert.equal(OPERATOR_KITS[SNOW], KITS[SNOW]);
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
for (const skill of [0, 1, 2]) {
const { h, u } = field({ tier, elite, mod, skill });
const form = formOf(tier, elite), m = elite ? modOf(tier, mod) : null;
assert.deepEqual([u.def.charId, u.def.diyFor, u.skill.id, !!u.kit.generic, u.kit.skillSource], [SNOW, SLOT[tier], form.skills[skill].skillId, false, 'skills'], label(f));
assert.deepEqual([u.base.maxHp, u.base.atk, u.base.def, u.base.respawnTime],
[form.stats.maxHp + (m?.attr.maxHp ?? 0), form.stats.atk + (m?.attr.atk ?? 0), form.stats.def + (m?.attr.def ?? 0), form.stats.respawnTime + (m?.attr.respawnTime ?? 0)], `${label(f)}: stats`);
assert.deepEqual([u.s.blockCnt, u.profile.attack, u.profile.canHitFly, u.profile.dmgType, u.base.bat], [1, 'ranged', true, 'phys', 1.6], `${label(f)}: 重射手`);
assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: 3-6`);
assert.equal(u.skill.rule, form.skills[skill].trigger.rule, `${label(f)}: the data's trigger`);
assert.ok(!u.s.flags.liftoff && !u.s.flags.camou && !u.s.flags.stealth, `${label(f)}: ground enemies target her`);
done(h);
}
}
assert.deepEqual(formOf(6, true).skills.map((s) => s.trigger.rule), ['DEFAULT', 'SKILL_RANGE', 'ACTIVE_RANGE']);
assert.deepEqual([modOf(5, Y).attr, modOf(6, Y).attr, modOf(5, X).attr, modOf(6, X).attr],
[{ maxHp: 130, atk: 55 }, { maxHp: 170, atk: 75 }, { atk: 80, def: 10, respawnTime: -25 }, { atk: 120, def: 16, respawnTime: -25 }]);
const data = { chess: CHESS, backups: BACKUPS };
assert.ok(KITTED_CHARS.includes(SNOW) && [5, 6].every((t) => diyPool(t, { data, kitted: KITTED_CHARS }).includes(SNOW)));
assert.equal(validateDiyPicks({ [SLOT[5]]: { charId: SNOW, skillIndex: 1, uniEquipId: X } }, { data, kitted: KITTED_CHARS }).ok, true);
});
test('S1 抑扬格 (AUTO, attack SP 29 / 25, data DEFAULT): after that many attacks ATK +35 % / +50 % until she leaves the field, every attack meanwhile 30 % to deal ×145 % / ×160 %', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S1);
assert.deepEqual([sk.spCost, sk.bb.atk, sk.bb.prob, sk.bb.atk_scale], elite ? [25, 0.5, 0.3, 1.6] : [29, 0.35, 0.3, 1.45], `T${tier}`);
const { h, u } = field({ tier, elite, skill: 0, seed: 12 });
assert.deepEqual([u.skill.kind, u.skill.rule, u.skill.spType], ['toggle', 'DEFAULT', 'attack'], `T${tier}`);
h.spawn('enemy_dummy', { pos: [11, 6] });
assert.ok(h.runUntil(() => u.skill.active, 60), `T${tier}: cast`);
const before = from(h, u);
assert.equal(before.length, sk.spCost, `T${tier}: after ${sk.spCost} attacks`);
assert.ok(before.every((c) => Math.abs(c.amount - u.base.atk) < 1e-6), `T${tier}: plain before it`);
approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `T${tier}: ATK`);
h.run(500);
assert.ok(u.skill.active, `T${tier}: 持续时间无限`);
const after = from(h, u).slice(before.length);
const atk = u.base.atk * (1 + sk.bb.atk);
for (const c of after) assert.ok([atk, atk * sk.bb.atk_scale].some((v) => Math.abs(c.amount - v) < 1e-6), `T${tier}: ${c.amount}`);
const r = after.filter((c) => c.amount > atk + 1e-6).length / after.length;
assert.ok(Math.abs(r - sk.bb.prob) < 0.06, `T${tier}: ${r}`);
h.b.retreat(u);
assert.ok(!u.skill.active, `T${tier}: ends when she leaves`);
done(h);
}
});
test('S2 点题 (MANUAL, 2 charges, data SKILL_RANGE on 3-1): an enemy 3 tiles ahead (3-1, not 3-6) casts it and is struck at once: atk_scale × ATK as a 普通伤害 attack, then twice as 持续伤害 (not dodgeable); her card range stays 3-6', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S2);
assert.deepEqual([sk.bb.atk_scale, sk.bb.respawn_time, sk.maxChargeTime, sk.spCost, sk.initSp], elite ? [2, 0.6, 2, 10, 6] : [1.8, 0.65, 2, 11, 3], `T${tier}`);
const { h, u } = field({ tier, elite, skill: 1, seed: 3 });
assert.deepEqual([u.skill.kind, u.skill.rule, u.skill.maxCharges], ['charges', 'SKILL_RANGE', 2], `T${tier}`);
u.skill.gainSp(999);
h.run(1);
assert.equal(u.skill.activations, 0, `T${tier}: nobody in range`);
const e = h.spawn('enemy_dummy', { pos: [10, 8] });
h.b.addBuff(e, { key: 'test:dodge', mods: { dodgePhys: 1 } });
assert.ok(h.runUntil(() => u.skill.activations === 1, 1), `T${tier}: cast for the enemy on 3-1`);
const tc = h.b.time;
assert.deepEqual(u.liveRangeGrid, formOf(tier, elite).rangeGrid, `T${tier}: the card keeps 3-6`);
h.run(0.5);
const atk = h.hooksOf('attack').filter((c) => c.attacker === u);
approx(atk[0].t, tc, `T${tier}: 立即`, 0.04);
assert.equal(atk[0].isSkill, true);
const dodged = h.hooksOf('dodge').filter((c) => c.source === u);
const hits = from(h, u);
assert.equal(dodged.length, 1, `T${tier}: the 普通伤害 hit can be dodged (a dodge enemy)`);
assert.equal(hits.length, 2, `T${tier}: the two 持续伤害 hits cannot`);
for (const c of hits) {
approx(c.amount, u.s.atk * sk.bb.atk_scale, `T${tier}: ×${sk.bb.atk_scale}`);
assert.deepEqual([c.dmg.isAttack, c.dmg.canDodge, c.dmg.tags.includes('dot'), c.type], [false, false, true, 'phys']);
}
assert.equal(u.skill.charges, 1, `T${tier}: one of two charges spent`);
h.b.removeBuff(e, 'test:dodge');
const n0 = from(h, u).length;
h.run(3.5);
const second = from(h, u).slice(n0).filter((c) => c.dmg.isSkill);
assert.deepEqual(second.map((c) => [c.dmg.isAttack, c.dmg.tags.includes('dot')]), [[true, false], [false, true], [false, true]], `T${tier}: the second charge 3 s later: 普通 then 2 × 持续`);
done(h);
}
});
test('S3 锐笔速写 (MANUAL, data ACTIVE_RANGE on 3-1): an enemy only on [0,3] casts it; range 3-1 (and back), interval 1.6 − 0.6 s, ×155 % / 170 % off her line, ×185 % / 215 % on the 正前方3格', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S3);
assert.deepEqual([sk.duration, sk.bb.base_attack_time, sk.bb.atk_scale, sk.bb['bgsnow_s_3[atk_up].atk_scale'], sk.rangeId], elite ? [27, -0.6, 1.7, 2.15, '3-1'] : [26, -0.6, 1.55, 1.85, '3-1'], `T${tier}`);
const { h, u } = field({ tier, elite, skill: 2, seed: 7 });
assert.deepEqual([u.skill.kind, u.skill.rule], ['duration', 'ACTIVE_RANGE']);
u.skill.gainSp(999);
const far = h.spawn('enemy_dummy', { pos: [10, 8] });
assert.ok(h.runUntil(() => u.skill.active, 1), `T${tier}: cast for [0,3] (outside 3-6)`);
assert.deepEqual(u.liveRangeGrid, sk.rangeGrid, `T${tier}: 3-1 while it runs`);
approx(u.s.interval, 1.0, `T${tier}: interval`);
const side = h.spawn('enemy_dummy', { pos: [11, 7] });
h.b.kill(far, null);
const front = h.spawn('enemy_dummy', { pos: [10, 7] });
h.run(8);
const onFront = from(h, u).filter((c) => c.dmg.isAttack && c.target === front);
h.b.kill(front, null);
h.run(4);
const onSide2 = from(h, u).filter((c) => c.dmg.isAttack && c.target === side);
assert.ok(onFront.length > 3 && onSide2.length > 1, `T${tier}: both struck (${onFront.length} / ${onSide2.length})`);
for (const c of onFront) approx(c.amount, u.base.atk * sk.bb['bgsnow_s_3[atk_up].atk_scale'], `T${tier}: 正前方3格`);
for (const c of onSide2) approx(c.amount, u.base.atk * sk.bb.atk_scale, `T${tier}: off the line`);
u.skill.extend(-999);
h.step(2);
assert.deepEqual(u.liveRangeGrid, formOf(tier, elite).rangeGrid, `T${tier}: back to 3-6`);
approx(u.s.interval, 1.6, `T${tier}: interval back`);
done(h);
}
});
test('ARC-Y 打字机色带 trait: ×105 % and no dodge on her straight line ahead (stages 1 and 3) — 点题\'s 持续伤害 hits too; off the line it dodges; ARC-X / no module nothing', () => {
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
const y = elite && mod === Y;
const { h, u } = field({ tier, elite, mod, skill: 1, seed: 3 });
u.skill.charges = 0; u.skill.sp = 0;
h.b.addBuff(u, { key: 'test:noSp', flags: { noSp: true } });
const front = h.spawn('enemy_dummy', { pos: [10, 7] });
const side = h.spawn('enemy_dummy', { pos: [11, 7] });
for (const e of [front, side]) h.b.addBuff(e, { key: 'test:dodge', mods: { dodgePhys: 1 } });
h.run(12);
const onFront = from(h, u).filter((c) => c.target === front);
if (y) {
assert.ok(onFront.length > 4, `${label(f)}: the front enemy is hit`);
for (const c of onFront) approx(c.amount, u.s.atk * 1.05, `${label(f)}: ×1.05`);
// 点题 on the front enemy: all three hits ×1.05
h.b.removeBuff(u, 'test:noSp');
u.skill.addCharge(1);
const n0 = from(h, u).length;
h.run(0.5);
const s2 = from(h, u).slice(n0).filter((c) => c.dmg.isSkill);
assert.equal(s2.length, 3, `${label(f)}: 点题's three hits land`);
for (const c of s2) approx(c.amount, u.s.atk * skillOf(tier, true, S2).bb.atk_scale * 1.05, `${label(f)}: 点题 ×1.05`);
h.b.kill(front, null);
h.run(6);
assert.ok(h.hooksOf('dodge').filter((c) => c.source === u && c.target === side).length > 2, `${label(f)}: off the line it dodges`);
} else {
assert.equal(onFront.length, 0, `${label(f)}: every attack dodged`);
}
done(h);
}
});
test('“打字机”: her placed piece runs its kit (not the generic one) — the variant\'s stats (ARC-Y stage 3 stronger), untargetable, 3-2, blocks 0, hits air; deploys with the board, leaves after 25 s, back on its tile after 40 s (S2: ×65 % / ×60 %) for 5 DP, never while 鸿雪 is down', () => {
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
for (const skill of [0, 1, 2]) {
const v = tokOf(tier, elite, skill, elite ? mod : null);
const { h, t } = field({ tier, elite, mod, skill, tok: [12, 3], tokFirst: skill === 1 });
assert.ok(t && t.alive && t.deployed, `${label(f)} S${skill + 1}: deployed with the board`);
assert.deepEqual([!!t.kit.generic, t.skill.id, t.base.atk, t.base.maxHp, t.base.respawnTime, t.base.cost], [false, v.skill.skillId, v.stats.atk, v.stats.maxHp, 40, 5], `${label(f)} S${skill + 1}`);
assert.deepEqual([!!t.s.flags.untargetable, t.s.blockCnt, t.profile.canHitFly, t.profile.attack, t.liveRangeGrid], [true, 0, true, 'ranged', [[0, 0], [0, 1], [0, 2], [0, 3]]], `${label(f)} S${skill + 1}: 不会受到攻击`);
done(h);
}
}
assert.deepEqual([tokOf(5, false, 0).stats.atk, tokOf(5, true, 0).stats.atk, tokOf(6, true, 0, Y).stats.atk, tokOf(5, true, 0, Y).stats.atk], [666, 799, 869, 799]);
// life and redeploys (S1: 40 s; S2: ×respawn_time)
for (const [tier, elite, skill, mul] of [[5, false, 0, 1], [5, false, 1, 0.65], [6, true, 1, 0.6]]) {
const { h, u, t } = field({ tier, elite, skill, tok: [12, 3] });
h.b.players[0].dp = 20;
assert.ok(h.runUntil(() => !t.alive, 26));
approx(h.b.time, 25, `T${tier} S${skill + 1}: 25 s`, 0.05);
assert.equal(h.hooksOf('death').find((c) => c.unit === t).reason, 'expired');
assert.ok(h.runUntil(() => t.alive, 60));
approx(h.b.time, 25 + 40 * mul, `T${tier} S${skill + 1}: back after ${40 * mul} s`, 0.3);
assert.deepEqual([t.tileR, t.tileC, h.b.players[0].dp], [12, 3, 15], `T${tier} S${skill + 1}: on its tile, 5 DP`);
assert.ok(u.alive);
done(h);
}
// 鸿雪 down: the typewriter waits for her
const { h, u, t } = field({ tier: 5, elite: false, skill: 0, tok: [12, 3] });
h.b.players[0].dp = 0;
h.run(10);
h.b.kill(u, null);
h.run(60);
assert.ok(!t.alive, 'not while 鸿雪 is down');
h.b.players[0].dp = 25;
assert.ok(h.runUntil(() => u.alive, 30), '鸿雪 back');
assert.ok(h.runUntil(() => t.alive, 1), 'then the typewriter');
done(h);
});
test('“打字机”\'s skill is the copy of hers, free once per deployment: S1 ATK +35 % / +50 % for good with the 30 % proc, S2 three hits of ×180 % / ×200 % (普通 + 2 × 持续), S3 4-1 + interval −0.6 s + ×185 % / ×215 % for 16 / 17 s', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
for (const skill of [0, 1, 2]) {
const v = tokOf(tier, elite, skill, null);
const tb = v.skill.bb;
const { h, t } = field({ tier, elite, skill, tok: [11, 5], seed: 4 });
h.spawn('enemy_dummy', { pos: [11, 7] });
assert.deepEqual([t.skill.rule, t.skill.spCost, t.skill.charges], ['DEFAULT', 0, 1], `T${tier} S${skill + 1}: free, ready`);
assert.ok(h.runUntil(() => t.skill.activations === 1, 1), `T${tier} S${skill + 1}: cast at its first attack`);
if (skill === 0) {
approx(t.s.atk, t.base.atk * (1 + tb.atk), `T${tier}: S1 copy ATK`);
h.run(20);
assert.ok(t.skill.active, `T${tier}: S1 copy stays`);
const hits = from(h, t);
const atk = t.base.atk * (1 + tb.atk);
assert.ok(hits.some((c) => Math.abs(c.amount - atk * tb.atk_scale) < 1e-6) && hits.some((c) => Math.abs(c.amount - atk) < 1e-6), `T${tier}: S1 copy procs ×${tb.atk_scale}`);
} else if (skill === 1) {
h.run(0.5);
const s2 = from(h, t).filter((c) => c.dmg.isSkill);
assert.deepEqual(s2.map((c) => [c.dmg.isAttack, c.dmg.tags.includes('dot')]), [[true, false], [false, true], [false, true]], `T${tier}: S2 copy`);
for (const c of s2) approx(c.amount, t.base.atk * tb.atk_scale, `T${tier}: S2 copy ×${tb.atk_scale}`);
} else {
assert.deepEqual(t.liveRangeGrid, [[0, 0], [0, 1], [0, 2], [0, 3], [0, 4]], `T${tier}: S3 copy 4-1`);
approx(t.s.interval, 1.0, `T${tier}: S3 copy interval`);
approx(t.skill.timeLeft, v.skill.duration, `T${tier}: ${v.skill.duration} s`, 0.05);
h.run(3);
for (const c of from(h, t).filter((x) => x.dmg.isSkill)) approx(c.amount, t.base.atk * tb['attack@atk_scale'], `T${tier}: S3 copy ×${tb['attack@atk_scale']}`);
}
h.run(24);
assert.equal(t.skill.activations, 1, `T${tier} S${skill + 1}: once per deployment`);
done(h);
}
}
});
test('弱点速记: every damage of the typewriter cuts the target\'s DEF 18 % for 4 s, 23 % on one of the 4 tiles next to 鸿雪 (not diagonal); ARC-X stage 3: 28 % / 33 % and the 8 tiles; 鸿雪 down ⇒ the base cut', () => {
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
const x3 = tier === 6 && elite && mod === X;
const tal = tokOf(tier, elite, 2, elite ? mod : null).talents.find((x) => x && x.bb.duration);
const c1 = -tal.bb['bgsnow_token[def_down]_1.def'], c2 = -tal.bb['bgsnow_token[def_down]_2.def'];
assert.deepEqual([c1, c2, tal.bb.duration], x3 ? [0.28, 0.33, 4] : [0.18, 0.23, 4], label(f));
for (const [tile, near] of [[[11, 5], true], [[11, 4], x3], [[12, 3], false]]) {
const { h, u, t } = field({ tier, elite, mod, skill: 0, tok: tile, seed: 2 });
h.spawn('enemy_dummy', { pos: [tile[0], tile[1] + 2] });
h.run(4);
const cuts = h.hooksOf('statusApplied').filter((c) => c.source === t && c.status === 'defDown');
assert.ok(cuts.length >= 2, `${label(f)} ${tile}: cuts`);
for (const c of cuts) assert.deepEqual([c.value, c.duration], [near ? c2 : c1, 4], `${label(f)} ${tile}: ${near ? 'near' : 'apart'}`);
if (near) {
h.b.kill(u, null);
h.run(3);
const after = h.hooksOf('statusApplied').filter((c) => c.source === t && c.status === 'defDown').slice(cuts.length);
assert.ok(after.length && after.every((c) => c.value === c1), `${label(f)}: 鸿雪 down ⇒ ${c1}`);
}
done(h);
}
}
});
+294
View File
@@ -0,0 +1,294 @@
// test/content/op_cerber.test.js — the 自选 operator kit of 刻俄柏 (char_2013_cerber, 6★ 中坚术师; kit
// server/sim/content/kits/ops/op-cerber.js), fielded the production way (a DIY slot + its `diy` pick, simdata getDiy) in
// every form: tiers 5 / 6, normal (E2 Lv1, skill rank 4, no module) and elite (E2 Lv60, rank 7) with no module, CCR-X
// “很干的面包” or CCR-Y “我打的刀” at stage 1 (tier 5) / 3 (tier 6). Every number is read back from data/backups.json (the
// form of that slot status); the fidelity checklist of kits/README.md item by item.
// Run: node --test test/content/op_cerber.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
import { KITTED_CHARS, OPERATOR_KITS, KITS } from '../../server/sim/content/kits/index.js';
import { diyPool, validateDiyPicks } from '../../shared/diy.js';
const load = (f) => JSON.parse(readFileSync(new URL(`../../data/${f}.json`, import.meta.url), 'utf8'));
const CHESS = load('chess');
const BACKUPS = load('backups');
const ID = 'char_2013_cerber';
const FORMS = BACKUPS.units[ID].forms;
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
const CCRX = 'uniequip_002_cerber', CCRY = 'uniequip_003_cerber';
const S1 = 'skchr_cerber_1', S2 = 'skchr_cerber_2', S3 = 'skchr_cerber_3';
const formOf = (tier, elite) => FORMS[elite ? (tier === 5 ? '2/60/7/1' : '2/60/7/3') : '2/1/4/0'];
const skillOf = (tier, elite, id) => formOf(tier, elite).skills.find((s) => s.skillId === id);
const modOf = (tier, mod) => (mod ? formOf(tier, true).modules.find((m) => m.uniEquipId === mod) : null);
const approx = (a, b, msg, eps = 1e-6) => assert.ok(Math.abs(a - b) <= eps * Math.max(1, Math.abs(b)), `${msg}: ${a} vs ${b}`);
const dummy = (key, o = {}) => enemyRec({ key, hp: 1e9, speed: 0, mass: 0, ...o });
const ENEMIES = {
enemy_dummy: dummy('enemy_dummy'), enemy_fly: dummy('enemy_fly', { motion: 'FLY' }),
enemy_d100: dummy('enemy_d100', { def: 100 }), enemy_d200: dummy('enemy_d200', { def: 200 }), enemy_d300: dummy('enemy_d300', { def: 300 }),
enemy_d400: dummy('enemy_d400', { def: 400 }), enemy_r50: dummy('enemy_r50', { res: 50 }),
enemy_elite: dummy('enemy_elite', { rank: 'ELITE' }), enemy_boss: dummy('enemy_boss', { rank: 'BOSS' }),
};
/** Every 自选 form: [tier, elite, module]. */
const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, CCRX, CCRY].map((m) => [t, true, m]))];
const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`;
const SHELL = 'cerber:shell';
const shellOf = (c) => !!c.dmg?.tags?.includes(SHELL);
/** A battle with 刻俄柏 as uid 1 at (row, col) facing RIGHT, plus `others`. */
function field({ tier = 5, elite = false, mod = null, skill = 2, row = 10, col = 5, others = [], seed = 5, setup = null } = {}) {
const h = makeBattle({
defs: { enemies: ENEMIES }, timeLimit: 900, autoFinish: false, seed,
flags: { dpPerSec: 0, dpMax: 999 }, hooks: ['damaged', 'skillStart', 'skillEnd', 'statusApplied', 'spGain'], captureNoisy: true,
units: [{ uid: 1, diy: { slot: SLOT[tier], charId: ID, skillIndex: skill, uniEquipId: mod }, elite, row, col }, ...others],
setup,
});
h.step();
return { h, u: h.unit(1) };
}
const hitsBy = (h, u) => h.hooksOf('damaged').filter((c) => c.source === u);
const atkHits = (h, u) => hitsBy(h, u).filter((c) => c.dmg?.isAttack);
function done(h) {
checkInvariants(h.b);
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
}
const arts = (amount, res, ignore = 0) => Math.max(amount * (1 - Math.max(0, res - ignore) / 100), amount * 0.05);
test('刻俄柏 in every 自选 form: her operator kit (all three skills authored), the form\'s stats + module attributes, 3-1 range, ranged arts that hits air, block 1, 1.6 s, 空 bonds, no 特质', () => {
assert.equal(OPERATOR_KITS[ID], KITS[ID]);
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
for (const skill of [0, 1, 2]) {
const { h, u } = field({ tier, elite, mod, skill });
const form = formOf(tier, elite), m = elite ? modOf(tier, mod) : null;
assert.deepEqual([u.def.charId, u.def.diyFor, u.skill.id, !!u.kit.generic, u.kit.skillSource], [ID, SLOT[tier], form.skills[skill].skillId, false, 'skills'], label(f));
assert.deepEqual([u.base.maxHp, u.base.atk, u.base.def, u.base.res], [form.stats.maxHp + (m?.attr.maxHp ?? 0), form.stats.atk + (m?.attr.atk ?? 0), form.stats.def + (m?.attr.def ?? 0), 20], `${label(f)}: stats`);
assert.deepEqual([u.s.blockCnt, u.profile.attack, u.profile.dmgType, u.profile.canHitFly, u.profile.groundOnly, u.base.bat], [1, 'ranged', 'arts', true, false, 1.6], `${label(f)}: 中坚术师`);
assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: 3-1`);
assert.deepEqual([u.def.bonds, u.def.raw.garrisonIds, u.def.raw.tokens], [['emptyShip'], [], []], `${label(f)}: bonds / 特质 / no summon`);
assert.ok(!u.s.flags.liftoff && !u.s.flags.camou && !u.s.flags.stealth && !u.s.flags.untargetable, `${label(f)}: ground enemies target her`);
done(h);
}
}
// E2 Lv1 1220 / 553 / 114, E2 Lv60 1449 / 629 / 123; CCR-X +130 / +40 → +180 / +65, CCR-Y +80 / +40 / +17 → +120 / +56 / +23
assert.deepEqual([FORMS['2/1/4/0'].stats.maxHp, FORMS['2/1/4/0'].stats.atk, FORMS['2/60/7/1'].stats.maxHp, FORMS['2/60/7/1'].stats.atk], [1220, 553, 1449, 629]);
assert.deepEqual([modOf(5, CCRX).attr, modOf(6, CCRX).attr, modOf(5, CCRY).attr, modOf(6, CCRY).attr], [{ maxHp: 130, atk: 40 }, { maxHp: 180, atk: 65 }, { maxHp: 80, atk: 40, def: 17 }, { maxHp: 120, atk: 56, def: 23 }]);
});
test('a 自选 pick: 刻俄柏 is offered at tiers 5 and 6 and a roster with her passes validateDiyPicks', () => {
const data = { chess: CHESS, backups: BACKUPS };
assert.ok(KITTED_CHARS.includes(ID));
for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(ID), `tier ${t}`);
assert.deepEqual(validateDiyPicks({ [SLOT[5]]: { charId: ID, skillIndex: 0, uniEquipId: CCRX } }, { data, kitted: KITTED_CHARS }),
{ ok: true, picks: { [SLOT[5]]: { charId: ID, skillIndex: 0, uniEquipId: CCRX } } });
});
test('S1 “很冰的斧” (AUTO, 1 / 2 charges, data DEFAULT): the next attack at 165 % / 180 % ATK arts on an unblocked enemy rather than a blocked one, binding it 2 / 2.5 s', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S1);
// 角峰 blocks the dummy spawned on its tile; the other dummy is free (both in her range; the blocked one is nearer its goal)
const { h, u } = field({ tier, elite, skill: 0, others: [{ uid: 2, chessId: 'chess_char_1_02_a', row: 10, col: 7 }] });
assert.deepEqual([u.skill.rule, u.skill.kind, u.skill.maxCharges, sk.spCost, sk.bb.atk_scale, sk.bb.duration], ['DEFAULT', elite ? 'charges' : 'instant', elite ? 2 : 1, 7, elite ? 1.8 : 1.65, elite ? 2.5 : 2], `T${tier}`);
u.skill.charges = 0;
u.skill.sp = 0;
const blocked = h.spawn('enemy_dummy', { pos: [10, 7] });
const free = h.spawn('enemy_dummy', { pos: [11, 7] });
h.run(0.2);
assert.equal(blocked.blockedBy, h.unit(2), `T${tier}: 角峰 blocks it`);
const n0 = atkHits(h, u).length;
h.run(1.7);
const plain = atkHits(h, u).slice(n0);
assert.ok(plain.length >= 1 && plain.every((c) => c.target === blocked && !c.dmg.isSkill), `T${tier}: her plain attacks go to the blocked one`);
u.skill.gainSp(999);
const n1 = atkHits(h, u).length;
assert.ok(h.runUntil(() => atkHits(h, u).slice(n1).some((c) => c.dmg.isSkill), 3), `T${tier}: cast`);
const sh = atkHits(h, u).slice(n1).find((c) => c.dmg.isSkill);
assert.equal(sh.target, free, `T${tier}: "优先攻击没有被阻挡的目标"`);
approx(sh.amount, u.s.atk * sk.bb.atk_scale, `T${tier}: ${sk.bb.atk_scale * 100} % ATK`);
assert.equal(sh.type, 'arts');
const bind = h.hooksOf('statusApplied').find((c) => c.source === u && c.status === 'bind');
assert.ok(bind && bind.target === free, `T${tier}: bound`);
approx(bind.duration, sk.bb.duration, `T${tier}: 束缚 ${sk.bb.duration} s`);
done(h);
}
});
test('S2 “很热的刀” (MANUAL, data DEFAULT): 33 / 36 s, attack interval × 0.6 / × 0.4 (PRTS "(*0.4)"), the highest-DEF enemy first; all back after', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S2);
const { h, u } = field({ tier, elite, skill: 1 });
assert.deepEqual([u.skill.rule, sk.duration, sk.bb.base_attack_time, sk.spCost, sk.initSp], ['DEFAULT', elite ? 36 : 33, elite ? 0.4 : 0.6, elite ? 44 : 47, elite ? 16 : 13], `T${tier}`);
h.run(0.6);
const iv0 = u.s.interval;
const d1 = h.spawn('enemy_d100', { pos: [10, 6] }), d3 = h.spawn('enemy_d300', { pos: [11, 7] }), d2 = h.spawn('enemy_d200', { pos: [9, 6] });
void d1; void d2;
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 3), `T${tier}: cast`);
approx(u.skill.timeLeft, sk.duration, `T${tier}: ${sk.duration} s`, 0.05);
approx(u.s.interval, iv0 * sk.bb.base_attack_time, `T${tier}: interval × ${sk.bb.base_attack_time}`);
const n0 = atkHits(h, u).length;
h.run(5);
const hits = atkHits(h, u).slice(n0);
assert.ok(hits.length >= Math.floor(5 / u.s.interval) - 1, `T${tier}: ${hits.length} attacks`);
assert.ok(hits.every((c) => c.target === d3), `T${tier}: the DEF 300 enemy`);
assert.ok(h.runUntil(() => !u.skill.active, sk.duration));
approx(u.s.interval, iv0, `T${tier}: interval back`);
done(h);
}
});
test('S3 “很重的枪” (MANUAL, data ACTIVE_RANGE on 3-3): 56 / 57 s, range 3-3 while on, ATK +130 % / +160 %, physical, the lowest-DEF enemy first, each hit silences 2.5 / 3 s; arts and 3-1 again after', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S3);
const { h, u } = field({ tier, elite, skill: 2, seed: 7 });
assert.deepEqual([u.skill.rule, sk.trigger.rawRule, sk.rangeId, sk.duration, sk.bb.atk, sk.bb['attack@silence'], sk.initSp], ['ACTIVE_RANGE', 'DEFAULT', '3-3', elite ? 57 : 56, elite ? 1.6 : 1.3, elite ? 3 : 2.5, elite ? 47 : 41], `T${tier}`);
assert.deepEqual(u.skill.triggerGrid, sk.rangeGrid, `T${tier}: the trigger grid is 3-3`);
h.run(0.6);
const atk0 = u.s.atk;
u.skill.gainSp(999);
// (9,8) / (11,8) are on 3-3, not on her 3-1: the cast comes with an enemy there
const hi = h.spawn('enemy_d300', { pos: [9, 8] });
const lo = h.spawn('enemy_d100', { pos: [11, 8] });
assert.ok(h.runUntil(() => u.skill.active, 2), `T${tier}: cast with enemies on 3-3 only`);
assert.deepEqual(u.liveRangeGrid, sk.rangeGrid, `T${tier}: 3-3 while on`);
approx(u.s.atk, atk0 + u.base.atk * sk.bb.atk, `T${tier}: ATK`);
const n0 = atkHits(h, u).length;
h.run(5);
const hits = atkHits(h, u).slice(n0);
assert.ok(hits.length >= 2 && hits.every((c) => c.target === lo && c.type === 'phys'), `T${tier}: physical, on the lowest DEF`);
approx(hits[0].amount, Math.max(u.s.atk - 100, u.s.atk * 0.05), `T${tier}: physical on DEF 100`);
const sil = h.hooksOf('statusApplied').filter((c) => c.source === u && c.status === 'silence');
assert.ok(sil.length >= hits.length && sil.every((c) => c.target === lo), `T${tier}: silenced on every hit`);
approx(sil[0].duration, sk.bb['attack@silence'], `T${tier}: ${sk.bb['attack@silence']} s`);
// 剥壳 stays arts: DEF 100 × 40 %
const shells = hitsBy(h, u).slice(n0).filter(shellOf);
assert.ok(shells.length >= hits.length - 1 && shells.every((c) => c.type === 'arts'), `T${tier}: 剥壳 arts`);
u.skill.end('test');
h.run(0.1);
assert.deepEqual(u.liveRangeGrid, formOf(tier, elite).rangeGrid, `T${tier}: back to 3-1`);
void hi;
done(h);
}
});
test('T1 剥壳: each hit of her attacks adds DEF × 40 % arts (none on DEF 0); CCR-X stage 3: 50 % +5 % per hit on the same target up to 75 %, back to 50 % on another; stage 1 keeps 40 %', () => {
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
const { h, u } = field({ tier, elite, mod, skill: 1 });
const x3 = elite && tier === 6 && mod === CCRX;
const t0 = u.def.raw.talents.find((t) => t.index === 0);
assert.equal(t0.bb.atk_scale, x3 ? 0.5 : 0.4, `${label(f)}: the data`);
u.skill.charges = 0;
const a = h.spawn('enemy_d400', { pos: [10, 6] });
const n0 = hitsBy(h, u).length;
h.run(1.6 * 8 + 0.4);
const shells = hitsBy(h, u).slice(n0).filter((c) => shellOf(c) && c.target === a);
const plain = hitsBy(h, u).slice(n0).filter((c) => c.dmg.isAttack && c.target === a);
assert.ok(plain.length >= 7 && shells.length === plain.length, `${label(f)}: one 剥壳 per hit (${shells.length} / ${plain.length})`);
const want = x3 ? [0.5, 0.55, 0.6, 0.65, 0.7, 0.75, 0.75] : Array(7).fill(0.4);
shells.slice(0, 7).forEach((c, i) => {
approx(c.amount, 400 * want[i], `${label(f)}: hit ${i + 1}`);
assert.deepEqual([c.type, !!c.dmg.isAttack], ['arts', false], `${label(f)}: a talent arts instance`);
});
// another target: back to the base ratio; a DEF-0 target takes no 剥壳
h.b.kill(a, null);
const b = h.spawn('enemy_d200', { pos: [10, 6] });
const n1 = hitsBy(h, u).length;
h.run(1.7);
const sb = hitsBy(h, u).slice(n1).filter((c) => shellOf(c) && c.target === b);
assert.ok(sb.length >= 1);
approx(sb[0].amount, 200 * (x3 ? 0.5 : 0.4), `${label(f)}: a new target — the base ratio`);
h.b.kill(b, null);
const z = h.spawn('enemy_dummy', { pos: [10, 6] });
const n2 = hitsBy(h, u).length;
h.run(1.7);
assert.ok(hitsBy(h, u).slice(n2).some((c) => c.target === z && c.dmg.isAttack), label(f));
assert.ok(!hitsBy(h, u).slice(n2).some((c) => c.target === z && shellOf(c)), `${label(f)}: no 剥壳 on DEF 0`);
done(h);
}
});
test('T2 独行长路: ATK +8 % / ASPD +8 while nobody else is on her x-5 (a device too), off with a neighbour; an enemy-like ally does not switch it off but, seen while it is off, keeps it off until it leaves; CCR-Y stage 3: +15 % / +15 decided once per deployment', () => {
const lone = (u) => u.findBuff('talent:cerber:lone');
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
const y3 = elite && tier === 6 && mod === CCRY;
const { h, u } = field({ tier, elite, mod, skill: 1 });
h.step();
const v = y3 ? { atkPct: 0.15, aspd: 15 } : { atkPct: 0.08, aspd: 8 };
assert.deepEqual(lone(u)?.mods, v, `${label(f)}: alone`);
const dev = h.b.spawnDevice('trap_1105_accrate', 10, 6, { name: 'test crate' });
assert.ok(dev && dev.side === 'ally', label(f));
h.step();
assert.equal(!!lone(u), y3, `${label(f)}: a crate in front of her ${y3 ? '(the deployment decided)' : ''}`);
h.b.kill(dev, null);
h.step();
assert.deepEqual(lone(u)?.mods, v, `${label(f)}: alone again`);
if (!y3) {
// an enemy-like ally (炎佑's kind: an ally built from an enemy record): stays on while on …
const like = h.b.spawnDevice('trap_1105_accrate', 9, 5, { name: 'enemy-like' });
like.defId = 'enemy_9012_acloon';
h.step();
assert.ok(lone(u), `${label(f)}: an enemy-like ally does not switch it off`);
// … but seen while off (an ordinary neighbour came first) it keeps it off until it leaves the field
const mate = h.b.spawnDevice('trap_1105_accrate', 11, 5, { name: 'mate' });
h.step();
assert.equal(lone(u), null, `${label(f)}: off with an ordinary neighbour`);
h.b.kill(mate, null);
h.step();
assert.equal(lone(u), null, `${label(f)}: the enemy-like ally seen while off keeps it off`);
h.b.relocate(like, 12, 9);
h.step();
assert.equal(lone(u), null, `${label(f)}: even away from her`);
h.b.kill(like, null);
h.step();
assert.ok(lone(u), `${label(f)}: on once it left the field`);
}
done(h);
}
// CCR-Y stage 3 with a neighbour at the deployment: none for that deployment, then a lone redeploy gets it
const { h, u } = field({ tier: 6, elite: true, mod: CCRY, skill: 1, others: [{ uid: 2, chessId: 'chess_char_1_08_a', row: 11, col: 5 }] });
h.run(0.5);
assert.equal(lone(u), null, 'a neighbour at the battle-start deployment');
h.b.retreat(h.unit(2));
h.run(0.5);
assert.equal(lone(u), null, 'still none: decided at the deployment');
h.b.retreat(u);
h.b.redeploy(u);
h.step();
assert.deepEqual(lone(u)?.mods, { atkPct: 0.15, aspd: 15 }, 'a lone redeploy');
done(h);
const t1 = formOf(6, true).modules.find((m) => m.uniEquipId === CCRY).talentChanges.find((t) => t.talentIndex === 1);
assert.match(t1.desc, /^部署时若周围四格内没有其他友方单位/);
});
test('modules: CCR-X "无视目标10点法术抗性" at stages 1 and 3 (× 0.6 on RES 50); CCR-Y "普通攻击命中精英或领袖敌人时获得1点技力" — not on a normal enemy, not from 剥壳', () => {
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
const { h, u } = field({ tier, elite, mod, skill: 2 });
const x = elite && mod === CCRX, y = elite && mod === CCRY;
assert.equal(u.s.resIgnoreFlat, x ? 10 : 0, `${label(f)}: RES ignore`);
if (y) assert.deepEqual(u.def.raw.trait.bb, { sp: 1 });
const foe = h.spawn('enemy_r50', { pos: [10, 6] });
const n0 = hitsBy(h, u).length;
h.run(3.5);
const hits = hitsBy(h, u).slice(n0).filter((c) => c.target === foe && c.dmg.isAttack);
assert.ok(hits.length >= 2, label(f));
approx(hits[0].amount, arts(u.s.atk, 50, x ? 10 : 0), `${label(f)}: arts on RES 50`);
const traitSp = () => h.hooksOf('spGain').filter((c) => c.unit === u && c.reason === 'trait').length;
assert.equal(traitSp(), 0, `${label(f)}: no SP from a normal enemy`);
h.b.kill(foe, null);
for (const key of ['enemy_elite', 'enemy_boss']) {
const el = h.spawn(key, { pos: [10, 6] });
const k0 = traitSp(), a0 = atkHits(h, u).filter((c) => c.target === el).length;
h.run(3.5);
const hitsE = atkHits(h, u).filter((c) => c.target === el).length - a0;
assert.ok(hitsE >= 2, label(f));
assert.equal(traitSp() - k0, y ? hitsE : 0, `${label(f)}: +1 SP per hit on an ${key}`);
h.b.kill(el, null);
}
done(h);
}
});
+303
View File
@@ -0,0 +1,303 @@
// test/content/op_cgbird.test.js — the 自选 operator kit of 夜莺 (char_179_cgbird, 6★ 群愈师; kit
// server/sim/content/kits/ops/op-cgbird.js) and her summon 幻影 (token_10003_cgbird_bird), fielded the production way (a DIY
// slot + its `diy` pick, simdata getDiy; the 幻影 as a board token piece of hers) in every form: tiers 5 / 6, normal (E2 Lv1,
// skill rank 4, no module) and elite (E2 Lv60, rank 7) with no module, RIN-X “封闭的希望” or RIN-Y “莺歌” at stage 1
// (tier 5) / 3 (tier 6). Every number is read back from data/backups.json; the fidelity checklist of kits/README.md.
// Run: node --test test/content/op_cgbird.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { makeBattle, enemyRec, chessRec, checkInvariants } from '../helpers/battleHarness.js';
import { KITTED_CHARS, OPERATOR_KITS, KITS } from '../../server/sim/content/kits/index.js';
import { diyPool, validateDiyPicks, diyRecord } from '../../shared/diy.js';
import { attackRangeGrid } from '../../shared/loadoutRecord.js';
const load = (f) => JSON.parse(readFileSync(new URL(`../../data/${f}.json`, import.meta.url), 'utf8'));
const CHESS = load('chess');
const BACKUPS = load('backups');
const CGBIRD = 'char_179_cgbird';
const PHANTOM = 'token_10003_cgbird_bird';
const FORMS = BACKUPS.units[CGBIRD].forms;
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
const RINX = 'uniequip_002_cgbird', RINY = 'uniequip_003_cgbird';
const S1 = 'skcom_heal_up[3]', S2 = 'skchr_cgbird_2', S3 = 'skchr_cgbird_3';
const statusOf = (tier, elite) => (elite ? (tier === 5 ? '2/60/7/1' : '2/60/7/3') : '2/1/4/0');
const formOf = (tier, elite) => FORMS[statusOf(tier, elite)];
const skillOf = (tier, elite, id) => formOf(tier, elite).skills.find((s) => s.skillId === id);
const modOf = (tier, mod) => (mod ? formOf(tier, true).modules.find((m) => m.uniEquipId === mod) : null);
const tokenVariant = (tier, elite, mod) => {
const v = BACKUPS.tokens[PHANTOM].variants[`${CGBIRD}@${statusOf(tier, elite)}`];
return mod && v.byModule?.[mod] ? { ...v, ...v.byModule[mod] } : v;
};
const approx = (a, b, msg, eps = 1e-6) => assert.ok(Math.abs(a - b) <= eps * Math.max(1, Math.abs(b)), `${msg}: ${a} vs ${b}`);
const ENEMIES = { enemy_dummy: enemyRec({ key: 'enemy_dummy', hp: 1e9, speed: 0, mass: 0 }) };
const ALLIES = { test_melee_a: chessRec({ id: 'test_melee_a', profession: 'WARRIOR', stats: { maxHp: 10000, def: 100, res: 10, atk: 0 }, skill: null }) };
const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, RINX, RINY].map((m) => [t, true, m]))];
const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`;
/**
* 夜莺 as uid 1 on the 高台 tile (11,2) facing RIGHT — her y-2: rows 10–12, cols 1–4; RIN-X adds (11,5); 圣域's y-4 adds
* (11,5) and (9,1)–(9,3) —, allies (melee, no skill) at (11,3) uid 2, (10,3) uid 3, (12,3) uid 4, (10,4) uid 5 (all in
* y-2), (11,5) uid 6 (RIN-X / y-4 only), (9,3) uid 7 (y-4 only), (11,8) uid 8 (outside); `phantom` = a board 幻影 of hers.
*/
function field({ tier = 5, elite = false, mod = null, skill = 0, seed = 5, phantom = null, dp = 0 } = {}) {
const ally = (uid, row, col) => ({ uid, chessId: 'test_melee_a', row, col });
const h = makeBattle({
defs: { enemies: ENEMIES, chess: ALLIES }, timeLimit: 900, autoFinish: false, seed,
flags: { dpPerSec: 0, dpMax: 999, dpInit: dp }, hooks: ['damaged', 'heal', 'attack', 'skillStart', 'skillEnd', 'death', 'deploy'], captureNoisy: true,
units: [
{ uid: 1, diy: { slot: SLOT[tier], charId: CGBIRD, skillIndex: skill, uniEquipId: mod }, elite, row: 11, col: 2 },
ally(2, 11, 3), ally(3, 10, 3), ally(4, 12, 3), ally(5, 10, 4), ally(6, 11, 5), ally(7, 9, 3), ally(8, 11, 8),
...(phantom ? [{ uid: 9, kind: 'token', tokenId: PHANTOM, ownerUid: 1, row: phantom[0], col: phantom[1] }] : []),
],
});
h.step();
const u = h.unit(1);
return { h, u, a: (uid) => h.unit(uid), bird: phantom ? h.b.allyUnits.find((t) => t.kind === 'token' && t.uid === 9) : null };
}
function done(h) {
checkInvariants(h.b);
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
}
const rangeOf = (tier, elite, mod) => {
const rec = diyRecord(SLOT[tier], { charId: CGBIRD, skillIndex: 0, uniEquipId: mod }, { elite, data: { chess: CHESS, backups: BACKUPS } });
return attackRangeGrid(rec);
};
test('夜莺 in every 自选 form: her operator kit (all three skills authored), the form\'s stats + module attributes, y-2 (RIN-X: its grid), 3 heals (RIN-Y: 4), no 特质', () => {
assert.equal(OPERATOR_KITS[CGBIRD], KITS[CGBIRD]);
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
for (const skill of [0, 1, 2]) {
const { h, u } = field({ tier, elite, mod, skill });
const form = formOf(tier, elite), m = elite ? modOf(tier, mod) : null;
assert.deepEqual([u.def.charId, u.def.diyFor, u.skill.id, !!u.kit.generic, u.kit.skillSource], [CGBIRD, SLOT[tier], form.skills[skill].skillId, false, 'skills'], label(f));
assert.deepEqual([u.base.maxHp, u.base.atk, u.base.def, u.base.res, u.base.aspd],
[form.stats.maxHp + (m?.attr.maxHp ?? 0), form.stats.atk + (m?.attr.atk ?? 0), form.stats.def, form.stats.res + (m?.attr.res ?? 0), form.stats.aspd + (m?.attr.aspd ?? 0)], `${label(f)}: stats`);
assert.deepEqual([u.profile.dmgType, u.profile.heal?.mode, u.profile.heal?.count, u.profile.attack], ['heal', 'multi', elite && mod === RINY ? 4 : 3, 'ranged'], `${label(f)}: 群愈师`);
const rinx = elite && mod === RINX;
const mg = rinx ? modOf(tier, RINX).talentChanges.find((t) => t.talentIndex === -1).rangeGrid : form.rangeGrid;
assert.deepEqual(u.liveRangeGrid, mg, `${label(f)}: range`);
assert.deepEqual(u.liveRangeGrid, rangeOf(tier, elite, mod), `${label(f)}: the range the card draws (attackRangeGrid)`);
assert.equal(mg.length, rinx ? 13 : 12, `${label(f)}: y-2 (+ [0,3])`);
assert.deepEqual([u.def.bonds, u.def.raw.garrisonIds], [['emptyShip'], []], `${label(f)}: bonds / 特质`);
assert.ok(!u.s.flags.liftoff && !u.s.flags.camou && !u.s.flags.stealth && !u.s.flags.untargetable, `${label(f)}: enemies target her`);
done(h);
}
}
assert.deepEqual([modOf(5, RINX).attr, modOf(6, RINX).attr, modOf(5, RINY).attr, modOf(6, RINY).attr], [{ atk: 40, res: 5 }, { atk: 65, res: 5 }, { maxHp: 160, aspd: 5 }, { maxHp: 220, aspd: 7 }]);
});
test('a 自选 pick: 夜莺 is offered at tiers 5 and 6 (she has a kit) and a roster with her passes validateDiyPicks', () => {
const data = { chess: CHESS, backups: BACKUPS };
assert.ok(KITTED_CHARS.includes(CGBIRD));
for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(CGBIRD), `tier ${t}`);
assert.deepEqual(validateDiyPicks({ [SLOT[6]]: { charId: CGBIRD, skillIndex: 1, uniEquipId: RINY } }, { data, kitted: KITTED_CHARS }),
{ ok: true, picks: { [SLOT[6]]: { charId: CGBIRD, skillIndex: 1, uniEquipId: RINY } } });
});
test('群愈师 heals 3 allies at once; RIN-Y “莺歌” 4 (stages 1 and 3)', () => {
for (const f of [[5, false, null], [5, true, RINY], [6, true, RINY], [6, true, RINX]]) {
const [tier, elite, mod] = f;
const { h, u, a } = field({ tier, elite, mod, skill: 0 });
for (const uid of [2, 3, 4, 5]) a(uid).hp = 1000 + uid;
const n0 = h.hooksOf('attack').length;
assert.ok(h.runUntil(() => h.hooksOf('attack').slice(n0).some((c) => c.attacker === u), 6), label(f));
const at = h.hooksOf('attack').slice(n0).find((c) => c.attacker === u);
assert.equal(new Set(at.targets.map((t) => t.uid)).size, elite && mod === RINY ? 4 : 3, `${label(f)}: heals per attack`);
done(h);
}
});
test('S1 治疗强化·γ型 (MANUAL, data DEFAULT): 30 s of ATK +55 % / +70 %, cast on an injured ally in range; 35 / 32 SP from 20', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S1);
const { h, u, a } = field({ tier, elite, mod: elite ? RINX : null, skill: 0 });
assert.deepEqual([u.skill.rule, u.skill.spCost, sk.initSp, sk.duration, sk.bb.atk], ['DEFAULT', elite ? 32 : 35, 20, 30, elite ? 0.7 : 0.55], `T${tier}`);
u.skill.gainSp(999);
a(8).hp = 100; // outside her range: no cast
h.run(2);
assert.equal(u.skill.activations, 0, `T${tier}: nobody injured in range`);
a(2).hp = 100;
assert.ok(h.runUntil(() => u.skill.active, 5), `T${tier}: cast`);
approx(u.skill.timeLeft, 30, `T${tier}: 30 s`, 0.01);
approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `T${tier}: ATK`);
h.runUntil(() => !u.skill.active, 35);
approx(u.s.atk, u.base.atk, `T${tier}: back`);
done(h);
}
});
test('S2 法术护盾 (AUTO, data DEFAULT, 2 charges): the next heal gives each of its targets a 屏障 of 50 % / 60 % ATK for 3.5 / 4 s that absorbs arts damage only, and RES +10 / +15 while it holds', () => {
for (const f of [[5, false, null], [6, true, RINY]]) {
const [tier, elite, mod] = f;
const sk = skillOf(tier, elite, S2);
const { h, u, a } = field({ tier, elite, mod, skill: 1 });
assert.deepEqual([u.skill.rule, u.skill.maxCharges, u.skill.kind, sk.spCost, sk.bb.atk_scale, sk.bb.duration, sk.bb.magic_resistance],
['DEFAULT', 2, 'charges', 10, elite ? 0.6 : 0.5, elite ? 4 : 3.5, elite ? 15 : 10], label(f));
u.skill.gainSp(999);
h.run(2);
assert.equal(u.skill.activations, 0, `${label(f)}: nobody injured, no cast`);
const hurt = [2, 3, 4, 5].map(a);
hurt.forEach((x, i) => { x.hp = 1000 + i; });
assert.ok(h.runUntil(() => u.skill.activations === 1 && hurt.some((x) => x.findBuff('cgbird:barrier')), 5), `${label(f)}: cast with a heal`);
const got = hurt.filter((x) => x.findBuff('cgbird:barrier'));
const first = got.map((x) => x.findBuff('cgbird:barrier'));
assert.equal(got.length, elite && mod === RINY ? 4 : 3, `${label(f)}: every target of that heal`);
for (const x of got) {
const b = x.findBuff('cgbird:barrier');
approx(b.shield, u.s.atk * sk.bb.atk_scale, `${label(f)}: ${sk.bb.atk_scale * 100} % ATK`);
approx(b.duration, sk.bb.duration, `${label(f)}: ${sk.bb.duration} s`);
assert.equal(b.shieldType, 'arts', `${label(f)}: arts only`);
assert.deepEqual(x.findBuff('cgbird:barrierRes')?.mods, { resFlat: sk.bb.magic_resistance }, `${label(f)}: RES bonus`);
}
const x = got[0], b = x.findBuff('cgbird:barrier');
const hp0 = x.hp, sh0 = b.shield;
h.b.dealDamage(null, x, { amount: 50, type: 'phys', canDodge: false });
assert.ok(x.hp < hp0, `${label(f)}: physical damage passes`);
approx(b.shield, sh0, `${label(f)}: the barrier untouched by physical damage`);
h.b.dealDamage(null, x, { amount: 20, type: 'true' });
approx(b.shield, sh0, `${label(f)}: … and by true damage`);
const hp1 = x.hp;
h.b.dealDamage(null, x, { amount: 30, type: 'arts', canDodge: false });
approx(x.hp, hp1, `${label(f)}: arts damage absorbed`);
assert.ok(b.shield < sh0, `${label(f)}: the barrier took it`);
h.b.dealDamage(null, x, { amount: 1e6, type: 'arts', canDodge: false, resIgnorePct: 1 });
assert.equal(x.findBuff('cgbird:barrier'), null, `${label(f)}: broken`);
assert.equal(x.findBuff('cgbird:barrierRes'), null, `${label(f)}: the RES bonus goes with it`);
h.run(sk.bb.duration + 0.1);
got.forEach((y, i) => assert.ok(!y.buffs.includes(first[i]), `${label(f)}: the first heal's barrier expired`));
done(h);
}
});
test('S3 圣域 (MANUAL, data ACTIVE_RANGE on y-4): cast for an injured ally inside y-4 only; 60 s, range y-4, ATK +40 % / +50 %, allies in it RES ×1.9 / ×2.05 and 15 % / 20 % arts dodge; all back after', () => {
for (const f of [[5, false, null], [6, true, null], [6, true, RINX]]) {
const [tier, elite, mod] = f;
const sk = skillOf(tier, elite, S3);
const { h, u, a } = field({ tier, elite, mod, skill: 2 });
assert.deepEqual([u.skill.rule, sk.duration, sk.spCost, sk.rangeId, sk.bb.atk, sk.bb.magic_resistance, sk.bb.prob],
['ACTIVE_RANGE', 60, 120, 'y-4', elite ? 0.5 : 0.4, elite ? 1.05 : 0.9, elite ? 0.2 : 0.15], label(f));
assert.deepEqual(sk.trigger.customRangeGrid, sk.rangeGrid, `${label(f)}: the trigger grid is its running range`);
const base = u.liveRangeGrid;
u.skill.gainSp(999);
a(8).hp = 100; // outside y-4
h.run(2);
assert.equal(u.skill.activations, 0, `${label(f)}: no injured ally in y-4`);
a(7).hp = 100; // (9,3): y-4 only
assert.ok(h.runUntil(() => u.skill.active, 2), `${label(f)}: cast for the ally 2 rows down`);
assert.deepEqual(u.liveRangeGrid, sk.rangeGrid, `${label(f)}: y-4 while it runs`);
h.run(0.3);
approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `${label(f)}: ATK`);
for (const uid of [2, 6, 7]) assert.deepEqual(a(uid).findBuff('skill:cgbird:sanctuary')?.mods, { resMul: 1 + sk.bb.magic_resistance, dodgeArts: sk.bb.prob }, `${label(f)}: uid ${uid} in y-4`);
assert.deepEqual(u.findBuff('skill:cgbird:sanctuary')?.mods, { resMul: 1 + sk.bb.magic_resistance, dodgeArts: sk.bb.prob }, `${label(f)}: herself`);
assert.equal(a(8).findBuff('skill:cgbird:sanctuary'), null, `${label(f)}: outside`);
// RES = (10 + 15 talent) × (1 + v) on an ally (base 10)
approx(a(7).s.res, Math.min(100, (10 + 15) * (1 + sk.bb.magic_resistance)), `${label(f)}: RES`);
approx(a(7).s.dodgeArts, sk.bb.prob, `${label(f)}: arts dodge`);
u.skill.end('test');
h.run(0.3);
assert.deepEqual(u.liveRangeGrid, base, `${label(f)}: own range back`);
for (const uid of [2, 6, 7]) assert.equal(a(uid).findBuff('skill:cgbird:sanctuary'), null, `${label(f)}: uid ${uid} after`);
done(h);
}
});
test('T1 白恶魔的庇护: allies in her range RES +15 (herself too, not outside); RIN-X: its range tile too, stage 3 also 受到的治疗效果 ×1.05', () => {
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
const { h, u, a } = field({ tier, elite, mod, skill: 0 });
h.run(0.3);
const rinx = elite && mod === RINX, x3 = rinx && tier === 6;
const want = x3 ? { resFlat: 15, healingTakenMul: 1.05 } : { resFlat: 15 };
for (const uid of [2, 3, 4, 5]) assert.deepEqual(a(uid).findBuff('talent:cgbird:res')?.mods, want, `${label(f)}: uid ${uid}`);
assert.deepEqual(u.findBuff('talent:cgbird:res')?.mods, want, `${label(f)}: herself`);
assert.deepEqual(a(6).findBuff('talent:cgbird:res')?.mods ?? null, rinx ? want : null, `${label(f)}: (11,5) — the RIN-X tile`);
for (const uid of [7, 8]) assert.equal(a(uid).findBuff('talent:cgbird:res'), null, `${label(f)}: uid ${uid} outside`);
approx(a(2).s.res, 10 + 15, `${label(f)}: RES`);
if (x3) {
a(2).hp = 1000;
const n0 = h.hooksOf('heal').length;
assert.ok(h.runUntil(() => h.hooksOf('heal').slice(n0).some((c) => c.target === a(2) && c.source === u), 6));
const c = h.hooksOf('heal').slice(n0).find((x) => x.target === a(2) && x.source === u);
approx(c.amount, u.s.atk * 1.05, `${label(f)}: healing received ×1.05`);
}
done(h);
}
});
test('幻影 (T2): the token of her form — HP 3000 / 4989, RES 75, blocks nothing, taunt 1, no attack; 30 % physical dodge (RIN-Y stage 3: 40 %), loses 3 % of its max HP each second', () => {
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
const { h, u, bird } = field({ tier, elite, mod, skill: 0, phantom: [12, 8] }); // outside her range: no heals on it
const v = tokenVariant(tier, elite, mod);
assert.ok(bird && bird.alive, `${label(f)}: deployed with the board`);
assert.equal(bird.ownerUnit, u, `${label(f)}: hers`);
assert.deepEqual([bird.base.maxHp, bird.base.res, bird.s.blockCnt, bird.s.taunt, bird.profile.noAttack], [v.stats.maxHp, 75, 0, 1, true], `${label(f)}: stats`);
assert.equal(v.stats.maxHp, elite ? 4989 : 3000);
const y3 = elite && tier === 6 && mod === RINY;
approx(bird.s.dodgePhys, y3 ? 0.4 : 0.3, `${label(f)}: physical dodge`);
assert.equal(bird.s.dodgeArts, 0, `${label(f)}: no arts dodge`);
const hp0 = bird.hp;
h.run(3.02);
approx(hp0 - bird.hp, 3 * 0.03 * bird.s.maxHp, `${label(f)}: 3 × 3 % in 3 s`, 1e-6);
assert.ok(h.hooksOf('damaged').some((c) => c.target === bird && c.dmg?.tags?.includes('hpLoss')), `${label(f)}: a 流失`);
// enemies: a 30 % dodge on physical hits, none on arts
let dodged = 0;
for (let i = 0; i < 400; i++) if (h.b.dealDamage(null, bird, { amount: 1, type: 'phys' }) === 0) dodged++;
assert.ok(Math.abs(dodged / 400 - (y3 ? 0.4 : 0.3)) < 0.08, `${label(f)}: ${dodged} / 400 dodged`);
done(h);
}
});
test('幻影: each 夜莺 deployment adds 2 (RIN-Y stage 3: 3) to her stock, at most 3; a fallen 幻影 comes back on its tile 20 s later using one and paying 5 DP; it stays when she leaves', () => {
for (const f of [[5, true, RINY], [6, true, RINY], [6, false, null]]) {
const [tier, elite, mod] = f;
const { h, u, bird } = field({ tier, elite, mod, skill: 0, phantom: [12, 8], dp: 50 });
const cnt = elite && tier === 6 && mod === RINY ? 3 : 2;
assert.equal(u.mem.phantomStock, cnt, `${label(f)}: her deployment gave ${cnt}; the board piece used none`);
const tile = [bird.tileR, bird.tileC];
h.b.kill(bird, null);
assert.ok(!bird.alive && !bird.removed, `${label(f)}: fallen, kept`);
h.run(19.5);
assert.ok(!bird.alive, `${label(f)}: not before its 20 s`);
const dp0 = h.b.players[0].dp;
assert.ok(h.runUntil(() => bird.alive, 1), `${label(f)}: back after 20 s`);
assert.deepEqual([bird.tileR, bird.tileC], tile, `${label(f)}: on its own tile`);
assert.equal(u.mem.phantomStock, cnt - 1, `${label(f)}: one used`);
approx(dp0 - h.b.players[0].dp, 5, `${label(f)}: its DP cost`);
approx(bird.hp, bird.s.maxHp, `${label(f)}: full HP`, 0.05);
approx(bird.s.dodgePhys, tokenVariant(tier, elite, mod).talents.at(-1).bb.prob, `${label(f)}: its talent again`);
// she leaves: the deployed 幻影 stays; she comes back: +cnt (cap 3)
h.b.retreat(u);
h.step();
assert.ok(bird.alive, `${label(f)}: stays when she leaves`);
h.b.redeploy(u, { free: true });
assert.equal(u.mem.phantomStock, Math.min(3, cnt - 1 + cnt), `${label(f)}: +${cnt}, at most 3`);
// none held: a fallen 幻影 waits
u.mem.phantomStock = 0;
h.b.kill(bird, null);
h.run(30);
assert.ok(!bird.alive, `${label(f)}: no 幻影 held, none comes`);
u.mem.phantomStock = 1;
assert.ok(h.runUntil(() => bird.alive, 1), `${label(f)}: comes as soon as one is held`);
assert.equal(u.mem.phantomStock, 0);
done(h);
}
});
test('幻影 without DP waits for it; 夜莺 heals her 幻影 (an injured 友方单位 in range) and her S2 barrier reaches it', () => {
const { h, u, bird } = field({ tier: 6, elite: true, mod: RINY, skill: 1, phantom: [12, 4], dp: 0 });
// the drain makes it an injured ally of her range
u.skill.gainSp(999);
assert.ok(h.runUntil(() => bird.findBuff('cgbird:barrier'), 6), 'barrier on the 幻影 with the next heal');
assert.ok(h.hooksOf('heal').some((c) => c.source === u && c.target === bird), 'healed');
h.b.kill(bird, null);
h.run(25);
assert.ok(!bird.alive, 'no DP: still down');
h.b.players[0].dp = 5;
assert.ok(h.runUntil(() => bird.alive, 1), 'back once the DP is there');
approx(h.b.players[0].dp, 0, 'paid 5');
done(h);
});
+316
View File
@@ -0,0 +1,316 @@
// test/content/op_chen.test.js — the 自选 operator kit of 陈 (char_010_chen, 6★ 剑豪; kit
// server/sim/content/kits/ops/op-chen.js), fielded the production way (a DIY slot + its `diy` pick, simdata getDiy) in every
// form: tiers 5 / 6, normal (E2 Lv1, skill rank 4, no module) and elite (E2 Lv60, rank 7) with no module, SWO-X “罗德岛制式剑”
// or SWO-Y “往昔时光” at stage 1 (tier 5) / 3 (tier 6). Every number is read back from data/backups.json (the form of that
// slot status); the fidelity checklist of kits/README.md item by item.
// Run: node --test test/content/op_chen.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
import { KITTED_CHARS, OPERATOR_KITS, KITS } from '../../server/sim/content/kits/index.js';
import { diyPool, validateDiyPicks } from '../../shared/diy.js';
const load = (f) => JSON.parse(readFileSync(new URL(`../../data/${f}.json`, import.meta.url), 'utf8'));
const CHESS = load('chess');
const BACKUPS = load('backups');
const CHEN = 'char_010_chen';
const FORMS = BACKUPS.units[CHEN].forms;
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
const SWOX = 'uniequip_002_chen', SWOY = 'uniequip_003_chen';
const S1 = 'skchr_chen_1', S2 = 'skchr_chen_2', S3 = 'skchr_chen_3';
const formOf = (tier, elite) => FORMS[elite ? (tier === 5 ? '2/60/7/1' : '2/60/7/3') : '2/1/4/0'];
const skillOf = (tier, elite, id) => formOf(tier, elite).skills.find((s) => s.skillId === id);
const modOf = (tier, mod) => (mod ? formOf(tier, true).modules.find((m) => m.uniEquipId === mod) : null);
const approx = (a, b, msg, eps = 1e-6) => assert.ok(Math.abs(a - b) <= eps * Math.max(1, Math.abs(b)), `${msg}: ${a} vs ${b}`);
const dummy = (key, o = {}) => enemyRec({ key, hp: 1e9, speed: 0, mass: 0, ...o });
const ENEMIES = {
enemy_dummy: dummy('enemy_dummy'), enemy_fly: dummy('enemy_fly', { motion: 'FLY' }), enemy_def: dummy('enemy_def', { def: 300 }),
enemy_walk: enemyRec({ key: 'enemy_walk', hp: 1e9, speed: 0.6, mass: 0 }), enemy_weak: dummy('enemy_weak', { hp: 1000 }),
};
/** Every 自选 form: [tier, elite, module]. */
const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, SWOX, SWOY].map((m) => [t, true, m]))];
const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`;
/** The SWO-X multiplier of her skill damage in a form. */
const skillMulOf = (elite, mod) => (elite && mod === SWOX ? 1.1 : 1);
/** A battle with 陈 as uid 1 at (row, col) facing RIGHT, plus `others`. */
function field({ tier = 5, elite = false, mod = null, skill = 0, row = 10, col = 5, others = [], seed = 5, startOpCooldown } = {}) {
const h = makeBattle({
defs: { enemies: ENEMIES }, timeLimit: 600, autoFinish: false, seed,
flags: { dpPerSec: 0, dpMax: 999, ...(startOpCooldown != null ? { startOpCooldown } : null) },
hooks: ['damaged', 'skillStart', 'skillEnd', 'statusApplied', 'attack'], captureNoisy: true,
units: [{ uid: 1, diy: { slot: SLOT[tier], charId: CHEN, skillIndex: skill, uniEquipId: mod }, elite, row, col }, ...others],
});
h.step();
return { h, u: h.unit(1) };
}
const hitsBy = (h, u) => h.hooksOf('damaged').filter((c) => c.source === u);
function done(h) {
checkInvariants(h.b);
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
}
test('陈 in every 自选 form: her operator kit (all three skills authored), the form\'s stats + module attributes, 1-1 range, blocks 2, two hits per attack, melee ground-only, 炎 (yanShip), no 特质', () => {
assert.equal(OPERATOR_KITS[CHEN], KITS[CHEN]);
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
for (const skill of [0, 1, 2]) {
const { h, u } = field({ tier, elite, mod, skill });
const form = formOf(tier, elite), m = elite ? modOf(tier, mod) : null;
assert.deepEqual([u.def.charId, u.def.diyFor, u.skill.id, !!u.kit.generic, u.kit.skillSource], [CHEN, SLOT[tier], form.skills[skill].skillId, false, 'skills'], label(f));
assert.deepEqual([u.base.maxHp, u.base.atk, u.base.def, u.base.aspd],
[form.stats.maxHp + (m?.attr.maxHp ?? 0), form.stats.atk + (m?.attr.atk ?? 0), form.stats.def + (m?.attr.def ?? 0), 100 + (m?.attr.aspd ?? 0)], `${label(f)}: stats`);
assert.deepEqual([u.s.blockCnt, u.profile.attack, u.profile.canHitFly, u.profile.hits, u.profile.dmgType, u.base.bat], [2, 'melee', false, 2, 'phys', 1.3], `${label(f)}: 剑豪`);
assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: 1-1`);
assert.deepEqual([u.def.bonds, u.def.raw.garrisonIds], [['yanShip'], []], `${label(f)}: bonds / 特质`);
assert.ok(!u.s.flags.liftoff && !u.s.flags.camou && !u.s.flags.stealth, `${label(f)}: ground enemies target her`);
assert.deepEqual(u.def.raw.tokens, [], `${label(f)}: no summons`);
done(h);
}
}
// the numbers of the forms (zh_CN): E2 Lv1 2188 / 469 / 288, E2 Lv60 2647 / 562 / 330; SWO-X +50 ATK +5 ASPD → +80 / +7,
// SWO-Y +52 / +28 → +85 / +42 ATK / DEF
assert.deepEqual([FORMS['2/1/4/0'].stats.maxHp, FORMS['2/1/4/0'].stats.atk, FORMS['2/60/7/1'].stats.maxHp, FORMS['2/60/7/1'].stats.atk], [2188, 469, 2647, 562]);
assert.deepEqual([modOf(5, SWOX).attr, modOf(6, SWOX).attr, modOf(5, SWOY).attr, modOf(6, SWOY).attr], [{ atk: 50, aspd: 5 }, { atk: 80, aspd: 7 }, { atk: 52, def: 28 }, { atk: 85, def: 42 }]);
});
test('a 自选 pick: 陈 is offered at tiers 5 and 6 (she has a kit) and a roster with her passes validateDiyPicks', () => {
const data = { chess: CHESS, backups: BACKUPS };
assert.ok(KITTED_CHARS.includes(CHEN));
for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(CHEN), `tier ${t}`);
assert.deepEqual(validateDiyPicks({ [SLOT[6]]: { charId: CHEN, skillIndex: 2, uniEquipId: SWOX } }, { data, kitted: KITTED_CHARS }),
{ ok: true, picks: { [SLOT[6]]: { charId: CHEN, skillIndex: 2, uniEquipId: SWOX } } });
});
test('剑豪 trait: a normal attack is two physical hits of 100 % ATK on her target (one attack id); she gains 1 attack SP per attack', () => {
for (const [tier, elite, mod] of [[5, false, null], [6, true, SWOY]]) {
const { h, u } = field({ tier, elite, mod, skill: 2 });
const e = h.spawn('enemy_dummy', { pos: [10, 6] });
const sp0 = u.skill.sp;
assert.ok(h.runUntil(() => hitsBy(h, u).length >= 2, 3), 'she attacks');
const [a, b] = hitsBy(h, u);
assert.deepEqual([a.target, b.target, a.dmg.attackId === b.dmg.attackId, a.type, a.dmg.isAttack, a.dmg.isSkill], [e, e, true, 'phys', true, false]);
approx(a.amount, u.s.atk, 'hit 1'); approx(b.amount, u.s.atk, 'hit 2');
approx(u.skill.sp, sp0 + 1, 'one SP per attack (not per hit)');
done(h);
}
});
test('S1 鞘击 (AUTO, attack SP 6 / 5, data DEFAULT): the next attack is ONE strike of 230 % / 260 % ATK physical that stuns its target 1.25 / 1.5 s; then two-hit attacks again', () => {
for (const [tier, elite, mod] of [[5, false, null], [5, true, SWOX], [6, true, SWOX], [6, true, SWOY]]) {
const sk = skillOf(tier, elite, S1), mul = skillMulOf(elite, mod), lb = `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`;
const { h, u } = field({ tier, elite, mod, skill: 0 });
assert.deepEqual([u.skill.rule, u.skill.spType, u.skill.spCost, sk.initSp, sk.bb.atk_scale, sk.bb.stun], ['DEFAULT', 'attack', elite ? 5 : 6, 0, elite ? 2.6 : 2.3, elite ? 1.5 : 1.25], lb);
u.skill.gainSp(999);
const e = h.spawn('enemy_dummy', { pos: [10, 6] });
assert.ok(h.runUntil(() => u.skill.activations === 1 && hitsBy(h, u).length > 0, 3), `${lb}: cast on her next attack`);
h.step();
const first = hitsBy(h, u);
assert.equal(first.filter((c) => c.dmg.attackId === first[0].dmg.attackId).length, 1, `${lb}: one strike, not two`);
assert.deepEqual([first[0].target, first[0].type, first[0].dmg.isSkill], [e, 'phys', true], lb);
approx(first[0].amount, u.s.atk * sk.bb.atk_scale * mul, `${lb}: ${sk.bb.atk_scale * 100} % ATK${mul > 1 ? ' × 1.1 (SWO-X)' : ''}`);
const stun = h.hooksOf('statusApplied').find((c) => c.source === u && c.status === 'stun');
assert.ok(stun && stun.target === e, `${lb}: the target is stunned`);
approx(stun.duration, sk.bb.stun, `${lb}: stun ${sk.bb.stun} s`);
const n0 = hitsBy(h, u).length;
assert.ok(h.runUntil(() => hitsBy(h, u).length >= n0 + 2, 4), `${lb}: next attack`);
const next = hitsBy(h, u).slice(n0);
assert.deepEqual([next[0].dmg.isSkill, next[1].dmg.attackId === next[0].dmg.attackId], [false, true], `${lb}: two plain hits again`);
done(h);
}
});
test('S2 赤霄·拔刀 (MANUAL, data SKILL_RANGE on 3-12): cast with an enemy in the skill range (no attack needed); up to 5 / 6 enemies there — flyers too — each take 370 % / 410 % ATK arts, then the same physical; nothing outside', () => {
for (const [tier, elite, mod] of [[5, false, null], [6, true, null], [6, true, SWOX]]) {
const sk = skillOf(tier, elite, S2), mul = skillMulOf(elite, mod), lb = `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`;
const { h, u } = field({ tier, elite, mod, skill: 1 });
assert.deepEqual([u.skill.rule, u.skill.spType, u.skill.spCost, u.skill.sp, sk.bb.atk_scale, sk.bb.max_target, sk.rangeId],
['SKILL_RANGE', 'attack', elite ? 25 : 26, elite ? 14 : 12, elite ? 4.1 : 3.7, elite ? 6 : 5, '3-12'], lb);
assert.deepEqual(u.skill.triggerGrid, sk.rangeGrid, `${lb}: the trigger grid is the skill range`);
u.skill.gainSp(999);
h.run(1);
assert.equal(u.skill.activations, 0, `${lb}: nobody in range`);
// 3 tiles ahead: inside 3-12, outside her 1-1 — she cannot attack it
const far = h.spawn('enemy_dummy', { pos: [10, 8] });
const out1 = h.spawn('enemy_dummy', { pos: [10, 9] }), out2 = h.spawn('enemy_dummy', { pos: [10, 4] });
const fly = h.spawn('enemy_fly', { pos: [9, 6] });
const more = [[11, 6], [10, 7], [9, 5]].map((pos) => h.spawn('enemy_dummy', { pos }));
assert.ok(h.runUntil(() => u.skill.activations === 1, 1), `${lb}: cast`);
const hit = hitsBy(h, u);
const victims = [...new Set(hit.map((c) => c.target))];
assert.equal(victims.length, Math.min(sk.bb.max_target, 5), `${lb}: every enemy of the skill range up to ${sk.bb.max_target}`);
for (const e of [far, fly, ...more]) {
const mine = hit.filter((c) => c.target === e);
assert.deepEqual(mine.map((c) => c.type), ['arts', 'phys'], `${lb}: ${e.tileR},${e.tileC} arts then physical`);
for (const c of mine) { approx(c.amount, u.s.atk * sk.bb.atk_scale * mul, `${lb}: ${sk.bb.atk_scale * 100} %`); assert.equal(c.dmg.isSkill, true); }
}
for (const e of [out1, out2]) assert.ok(!hit.some((c) => c.target === e), `${lb}: ${e.tileR},${e.tileC} outside 3-12`);
assert.ok(!h.hooksOf('attack').some((c) => c.attacker === u), `${lb}: no attack — nobody stood in her 1-1`);
done(h);
}
// more enemies than max_target: exactly max_target are cut
const sk = skillOf(5, false, S2);
const { h, u } = field({ tier: 5, skill: 1 });
u.skill.gainSp(999);
for (const pos of [[11, 5], [11, 6], [10, 6], [10, 7], [10, 8], [9, 5], [9, 6]]) h.spawn('enemy_dummy', { pos });
assert.ok(h.runUntil(() => u.skill.activations === 1, 1));
assert.equal(new Set(hitsBy(h, u).filter((c) => c.dmg.isSkill).map((c) => c.target)).size, sk.bb.max_target, `${sk.bb.max_target} of 7`);
done(h);
});
test('S3 赤霄·绝影 (MANUAL, data SKILL_RANGE on x-1): not opened with only a flyer in range; then 10 slashes of 230 % / 260 % ATK on the nearest ground enemy at the skeleton\'s times, the last stunning 2.5 / 3 s; 2.933 s, invulnerable, unable to block, stun immune', () => {
for (const [tier, elite, mod] of [[5, false, null], [6, true, null], [6, true, SWOX]]) {
const sk = skillOf(tier, elite, S3), mul = skillMulOf(elite, mod), lb = `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`;
const { h, u } = field({ tier, elite, mod, skill: 2, row: 10, col: 5 });
assert.deepEqual([u.skill.rule, u.skill.spType, u.skill.spCost, u.skill.sp, sk.bb.atk_scale, sk.bb.times, sk.bb.stun, sk.rangeId],
['SKILL_RANGE', 'attack', elite ? 36 : 38, 10, elite ? 2.6 : 2.3, 10, elite ? 3 : 2.5, 'x-1'], lb);
assert.deepEqual(u.skill.triggerGrid, sk.rangeGrid, `${lb}: the trigger grid is the skill range`);
u.skill.gainSp(999);
const fly = h.spawn('enemy_fly', { pos: [10, 6] });
h.run(1.5);
assert.equal(u.skill.activations, 0, `${lb}: a flyer alone in x-1 does not open it (不可对空), the skill stays ready`);
assert.equal(u.skill.charges, 1, lb);
const near = h.spawn('enemy_dummy', { pos: [9, 6] }); // √2 away
const farther = h.spawn('enemy_dummy', { pos: [10, 7] }); // 2 away
assert.ok(h.runUntil(() => u.skill.active, 1), `${lb}: opened with a ground enemy in range`);
const t0 = u.skill.lastStart;
assert.deepEqual([!!u.s.flags.invulnerable, !!u.s.flags.noBlock], [true, true], `${lb}: 无敌, 无法阻挡`);
assert.equal(h.b.applyStatus(u, 'stun', { duration: 2, source: near }), false, `${lb}: 眩晕免疫`);
assert.equal(h.b.applyStatus(u, 'freeze', { duration: 2, source: near }), false, `${lb}: 冻结免疫`);
const n0 = hitsBy(h, u).length;
h.runUntil(() => !u.skill.active, 4);
const end = h.hooksOf('skillEnd').find((c) => c.unit === u);
assert.deepEqual([end.reason, Math.round((end.t - t0) * 30)], ['duration', 88], `${lb}: the Skill_3 clip (2.933 s = 88 ticks)`);
const slashes = hitsBy(h, u).slice(n0);
assert.equal(slashes.length, 10, `${lb}: 10 slashes`);
assert.ok(slashes.every((c) => c.target === near && c.type === 'phys' && c.dmg.isSkill), `${lb}: all on the nearest ground enemy`);
for (const c of slashes) approx(c.amount, u.s.atk * sk.bb.atk_scale * mul, `${lb}: ${sk.bb.atk_scale * 100} %`);
const at = slashes.map((c) => Math.round((c.t - t0) * 30));
assert.deepEqual(at, [18, 24, 30, 36, 42, 48, 54, 60, 66, 85], `${lb}: 0.6 … 2.2 s, 2.833 s (ticks)`);
const stuns = h.hooksOf('statusApplied').filter((c) => c.source === u && c.status === 'stun');
assert.equal(stuns.length, 1, `${lb}: the last slash stuns`);
assert.deepEqual([stuns[0].target, stuns[0].duration], [near, sk.bb.stun], lb);
assert.ok(!hitsBy(h, u).some((c) => c.target === fly || c.target === farther), `${lb}: nobody else`);
assert.deepEqual([!!u.s.flags.invulnerable, !!u.s.flags.noBlock], [false, false], `${lb}: back to normal`);
assert.deepEqual(u.liveRangeGrid, formOf(tier, elite).rangeGrid, `${lb}: her range stays 1-1`);
done(h);
}
});
test('S3: no normal attack and no SP while it runs; a slain target is replaced by the next nearest; with no target left in x-1 it ends at once', () => {
const { h, u } = field({ tier: 6, elite: true, skill: 2 });
u.skill.gainSp(999);
const weak = h.spawn('enemy_weak', { pos: [10, 6] });
const next = h.spawn('enemy_dummy', { pos: [11, 6] });
assert.ok(h.runUntil(() => u.skill.active, 1));
const n0 = hitsBy(h, u).length;
h.runUntil(() => !u.skill.active, 4);
const hits = hitsBy(h, u).slice(n0);
assert.ok(!weak.alive, 'the first target fell');
assert.ok(hits.length === 10 && hits.every((c) => c.dmg.isSkill), 'ten slashes, no normal attack');
assert.ok(hits.some((c) => c.target === next) && hits[hits.length - 1].target === next, 'the rest on the next nearest');
assert.ok(!h.hooksOf('attack').some((c) => c.attacker === u && c.t > u.skill.lastStart && c.t < u.skill.lastStart + 2.9), 'no attack meanwhile');
done(h);
// the only target dies at the first slash: the skill ends right there (no 2.9 s)
const r = field({ tier: 5, skill: 2 });
r.u.skill.gainSp(999);
r.h.spawn('enemy_weak', { pos: [10, 6] });
assert.ok(r.h.runUntil(() => r.u.skill.active, 1));
const t0 = r.u.skill.lastStart;
r.h.runUntil(() => !r.u.skill.active, 4);
const early = r.h.hooksOf('skillEnd').find((c) => c.unit === r.u);
assert.deepEqual([early.reason, Math.round((early.t - t0) * 30)], ['noTarget', 19], 'ended the tick after the first slash felled it');
assert.equal(r.u.skill.sp, 0, 'SP spent');
done(r.h);
// the 10th slash fells the only target: the slashes are done, so the clip runs out (only "未到10次之前" ends it early)
const z = field({ tier: 5, skill: 2 });
const slash = z.u.s.atk * skillOf(5, false, S3).bb.atk_scale;
const tenth = z.h.spawn('enemy_dummy', { pos: [10, 6] });
tenth.hp = 9.5 * slash;
z.u.skill.gainSp(999);
assert.ok(z.h.runUntil(() => z.u.skill.active, 1));
const z0 = z.u.skill.lastStart;
z.h.runUntil(() => !z.u.skill.active, 4);
assert.ok(!tenth.alive, 'felled by the 10th slash');
assert.equal(hitsBy(z.h, z.u).length, 10);
const zEnd = z.h.hooksOf('skillEnd').find((c) => c.unit === z.u);
assert.deepEqual([zEnd.reason, Math.round((zEnd.t - z0) * 30)], ['duration', 88], 'the clip runs out');
done(z.h);
// a walker she blocked walks on while she slashes (无法阻挡)
const w = field({ tier: 6, elite: true, skill: 2, row: 9, col: 5 });
w.h.spawn('enemy_walk', { routeIndex: 0 });
assert.ok(w.h.runUntil(() => w.u.blocking.length > 0, 30), 'blocked');
const walker = w.u.blocking[0];
w.u.skill.gainSp(999);
assert.ok(w.h.runUntil(() => w.u.skill.active, 2));
w.h.step();
assert.equal(walker.blockedBy, null, 'released');
done(w.h);
});
test('T1 呵斥: every 4 s on the field (3 s with SWO-X stage 3) every attack / hurt SP ally gets 1 SP — her too, not time SP; SWO-X stage 3: +1 more for her; nothing once she left', () => {
const others = [{ uid: 2, chessId: 'chess_char_1_09_a', row: 12, col: 3 }, { uid: 3, chessId: 'chess_char_1_12_a', row: 12, col: 5 }, { uid: 4, chessId: 'chess_char_1_02_a', row: 12, col: 7 }];
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
const x3 = elite && mod === SWOX && tier === 6;
const t0 = formOf(tier, elite).talents[0].bb;
const iv = x3 ? 3 : 4;
const { h, u } = field({ tier, elite, mod, skill: 2, others });
if (!x3) assert.deepEqual(u.def.raw.talents.find((t) => t.index === 0).bb, t0, label(f));
const [snipe, guard, tank] = [h.unit(2), h.unit(3), h.unit(4)];
assert.deepEqual([snipe.skill.spType, guard.skill.spType, tank.skill.spType], ['attack', 'hurt', 'time']);
const sp = () => [u.skill.sp, snipe.skill.sp, guard.skill.sp];
const base = sp();
h.run(iv - 0.1 - h.b.time);
assert.deepEqual(sp(), base, `${label(f)}: nothing before ${iv} s`);
h.run(0.2);
assert.deepEqual(sp(), [base[0] + (x3 ? 2 : 1), base[1] + 1, base[2] + 1], `${label(f)}: +1 each at ${iv} s${x3 ? ', +2 for her' : ''}`);
approx(tank.skill.sp, Math.min(tank.skill.spCost, 0 + h.b.time), `${label(f)}: the time-SP 角峰 only recovers with time`, 0.05);
h.run(iv);
assert.deepEqual(sp(), [base[0] + (x3 ? 4 : 2), base[1] + 2, base[2] + 2], `${label(f)}: again at ${2 * iv} s`);
h.b.retreat(u);
const off = [snipe.skill.sp, guard.skill.sp];
h.run(3 * iv);
assert.deepEqual([snipe.skill.sp, guard.skill.sp], off, `${label(f)}: nothing once she left`);
done(h);
}
// SWO-X stage 3 is the module's text: 每3秒…,自身额外回复1点技力
assert.match(modOf(6, SWOX).talentChanges.find((t) => !t.hidden).desc, /每3秒回复全场友方角色1点攻击\/受击技力,自身额外回复1点技力/);
});
test('T2 持刀格斗术: ATK +5 %, DEF +5 %, 物理闪避 10 %; SWO-Y stage 3: +15 % / +15 % / 18 %', () => {
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
const { h, u } = field({ tier, elite, mod, skill: 1 });
const y3 = elite && mod === SWOY && tier === 6;
const [a, d, p] = y3 ? [0.15, 0.15, 0.18] : [0.05, 0.05, 0.1];
assert.deepEqual(u.findBuff('talent:chen:knife')?.mods, { atkPct: a, defPct: d, dodgePhys: p }, label(f));
approx(u.s.atk, u.base.atk * (1 + a), `${label(f)}: ATK`);
approx(u.s.def, u.base.def * (1 + d), `${label(f)}: DEF`);
approx(u.s.dodgePhys, p, `${label(f)}: 物理闪避`);
done(h);
}
});
test('modules: SWO-X skill damage ×1.1 during her skill (PRTS 修正 技能期间), normal attacks unchanged; SWO-Y ignores 70 DEF on every hit (stages 1 and 3); none without', () => {
for (const f of FORMS_ALL.filter(([, elite]) => elite)) {
const [tier, elite, mod] = f;
const { h, u } = field({ tier, elite, mod, skill: 1 });
h.spawn('enemy_def', { pos: [10, 6] });
assert.ok(h.runUntil(() => hitsBy(h, u).length >= 2, 3));
const pen = mod === SWOY ? 70 : 0;
approx(hitsBy(h, u)[0].amount, u.s.atk - (300 - pen), `${label(f)}: a normal hit vs DEF 300`);
if (mod === SWOY) assert.deepEqual(u.def.raw.trait.bb, { def_penetrate_fixed: 70 });
if (mod === SWOX) assert.deepEqual(u.def.raw.trait.bb, { damage_scale: 1.1, value: 0.1 });
// S2 on the same target
u.skill.gainSp(999);
const n0 = hitsBy(h, u).length;
assert.ok(h.runUntil(() => u.skill.activations === 1, 4));
const cut = hitsBy(h, u).slice(n0).filter((c) => c.dmg.isSkill && c.type === 'phys')[0];
const sk = skillOf(tier, elite, S2);
approx(cut.amount, (u.s.atk * sk.bb.atk_scale - (300 - pen)) * skillMulOf(elite, mod), `${label(f)}: S2 physical vs DEF 300`);
done(h);
}
});
+327
View File
@@ -0,0 +1,327 @@
// test/content/op_chyue.test.js — the 自选 operator kit of 重岳 (char_2024_chyue, 6★ 斗士; kit
// server/sim/content/kits/ops/op-chyue.js), fielded the production way (a DIY slot + its `diy` pick, simdata getDiy) in
// every form: tiers 5 / 6, normal (E2 Lv1, skill rank 4, no module) and elite (E2 Lv60, rank 7) with no module, FGT-X
// “自晦及明” or FGT-Y “朔” at stage 1 (tier 5) / 3 (tier 6). Every number is read back from data/backups.json (the form of
// that slot status); the fidelity checklist of kits/README.md item by item.
// Run: node --test test/content/op_chyue.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
import { KITTED_CHARS, OPERATOR_KITS, KITS } from '../../server/sim/content/kits/index.js';
import { diyPool, validateDiyPicks } from '../../shared/diy.js';
const load = (f) => JSON.parse(readFileSync(new URL(`../../data/${f}.json`, import.meta.url), 'utf8'));
const CHESS = load('chess');
const BACKUPS = load('backups');
const CHYUE = 'char_2024_chyue';
const FORMS = BACKUPS.units[CHYUE].forms;
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
const FGTX = 'uniequip_002_chyue', FGTY = 'uniequip_003_chyue';
const S1 = 'skchr_chyue_1', S2 = 'skchr_chyue_2', S3 = 'skchr_chyue_3';
const X6 = [[2, 0], [1, 0], [0, -2], [0, -1], [0, 0], [0, 1], [0, 2], [-1, 0], [-2, 0]];
const formOf = (tier, elite) => FORMS[elite ? (tier === 5 ? '2/60/7/1' : '2/60/7/3') : '2/1/4/0'];
const skillOf = (tier, elite, id) => formOf(tier, elite).skills.find((s) => s.skillId === id);
const modOf = (tier, mod) => (mod ? formOf(tier, true).modules.find((m) => m.uniEquipId === mod) : null);
const approx = (a, b, msg, eps = 1e-6) => assert.ok(Math.abs(a - b) <= eps * Math.max(1, Math.abs(b)), `${msg}: ${a} vs ${b}`);
const dummy = (key, o = {}) => enemyRec({ key, hp: 1e9, speed: 0, mass: 0, ...o });
const ENEMIES = {
enemy_dummy: dummy('enemy_dummy'), enemy_fly: dummy('enemy_fly', { motion: 'FLY' }),
enemy_frail: dummy('enemy_frail', { hp: 100 }), enemy_mid: dummy('enemy_mid', { hp: 3000 }),
enemy_walk: enemyRec({ key: 'enemy_walk', hp: 1e9, speed: 0.6, mass: 0 }),
};
/** Every 自选 form: [tier, elite, module]. */
const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, FGTX, FGTY].map((m) => [t, true, m]))];
const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`;
const markOf = (u, e) => e.findBuff(`talent:chyue:mark#${u.id}`);
/** A battle with 重岳 as uid 1 at (row, col) facing RIGHT, plus `others`. */
function field({ tier = 5, elite = false, mod = null, skill = 2, row = 10, col = 5, others = [], seed = 5 } = {}) {
const h = makeBattle({
defs: { enemies: ENEMIES }, timeLimit: 900, autoFinish: false, seed,
flags: { dpPerSec: 0, dpMax: 999 }, hooks: ['damaged', 'skillStart', 'skillEnd', 'statusApplied'], captureNoisy: true,
units: [{ uid: 1, diy: { slot: SLOT[tier], charId: CHYUE, skillIndex: skill, uniEquipId: mod }, elite, row, col }, ...others],
});
h.step();
return { h, u: h.unit(1) };
}
const hitsBy = (h, u) => h.hooksOf('damaged').filter((c) => c.source === u && c.target?.side === 'enemy');
function done(h) {
checkInvariants(h.b);
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
}
test('重岳 in every 自选 form: his operator kit (all three skills authored), the form\'s stats + module attributes, 1-1 range, blocks 1, melee ground-only, 炎, no 特质; FGT-X 15 % physical dodge, FGT-Y +10 ASPD above 50 % HP', () => {
assert.equal(OPERATOR_KITS[CHYUE], KITS[CHYUE]);
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
for (const skill of [0, 1, 2]) {
const { h, u } = field({ tier, elite, mod, skill });
const form = formOf(tier, elite), m = elite ? modOf(tier, mod) : null;
assert.deepEqual([u.def.charId, u.def.diyFor, u.skill.id, !!u.kit.generic, u.kit.skillSource], [CHYUE, SLOT[tier], form.skills[skill].skillId, false, 'skills'], label(f));
assert.deepEqual([u.base.maxHp, u.base.atk, u.base.def], [form.stats.maxHp + (m?.attr.maxHp ?? 0), form.stats.atk + (m?.attr.atk ?? 0), form.stats.def + (m?.attr.def ?? 0)], `${label(f)}: stats`);
assert.deepEqual([u.s.blockCnt, u.profile.attack, u.profile.canHitFly, u.profile.dmgType, u.base.bat], [1, 'melee', false, 'phys', 0.78], `${label(f)}: 斗士`);
assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: 1-1`);
assert.deepEqual([u.def.bonds, u.def.raw.garrisonIds], [['yanShip'], []], `${label(f)}: bonds / 特质`);
assert.ok(!u.s.flags.liftoff && !u.s.flags.camou && !u.s.flags.stealth && !u.s.flags.untargetable, `${label(f)}: ground enemies target him`);
assert.equal(u.s.dodgePhys, elite && mod === FGTX ? 0.15 : 0, `${label(f)}: FGT-X 物理闪避`);
assert.equal(u.s.aspd, elite && mod === FGTY ? 110 : 100, `${label(f)}: FGT-Y at full HP`);
if (elite && mod === FGTY) {
u.hp = u.s.maxHp * 0.5;
h.step();
assert.equal(u.s.aspd, 100, `${label(f)}: FGT-Y off at 50 % (not above)`);
}
done(h);
}
}
// the numbers of the forms (zh_CN): E2 Lv1 2160 / 477 / 304, E2 Lv60 2475 / 552 / 343; FGT-X +210 / +25 / +20 →
// +260 / +45 / +30, FGT-Y +52 / +25 → +75 / +35 ATK / DEF
assert.deepEqual([FORMS['2/1/4/0'].stats.maxHp, FORMS['2/1/4/0'].stats.atk, FORMS['2/60/7/1'].stats.maxHp, FORMS['2/60/7/1'].stats.atk], [2160, 477, 2475, 552]);
assert.deepEqual([modOf(5, FGTX).attr, modOf(6, FGTX).attr, modOf(5, FGTY).attr, modOf(6, FGTY).attr],
[{ maxHp: 210, atk: 25, def: 20 }, { maxHp: 260, atk: 45, def: 30 }, { atk: 52, def: 25 }, { atk: 75, def: 35 }]);
assert.deepEqual([modOf(5, FGTX).traitOverride.bb, modOf(6, FGTY).traitOverride.bb], [{ prob: 0.15 }, { attack_speed: 10, hp_ratio: 0.5 }]);
});
test('a 自选 pick: 重岳 is offered at tiers 5 and 6 (he has a kit) and a roster with him passes validateDiyPicks', () => {
const data = { chess: CHESS, backups: BACKUPS };
assert.ok(KITTED_CHARS.includes(CHYUE));
for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(CHYUE), `tier ${t}`);
assert.deepEqual(validateDiyPicks({ [SLOT[6]]: { charId: CHYUE, skillIndex: 0, uniEquipId: FGTX } }, { data, kitted: KITTED_CHARS }),
{ ok: true, picks: { [SLOT[6]]: { charId: CHYUE, skillIndex: 0, uniEquipId: FGTX } } });
});
test('S1 冲盈 (MANUAL, attack SP 5 / 4, 3 charges, data DEFAULT): 250 % / 290 % ATK on the attack target; from a full stack (蓄力) every charge goes and it strikes 3 times', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S1);
const { h, u } = field({ tier, elite, skill: 0 });
assert.deepEqual([u.skill.rule, u.skill.spType, u.skill.spCost, u.skill.maxCharges, sk.initSp, sk.bb.atk_scale, sk.bb.times],
['DEFAULT', 'attack', elite ? 4 : 5, 3, 0, elite ? 2.9 : 2.5, 3], `T${tier}`);
// a full stack: 蓄力
u.skill.gainSp(999);
assert.equal(u.skill.charges, 3);
const e = h.spawn('enemy_dummy', { pos: [10, 6] });
const fly = h.spawn('enemy_fly', { pos: [10, 6] });
assert.ok(h.runUntil(() => u.skill.activations === 1, 3), `T${tier}: cast`);
h.step();
const strike = hitsBy(h, u).filter((c) => c.dmg.isSkill);
assert.equal(strike.length, 3, `T${tier}: three strikes`);
for (const c of strike) {
assert.equal(c.target, e, `T${tier}: the ground target`);
approx(c.dmg.amount, u.s.atk * sk.bb.atk_scale, `T${tier}: ${sk.bb.atk_scale * 100} %`);
assert.ok(c.dmg.mul === 1 || Math.abs(c.dmg.mul - 1.65) < 1e-9, `T${tier}: ×1 or ×1.65 (止戈)`);
approx(c.amount, c.dmg.amount * c.dmg.mul, `T${tier}: physical, DEF 0`);
}
assert.deepEqual([u.skill.charges, u.skill.sp], [0, 0], `T${tier}: every charge spent`);
assert.ok(!hitsBy(h, u).some((c) => c.target === fly), `T${tier}: never the flyer`);
// one charge: one strike
u.skill.gainSp(u.skill.spCost);
assert.equal(u.skill.charges, 1);
const n0 = hitsBy(h, u).filter((c) => c.dmg.isSkill).length;
assert.ok(h.runUntil(() => u.skill.activations === 2, 4), `T${tier}: second cast`);
h.step();
assert.equal(hitsBy(h, u).filter((c) => c.dmg.isSkill).length - n0, 1, `T${tier}: one strike`);
done(h);
}
});
test('S1 冲盈: exact damage — 290 % ATK per strike, ×1.65 on a 止戈-marked target; the strike rolls no 止戈 and gives no attack SP', () => {
const { h, u } = field({ tier: 6, elite: true, skill: 0 });
const sk = skillOf(6, true, S1);
const e = h.spawn('enemy_dummy', { pos: [10, 6] });
u.skill.gainSp(u.skill.spCost);
assert.ok(h.runUntil(() => u.skill.activations === 1, 3));
h.step();
const c = hitsBy(h, u).find((x) => x.dmg.isSkill);
approx(c.amount, u.s.atk * sk.bb.atk_scale, '290 % (no mark yet)');
assert.equal(markOf(u, e), null, 'the skill strike marks nothing');
assert.equal(u.skill.sp, 0, 'no attack SP for the strike');
h.b.addBuff(e, { key: `talent:chyue:mark#${u.id}`, duration: 2.5 });
u.skill.gainSp(u.skill.spCost);
assert.ok(h.runUntil(() => u.skill.activations === 2, 4));
h.step();
const c2 = hitsBy(h, u).filter((x) => x.dmg.isSkill)[1];
approx(c2.amount, u.s.atk * sk.bb.atk_scale * 1.65, '×1.65 on the marked target');
done(h);
});
test('S2 拂尘 (MANUAL, 2 charges, data SKILL_RANGE x-4): ≤ 4 enemies around (air too; his marked ones first, then the one he blocks) take 290 % / 350 %; the marked ground ones 浮空 2 s; 0.5 s later every 浮空 enemy around loses it, takes 410 % / 480 % and is marked', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S2);
const { h, u } = field({ tier, elite, skill: 1, row: 9 });
assert.deepEqual([u.skill.rule, u.skill.spType, u.skill.spCost, u.skill.maxCharges, sk.initSp, sk.rangeId, sk.bb.max_target, sk.bb.atk_scale, sk.bb.atk_scale_down],
['SKILL_RANGE', 'time', elite ? 11 : 12, 2, elite ? 7 : 6, 'x-4', 4, elite ? 3.5 : 2.9, elite ? 4.8 : 4.1], `T${tier}`);
const blocked = h.spawn('enemy_dummy', { pos: [9, 5] });
const m1 = h.spawn('enemy_dummy', { pos: [10, 6] });
const m2 = h.spawn('enemy_dummy', { pos: [10, 4] });
const flyM = h.spawn('enemy_fly', { pos: [8, 6] });
const plain1 = h.spawn('enemy_dummy', { pos: [8, 4] });
const plain2 = h.spawn('enemy_dummy', { pos: [9, 6] });
const outside = h.spawn('enemy_dummy', { pos: [9, 7] });
const lifted = h.spawn('enemy_dummy', { pos: [10, 5] }); // 浮空 by another source, not marked
h.step();
assert.equal(blocked.blockedBy, u);
for (const e of [m1, m2, flyM]) h.b.addBuff(e, { key: `talent:chyue:mark#${u.id}`, duration: 9 });
h.b.applyStatus(lifted, 'levitate', { duration: 5, source: null });
assert.ok(lifted.isFlying);
u.skill.gainSp(sk.spCost);
assert.ok(h.runUntil(() => u.skill.activations === 1, 2), `T${tier}: cast`);
const t0 = h.b.time;
const first = hitsBy(h, u).filter((c) => c.dmg.isSkill);
const hitSet = new Set(first.map((c) => c.target));
assert.equal(first.length, 4, `T${tier}: four targets`);
assert.deepEqual([m1, m2, flyM, blocked].map((e) => hitSet.has(e)), [true, true, true, true], `T${tier}: the marked (flyer included), then the blocked one`);
assert.ok(![plain1, plain2, outside].some((e) => hitSet.has(e)), `T${tier}: no room for the rest`);
for (const c of first) approx(c.amount, u.s.atk * sk.bb.atk_scale * (markOf(u, c.target) ? 1.65 : 1), `T${tier}: ${sk.bb.atk_scale * 100} %`);
assert.deepEqual([m1, m2, flyM, blocked].map((e) => !!e.s.flags.levitate), [true, true, false, false], `T${tier}: the marked ground ones 浮空 (not a flyer, not the unmarked)`);
approx(m1.findBuff('levitate').timeLeft, 2, `T${tier}: 2 s`, 0.05);
assert.equal(blocked.blockedBy, u, 'still blocked');
h.run(0.4);
assert.equal(hitsBy(h, u).filter((c) => c.dmg.isSkill).length, 4, `T${tier}: nothing yet`);
h.runUntil(() => h.b.time >= t0 + 0.5 - 1e-9, 0.3);
h.step();
const second = hitsBy(h, u).filter((c) => c.dmg.isSkill).slice(4);
assert.deepEqual(new Set(second.map((c) => c.target)), new Set([m1, m2, lifted]), `T${tier}: every 浮空 enemy around (the other source's too)`);
for (const c of second) approx(c.amount, u.s.atk * sk.bb.atk_scale_down * (c.target === lifted ? 1 : 1.65), `T${tier}: ${sk.bb.atk_scale_down * 100} % (its own mark comes after the damage)`);
for (const e of [m1, m2, lifted]) {
assert.ok(!e.s.flags.levitate, `T${tier}: 浮空 over`);
approx(markOf(u, e).timeLeft, 2.5, `T${tier}: marked 2.5 s`, 0.1);
}
done(h);
}
});
test('S3 我无 (MANUAL, attack SP 11 / 10, data DEFAULT): its strike hits the target and the ground enemies within 0.8 for 240 % / 300 %; the 5th cast turns him — x-6, double normal hits, +1 SP per attack (阻回 ignored), a self-cast the moment SP is full, the strike lands twice; a redeploy undoes it', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S3);
const { h, u } = field({ tier, elite, skill: 2, seed: 9 });
assert.deepEqual([u.skill.rule, u.skill.spType, u.skill.spCost, sk.initSp, sk.bb.atk_scale, sk.bb.cast_cnt], ['DEFAULT', 'attack', elite ? 10 : 11, 0, elite ? 3 : 2.4, 5], `T${tier}`);
const e = h.spawn('enemy_dummy', { pos: [10, 6] });
const near = h.spawn('enemy_dummy', { pos: [10.6, 6.4] });
const fly = h.spawn('enemy_fly', { pos: [10, 6] });
const far = h.spawn('enemy_dummy', { pos: [11, 7] });
const strikes = () => hitsBy(h, u).filter((c) => c.dmg.isSkill);
for (let n = 1; n <= 5; n++) {
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.activations === n, 5), `T${tier}: cast ${n} (3 s apart: the automatic-operation cooldown)`);
h.step();
assert.equal(u.mem.chyueAwake, n === 5, `T${tier}: transformed after the 5th cast only`);
}
const pre = strikes();
assert.equal(pre.length, 10, `T${tier}: each of the five strikes hits the target and the one within 0.8`);
assert.deepEqual(new Set(pre.map((c) => c.target)), new Set([e, near]), `T${tier}: not the flyer, not the one 1.4 away`);
for (const c of pre) {
approx(c.dmg.amount, u.s.atk * sk.bb.atk_scale, `T${tier}: ${sk.bb.atk_scale * 100} %`);
approx(c.amount, c.dmg.amount * c.dmg.mul, `T${tier}: physical (×1.65 on a marked one)`);
}
assert.deepEqual(u.liveRangeGrid, X6, `T${tier}: x-6`);
assert.ok(!hitsBy(h, u).some((c) => c.target === fly || c.target === far), `T${tier}: never the flyer / far one before`);
// transformed: two hits per normal attack, 2 SP per attack (阻回 does not stop the extra one)
const n0 = hitsBy(h, u).length;
h.b.addBuff(u, { key: 'test:noSp', flags: { noSp: true }, duration: 30 });
const sp0 = u.skill.sp;
h.runUntil(() => hitsBy(h, u).length > n0, 3);
h.step();
const plain = hitsBy(h, u).slice(n0).filter((c) => !c.dmg.isSkill);
assert.ok(plain.length >= 1 && plain.length % 2 === 0, `T${tier}: double hits`);
assert.equal(new Set(plain.map((c) => c.dmg.attackId)).size, plain.length / 2, `T${tier}: two per attack`);
approx(u.skill.sp - sp0, plain.length / 2, `T${tier}: +1 SP per attack under 阻回`);
h.b.removeBuff(u, 'test:noSp');
// the self-cast: it fires the moment its SP is full (no 3 s operation cooldown), the strike twice
const c6 = u.skill.activations;
const s0 = strikes().length;
u.skill.gainSp(999);
h.step();
assert.equal(u.skill.activations, c6 + 1, `T${tier}: cast at once`);
const twice = strikes().slice(s0);
assert.equal(twice.filter((c) => c.target === e).length, 2, `T${tier}: the strike lands twice on its target`);
assert.equal(twice.filter((c) => c.target === near).length, 2, `T${tier}: and twice around it`);
assert.ok(far.alive && !twice.some((c) => c.target === far || c.target === fly));
// a redeploy starts untransformed
h.b.kill(u, e);
h.b.redeploy(u);
h.step();
assert.deepEqual([u.mem.chyueAwake, u.mem.chyueCasts], [false, 0], `T${tier}: reset`);
assert.deepEqual(u.liveRangeGrid, formOf(tier, elite).rangeGrid, `T${tier}: 1-1 again`);
done(h);
}
});
test('T1 止戈: each normal-attack damage instance rolls 23 % to mark its target for 2.5 s (not that instance), every damage of his to a marked target ×1.65 — FGT-Y stage 3 ×1.75 (stage 1 ×1.65)', () => {
for (const f of [[5, false, null], [5, true, FGTY], [6, true, FGTY]]) {
const [tier, elite, mod] = f;
const { h, u } = field({ tier, elite, mod, skill: 0, seed: 21 });
const t0 = u.def.raw.talents.find((t) => t.index === 0).bb;
const scale = tier === 6 && mod === FGTY ? 1.75 : 1.65;
assert.deepEqual([t0.prob, t0.damage_scale, t0.up_duration], [0.23, scale, 2.5], label(f));
const e = h.spawn('enemy_dummy', { pos: [10, 6] });
let marked = 0, plainHits = 0, prevMarked = false;
h.b.on('hit', (c) => { if (c.source === u && c.target === e && c.dmg.isAttack && !c.dmg.isSkill) prevMarked = !!markOf(u, e); }, { priority: 100 });
const amounts = [];
h.b.on('damaged', (c) => {
if (c.source !== u || c.target !== e || c.dmg.isSkill) return;
plainHits++;
amounts.push([c.amount, prevMarked]);
if (!prevMarked && markOf(u, e)) marked++;
});
h.b.addBuff(u, { key: 'test:noSp', flags: { noSp: true }, duration: Infinity }); // keep S1 off: plain attacks only
h.run(400);
const unmarkedHits = amounts.filter(([, m]) => !m).length;
const rate = marked / unmarkedHits;
assert.ok(rate > 0.18 && rate < 0.28, `${label(f)}: ${marked} / ${unmarkedHits} ≈ 23 %`);
for (const [amt, m] of amounts.slice(0, 50)) approx(amt, u.s.atk * (m ? scale : 1), `${label(f)}: ×${scale} while marked`);
assert.ok(plainHits > 400);
done(h);
}
});
test('T2 万象为宾: a skill kill gives 3 SP when the skill ends (阻回 ignored; FGT-X stage 3: 4, and 1 when it killed nobody — stage 1: 3 / 0); a normal-attack kill gives nothing; S2\'s second segment is judged on its own', () => {
for (const f of [[5, true, null], [5, true, FGTX], [6, true, FGTX]]) {
const [tier, elite, mod] = f;
const x3 = tier === 6 && mod === FGTX;
const { h, u } = field({ tier, elite, mod, skill: 1, row: 9 });
const t1 = u.def.raw.talents.find((t) => t.index === 1).bb;
assert.equal(t1.sp, x3 ? 4 : 3, label(f));
const s2 = skillOf(tier, elite, S2);
// a cast that kills: +sp at its end; the second segment (no 浮空 enemy) gives the unkill part alone
h.b.addBuff(u, { key: 'test:noSp', flags: { noSp: true }, duration: Infinity });
const frail = h.spawn('enemy_frail', { pos: [10, 6] });
u.skill.sp = 0;
u.skill.addCharge(1);
const sp0 = u.skill.sp;
assert.ok(h.runUntil(() => u.skill.activations === 1, 2), label(f));
assert.ok(!frail.alive, `${label(f)}: killed by the skill`);
approx(u.skill.sp - sp0, x3 ? 4 : 3, `${label(f)}: +${x3 ? 4 : 3} under 阻回`);
h.run(1.05);
approx(u.skill.sp - sp0, (x3 ? 4 : 3) + (x3 ? 1 : 0), `${label(f)}: the second segment killed nobody: +${x3 ? 1 : 0}`);
// a cast that kills nobody
const e = h.spawn('enemy_dummy', { pos: [10, 6] });
const sp1 = u.skill.sp;
u.skill.addCharge(1);
h.run(3.5);
assert.equal(u.skill.activations, 2, label(f));
approx(u.skill.sp - sp1, x3 ? 2 : 0, `${label(f)}: no kill: ${x3 ? '1 + 1' : 'nothing'}`);
assert.ok(e.alive);
assert.ok(s2.spCost > 0);
done(h);
}
// a normal-attack kill gives nothing; S2's second segment killing gives its own SP
const { h, u } = field({ tier: 6, elite: true, mod: FGTX, skill: 1, row: 9 });
h.b.addBuff(u, { key: 'test:noSp', flags: { noSp: true }, duration: Infinity });
u.skill.sp = 0;
const weak = h.spawn('enemy_frail', { pos: [9, 6] });
h.runUntil(() => !weak.alive, 3);
assert.equal(u.skill.sp, 0, 'a normal-attack kill: nothing (and 阻回 blocks the attack SP: time SP anyway)');
const tough = h.spawn('enemy_dummy', { pos: [10, 6] });
const late = h.spawn('enemy_mid', { pos: [10, 5] }); // survives segment 1 (350 %), not segment 2 (+480 %)
h.b.applyStatus(late, 'levitate', { duration: 5, source: null });
h.b.addBuff(tough, { key: `talent:chyue:mark#${u.id}`, duration: 9 });
u.skill.addCharge(1);
const sp0 = u.skill.sp;
assert.ok(h.runUntil(() => u.skill.activations === 1, 2));
approx(u.skill.sp - sp0, 1, 'segment 1 killed nobody: +1');
h.run(1.05);
assert.ok(!late.alive, 'the second segment killed the 浮空 one');
approx(u.skill.sp - sp0, 1 + 4, 'segment 2 killed: +4');
done(h);
});
+259
View File
@@ -0,0 +1,259 @@
// test/content/op_helage.test.js — the 自选 operator kit of 赫拉格 (char_188_helage, 6★ 武者; kit
// server/sim/content/kits/ops/op-helage.js), fielded the production way (a DIY slot + its `diy` pick, simdata getDiy) in
// every form: tiers 5 / 6, normal (E2 Lv1, skill rank 4, no module) and elite (E2 Lv60, rank 7) with no module, SBL-X
// “藏锋” or SBL-Y “热的雪” at stage 1 (tier 5) / 3 (tier 6). Every number is read back from data/backups.json (the form of
// that slot status); the fidelity checklist of kits/README.md item by item.
// Run: node --test test/content/op_helage.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
import { KITTED_CHARS, OPERATOR_KITS, KITS } from '../../server/sim/content/kits/index.js';
import { diyPool, validateDiyPicks } from '../../shared/diy.js';
const load = (f) => JSON.parse(readFileSync(new URL(`../../data/${f}.json`, import.meta.url), 'utf8'));
const CHESS = load('chess');
const BACKUPS = load('backups');
const HELAGE = 'char_188_helage';
const FORMS = BACKUPS.units[HELAGE].forms;
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
const SBLX = 'uniequip_002_helage', SBLY = 'uniequip_003_helage';
const S1 = 'skchr_helage_1', S2 = 'skchr_helage_2', S3 = 'skchr_helage_3';
const formOf = (tier, elite) => FORMS[elite ? (tier === 5 ? '2/60/7/1' : '2/60/7/3') : '2/1/4/0'];
const skillOf = (tier, elite, id) => formOf(tier, elite).skills.find((s) => s.skillId === id);
const modOf = (tier, mod) => (mod ? formOf(tier, true).modules.find((m) => m.uniEquipId === mod) : null);
const approx = (a, b, msg, eps = 1e-6) => assert.ok(Math.abs(a - b) <= eps * Math.max(1, Math.abs(b)), `${msg}: ${a} vs ${b}`);
const dummy = (key, o = {}) => enemyRec({ key, hp: 1e9, speed: 0, mass: 0, ...o });
const ENEMIES = {
enemy_dummy: dummy('enemy_dummy'), enemy_fly: dummy('enemy_fly', { motion: 'FLY' }),
enemy_walk: enemyRec({ key: 'enemy_walk', hp: 1e9, speed: 0.6, mass: 0 }),
};
/** Every 自选 form: [tier, elite, module]. */
const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, SBLX, SBLY].map((m) => [t, true, m]))];
const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`;
/** A battle with 赫拉格 as uid 1 at (row, col) facing RIGHT, plus `others`. */
function field({ tier = 5, elite = false, mod = null, skill = 0, row = 10, col = 5, others = [], seed = 5 } = {}) {
const h = makeBattle({
defs: { enemies: ENEMIES }, timeLimit: 600, autoFinish: false, seed,
flags: { dpPerSec: 0, dpMax: 999 },
hooks: ['damaged', 'skillStart', 'skillEnd', 'heal', 'attack'], captureNoisy: true,
units: [{ uid: 1, diy: { slot: SLOT[tier], charId: HELAGE, skillIndex: skill, uniEquipId: mod }, elite, row, col }, ...others],
});
h.step();
return { h, u: h.unit(1) };
}
const hitsBy = (h, u) => h.hooksOf('damaged').filter((c) => c.source === u);
const selfHeals = (h, u) => h.hooksOf('heal').filter((c) => c.source === u && c.target === u && !c.opts?.regen);
function done(h) {
checkInvariants(h.b);
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
}
test('赫拉格 in every 自选 form: his operator kit (all three skills authored), the form\'s stats + module attributes, 1-1 range, blocks 1, melee ground-only, 武者 (no heal from others, 70 HP per hit), no core bond (emptyShip), no 特质', () => {
assert.equal(OPERATOR_KITS[HELAGE], KITS[HELAGE]);
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
for (const skill of [0, 1, 2]) {
const { h, u } = field({ tier, elite, mod, skill });
const form = formOf(tier, elite), m = elite ? modOf(tier, mod) : null;
assert.deepEqual([u.def.charId, u.def.diyFor, u.skill.id, !!u.kit.generic, u.kit.skillSource], [HELAGE, SLOT[tier], form.skills[skill].skillId, false, 'skills'], label(f));
assert.deepEqual([u.base.maxHp, u.base.atk, u.base.def], [form.stats.maxHp + (m?.attr.maxHp ?? 0), form.stats.atk + (m?.attr.atk ?? 0), form.stats.def + (m?.attr.def ?? 0)], `${label(f)}: stats`);
assert.deepEqual([u.s.blockCnt, u.profile.attack, u.profile.canHitFly, u.profile.hits, u.profile.noHeal, u.profile.selfHeal, u.base.bat], [1, 'melee', false, 1, true, 70, 1.2], `${label(f)}: 武者`);
assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: 1-1`);
assert.deepEqual([u.def.bonds, u.def.raw.garrisonIds], [['emptyShip'], []], `${label(f)}: bonds / 特质`);
assert.ok(!u.s.flags.liftoff && !u.s.flags.camou && !u.s.flags.stealth, `${label(f)}: ground enemies target him`);
assert.deepEqual(u.def.raw.tokens, [], `${label(f)}: no summons`);
done(h);
}
}
// the numbers of the forms (zh_CN): E2 Lv1 2868 / 632 / 283, E2 Lv60 3502 / 718 / 317; SBL-X +230 HP +55 ATK → +350 / +90,
// SBL-Y +55 ATK +50 DEF → +90 / +60
assert.deepEqual([FORMS['2/1/4/0'].stats.maxHp, FORMS['2/1/4/0'].stats.atk, FORMS['2/60/7/1'].stats.maxHp, FORMS['2/60/7/1'].stats.atk], [2868, 632, 3502, 718]);
assert.deepEqual([modOf(5, SBLX).attr, modOf(6, SBLX).attr, modOf(5, SBLY).attr, modOf(6, SBLY).attr], [{ maxHp: 230, atk: 55 }, { maxHp: 350, atk: 90 }, { atk: 55, def: 50 }, { atk: 90, def: 60 }]);
});
test('a 自选 pick: 赫拉格 is offered at tiers 5 and 6 (he has a kit) and a roster with him passes validateDiyPicks', () => {
const data = { chess: CHESS, backups: BACKUPS };
assert.ok(KITTED_CHARS.includes(HELAGE));
for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(HELAGE), `tier ${t}`);
assert.deepEqual(validateDiyPicks({ [SLOT[6]]: { charId: HELAGE, skillIndex: 2, uniEquipId: SBLY } }, { data, kitted: KITTED_CHARS }),
{ ok: true, picks: { [SLOT[6]]: { charId: HELAGE, skillIndex: 2, uniEquipId: SBLY } } });
});
test('武者 trait: one hit of 100 % ATK per attack heals him 70; another unit\'s heal does not reach him', () => {
const { h, u } = field({ tier: 6, elite: true, skill: 2, others: [{ uid: 2, chessId: 'chess_char_1_06_a', row: 12, col: 5 }] });
u.hp = u.s.maxHp * 0.5;
const e = h.spawn('enemy_dummy', { pos: [10, 6] });
assert.ok(h.runUntil(() => hitsBy(h, u).length >= 1, 3));
h.step();
const [a] = hitsBy(h, u);
assert.deepEqual([a.target, a.type, a.dmg.isAttack], [e, 'phys', true]);
approx(a.amount, u.s.atk, 'one hit');
assert.deepEqual(selfHeals(h, u).map((c) => c.amount), [70], 'healed 70');
const hp = u.hp;
assert.equal(h.b.heal(h.unit(2), u, 500), 0, 'not a heal target of others');
assert.equal(u.hp, hp);
done(h);
});
test('S1 新月 (AUTO, attack SP 3, data DEFAULT): the next attack hits twice at 125 % / 145 % ATK — and heals twice (one trait heal per damage instance)', () => {
for (const [tier, elite, mod] of [[5, false, null], [6, true, null], [6, true, SBLY]]) {
const sk = skillOf(tier, elite, S1), lb = `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`;
const { h, u } = field({ tier, elite, mod, skill: 0 });
assert.deepEqual([u.skill.rule, u.skill.spType, u.skill.spCost, sk.initSp, sk.bb.atk_scale], ['DEFAULT', 'attack', 3, 0, elite ? 1.45 : 1.25], lb);
u.hp = u.s.maxHp * 0.5;
u.skill.gainSp(999);
const e = h.spawn('enemy_dummy', { pos: [10, 6] });
assert.ok(h.runUntil(() => u.skill.activations === 1 && hitsBy(h, u).length >= 2, 3), `${lb}: cast on his next attack`);
h.step();
const [a, b] = hitsBy(h, u);
assert.deepEqual([a.target, b.target, a.dmg.attackId === b.dmg.attackId, a.dmg.isSkill], [e, e, true, true], lb);
for (const c of [a, b]) approx(c.amount, u.s.atk * sk.bb.atk_scale, `${lb}: ${sk.bb.atk_scale * 100} %`);
assert.deepEqual(selfHeals(h, u).map((c) => c.amount), [70, 70], `${lb}: 70 per hit`);
done(h);
}
});
test('S2 弦月 (MANUAL, time SP 32 / 29 from 5 / 10, data DEFAULT): 11 / 12 s of ATK +35 % / +50 %, two hits per attack (two heals), 物理闪避 75 %; all back after', () => {
for (const [tier, elite, mod] of [[5, false, null], [6, true, null], [5, true, SBLX]]) {
const sk = skillOf(tier, elite, S2), lb = `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`;
const { h, u } = field({ tier, elite, mod, skill: 1 });
assert.deepEqual([u.skill.rule, u.skill.spType, u.skill.spCost, sk.initSp, sk.duration, sk.bb.atk, sk.bb.prob],
['DEFAULT', 'time', elite ? 29 : 32, elite ? 10 : 5, elite ? 12 : 11, elite ? 0.5 : 0.35, 0.75], lb);
u.skill.gainSp(999);
h.run(1);
assert.equal(u.skill.activations, 0, `${lb}: no enemy, no cast`);
u.hp = u.s.maxHp * 0.6;
const e = h.spawn('enemy_dummy', { pos: [10, 6] });
assert.ok(h.runUntil(() => u.skill.active, 3), `${lb}: cast with an enemy in his range`);
approx(u.skill.timeLeft, sk.duration, `${lb}: ${sk.duration} s`, 0.01);
approx(u.s.dodgePhys, sk.bb.prob, `${lb}: 物理闪避`);
const n0 = hitsBy(h, u).length, h0 = selfHeals(h, u).length;
h.run(4);
const hits = hitsBy(h, u).slice(n0);
const ids = [...new Set(hits.map((c) => c.dmg.attackId))];
assert.ok(ids.length >= 2, `${lb}: attacks`);
for (const id of ids.slice(0, -1)) assert.equal(hits.filter((c) => c.dmg.attackId === id).length, 2, `${lb}: 二连击`);
for (const c of hits) approx(c.amount, u.s.atk, `${lb}: ATK +${sk.bb.atk * 100} % per hit`);
approx(u.s.atk / u.base.atk, 1 + sk.bb.atk, `${lb}: ATK`);
assert.ok(selfHeals(h, u).length - h0 >= 2 * (ids.length - 1), `${lb}: a heal per hit`);
h.runUntil(() => !u.skill.active, 20);
approx(u.s.atk, u.base.atk, `${lb}: ATK back`);
approx(u.s.dodgePhys, 0, `${lb}: no dodge after`);
const n1 = hitsBy(h, u).length;
h.run(3);
const after = hitsBy(h, u).slice(n1);
assert.equal(after.length, new Set(after.map((c) => c.dmg.attackId)).size, `${lb}: one hit per attack again`);
assert.ok(e.alive);
done(h);
}
});
test('S3 满月 (MANUAL, time SP 43 / 41 from 15, data ACTIVE_RANGE): cast with an enemy 2 tiles ahead (outside 1-1); 13 / 14 s of ATK +55 % / +70 %, range 1-1 + 1 forward, up to 3 targets; back after', () => {
for (const [tier, elite, mod] of [[5, false, null], [6, true, null], [6, true, SBLX]]) {
const sk = skillOf(tier, elite, S3), lb = `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`;
const { h, u } = field({ tier, elite, mod, skill: 2 });
assert.deepEqual([u.skill.rule, u.skill.spType, u.skill.spCost, sk.initSp, sk.duration, sk.bb.atk, sk.bb.ability_range_forward_extend, sk.bb['attack@max_target']],
['ACTIVE_RANGE', 'time', elite ? 41 : 43, 15, elite ? 14 : 13, elite ? 0.7 : 0.55, 1, 3], lb);
assert.deepEqual(u.skill.triggerGrid, [[0, 0], [0, 1], [0, 2]], `${lb}: the trigger grid = the running range`);
u.skill.gainSp(999);
const far = h.spawn('enemy_dummy', { pos: [10, 7] });
assert.ok(h.runUntil(() => u.skill.active, 1), `${lb}: cast with an enemy in the running range only`);
assert.deepEqual(u.liveRangeGrid, [[0, 0], [0, 1], [0, 2]], `${lb}: 1-1 + 1`);
approx(u.skill.timeLeft, sk.duration, `${lb}: ${sk.duration} s`, 0.01);
approx(u.s.atk / u.base.atk, 1 + sk.bb.atk, `${lb}: ATK`);
const near = h.spawn('enemy_dummy', { pos: [10, 6] }), third = h.spawn('enemy_dummy', { pos: [10, 6] }), fourth = h.spawn('enemy_dummy', { pos: [10, 7] });
const fly = h.spawn('enemy_fly', { pos: [10, 7] });
const n0 = hitsBy(h, u).length;
h.run(2);
const hits = hitsBy(h, u).slice(n0);
const id = hits[0].dmg.attackId;
assert.equal(hits.filter((c) => c.dmg.attackId === id).length, 3, `${lb}: three targets per attack`);
assert.ok(hits.every((c) => [far, near, third, fourth].includes(c.target)), `${lb}: ground enemies only (the flyer is never struck)`);
assert.ok(!hits.some((c) => c.target === fly), lb);
h.runUntil(() => !u.skill.active, 20);
assert.deepEqual(u.liveRangeGrid, formOf(tier, elite).rangeGrid, `${lb}: back to 1-1`);
approx(u.s.atk, u.base.atk, `${lb}: ATK back`);
done(h);
}
});
test('T1 月盈星亏: 坚忍 ASPD up to +100 at 70 % of his HP lost (linear); SBL-X stage 3: +130 at 50 %', () => {
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
const { h, u } = field({ tier, elite, mod, skill: 2 });
const x3 = elite && mod === SBLX && tier === 6;
const [maxAs, minHp] = x3 ? [130, 0.5] : [100, 0.3];
for (const r of [1, 0.85, 0.65, 0.5, 0.3, 0.1]) {
u.hp = u.s.maxHp * r;
h.step();
approx(u.s.aspd, 100 + maxAs * Math.min(1, (1 - r) / (1 - minHp)), `${label(f)}: at ${r * 100} % HP`, 0.002);
}
done(h);
}
});
test('T2 运筹帷幄: 60 HP / s (生命回复速度) while he blocks nobody, none while blocking; SBL-Y stage 3: 80 / s, and also while blocking below 30 % HP', () => {
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
const y3 = elite && mod === SBLY && tier === 6;
const regen = y3 ? 80 : 60;
const { h, u } = field({ tier, elite, mod, skill: 2, row: 9, col: 5 });
h.step();
assert.equal(u.s.hpRegen, regen, `${label(f)}: blocking nobody`);
u.hp = u.s.maxHp * 0.5;
const hp = u.hp;
h.run(1);
approx(u.hp - hp, regen, `${label(f)}: +${regen} HP in 1 s`, 0.05);
assert.ok(!selfHeals(h, u).length, `${label(f)}: an HP-regen attribute, no heal`);
h.spawn('enemy_walk', { routeIndex: 0 });
assert.ok(h.runUntil(() => u.blocking.length > 0, 30), `${label(f)}: the walker reaches him`);
h.step();
assert.equal(u.s.hpRegen, 0, `${label(f)}: none while blocking above 30 % HP`);
u.hp = u.s.maxHp * 0.25;
h.step();
assert.equal(u.s.hpRegen, y3 ? 80 : 0, `${label(f)}: blocking below 30 % HP`);
done(h);
}
});
test('SBL-X “藏锋”: below 50 % HP the physical and arts damage he takes ×0.75 (庇护, stages 1 and 3), true damage untouched; none without it', () => {
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
const { h, u } = field({ tier, elite, mod, skill: 2 });
const e = h.spawn('enemy_dummy', { pos: [9, 9] });
const shelter = elite && mod === SBLX;
if (shelter) assert.deepEqual([u.def.raw.talents[0].bb.hp_ratio, u.def.raw.talents[0].bb.damage_resistance], [0.5, 0.25], label(f));
const at = (r, dmg) => { u.hp = u.s.maxHp * r; return h.b.dealDamage(e, u, { canDodge: false, ...dmg }); };
approx(at(0.8, { amount: 400, type: 'arts' }), 400, `${label(f)}: above 50 %`);
approx(at(0.4, { amount: 400, type: 'arts' }), shelter ? 300 : 400, `${label(f)}: arts below 50 %`);
approx(at(0.4, { amount: 900, type: 'phys' }), (900 - u.s.def) * (shelter ? 0.75 : 1), `${label(f)}: physical below 50 %`);
approx(at(0.4, { amount: 400, type: 'true' }), 400, `${label(f)}: true damage`);
done(h);
}
});
test('SBL-Y “热的雪”: the first lethal blow of a deployment leaves him at 30 % HP (stages 1 and 3); the next one knocks him out; a new deployment has it again; none without it', () => {
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
const { h, u } = field({ tier, elite, mod, skill: 2 });
const e = h.spawn('enemy_dummy', { pos: [9, 9] });
const rise = elite && mod === SBLY;
if (rise) assert.deepEqual(u.def.raw.trait.bb, { value: 70, hp_ratio: 0.3 }, label(f));
h.b.dealDamage(e, u, { amount: 1e7, type: 'true' });
assert.equal(u.alive, rise, `${label(f)}: the first lethal blow`);
if (!rise) { done(h); continue; }
approx(u.hp, u.s.maxHp * 0.3, `${label(f)}: at 30 %`);
h.step();
h.b.dealDamage(e, u, { amount: 1e7, type: 'true' });
assert.equal(u.alive, false, `${label(f)}: once per deployment`);
h.b.redeploy(u);
assert.ok(u.alive && u.deployed, 'redeployed');
h.b.dealDamage(e, u, { amount: 1e7, type: 'true' });
assert.equal(u.alive, true, `${label(f)}: again after a new deployment`);
approx(u.hp, u.s.maxHp * 0.3, `${label(f)}: at 30 % again`);
done(h);
}
});
+362
View File
@@ -0,0 +1,362 @@
// test/content/op_heyak.test.js — the 自选 operator kit of 霍尔海雅 (char_4027_heyak, 6★ 中坚术师; kit
// server/sim/content/kits/ops/op-heyak.js), fielded the production way (a DIY slot + its `diy` pick, simdata getDiy) in
// every form: tiers 5 / 6, normal (E2 Lv1, skill rank 4, no module) and elite (E2 Lv60, rank 7) with no module, CCR-X
// “图书馆”, CCR-Y “羽蛇的时间博物馆” or ISW-A “霍尔海雅特限证章” at stage 1 (tier 5) / 3 (tier 6). Every number is read back from
// data/backups.json (the form of that slot status); the fidelity checklist of kits/README.md item by item.
// Run: node --test test/content/op_heyak.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
import { KITTED_CHARS, OPERATOR_KITS, KITS } from '../../server/sim/content/kits/index.js';
import { diyPool, validateDiyPicks } from '../../shared/diy.js';
const load = (f) => JSON.parse(readFileSync(new URL(`../../data/${f}.json`, import.meta.url), 'utf8'));
const CHESS = load('chess');
const BACKUPS = load('backups');
const ID = 'char_4027_heyak';
const FORMS = BACKUPS.units[ID].forms;
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
const CCRX = 'uniequip_002_heyak', CCRY = 'uniequip_003_heyak', ISWA = 'uniequip_004_heyak';
const S1 = 'skchr_heyak_1', S2 = 'skchr_heyak_2', S3 = 'skchr_heyak_3';
const formOf = (tier, elite) => FORMS[elite ? (tier === 5 ? '2/60/7/1' : '2/60/7/3') : '2/1/4/0'];
const skillOf = (tier, elite, id) => formOf(tier, elite).skills.find((s) => s.skillId === id);
const modOf = (tier, mod) => (mod ? formOf(tier, true).modules.find((m) => m.uniEquipId === mod) : null);
const approx = (a, b, msg, eps = 1e-6) => assert.ok(Math.abs(a - b) <= eps * Math.max(1, Math.abs(b)), `${msg}: ${a} vs ${b}`);
const dummy = (key, o = {}) => enemyRec({ key, hp: 1e9, speed: 0, mass: 0, ...o });
const ENEMIES = {
enemy_dummy: dummy('enemy_dummy'), enemy_fly: dummy('enemy_fly', { motion: 'FLY' }), enemy_heavy: dummy('enemy_heavy', { mass: 2 }),
enemy_r50: dummy('enemy_r50', { res: 50 }), enemy_elite: dummy('enemy_elite', { rank: 'ELITE' }), enemy_boss: dummy('enemy_boss', { rank: 'BOSS' }),
};
/** Every 自选 form: [tier, elite, module]. */
const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, CCRX, CCRY, ISWA].map((m) => [t, true, m]))];
const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`;
/** A battle with 霍尔海雅 as uid 1 at (row, col) facing RIGHT, plus `others`. */
function field({ tier = 5, elite = false, mod = null, skill = 2, row = 10, col = 5, others = [], seed = 5, setup = null } = {}) {
const h = makeBattle({
defs: { enemies: ENEMIES }, timeLimit: 900, autoFinish: false, seed,
flags: { dpPerSec: 0, dpMax: 999 }, hooks: ['damaged', 'skillStart', 'skillEnd', 'statusApplied', 'spGain'], captureNoisy: true,
units: [{ uid: 1, diy: { slot: SLOT[tier], charId: ID, skillIndex: skill, uniEquipId: mod }, elite, row, col }, ...others],
setup,
});
h.step();
return { h, u: h.unit(1) };
}
const hitsBy = (h, u) => h.hooksOf('damaged').filter((c) => c.source === u);
const statusBy = (h, u, key) => h.hooksOf('statusApplied').filter((c) => c.source === u && c.status === key);
function done(h) {
checkInvariants(h.b);
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
}
const arts = (amount, res, ignore = 0) => Math.max(amount * (1 - Math.max(0, res - ignore) / 100), amount * 0.05);
/** 传承终焉's numbers in a form: [atk_scale, silence]. */
const t0Of = (tier, elite, mod) => {
const t = (mod ? modOf(tier, mod).talentChanges.find((c) => c.talentIndex === 0) : null) ?? formOf(tier, elite).talents.find((c) => c.index === 0);
return [t.bb.atk_scale, t.bb.silence];
};
test('霍尔海雅 in every 自选 form: her operator kit (all three skills authored), the form\'s stats + module attributes, 3-1 range, ranged arts that hits air, block 1, 1.6 s, 空 bonds, no 特质', () => {
assert.equal(OPERATOR_KITS[ID], KITS[ID]);
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
for (const skill of [0, 1, 2]) {
const { h, u } = field({ tier, elite, mod, skill });
const form = formOf(tier, elite), m = elite ? modOf(tier, mod) : null;
assert.deepEqual([u.def.charId, u.def.diyFor, u.skill.id, !!u.kit.generic, u.kit.skillSource], [ID, SLOT[tier], form.skills[skill].skillId, false, 'skills'], label(f));
assert.deepEqual([u.base.maxHp, u.base.atk, u.base.def, u.base.res], [form.stats.maxHp + (m?.attr.maxHp ?? 0), form.stats.atk + (m?.attr.atk ?? 0), form.stats.def + (m?.attr.def ?? 0), 20], `${label(f)}: stats`);
assert.deepEqual([u.s.blockCnt, u.profile.attack, u.profile.dmgType, u.profile.canHitFly, u.profile.groundOnly, u.base.bat], [1, 'ranged', 'arts', true, false, 1.6], `${label(f)}: 中坚术师`);
assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: 3-1`);
assert.deepEqual([u.def.bonds, u.def.raw.garrisonIds, u.def.raw.tokens], [['emptyShip'], [], []], `${label(f)}: bonds / 特质 / no summon`);
assert.ok(!u.s.flags.liftoff && !u.s.flags.camou && !u.s.flags.stealth && !u.s.flags.untargetable, `${label(f)}: ground enemies target her`);
done(h);
}
}
// E2 Lv1 1380 / 525 / 116, E2 Lv60 1639 / 597 / 125; CCR-X +120 / +40 → +175 / +65, CCR-Y +105 / +32 / +15 → +140 / +45 /
// +25, ISW-A +100 / +55 → +130 / +80
assert.deepEqual([FORMS['2/1/4/0'].stats.maxHp, FORMS['2/1/4/0'].stats.atk, FORMS['2/60/7/1'].stats.maxHp, FORMS['2/60/7/1'].stats.atk], [1380, 525, 1639, 597]);
assert.deepEqual([modOf(5, CCRX).attr, modOf(6, CCRX).attr, modOf(5, CCRY).attr, modOf(6, CCRY).attr, modOf(5, ISWA).attr, modOf(6, ISWA).attr],
[{ maxHp: 120, atk: 40 }, { maxHp: 175, atk: 65 }, { maxHp: 105, atk: 32, def: 15 }, { maxHp: 140, atk: 45, def: 25 }, { maxHp: 100, atk: 55 }, { maxHp: 130, atk: 80 }]);
});
test('a 自选 pick: 霍尔海雅 is offered at tiers 5 and 6 and a roster with her (ISW-A included) passes validateDiyPicks', () => {
const data = { chess: CHESS, backups: BACKUPS };
assert.ok(KITTED_CHARS.includes(ID));
for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(ID), `tier ${t}`);
assert.deepEqual(validateDiyPicks({ [SLOT[6]]: { charId: ID, skillIndex: 2, uniEquipId: ISWA } }, { data, kitted: KITTED_CHARS }),
{ ok: true, picks: { [SLOT[6]]: { charId: ID, skillIndex: 2, uniEquipId: ISWA } } });
});
test('S1 但为求索 (AUTO, 1 / 2 charges, data DEFAULT): the next attack hits two enemies at 200 % / 240 % ATK arts; with one enemy only it lifts it 2.5 / 3 s — before 传承终焉, so that hit is × 1.2 and silences', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S1);
const [ts, sil] = t0Of(tier, elite, null);
assert.deepEqual([sk.skillType, sk.spCost, sk.maxChargeTime, sk.bb.atk_scale, sk.bb.levitate, ts, sil], ['AUTO', 8, elite ? 2 : 1, elite ? 2.4 : 2, elite ? 3 : 2.5, 1.2, 3], `T${tier}`);
// two enemies: two shots, no lift
{
const { h, u } = field({ tier, elite, skill: 0 });
assert.deepEqual([u.skill.rule, u.skill.kind, u.skill.maxCharges], ['DEFAULT', elite ? 'charges' : 'instant', elite ? 2 : 1], `T${tier}`);
u.skill.charges = 0;
u.skill.sp = 0;
const a = h.spawn('enemy_dummy', { pos: [10, 6] }), b = h.spawn('enemy_dummy', { pos: [11, 7] });
u.skill.gainSp(999);
const n0 = hitsBy(h, u).length;
assert.ok(h.runUntil(() => hitsBy(h, u).slice(n0).filter((c) => c.dmg.isSkill).length >= 2, 3), `T${tier}: cast`);
const sh = hitsBy(h, u).slice(n0).filter((c) => c.dmg.isSkill);
assert.deepEqual(new Set(sh.slice(0, 2).map((c) => c.target)), new Set([a, b]), `T${tier}: one more target`);
for (const c of sh.slice(0, 2)) approx(c.amount, u.s.atk * sk.bb.atk_scale, `T${tier}: ${sk.bb.atk_scale * 100} % ATK`);
assert.equal(statusBy(h, u, 'levitate').length, 0, `T${tier}: two shots — no lift`);
done(h);
}
// one enemy: lifted, and that hit already counts as one on an air unit
{
const { h, u } = field({ tier, elite, skill: 0 });
u.skill.charges = 0;
u.skill.sp = 0;
const a = h.spawn('enemy_dummy', { pos: [10, 6] });
u.skill.gainSp(999);
const n0 = hitsBy(h, u).length;
assert.ok(h.runUntil(() => hitsBy(h, u).slice(n0).some((c) => c.dmg.isSkill), 3), `T${tier}: cast`);
const sh = hitsBy(h, u).slice(n0).find((c) => c.dmg.isSkill);
assert.equal(sh.target, a);
approx(sh.amount, u.s.atk * sk.bb.atk_scale * ts, `T${tier}: × ${ts} (lifted first)`);
const lev = statusBy(h, u, 'levitate');
assert.equal(lev.length, 1, `T${tier}: lifted`);
approx(lev[0].duration, sk.bb.levitate, `T${tier}: 浮空 ${sk.bb.levitate} s`);
const sl = statusBy(h, u, 'silence');
assert.ok(sl.length >= 1 && sl[0].target === a, `T${tier}: silenced`);
approx(sl[0].duration, sil, `T${tier}: ${sil} s`);
done(h);
}
}
});
test('S2 群星逶迤 (MANUAL, data DEFAULT): 16 s, each attack 9 shots of 27 % / 35 % ATK at random enemies of her range, each rolling 10 % / 12 % to lift 1 s; a lifted or flying target takes × 1.2', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S2);
const sc = sk.bb['attack@atk_scale'], p = sk.bb['attack@prob'];
assert.deepEqual([sk.duration, sc, p, sk.bb['attack@levitate'], sk.spCost, sk.initSp], [16, elite ? 0.35 : 0.27, elite ? 0.12 : 0.1, 1, elite ? 32 : 36, elite ? 22 : 21], `T${tier}`);
assert.match(sk.desc, /9连发/);
const flying = [];
const setup = (b) => b.on('damaged', (c) => { if (c.source?.uid === 1) flying.push(c.target.isFlying); }, { priority: -100 });
const { h, u } = field({ tier, elite, skill: 1, seed: 21, setup });
assert.equal(u.skill.rule, 'DEFAULT');
let rolls = 0;
const chance = h.b.rng.chance;
h.b.rng.chance = (q) => { if (q === p) rolls++; return chance(q); };
const foes = [[10, 6], [9, 6], [11, 6], [10, 7], [9, 7], [11, 7]].map((pos) => h.spawn('enemy_dummy', { pos }));
const fly = h.spawn('enemy_fly', { pos: [10, 8] });
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 3), `T${tier}: cast`);
approx(u.skill.timeLeft, 16, `T${tier}: 16 s`, 0.05);
const n0 = hitsBy(h, u).length, f0 = flying.length;
assert.ok(h.runUntil(() => !u.skill.active, 17));
h.run(0.6);
const shots = hitsBy(h, u).slice(n0).filter((c) => c.dmg.isSkill);
const fl = flying.slice(f0, f0 + hitsBy(h, u).slice(n0).length);
const byAttack = new Map();
for (const c of shots) byAttack.set(c.dmg.attackId, [...(byAttack.get(c.dmg.attackId) ?? []), c.target]);
assert.ok(byAttack.size >= 9, `T${tier}: ${byAttack.size} attacks`);
for (const [, ts] of byAttack) assert.equal(ts.length, 9, `T${tier}: 9 shots per attack`);
for (const e of [...foes, fly]) assert.ok(shots.some((c) => c.target === e), `T${tier}: ${e.tileR},${e.tileC} drew shots (random)`);
assert.equal(rolls, shots.length, `T${tier}: one ${p * 100} % roll per shot`);
const lifts = statusBy(h, u, 'levitate');
assert.ok(lifts.length > 0.5 * p * shots.length && lifts.length <= 1.3 * p * shots.length, `T${tier}: ${lifts.length} lifts of ${shots.length} shots`);
assert.ok(lifts.every((c) => c.target !== fly && Math.abs(c.duration - 1) < 1e-9), `T${tier}: 1 s, never on a data flyer`);
const all = hitsBy(h, u).slice(n0);
all.forEach((c, i) => {
if (!c.dmg.isSkill) return;
approx(c.amount, u.s.atk * sc * (fl[i] ? 1.2 : 1), `T${tier}: shot ${i} (${fl[i] ? 'air' : 'ground'})`);
});
assert.ok(fl.filter(Boolean).length > shots.filter((c) => c.target === fly).length, `T${tier}: lifted ground enemies took × 1.2 too`);
done(h);
}
});
test('S3 博览者的狂语 (MANUAL, data ACTIVE_RANGE on 3-10): 45 s, interval 1.6 + 1.4 = 3.0 s, range 3-10 while on; each attack three whirlwinds (her row and the rows beside), 1 tile/s, each lifting the first enemy it touches then dealing 2.33→3.5 / 2.53→3.8 × ATK over 1.77 s (× 1.2: lifted)', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S3);
const lo = sk.bb['attack@min_atk_scale'], hi = sk.bb['attack@max_atk_scale'], lev = sk.bb['attack@levitate'];
assert.deepEqual([sk.rangeId, sk.duration, sk.bb.base_attack_time, lo, hi, lev, sk.spCost, sk.initSp], ['3-10', 45, 1.4, elite ? 2.53 : 2.33, elite ? 3.8 : 3.5, elite ? 2 : 1.8, elite ? 64 : 67, elite ? 42 : 41], `T${tier}`);
const { h, u } = field({ tier, elite, skill: 2 });
assert.deepEqual([u.skill.rule, u.skill.triggerGrid], ['ACTIVE_RANGE', sk.rangeGrid], `T${tier}: the trigger`);
h.run(0.6);
u.skill.gainSp(999);
// (10,9) is on 3-10 only: the cast comes with it; lanes: row 9 → (9,6) 1 tile ahead, row 10 → (10,8) before (10,9), row 11 → (11,9)
const far = h.spawn('enemy_dummy', { pos: [10, 9] });
assert.ok(h.runUntil(() => u.skill.active, 2), `T${tier}: cast with an enemy on 3-10 only`);
const near = h.spawn('enemy_dummy', { pos: [9, 6] }), mid = h.spawn('enemy_dummy', { pos: [10, 8] }), low = h.spawn('enemy_dummy', { pos: [11, 9] });
assert.deepEqual(u.liveRangeGrid, sk.rangeGrid, `T${tier}: 3-10 while on`);
approx(u.s.interval, 3, `T${tier}: 3.0 s`);
const n0 = hitsBy(h, u).length;
h.run(6.5);
const hits = hitsBy(h, u).slice(n0);
const first = (e) => hits.find((c) => c.target === e);
const scale = (age) => lo + (hi - lo) * Math.min(1, age / 1.77);
approx(first(near).amount, u.s.atk * scale(0.5) * 1.2, `T${tier}: 0.5 tile flown`, 1e-3);
approx(first(mid).amount, u.s.atk * hi * 1.2, `T${tier}: 2.5 tiles — the maximum`);
approx(first(low).amount, u.s.atk * hi * 1.2, `T${tier}: 3.5 tiles`);
assert.ok(!hits.some((c) => c.target === far), `T${tier}: (10,9) behind (10,8) in its lane is never reached`);
for (const c of hits) assert.deepEqual([c.type, c.dmg.isAttack, c.dmg.isSkill], ['arts', true, true]);
const lifts = statusBy(h, u, 'levitate');
assert.ok(lifts.length >= 3 && lifts.every((c) => Math.abs(c.duration - lev) < 1e-9), `T${tier}: 浮空 ${lev} s`);
assert.ok(statusBy(h, u, 'silence').length >= 3, `T${tier}: 传承终焉 silences the lifted`);
u.skill.end('test');
h.run(0.1);
assert.deepEqual(u.liveRangeGrid, formOf(tier, elite).rangeGrid, `T${tier}: back to 3-1`);
approx(u.s.interval, 1.6, `T${tier}: interval back`);
done(h);
}
});
test('S3 whirlwinds: they fly 5 tiles at most, a heavy (mass > 3) target is lifted half as long, and those out when she leaves fly on without 传承终焉 or her module\'s RES ignore', () => {
// 5 tiles: from her centre (col 4) a box reaches col 9.5 — (11,9) is struck 4.5 tiles out, (10,10) never
{
const { h, u } = field({ tier: 6, elite: true, skill: 2, col: 4 });
u.skill.gainSp(999);
const trig = h.spawn('enemy_dummy', { pos: [9, 8] });
const edge = h.spawn('enemy_dummy', { pos: [11, 9] }), beyond = h.spawn('enemy_dummy', { pos: [10, 10] });
assert.ok(h.runUntil(() => u.skill.active, 2));
h.run(7);
assert.ok(hitsBy(h, u).some((c) => c.target === trig && c.dmg.isSkill), 'the lane-9 whirlwind');
assert.ok(hitsBy(h, u).some((c) => c.target === edge && c.dmg.isSkill), 'struck 4.5 tiles out');
assert.ok(!hitsBy(h, u).some((c) => c.target === beyond), '6 tiles out: the whirlwind is gone');
done(h);
}
// her module leaves with her (CCR-X stage 3 on RES 50): a whirlwind out when she retreats hits × 1 without the RES ignore
{
const { h, u } = field({ tier: 6, elite: true, mod: CCRX, skill: 2 });
u.skill.gainSp(999);
const e = h.spawn('enemy_r50', { pos: [10, 9] });
assert.ok(h.runUntil(() => u.skill.active, 2));
assert.ok(h.runUntil(() => u.mem.heyakWinds.length > 0, 5), 'whirlwinds out');
h.run(0.5);
const atk = u.s.atk;
h.b.retreat(u);
const s0 = statusBy(h, u, 'silence').length;
assert.ok(h.runUntil(() => hitsBy(h, u).some((c) => c.target === e), 6), 'the whirlwind flies on');
const c = hitsBy(h, u).find((x) => x.target === e);
approx(c.amount, arts(atk * 3.8, 50, 0), 'no 传承终焉 (× 1.2) and no RES ignore');
assert.equal(statusBy(h, u, 'silence').length, s0, 'no silence');
assert.equal(statusBy(h, u, 'levitate').filter((x) => x.target === e).length, 1, 'still lifted');
done(h);
}
// a mass-4 target: 浮空 halved (2 s → 1 s at rank 7); 曾有羽翼's 失重 lowers it to 3 while above 80 % HP
{
const { h, u } = field({ tier: 6, elite: true, skill: 2 });
u.skill.gainSp(999);
const heavy = h.spawn('enemy_heavy', { pos: [10, 9] });
heavy.base.massLevel = 4;
heavy.markDirty();
heavy.hp = heavy.s.maxHp * 0.7; // below 80 %: no 失重
assert.ok(h.runUntil(() => u.skill.active, 2));
assert.ok(h.runUntil(() => statusBy(h, u, 'levitate').length > 0, 8));
approx(statusBy(h, u, 'levitate')[0].duration, 1, 'mass 4: half of 2 s');
done(h);
}
});
test('T1 传承终焉: hits on air units × 1.2 and silence 3 s (CCR-X stage 3: × 1.35 / 5 s, ISW-A stage 3: × 1.45 / 5 s; stage 1 modules keep 1.2 / 3); ground targets: neither', () => {
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
const [ts, sil] = t0Of(tier, elite, elite ? mod : null);
const want = elite && tier === 6 && mod === CCRX ? [1.35, 5] : elite && tier === 6 && mod === ISWA ? [1.45, 5] : [1.2, 3];
assert.deepEqual([ts, sil], want, `${label(f)}: the data`);
const { h, u } = field({ tier, elite, mod, skill: 2 });
const fly = h.spawn('enemy_fly', { pos: [10, 6] });
const n0 = hitsBy(h, u).length;
h.run(1.7);
const c = hitsBy(h, u).slice(n0).find((x) => x.target === fly && x.dmg.isAttack);
assert.ok(c, label(f));
approx(c.amount, u.s.atk * ts, `${label(f)}: × ${ts} on a flyer`);
const sl = statusBy(h, u, 'silence').find((x) => x.target === fly);
assert.ok(sl, `${label(f)}: silenced`);
approx(sl.duration, sil, `${label(f)}: ${sil} s`);
h.b.kill(fly, null);
const g = h.spawn('enemy_dummy', { pos: [10, 6] });
const n1 = hitsBy(h, u).length, s1 = statusBy(h, u, 'silence').length;
h.run(1.7);
const cg = hitsBy(h, u).slice(n1).find((x) => x.target === g && x.dmg.isAttack);
approx(cg.amount, u.s.atk, `${label(f)}: × 1 on the ground`);
assert.equal(statusBy(h, u, 'silence').length, s1, `${label(f)}: no silence on the ground`);
done(h);
}
});
test('T2 曾有羽翼: 失重 (weight −1) on every enemy of her range above 80 % HP, gone below it or out of range, never on top of another 失重; CCR-Y stage 3: above 50 %, and 20 % 【法术脆弱】 below 50 %', () => {
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
const y3 = elite && tier === 6 && mod === CCRY;
const thr = y3 ? 0.5 : 0.8;
const { h, u } = field({ tier, elite, mod, skill: 1 });
const inR = h.spawn('enemy_heavy', { pos: [10, 6] }), out = h.spawn('enemy_heavy', { pos: [10, 10] });
h.step(2);
assert.ok(inR.findBuff('heyak:weightless'), `${label(f)}: full HP in range`);
assert.equal(inR.weight, 1, `${label(f)}: mass 2 → 1`);
assert.equal(out.findBuff('heyak:weightless'), null, `${label(f)}: out of range`);
inR.hp = inR.s.maxHp * (thr + 0.01);
h.step();
assert.ok(inR.findBuff('heyak:weightless'), `${label(f)}: above ${thr * 100} %`);
inR.hp = inR.s.maxHp * (thr - 0.01);
h.step();
assert.equal(inR.findBuff('heyak:weightless'), null, `${label(f)}: below ${thr * 100} %`);
assert.equal(inR.weight, 2, `${label(f)}: mass back`);
inR.hp = inR.s.maxHp * 0.3;
h.run(0.5);
const fr = h.hooksOf('statusApplied').filter((c) => c.source === u && c.status === 'artsFragile' && c.target === inR);
assert.equal(fr.length > 0, y3, `${label(f)}: 【法术脆弱】 below 50 %`);
if (y3) { assert.equal(fr[0].value, 0.2); approx(inR.s.artsTakenMul, 1.2, `${label(f)}: arts taken × 1.2`); }
// another source's 失重 (安洁莉娜's key): not doubled
inR.hp = inR.s.maxHp;
h.b.addBuff(inR, { key: 'aglina:weightless', status: 'weightless', duration: 1, mods: { massFlat: -1 } });
h.step();
assert.equal(inR.findBuff('heyak:weightless'), null, `${label(f)}: 同名效果不可叠加`);
assert.equal(inR.weight, 1);
h.run(1.1);
assert.ok(inR.findBuff('heyak:weightless'), `${label(f)}: hers again once the other ended`);
h.b.retreat(u);
h.step();
assert.equal(inR.findBuff('heyak:weightless'), null, `${label(f)}: gone when she leaves`);
done(h);
}
const hp = formOf(6, true).modules.find((m) => m.uniEquipId === CCRY).talentChanges;
assert.deepEqual([hp.find((t) => t.talentIndex === 1).bb, hp.find((t) => t.talentIndex === -1).bb], [{ hp_ratio: 0.5 }, { damage_scale: 1.2, hp_ratio: 0.5 }]);
});
test('modules: CCR-X "无视目标10点法术抗性" (stages 1 and 3); CCR-Y "普通攻击命中精英或领袖敌人时获得1点技力"; ISW-A (集成战略 only): no RES ignore, no SP, no goal-tile 浮空, no radius-1.1 skill splash — its stats and 传承终焉 numbers only', () => {
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
const { h, u } = field({ tier, elite, mod, skill: 2 });
const x = elite && mod === CCRX, y = elite && mod === CCRY, w = elite && mod === ISWA;
assert.equal(u.s.resIgnoreFlat, x ? 10 : 0, `${label(f)}: RES ignore`);
if (w) assert.match(u.def.raw.trait.moduleDesc, /^在集成战略中/, `${label(f)}: the ISW trait text`);
const foe = h.spawn('enemy_r50', { pos: [10, 6] });
const n0 = hitsBy(h, u).length;
h.run(3.5);
const hits = hitsBy(h, u).slice(n0).filter((c) => c.target === foe && c.dmg.isAttack);
assert.ok(hits.length >= 2, label(f));
approx(hits[0].amount, arts(u.s.atk, 50, x ? 10 : 0), `${label(f)}: arts on RES 50`);
const traitSp = () => h.hooksOf('spGain').filter((c) => c.unit === u && c.reason === 'trait').length;
assert.equal(traitSp(), 0, `${label(f)}: no SP from a normal enemy`);
h.b.kill(foe, null);
const el = h.spawn('enemy_elite', { pos: [10, 6] });
const k0 = traitSp(), a0 = hitsBy(h, u).filter((c) => c.target === el && c.dmg.isAttack).length;
h.run(3.5);
const hitsE = hitsBy(h, u).filter((c) => c.target === el && c.dmg.isAttack).length - a0;
assert.equal(traitSp() - k0, y ? hitsE : 0, `${label(f)}: +1 SP per hit on an elite`);
done(h);
}
// ISW-A stage 3: a whirlwind strikes its target only (the 1.1 splash is 集成战略-only)
const { h, u } = field({ tier: 6, elite: true, mod: ISWA, skill: 2 });
u.skill.gainSp(999);
const t = h.spawn('enemy_dummy', { pos: [10, 9] });
const side = h.spawn('enemy_dummy', { pos: [10, 9] });
side.x = 9.6; side.y = 9.7; // 0.67 from it, behind it in lane 10 (whose whirlwind is spent on it), outside lane 9
assert.ok(h.runUntil(() => u.skill.active, 2));
h.run(8);
assert.ok(hitsBy(h, u).some((c) => c.target === t), 'the target');
assert.ok(!hitsBy(h, u).some((c) => c.target === side), 'nothing beside it');
assert.ok(!statusBy(h, u, 'levitate').some((c) => c.target === side), 'no splash 浮空');
done(h);
});
+358
View File
@@ -0,0 +1,358 @@
// test/content/op_huang.test.js — the 自选 operator kit of 煌 (char_017_huang, 6★ 强攻手; kit
// server/sim/content/kits/ops/op-huang.js), fielded the production way (a DIY slot + its `diy` pick, simdata getDiy) in
// every form: tiers 5 / 6, normal (E2 Lv1, skill rank 4, no module) and elite (E2 Lv60, rank 7) with no module, CEN-X
// “过载的武器与过热的心脏” or CEN-Y “稍后回来” at stage 1 (tier 5) / 3 (tier 6). Every number is read back from
// data/backups.json (the form of that slot status); the fidelity checklist of kits/README.md item by item.
// Run: node --test test/content/op_huang.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
import { KITTED_CHARS, OPERATOR_KITS, KITS } from '../../server/sim/content/kits/index.js';
import { diyPool, validateDiyPicks } from '../../shared/diy.js';
const load = (f) => JSON.parse(readFileSync(new URL(`../../data/${f}.json`, import.meta.url), 'utf8'));
const CHESS = load('chess');
const BACKUPS = load('backups');
const HUANG = 'char_017_huang';
const FORMS = BACKUPS.units[HUANG].forms;
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
const CENX = 'uniequip_002_huang', CENY = 'uniequip_003_huang';
const S1 = 'skchr_huang_1', S2 = 'skchr_huang_2', S3 = 'skchr_huang_3';
const formOf = (tier, elite) => FORMS[elite ? (tier === 5 ? '2/60/7/1' : '2/60/7/3') : '2/1/4/0'];
const skillOf = (tier, elite, id) => formOf(tier, elite).skills.find((s) => s.skillId === id);
const modOf = (tier, mod) => (mod ? formOf(tier, true).modules.find((m) => m.uniEquipId === mod) : null);
const approx = (a, b, msg, eps = 1e-6) => assert.ok(Math.abs(a - b) <= eps * Math.max(1, Math.abs(b)), `${msg}: ${a} vs ${b}`);
const dummy = (key, o = {}) => enemyRec({ key, hp: 1e9, speed: 0, mass: 0, ...o });
const ENEMIES = {
enemy_dummy: dummy('enemy_dummy'), enemy_fly: dummy('enemy_fly', { motion: 'FLY' }),
enemy_hitter: dummy('enemy_hitter', { atk: 400, bat: 1 }),
enemy_walk: enemyRec({ key: 'enemy_walk', hp: 1e9, speed: 0.6, mass: 0 }),
};
/** Every 自选 form: [tier, elite, module]. */
const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, CENX, CENY].map((m) => [t, true, m]))];
const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`;
/** A battle with 煌 as uid 1 at (row, col) facing RIGHT, plus `others`. */
function field({ tier = 5, elite = false, mod = null, skill = 2, row = 10, col = 5, others = [], seed = 5 } = {}) {
const h = makeBattle({
defs: { enemies: ENEMIES }, timeLimit: 900, autoFinish: false, seed,
flags: { dpPerSec: 0, dpMax: 999 }, hooks: ['damaged', 'heal', 'skillStart', 'skillEnd', 'statusApplied', 'fatal'], captureNoisy: true,
units: [{ uid: 1, diy: { slot: SLOT[tier], charId: HUANG, skillIndex: skill, uniEquipId: mod }, elite, row, col }, ...others],
});
h.step();
return { h, u: h.unit(1) };
}
const hitsBy = (h, u) => h.hooksOf('damaged').filter((c) => c.source === u && c.target?.side === 'enemy');
function done(h) {
checkInvariants(h.b);
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
}
test('煌 in every 自选 form: her operator kit (all three skills authored), the form\'s stats + module attributes, 1-1 range, blocks 3 and strikes every blocked enemy, melee ground-only, 炎 + 维多利亚, no 特质', () => {
assert.equal(OPERATOR_KITS[HUANG], KITS[HUANG]);
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
for (const skill of [0, 1, 2]) {
const { h, u } = field({ tier, elite, mod, skill });
const form = formOf(tier, elite), m = elite ? modOf(tier, mod) : null;
assert.deepEqual([u.def.charId, u.def.diyFor, u.skill.id, !!u.kit.generic, u.kit.skillSource], [HUANG, SLOT[tier], form.skills[skill].skillId, false, 'skills'], label(f));
assert.deepEqual([u.base.maxHp, u.base.atk, u.base.def], [form.stats.maxHp + (m?.attr.maxHp ?? 0), form.stats.atk + (m?.attr.atk ?? 0), form.stats.def + (m?.attr.def ?? 0)], `${label(f)}: stats`);
assert.deepEqual([u.s.blockCnt, u.profile.hitAllBlocked, u.profile.attack, u.profile.canHitFly, u.profile.dmgType, u.base.bat], [3, true, 'melee', false, 'phys', 1.2], `${label(f)}: 强攻手`);
assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: 1-1`);
assert.deepEqual([u.def.bonds, u.def.raw.garrisonIds], [['yanShip', 'victoriaShip'], []], `${label(f)}: bonds / 特质`);
assert.ok(!u.s.flags.liftoff && !u.s.flags.camou && !u.s.flags.stealth && !u.s.flags.untargetable, `${label(f)}: ground enemies target her`);
done(h);
}
}
// the numbers of the forms (zh_CN): E2 Lv1 2115 / 581 / 303, E2 Lv60 2583 / 703 / 347; CEN-X +240 / +50 → +285 / +86
// HP / ATK, CEN-Y +300 / +40 → +345 / +66
assert.deepEqual([FORMS['2/1/4/0'].stats.maxHp, FORMS['2/1/4/0'].stats.atk, FORMS['2/60/7/1'].stats.maxHp, FORMS['2/60/7/1'].stats.atk], [2115, 581, 2583, 703]);
assert.deepEqual([modOf(5, CENX).attr, modOf(6, CENX).attr, modOf(5, CENY).attr, modOf(6, CENY).attr], [{ maxHp: 240, atk: 50 }, { maxHp: 285, atk: 86 }, { maxHp: 300, atk: 40 }, { maxHp: 345, atk: 66 }]);
});
test('a 自选 pick: 煌 is offered at tiers 5 and 6 (she has a kit) and a roster with her passes validateDiyPicks', () => {
const data = { chess: CHESS, backups: BACKUPS };
assert.ok(KITTED_CHARS.includes(HUANG));
for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(HUANG), `tier ${t}`);
assert.deepEqual(validateDiyPicks({ [SLOT[5]]: { charId: HUANG, skillIndex: 2, uniEquipId: CENY } }, { data, kitted: KITTED_CHARS }),
{ ok: true, picks: { [SLOT[5]]: { charId: HUANG, skillIndex: 2, uniEquipId: CENY } } });
});
test('S1 强力击·γ型 (AUTO, attack SP 3, data DEFAULT): her 4th attack is the skill one — 205 % / 225 % ATK on every enemy she blocks', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S1);
const { h, u } = field({ tier, elite, skill: 0, row: 9 });
assert.deepEqual([u.skill.rule, u.skill.spType, u.skill.spCost, sk.initSp, sk.bb.atk_scale], ['DEFAULT', 'attack', 3, 0, elite ? 2.25 : 2.05], `T${tier}`);
const foes = [0, 1, 2].map(() => h.spawn('enemy_dummy', { pos: [9, 5] }));
h.step();
assert.equal(u.blocking.length, 3, `T${tier}: blocks all three`);
assert.ok(h.runUntil(() => u.skill.activations === 1, 10), `T${tier}: cast`);
h.step();
const attacks = [...new Set(hitsBy(h, u).map((c) => c.dmg.attackId))];
assert.equal(attacks.length, 4, `T${tier}: the 4th attack (3 SP, then the skill attack)`);
const last = hitsBy(h, u).filter((c) => c.dmg.attackId === attacks[3]);
assert.deepEqual(last.map((c) => c.target).sort((a, b) => a.id - b.id), foes.sort((a, b) => a.id - b.id), `T${tier}: every blocked enemy`);
for (const c of last) {
approx(c.amount, u.s.atk * sk.bb.atk_scale, `T${tier}: ${sk.bb.atk_scale * 100} %`);
assert.deepEqual([c.type, c.dmg.isSkill], ['phys', true]);
}
for (const c of hitsBy(h, u).filter((c) => c.dmg.attackId !== attacks[3])) approx(c.amount, u.s.atk, `T${tier}: plain attacks`);
done(h);
}
});
test('S2 链锯延伸模块 (AUTO, 持续时间无限): on as soon as its 85 / 80 SP are full — no enemy needed —, ATK +55 % / +70 %, DEF +15 % / +20 %, range 2-2, never ends', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S2);
const { h, u } = field({ tier, elite, skill: 1 });
assert.deepEqual([u.skill.kind, u.skill.rule, u.skill.spCost, sk.initSp, sk.bb.atk, sk.bb.def, sk.duration], ['toggle', 'SP_FULL', elite ? 80 : 85, 0, elite ? 0.7 : 0.55, elite ? 0.2 : 0.15, -1], `T${tier}`);
h.run(sk.spCost - 1);
assert.equal(u.skill.activations, 0, `T${tier}: not before its SP is full`);
assert.ok(h.runUntil(() => u.skill.active, 2), `T${tier}: on at full SP, no enemy on the field`);
approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `T${tier}: ATK`);
approx(u.s.def, u.base.def * (1 + sk.bb.def), `T${tier}: DEF`);
assert.deepEqual(u.liveRangeGrid, sk.rangeGrid, `T${tier}: 2-2`);
assert.deepEqual(sk.rangeGrid, [[0, 0], [0, 1], [0, 2]]);
const far = h.spawn('enemy_dummy', { pos: [10, 7] });
h.run(3);
assert.ok(hitsBy(h, u).some((c) => c.target === far), `T${tier}: strikes two tiles ahead`);
h.run(300);
assert.ok(u.skill.active, `T${tier}: still on (持续时间无限)`);
done(h);
}
});
test('S3 沸腾爆裂 (data DEFAULT, 维持技能状态, 10 s): no normal attack; a cut every second for 8 s on the ground enemies of her 1-1 (not air), ATK / DEF ramp to +45 % / +60 % (full at 8.95 s); at 9 s −25 % max HP (never below 1) and 320 % / 340 % on the 3 × 3 ahead, air too', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S3);
const { h, u } = field({ tier, elite, skill: 2 });
assert.deepEqual([u.skill.rule, u.skill.kind, sk.duration, sk.bb.atk, sk.bb.def, sk.bb.hp_ratio, sk.bb.damage_by_atk_scale, sk.rangeGrid],
['DEFAULT', 'duration', 10, elite ? 0.6 : 0.45, elite ? 0.6 : 0.45, 0.25, elite ? 3.4 : 3.2, null], `T${tier}`);
const front = h.spawn('enemy_dummy', { pos: [10, 6] });
const own = h.spawn('enemy_dummy', { pos: [10, 5] });
const flyIn = h.spawn('enemy_fly', { pos: [10, 6] });
const side = h.spawn('enemy_dummy', { pos: [9, 6] }); // 3-6, not 1-1
const ahead2 = h.spawn('enemy_dummy', { pos: [10, 7] }); // 3-6, not 1-1
const flySide = h.spawn('enemy_fly', { pos: [11, 7] }); // 3-6 (air)
const outside = h.spawn('enemy_dummy', { pos: [10, 8] });
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 3), `T${tier}: cast with an enemy in her range`);
const t0 = h.b.time;
approx(u.s.atk, u.base.atk, `T${tier}: no bonus before the first update`);
const at = [], hits = [];
let ended = false, lossAt = null;
h.b.on('damaged', (c) => { if (c.target === u && c.dmg?.tags?.includes('hpLoss')) lossAt = h.b.time - t0; });
h.b.on('skillEnd', (c) => { if (c.unit === u) ended = true; });
h.b.on('damaged', (c) => {
if (c.source !== u || c.target?.side !== 'enemy' || ended) return;
hits.push(c);
if (c.target === front) at.push({ t: h.b.time - t0, amt: c.amount, atk: u.s.atk, def: u.s.def });
});
h.runUntil(() => !u.skill.active, 12);
const span = h.b.time - t0;
assert.ok(span > 9.9 && span < 10.1, `T${tier}: the state lasts its 10 s (${span})`);
approx(lossAt, 9, `T${tier}: the finish at 9 s`, 0.05);
approx(at[8].t, 9, `T${tier}: the burst at 9 s`, 0.05);
assert.ok(hits.every((c) => c.dmg.isSkill && !c.dmg.isAttack), `T${tier}: no normal attack while it runs`);
const cutsOn = (e) => hits.filter((c) => c.target === e).length;
assert.deepEqual([cutsOn(front), cutsOn(own)], [9, 9], `T${tier}: 8 cuts + the burst on her 1-1`);
assert.deepEqual([cutsOn(flyIn), cutsOn(side), cutsOn(ahead2), cutsOn(flySide), cutsOn(outside)], [1, 1, 1, 1, 0], `T${tier}: the burst alone on the 3 × 3 (air too); nothing outside`);
const D = sk.duration - 1.05;
for (let k = 1; k <= 8; k++) {
const c = at[k - 1];
approx(c.t, k, `T${tier}: cut ${k} at ${k} s`, 0.05);
approx(c.atk, u.base.atk * (1 + sk.bb.atk * Math.min(1, k / D)), `T${tier}: ATK at cut ${k}`);
approx(c.def, u.base.def * (1 + sk.bb.def * Math.min(1, k / D)), `T${tier}: DEF at cut ${k}`);
approx(c.amt, c.atk, `T${tier}: cut ${k} = 100 % ATK`);
}
approx(at[8].amt, u.base.atk * (1 + sk.bb.atk) * sk.bb.damage_by_atk_scale, `T${tier}: the burst at full ATK`);
const loss = h.hooksOf('damaged').filter((c) => c.target === u && c.dmg?.tags?.includes('hpLoss'));
assert.equal(loss.length, 1, `T${tier}: one 流失`);
approx(loss[0].amount, u.s.maxHp * 0.25, `T${tier}: 25 % of MAX HP`);
assert.equal(u.findBuff('skill:huang:ramp'), null, `T${tier}: the bonus goes with the skill`);
approx(u.s.atk, u.base.atk, `T${tier}: ATK back`);
assert.deepEqual(u.liveRangeGrid, formOf(tier, elite).rangeGrid, `T${tier}: her range throughout`);
done(h);
}
});
test('S3 沸腾爆裂: the 流失 never takes her below 1 HP; a 晕眩 ends the 维持技能状态 early — it still finishes (流失 + burst)', () => {
const { h, u } = field({ tier: 6, elite: true, skill: 2 });
u.skill.gainSp(999);
h.run(2);
assert.equal(u.skill.activations, 0, 'DEFAULT: no enemy, no cast');
const e = h.spawn('enemy_dummy', { pos: [10, 6] });
assert.ok(h.runUntil(() => u.skill.active, 3));
h.run(3.2);
const n0 = hitsBy(h, u).filter((c) => c.target === e).length;
assert.equal(n0, 3, 'three cuts');
h.b.applyStatus(u, 'stun', { duration: 2, source: e });
h.step();
assert.ok(!u.skill.active, 'the stun ended it');
const after = hitsBy(h, u).filter((c) => c.target === e);
assert.equal(after.length, 4, 'the burst on its end');
approx(after[3].amount, u.base.atk * (1 + 0.6 * 3 / 8.95) * 3.4, 'the burst at the ATK of that moment (three updates)');
done(h);
// the non-lethal 流失: at 10 % HP the skill end leaves her at 1 HP (her 紧急除颤 then fires: HP ≤ 25 %)
const r = field({ tier: 5, elite: false, skill: 2 });
r.h.spawn('enemy_dummy', { pos: [10, 6] });
r.u.mem.huangDefib.undeadA = false; // talent already spent: only the 流失's own floor
r.u.hp = r.u.s.maxHp * 0.1;
r.u.skill.gainSp(999);
assert.ok(r.h.runUntil(() => r.u.skill.active, 3));
r.h.runUntil(() => !r.u.skill.active, 12);
assert.ok(r.u.alive && Math.abs(r.u.hp - 1) < 1e-6, `left at 1 HP (${r.u.hp})`);
done(r.h);
});
test('T1 紧急除颤: 不死 until her HP first reaches ≤ 25 % — then +50 % max HP and for 6 s no damage takes her below 50 % (a 流失 still does); once per deployment, again after a redeploy', () => {
for (const f of [[5, false, null], [6, true, CENX], [5, true, CENY]]) {
const [tier, elite, mod] = f;
const { h, u } = field({ tier, elite, mod, skill: 0 });
const t0 = u.def.raw.talents.find((t) => t.index === 0).bb;
assert.deepEqual([t0.hp_ratio, t0['huang_t_1[heal].hp_ratio'], t0['huang_t_1[lock].min_hp_ratio'], t0['huang_t_1[lock].duration']], [0.25, 0.5, 0.5, 6], label(f));
const e = h.spawn('enemy_dummy', { pos: [10, 6] });
const max = u.s.maxHp;
// a lethal hit from full HP: 不死 keeps her at 1 HP, the talent heals 50 %
h.b.dealDamage(e, u, { amount: max * 5, type: 'true' });
assert.ok(u.alive, `${label(f)}: 不死`);
approx(u.hp, 1 + max * 0.5, `${label(f)}: 1 HP + 50 %`);
const lockStart = h.b.time;
// inside 6 s: a 60 % hit stops at exactly 50 %
h.b.dealDamage(e, u, { amount: max * 0.6, type: 'true' });
approx(u.hp, max * 0.5, `${label(f)}: held at 50 %`);
h.b.dealDamage(e, u, { amount: max * 0.3, type: 'true' });
approx(u.hp, max * 0.5, `${label(f)}: below 50 % nothing passes`);
h.b.loseHp(u, max * 0.1, { source: e });
approx(u.hp, max * 0.4, `${label(f)}: a 流失 is not held`);
h.run(6 - (h.b.time - lockStart) + 0.1);
h.b.dealDamage(e, u, { amount: max * 0.3, type: 'true' });
approx(u.hp, max * 0.1, `${label(f)}: the lock is over`);
h.b.dealDamage(e, u, { amount: max, type: 'true' });
assert.ok(!u.alive, `${label(f)}: spent — the next lethal hit kills`);
// a redeploy re-arms it
h.b.redeploy(u);
h.step();
h.b.dealDamage(e, u, { amount: u.s.maxHp * 0.8, type: 'true' });
approx(u.hp, u.s.maxHp * 0.7, `${label(f)}: again after the redeploy (20 % + 50 %)`);
done(h);
}
});
test('T2 严酷训练: 15 s after each deployment 抵抗 (control statuses last half as long); CEN-X stage 3 adds ATK +6 % after 30 s and ASPD +12 after 45 s (stage 1: nothing more)', () => {
for (const f of [[5, false, null], [5, true, CENX], [6, true, CENX], [6, true, CENY]]) {
const [tier, elite, mod] = f;
const { h, u } = field({ tier, elite, mod, skill: 0 });
const x3 = tier === 6 && mod === CENX;
const e = h.spawn('enemy_dummy', { pos: [10, 6] });
h.run(14.8);
assert.equal(h.b.resistOf(u), 0, `${label(f)}: no 抵抗 before 15 s`);
h.run(0.4);
assert.equal(h.b.resistOf(u), 0.5, `${label(f)}: 抵抗 at 15 s`);
h.b.applyStatus(u, 'stun', { duration: 2, source: e });
approx(u.findBuff('stun').timeLeft, 1, `${label(f)}: a 2 s stun lasts 1 s`);
h.run(15);
assert.equal(!!u.findBuff('talent:huang:atk'), x3, `${label(f)}: ATK +6 % after 30 s`);
if (x3) approx(u.s.atk, u.base.atk * 1.06, 'ATK +6 %');
h.run(15);
assert.equal(!!u.findBuff('talent:huang:aspd'), x3, `${label(f)}: ASPD +12 after 45 s`);
if (x3) assert.equal(u.s.aspd, 112);
// per deployment: knocked out and back, the timer starts again
h.b.kill(u, e);
assert.ok(!u.alive);
h.b.redeploy(u);
h.step();
assert.deepEqual([h.b.resistOf(u), !!u.findBuff('talent:huang:atk')], [0, false], `${label(f)}: reset by the redeploy`);
h.run(15.2);
assert.equal(h.b.resistOf(u), 0.5, `${label(f)}: 抵抗 again 15 s later`);
done(h);
}
});
test('CEN-X (stages 1 and 3): ×1.1 ATK scale on every damage of hers to a blocked enemy (normal attacks and S3 cuts) — none to an unblocked one; none without the module', () => {
for (const f of [[5, true, null], [5, true, CENX], [6, true, CENX], [6, true, CENY]]) {
const [tier, elite, mod] = f;
const mul = mod === CENX ? 1.1 : 1;
if (mod === CENX) assert.deepEqual(formOf(tier, true).modules.find((m) => m.uniEquipId === CENX).traitOverride.bb, { atk_scale: 1.1 });
const { h, u } = field({ tier, elite, mod, skill: 2, row: 9 });
const blocked = h.spawn('enemy_dummy', { pos: [9, 5] });
h.step();
assert.equal(blocked.blockedBy, u, `${label(f)}: blocked`);
h.run(3);
const plain = hitsBy(h, u).filter((c) => c.target === blocked && !c.dmg.isSkill);
assert.ok(plain.length >= 2, label(f));
for (const c of plain) approx(c.amount, u.s.atk * mul, `${label(f)}: blocked target`);
const free = h.spawn('enemy_dummy', { pos: [9, 6] });
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 3));
h.run(1.1);
const cut = hitsBy(h, u).filter((c) => c.dmg.isSkill);
approx(cut.find((c) => c.target === blocked).amount, u.s.atk * mul, `${label(f)}: S3 cut on the blocked one`);
approx(cut.find((c) => c.target === free).amount, u.s.atk, `${label(f)}: S3 cut on the free one`);
done(h);
}
});
test('CEN-Y stage 1: −20 % physical damage taken above 50 % HP (arts untouched); stage 3: 紧急除颤 at 50 % then a second trigger at 25 % (its own 不死, 8 s), and 150 DEF ignored while above 50 %', () => {
for (const tier of [5, 6]) {
const { h, u } = field({ tier, elite: true, mod: CENY, skill: 0 });
assert.deepEqual(u.def.raw.trait.bb, { hp_ratio: 0.5, damage_resistance: 0.2 });
const e = h.spawn('enemy_dummy', { pos: [10, 6] });
h.step();
assert.equal(u.s.defIgnoreFlat, tier === 6 ? 150 : 0, `T${tier}: 150 DEF ignored above 50 % from stage 2 on`);
const max = u.s.maxHp;
let hp = u.hp;
h.b.dealDamage(e, u, { amount: 1000, type: 'phys' });
approx(hp - u.hp, Math.max(1000 - u.s.def, 50) * 0.8, `T${tier}: physical ×0.8`);
hp = u.hp;
h.b.dealDamage(e, u, { amount: 300, type: 'arts' });
approx(hp - u.hp, 300, `T${tier}: arts untouched`);
hp = u.hp;
h.b.dealDamage(e, u, { amount: 200, type: 'true' });
approx(hp - u.hp, 200, `T${tier}: true untouched`);
u.mem.huangDefib.undeadA = u.mem.huangDefib.undeadB = false; // (stage 3's talent would fire at 50 %)
u.hp = max * 0.5;
hp = u.hp;
h.b.dealDamage(e, u, { amount: 1000, type: 'phys' });
approx(hp - u.hp, Math.max(1000 - u.s.def, 50), `T${tier}: at 50 % (not above) the full physical damage`);
done(h);
}
// stage 3: the talent fires at 50 % (8 s), the hidden part at 25 % — only once the first is spent
const { h, u } = field({ tier: 6, elite: true, mod: CENY, skill: 0 });
const t0 = u.def.raw.talents.find((t) => t.index === 0).bb;
const hid = Object.assign({}, ...u.def.raw.talents.filter((t) => t.index === -1).map((t) => t.bb));
assert.deepEqual([t0.hp_ratio, t0['huang_t_1[lock].duration'], hid.def_penetrate_fixed, hid['huang_e_003[lock].check_hp_ratio'], hid['huang_e_003[lock].duration']], [0.5, 8, 150, 0.25, 8]);
const e = h.spawn('enemy_dummy', { pos: [10, 6] });
h.step();
assert.equal(u.s.defIgnoreFlat, 150, 'above 50 %: 150 DEF ignored');
const max = u.s.maxHp;
h.b.dealDamage(e, u, { amount: max * 0.6, type: 'true' }); // 100 % → 40 %: the 50 % trigger
approx(u.hp, max * 0.9, '50 % trigger: +50 %');
h.run(8.2);
h.b.dealDamage(e, u, { amount: max * 0.3, type: 'true' }); // 90 % → 60 %
h.step();
assert.equal(u.s.defIgnoreFlat, 150);
h.b.dealDamage(e, u, { amount: max * 0.2, type: 'true' }); // 60 % → 40 %: nothing (the first is spent)
approx(u.hp, max * 0.4, 'no second 50 % trigger');
h.step();
assert.equal(u.s.defIgnoreFlat, 0, 'at 40 % the DEF penetration is off');
h.b.dealDamage(e, u, { amount: max * 5, type: 'true' }); // lethal: its own 不死, then the 25 % trigger
assert.ok(u.alive, 'the second 不死');
approx(u.hp, 1 + max * 0.5, '25 % trigger: +50 %');
h.b.dealDamage(e, u, { amount: max * 0.4, type: 'true' });
approx(u.hp, max * 0.5, 'its 8 s lock holds 50 %');
h.run(8.2);
h.b.dealDamage(e, u, { amount: max, type: 'true' });
assert.ok(!u.alive, 'both spent');
done(h);
// a single hit from above 50 % to 10 %: the 50 % trigger first (+50 % ⇒ 60 %), the 25 % one waits for a later drop
const r = field({ tier: 6, elite: true, mod: CENY, skill: 0 });
const e2 = r.h.spawn('enemy_dummy', { pos: [10, 6] });
const m2 = r.u.s.maxHp;
r.h.b.dealDamage(e2, r.u, { amount: m2 * 0.9, type: 'true' });
approx(r.u.hp, m2 * 0.6, 'the 50 % trigger first');
assert.deepEqual([r.u.mem.huangDefib.undeadA, r.u.mem.huangDefib.undeadB], [false, true], 'the 25 % trigger still armed');
done(r.h);
});
+294
View File
@@ -0,0 +1,294 @@
// test/content/op_ifrit.test.js — the 自选 operator kit of 伊芙利特 (char_134_ifrit, 6★ 轰击术师; kit
// server/sim/content/kits/ops/op-ifrit.js), fielded the production way (a DIY slot + its `diy` pick, simdata getDiy) in
// every form: tiers 5 / 6, normal (E2 Lv1, skill rank 4, no module) and elite (E2 Lv60, rank 7) with no module, BLA-X
// “同熔” or BLA-D “热成形记忆” at stage 1 (tier 5) / 3 (tier 6). Every number is read back from data/backups.json (the form of
// that slot status); the fidelity checklist of kits/README.md item by item.
// Run: node --test test/content/op_ifrit.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
import { KITTED_CHARS, OPERATOR_KITS, KITS } from '../../server/sim/content/kits/index.js';
import { diyPool, validateDiyPicks } from '../../shared/diy.js';
const load = (f) => JSON.parse(readFileSync(new URL(`../../data/${f}.json`, import.meta.url), 'utf8'));
const CHESS = load('chess');
const BACKUPS = load('backups');
const IFRIT = 'char_134_ifrit';
const FORMS = BACKUPS.units[IFRIT].forms;
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
const BLAX = 'uniequip_002_ifrit', BLAD = 'uniequip_003_ifrit';
const S1 = 'skchr_ifrit_1', S2 = 'skchr_ifrit_2', S3 = 'skchr_ifrit_3';
const formOf = (tier, elite) => FORMS[elite ? (tier === 5 ? '2/60/7/1' : '2/60/7/3') : '2/1/4/0'];
const skillOf = (tier, elite, id) => formOf(tier, elite).skills.find((s) => s.skillId === id);
const modOf = (tier, mod) => (mod ? formOf(tier, true).modules.find((m) => m.uniEquipId === mod) : null);
const approx = (a, b, msg, eps = 1e-6) => assert.ok(Math.abs(a - b) <= eps * Math.max(1, Math.abs(b)), `${msg}: ${a} vs ${b}`);
const dummy = (key, o = {}) => enemyRec({ key, hp: 1e9, speed: 0, mass: 0, ...o });
const ENEMIES = {
enemy_dummy: dummy('enemy_dummy'), enemy_fly: dummy('enemy_fly', { motion: 'FLY' }),
enemy_res: dummy('enemy_res', { res: 50 }), enemy_def: dummy('enemy_def', { def: 300 }),
enemy_hitter: dummy('enemy_hitter', { atk: 1000, range: 0, bat: 1 }),
};
/** Every 自选 form: [tier, elite, module]. */
const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, BLAX, BLAD].map((m) => [t, true, m]))];
const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`;
/** A battle with 伊芙利特 as uid 1 at (row, col) facing RIGHT (her line: that row, col … col + 5), plus `others`. */
function field({ tier = 5, elite = false, mod = null, skill = 0, row = 10, col = 4, others = [], seed = 5, flags = {} } = {}) {
const h = makeBattle({
defs: { enemies: ENEMIES }, timeLimit: 900, autoFinish: false, seed,
flags: { dpPerSec: 0, dpMax: 999, ...flags }, hooks: ['damaged', 'skillStart', 'skillEnd', 'statusApplied', 'spGain'], captureNoisy: true,
units: [{ uid: 1, diy: { slot: SLOT[tier], charId: IFRIT, skillIndex: skill, uniEquipId: mod }, elite, row, col }, ...others],
});
h.step();
return { h, u: h.unit(1) };
}
const atkHits = (h, u) => h.hooksOf('damaged').filter((c) => c.source === u && c.dmg?.isAttack);
const tagged = (h, u, tag) => h.hooksOf('damaged').filter((c) => (c.source === u || c.credit === u) && c.dmg?.tags?.includes(tag));
function done(h) {
checkInvariants(h.b);
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
}
test('伊芙利特 in every 自选 form: her operator kit (all three skills authored), the form\'s stats + module attributes, 5-1 line, 轰击术师 (every enemy of the line, air too, arts, instant), no 特质', () => {
assert.equal(OPERATOR_KITS[IFRIT], KITS[IFRIT]);
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
for (const skill of [0, 1, 2]) {
const { h, u } = field({ tier, elite, mod, skill });
const form = formOf(tier, elite), m = elite ? modOf(tier, mod) : null;
assert.deepEqual([u.def.charId, u.def.diyFor, u.skill.id, !!u.kit.generic, u.kit.skillSource], [IFRIT, SLOT[tier], form.skills[skill].skillId, false, 'skills'], label(f));
assert.deepEqual([u.base.maxHp, u.base.atk, u.base.def, u.base.aspd], [form.stats.maxHp + (m?.attr.maxHp ?? 0), form.stats.atk + (m?.attr.atk ?? 0), form.stats.def + (m?.attr.def ?? 0), 100 + (m?.attr.aspd ?? 0)], `${label(f)}: stats`);
assert.deepEqual([u.profile.attack, u.profile.dmgType, u.profile.canHitFly, u.profile.allInRange, u.profile.projectile, u.base.bat], ['ranged', 'arts', true, true, 'beam', 2.9], `${label(f)}: 轰击术师`);
assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: 5-1`);
assert.deepEqual([u.def.bonds, u.def.raw.garrisonIds], [BACKUPS.diy.operators[IFRIT].bonds, []], `${label(f)}: bonds / 特质`);
assert.ok(!u.s.flags.liftoff && !u.s.flags.camou && !u.s.flags.stealth, `${label(f)}: enemies target her`);
done(h);
}
}
// E2 Lv1 1276 / 722, E2 Lv60 1544 / 820; BLA-X +50 ATK +5 ASPD → +72 / +7, BLA-D +120 HP +45 ATK → +200 / +70
assert.deepEqual([FORMS['2/1/4/0'].stats.maxHp, FORMS['2/1/4/0'].stats.atk, FORMS['2/60/7/1'].stats.maxHp, FORMS['2/60/7/1'].stats.atk], [1276, 722, 1544, 820]);
assert.deepEqual([modOf(5, BLAX).attr, modOf(6, BLAX).attr, modOf(5, BLAD).attr, modOf(6, BLAD).attr], [{ atk: 50, aspd: 5 }, { atk: 72, aspd: 7 }, { maxHp: 120, atk: 45 }, { maxHp: 200, atk: 70 }]);
});
test('a 自选 pick: 伊芙利特 is offered at tiers 5 and 6 (she has a kit) and a roster with her passes validateDiyPicks', () => {
const data = { chess: CHESS, backups: BACKUPS };
assert.ok(KITTED_CHARS.includes(IFRIT));
for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(IFRIT), `tier ${t}`);
assert.deepEqual(validateDiyPicks({ [SLOT[6]]: { charId: IFRIT, skillIndex: 2, uniEquipId: BLAD } }, { data, kitted: KITTED_CHARS }),
{ ok: true, picks: { [SLOT[6]]: { charId: IFRIT, skillIndex: 2, uniEquipId: BLAD } } });
});
test('trait 轰击术师: one attack strikes every enemy of her 5-1 line at once — ground and air, the same damage near and far (no module) — and nothing off the line', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const { h, u } = field({ tier, elite, skill: 0 });
const near = h.spawn('enemy_dummy', { pos: [10, 5] }), far = h.spawn('enemy_dummy', { pos: [10, 9] });
const fly = h.spawn('enemy_fly', { pos: [10, 7] }), off = h.spawn('enemy_dummy', { pos: [11, 5] }), behind = h.spawn('enemy_dummy', { pos: [10, 3] });
assert.ok(h.runUntil(() => atkHits(h, u).length > 0, 4), `T${tier}: she attacks`);
const first = atkHits(h, u);
const hit = first.filter((c) => c.dmg.attackId === first[0].dmg.attackId);
assert.deepEqual(hit.map((c) => c.target.id).sort(), [near, far, fly].map((e) => e.id).sort(), `T${tier}: her line, air included`);
for (const c of hit) { approx(c.amount, u.s.atk, `T${tier}: 100 % ATK at any distance`); assert.equal(c.type, 'arts'); }
for (const e of [off, behind]) assert.ok(!hit.some((c) => c.target === e), `T${tier}: off the line`);
done(h);
}
});
test('S1 狂热 (MANUAL, data DEFAULT, 20 s): ATK +10 % / +20 %, ASPD +58 / +67; all back after', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S1);
const { h, u } = field({ tier, elite, skill: 0 });
assert.deepEqual([u.skill.rule, sk.duration, sk.bb.atk, sk.bb.attack_speed, u.skill.spCost], ['DEFAULT', 20, elite ? 0.2 : 0.1, elite ? 67 : 58, elite ? 44 : 47], `T${tier}`);
u.skill.gainSp(999);
h.run(1);
assert.equal(u.skill.activations, 0, `T${tier}: no enemy, no cast`);
h.spawn('enemy_dummy', { pos: [10, 6] });
assert.ok(h.runUntil(() => u.skill.active, 2), `T${tier}: cast on her attack`);
approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `T${tier}: ATK`);
assert.equal(u.s.aspd, 100 + sk.bb.attack_speed, `T${tier}: ASPD`);
approx(u.skill.timeLeft, 20, `T${tier}: 20 s`, 0.01);
assert.deepEqual(u.liveRangeGrid, formOf(tier, elite).rangeGrid, `T${tier}: 5-1 while it runs`);
h.runUntil(() => !u.skill.active, 25);
approx(u.s.atk, u.base.atk, `T${tier}: ATK back`);
assert.equal(u.s.aspd, 100, `T${tier}: ASPD back`);
done(h);
}
});
test('S2 炎爆 (AUTO, 2 / 3 charges, data DEFAULT): the next attack hits her whole line for 160 % / 190 %, DEF −100 / −200 for 3 s and the 灼伤 (33 % of her current ATK per s, 3 ticks, no dodge) on every enemy hit — the burn ticks on after she leaves', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S2);
const { h, u } = field({ tier, elite, skill: 1 });
assert.deepEqual([u.skill.rule, u.skill.kind, u.skill.maxCharges, u.skill.spCost, sk.bb.atk_scale, sk.bb.def, sk.bb['burn.atk_scale'], sk.bb.duration],
['DEFAULT', 'charges', elite ? 3 : 2, 8, elite ? 1.9 : 1.6, elite ? -200 : -100, 0.33, 3.01], `T${tier}`);
const a = h.spawn('enemy_def', { pos: [10, 6] }), b = h.spawn('enemy_fly', { pos: [10, 8] }), off = h.spawn('enemy_dummy', { pos: [11, 6] });
u.skill.gainSp(999);
const n0 = atkHits(h, u).length;
assert.ok(h.runUntil(() => u.skill.activations === 1 && atkHits(h, u).length > n0, 4), `T${tier}: cast on her next attack`);
const hits = atkHits(h, u).slice(n0);
const t0 = h.b.time;
for (const c of hits) assert.equal(c.dmg.isSkill, true);
approx(hits.find((c) => c.target === b).amount, u.s.atk * sk.bb.atk_scale, `T${tier}: ${sk.bb.atk_scale * 100} % on the flyer`);
approx(a.s.def, 300 + sk.bb.def < 0 ? 0 : 300 + sk.bb.def, `T${tier}: DEF cut`);
assert.equal(off.findBuff('ifrit:pyroDef'), null, `T${tier}: off the line`);
assert.ok(a.findBuff('ifrit:pyroBurn') && b.findBuff('ifrit:pyroBurn'), `T${tier}: both burn`);
// the burn: 3 ticks of 33 % of her current ATK; she leaves the field after the first one
h.runUntil(() => tagged(h, u, 'ifrit:burn').filter((c) => c.target === b).length >= 1, 2);
const atkNow = u.s.atk;
h.b.retreat(u);
h.run(3);
const burns = tagged(h, u, 'ifrit:burn').filter((c) => c.target === b);
assert.equal(burns.length, 3, `T${tier}: 3 ticks`);
approx(burns[0].amount, atkNow * 0.33, `T${tier}: 33 % ATK arts`);
for (const c of burns) { assert.deepEqual([c.type, c.dmg.canDodge], ['arts', false]); assert.ok(c.t - t0 <= 3.05, `T${tier}: within 3 s`); }
assert.equal(a.findBuff('ifrit:pyroDef'), null, `T${tier}: DEF cut over after 3 s`);
done(h);
}
});
test('S3 灼地 (MANUAL, data DEFAULT, 20 s, 维持技能状态): no normal attack; from the Begin clip\'s event (0.4 s) every second each ground enemy of her line takes 90 % / 110 % ATK arts and RES −7 / −10 for 1 s; she loses 2 % max HP per s; 20 ticks; a stun ends it at once', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S3);
const { h, u } = field({ tier, elite, skill: 2 });
assert.deepEqual([u.skill.rule, sk.duration, sk.bb.atk_scale, sk.bb.magic_resistance, sk.bb.hp_ratio], ['DEFAULT', 20, elite ? 1.1 : 0.9, elite ? -10 : -7, 0.02], `T${tier}`);
const g = h.spawn('enemy_res', { pos: [10, 6] }), fly = h.spawn('enemy_fly', { pos: [10, 7] }), off = h.spawn('enemy_res', { pos: [11, 6] });
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 4), `T${tier}: cast`);
const n0 = atkHits(h, u).length, hp0 = u.hp, cast = h.hooksOf('skillStart').at(-1).t;
h.run(1.02);
// RES 50 × 0.6 (精神融解) − 7 / −10: (50 − cut) × 0.6
approx(g.s.res, (50 + sk.bb.magic_resistance) * 0.6, `T${tier}: RES cut + 精神融解`, 1e-9);
approx(off.s.res, 50, `T${tier}: off the line`);
assert.deepEqual(u.liveRangeGrid, formOf(tier, elite).rangeGrid, `T${tier}: 5-1 while it runs`);
const ticks = () => tagged(h, u, 'ifrit:scorch').filter((c) => c.type === 'arts');
assert.equal(ticks().length, 1, `T${tier}: one tick after 1 s`);
approx(ticks()[0].t - cast, 0.4, `T${tier}: the first tick at the Begin clip's attack event`, 0.05);
approx(ticks()[0].amount, u.s.atk * sk.bb.atk_scale * (1 - (50 + sk.bb.magic_resistance) * 0.6 / 100), `T${tier}: ${sk.bb.atk_scale * 100} % ATK arts`);
assert.ok(!ticks().some((c) => c.target === fly || c.target === off), `T${tier}: ground enemies of her line only`);
approx(hp0 - u.hp, u.s.maxHp * 0.02, `T${tier}: 2 % max HP`);
h.runUntil(() => !u.skill.active, 25);
assert.equal(ticks().length, 20, `T${tier}: 20 ticks`);
const endT = h.hooksOf('skillEnd').at(-1).t;
assert.equal(atkHits(h, u).filter((c) => c.t < endT).length, n0, `T${tier}: no normal attack meanwhile`);
// a second cast, cut short by a stun
u.hp = u.s.maxHp;
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 6), `T${tier}: cast again`);
h.run(2.5);
h.b.applyStatus(u, 'stun', { duration: 1, force: true });
h.step();
assert.equal(u.skill.active, false, `T${tier}: the stun ends 灼地`);
assert.equal(h.hooksOf('skillEnd').at(-1).reason, 'interrupted');
done(h);
}
});
test('T1 精神融解: RES ×0.6 on every enemy of her line (gone once it leaves), none off it; BLA-D stage 3: her attacks and 灼地 ticks on an enemy in its 灼燃 burst add 50 % ATK 元素伤害 (a 持续伤害)', () => {
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
const { h, u } = field({ tier, elite, mod, skill: 0 });
const t0 = u.def.raw.talents.find((t) => t.index === 0);
assert.equal(t0.bb.magic_resistance, -0.4, label(f));
const on = h.spawn('enemy_res', { pos: [10, 7] }), off = h.spawn('enemy_res', { pos: [11, 7] });
h.run(0.3);
approx(on.s.res, 30, `${label(f)}: 50 → 30`);
approx(off.s.res, 50, `${label(f)}: off the line`);
h.b.retreat(u);
h.run(0.6);
approx(on.s.res, 50, `${label(f)}: gone with her`);
done(h);
}
for (const [tier, mod, extra] of [[6, BLAD, 0.5], [5, BLAD, 0], [6, BLAX, 0], [6, null, 0]]) {
const { h, u } = field({ tier, elite: true, mod, skill: 2 });
const e = h.spawn('enemy_dummy', { pos: [10, 6] });
const calm = h.spawn('enemy_dummy', { pos: [10, 7] });
h.b.addBuff(e, { key: 'burnBurst', duration: 60, flags: { burstLock: true } });
const n0 = atkHits(h, u).length;
h.runUntil(() => atkHits(h, u).length > n0, 4);
h.step();
const el = () => tagged(h, u, 'ifrit:meltdown');
assert.equal(el().length, extra ? 1 : 0, `T${tier} ${mod}: the extra hit on her attack`);
if (extra) {
approx(el()[0].amount, u.s.atk * extra, 'T6 BLA-D: 50 % ATK');
assert.deepEqual([el()[0].type, el()[0].dmg.element, el()[0].dmg.tags.includes('dot'), el()[0].target], ['elemental', 'burn', true, e]);
assert.match(u.def.raw.talents.find((t) => t.index === 0).desc, /灼燃损伤.*爆发期间.*50%的元素伤害/);
}
// the 灼地 ticks are attacks of hers too (the equip adds it to every ability family)
u.skill.gainSp(999);
h.runUntil(() => u.skill.active, 4);
h.run(2.5);
const onTicks = el().filter((c) => c.t > h.hooksOf('skillStart').at(-1).t);
assert.equal(onTicks.length, extra ? 3 : 0, `T${tier} ${mod}: one per 灼地 tick (0.4 / 1.4 / 2.4 s)`);
assert.ok(!el().some((c) => c.target === calm), `T${tier} ${mod}: none on an enemy outside a burst`);
done(h);
}
});
test('T2 莱茵回路: +2 SP every 6 s of a deployment; BLA-X stage 3: a 30 % roll for +5 SP more each 6 s (none in any other form)', () => {
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
const { h, u } = field({ tier, elite, mod, skill: 0, seed: 21 });
const dice = elite && tier === 6 && mod === BLAX;
const t1 = u.def.raw.talents.find((t) => t.index === 1).bb;
assert.deepEqual([t1.sp, t1.interval, t1['ifrit_e_002[dice_sp].prob'] ?? 0, t1['ifrit_e_002[dice_sp].sp'] ?? 0], [2, 6, dice ? 0.3 : 0, dice ? 5 : 0], label(f));
const gains = () => h.hooksOf('spGain').filter((c) => c.unit === u && c.reason === 'talent');
for (let i = 0; i < 60; i++) { u.skill.sp = 0; h.run(6); }
const g = gains();
const twos = g.filter((c) => c.amount === 2).length, fives = g.filter((c) => c.amount === 5).length;
assert.ok(twos >= 59 && twos <= 60, `${label(f)}: ${twos} gifts of 2 SP in 360 s`);
if (dice) assert.ok(fives >= 9 && fives <= 30, `${label(f)}: ${fives} / 60 rolls of +5 ≈ 30 %`);
else assert.equal(fives, 0, `${label(f)}: no roll`);
done(h);
}
});
test('BLA-X “同熔” (stages 1 and 3): ×(1 + 0.1 × d / 4) by the distance to the target, capped at 4 tiles — on her attacks, the 灼伤 while she stands and the 灼地 ticks; none without it', () => {
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
const { h, u } = field({ tier, elite, mod, skill: 0 });
const blax = elite && mod === BLAX;
if (blax) assert.deepEqual(u.def.raw.trait.bb, { min_dist: 0, max_dist: 4, damage_scale: 0.1 }, label(f));
const at = [5, 6, 8, 9].map((c) => h.spawn('enemy_dummy', { pos: [10, c] }));
const n0 = atkHits(h, u).length;
h.runUntil(() => atkHits(h, u).length >= n0 + 4, 4);
const hits = atkHits(h, u).slice(n0, n0 + 4);
for (const [i, d] of [[0, 1], [1, 2], [2, 4], [3, 5]]) {
const c = hits.find((x) => x.target === at[i]);
approx(c.amount, u.s.atk * (blax ? 1 + 0.1 * Math.min(1, d / 4) : 1), `${label(f)}: d = ${d}`);
}
done(h);
}
// the 灼伤 while she stands and after she left; the 灼地 tick
for (const [skill, tag] of [[1, 'ifrit:burn'], [2, 'ifrit:scorch']]) {
const { h, u } = field({ tier: 6, elite: true, mod: BLAX, skill });
const e = h.spawn('enemy_dummy', { pos: [10, 8] }); // 4 tiles: ×1.1
u.skill.gainSp(999);
h.runUntil(() => tagged(h, u, tag).filter((c) => c.type === 'arts').length >= 1, 6);
const c = tagged(h, u, tag).find((x) => x.type === 'arts' && x.target === e);
const scale = skill === 1 ? 0.33 : skillOf(6, true, S3).bb.atk_scale;
approx(c.amount, u.s.atk * scale * 1.1, `${tag}: ×1.1 at 4 tiles`);
if (skill === 1) {
h.b.retreat(u);
h.run(2.1);
const after = tagged(h, u, tag).filter((x) => x.target === e).at(-1);
approx(after.amount, u.s.atk * scale, '灼伤 after she left: no module effect');
}
done(h);
}
});
test('BLA-D “热成形记忆” (stages 1 and 3): every arts damage she deals while on the field attaches 8 % of it as 灼燃损伤 — her attacks and the 灼伤 — and no 元素损伤 without it', () => {
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
const { h, u } = field({ tier, elite, mod, skill: 1 });
const blad = elite && mod === BLAD;
if (blad) assert.equal(u.def.raw.trait.bb.ep_damage_ratio, 0.08, label(f));
const e = h.spawn('enemy_dummy', { pos: [10, 7] });
u.skill.gainSp(999);
h.runUntil(() => tagged(h, u, 'ifrit:burn').length >= 2, 6);
const arts = h.hooksOf('damaged').filter((c) => c.source === u && c.target === e && c.type === 'arts').reduce((s, c) => s + c.amount, 0);
approx(e.elem.burn, blad ? arts * 0.08 : 0, `${label(f)}: 8 % of ${Math.round(arts)} arts damage as 灼燃损伤`);
done(h);
}
});
+324
View File
@@ -0,0 +1,324 @@
// test/content/op_irene.test.js — the 自选 operator kit of 艾丽妮 (char_4009_irene, 6★ 剑豪; kit
// server/sim/content/kits/ops/op-irene.js), fielded the production way (a DIY slot + its `diy` pick, simdata getDiy) in
// every form: tiers 5 / 6, normal (E2 Lv1, skill rank 4, no module) and elite (E2 Lv60, rank 7) with no module, SWO-Y
// “厚重的经卷”, SWO-X “审判官口粮” or ISW-A “艾丽妮特限证章” at stage 1 (tier 5) / 3 (tier 6). Every number is read back from
// data/backups.json (the form of that slot status); the fidelity checklist of kits/README.md item by item.
// Run: node --test test/content/op_irene.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
import { KITTED_CHARS, OPERATOR_KITS, KITS } from '../../server/sim/content/kits/index.js';
import { diyPool, validateDiyPicks } from '../../shared/diy.js';
const load = (f) => JSON.parse(readFileSync(new URL(`../../data/${f}.json`, import.meta.url), 'utf8'));
const CHESS = load('chess');
const BACKUPS = load('backups');
const IRENE = 'char_4009_irene';
const FORMS = BACKUPS.units[IRENE].forms;
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
const SWOY = 'uniequip_002_irene', SWOX = 'uniequip_003_irene', ISWA = 'uniequip_004_irene';
const S1 = 'skchr_irene_1', S2 = 'skchr_irene_2', S3 = 'skchr_irene_3';
const formOf = (tier, elite) => FORMS[elite ? (tier === 5 ? '2/60/7/1' : '2/60/7/3') : '2/1/4/0'];
const skillOf = (tier, elite, id) => formOf(tier, elite).skills.find((s) => s.skillId === id);
const modOf = (tier, mod) => (mod ? formOf(tier, true).modules.find((m) => m.uniEquipId === mod) : null);
const approx = (a, b, msg, eps = 1e-6) => assert.ok(Math.abs(a - b) <= eps * Math.max(1, Math.abs(b)), `${msg}: ${a} vs ${b}`);
const dummy = (key, o = {}) => enemyRec({ key, hp: 1e9, speed: 0, mass: 0, ...o });
const ENEMIES = {
enemy_dummy: dummy('enemy_dummy'), enemy_fly: dummy('enemy_fly', { motion: 'FLY' }),
enemy_def: dummy('enemy_def', { def: 400 }), enemy_fly_def: dummy('enemy_fly_def', { motion: 'FLY', def: 400 }),
enemy_heavy: dummy('enemy_heavy', { mass: 4 }), enemy_fly_weak: dummy('enemy_fly_weak', { motion: 'FLY', hp: 2000 }),
enemy_frail: dummy('enemy_frail', { hp: 1000 }), enemy_weak2k: dummy('enemy_weak2k', { hp: 2000 }), enemy_sea: { ...dummy('enemy_sea'), tags: ['seamonster'] },
};
/** Every 自选 form: [tier, elite, module]. */
const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, SWOY, SWOX, ISWA].map((m) => [t, true, m]))];
const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`;
const skillMulOf = (elite, mod) => (elite && mod === SWOX ? 1.1 : 1);
/** 审判之火's DEF share ignored in a form (SWO-X stage 3: 55 %). */
const penOf = (tier, elite, mod) => (elite && mod === SWOX && tier === 6 ? 0.55 : 0.5);
/** A battle with 艾丽妮 as uid 1 at (row, col) facing RIGHT, plus `others`. */
function field({ tier = 5, elite = false, mod = null, skill = 0, row = 10, col = 5, others = [], seed = 5 } = {}) {
const h = makeBattle({
defs: { enemies: ENEMIES }, timeLimit: 600, autoFinish: false, seed,
flags: { dpPerSec: 0, dpMax: 999 },
hooks: ['damaged', 'skillStart', 'skillEnd', 'statusApplied', 'attack'], captureNoisy: true,
units: [{ uid: 1, diy: { slot: SLOT[tier], charId: IRENE, skillIndex: skill, uniEquipId: mod }, elite, row, col }, ...others],
});
h.step();
return { h, u: h.unit(1) };
}
const hitsBy = (h, u) => h.hooksOf('damaged').filter((c) => c.source === u);
function done(h) {
checkInvariants(h.b);
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
}
test('艾丽妮 in every 自选 form: her operator kit (all three skills authored), the form\'s stats + module attributes, 1-1 range, blocks 2, two hits per attack, melee ground-only, 无 core bond (emptyShip), no 特质', () => {
assert.equal(OPERATOR_KITS[IRENE], KITS[IRENE]);
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
for (const skill of [0, 1, 2]) {
const { h, u } = field({ tier, elite, mod, skill });
const form = formOf(tier, elite), m = elite ? modOf(tier, mod) : null;
assert.deepEqual([u.def.charId, u.def.diyFor, u.skill.id, !!u.kit.generic, u.kit.skillSource], [IRENE, SLOT[tier], form.skills[skill].skillId, false, 'skills'], label(f));
assert.deepEqual([u.base.maxHp, u.base.atk, u.base.def, u.base.aspd],
[form.stats.maxHp + (m?.attr.maxHp ?? 0), form.stats.atk + (m?.attr.atk ?? 0), form.stats.def + (m?.attr.def ?? 0), 100 + (m?.attr.aspd ?? 0)], `${label(f)}: stats`);
assert.deepEqual([u.s.blockCnt, u.profile.attack, u.profile.canHitFly, u.profile.hits, u.profile.dmgType, u.base.bat], [2, 'melee', false, 2, 'phys', 1.3], `${label(f)}: 剑豪`);
assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: 1-1`);
assert.deepEqual([u.def.bonds, u.def.raw.garrisonIds], [['emptyShip'], []], `${label(f)}: bonds / 特质`);
assert.ok(!u.s.flags.liftoff && !u.s.flags.camou && !u.s.flags.stealth, `${label(f)}: ground enemies target her`);
assert.deepEqual(u.def.raw.tokens, [], `${label(f)}: no summons`);
done(h);
}
}
// the numbers of the forms (zh_CN): E2 Lv1 2201 / 484 / 282, E2 Lv60 2688 / 575 / 326; SWO-Y +50 / +35 → +65 / +50
// ATK / DEF, SWO-X +50 ATK +5 ASPD → +80 / +7, ISW-A +240 HP +40 ATK → +300 / +65
assert.deepEqual([FORMS['2/1/4/0'].stats.maxHp, FORMS['2/1/4/0'].stats.atk, FORMS['2/60/7/1'].stats.maxHp, FORMS['2/60/7/1'].stats.atk], [2201, 484, 2688, 575]);
assert.deepEqual([SWOY, SWOX, ISWA].map((m) => [modOf(5, m).attr, modOf(6, m).attr]),
[[{ atk: 50, def: 35 }, { atk: 65, def: 50 }], [{ atk: 50, aspd: 5 }, { atk: 80, aspd: 7 }], [{ maxHp: 240, atk: 40 }, { maxHp: 300, atk: 65 }]]);
});
test('a 自选 pick: 艾丽妮 is offered at tiers 5 and 6 (she has a kit) and a roster with her passes validateDiyPicks', () => {
const data = { chess: CHESS, backups: BACKUPS };
assert.ok(KITTED_CHARS.includes(IRENE));
for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(IRENE), `tier ${t}`);
assert.deepEqual(validateDiyPicks({ [SLOT[5]]: { charId: IRENE, skillIndex: 1, uniEquipId: ISWA } }, { data, kitted: KITTED_CHARS }),
{ ok: true, picks: { [SLOT[5]]: { charId: IRENE, skillIndex: 1, uniEquipId: ISWA } } });
});
test('T1 审判之火 vs an air unit: every physical instance ignores 50 % (SWO-X stage 3: 55 %) of its DEF with no random draw; a levitated enemy is an air unit; arts untouched', () => {
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
const { h, u } = field({ tier, elite, mod, skill: 2 });
const pen = penOf(tier, elite, mod), flat = elite && mod === SWOY ? 70 : 0;
const t0 = u.def.raw.talents.find((t) => t.index === 0).bb;
assert.deepEqual([t0.prob, t0.def_penetrate], [0.5, pen], `${label(f)}: the data`);
const fly = h.spawn('enemy_fly_def', { pos: [9, 8] });
for (let i = 0; i < 20; i++) {
const st = h.b.rng.state();
approx(h.b.dealDamage(u, fly, { amount: 1000, type: 'phys' }), 1000 - Math.max(0, 400 * (1 - pen) - flat), `${label(f)}: air #${i}`);
assert.equal(h.b.rng.state(), st, `${label(f)}: no random draw against an air unit`);
}
const g = h.spawn('enemy_def', { pos: [9, 9] });
h.b.applyStatus(g, 'levitate', { duration: 5, source: u });
assert.ok(g.isFlying, 'levitated = an air unit');
for (let i = 0; i < 5; i++) approx(h.b.dealDamage(u, g, { amount: 1000, type: 'phys' }), 1000 - Math.max(0, 400 * (1 - pen) - flat), `${label(f)}: levitated #${i}`);
const st = h.b.rng.state();
approx(h.b.dealDamage(u, fly, { amount: 1000, type: 'arts' }), 1000, `${label(f)}: arts (RES 0)`);
assert.equal(h.b.rng.state(), st, `${label(f)}: arts draws nothing`);
done(h);
}
});
test('T1 审判之火 vs a ground enemy: one draw per physical instance at 50 %; a won charge is spent only by an instance that lands — a dodged one keeps it and the next lands pierced without a draw', () => {
for (const [tier, elite, mod] of [[5, false, null], [6, true, SWOX], [6, true, ISWA]]) {
const { h, u } = field({ tier, elite, mod, skill: 2, seed: 17 });
const pen = penOf(tier, elite, mod);
const g = h.spawn('enemy_def', { pos: [9, 9] });
let pierced = 0;
const N = 400;
for (let i = 0; i < N; i++) {
const st = h.b.rng.state();
const d = h.b.dealDamage(u, g, { amount: 1000, type: 'phys' });
assert.notEqual(h.b.rng.state(), st, 'a draw per ground instance (no charge held)');
if (Math.abs(d - (1000 - 400 * (1 - pen))) < 1e-6) pierced++;
else approx(d, 600, 'or no pierce');
}
assert.ok(pierced > 0.4 * N && pierced < 0.6 * N, `${pierced} / ${N} pierced ≈ 50 %`);
// a charge drawn against a flyer that dodges is kept: the next ground instance lands pierced and draws nothing
const fly = h.spawn('enemy_fly_def', { pos: [9, 8] });
h.b.addBuff(fly, { key: 'test:dodge', mods: { dodgePhys: 1 } });
assert.equal(h.b.dealDamage(u, fly, { amount: 1000, type: 'phys' }), 0, 'dodged');
assert.equal(u.mem.ireneCharge, true, 'the charge is kept');
const st = h.b.rng.state();
approx(h.b.dealDamage(u, g, { amount: 1000, type: 'phys' }), 1000 - 400 * (1 - pen), 'pierced');
assert.equal(h.b.rng.state(), st, 'no draw while a charge is held');
assert.equal(u.mem.ireneCharge, false, 'spent');
// a held charge does not outlive her deployment (a buff on her)
assert.equal(h.b.dealDamage(u, fly, { amount: 1000, type: 'phys' }), 0, 'dodged again');
assert.equal(u.mem.ireneCharge, true, 'held');
h.b.retreat(u);
h.b.redeploy(u);
assert.ok(u.alive && u.deployed, 'redeployed');
assert.equal(u.mem.ireneCharge, false, 'a new deployment holds no charge');
done(h);
}
});
test('T2 净化之剑: ASPD +18 (SWO-Y stage 3: ATK +5 % too), doubled while a 【海怪】 enemy is on the field', () => {
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
const { h, u } = field({ tier, elite, mod, skill: 2 });
const y3 = elite && mod === SWOY && tier === 6;
const atk = y3 ? 0.05 : 0;
approx(u.s.aspd, u.base.aspd + 18, `${label(f)}: ASPD +18`);
approx(u.s.atk, u.base.atk * (1 + atk), `${label(f)}: ATK`);
const sea = h.spawn('enemy_sea', { pos: [9, 9] });
h.step();
approx(u.s.aspd, u.base.aspd + 36, `${label(f)}: ×2 with a 海怪 on the field`);
approx(u.s.atk, u.base.atk * (1 + 2 * atk), `${label(f)}: ATK ×2`);
h.b.kill(sea, null);
h.step();
approx(u.s.aspd, u.base.aspd + 18, `${label(f)}: back once it is gone`);
done(h);
}
});
test('S1 起风 (AUTO, attack SP 6 / 5, data DEFAULT): the next attack hits its target for 130 % / 160 % ATK, levitates it 1 s, then hits it again for 130 % / 160 % — an air unit by then, so 审判之火 always pierces', () => {
for (const [tier, elite, mod] of [[5, false, null], [6, true, null], [5, true, SWOX], [6, true, SWOY]]) {
const sk = skillOf(tier, elite, S1), mul = skillMulOf(elite, mod), pen = penOf(tier, elite, mod), flat = elite && mod === SWOY ? 70 : 0;
const lb = `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`;
const { h, u } = field({ tier, elite, mod, skill: 0 });
assert.deepEqual([u.skill.rule, u.skill.spType, u.skill.spCost, sk.initSp, sk.bb.atk_scale, sk.bb.levitate], ['DEFAULT', 'attack', elite ? 5 : 6, 0, elite ? 1.6 : 1.3, 1], lb);
u.skill.gainSp(999);
const e = h.spawn('enemy_def', { pos: [10, 6] });
assert.ok(h.runUntil(() => u.skill.activations === 1 && hitsBy(h, u).length >= 2, 3), `${lb}: cast on her next attack`);
const [a, b] = hitsBy(h, u);
assert.deepEqual([a.target, b.target, a.dmg.attackId === b.dmg.attackId, a.dmg.isSkill, b.dmg.isSkill, a.type, b.type], [e, e, true, true, true, 'phys', 'phys'], lb);
const A = u.s.atk * sk.bb.atk_scale;
const raw = (p) => (A - Math.max(0, 400 * (1 - p) - flat)) * mul;
assert.ok([raw(0), raw(pen)].some((v) => Math.abs(v - a.amount) < 1e-6), `${lb}: the first strike (${a.amount})`);
approx(b.amount, raw(pen), `${lb}: the second on the levitated target, pierced`);
const lev = h.hooksOf('statusApplied').find((c) => c.source === u && c.status === 'levitate');
assert.ok(lev && lev.target === e && Math.abs(lev.duration - 1) < 1e-9, `${lb}: levitate 1 s`);
assert.equal(hitsBy(h, u).length, 2, `${lb}: two hits in all`);
done(h);
}
// 浮空 rules: a 重量 > 3 target floats half as long (engine)
const { h, u } = field({ tier: 6, elite: true, skill: 0 });
u.skill.gainSp(999);
const heavy = h.spawn('enemy_heavy', { pos: [10, 6] });
assert.ok(h.runUntil(() => u.skill.activations === 1, 3));
h.step();
const lev = h.hooksOf('statusApplied').find((c) => c.source === u && c.status === 'levitate');
assert.ok(lev && lev.target === heavy && Math.abs(lev.duration - 0.5) < 1e-9, 'half on a heavy target');
done(h);
});
test('S2 裂潮 (MANUAL, 2 charges, data SKILL_RANGE on 3-12): not opened with only a flyer in range; up to 5 / 6 ground enemies there take 310 % / 340 % ATK, those of 重量 ≤ 3 float 2 / 3 s; the second charge ≥ 3 s later', () => {
for (const [tier, elite, mod] of [[5, false, null], [6, true, null], [6, true, SWOX]]) {
const sk = skillOf(tier, elite, S2), mul = skillMulOf(elite, mod), lb = `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`;
const { h, u } = field({ tier, elite, mod, skill: 1 });
assert.deepEqual([u.skill.rule, u.skill.kind, u.skill.maxCharges, u.skill.spCost, sk.initSp, sk.bb.atk_scale, sk.bb.max_target, sk.bb.levitate, sk.bb.value, sk.rangeId],
['SKILL_RANGE', 'charges', 2, elite ? 10 : 11, 0, elite ? 3.4 : 3.1, elite ? 6 : 5, elite ? 3 : 2, 3, '3-12'], lb);
assert.deepEqual(u.skill.triggerGrid, sk.rangeGrid, `${lb}: the trigger grid is the skill range`);
u.skill.gainSp(999);
assert.equal(u.skill.charges, 2, lb);
const fly = h.spawn('enemy_fly', { pos: [10, 7] });
h.run(1.5);
assert.equal(u.skill.activations, 0, `${lb}: a flyer alone does not open it (地面敌人 only)`);
const light = h.spawn('enemy_dummy', { pos: [10, 8] }), heavy = h.spawn('enemy_heavy', { pos: [9, 6] });
const out = h.spawn('enemy_dummy', { pos: [10, 9] });
assert.ok(h.runUntil(() => u.skill.activations === 1, 1), `${lb}: opened`);
const hit = hitsBy(h, u);
assert.deepEqual([...new Set(hit.map((c) => c.target))].sort((a, b) => a.id - b.id), [light, heavy].sort((a, b) => a.id - b.id), `${lb}: the ground enemies of 3-12`);
for (const c of hit) { approx(c.amount, u.s.atk * sk.bb.atk_scale * mul, `${lb}: ${sk.bb.atk_scale * 100} %`); assert.deepEqual([c.type, c.dmg.isSkill], ['phys', true]); }
assert.ok(!hit.some((c) => c.target === fly || c.target === out), `${lb}: no flyer, nothing outside`);
const lev = h.hooksOf('statusApplied').filter((c) => c.source === u && c.status === 'levitate');
assert.deepEqual(lev.map((c) => [c.target, c.duration]), [[light, sk.bb.levitate]], `${lb}: only the light one floats (重量 ≤ 3)`);
assert.equal(u.skill.charges, 1, `${lb}: one charge left`);
const t1 = u.skill.lastStart;
assert.ok(h.runUntil(() => u.skill.activations === 2, 5), `${lb}: the second charge`);
assert.ok(u.skill.lastStart - t1 >= 3 - 1e-9, `${lb}: 3 s apart (automatic operation cooldown)`);
done(h);
}
// seven ground enemies: max_target of them
const sk = skillOf(5, false, S2);
const { h, u } = field({ tier: 5, skill: 1 });
u.skill.gainSp(999);
for (const pos of [[11, 5], [11, 6], [10, 6], [10, 7], [10, 8], [9, 5], [9, 6]]) h.spawn('enemy_dummy', { pos });
assert.ok(h.runUntil(() => u.skill.activations === 1, 1));
assert.equal(new Set(hitsBy(h, u).filter((c) => c.dmg.isSkill).map((c) => c.target)).size, sk.bb.max_target, `${sk.bb.max_target} of 7`);
done(h);
});
test('S3 判决 (MANUAL, data SKILL_RANGE on x-1): every ground enemy of x-1 takes 250 % ATK and floats 2 / 3 s, then 10 bombardments every 0.3 s on a random enemy of x-1, radius 1.1 (air too) at 200 % / 230 %; 3.5 s, no normal attack', () => {
for (const [tier, elite, mod] of [[5, false, null], [6, true, null], [6, true, SWOX]]) {
const sk = skillOf(tier, elite, S3), mul = skillMulOf(elite, mod), lb = `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`;
const { h, u } = field({ tier, elite, mod, skill: 2 });
assert.deepEqual([u.skill.rule, u.skill.spCost, u.skill.sp, sk.bb.atk_scale, sk.bb.levitate, sk.bb.multi_atk_scale, sk.bb.multi_times, sk.bb.multi_hit_interval, sk.rangeId],
['SKILL_RANGE', elite ? 30 : 32, elite ? 16 : 8, 2.5, elite ? 3 : 2, elite ? 2.3 : 2, 10, 0.3, 'x-1'], lb);
assert.deepEqual(u.skill.triggerGrid, sk.rangeGrid, `${lb}: the trigger grid is the skill range`);
u.skill.gainSp(999);
const a = h.spawn('enemy_dummy', { pos: [10, 7] }); // in x-1
const b = h.spawn('enemy_dummy', { pos: [10, 8] }); // outside x-1, 1 tile from a
const c = h.spawn('enemy_dummy', { pos: [10, 9] }); // 2 tiles from a
assert.ok(h.runUntil(() => u.skill.active, 1), `${lb}: opened`);
const t0 = u.skill.lastStart;
h.runUntil(() => !u.skill.active, 5);
approx(h.b.time - t0, 3.5, `${lb}: 3.5 s`, 0.02);
const hit = hitsBy(h, u);
const opening = hit.filter((x) => Math.abs(x.t - t0) < 1e-6);
assert.deepEqual(opening.map((x) => x.target), [a], `${lb}: the opening strike on x-1's ground enemies only`);
approx(opening[0].amount, u.s.atk * sk.bb.atk_scale * mul, `${lb}: 250 %`);
const lev = h.hooksOf('statusApplied').find((x) => x.source === u && x.status === 'levitate');
assert.deepEqual([lev.target, lev.duration], [a, sk.bb.levitate], `${lb}: floats`);
const bomb = hit.filter((x) => x.t > t0 + 1e-6);
assert.equal(bomb.filter((x) => x.target === a).length, 10, `${lb}: 10 bombardments on the only enemy of x-1 (an air unit by then)`);
assert.equal(bomb.filter((x) => x.target === b).length, 10, `${lb}: each also hits within 1.1 (outside x-1 too)`);
assert.ok(!bomb.some((x) => x.target === c), `${lb}: nothing farther than 1.1`);
for (const x of bomb) approx(x.amount, u.s.atk * sk.bb.multi_atk_scale * mul, `${lb}: ${sk.bb.multi_atk_scale * 100} %`);
const at = [...new Set(bomb.map((x) => Math.round((x.t - t0) * 30)))];
assert.deepEqual(at, [9, 18, 27, 36, 45, 54, 63, 72, 81, 90], `${lb}: every 0.3 s (ticks)`);
assert.ok(!h.hooksOf('attack').some((x) => x.attacker === u && x.t > t0 && x.t < t0 + 3.5), `${lb}: no normal attack meanwhile`);
assert.deepEqual(u.liveRangeGrid, formOf(tier, elite).rangeGrid, `${lb}: her range stays 1-1`);
done(h);
}
// opened with only a flyer in range (PRTS 备注), the bombardments reach it
const { h, u } = field({ tier: 6, elite: true, skill: 2 });
u.skill.gainSp(999);
const fly = h.spawn('enemy_fly', { pos: [9, 6] });
assert.ok(h.runUntil(() => u.skill.active, 1), 'a flyer alone opens it');
h.runUntil(() => !u.skill.active, 5);
assert.equal(hitsBy(h, u).filter((x) => x.target === fly).length, 10, 'ten bombardments on the flyer');
done(h);
});
test('SWO-X stage 3 审判之火: an air unit knocked out during her skill gives 6 SP once the skill ends — a levitated one too — not at stage 1, not a ground kill, not without the module', () => {
for (const [tier, elite, mod] of [[6, true, SWOX], [5, true, SWOX], [6, true, SWOY], [6, false, null]]) {
const lb = `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`;
const sp = elite && mod === SWOX && tier === 6 ? 6 : 0;
if (sp) assert.equal(formOf(6, true).modules.find((m) => m.uniEquipId === SWOX).talentChanges[0].bb.sp, 6);
// a flyer (the bombardments); a ground enemy the opening strike levitates and the bombardments finish (an air unit when
// it falls); a ground enemy the opening strike itself knocks out (before it floats)
for (const [key, pos, air] of [['enemy_fly_weak', [9, 6], true], ['enemy_weak2k', [10, 7], true], ['enemy_frail', [10, 7], false]]) {
const { h, u } = field({ tier, elite, mod, skill: 2 });
u.skill.gainSp(999);
const e = h.spawn(key, { pos });
assert.ok(h.runUntil(() => u.skill.active, 1), `${lb} ${key}: opened`);
h.runUntil(() => !u.skill.active, 5);
h.step();
assert.ok(!e.alive, `${lb} ${key}: knocked out during the skill`);
assert.equal(e.isFlying, air, `${lb} ${key}: an air unit when it fell`);
approx(u.skill.sp, air ? sp : 0, `${lb} ${key}: SP after the skill`);
done(h);
}
}
});
test('modules: SWO-X skill damage ×1.1, normal attacks unchanged; SWO-Y ignores 70 DEF (stages 1 and 3); ISW-A only its attributes (集成战略-only parts: no deploy SP, 50 % / 50 %)', () => {
for (const f of FORMS_ALL.filter(([, elite]) => elite)) {
const [tier, elite, mod] = f;
const { h, u } = field({ tier, elite, mod, skill: 1 });
assert.equal(u.s.defIgnoreFlat, mod === SWOY ? 70 : 0, `${label(f)}: SWO-Y`);
if (mod === SWOX) assert.deepEqual(u.def.raw.trait.bb, { damage_scale: 1.1, value: 0.1 });
if (mod === ISWA) {
assert.match(u.def.raw.trait.moduleDesc, /集成战略/);
assert.equal(u.skill.sp, skillOf(tier, elite, S2).initSp, `${label(f)}: no 部署后立即获得8点技力 here`);
assert.equal(u.base.respawnTime, formOf(tier, elite).stats.respawnTime, `${label(f)}: no 再部署时间-60%`);
const t0 = u.def.raw.talents.find((t) => t.index === 0);
assert.deepEqual([t0.bb.prob, t0.bb.def_penetrate], [0.5, 0.5], `${label(f)}: 审判之火 unchanged`);
}
const e = h.spawn('enemy_dummy', { pos: [10, 6] });
assert.ok(h.runUntil(() => hitsBy(h, u).length >= 2, 3));
approx(hitsBy(h, u)[0].amount, u.s.atk, `${label(f)}: a normal hit (DEF 0)`);
u.skill.gainSp(999);
const n0 = hitsBy(h, u).length;
assert.ok(h.runUntil(() => u.skill.activations === 1, 4));
const cut = hitsBy(h, u).slice(n0).find((c) => c.dmg.isSkill && c.target === e);
approx(cut.amount, u.s.atk * skillOf(tier, elite, S2).bb.atk_scale * skillMulOf(elite, mod), `${label(f)}: S2`);
done(h);
}
});
+292
View File
@@ -0,0 +1,292 @@
// test/content/op_lin.test.js — the 自选 operator kit of 林 (char_4080_lin, 6★ 阵法术师; kit
// server/sim/content/kits/ops/op-lin.js), fielded the production way (a DIY slot + its `diy` pick, simdata getDiy) in
// every form: tiers 5 / 6, normal (E2 Lv1, skill rank 4, no module) and elite (E2 Lv60, rank 7) with no module, PLX-X
// “七窍玲珑” or PLX-Y “情与义” at stage 1 (tier 5) / 3 (tier 6). Every number is read back from data/backups.json (the form
// of that slot status); the fidelity checklist of kits/README.md item by item.
// Run: node --test test/content/op_lin.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
import { KITTED_CHARS, OPERATOR_KITS, KITS } from '../../server/sim/content/kits/index.js';
import { diyPool, validateDiyPicks } from '../../shared/diy.js';
const load = (f) => JSON.parse(readFileSync(new URL(`../../data/${f}.json`, import.meta.url), 'utf8'));
const CHESS = load('chess');
const BACKUPS = load('backups');
const LIN = 'char_4080_lin';
const FORMS = BACKUPS.units[LIN].forms;
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
const PLXX = 'uniequip_002_lin', PLXY = 'uniequip_003_lin';
const S1 = 'skchr_lin_1', S2 = 'skchr_lin_2', S3 = 'skchr_lin_3';
const formOf = (tier, elite) => FORMS[elite ? (tier === 5 ? '2/60/7/1' : '2/60/7/3') : '2/1/4/0'];
const skillOf = (tier, elite, id) => formOf(tier, elite).skills.find((s) => s.skillId === id);
const modOf = (tier, mod) => (mod ? formOf(tier, true).modules.find((m) => m.uniEquipId === mod) : null);
const approx = (a, b, msg, eps = 1e-6) => assert.ok(Math.abs(a - b) <= eps * Math.max(1, Math.abs(b)), `${msg}: ${a} vs ${b}`);
const dummy = (key, o = {}) => enemyRec({ key, hp: 1e9, speed: 0, mass: 0, ...o });
const ENEMIES = { enemy_dummy: dummy('enemy_dummy'), enemy_fly: dummy('enemy_fly', { motion: 'FLY' }), enemy_weak: dummy('enemy_weak', { hp: 10 }) };
const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, PLXX, PLXY].map((m) => [t, true, m]))];
const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`;
/** Her x-1 (and x-2) range from (10, 5) facing RIGHT, as [row, col]. */
const X1 = [[12, 5], [11, 4], [11, 5], [11, 6], [10, 3], [10, 4], [10, 5], [10, 6], [10, 7], [9, 4], [9, 5], [9, 6], [8, 5]];
/** A battle with 林 as uid 1 at (10, 5) facing RIGHT, plus `others`. */
function field({ tier = 5, elite = false, mod = null, skill = 0, others = [], seed = 5, setup = null } = {}) {
const h = makeBattle({
defs: { enemies: ENEMIES }, timeLimit: 900, autoFinish: false, seed, setup,
flags: { dpPerSec: 0, dpMax: 999 }, hooks: ['damaged', 'skillStart', 'skillEnd', 'statusApplied', 'spGain'], captureNoisy: true,
units: [{ uid: 1, diy: { slot: SLOT[tier], charId: LIN, skillIndex: skill, uniEquipId: mod }, elite, row: 10, col: 5 }, ...others],
});
h.step();
return { h, u: h.unit(1) };
}
const atkHits = (h, u) => h.hooksOf('damaged').filter((c) => c.source === u && c.dmg?.isAttack);
const glassHits = (h, u) => h.hooksOf('damaged').filter((c) => c.source === u && c.dmg?.tags?.includes('lin:glass'));
const glassOf = (a) => a.findBuff('lin:glass');
/** True damage straight to `t` from `src` (no DEF / RES: the exact amount meets the 琉璃璧). */
const hurt = (h, src, t, n) => h.b.dealDamage(src, t, { amount: n, type: 'true' });
function done(h) {
checkInvariants(h.b);
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
}
test('林 in every 自选 form: her operator kit (all three skills authored), the form\'s stats + module attributes, x-1 range, 阵法术师 (no attack and the guard while no skill runs; every enemy of her range, air too, while one does)', () => {
assert.equal(OPERATOR_KITS[LIN], KITS[LIN]);
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
for (const skill of [0, 1, 2]) {
const { h, u } = field({ tier, elite, mod, skill });
const form = formOf(tier, elite), m = elite ? modOf(tier, mod) : null;
assert.deepEqual([u.def.charId, u.def.diyFor, u.skill.id, !!u.kit.generic, u.kit.skillSource], [LIN, SLOT[tier], form.skills[skill].skillId, false, 'skills'], label(f));
assert.deepEqual([u.base.maxHp, u.base.atk, u.base.def, u.base.res, u.base.bat], [form.stats.maxHp + (m?.attr.maxHp ?? 0), form.stats.atk + (m?.attr.atk ?? 0), form.stats.def + (m?.attr.def ?? 0), 15, 2], `${label(f)}: stats`);
assert.deepEqual([u.profile.noAttackUnlessSkill, u.profile.allInRange, u.profile.canHitFly, u.profile.dmgType, u.profile.projectile], [true, true, true, 'arts', 'beam'], `${label(f)}: 阵法术师`);
const plxx = elite && mod === PLXX;
assert.deepEqual([u.profile.guardDef, u.profile.guardRes], plxx ? [2.15, 25] : [2, 20], `${label(f)}: trait tunables`);
assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: x-1`);
assert.deepEqual([u.def.bonds, u.def.raw.garrisonIds], [BACKUPS.diy.operators[LIN].bonds, []], `${label(f)}: bonds / 特质`);
assert.ok(!u.s.flags.liftoff && !u.s.flags.camou && !u.s.flags.stealth, `${label(f)}: enemies target her`);
done(h);
}
}
// E2 Lv1 1679 / 730 / 212, E2 Lv60 1924 / 809 / 232; PLX-X +62 / +45 → +77 / +53 ATK / DEF, PLX-Y +50 / +20 → +73 / +40
assert.deepEqual([FORMS['2/1/4/0'].stats.maxHp, FORMS['2/1/4/0'].stats.atk, FORMS['2/60/7/1'].stats.maxHp, FORMS['2/60/7/1'].stats.atk], [1679, 730, 1924, 809]);
assert.deepEqual([modOf(5, PLXX).attr, modOf(6, PLXX).attr, modOf(5, PLXY).attr, modOf(6, PLXY).attr], [{ atk: 62, def: 45 }, { atk: 77, def: 53 }, { atk: 50, def: 20 }, { atk: 73, def: 40 }]);
});
test('a 自选 pick: 林 is offered at tiers 5 and 6 (she has a kit) and a roster with her passes validateDiyPicks', () => {
const data = { chess: CHESS, backups: BACKUPS };
assert.ok(KITTED_CHARS.includes(LIN));
for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(LIN), `tier ${t}`);
assert.deepEqual(validateDiyPicks({ [SLOT[5]]: { charId: LIN, skillIndex: 0, uniEquipId: PLXX } }, { data, kitted: KITTED_CHARS }),
{ ok: true, picks: { [SLOT[5]]: { charId: LIN, skillIndex: 0, uniEquipId: PLXX } } });
});
test('trait + PLX-X: DEF ×3 / RES +20 while no skill runs (PLX-X ×3.15 / +25); while one runs the guard is off — PLX-X keeps DEF +100 % / RES +10 (stages 1 and 3)', () => {
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
const { h, u } = field({ tier, elite, mod, skill: 1 });
const plxx = elite && mod === PLXX;
if (plxx) assert.deepEqual(u.def.raw.trait.bb, { def: 2.15, magic_resistance: 25, 'lin_e_002[buff].def': 1, 'lin_e_002[buff].magic_resistance': 10 }, label(f));
approx(u.s.def, u.base.def * (1 + (plxx ? 2.15 : 2)), `${label(f)}: guard DEF`);
approx(u.s.res, 15 + (plxx ? 25 : 20), `${label(f)}: guard RES`);
h.run(1);
assert.equal(atkHits(h, u).length, 0, `${label(f)}: no attack while no skill runs`);
u.skill.gainSp(999);
h.spawn('enemy_dummy', { pos: [10, 6] });
assert.ok(h.runUntil(() => u.skill.active, 1), `${label(f)}: 荫庇`);
approx(u.s.def, u.base.def * (1 + (plxx ? 1 : 0)), `${label(f)}: skill DEF`);
approx(u.s.res, 15 + (plxx ? 10 : 0), `${label(f)}: skill RES`);
h.runUntil(() => !u.skill.active, 30);
approx(u.s.def, u.base.def * (1 + (plxx ? 2.15 : 2)), `${label(f)}: the guard is back`);
done(h);
}
});
test('S1 玲珑 (MANUAL, data SEARCH, a 状态切换类 skill: switched on once and kept): cast as soon as an enemy is in her range; ATK +25 % / +40 %, interval 2 → 3 s, every enemy of her range (air too) 停顿 0.8 / 0.9 s per attack', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S1);
const { h, u } = field({ tier, elite, skill: 0 });
assert.deepEqual([u.skill.rule, u.skill.kind, sk.bb.atk, sk.bb['attack@sluggish'], sk.bb.base_attack_time, u.skill.spCost], ['SEARCH', 'toggle', elite ? 0.4 : 0.25, elite ? 0.9 : 0.8, 1, elite ? 6 : 8], `T${tier}`);
u.skill.gainSp(999);
h.run(1);
assert.equal(u.skill.activations, 0, `T${tier}: nobody in range`);
const a = h.spawn('enemy_dummy', { pos: [10, 7] }), b = h.spawn('enemy_fly', { pos: [11, 5] }), off = h.spawn('enemy_dummy', { pos: [10, 8] });
assert.ok(h.runUntil(() => u.skill.active, 0.2), `T${tier}: cast at once (SEARCH)`);
approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `T${tier}: ATK`);
approx(u.s.interval, 3, `T${tier}: 3 s`);
h.runUntil(() => atkHits(h, u).length >= 2, 4);
const hits = atkHits(h, u);
assert.deepEqual(hits.map((c) => c.target.id).sort(), [a.id, b.id].sort(), `T${tier}: both in range, air too`);
for (const c of hits) { approx(c.amount, u.s.atk, `T${tier}: 100 % ATK`); assert.equal(c.type, 'arts', `T${tier}: arts`); }
assert.deepEqual(u.liveRangeGrid, formOf(tier, elite).rangeGrid, `T${tier}: x-1 while it runs`);
const slow = h.hooksOf('statusApplied').filter((c) => c.source === u && c.status === 'sluggish');
assert.deepEqual(slow.map((c) => c.target.id).sort(), [a.id, b.id].sort(), `T${tier}: 停顿 on each`);
for (const c of slow) approx(c.duration, sk.bb['attack@sluggish'], `T${tier}: 停顿 ${sk.bb['attack@sluggish']} s`);
assert.ok(!hits.some((c) => c.target === off), `T${tier}: outside x-1`);
h.run(120);
assert.deepEqual([u.skill.active, u.skill.activations], [true, 1], `T${tier}: kept on`);
done(h);
}
});
test('S2 荫庇 (MANUAL, data SEARCH, 25 s): ASPD +50 / +90, taunt −1; every allied unit of her range gets its own 琉璃璧 (threshold 200, Lin\'s ATK, centred on it, its own regrowth), lost at the end; none outside', () => {
for (const [tier, elite, mod] of [[5, false, null], [6, true, PLXY]]) {
const sk = skillOf(tier, elite, S2);
const t0 = formOf(tier, elite).modules?.find((m) => m.uniEquipId === mod)?.talentChanges.find((t) => t.talentIndex === 0)?.bb ?? formOf(tier, elite).talents[0].bb;
const others = [{ uid: 2, chessId: 'chess_char_1_02_a', row: 10, col: 6 }, { uid: 3, chessId: 'chess_char_1_02_a', row: 12, col: 9 }];
const { h, u } = field({ tier, elite, mod, skill: 1, others });
const near = h.unit(2), far = h.unit(3);
assert.deepEqual([u.skill.rule, sk.duration, sk.bb.attack_speed, sk.bb.taunt_level], ['SEARCH', 25, elite ? 90 : 50, -1], `T${tier}`);
assert.equal(glassOf(near), null, `T${tier}: no copy before the skill`);
u.skill.gainSp(999);
const e0 = h.spawn('enemy_dummy', { pos: [11, 5] });
assert.ok(h.runUntil(() => u.skill.active, 0.5), `T${tier}: cast`);
h.run(0.2);
assert.deepEqual([u.s.aspd, u.s.taunt], [100 + sk.bb.attack_speed, -1], `T${tier}: ASPD / taunt`);
assert.deepEqual([glassOf(near)?.shield, glassOf(near)?.source === u, glassOf(far)], [200, true, null], `T${tier}: a copy in range only`);
// the copy breaks on a hit over 200: burst on Lin's range centred on the ally, Lin's ATK, source Lin
const inBurst = h.spawn('enemy_dummy', { pos: [10, 8] }), outBurst = h.spawn('enemy_dummy', { pos: [10, 9] });
h.step();
const hp0 = near.hp;
hurt(h, e0, near, 300);
approx(hp0 - near.hp, 100, `T${tier}: 300 − 200 through`);
assert.equal(glassOf(near), null, `T${tier}: broken`);
const gh = glassHits(h, u);
assert.ok(gh.some((c) => c.target === inBurst) && !gh.some((c) => c.target === outBurst), `T${tier}: centred on the ally`);
approx(gh.find((c) => c.target === inBurst).amount, u.s.atk * t0.atk_scale * (mod === PLXY ? 1 + 0.03 : 1), `T${tier}: ${t0.atk_scale * 100} % Lin ATK`);
assert.ok(h.hooksOf('statusApplied').some((c) => c.source === u && c.status === 'stun' && c.target === inBurst), `T${tier}: stunned`);
h.run(t0.interval - 0.2);
assert.equal(glassOf(near), null, `T${tier}: not before ${t0.interval} s`);
h.run(0.4);
assert.equal(glassOf(near)?.shield, 200, `T${tier}: back after ${t0.interval} s`);
h.runUntil(() => !u.skill.active, 30);
assert.equal(glassOf(near), null, `T${tier}: gone with the skill`);
assert.ok(glassOf(u), `T${tier}: hers stays`);
done(h);
}
});
test('S3 流光乍裂 (MANUAL, data CUSTOM_RANGE x-2, 26 / 28 s): cast with an enemy in x-2; range x-2 while on (bursts too), back after; ATK +125 % / +160 %; her 琉璃璧 threshold ×2 / ×2.5; a knock-out by her attack breaks it (burst) and raises it at once', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S3);
const { h, u } = field({ tier, elite, skill: 2 });
assert.deepEqual([u.skill.rule, sk.duration, sk.bb.atk, sk.bb.talent_scale, sk.rangeId], ['CUSTOM_RANGE', elite ? 28 : 26, elite ? 1.6 : 1.25, elite ? 2.5 : 2, 'x-2'], `T${tier}`);
assert.deepEqual(sk.trigger.customRangeGrid, sk.rangeGrid);
u.skill.gainSp(999);
const e = h.spawn('enemy_dummy', { pos: [11, 7] }); // x-2 [1, 2], not x-1
assert.ok(h.runUntil(() => u.skill.active, 0.5), `T${tier}: cast with an enemy in x-2`);
assert.deepEqual(u.liveRangeGrid, sk.rangeGrid, `T${tier}: x-2`);
approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `T${tier}: ATK`);
const thr = 200 * sk.bb.talent_scale;
assert.equal(glassOf(u).shield, thr, `T${tier}: threshold ${thr}`);
const hp0 = u.hp;
hurt(h, e, u, 450);
if (thr > 450) { assert.equal(u.hp, hp0, `T${tier}: 450 absorbed`); assert.ok(glassOf(u)); } else { approx(hp0 - u.hp, 450 - thr, `T${tier}: 450 − ${thr}`); assert.equal(glassOf(u), null); }
// a knock-out by her attack: burst (x-2 around her) and the 琉璃璧 standing again at once
if (!glassOf(u)) { h.run(9); assert.ok(glassOf(u), `T${tier}: regrown`); }
const witness = h.spawn('enemy_dummy', { pos: [9, 3] }); // x-2 [−1, −2]
const weak = h.spawn('enemy_weak', { pos: [10, 6] });
h.step();
const g0 = glassHits(h, u).length;
h.runUntil(() => !weak.alive, 4);
h.step();
assert.ok(glassHits(h, u).slice(g0).some((c) => c.target === witness), `T${tier}: the kill's burst reaches x-2`);
assert.equal(glassOf(u)?.shield, thr, `T${tier}: raised again at once`);
h.runUntil(() => !u.skill.active, 40);
assert.deepEqual(u.liveRangeGrid, formOf(tier, elite).rangeGrid, `T${tier}: x-1 again`);
assert.equal(glassOf(u)?.shield, 200, `T${tier}: plain threshold again`);
done(h);
}
});
test('T1 计出万全 (every form): a 琉璃璧 from deployment — a hit ≤ 200 absorbed whole, a hit over 200 goes through less 200 and breaks it: stun 1 s first, then 100 % / 110 % ATK arts on her range, regrowing after 8 s / 6 s (PLX-Y stage 3); a lethal hit still breaks it', () => {
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
const order = [];
const { h, u } = field({ tier, elite, mod, skill: 2, setup: (b) => {
b.on('statusApplied', (c) => { if (c.status === 'stun' && c.source?.uid === 1) order.push('stun'); });
b.on('damaged', (c) => { if (c.dmg?.tags?.includes('lin:glass')) order.push(c.target.s.flags.stun ? 'hit-stunned' : 'hit'); });
} });
const t0 = u.def.raw.talents.find((t) => t.index === 0).bb;
const y3 = elite && tier === 6 && mod === PLXY;
assert.deepEqual([t0.value, t0.atk_scale, t0.stun, t0.interval], [200, y3 ? 1.1 : 1, 1, y3 ? 6 : 8], label(f));
assert.equal(glassOf(u)?.shield, 200, `${label(f)}: up from deployment`);
const e = h.spawn('enemy_dummy', { pos: [10, 7] }), out = h.spawn('enemy_dummy', { pos: [10, 9] });
h.step();
const hp0 = u.hp;
hurt(h, e, u, 150);
hurt(h, e, u, 200);
assert.deepEqual([u.hp, glassOf(u)?.shield], [hp0, 200], `${label(f)}: 150 and 200 absorbed, full again`);
hurt(h, e, u, 300);
approx(hp0 - u.hp, 100, `${label(f)}: 300 − 200`);
assert.equal(glassOf(u), null, `${label(f)}: broken`);
const gh = glassHits(h, u);
assert.deepEqual(gh.map((c) => c.target.id), [e.id], `${label(f)}: her range only`);
const plxy = elite && mod === PLXY ? 1 + 0.03 * 1 : 1;
approx(gh[0].amount, u.s.atk * t0.atk_scale * plxy, `${label(f)}: ${t0.atk_scale * 100} % ATK`);
assert.deepEqual(order, ['stun', 'hit-stunned'], `${label(f)}: stun first`);
assert.ok(!h.hooksOf('statusApplied').some((c) => c.target === out && c.status === 'stun'), `${label(f)}: none outside`);
h.run(t0.interval - 0.1);
assert.equal(glassOf(u), null, `${label(f)}: not yet`);
h.run(0.2);
assert.equal(glassOf(u)?.shield, 200, `${label(f)}: back after ${t0.interval} s`);
// a lethal hit: the burst still comes
const n0 = glassHits(h, u).length;
hurt(h, e, u, u.hp + 1000);
assert.equal(u.alive, false, `${label(f)}: knocked out`);
assert.equal(glassHits(h, u).length, n0 + 1, `${label(f)}: the lethal hit broke it`);
done(h);
}
});
test('T2 韬光: each damage instance she takes rolls 50 % for +1 SP (PLX-X stage 3: 75 %, +2) — one the 琉璃璧 absorbs whole too; a 流失 never', () => {
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
const { h, u } = field({ tier, elite, mod, skill: 2, seed: 33 });
const x3 = elite && tier === 6 && mod === PLXX;
const t1 = u.def.raw.talents.find((t) => t.index === 1).bb;
assert.deepEqual([t1.prob, t1.sp], x3 ? [0.75, 2] : [0.5, 1], label(f));
const e = h.spawn('enemy_dummy', { pos: [10, 9] });
h.step();
const gains = () => h.hooksOf('spGain').filter((c) => c.unit === u && c.reason === 'talent');
const hp0 = u.hp;
for (let i = 0; i < 200; i++) { if (i % 10 === 0) u.skill.sp = 0; hurt(h, e, u, 50); }
assert.equal(u.hp, hp0, `${label(f)}: all absorbed`);
const absorbed = gains();
assert.ok(Math.abs(absorbed.length / 200 - t1.prob) < 0.12, `${label(f)}: absorbed hits roll too: ${absorbed.length} / 200 ≈ ${t1.prob * 100} %`);
assert.ok(absorbed.every((c) => c.amount === t1.sp), `${label(f)}: +${t1.sp} SP`);
hurt(h, e, u, 400); // breaks it: 200 through
const n0 = gains().length;
for (let i = 0; i < 200; i++) { if (i % 10 === 0) u.skill.sp = 0; hurt(h, e, u, 1); }
const g = gains().slice(n0);
assert.ok(Math.abs(g.length / 200 - t1.prob) < 0.12, `${label(f)}: ${g.length} / 200 ≈ ${t1.prob * 100} %`);
const n1 = gains().length;
for (let i = 0; i < 50; i++) { u.skill.sp = 0; h.b.loseHp(u, 1, { source: e }); }
assert.equal(gains().length, n1, `${label(f)}: a 流失 rolls nothing`);
done(h);
}
});
test('PLX-Y “情与义” (stages 1 and 3): +3 % damage per enemy on her range, at most 5 (+15 %); none without it', () => {
for (const f of FORMS_ALL.filter(([, elite]) => elite)) {
const [tier, , mod] = f;
const plxy = mod === PLXY;
for (const n of [2, 7]) {
const { h, u } = field({ tier, elite: true, mod, skill: 0 });
if (plxy) assert.deepEqual(u.def.raw.talents.filter((t) => t.index === -1).map((t) => t.bb), [{ damage_scale: 0.03, max_valid_stack_cnt: 5 }], label(f));
const tiles = [[10, 6], [10, 7], [11, 5], [9, 5], [11, 4], [9, 4], [11, 6]].slice(0, n);
for (const p of tiles) h.spawn('enemy_dummy', { pos: p });
u.skill.gainSp(999);
h.runUntil(() => atkHits(h, u).length >= n, 5);
for (const c of atkHits(h, u).slice(0, n)) approx(c.amount, u.s.atk * (plxy ? 1 + 0.03 * Math.min(5, n) : 1), `${label(f)}: ${n} enemies`);
done(h);
}
}
});
test('the x-1 grid of the tests is her data range (from (10, 5) facing RIGHT)', () => {
const { h, u } = field({});
assert.deepEqual([...u.rangeKeys].sort((a, b) => a - b), X1.map(([r, c]) => r * 21 + c).sort((a, b) => a - b));
done(h);
});
+390
View File
@@ -0,0 +1,390 @@
// test/content/op_logos.test.js — the 自选 operator kit of 逻各斯 (char_4133_logos, 6★ 中坚术师; kit
// server/sim/content/kits/ops/op-logos.js), fielded the production way (a DIY slot + its `diy` pick, simdata getDiy) in
// every form: tiers 5 / 6, normal (E2 Lv1, skill rank 4, no module) and elite (E2 Lv60, rank 7) with no module, CCR-Δ
// “来自河谷的笔盒” or CCR-Y “《语义范式百科》” at stage 1 (tier 5) / 3 (tier 6). Every number is read back from
// data/backups.json (the form of that slot status); the fidelity checklist of kits/README.md item by item.
// Run: node --test test/content/op_logos.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
import { KITTED_CHARS, OPERATOR_KITS, KITS } from '../../server/sim/content/kits/index.js';
import { diyPool, validateDiyPicks } from '../../shared/diy.js';
const load = (f) => JSON.parse(readFileSync(new URL(`../../data/${f}.json`, import.meta.url), 'utf8'));
const CHESS = load('chess');
const BACKUPS = load('backups');
const LOGOS = 'char_4133_logos';
const FORMS = BACKUPS.units[LOGOS].forms;
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
const CCRD = 'uniequip_002_logos', CCRY = 'uniequip_003_logos';
const S1 = 'skchr_logos_1', S2 = 'skchr_logos_2', S3 = 'skchr_logos_3';
const formOf = (tier, elite) => FORMS[elite ? (tier === 5 ? '2/60/7/1' : '2/60/7/3') : '2/1/4/0'];
const skillOf = (tier, elite, id) => formOf(tier, elite).skills.find((s) => s.skillId === id);
const modOf = (tier, mod) => (mod ? formOf(tier, true).modules.find((m) => m.uniEquipId === mod) : null);
/** The talent bb of a form with a module's changes (stage 2+ rewrites 语汇演化). */
const talentOf = (tier, elite, mod, i) => {
const m = elite ? modOf(tier, mod) : null;
return m?.talentChanges.find((t) => t.talentIndex === i)?.bb ?? formOf(tier, elite).talents.find((t) => t.index === i).bb;
};
const approx = (a, b, msg, eps = 1e-6) => assert.ok(Math.abs(a - b) <= eps * Math.max(1, Math.abs(b)), `${msg}: ${a} vs ${b}`);
const dummy = (key, o = {}) => enemyRec({ key, hp: 1e9, speed: 0, mass: 0, ...o });
const ENEMIES = {
enemy_dummy: dummy('enemy_dummy'), enemy_fly: dummy('enemy_fly', { motion: 'FLY' }), enemy_elite: dummy('enemy_elite', { rank: 'ELITE' }),
enemy_boss: dummy('enemy_boss', { rank: 'BOSS' }), enemy_low: dummy('enemy_low', { hp: 300 }),
enemy_gun: dummy('enemy_gun', { atk: 100, range: 3, bat: 1, applyWay: 'RANGED' }),
};
const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, CCRD, CCRY].map((m) => [t, true, m]))];
const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`;
/** A battle with 逻各斯 as uid 1 at (row, col) facing RIGHT. */
function field({ tier = 5, elite = false, mod = null, skill = 0, row = 10, col = 5, others = [], seed = 5, setup } = {}) {
const h = makeBattle({
defs: { enemies: ENEMIES }, timeLimit: 900, autoFinish: false, seed, setup,
flags: { dpPerSec: 0 }, hooks: ['damaged', 'skillStart', 'skillEnd', 'statusApplied', 'attack', 'elementBurst'], captureNoisy: true,
units: [{ uid: 1, diy: { slot: SLOT[tier], charId: LOGOS, skillIndex: skill, uniEquipId: mod }, elite, row, col }, ...others],
});
h.step();
return { h, u: h.unit(1) };
}
const atkHits = (h, u) => h.hooksOf('damaged').filter((c) => c.source === u && c.dmg?.isAttack);
const tagged = (h, u, tag) => h.hooksOf('damaged').filter((c) => c.source === u && (c.dmg?.tags || []).includes(tag));
function done(h) {
checkInvariants(h.b);
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
}
/** Start the skill now (SP filled, no enemy needed). */
function cast(u) { u.skill.gainSp(999); assert.ok(u.skill.activate('test')); }
test('逻各斯 in every 自选 form: his operator kit (all three skills authored), stats + module attributes, 3-1 range, ranged arts that hits air units, targetable by ground enemies, 空 bond, no 特质', () => {
assert.equal(OPERATOR_KITS[LOGOS], KITS[LOGOS]);
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
for (const skill of [0, 1, 2]) {
const { h, u } = field({ tier, elite, mod, skill });
const form = formOf(tier, elite), m = elite ? modOf(tier, mod) : null;
assert.deepEqual([u.def.charId, u.def.diyFor, u.skill.id, !!u.kit.generic, u.kit.skillSource], [LOGOS, SLOT[tier], form.skills[skill].skillId, false, 'skills'], label(f));
assert.deepEqual([u.base.maxHp, u.base.atk, u.base.def, u.base.res], [form.stats.maxHp + (m?.attr.maxHp ?? 0), form.stats.atk + (m?.attr.atk ?? 0), form.stats.def, form.stats.res + (m?.attr.res ?? 0)], `${label(f)}: stats`);
assert.deepEqual([u.s.blockCnt, u.profile.attack, u.profile.dmgType, u.profile.canHitFly, u.base.bat], [1, 'ranged', 'arts', true, 1.6], `${label(f)}: 中坚术师`);
assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: 3-1`);
assert.deepEqual([u.def.bonds, u.def.raw.garrisonIds], [['emptyShip'], []], `${label(f)}: bonds / 特质`);
assert.ok(!u.s.flags.liftoff && !u.s.flags.camou && !u.s.flags.stealth, `${label(f)}: ground enemies target him`);
done(h);
}
}
// the numbers of the forms (zh_CN): E2 Lv1 1297 / 556, E2 Lv60 1540 / 632; CCR-Δ +100 / +36 → +170 / +67 HP / ATK,
// CCR-Y +35 / +5 → +60 / +5 ATK / RES
assert.deepEqual([FORMS['2/1/4/0'].stats.maxHp, FORMS['2/1/4/0'].stats.atk, FORMS['2/60/7/1'].stats.maxHp, FORMS['2/60/7/1'].stats.atk], [1297, 556, 1540, 632]);
assert.deepEqual([modOf(5, CCRD).attr, modOf(6, CCRD).attr, modOf(5, CCRY).attr, modOf(6, CCRY).attr], [{ maxHp: 100, atk: 36 }, { maxHp: 170, atk: 67 }, { atk: 35, res: 5 }, { atk: 60, res: 5 }]);
});
test('a 自选 pick: 逻各斯 is offered at tiers 5 and 6 (he has a kit) and a roster with him passes validateDiyPicks', () => {
const data = { chess: CHESS, backups: BACKUPS };
assert.ok(KITTED_CHARS.includes(LOGOS));
for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(LOGOS), `tier ${t}`);
assert.deepEqual(validateDiyPicks({ [SLOT[6]]: { charId: LOGOS, skillIndex: 2, uniEquipId: CCRY } }, { data, kitted: KITTED_CHARS }),
{ ok: true, picks: { [SLOT[6]]: { charId: LOGOS, skillIndex: 2, uniEquipId: CCRY } } });
});
test('S1 殁亡 (AUTO, data DEFAULT): 74 / 68 SP from 0, cast as he is about to attack an enemy of his 3-1; then for good the 3-3 range and ATK +40 % / +70 %', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S1);
const { h, u } = field({ tier, elite, skill: 0 });
assert.deepEqual([u.skill.rule, u.skill.kind, u.skill.spCost, sk.initSp, sk.bb.atk, sk.rangeId], ['DEFAULT', 'toggle', elite ? 68 : 74, 0, elite ? 0.7 : 0.4, '3-3'], `T${tier}`);
h.run(sk.spCost - 1);
assert.equal(u.skill.activations, 0, `T${tier}: not before its SP is full`);
h.run(3);
assert.equal(u.skill.activations, 0, `T${tier}: full SP, no enemy: no cast`);
h.spawn('enemy_dummy', { pos: [10, 8] });
assert.ok(h.runUntil(() => u.skill.active, 3), `T${tier}: cast with an enemy in his range`);
assert.deepEqual(u.liveRangeGrid, sk.rangeGrid, `T${tier}: 3-3 while on`);
approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `T${tier}: ATK`);
h.run(200);
assert.ok(u.skill.active, `T${tier}: 持续时间无限`);
assert.deepEqual(u.liveRangeGrid, sk.rangeGrid, `T${tier}: still 3-3`);
done(h);
}
});
test('S1 殁亡 execution: an enemy of his range under 90 % / 120 % of his ATK falls at once (9999999 无来源 true damage, no dodge), then another random enemy of the range takes arts damage equal to the HP it had left; one try only on a survivor; air units too', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S1);
let guard = null;
const { h, u } = field({ tier, elite, skill: 0, setup: (b) => b.on('fatal', (c) => { if (c.unit === guard) c.prevented = true; }) });
cast(u);
const thr = sk.bb['attack@kill_atk_scale'] * u.s.atk;
assert.equal(sk.bb['attack@kill_damage'], 9999999);
const big = h.spawn('enemy_dummy', { pos: [11, 8] }); // a 3-3 tile outside the 3-1 (the toggle's range counts)
const low = h.spawn('enemy_fly', { pos: [10, 7] });
const hp0 = thr * 0.6;
low.hp = hp0;
const high = h.spawn('enemy_dummy', { pos: [9, 6] });
h.step();
assert.ok(!low.alive, `T${tier}: the flyer under the threshold fell`);
const kill = h.hooksOf('damaged').find((c) => c.target === low && (c.dmg?.tags || []).includes('execute'));
assert.ok(kill && kill.source === null && kill.credit === u && kill.type === 'true' && kill.dmg.sourceless && !kill.dmg.canDodge, `T${tier}: 无来源 true, credited to him`);
assert.equal(low.removeReason, 'killed');
const follow = tagged(h, u, 'logosPerish');
assert.equal(follow.length, 1, `T${tier}: one follow-up`);
assert.ok([big, high].includes(follow[0].target) && follow[0].type === 'arts', `T${tier}: on another enemy of his range`);
approx(follow[0].amount, hp0, `T${tier}: the HP the fallen one had left`);
assert.ok(![big, high].some((e) => h.hooksOf('damaged').some((c) => c.target === e && (c.dmg?.tags || []).includes('execute'))), `T${tier}: above the threshold: no execution`);
// an enemy that falls under the threshold while on his range is executed then (the aura checks every tick)
high.hp = thr * 0.9;
h.step();
assert.ok(!high.alive, `T${tier}: executed once under the threshold`);
// a survivor (不死) is never tried again
guard = h.spawn('enemy_dummy', { pos: [10, 6] });
guard.hp = thr * 0.5;
h.step();
const tries = () => h.hooksOf('damaged').filter((c) => c.target === guard && (c.dmg?.tags || []).includes('execute')).length;
assert.equal(tries(), 1, `T${tier}: tried once`);
assert.ok(guard.alive && guard.hp <= 1 + 1e-9, `T${tier}: held at 1 HP`);
h.run(3);
assert.equal(tries(), 1, `T${tier}: never again`);
done(h);
}
});
test('S2 提喻 (MANUAL, data DEFAULT): 20 s, RES +40 / +50; locks one target with a hit every 0.5 s whatever the attack speed, 35 %→105 % / 50 %→150 % of ATK and its speed down to 40 % over 10 hits; a stun breaks the lock', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S2);
const b = sk.bb;
const { h, u } = field({ tier, elite, skill: 1, seed: 7 });
assert.deepEqual([u.skill.rule, u.skill.kind, sk.duration, b.magic_resistance, b['attack@cooldown'], b['attack@atk_scale_base'], b['attack@atk_scale_delta'], b['attack@move_speed'], b['attack@max_stack_cnt']],
['DEFAULT', 'duration', 20, elite ? 50 : 40, 0.5, elite ? 0.5 : 0.35, elite ? 0.1 : 0.07, -0.06, 10], `T${tier}`);
const e = h.spawn('enemy_dummy', { pos: [10, 7] });
h.b.addBuff(u, { key: 'test:aspd', mods: { aspd: 300 } }); // the lock ignores the attack speed
const res0 = u.s.res;
cast(u);
approx(u.s.res, res0 + b.magic_resistance, `T${tier}: RES`);
const n0 = atkHits(h, u).length;
h.run(6.02);
const hits = atkHits(h, u).slice(n0);
assert.ok(hits.length >= 12 && hits.length <= 13, `T${tier}: ${hits.length} hits in 6 s (one per 0.5 s)`);
for (let i = 1; i < hits.length; i++) approx(hits[i].t - hits[i - 1].t, 0.5, `T${tier}: cadence`, 0.07);
hits.forEach((c, i) => {
assert.equal(c.target, e, `T${tier}: the locked target`);
// + 剜魂具辞's 150 (the first hit marks it already); the target's RES 0
approx(c.amount, u.s.atk * (b['attack@atk_scale_base'] + b['attack@atk_scale_delta'] * Math.min(10, i)) + 150, `T${tier}: hit ${i}`, 1e-4);
});
assert.ok(Math.abs(hits[10].amount - (u.s.atk * b['attack@atk_scale_base'] * 3 + 150)) < 1e-3 * hits[10].amount, `T${tier}: 3× after 5 s`);
approx(e.findBuff(`logos:lockSlow:${u.id}`).mods.moveMul, 0.4, `T${tier}: 40 % speed`);
// a stun breaks it: the slow goes, the next hits start over
h.b.applyStatus(u, 'stun', { duration: 1, source: e });
h.step();
assert.equal(e.findBuff(`logos:lockSlow:${u.id}`), null, `T${tier}: slow gone with the lock`);
const n1 = atkHits(h, u).length;
h.run(1.6);
const after = atkHits(h, u).slice(n1);
approx(after[0].amount, u.s.atk * b['attack@atk_scale_base'] + 150, `T${tier}: reset`, 1e-4);
// beam: the hit lands as the attack is made (no bolt in flight)
const atks = h.hooksOf('attack').filter((c) => c.attacker === u);
assert.ok(Math.abs(atks[atks.length - 1].t - after[after.length - 1].t) < 1e-9, `T${tier}: instant`);
h.runUntil(() => !u.skill.active, 25);
approx(u.s.res, res0, `T${tier}: RES back`);
done(h);
}
});
test('S2 提喻 keeps its lock while another enemy comes first in the order, and locks anew when its target leaves the field', () => {
const { h, u } = field({ tier: 6, elite: true, skill: 1 });
const a = h.spawn('enemy_dummy', { pos: [10, 8] });
cast(u);
h.run(1.1);
assert.ok(atkHits(h, u).every((c) => c.target === a));
h.spawn('enemy_dummy', { pos: [10, 6], routeIndex: 0 }); // nearer the goal: first in the order
const n0 = atkHits(h, u).length;
h.run(1.6);
assert.ok(atkHits(h, u).slice(n0).every((c) => c.target === a), 'still the locked one');
h.b.kill(a, null);
const n1 = atkHits(h, u).length;
h.run(1.1);
const next = atkHits(h, u).slice(n1);
assert.ok(next.length >= 2 && next.every((c) => c.target !== a));
approx(next[0].amount, u.s.atk * 0.5 + 150, 'a new lock starts at the base scale', 1e-4);
done(h);
});
test('S3 延异视阈 (MANUAL, data ACTIVE_RANGE on its 3-4): cast with an enemy on the 3-4 only, 30 s, ATK +160 % / +220 %, 3 targets at once', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S3);
const { h, u } = field({ tier, elite, skill: 2 });
assert.deepEqual([u.skill.rule, sk.rangeId, sk.duration, sk.bb.atk, sk.bb['attack@max_target'], sk.bb.projectile_move_scale], ['ACTIVE_RANGE', '3-4', 30, elite ? 2.2 : 1.6, 3, 0.05], `T${tier}`);
assert.deepEqual(u.skill.triggerGrid, sk.rangeGrid, `T${tier}: the trigger grid is the 3-4`);
u.skill.gainSp(999);
h.run(1);
assert.equal(u.skill.activations, 0, `T${tier}: no enemy`);
const far = h.spawn('enemy_dummy', { pos: [12, 6] }); // dRow +2: the 3-4 only
assert.ok(h.runUntil(() => u.skill.active, 1), `T${tier}: cast for an enemy outside his own range`);
assert.deepEqual(u.liveRangeGrid, sk.rangeGrid, `T${tier}: 3-4 while on`);
approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `T${tier}: ATK`);
const more = [h.spawn('enemy_dummy', { pos: [10, 6] }), h.spawn('enemy_fly', { pos: [9, 7] }), h.spawn('enemy_dummy', { pos: [11, 8] })];
const a0 = h.hooksOf('attack').filter((c) => c.attacker === u).length;
h.run(5);
const atks = h.hooksOf('attack').filter((c) => c.attacker === u).slice(a0);
assert.ok(atks.length >= 2 && atks.every((c) => c.targets.length === 3), `T${tier}: 3 targets per attack`);
assert.ok(atks.every((c) => c.targets.every((e) => [far, ...more].includes(e))));
h.runUntil(() => !u.skill.active, 40);
assert.deepEqual(u.liveRangeGrid, formOf(tier, elite).rangeGrid, `T${tier}: back to 3-1`);
done(h);
}
});
test('S3 延异视阈: enemy shots on his range fly at 5 % of their speed (back once they leave it) and are cleared when the skill ends — not when he is disarmed then', () => {
for (const disarm of [false, true]) {
const { h, u } = field({ tier: 6, elite: true, skill: 2 });
h.spawn('enemy_gun', { pos: [10, 7] });
h.run(1.5);
cast(u);
h.run(0.2);
h.run(3);
const shots = h.b.projectiles.list.filter((p) => p.source && p.source.side === 'enemy');
assert.ok(shots.length >= 2, `${shots.length} enemy shots held`);
for (const p of shots) approx(p.speed, 10 * 0.05, 'slowed to 0.05×');
if (disarm) h.b.applyStatus(u, 'disarm', { duration: 40, source: null });
h.runUntil(() => u.skill.timeLeft < 0.5, 40);
for (const g of h.enemies()) h.b.applyStatus(g, 'stun', { duration: 99, source: null }); // no new shot
const held = h.b.projectiles.list.filter((p) => p.source && p.source.side === 'enemy').length;
assert.ok(held >= 1, `${held} shots in the air as it ends`);
const taken = u.stats.taken;
h.runUntil(() => !u.skill.active, 2);
h.step();
const left = h.b.projectiles.list.filter((p) => p.source && p.source.side === 'enemy');
if (!disarm) {
assert.equal(left.length, 0, 'cleared');
h.run(5);
assert.equal(u.stats.taken, taken, 'none of the held shots hit him');
} else {
assert.ok(left.length > 0 && left.every((p) => Math.abs(p.speed - 10) < 1e-9), 'disarmed: kept, at full speed again');
}
done(h);
}
});
test('T1 语汇演化: 40 % per attacked target for an extra 60 % ATK arts hit on a random enemy of his range + 停顿 0.8 s; CCR-Δ stage 3: 60 % and 60 % ATK 元素伤害 on a target in its 凋亡 burst; CCR-Y stage 3: two executions at 65 %', () => {
for (const f of [[5, false, null], [5, true, CCRD], [6, true, CCRD], [6, true, CCRY]]) {
const [tier, elite, mod] = f;
const t = talentOf(tier, elite, mod, 0);
const { h, u } = field({ tier, elite, mod, skill: 0, seed: 21 });
h.b.addBuff(u, { key: 'test:aspd', mods: { aspd: 500 } });
const es = [h.spawn('enemy_dummy', { pos: [10, 7] }), h.spawn('enemy_fly', { pos: [11, 6] })];
h.run(50);
const attacks = h.hooksOf('attack').filter((c) => c.attacker === u).reduce((n, c) => n + c.targets.length, 0);
const extra = tagged(h, u, 'logosLexicon').filter((c) => c.type === 'arts');
const emit = t.emit_count ?? 1;
const ratio = extra.length / (attacks * emit);
assert.ok(attacks > 150 && Math.abs(ratio - t.prob) < 0.08, `${label(f)}: ${extra.length} / ${attacks}×${emit} ≈ ${t.prob}`);
for (const c of extra.slice(0, 6)) {
assert.ok(es.includes(c.target), `${label(f)}: an enemy of his range (air too)`);
const marked = h.hooksOf('damaged').some((d) => d.source === u && d.target === c.target && d.dmg?.isAttack && d.t <= c.t && c.t - d.t <= 5);
approx(c.amount, u.s.atk * t.atk_scale + (marked ? 150 : 0), `${label(f)}: ${t.atk_scale * 100} % ATK (+ 剜魂具辞 on a marked one)`, 1e-4);
}
const slug = h.hooksOf('statusApplied').filter((c) => c.source === u && c.status === 'sluggish');
assert.ok(slug.length >= extra.length * 0.95 && slug.every((c) => Math.abs(c.duration - t.sluggish) < 1e-9), `${label(f)}: 停顿 ${t.sluggish} s`);
const elem = tagged(h, u, 'logosLexicon').filter((c) => c.type === 'elemental');
if (t.element_atk_scale) {
assert.ok(elem.length > 0, `${label(f)}: 元素伤害 during the 凋亡 bursts (his own CCR-Δ fills them)`);
for (const c of elem) {
approx(c.amount, u.s.atk * t.element_atk_scale, `${label(f)}: ${t.element_atk_scale * 100} % ATK 元素伤害`, 1e-4);
assert.ok(h.hooksOf('elementBurst').some((b) => b.target === c.target && b.element === 'apoptosis' && b.t <= c.t && c.t - b.t < 15), 'in its 凋亡 burst');
}
} else assert.equal(elem.length, 0, `${label(f)}: no 元素伤害`);
done(h);
}
// the module texts
assert.match(modOf(6, CCRD).talentChanges.find((t) => t.talentIndex === 0).desc, /60%几率.*凋亡损伤爆发期间则同时造成相当于攻击力60%的元素伤害/);
assert.match(modOf(6, CCRY).talentChanges.find((t) => t.talentIndex === 0).desc, /两个随机目标造成相当于攻击力65%/);
assert.equal(modOf(5, CCRD).talentChanges.find((t) => t.talentIndex === 0), undefined, 'stage 1: the talent unchanged');
});
test('T1 语汇演化 also follows 殁亡\'s follow-up hit (备注 "可触发第一天赋") and works while he is disarmed', () => {
const { h, u } = field({ tier: 6, elite: true, skill: 0, seed: 3 });
cast(u);
h.b.applyStatus(u, 'disarm', { duration: 999, source: null });
const thr = 1.2 * u.s.atk;
let rolls = 0;
for (let i = 0; i < 40; i++) {
h.spawn('enemy_dummy', { pos: [9, 7] });
const low = h.spawn('enemy_dummy', { pos: [10, 7] });
low.hp = thr * 0.5;
h.step();
rolls++;
for (const e of h.enemies()) h.b.kill(e, null);
h.step();
}
assert.equal(atkHits(h, u).length, 0, 'disarmed: no attack');
assert.equal(tagged(h, u, 'logosPerish').length, rolls);
const extra = tagged(h, u, 'logosLexicon');
assert.ok(extra.length > rolls * 0.2 && extra.length < rolls * 0.6, `${extra.length} / ${rolls} ≈ 40 %`);
done(h);
});
test('T2 剜魂具辞: each of his attack hits gives the target RES −10 and +150 on every arts damage it takes for 5 s — the hit itself included, other casters\' arts too; one instance per enemy', () => {
for (const [tier, elite, mod] of [[5, false, null], [6, true, CCRY]]) {
const t = talentOf(tier, elite, mod, 1);
assert.deepEqual(t, { duration: 5, magic_resistance: -10, atk_addition: 150 });
const { h, u } = field({ tier, elite, mod, skill: 0, seed: 9 });
const e = h.spawn('enemy_dummy', { pos: [10, 7] });
e.base.res = 30; e.markDirty();
const first = () => atkHits(h, u)[0];
assert.ok(h.runUntil(() => !!first(), 4));
const c = first();
approx(c.amount, (u.s.atk + 150) * (1 - 0.2), `T${tier}: (ATK + 150) × (1 − (30 − 10) %) on the very hit`, 1e-4);
const mark = e.findBuff('logos:soulRend');
assert.ok(mark && Math.abs(mark.mods.resFlat + 10) < 1e-9 && mark.data.value === 150);
approx(e.s.res, 20, `T${tier}: RES −10`);
// another source's arts damage gets the 150 too; physical does not
approx(h.b.dealDamage(null, e, { amount: 1000, type: 'arts', canDodge: false }), (1000 + 150) * 0.8, 'arts +150');
approx(h.b.dealDamage(null, e, { amount: 1000, type: 'phys', canDodge: false }), 1000, 'phys unchanged');
// it lapses 5 s after his last hit
h.b.applyStatus(u, 'stun', { duration: 20, source: null });
h.run(5.2);
assert.equal(e.findBuff('logos:soulRend'), null, `T${tier}: gone after 5 s`);
approx(e.s.res, 30, `T${tier}: RES back`);
done(h);
}
});
test('CCR-Δ “来自河谷的笔盒” (stages 1 and 3): 8 % of every arts damage he deals as 凋亡 损伤; none without it', () => {
for (const f of [[5, false, null], [5, true, null], [5, true, CCRD], [6, true, CCRD], [6, true, CCRY]]) {
const [tier, elite, mod] = f;
const { h, u } = field({ tier, elite, mod, skill: 0, seed: 4 });
const e = h.spawn('enemy_dummy', { pos: [10, 7] });
h.run(4);
const arts = h.hooksOf('damaged').filter((c) => c.source === u && c.target === e && c.type === 'arts');
const fills = h.hooksOf('damaged').filter((c) => c.source === u && c.target === e && c.type === 'element');
assert.ok(arts.length > 0);
if (elite && mod === CCRD) {
assert.equal(u.def.raw.talents.find((t) => t.index === -1).bb.ep_damage_ratio, 0.08);
assert.equal(fills.length, arts.length, `${label(f)}: one fill per arts hit`);
fills.forEach((c, i) => {
assert.equal(c.dmg.element, 'apoptosis');
approx(c.amount, arts[i].amount * 0.08, `${label(f)}: 8 %`);
});
} else assert.equal(fills.length, 0, `${label(f)}: no 凋亡`);
done(h);
}
});
test('CCR-Y “《语义范式百科》” (stages 1 and 3): +1 SP per normal-attack hit on an elite or leader enemy (none on a normal one, none while a skill runs); none without it', () => {
for (const f of [[5, true, CCRY], [6, true, CCRY], [6, true, CCRD], [6, false, null]]) {
const [tier, elite, mod] = f;
const y = elite && mod === CCRY;
if (y) assert.deepEqual(modOf(tier, CCRY).traitOverride.bb, { sp: 1 });
for (const key of ['enemy_dummy', 'enemy_elite', 'enemy_boss']) {
const { h, u } = field({ tier, elite, mod, skill: 0, seed: 2 });
const e = h.spawn(key, { pos: [10, 7] });
h.b.applyStatus(u, 'disarm', { duration: 3, source: null });
h.run(3.1);
const sp0 = u.skill.sp, t0 = h.b.time;
h.run(5);
const hits = [...atkHits(h, u), ...tagged(h, u, 'logosLexicon')].filter((c) => c.target === e && c.t >= t0 - 1e-9).length;
const gain = u.skill.sp - sp0 - 5; // 5 s of natural SP
const want = y && key !== 'enemy_dummy' ? hits : 0;
approx(gain, want, `${label(f)} ${key}: ${hits} hits`, 1e-6);
done(h);
}
}
});
+371
View File
@@ -0,0 +1,371 @@
// test/content/op_mantra.test.js — the 自选 operator kit of 真言 (char_4204_mantra, 6★ 本源术师; kit
// server/sim/content/kits/ops/op-mantra.js), fielded the production way (a DIY slot + its `diy` pick, simdata getDiy) in
// every form: tiers 5 / 6, normal (E2 Lv1, skill rank 4, no module) and elite (E2 Lv60, rank 7) with no module or PRI-X
// “中枢神经探测模块” at stage 1 (tier 5) / 3 (tier 6). Every number is read back from data/backups.json (the form of that
// slot status); the fidelity checklist of kits/README.md item by item.
// Run: node --test test/content/op_mantra.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
import { KITTED_CHARS, OPERATOR_KITS, KITS } from '../../server/sim/content/kits/index.js';
import { diyPool, validateDiyPicks } from '../../shared/diy.js';
const load = (f) => JSON.parse(readFileSync(new URL(`../../data/${f}.json`, import.meta.url), 'utf8'));
const CHESS = load('chess');
const BACKUPS = load('backups');
const MANTRA = 'char_4204_mantra';
const FORMS = BACKUPS.units[MANTRA].forms;
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
const PRIX = 'uniequip_002_mantra';
const S1 = 'skchr_mantra_1', S2 = 'skchr_mantra_2', S3 = 'skchr_mantra_3';
const formOf = (tier, elite) => FORMS[elite ? (tier === 5 ? '2/60/7/1' : '2/60/7/3') : '2/1/4/0'];
const skillOf = (tier, elite, id) => formOf(tier, elite).skills.find((s) => s.skillId === id);
const modOf = (tier, mod) => (mod ? formOf(tier, true).modules.find((m) => m.uniEquipId === mod) : null);
const talentOf = (tier, elite, mod, i) => {
const m = elite ? modOf(tier, mod) : null;
return m?.talentChanges.find((t) => t.talentIndex === i)?.bb ?? formOf(tier, elite).talents.find((t) => t.index === i).bb;
};
const approx = (a, b, msg, eps = 1e-6) => assert.ok(Math.abs(a - b) <= eps * Math.max(1, Math.abs(b)), `${msg}: ${a} vs ${b}`);
const dummy = (key, o = {}) => enemyRec({ key, hp: 1e9, speed: 0, mass: 0, ...o });
const ENEMIES = {
enemy_dummy: dummy('enemy_dummy'), enemy_fly: dummy('enemy_fly', { motion: 'FLY' }),
enemy_shooter: dummy('enemy_shooter', { atk: 1, bat: 1, range: 4 }),
enemy_walker: enemyRec({ key: 'enemy_walker', hp: 1e9, speed: 0.3, mass: 0 }),
enemy_immune: dummy('enemy_immune', { otherImmunities: ['palsy'] }),
};
const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, PRIX].map((m) => [t, true, m]))];
const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`;
function field({ tier = 5, elite = false, mod = null, skill = 0, row = 10, col = 5, others = [], seed = 5, setup, stage } = {}) {
const h = makeBattle({
defs: { enemies: ENEMIES }, timeLimit: 900, autoFinish: false, seed, setup, ...(stage ? { flat: stage } : null),
flags: { dpPerSec: 0 }, hooks: ['damaged', 'skillStart', 'skillEnd', 'statusApplied', 'attack', 'elementBurst'], captureNoisy: true,
units: [{ uid: 1, diy: { slot: SLOT[tier], charId: MANTRA, skillIndex: skill, uniEquipId: mod }, elite, row, col }, ...others],
});
h.step();
return { h, u: h.unit(1) };
}
const atkHits = (h, u) => h.hooksOf('damaged').filter((c) => c.source === u && c.dmg?.isAttack);
const tagged = (h, u, tag, src = u) => h.hooksOf('damaged').filter((c) => (c.source === src || c.credit === src) && (c.dmg?.tags || []).includes(tag));
function done(h) {
checkInvariants(h.b);
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
}
function cast(u) { u.skill.gainSp(999); assert.ok(u.skill.activate('test')); }
const palsy = (e) => e.findBuff('palsy')?.stacks ?? 0;
test('真言 in every 自选 form: her operator kit (all three skills authored), stats + PRI-X attributes, 3-1 range, ranged arts that hits air units, targetable by ground enemies, 萨尔贡 bond, no 特质', () => {
assert.equal(OPERATOR_KITS[MANTRA], KITS[MANTRA]);
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
for (const skill of [0, 1, 2]) {
const { h, u } = field({ tier, elite, mod, skill });
const form = formOf(tier, elite), m = elite ? modOf(tier, mod) : null;
assert.deepEqual([u.def.charId, u.def.diyFor, u.skill.id, !!u.kit.generic, u.kit.skillSource], [MANTRA, SLOT[tier], form.skills[skill].skillId, false, 'skills'], label(f));
assert.deepEqual([u.base.maxHp, u.base.atk, u.base.def], [form.stats.maxHp + (m?.attr.maxHp ?? 0), form.stats.atk + (m?.attr.atk ?? 0), form.stats.def], `${label(f)}: stats`);
assert.deepEqual([u.s.blockCnt, u.profile.attack, u.profile.dmgType, u.profile.canHitFly, u.base.bat], [1, 'ranged', 'arts', true, 1.6], `${label(f)}: 本源术师`);
assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: 3-1`);
assert.deepEqual([u.def.bonds, u.def.raw.garrisonIds], [['sargonShip'], []], `${label(f)}: bonds / 特质`);
assert.ok(!u.s.flags.liftoff && !u.s.flags.camou && !u.s.flags.stealth, `${label(f)}: ground enemies target her`);
done(h);
}
}
assert.deepEqual([FORMS['2/1/4/0'].stats.maxHp, FORMS['2/1/4/0'].stats.atk, FORMS['2/60/7/1'].stats.maxHp, FORMS['2/60/7/1'].stats.atk], [1071, 583, 1308, 657]);
assert.deepEqual([modOf(5, PRIX).attr, modOf(6, PRIX).attr], [{ maxHp: 90, atk: 43 }, { maxHp: 145, atk: 87 }]);
});
test('a 自选 pick: 真言 is offered at tiers 5 and 6 (she has a kit) and a roster with her passes validateDiyPicks', () => {
const data = { chess: CHESS, backups: BACKUPS };
assert.ok(KITTED_CHARS.includes(MANTRA));
for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(MANTRA), `tier ${t}`);
assert.deepEqual(validateDiyPicks({ [SLOT[5]]: { charId: MANTRA, skillIndex: 2, uniEquipId: PRIX } }, { data, kitted: KITTED_CHARS }),
{ ok: true, picks: { [SLOT[5]]: { charId: MANTRA, skillIndex: 2, uniEquipId: PRIX } } });
});
test('S1 共鸣溃缩 (AUTO, attack SP 3, data DEFAULT): every 4th attack deals 230 % / 275 % ATK arts, then 15 % / 20 % of it as 神经 损伤, then — on a target in its 神经 burst — 150 % / 160 % ATK 元素伤害', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S1);
const { h, u } = field({ tier, elite, skill: 0 });
assert.deepEqual([u.skill.rule, u.skill.kind, u.skill.spType, u.skill.spCost, sk.bb.atk_scale, sk.bb.ep_damage_ratio, sk.bb.element_atk_scale],
['DEFAULT', 'instant', 'attack', 3, elite ? 2.75 : 2.3, elite ? 0.2 : 0.15, elite ? 1.6 : 1.5], `T${tier}`);
const e = h.spawn('enemy_dummy', { pos: [10, 7] });
h.run(16 * 1.6 + 0.5);
const hits = atkHits(h, u).filter((c) => c.target === e);
const skillHits = hits.filter((c) => c.dmg.isSkill);
assert.ok(hits.length >= 15, `T${tier}: ${hits.length} attacks`);
hits.forEach((c, i) => assert.equal(c.dmg.isSkill, i % 4 === 3, `T${tier}: attack ${i + 1}`));
const fills = h.hooksOf('damaged').filter((c) => c.source === u && c.type === 'element');
for (const c of skillHits) {
const fill = fills.find((x) => Math.abs(x.t - c.t) < 1e-9);
if (fill) approx(fill.amount, c.amount * sk.bb.ep_damage_ratio, `T${tier}: 神经 = ${sk.bb.ep_damage_ratio * 100} % of the damage`);
}
approx(skillHits[0].amount, u.s.atk * sk.bb.atk_scale, `T${tier}: ${sk.bb.atk_scale * 100} % ATK`);
assert.ok(fills.length >= 1 && fills.every((x) => x.dmg.element === 'neural'));
// in its 神经 burst: + element_atk_scale × ATK 元素伤害 after the 神经 fill (a fresh target one point short of it)
h.b.kill(e, null);
const e2 = h.spawn('enemy_dummy', { pos: [10, 7] });
e2.elem.neural = e2.gaugeMax - 1;
const n0 = h.hooksOf('damaged').length;
h.runUntil(() => h.hooksOf('damaged').slice(n0).some((c) => c.source === u && c.dmg?.isSkill && c.dmg?.isAttack), 8);
h.step();
const after = h.hooksOf('damaged').slice(n0).filter((c) => c.source === u && c.type === 'elemental' && (c.dmg.tags || []).includes('mantra'));
assert.ok(h.hooksOf('elementBurst').some((c) => c.target === e2 && c.element === 'neural'), `T${tier}: her 神经 burst it`);
assert.equal(after.length, 1, `T${tier}: the burst made by its own 神经 counts`);
approx(after[0].amount, u.s.atk * sk.bb.element_atk_scale, `T${tier}: ${sk.bb.element_atk_scale * 100} % ATK 元素伤害`);
done(h);
}
});
test('S2 意识联协 (MANUAL, data DEFAULT): 25 s, attack interval 1.6 − 0.6 / 0.7 s; 170 % / 210 % ATK on the target, then 2 / 3 jumps at 3.2 tiles/s to the nearest new enemy within 2.0 tiles for 100 % / 110 %; 神经 riders on every hit', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S2);
const b = sk.bb;
const { h, u } = field({ tier, elite, skill: 1 });
assert.deepEqual([u.skill.rule, sk.duration, b.base_attack_time, b['attack@atk_scale'], b['attack@chain.atk_scale'], b.chain_times, b['attack@ep_damage_ratio'], b['attack@element_atk_scale']],
['DEFAULT', 25, elite ? -0.7 : -0.6, elite ? 2.1 : 1.7, elite ? 1.1 : 1, elite ? 3 : 2, elite ? 0.15 : 0.13, elite ? 0.2 : 0.18], `T${tier}`);
// a line of enemies 1.5 tiles apart (the 4th is 2.1 from the 3rd: out of the jump radius at tier 6)
const main = h.spawn('enemy_dummy', { pos: [10, 7] });
const j1 = h.spawn('enemy_dummy', { pos: [10, 8.5] });
const j2 = h.spawn('enemy_fly', { pos: [10, 10] });
const j3 = h.spawn('enemy_dummy', { pos: [11.5, 10] });
const out = h.spawn('enemy_dummy', { pos: [11.5, 12.1] });
cast(u);
approx(u.s.interval, 1.6 + b.base_attack_time, `T${tier}: interval`);
const a0 = h.hooksOf('attack').filter((c) => c.attacker === u).length;
h.runUntil(() => h.hooksOf('attack').filter((c) => c.attacker === u).length > a0, 2);
const at = h.hooksOf('attack').filter((c) => c.attacker === u).slice(-1)[0];
assert.equal(at.targets[0], main, `T${tier}: the first target`);
h.run(3);
const id = atkHits(h, u).find((c) => c.target === main && c.t >= at.t).dmg.attackId;
const chainHits = atkHits(h, u).filter((c) => c.dmg.attackId === id);
const order = chainHits.map((c) => c.target);
assert.deepEqual(order, [main, j1, j2, j3].slice(0, 1 + b.chain_times), `T${tier}: the nearest new enemy each time`);
assert.ok(!order.includes(out), `T${tier}: 2.1 tiles: out of reach`);
approx(chainHits[0].amount, u.s.atk * b['attack@atk_scale'], `T${tier}: main`);
for (const c of chainHits.slice(1)) {
approx(c.amount, u.s.atk * b['attack@chain.atk_scale'], `T${tier}: jump`);
assert.ok(c.dmg.tags.includes('chain') && c.dmg.isSkill);
}
approx(chainHits[2].t - chainHits[1].t, 1.5 / 3.2, `T${tier}: 3.2 tiles/s`, 0.1);
for (const [i, c] of chainHits.entries()) {
const fill = h.hooksOf('damaged').find((x) => x.source === u && x.type === 'element' && x.target === c.target && Math.abs(x.t - c.t) < 1e-9);
assert.ok(fill, `T${tier}: a 神经 fill with hit ${i}`);
approx(fill.amount, c.amount * b['attack@ep_damage_ratio'], `T${tier}: 神经 ${i}`);
}
h.runUntil(() => !u.skill.active, 30);
approx(u.s.interval, 1.6, `T${tier}: interval back`);
done(h);
}
});
test('S3 无言为真 (MANUAL, data ACTIVE_RANGE on its 3-17): cast for an enemy outside her own range, 40 s, ATK +170 % / +210 %, 2 targets, the 3-17 while on; 隐匿 enemies on it are revealed', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S3);
const { h, u } = field({ tier, elite, skill: 2 });
assert.deepEqual([u.skill.rule, sk.rangeId, sk.duration, sk.bb.atk, sk.bb['attack@max_target'], sk.bb.max_trigger_cnt, sk.bb.per_active, sk.bb.interval_projectile_trigger, sk.bb.atk_scale],
['ACTIVE_RANGE', '3-17', 40, elite ? 2.1 : 1.7, 2, 3, 2, 1.5, elite ? 1.45 : 1.1], `T${tier}`);
u.skill.gainSp(999);
h.run(1);
assert.equal(u.skill.activations, 0);
const far = h.spawn('enemy_dummy', { pos: [12, 6] }); // dRow +2: the 3-17 only
assert.ok(h.runUntil(() => u.skill.active, 1), `T${tier}: cast`);
assert.ok(!formOf(tier, elite).rangeGrid.some(([r, c]) => r === far.y - 10 && c === far.x - 5), `T${tier}: the enemy is not on her own 3-1`);
assert.deepEqual(u.liveRangeGrid, sk.rangeGrid, `T${tier}: 3-17`);
approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `T${tier}: ATK`);
const sneak = h.spawn('enemy_dummy', { pos: [10, 7] });
h.b.addBuff(sneak, { key: 'test:stealth', flags: { stealth: true } });
h.step();
assert.ok(sneak.s.flags.reveal, `T${tier}: revealed on her range`);
const a0 = h.hooksOf('attack').filter((c) => c.attacker === u).length;
h.run(4);
const atks = h.hooksOf('attack').filter((c) => c.attacker === u).slice(a0);
assert.ok(atks.length >= 2 && atks.every((c) => c.targets.length === 2), `T${tier}: 2 targets`);
h.runUntil(() => !u.skill.active, 45);
h.run(0.3);
assert.ok(!sneak.s.flags.reveal, `T${tier}: hidden again once it ends`);
assert.deepEqual(u.liveRangeGrid, formOf(tier, elite).rangeGrid, `T${tier}: back to 3-1`);
done(h);
}
});
test('S3 无言为真 麻痹付与 and the cap 2: 0.1 s after the cast every enemy on her range gets 2 麻痹; another operator on her range starting a skill grants again (3 times per cast, a grant kept while nobody is there); stacks over 2 become 溢出麻痹 — each an 元素伤害 bounce of 110 % / 145 % ATK on the holder and the nearest other enemy within 2.0 tiles, one at once and one per 1.5 s', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S3);
const others = [{ uid: 2, chessId: 'chess_char_1_08_a', row: 11, col: 6 }, { uid: 3, chessId: 'chess_char_1_08_a', row: 9, col: 2 }];
const { h, u } = field({ tier, elite, skill: 2, others });
const inside = h.unit(2), outside = h.unit(3);
for (const a of [inside, outside]) { a.skill.sp = 0; a.skill.charges = 0; }
cast(u);
h.run(0.05);
// nobody on her range yet: the grant at 0.1 s is kept
h.run(0.1);
assert.equal(u.mem.mantraS3.grants, 3, `T${tier}: kept`);
const e = h.spawn('enemy_dummy', { pos: [10, 7] });
const near = h.spawn('enemy_dummy', { pos: [10, 8] });
h.step();
// an operator outside her range starting a skill: nothing
outside.skill.gainSp(999); outside.skill.activate('test');
h.step();
assert.equal(palsy(e), 0, `T${tier}: an operator off her range`);
// the one on her range: the first grant (2 stacks, at the cap)
inside.skill.gainSp(999); inside.skill.activate('test');
h.step();
assert.deepEqual([palsy(e), palsy(near), u.mem.mantraS3.grants], [2, 2, 2], `T${tier}: 2 麻痹 each`);
assert.equal(tagged(h, u, 'mantraOverflow').length, 0, `T${tier}: no overflow yet`);
// the second: 2 over the cap each ⇒ 溢出麻痹 2 each, one bounce at once each
const n0 = h.hooksOf('damaged').length;
inside.skill.gainSp(999); inside.skill.activate('test');
h.step();
assert.deepEqual([palsy(e), palsy(near)], [2, 2], `T${tier}: capped at 2`);
let bounces = h.hooksOf('damaged').slice(n0).filter((c) => c.source === u && (c.dmg.tags || []).includes('mantraOverflow'));
assert.deepEqual(bounces.map((c) => c.target.id).sort(), [e.id, near.id].sort(), `T${tier}: each holder hit at once`);
for (const c of bounces) {
approx(c.amount, u.s.atk * sk.bb.atk_scale, `T${tier}: ${sk.bb.atk_scale * 100} % ATK`);
assert.deepEqual([c.type, c.dmg.element], ['elemental', 'neural']);
}
h.run(0.5); // the jumps land (1 tile at 3.2 tiles/s)
bounces = h.hooksOf('damaged').slice(n0).filter((c) => c.source === u && (c.dmg.tags || []).includes('mantraOverflow'));
assert.equal(bounces.length, 4, `T${tier}: holder + the nearest other, twice`);
h.run(1.1); // 1.5 s after: the second 溢出麻痹 of each
bounces = h.hooksOf('damaged').slice(n0).filter((c) => c.source === u && (c.dmg.tags || []).includes('mantraOverflow'));
assert.ok(bounces.length >= 6, `T${tier}: the next one 1.5 s later (${bounces.length})`);
// the third grant, then no more
inside.skill.gainSp(999); inside.skill.activate('test');
h.step();
assert.equal(u.mem.mantraS3.grants, 0, `T${tier}: 3 grants used`);
const g = h.hooksOf('statusApplied').filter((c) => c.source === u && c.status === 'palsy').length;
inside.skill.gainSp(999); inside.skill.activate('test');
h.step();
assert.equal(h.hooksOf('statusApplied').filter((c) => c.source === u && c.status === 'palsy').length, g, `T${tier}: no 4th grant`);
done(h);
}
});
test('S3 无言为真: an enemy holding 3 麻痹 entering her range keeps 2 and bounces the third; 溢出麻痹 left when it leaves the range or the skill ends all bounce at once; a 麻痹免疫 enemy counts the stacks she gives it', () => {
const { h, u } = field({ tier: 6, elite: true, skill: 2 });
cast(u);
h.run(0.2); // the grant of 0.1 s finds nobody (kept, never fired again here)
const e = h.spawn('enemy_dummy', { pos: [10, 12] }); // off her range
h.b.applyStatus(e, 'palsy', { value: 3 });
h.step();
assert.equal(palsy(e), 3, 'off her range: cap 3');
e.x = 7; h.step();
assert.equal(palsy(e), 2, 'on her range: 2');
assert.equal(tagged(h, u, 'mantraOverflow').filter((c) => c.target === e).length, 1, 'the third bounced');
// a neural burst's 3 麻痹 on a holder at the cap: 3 溢出麻痹 — one at once, two left; it leaves ⇒ both at once
const n0 = tagged(h, u, 'mantraOverflow').length;
h.b.applyStatus(e, 'palsy', { value: 3 });
assert.equal(tagged(h, u, 'mantraOverflow').length - n0, 1, 'one at once');
e.x = 13; h.step();
assert.equal(tagged(h, u, 'mantraOverflow').length - n0, 3, 'the two left at once when it leaves');
assert.equal(palsy(e), 2, 'its stacks stay (cap 3 again)');
// the skill ends with 溢出麻痹 held: all of it bounces
const f = h.spawn('enemy_dummy', { pos: [10, 6] });
h.step();
h.b.applyStatus(f, 'palsy', { value: 3 });
h.b.applyStatus(f, 'palsy', { value: 3 });
const n1 = tagged(h, u, 'mantraOverflow').filter((c) => c.target === f).length;
u.skill.end('test');
assert.equal(tagged(h, u, 'mantraOverflow').filter((c) => c.target === f).length - n1, 2, 'the rest at the end');
// 麻痹免疫: her grants count (ineffective stacks), so the second grant overflows
const { h: h2, u: u2 } = field({ tier: 6, elite: true, skill: 2, others: [{ uid: 2, chessId: 'chess_char_1_08_a', row: 11, col: 6 }] });
const imm = h2.spawn('enemy_immune', { pos: [10, 7] });
cast(u2);
h2.run(0.2);
assert.equal(palsy(imm), 0, 'no 麻痹 buff (immune)');
assert.equal(imm.mem.mantraPalsy, 2, 'counted');
const ally = h2.unit(2);
ally.skill.gainSp(999); ally.skill.activate('test');
h2.step();
assert.equal(tagged(h2, u2, 'mantraOverflow').filter((c) => c.target === imm).length, 1, 'its 溢出麻痹 bounces');
done(h);
done(h2);
});
test('T1 噤声限域: an enemy whose attack a 麻痹 stack interrupts takes 135 % (PRI-X stage 3: 160 %) of her ATK as 元素伤害 (tagged 持续伤害); 10 % / 15 % of the interrupts keep the stack; not for an enemy she cannot select (隐匿)', () => {
for (const f of [[5, false, null], [6, true, null], [5, true, PRIX], [6, true, PRIX]]) {
const [tier, elite, mod] = f;
const t = talentOf(tier, elite, mod, 0);
assert.deepEqual(t, elite && mod === PRIX && tier === 6 ? { atk_scale: 1.6, prob: 0.15 } : { atk_scale: 1.35, prob: 0.1 }, label(f));
const { h, u } = field({ tier, elite, mod, skill: 0, seed: 13 });
const e = h.spawn('enemy_shooter', { pos: [10, 8] });
h.b.applyStatus(u, 'disarm', { duration: 999, source: null }); // her attacks would kill nothing, but keep it clean
h.run(0.6); // the aura's 0.5 s check has marked it
let interrupts = 0, kept = 0;
h.b.on('palsyTrigger', (c) => { if (c.enemy === e) { interrupts++; } });
for (let i = 0; i < 400; i++) {
h.b.applyStatus(e, 'palsy', { value: 1 });
const before = interrupts;
h.runUntil(() => interrupts > before, 3);
h.step();
if (palsy(e) > 0) { kept++; h.b.removeBuff(e, 'palsy'); }
}
const hush = tagged(h, u, 'mantraHush');
assert.equal(hush.length, interrupts, `${label(f)}: one hit per interrupt`);
for (const c of hush.slice(0, 5)) {
approx(c.amount, u.s.atk * t.atk_scale, `${label(f)}: ${t.atk_scale * 100} % ATK`);
assert.deepEqual([c.type, c.dmg.element, c.dmg.tags.includes('dot')], ['elemental', 'neural', true]);
}
assert.ok(Math.abs(kept / interrupts - t.prob) < 0.05, `${label(f)}: kept ${kept} / ${interrupts} ≈ ${t.prob}`);
done(h);
}
// 隐匿: no mark, no hit; during S3 the check ignores 隐匿
const { h, u } = field({ tier: 6, elite: true, skill: 2, seed: 1 });
const e = h.spawn('enemy_shooter', { pos: [10, 2] }); // behind her: it shoots her, she cannot see it (and S3 does not reveal it)
h.b.addBuff(e, { key: 'test:stealth', flags: { stealth: true } });
let interrupts = 0;
h.b.on('palsyTrigger', (c) => { if (c.enemy === e) interrupts++; });
h.run(0.6);
h.b.applyStatus(e, 'palsy', { value: 2 });
h.run(2.2);
assert.equal(interrupts, 2, 'its attacks were interrupted');
assert.equal(tagged(h, u, 'mantraHush').length, 0, 'a 隐匿 enemy: no effect');
cast(u);
h.run(0.6);
assert.ok(!e.s.flags.reveal, 'not revealed (off her range)');
h.b.applyStatus(e, 'palsy', { value: 2 });
h.run(2.2);
assert.equal(tagged(h, u, 'mantraHush').length, 2, 'S3: the check ignores 隐匿');
done(h);
});
test('T2 全局洞悉: the 侵入点 nearest to her (Manhattan) — every enemy that comes out of it gets 1 麻痹 0.1 s later (隐匿 too); the other gate\'s do not', () => {
for (const [row, gate, other] of [[10, 0, 1], [11, 1, 0]]) { // flat stage gates: route 0 starts at (9,10), route 1 at (12,10)
const { h, u } = field({ tier: 5, skill: 0, row, seed: 2 });
assert.deepEqual(u.mem.mantraGate, gate === 0 ? [9, 10] : [12, 10], `row ${row}: the nearest gate`);
const a = h.spawn('enemy_walker', { routeIndex: gate });
const b = h.spawn('enemy_walker', { routeIndex: other });
h.b.addBuff(a, { key: 'test:stealth', flags: { stealth: true } });
h.step();
assert.equal(palsy(a), 0, 'not at once');
h.run(0.1);
assert.deepEqual([palsy(a), palsy(b)], [1, 0], `row ${row}`);
done(h);
}
// a tie (two gates at the same distance) is drawn at random
const picks = new Set();
for (let seed = 1; seed <= 12; seed++) {
const { h, u } = field({ tier: 5, skill: 0, row: 10, col: 10, seed, stage: { rows: { 11: '##hrrrrrrrSrrrrrrrf##' } } });
picks.add(String(u.mem.mantraGate));
done(h);
}
assert.deepEqual([...picks].sort(), ['11,10', '9,10'], 'both gates one tile away get drawn');
});
test('PRI-X “中枢神经探测模块” (stages 1 and 3): damage to an enemy in an element burst ×1.1; none without it', () => {
for (const f of [[5, true, PRIX], [6, true, PRIX], [6, true, null], [5, false, null]]) {
const [tier, elite, mod] = f;
const { h, u } = field({ tier, elite, mod, skill: 0, seed: 8 });
const plain = h.spawn('enemy_dummy', { pos: [10, 7] });
h.run(1.7);
const n0 = atkHits(h, u).length;
h.b.addBuff(plain, { key: 'burnBurst', duration: 99, flags: { burstLock: true } });
h.run(1.7);
const a = atkHits(h, u)[0].amount, b = atkHits(h, u).slice(n0)[0].amount;
approx(b / a, elite && mod === PRIX ? 1.1 : 1, label(f));
if (elite && mod === PRIX) assert.equal(modOf(tier, PRIX).traitOverride.bb.damage_scale, 1.1);
done(h);
}
});
+376
View File
@@ -0,0 +1,376 @@
// test/content/op_narant.test.js — the 自选 operator kit of 娜仁图亚 (char_4138_narant, 6★ 回环射手; kit
// server/sim/content/kits/ops/op-narant.js), fielded the production way (a DIY slot + its `diy` pick, simdata getDiy) in
// every form: tiers 5 / 6, normal (E2 Lv1, skill rank 4, no module) and elite (E2 Lv60, rank 7) with no module, LPS-X
// “吹尽狂沙” or LPS-Y “致我们的老大” at stage 1 (tier 5) / 3 (tier 6). Every number is read back from data/backups.json (the
// form of that slot status); the fidelity checklist of kits/README.md item by item. Also the two engine hooks of the
// boomerang flight it relies on (ai.js throwBoomerang: `boomerangCaught`, a profile's `boomerangOnward`).
// Run: node --test test/content/op_narant.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
import { KITTED_CHARS, OPERATOR_KITS, KITS } from '../../server/sim/content/kits/index.js';
import { diyPool, validateDiyPicks } from '../../shared/diy.js';
import { PROJECTILE_SPEEDS, BOOMERANG_RETURN_SPEED } from '../../server/sim/constants.js';
const load = (f) => JSON.parse(readFileSync(new URL(`../../data/${f}.json`, import.meta.url), 'utf8'));
const CHESS = load('chess');
const BACKUPS = load('backups');
const NARANT = 'char_4138_narant';
const FORMS = BACKUPS.units[NARANT].forms;
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
const LPSX = 'uniequip_002_narant', LPSY = 'uniequip_003_narant';
const S1 = 'skchr_narant_1', S2 = 'skchr_narant_2', S3 = 'skchr_narant_3';
const formOf = (tier, elite) => FORMS[elite ? (tier === 5 ? '2/60/7/1' : '2/60/7/3') : '2/1/4/0'];
const skillOf = (tier, elite, id) => formOf(tier, elite).skills.find((s) => s.skillId === id);
const modOf = (tier, mod) => (mod ? formOf(tier, true).modules.find((m) => m.uniEquipId === mod) : null);
const approx = (a, b, msg, eps = 1e-6) => assert.ok(Math.abs(a - b) <= eps * Math.max(1, Math.abs(b)), `${msg}: ${a} vs ${b}`);
const dummy = (key, o = {}) => enemyRec({ key, hp: 1e9, speed: 0, mass: 0, ...o });
const ENEMIES = {
enemy_dummy: dummy('enemy_dummy'), enemy_brute: dummy('enemy_brute', { atk: 400, def: 300 }),
enemy_fly: dummy('enemy_fly', { motion: 'FLY' }),
// a ranged attacker that shoots twice a second at whatever ally its 2.5-tile radius holds (an ATK her steals cannot drain)
enemy_shooter: dummy('enemy_shooter', { atk: 5000, bat: 0.5, range: 2.5 }),
};
/** Every 自选 form: [tier, elite, module]. */
const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, LPSX, LPSY].map((m) => [t, true, m]))];
/** LPS-Y stage 3 婀娜虚影: ATK +15 % while unhurt — she starts unhurt. */
const calmAtk = (tier, elite, mod) => (elite && mod === LPSY && tier === 6 ? 0.15 : 0);
/** A battle with 娜仁图亚 as uid 1 at (row, col) facing RIGHT, plus `others`. */
function field({ tier = 5, elite = false, mod = null, skill = 0, row = 10, col = 3, others = [], seed = 5, hooks = [] } = {}) {
const h = makeBattle({
defs: { enemies: ENEMIES }, timeLimit: 900, autoFinish: false, seed,
flags: { dpPerSec: 0, dpMax: 999 }, hooks: ['damaged', 'skillStart', 'skillEnd', 'statusApplied', 'boomerangCaught', 'spGain', 'attack', ...hooks], captureNoisy: true,
units: [{ uid: 1, diy: { slot: SLOT[tier], charId: NARANT, skillIndex: skill, uniEquipId: mod }, elite, row, col }, ...others],
});
h.step();
return { h, u: h.unit(1) };
}
const hitsBy = (h, u) => h.hooksOf('damaged').filter((c) => c.source === u);
const atkHits = (h, u) => hitsBy(h, u).filter((c) => c.dmg?.isAttack);
function done(h) {
checkInvariants(h.b);
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
}
const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`;
const TICK = 1 / 30;
test('娜仁图亚 in every 自选 form: her operator kit (all three skills authored), the form\'s stats + module attributes, y-7 range, ranged physical boomerangs that hit air, block 1, 35 % dodge, no 特质', () => {
assert.equal(OPERATOR_KITS[NARANT], KITS[NARANT]);
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
for (const skill of [0, 1, 2]) {
const { h, u } = field({ tier, elite, mod, skill });
const form = formOf(tier, elite), m = elite ? modOf(tier, mod) : null;
assert.deepEqual([u.def.charId, u.def.diyFor, u.skill.id, !!u.kit.generic, u.kit.skillSource], [NARANT, SLOT[tier], form.skills[skill].skillId, false, 'skills'], label(f));
assert.deepEqual([u.base.maxHp, u.base.atk, u.base.def], [form.stats.maxHp + (m?.attr.maxHp ?? 0), form.stats.atk + (m?.attr.atk ?? 0), form.stats.def + (m?.attr.def ?? 0)], `${label(f)}: stats`);
assert.deepEqual([u.s.blockCnt, u.profile.attack, u.profile.dmgType, u.profile.projectile, u.profile.canHitFly, u.base.bat, typeof u.profile.canAttack],
[1, 'ranged', 'phys', 'boomerang', true, 1, 'function'], `${label(f)}: 回环射手`);
assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: y-7`);
assert.deepEqual([u.s.dodgePhys, u.s.dodgeArts], [0.35, 0.35], `${label(f)}: 婀娜虚影 dodge`);
assert.deepEqual([u.def.bonds, u.def.raw.garrisonIds], [BACKUPS.diy.operators[NARANT].bonds, []], `${label(f)}: bonds / 特质`);
assert.ok(!u.s.flags.liftoff && !u.s.flags.camou && !u.s.flags.stealth, `${label(f)}: ground enemies target her`);
done(h);
}
}
// E2 Lv1 2000 / 546 / 144, E2 Lv60 2331 / 632 / 161; LPS-X +25 / +25 → +45 / +45 ATK / DEF, LPS-Y +210 / +20 → +300 / +40 HP / ATK
assert.deepEqual([FORMS['2/1/4/0'].stats.maxHp, FORMS['2/1/4/0'].stats.atk, FORMS['2/60/7/1'].stats.maxHp, FORMS['2/60/7/1'].stats.atk], [2000, 546, 2331, 632]);
assert.deepEqual([modOf(5, LPSX).attr, modOf(6, LPSX).attr, modOf(5, LPSY).attr, modOf(6, LPSY).attr], [{ atk: 25, def: 25 }, { atk: 45, def: 45 }, { maxHp: 210, atk: 20 }, { maxHp: 300, atk: 40 }]);
});
test('a 自选 pick: 娜仁图亚 is offered at tiers 5 and 6 (she has a kit) and a roster with her passes validateDiyPicks', () => {
const data = { chess: CHESS, backups: BACKUPS };
assert.ok(KITTED_CHARS.includes(NARANT));
for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(NARANT), `tier ${t}`);
assert.deepEqual(validateDiyPicks({ [SLOT[5]]: { charId: NARANT, skillIndex: 0, uniEquipId: LPSX } }, { data, kitted: KITTED_CHARS }),
{ ok: true, picks: { [SLOT[5]]: { charId: NARANT, skillIndex: 0, uniEquipId: LPSX } } });
});
test('trait 回环射手 (engine): one boomerang out at 15 and back at 3.75 tiles/s, the next throw once it is caught — each catch fires boomerangCaught { unit, attackId, isSkill }; a flyer is hit', () => {
const { h, u } = field({ tier: 6, elite: true, skill: 2 });
const fly = h.spawn('enemy_fly', { pos: [10, 6] });
h.run(6);
const hits = atkHits(h, u), caught = h.hooksOf('boomerangCaught').filter((c) => c.unit === u);
assert.ok(hits.length >= 2 && hits.every((c) => c.target === fly), '可对空');
assert.ok(caught.length >= 2);
for (const c of caught) assert.ok(hits.some((x) => x.dmg.attackId === c.attackId) && c.isSkill === false, 'the catch names its attack');
const d = 3;
const flight = d / PROJECTILE_SPEEDS.boomerang + d / BOOMERANG_RETURN_SPEED;
approx(caught[0].t - hits[0].t, d / BOOMERANG_RETURN_SPEED, 'back at 3.75 tiles/s', 0.1);
approx(hits[1].t - hits[0].t, flight, 'she waits for it (longer than her 1 s interval)', 0.1);
assert.ok(h.eventsOf('atk').filter((e) => e[1] === u.id).every((e) => e[3] === 'boomerang'));
done(h);
});
test('S1 旋刃 (切换: on until she leaves; data DEFAULT): range −1 (the far column of y-7), 140 % / 160 % ATK, then up to 3 bounces (radius 1.7, an enemy this attack has not hit first, then the nearest; 6 tiles/s), then back; a lone enemy: no bounce', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S1);
const { h, u } = field({ tier, elite, skill: 0 });
assert.deepEqual([u.skill.rule, u.skill.kind, u.skill.spCost, u.skill.initSp, sk.bb['attack@times'], sk.bb['attack@atk_scale'], sk.bb.ability_range_forward_extend],
['DEFAULT', 'toggle', elite ? 8 : 9, 0, 3, elite ? 1.6 : 1.4, -1], `T${tier}`);
const lone = h.spawn('enemy_dummy', { pos: [10, 5] });
assert.ok(h.runUntil(() => u.skill.active, 12), `T${tier}: on once its SP is full and an enemy is in range`);
const short = formOf(tier, elite).rangeGrid.filter(([, c]) => c < 3);
assert.deepEqual(u.liveRangeGrid, short, `T${tier}: 攻击距离-1`);
h.run(3);
const one = atkHits(h, u).filter((c) => c.dmg.isSkill);
assert.ok(one.length >= 1 && one.every((c) => c.target === lone), `T${tier}: one hit per attack on a lone enemy`);
assert.equal(new Set(one.map((c) => c.dmg.attackId)).size, one.length, `T${tier}: no bounce`);
const before = atkHits(h, u).filter((c) => c.t < one[0].t - 1e-9).length; // her normal attacks' steals so far
approx(one[0].amount, (u.base.atk + Math.min(250, 25 * before)) * sk.bb['attack@atk_scale'], `T${tier}: ${sk.bb['attack@atk_scale'] * 100} % (+ the ATK stolen before)`);
// A (lone) at 10,5; B at 11,5 (1 from A); C at 9,6 (1.41 from A, 2.24 from B)
const b = h.spawn('enemy_dummy', { pos: [11, 5] }), c = h.spawn('enemy_dummy', { pos: [9, 6] });
const far = h.spawn('enemy_dummy', { pos: [12, 5] }); // 2 from A, 1 from B: reachable from B only
h.run(0.1);
const n0 = atkHits(h, u).length;
h.run(4);
const after = atkHits(h, u).slice(n0);
const id = after[0].dmg.attackId;
const chain = after.filter((x) => x.dmg.attackId === id);
assert.equal(chain.length, 4, `T${tier}: the hit + 3 bounces`);
assert.equal(chain[0].target, lone, `T${tier}: her target`);
assert.equal(chain[1].target, b, `T${tier}: nearest not yet hit (B, 1 tile)`);
assert.equal(chain[2].target, far, `T${tier}: from B the one not yet hit (12,5), before A`);
approx(chain[1].t - chain[0].t, 1 / 6 + TICK, `T${tier}: bounce at 6 tiles/s`, 0.07);
assert.ok(chain.every((x) => x.dmg.isAttack && x.dmg.isSkill && x.type === 'phys'), `T${tier}: hits of the attack`);
assert.ok(!chain.some((x) => x.target === c) || chain[3].target === c, `T${tier}: C (2.24 from B) only from A`);
// on until she leaves; redeployed: off, own range
h.run(30);
assert.ok(u.skill.active, `T${tier}: still on`);
h.b.retreat(u);
h.b.redeploy(u);
assert.ok(!u.skill.active, `T${tier}: off after a redeploy`);
assert.deepEqual(u.liveRangeGrid, formOf(tier, elite).rangeGrid, `T${tier}: y-7 again`);
done(h);
}
});
test('S2 恶魇 (30 s, attack SP 18 from 5 / 8): 210 % / 225 % ATK + 停顿 1 s; the boomerang flies on 0.5 s, then hits every enemy within 1.0 of its way back once for 155 % / 170 %; farther ones untouched', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S2);
const { h, u } = field({ tier, elite, skill: 1 });
assert.deepEqual([u.skill.rule, u.skill.spType, u.skill.spCost, u.skill.initSp, sk.duration, sk.bb['attack@atk_scale'], sk.bb['attack@atk_scale_comeback'], sk.bb['attack@sluggish'], sk.bb['attack@move_ahead_time'], sk.bb['attack@projectile_range']],
['DEFAULT', 'attack', 18, elite ? 8 : 5, 30, elite ? 2.25 : 2.1, elite ? 1.7 : 1.55, 1, 0.5, 1], `T${tier}`);
const target = h.spawn('enemy_dummy', { pos: [10, 6] });
const side = h.spawn('enemy_dummy', { pos: [11, 5] }); // 1.0 from the return line (row 10): hit on the way back
const off = h.spawn('enemy_dummy', { pos: [12, 4] }); // 2 rows off: never
h.b.addBuff(target, { key: 'test:taunt', mods: { taunt: 5 } }); // her target
// attack SP: one per normal attack
const sp0 = u.skill.sp;
h.run(3.5);
const normal = atkHits(h, u).filter((c) => !c.dmg.isSkill).length;
approx(u.skill.sp, Math.min(18, sp0 + normal), `T${tier}: +1 SP an attack`);
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 4), `T${tier}: cast`);
approx(u.skill.timeLeft, 30, `T${tier}: 30 s`, 0.1);
h.run(3);
const sk2 = atkHits(h, u).filter((c) => c.dmg.isSkill);
const id = sk2[0].dmg.attackId;
const main = sk2.find((c) => c.dmg.attackId === id && !c.dmg.tags?.includes('narant:comeback'));
const back = sk2.filter((c) => c.dmg.attackId === id && c.dmg.tags?.includes('narant:comeback'));
assert.equal(main.target, target, `T${tier}: her target`);
assert.deepEqual(back.map((c) => c.target).sort((a, b) => a.id - b.id), [target, side].sort((a, b) => a.id - b.id), `T${tier}: once each within 1.0 of the way back`);
assert.ok(back.every((c) => c.t - main.t >= 0.5), `T${tier}: after the 0.5 s dash`);
assert.ok(!sk2.some((c) => c.target === off), `T${tier}: 2 tiles off the line`);
assert.ok(back.every((c) => c.dmg.isAttack && c.type === 'phys'), `T${tier}: hits of the attack`);
const slows = h.hooksOf('statusApplied').filter((c) => c.source === u && c.status === 'sluggish');
assert.ok(slows.length && slows.every((c) => c.target === target && Math.abs(c.duration - 1) < 1e-9), `T${tier}: 停顿 1 s on the hit target only`);
// the hit's ATK scale: her ATK at that hit (base + the steals before it) × 225 %
const before = atkHits(h, u).filter((c) => c.t < main.t - 1e-9).length;
const sa = formOf(tier, elite).talents.find((t) => t.index === 0).bb['attack@steal_atk'];
approx(main.amount, (u.base.atk + Math.min(250, sa * before)) * sk.bb['attack@atk_scale'], `T${tier}: ${sk.bb['attack@atk_scale'] * 100} %`);
done(h);
}
});
test('S3 吞日 (20 s): 3 boomerangs per attack (3 hits of 145 % / 160 %, flying together); once they are caught she strikes at most 3 enemies within one tile for 130 % / 140 % and 停顿 1 s (no attack)', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S3);
const { h, u } = field({ tier, elite, skill: 2 });
assert.deepEqual([u.skill.rule, u.skill.spCost, u.skill.initSp, sk.duration, sk.bb.cnt, sk.bb['attack@atk_scale'], sk.bb.atk_scale_aoe, sk.bb['attack@aoe.max_target'], sk.bb.sluggish],
['DEFAULT', elite ? 36 : 39, elite ? 17 : 14, 20, 3, elite ? 1.6 : 1.45, elite ? 1.4 : 1.3, 3, 1], `T${tier}`);
const target = h.spawn('enemy_dummy', { pos: [10, 6] });
const around = [[9, 3], [11, 3], [9, 4], [11, 4]].map((pos) => h.spawn('enemy_dummy', { pos }));
h.b.addBuff(target, { key: 'test:taunt', mods: { taunt: 5 } }); // her target: the far one
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 4), `T${tier}: cast`);
approx(u.skill.timeLeft, 20, `T${tier}: 20 s`, 0.1);
h.run(2.5);
const sk3 = atkHits(h, u).filter((c) => c.dmg.isSkill);
const id = sk3[0].dmg.attackId;
const three = sk3.filter((c) => c.dmg.attackId === id);
assert.equal(three.length, 3, `T${tier}: 3 boomerangs`);
assert.ok(three.every((c) => c.target === target && c.t === three[0].t), `T${tier}: together, at the target`);
const caught = h.hooksOf('boomerangCaught').find((c) => c.unit === u && c.attackId === id);
assert.ok(caught?.isSkill, `T${tier}: caught`);
const blast = hitsBy(h, u).filter((c) => c.dmg.tags?.includes('narant:blast') && Math.abs(c.t - caught.t) < 1e-9);
assert.equal(blast.length, 3, `T${tier}: at most 3 of the 4 around her`);
assert.ok(blast.every((c) => around.includes(c.target) && !c.dmg.isAttack && c.dmg.isSkill && c.type === 'phys'), `T${tier}: skill hits within one tile`);
const slows = h.hooksOf('statusApplied').filter((c) => c.source === u && c.status === 'sluggish' && Math.abs(c.t - caught.t) < 1e-9);
assert.equal(slows.length, 3, `T${tier}: 停顿 on each`);
for (const c of slows) approx(c.duration, 1, '1 s');
done(h);
}
});
test('T1 “我见,我得”: every landing attack hit steals 25 ATK / 20 DEF — the enemy loses, she gains, each capped at 250 / 200 (LPS-X stage 3: 300 / 240, twice within one tile); two of her: the larger loss, not the sum; a dodged hit steals nothing; all given back when she leaves', () => {
for (const f of [[5, false, null], [6, true, null], [5, true, LPSX], [6, true, LPSX], [6, true, LPSY]]) {
const [tier, elite, mod] = f;
const t0 = formOf(tier, elite).talents.find((t) => t.index === 0).bb;
const tc = mod === LPSX && tier === 6 ? modOf(6, LPSX).talentChanges.find((t) => t.talentIndex === 0) : null;
const capA = tc?.bb['attack@steal_atk_max'] ?? t0['attack@steal_atk_max'], capD = tc?.bb['attack@steal_def_max'] ?? t0['attack@steal_def_max'];
assert.deepEqual([t0['attack@steal_atk'], t0['attack@steal_def'], capA, capD], [25, 20, ...(tc ? [300, 240] : [250, 200])], label(f));
const twice = !!tc;
if (twice) assert.match(tc.desc, /攻击到周围8格的敌人时偷取触发2次/);
// one hit at two tiles, then one at one tile
{
const { h, u } = field({ tier, elite, mod, skill: 2 });
const e = h.spawn('enemy_brute', { pos: [10, 5] });
assert.ok(h.runUntil(() => atkHits(h, u).length === 1, 3));
assert.deepEqual(u.findBuff('talent:narant:loot')?.mods, { atkFlat: 25, defFlat: 20 }, `${label(f)}: she gains`);
assert.deepEqual(e.findBuff('narant:stolen')?.mods, { atkFlat: -25, defFlat: -20 }, `${label(f)}: the enemy loses`);
assert.deepEqual([e.s.atk, e.s.def], [375, 280], `${label(f)}: its stats`);
h.b.kill(e, null);
const near = h.spawn('enemy_brute', { pos: [10, 4] });
assert.ok(h.runUntil(() => atkHits(h, u).length === 2, 4));
const k = twice ? 2 : 1;
assert.deepEqual(near.findBuff('narant:stolen')?.mods, { atkFlat: -25 * k, defFlat: -20 * k }, `${label(f)}: within one tile ×${k}`);
assert.deepEqual(u.findBuff('talent:narant:loot')?.mods, { atkFlat: 25 * (1 + k), defFlat: 20 * (1 + k) }, `${label(f)}: hers`);
// the caps
h.run(40);
assert.deepEqual(u.findBuff('talent:narant:loot')?.mods, { atkFlat: capA, defFlat: capD }, `${label(f)}: her cap`);
assert.deepEqual(near.findBuff('narant:stolen')?.mods, { atkFlat: -capA, defFlat: -capD }, `${label(f)}: its cap`);
approx(u.s.atk, (u.base.atk + capA) * (1 + calmAtk(tier, elite, mod)), `${label(f)}: her ATK`);
// she leaves: nothing kept, everything given back
h.b.retreat(u);
h.step();
assert.equal(u.findBuff('talent:narant:loot'), null, `${label(f)}: her gains go`);
assert.equal(near.findBuff('narant:stolen'), null, `${label(f)}: its losses come back`);
assert.deepEqual([near.s.atk, near.s.def], [400, 300]);
done(h);
}
}
// two of her (a T5 and a T6 piece) hitting one enemy: the larger loss, never the sum; a dodged hit steals nothing
const { h, u } = field({ tier: 5, skill: 2, others: [{ uid: 2, diy: { slot: SLOT[6], charId: NARANT, skillIndex: 2 }, row: 12, col: 3 }] });
const v = h.unit(2);
const e = h.spawn('enemy_brute', { pos: [11, 5] });
h.run(8);
const n1 = atkHits(h, u).filter((c) => c.target === e).length, n2 = atkHits(h, v).filter((c) => c.target === e).length;
assert.ok(n1 >= 3 && n2 >= 3, `both hit it (${n1}, ${n2})`);
assert.deepEqual(e.findBuff('narant:stolen')?.mods.atkFlat, -Math.min(250, 25 * Math.max(n1, n2)), 'the larger loss');
const ghost = h.spawn('enemy_brute', { pos: [11, 4] });
h.b.kill(e, null);
h.b.addBuff(ghost, { key: 'test:dodge', mods: { dodgePhys: 1 } });
h.run(4);
assert.ok(h.hooksOf('damaged').every((c) => c.target !== ghost), 'every hit dodged');
assert.equal(ghost.findBuff('narant:stolen'), null, 'a dodged hit steals nothing');
done(h);
});
test('T2 婀娜虚影: enemies within one tile of her miss 20 % (LPS-Y stage 3: 30 %) of their physical / arts attacks — one roll per attack —, farther ones never; LPS-Y stage 3: ATK +15 % after 6 s without damage', () => {
for (const f of [[5, false, null], [6, true, LPSY], [5, true, LPSY]]) {
const [tier, elite, mod] = f;
const t1 = formOf(tier, elite).talents.find((t) => t.index === 1).bb;
const cut = mod === LPSY && tier === 6 ? -modOf(6, LPSY).talentChanges.find((t) => t.talentIndex === 1).bb.damage_hitrate_physical : -t1.damage_hitrate_physical;
assert.equal(cut, mod === LPSY && tier === 6 ? 0.3 : 0.2, label(f));
for (const [pos, inside] of [[[10, 4], true], [[11, 5], false]]) {
const { h, u } = field({ tier, elite, mod, skill: 2, seed: 13 });
h.b.removeBuff(u, 'talent:narant:dodge'); // her own 闪避 aside: every lost shot is a miss
h.b.addBuff(u, { key: 'test:tank', mods: { hpFlat: 1e9 } });
const shooter = h.spawn('enemy_shooter', { pos });
h.run(150);
const shots = h.hooksOf('attack').filter((c) => c.attacker === shooter).length;
const landed = h.hooksOf('damaged').filter((c) => c.source === shooter).length;
assert.ok(shots > 200, `${label(f)}: ${shots} shots`);
const rate = 1 - landed / shots;
if (inside) assert.ok(Math.abs(rate - cut) < 0.07, `${label(f)}: ${(rate * 100).toFixed(1)} % missed ≈ ${cut * 100} %`);
else assert.ok(landed >= shots - 1, `${label(f)}: two tiles away: no miss (${landed} / ${shots}, the last one may still fly)`);
done(h);
}
}
// the calm ATK
const { h, u } = field({ tier: 6, elite: true, mod: LPSY, skill: 2 });
assert.deepEqual(modOf(6, LPSY).talentChanges.find((t) => t.talentIndex === -1).bb, { atk: 0.15, interval: 6 });
h.step();
assert.deepEqual(u.findBuff('talent:narant:calm')?.mods, { atkPct: 0.15 }, 'unhurt since her deployment');
h.b.dealDamage(null, u, { amount: 10, type: 'true' });
h.step();
assert.equal(u.findBuff('talent:narant:calm'), null, 'hurt: gone');
h.run(5.5);
assert.equal(u.findBuff('talent:narant:calm'), null, 'not yet');
h.run(0.6);
assert.ok(u.findBuff('talent:narant:calm'), '6 s later: back');
h.b.loseHp(u, 10, {});
h.step();
assert.ok(u.findBuff('talent:narant:calm'), 'a 流失 is no damage');
done(h);
});
test('LPS-X “吹尽狂沙”: ×1.1 on her attack hits within one tile (stages 1 and 3), not beyond; none without it', () => {
for (const [tier, mod] of [[5, LPSX], [6, LPSX], [6, LPSY], [5, null]]) {
const near = mod === LPSX ? 1.1 : 1;
if (mod === LPSX) assert.deepEqual(modOf(tier, LPSX).traitOverride.bb, { atk_scale: 1.1 });
for (const [pos, d1] of [[[10, 4], true], [[10, 5], false], [[11, 4], true]]) {
const { h, u } = field({ tier, elite: true, mod, skill: 2 });
const e = h.spawn('enemy_dummy', { pos });
assert.ok(h.runUntil(() => atkHits(h, u).length === 1, 3));
approx(atkHits(h, u)[0].amount, u.base.atk * (1 + calmAtk(tier, true, mod)) * (d1 ? near : 1), `T${tier} ${mod}: ${pos}`);
assert.equal(atkHits(h, u)[0].target, e);
done(h);
}
}
});
test('LPS-Y “致我们的老大”: 1 SP every 5 boomerangs caught (an S3 throw carries 3), none while her skill runs; none without it', () => {
for (const [tier, mod] of [[5, LPSY], [6, LPSY], [6, LPSX]]) {
if (mod === LPSY) assert.deepEqual(modOf(tier, LPSY).traitOverride.bb, { come_back_cnt: 5, sp: 1 });
const { h, u } = field({ tier, elite: true, mod, skill: 1 }); // S2: attack SP — the time SP stays out of it
h.spawn('enemy_dummy', { pos: [10, 6] });
h.run(9);
const caught = h.hooksOf('boomerangCaught').filter((c) => c.unit === u && !c.isSkill).length;
const gains = h.hooksOf('spGain').filter((c) => c.unit === u && c.reason === 'module').length;
assert.ok(caught >= 5, `T${tier} ${mod}: ${caught} caught`);
assert.equal(gains, mod === LPSY ? Math.floor(caught / 5) : 0, `T${tier} ${mod}: ${gains} module SP`);
done(h);
}
// S3: each throw is 3 boomerangs; while S3 runs no SP is gained (the catches still count)
const { h, u } = field({ tier: 6, elite: true, mod: LPSY, skill: 2 });
h.spawn('enemy_dummy', { pos: [10, 6] });
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 4));
h.run(6);
const during = h.hooksOf('boomerangCaught').filter((c) => c.unit === u && c.isSkill).length;
assert.ok(during >= 3, `${during} S3 throws caught`);
assert.equal(u.mem.narantCaught, (3 * during) % 5, 'the S3 catches count 3 each');
assert.equal(h.hooksOf('spGain').filter((c) => c.unit === u && c.reason === 'module' && c.amount > 0 && u.skill.active).length, 0, 'no SP during S3');
done(h);
});
test('engine: a profile `boomerangOnward` takes the flight over after the first hit — ctx.hit / ctx.comeBack once — and a content error there still brings the boomerang back', () => {
const calls = [];
const kits = {
[NARANT]: () => ({
skills: Object.fromEntries([S1, S2, S3].map((id) => [id, { kind: 'duration' }])),
trait: {
boomerangOnward(ctx) {
calls.push(ctx.attackId);
if (calls.length === 2) throw new Error('kit bug');
ctx.battle.after(0.5, () => { assert.equal(typeof ctx.comeBack(ctx.x, ctx.y), 'object'); assert.equal(ctx.comeBack(ctx.x, ctx.y), null, 'once'); });
},
},
}),
};
const h = makeBattle({ defs: { enemies: ENEMIES }, timeLimit: 120, autoFinish: false, seed: 1, kits, verbose: false, hooks: ['boomerangCaught'],
units: [{ uid: 1, diy: { slot: SLOT[6], charId: NARANT, skillIndex: 2 }, elite: true, row: 10, col: 3 }] });
h.step();
const u = h.unit(1);
h.spawn('enemy_dummy', { pos: [10, 6] });
h.run(6);
assert.ok(calls.length >= 3, `${calls.length} flights taken over`);
assert.ok(h.hooksOf('boomerangCaught').length >= 2, 'caught every time (the error one too)');
assert.ok(h.b.errors.some((e) => /boomerang\.onward/.test(e.label ?? e.where ?? JSON.stringify(e))), 'the error is logged');
assert.equal(u.trait.boomerangsOut <= 1, true);
checkInvariants(h.b);
});
+343
View File
@@ -0,0 +1,343 @@
// test/content/op_poca.test.js — the 自选 operator kit of 早露 (char_197_poca, 6★ 攻城手; kit
// server/sim/content/kits/ops/op-poca.js), fielded the production way (a DIY slot + its `diy` pick, simdata getDiy) in every
// form: tiers 5 / 6, normal (E2 Lv1, skill rank 4, no module) and elite (E2 Lv60, rank 7) with no module, SIE-X
// 攻城器械训练装置 or SIE-Y “心” at stage 1 (tier 5) / 3 (tier 6). Every number is read back from data/backups.json (the form
// of that slot status); the fidelity checklist of kits/README.md item by item.
// Run: node --test test/content/op_poca.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
import { KITTED_CHARS, OPERATOR_KITS, KITS } from '../../server/sim/content/kits/index.js';
import { diyPool, validateDiyPicks } from '../../shared/diy.js';
const load = (f) => JSON.parse(readFileSync(new URL(`../../data/${f}.json`, import.meta.url), 'utf8'));
const CHESS = load('chess');
const BACKUPS = load('backups');
const POCA = 'char_197_poca';
const FORMS = BACKUPS.units[POCA].forms;
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
const SIEX = 'uniequip_002_poca', SIEY = 'uniequip_003_poca';
const S1 = 'skcom_atk_up[3]', S2 = 'skchr_poca_2', S3 = 'skchr_poca_3';
const formOf = (tier, elite) => FORMS[elite ? (tier === 5 ? '2/60/7/1' : '2/60/7/3') : '2/1/4/0'];
const skillOf = (tier, elite, id) => formOf(tier, elite).skills.find((s) => s.skillId === id);
const modOf = (tier, mod) => (mod ? formOf(tier, true).modules.find((m) => m.uniEquipId === mod) : null);
const approx = (a, b, msg, eps = 1e-6) => assert.ok(Math.abs(a - b) <= eps * Math.max(1, Math.abs(b)), `${msg}: ${a} vs ${b}`);
const dummy = (key, o = {}) => enemyRec({ key, hp: 1e9, speed: 0, mass: 0, ...o });
const ENEMIES = {
enemy_light: dummy('enemy_light', { mass: 1 }), enemy_mid: dummy('enemy_mid', { mass: 2 }), enemy_heavy: dummy('enemy_heavy', { mass: 3 }),
enemy_armor: dummy('enemy_armor', { mass: 3, def: 600 }), enemy_plate: dummy('enemy_plate', { mass: 2, def: 600 }),
enemy_fly: dummy('enemy_fly', { mass: 1, motion: 'FLY' }), enemy_hfly: dummy('enemy_hfly', { mass: 4, motion: 'FLY' }),
};
/** Every 自选 form: [tier, elite, module]. */
const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, SIEX, SIEY].map((m) => [t, true, m]))];
/** 学生楷模: she is a 【乌萨斯学生自治团】 operator herself — her ATK always carries the talent's +atk. */
const studentAtk = (tier, elite, mod) => (elite && mod === SIEY && tier === 6 ? 0.12 : 0.08);
/** A battle with 早露 as uid 1 at (row, col) facing RIGHT, plus `others`. */
function field({ tier = 5, elite = false, mod = null, skill = 0, row = 10, col = 3, others = [], seed = 5 } = {}) {
const h = makeBattle({
defs: { enemies: ENEMIES }, timeLimit: 600, autoFinish: false, seed,
flags: { dpPerSec: 0, dpMax: 999 }, hooks: ['damaged', 'skillStart', 'skillEnd', 'statusApplied'], captureNoisy: true,
units: [{ uid: 1, diy: { slot: SLOT[tier], charId: POCA, skillIndex: skill, uniEquipId: mod }, elite, row, col }, ...others],
});
h.step();
return { h, u: h.unit(1) };
}
const hitsBy = (h, u) => h.hooksOf('damaged').filter((c) => c.source === u);
const atkHits = (h, u) => hitsBy(h, u).filter((c) => c.dmg?.isAttack);
function done(h) {
checkInvariants(h.b);
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
}
const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`;
test('早露 in every 自选 form: her operator kit (all three skills authored), the form\'s stats + module attributes, 4-3 range without her tile, ranged physical arrows that hit air, heaviest first, block 1, no 特质', () => {
assert.equal(OPERATOR_KITS[POCA], KITS[POCA]);
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
for (const skill of [0, 1, 2]) {
const { h, u } = field({ tier, elite, mod, skill });
const form = formOf(tier, elite), m = elite ? modOf(tier, mod) : null;
assert.deepEqual([u.def.charId, u.def.diyFor, u.skill.id, !!u.kit.generic, u.kit.skillSource], [POCA, SLOT[tier], form.skills[skill].skillId, false, 'skills'], label(f));
assert.deepEqual([u.base.maxHp, u.base.atk, u.base.def], [form.stats.maxHp + (m?.attr.maxHp ?? 0), form.stats.atk + (m?.attr.atk ?? 0), form.stats.def + (m?.attr.def ?? 0)], `${label(f)}: stats`);
assert.deepEqual([u.s.blockCnt, u.profile.attack, u.profile.dmgType, u.profile.canHitFly, u.profile.groundOnly, u.profile.priority, u.base.bat],
[1, 'ranged', 'phys', true, false, 'heaviest', 2.4], `${label(f)}: 攻城手`);
assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: 4-3`);
assert.ok(!u.liveRangeGrid.some(([r, c]) => r === 0 && c === 0), `${label(f)}: PRTS 攻城手 "攻击范围不包含自身所在地块"`);
assert.deepEqual([u.def.bonds, u.def.raw.garrisonIds], [BACKUPS.diy.operators[POCA].bonds, []], `${label(f)}: bonds / 特质`);
assert.ok(!u.s.flags.liftoff && !u.s.flags.camou && !u.s.flags.stealth, `${label(f)}: ground enemies target her`);
done(h);
}
}
// the numbers of the forms (zh_CN): E2 Lv1 1351 / 856 / 100, E2 Lv60 1619 / 973 / 115; SIE-X +60 / +30 → +90 / +45,
// SIE-Y +70 / +20 → +100 / +30 ATK / DEF
assert.deepEqual([FORMS['2/1/4/0'].stats.maxHp, FORMS['2/1/4/0'].stats.atk, FORMS['2/60/7/1'].stats.maxHp, FORMS['2/60/7/1'].stats.atk], [1351, 856, 1619, 973]);
assert.deepEqual([modOf(5, SIEX).attr, modOf(6, SIEX).attr, modOf(5, SIEY).attr, modOf(6, SIEY).attr], [{ atk: 60, def: 30 }, { atk: 90, def: 45 }, { atk: 70, def: 20 }, { atk: 100, def: 30 }]);
});
test('a 自选 pick: 早露 is offered at tiers 5 and 6 (she has a kit) and a roster with her passes validateDiyPicks', () => {
const data = { chess: CHESS, backups: BACKUPS };
assert.ok(KITTED_CHARS.includes(POCA));
for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(POCA), `tier ${t}`);
assert.deepEqual(validateDiyPicks({ [SLOT[5]]: { charId: POCA, skillIndex: 2, uniEquipId: SIEY } }, { data, kitted: KITTED_CHARS }),
{ ok: true, picks: { [SLOT[5]]: { charId: POCA, skillIndex: 2, uniEquipId: SIEY } } });
});
test('trait 攻城手 "优先攻击重量最重的敌人": the heaviest enemy of her range first, wherever it stands; a lighter one only once it is heavier (失重 moves it down); flyers are targets too', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const { h, u } = field({ tier, elite, skill: 0 });
const light = h.spawn('enemy_light', { pos: [10, 5] }), heavy = h.spawn('enemy_heavy', { pos: [11, 7] });
const n0 = atkHits(h, u).length;
assert.ok(h.runUntil(() => atkHits(h, u).length > n0, 4), `T${tier}: an attack`);
assert.equal(atkHits(h, u).at(-1).target, heavy, `T${tier}: weight 3 before weight 1 (the nearer one)`);
h.b.addBuff(heavy, { key: 'test:weightless', mods: { massFlat: -3 } });
const n1 = atkHits(h, u).length;
assert.ok(h.runUntil(() => atkHits(h, u).length > n1, 4));
assert.equal(atkHits(h, u).at(-1).target, light, `T${tier}: 失重 to 0 — now the light one first`);
const fly = h.spawn('enemy_hfly', { pos: [9, 6] });
const n2 = atkHits(h, u).length;
assert.ok(h.runUntil(() => atkHits(h, u).length > n2, 4));
assert.equal(atkHits(h, u).at(-1).target, fly, `T${tier}: a weight-4 flyer first (可对空)`);
done(h);
}
});
test('S1 攻击力强化·γ型 (MANUAL, data DEFAULT): ATK +45 % / +60 % for 30 s, SP 37 / 35 from 5 / 10; cast with an enemy in her range at her attack', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S1);
const { h, u } = field({ tier, elite, skill: 0 });
assert.deepEqual([u.skill.rule, u.skill.kind, u.skill.spType, u.skill.spCost, u.skill.initSp, sk.duration, sk.bb.atk],
['DEFAULT', 'duration', 'time', elite ? 35 : 37, elite ? 10 : 5, 30, elite ? 0.6 : 0.45], `T${tier}`);
const st = studentAtk(tier, elite, null);
approx(u.s.atk, u.base.atk * (1 + st), `T${tier}: 学生楷模 only`);
u.skill.gainSp(999);
h.run(2);
assert.equal(u.skill.activations, 0, `T${tier}: no enemy, no cast`);
h.spawn('enemy_light', { pos: [10, 6] });
assert.ok(h.runUntil(() => u.skill.active, 4), `T${tier}: cast`);
approx(u.skill.timeLeft, 30, `T${tier}: 30 s`, 0.01);
approx(u.s.atk, u.base.atk * (1 + st + sk.bb.atk), `T${tier}: ATK +${sk.bb.atk * 100} %`);
h.runUntil(() => !u.skill.active, 31);
approx(u.s.atk, u.base.atk * (1 + st), `T${tier}: back`);
done(h);
}
});
test('S2 分裂射击 (60 s): ATK +45 % / +60 % and two targets per attack (attack@max_target 2) — the two heaviest; a lone enemy gets one arrow', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S2);
const { h, u } = field({ tier, elite, skill: 1 });
assert.deepEqual([u.skill.rule, sk.duration, sk.bb.atk, sk.bb['attack@max_target'], u.skill.spCost, u.skill.initSp], ['DEFAULT', 60, elite ? 0.6 : 0.45, 2, 80, elite ? 45 : 42], `T${tier}`);
const lone = h.spawn('enemy_light', { pos: [10, 6] });
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 4));
approx(u.skill.timeLeft, 60, `T${tier}: 60 s`, 0.05);
approx(u.s.atk, u.base.atk * (1 + studentAtk(tier, elite, null) + sk.bb.atk), `T${tier}: ATK`);
h.run(0.6);
const n0 = atkHits(h, u).length;
h.run(2.5);
const one = atkHits(h, u).slice(n0);
assert.ok(one.length >= 1 && one.every((c) => c.target === lone), `T${tier}: one target, one arrow per attack`);
const mid = h.spawn('enemy_mid', { pos: [11, 6] }), heavy = h.spawn('enemy_heavy', { pos: [9, 5] });
h.run(0.5);
const n1 = atkHits(h, u).length;
h.run(2.5);
const ids = atkHits(h, u).slice(n1).map((c) => c.dmg.attackId);
const first = atkHits(h, u).slice(n1).filter((c) => c.dmg.attackId === ids[0]);
assert.deepEqual(first.map((c) => c.target).sort((a, b) => a.id - b.id), [mid, heavy].sort((a, b) => a.id - b.id), `T${tier}: the two heaviest (weights 3, 2), not the light one`);
done(h);
}
});
test('S3 雪崩击 (6 / 7 s): harpoons link the 3 heaviest enemies of her range (a flyer too), 束缚 them, and strike each every second — 6 / 7 strikes at ATK +10 % (attacks), no other attack; the binds end with it', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S3);
const { h, u } = field({ tier, elite, skill: 2 });
assert.deepEqual([u.skill.rule, sk.duration, sk.bb.max_target, sk.bb.hit_interval, sk.bb.hit_duration, sk.bb.atk, u.skill.spCost, u.skill.initSp],
['DEFAULT', elite ? 7 : 6, 3, 1, elite ? 7 : 6, 0.1, elite ? 34 : 37, elite ? 12 : 11], `T${tier}`);
const light = h.spawn('enemy_light', { pos: [10, 5] }), mid = h.spawn('enemy_mid', { pos: [10, 6] });
const heavy = h.spawn('enemy_heavy', { pos: [11, 6] }), hfly = h.spawn('enemy_hfly', { pos: [9, 6] });
const outside = h.spawn('enemy_heavy', { pos: [10, 9] });
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 4), `T${tier}: cast`);
const t0 = h.b.time;
const linked = [mid, heavy, hfly];
for (const e of linked) assert.equal(e.findBuff('bind')?.source, u, `T${tier}: ${e.defId} bound`);
assert.equal(light.findBuff('bind'), null, `T${tier}: the 4th heaviest is not linked`);
assert.equal(outside.findBuff('bind'), null, `T${tier}: out of range`);
approx(u.s.atk, u.base.atk * (1 + studentAtk(tier, elite, null) + 0.1), `T${tier}: ATK +10 %`);
assert.ok(h.runUntil(() => !u.skill.active, 10), `T${tier}: ends`);
approx(h.b.time - t0, sk.duration, `T${tier}: ${sk.duration} s`, 0.02);
const hits = hitsBy(h, u);
for (const e of linked) {
const on = hits.filter((c) => c.target === e);
assert.equal(on.length, sk.duration, `T${tier}: ${on.length} strikes on ${e.defId}`);
assert.ok(on.every((c) => c.dmg.isAttack && c.dmg.isSkill && c.type === 'phys'), `T${tier}: physical skill attacks`);
on.forEach((c, i) => i && approx(c.t - on[i - 1].t, 1, `T${tier}: one a second`, 0.02));
}
assert.ok(!hits.some((c) => c.target === light || c.target === outside), `T${tier}: nobody else is hit`);
for (const e of linked) assert.equal(e.findBuff('bind'), null, `T${tier}: unbound at the end`);
// the strikes are instant along the links
assert.ok(h.eventsOf('atk').filter((ev) => ev[1] === u.id).some((ev) => ev[3] === 'beam'));
done(h);
}
});
test('S3 雪崩击 (PRTS 备注): a link breaks when its enemy is knocked out or vanishes, the skill ends at once when none is left; a stun / silence on her breaks every link', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
// all links lost ⇒ the skill ends early
{
const { h, u } = field({ tier, elite, skill: 2 });
const a = h.spawn('enemy_heavy', { pos: [10, 6] }), b = h.spawn('enemy_mid', { pos: [11, 6] });
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 4));
h.run(1.2);
h.b.kill(a, null);
h.step();
assert.ok(u.skill.active, `T${tier}: one link left`);
b.hidden = true; // 消失
h.step();
assert.ok(!u.skill.active, `T${tier}: no link left ⇒ ends at once`);
b.hidden = false;
assert.equal(b.findBuff('bind'), null, `T${tier}: its bind went with the link`);
done(h);
}
for (const status of ['stun', 'silence']) {
const { h, u } = field({ tier, elite, skill: 2 });
const a = h.spawn('enemy_heavy', { pos: [10, 6] });
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 4));
h.run(1.5);
h.b.applyStatus(u, status, { duration: 2, source: null });
h.step();
assert.ok(!u.skill.active, `T${tier}: ${status} breaks the links`);
assert.equal(a.findBuff('bind'), null, `T${tier}: ${status}: unbound`);
done(h);
}
}
});
test('T1 深入骨髓: her damage ignores 60 % of the DEF of an enemy of 重量等级 ≥ 3 (exact numbers), none below; the S3 strikes too', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const t0 = formOf(tier, elite).talents.find((t) => t.index === 0);
assert.deepEqual([t0.bb.value, t0.bb.def_penetrate], [3, 0.6], `T${tier}: data`);
for (const skill of [0, 2]) {
const { h, u } = field({ tier, elite, skill });
const armor = h.spawn('enemy_armor', { pos: [10, 6] }), plate = h.spawn('enemy_plate', { pos: [11, 6] });
if (skill === 2) { u.skill.gainSp(999); assert.ok(h.runUntil(() => u.skill.active, 4)); h.run(2.2); } else h.run(12);
const on = (e) => atkHits(h, u).filter((c) => c.target === e);
assert.ok(on(armor).length, `T${tier} S${skill + 1}: hit the weight-3 one`);
for (const c of on(armor)) approx(c.amount, u.s.atk - 600 * (1 - 0.6), `T${tier} S${skill + 1}: weight 3, DEF 600 × 40 %`);
if (skill === 2) {
assert.ok(on(plate).length, `T${tier}: S3 links the weight-2 one too`);
for (const c of on(plate)) approx(c.amount, u.s.atk - 600, `T${tier} S3: weight 2, full DEF`);
}
done(h);
}
}
});
test('T2 学生楷模: every 【乌萨斯学生自治团】 operator of her team — her and 古米, deployed or not — ATK +8 %, nobody else; SIE-Y stage 3: +12 % and +15 % per student in skill (≤ +45 %), re-read every 0.3 s', () => {
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
const others = [{ uid: 2, chessId: 'chess_char_1_10_a', row: 12, col: 3 }, { uid: 3, chessId: 'chess_char_1_02_a', row: 12, col: 6 }];
const { h, u } = field({ tier, elite, mod, skill: 0, others });
const gummy = h.unit(2), yak = h.unit(3);
const v = studentAtk(tier, elite, mod);
for (const a of [u, gummy]) assert.deepEqual(a.findBuff('talent:poca:students')?.mods, { atkPct: v }, `${label(f)}: ${a.def.charId}`);
assert.equal(yak.findBuff('talent:poca:students'), null, `${label(f)}: 角峰 is no student`);
h.b.retreat(u);
h.run(0.5);
assert.ok(gummy.findBuff('talent:poca:students'), `${label(f)}: 编入队伍时 — still there with her off the field`);
assert.ok(u.findBuff('talent:poca:students'), `${label(f)}: kept through her retreat`);
assert.equal(!!gummy.findBuff('talent:poca:studentSkills'), false, `${label(f)}: nobody in skill`);
done(h);
}
// SIE-Y stage 3: the extra +15 % per student operator on the field whose skill runs, at most +45 %
const t6 = formOf(6, true).modules.find((m) => m.uniEquipId === SIEY).talentChanges;
assert.deepEqual([t6.find((t) => t.talentIndex === 1).bb.atk, t6.find((t) => t.talentIndex === -1).bb], [0.12, { init_atk: 0.15, max_atk: 0.45 }]);
const others = [
{ uid: 2, chessId: 'chess_char_1_10_a', row: 12, col: 3, skillIndex: 1 }, { uid: 3, chessId: 'chess_char_1_10_b', row: 12, col: 5, skillIndex: 1 },
{ uid: 4, diy: { slot: 'chess_char_6_diy2_a', charId: 'char_1051_headb2', skillIndex: 0 }, row: 11, col: 8 },
{ uid: 5, chessId: 'chess_char_1_02_a', row: 12, col: 7 },
];
const { h, u } = field({ tier: 6, elite: true, mod: SIEY, skill: 0, others });
const team = [u, h.unit(2), h.unit(3), h.unit(4)];
assert.ok(team.every((a) => a && a.deployed), 'four students on the field');
const drive = (a) => a.findBuff('talent:poca:studentSkills')?.mods.atkPct ?? 0;
const cast = (a) => { a.skill.gainSp(999); a.skill.activate('test'); assert.ok(a.skill.active, `${a.def.charId} in skill`); };
cast(u);
h.step(); // at once: 每次开启技能时
for (const a of team) approx(drive(a), 0.15, `one in skill: ${a.def.charId}`);
assert.equal(drive(h.unit(5)), 0, '角峰 gets nothing');
approx(u.s.atk, u.base.atk * (1 + 0.12 + 0.15 + skillOf(6, true, S1).bb.atk), 'her ATK: +12 % +15 % (and her S1)');
cast(h.unit(2));
h.step();
approx(drive(h.unit(4)), 0.3, 'two in skill');
cast(h.unit(3));
cast(h.unit(4));
h.step();
approx(drive(u), 0.45, 'four in skill: capped at +45 %');
u.skill.end('test');
h.run(0.35); // the 0.3 s re-read
approx(drive(h.unit(2)), 0.45, 'three in skill');
for (const a of team.slice(1)) a.skill.end('test');
h.run(0.35);
assert.equal(drive(u), 0, 'nobody in skill');
done(h);
});
test('SIE-X 攻城器械训练装置: ×1.15 on her attacks against 重量等级 ≥ 3 (normal attacks, S3 strikes), nothing below; stage 3 深入骨髓 adds a 60 % ATK physical hit on such enemies (with the DEF ignore)', () => {
for (const [tier, mod] of [[5, null], [5, SIEX], [6, SIEX], [6, SIEY], [6, null]]) {
const xs = mod === SIEX ? 1.15 : 1;
const extra = mod === SIEX && tier === 6 ? 0.6 : 0;
if (mod === SIEX) assert.deepEqual(modOf(tier, SIEX).talentChanges.find((t) => t.hidden).bb, { atk_scale: 1.15, value: 3 }, `T${tier}: data`);
for (const skill of [0, 2]) {
const { h, u } = field({ tier, elite: true, mod, skill });
const heavy = h.spawn('enemy_heavy', { pos: [10, 5] }), mid = h.spawn('enemy_mid', { pos: [10, 6] });
if (skill === 2) { u.skill.gainSp(999); assert.ok(h.runUntil(() => u.skill.active, 4)); h.run(2.2); } else h.run(12);
const main = atkHits(h, u);
const onH = main.filter((c) => c.target === heavy), onM = main.filter((c) => c.target === mid);
// (SIE-Y's own distance factor: 2 / 3 tiles)
const far = (d) => (mod === SIEY ? 1 + 0.12 * (d - 1) / 3.5 : 1);
assert.ok(onH.length, `T${tier} ${mod} S${skill + 1}: hits the heavy one`);
for (const c of onH) approx(c.amount, u.s.atk * xs * far(2), `T${tier} ${mod} S${skill + 1}: ×${xs} on weight 3`);
for (const c of onM) approx(c.amount, u.s.atk * far(3), `T${tier} ${mod} S${skill + 1}: ×1 on weight 2`);
const ex = hitsBy(h, u).filter((c) => c.dmg.tags?.includes('poca:extra'));
if (extra) {
assert.equal(ex.length, onH.length, `T${tier}: one extra hit per attack hit on the heavy one`);
assert.ok(ex.every((c) => c.target === heavy && c.type === 'phys' && !c.dmg.isAttack), `T${tier}: physical, not an attack`);
for (const c of ex) approx(c.amount, u.s.atk * extra, `T${tier}: 60 % ATK`);
} else assert.equal(ex.length, 0, `T${tier} ${mod}: no extra hit`);
done(h);
}
}
// the extra hit gets 深入骨髓's 物理穿透 too: weight 3, DEF 600
const { h, u } = field({ tier: 6, elite: true, mod: SIEX, skill: 0 });
const armor = h.spawn('enemy_armor', { pos: [10, 6] });
h.run(6);
const ex = hitsBy(h, u).filter((c) => c.target === armor && c.dmg.tags?.includes('poca:extra'));
assert.ok(ex.length);
for (const c of ex) approx(c.amount, u.s.atk * 0.6 - 600 * 0.4, 'extra hit: DEF 600 × 40 %');
done(h);
});
test('SIE-Y “心”: her attack damage rises with the distance, linearly from 1 tile to 4.5 (at most +12 %), stages 1 and 3; nothing without it', () => {
for (const [tier, mod] of [[5, SIEY], [6, SIEY], [6, SIEX], [5, null]]) {
const tb = mod === SIEY ? modOf(tier, SIEY).traitOverride.bb : {};
if (mod === SIEY) assert.deepEqual(tb, { max_dist: 4.5, min_dist: 1, damage_scale: 0.12 }, `T${tier}: data`);
for (const [r, c] of [[10, 5], [10, 6], [10, 7], [11, 6], [12, 5]]) {
const { h, u } = field({ tier, elite: true, mod, skill: 0 });
const e = h.spawn('enemy_light', { pos: [r, c] });
h.run(3);
const hit = atkHits(h, u).find((x) => x.target === e);
assert.ok(hit, `T${tier} ${mod}: hit at ${r},${c}`);
const d = Math.hypot(c - 3, r - 10);
const want = mod === SIEY ? 1 + 0.12 * Math.max(0, Math.min(1, (d - 1) / 3.5)) : 1;
approx(hit.amount, u.s.atk * want, `T${tier} ${mod}: ${d.toFixed(2)} tiles ×${want.toFixed(4)}`);
done(h);
}
}
});
+379
View File
@@ -0,0 +1,379 @@
// test/content/op_saga.test.js — the 自选 operator kit of 嵯峨 (char_362_saga, 6★ 尖兵; kit
// server/sim/content/kits/ops/op-saga.js), fielded the production way (a DIY slot + its `diy` pick, simdata getDiy) in every
// form: tiers 5 / 6, normal (E2 Lv1, skill rank 4, no module) and elite (E2 Lv60, rank 7) with no module, SOL-Y 身向云泥 or
// SOL-X 何处寻驮? at stage 1 (tier 5) / 3 (tier 6). Every number is read back from data/backups.json; the fidelity
// checklist of kits/README.md item by item (劝善's 重伤 per PRTS 术语释义 重伤 / the talent and S2 备注).
// Run: node --test test/content/op_saga.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
import { KITTED_CHARS, OPERATOR_KITS, KITS } from '../../server/sim/content/kits/index.js';
import { diyPool, validateDiyPicks } from '../../shared/diy.js';
import { DYING, S2_CHECK, isDying } from '../../server/sim/content/kits/ops/op-saga.js';
const load = (f) => JSON.parse(readFileSync(new URL(`../../data/${f}.json`, import.meta.url), 'utf8'));
const CHESS = load('chess');
const BACKUPS = load('backups');
const SAGA = 'char_362_saga';
const FORMS = BACKUPS.units[SAGA].forms;
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
const SOLY = 'uniequip_002_saga', SOLX = 'uniequip_003_saga';
const S1 = 'skcom_charge_cost[3]', S2 = 'skchr_saga_2', S3 = 'skchr_saga_3';
const formOf = (tier, elite) => FORMS[elite ? (tier === 5 ? '2/60/7/1' : '2/60/7/3') : '2/1/4/0'];
const skillOf = (tier, elite, id) => formOf(tier, elite).skills.find((s) => s.skillId === id);
const modOf = (tier, mod) => (mod ? formOf(tier, true).modules.find((m) => m.uniEquipId === mod) : null);
const approx = (a, b, msg, eps = 1e-6) => assert.ok(Math.abs(a - b) <= eps * Math.max(1, Math.abs(b)), `${msg}: ${a} vs ${b}`);
const dummy = (key, o = {}) => enemyRec({ key, hp: 1e9, speed: 0, mass: 0, ...o });
const ENEMIES = {
enemy_dummy: dummy('enemy_dummy'), enemy_fly: dummy('enemy_fly', { motion: 'FLY' }), enemy_weak: dummy('enemy_weak', { hp: 100 }),
enemy_walk: enemyRec({ key: 'enemy_walk', hp: 1e9, speed: 0.6, mass: 0 }),
};
// a regenerating enemy (data/enemies.json has two: 200 HP/s for enemy_1061_zomshd)
ENEMIES.enemy_regen = { ...dummy('enemy_regen', { hp: 100 }), stats: { ...dummy('enemy_regen', { hp: 100 }).stats, hpRecoveryPerSec: 200 } };
const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, SOLY, SOLX].map((m) => [t, true, m]))];
const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`;
/** A battle with 嵯峨 as uid 1 at (row, col) facing RIGHT, plus `others`. */
function field({ tier = 5, elite = false, mod = null, skill = 0, row = 10, col = 5, others = [], seed = 5, dp = 0 } = {}) {
const h = makeBattle({
defs: { enemies: ENEMIES }, timeLimit: 900, autoFinish: false, seed,
flags: { dpPerSec: 0, dpMax: 999, dpInit: dp }, hooks: ['damaged', 'skillStart', 'skillEnd', 'kill', 'death', 'spGain', 'attack'], captureNoisy: true,
units: [{ uid: 1, diy: { slot: SLOT[tier], charId: SAGA, skillIndex: skill, uniEquipId: mod }, elite, row, col }, ...others],
});
h.step();
return { h, u: h.unit(1) };
}
const hitsBy = (h, u, from = 0) => h.hooksOf('damaged').slice(from).filter((c) => c.source === u && c.target?.side === 'enemy');
function done(h) {
checkInvariants(h.b);
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
}
test('嵯峨 in every 自选 form: her operator kit (all three skills authored), the form\'s stats + module attributes, 1-1, blocks 2, melee ground-only, no 特质', () => {
assert.equal(OPERATOR_KITS[SAGA], KITS[SAGA]);
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
for (const skill of [0, 1, 2]) {
const { h, u } = field({ tier, elite, mod, skill });
const form = formOf(tier, elite), m = elite ? modOf(tier, mod) : null;
assert.deepEqual([u.def.charId, u.def.diyFor, u.skill.id, !!u.kit.generic, u.kit.skillSource], [SAGA, SLOT[tier], form.skills[skill].skillId, false, 'skills'], label(f));
assert.deepEqual([u.base.maxHp, u.base.atk, u.base.def], [form.stats.maxHp + (m?.attr.maxHp ?? 0), form.stats.atk + (m?.attr.atk ?? 0), form.stats.def + (m?.attr.def ?? 0)], `${label(f)}: stats`);
assert.deepEqual([u.s.blockCnt, u.profile.attack, u.profile.dmgType, u.profile.canHitFly, u.base.bat, typeof u.profile.skipEnemy], [2, 'melee', 'phys', false, 1.05, 'function'], `${label(f)}: 尖兵`);
assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: 1-1`);
assert.deepEqual([u.def.bonds, u.def.raw.garrisonIds], [['emptyShip'], []], `${label(f)}: bonds / 特质`);
assert.ok(!u.s.flags.liftoff && !u.s.flags.camou && !u.s.flags.stealth, `${label(f)}: ground enemies target her`);
done(h);
}
}
// E2 Lv1 1609 / 434 / 297, E2 Lv60 2004 / 498 / 347; SOL-Y +75 / +45 / +15 → +125 / +55 / +20, SOL-X +50 / +28 → +70 / +44
assert.deepEqual([FORMS['2/1/4/0'].stats.maxHp, FORMS['2/1/4/0'].stats.atk, FORMS['2/60/7/1'].stats.maxHp, FORMS['2/60/7/1'].stats.atk], [1609, 434, 2004, 498]);
assert.deepEqual([modOf(5, SOLY).attr, modOf(6, SOLY).attr, modOf(5, SOLX).attr, modOf(6, SOLX).attr],
[{ maxHp: 75, atk: 45, def: 15 }, { maxHp: 125, atk: 55, def: 20 }, { atk: 50, def: 28 }, { atk: 70, def: 44 }]);
});
test('a 自选 pick: 嵯峨 is offered at tiers 5 and 6 (she has a kit) and a roster with her passes validateDiyPicks', () => {
const data = { chess: CHESS, backups: BACKUPS };
assert.ok(KITTED_CHARS.includes(SAGA));
for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(SAGA), `tier ${t}`);
assert.deepEqual(validateDiyPicks({ [SLOT[5]]: { charId: SAGA, skillIndex: 1, uniEquipId: SOLX } }, { data, kitted: KITTED_CHARS }),
{ ok: true, picks: { [SLOT[5]]: { charId: SAGA, skillIndex: 1, uniEquipId: SOLX } } });
});
test('melee, ground-only: no attack on a flyer in her range; a ground enemy there is attacked (physical, 100 % ATK)', () => {
const { h, u } = field({ tier: 5, skill: 0 });
const fly = h.spawn('enemy_fly', { pos: [10, 6] });
h.run(3);
assert.equal(hitsBy(h, u).filter((c) => c.target === fly).length, 0, 'flyer untouched');
const e = h.spawn('enemy_dummy', { pos: [10, 6] });
assert.ok(h.runUntil(() => hitsBy(h, u).some((c) => c.target === e), 3));
const c = hitsBy(h, u).find((x) => x.target === e);
assert.deepEqual([c.type, c.dmg.isAttack], ['phys', true]);
approx(c.amount, u.s.atk, '100 % ATK');
done(h);
});
test('S1 冲锋号令·γ型 (AUTO): +12 DP as soon as its SP is full — no enemy needed (SP_FULL); 41 / 38 SP from 12 / 14', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S1);
const { h, u } = field({ tier, elite, mod: elite ? SOLX : null, skill: 0 });
assert.deepEqual([u.skill.rule, u.skill.spCost, sk.initSp, sk.bb.cost], ['SP_FULL', elite ? 38 : 41, elite ? 14 : 12, 12], `T${tier}`);
h.b.players[0].dp = 0;
h.run(sk.spCost - sk.initSp - 0.5);
assert.equal(u.skill.activations, 0, `T${tier}: not before`);
assert.ok(h.runUntil(() => u.skill.activations === 1, 1), `T${tier}: at full SP, no enemy`);
approx(h.b.players[0].dp, 12, `T${tier}: +12 DP`);
done(h);
}
});
test('S2 除恶 (MANUAL, data SKILL_RANGE on x-6, 2 charges): +4 DP; the cross\'s ground enemies take 280 % / 320 % ATK physical — at most 6, flyers and other tiles untouched', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S2);
const { h, u } = field({ tier, elite, mod: elite ? SOLY : null, skill: 1 });
assert.deepEqual([u.skill.rule, u.skill.maxCharges, sk.rangeId, sk.bb.atk_scale, sk.bb.cost, sk.spCost, sk.initSp], ['SKILL_RANGE', 2, 'x-6', elite ? 3.2 : 2.8, 4, elite ? 16 : 18, elite ? 10 : 9], `T${tier}`);
assert.match(sk.desc, /最多6名地面敌人/);
const cross = [[12, 5], [11, 5], [10, 3], [10, 4], [10, 6], [10, 7], [9, 5]];
const ground = cross.map((pos) => h.spawn('enemy_dummy', { pos }));
const fly = h.spawn('enemy_fly', { pos: [8, 5] });
const diag = h.spawn('enemy_dummy', { pos: [11, 6] });
h.b.players[0].dp = 0;
const n0 = hitsBy(h, u).length;
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.activations === 1, 1), `T${tier}: cast (enemies in the cross)`);
const hit = hitsBy(h, u, n0).filter((c) => c.dmg.tags?.includes('saga:s2'));
assert.equal(hit.length, 6, `T${tier}: at most 6`);
assert.ok(hit.every((c) => ground.includes(c.target)), `T${tier}: ground enemies of the cross only`);
assert.equal(new Set(hit.map((c) => c.target)).size, 6, `T${tier}: six different`);
for (const c of hit) {
approx(c.amount, u.s.atk * sk.bb.atk_scale, `T${tier}: ${sk.bb.atk_scale * 100} % ATK`);
assert.deepEqual([c.type, c.dmg.isSkill, c.dmg.isAttack], ['phys', true, false], `T${tier}: a physical skill damage`);
}
assert.ok(!hit.some((c) => c.target === fly || c.target === diag), `T${tier}: flyer / diagonal untouched`);
approx(h.b.players[0].dp, 4, `T${tier}: +4 DP`);
assert.equal(u.skill.charges, 1, `T${tier}: one charge left`);
done(h);
}
});
test('S2 除恶: its trigger counts any enemy in the cross (a flyer too, 无视其不可选中) but hits ground ones only', () => {
const { h, u } = field({ tier: 6, elite: true, skill: 1 });
const fly = h.spawn('enemy_fly', { pos: [9, 5] });
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.activations === 1, 1), 'cast for a flyer alone in the cross');
assert.equal(hitsBy(h, u).filter((c) => c.target === fly).length, 0, 'but not hit');
done(h);
});
test('S2 除恶: a 重伤 enemy is selected (可选择重伤单位) and every hit enemy still 重伤 0.7 s later is killed by her (killer 嵯峨, +2 SP each)', () => {
const { h, u } = field({ tier: 6, elite: true, skill: 1 });
const sk = skillOf(6, true, S2);
const weak = h.spawn('enemy_weak', { pos: [10, 7] }); // in the cross, outside her 1-1
const front = h.spawn('enemy_weak', { pos: [10, 6] }); // in her 1-1
assert.ok(h.runUntil(() => isDying(front), 3), 'her attack: 重伤');
assert.deepEqual([front.hp, weak.hp], [1, 100]);
// 除恶 comes by itself once a charge is ready (SKILL_RANGE: enemies in the cross)
assert.ok(h.runUntil(() => u.skill.activations === 1, sk.spCost - sk.initSp + 1), 'cast');
const tc = h.b.time;
assert.ok(hitsBy(h, u).some((c) => c.target === front && c.dmg.tags?.includes('saga:s2')), '可选择重伤单位');
assert.ok(isDying(weak) && weak.hp === 1, 'the weak one: 1 HP, 重伤');
h.run(S2_CHECK - 0.1);
assert.ok(weak.alive && front.alive, 'not before 0.7 s');
assert.ok(h.runUntil(() => !weak.alive && !front.alive, 0.2), 'both killed at 0.7 s');
const kills = h.hooksOf('kill').filter((c) => c.victim === weak || c.victim === front);
assert.ok(kills.length === 2 && kills.every((c) => c.killer === u), 'killer: 嵯峨');
assert.equal(u.stats.kills, 2);
const talentSp = h.hooksOf('spGain').filter((c) => c.unit === u && c.reason === 'talent' && c.t >= tc).reduce((n, c) => n + c.amount, 0);
approx(talentSp, 4, '2 × 2 SP');
done(h);
});
test('S3 怒目 (MANUAL, data ACTIVE_RANGE on 1-1 + 1): 20 s, ATK +70 % / +100 %, interval 1.05 + 0.5 s, range +1, every blocked enemy at once, +20 DP over the skill; all back after', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S3);
const { h, u } = field({ tier, elite, skill: 2, row: 9, col: 5 });
assert.deepEqual([u.skill.rule, sk.duration, sk.bb.atk, sk.bb.base_attack_time, sk.bb.ability_range_forward_extend, sk.bb.cost, sk.bb.interval, sk.bb.value, sk.bb['attack@hp_ratio']],
['ACTIVE_RANGE', 20, elite ? 1 : 0.7, 0.5, 1, 1, 1, 20, 0.5], `T${tier}`);
assert.deepEqual(sk.trigger.customRangeGrid, [[0, 0], [0, 1], [0, 2]], `T${tier}: her 1-1 + 1`);
u.skill.gainSp(999);
const far = h.spawn('enemy_dummy', { pos: [9, 7] }); // two tiles ahead: the running range only
assert.ok(h.runUntil(() => u.skill.active, 1), `T${tier}: cast for an enemy 2 tiles ahead`);
h.b.players[0].dp = 0;
assert.deepEqual(u.liveRangeGrid, [[0, 0], [0, 1], [0, 2]], `T${tier}: range +1`);
approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `T${tier}: ATK`);
approx(u.s.interval, 1.55, `T${tier}: +0.5 s`);
assert.ok(h.runUntil(() => hitsBy(h, u).some((c) => c.target === far), 3), `T${tier}: reaches 2 tiles ahead`);
h.b.kill(far, null);
// two walkers blocked: one attack hits both
h.spawn('enemy_walk', { routeIndex: 0 });
h.spawn('enemy_walk', { routeIndex: 0, pos: [9, 9] });
assert.ok(h.runUntil(() => u.blocking.length === 2, 30), `T${tier}: blocking two`);
const n0 = h.hooksOf('attack').length;
assert.ok(h.runUntil(() => h.hooksOf('attack').slice(n0).some((c) => c.attacker === u), 3));
const at = h.hooksOf('attack').slice(n0).find((c) => c.attacker === u);
assert.equal(at.targets.length, 2, `T${tier}: every blocked enemy`);
h.runUntil(() => !u.skill.active, 25);
approx(h.b.players[0].dp, 20, `T${tier}: +20 DP in all`, 1e-6);
assert.deepEqual(u.liveRangeGrid, formOf(tier, elite).rangeGrid, `T${tier}: 1-1 again`);
approx(u.s.interval, 1.05, `T${tier}: interval back`);
done(h);
}
});
test('S3 怒目: DP comes gradually (1 per second) and one more attack follows each attack on a target below half HP (checked before the hit), none above', () => {
const { h, u } = field({ tier: 6, elite: true, skill: 2 });
const low = h.spawn('enemy_dummy', { pos: [10, 6] });
low.hp = low.s.maxHp * 0.4;
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 1));
h.b.players[0].dp = 0;
h.run(5.5);
approx(h.b.players[0].dp, 5, '5 DP after 5.5 s', 1e-6);
// hits on the target, grouped by attack: an extra attack lands one tick after its attack
const groups = (hits) => {
const out = [];
for (const c of hits) { const g = out.at(-1); if (g && c.t - g.at(-1).t < 0.1) g.push(c); else out.push([c]); }
return out.map((g) => g.length);
};
const n0 = hitsBy(h, u).length;
h.run(6);
const low6 = groups(hitsBy(h, u, n0).filter((c) => c.target === low && c.dmg.isAttack));
assert.ok(low6.length >= 3, `${low6.length} attacks in 6 s (1.55 s apart)`);
assert.ok(low6.slice(0, -1).every((n) => n === 2), `every attack followed by one more: ${low6}`);
// above half: no extra attack
h.b.kill(low, null);
const high = h.spawn('enemy_dummy', { pos: [10, 6] });
high.hp = high.s.maxHp * 0.6;
const n1 = hitsBy(h, u).length;
h.run(5);
const high5 = groups(hitsBy(h, u, n1).filter((c) => c.target === high && c.dmg.isAttack));
assert.ok(high5.length >= 2 && high5.every((n) => n === 1), `one hit per attack: ${high5}`);
done(h);
});
test('T1 劝善: her damage leaves an enemy at 1 HP and 重伤 (move ×0.2, unblockable, 禁疗, no regeneration); she never attacks it; it dies 10 s later (无来源, no SP); a 重伤 enemy takes no second 重伤', () => {
for (const f of [[5, false, null], [6, true, SOLY]]) {
const { h, u } = field({ tier: f[0], elite: f[1], mod: f[2], skill: 2 });
const t0 = BACKUPS.units[SAGA].forms[f[1] ? '2/60/7/3' : '2/1/4/0'].talents[0].bb;
assert.deepEqual(t0, { move_speed: -0.8, sp: 2, interval: 10 });
const e = h.spawn('enemy_weak', { pos: [10, 6] });
assert.ok(h.runUntil(() => isDying(e), 3), `${label(f)}: 重伤`);
const at = h.b.time;
assert.deepEqual([e.alive, e.hp], [true, 1], `${label(f)}: 1 HP left`);
const d = e.findBuff(DYING);
assert.equal(d.source, u);
approx(d.mods.moveMul, 0.2, `${label(f)}: move −80 %`);
assert.equal(d.mods.hpRegen, -1e9, `${label(f)}: regeneration zeroed`);
assert.deepEqual(d.flags, { unblockable: true, healFree: true, noHeal: true }, `${label(f)}: 禁用近战 / 禁疗`);
approx(e.s.moveSpeed, e.base.moveSpeed * 0.2, `${label(f)}: move`);
assert.ok(e.s.hpRegen <= 0, `${label(f)}: no regeneration (a cancelling hpRegen)`);
assert.equal(h.b.heal(e, e, 50, { self: true }), 0, `${label(f)}: 禁疗`);
// she never attacks it again
const n0 = hitsBy(h, u).length, a0 = u.stats.attacks;
h.run(4);
assert.equal(hitsBy(h, u, n0).length, 0, `${label(f)}: no damage of hers`);
assert.equal(u.stats.attacks, a0, `${label(f)}: no attack`);
// no second 重伤
h.b.dealDamage(u, e, { amount: 1000, type: 'true' });
assert.equal(e.findBuff(DYING), d, `${label(f)}: the same 重伤`);
assert.equal(e.hp, 1, `${label(f)}: still not lethal`);
// 10 s: a forced, 无来源 death
const sp0 = u.skill.sp;
h.run(at + 10 - h.b.time - 0.1);
assert.ok(e.alive, `${label(f)}: alive before 10 s`);
assert.ok(h.runUntil(() => !e.alive, 0.2), `${label(f)}: dead at 10 s`);
const k = h.hooksOf('kill').find((c) => c.victim === e);
assert.deepEqual([k.killer, e.removeReason], [null, 'killed'], `${label(f)}: 无来源 kill`);
assert.equal(h.b.killed, 1, `${label(f)}: counted`);
assert.equal(h.hooksOf('spGain').filter((c) => c.reason === 'talent').length, 0, `${label(f)}: no SP for it`);
assert.ok(u.skill.sp >= sp0, `${label(f)}: (time SP only)`);
done(h);
}
});
test('T1 劝善: a 重伤 enemy regenerates nothing (生命回复速度归零) — one with 200 HP/s stays at 1 HP', () => {
const { h, u } = field({ tier: 5, skill: 2 });
const e = h.spawn('enemy_regen', { pos: [10, 6] });
assert.equal(e.s.hpRegen, 200, 'it regenerates');
assert.ok(h.runUntil(() => isDying(e), 3));
h.run(3);
assert.deepEqual([e.alive, e.hp], [true, 1], 'no HP back while 重伤');
assert.equal(hitsBy(h, u).filter((c) => c.target === e).length, 1, 'one hit of hers, then none');
done(h);
});
test('T1 劝善: whoever kills a 重伤 enemy gets 2 SP; 重伤 never kills while 消失 and kills at once when it comes back', () => {
const others = [{ uid: 2, chessId: 'chess_char_1_08_a', row: 12, col: 9 }];
const { h } = field({ tier: 5, skill: 2, others });
const texas = h.unit(2);
const e = h.spawn('enemy_weak', { pos: [10, 6] });
assert.ok(h.runUntil(() => isDying(e), 3));
const sp0 = texas.skill.sp;
h.b.dealDamage(texas, e, { amount: 1e6, type: 'true' });
assert.ok(!e.alive, 'another operator\'s damage kills it');
approx(texas.skill.sp - sp0, 2, '+2 SP to 德克萨斯');
// 消失
const g = h.spawn('enemy_weak', { pos: [10, 6] });
assert.ok(h.runUntil(() => isDying(g), 3));
h.step();
g.hidden = true;
h.run(12);
assert.ok(g.alive, 'no natural death while 消失');
g.hidden = false;
h.step();
assert.ok(!g.alive, 'dead the moment it appears');
assert.equal(h.hooksOf('kill').find((c) => c.victim === g).killer, null);
done(h);
});
test('T1 劝善 SOL-X stage 3: her damage on a target below half HP ×1.15 (stage 1 and the other forms: none)', () => {
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
const { h, u } = field({ tier, elite, mod, skill: 0 });
const x3 = elite && tier === 6 && mod === SOLX;
const e = h.spawn('enemy_dummy', { pos: [10, 6] });
const hitAt = (ratio) => {
e.hp = e.s.maxHp * ratio;
const n0 = hitsBy(h, u).length;
assert.ok(h.runUntil(() => hitsBy(h, u, n0).length > 0, 3), label(f));
return hitsBy(h, u, n0)[0].amount;
};
const lo = hitAt(0.4), hi = hitAt(0.6);
approx(lo / hi, x3 ? 1.15 : 1, `${label(f)}: below / above half`);
if (x3) assert.match(u.def.raw.talents.find((t) => t.index === 0).desc, /对生命值低于一半的单位造成的伤害提升15%/);
done(h);
}
});
test('T2 清明: once per deployment, the first time she drops below 40 %: 70 % physical dodge and 5 % max HP/s for 15 s; SOL-Y stage 3: 6.5 % (its blackboard) for 18 s', () => {
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
const { h, u } = field({ tier, elite, mod, skill: 0, row: 12, col: 9 });
const y3 = elite && tier === 6 && mod === SOLY;
const tb = u.def.raw.talents.find((t) => t.index === 1).bb;
assert.deepEqual(tb, { hp_ratio: 0.4, hp_recovery_per_sec_by_max_hp_ratio: y3 ? 0.065 : 0.05, prob: 0.7, duration: y3 ? 18 : 15 }, label(f));
h.b.dealDamage(null, u, { amount: u.s.maxHp * 0.55, type: 'true' });
assert.equal(u.findBuff('talent:saga:qingming'), null, `${label(f)}: at 45 % nothing`);
h.b.dealDamage(null, u, { amount: u.s.maxHp * 0.1, type: 'true' });
const b = u.findBuff('talent:saga:qingming');
assert.ok(b, `${label(f)}: below 40 %`);
assert.deepEqual(b.mods, { dodgePhys: 0.7, hpRegenRatio: y3 ? 0.065 : 0.05 }, `${label(f)}: mods`);
approx(b.duration, y3 ? 18 : 15, `${label(f)}: duration`);
approx(u.s.dodgePhys, 0.7, `${label(f)}: dodge`);
const hp0 = u.hp;
h.run(2.01);
approx(u.hp - hp0, 2 * (y3 ? 0.065 : 0.05) * u.s.maxHp, `${label(f)}: regeneration`, 0.02);
h.run(b.duration);
assert.equal(u.findBuff('talent:saga:qingming'), null, `${label(f)}: over`);
u.hp = u.s.maxHp;
h.b.dealDamage(null, u, { amount: u.s.maxHp * 0.7, type: 'true' });
assert.equal(u.findBuff('talent:saga:qingming'), null, `${label(f)}: only once`);
h.b.retreat(u);
h.b.redeploy(u, { free: true });
h.b.dealDamage(null, u, { amount: u.s.maxHp * 0.7, type: 'true' });
assert.ok(u.findBuff('talent:saga:qingming'), `${label(f)}: again after a new deployment`);
done(h);
}
});
test('SOL-X 何处寻驮? trait: ATK / DEF +8 % while she blocks an enemy (stages 1 and 3); none without it; SOL-Y\'s 首次部署时部署费用-4 changes nothing in battle', () => {
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
const { h, u } = field({ tier, elite, mod, skill: 0, row: 9, col: 5 });
const solx = elite && mod === SOLX;
if (solx) assert.deepEqual(u.def.raw.trait.bb, { atk: 0.08, def: 0.08 }, label(f));
h.run(1);
assert.equal(u.findBuff('trait:saga:block'), null, `${label(f)}: nothing blocked`);
h.spawn('enemy_walk', { routeIndex: 0 });
assert.ok(h.runUntil(() => u.blocking.length > 0, 30), `${label(f)}: the walker reaches her`);
h.step();
assert.equal(!!u.findBuff('trait:saga:block'), solx, `${label(f)}: blocking`);
if (solx) assert.deepEqual(u.findBuff('trait:saga:block').mods, { atkPct: 0.08, defPct: 0.08 });
if (elite && mod === SOLY) {
assert.deepEqual(u.def.raw.talents.filter((t) => t.index === -1).map((t) => t.bb.runtime_cost).filter((v) => v != null), [-4], `${label(f)}: the data carries it`);
assert.equal(u.base.cost, formOf(tier, true).stats.cost, `${label(f)}: no cost change`);
}
done(h);
}
});
+252
View File
@@ -0,0 +1,252 @@
// test/content/op_shining.test.js — the 自选 operator kit of 闪灵 (char_147_shining, 6★ 医师; kit
// server/sim/content/kits/ops/op-shining.js), fielded the production way (a DIY slot + its `diy` pick, simdata getDiy) in
// every form: tiers 5 / 6, normal (E2 Lv1, skill rank 4, no module) and elite (E2 Lv60, rank 7) with no module, PHY-Y
// 干枯剑鞘 or PHY-X “使徒” at stage 1 (tier 5) / 3 (tier 6). Every number is read back from data/backups.json (the form of
// that slot status); the fidelity checklist of kits/README.md item by item.
// Run: node --test test/content/op_shining.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { makeBattle, enemyRec, chessRec, checkInvariants } from '../helpers/battleHarness.js';
import { KITTED_CHARS, OPERATOR_KITS, KITS } from '../../server/sim/content/kits/index.js';
import { diyPool, validateDiyPicks } from '../../shared/diy.js';
const load = (f) => JSON.parse(readFileSync(new URL(`../../data/${f}.json`, import.meta.url), 'utf8'));
const CHESS = load('chess');
const BACKUPS = load('backups');
const SHINING = 'char_147_shining';
const FORMS = BACKUPS.units[SHINING].forms;
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
const PHYY = 'uniequip_002_shining', PHYX = 'uniequip_003_shining';
const S1 = 'skchr_shining_1', S2 = 'skchr_shining_2', S3 = 'skchr_shining_3';
const formOf = (tier, elite) => FORMS[elite ? (tier === 5 ? '2/60/7/1' : '2/60/7/3') : '2/1/4/0'];
const skillOf = (tier, elite, id) => formOf(tier, elite).skills.find((s) => s.skillId === id);
const modOf = (tier, mod) => (mod ? formOf(tier, true).modules.find((m) => m.uniEquipId === mod) : null);
const approx = (a, b, msg, eps = 1e-6) => assert.ok(Math.abs(a - b) <= eps * Math.max(1, Math.abs(b)), `${msg}: ${a} vs ${b}`);
const ENEMIES = { enemy_dummy: enemyRec({ key: 'enemy_dummy', hp: 1e9, speed: 0, mass: 0 }) };
/** Synthetic allies with no skill: a melee one (地面 tiles) and a ranged one (高台). */
const ALLIES = {
test_melee_a: chessRec({ id: 'test_melee_a', profession: 'WARRIOR', stats: { maxHp: 10000, def: 100, atk: 0 }, skill: null }),
test_ranged_a: chessRec({ id: 'test_ranged_a', profession: 'SNIPER', stats: { maxHp: 10000, def: 100, atk: 0 }, skill: null }),
};
const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, PHYY, PHYX].map((m) => [t, true, m]))];
const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`;
/**
* A battle with 闪灵 as uid 1 on the 高台 tile (11,2) facing RIGHT (her 3-3: rows 10–12, cols 2–5), a melee ally on the
* 地面 tile (10,4) (uid 2) and a ranged ally on the 高台 tile (10,2) (uid 3) — both in her range —, a melee ally outside it
* at (10,8) (uid 4).
*/
function field({ tier = 5, elite = false, mod = null, skill = 0, seed = 5, extra = [] } = {}) {
const h = makeBattle({
defs: { enemies: ENEMIES, chess: ALLIES }, timeLimit: 600, autoFinish: false, seed,
flags: { dpPerSec: 0, dpMax: 999 }, hooks: ['damaged', 'heal', 'skillStart', 'skillEnd'], captureNoisy: true,
units: [
{ uid: 1, diy: { slot: SLOT[tier], charId: SHINING, skillIndex: skill, uniEquipId: mod }, elite, row: 11, col: 2 },
{ uid: 2, chessId: 'test_melee_a', row: 10, col: 4 },
{ uid: 3, chessId: 'test_ranged_a', row: 10, col: 2 },
{ uid: 4, chessId: 'test_melee_a', row: 10, col: 8 },
...extra,
],
});
h.step();
return { h, u: h.unit(1), ground: h.unit(2), high: h.unit(3), out: h.unit(4) };
}
function done(h) {
checkInvariants(h.b);
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
}
const healsOn = (h, u, t, from = 0) => h.hooksOf('heal').slice(from).filter((c) => c.source === u && c.target === t && !c.opts?.regen);
test('闪灵 in every 自选 form: her operator kit (all three skills authored), the form\'s stats + module attributes, 3-3, one heal (医师), no 特质', () => {
assert.equal(OPERATOR_KITS[SHINING], KITS[SHINING]);
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
for (const skill of [0, 1, 2]) {
const { h, u } = field({ tier, elite, mod, skill });
const form = formOf(tier, elite), m = elite ? modOf(tier, mod) : null;
assert.deepEqual([u.def.charId, u.def.diyFor, u.skill.id, !!u.kit.generic, u.kit.skillSource], [SHINING, SLOT[tier], form.skills[skill].skillId, false, 'skills'], label(f));
assert.deepEqual([u.base.maxHp, u.base.atk, u.base.def, u.base.aspd], [form.stats.maxHp + (m?.attr.maxHp ?? 0), form.stats.atk + (m?.attr.atk ?? 0), form.stats.def + (m?.attr.def ?? 0), form.stats.aspd + (m?.attr.aspd ?? 0)], `${label(f)}: stats`);
// a heal attack selects injured allies only (ai.js acquireTargets): no enemy, so no anti-air question
assert.deepEqual([u.profile.dmgType, u.profile.heal?.mode, u.profile.attack, u.base.bat], ['heal', 'single', 'ranged', 2.85], `${label(f)}: 医师`);
assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: 3-3`);
assert.deepEqual([u.def.bonds, u.def.raw.garrisonIds], [['emptyShip'], []], `${label(f)}: bonds / 特质`);
assert.ok(!u.s.flags.liftoff && !u.s.flags.camou && !u.s.flags.stealth && !u.s.flags.untargetable, `${label(f)}: enemies target her`);
done(h);
}
}
// the numbers of the forms (zh_CN): E2 Lv1 1451 / 424 / 110, E2 Lv60 1558 / 494 / 129; PHY-Y +35 ATK +5 ASPD → +60 / +7,
// PHY-X +45 ATK +13 DEF → +63 / +20
assert.deepEqual([FORMS['2/1/4/0'].stats.maxHp, FORMS['2/1/4/0'].stats.atk, FORMS['2/60/7/1'].stats.maxHp, FORMS['2/60/7/1'].stats.atk], [1451, 424, 1558, 494]);
assert.deepEqual([modOf(5, PHYY).attr, modOf(6, PHYY).attr, modOf(5, PHYX).attr, modOf(6, PHYX).attr], [{ atk: 35, aspd: 5 }, { atk: 60, aspd: 7 }, { atk: 45, def: 13 }, { atk: 63, def: 20 }]);
});
test('a 自选 pick: 闪灵 is offered at tiers 5 and 6 (she has a kit) and a roster with her passes validateDiyPicks', () => {
const data = { chess: CHESS, backups: BACKUPS };
assert.ok(KITTED_CHARS.includes(SHINING));
for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(SHINING), `tier ${t}`);
assert.deepEqual(validateDiyPicks({ [SLOT[5]]: { charId: SHINING, skillIndex: 2, uniEquipId: PHYX } }, { data, kitted: KITTED_CHARS }),
{ ok: true, picks: { [SLOT[5]]: { charId: SHINING, skillIndex: 2, uniEquipId: PHYX } } });
});
test('S1 信条 (MANUAL, data DEFAULT): cast on an injured ally in range only; 20 s of ATK +58 % / +67 % and ASPD +10 / +20; 47 / 44 SP from 10 / 14', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S1);
const { h, u, ground } = field({ tier, elite, mod: elite ? PHYY : null, skill: 0 });
assert.deepEqual([u.skill.rule, u.skill.spCost, sk.initSp, sk.duration, sk.bb.atk, sk.bb.attack_speed],
['DEFAULT', elite ? 44 : 47, elite ? 14 : 10, 20, elite ? 0.67 : 0.58, elite ? 20 : 10], `T${tier}`);
const aspd0 = u.s.aspd;
u.skill.gainSp(999);
h.run(3);
assert.equal(u.skill.activations, 0, `T${tier}: nobody injured, no cast`);
ground.hp = 100;
assert.ok(h.runUntil(() => u.skill.active, 5), `T${tier}: cast as she heals`);
approx(u.skill.timeLeft, 20, `T${tier}: 20 s`, 0.01);
approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `T${tier}: ATK`);
approx(u.s.aspd, aspd0 + sk.bb.attack_speed, `T${tier}: ASPD`);
h.runUntil(() => !u.skill.active, 25);
approx(u.s.atk, u.base.atk, `T${tier}: ATK back`);
approx(u.s.aspd, aspd0, `T${tier}: ASPD back`);
done(h);
}
});
test('S2 自动掩护 (AUTO, data DEFAULT, 1 / 2 charges): the next heal gives its target a 屏障 of 28 % / 35 % ATK for 2 / 3 s (every damage type) and DEF +25 % / +35 % while it holds', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S2);
const { h, u, ground } = field({ tier, elite, mod: elite ? PHYX : null, skill: 1 });
assert.deepEqual([u.skill.rule, u.skill.maxCharges, u.skill.kind, sk.spCost, sk.bb.atk_scale, sk.bb.duration, sk.bb.def],
['DEFAULT', elite ? 2 : 1, elite ? 'charges' : 'instant', 7, elite ? 0.35 : 0.28, elite ? 3 : 2, elite ? 0.35 : 0.25], `T${tier}`);
u.skill.gainSp(999);
h.run(2);
assert.equal(u.skill.activations, 0, `T${tier}: nobody injured, no cast (it waits for a heal)`);
ground.hp = 100;
const def0 = ground.s.def;
assert.ok(h.runUntil(() => ground.findBuff('shining:barrier'), 5), `T${tier}: the barrier comes with the heal`);
assert.equal(healsOn(h, u, ground).length, 1, `T${tier}: one heal so far`);
const bar = ground.findBuff('shining:barrier');
approx(bar.shield, u.s.atk * sk.bb.atk_scale, `T${tier}: ${sk.bb.atk_scale * 100} % ATK`);
approx(bar.duration, sk.bb.duration, `T${tier}: ${sk.bb.duration} s`);
assert.equal(bar.shieldType, null, `T${tier}: absorbs every damage type`);
assert.deepEqual(ground.findBuff('shining:barrierDef')?.mods, { defPct: sk.bb.def }, `T${tier}: DEF bonus`);
approx(ground.s.def, def0 * (1 + sk.bb.def), `T${tier}: DEF`, 1e-6);
// the barrier takes physical and arts damage alike
const hp0 = ground.hp, sh0 = bar.shield;
h.b.dealDamage(null, ground, { amount: 10, type: 'arts', canDodge: false });
approx(ground.hp, hp0, `T${tier}: arts absorbed`);
approx(bar.shield, sh0 - 10, `T${tier}: shield −10`);
h.b.dealDamage(null, ground, { amount: 1e6, type: 'true' });
assert.equal(ground.findBuff('shining:barrier'), null, `T${tier}: broken`);
assert.equal(ground.findBuff('shining:barrierDef'), null, `T${tier}: the DEF bonus goes with it`);
done(h);
}
});
test('S2 自动掩护: barriers stack with their own timers, the older absorbs first, the DEF bonus does not stack; all gone after their time', () => {
const sk = skillOf(6, true, S2);
const { h, u, ground } = field({ tier: 6, elite: true, mod: PHYY, skill: 1 });
ground.hp = 100;
u.skill.gainSp(999);
assert.ok(h.runUntil(() => ground.buffs.filter((b) => b.key === 'shining:barrier').length === 1, 5));
ground.hp = 100; // still the most injured
assert.ok(h.runUntil(() => ground.buffs.filter((b) => b.key === 'shining:barrier').length === 2, 8), 'the second charge on the next heal');
const [a, b] = ground.buffs.filter((x) => x.key === 'shining:barrier');
assert.ok(a.seq < b.seq && a.timeLeft < b.timeLeft, 'own timers');
assert.equal(ground.buffs.filter((x) => x.key === 'shining:barrierDef').length, 1, 'one DEF bonus');
approx(ground.findBuff('shining:barrierDef').mods.defPct, sk.bb.def, 'not doubled');
approx(ground.s.shield, a.shield + b.shield, 'the absorption adds up');
const sa = a.shield, sb = b.shield;
h.b.dealDamage(null, ground, { amount: sa / 2, type: 'true' });
approx(a.shield, sa / 2, 'the older one first');
approx(b.shield, sb, 'the newer one untouched');
h.run(sk.bb.duration + 0.1);
assert.equal(ground.findBuff('shining:barrier'), null, 'expired');
assert.equal(ground.findBuff('shining:barrierDef'), null, 'the DEF bonus with them');
done(h);
});
test('S3 教条力场 (MANUAL, data DEFAULT): 60 s, ATK +30 % / +40 %, every ally in her range DEF +45 % / +60 % (not outside it); gone after it ends', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S3);
const { h, u, ground, high, out } = field({ tier, elite, mod: elite ? PHYX : null, skill: 2 });
assert.deepEqual([u.skill.rule, sk.duration, sk.spCost, sk.initSp, sk.bb.atk, sk.bb.def], ['DEFAULT', 60, 120, 100, elite ? 0.4 : 0.3, elite ? 0.6 : 0.45], `T${tier}`);
u.skill.gainSp(999);
ground.hp = 100;
assert.ok(h.runUntil(() => u.skill.active, 5), `T${tier}: cast on the injured ally`);
h.run(0.3);
approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `T${tier}: ATK`);
for (const a of [ground, high, u]) assert.deepEqual(a.findBuff('skill:shining:def')?.mods, { defPct: sk.bb.def }, `T${tier}: ${a.defId} in range`);
assert.equal(out.findBuff('skill:shining:def'), null, `T${tier}: outside her range`);
u.skill.end('test');
h.run(0.3);
for (const a of [ground, high, u]) assert.equal(a.findBuff('skill:shining:def'), null, `T${tier}: gone once it ends`);
done(h);
}
});
test('T1 黑恶魔的庇护: allies in her range DEF +60 (herself too, not outside); PHY-Y stage 3: +100 and 地面 units +40 more (stage 1: +60 only)', () => {
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
const { h, u, ground, high, out } = field({ tier, elite, mod, skill: 0 });
h.run(0.3);
const y3 = elite && tier === 6 && mod === PHYY;
const d = y3 ? 100 : 60, low = y3 ? 40 : 0;
assert.deepEqual(ground.findBuff('talent:shining:def')?.mods, { defFlat: d + low }, `${label(f)}: 地面 ally`);
assert.deepEqual(high.findBuff('talent:shining:def')?.mods, { defFlat: d }, `${label(f)}: 高台 ally`);
assert.deepEqual(u.findBuff('talent:shining:def')?.mods, { defFlat: d }, `${label(f)}: herself (高台)`);
assert.equal(out.findBuff('talent:shining:def'), null, `${label(f)}: outside her range`);
approx(ground.s.def, 100 + d + low, `${label(f)}: DEF of the 地面 ally`);
if (y3) assert.match(u.def.raw.talents.find((t) => t.index === 0).desc, /防御力\+100,地面单位防御力额外\+40/);
h.b.retreat(u);
h.run(0.3);
assert.equal(ground.findBuff('talent:shining:def'), null, `${label(f)}: gone once she leaves`);
done(h);
}
});
test('T2 法典: ASPD +10; PHY-X stage 3: ASPD +15, ATK +25 % with 自动掩护 picked, SP +0.6/s with 教条力场 picked (stage 1: ASPD +10 only)', () => {
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
for (const skill of [0, 1, 2]) {
const { h, u } = field({ tier, elite, mod, skill });
const x3 = elite && tier === 6 && mod === PHYX;
approx(u.s.aspd, u.base.aspd + (x3 ? 15 : 10), `${label(f)} S${skill + 1}: ASPD`);
approx(u.s.atk, u.base.atk * (1 + (x3 && skill === 1 ? 0.25 : 0)), `${label(f)} S${skill + 1}: ATK`);
approx(u.s.spRecovery, 1 + (x3 && skill === 2 ? 0.6 : 0), `${label(f)} S${skill + 1}: SP/s`);
if (x3 && skill === 2) {
const sp0 = u.skill.sp;
h.run(5);
approx(u.skill.sp - sp0, 5 * 1.6, `${label(f)}: 1.6 SP/s`, 0.01);
}
done(h);
}
}
});
test('module traits: PHY-Y heals on 地面 units ×1.15 (not on 高台), PHY-X heals on allies below 50 % ×1.15 (stages 1 and 3); none without a module', () => {
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
const { h, u, ground, high } = field({ tier, elite, mod, skill: 0 });
const tb = u.def.raw.trait.bb;
const y = elite && mod === PHYY, x = elite && mod === PHYX;
if (y) assert.deepEqual(tb, { heal_scale: 1.15 }, label(f));
if (x) assert.deepEqual(tb, { heal_scale: 1.15, hp_ratio: 0.5 }, label(f));
const heal = (t, hp) => {
t.hp = hp;
const n0 = h.hooksOf('heal').length;
assert.ok(h.runUntil(() => healsOn(h, u, t, n0).length > 0, 6), `${label(f)}: a heal on ${t.defId}`);
return healsOn(h, u, t, n0)[0].amount;
};
// 地面 vs 高台, both below 50 %
approx(heal(ground, 100), u.s.atk * (y || x ? 1.15 : 1), `${label(f)}: 地面 ally at 1 %`);
ground.hp = ground.s.maxHp;
approx(heal(high, 100), u.s.atk * (x ? 1.15 : 1), `${label(f)}: 高台 ally at 1 %`);
high.hp = high.s.maxHp;
// above 50 %
approx(heal(ground, 6000), u.s.atk * (y ? 1.15 : 1), `${label(f)}: 地面 ally at 60 %`);
ground.hp = ground.s.maxHp;
approx(heal(high, 6000), u.s.atk, `${label(f)}: 高台 ally at 60 %`);
done(h);
}
});
+269
View File
@@ -0,0 +1,269 @@
// test/content/op_shwaz.test.js — the 自选 operator kit of 黑 (char_340_shwaz, 6★ 重射手; kit
// server/sim/content/kits/ops/op-shwaz.js), fielded the production way (a DIY slot + its `diy` pick, simdata getDiy) in
// every form: tiers 5 / 6, normal (E2 Lv1, skill rank 4, no module) and elite (E2 Lv60, rank 7) with no module, ARC-X
// “定制弩配件套装” or ARC-Y “老剃刀” at stage 1 (tier 5) / 3 (tier 6). Every number is read back from data/backups.json (the
// form of that slot status); the fidelity checklist of kits/README.md item by item.
// Run: node --test test/content/op_shwaz.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
import { KITTED_CHARS, OPERATOR_KITS, KITS } from '../../server/sim/content/kits/index.js';
import { diyPool, validateDiyPicks } from '../../shared/diy.js';
const load = (f) => JSON.parse(readFileSync(new URL(`../../data/${f}.json`, import.meta.url), 'utf8'));
const CHESS = load('chess');
const BACKUPS = load('backups');
const SHWAZ = 'char_340_shwaz';
const FORMS = BACKUPS.units[SHWAZ].forms;
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
const X = 'uniequip_002_shwaz', Y = 'uniequip_003_shwaz';
const S1 = 'skchr_shwaz_1', S2 = 'skchr_shwaz_2', S3 = 'skchr_shwaz_3';
const PROVE = 'chess_char_1_07_a', YAK = 'chess_char_1_02_a';
/** The unit form of a slot (tier, normal / elite). */
const formOf = (tier, elite) => FORMS[elite ? (tier === 5 ? '2/60/7/1' : '2/60/7/3') : '2/1/4/0'];
const skillOf = (tier, elite, id) => formOf(tier, elite).skills.find((s) => s.skillId === id);
const modOf = (tier, mod) => (mod ? formOf(tier, true).modules.find((m) => m.uniEquipId === mod) : null);
const approx = (a, b, msg, eps = 1e-6) => assert.ok(Math.abs(a - b) <= eps * Math.max(1, Math.abs(b)), `${msg}: ${a} vs ${b}`);
const dummy = (key, o = {}) => enemyRec({ key, hp: 1e9, speed: 0, mass: 0, ...o });
const ENEMIES = { enemy_dummy: dummy('enemy_dummy'), enemy_fly: dummy('enemy_fly', { motion: 'FLY' }) };
/** Every 自选 form: [tier, elite, module]. */
const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, X, Y].map((m) => [t, true, m]))];
/** 破甲箭头 of a form: the base talent, upgraded by ARC-Y's NAMED change only (stage 3: 170 % / −25 %). */
const pierce = (tier, elite, mod) => ({ ...formOf(tier, elite).talents[0].bb, ...(modOf(tier, mod)?.talentChanges ?? []).filter((t) => t.talentIndex === 0 && t.name).reduce((o, t) => ({ ...o, ...t.bb }), {}) });
/** A battle with 黑 as uid 1 at (row, col) facing RIGHT, plus `others`. */
function field({ tier = 5, elite = false, mod = null, skill = 0, row = 10, col = 5, others = [], seed = 5, players = null } = {}) {
const h = makeBattle({
defs: { enemies: ENEMIES }, timeLimit: 900, autoFinish: false, seed,
flags: { dpPerSec: 0, dpMax: 999 }, hooks: ['damaged', 'skillStart', 'skillEnd', 'statusApplied', 'dodge'], captureNoisy: true,
...(players ? { players } : { units: [{ uid: 1, diy: { slot: SLOT[tier], charId: SHWAZ, skillIndex: skill, uniEquipId: mod }, elite, row, col }, ...others] }),
});
h.step();
return { h, u: h.unit(1) };
}
const atkHits = (h, u) => h.hooksOf('damaged').filter((c) => c.source === u && c.dmg?.isAttack);
function done(h) {
checkInvariants(h.b);
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
}
const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`;
test('黑 in every 自选 form: her operator kit (all three skills authored), the form\'s stats + module attributes, 3-6, ranged physical, hits air, blocks 1, ground-targetable, data triggers', () => {
assert.equal(OPERATOR_KITS[SHWAZ], KITS[SHWAZ]);
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
for (const skill of [0, 1, 2]) {
const { h, u } = field({ tier, elite, mod, skill });
const form = formOf(tier, elite), m = elite ? modOf(tier, mod) : null;
assert.deepEqual([u.def.charId, u.def.diyFor, u.skill.id, !!u.kit.generic, u.kit.skillSource], [SHWAZ, SLOT[tier], form.skills[skill].skillId, false, 'skills'], label(f));
assert.deepEqual([u.base.maxHp, u.base.atk, u.base.def], [form.stats.maxHp + (m?.attr.maxHp ?? 0), form.stats.atk + (m?.attr.atk ?? 0), form.stats.def + (m?.attr.def ?? 0)], `${label(f)}: stats`);
// ARC-X “定制弩配件套装”: "再部署时间减少" — the attribute respawn_time −25 (70 → 45 s)
assert.equal(u.base.respawnTime, form.stats.respawnTime + (m?.attr.respawnTime ?? 0), `${label(f)}: redeploy time`);
assert.deepEqual([u.s.blockCnt, u.profile.attack, u.profile.canHitFly, u.profile.dmgType, u.base.bat], [1, 'ranged', true, 'phys', 1.6], `${label(f)}: 重射手`);
assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: 3-6`);
assert.ok(!u.s.flags.liftoff && !u.s.flags.camou && !u.s.flags.stealth, `${label(f)}: ground enemies target her`);
assert.equal(u.skill.rule, form.skills[skill].trigger.rule, `${label(f)}: the data's trigger`);
done(h);
}
}
// the data: all three DEFAULT (S3's 「攻击范围改为」 is an attack-range change, not a 技能范围 — tools/build-data.mjs
// ATTACK_RANGE_CHANGE); E2 Lv1 1393 / 646 / 164, Lv60 1685 / 775 / 204
assert.deepEqual(formOf(6, true).skills.map((s) => s.trigger.rule), ['DEFAULT', 'DEFAULT', 'DEFAULT']);
assert.deepEqual([FORMS['2/1/4/0'].stats.atk, FORMS['2/60/7/1'].stats.atk], [646, 775]);
assert.deepEqual([modOf(5, X).attr, modOf(6, X).attr, modOf(5, Y).attr, modOf(6, Y).attr],
[{ maxHp: 130, atk: 55, respawnTime: -25 }, { maxHp: 170, atk: 75, respawnTime: -25 }, { atk: 80, def: 10 }, { atk: 120, def: 16 }]);
});
test('a 自选 pick: 黑 is offered at tiers 5 and 6 (she has a kit) and a roster with her passes validateDiyPicks', () => {
const data = { chess: CHESS, backups: BACKUPS };
assert.ok(KITTED_CHARS.includes(SHWAZ));
for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(SHWAZ), `tier ${t}`);
assert.deepEqual(validateDiyPicks({ [SLOT[6]]: { charId: SHWAZ, skillIndex: 2, uniEquipId: Y } }, { data, kitted: KITTED_CHARS }),
{ ok: true, picks: { [SLOT[6]]: { charId: SHWAZ, skillIndex: 2, uniEquipId: Y } } });
});
test('S1 强弩 (AUTO, attack SP 4, data DEFAULT): every 5th attack ×175 % / 190 % ATK, 破甲箭头 then at 60 % / 70 % (20 % otherwise); air units are hit', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S1), t0 = pierce(tier, elite, null);
const { h, u } = field({ tier, elite, skill: 0, seed: 21 });
assert.deepEqual([u.skill.kind, u.skill.rule, u.skill.spCost, u.skill.spType, sk.bb.atk_scale, sk.bb['talent@prob']],
['instant', 'DEFAULT', 4, 'attack', elite ? 1.9 : 1.75, elite ? 0.7 : 0.6], `T${tier}`);
h.spawn('enemy_fly', { pos: [10, 6] }); // a flyer in front: she hits air units
h.run(800);
const hits = atkHits(h, u);
const sk1 = hits.filter((c) => c.dmg.isSkill), plain = hits.filter((c) => !c.dmg.isSkill);
assert.ok(Math.abs(sk1.length * 5 - hits.length) <= 5, `T${tier}: one S1 attack in 5 (${sk1.length} / ${hits.length})`);
const base = u.s.atk;
for (const c of sk1) assert.ok([base * sk.bb.atk_scale, base * sk.bb.atk_scale * t0.atk_scale].some((v) => Math.abs(c.amount - v) < 1e-6), `T${tier}: S1 hit ${c.amount}`);
for (const c of plain) assert.ok([base, base * t0.atk_scale].some((v) => Math.abs(c.amount - v) < 1e-6), `T${tier}: plain hit ${c.amount}`);
const rate = (list, scale) => list.filter((c) => c.amount > base * scale + 1e-6).length / list.length;
const r1 = rate(sk1, sk.bb.atk_scale), r0 = rate(plain, 1);
assert.ok(Math.abs(r1 - sk.bb['talent@prob']) < 0.12, `T${tier}: 破甲箭头 on S1 ${r1}`);
assert.ok(Math.abs(r0 - t0.prob) < 0.07, `T${tier}: 破甲箭头 otherwise ${r0}`);
assert.ok(hits.every((c) => c.type === 'phys' && c.target.motion === 'FLY'), `T${tier}: physical, on the flyer`);
done(h);
}
});
test('S2 暮眼锐瞳 (MANUAL, data DEFAULT): ATK +85 % / +100 % for 33 / 36 s, 破甲箭头 at 40 % / 45 % meanwhile; no cast without an enemy', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S2), t0 = pierce(tier, elite, null);
const { h, u } = field({ tier, elite, skill: 1, seed: 8 });
assert.deepEqual([u.skill.kind, u.skill.rule, sk.duration, sk.bb.atk, sk.bb['talent@prob']], ['duration', 'DEFAULT', elite ? 36 : 33, elite ? 1 : 0.85, elite ? 0.45 : 0.4], `T${tier}`);
u.skill.gainSp(999);
h.run(2);
assert.equal(u.skill.activations, 0, `T${tier}: no enemy, no cast`);
h.spawn('enemy_dummy', { pos: [10, 7] });
assert.ok(h.runUntil(() => u.skill.active, 3), `T${tier}: cast with an enemy in range`);
approx(u.skill.timeLeft, sk.duration, `T${tier}: ${sk.duration} s`, 0.01);
approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `T${tier}: ATK +${sk.bb.atk * 100} %`);
const n0 = atkHits(h, u).length;
u.skill.extend(600);
h.runUntil(() => !u.skill.active, 700);
const during = atkHits(h, u).slice(n0);
const atk = u.base.atk * (1 + sk.bb.atk);
assert.ok(during.length > 300 && during.every((c) => c.dmg.isSkill), `T${tier}: ${during.length} attacks, marked`);
const r = during.filter((c) => c.amount > atk + 1e-6).length / during.length;
assert.ok(Math.abs(r - sk.bb['talent@prob']) < 0.07, `T${tier}: 破甲箭头 ${r}`);
for (const c of during.slice(0, 20)) assert.ok([atk, atk * t0.atk_scale].some((v) => Math.abs(c.amount - v) < 1e-6), `T${tier}: ${c.amount}`);
approx(u.s.atk, u.base.atk, `T${tier}: ATK back`);
done(h);
}
});
test('S3 战术的终结: range 3-2 while it runs (and back), interval 1.6 + 0.4 s, ATK +120 % / +140 %, 破甲箭头 on every attack; the basic strategy (DEFAULT): an enemy she is about to attack casts it, then only the 3-2 line is attacked', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S3), t0 = pierce(tier, elite, null);
assert.deepEqual(sk.rangeGrid, [[0, 0], [0, 1], [0, 2], [0, 3]]);
const { h, u } = field({ tier, elite, skill: 2, seed: 4 });
assert.deepEqual([u.skill.kind, u.skill.rule, sk.duration, sk.bb.atk, sk.bb.base_attack_time, sk.bb['talent@prob']],
['duration', 'DEFAULT', elite ? 21 : 18, elite ? 1.4 : 1.2, 0.4, 1], `T${tier}`);
u.skill.gainSp(999);
const side = h.spawn('enemy_dummy', { pos: [11, 6] }); // in 3-6, not in 3-2
assert.ok(h.runUntil(() => u.skill.active, 3), `T${tier}: cast as she is about to attack the 3-6 enemy (DEFAULT)`);
const far = h.spawn('enemy_dummy', { pos: [10, 8] }); // 3 tiles ahead: 3-2, outside 3-6
assert.deepEqual(u.liveRangeGrid, sk.rangeGrid, `T${tier}: range 3-2 while it runs`);
approx(u.s.interval, 2.0, `T${tier}: interval +0.4 s`);
approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `T${tier}: ATK`);
const n0 = atkHits(h, u).length;
h.runUntil(() => !u.skill.active, 30);
const during = atkHits(h, u).slice(n0);
assert.ok(during.length >= 8 && during.every((c) => c.target === far), `T${tier}: only the 3-2 enemy, not the side one (${during.length})`);
for (const c of during) approx(c.amount, u.base.atk * (1 + sk.bb.atk) * t0.atk_scale, `T${tier}: every attack procs`);
const cuts = h.hooksOf('statusApplied').filter((c) => c.source === u && c.status === 'defDown' && c.target === far);
assert.ok(cuts.length >= during.length - 1, `T${tier}: DEF cut each time`);
assert.deepEqual(u.liveRangeGrid, formOf(tier, elite).rangeGrid, `T${tier}: back to 3-6`);
approx(u.s.interval, 1.6, `T${tier}: interval back`);
done(h);
}
});
test('T1 破甲箭头: a proc ×160 % (ARC-Y stage 3: 170 %) and, once it hit, DEF ×0.8 (×0.75) for 5 s (defDown); ARC-Y stage 1 keeps 160 % (its hidden trait part is not 破甲箭头); a dodged attack cuts nothing', () => {
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
const t0 = pierce(tier, elite, mod);
assert.deepEqual([t0.atk_scale, t0.def, t0.prob, t0.defdown_duration], tier === 6 && mod === Y ? [1.7, -0.25, 0.2, 5] : [1.6, -0.2, 0.2, 5], label(f));
const { h, u } = field({ tier, elite, mod, skill: 2, seed: 13 });
const e = h.spawn('enemy_dummy', { pos: [11, 6] }); // off her front line: no ARC-Y bonus
h.run(120);
const hits = atkHits(h, u).filter((c) => c.target === e);
const procs = hits.filter((c) => c.amount > u.s.atk + 1e-6);
assert.ok(procs.length > 5 && procs.length < hits.length * 0.4, `${label(f)}: ${procs.length} / ${hits.length}`);
for (const c of procs) approx(c.amount, u.s.atk * t0.atk_scale, `${label(f)}: ×${t0.atk_scale}`);
const cuts = h.hooksOf('statusApplied').filter((c) => c.source === u && c.status === 'defDown');
assert.equal(cuts.length, procs.length, `${label(f)}: one DEF cut per proc`);
for (const c of cuts) assert.deepEqual([c.value, c.duration], [-t0.def, t0.defdown_duration], label(f));
// a dodged attack: no hit, no cut
const d = h.spawn('enemy_dummy', { pos: [9, 6] });
h.b.addBuff(d, { key: 'test:dodge', mods: { dodgePhys: 1 } });
h.b.kill(e, null);
const c0 = cuts.length;
h.run(60);
assert.ok(h.hooksOf('dodge').filter((c) => c.source === u).length > 20, `${label(f)}: dodged`);
assert.equal(h.hooksOf('statusApplied').filter((c) => c.source === u && c.status === 'defDown').length, c0, `${label(f)}: nothing cut`);
done(h);
}
});
test('T2 交叉火力: with another 【狙击】 operator on the field every sniper (her too, a partner\'s too) ATK +8 % — not 角峰, not alone, not once she leaves; ARC-X stage 3 "携带": +13 % on her player\'s snipers for the whole battle', () => {
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
const squad = tier === 6 && mod === X;
const v = squad ? 0.13 : 0.08;
const t1 = { ...formOf(tier, elite).talents[1].bb, ...(modOf(tier, mod)?.talentChanges ?? []).filter((t) => t.talentIndex === 1).reduce((o, t) => ({ ...o, ...t.bb }), {}) };
assert.equal(t1.atk, v, label(f));
// alone: nothing
const solo = field({ tier, elite, mod, skill: 0 });
solo.h.run(1);
assert.equal(solo.u.findBuff('talent:shwaz:crossfire'), null, `${label(f)}: alone`);
done(solo.h);
const { h, u } = field({ tier, elite, mod, skill: 0, others: [{ uid: 2, chessId: PROVE, row: 12, col: 3 }, { uid: 3, chessId: YAK, row: 9, col: 3 }] });
h.run(1);
const prove = h.unit(2), yak = h.unit(3);
for (const a of [u, prove]) assert.deepEqual(a.findBuff('talent:shwaz:crossfire')?.mods, { atkPct: v }, `${label(f)}: ${a.def.name}`);
assert.equal(yak.findBuff('talent:shwaz:crossfire'), null, `${label(f)}: 角峰 (重装)`);
approx(u.s.atk, u.base.atk * (1 + v), `${label(f)}: her ATK`);
h.b.retreat(prove);
h.run(1);
assert.equal(!!u.findBuff('talent:shwaz:crossfire'), squad, `${label(f)}: 普罗旺斯 off the field (携带: still in the squad)`);
h.b.redeploy(prove);
h.run(1);
h.b.retreat(u);
h.run(1);
assert.equal(!!prove.findBuff('talent:shwaz:crossfire'), squad, `${label(f)}: 黑 off the field`);
done(h);
}
// a partner's sniper on a shared field: the field version counts it and buffs it; the squad version does not
const pair = (mod, tier) => field({ players: [
{ playerId: 'p1', seat: 0, side: 'L', colOffset: 0, units: [{ uid: 1, kind: 'chess', chessId: tier === 6 ? 'chess_char_6_diy1_b' : 'chess_char_5_diy1_b', diy: { charId: SHWAZ, skillIndex: 0, uniEquipId: mod }, row: 10, col: 5 }], bonds: {}, bandId: null, playerEffects: [] },
{ playerId: 'p2', seat: 1, side: 'L', colOffset: 0, units: [{ uid: 1, kind: 'chess', chessId: PROVE, row: 12, col: 3 }], bonds: {}, bandId: null, playerEffects: [] },
] });
for (const [mod, tier, want] of [[null, 6, 0.08], [X, 6, null]]) {
const { h } = pair(mod, tier);
h.run(1);
const mine = h.b.allyUnits.find((a) => a.ownerId === 'p1'), theirs = h.b.allyUnits.find((a) => a.ownerId === 'p2');
assert.equal(mine.findBuff('talent:shwaz:crossfire')?.mods.atkPct ?? null, want, `${mod ?? 'none'}: 黑`);
assert.equal(theirs.findBuff('talent:shwaz:crossfire')?.mods.atkPct ?? null, want, `${mod ?? 'none'}: the partner's 普罗旺斯`);
done(h);
}
});
test('ARC-Y 老剃刀 trait: an enemy on her straight line ahead takes ×105 % and cannot dodge (stages 1 and 3); off the line it dodges; ARC-X / no module change nothing; facing UP turns the line', () => {
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
const y = elite && mod === Y;
if (y) assert.deepEqual(formOf(tier, true).modules.find((m) => m.uniEquipId === Y).traitOverride.bb, { atk_scale: 1.05 }, label(f));
const { h, u } = field({ tier, elite, mod, skill: 0, seed: 3 });
u.skill.sp = 0; u.skill.charges = 0;
const front = h.spawn('enemy_dummy', { pos: [10, 7] });
const side = h.spawn('enemy_dummy', { pos: [11, 7] });
for (const e of [front, side]) h.b.addBuff(e, { key: 'test:dodge', mods: { dodgePhys: 1 } });
h.run(40);
const onFront = atkHits(h, u).filter((c) => c.target === front);
const dodged = h.hooksOf('dodge').filter((c) => c.source === u);
if (y) {
assert.ok(onFront.length > 10, `${label(f)}: the front enemy is hit (${onFront.length})`);
for (const c of onFront.filter((x) => !x.dmg.isSkill)) assert.ok([u.s.atk * 1.05, u.s.atk * 1.05 * pierce(tier, elite, mod).atk_scale].some((v) => Math.abs(c.amount - v) < 1e-6), `${label(f)}: ×1.05 (${c.amount})`);
h.b.kill(front, null);
h.run(10);
assert.ok(h.hooksOf('dodge').filter((c) => c.source === u && c.target === side).length > 3, `${label(f)}: off the line it dodges`);
} else {
assert.equal(onFront.length, 0, `${label(f)}: no bonus — every attack dodged`);
assert.ok(dodged.length > 10, label(f));
}
done(h);
}
// facing UP: the line is the column above her
const h = makeBattle({ defs: { enemies: ENEMIES }, timeLimit: 60, autoFinish: false, seed: 2, flags: { dpPerSec: 0 }, hooks: ['damaged'], captureNoisy: true,
units: [{ uid: 1, diy: { slot: 6, charId: SHWAZ, skillIndex: 0, uniEquipId: Y }, elite: true, row: 10, col: 5, dir: 'UP' }] });
h.step();
const u = h.unit(1);
const e = h.spawn('enemy_dummy', { pos: [12, 5] });
h.run(20);
const plain = h.hooksOf('damaged').filter((c) => c.source === u && c.target === e && !c.dmg.isSkill);
assert.ok(plain.length > 5);
for (const c of plain) assert.ok([1.05, 1.05 * pierce(6, true, Y).atk_scale].some((k) => Math.abs(c.amount - u.base.atk * k) < 1e-6), `UP: two tiles above her is her line (${c.amount})`);
done(h);
});
+343
View File
@@ -0,0 +1,343 @@
// test/content/op_typhon.test.js — the 自选 operator kit of 提丰 (char_2012_typhon, 6★ 攻城手; kit
// server/sim/content/kits/ops/op-typhon.js), fielded the production way (a DIY slot + its `diy` pick, simdata getDiy) in
// every form: tiers 5 / 6, normal (E2 Lv1, skill rank 4, no module) and elite (E2 Lv60, rank 7) with no module, SIE-X
// 自然的包容, SIE-Y 冰原的影子 or ISW-A 提丰特限证章 at stage 1 (tier 5) / 3 (tier 6). Every number is read back from
// data/backups.json (the form of that slot status); the fidelity checklist of kits/README.md item by item.
// Run: node --test test/content/op_typhon.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
import { KITTED_CHARS, OPERATOR_KITS, KITS } from '../../server/sim/content/kits/index.js';
import { diyPool, validateDiyPicks } from '../../shared/diy.js';
const load = (f) => JSON.parse(readFileSync(new URL(`../../data/${f}.json`, import.meta.url), 'utf8'));
const CHESS = load('chess');
const BACKUPS = load('backups');
const TYPHON = 'char_2012_typhon';
const FORMS = BACKUPS.units[TYPHON].forms;
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
const SIEX = 'uniequip_002_typhon', SIEY = 'uniequip_003_typhon', ISWA = 'uniequip_004_typhon';
const S1 = 'skcom_quickattack[3]', S2 = 'skchr_typhon_2', S3 = 'skchr_typhon_3';
const formOf = (tier, elite) => FORMS[elite ? (tier === 5 ? '2/60/7/1' : '2/60/7/3') : '2/1/4/0'];
const skillOf = (tier, elite, id) => formOf(tier, elite).skills.find((s) => s.skillId === id);
const modOf = (tier, mod) => (mod ? formOf(tier, true).modules.find((m) => m.uniEquipId === mod) : null);
const approx = (a, b, msg, eps = 1e-6) => assert.ok(Math.abs(a - b) <= eps * Math.max(1, Math.abs(b)), `${msg}: ${a} vs ${b}`);
const dummy = (key, o = {}) => enemyRec({ key, hp: 1e9, speed: 0, mass: 0, ...o });
const ENEMIES = {
enemy_light: dummy('enemy_light', { mass: 1 }), enemy_mid: dummy('enemy_mid', { mass: 2 }), enemy_heavy: dummy('enemy_heavy', { mass: 3 }),
enemy_plate: dummy('enemy_plate', { mass: 1, def: 600 }), enemy_fly: dummy('enemy_fly', { mass: 1, motion: 'FLY' }),
enemy_walk: enemyRec({ key: 'enemy_walk', hp: 1e9, speed: 0.8, mass: 3 }),
};
/** Every 自选 form: [tier, elite, module]. */
const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, SIEX, SIEY, ISWA].map((m) => [t, true, m]))];
/** A battle with 提丰 as uid 1 at (row, col) facing RIGHT, plus `others`. */
function field({ tier = 5, elite = false, mod = null, skill = 0, row = 10, col = 3, others = [], seed = 5 } = {}) {
const h = makeBattle({
defs: { enemies: ENEMIES }, timeLimit: 900, autoFinish: false, seed,
flags: { dpPerSec: 0, dpMax: 999 }, hooks: ['damaged', 'skillStart', 'skillEnd', 'statusApplied'], captureNoisy: true,
units: [{ uid: 1, diy: { slot: SLOT[tier], charId: TYPHON, skillIndex: skill, uniEquipId: mod }, elite, row, col }, ...others],
});
h.step();
return { h, u: h.unit(1) };
}
const hitsBy = (h, u) => h.hooksOf('damaged').filter((c) => c.source === u);
const atkHits = (h, u) => hitsBy(h, u).filter((c) => c.dmg?.isAttack);
const rainHits = (h, u) => hitsBy(h, u).filter((c) => c.dmg?.tags?.includes('typhon:rain'));
function done(h) {
checkInvariants(h.b);
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
}
const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`;
test('提丰 in every 自选 form: her operator kit (all three skills authored), the form\'s stats + module attributes (SIE-Y ASPD), 4-3 range, ranged physical arrows that hit air, heaviest first, no 特质', () => {
assert.equal(OPERATOR_KITS[TYPHON], KITS[TYPHON]);
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
for (const skill of [0, 1, 2]) {
const { h, u } = field({ tier, elite, mod, skill });
const form = formOf(tier, elite), m = elite ? modOf(tier, mod) : null;
assert.deepEqual([u.def.charId, u.def.diyFor, u.skill.id, !!u.kit.generic, u.kit.skillSource], [TYPHON, SLOT[tier], form.skills[skill].skillId, false, 'skills'], label(f));
assert.deepEqual([u.base.maxHp, u.base.atk, u.base.def, u.base.aspd],
[form.stats.maxHp + (m?.attr.maxHp ?? 0), form.stats.atk + (m?.attr.atk ?? 0), form.stats.def + (m?.attr.def ?? 0), 100 + (m?.attr.aspd ?? 0)], `${label(f)}: stats`);
assert.deepEqual([u.s.blockCnt, u.profile.attack, u.profile.dmgType, u.profile.canHitFly, u.profile.priority, u.base.bat], [1, 'ranged', 'phys', true, 'heaviest', 2.4], `${label(f)}: 攻城手`);
assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: 4-3`);
assert.deepEqual([u.def.bonds, u.def.raw.garrisonIds], [BACKUPS.diy.operators[TYPHON].bonds, []], `${label(f)}: bonds / 特质`);
assert.ok(!u.s.flags.liftoff && !u.s.flags.camou && !u.s.flags.stealth, `${label(f)}: ground enemies target her`);
done(h);
}
}
// E2 Lv1 1310 / 867 / 92, E2 Lv60 1570 / 985 / 106; SIE-X +120 HP / +60 ATK → +210 / +90, SIE-Y +70 ATK / +5 ASPD → +94 / +7,
// ISW-A +100 / +70 → +150 / +90
assert.deepEqual([FORMS['2/1/4/0'].stats.maxHp, FORMS['2/1/4/0'].stats.atk, FORMS['2/60/7/1'].stats.maxHp, FORMS['2/60/7/1'].stats.atk], [1310, 867, 1570, 985]);
assert.deepEqual([modOf(5, SIEX).attr, modOf(6, SIEX).attr, modOf(5, SIEY).attr, modOf(6, SIEY).attr, modOf(5, ISWA).attr, modOf(6, ISWA).attr],
[{ maxHp: 120, atk: 60 }, { maxHp: 210, atk: 90 }, { atk: 70, aspd: 5 }, { atk: 94, aspd: 7 }, { maxHp: 100, atk: 70 }, { maxHp: 150, atk: 90 }]);
});
test('a 自选 pick: 提丰 is offered at tiers 5 and 6 (she has a kit) and a roster with her passes validateDiyPicks', () => {
const data = { chess: CHESS, backups: BACKUPS };
assert.ok(KITTED_CHARS.includes(TYPHON));
for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(TYPHON), `tier ${t}`);
assert.deepEqual(validateDiyPicks({ [SLOT[6]]: { charId: TYPHON, skillIndex: 1, uniEquipId: ISWA } }, { data, kitted: KITTED_CHARS }),
{ ok: true, picks: { [SLOT[6]]: { charId: TYPHON, skillIndex: 1, uniEquipId: ISWA } } });
});
test('trait 攻城手: the heaviest enemy of her range first; a flyer is a target', () => {
const { h, u } = field({ tier: 6, elite: true });
const light = h.spawn('enemy_light', { pos: [10, 5] }), heavy = h.spawn('enemy_heavy', { pos: [9, 6] });
h.run(3);
assert.equal(atkHits(h, u)[0].target, heavy);
h.b.kill(heavy, null);
h.b.kill(light, null);
const fly = h.spawn('enemy_fly', { pos: [11, 5] });
h.run(3);
assert.ok(atkHits(h, u).some((c) => c.target === fly), '可对空');
done(h);
});
test('S1 迅捷打击·γ型 (MANUAL, data DEFAULT): ATK +27 % / +34 %, ASPD +25 / +35 for 35 s; SP 42 / 39 from 5 / 10', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S1);
const { h, u } = field({ tier, elite, skill: 0 });
assert.deepEqual([u.skill.rule, u.skill.kind, u.skill.spCost, u.skill.initSp, sk.duration, sk.bb.atk, sk.bb.attack_speed],
['DEFAULT', 'duration', elite ? 39 : 42, elite ? 10 : 5, 35, elite ? 0.34 : 0.27, elite ? 35 : 25], `T${tier}`);
u.skill.gainSp(999);
h.run(2);
assert.equal(u.skill.activations, 0, `T${tier}: no enemy, no cast`);
h.spawn('enemy_light', { pos: [10, 6] });
assert.ok(h.runUntil(() => u.skill.active, 4));
approx(u.skill.timeLeft, 35, `T${tier}: 35 s`, 0.01);
approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `T${tier}: ATK`);
assert.equal(u.s.aspd, 100 + sk.bb.attack_speed, `T${tier}: ASPD`);
h.runUntil(() => !u.skill.active, 36);
assert.deepEqual([u.s.atk, u.s.aspd], [u.base.atk, 100], `T${tier}: back`);
done(h);
}
});
test('S2 冰原秩序: ATK +26 % / +35 %, two arrows per attack — two enemies when there are two, both at a lone one — each with 40 % to stun 1 s; 20 s on the first cast of a deployment, then 持续时间无限 until she leaves (a redeploy counts again)', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S2);
const { h, u } = field({ tier, elite, skill: 1, seed: 9 });
assert.deepEqual([u.skill.rule, u.skill.kind, sk.duration, sk.bb.first_duration, sk.bb.atk, sk.bb['attack@prob'], sk.bb['attack@stun'], u.skill.spCost, u.skill.initSp],
['DEFAULT', 'toggle', 20, 20, elite ? 0.35 : 0.26, 0.4, 1, elite ? 51 : 53, elite ? 40 : 37], `T${tier}`);
const lone = h.spawn('enemy_light', { pos: [10, 6] });
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 4), `T${tier}: cast`);
const t0 = h.b.time;
approx(u.skill.timeLeft, 20, `T${tier}: first cast 20 s`, 0.05);
approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `T${tier}: ATK`);
h.run(5);
const s2 = atkHits(h, u).filter((c) => c.dmg.isSkill);
const byAttack = Map.groupBy(s2, (c) => c.dmg.attackId);
assert.ok(byAttack.size >= 2, `T${tier}: attacks`);
for (const list of byAttack.values()) assert.deepEqual(list.map((c) => c.target), [lone, lone], `T${tier}: both arrows at the lone enemy`);
const other = h.spawn('enemy_mid', { pos: [11, 6] });
h.run(5);
const later = atkHits(h, u).filter((c) => c.dmg.isSkill && c.t > h.b.time - 4.5);
for (const list of Map.groupBy(later, (c) => c.dmg.attackId).values()) {
assert.deepEqual(list.map((c) => c.target).sort((a, b) => a.id - b.id), [lone, other].sort((a, b) => a.id - b.id), `T${tier}: two different enemies`);
}
assert.ok(h.runUntil(() => !u.skill.active, 15), `T${tier}: the first cast ends`);
approx(h.b.time - t0, 20, `T${tier}: after 20 s`, 0.05);
// the second cast never ends
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 4), `T${tier}: second cast`);
assert.equal(u.skill.timeLeft, Infinity, `T${tier}: 持续时间无限`);
h.run(120);
assert.ok(u.skill.active, `T${tier}: still on after 120 s`);
// the stun: 40 % of the arrows, 1 s
const all = atkHits(h, u).filter((c) => c.dmg.isSkill);
const stuns = h.hooksOf('statusApplied').filter((c) => c.source === u && c.status === 'stun');
assert.ok(all.length > 100, `T${tier}: ${all.length} arrows`);
assert.ok(stuns.length / all.length > 0.32 && stuns.length / all.length < 0.48, `T${tier}: ${stuns.length} / ${all.length} stuns ≈ 40 %`);
for (const c of stuns) approx(c.duration, 1, `T${tier}: 1 s`);
// a redeploy counts its casts again
h.b.retreat(u);
h.b.redeploy(u);
assert.ok(!u.skill.active && u.deployed, `T${tier}: back on the field, skill off`);
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 4), `T${tier}: cast after the redeploy`);
approx(u.skill.timeLeft, 20, `T${tier}: the first cast of this deployment — 20 s again`, 0.05);
done(h);
}
});
test('S3 “永恒狩猎”: 7 / 8 rounds, attack interval 2.4 + 3.1 s; the mark on the heaviest enemy of her own range; range 全场 but attacks only with an enemy in the mark area (radius 1.3); each round 4 arrows 0.1 s apart on random enemies of the area, 145 % / 160 % ATK physical skill hits (no attack), 0.4 s stun once they land', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S3);
const { h, u } = field({ tier, elite, skill: 2, seed: 7 });
assert.deepEqual([u.skill.rule, u.skill.kind, sk.durationType, sk.bb['attack@s3_trigger_time'], sk.bb.base_attack_time, sk.bb['attack@s3_atk_scale'], sk.bb['attack@s3_max_hit_num'], sk.bb['attack@s3_stun'], u.skill.spCost, u.skill.initSp],
['DEFAULT', 'ammo', 'AMMO', elite ? 8 : 7, 3.1, elite ? 1.6 : 1.45, 4, 0.4, elite ? 43 : 47, 25], `T${tier}`);
const marked = h.spawn('enemy_heavy', { pos: [10, 6] }), near = h.spawn('enemy_light', { pos: [11, 6] });
const away = h.spawn('enemy_mid', { pos: [9, 5] }); // in her range, 1.4+ tiles from the mark: never hit
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 4), `T${tier}: cast`);
const t0 = h.b.time;
assert.equal(u.mem.typhonMark.target, marked, `T${tier}: the heaviest of her range is marked`);
assert.ok(u.liveRangeGrid.length >= 400, `T${tier}: the card shows 全场`);
approx(u.s.interval, 2.4 + 3.1, `T${tier}: interval 5.5 s`);
assert.ok(h.runUntil(() => !u.skill.active, 60), `T${tier}: ammo spent`);
h.run(0.5);
const rain = rainHits(h, u);
assert.equal(atkHits(h, u).filter((c) => c.t >= t0 - 0.05).length, 0, `T${tier}: the round itself deals nothing`);
assert.equal(rain.length, sk.bb['attack@s3_trigger_time'] * 4, `T${tier}: ${rain.length} arrows (rounds × 4)`);
assert.ok(rain.every((c) => c.target === marked || c.target === near), `T${tier}: the area only`);
assert.ok(rain.some((c) => c.target === near) && rain.some((c) => c.target === marked), `T${tier}: random among the area`);
assert.ok(rain.every((c) => c.type === 'phys' && c.dmg.isSkill && !c.dmg.isAttack), `T${tier}: physical skill hits, no attack`);
const rounds = Map.groupBy(rain, (c) => Math.round((c.t - t0) / 5.5));
assert.equal(rounds.size, sk.bb['attack@s3_trigger_time'], `T${tier}: one round per bullet`);
for (const list of rounds.values()) list.forEach((c, i) => i && approx(c.t - list[i - 1].t, 0.1, `T${tier}: 0.1 s apart`, 0.05));
// amounts: ATK × s3_atk_scale (DEF 0), the first hit on each enemy ×1.6 (重如沼泥)
const firsts = new Set([marked, near].map((e) => rain.find((c) => c.target === e)));
for (const c of rain) approx(c.amount, u.s.atk * sk.bb['attack@s3_atk_scale'] * (firsts.has(c) ? 1.6 : 1), `T${tier}: s3_atk_scale`);
const stuns = h.hooksOf('statusApplied').filter((c) => c.source === u && c.status === 'stun');
assert.equal(stuns.length, rain.length, `T${tier}: a stun per landed arrow`);
for (const c of stuns) approx(c.duration, 0.4, `T${tier}: 0.4 s`);
assert.ok(!hitsBy(h, u).some((c) => c.target === away), `T${tier}: outside the mark area`);
assert.ok(u.liveRangeGrid.length < 20, `T${tier}: own range back`);
done(h);
}
});
test('S3 “永恒狩猎”: no enemy in the mark area ⇒ no attack and no bullet spent; the mark follows its enemy and stays where it left the field', () => {
const { h, u } = field({ tier: 6, elite: true, skill: 2, seed: 3 });
const walker = h.spawn('enemy_walk', { routeIndex: 0 });
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 30), 'cast once the walker is in her range');
assert.equal(u.mem.typhonMark.target, walker);
const x0 = u.mem.typhonMark.x;
h.run(1);
assert.notEqual(u.mem.typhonMark.x, x0, 'the mark moves with its enemy');
const ammo = u.skill.ammoLeft;
h.b.kill(walker, null);
const spot = [u.mem.typhonMark.x, u.mem.typhonMark.y];
h.run(12);
assert.equal(u.skill.ammoLeft, ammo, 'nothing in the area: no round');
assert.deepEqual([u.mem.typhonMark.x, u.mem.typhonMark.y], spot, 'the mark stays');
const later = h.spawn('enemy_light', { pos: [Math.round(spot[1]), Math.round(spot[0])] });
assert.ok(h.runUntil(() => rainHits(h, u).some((c) => c.target === later), 12), 'an enemy at the old spot is hit');
assert.equal(u.skill.ammoLeft, ammo - 1);
done(h);
});
test('T1 锐如兽牙: each attack adds 10 % (SIE-X stage 3: 12 %) DEF ignore up to 5 stacks, applied to that attack already; all gone 8 s (10 s) after her last attack', () => {
for (const [tier, elite, mod] of [[5, false, null], [6, true, null], [5, true, SIEX], [6, true, SIEX]]) {
const t0 = formOf(tier, elite).talents.find((t) => t.index === 0).bb;
const tc = mod ? modOf(tier, mod).talentChanges.find((t) => t.talentIndex === 0 && !t.hidden)?.bb : null;
const per = tc?.def_penetrate ?? t0.def_penetrate, dur = tc?.duration ?? t0.duration;
assert.deepEqual([per, dur, tc?.max_stack_cnt ?? t0.max_stack_cnt], tier === 6 && mod === SIEX ? [0.12, 10, 5] : [0.1, 8, 5], label([tier, elite, mod]));
const { h, u } = field({ tier, elite, mod, skill: 0 });
const e = h.spawn('enemy_plate', { pos: [10, 6] });
h.run(16);
const list = atkHits(h, u).filter((c) => c.target === e);
assert.ok(list.length >= 6, `${label([tier, elite, mod])}: ${list.length} attacks`);
list.forEach((c, i) => approx(c.amount, u.s.atk - 600 * (1 - per * Math.min(5, i + 1)), `${label([tier, elite, mod])}: attack ${i + 1}`));
h.b.kill(e, null);
const last = list.at(-1).t;
h.run(last + dur - 1 - h.b.time);
assert.equal(u.findBuff('talent:typhon:fang')?.stacks, 5, `${label([tier, elite, mod])}: still there ${dur - 1} s after the last attack`);
h.run(1.5);
assert.equal(u.findBuff('talent:typhon:fang'), null, `${label([tier, elite, mod])}: lost after ${dur} s without an attack`);
done(h);
}
});
test('T2 重如沼泥: while her skill runs, the first damage on each enemy deals ×1.6 and 停顿 3 s (SIE-Y stage 3: the first two, ×2.2); not outside a skill; a new cast clears the marks', () => {
for (const [tier, elite, mod] of [[5, false, null], [6, true, null], [5, true, SIEY], [6, true, SIEY], [6, true, ISWA]]) {
const t1 = formOf(tier, elite).talents.find((t) => t.index === 1).bb;
const tc = mod === SIEY && tier === 6 ? modOf(tier, SIEY).talentChanges.find((t) => t.talentIndex === 1).bb : null;
const n = tc?.max_stack_cnt ?? 1, scale = tc?.atk_scale ?? t1.atk_scale;
assert.deepEqual([scale, tc?.sluggish ?? t1.sluggish], [tier === 6 && mod === SIEY ? 2.2 : 1.6, 3], label([tier, elite, mod]));
const { h, u } = field({ tier, elite, mod, skill: 0 });
const a = h.spawn('enemy_light', { pos: [10, 5] });
const far = (d) => (mod === SIEY ? 1 + 0.12 * (d - 1) / 3.5 : 1);
h.run(5);
assert.ok(atkHits(h, u).length && atkHits(h, u).every((c) => c.target === a), `${label([tier, elite, mod])}: attacks`);
for (const c of atkHits(h, u)) approx(c.amount, u.s.atk * far(2), `${label([tier, elite, mod])}: no skill, no bonus`);
assert.equal(h.hooksOf('statusApplied').filter((c) => c.source === u && c.status === 'sluggish').length, 0);
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 4));
const t0 = h.b.time;
h.run(8);
const during = atkHits(h, u).filter((c) => c.t >= t0 - 0.05);
assert.ok(during.length > n + 1, `${label([tier, elite, mod])}: attacks during S1`);
during.forEach((c, i) => approx(c.amount, u.s.atk * far(2) * (i < n ? scale : 1), `${label([tier, elite, mod])}: hit ${i + 1}`));
const slows = h.hooksOf('statusApplied').filter((c) => c.source === u && c.status === 'sluggish');
assert.equal(slows.length, n, `${label([tier, elite, mod])}: 停顿 with the marked hits`);
for (const c of slows) approx(c.duration, 3, '3 s');
// a second enemy gets its own first hit; a new cast clears the marks
u.skill.end('test');
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 5));
const t1s = h.b.time;
h.run(3);
const again = atkHits(h, u).filter((c) => c.t >= t1s - 0.05);
approx(again[0].amount, u.s.atk * far(2) * scale, `${label([tier, elite, mod])}: marks cleared by the new cast`);
done(h);
}
});
test('SIE-X 自然的包容: ×1.15 on her attacks against 重量等级 ≥ 3, nothing below; the S3 arrow rain (no attack) is not raised', () => {
for (const [tier, mod] of [[5, SIEX], [6, SIEX], [6, null], [6, ISWA]]) {
const xs = mod === SIEX ? 1.15 : 1;
if (mod === SIEX) assert.deepEqual(modOf(tier, SIEX).talentChanges.find((t) => t.hidden).bb, { atk_scale: 1.15, value: 3 });
const { h, u } = field({ tier, elite: true, mod, skill: 0 });
const heavy = h.spawn('enemy_heavy', { pos: [10, 5] });
h.run(4);
const mid = h.spawn('enemy_mid', { pos: [10, 6] });
h.b.kill(heavy, null);
h.run(4);
for (const c of atkHits(h, u)) approx(c.amount, u.s.atk * (c.target.weight >= 3 ? xs : 1), `T${tier} ${mod}: ${c.target.defId}`);
assert.ok(atkHits(h, u).some((c) => c.target === mid) && atkHits(h, u).some((c) => c.target.weight >= 3));
done(h);
}
// S3: rain hits on a weight-3 enemy are s3_atk_scale × ATK (× 1.6 the first)
const { h, u } = field({ tier: 6, elite: true, mod: SIEX, skill: 2 });
h.spawn('enemy_heavy', { pos: [10, 6] });
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 4));
h.run(7);
const rain = rainHits(h, u);
rain.forEach((c, i) => approx(c.amount, u.s.atk * 1.6 * (i === 0 ? 1.6 : 1), `rain ${i + 1}: no ×1.15`));
done(h);
});
test('SIE-Y 冰原的影子: her attack damage rises with the distance, linearly 1 → 4.5 tiles, at most +12 % (stages 1 and 3); the S3 rain is not raised', () => {
for (const tier of [5, 6]) {
assert.deepEqual(modOf(tier, SIEY).traitOverride.bb, { max_dist: 4.5, min_dist: 1, damage_scale: 0.12 });
for (const [r, c] of [[10, 5], [10, 7], [11, 6]]) {
const { h, u } = field({ tier, elite: true, mod: SIEY, skill: 0 });
const e = h.spawn('enemy_light', { pos: [r, c] });
h.run(3);
const d = Math.hypot(c - 3, r - 10);
approx(atkHits(h, u).find((x) => x.target === e).amount, u.s.atk * (1 + 0.12 * (d - 1) / 3.5), `T${tier}: ${d.toFixed(2)} tiles`);
done(h);
}
}
const { h, u } = field({ tier: 6, elite: true, mod: SIEY, skill: 2 });
h.spawn('enemy_light', { pos: [10, 7] });
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 4));
h.run(7);
const n = modOf(6, SIEY).talentChanges.find((t) => t.talentIndex === 1).bb.max_stack_cnt;
rainHits(h, u).forEach((c, i) => approx(c.amount, u.s.atk * 1.6 * (i < n ? 2.2 : 1), `rain ${i + 1}: no distance bonus`));
done(h);
});
test('ISW-A 提丰特限证章: only its HP / ATK — the 集成战略 trait (damage_scale 1.4 on heavy enemies) and arrow-rain mark do nothing here', () => {
for (const tier of [5, 6]) {
const m = modOf(tier, ISWA);
assert.match(m.traitOverride.moduleDesc, /^在集成战略中/);
assert.ok(m.talentChanges.some((t) => t.bb.damage_scale === 1.4), 'the data carries the IS part');
const { h, u } = field({ tier, elite: true, mod: ISWA, skill: 0 });
const heavy = h.spawn('enemy_heavy', { pos: [10, 5] });
h.run(8);
for (const c of atkHits(h, u).filter((x) => x.target === heavy)) approx(c.amount, u.s.atk, `T${tier}: ×1 on a weight-3 enemy`);
h.run(30);
assert.equal(h.hooksOf('damaged').filter((c) => c.source === u && !c.dmg.isAttack).length, 0, `T${tier}: no arrow rain of its own`);
done(h);
}
});
+269
View File
@@ -0,0 +1,269 @@
// test/content/op_veen.test.js — the 自选 operator kit of 维伊 (char_4226_veen, 6★ 秘术师; kit
// server/sim/content/kits/ops/op-veen.js), fielded the production way (a DIY slot + its `diy` pick, simdata getDiy) in
// every form: tiers 5 / 6, normal (E2 Lv1, skill rank 4, no module) and elite (E2 Lv60, rank 7) with no module or MSC-Y
// “军械库” at stage 1 (tier 5) / 3 (tier 6). Every number is read back from data/backups.json (the form of that slot
// status); the fidelity checklist of kits/README.md item by item.
// Run: node --test test/content/op_veen.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
import { KITTED_CHARS, OPERATOR_KITS, KITS } from '../../server/sim/content/kits/index.js';
import { diyPool, validateDiyPicks } from '../../shared/diy.js';
const load = (f) => JSON.parse(readFileSync(new URL(`../../data/${f}.json`, import.meta.url), 'utf8'));
const CHESS = load('chess');
const BACKUPS = load('backups');
const VEEN = 'char_4226_veen';
const FORMS = BACKUPS.units[VEEN].forms;
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
const MSCY = 'uniequip_002_veen';
const S1 = 'skchr_veen_1', S2 = 'skchr_veen_2', S3 = 'skchr_veen_3';
const formOf = (tier, elite) => FORMS[elite ? (tier === 5 ? '2/60/7/1' : '2/60/7/3') : '2/1/4/0'];
const skillOf = (tier, elite, id) => formOf(tier, elite).skills.find((s) => s.skillId === id);
const modOf = (tier, mod) => (mod ? formOf(tier, true).modules.find((m) => m.uniEquipId === mod) : null);
const talentOf = (tier, elite, mod, i) => {
const m = elite ? modOf(tier, mod) : null;
return m?.talentChanges.find((t) => t.talentIndex === i)?.bb ?? formOf(tier, elite).talents.find((t) => t.index === i).bb;
};
const approx = (a, b, msg, eps = 1e-6) => assert.ok(Math.abs(a - b) <= eps * Math.max(1, Math.abs(b)), `${msg}: ${a} vs ${b}`);
const dummy = (key, o = {}) => enemyRec({ key, hp: 1e9, speed: 0, mass: 0, ...o });
const ENEMIES = { enemy_dummy: dummy('enemy_dummy'), enemy_fly: dummy('enemy_fly', { motion: 'FLY' }) };
const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, MSCY].map((m) => [t, true, m]))];
const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`;
function field({ tier = 5, elite = false, mod = null, skill = 0, row = 10, col = 5, others = [], seed = 5 } = {}) {
const h = makeBattle({
defs: { enemies: ENEMIES }, timeLimit: 900, autoFinish: false, seed,
flags: { dpPerSec: 0 }, hooks: ['damaged', 'skillStart', 'skillEnd', 'attack', 'ammoUsed'], captureNoisy: true,
units: [{ uid: 1, diy: { slot: SLOT[tier], charId: VEEN, skillIndex: skill, uniEquipId: mod }, elite, row, col }, ...others],
});
h.step();
return { h, u: h.unit(1) };
}
const atkHits = (h, u) => h.hooksOf('damaged').filter((c) => c.source === u && c.dmg?.isAttack);
const warTicks = (h, u, e) => h.hooksOf('damaged').filter((c) => c.source === u && c.target === e && (c.dmg?.tags || []).includes('veenWar'));
const stored = (u) => ({ s: u.trait.veen?.s ?? 0, t: u.trait.veen?.t ?? 0 });
function done(h) {
checkInvariants(h.b);
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
}
/** Run until she holds `t` 转置能量 and `s` stored energies (no enemy on the field). */
function storeUp(h, u, t, s) {
assert.ok(h.runUntil(() => stored(u).t === t && stored(u).s === s, 60), `stores up to ${t}/${s}`);
}
test('维伊 in every 自选 form: her operator kit (all three skills authored), stats + MSC-Y attributes, 3-14 range, ranged arts every 3 s that hits air units, targetable by ground enemies, 空 bond, no 特质', () => {
assert.equal(OPERATOR_KITS[VEEN], KITS[VEEN]);
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
for (const skill of [0, 1, 2]) {
const { h, u } = field({ tier, elite, mod, skill });
const form = formOf(tier, elite), m = elite ? modOf(tier, mod) : null;
assert.deepEqual([u.def.charId, u.def.diyFor, u.skill.id, !!u.kit.generic, u.kit.skillSource], [VEEN, SLOT[tier], form.skills[skill].skillId, false, 'skills'], label(f));
assert.deepEqual([u.base.maxHp, u.base.atk, u.base.def], [form.stats.maxHp + (m?.attr.maxHp ?? 0), form.stats.atk + (m?.attr.atk ?? 0), form.stats.def], `${label(f)}: stats`);
assert.deepEqual([u.s.blockCnt, u.profile.attack, u.profile.dmgType, u.profile.canHitFly, u.base.bat, u.base.cost], [1, 'ranged', 'arts', true, 3, 27], `${label(f)}: 秘术师`);
assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: 3-14`);
assert.deepEqual([u.def.bonds, u.def.raw.garrisonIds], [['emptyShip'], []], `${label(f)}: bonds / 特质`);
assert.ok(!u.s.flags.liftoff && !u.s.flags.camou && !u.s.flags.stealth, `${label(f)}: ground enemies target her`);
done(h);
}
}
assert.deepEqual([FORMS['2/1/4/0'].stats.atk, FORMS['2/60/7/1'].stats.atk, FORMS['2/60/7/1'].trait.bb], [1142, 1320, { times: 9, merge_cnt: 3 }]);
assert.deepEqual([modOf(5, MSCY).attr, modOf(6, MSCY).attr], [{ maxHp: 80, atk: 85 }, { maxHp: 190, atk: 120 }]);
});
test('a 自选 pick: 维伊 is offered at tiers 5 and 6 (she has a kit) and a roster with her passes validateDiyPicks', () => {
const data = { chess: CHESS, backups: BACKUPS };
assert.ok(KITTED_CHARS.includes(VEEN));
for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(VEEN), `tier ${t}`);
assert.deepEqual(validateDiyPicks({ [SLOT[6]]: { charId: VEEN, skillIndex: 1, uniEquipId: MSCY } }, { data, kitted: KITTED_CHARS }),
{ ok: true, picks: { [SLOT[6]]: { charId: VEEN, skillIndex: 1, uniEquipId: MSCY } } });
});
test('trait + T1: with no target her attack check stores one energy (an attack action: the interval restarts — PRTS 分支特性信息), 3 merge into a 转置能量, at most 3 of those (then she stops); ATK +10 % while she holds one, ASPD +15 while she holds none; MSC-Y: ASPD +30 while she holds any energy', () => {
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
const t0 = talentOf(tier, elite, mod, 0);
assert.deepEqual(t0, { atk: 0.1, attack_speed: 15 }, label(f));
const msc = elite && mod === MSCY ? 30 : 0;
const { h, u } = field({ tier, elite, mod, skill: 0 });
u.skill.sp = 0;
assert.deepEqual(stored(u), { s: 1, t: 0 }, `${label(f)}: her first attack check (at deployment) stores at once`);
approx(u.s.aspd, 100 + 15 + msc, `${label(f)}: no 转置能量: ASPD +15 (MSC-Y +30 while holding)`);
approx(u.s.atk, u.base.atk, `${label(f)}: no ATK bonus`);
const t1 = h.b.time;
h.runUntil(() => stored(u).s === 2, 10);
approx(h.b.time - t1, 3 * 100 / 115, `${label(f)}: the next one an attack interval later (set at the store, before MSC-Y)`, 0.03);
const t2 = h.b.time;
h.runUntil(() => stored(u).t === 1, 10);
approx(h.b.time - t2, 3 * 100 / (115 + msc), `${label(f)}: then at the interval of ASPD ${115 + msc}`, 0.03);
storeUp(h, u, 1, 0);
approx(u.s.aspd, 100 + msc, `${label(f)}: a 转置能量: the ASPD +15 goes`);
approx(u.s.atk, u.base.atk * 1.1, `${label(f)}: ATK +10 %`);
storeUp(h, u, 3, 0);
h.run(30);
assert.deepEqual(stored(u), { s: 0, t: 3 }, `${label(f)}: no more storing at 3 转置能量`);
done(h);
}
});
test('the volley: 转置能量 first (300 % ATK each), the main attack (100 %), then the stored energies (100 %) — one target, all landing together, each with the ATK it left with (+10 % while a 转置能量 was held)', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const { h, u } = field({ tier, elite, skill: 1 });
u.skill.sp = 0;
storeUp(h, u, 1, 2);
const atk = u.s.atk;
approx(atk, u.base.atk * 1.1, `T${tier}: holding a 转置能量`);
const e = h.spawn('enemy_fly', { pos: [10, 7] });
const a0 = atkHits(h, u).length;
h.runUntil(() => atkHits(h, u).length > a0, 4);
h.step();
const hits = atkHits(h, u).slice(a0);
assert.deepEqual(hits.map((c) => Math.round(c.amount / atk * 100) / 100), [3, 1, 1, 1], `T${tier}: 300 % + 100 % + 2 × 100 % at the cached ATK`);
assert.ok(hits.every((c) => c.target === e && c.t === hits[0].t && c.dmg.attackId === hits[0].dmg.attackId && c.type === 'arts'), `T${tier}: one attack, one target (a flyer), together`);
assert.deepEqual(stored(u), { s: 0, t: 0 }, `T${tier}: spent`);
h.run(0.2);
approx(u.s.atk, u.base.atk, `T${tier}: the ATK bonus gone after the volley`);
done(h);
}
});
test('T2 战争技艺: each hit adds a stack (a 转置能量 3) of 90 arts / s for 5 s, at most 3 (MSC-Y stage 3: 120 / s, at most 4); she never targets a marked enemy — with only marked ones in range she stores energy', () => {
for (const f of [[5, false, null], [5, true, MSCY], [6, true, MSCY]]) {
const [tier, elite, mod] = f;
const t1 = talentOf(tier, elite, mod, 1);
const s3m = elite && mod === MSCY && tier === 6;
assert.deepEqual(t1, s3m ? { 'attack@duration': 5, 'attack@value': 120, 'attack@max_stack_cnt': 4 } : { 'attack@duration': 5, 'attack@value': 90, 'attack@max_stack_cnt': 3 }, label(f));
const { h, u } = field({ tier, elite, mod, skill: 0 });
u.skill.sp = 0;
h.run(3.1);
u.trait.veen = null; // (start the check with nothing stored)
const a = h.spawn('enemy_dummy', { pos: [10, 7] });
h.runUntil(() => atkHits(h, u).length > 0, 4);
h.step();
const war = a.findBuff(`veen:war:${u.id}`);
assert.ok(war && war.stacks === 1, `${label(f)}: 1 stack`);
h.run(1.02);
const ticks = warTicks(h, u, a);
assert.equal(ticks.length, 1, `${label(f)}: a tick per second`);
approx(ticks[0].amount, t1['attack@value'], `${label(f)}: ${t1['attack@value']} arts`);
// marked: not targeted — she stores instead while it is her only enemy in range
const n0 = atkHits(h, u).length;
h.run(2.9);
assert.equal(atkHits(h, u).length, n0, `${label(f)}: no attack on a marked enemy`);
assert.ok(stored(u).s >= 1, `${label(f)}: her next attack check stored an energy instead`);
// another enemy: targeted at once; the mark lapses 5 s after the last stack
const b = h.spawn('enemy_dummy', { pos: [9, 7] });
h.runUntil(() => atkHits(h, u).length > n0, 4);
assert.ok(atkHits(h, u).slice(n0).every((c) => c.target === b), `${label(f)}: the unmarked one`);
assert.ok(h.runUntil(() => !a.findBuff(`veen:war:${u.id}`), 6), `${label(f)}: 5 s`);
assert.equal(warTicks(h, u, a).length, 5, `${label(f)}: 5 ticks`);
done(h);
}
// a 转置能量 gives 3 stacks; the cap
for (const [tier, mod, cap] of [[6, null, 3], [6, MSCY, 4]]) {
const { h, u } = field({ tier, elite: true, mod, skill: 0 });
u.skill.sp = 0;
storeUp(h, u, 2, 0);
const e = h.spawn('enemy_dummy', { pos: [10, 7] });
h.runUntil(() => atkHits(h, u).length >= 3, 4);
h.step();
assert.equal(e.findBuff(`veen:war:${u.id}`).stacks, cap, `T6 ${mod ?? 'none'}: 2 × 3 + 1 stacks capped at ${cap}`);
h.run(1.02);
approx(warTicks(h, u, e)[0].amount, talentOf(6, true, mod, 1)['attack@value'] * cap, `${cap} stacks`);
done(h);
}
});
test('S1 “自呼号生发” (AUTO): ASPD +60 / +75 for 18 s, cast as soon as its SP is full (31 / 29 from 7 / 9) — no enemy needed', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S1);
const { h, u } = field({ tier, elite, skill: 0 });
assert.deepEqual([u.skill.rule, u.skill.spCost, sk.initSp, sk.duration, sk.bb.attack_speed], ['SP_FULL', elite ? 29 : 31, elite ? 9 : 7, 18, elite ? 75 : 60], `T${tier}`);
h.run(sk.spCost - sk.initSp - 0.5);
assert.equal(u.skill.activations, 0);
assert.ok(h.runUntil(() => u.skill.active, 1), `T${tier}: at full SP, no enemy`);
const base = u.s.aspd - sk.bb.attack_speed;
assert.ok(base === 115 || base === 100, `T${tier}: +${sk.bb.attack_speed} on top of ${base}`);
approx(u.skill.timeLeft, 18, `T${tier}: 18 s`, 0.01);
done(h);
}
});
test('S2 “以鲜血洗去” (MANUAL, data DEFAULT): 28 s, attack interval 3.0 − 0.5 s; each released shot +9 % / +11 % ATK and +6 / +7 ASPD (a 转置能量 3 stacks), at most 8 / 9 — later shots of a volley carry the stacks of the earlier ones', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S2);
const b = sk.bb;
const per = b['attack@veen_s_2_buff[stack].atk'], perAs = b['attack@veen_s_2_buff[stack].attack_speed'], max = b['attack@veen_s_2_buff[stack].max_stack_cnt'];
assert.deepEqual([sk.duration, b.base_attack_time, per, perAs, max], [28, -0.5, elite ? 0.11 : 0.09, elite ? 7 : 6, elite ? 9 : 8], `T${tier}`);
const { h, u } = field({ tier, elite, skill: 1 });
assert.equal(u.skill.rule, 'DEFAULT');
u.skill.sp = 0;
storeUp(h, u, 1, 1);
const base = u.base.atk;
u.skill.gainSp(999);
const e = h.spawn('enemy_dummy', { pos: [10, 7] });
assert.ok(h.runUntil(() => u.skill.active, 4), `T${tier}: cast as she attacks`);
h.step(2);
approx(u.s.interval, 2.5 * 100 / u.s.aspd, `T${tier}: base interval 2.5 s`);
const hits = atkHits(h, u);
h.runUntil(() => atkHits(h, u).length >= 3, 4);
const v = atkHits(h, u).slice(0, 3);
// 转置能量 (0 stacks, +10 %), main (3 stacks), stored (4 stacks) — every one +10 % (a 转置能量 was held)
approx(v[0].amount, base * (1 + 0.1) * 3, `T${tier}: 转置能量 before any stack`);
approx(v[1].amount, base * (1 + 0.1 + 3 * per), `T${tier}: the main shot after 3`);
approx(v[2].amount, base * (1 + 0.1 + 4 * per), `T${tier}: the stored one after 4`);
assert.ok(hits.length <= 3);
approx(u.s.aspd, 100 + 15 + 5 * perAs, `T${tier}: 5 stacks of ASPD (and T1's +15 again)`);
h.run(25);
assert.equal(u.findBuff('veen:s2stack').mods.atkPct, per * max, `T${tier}: at most ${max}`);
h.runUntil(() => !u.skill.active, 10);
assert.equal(u.findBuff('veen:s2stack'), null, `T${tier}: gone with the skill`);
void e;
done(h);
}
});
test('S3 “用赤铁铭记” (MANUAL, data DEFAULT): 18 / 21 bullets, ASPD +110 / +140; an attack spends 1 bullet per stored energy and 3 per 转置能量 (none without), its stored shots deal nothing and its main hit bounces that many times — every 0.5 s within 1.7 tiles, a new enemy first — for 130 % / 140 % of the cached ATK, each with a 战争技艺 stack', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S3);
const b = sk.bb;
assert.deepEqual([b['attack@trigger_time'], b.attack_speed, b['attack@bounce_atk_scale'], b['attack@base_atk_scale']], [elite ? 21 : 18, elite ? 140 : 110, elite ? 1.4 : 1.3, 1], `T${tier}`);
const { h, u } = field({ tier, elite, skill: 2 });
assert.equal(u.skill.rule, 'DEFAULT');
u.skill.sp = 0;
storeUp(h, u, 1, 1);
const atk = u.s.atk;
u.skill.gainSp(999);
const e = h.spawn('enemy_dummy', { pos: [10, 7] });
const near = h.spawn('enemy_fly', { pos: [10, 8.2] });
const far = h.spawn('enemy_dummy', { pos: [10, 12] }); // out of her range and 3.8 from the target
assert.ok(h.runUntil(() => u.skill.active, 4), `T${tier}: cast as she attacks`);
assert.deepEqual([u.skill.kind, u.skill.ammoMax], ['ammo', b['attack@trigger_time']], `T${tier}`);
approx(u.s.aspd, 100 + b.attack_speed + 15, `T${tier}: ASPD (+ T1's 15: the volley spent the 转置能量)`, 1e-9);
h.run(2.2);
// the first attack: 1 + 3 = 4 bullets, the main hit then 4 bounces (e ↔ near: a new enemy first, then back)
assert.equal(u.skill.ammoLeft, b['attack@trigger_time'] - 4, `T${tier}: 4 bullets`);
assert.equal(h.hooksOf('ammoUsed').filter((c) => c.unit === u).length, 4);
const hits = atkHits(h, u);
const id = hits[0].dmg.attackId;
const of = hits.filter((c) => c.dmg.attackId === id);
assert.equal(of.length, 5, `T${tier}: main + 4 bounces, no stored-shot damage`);
approx(of[0].amount, atk * b['attack@base_atk_scale'], `T${tier}: the main hit`);
assert.deepEqual(of.map((c) => c.target.id), [e.id, near.id, e.id, near.id, e.id], `T${tier}: a new enemy first, then between them`);
for (const c of of.slice(1)) approx(c.amount, atk * b['attack@bounce_atk_scale'], `T${tier}: bounce at the cached ATK`);
for (let i = 2; i < of.length; i++) approx(of[i].t - of[i - 1].t, 0.5, `T${tier}: 0.5 s apart`, 0.08);
assert.ok(!of.some((c) => c.target === far), `T${tier}: 1.7 tiles at most`);
assert.equal(near.findBuff(`veen:war:${u.id}`).stacks, 2, `T${tier}: a 战争技艺 stack per bounce`);
// attacks without stored energy spend nothing; the skill ends when the bullets run out
const left = u.skill.ammoLeft;
h.run(1.5);
assert.ok(u.skill.ammoLeft <= left);
h.runUntil(() => !u.skill.active, 300);
assert.equal(u.skill.ammoLeft, 0, `T${tier}: ended out of bullets`);
assert.equal(h.hooksOf('skillEnd').filter((c) => c.unit === u)[0].reason, 'ammo');
done(h);
}
});
+374
View File
@@ -0,0 +1,374 @@
// test/content/op_zuole.test.js — the 自选 operator kit of 左乐 (char_4121_zuole, 6★ 武者; kit
// server/sim/content/kits/ops/op-zuole.js), fielded the production way (a DIY slot + its `diy` pick, simdata getDiy) in
// every form: tiers 5 / 6, normal (E2 Lv1, skill rank 4, no module) and elite (E2 Lv60, rank 7) with no module, SBL-X
// “岂苦夜长” or SBL-Y “秉烛人的记录” at stage 1 (tier 5) / 3 (tier 6). Every number is read back from data/backups.json
// (the form of that slot status); the fidelity checklist of kits/README.md item by item.
// Run: node --test test/content/op_zuole.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
import { KITTED_CHARS, OPERATOR_KITS, KITS } from '../../server/sim/content/kits/index.js';
import { diyPool, validateDiyPicks } from '../../shared/diy.js';
const load = (f) => JSON.parse(readFileSync(new URL(`../../data/${f}.json`, import.meta.url), 'utf8'));
const CHESS = load('chess');
const BACKUPS = load('backups');
const ZUOLE = 'char_4121_zuole';
const FORMS = BACKUPS.units[ZUOLE].forms;
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
const SBLX = 'uniequip_002_zuole', SBLY = 'uniequip_003_zuole';
const S1 = 'skchr_zuole_1', S2 = 'skchr_zuole_2', S3 = 'skchr_zuole_3';
const formOf = (tier, elite) => FORMS[elite ? (tier === 5 ? '2/60/7/1' : '2/60/7/3') : '2/1/4/0'];
const skillOf = (tier, elite, id) => formOf(tier, elite).skills.find((s) => s.skillId === id);
const modOf = (tier, mod) => (mod ? formOf(tier, true).modules.find((m) => m.uniEquipId === mod) : null);
const approx = (a, b, msg, eps = 1e-6) => assert.ok(Math.abs(a - b) <= eps * Math.max(1, Math.abs(b)), `${msg}: ${a} vs ${b}`);
const dummy = (key, o = {}) => enemyRec({ key, hp: 1e9, speed: 0, mass: 0, ...o });
const ENEMIES = {
enemy_dummy: dummy('enemy_dummy'), enemy_fly: dummy('enemy_fly', { motion: 'FLY' }),
enemy_walk: enemyRec({ key: 'enemy_walk', hp: 1e9, speed: 0.6, mass: 0 }),
};
/** Every 自选 form: [tier, elite, module]. */
const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, SBLX, SBLY].map((m) => [t, true, m]))];
const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`;
/** A battle with 左乐 as uid 1 at (row, col) facing RIGHT, plus `others`. */
function field({ tier = 5, elite = false, mod = null, skill = 2, row = 10, col = 5, others = [], seed = 5 } = {}) {
const h = makeBattle({
defs: { enemies: ENEMIES }, timeLimit: 900, autoFinish: false, seed,
flags: { dpPerSec: 0, dpMax: 999 }, hooks: ['damaged', 'heal', 'skillStart', 'skillEnd', 'statusApplied', 'fatal'], captureNoisy: true,
units: [{ uid: 1, diy: { slot: SLOT[tier], charId: ZUOLE, skillIndex: skill, uniEquipId: mod }, elite, row, col }, ...others],
});
h.step();
return { h, u: h.unit(1) };
}
const hitsBy = (h, u) => h.hooksOf('damaged').filter((c) => c.source === u && c.target?.side === 'enemy');
const healsOf = (h, u) => h.hooksOf('heal').filter((c) => c.target === u);
/** Keep 守正自明 / 坚忍 SP out of the way: no SP of any kind (阻回). */
const noSp = (h, u) => h.b.addBuff(u, { key: 'test:noSp', flags: { noSp: true }, duration: Infinity });
function done(h) {
checkInvariants(h.b);
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
}
test('左乐 in every 自选 form: his operator kit (all three skills authored), the form\'s stats + module attributes, 1-1 range, blocks 1, melee ground-only, 炎, no 特质; 武者: no heal from others, 70 HP per enemy hit', () => {
assert.equal(OPERATOR_KITS[ZUOLE], KITS[ZUOLE]);
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
for (const skill of [0, 1, 2]) {
const { h, u } = field({ tier, elite, mod, skill });
const form = formOf(tier, elite), m = elite ? modOf(tier, mod) : null;
assert.deepEqual([u.def.charId, u.def.diyFor, u.skill.id, !!u.kit.generic, u.kit.skillSource], [ZUOLE, SLOT[tier], form.skills[skill].skillId, false, 'skills'], label(f));
assert.deepEqual([u.base.maxHp, u.base.atk, u.base.def], [form.stats.maxHp + (m?.attr.maxHp ?? 0), form.stats.atk + (m?.attr.atk ?? 0), form.stats.def + (m?.attr.def ?? 0)], `${label(f)}: stats`);
assert.deepEqual([u.s.blockCnt, u.profile.attack, u.profile.canHitFly, u.profile.dmgType, u.profile.noHeal, u.profile.selfHeal, u.base.bat], [1, 'melee', false, 'phys', true, 70, 1.2], `${label(f)}: 武者`);
assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: 1-1`);
assert.deepEqual([u.def.bonds, u.def.raw.garrisonIds], [['yanShip'], []], `${label(f)}: bonds / 特质`);
assert.ok(!u.s.flags.liftoff && !u.s.flags.camou && !u.s.flags.stealth && !u.s.flags.untargetable, `${label(f)}: ground enemies target him`);
done(h);
}
}
// the numbers of the forms (zh_CN): E2 Lv1 2893 / 647 / 267, E2 Lv60 3533 / 735 / 299; SBL-X +55 / +40 → +90 / +60
// ATK / DEF, SBL-Y +300 / +60 → +380 / +84 HP / ATK
assert.deepEqual([FORMS['2/1/4/0'].stats.maxHp, FORMS['2/1/4/0'].stats.atk, FORMS['2/60/7/1'].stats.maxHp, FORMS['2/60/7/1'].stats.atk], [2893, 647, 3533, 735]);
assert.deepEqual([modOf(5, SBLX).attr, modOf(6, SBLX).attr, modOf(5, SBLY).attr, modOf(6, SBLY).attr], [{ atk: 55, def: 40 }, { atk: 90, def: 60 }, { maxHp: 300, atk: 60 }, { maxHp: 380, atk: 84 }]);
});
test('a 自选 pick: 左乐 is offered at tiers 5 and 6 (he has a kit) and a roster with him passes validateDiyPicks', () => {
const data = { chess: CHESS, backups: BACKUPS };
assert.ok(KITTED_CHARS.includes(ZUOLE));
for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(ZUOLE), `tier ${t}`);
assert.deepEqual(validateDiyPicks({ [SLOT[5]]: { charId: ZUOLE, skillIndex: 1, uniEquipId: SBLX } }, { data, kitted: KITTED_CHARS }),
{ ok: true, picks: { [SLOT[5]]: { charId: ZUOLE, skillIndex: 1, uniEquipId: SBLX } } });
});
test('trait (武者): +70 HP on every enemy hit, none from another unit\'s heal', () => {
const { h, u } = field({ tier: 5, elite: true, skill: 1 });
noSp(h, u);
const e = h.spawn('enemy_dummy', { pos: [10, 6] });
u.hp = u.s.maxHp * 0.6;
const hp0 = u.hp;
h.runUntil(() => hitsBy(h, u).length >= 3, 6);
approx(u.hp - hp0, 70 * hitsBy(h, u).length, 'one heal of 70 per hit');
assert.equal(h.b.heal(e, u, 500), 0, 'no heal from another unit');
done(h);
});
test('S1 破虏 (AUTO, 2 charges, data DEFAULT): the next attack at 145 % / 160 %; below 80 % HP one more strike, below 50 % two more — each a damage instance of its own (and a trait heal)', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S1);
assert.deepEqual([sk.skillType, sk.spType, sk.spCost, sk.initSp, sk.maxChargeTime, sk.bb.atk_scale, sk.bb.hp_ratio_double, sk.bb.hp_ratio_tripple],
['AUTO', 'INCREASE_WITH_TIME', elite ? 5 : 6, elite ? 4 : 2, 2, elite ? 1.6 : 1.45, 0.8, 0.5], `T${tier}`);
for (const [ratio, strikes] of [[1, 1], [0.79, 2], [0.49, 3]]) {
const { h, u } = field({ tier, elite, skill: 0 });
assert.deepEqual([u.skill.rule, u.skill.maxCharges], ['DEFAULT', 2], `T${tier}`);
noSp(h, u);
u.skill.sp = 0;
u.skill.charges = 1;
u.hp = u.s.maxHp * ratio;
const e = h.spawn('enemy_dummy', { pos: [10, 6] });
assert.ok(h.runUntil(() => u.skill.activations === 1, 2), `T${tier}: cast`);
h.step();
const sk1 = hitsBy(h, u).filter((c) => c.dmg.isSkill);
assert.equal(sk1.length, strikes, `T${tier} at ${ratio * 100} % HP: ${strikes} strike(s)`);
for (const c of sk1) {
assert.equal(c.target, e);
approx(c.amount, u.s.atk * sk.bb.atk_scale, `T${tier}: ${sk.bb.atk_scale * 100} %`);
assert.equal(c.type, 'phys');
}
assert.equal(healsOf(h, u).filter((c) => c.opts?.self && !c.opts?.tags).length, strikes, `T${tier}: a trait heal per strike`);
done(h);
}
}
});
test('S2 行险 (MANUAL, 12 s, data DEFAULT): −50 % current HP (never below 1), a barrier of 75 % / 90 % max HP stacking to 2× max HP, ATK +110 % / +140 %, block 2, every blocked enemy struck; once over the barrier loses 200 per second', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S2);
const { h, u } = field({ tier, elite, skill: 1, row: 9 });
assert.deepEqual([u.skill.rule, sk.duration, sk.spCost, sk.initSp, sk.bb.atk, sk.bb.hp_ratio, sk.bb.scale, sk.bb.block_cnt, sk.bb.max_scale, sk.bb.shield_decrease],
['DEFAULT', 12, elite ? 24 : 27, elite ? 10 : 5, elite ? 1.4 : 1.1, 0.5, elite ? 0.9 : 0.75, 1, 2, -200], `T${tier}`);
noSp(h, u);
const max = u.s.maxHp;
const a = h.spawn('enemy_dummy', { pos: [9, 5] }), b = h.spawn('enemy_dummy', { pos: [9, 5] });
h.step();
assert.equal(u.blocking.length, 1, `T${tier}: blocks 1`);
const hpAtStart = [];
h.b.on('skillStart', (c) => { if (c.unit === u) hpAtStart.push(u.hp); });
u.skill.charges = 1;
assert.ok(h.runUntil(() => u.skill.active, 3), `T${tier}: cast`);
approx(hpAtStart[0], max * 0.5, `T${tier}: −50 % of the current HP`);
const bar = () => u.findBuff('skill:zuole:barrier2');
approx(bar().shield, max * sk.bb.scale, `T${tier}: barrier ${sk.bb.scale * 100} % max HP`);
approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `T${tier}: ATK`);
h.step();
assert.deepEqual([u.s.blockCnt, u.blocking.length], [2, 2], `T${tier}: block +1`);
const n0 = hitsBy(h, u).length;
h.runUntil(() => hitsBy(h, u).length > n0, 3);
const atk = hitsBy(h, u).slice(n0);
assert.deepEqual(new Set(atk.map((c) => c.target)), new Set([a, b]), `T${tier}: both blocked enemies at once`);
// the barrier absorbs damage; it stays put while the skill runs
const hpBefore = u.hp;
h.b.dealDamage(a, u, { amount: 1000, type: 'true' });
approx(bar().shield, max * sk.bb.scale - 1000, `T${tier}: absorbed`);
approx(u.hp, hpBefore, `T${tier}: HP untouched`);
const left = bar().shield;
h.runUntil(() => !u.skill.active, 13);
approx(bar().shield, left, `T${tier}: no decay during the skill`);
h.run(3.05);
approx(bar().shield, left - 600, `T${tier}: −200 per second once over`, 1e-3);
// a second cast stacks onto what is left, capped at 2× max HP; the 流失 never goes below 1 HP
u.hp = 1;
const before = bar().shield;
u.skill.charges = 1;
assert.ok(h.runUntil(() => u.skill.active, 5), `T${tier}: second cast`);
assert.equal(hpAtStart[1], 1, `T${tier}: at 1 HP nothing is lost`);
const diff = bar().shield - before, add = max * sk.bb.scale;
assert.ok(Math.abs(diff - add) < 1e-6 || Math.abs(diff - (add - 200)) < 1e-6, `T${tier}: stacked onto what was left (${diff} vs ${add}, a decay step at most in between)`);
done(h);
}
// the cap: a cast on a barrier of 1.6× max HP stops at 2×
const { h, u } = field({ tier: 6, elite: true, skill: 1 });
noSp(h, u);
h.b.addBuff(u, { key: 'skill:zuole:barrier2', shield: u.s.maxHp * 1.6, data: { createdAt: 0 } });
u.skill.charges = 1;
h.spawn('enemy_dummy', { pos: [10, 6] });
assert.ok(h.runUntil(() => u.skill.active, 3));
approx(u.findBuff('skill:zuole:barrier2').shield, u.s.maxHp * 2, 'capped at 2× max HP');
done(h);
});
test('S3 佑序有炎 (MANUAL, data SKILL_RANGE on 3-2): 7 slashes on ≤ 3 enemies of the 3-2 (air too) at 205 % / 220 %, the last ×2 and 晕眩 3.5 / 4 s; meanwhile no trait heal — every hit adds 70 × 2.5 / 3 to a barrier (≤ 2× max HP, 15 s from the last gain)', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S3);
const { h, u } = field({ tier, elite, skill: 2 });
assert.deepEqual([u.skill.rule, sk.rangeId, sk.spCost, sk.initSp, sk.bb.atk_scale, sk.bb.times, sk.bb.stun, sk.bb.last_atk_bonus, sk.bb.shield_scale, sk.bb.shield_duration, sk.bb.max_scale],
['SKILL_RANGE', '3-2', elite ? 28 : 29, elite ? 6 : 3, elite ? 2.2 : 2.05, 7, elite ? 4 : 3.5, 2, elite ? 3 : 2.5, 15, 2], `T${tier}`);
assert.deepEqual(u.skill.triggerGrid, [[0, 0], [0, 1], [0, 2], [0, 3]], `T${tier}: its 3-2`);
noSp(h, u);
u.hp = u.s.maxHp * 0.6;
const hp0 = u.hp;
let hpAtEnd = null;
h.b.on('skillEnd', (c) => { if (c.unit === u && hpAtEnd == null) hpAtEnd = u.hp; });
u.skill.charges = 1;
h.run(1);
assert.equal(u.skill.activations, 0, `T${tier}: no enemy on the 3-2, no cast`);
const near = h.spawn('enemy_dummy', { pos: [10, 6] });
const mid = h.spawn('enemy_fly', { pos: [10, 7] });
const tip = h.spawn('enemy_dummy', { pos: [10, 8] });
const fourth = h.spawn('enemy_dummy', { pos: [10, 8] });
const beyond = h.spawn('enemy_dummy', { pos: [10, 9] });
const side = h.spawn('enemy_dummy', { pos: [9, 6] });
h.step();
assert.equal(u.skill.activations, 1, `T${tier}: cast as soon as an enemy is on its 3-2`);
const slashes = hitsBy(h, u).filter((c) => c.dmg.isSkill);
assert.equal(slashes.length, 21, `T${tier}: 7 × 3`);
const struck = new Set(slashes.map((c) => c.target));
assert.equal(struck.size, 3, `T${tier}: the same three`);
assert.ok(struck.has(near) && struck.has(mid), `T${tier}: the flyer too`);
assert.ok(!struck.has(beyond) && !struck.has(side), `T${tier}: nothing off the 3-2`);
assert.ok(struck.has(tip) !== struck.has(fourth), `T${tier}: one of the two on the tip (at most 3)`);
const amt = u.s.atk * sk.bb.atk_scale;
for (const [i, c] of slashes.entries()) approx(c.amount, amt * (i >= 18 ? 2 : 1), `T${tier}: slash ${Math.floor(i / 3) + 1}`);
const stuns = h.hooksOf('statusApplied').filter((c) => c.source === u && c.status === 'stun');
assert.equal(stuns.length, 3, `T${tier}: the last slash stuns`);
for (const c of stuns) approx(c.duration, sk.bb.stun, `T${tier}: ${sk.bb.stun} s`);
approx(hpAtEnd, hp0, `T${tier}: no trait heal during the slashes`);
const bar = u.findBuff('skill:zuole:barrier3');
approx(bar.shield, 21 * 70 * sk.bb.shield_scale, `T${tier}: 21 × 70 × ${sk.bb.shield_scale}`);
approx(bar.timeLeft, 15, `T${tier}: 15 s`, 0.01);
// afterwards the trait heals again; the barrier lasts 15 s from its last gain
h.runUntil(() => hitsBy(h, u).some((c) => !c.dmg.isSkill), 3);
assert.ok(u.hp > hp0, `T${tier}: the trait heals again`);
h.run(15);
assert.equal(u.findBuff('skill:zuole:barrier3'), null, `T${tier}: gone 15 s later`);
done(h);
}
// the cap: 2× max HP at each gain; each gain restarts the 15 s
const { h, u } = field({ tier: 6, elite: true, skill: 2 });
noSp(h, u);
h.b.addBuff(u, { key: 'skill:zuole:barrier3', shield: u.s.maxHp * 1.99, duration: 3, data: { createdAt: 0 } });
u.skill.charges = 1;
h.spawn('enemy_dummy', { pos: [10, 6] });
h.step();
const b = u.findBuff('skill:zuole:barrier3');
approx(b.shield, u.s.maxHp * 2, 'capped at 2× max HP');
approx(b.timeLeft, 15, 'restarted', 0.01);
done(h);
});
test('T1 秉烛照影 (坚忍): ASPD up to +50 and SP recovery up to +2/s, linear in the HP lost, full at 70 % lost — SBL-X stage 3: +70 / +2.3 at 50 % lost (stage 1: unchanged)', () => {
for (const f of [[5, false, null], [6, true, null], [5, true, SBLX], [6, true, SBLX], [6, true, SBLY]]) {
const [tier, elite, mod] = f;
const x3 = tier === 6 && mod === SBLX;
const [as, sp, full] = x3 ? [70, 2.3, 0.5] : [50, 2, 0.3];
const { h, u } = field({ tier, elite, mod, skill: 2 });
const t0 = u.def.raw.talents.find((t) => t.index === 0).bb;
assert.deepEqual([t0.min_attack_speed, t0.min_sp_recovery_per_sec, t0.min_hp_ratio], [as, sp, full], label(f));
assert.deepEqual([u.s.aspd, u.s.spRecovery], [100, 1], `${label(f)}: full HP`);
for (const ratio of [0.8, 0.6, full, 0.1]) {
u.hp = u.s.maxHp * ratio;
h.step();
const k = Math.min(1, (1 - ratio) / (1 - full));
approx(u.s.aspd, 100 + as * k, `${label(f)}: ASPD at ${ratio * 100} %`);
approx(u.s.spRecovery, 1 + sp * k, `${label(f)}: SP recovery at ${ratio * 100} %`);
}
u.hp = u.s.maxHp;
h.step();
assert.deepEqual([u.s.aspd, u.s.spRecovery], [100, 1], `${label(f)}: back at full HP`);
done(h);
}
});
test('T2 守正自明: every damage he outputs rolls 20 % (70 % below half HP) for 1 SP — SBL-Y stage 3: 80 % below half and the instance ×1.2 on a success (stage 1: unchanged)', () => {
for (const f of [[5, false, null], [5, true, SBLY], [6, true, SBLY]]) {
const [tier, elite, mod] = f;
const y3 = tier === 6 && mod === SBLY;
const { h, u } = field({ tier, elite, mod, skill: 1, seed: 17 });
const t1 = u.def.raw.talents.find((t) => t.index === 1).bb;
assert.deepEqual([t1.prob_1, t1.prob_2, t1.hp_ratio, t1.sp, t1.atk_scale ?? 1], [0.2, y3 ? 0.8 : 0.7, 0.5, 1, y3 ? 1.2 : 1], label(f));
const e = h.spawn('enemy_dummy', { pos: [10, 6] });
u.skill.spCostMul = 1e5; // the skill never fills: every success is an spGain of its own
for (const [ratio, prob] of [[0.9, 0.2], [0.3, y3 ? 0.8 : 0.7]]) {
let gains = 0, rolls = 0, boosted = 0;
const offG = h.b.on('spGain', (c) => { if (c.unit === u && c.reason === 'talent') gains++; });
const offD = h.b.on('damaged', (c) => {
if (c.source !== u || c.target !== e) return;
rolls++;
if (Math.abs(c.amount - u.s.atk * 1.2) < 1e-6) boosted++;
u.hp = u.s.maxHp * ratio; // keep the HP band
});
u.hp = u.s.maxHp * ratio;
h.run(150);
h.b.off(offG);
h.b.off(offD);
const rate = gains / rolls;
assert.ok(Math.abs(rate - prob) < 0.08, `${label(f)} at ${ratio * 100} %: ${gains} / ${rolls} ≈ ${prob}`);
assert.equal(boosted, y3 ? gains : 0, `${label(f)}: ×1.2 on each success`);
}
done(h);
}
});
test('SBL-X (stages 1 and 3): 25 % 庇护 below 50 % HP — physical and arts, not true; none at or above 50 %; after 行险 the 庇护 comes back behind the barrier (it cuts only what passes it)', () => {
for (const tier of [5, 6]) {
const { h, u } = field({ tier, elite: true, mod: SBLX, skill: 1 });
const t0 = u.def.raw.talents.find((t) => t.index === 0).bb;
assert.deepEqual([t0.hp_ratio, t0.damage_resistance], [0.5, 0.25], `T${tier}`);
noSp(h, u);
const e = h.spawn('enemy_dummy', { pos: [10, 6] });
const take = (amount, type) => { const hp = u.hp; h.b.dealDamage(e, u, { amount, type }); return hp - u.hp; };
u.hp = u.s.maxHp * 0.6;
approx(take(300, 'true'), 300, `T${tier}: above 50 %: nothing`);
u.hp = u.s.maxHp * 0.4;
approx(take(1000, 'phys'), Math.max(1000 - u.s.def, 50) * 0.75, `T${tier}: physical ×0.75`);
approx(take(400, 'arts'), 400 * 0.75, `T${tier}: arts ×0.75`);
approx(take(400, 'true'), 400, `T${tier}: true untouched`);
// 行险: 庇护 reset for 0.1 s, then judged again — after the barrier
u.hp = u.s.maxHp * 0.8;
let hpAtStart = null;
h.b.on('skillStart', (c) => { if (c.unit === u && hpAtStart == null) hpAtStart = u.hp; });
u.skill.charges = 1;
assert.ok(h.runUntil(() => u.skill.active, 3));
approx(hpAtStart, u.s.maxHp * 0.4, `T${tier}: −50 %`);
const bar = u.findBuff('skill:zuole:barrier2');
const B = bar.shield;
approx(take(1000, 'true'), 0, `T${tier}: inside 0.1 s the barrier takes it whole`);
approx(bar.shield, B - 1000, `T${tier}: no 庇护 inside the 0.1 s`);
h.run(0.2);
bar.shield = 500;
const hp = u.hp;
take(1300, 'arts');
approx(bar.shield, 0, `T${tier}: the barrier took 500 of the full 1300`);
approx(hp - u.hp, 800 * 0.75, `T${tier}: 庇护 cut the 800 that passed`);
done(h);
}
// without the module: no 庇护
const { h, u } = field({ tier: 6, elite: true, mod: SBLY, skill: 1 });
const e = h.spawn('enemy_dummy', { pos: [10, 6] });
u.hp = u.s.maxHp * 0.3;
const hp = u.hp;
h.b.dealDamage(e, u, { amount: 400, type: 'arts' });
approx(hp - u.hp, 400, 'SBL-Y: no 庇护');
done(h);
});
test('SBL-Y (stages 1 and 3): knocked out ⇒ he stays and heals 30 % max HP, once per deployment (again after a redeploy); not while a 不死 holds him; none without the module', () => {
for (const tier of [5, 6]) {
const { h, u } = field({ tier, elite: true, mod: SBLY, skill: 1 });
assert.deepEqual(u.def.raw.trait.bb, { value: 70, hp_ratio: 0.3 }, `T${tier}`);
const e = h.spawn('enemy_dummy', { pos: [10, 6] });
h.b.dealDamage(e, u, { amount: 1e6, type: 'true' });
assert.ok(u.alive, `T${tier}: not withdrawn`);
approx(u.hp, u.s.maxHp * 0.3, `T${tier}: 30 % max HP`);
h.b.dealDamage(e, u, { amount: 1e6, type: 'true' });
assert.ok(!u.alive, `T${tier}: once per deployment`);
h.b.redeploy(u);
h.step();
h.b.dealDamage(e, u, { amount: 1e6, type: 'true' });
assert.ok(u.alive, `T${tier}: again after the redeploy`);
// a running 不死 (坚固维式重锤's window) keeps him up instead
h.b.redeploy(u);
const r = field({ tier, elite: true, mod: SBLY, skill: 1 });
r.u.mem.undyingAt = `${r.u.deploySeq}:0`;
r.u.mem.undyingUntil = r.h.b.time + 5;
const hammer = r.h.b.on('fatal', (c) => { if (c.unit === r.u && !c.prevented) c.prevented = true; }, { priority: -99 });
const f = r.h.spawn('enemy_dummy', { pos: [10, 6] });
r.h.b.dealDamage(f, r.u, { amount: 1e6, type: 'true' });
assert.ok(r.u.alive && r.u.hp < 2, `T${tier}: held by the 不死, the module untouched`);
r.h.b.off(hammer);
r.u.mem.undyingUntil = 0;
r.h.b.dealDamage(f, r.u, { amount: 1e6, type: 'true' });
assert.ok(r.u.alive, `T${tier}: the module still there afterwards`);
approx(r.u.hp, r.u.s.maxHp * 0.3, `T${tier}: 30 % max HP`);
done(h);
done(r.h);
}
for (const mod of [null, SBLX]) {
const { h, u } = field({ tier: 6, elite: true, mod, skill: 1 });
const e = h.spawn('enemy_dummy', { pos: [10, 6] });
h.b.dealDamage(e, u, { amount: 1e6, type: 'true' });
assert.ok(!u.alive, `${mod ?? 'no module'}: withdrawn`);
done(h);
}
});
+2 -2
View File
@@ -35,11 +35,11 @@ test('slots: two per tier (5, 6), each with its elite twin', () => {
assert.equal(diyTokenOwner(SIEGE, chess.chess_char_6_diy1_b.status), 'char_112_siege@2/60/7/3');
});
test('diyPool: tier 5 = 15 prototypes + 72 owned 6★, tier 6 = 9 + 72; no preset, no collab; with the kit registry only kitted operators', () => {
test('diyPool: tier 5 = 15 prototypes + 71 owned 6★, tier 6 = 9 + 71; no preset, no collab; with the kit registry only kitted operators', () => {
const p5 = diyPool(5, { data: DATA }), p6 = diyPool(6, { data: DATA });
assert.deepEqual(p5, [...RESERVES5, ...ELITES, ...ownedPool]);
assert.deepEqual(p6, [...ELITES, ...ownedPool]);
assert.deepEqual([p5.length, p6.length], [87, 81]);
assert.deepEqual([p5.length, p6.length], [86, 80]);
const roster = new Set(Object.values(chess).map((c) => c.charId).filter(Boolean));
for (const id of p5) assert.ok(!roster.has(id) && !COLLAB.includes(id), id);
assert.deepEqual(diyPool(4, { data: DATA }), [], 'no 自选 slot at tier 4');
+1 -1
View File
@@ -19,7 +19,7 @@ through.
| `fields` | 22 battles: every Final Assault / Hidden Core leader on a pair and a solo template (shared pool, 200 s cap) and the 联防 field with 1 and 2 helpers on its own map (carried HP / SP, a knocked-out operator, two leakers' enemies) |
| `matches` | 18 matches to the end in virtual time: 16 bot-only (solo 标准 / 险境 / 绝境 / 终极 × 2 seeds, co-op 2 / 3 / 4, one server-run combat match, two runs boosted to the Hidden Core), one co-op match whose human seat (AI 托管, offline) does not own 7 NORMAL chess — they fight as their 补位 stand-ins (its digest lists them per round, `standIns`) — and one whose human seat slots 自选 picks (推进之王 and prototypes) with its 调度中心 at level 5 from the first prep: its own shop draws them and its AI fields 推进之王 (its digest lists per round the 自选 shop draws and the fielded pieces, `diy`) |
| `standins` | 10 battles: every NORMAL chess record (normal and elite, 110) fielded as its 补位 stand-in (`standIn: true`: the stand-in's body, backup skill / module and kit — all 17 stand-ins, every skill a chess names for them), 12 per battle by strength band, laid out by the stand-in's position on a real stage, against the round's real wave three times over plus 8 ground enemy kinds (melee stand-ins always meet an enemy), an item each, bonds from the board |
| `diy` | 6 battles of 自选 pieces (a DIY slot with its `diy` pick, `shared/diy.js`): every owned 6★ with an operator kit (`kits/index.js OPERATOR_KITS` — 推进之王 so far) in each form of tiers 5 and 6 (normal; elite with no module and with each module) under each skill, then every prototype pick with a kit at its locked selection, normal and elite, 12 per battle against the round's real wave; a new operator kit adds its scenarios |
| `diy` | battles of 自选 pieces (a DIY slot with its `diy` pick, `shared/diy.js`): every owned 6★ with an operator kit (`kits/index.js OPERATOR_KITS`) in each form of tiers 5 and 6 (normal; elite with no module and with each module) under each skill, then every prototype pick with a kit at its locked selection, normal and elite, 12 per battle against the round's real wave; a new operator kit adds its scenarios (the battle count grows with the kits) |
Battles go through the production BattleSpec path (`server/sim/spec.js`, as browsers and the server's headless fields
run them); matches construct `Match` directly with a `VirtualScheduler`. Every option is explicit, so a change to a
+1987 -100
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -95,7 +95,7 @@ test('data/i18n/en.json: shape, the 盟约 season of the EN build, coverage and
assert.equal(OVERLAY.version, 1);
assert.equal(OVERLAY.lang, 'en');
assert.equal(OVERLAY.meta.source.season, 'act2autochess', 'built from an EN client that has 卫戍协议:盟约 下半');
// the 自选 data (0.2.0) brings 72 owned 6★, some of them (and some newer modules) not in the EN client yet: their
// the 自选 data (0.2.0) brings 71 owned 6★, some of them (and some newer modules) not in the EN client yet: their
// names, skills, talents and modules stay Chinese — measured 97.6 % in all, skills 95.9 / talents 95.1 % (2026-10-05)
assert.ok(OVERLAY.meta.totals.pct >= 97, `coverage ${OVERLAY.meta.totals.pct} %`);
for (const kind of ['operators', 'skills', 'talents', 'modules', 'traits', 'enemies', 'bonds', 'items', 'effects', 'bands', 'garrisons', 'stages', 'tokens', 'choices', 'config']) {
+55
View File
@@ -0,0 +1,55 @@
// test/sim/feedback5-hpdamage-hook.test.js — the `hpDamage` hook of damage.js dealDamage (0.2.0, the 自选 operator kits):
// what passed the shields of a damage instance, before the HP loss; a handler may only lower it — the 伤判效果 that act
// after a barrier (煌's 紧急除颤 HP floor, 左乐's 庇护 re-applied behind his 行险 barrier). A 流失 (Battle.loseHp) never
// reaches it, a hit the shields absorb whole neither. docs/SIM.md §4 / §5.
// Run: node --test test/sim/feedback5-hpdamage-hook.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
function arena() {
const h = makeBattle({
defs: { enemies: { enemy_dummy: enemyRec({ key: 'enemy_dummy', hp: 1e6, speed: 0, mass: 0 }) } },
units: [{ uid: 1, chessId: 'chess_char_1_02_a', row: 10, col: 5 }], timeLimit: 60, autoFinish: false,
});
h.step();
const u = h.unit(1), e = h.spawn('enemy_dummy', { pos: [10, 8] });
return { h, u, e };
}
test('hpDamage: after the shields, before the HP loss — a handler lowers what reaches the HP; a raise is ignored', () => {
const { h, u, e } = arena();
const seen = [];
const off = h.b.on('hpDamage', (c) => { if (c.target === u) { seen.push([c.amount, c.source, c.dmg.type]); c.amount = Math.min(c.amount, 100); } });
h.b.addBuff(u, { key: 'test:shield', shield: 300 });
const hp = u.hp;
const dealt = h.b.dealDamage(e, u, { amount: 1000, type: 'true' });
assert.deepEqual(seen, [[700, e, 'true']], 'the 300 shield first, then the hook sees the 700 that passed');
assert.equal(u.findBuff('test:shield'), null, 'the shield was spent');
assert.deepEqual([hp - u.hp, dealt], [100, 100], 'HP loss and the returned amount follow the lowered value');
h.b.off(off);
h.b.on('hpDamage', (c) => { if (c.target === u) c.amount = 1e9; });
const hp2 = u.hp;
h.b.dealDamage(e, u, { amount: 50, type: 'true' });
assert.equal(hp2 - u.hp, 50, 'a raise is ignored');
checkInvariants(h.b);
});
test('hpDamage: no call for a 流失 nor for a hit the shields take whole; without a handler nothing changes', () => {
const { h, u, e } = arena();
let n = 0;
h.b.on('hpDamage', (c) => { if (c.target === u) n++; });
h.b.loseHp(u, 100, { source: e });
assert.equal(n, 0, 'a 流失 skips it (no 伤判效果)');
h.b.addBuff(u, { key: 'test:shield', shield: 500 });
h.b.dealDamage(e, u, { amount: 200, type: 'true' });
assert.equal(n, 0, 'absorbed whole: nothing reaches the HP');
h.b.dealDamage(e, u, { amount: 400, type: 'true' });
assert.equal(n, 1, 'the 100 that passed');
const r = arena();
const hp = r.u.hp;
r.h.b.dealDamage(r.e, r.u, { amount: 250, type: 'true' });
assert.equal(hp - r.u.hp, 250, 'no handler: the plain pipeline');
checkInvariants(h.b);
});
+84
View File
@@ -0,0 +1,84 @@
// test/sim/feedback5-o8-engine.test.js — two additive engine rules the 0.2.0 自选 kits of 夜莺 and 嵯峨 need:
// - a 屏障 of one damage type (buff `shieldType`, damage.js absorbShields): 夜莺 S2 法术护盾 "能吸收…法术伤害"; a shield
// without a type absorbs every type (PRTS 术语释义 屏障 "若无特殊说明,屏障可吸收全种类伤害"); older shields first;
// - a profile's own target exclusion (`skipEnemy(e)`, targeting.js canTargetEnemy): 嵯峨 "不攻击重伤单位" — no normal
// attack on such an enemy, not as a block target, and the DEFAULT trigger condition ignores it.
// Run: node --test test/sim/feedback5-o8-engine.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, enemyRec, chessRec, checkInvariants } from '../helpers/battleHarness.js';
import { canTargetEnemy } from '../../server/sim/targeting.js';
const ENEMIES = { enemy_dummy: enemyRec({ key: 'enemy_dummy', hp: 1e9, speed: 0, mass: 0 }) };
const CHESS = { test_guard_a: chessRec({ id: 'test_guard_a', profession: 'WARRIOR', stats: { maxHp: 10000, atk: 300, def: 0, res: 0 }, skill: { duration: 5, spCost: 1, initSp: 0, trigger: { rule: 'DEFAULT' } } }) };
function field(kits = undefined) {
const h = makeBattle({
defs: { enemies: ENEMIES, chess: CHESS }, timeLimit: 600, autoFinish: false, seed: 3, kits,
hooks: ['damaged', 'skillStart'], captureNoisy: true,
units: [{ uid: 1, chessId: 'test_guard_a', row: 10, col: 5 }],
});
h.step();
return { h, u: h.unit(1) };
}
test('typed shield: an arts 屏障 absorbs arts damage only; an untyped one every type; the older shield is spent first', () => {
const { h, u } = field();
h.b.addBuff(u, { key: 'test:artsBarrier', shield: 100, shieldType: 'arts', duration: 30 });
for (const type of ['phys', 'true', 'elemental']) {
const hp = u.hp;
h.b.dealDamage(null, u, { amount: 10, type, canDodge: false });
assert.equal(u.hp, hp - 10, `${type} passes the arts barrier`);
}
assert.equal(u.findBuff('test:artsBarrier').shield, 100);
assert.equal(u.s.shield, 100, 'it counts in the shield bar');
let hp = u.hp;
h.b.dealDamage(null, u, { amount: 40, type: 'arts', canDodge: false });
assert.deepEqual([u.hp, u.findBuff('test:artsBarrier').shield], [hp, 60], 'arts absorbed');
// an untyped shield added later: arts goes to the older (typed) one first, physical to the untyped one
h.b.addBuff(u, { key: 'test:barrier', shield: 50, duration: 30 });
h.b.dealDamage(null, u, { amount: 30, type: 'arts', canDodge: false });
assert.deepEqual([u.findBuff('test:artsBarrier').shield, u.findBuff('test:barrier').shield], [30, 50], 'older first');
h.b.dealDamage(null, u, { amount: 20, type: 'phys', canDodge: false });
assert.deepEqual([u.findBuff('test:artsBarrier').shield, u.findBuff('test:barrier').shield], [30, 30], 'physical: the untyped one only');
hp = u.hp;
h.b.dealDamage(null, u, { amount: 100, type: 'arts', canDodge: false });
assert.equal(u.findBuff('test:artsBarrier'), null, 'spent and removed');
assert.equal(u.findBuff('test:barrier'), null, 'the rest on the untyped one, spent too');
assert.equal(u.hp, hp - 40, '100 − 30 − 30 reaches HP');
// a typed hit-negating barrier
h.b.addBuff(u, { key: 'test:physBlock', shieldHits: 1, shieldType: 'phys', duration: 30 });
hp = u.hp;
h.b.dealDamage(null, u, { amount: 25, type: 'arts', canDodge: false });
assert.equal(u.hp, hp - 25, 'arts passes a physical block');
h.b.dealDamage(null, u, { amount: 25, type: 'phys', canDodge: false });
assert.equal(u.hp, hp - 25, 'the physical hit is negated');
assert.equal(u.findBuff('test:physBlock'), null);
checkInvariants(h.b);
});
test('profile skipEnemy: the unit never selects such an enemy — no attack, no block target, no DEFAULT trigger — and other enemies stay its targets', () => {
const skip = new Set();
const { h, u } = field({ test_guard_a: () => ({ skill: { kind: 'duration' }, trait: { skipEnemy: (e) => skip.has(e) } }) });
const e = h.spawn('enemy_dummy', { pos: [10, 6] });
skip.add(e);
assert.equal(canTargetEnemy(u, e, u.profile), false);
assert.equal(canTargetEnemy(u, e, { canHitFly: false }), true, 'another profile still may');
h.run(3);
assert.equal(h.hooksOf('damaged').filter((c) => c.source === u).length, 0, 'never attacked');
assert.equal(u.skill.activations, 0, 'DEFAULT: no target');
// blocked: still not a target
const w = h.spawn('enemy_dummy', { pos: [10, 5] });
skip.add(w);
h.run(1);
assert.ok(u.blocking.includes(w), 'she blocks it');
assert.equal(h.b.blockedTargets(u, u.profile).length, 0, 'not a block target');
assert.equal(h.hooksOf('damaged').filter((c) => c.source === u).length, 0);
// an ordinary enemy is attacked
const o = h.spawn('enemy_dummy', { pos: [10, 6] });
assert.ok(h.runUntil(() => h.hooksOf('damaged').some((c) => c.source === u && c.target === o), 3), 'attacks the other one');
assert.ok(h.hooksOf('damaged').filter((c) => c.source === u).every((c) => c.target === o), 'only that one');
assert.ok(u.skill.activations >= 1, 'DEFAULT fires for it');
checkInvariants(h.b);
});
+62
View File
@@ -0,0 +1,62 @@
// test/sim/palsy-trigger.test.js — the engine hook `palsyTrigger` (server/sim/ai.js enemyAttack): fired when a 麻痹 stack
// interrupts an enemy's normal attack (PRTS 真言 备注 "触发麻痹时指的是敌人的攻击被麻痹打断时"), once per interrupt; a
// handler may keep the stack (`keep`) or kill the enemy. 真言's 噤声限域 (kits/ops/op-mantra.js) is its user.
// Run: node --test test/sim/palsy-trigger.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, chessRec, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
const guard = chessRec({ id: 't_guard', profession: 'WARRIOR', stats: { atk: 0, blockCnt: 2, maxHp: 1e6 }, skill: null });
const shooter = enemyRec({ key: 'enemy_shooter', hp: 1e6, atk: 100, bat: 1, range: 3, speed: 0, mass: 0 });
function field(setup) {
const h = makeBattle({
defs: { chess: { t_guard: guard }, enemies: { enemy_shooter: shooter } },
units: [{ chessId: 't_guard', row: 10, col: 5 }], content: 'none', autoFinish: false, setup,
});
h.step();
return h;
}
test('palsyTrigger: one event per interrupted attack, before the stack is used; no event without 麻痹', () => {
const seen = [];
const h = field((b) => b.on('palsyTrigger', (c) => seen.push({ t: b.time, stacks: c.buff.stacks, enemy: c.enemy })));
const e = h.spawn('enemy_shooter', { pos: [10, 7] });
h.run(3.05);
assert.equal(seen.length, 0, 'no 麻痹, no event');
const a0 = e.stats.attacks;
h.b.applyStatus(e, 'palsy', { value: 2 });
h.run(2.05);
assert.deepEqual(seen.map((x) => [x.enemy, x.stacks]), [[e, 2], [e, 1]], 'each interrupt, the stack count before it is used');
assert.equal(e.stats.attacks, a0, 'both attacks interrupted');
assert.equal(e.findBuff('palsy'), null);
h.run(1.1);
assert.ok(e.stats.attacks > a0, 'attacks again');
checkInvariants(h.b);
});
test('palsyTrigger: `keep` leaves the stack; a handler that kills the enemy ends its turn', () => {
let keepNext = true;
const h = field((b) => b.on('palsyTrigger', (c) => { if (keepNext) { c.keep = true; keepNext = false; } }));
const e = h.spawn('enemy_shooter', { pos: [10, 7] });
h.step();
h.b.applyStatus(e, 'palsy', { value: 1 });
const a0 = e.stats.attacks;
h.run(1.1);
assert.equal(e.findBuff('palsy')?.stacks, 1, 'kept');
assert.equal(e.stats.attacks, a0, 'still interrupted');
h.run(1.05);
assert.equal(e.findBuff('palsy'), null, 'used by the next interrupt');
assert.equal(e.stats.attacks, a0);
// a lethal handler
const h2 = field((b) => b.on('palsyTrigger', (c) => b.kill(c.enemy, null)));
const e2 = h2.spawn('enemy_shooter', { pos: [10, 7] });
h2.step();
h2.b.applyStatus(e2, 'palsy', { value: 3 });
h2.run(1.2);
assert.ok(!e2.alive);
assert.equal(h2.b.errors.length, 0, JSON.stringify(h2.b.errors[0]));
checkInvariants(h.b);
checkInvariants(h2.b);
});
+9 -2
View File
@@ -437,7 +437,7 @@ const TRIGGER_RENAME = { ALWAYS: 'SP_FULL', CUSTOM_RANGE_SEARCH_ENEMY: 'CUSTOM_R
* 溅射范围扩大" — and not a 技能范围 of its own (PRTS 卫戍协议/帮助 技能操作: "拥有技能范围的技能(非攻击距离增加)").
* "攻击范围内…" (an effect on the attack range) does not match.
*/
const ATTACK_RANGE_CHANGE = /攻击(?:范围|距离)(?:与溅射范围)?(?:扩大|改变|缩小|缩短|加长|增加|\+)/;
const ATTACK_RANGE_CHANGE = /攻击(?:范围|距离)(?:与溅射范围)?(?:扩大|增大|改变|改为|缩小|缩短|加长|增加|\+)/;
/**
* A deliberate deviation from the official 技能策略 (DESIGN §21.29): the owner's decision of 2026-10-03 after community
@@ -1080,6 +1080,12 @@ const DIY_EXTRA_PROTOTYPES = Object.freeze({ 5: Object.freeze({ rarity: 4, exclu
* (麒麟R夜刀), mujica (丰川祥子), sees (结城理), laios (玛露西尔). The excel does not exclude them (research 0.2.0 §2.2).
*/
const DIY_EXCLUDED_TEAMS = Object.freeze(['rainbow', 'action4', 'mujica', 'sees', 'laios']);
/**
* …and every 6★ of a 联动寻访, by the prefix of its character_table `displayNumber` (the collab series: MH Monster Hunter —
* 麒麟R夜刀 MH02, 焰狐龙梓兰 MH05, whose team reserve6 names no collab —, RS Rainbow Six, AM Ave Mujica, PS Persona, DD
* Dungeon Meshi); every other prefix in the pool is a faction (LM, NM, RE, RL, …).
*/
const DIY_EXCLUDED_NUMBER_PREFIXES = Object.freeze(['MH', 'RS', 'AM', 'PS', 'DD']);
/**
* The skill a prototype carries in a 自选 slot when no 补位 row of the slot's tier names it (only 预备干员-医疗 at tier 5:
@@ -1269,7 +1275,8 @@ function buildBackups(ctx, chess) {
return id.startsWith('char_') && requirements.has(ch.rarity) && ch.profession !== 'TOKEN' && ch.profession !== 'TRAP'
&& !ch.isNotObtainable && !roster.has(id);
}).sort(naturalCmp);
const excluded = legal6.filter((id) => teamsOf(charTable[id]).some((t) => excludedTeams.has(t)));
const collabNumber = (ch) => DIY_EXCLUDED_NUMBER_PREFIXES.some((p) => new RegExp(`^${p}\\d`).test(ch.displayNumber || ''));
const excluded = legal6.filter((id) => teamsOf(charTable[id]).some((t) => excludedTeams.has(t)) || collabNumber(charTable[id]));
for (const t of DIY_EXCLUDED_TEAMS) if (!excluded.some((id) => teamsOf(charTable[id]).includes(t))) warn(`DIY_EXCLUDED_TEAMS: no owned-6★ pick of team ${t}`);
const ownedPool = legal6.filter((id) => !excluded.includes(id));
for (const id of ownedPool) for (const st of diyStatuses.values()) addNeed(id, st);