feedback5: merge W5H — 自选 data (72 owned 6★ without the 6 collab operators, every DIY form, summons, prototypes' locked skills), shared/diy.js, the sim fields DIY picks, OPERATOR_KIT_FILES with 推进之王 as the first operator kit, golden family diy, the art plan (--add-only)

Conflicts: tools/golden.mjs / test/golden/README.md take both new families (standins from W5G, diy from W5H); DATA.md / SIM.md keep both texts. data/ equals an offline build of the merged tools/build-data.mjs. Golden regenerated on the merged code: against feedback5 nothing moved, diy.json is new; against fb5-diy the W5G / W5J / W5L scenarios moved, and diy-004 / diy-006 (the prototype battles with Touch, changed by W5L) — diy-001/002/003/005 identical.
This commit is contained in:
sganggs committed 2026-10-05 22:52:24 +08:00
commit 1006254beb
32 files changed
+2229 -174

No files matched your search

+1 -1
View File
@@ -1 +1 @@
{"version":1,"hash":"a7a431fb0726","generator":"tools/fetch-assets.mjs","stats":{"files":4016,"bytes":284530642,"chars":138,"charsWithBack":135,"enemies":252,"enemiesWithSpine":251,"tokens":20,"tokensWithSpine":13,"spineModels":529,"bonds":23,"items":59,"bands":40,"skills":317,"ui":601,"sfxUnits":382},"chars":{"char_1012_skadi2":{"avatar":"/assets/char/avatar/char_1012_skadi2.png","avatarE2":"/assets/char/avatar/char_1012_skadi2_2.png","portrait":"/assets/char/portrait/char_1012_skadi2_1.png","portraitE2":"/assets/char/portrait/char_1012_skadi2_2.png","spine":{"front":{"skel":"/assets/spine/op/char_1012_skadi2/front/char_1012_skadi2.skel","atlas":"/assets/spine/op/char_1012_skadi2/front/char_1012_skadi2.atlas","textures":["/assets/spine/op/char_1012_skadi2/front/char_1012_skadi2.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Skill_1_Loop","end":null,"via":"skill"},"attackDown":null,"skill":{"begin":"Skill_3_Begin","loop":"Skill_3_Loop","end":null,"index":2,"idle":null},"die":"Die","move":null,"stun":{"begin":"Stun_Begin","loop":"Stun","end":null},"skills":{"0":{"begin":"Skill_1_Begin","loop":"Skill_1_Loop","end":null,"index":0,"idle":null},"1":{"begin":"Skill_2_Begin","loop":"Skill_2_Loop","end":null,"index":1,"idle":null},"2":{"begin":"Skill_3_Begin","loop":"Skill_3_Loop","end":null,"index":2,"idle":null}}},"animations":{"Default":0,"Die":1,"Idle":8.767,"Skill_1_Begin":3.2,"Skill_1_Loop":8.767,"Skill_2_Begin":3.933,"Skill_2_Loop":3.333,"Skill_3_Begin":4.4,"Skill_3_Loop":8.767,"Start":1,"Stun":2,"Stun_Begin":1},"events":["OnAttack","OnStart"],"hits":{},"bounds":{"x":-190.586,"y":-25.785,"width":393.516,"height":443.426}}}},"char_1014_nearl2":{"avatar":"/assets/char/avatar/char_1014_nearl2.png","avatarE2":"/assets/char/avatar/char_1014_nearl2_2.png","portrait":"/assets/char/portrait/char_1014_nearl2_1.png","portraitE2":"/assets/char/portrait/char_1014_nearl2_2.png","spine":{"front":{"skel":"/assets/spine/op/char_1014_nearl2/front/char_1014_nearl2.skel","atlas":"/assets/spine/op/char_1014_nearl2/front/char_1014_nearl2.atlas","textures":["/assets/spine/op/char_1014_nearl2/front/char_1014_nearl2.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":"Attack_Begin","loop":"Attack_Loop","end":"Attack_End"},"attackDown":null,"skill":{"begin":null,"loop":"Skill_3","end":null,"index":2,"idle":"Skill_3_Idle"},"die":"Die","move":null,"stun":null,"skills":{"0":{"begin":"Skill_1_Begin","loop":"Skill_1_Loop","end":null,"index":0,"idle":"Skill_1_Idle"},"1":{"begin":null,"loop":"Skill_2_Die","end":null,"index":1,"idle":null},"2":{"begin":null,"loop":"Skill_3","end":null,"index":2,"idle":"Skill_3_Idle"}}},"animations":{"Attack_Begin":0.1,"Attack_End":0.167,"Attack_Loop":1.5,"Default":0,"Die":1,"Idle":2.667,"Skill_3":1.5,"Skill_1_Begin":0.1,"Skill_1_Idle":1,"Skill_1_Loop":1.5,"Skill_1_Loop_Down":1.5,"Skill_2_Die":1.167,"Skill_3_Down":1.5,"Skill_3_Idle":1.333,"Start":1},"events":["OnAttack","OnStart"],"hits":{"Attack_Loop":[0.433],"Skill_3":[0.5],"Skill_1_Loop":[0.467],"Skill_1_Loop_Down":[0.467],"Skill_3_Down":[0.5]},"bounds":{"x":-400.77,"y":-461.007,"width":720.545,"height":872.242}},"back":{"skel":"/assets/spine/op/char_1014_nearl2/back/char_1014_nearl2.skel","atlas":"/assets/spine/op/char_1014_nearl2/back/char_1014_nearl2.atlas","textures":["/assets/spine/op/char_1014_nearl2/back/char_1014_nearl2.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":"Attack_Begin","loop":"Attack_Loop","end":"Attack_End"},"attackDown":null,"skill":{"begin":null,"loop":"Skill_3","end":null,"index":2,"idle":"Skill_3_Idle"},"die":null,"move":null,"stun":null,"skills":{"0":{"begin":"Skill_1_Begin","loop":"Skill_1_Loop","end":null,"index":0,"idle":"Skill_1_Idle"},"1":{"begin":"Attack_Begin","loop":"Attack_Loop","end":"Attack_End","via":"attack","index":1,"idle":null},"2":{"begin":null,"loop":"Skill_3","end":null,"index":2,"idle":"Skill_3_Idle"}}},"animations":{"Attack_Begin":0.1,"Attack_End":0.167,"Attack_Loop":1.5,"Default":0,"Idle":2.667,"Skill_1_Begin":0.1,"Skill_1_Idle":1,"Skill_1_Loop":1.5,"Skill_3":1.5,"Skill_3_Idle":1.333,"Start":1},"events":["OnAttack","OnStart"],"hits":{"Attack_Loop":[0.433],"Skill_1_Loop":[0.467],"Skill_3":[0.5]},"bounds":null}}},"char_1016_agoat2":{"avatar":"/assets/char/avatar/char_1016_agoat2.png","avatarE2":"/assets/char/avatar/char_1016_agoat2_2.png","portrait":"/assets/char/portrait/char_1016_agoat2_1.png","portraitE2":"/assets/char/portrait/char_1016_agoat2_2.png","spine":{"front":{"skel":"/assets/spine/op/char_1016_agoat2/front/char_1016_agoat2.skel","atlas":"/assets/spine/op/char_1016_agoat2/front/char_1016_agoat2.atlas","textures":["/assets/spine/op/char_1016_agoat2/front/char_1016_agoat2.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Attack","end":null},"attackDown":null,"skill":{"begin":"Skill_3_Begin","loop":"Skill_3_Loop","end":"Skill_3_End","index":2,"idle":"Skill_3_Idle"},"diLine truncated {"version":1,"hash":"c259e54bcb92","generator":"tools/fetch-assets.mjs","stats":{"files":5310,"bytes":415518495,"chars":210,"charsWithBack":207,"enemies":252,"enemiesWithSpine":251,"tokens":58,"tokensWithSpine":13,"spineModels":673,"bonds":23,"items":59,"bands":40,"skills":525,"modules":131,"ui":602,"sfxUnits":484},"chars":{"char_003_kalts":{"avatar":"/assets/char/avatar/char_003_kalts.png","avatarE2":"/assets/char/avatar/char_003_kalts_2.png","portrait":"/assets/char/portrait/char_003_kalts_1.png","portraitE2":"/assets/char/portrait/char_003_kalts_2.png","spine":{"front":{"skel":"/assets/spine/op/char_003_kalts/front/char_003_kalts.skel","atlas":"/assets/spine/op/char_003_kalts/front/char_003_kalts.atlas","textures":["/assets/spine/op/char_003_kalts/front/char_003_kalts.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Attack","end":null},"attackDown":null,"skill":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":0,"idle":null},"die":"Die","move":null,"stun":null,"skills":{"0":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":0,"idle":null},"1":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":1,"idle":null},"2":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":2,"idle":null}}},"animations":{"Attack":1,"Default":0,"Die":1,"Idle":9,"Start":1},"events":["OnAttack","OnStart"],"hits":{"Attack":[0.433]},"bounds":{"x":-227.579,"y":-138.244,"width":532.04,"height":551.479}},"back":{"skel":"/assets/spine/op/char_003_kalts/back/char_003_kalts.skel","atlas":"/assets/spine/op/char_003_kalts/back/char_003_kalts.atlas","textures":["/assets/spine/op/char_003_kalts/back/char_003_kalts.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Attack","end":null},"attackDown":null,"skill":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":0,"idle":null},"die":null,"move":null,"stun":null,"skills":{"0":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":0,"idle":null},"1":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":1,"idle":null},"2":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":2,"idle":null}}},"animations":{"Attack":1,"Default":0,"Idle":9,"Start":1},"events":["OnAttack","OnStart"],"hits":{"Attack":[0.433]},"bounds":{"x":410.785,"y":-2.26,"width":531.785,"height":416.492}}}},"char_010_chen":{"avatar":"/assets/char/avatar/char_010_chen.png","avatarE2":"/assets/char/avatar/char_010_chen_2.png","portrait":"/assets/char/portrait/char_010_chen_1.png","portraitE2":"/assets/char/portrait/char_010_chen_2.png","spine":{"front":{"skel":"/assets/spine/op/char_010_chen/front/char_010_chen.skel","atlas":"/assets/spine/op/char_010_chen/front/char_010_chen.atlas","textures":["/assets/spine/op/char_010_chen/front/char_010_chen.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Attack","end":null},"attackDown":null,"skill":{"begin":null,"loop":"Skill","end":"Skill_End","index":0,"idle":null},"die":"Die","move":null,"stun":null,"skills":{"0":{"begin":null,"loop":"Skill","end":"Skill_End","index":0,"idle":null},"1":{"begin":null,"loop":"Skill_2","end":null,"index":1,"idle":null},"2":{"begin":null,"loop":"Skill_3","end":null,"index":2,"idle":null}}},"animations":{"Attack":1.5,"Attack_End":0.6,"Attack_Pre":0.6,"Default":0,"Die":1,"Idle":2,"Skill":1,"Skill_2":1.167,"Skill_3":2.933,"Skill_End":0.6,"Skill_End_2":0.6,"Skill_End_3":0.3,"Start":0.833},"events":["OnAttack","OnStart"],"hits":{"Attack":[0.433,1],"Skill":[0.467],"Skill_2":[0.467],"Skill_3":[0.6,0.8,1,1.2,1.4,1.6,1.8,2,2.2,2.833,2.933]},"bounds":{"x":-322.335,"y":-109.274,"width":663.549,"height":524.806}},"back":{"skel":"/assets/spine/op/char_010_chen/back/char_010_chen.skel","atlas":"/assets/spine/op/char_010_chen/back/char_010_chen.atlas","textures":["/assets/spine/op/char_010_chen/back/char_010_chen.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Attack","end":null},"attackDown":null,"skill":{"begin":null,"loop":"Skill","end":"Skill_End","index":0,"idle":null},"die":null,"move":null,"stun":null,"skills":{"0":{"begin":null,"loop":"Skill","end":"Skill_End","index":0,"idle":null},"1":{"begin":null,"loop":"Skill","end":"Skill_End","index":1,"idle":null},"2":{"begin":null,"loop":"Skill","end":"Skill_End","index":2,"idle":null}}},"animations":{"Attack":1.5,"Attack_End":0.6,"Attack_Pre":0.6,"Default":0,"Idle":2,"Skill":1,"Skill_End":0.6,"Start":0.833},"events":["OnAttack","OnStart"],"hits":{"Attack":[0.433,1],"Skill":[0.467]},"bounds":{"x":-173.082,"y":-13.829,"width":315.887,"height":404.623}}}},"char_017_huang":{"avatar":"/assets/char/avatar/char_017_huang.png","avatarE2":"/assets/char/avatar/char_017_huang_2.png","portrait":"/assets/char/portrait/char_017_huang_1.png","portraitE2":"/assets/char/portrait/char_017_huang_2.png","spine":{"front":{"skel":"/assets/spine/op/char_017_huang/front/char_017_huang.skel","atlas":"/assetLine truncated
+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
+9 -5
View File
@@ -22,6 +22,7 @@ npm run assets # = node tools/vendor.mjs && node tools/fetch-assets.mjs
| `--refresh-index` | Re-download the two upstream indexes: `audio_data.json` and `models_data.json`. | | `--refresh-index` | Re-download the two upstream indexes: `audio_data.json` and `models_data.json`. |
| `--prune` | Delete files under `public/assets/` that the manifest no longer references, for example after a mapping change. Without this flag they are only listed in the report. `public/assets/local/` (written by `tools/local-extract`) is never pruned. Implies `--allow-shrink`. | | `--prune` | Delete files under `public/assets/` that the manifest no longer references, for example after a mapping change. Without this flag they are only listed in the report. `public/assets/local/` (written by `tools/local-extract`) is never pruned. Implies `--allow-shrink`. |
| `--allow-shrink` | Write `data/assets.json` even when it loses entries the current one has (see "The manifest never shrinks by accident" below). | | `--allow-shrink` | Write `data/assets.json` even when it loses entries the current one has (see "The manifest never shrinks by accident" below). |
| `--add-only` | For a checkout whose `public/assets/` and `public/fonts/` are shared with another one (a git worktree with symlinked asset folders): download only the files missing on disk and never re-download, rewrite or delete an existing file — atlases already on disk are left as they are, the fonts are not rebuilt (the manifest keeps its current `fonts`). Not with `--prune` / `--force`. |
| `--local-spines` | Rewrite `tools/assets/local-enemy-spines.json` (the metadata of the enemy models only the local client has, see "Enemy aliases") from the models `tools/local-extract/extract.py` extracted to `public/assets/local/spine/enemy/`. Run it after a game update changed them; without it the committed file is used and a differing extraction only gets a warning. | | `--local-spines` | Rewrite `tools/assets/local-enemy-spines.json` (the metadata of the enemy models only the local client has, see "Enemy aliases") from the models `tools/local-extract/extract.py` extracted to `public/assets/local/spine/enemy/`. Run it after a game update changed them; without it the committed file is used and a differing extraction only gets a warning. |
**The manifest never shrinks by accident.** An entry whose files are missing on this machine is left out of a rebuilt **The manifest never shrinks by accident.** An entry whose files are missing on this machine is left out of a rebuilt
@@ -71,7 +72,8 @@ The research JSONs in `docs/research/` (03, 05, 07) define **which** ids are nee
|---|---|---| |---|---|---|
| Operator avatars, 180×180. Base, plus E2 when it exists. | yuanyan3060/ArknightsGameResource `avatar/` | `char/avatar/{charId}.png`, `char/avatar/{charId}_2.png` | | Operator avatars, 180×180. Base, plus E2 when it exists. | yuanyan3060/ArknightsGameResource `avatar/` | `char/avatar/{charId}.png`, `char/avatar/{charId}_2.png` |
| Operator half-body portraits, 180×360 | yuanyan `portrait/` | `char/portrait/{charId}_1.png`, `_2.png` | | Operator half-body portraits, 180×360 | yuanyan `portrait/` | `char/portrait/{charId}_1.png`, `_2.png` |
| Default-skill icons, including backup operators' skills | yuanyan `skill/` | `skill/{iconId sanitized}.png` | | Skill icons: every skill index of every planned operator (DESIGN §16 loadouts, 自选 picks) | yuanyan `skill/` | `skill/{iconId sanitized}.png` |
| Module type icons: the `typeIcon` of every module of `data/chess.json` and `data/backups.json` (the 干员调配 / 自选 module tiles when the local-client art lacks them) | AA2 `cn` `arts/ui/uniequiptype/{typeIcon}.png` (else its lower-case name: `WAH-Y` → `wah-y.png`) | `module/{typeIcon}.png` |
| Enemy icons | yuanyan `enemy/`. Fallbacks: the handbook id, then the base id. | `enemy/icon/{enemyId}.png` | | Enemy icons | yuanyan `enemy/`. Fallbacks: the handbook id, then the base id. | `enemy/icon/{enemyId}.png` |
| Token avatars | yuanyan `avatar/` | `token/avatar/{tokenId}.png` | | Token avatars | yuanyan `avatar/` | `token/avatar/{tokenId}.png` |
| Bond icons (the real autochess glyphs) | ArknightsAssets2 `cn` `ui/autochess/[uc]autochesscommon/arts/bondicon/`. Fallback: the camp logo. | `bond/{bondId}.png` | | Bond icons (the real autochess glyphs) | ArknightsAssets2 `cn` `ui/autochess/[uc]autochesscommon/arts/bondicon/`. Fallback: the camp logo. | `bond/{bondId}.png` |
@@ -92,8 +94,8 @@ The `stem` of a Spine model is the upstream file name. Two examples: `char_107_l
### Id scope ### Id scope
- **Operators:** all 138 pool charIds from `activity_table` (`charShopChessDatas[*].charId ∪ backupCharId`), including hidden chess and backup operators. - **Operators:** all 138 pool charIds from `activity_table` (`charShopChessDatas[*].charId ∪ backupCharId`), including hidden chess and backup operators; plus every unit of `data/backups.json` research 07 does not list — the 72 自选 owned-6★ picks (`diy.ownedPool`, DATA.md §18; the collab operators are not in the data) — planned from 07's URL patterns (`tools/assets/plan.mjs patternOperator`, `tools/fetch-assets.mjs dataExtras`): avatar and portrait (E0–E1 and E2), the default-skin battle Spine Front / Back, the icon and skill sound of each skill, the sub-profession icon. 210 operators in all (207 with a Back model).
- **Tokens:** the 20 pool tokens. - **Tokens:** the 20 pool tokens, and the 38 summons of the 自选 picks (`data/backups.json tokens`) as extra tokens (below): their avatars; their battle Spine exists upstream only as skin variants, which the default locations miss — no Spine yet (the client draws the avatar).
- **Enemies:** 253 ids planned, 252 in the manifest (心烛 has no assets). The set is the union of: - **Enemies:** 253 ids planned, 252 in the manifest (心烛 has no assets). The set is the union of:
- the 07 enemy list; - the 07 enemy list;
- every enemy in the `act1autochess_*` wave, boss and 联防 levels that act2 modes use (from `05-maps.json`; the tutorial is excluded); - every enemy in the `act1autochess_*` wave, boss and 联防 levels that act2 modes use (from `05-maps.json`; the tutorial is excluded);
@@ -104,7 +106,7 @@ The `stem` of a Spine model is the upstream file name. Two examples: `char_107_l
- enemy units spawned by operator kits (research 03 skills/talents). For example, 隐德来希's default S3 summons 心烛 `enemy_5601_entlec` through the talent key `take_extra_enemy_key`. 心烛 has no icon and no Spine in any dump, so it has no manifest entry: it is reported as a miss, and the client must draw a glyph. - enemy units spawned by operator kits (research 03 skills/talents). For example, 隐德来希's default S3 summons 心烛 `enemy_5601_entlec` through the talent key `take_extra_enemy_key`. 心烛 has no icon and no Spine in any dump, so it has no manifest entry: it is reported as a miss, and the client must draw a glyph.
- **Extra tokens:** any `token_*` key of `data/tokens.json` that research does not list gets the default avatar and Spine locations. - **Extra tokens:** any `token_*` key of `data/tokens.json` that research does not list gets the default avatar and Spine locations.
- The non-token summons in that file (`enemy_9012_acloon` 炎佑, `char_605_cmedic`, `char_613_acmedc`) are found under `enemies` and `chars`. - The non-token summons in that file (`enemy_9012_acloon` 炎佑, `char_605_cmedic`, `char_613_acmedc`) are found under `enemies` and `chars`.
- **Skill icons:** the default skill of every chess (`defaultSkillIndex`), plus each backup operator's `skillIndex`. That makes 144 icons. - **Skill icons:** every skill index of every planned operator (the default one first; DESIGN §16 loadouts, the 自选 picks' three skills): 525 icons.
- **UI:** - **UI:**
- every group from `07-assets.json → autochessUi`: rarity, elite and chess-level sprites, the shop panel and cards, HUD, bond board, equip slot, round dialog, band choose, settlement, prepare backdrop; - every group from `07-assets.json → autochessUi`: rarity, elite and chess-level sprites, the shop panel and cards, HUD, bond board, equip slot, round dialog, band choose, settlement, prepare backdrop;
- `arts` (rarity stars, elite icons, the camp logos of pool nations, the loading illustrations used by the act2 modes, battle common sprites, act2 entry backdrops and season logo, item rarity frames); - `arts` (rarity stars, elite icons, the camp logos of pool nations, the loading illustrations used by the act2 modes, battle common sprites, act2 entry backdrops and season logo, item rarity frames);
@@ -136,7 +138,7 @@ All paths are URL paths relative to the site root, for example `/assets/char/ava
hash: 'a1b2c3d4e5f6', // content hash (cache busting) hash: 'a1b2c3d4e5f6', // content hash (cache busting)
generator: 'tools/fetch-assets.mjs', generator: 'tools/fetch-assets.mjs',
stats: { files, bytes, chars, charsWithBack, enemies, enemiesWithSpine, tokens, tokensWithSpine, stats: { files, bytes, chars, charsWithBack, enemies, enemiesWithSpine, tokens, tokensWithSpine,
spineModels, bonds, items, bands, skills, ui, sfxUnits }, spineModels, bonds, items, bands, skills, modules, ui, sfxUnits },
chars: { [charId]: { avatar, avatarE2?, portrait, portraitE2?, spine: { front: Spine, back?: Spine } } }, chars: { [charId]: { avatar, avatarE2?, portrait, portraitE2?, spine: { front: Spine, back?: Spine } } },
enemies: { [enemyId]: { icon, spine?: Spine, spineAliasOf?: enemyId, enemies: { [enemyId]: { icon, spine?: Spine, spineAliasOf?: enemyId,
spineLocal?: { group, skel, atlas, textures, …Spine } } }, spineLocal?: { group, skel, atlas, textures, …Spine } } },
@@ -148,6 +150,8 @@ All paths are URL paths relative to the site root, for example `/assets/char/ava
bands: { [bandId]: url }, bands: { [bandId]: url },
skills: { [iconId]: url }, // iconId = skill_table iconId ?? skillId skills: { [iconId]: url }, // iconId = skill_table iconId ?? skillId
skillsById: { [skillId]: iconId }, skillsById: { [skillId]: iconId },
modules: { [typeIcon]: url }, // module type icon (uniequip typeIcon, e.g. 'sol-x'): public/js/screens/loadout.js
// moduleIconOf after the local-client art
ui: { ['group/key']: url }, // e.g. 'hudPanel/icon_hp', 'shopCard/frame_lv1', 'loading/loading_ac_core'; ui: { ['group/key']: url }, // e.g. 'hudPanel/icon_hp', 'shopCard/frame_lv1', 'loading/loading_ac_core';
// 'emoticon/basic/pic_happy_battle', 'guide/autochess_home_1': the data/local-assets.json // 'emoticon/basic/pic_happy_battle', 'guide/autochess_home_1': the data/local-assets.json
// group + name of the same picture (the client takes the local one first) // group + name of the same picture (the client takes the local one first)
+46 -21
View File
@@ -501,8 +501,9 @@ Glyph legend (`rows`):
`…_eagle1/3` stay listed (`sources:["display"]`, belong to S1/S3). With another skill selected (DESIGN §16) the `…_eagle1/3` stay listed (`sources:["display"]`, belong to S1/S3). With another skill selected (DESIGN §16) the
talent follows that skill's eagle (`simdata loadoutRecord`; `variants[o].bySkill[i].sources`). talent follows that skill's eagle (`simdata loadoutRecord`; `variants[o].bySkill[i].sources`).
2. **DIY chess** (`chess_char_5_diy1/2`, `chess_char_6_diy1/2`, `_a` and `_b` = 8 records): no `charId`, no stats/skill; `visible:false`, 2. **DIY chess** (`chess_char_5_diy1/2`, `chess_char_6_diy1/2`, `_a` and `_b` = 8 records): no `charId`, no stats/skill; `visible:false`,
name placeholder `甄选干员`, not in the shop. Their picks and bond rule are in `backups.json diy` (§18); the gameplay name placeholder `甄选干员`, not in the shop. Their picks and bond rule are in `backups.json diy` (§18); a slot fights
is not implemented yet. only as a 自选 piece — a PlayerBattleInput entry with its `diy` pick (docs/SIM.md §12; shared/diy.js) — and a slot
without a legal pick fields nothing. The picker and the per-player shop are not implemented yet.
3. **Module-less chess**: 蒂比 (`chess_char_2_13`) and 凛御银灰 (`chess_char_5_14`) have no module; their golden 3. **Module-less chess**: 蒂比 (`chess_char_2_13`) and 凛御银灰 (`chess_char_5_14`) have no module; their golden
record has `module:{id:null,active:false}` and no module stat bonus. record has `module:{id:null,active:false}` and no module stat bonus.
4. **Hidden chess (17)** are kept with `visible:false`; several operators exist in two tiers with one hidden 4. **Hidden chess (17)** are kept with `visible:false`; several operators exist in two tiers with one hidden
@@ -562,14 +563,16 @@ 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`, `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`, `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: 17 stand-in units (43 forms), 4 DIY slots, 15 / 9 prototype picks (tier 5 / 6), 78 owned-6★ picks`. `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)`.
## 17. Integrity guarantees (checked by the builder and `test/data.test.js`) ## 17. Integrity guarantees (checked by the builder and `test/data.test.js`)
Every PRESET chess is its own backup and the stand-in builder gives it back field for field (§18); every NORMAL chess Every PRESET chess is its own backup and the stand-in builder gives it back field for field (§18); every NORMAL chess
(both forms) composes into its stand-in with an unlocked backup skill and, on the elite form, its module at that level; (both forms) composes into its stand-in with an unlocked backup skill and, on the elite form, its module at that level;
every DIY prototype pick has a form for both slot statuses; derived DIY bonds exist (`test/backups.test.js` also every DIY prototype pick has a form for both slot statuses and its locked skill / module there; every owned pick has
re-derives the backup fields, the stand-in numbers and the faction ids from the raw tables). both slot forms with its three skills, every module of the character at the elite's stage and a `tokens` variant per
summon; derived DIY bonds exist; no 自选 summon id is a tokens.json record (`test/backups.test.js` also re-derives the
backup fields, the stand-in numbers, the owned pool and the faction ids from the raw tables).
Chess bonds/garrisons/tokens/base/golden ids resolve; talent tokens are in `chess.tokens`; every non-DIY chess has Chess bonds/garrisons/tokens/base/golden ids resolve; talent tokens are in `chess.tokens`; every non-DIY chess has
`skills[]` with exactly one default equal to `skill` (and every skill token listed in `chess.tokens`); golden module `skills[]` with exactly one default equal to `skill` (and every skill token listed in `chess.tokens`); golden module
choices are consistent (one default iff `module.active`, base fields present; composing the default reproduces the choices are consistent (one default iff `module.active`, base fields present; composing the default reproduces the
@@ -589,15 +592,16 @@ included) × allowed entry, and `test/sim/pathing-crosscheck.test.js` re-impleme
whole map for every walkable tile of the normal / 联防 / boss fields of the 8 stages (extended by the road-over-floor whole map for every walkable tile of the normal / 联防 / boss fields of the 8 stages (extended by the road-over-floor
preference; against the pure official algorithm: identical route lengths, never more non-blockable tiles crossed). preference; against the pure official algorithm: identical route lengths, never more non-blockable tiles crossed).
## 18. `backups.json` — 补位 stand-ins and 自选 (DIY) data — `{ units, diy }` ## 18. `backups.json` — 补位 stand-ins and 自选 (DIY) data — `{ units, tokens, diy }`
The data of two official features: **补位** — a NORMAL chess whose operator the player does not own is fielded as its The data of two official features: **补位** — a NORMAL chess whose operator the player does not own is fielded as its
official stand-in (原型干员); played since 0.2.0: the player marks operators as not owned on the 干员持有 tab official stand-in (原型干员); played since 0.2.0: the player marks operators as not owned on the 干员持有 tab
(`room.ownership { notOwned }`, DESIGN 0.2.0 draft, docs/PLAYING.md §3), the match fields those chess with (`room.ownership { notOwned }`, DESIGN 0.2.0 draft, docs/PLAYING.md §3), the match fields those chess with
`standIn: true` — and **自选编队** — two tier-5 and two tier-6 DIY slots, each filled with a 6★ the player owns or a `standIn: true` — and **自选编队** — two tier-5 and two tier-6 DIY slots, each filled with a 6★ the player owns or a
prototype (gameplay not implemented yet). Built by `tools/build-data.mjs buildBackups`; prototype. Built by `tools/build-data.mjs buildBackups`; `shared/standIn.js` composes it into chess-shaped records,
`shared/standIn.js` composes it into chess-shaped records. The rules in the data (activity_table act2autochess `shared/diy.js` adds the 自选 rules on top. The sim fields both (docs/SIM.md §12); the 自选 picker and per-player shop
`charShopChessDatas`; PRTS 卫戍协议, 卫戍协议:盟约 下半/PRTS盟约记录): come later. The rules in the data (activity_table act2autochess `charShopChessDatas`; PRTS 卫戍协议, 卫戍协议:盟约
下半/PRTS盟约记录):
- PRESET (74, 特许干员) always fields the real operator (`backup.charId` = itself); NORMAL (55) names one of 17 stand-ins — - PRESET (74, 特许干员) always fields the real operator (`backup.charId` = itself); NORMAL (55) names one of 17 stand-ins —
the 4★ 预备干员 `char_600–607`, the 6★ `char_608–615` and 领主·Sharp `char_617` (the "其它分支"); DIY (4) has none. the 4★ 预备干员 `char_600–607`, the 6★ `char_608–615` and 领主·Sharp `char_617` (the "其它分支"); DIY (4) has none.
@@ -606,21 +610,22 @@ prototype (gameplay not implemented yet). Built by `tools/build-data.mjs buildBa
character takes S2 on one chess and S3 on another), module `backup.uniEquipId` (null at tiers 3–4 and for every 4★, so 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). even their elite form has none) and potential 0 (`potRank` 0 on all 55).
`units[charId]` — the 17 stand-ins (no unit for a PRESET or DIY chess): `units[charId]` — first the 17 stand-ins, then the 72 owned-6★ 自选 picks (`diy.ownedPool`); no unit for a PRESET or DIY
chess:
| Field | Example (`char_611_acnipe`) | Meaning | | Field | Example (`char_611_acnipe`) | Meaning |
|---|---|---| |---|---|---|
| `charId`, `name`, `appellation`, `rarity`, `profession`, `subProfessionId`, `subProfessionName`, `position`, `nationId`, `isNotObtainable` | `"char_611_acnipe"`, `"Stormeye"`, `"Stormeye"`, `6`, `"SNIPER"`, `"fastshot"`, `"速射手"`, `"RANGED"`, `null`, `true` | as on a chess record | | `charId`, `name`, `appellation`, `rarity`, `profession`, `subProfessionId`, `subProfessionName`, `position`, `nationId`, `isNotObtainable` | `"char_611_acnipe"`, `"Stormeye"`, `"Stormeye"`, `6`, `"SNIPER"`, `"fastshot"`, `"速射手"`, `"RANGED"`, `null`, `true` | as on a chess record (`isNotObtainable` false for the owned picks) |
| `assets` | `{"avatar":"char_611_acnipe","avatarGolden":"char_611_acnipe","portrait":"char_611_acnipe_1","portraitGolden":"char_611_acnipe_1","spine":"char_611_acnipe","subProfIcon":"sub_fastshot_icon"}` | art ids (URLs in `data/assets.json`, which already carries all 17); the elite form takes the E2 art when it exists — only 领主·Sharp has it | | `assets` | `{"avatar":"char_611_acnipe","avatarGolden":"char_611_acnipe","portrait":"char_611_acnipe_1","portraitGolden":"char_611_acnipe_1","spine":"char_611_acnipe","subProfIcon":"sub_fastshot_icon"}` | art ids (URLs in `data/assets.json`, which carries all 89 — the owned picks since 0.2.0, ASSETS.md); the elite form takes the E2 art when it exists — of the stand-ins only 领主·Sharp has it, every owned pick does |
| `moduleNames` | `{"uniequip_001_acnipe":{"name":"Stormeye证章","typeName":"ORIGINAL"},"uniequip_002_acnipe":{"name":"Stormeye证章","typeName":"MAR-X"}}` | every module of the character (a composed record names its module on the normal form too) | | `moduleNames` | `{"uniequip_001_acnipe":{"name":"Stormeye证章","typeName":"ORIGINAL"},"uniequip_002_acnipe":{"name":"Stormeye证章","typeName":"MAR-X"}}` | every module of the character (a composed record names its module on the normal form too) |
| `standsIn[]` | `["chess_char_3_21_a","chess_char_4_02_a","chess_char_5_18_a","chess_char_5_22_a","chess_char_6_01_a","chess_char_6_05_a"]` | the NORMAL base chess it replaces | | `standsIn[]` | `["chess_char_3_21_a","chess_char_4_02_a","chess_char_5_18_a","chess_char_5_22_a","chess_char_6_01_a","chess_char_6_05_a"]` | the NORMAL base chess it replaces (`[]` for an owned pick) |
| `forms[statusKey]` | keys `"2/1/4/0"`, `"2/60/7/1"`, `"2/60/7/3"` | the character at every status it fights at — of the chess it stands in for and of the DIY slots it may fill (`statusKey(status)` = `phase/level/skillLevel/equipLevel`): 3 forms per 6★, 2 per 4★ | | `forms[statusKey]` | keys `"2/1/4/0"`, `"2/60/7/1"`, `"2/60/7/3"` | the character at every status it fights at — of the chess it stands in for and of the DIY slots it may fill (`statusKey(status)` = `phase/level/skillLevel/equipLevel`): 3 forms per 6★ (an owned pick: exactly the three DIY slot statuses — E2 Lv1 skill rank 4 without a module, E2 Lv60 rank 7 with every module at stage 1 and at stage 3), 2 per 4★ |
A **form** holds the operator fields of a chess record with **nothing selected**: `status`; `stats`, `trait`, `talents` A **form** holds the operator fields of a chess record with **nothing selected**: `status`; `stats`, `trait`, `talents`
**without** a module; `immunities`, `rangeId`, `rangeGrid`, `dmgType`, `attackKind`, `projectile`, `canHitFly`, **without** a module; `immunities`, `rangeId`, `rangeGrid`, `dmgType`, `attackKind`, `projectile`, `canHitFly`,
`targetPriority`; `skills[]` — every skill unlocked at the status, at its skill level, `trigger` resolved per skill, no `targetPriority`; `skills[]` — every skill unlocked at the status, at its skill level, `trigger` resolved per skill, no
`isDefault`; `displayTokens` / `tokens` (summons — none of the 17 has one); at `equipLevel > 0` `modules[]` (§2.2 shape `isDefault`; `displayTokens` / `tokens` (summons — none of the 17 stand-ins has one; 27 of the owned picks do, their
without `isDefault`). `buildUnitForm` uses buildChess's helpers and rules, and the build fails when `buildUnitForm` + records in `tokens` below); at `equipLevel > 0` `modules[]` (§2.2 shape without `isDefault`). `buildUnitForm` uses buildChess's helpers and rules, and the build fails when `buildUnitForm` +
`composeUnitRecord` do not give back every PRESET chess field for field (each is its own backup), so a later change to `composeUnitRecord` do not give back every PRESET chess field for field (each is its own backup), so a later change to
buildChess that the stand-ins would miss stops the build. The chess trigger deviations (§2.2, DESIGN §21.29) name chess buildChess that the stand-ins would miss stops the build. The chess trigger deviations (§2.2, DESIGN §21.29) name chess
and never apply to a form; the 重装 stand-ins have their own, by charId (`tools/build-data.mjs STANDIN_TRIGGER_DEVIATIONS`, and never apply to a form; the 重装 stand-ins have their own, by charId (`tools/build-data.mjs STANDIN_TRIGGER_DEVIATIONS`,
@@ -643,8 +648,19 @@ the player's own field, 联防 and the boss fields alike) is fielded as `getChes
§12: this record, normalised), and its kit is found by its `charId` (`server/sim/content/index.js kitOf`; §12: this record, normalised), and its kit is found by its `charId` (`server/sim/content/index.js kitOf`;
kits/README.md "Stand-in kits") — never by the chess id it keeps, which names the replaced operator's kit. The client kits/README.md "Stand-in kits") — never by the chess id it keeps, which names the replaced operator's kit. The client
composes the same record (`public/js/ui/gameLogic/standIn.js standInOf`, the renderer's `data.standIn`) for the cards' composes the same record (`public/js/ui/gameLogic/standIn.js standInOf`, the renderer's `data.standIn`) for the cards'
「替补:X」 badge, the board model and the detail card. `diyRecord(slot, charId, backups, { skillIndex, moduleId })` fills a DIY slot with a prototype (the slot's 「替补:X」 badge, the board model and the detail card.
tier and price, no 特质, the pick's derived bonds); which skill a prototype carries in a slot is not in the data. A 自选 piece is composed by `shared/diy.js`:
`checkDiyPick(slotId, pick, data)` checks one pick `{ charId, skillIndex?, uniEquipId? }` against a slot (a pick of the
slot's tier; a prototype takes its `diy.locked` selection, another skill is refused; an owned pick names one of its three
skills and optionally a module of its elite form at the slot's stage), `diyRecord(slotId, pick, { elite, data })` /
`diyRecordOf(slot, pick, data)` give the record — the slot's identity (tier, price, merge, status; no 特质), the pick's
derived bonds, the operator's form at the slot's status with that skill and module (active on the elite only), plus
`diyFor` = the slot's base id —, `diyPool(tier, { data, kitted })` the legal picks of a tier (prototypes, then the owned
pool; with `kitted`, only operators with a kit: server/sim/content/kits/index.js `KITTED_CHARS`) and
`validateDiyPicks(picks, { data, kitted })` a roster (a prototype may fill a tier-5 and a tier-6 slot, an owned operator
one slot, the picks of a tier differ). **In battle** a PlayerBattleInput entry of a DIY slot carries `diy` (the pick) and
is fielded as `getChess(slotId, { diy })` (docs/SIM.md §12); its kit is `KITS[charId]` (kits/README.md "How to add an
operator (自选)"). (`shared/standIn.js diyRecord(slot, charId, backups, sel)` is the older prototype-only form.)
`diy`: `diy`:
@@ -652,9 +668,18 @@ tier and price, no 特质, the pick's derived bonds); which skill a prototype ca
|---|---|---| |---|---|---|
| `slots[slotId]` | `{"tier":5,"goldenId":"chess_char_5_diy1_b","shopLevel":5,"requirement":"TIER_6"}` | the four DIY chess `chess_char_5_diy1/2_a`, `chess_char_6_diy1/2_a` (chess.json: price 4, sell 1, `diyRequirement`, empty `bonds` / `garrisonIds`); `shopLevel` = the 调度中心 level whose `shopLevelDisplayDataDict.charChessDiySlotIdList` lists the slot | | `slots[slotId]` | `{"tier":5,"goldenId":"chess_char_5_diy1_b","shopLevel":5,"requirement":"TIER_6"}` | the four DIY chess `chess_char_5_diy1/2_a`, `chess_char_6_diy1/2_a` (chess.json: price 4, sell 1, `diyRequirement`, empty `bonds` / `garrisonIds`); `shopLevel` = the 调度中心 level whose `shopLevelDisplayDataDict.charChessDiySlotIdList` lists the slot |
| `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阶之间重复选取") | | `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阶之间重复选取") |
| `ownedPool[]` | 78 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 ("玩家已拥有干员不可重复选取"). The data does not exclude collab operators | | `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 |
| `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"]` | | `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,
`placeable`, `ownerRange`, `abnormal`, `assets`) whose `variants` are keyed by the owner FORM `<charId>@<statusKey>`
(`owners` = those keys; shared/diy.js `diyTokenOwner`) instead of a chess id — a DIY piece is a slot, and two players may
fill one slot with different operators. A variant is the token at the owner's status for its first skill and no module,
`bySkill[i]` the token skill / count / sources under each other skill, `byModule[id]` each module's token attributes,
trait and talents (`tools/build-data.mjs buildDiyTokens`); simdata `getToken` merges them with the pick as the owner's
loadout. No id is also a tokens.json record.
Not in this data (the gameplay workstreams): a player's ownership roster, 助战 borrows (`borrowCount` 20), DIY stock, the Not in this data (the gameplay workstreams): a player's ownership roster, 助战 borrows (`borrowCount` 20), DIY stock, the
owned-operator training bonus (`prepareStateDict`), forms and kits of owned 6★ picks (the same `buildUnitForm` builds owned-operator training bonus (`prepareStateDict`), potential (0 for every 自选 pick, [ASSUMED]: no account).
them once kits exist).
+1 -1
View File
@@ -21,7 +21,7 @@ In scope:
- Rendering with the real Spine battle chibis (PixiJS 7 + pixi-spine 4), procedural tiles, VFX, damage numbers, real BGM/SFX, emotes, broadcast ticker. - Rendering with the real Spine battle chibis (PixiJS 7 + pixi-spine 4), procedural tiles, VFX, damage numbers, real BGM/SFX, emotes, broadcast ticker.
- Reconnect, AI take-over of disconnected players, robust validation of every client intent. - Reconnect, AI take-over of disconnected players, robust validation of every client intent.
Out of scope v1: matchmaking queue, training/tutorial, DIY (甄选) slots (the 4 DIY chess are removed from the pool), trophies/progression persistence, reporting. Out of scope v1: matchmaking queue, training/tutorial, DIY (甄选) slots (the 4 DIY chess are removed from the pool — 0.2.0 adds their data and battle side: DATA.md §18, shared/diy.js), trophies/progression persistence, reporting.
--- ---
+21 -2
View File
@@ -887,6 +887,11 @@ true })`, §12): the def keeps the chess's ids, so `content/index.js kitOf` look
stand-in's own, `kits/ops/standin-<codename>.js` registered under that charId (kits/README.md "Stand-in kits") — never stand-in's own, `kits/ops/standin-<codename>.js` registered under that charId (kits/README.md "Stand-in kits") — never
by the chess id, which names the replaced operator's kit. A stand-in has no default skill: its kit authors each skill in by the chess id, which names the replaced operator's kit. A stand-in has no default skill: its kit authors each skill in
`skills` (`skill` is ignored); without a kit it gets the generic kit plus `genericTalents(def)` (§7.4). `skills` (`skill` is ignored); without a kit it gets the generic kit plus `genericTalents(def)` (§7.4).
自选 pieces (DATA.md §18; a PlayerBattleInput entry of a DIY slot with `diy: { charId, skillIndex, uniEquipId }` ⇒ the def
of `getChess(slotId, { diy })`, §12) take their kit the same way, `KITS[def.charId]` only (`content/index.js isDiyDef`): an
owned 6★'s `kits/ops/op-<codename>.js` (kits/index.js `OPERATOR_KIT_FILES`, kits/README.md "How to add an operator
(自选)"), a prototype's stand-in kit, a 预备干员's generic kit; no default skill either (the pick chooses any of the
three). `KITTED_CHARS` lists who has one — shared/diy.js offers no other pick.
Operator loadouts (DESIGN §16): the unit's def is `getChess(chessId, { skillIndex, moduleId })` of its PlayerBattleInput Operator loadouts (DESIGN §16): the unit's def is `getChess(chessId, { skillIndex, moduleId })` of its PlayerBattleInput
entry (no loadout fields = the default). The skill spec is `kit.skills[selectedSkillId]` when authored, else `kit.skill` entry (no loadout fields = the default). The skill spec is `kit.skills[selectedSkillId]` when authored, else `kit.skill`
only when the selected skill is the default one, else the GENERIC spec of the selected skill (its generic install is only when the selected skill is the default one, else the GENERIC spec of the selected skill (its generic install is
@@ -1211,7 +1216,7 @@ Unknown subprofessions fall back to the profession default (test `professions.te
`['fx', kind, x, y, extra]` (`hitCap` `{ id, n }`: a leader's hit cancelled by 限伤 — the renderer draws nothing; `['fx', kind, x, y, extra]` (`hitCap` `{ id, n }`: a leader's hit cancelled by 限伤 — the renderer draws nothing;
`extra.form` = the unit's model form from then on — an enemy's `content/enemies/helpers.js setForm`, a 傀儡师's 替身 — `shared/protocol.js fxForm`), `extra.form` = the unit's model form from then on — an enemy's `content/enemies/helpers.js setForm`, a 傀儡师's 替身 — `shared/protocol.js fxForm`),
`['layer', playerId, bondId, n]` (n = the layers actually added, capped at 999), `['bounty', playerId, coins]`. `['layer', playerId, bondId, n]` (n = the layers actually added, capped at 999), `['bounty', playerId, coins]`.
- `UnitInfo = { id, kind, side, ownerId, defId, name, tier, golden, spine, avatar, x, y, facing, dir, maxHp, motion?, boss?, uid?, form?, skillIndex?, moduleId?, items?, standInFor? }` (`standInFor`: a 补位 stand-in's replaced operator charId — `spine` / `avatar` / `name` are the stand-in's, DATA.md §18; the prep scouting units of `Match.prepFieldMeta` carry it too: a board piece with the stand-in's art, a held one with the chess's own; `skillIndex`: an ally's equipped skill, DESIGN §16; `form`: the unit's current model form — `content/enemies/helpers.js setForm`: 掠海漂移体 `'crawl'`, 暴鸰 `'bombed'` after its drop, 转译基底·α's forms …; a 傀儡师 fighting as its 替身 `'doll'` — so a view built mid-battle from `fieldMeta()` starts on that clip set; `items`: an ally operator's equipped item ids — a 变形同构体 wearer is a member of the bond it grants on the client too, the bond popup and the detail card's chips) - `UnitInfo = { id, kind, side, ownerId, defId, name, tier, golden, spine, avatar, x, y, facing, dir, maxHp, motion?, boss?, uid?, form?, skillIndex?, moduleId?, items?, standInFor?, diy? }` (`standInFor`: a 补位 stand-in's replaced operator charId — `spine` / `avatar` / `name` are the stand-in's, DATA.md §18; the prep scouting units of `Match.prepFieldMeta` carry it too: a board piece with the stand-in's art, a held one with the chess's own; `diy`: a 自选 piece's pick `{ charId, skillIndex, uniEquipId }` — `defId` is its slot, `spine` / `avatar` / `name` the operator's; `skillIndex`: an ally's equipped skill, DESIGN §16; `form`: the unit's current model form — `content/enemies/helpers.js setForm`: 掠海漂移体 `'crawl'`, 暴鸰 `'bombed'` after its drop, 转译基底·α's forms …; a 傀儡师 fighting as its 替身 `'doll'` — so a view built mid-battle from `fieldMeta()` starts on that clip set; `items`: an ally operator's equipped item ids — a 变形同构体 wearer is a member of the bond it grants on the client too, the bond popup and the detail card's chips)
(`dir` = the unit direction, allies meaningful, enemies 'RIGHT'; `facing` = its horizontal sign for sprite flipping) (`dir` = the unit direction, allies meaningful, enemies 'RIGHT'; `facing` = its horizontal sign for sprite flipping)
(`spine`/`avatar` are asset ids from data). (`spine`/`avatar` are asset ids from data).
- flags: UF bits (blocked 1, stunned 2, frozen 4, stealth 8 — 隐匿 (an enemy's only while not blocked / revealed and not within 3 s of a block's end) or an ally's 迷彩 — skill 16, shield 32, invuln 64, cold 128, sleep 256, flying 512); - flags: UF bits (blocked 1, stunned 2, frozen 4, stealth 8 — 隐匿 (an enemy's only while not blocked / revealed and not within 3 s of a block's end) or an ally's 迷彩 — skill 16, shield 32, invuln 64, cold 128, sleep 256, flying 512);
@@ -1250,7 +1255,10 @@ or `players`; `enemies:
`timeLimit`; `content` (`'full'|'generic'|'none'`); `kits` (inject `{ baseId: kitFn }`; a stand-in's by its charId); a unit's `timeLimit`; `content` (`'full'|'generic'|'none'`); `kits` (inject `{ baseId: kitFn }`; a stand-in's by its charId); a unit's
`standIn: true` fields a NORMAL chess as its 补位 stand-in (the production path, the chess's backup skill / module; `_a` = `standIn: true` fields a NORMAL chess as its 补位 stand-in (the production path, the chess's backup skill / module; `_a` =
normal, `_b` = elite), `standIn: { skillIndex?, moduleId? }` another skill / module of it (the composed record `standInRec(chessId, normal, `_b` = elite), `standIn: { skillIndex?, moduleId? }` another skill / module of it (the composed record `standInRec(chessId,
sel)` replaces that chess's record for the battle; kits/README.md "How to test a stand-in kit"); `defs: { chess, enemies, tokens }` sel)` replaces that chess's record for the battle; kits/README.md "How to test a stand-in kit"); a unit `{ diy: { slot,
charId, skillIndex?, uniEquipId? }, elite?, row, col }` fields a 自选 piece the production way (`slot` = a DIY slot's base
id or its tier 5 / 6, `elite` = its `_b` form; `getChess(slotId, { diy })`, kits/README.md "How to add an operator
(自选)"); `defs: { chess, enemies, tokens }`
(extra/override records; build them with `chessRec({...})` / `enemyRec({...})`); `bonds`, `bandId`, `playerEffects`, (extra/override records; build them with `chessRec({...})` / `enemyRec({...})`); `bonds`, `bandId`, `playerEffects`,
`flags`, `sharedBoss`, `setup(battle)`, `hooks` (names to capture; `captureNoisy` to keep tick/hit/damaged/heal/spGain/ `flags`, `sharedBoss`, `setup(battle)`, `hooks` (names to capture; `captureNoisy` to keep tick/hit/damaged/heal/spGain/
attack contexts), `autoFinish` (default: true when enemies are scheduled). attack contexts), `autoFinish` (default: true when enemies are scheduled).
@@ -1309,6 +1317,17 @@ the replaced operator's, `loadout` the backup selection (`standIn: true`); its s
("对于补位干员其技能不可更改", shared/standIn.js). A PRESET / DIY chess or a source without backups (a browser that did not ("对于补位干员其技能不可更改", shared/standIn.js). A PRESET / DIY chess or a source without backups (a browser that did not
fetch backups.json) gives the chess's own def. A PlayerBattleInput entry carries it as `standIn: true` (spec.js keeps fetch backups.json) gives the chess's own def. A PlayerBattleInput entry carries it as `standIn: true` (spec.js keeps
nothing else); the per-battle view maps id-only lookups of that chess id to the stand-in. nothing else); the per-battle view maps id-only lookups of that chess id to the stand-in.
自选 (DATA.md §18): `getChess(slotId, { diy })` = `getDiy(slotId, pick)` — shared/diy.js `diyRecordOf` of the DIY slot
record (`_a` normal, `_b` elite) with the pick over this source, normalised: `id` / `baseId` / `golden` / `tier` of the
slot, `bonds` the pick's derived ones, `raw.garrisonIds` `[]` (no 特质), every combat field of the operator at the slot's
status with the pick's skill and module (a prototype's locked ones), `charId` the operator's, `diyFor` the slot's base
id, `loadout` the selection with `diy` = the checked pick `{ charId, skillIndex, uniEquipId }`, `tokenOwner` =
`<charId>@<statusKey>`; null for an illegal pick (shared/diy.js `checkDiyPick`); cached per (id, pick). Its summons:
`getToken(id, slotId, def.loadout)` reads `rawBackups().tokens[id].variants[tokenOwner]` with `bySkill[skillIndex]` /
`byModule[uniEquipId]` merged (`getDiyToken`); `rawToken(id)` falls back to `backups.tokens`, and content/tokens.js /
`Battle.producesToken` key a 自选 owner's variant by `def.tokenOwner`. A PlayerBattleInput entry carries the pick as
`diy` (spec.js keeps `charId` / `skillIndex` / `uniEquipId` when well-formed); Battle fields a DIY slot only with a legal
pick (none ⇒ no unit), and each unit resolves its own pick — two players filling one slot id differently are exact.
Assumptions taken here (documented choices): element burst numbers for necrosis/apoptosis; unspawned enemies at timeout Assumptions taken here (documented choices): element burst numbers for necrosis/apoptosis; unspawned enemies at timeout
are dropped (not leaks); geek drain is non-lethal; tactician reinforcement stats; displacement is instantaneous (the official 失衡 slide over ≈ 0.4–1.2 s is not are dropped (not leaks); geek drain is non-lethal; tactician reinforcement stats; displacement is instantaneous (the official 失衡 slide over ≈ 0.4–1.2 s is not
modelled — only its distance, `push` / `pull`); pushes PRTS does not classify use the 弹道 distance column (`PUSH_TILES`); modelled — only its distance, `push` / `pull`); pushes PRTS does not classify use the 弹道 distance column (`PUSH_TILES`);
+6 -2
View File
@@ -103,11 +103,15 @@ export class BattlePlayers {
} }
// the unit's own loadout (DESIGN §16): an entry without loadout fields is the DEFAULT — never another player's // the unit's own loadout (DESIGN §16): an entry without loadout fields is the DEFAULT — never another player's
// choice for the same chess id in a multi-player field (the per-battle data view maps id-only lookups); `standIn: // choice for the same chess id in a multi-player field (the per-battle data view maps id-only lookups); `standIn:
// true` fields the chess as its 补位 stand-in (simdata getStandIn: the stand-in's body, the chess's identity) // true` fields the chess as its 补位 stand-in (simdata getStandIn: the stand-in's body, the chess's identity); `diy`
// fills a 自选 slot with its pick (simdata getDiy: the slot's identity, the operator's body, skill and module)
const lo = { skillIndex: inp.skillIndex ?? null, moduleId: inp.moduleId ?? null }; const lo = { skillIndex: inp.skillIndex ?? null, moduleId: inp.moduleId ?? null };
if (inp.standIn === true) lo.standIn = true; if (inp.standIn === true) lo.standIn = true;
if (inp.diy && typeof inp.diy === 'object') lo.diy = inp.diy;
const def = this.data.getChess(inp.chessId, lo); const def = this.data.getChess(inp.chessId, lo);
if (!def) { this.log(`unknown chess ${inp.chessId}`); return null; } if (!def) { this.log(`unknown chess ${inp.chessId}${lo.diy ? ' (illegal 自选 pick)' : ''}`); return null; }
// a DIY slot has no body of its own (甄选干员): it fights only as a 自选 piece (its `diy` pick)
if (def.raw?.isDiy && !def.diyFor) { this.log(`自选 slot ${inp.chessId} without a pick`); return null; }
const u = this._makeAlly(ps, def, 'op', r, c, { uid: inp.uid, dir }); const u = this._makeAlly(ps, def, 'op', r, c, { uid: inp.uid, dir });
u.items = [...(inp.items ?? [])]; u.items = [...(inp.items ?? [])];
u.carry = inp.carryState ?? null; u.carry = inp.carryState ?? null;
+1 -1
View File
@@ -31,7 +31,7 @@ export class BattleSummons {
const src = d && Array.isArray(d.sources) ? d.sources : null; const src = d && Array.isArray(d.sources) ? d.sources : null;
if (!src || src.includes('skill') || src.includes('talent')) return true; if (!src || src.includes('skill') || src.includes('talent')) return true;
const vs = this.data.rawToken?.(tokenId)?.variants; const vs = this.data.rawToken?.(tokenId)?.variants;
const id = String(owner.defId ?? ''); const id = String(owner.def?.tokenOwner ?? owner.defId ?? ''); // a 自选 piece's variants are keyed by its owner form
return !(vs && typeof vs === 'object' && (vs[id] || vs[id.replace(/_b$/, '_a')])); return !(vs && typeof vs === 'object' && (vs[id] || vs[id.replace(/_b$/, '_a')]));
} }
+33 -14
View File
@@ -11,6 +11,9 @@
// kits[def.charId] (the stand-in's own, kits/ops/standin-<codename>.js), never the chess id's — that is the replaced // kits[def.charId] (the stand-in's own, kits/ops/standin-<codename>.js), never the chess id's — that is the replaced
// operator's kit; without one, the generic kit plus the talents it can apply exactly (generic.js genericTalents). // operator's kit; without one, the generic kit plus the talents it can apply exactly (generic.js genericTalents).
// A stand-in has no default skill: its kit authors every skill it supports in `skills` (selectSkillSpec). // A stand-in has no default skill: its kit authors every skill it supports in `skills` (selectSkillSpec).
// 自选 pieces (isDiyDef: the def of simdata getDiy — a DIY slot's ids, the picked operator's body) take their kit the
// same way: kits[def.charId] (an owned 6★'s kits/ops/op-<codename>.js, a prototype's standin-<codename>.js), else the
// generic kit plus genericTalents; no default skill either (the pick chooses any of the three).
// Operator loadouts (DESIGN §16): the def is resolved for the unit's selected skill / module (simdata getChess), so // Operator loadouts (DESIGN §16): the def is resolved for the unit's selected skill / module (simdata getChess), so
// the kit receives `chess.skill` = the SELECTED skill record and `bb` = its blackboard (talents / trait / module of // the kit receives `chess.skill` = the SELECTED skill record and `bb` = its blackboard (talents / trait / module of
// the selected module). Kit contract (backward compatible): `{ skill?, skills?: { [skillId]: SkillSpec }, talents, // the selected module). Kit contract (backward compatible): `{ skill?, skills?: { [skillId]: SkillSpec }, talents,
@@ -60,21 +63,33 @@ export function isStandInDef(def) {
} }
/** /**
* The kit builder of an operator def in `reg` ({ [key]: (bb, chess, def) => Kit }), or undefined. A stand-in's kit is * Whether `def` is a 自选 piece: a DIY slot fielded with a picked operator's body (simdata getDiy — `diyFor` = the slot's
* keyed by its charId (`char_609_acguad`) and by nothing else: the chess id it keeps names the replaced operator's kit. * base id, `charId` = the operator's).
* Any other def: baseId (`…_a`, data/SIM.md), the exact id, or the suffix-less id of DESIGN §5.6's example. */
export function isDiyDef(def) {
return !!(def && def.diyFor && def.charId);
}
/** A def whose body is another character than its chess id names (补位 stand-in, 自选 piece): its kit is keyed by charId. */
const isOtherBody = (def) => isStandInDef(def) || isDiyDef(def);
/**
* The kit builder of an operator def in `reg` ({ [key]: (bb, chess, def) => Kit }), or undefined. A stand-in's and a
* 自选 piece's kit is keyed by its charId (`char_609_acguad`, `char_112_siege`) and by nothing else: the chess id it keeps
* names the replaced operator's kit (a DIY slot has none). Any other def: baseId (`…_a`, data/SIM.md), the exact id, or
* the suffix-less id of DESIGN §5.6's example.
*/ */
export function kitOf(def, reg) { export function kitOf(def, reg) {
if (!def || !reg) return undefined; if (!def || !reg) return undefined;
if (isStandInDef(def)) return Object.prototype.hasOwnProperty.call(reg, def.charId) ? reg[def.charId] : undefined; if (isOtherBody(def)) return Object.prototype.hasOwnProperty.call(reg, def.charId) ? reg[def.charId] : undefined;
const bare = String(def.baseId ?? def.id ?? '').replace(/_[ab]$/, ''); const bare = String(def.baseId ?? def.id ?? '').replace(/_[ab]$/, '');
return reg[def.baseId] ?? reg[def.id] ?? reg[bare]; return reg[def.baseId] ?? reg[def.id] ?? reg[bare];
} }
/** The generic kit of an operator def; a stand-in's also carries the talents generic.js can apply exactly. */ /** The generic kit of an operator def; a stand-in's / 自选 piece's also carries the talents generic.js can apply exactly. */
function fallbackKit(bb, raw, def) { function fallbackKit(bb, raw, def) {
const k = genericKit(bb, raw, def); const k = genericKit(bb, raw, def);
if (!isStandInDef(def)) return k; if (!isOtherBody(def)) return k;
const talents = genericTalents(def); const talents = genericTalents(def);
return talents.length ? { ...k, talents: [...(k.talents ?? []), ...talents] } : k; return talents.length ? { ...k, talents: [...(k.talents ?? []), ...talents] } : k;
} }
@@ -102,7 +117,7 @@ export function setupUnitKit(battle, unit, mode = 'full') {
const k = f(bb, raw, def); const k = f(bb, raw, def);
if (k) return selectSkillSpec(k, bb, raw, def); if (k) return selectSkillSpec(k, bb, raw, def);
} catch (e) { } catch (e) {
battle._handlerError(`kit:${isStandInDef(def) ? def.charId : def.baseId}`, unit, e); battle._handlerError(`kit:${isOtherBody(def) ? def.charId : def.baseId}`, unit, e);
} }
} }
} }
@@ -120,15 +135,18 @@ function inputEntry(unit) {
/** /**
* The loadout `{ skillIndex, moduleId }` a unit's PlayerBattleInput entry gives; an entry without loadout fields means * The loadout `{ skillIndex, moduleId }` a unit's PlayerBattleInput entry gives; an entry without loadout fields means
* the DEFAULT loadout (`{}`), never "whatever the data view maps this chess id to" (another player's choice in a * the DEFAULT loadout (`{}`), never "whatever the data view maps this chess id to" (another player's choice in a
* multi-player field). `standIn: true` rides along (补位: the stand-in def whatever the skill / module fields say). * multi-player field). `standIn: true` rides along (补位: the stand-in def whatever the skill / module fields say), as
* Null when the unit has no input entry. * does `diy` (自选: the pick of a DIY slot's piece). Null when the unit has no input entry.
*/ */
function inputLoadout(unit) { function inputLoadout(unit) {
const x = inputEntry(unit); const x = inputEntry(unit);
if (!x) return null; if (!x) return null;
const standIn = x.standIn === true ? { standIn: true } : null; const extra = {
if (x.skillIndex == null && x.moduleId == null) return standIn ?? {}; ...(x.standIn === true ? { standIn: true } : null),
return { skillIndex: x.skillIndex ?? null, moduleId: x.moduleId ?? null, ...standIn }; ...(x.diy && typeof x.diy === 'object' ? { diy: x.diy } : null),
};
if (x.skillIndex == null && x.moduleId == null) return extra;
return { skillIndex: x.skillIndex ?? null, moduleId: x.moduleId ?? null, ...extra };
} }
/** Put a def on a not-yet-deployed ally (the fields Battle._makeAlly takes from the def). */ /** Put a def on a not-yet-deployed ally (the fields Battle._makeAlly takes from the def). */
@@ -179,9 +197,10 @@ const selectedSkillId = (def) => def?.skill?.id ?? def?.raw?.skill?.skillId ?? n
/** /**
* True when the def's selected skill is the chess's default skill (no loadout info ⇒ default). Never for a stand-in: * True when the def's selected skill is the chess's default skill (no loadout info ⇒ default). Never for a stand-in:
* the skill it fields is the one its chess names (S2 on one chess, S3 on another), so its kit authors each in `skills`. * the skill it fields is the one its chess names (S2 on one chess, S3 on another), so its kit authors each in `skills`;
* nor for a 自选 piece (the pick chooses any of its skills).
*/ */
const skillIsDefault = (def) => !isStandInDef(def) && (!def?.loadout || def.loadout.skillIsDefault !== false); const skillIsDefault = (def) => !isOtherBody(def) && (!def?.loadout || def.loadout.skillIsDefault !== false);
/** /**
* The kit with the skill spec of the SELECTED skill (see header): `kit.skills[id]` → `kit.skill` (default skill only; * The kit with the skill spec of the SELECTED skill (see header): `kit.skills[id]` → `kit.skill` (default skill only;
+73 -3
View File
@@ -9,17 +9,18 @@ operators, the self-select 6★ operators, contributions from GitHub issue #136
| path | what | | path | what |
|---|---| |---|---|
| `index.js` | the registry: `KIT_FILES` lists every kit file, grouped by tier, and `STANDIN_KIT_FILES` the 补位 stand-in kits; `KITS` (base chess id → kit builder, then stand-in charId → kit builder), `TIER_KITS` (one registry per tier group) and `STANDIN_KITS` are built from them | | `index.js` | the registry: `KIT_FILES` lists every kit file, grouped by tier, `STANDIN_KIT_FILES` the 补位 stand-in kits and `OPERATOR_KIT_FILES` the 自选 operator kits; `KITS` (base chess id → kit builder, then stand-in charId → kit builder, then operator charId → kit builder), `TIER_KITS` (one registry per tier group), `STANDIN_KITS`, `OPERATOR_KITS` and `KITTED_CHARS` (who a 自选 slot may field) are built from them |
| `ops/<chessId>-<codename>.js` | one kit per file, with the helpers and constants only that kit uses | | `ops/<chessId>-<codename>.js` | one kit per file, with the helpers and constants only that kit uses |
| `ops/standin-<codename>.js` | one 补位 stand-in kit per file (below: "Stand-in kits") | | `ops/standin-<codename>.js` | one 补位 stand-in kit per file (below: "Stand-in kits") |
| `ops/op-<codename>.js` | one 自选 operator kit per file (below: "How to add an operator (自选)") |
| `shared/tier1.js` | the general kit helpers (blackboard readers, unit predicates, hit hooks, area queries, buffs, zones, free tiles, skill records) and the notes of the tier-1 kits | | `shared/tier1.js` | the general kit helpers (blackboard readers, unit predicates, hit hooks, area queries, buffs, zones, free tiles, skill records) and the notes of the tier-1 kits |
| `shared/tier2.js` … `tier6.js` | helpers two or more kits of that tier use, and that tier's notes (conventions, simplifications, fx kinds) | | `shared/tier2.js` … `tier6.js` | helpers two or more kits of that tier use, and that tier's notes (conventions, simplifications, fx kinds) |
| `tier1.js` … `tier6.js` | re-export shims for the old import paths; removed after the 0.2.0 refactor | | `tier1.js` … `tier6.js` | re-export shims for the old import paths; removed after the 0.2.0 refactor |
`content/index.js` takes `KITS` from `index.js`: a unit's kit is `KITS[def.baseId]` (also the exact or the suffix-less `content/index.js` takes `KITS` from `index.js`: a unit's kit is `KITS[def.baseId]` (also the exact or the suffix-less
id), else the generic kit built from the skill blackboard (`content/generic.js`, docs/SIM.md §7.4). A 补位 stand-in's id), else the generic kit built from the skill blackboard (`content/generic.js`, docs/SIM.md §7.4). A 补位 stand-in's
kit is `KITS[def.charId]` and nothing else (`content/index.js kitOf`): it keeps the chess's ids, which name the replaced and a 自选 piece's kit is `KITS[def.charId]` and nothing else (`content/index.js kitOf`): it keeps the chess's (the DIY
operator's kit. slot's) ids, which name the replaced operator's kit (a slot has none).
## Naming ## Naming
@@ -135,6 +136,75 @@ every active backup skill is cast).
- `node --test test/content/kits_layout.test.js` checks the file name, the key (a charId of data/backups.json `units`) - `node --test test/content/kits_layout.test.js` checks the file name, the key (a charId of data/backups.json `units`)
and the registry entry. and the registry entry.
## How to add an operator (自选)
The 自选 (self-select) slots — two at tier 5, two at tier 6 — field a 6★ the player owns that is not in the chess pool,
or a prototype (DATA.md §18, `shared/diy.js`). The prototypes run their stand-in kits, the 4★ 预备干员 the generic kit;
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,
the owner's decision of 2026-10-05). The ones still without a kit:
```sh
node --input-type=module -e "import { readFileSync } from 'node:fs'; import { KITTED_CHARS } from './server/sim/content/kits/index.js';
const b = JSON.parse(readFileSync('data/backups.json', 'utf8')); for (const id of b.diy.ownedPool) if (!KITTED_CHARS.includes(id)) console.log(id, b.units[id].name);"
```
**The data is already built** — `data/backups.json units[charId]` (no chess record exists for these operators):
- `forms['2/1/4/0']` = the normal form (E2 Lv1, skills at rank 4, no module), `forms['2/60/7/1']` = the tier-5 elite (E2
Lv60, rank 7, modules at stage 1), `forms['2/60/7/3']` = the tier-6 elite (modules at stage 3). Each holds `stats`,
`rangeGrid`, `trait`, `talents` (no module), all three `skills` (blackboard `bb`, SP data, `rangeGrid`, the resolved
auto-cast `trigger`) and on the elites `modules[]` (`attr`, `traitOverride`, `talentChanges` — the official module
parts at that stage).
- `tokens[tokenId]` = its summons, per owner form (`variants['<charId>@<statusKey>']`, with `bySkill` / `byModule`).
- Verify the numbers against the official tables (`.cache/gamedata/excel`) and PRTS like any kit. If the data itself is
wrong or lacks something — a trigger deviation, a summon's abnormal effect — fix `tools/build-data.mjs`
(`STANDIN_TRIGGER_DEVIATIONS` is keyed by charId and applies to these units too; `TOKEN_ABNORMAL`), rebuild with
`node tools/build-data.mjs --offline` and check with a JSON compare that only the intended data changed.
**File and registration.**
1. `ops/op-<codename>.js`, the code name being the charId without `char_<n>_` (`op-siege.js` = 推进之王,
`char_112_siege`); the Chinese name in the first line.
2. The default export has exactly one key, the charId: `export default { char_112_siege: (bb, chess, def) => Kit }`.
3. Append the file name to `OPERATOR_KIT_FILES` in `index.js`. The operator becomes a legal pick (`KITTED_CHARS`;
`shared/diy.js diyPool` / `validateDiyPicks` with `kitted`) — never register it under a chess or slot id.
4. `node --test test/content/kits_layout.test.js` checks the name, the key (an owned pick), the registry and that every
skill of every form is authored under `skills`.
**The kit contract** (as for a stand-in): `bb` = the blackboard of the picked skill at the slot's rank; `chess` = the
composed record (`shared/diy.js diyRecordOf`): `skills` (all three at that rank), `talents` with the picked module's
changes, `trait` (the module's override), `module` (`{ id, level: 1 | 3, active }` — active on the elite only), and on an
elite with a module stage `statsBase` / `traitBase` / `talentsBase` / `modules[]` (a module talent that adds to a base
talent: read the base from `talentsBase` and the module's own part from the module's `talentChanges`, as `op-siege.js`
万兽之王); `def.tier` (5 / 6), `def.golden`, `def.charId`, `def.diyFor` (the slot). There is **no default skill**: the
pick chooses any of the three, so write every skill under `skills: { [skillId]: SkillSpec }` (`skill` is ignored);
`talents`, `trait` and `install` apply under every skill. Read every number from `chess` / `bb` — the same file serves
both tiers, both forms and both module stages. A 自选 piece has no 特质 and its bonds come from its factions: neither is
the kit's business. Summons: `battle.tokenDef(tokenId, unit)` / `battle.spawnToken(unit, …)` resolve the variant of the
pick (the hand pieces of 自选 summons come with the per-player shop). Potential is 0 [ASSUMED: no account].
**The fidelity rule and the checklist** above apply item by item: every skill at rank 4 and 7, every talent, every
module at stages 1 and 3 (trait override, talent changes, stats), the range while a skill runs, the auto-cast trigger
(the data's; the owner's rules of checklist item 5 — an AUTO skill acting on nobody fires at full SP), anti-air and
targetability, damage typing, summons, statuses. Mark what no source settles `[ASSUMED]` in the comment and the pull
request.
**Tests** — `test/content/op_<codename>.test.js`, the pattern of `test/content/op_siege.test.js`:
- field the operator the production way: `makeBattle({ units: [{ diy: { slot: 5, charId: 'char_112_siege',
skillIndex: 2, uniEquipId: 'uniequip_002_siege' }, elite: true, row: 10, col: 5 }] })` — `slot` = a DIY slot id or its
tier (5 / 6 ⇒ that tier's first slot), `elite` = the `_b` form, `uniEquipId` null / omitted = no module;
- loop over every form (`[5, false]`, `[6, false]`, and both tiers' elites with no module and with each module) and every
skill; assert that the unit is yours (`u.def.charId`, `!u.kit.generic`, `u.kit.skillSource === 'skills'`), then the
checklist; read the expected numbers from `data/backups.json` forms, never from memory;
- seed everything, use synthetic `enemyRec` targets for exact numbers, end with `checkInvariants(h.b)`.
**Golden** — the `diy` family of `tools/golden.mjs` fields every operator of `OPERATOR_KIT_FILES` in every form × module ×
skill: a new kit adds its scenarios. Run `npm run golden:update`, check that only `test/golden/diy.json` changed (new
scenarios; no other family moves), and commit it with the kit (`test/golden/README.md`).
**Art** comes with the data: `tools/fetch-assets.mjs` plans every unit of `data/backups.json` (ASSETS.md) — nothing to do.
## Testing a kit ## Testing a kit
- The harness is `test/helpers/battleHarness.js` (`makeBattle`, `runUntil`, `hooksOf`, `eventsOf`, `checkInvariants`; - The harness is `test/helpers/battleHarness.js` (`makeBattle`, `runUntil`, `hooksOf`, `eventsOf`, `checkInvariants`;
+37 -2
View File
@@ -14,6 +14,14 @@
// key, that charId. A stand-in keeps the ids of the chess it replaces, so content/index.js kitOf finds its kit by // key, that charId. A stand-in keeps the ids of the chess it replaces, so content/index.js kitOf finds its kit by
// `def.charId` only, never by the chess id (the replaced operator's kit). STANDIN_KITS is merged into KITS after every // `def.charId` only, never by the chess id (the replaced operator's kit). STANDIN_KITS is merged into KITS after every
// tier group: the chess keys keep their order, and `char_…` keys never meet `chess_char_…` ones. // tier group: the chess keys keep their order, and `char_…` keys never meet `chess_char_…` ones.
//
// 自选 operator kits (DATA.md §18, README.md "How to add an operator (自选)"): OPERATOR_KIT_FILES lists
// `ops/op-<codename>.js`, one per owned-6★ pick of data/backups.json `diy.ownedPool` (codename = its charId without
// `char_<n>_`: `op-siege.js` = 推进之王, char_112_siege), each registering exactly that charId and writing every skill under
// `skills`. A 自选 piece keeps its DIY slot's ids, so kitOf finds its kit by `def.charId` — a prototype pick runs its
// stand-in kit, a 4★ reserve the generic kit (GENERIC_KIT_CHARS). OPERATOR_KITS is merged after the stand-ins.
// KITTED_CHARS = every character a 自选 pick may field with a faithful kit; an operator outside it is not offered
// (shared/diy.js diyPool / validateDiyPicks `kitted`).
export const KIT_FILES = Object.freeze([ export const KIT_FILES = Object.freeze([
// tier 1 // tier 1
@@ -66,6 +74,19 @@ export const KIT_FILES = Object.freeze([
*/ */
export const STANDIN_KIT_FILES = Object.freeze(['standin-acguad.js', 'standin-sharp2.js', 'standin-acspec.js', 'standin-acmedc.js', 'standin-acfend.js', 'standin-acpion.js', 'standin-acnipe.js', 'standin-accast.js', 'standin-acsupo.js']); export const STANDIN_KIT_FILES = Object.freeze(['standin-acguad.js', 'standin-sharp2.js', 'standin-acspec.js', 'standin-acmedc.js', 'standin-acfend.js', 'standin-acpion.js', 'standin-acnipe.js', 'standin-accast.js', 'standin-acsupo.js']);
/**
* 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']);
/**
* The 预备干员 whose every skill the generic kit covers exactly (冲锋号令, 攻击力 / 防御力 / 治疗强化, 战术咏唱, 一击即退 —
* test/content/standin.test.js): fielded with no kit file, as 补位 stand-ins and as tier-5 自选 picks.
*/
export const GENERIC_KIT_CHARS = Object.freeze(['char_600_cpione', 'char_601_cguard', 'char_602_cdfend', 'char_603_csnipe',
'char_604_ccast', 'char_605_cmedic', 'char_606_csuppo', 'char_607_cspec']);
async function loadKitFile(file) { async function loadKitFile(file) {
try { try {
return await import(`./ops/${file}`); return await import(`./ops/${file}`);
@@ -77,6 +98,7 @@ async function loadKitFile(file) {
const MODULES = await Promise.all(KIT_FILES.map((group) => Promise.all(group.map(loadKitFile)))); const MODULES = await Promise.all(KIT_FILES.map((group) => Promise.all(group.map(loadKitFile))));
const STANDIN_MODULES = await Promise.all(STANDIN_KIT_FILES.map(loadKitFile)); const STANDIN_MODULES = await Promise.all(STANDIN_KIT_FILES.map(loadKitFile));
const OPERATOR_MODULES = await Promise.all(OPERATOR_KIT_FILES.map(loadKitFile));
const registryOf = (m) => (m && m.default && typeof m.default === 'object' ? m.default : {}); const registryOf = (m) => (m && m.default && typeof m.default === 'object' ? m.default : {});
/** The registry of each tier group (index 0 = tier 1): baseChessId → kit builder, in KIT_FILES order. */ /** The registry of each tier group (index 0 = tier 1): baseChessId → kit builder, in KIT_FILES order. */
@@ -85,5 +107,18 @@ export const TIER_KITS = Object.freeze(MODULES.map((group) => Object.assign({},
/** The 补位 stand-in kits: stand-in charId → kit builder, in STANDIN_KIT_FILES order. */ /** The 补位 stand-in kits: stand-in charId → kit builder, in STANDIN_KIT_FILES order. */
export const STANDIN_KITS = Object.freeze(Object.assign({}, ...STANDIN_MODULES.map(registryOf))); export const STANDIN_KITS = Object.freeze(Object.assign({}, ...STANDIN_MODULES.map(registryOf)));
/** The merged kit registry: baseChessId → (bb, chess, def) => Kit, tier 1 … tier 6, then the stand-ins' charIds. */ /** The 自选 operator kits: owned-6★ charId → kit builder, in OPERATOR_KIT_FILES order. */
export const KITS = Object.freeze(Object.assign({}, ...TIER_KITS, STANDIN_KITS)); export const OPERATOR_KITS = Object.freeze(Object.assign({}, ...OPERATOR_MODULES.map(registryOf)));
/**
* The merged kit registry: baseChessId → (bb, chess, def) => Kit, tier 1 … tier 6, then the stand-ins' charIds, then
* the 自选 operators' charIds.
*/
export const KITS = Object.freeze(Object.assign({}, ...TIER_KITS, STANDIN_KITS, OPERATOR_KITS));
/**
* Every character a 自选 pick may field with a faithful kit: the 预备干员 (GENERIC_KIT_CHARS), the stand-ins with a kit
* file and the operators of OPERATOR_KIT_FILES whose file loaded. The `kitted` option of shared/diy.js diyPool /
* validateDiyPicks.
*/
export const KITTED_CHARS = Object.freeze([...new Set([...GENERIC_KIT_CHARS, ...Object.keys(STANDIN_KITS), ...Object.keys(OPERATOR_KITS)])]);
+127
View File
@@ -0,0 +1,127 @@
// server/sim/content/kits/ops/op-siege.js — 推进之王 (char_112_siege) 自选 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_112_siege, 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 推进之王
// (talent 修正 "在场时"; 粉碎 备注 "本天赋无视自身的阻回,可响应飞行单位的死亡"; S2 备注 "技能固定动画不受攻击速度影响";
// S3 "攻击间隔增大(+1.0)").
// - Trait (尖兵) "能够阻挡两个敌人": the profession default (block 2). Module SOL-X “糖果盒” overrides it with
// "阻挡敌人时攻击力和防御力各+8%" (trait bb atk / def): +atk / +def while she blocks at least one enemy.
// - T1 万兽之王 "在场时,所有【先锋】职业干员的攻击力和防御力各+8%": an aura on every 【先锋】 operator of the field (her
// included) while she is deployed (installAura: the kits' convention for field-wide talents — teammates' pioneers in a
// shared field too; several sources keep the strongest). SOL-X stage 2+ adds "自身攻击力和防御力额外+N%" (the module
// talent change's bb, +8 % at stage 3): a self buff on top; the aura value stays the base talent's (talentsBase).
// - T2 粉碎 "周围四格内有敌人倒下时获得1点技力" (talent range x-5: her tile and the four next to it): +sp SP when an enemy
// dies there — flying enemies too, and under her own 阻回 (PRTS 备注) — never while a timed skill runs (AK: no SP during
// a skill). SOL-Y stage 2+ raises it to sp (3 at stage 3) "场上随机另一名【先锋】职业干员获得1点技力": the hidden module
// talent's sp to one other 【先锋】 operator of the field, drawn uniformly [ASSUMED: any deployed other pioneer, a draw that
// cannot take SP wastes it].
// - Module SOL-Y “初诺” "首次部署时部署费用-4" (hidden runtime_cost −4): the first deployment of a battle costs nothing in
// this mode ⇒ no effect (as 德克萨斯's 战术快递 elite note); its HP / ATK are in the stats.
// - 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 kit casts the same skill — the owner's AUTO rule, kits/README.md checklist 5).
// - S2 跃空锤 (AUTO, 可充能 2 / 3 次): the next attack hits every enemy around her — the skill range x-5 — for atk_scale × ATK
// physical and gives cost DP; the data's DEFAULT trigger (an AUTO "next attack" waits for her attack). The AoE is the
// attack itself, so it reaches the ground enemies a melee attack can [ASSUMED: no source says it reaches air]. The fixed
// skill animation (PRTS 备注) does not change the sim's attack timing.
// - S3 碎颅击 (MANUAL, 18 / 21 s): attack interval +base_attack_time s (a flat +1.0 on her 1.05 s), each attack at
// attack@atk_scale × ATK with attack@buff_prob to stun the target attack@stun s.
import { num, talentBb, talentGrid, moduleBb, traitBb, moduleOn, skillRec, statBuff, toggleBuff, installAura, batMod,
up, skillBusy, giveSp } from '../shared/tier1.js';
import { absoluteRangeKeys } from '../../../targeting.js';
import { bodyInKeys } from '../../../body.js';
const S1 = 'skcom_charge_cost[3]';
const S2 = 'skchr_siege_2';
const S3 = 'skchr_siege_3';
/** The four tiles around her and her own (talent / skill range x-5) when the data carries no grid. */
const X5 = Object.freeze([[1, 0], [0, -1], [0, 0], [0, 1], [-1, 0]]);
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
/** A 【先锋】 operator (summons are never 【先锋】 operators). */
const isPioneer = (a) => !!a && a.kind === 'op' && a.def?.profession === 'PIONEER';
/** 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;
}
export default {
char_112_siege: (bb, chess) => {
const base0 = talentBb({ talents: chess?.talentsBase ?? chess?.talents }, 0); // 万兽之王 without the module change
const mod = activeModule(chess);
const self0 = mod?.talentChanges?.find((t) => t && t.talentIndex === 0)?.bb ?? {}; // SOL-X stage 2+: 自身额外
const t1 = talentBb(chess, 1);
const grid1 = talentGrid(chess, 1) ?? X5;
const hidden = moduleBb(chess); // SOL-Y: runtime_cost (no effect), sp to another pioneer (stage 2+)
const tb = traitBb(chess);
const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3);
const s2 = skillRec(chess, S2);
return {
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',
targeting: { rangeGrid: s2?.rangeGrid ?? X5, allInRange: true },
attack: { atkScale: num(b2.atk_scale, 1) },
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 });
battle.fx('aoe', { x: unit.x, y: unit.y, radius: 1, id: unit.id, skill: 'siege:hammer' });
},
},
[S3]: {
kind: 'duration',
mods: { batPct: batMod(b3.base_attack_time, chess) },
attack: {
atkScale: num(b3['attack@atk_scale'], 1),
onHit({ battle, unit, target }) {
if (target && target.alive && target.side === 'enemy' && battle.rng.chance(num(b3['attack@buff_prob']))) {
battle.applyStatus(target, 'stun', { duration: num(b3['attack@stun']), source: unit });
}
},
},
},
},
talents: [
{ install(battle, unit) { // 万兽之王: the field's 【先锋】 ATK / DEF +atk / +def; SOL-X stage 2+: her own extra
const a = num(base0.atk), d = num(base0.def);
if (a || d) installAura(battle, unit, { key: 'talent:siege:pioneers', value: a, select: isPioneer, mods: { atkPct: a, defPct: d } });
statBuff(battle, unit, 'talent:siege:self', { atkPct: num(self0.atk), defPct: num(self0.def) });
} },
{ install(battle, unit) { // 粉碎: an enemy falls on her x-5 ⇒ +sp SP (阻回 ignored); SOL-Y: +1 SP to another pioneer
const sp = num(t1.sp), other = num(hidden.sp);
if (!(sp > 0)) return;
battle.on('death', (ctx) => {
const e = ctx.unit;
if (e.side !== 'enemy' || ctx.reason !== 'killed' || !up(unit) || !unit.skill) return;
if (!bodyInKeys(e, new Set(absoluteRangeKeys(grid1, unit.tileR, unit.tileC, unit.dir, 0)))) return;
// "无视自身的阻回": the 'init' reason passes 阻回; a running timed skill still takes no SP
if (!skillBusy(unit)) unit.skill.gainSp(sp, 'init');
if (other > 0) {
const mates = battle.alliesFor(unit).filter((a) => a !== unit && isPioneer(a));
if (mates.length) giveSp(battle.rng.pick(mates), other);
}
}, { owner: unit });
} },
],
install(battle, unit) {
// SOL-X “糖果盒”: 阻挡敌人时攻击力和防御力各+8%
const ma = num(tb.atk), md = num(tb.def);
if (ma || md) toggleBuff(battle, unit, 'trait:siege:block', () => unit.blocking.length > 0, { atkPct: ma, defPct: md });
},
};
},
};
+5 -2
View File
@@ -144,10 +144,13 @@ function withLoadout(v, lo) {
return out; return out;
} }
/** The owner's own variant of a token record (chess id, else its normal `_a` sibling) with its loadout; null if none. */ /**
* The owner's own variant of a token record (chess id, else its normal `_a` sibling; a 自选 piece's: its owner form,
* `def.tokenOwner`) with its loadout; null if none.
*/
function ownVariant(raw, owner) { function ownVariant(raw, owner) {
const vs = raw?.variants; const vs = raw?.variants;
const oid = owner?.defId; const oid = owner?.def?.tokenOwner ?? owner?.defId;
if (!vs || typeof vs !== 'object' || !oid) return null; if (!vs || typeof vs !== 'object' || !oid) return null;
const v = vs[oid] ?? vs[String(oid).replace(/_b$/, '_a')] ?? null; const v = vs[oid] ?? vs[String(oid).replace(/_b$/, '_a')] ?? null;
return v ? withLoadout(v, owner.def?.loadout) : null; return v ? withLoadout(v, owner.def?.loadout) : null;
+75 -5
View File
@@ -21,6 +21,12 @@
// body, skill `backup.skillIndex` and module `backup.uniEquipId` — any other skill / module choice is ignored), with // body, skill `backup.skillIndex` and module `backup.uniEquipId` — any other skill / module choice is ignored), with
// `def.standInFor` = the replaced operator's charId and `def.charId` = the stand-in's (content/index.js looks its kit // `def.standInFor` = the replaced operator's charId and `def.charId` = the stand-in's (content/index.js looks its kit
// up by that charId). A PRESET / DIY chess, or a source without backups.json, gives the chess's own def instead. // up by that charId). A PRESET / DIY chess, or a source without backups.json, gives the chess's own def instead.
// 自选 picks (DATA.md §18): `getChess(slotId, { diy: { charId, skillIndex, uniEquipId } })` = the def of a DIY slot (its
// `_a` / `_b` id) filled with that operator (shared/diy.js diyRecordOf: the slot's tier / price / status, no 特质, the
// pick's derived bonds, the operator's body, skill and module — a prototype's locked ones), with `def.diyFor` = the
// slot's base id, `def.charId` = the operator (its kit, content/index.js kitOf), `def.loadout.diy` = the checked pick and
// `def.tokenOwner` = the key of its summons' variants (data/backups.json `tokens`, read by getToken); null for an illegal
// pick.
// //
// This module is pure ESM shared with browsers (served at /sim/): no Node API is imported here. Under Node the default // This module is pure ESM shared with browsers (served at /sim/): no Node API is imported here. Under Node the default
// loader (./nodeData.js) is imported dynamically at module evaluation; browsers never load it. // loader (./nodeData.js) is imported dynamically at module evaluation; browsers never load it.
@@ -31,6 +37,7 @@
import { resolveRecordLoadout, composeStats, composeTalents, loadoutRecord } from '../../shared/loadoutRecord.js'; import { resolveRecordLoadout, composeStats, composeTalents, loadoutRecord } from '../../shared/loadoutRecord.js';
import { standInRecord } from '../../shared/standIn.js'; import { standInRecord } from '../../shared/standIn.js';
import { diyRecordOf, diyTokenOwner } from '../../shared/diy.js';
import { normHitArea } from './body.js'; import { normHitArea } from './body.js';
// --------------------------------------------------------------------------------------------------------------- // ---------------------------------------------------------------------------------------------------------------
@@ -201,6 +208,8 @@ export function normalizeChess(rec) {
avatar: rec.assets?.avatar ?? rec.avatar ?? rec.charId ?? null, avatar: rec.assets?.avatar ?? rec.avatar ?? rec.charId ?? null,
// a 补位 stand-in (shared/standIn.js): the replaced operator's charId — `charId` is the stand-in's (absent otherwise) // a 补位 stand-in (shared/standIn.js): the replaced operator's charId — `charId` is the stand-in's (absent otherwise)
...(rec.standInFor ? { standInFor: rec.standInFor } : null), ...(rec.standInFor ? { standInFor: rec.standInFor } : null),
// a 自选 piece (shared/diy.js): the slot's base id — `charId` is the operator's (absent otherwise)
...(rec.diyFor ? { diyFor: rec.diyFor } : null),
raw: rec, raw: rec,
}; };
} }
@@ -541,7 +550,8 @@ export class DataSource {
rawEnemy(key) { rawEnemy(key) {
return this.raw.enemies[key] ?? this.raw.enemies['enemy_' + key] ?? this.raw.enemies[String(key).replace(/^enemy_/, '')] ?? this.fallback?.rawEnemy(key) ?? null; return this.raw.enemies[key] ?? this.raw.enemies['enemy_' + key] ?? this.raw.enemies[String(key).replace(/^enemy_/, '')] ?? this.fallback?.rawEnemy(key) ?? null;
} }
rawToken(id) { return this.raw.tokens[id] ?? this.fallback?.rawToken(id) ?? null; } /** A token record: data/tokens.json, else the 自选 picks' summons (data/backups.json `tokens`). */
rawToken(id) { return this.raw.tokens[id] ?? this.fallback?.rawToken(id) ?? this.rawBackups()?.tokens?.[id] ?? null; }
rawStage(id) { return this.raw.stages[id] ?? this.fallback?.rawStage(id) ?? null; } rawStage(id) { return this.raw.stages[id] ?? this.fallback?.rawStage(id) ?? null; }
rawWave(id) { return this.raw.waves[id] ?? this.fallback?.rawWave(id) ?? null; } rawWave(id) { return this.raw.waves[id] ?? this.fallback?.rawWave(id) ?? null; }
/** data/backups.json (`{ units, diy }`) of this source or its fallback, or null. */ /** data/backups.json (`{ units, diy }`) of this source or its fallback, or null. */
@@ -553,9 +563,11 @@ export class DataSource {
* without skill choices); `def.id` is always the chess id. Defs are cached per (id, resolved loadout). * without skill choices); `def.id` is always the chess id. Defs are cached per (id, resolved loadout).
* `loadout.standIn === true`: the 补位 def of the chess (getStandIn) — its skill and module are the chess's backup * `loadout.standIn === true`: the 补位 def of the chess (getStandIn) — its skill and module are the chess's backup
* selection whatever `skillIndex` / `moduleId` say; a chess without a stand-in (PRESET, DIY, no backups data) gives * selection whatever `skillIndex` / `moduleId` say; a chess without a stand-in (PRESET, DIY, no backups data) gives
* its own def for that loadout. * its own def for that loadout. `loadout.diy` (`{ charId, skillIndex, uniEquipId }`): the 自选 def of a DIY slot
* (getDiy; null when the pick is not legal for that slot).
*/ */
getChess(id, loadout = null) { getChess(id, loadout = null) {
if (loadout && loadout.diy && typeof loadout.diy === 'object') return this.getDiy(id, loadout.diy);
if (loadout && loadout.standIn === true) { if (loadout && loadout.standIn === true) {
const sd = this.getStandIn(id); const sd = this.getStandIn(id);
if (sd) return sd; if (sd) return sd;
@@ -595,6 +607,32 @@ export class DataSource {
this._chess.set(key, d); this._chess.set(key, d);
return d; return d;
} }
/**
* The 自选 def of DIY slot `id` (its `_a` or `_b` record) filled with `pick` (DATA.md §18): shared/diy.js diyRecordOf
* over this source (chess records, backups.json), normalised — `def.id` / `baseId` / `golden` / `tier` / `bonds` (the
* pick's derived ones) of the slot, every combat field of the operator at the slot's status with the pick's skill and
* module, `def.charId` the operator's, `def.diyFor` the slot's base id, `def.loadout` = the selection (`isDefault`) with
* `diy` = the checked pick, `def.tokenOwner` = its summons' variant key (shared/diy.js diyTokenOwner). Null for an
* illegal pick (not a pick of the slot's tier, a prototype off its locked skill, an unknown skill or module — checkDiyPick)
* or when the data lacks it. Cached per (id, pick).
* @param {string} id
* @param {{ charId?: string, skillIndex?: number|null, uniEquipId?: string|null }} pick
*/
getDiy(id, pick) {
const key = `${id}|diy|${pick?.charId ?? ''}|${pick?.skillIndex ?? ''}|${pick?.uniEquipId ?? ''}`;
if (this._chess.has(key)) return this._chess.get(key);
const r = this.rawChess(id);
const rec = r && r.isDiy ? diyRecordOf(r, pick, this) : null;
let d = null;
if (rec) {
d = normalizeChess({ ...rec, chessId: id });
d.loadout = { ...resolveLoadout(rec, null), diy: { charId: rec.charId, skillIndex: rec.skill.index, uniEquipId: rec.module?.id ?? null } };
d.tokenOwner = diyTokenOwner(rec.charId, rec.status);
freezeDef(d);
}
this._chess.set(key, d);
return d;
}
getEnemy(key) { getEnemy(key) {
if (this._enemy.has(key)) return this._enemy.get(key); if (this._enemy.has(key)) return this._enemy.get(key);
const r = this.rawEnemy(key); const r = this.rawEnemy(key);
@@ -611,6 +649,7 @@ export class DataSource {
* than 'display'; PlayerState / the kits' skill summons read them from here). * than 'display'; PlayerState / the kits' skill summons read them from here).
*/ */
getToken(id, ownerChessId = null, ownerLoadout = null) { getToken(id, ownerChessId = null, ownerLoadout = null) {
if (ownerChessId && ownerLoadout && ownerLoadout.diy && typeof ownerLoadout.diy === 'object') return this.getDiyToken(id, ownerChessId, ownerLoadout.diy);
const olo = ownerChessId && ownerLoadout ? resolveLoadout(this.rawChess(ownerChessId), ownerLoadout) : null; const olo = ownerChessId && ownerLoadout ? resolveLoadout(this.rawChess(ownerChessId), ownerLoadout) : null;
const ck = id + '|' + (ownerChessId ?? '') + loadoutKey(olo); const ck = id + '|' + (ownerChessId ?? '') + loadoutKey(olo);
if (this._token.has(ck)) return this._token.get(ck); if (this._token.has(ck)) return this._token.get(ck);
@@ -642,6 +681,33 @@ export class DataSource {
this._token.set(ck, d); this._token.set(ck, d);
return d; return d;
} }
/**
* Token def of a summon of a 自选 piece: its record (data/backups.json `tokens`) with the variant of the owner form
* (`<charId>@<statusKey of the slot record>`, shared/diy.js diyTokenOwner) and the pick as the owner's loadout —
* `bySkill[skillIndex]` (the token skill / count / sources of that skill), `byModule[uniEquipId]` (module token
* attributes, trait, talents), as getToken does for a chess owner's loadout. A token without a variant of that form
* gets the record's defaults (`sources` null).
* @param {string} id token id
* @param {string} slotId the owner's DIY slot record id (`_a` / `_b`)
* @param {{ charId?: string, skillIndex?: number|null, uniEquipId?: string|null }} pick the owner def's `loadout.diy`
*/
getDiyToken(id, slotId, pick) {
const slot = this.rawChess(slotId);
const owner = slot && typeof pick?.charId === 'string' ? diyTokenOwner(pick.charId, slot.status) : null;
const ck = `${id}|diy|${owner ?? ''}|${pick?.skillIndex ?? ''}|${pick?.uniEquipId ?? ''}`;
if (this._token.has(ck)) return this._token.get(ck);
const r = this.rawToken(id);
let variant = r && r.variants && owner ? r.variants[owner] ?? null : null;
if (variant) {
const si = pick.skillIndex;
if (Number.isInteger(si) && variant.bySkill && variant.bySkill[si]) variant = { ...variant, ...variant.bySkill[si] };
const mid = pick.uniEquipId;
if (typeof mid === 'string' && variant.byModule && variant.byModule[mid]) variant = { ...variant, ...variant.byModule[mid] };
}
const d = r ? freezeDef(normalizeToken(id, r, owner, variant)) : null;
this._token.set(ck, d);
return d;
}
getStage(id) { getStage(id) {
if (this._stage.has(id)) return this._stage.get(id); if (this._stage.has(id)) return this._stage.get(id);
const d = normalizeStage(id, this.rawStage(id)); const d = normalizeStage(id, this.rawStage(id));
@@ -732,7 +798,8 @@ export function toDataSource(data) {
* moduleId })`, `getToken(id, owner, ownerDef.loadout)`) are always exact. Returns `ds` itself when no unit carries a * moduleId })`, `getToken(id, owner, ownerDef.loadout)`) are always exact. Returns `ds` itself when no unit carries a
* loadout, and an existing view unchanged (`isLoadoutView`). Used by spec.js createBattleFromSpec and by * loadout, and an existing view unchanged (`isLoadoutView`). Used by spec.js createBattleFromSpec and by
* content/index.js installContent (every Battle, however constructed). An entry with `standIn: true` (补位) maps its * content/index.js installContent (every Battle, however constructed). An entry with `standIn: true` (补位) maps its
* chess id to the stand-in def (`getChess(id, { standIn: true })`) the same way. * chess id to the stand-in def (`getChess(id, { standIn: true })`) the same way, and one with `diy` (自选: `{ charId,
* skillIndex, uniEquipId }`) to its 自选 def (`getChess(id, { diy })`).
* @param {object} ds DataSource * @param {object} ds DataSource
* @param {object[]} players PlayerBattleInput[] (spec players) * @param {object[]} players PlayerBattleInput[] (spec players)
*/ */
@@ -744,12 +811,15 @@ export function withUnitLoadouts(ds, players) {
for (const p of Array.isArray(players) ? players : []) { for (const p of Array.isArray(players) ? players : []) {
for (const u of (p && Array.isArray(p.units) ? p.units : [])) { for (const u of (p && Array.isArray(p.units) ? p.units : [])) {
if (!u || u.kind === 'token' || typeof u.chessId !== 'string') continue; if (!u || u.kind === 'token' || typeof u.chessId !== 'string') continue;
if (u.skillIndex != null || u.moduleId != null || u.standIn === true) any = true; const diy = u.diy && typeof u.diy === 'object' ? u.diy : null;
if (u.skillIndex != null || u.moduleId != null || u.standIn === true || diy) any = true;
const lo = { skillIndex: u.skillIndex ?? null, moduleId: u.moduleId ?? null }; const lo = { skillIndex: u.skillIndex ?? null, moduleId: u.moduleId ?? null };
if (u.standIn === true) lo.standIn = true; if (u.standIn === true) lo.standIn = true;
if (diy) lo.diy = diy;
const prev = map.get(u.chessId); const prev = map.get(u.chessId);
if (!prev) map.set(u.chessId, lo); if (!prev) map.set(u.chessId, lo);
else if (prev.skillIndex !== lo.skillIndex || prev.moduleId !== lo.moduleId || !!prev.standIn !== !!lo.standIn) conflicts.add(u.chessId); else if (prev.skillIndex !== lo.skillIndex || prev.moduleId !== lo.moduleId || !!prev.standIn !== !!lo.standIn
|| JSON.stringify(prev.diy ?? null) !== JSON.stringify(lo.diy ?? null)) conflicts.add(u.chessId);
} }
} }
if (!any) return ds; if (!any) return ds;
+4 -1
View File
@@ -2,7 +2,8 @@
// //
// b.snap = { fieldId, t, units: [[id, x, y, hp, maxHp, sp, spMax, flags, anim]], dp, killed, total } // b.snap = { fieldId, t, units: [[id, x, y, hp, maxHp, sp, spMax, flags, anim]], dp, killed, total }
// UnitInfo = { id, kind, side, ownerId, defId, name, tier, golden, spine, avatar, x, y, facing, dir, maxHp, motion?, boss?, uid?, // UnitInfo = { id, kind, side, ownerId, defId, name, tier, golden, spine, avatar, x, y, facing, dir, maxHp, motion?, boss?, uid?,
// form?, skillIndex?, moduleId?, items?, standInFor? } (standInFor = the replaced operator's charId of a 补位 stand-in; // form?, skillIndex?, moduleId?, items?, standInFor?, diy? } (standInFor = the replaced operator's charId of a 补位
// stand-in; diy = a 自选 piece's pick { charId, skillIndex, uniEquipId } — defId is its slot, spine / avatar the operator's;
// form = the unit's current model form — an enemy's, content/enemies/helpers.js setForm: // form = the unit's current model form — an enemy's, content/enemies/helpers.js setForm:
// 掠海漂移体 'crawl', 暴鸰 'bombed', 转译基底·α's forms …; a 傀儡师 fighting as its 替身 'doll', professions.js — a view built // 掠海漂移体 'crawl', 暴鸰 'bombed', 转译基底·α's forms …; a 傀儡师 fighting as its 替身 'doll', professions.js — a view built
// after the change, a field opened mid-battle, draws it: render/units.js FORMS) // after the change, a field opened mid-battle, draws it: render/units.js FORMS)
@@ -55,6 +56,8 @@ export function unitInfo(u) {
// 补位: the replaced operator's charId when the chess fights as its stand-in (spine / avatar are the stand-in's; the // 补位: the replaced operator's charId when the chess fights as its stand-in (spine / avatar are the stand-in's; the
// detail card composes the stand-in record, shared/standIn.js standInRecord) // detail card composes the stand-in record, shared/standIn.js standInRecord)
standInFor: u.side === 'ally' && u.kind === 'op' && typeof d.standInFor === 'string' ? d.standInFor : undefined, standInFor: u.side === 'ally' && u.kind === 'op' && typeof d.standInFor === 'string' ? d.standInFor : undefined,
// 自选: the pick of a DIY slot's piece (the detail card composes its record, shared/diy.js diyRecord)
diy: u.side === 'ally' && u.kind === 'op' && d.diyFor && d.loadout?.diy ? { ...d.loadout.diy } : undefined,
}; };
} }
+25 -3
View File
@@ -22,6 +22,9 @@
// ownerLoadout)); an explicit loadout argument always wins over the view's per-chess lookup. // ownerLoadout)); an explicit loadout argument always wins over the view's per-chess lookup.
// 补位 (DATA.md §18): an operator entry with `standIn: true` (kept only when exactly `true`) is fielded as its chess's // 补位 (DATA.md §18): an operator entry with `standIn: true` (kept only when exactly `true`) is fielded as its chess's
// stand-in (simdata getChess(id, { standIn: true }); its skill / module are the chess's backup selection). // stand-in (simdata getChess(id, { standIn: true }); its skill / module are the chess's backup selection).
// 自选 (DATA.md §18): an operator entry of a DIY slot (`chessId` its `_a` / `_b` id) carries `diy: { charId, skillIndex?,
// uniEquipId? }` (kept when well-formed; the data layer checks legality — shared/diy.js checkDiyPick) and is fielded as
// simdata getChess(id, { diy }).
import { Battle } from './Battle.js'; import { Battle } from './Battle.js';
import { toDataSource, withUnitLoadouts } from './simdata.js'; import { toDataSource, withUnitLoadouts } from './simdata.js';
@@ -79,14 +82,33 @@ export function buildBattleSpec(o = {}) {
const LOADOUT_ID = /^[A-Za-z0-9_\-]{1,64}$/; const LOADOUT_ID = /^[A-Za-z0-9_\-]{1,64}$/;
/** /**
* Keep a unit's `skillIndex` / `moduleId` only when well-formed (the data layer checks legality) and `standIn` only * A well-formed 自选 pick `{ charId, skillIndex?, uniEquipId? }` reduced to those fields (skill 0–9 or null, module a
* when exactly `true` (补位). Mutates `u`. * loadout id or null), or null.
*/
function cleanDiyPick(d) {
if (!d || typeof d !== 'object' || Array.isArray(d) || typeof d.charId !== 'string' || !LOADOUT_ID.test(d.charId)) return null;
const out = { charId: d.charId };
if (d.skillIndex != null) {
if (!(Number.isInteger(d.skillIndex) && d.skillIndex >= 0 && d.skillIndex <= 9)) return null;
out.skillIndex = d.skillIndex;
}
if (d.uniEquipId != null) {
if (!(typeof d.uniEquipId === 'string' && LOADOUT_ID.test(d.uniEquipId))) return null;
out.uniEquipId = d.uniEquipId;
}
return out;
}
/**
* Keep a unit's `skillIndex` / `moduleId` only when well-formed (the data layer checks legality), `standIn` only when
* exactly `true` (补位) and `diy` only as a well-formed pick (自选). Mutates `u`.
*/ */
export function sanitizeUnitLoadout(u) { export function sanitizeUnitLoadout(u) {
if ('skillIndex' in u && !(Number.isInteger(u.skillIndex) && u.skillIndex >= 0 && u.skillIndex <= 9)) delete u.skillIndex; if ('skillIndex' in u && !(Number.isInteger(u.skillIndex) && u.skillIndex >= 0 && u.skillIndex <= 9)) delete u.skillIndex;
if ('moduleId' in u && !(typeof u.moduleId === 'string' && LOADOUT_ID.test(u.moduleId))) delete u.moduleId; if ('moduleId' in u && !(typeof u.moduleId === 'string' && LOADOUT_ID.test(u.moduleId))) delete u.moduleId;
if ('standIn' in u && u.standIn !== true) delete u.standIn; if ('standIn' in u && u.standIn !== true) delete u.standIn;
if (u.kind === 'token') { delete u.skillIndex; delete u.moduleId; delete u.standIn; } if ('diy' in u) { const d = cleanDiyPick(u.diy); if (d) u.diy = d; else delete u.diy; }
if (u.kind === 'token') { delete u.skillIndex; delete u.moduleId; delete u.standIn; delete u.diy; }
return u; return u;
} }
+240
View File
@@ -0,0 +1,240 @@
// shared/diy.js — 自选编队: a 自选 (DIY) slot filled with an operator. Pure ESM shared by the simulation
// (server/sim/simdata.js getChess(id, { diy })), the server and the client. Data: data/backups.json `units` / `tokens` /
// `diy` (docs/DATA.md §18), composed with shared/standIn.js composeUnitRecord into a record shaped like a data/chess.json
// record, so normalizeChess, resolveLoadout / loadoutRecord and the detail card read it unchanged.
//
// The rules (research 0.2.0 §2; PRTS 卫戍协议, 卫戍协议:盟约 下半/PRTS盟约记录; the owner's decisions of 2026-10-05):
// - two slots per tier, 5 and 6: chess_char_5_diy1/2_a, chess_char_6_diy1/2_a, each with its `_b` elite twin (tier,
// price, merge of the slot: buy 4, sell 1);
// - the picks of a tier: the owned 6★ outside the chess pool (`diy.ownedPool`: no preset operator, no collab) and the
// nine 6★ prototypes at both tiers, the six 4★ reserves (not 先锋 / 特种) at tier 5 only (`diy.prototypes[tier]`);
// - a prototype may fill one tier-5 and one tier-6 slot, an owned operator one slot; the two picks of a tier differ;
// - no 特质 (PRTS "甄选加入的干员不会拥有任何特质"); bonds from the operator's factions (`diy.operators[id].bonds`);
// - the normal form is E2 Lv1, skill rank 4, no module; the elite E2 Lv60, rank 7, its module at stage 1 (tier 5) or 3
// (tier 6) — the slot record's `status` (activity_table diyChessDict) picks the unit form;
// - an owned pick chooses its skill (any of 3) and module (any of its modules, or none); a prototype carries the skill
// and module of its 补位 rows at that tier (`diy.locked`: "技能携带规则与系统补位时一致" — [ASSUMED] that reading, the
// owner's decision of 2026-10-05);
// - potential 0 for everyone [ASSUMED: no account].
// Which operators have a kit (a pick without one is not offered) is the sim's kit registry
// (server/sim/content/kits/index.js KITTED_CHARS), passed in as `kitted`.
import { composeUnitRecord, unitForm, statusKey } from './standIn.js';
/** The tiers that have 自选 slots. */
export const DIY_TIERS = Object.freeze([5, 6]);
/**
* @typedef {{ charId: string, skillIndex?: number|null, uniEquipId?: string|null }} DiyPick a 自选 pick: the operator,
* its skill index (0-based; a prototype's is its locked one) and module (uniEquipId; null / absent / 'none' = none)
* @typedef {{ charId: string, skillIndex: number, uniEquipId: string|null }} DiyLoadout a checked, complete pick
* @typedef {{ chess?: Record<string, any>|null, backups?: any, rawChess?: (id: string) => any, rawBackups?: () => any }} DiyData
* the game data — `{ chess, backups }` (data/chess.json, data/backups.json) or a sim DataSource (`rawChess(id)`,
* `rawBackups()`)
* @typedef {Iterable<string>|((charId: string) => boolean)|null|undefined} Kitted the charIds with a kit, or a predicate
*/
const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
const ID = /^[A-Za-z0-9_-]{1,64}$/;
const own = (o, k) => isObj(o) && Object.prototype.hasOwnProperty.call(o, k);
/** data/backups.json of `data`, or null. @param {DiyData|null|undefined} data */
function backupsOf(data) {
const b = typeof data?.rawBackups === 'function' ? data.rawBackups() : data?.backups;
return isObj(b) ? b : null;
}
/** The data/chess.json record `id` of `data`, or null. @param {DiyData|null|undefined} data @param {string} id */
function chessOf(data, id) {
const r = typeof data?.rawChess === 'function' ? data.rawChess(id) : data?.chess?.[id];
return isObj(r) ? r : null;
}
/** The `diy` part of data/backups.json, or null. @param {DiyData|null|undefined} data */
function diyOf(data) {
const d = backupsOf(data)?.diy;
return isObj(d) && isObj(d.slots) ? d : null;
}
/**
* The 自选 slot a chess id names: its base (normal) id, its tier and both records. Null for any other id.
* @param {string} slotId a slot's normal (`_a`) or elite (`_b`) id
* @param {DiyData} data
*/
export function diySlot(slotId, data) {
const diy = diyOf(data);
if (!diy || typeof slotId !== 'string') return null;
const baseId = own(diy.slots, slotId) ? slotId : Object.keys(diy.slots).find((id) => diy.slots[id]?.goldenId === slotId);
const s = baseId ? diy.slots[baseId] : null;
if (!s) return null;
return { baseId, goldenId: s.goldenId, tier: s.tier, elite: slotId !== baseId, normal: chessOf(data, baseId), golden: chessOf(data, s.goldenId) };
}
/** The base ids of the 自选 slots (data order: tier 5 then tier 6). @param {DiyData} data @returns {string[]} */
export function diySlotIds(data) {
return Object.keys(diyOf(data)?.slots ?? {});
}
/**
* The key of a DIY owner's summon variants in data/backups.json `tokens[id].variants`: the operator and the slot form.
* @param {string} charId
* @param {object} status the slot record's `status`
*/
export function diyTokenOwner(charId, status) {
return `${charId}@${statusKey(status)}`;
}
/** Whether `charId` is a prototype pick (原型干员) of `tier`. @param {DiyData} data @param {number} tier @param {string} charId */
export function isPrototypePick(data, tier, charId) {
return (diyOf(data)?.prototypes?.[tier] ?? []).includes(charId);
}
/**
* The skill / module a prototype carries in a slot of `tier` (`diy.locked`), or null for anyone else.
* @param {DiyData} data @param {number} tier @param {string} charId
* @returns {{ skillIndex: number, uniEquipId: string|null }|null}
*/
export function lockedSelection(data, tier, charId) {
const l = diyOf(data)?.locked?.[tier]?.[charId];
return isObj(l) && Number.isInteger(l.skillIndex) ? { skillIndex: l.skillIndex, uniEquipId: l.uniEquipId ?? null } : null;
}
/** A `kitted` option as a predicate (no option = everyone). @param {Kitted} kitted @returns {(id: string) => boolean} */
function kittedTest(kitted) {
if (kitted == null) return () => true;
if (typeof kitted === 'function') return (id) => !!kitted(id);
const set = new Set(kitted);
return (id) => set.has(id);
}
/**
* The legal picks of a tier: its prototypes (`diy.prototypes[tier]`), then the owned 6★ (`diy.ownedPool`), in data
* order. With `kitted`, only the operators that have a kit (a pick without one is not offered).
* @param {number} tier 5 or 6
* @param {{ data: DiyData, kitted?: Kitted }} opts
* @returns {string[]}
*/
export function diyPool(tier, { data, kitted = null } = { data: null }) {
const diy = diyOf(data);
if (!diy || !DIY_TIERS.includes(tier)) return [];
const ok = kittedTest(kitted);
return [...new Set([...(diy.prototypes?.[tier] ?? []), ...(diy.ownedPool ?? [])])].filter(ok);
}
/**
* Check one pick against a slot and complete it: the operator must be a pick of the slot's tier; a prototype takes its
* locked skill and module (a different one given is an error); an owned operator needs a skill index of its form and
* may name a module of its elite form at the slot's stage. Both slot forms must exist in the data.
* @param {string} slotId a slot's normal or elite id
* @param {DiyPick|null|undefined} pick
* @param {DiyData} data
* @returns {{ ok: true, pick: DiyLoadout } | { error: string }}
*/
export function checkDiyPick(slotId, pick, data) {
const slot = diySlot(slotId, data);
if (!slot) return { error: `not a 自选 slot: ${slotId}` };
if (!isObj(pick) || typeof pick.charId !== 'string' || !ID.test(pick.charId)) return { error: `${slot.baseId}: bad pick` };
const { charId } = pick;
const diy = diyOf(data);
const proto = isPrototypePick(data, slot.tier, charId);
if (!proto && !(diy.ownedPool ?? []).includes(charId)) return { error: `${charId} is not a tier-${slot.tier} 自选 pick` };
const want = pick.skillIndex ?? null;
const wantMod = pick.uniEquipId === undefined || pick.uniEquipId === 'none' ? null : pick.uniEquipId;
if (want !== null && !(Number.isInteger(want) && want >= 0 && want <= 9)) return { error: `${charId}: bad skill index ${want}` };
if (wantMod !== null && (typeof wantMod !== 'string' || !ID.test(wantMod))) return { error: `${charId}: bad module ${wantMod}` };
const forms = [slot.normal, slot.golden].map((rec) => (rec ? unitForm(backupsOf(data), charId, rec.status) : null));
if (forms.some((f) => !f)) return { error: `${charId}: no form for ${slot.baseId}` };
let skillIndex, uniEquipId;
if (proto) {
const lk = lockedSelection(data, slot.tier, charId);
if (!lk) return { error: `${charId}: no locked selection at tier ${slot.tier}` };
// omitted / null = its locked selection; another skill, another module or an explicit 'none' is refused
if (want !== null && want !== lk.skillIndex) return { error: `${charId}: a prototype carries skill ${lk.skillIndex} (与系统补位时一致)` };
if (pick.uniEquipId != null && wantMod !== lk.uniEquipId) return { error: `${charId}: a prototype carries module ${lk.uniEquipId ?? 'none'}` };
({ skillIndex, uniEquipId } = lk);
} else {
if (want === null) return { error: `${charId}: an owned pick needs a skill index` };
skillIndex = want;
uniEquipId = wantMod;
}
if (!forms.every((f) => (f.skills ?? []).some((s) => s && s.index === skillIndex))) return { error: `${charId}: no skill ${skillIndex}` };
const elite = forms[1];
if (uniEquipId !== null && !(elite.modules ?? []).some((m) => m && m.uniEquipId === uniEquipId)) {
return { error: `${charId}: no module ${uniEquipId} at stage ${slot.golden?.status?.equipLevel ?? '?'}` };
}
return { ok: true, pick: { charId, skillIndex, uniEquipId } };
}
/**
* A 自选 slot record (either form) filled with a checked pick: composeUnitRecord of the slot's identity (tier, price,
* merge, status; no 特质) with the operator's form at the slot's status, the pick's skill and module (active on the elite
* form only), the derived bonds — plus `diyFor` (the slot's base id: the record is a 自选 piece) and `charId` (the
* operator). Null when the pick is not legal for the slot (checkDiyPick) or the data lacks a part.
* @param {object} slot a DIY chess record (data/chess.json, `isDiy`)
* @param {DiyPick} pick
* @param {DiyData} data
* @returns {object|null}
*/
export function diyRecordOf(slot, pick, data) {
if (!isObj(slot) || !slot.isDiy || typeof slot.chessId !== 'string') return null;
const c = checkDiyPick(slot.chessId, pick, data);
if (!('ok' in c)) return null;
const { charId, skillIndex, uniEquipId } = c.pick;
const backups = backupsOf(data);
const unit = backups?.units?.[charId] ?? null;
const rec = composeUnitRecord(slot, unit, unitForm(backups, charId, slot.status),
{ skillIndex, moduleId: uniEquipId, bonds: diyOf(data)?.operators?.[charId]?.bonds ?? null });
if (!rec || !rec.skill) return null;
rec.diyFor = slot.baseId ?? slot.chessId;
return rec;
}
/**
* The record of a 自选 slot filled with `pick` (diyRecordOf): `elite` = the slot's `_b` form (E2 Lv60, module stage 1 at
* tier 5 / 3 at tier 6), else the normal form (E2 Lv1, no module).
* @param {string} slotId the slot's base id (an elite id is accepted and taken as the elite)
* @param {DiyPick} pick
* @param {{ elite?: boolean, data: DiyData }} opts
* @returns {object|null}
*/
export function diyRecord(slotId, pick, { elite = false, data } = { data: null }) {
const slot = diySlot(slotId, data);
if (!slot) return null;
return diyRecordOf(elite || slot.elite ? slot.golden : slot.normal, pick, data);
}
/**
* Check a whole 自选 roster: `picks` maps slot base ids to a pick (absent / null = an empty slot). Strict: an unknown
* slot, an illegal pick (checkDiyPick), an operator without a kit (when `kitted` is given), the same operator twice in
* one tier, or an owned operator in two slots rejects the roster; a prototype may fill a tier-5 and a tier-6 slot.
* @param {any} picks
* @param {{ data: DiyData, kitted?: Kitted }} opts
* @returns {{ ok: true, picks: Record<string, DiyLoadout> } | { error: 'BAD_MSG'|'BAD_TARGET', detail: string }}
*/
export function validateDiyPicks(picks, { data, kitted = null } = { data: null }) {
if (!isObj(picks)) return { error: 'BAD_MSG', detail: 'bad 自选 picks' };
const diy = diyOf(data);
if (!diy) return { error: 'BAD_TARGET', detail: 'no 自选 data' };
const ok = kittedTest(kitted);
/** @type {Record<string, DiyLoadout>} */
const out = {};
const byTier = new Map();
const ownedSeen = new Map();
for (const [slotId, pick] of Object.entries(picks)) {
if (!own(diy.slots, slotId)) return { error: 'BAD_TARGET', detail: `not a 自选 slot: ${slotId}` };
if (pick == null) continue;
const c = checkDiyPick(slotId, pick, data);
if (!('ok' in c)) return { error: 'BAD_TARGET', detail: c.error };
const { charId } = c.pick;
if (!ok(charId)) return { error: 'BAD_TARGET', detail: `${charId} has no kit yet` };
const tier = diy.slots[slotId].tier;
const inTier = byTier.get(tier) ?? new Set();
if (inTier.has(charId)) return { error: 'BAD_TARGET', detail: `${charId} fills two tier-${tier} slots` };
inTier.add(charId);
byTier.set(tier, inTier);
if (!isPrototypePick(data, tier, charId)) {
if (ownedSeen.has(charId)) return { error: 'BAD_TARGET', detail: `${charId} fills ${ownedSeen.get(charId)} and ${slotId} (an owned operator fills one slot)` };
ownedSeen.set(charId, slotId);
}
out[slotId] = c.pick;
}
return { ok: true, picks: out };
}
+93
View File
@@ -0,0 +1,93 @@
// test/assets-diy.test.js — the art of the 自选 picks in the asset pipeline (tools/fetch-assets.mjs dataExtras,
// tools/assets/plan.mjs patternOperator / buildPlan `extraOperators` / `moduleTypes`; docs/ASSETS.md): the owned-6★
// units of data/backups.json are planned from research 07's URL patterns (avatar, portrait — E2 too —, battle Spine
// Front / Back, every skill icon), their summons as tokens, and every module's type icon as manifest `modules`; the
// committed data/assets.json lists them (checked against public/assets when present); --add-only never rewrites a file.
// Run: node --test test/assets-diy.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, readFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { dataExtras, parseArgs } from '../tools/fetch-assets.mjs';
import { patternOperator, buildPlan } from '../tools/assets/plan.mjs';
import { indexAudio } from '../tools/assets/audio.mjs';
import { Downloader } from '../tools/assets/downloader.mjs';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const load = (f) => JSON.parse(readFileSync(join(ROOT, 'data', `${f}.json`), 'utf8'));
const BACKUPS = load('backups');
const CHESS = load('chess');
const YUAN = 'https://raw.githubusercontent.com/yuanyan3060/ArknightsGameResource/main/';
const FEXLI = 'https://raw.githubusercontent.com/fexli/ArknightsResource/main/';
test('dataExtras: every backups.json unit (skills by index, once), the 自选 summons, the type icon of every module of chess.json and backups.json', () => {
const x = dataExtras(BACKUPS, CHESS);
assert.deepEqual(Object.keys(x.extraOperators), Object.keys(BACKUPS.units));
assert.deepEqual(x.extraOperators.char_112_siege, {
name: '推进之王', subProfessionId: 'pioneer', nationId: 'victoria',
skills: [{ index: 0, skillId: 'skcom_charge_cost[3]', iconId: 'skcom_charge_cost[3]' }, { index: 1, skillId: 'skchr_siege_2', iconId: 'skchr_siege_2' }, { index: 2, skillId: 'skchr_siege_3', iconId: 'skchr_siege_3' }],
});
assert.deepEqual(x.tokenIds, Object.keys(BACKUPS.tokens).sort());
const types = new Set();
for (const c of Object.values(CHESS)) for (const m of c.modules || []) types.add(m.typeIcon);
for (const u of Object.values(BACKUPS.units)) for (const f of Object.values(u.forms)) for (const m of f.modules || []) types.add(m.typeIcon);
assert.deepEqual(x.moduleTypes, [...types].sort());
assert.ok(x.moduleTypes.includes('sol-x') && x.moduleTypes.includes('sol-y'));
assert.deepEqual(dataExtras(null, null), { extraOperators: {}, tokenIds: [], moduleTypes: [] });
});
test('patternOperator / buildPlan: an operator research 07 lacks gets the 07 URL patterns; known operators keep their research record; module type icons become `modules`', () => {
const o = patternOperator('char_112_siege', { subProfessionId: 'pioneer', skills: [{ index: 0, skillId: 'skcom_charge_cost[3]', iconId: 'skcom_charge_cost[3]' }] });
assert.deepEqual([o.avatar.e0e1.url, o.avatar.e2.url, o.portrait.e0e1.url, o.portrait.e2.url],
[`${YUAN}avatar/char_112_siege.png`, `${YUAN}avatar/char_112_siege_2.png`, `${YUAN}portrait/char_112_siege_1.png`, `${YUAN}portrait/char_112_siege_2.png`]);
assert.equal(o.battleSpine.front.skel, `${FEXLI}spine/char_112_siege/char_112_siege/Front/char_112_siege.skel`);
assert.equal(o.battleSpine.back.png, `${FEXLI}spine/char_112_siege/char_112_siege/Back/char_112_siege.png`);
assert.equal(o.skills[0].icon.url, `${YUAN}skill/skill_icon_skcom_charge_cost%5B3%5D.png`, 'brackets percent-encoded');
assert.match(o.subProfessionIcon, /arts\/ui\/subprofessionicon\/sub_pioneer_icon\.png$/);
const known = { name: 'x', avatar: { e0e1: { url: 'https://example.invalid/known.png' } }, skills: [] };
const t = buildPlan({
assets07: { operators: { char_609_acguad: known } }, ops03: {}, enemies05: {}, maps05: {}, audio: indexAudio({}), modelsData: {},
extraOperators: { char_112_siege: dataExtras(BACKUPS, CHESS).extraOperators.char_112_siege, char_609_acguad: { skills: [] }, 'bad id': {} },
moduleTypes: ['sol-x', 'sol-x', 'bad/type'],
});
const c = t.template.chars.char_112_siege;
assert.deepEqual([c.avatar.alts[0].rel, c.avatarE2.alts[0].rel, c.portrait.alts[0].rel, c.portraitE2.alts[0].rel],
['char/avatar/char_112_siege.png', 'char/avatar/char_112_siege_2.png', 'char/portrait/char_112_siege_1.png', 'char/portrait/char_112_siege_2.png']);
assert.deepEqual(c.spine, { front: { model: 'op:char_112_siege:front' }, back: { model: 'op:char_112_siege:back' } });
assert.deepEqual(t.models.get('op:char_112_siege:front').skillIndices, [0, 1, 2], 'a Spine clip per skill index');
for (const id of ['skcom_charge_cost[3]', 'skchr_siege_2', 'skchr_siege_3']) assert.ok(t.template.skills[id] && t.template.skillsById[id] === id, id);
assert.equal(t.template.chars.char_609_acguad.avatar.alts[0].urls[0], 'https://example.invalid/known.png', 'research 07 wins for an operator it lists');
assert.equal(t.template.chars['bad id'], undefined);
assert.deepEqual(Object.keys(t.template.modules), ['sol-x']);
assert.match(t.template.modules['sol-x'].alts[0].urls[0], /arts\/ui\/uniequiptype\/sol-x\.png$/);
assert.equal(t.template.prof.sub.pioneer.alts[0].rel, 'prof/sub/pioneer.png');
});
test('--add-only: parsed, refused with --prune / --force; its downloader keeps every existing file and records what it wrote', async () => {
assert.equal(parseArgs(['--add-only']).addOnly, true);
assert.equal(parseArgs([]).addOnly, false);
assert.throws(() => parseArgs(['--add-only', '--prune']), /never deletes or rewrites/);
assert.throws(() => parseArgs(['--add-only', '--force']), /never deletes or rewrites/);
const dl = new Downloader({ root: join(ROOT, 'data'), ledgerPath: join(ROOT, '.cache', 'test-never-written.json'), keepExisting: true, force: true, log() {} });
assert.equal(dl.force, false, '--force never applies');
assert.equal(await dl.existingSize({ rel: 'chess.json', urls: [], kind: 'png', bytes: 1 }), readFileSync(join(ROOT, 'data', 'chess.json')).length, 'an existing file is kept whatever its size / kind');
assert.equal(await dl.existingSize({ rel: 'no-such-file.png', urls: [], kind: 'png' }), -1);
assert.deepEqual([...dl.written], []);
});
test('the committed data/assets.json lists every 自选 operator\'s avatar, portrait, Front Spine and skill icons, and a type icon per module (files on disk when public/assets is here)', () => {
const m = load('assets');
const disk = existsSync(join(ROOT, 'public', 'assets', 'char'));
const onDisk = (u) => !disk || existsSync(join(ROOT, 'public', u));
for (const id of BACKUPS.diy.ownedPool) {
const c = m.chars[id];
assert.ok(c && c.avatar && c.portrait && c.spine?.front?.skel, `${id}: art`);
for (const u of [c.avatar, c.portrait, c.spine.front.skel, c.spine.front.atlas, ...c.spine.front.textures]) assert.ok(onDisk(u), `${id}: ${u} on disk`);
assert.deepEqual(Object.keys(c.spine.front.anims.skills || {}).sort(), ['0', '1', '2'], `${id}: a clip per skill index`);
for (const s of BACKUPS.units[id].forms['2/1/4/0'].skills) assert.ok(m.skills[m.skillsById[s.skillId]] || m.skills[s.iconId], `${id}: skill icon ${s.skillId}`);
}
for (const t of dataExtras(BACKUPS, CHESS).moduleTypes) assert.ok(m.modules[t] && onDisk(m.modules[t]), `module type icon ${t}`);
assert.equal(m.stats.modules, Object.keys(m.modules).length);
});
+97 -13
View File
@@ -1,7 +1,8 @@
// test/backups.test.js — the data of 补位 (stand-ins) and 自选 (DIY slots): chess.json `backup` and data/backups.json // 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). The gameplay of both features comes later; // (docs/DATA.md §18; tools/build-data.mjs buildBackups; shared/standIn.js, shared/diy.js). These tests pin the data the
// these tests pin the data the gameplay will read: the 133 base chess and their types, the 17 stand-in characters and // gameplay reads: the 133 base chess and their types, the 17 stand-in characters and their forms, every NORMAL chess
// their forms, every NORMAL chess resolving to its stand-in, the 4 DIY slots, the legal picks and the faction bonds. // resolving to its stand-in, the 4 DIY slots, the legal picks (the 72 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 // 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. // re-derived from the raw tables.
// Run: node --test test/backups.test.js // Run: node --test test/backups.test.js
@@ -61,12 +62,16 @@ test('chess: the backup fields are the official shop row, verbatim', { skip: !HA
} }
}); });
const STAT_KEYS = ['maxHp', 'atk', 'def', 'res', 'cost', 'blockCnt', 'bat', 'aspd', 'respawnTime', 'spRecovery', 'moveSpeed'];
test('backups: the 17 stand-in characters, each with a form for every status it fights at', () => { test('backups: the 17 stand-in characters, each with a form for every status it fights at', () => {
const ids = Object.keys(backups.units); const ids = Object.keys(backups.units).filter((id) => backups.units[id].standsIn.length);
assert.deepEqual(ids, [...RESERVES, ...ELITES]); assert.deepEqual(ids, [...RESERVES, ...ELITES]);
assert.deepEqual([...new Set(ofType('NORMAL').map((c) => c.backup.charId))].sort(), [...ids].sort(), 'the units are exactly the NORMAL chess backups'); assert.deepEqual(Object.keys(backups.units), [...RESERVES, ...ELITES, ...backups.diy.ownedPool], 'the stand-ins, then the owned 6★ picks');
const statKeys = ['maxHp', 'atk', 'def', 'res', 'cost', 'blockCnt', 'bat', 'aspd', 'respawnTime', 'spRecovery', 'moveSpeed']; assert.deepEqual([...new Set(ofType('NORMAL').map((c) => c.backup.charId))].sort(), [...ids].sort(), 'the stand-ins are exactly the NORMAL chess backups');
for (const [id, u] of Object.entries(backups.units)) { const statKeys = STAT_KEYS;
for (const id of ids) {
const u = backups.units[id];
assert.equal(u.charId, id); assert.equal(u.charId, id);
assert.equal(u.rarity, ELITES.includes(id) ? 6 : 4, `${id}: rarity`); assert.equal(u.rarity, ELITES.includes(id) ? 6 : 4, `${id}: rarity`);
assert.equal(u.isNotObtainable, true, `${id}: a prototype cannot be owned`); assert.equal(u.isNotObtainable, true, `${id}: a prototype cannot be owned`);
@@ -229,10 +234,81 @@ 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'); 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', () => {
const { ownedPool } = backups.diy;
assert.equal(ownedPool.length, 72);
let summoners = 0;
for (const id of ownedPool) {
const u = backups.units[id];
assert.ok(u, `${id}: unit`);
assert.deepEqual([u.charId, u.rarity, u.isNotObtainable, u.standsIn], [id, 6, false, []], `${id}: identity`);
assert.ok(u.name && u.profession && u.subProfessionId && u.subProfessionName && u.position, `${id}: identity`);
assert.ok(u.assets.avatar === id && u.assets.spine === id && u.assets.avatarGolden === `${id}_2` && u.assets.portraitGolden === `${id}_2`, `${id}: assets (E2 art on the elite form)`);
assert.deepEqual(Object.keys(u.forms), ['2/1/4/0', '2/60/7/1', '2/60/7/3'], `${id}: forms`);
const advanced = Object.keys(u.moduleNames).filter((m) => u.moduleNames[m].typeName !== 'ORIGINAL');
let summons = false;
for (const [key, f] of Object.entries(u.forms)) {
const label = `${id}@${key}`;
for (const k of STAT_KEYS) assert.ok(isFiniteNum(f.stats[k]), `${label}: stats.${k}`);
assert.ok(f.stats.maxHp > 0 && f.stats.bat > 0 && Array.isArray(f.rangeGrid) && f.rangeGrid.length > 0, `${label}: stats / range`);
assert.ok(typeof f.trait?.desc === 'string' && f.trait.desc.length > 0 && Array.isArray(f.talents), `${label}: trait / talents`);
assert.deepEqual(f.skills.map((s) => [s.index, s.level]), [0, 1, 2].map((i) => [i, f.status.skillLevel]), `${label}: every skill at the slot's skill rank`);
for (const s of f.skills) assert.ok(typeof s.trigger?.rule === 'string' && !('isDefault' in s), `${label} ${s.skillId}: trigger, no selection`);
if (f.status.equipLevel === 0) assert.equal(f.modules, undefined, `${label}: no module phase on the normal form`);
else assert.deepEqual(f.modules.map((m) => [m.uniEquipId, m.level]), advanced.map((m) => [m, f.status.equipLevel]), `${label}: every module at the stage`);
for (const t of f.tokens) {
summons = true;
const rec = backups.tokens[t];
assert.ok(rec && rec.kind === 'summon' && rec.owners.includes(`${id}@${key}`), `${label}: summon ${t}`);
const v = rec.variants[`${id}@${key}`];
assert.ok(v.stats && v.skill !== undefined && Array.isArray(v.sources), `${label}: ${t} variant`);
assert.deepEqual(Object.keys(v.bySkill || {}).map(Number), [1, 2], `${label}: ${t} per other skill`);
if (f.status.equipLevel > 0 && f.modules.length) assert.deepEqual(Object.keys(v.byModule), f.modules.map((m) => m.uniEquipId), `${label}: ${t} per module`);
}
}
if (summons) summoners++;
}
assert.equal(summoners, 27, 'owned picks with summons');
for (const [id, rec] of Object.entries(backups.tokens)) {
assert.deepEqual(Object.keys(rec.variants), rec.owners, `${id}: owners = variant keys`);
for (const o of rec.owners) assert.ok(/^char_\w+@\d+\/\d+\/\d+\/\d+$/.test(o) && ownedPool.includes(o.split('@')[0]), `${id}: owner ${o}`);
}
// spot checks (zh_CN client data): 推进之王 E2 Lv60 = 2046 HP / 484 ATK, SOL-X stage 1 ATK +60 DEF +40 and its trait, stage 3
// the 万兽之王 change; 令 S1 / S2 / S3 summon 清平 / 逍遥 / 弦惊
const siege = backups.units.char_112_siege.forms;
assert.deepEqual([siege['2/60/7/1'].stats.maxHp, siege['2/60/7/1'].stats.atk], [2046, 484]);
const solx1 = siege['2/60/7/1'].modules.find((m) => m.typeName === 'SOL-X');
assert.deepEqual([solx1.attr, solx1.traitOverride.bb, solx1.talentChanges], [{ atk: 60, def: 40 }, { atk: 0.08, def: 0.08 }, []]);
assert.deepEqual(siege['2/60/7/3'].modules.find((m) => m.typeName === 'SOL-X').talentChanges.map((t) => [t.talentIndex, t.bb]), [[0, { atk: 0.08, def: 0.08 }]]);
const ling = backups.units.char_2023_ling.forms['2/60/7/3'];
assert.deepEqual(ling.skills.map((s) => s.overrideTokenKey), ['token_10020_ling_soul1', 'token_10020_ling_soul2', 'token_10020_ling_soul3']);
const soul3 = backups.tokens.token_10020_ling_soul3.variants['char_2023_ling@2/60/7/3'];
assert.deepEqual([soul3.sources, soul3.bySkill['2'].sources], [['display'], ['skill', 'display']], '弦惊: made by S3 only');
});
test('DIY: prototype picks carry the skill / module of their 补位 rows at the slot tier (预备干员-医疗: S3, assumed)', () => {
const { locked, prototypes } = backups.diy;
assert.deepEqual(Object.keys(locked), ['5', '6']);
for (const tier of ['5', '6']) {
assert.deepEqual(Object.keys(locked[tier]), prototypes[tier], `tier ${tier}: one locked selection per prototype`);
for (const [id, l] of Object.entries(locked[tier])) {
const rows = ofType('NORMAL').filter((c) => c.tier === Number(tier) && c.backup.charId === id).map((c) => c.chessId).sort((a, b) => a.localeCompare(b, 'en', { numeric: true }));
assert.deepEqual(l.from, rows, `${id}@${tier}: from its 补位 rows`);
if (rows.length) assert.deepEqual([l.skillIndex, l.uniEquipId], [chess[rows[0]].backup.skillIndex, chess[rows[0]].backup.uniEquipId], `${id}@${tier}`);
}
}
// the 6★ elites: S3 with their own module at both tiers; 领主·Sharp: S1; the reserves: S3 without a module
for (const id of ELITES) for (const tier of ['5', '6']) {
assert.deepEqual([locked[tier][id].skillIndex, locked[tier][id].uniEquipId], [id === 'char_617_sharp2' ? 0 : 2, `uniequip_002_${id.replace(/^char_\d+_/, '')}`], `${id}@${tier}`);
}
for (const id of prototypes['5'].filter((x) => RESERVES.includes(x))) assert.deepEqual([locked['5'][id].skillIndex, locked['5'][id].uniEquipId], [2, null], id);
assert.deepEqual(locked['5'].char_605_cmedic, { skillIndex: 2, uniEquipId: null, from: [] }, '预备干员-医疗 has no tier-5 补位 row: S3 [ASSUMED]');
});
test('DIY: the owned-6★ pool and the faction → bond rule (mainPower + every subPower vs powerIdList, else 协防干员)', () => { test('DIY: the owned-6★ pool and the faction → bond rule (mainPower + every subPower vs powerIdList, else 协防干员)', () => {
const { ownedPool, operators, prototypes } = backups.diy; const { ownedPool, operators, prototypes } = backups.diy;
const roster = new Set(base.map((c) => c.charId).filter(Boolean)); const roster = new Set(base.map((c) => c.charId).filter(Boolean));
assert.equal(ownedPool.length, 78); assert.equal(ownedPool.length, 72);
for (const id of ownedPool) { for (const id of ownedPool) {
assert.ok(!roster.has(id), `${id}: a roster operator is never a pick`); assert.ok(!roster.has(id), `${id}: a roster operator is never a pick`);
assert.deepEqual([operators[id].rarity, operators[id].obtainable], [6, true], id); assert.deepEqual([operators[id].rarity, operators[id].obtainable], [6, true], id);
@@ -246,7 +322,11 @@ test('DIY: the owned-6★ pool and the faction → bond rule (mainPower + every
} }
const tally = {}; const tally = {};
for (const id of ownedPool) { const k = operators[id].bonds.join('+'); tally[k] = (tally[k] || 0) + 1; } for (const id of ownedPool) { const k = operators[id].bonds.join('+'); tally[k] = (tally[k] || 0) + 1; }
assert.deepEqual(tally, { emptyShip: 44, yanShip: 14, victoriaShip: 8, sargonShip: 4, siracusaShip: 4, lateranoShip: 2, kazimierzShip: 1, 'yanShip+victoriaShip': 1 }); assert.deepEqual(tally, { emptyShip: 39, 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夜刀, 艾拉, 玛露西尔, 丰川祥子, 结城理');
assert.deepEqual(operators.char_017_huang.bonds, ['yanShip', 'victoriaShip'], '煌: 炎 + 维多利亚 from her subPower (mainPower rhodes / elite)'); 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`); for (const id of [...prototypes['5'], ...prototypes['6']]) assert.deepEqual(operators[id].bonds, ['emptyShip'], `${id}: no faction`);
}); });
@@ -255,20 +335,24 @@ test('DIY: powers and the owned pool re-derived from character_table', { skip: !
const CT = raw('character_table'); const CT = raw('character_table');
const act = raw('activity_table').activity.AUTOCHESS_SEASON.act2autochess; const act = raw('activity_table').activity.AUTOCHESS_SEASON.act2autochess;
const roster = new Set(Object.values(act.charShopChessDatas).map((r) => r.charId).filter(Boolean)); const roster = new Set(Object.values(act.charShopChessDatas).map((r) => r.charId).filter(Boolean));
const pool = Object.entries(CT).filter(([id, c]) => id.startsWith('char_') && c.rarity === 'TIER_6' && !['TOKEN', 'TRAP'].includes(c.profession) && !c.isNotObtainable && !roster.has(id)).map(([id]) => id); const collab = (c) => [c.mainPower, ...(c.subPower || [])].some((p) => ['rainbow', 'action4', 'mujica', 'sees', 'laios'].includes(p?.teamId));
assert.deepEqual([...backups.diy.ownedPool].sort(), pool.sort()); 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());
assert.deepEqual([...backups.diy.excluded].sort(), legal.filter(([, c]) => collab(c)).map(([id]) => id).sort());
for (const [id, o] of Object.entries(backups.diy.operators)) { for (const [id, o] of Object.entries(backups.diy.operators)) {
const c = CT[id]; const c = CT[id];
const want = [...new Set([c.mainPower, ...(c.subPower || [])].flatMap((p) => [p?.nationId, p?.groupId, p?.teamId]).filter(Boolean))]; const want = [...new Set([c.mainPower, ...(c.subPower || [])].flatMap((p) => [p?.nationId, p?.groupId, p?.teamId]).filter(Boolean))];
assert.deepEqual(o.powers, want, `${id}: powers`); assert.deepEqual(o.powers, want, `${id}: powers`);
} }
// nine obtainable 6★ get their core bond from a subPower only (鸿雪, 真言, 假日威龙陈, 弑君者, 予愿安洁莉娜, 结城理, 涤火杰西卡, 薇薇安娜, 煌) // eight owned picks get their core bond from a subPower only (鸿雪, 真言, 假日威龙陈, 弑君者, 予愿安洁莉娜, 涤火杰西卡, 薇薇安娜, 煌;
// the ninth of the excel, 结城理, is a collab)
const coreOf = (powers) => Object.values(bonds).filter((b) => b.isCore && b.powerIdList.some((p) => powers.includes(p))).map((b) => b.bondId); const coreOf = (powers) => Object.values(bonds).filter((b) => b.isCore && b.powerIdList.some((p) => powers.includes(p))).map((b) => b.bondId);
const subOnly = backups.diy.ownedPool.filter((id) => { const subOnly = backups.diy.ownedPool.filter((id) => {
const m = CT[id].mainPower; const m = CT[id].mainPower;
return coreOf([m?.nationId, m?.groupId, m?.teamId].filter(Boolean)).join() !== coreOf(backups.diy.operators[id].powers).join(); return coreOf([m?.nationId, m?.groupId, m?.teamId].filter(Boolean)).join() !== coreOf(backups.diy.operators[id].powers).join();
}); });
assert.equal(subOnly.length, 9); assert.equal(subOnly.length, 8);
}); });
test('composeUnitRecord: identity from the chess, everything else from the unit (no field of the replaced operator leaks)', () => { test('composeUnitRecord: identity from the chess, everything else from the unit (no field of the replaced operator leaks)', () => {
+148
View File
@@ -0,0 +1,148 @@
// test/content/diy.test.js — 自选 pieces in battle (DATA.md §18, docs/SIM.md §12): simdata getChess(slotId, { diy }) /
// getDiy, the PlayerBattleInput `diy` field through Battle, the BattleSpec path and the per-battle loadout view, the
// kit lookup by charId (an owned 6★'s operator kit, a prototype's stand-in kit, a 预备干员's generic kit, the generic kit
// for an operator without one), the summons of an owned pick (data/backups.json `tokens`, getToken with the pick as the
// owner's loadout) and the wire info. The 推进之王 kit itself: test/content/op_siege.test.js.
// Run: node --test test/content/diy.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { makeBattle, enemyRec, checkInvariants, flatStage, flatRoutes } from '../helpers/battleHarness.js';
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';
const load = (f) => JSON.parse(readFileSync(new URL(`../../data/${f}.json`, import.meta.url), 'utf8'));
const BACKUPS = load('backups');
const SIEGE = 'char_112_siege';
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') };
function battle(units, o = {}) {
return makeBattle({ defs: { enemies: ENEMIES }, units, timeLimit: 300, autoFinish: false, seed: o.seed ?? 7, flags: { dpPerSec: 0 }, ...o });
}
function done(h) {
checkInvariants(h.b);
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
}
test('getChess(slotId, { diy }): the 自选 def — the slot\'s ids / tier, the pick\'s body, skill, module and bonds; diyFor, charId, loadout.diy, tokenOwner; cached; null for an illegal pick', () => {
const ds = getDefaultSource();
const pick = { charId: SIEGE, skillIndex: 0, uniEquipId: 'uniequip_003_siege' };
const d = ds.getChess('chess_char_6_diy1_b', { diy: pick });
assert.deepEqual([d.id, d.baseId, d.golden, d.tier, d.charId, d.diyFor, d.name, d.profession], ['chess_char_6_diy1_b', T6, true, 6, SIEGE, T6, '推进之王', 'PIONEER']);
assert.deepEqual([d.bonds, d.raw.garrisonIds, d.skill.id, d.raw.module.id, d.raw.module.level, d.raw.module.active], [['victoriaShip'], [], 'skcom_charge_cost[3]', 'uniequip_003_siege', 3, true]);
assert.deepEqual(d.loadout.diy, pick);
assert.equal(d.tokenOwner, 'char_112_siege@2/60/7/3');
assert.ok(isDiyDef(d) && Object.isFrozen(d));
assert.equal(ds.getChess('chess_char_6_diy1_b', { diy: { ...pick } }), d, 'cached per (id, pick)');
assert.equal(ds.getChess(T6, { diy: { charId: 'char_601_cguard' } }), null, 'a reserve is no tier-6 pick');
assert.equal(ds.getChess(T5, { diy: { charId: 'char_609_acguad', skillIndex: 0 } }), null, 'a prototype off its locked skill');
assert.equal(ds.getChess('chess_char_5_01_a', { diy: pick }), null, 'not a slot');
const proto = ds.getChess(T5, { diy: { charId: 'char_617_sharp2' } });
assert.deepEqual([proto.skill.id, proto.loadout.diy], ['skchr_sharp2_1', { charId: 'char_617_sharp2', skillIndex: 0, uniEquipId: 'uniequip_002_sharp2' }], '领主·Sharp: S1, its module (inactive on the normal form)');
assert.equal(proto.raw.module.active, false);
});
test('a battle fields 自选 pieces by their kit: an owned 6★\'s operator kit (keyed by its charId), a prototype\'s stand-in kit, a 预备干员\'s generic kit, the generic kit (+ its exact talents) without a kit', () => {
// an injected kit keyed by the operator's charId — what kits/ops/op-<codename>.js registers (KITS[charId])
const siegeKit = () => ({ skills: { skchr_siege_2: { kind: 'charges' } }, talents: [], diyProbe: true });
const h = battle([
{ 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 },
{ 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');
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');
// 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();
const g = h2.unit(1);
assert.deepEqual([g.def.charId, g.skill.id, !!g.kit.generic], ['char_601_cguard', 'skcom_atk_up[3]', true], '预备干员-近卫 at tier 5: S3 攻击力强化·γ型, its 补位 row');
assert.ok(Math.abs(g.s.atk - g.base.atk * 1.08) < 1e-6, 'its talent 攻击提升 (攻击力+8%) through genericTalents');
done(h);
done(h2);
});
test('BattleSpec path: buildBattleSpec keeps a well-formed `diy` (and drops a malformed one); the spec battle fields the 自选 def, with the per-unit pick exact when two players fill one slot id differently', () => {
const players = [
{ playerId: 'p1', seat: 0, side: 'L', colOffset: 0, bonds: {}, units: [{ uid: 1, kind: 'chess', chessId: 'chess_char_5_diy1_b', diy: { charId: SIEGE, skillIndex: 2, uniEquipId: 'uniequip_002_siege', extra: 1 }, row: 10, col: 4 }] },
{ playerId: 'p2', seat: 1, side: 'L', colOffset: 0, bonds: {}, units: [
{ uid: 1, kind: 'chess', chessId: 'chess_char_5_diy1_b', diy: { charId: 'char_611_acnipe' }, row: 11, col: 4 },
{ uid: 2, kind: 'chess', chessId: 'chess_char_5_diy2_a', diy: { charId: 'not ok!' }, row: 12, col: 4 },
] },
];
const spec = buildBattleSpec({ battleId: 'diy', kind: 'normal', seed: 3, stageId: null, players, spawns: [], routes: flatRoutes(), timeLimit: 30 });
assert.deepEqual(spec.players[0].units[0].diy, { charId: SIEGE, skillIndex: 2, uniEquipId: 'uniequip_002_siege' }, 'reduced to the pick');
assert.deepEqual(spec.players[1].units[0].diy, { charId: 'char_611_acnipe' });
assert.equal('diy' in spec.players[1].units[1], false, 'a malformed pick is dropped');
// the same players through the harness (a flat stage; the spec carries no stage object): each unit its own pick
const h = makeBattle({ defs: { enemies: ENEMIES }, players: spec.players, timeLimit: 30, autoFinish: false, stage: flatStage(), flags: { dpPerSec: 0 } });
h.step();
const mine = h.b.allyUnits.find((u) => u.ownerId === 'p1' && u.uid === 1);
const theirs = h.b.allyUnits.find((u) => u.ownerId === 'p2' && u.uid === 1);
assert.deepEqual([mine.def.charId, mine.skill.id, theirs.def.charId, theirs.skill.id], [SIEGE, 'skchr_siege_3', 'char_611_acnipe', 'skchr_acnipe_3']);
assert.ok(h.b.data.loadoutConflicts.includes('chess_char_5_diy1_b'), 'the id-only view notes the conflict; each unit still has its own def');
assert.equal(h.b.allyUnits.some((u) => u.ownerId === 'p2' && u.uid === 2), false, 'a DIY slot without a pick fields nothing');
done(h);
// and createBattleFromSpec on a real stage builds the same 自选 def
const stageId = Object.keys(load('stages')).find((id) => load('stages')[id].kind !== 'unite');
const b = createBattleFromSpec({ ...spec, stageId, routes: [] }, getDefaultSource(), { quiet: true });
b.step();
assert.deepEqual(b.allyUnits.filter((u) => u.kind === 'op').map((u) => [u.ownerId, u.def.charId, u.def.loadout.diy.skillIndex]), [['p1', SIEGE, 2], ['p2', 'char_611_acnipe', 2]]);
});
test('summons of an owned pick: battle.tokenDef resolves the variant of its form and pick (令 at tier 6 elite: S3 makes 弦惊, not 清平)', () => {
const h = battle([{ diy: { slot: 6, charId: 'char_2023_ling', skillIndex: 2, uniEquipId: 'uniequip_002_ling' }, elite: true, row: 10, col: 4 }]);
h.step();
const u = h.unit(1);
assert.equal(u.def.tokenOwner, 'char_2023_ling@2/60/7/3');
const rec3 = BACKUPS.tokens.token_10020_ling_soul3;
const v = rec3.variants['char_2023_ling@2/60/7/3'];
const t3 = h.b.tokenDef('token_10020_ling_soul3', u);
assert.deepEqual([t3.id, t3.sources, t3.skill?.id], ['token_10020_ling_soul3', ['skill', 'display'], v.bySkill['2'].skill.skillId]);
const stats = { ...v.stats, ...(v.byModule?.uniequip_002_ling?.stats ?? {}) };
assert.deepEqual([t3.stats.maxHp, t3.stats.atk, t3.stats.def], [stats.maxHp, stats.atk, stats.def], 'the form\'s stats with the module\'s token attributes');
assert.equal(h.b.producesToken(u, 'token_10020_ling_soul3'), true);
assert.equal(h.b.producesToken(u, 'token_10020_ling_soul1'), false, 'S3 does not make 清平');
const t1 = h.b.tokenDef('token_10020_ling_soul1', u);
assert.deepEqual(t1.sources, ['display']);
// the same token of the normal form (tier 5, no module) is the form's own variant
const h2 = battle([{ diy: { slot: 5, charId: 'char_2023_ling', skillIndex: 0 }, row: 10, col: 4 }]);
h2.step();
const n1 = h2.b.tokenDef('token_10020_ling_soul1', h2.unit(1));
assert.deepEqual([n1.sources, n1.stats.atk], [['skill', 'display'], BACKUPS.tokens.token_10020_ling_soul1.variants['char_2023_ling@2/1/4/0'].stats.atk]);
done(h);
done(h2);
});
test('wire info: a 自选 piece carries its pick (defId = the slot; spine / avatar = the operator\'s; no 补位 mark)', () => {
const h = battle([{ diy: { slot: 5, charId: SIEGE, skillIndex: 2, uniEquipId: 'uniequip_002_siege' }, elite: true, row: 10, col: 4 }]);
h.step();
const info = unitInfo(h.unit(1));
assert.deepEqual([info.defId, info.spine, info.avatar, info.tier, info.golden, info.skillIndex, info.moduleId, info.standInFor],
['chess_char_5_diy1_b', SIEGE, `${SIEGE}_2`, 5, true, 2, 'uniequip_002_siege', undefined]);
assert.deepEqual(info.diy, { charId: SIEGE, skillIndex: 2, uniEquipId: 'uniequip_002_siege' });
const plain = battle([{ chessId: 'chess_char_1_02_a', row: 10, col: 4 }]);
plain.step();
assert.equal(unitInfo(plain.unit(1)).diy, undefined);
done(h);
});
test('a DataSource without backups.json fields no 自选 piece (null def), and its plain DIY slot def is unchanged', () => {
const ds = new DataSource({ chess: { [T5]: load('chess')[T5] } }, null);
assert.equal(ds.getChess(T5, { diy: { charId: SIEGE, skillIndex: 0 } }), null);
assert.equal(ds.getChess(T5).name, '甄选干员');
});
+41 -10
View File
@@ -1,16 +1,21 @@
// The operator kit layout (server/sim/content/kits/README.md): every file of kits/ops/ is listed once in kits/index.js // The operator kit layout (server/sim/content/kits/README.md): every file of kits/ops/ is listed once in kits/index.js
// KIT_FILES or STANDIN_KIT_FILES and nothing else is; a file `<chessId>-<codename>.js` registers exactly the kit of // KIT_FILES, STANDIN_KIT_FILES or OPERATOR_KIT_FILES and nothing else is; a file `<chessId>-<codename>.js` registers
// `<chessId>_a`, whose data record has that charId code name; a 补位 file `standin-<codename>.js` registers exactly the // exactly the kit of `<chessId>_a`, whose data record has that charId code name; a 补位 file `standin-<codename>.js`
// kit of the stand-in character whose charId has that code name (data/backups.json `units`); the registry is the tier // registers exactly the kit of the stand-in character whose charId has that code name (data/backups.json `units`); a
// groups in order, then the stand-ins, and content/index.js serves that object; no kit file imports a Node-only or // 自选 file `op-<codename>.js` registers exactly the kit of the owned-6★ pick with that code name (data/backups.json
// server-only module or reads the clock / Math.random (the sim is deterministic and also runs in the browser, /sim/). // `diy.ownedPool`) and writes every skill of the operator under `skills`; the registry is the tier groups in order, then
// the stand-ins, then the 自选 operators, and content/index.js serves that object; KITTED_CHARS = the 预备干员 + every
// registered stand-in / operator charId; no kit file imports a Node-only or server-only module or reads the clock /
// Math.random (the sim is deterministic and also runs in the browser, /sim/).
import { test } from 'node:test'; import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { readdirSync, readFileSync } from 'node:fs'; import { readdirSync, readFileSync } from 'node:fs';
import { join, dirname } from 'node:path'; import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { KIT_FILES, TIER_KITS, KITS, STANDIN_KIT_FILES, STANDIN_KITS } from '../../server/sim/content/kits/index.js'; import { KIT_FILES, TIER_KITS, KITS, STANDIN_KIT_FILES, STANDIN_KITS, OPERATOR_KIT_FILES, OPERATOR_KITS, GENERIC_KIT_CHARS, KITTED_CHARS } from '../../server/sim/content/kits/index.js';
import { KITS as CONTENT_KITS } from '../../server/sim/content/index.js'; import { KITS as CONTENT_KITS } from '../../server/sim/content/index.js';
import { diyRecord } from '../../shared/diy.js';
import { normalizeChess } from '../../server/sim/simdata.js';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '../..'); const ROOT = join(dirname(fileURLToPath(import.meta.url)), '../..');
const KIT_DIR = join(ROOT, 'server/sim/content/kits'); const KIT_DIR = join(ROOT, 'server/sim/content/kits');
@@ -19,7 +24,7 @@ const BACKUPS = JSON.parse(readFileSync(join(ROOT, 'data/backups.json'), 'utf8')
const jsIn = (dir) => readdirSync(join(KIT_DIR, dir)).filter((f) => f.endsWith('.js')).sort(); const jsIn = (dir) => readdirSync(join(KIT_DIR, dir)).filter((f) => f.endsWith('.js')).sort();
test('kits/index.js lists every kit file of kits/ops/ exactly once, and only those', () => { test('kits/index.js lists every kit file of kits/ops/ exactly once, and only those', () => {
const listed = [...KIT_FILES.flat(), ...STANDIN_KIT_FILES]; const listed = [...KIT_FILES.flat(), ...STANDIN_KIT_FILES, ...OPERATOR_KIT_FILES];
assert.equal(new Set(listed).size, listed.length, 'a file listed twice'); assert.equal(new Set(listed).size, listed.length, 'a file listed twice');
assert.deepEqual([...listed].sort(), jsIn('ops')); assert.deepEqual([...listed].sort(), jsIn('ops'));
}); });
@@ -39,6 +44,32 @@ test('a stand-in kit file standin-<codename>.js registers the kit of that stand-
} }
}); });
test('a 自选 kit file op-<codename>.js registers the kit of that owned-6★ pick (data/backups.json diy.ownedPool), every skill under `skills`', async () => {
const pool = BACKUPS.diy.ownedPool;
for (const file of OPERATOR_KIT_FILES) {
const m = /^op-([a-z0-9]+)\.js$/.exec(file);
assert.ok(m, `${file}: name`);
const charId = pool.find((id) => id.replace(/^char_\d+_/, '') === m[1]);
assert.ok(charId, `${file}: no owned-6★ pick with the code name ${m[1]}`);
const mod = await import(`../../server/sim/content/kits/ops/${file}`);
assert.deepEqual(Object.keys(mod.default), [charId], `${file}: default export`);
assert.equal(typeof mod.default[charId], 'function', `${file}: kit builder`);
assert.equal(OPERATOR_KITS[charId], mod.default[charId], `${file}: registered`);
assert.equal(KITS[charId], mod.default[charId], `${file}: in the merged registry`);
// every skill of every slot form is authored (a 自选 piece has no default skill: the pick chooses any), built from the
// composed record and its def as the sim does (shared/diy.js diyRecord, simdata normalizeChess)
for (const slot of ['chess_char_5_diy1_a', 'chess_char_6_diy1_a']) {
for (const elite of [false, true]) {
const rec = diyRecord(slot, { charId, skillIndex: 0 }, { elite, data: { chess: CHESS, backups: BACKUPS } });
assert.ok(rec, `${file}: a legal pick of ${slot}`);
const kit = mod.default[charId](rec.skill.bb, rec, normalizeChess(rec));
assert.deepEqual(Object.keys(kit.skills || {}).sort(), rec.skills.map((s) => s.skillId).sort(), `${file}@${slot}${elite ? ' elite' : ''}: skills`);
}
}
}
assert.deepEqual([...KITTED_CHARS].sort(), [...new Set([...GENERIC_KIT_CHARS, ...Object.keys(STANDIN_KITS), ...Object.keys(OPERATOR_KITS)])].sort());
});
test('a kit file <chessId>-<codename>.js registers the kit of <chessId>_a (codename: its charId without char_<n>_)', async () => { test('a kit file <chessId>-<codename>.js registers the kit of <chessId>_a (codename: its charId without char_<n>_)', async () => {
for (const file of KIT_FILES.flat()) { for (const file of KIT_FILES.flat()) {
const m = /^(chess_char_\w+?)-([a-z0-9]+)\.js$/.exec(file); const m = /^(chess_char_\w+?)-([a-z0-9]+)\.js$/.exec(file);
@@ -54,9 +85,9 @@ test('a kit file <chessId>-<codename>.js registers the kit of <chessId>_a (coden
} }
}); });
test('the registry is the tier groups in KIT_FILES order, then the stand-ins, one key per file; content/index.js serves the same object', () => { test('the registry is the tier groups in KIT_FILES order, then the stand-ins, then the 自选 operators, one key per file; content/index.js serves the same object', () => {
assert.deepEqual(Object.keys(KITS), [...TIER_KITS.flatMap((t) => Object.keys(t)), ...Object.keys(STANDIN_KITS)]); assert.deepEqual(Object.keys(KITS), [...TIER_KITS.flatMap((t) => Object.keys(t)), ...Object.keys(STANDIN_KITS), ...Object.keys(OPERATOR_KITS)]);
assert.equal(Object.keys(KITS).length, KIT_FILES.flat().length + STANDIN_KIT_FILES.length, 'one key per file, none twice'); assert.equal(Object.keys(KITS).length, KIT_FILES.flat().length + STANDIN_KIT_FILES.length + OPERATOR_KIT_FILES.length, 'one key per file, none twice');
assert.equal(CONTENT_KITS, KITS); assert.equal(CONTENT_KITS, KITS);
}); });
+235
View File
@@ -0,0 +1,235 @@
// test/content/op_siege.test.js — the 自选 operator kit of 推进之王 (char_112_siege, 6★ 尖兵; kit
// server/sim/content/kits/ops/op-siege.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-X
// “糖果盒” or SOL-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_siege.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 SIEGE = 'char_112_siege';
const FORMS = BACKUPS.units[SIEGE].forms;
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
const SOLX = 'uniequip_002_siege', SOLY = 'uniequip_003_siege';
const S1 = 'skcom_charge_cost[3]', S2 = 'skchr_siege_2', S3 = 'skchr_siege_3';
/** 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' }), 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, SOLX, SOLY].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 = 2, 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'], captureNoisy: true,
units: [{ uid: 1, diy: { slot: SLOT[tier], charId: SIEGE, 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: its operator kit (all three skills authored), the form\'s stats + module attributes, 1-1 range, blocks 2, melee ground-only, 维多利亚, no 特质', () => {
assert.equal(OPERATOR_KITS[SIEGE], KITS[SIEGE]);
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], [SIEGE, 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.base.bat], [2, 'melee', false, 1.05], `${label(f)}: 尖兵`);
assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: 1-1`);
assert.deepEqual([u.def.bonds, u.def.raw.garrisonIds], [['victoriaShip'], []], `${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 1643 / 422 / 307, E2 Lv60 2046 / 484 / 358; SOL-X +60 / +40 → +75 / +50 ATK / DEF,
// SOL-Y +200 / +65 → +260 / +82 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], [1643, 422, 2046, 484]);
assert.deepEqual([modOf(5, SOLX).attr, modOf(6, SOLX).attr, modOf(5, SOLY).attr, modOf(6, SOLY).attr], [{ atk: 60, def: 40 }, { atk: 75, def: 50 }, { maxHp: 200, atk: 65 }, { maxHp: 260, atk: 82 }]);
});
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(SIEGE));
for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(SIEGE), `tier ${t}`);
assert.deepEqual(validateDiyPicks({ [SLOT[6]]: { charId: SIEGE, skillIndex: 1, uniEquipId: SOLY } }, { data, kitted: KITTED_CHARS }),
{ ok: true, picks: { [SLOT[6]]: { charId: SIEGE, skillIndex: 1, uniEquipId: SOLY } } });
});
test('S1 冲锋号令·γ型 (AUTO): +12 DP as soon as its SP is full — no enemy needed (the kit\'s SP_FULL, as 德克萨斯 casts it); 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;
const wait = sk.spCost - sk.initSp;
h.run(wait - 0.5);
assert.equal(u.skill.activations, 0, `T${tier}: not before its SP is full`);
assert.ok(h.runUntil(() => u.skill.activations === 1, 1), `T${tier}: cast at full SP (${wait} s), no enemy on the field`);
approx(h.b.players[0].dp, 12, `T${tier}: +12 DP`);
h.run(sk.spCost + 0.1);
assert.equal(u.skill.activations, 2, `T${tier}: again ${sk.spCost} s later`);
approx(h.b.players[0].dp, 24, `T${tier}: +12 DP each cast`);
done(h);
}
});
test('S2 跃空锤 (AUTO, 2 / 3 charges): the next attack hits every ground enemy on her x-5 for 250 % / 280 % ATK physical and gives 3 DP; one charge per attack; outside x-5 and flyers 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.bb.atk_scale, sk.bb.cost, sk.rangeId], ['DEFAULT', elite ? 3 : 2, elite ? 2.8 : 2.5, 3, 'x-5'], `T${tier}`);
const at = (pos, key = 'enemy_dummy') => h.spawn(key, { pos });
const front = at([10, 6]), back = at([10, 4]), upE = at([9, 5]);
const fly = at([11, 5], 'enemy_fly');
const diag = at([9, 6]), far = at([10, 7]);
u.skill.gainSp(999);
assert.equal(u.skill.charges, u.skill.maxCharges, `T${tier}: charges stored`);
h.b.players[0].dp = 0;
const n0 = atkHits(h, u).length;
assert.ok(h.runUntil(() => u.skill.activations === 1 && atkHits(h, u).length > n0, 3), `T${tier}: cast on her next attack`);
const first = atkHits(h, u).slice(n0);
const id = first[0].dmg.attackId;
const hit = first.filter((c) => c.dmg.attackId === id);
assert.deepEqual(hit.map((c) => c.target).sort((a, b) => a.id - b.id), [front, back, upE].sort((a, b) => a.id - b.id), `T${tier}: her x-5 ground enemies`);
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], ['phys', true], `T${tier}: physical skill attack`);
}
for (const e of [fly, diag, far]) assert.ok(!hit.some((c) => c.target === e), `T${tier}: ${e.defId} at ${e.tileR},${e.tileC} untouched`);
approx(h.b.players[0].dp, 3, `T${tier}: +3 DP`);
assert.equal(u.skill.charges, u.skill.maxCharges - 1, `T${tier}: one charge spent`);
assert.deepEqual(u.liveRangeGrid, formOf(tier, elite).rangeGrid, `T${tier}: back to 1-1 after the attack`);
// the stored charges go on her next attacks, one each
h.runUntil(() => u.skill.activations === u.skill.maxCharges, 10);
assert.equal(u.skill.charges, 0);
approx(h.b.players[0].dp, 3 * u.skill.maxCharges, `T${tier}: +3 DP per charge`);
done(h);
}
});
test('S3 碎颅击 (MANUAL, data DEFAULT): 18 / 21 s, attack interval 1.05 + 1.0 s, every attack 290 % / 320 % ATK with 40 % to stun 0.8 / 1.1 s; 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, seed: 11 });
assert.deepEqual([u.skill.rule, sk.duration, sk.bb['attack@atk_scale'], sk.bb['attack@buff_prob'], sk.bb['attack@stun'], sk.bb.base_attack_time],
['DEFAULT', elite ? 21 : 18, elite ? 3.2 : 2.9, 0.4, elite ? 1.1 : 0.8, 1], `T${tier}`);
approx(u.s.interval, 1.05, `T${tier}: own interval`);
u.skill.gainSp(999);
h.run(2);
assert.equal(u.skill.activations, 0, `T${tier}: no enemy, no cast`);
const e = 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, sk.duration, `T${tier}: ${sk.duration} s`, 0.01);
approx(u.s.interval, 2.05, `T${tier}: +1.0 s`);
const n0 = atkHits(h, u).length;
u.skill.extend(400); // ≈ 200 attacks
h.runUntil(() => !u.skill.active, 500);
const hits = atkHits(h, u).slice(n0).filter((c) => c.dmg.isSkill);
assert.ok(hits.length > 190, `T${tier}: ${hits.length} skill attacks`);
for (const c of hits.slice(0, 5)) approx(c.amount, u.s.atk * sk.bb['attack@atk_scale'], `T${tier}: ${sk.bb['attack@atk_scale'] * 100} % ATK`);
const stuns = h.hooksOf('statusApplied').filter((c) => c.source === u && c.status === 'stun' && c.target === e);
assert.ok(stuns.length / hits.length > 0.32 && stuns.length / hits.length < 0.48, `T${tier}: ${stuns.length} / ${hits.length} stuns ≈ 40 %`);
for (const c of stuns) approx(c.duration, sk.bb['attack@stun'], `T${tier}: stun ${sk.bb['attack@stun']} s`);
approx(u.s.interval, 1.05, `T${tier}: interval back`);
const n1 = atkHits(h, u).length;
h.run(10);
assert.ok(atkHits(h, u).slice(n1).every((c) => !c.dmg.isSkill && Math.abs(c.amount - u.s.atk) < 1e-6), `T${tier}: plain attacks once it ends`);
done(h);
}
});
test('T1 万兽之王: every 【先锋】 operator of the field ATK / DEF +8 % while she is deployed (not other professions); SOL-X stage 3 adds her own +8 % / +8 %', () => {
const others = [{ uid: 2, chessId: 'chess_char_1_08_a', row: 11, col: 3 }, { uid: 3, chessId: 'chess_char_1_02_a', row: 11, col: 7 }];
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
const { h, u } = field({ tier, elite, mod, skill: 2, others });
h.run(0.6);
const texas = h.unit(2), yak = h.unit(3);
const t0 = u.def.raw.talents.find((t) => t.index === 0);
const extra = elite && mod === SOLX && tier === 6 ? 0.08 : 0;
assert.deepEqual(texas.findBuff('talent:siege:pioneers')?.mods, { atkPct: 0.08, defPct: 0.08 }, `${label(f)}: 德克萨斯 (先锋)`);
assert.equal(yak.findBuff('talent:siege:pioneers'), null, `${label(f)}: 角峰 (重装) gets nothing`);
assert.deepEqual(u.findBuff('talent:siege:pioneers')?.mods, { atkPct: 0.08, defPct: 0.08 }, `${label(f)}: herself`);
approx(u.s.atk, u.base.atk * (1 + 0.08 + extra), `${label(f)}: her ATK`);
approx(u.s.def, u.base.def * (1 + 0.08 + extra), `${label(f)}: her DEF`);
assert.equal(!!u.findBuff('talent:siege:self'), extra > 0, `${label(f)}: the SOL-X self extra`);
if (extra) assert.match(t0.desc, /自身攻击力和防御力额外\+8%/, 'the module text');
h.b.retreat(u);
h.run(0.7);
assert.equal(texas.findBuff('talent:siege:pioneers'), null, `${label(f)}: gone once she leaves the field`);
done(h);
}
});
test('T2 粉碎: an enemy falling on her x-5 gives 1 SP (flyers too, under her own 阻回 too, never during a timed skill); SOL-Y stage 3: 3 SP and 1 SP to another 【先锋】 operator', () => {
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
const others = [{ uid: 2, chessId: 'chess_char_1_08_a', row: 12, col: 9 }];
const { h, u } = field({ tier, elite, mod, skill: 2, others });
const texas = h.unit(2);
const y3 = elite && mod === SOLY && tier === 6;
const sp = y3 ? 3 : 1;
const kill = (pos, key = 'enemy_dummy') => {
const e = h.spawn(key, { pos });
const [a, b] = [u.skill.sp, texas.skill.sp];
h.b.kill(e, null);
const r6 = (v) => Math.round(v * 1e6) / 1e6;
return [r6(u.skill.sp - a), r6(texas.skill.sp - b)];
};
assert.deepEqual(kill([10, 4]), [sp, y3 ? 1 : 0], `${label(f)}: behind her (x-5)`);
assert.deepEqual(kill([9, 5], 'enemy_fly'), [sp, y3 ? 1 : 0], `${label(f)}: a flyer above her`);
assert.deepEqual(kill([9, 6]), [0, 0], `${label(f)}: diagonal — outside x-5`);
assert.deepEqual(kill([10, 7]), [0, 0], `${label(f)}: two tiles ahead`);
h.b.addBuff(u, { key: 'test:noSp', flags: { noSp: true }, duration: 5 });
assert.equal(kill([10, 6])[0], sp, `${label(f)}: 无视自身的阻回`);
h.b.removeBuff(u, 'test:noSp');
u.skill.gainSp(999);
u.skill.activate('test');
assert.ok(u.skill.active);
assert.equal(kill([11, 5])[0], 0, `${label(f)}: no SP while 碎颅击 runs`);
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: 2, 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:siege: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:siege:block'), solx, `${label(f)}: blocking`);
if (solx) assert.deepEqual(u.findBuff('trait:siege: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);
}
});
+165
View File
@@ -0,0 +1,165 @@
// test/diy.test.js — the 自选 rules of shared/diy.js over the real data (data/chess.json, data/backups.json; DATA.md §18):
// the legal picks per tier (diyPool, with and without the kit registry), one pick against a slot (checkDiyPick: the
// prototypes' locked skill / module, an owned operator's choice of skill and module at the slot's stage), the composed
// record (diyRecord: the slot's identity, no 特质, derived bonds, the operator's body at the slot form) and a whole roster
// (validateDiyPicks). Research 0.2.0 §2; the owner's decisions of 2026-10-05.
// Run: node --test test/diy.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { diyPool, diySlot, diySlotIds, checkDiyPick, diyRecord, diyRecordOf, validateDiyPicks, lockedSelection, diyTokenOwner, DIY_TIERS } from '../shared/diy.js';
import { unitForm, composeUnitRecord } from '../shared/standIn.js';
import { composeStats, composeTalents } from '../shared/loadoutRecord.js';
import { normalizeChess, resolveLoadout, loadoutRecord } from '../server/sim/simdata.js';
import { KITTED_CHARS, GENERIC_KIT_CHARS, STANDIN_KITS, OPERATOR_KITS } from '../server/sim/content/kits/index.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 DATA = { chess, backups };
const { ownedPool, prototypes } = backups.diy;
const ELITES = ['char_608_acpion', 'char_609_acguad', 'char_610_acfend', 'char_611_acnipe', 'char_612_accast', 'char_613_acmedc', 'char_614_acsupo', 'char_615_acspec', 'char_617_sharp2'];
const RESERVES5 = ['char_601_cguard', 'char_602_cdfend', 'char_603_csnipe', 'char_604_ccast', 'char_605_cmedic', 'char_606_csuppo'];
const COLLAB = ['char_456_ash', 'char_1029_yato2', 'char_4123_ela', 'char_4141_marcil', 'char_4182_oblvns', 'char_4217_makoto'];
const SIEGE = 'char_112_siege';
const T5 = 'chess_char_5_diy1_a', T5B = 'chess_char_5_diy2_a', T6 = 'chess_char_6_diy1_a', T6B = 'chess_char_6_diy2_a';
test('slots: two per tier (5, 6), each with its elite twin', () => {
assert.deepEqual(DIY_TIERS, [5, 6]);
assert.deepEqual(diySlotIds(DATA), [T5, T5B, T6, T6B]);
assert.deepEqual(diySlot('chess_char_6_diy2_b', DATA) && { ...diySlot('chess_char_6_diy2_b', DATA), normal: undefined, golden: undefined },
{ baseId: T6B, goldenId: 'chess_char_6_diy2_b', tier: 6, elite: true, normal: undefined, golden: undefined });
assert.equal(diySlot('chess_char_5_01_a', DATA), null, 'not a slot');
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', () => {
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]);
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');
// the kit registry: the 预备干员 run the generic kit (exact for their skills), the nine elites their stand-in kits, the
// owned 6★ only with an operator kit file (kits/index.js OPERATOR_KIT_FILES), in pool order
assert.deepEqual(GENERIC_KIT_CHARS.filter((id) => prototypes['5'].includes(id)), RESERVES5);
for (const id of ELITES) assert.equal(typeof STANDIN_KITS[id], 'function', `${id}: stand-in kit`);
const ops = ownedPool.filter((id) => typeof OPERATOR_KITS[id] === 'function');
assert.deepEqual([...ops].sort(), Object.keys(OPERATOR_KITS).sort(), 'every operator kit is an owned-6★ pick');
assert.deepEqual(diyPool(5, { data: DATA, kitted: KITTED_CHARS }), [...RESERVES5, ...ELITES, ...ops]);
assert.deepEqual(diyPool(6, { data: DATA, kitted: KITTED_CHARS }), [...ELITES, ...ops]);
assert.deepEqual(diyPool(6, { data: DATA, kitted: (id) => id === SIEGE }), [SIEGE], 'a predicate works too');
assert.deepEqual(diyPool(5, { data: DATA, kitted: [] }), [], 'nothing kitted, nothing offered');
});
test('checkDiyPick: a prototype carries its locked skill / module (与系统补位时一致) — omitted ⇒ filled in, another ⇒ refused', () => {
for (const [slot, tier] of [[T5, 5], [T6, 6]]) {
for (const id of prototypes[tier]) {
const lk = lockedSelection(DATA, tier, id);
assert.deepEqual(checkDiyPick(slot, { charId: id }, DATA), { ok: true, pick: { charId: id, ...lk } }, `${id}@${tier}`);
assert.deepEqual(checkDiyPick(slot, { charId: id, skillIndex: lk.skillIndex, uniEquipId: lk.uniEquipId }, DATA).ok, true);
const other = lk.skillIndex === 0 ? 1 : 0;
if (unitForm(backups, id, chess[slot].status).skills.some((s) => s.index === other)) {
assert.match(checkDiyPick(slot, { charId: id, skillIndex: other }, DATA).error, /prototype carries skill/, `${id}@${tier}: skill-locked`);
}
}
}
assert.deepEqual(lockedSelection(DATA, 5, 'char_609_acguad'), { skillIndex: 2, uniEquipId: 'uniequip_002_acguad' });
assert.deepEqual(lockedSelection(DATA, 6, 'char_617_sharp2'), { skillIndex: 0, uniEquipId: 'uniequip_002_sharp2' });
assert.deepEqual(lockedSelection(DATA, 5, 'char_605_cmedic'), { skillIndex: 2, uniEquipId: null });
assert.match(checkDiyPick(T5, { charId: 'char_609_acguad', uniEquipId: 'none' }, DATA).error, /prototype carries module/);
assert.match(checkDiyPick(T5, { charId: 'char_609_acguad', uniEquipId: 'uniequip_001_acguad' }, DATA).error, /prototype carries module/);
assert.deepEqual(checkDiyPick(T5, { charId: 'char_609_acguad', skillIndex: null, uniEquipId: null }, DATA).pick, { charId: 'char_609_acguad', skillIndex: 2, uniEquipId: 'uniequip_002_acguad' }, 'null = the locked selection');
assert.equal(lockedSelection(DATA, 5, SIEGE), null, 'an owned operator has no lock');
});
test('checkDiyPick: an owned 6★ chooses any of its 3 skills and any module of its elite form at the slot\'s stage (or none); illegal picks are refused', () => {
for (const slot of [T5, T6]) {
for (const s of [0, 1, 2]) {
for (const m of [undefined, null, 'none', 'uniequip_002_siege', 'uniequip_003_siege']) {
const c = checkDiyPick(slot, { charId: SIEGE, skillIndex: s, uniEquipId: m }, DATA);
assert.deepEqual(c, { ok: true, pick: { charId: SIEGE, skillIndex: s, uniEquipId: m && m !== 'none' ? m : null } }, `${slot} S${s + 1} ${m}`);
}
}
}
const bad = [
[T5, { charId: SIEGE }, /needs a skill index/],
[T5, { charId: SIEGE, skillIndex: 3 }, /no skill 3/],
[T5, { charId: SIEGE, skillIndex: 0, uniEquipId: 'uniequip_001_siege' }, /no module uniequip_001_siege/],
[T5, { charId: SIEGE, skillIndex: 0, uniEquipId: 'uniequip_002_acguad' }, /no module/],
[T6, { charId: 'char_601_cguard' }, /not a tier-6 自选 pick/],
[T5, { charId: 'char_600_cpione' }, /not a tier-5/],
[T5, { charId: 'char_607_cspec' }, /not a tier-5/],
[T5, { charId: 'char_102_texas', skillIndex: 0 }, /not a tier-5/],
[T6, { charId: 'char_1012_skadi2', skillIndex: 0 }, /not a tier-6/],
[T5, { charId: 'char_456_ash', skillIndex: 0 }, /not a tier-5/],
[T5, null, /bad pick/],
[T5, { charId: 42 }, /bad pick/],
['chess_char_5_01_a', { charId: SIEGE, skillIndex: 0 }, /not a 自选 slot/],
];
for (const [slot, pick, re] of bad) assert.match(checkDiyPick(slot, pick, DATA).error, re, JSON.stringify(pick));
});
test('diyRecord: the slot\'s identity (tier, price, merge, status), no 特质, derived bonds, the operator\'s body at the slot form; module active on the elite only, stage 1 / 3', () => {
const cases = [[T5, false, '2/1/4/0', 0], [T5, true, '2/60/7/1', 1], [T6, false, '2/1/4/0', 0], [T6, true, '2/60/7/3', 3]];
for (const [slotId, elite, key, stage] of cases) {
for (const mod of [null, 'uniequip_002_siege', 'uniequip_003_siege']) {
const r = diyRecord(slotId, { charId: SIEGE, skillIndex: 1, uniEquipId: mod }, { elite, data: DATA });
const slot = chess[elite ? backups.diy.slots[slotId].goldenId : slotId];
const form = backups.units[SIEGE].forms[key];
const label = `${slot.chessId} ${mod}`;
assert.deepEqual([r.chessId, r.baseId, r.isGolden, r.tier, r.price, r.sellPrice, r.upgradeNum, r.chessType, r.isDiy], [slot.chessId, slotId, elite, slot.tier, 4, 1, elite ? 0 : 3, 'DIY', true], label);
assert.deepEqual([r.charId, r.diyFor, r.name, r.profession, r.rarity], [SIEGE, slotId, '推进之王', 'PIONEER', 6], label);
assert.deepEqual([r.garrisonIds, r.bonds, r.standInFor], [[], ['victoriaShip'], undefined], `${label}: no 特质, 维多利亚 from 维多利亚 / 格拉斯哥帮`);
assert.deepEqual(r.status, slot.status, label);
assert.deepEqual([r.skill.skillId, r.skill.level], ['skchr_siege_2', elite ? 7 : 4], `${label}: S2 at the slot's rank`);
assert.deepEqual(r.skills.filter((s) => s.isDefault).map((s) => s.index), [1]);
const m = mod && elite ? form.modules.find((x) => x.uniEquipId === mod) : null;
assert.deepEqual(r.stats, composeStats(form.stats, m?.attr), `${label}: stats`);
assert.deepEqual(r.talents, composeTalents(form.talents, m?.talentChanges), `${label}: talents`);
assert.deepEqual(r.trait, m?.traitOverride ?? form.trait, `${label}: trait`);
assert.equal(!!r.module?.active, !!m, `${label}: module active`);
if (m) assert.equal(r.module.level, stage, `${label}: stage ${stage}`);
if (elite) assert.deepEqual(r.modules.map((x) => [x.uniEquipId, x.level, x.isDefault]), form.modules.map((x) => [x.uniEquipId, stage, x.uniEquipId === mod]), label);
assert.equal(r.assets.avatar, elite ? `${SIEGE}_2` : SIEGE, `${label}: E2 art on the elite`);
// chess-shaped: the loadout machinery and the sim read it unchanged
assert.equal(loadoutRecord(r, resolveLoadout(r, null)), r);
const def = normalizeChess(r);
assert.deepEqual([def.id, def.baseId, def.charId, def.diyFor, def.golden, def.tier, def.skill.id], [slot.chessId, slotId, SIEGE, slotId, elite, slot.tier, 'skchr_siege_2'], label);
}
}
// a prototype's derived bond is 协防; 煌 gets 炎 + 维多利亚 (subPower only); the composed record equals composeUnitRecord's
const sharp = diyRecord(T6, { charId: 'char_609_acguad' }, { elite: true, data: DATA });
assert.deepEqual([sharp.bonds, sharp.skill.skillId, sharp.module.id, sharp.module.level, sharp.garrisonIds], [['emptyShip'], 'skchr_acguad_3', 'uniequip_002_acguad', 3, []]);
assert.deepEqual(diyRecord(T5, { charId: 'char_017_huang', skillIndex: 0 }, { data: DATA }).bonds, ['yanShip', 'victoriaShip']);
const slot = chess.chess_char_5_diy2_b;
const { diyFor, ...rest } = diyRecordOf(slot, { charId: SIEGE, skillIndex: 2, uniEquipId: 'uniequip_003_siege' }, DATA);
assert.equal(diyFor, T5B);
assert.deepEqual(rest, composeUnitRecord(slot, backups.units[SIEGE], unitForm(backups, SIEGE, slot.status), { skillIndex: 2, moduleId: 'uniequip_003_siege', bonds: ['victoriaShip'] }));
assert.equal(diyRecord(T6, { charId: 'char_601_cguard' }, { data: DATA }), null, 'illegal ⇒ null');
assert.equal(diyRecordOf(chess.chess_char_5_01_a, { charId: SIEGE, skillIndex: 0 }, DATA), null, 'not a slot record');
});
test('validateDiyPicks: a prototype may fill a tier-5 and a tier-6 slot, an owned operator one slot, the picks of a tier differ; unkitted operators are refused', () => {
const sharp = { charId: 'char_609_acguad' };
const ok = validateDiyPicks({ [T5]: sharp, [T5B]: { charId: SIEGE, skillIndex: 2, uniEquipId: 'uniequip_002_siege' }, [T6]: sharp, [T6B]: null }, { data: DATA, kitted: [...KITTED_CHARS, SIEGE] });
assert.deepEqual(ok, { ok: true, picks: {
[T5]: { charId: 'char_609_acguad', skillIndex: 2, uniEquipId: 'uniequip_002_acguad' },
[T5B]: { charId: SIEGE, skillIndex: 2, uniEquipId: 'uniequip_002_siege' },
[T6]: { charId: 'char_609_acguad', skillIndex: 2, uniEquipId: 'uniequip_002_acguad' },
} });
assert.deepEqual(validateDiyPicks({}, { data: DATA }), { ok: true, picks: {} }, 'empty slots are fine');
const err = (picks, opts = {}) => validateDiyPicks(picks, { data: DATA, ...opts });
assert.match(err({ [T5]: sharp, [T5B]: sharp }).detail, /fills two tier-5 slots/);
assert.match(err({ [T5]: { charId: SIEGE, skillIndex: 0 }, [T6]: { charId: SIEGE, skillIndex: 1 } }).detail, /an owned operator fills one slot/);
assert.match(err({ [T5]: { charId: 'char_017_huang', skillIndex: 0 } }, { kitted: KITTED_CHARS.filter((id) => id !== 'char_017_huang') }).detail, /has no kit yet/);
assert.match(err({ [T5]: sharp }, { kitted: (id) => id !== 'char_609_acguad' }).detail, /char_609_acguad has no kit yet/);
assert.deepEqual(err({ [T5]: { charId: 'char_017_huang', skillIndex: 0 } }).ok, true, 'without the registry every legal pick passes');
assert.match(err({ chess_char_5_01_a: sharp }).detail, /not a 自选 slot/);
assert.match(err({ [T6]: { charId: 'char_602_cdfend' } }).detail, /not a tier-6/);
assert.match(err({ [T5]: { charId: 'char_609_acguad', skillIndex: 0 } }).detail, /prototype carries skill 2/);
assert.deepEqual(err('x'), { error: 'BAD_MSG', detail: 'bad 自选 picks' });
assert.equal(err([sharp]).error, 'BAD_MSG');
});
+3 -2
View File
@@ -7,7 +7,7 @@ through.
- `tools/golden.mjs` builds the corpus from `data/*.json` (fixed order, fixed seeds) and reduces every scenario to a - `tools/golden.mjs` builds the corpus from `data/*.json` (fixed order, fixed seeds) and reduces every scenario to a
digest. Its header documents the families and the digest fields. digest. Its header documents the families and the digest fields.
- `test/golden/<family>.json` hold the digests (`roster`, `bonds`, `fields`, `matches`, `standins`). - `test/golden/<family>.json` hold the digests (`roster`, `bonds`, `fields`, `matches`, `standins`, `diy`).
- `test/golden.test.js` recomputes them and compares. On a mismatch it prints the scenario, the field and old → new. - `test/golden.test.js` recomputes them and compares. On a mismatch it prints the scenario, the field and old → new.
## The corpus ## The corpus
@@ -19,6 +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) | | `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` | 17 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) and 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`) | | `matches` | 17 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) and 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`) |
| `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 | | `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 |
Battles go through the production BattleSpec path (`server/sim/spec.js`, as browsers and the server's headless fields 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 run them); matches construct `Match` directly with a `VirtualScheduler`. Every option is explicit, so a change to a
@@ -31,7 +32,7 @@ to 2 decimals.
`npm test` runs the fast subset (the scenarios marked `"fast": true`: every chess record with its default loadout, `npm test` runs the fast subset (the scenarios marked `"fast": true`: every chess record with its default loadout,
every stage and every non-leader enemy kind, every bond at its top tier, six fields, six matches, the five stand-in every stage and every non-leader enemy kind, every bond at its top tier, six fields, six matches, the five stand-in
battles of the normal records — 59 of the 144). `GOLDEN_FULL=1` checks everything. battles of the normal records, the first 自选 battle of the operator kits and of the prototypes — 61 of the 150). `GOLDEN_FULL=1` checks everything.
## Workflow ## Workflow
+255
View File
@@ -0,0 +1,255 @@
{
"family": "diy",
"about": "自选 pieces: every kitted owned 6★ × form (tiers 5 / 6, normal / elite × module) × skill, every kitted prototype at its locked selection",
"note": "Generated by `npm run golden:update` (tools/golden.mjs). A refactor must never change this file; see test/golden/README.md.",
"columns": {
"units": ["player","ref","kind","def","skill","module","hp","alive","deploys","kills","dmg","taken","heal","attacks","casts"],
"enemies": ["spawned","killed","leaked","dmg","taken"],
"hooks": ["battleStart","deploy","tick","beforeAttack","attack","hit","damaged","heal","kill","death","skillStart","skillEnd","ammoUsed","spGain","statusApplied","blocked","enemySpawn","enemyLeak","battleEnd","fatal","layerGain","elementBurst","dodge"]
},
"scenarios": {
"diy-001": {
"about": "5_diy1_a=siege/S1 5_diy2_a=siege/S2 5_diy1_a=siege/S3 5_diy2_b=siege/S1 5_diy1_b=siege/S2 5_diy2_b=siege/S3 5_diy1_b=siege/S1/002_siege 5_diy2_b=siege/S2/002_siege 5_diy1_b=siege/S3/002_siege 5_diy2_b=siege/S1/003_siege 5_diy1_b=siege/S2/003_siege 5_diy2_b=siege/S3/003_siege",
"fast": true,
"kind": "normal",
"stageId": "act1autochess_m01",
"modeId": "mode_multi_normal",
"round": 12,
"seed": 2851314323,
"waveId": "act1autochess_h05",
"time": 230,
"ticks": 6900,
"reason": "timeout",
"killed": 0,
"total": 93,
"unspawned": 0,
"bossHpLeft": null,
"rngDraws": 3,
"errors": {"count":0,"kinds":[]},
"players": {"p1":{"killed":0,"total":93,"perfect":false,"leaks":93,"leaksUncounted":0,"coins":0,"dmg":76088,"bossDmg":0,"heal":0,"deaths":28,"layerGains":{}}},
"units": [
["p1",1,"op","chess_char_5_diy1_a",0,null,0,0,122,0,923,4929,0,7,1],
["p1",2,"op","chess_char_5_diy2_a",1,null,0,0,123,0,7443,4929,0,19,3],
["p1",3,"op","chess_char_5_diy1_a",2,null,0,0,124,0,7649,4929,0,23,0],
["p1",4,"op","chess_char_5_diy2_b",0,"none",0,0,117,0,4072,6138,0,11,0],
["p1",5,"op","chess_char_5_diy1_b",1,"none",2046,1,125,0,9788,4092,0,14,2],
["p1",6,"op","chess_char_5_diy2_b",2,"none",0,0,82,0,3152,4092,0,4,1],
["p1",7,"op","chess_char_5_diy1_b",0,"uniequip_002_siege",0,0,90,0,2443,4092,0,7,1],
["p1",8,"op","chess_char_5_diy2_b",1,"uniequip_002_siege",0,0,99,0,19095,4092,0,8,3],
["p1",9,"op","chess_char_5_diy1_b",2,"uniequip_002_siege",0,0,111,0,3317,4092,0,5,1],
["p1",10,"op","chess_char_5_diy2_b",0,"uniequip_003_siege",0,0,71,0,1958,4492,0,6,0],
["p1",11,"op","chess_char_5_diy1_b",1,"uniequip_003_siege",0,0,68,0,13962,4492,0,7,2],
["p1",12,"op","chess_char_5_diy2_b",2,"uniequip_003_siege",0,0,63,0,2285,4492,0,10,0],
["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null],
["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null],
["-","~3","device","trap_1105_accrate",null,null,100,1,3,0,0,0,0,0,null]
],
"enemies": {"enemy_1267_nhpbr_2":[36,0,35,9319,36291],"enemy_1270_nhstlk_2":[27,0,27,22708,32133],"enemy_9006_actoxi":[30,0,30,22834,7664]},
"hooks": [1,125,6900,243,243,280,280,0,28,120,14,14,0,10303,0,52,93,92,1,28,0,0,0],
"events": {"atk":260,"deploy":29,"die":28,"dmg":280,"fx":22,"leak":92,"skill":28,"spawn":108,"status":26},
"fx": {"aoe":10,"dp":12},
"snaps": "11fd58d9 8c9b2fa6 4326c48e 207bf323 f5892cfd eb207b8b 716efaa3 73d27b32 157261e1 b639e83b 8c6960a9 594efc22 ede97e52 8c0b4015 9a8cdd7e 6bb87f5f 4bf762d2 9cd529d0 40b20b8e c21a56fc 4f34951d b94bd6bf 54fb0c18"
},
"diy-002": {
"about": "6_diy1_a=siege/S1 6_diy2_a=siege/S2 6_diy1_a=siege/S3 6_diy2_b=siege/S1 6_diy1_b=siege/S2 6_diy2_b=siege/S3 6_diy1_b=siege/S1/002_siege 6_diy2_b=siege/S2/002_siege 6_diy1_b=siege/S3/002_siege 6_diy2_b=siege/S1/003_siege 6_diy1_b=siege/S2/003_siege 6_diy2_b=siege/S3/003_siege",
"fast": false,
"kind": "normal",
"stageId": "act1autochess_m02",
"modeId": "mode_multi_hard",
"round": 13,
"seed": 197068430,
"waveId": "act1autochess_h06",
"time": 223.3,
"ticks": 6699,
"reason": "cleared",
"killed": 2,
"total": 123,
"unspawned": 0,
"bossHpLeft": null,
"rngDraws": 5,
"errors": {"count":0,"kinds":[]},
"players": {"p1":{"killed":2,"total":123,"perfect":false,"leaks":121,"leaksUncounted":0,"coins":0,"dmg":66245,"bossDmg":0,"heal":0,"deaths":26,"layerGains":{}}},
"units": [
["p1",1,"op","chess_char_6_diy1_a",0,null,0,0,154,0,8115,4929,0,23,1],
["p1",2,"op","chess_char_6_diy2_a",1,null,1643,1,144,0,365,3286,0,8,2],
["p1",3,"op","chess_char_6_diy1_a",2,null,1643,1,151,0,246,3286,0,7,1],
["p1",4,"op","chess_char_6_diy2_b",0,"none",2046,1,142,0,233,4092,0,9,2],
["p1",5,"op","chess_char_6_diy1_b",1,"none",2046,1,155,0,18574,4092,0,24,6],
["p1",6,"op","chess_char_6_diy2_b",2,"none",2046,1,153,0,1079,4092,0,11,0],
["p1",7,"op","chess_char_6_diy1_b",0,"uniequip_002_siege",2046,1,132,0,203,4092,0,6,2],
["p1",8,"op","chess_char_6_diy2_b",1,"uniequip_002_siege",2046,1,156,0,1316,4092,0,4,2],
["p1",9,"op","chess_char_6_diy1_b",2,"uniequip_002_siege",0,0,94,0,677,4092,0,4,1],
["p1",10,"op","chess_char_6_diy2_b",0,"uniequip_003_siege",0,0,119,1,9041,4612,0,19,1],
["p1",11,"op","chess_char_6_diy1_b",1,"uniequip_003_siege",0,0,79,1,24153,4612,0,21,4],
["p1",12,"op","chess_char_6_diy2_b",2,"uniequip_003_siege",0,0,134,0,2242,6918,0,12,0]
],
"enemies": {"enemy_1006_shield_3":[21,0,21,20551,4442],"enemy_1043_zomsbr":[66,2,64,10572,58576],"enemy_1402_tgshd_2":[36,0,36,21072,3227]},
"hooks": [1,156,6699,282,282,308,308,7543,28,149,22,22,0,17563,2,43,123,121,1,28,0,0,0],
"events": {"atk":308,"deploy":33,"die":28,"dmg":308,"fx":70,"leak":121,"skill":44,"spawn":135,"status":28},
"fx": {"aoe":14,"dp":20,"expose":36},
"snaps": "d7b739d1 f5e170c4 bc463f9c 6ebd80ed 93f8e1ae ea1947a7 9541a28f e8dbac5b b0b3c196 14ae4a64 46640828 01c55ea9 0f261678 f447848b 0aad3542 db5850a3 631cc5ca 455c1dbc 68ad7134 24c71ebb 34a3ee93 37b05130 7a58a87e"
},
"diy-003": {
"about": "5_diy1_a=cdfend 5_diy2_b=cdfend 5_diy1_a=cguard 5_diy2_b=cguard 5_diy1_a=csnipe 5_diy2_b=csnipe 5_diy1_a=ccast 5_diy2_b=ccast 5_diy1_a=cmedic 5_diy2_b=cmedic 5_diy1_a=csuppo 5_diy2_b=csuppo",
"fast": true,
"kind": "normal",
"stageId": "act1autochess_m03",
"modeId": "mode_single_normal",
"round": 12,
"seed": 3865246633,
"waveId": "act1autochess_h05",
"time": 196.5,
"ticks": 5895,
"reason": "cleared",
"killed": 8,
"total": 93,
"unspawned": 0,
"bossHpLeft": null,
"rngDraws": 0,
"errors": {"count":0,"kinds":[]},
"players": {"p1":{"killed":8,"total":93,"perfect":false,"leaks":85,"leaksUncounted":0,"coins":0,"dmg":306215,"bossDmg":0,"heal":22436,"deaths":11,"layerGains":{}}},
"units": [
["p1",1,"op","chess_char_5_diy1_a",2,null,0,0,114,0,690,6384,0,13,0],
["p1",2,"op","chess_char_5_diy2_b",2,"none",0,0,98,1,7246,10191,0,56,1],
["p1",3,"op","chess_char_5_diy1_a",2,null,2564,1,116,1,16020,8139,0,28,1],
["p1",4,"op","chess_char_5_diy2_b",2,"none",0,0,74,3,34176,12589,0,37,2],
["p1",5,"op","chess_char_5_diy1_a",2,null,871,1,8,1,22261,400,0,129,3],
["p1",6,"op","chess_char_5_diy2_b",2,"none",1510,1,12,0,32187,200,0,108,3],
["p1",7,"op","chess_char_5_diy1_a",2,null,1133,1,14,0,30588,200,0,77,2],
["p1",8,"op","chess_char_5_diy2_b",2,"none",1160,1,4,1,71148,200,0,154,3],
["p1",9,"op","chess_char_5_diy1_a",2,null,1218,1,6,0,0,600,18186,48,3],
["p1",10,"op","chess_char_5_diy2_b",2,"none",1374,1,115,0,0,8077,4251,12,2],
["p1",11,"op","chess_char_5_diy1_a",2,null,932,1,13,1,40672,2000,0,123,3],
["p1",12,"op","chess_char_5_diy2_b",2,"none",1131,1,15,0,51226,800,0,132,3],
["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null],
["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null],
["-","~3","device","trap_1105_accrate",null,null,100,1,3,0,0,0,0,0,null]
],
"enemies": {"enemy_1267_nhpbr_2":[36,8,28,17650,131613],"enemy_1270_nhstlk_2":[27,0,27,15235,63417],"enemy_9006_actoxi":[30,0,30,16896,111185]},
"hooks": [1,116,5895,1089,1089,1215,1215,60,19,104,26,24,0,28385,254,28,93,85,1,19,0,0,0],
"events": {"atk":1089,"deploy":20,"die":19,"dmg":1215,"fx":8,"heal":60,"leak":85,"skill":50,"spawn":108,"status":504},
"fx": {"zone":8},
"snaps": "b1b2dda9 6ac577d6 84811e80 eb6305ea 52a34c95 c2fd4731 45525f0b dbcc3ddb ddfcbc11 0874e859 206cd671 4c83f44b f4f5331a 194da7d5 1fe13dbf 6b7331b4 4cf19629 7bae5e13 5b7078fc 65a8a1f5"
},
"diy-004": {
"about": "5_diy1_a=acfend 5_diy2_b=acfend 5_diy1_a=acpion 5_diy2_b=acpion 5_diy1_a=acguad 5_diy2_b=acguad 5_diy1_a=acnipe 5_diy2_b=acnipe 5_diy1_a=accast 5_diy2_b=accast 5_diy1_a=acmedc 5_diy2_b=acmedc",
"fast": false,
"kind": "normal",
"stageId": "act1autochess_m04",
"modeId": "mode_multi_abyss",
"round": 12,
"seed": 1210847140,
"waveId": "act1autochess_h05",
"time": 224.77,
"ticks": 6743,
"reason": "cleared",
"killed": 21,
"total": 117,
"unspawned": 0,
"bossHpLeft": null,
"rngDraws": 350,
"errors": {"count":0,"kinds":[]},
"players": {"p1":{"killed":21,"total":117,"perfect":false,"leaks":96,"leaksUncounted":0,"coins":0,"dmg":374057,"bossDmg":0,"heal":25487,"deaths":23,"layerGains":{}}},
"units": [
["p1",1,"op","chess_char_5_diy1_a",2,null,0,0,116,0,0,5208,0,0,0],
["p1",2,"op","chess_char_5_diy2_b",2,"uniequip_002_acfend",100.37,1,143,0,0,6642,0,0,0],
["p1",3,"op","chess_char_5_diy1_a",2,null,1060.28,1,145,0,4912,3527,0,0,1],
["p1",4,"op","chess_char_5_diy2_b",2,"uniequip_002_acpion",0,0,98,5,85077,5533,0,0,3],
["p1",5,"op","chess_char_5_diy1_a",2,null,0,0,104,0,0,8356,0,0,0],
["p1",6,"op","chess_char_5_diy2_b",2,"uniequip_002_acguad",0,0,100,0,0,20296,0,0,0],
["p1",7,"op","chess_char_5_diy1_a",2,null,0,0,132,0,16051,2544,0,16,1],
["p1",8,"op","chess_char_5_diy2_b",2,"uniequip_002_acnipe",0,0,135,5,91286,3174,0,37,1],
["p1",9,"op","chess_char_5_diy1_a",2,null,0,0,77,0,6653,2744,0,3,0],
["p1",10,"op","chess_char_5_diy2_b",2,"uniequip_002_accast",0,0,7,11,170077,1749,0,21,1],
["p1",11,"op","chess_char_5_diy1_a",2,null,0,0,144,0,0,5333,10172,24,1],
["p1",12,"op","chess_char_5_diy2_b",2,"uniequip_002_acmedc",0,0,94,0,0,8375,15315,21,1],
["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null],
["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null],
["-","~3","device","trap_1105_accrate",null,null,100,1,3,0,0,0,0,0,null]
],
"enemies": {"enemy_1005_yokai":[51,16,35,0,83756],"enemy_1005_yokai_2":[54,5,49,24107,111017],"enemy_1005_yokai_3":[12,0,12,42273,179284]},
"hooks": [1,145,6743,501,501,975,936,86,44,140,9,9,0,14181,0,0,117,96,1,44,0,0,39],
"events": {"atk":594,"deploy":25,"die":44,"dmg":936,"fx":87,"heal":78,"leak":96,"skill":18,"spawn":132,"status":8},
"fx": {"aoe":1,"dodge":39,"dp":9,"slash":32,"spGain":6},
"snaps": "6b632c98 e087bd9f f5b9da13 f7038fad bb4d7811 5178d072 0c0de684 872f1892 81773c41 3d296a6a 66f1329a 3a99c8b1 aa011a13 319c8ec5 26231cc7 f808b9be 73dd7977 75857859 817f18b6 d1cdd533 cd927f27 64106a51 89f62784"
},
"diy-005": {
"about": "6_diy1_a=acfend 6_diy2_b=acfend 5_diy1_a=sharp2 5_diy2_b=sharp2 6_diy1_a=acpion 6_diy2_b=acpion 5_diy1_a=acspec 5_diy2_b=acspec 6_diy1_a=acguad 6_diy2_b=acguad 5_diy1_a=acsupo 5_diy2_b=acsupo",
"fast": false,
"kind": "normal",
"stageId": "act1autochess_m05",
"modeId": "mode_multi_funny",
"round": 13,
"seed": 823748262,
"waveId": "act1autochess_h06",
"time": 230,
"ticks": 6900,
"reason": "timeout",
"killed": 63,
"total": 123,
"unspawned": 0,
"bossHpLeft": null,
"rngDraws": 117,
"errors": {"count":0,"kinds":[]},
"players": {"p1":{"killed":63,"total":123,"perfect":false,"leaks":60,"leaksUncounted":0,"coins":0,"dmg":274732,"bossDmg":0,"heal":0,"deaths":20,"layerGains":{}}},
"units": [
["p1",1,"op","chess_char_6_diy1_a",2,null,1834,1,132,5,19446,3374,0,80,2],
["p1",2,"op","chess_char_6_diy2_b",2,"uniequip_002_acfend",2142.36,1,141,5,29488,4720,0,101,2],
["p1",3,"op","chess_char_5_diy1_a",0,null,1865,1,154,3,27639,3730,0,42,1],
["p1",4,"op","chess_char_5_diy2_b",0,"uniequip_002_sharp2",2374,1,152,11,43106,4748,0,39,1],
["p1",5,"op","chess_char_6_diy1_a",2,null,0,0,135,3,18986,4587,0,18,1],
["p1",6,"op","chess_char_6_diy2_b",2,"uniequip_002_acpion",1808.52,1,143,18,92036,3903,0,40,4],
["p1",7,"op","chess_char_5_diy1_a",2,null,117.2,1,130,12,39744,6807,0,115,0],
["p1",8,"op","chess_char_5_diy2_b",2,"uniequip_002_acspec",1448,1,1,0,0,0,0,0,0],
["p1",9,"op","chess_char_6_diy1_a",2,null,2638,1,3,0,0,0,0,0,0],
["p1",10,"op","chess_char_6_diy2_b",2,"uniequip_002_acguad",3531,1,5,0,0,0,0,0,0],
["p1",11,"op","chess_char_5_diy1_a",2,null,1160,1,153,0,1743,2320,0,7,0],
["p1",12,"op","chess_char_5_diy2_b",2,"uniequip_002_acsupo",1120.4,1,145,0,2545,3170,0,7,1]
],
"enemies": {"enemy_1019_jshoot_2":[57,37,20,9953,112024],"enemy_1299_ymkilr_2":[27,6,20,25821,123649],"enemy_1422_lrsldr_2":[39,20,19,1584,123193]},
"hooks": [1,154,6900,835,835,3335,3375,0,83,142,12,15,0,16427,38,83,123,59,1,83,0,0,0],
"events": {"atk":851,"deploy":31,"die":83,"dmg":3367,"fx":150,"leak":59,"skill":22,"spawn":135,"status":47},
"fx": {"aoe":83,"displace":3,"dp":26,"slash":38},
"snaps": "aa4b712f 51a2360b 59868653 c815c7eb 7dd47d06 3f3f03f4 89e883c3 60e1e7f6 2355716a 5d734980 75cde05f c0dd657c 4e5bd257 0f8a040d b1aafe05 c04cbb36 e6d37890 8457998a a19143e5 d709bc2b 4283543b 0d9edf54 6ef9963d"
},
"diy-006": {
"about": "6_diy1_a=sharp2 6_diy2_b=sharp2 6_diy1_a=acspec 6_diy2_b=acspec 6_diy1_a=acnipe 6_diy2_b=acnipe 6_diy1_a=accast 6_diy2_b=accast 6_diy1_a=acmedc 6_diy2_b=acmedc 6_diy1_a=acsupo 6_diy2_b=acsupo",
"fast": false,
"kind": "normal",
"stageId": "act1autochess_m06",
"modeId": "mode_single_hard",
"round": 13,
"seed": 2464539579,
"waveId": "act1autochess_h06",
"time": 210.23,
"ticks": 6307,
"reason": "cleared",
"killed": 5,
"total": 81,
"unspawned": 0,
"bossHpLeft": null,
"rngDraws": 123,
"errors": {"count":0,"kinds":[]},
"players": {"p1":{"killed":5,"total":81,"perfect":false,"leaks":76,"leaksUncounted":0,"coins":0,"dmg":185202,"bossDmg":0,"heal":4048,"deaths":30,"layerGains":{}}},
"units": [
["p1",1,"op","chess_char_6_diy1_a",0,null,0,0,3,0,11325,1865,0,7,1],
["p1",2,"op","chess_char_6_diy2_b",0,"uniequip_002_sharp2",0,0,6,3,52957,2494,0,16,1],
["p1",3,"op","chess_char_6_diy1_a",2,null,1154,1,112,0,15444,8178,0,22,0],
["p1",4,"op","chess_char_6_diy2_b",2,"uniequip_002_acspec",0,0,109,2,51553,12663,0,57,0],
["p1",5,"op","chess_char_6_diy1_a",2,null,1272,1,113,0,4256,2544,0,15,0],
["p1",6,"op","chess_char_6_diy2_b",2,"uniequip_002_acnipe",0,0,105,0,10180,3286,0,19,0],
["p1",7,"op","chess_char_6_diy1_a",2,null,0,0,5,0,3060,1372,0,2,0],
["p1",8,"op","chess_char_6_diy2_b",2,"uniequip_002_accast",0,0,2,0,30589,1819,0,4,1],
["p1",9,"op","chess_char_6_diy1_a",2,null,0,0,8,0,0,2028,2464,7,1],
["p1",10,"op","chess_char_6_diy2_b",2,"uniequip_002_acmedc",0,0,61,0,0,3890,1584,6,1],
["p1",11,"op","chess_char_6_diy1_a",2,null,0,0,58,0,2490,3291,0,7,1],
["p1",12,"op","chess_char_6_diy2_b",2,"uniequip_002_acsupo",0,0,60,0,3347,3320,0,8,0]
],
"enemies": {"enemy_1267_nhpbr_2":[27,5,22,7765,89733],"enemy_1270_nhstlk_2":[36,0,36,14278,68347],"enemy_1272_nhtank_2":[18,0,18,24706,27121]},
"hooks": [1,113,6307,262,262,449,476,16,35,111,6,21,0,5828,86,24,81,76,1,35,0,0,0],
"events": {"atk":283,"deploy":32,"die":35,"dmg":441,"fx":58,"heal":15,"leak":76,"skill":12,"spawn":93,"status":142},
"fx": {"aoe":7,"displace":21,"spGain":6,"zone":24},
"snaps": "1dcf4706 8bd55683 d93e4d02 ab832ad5 132699ab f37b039d f12175ca fe30225b 6b2b77c0 2caefa3a 1018f9e1 a60a2364 f81df3c0 d0f2780e 0e940f53 372c90ef 46af3a81 a6b2adf1 63234976 3b8b7c59 b62aa8d4"
}
}
}
+21 -2
View File
@@ -17,7 +17,12 @@
// path: the chess's ids / bonds / 特质 / tier, the stand-in's body, its kit by charId); the `_a` id is the // path: the chess's ids / bonds / 特质 / tier, the stand-in's body, its kit by charId); the `_a` id is the
// normal form, `_b` the elite, skill / module are the chess's backup selection. `standIn: { skillIndex?, // normal form, `_b` the elite, skill / module are the chess's backup selection. `standIn: { skillIndex?,
// moduleId? }` fields it with another of the stand-in's skills / modules (a kit test of a skill no chess // moduleId? }` fields it with another of the stand-in's skills / modules (a kit test of a skill no chess
// names): the composed record (standInRec) replaces that chess id's record for the whole battle // names): the composed record (standInRec) replaces that chess id's record for the whole battle.
// `{ diy: { slot, charId, skillIndex?, uniEquipId? }, elite?, row, col }` fields a 自选 piece — the
// production path (simdata getChess(slotId, { diy }), shared/diy.js): `slot` = a DIY slot's base id
// (`chess_char_5_diy1_a`) or its tier (5 / 6 ⇒ that tier's first slot), `elite` = its `_b` form (E2
// Lv60, module stage 1 at tier 5 / 3 at tier 6); the operator's kit is KITS[charId]; a prototype may
// omit skillIndex / uniEquipId (its locked ones)
// players full PlayerBattleInput[] (overrides `units`) // players full PlayerBattleInput[] (overrides `units`)
// enemies [{ key, time=0, route=0 | RouteSpec, pos?, count?, interval?, mods?, tag?, bounty?, sourcePlayerId? }] // enemies [{ key, time=0, route=0 | RouteSpec, pos?, count?, interval?, mods?, tag?, bounty?, sourcePlayerId? }]
// waveTemplate wave id (data/waves.json) or template object → routes + spawns + timeLimit // waveTemplate wave id (data/waves.json) or template object → routes + spawns + timeLimit
@@ -179,6 +184,20 @@ function buildData(defs) {
return new DataSource({ chess: defs.chess ?? {}, enemies: defs.enemies ?? {}, tokens: defs.tokens ?? {}, stages: defs.stages ?? {}, waves: defs.waves ?? {} }, getDefaultSource()); return new DataSource({ chess: defs.chess ?? {}, enemies: defs.enemies ?? {}, tokens: defs.tokens ?? {}, stages: defs.stages ?? {}, waves: defs.waves ?? {} }, getDefaultSource());
} }
/**
* A harness `units[]` entry with `diy` → its PlayerBattleInput fields: `chessId` = the slot's normal or elite id, `diy` =
* the pick ({ charId, skillIndex, uniEquipId } as given). Other entries unchanged.
*/
function diyEntry(u, data = getDefaultSource()) {
if (!u || !u.diy || typeof u.diy !== 'object') return u;
const slots = data.rawBackups?.()?.diy?.slots ?? {};
const { slot, ...pick } = u.diy;
const base = typeof slot === 'number' ? Object.keys(slots).find((id) => slots[id].tier === slot) : slot;
if (!base || !slots[base]) throw new Error(`diy: unknown slot ${slot}`);
const { elite, ...rest } = u;
return { ...rest, chessId: elite ? slots[base].goldenId : base, diy: pick };
}
/** `units[].standIn` objects → the composed records for `defs.chess` (null when there are none). */ /** `units[].standIn` objects → the composed records for `defs.chess` (null when there are none). */
function standInDefs(units) { function standInDefs(units) {
let out = null; let out = null;
@@ -225,7 +244,7 @@ export function makeBattle(opts = {}) {
if (extraRoutes.length) routes = routes.concat(extraRoutes); if (extraRoutes.length) routes = routes.concat(extraRoutes);
const players = opts.players ?? [{ const players = opts.players ?? [{
playerId: 'p1', seat: 0, side: 'L', colOffset: 0, playerId: 'p1', seat: 0, side: 'L', colOffset: 0,
units: (opts.units ?? []).map((u, i) => ({ uid: u.uid ?? i + 1, kind: u.kind ?? 'chess', ...u, ...(u.standIn ? { standIn: true } : null) })), units: (opts.units ?? []).map((u, i) => ({ uid: u.uid ?? i + 1, kind: u.kind ?? 'chess', ...diyEntry(u), ...(u.standIn ? { standIn: true } : null) })),
bonds: opts.bonds ?? {}, bandId: opts.bandId ?? null, playerEffects: opts.playerEffects ?? [], bonds: opts.bonds ?? {}, bandId: opts.bandId ?? null, playerEffects: opts.playerEffects ?? [],
}]; }];
const hookNames = opts.hooks ?? ALL_HOOKS; const hookNames = opts.hooks ?? ALL_HOOKS;
+10 -3
View File
@@ -10,7 +10,8 @@
// - Idempotent: an existing file is kept when its size matches the ledger entry // - Idempotent: an existing file is kept when its size matches the ledger entry
// of a previous download or the expected byte count from research, or (when // of a previous download or the expected byte count from research, or (when
// neither is known) when it passes format validation. The ledger lives in // neither is known) when it passes format validation. The ledger lives in
// .cache/assets-ledger.json. // .cache/assets-ledger.json. `keepExisting` (fetch-assets --add-only) keeps
// every existing file as it is; `written` = the files this run wrote.
import { mkdir, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises'; import { mkdir, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises';
import { dirname, join } from 'node:path'; import { dirname, join } from 'node:path';
@@ -39,17 +40,21 @@ export class Downloader {
* @param {number} [o.retries] * @param {number} [o.retries]
* @param {number} [o.timeoutMs] * @param {number} [o.timeoutMs]
* @param {boolean} [o.force] re-download even when files exist * @param {boolean} [o.force] re-download even when files exist
* @param {boolean} [o.keepExisting] never re-download or rewrite an existing file (a checkout sharing public/assets)
* @param {(msg:string)=>void} [o.log] * @param {(msg:string)=>void} [o.log]
* @param {typeof fetch} [o.fetchImpl] * @param {typeof fetch} [o.fetchImpl]
* @param {number} [o.backoffMs] base retry delay (doubles per attempt) * @param {number} [o.backoffMs] base retry delay (doubles per attempt)
*/ */
constructor({ root, ledgerPath, concurrency = 16, retries = 3, timeoutMs = 120000, force = false, log = console.log, fetchImpl = globalThis.fetch, backoffMs = 400 }) { constructor({ root, ledgerPath, concurrency = 16, retries = 3, timeoutMs = 120000, force = false, keepExisting = false, log = console.log, fetchImpl = globalThis.fetch, backoffMs = 400 }) {
this.root = root; this.root = root;
this.ledgerPath = ledgerPath; this.ledgerPath = ledgerPath;
this.concurrency = Math.max(1, Math.min(64, Number(concurrency) || 16)); this.concurrency = Math.max(1, Math.min(64, Number(concurrency) || 16));
this.retries = Math.max(1, Number(retries) || 3); this.retries = Math.max(1, Number(retries) || 3);
this.timeoutMs = timeoutMs; this.timeoutMs = timeoutMs;
this.force = force; this.force = force && !keepExisting;
this.keepExisting = !!keepExisting;
/** @type {Set<string>} the files (paths under root) this run wrote */
this.written = new Set();
this.log = log; this.log = log;
this.fetch = fetchImpl; this.fetch = fetchImpl;
this.backoffMs = Math.max(0, Number(backoffMs) || 0); this.backoffMs = Math.max(0, Number(backoffMs) || 0);
@@ -87,6 +92,7 @@ export class Downloader {
let st; let st;
try { st = await stat(abs); } catch { return -1; } try { st = await stat(abs); } catch { return -1; }
if (!st.isFile() || st.size <= 0) return -1; if (!st.isFile() || st.size <= 0) return -1;
if (this.keepExisting) return st.size;
const led = this.ledger.files[job.rel]; const led = this.ledger.files[job.rel];
if (job.mutable) { if (job.mutable) {
// Post-processed files (atlases) change size; validate content instead. // Post-processed files (atlases) change size; validate content instead.
@@ -162,6 +168,7 @@ export class Downloader {
return { status: 'error', bytes: 0, error: `write failed: ${e.message}` }; return { status: 'error', bytes: 0, error: `write failed: ${e.message}` };
} }
this.ledger.files[job.rel] = { url: src, bytes: r.buf.length }; this.ledger.files[job.rel] = { url: src, bytes: r.buf.length };
this.written.add(job.rel);
this.dirty++; this.dirty++;
const sizeChanged = !!(job.bytes && job.bytes !== r.buf.length && !job.mutable); const sizeChanged = !!(job.bytes && job.bytes !== r.buf.length && !job.mutable);
return { status: 'ok', bytes: r.buf.length, url: src, sizeChanged }; return { status: 'ok', bytes: r.buf.length, url: src, sizeChanged };
+52 -7
View File
@@ -11,7 +11,10 @@
// audio_data.json and Ark-Models' models_data.json. // audio_data.json and Ark-Models' models_data.json.
// //
// Scope (research 07 §1, DESIGN §0): all 138 pool charIds (incl. backup // Scope (research 07 §1, DESIGN §0): all 138 pool charIds (incl. backup
// operators), the 20 pool tokens, every enemy that can appear in an // operators), the 自选 owned-6★ picks of data/backups.json (`extraOperators`:
// research 07 does not list them — their URLs follow its patterns) and their
// summons, the module type icons of every module the data offers
// (`moduleTypes`), the 20 pool tokens, every enemy that can appear in an
// act2autochess match (07 enemy list ∪ act1autochess wave/boss levels used by // act2autochess match (07 enemy list ∪ act1autochess wave/boss levels used by
// act2 modes ∪ bosses ∪ their summons ∪ enemy units spawned by operator kits), // act2 modes ∪ bosses ∪ their summons ∪ enemy units spawned by operator kits),
// the 23 bonds, 59 shop items, 40 bands, // the 23 bonds, 59 shop items, 40 bands,
@@ -165,6 +168,32 @@ function walkKeys(node, add) {
} }
} }
/**
* A research-07-shaped operator record of a character research 07 does not list (the 自选 owned-6★ picks): every URL
* from the patterns of 07-assets.json `meta.patterns` — avatar / portrait (E0–E1 and E2), the default-skin battle Spine
* Front / Back, the skill icons, the sub-profession icon. No expected byte counts (the downloader validates the files);
* a file the mirrors lack is a miss the client falls back from (the E2 art to the E0–E1 one, a missing Back to Front).
* @param {string} id charId
* @param {{ subProfessionId?: string|null, nationId?: string|null, skills?: Array<{ index: number, skillId: string, iconId?: string|null }> }} x
*/
export function patternOperator(id, x = {}) {
const sp = (side) => {
const b = `${RAW.fexli}spine/${id}/${id}/${side}/${id}`;
return { skel: `${b}.skel`, atlas: `${b}.atlas`, png: `${b}.png` };
};
return {
name: x.name ?? null, subProfessionId: x.subProfessionId ?? null, nationId: x.nationId ?? null, chess: [],
avatar: { e0e1: { url: `${RAW.yuanyan}avatar/${id}.png` }, e2: { url: `${RAW.yuanyan}avatar/${id}_2.png` } },
portrait: { e0e1: { url: `${RAW.yuanyan}portrait/${id}_1.png` }, e2: { url: `${RAW.yuanyan}portrait/${id}_2.png` } },
skills: (x.skills || []).map((s) => ({
index: s.index, skillId: s.skillId, iconId: s.iconId || s.skillId,
icon: { url: `${RAW.yuanyan}skill/skill_icon_${encodeURIComponent(s.iconId || s.skillId)}.png` },
})),
battleSpine: { front: sp('Front'), back: sp('Back'), note: null },
subProfessionIcon: x.subProfessionId ? joinUrl(RAW.aa2, `arts/ui/subprofessionicon/sub_${x.subProfessionId}_icon.png`) : null,
};
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// id sets // id sets
@@ -249,9 +278,14 @@ export function collectEnemyIds({ assets07, enemies05, maps05, ops03 }) {
* @param {Record<string, import('./spine.mjs').LocalSpineMeta>} [p.localEnemySpines] metadata of the enemy models the * @param {Record<string, import('./spine.mjs').LocalSpineMeta>} [p.localEnemySpines] metadata of the enemy models the
* local client has (the committed tools/assets/local-enemy-spines.json, never the disk): each planned enemy listed * local client has (the committed tools/assets/local-enemy-spines.json, never the disk): each planned enemy listed
* gets `spineLocal` = { group: 'spine/enemy/<id>', ...meta } beside its web `spine` * gets `spineLocal` = { group: 'spine/enemy/<id>', ...meta } beside its web `spine`
* @param {Record<string, any>} [p.extraOperators] charId → { subProfessionId, nationId, skills: [{ index, skillId, iconId }] }
* of characters research 07 does not list (the 自选 owned-6★ picks, data/backups.json `units`): planned like the pool
* operators from patternOperator
* @param {string[]} [p.moduleTypes] module type icon ids (`typeIcon`, e.g. 'sol-x') → manifest `modules[typeIcon]`, the
* official type icon (arts/ui/uniequiptype) the 干员调配 / 自选 module tiles draw when the local-client art lacks it
* @returns {{ template: any, models: Map<string, any>, notes: string[] }} * @returns {{ template: any, models: Map<string, any>, notes: string[] }}
*/ */
export function buildPlan({ assets07, ops03, enemies05, maps05, audio, modelsData, extraEnemyIds = [], extraTokenIds = [], extraHandbook = {}, localEnemySpines = {} }) { export function buildPlan({ assets07, ops03, enemies05, maps05, audio, modelsData, extraEnemyIds = [], extraTokenIds = [], extraHandbook = {}, localEnemySpines = {}, extraOperators = {}, moduleTypes = [] }) {
const notes = []; const notes = [];
/** @type {Map<string, any>} */ /** @type {Map<string, any>} */
const models = new Map(); const models = new Map();
@@ -280,9 +314,13 @@ export function buildPlan({ assets07, ops03, enemies05, maps05, audio, modelsDat
const skills = {}; const skills = {};
const skillsById = {}; const skillsById = {};
const unitsSfx = {}; const unitsSfx = {};
const charIds = Object.keys(assets07?.operators || {}).sort(); const known = assets07?.operators || {};
const extraOps = {};
for (const [id, x] of Object.entries(extraOperators || {})) if (/^char_\d+_[a-z0-9]+$/i.test(id) && !known[id]) extraOps[id] = patternOperator(id, x);
const operators = { ...known, ...extraOps };
const charIds = Object.keys(operators).sort();
for (const id of charIds) { for (const id of charIds) {
const o = assets07.operators[id]; const o = operators[id];
// DESIGN §16 operator loadouts: any skill of the character can be equipped — the icons, skill SFX and Spine skill // DESIGN §16 operator loadouts: any skill of the character can be equipped — the icons, skill SFX and Spine skill
// clips of every skill index (the pool's primary index first, as before) // clips of every skill index (the pool's primary index first, as before)
const idx0 = skillIdx.get(id) || [0]; const idx0 = skillIdx.get(id) || [0];
@@ -448,7 +486,7 @@ export function buildPlan({ assets07, ops03, enemies05, maps05, audio, modelsDat
for (const p of [...PROFESSIONS, 'token']) { for (const p of [...PROFESSIONS, 'token']) {
prof.battlecard[p] = leaf(alt(`prof/battlecard_${p}.png`, joinUrl(RAW.aa2, `arts/ui/[uc]battlecommon/ui_battle_new/battlecard/icon_profession_${p}.png`))); prof.battlecard[p] = leaf(alt(`prof/battlecard_${p}.png`, joinUrl(RAW.aa2, `arts/ui/[uc]battlecommon/ui_battle_new/battlecard/icon_profession_${p}.png`)));
} }
for (const o of Object.values(assets07?.operators || {})) { for (const o of Object.values(operators)) {
const sub = o.subProfessionId; const sub = o.subProfessionId;
if (typeof sub === 'string' && sub && !prof.sub[sub] && o.subProfessionIcon) prof.sub[sub] = leaf(alt(`prof/sub/${safeName(sub)}.png`, o.subProfessionIcon)); if (typeof sub === 'string' && sub && !prof.sub[sub] && o.subProfessionIcon) prof.sub[sub] = leaf(alt(`prof/sub/${safeName(sub)}.png`, o.subProfessionIcon));
} }
@@ -463,7 +501,7 @@ export function buildPlan({ assets07, ops03, enemies05, maps05, audio, modelsDat
for (const [group, entries] of Object.entries(assets07?.autochessUi || {})) { for (const [group, entries] of Object.entries(assets07?.autochessUi || {})) {
for (const [key, url] of Object.entries(entries || {})) addUi(group, key, url); for (const [key, url] of Object.entries(entries || {})) addUi(group, key, url);
} }
const nations = new Set(Object.values(assets07?.operators || {}).map((o) => o.nationId).filter(Boolean)); const nations = new Set(Object.values(operators).map((o) => o.nationId).filter(Boolean));
const logos = new Set(['logo_rhodes', ...[...nations].map((n) => `logo_${n}`)]); const logos = new Set(['logo_rhodes', ...[...nations].map((n) => `logo_${n}`)]);
for (const b of Object.values(assets07?.bonds || {})) if (b.fallbackCampLogo) logos.add(urlBase(b.fallbackCampLogo).replace(/\.png$/i, '')); for (const b of Object.values(assets07?.bonds || {})) if (b.fallbackCampLogo) logos.add(urlBase(b.fallbackCampLogo).replace(/\.png$/i, ''));
for (const [src, group] of Object.entries(ARTS_GROUPS)) { for (const [src, group] of Object.entries(ARTS_GROUPS)) {
@@ -517,8 +555,15 @@ export function buildPlan({ assets07, ops03, enemies05, maps05, audio, modelsDat
const sfxBattle = {}; const sfxBattle = {};
for (const [name, spec] of Object.entries(BATTLE_SFX)) { const l = soundLeaf(resolveSpec(spec, audio.bank)); if (l) sfxBattle[name] = l; else notes.push(`battle SFX ${name}: no sound`); } for (const [name, spec] of Object.entries(BATTLE_SFX)) { const l = soundLeaf(resolveSpec(spec, audio.bank)); if (l) sfxBattle[name] = l; else notes.push(`battle SFX ${name}: no sound`); }
// --- module type icons ----------------------------------------------------
const modules = {};
for (const t of [...new Set(moduleTypes || [])].filter((x) => typeof x === 'string' && /^[a-z0-9-]+$/i.test(x)).sort()) {
// the client's file name, else its lower-case form (the type id of a few modules is mixed case: WAH-Y → wah-y.png)
modules[t] = leaf(alt(`module/${safeName(t)}.png`, [...new Set([t, t.toLowerCase()])].map((n) => joinUrl(RAW.aa2, `arts/ui/uniequiptype/${n}.png`))));
}
const template = { const template = {
chars, enemies, tokens, bonds, items, bands, skills, skillsById, ui, prof, chars, enemies, tokens, bonds, items, bands, skills, skillsById, modules, ui, prof,
audio: { bgm, bossBgm: Object.fromEntries(Object.entries(bossBgm).sort(([a], [b]) => a.localeCompare(b, 'en', { numeric: true }))), sfx: { ui: sfxUi, battle: sfxBattle, units: unitsSfx } }, audio: { bgm, bossBgm: Object.fromEntries(Object.entries(bossBgm).sort(([a], [b]) => a.localeCompare(b, 'en', { numeric: true }))), sfx: { ui: sfxUi, battle: sfxBattle, units: unitsSfx } },
}; };
return { template, models, notes }; return { template, models, notes };
+5 -1
View File
@@ -123,10 +123,12 @@ export async function loadLocalEnemySpines(path) {
* @param {import('./downloader.mjs').Downloader} o.dl * @param {import('./downloader.mjs').Downloader} o.dl
* @param {string} o.cachePath parse cache JSON path * @param {string} o.cachePath parse cache JSON path
* @param {boolean} [o.download] false = only post-process what is on disk * @param {boolean} [o.download] false = only post-process what is on disk
* @param {(rel: string) => boolean} [o.writable] whether a file on disk may be rewritten (normalized atlas) or deleted
* (corrupt skeleton); fetch-assets --add-only allows only the files this run downloaded
* @param {(m:string)=>void} [o.log] * @param {(m:string)=>void} [o.log]
* @returns {Promise<{ entries: Map<string, SpineEntry>, problems: string[] }>} * @returns {Promise<{ entries: Map<string, SpineEntry>, problems: string[] }>}
*/ */
export async function processModels(models, { root, dl, cachePath, download = true, log = console.log }) { export async function processModels(models, { root, dl, cachePath, download = true, writable = () => true, log = console.log }) {
const problems = []; const problems = [];
const list = [...models.values()]; const list = [...models.values()];
if (download) { if (download) {
@@ -180,6 +182,7 @@ export async function processModels(models, { root, dl, cachePath, download = tr
const norm = normalizeAtlas(atlasText, { pageSize: (p) => sizes.get(p) || null, pma: !!m.pma, renamePage: (p) => safeName(p) }); const norm = normalizeAtlas(atlasText, { pageSize: (p) => sizes.get(p) || null, pma: !!m.pma, renamePage: (p) => safeName(p) });
if (norm.missingSize.length) { problems.push(`${m.key}: cannot size pages ${norm.missingSize.join(',')}`); continue; } if (norm.missingSize.length) { problems.push(`${m.key}: cannot size pages ${norm.missingSize.join(',')}`); continue; }
if (norm.changed) { if (norm.changed) {
if (!writable(m.atlas.rel)) { problems.push(`${m.key}: atlas needs normalizing, left as it is (an existing file)`); continue; }
await writeFile(atlasAbs + '.tmp', norm.text); await writeFile(atlasAbs + '.tmp', norm.text);
await rename(atlasAbs + '.tmp', atlasAbs); await rename(atlasAbs + '.tmp', atlasAbs);
} }
@@ -196,6 +199,7 @@ export async function processModels(models, { root, dl, cachePath, download = tr
} catch (e) { } catch (e) {
// A corrupt skeleton would otherwise be kept forever (its size matches the ledger): // A corrupt skeleton would otherwise be kept forever (its size matches the ledger):
// delete it and forget it so the next online run downloads it again. // delete it and forget it so the next online run downloads it again.
if (!writable(m.skel.rel)) { problems.push(`${m.key}: skel parse failed (${e.message}); an existing file, left as it is`); continue; }
problems.push(`${m.key}: skel parse failed (${e.message}); deleted, re-run to re-download`); problems.push(`${m.key}: skel parse failed (${e.message}); deleted, re-run to re-download`);
try { await unlink(skelAbs); } catch { /* ignore */ } try { await unlink(skelAbs); } catch { /* ignore */ }
if (dl?.ledger?.files) delete dl.ledger.files[m.skel.rel]; if (dl?.ledger?.files) delete dl.ledger.files[m.skel.rel];
+207 -55
View File
@@ -1071,6 +1071,21 @@ function buildChess(ctx) {
*/ */
const DIY_EXTRA_PROTOTYPES = Object.freeze({ 5: Object.freeze({ rarity: 4, excludedProfessions: Object.freeze(['PIONEER', 'SPECIAL']) }) }); const DIY_EXTRA_PROTOTYPES = Object.freeze({ 5: Object.freeze({ rarity: 4, excludedProfessions: Object.freeze(['PIONEER', 'SPECIAL']) }) });
/**
* 自选 owned picks left out of the data and the pool by the owner's decision of 2026-10-05: the collab operators
* (copyright) — every 6★ whose `mainPower` or a `subPower` names one of these 联动 teams: rainbow (灰烬, 艾拉), action4
* (麒麟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']);
/**
* The skill a prototype carries in a 自选 slot when no 补位 row of the slot's tier names it (only 预备干员-医疗 at tier 5:
* it stands in at tier 3 only). PRTS 卫戍协议 says the prototypes' "技能携带规则与系统补位时一致"; [ASSUMED] (the owner's
* decision of 2026-10-05) the selection of its 补位 rows at that tier, and for this one S3 — every other 4★ reserve's
* tier-5 row — with no module.
*/
const DIY_PROTOTYPE_FALLBACK_SKILL = 2;
const rarityOf = (char) => Number(String(char?.rarity).replace('TIER_', '')) || null; const rarityOf = (char) => Number(String(char?.rarity).replace('TIER_', '')) || null;
const statusCmp = (a, b) => a.phase - b.phase || a.level - b.level || a.skillLevel - b.skillLevel || a.equipLevel - b.equipLevel; const statusCmp = (a, b) => a.phase - b.phase || a.level - b.level || a.skillLevel - b.skillLevel || a.equipLevel - b.equipLevel;
@@ -1173,16 +1188,21 @@ function buildUnitForm(ctx, charId, status, { chessId = null, skillIndex = null
/** /**
* data/backups.json (DATA.md §18; DESIGN 0.2.0 draft): the data of 补位 and 自选. * data/backups.json (DATA.md §18; DESIGN 0.2.0 draft): the data of 补位 and 自选.
* - `units`: every character a NORMAL chess names as `backup.charId` — the 17 原型干员 (预备干员 char_600–607, the 6★ * - `units`: every character a 补位 or 自选 piece fields, with a form for every status it fights at. First the 17
* 罗德岛特派高级资深干员 char_608–615 and 领主·Sharp char_617) — with a form for every status it fights at: the statuses * 原型干员 a NORMAL chess names as `backup.charId` (预备干员 char_600–607, the 6★ 罗德岛特派高级资深干员 char_608–615 and
* of the chess it stands in for (normal and elite) and of the DIY slots it may fill. No unit for a PRESET chess * 领主·Sharp char_617) at the statuses of the chess it stands in for (normal and elite) and of the DIY slots it may fill;
* (特许: always the real operator; its backup is itself) or a DIY slot (no backup). * then every owned-6★ pick (`diy.ownedPool`) at the DIY slot statuses — E2 Lv1 skill rank 4 without a module, E2 Lv60
* rank 7 with every module at stage 1 (tier 5) and at stage 3 (tier 6). No unit for a PRESET chess (特许: always the
* real operator; its backup is itself) or a DIY slot (no backup).
* - `tokens`: the summons of the owned picks (buildDiyTokens: tokens.json records whose `variants` are keyed by owner
* form, `<charId>@<statusKey>`).
* - `diy`: the slots (tier, elite id, the shop level that lists them, the rarity requirement), the prototype picks per * - `diy`: the slots (tier, elite id, the shop level that lists them, the rarity requirement), the prototype picks per
* slot tier (DIY_EXTRA_PROTOTYPES), the owned-6★ pool (obtainable, not a roster operator: no chess names it, hidden * slot tier (DIY_EXTRA_PROTOTYPES) and the skill / module each carries there (`locked`: its 补位 rows' selection at
* chess included — "不可甄选加入已在名单中的固定干员") and, for every pick, its faction ids (`mainPower` and every * that tier — "技能携带规则与系统补位时一致"), the owned-6★ pool (obtainable, not a roster operator: no chess names it,
* `subPower`: "依据其「所属势力」「隐藏势力」等属性决定其盟约") and the core bonds whose `powerIdList` meets them, else * hidden chess included — "不可甄选加入已在名单中的固定干员" — and not a collab, DIY_EXCLUDED_TEAMS: `excluded`) and, for
* `constData.fallbackBondId` (协防干员) — PRTS 「卫戍协议」 "甄选加入的干员会根据其实际阵营所属分配核心盟约,若没有可匹配的 * every pick, its faction ids (`mainPower` and every `subPower`: "依据其「所属势力」「隐藏势力」等属性决定其盟约") and the
* 则改为分配协防干员盟约". * core bonds whose `powerIdList` meets them, else `constData.fallbackBondId` (协防干员) — PRTS 「卫戍协议」
* "甄选加入的干员会根据其实际阵营所属分配核心盟约,若没有可匹配的则改为分配协防干员盟约".
*/ */
function buildBackups(ctx, chess) { function buildBackups(ctx, chess) {
const { act, ac, charTable } = ctx; const { act, ac, charTable } = ctx;
@@ -1207,12 +1227,15 @@ function buildBackups(ctx, chess) {
for (const d of Object.values(act.shopLevelDisplayDataDict || {})) for (const id of d.charChessDiySlotIdList || []) shopLevelOf[id] = d.shopLevel; for (const d of Object.values(act.shopLevelDisplayDataDict || {})) for (const id of d.charChessDiySlotIdList || []) shopLevelOf[id] = d.shopLevel;
const slots = {}; const slots = {};
const prototypes = {}; const prototypes = {};
const locked = {};
const requirements = new Set(); const requirements = new Set();
const diyStatuses = new Map(); // statusKey → status of every DIY slot record (both forms)
for (const s of Object.values(chess).filter((c) => c.isDiy && !c.isGolden).sort((x, y) => naturalCmp(x.chessId, y.chessId))) { for (const s of Object.values(chess).filter((c) => c.isDiy && !c.isGolden).sort((x, y) => naturalCmp(x.chessId, y.chessId))) {
const golden = chess[s.goldenId]; const golden = chess[s.goldenId];
if (!golden) continue; if (!golden) continue;
requirements.add(s.diyRequirement); requirements.add(s.diyRequirement);
slots[s.chessId] = { tier: s.tier, goldenId: s.goldenId, shopLevel: shopLevelOf[s.chessId] ?? null, requirement: s.diyRequirement }; slots[s.chessId] = { tier: s.tier, goldenId: s.goldenId, shopLevel: shopLevelOf[s.chessId] ?? null, requirement: s.diyRequirement };
for (const rec of [s, golden]) diyStatuses.set(statusKey(rec.status), rec.status);
const extra = DIY_EXTRA_PROTOTYPES[s.tier]; const extra = DIY_EXTRA_PROTOTYPES[s.tier];
const picks = standInIds.filter((id) => { const picks = standInIds.filter((id) => {
const ch = charTable[id]; const ch = charTable[id];
@@ -1221,27 +1244,48 @@ function buildBackups(ctx, chess) {
if (prototypes[s.tier] && JSON.stringify(prototypes[s.tier]) !== JSON.stringify(picks)) warn(`DIY tier ${s.tier}: slots disagree on the prototype picks`); if (prototypes[s.tier] && JSON.stringify(prototypes[s.tier]) !== JSON.stringify(picks)) warn(`DIY tier ${s.tier}: slots disagree on the prototype picks`);
prototypes[s.tier] = picks; prototypes[s.tier] = picks;
for (const id of picks) { addNeed(id, s.status); addNeed(id, golden.status); } for (const id of picks) { addNeed(id, s.status); addNeed(id, golden.status); }
// the skill / module a prototype carries in a slot of this tier: its 补位 rows' (the NORMAL base chess of the tier
// whose backup it is), else DIY_PROTOTYPE_FALLBACK_SKILL without a module [ASSUMED]
locked[s.tier] = {};
for (const id of picks) {
const rows = Object.values(chess).filter((c) => c.chessType === 'NORMAL' && !c.isGolden && c.tier === s.tier && c.backup?.charId === id)
.sort((x, y) => naturalCmp(x.chessId, y.chessId));
const kinds = new Set(rows.map((c) => `${c.backup.skillIndex}|${c.backup.uniEquipId ?? ''}`));
if (kinds.size > 1) warn(`DIY tier ${s.tier} prototype ${id}: its 补位 rows carry different selections (${[...kinds].join(', ')}); the first row's is used`);
locked[s.tier][id] = rows.length
? { skillIndex: rows[0].backup.skillIndex, uniEquipId: rows[0].backup.uniEquipId ?? null, from: rows.map((c) => c.chessId) }
: { skillIndex: DIY_PROTOTYPE_FALLBACK_SKILL, uniEquipId: null, from: [] };
}
} }
const roster = new Set(Object.values(act.charShopChessDatas).map((r) => r.charId).filter(Boolean));
const teamsOf = (ch) => [ch.mainPower?.teamId ?? ch.teamId, ...(ch.subPower || []).map((p) => p?.teamId)].filter(Boolean);
const excludedTeams = new Set(DIY_EXCLUDED_TEAMS);
const legal6 = Object.keys(charTable).filter((id) => {
const ch = charTable[id];
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)));
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);
const units = {}; const units = {};
for (const id of [...need.keys()].sort(naturalCmp)) { for (const id of [...standInIds, ...ownedPool]) {
const unit = buildUnitHead(ctx, id); const unit = buildUnitHead(ctx, id);
unit.standsIn = (standsIn.get(id) || []).sort(naturalCmp); unit.standsIn = (standsIn.get(id) || []).sort(naturalCmp);
unit.forms = {}; unit.forms = {};
for (const st of [...need.get(id).values()].sort(statusCmp)) { for (const st of [...need.get(id).values()].sort(statusCmp)) {
const form = buildUnitForm(ctx, id, st); const form = buildUnitForm(ctx, id, st);
if (form.tokens.length) warn(`unit ${id}@${statusKey(st)}: summons ${form.tokens.join(', ')} have no tokens.json variant for a stand-in owner`); if (form.tokens.length && unit.standsIn.length) warn(`unit ${id}@${statusKey(st)}: summons ${form.tokens.join(', ')} have no tokens.json variant for a stand-in owner`);
unit.forms[statusKey(st)] = form; unit.forms[statusKey(st)] = form;
} }
units[id] = unit; units[id] = unit;
} }
for (const id of need.keys()) if (!units[id]) warn(`unit ${id}: needed but neither a stand-in nor an owned pick`);
const tokens = buildDiyTokens(ctx, units, ownedPool);
const roster = new Set(Object.values(act.charShopChessDatas).map((r) => r.charId).filter(Boolean));
const ownedPool = Object.keys(charTable).filter((id) => {
const ch = charTable[id];
return id.startsWith('char_') && requirements.has(ch.rarity) && ch.profession !== 'TOKEN' && ch.profession !== 'TRAP'
&& !ch.isNotObtainable && !roster.has(id);
}).sort(naturalCmp);
const coreBonds = Object.values(ac.bondInfoDict || {}).filter((b) => b.isPower && (b.powerIdList || []).length); const coreBonds = Object.values(ac.bondInfoDict || {}).filter((b) => b.isPower && (b.powerIdList || []).length);
const fallback = act.constData.fallbackBondId; const fallback = act.constData.fallbackBondId;
const operators = {}; const operators = {};
@@ -1257,7 +1301,7 @@ function buildBackups(ctx, chess) {
obtainable: !ch.isNotObtainable, powers, bonds: hit.length ? hit : [fallback], obtainable: !ch.isNotObtainable, powers, bonds: hit.length ? hit : [fallback],
}; };
} }
return { units, diy: { slots, prototypes, ownedPool, operators } }; return { units, tokens, diy: { slots, prototypes, locked, ownedPool, excluded, operators } };
} }
/** /**
@@ -1380,14 +1424,130 @@ const TOKEN_ABNORMAL = Object.freeze({
enemy_9012_acloon: ['isolated'], // “炎佑” enemy_9012_acloon: ['isolated'], // “炎佑”
}); });
/**
* The tokens.json record of a summon (buildTokens; the 自选 picks' summons, buildDiyTokens) from its per-owner
* `variants` (key → variant; `owners` = the keys, in order; the defaults are the first owner's variant).
* Hand cards placed during the prep phase (`placeable`) are the MANUALLY DEPLOYABLE summons (PRTS 卫戍协议/帮助
* §战斗部署: "如果部署的干员拥有可手动部署的附属召唤物,则该召唤物会立刻加入手牌区"; user playtest #6): the shop
* state's tokenDisplayType DEFAULT — 赫默's 医疗探机 and 巫恋's 诅咒娃娃 (skill summons) as well as 浊心斯卡蒂's 海嗣,
* 伺夜's 狼群 and 缪尔赛思's 流形 (talent summons) — and a summon the shop state does not list at all: 凯瑟琳's
* 爬行号·防护单元 (talent "携带3个支援装置(最多部署2个)", deployed by hand in the base game; a friend of the user:
* placed by hand officially; confirmed by the user after playtest #6 — DESIGN §20). It is the only pool summon
* missing from shopStateTokenDict (every other one is listed, as are newer tokens such as 10040, 10042, 10043,
* 10055–10058, 10065), so no entry is read as the default display. HIDDEN tokens exist in battle only (e.g.
* 投递坐标 — PRTS: "携带技能【使命必达!】的新约能天使,不会提供所属召唤物"). Only a token its owner actually makes
* (`produced`: a talent or a skill of some loadout, `sources`) is a card; which loadouts make it is per variant
* (`sources`, `bySkill[i].sources`: 赫默 / 巫恋 on S1 get none). In battle a skill's summon deploys once at the start, then
* takes its tile again each time the skill gives one (sim/content/tokens.js dockSkillSummons, shared/constants.js
* SKILL_SUMMON_START_DEPLOY).
* `ownerRange`: the token text "只能部署在召唤者攻击范围内" (the tacticians' 援军 — 伺夜's 狼群, 缪尔赛思's 流形; PRTS 狼群
* 特性): its hand piece may only be placed on a tile of its owner's attack range (server/match/board.js
* ownerRangeKeys, PlayerState._legal; player report #9 after 0.1.0: 伺夜's tactical point could go anywhere).
* `abnormal` = TOKEN_ABNORMAL (PRTS).
*/
function summonRecord(ctx, tokenId, char, variants, produced) {
const displayType = ctx.ac.shopStateTokenDict?.[tokenId]?.tokenDisplayType || null;
const owners = Object.keys(variants);
const first = variants[owners[0]];
const ownerRange = /只能部署在\S*攻击范围内/.test(stripRich(first.trait.desc) || '');
return {
tokenId, kind: 'summon', name: char.name, appellation: char.appellation || null,
desc: stripRich(first.trait.desc), descRaw: first.trait.descRaw,
profession: char.profession, subProfessionId: char.subProfessionId, position: char.position,
displayType, placeable: displayType !== 'HIDDEN' && produced, ownerRange,
owners,
// Defaults = first owner's variant; per-owner data in variants[owner].
stats: first.stats, rangeGrid: first.rangeGrid, dmgType: first.dmgType, attackKind: first.attackKind,
projectile: first.projectile, canHitFly: first.canHitFly,
skill: first.skill ? { skillId: first.skill.skillId, bb: first.skill.bb } : null,
deployLimit: first.stats?.deployLimit ?? 1,
count: first.count,
abnormal: TOKEN_ABNORMAL[tokenId] ? [...TOKEN_ABNORMAL[tokenId]] : [],
variants,
assets: { avatar: tokenId, spine: tokenId },
};
}
/**
* The summons of the 自选 owned picks (data/backups.json `tokens`, DATA.md §18): one summonRecord per token whose
* `variants` are keyed by the owner FORM, `<charId>@<statusKey>` (shared/diy.js diyTokenOwner), never by a chess id — a
* DIY piece is a slot, and two players may fill the same slot with different operators. A variant is the token at the
* owner's status (the owner's phase / level / skill level, as for a chess owner) for the owner's FIRST skill and no
* module; `bySkill[i]` the token skill, count and sources under each other skill, `byModule[id]` the module token
* attributes / trait / talents under each module of the form — the chess variant shape (DESIGN §16), applied with the
* pick as the owner's loadout (simdata getToken, content/tokens.js withLoadout). `count` / `sources` come from the
* owner's no-module talents and the selected skill, as buildChess's `tokenUse`.
* @param {object} units backups `units`
* @param {string[]} owned the owned-6★ picks (their forms are the DIY slot statuses)
*/
function buildDiyTokens(ctx, units, owned) {
const { charTable, battleEquip } = ctx;
const owners = new Map(); // tokenId → [{ key, variant, produced }]
const makes = (list) => list.includes('talent') || list.includes('skill');
for (const charId of owned) {
for (const [key, form] of Object.entries(units[charId]?.forms || {})) {
if (!form.tokens.length) continue;
const { phase, level, skillLevel, equipLevel } = form.status;
/** token id → its sources under `skill` (buildChess tokenUse), and the count a talent / the skill sends */
const tokenUse = (skill) => {
const skTok = skill?.overrideTokenKey || null;
const use = new Map();
const add = (id, src) => { if (!use.has(id)) use.set(id, new Set()); use.get(id).add(src); };
for (const id of form.displayTokens) add(id, 'display');
if (skTok) add(skTok, 'skill');
for (const t of form.talents) if (t.tokenKey) add(t.tokenKey, 'talent');
const count = (id) => {
const tal = form.talents.find((t) => t.tokenKey === id && typeof t.bb.cnt === 'number');
const skillCnt = skTok === id ? skill?.bb?.cnt : undefined;
return tal ? tal.bb.cnt : typeof skillCnt === 'number' ? skillCnt : null;
};
const sources = (id) => ['talent', 'skill', 'display'].filter((x) => use.get(id)?.has(x));
return { count, sources };
};
const [first, ...alts] = form.skills;
for (const tokenId of form.tokens) {
const tchar = charTable[tokenId];
const label = `token ${tokenId}@${charId}@${key}`;
const at = (o) => tokenVariant(ctx, tokenId, tchar, { phase, level, skillIndex: first.index, skillLevel, modulePhase: null, moduleTokenParts: [], label, ...o });
const u0 = tokenUse(first);
const v = { ...at({}), count: u0.count(tokenId), sources: u0.sources(tokenId) };
let produced = makes(v.sources);
if (alts.length) {
v.bySkill = {};
for (const s of alts) {
const u = tokenUse(s);
v.bySkill[s.index] = { skill: at({ skillIndex: s.index }).skill, count: u.count(tokenId), sources: u.sources(tokenId) };
produced = produced || makes(v.bySkill[s.index].sources);
}
}
if (equipLevel > 0 && form.modules?.length) {
v.byModule = {};
for (const m of form.modules) {
const ph = battleEquip[m.uniEquipId]?.phases?.find((p) => p.equipLevel === equipLevel) || null;
const tv = at({ modulePhase: ph, moduleTokenParts: splitModuleParts(ph).token });
v.byModule[m.uniEquipId] = { stats: tv.stats, immunities: tv.immunities, trait: tv.trait, talents: tv.talents };
}
}
if (!owners.has(tokenId)) owners.set(tokenId, []);
owners.get(tokenId).push({ key: `${charId}@${key}`, variant: v, produced });
}
}
}
const out = {};
for (const tokenId of [...owners.keys()].sort(naturalCmp)) {
const list = owners.get(tokenId);
out[tokenId] = summonRecord(ctx, tokenId, charTable[tokenId], Object.fromEntries(list.map((o) => [o.key, o.variant])), list.some((o) => o.produced));
}
return out;
}
/** /**
* Build data/tokens.json: summons of chess (per-owner variants), bond summons (炎佑) and band map * Build data/tokens.json: summons of chess (per-owner variants), bond summons (炎佑) and band map
* characters (band_amedic 预备干员-医疗 / Touch). `abnormal` = TOKEN_ABNORMAL (PRTS). * characters (band_amedic 预备干员-医疗 / Touch). `abnormal` = TOKEN_ABNORMAL (PRTS).
*/ */
function buildTokens(ctx, chess, tokenOwners, enemies) { function buildTokens(ctx, chess, tokenOwners, enemies) {
const { charTable, ac } = ctx; const { charTable } = ctx;
const out = {}; const out = {};
const displayType = (id) => ac.shopStateTokenDict?.[id]?.tokenDisplayType || null;
for (const tokenId of [...tokenOwners.keys()].sort(naturalCmp)) { for (const tokenId of [...tokenOwners.keys()].sort(naturalCmp)) {
const char = charTable[tokenId]; const char = charTable[tokenId];
const owners = tokenOwners.get(tokenId); const owners = tokenOwners.get(tokenId);
@@ -1417,42 +1577,9 @@ function buildTokens(ctx, chess, tokenOwners, enemies) {
} }
} }
} }
const first = variants[owners[0].chessId];
// Hand cards placed during the prep phase (`placeable`) are the MANUALLY DEPLOYABLE summons (PRTS 卫戍协议/帮助
// §战斗部署: "如果部署的干员拥有可手动部署的附属召唤物,则该召唤物会立刻加入手牌区"; user playtest #6): the shop
// state's tokenDisplayType DEFAULT — 赫默's 医疗探机 and 巫恋's 诅咒娃娃 (skill summons) as well as 浊心斯卡蒂's 海嗣,
// 伺夜's 狼群 and 缪尔赛思's 流形 (talent summons) — and a summon the shop state does not list at all: 凯瑟琳's
// 爬行号·防护单元 (talent "携带3个支援装置(最多部署2个)", deployed by hand in the base game; a friend of the user:
// placed by hand officially; confirmed by the user after playtest #6 — DESIGN §20). It is the only pool summon
// missing from shopStateTokenDict (every other one is listed, as are newer tokens such as 10040, 10042, 10043,
// 10055–10058, 10065), so no entry is read as the default display. HIDDEN tokens exist in battle only (e.g.
// 投递坐标 — PRTS: "携带技能【使命必达!】的新约能天使,不会提供所属召唤物"). Only a token its owner actually makes
// (a talent or a skill of some loadout, `sources`) is a card; which loadouts make it is per variant (`sources`,
// `bySkill[i].sources`: 赫默 / 巫恋 on S1 get none). In battle a skill's summon deploys once at the start, then
// takes its tile again each time the skill gives one (sim/content/tokens.js dockSkillSummons, shared/constants.js
// SKILL_SUMMON_START_DEPLOY).
const makes = (list) => (list || []).some((s) => s === 'talent' || s === 'skill'); const makes = (list) => (list || []).some((s) => s === 'talent' || s === 'skill');
const produced = owners.some((o) => makes(o.sources) || (o.skillAlts || []).some((a) => makes(a.sources))); const produced = owners.some((o) => makes(o.sources) || (o.skillAlts || []).some((a) => makes(a.sources)));
// `ownerRange`: the token text "只能部署在召唤者攻击范围内" (the tacticians' 援军 — 伺夜's 狼群, 缪尔赛思's 流形; PRTS 狼群 out[tokenId] = summonRecord(ctx, tokenId, char, variants, produced);
// 特性): its hand piece may only be placed on a tile of its owner's attack range (server/match/board.js
// ownerRangeKeys, PlayerState._legal; player report #9 after 0.1.0: 伺夜's tactical point could go anywhere)
const ownerRange = /只能部署在\S*攻击范围内/.test(stripRich(first.trait.desc) || '');
out[tokenId] = {
tokenId, kind: 'summon', name: char.name, appellation: char.appellation || null,
desc: stripRich(first.trait.desc), descRaw: first.trait.descRaw,
profession: char.profession, subProfessionId: char.subProfessionId, position: char.position,
displayType: displayType(tokenId), placeable: displayType(tokenId) !== 'HIDDEN' && produced, ownerRange,
owners: owners.map((o) => o.chessId),
// Defaults = first owner's variant; per-owner data in variants[chessId].
stats: first.stats, rangeGrid: first.rangeGrid, dmgType: first.dmgType, attackKind: first.attackKind,
projectile: first.projectile, canHitFly: first.canHitFly,
skill: first.skill ? { skillId: first.skill.skillId, bb: first.skill.bb } : null,
deployLimit: first.stats?.deployLimit ?? 1,
count: first.count,
abnormal: TOKEN_ABNORMAL[tokenId] ? [...TOKEN_ABNORMAL[tokenId]] : [],
variants,
assets: { avatar: tokenId, spine: tokenId },
};
} }
// 炎佑 (yanShip 6-member summon) — allied flying unit built from its enemy template. // 炎佑 (yanShip 6-member summon) — allied flying unit built from its enemy template.
@@ -3495,9 +3622,32 @@ function validateAll(f) {
const slot = chess[id], golden = chess[s.goldenId]; const slot = chess[id], golden = chess[s.goldenId];
if (!slot?.isDiy || slot.isGolden || slot.tier !== s.tier || !golden?.isDiy) { err(`DIY slot ${id}: not a DIY chess pair of tier ${s.tier}`); continue; } if (!slot?.isDiy || slot.isGolden || slot.tier !== s.tier || !golden?.isDiy) { err(`DIY slot ${id}: not a DIY chess pair of tier ${s.tier}`); continue; }
if (!backups.diy.prototypes[s.tier]?.length) err(`DIY slot ${id}: no prototype picks`); if (!backups.diy.prototypes[s.tier]?.length) err(`DIY slot ${id}: no prototype picks`);
for (const p of backups.diy.prototypes[s.tier] || []) for (const rec of [slot, golden]) if (!unitForm(backups, p, rec.status)) err(`DIY slot ${rec.chessId}: prototype ${p} has no form ${statusKey(rec.status)}`); for (const p of backups.diy.prototypes[s.tier] || []) {
for (const rec of [slot, golden]) if (!unitForm(backups, p, rec.status)) err(`DIY slot ${rec.chessId}: prototype ${p} has no form ${statusKey(rec.status)}`);
// the locked selection exists at both slot forms (the module at the elite's stage)
const lk = backups.diy.locked?.[s.tier]?.[p];
if (!lk) { err(`DIY slot ${id}: prototype ${p} has no locked selection`); continue; }
for (const rec of [slot, golden]) {
const f = unitForm(backups, p, rec.status);
if (f && !f.skills.some((x) => x.index === lk.skillIndex)) err(`DIY slot ${rec.chessId}: prototype ${p} has no skill ${lk.skillIndex} at ${statusKey(rec.status)}`);
if (f && lk.uniEquipId && rec.status.equipLevel > 0 && !f.modules.some((m) => m.uniEquipId === lk.uniEquipId)) err(`DIY slot ${rec.chessId}: prototype ${p} has no module ${lk.uniEquipId} at ${statusKey(rec.status)}`);
}
}
// every owned pick: a form at both slot statuses with every skill, and at the elite every module of the character
for (const p of backups.diy.ownedPool) {
const u = backups.units[p];
for (const rec of [slot, golden]) {
const f = unitForm(backups, p, rec.status);
if (!f) { err(`DIY slot ${rec.chessId}: owned pick ${p} has no form ${statusKey(rec.status)}`); continue; }
if (f.skills.length !== 3) err(`DIY slot ${rec.chessId}: owned pick ${p} has ${f.skills.length} skills at ${statusKey(rec.status)}`);
const ids = Object.keys(u.moduleNames).filter((m) => u.moduleNames[m].typeName !== 'ORIGINAL');
if (rec.status.equipLevel > 0 && JSON.stringify(f.modules.map((m) => m.uniEquipId)) !== JSON.stringify(ids)) err(`DIY slot ${rec.chessId}: owned pick ${p} modules ${f.modules.map((m) => m.uniEquipId)} ≠ ${ids}`);
for (const t of f.tokens) if (!backups.tokens?.[t]?.variants?.[`${p}@${statusKey(rec.status)}`]) err(`DIY pick ${p}@${statusKey(rec.status)}: summon ${t} has no variant in backups.json tokens`);
}
}
} }
for (const [id, o] of Object.entries(backups.diy.operators)) for (const b of o.bonds) if (!bonds[b]) err(`DIY pick ${id}: bond ${b} missing`); for (const [id, o] of Object.entries(backups.diy.operators)) for (const b of o.bonds) if (!bonds[b]) err(`DIY pick ${id}: bond ${b} missing`);
for (const id of Object.keys(backups.tokens || {})) if (tokens[id]) err(`backups.json token ${id} is also a tokens.json record`);
// the 重装 stand-ins' deviations (STANDIN_TRIGGER_DEVIATIONS) still override an official TAKE_DAMAGE row, on every form // the 重装 stand-ins' deviations (STANDIN_TRIGGER_DEVIATIONS) still override an official TAKE_DAMAGE row, on every form
for (const [charId, skillsOf] of Object.entries(STANDIN_TRIGGER_DEVIATIONS)) { for (const [charId, skillsOf] of Object.entries(STANDIN_TRIGGER_DEVIATIONS)) {
const forms = Object.values(backups.units[charId]?.forms || {}); const forms = Object.values(backups.units[charId]?.forms || {});
@@ -3640,6 +3790,8 @@ async function main() {
tokens: Object.keys(tokens).length, factionEntries: Object.keys(factions.entries).length, tokens: Object.keys(tokens).length, factionEntries: Object.keys(factions.entries).length,
backupUnits: Object.keys(backups.units).length, backupUnits: Object.keys(backups.units).length,
backupForms: Object.values(backups.units).reduce((n, u) => n + Object.keys(u.forms).length, 0), backupForms: Object.values(backups.units).reduce((n, u) => n + Object.keys(u.forms).length, 0),
backupTokens: Object.keys(backups.tokens).length,
diyOwnedPicks: backups.diy.ownedPool.length,
}, },
sizes, totalBytes: total, out: OPTS.out, written: write, warnings, errors, sizes, totalBytes: total, out: OPTS.out, written: write, warnings, errors,
}; };
+61 -15
View File
@@ -24,9 +24,21 @@
// the entries it would drop and exits 1; --allow-shrink (or --prune) writes the // the entries it would drop and exits 1; --allow-shrink (or --prune) writes the
// smaller manifest. // smaller manifest.
// //
// Beyond research 07's ids the plan covers what data/*.json adds: spawnable
// enemies / tokens (data/enemies.json, data/tokens.json), the 自选 owned-6★ picks
// of data/backups.json `units` (art from 07's URL patterns) and their summons
// (`tokens`), and the module type icons of every module in data/chess.json /
// data/backups.json (manifest `modules`).
//
// --add-only: for a checkout whose public/assets / public/fonts are shared with
// another one (a git worktree): download only the files missing on disk, never
// re-download, rewrite or delete an existing file (atlases already on disk are
// left as they are; fonts are not rebuilt — the manifest keeps its current
// `fonts`).
//
// Usage: node tools/fetch-assets.mjs [--concurrency=16] [--force] [--offline] // Usage: node tools/fetch-assets.mjs [--concurrency=16] [--force] [--offline]
// [--dry-run] [--refresh-index] [--prune] // [--dry-run] [--refresh-index] [--prune]
// [--allow-shrink] [--local-spines] [--help] // [--allow-shrink] [--add-only] [--local-spines] [--help]
import { readFile, writeFile, mkdir, rename, readdir, unlink } from 'node:fs/promises'; import { readFile, writeFile, mkdir, rename, readdir, unlink } from 'node:fs/promises';
import { existsSync, realpathSync } from 'node:fs'; import { existsSync, realpathSync } from 'node:fs';
@@ -59,6 +71,8 @@ const HELP = `Usage: node tools/fetch-assets.mjs [options]
(public/assets/local/** of tools/local-extract is never deleted); implies --allow-shrink (public/assets/local/** of tools/local-extract is never deleted); implies --allow-shrink
--allow-shrink write data/assets.json even when it loses entries the current one has --allow-shrink write data/assets.json even when it loses entries the current one has
(without it such a run keeps the current manifest, lists the entries and exits 1) (without it such a run keeps the current manifest, lists the entries and exits 1)
--add-only download only files missing on disk; never re-download, rewrite or delete an existing
file, no font rebuild (a worktree sharing public/assets and public/fonts)
--local-spines rewrite ${LOCAL_ENEMY_SPINES_FILE} from the enemy models extracted --local-spines rewrite ${LOCAL_ENEMY_SPINES_FILE} from the enemy models extracted
by tools/local-extract/extract.py (public/assets/local/spine/enemy/) by tools/local-extract/extract.py (public/assets/local/spine/enemy/)
--help this text`; --help this text`;
@@ -66,10 +80,10 @@ const HELP = `Usage: node tools/fetch-assets.mjs [options]
/** /**
* Parse CLI flags. * Parse CLI flags.
* @param {string[]} argv * @param {string[]} argv
* @returns {{concurrency:number, force:boolean, offline:boolean, dryRun:boolean, refreshIndex:boolean, prune:boolean, allowShrink:boolean, localSpines:boolean, help:boolean}} * @returns {{concurrency:number, force:boolean, offline:boolean, dryRun:boolean, refreshIndex:boolean, prune:boolean, allowShrink:boolean, addOnly:boolean, localSpines:boolean, help:boolean}}
*/ */
export function parseArgs(argv) { export function parseArgs(argv) {
const o = { concurrency: 16, force: false, offline: false, dryRun: false, refreshIndex: false, prune: false, allowShrink: false, localSpines: false, help: false }; const o = { concurrency: 16, force: false, offline: false, dryRun: false, refreshIndex: false, prune: false, allowShrink: false, addOnly: false, localSpines: false, help: false };
for (const a of argv) { for (const a of argv) {
const [k, v] = a.split('='); const [k, v] = a.split('=');
if (k === '--concurrency') o.concurrency = Math.max(1, Math.min(64, parseInt(v, 10) || 16)); if (k === '--concurrency') o.concurrency = Math.max(1, Math.min(64, parseInt(v, 10) || 16));
@@ -79,13 +93,38 @@ export function parseArgs(argv) {
else if (k === '--refresh-index') o.refreshIndex = true; else if (k === '--refresh-index') o.refreshIndex = true;
else if (k === '--prune') o.prune = true; else if (k === '--prune') o.prune = true;
else if (k === '--allow-shrink') o.allowShrink = true; else if (k === '--allow-shrink') o.allowShrink = true;
else if (k === '--add-only') o.addOnly = true;
else if (k === '--local-spines') o.localSpines = true; else if (k === '--local-spines') o.localSpines = true;
else if (k === '--help' || k === '-h') o.help = true; else if (k === '--help' || k === '-h') o.help = true;
else throw new Error(`unknown option ${a}\n${HELP}`); else throw new Error(`unknown option ${a}\n${HELP}`);
} }
if (o.addOnly && (o.prune || o.force)) throw new Error(`--add-only never deletes or rewrites files: not with --prune / --force\n${HELP}`);
return o; return o;
} }
/**
* What data/backups.json and data/chess.json add to the asset plan: `extraOperators` — every unit of backups.json
* (`{ name, subProfessionId, nationId, skills: [{ index, skillId, iconId }] }`; buildPlan plans those research 07 lacks,
* the 自选 owned-6★ picks), `tokenIds` — the summons of the 自选 picks (backups.json `tokens`), `moduleTypes` — the type
* icon of every module a chess or a unit form offers.
* @param {any} backups data/backups.json (or null)
* @param {any} chess data/chess.json (or null)
*/
export function dataExtras(backups, chess) {
const extraOperators = {};
const moduleTypes = new Set();
for (const [id, u] of Object.entries(backups?.units || {})) {
const skills = new Map();
for (const f of Object.values(u.forms || {})) {
for (const sk of f.skills || []) if (!skills.has(sk.index)) skills.set(sk.index, { index: sk.index, skillId: sk.skillId, iconId: sk.iconId || sk.skillId });
for (const m of f.modules || []) if (m.typeIcon) moduleTypes.add(m.typeIcon);
}
extraOperators[id] = { name: u.name, subProfessionId: u.subProfessionId, nationId: u.nationId, skills: [...skills.values()].sort((a, b) => a.index - b.index) };
}
for (const c of Object.values(chess || {})) for (const m of c?.modules || []) if (m.typeIcon) moduleTypes.add(m.typeIcon);
return { extraOperators, tokenIds: Object.keys(backups?.tokens || {}).sort(), moduleTypes: [...moduleTypes].sort() };
}
/** /**
* The shrink guard of data/assets.json (header): the entries of the current manifest `prev` that `next` would drop, * The shrink guard of data/assets.json (header): the entries of the current manifest `prev` that `next` would drop,
* and whether `next` may be written — always when nothing is dropped (or there is no current manifest), otherwise * and whether `next` may be written — always when nothing is dropped (or there is no current manifest), otherwise
@@ -156,6 +195,7 @@ function countStats(m, bytes, files) {
items: Object.keys(m.items || {}).length, items: Object.keys(m.items || {}).length,
bands: Object.keys(m.bands || {}).length, bands: Object.keys(m.bands || {}).length,
skills: Object.keys(m.skills || {}).length, skills: Object.keys(m.skills || {}).length,
modules: Object.keys(m.modules || {}).length,
ui: Object.keys(m.ui || {}).length, ui: Object.keys(m.ui || {}).length,
sfxUnits: Object.keys(m.audio?.sfx?.units || {}).length, sfxUnits: Object.keys(m.audio?.sfx?.units || {}).length,
}; };
@@ -222,17 +262,20 @@ async function main() {
const audio = indexAudio(audioData); const audio = indexAudio(audioData);
// The game data built by tools/build-data.mjs (when present) may reference more // The game data built by tools/build-data.mjs (when present) may reference more
// spawnable enemies/tokens than research lists (e.g. 机变 enemy swaps): cover them too. // spawnable enemies/tokens than research lists (e.g. 机变 enemy swaps): cover them too.
const [dataEnemies, dataTokens, dataBosses] = await Promise.all( const [dataEnemies, dataTokens, dataBosses, dataBackups, dataChess] = await Promise.all(
['data/enemies.json', 'data/tokens.json', 'data/bosses.json'].map((f) => readJson(f).catch(() => null))); ['data/enemies.json', 'data/tokens.json', 'data/bosses.json', 'data/backups.json', 'data/chess.json'].map((f) => readJson(f).catch(() => null)));
const extras = dataExtras(dataBackups, dataChess);
const extraHandbook = {}; const extraHandbook = {};
for (const b of Object.values(dataBosses || {})) if (b?.enemyKey && typeof b.handbookId === 'string') extraHandbook[b.enemyKey] = b.handbookId; for (const b of Object.values(dataBosses || {})) if (b?.enemyKey && typeof b.handbookId === 'string') extraHandbook[b.enemyKey] = b.handbookId;
const localEnemySpines = await syncLocalEnemySpines(opts); const localEnemySpines = await syncLocalEnemySpines(opts);
const plan = buildPlan({ const plan = buildPlan({
assets07, ops03, enemies05, maps05, audio, modelsData, assets07, ops03, enemies05, maps05, audio, modelsData,
extraEnemyIds: Object.keys(dataEnemies || {}), extraEnemyIds: Object.keys(dataEnemies || {}),
extraTokenIds: Object.keys(dataTokens || {}), extraTokenIds: [...Object.keys(dataTokens || {}), ...extras.tokenIds],
extraHandbook, extraHandbook,
localEnemySpines, localEnemySpines,
extraOperators: extras.extraOperators,
moduleTypes: extras.moduleTypes,
}); });
const leaves = collectLeaves(plan.template); const leaves = collectLeaves(plan.template);
log(`[plan] ${leaves.length} files + ${plan.models.size} Spine models ` + log(`[plan] ${leaves.length} files + ${plan.models.size} Spine models ` +
@@ -246,26 +289,32 @@ async function main() {
const dl = new Downloader({ const dl = new Downloader({
root: ASSETS, ledgerPath: join(CACHE, 'assets-ledger.json'), root: ASSETS, ledgerPath: join(CACHE, 'assets-ledger.json'),
concurrency: opts.concurrency, force: opts.force, log, concurrency: opts.concurrency, force: opts.force, keepExisting: opts.addOnly, log,
}); });
await dl.loadLedger(); await dl.loadLedger();
const downloadErrors = opts.offline ? [] : await downloadLeaves(leaves, dl, ASSETS, 'files'); const downloadErrors = opts.offline ? [] : await downloadLeaves(leaves, dl, ASSETS, 'files');
// Fonts let current = null;
if (existsSync(MANIFEST)) {
try { current = JSON.parse(await readFile(MANIFEST, 'utf8')); } catch (e) { log(`[manifest] the current ${relative(ROOT, MANIFEST)} is unreadable (${e.message}): replaced`); }
}
// Fonts (--add-only: not rebuilt — public/fonts may be another checkout's; the manifest keeps its current entry)
let fontErrors = []; let fontErrors = [];
if (!opts.offline) { if (!opts.offline && !opts.addOnly) {
const fdl = new Downloader({ root: FONTS, ledgerPath: join(CACHE, 'fonts-ledger.json'), concurrency: 4, force: opts.force, log }); const fdl = new Downloader({ root: FONTS, ledgerPath: join(CACHE, 'fonts-ledger.json'), concurrency: 4, force: opts.force, log });
await fdl.loadLedger(); await fdl.loadLedger();
await fdl.run(fontJobs(), 'fonts'); await fdl.run(fontJobs(), 'fonts');
dl.totals.bytesDownloaded += fdl.totals.bytesDownloaded; dl.totals.bytesDownloaded += fdl.totals.bytesDownloaded;
for (const k of ['ok', 'skip', 'miss', 'error']) dl.totals[k] += fdl.totals[k]; for (const k of ['ok', 'skip', 'miss', 'error']) dl.totals[k] += fdl.totals[k];
} }
const fonts = await buildFonts(FONTS, log); const fonts = opts.addOnly ? { files: {}, errors: [] } : await buildFonts(FONTS, log);
fontErrors = fonts.errors; fontErrors = fonts.errors;
// Spine // Spine
const spine = await processModels(plan.models, { const spine = await processModels(plan.models, {
root: ASSETS, dl, cachePath: join(CACHE, 'spine-info.json'), download: !opts.offline, log, root: ASSETS, dl, cachePath: join(CACHE, 'spine-info.json'), download: !opts.offline, log,
...(opts.addOnly ? { writable: (rel) => dl.written.has(rel) } : null),
}); });
// Manifest // Manifest
@@ -274,7 +323,8 @@ async function main() {
tidyManifest(body); tidyManifest(body);
const fontFaces = {}; const fontFaces = {};
for (const [name, f] of Object.entries(fonts.files)) fontFaces[name] = f; for (const [name, f] of Object.entries(fonts.files)) fontFaces[name] = f;
body.fonts = existsSync(join(FONTS, 'fonts.css')) ? { css: '/fonts/fonts.css', faces: fontFaces } : { faces: fontFaces }; body.fonts = opts.addOnly && current?.fonts ? current.fonts
: existsSync(join(FONTS, 'fonts.css')) ? { css: '/fonts/fonts.css', faces: fontFaces } : { faces: fontFaces };
const bytes = totalBytes(ASSETS, resolved.files); const bytes = totalBytes(ASSETS, resolved.files);
const manifest = { const manifest = {
version: MANIFEST_VERSION, version: MANIFEST_VERSION,
@@ -283,10 +333,6 @@ async function main() {
stats: countStats(body, bytes, resolved.files.size), stats: countStats(body, bytes, resolved.files.size),
...body, ...body,
}; };
let current = null;
if (existsSync(MANIFEST)) {
try { current = JSON.parse(await readFile(MANIFEST, 'utf8')); } catch (e) { log(`[manifest] the current ${relative(ROOT, MANIFEST)} is unreadable (${e.message}): replaced`); }
}
const guard = shrinkGuard(current, manifest, opts); const guard = shrinkGuard(current, manifest, opts);
if (guard.write) await writeJsonAtomic(MANIFEST, manifest); if (guard.write) await writeJsonAtomic(MANIFEST, manifest);
+131 -2
View File
@@ -33,6 +33,10 @@
// standIn: true — DATA.md §18: the stand-in's body, its backup skill / module, its kit by charId; all 17 // standIn: true — DATA.md §18: the stand-in's body, its backup skill / module, its kit by charId; all 17
// stand-ins and every skill a chess names for them), 12 per battle by strength band, laid out by the stand-in's // stand-ins and 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, an item each, bonds from the board // position on a real stage, against the round's real wave three times over, an item each, bonds from the board
// diy 自选 pieces (PlayerBattleInput `diy`, 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 of its skills, then every prototype pick with a kit at its locked selection, both forms of each tier;
// 12 pieces per battle on a real stage against the round's real wave three times over (diyScenarios)
// Battles run through the production BattleSpec path (server/sim/spec.js buildBattleSpec → createBattleFromSpec, the // Battles run through the production BattleSpec path (server/sim/spec.js buildBattleSpec → createBattleFromSpec, the
// path browsers and the server's headless fields use) with every option explicit; matches construct Match directly // path browsers and the server's headless fields use) with every option explicit; matches construct Match directly
// with a VirtualScheduler (as tools/botbench.mjs) — test-harness defaults never move a digest. // with a VirtualScheduler (as tools/botbench.mjs) — test-harness defaults never move a digest.
@@ -69,11 +73,14 @@ import { computeBonds, bondSnapshot } from '../server/match/bondsMeta.js';
import { Match } from '../server/match/Match.js'; import { Match } from '../server/match/Match.js';
import { VirtualScheduler } from '../server/match/scheduler.js'; import { VirtualScheduler } from '../server/match/scheduler.js';
import { resolveRecordLoadout, loadoutRecord, attackRangeGrid } from '../shared/loadoutRecord.js'; import { resolveRecordLoadout, loadoutRecord, attackRangeGrid } from '../shared/loadoutRecord.js';
import { diyRecordOf, DIY_TIERS } from '../shared/diy.js';
import { unitForm } from '../shared/standIn.js';
import { OPERATOR_KITS, KITTED_CHARS } from '../server/sim/content/kits/index.js';
import { GEO } from '../shared/constants.js'; import { GEO } from '../shared/constants.js';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
export const GOLDEN_DIR = join(ROOT, 'test', 'golden'); export const GOLDEN_DIR = join(ROOT, 'test', 'golden');
export const FAMILY_NAMES = Object.freeze(['roster', 'bonds', 'fields', 'matches', 'standins']); export const FAMILY_NAMES = Object.freeze(['roster', 'bonds', 'fields', 'matches', 'standins', 'diy']);
const QUIET = Object.freeze({ warn() {}, error() {}, info() {}, log() {}, debug() {} }); const QUIET = Object.freeze({ warn() {}, error() {}, info() {}, log() {}, debug() {} });
const data = getData({ log: QUIET }); const data = getData({ log: QUIET });
@@ -815,16 +822,138 @@ export function runMatch(cfg) {
return digest; return digest;
} }
// ---------------------------------------------------------------------------------------------------------------
// family: diy (自选 pieces: a DIY slot fielded with its `diy` pick — simdata getDiy, shared/diy.js)
const DIY_SLOTS = data.backups?.diy?.slots ?? {};
/** The slot record of a tier's first slot, normal or elite (the forms every slot of the tier shares). */
const diySlotRec = (tier, elite) => {
const base = Object.keys(DIY_SLOTS).find((id) => DIY_SLOTS[id].tier === tier);
return base ? data.chess[elite ? DIY_SLOTS[base].goldenId : base] : null;
};
/**
* The 自选 configurations of the corpus, in a fixed order: every owned 6★ with an operator kit (OPERATOR_KITS, file
* order) in each form of both tiers — normal (no module: none is active), elite with no module and with each module of
* the form — under each skill; then every prototype pick with a kit (KITTED_CHARS: its stand-in kit, a 预备干员's generic
* kit) at its locked selection, normal and elite, per tier. `{ tier, elite, pick, group }`.
*/
function diyConfigs() {
const diy = data.backups?.diy;
if (!diy) return [];
const out = [];
for (const charId of Object.keys(OPERATOR_KITS)) {
if (!diy.ownedPool.includes(charId)) continue;
for (const tier of DIY_TIERS) {
for (const elite of [false, true]) {
const form = unitForm(data.backups, charId, diySlotRec(tier, elite)?.status);
if (!form) continue;
for (const uniEquipId of elite ? [null, ...(form.modules ?? []).map((m) => m.uniEquipId)] : [null]) {
for (const s of form.skills) out.push({ tier, elite, pick: { charId, skillIndex: s.index, uniEquipId }, group: 'operator' });
}
}
}
}
for (const tier of DIY_TIERS) {
for (const charId of diy.prototypes?.[tier] ?? []) {
if (!KITTED_CHARS.includes(charId)) continue;
for (const elite of [false, true]) out.push({ tier, elite, pick: { charId }, group: 'prototype' });
}
}
return out;
}
/**
* Lay 自选 pieces on a player's board like `layout` (melee — most blockers first — on the deployable tiles the enemy
* ground paths cross most, ranged ones beside the paths, every 7th one facing UP / LEFT / DOWN), their class read from
* the composed record (`w.rec`, shared/diy.js diyRecordOf). No summons are placed (the 自选 hand pieces come with the
* per-player shop).
*/
function diyLayout(stageId, wanted) {
const map = buildDeployMap(data.stages[stageId], { field: 'normal' });
const traffic = pathTraffic(stageId, 'normal', 0);
const pathList = [...traffic.keys()].map((k) => k.split(',').map(Number));
const dist = (r, c) => (pathList.length ? Math.min(...pathList.map(([pr, pc]) => Math.max(Math.abs(pr - r), Math.abs(pc - c)))) : 9);
const used = new Set();
const tiles = (cls) => {
const out = [];
for (let r = 12; r >= 9; r--) for (let c = 2; c <= 10; c++) if (!used.has(tileKey(r, c)) && canPlace(map, cls, r, c)) out.push([r, c]);
if (cls === 'melee') return out.sort((a, b) => (traffic.get(tileKey(b[0], b[1])) || 0) - (traffic.get(tileKey(a[0], a[1])) || 0));
const rank = (r, c) => { const d = dist(r, c); return d === 0 ? 1.5 : d; };
return out.sort((a, b) => rank(a[0], a[1]) - rank(b[0], b[1]));
};
const cls = (w) => positionClass(w.rec, w.diy.uniEquipId ?? null);
const order = wanted.slice().sort((a, b) => ((cls(b) === 'melee') - (cls(a) === 'melee')) || (cls(a) === 'melee' ? (b.rec.stats.blockCnt || 0) - (a.rec.stats.blockCnt || 0) : 0));
const units = [];
let uid = 1;
for (const w of order) {
const free = tiles(cls(w));
if (!free.length) continue;
const [r, c] = free[0];
used.add(tileKey(r, c));
const dir = units.length % 7 === 6 ? ['UP', 'LEFT', 'DOWN'][Math.floor(units.length / 7) % 3] : 'RIGHT';
units.push({ uid: uid++, kind: 'chess', chessId: w.chessId, diy: { ...w.diy }, row: r, col: c, dir, items: [] });
}
return units;
}
/**
* The diy family: the configurations (diyConfigs) 12 per battle, each on the next of its tier's two slots (the elite
* twin for an elite form); a real stage and mode in turn, the round of the strongest piece (BAND_ROUND), the round's
* real wave three times over, no bonds / items / band (the 自选 bonds come with the per-player roster). `pass` 0 = the
* first battle of each group (operator kits, prototypes): the fast subset.
*/
export function diyScenarios() {
const configs = diyConfigs();
const scenarios = [];
const firstOf = new Set();
for (let i = 0, n = 0; i < configs.length; i += 12, n++) {
const chunk = configs.slice(i, i + 12);
const stageId = STAGES[n % STAGES.length];
const modeId = ROSTER_MODES[n % ROSTER_MODES.length];
const gd = gdFor(modeId);
const turn = {};
const wanted = chunk.map((c) => {
const ids = Object.keys(DIY_SLOTS).filter((id) => DIY_SLOTS[id].tier === c.tier);
turn[c.tier] = (turn[c.tier] ?? -1) + 1;
const base = ids[turn[c.tier] % ids.length];
const chessId = c.elite ? DIY_SLOTS[base].goldenId : base;
const rec = diyRecordOf(data.chess[chessId], c.pick, data);
if (!rec) throw new Error(`diy: ${c.pick.charId} is no legal pick of ${chessId}`);
return { chessId, diy: c.pick, rec, band: c.tier + (c.elite ? 1 : 0) };
});
const units = diyLayout(stageId, wanted);
if (units.length !== wanted.length) throw new Error(`diy: ${wanted.length - units.length} pieces do not fit on ${stageId}`);
const round = BAND_ROUND[Math.max(...wanted.map((w) => w.band))];
const seed = deriveSeed(20261005, `diy:${n}`);
const wave = normalWave(gd, round, seed);
const pid = 'p1';
const groups = [...new Set(chunk.map((c) => c.group))];
const pass = groups.some((g) => !firstOf.has(g)) ? 0 : 1;
for (const g of groups) firstOf.add(g);
scenarios.push({
id: `diy-${String(n + 1).padStart(3, '0')}`, family: 'diy', kind: 'normal', modeId, round, stageId, seed, pass,
rect: { ...GEO.NORMAL_RECT }, timeLimit: wave.timeLimit, routes: wave.routes, waveId: wave.templateId, enemyOverrides: wave.overrides,
flags: { layerGainsEnabled: true, ...gd.dp },
players: [playerInput(pid, 0, units)],
spawns: [0, 1, 2].flatMap((k) => wave.spawns.map((s) => ({ ...s, time: s.time + k * Math.round(wave.timeLimit / 4), ownerPlayerId: pid }))),
about: `${units.map((u) => `${u.chessId.replace(/^chess_char_/, '')}=${u.diy.charId.replace(/^char_\d+_/, '')}${u.diy.skillIndex != null ? `/S${u.diy.skillIndex + 1}` : ''}${u.diy.uniEquipId ? `/${u.diy.uniEquipId.replace(/^uniequip_/, '')}` : ''}`).join(' ')}`,
});
}
return scenarios;
}
// --------------------------------------------------------------------------------------------------------------- // ---------------------------------------------------------------------------------------------------------------
// families, fast subset, comparison // families, fast subset, comparison
const GENERATORS = { roster: rosterScenarios, bonds: bondScenarios, fields: fieldScenarios, matches: matchScenarios, standins: standInScenarios }; const GENERATORS = { roster: rosterScenarios, bonds: bondScenarios, fields: fieldScenarios, matches: matchScenarios, standins: standInScenarios, diy: diyScenarios };
const ABOUT = { const ABOUT = {
roster: 'every visible chess record × every selectable skill / module, every stage, every non-leader enemy kind, items, bands, 机变 cards, map cards, placeable summons', roster: 'every visible chess record × every selectable skill / module, every stage, every non-leader enemy kind, items, bands, 机变 cards, map cards, placeable summons',
bonds: 'every bond at its activation threshold (layers 1) and at its top tier (layers 999)', bonds: 'every bond at its activation threshold (layers 1) and at its top tier (layers 999)',
fields: 'Final Assault / Hidden Core leaders (pair + solo templates, shared pool, 200 s cap) and 联防 fields (1 / 2 helpers)', fields: 'Final Assault / Hidden Core leaders (pair + solo templates, shared pool, 200 s cap) and 联防 fields (1 / 2 helpers)',
matches: 'matches run to the end in virtual time: bot-only (solo ×4 difficulties ×2 seeds, co-op 2/3/4, one server-run, two boosted to the Hidden Core) and one co-op match whose human seat fields 补位 stand-ins', matches: 'matches run to the end in virtual time: bot-only (solo ×4 difficulties ×2 seeds, co-op 2/3/4, one server-run, two boosted to the Hidden Core) and one co-op match whose human seat fields 补位 stand-ins',
standins: 'every NORMAL chess record (normal + elite) fielded as its 补位 stand-in (all 17 stand-ins and the skills the chess name for them), 12 per battle on real stages and waves', standins: 'every NORMAL chess record (normal + elite) fielded as its 补位 stand-in (all 17 stand-ins and the skills the chess name for them), 12 per battle on real stages and waves',
diy: '自选 pieces: every kitted owned 6★ × form (tiers 5 / 6, normal / elite × module) × skill, every kitted prototype at its locked selection',
}; };
/** /**
* The default test subset (GOLDEN_FULL=1 runs everything): the pass-0 roster battles (every chess record with its * The default test subset (GOLDEN_FULL=1 runs everything): the pass-0 roster battles (every chess record with its