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:
sganggs committed 2026-10-08 22:35:33 +08:00
commit 0ced21d321
82 files changed
+9156 -6840

No files matched your search

+1 -1
View File
@@ -1 +1 @@
{"units":{"char_600_cpione":{"charId":"char_600_cpione","name":"预备干员-先锋","appellation":"Reserve Operator - Vanguard","rarity":4,"profession":"PIONEER","subProfessionId":"pioneer","subProfessionName":"尖兵","position":"MELEE","nationId":null,"isNotObtainable":true,"assets":{"avatar":"char_600_cpione","avatarGolden":"char_600_cpione","portrait":"char_600_cpione_1","portraitGolden":"char_600_cpione_1","spine":"char_600_cpione","subProfIcon":"sub_pioneer_icon"},"moduleNames":{},"standsIn":["chess_char_5_01_a"],"forms":{"2/1/4/0":{"status":{"phase":2,"level":1,"skillLevel":4,"equipLevel":0},"stats":{"maxHp":1225,"atk":320,"def":272,"res":0,"cost":11,"blockCnt":2,"bat":1.05,"aspd":100,"respawnTime":70,"spRecovery":1,"hpRecoveryPerSec":0,"moveSpeed":1,"tauntLevel":0,"massLevel":0,"deployLimit":1,"deckStack":0},"immunities":{"stun":false,"silence":false,"sleep":false,"frozen":false,"levitate":false},"rangeId":"1-1","rangeGrid":[[0,0],[0,1]],"dmgType":"phys","attackKind":"melee","projectile":"none","canHitFly":false,"targetPriority":null,"trait":{"desc":"能够阻挡两个敌人","descRaw":"能够阻挡两个敌人","bb":{},"bbStr":{},"rangeGrid":null},"skills":[{"skillId":"skcom_charge_cost[1]","iconId":"skcom_charge_cost[1]","name":"冲锋号令·α型","level":4,"desc":"立即获得6点部署费用","descRaw":"立即获得<@ba.vup>6</>点部署费用","skillType":"AUTO","durationType":"NONE","duration":0,"spType":"INCREASE_WITH_TIME","spCost":27,"initSp":3,"maxChargeTime":1,"increment":1,"bb":{"cost":6},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skcom_charge_cost","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":0,"overrideTokenKey":null},{"skillId":"skcom_charge_cost[2]","iconId":"skcom_charge_cost[2]","name":"冲锋号令·β型","level":4,"desc":"立即获得9点部署费用","descRaw":"立即获得<@ba.vup>9</>点部署费用","skillType":"AUTO","durationType":"NONE","duration":0,"spType":"INCREASE_WITH_TIME","spCost":36,"initSp":9,"maxChargeTime":1,"increment":1,"bb":{"cost":9},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skcom_charge_cost","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":1,"overrideTokenKey":null},{"skillId":"skcom_charge_cost[3]","iconId":"skcom_charge_cost[3]","name":"冲锋号令·γ型","level":4,"desc":"立即获得12点部署费用","descRaw":"立即获得<@ba.vup>12</>点部署费用","skillType":"AUTO","durationType":"NONE","duration":0,"spType":"INCREASE_WITH_TIME","spCost":41,"initSp":12,"maxChargeTime":1,"increment":1,"bb":{"cost":12},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skcom_charge_cost","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":2,"overrideTokenKey":null}],"talents":[{"index":0,"name":"攻击提升","desc":"攻击力+8%","descRaw":"攻击力+8%","bb":{"atk":0.08},"bbStr":{},"rangeGrid":null,"tokenKey":null,"hidden":false,"fromModule":false}],"displayTokens":[],"tokens":[]},"2/60/7/1":{"status":{"phase":2,"level":60,"skillLevel":7,"equipLevel":1},"stats":{"maxHp":1674,"atk":380,"def":322,"res":0,"cost":11,"blockCnt":2,"bat":1.05,"aspd":100,"respawnTime":70,"spRecovery":1,"hpRecoveryPerSec":0,"moveSpeed":1,"tauntLevel":0,"massLevel":0,"deployLimit":1,"deckStack":0},"immunities":{"stun":false,"silence":false,"sleep":false,"frozen":false,"levitate":false},"rangeId":"1-1","rangeGrid":[[0,0],[0,1]],"dmgType":"phys","attackKind":"melee","projectile":"none","canHitFly":false,"targetPriority":null,"trait":{"desc":"能够阻挡两个敌人","descRaw":"能够阻挡两个敌人","bb":{},"bbStr":{},"rangeGrid":null},"skills":[{"skillId":"skcom_charge_cost[1]","iconId":"skcom_charge_cost[1]","name":"冲锋号令·α型","level":7,"desc":"立即获得6点部署费用","descRaw":"立即获得<@ba.vup>6</>点部署费用","skillType":"AUTO","durationType":"NONE","duration":0,"spType":"INCREASE_WITH_TIME","spCost":25,"initSp":6,"maxChargeTime":1,"increment":1,"bb":{"cost":6},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skcom_charge_cost","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":0,"overrideTokenKey":null},{"skillId":"skcom_charge_cost[2]","iconId":"skcom_charge_cost[2]","name":"冲锋号令·β型","level":7,"desc":"立即获得9点部署费用","descRaw":"立即获得<@ba.vup>9</>点部署费用","skillType":"AUTO","durationType":"NONE","duration":0,"spType":"INCREASE_WITH_TIME","spCost":33,"initSp":10,"maxChargeTime":1,"increment":1,"bb":{"cost":9},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skcom_charge_cost","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":1,"overrideTokenKey":null},{"skillId":"skcom_charge_cost[3]","iconId":"skcom_charge_cost[3]","name":"冲锋号令·γ型","level":7,"desc":"立即获得12点部署费用","descRaw":"立即获得<@ba.vup>12</>点部署费用","skillType":"AUTO","durationType":"NONE","duration":0,"spType":"INCREASE_WILine truncated {"units":{"char_600_cpione":{"charId":"char_600_cpione","name":"预备干员-先锋","appellation":"Reserve Operator - Vanguard","rarity":4,"profession":"PIONEER","subProfessionId":"pioneer","subProfessionName":"尖兵","position":"MELEE","nationId":null,"isNotObtainable":true,"assets":{"avatar":"char_600_cpione","avatarGolden":"char_600_cpione","portrait":"char_600_cpione_1","portraitGolden":"char_600_cpione_1","spine":"char_600_cpione","subProfIcon":"sub_pioneer_icon"},"moduleNames":{},"standsIn":["chess_char_5_01_a"],"forms":{"2/1/4/0":{"status":{"phase":2,"level":1,"skillLevel":4,"equipLevel":0},"stats":{"maxHp":1225,"atk":320,"def":272,"res":0,"cost":11,"blockCnt":2,"bat":1.05,"aspd":100,"respawnTime":70,"spRecovery":1,"hpRecoveryPerSec":0,"moveSpeed":1,"tauntLevel":0,"massLevel":0,"deployLimit":1,"deckStack":0},"immunities":{"stun":false,"silence":false,"sleep":false,"frozen":false,"levitate":false},"rangeId":"1-1","rangeGrid":[[0,0],[0,1]],"dmgType":"phys","attackKind":"melee","projectile":"none","canHitFly":false,"targetPriority":null,"trait":{"desc":"能够阻挡两个敌人","descRaw":"能够阻挡两个敌人","bb":{},"bbStr":{},"rangeGrid":null},"skills":[{"skillId":"skcom_charge_cost[1]","iconId":"skcom_charge_cost[1]","name":"冲锋号令·α型","level":4,"desc":"立即获得6点部署费用","descRaw":"立即获得<@ba.vup>6</>点部署费用","skillType":"AUTO","durationType":"NONE","duration":0,"spType":"INCREASE_WITH_TIME","spCost":27,"initSp":3,"maxChargeTime":1,"increment":1,"bb":{"cost":6},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skcom_charge_cost","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":0,"overrideTokenKey":null},{"skillId":"skcom_charge_cost[2]","iconId":"skcom_charge_cost[2]","name":"冲锋号令·β型","level":4,"desc":"立即获得9点部署费用","descRaw":"立即获得<@ba.vup>9</>点部署费用","skillType":"AUTO","durationType":"NONE","duration":0,"spType":"INCREASE_WITH_TIME","spCost":36,"initSp":9,"maxChargeTime":1,"increment":1,"bb":{"cost":9},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skcom_charge_cost","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":1,"overrideTokenKey":null},{"skillId":"skcom_charge_cost[3]","iconId":"skcom_charge_cost[3]","name":"冲锋号令·γ型","level":4,"desc":"立即获得12点部署费用","descRaw":"立即获得<@ba.vup>12</>点部署费用","skillType":"AUTO","durationType":"NONE","duration":0,"spType":"INCREASE_WITH_TIME","spCost":41,"initSp":12,"maxChargeTime":1,"increment":1,"bb":{"cost":12},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skcom_charge_cost","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":2,"overrideTokenKey":null}],"talents":[{"index":0,"name":"攻击提升","desc":"攻击力+8%","descRaw":"攻击力+8%","bb":{"atk":0.08},"bbStr":{},"rangeGrid":null,"tokenKey":null,"hidden":false,"fromModule":false}],"displayTokens":[],"tokens":[]},"2/60/7/1":{"status":{"phase":2,"level":60,"skillLevel":7,"equipLevel":1},"stats":{"maxHp":1674,"atk":380,"def":322,"res":0,"cost":11,"blockCnt":2,"bat":1.05,"aspd":100,"respawnTime":70,"spRecovery":1,"hpRecoveryPerSec":0,"moveSpeed":1,"tauntLevel":0,"massLevel":0,"deployLimit":1,"deckStack":0},"immunities":{"stun":false,"silence":false,"sleep":false,"frozen":false,"levitate":false},"rangeId":"1-1","rangeGrid":[[0,0],[0,1]],"dmgType":"phys","attackKind":"melee","projectile":"none","canHitFly":false,"targetPriority":null,"trait":{"desc":"能够阻挡两个敌人","descRaw":"能够阻挡两个敌人","bb":{},"bbStr":{},"rangeGrid":null},"skills":[{"skillId":"skcom_charge_cost[1]","iconId":"skcom_charge_cost[1]","name":"冲锋号令·α型","level":7,"desc":"立即获得6点部署费用","descRaw":"立即获得<@ba.vup>6</>点部署费用","skillType":"AUTO","durationType":"NONE","duration":0,"spType":"INCREASE_WITH_TIME","spCost":25,"initSp":6,"maxChargeTime":1,"increment":1,"bb":{"cost":6},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skcom_charge_cost","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":0,"overrideTokenKey":null},{"skillId":"skcom_charge_cost[2]","iconId":"skcom_charge_cost[2]","name":"冲锋号令·β型","level":7,"desc":"立即获得9点部署费用","descRaw":"立即获得<@ba.vup>9</>点部署费用","skillType":"AUTO","durationType":"NONE","duration":0,"spType":"INCREASE_WITH_TIME","spCost":33,"initSp":10,"maxChargeTime":1,"increment":1,"bb":{"cost":9},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skcom_charge_cost","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":1,"overrideTokenKey":null},{"skillId":"skcom_charge_cost[3]","iconId":"skcom_charge_cost[3]","name":"冲锋号令·γ型","level":7,"desc":"立即获得12点部署费用","descRaw":"立即获得<@ba.vup>12</>点部署费用","skillType":"AUTO","durationType":"NONE","duration":0,"spType":"INCREASE_WILine truncated
+1 -1
View File
@@ -1 +1 @@
{"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
View File
@@ -1 +1 @@
{"version":1,"lang":"en","meta":{"generator":"tools/build-i18n.mjs","source":{"id":"assets","label":"ArknightsAssets/ArknightsGamedata (en)","home":"https://github.com/ArknightsAssets/ArknightsGamedata","season":"act2autochess"},"fallback":"tools/i18n/fallback-remake.json (the remake), tools/i18n/fallback-pr70.json (PR #70 by @YuriRestia), public/i18n/en.json","coverage":{"bands":{"texts":186,"translated":186,"official":186,"pct":100},"bonds":{"texts":138,"translated":138,"official":138,"pct":100},"bosses":{"texts":66,"translated":66,"official":66,"pct":100},"choices":{"texts":679,"translated":679,"official":679,"pct":100},"config":{"texts":110,"translated":110,"official":103,"pct":100},"effects":{"texts":1083,"translated":1083,"official":1083,"pct":100},"emotes":{"texts":41,"translated":41,"official":3,"pct":100},"enemies":{"texts":1734,"translated":1734,"official":1734,"pct":100},"factions":{"texts":14,"translated":14,"official":14,"pct":100},"garrisons":{"texts":747,"translated":747,"official":747,"pct":100},"items":{"texts":582,"translated":582,"official":570,"pct":100},"modules":{"texts":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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+4 -2
View File
@@ -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.
+1 -1
View File
@@ -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.
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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、未精英化。"
} }
+94
View File
@@ -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>`;
}
+12 -6
View File
@@ -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>
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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}
+2 -2
View File
@@ -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';
+16 -4
View File
@@ -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
+44 -6
View File
@@ -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
View File
@@ -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
View File
@@ -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)) };
}, },
}); });
} }
+1 -1
View File
@@ -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
View File
@@ -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,
}; };
+8 -3
View File
@@ -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.
+9 -1
View File
@@ -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)
+5 -3
View File
@@ -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;
+14 -1
View File
@@ -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++) {
+59 -8
View File
@@ -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 };
} }
/** /**
+4 -2
View File
@@ -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 : [];
+4
View File
@@ -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') {
+3
View File
@@ -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
View File
@@ -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 自选编队) */
+10 -1
View File
@@ -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;
} }
+4 -1
View File
@@ -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 };
+1 -1
View File
@@ -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
+2 -1
View File
@@ -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;
+2 -1
View File
@@ -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)
+5 -3
View File
@@ -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);
+3 -1
View File
@@ -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
View File
@@ -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);
} }
} }
+4
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+207
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+13
View File
@@ -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
View File
@@ -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'));
+3 -2
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+3026 -3032
View File
File diff suppressed because it is too large. Load diff
+789 -792
View File
File diff suppressed because it is too large. Load diff
+338 -333
View File
File diff suppressed because it is too large. Load diff
+1529 -1532
View File
File diff suppressed because it is too large. Load diff
+49 -1
View File
@@ -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, []);
});
}); });
+4 -2
View File
@@ -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})`);
+2 -1
View File
@@ -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;
+146
View File
@@ -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();
});
+4 -2
View File
@@ -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');
+181
View File
@@ -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, '原型 自选');
});
+2 -2
View File
@@ -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');
+147
View File
@@ -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]);
});
+1 -1
View File
@@ -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 });
+80
View File
@@ -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);
}
}
}
});
+193
View File
@@ -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');
});
+1 -1
View File
@@ -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
View File
@@ -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();
}); });
+4 -3
View File
@@ -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 } } });
+183
View File
@@ -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);
});
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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;
} }