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:
commit
1006254beb
32 files changed
+2229
-174
No files matched your search
+1
-1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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`);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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`;
|
||||||
|
|||||||
@@ -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)])]);
|
||||||
@@ -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 });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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
@@ -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)', () => {
|
||||||
|
|||||||
@@ -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, '甄选干员');
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,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
@@ -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 };
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user