feedback7: merge fb7-loadout — per-operator 潜能 (1–6) and 练度 (未精英化 / 精英1 / 精英2 / 精英2 Lv.60) set in 干员调配, defaults 潜能 6 and 精英2 Lv.60 (the owner's decision of 2026-10-08); PR #301 ported as one aligned list; #398 the skill frame, #400 REA-Y's heal text
- Conflicts (the branch started at ae22335c): the four i18n packs (union of keys), game.js DetailPanel props (ops + the
voices' voice=true), detailPanel.js resolveDetail (cultOpts + the voices' tap), match/views.js (both helpers),
docs/PLAYING.md and design/network.md merged line by line against the base. test/match/bosshp.test.js's ATK formula
now includes the unit's 练度 multiplier (cultMul).
- Grok review: MERGE AFTER FIXES — the note and the module card at the set potential fixed on the branch.
- Golden: every owned operator ×1.1 ATK / DEF / max HP by default — roster 49, bonds 46, fields 22, matches 18, diy 134
(stand-in battles and prototype picks unchanged). Broad node --test 5666 (0 fail after the bosshp formula; 2 skipped);
lint 0 errors; typecheck; i18n strict clean (1152 msgids).
This commit is contained in:
commit
0ced21d321
82 files changed
+9156
-6840
No files matched your search
+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
|
||||||
+1
-1
@@ -1 +1 @@
|
|||||||
{"chess_char_1_01_a":{"chessId":"chess_char_1_01_a","baseId":"chess_char_1_01_a","goldenId":"chess_char_1_01_b","isGolden":false,"tier":1,"identifier":0,"isHidden":false,"isDiy":false,"visible":true,"chessType":"PRESET","backup":{"charId":"char_498_inside","tmplId":null,"skillIndex":1,"uniEquipId":"uniequip_002_inside","potRank":0},"shopSortId":1,"charId":"char_498_inside","name":"隐现","appellation":"Insider","rarity":5,"profession":"SNIPER","subProfessionId":"fastshot","subProfessionName":"速射手","position":"RANGED","nationId":"laterano","bonds":["lateranoShip","swiftShip"],"garrisonIds":["garrison_16_a"],"price":2,"sellPrice":1,"upgradeNum":3,"upgradeChessId":"chess_char_1_01_b","status":{"phase":1,"level":55,"skillLevel":4,"equipLevel":0},"stats":{"maxHp":1123,"atk":422,"def":140,"res":0,"cost":12,"blockCnt":1,"bat":1,"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":"3-3","rangeGrid":[[1,0],[1,1],[1,2],[1,3],[0,0],[0,1],[0,2],[0,3],[-1,0],[-1,1],[-1,2],[-1,3]],"dmgType":"phys","attackKind":"ranged","projectile":"arrow","canHitFly":true,"targetPriority":"fly","trait":{"desc":"优先攻击空中单位","descRaw":"优先攻击空中单位","bb":{},"bbStr":{},"rangeGrid":null},"skill":{"skillId":"skchr_inside_2","iconId":"skchr_inside_2","name":"“解决麻烦”","level":4,"desc":"攻击力+80%,攻击间隔略微缩短,优先攻击使用远程武器的敌人并且不容易成为敌人的攻击目标\n攻击装有14发弹药,打完后结束(可随时停止技能)","descRaw":"攻击力<@ba.vup>+80%</>,攻击间隔<@ba.vup>略微缩短</>,优先攻击使用远程武器的敌人并且不容易成为敌人的攻击目标\n<@ba.rem>攻击装有14发弹药,打完后结束(可随时停止技能)</>","skillType":"MANUAL","durationType":"AMMO","duration":0,"spType":"INCREASE_WITH_TIME","spCost":24,"initSp":8,"maxChargeTime":1,"increment":1,"bb":{"atk":0.8,"base_attack_time":-0.3,"attack@trigger_time":14},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skchr_inside_2","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":1,"overrideTokenKey":null},"talents":[{"index":0,"name":"火力支援","desc":"在场停留20秒后,自身弹药类技能弹药上限+2","descRaw":"在场停留20秒后,自身弹药类技能弹药上限+2","bb":{"self_ammo":2,"duration":20},"bbStr":{},"rangeGrid":null,"tokenKey":null,"hidden":false,"fromModule":false}],"tokens":[],"module":{"id":"uniequip_002_inside","name":"“最初的惊喜”","type":"MAR-X","level":0,"active":false},"assets":{"avatar":"char_498_inside","portrait":"char_498_inside_1","spine":"char_498_inside","skillIcon":"skchr_inside_2","subProfIcon":"sub_fastshot_icon"},"skills":[{"skillId":"skchr_inside_1","iconId":"skchr_inside_1","name":"“不惹麻烦”","level":4,"desc":"每次攻击造成相当于攻击力185%的物理伤害\n攻击装有4发弹药,打完后结束(可随时停止技能)","descRaw":"每次攻击造成相当于攻击力<@ba.vup>185%</>的物理伤害\n<@ba.rem>攻击装有4发弹药,打完后结束(可随时停止技能)</>","skillType":"AUTO","durationType":"AMMO","duration":0,"spType":"INCREASE_WHEN_ATTACK","spCost":14,"initSp":0,"maxChargeTime":1,"increment":1,"bb":{"attack@atk_scale":1.85,"attack@trigger_time":4},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skchr_inside_1","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":0,"overrideTokenKey":null,"isDefault":false},{"skillId":"skchr_inside_2","iconId":"skchr_inside_2","name":"“解决麻烦”","level":4,"desc":"攻击力+80%,攻击间隔略微缩短,优先攻击使用远程武器的敌人并且不容易成为敌人的攻击目标\n攻击装有14发弹药,打完后结束(可随时停止技能)","descRaw":"攻击力<@ba.vup>+80%</>,攻击间隔<@ba.vup>略微缩短</>,优先攻击使用远程武器的敌人并且不容易成为敌人的攻击目标\n<@ba.rem>攻击装有14发弹药,打完后结束(可随时停止技能)</>","skillType":"MANUAL","durationType":"AMMO","duration":0,"spType":"INCREASE_WITH_TIME","spCost":24,"initSp":8,"maxChargeTime":1,"increment":1,"bb":{"atk":0.8,"base_attack_time":-0.3,"attack@trigger_time":14},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skchr_inside_2","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":1,"overrideTokenKey":null,"isDefault":true}]},"chess_char_1_01_b":{"chessId":"chess_char_1_01_b","baseId":"chess_char_1_01_a","goldenId":"chess_char_1_01_b","isGolden":true,"tier":1,"identifier":133,"isHidden":false,"isDiy":false,"visible":true,"chessType":"PRESET","backup":{"charId":"char_498_inside","tmplId":null,"skillIndex":1,"uniEquipId":"uniequip_002_inside","potRank":0},"shopSortId":1,"charId":"char_498Line truncated
|
{"chess_char_1_01_a":{"chessId":"chess_char_1_01_a","baseId":"chess_char_1_01_a","goldenId":"chess_char_1_01_b","isGolden":false,"tier":1,"identifier":0,"isHidden":false,"isDiy":false,"visible":true,"chessType":"PRESET","backup":{"charId":"char_498_inside","tmplId":null,"skillIndex":1,"uniEquipId":"uniequip_002_inside","potRank":0},"shopSortId":1,"charId":"char_498_inside","name":"隐现","appellation":"Insider","rarity":5,"profession":"SNIPER","subProfessionId":"fastshot","subProfessionName":"速射手","position":"RANGED","nationId":"laterano","bonds":["lateranoShip","swiftShip"],"garrisonIds":["garrison_16_a"],"price":2,"sellPrice":1,"upgradeNum":3,"upgradeChessId":"chess_char_1_01_b","status":{"phase":1,"level":55,"skillLevel":4,"equipLevel":0},"stats":{"maxHp":1123,"atk":422,"def":140,"res":0,"cost":12,"blockCnt":1,"bat":1,"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":"3-3","rangeGrid":[[1,0],[1,1],[1,2],[1,3],[0,0],[0,1],[0,2],[0,3],[-1,0],[-1,1],[-1,2],[-1,3]],"dmgType":"phys","attackKind":"ranged","projectile":"arrow","canHitFly":true,"targetPriority":"fly","trait":{"desc":"优先攻击空中单位","descRaw":"优先攻击空中单位","bb":{},"bbStr":{},"rangeGrid":null},"skill":{"skillId":"skchr_inside_2","iconId":"skchr_inside_2","name":"“解决麻烦”","level":4,"desc":"攻击力+80%,攻击间隔略微缩短,优先攻击使用远程武器的敌人并且不容易成为敌人的攻击目标\n攻击装有14发弹药,打完后结束(可随时停止技能)","descRaw":"攻击力<@ba.vup>+80%</>,攻击间隔<@ba.vup>略微缩短</>,优先攻击使用远程武器的敌人并且不容易成为敌人的攻击目标\n<@ba.rem>攻击装有14发弹药,打完后结束(可随时停止技能)</>","skillType":"MANUAL","durationType":"AMMO","duration":0,"spType":"INCREASE_WITH_TIME","spCost":24,"initSp":8,"maxChargeTime":1,"increment":1,"bb":{"atk":0.8,"base_attack_time":-0.3,"attack@trigger_time":14},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skchr_inside_2","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":1,"overrideTokenKey":null},"talents":[{"index":0,"name":"火力支援","desc":"在场停留20秒后,自身弹药类技能弹药上限+2","descRaw":"在场停留20秒后,自身弹药类技能弹药上限+2","bb":{"self_ammo":2,"duration":20},"bbStr":{},"rangeGrid":null,"tokenKey":null,"hidden":false,"fromModule":false}],"tokens":[],"module":{"id":"uniequip_002_inside","name":"“最初的惊喜”","type":"MAR-X","level":0,"active":false},"assets":{"avatar":"char_498_inside","portrait":"char_498_inside_1","spine":"char_498_inside","skillIcon":"skchr_inside_2","subProfIcon":"sub_fastshot_icon"},"skills":[{"skillId":"skchr_inside_1","iconId":"skchr_inside_1","name":"“不惹麻烦”","level":4,"desc":"每次攻击造成相当于攻击力185%的物理伤害\n攻击装有4发弹药,打完后结束(可随时停止技能)","descRaw":"每次攻击造成相当于攻击力<@ba.vup>185%</>的物理伤害\n<@ba.rem>攻击装有4发弹药,打完后结束(可随时停止技能)</>","skillType":"AUTO","durationType":"AMMO","duration":0,"spType":"INCREASE_WHEN_ATTACK","spCost":14,"initSp":0,"maxChargeTime":1,"increment":1,"bb":{"attack@atk_scale":1.85,"attack@trigger_time":4},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skchr_inside_1","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":0,"overrideTokenKey":null,"isDefault":false},{"skillId":"skchr_inside_2","iconId":"skchr_inside_2","name":"“解决麻烦”","level":4,"desc":"攻击力+80%,攻击间隔略微缩短,优先攻击使用远程武器的敌人并且不容易成为敌人的攻击目标\n攻击装有14发弹药,打完后结束(可随时停止技能)","descRaw":"攻击力<@ba.vup>+80%</>,攻击间隔<@ba.vup>略微缩短</>,优先攻击使用远程武器的敌人并且不容易成为敌人的攻击目标\n<@ba.rem>攻击装有14发弹药,打完后结束(可随时停止技能)</>","skillType":"MANUAL","durationType":"AMMO","duration":0,"spType":"INCREASE_WITH_TIME","spCost":24,"initSp":8,"maxChargeTime":1,"increment":1,"bb":{"atk":0.8,"base_attack_time":-0.3,"attack@trigger_time":14},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skchr_inside_2","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":1,"overrideTokenKey":null,"isDefault":true}],"potDown":{"0":{"stats.cost":15},"1":{"stats.respawnTime":80},"2":{"stats.cost":14},"3":{"stats.atk":399},"4":{"stats.cost":13}}},"chess_char_1_01_b":{"chessId":"chess_char_1_01_b","baseId":"chess_char_1_01_a","goldenId":"chess_char_1_01_b","isGolden":true,"tier":1,"identifier":133,"isHidden":false,"isDiy":false,"visible":true,"chessType":"PRESET","backup":{"charId"Line truncated
|
||||||
+1
-1
@@ -1 +1 @@
|
|||||||
{"version":1,"lang":"en","meta":{"generator":"tools/build-i18n.mjs","source":{"id":"assets","label":"ArknightsAssets/ArknightsGamedata (en)","home":"https://github.com/ArknightsAssets/ArknightsGamedata","season":"act2autochess"},"fallback":"tools/i18n/fallback-remake.json (the remake), tools/i18n/fallback-pr70.json (PR #70 by @YuriRestia), public/i18n/en.json","coverage":{"bands":{"texts":186,"translated":186,"official":186,"pct":100},"bonds":{"texts":138,"translated":138,"official":138,"pct":100},"bosses":{"texts":66,"translated":66,"official":66,"pct":100},"choices":{"texts":679,"translated":679,"official":679,"pct":100},"config":{"texts":110,"translated":110,"official":103,"pct":100},"effects":{"texts":1083,"translated":1083,"official":1083,"pct":100},"emotes":{"texts":41,"translated":41,"official":3,"pct":100},"enemies":{"texts":1734,"translated":1734,"official":1734,"pct":100},"factions":{"texts":14,"translated":14,"official":14,"pct":100},"garrisons":{"texts":747,"translated":747,"official":747,"pct":100},"items":{"texts":582,"translated":582,"official":570,"pct":100},"modules":{"texts":2482,"translated":2437,"official":2419,"pct":98.2},"operators":{"texts":873,"translated":851,"official":843,"pct":97.5},"skills":{"texts":5646,"translated":5439,"official":5439,"pct":96.3},"stages":{"texts":1124,"translated":1124,"official":1122,"pct":100},"talents":{"texts":4297,"translated":4099,"official":4099,"pct":95.4},"tokens":{"texts":442,"translated":442,"official":441,"pct":100},"traits":{"texts":1870,"translated":1854,"official":1854,"pct":99.1}},"totals":{"texts":22114,"translated":21626,"official":21540,"pct":97.8}},"names":{"\"炎佑\"":"Yan's Protection","Dor-1号失败品":"Dor-1 Failure","Dor-α号失败品":"Dor-α Failure","Dor-β号复制体":"Dor-β Replica","M3茧甲":"M3 Cocoon Shell","W·悬赏":"W/Bounty","“变形者集群”":"'Damazti Cluster'","“墓碑”":"'Gravestone'","“墓碑”·悬赏":"'Gravestone'/Bounty","“复仇者”":"'Hateful Avenger'","“复仇者”·悬赏":"'Hateful Avenger'/Bounty","“小自在”":"'Freeling'","“巨大的丑东西”":"'The Big Ugly Thing'","“巨大的丑东西”·悬赏":"'The Big Ugly Thing'/Bounty","“帝国的甲胄”":"'Armor of the Empire'","“庞贝”":"'Pompeii'","“庞贝”·悬赏":"'Pompeii'/Bounty","“斩胄之剑”":"'Armor Cleaver'","“灵幛”":"Sudaram","“独轮车玩具”":"'Unicycle Toy'","“破胄之锤”":"'Armor Crusher'","“碎铳之簧”":"'Gunshatter'","“神秘顾客”":"'Mystery Customer'","“耀阳”":"'Blazing Sun'","“自在”":"'Free'","“自在”·悬赏":"'Free'/Bounty","“萨科塔之眼”":"'Eyes of the Sankta'","“萨科塔之眼”·悬赏":"'Eyes of the Sankta'/Bounty","“萨科塔之翼”":"'Wings of the Sankta'","“萨科塔昂首”":"'Pride of the Sankta'","“萨米的意志”":"'Samivilinn,' the Resolution of the Very North","“裂管之音”/“断弦之音”":"'Echoes of Cracked Pipe'/'Echoes of Broken Strings'","“越长尘”":"'Sand Voyager'","“遗弃者”":"'Forsaken One'","“遗弃者”·悬赏":"'Forsaken One'/Bounty","“邪魔的利刃”":"'Demon's Blade'","“邪魔的利刃”·悬赏":"'Demon's Blade'/Bounty","“鼠王”·悬赏":"'Rat King'/Bounty","不屈":"Resilient","不屈弹射器":"Tough Launcher","不祥幻影":"Ominous Apparition","不稳定要素":"Unstable Element","业余竞演者":"Amateur Performer","业务指标":"Work Quota","临时收音师":"Audio Technician","主角阵营角色":"Hero's Sidekick","乌尔比安":"Ulpianus","乌顶巨角卢鲁":"Blackhorn Rhuul","九流之缘":"Streetwise Ties","人事部文档":"HR File","人才盲盒":"Talent Blind Box","仇白":"Qiubai","从不混淆的方向":"Undeviating Course","令":"Ling","以己之长":"Personal Power","伊内丝":"Ines","伊利昂的木驮兽":"Wooden Burdenbeast of Ilion","伊奥莱塔·罗素":"Ioleta Russell","伊芙利特":"Ifrit","休眠子裔":"Dormant Heir","休谟斯":"Humus","休露丝":"Sciurus","众志合一":"Wills As One","优等生":"Top Student","传奇重犯":"Infamous Recidivist","伪装服":"Disguise Outfit","伺夜":"Vigil","余":"Yu","佩佩":"Pepe","俗心":"Vernacularism","保护目标(冻结状态)":"Protection Objective (Frozen)","信仰搅拌机":"Sankta Miksaparato","信标":"Transmitter","假想敌:再生":"OpFor: Rebirth","假想敌:弦":"OpFor: String","假想敌:淤困":"OpFor: Quagmire","假想敌:管":"OpFor: Pipe","假想敌:胄":"OpFor: Armor","假想敌:蚀裂":"OpFor: Fissure","假想敌:铳":"OpFor: Gun","假想敌:镜膜":"OpFor: Mirror","假想敌:骨刺":"OpFor: Bonespur","假想敌:黑云":"OpFor: BlackCloud","假日威龙陈":"Ch'en the Holungday","傀影":"Phantom","催泪瓦斯":"Teargas Can","元核孽生者":"Nucleic Maleficent","元核孽生者·悬赏":"Nucleic Maleficient/Bounty","克莱门莎":"Clementia","冒失的小弟":"Careless Underling","军用猎狗":"Military Hound","冰爆源石虫":Line truncated
|
{"version":1,"lang":"en","meta":{"generator":"tools/build-i18n.mjs","source":{"id":"assets","label":"ArknightsAssets/ArknightsGamedata (en)","home":"https://github.com/ArknightsAssets/ArknightsGamedata","season":"act2autochess"},"fallback":"tools/i18n/fallback-remake.json (the remake), tools/i18n/fallback-pr70.json (PR #70 by @YuriRestia), public/i18n/en.json","coverage":{"bands":{"texts":186,"translated":186,"official":186,"pct":100},"bonds":{"texts":138,"translated":138,"official":138,"pct":100},"bosses":{"texts":66,"translated":66,"official":66,"pct":100},"choices":{"texts":679,"translated":679,"official":679,"pct":100},"config":{"texts":110,"translated":110,"official":103,"pct":100},"effects":{"texts":1083,"translated":1083,"official":1083,"pct":100},"emotes":{"texts":41,"translated":41,"official":3,"pct":100},"enemies":{"texts":1734,"translated":1734,"official":1734,"pct":100},"factions":{"texts":14,"translated":14,"official":14,"pct":100},"garrisons":{"texts":747,"translated":747,"official":747,"pct":100},"items":{"texts":582,"translated":582,"official":570,"pct":100},"modules":{"texts":2482,"translated":2437,"official":2419,"pct":98.2},"operators":{"texts":873,"translated":851,"official":843,"pct":97.5},"skills":{"texts":5646,"translated":5439,"official":5439,"pct":96.3},"stages":{"texts":1124,"translated":1124,"official":1122,"pct":100},"talents":{"texts":5963,"translated":5687,"official":5679,"pct":95.4},"tokens":{"texts":456,"translated":456,"official":455,"pct":100},"traits":{"texts":1870,"translated":1854,"official":1854,"pct":99.1}},"totals":{"texts":23794,"translated":23228,"official":23134,"pct":97.6}},"names":{"\"炎佑\"":"Yan's Protection","Dor-1号失败品":"Dor-1 Failure","Dor-α号失败品":"Dor-α Failure","Dor-β号复制体":"Dor-β Replica","M3茧甲":"M3 Cocoon Shell","W·悬赏":"W/Bounty","“变形者集群”":"'Damazti Cluster'","“墓碑”":"'Gravestone'","“墓碑”·悬赏":"'Gravestone'/Bounty","“复仇者”":"'Hateful Avenger'","“复仇者”·悬赏":"'Hateful Avenger'/Bounty","“小自在”":"'Freeling'","“巨大的丑东西”":"'The Big Ugly Thing'","“巨大的丑东西”·悬赏":"'The Big Ugly Thing'/Bounty","“帝国的甲胄”":"'Armor of the Empire'","“庞贝”":"'Pompeii'","“庞贝”·悬赏":"'Pompeii'/Bounty","“斩胄之剑”":"'Armor Cleaver'","“灵幛”":"Sudaram","“独轮车玩具”":"'Unicycle Toy'","“破胄之锤”":"'Armor Crusher'","“碎铳之簧”":"'Gunshatter'","“神秘顾客”":"'Mystery Customer'","“耀阳”":"'Blazing Sun'","“自在”":"'Free'","“自在”·悬赏":"'Free'/Bounty","“萨科塔之眼”":"'Eyes of the Sankta'","“萨科塔之眼”·悬赏":"'Eyes of the Sankta'/Bounty","“萨科塔之翼”":"'Wings of the Sankta'","“萨科塔昂首”":"'Pride of the Sankta'","“萨米的意志”":"'Samivilinn,' the Resolution of the Very North","“裂管之音”/“断弦之音”":"'Echoes of Cracked Pipe'/'Echoes of Broken Strings'","“越长尘”":"'Sand Voyager'","“遗弃者”":"'Forsaken One'","“遗弃者”·悬赏":"'Forsaken One'/Bounty","“邪魔的利刃”":"'Demon's Blade'","“邪魔的利刃”·悬赏":"'Demon's Blade'/Bounty","“鼠王”·悬赏":"'Rat King'/Bounty","不屈":"Resilient","不屈弹射器":"Tough Launcher","不祥幻影":"Ominous Apparition","不稳定要素":"Unstable Element","业余竞演者":"Amateur Performer","业务指标":"Work Quota","临时收音师":"Audio Technician","主角阵营角色":"Hero's Sidekick","乌尔比安":"Ulpianus","乌顶巨角卢鲁":"Blackhorn Rhuul","九流之缘":"Streetwise Ties","人事部文档":"HR File","人才盲盒":"Talent Blind Box","仇白":"Qiubai","从不混淆的方向":"Undeviating Course","令":"Ling","以己之长":"Personal Power","伊内丝":"Ines","伊利昂的木驮兽":"Wooden Burdenbeast of Ilion","伊奥莱塔·罗素":"Ioleta Russell","伊芙利特":"Ifrit","休眠子裔":"Dormant Heir","休谟斯":"Humus","休露丝":"Sciurus","众志合一":"Wills As One","优等生":"Top Student","传奇重犯":"Infamous Recidivist","伪装服":"Disguise Outfit","伺夜":"Vigil","余":"Yu","佩佩":"Pepe","俗心":"Vernacularism","保护目标(冻结状态)":"Protection Objective (Frozen)","信仰搅拌机":"Sankta Miksaparato","信标":"Transmitter","假想敌:再生":"OpFor: Rebirth","假想敌:弦":"OpFor: String","假想敌:淤困":"OpFor: Quagmire","假想敌:管":"OpFor: Pipe","假想敌:胄":"OpFor: Armor","假想敌:蚀裂":"OpFor: Fissure","假想敌:铳":"OpFor: Gun","假想敌:镜膜":"OpFor: Mirror","假想敌:骨刺":"OpFor: Bonespur","假想敌:黑云":"OpFor: BlackCloud","假日威龙陈":"Ch'en the Holungday","傀影":"Phantom","催泪瓦斯":"Teargas Can","元核孽生者":"Nucleic Maleficent","元核孽生者·悬赏":"Nucleic Maleficient/Bounty","克莱门莎":"Clementia","冒失的小弟":"Careless Underling","军用猎狗":"Military Hound","冰爆源石虫":Line truncated
|
||||||
+1
-1
@@ -1 +1 @@
|
|||||||
{"version":1,"lang":"ja","meta":{"generator":"tools/build-i18n.mjs","source":{"id":"assets","label":"ArknightsAssets/ArknightsGamedata (jp)","home":"https://github.com/ArknightsAssets/ArknightsGamedata","season":"act2autochess"},"fallback":"public/i18n/ja.json (the pack's UI strings)","coverage":{"bands":{"texts":186,"translated":186,"official":186,"pct":100},"bonds":{"texts":138,"translated":138,"official":138,"pct":100},"bosses":{"texts":66,"translated":66,"official":66,"pct":100},"choices":{"texts":679,"translated":679,"official":679,"pct":100},"config":{"texts":110,"translated":103,"official":103,"pct":93.6},"effects":{"texts":1083,"translated":1083,"official":1083,"pct":100},"emotes":{"texts":41,"translated":41,"official":3,"pct":100},"enemies":{"texts":1734,"translated":1734,"official":1734,"pct":100},"factions":{"texts":14,"translated":14,"official":14,"pct":100},"garrisons":{"texts":747,"translated":747,"official":747,"pct":100},"items":{"texts":582,"translated":570,"official":570,"pct":97.9},"modules":{"texts":2482,"translated":2419,"official":2419,"pct":97.5},"operators":{"texts":873,"translated":843,"official":843,"pct":96.6},"skills":{"texts":5646,"translated":5439,"official":5439,"pct":96.3},"stages":{"texts":1124,"translated":1111,"official":1111,"pct":98.8},"talents":{"texts":4297,"translated":4095,"official":4095,"pct":95.3},"tokens":{"texts":442,"translated":441,"official":441,"pct":99.8},"traits":{"texts":1870,"translated":1854,"official":1854,"pct":99.1}},"totals":{"texts":22114,"translated":21563,"official":21525,"pct":97.5}},"names":{"\"炎佑\"":"「炎祐」","Dor-1号失败品":"Dor-1号失敗作","Dor-α号失败品":"Dor-α号失敗作","Dor-β号复制体":"Dor-β号複製体","M3茧甲":"M3コクーンアーマー","W·悬赏":"懸賞・W","“变形者集群”":"「変形者」","“墓碑”":"「墓碑」","“墓碑”·悬赏":"懸賞・「墓碑」","“复仇者”":"Vendetta","“复仇者”·悬赏":"懸賞・「ヴェンデッタ」","“小自在”":"「小自在」","“巨大的丑东西”":"「ビッグアグリー」","“巨大的丑东西”·悬赏":"懸賞・「ビッグアグリー」","“帝国的甲胄”":"「帝国の甲冑」","“庞贝”":"「ポンペイ」","“庞贝”·悬赏":"懸賞・Pompeii","“斩胄之剑”":"「甲冑断つ剣」","“灵幛”":"「霊骸布」","“独轮车玩具”":"「一輪車のおもちゃ」","“破胄之锤”":"「甲冑割る槌」","“碎铳之簧”":"「機銃砕く輪」","“神秘顾客”":"「謎の客人」","“耀阳”":"「ブレイジング・サン」","“自在”":"「ジザイ」","“自在”·悬赏":"懸賞・「ジザイ」","“萨科塔之眼”":"「サンクタの眼」","“萨科塔之眼”·悬赏":"懸賞・「サンクタの眼」","“萨科塔之翼”":"「サンクタの翼」","“萨科塔昂首”":"「サンクタの仰け反り」","“萨米的意志”":"「サーミの意志」","“裂管之音”/“断弦之音”":"「断絃の音」/「裂管の音」","“越长尘”":"「長塵」","“遗弃者”":"「見棄てし者」","“遗弃者”·悬赏":"懸賞・「見棄てし者」","“邪魔的利刃”":"「悪魔の利刃」","“邪魔的利刃”·悬赏":"懸賞・「悪魔の利刃」","“鼠王”·悬赏":"懸賞・「鼠王」","不屈":"不屈","不屈弹射器":"不屈の射出装置","不祥幻影":"不祥の幻影","不稳定要素":"不安定要素","业余竞演者":"一般参加の競演者","业务指标":"業務ノルマ","临时收音师":"臨時録音技師","主角阵营角色":"主役側の脇役","乌尔比安":"ウルピアヌス","乌顶巨角卢鲁":"黒き大角のルル","九流之缘":"人たらし","人事部文档":"人事部の書類","人才盲盒":"人材抽選","仇白":"チューバイ","从不混淆的方向":"混同せぬ方向","令":"リィン","以己之长":"持ち味を活かして","伊内丝":"イネス","伊利昂的木驮兽":"イリオスの木駄獣","伊奥莱塔·罗素":"イオレッタ・ラッセル","伊芙利特":"イフリータ","休眠子裔":"眠れる眷属","休谟斯":"ヒューマス","休露丝":"スキウース","众志合一":"一致団結","优等生":"優等生","传奇重犯":"伝説の凶悪犯","伪装服":"迷彩ヘルメット","伺夜":"ヴィジェル","余":"ユー","佩佩":"ペペ","俗心":"俗心","保护目标(冻结状态)":"防衛ライン(凍結状態)","信仰搅拌机":"サンクタ・ミキサー","信标":"ビーコン","假想敌:再生":"仮想敵:再生","假想敌:弦":"仮想敵:絃","假想敌:淤困":"仮想敵:泥濘","假想敌:管":"仮想敵:管","假想敌:胄":"仮想敵:冑","假想敌:蚀裂":"仮想敵:蝕裂","假想敌:铳":"仮想敵:銃","假想敌:镜膜":"仮想敵:鏡膜","假想敌:骨刺":"仮想敵:骨棘","假想敌:黑云":"仮想敵:黒雲","假日威龙陈":"遊龍チェン","傀影":"Line truncated
|
{"version":1,"lang":"ja","meta":{"generator":"tools/build-i18n.mjs","source":{"id":"assets","label":"ArknightsAssets/ArknightsGamedata (jp)","home":"https://github.com/ArknightsAssets/ArknightsGamedata","season":"act2autochess"},"fallback":"public/i18n/ja.json (the pack's UI strings)","coverage":{"bands":{"texts":186,"translated":186,"official":186,"pct":100},"bonds":{"texts":138,"translated":138,"official":138,"pct":100},"bosses":{"texts":66,"translated":66,"official":66,"pct":100},"choices":{"texts":679,"translated":679,"official":679,"pct":100},"config":{"texts":110,"translated":103,"official":103,"pct":93.6},"effects":{"texts":1083,"translated":1083,"official":1083,"pct":100},"emotes":{"texts":41,"translated":41,"official":3,"pct":100},"enemies":{"texts":1734,"translated":1734,"official":1734,"pct":100},"factions":{"texts":14,"translated":14,"official":14,"pct":100},"garrisons":{"texts":747,"translated":747,"official":747,"pct":100},"items":{"texts":582,"translated":570,"official":570,"pct":97.9},"modules":{"texts":2482,"translated":2419,"official":2419,"pct":97.5},"operators":{"texts":873,"translated":843,"official":843,"pct":96.6},"skills":{"texts":5646,"translated":5439,"official":5439,"pct":96.3},"stages":{"texts":1124,"translated":1111,"official":1111,"pct":98.8},"talents":{"texts":5963,"translated":5675,"official":5675,"pct":95.2},"tokens":{"texts":456,"translated":455,"official":455,"pct":99.8},"traits":{"texts":1870,"translated":1854,"official":1854,"pct":99.1}},"totals":{"texts":23794,"translated":23157,"official":23119,"pct":97.3}},"names":{"\"炎佑\"":"「炎祐」","Dor-1号失败品":"Dor-1号失敗作","Dor-α号失败品":"Dor-α号失敗作","Dor-β号复制体":"Dor-β号複製体","M3茧甲":"M3コクーンアーマー","W·悬赏":"懸賞・W","“变形者集群”":"「変形者」","“墓碑”":"「墓碑」","“墓碑”·悬赏":"懸賞・「墓碑」","“复仇者”":"Vendetta","“复仇者”·悬赏":"懸賞・「ヴェンデッタ」","“小自在”":"「小自在」","“巨大的丑东西”":"「ビッグアグリー」","“巨大的丑东西”·悬赏":"懸賞・「ビッグアグリー」","“帝国的甲胄”":"「帝国の甲冑」","“庞贝”":"「ポンペイ」","“庞贝”·悬赏":"懸賞・Pompeii","“斩胄之剑”":"「甲冑断つ剣」","“灵幛”":"「霊骸布」","“独轮车玩具”":"「一輪車のおもちゃ」","“破胄之锤”":"「甲冑割る槌」","“碎铳之簧”":"「機銃砕く輪」","“神秘顾客”":"「謎の客人」","“耀阳”":"「ブレイジング・サン」","“自在”":"「ジザイ」","“自在”·悬赏":"懸賞・「ジザイ」","“萨科塔之眼”":"「サンクタの眼」","“萨科塔之眼”·悬赏":"懸賞・「サンクタの眼」","“萨科塔之翼”":"「サンクタの翼」","“萨科塔昂首”":"「サンクタの仰け反り」","“萨米的意志”":"「サーミの意志」","“裂管之音”/“断弦之音”":"「断絃の音」/「裂管の音」","“越长尘”":"「長塵」","“遗弃者”":"「見棄てし者」","“遗弃者”·悬赏":"懸賞・「見棄てし者」","“邪魔的利刃”":"「悪魔の利刃」","“邪魔的利刃”·悬赏":"懸賞・「悪魔の利刃」","“鼠王”·悬赏":"懸賞・「鼠王」","不屈":"不屈","不屈弹射器":"不屈の射出装置","不祥幻影":"不祥の幻影","不稳定要素":"不安定要素","业余竞演者":"一般参加の競演者","业务指标":"業務ノルマ","临时收音师":"臨時録音技師","主角阵营角色":"主役側の脇役","乌尔比安":"ウルピアヌス","乌顶巨角卢鲁":"黒き大角のルル","九流之缘":"人たらし","人事部文档":"人事部の書類","人才盲盒":"人材抽選","仇白":"チューバイ","从不混淆的方向":"混同せぬ方向","令":"リィン","以己之长":"持ち味を活かして","伊内丝":"イネス","伊利昂的木驮兽":"イリオスの木駄獣","伊奥莱塔·罗素":"イオレッタ・ラッセル","伊芙利特":"イフリータ","休眠子裔":"眠れる眷属","休谟斯":"ヒューマス","休露丝":"スキウース","众志合一":"一致団結","优等生":"優等生","传奇重犯":"伝説の凶悪犯","伪装服":"迷彩ヘルメット","伺夜":"ヴィジェル","余":"ユー","佩佩":"ペペ","俗心":"俗心","保护目标(冻结状态)":"防衛ライン(凍結状態)","信仰搅拌机":"サンクタ・ミキサー","信标":"ビーコン","假想敌:再生":"仮想敵:再生","假想敌:弦":"仮想敵:絃","假想敌:淤困":"仮想敵:泥濘","假想敌:管":"仮想敵:管","假想敌:胄":"仮想敵:冑","假想敌:蚀裂":"仮想敵:蝕裂","假想敌:铳":"仮想敵:銃","假想敌:镜膜":"仮想敵:鏡膜","假想敌:骨刺":"仮想敵:骨棘","假想敌:黑云":"仮想敵:黒雲","假日威龙陈":"遊龍チェン","傀影":"Line truncated
|
||||||
+1
-1
@@ -1 +1 @@
|
|||||||
{"version":1,"lang":"ko","meta":{"generator":"tools/build-i18n.mjs","source":{"id":"assets","label":"ArknightsAssets/ArknightsGamedata (kr)","home":"https://github.com/ArknightsAssets/ArknightsGamedata","season":"act2autochess"},"fallback":"public/i18n/ko.json (the pack's UI strings)","coverage":{"bands":{"texts":186,"translated":186,"official":186,"pct":100},"bonds":{"texts":138,"translated":138,"official":138,"pct":100},"bosses":{"texts":66,"translated":66,"official":66,"pct":100},"choices":{"texts":679,"translated":679,"official":679,"pct":100},"config":{"texts":110,"translated":103,"official":103,"pct":93.6},"effects":{"texts":1083,"translated":1083,"official":1083,"pct":100},"emotes":{"texts":41,"translated":41,"official":3,"pct":100},"enemies":{"texts":1734,"translated":1734,"official":1734,"pct":100},"factions":{"texts":14,"translated":14,"official":14,"pct":100},"garrisons":{"texts":747,"translated":747,"official":747,"pct":100},"items":{"texts":582,"translated":570,"official":570,"pct":97.9},"modules":{"texts":2482,"translated":2419,"official":2419,"pct":97.5},"operators":{"texts":873,"translated":843,"official":843,"pct":96.6},"skills":{"texts":5646,"translated":5439,"official":5439,"pct":96.3},"stages":{"texts":1124,"translated":1111,"official":1111,"pct":98.8},"talents":{"texts":4297,"translated":4099,"official":4099,"pct":95.4},"tokens":{"texts":442,"translated":441,"official":441,"pct":99.8},"traits":{"texts":1870,"translated":1854,"official":1854,"pct":99.1}},"totals":{"texts":22114,"translated":21567,"official":21529,"pct":97.5}},"names":{"\"炎佑\"":"'염의 가호'","Dor-1号失败品":"Dor-1호 실패작","Dor-α号失败品":"Dor-α호 실패작","Dor-β号复制体":"Dor-β호 복제품","M3茧甲":"M3 고치 갑옷","W·悬赏":"W - 수배","“变形者集群”":"'다마즈티'","“墓碑”":"'툼스톤'","“墓碑”·悬赏":"'툼스톤' - 수배","“复仇者”":"'어벤져'","“复仇者”·悬赏":"'어벤져' - 수배","“小自在”":"'작은 즈자이'","“巨大的丑东西”":"'거대하고 못생긴 것'","“巨大的丑东西”·悬赏":"'거대하고 못생긴 것' - 수배","“帝国的甲胄”":"'제국의 갑옷'","“庞贝”":"'폼페이'","“庞贝”·悬赏":"'폼페이' - 수배","“斩胄之剑”":"'갑주를 베는 검'","“灵幛”":"'영장'","“独轮车玩具”":"'완구 외발자전거'","“破胄之锤”":"'갑주를 부수는 해머'","“碎铳之簧”":"'총을 부수는 리드'","“神秘顾客”":"'신비한 고객'","“耀阳”":"'눈 부신 태양'","“自在”":"'즈자이'","“自在”·悬赏":"'즈자이' - 수배","“萨科塔之眼”":"'산크타의 눈'","“萨科塔之眼”·悬赏":"'산크타의 눈' - 수배","“萨科塔之翼”":"'산크타의 날개'","“萨科塔昂首”":"'산크타의 긍지'","“萨米的意志”":"'사미의 의지'","“裂管之音”/“断弦之音”":"'단현지음'/'열관지음'","“越长尘”":"'월장진'","“遗弃者”":"'버림받은 자'","“遗弃者”·悬赏":"'버림받은 자' - 수배","“邪魔的利刃”":"'데몬의 칼날'","“邪魔的利刃”·悬赏":"'데몬의 칼날' - 수배","“鼠王”·悬赏":"'래트킹' - 수배","不屈":"불굴","不屈弹射器":"불굴의 사출기","不祥幻影":"불길한 환영","不稳定要素":"불안요소","业余竞演者":"아마추어 경연자","业务指标":"업무 지표","临时收音师":"임시 음향기사","主角阵营角色":"주인공 진영 캐릭터","乌尔比安":"울피아누스","乌顶巨角卢鲁":"검은 뿔의 루울","九流之缘":"구류의 인연","人事部文档":"인사부 파일","人才盲盒":"인재 랜덤박스","仇白":"치우바이","从不混淆的方向":"헷갈리지 않는 방향","令":"링","以己之长":"장점 활용","伊内丝":"이네스","伊利昂的木驮兽":"일리온의 나무 버든비스트","伊奥莱塔·罗素":"이올레타 로시","伊芙利特":"이프리트","休眠子裔":"잠자는 후예","休谟斯":"휴머스","休露丝":"시우루스","众志合一":"일치단결","优等生":"우등생","传奇重犯":"전설의 중범죄수","伪装服":"위장복","伺夜":"비질","余":"위","佩佩":"페페","俗心":"수신","保护目标(冻结状态)":"방어 포인트 (빙결 상태)","信仰搅拌机":"산크타 믹사파라토","信标":"비콘","假想敌:再生":"가상적: 재생","假想敌:弦":"가상적: 현","假想敌:淤困":"가상적: 정체","假想敌:管":"가상적: 관","假想敌:胄":"가상적: 갑주","假想敌:蚀裂":"가상적: 침식","假想敌:铳":"가상적: 총","假想敌:镜膜":"가상적: 경막","假想敌:骨刺":"가상적: 골극","假想敌:黑云":"가상적: 흑운","假日威龙陈":"첸 더 홀룽데이","傀影":"팬텀","催泪瓦斯":"최루가스","元核孽生者":"원핵의 악생자","元核孽生者·悬赏":"원핵의 악생자 - 수배","克莱Line truncated
|
{"version":1,"lang":"ko","meta":{"generator":"tools/build-i18n.mjs","source":{"id":"assets","label":"ArknightsAssets/ArknightsGamedata (kr)","home":"https://github.com/ArknightsAssets/ArknightsGamedata","season":"act2autochess"},"fallback":"public/i18n/ko.json (the pack's UI strings)","coverage":{"bands":{"texts":186,"translated":186,"official":186,"pct":100},"bonds":{"texts":138,"translated":138,"official":138,"pct":100},"bosses":{"texts":66,"translated":66,"official":66,"pct":100},"choices":{"texts":679,"translated":679,"official":679,"pct":100},"config":{"texts":110,"translated":103,"official":103,"pct":93.6},"effects":{"texts":1083,"translated":1083,"official":1083,"pct":100},"emotes":{"texts":41,"translated":41,"official":3,"pct":100},"enemies":{"texts":1734,"translated":1734,"official":1734,"pct":100},"factions":{"texts":14,"translated":14,"official":14,"pct":100},"garrisons":{"texts":747,"translated":747,"official":747,"pct":100},"items":{"texts":582,"translated":570,"official":570,"pct":97.9},"modules":{"texts":2482,"translated":2419,"official":2419,"pct":97.5},"operators":{"texts":873,"translated":843,"official":843,"pct":96.6},"skills":{"texts":5646,"translated":5439,"official":5439,"pct":96.3},"stages":{"texts":1124,"translated":1111,"official":1111,"pct":98.8},"talents":{"texts":5963,"translated":5679,"official":5679,"pct":95.2},"tokens":{"texts":456,"translated":455,"official":455,"pct":99.8},"traits":{"texts":1870,"translated":1854,"official":1854,"pct":99.1}},"totals":{"texts":23794,"translated":23161,"official":23123,"pct":97.3}},"names":{"\"炎佑\"":"'염의 가호'","Dor-1号失败品":"Dor-1호 실패작","Dor-α号失败品":"Dor-α호 실패작","Dor-β号复制体":"Dor-β호 복제품","M3茧甲":"M3 고치 갑옷","W·悬赏":"W - 수배","“变形者集群”":"'다마즈티'","“墓碑”":"'툼스톤'","“墓碑”·悬赏":"'툼스톤' - 수배","“复仇者”":"'어벤져'","“复仇者”·悬赏":"'어벤져' - 수배","“小自在”":"'작은 즈자이'","“巨大的丑东西”":"'거대하고 못생긴 것'","“巨大的丑东西”·悬赏":"'거대하고 못생긴 것' - 수배","“帝国的甲胄”":"'제국의 갑옷'","“庞贝”":"'폼페이'","“庞贝”·悬赏":"'폼페이' - 수배","“斩胄之剑”":"'갑주를 베는 검'","“灵幛”":"'영장'","“独轮车玩具”":"'완구 외발자전거'","“破胄之锤”":"'갑주를 부수는 해머'","“碎铳之簧”":"'총을 부수는 리드'","“神秘顾客”":"'신비한 고객'","“耀阳”":"'눈 부신 태양'","“自在”":"'즈자이'","“自在”·悬赏":"'즈자이' - 수배","“萨科塔之眼”":"'산크타의 눈'","“萨科塔之眼”·悬赏":"'산크타의 눈' - 수배","“萨科塔之翼”":"'산크타의 날개'","“萨科塔昂首”":"'산크타의 긍지'","“萨米的意志”":"'사미의 의지'","“裂管之音”/“断弦之音”":"'단현지음'/'열관지음'","“越长尘”":"'월장진'","“遗弃者”":"'버림받은 자'","“遗弃者”·悬赏":"'버림받은 자' - 수배","“邪魔的利刃”":"'데몬의 칼날'","“邪魔的利刃”·悬赏":"'데몬의 칼날' - 수배","“鼠王”·悬赏":"'래트킹' - 수배","不屈":"불굴","不屈弹射器":"불굴의 사출기","不祥幻影":"불길한 환영","不稳定要素":"불안요소","业余竞演者":"아마추어 경연자","业务指标":"업무 지표","临时收音师":"임시 음향기사","主角阵营角色":"주인공 진영 캐릭터","乌尔比安":"울피아누스","乌顶巨角卢鲁":"검은 뿔의 루울","九流之缘":"구류의 인연","人事部文档":"인사부 파일","人才盲盒":"인재 랜덤박스","仇白":"치우바이","从不混淆的方向":"헷갈리지 않는 방향","令":"링","以己之长":"장점 활용","伊内丝":"이네스","伊利昂的木驮兽":"일리온의 나무 버든비스트","伊奥莱塔·罗素":"이올레타 로시","伊芙利特":"이프리트","休眠子裔":"잠자는 후예","休谟斯":"휴머스","休露丝":"시우루스","众志合一":"일치단결","优等生":"우등생","传奇重犯":"전설의 중범죄수","伪装服":"위장복","伺夜":"비질","余":"위","佩佩":"페페","俗心":"수신","保护目标(冻结状态)":"방어 포인트 (빙결 상태)","信仰搅拌机":"산크타 믹사파라토","信标":"비콘","假想敌:再生":"가상적: 재생","假想敌:弦":"가상적: 현","假想敌:淤困":"가상적: 정체","假想敌:管":"가상적: 관","假想敌:胄":"가상적: 갑주","假想敌:蚀裂":"가상적: 침식","假想敌:铳":"가상적: 총","假想敌:镜膜":"가상적: 경막","假想敌:骨刺":"가상적: 골극","假想敌:黑云":"가상적: 흑운","假日威龙陈":"첸 더 홀룽데이","傀影":"팬텀","催泪瓦斯":"최루가스","元核孽生者":"원핵의 악생자","元核孽生者·悬赏":"원핵의 악생자 - 수배","克莱Line truncated
|
||||||
@@ -1 +1 @@
|
|||||||
{"version":1,"lang":"zh-TW","meta":{"generator":"tools/build-i18n.mjs","source":{"id":"assets","label":"ArknightsAssets/ArknightsGamedata (tw)","home":"https://github.com/ArknightsAssets/ArknightsGamedata","season":"act2autochess"},"fallback":"public/i18n/zh-TW.json (the pack's UI strings)","coverage":{"bands":{"texts":186,"translated":170,"official":170,"pct":91.4},"bonds":{"texts":138,"translated":130,"official":130,"pct":94.2},"bosses":{"texts":66,"translated":66,"official":66,"pct":100},"choices":{"texts":679,"translated":674,"official":674,"pct":99.3},"config":{"texts":110,"translated":103,"official":103,"pct":93.6},"effects":{"texts":1083,"translated":1053,"official":1053,"pct":97.2},"emotes":{"texts":41,"translated":22,"official":3,"pct":53.7},"enemies":{"texts":1734,"translated":1659,"official":1659,"pct":95.7},"factions":{"texts":14,"translated":14,"official":14,"pct":100},"garrisons":{"texts":747,"translated":747,"official":747,"pct":100},"items":{"texts":582,"translated":544,"official":544,"pct":93.5},"modules":{"texts":2482,"translated":2302,"official":2302,"pct":92.7},"operators":{"texts":873,"translated":585,"official":585,"pct":67},"skills":{"texts":5646,"translated":5110,"official":5110,"pct":90.5},"stages":{"texts":1124,"translated":901,"official":901,"pct":80.2},"talents":{"texts":4297,"translated":3806,"official":3806,"pct":88.6},"tokens":{"texts":442,"translated":419,"official":419,"pct":94.8},"traits":{"texts":1870,"translated":1854,"official":1854,"pct":99.1}},"totals":{"texts":22114,"translated":20159,"official":20140,"pct":91.2}},"names":{"\"炎佑\"":"“炎佑”","Dor-1号失败品":"Dor-1號失敗品","Dor-α号失败品":"Dor-α號失敗品","Dor-β号复制体":"Dor-β號複製體","M3茧甲":"M3繭甲","W·悬赏":"W·懸賞","“变形者集群”":"“變形者集群”","“墓碑”·悬赏":"“墓碑”·懸賞","“复仇者”":"“復仇者”","“复仇者”·悬赏":"“復仇者”·懸賞","“巨大的丑东西”":"“巨大的醜東西”","“巨大的丑东西”·悬赏":"“巨大的醜東西”·懸賞","“帝国的甲胄”":"“帝國的甲胄”","“庞贝”":"“龐貝”","“庞贝”·悬赏":"“龐貝”·懸賞","“斩胄之剑”":"“斬胄之劍”","“灵幛”":"“靈幛”","“独轮车玩具”":"“獨輪車玩具”","“破胄之锤”":"“破胄之錘”","“碎铳之簧”":"“碎銃之簧”","“神秘顾客”":"“神秘顧客”","“耀阳”":"“耀陽”","“自在”·悬赏":"“自在”·懸賞","“萨科塔之眼”":"“薩科塔之眼”","“萨科塔之眼”·悬赏":"“薩科塔之眼”·懸賞","“萨科塔之翼”":"“薩科塔之翼”","“萨科塔昂首”":"“薩科塔昂首”","“萨米的意志”":"“薩米的意志”","“裂管之音”/“断弦之音”":"“裂管之音”/“斷弦之音”","“越长尘”":"“越長塵”","“遗弃者”":"“遺棄者”","“遗弃者”·悬赏":"“遺棄者”·懸賞","“邪魔的利刃”·悬赏":"“邪魔的利刃”·懸賞","“鼠王”·悬赏":"“鼠王”·懸賞","不屈弹射器":"不屈彈射器","不稳定要素":"不穩定要素","业余竞演者":"業餘競演者","业务指标":"業務指標","临时收音师":"臨時收音師","主角阵营角色":"主角陣營角色","乌尔比安":"烏爾比安","乌顶巨角卢鲁":"烏頂巨角盧魯","九流之缘":"九流之緣","人事部文档":"人事部文件","从不混淆的方向":"從不混淆的方向","以己之长":"以己之長","伊内丝":"伊內絲","伊利昂的木驮兽":"伊利昂的木馱獸","伊奥莱塔·罗素":"伊奧萊塔·羅素","休谟斯":"休謨斯","休露丝":"休露絲","众志合一":"眾志合一","优等生":"優等生","传奇重犯":"傳奇重犯","伪装服":"偽裝服","余":"餘","保护目标(冻结状态)":"保護目標(凍結狀態)","信仰搅拌机":"信仰攪拌機","信标":"信標","假想敌:再生":"假想敵:再生","假想敌:弦":"假想敵:弦","假想敌:淤困":"假想敵:淤困","假想敌:管":"假想敵:管","假想敌:胄":"假想敵:胄","假想敌:蚀裂":"假想敵:蝕裂","假想敌:铳":"假想敵:銃","假想敌:镜膜":"假想敵:鏡膜","假想敌:骨刺":"假想敵:骨刺","假想敌:黑云":"假想敵:黑雲","假日威龙陈":"假日威龍陳","催泪瓦斯":"催淚瓦斯","元核孽生者·悬赏":"元核孽生者·懸賞","克莱门莎":"克萊門莎","军用猎狗":"軍用獵狗","冰爆源石虫":"冰爆源石蟲","冲击式施术单元":"衝擊式施術單元","凋零骑士":"凋零騎士","凋零骑士·悬赏":"凋零騎士·懸賞","凛御银灰":"凜御銀灰","凯尔希":"凱爾希","凯瑟琳":"凱瑟琳","列装":"列裝","利滚利":"利滾利","刺胄之弹":"刺胄之彈","加急调派":"緊急調派","加练!":"加練!","加速维式重锤":"加速維式重鎚","医疗探机":"醫療探機","升华":"昇華","华法琳":"華法琳",Line truncated
|
{"version":1,"lang":"zh-TW","meta":{"generator":"tools/build-i18n.mjs","source":{"id":"assets","label":"ArknightsAssets/ArknightsGamedata (tw)","home":"https://github.com/ArknightsAssets/ArknightsGamedata","season":"act2autochess"},"fallback":"public/i18n/zh-TW.json (the pack's UI strings)","coverage":{"bands":{"texts":186,"translated":170,"official":170,"pct":91.4},"bonds":{"texts":138,"translated":130,"official":130,"pct":94.2},"bosses":{"texts":66,"translated":66,"official":66,"pct":100},"choices":{"texts":679,"translated":674,"official":674,"pct":99.3},"config":{"texts":110,"translated":103,"official":103,"pct":93.6},"effects":{"texts":1083,"translated":1053,"official":1053,"pct":97.2},"emotes":{"texts":41,"translated":22,"official":3,"pct":53.7},"enemies":{"texts":1734,"translated":1659,"official":1659,"pct":95.7},"factions":{"texts":14,"translated":14,"official":14,"pct":100},"garrisons":{"texts":747,"translated":747,"official":747,"pct":100},"items":{"texts":582,"translated":544,"official":544,"pct":93.5},"modules":{"texts":2482,"translated":2302,"official":2302,"pct":92.7},"operators":{"texts":873,"translated":585,"official":585,"pct":67},"skills":{"texts":5646,"translated":5110,"official":5110,"pct":90.5},"stages":{"texts":1124,"translated":901,"official":901,"pct":80.2},"talents":{"texts":5963,"translated":5368,"official":5368,"pct":90},"tokens":{"texts":456,"translated":433,"official":433,"pct":95},"traits":{"texts":1870,"translated":1854,"official":1854,"pct":99.1}},"totals":{"texts":23794,"translated":21735,"official":21716,"pct":91.3}},"names":{"\"炎佑\"":"“炎佑”","Dor-1号失败品":"Dor-1號失敗品","Dor-α号失败品":"Dor-α號失敗品","Dor-β号复制体":"Dor-β號複製體","M3茧甲":"M3繭甲","W·悬赏":"W·懸賞","“变形者集群”":"“變形者集群”","“墓碑”·悬赏":"“墓碑”·懸賞","“复仇者”":"“復仇者”","“复仇者”·悬赏":"“復仇者”·懸賞","“巨大的丑东西”":"“巨大的醜東西”","“巨大的丑东西”·悬赏":"“巨大的醜東西”·懸賞","“帝国的甲胄”":"“帝國的甲胄”","“庞贝”":"“龐貝”","“庞贝”·悬赏":"“龐貝”·懸賞","“斩胄之剑”":"“斬胄之劍”","“灵幛”":"“靈幛”","“独轮车玩具”":"“獨輪車玩具”","“破胄之锤”":"“破胄之錘”","“碎铳之簧”":"“碎銃之簧”","“神秘顾客”":"“神秘顧客”","“耀阳”":"“耀陽”","“自在”·悬赏":"“自在”·懸賞","“萨科塔之眼”":"“薩科塔之眼”","“萨科塔之眼”·悬赏":"“薩科塔之眼”·懸賞","“萨科塔之翼”":"“薩科塔之翼”","“萨科塔昂首”":"“薩科塔昂首”","“萨米的意志”":"“薩米的意志”","“裂管之音”/“断弦之音”":"“裂管之音”/“斷弦之音”","“越长尘”":"“越長塵”","“遗弃者”":"“遺棄者”","“遗弃者”·悬赏":"“遺棄者”·懸賞","“邪魔的利刃”·悬赏":"“邪魔的利刃”·懸賞","“鼠王”·悬赏":"“鼠王”·懸賞","不屈弹射器":"不屈彈射器","不稳定要素":"不穩定要素","业余竞演者":"業餘競演者","业务指标":"業務指標","临时收音师":"臨時收音師","主角阵营角色":"主角陣營角色","乌尔比安":"烏爾比安","乌顶巨角卢鲁":"烏頂巨角盧魯","九流之缘":"九流之緣","人事部文档":"人事部文件","从不混淆的方向":"從不混淆的方向","以己之长":"以己之長","伊内丝":"伊內絲","伊利昂的木驮兽":"伊利昂的木馱獸","伊奥莱塔·罗素":"伊奧萊塔·羅素","休谟斯":"休謨斯","休露丝":"休露絲","众志合一":"眾志合一","优等生":"優等生","传奇重犯":"傳奇重犯","伪装服":"偽裝服","余":"餘","保护目标(冻结状态)":"保護目標(凍結狀態)","信仰搅拌机":"信仰攪拌機","信标":"信標","假想敌:再生":"假想敵:再生","假想敌:弦":"假想敵:弦","假想敌:淤困":"假想敵:淤困","假想敌:管":"假想敵:管","假想敌:胄":"假想敵:胄","假想敌:蚀裂":"假想敵:蝕裂","假想敌:铳":"假想敵:銃","假想敌:镜膜":"假想敵:鏡膜","假想敌:骨刺":"假想敵:骨刺","假想敌:黑云":"假想敵:黑雲","假日威龙陈":"假日威龍陳","催泪瓦斯":"催淚瓦斯","元核孽生者·悬赏":"元核孽生者·懸賞","克莱门莎":"克萊門莎","军用猎狗":"軍用獵狗","冰爆源石虫":"冰爆源石蟲","冲击式施术单元":"衝擊式施術單元","凋零骑士":"凋零騎士","凋零骑士·悬赏":"凋零騎士·懸賞","凛御银灰":"凜御銀灰","凯尔希":"凱爾希","凯瑟琳":"凱瑟琳","列装":"列裝","利滚利":"利滾利","刺胄之弹":"刺胄之彈","加急调派":"緊急調派","加练!":"加練!","加速维式重锤":"加速維式重鎚","医疗探机":"醫療探機","升华":"昇華","华法琳":"華法琳","协Line truncated
|
||||||
+1
-1
@@ -1 +1 @@
|
|||||||
{"token_10000_silent_healrb":{"tokenId":"token_10000_silent_healrb","kind":"summon","name":"医疗探机","appellation":"Medical Drone","desc":"不会受到攻击,恢复周围友方单位的生命","descRaw":"不会受到攻击,恢复周围友方单位的生命","profession":"TOKEN","subProfessionId":"notchar1","position":"ALL","displayType":"DEFAULT","placeable":true,"ownerRange":false,"owners":["chess_char_2_02_a","chess_char_2_02_b"],"stats":{"maxHp":1000,"atk":85,"def":0,"res":0,"cost":5,"blockCnt":0,"bat":0.5,"aspd":100,"respawnTime":5,"spRecovery":1,"hpRecoveryPerSec":0,"moveSpeed":1,"tauntLevel":0,"massLevel":0,"deployLimit":1,"deckStack":0},"rangeGrid":[[1,-1],[1,0],[1,1],[0,-1],[0,0],[0,1],[-1,-1],[-1,0],[-1,1]],"dmgType":"heal","attackKind":"heal","projectile":"orb","canHitFly":false,"skill":{"skillId":"skcom_withdraw","bb":{}},"deployLimit":1,"count":1,"abnormal":[],"variants":{"chess_char_2_02_a":{"phase":1,"level":60,"stats":{"maxHp":1000,"atk":85,"def":0,"res":0,"cost":5,"blockCnt":0,"bat":0.5,"aspd":100,"respawnTime":5,"spRecovery":1,"hpRecoveryPerSec":0,"moveSpeed":1,"tauntLevel":0,"massLevel":0,"deployLimit":1,"deckStack":0},"immunities":{"stun":false,"silence":false,"sleep":false,"frozen":false,"levitate":false},"rangeGrid":[[1,-1],[1,0],[1,1],[0,-1],[0,0],[0,1],[-1,-1],[-1,0],[-1,1]],"trait":{"desc":"不会受到攻击,恢复周围友方单位的生命","descRaw":"不会受到攻击,恢复周围友方单位的生命","bb":{},"bbStr":{}},"dmgType":"heal","attackKind":"heal","projectile":"orb","canHitFly":false,"targetPriority":null,"skill":{"skillId":"skcom_withdraw","iconId":"skcom_withdraw","name":"自我销毁","level":1,"desc":"10秒后自动销毁","descRaw":"<@ba.vup>10</>秒后自动销毁","skillType":"PASSIVE","durationType":"NONE","duration":10,"spType":"ON_DEPLOY","spCost":0,"initSp":0,"maxChargeTime":0,"increment":0,"bb":{},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skcom_withdraw","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":1},"talents":[],"count":1,"sources":["skill","display"],"bySkill":{"0":{"skill":{"skillId":"skcom_withdraw","iconId":"skcom_withdraw","name":"自我销毁","level":1,"desc":"10秒后自动销毁","descRaw":"<@ba.vup>10</>秒后自动销毁","skillType":"PASSIVE","durationType":"NONE","duration":10,"spType":"ON_DEPLOY","spCost":0,"initSp":0,"maxChargeTime":0,"increment":0,"bb":{},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skcom_withdraw","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":0},"count":null,"sources":["display"]}}},"chess_char_2_02_b":{"phase":2,"level":55,"stats":{"maxHp":1000,"atk":114,"def":0,"res":0,"cost":5,"blockCnt":0,"bat":0.5,"aspd":100,"respawnTime":5,"spRecovery":1,"hpRecoveryPerSec":0,"moveSpeed":1,"tauntLevel":0,"massLevel":0,"deployLimit":1,"deckStack":0},"immunities":{"stun":false,"silence":false,"sleep":false,"frozen":false,"levitate":false},"rangeGrid":[[1,-1],[1,0],[1,1],[0,-1],[0,0],[0,1],[-1,-1],[-1,0],[-1,1]],"trait":{"desc":"不会受到攻击,恢复周围友方单位的生命","descRaw":"不会受到攻击,恢复周围友方单位的生命","bb":{},"bbStr":{}},"dmgType":"heal","attackKind":"heal","projectile":"orb","canHitFly":false,"targetPriority":null,"skill":{"skillId":"skcom_withdraw","iconId":"skcom_withdraw","name":"自我销毁","level":1,"desc":"10秒后自动销毁","descRaw":"<@ba.vup>10</>秒后自动销毁","skillType":"PASSIVE","durationType":"NONE","duration":10,"spType":"ON_DEPLOY","spCost":0,"initSp":0,"maxChargeTime":0,"increment":0,"bb":{},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skcom_withdraw","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":1},"talents":[],"count":1,"sources":["skill","display"],"bySkill":{"0":{"skill":{"skillId":"skcom_withdraw","iconId":"skcom_withdraw","name":"自我销毁","level":1,"desc":"10秒后自动销毁","descRaw":"<@ba.vup>10</>秒后自动销毁","skillType":"PASSIVE","durationType":"NONE","duration":10,"spType":"ON_DEPLOY","spCost":0,"initSp":0,"maxChargeTime":0,"increment":0,"bb":{},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skcom_withdraw","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":0},"count":null,"sources":["display"]}},"byModule":{"none":{"stats":{"maxHp":1000,"atk":114,"def":0,"res":0,"cost":5,"blockCnt":0,"bat":0.5,"aspd":100,"respawnTime":5,"spRecovery":1,"hpRecoveryPerSec":0,"moveSpeed":1,"tauntLevel":0,"massLevel":0,"deployLimit":1,"deckStack":0},"immunities":{"stun":false,"silence":false,"sleep":false,"frozen":false,"levitate":false},"trait":{"desc":"不会受到攻击,恢复周围友方单位的生命","descRaw":"不会受到攻击,恢复周围友方单位的生命","bb":{},"bbStr":{}},"talents":[]}}}},"assets":{"avatar":"token_10000_silent_healrb","spine":"token_10000_silent_healrb"}},"token_10006_vodfox_doll":{"tokenId":"token_10006_vodfox_doll","kind":"summon"Line truncated
|
{"token_10000_silent_healrb":{"tokenId":"token_10000_silent_healrb","kind":"summon","name":"医疗探机","appellation":"Medical Drone","desc":"不会受到攻击,恢复周围友方单位的生命","descRaw":"不会受到攻击,恢复周围友方单位的生命","profession":"TOKEN","subProfessionId":"notchar1","position":"ALL","displayType":"DEFAULT","placeable":true,"ownerRange":false,"owners":["chess_char_2_02_a","chess_char_2_02_b"],"stats":{"maxHp":1000,"atk":85,"def":0,"res":0,"cost":5,"blockCnt":0,"bat":0.5,"aspd":100,"respawnTime":5,"spRecovery":1,"hpRecoveryPerSec":0,"moveSpeed":1,"tauntLevel":0,"massLevel":0,"deployLimit":1,"deckStack":0},"rangeGrid":[[1,-1],[1,0],[1,1],[0,-1],[0,0],[0,1],[-1,-1],[-1,0],[-1,1]],"dmgType":"heal","attackKind":"heal","projectile":"orb","canHitFly":false,"skill":{"skillId":"skcom_withdraw","bb":{}},"deployLimit":1,"count":1,"abnormal":[],"variants":{"chess_char_2_02_a":{"phase":1,"level":60,"stats":{"maxHp":1000,"atk":85,"def":0,"res":0,"cost":5,"blockCnt":0,"bat":0.5,"aspd":100,"respawnTime":5,"spRecovery":1,"hpRecoveryPerSec":0,"moveSpeed":1,"tauntLevel":0,"massLevel":0,"deployLimit":1,"deckStack":0},"immunities":{"stun":false,"silence":false,"sleep":false,"frozen":false,"levitate":false},"rangeGrid":[[1,-1],[1,0],[1,1],[0,-1],[0,0],[0,1],[-1,-1],[-1,0],[-1,1]],"trait":{"desc":"不会受到攻击,恢复周围友方单位的生命","descRaw":"不会受到攻击,恢复周围友方单位的生命","bb":{},"bbStr":{}},"dmgType":"heal","attackKind":"heal","projectile":"orb","canHitFly":false,"targetPriority":null,"skill":{"skillId":"skcom_withdraw","iconId":"skcom_withdraw","name":"自我销毁","level":1,"desc":"10秒后自动销毁","descRaw":"<@ba.vup>10</>秒后自动销毁","skillType":"PASSIVE","durationType":"NONE","duration":10,"spType":"ON_DEPLOY","spCost":0,"initSp":0,"maxChargeTime":0,"increment":0,"bb":{},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skcom_withdraw","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":1},"talents":[],"count":1,"sources":["skill","display"],"bySkill":{"0":{"skill":{"skillId":"skcom_withdraw","iconId":"skcom_withdraw","name":"自我销毁","level":1,"desc":"10秒后自动销毁","descRaw":"<@ba.vup>10</>秒后自动销毁","skillType":"PASSIVE","durationType":"NONE","duration":10,"spType":"ON_DEPLOY","spCost":0,"initSp":0,"maxChargeTime":0,"increment":0,"bb":{},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skcom_withdraw","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":0},"count":null,"sources":["display"]}}},"chess_char_2_02_b":{"phase":2,"level":55,"stats":{"maxHp":1000,"atk":114,"def":0,"res":0,"cost":5,"blockCnt":0,"bat":0.5,"aspd":100,"respawnTime":5,"spRecovery":1,"hpRecoveryPerSec":0,"moveSpeed":1,"tauntLevel":0,"massLevel":0,"deployLimit":1,"deckStack":0},"immunities":{"stun":false,"silence":false,"sleep":false,"frozen":false,"levitate":false},"rangeGrid":[[1,-1],[1,0],[1,1],[0,-1],[0,0],[0,1],[-1,-1],[-1,0],[-1,1]],"trait":{"desc":"不会受到攻击,恢复周围友方单位的生命","descRaw":"不会受到攻击,恢复周围友方单位的生命","bb":{},"bbStr":{}},"dmgType":"heal","attackKind":"heal","projectile":"orb","canHitFly":false,"targetPriority":null,"skill":{"skillId":"skcom_withdraw","iconId":"skcom_withdraw","name":"自我销毁","level":1,"desc":"10秒后自动销毁","descRaw":"<@ba.vup>10</>秒后自动销毁","skillType":"PASSIVE","durationType":"NONE","duration":10,"spType":"ON_DEPLOY","spCost":0,"initSp":0,"maxChargeTime":0,"increment":0,"bb":{},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skcom_withdraw","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":1},"talents":[],"count":1,"sources":["skill","display"],"bySkill":{"0":{"skill":{"skillId":"skcom_withdraw","iconId":"skcom_withdraw","name":"自我销毁","level":1,"desc":"10秒后自动销毁","descRaw":"<@ba.vup>10</>秒后自动销毁","skillType":"PASSIVE","durationType":"NONE","duration":10,"spType":"ON_DEPLOY","spCost":0,"initSp":0,"maxChargeTime":0,"increment":0,"bb":{},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skcom_withdraw","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":0},"count":null,"sources":["display"]}},"byModule":{"none":{"stats":{"maxHp":1000,"atk":114,"def":0,"res":0,"cost":5,"blockCnt":0,"bat":0.5,"aspd":100,"respawnTime":5,"spRecovery":1,"hpRecoveryPerSec":0,"moveSpeed":1,"tauntLevel":0,"massLevel":0,"deployLimit":1,"deckStack":0},"immunities":{"stun":false,"silence":false,"sleep":false,"frozen":false,"levitate":false},"trait":{"desc":"不会受到攻击,恢复周围友方单位的生命","descRaw":"不会受到攻击,恢复周围友方单位的生命","bb":{},"bbStr":{}},"talents":[]}}}},"assets":{"avatar":"token_10000_silent_healrb","spine":"token_10000_silent_healrb"}},"token_10006_vodfox_doll":{"tokenId":"token_10006_vodfox_doll","kind":"summon"Line truncated
|
||||||
@@ -103,7 +103,7 @@ so old imports keep working: `public/js/ui/gameLogic.js` (`public/js/ui/gameLogi
|
|||||||
| `public/index.html`, `public/js/main.js` | the page and its entry: boot, the router (title → lobby → room → game) |
|
| `public/index.html`, `public/js/main.js` | the page and its entry: boot, the router (title → lobby → room → game) |
|
||||||
| `public/js/net.js`, `public/js/store.js`, `public/js/data.js` | the socket client, the observable store, the data loader (`/data/*.json`, with the English overlay) |
|
| `public/js/net.js`, `public/js/store.js`, `public/js/data.js` | the socket client, the observable store, the data loader (`/data/*.json`, with the English overlay) |
|
||||||
| `public/js/battle/` | `runner.js` (the local battle: loads `/sim/`, steps it, reports), `observe.js` (who may watch which field) |
|
| `public/js/battle/` | `runner.js` (the local battle: loads `/sim/`, steps it, reports), `observe.js` (who may watch which field) |
|
||||||
| `public/js/screens/` | `title.js`, `lobby.js`, `room.js`, `loadout.js` (干员调配), `ownership.js` (干员持有), `diy.js` (自选编队), `briefing.js`, `bandDraft.js`, `game.js` with `public/js/screens/game/`, `result.js` |
|
| `public/js/screens/` | `title.js`, `lobby.js`, `room.js`, `loadout.js` (干员调配), `cultivation.js` (its 潜能 / 练度 controls), `ownership.js` (干员持有), `diy.js` (自选编队), `briefing.js`, `bandDraft.js`, `game.js` with `public/js/screens/game/`, `result.js` |
|
||||||
| `public/js/ui/` | the HUD components (`hud.js`, `shopBar.js`, `detailPanel.js`, `bondStrip.js`, `teamPanel.js` …); `public/js/ui/gameLogic/` the pure in-match logic, unit-tested in Node |
|
| `public/js/ui/` | the HUD components (`hud.js`, `shopBar.js`, `detailPanel.js`, `bondStrip.js`, `teamPanel.js` …); `public/js/ui/gameLogic/` the pure in-match logic, unit-tested in Node |
|
||||||
| `public/js/render/` | the field view: `app.js` with `public/js/render/app/`, `units.js` and `spine.js` (models), `tiles.js`, `projection.js`, `interp.js`, `pick.js`, `drag.js`, `public/js/render/fx/` (effects; `kinds.js` maps the fx kinds), `public/js/render/board3d/` (the official 3D board) |
|
| `public/js/render/` | the field view: `app.js` with `public/js/render/app/`, `units.js` and `spine.js` (models), `tiles.js`, `projection.js`, `interp.js`, `pick.js`, `drag.js`, `public/js/render/fx/` (effects; `kinds.js` maps the fx kinds), `public/js/render/board3d/` (the official 3D board) |
|
||||||
| `public/css/`, `public/i18n/<code>.json` | the styles; the UI strings of each language pack (English ships) |
|
| `public/css/`, `public/i18n/<code>.json` | the styles; the UI strings of each language pack (English ships) |
|
||||||
|
|||||||
+37
-11
@@ -164,7 +164,7 @@ Top level: `{ season, seasonName, modes, economy, lpCapPerRound, bossOvertimeAft
|
|||||||
| `price`, `sellPrice` | `2`, `1` | |
|
| `price`, `sellPrice` | `2`, `1` | |
|
||||||
| `upgradeNum`, `upgradeChessId` | `3`, `"chess_char_1_01_b"` | copies needed to merge (风丸 2; golden 0) |
|
| `upgradeNum`, `upgradeChessId` | `3`, `"chess_char_1_01_b"` | copies needed to merge (风丸 2; golden 0) |
|
||||||
| `status` | `{"phase":2,"level":50,"skillLevel":7,"equipLevel":1}` | training status used for all numbers |
|
| `status` | `{"phase":2,"level":50,"skillLevel":7,"equipLevel":1}` | training status used for all numbers |
|
||||||
| `stats` | `{"maxHp":1421,"atk":531,"def":167,"res":0,"cost":12,"blockCnt":1,"bat":1,"aspd":100,"respawnTime":70,"spRecovery":1,"hpRecoveryPerSec":0,"moveSpeed":1,"tauntLevel":0,"massLevel":0,"deployLimit":1,"deckStack":0}` | keyframes linearly interpolated at `status` **plus every potential attribute modifier** (full potential, 潜能 6: character_table `potentialRanks` 1–5 — 「攻击力+N」, 「部署费用-1」, 「再部署时间-N秒」, 生命 / 防御 / 法抗 / 攻速 — added to the raw values; the owner's decision of 2026-10-07, GitHub #252 / PR #255: the official records carry no potential, a tournament video shows 刺玫 at ATK 435 / cost 15 = her E1 Lv55 413 / 17 + 攻击力+22 and two 部署费用-1; `tools/build-data.mjs OPERATOR_POTENTIAL`), hp/atk/def/cost/block/respawn rounded; **golden includes the module attribute bonus**. `aspd` 100 = base (module attack_speed added). Talent stat boosts are NOT folded in. |
|
| `stats` | `{"maxHp":1421,"atk":531,"def":167,"res":0,"cost":12,"blockCnt":1,"bat":1,"aspd":100,"respawnTime":70,"spRecovery":1,"hpRecoveryPerSec":0,"moveSpeed":1,"tauntLevel":0,"massLevel":0,"deployLimit":1,"deckStack":0}` | keyframes linearly interpolated at `status` **plus every potential attribute modifier** (full potential, 潜能 6 — the default; character_table `potentialRanks` 1–5 — 「攻击力+N」, 「部署费用-1」, 「再部署时间-N秒」, 生命 / 防御 / 法抗 / 攻速 — added to the raw values: the official records carry no potential, a tournament video shows 刺玫 at ATK 435 / cost 15 = her E1 Lv55 413 / 17 + 攻击力+22 and two 部署费用-1; a lower potential — the player's 干员调配 setting since 0.2.2 — reads `potDown`, §2.3), hp/atk/def/cost/block/respawn rounded; **golden includes the module attribute bonus**. `aspd` 100 = base (module attack_speed added). Talent stat boosts are NOT folded in. |
|
||||||
| `immunities` | `{"stun":false,"silence":false,"sleep":false,"frozen":false,"levitate":false}` | |
|
| `immunities` | `{"stun":false,"silence":false,"sleep":false,"frozen":false,"levitate":false}` | |
|
||||||
| `rangeId`, `rangeGrid` | `"3-3"`, `[[1,0],[1,1],…]` | attack range at `status` |
|
| `rangeId`, `rangeGrid` | `"3-3"`, `[[1,0],[1,1],…]` | attack range at `status` |
|
||||||
| `dmgType` | `"phys"` | `phys` / `arts` / `heal` (derived, see §2.1) |
|
| `dmgType` | `"phys"` | `phys` / `arts` / `heal` (derived, see §2.1) |
|
||||||
@@ -172,15 +172,16 @@ Top level: `{ season, seasonName, modes, economy, lpCapPerRound, bossOvertimeAft
|
|||||||
| `projectile` | `"arrow"` | `arrow` (phys ranged) / `bolt` (arts ranged) / `orb` (heal) / `none` |
|
| `projectile` | `"arrow"` | `arrow` (phys ranged) / `bolt` (arts ranged) / `orb` (heal) / `none` |
|
||||||
| `canHitFly` | `true` | |
|
| `canHitFly` | `true` | |
|
||||||
| `targetPriority` | `"fly"` | from trait text: `fly` (优先攻击空中单位), `lowestDef` (防御力最低), else `null` |
|
| `targetPriority` | `"fly"` | from trait text: `fly` (优先攻击空中单位), `lowestDef` (防御力最低), else `null` |
|
||||||
| `trait` | `{"desc":"优先攻击空中单位","descRaw":"…","bb":{"atk_scale":1.1},"bbStr":{},"rangeGrid":null,"moduleDesc":"攻击空中单位时攻击力提升至110%","moduleDescRaw":"…"}` | profession trait (+ golden module trait upgrade merged into `bb`; `rangeGrid` = trait-effect area, e.g. 散射手 front row — **not** the attack range). `desc` = the class trait, or the module's rewrite of it (official `overrideDescripton`); `moduleDesc` = the module's added line (official `additionalDescription`, PRTS 「特性追加」), which the client shows after `desc`, never alone (0.2.0, community report item 16.2) |
|
| `trait` | `{"desc":"优先攻击空中单位","descRaw":"…","bb":{"atk_scale":1.1},"bbStr":{},"rangeGrid":null,"moduleDesc":"攻击空中单位时攻击力提升至110%","moduleDescRaw":"…"}` | profession trait (+ golden module trait upgrade merged into `bb`; `rangeGrid` = trait-effect area, e.g. 散射手 front row — **not** the attack range). `desc` = the class trait, or the module's rewrite of it (official `overrideDescripton`); `moduleDesc` = the module's added line (official `additionalDescription`, PRTS 「特性追加」), which the client shows after `desc`, never alone (0.2.0, community report item 16.2). A module part that only adds a display line (official target `DISPLAY`, no rewrite) fills that line with its blackboard and leaves `desc` at the class trait's numbers — 圣约送葬人 REA-Y: `desc` heals 50, `moduleDesc` 攻击速度+12 (0.2.2, GitHub #400); `bb` still merges every part (what the kits read) |
|
||||||
| `skill` | see below | default skill at `status.skillLevel` (normal 4, golden 7) |
|
| `skill` | see below | default skill at `status.skillLevel` (normal 4, golden 7) |
|
||||||
| `skills[]` | `[{…skill record…, "index":0, "isDefault":false}, {…, "index":1, "isDefault":true}]` | **loadout choices** (DESIGN §16): every skill unlocked at `status` (E1 ⇒ S1–S2, E2 ⇒ S1–S3; the default is always listed), same shape as `skill` + `isDefault`, at the chess skill level, `trigger` resolved **for that skill index** (§2.2). The `isDefault` entry equals `skill` |
|
| `skills[]` | `[{…skill record…, "index":0, "isDefault":false}, {…, "index":1, "isDefault":true}]` | **loadout choices** (DESIGN §16): every skill unlocked at `status` (E1 ⇒ S1–S2, E2 ⇒ S1–S3; the default is always listed), same shape as `skill` + `isDefault`, at the chess skill level, `trigger` resolved **for that skill index** (§2.2). The `isDefault` entry equals `skill` |
|
||||||
| `modules[]`, `statsBase`, `traitBase`, `talentsBase` | see §2.2 | golden chess with `equipLevel > 0` only: selectable modules + the no-module base they apply to |
|
| `modules[]`, `statsBase`, `traitBase`, `talentsBase` | see §2.2 | golden chess with `equipLevel > 0` only: selectable modules + the no-module base they apply to |
|
||||||
| `talents[]` | `{"index":0,"name":"火力支援","desc":"…","descRaw":"…","bb":{"self_ammo":3,"duration":20,"ally_ammo":1},"bbStr":{},"rangeGrid":null,"tokenKey":null,"hidden":false,"fromModule":false}` | best unlocked candidate at `status` at full potential (candidates up to `requiredPotentialRank` 5: the 「天赋效果增强」 steps; module talent candidates the same); golden: module talent upgrades applied (`fromModule`); module data-only talents have `name:null, hidden:true`. A module upgrade of an existing talent **merges** blackboards (module keys win, base keys it does not restate are kept — e.g. 宴 keeps `min_attack_speed`; 仇白's upgrade adds `atk_scale_t` next to the old `atk_scale`: prefer the key the text uses). Module parts flagged `isToken` are **not** applied to the operator; they upgrade its summons (tokens.json variants). `containerTokenKey`: the official talent token id when it is a container missing from character_table (凛御银灰), `tokenKey` then holds the default skill's token |
|
| `talents[]` | `{"index":0,"name":"火力支援","desc":"…","descRaw":"…","bb":{"self_ammo":3,"duration":20,"ally_ammo":1},"bbStr":{},"rangeGrid":null,"tokenKey":null,"hidden":false,"fromModule":false}` | best unlocked candidate at `status` at full potential (candidates up to `requiredPotentialRank` 5: the 「天赋效果增强」 steps; module talent candidates the same — an entry a lower potential changes chains its lower candidates, `potMin` / `potBelow`, §2.3); golden: module talent upgrades applied (`fromModule`); module data-only talents have `name:null, hidden:true`. A module upgrade of an existing talent **merges** blackboards (module keys win, base keys it does not restate are kept — e.g. 宴 keeps `min_attack_speed`; 仇白's upgrade adds `atk_scale_t` next to the old `atk_scale`: prefer the key the text uses). Module parts flagged `isToken` are **not** applied to the operator; they upgrade its summons (tokens.json variants). `containerTokenKey`: the official talent token id when it is a container missing from character_table (凛御银灰), `tokenKey` then holds the default skill's token |
|
||||||
| `tokens[]` | `["token_10028_vigil_wolf"]` | summons (→ `tokens.json`): displayTokenDict + default-skill `overrideTokenKey` + talent `tokenKey`; `tokens.json → variants[chessId].sources` tells which (a `display`-only token is not produced by this chess's default skill or talents) |
|
| `tokens[]` | `["token_10028_vigil_wolf"]` | summons (→ `tokens.json`): displayTokenDict + default-skill `overrideTokenKey` + talent `tokenKey`; `tokens.json → variants[chessId].sources` tells which (a `display`-only token is not produced by this chess's default skill or talents) |
|
||||||
| `module` | `{"id":"uniequip_002_inside","name":"“最初的惊喜”","type":"MAR-X","level":1,"active":true}` | active only on golden chess |
|
| `module` | `{"id":"uniequip_002_inside","name":"“最初的惊喜”","type":"MAR-X","level":1,"active":true}` | active only on golden chess |
|
||||||
| `assets` | `{"avatar":"char_498_inside_2","portrait":"char_498_inside_2","spine":"char_498_inside","skillIcon":"skchr_inside_2","subProfIcon":"sub_fastshot_icon"}` | asset **ids** (URLs in `data/assets.json`); golden uses the E2 art when it exists |
|
| `assets` | `{"avatar":"char_498_inside_2","portrait":"char_498_inside_2","spine":"char_498_inside","skillIcon":"skchr_inside_2","subProfIcon":"sub_fastshot_icon"}` | asset **ids** (URLs in `data/assets.json`); golden uses the E2 art when it exists |
|
||||||
| `diyRequirement` | `"TIER_6"` | DIY only |
|
| `diyRequirement` | `"TIER_6"` | DIY only |
|
||||||
|
| `potDown` | `{"0":{"stats.cost":17},"1":{"stats.respawnTime":70},"2":{"stats.atk":413},"4":{"stats.cost":16}}` (`chess_char_1_06_a`, 刺玫) | what a lower potential changes (§2.3; absent when nothing does: the DIY slots, 盟约·辅助干员) |
|
||||||
|
|
||||||
`skill`:
|
`skill`:
|
||||||
|
|
||||||
@@ -245,6 +246,30 @@ everything needed to resolve a unit for `(chessId, skillIndex, moduleId)` (`simd
|
|||||||
variants[owner].byModule` (§14). Module choices never change the combat classification (`dmgType`…; the build warns
|
variants[owner].byModule` (§14). Module choices never change the combat classification (`dmgType`…; the build warns
|
||||||
if one ever would).
|
if one ever would).
|
||||||
|
|
||||||
|
### 2.3 Potential below full potential: `potDown` and chained talents (0.2.2)
|
||||||
|
|
||||||
|
Potential (潜能 1–6) is the player's per-operator setting (干员调配, the owner's decision of 2026-10-08 — default 潜能 6;
|
||||||
|
official: 调度手册 「卫戍协议中干员潜能由自身已持有干员潜能决定」). Every record is built at **full potential** and carries
|
||||||
|
what each lower one changes; `shared/potential.js atPotential(rec, potential)` composes the record at a potential
|
||||||
|
(data rank r = potential − 1, character_table's `potentialRanks[r]` being the step from rank r to r + 1). The same
|
||||||
|
annotation is on chess records, `backups.json` unit forms (§18) and token variants (`tokens.json` and `backups.json`
|
||||||
|
`tokens` variants, §14 — the owner's potential; a token record's top-level defaults stay full):
|
||||||
|
|
||||||
|
- `potDown`: `{ "<rank>": { "<dotted.path>": value } }` — for each rank 0–4, the exact value at that rank of every leaf
|
||||||
|
that differs from the rank above (`stats.*` / `statsBase.*` of a record, `stats.*` of a form; a variant's
|
||||||
|
`stats.deployLimit`, `stats.deckStack`, `count`, `bySkill.<i>.count`, `byModule.<id>.stats.*`). Applied from rank 4
|
||||||
|
down to the wanted rank, each value overwriting, so every leaf ends at its value for that rank.
|
||||||
|
- a talent entry (`talents`, `talentsBase`, `modules[].talentChanges`; a variant's `talents` and `byModule[*].talents`)
|
||||||
|
that a lower rank changes: `potMin` = the lowest rank it holds for, `potBelow` = the fields the entry below it
|
||||||
|
changes (`desc`, `descRaw`, `bb` …), itself chained the same way — the candidates by `requiredPotentialRank`
|
||||||
|
(刺玫's 土壤基肥改良 `potMin` 4: 11% → 8%; Mon3tr's 不毁重构 below its owner's rank 4: 3 s / 1200).
|
||||||
|
|
||||||
|
`tools/build-data.mjs` runs the chess / backups / tokens builders at every rank (`ctx.potRank`) and annotates the
|
||||||
|
full-rank build from the others; the build fails unless `atRank` rebuilds every rank exactly, and the unit-form parity
|
||||||
|
(§18) holds at every rank. Nothing else depends on potential (no skill, trait or list length). The data translations
|
||||||
|
(`data/i18n/*.json`) translate the chained texts too. 练度 (the 自持有 bonus) is no record field: it is the
|
||||||
|
`char_attribute_mul` of the effects.json CHAR_MAP records `aceffect_char_1…4` (§7), applied to the unit.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. `bonds.json` — `{ [bondId]: Bond }` (23 bonds, 8 core)
|
## 3. `bonds.json` — `{ [bondId]: Bond }` (23 bonds, 8 core)
|
||||||
@@ -320,7 +345,7 @@ everything needed to resolve a unit for `(chessId, skillIndex, moduleId)` (`simd
|
|||||||
|
|
||||||
## 7. `effects.json` — `{ [effectId]: Effect }` (361: every effect of the season)
|
## 7. `effects.json` — `{ [effectId]: Effect }` (361: every effect of the season)
|
||||||
|
|
||||||
Types: `BAND_INITIAL` 41, `BOND` 23, `EQUIP` 115, `ENEMY_GAIN` 129 (悬赏), `BUFF_GAIN` 43 (战术决策), `ENEMY` 6 (`aceffect_enemy_*` global modifiers), `CHAR_MAP` 4.
|
Types: `BAND_INITIAL` 41, `BOND` 23, `EQUIP` 115, `ENEMY_GAIN` 129 (悬赏), `BUFF_GAIN` 43 (战术决策), `ENEMY` 6 (`aceffect_enemy_*` global modifiers), `CHAR_MAP` 4 (角色练度映射 — the 自持有 练度 tiers `aceffect_char_1…4`, 未精英化 / 精英阶段1 / 精英阶段2 / 精英阶段2-60级, in `autoChessData.cultivateEffectList` order: their `char_attribute_mul` buff — `atk` / `def` / `max_hp` 1, 1.05, 1.1 — is the unit's 练度 multiplier, `shared/potential.js cultivateMul`, 0.2.2).
|
||||||
|
|
||||||
| Field | Example | Meaning |
|
| Field | Example | Meaning |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
@@ -497,7 +522,7 @@ Glyph legend (`rows`):
|
|||||||
| `skill` | `{"skillId":"sktok_vigil_wolf_3","bb":{…}}` | default token skill (same slot as the owner's skill) |
|
| `skill` | `{"skillId":"sktok_vigil_wolf_3","bb":{…}}` | default token skill (same slot as the owner's skill) |
|
||||||
| `deployLimit`, `count` | `1`, `1` | `deployLimit` = the first owner's `stats.deployLimit`. `count` = copies sent to the hand / spawned (talent/skill `cnt`); `null` ⇒ use `deployLimit` |
|
| `deployLimit`, `count` | `1`, `1` | `deployLimit` = the first owner's `stats.deployLimit`. `count` = copies sent to the hand / spawned (talent/skill `cnt`); `null` ⇒ use `deployLimit` |
|
||||||
| `abnormal[]` | `["healFree"]` | abnormal effects the summon holds from the start, no official table carries them — `tools/build-data.mjs TOKEN_ABNORMAL` from the PRTS summon pages (user playtest #6 item 18): `healFree` = 禁疗 (“小自在”, “耀阳”, 斯卡蒂的海嗣, 沙之碑, 流形, 狼群, 迷迭香的战术装备, 黄金盟誓, 保护目标(冻结状态)), `isolated` = 孤立 "无法被同阵营选中" (“炎佑”, 从不混淆的方向); `[]` otherwise. The sim sets `noHeal` / `isolated` (docs/SIM.md §3) |
|
| `abnormal[]` | `["healFree"]` | abnormal effects the summon holds from the start, no official table carries them — `tools/build-data.mjs TOKEN_ABNORMAL` from the PRTS summon pages (user playtest #6 item 18): `healFree` = 禁疗 (“小自在”, “耀阳”, 斯卡蒂的海嗣, 沙之碑, 流形, 狼群, 迷迭香的战术装备, 黄金盟誓, 保护目标(冻结状态)), `isolated` = 孤立 "无法被同阵营选中" (“炎佑”, 从不混淆的方向); `[]` otherwise. The sim sets `noHeal` / `isolated` (docs/SIM.md §3) |
|
||||||
| `variants[ownerChessId]` | `{"phase":2,"level":1,"stats":{…},"immunities":{…},"rangeGrid":…,"trait":{…},"dmgType":…,"skill":{full skill record},"talents":[…],"count":1,"sources":["talent","display"]}` | stats at the owner's phase/level (clamped to the token's max level) — **none of the owner's potential attribute modifiers** (a token has no potential ranks) — + golden module `tokenAttributeBlackboard` + the token's own talent additions to `deployLimit` / `deckStack` (blackboard `max_deploy_count` / `max_deck_stack_cnt` — the hidden "TOKEN数" talent of 麦哲伦's / 令's summons and 白铁's devices, 夜莺's 幻影; a module token part of the same talent replaces it: `tools/build-data.mjs tokenTalentDeckBonus`, 0.2.0 — no tokens.json summon has one; PRTS 幻影 备注 "最大可部署数量为3", the owners' "最多同时部署3个"); its `talents` / `trait` candidates are picked at the **owner's potential** (full: their `requiredPotentialRank` mirrors the owner's 「天赋效果增强」 — 夕's “小自在” 18 层, 凯尔希's Mon3tr, 望's 棋子 +1 持有 / 部署; `tools/build-data.mjs OPERATOR_POTENTIAL`); the owner's module parts flagged `isToken` upgrade the variant's `trait` (+`moduleDesc`) and `talents` (伺夜's wolves, 缪尔赛思's 流形 `scale` 1, 浊心斯卡蒂's 海嗣 30 s, “耀阳” `atk_scale` 1.15). `sources` ⊆ `talent`/`skill`/`display`: how the owner produces it (`display` only = listed on the character but unused by its default skill/talents, e.g. 迷迭香 S2, 凛御银灰 eagle1/3). `count` = copies from a talent `cnt` or the default skill's `cnt` when that skill overrides this token; `null` ⇒ use `deployLimit` |
|
| `variants[ownerChessId]` | `{"phase":2,"level":1,"stats":{…},"immunities":{…},"rangeGrid":…,"trait":{…},"dmgType":…,"skill":{full skill record},"talents":[…],"count":1,"sources":["talent","display"]}` | stats at the owner's phase/level (clamped to the token's max level) — **none of the owner's potential attribute modifiers** (a token has no potential ranks) — + golden module `tokenAttributeBlackboard` + the token's own talent additions to `deployLimit` / `deckStack` (blackboard `max_deploy_count` / `max_deck_stack_cnt` — the hidden "TOKEN数" talent of 麦哲伦's / 令's summons and 白铁's devices, 夜莺's 幻影; a module token part of the same talent replaces it: `tools/build-data.mjs tokenTalentDeckBonus`, 0.2.0 — no tokens.json summon has one; PRTS 幻影 备注 "最大可部署数量为3", the owners' "最多同时部署3个"); its `talents` / `trait` candidates are picked at the **owner's potential** (full; their `requiredPotentialRank` mirrors the owner's 「天赋效果增强」 — 夕's “小自在” 18 层, 凯尔希's Mon3tr, 望's 棋子 +1 持有 / 部署 — and the variant chains the lower ones, §2.3: `atPotential(variant, ownerPotential)`); the owner's module parts flagged `isToken` upgrade the variant's `trait` (+`moduleDesc`) and `talents` (伺夜's wolves, 缪尔赛思's 流形 `scale` 1, 浊心斯卡蒂's 海嗣 30 s, “耀阳” `atk_scale` 1.15). `sources` ⊆ `talent`/`skill`/`display`: how the owner produces it (`display` only = listed on the character but unused by its default skill/talents, e.g. 迷迭香 S2, 凛御银灰 eagle1/3). `count` = copies from a talent `cnt` or the default skill's `cnt` when that skill overrides this token; `null` ⇒ use `deployLimit` |
|
||||||
| `variants[o].bySkill[i]` | `{"skill":{…},"count":1,"sources":["talent","display"]}` | owner loadout with the non-default skill index `i` (one entry per other selectable owner skill): the token skill of that slot (伺夜's wolves, 缪尔赛思's 流形, 凛御银灰's eagles…), the count and how the chess then produces it (`sources` may be `[]`: 风丸 S1 makes no 纸偶; 赫默 / 巫恋 S1 only `display` ⇒ no hand piece). The sim resolves them for an owner loadout: `simdata getToken(id, ownerChessId, loadout)` → `def.sources` / `def.count` |
|
| `variants[o].bySkill[i]` | `{"skill":{…},"count":1,"sources":["talent","display"]}` | owner loadout with the non-default skill index `i` (one entry per other selectable owner skill): the token skill of that slot (伺夜's wolves, 缪尔赛思's 流形, 凛御银灰's eagles…), the count and how the chess then produces it (`sources` may be `[]`: 风丸 S1 makes no 纸偶; 赫默 / 巫恋 S1 only `display` ⇒ no hand piece). The sim resolves them for an owner loadout: `simdata getToken(id, ownerChessId, loadout)` → `def.sources` / `def.count` |
|
||||||
| `variants[o].byModule[m]` | `{"stats":{…},"immunities":{…},"trait":{…},"talents":[…]}` | golden owner with another module `m` or `'none'`: the token as that module makes it (module `tokenAttributeBlackboard`, `isToken` trait/talent parts) |
|
| `variants[o].byModule[m]` | `{"stats":{…},"immunities":{…},"trait":{…},"talents":[…]}` | golden owner with another module `m` or `'none'`: the token as that module makes it (module `tokenAttributeBlackboard`, `isToken` trait/talent parts) |
|
||||||
| `assets` | `{"avatar":"token_10028_vigil_wolf","spine":"token_10028_vigil_wolf"}` | |
|
| `assets` | `{"avatar":"token_10028_vigil_wolf","spine":"token_10028_vigil_wolf"}` | |
|
||||||
@@ -577,7 +602,7 @@ Glyph legend (`rows`):
|
|||||||
|
|
||||||
`chess 266 (112 visible; 283 selectable skills over the visible chess, 184 module choices over 129 goldens; 74 PRESET / 55 NORMAL / 4 DIY base chess)`, `bonds 23`, `garrisons 249 (43 effect keys)`, `items 115`, `bands 40`, `effects 361`,
|
`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: 88 units (17 stand-ins, 71 owned-6★ picks; 256 forms), 38 自选 summons, 4 DIY slots, 15 / 9 prototype picks (tier 5 / 6), 71 owned-6★ picks (7 collab operators excluded)`.
|
`bounty cards 129`, `tactic cards 43`, `backups: 88 units (17 stand-ins, 71 owned-6★ picks; 256 forms), 38 自选 summons, 4 DIY slots, 15 / 9 prototype picks (tier 5 / 6), 71 owned-6★ picks (7 collab operators excluded)`, potential (§2.3): `256 chess records, 219 forms / variants with potDown; 442 + 401 + 19 chained talent entries (chess, backups, tokens)`.
|
||||||
|
|
||||||
## 17. Integrity guarantees (checked by the builder and `test/data.test.js`)
|
## 17. Integrity guarantees (checked by the builder and `test/data.test.js`)
|
||||||
|
|
||||||
@@ -626,8 +651,8 @@ server/match/player/diy.js, docs/META.md §3). The rules in the data (activity_t
|
|||||||
- A stand-in keeps the chess's bonds, 特质, tier, price, merge and status (the elite form uses the same backup at the
|
- A stand-in keeps the chess's bonds, 特质, tier, price, merge and status (the elite form uses the same backup at the
|
||||||
elite row) and fights as `backup.charId` at that status with skill `backup.skillIndex` (fixed by the chess: the same
|
elite row) and fights as `backup.charId` at that status with skill `backup.skillIndex` (fixed by the chess: the same
|
||||||
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 the row's potential (`potRank` 0 on all 55) — moot: every unit form is built at
|
even their elite form has none) and the row's potential (`potRank` 0 on all 55) — moot: the 17 原型干员 have no
|
||||||
full potential like a chess (the owner's decision of 2026-10-07), and the 17 原型干员 have no potential ranks.
|
potential ranks, and a stand-in takes neither the player's potential nor the 自持有 练度 (0.2.2).
|
||||||
|
|
||||||
`units[charId]` — first the 17 stand-ins, then the 71 owned-6★ 自选 picks (`diy.ownedPool`); no unit for a PRESET or DIY
|
`units[charId]` — first the 17 stand-ins, then the 71 owned-6★ 自选 picks (`diy.ownedPool`); no unit for a PRESET or DIY
|
||||||
chess:
|
chess:
|
||||||
@@ -641,7 +666,7 @@ chess:
|
|||||||
| `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★ |
|
| `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, at full potential as on a chess; `immunities`, `rangeId`, `rangeGrid`, `dmgType`, `attackKind`, `projectile`, `canHitFly`,
|
**without** a module, at full potential as on a chess (`potDown` / chained talents for the lower ones, §2.3); `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 stand-ins has one; 27 of the owned picks do, their
|
`isDefault`; `displayTokens` / `tokens` (summons — none of the 17 stand-ins has one; 27 of the owned picks do, their
|
||||||
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` +
|
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` +
|
||||||
@@ -708,5 +733,6 @@ loadout. No id is also a tokens.json record.
|
|||||||
|
|
||||||
Not in this data: a player's ownership roster and 自选 picks (browser settings sent with `room.ownership` / `room.diy`),
|
Not in this data: a player's ownership roster and 自选 picks (browser settings sent with `room.ownership` / `room.diy`),
|
||||||
the DIY stock (per player in the match, `PlayerState.diyStock`: `config.economy.poolCopies` of the slot's tier — no excel
|
the DIY stock (per player in the match, `PlayerState.diyStock`: `config.economy.poolCopies` of the slot's tier — no excel
|
||||||
field), 助战 borrows (`borrowCount` 20), the owned-operator training bonus (`prepareStateDict`), a per-player potential (every
|
field), 助战 borrows (`borrowCount` 20), and a player's potential / 练度 per operator (the 干员调配 settings sent with
|
||||||
form is built at full potential, as every chess: the owner's decision of 2026-10-07).
|
`room.loadout`; the forms are built at full potential and carry the lower ranks, §2.3; 练度 = effects.json
|
||||||
|
`aceffect_char_1…4`).
|
||||||
+4
-3
@@ -63,8 +63,9 @@
|
|||||||
- **位置上限**:棋盘最多部署 **8** 名;整备区 **10** 格。整备区满时**不能购买或领取**任何干员、装备(包括能凑齐 3 名合成精锐的那一张,和官方一样),先出售、部署或配发腾出格子;新获得的单位(合成的精锐、奖励、退回的装备等)进入**临时整备区**(整备区前面的 5 格),整备区一空出格子,临时整备区的单位就**自动补进去**(从右往左)。临时整备区有单位时**不能准备就绪**,棋盘上会用红色虚线框标出这一排并说明规则:把它们放进整备区或战场、配发或使用,或者出售 / 销毁。休整期中进入临时整备区的单位,若到**本休整期结束**时仍留在那里就会被自动处理(干员返还卡池、装备销毁);作战、结算期间或准备就绪之后才进入的(例如战斗奖励、结算时合成的精锐),会保留到**下一个休整期**结束,你一定有机会处理它们。
|
- **位置上限**:棋盘最多部署 **8** 名;整备区 **10** 格。整备区满时**不能购买或领取**任何干员、装备(包括能凑齐 3 名合成精锐的那一张,和官方一样),先出售、部署或配发腾出格子;新获得的单位(合成的精锐、奖励、退回的装备等)进入**临时整备区**(整备区前面的 5 格),整备区一空出格子,临时整备区的单位就**自动补进去**(从右往左)。临时整备区有单位时**不能准备就绪**,棋盘上会用红色虚线框标出这一排并说明规则:把它们放进整备区或战场、配发或使用,或者出售 / 销毁。休整期中进入临时整备区的单位,若到**本休整期结束**时仍留在那里就会被自动处理(干员返还卡池、装备销毁);作战、结算期间或准备就绪之后才进入的(例如战斗奖励、结算时合成的精锐),会保留到**下一个休整期**结束,你一定有机会处理它们。
|
||||||
- **晋升精锐**:3 名同名干员(无论是否部署)自动合成**精锐**(等级、技能、模组大幅提升;风丸只需 2 名),精锐不会再合成。如果被合成的干员里有**已经部署在战场上**的,精锐会直接出现在**那名干员的位置**(保持原来的朝向,不多占部署位;有两三名都在场上时,取最先部署的那名——最左一列优先,同一列从上到下);否则精锐进入整备区(满了进入临时整备区)。原先配发的装备都退回整备区,需要重新配发;被合成干员的召唤物随之移除,场上的精锐会把自己的召唤物送进整备区。点选一张能合成的商店卡片时,精锐将出现的那一格会亮起金色边框,同时打开的详情卡里也会写明去向(「可晋升:精锐干员将出现在作战区原位置 / 将进入整备区」;详情卡会自动停靠在不遮挡这一格的一侧)。在回合开始或结算时因策略、奖励等完成的合成,精锐出现时同样会播放金色的晋升特效。
|
- **晋升精锐**:3 名同名干员(无论是否部署)自动合成**精锐**(等级、技能、模组大幅提升;风丸只需 2 名),精锐不会再合成。如果被合成的干员里有**已经部署在战场上**的,精锐会直接出现在**那名干员的位置**(保持原来的朝向,不多占部署位;有两三名都在场上时,取最先部署的那名——最左一列优先,同一列从上到下);否则精锐进入整备区(满了进入临时整备区)。原先配发的装备都退回整备区,需要重新配发;被合成干员的召唤物随之移除,场上的精锐会把自己的召唤物送进整备区。点选一张能合成的商店卡片时,精锐将出现的那一格会亮起金色边框,同时打开的详情卡里也会写明去向(「可晋升:精锐干员将出现在作战区原位置 / 将进入整备区」;详情卡会自动停靠在不遮挡这一格的一侧)。在回合开始或结算时因策略、奖励等完成的合成,精锐出现时同样会播放金色的晋升特效。
|
||||||
- **晋级奖励**:每次晋升后获得一次特殊招募:从 3 名 **高一阶**(最高 VI)、**互不相同**的随机干员中**免费**挑 1 名(该阶剩余干员不足 3 名时由低一阶补足)。它不能刷新、不能冻结、本回合结束失效。
|
- **晋级奖励**:每次晋升后获得一次特殊招募:从 3 名 **高一阶**(最高 VI)、**互不相同**的随机干员中**免费**挑 1 名(该阶剩余干员不足 3 名时由低一阶补足)。它不能刷新、不能冻结、本回合结束失效。
|
||||||
- **干员持有(补位)**:大厅、房间和「确认本局信息」里的「干员调配」按钮打开的界面有两个标签页:「干员调配」选择每名干员携带的技能和精锐的模组;「**干员持有**」可以把你没有的干员**下掉**(点一下卡片切换「持有 / 未持有」)。和官方一样只有 55 张「普通」棋子能下掉(界面按阶级列出本赛季商店里的 53 名,每张写着由谁替补、用几技能);74 张「预设」棋子(多是 1–2 阶的常见干员)无论是否持有都由本人上场,不在其中。默认全部持有。下掉的干员和官方一样**显示为替补干员**:商店和晋升奖励的卡片、整备区和临时整备区里的单位、作战区、结算界面的阵容,都是替补干员的头像、名字和模型(例如银灰显示为 Sharp),带一个小小的「替补」标记;详情卡显示替补干员的立绘、名字、技能、天赋和模组,名字下面写着「银灰的替补」。**盟约、特质、阶级、价格、合成仍按原干员**(卡片和详情卡上的盟约、特质就是原干员的),作战时的属性、技能、天赋、模组、攻击范围、近战位 / 远程位都是替补干员的(例如银灰 → Sharp 的 2 技能「亮剑」)。同一名替补干员可能替补好几名干员(Sharp 替补忍冬、银灰、隐德来希等),它们按各自的原干员分别合成:看阶级和盟约区分,点开详情卡能看到替补的是谁。同盟模拟中队友查看你的整备区和作战区、你的盟约成员时,看到的也是替补干员。替补的技能和模组由棋子固定,「干员调配」对它不生效;替补干员没有召唤物(下掉缪尔赛思就没有流形)。这是**局外设置**:下一局开始时生效,对局中修改只保存到下一局;同盟模拟中只影响你自己的棋子,队友的棋子按队友的设置。没有官方的好友借用:想用本人上场,改回「持有」即可。「全部持有」一键恢复,「导出 / 导入」可以把设置带到别的浏览器;大厅的「干员调配」按钮上会显示「替补 N」提醒你有几名干员被下掉。
|
- **干员调配(技能、模组、潜能与练度)**:大厅、房间和「确认本局信息」里的「干员调配」按钮打开的界面有三个标签页:「干员调配」「干员持有」「自选编队」。「干员调配」把干员列成一张表,每名干员一行,各列是**技能**、精锐的**模组**和**潜能 · 练度**:直接点行里的技能图标、模组图标(「不装备」在最前,模组只在精锐时生效)或改两个下拉框就设好了;点头像或名字在右边打开详情,依次是潜能与练度、特质、技能(普通 Lv.4 / 精锐 Lv.7 两档数值可切换)、局内数值和模组(窄屏——宽度 1000 像素以下,例如横屏的手机——详情盖住列表,点「干员列表」返回)。**潜能**(1–6)和**练度**(未精英化 / 精英阶段1 / 精英阶段2 / 精英阶段2-60级)按干员设置,同一名干员的普通、精锐和自选共用,默认潜能 6、精英阶段2-60级(维护者 2026-10-08 的决定:默认满潜、满加成)。官方规则:潜能取你自己持有的这名干员的潜能;练度是调度手册「加成情况」里的**自持有加成**——未精英化 +0%,精英1 攻击、防御 +5%,精英2 攻击 +10%、防御和生命 +5%,精英2 60 级三项 +10%,单独乘算,不和其他「攻击力 +x%」相加;未持有的特许干员在官方按潜能 1、没有加成,想照官方打就把它设成潜能 1、未精英化。补位的替补干员和自选的原型干员没有潜能和练度,设置对它们不生效。商店卡片、详情卡和「局内数值」都按你的设置显示;同盟模拟中队友的干员按队友的设置。和技能、模组一样是**局外设置**:「确认本局信息」结束前修改对本局生效,之后只保存到下一局。「恢复默认」「全部恢复默认」同时重置潜能与练度,「导出 / 导入」会带上它们,「干员调配」按钮上的数字也算上改过潜能或练度的干员。
|
||||||
- **自选编队**:同一个界面的第三个标签页「**自选编队**」有 4 个名额:5 阶两个、6 阶两个。点名额打开选择列表,每个名额可以放一名**你持有的 6★ 干员**(棋子池里没有的那些,标「已持有」:3 个技能任选其一,精锐的模组任选其一或不装备;只在集成战略或生息演算里生效的模组不能选:ISW-A、SO-A / SO-B、RA-A)或一名**原型干员**(标「原型」:Sharp、Mechanist 等 9 名 6★,5 阶另有 6 名 4★ 预备干员;技能和模组与补位时相同,不能更改)。同一名原型干员可以同时放进一个 5 阶和一个 6 阶名额;你持有的干员只能放一个名额;同一阶的两个名额不能放同一人。列表只列出已经做好战斗逻辑的干员(原型干员全部可选,持有的 6★ 随战斗逻辑完成陆续加入)。对局中,自选干员**只出现在你自己的商店里**:调度中心升到 5 级后才会刷出 5 阶名额的干员,6 级后才会刷出 6 阶的;刷新概率和同阶干员一样,每个名额有自己的库存(5 阶 8 张、6 阶 5 张,不与队友共用),价格与同阶干员相同,晋升奖励里也可能出现;效果、奖励或机变卡「获得一名随机干员」时,也可能抽到你自己的自选干员(按那个效果自己的阶级规则,不看调度中心等级)。三张合成精锐,精锐时所选模组生效(5 阶 1 级、6 阶 3 级)。自选干员**没有特质**,盟约按干员的实际阵营分配(推进之王是维多利亚,煌同时是炎和维多利亚,没有对应阵营的是协防干员),和普通棋子一样计入盟约人数;本局禁用了它全部的盟约时,它不会出现在商店里(协防不会被禁用),开局的「确认本局信息」、选策略时的「本局信息」和对局中的「本局信息」都会在「本局禁用干员」下写明是哪一名。商店卡片、整备区、作战区和详情卡上都标着「自选」,详情卡显示所选的技能和模组。这同样是**局外设置**:下一局开始时生效,对局中修改只保存到下一局;AI 队友不使用自选;你开启 AI 托管时,AI 会优先买你的自选干员,并在快能刷出自选名额时提早一回合升级调度中心;信标不会把自选干员送给队友。「导出 / 导入」「全部清空」可用,大厅的「干员调配」按钮上会显示「自选 N」。
|
- **干员持有(补位)**:同一个界面的第二个标签页「**干员持有**」可以把你没有的干员**下掉**(点一下卡片切换「持有 / 未持有」)。和官方一样只有 55 张「普通」棋子能下掉(界面按阶级列出本赛季商店里的 53 名,每张写着由谁替补、用几技能);74 张「预设」棋子(多是 1–2 阶的常见干员)无论是否持有都由本人上场,不在其中。默认全部持有。下掉的干员和官方一样**显示为替补干员**:商店和晋升奖励的卡片、整备区和临时整备区里的单位、作战区、结算界面的阵容,都是替补干员的头像、名字和模型(例如银灰显示为 Sharp),带一个小小的「替补」标记;详情卡显示替补干员的立绘、名字、技能、天赋和模组,名字下面写着「银灰的替补」。**盟约、特质、阶级、价格、合成仍按原干员**(卡片和详情卡上的盟约、特质就是原干员的),作战时的属性、技能、天赋、模组、攻击范围、近战位 / 远程位都是替补干员的(例如银灰 → Sharp 的 2 技能「亮剑」)。同一名替补干员可能替补好几名干员(Sharp 替补忍冬、银灰、隐德来希等),它们按各自的原干员分别合成:看阶级和盟约区分,点开详情卡能看到替补的是谁。同盟模拟中队友查看你的整备区和作战区、你的盟约成员时,看到的也是替补干员。替补的技能和模组由棋子固定,「干员调配」对它不生效;替补干员没有召唤物(下掉缪尔赛思就没有流形)。这是**局外设置**:下一局开始时生效,对局中修改只保存到下一局;同盟模拟中只影响你自己的棋子,队友的棋子按队友的设置。没有官方的好友借用:想用本人上场,改回「持有」即可。「全部持有」一键恢复,「导出 / 导入」可以把设置带到别的浏览器;大厅的「干员调配」按钮上会显示「替补 N」提醒你有几名干员被下掉。
|
||||||
|
- **自选编队**:同一个界面的第三个标签页「**自选编队**」有 4 个名额:5 阶两个、6 阶两个。点名额打开选择列表,每个名额可以放一名**你持有的 6★ 干员**(棋子池里没有的那些,标「已持有」:3 个技能任选其一,精锐的模组任选其一或不装备;只在集成战略或生息演算里生效的模组不能选:ISW-A、SO-A / SO-B、RA-A)或一名**原型干员**(标「原型」:Sharp、Mechanist 等 9 名 6★,5 阶另有 6 名 4★ 预备干员;技能和模组与补位时相同,不能更改)。同一名原型干员可以同时放进一个 5 阶和一个 6 阶名额;你持有的干员只能放一个名额;同一阶的两个名额不能放同一人。列表只列出已经做好战斗逻辑的干员(原型干员全部可选,持有的 6★ 随战斗逻辑完成陆续加入)。对局中,自选干员**只出现在你自己的商店里**:调度中心升到 5 级后才会刷出 5 阶名额的干员,6 级后才会刷出 6 阶的;刷新概率和同阶干员一样,每个名额有自己的库存(5 阶 8 张、6 阶 5 张,不与队友共用),价格与同阶干员相同,晋升奖励里也可能出现;效果、奖励或机变卡「获得一名随机干员」时,也可能抽到你自己的自选干员(按那个效果自己的阶级规则,不看调度中心等级)。三张合成精锐,精锐时所选模组生效(5 阶 1 级、6 阶 3 级)。你持有的干员的潜能与练度在名额卡片上设置(默认潜能 6、精英阶段2-60级,规则同「干员调配」);原型干员没有潜能和练度。自选干员**没有特质**,盟约按干员的实际阵营分配(推进之王是维多利亚,煌同时是炎和维多利亚,没有对应阵营的是协防干员),和普通棋子一样计入盟约人数;本局禁用了它全部的盟约时,它不会出现在商店里(协防不会被禁用),开局的「确认本局信息」、选策略时的「本局信息」和对局中的「本局信息」都会在「本局禁用干员」下写明是哪一名。商店卡片、整备区、作战区和详情卡上都标着「自选」,详情卡显示所选的技能和模组。这同样是**局外设置**:下一局开始时生效,对局中修改只保存到下一局;AI 队友不使用自选;你开启 AI 托管时,AI 会优先买你的自选干员,并在快能刷出自选名额时提早一回合升级调度中心;信标不会把自选干员送给队友。「导出 / 导入」「全部清空」可用,大厅的「干员调配」按钮上会显示「自选 N」。
|
||||||
- 每回合商店会自动刷新未冻结的格子。
|
- 每回合商店会自动刷新未冻结的格子。
|
||||||
- **「本回合首次主动刷新」按每名干员各自计算**(拉普兰德):她在本回合遇到的第一次主动刷新生效,在整备区里也算——先刷新、再买下拉普兰德,下一次刷新她仍会叠层;本回合新合成的精锐算作一名新的拉普兰德——即使合成用的几名本回合已经生效,精锐在本回合的下一次主动刷新仍会 +8。
|
- **「本回合首次主动刷新」按每名干员各自计算**(拉普兰德):她在本回合遇到的第一次主动刷新生效,在整备区里也算——先刷新、再买下拉普兰德,下一次刷新她仍会叠层;本回合新合成的精锐算作一名新的拉普兰德——即使合成用的几名本回合已经生效,精锐在本回合的下一次主动刷新仍会 +8。
|
||||||
|
|
||||||
@@ -74,7 +75,7 @@
|
|||||||
- 想只改朝向:把干员拖回它自己所在的格子再选方向。朝向会一直保留到下一回合。
|
- 想只改朝向:把干员拖回它自己所在的格子再选方向。朝向会一直保留到下一回合。
|
||||||
- **点选按地上的方格**:点哪个方格就选中站在那个方格上的单位,点空方格不选任何单位(点到后一格干员画出来的头部,算作点了后面那一格)。作战中友方同样按方格;敌人在格子之间走动,点它的身体或脚下附近(0.6 格内)即可,飞行敌人点它的身体。
|
- **点选按地上的方格**:点哪个方格就选中站在那个方格上的单位,点空方格不选任何单位(点到后一格干员画出来的头部,算作点了后面那一格)。作战中友方同样按方格;敌人在格子之间走动,点它的身体或脚下附近(0.6 格内)即可,飞行敌人点它的身体。
|
||||||
- 拖动时模型一直在指针 / 手指下(脚在指针下方约半格,鼠标和触屏一样),**指针所在的格子**就是落点并高亮;装备拖到哪个格子,就配发给那个格子上的干员;在商店栏上松手则放回原位。
|
- 拖动时模型一直在指针 / 手指下(脚在指针下方约半格,鼠标和触屏一样),**指针所在的格子**就是落点并高亮;装备拖到哪个格子,就配发给那个格子上的干员;在商店栏上松手则放回原位。
|
||||||
- 点击干员显示攻击范围和底部按钮(「撤退」回到整备区、「出售 +1」);右键 / 长按查看详细信息。作战中、查看队友的阵地时,点开场上我方干员或召唤物(包括队友的)的详情卡,棋盘上也会画出它**当前的攻击范围**(按它现在的位置、朝向和技能,例如开着扩大攻击范围的技能时就是扩大后的范围),关掉详情卡、拖动或选方向时消失;敌人、整备区和临时整备区里的单位不画。详情卡里:头像旁是盟约,下面第一项就是干员的**特质**(如「休整期结束时 …」),之后是特性、属性、攻击范围、技能、模组、装备、天赋。属性是**实时数值**:作战中每秒刷新 4 次(当前生命、攻击、防御、法抗、攻击间隔、阻挡),休整期显示棋盘上的干员**开战时**的数值(已算入装备、盟约层数、特质、策略与机变效果);高于基础值为绿色、低于为红色并标出差值(攻击间隔变短算增益),鼠标悬停可看基础值。基础值是官方数据在该阶级的等级、技能等级(和精锐的模组)下、**满潜能**、0 信赖的数值:所有干员(精锐、补位、自选也一样)都带上全部潜能加成——部署费用、再部署时间、攻击力 / 生命 / 防御 / 法抗 / 攻速,以及「天赋效果增强」,召唤物的天赋也按召唤者的满潜能(维护者 2026-10-07 的决定,GitHub #252:官方赛事视频里的刺玫正是满潜能的攻击 435、费用 15)。
|
- 点击干员显示攻击范围和底部按钮(「撤退」回到整备区、「出售 +1」);右键 / 长按查看详细信息。作战中、查看队友的阵地时,点开场上我方干员或召唤物(包括队友的)的详情卡,棋盘上也会画出它**当前的攻击范围**(按它现在的位置、朝向和技能,例如开着扩大攻击范围的技能时就是扩大后的范围),关掉详情卡、拖动或选方向时消失;敌人、整备区和临时整备区里的单位不画。详情卡里:头像旁是盟约,下面第一项就是干员的**特质**(如「休整期结束时 …」),之后是特性、属性、攻击范围、技能、模组、装备、天赋。属性是**实时数值**:作战中每秒刷新 4 次(当前生命、攻击、防御、法抗、攻击间隔、阻挡),休整期显示棋盘上的干员**开战时**的数值(已算入装备、盟约层数、特质、策略与机变效果);高于基础值为绿色、低于为红色并标出差值(攻击间隔变短算增益),鼠标悬停可看基础值。基础值是官方数据在该阶级的等级、技能等级(和精锐的模组)下、按你在「干员调配」里给这名干员设的**潜能和练度**(默认潜能 6、精英阶段2-60级)、0 信赖的数值:潜能加成包括部署费用、再部署时间、攻击力 / 生命 / 防御 / 法抗 / 攻速,以及「天赋效果增强」,召唤物的天赋按召唤者的潜能;练度把攻击、防御、生命上限单独乘上自持有加成(精英阶段2-60级 ×1.1;召唤物不乘)。补位的替补干员和原型干员没有潜能档,也没有自持有加成(0.2.2;0.2.1 是所有干员一律满潜能、没有自持有加成,GitHub #252)。
|
||||||
- **召唤物手动摆放**:把带可部署召唤物的干员放上棋盘后,它的召唤物会进入整备区(赫默的医疗探机、巫恋的诅咒娃娃、凯瑟琳的 2 个支援装置、浊心斯卡蒂的海嗣、伺夜的狼群、缪尔赛思的流形;同一干员的召唤物叠在一格,不占部署位),像干员一样拖到棋盘上并选方向。凯瑟琳的支援装置给**它面朝的那一格**上的干员加屏障,所以要放在干员旁边、朝向干员。**战术家的援军**(伺夜的狼群、缪尔赛思的流形,也就是选战术点)和官方一样**只能放在召唤者的攻击范围内**:拖动时只有这些格子会亮起;之后在原地给战术家换朝向时,不在新攻击范围里的援军会退回整备区(还在范围里的保持不动;整备区和临时整备区都满、援军无处可退时,这次换朝向不生效并提示「整备区已满」)。撤退或出售干员时它的召唤物一起移除;**移动干员到别的格子时(包括把召唤物拖到干员身上交换位置),它已摆放的召唤物会退回整备区**,需要重新摆放。整备区满时退回的召唤物进入临时整备区,休整期结束还没处理的会在下一回合重新回到整备区。
|
- **召唤物手动摆放**:把带可部署召唤物的干员放上棋盘后,它的召唤物会进入整备区(赫默的医疗探机、巫恋的诅咒娃娃、凯瑟琳的 2 个支援装置、浊心斯卡蒂的海嗣、伺夜的狼群、缪尔赛思的流形;同一干员的召唤物叠在一格,不占部署位),像干员一样拖到棋盘上并选方向。凯瑟琳的支援装置给**它面朝的那一格**上的干员加屏障,所以要放在干员旁边、朝向干员。**战术家的援军**(伺夜的狼群、缪尔赛思的流形,也就是选战术点)和官方一样**只能放在召唤者的攻击范围内**:拖动时只有这些格子会亮起;之后在原地给战术家换朝向时,不在新攻击范围里的援军会退回整备区(还在范围里的保持不动;整备区和临时整备区都满、援军无处可退时,这次换朝向不生效并提示「整备区已满」)。撤退或出售干员时它的召唤物一起移除;**移动干员到别的格子时(包括把召唤物拖到干员身上交换位置),它已摆放的召唤物会退回整备区**,需要重新摆放。整备区满时退回的召唤物进入临时整备区,休整期结束还没处理的会在下一回合重新回到整备区。
|
||||||
- 作战中:海嗣、狼群、流形、支援装置开战时就在摆放的位置部署;医疗探机、诅咒娃娃开战时也在摆放的位置免费部署一次(持续 10 / 15 秒,和官方一样不需要先开技能),之后所属干员每次发动技能时再次在原位出现;所属干员被击倒、再部署回场时,技能给的那一个如果还没出现,会立刻出现。巫恋退场时诅咒娃娃随之撤退,赫默退场时已出现的医疗探机不受影响。医疗探机、诅咒娃娃、海嗣(以及自选干员的工程蓄水炮、沙地兽、打字机、本能的召唤)这类不会受到攻击、到时间就离场的召唤物不受任何伤害(活性源石也一样)、也不会被治疗,它们的血条就是倒计时:随时间减少,空了就离场。琳琅诗怀雅二技能的香槟炸弹也不会掉血(活性源石和各种伤害都不会)、不会被治疗,不会因为生命值而消失,只在「见面礼」炸到碰到它的第一个地面敌人后消失(维护者 2026-10-08 的决定);隐匿(没有被反隐、也没有被阻挡)或沉睡的敌人选不中,不会触发它。医疗探机、诅咒娃娃、支援装置、海嗣没有摆放就不会出现;狼群、流形(战术家的援军)没有摆放时会在战术点出现。狼群最后一只狼影被击倒后进入战术点形态:暂时离开战斗(不阻挡、不攻击、不会被攻击,格子仍然占着),经过狼影的恢复时间(25 秒)后在原地满血、带着 1 只狼影回来,之后重新开始每 25 秒加 1 只狼影;伺夜退场时狼群随之撤退,伺夜再部署回场时狼群重新带着 2 只狼影出现。
|
- 作战中:海嗣、狼群、流形、支援装置开战时就在摆放的位置部署;医疗探机、诅咒娃娃开战时也在摆放的位置免费部署一次(持续 10 / 15 秒,和官方一样不需要先开技能),之后所属干员每次发动技能时再次在原位出现;所属干员被击倒、再部署回场时,技能给的那一个如果还没出现,会立刻出现。巫恋退场时诅咒娃娃随之撤退,赫默退场时已出现的医疗探机不受影响。医疗探机、诅咒娃娃、海嗣(以及自选干员的工程蓄水炮、沙地兽、打字机、本能的召唤)这类不会受到攻击、到时间就离场的召唤物不受任何伤害(活性源石也一样)、也不会被治疗,它们的血条就是倒计时:随时间减少,空了就离场。琳琅诗怀雅二技能的香槟炸弹也不会掉血(活性源石和各种伤害都不会)、不会被治疗,不会因为生命值而消失,只在「见面礼」炸到碰到它的第一个地面敌人后消失(维护者 2026-10-08 的决定);隐匿(没有被反隐、也没有被阻挡)或沉睡的敌人选不中,不会触发它。医疗探机、诅咒娃娃、支援装置、海嗣没有摆放就不会出现;狼群、流形(战术家的援军)没有摆放时会在战术点出现。狼群最后一只狼影被击倒后进入战术点形态:暂时离开战斗(不阻挡、不攻击、不会被攻击,格子仍然占着),经过狼影的恢复时间(25 秒)后在原地满血、带着 1 只狼影回来,之后重新开始每 25 秒加 1 只狼影;伺夜退场时狼群随之撤退,伺夜再部署回场时狼群重新带着 2 只狼影出现。
|
||||||
- **阻挡**看接触:干员阻挡中心距离约 0.7 格以内、与它接触的敌人(地面干员挡不住飞行和近地悬浮的敌人)。敌人卡在两名干员之间时,阻挡它的干员倒下、撤退或被晕眩后,另一名仍与它接触、还有空余阻挡数的干员会**立刻接替阻挡**;都没有空位时敌人才会走掉。被冻结或晕眩的敌人同样会被接触到的干员挡住:干员再部署到它身边就会阻挡它,隐匿的敌人也随之破隐(沉睡、浮空、恐惧的敌人仍然挡不住)。迎面走来的敌人会停在干员格子前面。**阻挡了就一定能打到**:任何干员(包括站在近战位的远程干员和召唤物)总能攻击自己阻挡的敌人,不管它在不在攻击范围内、在干员哪一侧,并且优先攻击它——只有速射手例外:攻击范围内有飞行敌人时先打飞行敌人(特性「优先攻击空中单位」,维护者 2026-10-08 的决定,GitHub #220)(医疗干员除外:咒愈师以外的医疗挡住敌人时照常治疗队友,不会攻击它;「停止攻击」类技能期间也除外;技能原文另有限制的也按原文:普罗旺斯二技能开启时不以生命值高于 80% 的敌人为目标,寒檀二技能的冰凌随机落在攻击范围内的地块上)。
|
- **阻挡**看接触:干员阻挡中心距离约 0.7 格以内、与它接触的敌人(地面干员挡不住飞行和近地悬浮的敌人)。敌人卡在两名干员之间时,阻挡它的干员倒下、撤退或被晕眩后,另一名仍与它接触、还有空余阻挡数的干员会**立刻接替阻挡**;都没有空位时敌人才会走掉。被冻结或晕眩的敌人同样会被接触到的干员挡住:干员再部署到它身边就会阻挡它,隐匿的敌人也随之破隐(沉睡、浮空、恐惧的敌人仍然挡不住)。迎面走来的敌人会停在干员格子前面。**阻挡了就一定能打到**:任何干员(包括站在近战位的远程干员和召唤物)总能攻击自己阻挡的敌人,不管它在不在攻击范围内、在干员哪一侧,并且优先攻击它——只有速射手例外:攻击范围内有飞行敌人时先打飞行敌人(特性「优先攻击空中单位」,维护者 2026-10-08 的决定,GitHub #220)(医疗干员除外:咒愈师以外的医疗挡住敌人时照常治疗队友,不会攻击它;「停止攻击」类技能期间也除外;技能原文另有限制的也按原文:普罗旺斯二技能开启时不以生命值高于 80% 的敌人为目标,寒檀二技能的冰凌随机落在攻击范围内的地块上)。
|
||||||
|
|||||||
+23
-6
@@ -572,8 +572,9 @@ ATK / DEF / max HP bonus of the 卫戍 systems — 盟约, 策略 (bands), 装
|
|||||||
卫戍协议:盟约 下半/PRTS盟约记录 "盟约效果,策略效果,装备效果提供的属性加成均为直接乘算"). Content builds those with
|
卫戍协议:盟约 下半/PRTS盟约记录 "盟约效果,策略效果,装备效果提供的属性加成均为直接乘算"). Content builds those with
|
||||||
`content/support directMods({ atk, def, hp })` (constants.js `DIRECT_BONUS_STACKING` 'add'; 'multiply' = the v2.5
|
`content/support directMods({ atk, def, hp })` (constants.js `DIRECT_BONUS_STACKING` 'add'; 'multiply' = the v2.5
|
||||||
per-source ×(1 + x), which compounded with layers: user report after playtest #6). `Πmul` is for 最终乘算 / "提升至X%"
|
per-source ×(1 + x), which compounded with layers: user report after playtest #6). `Πmul` is for 最终乘算 / "提升至X%"
|
||||||
effects (炎佑 ×1.5 at 9 炎, 虚弱, 停顿 …) and the char_attribute_mul 特质 ("攻击力和生命值+20%", a rune on the base
|
effects (炎佑 ×1.5 at 9 炎, 虚弱, 停顿 …), the char_attribute_mul 特质 ("攻击力和生命值+20%", a rune on the base
|
||||||
attributes). `atkFinal` is the **最终加算** (PRTS `A_f = F_t[(A + D_p)(1 + D_t) + F_p]`: added after the 直接乘算, inside
|
attributes) and the unit's 练度 `cultMul` (自持有, the same char_attribute_mul key, 0.2.2: ATK / DEF / max HP of the owned
|
||||||
|
operator's tier, §12). `atkFinal` is the **最终加算** (PRTS `A_f = F_t[(A + D_p)(1 + D_t) + F_p]`: added after the 直接乘算, inside
|
||||||
the 最终乘算): 阿戈尔's devoured base ATK ("基础攻击力(最终加算)", DESIGN §24.7) — a skill's ATK +% does not scale it.
|
the 最终乘算): 阿戈尔's devoured base ATK ("基础攻击力(最终加算)", DESIGN §24.7) — a skill's ATK +% does not scale it.
|
||||||
Damage multipliers (`dmgDealtMul`, "伤害提升至X%"; `*TakenMul`, 脆弱 / "受到的伤害+X%") multiply each other
|
Damage multipliers (`dmgDealtMul`, "伤害提升至X%"; `*TakenMul`, 脆弱 / "受到的伤害+X%") multiply each other
|
||||||
(PRTS 游戏数据基础 "同种倍率间叠乘"), same-named statuses keep the strongest — catalogue statuses (§3) and the content
|
(PRTS 游戏数据基础 "同种倍率间叠乘"), same-named statuses keep the strongest — catalogue statuses (§3) and the content
|
||||||
@@ -1390,7 +1391,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?, 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, board and held pieces alike with the stand-in's art (the owner's recall of the official mode, 2026-10-06), and so do the m.result lineups; `diy`: a 自选 piece's pick `{ charId, skillIndex, uniEquipId }` — `defId` is its slot, `spine` / `avatar` / `name` the operator's; the prep scouting units of `Match.prepFieldMeta` and the m.result lineups carry it too (server/match/player/diy.js, the client composes the operator's card from it); `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?, potential?, cultivate? }` (`potential` / `cultivate`: an ally operator's potential below 6 and its 练度 tier, 0.2.2 — a teammate's card composes its numbers from them) (`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, board and held pieces alike with the stand-in's art (the owner's recall of the official mode, 2026-10-06), and so do the m.result lineups; `diy`: a 自选 piece's pick `{ charId, skillIndex, uniEquipId }` — `defId` is its slot, `spine` / `avatar` / `name` the operator's; the prep scouting units of `Match.prepFieldMeta` and the m.result lineups carry it too (server/match/player/diy.js, the client composes the operator's card from it); `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);
|
||||||
@@ -1477,8 +1478,14 @@ never displaced, §6), tags, abilities, skills, talent, raw`. Tokens use `varian
|
|||||||
`skcom_withdraw` token skills are ignored.
|
`skcom_withdraw` token skills are ignored.
|
||||||
Operator loadouts (DESIGN §16, DATA.md §2.2 / §14): `getChess(id, { skillIndex, moduleId })` = the def with the selected
|
Operator loadouts (DESIGN §16, DATA.md §2.2 / §14): `getChess(id, { skillIndex, moduleId })` = the def with the selected
|
||||||
skill (`def.skill`, its bb / SP / trigger) and, for elites, the module's stats / trait / talents; `def.loadout` = the
|
skill (`def.skill`, its bb / SP / trigger) and, for elites, the module's stats / trait / talents; `def.loadout` = the
|
||||||
resolved loadout `{ skillIndex, moduleId, skillIsDefault, moduleIsDefault, isDefault }` (illegal choices fall back to the
|
resolved loadout `{ skillIndex, moduleId, potential, skillIsDefault, moduleIsDefault, potentialIsDefault, isDefault }`
|
||||||
default; cached per loadout; the default loadout is the same object as `getChess(id)`). `getToken(id, ownerChessId,
|
(illegal choices fall back to the default; cached per loadout; the default loadout is the same object as `getChess(id)`).
|
||||||
|
Potential (0.2.2, DATA.md §2.3, shared/potential.js): a loadout's `potential` (潜能 1–6; default 6 = the data's full
|
||||||
|
potential) composes the record at it first (`loadoutRecord` → `atPotential`: stats, statsBase, talents, module talent
|
||||||
|
changes); `getToken` composes the owner variant at the owner's `loadout.potential` (talents; a 自选 summon's deploy
|
||||||
|
limit / holding / count) and `getDiy(slotId, pick, potential)` an owned pick's form (a prototype has none: `diyProto`);
|
||||||
|
the kits that read a summon variant raw (content/tokens.js `ownVariant`, `kits/shared/summoner.js tokenStat`, 多萝西,
|
||||||
|
麦克尼斯特) compose it the same way. `getToken(id, ownerChessId,
|
||||||
ownerLoadout)` = the summon for that owner loadout (`bySkill` / `byModule` merged; `def.sources` ⊆ ['talent', 'skill',
|
ownerLoadout)` = the summon for that owner loadout (`bySkill` / `byModule` merged; `def.sources` ⊆ ['talent', 'skill',
|
||||||
'display'] and `def.count` of that loadout — `[]` / `['display']`: the owner does not make it). Every Battle gets a
|
'display'] and `def.count` of that loadout — `[]` / `['display']`: the owner does not make it). Every Battle gets a
|
||||||
per-battle view (`withUnitLoadouts`: id-only `getChess(id)` / `getToken(id, owner)` use the loadout the inputs give that
|
per-battle view (`withUnitLoadouts`: id-only `getChess(id)` / `getToken(id, owner)` use the loadout the inputs give that
|
||||||
@@ -1490,7 +1497,17 @@ the source's `backups` (data/backups.json; `rawBackups()` follows the fallback c
|
|||||||
the replaced operator's, `loadout` the backup selection (`standIn: true`); its skill / module ignore the loadout's
|
the replaced operator's, `loadout` the backup selection (`standIn: true`); its skill / module ignore the loadout'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 — no `potential` / `cultivate`: a stand-in has neither); the per-battle view maps id-only lookups of that
|
||||||
|
chess id to the stand-in.
|
||||||
|
潜能 / 练度 in a PlayerBattleInput operator entry (0.2.2; the owner's decision of 2026-10-08 — the player's 干员调配 settings,
|
||||||
|
PlayerState.battleInput states both for every owned operator, bots the defaults 6 / 3): `potential` (1–6) → the def of
|
||||||
|
`getChess(chessId, { …, potential })`; `cultivate` (0–3) → `unit.cultivate` and `unit.cultMul` = `data.cultivateMul(n)`,
|
||||||
|
the effects.json CHAR_MAP `aceffect_char_<n+1>` `char_attribute_mul` (×ATK / ×DEF / ×max HP: 1 / 1.05·1.05·1 / 1.1·1.05·1.05
|
||||||
|
/ 1.1 each), a multiplier of its own in the Πmul of §2 (never summed with the 直接乘算 percentages: PRTS 盟约记录
|
||||||
|
「…与自持有干员的属性加成独立」); a stand-in or a prototype 自选 def never takes it, summons neither [ASSUMED: the official
|
||||||
|
buff names the operator]. spec.js keeps each only when well-formed (never on a stand-in or a summon); absent = full
|
||||||
|
potential and no 练度 (a raw unit: tests, tools). `unitStatsEntry` counts the 练度 in the unit's own numbers (`base`), as
|
||||||
|
the record cards do; UnitInfo carries `potential` (below 6) and `cultivate`.
|
||||||
自选 (DATA.md §18): `getChess(slotId, { diy })` = `getDiy(slotId, pick)` — shared/diy.js `diyRecordOf` of the DIY slot
|
自选 (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
|
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
|
slot, `bonds` the pick's derived ones, `raw.garrisonIds` `[]` (no 特质), every combat field of the operator at the slot's
|
||||||
|
|||||||
@@ -106,11 +106,13 @@ Official rule (bwiki 盟约, 更新公告 5114): before a match the player canno
|
|||||||
|
|
||||||
**Loadout wire & state:** the client keeps a per-browser loadout `{ [baseChessId]: { skill: index, module: uniEquipId | 'none' } }` (localStorage) and sends it with C2S `room.loadout { entries }` (validated: known visible chess, legal skill index for that chess's normal AND elite status, legal module id). The lobby stores it on the seat; at match start `Match` receives `seats[].loadout`; `PlayerState` keeps it immutable for the match; board pieces are resolved to `{chessId, skillIndex, moduleId}` when building `PlayerBattleInput.units[]` (fields `skillIndex`, `moduleId`; BattleSpec carries them). Bots use defaults. m.private exposes the effective loadout so the UI (shop cards, detail panel) shows the chosen skill/module.
|
**Loadout wire & state:** the client keeps a per-browser loadout `{ [baseChessId]: { skill: index, module: uniEquipId | 'none' } }` (localStorage) and sends it with C2S `room.loadout { entries }` (validated: known visible chess, legal skill index for that chess's normal AND elite status, legal module id). The lobby stores it on the seat; at match start `Match` receives `seats[].loadout`; `PlayerState` keeps it immutable for the match; board pieces are resolved to `{chessId, skillIndex, moduleId}` when building `PlayerBattleInput.units[]` (fields `skillIndex`, `moduleId`; BattleSpec carries them). Bots use defaults. m.private exposes the effective loadout so the UI (shop cards, detail panel) shows the chosen skill/module.
|
||||||
|
|
||||||
|
**潜能 / 练度 (0.2.2, the owner's decision of 2026-10-08: 「调配干员里自己设置吧,默认满潜满加成」):** official — an operator fights at the player's own potential (调度手册 「卫戍协议中干员潜能由自身已持有干员潜能决定」; an unowned 特许 operator at potential 1) and an owned operator of the same name gets the 自持有 练度 bonus (activity_table `cultivateEffectList`: 未精英化 +0 %, 精英1 ATK / DEF +5 %, 精英2 ATK +10 % DEF / HP +5 %, 精英2 Lv.60 +10 % each — a multiplier of its own, never summed with the 直接乘算 bonuses). The 干员调配 screen sets both per operator (by charId: initial, elite and 自选 forms share them; default 潜能 6 and 精英2 Lv.60); they travel as `room.loadout.ops` (`{ [charId]: { potential, cultivate } }`, defaults dropped; shared/protocol.js `checkLoadoutOps`, strict like the entries), `seats[].ops` → `PlayerState.ops` (re-checked; bots none = the defaults), and every operator the player owns states them in its `PlayerBattleInput` entry (`potential`, `cultivate`; shared/potential.js `cultivationOf`) — never a 补位 stand-in (a not-owned NORMAL chess) or a prototype 自选 pick. The sim composes the def at the potential (data/*.json carry every potential, DATA.md §2.3; the summons' talents at the owner's) and multiplies ATK / DEF / max HP by the tier's `char_attribute_mul` (effects.json `aceffect_char_1…4`, SIM.md §12). The cards (局内数值, shop and detail cards, a teammate's unit from UnitInfo `potential` / `cultivate`) show the numbers at the settings.
|
||||||
|
|
||||||
**Sim:** `simdata` resolves a unit def for `(chessId, skillIndex, moduleId)` (cached): `skill` = the selected SkillRecord; stats = `statsBase` + module attr (elite) ; trait/talents = base with the selected module's overrides. Kit contract (backward compatible): a kit function `(bb, chess) => { skill?, skills?: { [skillId]: SkillSpec }, talents, trait }` receives `chess.skill` = the **selected** skill and `bb` = its blackboard; the loader uses `skills[selectedSkillId]` if present, else `skill` only when the selected skill is the chess's default, else the **generic** skill spec — talents/trait from the kit always apply. Every visible chess must get hand-authored specs for all of its selectable skills (tracked by a coverage test).
|
**Sim:** `simdata` resolves a unit def for `(chessId, skillIndex, moduleId)` (cached): `skill` = the selected SkillRecord; stats = `statsBase` + module attr (elite) ; trait/talents = base with the selected module's overrides. Kit contract (backward compatible): a kit function `(bb, chess) => { skill?, skills?: { [skillId]: SkillSpec }, talents, trait }` receives `chess.skill` = the **selected** skill and `bb` = its blackboard; the loader uses `skills[selectedSkillId]` if present, else `skill` only when the selected skill is the chess's default, else the **generic** skill spec — talents/trait from the kit always apply. Every visible chess must get hand-authored specs for all of its selectable skills (tracked by a coverage test).
|
||||||
|
|
||||||
**UI:** a 干员调配 screen reachable from the lobby, room and briefing (INFO_CHECK): filter by tier/bond/class, per chess choose skill (icon, name, SP info, desc at Lv4 and Lv7) and elite module (official type icon from the local-client art when extracted (§13), else a lettered tile; name/type, stat bonus, trait/talent changes), 恢复默认, persisted; a 局内数值 section between the skills and the modules (普通 / 精锐 toggle, 精锐 first) draws the detail card's own stats block, 特性 and 天赋 for the chosen skill / module (§22.16); the chosen skill/module shown on shop cards and in the detail panel (a teammate's unit — 前往查看 in prep, 联防 / 最终攻势 fields, an observed battle — shows **its owner's** skill/module from its UnitInfo `skillIndex` / `moduleId`, `gameLogic.js unitLoadout`) — which also shows the stats, 特性, talents and attack range the unit fights with (`shared/loadoutRecord.js` `loadoutRecord` / `attackRangeGrid`, the same composition the sim's `getChess(id, loadout)` uses; the deploy wheel's range preview too) — the range it is deployed with: a selected passive range skill (引星棘刺 S3 3-9) and the 特性's permanent 攻击距离 (信仰搅拌机 SPT-Y +1) included, over all 1284 loadouts equal to the battle's range at deployment; in battle and prep the card draws the live `range` (§21.16). In battle the renderer plays the equipped skill's own Spine clip and ON_SKILL_START sound (`UnitInfo.skillIndex`; `data/assets.json` lists icons, clips and sounds for every skill index).
|
**UI:** a 干员调配 screen reachable from the lobby, room and briefing (INFO_CHECK): the roster is one list, a row per operator with aligned columns under a head row — portrait / name / bonds (opens the detail), the skills and the elite's modules to tap (不装备 first; the quick choices of PR #301 by @farridge, laid out as one list instead of its three columns of cards, the owner's call of 2026-10-08), the 潜能 / 练度 selects (`screens/loadout.js RosterRow`, `screens/cultivation.js`); the detail beside it (below 1000 px wide it slides over the list, a quick choice keeps the list) opens with the 潜能与练度 section (潜能 1–6, the four official 练度 tiers with their effect texts); the detail then shows the operator's 特质 — the in-match `GarrisonBlock` / RichText of the form its 普通 / 精锐 skill toggle shows, triggers such as 「<进入休整期时>」 kept (0.2.2, PR #301 by @farridge); filter by tier/bond/class, per chess choose skill (icon, name, SP info, desc at Lv4 and Lv7) and elite module (official type icon from the local-client art when extracted (§13), else a lettered tile; name/type, stat bonus, trait/talent changes), 恢复默认, persisted; a 局内数值 section between the skills and the modules (普通 / 精锐 toggle, 精锐 first) draws the detail card's own stats block, 特性 and 天赋 for the chosen skill / module (§22.16); the chosen skill/module shown on shop cards and in the detail panel (a teammate's unit — 前往查看 in prep, 联防 / 最终攻势 fields, an observed battle — shows **its owner's** skill/module from its UnitInfo `skillIndex` / `moduleId`, `gameLogic.js unitLoadout`) — which also shows the stats, 特性, talents and attack range the unit fights with (`shared/loadoutRecord.js` `loadoutRecord` / `attackRangeGrid`, the same composition the sim's `getChess(id, loadout)` uses; the deploy wheel's range preview too) — the range it is deployed with: a selected passive range skill (引星棘刺 S3 3-9) and the 特性's permanent 攻击距离 (信仰搅拌机 SPT-Y +1) included, over all 1284 loadouts equal to the battle's range at deployment; in battle and prep the card draws the live `range` (§21.16). In battle the renderer plays the equipped skill's own Spine clip and ON_SKILL_START sound (`UnitInfo.skillIndex`; `data/assets.json` lists icons, clips and sounds for every skill index).
|
||||||
|
|
||||||
**导出 / 导入 a loadout:** 导出 hands the current loadout out as a versioned envelope `{ kind: 'stronghold.loadout', v, exportedAt, count, entries }` — copied through `ui/clipboard.js copyText` or downloaded as a file. 导入 accepts that envelope, the stored `{ v, entries }` form and a bare `{ [chessId]: { skill, module } }` map (serialised text too), and refuses another tool's `kind`, junk, an empty result and a **newer** `v` (`parseImport` — a newer payload is never mis-read). Both go through `loadoutSync.applyLoadoutEntries` → `sanitizeEntries`, so a preset from another build never sends the server an entry it would refuse, an import that keeps nothing changes nothing, and the result is reported as N imported / M not (`ui/loadoutModel.js`, `screens/loadout.js`).
|
**导出 / 导入 a loadout:** 导出 hands the current loadout out as a versioned envelope `{ kind: 'stronghold.loadout', v, exportedAt, count, entries, ops }` (`ops`: the 潜能 / 练度 settings, 0.2.2 — an import without them leaves the current settings alone) — copied through `ui/clipboard.js copyText` or downloaded as a file. 导入 accepts that envelope, the stored `{ v, entries }` form and a bare `{ [chessId]: { skill, module } }` map (serialised text too), and refuses another tool's `kind`, junk, an empty result and a **newer** `v` (`parseImport` — a newer payload is never mis-read). Both go through `loadoutSync.applyLoadoutEntries` → `sanitizeEntries`, so a preset from another build never sends the server an entry it would refuse, an import that keeps nothing changes nothing, and the result is reported as N imported / M not (`ui/loadoutModel.js`, `screens/loadout.js`).
|
||||||
**干员持有 (0.2.0 补位):** the overlay's second tab marks operators as not owned (the 55 NORMAL chess; per-browser `sp.pref.ownership` = `{ v, notOwned }`, synced as `room.ownership { notOwned }` by the same sync engine as the loadout, `ui/loadoutSync.js installPrefSync`; 导出 / 导入 as `{ kind: 'stronghold.ownership', v, exportedAt, count, notOwned }`). A not-owned chess fights as its official stand-in with the backup skill / module — the loadout never applies to it ("对于补位干员其技能不可更改") — and is shown as the stand-in wherever the player's copy appears (shop / reward cards, hand and 临时整备区, board, detail card, result lineup, a teammate's scouting and bond popups: portrait, name, model, with a small 「替补」 mark; the owner's recall of the official mode, 2026-10-06), while its bonds, 特质, tier, price and merge stay the chess's; the 0.2.0 补位 design subsections have the rules and sources.
|
**干员持有 (0.2.0 补位):** the overlay's second tab marks operators as not owned (the 55 NORMAL chess; per-browser `sp.pref.ownership` = `{ v, notOwned }`, synced as `room.ownership { notOwned }` by the same sync engine as the loadout, `ui/loadoutSync.js installPrefSync`; 导出 / 导入 as `{ kind: 'stronghold.ownership', v, exportedAt, count, notOwned }`). A not-owned chess fights as its official stand-in with the backup skill / module — the loadout never applies to it ("对于补位干员其技能不可更改") — and is shown as the stand-in wherever the player's copy appears (shop / reward cards, hand and 临时整备区, board, detail card, result lineup, a teammate's scouting and bond popups: portrait, name, model, with a small 「替补」 mark; the owner's recall of the official mode, 2026-10-06), while its bonds, 特质, tier, price and merge stay the chess's; the 0.2.0 补位 design subsections have the rules and sources.
|
||||||
**自选编队 (0.2.0):** the overlay's third tab fills the four DIY slots (5阶 ×2, 6阶 ×2) with an owned 6★ outside the chess pool (any of its three skills, any module of its elite form but a 集成战略 one) or a prototype (its locked skill / module); only operators with a kit are offered (`welcome.diyKitted`). Per-browser `sp.pref.diy` = `{ v, picks }`, synced as `room.diy { picks }` by the same sync engine; 导出 / 导入 as `{ kind: 'stronghold.diy', v, exportedAt, count, picks }` (`ui/diyModel.js`, `screens/diy.js`). In the match a slotted piece is the operator for its player (name, art, class, bonds from its factions, no 特质, the pick's skill and module; the slot's tier, price and merge), sold only in that player's shop from the slot's 调度中心 level with its own stock; the shop / reward / detail cards and the own pieces carry a 「自选」 badge (`ui/gameLogic/diy.js`); the 0.2.0 自选编队 design subsection has the rules and sources.
|
**自选编队 (0.2.0):** the overlay's third tab fills the four DIY slots (5阶 ×2, 6阶 ×2) with an owned 6★ outside the chess pool (any of its three skills, any module of its elite form but a 集成战略 one) or a prototype (its locked skill / module); only operators with a kit are offered (`welcome.diyKitted`). Per-browser `sp.pref.diy` = `{ v, picks }`, synced as `room.diy { picks }` by the same sync engine; 导出 / 导入 as `{ kind: 'stronghold.diy', v, exportedAt, count, picks }` (`ui/diyModel.js`, `screens/diy.js`). In the match a slotted piece is the operator for its player (name, art, class, bonds from its factions, no 特质, the pick's skill and module; the slot's tier, price and merge), sold only in that player's shop from the slot's 调度中心 level with its own stock; the shop / reward / detail cards and the own pieces carry a 「自选」 badge (`ui/gameLogic/diy.js`); the 0.2.0 自选编队 design subsection has the rules and sources.
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ Part of [DESIGN.md](../DESIGN.md) (the index; section numbers are global).
|
|||||||
Transport: one WebSocket per tab at `/ws`, JSON text frames `{ t: '<type>', ...payload }`. Client requests may carry `rid` (request id); the server's direct reply echoes `rid` (`ok` or `error`). Server pushes have no `rid`. Max frame 64 KB inbound; rate limit 40 msgs/s per connection (excess dropped with `error RATE`).
|
Transport: one WebSocket per tab at `/ws`, JSON text frames `{ t: '<type>', ...payload }`. Client requests may carry `rid` (request id); the server's direct reply echoes `rid` (`ok` or `error`). Server pushes have no `rid`. Max frame 64 KB inbound; rate limit 40 msgs/s per connection (excess dropped with `error RATE`).
|
||||||
|
|
||||||
### 8.1 Session & lobby
|
### 8.1 Session & lobby
|
||||||
C→S: `hello {name, token?, version}` · `room.create {mode:'solo'|'coop', difficulty}` · `room.join {code}` · `room.leave` · `room.ready {ready}` · `room.setDifficulty {difficulty}` (host) · `room.setAiPicksLast {on}` (host, co-op, before the match — `ROOM_STARTED` during it, `BAD_TARGET` in a solo room; un-readies the other humans like a difficulty change; the match takes it as `opts.aiPicksLast`: humans draft before AI seats, off by default, no extra random draw) · `room.addBot` / `room.removeBot {seat}` (host) · `room.kick {seat, playerId}` (host, before the match: another human leaves the room and gets `room.closed {reason:'kicked'}`; may join again; `playerId` = the player the host confirmed, a seat that changed hands meanwhile → `BAD_TARGET`) · `room.start` (host) · `room.loadout {entries}` (§16: any time in the lobby / room and during INFO_CHECK, then `WRONG_PHASE`; heavy rate-limit bucket; the match exposes it as `m.private.loadout`) · `room.ownership {notOwned}` (0.2.0 补位, the 干员持有 tab: the base chess ids marked as not owned — ≤ 160 ids, kept leniently: anything but a droppable NORMAL chess is dropped; stored on the session and the seat, a match takes the seat's list when it starts and never changes it — during a match `ROOM_STARTED` 'stored for the next match'; heavy bucket; the match exposes it as `m.private.standIns`) · `room.diy {picks}` (0.2.0 自选编队: `{ [slotBaseId]: { charId, skillIndex?, uniEquipId? } | null }` for the four DIY slots — ≤ 8 entries, kept leniently: shared/protocol.js `checkDiyPicks` drops an illegal pick (an operator without a kit, another tier's prototype, a prototype off its locked skill, a module of another game mode (集成战略 / 生息演算), an owned operator's second slot, the same operator twice in a tier, an unknown slot), only malformed picks are `BAD_MSG`; stored on the session and the seat like the not-owned list, a match takes the seat's picks when it starts — during a match `ROOM_STARTED` 'stored for the next match'; heavy bucket; the match exposes them as `m.private.diy`) · `ping {c}`
|
C→S: `hello {name, token?, version}` · `room.create {mode:'solo'|'coop', difficulty}` · `room.join {code}` · `room.leave` · `room.ready {ready}` · `room.setDifficulty {difficulty}` (host) · `room.setAiPicksLast {on}` (host, co-op, before the match — `ROOM_STARTED` during it, `BAD_TARGET` in a solo room; un-readies the other humans like a difficulty change; the match takes it as `opts.aiPicksLast`: humans draft before AI seats, off by default, no extra random draw) · `room.addBot` / `room.removeBot {seat}` (host) · `room.kick {seat, playerId}` (host, before the match: another human leaves the room and gets `room.closed {reason:'kicked'}`; may join again; `playerId` = the player the host confirmed, a seat that changed hands meanwhile → `BAD_TARGET`) · `room.start` (host) · `room.loadout {entries, ops?}` (§16: any time in the lobby / room and during INFO_CHECK, then `WRONG_PHASE`; heavy rate-limit bucket; the match exposes it as `m.private.loadout`; `ops` — 0.2.2 — the per-operator 潜能 / 练度 `{ [charId]: { potential?: 1–6, cultivate?: 0–3 } }` of the 干员调配 roster and the 自选 owned pool, ≤ 256, checked strictly with the entries, `m.private.ops`; absent = none set: every operator 潜能 6, 精英2 Lv.60) · `room.ownership {notOwned}` (0.2.0 补位, the 干员持有 tab: the base chess ids marked as not owned — ≤ 160 ids, kept leniently: anything but a droppable NORMAL chess is dropped; stored on the session and the seat, a match takes the seat's list when it starts and never changes it — during a match `ROOM_STARTED` 'stored for the next match'; heavy bucket; the match exposes it as `m.private.standIns`) · `room.diy {picks}` (0.2.0 自选编队: `{ [slotBaseId]: { charId, skillIndex?, uniEquipId? } | null }` for the four DIY slots — ≤ 8 entries, kept leniently: shared/protocol.js `checkDiyPicks` drops an illegal pick (an operator without a kit, another tier's prototype, a prototype off its locked skill, a module of another game mode (集成战略 / 生息演算), an owned operator's second slot, the same operator twice in a tier, an unknown slot), only malformed picks are `BAD_MSG`; stored on the session and the seat like the not-owned list, a match takes the seat's picks when it starts — during a match `ROOM_STARTED` 'stored for the next match'; heavy bucket; the match exposes them as `m.private.diy`) · `ping {c}`
|
||||||
S→C: `welcome {playerId, token, name, serverNow}` · `room.state {code, hostId, mode, difficulty, aiPicksLast, seats:[{seat, playerId, name, isBot, ready, connected}|null ×4]}` · `room.closed {reason}` · `pong {c, s}` · `error {rid?, code, msg}` · `ok {rid}`
|
S→C: `welcome {playerId, token, name, serverNow}` · `room.state {code, hostId, mode, difficulty, aiPicksLast, seats:[{seat, playerId, name, isBot, ready, connected}|null ×4]}` · `room.closed {reason}` · `pong {c, s}` · `error {rid?, code, msg}` · `ok {rid}`
|
||||||
|
|
||||||
Platform additions (implemented): `welcome.{resumed, version}`, `welcome.diyKitted` (0.2.0: the operators a 自选 slot may field — server/sim/content/kits/index.js `KITTED_CHARS`, the picker's list), `room.state.inMatch`, `error.detail`; `room.closed.reason ∈ 'timeout'|'kicked'|'empty'|'shutdown'` (`kicked`: room.kick / room.removeSpectator; `empty`: a spectator whose room lost its last player); WebSocket close codes 4001 (session taken over by another socket — client must not auto-reconnect), 4002 (no hello in time), 1008 (flooding), 1001 (shutdown). The host's `room.start` counts as the host being ready. After any (re)`hello` the server pushes `room.state` and, if a match runs, the match resends `m.public`/`m.private` (+`m.field`, or the field's `b.start` under client-side combat). **Reconnect window:** a dropped session stays resumable for 10 min (`net.js NET_DEFAULTS.reconnectWindowMs`; co-op seats keep playing their last lineup meanwhile, §6.6); a session that drops while its **solo** room's match runs stays resumable for the official `config.constants.singleReconnectTime` (86 400 s = 24 h; lobby option `soloReconnectWindowMs`; research 01 §1 "24小时内随时返回") — the untimed solo run simply waits. Only when the window expires does the drop become `match.onLeave` (co-op: 中途退出 = elimination; solo: the run ends `'abandoned'`). The client keeps its token in sessionStorage plus a recent-token list in localStorage, so reopening the tab resumes. Everything lives in server memory: a server restart ends every room and run. The lobby⇄match interface (constructor opts, `start/handle/onDisconnect/onReconnect/onLeave/dispose`) is documented verbatim at the top of `server/match/Match.js` and is normative. `server/data.js` provides `getData()` + index getters (`getChess`, `getBond`, `getGarrison`, `getItem`, `getBand`, `getEffect`, `getEnemy`, `getWave`, `getStage`, `getBoss`, `getToken`, `getConfig`, `getMode`). Note: the skill trigger rule lives at `chess.skill.trigger.rule` in data.
|
Platform additions (implemented): `welcome.{resumed, version}`, `welcome.diyKitted` (0.2.0: the operators a 自选 slot may field — server/sim/content/kits/index.js `KITTED_CHARS`, the picker's list), `room.state.inMatch`, `error.detail`; `room.closed.reason ∈ 'timeout'|'kicked'|'empty'|'shutdown'` (`kicked`: room.kick / room.removeSpectator; `empty`: a spectator whose room lost its last player); WebSocket close codes 4001 (session taken over by another socket — client must not auto-reconnect), 4002 (no hello in time), 1008 (flooding), 1001 (shutdown). The host's `room.start` counts as the host being ready. After any (re)`hello` the server pushes `room.state` and, if a match runs, the match resends `m.public`/`m.private` (+`m.field`, or the field's `b.start` under client-side combat). **Reconnect window:** a dropped session stays resumable for 10 min (`net.js NET_DEFAULTS.reconnectWindowMs`; co-op seats keep playing their last lineup meanwhile, §6.6); a session that drops while its **solo** room's match runs stays resumable for the official `config.constants.singleReconnectTime` (86 400 s = 24 h; lobby option `soloReconnectWindowMs`; research 01 §1 "24小时内随时返回") — the untimed solo run simply waits. Only when the window expires does the drop become `match.onLeave` (co-op: 中途退出 = elimination; solo: the run ends `'abandoned'`). The client keeps its token in sessionStorage plus a recent-token list in localStorage, so reopening the tab resumes. Everything lives in server memory: a server restart ends every room and run. The lobby⇄match interface (constructor opts, `start/handle/onDisconnect/onReconnect/onLeave/dispose`) is documented verbatim at the top of `server/match/Match.js` and is normative. `server/data.js` provides `getData()` + index getters (`getChess`, `getBond`, `getGarrison`, `getItem`, `getBand`, `getEffect`, `getEnemy`, `getWave`, `getStage`, `getBoss`, `getToken`, `getConfig`, `getMode`). Note: the skill trigger rule lives at `chess.skill.trigger.rule` in data.
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ Rich-text tags (`<@ba.vup>…</>`, `<$ba.inspire>…</>`) are stripped in this d
|
|||||||
| 6 | E2 Lv1 / S-Lv4 / mod 0 | E2 Lv60 / S-Lv7 / mod 3 | 4 (golden entry also 4) | 1 (golden 1) | char_elite_base_2 / char_elite_gold_2 |
|
| 6 | E2 Lv1 / S-Lv4 / mod 0 | E2 Lv60 / S-Lv7 / mod 3 | 4 (golden entry also 4) | 1 (golden 1) | char_elite_base_2 / char_elite_gold_2 |
|
||||||
|
|
||||||
- Every chess has `upgradeNum = 3`: 3 copies of the same normal chess -> 1 golden (精锐) chess (`upgradeChessId` = `goldenChessId`). Golden chess have `upgradeChessId = null` (no further upgrade). Exception via 特质: 风丸 `chess_char_2_11` (garrison_93: "只需2个相同的本干员即可合成精锐") needs only 2 copies.
|
- Every chess has `upgradeNum = 3`: 3 copies of the same normal chess -> 1 golden (精锐) chess (`upgradeChessId` = `goldenChessId`). Golden chess have `upgradeChessId = null` (no further upgrade). Exception via 特质: 风丸 `chess_char_2_11` (garrison_93: "只需2个相同的本干员即可合成精锐") needs only 2 copies.
|
||||||
- favorPoint = 0 in every status -> **trust bonus 0**. The status has no potential field (the tables here are at potential 0); the remake fights at **full potential** (潜能 6 — the owner's decision of 2026-10-07, GitHub #252: a tournament video shows 刺玫 at ATK 435 / cost 15 = her E1 Lv55 413 / 17 + 攻击力+22 and two 部署费用-1; data/chess.json, build-data `OPERATOR_POTENTIAL`). Module (模组) is **inactive on normal chess** (equipLevel 0) and **active on golden chess** at stage 1 (tiers 1–5) or stage 3 (tier 6). The module used is always `defaultUniEquipId` (always the operator's `uniequip_002_*` = first advanced module, X or Y type depending on operator; 凛御银灰 `char_1045_svash2` has none).
|
- favorPoint = 0 in every status -> **trust bonus 0**. The status has no potential field (the tables here are at potential 0); the remake fights at the **player's potential** (潜能 1–6 per operator in 干员调配, default full potential 潜能 6 — the owner's decisions of 2026-10-07, GitHub #252: a tournament video shows 刺玫 at ATK 435 / cost 15 = her E1 Lv55 413 / 17 + 攻击力+22 and two 部署费用-1, and of 2026-10-08: the 调度手册 「卫戍协议中干员潜能由自身已持有干员潜能决定」; data/chess.json is built at full potential and carries the lower ranks, DATA.md §2.3). Module (模组) is **inactive on normal chess** (equipLevel 0) and **active on golden chess** at stage 1 (tiers 1–5) or stage 3 (tier 6). The module used is always `defaultUniEquipId` (always the operator's `uniequip_002_*` = first advanced module, X or Y type depending on operator; 凛御银灰 `char_1045_svash2` has none).
|
||||||
- Golden stat jump is large: tier 1 goes E1 Lv55 -> E2 Lv50 (so skill 3 exists but chess keep their `defaultSkillIndex`; the skill index does NOT change on upgrade, only the level 4 -> 7).
|
- Golden stat jump is large: tier 1 goes E1 Lv55 -> E2 Lv50 (so skill 3 exists but chess keep their `defaultSkillIndex`; the skill index does NOT change on upgrade, only the level 4 -> 7).
|
||||||
- Prices: see 1.1 table; purchasePrice is per tier (T1=2, T2=3, T3=3, T4=3, T5=4, T6=4 coins); selling any chess returns 1 (data `chessSoldPrice`=1 for all; the shop/economy doc should confirm whether golden sells for more via other rules). Note: garrison `SERVER_PRICE` effects override the purchase price for specific chess (e.g. "购买价格为1").
|
- Prices: see 1.1 table; purchasePrice is per tier (T1=2, T2=3, T3=3, T4=3, T5=4, T6=4 coins); selling any chess returns 1 (data `chessSoldPrice`=1 for all; the shop/economy doc should confirm whether golden sells for more via other rules). Note: garrison `SERVER_PRICE` effects override the purchase price for specific chess (e.g. "购买价格为1").
|
||||||
- Board/bench limits (constData): maxBattleChessCnt = **8** on field, maxDeckChessCnt = **10** on bench (整备区), storeCntMax = 6, shopRefreshPrice = 1, fallbackBondId = `emptyShip` (协防干员), borrowCount = 20 (max friend-support borrows).
|
- Board/bench limits (constData): maxBattleChessCnt = **8** on field, maxDeckChessCnt = **10** on bench (整备区), storeCntMax = 6, shopRefreshPrice = 1, fallbackBondId = `emptyShip` (协防干员), borrowCount = 20 (max friend-support borrows).
|
||||||
@@ -35,7 +35,7 @@ Rich-text tags (`<@ba.vup>…</>`, `<$ba.inspire>…</>`) are stripped in this d
|
|||||||
|
|
||||||
- `stat(level) = kf0 + (kf1 - kf0) * (level - kf0.level) / (kf1.level - kf0.level)` using `character_table[charId].phases[evolvePhase].attributesKeyFrames` (two keyframes: Lv1 and max level). maxHp/atk/def/cost/blockCnt/respawnTime rounded half-up to int; others kept as float.
|
- `stat(level) = kf0 + (kf1 - kf0) * (level - kf0.level) / (kf1.level - kf0.level)` using `character_table[charId].phases[evolvePhase].attributesKeyFrames` (two keyframes: Lv1 and max level). maxHp/atk/def/cost/blockCnt/respawnTime rounded half-up to int; others kept as float.
|
||||||
- Golden: + module `battle_equip_table[defaultUniEquipId].phases[equipLevel-1].attributeBlackboard` (max_hp, atk, def, attack_speed, magic_resistance, …). Module trait additions are listed as `module.traitEffect` / `module.hiddenTalentEffects` in JSON.
|
- Golden: + module `battle_equip_table[defaultUniEquipId].phases[equipLevel-1].attributeBlackboard` (max_hp, atk, def, attack_speed, magic_resistance, …). Module trait additions are listed as `module.traitEffect` / `module.hiddenTalentEffects` in JSON.
|
||||||
- No trust. No potential in these tables — data/chess.json adds every `potentialRanks` attribute step and takes the talent candidates up to `requiredPotentialRank` 5 (full potential, the owner's decision of 2026-10-07). Talent-driven permanent stat increases (e.g. "攻击力+8%") are NOT folded into the numbers; apply them in the sim from `talents[]`.
|
- No trust. No potential in these tables — data/chess.json adds every `potentialRanks` attribute step and takes the talent candidates up to `requiredPotentialRank` 5 (full potential, the default) and carries what each lower potential changes (`potDown`, chained talents — DATA.md §2.3: the player's 干员调配 setting since 0.2.2). Talent-driven permanent stat increases (e.g. "攻击力+8%") are NOT folded into the numbers; apply them in the sim from `talents[]`.
|
||||||
- `cost` (DP) and `respawnTime` are used by automatic redeployment in battle (see 1.4).
|
- `cost` (DP) and `respawnTime` are used by automatic redeployment in battle (see 1.4).
|
||||||
- Attack interval = `baseAttackTime * 100 / attackSpeed` seconds.
|
- Attack interval = `baseAttackTime * 100 / attackSpeed` seconds.
|
||||||
|
|
||||||
|
|||||||
+102
-45
@@ -138,56 +138,93 @@
|
|||||||
.lo-toggle.is-on .lo-toggle__box { background: var(--mint-500); border-color: var(--mint-400); }
|
.lo-toggle.is-on .lo-toggle__box { background: var(--mint-500); border-color: var(--mint-400); }
|
||||||
.lo-toggle.is-on .lo-toggle__box .icon { opacity: 1; }
|
.lo-toggle.is-on .lo-toggle__box .icon { opacity: 1; }
|
||||||
|
|
||||||
/* ---- roster grid ------------------------------------------------------------------------------------------------------------ */
|
/* ---- roster: one list, one row per operator (0.2.2) ------------------------------------------------------------------------ */
|
||||||
.lo-grid {
|
/* The quick choices of PR #301 (by @farridge) as ONE list instead of three columns of cards (the owner's decision of
|
||||||
|
2026-10-08: 「三个竖排有点乱影响观感」): every row has the same four columns — the operator (portrait, name, bonds), the
|
||||||
|
skills, the elite's modules, 潜能 / 练度 — aligned under a sticky head row, so the eye runs down a column. Sizes: --q a
|
||||||
|
quick button (a skill / module), --art the portrait; a coarse pointer gets 40 px targets. */
|
||||||
|
.lo-list {
|
||||||
|
--q: max(.4rem, 32px); --qg: max(.05rem, 4px); --art: max(.52rem, 40px);
|
||||||
|
--cols: minmax(max(2.2rem, 110px), max(3.2rem, 260px)) calc(3 * var(--q) + 2 * var(--qg)) calc(4 * var(--q) + 3 * var(--qg)) max(2.2rem, 160px);
|
||||||
flex: 1 1 auto; min-height: 0; overflow: auto; -webkit-overflow-scrolling: touch; overscroll-behavior: contain;
|
flex: 1 1 auto; min-height: 0; overflow: auto; -webkit-overflow-scrolling: touch; overscroll-behavior: contain;
|
||||||
display: grid; grid-template-columns: repeat(auto-fill, minmax(max(1.16rem, 64px), 1fr)); grid-auto-rows: max-content;
|
padding: 0 .06rem .2rem 0; scrollbar-width: thin; scrollbar-color: var(--line-3) transparent;
|
||||||
align-content: start; gap: .1rem; padding: .06rem .08rem .2rem .02rem;
|
|
||||||
scrollbar-width: thin; scrollbar-color: var(--line-3) transparent;
|
|
||||||
}
|
}
|
||||||
.lo-empty { grid-column: 1 / -1; padding: .4rem; text-align: center; font-size: max(.16rem, 11px); }
|
.sp-coarse .lo-list { --q: max(.44rem, 40px); }
|
||||||
|
.lo-list__head, .lo .lo-card { display: grid; grid-template-columns: var(--cols); justify-content: start; align-items: center; column-gap: max(.12rem, 8px); }
|
||||||
|
/* (the head has the rows' box — the same borders, transparent, and padding — so its columns are the rows' to the pixel) */
|
||||||
|
.lo-list__head {
|
||||||
|
position: sticky; top: 0; z-index: 2; padding: .05rem .1rem .05rem .07rem; border: 1px solid transparent; border-left-width: 3px;
|
||||||
|
font-size: max(.12rem, 9px); font-weight: 700; letter-spacing: .12em; color: var(--text-dim);
|
||||||
|
background: linear-gradient(180deg, #0d1110 70%, rgba(13, 17, 16, .85)); box-shadow: inset 0 -1px 0 var(--line);
|
||||||
|
}
|
||||||
|
.lo-list__h small { margin-left: .05rem; font-weight: 400; letter-spacing: .04em; color: var(--gold-2); opacity: .8; }
|
||||||
|
.lo-list__rows { display: flex; flex-direction: column; gap: max(.04rem, 3px); padding-top: max(.05rem, 4px); }
|
||||||
|
.lo-empty { padding: .4rem; text-align: center; font-size: max(.16rem, 11px); }
|
||||||
|
|
||||||
.lo-card {
|
.lo .lo-card {
|
||||||
--tc: var(--tier-1);
|
--tc: var(--tier-1);
|
||||||
position: relative; display: flex; flex-direction: column; align-items: stretch; min-width: 0;
|
position: relative; min-width: 0; padding: max(.05rem, 4px) .1rem max(.05rem, 4px) .07rem;
|
||||||
padding: 0; font: inherit; color: var(--text-hi); text-align: left; cursor: pointer;
|
background: rgba(255, 255, 255, .025); border: 1px solid var(--line); border-left: 3px solid var(--tc);
|
||||||
background: linear-gradient(180deg, rgba(34, 41, 38, .95), rgba(14, 17, 16, .98));
|
transition: background var(--t-fast), border-color var(--t-fast);
|
||||||
border: 1px solid var(--line); box-shadow: inset 0 -2px 0 var(--tc);
|
|
||||||
transition: border-color var(--t-fast), transform var(--t-fast), box-shadow var(--t-fast);
|
|
||||||
touch-action: manipulation;
|
|
||||||
}
|
}
|
||||||
.lo-card--t1 { --tc: var(--tier-1); } .lo-card--t2 { --tc: var(--tier-2); } .lo-card--t3 { --tc: var(--tier-3); }
|
.lo-card--t1 { --tc: var(--tier-1); } .lo-card--t2 { --tc: var(--tier-2); } .lo-card--t3 { --tc: var(--tier-3); }
|
||||||
.lo-card--t4 { --tc: var(--tier-4); } .lo-card--t5 { --tc: var(--tier-5); } .lo-card--t6 { --tc: var(--tier-6); }
|
.lo-card--t4 { --tc: var(--tier-4); } .lo-card--t5 { --tc: var(--tier-5); } .lo-card--t6 { --tc: var(--tier-6); }
|
||||||
.lo-card:hover { border-color: var(--line-3); transform: translateY(-1px); }
|
.lo .lo-card:hover { background: rgba(255, 255, 255, .045); border-color: var(--line-3); border-left-color: var(--tc); }
|
||||||
.lo-card.is-sel { border-color: var(--mint-400); box-shadow: inset 0 -2px 0 var(--tc), 0 0 0 1px var(--mint-500), 0 0 .16rem rgba(23, 249, 183, .35); }
|
.lo .lo-card.is-sel { background: linear-gradient(90deg, rgba(78, 216, 175, .1), rgba(78, 216, 175, .02) 60%); border-color: var(--mint-700); border-left-color: var(--tc); box-shadow: 0 0 0 1px var(--mint-500) inset; }
|
||||||
.lo-card:focus-visible { outline: 2px solid var(--mint-400); outline-offset: 1px; }
|
.lo-card button:focus-visible, .lo-card select:focus-visible { outline: 2px solid var(--mint-400); outline-offset: 1px; }
|
||||||
.lo-card__art { position: relative; display: block; aspect-ratio: 1; overflow: hidden; background: #1b201e; }
|
.lo-card__pick { display: flex; align-items: center; gap: max(.1rem, 6px); min-width: 0; padding: 0; font: inherit; color: inherit; text-align: left; background: none; border: 0; cursor: pointer; touch-action: manipulation; }
|
||||||
.lo-card__art img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
.lo-card__art { position: relative; flex: none; width: var(--art); height: var(--art); overflow: hidden; background: #1b201e; border: 1px solid var(--line-2); }
|
||||||
.lo-card__art::after { content: ''; position: absolute; inset: 55% 0 0; background: linear-gradient(180deg, transparent, rgba(8, 10, 9, .85)); }
|
.lo-card__art > img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||||
.lo-card__glyph { display: grid; place-items: center; width: 100%; height: 100%; font-size: .44rem; font-weight: 900; color: var(--text-dim); }
|
.lo-card__glyph { display: grid; place-items: center; width: 100%; height: 100%; font-size: max(.24rem, 16px); font-weight: 900; color: var(--text-dim); }
|
||||||
.lo-card__tier { position: absolute; left: .03rem; top: .03rem; z-index: 1; }
|
.lo-card__tier { position: absolute; left: 1px; top: 1px; z-index: 1; transform-origin: 0 0; transform: scale(.8); }
|
||||||
.lo-card__flag {
|
.lo-card__id { display: flex; flex-direction: column; gap: max(.03rem, 2px); min-width: 0; }
|
||||||
position: absolute; right: 0; top: 0; z-index: 1; width: .22rem; height: .22rem;
|
.lo-card__name { font-size: max(.17rem, 12px); font-weight: 700; line-height: 1.2; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
background: linear-gradient(225deg, var(--mint-400) 50%, transparent 50%);
|
.lo-card__bonds { display: flex; flex-wrap: wrap; gap: max(.04rem, 3px); min-height: max(.16rem, 12px); }
|
||||||
}
|
.lo-card__bond, .lo-card__bonds .lo-bond__dot { width: max(.18rem, 13px); height: max(.18rem, 13px); object-fit: contain; }
|
||||||
/* 0.2.0 补位: a chess marked 未持有 on the 干员持有 tab (its stand-in fights with a fixed skill) */
|
.lo-card__flag { position: absolute; right: 0; top: 0; z-index: 1; width: max(.16rem, 10px); height: max(.16rem, 10px); background: linear-gradient(225deg, var(--mint-400) 50%, transparent 50%); pointer-events: none; }
|
||||||
.lo-card__sub { position: absolute; right: .03rem; top: .26rem; z-index: 1; padding: 0 .04rem; font-size: max(.11rem, 8px); font-weight: 700; line-height: 1.4; color: var(--ice-900); background: var(--ice); }
|
/* 0.2.0 补位: a chess marked 未持有 on the 干员持有 tab — its stand-in fights with a fixed skill and no 潜能 / 练度; the choices
|
||||||
|
here apply once it is owned again */
|
||||||
|
.lo-card__sub { align-self: flex-start; padding: 0 .04rem; font-size: max(.11rem, 8px); font-weight: 700; line-height: 1.4; color: var(--ice-900); background: var(--ice); }
|
||||||
|
.lo .lo-card.is-standin .lo-card__skills, .lo .lo-card.is-standin .lo-card__mods, .lo .lo-card.is-standin .lo-cult { opacity: .55; }
|
||||||
.lo-locknote--standin { color: var(--ice); }
|
.lo-locknote--standin { color: var(--ice); }
|
||||||
.lo-card__name {
|
|
||||||
position: absolute; left: .05rem; right: .05rem; top: calc(100% - max(.64rem, 34px)); z-index: 1;
|
.lo-card__skills, .lo-card__mods { display: flex; align-items: center; gap: var(--qg); min-width: 0; }
|
||||||
font-size: max(.15rem, 10px); font-weight: 700; line-height: 1.2; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
.lo-card__mods { overflow-x: auto; overscroll-behavior-x: contain; scrollbar-width: none; }
|
||||||
text-shadow: 0 1px 2px #000;
|
.lo-q {
|
||||||
|
position: relative; flex: none; display: grid; place-items: center; width: var(--q); height: var(--q); padding: 0;
|
||||||
|
font: inherit; color: var(--text-lo); background: none; border: 0; cursor: pointer; touch-action: manipulation;
|
||||||
}
|
}
|
||||||
.lo-card__kit { display: flex; align-items: center; justify-content: space-between; gap: .04rem; height: max(.3rem, 18px); padding: 0 .04rem; background: rgba(0, 0, 0, .4); }
|
.lo-q--empty { border: 1px dashed rgba(255, 255, 255, .07); cursor: default; }
|
||||||
.lo-card__sk { display: inline-flex; align-items: center; gap: .04rem; font-size: max(.13rem, 9px); color: var(--text-lo); }
|
.lo-q:not(.is-on):not(.lo-q--empty) { opacity: .62; }
|
||||||
.lo-card__sk b { font-family: var(--font-num); letter-spacing: .04em; }
|
.lo-q:not(.is-on):not(.lo-q--empty):hover { opacity: 1; }
|
||||||
.lo-card__sk.is-alt { color: var(--mint-400); }
|
.lo-q__type { position: absolute; left: 0; bottom: 0; padding: 0 2px; font-size: max(.11rem, 8px); line-height: 1.3; color: var(--text-hi); background: rgba(0, 0, 0, .7); pointer-events: none; }
|
||||||
.lo-card__mod {
|
.lo-q--mod.is-on .lo-mglyph { border-color: var(--gold-2); box-shadow: 0 0 .08rem rgba(255, 198, 0, .35); }
|
||||||
display: inline-grid; place-items: center; min-width: max(.24rem, 15px); height: max(.2rem, 13px); padding: 0 .03rem;
|
|
||||||
font-family: var(--font-num); font-size: max(.13rem, 9px); font-weight: 700; color: var(--text-lo);
|
/* 潜能 / 练度 (screens/cultivation.js): two compact selects side by side in the row's last column; at the defaults (潜能 6,
|
||||||
border: 1px solid var(--line-2); background: rgba(255, 255, 255, .04);
|
精英2 Lv.60 — the same on every row) they stay quiet, a changed one is mint */
|
||||||
}
|
.lo-cult { display: flex; align-items: center; gap: max(.04rem, 3px); min-width: 0; }
|
||||||
.lo-card__mod.is-alt { color: var(--text-on-mint); background: var(--gold-2); border-color: var(--gold); }
|
.lo .lo-cult .lo-select { min-height: 0; height: max(.32rem, 22px); padding-left: 0; border-color: transparent; background: rgba(255, 255, 255, .03); }
|
||||||
|
.lo .lo-cult .lo-select:hover, .lo .lo-cult .lo-select:focus-within { border-color: var(--line-3); }
|
||||||
|
.lo .lo-cult .lo-select select { width: 100%; min-width: 0; padding: 0 max(.2rem, 14px) 0 max(.06rem, 4px); font-size: max(.14rem, 10px); color: var(--text-lo); }
|
||||||
|
.lo-cult__pot { flex: 0 0 auto; }
|
||||||
|
.lo-cult__tier { flex: 1 1 auto; min-width: 0; }
|
||||||
|
.lo .lo-cult .lo-select.is-off { border-color: var(--mint-700); background: rgba(78, 216, 175, .08); }
|
||||||
|
.lo .lo-cult .lo-select.is-off select { color: var(--mint-400); font-weight: 700; }
|
||||||
|
.sp-coarse .lo .lo-cult .lo-select { height: max(.32rem, 28px); }
|
||||||
|
|
||||||
|
/* the detail's 潜能 / 练度 section */
|
||||||
|
.lo-sec--cult { padding: .08rem .1rem .1rem; margin-bottom: .14rem; background: rgba(0, 0, 0, .22); border: 1px solid var(--line); border-left: 3px solid var(--mint-500); }
|
||||||
|
.lo-sec--cult .lo-sec__head .lo-badge { margin-left: auto; }
|
||||||
|
.lo-cult__row { display: grid; grid-template-columns: max(.5rem, 30px) minmax(0, 1fr); align-items: center; gap: .08rem; }
|
||||||
|
.lo-cult__k { font-size: max(.13rem, 10px); color: var(--text-dim); letter-spacing: .08em; }
|
||||||
|
.lo .lo-cult__row .lo-seg { margin-left: 0; flex-wrap: wrap; justify-self: start; }
|
||||||
|
.lo-seg--pot button { min-width: max(.36rem, 26px); font-family: var(--font-num); }
|
||||||
|
.lo-seg--tier button { padding: 0 .1rem; }
|
||||||
|
.lo-cult__eff { margin: .02rem 0 0; font-size: max(.14rem, 10px); line-height: 1.5; color: var(--text-md); }
|
||||||
|
.lo-cult__eff b { color: var(--mint-400); margin-right: .04rem; }
|
||||||
|
.lo-cult__note { margin: 0; font-size: max(.12rem, 9px); line-height: 1.5; color: var(--text-dim); }
|
||||||
|
.lo-sec--cult.is-moot .lo-cult__row, .lo-sec--cult.is-moot .lo-cult__eff { opacity: .55; }
|
||||||
|
.lo-sec--cult.is-moot .lo-cult__note { color: var(--ice); }
|
||||||
|
|
||||||
/* ---- skill icon / module glyph ---------------------------------------------------------------------------------------------- */
|
/* ---- skill icon / module glyph ---------------------------------------------------------------------------------------------- */
|
||||||
.lo-sicon { position: relative; flex: none; display: inline-grid; place-items: center; width: .72rem; height: .72rem; background: #151a18; border: 1px solid var(--line-2); }
|
.lo-sicon { position: relative; flex: none; display: inline-grid; place-items: center; width: .72rem; height: .72rem; background: #151a18; border: 1px solid var(--line-2); }
|
||||||
@@ -195,9 +232,13 @@
|
|||||||
.lo-sicon--xs { width: max(.2rem, 13px); height: max(.2rem, 13px); border: 0; background: transparent; }
|
.lo-sicon--xs { width: max(.2rem, 13px); height: max(.2rem, 13px); border: 0; background: transparent; }
|
||||||
.lo-sicon--xs .lo-sicon__glyph { display: none; }
|
.lo-sicon--xs .lo-sicon__glyph { display: none; }
|
||||||
.lo-sicon--md { width: max(.72rem, 38px); height: max(.72rem, 38px); }
|
.lo-sicon--md { width: max(.72rem, 38px); height: max(.72rem, 38px); }
|
||||||
|
.lo-sicon--q { width: 86%; height: 86%; }
|
||||||
.lo-sicon__glyph { font-family: var(--font-num); font-size: max(.2rem, 12px); font-weight: 700; color: var(--text-lo); letter-spacing: .04em; }
|
.lo-sicon__glyph { font-family: var(--font-num); font-size: max(.2rem, 12px); font-weight: 700; color: var(--text-lo); letter-spacing: .04em; }
|
||||||
.lo-sicon.is-on { border-color: #1ea0ff; }
|
.lo-sicon.is-on { border-color: #1ea0ff; }
|
||||||
.lo-sicon__outline { position: absolute; inset: -.05rem; width: calc(100% + .1rem) !important; height: calc(100% + .1rem) !important; object-fit: fill; pointer-events: none; }
|
/* the selected skill's frame (a 30 × 30 sprite, a 3 px ring to its edges) drawn .05rem outside the icon on every side, square:
|
||||||
|
theme.css `img { max-width: 100% }` clamped its width to the icon's while its height grew by .1rem, and `.lo-sicon img`
|
||||||
|
(more specific) made it `cover` — a tall, left-shifted frame with clipped side lines (GitHub #398, PR #399) */
|
||||||
|
.lo-sicon .lo-sicon__outline { position: absolute; inset: -.05rem; width: calc(100% + .1rem) !important; height: calc(100% + .1rem) !important; max-width: none; object-fit: fill; pointer-events: none; }
|
||||||
.lo-sicon__ring { position: absolute; inset: -.05rem; border: 2px solid #1ea0ff; pointer-events: none; }
|
.lo-sicon__ring { position: absolute; inset: -.05rem; border: 2px solid #1ea0ff; pointer-events: none; }
|
||||||
|
|
||||||
.lo-mglyph { position: relative; flex: none; display: inline-grid; place-items: center; width: max(.56rem, 30px); height: max(.56rem, 30px);
|
.lo-mglyph { position: relative; flex: none; display: inline-grid; place-items: center; width: max(.56rem, 30px); height: max(.56rem, 30px);
|
||||||
@@ -205,6 +246,7 @@
|
|||||||
.lo-mglyph img { width: 78%; height: 78%; object-fit: contain; }
|
.lo-mglyph img { width: 78%; height: 78%; object-fit: contain; }
|
||||||
.lo-mglyph__t { font-family: var(--font-num); font-size: max(.26rem, 15px); font-weight: 700; color: var(--gold-2); line-height: 1; }
|
.lo-mglyph__t { font-family: var(--font-num); font-size: max(.26rem, 15px); font-weight: 700; color: var(--gold-2); line-height: 1; }
|
||||||
.lo-mglyph--none { background: #121615; border-style: dashed; }
|
.lo-mglyph--none { background: #121615; border-style: dashed; }
|
||||||
|
.lo-mglyph--q { width: 86%; height: 86%; }
|
||||||
.lo-mglyph__slash { position: absolute; inset: 22%; border-radius: 50%; border: 2px solid var(--text-dim); }
|
.lo-mglyph__slash { position: absolute; inset: 22%; border-radius: 50%; border: 2px solid var(--text-dim); }
|
||||||
.lo-mglyph__slash::after { content: ''; position: absolute; left: 50%; top: -10%; bottom: -10%; width: 2px; background: var(--text-dim); transform: translateX(-50%) rotate(45deg); }
|
.lo-mglyph__slash::after { content: ''; position: absolute; left: 50%; top: -10%; bottom: -10%; width: 2px; background: var(--text-dim); transform: translateX(-50%) rotate(45deg); }
|
||||||
|
|
||||||
@@ -236,6 +278,11 @@
|
|||||||
.lo-dhead__reset { position: absolute; right: .12rem; top: .12rem; }
|
.lo-dhead__reset { position: absolute; right: .12rem; top: .12rem; }
|
||||||
|
|
||||||
.lo-detail__body { flex: 1 1 auto; min-height: 0; overflow: auto; -webkit-overflow-scrolling: touch; overscroll-behavior: contain; padding: .12rem .18rem .2rem; scrollbar-width: thin; scrollbar-color: var(--line-3) transparent; }
|
.lo-detail__body { flex: 1 1 auto; min-height: 0; overflow: auto; -webkit-overflow-scrolling: touch; overscroll-behavior: contain; padding: .12rem .18rem .2rem; scrollbar-width: thin; scrollbar-color: var(--line-3) transparent; }
|
||||||
|
.lo-garrisons { margin-bottom: .18rem; }
|
||||||
|
.lo-garrisons .dgarrison__k { font-size: max(.14rem, 11px); }
|
||||||
|
.lo-garrisons .dgarrison__type { font-size: max(.12rem, 10px); }
|
||||||
|
.lo-garrisons .dgarrison__icon { width: max(.18rem, 14px); height: max(.18rem, 14px); }
|
||||||
|
.lo-garrisons .dgarrison__text { font-size: max(.15rem, 11px); }
|
||||||
.lo-sec { display: flex; flex-direction: column; gap: .08rem; }
|
.lo-sec { display: flex; flex-direction: column; gap: .08rem; }
|
||||||
.lo-sec + .lo-sec { margin-top: .18rem; }
|
.lo-sec + .lo-sec { margin-top: .18rem; }
|
||||||
.lo-sec__head { display: flex; align-items: center; gap: .12rem; min-height: max(.36rem, 22px); }
|
.lo-sec__head { display: flex; align-items: center; gap: .12rem; min-height: max(.36rem, 22px); }
|
||||||
@@ -425,6 +472,9 @@
|
|||||||
.diy-slot__sicon, .diy-slot__micon { flex: none; display: inline-grid; place-items: center; width: max(.24rem, 16px); height: max(.24rem, 16px); object-fit: contain; font-size: .8em; color: var(--text-lo); }
|
.diy-slot__sicon, .diy-slot__micon { flex: none; display: inline-grid; place-items: center; width: max(.24rem, 16px); height: max(.24rem, 16px); object-fit: contain; font-size: .8em; color: var(--text-lo); }
|
||||||
.diy-slot__micon--none { border: 1px solid var(--line-2); }
|
.diy-slot__micon--none { border: 1px solid var(--line-2); }
|
||||||
.diy-slot__acts { display: flex; flex-direction: column; gap: .04rem; }
|
.diy-slot__acts { display: flex; flex-direction: column; gap: .04rem; }
|
||||||
|
/* 0.2.2: an owned pick's 潜能 / 练度 (screens/cultivation.js) — a prototype has neither (a dim line instead) */
|
||||||
|
.diy-slot__cult { display: block; margin-top: .03rem; font-size: max(.12rem, 9px); }
|
||||||
|
.diy-slot__cult .lo-cult { max-width: max(2.6rem, 200px); }
|
||||||
.diy-slot__bad { display: flex; align-items: center; gap: .04rem; margin: .02rem 0 0; font-size: max(.12rem, 9px); color: var(--amber); }
|
.diy-slot__bad { display: flex; align-items: center; gap: .04rem; margin: .02rem 0 0; font-size: max(.12rem, 9px); color: var(--amber); }
|
||||||
.diy-slot.is-bad { border-color: rgba(255, 196, 0, .45); }
|
.diy-slot.is-bad { border-color: rgba(255, 196, 0, .45); }
|
||||||
.diy-tag { display: inline-flex; align-items: center; height: 1.5em; padding: 0 .4em; font-size: max(.11rem, 8px); font-weight: 700; border-radius: 1em; white-space: nowrap; }
|
.diy-tag { display: inline-flex; align-items: center; height: 1.5em; padding: 0 .4em; font-size: max(.11rem, 8px); font-weight: 700; border-radius: 1em; white-space: nowrap; }
|
||||||
@@ -527,9 +577,13 @@
|
|||||||
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
|
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/* phones (the root scale is clamped at 40 px: less than ~14 rem of width): the detail slides over the roster */
|
/* phones and small tablets (up to 1000 px wide; 0.2.2 — 760 px before the roster became one list with quick choices): the
|
||||||
|
list takes the whole width and the detail slides over it — a row's quick choice keeps the list, its portrait / name
|
||||||
|
opens the detail */
|
||||||
@media (max-width: 760px) {
|
@media (max-width: 760px) {
|
||||||
.lo-top { padding: 0 .16rem; }
|
.lo-top { padding: 0 .16rem; }
|
||||||
|
}
|
||||||
|
@media (max-width: 1000px) {
|
||||||
.lo-body { grid-template-columns: minmax(0, 1fr); }
|
.lo-body { grid-template-columns: minmax(0, 1fr); }
|
||||||
.lo-detail-wrap { display: none; }
|
.lo-detail-wrap { display: none; }
|
||||||
.lo-body.is-detail .lo-roster { display: none; }
|
.lo-body.is-detail .lo-roster { display: none; }
|
||||||
@@ -540,11 +594,14 @@
|
|||||||
}
|
}
|
||||||
.lo-detail-back .icon { width: .2rem; height: .2rem; }
|
.lo-detail-back .icon { width: .2rem; height: .2rem; }
|
||||||
}
|
}
|
||||||
|
/* narrow phones: the row's bonds give their line to the name, the columns keep their place */
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.lo-card__bonds { display: none; }
|
||||||
|
}
|
||||||
@media (max-height: 520px) {
|
@media (max-height: 520px) {
|
||||||
.lo-note { display: none; }
|
.lo-note { display: none; }
|
||||||
.lo-dhead { grid-template-columns: max(1rem, 48px) minmax(0, 1fr); padding: .1rem .14rem; }
|
.lo-dhead { grid-template-columns: max(1rem, 48px) minmax(0, 1fr); padding: .1rem .14rem; }
|
||||||
.lo-dhead__en { display: none; }
|
.lo-dhead__en { display: none; }
|
||||||
.lo-grid { grid-template-columns: repeat(auto-fill, minmax(max(1rem, 58px), 1fr)); }
|
|
||||||
}
|
}
|
||||||
/* the shared Button / TextField scale with the root (40 px/rem on phones ⇒ 6–7 px text, 14–20 px tall): the overlay's
|
/* the shared Button / TextField scale with the root (40 px/rem on phones ⇒ 6–7 px text, 14–20 px tall): the overlay's
|
||||||
own controls (返回, 全部恢复默认, 恢复默认, the search field) keep a readable size and a tappable height (review fix) */
|
own controls (返回, 全部恢复默认, 恢复默认, the search field) keep a readable size and a tappable height (review fix) */
|
||||||
@@ -555,7 +612,7 @@
|
|||||||
.lo .lo-search .field__icon { width: max(.22rem, 14px); height: max(.22rem, 14px); }
|
.lo .lo-search .field__icon { width: max(.22rem, 14px); height: max(.22rem, 14px); }
|
||||||
.sp-coarse .lo-chip, .sp-coarse .lo-toggle, .sp-coarse .lo-select { min-height: 30px; }
|
.sp-coarse .lo-chip, .sp-coarse .lo-toggle, .sp-coarse .lo-select { min-height: 30px; }
|
||||||
.sp-coarse .lo-seg button { min-height: 28px; }
|
.sp-coarse .lo-seg button { min-height: 28px; }
|
||||||
@media (prefers-reduced-motion: reduce) { .lo, .lo-card { animation: none; transition: none; } }
|
@media (prefers-reduced-motion: reduce) { .lo, .lo .lo-card { animation: none; transition: none; } }
|
||||||
|
|
||||||
/* 导出 / 导入 (干员调配 presets): the dialog is the shared Modal, rendered NEXT TO `.lo` (a sibling) rather than inside
|
/* 导出 / 导入 (干员调配 presets): the dialog is the shared Modal, rendered NEXT TO `.lo` (a sibling) rather than inside
|
||||||
it — so components.css `.modal { position: fixed }` and its z-index above the overlay apply as they do everywhere
|
it — so components.css `.modal { position: fixed }` and its z-index above the overlay apply as they do everywhere
|
||||||
|
|||||||
+28
-1
@@ -1154,5 +1154,32 @@
|
|||||||
"清空本机的统计数据": "Clear the statistics stored on this device",
|
"清空本机的统计数据": "Clear the statistics stored on this device",
|
||||||
"本机数据:只保存在这个浏览器里,不会上传到服务器;同盟对局里队友的代号也会存在本机(导出文件里也有)。": "Local data: kept only in this browser, never uploaded to the server. Teammates' names from Team Simulations are stored here too (and appear in an export file).",
|
"本机数据:只保存在这个浏览器里,不会上传到服务器;同盟对局里队友的代号也会存在本机(导出文件里也有)。": "Local data: kept only in this browser, never uploaded to the server. Teammates' names from Team Simulations are stored here too (and appear in an export file).",
|
||||||
"本机保存的统计数据无法读取,已另存为备份,从现在起重新记录。": "The stats stored on this device could not be read. They were set aside as a backup and recording starts afresh.",
|
"本机保存的统计数据无法读取,已另存为备份,从现在起重新记录。": "The stats stored on this device could not be read. They were set aside as a backup and recording starts afresh.",
|
||||||
"有 {n} 条记录已损坏,已忽略。": "{n} damaged {n|record was|records were} ignored."
|
"有 {n} 条记录已损坏,已忽略。": "{n} damaged {n|record was|records were} ignored.",
|
||||||
|
"攻回": "Offensive",
|
||||||
|
"受回": "Defensive",
|
||||||
|
"自回": "Auto",
|
||||||
|
"常驻": "Always",
|
||||||
|
"瞬发": "Instant",
|
||||||
|
"部署触发": "On deployment",
|
||||||
|
"未精英化": "Non-Elite",
|
||||||
|
"精英阶段1": "Elite 1",
|
||||||
|
"精英阶段2": "Elite 2",
|
||||||
|
"精英阶段2-60级": "Elite 2 Lv.60",
|
||||||
|
"潜能": "Potential",
|
||||||
|
"潜能 {n}": "Potential {n}",
|
||||||
|
"练度": "Development",
|
||||||
|
"潜能与练度": "Potential & development",
|
||||||
|
"满潜能 · 精英2 Lv.60": "Max potential · Elite 2 Lv.60",
|
||||||
|
"干员持有中标记为未持有:替补干员没有潜能与练度;这里的设置在改回「持有」后生效": "Marked as not owned on Operator Roster: the stand-in has no potential or development; these settings apply once it is owned again",
|
||||||
|
"原型干员没有潜能与练度": "A prototype Operator has no potential or development",
|
||||||
|
"模组仅在精锐形态生效": "Modules apply only to the Elite form",
|
||||||
|
"未持有(干员持有):由替补干员上场,替补干员没有潜能与练度": "Not owned (Operator Roster): its stand-in fights, with no potential or development",
|
||||||
|
"含潜能与练度;": "Potential and development included; ",
|
||||||
|
"将 {nChanged} 名干员的技能、模组、潜能与练度恢复为默认配置?": "Reset the skills, modules, potential and development of {nChanged} Operators to their defaults?",
|
||||||
|
"已导入 {n} 名干员的潜能与练度": "Imported the potential and development of {n} Operators",
|
||||||
|
"精英1": "E1",
|
||||||
|
"精英2": "E2",
|
||||||
|
"精英2 Lv.60": "E2 Lv.60",
|
||||||
|
"开始模拟前可调整干员携带的技能与模组,以及潜能与练度;干员的局内等级不可调整": "Before the simulation starts you can change the Skills and Modules your Operators carry, and their potential and development; Operator levels in the match cannot be changed",
|
||||||
|
"默认满潜能、精英2 Lv.60(满加成)。官方的潜能取你自己的潜能,练度是自持有加成(已持有的同名干员的养成,攻击 / 防御 / 生命单独乘算);未持有的特许干员在官方按潜能1、没有加成,要照官方打请手动设为潜能1、未精英化。": "Default: max Potential, Elite 2 Lv.60 (the full bonus). Officially, Potential is your own and development is the owned-operator bonus (your training of the same Operator: ATK / DEF / HP, a separate multiplier); a licensed Operator you do not own fights at Potential 1 with no bonus — to play it that way, set Potential 1 and Non-Elite here."
|
||||||
}
|
}
|
||||||
+28
-1
@@ -1160,5 +1160,32 @@
|
|||||||
"清空本机的统计数据": "この端末の統計データを消去",
|
"清空本机的统计数据": "この端末の統計データを消去",
|
||||||
"本机数据:只保存在这个浏览器里,不会上传到服务器;同盟对局里队友的代号也会存在本机(导出文件里也有)。": "この端末のデータ:このブラウザにのみ保存され、サーバーには送信されません。連合演算の仲間の名前もこの端末に保存されます(エクスポートファイルにも含まれます)。",
|
"本机数据:只保存在这个浏览器里,不会上传到服务器;同盟对局里队友的代号也会存在本机(导出文件里也有)。": "この端末のデータ:このブラウザにのみ保存され、サーバーには送信されません。連合演算の仲間の名前もこの端末に保存されます(エクスポートファイルにも含まれます)。",
|
||||||
"本机保存的统计数据无法读取,已另存为备份,从现在起重新记录。": "この端末に保存された統計データを読み取れませんでした。バックアップとして退避し、記録を最初からやり直します。",
|
"本机保存的统计数据无法读取,已另存为备份,从现在起重新记录。": "この端末に保存された統計データを読み取れませんでした。バックアップとして退避し、記録を最初からやり直します。",
|
||||||
"有 {n} 条记录已损坏,已忽略。": "破損した記録{n}件を無視しました。"
|
"有 {n} 条记录已损坏,已忽略。": "破損した記録{n}件を無視しました。",
|
||||||
|
"攻回": "攻撃",
|
||||||
|
"受回": "被撃",
|
||||||
|
"自回": "自動",
|
||||||
|
"常驻": "常時",
|
||||||
|
"瞬发": "即時",
|
||||||
|
"部署触发": "配置時",
|
||||||
|
"未精英化": "未昇進",
|
||||||
|
"精英阶段1": "昇進段階1",
|
||||||
|
"精英阶段2": "昇進段階2",
|
||||||
|
"精英阶段2-60级": "昇進段階2-Lv60",
|
||||||
|
"潜能": "潜在",
|
||||||
|
"潜能 {n}": "潜在{n}",
|
||||||
|
"练度": "育成度",
|
||||||
|
"潜能与练度": "潜在と育成度",
|
||||||
|
"满潜能 · 精英2 Lv.60": "潜在最大・昇進2 Lv60",
|
||||||
|
"干员持有中标记为未持有:替补干员没有潜能与练度;这里的设置在改回「持有」后生效": "オペレーター所持で未所持に設定中:代役オペレーターには潜在も育成度もありません。ここでの設定は「所持」に戻すと有効になります",
|
||||||
|
"原型干员没有潜能与练度": "モデルオペレーターには潜在も育成度もありません",
|
||||||
|
"模组仅在精锐形态生效": "モジュールは精鋭のみ有効です",
|
||||||
|
"未持有(干员持有):由替补干员上场,替补干员没有潜能与练度": "未所持(オペレーター所持):代役オペレーターが出撃し、潜在も育成度もありません",
|
||||||
|
"含潜能与练度;": "潜在と育成度を含む。",
|
||||||
|
"将 {nChanged} 名干员的技能、模组、潜能与练度恢复为默认配置?": "{nChanged}名のオペレーターのスキル・モジュール・潜在・育成度を初期設定に戻しますか?",
|
||||||
|
"已导入 {n} 名干员的潜能与练度": "{n}名のオペレーターの潜在と育成度をインポートしました",
|
||||||
|
"精英1": "昇進1",
|
||||||
|
"精英2": "昇進2",
|
||||||
|
"精英2 Lv.60": "昇進2 Lv60",
|
||||||
|
"开始模拟前可调整干员携带的技能与模组,以及潜能与练度;干员的局内等级不可调整": "シミュレーション開始前に、オペレーターのスキルとモジュール、潜在と育成度を調整できます(局内のオペレーターのレベルは変更不可)",
|
||||||
|
"默认满潜能、精英2 Lv.60(满加成)。官方的潜能取你自己的潜能,练度是自持有加成(已持有的同名干员的养成,攻击 / 防御 / 生命单独乘算);未持有的特许干员在官方按潜能1、没有加成,要照官方打请手动设为潜能1、未精英化。": "初期値は潜在最大・昇進2 Lv60(ボーナス最大)。公式では潜在はあなた自身の潜在、育成度は所持オペレーターボーナス(同名オペレーターの育成度による攻撃・防御・HPの独立した乗算)です。未所持のプリセットオペレーターは公式では潜在1・ボーナスなしのため、公式どおりにするには潜在1・未昇進に設定してください。"
|
||||||
}
|
}
|
||||||
+28
-1
@@ -1156,5 +1156,32 @@
|
|||||||
"清空本机的统计数据": "이 기기의 통계 데이터 비우기",
|
"清空本机的统计数据": "이 기기의 통계 데이터 비우기",
|
||||||
"本机数据:只保存在这个浏览器里,不会上传到服务器;同盟对局里队友的代号也会存在本机(导出文件里也有)。": "이 기기의 데이터: 이 브라우저에만 저장되며 서버로 전송되지 않습니다. 동맹 시뮬레이션에서 함께한 동료의 이름도 이 기기에 저장됩니다(내보낸 파일에도 포함).",
|
"本机数据:只保存在这个浏览器里,不会上传到服务器;同盟对局里队友的代号也会存在本机(导出文件里也有)。": "이 기기의 데이터: 이 브라우저에만 저장되며 서버로 전송되지 않습니다. 동맹 시뮬레이션에서 함께한 동료의 이름도 이 기기에 저장됩니다(내보낸 파일에도 포함).",
|
||||||
"本机保存的统计数据无法读取,已另存为备份,从现在起重新记录。": "이 기기에 저장된 통계 데이터를 읽을 수 없습니다. 백업으로 따로 보관했으며 지금부터 새로 기록합니다.",
|
"本机保存的统计数据无法读取,已另存为备份,从现在起重新记录。": "이 기기에 저장된 통계 데이터를 읽을 수 없습니다. 백업으로 따로 보관했으며 지금부터 새로 기록합니다.",
|
||||||
"有 {n} 条记录已损坏,已忽略。": "손상된 기록 {n}건을 무시했습니다."
|
"有 {n} 条记录已损坏,已忽略。": "손상된 기록 {n}건을 무시했습니다.",
|
||||||
|
"攻回": "공격",
|
||||||
|
"受回": "피격",
|
||||||
|
"自回": "자동",
|
||||||
|
"常驻": "상시",
|
||||||
|
"瞬发": "즉시",
|
||||||
|
"部署触发": "배치 시",
|
||||||
|
"未精英化": "비정예화",
|
||||||
|
"精英阶段1": "1차 정예화",
|
||||||
|
"精英阶段2": "2차 정예화",
|
||||||
|
"精英阶段2-60级": "2차 정예화 Lv.60",
|
||||||
|
"潜能": "잠재",
|
||||||
|
"潜能 {n}": "잠재 {n}",
|
||||||
|
"练度": "육성도",
|
||||||
|
"潜能与练度": "잠재와 육성도",
|
||||||
|
"满潜能 · 精英2 Lv.60": "최대 잠재 · 2차 정예화 Lv.60",
|
||||||
|
"干员持有中标记为未持有:替补干员没有潜能与练度;这里的设置在改回「持有」后生效": "오퍼레이터 보유에서 미보유로 표시됨: 대체 오퍼레이터는 잠재와 육성도가 없습니다. 이 설정은 다시 '보유'로 바꾸면 적용됩니다",
|
||||||
|
"原型干员没有潜能与练度": "프로토타입 오퍼레이터는 잠재와 육성도가 없습니다",
|
||||||
|
"模组仅在精锐形态生效": "모듈은 정예화 형태에서만 적용됩니다",
|
||||||
|
"未持有(干员持有):由替补干员上场,替补干员没有潜能与练度": "미보유(오퍼레이터 보유): 대체 오퍼레이터가 출전하며 잠재와 육성도가 없습니다",
|
||||||
|
"含潜能与练度;": "잠재와 육성도 포함. ",
|
||||||
|
"将 {nChanged} 名干员的技能、模组、潜能与练度恢复为默认配置?": "오퍼레이터 {nChanged}명의 스킬, 모듈, 잠재, 육성도를 기본 설정으로 되돌릴까요?",
|
||||||
|
"已导入 {n} 名干员的潜能与练度": "오퍼레이터 {n}명의 잠재와 육성도를 가져왔습니다",
|
||||||
|
"精英1": "정예 1",
|
||||||
|
"精英2": "정예 2",
|
||||||
|
"精英2 Lv.60": "정예 2 Lv.60",
|
||||||
|
"开始模拟前可调整干员携带的技能与模组,以及潜能与练度;干员的局内等级不可调整": "시뮬레이션 시작 전에 오퍼레이터의 스킬과 모듈, 잠재와 육성도를 조정할 수 있습니다. 게임 내 오퍼레이터 레벨은 조정할 수 없습니다.",
|
||||||
|
"默认满潜能、精英2 Lv.60(满加成)。官方的潜能取你自己的潜能,练度是自持有加成(已持有的同名干员的养成,攻击 / 防御 / 生命单独乘算);未持有的特许干员在官方按潜能1、没有加成,要照官方打请手动设为潜能1、未精英化。": "기본값은 최대 잠재 · 2차 정예화 Lv.60(최대 보너스)입니다. 공식 규칙에서 잠재는 자신의 잠재, 육성도는 보유 오퍼레이터 보너스(같은 오퍼레이터의 육성도에 따른 공격 / 방어 / HP 별도 배율)입니다. 보유하지 않은 프리셋 오퍼레이터는 공식적으로 잠재 1·보너스 없음이므로, 공식대로 하려면 잠재 1, 비정예화로 직접 설정하세요."
|
||||||
}
|
}
|
||||||
+28
-1
@@ -1158,5 +1158,32 @@
|
|||||||
"清空本机的统计数据": "清空本機的統計資料",
|
"清空本机的统计数据": "清空本機的統計資料",
|
||||||
"本机数据:只保存在这个浏览器里,不会上传到服务器;同盟对局里队友的代号也会存在本机(导出文件里也有)。": "本機資料:只保存在這個瀏覽器裡,不會上傳到伺服器;同盟對局裡隊友的代號也會存在本機(匯出檔案裡也有)。",
|
"本机数据:只保存在这个浏览器里,不会上传到服务器;同盟对局里队友的代号也会存在本机(导出文件里也有)。": "本機資料:只保存在這個瀏覽器裡,不會上傳到伺服器;同盟對局裡隊友的代號也會存在本機(匯出檔案裡也有)。",
|
||||||
"本机保存的统计数据无法读取,已另存为备份,从现在起重新记录。": "本機保存的統計資料無法讀取,已另存為備份,從現在起重新記錄。",
|
"本机保存的统计数据无法读取,已另存为备份,从现在起重新记录。": "本機保存的統計資料無法讀取,已另存為備份,從現在起重新記錄。",
|
||||||
"有 {n} 条记录已损坏,已忽略。": "有 {n} 筆記錄已損毀,已略過。"
|
"有 {n} 条记录已损坏,已忽略。": "有 {n} 筆記錄已損毀,已略過。",
|
||||||
|
"攻回": "攻回",
|
||||||
|
"受回": "受回",
|
||||||
|
"自回": "自回",
|
||||||
|
"常驻": "常駐",
|
||||||
|
"瞬发": "瞬發",
|
||||||
|
"部署触发": "部署觸發",
|
||||||
|
"未精英化": "未精英化",
|
||||||
|
"精英阶段1": "精英階段1",
|
||||||
|
"精英阶段2": "精英階段2",
|
||||||
|
"精英阶段2-60级": "精英階段2-60級",
|
||||||
|
"潜能": "潛能",
|
||||||
|
"潜能 {n}": "潛能 {n}",
|
||||||
|
"练度": "練度",
|
||||||
|
"潜能与练度": "潛能與練度",
|
||||||
|
"满潜能 · 精英2 Lv.60": "滿潛能 · 精英2 Lv.60",
|
||||||
|
"干员持有中标记为未持有:替补干员没有潜能与练度;这里的设置在改回「持有」后生效": "幹員持有中標記為未持有:替補幹員沒有潛能與練度;這裡的設定在改回「持有」後生效",
|
||||||
|
"原型干员没有潜能与练度": "原型幹員沒有潛能與練度",
|
||||||
|
"模组仅在精锐形态生效": "模組僅在精銳形態生效",
|
||||||
|
"未持有(干员持有):由替补干员上场,替补干员没有潜能与练度": "未持有(幹員持有):由替補幹員上場,替補幹員沒有潛能與練度",
|
||||||
|
"含潜能与练度;": "含潛能與練度;",
|
||||||
|
"将 {nChanged} 名干员的技能、模组、潜能与练度恢复为默认配置?": "將 {nChanged} 名幹員的技能、模組、潛能與練度恢復為預設配置?",
|
||||||
|
"已导入 {n} 名干员的潜能与练度": "已匯入 {n} 名幹員的潛能與練度",
|
||||||
|
"精英1": "精英1",
|
||||||
|
"精英2": "精英2",
|
||||||
|
"精英2 Lv.60": "精英2 Lv.60",
|
||||||
|
"开始模拟前可调整干员携带的技能与模组,以及潜能与练度;干员的局内等级不可调整": "開始模擬前可調整幹員攜帶的技能與模組,以及潛能與練度;幹員的局內等級不可調整",
|
||||||
|
"默认满潜能、精英2 Lv.60(满加成)。官方的潜能取你自己的潜能,练度是自持有加成(已持有的同名干员的养成,攻击 / 防御 / 生命单独乘算);未持有的特许干员在官方按潜能1、没有加成,要照官方打请手动设为潜能1、未精英化。": "預設滿潛能、精英2 Lv.60(滿加成)。官方的潛能取你自己的潛能,練度是自持有加成(已持有的同名幹員的養成,攻擊 / 防禦 / 生命單獨乘算);未持有的特許幹員在官方按潛能1、沒有加成,要照官方打請手動設為潛能1、未精英化。"
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
// 潜能 / 练度 controls of the 干员调配 overlay (0.2.2, the owner's decision of 2026-10-08: 「调配干员里自己设置吧,默认满潜
|
||||||
|
// 满加成」): per operator (by charId — the normal, elite and 自选 forms share them), 潜能 1–6 and the 自持有 练度 tier
|
||||||
|
// (effects.json CHAR_MAP aceffect_char_1…4: 未精英化 / 精英阶段1 / 精英阶段2 / 精英阶段2-60级 — their official names and
|
||||||
|
// texts, localized by the data overlay), default 潜能 6 / 精英阶段2-60级. Official: 调度手册 「卫戍协议中干员潜能由自身已
|
||||||
|
// 持有干员潜能决定」 and 「提升已持有的干员,将为卫戍协议中同名的干员提供加成」 (an operator the player does not own fights
|
||||||
|
// at 潜能 1 with no bonus: set by hand for a 特许 one; a NORMAL one not owned is its stand-in, which has neither).
|
||||||
|
// CultivationSelects: two compact selects (a roster row, a 自选 slot card); CultivationSection: the detail's segmented
|
||||||
|
// controls with the tier's effect. Hook-free views (the tests draw them). The settings live in ui/loadoutSync.js
|
||||||
|
// (`ops`, synced with room.loadout); the model is ui/loadoutModel.js opsOf / setOps.
|
||||||
|
|
||||||
|
import { html, MicroLabel } from '../ui/components.js';
|
||||||
|
import { data } from '../data.js';
|
||||||
|
import { opsOf } from '../ui/loadoutModel.js';
|
||||||
|
import { CULTIVATE_EFFECTS, POTENTIAL_MIN, POTENTIAL_MAX } from '../../../shared/potential.js';
|
||||||
|
import { t, N_ } from '../../../shared/i18n.js';
|
||||||
|
|
||||||
|
const cx = (...p) => p.flat().filter(Boolean).join(' ');
|
||||||
|
const POTENTIALS = Array.from({ length: POTENTIAL_MAX - POTENTIAL_MIN + 1 }, (_, i) => POTENTIAL_MIN + i);
|
||||||
|
const TIERS = CULTIVATE_EFFECTS.map((_, i) => i);
|
||||||
|
/** Names while effects.json is not loaded (msgids; the data's own names win). */
|
||||||
|
const FALLBACK_NAMES = Object.freeze([N_('未精英化'), N_('精英阶段1'), N_('精英阶段2'), N_('精英阶段2-60级')]);
|
||||||
|
/** The short names a row's select shows (the full official name is its title and the detail's). */
|
||||||
|
const SHORT_NAMES = Object.freeze([N_('未精英化'), N_('精英1'), N_('精英2'), N_('精英2 Lv.60')]);
|
||||||
|
|
||||||
|
/** The official name of 练度 tier `n` (effects.json `name`, localized), else its fallback. */
|
||||||
|
export function cultivateName(n) {
|
||||||
|
return data.lookup('effects', CULTIVATE_EFFECTS[n])?.name || t(FALLBACK_NAMES[n] || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The official effect text of 练度 tier `n` (effects.json `desc`: 「攻击力、防御力和最大生命值+10%」 …), or ''. */
|
||||||
|
export function cultivateDesc(n) {
|
||||||
|
return data.lookup('effects', CULTIVATE_EFFECTS[n])?.desc || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Two compact selects — 潜能 and 练度 — of operator `charId` under the stored settings `ops` (a roster row, a 自选 slot).
|
||||||
|
* @param {{ charId: string, ops: Record<string, any>, onSet: (charId: string, patch: { potential?: number, cultivate?: number }) => void,
|
||||||
|
* note?: string|null }} props `note`: why the settings do not apply now (a 补位 stand-in), shown as the tooltip
|
||||||
|
*/
|
||||||
|
export function CultivationSelects({ charId, ops, onSet, note = null }) {
|
||||||
|
if (!charId) return null;
|
||||||
|
const cur = opsOf(ops, charId);
|
||||||
|
const set = (patch) => onSet(charId, patch);
|
||||||
|
// a setting off the default stands out (mint); the default stays quiet — it is the same on every row
|
||||||
|
return html`<span class=${cx('lo-cult', cur.changed && 'is-changed', note && 'is-moot')} data-char=${charId} title=${note || undefined}>
|
||||||
|
<span class=${cx('lo-select', 'lo-cult__sel', 'lo-cult__pot', cur.potential !== 6 && 'is-off')}>
|
||||||
|
<select value=${String(cur.potential)} aria-label=${t('潜能')} data-cult="potential"
|
||||||
|
onChange=${(e) => set({ potential: Number(e.currentTarget.value) })}>
|
||||||
|
${POTENTIALS.map((n) => html`<option key=${n} value=${String(n)}>${t('潜能 {n}', { n })}</option>`)}
|
||||||
|
</select>
|
||||||
|
</span>
|
||||||
|
<span class=${cx('lo-select', 'lo-cult__sel', 'lo-cult__tier', cur.cultivate !== TIERS.length - 1 && 'is-off')} title=${note ? undefined : cultivateName(cur.cultivate)}>
|
||||||
|
<select value=${String(cur.cultivate)} aria-label=${t('练度')} data-cult="cultivate"
|
||||||
|
onChange=${(e) => set({ cultivate: Number(e.currentTarget.value) })}>
|
||||||
|
${TIERS.map((n) => html`<option key=${n} value=${String(n)}>${t(SHORT_NAMES[n])}</option>`)}
|
||||||
|
</select>
|
||||||
|
</span>
|
||||||
|
</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The detail's 潜能 / 练度 section: 潜能 1–6 and the four 练度 tiers as segmented buttons, the tier's official effect,
|
||||||
|
* the rule in one line, and — for a chess the player marked not owned — that its stand-in has neither.
|
||||||
|
* @param {{ charId: string, ops: Record<string, any>, onSet: (charId: string, patch: object) => void, standIn?: boolean }} props
|
||||||
|
*/
|
||||||
|
export function CultivationSection({ charId, ops, onSet, standIn = false }) {
|
||||||
|
if (!charId) return null;
|
||||||
|
const cur = opsOf(ops, charId);
|
||||||
|
const set = (patch) => onSet(charId, patch);
|
||||||
|
return html`<section class=${cx('lo-sec', 'lo-sec--cult', standIn && 'is-moot')} aria-label=${t('潜能与练度')} data-char=${charId}>
|
||||||
|
<header class="lo-sec__head">
|
||||||
|
<h3>${t('潜能与练度')}<${MicroLabel}>POTENTIAL<//></h3>
|
||||||
|
${cur.changed ? html`<span class="lo-badge lo-badge--changed">${t('已调整')}</span>` : html`<span class="lo-badge lo-badge--plain">${t('满潜能 · 精英2 Lv.60')}</span>`}
|
||||||
|
</header>
|
||||||
|
<div class="lo-cult__row">
|
||||||
|
<span class="lo-cult__k">${t('潜能')}</span>
|
||||||
|
<div class="lo-seg lo-seg--pot" role="radiogroup" aria-label=${t('潜能')}>
|
||||||
|
${POTENTIALS.map((n) => html`<button key=${n} type="button" role="radio" aria-checked=${cur.potential === n ? 'true' : 'false'}
|
||||||
|
class=${cx(cur.potential === n && 'is-on')} data-potential=${n} onClick=${() => set({ potential: n })}><span class="num">${n}</span></button>`)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="lo-cult__row">
|
||||||
|
<span class="lo-cult__k">${t('练度')}</span>
|
||||||
|
<div class="lo-seg lo-seg--tier" role="radiogroup" aria-label=${t('练度')}>
|
||||||
|
${TIERS.map((n) => html`<button key=${n} type="button" role="radio" aria-checked=${cur.cultivate === n ? 'true' : 'false'}
|
||||||
|
class=${cx(cur.cultivate === n && 'is-on')} data-cultivate=${n} onClick=${() => set({ cultivate: n })}>${cultivateName(n)}</button>`)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="lo-cult__eff" data-cultivate=${cur.cultivate}><b>${cultivateName(cur.cultivate)}</b> <span>${cultivateDesc(cur.cultivate)}</span></p>
|
||||||
|
<p class="lo-cult__note">${standIn
|
||||||
|
? t('干员持有中标记为未持有:替补干员没有潜能与练度;这里的设置在改回「持有」后生效')
|
||||||
|
: t('默认满潜能、精英2 Lv.60(满加成)。官方的潜能取你自己的潜能,练度是自持有加成(已持有的同名干员的养成,攻击 / 防御 / 生命单独乘算);未持有的特许干员在官方按潜能1、没有加成,要照官方打请手动设为潜能1、未精英化。')}</p>
|
||||||
|
</section>`;
|
||||||
|
}
|
||||||
@@ -6,7 +6,8 @@
|
|||||||
// 原型 / 已持有 tag —, an operator held by another slot greyed out with where it is. In the match the operator is sold
|
// 原型 / 已持有 tag —, an operator held by another slot greyed out with where it is. In the match the operator is sold
|
||||||
// only in this player's shop, from 调度中心 level 5 (tier 5) / 6 (tier 6), and fights with the pick (server/match/player
|
// only in this player's shop, from 调度中心 level 5 (tier 5) / 6 (tier 6), and fights with the pick (server/match/player
|
||||||
// /diy.js). Out of match: the next match takes the picks. The picks live in ui/loadoutSync.js (localStorage + room.diy);
|
// /diy.js). Out of match: the next match takes the picks. The picks live in ui/loadoutSync.js (localStorage + room.diy);
|
||||||
// the model is ui/diyModel.js. Styles: css/screens/loadout.css (diy-*).
|
// the model is ui/diyModel.js. Styles: css/screens/loadout.css (diy-*). An owned pick's card carries its operator's 潜能 /
|
||||||
|
// 练度 selects (0.2.2, screens/cultivation.js — the same per-operator settings as 干员调配, `ops`); a prototype has neither.
|
||||||
|
|
||||||
import { useEffect, useState } from '../../vendor/hooks.module.js';
|
import { useEffect, useState } from '../../vendor/hooks.module.js';
|
||||||
import { html, Icon, Button, TierChip } from '../ui/components.js';
|
import { html, Icon, Button, TierChip } from '../ui/components.js';
|
||||||
@@ -15,6 +16,7 @@ import { chessAvatarUrl, chessPortraitUrl, profIconUrl, skillRecordIconUrl, modu
|
|||||||
import { data } from '../data.js';
|
import { data } from '../data.js';
|
||||||
import { PROF_NAME, skillLabel, moduleBadge, fullTraitText } from '../ui/loadoutModel.js';
|
import { PROF_NAME, skillLabel, moduleBadge, fullTraitText } from '../ui/loadoutModel.js';
|
||||||
import { diySlotList, pickChoices, pickOptions, slotRecord, defaultPick } from '../ui/diyModel.js';
|
import { diySlotList, pickChoices, pickOptions, slotRecord, defaultPick } from '../ui/diyModel.js';
|
||||||
|
import { CultivationSelects } from './cultivation.js';
|
||||||
import { t } from '../../../shared/i18n.js';
|
import { t } from '../../../shared/i18n.js';
|
||||||
|
|
||||||
const cx = (...p) => p.flat().filter(Boolean).join(' ');
|
const cx = (...p) => p.flat().filter(Boolean).join(' ');
|
||||||
@@ -38,8 +40,8 @@ function Bonds({ bonds }) {
|
|||||||
return html`<span class="diy-bonds">${(bonds || []).map((b) => html`<span key=${b} class="diy-bond"><${BondGlyph} bondId=${b} /><span>${bondName(b)}</span></span>`)}</span>`;
|
return html`<span class="diy-bonds">${(bonds || []).map((b) => html`<span key=${b} class="diy-bond"><${BondGlyph} bondId=${b} /><span>${bondName(b)}</span></span>`)}</span>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One slot: the pick (operator, class, bonds, skill, module) or an empty slot to fill. */
|
/** One slot: the pick (operator, class, bonds, skill, module, an owned pick's 潜能 / 练度) or an empty slot to fill. */
|
||||||
function SlotCard({ m, slot, pick, illegal, onOpen, onClear }) {
|
function SlotCard({ m, slot, pick, illegal, onOpen, onClear, ops = {}, onOps = null }) {
|
||||||
const D = diyData();
|
const D = diyData();
|
||||||
const rec = pick ? slotRecord(slot.slotId, pick, D) : null;
|
const rec = pick ? slotRecord(slot.slotId, pick, D) : null;
|
||||||
const elite = pick ? slotRecord(slot.slotId, pick, D, { elite: true }) : null;
|
const elite = pick ? slotRecord(slot.slotId, pick, D, { elite: true }) : null;
|
||||||
@@ -73,6 +75,8 @@ function SlotCard({ m, slot, pick, illegal, onOpen, onClear }) {
|
|||||||
${mod ? html`<${Img} src=${moduleTypeIconUrl(data.get('local'), mod.type)} class="diy-slot__micon" fallback=${html`<b class="num">${moduleBadge({ typeName: mod.type })}</b>`} />` : html`<span class="diy-slot__micon diy-slot__micon--none">—</span>`}
|
${mod ? html`<${Img} src=${moduleTypeIconUrl(data.get('local'), mod.type)} class="diy-slot__micon" fallback=${html`<b class="num">${moduleBadge({ typeName: mod.type })}</b>`} />` : html`<span class="diy-slot__micon diy-slot__micon--none">—</span>`}
|
||||||
<span>${mod ? html`<b class="num">${mod.type || ''}</b> ${mod.name || ''}` : t('不装备模组')} <small class="t-dim">${t('(精锐时生效)')}</small></span>
|
<span>${mod ? html`<b class="num">${mod.type || ''}</b> ${mod.name || ''}` : t('不装备模组')} <small class="t-dim">${t('(精锐时生效)')}</small></span>
|
||||||
</span>
|
</span>
|
||||||
|
${proto ? html`<small class="diy-slot__cult t-dim">${t('原型干员没有潜能与练度')}</small>`
|
||||||
|
: onOps ? html`<span class="diy-slot__cult"><${CultivationSelects} charId=${rec.charId} ops=${ops} onSet=${onOps} /></span>` : null}
|
||||||
${illegal ? html`<p class="diy-slot__bad"><${Icon} name="warn" />${t('这项自选在当前版本不可用,开局时会被移除')}</p>` : null}
|
${illegal ? html`<p class="diy-slot__bad"><${Icon} name="warn" />${t('这项自选在当前版本不可用,开局时会被移除')}</p>` : null}
|
||||||
</div>
|
</div>
|
||||||
<div class="diy-slot__acts">
|
<div class="diy-slot__acts">
|
||||||
@@ -229,7 +233,9 @@ export function DiyPickerView({ m, slot, picks, kitted, onDone, onClose, filter,
|
|||||||
/**
|
/**
|
||||||
* The tab's body: the four slots by tier, and the picker of the slot being filled (`picking`, kept here).
|
* The tab's body: the four slots by tier, and the picker of the slot being filled (`picking`, kept here).
|
||||||
* @param {{ m: any, picks: Record<string, any>, legal: Record<string, any>, kitted: string[]|null,
|
* @param {{ m: any, picks: Record<string, any>, legal: Record<string, any>, kitted: string[]|null,
|
||||||
* onSet: (slotId: string, pick: any) => void }} props
|
* onSet: (slotId: string, pick: any) => void, ops?: Record<string, any>,
|
||||||
|
* onOps?: ((charId: string, patch: { potential?: number, cultivate?: number }) => void) | null }} props
|
||||||
|
* `ops` / `onOps`: the stored 潜能 / 练度 and their setter (0.2.2 — an owned pick's card shows its selects)
|
||||||
*/
|
*/
|
||||||
export function DiyPanel(props) {
|
export function DiyPanel(props) {
|
||||||
const [picking, setPicking] = useState(null);
|
const [picking, setPicking] = useState(null);
|
||||||
@@ -237,7 +243,7 @@ export function DiyPanel(props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** The tab's view (no hooks: the tests draw it): `picking` = the slot being filled, or null. */
|
/** The tab's view (no hooks: the tests draw it): `picking` = the slot being filled, or null. */
|
||||||
export function DiyPanelView({ m, picks, legal, kitted, onSet, picking, onPicking: setPicking }) {
|
export function DiyPanelView({ m, picks, legal, kitted, onSet, picking, onPicking: setPicking, ops = {}, onOps = null }) {
|
||||||
const slots = diySlotList(diyData());
|
const slots = diySlotList(diyData());
|
||||||
const slot = picking ? slots.find((s) => s.slotId === picking) : null;
|
const slot = picking ? slots.find((s) => s.slotId === picking) : null;
|
||||||
const tiers = [...new Set(slots.map((s) => s.tier))];
|
const tiers = [...new Set(slots.map((s) => s.tier))];
|
||||||
@@ -250,7 +256,7 @@ export function DiyPanelView({ m, picks, legal, kitted, onSet, picking, onPickin
|
|||||||
<h3 class="own-tier__head"><${TierChip} tier=${tier} size="sm" /><span class="num">${ROMAN[tier]}</span><span>${t('阶')}</span></h3>
|
<h3 class="own-tier__head"><${TierChip} tier=${tier} size="sm" /><span class="num">${ROMAN[tier]}</span><span>${t('阶')}</span></h3>
|
||||||
<div class="diy-grid">
|
<div class="diy-grid">
|
||||||
${slots.filter((s) => s.tier === tier).map((s) => html`<${SlotCard} key=${s.slotId} m=${m} slot=${s} pick=${picks[s.slotId] || null}
|
${slots.filter((s) => s.tier === tier).map((s) => html`<${SlotCard} key=${s.slotId} m=${m} slot=${s} pick=${picks[s.slotId] || null}
|
||||||
illegal=${!!picks[s.slotId] && !legal[s.slotId]} onOpen=${setPicking} onClear=${(id) => onSet(id, null)} />`)}
|
illegal=${!!picks[s.slotId] && !legal[s.slotId]} onOpen=${setPicking} onClear=${(id) => onSet(id, null)} ops=${ops} onOps=${onOps} />`)}
|
||||||
</div>
|
</div>
|
||||||
</section>`)}
|
</section>`)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1476,7 +1476,7 @@ function MatchScreen() {
|
|||||||
onClose=${() => setBondOpen(null)} onMember=${(id, items, standInFor, diy) => setDetail({ kind: 'chess', id, owner: bondPop.ownerId, items: items || null, standInFor: standInFor || null, diy: diy || null })} />` : null}
|
onClose=${() => setBondOpen(null)} onMember=${(id, items, standInFor, diy) => setDetail({ kind: 'chess', id, owner: bondPop.ownerId, items: items || null, standInFor: standInFor || null, diy: diy || null })} />` : null}
|
||||||
|
|
||||||
${resolved ? html`<${DetailPanel} detail=${resolved} snapHp=${snapHp} onClose=${() => { setDetail(null); setSel(null); }}
|
${resolved ? html`<${DetailPanel} detail=${resolved} snapHp=${snapHp} onClose=${() => { setDetail(null); setSel(null); }}
|
||||||
bonds=${detailBonds} offBonds=${offBonds} loadout=${detailLoadout} side=${dSide} shopOpen=${shopOpen} live=${liveStats} voice=${true}
|
bonds=${detailBonds} offBonds=${offBonds} loadout=${detailLoadout} ops=${priv?.ops ?? null} side=${dSide} shopOpen=${shopOpen} live=${liveStats} voice=${true}
|
||||||
onBond=${(id) => openBond(id, detailOwner, 'detail')} />` : null}
|
onBond=${(id) => openBond(id, detailOwner, 'detail')} />` : null}
|
||||||
|
|
||||||
${selEntry && editable && !facing && !drag && showPrep ? html`<${Underframe} key=${sel.uid} view=${view} uid=${sel.uid}
|
${selEntry && editable && !facing && !drag && showPrep ? html`<${Underframe} key=${sel.uid} view=${view} uid=${sel.uid}
|
||||||
|
|||||||
+182
-68
@@ -5,12 +5,16 @@
|
|||||||
// present: operator_preset, image_skill_select_outline, skill_select_deco, icon_equip_non; the module type icons of
|
// present: operator_preset, image_skill_select_outline, skill_select_deco, icon_equip_non; the module type icons of
|
||||||
// groups.module — lettered tiles without them).
|
// groups.module — lettered tiles without them).
|
||||||
//
|
//
|
||||||
// Left: roster of the 112 visible chess (tier / class / bond filters, search, 仅看已调整) — each card shows the equipped
|
// Left: the 112 visible chess as one list (tier / class / bond filters, search, 仅看已调整) — a row per operator, its columns
|
||||||
// skill (S1–S3) and, when changed, the elite's module badge. Right: the selected chess — skills (icon, name, 默认,
|
// aligned (0.2.2; the quick choices of PR #301 by @farridge, laid out as one list instead of three columns of cards — the
|
||||||
|
// owner's decision of 2026-10-08): portrait, name and bonds (a click opens the detail), the skills and the elite's modules
|
||||||
|
// to tap (不装备 first), the 潜能 and 练度 selects (screens/cultivation.js). Right: the selected chess — its 潜能 / 练度,
|
||||||
|
// its 特质 (the in-match card's block, PR #301), skills (icon, name, 默认,
|
||||||
// SP recovery, 初始 / 消耗 SP, duration, description at 普通 Lv.4 or 精锐 Lv.7), 局内数值 (the stats, 攻击范围, 特性 and
|
// SP recovery, 初始 / 消耗 SP, duration, description at 普通 Lv.4 or 精锐 Lv.7), 局内数值 (the stats, 攻击范围, 特性 and
|
||||||
// 天赋 the chosen variant — 精锐 first, 普通 on the toggle — fights with under the chosen skill and module: the detail
|
// 天赋 the chosen variant — 精锐 first, 普通 on the toggle — fights with under the chosen skill and module: the detail
|
||||||
// card's own block and pure functions, GitHub issue #64) and the elite's modules (不装备 / X / Y … with the stat bonus,
|
// card's own block and pure functions, GitHub issue #64) and the elite's modules (不装备 / X / Y … with the stat bonus,
|
||||||
// the trait upgrade and the talent changes), 恢复默认; 全部恢复默认 in the top bar.
|
// the trait upgrade and the talent changes), 恢复默认; 全部恢复默认 in the top bar. The detail's 普通 / 精锐 skill toggle is one
|
||||||
|
// preview for the skills, the 特质 and the rows' skill details (display only: never stored).
|
||||||
// The loadout lives in ui/loadoutSync.js (localStorage + room.loadout); the model is ui/loadoutModel.js.
|
// The loadout lives in ui/loadoutSync.js (localStorage + room.loadout); the model is ui/loadoutModel.js.
|
||||||
// The second tab, 干员持有 (0.2.0 补位, screens/ownership.js): which NORMAL chess the player owns — a chess marked 未持有 is
|
// The second tab, 干员持有 (0.2.0 补位, screens/ownership.js): which NORMAL chess the player owns — a chess marked 未持有 is
|
||||||
// deployed as its official stand-in (room.ownership; ui/ownershipModel.js) — with its own 导出 / 导入 / 全部持有.
|
// deployed as its official stand-in (room.ownership; ui/ownershipModel.js) — with its own 导出 / 导入 / 全部持有.
|
||||||
@@ -22,16 +26,20 @@ import { useEffect, useMemo, useRef, useState } from '../../vendor/hooks.module.
|
|||||||
import { html, Icon, MicroLabel, Button, TierChip, TextField, Countdown, Spinner, confirmDialog, hasDeadline, Modal, Fragment } from '../ui/components.js';
|
import { html, Icon, MicroLabel, Button, TierChip, TextField, Countdown, Spinner, confirmDialog, hasDeadline, Modal, Fragment } from '../ui/components.js';
|
||||||
import { Img, RichText, UnitThumb } from '../ui/gameComponents.js';
|
import { Img, RichText, UnitThumb } from '../ui/gameComponents.js';
|
||||||
import { chessAvatarUrl, chessPortraitUrl, subProfIconUrl, bondIconUrl, moduleTypeIconUrl } from '../ui/assetUrls.js';
|
import { chessAvatarUrl, chessPortraitUrl, subProfIconUrl, bondIconUrl, moduleTypeIconUrl } from '../ui/assetUrls.js';
|
||||||
import { chessStatsBlock, traitText, chessTalents } from '../ui/detailPanel.js';
|
import { chessStatsBlock, traitText, chessTalents, GarrisonBlock } from '../ui/detailPanel.js';
|
||||||
import { chessLoadout } from '../ui/gameLogic.js';
|
import { chessLoadout } from '../ui/gameLogic.js';
|
||||||
import { data, useData, localAsset, DATA_FILES } from '../data.js';
|
import { data, useData, localAsset, DATA_FILES } from '../data.js';
|
||||||
import { useStore } from '../store.js';
|
import { useStore } from '../store.js';
|
||||||
import { PHASE } from '../../../shared/constants.js';
|
import { PHASE } from '../../../shared/constants.js';
|
||||||
import {
|
import {
|
||||||
MODULE_NONE, PROF_ORDER, PROF_NAME, rosterOf, filterRoster, recordsOf, chessOptions, effectiveChoice, setChoice, resetChoice,
|
MODULE_NONE, PROF_ORDER, PROF_NAME, rosterOf, filterRoster, recordsOf, chessOptions, effectiveChoice, setChoice, resetChoice,
|
||||||
changedCount, skillLabel, moduleBadge, attrRows, skillTags, traitLines, serializeExport, parseImport, LOADOUT_IMPORT_MAX_BYTES,
|
changedCount, skillLabel, moduleBadge, attrRows, skillTags, quickSkillTags, traitLines, serializeExport, parseImport, LOADOUT_IMPORT_MAX_BYTES,
|
||||||
|
opsOf, setOps, resetOps, moduleRecord,
|
||||||
} from '../ui/loadoutModel.js';
|
} from '../ui/loadoutModel.js';
|
||||||
import { loadoutStore, openLoadout, closeLoadout, setEntries, applyLoadoutEntries, setNotOwned, applyOwnershipImport, setDiyPicks, applyDiyImport } from '../ui/loadoutSync.js';
|
import { loadoutStore, openLoadout, closeLoadout, setEntries, setOpsMap, applyLoadoutEntries, setNotOwned, applyOwnershipImport, setDiyPicks, applyDiyImport } from '../ui/loadoutSync.js';
|
||||||
|
import { CultivationSelects, CultivationSection } from './cultivation.js';
|
||||||
|
import { cultivationCharIds } from '../../../shared/protocol.js';
|
||||||
|
import { atPotential } from '../../../shared/potential.js';
|
||||||
import { setOwned, notOwnedCount, serializeOwnership, parseOwnershipImport, OWNERSHIP_IMPORT_MAX_BYTES } from '../ui/ownershipModel.js';
|
import { setOwned, notOwnedCount, serializeOwnership, parseOwnershipImport, OWNERSHIP_IMPORT_MAX_BYTES } from '../ui/ownershipModel.js';
|
||||||
import { OwnershipPanel, useOwnershipRoster } from './ownership.js';
|
import { OwnershipPanel, useOwnershipRoster } from './ownership.js';
|
||||||
import { DiyPanel, diyData } from './diy.js';
|
import { DiyPanel, diyData } from './diy.js';
|
||||||
@@ -140,38 +148,96 @@ export function ModuleGlyph({ m, rec, id, size = 'md' }) {
|
|||||||
</span>`;
|
</span>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- roster card ---------------------------------------------------------------------------------------------------
|
// ---- roster rows -----------------------------------------------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// One list, one row per operator, its columns aligned (the head row names them): the quick choices of PR #301 (by
|
||||||
|
// @farridge) without its three columns of cards. Every row is the same few columns — tap a skill, a module, a 潜能 / 练度 —
|
||||||
|
// so the eye runs down a column; the portrait and name open the detail (on a phone it slides over; a quick choice keeps
|
||||||
|
// the list). Hook-free (the tests draw it).
|
||||||
|
|
||||||
function RosterCard({ m, chess, golden, entries, selected, onPick, notOwned = false }) {
|
/** One skill to tap: its icon, the equipped one ringed; the label / tooltip says slot, name, SP and duration (quickSkillTags). */
|
||||||
const choice = effectiveChoice(entries, chess, golden);
|
function QuickSkill({ m, opt, on, elite, onPick }) {
|
||||||
const opt = chessOptions(chess, golden);
|
const rec = elite ? opt.elite || opt.normal : opt.normal || opt.elite;
|
||||||
const skillRec = opt.skillOptions.find((s) => s.index === choice.skill)?.normal || chess.skill;
|
const tags = quickSkillTags(rec);
|
||||||
const modRec = golden && choice.module !== MODULE_NONE ? opt.moduleOptions.find((x) => x.id === choice.module)?.rec || null : null;
|
const label = `${skillLabel(opt.index)} · ${rec?.name || t('未知技能')} · ${tags.sp} · ${t('初始')} ${tags.init ?? '—'} / ${t('消耗')} ${tags.cost ?? '—'} · ${t('持续')} ${tags.duration}`;
|
||||||
const modChanged = golden && choice.module !== opt.defaultModule;
|
return html`<button type="button" class=${cx('lo-q', 'lo-q--skill', on && 'is-on')} data-skill=${opt.index} aria-pressed=${on ? 'true' : 'false'}
|
||||||
return html`<button type="button" role="option" aria-selected=${selected ? 'true' : 'false'} data-chess=${chess.chessId}
|
aria-label=${label} title=${label} onClick=${() => onPick(opt.index)}>
|
||||||
class=${cx('lo-card', `lo-card--t${chess.tier}`, selected && 'is-sel', choice.changed && 'is-changed')} onClick=${() => onPick(chess.chessId)}
|
<${SkillIcon} m=${m} rec=${rec} index=${opt.index} on=${on} size="q" />
|
||||||
title=${`${chess.name} · ${skillRec?.name || ''}`}>
|
|
||||||
<span class="lo-card__art">
|
|
||||||
<${Img} src=${chessAvatarUrl(m, chess)} fallback=${html`<span class="lo-card__glyph">${[...(chess.name || '?')][0]}</span>`} />
|
|
||||||
</span>
|
|
||||||
<${TierChip} tier=${chess.tier} size="sm" class="lo-card__tier" />
|
|
||||||
${choice.changed ? html`<span class="lo-card__flag" aria-label=${t('已调整')}></span>` : null}
|
|
||||||
${notOwned ? html`<span class="lo-card__sub" title=${t('未持有(干员持有):由替补干员上场')} aria-label=${t('未持有')}>${t('替补')}</span>` : null}
|
|
||||||
<span class="lo-card__name">${chess.name}</span>
|
|
||||||
<span class="lo-card__kit">
|
|
||||||
<span class=${cx('lo-card__sk', choice.skill !== opt.defaultSkill && 'is-alt')}>
|
|
||||||
<${SkillIcon} m=${m} rec=${skillRec} index=${choice.skill} size="xs" />
|
|
||||||
<b class="num">${skillLabel(choice.skill)}</b>
|
|
||||||
</span>
|
|
||||||
${golden ? html`<span class=${cx('lo-card__mod', modChanged && 'is-alt')} title=${modRec ? `${modRec.typeName} ${modRec.name}` : t('不装备模组')}>
|
|
||||||
${choice.module === MODULE_NONE ? '—' : moduleBadge(modRec)}
|
|
||||||
</span>` : null}
|
|
||||||
</span>
|
|
||||||
</button>`;
|
</button>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One module to tap (不装备 first): the type icon with its letter in the corner; the elite's equipped one ringed. */
|
||||||
|
function QuickModule({ m, opt, on, onPick }) {
|
||||||
|
const label = opt.id === MODULE_NONE ? t('不装备模组') : `${opt.rec?.typeName || ''} · ${opt.rec?.name || opt.id}`;
|
||||||
|
return html`<button type="button" class=${cx('lo-q', 'lo-q--mod', on && 'is-on')} data-module=${opt.id} aria-pressed=${on ? 'true' : 'false'}
|
||||||
|
aria-label=${label} title=${label} onClick=${() => onPick(opt.id)}>
|
||||||
|
<${ModuleGlyph} m=${m} rec=${opt.rec} id=${opt.id} size="q" />
|
||||||
|
${opt.id !== MODULE_NONE ? html`<b class="lo-q__type num" aria-hidden="true">${moduleBadge(opt.rec, opt.id)}</b>` : null}
|
||||||
|
</button>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The list's column heads (the rows share its grid). */
|
||||||
|
export function RosterHead() {
|
||||||
|
return html`<div class="lo-list__head" aria-hidden="true">
|
||||||
|
<span class="lo-list__h lo-list__h--op">${t('干员')}</span>
|
||||||
|
<span class="lo-list__h lo-list__h--skills">${t('技能')}</span>
|
||||||
|
<span class="lo-list__h lo-list__h--mods" title=${t('模组仅在精锐形态生效')}>${t('模组')}<small>${t('精锐')}</small></span>
|
||||||
|
<span class="lo-list__h lo-list__h--cult">${t('潜能')} · ${t('练度')}</span>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One operator's row (`.lo-card`, like the cards before it): portrait + name + bonds (`.lo-card__pick`: selects it, opens
|
||||||
|
* the detail), three skill slots (an empty one when the chess has two skills), the elite's modules, the 潜能 / 练度 selects.
|
||||||
|
* `level` 'elite': the skills' details at 精锐 Lv.7 (the shared preview). A chess marked not owned (干员持有) shows the
|
||||||
|
* 「替补」 tag: its stand-in fights with a fixed skill and no 潜能 / 练度, the choices here apply once it is owned again.
|
||||||
|
*/
|
||||||
|
export function RosterRow({ m, chess, golden, entries, ops = {}, selected, onPick, onChange, onOps, level = 'normal', notOwned = false }) {
|
||||||
|
const choice = effectiveChoice(entries, chess, golden);
|
||||||
|
const opt = chessOptions(chess, golden);
|
||||||
|
const cv = opsOf(ops, chess.charId);
|
||||||
|
const changed = choice.changed || cv.changed;
|
||||||
|
const modules = [...opt.moduleOptions].sort((a, b) => Number(b.id === MODULE_NONE) - Number(a.id === MODULE_NONE));
|
||||||
|
const elite = level === 'elite' && !!golden;
|
||||||
|
const slots = [0, 1, 2].map((i) => opt.skillOptions[i] || null);
|
||||||
|
const standInNote = notOwned ? t('未持有(干员持有):由替补干员上场,替补干员没有潜能与练度') : null;
|
||||||
|
return html`<div role="listitem" data-chess=${chess.chessId} data-variant=${elite ? 'elite' : 'normal'}
|
||||||
|
class=${cx('lo-card', `lo-card--t${chess.tier}`, selected && 'is-sel', changed && 'is-changed', notOwned && 'is-standin')}>
|
||||||
|
<button type="button" class="lo-card__pick" aria-pressed=${selected ? 'true' : 'false'} title=${chess.name} onClick=${() => onPick(chess.chessId)}>
|
||||||
|
<span class="lo-card__art">
|
||||||
|
<${Img} src=${chessAvatarUrl(m, chess)} fallback=${html`<span class="lo-card__glyph">${[...(chess.name || '?')][0]}</span>`} />
|
||||||
|
<${TierChip} tier=${chess.tier} size="sm" class="lo-card__tier" />
|
||||||
|
</span>
|
||||||
|
<span class="lo-card__id">
|
||||||
|
<span class="lo-card__name">${chess.name}</span>
|
||||||
|
<span class="lo-card__bonds">${(chess.bonds || []).map((b) => html`<${Img} key=${b} src=${bondIconUrl(m, b)} class="lo-card__bond"
|
||||||
|
alt=${data.lookup('bonds', b)?.name || b} fallback=${html`<i class="lo-bond__dot" title=${data.lookup('bonds', b)?.name || b}></i>`} />`)}</span>
|
||||||
|
${notOwned ? html`<span class="lo-card__sub" title=${t('未持有(干员持有):由替补干员上场')}>${t('替补')}</span>` : null}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
${changed ? html`<span class="lo-card__flag" aria-label=${t('已调整')}></span>` : null}
|
||||||
|
<div class="lo-card__skills lo-quick" role="group" aria-label=${t('选择技能')}>
|
||||||
|
${slots.map((sk, i) => (sk ? html`<${QuickSkill} key=${sk.index} m=${m} opt=${sk} elite=${elite} on=${sk.index === choice.skill}
|
||||||
|
onPick=${(skill) => onChange(chess.chessId, { skill })} />` : html`<span key=${`e${i}`} class="lo-q lo-q--empty" aria-hidden="true"></span>`))}
|
||||||
|
</div>
|
||||||
|
<div class="lo-card__mods lo-quick" role="group" aria-label=${t('选择模组')} title=${t('模组仅在精锐形态生效')}>
|
||||||
|
${golden ? modules.map((mo) => html`<${QuickModule} key=${mo.id} m=${m} opt=${mo} on=${mo.id === choice.module}
|
||||||
|
onPick=${(module) => onChange(chess.chessId, { module })} />`) : null}
|
||||||
|
</div>
|
||||||
|
<${CultivationSelects} charId=${chess.charId} ops=${ops} onSet=${onOps} note=${standInNote} />
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
// ---- detail --------------------------------------------------------------------------------------------------------------
|
// ---- detail --------------------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** The same attribute records and rich-text renderer as the in-match card, including literal trigger markers. */
|
||||||
|
export function LoadoutGarrisons({ chess, m }) {
|
||||||
|
const records = (chess?.garrisonIds || []).map((id) => data.lookup('garrisons', id)).filter(Boolean);
|
||||||
|
return records.length ? html`<section class="lo-garrisons" data-variant=${chess.isGolden ? 'elite' : 'normal'}>
|
||||||
|
${records.map((garrison) => html`<${GarrisonBlock} key=${garrison.garrisonId} garrison=${garrison} m=${m} />`)}
|
||||||
|
</section>` : null;
|
||||||
|
}
|
||||||
|
|
||||||
function SkillOption({ m, opt, on, level, onPick }) {
|
function SkillOption({ m, opt, on, level, onPick }) {
|
||||||
const rec = level === 'elite' ? opt.elite || opt.normal : opt.normal || opt.elite;
|
const rec = level === 'elite' ? opt.elite || opt.normal : opt.normal || opt.elite;
|
||||||
const tags = skillTags(rec);
|
const tags = skillTags(rec);
|
||||||
@@ -198,9 +264,16 @@ function SkillOption({ m, opt, on, level, onPick }) {
|
|||||||
</button>`;
|
</button>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ModuleInfo({ m, golden, opt }) {
|
/**
|
||||||
|
* The chosen module's card: its stat bonus, the 特性 it fights with, its talent changes — read at the operator's 潜能
|
||||||
|
* (`potential`, 0.2.2: the elite composed at it first, shared/potential.js atPotential, as 局内数值 and the battle do; the
|
||||||
|
* data record is the full-potential one — 余's 闲云隐市 「生命上限2%(+0.5%)」 is 「1.5%」 at 潜能1). null = full potential.
|
||||||
|
* @param {{ m: any, golden: any, opt: { id: string, rec: any, isDefault?: boolean }, potential?: number|null }} props
|
||||||
|
*/
|
||||||
|
export function ModuleInfo({ m, golden, opt, potential = null }) {
|
||||||
if (!golden) return null;
|
if (!golden) return null;
|
||||||
const rec = opt.id === MODULE_NONE ? null : opt.rec;
|
const at = atPotential(golden, potential);
|
||||||
|
const rec = opt.id === MODULE_NONE ? null : at === golden ? opt.rec : moduleRecord(at, opt.id) || opt.rec;
|
||||||
if (!rec) {
|
if (!rec) {
|
||||||
const traitBase = golden.traitBase || null;
|
const traitBase = golden.traitBase || null;
|
||||||
return html`<div class="lo-minfo lo-minfo--none">
|
return html`<div class="lo-minfo lo-minfo--none">
|
||||||
@@ -240,13 +313,15 @@ const standInName = (chess) => data.get('backups')?.units?.[chess?.backup?.charI
|
|||||||
* @param {any} base normal chess record @param {any} golden its elite record or null
|
* @param {any} base normal chess record @param {any} golden its elite record or null
|
||||||
* @param {Record<string, any>} entries the stored loadout @param {'normal'|'elite'} level
|
* @param {Record<string, any>} entries the stored loadout @param {'normal'|'elite'} level
|
||||||
* @param {(id: string) => any} getChess
|
* @param {(id: string) => any} getChess
|
||||||
|
* @param {Record<string, any>|null} [ops] the stored 潜能 / 练度 (0.2.2: the numbers at the operator's settings — the
|
||||||
|
* 练度 multiplier needs effects.json; none set = 潜能 6, 精英2 Lv.60)
|
||||||
* @returns {{ elite: boolean, chess: any, lo: any, record: any, trait: string, talents: any[] } | null} null without a record
|
* @returns {{ elite: boolean, chess: any, lo: any, record: any, trait: string, talents: any[] } | null} null without a record
|
||||||
*/
|
*/
|
||||||
export function statsPreview(base, golden, entries, level, getChess) {
|
export function statsPreview(base, golden, entries, level, getChess, ops = null) {
|
||||||
const elite = level === 'elite' && !!golden;
|
const elite = level === 'elite' && !!golden;
|
||||||
const chess = elite ? golden : base;
|
const chess = elite ? golden : base;
|
||||||
if (!chess) return null;
|
if (!chess) return null;
|
||||||
const lo = chessLoadout(chess, entries, getChess);
|
const lo = chessLoadout(chess, entries, getChess, { ops, effects: data.get('effects') });
|
||||||
const record = lo?.record || chess;
|
const record = lo?.record || chess;
|
||||||
// (the card's own rule: the 特性 line exists when the chess has one; its text follows the chosen module)
|
// (the card's own rule: the 特性 line exists when the chess has one; its text follows the chosen module)
|
||||||
return { elite, chess, lo, record, trait: chess.trait?.desc ? traitText(chess, !!chess.isGolden, lo) || '' : '', talents: chessTalents(record) };
|
return { elite, chess, lo, record, trait: chess.trait?.desc ? traitText(chess, !!chess.isGolden, lo) || '' : '', talents: chessTalents(record) };
|
||||||
@@ -259,9 +334,10 @@ export function statsPreview(base, golden, entries, level, getChess) {
|
|||||||
* toggle picks the 普通 or the 精锐 record; 精锐 is the default because the module only exists there.
|
* toggle picks the 普通 or the 精锐 record; 精锐 is the default because the module only exists there.
|
||||||
* @param {{ base: any, golden: any, entries: Record<string, any>, level: 'normal'|'elite', onLevel: (l: 'normal'|'elite') => void, getChess?: (id: string) => any }} props
|
* @param {{ base: any, golden: any, entries: Record<string, any>, level: 'normal'|'elite', onLevel: (l: 'normal'|'elite') => void, getChess?: (id: string) => any }} props
|
||||||
*/
|
*/
|
||||||
export function LoadoutStats({ base, golden, entries, level, onLevel, getChess = getChessRec }) {
|
export function LoadoutStats({ base, golden, entries, level, onLevel, getChess = getChessRec, ops = null }) {
|
||||||
const pv = statsPreview(base, golden, entries, level, getChess);
|
const pv = statsPreview(base, golden, entries, level, getChess, ops);
|
||||||
if (!pv) return null;
|
if (!pv) return null;
|
||||||
|
// (the caption names 潜能 / 练度 when the numbers carry both — the 练度 multiplier needs effects.json)
|
||||||
return html`<section class="lo-sec lo-sec--stats" aria-label=${t('局内数值')} data-variant=${pv.elite ? 'elite' : 'normal'}>
|
return html`<section class="lo-sec lo-sec--stats" aria-label=${t('局内数值')} data-variant=${pv.elite ? 'elite' : 'normal'}>
|
||||||
<header class="lo-sec__head">
|
<header class="lo-sec__head">
|
||||||
<h3>${t('局内数值')}<${MicroLabel}>STATS<//></h3>
|
<h3>${t('局内数值')}<${MicroLabel}>STATS<//></h3>
|
||||||
@@ -276,18 +352,19 @@ export function LoadoutStats({ base, golden, entries, level, onLevel, getChess =
|
|||||||
${pv.talents.map((tal, i) => html`<div key=${i} class="lo-minfo__row"><span class="lo-minfo__k">${t('天赋')}</span>
|
${pv.talents.map((tal, i) => html`<div key=${i} class="lo-minfo__row"><span class="lo-minfo__k">${t('天赋')}</span>
|
||||||
<span class="lo-minfo__v"><b class="lo-minfo__tname">${tal.name}</b><${RichText} text=${tal.descRaw || tal.desc || ''} /></span></div>`)}
|
<span class="lo-minfo__v"><b class="lo-minfo__tname">${tal.name}</b><${RichText} text=${tal.descRaw || tal.desc || ''} /></span></div>`)}
|
||||||
</div>` : null}
|
</div>` : null}
|
||||||
<p class="lo-stats__cap">${pv.elite ? t('数值含所选模组;') : golden ? t('普通干员没有模组,所选模组在「精锐」中生效;') : ''}${t('不含技能发动、装备、盟约等局内加成')}</p>
|
<p class="lo-stats__cap">${pv.elite ? t('数值含所选模组;') : golden ? t('普通干员没有模组,所选模组在「精锐」中生效;') : ''}${pv.lo?.cultivation && data.get('effects') ? t('含潜能与练度;') : ''}${t('不含技能发动、装备、盟约等局内加成')}</p>
|
||||||
</section>`;
|
</section>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Detail({ m, chess, golden, entries, onChange, onReset, locked, notOwned = false }) {
|
function Detail({ m, chess, golden, entries, ops = {}, onChange, onOps, onReset, locked, level = 'normal', onLevel, notOwned = false }) {
|
||||||
const [level, setLevel] = useState('normal');
|
|
||||||
const [statLevel, setStatLevel] = useState('elite'); // 局内数值: the 精锐 shows the chosen module's effect
|
const [statLevel, setStatLevel] = useState('elite'); // 局内数值: the 精锐 shows the chosen module's effect
|
||||||
|
const setLevel = onLevel;
|
||||||
const bodyRef = useRef(null);
|
const bodyRef = useRef(null);
|
||||||
useEffect(() => { if (bodyRef.current) bodyRef.current.scrollTop = 0; }, [chess?.chessId]);
|
useEffect(() => { if (bodyRef.current) bodyRef.current.scrollTop = 0; }, [chess?.chessId]);
|
||||||
if (!chess) return html`<aside class="lo-detail lo-detail--empty"><p class="t-dim">${t('没有符合条件的干员')}</p></aside>`;
|
if (!chess) return html`<aside class="lo-detail lo-detail--empty"><p class="t-dim">${t('没有符合条件的干员')}</p></aside>`;
|
||||||
const opt = chessOptions(chess, golden);
|
const opt = chessOptions(chess, golden);
|
||||||
const choice = effectiveChoice(entries, chess, golden);
|
const choice = effectiveChoice(entries, chess, golden);
|
||||||
|
const changed = choice.changed || opsOf(ops, chess.charId).changed;
|
||||||
const modOpt = opt.moduleOptions.find((x) => x.id === choice.module) || null;
|
const modOpt = opt.moduleOptions.find((x) => x.id === choice.module) || null;
|
||||||
const lv = (c) => c?.status?.skillLevel ?? '—';
|
const lv = (c) => c?.status?.skillLevel ?? '—';
|
||||||
return html`<aside class="lo-detail" aria-label=${t('{name} 调配', { name: chess.name })}>
|
return html`<aside class="lo-detail" aria-label=${t('{name} 调配', { name: chess.name })}>
|
||||||
@@ -297,7 +374,7 @@ function Detail({ m, chess, golden, entries, onChange, onReset, locked, notOwned
|
|||||||
</div>
|
</div>
|
||||||
<div class="lo-dhead__info">
|
<div class="lo-dhead__info">
|
||||||
<div class="lo-dhead__chips"><${TierChip} tier=${chess.tier} size="md" />
|
<div class="lo-dhead__chips"><${TierChip} tier=${chess.tier} size="md" />
|
||||||
${choice.changed ? html`<span class="lo-badge lo-badge--changed">${t('已调整')}</span>` : html`<span class="lo-badge lo-badge--plain">${t('默认配置')}</span>`}</div>
|
${changed ? html`<span class="lo-badge lo-badge--changed">${t('已调整')}</span>` : html`<span class="lo-badge lo-badge--plain">${t('默认配置')}</span>`}</div>
|
||||||
<h2 class="lo-dhead__name">${chess.name}</h2>
|
<h2 class="lo-dhead__name">${chess.name}</h2>
|
||||||
<span class="lo-dhead__en">${chess.appellation || ''}</span>
|
<span class="lo-dhead__en">${chess.appellation || ''}</span>
|
||||||
<span class="lo-dhead__class">
|
<span class="lo-dhead__class">
|
||||||
@@ -307,9 +384,11 @@ function Detail({ m, chess, golden, entries, onChange, onReset, locked, notOwned
|
|||||||
<span class="lo-dhead__bonds">${(chess.bonds || []).map((b) => html`<span key=${b} class="lo-bond">
|
<span class="lo-dhead__bonds">${(chess.bonds || []).map((b) => html`<span key=${b} class="lo-bond">
|
||||||
<${Img} src=${bondIconUrl(m, b)} class="lo-bond__icon" fallback=${html`<i class="lo-bond__dot"></i>`} />${data.lookup('bonds', b)?.name || b}</span>`)}</span>
|
<${Img} src=${bondIconUrl(m, b)} class="lo-bond__icon" fallback=${html`<i class="lo-bond__dot"></i>`} />${data.lookup('bonds', b)?.name || b}</span>`)}</span>
|
||||||
</div>
|
</div>
|
||||||
<${Button} variant="ghost" size="sm" icon="refresh" class="lo-dhead__reset" disabled=${!choice.changed} onClick=${onReset}>${t('恢复默认')}<//>
|
<${Button} variant="ghost" size="sm" icon="refresh" class="lo-dhead__reset" disabled=${!changed} onClick=${onReset}>${t('恢复默认')}<//>
|
||||||
</div>
|
</div>
|
||||||
<div class="lo-detail__body" ref=${bodyRef}>
|
<div class="lo-detail__body" ref=${bodyRef}>
|
||||||
|
<${CultivationSection} charId=${chess.charId} ops=${ops} onSet=${onOps} standIn=${notOwned} />
|
||||||
|
<${LoadoutGarrisons} chess=${level === 'elite' && golden ? golden : chess} m=${m} />
|
||||||
<section class="lo-sec">
|
<section class="lo-sec">
|
||||||
<header class="lo-sec__head">
|
<header class="lo-sec__head">
|
||||||
<h3>${t('技能')}<${MicroLabel}>SKILL<//></h3>
|
<h3>${t('技能')}<${MicroLabel}>SKILL<//></h3>
|
||||||
@@ -323,7 +402,7 @@ function Detail({ m, chess, golden, entries, onChange, onReset, locked, notOwned
|
|||||||
onPick=${(i) => onChange({ skill: i })} />`)}
|
onPick=${(i) => onChange({ skill: i })} />`)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<${LoadoutStats} base=${chess} golden=${golden} entries=${entries} level=${statLevel} onLevel=${setStatLevel} />
|
<${LoadoutStats} base=${chess} golden=${golden} entries=${entries} ops=${ops} level=${statLevel} onLevel=${setStatLevel} />
|
||||||
${golden ? html`<section class="lo-sec lo-sec--mod">
|
${golden ? html`<section class="lo-sec lo-sec--mod">
|
||||||
<header class="lo-sec__head">
|
<header class="lo-sec__head">
|
||||||
<h3>${t('模组')}<${MicroLabel}>MODULE<//></h3>
|
<h3>${t('模组')}<${MicroLabel}>MODULE<//></h3>
|
||||||
@@ -341,7 +420,7 @@ function Detail({ m, chess, golden, entries, onChange, onReset, locked, notOwned
|
|||||||
${mo.isDefault ? html`<span class="lo-badge lo-badge--def lo-mod__def">${t('默认')}</span>` : null}
|
${mo.isDefault ? html`<span class="lo-badge lo-badge--def lo-mod__def">${t('默认')}</span>` : null}
|
||||||
</button>`)}
|
</button>`)}
|
||||||
</div>
|
</div>
|
||||||
${modOpt ? html`<${ModuleInfo} m=${m} golden=${golden} opt=${modOpt} />` : null}
|
${modOpt ? html`<${ModuleInfo} m=${m} golden=${golden} opt=${modOpt} potential=${opsOf(ops, chess.charId).potential} />` : null}
|
||||||
</section>` : null}
|
</section>` : null}
|
||||||
${notOwned ? html`<p class="lo-locknote lo-locknote--standin" data-testid="loadout-standin-note"><${Icon} name="info" />${standInName(chess) ? t('干员持有中标记为未持有:此棋子由替补干员 {name} 上场,技能与模组固定(补位干员技能不可更改);这里的调配在改回「持有」后生效', { name: standInName(chess) })
|
${notOwned ? html`<p class="lo-locknote lo-locknote--standin" data-testid="loadout-standin-note"><${Icon} name="info" />${standInName(chess) ? t('干员持有中标记为未持有:此棋子由替补干员 {name} 上场,技能与模组固定(补位干员技能不可更改);这里的调配在改回「持有」后生效', { name: standInName(chess) })
|
||||||
: t('干员持有中标记为未持有:此棋子由替补干员上场,技能与模组固定(补位干员技能不可更改);这里的调配在改回「持有」后生效')}</p>` : null}
|
: t('干员持有中标记为未持有:此棋子由替补干员上场,技能与模组固定(补位干员技能不可更改);这里的调配在改回「持有」后生效')}</p>` : null}
|
||||||
@@ -433,7 +512,7 @@ export function DataMissing({ files }) {
|
|||||||
|
|
||||||
/** The overlay screen. */
|
/** The overlay screen. */
|
||||||
function LoadoutScreen({ st }) {
|
function LoadoutScreen({ st }) {
|
||||||
const ready = useData('chess', 'bonds', 'assets', 'local', 'backups');
|
const ready = useData('chess', 'bonds', 'assets', 'local', 'backups', 'garrisons', 'effects');
|
||||||
const phase = useStore((s) => s.match?.public?.phase || null);
|
const phase = useStore((s) => s.match?.public?.phase || null);
|
||||||
const inMatch = useStore((s) => !!s.room?.inMatch);
|
const inMatch = useStore((s) => !!s.room?.inMatch);
|
||||||
// co-op briefing (INFO_CHECK, 25 s): the overlay covers the briefing's own countdown, so it shows the time left — the
|
// co-op briefing (INFO_CHECK, 25 s): the overlay covers the briefing's own countdown, so it shows the time left — the
|
||||||
@@ -448,14 +527,16 @@ function LoadoutScreen({ st }) {
|
|||||||
return (data.list('bonds') || []).filter((b) => b && used.has(b.bondId))
|
return (data.list('bonds') || []).filter((b) => b && used.has(b.bondId))
|
||||||
.sort((a, b) => (b.isCore ? 1 : 0) - (a.isCore ? 1 : 0) || (a.bondOrder ?? 0) - (b.bondOrder ?? 0) || String(a.name).localeCompare(String(b.name), 'zh'));
|
.sort((a, b) => (b.isCore ? 1 : 0) - (a.isCore ? 1 : 0) || (a.bondOrder ?? 0) - (b.bondOrder ?? 0) || String(a.name).localeCompare(String(b.name), 'zh'));
|
||||||
}, [ready, roster]);
|
}, [ready, roster]);
|
||||||
const list = filterRoster(roster, st.filters, st.entries, getChess, getBond);
|
const list = filterRoster(roster, st.filters, st.entries, getChess, getBond, st.ops);
|
||||||
const selId = st.sel && roster.some((c) => c.chessId === st.sel) ? st.sel : list[0]?.chessId || roster[0]?.chessId || null;
|
const selId = st.sel && roster.some((c) => c.chessId === st.sel) ? st.sel : list[0]?.chessId || roster[0]?.chessId || null;
|
||||||
const { base, golden } = selId ? recordsOf(selId, getChess) : { base: null, golden: null };
|
const { base, golden } = selId ? recordsOf(selId, getChess) : { base: null, golden: null };
|
||||||
const nChanged = changedCount(st.entries, getChess);
|
const nChanged = changedCount(st.entries, getChess, st.ops, roster);
|
||||||
const locked = (inMatch && phase && phase !== PHASE.INFO_CHECK && phase !== PHASE.LOBBY) || st.sync === 'locked';
|
const locked = (inMatch && phase && phase !== PHASE.INFO_CHECK && phase !== PHASE.LOBBY) || st.sync === 'locked';
|
||||||
const gridRef = useRef(null);
|
const gridRef = useRef(null);
|
||||||
const fileRef = useRef(null); // hidden <input type=file> of the 导入 dialog
|
const fileRef = useRef(null); // hidden <input type=file> of the 导入 dialog
|
||||||
const [narrowDetail, setNarrowDetail] = useState(false); // phones: the detail slides over the roster
|
const [narrowDetail, setNarrowDetail] = useState(false); // phones: the detail slides over the roster
|
||||||
|
// the detail's 普通 / 精锐 skill toggle: one preview for its skills, its 特质 and the rows' skill details (display only)
|
||||||
|
const [previewLevel, setPreviewLevel] = useState('normal');
|
||||||
const [io, setIo] = useState(null); // 导出 / 导入 dialog: { mode, text } | null
|
const [io, setIo] = useState(null); // 导出 / 导入 dialog: { mode, text } | null
|
||||||
|
|
||||||
const tab = st.tab === 'ownership' || st.tab === 'diy' ? st.tab : 'loadout';
|
const tab = st.tab === 'ownership' || st.tab === 'diy' ? st.tab : 'loadout';
|
||||||
@@ -479,12 +560,30 @@ function LoadoutScreen({ st }) {
|
|||||||
if (ok) setNotOwned([]);
|
if (ok) setNotOwned([]);
|
||||||
};
|
};
|
||||||
const pick = (id) => { loadoutStore.set({ sel: id }); setNarrowDetail(true); };
|
const pick = (id) => { loadoutStore.set({ sel: id }); setNarrowDetail(true); };
|
||||||
|
// a row's quick choice (PR #301): the same setChoice / setEntries path; the row becomes the selection, a phone stays on the list
|
||||||
|
const quickChange = (id, patch) => {
|
||||||
|
const recs = recordsOf(id, getChess);
|
||||||
|
setEntries(setChoice(loadoutStore.get().entries, recs.base, recs.golden, patch));
|
||||||
|
loadoutStore.set({ sel: id });
|
||||||
|
};
|
||||||
const change = (patch) => { if (base) setEntries(setChoice(loadoutStore.get().entries, base, golden, patch)); };
|
const change = (patch) => { if (base) setEntries(setChoice(loadoutStore.get().entries, base, golden, patch)); };
|
||||||
const resetOne = () => { if (base) setEntries(resetChoice(loadoutStore.get().entries, base.chessId)); };
|
// 0.2.2: an operator's 潜能 / 练度 (by charId: its normal, elite and 自选 forms share them)
|
||||||
|
const setOpsOf = (charId, patch) => setOpsMap(setOps(loadoutStore.get().ops, charId, patch));
|
||||||
|
const resetOne = () => {
|
||||||
|
if (!base) return;
|
||||||
|
setOpsMap(resetOps(loadoutStore.get().ops, base.charId));
|
||||||
|
setEntries(resetChoice(loadoutStore.get().entries, base.chessId));
|
||||||
|
};
|
||||||
const resetAll = async () => {
|
const resetAll = async () => {
|
||||||
if (!nChanged) return;
|
if (!nChanged) return;
|
||||||
const ok = await confirmDialog({ title: t('全部恢复默认'), text: t('将 {nChanged} 名干员的技能与模组恢复为默认配置?', { nChanged }), okText: t('恢复默认'), danger: true });
|
const ok = await confirmDialog({ title: t('全部恢复默认'), text: t('将 {nChanged} 名干员的技能、模组、潜能与练度恢复为默认配置?', { nChanged }), okText: t('恢复默认'), danger: true });
|
||||||
if (ok) setEntries({});
|
if (!ok) return;
|
||||||
|
// the roster's operators only: a 自选 pick's settings belong to the 自选编队 tab
|
||||||
|
const rosterChars = new Set(roster.map((c) => c.charId));
|
||||||
|
const keep = {};
|
||||||
|
for (const [id, e] of Object.entries(loadoutStore.get().ops || {})) if (!rosterChars.has(id)) keep[id] = e;
|
||||||
|
setOpsMap(keep);
|
||||||
|
setEntries({});
|
||||||
};
|
};
|
||||||
|
|
||||||
// 导出 / 导入 the loadout (or, on the 干员持有 tab, the not-owned list) as the versioned payload (a downloaded file,
|
// 导出 / 导入 the loadout (or, on the 干员持有 tab, the not-owned list) as the versioned payload (a downloaded file,
|
||||||
@@ -495,7 +594,7 @@ function LoadoutScreen({ st }) {
|
|||||||
const openExport = () => setIo(tab === 'ownership'
|
const openExport = () => setIo(tab === 'ownership'
|
||||||
? { mode: 'export', kind: 'ownership', text: serializeOwnership(loadoutStore.get().notOwned) }
|
? { mode: 'export', kind: 'ownership', text: serializeOwnership(loadoutStore.get().notOwned) }
|
||||||
: tab === 'diy' ? { mode: 'export', kind: 'diy', text: serializeDiy(loadoutStore.get().diy) }
|
: tab === 'diy' ? { mode: 'export', kind: 'diy', text: serializeDiy(loadoutStore.get().diy) }
|
||||||
: { mode: 'export', kind: 'loadout', text: serializeExport(loadoutStore.get().entries) });
|
: { mode: 'export', kind: 'loadout', text: serializeExport(loadoutStore.get().entries, { ops: loadoutStore.get().ops }) });
|
||||||
const openImport = () => setIo({ mode: 'import', kind: tab, text: '' });
|
const openImport = () => setIo({ mode: 'import', kind: tab, text: '' });
|
||||||
const ioCopy = async () => {
|
const ioCopy = async () => {
|
||||||
const ok = await copyText(ioText);
|
const ok = await copyText(ioText);
|
||||||
@@ -536,13 +635,17 @@ function LoadoutScreen({ st }) {
|
|||||||
}
|
}
|
||||||
const res = parseImport(ioText);
|
const res = parseImport(ioText);
|
||||||
if (!res.ok) { toast(t('导入失败:{error}', { error: res.error }), 'error'); return; }
|
if (!res.ok) { toast(t('导入失败:{error}', { error: res.error }), 'error'); return; }
|
||||||
const { applied, dropped } = applyLoadoutEntries(res.entries, getChess);
|
// 0.2.2: a payload with `ops` replaces the 潜能 / 练度 too (an older export leaves them alone)
|
||||||
|
const opIds = res.ops ? cultivationCharIds(data.get('chess'), data.get('backups')) : null;
|
||||||
|
const r = applyLoadoutEntries(res.entries, getChess, res.ops ? { ops: res.ops, isOperator: (id) => opIds.has(id) } : {});
|
||||||
|
const { applied, dropped } = r;
|
||||||
|
const nOps = r.ops ?? 0;
|
||||||
// nothing survived sanitising (unknown chess, or every choice already the default): keep the current loadout
|
// nothing survived sanitising (unknown chess, or every choice already the default): keep the current loadout
|
||||||
if (!applied) { toast(t('导入失败:这份数据在当前版本没有可用的调配,未做任何改动'), 'error'); return; }
|
if (!applied && !nOps) { toast(t('导入失败:这份数据在当前版本没有可用的调配,未做任何改动'), 'error'); return; }
|
||||||
setIo(null);
|
setIo(null);
|
||||||
toast(dropped
|
toast(!applied ? t('已导入 {n} 名干员的潜能与练度', { n: nOps })
|
||||||
? t('已导入 {applied} 名干员(另有 {dropped} 项未导入)', { applied, dropped })
|
: dropped ? t('已导入 {applied} 名干员(另有 {dropped} 项未导入)', { applied, dropped })
|
||||||
: t('已导入 {applied} 名干员的调配', { applied }), dropped ? 'warn' : 'success');
|
: t('已导入 {applied} 名干员的调配', { applied }), dropped ? 'warn' : 'success');
|
||||||
};
|
};
|
||||||
|
|
||||||
// Esc closes; ←/→ browse the filtered roster (not while typing in the search field)
|
// Esc closes; ←/→ browse the filtered roster (not while typing in the search field)
|
||||||
@@ -554,8 +657,10 @@ function LoadoutScreen({ st }) {
|
|||||||
const typing = e.target && /^(INPUT|SELECT|TEXTAREA)$/.test(e.target.tagName);
|
const typing = e.target && /^(INPUT|SELECT|TEXTAREA)$/.test(e.target.tagName);
|
||||||
if (e.key === 'Escape') { e.preventDefault(); e.stopImmediatePropagation(); closeLoadout(); return; }
|
if (e.key === 'Escape') { e.preventDefault(); e.stopImmediatePropagation(); closeLoadout(); return; }
|
||||||
if (typing) return;
|
if (typing) return;
|
||||||
|
// (a row's quick choices: ←/→ stay with the focused group, PR #301)
|
||||||
|
if (e.target?.closest?.('.lo-quick') && (e.key === 'ArrowRight' || e.key === 'ArrowLeft')) return;
|
||||||
if ((e.key === 'ArrowRight' || e.key === 'ArrowLeft') && loadoutStore.get().tab !== 'ownership' && loadoutStore.get().tab !== 'diy') {
|
if ((e.key === 'ArrowRight' || e.key === 'ArrowLeft') && loadoutStore.get().tab !== 'ownership' && loadoutStore.get().tab !== 'diy') {
|
||||||
const ids = filterRoster(rosterOf(data.list('chess')), loadoutStore.get().filters, loadoutStore.get().entries, getChess, getBond).map((c) => c.chessId);
|
const ids = filterRoster(rosterOf(data.list('chess')), loadoutStore.get().filters, loadoutStore.get().entries, getChess, getBond, loadoutStore.get().ops).map((c) => c.chessId);
|
||||||
if (!ids.length) return;
|
if (!ids.length) return;
|
||||||
const cur = Math.max(0, ids.indexOf(loadoutStore.get().sel));
|
const cur = Math.max(0, ids.indexOf(loadoutStore.get().sel));
|
||||||
const next = ids[(cur + (e.key === 'ArrowRight' ? 1 : -1) + ids.length) % ids.length];
|
const next = ids[(cur + (e.key === 'ArrowRight' ? 1 : -1) + ids.length) % ids.length];
|
||||||
@@ -577,7 +682,7 @@ function LoadoutScreen({ st }) {
|
|||||||
|
|
||||||
const [syncText, syncCls] = tab === 'ownership' ? OWN_SYNC_TEXT[st.ownSync] || OWN_SYNC_TEXT.idle
|
const [syncText, syncCls] = tab === 'ownership' ? OWN_SYNC_TEXT[st.ownSync] || OWN_SYNC_TEXT.idle
|
||||||
: tab === 'diy' ? DIY_SYNC_TEXT[st.diySync] || DIY_SYNC_TEXT.idle : SYNC_TEXT[st.sync] || SYNC_TEXT.idle;
|
: tab === 'diy' ? DIY_SYNC_TEXT[st.diySync] || DIY_SYNC_TEXT.idle : SYNC_TEXT[st.sync] || SYNC_TEXT.idle;
|
||||||
const fromText = st.from === 'briefing' ? t('确认本局信息阶段结束前可调整本局配置') : t('开始模拟前可调整干员携带的技能与模组,干员等级不可调整');
|
const fromText = st.from === 'briefing' ? t('确认本局信息阶段结束前可调整本局配置') : t('开始模拟前可调整干员携带的技能与模组,以及潜能与练度;干员的局内等级不可调整');
|
||||||
// 干员持有 is out of match: a running match keeps the list its seat had at its start
|
// 干员持有 is out of match: a running match keeps the list its seat had at its start
|
||||||
const ownLocked = inMatch && !!phase && phase !== PHASE.LOBBY;
|
const ownLocked = inMatch && !!phase && phase !== PHASE.LOBBY;
|
||||||
const ownText = ownLocked ? t('干员持有是局外设置:本局按开局时的设置进行,修改将在下一局生效')
|
const ownText = ownLocked ? t('干员持有是局外设置:本局按开局时的设置进行,修改将在下一局生效')
|
||||||
@@ -631,20 +736,25 @@ function LoadoutScreen({ st }) {
|
|||||||
: html`<p class=${cx('lo-note', locked && 'is-locked')}><${Icon} name="info" />${locked ? t('本局的调配已锁定(确认本局信息后无法修改),修改将在下一局生效') : fromText}</p>`}
|
: html`<p class=${cx('lo-note', locked && 'is-locked')}><${Icon} name="info" />${locked ? t('本局的调配已锁定(确认本局信息后无法修改),修改将在下一局生效') : fromText}</p>`}
|
||||||
${!ready ? html`<div class="lo-loading"><${Spinner} size="sm" />${t('正在载入干员数据(打开页面后仅载入一次)…')}</div>`
|
${!ready ? html`<div class="lo-loading"><${Spinner} size="sm" />${t('正在载入干员数据(打开页面后仅载入一次)…')}</div>`
|
||||||
: lost.length ? html`<${DataMissing} files=${lost} />` : tab === 'diy'
|
: lost.length ? html`<${DataMissing} files=${lost} />` : tab === 'diy'
|
||||||
? html`<${DiyPanel} m=${m} picks=${st.diy || {}} legal=${diyLegal} kitted=${st.diyKitted} onSet=${setDiySlot} />`
|
? html`<${DiyPanel} m=${m} picks=${st.diy || {}} legal=${diyLegal} kitted=${st.diyKitted} onSet=${setDiySlot} ops=${st.ops} onOps=${setOpsOf} />`
|
||||||
: tab === 'ownership'
|
: tab === 'ownership'
|
||||||
? html`<${OwnershipPanel} m=${m} roster=${ownRoster} notOwned=${st.notOwned} onToggle=${toggleOwned} />`
|
? html`<${OwnershipPanel} m=${m} roster=${ownRoster} notOwned=${st.notOwned} onToggle=${toggleOwned} />`
|
||||||
: html`<main class=${cx('lo-body', narrowDetail && 'is-detail')}>
|
: html`<main class=${cx('lo-body', narrowDetail && 'is-detail')}>
|
||||||
<section class="lo-roster">
|
<section class="lo-roster">
|
||||||
<${Filters} m=${m} filters=${st.filters} bonds=${bonds} onFilters=${(filters) => loadoutStore.set({ filters })} />
|
<${Filters} m=${m} filters=${st.filters} bonds=${bonds} onFilters=${(filters) => loadoutStore.set({ filters })} />
|
||||||
<div class="lo-grid" role="listbox" aria-label=${t('干员列表')} ref=${gridRef}>
|
<div class="lo-list" ref=${gridRef}>
|
||||||
${list.length ? list.map((c) => html`<${RosterCard} key=${c.chessId} m=${m} chess=${c} golden=${c.goldenId ? getChess(c.goldenId) : null}
|
<${RosterHead} />
|
||||||
entries=${st.entries} selected=${c.chessId === selId} onPick=${pick} notOwned=${(st.notOwned || []).includes(c.chessId)} />`) : html`<p class="lo-empty t-dim">${t('没有符合条件的干员')}</p>`}
|
<div class="lo-list__rows" role="list" aria-label=${t('干员列表')}>
|
||||||
|
${list.length ? list.map((c) => html`<${RosterRow} key=${c.chessId} m=${m} chess=${c} golden=${c.goldenId ? getChess(c.goldenId) : null}
|
||||||
|
entries=${st.entries} ops=${st.ops} selected=${c.chessId === selId} onPick=${pick} onChange=${quickChange} onOps=${setOpsOf}
|
||||||
|
level=${previewLevel} notOwned=${(st.notOwned || []).includes(c.chessId)} />`) : html`<p class="lo-empty t-dim">${t('没有符合条件的干员')}</p>`}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<div class="lo-detail-wrap">
|
<div class="lo-detail-wrap">
|
||||||
<button type="button" class="lo-detail-back tapx" onClick=${() => setNarrowDetail(false)}><${Icon} name="chevronLeft" />${t('干员列表')}</button>
|
<button type="button" class="lo-detail-back tapx" onClick=${() => setNarrowDetail(false)}><${Icon} name="chevronLeft" />${t('干员列表')}</button>
|
||||||
<${Detail} m=${m} chess=${base} golden=${golden} entries=${st.entries} onChange=${change} onReset=${resetOne} locked=${locked}
|
<${Detail} m=${m} chess=${base} golden=${golden} entries=${st.entries} ops=${st.ops} onChange=${change} onOps=${setOpsOf} onReset=${resetOne} locked=${locked}
|
||||||
|
level=${golden ? previewLevel : 'normal'} onLevel=${setPreviewLevel}
|
||||||
notOwned=${!!base && (st.notOwned || []).includes(base.chessId)} />
|
notOwned=${!!base && (st.notOwned || []).includes(base.chessId)} />
|
||||||
</div>
|
</div>
|
||||||
</main>`}
|
</main>`}
|
||||||
@@ -711,11 +821,13 @@ export function LoadoutHost() {
|
|||||||
/**
|
/**
|
||||||
* The entry button's badge: the same number as the screen's 已调整 N (a stored entry of a chess the data no longer offers
|
* The entry button's badge: the same number as the screen's 已调整 N (a stored entry of a chess the data no longer offers
|
||||||
* never applies) once chess.json is loaded; before that the stored entries (the badge alone must not trigger the 1.6 MB
|
* never applies) once chess.json is loaded; before that the stored entries (the badge alone must not trigger the 1.6 MB
|
||||||
* download). Review fix: it counted stale entries the screen does not.
|
* download). Review fix: it counted stale entries the screen does not. With `ops` (0.2.2) the operators whose 潜能 / 练度
|
||||||
|
* differ count too (before chess.json: the stored entries plus the stored settings, an operator possibly twice).
|
||||||
* @param {Record<string, any>} entries @param {((id: string) => any) | null} getChess null while chess.json is not loaded
|
* @param {Record<string, any>} entries @param {((id: string) => any) | null} getChess null while chess.json is not loaded
|
||||||
|
* @param {Record<string, any>|null} [ops] @param {any[]|null} [roster] rosterOf(…) of the loaded data
|
||||||
*/
|
*/
|
||||||
export function badgeCount(entries, getChess) {
|
export function badgeCount(entries, getChess, ops = null, roster = null) {
|
||||||
return getChess ? changedCount(entries, getChess) : Object.keys(entries || {}).length;
|
return getChess ? changedCount(entries, getChess, ops, roster) : Object.keys(entries || {}).length + Object.keys(ops || {}).length;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -725,9 +837,11 @@ export function badgeCount(entries, getChess) {
|
|||||||
export function LoadoutButton({ from, size = 'md', variant = 'secondary', class: cls, label = t('干员调配') }) {
|
export function LoadoutButton({ from, size = 'md', variant = 'secondary', class: cls, label = t('干员调配') }) {
|
||||||
useData('local'); // the official preset icon (re-render once the local-art manifest arrives)
|
useData('local'); // the official preset icon (re-render once the local-art manifest arrives)
|
||||||
const entries = useStore((s) => s.entries, Object.is, loadoutStore);
|
const entries = useStore((s) => s.entries, Object.is, loadoutStore);
|
||||||
|
const ops = useStore((s) => s.ops, Object.is, loadoutStore);
|
||||||
const notOwned = useStore((s) => s.notOwned, Object.is, loadoutStore);
|
const notOwned = useStore((s) => s.notOwned, Object.is, loadoutStore);
|
||||||
const diy = useStore((s) => s.diy, Object.is, loadoutStore);
|
const diy = useStore((s) => s.diy, Object.is, loadoutStore);
|
||||||
const n = badgeCount(entries, data.status('chess') === 'ready' ? (id) => data.lookup('chess', id) : null);
|
const chessReady = data.status('chess') === 'ready';
|
||||||
|
const n = badgeCount(entries, chessReady ? (id) => data.lookup('chess', id) : null, ops, chessReady ? rosterOf(data.list('chess')) : null);
|
||||||
// 0.2.0 补位: how many operators the player marked as not owned (the 干员持有 tab) — easy to forget between sessions
|
// 0.2.0 补位: how many operators the player marked as not owned (the 干员持有 tab) — easy to forget between sessions
|
||||||
const off = Array.isArray(notOwned) ? notOwned.length : 0;
|
const off = Array.isArray(notOwned) ? notOwned.length : 0;
|
||||||
// 0.2.0 自选编队: how many DIY slots the player filled
|
// 0.2.0 自选编队: how many DIY slots the player filled
|
||||||
|
|||||||
+21
-12
@@ -37,7 +37,7 @@
|
|||||||
import { useEffect } from '../../vendor/hooks.module.js';
|
import { useEffect } from '../../vendor/hooks.module.js';
|
||||||
import { html, Icon, TierChip, MicroLabel, Button, confirmDialog, useTicker } from './components.js';
|
import { html, Icon, TierChip, MicroLabel, Button, confirmDialog, useTicker } from './components.js';
|
||||||
import { Img, RichText, UnitThumb, BondGlyph, GIcon, diyToken } from './gameComponents.js';
|
import { Img, RichText, UnitThumb, BondGlyph, GIcon, diyToken } from './gameComponents.js';
|
||||||
import { attackInterval, rangeGridBox, fmtNum, tileKey, chessLoadout, nextThreshold, bondTier, briefingBondTip, pieceBondIds, grantedBonds, morphPairings, ownStandIn, standInOf, standInLoadout, standInLabel, standInTip, standInForText, ownDiyRecord, ownDiyPick, diyRecordFor, pickGetter } from './gameLogic.js';
|
import { attackInterval, rangeGridBox, fmtNum, tileKey, chessLoadout, nextThreshold, bondTier, briefingBondTip, pieceBondIds, grantedBonds, morphPairings, ownStandIn, standInOf, standInLoadout, standInLabel, standInTip, standInForText, ownDiyRecord, ownDiyPick, diyRecordFor, pickGetter, unitPick, unitCultivation } from './gameLogic.js';
|
||||||
import { chessPortraitUrl, skillIconUrl, skillRecordIconUrl, profIconUrl, subProfIconUrl, itemIconUrl, enemyIconUrl, tokenAvatarUrl, factionIconUrl, uiUrl, moduleTypeIconUrl } from './assetUrls.js';
|
import { chessPortraitUrl, skillIconUrl, skillRecordIconUrl, profIconUrl, subProfIconUrl, itemIconUrl, enemyIconUrl, tokenAvatarUrl, factionIconUrl, uiUrl, moduleTypeIconUrl } from './assetUrls.js';
|
||||||
import { abilityRows } from './abilityLines.js';
|
import { abilityRows } from './abilityLines.js';
|
||||||
import { data } from '../data.js';
|
import { data } from '../data.js';
|
||||||
@@ -316,7 +316,7 @@ export function garrisonTypeIconKey(garrison) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** The operator's own effect (特质, garrisons.json): trigger chip + description, compact. */
|
/** The operator's own effect (特质, garrisons.json): trigger chip + description, compact. */
|
||||||
function GarrisonBlock({ garrison, m }) {
|
export function GarrisonBlock({ garrison, m }) {
|
||||||
return html`<section class="dgarrison" aria-label=${t('特质')} data-garrison=${garrison.garrisonId || ''}>
|
return html`<section class="dgarrison" aria-label=${t('特质')} data-garrison=${garrison.garrisonId || ''}>
|
||||||
<div class="dgarrison__head">
|
<div class="dgarrison__head">
|
||||||
<span class="dgarrison__k">${t('特质')}</span>
|
<span class="dgarrison__k">${t('特质')}</span>
|
||||||
@@ -384,7 +384,7 @@ function skillTextNote(sk) {
|
|||||||
return note ? html`<p class="dhint dhint--rule" data-skill-note=${sk.skillId}><${Icon} name="info" />${t(note)}</p>` : null;
|
return note ? html`<p class="dhint dhint--rule" data-skill-note=${sk.skillId}><${Icon} name="info" />${t(note)}</p>` : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bonds, offBonds = null, loadout, onBond, live = null, hint = null, unitItems = null, standIn = null, diy = null }) {
|
export function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bonds, offBonds = null, loadout, onBond, live = null, hint = null, unitItems = null, standIn = null, diy = null, cultOpts = null }) {
|
||||||
const m = data.get('assets');
|
const m = data.get('assets');
|
||||||
const hp = hpOf(live, snapHp);
|
const hp = hpOf(live, snapHp);
|
||||||
// 0.2.0 自选编队: `chess` is then the composed 自选 record (the operator, the slot's tier / price); its skill and module are
|
// 0.2.0 自选编队: `chess` is then the composed 自选 record (the operator, the slot's tier / price); its skill and module are
|
||||||
@@ -396,7 +396,9 @@ export function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bond
|
|||||||
// still apply (the owner's recall of the official mode, 2026-10-06)
|
// still apply (the owner's recall of the official mode, 2026-10-06)
|
||||||
const si = standIn && standIn.standInFor ? standIn : null;
|
const si = standIn && standIn.standInFor ? standIn : null;
|
||||||
const body = si || chess;
|
const body = si || chess;
|
||||||
const lo = si ? standInLoadout(si, getChess, data.get('backups')) : chessLoadout(chess, loadout, getChess);
|
// 0.2.2: at the operator's 潜能 / 练度 — the player's settings (`cultOpts.ops`) or a teammate's unit's own
|
||||||
|
// (`cultOpts.cultivation`); a stand-in has neither
|
||||||
|
const lo = si ? standInLoadout(si, getChess, data.get('backups')) : chessLoadout(chess, loadout, getChess, { ...(cultOpts || {}), effects: data.get('effects') });
|
||||||
const c = chess;
|
const c = chess;
|
||||||
// stats / talents the unit fights with: the chosen module's (or none — statsBase) for an elite (DESIGN §16)
|
// stats / talents the unit fights with: the chosen module's (or none — statsBase) for an elite (DESIGN §16)
|
||||||
const fr = lo?.record || body;
|
const fr = lo?.record || body;
|
||||||
@@ -776,9 +778,12 @@ export function resolveDetail(target, pieces, { priv = null, backups = data.get(
|
|||||||
const d = mate ? { chess: mate, diy: target.diy } : foreign ? null : ownDiy(c);
|
const d = mate ? { chess: mate, diy: target.diy } : foreign ? null : ownDiy(c);
|
||||||
// `tap`: which card tap opened it (game.js numbers every shop / reward card it opens) — selectVoiceKey
|
// `tap`: which card tap opened it (game.js numbers every shop / reward card it opens) — selectVoiceKey
|
||||||
const tap = target.tap != null ? { tap: target.tap } : null;
|
const tap = target.tap != null ? { tap: target.tap } : null;
|
||||||
if (d) return { type: 'chess', chess: d.chess, hint: target.hint || null, standIn: null, diy: d.diy, ...(items.length ? { unitItems: items } : {}), ...tap };
|
// 0.2.2: a teammate's member card reads its unit's 潜能 / 练度 when the row hands it on (`target.cultivation`), else
|
||||||
|
// the defaults (never the viewer's own settings)
|
||||||
|
const cv = foreign ? (target.cultivation !== undefined ? { cultivation: target.cultivation } : { ops: null }) : null;
|
||||||
|
if (d) return { type: 'chess', chess: d.chess, hint: target.hint || null, standIn: null, diy: d.diy, ...(items.length ? { unitItems: items } : {}), ...(cv ? { cultOpts: cv } : {}), ...tap };
|
||||||
const si = !c ? null : foreign ? (typeof target.standInFor === 'string' && target.standInFor ? standInOf(c, backups) : null) : ownSi(c);
|
const si = !c ? null : foreign ? (typeof target.standInFor === 'string' && target.standInFor ? standInOf(c, backups) : null) : ownSi(c);
|
||||||
return c ? { type: 'chess', chess: c, hint: target.hint || null, standIn: si, ...(items.length ? { unitItems: items } : {}), ...tap } : null;
|
return c ? { type: 'chess', chess: c, hint: target.hint || null, standIn: si, ...(items.length ? { unitItems: items } : {}), ...(cv ? { cultOpts: cv } : {}), ...tap } : null;
|
||||||
}
|
}
|
||||||
if (target.kind === 'item') { const it = data.lookup('items', target.id); return it ? { type: 'item', item: it } : null; }
|
if (target.kind === 'item') { const it = data.lookup('items', target.id); return it ? { type: 'item', item: it } : null; }
|
||||||
if (target.kind === 'enemy') { const en = data.lookup('enemies', target.id); return en ? { type: 'enemy', enemy: en, count: target.count } : null; }
|
if (target.kind === 'enemy') { const en = data.lookup('enemies', target.id); return en ? { type: 'enemy', enemy: en, count: target.count } : null; }
|
||||||
@@ -797,10 +802,13 @@ export function resolveDetail(target, pieces, { priv = null, backups = data.get(
|
|||||||
else if (c && own?.piece) si = ownSi(c);
|
else if (c && own?.piece) si = ownSi(c);
|
||||||
// 0.2.0 自选编队: a unit says itself which operator fills its DIY slot (UnitInfo diy); an own piece's unit follows
|
// 0.2.0 自选编队: a unit says itself which operator fills its DIY slot (UnitInfo diy); an own piece's unit follows
|
||||||
// m.private.diy like the piece
|
// m.private.diy like the piece
|
||||||
const pick = c && u.diy && typeof u.diy === 'object' ? u.diy : own?.piece ? ownDiyPick(priv, c) : null;
|
const pick = c && u.diy && typeof u.diy === 'object' ? unitPick(u) : own?.piece ? ownDiyPick(priv, c) : null;
|
||||||
const dr = pick ? diyRecordFor(c, pick, dd) : null;
|
const dr = pick ? diyRecordFor(c, pick, dd) : null;
|
||||||
if (dr) return { type: 'chess', chess: dr, piece: own?.piece || null, unitId: u.id, unitItems: Array.isArray(u.items) ? u.items : null, standIn: null, diy: pick };
|
// 0.2.2: another player's unit says its 潜能 / 练度 itself (UnitInfo potential / cultivate); an own piece's unit follows
|
||||||
if (c) return { type: 'chess', chess: c, piece: own?.piece || null, unitId: u.id, unitItems: Array.isArray(u.items) ? u.items : null, standIn: si };
|
// m.private.ops (the panel's `cultOpts`)
|
||||||
|
const cv = own?.piece ? null : { cultOpts: { cultivation: unitCultivation(u) } };
|
||||||
|
if (dr) return { type: 'chess', chess: dr, piece: own?.piece || null, unitId: u.id, unitItems: Array.isArray(u.items) ? u.items : null, standIn: null, diy: pick, ...cv };
|
||||||
|
if (c) return { type: 'chess', chess: c, piece: own?.piece || null, unitId: u.id, unitItems: Array.isArray(u.items) ? u.items : null, standIn: si, ...cv };
|
||||||
const t = data.lookup('tokens', u.defId) || diyToken(u.defId);
|
const t = data.lookup('tokens', u.defId) || diyToken(u.defId);
|
||||||
if (t) return { type: 'token', token: t, unitId: u.id, ownerId: tokenOwnerId(own?.piece, pieces) };
|
if (t) return { type: 'token', token: t, unitId: u.id, ownerId: tokenOwnerId(own?.piece, pieces) };
|
||||||
const en = data.lookup('enemies', u.defId);
|
const en = data.lookup('enemies', u.defId);
|
||||||
@@ -828,7 +836,8 @@ export function selectVoiceKey(detail) {
|
|||||||
* bonds: the owner's m.private.bonds (counts / tiers of the bond chips); offBonds: the bonds this mode never activates
|
* bonds: the owner's m.private.bonds (counts / tiers of the bond chips); offBonds: the bonds this mode never activates
|
||||||
* (gameLogic modeOffBonds — their chips and the 变形同构体 pairing lines read 本局禁用); loadout: m.private.loadout (DESIGN §16) for
|
* (gameLogic modeOffBonds — their chips and the 变形同构体 pairing lines read 本局禁用); loadout: m.private.loadout (DESIGN §16) for
|
||||||
* the player's own operators and shop cards; a teammate's unit gets its owner's choice (gameLogic unitLoadout); null
|
* the player's own operators and shop cards; a teammate's unit gets its owner's choice (gameLogic unitLoadout); null
|
||||||
* = the defaults
|
* = the defaults; ops: m.private.ops (0.2.2 潜能 / 练度 of the player's own operators and cards — a unit the detail
|
||||||
|
* resolved as another player's carries its own, `detail.cultOpts`)
|
||||||
* live: the unit's live stats (unitStatsEntry + src 'battle' | 'prep') — an object, or a getter the panel re-reads 4×
|
* live: the unit's live stats (unitStatsEntry + src 'battle' | 'prep') — an object, or a getter the panel re-reads 4×
|
||||||
* a second (the battle's own sim, battle/runner.js unitStats); null ⇒ the record's numbers
|
* a second (the battle's own sim, battle/runner.js unitStats); null ⇒ the record's numbers
|
||||||
* voice: whether the panel may speak — 选中干员 (audio.voice 'select') when it opens on an operator the player tapped:
|
* voice: whether the panel may speak — 选中干员 (audio.voice 'select') when it opens on an operator the player tapped:
|
||||||
@@ -836,7 +845,7 @@ export function selectVoiceKey(detail) {
|
|||||||
* (the owner's request of 2026-10-08 「添加一下干员点击上去的语气一样的语音」 lifted 2026-10-03's 「整备阶段不需要干员语音」
|
* (the owner's request of 2026-10-08 「添加一下干员点击上去的语气一样的语音」 lifted 2026-10-03's 「整备阶段不需要干员语音」
|
||||||
* for this line only; [ASSUMED] the official prep tap says 选中干员 like the battle's FOCUS_CHAR)
|
* for this line only; [ASSUMED] the official prep tap says 选中干员 like the battle's FOCUS_CHAR)
|
||||||
*/
|
*/
|
||||||
export function DetailPanel({ detail, editable, snapHp, onClose, onSell, onDestroy, bonds = [], offBonds = null, loadout = null, onBond = null, side = 'left', shopOpen = false, live = null, voice = false }) {
|
export function DetailPanel({ detail, editable, snapHp, onClose, onSell, onDestroy, bonds = [], offBonds = null, loadout = null, ops = null, onBond = null, side = 'left', shopOpen = false, live = null, voice = false }) {
|
||||||
const getter = typeof live === 'function' ? live : null;
|
const getter = typeof live === 'function' ? live : null;
|
||||||
useTicker(detail && getter ? 250 : 0);
|
useTicker(detail && getter ? 250 : 0);
|
||||||
// 选中干员 voice (audio.voice 'select'): once per opened operator — the panel stays mounted while the target changes,
|
// 选中干员 voice (audio.voice 'select'): once per opened operator — the panel stays mounted while the target changes,
|
||||||
@@ -869,7 +878,7 @@ export function DetailPanel({ detail, editable, snapHp, onClose, onSell, onDestr
|
|||||||
<div class="dpanel__scroll">
|
<div class="dpanel__scroll">
|
||||||
${detail.type === 'chess' ? html`<${ChessDetail} chess=${detail.chess} piece=${detail.piece} snapHp=${snapHp} editable=${editable} onSell=${sellIt}
|
${detail.type === 'chess' ? html`<${ChessDetail} chess=${detail.chess} piece=${detail.piece} snapHp=${snapHp} editable=${editable} onSell=${sellIt}
|
||||||
bonds=${bonds} offBonds=${offBonds} loadout=${loadout} onBond=${onBond} live=${liveNow} hint=${detail.hint || null} unitItems=${detail.unitItems || null}
|
bonds=${bonds} offBonds=${offBonds} loadout=${loadout} onBond=${onBond} live=${liveNow} hint=${detail.hint || null} unitItems=${detail.unitItems || null}
|
||||||
standIn=${detail.standIn || null} diy=${detail.diy || null} />` : null}
|
standIn=${detail.standIn || null} diy=${detail.diy || null} cultOpts=${detail.cultOpts || { ops }} />` : null}
|
||||||
${detail.type === 'item' ? html`<${ItemDetail} item=${detail.item} piece=${detail.piece} editable=${editable} onDestroy=${destroyIt} offBonds=${offBonds} />` : null}
|
${detail.type === 'item' ? html`<${ItemDetail} item=${detail.item} piece=${detail.piece} editable=${editable} onDestroy=${destroyIt} offBonds=${offBonds} />` : null}
|
||||||
${detail.type === 'enemy' ? html`<${EnemyDetail} enemy=${detail.enemy} snapHp=${snapHp} count=${detail.count} live=${liveNow} />` : null}
|
${detail.type === 'enemy' ? html`<${EnemyDetail} enemy=${detail.enemy} snapHp=${snapHp} count=${detail.count} live=${liveNow} />` : null}
|
||||||
${detail.type === 'token' ? html`<${TokenDetail} token=${detail.token} piece=${detail.piece} ownerId=${detail.ownerId ?? null} snapHp=${snapHp} live=${liveNow} />` : null}
|
${detail.type === 'token' ? html`<${TokenDetail} token=${detail.token} piece=${detail.piece} ownerId=${detail.ownerId ?? null} snapHp=${snapHp} live=${liveNow} />` : null}
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export { snapHud, bossFrac, bossPctText, hasFlag, UF, attackInterval, fmtNum, ra
|
|||||||
export { shortcutFor, closesOnFieldPress, shortcutBlocked, HOTKEY_ACTIONS, DEFAULT_HOTKEYS, isBindableCode, hotkeyLabel, sanitizeHotkeys, rebindHotkey, isDefaultHotkeys, hotkeyOf, actionForKey, captureHotkey, facingSwallows, facingEnter } from './gameLogic/shortcuts.js';
|
export { shortcutFor, closesOnFieldPress, shortcutBlocked, HOTKEY_ACTIONS, DEFAULT_HOTKEYS, isBindableCode, hotkeyLabel, sanitizeHotkeys, rebindHotkey, isDefaultHotkeys, hotkeyOf, actionForKey, captureHotkey, facingSwallows, facingEnter } from './gameLogic/shortcuts.js';
|
||||||
export { DEFAULT_SETTINGS, VOICE_LANGS, sanitizeSettings } from './gameLogic/settings.js';
|
export { DEFAULT_SETTINGS, VOICE_LANGS, sanitizeSettings } from './gameLogic/settings.js';
|
||||||
export { normalizeResult } from './gameLogic/result.js';
|
export { normalizeResult } from './gameLogic/result.js';
|
||||||
export { chessLoadout, unitLoadout } from './gameLogic/loadout.js';
|
export { chessLoadout, unitLoadout, unitCultivation } from './gameLogic/loadout.js';
|
||||||
export { standInIds, fieldsStandIn, standInOf, ownStandIn, cardStandIn, memberStandIn, standInGetter, standInLoadout, deployedRecord, deployedModuleId, standInLabel, standInForText, standInTip } from './gameLogic/standIn.js';
|
export { standInIds, fieldsStandIn, standInOf, ownStandIn, cardStandIn, memberStandIn, standInGetter, standInLoadout, deployedRecord, deployedModuleId, standInLabel, standInForText, standInTip } from './gameLogic/standIn.js';
|
||||||
export { diyPicks, ownDiyPick, diyRecordFor, ownDiyRecord, cardDiy, diyGetter, pickGetter, isDiyRecord, diyBannedPieces } from './gameLogic/diy.js';
|
export { diyPicks, ownDiyPick, diyRecordFor, ownDiyRecord, cardDiy, unitPick, diyGetter, pickGetter, isDiyRecord, diyBannedPieces } from './gameLogic/diy.js';
|
||||||
export { panelSide, panelSlots, PANEL_RIGHT_GAP, PANEL_RIGHT_BOTTOM, PANEL_RIGHT_BOTTOM_SHOP, BPOP, bondPopupPlace } from './gameLogic/panel.js';
|
export { panelSide, panelSlots, PANEL_RIGHT_GAP, PANEL_RIGHT_BOTTOM, PANEL_RIGHT_BOTTOM_SHOP, BPOP, bondPopupPlace } from './gameLogic/panel.js';
|
||||||
@@ -8,6 +8,8 @@
|
|||||||
// player's units say it themselves (UnitInfo `diy`: the sim's, Match.prepFieldMeta's for scouting, m.result lineups). The
|
// player's units say it themselves (UnitInfo `diy`: the sim's, Match.prepFieldMeta's for scouting, m.result lineups). The
|
||||||
// composed record is shared/diy.js diyRecordOf over chess.json + backups.json — the one the server and the sim use.
|
// composed record is shared/diy.js diyRecordOf over chess.json + backups.json — the one the server and the sim use.
|
||||||
// `data` below = `{ chess, backups }` (the client's data.get('chess') / data.get('backups'), localized copies).
|
// `data` below = `{ chess, backups }` (the client's data.get('chess') / data.get('backups'), localized copies).
|
||||||
|
// 0.2.2: a pick may carry `potential` (the owner's 潜能 of the operator — m.private.ops for the player's own, UnitInfo
|
||||||
|
// `potential` for another player's unit): the record is composed at it (an owned pick; a prototype has none).
|
||||||
|
|
||||||
import { diyRecordOf } from '../../../../shared/diy.js';
|
import { diyRecordOf } from '../../../../shared/diy.js';
|
||||||
import { isObj } from './shared.js';
|
import { isObj } from './shared.js';
|
||||||
@@ -26,7 +28,10 @@ export function ownDiyPick(priv, chess) {
|
|||||||
if (!isObj(chess) || !chess.isDiy) return null;
|
if (!isObj(chess) || !chess.isDiy) return null;
|
||||||
const slot = typeof chess.diyFor === 'string' ? chess.diyFor : chess.baseId || chess.chessId;
|
const slot = typeof chess.diyFor === 'string' ? chess.diyFor : chess.baseId || chess.chessId;
|
||||||
const p = diyPicks(priv)[slot];
|
const p = diyPicks(priv)[slot];
|
||||||
return isObj(p) && typeof p.charId === 'string' ? p : null;
|
if (!isObj(p) || typeof p.charId !== 'string') return null;
|
||||||
|
// 0.2.2: at the player's 潜能 of that operator (m.private.ops; none set = 6)
|
||||||
|
const pot = isObj(priv?.ops) && isObj(priv.ops[p.charId]) ? priv.ops[p.charId].potential : null;
|
||||||
|
return Number.isInteger(pot) && pot >= 1 && pot < 6 ? { ...p, potential: pot } : p;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Per backups object: `chessId|charId|skill|module` → composed record (or null). */
|
/** Per backups object: `chessId|charId|skill|module` → composed record (or null). */
|
||||||
@@ -44,10 +49,11 @@ export function diyRecordFor(chess, pick, data) {
|
|||||||
if (!isObj(pick) || !isObj(data?.backups)) return null;
|
if (!isObj(pick) || !isObj(data?.backups)) return null;
|
||||||
let m = CACHE.get(data.backups);
|
let m = CACHE.get(data.backups);
|
||||||
if (!m) { m = new Map(); CACHE.set(data.backups, m); }
|
if (!m) { m = new Map(); CACHE.set(data.backups, m); }
|
||||||
const key = `${chess.chessId}|${pick.charId}|${pick.skillIndex ?? ''}|${pick.uniEquipId ?? ''}`;
|
const pot = Number.isInteger(pick.potential) ? pick.potential : null;
|
||||||
|
const key = `${chess.chessId}|${pick.charId}|${pick.skillIndex ?? ''}|${pick.uniEquipId ?? ''}|${pot ?? ''}`;
|
||||||
if (m.has(key)) return m.get(key);
|
if (m.has(key)) return m.get(key);
|
||||||
let rec;
|
let rec;
|
||||||
try { rec = diyRecordOf(chess, pick, data); } catch { rec = null; }
|
try { rec = diyRecordOf(chess, pick, data, { potential: pot }); } catch { rec = null; }
|
||||||
m.set(key, rec);
|
m.set(key, rec);
|
||||||
return rec;
|
return rec;
|
||||||
}
|
}
|
||||||
@@ -65,10 +71,16 @@ export function ownDiyRecord(chess, priv, data) {
|
|||||||
*/
|
*/
|
||||||
export function cardDiy(chess, { priv = null, unit = null, data = null } = {}) {
|
export function cardDiy(chess, { priv = null, unit = null, data = null } = {}) {
|
||||||
if (!isObj(chess) || !chess.isDiy) return null;
|
if (!isObj(chess) || !chess.isDiy) return null;
|
||||||
if (unit) return isObj(unit.diy) ? diyRecordFor(chess, unit.diy, data) : null;
|
if (unit) return isObj(unit.diy) ? diyRecordFor(chess, unitPick(unit), data) : null;
|
||||||
return ownDiyRecord(chess, priv, data);
|
return ownDiyRecord(chess, priv, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A unit's 自选 pick with its potential (UnitInfo `diy` + `potential`, 0.2.2), or null. */
|
||||||
|
export function unitPick(unit) {
|
||||||
|
if (!isObj(unit) || !isObj(unit.diy)) return null;
|
||||||
|
return Number.isInteger(unit.potential) ? { ...unit.diy, potential: unit.potential } : unit.diy;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* id → the player's 自选 record of a filled DIY slot (both forms), else the chess record: the lookup the player's own
|
* id → the player's 自选 record of a filled DIY slot (both forms), else the chess record: the lookup the player's own
|
||||||
* pieces and cards resolve against (names, art, position, the loadout / range of a 自选 piece — shared/protocol.js
|
* pieces and cards resolve against (names, art, position, the loadout / range of a 自选 piece — shared/protocol.js
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { MODULE_NONE, loadoutOptions, resolveLoadout } from '../../../../shared/protocol.js';
|
import { MODULE_NONE, loadoutOptions, resolveLoadout } from '../../../../shared/protocol.js';
|
||||||
import { loadoutRecord, resolveRecordLoadout } from '../../../../shared/loadoutRecord.js';
|
import { loadoutRecord, resolveRecordLoadout } from '../../../../shared/loadoutRecord.js';
|
||||||
|
import { cultivationOf, cultivateMul, cultivatedStats, isPotential, isCultivate } from '../../../../shared/potential.js';
|
||||||
import { isObj } from './shared.js';
|
import { isObj } from './shared.js';
|
||||||
import { t } from '../../../../shared/i18n.js';
|
import { t } from '../../../../shared/i18n.js';
|
||||||
|
|
||||||
@@ -21,14 +22,21 @@ export function tokenVariantFor(token, ownerId = null) {
|
|||||||
* The skill / module a chess fights with under the player's loadout (m.private.loadout, DESIGN §16) — what the shop
|
* The skill / module a chess fights with under the player's loadout (m.private.loadout, DESIGN §16) — what the shop
|
||||||
* card and the detail panel show. Works with data that already lists every selectable skill (`skills[]`, elite
|
* card and the detail panel show. Works with data that already lists every selectable skill (`skills[]`, elite
|
||||||
* `modules[]`) and with older data (only the default `skill` / `module`).
|
* `modules[]`) and with older data (only the default `skill` / `module`).
|
||||||
|
* 0.2.2: the record (stats, talents) is at the operator's 潜能 and its stats carry the 练度 multiplier — the player's
|
||||||
|
* settings (`opts.ops`: m.private.ops / the 干员调配 store; missing = 潜能 6, 精英2 Lv.60), or a unit's own
|
||||||
|
* (`opts.cultivation`: a teammate's UnitInfo — null = none); a 补位 stand-in / prototype 自选 record has neither
|
||||||
|
* (shared/potential.js cultivationOf). The 练度 needs `opts.effects` (data/effects.json). A composed 自选 record is
|
||||||
|
* already at its potential (gameLogic/diy.js: the pick's `potential`).
|
||||||
* @param {any} chess the chess record (normal or elite)
|
* @param {any} chess the chess record (normal or elite)
|
||||||
* @param {any} loadout m.private.loadout `{ [baseChessId]: { skill, module } }` (entries equal to the defaults may be
|
* @param {any} loadout m.private.loadout `{ [baseChessId]: { skill, module } }` (entries equal to the defaults may be
|
||||||
* missing); null → defaults
|
* missing); null → defaults
|
||||||
* @param {(id: string) => any} [getChess]
|
* @param {(id: string) => any} [getChess]
|
||||||
|
* @param {{ ops?: any, cultivation?: { potential?: number|null, cultivate?: number|null } | null, effects?: any }} [opts]
|
||||||
* @returns {{ skill: any, skillIndex: number|null, defaultSkill: boolean, module: { id: string, name: string, typeName: string, none: boolean }|null,
|
* @returns {{ skill: any, skillIndex: number|null, defaultSkill: boolean, module: { id: string, name: string, typeName: string, none: boolean }|null,
|
||||||
* defaultModule: boolean, changed: boolean, choices: number } | null}
|
* defaultModule: boolean, changed: boolean, choices: number, record: any,
|
||||||
|
* cultivation: { potential: number, cultivate: number|null } | null } | null}
|
||||||
*/
|
*/
|
||||||
export function chessLoadout(chess, loadout, getChess = () => null) {
|
export function chessLoadout(chess, loadout, getChess = () => null, opts = {}) {
|
||||||
if (!isObj(chess)) return null;
|
if (!isObj(chess)) return null;
|
||||||
const lo = isObj(loadout) && !Array.isArray(loadout) ? loadout : null;
|
const lo = isObj(loadout) && !Array.isArray(loadout) ? loadout : null;
|
||||||
let r = { skillIndex: null, moduleId: null };
|
let r = { skillIndex: null, moduleId: null };
|
||||||
@@ -52,12 +60,42 @@ export function chessLoadout(chess, loadout, getChess = () => null) {
|
|||||||
}
|
}
|
||||||
defaultModule = opt.defaultModule == null || id === opt.defaultModule;
|
defaultModule = opt.defaultModule == null || id === opt.defaultModule;
|
||||||
}
|
}
|
||||||
// the record the unit fights with (stats / 特性 / talents of the chosen module or none — the battle's own composition,
|
// 0.2.2: the operator's 潜能 / 练度 (the player's settings, or the unit's own)
|
||||||
// shared/loadoutRecord.js): the detail card must show what the sim runs
|
const cv = cultivationFor(chess, opts);
|
||||||
|
// the record the unit fights with (stats / 特性 / talents of the chosen module or none, at its potential — the
|
||||||
|
// battle's own composition, shared/loadoutRecord.js): the detail card must show what the sim runs
|
||||||
let record = chess;
|
let record = chess;
|
||||||
try { record = loadoutRecord(chess, resolveRecordLoadout(chess, { skillIndex: r.skillIndex, moduleId: r.moduleId })) || chess; } catch { /* the record as is */ }
|
try { record = loadoutRecord(chess, resolveRecordLoadout(chess, { skillIndex: r.skillIndex, moduleId: r.moduleId, potential: cv ? cv.potential : null })) || chess; } catch { /* the record as is */ }
|
||||||
|
const mul = cv && isCultivate(cv.cultivate) ? cultivateMul(opts?.effects, cv.cultivate) : null;
|
||||||
|
if (mul && record && record.stats) record = { ...record, stats: cultivatedStats(record.stats, mul) };
|
||||||
return { skill, skillIndex: skill?.index ?? null, defaultSkill, module, defaultModule, changed: !defaultSkill || !defaultModule,
|
return { skill, skillIndex: skill?.index ?? null, defaultSkill, module, defaultModule, changed: !defaultSkill || !defaultModule,
|
||||||
choices: Math.max(skills.length, opt.skills?.length || 0), record };
|
choices: Math.max(skills.length, opt.skills?.length || 0), record, cultivation: cv };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The 潜能 / 练度 of a card's record (chessLoadout `opts`): the unit's own (`cultivation`, null = none) or the player's
|
||||||
|
* settings (`ops`, shared/potential.js cultivationOf — null for a stand-in / prototype 自选 record).
|
||||||
|
* @returns {{ potential: number, cultivate: number|null } | null}
|
||||||
|
*/
|
||||||
|
function cultivationFor(chess, opts) {
|
||||||
|
if (opts && opts.cultivation !== undefined) {
|
||||||
|
const c = opts.cultivation;
|
||||||
|
if (!isObj(c) || !isObj(chess) || chess.standInFor || chess.diyProto) return null;
|
||||||
|
return { potential: isPotential(c.potential) ? c.potential : 6, cultivate: isCultivate(c.cultivate) ? c.cultivate : null };
|
||||||
|
}
|
||||||
|
return cultivationOf(chess, opts ? opts.ops : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The 潜能 / 练度 a battle / scouting unit fights at (UnitInfo `potential` — below 6 only — and `cultivate`, 0.2.2): for
|
||||||
|
* chessLoadout `opts.cultivation`; null when the unit has neither (a stand-in, a prototype 自选 piece, a raw unit).
|
||||||
|
* @param {any} unit UnitInfo
|
||||||
|
*/
|
||||||
|
export function unitCultivation(unit) {
|
||||||
|
if (!isObj(unit)) return null;
|
||||||
|
const pot = isPotential(unit.potential) ? unit.potential : 6;
|
||||||
|
const cult = isCultivate(unit.cultivate) ? unit.cultivate : null;
|
||||||
|
return cult == null && pot === 6 ? null : { potential: pot, cultivate: cult };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+131
-15
@@ -8,8 +8,13 @@
|
|||||||
// (ModuleRecord: uniEquipId, name, typeName, attr, traitOverride, talentChanges, isDefault) — while the data lacks
|
// (ModuleRecord: uniEquipId, name, typeName, attr, traitOverride, talentChanges, isDefault) — while the data lacks
|
||||||
// them only the default skill / module is offered. The option rules are shared with the server
|
// them only the default skill / module is offered. The option rules are shared with the server
|
||||||
// (shared/protocol.js loadoutOptions / checkLoadout), so a sanitised loadout is always accepted.
|
// (shared/protocol.js loadoutOptions / checkLoadout), so a sanitised loadout is always accepted.
|
||||||
|
// 潜能 / 练度 (0.2.2, the owner's decision of 2026-10-08): `ops` = `{ [charId]: { potential?: 1–6, cultivate?: 0–3 } }` —
|
||||||
|
// per operator, so the normal, elite and 自选 forms share them; only values that differ from the defaults (潜能 6,
|
||||||
|
// 精英2 Lv.60) are kept; stored beside the entries (`sp.pref.loadout` = { v: 1, entries, ops }; an older stored loadout
|
||||||
|
// has none = the defaults) and sent with `room.loadout { entries, ops }` (shared/protocol.js checkLoadoutOps).
|
||||||
|
|
||||||
import { loadoutOptions, checkLoadout, resolveLoadout, MODULE_NONE, LOADOUT_LIMITS } from '../../../shared/protocol.js';
|
import { loadoutOptions, checkLoadout, checkLoadoutOps, resolveLoadout, MODULE_NONE, LOADOUT_LIMITS } from '../../../shared/protocol.js';
|
||||||
|
import { isPotential, isCultivate, POTENTIAL_DEFAULT, CULTIVATE_DEFAULT } from '../../../shared/potential.js';
|
||||||
import { t, N_ } from '../../../shared/i18n.js';
|
import { t, N_ } from '../../../shared/i18n.js';
|
||||||
|
|
||||||
export { MODULE_NONE };
|
export { MODULE_NONE };
|
||||||
@@ -56,8 +61,95 @@ export function parseStored(raw) {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Serialised form for localStorage. */
|
/**
|
||||||
export const toStored = (entries) => ({ v: LOADOUT_VERSION, entries: entries || {} });
|
* Parse the stored per-operator 潜能 / 练度 (0.2.2; any junk → {}): structurally valid entries only, defaults dropped.
|
||||||
|
* An older stored loadout (no `ops`) gives {} — every operator at the defaults.
|
||||||
|
* @param {any} raw `{ v, entries, ops }`
|
||||||
|
* @returns {Record<string, { potential?: number, cultivate?: number }>}
|
||||||
|
*/
|
||||||
|
export function parseStoredOps(raw) {
|
||||||
|
const src = isObj(raw) && isObj(raw.ops) ? raw.ops : null;
|
||||||
|
const out = {};
|
||||||
|
if (!src) return out;
|
||||||
|
for (const [id, e] of Object.entries(src)) {
|
||||||
|
if (Object.keys(out).length >= LOADOUT_LIMITS.ops) break;
|
||||||
|
if (UNSAFE_IDS.has(id) || !/^[A-Za-z0-9_\-.:]{1,64}$/.test(id) || !isObj(e)) continue;
|
||||||
|
const x = {};
|
||||||
|
if (isPotential(e.potential) && e.potential !== POTENTIAL_DEFAULT) x.potential = e.potential;
|
||||||
|
if (isCultivate(e.cultivate) && e.cultivate !== CULTIVATE_DEFAULT) x.cultivate = e.cultivate;
|
||||||
|
if (Object.keys(x).length) out[id] = x;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Serialised form for localStorage (`ops`: the per-operator 潜能 / 练度, 0.2.2). */
|
||||||
|
export const toStored = (entries, ops = {}) => ({ v: LOADOUT_VERSION, entries: entries || {}, ops: ops || {} });
|
||||||
|
|
||||||
|
// ---- 潜能 / 练度 (0.2.2) ------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The effective 潜能 / 练度 of operator `charId` under the stored settings (the defaults for a missing entry or field).
|
||||||
|
* @param {Record<string, any>|null|undefined} ops @param {string|null|undefined} charId
|
||||||
|
* @returns {{ potential: number, cultivate: number, changed: boolean }}
|
||||||
|
*/
|
||||||
|
export function opsOf(ops, charId) {
|
||||||
|
const e = charId && isObj(ops) && Object.hasOwn(ops, charId) && isObj(ops[charId]) ? ops[charId] : null;
|
||||||
|
const potential = e && isPotential(e.potential) ? e.potential : POTENTIAL_DEFAULT;
|
||||||
|
const cultivate = e && isCultivate(e.cultivate) ? e.cultivate : CULTIVATE_DEFAULT;
|
||||||
|
return { potential, cultivate, changed: potential !== POTENTIAL_DEFAULT || cultivate !== CULTIVATE_DEFAULT };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set (part of) one operator's 潜能 / 练度; an entry equal to the defaults is removed. Returns a new map.
|
||||||
|
* @param {Record<string, any>} ops @param {string} charId @param {{ potential?: number, cultivate?: number }} patch
|
||||||
|
*/
|
||||||
|
export function setOps(ops, charId, patch) {
|
||||||
|
if (typeof charId !== 'string' || !charId) return ops;
|
||||||
|
const cur = opsOf(ops, charId);
|
||||||
|
const potential = patch && isPotential(patch.potential) ? patch.potential : cur.potential;
|
||||||
|
const cultivate = patch && isCultivate(patch.cultivate) ? patch.cultivate : cur.cultivate;
|
||||||
|
const out = { ...(ops || {}) };
|
||||||
|
delete out[charId];
|
||||||
|
const e = {};
|
||||||
|
if (potential !== POTENTIAL_DEFAULT) e.potential = potential;
|
||||||
|
if (cultivate !== CULTIVATE_DEFAULT) e.cultivate = cultivate;
|
||||||
|
if (Object.keys(e).length) out[charId] = e;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove one operator's 潜能 / 练度 (恢复默认). */
|
||||||
|
export function resetOps(ops, charId) {
|
||||||
|
if (!ops || !Object.hasOwn(ops, charId)) return ops;
|
||||||
|
const out = { ...ops };
|
||||||
|
delete out[charId];
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The settings to send (`room.loadout.ops`): every stored entry the server would keep for the loaded data (an operator
|
||||||
|
* of the 干员调配 roster or the 自选 owned pool, `isOperator` — shared/protocol.js cultivationCharIds), the rest dropped one
|
||||||
|
* by one; defaults dropped.
|
||||||
|
* @param {Record<string, any>} ops @param {(charId: string) => boolean} isOperator
|
||||||
|
* @returns {Record<string, { potential?: number, cultivate?: number }>}
|
||||||
|
*/
|
||||||
|
export function sanitizeOps(ops, isOperator) {
|
||||||
|
const out = {};
|
||||||
|
for (const [id, e] of Object.entries(ops || {})) {
|
||||||
|
if (Object.keys(out).length >= LOADOUT_LIMITS.ops) break;
|
||||||
|
const one = {};
|
||||||
|
if (isPotential(e?.potential)) one.potential = e.potential;
|
||||||
|
if (isCultivate(e?.cultivate)) one.cultivate = e.cultivate;
|
||||||
|
if (!Object.keys(one).length) continue;
|
||||||
|
const res = checkLoadoutOps({ [id]: one }, isOperator);
|
||||||
|
if (res.ok && res.ops[id]) {
|
||||||
|
const x = {};
|
||||||
|
if (res.ops[id].potential !== POTENTIAL_DEFAULT) x.potential = res.ops[id].potential;
|
||||||
|
if (res.ops[id].cultivate !== CULTIVATE_DEFAULT) x.cultivate = res.ops[id].cultivate;
|
||||||
|
out[id] = x;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
// ---- export / import ----------------------------------------------------------------------------------------------
|
// ---- export / import ----------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
@@ -71,19 +163,23 @@ export const LOADOUT_EXPORT_KIND = 'stronghold.loadout';
|
|||||||
export const LOADOUT_IMPORT_MAX_BYTES = 256 * 1024;
|
export const LOADOUT_IMPORT_MAX_BYTES = 256 * 1024;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Portable payload of a loadout, as downloaded / copied by 导出.
|
* Portable payload of a loadout, as downloaded / copied by 导出 — with the per-operator 潜能 / 练度 (`ops`, 0.2.2; an
|
||||||
|
* older build reading it ignores the field).
|
||||||
* @param {Record<string, any>} entries `room.loadout.entries`
|
* @param {Record<string, any>} entries `room.loadout.entries`
|
||||||
* @param {{ now?: number }} [o]
|
* @param {{ now?: number, ops?: Record<string, any> }} [o]
|
||||||
*/
|
*/
|
||||||
export function exportPayload(entries, { now = Date.now() } = {}) {
|
export function exportPayload(entries, { now = Date.now(), ops = {} } = {}) {
|
||||||
const clean = {};
|
const clean = {};
|
||||||
for (const [id, e] of Object.entries(entries || {})) if (isObj(e)) clean[id] = { ...e };
|
for (const [id, e] of Object.entries(entries || {})) if (isObj(e)) clean[id] = { ...e };
|
||||||
|
const cleanOps = {};
|
||||||
|
for (const [id, e] of Object.entries(ops || {})) if (isObj(e)) cleanOps[id] = { ...e };
|
||||||
return {
|
return {
|
||||||
kind: LOADOUT_EXPORT_KIND,
|
kind: LOADOUT_EXPORT_KIND,
|
||||||
v: LOADOUT_VERSION,
|
v: LOADOUT_VERSION,
|
||||||
exportedAt: new Date(Number.isFinite(now) ? now : Date.now()).toISOString(),
|
exportedAt: new Date(Number.isFinite(now) ? now : Date.now()).toISOString(),
|
||||||
count: Object.keys(clean).length,
|
count: Object.keys(clean).length,
|
||||||
entries: clean,
|
entries: clean,
|
||||||
|
ops: cleanOps,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,8 +193,10 @@ export function serializeExport(entries, opts) {
|
|||||||
* `{ [chessId]: { skill, module } }` map all work, as does the serialised text of any of them. Parsing is STRUCTURAL
|
* `{ [chessId]: { skill, module } }` map all work, as does the serialised text of any of them. Parsing is STRUCTURAL
|
||||||
* only — the caller still runs `sanitizeEntries` against the loaded data, because a preset from another season may name
|
* only — the caller still runs `sanitizeEntries` against the loaded data, because a preset from another season may name
|
||||||
* chess / skills / modules this build does not have. `__proto__` / `constructor` keys are skipped (see parseStored).
|
* chess / skills / modules this build does not have. `__proto__` / `constructor` keys are skipped (see parseStored).
|
||||||
|
* A payload with `ops` (0.2.2) brings the per-operator 潜能 / 练度 too (`ops` in the result — absent when the payload has
|
||||||
|
* none: an older export leaves the current settings alone); a payload of settings only imports.
|
||||||
* @param {any} input payload object or serialised text
|
* @param {any} input payload object or serialised text
|
||||||
* @returns {{ ok: true, entries: Record<string, any> } | { ok: false, error: string }}
|
* @returns {{ ok: true, entries: Record<string, any>, ops?: Record<string, any> } | { ok: false, error: string }}
|
||||||
*/
|
*/
|
||||||
export function parseImport(input) {
|
export function parseImport(input) {
|
||||||
let raw = input;
|
let raw = input;
|
||||||
@@ -115,8 +213,9 @@ export function parseImport(input) {
|
|||||||
const kind = typeof raw.kind === 'string' ? raw.kind : null;
|
const kind = typeof raw.kind === 'string' ? raw.kind : null;
|
||||||
if (kind && kind !== LOADOUT_EXPORT_KIND) return { ok: false, error: t('这不是干员调配的数据') };
|
if (kind && kind !== LOADOUT_EXPORT_KIND) return { ok: false, error: t('这不是干员调配的数据') };
|
||||||
const entries = parseStored(raw);
|
const entries = parseStored(raw);
|
||||||
if (!Object.keys(entries).length) return { ok: false, error: t('里面没有有效的调配条目') };
|
const ops = isObj(raw.ops) ? parseStoredOps(raw) : null;
|
||||||
return { ok: true, entries };
|
if (!Object.keys(entries).length && !(ops && Object.keys(ops).length)) return { ok: false, error: t('里面没有有效的调配条目') };
|
||||||
|
return ops ? { ok: true, entries, ops } : { ok: true, entries };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- options & choices ---------------------------------------------------------------------------------------------
|
// ---- options & choices ---------------------------------------------------------------------------------------------
|
||||||
@@ -281,8 +380,9 @@ export function rosterOf(list) {
|
|||||||
* @param {Record<string, any>} entries stored loadout (for changedOnly)
|
* @param {Record<string, any>} entries stored loadout (for changedOnly)
|
||||||
* @param {(id: string) => any} getChess
|
* @param {(id: string) => any} getChess
|
||||||
* @param {(id: string) => any} [getBond] bond lookup (the search also matches bond names)
|
* @param {(id: string) => any} [getBond] bond lookup (the search also matches bond names)
|
||||||
|
* @param {Record<string, any>|null} [ops] the per-operator 潜能 / 练度 (changedOnly counts them too)
|
||||||
*/
|
*/
|
||||||
export function filterRoster(roster, f = {}, entries = {}, getChess = () => null, getBond = () => null) {
|
export function filterRoster(roster, f = {}, entries = {}, getChess = () => null, getBond = () => null, ops = null) {
|
||||||
const q = String(f.query || '').trim().toLowerCase();
|
const q = String(f.query || '').trim().toLowerCase();
|
||||||
return roster.filter((c) => {
|
return roster.filter((c) => {
|
||||||
if (f.tier && c.tier !== f.tier) return false;
|
if (f.tier && c.tier !== f.tier) return false;
|
||||||
@@ -290,7 +390,7 @@ export function filterRoster(roster, f = {}, entries = {}, getChess = () => null
|
|||||||
if (f.bond && !(Array.isArray(c.bonds) && c.bonds.includes(f.bond))) return false;
|
if (f.bond && !(Array.isArray(c.bonds) && c.bonds.includes(f.bond))) return false;
|
||||||
if (f.changedOnly) {
|
if (f.changedOnly) {
|
||||||
const golden = c.goldenId ? getChess(c.goldenId) : null;
|
const golden = c.goldenId ? getChess(c.goldenId) : null;
|
||||||
if (!effectiveChoice(entries, c, golden).changed) return false;
|
if (!effectiveChoice(entries, c, golden).changed && !opsOf(ops, c.charId).changed) return false;
|
||||||
}
|
}
|
||||||
if (q) {
|
if (q) {
|
||||||
const hay = [c.name, c.appellation, c.subProfessionName, t(PROF_NAME[c.profession]), ...(c.bonds || []).map((b) => getBond(b)?.name)]
|
const hay = [c.name, c.appellation, c.subProfessionName, t(PROF_NAME[c.profession]), ...(c.bonds || []).map((b) => getBond(b)?.name)]
|
||||||
@@ -302,12 +402,14 @@ export function filterRoster(roster, f = {}, entries = {}, getChess = () => null
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Number of chess whose choice differs from the defaults (only loadout slots of the loaded data count: an entry of a
|
/** Number of chess whose choice differs from the defaults (only loadout slots of the loaded data count: an entry of a
|
||||||
* retired / hidden chess is never sent nor applied). */
|
* retired / hidden chess is never sent nor applied) — with `ops` (0.2.2), also those whose operator's 潜能 / 练度 differ. */
|
||||||
export function changedCount(entries, getChess) {
|
export function changedCount(entries, getChess, ops = null, roster = null) {
|
||||||
|
const ids = new Set(Object.keys(entries || {}));
|
||||||
|
if (ops && Object.keys(ops).length && Array.isArray(roster)) for (const c of roster) if (opsOf(ops, c.charId).changed) ids.add(c.chessId);
|
||||||
let n = 0;
|
let n = 0;
|
||||||
for (const id of Object.keys(entries || {})) {
|
for (const id of ids) {
|
||||||
const { base, golden } = recordsOf(id, getChess);
|
const { base, golden } = recordsOf(id, getChess);
|
||||||
if (isLoadoutSlot(base) && base.chessId === id && effectiveChoice(entries, base, golden).changed) n++;
|
if (isLoadoutSlot(base) && base.chessId === id && (effectiveChoice(entries, base, golden).changed || opsOf(ops, base.charId).changed)) n++;
|
||||||
}
|
}
|
||||||
return n;
|
return n;
|
||||||
}
|
}
|
||||||
@@ -386,3 +488,17 @@ export function skillTags(rec) {
|
|||||||
passive,
|
passive,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Compact skill preview; SP and duration come from the selected form's generated record. */
|
||||||
|
export function quickSkillTags(rec) {
|
||||||
|
const tags = skillTags(rec);
|
||||||
|
if (!rec) return { ...tags, recovery: '—', duration: '—' };
|
||||||
|
// ON_DEPLOY covers constant passives too. A finite duration distinguishes the deploy-and-expire skills;
|
||||||
|
// some older records carry it only in the blackboard. Never infer it from a localized description.
|
||||||
|
const seconds = Number(rec.duration) > 0 ? Number(rec.duration) : tags.passive ? Number(rec.bb?.duration) : 0;
|
||||||
|
const deployment = tags.passive && seconds > 0;
|
||||||
|
const recovery = deployment ? '—' : tags.passive ? t('被动') : tags.spKind === 'atk' ? t('攻回') : tags.spKind === 'def' ? t('受回') : t('自回');
|
||||||
|
const duration = rec.durationType === 'AMMO' ? t('弹药') : seconds > 0 ? `${seconds}s`
|
||||||
|
: tags.passive ? t('常驻') : Number(rec.duration) < 0 ? '∞' : t('瞬发');
|
||||||
|
return { ...tags, sp: deployment ? t('部署触发') : tags.sp, recovery, duration };
|
||||||
|
}
|
||||||
+54
-18
@@ -7,9 +7,9 @@
|
|||||||
// (called once by main.js) keep the server's copies current — one sync engine (installPrefSync), three settings: after
|
// (called once by main.js) keep the server's copies current — one sync engine (installPrefSync), three settings: after
|
||||||
// every `welcome` (new or resumed
|
// every `welcome` (new or resumed
|
||||||
// session — the server keeps them on the session and on the seat, so joining a room needs no resend) and after every
|
// session — the server keeps them on the session and on the seat, so joining a room needs no resend) and after every
|
||||||
// edit (debounced; a pending edit goes out at once when the overlay closes), they send `room.loadout { entries }`
|
// edit (debounced; a pending edit goes out at once when the overlay closes), they send `room.loadout { entries, ops }`
|
||||||
// (sanitised against the loaded data/chess.json: ui/loadoutModel.js sanitizeEntries — a stale entry is dropped, never
|
// (sanitised against the loaded data/chess.json: ui/loadoutModel.js sanitizeEntries — a stale entry is dropped, never
|
||||||
// the whole loadout), `room.ownership { notOwned }` (sent as stored: the server keeps the droppable chess and drops
|
// the whole loadout; `ops` = the per-operator 潜能 / 练度 of 0.2.2, sanitizeOps against chess.json + backups.json), `room.ownership { notOwned }` (sent as stored: the server keeps the droppable chess and drops
|
||||||
// the rest, so no data is needed) and `room.diy { picks }` (sent as stored, structurally clean: the server keeps the
|
// the rest, so no data is needed) and `room.diy { picks }` (sent as stored, structurally clean: the server keeps the
|
||||||
// legal picks). Replies: RATE → retried later; WRONG_PHASE / ROOM_STARTED → the running match keeps what it took (the
|
// legal picks). Replies: RATE → retried later; WRONG_PHASE / ROOM_STARTED → the running match keeps what it took (the
|
||||||
// loadout locks when INFO_CHECK ends; the ownership and the 自选 picks never change during a match) — stored for the
|
// loadout locks when INFO_CHECK ends; the ownership and the 自选 picks never change during a match) — stored for the
|
||||||
@@ -18,7 +18,8 @@
|
|||||||
|
|
||||||
import { createStore, loadPref, savePref } from '../store.js';
|
import { createStore, loadPref, savePref } from '../store.js';
|
||||||
import { data } from '../data.js';
|
import { data } from '../data.js';
|
||||||
import { LOADOUT_PREF, parseStored, toStored, sanitizeEntries } from './loadoutModel.js';
|
import { LOADOUT_PREF, parseStored, parseStoredOps, toStored, sanitizeEntries, sanitizeOps } from './loadoutModel.js';
|
||||||
|
import { cultivationCharIds } from '../../../shared/protocol.js';
|
||||||
import { OWNERSHIP_PREF, parseStoredOwnership, toStoredOwnership, cleanIds, sanitizeNotOwned } from './ownershipModel.js';
|
import { OWNERSHIP_PREF, parseStoredOwnership, toStoredOwnership, cleanIds, sanitizeNotOwned } from './ownershipModel.js';
|
||||||
import { DIY_PREF, parseStoredDiy, toStoredDiy, cleanPicks, sanitizeDiyPicks } from './diyModel.js';
|
import { DIY_PREF, parseStoredDiy, toStoredDiy, cleanPicks, sanitizeDiyPicks } from './diyModel.js';
|
||||||
import { toast } from './toasts.js';
|
import { toast } from './toasts.js';
|
||||||
@@ -30,6 +31,9 @@ export const RETRY_MS = 1500;
|
|||||||
function readStored() {
|
function readStored() {
|
||||||
try { return parseStored(loadPref(LOADOUT_PREF, null)); } catch { return {}; }
|
try { return parseStored(loadPref(LOADOUT_PREF, null)); } catch { return {}; }
|
||||||
}
|
}
|
||||||
|
function readStoredOps() {
|
||||||
|
try { return parseStoredOps(loadPref(LOADOUT_PREF, null)); } catch { return {}; }
|
||||||
|
}
|
||||||
function readStoredOwnership() {
|
function readStoredOwnership() {
|
||||||
try { return parseStoredOwnership(loadPref(OWNERSHIP_PREF, null)); } catch { return []; }
|
try { return parseStoredOwnership(loadPref(OWNERSHIP_PREF, null)); } catch { return []; }
|
||||||
}
|
}
|
||||||
@@ -40,6 +44,7 @@ function readStoredDiy() {
|
|||||||
/** Loadout + ownership + screen state (separate from the app store: it must survive room / match resets). */
|
/** Loadout + ownership + screen state (separate from the app store: it must survive room / match resets). */
|
||||||
export const loadoutStore = createStore({
|
export const loadoutStore = createStore({
|
||||||
entries: readStored(),
|
entries: readStored(),
|
||||||
|
ops: readStoredOps(), // 0.2.2 潜能 / 练度: { [charId]: { potential?, cultivate? } } ({} = every operator 潜能 6, 精英2 Lv.60)
|
||||||
notOwned: readStoredOwnership(), // 干员持有: base chess ids marked 未持有 (sorted; [] = every operator owned)
|
notOwned: readStoredOwnership(), // 干员持有: base chess ids marked 未持有 (sorted; [] = every operator owned)
|
||||||
diy: readStoredDiy(), // 自选编队: { [slotBaseId]: { charId, skillIndex?, uniEquipId? } } ({} = every slot empty)
|
diy: readStoredDiy(), // 自选编队: { [slotBaseId]: { charId, skillIndex?, uniEquipId? } } ({} = every slot empty)
|
||||||
diyKitted: null, // the operators a DIY slot may field (welcome.diyKitted; null before the first welcome)
|
diyKitted: null, // the operators a DIY slot may field (welcome.diyKitted; null before the first welcome)
|
||||||
@@ -53,28 +58,46 @@ export const loadoutStore = createStore({
|
|||||||
diySync: 'idle',
|
diySync: 'idle',
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Replace the stored entries (persisted at once; the sync picks the change up). */
|
/** Replace the stored entries (persisted at once with the settings; the sync picks the change up). */
|
||||||
export function setEntries(entries) {
|
export function setEntries(entries) {
|
||||||
const next = entries && typeof entries === 'object' ? entries : {};
|
const next = entries && typeof entries === 'object' ? entries : {};
|
||||||
savePref(LOADOUT_PREF, toStored(next));
|
savePref(LOADOUT_PREF, toStored(next, loadoutStore.get().ops));
|
||||||
loadoutStore.set({ entries: next });
|
loadoutStore.set({ entries: next });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Replace the stored per-operator 潜能 / 练度 (0.2.2; persisted at once with the entries; the sync picks it up). */
|
||||||
|
export function setOpsMap(ops) {
|
||||||
|
const next = ops && typeof ops === 'object' ? ops : {};
|
||||||
|
savePref(LOADOUT_PREF, toStored(loadoutStore.get().entries, next));
|
||||||
|
loadoutStore.set({ ops: next });
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Apply a parsed entry map (an imported preset). Sanitised against the loaded data first, then persisted and synced
|
* Apply a parsed entry map (an imported preset). Sanitised against the loaded data first, then persisted and synced
|
||||||
* like any ordinary edit — so a preset from another build never sends the server an entry it would refuse. An import
|
* like any ordinary edit — so a preset from another build never sends the server an entry it would refuse. An import
|
||||||
* that keeps nothing (every chess unknown, or every choice already the default) changes NOTHING: wiping the current
|
* that keeps nothing (every chess unknown, or every choice already the default) changes NOTHING: wiping the current
|
||||||
* loadout over it would be a loss the player never asked for.
|
* loadout over it would be a loss the player never asked for.
|
||||||
|
* With `ops` (an import that carries the per-operator 潜能 / 练度, 0.2.2: `isOperator` from the loaded data) those are
|
||||||
|
* sanitised and replace the current ones as well; without, the current ones stay.
|
||||||
* @param {Record<string, any>} entries `parseImport(...).entries`
|
* @param {Record<string, any>} entries `parseImport(...).entries`
|
||||||
* @param {(id: string) => any} lookup chess lookup
|
* @param {(id: string) => any} lookup chess lookup
|
||||||
* @returns {{ applied: number, dropped: number }} entries kept / entries that were not imported
|
* @param {{ ops?: Record<string, any>|null, isOperator?: (charId: string) => boolean }} [o]
|
||||||
|
* @returns {{ applied: number, dropped: number, ops?: number }} entries kept / entries (and settings) not imported / settings
|
||||||
|
* kept (only with `ops`)
|
||||||
*/
|
*/
|
||||||
export function applyLoadoutEntries(entries, lookup) {
|
export function applyLoadoutEntries(entries, lookup, { ops = null, isOperator = null } = {}) {
|
||||||
const asked = Object.keys(entries || {}).length;
|
const asked = Object.keys(entries || {}).length;
|
||||||
const clean = sanitizeEntries(entries, lookup);
|
const clean = sanitizeEntries(entries, lookup);
|
||||||
const applied = Object.keys(clean).length;
|
const applied = Object.keys(clean).length;
|
||||||
if (applied) setEntries(clean);
|
const cleanOps = ops && isOperator ? sanitizeOps(ops, isOperator) : null;
|
||||||
return { applied, dropped: Math.max(0, asked - applied) };
|
const nOps = cleanOps ? Object.keys(cleanOps).length : 0;
|
||||||
|
const askedOps = ops ? Object.keys(ops).length : 0;
|
||||||
|
if (applied || nOps) {
|
||||||
|
if (cleanOps) loadoutStore.set({ ops: cleanOps });
|
||||||
|
setEntries(applied ? clean : loadoutStore.get().entries);
|
||||||
|
}
|
||||||
|
const dropped = Math.max(0, asked - applied) + Math.max(0, askedOps - nOps);
|
||||||
|
return cleanOps ? { applied, dropped, ops: nOps } : { applied, dropped };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Replace the stored not-owned list (干员持有; persisted at once, the sync picks the change up). */
|
/** Replace the stored not-owned list (干员持有; persisted at once, the sync picks the change up). */
|
||||||
@@ -128,14 +151,17 @@ export function openLoadout(from = 'lobby', sel = null, tab = null) {
|
|||||||
data.load('assets');
|
data.load('assets');
|
||||||
data.load('local');
|
data.load('local');
|
||||||
data.load('backups');
|
data.load('backups');
|
||||||
|
data.load('effects'); // 0.2.2: the 练度 multipliers of the 局内数值 (effects.json CHAR_MAP)
|
||||||
|
data.load('garrisons'); // the 特质 at the top of the detail (PR #301)
|
||||||
loadoutStore.set({ open: true, from, ...(sel ? { sel } : {}), ...(tab === 'loadout' || tab === 'ownership' || tab === 'diy' ? { tab } : {}) });
|
loadoutStore.set({ open: true, from, ...(sel ? { sel } : {}), ...(tab === 'loadout' || tab === 'ownership' || tab === 'diy' ? { tab } : {}) });
|
||||||
}
|
}
|
||||||
export const closeLoadout = () => loadoutStore.set({ open: false });
|
export const closeLoadout = () => loadoutStore.set({ open: false });
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One setting kept in sync with the server (see the header): `key` = the store field, `stateKey` = its sync state field,
|
* One setting kept in sync with the server (see the header): `key` = the store field (or a list of them: the loadout's
|
||||||
* `msgType` / `field` = the C2S message, `prepare()` → the payload to send (may await data; null = the data is missing:
|
* `entries` and `ops`), `stateKey` = its sync state field, `msgType` / `field` = the C2S message (`field` null: the payload
|
||||||
* nothing is sent, state 'error'), `lockedText` = what a refused edit tells the player.
|
* is the message body), `prepare()` → the payload to send (may await data; null = the data is missing: nothing is sent,
|
||||||
|
* state 'error'), `lockedText` = what a refused edit tells the player.
|
||||||
*/
|
*/
|
||||||
function installPrefSync({ net, timers, target, notify, key, stateKey, msgType, field, prepare, lockedText, tag }) {
|
function installPrefSync({ net, timers, target, notify, key, stateKey, msgType, field, prepare, lockedText, tag }) {
|
||||||
const T = timers || { setTimeout: (fn, ms) => globalThis.setTimeout(fn, ms), clearTimeout: (id) => globalThis.clearTimeout(id) };
|
const T = timers || { setTimeout: (fn, ms) => globalThis.setTimeout(fn, ms), clearTimeout: (id) => globalThis.clearTimeout(id) };
|
||||||
@@ -177,7 +203,7 @@ function installPrefSync({ net, timers, target, notify, key, stateKey, msgType,
|
|||||||
pendingJson = json;
|
pendingJson = json;
|
||||||
setState('sending');
|
setState('sending');
|
||||||
try {
|
try {
|
||||||
await net.request(msgType, { [field]: payload });
|
await net.request(msgType, field ? { [field]: payload } : payload);
|
||||||
if (my !== seq) return;
|
if (my !== seq) return;
|
||||||
pendingJson = null;
|
pendingJson = null;
|
||||||
lastSent = json;
|
lastSent = json;
|
||||||
@@ -201,8 +227,9 @@ function installPrefSync({ net, timers, target, notify, key, stateKey, msgType,
|
|||||||
}
|
}
|
||||||
|
|
||||||
const offWelcome = net.on('welcome', () => { lastSent = null; pendingJson = null; seq++; schedule(50); });
|
const offWelcome = net.on('welcome', () => { lastSent = null; pendingJson = null; seq++; schedule(50); });
|
||||||
|
const keys = Array.isArray(key) ? key : [key];
|
||||||
const offStore = target.subscribe((s, prev) => {
|
const offStore = target.subscribe((s, prev) => {
|
||||||
if (s[key] !== prev[key]) { edited = true; schedule(); }
|
if (keys.some((k) => s[k] !== prev[k])) { edited = true; schedule(); }
|
||||||
// closing the overlay sends a pending edit at once (review fix): the player's next click — 准备就绪 in the solo
|
// closing the overlay sends a pending edit at once (review fix): the player's next click — 准备就绪 in the solo
|
||||||
// briefing, 开始模拟 in the room — must not overtake the debounced message (the match locks its loadout when
|
// briefing, 开始模拟 in the room — must not overtake the debounced message (the match locks its loadout when
|
||||||
// INFO_CHECK ends, so a late edit would silently only apply to the next match). Same socket ⇒ ordered.
|
// INFO_CHECK ends, so a late edit would silently only apply to the next match). Same socket ⇒ ordered.
|
||||||
@@ -229,20 +256,29 @@ function installPrefSync({ net, timers, target, notify, key, stateKey, msgType,
|
|||||||
* timers?: { setTimeout: Function, clearTimeout: Function }, target?: ReturnType<typeof createStore> }} deps
|
* timers?: { setTimeout: Function, clearTimeout: Function }, target?: ReturnType<typeof createStore> }} deps
|
||||||
* @returns {{ flush: () => Promise<void>, dispose: () => void }}
|
* @returns {{ flush: () => Promise<void>, dispose: () => void }}
|
||||||
*/
|
*/
|
||||||
export function installLoadoutSync({ net, getChessReady, lookupChess, timers, target = loadoutStore, notify } = {}) {
|
export function installLoadoutSync({ net, getChessReady, getBackupsReady, lookupChess, operatorIds, timers, target = loadoutStore, notify } = {}) {
|
||||||
const ready = getChessReady || (() => data.load('chess'));
|
const ready = getChessReady || (() => data.load('chess'));
|
||||||
|
const readyBackups = getBackupsReady || (() => data.load('backups'));
|
||||||
const lookup = lookupChess || ((id) => data.lookup('chess', id));
|
const lookup = lookupChess || ((id) => data.lookup('chess', id));
|
||||||
|
// the operators a 潜能 / 练度 may name (shared/protocol.js cultivationCharIds: the roster + the 自选 owned pool)
|
||||||
|
const opIds = operatorIds || (() => cultivationCharIds(data.get('chess'), data.get('backups')));
|
||||||
return installPrefSync({
|
return installPrefSync({
|
||||||
net, timers, target, notify, key: 'entries', stateKey: 'sync', msgType: 'room.loadout', field: 'entries', tag: 'loadout',
|
net, timers, target, notify, key: ['entries', 'ops'], stateKey: 'sync', msgType: 'room.loadout', field: null, tag: 'loadout',
|
||||||
lockedText: N_('本局的干员调配已锁定,修改将在下一局生效'),
|
lockedText: N_('本局的干员调配已锁定,修改将在下一局生效'),
|
||||||
async prepare() {
|
async prepare() {
|
||||||
const current = target.get().entries;
|
const current = target.get().entries;
|
||||||
|
const ops = target.get().ops;
|
||||||
|
const hasOps = !!ops && Object.keys(ops).length > 0;
|
||||||
// an empty loadout needs no data (nothing to sanitise): a player who never opened 干员调配 does not download
|
// an empty loadout needs no data (nothing to sanitise): a player who never opened 干员调配 does not download
|
||||||
// chess.json in the lobby just for this
|
// chess.json in the lobby just for this
|
||||||
if (!current || Object.keys(current).length === 0) return {};
|
if ((!current || Object.keys(current).length === 0) && !hasOps) return { entries: {}, ops: {} };
|
||||||
const loaded = await ready();
|
const loaded = await ready();
|
||||||
if (loaded == null) return null;
|
if (loaded == null) return null;
|
||||||
return sanitizeEntries(target.get().entries, lookup);
|
const entries = sanitizeEntries(target.get().entries, lookup);
|
||||||
|
if (!hasOps) return { entries, ops: {} };
|
||||||
|
if ((await readyBackups()) == null) return null;
|
||||||
|
const ids = opIds();
|
||||||
|
return { entries, ops: sanitizeOps(target.get().ops, (id) => ids.has(id)) };
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ export function ChessCard({ slot, idx, priv, frozen = false, reason = null, free
|
|||||||
const willMerge = !!hint;
|
const willMerge = !!hint;
|
||||||
const bonds = Array.isArray(c?.bonds) ? c.bonds : [];
|
const bonds = Array.isArray(c?.bonds) ? c.bonds : [];
|
||||||
const disabled = !!reason;
|
const disabled = !!reason;
|
||||||
const lo = si ? standInLoadout(si, LOOKUPS.getChess, data.get('backups')) : c ? chessLoadout(c, priv?.loadout, dr ? diyGetter(LOOKUPS.getChess, priv, diyData) : LOOKUPS.getChess) : null;
|
const lo = si ? standInLoadout(si, LOOKUPS.getChess, data.get('backups')) : c ? chessLoadout(c, priv?.loadout, dr ? diyGetter(LOOKUPS.getChess, priv, diyData) : LOOKUPS.getChess, { ops: priv?.ops ?? null, effects: data.get('effects') }) : null;
|
||||||
const tap = () => { if (onTap) onTap(idx); else if (!disabled) onBuy(idx); else onDetail(slot.id, 'chess', hint); };
|
const tap = () => { if (onTap) onTap(idx); else if (!disabled) onBuy(idx); else onDetail(slot.id, 'chess', hint); };
|
||||||
const name = `${c?.name || t('干员')}${si ? t('({note})', { note: standInForText(c0.name) }) : ''}`;
|
const name = `${c?.name || t('干员')}${si ? t('({note})', { note: standInForText(c0.name) }) : ''}`;
|
||||||
const card = html`<button type="button" class=${cx('scard', `scard--t${tier}`, frozen && 'is-frozen', disabled && 'is-disabled', willMerge && 'is-merge', armed && 'is-armed', si && 'is-standin')}
|
const card = html`<button type="button" class=${cx('scard', `scard--t${tier}`, frozen && 'is-frozen', disabled && 'is-disabled', willMerge && 'is-merge', armed && 'is-armed', si && 'is-standin')}
|
||||||
|
|||||||
+35
-7
@@ -65,6 +65,9 @@
|
|||||||
// (or outside a room) it simply replaces the stored one; while the room's match runs it is also handed to
|
// (or outside a room) it simply replaces the stored one; while the room's match runs it is also handed to
|
||||||
// match.setLoadout(playerId, loadout), which accepts it only during INFO_CHECK (the 干员调配 entry of the briefing)
|
// match.setLoadout(playerId, loadout), which accepts it only during INFO_CHECK (the 干员调配 entry of the briefing)
|
||||||
// and refuses it afterwards (WRONG_PHASE: the match's loadout is locked, the stored one applies to the next match).
|
// and refuses it afterwards (WRONG_PHASE: the match's loadout is locked, the stored one applies to the next match).
|
||||||
|
// ▸ Its `ops` (0.2.2: the per-operator 潜能 / 练度, shared/protocol.js checkLoadoutOps — an operator of the 干员调配
|
||||||
|
// roster or the 自选 owned pool, strict like the entries) travel with it: session.ops / seat.ops / seats[].ops and
|
||||||
|
// match.setLoadout(playerId, loadout, ops); a message without `ops` sets none (every operator 潜能 6, 精英2 Lv.60).
|
||||||
// * Operator ownership (干员持有, 0.2.0 补位, owner's decision 2026-10-05): room.ownership { notOwned } — the base chess
|
// * Operator ownership (干员持有, 0.2.0 补位, owner's decision 2026-10-05): room.ownership { notOwned } — the base chess
|
||||||
// ids the player marked as not owned — is checked leniently (shared/protocol.js checkNotOwned: anything that is not
|
// ids the player marked as not owned — is checked leniently (shared/protocol.js checkNotOwned: anything that is not
|
||||||
// a droppable NORMAL chess is dropped, never the whole list; only a malformed list is BAD_MSG) and stored on the
|
// a droppable NORMAL chess is dropped, never the whole list; only a malformed list is BAD_MSG) and stored on the
|
||||||
@@ -97,7 +100,7 @@
|
|||||||
|
|
||||||
import { randomBytes, randomInt } from 'node:crypto';
|
import { randomBytes, randomInt } from 'node:crypto';
|
||||||
import { ERR, MAX_SEATS, MAX_SPECTATORS, ROOM_CODE_LEN, modeIdFor } from '../shared/constants.js';
|
import { ERR, MAX_SEATS, MAX_SPECTATORS, ROOM_CODE_LEN, modeIdFor } from '../shared/constants.js';
|
||||||
import { checkLoadout, checkNotOwned, checkDiyPicks } from '../shared/protocol.js';
|
import { checkLoadout, checkLoadoutOps, cultivationCharIds, checkNotOwned, checkDiyPicks } from '../shared/protocol.js';
|
||||||
import { encode, isDroppable, isErrCode, sendRaw, sendSession } from './net.js';
|
import { encode, isDroppable, isErrCode, sendRaw, sendSession } from './net.js';
|
||||||
import { getData as defaultGetData, lookup } from './data.js';
|
import { getData as defaultGetData, lookup } from './data.js';
|
||||||
import { Match as DefaultMatch } from './match/Match.js';
|
import { Match as DefaultMatch } from './match/Match.js';
|
||||||
@@ -129,6 +132,7 @@ const noopLog = { info() {}, warn() {}, error() {}, debug() {} };
|
|||||||
/**
|
/**
|
||||||
* @typedef {{ seat: number, playerId: string, name: string, isBot: boolean, ready: boolean,
|
* @typedef {{ seat: number, playerId: string, name: string, isBot: boolean, ready: boolean,
|
||||||
* connected: boolean, left: boolean, loadout?: Record<string, { skill: number, module: string|null }> | null,
|
* connected: boolean, left: boolean, loadout?: Record<string, { skill: number, module: string|null }> | null,
|
||||||
|
* ops?: Readonly<Record<string, { potential: number, cultivate: number }>> | null,
|
||||||
* notOwned?: readonly string[] | null, diy?: Readonly<Record<string, DiyLoadout>> | null }} Seat
|
* notOwned?: readonly string[] | null, diy?: Readonly<Record<string, DiyLoadout>> | null }} Seat
|
||||||
* @typedef {{ charId: string, skillIndex: number, uniEquipId: string|null }} DiyLoadout
|
* @typedef {{ charId: string, skillIndex: number, uniEquipId: string|null }} DiyLoadout
|
||||||
*/
|
*/
|
||||||
@@ -140,6 +144,22 @@ function freezeLoadout(loadout) {
|
|||||||
return Object.freeze(out);
|
return Object.freeze(out);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Deep-frozen copy of checked operator settings (0.2.2 潜能 / 练度; shared by the session, the seat and the match). */
|
||||||
|
function freezeOps(ops) {
|
||||||
|
const out = {};
|
||||||
|
for (const [id, e] of Object.entries(ops || {})) out[id] = Object.freeze({ potential: e.potential, cultivate: e.cultivate });
|
||||||
|
return Object.freeze(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The charIds a player may set a potential / 练度 for, per data object (shared/protocol.js cultivationCharIds). */
|
||||||
|
const OPS_IDS = new WeakMap();
|
||||||
|
function opsCharIds(data) {
|
||||||
|
if (!data || typeof data !== 'object') return new Set();
|
||||||
|
let ids = OPS_IDS.get(data);
|
||||||
|
if (!ids) { ids = cultivationCharIds(data.chess, data.backups); OPS_IDS.set(data, ids); }
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
/** Deep-frozen copy of checked 自选 picks (shared by the session, the seat and the match's PlayerState). */
|
/** Deep-frozen copy of checked 自选 picks (shared by the session, the seat and the match's PlayerState). */
|
||||||
function freezeDiy(picks) {
|
function freezeDiy(picks) {
|
||||||
const out = {};
|
const out = {};
|
||||||
@@ -600,24 +620,30 @@ export class Lobby {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* room.loadout (DESIGN §16): check the operator loadout against the game data, store it on the session and the seat,
|
* room.loadout (DESIGN §16): check the operator loadout — and its per-operator 潜能 / 练度 `ops` (0.2.2) — against the
|
||||||
* and — while a match runs — hand it to the match (accepted only during INFO_CHECK, see the header).
|
* game data, store both on the session and the seat, and — while a match runs — hand them to the match (accepted only
|
||||||
|
* during INFO_CHECK, see the header). Either part refused: nothing is stored.
|
||||||
*/
|
*/
|
||||||
loadout(session, { entries }) {
|
loadout(session, { entries, ops }) {
|
||||||
const data = this.safeData();
|
const data = this.safeData();
|
||||||
const res = checkLoadout(entries, (id) => lookup('chess', id, data));
|
const res = checkLoadout(entries, (id) => lookup('chess', id, data));
|
||||||
if (!res || res.error) return fail(res && isErrCode(res.error) ? res.error : ERR.BAD_MSG, res && res.detail);
|
if (!res || res.error) return fail(res && isErrCode(res.error) ? res.error : ERR.BAD_MSG, res && res.detail);
|
||||||
|
const ids = opsCharIds(data);
|
||||||
|
const resOps = checkLoadoutOps(ops, (id) => ids.has(id));
|
||||||
|
if (!resOps || resOps.error) return fail(resOps && isErrCode(resOps.error) ? resOps.error : ERR.BAD_MSG, resOps && resOps.detail);
|
||||||
const loadout = freezeLoadout(res.loadout);
|
const loadout = freezeLoadout(res.loadout);
|
||||||
|
const opsSet = freezeOps(resOps.ops);
|
||||||
session.loadout = loadout;
|
session.loadout = loadout;
|
||||||
|
session.ops = opsSet;
|
||||||
const room = this.roomOf(session);
|
const room = this.roomOf(session);
|
||||||
if (!room) return OK;
|
if (!room) return OK;
|
||||||
const seat = room.seatOf(session.playerId);
|
const seat = room.seatOf(session.playerId);
|
||||||
if (seat) seat.loadout = loadout;
|
if (seat) { seat.loadout = loadout; seat.ops = opsSet; }
|
||||||
if (!room.match || !seat) return OK; // a spectator's loadout stays on its session, never reaching the match
|
if (!room.match || !seat) return OK; // a spectator's loadout stays on its session, never reaching the match
|
||||||
if (typeof room.match.setLoadout !== 'function') return fail(ERR.ROOM_STARTED, 'stored for the next match');
|
if (typeof room.match.setLoadout !== 'function') return fail(ERR.ROOM_STARTED, 'stored for the next match');
|
||||||
let r;
|
let r;
|
||||||
try {
|
try {
|
||||||
r = room.match.setLoadout(session.playerId, loadout);
|
r = room.match.setLoadout(session.playerId, loadout, opsSet);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.log.error(`[lobby] ${room.code} match.setLoadout threw`, e);
|
this.log.error(`[lobby] ${room.code} match.setLoadout threw`, e);
|
||||||
return fail(ERR.INTERNAL);
|
return fail(ERR.INTERNAL);
|
||||||
@@ -679,8 +705,9 @@ export class Lobby {
|
|||||||
if (host) host.ready = true;
|
if (host) host.ready = true;
|
||||||
const seats = room.seats.filter(Boolean).map((s) => ({
|
const seats = room.seats.filter(Boolean).map((s) => ({
|
||||||
seat: s.seat, playerId: s.playerId, name: s.name, isBot: s.isBot, connected: s.connected,
|
seat: s.seat, playerId: s.playerId, name: s.name, isBot: s.isBot, connected: s.connected,
|
||||||
// DESIGN §16: the human's checked operator loadout (bots fight with the defaults)
|
// DESIGN §16: the human's checked operator loadout and (0.2.2) per-operator 潜能 / 练度 (bots fight with the defaults)
|
||||||
loadout: s.isBot ? null : s.loadout || null,
|
loadout: s.isBot ? null : s.loadout || null,
|
||||||
|
ops: s.isBot ? null : s.ops || null,
|
||||||
// 0.2.0 补位: the chess the human marked as not owned (bots own every operator)
|
// 0.2.0 补位: the chess the human marked as not owned (bots own every operator)
|
||||||
notOwned: s.isBot ? null : s.notOwned || null,
|
notOwned: s.isBot ? null : s.notOwned || null,
|
||||||
// 0.2.0 自选编队: the human's checked DIY picks (bots field no 自选 piece [ASSUMED])
|
// 0.2.0 自选编队: the human's checked DIY picks (bots field no 自选 piece [ASSUMED])
|
||||||
@@ -945,6 +972,7 @@ export class Lobby {
|
|||||||
return {
|
return {
|
||||||
seat: idx, playerId: session.playerId, name: session.name, isBot: false, ready: false, connected: session.connected, left: false,
|
seat: idx, playerId: session.playerId, name: session.name, isBot: false, ready: false, connected: session.connected, left: false,
|
||||||
loadout: session.loadout || null,
|
loadout: session.loadout || null,
|
||||||
|
ops: session.ops || null,
|
||||||
notOwned: session.notOwned || null,
|
notOwned: session.notOwned || null,
|
||||||
diy: session.diy || null,
|
diy: session.diy || null,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -17,12 +17,16 @@
|
|||||||
// (MatchPhases.humansFirst); only `true` turns it on; ignored in solo
|
// (MatchPhases.humansFirst); only `true` turns it on; ignored in solo
|
||||||
// opts.seats Array<{ seat: 0..3, playerId: string, name: string, isBot: boolean, connected: boolean,
|
// opts.seats Array<{ seat: 0..3, playerId: string, name: string, isBot: boolean, connected: boolean,
|
||||||
// loadout?: { [baseChessId]: { skill: index, module: uniEquipId|'none'|null } } | null,
|
// loadout?: { [baseChessId]: { skill: index, module: uniEquipId|'none'|null } } | null,
|
||||||
|
// ops?: { [charId]: { potential: 1–6, cultivate: 0–3 } } | null,
|
||||||
// notOwned?: string[] | null,
|
// notOwned?: string[] | null,
|
||||||
// diy?: { [slotBaseId]: { charId, skillIndex, uniEquipId } } | null }>
|
// diy?: { [slotBaseId]: { charId, skillIndex, uniEquipId } } | null }>
|
||||||
// sorted by seat, 1–4 entries, ≥ 1 human; solo ⇒ exactly 1 human and no bots.
|
// sorted by seat, 1–4 entries, ≥ 1 human; solo ⇒ exactly 1 human and no bots.
|
||||||
// Bot playerIds start with 'ai_'. Seat indexes may have gaps (e.g. seats 0 and 2).
|
// Bot playerIds start with 'ai_'. Seat indexes may have gaps (e.g. seats 0 and 2).
|
||||||
// `loadout` (DESIGN §16, optional): the human's operator loadout, already checked by the lobby
|
// `loadout` (DESIGN §16, optional): the human's operator loadout, already checked by the lobby
|
||||||
// (shared/protocol.js checkLoadout); PlayerState re-checks it against opts.data and ignores it for bots.
|
// (shared/protocol.js checkLoadout); PlayerState re-checks it against opts.data and ignores it for bots.
|
||||||
|
// `ops` (0.2.2, optional): the human's per-operator 潜能 / 练度 (checkLoadoutOps; missing = 潜能 6, 精英2
|
||||||
|
// Lv.60 — the owner's decision of 2026-10-08), re-checked and changed like the loadout (INFO_CHECK);
|
||||||
|
// bots fight with the defaults.
|
||||||
// `notOwned` (0.2.0 补位, optional): the base chess ids the human marked as not owned (干员持有) — fixed
|
// `notOwned` (0.2.0 补位, optional): the base chess ids the human marked as not owned (干员持有) — fixed
|
||||||
// for the match; those chess fight as their stand-ins (PlayerState setNotOwned; bots own everything).
|
// for the match; those chess fight as their stand-ins (PlayerState setNotOwned; bots own everything).
|
||||||
// `diy` (0.2.0 自选编队, optional): the human's 自选 picks (shared/protocol.js checkDiyPicks) — fixed for
|
// `diy` (0.2.0 自选编队, optional): the human's 自选 picks (shared/protocol.js checkDiyPicks) — fixed for
|
||||||
@@ -56,9 +60,10 @@
|
|||||||
// (a resync request). May be called without a preceding onDisconnect. Resend full
|
// (a resync request). May be called without a preceding onDisconnect. Resend full
|
||||||
// state: m.public, m.private and, if a battle is on (client-side combat), the
|
// state: m.public, m.private and, if a battle is on (client-side combat), the
|
||||||
// b.start of the field the player is on / watching (server-run mode: m.field + b.snap).
|
// b.start of the field the player is on / watching (server-run mode: m.field + b.snap).
|
||||||
// setLoadout(playerId, loadout) → { ok } | { error, detail? } (DESIGN §16; optional for the platform) a new checked
|
// setLoadout(playerId, loadout, ops?) → { ok } | { error, detail? } (DESIGN §16; optional for the platform) a new
|
||||||
// operator loadout from room.loadout. Accepted only during INFO_CHECK (the briefing's
|
// checked operator loadout (and its 潜能 / 练度 `ops`, 0.2.2) from room.loadout. Accepted only
|
||||||
// 干员调配 entry); afterwards the match's loadout is locked (WRONG_PHASE).
|
// during INFO_CHECK (the briefing's 干员调配 entry); afterwards the match's loadout is locked
|
||||||
|
// (WRONG_PHASE).
|
||||||
// onLeave(playerId) The human quit permanently (g.leave, room.leave, or the 10-minute reconnect
|
// onLeave(playerId) The human quit permanently (g.leave, room.leave, or the 10-minute reconnect
|
||||||
// window expired). They will never return under this playerId in this match;
|
// window expired). They will never return under this playerId in this match;
|
||||||
// treat as quit (AI takes over / eliminated per DESIGN). No onDisconnect follows.
|
// treat as quit (AI takes over / eliminated per DESIGN). No onDisconnect follows.
|
||||||
|
|||||||
@@ -73,6 +73,12 @@
|
|||||||
// mismatch falls back to the defaults) and kept frozen; bots always use the defaults. Match.setLoadout may replace
|
// mismatch falls back to the defaults) and kept frozen; bots always use the defaults. Match.setLoadout may replace
|
||||||
// it during INFO_CHECK only. battleInput() resolves every chess unit to `skillIndex` + `moduleId` (resolveLoadout:
|
// it during INFO_CHECK only. battleInput() resolves every chess unit to `skillIndex` + `moduleId` (resolveLoadout:
|
||||||
// normal chess → moduleId null, elite → uniEquipId | 'none'); m.private exposes `loadout`.
|
// normal chess → moduleId null, elite → uniEquipId | 'none'); m.private exposes `loadout`.
|
||||||
|
// * 潜能 / 练度 (0.2.2, the owner's decision of 2026-10-08 「调配干员里自己设置吧,默认满潜满加成」): the human's checked
|
||||||
|
// `seat.ops` ({ [charId]: { potential, cultivate } }, defaults dropped) is re-checked (checkLoadoutOps) and changed
|
||||||
|
// with the loadout; a missing entry — and every operator of a bot — is 潜能 6, 精英2 Lv.60. battleInput() states both
|
||||||
|
// for every operator the player owns (a chess fielded as itself, an owned 自选 pick: `cultivationFor`), never for a
|
||||||
|
// 补位 stand-in or a prototype pick (shared/potential.js cultivationOf); the 自选 summons' hand count follows the
|
||||||
|
// owner's potential (望's 棋子). m.private exposes `ops`; the scouting views carry each unit's `potential` / `cultivate`.
|
||||||
// * Operator ownership (0.2.0 补位, the approved plan — owner's decision 2026-10-05): the human's `seat.notOwned` (base
|
// * Operator ownership (0.2.0 补位, the approved plan — owner's decision 2026-10-05): the human's `seat.notOwned` (base
|
||||||
// chess ids marked 未持有 on the 干员持有 screen) is re-checked against this match's data (shared/protocol.js
|
// chess ids marked 未持有 on the 干员持有 screen) is re-checked against this match's data (shared/protocol.js
|
||||||
// checkNotOwned + a stand-in record in data/backups.json) and fixed for the match (`standIns`; bots own every
|
// checkNotOwned + a stand-in record in data/backups.json) and fixed for the match (`standIns`; bots own every
|
||||||
@@ -152,7 +158,9 @@ export class PlayerState {
|
|||||||
this.lastEmoteAt = -Infinity;
|
this.lastEmoteAt = -Infinity;
|
||||||
/** operator loadout (DESIGN §16): frozen { [baseChessId]: { skill, module } }, {} = every chess on its defaults */
|
/** operator loadout (DESIGN §16): frozen { [baseChessId]: { skill, module } }, {} = every chess on its defaults */
|
||||||
this.loadout = Object.freeze({});
|
this.loadout = Object.freeze({});
|
||||||
if (!this.isBot && seat.loadout) this.setLoadout(seat.loadout);
|
// 0.2.2: the per-operator 潜能 / 练度 ({} = every operator 潜能 6, 精英2 Lv.60 — bots always)
|
||||||
|
this.ops = Object.freeze({});
|
||||||
|
if (!this.isBot && (seat.loadout || seat.ops)) this.setLoadout(seat.loadout ?? null, seat.ops ?? null);
|
||||||
/**
|
/**
|
||||||
* 补位 (0.2.0): the base chess ids this player fields as their stand-ins — the seat's not-owned list when the match
|
* 补位 (0.2.0): the base chess ids this player fields as their stand-ins — the seat's not-owned list when the match
|
||||||
* started, re-checked against this match's data; frozen, sorted; [] = every operator owned (bots always)
|
* started, re-checked against this match's data; frozen, sorted; [] = every operator owned (bots always)
|
||||||
|
|||||||
@@ -58,18 +58,20 @@ export class MatchPlatform {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* room.loadout during the match (DESIGN §16): only while INFO_CHECK runs (the briefing's 干员调配 entry); the lobby
|
* room.loadout during the match (DESIGN §16): only while INFO_CHECK runs (the briefing's 干员调配 entry); the lobby
|
||||||
* already checked it against the data (PlayerState.setLoadout re-checks it).
|
* already checked it against the data (PlayerState.setLoadout re-checks it). `ops` (0.2.2): the per-operator 潜能 /
|
||||||
|
* 练度 that come with it (undefined = unchanged).
|
||||||
* @param {string} playerId
|
* @param {string} playerId
|
||||||
* @param {Record<string, { skill: number, module: string|null }> | null} loadout
|
* @param {Record<string, { skill: number, module: string|null }> | null} loadout
|
||||||
|
* @param {Record<string, { potential: number, cultivate: number }> | null} [ops]
|
||||||
* @returns {{ ok: true } | { error: string, detail?: string }}
|
* @returns {{ ok: true } | { error: string, detail?: string }}
|
||||||
*/
|
*/
|
||||||
setLoadout(playerId, loadout) {
|
setLoadout(playerId, loadout, ops = undefined) {
|
||||||
const ps = this.players.get(playerId);
|
const ps = this.players.get(playerId);
|
||||||
if (!ps || ps.isBot || ps.left) return fail(ERR.NOT_IN_ROOM);
|
if (!ps || ps.isBot || ps.left) return fail(ERR.NOT_IN_ROOM);
|
||||||
if (this.disposed || this.ended || this.phase !== PHASE.INFO_CHECK) return fail(ERR.WRONG_PHASE, 'loadout locked for this match');
|
if (this.disposed || this.ended || this.phase !== PHASE.INFO_CHECK) return fail(ERR.WRONG_PHASE, 'loadout locked for this match');
|
||||||
let res = OK;
|
let res = OK;
|
||||||
this.guard(() => {
|
this.guard(() => {
|
||||||
if (!ps.setLoadout(loadout)) { res = fail(ERR.BAD_TARGET, 'loadout does not match the game data'); return; }
|
if (!ps.setLoadout(loadout, ops)) { res = fail(ERR.BAD_TARGET, 'loadout does not match the game data'); return; }
|
||||||
this.markPrivate(ps);
|
this.markPrivate(ps);
|
||||||
});
|
});
|
||||||
return res;
|
return res;
|
||||||
|
|||||||
@@ -21,6 +21,17 @@ const finiteOrNull = (v, cap = Infinity) => {
|
|||||||
return Math.max(0, Math.min(Number.isFinite(cap) ? cap : Infinity, Math.trunc(n)));
|
return Math.max(0, Math.min(Number.isFinite(cap) ? cap : Infinity, Math.trunc(n)));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A scouted operator's potential (below 6) and 练度 (0.2.2), like the sim's UnitInfo: PlayerState.loadoutFor's
|
||||||
|
* `potential` / `cultivate` (null for a stand-in or a prototype 自选 pick: neither field).
|
||||||
|
*/
|
||||||
|
function cultivationInfo(lo) {
|
||||||
|
const out = {};
|
||||||
|
if (lo && Number.isInteger(lo.potential) && lo.potential < 6) out.potential = lo.potential;
|
||||||
|
if (lo && Number.isInteger(lo.cultivate)) out.cultivate = lo.cultivate;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
export class MatchViews {
|
export class MatchViews {
|
||||||
statusOf(ps) {
|
statusOf(ps) {
|
||||||
if (ps.left) return 'left';
|
if (ps.left) return 'left';
|
||||||
@@ -238,7 +249,7 @@ export class MatchViews {
|
|||||||
const rec = piece.kind === 'token' ? gd.token(piece.id) : ps.fieldRecord(chess);
|
const rec = piece.kind === 'token' ? gd.token(piece.id) : ps.fieldRecord(chess);
|
||||||
const assets = (rec && rec.assets) || {};
|
const assets = (rec && rec.assets) || {};
|
||||||
// DESIGN §16: the skill / module THIS player's operator fights with (the scout's detail card shows it, like the
|
// DESIGN §16: the skill / module THIS player's operator fights with (the scout's detail card shows it, like the
|
||||||
// sim's UnitInfo in a shared field); moduleId only for an elite
|
// sim's UnitInfo in a shared field); moduleId only for an elite; 0.2.2 its potential (below 6) and 练度
|
||||||
const lo = piece.kind === 'chess' && chess ? ps.loadoutFor(chess) : null;
|
const lo = piece.kind === 'chess' && chess ? ps.loadoutFor(chess) : null;
|
||||||
units.push({
|
units.push({
|
||||||
id: piece.uid, uid: piece.uid, kind: piece.kind === 'token' ? 'token' : 'op', side: 'ally', ownerId: ps.playerId, defId: piece.id,
|
id: piece.uid, uid: piece.uid, kind: piece.kind === 'token' ? 'token' : 'op', side: 'ally', ownerId: ps.playerId, defId: piece.id,
|
||||||
@@ -252,6 +263,7 @@ export class MatchViews {
|
|||||||
items: piece.kind === 'chess' && Array.isArray(piece.items) && piece.items.length ? piece.items.map((it) => it.id) : undefined,
|
items: piece.kind === 'chess' && Array.isArray(piece.items) && piece.items.length ? piece.items.map((it) => it.id) : undefined,
|
||||||
standInFor: rec && rec.standInFor ? rec.standInFor : undefined,
|
standInFor: rec && rec.standInFor ? rec.standInFor : undefined,
|
||||||
diy: this._diyInfo(ps, piece),
|
diy: this._diyInfo(ps, piece),
|
||||||
|
...cultivationInfo(lo),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return units;
|
return units;
|
||||||
@@ -315,6 +327,7 @@ export class MatchViews {
|
|||||||
items: piece.kind === 'chess' && Array.isArray(piece.items) && piece.items.length ? piece.items.map((it) => it.id) : undefined,
|
items: piece.kind === 'chess' && Array.isArray(piece.items) && piece.items.length ? piece.items.map((it) => it.id) : undefined,
|
||||||
standInFor: standIn && standIn.standInFor ? standIn.standInFor : undefined,
|
standInFor: standIn && standIn.standInFor ? standIn.standInFor : undefined,
|
||||||
diy: this._diyInfo(ps, piece),
|
diy: this._diyInfo(ps, piece),
|
||||||
|
...cultivationInfo(lo),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
for (let i = 0; i < ps.hand.length; i++) {
|
for (let i = 0; i < ps.hand.length; i++) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// server/match/player/basics.js — PlayerState methods: the basics — seat flags and counts (isHumanActive,
|
// server/match/player/basics.js — PlayerState methods: the basics — seat flags and counts (isHumanActive,
|
||||||
// botControlled, deployCap, deployCount, tempEmpty), the prep that resolves a temp piece (tempDue, _putTemp: every
|
// botControlled, deployCap, deployCount, tempEmpty), the prep that resolves a temp piece (tempDue, _putTemp: every
|
||||||
// write into a temp slot), the operator loadout (DESIGN §16: setLoadout, loadoutFor), the not-owned operators fielded
|
// write into a temp slot), the operator loadout (DESIGN §16: setLoadout, loadoutFor; 0.2.2 潜能 / 练度: cultivationFor), the not-owned operators fielded
|
||||||
// as their stand-ins (0.2.0 补位: setNotOwned, fieldsStandIn, fieldRecord), the deploy map on the field the
|
// as their stand-ins (0.2.0 补位: setNotOwned, fieldsStandIn, fieldRecord), the deploy map on the field the
|
||||||
// player deploys on (Match.deployFieldOf) and the withdrawal of pieces a terrain / deploy-field change left on tiles
|
// player deploys on (Match.deployFieldOf) and the withdrawal of pieces a terrain / deploy-field change left on tiles
|
||||||
// they may no longer occupy (_evictIllegal), dirty.
|
// they may no longer occupy (_evictIllegal), dirty.
|
||||||
@@ -9,9 +9,19 @@
|
|||||||
|
|
||||||
import { PHASE } from '../../../shared/constants.js';
|
import { PHASE } from '../../../shared/constants.js';
|
||||||
import { msg, dn } from '../../../shared/i18n.js';
|
import { msg, dn } from '../../../shared/i18n.js';
|
||||||
import { checkLoadout, checkNotOwned, resolveLoadout } from '../../../shared/protocol.js';
|
import { checkLoadout, checkLoadoutOps, cultivationCharIds, checkNotOwned, resolveLoadout } from '../../../shared/protocol.js';
|
||||||
|
import { cultivationOf } from '../../../shared/potential.js';
|
||||||
import { tileKey, boardOrder } from '../board.js';
|
import { tileKey, boardOrder } from '../board.js';
|
||||||
|
|
||||||
|
/** The charIds a player may set a potential / 练度 for, per game-data object (shared/protocol.js cultivationCharIds). */
|
||||||
|
const OPS_IDS = new WeakMap();
|
||||||
|
function opsCharIds(raw) {
|
||||||
|
if (!raw || typeof raw !== 'object') return new Set();
|
||||||
|
let ids = OPS_IDS.get(raw);
|
||||||
|
if (!ids) { ids = cultivationCharIds(raw.chess, raw.backups); OPS_IDS.set(raw, ids); }
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
export class PlayerBasics {
|
export class PlayerBasics {
|
||||||
get isHumanActive() { return !this.isBot && !this.left; }
|
get isHumanActive() { return !this.isBot && !this.left; }
|
||||||
/** The engine acts for this seat (AI teammate or "AI 托管"; a departed human is eliminated, so nothing is left to do). */
|
/** The engine acts for this seat (AI teammate or "AI 托管"; a departed human is eliminated, so nothing is left to do). */
|
||||||
@@ -46,14 +56,35 @@ export class PlayerBasics {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Replace the operator loadout (DESIGN §16) after re-checking it against this match's data. Accepts the checked
|
* Replace the operator loadout (DESIGN §16) — and, when `ops` is given, the per-operator 潜能 / 练度 (0.2.2; null = none
|
||||||
* form `{ id: { skill, module|null } }` or raw `room.loadout` entries. Returns false (loadout unchanged) when it does
|
* set, undefined = unchanged) — after re-checking them against this match's data. Accepts the checked form
|
||||||
* not fit the data; bots keep the defaults.
|
* `{ id: { skill, module|null } }` or raw `room.loadout` entries, and the checked or raw `ops`. Returns false (nothing
|
||||||
|
* changes) when either does not fit the data; bots keep the defaults.
|
||||||
* @param {any} loadout
|
* @param {any} loadout
|
||||||
|
* @param {any} [ops]
|
||||||
* @returns {boolean}
|
* @returns {boolean}
|
||||||
*/
|
*/
|
||||||
setLoadout(loadout) {
|
setLoadout(loadout, ops = undefined) {
|
||||||
if (this.isBot) return false;
|
if (this.isBot) return false;
|
||||||
|
let opsRes = null;
|
||||||
|
if (ops !== undefined) {
|
||||||
|
const raw = {};
|
||||||
|
if (ops && typeof ops === 'object' && !Array.isArray(ops)) {
|
||||||
|
for (const [id, e] of Object.entries(ops)) {
|
||||||
|
if (!e || typeof e !== 'object') continue;
|
||||||
|
const x = {};
|
||||||
|
if (Number.isInteger(e.potential)) x.potential = e.potential;
|
||||||
|
if (Number.isInteger(e.cultivate)) x.cultivate = e.cultivate;
|
||||||
|
if (Object.keys(x).length) raw[id] = x;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const ids = opsCharIds(this.gd.raw);
|
||||||
|
opsRes = checkLoadoutOps(raw, (id) => ids.has(id));
|
||||||
|
if (!opsRes || !opsRes.ok) {
|
||||||
|
this.m.log?.warn?.(`[match ${this.m.roomCode}] operator settings of ${this.playerId} ignored: ${opsRes && opsRes.detail}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
const entries = {};
|
const entries = {};
|
||||||
if (loadout && typeof loadout === 'object' && !Array.isArray(loadout)) {
|
if (loadout && typeof loadout === 'object' && !Array.isArray(loadout)) {
|
||||||
for (const [id, e] of Object.entries(loadout)) {
|
for (const [id, e] of Object.entries(loadout)) {
|
||||||
@@ -72,9 +103,27 @@ export class PlayerBasics {
|
|||||||
const out = {};
|
const out = {};
|
||||||
for (const [id, e] of Object.entries(res.loadout)) out[id] = Object.freeze({ skill: e.skill, module: e.module ?? null });
|
for (const [id, e] of Object.entries(res.loadout)) out[id] = Object.freeze({ skill: e.skill, module: e.module ?? null });
|
||||||
this.loadout = Object.freeze(out);
|
this.loadout = Object.freeze(out);
|
||||||
|
if (opsRes) {
|
||||||
|
const o = {};
|
||||||
|
for (const [id, e] of Object.entries(opsRes.ops)) o[id] = Object.freeze({ potential: e.potential, cultivate: e.cultivate });
|
||||||
|
this.ops = Object.freeze(o);
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The potential / 练度 this player's piece of chess record `chessRecord` fights at (0.2.2; shared/potential.js
|
||||||
|
* cultivationOf over `this.ops` — defaults 潜能 6, 精英2 Lv.60): the operator the player owns — the chess fielded as
|
||||||
|
* itself, an owned 自选 pick (its charId) — or null for a 补位 stand-in and a prototype 自选 pick.
|
||||||
|
* @param {object|null} chessRecord
|
||||||
|
* @returns {{ potential: number, cultivate: number } | null}
|
||||||
|
*/
|
||||||
|
cultivationFor(chessRecord) {
|
||||||
|
if (!chessRecord || this.fieldsStandIn(chessRecord)) return null;
|
||||||
|
const rec = chessRecord.isDiy && typeof chessRecord.chessId === 'string' ? this.gd.chess(chessRecord.chessId) || chessRecord : chessRecord;
|
||||||
|
return cultivationOf(rec, this.ops);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The skill index / module a chess record fights with under this player's loadout (DESIGN §16) — for a chess this
|
* The skill index / module a chess record fights with under this player's loadout (DESIGN §16) — for a chess this
|
||||||
* player fields as its stand-in (0.2.0 补位) the stand-in's backup selection, whatever the loadout says; for a slotted
|
* player fields as its stand-in (0.2.0 补位) the stand-in's backup selection, whatever the loadout says; for a slotted
|
||||||
@@ -83,10 +132,12 @@ export class PlayerBasics {
|
|||||||
*/
|
*/
|
||||||
loadoutFor(chessRecord) {
|
loadoutFor(chessRecord) {
|
||||||
if (this.fieldsStandIn(chessRecord)) {
|
if (this.fieldsStandIn(chessRecord)) {
|
||||||
return resolveLoadout(null, this.gd.standIn(chessRecord.chessId), (id) => this.gd.standIn(id) || this.gd.chess(id));
|
return { ...resolveLoadout(null, this.gd.standIn(chessRecord.chessId), (id) => this.gd.standIn(id) || this.gd.chess(id)), potential: null, cultivate: null };
|
||||||
}
|
}
|
||||||
const rec = chessRecord && chessRecord.isDiy && typeof chessRecord.chessId === 'string' ? this.gd.chess(chessRecord.chessId) || chessRecord : chessRecord;
|
const rec = chessRecord && chessRecord.isDiy && typeof chessRecord.chessId === 'string' ? this.gd.chess(chessRecord.chessId) || chessRecord : chessRecord;
|
||||||
return resolveLoadout(this.loadout, rec, (id) => this.gd.chess(id));
|
// 0.2.2: with the operator's potential / 练度 (null for a prototype 自选 pick) — the 自选 summons' hand count reads it
|
||||||
|
const cv = this.cultivationFor(chessRecord);
|
||||||
|
return { ...resolveLoadout(this.loadout, rec, (id) => this.gd.chess(id)), potential: cv ? cv.potential : null, cultivate: cv ? cv.cultivate : null };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -36,6 +36,7 @@
|
|||||||
import { KITTED_CHARS } from '../../sim/content/kits/index.js';
|
import { KITTED_CHARS } from '../../sim/content/kits/index.js';
|
||||||
import { checkDiyPicks } from '../../../shared/protocol.js';
|
import { checkDiyPicks } from '../../../shared/protocol.js';
|
||||||
import { diyRecord, diySlot, diyTokenOwner } from '../../../shared/diy.js';
|
import { diyRecord, diySlot, diyTokenOwner } from '../../../shared/diy.js';
|
||||||
|
import { atPotential } from '../../../shared/potential.js';
|
||||||
|
|
||||||
const posIntOr = (v, d) => (Number.isInteger(v) && v > 0 ? v : d);
|
const posIntOr = (v, d) => (Number.isInteger(v) && v > 0 ? v : d);
|
||||||
const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
||||||
@@ -102,7 +103,8 @@ export function diyGameData(gd, records) {
|
|||||||
* placeable, by the variant of the owner form (`bySkill[skillIndex]` sources) — the deploy limit as the count (PRTS
|
* placeable, by the variant of the owner form (`bySkill[skillIndex]` sources) — the deploy limit as the count (PRTS
|
||||||
* 卫戍协议/帮助 "根据召唤物部署数量上限(非初始持有量)"), the active module's own when its variant has one (`byModule`:
|
* 卫戍协议/帮助 "根据召唤物部署数量上限(非初始持有量)"), the active module's own when its variant has one (`byModule`:
|
||||||
* 望's TRP-X "可同时部署的陷阱数量提升", 6 → 7 棋子; SUM-Y stage 2+ 4 drones / summons). The data's deploy limit holds the
|
* 望's TRP-X "可同时部署的陷阱数量提升", 6 → 7 棋子; SUM-Y stage 2+ 4 drones / summons). The data's deploy limit holds the
|
||||||
* token's own talent additions (tools/build-data.mjs tokenTalentDeckBonus, 0.2.0): 麦哲伦 / 令 / 电弧 3, 白铁 2, 夜莺 3 幻影.
|
* token's own talent additions (tools/build-data.mjs tokenTalentDeckBonus, 0.2.0): 麦哲伦 / 令 / 电弧 3, 白铁 2, 夜莺 3 幻影 —
|
||||||
|
* at the owner's potential (`loadout.potential`, PlayerState.loadoutFor; 0.2.2: 望's 棋子 6 below 潜能3, 7 from it).
|
||||||
*/
|
*/
|
||||||
placeableTokens: {
|
placeableTokens: {
|
||||||
value: (chessId, loadout = null) => {
|
value: (chessId, loadout = null) => {
|
||||||
@@ -113,7 +115,7 @@ export function diyGameData(gd, records) {
|
|||||||
for (const tid of Array.isArray(rec.tokens) ? rec.tokens : []) {
|
for (const tid of Array.isArray(rec.tokens) ? rec.tokens : []) {
|
||||||
const t = tokenOf(tid);
|
const t = tokenOf(tid);
|
||||||
if (!t || t.kind !== 'summon' || t.placeable !== true) continue;
|
if (!t || t.kind !== 'summon' || t.placeable !== true) continue;
|
||||||
const v = isObj(t.variants) ? t.variants[owner] ?? null : null;
|
const v = isObj(t.variants) ? atPotential(t.variants[owner] ?? null, loadout?.potential) : null;
|
||||||
if (v) {
|
if (v) {
|
||||||
const alt = loadout && Number.isInteger(loadout.skillIndex) && v.bySkill ? v.bySkill[loadout.skillIndex] : null;
|
const alt = loadout && Number.isInteger(loadout.skillIndex) && v.bySkill ? v.bySkill[loadout.skillIndex] : null;
|
||||||
const src = Array.isArray(alt?.sources) ? alt.sources : Array.isArray(v.sources) ? v.sources : [];
|
const src = Array.isArray(alt?.sources) ? alt.sources : Array.isArray(v.sources) ? v.sources : [];
|
||||||
|
|||||||
@@ -119,6 +119,10 @@ export class PlayerRound {
|
|||||||
u.skillIndex = lo.skillIndex;
|
u.skillIndex = lo.skillIndex;
|
||||||
u.moduleId = lo.moduleId;
|
u.moduleId = lo.moduleId;
|
||||||
}
|
}
|
||||||
|
// 0.2.2: the potential / 练度 of an operator the player owns (the chess as itself, an owned 自选 pick — the player's
|
||||||
|
// 干员调配 settings, 潜能 6 / 精英2 Lv.60 by default and for bots; never a stand-in's nor a prototype pick's)
|
||||||
|
const cv = u.standIn ? null : this.cultivationFor(this.gd.chess(piece.id));
|
||||||
|
if (cv) { u.potential = cv.potential; u.cultivate = cv.cultivate; }
|
||||||
if (carry && carry.has(piece.uid)) u.carryState = carry.get(piece.uid);
|
if (carry && carry.has(piece.uid)) u.carryState = carry.get(piece.uid);
|
||||||
units.push(u);
|
units.push(u);
|
||||||
} else if (piece.kind === 'token') {
|
} else if (piece.kind === 'token') {
|
||||||
|
|||||||
@@ -96,6 +96,9 @@ export class PlayerViews {
|
|||||||
nextEnemies: this.m.nextEnemiesFor(this),
|
nextEnemies: this.m.nextEnemiesFor(this),
|
||||||
// DESIGN §16: the effective operator loadout ({ [baseChessId]: { skill, module } }; chess not listed use defaults)
|
// DESIGN §16: the effective operator loadout ({ [baseChessId]: { skill, module } }; chess not listed use defaults)
|
||||||
loadout: this.loadout,
|
loadout: this.loadout,
|
||||||
|
// 0.2.2: the effective per-operator 潜能 / 练度 ({ [charId]: { potential, cultivate } }; operators not listed: 潜能 6,
|
||||||
|
// 精英2 Lv.60) — the client's cards compose the numbers from it (shared/potential.js)
|
||||||
|
ops: this.ops,
|
||||||
// 0.2.0 补位: the base chess ids this player fields as their stand-ins in this match (the not-owned list the seat had
|
// 0.2.0 补位: the base chess ids this player fields as their stand-ins in this match (the not-owned list the seat had
|
||||||
// at the match start; [] = every operator owned) — the client shows these as their stand-ins (cards, pieces, the
|
// at the match start; [] = every operator owned) — the client shows these as their stand-ins (cards, pieces, the
|
||||||
// detail card, with a small 「替补」 mark)
|
// detail card, with a small 「替补」 mark)
|
||||||
|
|||||||
+4
-1
@@ -62,7 +62,8 @@ export const NET_DEFAULTS = Object.freeze({
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Intents that also draw from the per-connection heavy bucket: g.watch (its reply is a large state resend, m.field),
|
* Intents that also draw from the per-connection heavy bucket: g.watch (its reply is a large state resend, m.field),
|
||||||
* room.loadout (a ≤ 160-entry map validated against the game data; the client debounces its edits), room.ownership
|
* room.loadout (a ≤ 160-entry map and ≤ 256 operator settings validated against the game data; the client debounces its
|
||||||
|
* edits), room.ownership
|
||||||
* (a ≤ 160-id list, the same way), room.diy (≤ 8 自选 picks checked against the data, the same way) and room.spectate
|
* (a ≤ 160-id list, the same way), room.diy (≤ 8 自选 picks checked against the data, the same way) and room.spectate
|
||||||
* (taking a spectator seat in a running match resends its state like a watcher's g.watch — server/lobby.js spectate).
|
* (taking a spectator seat in a running match resends its state like a watcher's g.watch — server/lobby.js spectate).
|
||||||
*/
|
*/
|
||||||
@@ -109,6 +110,8 @@ export class Session {
|
|||||||
this.resyncAt = -Infinity;
|
this.resyncAt = -Infinity;
|
||||||
/** @type {Record<string, { skill: number, module: string|null }> | null} checked operator loadout (lobby-owned, DESIGN §16) */
|
/** @type {Record<string, { skill: number, module: string|null }> | null} checked operator loadout (lobby-owned, DESIGN §16) */
|
||||||
this.loadout = null;
|
this.loadout = null;
|
||||||
|
/** @type {Readonly<Record<string, { potential: number, cultivate: number }>> | null} checked per-operator 潜能 / 练度 (lobby-owned, 0.2.2; room.loadout `ops`) */
|
||||||
|
this.ops = null;
|
||||||
/** @type {readonly string[] | null} checked not-owned chess ids (干员持有, lobby-owned, 0.2.0 补位) */
|
/** @type {readonly string[] | null} checked not-owned chess ids (干员持有, lobby-owned, 0.2.0 补位) */
|
||||||
this.notOwned = null;
|
this.notOwned = null;
|
||||||
/** @type {Readonly<Record<string, { charId: string, skillIndex: number, uniEquipId: string|null }>> | null} checked 自选 picks (lobby-owned, 0.2.0 自选编队) */
|
/** @type {Readonly<Record<string, { charId: string, skillIndex: number, uniEquipId: string|null }>> | null} checked 自选 picks (lobby-owned, 0.2.0 自选编队) */
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { normDir, mirrorDir } from '../dir.js';
|
|||||||
import { SkillRuntime } from '../skills.js';
|
import { SkillRuntime } from '../skills.js';
|
||||||
import { resolveProfile } from '../professions.js';
|
import { resolveProfile } from '../professions.js';
|
||||||
import { normalizeToken } from '../simdata.js';
|
import { normalizeToken } from '../simdata.js';
|
||||||
|
import { isPotential, isCultivate } from '../../../shared/potential.js';
|
||||||
import { setupUnitKit } from '../content/index.js';
|
import { setupUnitKit } from '../content/index.js';
|
||||||
import { installTraitAttackSpeed } from '../content/traitMods.js';
|
import { installTraitAttackSpeed } from '../content/traitMods.js';
|
||||||
import { clone } from './util.js';
|
import { clone } from './util.js';
|
||||||
@@ -145,10 +146,12 @@ 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); `diy`
|
// 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)
|
// fills a 自选 slot with its pick (simdata getDiy: the slot's identity, the operator's body, skill and module);
|
||||||
|
// `potential` (潜能 1–6, 0.2.2) composes the def at it (never a stand-in's)
|
||||||
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;
|
if (inp.diy && typeof inp.diy === 'object') lo.diy = inp.diy;
|
||||||
|
if (inp.standIn !== true && isPotential(inp.potential)) lo.potential = inp.potential;
|
||||||
const def = this.data.getChess(inp.chessId, lo);
|
const def = this.data.getChess(inp.chessId, lo);
|
||||||
if (!def) { this.log(`unknown chess ${inp.chessId}${lo.diy ? ' (illegal 自选 pick)' : ''}`); 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)
|
// a DIY slot has no body of its own (甄选干员): it fights only as a 自选 piece (its `diy` pick)
|
||||||
@@ -156,6 +159,12 @@ export class BattlePlayers {
|
|||||||
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;
|
||||||
|
// 练度 (自持有, 0.2.2): the owned operator's ×ATK / ×DEF / ×max HP (units.js Πmul) — never a 补位 stand-in's nor a
|
||||||
|
// prototype 自选 pick's (another character than the one the player owns)
|
||||||
|
if (isCultivate(inp.cultivate) && !def.standInFor && !def.raw?.diyProto) {
|
||||||
|
const mul = typeof this.data.cultivateMul === 'function' ? this.data.cultivateMul(inp.cultivate) : null;
|
||||||
|
if (mul) { u.cultivate = inp.cultivate; u.cultMul = Object.freeze({ ...mul }); u.markDirty(); }
|
||||||
|
}
|
||||||
return u;
|
return u;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
|
|
||||||
import { genericKit, genericTalents } from './generic.js';
|
import { genericKit, genericTalents } from './generic.js';
|
||||||
import { withUnitLoadouts } from '../simdata.js';
|
import { withUnitLoadouts } from '../simdata.js';
|
||||||
|
import { isPotential } from '../../../shared/potential.js';
|
||||||
|
|
||||||
// Content files are loaded with guarded dynamic imports: a module that fails to load (syntax error, throwing
|
// Content files are loaded with guarded dynamic imports: a module that fails to load (syntax error, throwing
|
||||||
// top-level code, missing file) is logged and replaced by an empty module instead of breaking the server.
|
// top-level code, missing file) is logged and replaced by an empty module instead of breaking the server.
|
||||||
@@ -136,7 +137,8 @@ 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), as
|
* multi-player field). `standIn: true` rides along (补位: the stand-in def whatever the skill / module fields say), as
|
||||||
* does `diy` (自选: the pick of a DIY slot's piece). Null when the unit has no input entry.
|
* do `diy` (自选: the pick of a DIY slot's piece) and `potential` (0.2.2: 1–6, never a stand-in's). Null when the unit
|
||||||
|
* has no input entry.
|
||||||
*/
|
*/
|
||||||
function inputLoadout(unit) {
|
function inputLoadout(unit) {
|
||||||
const x = inputEntry(unit);
|
const x = inputEntry(unit);
|
||||||
@@ -144,6 +146,7 @@ function inputLoadout(unit) {
|
|||||||
const extra = {
|
const extra = {
|
||||||
...(x.standIn === true ? { standIn: true } : null),
|
...(x.standIn === true ? { standIn: true } : null),
|
||||||
...(x.diy && typeof x.diy === 'object' ? { diy: x.diy } : null),
|
...(x.diy && typeof x.diy === 'object' ? { diy: x.diy } : null),
|
||||||
|
...(x.standIn !== true && isPotential(x.potential) ? { potential: x.potential } : null),
|
||||||
};
|
};
|
||||||
if (x.skillIndex == null && x.moduleId == null) return extra;
|
if (x.skillIndex == null && x.moduleId == null) return extra;
|
||||||
return { skillIndex: x.skillIndex ?? null, moduleId: x.moduleId ?? null, ...extra };
|
return { skillIndex: x.skillIndex ?? null, moduleId: x.moduleId ?? null, ...extra };
|
||||||
|
|||||||
@@ -206,7 +206,7 @@ hook), and clear the owner's `mem.summonStock[tokenId]` when the skill takes the
|
|||||||
`units: [op, { uid, kind: 'token', tokenId, ownerUid, row, col }]` (`test/content/op_bgsnow.test.js`); the match side is
|
`units: [op, { uid, kind: 'token', tokenId, ownerUid, row, col }]` (`test/content/op_bgsnow.test.js`); the match side is
|
||||||
covered by `test/match/diy-shop.test.js` (a DIY 鸿雪's 打字机 from prep to battle). A 召唤师 whose talent holds a deck of
|
covered by `test/match/diy-shop.test.js` (a DIY 鸿雪's 打字机 from prep to battle). A 召唤师 whose talent holds a deck of
|
||||||
summons ("可以使用5个召唤物(最多同时部署3个)") takes `shared/summoner.js summonDeck` — the holding, a placed piece's return
|
summons ("可以使用5个召唤物(最多同时部署3个)") takes `shared/summoner.js summonDeck` — the holding, a placed piece's return
|
||||||
on its tile, recalls, its pieces' kits (`op-mgllan.js`, `op-ling.js`, `op-radian.js`). Every pick fights at full potential, as every chess (the owner's decision of 2026-10-07: data/backups.json forms, build-data `OPERATOR_POTENTIAL`). A new kit makes the operator a legal pick at once:
|
on its tile, recalls, its pieces' kits (`op-mgllan.js`, `op-ling.js`, `op-radian.js`). Every owned pick fights at its player's potential and 练度, as every chess (default 潜能 6 and 精英2 Lv.60 — the owner's decision of 2026-10-08; data/backups.json forms are built at full potential and carry the lower ranks, DATA.md §2.3); a prototype pick has neither. A new kit makes the operator a legal pick at once:
|
||||||
the server's `welcome.diyKitted` lists `KITTED_CHARS`, so the 自选编队 picker offers it and `room.diy` keeps it.
|
the server's `welcome.diyKitted` lists `KITTED_CHARS`, so the 自选编队 picker offers it and `room.diy` keeps it.
|
||||||
|
|
||||||
**The fidelity rule and the checklist** above apply item by item: every skill at rank 4 and 7, every talent, every
|
**The fidelity rule and the checklist** above apply item by item: every skill at rank 4 and 7, every talent, every
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ import { num, talentBb, moduleBb, traitBb, up, toggleBuff, summonTileFree } from
|
|||||||
import { absoluteRangeKeys, canTargetEnemy } from '../../../targeting.js';
|
import { absoluteRangeKeys, canTargetEnemy } from '../../../targeting.js';
|
||||||
import { bodyOnTile } from '../../../body.js';
|
import { bodyOnTile } from '../../../body.js';
|
||||||
import { COLS } from '../../../constants.js';
|
import { COLS } from '../../../constants.js';
|
||||||
|
import { atPotential } from '../../../../../shared/potential.js';
|
||||||
|
|
||||||
const S1 = 'skchr_doroth_1';
|
const S1 = 'skchr_doroth_1';
|
||||||
const S2 = 'skchr_doroth_2';
|
const S2 = 'skchr_doroth_2';
|
||||||
@@ -112,7 +113,7 @@ export default {
|
|||||||
/** The token's deploy limit for her loadout (variant stats, the module's when it carries one); her max stock too. */
|
/** The token's deploy limit for her loadout (variant stats, the module's when it carries one); her max stock too. */
|
||||||
function limitOf(battle, unit) {
|
function limitOf(battle, unit) {
|
||||||
const raw = battle.data.rawToken?.(RESONATOR);
|
const raw = battle.data.rawToken?.(RESONATOR);
|
||||||
const v = raw?.variants?.[unit.def?.tokenOwner];
|
const v = atPotential(raw?.variants?.[unit.def?.tokenOwner], unit.def?.loadout?.potential);
|
||||||
const mod = unit.def?.loadout?.moduleId;
|
const mod = unit.def?.loadout?.moduleId;
|
||||||
const n = num(v?.byModule?.[mod]?.stats?.deployLimit, num(v?.stats?.deployLimit, num(raw?.deployLimit, 0)));
|
const n = num(v?.byModule?.[mod]?.stats?.deployLimit, num(v?.stats?.deployLimit, num(raw?.deployLimit, 0)));
|
||||||
if (n > 0) return n;
|
if (n > 0) return n;
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ import { num, skillRec, batMod, up } from '../shared/tier1.js';
|
|||||||
import { COLS } from '../../../constants.js';
|
import { COLS } from '../../../constants.js';
|
||||||
import { dirVec } from '../../../dir.js';
|
import { dirVec } from '../../../dir.js';
|
||||||
import { hypot } from '../../../detmath.js';
|
import { hypot } from '../../../detmath.js';
|
||||||
|
import { atPotential } from '../../../../../shared/potential.js';
|
||||||
|
|
||||||
const S1 = 'skchr_mcnist_1';
|
const S1 = 'skchr_mcnist_1';
|
||||||
const S2 = 'skchr_mcnist_2';
|
const S2 = 'skchr_mcnist_2';
|
||||||
@@ -286,7 +287,7 @@ export default {
|
|||||||
},
|
},
|
||||||
talents: [
|
talents: [
|
||||||
{ install(battle, unit) { // 结构性原理: her pieces run the token's kit (set up before the battle starts)
|
{ install(battle, unit) { // 结构性原理: her pieces run the token's kit (set up before the battle starts)
|
||||||
const raw = battle.data.rawToken?.(MCGRAF)?.variants?.[unit.def?.tokenOwner] ?? null;
|
const raw = atPotential(battle.data.rawToken?.(MCGRAF)?.variants?.[unit.def?.tokenOwner] ?? null, unit.def?.loadout?.potential);
|
||||||
for (const t of battle.allyUnits) {
|
for (const t of battle.allyUnits) {
|
||||||
if (!isGrafOf(t, unit) || t.alive || t.deployed) continue;
|
if (!isGrafOf(t, unit) || t.alive || t.deployed) continue;
|
||||||
// its life: the base variant's (the SO-A stage-3 skill@duration −1 is 集成战略-only)
|
// its life: the base variant's (the SO-A stage-3 skill@duration −1 is 集成战略-only)
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
// its owner), else the engine's removal reason.
|
// its owner), else the engine's removal reason.
|
||||||
|
|
||||||
import { num, up } from './tier1.js';
|
import { num, up } from './tier1.js';
|
||||||
|
import { atPotential } from '../../../../../shared/potential.js';
|
||||||
|
|
||||||
/** Seconds between two tries of a piece waiting to come back (its owner off the field, timer, holding, DP, tile). */
|
/** Seconds between two tries of a piece waiting to come back (its owner off the field, timer, holding, DP, tile). */
|
||||||
export const DECK_RETRY = 0.25;
|
export const DECK_RETRY = 0.25;
|
||||||
@@ -73,8 +74,9 @@ export function summonTriggerArea(battle, owner, skillId, area) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A field of a summon's token record at its owner's form and module (data/backups.json `tokens[id].variants[<charId>@
|
* A field of a summon's token record at its owner's form, module and potential (data/backups.json `tokens[id].variants[<charId>@
|
||||||
* <status>]`, `byModule[uniEquipId]`): the stats the normalised def drops (`deployLimit`, `deckStack`).
|
* <status>]`, `byModule[uniEquipId]`; shared/potential.js atPotential): the stats the normalised def drops (`deployLimit`,
|
||||||
|
* `deckStack`).
|
||||||
* @param {object} battle
|
* @param {object} battle
|
||||||
* @param {object} owner the summoner unit
|
* @param {object} owner the summoner unit
|
||||||
* @param {string} tokenId
|
* @param {string} tokenId
|
||||||
@@ -82,7 +84,7 @@ export function summonTriggerArea(battle, owner, skillId, area) {
|
|||||||
*/
|
*/
|
||||||
export function tokenStat(battle, owner, tokenId, key) {
|
export function tokenStat(battle, owner, tokenId, key) {
|
||||||
const raw = battle.data?.rawToken?.(tokenId) ?? null;
|
const raw = battle.data?.rawToken?.(tokenId) ?? null;
|
||||||
const v = raw?.variants?.[owner?.def?.tokenOwner] ?? null;
|
const v = atPotential(raw?.variants?.[owner?.def?.tokenOwner] ?? null, owner?.def?.loadout?.potential);
|
||||||
const mod = owner?.def?.loadout?.diy?.uniEquipId ?? owner?.def?.loadout?.moduleId ?? null;
|
const mod = owner?.def?.loadout?.diy?.uniEquipId ?? owner?.def?.loadout?.moduleId ?? null;
|
||||||
const st = (mod && v?.byModule?.[mod]?.stats) || v?.stats || raw?.stats || null;
|
const st = (mod && v?.byModule?.[mod]?.stats) || v?.stats || raw?.stats || null;
|
||||||
return st && Number.isFinite(st[key]) ? st[key] : (Number.isFinite(raw?.[key]) ? raw[key] : null);
|
return st && Number.isFinite(st[key]) ? st[key] : (Number.isFinite(raw?.[key]) ? raw[key] : null);
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ import { bardRegen } from '../professions.js';
|
|||||||
import { normDir, localOrder } from '../dir.js';
|
import { normDir, localOrder } from '../dir.js';
|
||||||
import { SKILL_SUMMON_START_DEPLOY } from '../../../shared/constants.js';
|
import { SKILL_SUMMON_START_DEPLOY } from '../../../shared/constants.js';
|
||||||
import { hypot } from '../detmath.js';
|
import { hypot } from '../detmath.js';
|
||||||
|
import { atPotential } from '../../../shared/potential.js';
|
||||||
|
|
||||||
const num = (v, d = 0) => (typeof v === 'number' && Number.isFinite(v) ? v : (typeof v === 'string' && v.trim() !== '' && Number.isFinite(+v) ? +v : d));
|
const num = (v, d = 0) => (typeof v === 'number' && Number.isFinite(v) ? v : (typeof v === 'string' && v.trim() !== '' && Number.isFinite(+v) ? +v : d));
|
||||||
const GRID_3X3 = Object.freeze([[1, -1], [1, 0], [1, 1], [0, -1], [0, 0], [0, 1], [-1, -1], [-1, 0], [-1, 1]]);
|
const GRID_3X3 = Object.freeze([[1, -1], [1, 0], [1, 1], [0, -1], [0, 0], [0, 1], [-1, -1], [-1, 0], [-1, 1]]);
|
||||||
@@ -161,7 +162,8 @@ function ownVariant(raw, owner) {
|
|||||||
const oid = owner?.def?.tokenOwner ?? 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;
|
// at the owner's potential (0.2.2: a 自选 summon's deploy limit / count, the talents — shared/potential.js)
|
||||||
|
return v ? withLoadout(atPotential(v, owner.def?.loadout?.potential), owner.def?.loadout) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+49
-13
@@ -27,6 +27,10 @@
|
|||||||
// slot's base id, `def.charId` = the operator (its kit, content/index.js kitOf), `def.loadout.diy` = the checked pick and
|
// 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
|
// `def.tokenOwner` = the key of its summons' variants (data/backups.json `tokens`, read by getToken); null for an illegal
|
||||||
// pick.
|
// pick.
|
||||||
|
// Potential (潜能 1–6, 0.2.2 — the player's 干员调配 setting; shared/potential.js): a loadout's `potential` composes the
|
||||||
|
// chess / 自选 record at it (`def.loadout.potential`; full potential 6 is the default and the data's own) and the
|
||||||
|
// summons' variants at their owner's potential (getToken / getDiyToken: talents, a 自选 summon's deploy limit / count).
|
||||||
|
// A 补位 stand-in and a prototype 自选 pick have no potential.
|
||||||
//
|
//
|
||||||
// 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.
|
||||||
@@ -36,6 +40,7 @@
|
|||||||
// getSimData() the injected data (browser) or the generated data (Node), or null.
|
// getSimData() the injected data (browser) or the generated data (Node), or null.
|
||||||
|
|
||||||
import { resolveRecordLoadout, composeStats, composeTalents, loadoutRecord } from '../../shared/loadoutRecord.js';
|
import { resolveRecordLoadout, composeStats, composeTalents, loadoutRecord } from '../../shared/loadoutRecord.js';
|
||||||
|
import { atPotential, isPotential, POTENTIAL_DEFAULT, cultivateMul } from '../../shared/potential.js';
|
||||||
import { standInRecord } from '../../shared/standIn.js';
|
import { standInRecord } from '../../shared/standIn.js';
|
||||||
import { diyRecordOf, diyTokenOwner } from '../../shared/diy.js';
|
import { diyRecordOf, diyTokenOwner } from '../../shared/diy.js';
|
||||||
import { normHitArea } from './body.js';
|
import { normHitArea } from './body.js';
|
||||||
@@ -230,7 +235,7 @@ export { composeStats, composeTalents, loadoutRecord };
|
|||||||
|
|
||||||
/** Cache key suffix of a resolved non-default loadout ('' for the default). */
|
/** Cache key suffix of a resolved non-default loadout ('' for the default). */
|
||||||
export function loadoutKey(lo) {
|
export function loadoutKey(lo) {
|
||||||
return lo && !lo.isDefault ? `|s${lo.skillIndex}|m${lo.moduleId ?? ''}` : '';
|
return lo && !lo.isDefault ? `|s${lo.skillIndex}|m${lo.moduleId ?? ''}${lo.potentialIsDefault === false ? `|p${lo.potential}` : ''}` : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Build an immunity Set from an object `{stun:true,…}` or an array `['stun', 'feared']`. */
|
/** Build an immunity Set from an object `{stun:true,…}` or an array `['stun', 'feared']`. */
|
||||||
@@ -560,6 +565,7 @@ export class DataSource {
|
|||||||
stages: asMap(unwrap(raw.stages, 'stages'), 'id') ?? {},
|
stages: asMap(unwrap(raw.stages, 'stages'), 'id') ?? {},
|
||||||
waves: asMap(unwrap(raw.waves, 'waves'), 'id') ?? {},
|
waves: asMap(unwrap(raw.waves, 'waves'), 'id') ?? {},
|
||||||
backups: raw.backups && typeof raw.backups === 'object' && raw.backups.units ? raw.backups : null,
|
backups: raw.backups && typeof raw.backups === 'object' && raw.backups.units ? raw.backups : null,
|
||||||
|
effects: raw.effects && typeof raw.effects === 'object' ? raw.effects : null,
|
||||||
};
|
};
|
||||||
this.fallback = fallback;
|
this.fallback = fallback;
|
||||||
this._chess = new Map();
|
this._chess = new Map();
|
||||||
@@ -578,6 +584,20 @@ export class DataSource {
|
|||||||
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. */
|
||||||
rawBackups() { return this.raw.backups ?? (typeof this.fallback?.rawBackups === 'function' ? this.fallback.rawBackups() : null); }
|
rawBackups() { return this.raw.backups ?? (typeof this.fallback?.rawBackups === 'function' ? this.fallback.rawBackups() : null); }
|
||||||
|
/** A data/effects.json record of this source, its fallback, or the default game data (Node / the injected data). */
|
||||||
|
rawEffect(id) {
|
||||||
|
const own = this.raw.effects && Object.prototype.hasOwnProperty.call(this.raw.effects, id) ? this.raw.effects[id] : null;
|
||||||
|
if (own) return own;
|
||||||
|
if (typeof this.fallback?.rawEffect === 'function') return this.fallback.rawEffect(id);
|
||||||
|
const fx = generatedCache && generatedCache.effects;
|
||||||
|
return fx && Object.prototype.hasOwnProperty.call(fx, id) ? fx[id] : null;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* The 练度 (自持有) multipliers of tier `cultivate` (0–3): ATK / DEF / max HP from the effects.json CHAR_MAP record's
|
||||||
|
* char_attribute_mul (shared/potential.js cultivateMul), or null.
|
||||||
|
* @param {number} cultivate
|
||||||
|
*/
|
||||||
|
cultivateMul(cultivate) { return cultivateMul((id) => this.rawEffect(id), cultivate); }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Chess def for the default loadout, or for `loadout` `{ skillIndex, moduleId }` (DESIGN §16; resolved with
|
* Chess def for the default loadout, or for `loadout` `{ skillIndex, moduleId }` (DESIGN §16; resolved with
|
||||||
@@ -586,10 +606,11 @@ export class DataSource {
|
|||||||
* `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. `loadout.diy` (`{ charId, skillIndex, uniEquipId }`): the 自选 def of a DIY slot
|
* 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).
|
* (getDiy; null when the pick is not legal for that slot). `loadout.potential` (1–6): the record at that potential
|
||||||
|
* (loadoutRecord; not for a stand-in).
|
||||||
*/
|
*/
|
||||||
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.diy && typeof loadout.diy === 'object') return this.getDiy(id, loadout.diy, loadout.potential);
|
||||||
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;
|
||||||
@@ -636,19 +657,26 @@ export class DataSource {
|
|||||||
* module, `def.charId` the operator's, `def.diyFor` the slot's base id, `def.loadout` = the selection (`isDefault`) with
|
* 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
|
* `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)
|
* 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).
|
* or when the data lacks it. An owned pick is composed at `potential` (1–6, default 6; shared/diy.js diyRecordOf —
|
||||||
|
* `def.loadout.potential`, its summons' variants follow); a prototype has none. Cached per (id, pick, potential).
|
||||||
* @param {string} id
|
* @param {string} id
|
||||||
* @param {{ charId?: string, skillIndex?: number|null, uniEquipId?: string|null }} pick
|
* @param {{ charId?: string, skillIndex?: number|null, uniEquipId?: string|null }} pick
|
||||||
|
* @param {number|null} [potential]
|
||||||
*/
|
*/
|
||||||
getDiy(id, pick) {
|
getDiy(id, pick, potential = null) {
|
||||||
const key = `${id}|diy|${pick?.charId ?? ''}|${pick?.skillIndex ?? ''}|${pick?.uniEquipId ?? ''}`;
|
const pot = isPotential(potential) ? potential : POTENTIAL_DEFAULT;
|
||||||
|
const key = `${id}|diy|${pick?.charId ?? ''}|${pick?.skillIndex ?? ''}|${pick?.uniEquipId ?? ''}|p${pot}`;
|
||||||
if (this._chess.has(key)) return this._chess.get(key);
|
if (this._chess.has(key)) return this._chess.get(key);
|
||||||
const r = this.rawChess(id);
|
const r = this.rawChess(id);
|
||||||
const rec = r && r.isDiy ? diyRecordOf(r, pick, this) : null;
|
const rec = r && r.isDiy ? diyRecordOf(r, pick, this, { potential: pot }) : null;
|
||||||
let d = null;
|
let d = null;
|
||||||
if (rec) {
|
if (rec) {
|
||||||
d = normalizeChess({ ...rec, chessId: id });
|
d = normalizeChess({ ...rec, chessId: id });
|
||||||
d.loadout = { ...resolveLoadout(rec, null), diy: { charId: rec.charId, skillIndex: rec.skill.index, uniEquipId: rec.module?.id ?? null } };
|
const own = rec.diyProto ? POTENTIAL_DEFAULT : pot;
|
||||||
|
d.loadout = {
|
||||||
|
...resolveLoadout(rec, null), potential: own, potentialIsDefault: own === POTENTIAL_DEFAULT,
|
||||||
|
diy: { charId: rec.charId, skillIndex: rec.skill.index, uniEquipId: rec.module?.id ?? null },
|
||||||
|
};
|
||||||
d.tokenOwner = diyTokenOwner(rec.charId, rec.status);
|
d.tokenOwner = diyTokenOwner(rec.charId, rec.status);
|
||||||
freezeDef(d);
|
freezeDef(d);
|
||||||
}
|
}
|
||||||
@@ -671,7 +699,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);
|
if (ownerChessId && ownerLoadout && ownerLoadout.diy && typeof ownerLoadout.diy === 'object') return this.getDiyToken(id, ownerChessId, ownerLoadout.diy, ownerLoadout.potential);
|
||||||
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);
|
||||||
@@ -695,6 +723,8 @@ export class DataSource {
|
|||||||
}
|
}
|
||||||
if (r && r.variants && !owner) owner = Object.keys(r.variants)[0] ?? null;
|
if (r && r.variants && !owner) owner = Object.keys(r.variants)[0] ?? null;
|
||||||
let variant = r && r.variants && owner ? r.variants[owner] ?? null : null;
|
let variant = r && r.variants && owner ? r.variants[owner] ?? null : null;
|
||||||
|
// the owner's potential (0.2.2): the variant's talents (and counts) at it — before the skill / module parts merge
|
||||||
|
if (variant && olo && olo.potentialIsDefault === false) variant = atPotential(variant, olo.potential);
|
||||||
if (variant && olo && !olo.isDefault) {
|
if (variant && olo && !olo.isDefault) {
|
||||||
if (!olo.skillIsDefault && variant.bySkill && variant.bySkill[olo.skillIndex]) variant = { ...variant, ...variant.bySkill[olo.skillIndex] };
|
if (!olo.skillIsDefault && variant.bySkill && variant.bySkill[olo.skillIndex]) variant = { ...variant, ...variant.bySkill[olo.skillIndex] };
|
||||||
if (!olo.moduleIsDefault && variant.byModule && variant.byModule[olo.moduleId]) variant = { ...variant, ...variant.byModule[olo.moduleId] };
|
if (!olo.moduleIsDefault && variant.byModule && variant.byModule[olo.moduleId]) variant = { ...variant, ...variant.byModule[olo.moduleId] };
|
||||||
@@ -712,14 +742,17 @@ export class DataSource {
|
|||||||
* @param {string} id token id
|
* @param {string} id token id
|
||||||
* @param {string} slotId the owner's DIY slot record id (`_a` / `_b`)
|
* @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`
|
* @param {{ charId?: string, skillIndex?: number|null, uniEquipId?: string|null }} pick the owner def's `loadout.diy`
|
||||||
|
* @param {number|null} [potential] the owner's potential (its `loadout.potential`; default 6): the variant at it
|
||||||
*/
|
*/
|
||||||
getDiyToken(id, slotId, pick) {
|
getDiyToken(id, slotId, pick, potential = null) {
|
||||||
const slot = this.rawChess(slotId);
|
const slot = this.rawChess(slotId);
|
||||||
const owner = slot && typeof pick?.charId === 'string' ? diyTokenOwner(pick.charId, slot.status) : null;
|
const owner = slot && typeof pick?.charId === 'string' ? diyTokenOwner(pick.charId, slot.status) : null;
|
||||||
const ck = `${id}|diy|${owner ?? ''}|${pick?.skillIndex ?? ''}|${pick?.uniEquipId ?? ''}`;
|
const pot = isPotential(potential) ? potential : POTENTIAL_DEFAULT;
|
||||||
|
const ck = `${id}|diy|${owner ?? ''}|${pick?.skillIndex ?? ''}|${pick?.uniEquipId ?? ''}|p${pot}`;
|
||||||
if (this._token.has(ck)) return this._token.get(ck);
|
if (this._token.has(ck)) return this._token.get(ck);
|
||||||
const r = this.rawToken(id);
|
const r = this.rawToken(id);
|
||||||
let variant = r && r.variants && owner ? r.variants[owner] ?? null : null;
|
let variant = r && r.variants && owner ? r.variants[owner] ?? null : null;
|
||||||
|
if (variant) variant = atPotential(variant, pot);
|
||||||
if (variant) {
|
if (variant) {
|
||||||
const si = pick.skillIndex;
|
const si = pick.skillIndex;
|
||||||
if (Number.isInteger(si) && variant.bySkill && variant.bySkill[si]) variant = { ...variant, ...variant.bySkill[si] };
|
if (Number.isInteger(si) && variant.bySkill && variant.bySkill[si]) variant = { ...variant, ...variant.bySkill[si] };
|
||||||
@@ -821,7 +854,7 @@ export function toDataSource(data) {
|
|||||||
* 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, and one with `diy` (自选: `{ charId,
|
* 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 })`).
|
* skillIndex, uniEquipId }`) to its 自选 def (`getChess(id, { diy })`); a `potential` (1–6) rides along (0.2.2).
|
||||||
* @param {object} ds DataSource
|
* @param {object} ds DataSource
|
||||||
* @param {object[]} players PlayerBattleInput[] (spec players)
|
* @param {object[]} players PlayerBattleInput[] (spec players)
|
||||||
*/
|
*/
|
||||||
@@ -834,13 +867,16 @@ export function withUnitLoadouts(ds, 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;
|
||||||
const diy = u.diy && typeof u.diy === 'object' ? u.diy : null;
|
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 pot = u.standIn !== true && isPotential(u.potential) ? u.potential : null;
|
||||||
|
if (u.skillIndex != null || u.moduleId != null || u.standIn === true || diy || pot != null) 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;
|
if (diy) lo.diy = diy;
|
||||||
|
if (pot != null) lo.potential = pot;
|
||||||
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
|
else if (prev.skillIndex !== lo.skillIndex || prev.moduleId !== lo.moduleId || !!prev.standIn !== !!lo.standIn
|
||||||
|
|| (prev.potential ?? null) !== (lo.potential ?? null)
|
||||||
|| JSON.stringify(prev.diy ?? null) !== JSON.stringify(lo.diy ?? null)) conflicts.add(u.chessId);
|
|| JSON.stringify(prev.diy ?? null) !== JSON.stringify(lo.diy ?? null)) conflicts.add(u.chessId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,6 +63,10 @@ export function unitInfo(u) {
|
|||||||
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)
|
// 自选: 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,
|
diy: u.side === 'ally' && u.kind === 'op' && d.diyFor && d.loadout?.diy ? { ...d.loadout.diy } : undefined,
|
||||||
|
// 0.2.2: an ally operator's potential below 6 and its 练度 tier (a teammate's unit shows ITS owner's numbers; absent:
|
||||||
|
// full potential, no 练度 — a stand-in, a prototype 自选 pick)
|
||||||
|
potential: u.side === 'ally' && u.kind === 'op' && d.loadout?.potentialIsDefault === false ? d.loadout.potential : undefined,
|
||||||
|
cultivate: u.side === 'ally' && u.kind === 'op' && Number.isInteger(u.cultivate) ? u.cultivate : undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+12
-2
@@ -25,9 +25,15 @@
|
|||||||
// 自选 (DATA.md §18): an operator entry of a DIY slot (`chessId` its `_a` / `_b` id) carries `diy: { charId, skillIndex?,
|
// 自选 (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
|
// uniEquipId? }` (kept when well-formed; the data layer checks legality — shared/diy.js checkDiyPick) and is fielded as
|
||||||
// simdata getChess(id, { diy }).
|
// simdata getChess(id, { diy }).
|
||||||
|
// Potential and 练度 (0.2.2, shared/potential.js — the player's 干员调配 settings, the owner's decision of 2026-10-08): an
|
||||||
|
// operator entry may carry `potential` (潜能 1–6: the def at that potential, its summons' talents at it) and
|
||||||
|
// `cultivate` (练度 0–3: the effects.json CHAR_MAP aceffect_char_1…4 — a ×ATK / ×DEF / ×max HP of the unit, its own
|
||||||
|
// multiplier); each kept only when well-formed and never on a stand-in. Absent: full potential and no 练度 (a raw
|
||||||
|
// unit — the match always states both for an owned operator: PlayerState.battleInput).
|
||||||
|
|
||||||
import { Battle } from './Battle.js';
|
import { Battle } from './Battle.js';
|
||||||
import { toDataSource, withUnitLoadouts } from './simdata.js';
|
import { toDataSource, withUnitLoadouts } from './simdata.js';
|
||||||
|
import { isPotential, isCultivate } from '../../shared/potential.js';
|
||||||
import { BOSS_POOL_MIN_HP } from './constants.js';
|
import { BOSS_POOL_MIN_HP } from './constants.js';
|
||||||
|
|
||||||
export const SPEC_VERSION = 1;
|
export const SPEC_VERSION = 1;
|
||||||
@@ -101,14 +107,18 @@ function cleanDiyPick(d) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Keep a unit's `skillIndex` / `moduleId` only when well-formed (the data layer checks legality), `standIn` only when
|
* 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`.
|
* exactly `true` (补位), `diy` only as a well-formed pick (自选), `potential` (1–6) / `cultivate` (0–3) only when
|
||||||
|
* well-formed and not on a stand-in. 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 ('diy' in u) { const d = cleanDiyPick(u.diy); if (d) u.diy = d; else delete u.diy; }
|
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; }
|
if ('potential' in u && !isPotential(u.potential)) delete u.potential;
|
||||||
|
if ('cultivate' in u && !isCultivate(u.cultivate)) delete u.cultivate;
|
||||||
|
if (u.standIn === true) { delete u.potential; delete u.cultivate; }
|
||||||
|
if (u.kind === 'token') { delete u.skillIndex; delete u.moduleId; delete u.standIn; delete u.diy; delete u.potential; delete u.cultivate; }
|
||||||
return u;
|
return u;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+11
-4
@@ -1,7 +1,7 @@
|
|||||||
// server/sim/units.js — Unit model (operators, tokens, enemies, devices) and stat aggregation (DESIGN §5.2).
|
// server/sim/units.js — Unit model (operators, tokens, enemies, devices) and stat aggregation (DESIGN §5.2).
|
||||||
//
|
//
|
||||||
// Aggregation (recomputed lazily whenever buffs change — `unit.markDirty()`):
|
// Aggregation (recomputed lazily whenever buffs change — `unit.markDirty()`):
|
||||||
// ATK/DEF/maxHp = (base + Σflat) × (1 + Σpct) × Πmul
|
// ATK/DEF/maxHp = (base + Σflat) × (1 + Σpct) × Πmul (Πmul includes the unit's 练度 `cultMul`, 0.2.2)
|
||||||
// res = clamp((base + Σflat) × Πmul, 0, 100)
|
// res = clamp((base + Σflat) × Πmul, 0, 100)
|
||||||
// aspd = clamp(base + Σaspd, 20, 600) (base is 100 for almost everyone; floor 20 = PRTS 数值范围)
|
// aspd = clamp(base + Σaspd, 20, 600) (base is 100 for almost everyone; floor 20 = PRTS 数值范围)
|
||||||
// interval = bat × (1 + ΣbatPct) × 100 / aspd (ΣbatPct floored at −0.9)
|
// interval = bat × (1 + ΣbatPct) × 100 / aspd (ΣbatPct floored at −0.9)
|
||||||
@@ -16,6 +16,8 @@ import { DIR_VEC, normDir } from './dir.js';
|
|||||||
|
|
||||||
const clamp = (v, a, b) => (v < a ? a : v > b ? b : v);
|
const clamp = (v, a, b) => (v < a ? a : v > b ? b : v);
|
||||||
const EMPTY = Object.freeze({});
|
const EMPTY = Object.freeze({});
|
||||||
|
/** No 练度 multiplier (a unit without one: enemies, summons, stand-ins, a raw spec unit). */
|
||||||
|
const NO_MUL = Object.freeze({ atk: 1, def: 1, hp: 1 });
|
||||||
/** Aggregates can overflow (huge stacked *Mul mods → Infinity) — fall back to `d` so no stat is ever non-finite. */
|
/** Aggregates can overflow (huge stacked *Mul mods → Infinity) — fall back to `d` so no stat is ever non-finite. */
|
||||||
const fin = (v, d) => (Number.isFinite(v) ? v : d);
|
const fin = (v, d) => (Number.isFinite(v) ? v : d);
|
||||||
|
|
||||||
@@ -103,6 +105,10 @@ export class Unit {
|
|||||||
// here so every unit keeps the same object shape (the hot loops' property reads)
|
// here so every unit keeps the same object shape (the hot loops' property reads)
|
||||||
this.inTotal = false;
|
this.inTotal = false;
|
||||||
this.bossPool = null;
|
this.bossPool = null;
|
||||||
|
// 练度 (自持有, 0.2.2; battle/players.js): the owned operator's tier (0–3, effects.json aceffect_char_1…4) and its
|
||||||
|
// char_attribute_mul — ATK / DEF / max HP, a multiplier of its own (never summed with the 直接乘算 percentages)
|
||||||
|
this.cultivate = null;
|
||||||
|
this.cultMul = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
markDirty() { this._dirty = true; }
|
markDirty() { this._dirty = true; }
|
||||||
@@ -129,12 +135,13 @@ export class Unit {
|
|||||||
const a = (k) => add[k] ?? 0;
|
const a = (k) => add[k] ?? 0;
|
||||||
const m = (k) => mul[k] ?? 1;
|
const m = (k) => mul[k] ?? 1;
|
||||||
const b = this.base;
|
const b = this.base;
|
||||||
|
const cm = this.cultMul || NO_MUL;
|
||||||
const bHp = fin(b.maxHp, 1) > 0 ? fin(b.maxHp, 1) : 1;
|
const bHp = fin(b.maxHp, 1) > 0 ? fin(b.maxHp, 1) : 1;
|
||||||
const maxHp = Math.max(1, fin((bHp + a('hpFlat')) * Math.max(0, 1 + a('hpPct')) * m('hpMul'), bHp));
|
const maxHp = Math.max(1, fin((bHp + a('hpFlat')) * Math.max(0, 1 + a('hpPct')) * m('hpMul') * cm.hp, bHp));
|
||||||
// PRTS 游戏数据基础 属性基本公式 A_f = F_t[(A + D_p)(1 + D_t) + F_p]: `atkFinal` is the 最终加算 (FINAL_ADDITION) —
|
// PRTS 游戏数据基础 属性基本公式 A_f = F_t[(A + D_p)(1 + D_t) + F_p]: `atkFinal` is the 最终加算 (FINAL_ADDITION) —
|
||||||
// added after the percentages, inside the Πmul (阿戈尔's devoured base ATK, DESIGN §24.7)
|
// added after the percentages, inside the Πmul (阿戈尔's devoured base ATK, DESIGN §24.7)
|
||||||
const atk = Math.max(0, fin(((b.atk + a('atkFlat')) * Math.max(0, 1 + a('atkPct')) + a('atkFinal')) * m('atkMul'), fin(b.atk, 0)));
|
const atk = Math.max(0, fin(((b.atk + a('atkFlat')) * Math.max(0, 1 + a('atkPct')) + a('atkFinal')) * m('atkMul') * cm.atk, fin(b.atk, 0)));
|
||||||
const def = Math.max(0, fin((b.def + a('defFlat')) * Math.max(0, 1 + a('defPct')) * m('defMul'), fin(b.def, 0)));
|
const def = Math.max(0, fin((b.def + a('defFlat')) * Math.max(0, 1 + a('defPct')) * m('defMul') * cm.def, fin(b.def, 0)));
|
||||||
const res = clamp(fin((b.res + a('resFlat')) * m('resMul'), fin(b.res, 0)), 0, 100);
|
const res = clamp(fin((b.res + a('resFlat')) * m('resMul'), fin(b.res, 0)), 0, 100);
|
||||||
const aspd = clamp(fin(b.aspd + a('aspd'), 100), ASPD_MIN, ASPD_MAX);
|
const aspd = clamp(fin(b.aspd + a('aspd'), 100), ASPD_MIN, ASPD_MAX);
|
||||||
const bBat = fin(b.bat, 1) > 0 ? fin(b.bat, 1) : 1;
|
const bBat = fin(b.bat, 1) > 0 ? fin(b.bat, 1) : 1;
|
||||||
|
|||||||
+19
-10
@@ -19,11 +19,14 @@
|
|||||||
// still field any module, as the kits test them); a prototype carries the skill
|
// still field any module, as the kits test them); a prototype carries the skill
|
||||||
// and module of its 补位 rows at that tier (`diy.locked`: "技能携带规则与系统补位时一致" — [ASSUMED] that reading, the
|
// and module of its 补位 rows at that tier (`diy.locked`: "技能携带规则与系统补位时一致" — [ASSUMED] that reading, the
|
||||||
// owner's decision of 2026-10-05);
|
// owner's decision of 2026-10-05);
|
||||||
// - full potential for everyone, as every chess (the owner's decision of 2026-10-07: the backups.json forms).
|
// - an owned pick fights at its player's potential and 练度, as every chess (0.2.2, the owner's decision of 2026-10-08 —
|
||||||
|
// default 潜能 6 / 精英2 Lv.60; the backups.json forms carry the lower potentials, shared/potential.js); a prototype
|
||||||
|
// pick has neither (`diyProto` on its composed record).
|
||||||
// Which operators have a kit (a pick without one is not offered) is the sim's kit registry
|
// 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`.
|
// (server/sim/content/kits/index.js KITTED_CHARS), passed in as `kitted`.
|
||||||
|
|
||||||
import { composeUnitRecord, unitForm, statusKey } from './standIn.js';
|
import { composeUnitRecord, unitForm, statusKey } from './standIn.js';
|
||||||
|
import { atPotential } from './potential.js';
|
||||||
|
|
||||||
/** The tiers that have 自选 slots. */
|
/** The tiers that have 自选 slots. */
|
||||||
export const DIY_TIERS = Object.freeze([5, 6]);
|
export const DIY_TIERS = Object.freeze([5, 6]);
|
||||||
@@ -191,40 +194,46 @@ export function checkDiyPick(slotId, pick, data) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* A 自选 slot record (either form) filled with a checked pick: composeUnitRecord of the slot's identity (tier, price,
|
* 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
|
* merge, status; no 特质) with the operator's form at the slot's status — an owned pick's at `potential` (1–6, default
|
||||||
* form only), the derived bonds — plus `diyFor` (the slot's base id: the record is a 自选 piece) and `charId` (the
|
* 6: shared/potential.js atPotential) — the pick's skill and module (active on the elite form only), the derived bonds —
|
||||||
* operator). Null when the pick is not legal for the slot (checkDiyPick) or the data lacks a part.
|
* plus `diyFor` (the slot's base id: the record is a 自选 piece), `charId` (the operator) and, for a prototype pick,
|
||||||
|
* `diyProto: true` (no potential, no 练度: shared/potential.js cultivationOf). 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 {object} slot a DIY chess record (data/chess.json, `isDiy`)
|
||||||
* @param {DiyPick} pick
|
* @param {DiyPick} pick
|
||||||
* @param {DiyData} data
|
* @param {DiyData} data
|
||||||
|
* @param {{ potential?: number|null }} [opts]
|
||||||
* @returns {object|null}
|
* @returns {object|null}
|
||||||
*/
|
*/
|
||||||
export function diyRecordOf(slot, pick, data) {
|
export function diyRecordOf(slot, pick, data, { potential = null } = {}) {
|
||||||
if (!isObj(slot) || !slot.isDiy || typeof slot.chessId !== 'string') return null;
|
if (!isObj(slot) || !slot.isDiy || typeof slot.chessId !== 'string') return null;
|
||||||
const c = checkDiyPick(slot.chessId, pick, data);
|
const c = checkDiyPick(slot.chessId, pick, data);
|
||||||
if (!('ok' in c)) return null;
|
if (!('ok' in c)) return null;
|
||||||
const { charId, skillIndex, uniEquipId } = c.pick;
|
const { charId, skillIndex, uniEquipId } = c.pick;
|
||||||
const backups = backupsOf(data);
|
const backups = backupsOf(data);
|
||||||
const unit = backups?.units?.[charId] ?? null;
|
const unit = backups?.units?.[charId] ?? null;
|
||||||
const rec = composeUnitRecord(slot, unit, unitForm(backups, charId, slot.status),
|
const proto = isPrototypePick(data, slot.tier, charId);
|
||||||
|
const form = unitForm(backups, charId, slot.status);
|
||||||
|
const rec = composeUnitRecord(slot, unit, proto ? form : atPotential(form, potential),
|
||||||
{ skillIndex, moduleId: uniEquipId, bonds: diyOf(data)?.operators?.[charId]?.bonds ?? null });
|
{ skillIndex, moduleId: uniEquipId, bonds: diyOf(data)?.operators?.[charId]?.bonds ?? null });
|
||||||
if (!rec || !rec.skill) return null;
|
if (!rec || !rec.skill) return null;
|
||||||
rec.diyFor = slot.baseId ?? slot.chessId;
|
rec.diyFor = slot.baseId ?? slot.chessId;
|
||||||
|
if (proto) rec.diyProto = true;
|
||||||
return rec;
|
return rec;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The record of a 自选 slot filled with `pick` (diyRecordOf): `elite` = the slot's `_b` form (E2 Lv60, module stage 1 at
|
* 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).
|
* tier 5 / 3 at tier 6), else the normal form (E2 Lv1, no module); `potential` as diyRecordOf.
|
||||||
* @param {string} slotId the slot's base id (an elite id is accepted and taken as the elite)
|
* @param {string} slotId the slot's base id (an elite id is accepted and taken as the elite)
|
||||||
* @param {DiyPick} pick
|
* @param {DiyPick} pick
|
||||||
* @param {{ elite?: boolean, data: DiyData }} opts
|
* @param {{ elite?: boolean, data: DiyData, potential?: number|null }} opts
|
||||||
* @returns {object|null}
|
* @returns {object|null}
|
||||||
*/
|
*/
|
||||||
export function diyRecord(slotId, pick, { elite = false, data } = { data: null }) {
|
export function diyRecord(slotId, pick, { elite = false, data, potential = null } = { data: null }) {
|
||||||
const slot = diySlot(slotId, data);
|
const slot = diySlot(slotId, data);
|
||||||
if (!slot) return null;
|
if (!slot) return null;
|
||||||
return diyRecordOf(elite || slot.elite ? slot.golden : slot.normal, pick, data);
|
return diyRecordOf(elite || slot.elite ? slot.golden : slot.normal, pick, data, { potential });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+38
-19
@@ -4,16 +4,20 @@
|
|||||||
// user playtest #2 integration: an elite on 不装备 showed its default module's ATK and trait; attackRangeGrid = the range
|
// user playtest #2 integration: an elite on 不装备 showed its default module's ATK and trait; attackRangeGrid = the range
|
||||||
// it is deployed with, also the board overlay and the deploy wheel — extendedGrid is the battle's own 攻击距离 growth,
|
// it is deployed with, also the board overlay and the deploy wheel — extendedGrid is the battle's own 攻击距离 growth,
|
||||||
// re-exported by server/sim/targeting.js). One implementation, so the card and the battle never disagree. (Which
|
// re-exported by server/sim/targeting.js). One implementation, so the card and the battle never disagree. (Which
|
||||||
// choices a player may make: shared/protocol.js loadoutOptions.)
|
// choices a player may make: shared/protocol.js loadoutOptions.) A loadout may carry the operator's potential (潜能 1–6,
|
||||||
|
// the player's 干员调配 setting since 0.2.2 — shared/potential.js): the record is composed at it first.
|
||||||
|
|
||||||
import { GEO } from './constants.js';
|
import { GEO } from './constants.js';
|
||||||
|
import { atPotential, isPotential, POTENTIAL_DEFAULT } from './potential.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve a loadout against a chess record.
|
* Resolve a loadout against a chess record. `potential` (潜能 1–6; anything else = 6, full potential — the default)
|
||||||
|
* rides along: loadoutRecord composes the record at it (shared/potential.js atPotential).
|
||||||
* @param {object|null} rec data/chess.json record
|
* @param {object|null} rec data/chess.json record
|
||||||
* @param {{ skillIndex?: number, moduleId?: string, skill?: number, module?: string }|null} [loadout]
|
* @param {{ skillIndex?: number, moduleId?: string, skill?: number, module?: string, potential?: number }|null} [loadout]
|
||||||
* @returns {{ skillIndex: number|null, moduleId: string|null, skillIsDefault: boolean, moduleIsDefault: boolean,
|
* @returns {{ skillIndex: number|null, moduleId: string|null, potential: number, skillIsDefault: boolean,
|
||||||
* isDefault: boolean }|null} null without a record; `moduleId` null for chess without module choices
|
* moduleIsDefault: boolean, potentialIsDefault: boolean, isDefault: boolean }|null} null without a record;
|
||||||
|
* `moduleId` null for chess without module choices
|
||||||
*/
|
*/
|
||||||
export function resolveRecordLoadout(rec, loadout = null) {
|
export function resolveRecordLoadout(rec, loadout = null) {
|
||||||
if (!rec || typeof rec !== 'object') return null;
|
if (!rec || typeof rec !== 'object') return null;
|
||||||
@@ -26,9 +30,14 @@ export function resolveRecordLoadout(rec, loadout = null) {
|
|||||||
const defMod = mods ? (mods.find((m) => m && m.isDefault)?.uniEquipId ?? 'none') : null;
|
const defMod = mods ? (mods.find((m) => m && m.isDefault)?.uniEquipId ?? 'none') : null;
|
||||||
const wantMod = lo.moduleId ?? lo.module;
|
const wantMod = lo.moduleId ?? lo.module;
|
||||||
const moduleId = mods && (wantMod === 'none' || (typeof wantMod === 'string' && mods.some((m) => m && m.uniEquipId === wantMod))) ? wantMod : defMod;
|
const moduleId = mods && (wantMod === 'none' || (typeof wantMod === 'string' && mods.some((m) => m && m.uniEquipId === wantMod))) ? wantMod : defMod;
|
||||||
|
const potential = isPotential(lo.potential) ? lo.potential : POTENTIAL_DEFAULT;
|
||||||
const skillIsDefault = skillIndex === defSkill;
|
const skillIsDefault = skillIndex === defSkill;
|
||||||
const moduleIsDefault = moduleId === defMod;
|
const moduleIsDefault = moduleId === defMod;
|
||||||
return { skillIndex, moduleId, skillIsDefault, moduleIsDefault, isDefault: skillIsDefault && moduleIsDefault };
|
const potentialIsDefault = potential === POTENTIAL_DEFAULT;
|
||||||
|
return {
|
||||||
|
skillIndex, moduleId, potential, skillIsDefault, moduleIsDefault, potentialIsDefault,
|
||||||
|
isDefault: skillIsDefault && moduleIsDefault && potentialIsDefault,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const clean6 = (v) => (typeof v !== 'number' || !Number.isFinite(v) || Number.isInteger(v) || Math.abs(v) >= 1e6 ? v : Math.round(v * 1e6) / 1e6);
|
const clean6 = (v) => (typeof v !== 'number' || !Number.isFinite(v) || Number.isInteger(v) || Math.abs(v) >= 1e6 ? v : Math.round(v * 1e6) / 1e6);
|
||||||
@@ -40,15 +49,23 @@ export function composeStats(statsBase, attr) {
|
|||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A talent entry without its potential chain (shared/potential.js: `potMin` / `potBelow`).
|
||||||
|
* @param {any} t
|
||||||
|
* @returns {any}
|
||||||
|
*/
|
||||||
|
const unchained = ({ potMin: _m, potBelow: _b, ...t }) => t;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Talents with a module: apply ModuleRecord.talentChanges to the no-module talents — the merge rule of
|
* Talents with a module: apply ModuleRecord.talentChanges to the no-module talents — the merge rule of
|
||||||
* tools/build-data.mjs mergeTalentChanges (override of an existing index: module values win, base keys the module does
|
* tools/build-data.mjs mergeTalentChanges (override of an existing index: module values win, base keys the module does
|
||||||
* not restate are kept; otherwise appended; empty placeholders dropped).
|
* not restate are kept; otherwise appended; empty placeholders dropped). The result carries no potential chains: a merged
|
||||||
|
* list is composed at one potential (resolve the inputs first — shared/potential.js atPotential).
|
||||||
*/
|
*/
|
||||||
export function composeTalents(base, changes) {
|
export function composeTalents(base, changes) {
|
||||||
const talents = (base || []).map((t) => ({ ...t }));
|
const talents = (base || []).map(unchained);
|
||||||
for (const ch of changes || []) {
|
for (const ch0 of changes || []) {
|
||||||
const { talentIndex, ...rest } = ch;
|
const { talentIndex, ...rest } = unchained(ch0);
|
||||||
const rec = { index: talentIndex, ...rest, fromModule: true };
|
const rec = { index: talentIndex, ...rest, fromModule: true };
|
||||||
const at = talentIndex >= 0 ? talents.findIndex((x) => x.index === talentIndex) : -1;
|
const at = talentIndex >= 0 ? talents.findIndex((x) => x.index === talentIndex) : -1;
|
||||||
if (at >= 0) {
|
if (at >= 0) {
|
||||||
@@ -68,16 +85,18 @@ export function composeTalents(base, changes) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The chess record as the selected loadout makes it (a new object; the input is never mutated): `skill` = the selected
|
* The chess record as the selected loadout makes it (a new object; the input is never mutated): at a potential below 6
|
||||||
* SkillRecord; golden chess with a non-default module choice: `stats` = statsBase + module attr, `trait` = the module's
|
* the record at that potential first (stats / statsBase / talents / talentsBase / module talent changes — shared/potential.js
|
||||||
* traitOverride or traitBase, `talents` = talentsBase + talentChanges, `module` = the chosen module (`active:false`,
|
* atPotential); `skill` = the selected SkillRecord; golden chess with a non-default module choice: `stats` = statsBase +
|
||||||
* id null for 'none'). A talent that summons through a container token (凛御银灰) follows the selected skill's token.
|
* module attr, `trait` = the module's traitOverride or traitBase, `talents` = talentsBase + talentChanges, `module` =
|
||||||
* The default loadout returns `rec` itself.
|
* the chosen module (`active:false`, id null for 'none'). A talent that summons through a container token (凛御银灰)
|
||||||
* @param {object} rec data/chess.json record
|
* follows the selected skill's token. The default loadout returns `rec` itself.
|
||||||
* @param {object} lo resolveRecordLoadout(rec, …)
|
* @param {object} rec0 data/chess.json record
|
||||||
|
* @param {object} lo resolveRecordLoadout(rec0, …)
|
||||||
*/
|
*/
|
||||||
export function loadoutRecord(rec, lo) {
|
export function loadoutRecord(rec0, lo) {
|
||||||
if (!rec || !lo || lo.isDefault) return rec;
|
if (!rec0 || !lo || lo.isDefault) return rec0;
|
||||||
|
const rec = lo.potentialIsDefault === false ? atPotential(rec0, lo.potential) : rec0;
|
||||||
const out = { ...rec };
|
const out = { ...rec };
|
||||||
if (!lo.moduleIsDefault && Array.isArray(rec.modules)) {
|
if (!lo.moduleIsDefault && Array.isArray(rec.modules)) {
|
||||||
const m = lo.moduleId === 'none' ? null : rec.modules.find((x) => x.uniEquipId === lo.moduleId) ?? null;
|
const m = lo.moduleId === 'none' ? null : rec.modules.find((x) => x.uniEquipId === lo.moduleId) ?? null;
|
||||||
|
|||||||
@@ -0,0 +1,207 @@
|
|||||||
|
// shared/potential.js — operator potential (潜能 1–6) as a runtime input, and the 自持有 练度 bonus (0.2.2; the owner's
|
||||||
|
// decision of 2026-10-08: 「调配干员里自己设置吧,默认满潜满加成」). Pure ESM shared by the build (tools/build-data.mjs
|
||||||
|
// proves the composition below rebuilds every record at every potential), the simulation (server/sim/simdata.js), the
|
||||||
|
// server (PlayerState) and the client (干员调配 / detail cards).
|
||||||
|
//
|
||||||
|
// Official (调度手册 autochess_shop_3 「卫戍协议中干员潜能由自身已持有干员潜能决定」; PRTS 卫戍协议 「干员的潜能等级始终以自身
|
||||||
|
// 持有状态为准」): an operator fights at the player's own potential; the season's chess rows carry none. The data records
|
||||||
|
// (data/chess.json, data/backups.json forms, the token variants of data/tokens.json and backups.json `tokens`) are
|
||||||
|
// built at FULL potential (潜能 6 = rank 5, the default) and carry what any lower potential changes (docs/DATA.md §2.3):
|
||||||
|
// * `potDown` on a record / form / token variant: `{ "<rank>": { "<dotted.path>": value } }` — the exact value at that
|
||||||
|
// rank of every leaf (stats.cost, statsBase.atk, count, stats.deployLimit, byModule.<id>.stats.deckStack …) that
|
||||||
|
// differs from the rank above; applied from rank 4 down to the wanted rank (each overwrites), so a leaf ends at its
|
||||||
|
// value for that rank (the potentialRanks attribute steps: 「部署费用-1」, 「攻击力+22」, 「再部署时间-4秒」 …);
|
||||||
|
// * on a talent entry (talents / talentsBase / modules[].talentChanges / a variant's talents and byModule talents):
|
||||||
|
// `potMin` = the lowest rank the entry holds for and `potBelow` = the fields the entry below it changes (desc /
|
||||||
|
// descRaw / bb …; itself chained): the talent candidates by `requiredPotentialRank` (「天赋效果增强」: 刺玫's
|
||||||
|
// 土壤基肥改良 11% → 8% below rank 4). A summon's talents follow its OWNER's potential (their candidates mirror the
|
||||||
|
// owner's steps; DESIGN §26.14).
|
||||||
|
// Data rank r = potential − 1 (character_table `potentialRanks[r]` is the step from rank r to r + 1).
|
||||||
|
//
|
||||||
|
// 练度 (cultivate 0–3, activity_table autoChessData.cultivateEffectList): the effects.json CHAR_MAP records
|
||||||
|
// aceffect_char_1 … 4 — 未精英化 / 精英阶段1 / 精英阶段2 / 精英阶段2-60级 — whose `char_attribute_mul` buff multiplies ATK /
|
||||||
|
// DEF / max HP (×1 / ×1.05 ATK, DEF / ×1.1 ATK, ×1.05 DEF, HP / ×1.1 all three): a multiplier of its own (the sim's Πmul),
|
||||||
|
// never summed with the 直接乘算 bonuses (PRTS 盟约记录 「…均为直接乘算,与自持有干员的属性加成独立」). Only an owned operator
|
||||||
|
// of the same name has it — initial, elite and 自选 forms alike; a 补位 stand-in and a prototype 自选 pick have neither
|
||||||
|
// potential nor 练度 (`cultivationOf`).
|
||||||
|
|
||||||
|
/** Potential bounds (潜能 1–6) and the default (full potential, the data's own). */
|
||||||
|
export const POTENTIAL_MIN = 1;
|
||||||
|
export const POTENTIAL_MAX = 6;
|
||||||
|
export const POTENTIAL_DEFAULT = 6;
|
||||||
|
/** The data rank every record is built at (potential 6). */
|
||||||
|
export const FULL_RANK = POTENTIAL_MAX - 1;
|
||||||
|
|
||||||
|
/** The 练度 tiers: effects.json CHAR_MAP effect ids in cultivateEffectList order (cultivateNum 1 / 1001 / 2001 / 2060). */
|
||||||
|
export const CULTIVATE_EFFECTS = Object.freeze(['aceffect_char_1', 'aceffect_char_2', 'aceffect_char_3', 'aceffect_char_4']);
|
||||||
|
export const CULTIVATE_MAX = CULTIVATE_EFFECTS.length - 1;
|
||||||
|
/** The default 练度: 精英阶段2-60级 (the owner's 「默认满潜满加成」). */
|
||||||
|
export const CULTIVATE_DEFAULT = CULTIVATE_MAX;
|
||||||
|
|
||||||
|
/** Is `v` a potential (integer 1–6)? */
|
||||||
|
export const isPotential = (v) => Number.isInteger(v) && v >= POTENTIAL_MIN && v <= POTENTIAL_MAX;
|
||||||
|
/** Is `v` a 练度 tier (integer 0–3)? */
|
||||||
|
export const isCultivate = (v) => Number.isInteger(v) && v >= 0 && v <= CULTIVATE_MAX;
|
||||||
|
/** The data rank of a potential (anything else: full potential). */
|
||||||
|
export const rankOf = (potential) => (isPotential(potential) ? potential - 1 : FULL_RANK);
|
||||||
|
|
||||||
|
const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
||||||
|
const own = (o, k) => isObj(o) && Object.prototype.hasOwnProperty.call(o, k);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A talent entry at data rank `rank`: its `potBelow` chain followed while `rank < potMin`, returned without the chain
|
||||||
|
* fields (a copy only when the entry carries them).
|
||||||
|
* @param {any} t @param {number} rank
|
||||||
|
*/
|
||||||
|
export function talentAtRank(t, rank) {
|
||||||
|
if (!isObj(t) || !(own(t, 'potMin') || own(t, 'potBelow'))) return t;
|
||||||
|
const { potMin: _m, potBelow: _b, ...out } = t;
|
||||||
|
// each `potBelow` holds the fields its rank changes (a partial entry); merged down the chain
|
||||||
|
let x = t;
|
||||||
|
while (isObj(x.potBelow) && rank < (Number.isInteger(x.potMin) ? x.potMin : 0)) {
|
||||||
|
x = x.potBelow;
|
||||||
|
for (const [k, v] of Object.entries(x)) if (k !== 'potMin' && k !== 'potBelow') out[k] = v;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A talent list at data rank `rank` (talentAtRank of every entry). */
|
||||||
|
export function talentsAtRank(list, rank) {
|
||||||
|
return Array.isArray(list) ? list.map((t) => talentAtRank(t, rank)) : list;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `obj` with the `potDown` leaves of ranks FULL_RANK − 1 … `rank` applied (copy-on-write; `potDown` dropped). */
|
||||||
|
function applyPotDown(obj, rank) {
|
||||||
|
const out = { ...obj };
|
||||||
|
delete out.potDown;
|
||||||
|
const down = obj.potDown;
|
||||||
|
if (!isObj(down)) return out;
|
||||||
|
const copied = new Set([out]);
|
||||||
|
for (let r = FULL_RANK - 1; r >= rank; r--) {
|
||||||
|
const step = down[String(r)];
|
||||||
|
if (!isObj(step)) continue;
|
||||||
|
for (const [path, value] of Object.entries(step)) {
|
||||||
|
const keys = path.split('.');
|
||||||
|
let cur = out;
|
||||||
|
for (let i = 0; i < keys.length - 1; i++) {
|
||||||
|
const k = Array.isArray(cur) ? Number(keys[i]) : keys[i];
|
||||||
|
const next = cur[k];
|
||||||
|
if (!next || typeof next !== 'object') { cur = null; break; }
|
||||||
|
if (!copied.has(next)) { cur[k] = Array.isArray(next) ? next.slice() : { ...next }; copied.add(cur[k]); }
|
||||||
|
cur = cur[k];
|
||||||
|
}
|
||||||
|
if (cur) cur[Array.isArray(cur) ? Number(keys[keys.length - 1]) : keys[keys.length - 1]] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A record / backups form / token variant at data rank `rank`: its `potDown` applied and every talent list resolved —
|
||||||
|
* `talents`, `talentsBase`, `modules[].talentChanges`, `byModule[*].talents` (a variant's `bySkill` holds no talents).
|
||||||
|
* `rank` ≥ FULL_RANK (or no potential data) returns `obj` itself.
|
||||||
|
* @template T
|
||||||
|
* @param {T} obj
|
||||||
|
* @param {number} rank
|
||||||
|
* @returns {T}
|
||||||
|
*/
|
||||||
|
export function atRank(obj, rank) {
|
||||||
|
if (!isObj(obj) || !(Number.isInteger(rank) && rank >= 0 && rank < FULL_RANK)) return obj;
|
||||||
|
const out = applyPotDown(obj, rank);
|
||||||
|
for (const k of ['talents', 'talentsBase']) if (Array.isArray(out[k])) out[k] = talentsAtRank(out[k], rank);
|
||||||
|
if (Array.isArray(out.modules)) {
|
||||||
|
out.modules = out.modules.map((m) => (isObj(m) && Array.isArray(m.talentChanges) ? { ...m, talentChanges: talentsAtRank(m.talentChanges, rank) } : m));
|
||||||
|
}
|
||||||
|
if (isObj(out.byModule)) {
|
||||||
|
const bm = {};
|
||||||
|
for (const [id, m] of Object.entries(out.byModule)) bm[id] = isObj(m) && Array.isArray(m.talents) ? { ...m, talents: talentsAtRank(m.talents, rank) } : m;
|
||||||
|
out.byModule = bm;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A data/chess.json record (or a backups form, or a token variant) at `potential` (1–6; anything else = full
|
||||||
|
* potential, the record itself) — atRank of its data rank.
|
||||||
|
* @template T
|
||||||
|
* @param {T} rec
|
||||||
|
* @param {number|null|undefined} potential
|
||||||
|
* @returns {T}
|
||||||
|
*/
|
||||||
|
export function atPotential(rec, potential) {
|
||||||
|
return isPotential(potential) && potential < POTENTIAL_MAX ? atRank(rec, potential - 1) : rec;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a record / form / variant changes below full potential at all (`potDown` or a chained talent list). */
|
||||||
|
export function hasPotentialData(obj) {
|
||||||
|
if (!isObj(obj)) return false;
|
||||||
|
if (isObj(obj.potDown) && Object.keys(obj.potDown).length) return true;
|
||||||
|
const chained = (list) => Array.isArray(list) && list.some((t) => isObj(t) && isObj(t.potBelow));
|
||||||
|
if (chained(obj.talents) || chained(obj.talentsBase)) return true;
|
||||||
|
if (Array.isArray(obj.modules) && obj.modules.some((m) => chained(m?.talentChanges))) return true;
|
||||||
|
return isObj(obj.byModule) && Object.values(obj.byModule).some((m) => chained(m?.talents));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A deep copy of `v` without the potential annotations (`potDown`, `potMin`, `potBelow`): what a record built at one
|
||||||
|
* rank looks like (the build's self-check; tests).
|
||||||
|
*/
|
||||||
|
export function stripPotential(v) {
|
||||||
|
if (Array.isArray(v)) return v.map(stripPotential);
|
||||||
|
if (!isObj(v)) return v;
|
||||||
|
const out = {};
|
||||||
|
for (const [k, x] of Object.entries(v)) if (k !== 'potDown' && k !== 'potMin' && k !== 'potBelow') out[k] = stripPotential(x);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 练度 (自持有) ---------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ATK / DEF / max HP multipliers of 练度 tier `cultivate` (0–3): the `char_attribute_mul` buff of its effects.json
|
||||||
|
* CHAR_MAP record (a key the record does not name is ×1: aceffect_char_1 names only atk 1). Null for no tier / no data.
|
||||||
|
* @param {any} effects data/effects.json (or a lookup `(id) => record`)
|
||||||
|
* @param {number|null|undefined} cultivate
|
||||||
|
* @returns {{ atk: number, def: number, hp: number } | null}
|
||||||
|
*/
|
||||||
|
export function cultivateMul(effects, cultivate) {
|
||||||
|
if (!isCultivate(cultivate)) return null;
|
||||||
|
const id = CULTIVATE_EFFECTS[cultivate];
|
||||||
|
const rec = typeof effects === 'function' ? effects(id) : own(effects, id) ? effects[id] : null;
|
||||||
|
const buff = isObj(rec) && Array.isArray(rec.buffs) ? rec.buffs.find((b) => isObj(b) && b.key === 'char_attribute_mul') : null;
|
||||||
|
if (!buff) return null;
|
||||||
|
const bb = isObj(buff.bb) ? buff.bb : {};
|
||||||
|
const m = (k) => (typeof bb[k] === 'number' && Number.isFinite(bb[k]) && bb[k] > 0 ? bb[k] : 1);
|
||||||
|
return { atk: m('atk'), def: m('def'), hp: m('max_hp') };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stats with a 练度 multiplier applied to ATK / DEF / max HP (what the unit's own numbers read: the detail card, the
|
||||||
|
* 干员调配 局内数值). The sim keeps the multiplier on the unit (units.js Πmul): these are the same products.
|
||||||
|
* @param {any} stats @param {{ atk: number, def: number, hp: number } | null} mul
|
||||||
|
*/
|
||||||
|
export function cultivatedStats(stats, mul) {
|
||||||
|
if (!isObj(stats) || !mul) return stats;
|
||||||
|
const out = { ...stats };
|
||||||
|
if (typeof out.atk === 'number') out.atk *= mul.atk;
|
||||||
|
if (typeof out.def === 'number') out.def *= mul.def;
|
||||||
|
if (typeof out.maxHp === 'number') out.maxHp *= mul.hp;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The potential / 练度 a player's piece of record `rec` fights at, from the player's per-operator settings `ops`
|
||||||
|
* (`{ [charId]: { potential?, cultivate? } }`; a missing entry or field is the default — 潜能 6, 精英阶段2-60级), or null
|
||||||
|
* when it has neither: a 补位 stand-in (`standInFor`) or a prototype 自选 pick (`diyProto`) — another character than
|
||||||
|
* the operator the player owns.
|
||||||
|
* @param {any} rec a chess record or a composed 自选 / stand-in record
|
||||||
|
* @param {Record<string, { potential?: number, cultivate?: number }> | null | undefined} ops
|
||||||
|
* @returns {{ potential: number, cultivate: number } | null}
|
||||||
|
*/
|
||||||
|
export function cultivationOf(rec, ops) {
|
||||||
|
if (!isObj(rec) || rec.standInFor || rec.diyProto || typeof rec.charId !== 'string' || !rec.charId) return null;
|
||||||
|
const e = own(ops, rec.charId) && isObj(ops[rec.charId]) ? ops[rec.charId] : null;
|
||||||
|
return {
|
||||||
|
potential: e && isPotential(e.potential) ? e.potential : POTENTIAL_DEFAULT,
|
||||||
|
cultivate: e && isCultivate(e.cultivate) ? e.cultivate : CULTIVATE_DEFAULT,
|
||||||
|
};
|
||||||
|
}
|
||||||
+62
-9
@@ -4,6 +4,7 @@
|
|||||||
import { DIFFICULTIES, NAME_MAX_LEN, ROOM_CODE_LEN, MAX_SEATS, EMOTES, GEO } from './constants.js';
|
import { DIFFICULTIES, NAME_MAX_LEN, ROOM_CODE_LEN, MAX_SEATS, EMOTES, GEO } from './constants.js';
|
||||||
import { isDroppableChess } from './standIn.js';
|
import { isDroppableChess } from './standIn.js';
|
||||||
import { diySlotIds, validateDiyPicks } from './diy.js';
|
import { diySlotIds, validateDiyPicks } from './diy.js';
|
||||||
|
import { cultivatedStats, isPotential, isCultivate, POTENTIAL_DEFAULT, CULTIVATE_DEFAULT } from './potential.js';
|
||||||
|
|
||||||
// ---- tiny validators -------------------------------------------------------
|
// ---- tiny validators -------------------------------------------------------
|
||||||
const isInt = (v, lo = -Infinity, hi = Infinity) => Number.isInteger(v) && v >= lo && v <= hi;
|
const isInt = (v, lo = -Infinity, hi = Infinity) => Number.isInteger(v) && v >= lo && v <= hi;
|
||||||
@@ -70,12 +71,15 @@ export function isBattleResult(v) {
|
|||||||
// ---- operator loadout (DESIGN §16): room.loadout { entries } -------------------------------------------------
|
// ---- operator loadout (DESIGN §16): room.loadout { entries } -------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `room.loadout { entries }`: `entries` = `{ [baseChessId]: { skill?: skillIndex, module?: uniEquipId | 'none' } }`
|
* `room.loadout { entries, ops? }`: `entries` = `{ [baseChessId]: { skill?: skillIndex, module?: uniEquipId | 'none' } }`
|
||||||
* (the per-browser loadout of the 干员调配 screen). Structural limits below; the semantic check against the game data
|
* (the per-browser loadout of the 干员调配 screen); `ops` (0.2.2) = `{ [charId]: { potential?: 1–6, cultivate?: 0–3 } }`,
|
||||||
* (known visible chess, legal skill index for the normal AND the elite status, legal module of the elite) is
|
* the player's per-operator 潜能 and 练度 (shared/potential.js; absent / missing = 潜能 6, 精英2 Lv.60 — the owner's
|
||||||
* `checkLoadout` — used by the server (lobby, match) and by the client to sanitise a stored loadout before sending.
|
* decision of 2026-10-08). Structural limits below; the semantic checks against the game data — known visible chess,
|
||||||
|
* legal skill index for the normal AND the elite status, legal module of the elite (`checkLoadout`); an operator of the
|
||||||
|
* 干员调配 roster or the 自选 owned pool (`checkLoadoutOps`) — are used by the server (lobby, match) and by the client to
|
||||||
|
* sanitise a stored loadout before sending.
|
||||||
*/
|
*/
|
||||||
export const LOADOUT_LIMITS = Object.freeze({ entries: 160, skillIndex: 9 });
|
export const LOADOUT_LIMITS = Object.freeze({ entries: 160, skillIndex: 9, ops: 256 });
|
||||||
/** The "no module" choice of an elite (模组: 不装备). */
|
/** The "no module" choice of an elite (模组: 不装备). */
|
||||||
export const MODULE_NONE = 'none';
|
export const MODULE_NONE = 'none';
|
||||||
const isLoadoutEntry = (e) => isPlain(e) && Object.keys(e).length > 0 && Object.keys(e).every((k) => k === 'skill' || k === 'module')
|
const isLoadoutEntry = (e) => isPlain(e) && Object.keys(e).length > 0 && Object.keys(e).every((k) => k === 'skill' || k === 'module')
|
||||||
@@ -179,6 +183,52 @@ export function resolveLoadout(loadout, chess, getChess) {
|
|||||||
return { skillIndex, moduleId };
|
return { skillIndex, moduleId };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One `room.loadout.ops` entry: `{ potential?: 1–6, cultivate?: 0–3 }`, at least one of them. */
|
||||||
|
const isOpsEntry = (e) => isPlain(e) && Object.keys(e).length > 0 && Object.keys(e).every((k) => k === 'potential' || k === 'cultivate')
|
||||||
|
&& optional(isPotential)(e.potential) && optional(isCultivate)(e.cultivate);
|
||||||
|
/** Structural check of `room.loadout.ops` (0.2.2): a map of ≤ 256 charIds → `{ potential?, cultivate? }`. */
|
||||||
|
export const isLoadoutOps = (v) => isMap(v, LOADOUT_LIMITS.ops, isId, isOpsEntry);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The operators a player sets a potential / 练度 for (0.2.2): the charIds of the 干员调配 roster — the visible normal chess
|
||||||
|
* (checkLoadout's targets; a hidden chess shares its visible twin's charId: 锡人, 耶拉 …) — and the owned 6★ 自选 picks
|
||||||
|
* (data/backups.json `diy.ownedPool`). A PRESET (特许) operator the player does not own is set by hand (潜能 1, 未精英化:
|
||||||
|
* the official 「未持有的特许按1潜」 and +0 %); a 补位 stand-in or a prototype pick takes neither.
|
||||||
|
* @param {Record<string, any>|null|undefined} chess data/chess.json
|
||||||
|
* @param {any} [backups] data/backups.json
|
||||||
|
* @returns {Set<string>}
|
||||||
|
*/
|
||||||
|
export function cultivationCharIds(chess, backups = null) {
|
||||||
|
const out = new Set();
|
||||||
|
for (const c of Object.values(chess && typeof chess === 'object' ? chess : {})) {
|
||||||
|
if (c && !c.isGolden && !c.isDiy && c.visible !== false && !c.isHidden && (!c.baseId || c.baseId === c.chessId) && isId(c.charId)) out.add(c.charId);
|
||||||
|
}
|
||||||
|
for (const id of Array.isArray(backups?.diy?.ownedPool) ? backups.diy.ownedPool : []) if (isId(id)) out.add(id);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Semantic check + normalisation of `room.loadout.ops` (0.2.2) — strict like checkLoadout: an operator `isOperator` does
|
||||||
|
* not know (cultivationCharIds) rejects the whole message. Entries equal to the defaults (潜能 6, 练度 3) are dropped, the
|
||||||
|
* rest stored complete `{ potential, cultivate }`. `undefined` / `null` = no settings (`{}`).
|
||||||
|
* @param {any} ops
|
||||||
|
* @param {(charId: string) => boolean} isOperator
|
||||||
|
* @returns {{ ok: true, ops: Record<string, { potential: number, cultivate: number }> } | { error: 'BAD_MSG'|'BAD_TARGET', detail: string }}
|
||||||
|
*/
|
||||||
|
export function checkLoadoutOps(ops, isOperator) {
|
||||||
|
if (ops == null) return { ok: true, ops: {} };
|
||||||
|
if (!isLoadoutOps(ops)) return { error: 'BAD_MSG', detail: 'bad operator settings' };
|
||||||
|
const out = {};
|
||||||
|
for (const id of Object.keys(ops)) {
|
||||||
|
if (typeof isOperator !== 'function' || !isOperator(id)) return { error: 'BAD_TARGET', detail: `unknown operator ${id}` };
|
||||||
|
const potential = ops[id].potential ?? POTENTIAL_DEFAULT;
|
||||||
|
const cultivate = ops[id].cultivate ?? CULTIVATE_DEFAULT;
|
||||||
|
if (potential === POTENTIAL_DEFAULT && cultivate === CULTIVATE_DEFAULT) continue;
|
||||||
|
out[id] = { potential, cultivate };
|
||||||
|
}
|
||||||
|
return { ok: true, ops: out };
|
||||||
|
}
|
||||||
|
|
||||||
// ---- operator ownership (干员持有, 0.2.0 补位): room.ownership { notOwned } -------------------------------------------
|
// ---- operator ownership (干员持有, 0.2.0 补位): room.ownership { notOwned } -------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -276,7 +326,8 @@ const statView = (x) => ({
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The detail card's stats of a sim unit (server/sim/units.js Unit): its effective stats `s` (the aggregated `unit.s`,
|
* The detail card's stats of a sim unit (server/sim/units.js Unit): its effective stats `s` (the aggregated `unit.s`,
|
||||||
* or the last ones the sim computed) next to its own `unit.base` (no buffs) — max HP, ATK, DEF, RES, attack interval
|
* or the last ones the sim computed) next to its own numbers — `unit.base` with its 练度 multiplier (`unit.cultMul`,
|
||||||
|
* 0.2.2: part of the operator's own numbers, as on the record cards; no buffs) — max HP, ATK, DEF, RES, attack interval
|
||||||
* (s), block, move speed — rounded for display (the sim keeps floats), plus the current HP. The shape of the
|
* (s), block, move speed — rounded for display (the sim keeps floats), plus the current HP. The shape of the
|
||||||
* `m.unitStats` units (Match.unitStats: what the board's units start their next battle with) and of the browser
|
* `m.unitStats` units (Match.unitStats: what the board's units start their next battle with) and of the browser
|
||||||
* runner's live battle stats (public/js/battle/runner.js unitStats). An ally with a range also carries `range`: the grid
|
* runner's live battle stats (public/js/battle/runner.js unitStats). An ally with a range also carries `range`: the grid
|
||||||
@@ -292,7 +343,8 @@ const statView = (x) => ({
|
|||||||
* range?: Array<[number, number]>, dir?: string }}
|
* range?: Array<[number, number]>, dir?: string }}
|
||||||
*/
|
*/
|
||||||
export function unitStatsEntry(u, s = null) {
|
export function unitStatsEntry(u, s = null) {
|
||||||
const base = u && u.base && typeof u.base === 'object' ? u.base : {};
|
const own = u && u.base && typeof u.base === 'object' ? u.base : {};
|
||||||
|
const base = u && u.cultMul ? cultivatedStats(own, u.cultMul) : own;
|
||||||
const cur = s && typeof s === 'object' ? s : base;
|
const cur = s && typeof s === 'object' ? s : base;
|
||||||
const range = u?.side !== 'enemy' && Array.isArray(u?.liveRangeGrid)
|
const range = u?.side !== 'enemy' && Array.isArray(u?.liveRangeGrid)
|
||||||
? u.liveRangeGrid.filter((p) => Array.isArray(p) && Number.isInteger(p[0]) && Number.isInteger(p[1])).map((p) => [p[0], p[1]])
|
? u.liveRangeGrid.filter((p) => Array.isArray(p) && Number.isInteger(p[0]) && Number.isInteger(p[1])).map((p) => [p[0], p[1]])
|
||||||
@@ -342,8 +394,9 @@ export const C2S = {
|
|||||||
// host confirmed — a seat that changed hands meanwhile is refused
|
// host confirmed — a seat that changed hands meanwhile is refused
|
||||||
'room.kick': { seat: (v) => isInt(v, 0, MAX_SEATS - 1), playerId: isId },
|
'room.kick': { seat: (v) => isInt(v, 0, MAX_SEATS - 1), playerId: isId },
|
||||||
'room.start': {},
|
'room.start': {},
|
||||||
// operator loadout (DESIGN §16): stored per session/seat; accepted until the match leaves INFO_CHECK
|
// operator loadout (DESIGN §16): stored per session/seat; accepted until the match leaves INFO_CHECK — `ops` (0.2.2):
|
||||||
'room.loadout': { entries: isLoadoutEntries },
|
// the per-operator 潜能 / 练度 (absent = none set: every operator at 潜能 6, 精英2 Lv.60)
|
||||||
|
'room.loadout': { entries: isLoadoutEntries, ops: isLoadoutOps, $optional: ['ops'] },
|
||||||
// operator ownership (干员持有, 0.2.0 补位): stored per session / seat; a match takes the list its seat had when it
|
// operator ownership (干员持有, 0.2.0 补位): stored per session / seat; a match takes the list its seat had when it
|
||||||
// started (an out-of-match setting — during a match it is stored for the next one: ROOM_STARTED)
|
// started (an out-of-match setting — during a match it is stored for the next one: ROOM_STARTED)
|
||||||
'room.ownership': { notOwned: isNotOwnedList },
|
'room.ownership': { notOwned: isNotOwnedList },
|
||||||
|
|||||||
+7
-4
@@ -15,6 +15,7 @@
|
|||||||
// chess's ids, so its kit must be looked up by its `charId`, never by `baseId` (that is the replaced operator's kit).
|
// chess's ids, so its kit must be looked up by its `charId`, never by `baseId` (that is the replaced operator's kit).
|
||||||
|
|
||||||
import { composeStats, composeTalents } from './loadoutRecord.js';
|
import { composeStats, composeTalents } from './loadoutRecord.js';
|
||||||
|
import { stripPotential } from './potential.js';
|
||||||
|
|
||||||
/** The key of a unit form: a chess record's `status` as `${phase}/${level}/${skillLevel}/${equipLevel}`. */
|
/** The key of a unit form: a chess record's `status` as `${phase}/${level}/${skillLevel}/${equipLevel}`. */
|
||||||
export function statusKey(status) {
|
export function statusKey(status) {
|
||||||
@@ -44,7 +45,9 @@ const byNatural = (a, b) => String(a).localeCompare(String(b), 'en', { numeric:
|
|||||||
* The same rules as tools/build-data.mjs buildChess: stats = form stats + the module's `attr`, trait = the module's
|
* The same rules as tools/build-data.mjs buildChess: stats = form stats + the module's `attr`, trait = the module's
|
||||||
* `traitOverride` or the form's, talents = form talents + the module's `talentChanges`; `module` is active only when
|
* `traitOverride` or the form's, talents = form talents + the module's `talentChanges`; `module` is active only when
|
||||||
* `status.equipLevel > 0` and the module has that level; an elite with `equipLevel > 0` carries
|
* `status.equipLevel > 0` and the module has that level; an elite with `equipLevel > 0` carries
|
||||||
* `statsBase` / `traitBase` / `talentsBase` / `modules[]`; `tokens` are the summons the selection produces.
|
* `statsBase` / `traitBase` / `talentsBase` / `modules[]`; `tokens` are the summons the selection produces. The record is
|
||||||
|
* composed at ONE potential: pass the form at the wanted potential (shared/potential.js atPotential — a stand-in has none)
|
||||||
|
* and the result carries no potential annotation (`potDown`, chained talents).
|
||||||
* @param {object} identity the data/chess.json record whose identity the unit takes (IDENTITY_FIELDS)
|
* @param {object} identity the data/chess.json record whose identity the unit takes (IDENTITY_FIELDS)
|
||||||
* @param {object} unit backups.units[charId]
|
* @param {object} unit backups.units[charId]
|
||||||
* @param {object} form backups.units[charId].forms[statusKey(identity.status)]
|
* @param {object} form backups.units[charId].forms[statusKey(identity.status)]
|
||||||
@@ -63,7 +66,7 @@ export function composeUnitRecord(identity, unit, form, { skillIndex, moduleId =
|
|||||||
const skill = skills.find((s) => s && s.index === skillIndex) ?? null;
|
const skill = skills.find((s) => s && s.index === skillIndex) ?? null;
|
||||||
const mods = Array.isArray(form.modules) ? form.modules : null;
|
const mods = Array.isArray(form.modules) ? form.modules : null;
|
||||||
const mod = moduleId && mods ? mods.find((m) => m && m.uniEquipId === moduleId) ?? null : null;
|
const mod = moduleId && mods ? mods.find((m) => m && m.uniEquipId === moduleId) ?? null : null;
|
||||||
const talents = mod ? composeTalents(form.talents, mod.talentChanges) : form.talents;
|
const talents = composeTalents(form.talents, mod ? mod.talentChanges : null);
|
||||||
// summons: listed by the character, produced by the selected skill or a talent (buildChess `tokens`)
|
// summons: listed by the character, produced by the selected skill or a talent (buildChess `tokens`)
|
||||||
const known = new Set(form.tokens || []);
|
const known = new Set(form.tokens || []);
|
||||||
const tokenIds = new Set([...(form.displayTokens || []), skill?.overrideTokenKey, ...(talents || []).map((t) => t?.tokenKey)]
|
const tokenIds = new Set([...(form.displayTokens || []), skill?.overrideTokenKey, ...(talents || []).map((t) => t?.tokenKey)]
|
||||||
@@ -95,8 +98,8 @@ export function composeUnitRecord(identity, unit, form, { skillIndex, moduleId =
|
|||||||
if (golden && equipLevel > 0) {
|
if (golden && equipLevel > 0) {
|
||||||
out.statsBase = form.stats;
|
out.statsBase = form.stats;
|
||||||
out.traitBase = form.trait;
|
out.traitBase = form.trait;
|
||||||
out.talentsBase = form.talents;
|
out.talentsBase = stripPotential(form.talents);
|
||||||
out.modules = (mods || []).map((m) => ({ ...m, isDefault: m.uniEquipId === moduleId }));
|
out.modules = (mods || []).map((m) => ({ ...stripPotential(m), isDefault: m.uniEquipId === moduleId }));
|
||||||
}
|
}
|
||||||
if (standInFor) out.standInFor = standInFor;
|
if (standInFor) out.standInFor = standInFor;
|
||||||
return out;
|
return out;
|
||||||
|
|||||||
@@ -178,6 +178,19 @@ test('圣约送葬人 module REA-Y (已知悉): ASPD +12 with ≥ 2 enemies in r
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('圣约送葬人 heals 50 per enemy hit with REA-Y or no module, 60 with REA-X (the REA-Y line\'s 12 is its ASPD, GitHub #400)', () => {
|
||||||
|
for (const [moduleId, want] of [['uniequip_003_excu2', 50], ['uniequip_002_excu2', 60], ['none', 50]]) {
|
||||||
|
const h = run({
|
||||||
|
defs: { enemies: { enemy_dummy: dummy('enemy_dummy') } },
|
||||||
|
units: [{ chessId: 'chess_char_5_01_b', row: 10, col: 4, moduleId }],
|
||||||
|
enemies: [{ key: 'enemy_dummy', pos: [10, 5] }],
|
||||||
|
});
|
||||||
|
const u = h.unit('chess_char_5_01_b');
|
||||||
|
assert.equal(u.profile.selfHeal, want, moduleId);
|
||||||
|
done(h);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// =================================================================================================================
|
// =================================================================================================================
|
||||||
// 缇缇
|
// 缇缇
|
||||||
|
|
||||||
|
|||||||
+29
-1
@@ -16,6 +16,7 @@ import { join, dirname } from 'node:path';
|
|||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { spawnSync } from 'node:child_process';
|
import { spawnSync } from 'node:child_process';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { stripPotential } from '../shared/potential.js';
|
||||||
|
|
||||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
// DATA_DIR lets the suite validate an alternative build output (e.g. `--out /tmp/x`).
|
// DATA_DIR lets the suite validate an alternative build output (e.g. `--out /tmp/x`).
|
||||||
@@ -558,7 +559,8 @@ test('chess: golden modules[] (+ statsBase/traitBase/talentsBase) compose back t
|
|||||||
// default loadout = the record's own stats / trait / talents
|
// default loadout = the record's own stats / trait / talents
|
||||||
assert.deepEqual(composeStats(c.statsBase, dm?.attr), c.stats, `${c.chessId}: statsBase + default attr = stats`);
|
assert.deepEqual(composeStats(c.statsBase, dm?.attr), c.stats, `${c.chessId}: statsBase + default attr = stats`);
|
||||||
assert.deepEqual(dm?.traitOverride ?? c.traitBase, c.trait, `${c.chessId}: trait`);
|
assert.deepEqual(dm?.traitOverride ?? c.traitBase, c.trait, `${c.chessId}: trait`);
|
||||||
assert.deepEqual(composeTalents(c.talentsBase, dm?.talentChanges), c.talents, `${c.chessId}: talents`);
|
// (the record's own lists chain their lower-potential entries — shared/potential.js; a composed list carries none)
|
||||||
|
assert.deepEqual(composeTalents(c.talentsBase, dm?.talentChanges), stripPotential(c.talents), `${c.chessId}: talents`);
|
||||||
assert.equal(new Set(c.modules.map((m) => m.uniEquipId)).size, c.modules.length);
|
assert.equal(new Set(c.modules.map((m) => m.uniEquipId)).size, c.modules.length);
|
||||||
for (const m of c.modules) {
|
for (const m of c.modules) {
|
||||||
nMods++;
|
nMods++;
|
||||||
@@ -727,6 +729,32 @@ test('official spot checks (hard-coded values from the zh_CN client data)', () =
|
|||||||
assert.equal(config.modes.mode_multi_abyss.enemyScale['6'].hp, 2.239488); // 1.2^4 × 1.08
|
assert.equal(config.modes.mode_multi_abyss.enemyScale['6'].hp, 2.239488); // 1.2^4 × 1.08
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a module trait part that only adds a display line writes that line only (GitHub #400): 圣约送葬人 REA-Y heals 50 per enemy hit, its line ASPD +12', () => {
|
||||||
|
// official battle_equip_table uniequip_003_excu2: a DISPLAY part whose blackboard `value` 12 belongs to its own
|
||||||
|
// 「攻击速度+{value}」; the class trait's 「回复自身{value}生命」 stays the base 50 (REA-X's TRAIT_DATA_ONLY part rewrites it: 60)
|
||||||
|
const g = chess.chess_char_5_01_b;
|
||||||
|
const y = g.modules.find((m) => m.uniEquipId === 'uniequip_003_excu2').traitOverride;
|
||||||
|
assert.equal(y.desc, g.traitBase.desc, 'the class trait as without a module');
|
||||||
|
assert.match(y.desc, /每攻击到一个敌人回复自身50生命/);
|
||||||
|
assert.match(y.descRaw, /回复自身<@ba\.kw>50<\/>生命/);
|
||||||
|
assert.equal(y.moduleDesc, '攻击范围内存在2名及以上敌人时攻击速度+12');
|
||||||
|
assert.match(g.modules.find((m) => m.uniEquipId === 'uniequip_002_excu2').traitOverride.desc, /回复自身60生命/, 'REA-X');
|
||||||
|
// no other module record carries a trait line that differs from its owner's class trait only in a number its own
|
||||||
|
// display line uses (the class of this bug)
|
||||||
|
const forms = [...Object.values(chess), ...Object.values(D.backups.units || {}).flatMap((u) => Object.values(u.forms || {}))];
|
||||||
|
for (const f of forms) {
|
||||||
|
for (const m of f.modules || []) {
|
||||||
|
const o = m.traitOverride;
|
||||||
|
if (!o?.moduleDesc || !f.traitBase?.desc || o.desc === f.traitBase.desc) continue;
|
||||||
|
const nums = (t) => String(t).match(/\d+(?:\.\d+)?/g) || [];
|
||||||
|
const sameWords = o.desc.replace(/\d+(?:\.\d+)?/g, '#') === f.traitBase.desc.replace(/\d+(?:\.\d+)?/g, '#');
|
||||||
|
if (!sameWords) continue;
|
||||||
|
const moved = nums(o.desc).filter((n, i) => n !== nums(f.traitBase.desc)[i]);
|
||||||
|
assert.ok(!moved.every((n) => nums(o.moduleDesc).includes(n)), `${f.chessId || f.charId} ${m.uniEquipId}: ${o.desc} / ${o.moduleDesc}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
/** Read an official cache file (only called when HAS_CACHE). */
|
/** Read an official cache file (only called when HAS_CACHE). */
|
||||||
const raw = (rel) => JSON.parse(readFileSync(join(CACHE, rel), 'utf8'));
|
const raw = (rel) => JSON.parse(readFileSync(join(CACHE, rel), 'utf8'));
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
// test/full-potential.test.js — every operator fights at FULL potential (潜能 6), the owner's decision of 2026-10-07
|
// test/full-potential.test.js — the data records are built at FULL potential (潜能 6: the default — the owner's decisions of
|
||||||
// (GitHub #252, PR #255; tools/build-data.mjs OPERATOR_POTENTIAL, docs/DATA.md §2): every chess (normal and elite), 自选
|
// 2026-10-07, GitHub #252 / PR #255, and 2026-10-08: a lower potential is the player's 干员调配 setting, test/potential.test.js;
|
||||||
|
// tools/build-data.mjs FULL_RANK, docs/DATA.md §2 / §2.3): every chess (normal and elite), 自选
|
||||||
// pick and 补位 stand-in adds its character_table `potentialRanks` attribute steps and takes its talent / module-talent
|
// pick and 补位 stand-in adds its character_table `potentialRanks` attribute steps and takes its talent / module-talent
|
||||||
// candidates up to `requiredPotentialRank` 5; a summon picks its talent candidates at its owner's potential but takes
|
// candidates up to `requiredPotentialRank` 5; a summon picks its talent candidates at its owner's potential but takes
|
||||||
// none of the owner's attribute steps. The evidence: a tournament video shows 刺玫 (chess_char_1_06_a) at ATK 435 and
|
// none of the owner's attribute steps. The evidence: a tournament video shows 刺玫 (chess_char_1_06_a) at ATK 435 and
|
||||||
|
|||||||
+748
-750
File diff suppressed because it is too large.
Load diff
+3026
-3032
File diff suppressed because it is too large.
Load diff
+789
-792
File diff suppressed because it is too large.
Load diff
+338
-333
File diff suppressed because it is too large.
Load diff
+1529
-1532
File diff suppressed because it is too large.
Load diff
@@ -1,7 +1,7 @@
|
|||||||
// test/lobby-loadout.test.js — room.loadout end to end over WebSocket (DESIGN §16): strict validation, storage on the
|
// test/lobby-loadout.test.js — room.loadout end to end over WebSocket (DESIGN §16): strict validation, storage on the
|
||||||
// session (follows the player into rooms, survives a resume) and on the seat, seats[].loadout handed to the Match
|
// session (follows the player into rooms, survives a resume) and on the seat, seats[].loadout handed to the Match
|
||||||
// (bots: none), re-sendable until the match leaves INFO_CHECK (real Match: accepted in the briefing, WRONG_PHASE after),
|
// (bots: none), re-sendable until the match leaves INFO_CHECK (real Match: accepted in the briefing, WRONG_PHASE after),
|
||||||
// the heavy-intent rate limit.
|
// the heavy-intent rate limit; its per-operator 潜能 / 练度 `ops` (0.2.2): validated with it, stored and handed over beside it.
|
||||||
import { describe, test, before, after, afterEach } from 'node:test';
|
import { describe, test, before, after, afterEach } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
@@ -111,6 +111,39 @@ describe('room.loadout (lobby, stub match)', () => {
|
|||||||
await back.terminate();
|
await back.terminate();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('潜能 / 练度 (ops, 0.2.2): strict validation, defaults dropped, stored per session and seat beside the loadout; a message without ops sets none', async () => {
|
||||||
|
const VENDLA = C('chess_char_1_06_a').charId; // a 特许 operator of the roster
|
||||||
|
const KALTS = 'char_003_kalts'; // an owned-6★ 自选 pick
|
||||||
|
const a = await pool.player('Ops');
|
||||||
|
await err(a, { t: 'room.loadout', entries: {}, ops: [] }, ERR.BAD_MSG);
|
||||||
|
await err(a, { t: 'room.loadout', entries: {}, ops: { [VENDLA]: {} } }, ERR.BAD_MSG);
|
||||||
|
await err(a, { t: 'room.loadout', entries: {}, ops: { [VENDLA]: { potential: 0 } } }, ERR.BAD_MSG);
|
||||||
|
await err(a, { t: 'room.loadout', entries: {}, ops: { [VENDLA]: { potential: 7 } } }, ERR.BAD_MSG);
|
||||||
|
await err(a, { t: 'room.loadout', entries: {}, ops: { [VENDLA]: { cultivate: 4 } } }, ERR.BAD_MSG);
|
||||||
|
await err(a, { t: 'room.loadout', entries: {}, ops: { [VENDLA]: { potential: 2, elite: 1 } } }, ERR.BAD_MSG);
|
||||||
|
await err(a, { t: 'room.loadout', entries: {}, ops: { char_609_acguad: { potential: 1 } } }, ERR.BAD_TARGET); // a prototype
|
||||||
|
await err(a, { t: 'room.loadout', entries: {}, ops: { char_999_nobody: { potential: 1 } } }, ERR.BAD_TARGET);
|
||||||
|
// a refusal of the ops refuses the entries too (nothing stored)
|
||||||
|
await err(a, { t: 'room.loadout', entries: { [INSIDE]: { skill: 0 } }, ops: { char_999_nobody: { potential: 1 } } }, ERR.BAD_TARGET);
|
||||||
|
await ok(a, { t: 'room.loadout', entries: { [INSIDE]: { skill: 0 } }, ops: { [VENDLA]: { potential: 1, cultivate: 0 }, [KALTS]: { cultivate: 2 }, [C(INSIDE).charId]: { potential: 6, cultivate: 3 } } });
|
||||||
|
await createRoom(a, 'solo');
|
||||||
|
await ok(a, { t: 'room.start' });
|
||||||
|
await a.waitFor('m.public', (p) => p.phase === 'INFO_CHECK');
|
||||||
|
const seat = RecordingStub.instances.at(-1).opts.seats[0];
|
||||||
|
assert.deepEqual(seat.loadout, { [INSIDE]: { skill: 0, module: 'uniequip_002_inside' } });
|
||||||
|
assert.deepEqual(seat.ops, { [VENDLA]: { potential: 1, cultivate: 0 }, [KALTS]: { potential: 6, cultivate: 2 } }, 'complete entries, defaults dropped');
|
||||||
|
assert.ok(Object.isFrozen(seat.ops) && Object.isFrozen(seat.ops[VENDLA]));
|
||||||
|
// an older client's message (no ops): none set — the next match fields every operator at 潜能 6 / 精英2 Lv.60
|
||||||
|
await err(a, { t: 'room.loadout', entries: {} }, ERR.ROOM_STARTED);
|
||||||
|
const b = await pool.player('Old');
|
||||||
|
await ok(b, { t: 'room.loadout', entries: { [INSIDE]: { skill: 0 } } });
|
||||||
|
await createRoom(b, 'solo');
|
||||||
|
await ok(b, { t: 'room.start' });
|
||||||
|
await b.waitFor('m.public', (p) => p.phase === 'INFO_CHECK');
|
||||||
|
assert.deepEqual(RecordingStub.instances.at(-1).opts.seats[0].ops, {});
|
||||||
|
assert.deepEqual(cap.errors, []);
|
||||||
|
});
|
||||||
|
|
||||||
test('heavy-intent limit: a burst of room.loadout beyond the bucket is rate-limited', async () => {
|
test('heavy-intent limit: a burst of room.loadout beyond the bucket is rate-limited', async () => {
|
||||||
const a = await pool.player('Spam');
|
const a = await pool.player('Spam');
|
||||||
const replies = await Promise.all(Array.from({ length: 10 }, () => a.request({ t: 'room.loadout', entries: {} })));
|
const replies = await Promise.all(Array.from({ length: 10 }, () => a.request({ t: 'room.loadout', entries: {} })));
|
||||||
@@ -146,4 +179,19 @@ describe('room.loadout (lobby + real match)', () => {
|
|||||||
assert.match(r.detail || '', /locked/);
|
assert.match(r.detail || '', /locked/);
|
||||||
assert.deepEqual(cap.errors, []);
|
assert.deepEqual(cap.errors, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('潜能 / 练度 (0.2.2): taken with the loadout in the briefing (m.private.ops), stated in the battle input; locked afterwards', async () => {
|
||||||
|
const VENDLA = C('chess_char_1_06_a').charId;
|
||||||
|
const a = await pool.player('Solo2');
|
||||||
|
await createRoom(a, 'solo');
|
||||||
|
await ok(a, { t: 'room.start' });
|
||||||
|
await a.waitFor('m.public', (p) => p.phase === PHASE.INFO_CHECK, 5000);
|
||||||
|
await ok(a, { t: 'room.loadout', entries: {}, ops: { [VENDLA]: { potential: 2, cultivate: 1 } } });
|
||||||
|
const priv = await a.waitFor('m.private', (p) => p.ops && p.ops[VENDLA], 3000);
|
||||||
|
assert.deepEqual(priv.ops, { [VENDLA]: { potential: 2, cultivate: 1 } });
|
||||||
|
await ok(a, { t: 'g.infoReady' });
|
||||||
|
await a.waitFor('m.public', (p) => p.phase === PHASE.BAND_DRAFT, 3000);
|
||||||
|
await err(a, { t: 'room.loadout', entries: {}, ops: {} }, ERR.WRONG_PHASE);
|
||||||
|
assert.deepEqual(cap.errors, []);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -88,8 +88,10 @@ test('终极 Final Assault (+200 layers per active bond): one pool (bloodPoint
|
|||||||
for (const k of MUL_STATS) assert.ok(!(k in b.mods), `${u.name}: ${b.key} is a 直接乘算 bonus, not ${k} (${JSON.stringify(b.mods)})`);
|
for (const k of MUL_STATS) assert.ok(!(k in b.mods), `${u.name}: ${b.key} is a 直接乘算 bonus, not ${k} (${JSON.stringify(b.mods)})`);
|
||||||
}
|
}
|
||||||
const { add, mul } = aggregateMods(u.buffs);
|
const { add, mul } = aggregateMods(u.buffs);
|
||||||
const want = Math.max(0, ((u.base.atk + (add.atkFlat ?? 0)) * Math.max(0, 1 + (add.atkPct ?? 0)) + (add.atkFinal ?? 0)) * (mul.atkMul ?? 1));
|
// the 练度 multiplier (0.2.2, the unit's own cultMul: default 精英2 Lv.60 = ×1.1) is a separate factor, after 直接乘算
|
||||||
assert.ok(Math.abs(u.s.atk - want) <= 1e-6 * Math.max(1, want), `${u.name}: ATK = ((base + flat) × (1 + Σ%) + 最终加算) × Π(提升至/runes)`);
|
const cult = u.cultMul ? u.cultMul.atk : 1;
|
||||||
|
const want = Math.max(0, ((u.base.atk + (add.atkFlat ?? 0)) * Math.max(0, 1 + (add.atkPct ?? 0)) + (add.atkFinal ?? 0)) * (mul.atkMul ?? 1) * cult);
|
||||||
|
assert.ok(Math.abs(u.s.atk - want) <= 1e-6 * Math.max(1, want), `${u.name}: ATK = ((base + flat) × (1 + Σ%) + 最终加算) × Π(提升至/runes) × 练度`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
assert.ok(ops >= 12 && withBonds >= 8, `the bots' lineups carry bond / item bonuses (${withBonds} of ${ops})`);
|
assert.ok(ops >= 12 && withBonds >= 8, `the bots' lineups carry bond / item bonuses (${withBonds} of ${ops})`);
|
||||||
|
|||||||
@@ -96,7 +96,8 @@ test('seats[].diy → PlayerState.diy (re-checked, frozen; bots none); the playe
|
|||||||
const want = diyRecord(T5A, SIEGE_PICK, { elite, data: DATA });
|
const want = diyRecord(T5A, SIEGE_PICK, { elite, data: DATA });
|
||||||
assert.deepEqual([rec.name, rec.charId, rec.diyFor, rec.bonds, rec.garrisonIds, rec.tier, rec.price, rec.sellPrice], [want.name, SIEGE, T5A, ['victoriaShip'], [], 5, 4, 1]);
|
assert.deepEqual([rec.name, rec.charId, rec.diyFor, rec.bonds, rec.garrisonIds, rec.tier, rec.price, rec.sellPrice], [want.name, SIEGE, T5A, ['victoriaShip'], [], 5, 4, 1]);
|
||||||
assert.equal(rec.skill.skillId, 'skchr_siege_3');
|
assert.equal(rec.skill.skillId, 'skchr_siege_3');
|
||||||
assert.deepEqual(p0.loadoutFor(h.m.gd.chess(id)), { skillIndex: 2, moduleId: elite ? 'uniequip_002_siege' : null }, 'the pick, never the loadout');
|
// (0.2.2: an owned pick at the player's 潜能 / 练度 — none set: 6 / 3)
|
||||||
|
assert.deepEqual(p0.loadoutFor(h.m.gd.chess(id)), { skillIndex: 2, moduleId: elite ? 'uniequip_002_siege' : null, potential: 6, cultivate: 3 }, 'the pick, never the loadout');
|
||||||
assert.equal(h.m.gd.chess(id).name, DATA.chess[id].name, 'the match\'s own record is the slot');
|
assert.equal(h.m.gd.chess(id).name, DATA.chess[id].name, 'the match\'s own record is the slot');
|
||||||
}
|
}
|
||||||
const s1 = DATA.backups.units[SIEGE].forms['2/1/4/0'].skills.find((x) => x.index === 0).skillId;
|
const s1 = DATA.backups.units[SIEGE].forms['2/1/4/0'].skills.find((x) => x.index === 0).skillId;
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
// test/match/potential.test.js — the player's 潜能 / 练度 on the server (0.2.2, the owner's decision of 2026-10-08:
|
||||||
|
// 「调配干员里自己设置吧,默认满潜满加成」): room.loadout `ops` (shared/protocol.js checkLoadoutOps) → seats[].ops →
|
||||||
|
// PlayerState.ops (re-checked, frozen; bots none) → every owned operator's PlayerBattleInput entry states `potential` /
|
||||||
|
// `cultivate` (defaults 6 / 3 — also an old loadout without ops), never a 补位 stand-in or a prototype 自选 pick; the
|
||||||
|
// 自选 summons' hand count follows the owner's potential; m.private.ops; the scouting units; Match.setLoadout takes
|
||||||
|
// them in INFO_CHECK only.
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { ERR, PHASE } from '../../shared/constants.js';
|
||||||
|
import { validateC2S, checkLoadoutOps, isLoadoutOps, cultivationCharIds, LOADOUT_LIMITS } from '../../shared/protocol.js';
|
||||||
|
import { buildBattleSpec, createBattleFromSpec } from '../../server/sim/spec.js';
|
||||||
|
import { DATA, makeMatch } from './harness.js';
|
||||||
|
|
||||||
|
const chess = (id) => (Object.hasOwn(DATA.chess, id) ? DATA.chess[id] : null);
|
||||||
|
const VENDLA = 'chess_char_1_06_a'; // 刺玫 (PRESET)
|
||||||
|
const VENDLA_CHAR = chess(VENDLA).charId;
|
||||||
|
const GRANI = Object.values(DATA.chess).find((c) => c.chessType === 'NORMAL' && !c.isGolden && c.visible && c.backup?.charId && c.backup.charId !== c.charId);
|
||||||
|
const T6A = 'chess_char_6_diy1_a';
|
||||||
|
const T5A = 'chess_char_5_diy1_a';
|
||||||
|
const KALTS = 'char_003_kalts';
|
||||||
|
const WANG = 'char_2027_wang';
|
||||||
|
const SHARP = 'char_609_acguad';
|
||||||
|
const IDS = cultivationCharIds(DATA.chess, DATA.backups);
|
||||||
|
|
||||||
|
function place(ps, chessId, row, col) {
|
||||||
|
const p = ps.newPiece('chess', chessId);
|
||||||
|
ps.board.set(`${row},${col}`, p);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('room.loadout ops: structure (≤ 256 charIds → { potential 1–6?, cultivate 0–3? }) and the strict data check', () => {
|
||||||
|
const ok = (ops) => validateC2S({ t: 'room.loadout', entries: {}, ops }) === null;
|
||||||
|
assert.ok(validateC2S({ t: 'room.loadout', entries: {} }) === null, 'ops optional (an older client)');
|
||||||
|
assert.ok(ok({}));
|
||||||
|
assert.ok(ok({ [VENDLA_CHAR]: { potential: 1 } }));
|
||||||
|
assert.ok(ok({ [VENDLA_CHAR]: { cultivate: 0 } }));
|
||||||
|
assert.ok(ok({ [VENDLA_CHAR]: { potential: 6, cultivate: 3 } }));
|
||||||
|
for (const bad of [[], null, { [VENDLA_CHAR]: {} }, { [VENDLA_CHAR]: { potential: 0 } }, { [VENDLA_CHAR]: { potential: 6.5 } },
|
||||||
|
{ [VENDLA_CHAR]: { cultivate: -1 } }, { [VENDLA_CHAR]: { cultivate: 4 } }, { [VENDLA_CHAR]: { potential: '3' } }, { 'a b': { potential: 1 } },
|
||||||
|
{ [VENDLA_CHAR]: { potential: 1, trust: 100 } }]) assert.ok(!ok(bad), JSON.stringify(bad));
|
||||||
|
const many = Object.fromEntries(Array.from({ length: LOADOUT_LIMITS.ops + 1 }, (_, i) => [`char_${i}`, { potential: 1 }]));
|
||||||
|
assert.ok(!isLoadoutOps(many));
|
||||||
|
// the operators one may set: the 112-chess roster's charIds (hidden twins share them) and the 71 owned 自选 picks
|
||||||
|
assert.ok(IDS.has(VENDLA_CHAR) && IDS.has(KALTS) && IDS.has(WANG));
|
||||||
|
assert.ok(!IDS.has(SHARP) && !IDS.has(GRANI.backup.charId), 'a prototype / stand-in character is no setting');
|
||||||
|
assert.ok(IDS.size >= 180 && IDS.size <= 200, `${IDS.size}`);
|
||||||
|
const has = (id) => IDS.has(id);
|
||||||
|
assert.deepEqual(checkLoadoutOps(undefined, has), { ok: true, ops: {} });
|
||||||
|
assert.deepEqual(checkLoadoutOps({ [VENDLA_CHAR]: { potential: 2 }, [KALTS]: { potential: 6, cultivate: 3 } }, has), { ok: true, ops: { [VENDLA_CHAR]: { potential: 2, cultivate: 3 } } });
|
||||||
|
assert.equal(checkLoadoutOps({ [SHARP]: { potential: 2 } }, has).error, 'BAD_TARGET');
|
||||||
|
assert.equal(checkLoadoutOps({ [VENDLA_CHAR]: { potential: 9 } }, has).error, 'BAD_MSG');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('battle input: every owned operator states 潜能 / 练度 — the human\'s settings, the defaults (6 / 3) for the rest, for a seat without ops and for bots', () => {
|
||||||
|
const ops = { [VENDLA_CHAR]: { potential: 1, cultivate: 0 } };
|
||||||
|
const seats = [
|
||||||
|
{ seat: 0, playerId: 'p_0', name: 'P0', isBot: false, connected: true, loadout: null, ops, notOwned: [GRANI.chessId] },
|
||||||
|
{ seat: 1, playerId: 'p_1', name: 'P1', isBot: false, connected: true, loadout: {} }, // an old seat: no ops
|
||||||
|
{ seat: 2, playerId: 'ai_0', name: 'AI0', isBot: true, connected: true, ops },
|
||||||
|
];
|
||||||
|
const h = makeMatch({ mode: 'coop', seats, seed: 3 }).start();
|
||||||
|
const m = h.m;
|
||||||
|
const [p0, p1, bot] = ['p_0', 'p_1', 'ai_0'].map((id) => h.ps(id));
|
||||||
|
assert.deepEqual(p0.ops, { [VENDLA_CHAR]: { potential: 1, cultivate: 0 } });
|
||||||
|
assert.ok(Object.isFrozen(p0.ops) && Object.isFrozen(p0.ops[VENDLA_CHAR]));
|
||||||
|
assert.deepEqual([p1.ops, bot.ops], [{}, {}], 'no settings / a bot: the defaults');
|
||||||
|
h.flushAll();
|
||||||
|
assert.deepEqual(h.lastTo('p_0', 'm.private').ops, p0.ops, 'm.private exposes the effective settings');
|
||||||
|
h.toPrep(1);
|
||||||
|
for (const ps of [p0, p1, bot]) { for (const p of [...ps.board.values()]) ps.returnCopies(p); ps.board.clear(); }
|
||||||
|
place(p0, VENDLA, 9, 3);
|
||||||
|
place(p0, chess(VENDLA).goldenId, 10, 3);
|
||||||
|
place(p0, 'chess_char_1_02_a', 11, 3);
|
||||||
|
place(p0, GRANI.chessId, 12, 3); // not owned: its stand-in
|
||||||
|
place(p1, VENDLA, 9, 3);
|
||||||
|
place(bot, VENDLA, 9, 3);
|
||||||
|
const by = (ps, id) => ps.battleInput().units.find((u) => u.chessId === id);
|
||||||
|
assert.deepEqual([by(p0, VENDLA).potential, by(p0, VENDLA).cultivate], [1, 0], 'the human\'s setting');
|
||||||
|
assert.deepEqual([by(p0, chess(VENDLA).goldenId).potential, by(p0, chess(VENDLA).goldenId).cultivate], [1, 0], 'the elite shares the charId');
|
||||||
|
assert.deepEqual([by(p0, 'chess_char_1_02_a').potential, by(p0, 'chess_char_1_02_a').cultivate], [6, 3], 'no entry: 满潜满加成');
|
||||||
|
assert.deepEqual(['potential' in by(p0, GRANI.chessId), 'cultivate' in by(p0, GRANI.chessId), by(p0, GRANI.chessId).standIn], [false, false, true], 'a stand-in has neither');
|
||||||
|
assert.deepEqual([by(p1, VENDLA).potential, by(p1, VENDLA).cultivate], [6, 3], 'an old loadout: the defaults');
|
||||||
|
assert.deepEqual([by(bot, VENDLA).potential, by(bot, VENDLA).cultivate], [6, 3], 'bots: the defaults');
|
||||||
|
// the spec keeps them and the battle fields them
|
||||||
|
const spec = buildBattleSpec(m._normalOpts(p0));
|
||||||
|
const b = createBattleFromSpec(spec, undefined, { quiet: true });
|
||||||
|
const u = b.allyUnits.find((x) => x.defId === VENDLA);
|
||||||
|
const e = b.allyUnits.find((x) => x.defId === 'chess_char_1_02_a');
|
||||||
|
assert.deepEqual([u.base.atk, u.base.cost, u.cultivate, u.s.atk], [413, 17, 0, 413]);
|
||||||
|
assert.deepEqual([e.cultivate, e.s.maxHp], [3, e.base.maxHp * 1.1]);
|
||||||
|
// the scouting units carry them (a teammate's card)
|
||||||
|
const scout = m.prepFieldMeta(p0).units;
|
||||||
|
const sv = scout.find((x) => x.defId === VENDLA);
|
||||||
|
assert.deepEqual([sv.potential, sv.cultivate], [1, 0]);
|
||||||
|
const sg = scout.find((x) => x.defId === GRANI.chessId);
|
||||||
|
assert.deepEqual([sg.potential, sg.cultivate], [undefined, undefined]);
|
||||||
|
assert.deepEqual([scout.find((x) => x.defId === 'chess_char_1_02_a').potential, scout.find((x) => x.defId === 'chess_char_1_02_a').cultivate], [undefined, 3]);
|
||||||
|
m.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('自选: an owned pick states its operator\'s settings, a prototype neither; 望\'s 棋子 hand count follows her potential', () => {
|
||||||
|
const ops = { [KALTS]: { potential: 2, cultivate: 1 }, [WANG]: { potential: 2 } };
|
||||||
|
const seats = [{ seat: 0, playerId: 'p_0', name: 'P0', isBot: false, connected: true, ops,
|
||||||
|
diy: { [T6A]: { charId: KALTS, skillIndex: 0 }, [T5A]: { charId: SHARP }, chess_char_6_diy2_a: { charId: WANG, skillIndex: 0 } } }];
|
||||||
|
const h = makeMatch({ mode: 'solo', seats, seed: 7 }).start();
|
||||||
|
const m = h.m;
|
||||||
|
const p0 = h.ps('p_0');
|
||||||
|
h.toPrep(1);
|
||||||
|
for (const p of [...p0.board.values()]) p0.returnCopies(p);
|
||||||
|
p0.board.clear();
|
||||||
|
place(p0, T6A, 10, 3);
|
||||||
|
place(p0, T5A, 10, 5);
|
||||||
|
const units = p0.battleInput().units;
|
||||||
|
const k = units.find((u) => u.chessId === T6A);
|
||||||
|
const s = units.find((u) => u.chessId === T5A);
|
||||||
|
assert.deepEqual([k.diy.charId, k.potential, k.cultivate], [KALTS, 2, 1]);
|
||||||
|
assert.deepEqual([s.diy.charId, 'potential' in s, 'cultivate' in s], [SHARP, false, false]);
|
||||||
|
// 望 (潜能 2): 6 棋子 to the hand, not 7
|
||||||
|
const lo = p0.loadoutFor(p0.gd.chess('chess_char_6_diy2_a'));
|
||||||
|
assert.equal(lo.potential, 2);
|
||||||
|
assert.deepEqual(p0.gd.placeableTokens('chess_char_6_diy2_a', lo), [{ tokenId: 'token_10064_wang_stone1', count: 6 }]);
|
||||||
|
assert.deepEqual(p0.gd.placeableTokens('chess_char_6_diy2_a', { ...lo, potential: 6 }), [{ tokenId: 'token_10064_wang_stone1', count: 7 }]);
|
||||||
|
m.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Match.setLoadout: the settings change with the loadout in INFO_CHECK only; re-checked; a refusal changes nothing', () => {
|
||||||
|
const h = makeMatch({ mode: 'coop', humans: 2, bots: 1, seed: 4 }).start();
|
||||||
|
const m = h.m;
|
||||||
|
const ps = h.ps('p_0');
|
||||||
|
assert.deepEqual(ps.ops, {});
|
||||||
|
assert.deepEqual(m.setLoadout('p_0', {}, { [VENDLA_CHAR]: { potential: 3 } }), { ok: true });
|
||||||
|
assert.deepEqual(ps.ops, { [VENDLA_CHAR]: { potential: 3, cultivate: 3 } });
|
||||||
|
assert.deepEqual(m.setLoadout('p_0', {}), { ok: true }, 'no ops argument: unchanged');
|
||||||
|
assert.deepEqual(ps.ops, { [VENDLA_CHAR]: { potential: 3, cultivate: 3 } });
|
||||||
|
assert.equal(m.setLoadout('p_0', {}, { [SHARP]: { potential: 1 } }).error, ERR.BAD_TARGET);
|
||||||
|
assert.equal(m.setLoadout('p_0', { chess_char_1_01_a: { skill: 0 } }, { [VENDLA_CHAR]: { potential: 9 } }).error, ERR.BAD_TARGET);
|
||||||
|
assert.deepEqual([ps.ops, ps.loadout], [{ [VENDLA_CHAR]: { potential: 3, cultivate: 3 } }, {}], 'a refusal changes nothing');
|
||||||
|
assert.deepEqual(m.setLoadout('p_0', {}, null), { ok: true }, 'null: none set');
|
||||||
|
assert.deepEqual(ps.ops, {});
|
||||||
|
for (const id of ['p_0', 'p_1']) m.handle(id, { t: 'g.infoReady' });
|
||||||
|
h.sched.advance(1);
|
||||||
|
assert.equal(m.phase, PHASE.BAND_DRAFT);
|
||||||
|
assert.equal(m.setLoadout('p_0', {}, { [VENDLA_CHAR]: { potential: 1 } }).error, ERR.WRONG_PHASE, 'locked after INFO_CHECK');
|
||||||
|
assert.deepEqual(ps.ops, {});
|
||||||
|
m.dispose();
|
||||||
|
});
|
||||||
@@ -438,8 +438,10 @@ test('live unit stats (user playtest #4 item 7): unitStats(id) reads the battle
|
|||||||
assert.equal(got.interval, Math.round(s.interval * 100) / 100);
|
assert.equal(got.interval, Math.round(s.interval * 100) / 100);
|
||||||
assert.equal(got.blockCnt, s.blockCnt);
|
assert.equal(got.blockCnt, s.blockCnt);
|
||||||
assert.equal(got.hp, Math.round(ally.hp));
|
assert.equal(got.hp, Math.round(ally.hp));
|
||||||
assert.equal(got.base.atk, Math.round(ally.base.atk));
|
// the unit's own numbers: its base with its 练度 (0.2.2: the match states 精英2 Lv.60 by default — ×1.1)
|
||||||
assert.equal(got.base.maxHp, Math.round(ally.base.maxHp));
|
assert.equal(ally.cultivate, 3);
|
||||||
|
assert.equal(got.base.atk, Math.round(ally.base.atk * ally.cultMul.atk));
|
||||||
|
assert.equal(got.base.maxHp, Math.round(ally.base.maxHp * ally.cultMul.hp));
|
||||||
assert.equal(r.runner.unitStats(ally.id, start.fieldId)?.id, ally.id, 'on the named field');
|
assert.equal(r.runner.unitStats(ally.id, start.fieldId)?.id, ally.id, 'on the named field');
|
||||||
assert.equal(r.runner.unitStats(ally.id, 'n:someone_else'), null, 'another field: nothing');
|
assert.equal(r.runner.unitStats(ally.id, 'n:someone_else'), null, 'another field: nothing');
|
||||||
assert.equal(r.runner.unitStats(999999), null, 'unknown unit');
|
assert.equal(r.runner.unitStats(999999), null, 'unknown unit');
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
// test/potential.test.js — potential (潜能 1–6) is a per-player runtime input (0.2.2, the owner's decision of 2026-10-08:
|
||||||
|
// 「调配干员里自己设置吧,默认满潜满加成」): the data records are built at full potential and carry what every lower
|
||||||
|
// potential changes (shared/potential.js; docs/DATA.md §2.3 — `potDown` leaves, chained talent entries). These tests
|
||||||
|
// check the composition at every potential against the raw official tables (character_table keyframes plus the
|
||||||
|
// `potentialRanks` steps up to the potential, talent candidates up to `requiredPotentialRank`), the summons at their
|
||||||
|
// owner's potential, and the data model (full potential = the record itself; a resolved record carries no annotation).
|
||||||
|
// With the official-data cache (.cache/gamedata) the re-derivations run; the spot checks always do.
|
||||||
|
// Run: node --test test/potential.test.js
|
||||||
|
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFileSync, existsSync } from 'node:fs';
|
||||||
|
import { join, dirname } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import {
|
||||||
|
atPotential, atRank, talentAtRank, stripPotential, hasPotentialData, rankOf, isPotential, isCultivate, cultivateMul,
|
||||||
|
cultivatedStats, cultivationOf, CULTIVATE_EFFECTS, POTENTIAL_DEFAULT, CULTIVATE_DEFAULT, FULL_RANK,
|
||||||
|
} from '../shared/potential.js';
|
||||||
|
import { composeTalents } from '../shared/loadoutRecord.js';
|
||||||
|
|
||||||
|
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
const DATA = process.env.DATA_DIR || join(ROOT, 'data');
|
||||||
|
const CACHE = join(ROOT, '.cache', 'gamedata');
|
||||||
|
const HAS_CACHE = ['activity_table', 'character_table'].every((f) => existsSync(join(CACHE, 'excel', `${f}.json`)));
|
||||||
|
const load = (name) => JSON.parse(readFileSync(join(DATA, `${name}.json`), 'utf8'));
|
||||||
|
const chess = load('chess');
|
||||||
|
const tokens = load('tokens');
|
||||||
|
const backups = load('backups');
|
||||||
|
const effects = load('effects');
|
||||||
|
const raw = (f) => JSON.parse(readFileSync(join(CACHE, 'excel', `${f}.json`), 'utf8'));
|
||||||
|
const talent = (rec, name) => rec.talents.find((t) => t.name === name);
|
||||||
|
|
||||||
|
test('刺玫 at every potential: 潜能1 ATK 413 / cost 17 / 70 s and 土壤基肥改良 8%, each step at its rank, 潜能6 = the record itself', () => {
|
||||||
|
const v = chess.chess_char_1_06_a;
|
||||||
|
const at = (p) => atPotential(v, p);
|
||||||
|
const row = (p) => [at(p).stats.atk, at(p).stats.cost, at(p).stats.respawnTime, talent(at(p), '土壤基肥改良').bb.heal_scale];
|
||||||
|
assert.deepEqual(row(1), [413, 17, 70, 1.08]);
|
||||||
|
assert.deepEqual(row(2), [413, 16, 70, 1.08], '潜能2 部署费用-1');
|
||||||
|
assert.deepEqual(row(3), [413, 16, 66, 1.08], '潜能3 再部署时间-4秒');
|
||||||
|
assert.deepEqual(row(4), [435, 16, 66, 1.08], '潜能4 攻击力+22');
|
||||||
|
assert.deepEqual(row(5), [435, 16, 66, 1.11], '潜能5 天赋效果增强');
|
||||||
|
assert.deepEqual(row(6), [435, 15, 66, 1.11], '潜能6 部署费用-1');
|
||||||
|
assert.equal(at(6), v, 'full potential is the record itself');
|
||||||
|
assert.equal(at(null), v);
|
||||||
|
assert.equal(talent(at(1), '土壤基肥改良').desc, '攻击范围内生命上限最高的友方角色受到的治疗效果提升8%');
|
||||||
|
// the elite: statsBase and stats move together, the talent from its own E2 candidates
|
||||||
|
const e = chess.chess_char_1_06_b;
|
||||||
|
assert.deepEqual([atPotential(e, 1).stats.atk, atPotential(e, 1).statsBase.atk, atPotential(e, 1).stats.cost], [531, 508, 17]);
|
||||||
|
assert.equal(talent(atPotential(e, 4), '土壤基肥改良').bb.heal_scale, 1.15);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a resolved record carries no annotation; the full record minus them is what 0.2.1 shipped (the stats and talents of 潜能6)', () => {
|
||||||
|
let n = 0;
|
||||||
|
for (const c of Object.values(chess)) {
|
||||||
|
if (c.isDiy || !c.charId) continue;
|
||||||
|
for (let p = 1; p <= 5; p++) {
|
||||||
|
const r = atPotential(c, p);
|
||||||
|
assert.notEqual(r, c);
|
||||||
|
assert.equal(JSON.stringify(r).match(/"pot(Down|Min|Below)"/), null, `${c.chessId} @${p}`);
|
||||||
|
}
|
||||||
|
if (hasPotentialData(c)) n++;
|
||||||
|
}
|
||||||
|
assert.ok(n >= 250, `chess records that change below full potential: ${n}`);
|
||||||
|
// composed lists never carry chains (shared/loadoutRecord.js composeTalents)
|
||||||
|
const e = chess.chess_char_1_06_b;
|
||||||
|
assert.equal(JSON.stringify(composeTalents(e.talentsBase, e.modules[0].talentChanges)).includes('potBelow'), false);
|
||||||
|
assert.deepEqual(stripPotential({ a: [{ potMin: 4, potBelow: { x: 1 }, x: 2 }], potDown: { 0: {} } }), { a: [{ x: 2 }] });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('every chess and 自选 form at every potential: the raw keyframes plus the potential steps up to it (character_table)', { skip: !HAS_CACHE && 'no .cache/gamedata' }, () => {
|
||||||
|
const CT = raw('character_table');
|
||||||
|
const KEY = { MAX_HP: 'maxHp', ATK: 'atk', DEF: 'def', MAGIC_RESISTANCE: 'res', COST: 'cost', RESPAWN_TIME: 'respawnTime', ATTACK_SPEED: 'aspd' };
|
||||||
|
const steps = (charId, rank) => {
|
||||||
|
const out = {};
|
||||||
|
for (const r of (CT[charId].potentialRanks || []).slice(0, rank)) {
|
||||||
|
for (const m of r.buff?.attributes?.attributeModifiers || []) out[KEY[m.attributeType]] = (out[KEY[m.attributeType]] || 0) + m.value;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
const frame = (charId, phase, level) => {
|
||||||
|
const P = CT[charId].phases[phase];
|
||||||
|
const k0 = P.attributesKeyFrames[0], k1 = P.attributesKeyFrames[P.attributesKeyFrames.length - 1];
|
||||||
|
const t = (level - k0.level) / (k1.level - k0.level || 1);
|
||||||
|
const f = (key) => k0.data[key] + (k1.data[key] - k0.data[key]) * t;
|
||||||
|
return { maxHp: f('maxHp'), atk: f('atk'), def: f('def'), res: f('magicResistance'), cost: f('cost'), respawnTime: f('respawnTime'), aspd: f('attackSpeed') };
|
||||||
|
};
|
||||||
|
const check = (label, charId, status, stats, rank) => {
|
||||||
|
const exp = frame(charId, status.phase, status.level);
|
||||||
|
for (const [k, v] of Object.entries(steps(charId, rank))) exp[k] += v;
|
||||||
|
for (const [k, v] of Object.entries(exp)) assert.ok(Math.abs(v - stats[k]) <= 0.5 + 1e-9, `${label} rank ${rank}: ${k} ${stats[k]} vs official ${v}`);
|
||||||
|
};
|
||||||
|
let n = 0;
|
||||||
|
for (const c of Object.values(chess)) {
|
||||||
|
if (c.isDiy || !c.charId) continue;
|
||||||
|
for (let rank = 0; rank <= FULL_RANK; rank++) { const r = atRank(c, rank); check(c.chessId, c.charId, c.status, r.statsBase ?? r.stats, rank); n++; }
|
||||||
|
}
|
||||||
|
assert.equal(n, 258 * 6);
|
||||||
|
let forms = 0;
|
||||||
|
for (const [charId, u] of Object.entries(backups.units)) {
|
||||||
|
for (const f of Object.values(u.forms)) {
|
||||||
|
for (let rank = 0; rank <= FULL_RANK; rank++) check(`${charId}@${f.status.phase}/${f.status.level}`, charId, f.status, atRank(f, rank).stats, rank);
|
||||||
|
forms++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.equal(forms, 256);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('talent candidates at every potential: each talent is the last one unlocked with requiredPotentialRank ≤ the rank', { skip: !HAS_CACHE && 'no .cache/gamedata' }, () => {
|
||||||
|
const CT = raw('character_table');
|
||||||
|
const PH = { PHASE_0: 0, PHASE_1: 1, PHASE_2: 2 };
|
||||||
|
const ok = (c, phase, level, rank) => {
|
||||||
|
const u = c.unlockCondition;
|
||||||
|
const p = PH[u?.phase ?? 'PHASE_0'];
|
||||||
|
return (c.requiredPotentialRank || 0) <= rank && (p < phase || (p === phase && (u?.level || 1) <= level));
|
||||||
|
};
|
||||||
|
let moved = 0;
|
||||||
|
const checkList = (label, charId, status, list) => {
|
||||||
|
for (let rank = 0; rank <= FULL_RANK; rank++) {
|
||||||
|
(CT[charId].talents || []).forEach((t, index) => {
|
||||||
|
const cands = (t.candidates || []).filter((x) => ok(x, status.phase, status.level, rank));
|
||||||
|
const best = cands[cands.length - 1];
|
||||||
|
if (!best || (!best.name && !best.description && !best.blackboard.length && !best.tokenKey)) return;
|
||||||
|
const rec = talentAtRank(list.find((x) => x.index === index), rank);
|
||||||
|
assert.ok(rec, `${label} talent ${index}`);
|
||||||
|
for (const b of best.blackboard) if (!(b.valueStr && b.value === 0)) assert.ok(Math.abs(rec.bb[b.key] - b.value) < 1e-9, `${label} talent ${index} rank ${rank} ${b.key}: ${rec.bb[b.key]} vs ${b.value}`);
|
||||||
|
if (rank === 0 && (best.requiredPotentialRank || 0) === 0 && (CT[charId].talents[index].candidates || []).some((x) => (x.requiredPotentialRank || 0) > 0 && ok(x, status.phase, status.level, FULL_RANK))) moved++;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for (const c of Object.values(chess)) {
|
||||||
|
if (c.isDiy || !c.charId) continue;
|
||||||
|
checkList(c.chessId, c.charId, c.status, c.talentsBase ?? c.talents);
|
||||||
|
}
|
||||||
|
for (const [charId, u] of Object.entries(backups.units)) for (const f of Object.values(u.forms)) checkList(`${charId}@${f.status.phase}/${f.status.level}`, charId, f.status, f.talents);
|
||||||
|
assert.ok(moved >= 150, `talents whose potential step moves them: ${moved}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('summons follow their owner\'s potential: Mon3tr 不毁重构 3 s / 1200 below 潜能5, 望\'s 棋子 6 deployed / 7 held below 潜能3, “小自在” 15 层', () => {
|
||||||
|
const mon = backups.tokens.token_10002_kalts_mon3tr.variants['char_003_kalts@2/1/4/0'];
|
||||||
|
assert.deepEqual([atPotential(mon, 4).talents[1].bb.stun, atPotential(mon, 4).talents[1].bb.value], [3, 1200]);
|
||||||
|
assert.deepEqual([atPotential(mon, 5).talents[1].bb.stun, atPotential(mon, 5).talents[1].bb.value], [3.5, 1400]);
|
||||||
|
const stone = backups.tokens.token_10064_wang_stone1.variants['char_2027_wang@2/1/4/0'];
|
||||||
|
assert.deepEqual([atPotential(stone, 2).stats.deployLimit, atPotential(stone, 2).stats.deckStack], [6, 7]);
|
||||||
|
assert.deepEqual([atPotential(stone, 3).stats.deployLimit, atPotential(stone, 3).stats.deckStack], [7, 8]);
|
||||||
|
const tvar = backups.tokens.token_10064_wang_stone1.variants['char_2027_wang@2/60/7/1'];
|
||||||
|
assert.equal(atPotential(tvar, 1).byModule.uniequip_002_wang.stats.deployLimit, 7, 'TRP-X: 6 + 1 below 潜能3');
|
||||||
|
assert.equal(tvar.byModule.uniequip_002_wang.stats.deployLimit, 8);
|
||||||
|
// tokens.json: 夕's “小自在” 化境 18 → 15 层 below 潜能5
|
||||||
|
const dusk = tokens.token_10015_dusk_drgn.variants.chess_char_5_12_a;
|
||||||
|
assert.deepEqual([atPotential(dusk, 4).talents[0].bb.max_stack_cnt, dusk.talents[0].bb.max_stack_cnt], [15, 18]);
|
||||||
|
// the owner's own talent text follows too (望's kit reads 「最多拥有N枚」 from it)
|
||||||
|
assert.match(atPotential(backups.units.char_2027_wang.forms['2/60/7/1'], 1).talents[0].desc, /最多拥有7枚/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('练度 (自持有): the CHAR_MAP effects in cultivateEffectList order, their char_attribute_mul as ×ATK / ×DEF / ×HP', { skip: !HAS_CACHE && 'no .cache/gamedata' }, () => {
|
||||||
|
const list = raw('activity_table').autoChessData.cultivateEffectList.slice().sort((a, b) => a.cultivateNum - b.cultivateNum);
|
||||||
|
assert.deepEqual(list.map((x) => x.effectId), [...CULTIVATE_EFFECTS]);
|
||||||
|
list.forEach((row, tier) => {
|
||||||
|
const m = cultivateMul(effects, tier);
|
||||||
|
assert.equal(effects[row.effectId].effectType, 'CHAR_MAP');
|
||||||
|
assert.deepEqual([m.atk, m.def, m.hp].map((x) => Math.round((x - 1) * 100)), [row.atkPer, row.defPer, row.hpPer], row.effectId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('练度 helpers: the four tiers, the default, the products, who has neither (补位 / 原型 自选)', () => {
|
||||||
|
assert.deepEqual([0, 1, 2, 3].map((n) => cultivateMul(effects, n)), [
|
||||||
|
{ atk: 1, def: 1, hp: 1 }, { atk: 1.05, def: 1.05, hp: 1 }, { atk: 1.1, def: 1.05, hp: 1.05 }, { atk: 1.1, def: 1.1, hp: 1.1 },
|
||||||
|
]);
|
||||||
|
assert.equal(cultivateMul(effects, 4), null);
|
||||||
|
assert.equal(cultivateMul(effects, null), null);
|
||||||
|
assert.deepEqual(cultivatedStats({ atk: 435, def: 77, maxHp: 1137, cost: 15 }, cultivateMul(effects, 3)), { atk: 435 * 1.1, def: 77 * 1.1, maxHp: 1137 * 1.1, cost: 15 });
|
||||||
|
assert.deepEqual([POTENTIAL_DEFAULT, CULTIVATE_DEFAULT, rankOf(1), rankOf(6), rankOf(0), rankOf('6')], [6, 3, 0, 5, 5, 5]);
|
||||||
|
assert.deepEqual([isPotential(0), isPotential(1), isPotential(6), isPotential(7), isPotential(2.5)], [false, true, true, false, false]);
|
||||||
|
assert.deepEqual([isCultivate(-1), isCultivate(0), isCultivate(3), isCultivate(4)], [false, true, true, false]);
|
||||||
|
const v = chess.chess_char_1_06_a;
|
||||||
|
assert.deepEqual(cultivationOf(v, null), { potential: 6, cultivate: 3 }, 'no setting: 满潜满加成');
|
||||||
|
assert.deepEqual(cultivationOf(v, { [v.charId]: { potential: 1 } }), { potential: 1, cultivate: 3 });
|
||||||
|
assert.deepEqual(cultivationOf(v, { [v.charId]: { potential: 9, cultivate: 0 } }), { potential: 6, cultivate: 0 }, 'a bad value is the default');
|
||||||
|
assert.equal(cultivationOf({ ...v, standInFor: 'char_x' }, null), null, '补位');
|
||||||
|
assert.equal(cultivationOf({ ...v, diyProto: true }, null), null, '原型 自选');
|
||||||
|
});
|
||||||
@@ -24,7 +24,7 @@ const WTRMAN = 'token_10030_mlyss_wtrman';
|
|||||||
|
|
||||||
test('resolveLoadout: legal choices resolve, anything else falls back to the default', { skip }, () => {
|
test('resolveLoadout: legal choices resolve, anything else falls back to the default', { skip }, () => {
|
||||||
const n = C[INSIDE];
|
const n = C[INSIDE];
|
||||||
assert.deepEqual(resolveLoadout(n, null), { skillIndex: 1, moduleId: null, skillIsDefault: true, moduleIsDefault: true, isDefault: true });
|
assert.deepEqual(resolveLoadout(n, null), { skillIndex: 1, moduleId: null, potential: 6, skillIsDefault: true, moduleIsDefault: true, potentialIsDefault: true, isDefault: true });
|
||||||
assert.equal(resolveLoadout(n, { skillIndex: 0 }).skillIndex, 0);
|
assert.equal(resolveLoadout(n, { skillIndex: 0 }).skillIndex, 0);
|
||||||
assert.equal(resolveLoadout(n, { skill: 0 }).skillIndex, 0, 'client shape { skill, module } accepted');
|
assert.equal(resolveLoadout(n, { skill: 0 }).skillIndex, 0, 'client shape { skill, module } accepted');
|
||||||
assert.equal(resolveLoadout(n, { skillIndex: 2 }).skillIndex, 1, 'S3 is not unlocked at E1 → default');
|
assert.equal(resolveLoadout(n, { skillIndex: 2 }).skillIndex, 1, 'S3 is not unlocked at E1 → default');
|
||||||
@@ -80,7 +80,7 @@ test('getChess(id, loadout): selected skill (bb, SP, trigger) and module (stats,
|
|||||||
assert.equal(s1.skill.id, 'skchr_inside_1');
|
assert.equal(s1.skill.id, 'skchr_inside_1');
|
||||||
assert.deepEqual(s1.skill.bb, C[INSIDE].skills[0].bb);
|
assert.deepEqual(s1.skill.bb, C[INSIDE].skills[0].bb);
|
||||||
assert.equal(s1.skill.spCost, C[INSIDE].skills[0].spCost);
|
assert.equal(s1.skill.spCost, C[INSIDE].skills[0].spCost);
|
||||||
assert.deepEqual(s1.loadout, { skillIndex: 0, moduleId: null, skillIsDefault: false, moduleIsDefault: true, isDefault: false });
|
assert.deepEqual(s1.loadout, { skillIndex: 0, moduleId: null, potential: 6, skillIsDefault: false, moduleIsDefault: true, potentialIsDefault: true, isDefault: false });
|
||||||
assert.deepEqual(s1.stats, def0.stats, 'a skill choice never changes stats');
|
assert.deepEqual(s1.stats, def0.stats, 'a skill choice never changes stats');
|
||||||
assert.ok(Object.isFrozen(s1) && Object.isFrozen(s1.skill.bb), 'variant defs are frozen like every def');
|
assert.ok(Object.isFrozen(s1) && Object.isFrozen(s1.skill.bb), 'variant defs are frozen like every def');
|
||||||
assert.equal(C[INSIDE].skill.skillId, 'skchr_inside_2', 'the raw record is never mutated');
|
assert.equal(C[INSIDE].skill.skillId, 'skchr_inside_2', 'the raw record is never mutated');
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
// test/sim/potential-battle.test.js — potential and 练度 on a battle unit (0.2.2, the owner's decision of 2026-10-08:
|
||||||
|
// 「调配干员里自己设置吧,默认满潜满加成」): a PlayerBattleInput operator entry's `potential` (潜能 1–6) composes its def at
|
||||||
|
// that potential (stats, talents, its summons' talents at the owner's potential), its `cultivate` (练度 0–3: effects.json
|
||||||
|
// aceffect_char_1…4 char_attribute_mul) multiplies ATK / DEF / max HP on top of every other bonus (Πmul, never summed
|
||||||
|
// with the 直接乘算 percentages: PRTS 盟约记录 「…与自持有干员的属性加成独立」); a stand-in and a prototype 自选 pick take
|
||||||
|
// neither. The spec keeps both when well-formed, so the browser and the server field the same unit (docs/SIM.md §12).
|
||||||
|
// Run: node --test test/sim/potential-battle.test.js
|
||||||
|
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { makeBattle } from '../helpers/battleHarness.js';
|
||||||
|
import { buildBattleSpec, createBattleFromSpec, resultDigest } from '../../server/sim/spec.js';
|
||||||
|
import { getDefaultSource } from '../../server/sim/simdata.js';
|
||||||
|
import { unitInfo } from '../../server/sim/snapshot.js';
|
||||||
|
import { unitStatsEntry } from '../../shared/protocol.js';
|
||||||
|
import { tokenStat } from '../../server/sim/content/kits/shared/summoner.js';
|
||||||
|
|
||||||
|
const VENDLA = 'chess_char_1_06_a'; // 刺玫 (PRESET): E1 Lv55, 潜能 6 = ATK 435 / cost 15 / 66 s
|
||||||
|
const T6 = 'chess_char_6_diy1_a';
|
||||||
|
const unit = (h, id) => h.b.allyUnits.find((u) => u.defId === id && u.kind === 'op');
|
||||||
|
const talentBb = (u, name) => u.def.talents.find((t) => t.name === name)?.bb;
|
||||||
|
|
||||||
|
test('potential on a battle unit: the def at that potential (stats, redeploy, cost, talent), 潜能 6 / absent = the data record', () => {
|
||||||
|
const at = (potential) => {
|
||||||
|
const h = makeBattle({ units: [{ chessId: VENDLA, row: 10, col: 5, ...(potential != null ? { potential } : null) }], timeLimit: 1 });
|
||||||
|
const u = unit(h, VENDLA);
|
||||||
|
return [u.base.atk, u.base.cost, u.base.respawnTime, talentBb(u, '土壤基肥改良').heal_scale, u.def.loadout.potential];
|
||||||
|
};
|
||||||
|
assert.deepEqual(at(1), [413, 17, 70, 1.08, 1]);
|
||||||
|
assert.deepEqual(at(3), [413, 16, 66, 1.08, 3]);
|
||||||
|
assert.deepEqual(at(5), [435, 16, 66, 1.11, 5]);
|
||||||
|
assert.deepEqual(at(6), [435, 15, 66, 1.11, 6]);
|
||||||
|
assert.deepEqual(at(null), [435, 15, 66, 1.11, 6], 'absent: full potential');
|
||||||
|
// the elite with another module: statsBase at the potential + the module's attr
|
||||||
|
const h = makeBattle({ units: [{ chessId: 'chess_char_1_06_b', moduleId: 'none', potential: 1, row: 10, col: 5 }], timeLimit: 1 });
|
||||||
|
assert.deepEqual([unit(h, 'chess_char_1_06_b').base.atk, unit(h, 'chess_char_1_06_b').base.cost], [508, 17]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('练度 on a battle unit: ×ATK / ×DEF / ×max HP of its tier, a multiplier of its own on top of the percentages', () => {
|
||||||
|
const field = (cultivate, extra = {}) => {
|
||||||
|
const h = makeBattle({ units: [{ chessId: VENDLA, row: 10, col: 5, ...(cultivate != null ? { cultivate } : null), ...extra }], timeLimit: 1 });
|
||||||
|
return unit(h, VENDLA);
|
||||||
|
};
|
||||||
|
const raw = field(null);
|
||||||
|
assert.deepEqual([raw.s.atk, raw.s.def, raw.s.maxHp, raw.cultivate, raw.cultMul], [435, 77, 1137, null, null], 'absent: no 练度');
|
||||||
|
const t = (c) => { const u = field(c); return [u.s.atk, u.s.def, u.s.maxHp]; };
|
||||||
|
assert.deepEqual(t(0), [435, 77, 1137], '未精英化 +0%');
|
||||||
|
assert.deepEqual(t(1), [435 * 1.05, 77 * 1.05, 1137], '精英1: ATK / DEF +5%');
|
||||||
|
assert.deepEqual(t(2), [435 * 1.1, 77 * 1.05, 1137 * 1.05], '精英2: ATK +10%, DEF / HP +5%');
|
||||||
|
assert.deepEqual(t(3), [435 * 1.1, 77 * 1.1, 1137 * 1.1], '精英2 Lv.60: +10% each');
|
||||||
|
// with a 直接乘算 bonus (+20 % ATK) the tier multiplies the result — never 1 + 0.2 + 0.1
|
||||||
|
const u = field(3);
|
||||||
|
u.buffs.push({ key: 'test:atk', mods: { atkPct: 0.2 }, timeLeft: Infinity, duration: Infinity, stacks: 1, maxStacks: 1 });
|
||||||
|
u.markDirty();
|
||||||
|
assert.equal(u.s.atk, 435 * 1.2 * 1.1);
|
||||||
|
// the deployed unit starts at its multiplied max HP; the card's own numbers include the tier
|
||||||
|
const h = makeBattle({ units: [{ chessId: VENDLA, row: 10, col: 5, cultivate: 3 }], timeLimit: 3 });
|
||||||
|
h.run(1);
|
||||||
|
const d = unit(h, VENDLA);
|
||||||
|
assert.ok(d.alive && Math.abs(d.hp - 1137 * 1.1) < 1e-9, `HP ${d.hp}`);
|
||||||
|
const e = unitStatsEntry(d, d.s);
|
||||||
|
assert.deepEqual([e.atk, e.base.atk, e.maxHp, e.base.maxHp, e.def, e.base.def], [479, 479, 1251, 1251, 85, 85], 'no green delta for the tier');
|
||||||
|
assert.deepEqual([unitInfo(d).cultivate, unitInfo(d).potential], [3, undefined]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a 补位 stand-in and a prototype 自选 pick take neither; an owned 自选 pick takes both', () => {
|
||||||
|
const h = makeBattle({
|
||||||
|
units: [
|
||||||
|
{ chessId: 'chess_char_3_21_a', standIn: true, potential: 1, cultivate: 3, row: 10, col: 3 },
|
||||||
|
{ diy: { slot: 6, charId: 'char_609_acguad' }, potential: 1, cultivate: 3, row: 10, col: 5 },
|
||||||
|
{ diy: { slot: 'chess_char_6_diy2_a', charId: 'char_003_kalts', skillIndex: 0 }, potential: 1, cultivate: 3, row: 11, col: 5 },
|
||||||
|
],
|
||||||
|
timeLimit: 1,
|
||||||
|
});
|
||||||
|
const ops = h.b.allyUnits.filter((u) => u.kind === 'op');
|
||||||
|
const st = ops.find((u) => u.def.standInFor);
|
||||||
|
const proto = ops.find((u) => u.def.charId === 'char_609_acguad');
|
||||||
|
const kalts = ops.find((u) => u.def.charId === 'char_003_kalts');
|
||||||
|
assert.deepEqual([st.cultMul, st.def.loadout.potentialIsDefault ?? true], [null, true], 'stand-in');
|
||||||
|
assert.deepEqual([proto.cultMul, proto.def.loadout.potential], [null, 6], 'prototype');
|
||||||
|
// 凯尔希 潜能1: E2 Lv1 ATK 392 / cost 20 / 70 s (潜能 6: 417 / 18 / 66), ×1.1
|
||||||
|
assert.deepEqual([kalts.base.atk, kalts.base.cost, kalts.base.respawnTime, kalts.def.loadout.potential], [392, 20, 70, 1]);
|
||||||
|
assert.equal(kalts.s.atk, 392 * 1.1);
|
||||||
|
// the spec drops both on a stand-in entry (and on summons)
|
||||||
|
const spec = buildBattleSpec({ players: [{ playerId: 'p1', units: [
|
||||||
|
{ uid: 1, kind: 'chess', chessId: 'chess_char_3_21_a', standIn: true, potential: 2, cultivate: 1, row: 10, col: 3 },
|
||||||
|
{ uid: 2, kind: 'token', tokenId: 'token_10028_vigil_wolf', potential: 2, cultivate: 1, row: 10, col: 4, ownerUid: 1 },
|
||||||
|
] }] });
|
||||||
|
for (const u of spec.players[0].units) assert.deepEqual(['potential' in u, 'cultivate' in u], [false, false], u.kind);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('summons follow their owner\'s potential: Mon3tr\'s 不毁重构 and 望\'s 棋子 deploy limit / holding', () => {
|
||||||
|
const ds = getDefaultSource();
|
||||||
|
const owner = (potential) => ds.getChess(T6, { diy: { charId: 'char_003_kalts', skillIndex: 0 }, potential });
|
||||||
|
const mon = (p) => ds.getToken('token_10002_kalts_mon3tr', T6, owner(p).loadout).talents.find((t) => t.name === '不毁重构').bb;
|
||||||
|
assert.deepEqual([mon(4).stun, mon(4).value], [3, 1200]);
|
||||||
|
assert.deepEqual([mon(5).stun, mon(5).value], [3.5, 1400]);
|
||||||
|
assert.deepEqual([mon(null).stun, mon(null).value], [3.5, 1400]);
|
||||||
|
// 望 (tier-6 slot, normal form E2 Lv1): her 棋子 hold 7 / deploy 6 below 潜能3, 8 / 7 from it (the kit's tokenStat)
|
||||||
|
const wang = (potential) => {
|
||||||
|
const h = makeBattle({ units: [{ diy: { slot: 6, charId: 'char_2027_wang', skillIndex: 0 }, potential, row: 10, col: 5 }], timeLimit: 1 });
|
||||||
|
const u = h.b.allyUnits.find((x) => x.kind === 'op');
|
||||||
|
return [tokenStat(h.b, u, 'token_10064_wang_stone1', 'deployLimit'), tokenStat(h.b, u, 'token_10064_wang_stone1', 'deckStack')];
|
||||||
|
};
|
||||||
|
assert.deepEqual(wang(2), [6, 7]);
|
||||||
|
assert.deepEqual(wang(3), [7, 8]);
|
||||||
|
// a chess owner (tokens.json): 夕's “小自在” 化境 15 层 below 潜能5
|
||||||
|
const dusk = (p) => ds.getToken('token_10015_dusk_drgn', 'chess_char_5_12_a', ds.getChess('chess_char_5_12_a', { potential: p }).loadout).talents[0].bb.max_stack_cnt;
|
||||||
|
assert.deepEqual([dusk(4), dusk(5)], [15, 18]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('spec round trip: potential / 练度 kept when well-formed, dropped otherwise; the JSON spec fields the same battle', () => {
|
||||||
|
const units = [
|
||||||
|
{ uid: 1, kind: 'chess', chessId: VENDLA, potential: 2, cultivate: 1, row: 10, col: 5 },
|
||||||
|
{ uid: 2, kind: 'chess', chessId: 'chess_char_1_02_a', potential: 7, cultivate: 4, row: 10, col: 3 },
|
||||||
|
{ uid: 3, kind: 'chess', chessId: 'chess_char_1_14_a', potential: '3', cultivate: 2.5, row: 11, col: 3 },
|
||||||
|
];
|
||||||
|
const spec = buildBattleSpec({ battleId: 'b1', fieldId: 'n:p1', kind: 'normal', seed: 7, stageId: 'act2autochess_m01', timeLimit: 30,
|
||||||
|
players: [{ playerId: 'p1', units }], spawns: [{ time: 1, enemyKey: 'enemy_1007_slime', routeIndex: 0, count: 3, interval: 1 }] });
|
||||||
|
const [a, b, c] = spec.players[0].units;
|
||||||
|
assert.deepEqual([a.potential, a.cultivate], [2, 1]);
|
||||||
|
assert.deepEqual(['potential' in b, 'cultivate' in b, 'potential' in c, 'cultivate' in c], [false, false, false, false]);
|
||||||
|
const run = (s) => {
|
||||||
|
const battle = createBattleFromSpec(s, undefined, { quiet: true });
|
||||||
|
battle.start();
|
||||||
|
const u = battle.allyUnits.find((x) => x.uid === 1);
|
||||||
|
const out = [u.base.atk, u.base.cost, u.s.atk, u.s.def];
|
||||||
|
while (!battle.finished) battle.step();
|
||||||
|
return { out, digest: resultDigest(battle.result()).hash };
|
||||||
|
};
|
||||||
|
const direct = run(spec);
|
||||||
|
const wire = run(JSON.parse(JSON.stringify(spec)));
|
||||||
|
assert.deepEqual(direct.out, [413, 16, 413 * 1.05, 77 * 1.05]);
|
||||||
|
assert.deepEqual(wire, direct);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('two players field the same chess at different potentials and 练度 on one field: each unit is exact', () => {
|
||||||
|
const players = [
|
||||||
|
{ playerId: 'p1', side: 'L', colOffset: 0, units: [{ uid: 1, kind: 'chess', chessId: VENDLA, potential: 1, cultivate: 0, row: 10, col: 5 }] },
|
||||||
|
{ playerId: 'p2', side: 'L', colOffset: 8, units: [{ uid: 2, kind: 'chess', chessId: VENDLA, potential: 6, cultivate: 3, row: 10, col: 5 }] },
|
||||||
|
];
|
||||||
|
const battle = createBattleFromSpec(buildBattleSpec({ kind: 'unite', seed: 3, stageId: 'act2autochess_m01', players, timeLimit: 10 }), undefined, { quiet: true });
|
||||||
|
battle.start();
|
||||||
|
const [u1, u2] = [1, 2].map((uid) => battle.allyUnits.find((x) => x.uid === uid));
|
||||||
|
assert.deepEqual([u1.base.atk, u1.s.atk, u1.base.cost], [413, 413, 17]);
|
||||||
|
assert.deepEqual([u2.base.atk, u2.s.atk, u2.base.cost], [435, 435 * 1.1, 15]);
|
||||||
|
});
|
||||||
@@ -52,7 +52,7 @@ describe('user playtest #2 item 1 — loadout chosen in the UI fights in the loc
|
|||||||
await c.click('.lo-search input');
|
await c.click('.lo-search input');
|
||||||
await c.page.keyboard.type('野鬃');
|
await c.page.keyboard.type('野鬃');
|
||||||
await c.page.waitForFunction(() => document.querySelectorAll('.lo-card').length === 1, { timeout: 5000 });
|
await c.page.waitForFunction(() => document.querySelectorAll('.lo-card').length === 1, { timeout: 5000 });
|
||||||
await c.click('.lo-card');
|
await c.click('.lo-card__pick');
|
||||||
await c.click('.lo-detail .lo-skill[data-skill="0"]');
|
await c.click('.lo-detail .lo-skill[data-skill="0"]');
|
||||||
await c.click('.lo-detail .lo-mod[data-module="none"]');
|
await c.click('.lo-detail .lo-mod[data-module="none"]');
|
||||||
await c.page.waitForFunction(() => document.querySelector('.lo-skill.is-on[data-skill="0"]') && document.querySelector('.lo-mod.is-on[data-module="none"]'), { timeout: 3000 });
|
await c.page.waitForFunction(() => document.querySelector('.lo-skill.is-on[data-skill="0"]') && document.querySelector('.lo-mod.is-on[data-module="none"]'), { timeout: 3000 });
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
// Quick loadout previews use the same generated records as the in-match detail, without changing a loadout.
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { quickSkillTags } from '../../public/js/ui/loadoutModel.js';
|
||||||
|
|
||||||
|
const chess = JSON.parse(readFileSync(new URL('../../data/chess.json', import.meta.url), 'utf8'));
|
||||||
|
const operator = (name) => Object.values(chess).find((c) => c.name === name && !c.isGolden && !c.isDiy);
|
||||||
|
|
||||||
|
test('quick skill previews show the generated normal/elite SP and duration', () => {
|
||||||
|
const base = operator('空弦');
|
||||||
|
const elite = chess[base.goldenId];
|
||||||
|
for (const c of [base, elite]) {
|
||||||
|
for (const s of c.skills) {
|
||||||
|
const tags = quickSkillTags(s);
|
||||||
|
assert.equal(tags.recovery, '攻回');
|
||||||
|
assert.equal(tags.init, s.initSp);
|
||||||
|
assert.equal(tags.cost, s.spCost);
|
||||||
|
assert.equal(tags.duration, s.duration > 0 ? `${s.duration}s` : '瞬发');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deployment skills have no SP cycle, while permanent passives remain passive (in either language)', () => {
|
||||||
|
for (const name of ['缄默德克萨斯', '砾', '斯卡蒂', '野鬃']) {
|
||||||
|
const c = operator(name);
|
||||||
|
for (const form of [c, chess[c.goldenId]]) {
|
||||||
|
for (const s of form.skills.filter((s) => s.spType === 'ON_DEPLOY')) {
|
||||||
|
const tags = quickSkillTags({ ...s, desc: 'localized text', descRaw: '' });
|
||||||
|
assert.equal(tags.recovery, '—', `${name} S${s.index + 1}`);
|
||||||
|
assert.equal(tags.init, null);
|
||||||
|
assert.equal(tags.cost, null);
|
||||||
|
assert.equal(tags.duration, `${s.duration > 0 ? s.duration : s.bb.duration}s`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const passive = quickSkillTags(operator('星熊').skills[1]);
|
||||||
|
assert.equal(passive.recovery, '被动');
|
||||||
|
assert.equal(passive.duration, '常驻');
|
||||||
|
assert.equal(passive.cost, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ammo and infinite skills keep their duration semantics', () => {
|
||||||
|
assert.equal(quickSkillTags({ skillType: 'MANUAL', spType: 'INCREASE_WITH_TIME', durationType: 'AMMO' }).duration, '弹药');
|
||||||
|
assert.equal(quickSkillTags({ skillType: 'MANUAL', spType: 'INCREASE_WITH_TIME', duration: -1 }).duration, '∞');
|
||||||
|
assert.equal(quickSkillTags(null).recovery, '—');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('loadout attributes use the in-match block and keep obtain/rest triggers for both forms', async () => {
|
||||||
|
globalThis.fetch = async (url) => {
|
||||||
|
try {
|
||||||
|
const value = JSON.parse(readFileSync(new URL(`../../data/${String(url).split('/').pop()}`, import.meta.url), 'utf8'));
|
||||||
|
return { ok: true, json: async () => value };
|
||||||
|
} catch { return { ok: false, status: 404 }; }
|
||||||
|
};
|
||||||
|
const { data } = await import('../../public/js/data.js');
|
||||||
|
const { LoadoutGarrisons } = await import('../../public/js/screens/loadout.js');
|
||||||
|
const { GarrisonBlock } = await import('../../public/js/ui/detailPanel.js');
|
||||||
|
await data.loadAll('chess', 'garrisons');
|
||||||
|
function* walk(v) {
|
||||||
|
if (Array.isArray(v)) { for (const n of v) yield* walk(n); return; }
|
||||||
|
if (!v || typeof v !== 'object') return;
|
||||||
|
yield v;
|
||||||
|
yield* walk(v.props?.children);
|
||||||
|
}
|
||||||
|
for (const [name, trigger] of [['空弦', '<进入休整期时>'], ['星熊', '<获得时>'], ['缄默德克萨斯', '<进入休整期时>']]) {
|
||||||
|
const base = operator(name);
|
||||||
|
for (const rec of [base, chess[base.goldenId]]) {
|
||||||
|
const nodes = [...walk(LoadoutGarrisons({ chess: rec, m: null }))].filter((n) => n.type === GarrisonBlock);
|
||||||
|
assert.equal(nodes.length, rec.garrisonIds.length);
|
||||||
|
for (const node of nodes) {
|
||||||
|
const original = data.lookup('garrisons', rec.garrisonIds[0]);
|
||||||
|
assert.deepEqual(node.props.garrison, original);
|
||||||
|
const raw = [...walk(GarrisonBlock(node.props))].find((n) => n.type?.name === 'RichText').props.text;
|
||||||
|
assert.ok(raw.startsWith(trigger), `${name}: ${raw}`);
|
||||||
|
assert.equal(raw, original.descRaw || original.desc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
// test/ui/loadout-rows.test.js — the 干员调配 roster as one list (0.2.2): the quick choices of PR #301 (by @farridge) laid
|
||||||
|
// out as one row per operator with aligned columns instead of three columns of cards (the owner's decision of 2026-10-08:
|
||||||
|
// 「三个竖排有点乱影响观感」), and the per-operator 潜能 / 练度 controls (「调配干员里自己设置吧,默认满潜满加成」): the row's skill
|
||||||
|
// / module buttons and selects (state, labels, what a tap sets), the detail's section, a 自选 slot's selects, the 局内数值 at
|
||||||
|
// the settings, and the css that keeps the columns aligned and the detail sliding over the list below 1000 px. Hook-free
|
||||||
|
// views drawn as vnode trees. (The browser side: test/ui/loadout.e2e.test.js.)
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||||
|
globalThis.fetch = async (url) => {
|
||||||
|
const name = String(url).split('/').pop();
|
||||||
|
try { const body = readFileSync(path.join(ROOT, 'data', name), 'utf8'); return { ok: true, status: 200, json: async () => JSON.parse(body) }; } catch { return { ok: false, status: 404, json: async () => ({}) }; }
|
||||||
|
};
|
||||||
|
const { RosterRow, RosterHead, statsPreview, LoadoutStats, ModuleInfo } = await import('../../public/js/screens/loadout.js');
|
||||||
|
const { CultivationSelects, CultivationSection, cultivateName } = await import('../../public/js/screens/cultivation.js');
|
||||||
|
const { DiyPanelView } = await import('../../public/js/screens/diy.js');
|
||||||
|
const { data } = await import('../../public/js/data.js');
|
||||||
|
await data.loadAll('chess', 'garrisons', 'assets', 'bonds', 'effects', 'backups');
|
||||||
|
|
||||||
|
const get = (id) => data.lookup('chess', id);
|
||||||
|
const INSIDE = 'chess_char_1_01_a'; // 隐现: S1 / S2 (default S2), MAR-X
|
||||||
|
const VENDLA = 'chess_char_1_06_a'; // 刺玫
|
||||||
|
const CHAR = (id) => get(id).charId;
|
||||||
|
|
||||||
|
function* walk(v) {
|
||||||
|
if (Array.isArray(v)) { for (const x of v) yield* walk(x); return; }
|
||||||
|
if (!v || typeof v !== 'object') return;
|
||||||
|
yield v;
|
||||||
|
if (typeof v.type === 'function' && ['QuickSkill', 'QuickModule', 'CultivationSelects', 'SlotCard', 'KindTag', 'Bonds'].includes(v.type.name)) { yield* walk(v.type(v.props)); return; }
|
||||||
|
yield* walk(v.props?.children);
|
||||||
|
}
|
||||||
|
const hasClass = (v, c) => typeof v?.props?.class === 'string' && v.props.class.split(/\s+/).includes(c);
|
||||||
|
const find = (tree, pred) => [...walk(tree)].filter(pred);
|
||||||
|
const textOf = (v) => (v == null || typeof v === 'boolean' ? '' : typeof v !== 'object' ? String(v) : Array.isArray(v) ? v.map(textOf).join('') : textOf(v.props?.children));
|
||||||
|
|
||||||
|
const row = (props) => RosterRow({ m: null, chess: get(INSIDE), golden: get(get(INSIDE).goldenId), entries: {}, ops: {}, selected: false, onPick: () => {}, onChange: () => {}, onOps: () => {}, ...props });
|
||||||
|
|
||||||
|
test('a row: portrait / name pick, three skill slots (an empty one for a two-skill chess), 不装备 first, the equipped ones pressed', () => {
|
||||||
|
const picks = [];
|
||||||
|
const changes = [];
|
||||||
|
const r = row({ onPick: (id) => picks.push(id), onChange: (id, p) => changes.push([id, p]) });
|
||||||
|
assert.ok(hasClass(r, 'lo-card') && hasClass(r, 'lo-card--t1'));
|
||||||
|
assert.equal(r.props['data-chess'], INSIDE);
|
||||||
|
const pick = find(r, (v) => hasClass(v, 'lo-card__pick'))[0];
|
||||||
|
pick.props.onClick();
|
||||||
|
assert.deepEqual(picks, [INSIDE]);
|
||||||
|
const skills = find(r, (v) => hasClass(v, 'lo-q--skill'));
|
||||||
|
assert.deepEqual(skills.map((b) => [b.props['data-skill'], b.props['aria-pressed']]), [[0, 'false'], [1, 'true']], 'S2 is the default');
|
||||||
|
assert.equal(find(r, (v) => hasClass(v, 'lo-q--empty')).length, 1, 'the third slot stays empty');
|
||||||
|
assert.match(skills[0].props['aria-label'], /^S1 · “不惹麻烦” · /);
|
||||||
|
const mods = find(r, (v) => hasClass(v, 'lo-q--mod'));
|
||||||
|
assert.deepEqual(mods.map((b) => [b.props['data-module'], b.props['aria-pressed']]), [['none', 'false'], ['uniequip_002_inside', 'true']]);
|
||||||
|
skills[0].props.onClick();
|
||||||
|
mods[0].props.onClick();
|
||||||
|
assert.deepEqual(changes, [[INSIDE, { skill: 0 }], [INSIDE, { module: 'none' }]]);
|
||||||
|
assert.equal(find(r, (v) => hasClass(v, 'lo-card__flag')).length, 0, 'nothing changed: no flag');
|
||||||
|
// the shared preview: 精锐 shows the Lv.7 SP numbers of the same skill
|
||||||
|
const elite = get(get(INSIDE).goldenId).skills.find((s) => s.index === 0);
|
||||||
|
const e = find(row({ level: 'elite' }), (v) => hasClass(v, 'lo-q--skill'))[0];
|
||||||
|
assert.match(e.props['aria-label'], new RegExp(`消耗 ${elite.spCost}`));
|
||||||
|
assert.equal(row({ level: 'elite' }).props['data-variant'], 'elite');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a row\'s 潜能 / 练度 selects: the defaults quiet, a changed one marked; a change sets the operator\'s settings', () => {
|
||||||
|
const sets = [];
|
||||||
|
const r = row({ onOps: (id, p) => sets.push([id, p]) });
|
||||||
|
const sel = find(r, (v) => v.type === 'select');
|
||||||
|
assert.deepEqual(sel.map((s) => [s.props['data-cult'], s.props.value]), [['potential', '6'], ['cultivate', '3']]);
|
||||||
|
assert.equal(find(r, (v) => hasClass(v, 'is-off')).length, 0, 'the defaults are not marked');
|
||||||
|
sel[0].props.onChange({ currentTarget: { value: '2' } });
|
||||||
|
sel[1].props.onChange({ currentTarget: { value: '0' } });
|
||||||
|
assert.deepEqual(sets, [[CHAR(INSIDE), { potential: 2 }], [CHAR(INSIDE), { cultivate: 0 }]]);
|
||||||
|
const options = find(sel[0], (v) => v.type === 'option').map((o) => textOf(o));
|
||||||
|
assert.deepEqual(options, ['潜能 1', '潜能 2', '潜能 3', '潜能 4', '潜能 5', '潜能 6']);
|
||||||
|
assert.deepEqual(find(sel[1], (v) => v.type === 'option').map((o) => textOf(o)), ['未精英化', '精英1', '精英2', '精英2 Lv.60']);
|
||||||
|
// changed: the select is marked, the row flagged and counted as changed
|
||||||
|
const c = row({ ops: { [CHAR(INSIDE)]: { potential: 2 } } });
|
||||||
|
assert.ok(hasClass(c, 'is-changed'));
|
||||||
|
assert.equal(find(c, (v) => hasClass(v, 'lo-card__flag')).length, 1);
|
||||||
|
assert.deepEqual(find(c, (v) => hasClass(v, 'is-off')).map((v) => v.props.class.includes('lo-cult__pot')), [true]);
|
||||||
|
// a chess marked not owned: the 替补 tag, the settings say they do not apply now
|
||||||
|
const s = row({ notOwned: true });
|
||||||
|
assert.ok(hasClass(s, 'is-standin'));
|
||||||
|
assert.equal(textOf(find(s, (v) => hasClass(v, 'lo-card__sub'))[0]), '替补');
|
||||||
|
assert.match(find(s, (v) => hasClass(v, 'lo-cult'))[0].props.title, /替补干员没有潜能与练度/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the head names the four columns; CultivationSelects without an operator draws nothing', () => {
|
||||||
|
assert.deepEqual(find(RosterHead(), (v) => hasClass(v, 'lo-list__h')).map((v) => textOf(v)), ['干员', '技能', '模组精锐', '潜能 · 练度']);
|
||||||
|
assert.equal(CultivationSelects({ charId: null, ops: {}, onSet: () => {} }), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the detail\'s section: 潜能 1–6 and the four official 练度 tiers, the tier\'s effect, the stand-in note', () => {
|
||||||
|
const sets = [];
|
||||||
|
const id = CHAR(VENDLA);
|
||||||
|
const sec = CultivationSection({ charId: id, ops: { [id]: { potential: 3 } }, onSet: (c, p) => sets.push([c, p]) });
|
||||||
|
const pots = find(sec, (v) => v.props && v.props['data-potential'] != null);
|
||||||
|
assert.deepEqual(pots.map((b) => [b.props['data-potential'], b.props['aria-checked']]), [[1, 'false'], [2, 'false'], [3, 'true'], [4, 'false'], [5, 'false'], [6, 'false']]);
|
||||||
|
const tiers = find(sec, (v) => v.type === 'button' && v.props['data-cultivate'] != null);
|
||||||
|
assert.deepEqual(tiers.map((b) => textOf(b)), ['未精英化', '精英阶段1', '精英阶段2', '精英阶段2-60级'], 'effects.json names');
|
||||||
|
assert.equal(tiers[3].props['aria-checked'], 'true', 'the default tier');
|
||||||
|
assert.match(textOf(find(sec, (v) => hasClass(v, 'lo-cult__eff'))[0]), /精英阶段2-60级 攻击力、防御力和最大生命值\+10%/);
|
||||||
|
pots[0].props.onClick();
|
||||||
|
tiers[1].props.onClick();
|
||||||
|
assert.deepEqual(sets, [[id, { potential: 1 }], [id, { cultivate: 1 }]]);
|
||||||
|
assert.equal(cultivateName(0), '未精英化');
|
||||||
|
// the rule line says what an operator with nothing set fights at — the default, 满潜能 / 精英2 Lv.60 — and that an unowned
|
||||||
|
// 特许 operator at the official 潜能1 / 未精英化 is the player's own setting (Grok review of the branch: it read as automatic)
|
||||||
|
const note = textOf(find(sec, (v) => hasClass(v, 'lo-cult__note'))[0]);
|
||||||
|
assert.match(note, /^默认满潜能、精英2 Lv\.60(满加成)。/);
|
||||||
|
assert.match(note, /未持有的特许干员在官方按潜能1、没有加成,要照官方打请手动设为潜能1、未精英化。$/);
|
||||||
|
const st = CultivationSection({ charId: id, ops: {}, onSet: () => {}, standIn: true });
|
||||||
|
assert.ok(hasClass(st, 'is-moot'));
|
||||||
|
assert.match(textOf(find(st, (v) => hasClass(v, 'lo-cult__note'))[0]), /替补干员没有潜能与练度/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('自选编队: an owned pick\'s card carries its operator\'s selects, a prototype says it has neither', () => {
|
||||||
|
const sets = [];
|
||||||
|
const picks = { chess_char_6_diy1_a: { charId: 'char_003_kalts', skillIndex: 0 }, chess_char_5_diy1_a: { charId: 'char_609_acguad' } };
|
||||||
|
const tree = DiyPanelView({ m: null, picks, legal: picks, kitted: null, onSet: () => {}, picking: null, onPicking: () => {}, ops: { char_003_kalts: { cultivate: 1 } }, onOps: (c, p) => sets.push([c, p]) });
|
||||||
|
const cult = find(tree, (v) => hasClass(v, 'diy-slot__cult'));
|
||||||
|
assert.equal(cult.length, 2);
|
||||||
|
const sel = find(tree, (v) => v.type === 'select');
|
||||||
|
assert.deepEqual(sel.map((s) => s.props.value), ['6', '1'], '凯尔希: 潜能 6, 精英1');
|
||||||
|
sel[0].props.onChange({ currentTarget: { value: '4' } });
|
||||||
|
assert.deepEqual(sets, [['char_003_kalts', { potential: 4 }]]);
|
||||||
|
assert.ok(cult.some((v) => textOf(v) === '原型干员没有潜能与练度'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('局内数值 at the operator\'s settings: 刺玫 at 潜能1 / 未精英化 and at the defaults (×1.1)', () => {
|
||||||
|
const v = get(VENDLA);
|
||||||
|
const g = get(v.goldenId);
|
||||||
|
const pv = (ops, level = 'normal') => statsPreview(v, g, {}, level, get, ops).record.stats;
|
||||||
|
assert.deepEqual([pv({ [v.charId]: { potential: 1, cultivate: 0 } }).atk, pv({ [v.charId]: { potential: 1, cultivate: 0 } }).cost], [413, 17]);
|
||||||
|
assert.equal(pv({}).atk, 435 * 1.1, 'none set: 满潜 + 精英2 Lv.60');
|
||||||
|
assert.equal(pv({ [v.charId]: { potential: 1, cultivate: 0 } }, 'elite').atk, 508 + 23, 'the elite: statsBase at 潜能1 + INC-X');
|
||||||
|
assert.ok(statsPreview(v, g, {}, 'normal', get, {}).lo.cultivation);
|
||||||
|
// the caption says the numbers carry 潜能 and 练度 (an owned operator; effects.json loaded)
|
||||||
|
const cap = (tree) => textOf(find(tree, (n) => hasClass(n, 'lo-stats__cap'))[0]);
|
||||||
|
assert.match(cap(LoadoutStats({ base: v, golden: g, entries: {}, level: 'elite', onLevel() {}, getChess: get, ops: {} })), /^数值含所选模组;含潜能与练度;不含技能发动/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the module card reads its talent changes at the operator\'s 潜能, as 局内数值 and the battle do (余 闲云隐市: 2%(+0.5%) / 1.5%)', () => {
|
||||||
|
// (Grok review of the branch: the card printed the full-potential line whatever the setting)
|
||||||
|
const g = get('chess_char_6_03_b');
|
||||||
|
const mod = g.modules.find((x) => x.uniEquipId === 'uniequip_002_yu');
|
||||||
|
const opt = { id: mod.uniEquipId, rec: mod, isDefault: !!mod.isDefault };
|
||||||
|
const texts = (tree) => find(tree, (v) => typeof v.type === 'function' && v.type.name === 'RichText').map((v) => String(v.props.text));
|
||||||
|
const full = texts(ModuleInfo({ m: null, golden: g, opt }));
|
||||||
|
assert.ok(full.some((x) => x.includes('生命上限2%<@ba.talpu>(+0.5%)</>')), 'full potential (none given)');
|
||||||
|
assert.deepEqual(texts(ModuleInfo({ m: null, golden: g, opt, potential: 6 })), full);
|
||||||
|
const p1 = texts(ModuleInfo({ m: null, golden: g, opt, potential: 1 }));
|
||||||
|
assert.ok(p1.some((x) => x.includes('生命上限1.5%的生命')), '潜能1');
|
||||||
|
assert.ok(!p1.some((x) => x.includes('(+0.5%)')));
|
||||||
|
assert.deepEqual(texts(ModuleInfo({ m: null, golden: g, opt: { id: 'none', rec: null }, potential: 1 })), [g.traitBase.descRaw], '不装备: the base 特性');
|
||||||
|
// 圣约送葬人 REA-Y (GitHub #400): 特性 heals 50 per enemy hit as in battle; 特性追加 is the module's ASPD line
|
||||||
|
const ex = get('chess_char_5_01_b');
|
||||||
|
const rea = ex.modules.find((x) => x.uniEquipId === 'uniequip_003_excu2');
|
||||||
|
const lines = texts(ModuleInfo({ m: null, golden: ex, opt: { id: rea.uniEquipId, rec: rea }, potential: 6 }));
|
||||||
|
assert.ok(lines.some((x) => x.includes('每攻击到一个敌人回复自身<@ba.kw>50</>生命')), lines.join(' | '));
|
||||||
|
assert.ok(lines.some((x) => x === '攻击范围内存在2名及以上敌人时攻击速度<@ba.kw>+12</>'));
|
||||||
|
// the detail hands the operator's 潜能 to it
|
||||||
|
const src = readFileSync(path.join(ROOT, 'public/js/screens/loadout.js'), 'utf8');
|
||||||
|
assert.match(src, /<\$\{ModuleInfo\} m=\$\{m\} golden=\$\{golden\} opt=\$\{modOpt\} potential=\$\{opsOf\(ops, chess\.charId\)\.potential\} \/>/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('css: the selected skill\'s frame is square around its icon (GitHub #398 / PR #399): no img max-width clamp, fill beats `.lo-sicon img`', () => {
|
||||||
|
// theme.css `img { max-width: 100% }` held the frame's width to the icon's while its height grew by .1rem (36 × 40 px
|
||||||
|
// on a phone, 70 × 80 at 1920 px), and the more specific `.lo-sicon img { object-fit: cover }` cropped its side lines
|
||||||
|
const css = readFileSync(path.join(ROOT, 'public/css/screens/loadout.css'), 'utf8');
|
||||||
|
const rule = css.match(/^([^{}\n]*\.lo-sicon__outline) \{([^}]*)\}/m);
|
||||||
|
assert.ok(rule, 'the frame rule');
|
||||||
|
assert.equal(rule[1], '.lo-sicon .lo-sicon__outline', 'more specific than `.lo-sicon img`');
|
||||||
|
assert.match(rule[2], /inset: -\.05rem; width: calc\(100% \+ \.1rem\) !important; height: calc\(100% \+ \.1rem\) !important;/);
|
||||||
|
assert.match(rule[2], /max-width: none;/);
|
||||||
|
assert.match(rule[2], /object-fit: fill;/);
|
||||||
|
assert.match(css, /\.lo-sicon img \{ width: 100%; height: 100%; object-fit: cover;/, 'the icon itself still covers');
|
||||||
|
assert.match(readFileSync(path.join(ROOT, 'public/css/theme.css'), 'utf8'), /\bimg \{ max-width: 100%; \}/, 'the global clamp the frame opts out of');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('css: the head and the rows share one grid (columns aligned), quick targets grow on a coarse pointer, the detail slides over below 1000 px', () => {
|
||||||
|
const css = readFileSync(path.join(ROOT, 'public/css/screens/loadout.css'), 'utf8');
|
||||||
|
assert.match(css, /\.lo-list__head, \.lo \.lo-card \{ display: grid; grid-template-columns: var\(--cols\);/);
|
||||||
|
assert.match(css, /\.sp-coarse \.lo-list \{ --q: max\(\.44rem, 40px\); \}/);
|
||||||
|
const block = css.slice(css.indexOf('@media (max-width: 1000px) {\n .lo-body'));
|
||||||
|
assert.match(block, /\.lo-body \{ grid-template-columns: minmax\(0, 1fr\); \}/);
|
||||||
|
assert.match(block, /\.lo-detail-wrap \{ display: none; \}/);
|
||||||
|
assert.ok(!/\.lo-grid\b/.test(css), 'the old card grid is gone');
|
||||||
|
});
|
||||||
@@ -256,7 +256,7 @@ test('place in the detail: after the skills, before the modules; the first .lo-s
|
|||||||
const mods = detail.indexOf('<section class="lo-sec lo-sec--mod">');
|
const mods = detail.indexOf('<section class="lo-sec lo-sec--mod">');
|
||||||
assert.ok(toggle > 0 && skills > toggle && stats > skills && mods > stats, 'skills (and their level toggle) → 局内数值 → modules');
|
assert.ok(toggle > 0 && skills > toggle && stats > skills && mods > stats, 'skills (and their level toggle) → 局内数值 → modules');
|
||||||
// one record resolution, shared with the card: chessLoadout, the card's block, trait and talent helpers — no stat arithmetic of its own
|
// one record resolution, shared with the card: chessLoadout, the card's block, trait and talent helpers — no stat arithmetic of its own
|
||||||
assert.match(src, /import \{ chessStatsBlock, traitText, chessTalents \} from '\.\.\/ui\/detailPanel\.js';/);
|
assert.match(src, /import \{ chessStatsBlock, traitText, chessTalents, GarrisonBlock \} from '\.\.\/ui\/detailPanel\.js';/);
|
||||||
assert.match(src, /import \{ chessLoadout \} from '\.\.\/ui\/gameLogic\.js';/);
|
assert.match(src, /import \{ chessLoadout \} from '\.\.\/ui\/gameLogic\.js';/);
|
||||||
const fn = src.slice(src.indexOf('export function statsPreview'), src.indexOf('export function LoadoutStats'));
|
const fn = src.slice(src.indexOf('export function statsPreview'), src.indexOf('export function LoadoutStats'));
|
||||||
assert.ok(!/composeStats|attr\b|maxHp|statsBase/.test(fn), 'no stat arithmetic in the screen');
|
assert.ok(!/composeStats|attr\b|maxHp|statsBase/.test(fn), 'no stat arithmetic in the screen');
|
||||||
|
|||||||
+72
-28
@@ -101,7 +101,7 @@ describe('干员调配 overlay (real server, headless Chrome)', { skip: !ENABLED
|
|||||||
// search → one card
|
// search → one card
|
||||||
await page.type('.lo-search input', '隐现');
|
await page.type('.lo-search input', '隐现');
|
||||||
await page.waitForFunction(() => document.querySelectorAll('.lo-card').length === 1, { timeout: 5000 });
|
await page.waitForFunction(() => document.querySelectorAll('.lo-card').length === 1, { timeout: 5000 });
|
||||||
await page.click('.lo-card');
|
await page.click('.lo-card__pick');
|
||||||
await page.waitForSelector('.lo-detail .lo-skill[data-skill="0"]', { visible: true });
|
await page.waitForSelector('.lo-detail .lo-skill[data-skill="0"]', { visible: true });
|
||||||
await page.click('.lo-detail .lo-skill[data-skill="0"]');
|
await page.click('.lo-detail .lo-skill[data-skill="0"]');
|
||||||
await page.click('.lo-detail .lo-mod[data-module="none"]');
|
await page.click('.lo-detail .lo-mod[data-module="none"]');
|
||||||
@@ -199,7 +199,7 @@ describe('干员调配 overlay (real server, headless Chrome)', { skip: !ENABLED
|
|||||||
// one edit, so there is something to export
|
// one edit, so there is something to export
|
||||||
await page.type('.lo-search input', '隐现');
|
await page.type('.lo-search input', '隐现');
|
||||||
await page.waitForFunction(() => document.querySelectorAll('.lo-card').length === 1, { timeout: 5000 });
|
await page.waitForFunction(() => document.querySelectorAll('.lo-card').length === 1, { timeout: 5000 });
|
||||||
await page.click('.lo-card');
|
await page.click('.lo-card__pick');
|
||||||
await page.waitForSelector('.lo-detail .lo-skill[data-skill="0"]', { visible: true });
|
await page.waitForSelector('.lo-detail .lo-skill[data-skill="0"]', { visible: true });
|
||||||
await page.click('.lo-detail .lo-skill[data-skill="0"]');
|
await page.click('.lo-detail .lo-skill[data-skill="0"]');
|
||||||
const stored = await page.evaluate(() => JSON.parse(localStorage.getItem('sp.pref.loadout')));
|
const stored = await page.evaluate(() => JSON.parse(localStorage.getItem('sp.pref.loadout')));
|
||||||
@@ -268,7 +268,7 @@ describe('干员调配 overlay (real server, headless Chrome)', { skip: !ENABLED
|
|||||||
await page.waitForSelector('.lo .lo-card', { visible: true, timeout: 15000 });
|
await page.waitForSelector('.lo .lo-card', { visible: true, timeout: 15000 });
|
||||||
await page.type('.lo-search input', '隐现');
|
await page.type('.lo-search input', '隐现');
|
||||||
await page.waitForFunction(() => document.querySelectorAll('.lo-card').length === 1, { timeout: 5000 });
|
await page.waitForFunction(() => document.querySelectorAll('.lo-card').length === 1, { timeout: 5000 });
|
||||||
await page.click('.lo-card');
|
await page.click('.lo-card__pick');
|
||||||
await page.waitForSelector('.lo-detail .lo-skill[data-skill="0"]', { visible: true });
|
await page.waitForSelector('.lo-detail .lo-skill[data-skill="0"]', { visible: true });
|
||||||
// choose S1, close and confirm at once — well inside the sync's debounce
|
// choose S1, close and confirm at once — well inside the sync's debounce
|
||||||
await page.click('.lo-detail .lo-skill[data-skill="0"]');
|
await page.click('.lo-detail .lo-skill[data-skill="0"]');
|
||||||
@@ -331,7 +331,7 @@ describe('干员调配 overlay (real server, headless Chrome)', { skip: !ENABLED
|
|||||||
await page.setViewport({ width: 1920, height: 1080 });
|
await page.setViewport({ width: 1920, height: 1080 });
|
||||||
await page.type('.lo-search input', '隐现');
|
await page.type('.lo-search input', '隐现');
|
||||||
await page.waitForFunction(() => document.querySelectorAll('.lo-card').length === 1, { timeout: 5000 });
|
await page.waitForFunction(() => document.querySelectorAll('.lo-card').length === 1, { timeout: 5000 });
|
||||||
await page.click('.lo-card');
|
await page.click('.lo-card__pick');
|
||||||
await page.waitForSelector('.lo-detail .lo-skill[data-skill="0"]', { visible: true });
|
await page.waitForSelector('.lo-detail .lo-skill[data-skill="0"]', { visible: true });
|
||||||
// edit, and the briefing ends before the debounced send (every human ready: the requests stand in for the timer)
|
// edit, and the briefing ends before the debounced send (every human ready: the requests stand in for the timer)
|
||||||
await page.click('.lo-detail .lo-skill[data-skill="0"]');
|
await page.click('.lo-detail .lo-skill[data-skill="0"]');
|
||||||
@@ -355,7 +355,7 @@ describe('干员调配 overlay (real server, headless Chrome)', { skip: !ENABLED
|
|||||||
await page.waitForSelector('.lo .lo-card', { visible: true, timeout: 15000 });
|
await page.waitForSelector('.lo .lo-card', { visible: true, timeout: 15000 });
|
||||||
await page.type('.lo-search input', '烛煌');
|
await page.type('.lo-search input', '烛煌');
|
||||||
await page.waitForFunction(() => document.querySelectorAll('.lo-card').length === 1, { timeout: 5000 });
|
await page.waitForFunction(() => document.querySelectorAll('.lo-card').length === 1, { timeout: 5000 });
|
||||||
await page.click('.lo-card');
|
await page.click('.lo-card__pick');
|
||||||
await page.waitForSelector('.lo-detail .lo-mod[data-module="none"]', { visible: true });
|
await page.waitForSelector('.lo-detail .lo-mod[data-module="none"]', { visible: true });
|
||||||
// wait for every module tile to settle (image loaded or lettered fallback)
|
// wait for every module tile to settle (image loaded or lettered fallback)
|
||||||
await page.waitForFunction(() => [...document.querySelectorAll('.lo-mod:not(.lo-mod--none) .lo-mglyph')]
|
await page.waitForFunction(() => [...document.querySelectorAll('.lo-mod:not(.lo-mod--none) .lo-mglyph')]
|
||||||
@@ -378,36 +378,50 @@ describe('干员调配 overlay (real server, headless Chrome)', { skip: !ENABLED
|
|||||||
await ctx.close();
|
await ctx.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('phone 844×390 (touch): roster + detail side by side, tier / class filters, tap to choose', async () => {
|
test('phone 844×390 (touch): one list with the quick choices (0.2.2), the detail slides over it, tier / class filters, tap to choose', async () => {
|
||||||
const { ctx, page, problems } = await open({ w: 844, h: 390, touch: true });
|
const { ctx, page, problems } = await open({ w: 844, h: 390, touch: true });
|
||||||
await page.tap('.lobby-screen [data-testid="loadout-open"]');
|
await page.tap('.lobby-screen [data-testid="loadout-open"]');
|
||||||
await page.waitForSelector('.lo .lo-card', { visible: true, timeout: 15000 });
|
await page.waitForSelector('.lo .lo-card', { visible: true, timeout: 15000 });
|
||||||
await assertBgLayer(page);
|
await assertBgLayer(page);
|
||||||
await page.screenshot({ path: path.join(OUT, 'loadout-phone.png') });
|
await page.screenshot({ path: path.join(OUT, 'loadout-phone.png') });
|
||||||
// review fix: the shared Button / TextField shrink to 6–7 px text on phones — the overlay's own controls stay readable
|
// review fix: the shared Button / TextField shrink to 6–7 px text on phones — the overlay's own controls stay readable
|
||||||
// and tappable (返回, 全部恢复默认, the detail's 恢复默认, the search input)
|
// and tappable (返回, 全部恢复默认, the search input; the detail's 恢复默认 below)
|
||||||
const ctl = await page.evaluate(() => Object.fromEntries(['.lo-back', '.lo-top__right .btn', '.lo-dhead__reset', '.lo-search .field__input'].map((sel) => {
|
const measure = (sels) => page.evaluate((list) => Object.fromEntries(list.map((sel) => {
|
||||||
const el = document.querySelector(sel);
|
const el = document.querySelector(sel);
|
||||||
const b = el.getBoundingClientRect();
|
const b = el.getBoundingClientRect();
|
||||||
return [sel, { h: Math.round(b.height), fs: parseFloat(getComputedStyle(el).fontSize) }];
|
return [sel, { h: Math.round(b.height), fs: parseFloat(getComputedStyle(el).fontSize) }];
|
||||||
})));
|
})), sels);
|
||||||
for (const [sel, v] of Object.entries(ctl)) {
|
const check = (ctl) => {
|
||||||
assert.ok(v.fs >= 11, `${sel} text ≥ 11 px (${JSON.stringify(v)})`);
|
for (const [sel, v] of Object.entries(ctl)) {
|
||||||
if (sel !== '.lo-search .field__input') assert.ok(v.h >= 28, `${sel} ≥ 28 px tall (${JSON.stringify(v)})`);
|
assert.ok(v.fs >= 11, `${sel} text ≥ 11 px (${JSON.stringify(v)})`);
|
||||||
}
|
if (sel !== '.lo-search .field__input') assert.ok(v.h >= 28, `${sel} ≥ 28 px tall (${JSON.stringify(v)})`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
check(await measure(['.lo-back', '.lo-top__right .btn', '.lo-search .field__input']));
|
||||||
|
// the list takes the whole width (no detail beside it below 1000 px); the quick choices are 40 px touch targets
|
||||||
|
assert.equal(await page.$eval('.lo-detail-wrap', (el) => getComputedStyle(el).display), 'none');
|
||||||
|
const q = await page.$eval('.lo-card .lo-q--skill', (el) => { const b = el.getBoundingClientRect(); return [Math.round(b.width), Math.round(b.height)]; });
|
||||||
|
assert.ok(q[0] >= 40 && q[1] >= 40, `quick targets ${q}`);
|
||||||
|
assert.equal(await page.evaluate(() => document.querySelector('.lo').scrollWidth > innerWidth + 1), false, 'no horizontal overflow');
|
||||||
await page.tap('.lo-chip--t6');
|
await page.tap('.lo-chip--t6');
|
||||||
await page.waitForFunction(() => [...document.querySelectorAll('.lo-card')].every((c) => c.classList.contains('lo-card--t6')));
|
await page.waitForFunction(() => [...document.querySelectorAll('.lo-card')].every((c) => c.classList.contains('lo-card--t6')));
|
||||||
await page.tap('.lo-chip--prof[title="狙击"]');
|
await page.tap('.lo-chip--prof[title="狙击"]');
|
||||||
await page.waitForFunction(() => document.querySelectorAll('.lo-card').length >= 1);
|
await page.waitForFunction(() => document.querySelectorAll('.lo-card').length >= 1);
|
||||||
await page.tap('.lo-card');
|
// a quick choice keeps the list
|
||||||
|
await page.tap('.lo-card .lo-q--skill[data-skill="0"]');
|
||||||
|
assert.notEqual(await page.$eval('.lo-roster', (el) => getComputedStyle(el).display), 'none', 'a quick choice keeps the list');
|
||||||
|
await page.tap('.lo-card__pick');
|
||||||
await page.waitForSelector('.lo-detail .lo-skill', { visible: true });
|
await page.waitForSelector('.lo-detail .lo-skill', { visible: true });
|
||||||
assert.notEqual(await page.$eval('.lo-roster', (el) => getComputedStyle(el).display), 'none', 'side by side');
|
assert.equal(await page.$eval('.lo-roster', (el) => getComputedStyle(el).display), 'none', 'the detail covers the list');
|
||||||
|
check(await measure(['.lo-dhead__reset']));
|
||||||
// every skill option is reachable inside the viewport (the detail body scrolls)
|
// every skill option is reachable inside the viewport (the detail body scrolls)
|
||||||
const r = await page.$eval('.lo-detail .lo-skill:last-child', (el) => { el.scrollIntoView({ block: 'nearest' }); const b = el.getBoundingClientRect(); return { top: b.top, bottom: b.bottom }; });
|
const r = await page.$eval('.lo-detail .lo-skill:last-child', (el) => { el.scrollIntoView({ block: 'nearest' }); const b = el.getBoundingClientRect(); return { top: b.top, bottom: b.bottom }; });
|
||||||
assert.ok(r.top >= 0 && r.bottom <= 390, JSON.stringify(r));
|
assert.ok(r.top >= 0 && r.bottom <= 390, JSON.stringify(r));
|
||||||
await page.tap('.lo-detail .lo-skill:last-child');
|
await page.tap('.lo-detail .lo-skill:last-child');
|
||||||
await page.waitForFunction(() => document.querySelector('.lo-detail .lo-skill:last-child').classList.contains('is-on'));
|
await page.waitForFunction(() => document.querySelector('.lo-detail .lo-skill:last-child').classList.contains('is-on'));
|
||||||
await page.screenshot({ path: path.join(OUT, 'loadout-phone-detail.png') });
|
await page.screenshot({ path: path.join(OUT, 'loadout-phone-detail.png') });
|
||||||
|
await page.tap('.lo-detail-back');
|
||||||
|
await page.waitForSelector('.lo-roster', { visible: true });
|
||||||
assert.deepEqual(problems, []);
|
assert.deepEqual(problems, []);
|
||||||
await ctx.close();
|
await ctx.close();
|
||||||
});
|
});
|
||||||
@@ -417,7 +431,7 @@ describe('干员调配 overlay (real server, headless Chrome)', { skip: !ENABLED
|
|||||||
await page.tap('.lobby-screen [data-testid="loadout-open"]');
|
await page.tap('.lobby-screen [data-testid="loadout-open"]');
|
||||||
await page.waitForSelector('.lo .lo-card', { visible: true, timeout: 15000 });
|
await page.waitForSelector('.lo .lo-card', { visible: true, timeout: 15000 });
|
||||||
assert.equal(await page.$eval('.lo-detail-wrap', (el) => getComputedStyle(el).display), 'none');
|
assert.equal(await page.$eval('.lo-detail-wrap', (el) => getComputedStyle(el).display), 'none');
|
||||||
await page.tap('.lo-card');
|
await page.tap('.lo-card__pick');
|
||||||
await page.waitForSelector('.lo-detail .lo-skill', { visible: true });
|
await page.waitForSelector('.lo-detail .lo-skill', { visible: true });
|
||||||
assert.equal(await page.$eval('.lo-roster', (el) => getComputedStyle(el).display), 'none', 'detail covers the roster');
|
assert.equal(await page.$eval('.lo-roster', (el) => getComputedStyle(el).display), 'none', 'detail covers the roster');
|
||||||
await page.screenshot({ path: path.join(OUT, 'loadout-small-phone.png') });
|
await page.screenshot({ path: path.join(OUT, 'loadout-small-phone.png') });
|
||||||
@@ -430,6 +444,8 @@ describe('干员调配 overlay (real server, headless Chrome)', { skip: !ENABLED
|
|||||||
// ---- 局内数值 (GitHub issue #64) -------------------------------------------------------------------------------------
|
// ---- 局内数值 (GitHub issue #64) -------------------------------------------------------------------------------------
|
||||||
const CHESS = () => JSON.parse(readFileSync(path.join(ROOT, 'data/chess.json'), 'utf8'));
|
const CHESS = () => JSON.parse(readFileSync(path.join(ROOT, 'data/chess.json'), 'utf8'));
|
||||||
const fmt = (n) => Math.round(n).toLocaleString('en-US');
|
const fmt = (n) => Math.round(n).toLocaleString('en-US');
|
||||||
|
// 0.2.2: the numbers carry the 练度 — none set: 精英2 Lv.60, effects.json aceffect_char_4 (×1.1 ATK / DEF / max HP)
|
||||||
|
const T4 = JSON.parse(readFileSync(path.join(ROOT, 'data/effects.json'), 'utf8')).aceffect_char_4.buffs.find((b) => b.key === 'char_attribute_mul').bb;
|
||||||
/** The section's eight stats: label → shown value. */
|
/** The section's eight stats: label → shown value. */
|
||||||
const shownStats = (page) => page.$$eval('.lo-sec--stats .dstat', (els) => Object.fromEntries(els.map((e) => [e.querySelector('.dstat__k').textContent, e.querySelector('.dstat__v').textContent])));
|
const shownStats = (page) => page.$$eval('.lo-sec--stats .dstat', (els) => Object.fromEntries(els.map((e) => [e.querySelector('.dstat__k').textContent, e.querySelector('.dstat__v').textContent])));
|
||||||
const rangeTiles = (page) => page.$$eval('.lo-sec--stats .rgrid i.on', (els) => els.length);
|
const rangeTiles = (page) => page.$$eval('.lo-sec--stats .rgrid i.on', (els) => els.length);
|
||||||
@@ -438,7 +454,7 @@ describe('干员调配 overlay (real server, headless Chrome)', { skip: !ENABLED
|
|||||||
await page.keyboard.press('Backspace');
|
await page.keyboard.press('Backspace');
|
||||||
await page.type('.lo-search input', name);
|
await page.type('.lo-search input', name);
|
||||||
await page.waitForFunction((n) => document.querySelectorAll('.lo-card').length === 1 && document.querySelector('.lo-card .lo-card__name')?.textContent === n, { timeout: 5000 }, name);
|
await page.waitForFunction((n) => document.querySelectorAll('.lo-card').length === 1 && document.querySelector('.lo-card .lo-card__name')?.textContent === n, { timeout: 5000 }, name);
|
||||||
await page.click('.lo-card');
|
await page.click('.lo-card__pick');
|
||||||
await page.waitForSelector('.lo-sec--stats .dstat', { visible: true });
|
await page.waitForSelector('.lo-sec--stats .dstat', { visible: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -452,25 +468,25 @@ describe('干员调配 overlay (real server, headless Chrome)', { skip: !ENABLED
|
|||||||
await page.waitForSelector('.lo .lo-card', { visible: true, timeout: 15000 });
|
await page.waitForSelector('.lo .lo-card', { visible: true, timeout: 15000 });
|
||||||
await pickChess(page, '隐现');
|
await pickChess(page, '隐现');
|
||||||
// between the skills and the modules, 精锐 shown first: the default module's numbers, the 3 × 4 range
|
// between the skills and the modules, 精锐 shown first: the default module's numbers, the 3 × 4 range
|
||||||
assert.deepEqual(await page.$$eval('.lo-detail__body > .lo-sec > header h3', (els) => els.map((e) => e.firstChild.textContent)), ['技能', '局内数值', '模组']);
|
assert.deepEqual(await page.$$eval('.lo-detail__body > .lo-sec > header h3', (els) => els.map((e) => e.firstChild.textContent)), ['潜能与练度', '技能', '局内数值', '模组']);
|
||||||
const stats = await shownStats(page);
|
const stats = await shownStats(page);
|
||||||
assert.deepEqual(Object.keys(stats), ['生命上限', '攻击', '防御', '法术抗性', '攻击间隔', '阻挡数', '部署费用', '再部署']);
|
assert.deepEqual(Object.keys(stats), ['生命上限', '攻击', '防御', '法术抗性', '攻击间隔', '阻挡数', '部署费用', '再部署']);
|
||||||
assert.equal(stats['生命上限'], fmt(golden.statsBase.maxHp + mod.attr.maxHp));
|
assert.equal(stats['生命上限'], fmt((golden.statsBase.maxHp + mod.attr.maxHp) * T4.max_hp));
|
||||||
assert.equal(stats['攻击'], fmt(golden.statsBase.atk + mod.attr.atk));
|
assert.equal(stats['攻击'], fmt((golden.statsBase.atk + mod.attr.atk) * T4.atk));
|
||||||
assert.equal(await rangeTiles(page), base.rangeGrid.length);
|
assert.equal(await rangeTiles(page), base.rangeGrid.length);
|
||||||
assert.equal(await page.$eval('.lo-sec--stats', (el) => el.dataset.variant), 'elite');
|
assert.equal(await page.$eval('.lo-sec--stats', (el) => el.dataset.variant), 'elite');
|
||||||
await page.screenshot({ path: path.join(OUT, 'loadout-stats-desktop.png') });
|
await page.screenshot({ path: path.join(OUT, 'loadout-stats-desktop.png') });
|
||||||
// 不装备 → the base numbers; the skill choice changes nothing; 普通 → the normal chess; both toggles are independent
|
// 不装备 → the base numbers; the skill choice changes nothing; 普通 → the normal chess; both toggles are independent
|
||||||
await page.click('.lo-detail .lo-mod[data-module="none"]');
|
await page.click('.lo-detail .lo-mod[data-module="none"]');
|
||||||
await page.waitForFunction((v) => document.querySelector('.lo-sec--stats .dstat__v')?.textContent === v, {}, fmt(golden.statsBase.maxHp));
|
await page.waitForFunction((v) => document.querySelector('.lo-sec--stats .dstat__v')?.textContent === v, {}, fmt(golden.statsBase.maxHp * T4.max_hp));
|
||||||
assert.equal((await shownStats(page))['攻击'], fmt(golden.statsBase.atk));
|
assert.equal((await shownStats(page))['攻击'], fmt(golden.statsBase.atk * T4.atk));
|
||||||
await page.click('.lo-detail .lo-skill[data-skill="0"]');
|
await page.click('.lo-detail .lo-skill[data-skill="0"]');
|
||||||
assert.equal((await shownStats(page))['攻击'], fmt(golden.statsBase.atk), 'a skill is not a stat');
|
assert.equal((await shownStats(page))['攻击'], fmt(golden.statsBase.atk * T4.atk), 'a skill is not a stat');
|
||||||
await page.click('.lo-sec--stats .lo-seg button[data-variant="normal"]');
|
await page.click('.lo-sec--stats .lo-seg button[data-variant="normal"]');
|
||||||
await page.waitForFunction((v) => document.querySelector('.lo-sec--stats .dstat__v')?.textContent === v, {}, fmt(base.stats.maxHp));
|
await page.waitForFunction((v) => document.querySelector('.lo-sec--stats .dstat__v')?.textContent === v, {}, fmt(base.stats.maxHp * T4.max_hp));
|
||||||
assert.equal(await page.$eval('.lo-sec--stats', (el) => el.dataset.variant), 'normal');
|
assert.equal(await page.$eval('.lo-sec--stats', (el) => el.dataset.variant), 'normal');
|
||||||
assert.equal(await page.$eval('.lo-skill.is-on', (el) => el.dataset.skill), '0');
|
assert.equal(await page.$eval('.lo-skill.is-on', (el) => el.dataset.skill), '0');
|
||||||
assert.equal(await page.$eval('.lo-seg button.is-on', (el) => el.textContent.startsWith('普通')), true, 'the skill level toggle is its own');
|
assert.equal(await page.$eval('.lo-sec:not(.lo-sec--cult) .lo-seg button.is-on', (el) => el.textContent.startsWith('普通')), true, 'the skill level toggle is its own');
|
||||||
// the layout: no sideways overflow, the range box beside the numbers
|
// the layout: no sideways overflow, the range box beside the numbers
|
||||||
const box = await page.evaluate(() => {
|
const box = await page.evaluate(() => {
|
||||||
const body = document.querySelector('.lo-detail__body');
|
const body = document.querySelector('.lo-detail__body');
|
||||||
@@ -501,7 +517,7 @@ describe('干员调配 overlay (real server, headless Chrome)', { skip: !ENABLED
|
|||||||
await page.waitForSelector('.lo .lo-card', { visible: true, timeout: 15000 });
|
await page.waitForSelector('.lo .lo-card', { visible: true, timeout: 15000 });
|
||||||
await page.type('.lo-search input', '隐现');
|
await page.type('.lo-search input', '隐现');
|
||||||
await page.waitForFunction(() => document.querySelectorAll('.lo-card').length === 1, { timeout: 5000 });
|
await page.waitForFunction(() => document.querySelectorAll('.lo-card').length === 1, { timeout: 5000 });
|
||||||
await page.tap('.lo-card');
|
await page.tap('.lo-card__pick');
|
||||||
await page.waitForSelector('.lo-sec--stats .dstat', { visible: true });
|
await page.waitForSelector('.lo-sec--stats .dstat', { visible: true });
|
||||||
await page.$eval('.lo-sec--stats', (el) => el.scrollIntoView({ block: 'start' }));
|
await page.$eval('.lo-sec--stats', (el) => el.scrollIntoView({ block: 'start' }));
|
||||||
const m = await page.evaluate(() => {
|
const m = await page.evaluate(() => {
|
||||||
@@ -529,8 +545,36 @@ describe('干员调配 overlay (real server, headless Chrome)', { skip: !ENABLED
|
|||||||
await page.screenshot({ path: path.join(OUT, 'loadout-stats-phone.png') });
|
await page.screenshot({ path: path.join(OUT, 'loadout-stats-phone.png') });
|
||||||
// a choice made further down updates it (touch: the module card, 不装备)
|
// a choice made further down updates it (touch: the module card, 不装备)
|
||||||
await page.tap('.lo-detail .lo-mod[data-module="none"]');
|
await page.tap('.lo-detail .lo-mod[data-module="none"]');
|
||||||
await page.waitForFunction((v) => document.querySelector('.lo-sec--stats .dstat__v')?.textContent === v, {}, fmt(golden.statsBase.maxHp));
|
await page.waitForFunction((v) => document.querySelector('.lo-sec--stats .dstat__v')?.textContent === v, {}, fmt(golden.statsBase.maxHp * T4.max_hp));
|
||||||
assert.equal((await shownStats(page))['攻击'], fmt(golden.statsBase.atk));
|
assert.equal((await shownStats(page))['攻击'], fmt(golden.statsBase.atk * T4.atk));
|
||||||
|
assert.deepEqual(problems, []);
|
||||||
|
await ctx.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('潜能 / 练度 (0.2.2): a row\'s selects and the detail\'s buttons set the operator\'s settings — stored, synced, the numbers follow', async () => {
|
||||||
|
const chess = CHESS();
|
||||||
|
const v = chess.chess_char_1_06_a; // 刺玫: 潜能1 ATK 413 / cost 17; elite statsBase 508 at 潜能1
|
||||||
|
const { ctx, page, problems } = await open();
|
||||||
|
await clickSel(page, '.lobby-screen [data-testid="loadout-open"]');
|
||||||
|
await page.waitForSelector('.lo .lo-card', { visible: true, timeout: 15000 });
|
||||||
|
await pickChess(page, '刺玫');
|
||||||
|
// a row's select: 潜能 1
|
||||||
|
await page.select('.lo-card[data-chess="chess_char_1_06_a"] select[data-cult="potential"]', '1');
|
||||||
|
await page.waitForFunction((id) => JSON.parse(localStorage.getItem('sp.pref.loadout') || '{}').ops?.[id]?.potential === 1, {}, v.charId);
|
||||||
|
await page.click('.lo-sec--stats .lo-seg button[data-variant="normal"]');
|
||||||
|
await page.waitForFunction((x) => [...document.querySelectorAll('.lo-sec--stats .dstat')].some((e) => e.querySelector('.dstat__k').textContent === '部署费用' && e.querySelector('.dstat__v').textContent === x), {}, '17');
|
||||||
|
assert.equal((await shownStats(page))['攻击'], fmt(413 * T4.atk), '潜能1 at 精英2 Lv.60');
|
||||||
|
// the detail's 练度 buttons: 未精英化 → ×1
|
||||||
|
await page.click('.lo-sec--cult [data-cultivate="0"]');
|
||||||
|
await page.waitForFunction(() => document.querySelector('.lo-sec--cult [data-cultivate="0"]').getAttribute('aria-checked') === 'true');
|
||||||
|
assert.equal((await shownStats(page))['攻击'], fmt(413));
|
||||||
|
assert.equal(await page.$eval('.lo-card[data-chess="chess_char_1_06_a"] select[data-cult="cultivate"]', (el) => el.value), '0', 'the row follows');
|
||||||
|
assert.ok(await page.$eval('.lo-card[data-chess="chess_char_1_06_a"]', (el) => el.classList.contains('is-changed')), 'a changed operator');
|
||||||
|
await page.waitForFunction(() => /已同步/.test(document.querySelector('.lo-sync')?.textContent || ''), { timeout: 8000 });
|
||||||
|
// 恢复默认 resets them with the skill / module
|
||||||
|
await page.click('.lo-dhead__reset');
|
||||||
|
await page.waitForFunction((id) => !JSON.parse(localStorage.getItem('sp.pref.loadout') || '{}').ops?.[id], {}, v.charId);
|
||||||
|
assert.equal((await shownStats(page))['攻击'], fmt(v.stats.atk * T4.atk));
|
||||||
assert.deepEqual(problems, []);
|
assert.deepEqual(problems, []);
|
||||||
await ctx.close();
|
await ctx.close();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -43,7 +43,8 @@ test('parseStored: tolerant of junk, keeps structurally valid entries; toStored
|
|||||||
test('exportPayload / serializeExport: versioned envelope, entries copied; parseImport round trip', () => {
|
test('exportPayload / serializeExport: versioned envelope, entries copied; parseImport round trip', () => {
|
||||||
const entries = { [INSIDE]: { skill: 0 }, [SWIRE]: { module: SWIRE_ALT } };
|
const entries = { [INSIDE]: { skill: 0 }, [SWIRE]: { module: SWIRE_ALT } };
|
||||||
const p = exportPayload(entries, { now: Date.UTC(2026, 9, 3, 4, 5, 6) });
|
const p = exportPayload(entries, { now: Date.UTC(2026, 9, 3, 4, 5, 6) });
|
||||||
assert.deepEqual(Object.keys(p).sort(), ['count', 'entries', 'exportedAt', 'kind', 'v'], 'exactly the envelope, no unused field');
|
assert.deepEqual(Object.keys(p).sort(), ['count', 'entries', 'exportedAt', 'kind', 'ops', 'v'], 'exactly the envelope (0.2.2: + the 潜能 / 练度 ops), no unused field');
|
||||||
|
assert.deepEqual(p.ops, {}, 'no settings: an empty map (an import then resets the settings to the defaults)');
|
||||||
assert.equal(p.kind, LOADOUT_EXPORT_KIND);
|
assert.equal(p.kind, LOADOUT_EXPORT_KIND);
|
||||||
assert.equal(p.v, LOADOUT_VERSION);
|
assert.equal(p.v, LOADOUT_VERSION);
|
||||||
assert.equal(p.count, 2);
|
assert.equal(p.count, 2);
|
||||||
@@ -262,7 +263,7 @@ test('sync: welcome sends the sanitised loadout; edits are debounced; identical
|
|||||||
const s = installLoadoutSync({ net, timers: T, target, getChessReady: async () => CHESS, lookupChess: get });
|
const s = installLoadoutSync({ net, timers: T, target, getChessReady: async () => CHESS, lookupChess: get });
|
||||||
net.emit('welcome', {});
|
net.emit('welcome', {});
|
||||||
await T.advance(100);
|
await T.advance(100);
|
||||||
assert.deepEqual(net.sent, [{ t: 'room.loadout', entries: { [INSIDE]: { skill: 0 } } }], 'stale entry dropped');
|
assert.deepEqual(net.sent, [{ t: 'room.loadout', entries: { [INSIDE]: { skill: 0 } }, ops: {} }], 'stale entry dropped (no 潜能 / 练度 set: ops {})');
|
||||||
assert.equal(target.get().sync, 'synced');
|
assert.equal(target.get().sync, 'synced');
|
||||||
target.set({ entries: { [INSIDE]: { skill: 0 }, [SWIRE]: { module: SWIRE_ALT } } });
|
target.set({ entries: { [INSIDE]: { skill: 0 }, [SWIRE]: { module: SWIRE_ALT } } });
|
||||||
target.set({ entries: { [INSIDE]: { skill: 0 }, [SWIRE]: { module: MODULE_NONE } } });
|
target.set({ entries: { [INSIDE]: { skill: 0 }, [SWIRE]: { module: MODULE_NONE } } });
|
||||||
@@ -480,7 +481,7 @@ test('sync: an empty loadout is sent without loading chess.json (no 1.6 MB downl
|
|||||||
const s = installLoadoutSync({ net, timers: T, target, getChessReady: async () => { loads++; return CHESS; }, lookupChess: get, notify: () => {} });
|
const s = installLoadoutSync({ net, timers: T, target, getChessReady: async () => { loads++; return CHESS; }, lookupChess: get, notify: () => {} });
|
||||||
net.emit('welcome', {});
|
net.emit('welcome', {});
|
||||||
await T.advance(100);
|
await T.advance(100);
|
||||||
assert.deepEqual(net.sent, [{ t: 'room.loadout', entries: {} }]);
|
assert.deepEqual(net.sent, [{ t: 'room.loadout', entries: {}, ops: {} }]);
|
||||||
assert.equal(loads, 0);
|
assert.equal(loads, 0);
|
||||||
await net.reply();
|
await net.reply();
|
||||||
target.set({ entries: { [INSIDE]: { skill: 0 } } });
|
target.set({ entries: { [INSIDE]: { skill: 0 } } });
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
// test/ui/potential-ui.test.js — the client side of the per-operator 潜能 / 练度 (0.2.2, the owner's decision of
|
||||||
|
// 2026-10-08: 「调配干员里自己设置吧,默认满潜满加成」): the stored settings (`sp.pref.loadout` { v, entries, ops } — an
|
||||||
|
// older stored loadout has none = the defaults), their helpers (set / reset / sanitise against the operators the server
|
||||||
|
// knows), 导出 / 导入 with them, the sync (`room.loadout { entries, ops }`), and the cards: chessLoadout composes the record
|
||||||
|
// at the operator's potential with its 练度 multiplier (the player's settings, a teammate's unit's own, never a stand-in's),
|
||||||
|
// a 自选 card at its pick's potential.
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
import { cultivationCharIds } from '../../shared/protocol.js';
|
||||||
|
import {
|
||||||
|
parseStored, parseStoredOps, toStored, opsOf, setOps, resetOps, sanitizeOps, exportPayload, parseImport, changedCount,
|
||||||
|
rosterOf, filterRoster,
|
||||||
|
} from '../../public/js/ui/loadoutModel.js';
|
||||||
|
import { installLoadoutSync, SYNC_DEBOUNCE_MS, applyLoadoutEntries, loadoutStore, setEntries, setOpsMap } from '../../public/js/ui/loadoutSync.js';
|
||||||
|
import { createStore } from '../../public/js/store.js';
|
||||||
|
import { chessLoadout, unitCultivation, standInLoadout, standInOf, ownDiyPick, ownDiyRecord, cardDiy } from '../../public/js/ui/gameLogic.js';
|
||||||
|
|
||||||
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||||
|
const load = (n) => JSON.parse(readFileSync(path.join(ROOT, `data/${n}.json`), 'utf8'));
|
||||||
|
const CHESS = load('chess');
|
||||||
|
const BACKUPS = load('backups');
|
||||||
|
const EFFECTS = load('effects');
|
||||||
|
const get = (id) => (Object.hasOwn(CHESS, id) ? CHESS[id] : null);
|
||||||
|
const VENDLA = 'chess_char_1_06_a';
|
||||||
|
const V = CHESS[VENDLA].charId;
|
||||||
|
const KALTS = 'char_003_kalts';
|
||||||
|
const IDS = cultivationCharIds(CHESS, BACKUPS);
|
||||||
|
const isOp = (id) => IDS.has(id);
|
||||||
|
|
||||||
|
test('storage: ops beside the entries; junk / defaults dropped; an older stored loadout has none (the defaults)', () => {
|
||||||
|
assert.deepEqual(parseStoredOps({ v: 1, entries: { [VENDLA]: { skill: 0 } } }), {}, 'a 0.2.1 stored loadout');
|
||||||
|
assert.deepEqual(parseStoredOps(null), {});
|
||||||
|
const raw = { v: 1, entries: {}, ops: { [V]: { potential: 1, cultivate: 0 }, [KALTS]: { potential: 6, cultivate: 3 }, x: 'junk', 'a b': { potential: 1 }, char_y: { potential: 9 }, __proto__: { potential: 1 } } };
|
||||||
|
assert.deepEqual(parseStoredOps(raw), { [V]: { potential: 1, cultivate: 0 } });
|
||||||
|
assert.deepEqual(toStored({ [VENDLA]: { skill: 0 } }, { [V]: { potential: 2 } }), { v: 1, entries: { [VENDLA]: { skill: 0 } }, ops: { [V]: { potential: 2 } } });
|
||||||
|
assert.deepEqual(parseStored(toStored({ [VENDLA]: { skill: 0 } }, { [V]: { potential: 2 } })), { [VENDLA]: { skill: 0 } }, 'the entries read as before');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('helpers: opsOf / setOps / resetOps keep only non-default values; sanitizeOps keeps what the server would', () => {
|
||||||
|
assert.deepEqual(opsOf({}, V), { potential: 6, cultivate: 3, changed: false });
|
||||||
|
let o = setOps({}, V, { potential: 1 });
|
||||||
|
assert.deepEqual(o, { [V]: { potential: 1 } });
|
||||||
|
o = setOps(o, V, { cultivate: 0 });
|
||||||
|
assert.deepEqual(opsOf(o, V), { potential: 1, cultivate: 0, changed: true });
|
||||||
|
o = setOps(o, V, { potential: 6, cultivate: 3 });
|
||||||
|
assert.deepEqual(o, {}, 'back to the defaults: no entry');
|
||||||
|
o = setOps(o, V, { potential: 0 });
|
||||||
|
assert.deepEqual(o, {}, 'an illegal value is ignored');
|
||||||
|
assert.deepEqual(resetOps({ [V]: { potential: 2 }, [KALTS]: { cultivate: 1 } }, V), { [KALTS]: { cultivate: 1 } });
|
||||||
|
assert.deepEqual(sanitizeOps({ [V]: { potential: 2 }, [KALTS]: { cultivate: 1 }, char_609_acguad: { potential: 1 }, char_999_x: { potential: 1 }, [CHESS.chess_char_1_01_a.charId]: { potential: 6 } }, isOp),
|
||||||
|
{ [V]: { potential: 2 }, [KALTS]: { cultivate: 1 } }, 'a prototype / unknown operator / defaults dropped');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('导出 / 导入: the envelope carries ops; an older export keeps the current settings; settings alone import', () => {
|
||||||
|
const p = exportPayload({ [VENDLA]: { skill: 0 } }, { ops: { [V]: { potential: 2 } } });
|
||||||
|
assert.deepEqual(p.ops, { [V]: { potential: 2 } });
|
||||||
|
const back = parseImport(JSON.stringify(p));
|
||||||
|
assert.deepEqual([back.ok, back.entries, back.ops], [true, { [VENDLA]: { skill: 0 } }, { [V]: { potential: 2 } }]);
|
||||||
|
const old = parseImport({ kind: 'stronghold.loadout', v: 1, entries: { [VENDLA]: { skill: 0 } } });
|
||||||
|
assert.equal('ops' in old, false, 'an older export: no ops');
|
||||||
|
assert.equal(parseImport({ v: 1, entries: {}, ops: { [V]: { cultivate: 1 } } }).ok, true, 'settings only');
|
||||||
|
assert.equal(parseImport({ v: 1, entries: {}, ops: {} }).ok, false, 'nothing at all');
|
||||||
|
const before = { entries: loadoutStore.get().entries, ops: loadoutStore.get().ops };
|
||||||
|
try {
|
||||||
|
setOpsMap({ [KALTS]: { cultivate: 1 } });
|
||||||
|
const r1 = applyLoadoutEntries({ [VENDLA]: { skill: 0 } }, get);
|
||||||
|
assert.deepEqual(r1, { applied: 1, dropped: 0 });
|
||||||
|
assert.deepEqual(loadoutStore.get().ops, { [KALTS]: { cultivate: 1 } }, 'no ops in the import: the settings stay');
|
||||||
|
const r2 = applyLoadoutEntries({}, get, { ops: { [V]: { potential: 3 }, char_609_acguad: { potential: 1 } }, isOperator: isOp });
|
||||||
|
assert.deepEqual(r2, { applied: 0, dropped: 1, ops: 1 });
|
||||||
|
assert.deepEqual(loadoutStore.get().ops, { [V]: { potential: 3 } }, 'the imported settings replace the current ones');
|
||||||
|
} finally {
|
||||||
|
setEntries(before.entries);
|
||||||
|
setOpsMap(before.ops);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the 已调整 count and 仅看已调整 include an operator whose 潜能 / 练度 differ', () => {
|
||||||
|
const roster = rosterOf(Object.values(CHESS));
|
||||||
|
assert.equal(changedCount({}, get, { [V]: { potential: 2 } }, roster), 1);
|
||||||
|
assert.equal(changedCount({ [VENDLA]: { skill: 0 } }, get, { [V]: { potential: 2 } }, roster), 1, 'one operator');
|
||||||
|
assert.equal(changedCount({}, get, {}, roster), 0);
|
||||||
|
const only = filterRoster(roster, { changedOnly: true }, {}, get, () => null, { [V]: { cultivate: 0 } });
|
||||||
|
assert.deepEqual(only.map((c) => c.chessId), [VENDLA]);
|
||||||
|
});
|
||||||
|
|
||||||
|
function fakeNet() {
|
||||||
|
const listeners = new Map();
|
||||||
|
const net = {
|
||||||
|
status: 'online', sent: [], replies: [],
|
||||||
|
on(t, fn) { if (!listeners.has(t)) listeners.set(t, new Set()); listeners.get(t).add(fn); return () => listeners.get(t).delete(fn); },
|
||||||
|
emit(t, msg) { for (const fn of listeners.get(t) || []) fn(msg); },
|
||||||
|
request(t, fields) { net.sent.push({ t, ...fields }); return Promise.resolve({ t: 'ok' }); },
|
||||||
|
};
|
||||||
|
return net;
|
||||||
|
}
|
||||||
|
function fakeTimers() {
|
||||||
|
let now = 0; let seq = 0;
|
||||||
|
const q = new Map();
|
||||||
|
return {
|
||||||
|
setTimeout: (fn, ms) => { const id = ++seq; q.set(id, { at: now + ms, fn }); return id; },
|
||||||
|
clearTimeout: (id) => q.delete(id),
|
||||||
|
async advance(ms) {
|
||||||
|
const end = now + ms;
|
||||||
|
for (;;) {
|
||||||
|
const next = [...q.entries()].filter(([, x]) => x.at <= end).sort((a, b) => a[1].at - b[1].at)[0];
|
||||||
|
if (!next) break;
|
||||||
|
q.delete(next[0]); now = next[1].at; next[1].fn();
|
||||||
|
for (let i = 0; i < 8; i++) await Promise.resolve();
|
||||||
|
}
|
||||||
|
now = end;
|
||||||
|
for (let i = 0; i < 8; i++) await Promise.resolve();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('sync: room.loadout carries the sanitised ops; an edit of the settings alone is sent; none set ⇒ ops {} without a download', async () => {
|
||||||
|
const net = fakeNet();
|
||||||
|
const T = fakeTimers();
|
||||||
|
let backups = 0;
|
||||||
|
const target = createStore({ entries: {}, ops: { [V]: { potential: 1 }, char_609_acguad: { potential: 1 } }, open: false, sync: 'idle' });
|
||||||
|
const s = installLoadoutSync({ net, timers: T, target, getChessReady: async () => CHESS, getBackupsReady: async () => { backups++; return BACKUPS; }, lookupChess: get, operatorIds: () => IDS });
|
||||||
|
net.emit('welcome', {});
|
||||||
|
await T.advance(100);
|
||||||
|
assert.deepEqual(net.sent, [{ t: 'room.loadout', entries: {}, ops: { [V]: { potential: 1 } } }], 'a prototype dropped');
|
||||||
|
assert.equal(backups, 1, 'the 自选 pool needs backups.json');
|
||||||
|
target.set({ ops: { [V]: { potential: 1, cultivate: 2 } } });
|
||||||
|
await T.advance(SYNC_DEBOUNCE_MS + 10);
|
||||||
|
assert.deepEqual(net.sent[1], { t: 'room.loadout', entries: {}, ops: { [V]: { potential: 1, cultivate: 2 } } });
|
||||||
|
s.dispose();
|
||||||
|
// nothing set: nothing to sanitise, no data loaded
|
||||||
|
const net2 = fakeNet();
|
||||||
|
let loads = 0;
|
||||||
|
const t2 = createStore({ entries: {}, ops: {}, open: false, sync: 'idle' });
|
||||||
|
const s2 = installLoadoutSync({ net: net2, timers: T, target: t2, getChessReady: async () => { loads++; return CHESS; }, getBackupsReady: async () => { loads++; return BACKUPS; }, lookupChess: get, operatorIds: () => IDS });
|
||||||
|
net2.emit('welcome', {});
|
||||||
|
await T.advance(100);
|
||||||
|
assert.deepEqual([net2.sent, loads], [[{ t: 'room.loadout', entries: {}, ops: {} }], 0]);
|
||||||
|
s2.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cards: the record at the player\'s 潜能 with its 练度 multiplier; a teammate\'s unit its own; a stand-in neither', () => {
|
||||||
|
const v = CHESS[VENDLA];
|
||||||
|
const fx = { effects: EFFECTS };
|
||||||
|
const at = (opts) => chessLoadout(v, null, get, { ...fx, ...opts }).record.stats;
|
||||||
|
assert.deepEqual([at({}).atk, at({}).cost], [435 * 1.1, 15], 'no settings: 满潜 + 精英2 Lv.60');
|
||||||
|
assert.deepEqual([at({ ops: { [V]: { potential: 1, cultivate: 0 } } }).atk, at({ ops: { [V]: { potential: 1, cultivate: 0 } } }).cost], [413, 17]);
|
||||||
|
assert.deepEqual([at({ ops: { [V]: { cultivate: 1 } } }).atk, at({ ops: { [V]: { cultivate: 1 } } }).def], [435 * 1.05, 77 * 1.05]);
|
||||||
|
assert.equal(chessLoadout(v, null, get, { ops: { [V]: { potential: 4 } }, ...fx }).record.talents.find((t) => t.name === '土壤基肥改良').bb.heal_scale, 1.08);
|
||||||
|
assert.deepEqual(chessLoadout(v, null, get, { ops: { [V]: { potential: 2 } }, ...fx }).cultivation, { potential: 2, cultivate: 3 });
|
||||||
|
// a teammate's unit: its UnitInfo
|
||||||
|
assert.deepEqual(unitCultivation({ potential: 3, cultivate: 1 }), { potential: 3, cultivate: 1 });
|
||||||
|
assert.equal(unitCultivation({}), null, 'neither: a raw / stand-in unit');
|
||||||
|
assert.equal(at({ cultivation: unitCultivation({ cultivate: 0 }) }).atk, 435);
|
||||||
|
assert.equal(at({ cultivation: null }).atk, 435, 'none');
|
||||||
|
// a stand-in (补位): neither, whatever the settings say
|
||||||
|
const normal = Object.values(CHESS).find((c) => c.chessType === 'NORMAL' && !c.isGolden && c.visible);
|
||||||
|
const st = standInOf(normal, BACKUPS);
|
||||||
|
const si = standInLoadout(st, get, BACKUPS);
|
||||||
|
assert.ok(st && st.standInFor === normal.charId);
|
||||||
|
assert.equal(si.cultivation, null);
|
||||||
|
assert.deepEqual(si.record.stats, st.stats, 'the stand-in\'s own numbers');
|
||||||
|
assert.equal(chessLoadout(st, null, get, { ops: { [normal.charId]: { potential: 1, cultivate: 0 }, [st.charId]: { potential: 1 } }, ...fx }).cultivation, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('自选 cards: the player\'s own pick at its 潜能 (m.private.ops), another player\'s unit at its UnitInfo potential', () => {
|
||||||
|
const T6 = 'chess_char_6_diy1_a';
|
||||||
|
const data = { chess: CHESS, backups: BACKUPS };
|
||||||
|
const priv = { diy: { [T6]: { charId: KALTS, skillIndex: 0, uniEquipId: null } }, ops: { [KALTS]: { potential: 1 } } };
|
||||||
|
assert.deepEqual(ownDiyPick(priv, CHESS[T6]), { charId: KALTS, skillIndex: 0, uniEquipId: null, potential: 1 });
|
||||||
|
const rec = ownDiyRecord(CHESS[T6], priv, data);
|
||||||
|
assert.deepEqual([rec.stats.atk, rec.stats.cost], [392, 20], '凯尔希 潜能1');
|
||||||
|
const full = ownDiyRecord(CHESS[T6], { ...priv, ops: {} }, data);
|
||||||
|
assert.deepEqual([full.stats.atk, full.stats.cost], [417, 18]);
|
||||||
|
const mate = cardDiy(CHESS[T6], { unit: { diy: { charId: KALTS, skillIndex: 0, uniEquipId: null }, potential: 1, cultivate: 3 }, data });
|
||||||
|
assert.equal(mate.stats.atk, 392);
|
||||||
|
// the card multiplies the 练度 on top (the composed record is already at its potential)
|
||||||
|
assert.equal(chessLoadout(rec, null, get, { ops: priv.ops, effects: EFFECTS }).record.stats.atk, 392 * 1.1);
|
||||||
|
});
|
||||||
@@ -58,7 +58,7 @@ describe('DESIGN §16 — a teammate\'s unit shows its owner\'s loadout (real se
|
|||||||
await guest.click('.lo-search input');
|
await guest.click('.lo-search input');
|
||||||
await guest.page.keyboard.type('野鬃');
|
await guest.page.keyboard.type('野鬃');
|
||||||
await guest.page.waitForFunction(() => document.querySelectorAll('.lo-card').length === 1, { timeout: 5000 });
|
await guest.page.waitForFunction(() => document.querySelectorAll('.lo-card').length === 1, { timeout: 5000 });
|
||||||
await guest.click('.lo-card');
|
await guest.click('.lo-card__pick');
|
||||||
await guest.click('.lo-detail .lo-skill[data-skill="0"]');
|
await guest.click('.lo-detail .lo-skill[data-skill="0"]');
|
||||||
await guest.click('.lo-detail .lo-mod[data-module="none"]');
|
await guest.click('.lo-detail .lo-mod[data-module="none"]');
|
||||||
await guest.page.waitForFunction(() => /已同步/.test(document.querySelector('.lo-sync')?.textContent || ''), { timeout: 8000 });
|
await guest.page.waitForFunction(() => /已同步/.test(document.querySelector('.lo-sync')?.textContent || ''), { timeout: 8000 });
|
||||||
|
|||||||
+170
-43
@@ -39,6 +39,7 @@ import { Grid, DEPLOY_REFUSED_TILES } from '../server/sim/grid.js';
|
|||||||
import { bandBondIds } from '../shared/bandBonds.js';
|
import { bandBondIds } from '../shared/bandBonds.js';
|
||||||
import { statusKey, composeUnitRecord, standInRecord, unitForm } from '../shared/standIn.js';
|
import { statusKey, composeUnitRecord, standInRecord, unitForm } from '../shared/standIn.js';
|
||||||
import { extendedGrid } from '../shared/loadoutRecord.js';
|
import { extendedGrid } from '../shared/loadoutRecord.js';
|
||||||
|
import { FULL_RANK, atRank, stripPotential } from '../shared/potential.js';
|
||||||
|
|
||||||
// ===== CLI & IO ==================================================================================
|
// ===== CLI & IO ==================================================================================
|
||||||
|
|
||||||
@@ -285,20 +286,24 @@ function bestCandidate(cands, phase, level, potRank = 0) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The potential rank every operator fights at: FULL potential, 潜能 6 = rank 5 (character_table `potentialRanks` lists
|
* Potential (潜能) is a per-player runtime input (0.2.2, the owner's decision of 2026-10-08: 「调配干员里自己设置吧,默认满潜
|
||||||
* ranks 1–5, the 潜能 2–6 steps; a candidate's `requiredPotentialRank` counts the same way) — the owner's decision of
|
* 满加成」; 0.2.1 baked full potential in, DESIGN §26.14). Official: 调度手册 「卫戍协议中干员潜能由自身已持有干员潜能决定」 —
|
||||||
* 2026-10-07 (GitHub #252, PR #255). The season's chess records (activity_table charChessDataDict `status`: evolvePhase,
|
* the season's chess records (activity_table charChessDataDict `status`: evolvePhase, charLevel, skillLevel, favorPoint 0,
|
||||||
* charLevel, skillLevel, favorPoint 0, equipLevel) carry no potential field, while a tournament video shows 刺玫
|
* equipLevel) carry no potential field; a tournament video shows 刺玫 (chess_char_1_06_a) at ATK 435 and cost 15 (her
|
||||||
* (chess_char_1_06_a) at ATK 435 and cost 15: her E1 Lv55 413 / 17 plus her potentials 攻击力+22 and two 部署费用-1. Every
|
* E1 Lv55 413 / 17 plus 攻击力+22 and two 部署费用-1: an account at full potential). Ranks count like character_table
|
||||||
* chess (normal and elite), 补位 stand-in and 自选 pick is built at this rank: its `potentialRanks` attribute modifiers are
|
* `potentialRanks` (ranks 1–5 = the 潜能 2–6 steps) and a candidate's `requiredPotentialRank`.
|
||||||
* added (withPotential) and its talent / trait / module candidates are picked at it (the 「天赋效果增强」 steps). The 原型干员
|
* The builders take the rank from `ctx.potRank`: main() runs the chess / backups / tokens builders once per rank (0–5)
|
||||||
* stand-ins have no potential ranks (unchanged). A summon picks its own talent / trait candidates at its owner's rank —
|
* and keeps the FULL-potential build (潜能 6 = rank 5, FULL_RANK: the default and what the data's own fields
|
||||||
* their `requiredPotentialRank` mirrors the owner's 「天赋效果增强」 (夕's “小自在” 化境 15 → 18 层, 凯尔希's Mon3tr 不毁重构,
|
* hold) annotated with what every lower rank changes (attachPotential: `potDown` leaves, chained talent entries —
|
||||||
* 望's 棋子 +1 持有 / 部署) — and takes none of the owner's attribute modifiers (a token has no potential ranks; the owner's
|
* shared/potential.js, docs/DATA.md §2.3); a self-check proves shared/potential.js atRank rebuilds every rank exactly.
|
||||||
|
* At a rank, every chess (normal and elite), 补位 / 自选 unit form adds the `potentialRanks` attribute modifiers up to it
|
||||||
|
* (withPotential) and picks its talent / trait / module candidates at it (the 「天赋效果增强」 steps). The 原型干员
|
||||||
|
* stand-ins have no potential ranks. A summon picks its own talent / trait candidates at its owner's rank — their
|
||||||
|
* `requiredPotentialRank` mirrors the owner's 「天赋效果增强」 (夕's “小自在” 化境 15 → 18 层, 凯尔希's Mon3tr 不毁重构, 望's
|
||||||
|
* 棋子 +1 持有 / 部署) — and takes none of the owner's attribute modifiers (a token has no potential ranks; the owner's
|
||||||
* ATK / cost / redeploy steps are the owner's own). Stage devices and the band's map characters are no player's
|
* ATK / cost / redeploy steps are the owner's own). Stage devices and the band's map characters are no player's
|
||||||
* operators: rank 0.
|
* operators: rank 0.
|
||||||
*/
|
*/
|
||||||
const OPERATOR_POTENTIAL = 5;
|
|
||||||
|
|
||||||
/** character_table potential attribute types → interpolateAttrs keys (every potential modifier of the season is an ADDITION). */
|
/** character_table potential attribute types → interpolateAttrs keys (every potential modifier of the season is an ADDITION). */
|
||||||
const POTENTIAL_ATTRS = Object.freeze({
|
const POTENTIAL_ATTRS = Object.freeze({
|
||||||
@@ -698,22 +703,32 @@ function splitModuleParts(modulePhase) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Apply module trait-override parts to a trait blackboard/text (golden chess or their tokens).
|
* Apply module trait-override parts to a trait blackboard/text (golden chess or their tokens).
|
||||||
* Mutates `traitBB`; returns the (possibly overridden) trait template, module text and trait range id.
|
* Mutates `traitBB`; returns the (possibly overridden) trait template, module text and trait range id, and `descBB` —
|
||||||
* @returns {{ template: string, moduleText: string|null, rangeId: string|null }}
|
* the blackboard the trait's own line is written with: a part whose target is `DISPLAY` (display only) and that adds a
|
||||||
|
* line without rewriting the trait's feeds its blackboard to that added line only. 圣约送葬人 REA-Y (uniequip_003_excu2):
|
||||||
|
* its DISPLAY part's `value` 12 is the 「攻击速度+{value}」 of its own line, not the trait's 「回复自身{value}生命」 (50 —
|
||||||
|
* GitHub #400; the battle heals 50, its kit reads traitBase). `traitBB` (the battle's trait blackboard) still takes
|
||||||
|
* every part, as before (the kits that read it are written against it).
|
||||||
|
* @returns {{ template: string, moduleText: string|null, rangeId: string|null, descBB: { bb: object, bbStr: object } }}
|
||||||
*/
|
*/
|
||||||
function applyModuleTraitParts(parts, phase, level, traitBB, template, rangeId, label, potRank = 0) {
|
function applyModuleTraitParts(parts, phase, level, traitBB, template, rangeId, label, potRank = 0) {
|
||||||
let moduleText = null;
|
let moduleText = null;
|
||||||
|
const descBB = { bb: { ...traitBB.bb }, bbStr: { ...traitBB.bbStr } };
|
||||||
for (const part of parts || []) {
|
for (const part of parts || []) {
|
||||||
const mc = bestCandidate(part.overrideTraitDataBundle?.candidates, phase, level, potRank);
|
const mc = bestCandidate(part.overrideTraitDataBundle?.candidates, phase, level, potRank);
|
||||||
if (!mc) continue;
|
if (!mc) continue;
|
||||||
const mb = flattenBB(mc.blackboard, `${label} module trait`);
|
const mb = flattenBB(mc.blackboard, `${label} module trait`);
|
||||||
Object.assign(traitBB.bb, mb.bb);
|
Object.assign(traitBB.bb, mb.bb);
|
||||||
Object.assign(traitBB.bbStr, mb.bbStr);
|
Object.assign(traitBB.bbStr, mb.bbStr);
|
||||||
|
if (part.target !== 'DISPLAY' || mc.overrideDescripton) {
|
||||||
|
Object.assign(descBB.bb, mb.bb);
|
||||||
|
Object.assign(descBB.bbStr, mb.bbStr);
|
||||||
|
}
|
||||||
if (mc.overrideDescripton) template = mc.overrideDescripton;
|
if (mc.overrideDescripton) template = mc.overrideDescripton;
|
||||||
if (mc.additionalDescription) moduleText = mc.additionalDescription;
|
if (mc.additionalDescription) moduleText = mc.additionalDescription;
|
||||||
if (mc.rangeId) rangeId = mc.rangeId;
|
if (mc.rangeId) rangeId = mc.rangeId;
|
||||||
}
|
}
|
||||||
return { template, moduleText, rangeId };
|
return { template, moduleText, rangeId, descBB };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -726,7 +741,7 @@ function traitRecord(ctx, char, phase, level, opParts, chessId, potRank = 0) {
|
|||||||
const traitBB = flattenBB(tc?.blackboard, `${chessId} trait`);
|
const traitBB = flattenBB(tc?.blackboard, `${chessId} trait`);
|
||||||
const traitMod = applyModuleTraitParts(opParts, phase, level, traitBB,
|
const traitMod = applyModuleTraitParts(opParts, phase, level, traitBB,
|
||||||
tc?.overrideDescripton || char.description || '', tc?.rangeId || null, chessId, potRank);
|
tc?.overrideDescripton || char.description || '', tc?.rangeId || null, chessId, potRank);
|
||||||
const tp = textPair(traitMod.template, traitBB.bb, traitBB.bbStr, `${chessId} trait`);
|
const tp = textPair(traitMod.template, traitMod.descBB.bb, traitMod.descBB.bbStr, `${chessId} trait`);
|
||||||
const trait = { desc: tp.desc, descRaw: tp.descRaw, bb: traitBB.bb, bbStr: traitBB.bbStr, rangeGrid: rangeGrid(ctx, traitMod.rangeId) };
|
const trait = { desc: tp.desc, descRaw: tp.descRaw, bb: traitBB.bb, bbStr: traitBB.bbStr, rangeGrid: rangeGrid(ctx, traitMod.rangeId) };
|
||||||
if (traitMod.moduleText) {
|
if (traitMod.moduleText) {
|
||||||
const mp = textPair(traitMod.moduleText, traitBB.bb, traitBB.bbStr, `${chessId} module trait`);
|
const mp = textPair(traitMod.moduleText, traitBB.bb, traitBB.bbStr, `${chessId} module trait`);
|
||||||
@@ -758,7 +773,7 @@ function moduleAttr(modulePhase) {
|
|||||||
* An override of an existing talent keeps the base values that the module candidate does not
|
* An override of an existing talent keeps the base values that the module candidate does not
|
||||||
* restate (blackboard keys merged, module values win; name/text/range/token kept when absent).
|
* restate (blackboard keys merged, module values win; name/text/range/token kept when absent).
|
||||||
* `modPhase`/`modLevel` select module candidates (tokens: the owner's phase/level); `potRank` every candidate (tokens:
|
* `modPhase`/`modLevel` select module candidates (tokens: the owner's phase/level); `potRank` every candidate (tokens:
|
||||||
* the owner's potential rank, OPERATOR_POTENTIAL).
|
* the owner's potential rank, ctx.potRank).
|
||||||
* @returns {Array<{index:number,name:string|null,desc:string|null,descRaw:string|null,bb:object,bbStr:object,rangeGrid:any,tokenKey:string|null,hidden:boolean,fromModule:boolean}>}
|
* @returns {Array<{index:number,name:string|null,desc:string|null,descRaw:string|null,bb:object,bbStr:object,rangeGrid:any,tokenKey:string|null,hidden:boolean,fromModule:boolean}>}
|
||||||
*/
|
*/
|
||||||
function buildTalents(ctx, char, phase, level, moduleParts, label, modPhase = phase, modLevel = level, potRank = 0) {
|
function buildTalents(ctx, char, phase, level, moduleParts, label, modPhase = phase, modLevel = level, potRank = 0) {
|
||||||
@@ -981,8 +996,8 @@ function buildChess(ctx) {
|
|||||||
// Module parts flagged isToken upgrade the summons, not the operator (see splitModuleParts).
|
// Module parts flagged isToken upgrade the summons, not the operator (see splitModuleParts).
|
||||||
const moduleParts = splitModuleParts(modulePhase);
|
const moduleParts = splitModuleParts(modulePhase);
|
||||||
|
|
||||||
// Stats at full potential (OPERATOR_POTENTIAL, withPotential) (+ module attribute bonus on golden).
|
// Stats at the pass's potential rank (ctx.potRank, withPotential) (+ module attribute bonus on golden).
|
||||||
const attrs = withPotential(char, interpolateAttrs(char, phase, level), OPERATOR_POTENTIAL, `chess ${chessId}`);
|
const attrs = withPotential(char, interpolateAttrs(char, phase, level), ctx.potRank, `chess ${chessId}`);
|
||||||
if (!attrs) warn(`chess ${chessId}: cannot interpolate attributes`);
|
if (!attrs) warn(`chess ${chessId}: cannot interpolate attributes`);
|
||||||
const bonus = {};
|
const bonus = {};
|
||||||
for (const b of modulePhase?.attributeBlackboard || []) bonus[b.key] = (bonus[b.key] || 0) + b.value;
|
for (const b of modulePhase?.attributeBlackboard || []) bonus[b.key] = (bonus[b.key] || 0) + b.value;
|
||||||
@@ -992,7 +1007,7 @@ function buildChess(ctx) {
|
|||||||
// Trait (character trait candidate + module trait override on golden).
|
// Trait (character trait candidate + module trait override on golden).
|
||||||
// Trait-effect range (e.g. 散射手 front row, 傀儡师 substitute area) — NOT the attack range.
|
// Trait-effect range (e.g. 散射手 front row, 傀儡师 substitute area) — NOT the attack range.
|
||||||
const rangeId = char.phases?.[phase]?.rangeId || null;
|
const rangeId = char.phases?.[phase]?.rangeId || null;
|
||||||
const traitDefault = traitRecord(ctx, char, phase, level, moduleParts.op, chessId, OPERATOR_POTENTIAL);
|
const traitDefault = traitRecord(ctx, char, phase, level, moduleParts.op, chessId, ctx.potRank);
|
||||||
rec.trait = traitDefault.trait;
|
rec.trait = traitDefault.trait;
|
||||||
rec.rangeId = rangeId;
|
rec.rangeId = rangeId;
|
||||||
rec.rangeGrid = rangeGrid(ctx, rangeId);
|
rec.rangeGrid = rangeGrid(ctx, rangeId);
|
||||||
@@ -1022,16 +1037,16 @@ function buildChess(ctx) {
|
|||||||
}
|
}
|
||||||
rec.skills = skillRecs.map((s) => ({ ...s, isDefault: s.index === sIdx }));
|
rec.skills = skillRecs.map((s) => ({ ...s, isDefault: s.index === sIdx }));
|
||||||
|
|
||||||
// Talents at full potential (module token parts excluded: they belong to the summons).
|
// Talents at the pass's potential rank (module token parts excluded: they belong to the summons).
|
||||||
const talentList = baseTalentList(ctx, char, phase, level, `chess ${chessId}`, OPERATOR_POTENTIAL);
|
const talentList = baseTalentList(ctx, char, phase, level, `chess ${chessId}`, ctx.potRank);
|
||||||
rec.talents = mergeTalentChanges(talentList, moduleTalentChanges(ctx, moduleParts.op, phase, level, `chess ${chessId}`, OPERATOR_POTENTIAL));
|
rec.talents = mergeTalentChanges(talentList, moduleTalentChanges(ctx, moduleParts.op, phase, level, `chess ${chessId}`, ctx.potRank));
|
||||||
|
|
||||||
// Selectable modules of the golden chess (DESIGN §16): every ADVANCED uniequip of the character
|
// Selectable modules of the golden chess (DESIGN §16): every ADVANCED uniequip of the character
|
||||||
// (INITIAL = "no module") at the chess equipLevel, plus the no-module base the choices apply to.
|
// (INITIAL = "no module") at the chess equipLevel, plus the no-module base the choices apply to.
|
||||||
const moduleAlts = [];
|
const moduleAlts = [];
|
||||||
if (isGolden && equipLevel > 0) {
|
if (isGolden && equipLevel > 0) {
|
||||||
rec.statsBase = statsFrom(attrs, {});
|
rec.statsBase = statsFrom(attrs, {});
|
||||||
rec.traitBase = traitRecord(ctx, char, phase, level, [], chessId, OPERATOR_POTENTIAL).trait;
|
rec.traitBase = traitRecord(ctx, char, phase, level, [], chessId, ctx.potRank).trait;
|
||||||
rec.talentsBase = mergeTalentChanges(talentList, []);
|
rec.talentsBase = mergeTalentChanges(talentList, []);
|
||||||
rec.modules = [];
|
rec.modules = [];
|
||||||
for (const id of uniequip.charEquip?.[shop.charId] || []) {
|
for (const id of uniequip.charEquip?.[shop.charId] || []) {
|
||||||
@@ -1040,8 +1055,8 @@ function buildChess(ctx) {
|
|||||||
const ph = battleEquip[id]?.phases?.find((p) => p.equipLevel === equipLevel) || null;
|
const ph = battleEquip[id]?.phases?.find((p) => p.equipLevel === equipLevel) || null;
|
||||||
if (!ph) { warn(`chess ${chessId}: module ${id} has no level ${equipLevel} (not selectable)`); continue; }
|
if (!ph) { warn(`chess ${chessId}: module ${id} has no level ${equipLevel} (not selectable)`); continue; }
|
||||||
const parts = splitModuleParts(ph);
|
const parts = splitModuleParts(ph);
|
||||||
const hasTraitPart = parts.op.some((pt) => bestCandidate(pt.overrideTraitDataBundle?.candidates, phase, level, OPERATOR_POTENTIAL));
|
const hasTraitPart = parts.op.some((pt) => bestCandidate(pt.overrideTraitDataBundle?.candidates, phase, level, ctx.potRank));
|
||||||
const tr = hasTraitPart ? traitRecord(ctx, char, phase, level, parts.op, chessId, OPERATOR_POTENTIAL) : null;
|
const tr = hasTraitPart ? traitRecord(ctx, char, phase, level, parts.op, chessId, ctx.potRank) : null;
|
||||||
if (tr && JSON.stringify(tr.classify) !== JSON.stringify(traitDefault.classify)) {
|
if (tr && JSON.stringify(tr.classify) !== JSON.stringify(traitDefault.classify)) {
|
||||||
warn(`chess ${chessId}: module ${id} changes the combat classification (not applied by loadouts)`);
|
warn(`chess ${chessId}: module ${id} changes the combat classification (not applied by loadouts)`);
|
||||||
}
|
}
|
||||||
@@ -1052,7 +1067,7 @@ function buildChess(ctx) {
|
|||||||
isDefault: id === modId, level: equipLevel,
|
isDefault: id === modId, level: equipLevel,
|
||||||
attr: moduleAttr(ph),
|
attr: moduleAttr(ph),
|
||||||
traitOverride: tr ? tr.trait : null,
|
traitOverride: tr ? tr.trait : null,
|
||||||
talentChanges: moduleTalentChanges(ctx, parts.op, phase, level, `chess ${chessId}`, OPERATOR_POTENTIAL),
|
talentChanges: moduleTalentChanges(ctx, parts.op, phase, level, `chess ${chessId}`, ctx.potRank),
|
||||||
});
|
});
|
||||||
if (id !== modId) moduleAlts.push({ id, modulePhase: ph, moduleTokenParts: parts.token });
|
if (id !== modId) moduleAlts.push({ id, modulePhase: ph, moduleTokenParts: parts.token });
|
||||||
}
|
}
|
||||||
@@ -1111,7 +1126,7 @@ function buildChess(ctx) {
|
|||||||
});
|
});
|
||||||
if (!tokenOwners.has(tokenId)) tokenOwners.set(tokenId, []);
|
if (!tokenOwners.has(tokenId)) tokenOwners.set(tokenId, []);
|
||||||
tokenOwners.get(tokenId).push({
|
tokenOwners.get(tokenId).push({
|
||||||
chessId, charId: shop.charId, phase, level, skillIndex: sIdx, skillLevel, potRank: OPERATOR_POTENTIAL,
|
chessId, charId: shop.charId, phase, level, skillIndex: sIdx, skillLevel, potRank: ctx.potRank,
|
||||||
count: defUse.count(tokenId), golden: isGolden, modulePhase, moduleTokenParts: moduleParts.token,
|
count: defUse.count(tokenId), golden: isGolden, modulePhase, moduleTokenParts: moduleParts.token,
|
||||||
// 'display' only = listed by the character but not produced by this chess's default skill or talents.
|
// 'display' only = listed by the character but not produced by this chess's default skill or talents.
|
||||||
sources: ['talent', 'skill', 'display'].filter((s) => src.has(s)),
|
sources: ['talent', 'skill', 'display'].filter((s) => src.has(s)),
|
||||||
@@ -1214,11 +1229,11 @@ function buildUnitForm(ctx, charId, status, { chessId = null, skillIndex = null
|
|||||||
const char = charTable[charId];
|
const char = charTable[charId];
|
||||||
const { phase, level, skillLevel, equipLevel } = status;
|
const { phase, level, skillLevel, equipLevel } = status;
|
||||||
const label = chessId ? `chess ${chessId}` : `unit ${charId}@${statusKey(status)}`;
|
const label = chessId ? `chess ${chessId}` : `unit ${charId}@${statusKey(status)}`;
|
||||||
const attrs = withPotential(char, interpolateAttrs(char, phase, level), OPERATOR_POTENTIAL, label);
|
const attrs = withPotential(char, interpolateAttrs(char, phase, level), ctx.potRank, label);
|
||||||
if (!attrs) warn(`${label}: cannot interpolate attributes`);
|
if (!attrs) warn(`${label}: cannot interpolate attributes`);
|
||||||
const stats = statsFrom(attrs, {});
|
const stats = statsFrom(attrs, {});
|
||||||
const rangeId = char.phases?.[phase]?.rangeId || null;
|
const rangeId = char.phases?.[phase]?.rangeId || null;
|
||||||
const base = traitRecord(ctx, char, phase, level, [], chessId || charId, OPERATOR_POTENTIAL);
|
const base = traitRecord(ctx, char, phase, level, [], chessId || charId, ctx.potRank);
|
||||||
const skills = [];
|
const skills = [];
|
||||||
(char.skills || []).forEach((se, i) => {
|
(char.skills || []).forEach((se, i) => {
|
||||||
if (!se?.skillId || (i !== skillIndex && !unlocked(se.unlockCond, phase, level))) return;
|
if (!se?.skillId || (i !== skillIndex && !unlocked(se.unlockCond, phase, level))) return;
|
||||||
@@ -1229,7 +1244,7 @@ function buildUnitForm(ctx, charId, status, { chessId = null, skillIndex = null
|
|||||||
s.overrideTokenKey = se.overrideTokenKey || null;
|
s.overrideTokenKey = se.overrideTokenKey || null;
|
||||||
skills.push(s);
|
skills.push(s);
|
||||||
});
|
});
|
||||||
const talents = mergeTalentChanges(baseTalentList(ctx, char, phase, level, label, OPERATOR_POTENTIAL), []);
|
const talents = mergeTalentChanges(baseTalentList(ctx, char, phase, level, label, ctx.potRank), []);
|
||||||
const modules = [];
|
const modules = [];
|
||||||
if (equipLevel > 0) {
|
if (equipLevel > 0) {
|
||||||
for (const id of uniequip.charEquip?.[charId] || []) {
|
for (const id of uniequip.charEquip?.[charId] || []) {
|
||||||
@@ -1238,8 +1253,8 @@ function buildUnitForm(ctx, charId, status, { chessId = null, skillIndex = null
|
|||||||
const ph = battleEquip[id]?.phases?.find((p) => p.equipLevel === equipLevel) || null;
|
const ph = battleEquip[id]?.phases?.find((p) => p.equipLevel === equipLevel) || null;
|
||||||
if (!ph) { warn(`${label}: module ${id} has no level ${equipLevel} (not selectable)`); continue; }
|
if (!ph) { warn(`${label}: module ${id} has no level ${equipLevel} (not selectable)`); continue; }
|
||||||
const parts = splitModuleParts(ph);
|
const parts = splitModuleParts(ph);
|
||||||
const hasTraitPart = parts.op.some((pt) => bestCandidate(pt.overrideTraitDataBundle?.candidates, phase, level, OPERATOR_POTENTIAL));
|
const hasTraitPart = parts.op.some((pt) => bestCandidate(pt.overrideTraitDataBundle?.candidates, phase, level, ctx.potRank));
|
||||||
const tr = hasTraitPart ? traitRecord(ctx, char, phase, level, parts.op, chessId || charId, OPERATOR_POTENTIAL) : null;
|
const tr = hasTraitPart ? traitRecord(ctx, char, phase, level, parts.op, chessId || charId, ctx.potRank) : null;
|
||||||
if (tr && JSON.stringify(tr.classify) !== JSON.stringify(base.classify)) warn(`${label}: module ${id} changes the combat classification (not applied by loadouts)`);
|
if (tr && JSON.stringify(tr.classify) !== JSON.stringify(base.classify)) warn(`${label}: module ${id} changes the combat classification (not applied by loadouts)`);
|
||||||
modules.push({
|
modules.push({
|
||||||
uniEquipId: id, name: meta.uniEquipName || null,
|
uniEquipId: id, name: meta.uniEquipName || null,
|
||||||
@@ -1247,7 +1262,7 @@ function buildUnitForm(ctx, charId, status, { chessId = null, skillIndex = null
|
|||||||
typeIcon: meta.typeIcon || null, icon: meta.uniEquipIcon || id, level: equipLevel,
|
typeIcon: meta.typeIcon || null, icon: meta.uniEquipIcon || id, level: equipLevel,
|
||||||
attr: moduleAttr(ph),
|
attr: moduleAttr(ph),
|
||||||
traitOverride: tr ? tr.trait : null,
|
traitOverride: tr ? tr.trait : null,
|
||||||
talentChanges: moduleTalentChanges(ctx, parts.op, phase, level, label, OPERATOR_POTENTIAL),
|
talentChanges: moduleTalentChanges(ctx, parts.op, phase, level, label, ctx.potRank),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1300,7 +1315,7 @@ function buildBackups(ctx, chess) {
|
|||||||
const b = c.backup;
|
const b = c.backup;
|
||||||
if (c.chessType !== 'NORMAL' || !b?.charId || b.charId === c.charId) continue;
|
if (c.chessType !== 'NORMAL' || !b?.charId || b.charId === c.charId) continue;
|
||||||
if (!charTable[b.charId]) { warn(`chess ${c.chessId}: backup ${b.charId} missing from character_table`); continue; }
|
if (!charTable[b.charId]) { warn(`chess ${c.chessId}: backup ${b.charId} missing from character_table`); continue; }
|
||||||
if (b.potRank !== 0) warn(`chess ${c.chessId}: backup potential ${b.potRank} (every unit form is built at full potential, OPERATOR_POTENTIAL)`);
|
if (b.potRank !== 0) warn(`chess ${c.chessId}: backup potential ${b.potRank} (a 补位 stand-in fights without potential: not applied)`);
|
||||||
if (b.tmplId) warn(`chess ${c.chessId}: backup template ${b.tmplId} is not built`);
|
if (b.tmplId) warn(`chess ${c.chessId}: backup template ${b.tmplId} is not built`);
|
||||||
addNeed(b.charId, c.status);
|
addNeed(b.charId, c.status);
|
||||||
if (!c.isGolden) standsIn.set(b.charId, [...(standsIn.get(b.charId) || []), c.chessId]);
|
if (!c.isGolden) standsIn.set(b.charId, [...(standsIn.get(b.charId) || []), c.chessId]);
|
||||||
@@ -1442,7 +1457,7 @@ const TOKEN_DECK_KEYS = Object.freeze(['max_deploy_count', 'max_deck_stack_cnt']
|
|||||||
* reads the frame only): PRTS 幻影 备注 "每次部署夜莺时获得2个可部署的幻影,最大可部署数量为3" (1 + 2); the owners' own
|
* reads the frame only): PRTS 幻影 备注 "每次部署夜莺时获得2个可部署的幻影,最大可部署数量为3" (1 + 2); the owners' own
|
||||||
* talents "最多同时部署3个" (麦哲伦 / 令: 1 + 2), "最多可部署2个" (白铁: 1 + 1); SUM-Y stage 2+ "最多同时部署4个" — its token part
|
* talents "最多同时部署3个" (麦哲伦 / 令: 1 + 2), "最多可部署2个" (白铁: 1 + 1); SUM-Y stage 2+ "最多同时部署4个" — its token part
|
||||||
* (the same talent, max_deploy_count 3) replaces the talent's values. The candidates as everywhere: the token's phase /
|
* (the same talent, max_deploy_count 3) replaces the talent's values. The candidates as everywhere: the token's phase /
|
||||||
* level at its owner's potential rank (`potRank`, OPERATOR_POTENTIAL: 望's 棋子 takes its rank-2 candidate, +1 持有 / 部署 —
|
* level at its owner's potential rank (`potRank`, ctx.potRank: 望's 棋子 takes its rank-2 candidate, +1 持有 / 部署 —
|
||||||
* 望's 潜能 3 「第一天赋效果增强」); a module's token part (`moduleTokenParts`, unlocked at the owner's
|
* 望's 潜能 3 「第一天赋效果增强」); a module's token part (`moduleTokenParts`, unlocked at the owner's
|
||||||
* phase / level as moduleTalentChanges) replaces the base candidate of the same prefabKey key by key (mergeTalentChanges:
|
* phase / level as moduleTalentChanges) replaces the base candidate of the same prefabKey key by key (mergeTalentChanges:
|
||||||
* what it does not restate stays) — matched by prefabKey, not talentIndex (夜莺's RIN-Y stage 3 part names talentIndex 0
|
* what it does not restate stays) — matched by prefabKey, not talentIndex (夜莺's RIN-Y stage 3 part names talentIndex 0
|
||||||
@@ -1470,8 +1485,8 @@ function tokenTalentDeckBonus(char, phase, level, moduleTokenParts, modPhase, mo
|
|||||||
/**
|
/**
|
||||||
* Build one owner-specific variant of a token / map character at (phase, level, skill level). Its stats add the module's
|
* Build one owner-specific variant of a token / map character at (phase, level, skill level). Its stats add the module's
|
||||||
* token attributes and the summon's talent additions to its deploy limit / holding (tokenTalentDeckBonus). Its talent /
|
* token attributes and the summon's talent additions to its deploy limit / holding (tokenTalentDeckBonus). Its talent /
|
||||||
* trait candidates are picked at `potRank`, its owner's potential rank (OPERATOR_POTENTIAL; a map character: 0); the
|
* trait candidates are picked at `potRank`, its owner's potential rank (ctx.potRank; a map character: 0); the owner's
|
||||||
* owner's potential attribute modifiers never reach it (OPERATOR_POTENTIAL).
|
* potential attribute modifiers never reach it.
|
||||||
* @returns {object}
|
* @returns {object}
|
||||||
*/
|
*/
|
||||||
function tokenVariant(ctx, tokenId, char, { phase, level, skillIndex, skillLevel, modulePhase, moduleTokenParts, label, potRank = 0 }) {
|
function tokenVariant(ctx, tokenId, char, { phase, level, skillIndex, skillLevel, modulePhase, moduleTokenParts, label, potRank = 0 }) {
|
||||||
@@ -1489,7 +1504,7 @@ function tokenVariant(ctx, tokenId, char, { phase, level, skillIndex, skillLevel
|
|||||||
const ownerPhase = phase, ownerLevel = level;
|
const ownerPhase = phase, ownerLevel = level;
|
||||||
const traitMod = applyModuleTraitParts(moduleTokenParts, ownerPhase, ownerLevel, tbb,
|
const traitMod = applyModuleTraitParts(moduleTokenParts, ownerPhase, ownerLevel, tbb,
|
||||||
tc?.overrideDescripton || char.description || '', null, label, potRank);
|
tc?.overrideDescripton || char.description || '', null, label, potRank);
|
||||||
const tp = textPair(traitMod.template, tbb.bb, tbb.bbStr, `${label} trait`);
|
const tp = textPair(traitMod.template, traitMod.descBB.bb, traitMod.descBB.bbStr, `${label} trait`);
|
||||||
const moduleTrait = traitMod.moduleText ? textPair(traitMod.moduleText, tbb.bb, tbb.bbStr, `${label} module trait`) : null;
|
const moduleTrait = traitMod.moduleText ? textPair(traitMod.moduleText, tbb.bb, tbb.bbStr, `${label} module trait`) : null;
|
||||||
// Token skill: same index as the owner's skill when present, else the first defined one.
|
// Token skill: same index as the owner's skill when present, else the first defined one.
|
||||||
const skills = char.skills || [];
|
const skills = char.skills || [];
|
||||||
@@ -1657,7 +1672,7 @@ function buildDiyTokens(ctx, units, owned) {
|
|||||||
for (const tokenId of form.tokens) {
|
for (const tokenId of form.tokens) {
|
||||||
const tchar = charTable[tokenId];
|
const tchar = charTable[tokenId];
|
||||||
const label = `token ${tokenId}@${charId}@${key}`;
|
const label = `token ${tokenId}@${charId}@${key}`;
|
||||||
const at = (o) => tokenVariant(ctx, tokenId, tchar, { phase, level, skillIndex: first.index, skillLevel, modulePhase: null, moduleTokenParts: [], label, potRank: OPERATOR_POTENTIAL, ...o });
|
const at = (o) => tokenVariant(ctx, tokenId, tchar, { phase, level, skillIndex: first.index, skillLevel, modulePhase: null, moduleTokenParts: [], label, potRank: ctx.potRank, ...o });
|
||||||
const u0 = tokenUse(first);
|
const u0 = tokenUse(first);
|
||||||
const v = { ...at({}), count: u0.count(tokenId), sources: u0.sources(tokenId) };
|
const v = { ...at({}), count: u0.count(tokenId), sources: u0.sources(tokenId) };
|
||||||
let produced = makes(v.sources);
|
let produced = makes(v.sources);
|
||||||
@@ -3726,6 +3741,108 @@ function buildConfig(ctx, waves, stages, bands) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== potential (0.2.2) =========================================================================
|
||||||
|
|
||||||
|
/** Keys of the talent lists whose entries chain their lower-rank values (shared/potential.js talentAtRank). */
|
||||||
|
const POTENTIAL_TALENT_LISTS = new Set(['talents', 'talentsBase', 'talentChanges']);
|
||||||
|
const isPlainObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A talent entry built at ranks 0 … `hi` (`vals[r]`) as one chained entry: the rank-`hi` entry, with `potMin` (the
|
||||||
|
* lowest rank that builds the same entry) and `potBelow` (the entry below it — only the fields it changes, `desc` /
|
||||||
|
* `descRaw` / `bb` …, chained the same way) when a lower rank builds another one: the candidates by
|
||||||
|
* `requiredPotentialRank`.
|
||||||
|
*/
|
||||||
|
function chainTalent(vals, hi) {
|
||||||
|
const node = vals[hi];
|
||||||
|
let m = hi;
|
||||||
|
while (m > 0 && isDeepStrictEqual(vals[m - 1], node)) m--;
|
||||||
|
if (m === 0) return node;
|
||||||
|
const below = chainTalent(vals, m - 1);
|
||||||
|
const part = {};
|
||||||
|
for (const [k, v] of Object.entries(below)) if (k === 'potMin' || k === 'potBelow' || !isDeepStrictEqual(v, node[k])) part[k] = v;
|
||||||
|
return { ...node, potMin: m, potBelow: part };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The full-rank value `vals[FULL_RANK]` annotated with what the lower ranks change: talent lists chain their entries
|
||||||
|
* (chainTalent), every other differing leaf — or a value whose shape changes — records its exact value at each rank where
|
||||||
|
* it differs from the rank above in `potDown[rank][dotted.path]`.
|
||||||
|
*/
|
||||||
|
function annotatePotential(vals, path, potDown, label) {
|
||||||
|
const full = vals[FULL_RANK];
|
||||||
|
if (vals.every((v) => isDeepStrictEqual(v, full))) return full;
|
||||||
|
const key = path[path.length - 1];
|
||||||
|
if (POTENTIAL_TALENT_LISTS.has(key) && vals.every((v) => Array.isArray(v) && v.length === full.length)) {
|
||||||
|
return full.map((_, i) => chainTalent(vals.map((v) => v[i]), FULL_RANK));
|
||||||
|
}
|
||||||
|
if (isPlainObj(full) && vals.every((v) => isPlainObj(v) && isDeepStrictEqual(Object.keys(v), Object.keys(full)))) {
|
||||||
|
const o = {};
|
||||||
|
for (const k of Object.keys(full)) o[k] = annotatePotential(vals.map((v) => v[k]), [...path, k], potDown, label);
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
if (Array.isArray(full) && vals.every((v) => Array.isArray(v) && v.length === full.length)) {
|
||||||
|
return full.map((_, i) => annotatePotential(vals.map((v) => v[i]), [...path, i], potDown, label));
|
||||||
|
}
|
||||||
|
if (!path.length || path.some((k) => String(k).includes('.'))) {
|
||||||
|
warn(`${label}: a potential-dependent value at "${path.join('/')}" cannot be written as a potDown path`);
|
||||||
|
return full;
|
||||||
|
}
|
||||||
|
for (let r = FULL_RANK - 1; r >= 0; r--) {
|
||||||
|
if (!isDeepStrictEqual(vals[r], vals[r + 1])) (potDown[r] ||= {})[path.join('.')] = vals[r];
|
||||||
|
}
|
||||||
|
return full;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One record / form / variant built at every rank (`vals[r]`, r = 0 … FULL_RANK) as its full-rank build annotated for
|
||||||
|
* the lower ranks (annotatePotential; `potDown` last, only when something differs), checked: shared/potential.js
|
||||||
|
* atRank must rebuild every rank exactly (an error otherwise).
|
||||||
|
* @returns {object} the annotated full-rank value
|
||||||
|
*/
|
||||||
|
function withPotentialData(vals, label, errors) {
|
||||||
|
const potDown = {};
|
||||||
|
const out = annotatePotential(vals, [], potDown, label);
|
||||||
|
const res = Object.keys(potDown).length && isPlainObj(out) ? { ...out, potDown } : out;
|
||||||
|
for (let r = 0; r <= FULL_RANK; r++) {
|
||||||
|
if (!isDeepStrictEqual(stripPotential(atRank(res, r)), stripPotential(vals[r]))) errors.push(`potential ${label}: shared/potential.js atRank does not rebuild rank ${r}`);
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Annotate the full-potential build with every lower rank (docs/DATA.md §2.3): each chess record, each backups.json unit
|
||||||
|
* form and each token variant (data/tokens.json and backups.json `tokens`: the owner's potential — a record's top-level
|
||||||
|
* defaults stay the first owner's full-potential ones). `passes[r]` = { chess, backups, tokens } built at rank r.
|
||||||
|
* @returns {string[]} errors (an atRank mismatch, a record missing at a rank)
|
||||||
|
*/
|
||||||
|
function attachPotential(passes) {
|
||||||
|
const errors = [];
|
||||||
|
const full = passes[FULL_RANK];
|
||||||
|
for (const id of Object.keys(full.chess)) {
|
||||||
|
const vals = passes.map((p) => p.chess[id]);
|
||||||
|
if (vals.some((v) => !v)) { errors.push(`potential chess ${id}: missing at a rank`); continue; }
|
||||||
|
full.chess[id] = withPotentialData(vals, `chess ${id}`, errors);
|
||||||
|
}
|
||||||
|
for (const [charId, unit] of Object.entries(full.backups.units)) {
|
||||||
|
for (const key of Object.keys(unit.forms)) {
|
||||||
|
const vals = passes.map((p) => p.backups.units[charId]?.forms?.[key]);
|
||||||
|
if (vals.some((v) => !v)) { errors.push(`potential unit ${charId}@${key}: missing at a rank`); continue; }
|
||||||
|
unit.forms[key] = withPotentialData(vals, `unit ${charId}@${key}`, errors);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [file, pick] of [['tokens', (p) => p.tokens], ['backups tokens', (p) => p.backups.tokens]]) {
|
||||||
|
for (const [tokenId, rec] of Object.entries(pick(full))) {
|
||||||
|
for (const owner of Object.keys(rec.variants || {})) {
|
||||||
|
const vals = passes.map((p) => pick(p)[tokenId]?.variants?.[owner]);
|
||||||
|
if (vals.some((v) => !v)) { errors.push(`potential ${file} ${tokenId}@${owner}: missing at a rank`); continue; }
|
||||||
|
rec.variants[owner] = withPotentialData(vals, `${file} ${tokenId}@${owner}`, errors);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
// ===== validation ===============================================================================
|
// ===== validation ===============================================================================
|
||||||
|
|
||||||
/** Recursively find non-finite numbers (NaN/Infinity would silently become null in JSON). */
|
/** Recursively find non-finite numbers (NaN/Infinity would silently become null in JSON). */
|
||||||
@@ -3937,15 +4054,23 @@ async function main() {
|
|||||||
const t0 = Date.now();
|
const t0 = Date.now();
|
||||||
const ctx = await loadContext();
|
const ctx = await loadContext();
|
||||||
log('building…');
|
log('building…');
|
||||||
const { chess, tokenOwners } = buildChess(ctx);
|
// one pass of the operator builders per potential rank (潜能 1–6, ctx.potRank): the full-potential pass is the data,
|
||||||
const backups = buildBackups(ctx, chess);
|
// annotated below with what every lower rank changes (attachPotential)
|
||||||
|
const passes = [];
|
||||||
|
for (let r = 0; r <= FULL_RANK; r++) {
|
||||||
|
const pctx = { ...ctx, potRank: r };
|
||||||
|
const built = buildChess(pctx);
|
||||||
|
passes.push({ ctx: pctx, chess: built.chess, tokenOwners: built.tokenOwners, backups: buildBackups(pctx, built.chess) });
|
||||||
|
}
|
||||||
|
const { chess, backups } = passes[FULL_RANK];
|
||||||
const effects = buildEffects(ctx);
|
const effects = buildEffects(ctx);
|
||||||
const bonds = buildBonds(ctx, chess, effects);
|
const bonds = buildBonds(ctx, chess, effects);
|
||||||
const garrisons = buildGarrisons(ctx, chess);
|
const garrisons = buildGarrisons(ctx, chess);
|
||||||
const items = buildItems(ctx, effects);
|
const items = buildItems(ctx, effects);
|
||||||
const bands = buildBands(ctx, effects);
|
const bands = buildBands(ctx, effects);
|
||||||
const enemies = buildEnemies(ctx);
|
const enemies = buildEnemies(ctx);
|
||||||
const tokens = buildTokens(ctx, chess, tokenOwners, enemies);
|
for (const p of passes) p.tokens = buildTokens(p.ctx, p.chess, p.tokenOwners, enemies);
|
||||||
|
const tokens = passes[FULL_RANK].tokens;
|
||||||
const waves = buildWaves(ctx, enemies);
|
const waves = buildWaves(ctx, enemies);
|
||||||
const stages = buildStages(ctx, ctx.act.modeDataDict);
|
const stages = buildStages(ctx, ctx.act.modeDataDict);
|
||||||
const factions = buildFactions(ctx, enemies);
|
const factions = buildFactions(ctx, enemies);
|
||||||
@@ -3957,7 +4082,9 @@ async function main() {
|
|||||||
const config = buildConfig(ctx, waves, stages, bands);
|
const config = buildConfig(ctx, waves, stages, bands);
|
||||||
const files = { config, chess, bonds, garrisons, items, bands, effects, choices, enemies, factions, waves, stages, bosses, tokens, backups };
|
const files = { config, chess, bonds, garrisons, items, bands, effects, choices, enemies, factions, waves, stages, bosses, tokens, backups };
|
||||||
|
|
||||||
const errors = [...validateAll(files), ...checkUnitFormParity(ctx, chess)];
|
// the unit forms rebuild every PRESET chess at every rank (before the annotation: each pass as built)
|
||||||
|
const errors = [...validateAll(files), ...passes.flatMap((p) => checkUnitFormParity(p.ctx, p.chess).map((e) => `${e} (potential rank ${p.ctx.potRank})`))];
|
||||||
|
errors.push(...attachPotential(passes));
|
||||||
let total = 0;
|
let total = 0;
|
||||||
const sizes = {};
|
const sizes = {};
|
||||||
const texts = {};
|
const texts = {};
|
||||||
|
|||||||
+9
-2
@@ -39,7 +39,9 @@
|
|||||||
// each of its skills, then every prototype pick with a kit at its locked selection, both forms of each tier;
|
// 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)
|
// 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 — every operator the player owns at
|
||||||
|
// the default player's settings, 潜能 6 and 练度 精英2 Lv.60 (PlayerBattleInput `potential` / `cultivate`, as
|
||||||
|
// PlayerState.battleInput states them, 0.2.2; a stand-in and a prototype 自选 pick neither); 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.
|
||||||
//
|
//
|
||||||
// Digest (battle): end time, ticks, reason, kills / total / leaks, per player (kills, leaks, coins, damage, boss
|
// Digest (battle): end time, ticks, reason, kills / total / leaks, per player (kills, leaks, coins, damage, boss
|
||||||
@@ -75,10 +77,13 @@ 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 { diyRecordOf, DIY_TIERS } from '../shared/diy.js';
|
||||||
import { unitForm } from '../shared/standIn.js';
|
import { unitForm } from '../shared/standIn.js';
|
||||||
|
import { POTENTIAL_DEFAULT, CULTIVATE_DEFAULT } from '../shared/potential.js';
|
||||||
import { OPERATOR_KITS, KITTED_CHARS } from '../server/sim/content/kits/index.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)), '..');
|
||||||
|
/** The default player's 潜能 / 练度 of every owned operator (shared/potential.js; the owner's decision of 2026-10-08). */
|
||||||
|
const DEFAULT_CULTIVATION = Object.freeze({ potential: POTENTIAL_DEFAULT, cultivate: CULTIVATE_DEFAULT });
|
||||||
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', 'diy']);
|
export const FAMILY_NAMES = Object.freeze(['roster', 'bonds', 'fields', 'matches', 'standins', 'diy']);
|
||||||
|
|
||||||
@@ -351,6 +356,7 @@ function layout(gd, stageId, wanted, { field = 'normal', colOffset = 0, max = 12
|
|||||||
if (w.skillIndex != null) u.skillIndex = w.skillIndex;
|
if (w.skillIndex != null) u.skillIndex = w.skillIndex;
|
||||||
if (w.moduleId != null) u.moduleId = w.moduleId;
|
if (w.moduleId != null) u.moduleId = w.moduleId;
|
||||||
if (w.standIn) u.standIn = true;
|
if (w.standIn) u.standIn = true;
|
||||||
|
else Object.assign(u, DEFAULT_CULTIVATION); // an owned operator: the default player's 潜能 / 练度 (0.2.2)
|
||||||
if (w.carryState) u.carryState = w.carryState;
|
if (w.carryState) u.carryState = w.carryState;
|
||||||
units.push(u);
|
units.push(u);
|
||||||
}
|
}
|
||||||
@@ -953,7 +959,8 @@ function diyLayout(stageId, wanted) {
|
|||||||
const [r, c] = free[0];
|
const [r, c] = free[0];
|
||||||
used.add(tileKey(r, c));
|
used.add(tileKey(r, c));
|
||||||
const dir = units.length % 7 === 6 ? ['UP', 'LEFT', 'DOWN'][Math.floor(units.length / 7) % 3] : 'RIGHT';
|
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: [] });
|
// an owned pick at the default player's 潜能 / 练度 (0.2.2); a prototype has neither
|
||||||
|
units.push({ uid: uid++, kind: 'chess', chessId: w.chessId, diy: { ...w.diy }, row: r, col: c, dir, items: [], ...(w.rec.diyProto ? null : DEFAULT_CULTIVATION) });
|
||||||
}
|
}
|
||||||
return units;
|
return units;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user