feedback5: merge W5B — stand-in (补位) and self-select (自选编队) data: chess backup fields, data/backups.json (17 stand-ins, 4 DIY slots, picks, faction bonds), shared/standIn.js
This commit is contained in:
commit
67ca229d44
9 files changed
+747
-16
No files matched your search
@@ -0,0 +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
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"chess_char_1_01_a":{"chessId":"chess_char_1_01_a","baseId":"chess_char_1_01_a","goldenId":"chess_char_1_01_b","isGolden":false,"tier":1,"identifier":0,"isHidden":false,"isDiy":false,"visible":true,"chessType":"PRESET","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":399,"def":140,"res":0,"cost":15,"blockCnt":1,"bat":1,"aspd":100,"respawnTime":80,"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","shopSortId":1,"charId":"char_498_inside","name":"隐现","appellation":"Insider","rarity":5,"profession":"SNIPER","subProfessionId":"fastshot","subProfessionName":"速射手","position":"RANGED","nationId":"laterano","bonds":["lateranoShip","swiftShip"],"garriLine 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":399,"def":140,"res":0,"cost":15,"blockCnt":1,"bat":1,"aspd":100,"respawnTime":80,"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
|
||||
+69
-5
@@ -25,7 +25,7 @@ Unknown options or a missing option value are errors (exit code 2); `--refresh`
|
||||
Integrity errors (see §17) make the exit code 1 **and leave the previous output untouched** (unless `--force`);
|
||||
warnings never do. Each output file is written atomically (temp file + rename).
|
||||
- **Determinism.** Same inputs ⇒ byte-identical outputs (stable key order, no timestamps, no randomness).
|
||||
- **Size.** ≈3.5 MB total (limit 6 MB; `chess.json` ≈1.65 MB with the loadout choices), compact JSON (no indentation).
|
||||
- **Size.** ≈3.7 MB total (limit 6 MB; `chess.json` ≈1.69 MB with the loadout choices, `backups.json` ≈0.17 MB), compact JSON (no indentation).
|
||||
- **Derived paths.** `stages.json groundPaths*` come from the sim's own `server/sim/grid.js` pathing: a change there
|
||||
needs a rebuild (the offline-rebuild test catches a stale `data/`).
|
||||
|
||||
@@ -146,6 +146,7 @@ Top level: `{ season, seasonName, modes, economy, lpCapPerRound, bossOvertimeAft
|
||||
| `identifier`, `shopSortId` | `133`, `1` | official ordering |
|
||||
| `isHidden`, `isDiy`, `visible` | | `visible = !isHidden && !isDiy` (only visible chess enter the shop pool) |
|
||||
| `chessType` | `"PRESET"` | `PRESET` / `NORMAL` / `DIY` |
|
||||
| `backup` | `{"charId":"char_611_acnipe","tmplId":null,"skillIndex":2,"uniEquipId":"uniequip_002_acnipe","potRank":0}` (`chess_char_5_22_a/_b`, 妮芙) | the shop row's official stand-in fields, verbatim (`backupCharId`, `backupTmplId`, `backupCharSkillIndex`, `backupCharUniEquipId`, `backupCharPotRank`), the same on both forms: PRESET (特许) = itself, NORMAL = the 补位 stand-in fielded when the player does not own the operator (§18), DIY = none (`charId` null). The current match ignores it (every chess fields its real operator) |
|
||||
| `charId` | `"char_498_inside"` | operator (null for DIY) |
|
||||
| `name`, `appellation` | `"隐现"`, `"Insider"` | |
|
||||
| `rarity` | `5` | stars 1–6 |
|
||||
@@ -239,7 +240,7 @@ everything needed to resolve a unit for `(chessId, skillIndex, moduleId)` (`simd
|
||||
| Field | Example (`deputShip`) | Meaning |
|
||||
|---|---|---|
|
||||
| `bondId`, `name`, `identifier` | `"deputShip"`, `"助力"`, `14` | |
|
||||
| `isCore`, `bondType`, `bondOrder`, `powerIdList` | `false`, `"REGULAR"`, `2`, `[]` | core = `isPower` (SEASON); `powerIdList` = nation/group ids for DIY bond derivation |
|
||||
| `isCore`, `bondType`, `bondOrder`, `powerIdList` | `false`, `"REGULAR"`, `2`, `[]` | core = `isPower` (SEASON); `powerIdList` = nation/group/team ids for DIY bond derivation (`backups.json diy.operators[*].bonds`, §18) |
|
||||
| `iconId` | `"icon_deputShip"` | |
|
||||
| `activeCount` | `2` | official activation count |
|
||||
| `thresholds` | `[2,3]` | ascending member counts that raise the tier (tier = number of thresholds reached). yan `[3,6,9]`, egir `[3,5]`, sunt `[2,5]`, solo `[1]` |
|
||||
@@ -487,7 +488,8 @@ Glyph legend (`rows`):
|
||||
`…_eagle1/3` stay listed (`sources:["display"]`, belong to S1/S3). With another skill selected (DESIGN §16) the
|
||||
talent follows that skill's eagle (`simdata loadoutRecord`; `variants[o].bySkill[i].sources`).
|
||||
2. **DIY chess** (`chess_char_5_diy1/2`, `chess_char_6_diy1/2`, `_a` and `_b` = 8 records): no `charId`, no stats/skill; `visible:false`,
|
||||
name placeholder `甄选干员`. Out of scope for v1.
|
||||
name placeholder `甄选干员`, not in the shop. Their picks and bond rule are in `backups.json diy` (§18); the gameplay
|
||||
is not implemented yet.
|
||||
3. **Module-less chess**: 蒂比 (`chess_char_2_13`) and 凛御银灰 (`chess_char_5_14`) have no module; their golden
|
||||
record has `module:{id:null,active:false}` and no module stat bonus.
|
||||
4. **Hidden chess (17)** are kept with `visible:false`; several operators exist in two tiers with one hidden
|
||||
@@ -542,12 +544,16 @@ Glyph legend (`rows`):
|
||||
|
||||
## 16. Counts (current build)
|
||||
|
||||
`chess 266 (112 visible; 283 selectable skills over the visible chess, 184 module choices over 129 goldens)`, `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`,
|
||||
`bounty cards 129`, `tactic cards 43`.
|
||||
`bounty cards 129`, `tactic cards 43`, `backups: 17 stand-in units (43 forms), 4 DIY slots, 15 / 9 prototype picks (tier 5 / 6), 78 owned-6★ picks`.
|
||||
|
||||
## 17. Integrity guarantees (checked by the builder and `test/data.test.js`)
|
||||
|
||||
Every PRESET chess is its own backup and the stand-in builder gives it back field for field (§18); every NORMAL chess
|
||||
(both forms) composes into its stand-in with an unlocked backup skill and, on the elite form, its module at that level;
|
||||
every DIY prototype pick has a form for both slot statuses; derived DIY bonds exist (`test/backups.test.js` also
|
||||
re-derives the backup fields, the stand-in numbers and the faction ids from the raw tables).
|
||||
Chess bonds/garrisons/tokens/base/golden ids resolve; talent tokens are in `chess.tokens`; every non-DIY chess has
|
||||
`skills[]` with exactly one default equal to `skill` (and every skill token listed in `chess.tokens`); golden module
|
||||
choices are consistent (one default iff `module.active`, base fields present; composing the default reproduces the
|
||||
@@ -566,3 +572,61 @@ generator and preview zones on the raw level files for every mode × round × te
|
||||
included) × allowed entry, and `test/sim/pathing-crosscheck.test.js` re-implements the client SPFA + smoothing over the
|
||||
whole map for every walkable tile of the normal / 联防 / boss fields of the 8 stages (extended by the road-over-floor
|
||||
preference; against the pure official algorithm: identical route lengths, never more non-blockable tiles crossed).
|
||||
|
||||
## 18. `backups.json` — 补位 stand-ins and 自选 (DIY) data — `{ units, diy }`
|
||||
|
||||
The data of two official features whose gameplay is not implemented yet (DESIGN 0.2.0 draft): **补位** — a NORMAL chess
|
||||
whose operator the player does not own is fielded as its official stand-in (原型干员) — and **自选编队** — two tier-5 and
|
||||
two tier-6 DIY slots, each filled with a 6★ the player owns or a prototype. Built by `tools/build-data.mjs buildBackups`;
|
||||
`shared/standIn.js` composes it into chess-shaped records. The rules in the data (activity_table act2autochess
|
||||
`charShopChessDatas`; PRTS 卫戍协议, 卫戍协议:盟约 下半/PRTS盟约记录):
|
||||
|
||||
- PRESET (74, 特许干员) always fields the real operator (`backup.charId` = itself); NORMAL (55) names one of 17 stand-ins —
|
||||
the 4★ 预备干员 `char_600–607`, the 6★ `char_608–615` and 领主·Sharp `char_617` (the "其它分支"); DIY (4) has none.
|
||||
- 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
|
||||
character takes S2 on one chess and S3 on another), module `backup.uniEquipId` (null at tiers 3–4 and for every 4★, so
|
||||
even their elite form has none) and potential 0 (`potRank` 0 on all 55).
|
||||
|
||||
`units[charId]` — the 17 stand-ins (no unit for a PRESET or DIY chess):
|
||||
|
||||
| Field | Example (`char_611_acnipe`) | Meaning |
|
||||
|---|---|---|
|
||||
| `charId`, `name`, `appellation`, `rarity`, `profession`, `subProfessionId`, `subProfessionName`, `position`, `nationId`, `isNotObtainable` | `"char_611_acnipe"`, `"Stormeye"`, `"Stormeye"`, `6`, `"SNIPER"`, `"fastshot"`, `"速射手"`, `"RANGED"`, `null`, `true` | as on a chess record |
|
||||
| `assets` | `{"avatar":"char_611_acnipe","avatarGolden":"char_611_acnipe","portrait":"char_611_acnipe_1","portraitGolden":"char_611_acnipe_1","spine":"char_611_acnipe","subProfIcon":"sub_fastshot_icon"}` | art ids (URLs in `data/assets.json`, which already carries all 17); the elite form takes the E2 art when it exists — only 领主·Sharp has it |
|
||||
| `moduleNames` | `{"uniequip_001_acnipe":{"name":"Stormeye证章","typeName":"ORIGINAL"},"uniequip_002_acnipe":{"name":"Stormeye证章","typeName":"MAR-X"}}` | every module of the character (a composed record names its module on the normal form too) |
|
||||
| `standsIn[]` | `["chess_char_3_21_a","chess_char_4_02_a","chess_char_5_18_a","chess_char_5_22_a","chess_char_6_01_a","chess_char_6_05_a"]` | the NORMAL base chess it replaces |
|
||||
| `forms[statusKey]` | keys `"2/1/4/0"`, `"2/60/7/1"`, `"2/60/7/3"` | the character at every status it fights at — of the chess it stands in for and of the DIY slots it may fill (`statusKey(status)` = `phase/level/skillLevel/equipLevel`): 3 forms per 6★, 2 per 4★ |
|
||||
|
||||
A **form** holds the operator fields of a chess record with **nothing selected**: `status`; `stats`, `trait`, `talents`
|
||||
**without** a module; `immunities`, `rangeId`, `rangeGrid`, `dmgType`, `attackKind`, `projectile`, `canHitFly`,
|
||||
`targetPriority`; `skills[]` — every skill unlocked at the status, at its skill level, `trigger` resolved per skill, no
|
||||
`isDefault`; `displayTokens` / `tokens` (summons — none of the 17 has one); at `equipLevel > 0` `modules[]` (§2.2 shape
|
||||
without `isDefault`). `buildUnitForm` uses buildChess's helpers and rules, and the build fails when `buildUnitForm` +
|
||||
`composeUnitRecord` do not give back every PRESET chess field for field (each is its own backup), so a later change to
|
||||
buildChess that the stand-ins would miss stops the build. No trigger deviation (§2.2, DESIGN §21.29) applies to a form —
|
||||
the deviations name chess, so the 重装 stand-ins (预备干员-重装, Mechanist) keep the official `TAKE_DAMAGE` row.
|
||||
|
||||
**Composition** (`shared/standIn.js`, pure ESM for the server, the sim and the client): `standInRecord(chess, backups)` is
|
||||
the NORMAL chess as its stand-in — `IDENTITY_FIELDS` (ids, tier, `isHidden` / `visible`, `chessType`, `backup`, `bonds`,
|
||||
`garrisonIds`, prices, merge, `status`) from the chess, every other field from the unit's form at the chess's status
|
||||
with `backup.skillIndex` / `backup.uniEquipId` as the defaults, plus `standInFor` (the replaced charId: the official 补位
|
||||
mark on the avatar). The result is shaped exactly like a chess record (`skill` = the `isDefault` entry of `skills[]`;
|
||||
elite `statsBase` / `traitBase` / `talentsBase` / `modules[]`), so `normalizeChess`, `resolveLoadout` (no loadout ⇒ the
|
||||
backup selection) and `loadoutRecord` read it unchanged; null for a PRESET or DIY chess. **Kits are keyed by base chess
|
||||
id** (`server/sim/content/index.js setupUnitKit`): a stand-in keeps the chess's ids, so its kit must be found by its
|
||||
`charId`. `diyRecord(slot, charId, backups, { skillIndex, moduleId })` fills a DIY slot with a prototype (the slot's
|
||||
tier and price, no 特质, the pick's derived bonds); which skill a prototype carries in a slot is not in the data.
|
||||
|
||||
`diy`:
|
||||
|
||||
| Field | Example | Meaning |
|
||||
|---|---|---|
|
||||
| `slots[slotId]` | `{"tier":5,"goldenId":"chess_char_5_diy1_b","shopLevel":5,"requirement":"TIER_6"}` | the four DIY chess `chess_char_5_diy1/2_a`, `chess_char_6_diy1/2_a` (chess.json: price 4, sell 1, `diyRequirement`, empty `bonds` / `garrisonIds`); `shopLevel` = the 调度中心 level whose `shopLevelDisplayDataDict.charChessDiySlotIdList` lists the slot |
|
||||
| `prototypes[tier]` | `{"5":[…15],"6":[…9]}` | the legal prototype picks: the nine 6★ at both tiers, at tier 5 also the six 4★ that are not 先锋 / 特种 ("第5阶可额外从6名四星原型干员(先锋、特种职业除外)中选取"; `DIY_EXTRA_PROTOTYPES`). A prototype may fill a tier-5 and a tier-6 slot ("原型干员可于5、6阶之间重复选取") |
|
||||
| `ownedPool[]` | 78 charIds | the owned 6★ a player may slot: obtainable, rarity = the requirement, and no chess names it — hidden chess included ("不可甄选加入已在名单中的固定干员"); each at most once per roster ("玩家已拥有干员不可重复选取"). The data does not exclude collab operators |
|
||||
| `operators[charId]` | `{"name":"煌","rarity":6,"profession":"WARRIOR","subProfessionId":"centurion","obtainable":true,"powers":["rhodes","elite","yan","victoria"],"bonds":["yanShip","victoriaShip"]}` | every pick (owned pool + prototypes): `powers` = the `nationId` / `groupId` / `teamId` of `mainPower` and of every `subPower` (隐藏势力); `bonds` = the core bonds whose `powerIdList` meets them — one or several — else `economy.fallbackBondId` 协防干员 ("甄选加入的干员会根据其实际阵营所属分配核心盟约,若没有可匹配的则改为分配协防干员盟约"); every prototype gets `["emptyShip"]` |
|
||||
|
||||
Not in this data (the gameplay workstreams): a player's ownership roster, 助战 borrows (`borrowCount` 20), DIY stock, the
|
||||
owned-operator training bonus (`prepareStateDict`), forms and kits of owned 6★ picks (the same `buildUnitForm` builds
|
||||
them once kits exist).
|
||||
@@ -48,7 +48,7 @@ Core (isPower, bondType SEASON): 炎 萨尔贡 维多利亚 谢拉格 拉特兰
|
||||
- "不同" (distinct): operators are identified by their normal chess id (chessNormalIdLookupDict maps *_b -> *_a), so a normal and an elite copy of the same operator count once. Exception: 助力 3-member tier counts operators that differ in name OR elite state (PRTS 修正). 独行 is only broken by 2+ DIFFERENT 独行 operators (PRTS 修正).
|
||||
- 调和 (maniShip): while active (>=1 调和 operator on board) every core bond (isPower=true) gets member count +1 (a single +1, not per 调和 operator), and 调和 operators receive the effects of every active core bond as if members. [ASSUMED: the +1 applies only to core bonds that already have >=1 real member on the board.] "炎佑" does not include 调和 operators' stats unless they are also 炎 (PRTS).
|
||||
- Item 变形同构体 (chess_item_6_09_e, canGiveBond) worn together with a bond item (trapChessData.giveBondId) makes the wearer an extra member of that bond. 14 bonds have such items; 灵巧/远见/奇迹/投资人/助力/调和/协防干员/独行/绝技 do not.
|
||||
- DIY (自选) chess slots chess_char_5_diy1/2 and chess_char_6_diy1/2 have bondIds [] and are filled with a 6-star operator the player owns (diyChessDict = TIER_6). Their bond is derived from the chosen operator's nationId/groupId/teamId matched against autoChessData.bondInfoDict[*].powerIdList (e.g. yan: yan,sui,lungmen,lee,lgd; victoria: victoria,glasgow; kjerag: kjerag,karlan; egir: egir,abyssal; siracusa: siracusa,chiave; kazimierz: kazimierz,pinus; sargon; laterano). No match -> constData.fallbackBondId = emptyShip (协防干员). (bwiki: operators from those nations get the nation alliance, "others receive the 协防 designation".)
|
||||
- DIY (自选) chess slots chess_char_5_diy1/2 and chess_char_6_diy1/2 have bondIds [] and are filled with a 6-star operator the player owns (diyChessDict = TIER_6). Their bond is derived from the chosen operator's nationId/groupId/teamId — of `mainPower` and of every `subPower` (PRTS 盟约 下半 record: 所属势力 and 隐藏势力; one or several core bonds; built in data/backups.json `diy.operators`, DATA.md §18) — matched against autoChessData.bondInfoDict[*].powerIdList (e.g. yan: yan,sui,lungmen,lee,lgd; victoria: victoria,glasgow; kjerag: kjerag,karlan; egir: egir,abyssal; siracusa: siracusa,chiave; kazimierz: kazimierz,pinus; sargon; laterano). No match -> constData.fallbackBondId = emptyShip (协防干员). (bwiki: operators from those nations get the nation alliance, "others receive the 协防 designation".)
|
||||
- Prototype substitution: a NORMAL chess whose operator the player does not own is fielded as its backupCharId (预备干员 / 原型干员) but keeps the chess's bonds and traits (PRTS: "烛煌(或对应的原型干员)").
|
||||
- Mode filter: 标准模拟 (mode_single_funny / mode_multi_funny) enables only 13 bonds (炎 萨尔贡 维多利亚 谢拉格 叙拉古 精准 迅捷 坚守 助力 远见 不屈 调和 协防干员); 拉特兰 阿戈尔 卡西米尔 灵巧 奥术 奇迹 投资人 突袭 独行 绝技 are inactive there. Training/险境/绝境/终极 enable all 23.
|
||||
- Stat math: attribute bonuses from 盟约, strategies and equipment are "直接乘算" (PRTS 盟约记录 "盟约效果,策略效果,装备效果提供的属性加成均为直接乘算,与自持有干员的属性加成独立"). **Corrected 2026-10-01 (DESIGN §20.10):** 直接乘算 is PRTS's technical modifier class (游戏数据基础 属性基本公式: "直接乘算结果 D_t = t₁ + t₂ + … + tₙ", A = (A₀ + D_p)(1 + D_t); 作战机制: X₂ = X₁(1 + b₁% + b₂%), a skill's "攻击力+180%" being the example) — the values are SUMMED with each other and with the skills' "+X%"; "与自持有干员的属性加成独立" means they are separate from the **自持有加成** (PRTS 卫戍协议:盟约 下半, its own table: an operator the player really owns gets +5–10 % ATK / DEF / max HP by its real promotion — 精英1 Lv.1 +5/+5/–, 精英2 Lv.1 +10/+5/+5, 精英2 Lv.60 +10/+10/+10, the highest met applies), which the remake does not model. E.g. 炎 +50% ATK and an item +30% ATK -> final = base_atk x (1 + 0.5 + 0.3) (+ any skill %), not x 1.5 x 1.3 (the reading used until v2.5).
|
||||
|
||||
@@ -26,8 +26,8 @@ Rich-text tags (`<@ba.vup>…</>`, `<$ba.inspire>…</>`) are stripped in this d
|
||||
### 1.2 chessType, isHidden, backup (原型/替补) and DIY (甄选)
|
||||
|
||||
- `PRESET` (74): 特许干员 — all 4★ (19) / 5★ (48) and 7 non-gacha 6★ (伺夜, 信仰搅拌机, 歌蕾蒂娅, 魔王 x2, 流明, 溯光星源); usable even if the player does not own them.
|
||||
- `NORMAL` (55): all 6★ gacha operators. If the player owns the operator, their own copy is used; otherwise a friend-support copy (up to `borrowCount`=20) or the **backup** operator: `backupCharId` is a special non-obtainable stand-in (预备干员 4★ `char_600..607` or 6★ "罗德岛特派高级干员" `char_608..617` = 郁金香/Sharp/Mechanist/Stormeye/Pith/Touch/Raidian/Misery/领主·Sharp) with `backupCharSkillIndex`. Backups keep the chess's bonds and 特质. **For the fan remake: always use the real operator** (no ownership system); backup data is kept in JSON (`backup`).
|
||||
- `DIY` (4): `chess_char_5_diy1/2_a`, `chess_char_6_diy1/2_a` — 甄选 slots (2 in tier 5, 2 in tier 6). `diyChessDict` = `TIER_6` means the player may put in **one of their own 6★ operators** not already in the pool; bondIds = [] and garrisonIds = null, so it gets the fallback bond `emptyShip` (协防干员) and no 特质. Status same as tier 5/6 chess. Remake suggestion [ASSUMED]: either drop DIY slots or let each player pick any 6★ from a curated list before the match.
|
||||
- `NORMAL` (55): all 6★ gacha operators. If the player owns the operator, their own copy is used; otherwise a friend-support copy (up to `borrowCount`=20) or the **backup** operator: `backupCharId` is a special non-obtainable stand-in (预备干员 4★ `char_600..607` or 6★ "罗德岛特派高级干员" `char_608..617` = 郁金香/Sharp/Mechanist/Stormeye/Pith/Touch/Raidian/Misery/领主·Sharp) with `backupCharSkillIndex`. Backups keep the chess's bonds and 特质. **For the fan remake: always use the real operator** (no ownership system); backup data is kept in JSON (`backup`) — since 0.2.0 also in the built data/chess.json (`backup`), with the 17 stand-ins built in data/backups.json (docs/DATA.md §18).
|
||||
- `DIY` (4): `chess_char_5_diy1/2_a`, `chess_char_6_diy1/2_a` — 甄选 slots (2 in tier 5, 2 in tier 6). `diyChessDict` = `TIER_6` means the player may put in **one of their own 6★ operators** not already in the pool; bondIds = [] and garrisonIds = null: no 特质, and the bonds come from the chosen operator's factions — `mainPower` and every `subPower` against the core bonds' `powerIdList`, one or several — else the fallback bond `emptyShip` (协防干员) (PRTS 卫戍协议; data/backups.json `diy.operators`, DATA.md §18). Status same as tier 5/6 chess. Remake suggestion [ASSUMED]: either drop DIY slots or let each player pick any 6★ from a curated list before the match.
|
||||
- `isHidden = true` (17 chess): not offered in the current shop pool. Best explained as **上半期 entries retired in the 下半期 rebalance** (official news 5114: "调整部分上半期预设干员的等阶、特质及所属盟约"): 8 of them are operators that now have a different visible tier entry; `chess_char_1_15_a` 盟约·辅助干员 (char_616_pithst, bonds 调和+协防干员) is a special piece granted only by strategy band effect `aceffect_band_42` 优等生 (round 1: gain 1 special operator); the other 8 (红豆, 地灵, 崖心, 协律, 见行者, 巫恋, 蜜蜡, 瑰盐) were dropped from the pool. For the remake, **exclude hidden chess from the shop pool** (keep them in data only; a few effects may still reference them - verify in shop/garrison research). Operators appearing in two tiers: 锡人 (char_4151_tinman): chess_char_1_16 hidden, chess_char_2_19; 耶拉 (char_4013_kjera): chess_char_3_20, chess_char_4_03 hidden; 录武官 (char_4196_reckpr): chess_char_4_15 hidden, chess_char_5_23; 白面鸮 (char_128_plosis): chess_char_4_21, chess_char_5_16 hidden; 百炼嘉维尔 (char_1026_gvial2): chess_char_4_23, chess_char_5_18 hidden; 魔王 (char_4134_cetsyr): chess_char_4_25, chess_char_5_09 hidden; 华法琳 (char_171_bldsk): chess_char_4_26, chess_char_5_04 hidden; 妮芙 (char_4146_nymph): chess_char_5_22, chess_char_6_10 hidden. Hidden list: 1_05 红豆, 1_11 地灵, 1_15 盟约·辅助干员, 1_16 锡人, 2_03 崖心, 2_15 协律, 3_07 见行者, 3_15 巫恋, 4_03 耶拉, 4_05 蜜蜡, 4_08 瑰盐, 4_15 录武官, 5_04 华法琳, 5_09 魔王, 5_16 白面鸮, 5_18 百炼嘉维尔, 6_10 妮芙.
|
||||
- Owned-operator bonus (`autoChessData.prepareStateDict`, applies in the real game only when the player owns the operator; **not needed for the remake**): aceffect_char_1 ATK×1.05; _2 ATK×1.05 DEF×1.05; _3 ATK×1.10 DEF×1.05 HP×1.05; _4 ATK/DEF/HP×1.10 (by the player's own training level; community: E1 / E2 Lv1 / E2 Lv60+).
|
||||
|
||||
@@ -1987,4 +1987,4 @@ Consequences for the current pool:
|
||||
The per-match ban removes every visible chess whose bonds are **all** in the disabled set. The rule is verified; see 01 Addendum A2. Elites of banned chess are also unobtainable. Hidden chess (17) stay out of the shop as §1.2 says.
|
||||
|
||||
### C4. DIY (甄选) recommendation
|
||||
The 2024 rule text reads "对于五六阶干员,除了目标干员外,还各共开放了2个甄选干员名额,博士可以选择等阶加入精英干员或自己在活动外已有的六星干员". The remake has no account roster. **Recommendation:** v1 disables the 4 DIY chess, so they are not in the pool or the copy tables. v2 lets each player pre-pick up to 2 T5 and 2 T6 from a curated 6★ list. Stats come from `character_table` at the tier's status. The bond is derived from nation/group/team through `powerIdList`, else 协防干员. There is no 特质.
|
||||
The 2024 rule text reads "对于五六阶干员,除了目标干员外,还各共开放了2个甄选干员名额,博士可以选择等阶加入精英干员或自己在活动外已有的六星干员". The remake has no account roster. **Recommendation:** v1 disables the 4 DIY chess, so they are not in the pool or the copy tables. v2 lets each player pre-pick up to 2 T5 and 2 T6 from a curated 6★ list. Stats come from `character_table` at the tier's status. The bond is derived from the nation/group/team of `mainPower` and every `subPower` through `powerIdList` (several core bonds possible), else 协防干员 (data/backups.json `diy.operators`, DATA.md §18). There is no 特质.
|
||||
@@ -0,0 +1,131 @@
|
||||
// shared/standIn.js — a chess fielded with another character's body: the 补位 stand-in (原型干员) of a NORMAL chess
|
||||
// whose operator the player does not own, and a 自选 (DIY) slot filled with a prototype. Pure ESM shared by the build
|
||||
// (tools/build-data.mjs proves composeUnitRecord rebuilds every PRESET chess from its own unit form), the simulation and
|
||||
// the client. Data: data/backups.json (docs/DATA.md §18).
|
||||
//
|
||||
// `backups.units[charId].forms[statusKey]` holds what a character is at one training status (stats, trait, talents,
|
||||
// every skill unlocked there, the modules of that equip level) with nothing selected. composeUnitRecord joins a form to
|
||||
// the identity of a chess record — chess id, tier, bonds, 特质 (garrisonIds), price, merge, status: PRTS 「卫戍协议」
|
||||
// "原型干员会继承其补位干员的盟约 / 特质" — with a selected skill and module, and returns a record shaped exactly like a
|
||||
// data/chess.json record whose `skill` / `module` defaults are that selection: normalizeChess, resolveLoadout /
|
||||
// loadoutRecord and the detail card read it unchanged. A stand-in's skill and module are fixed by the chess
|
||||
// (`backup.skillIndex` / `backup.uniEquipId`; Bilibili wiki 盟约: "对于补位干员其技能不可更改"), so a caller resolves it
|
||||
// with no loadout. Sim kits are keyed by base chess id (server/sim/content/index.js setupUnitKit): a stand-in keeps the
|
||||
// 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';
|
||||
|
||||
/** The key of a unit form: a chess record's `status` as `${phase}/${level}/${skillLevel}/${equipLevel}`. */
|
||||
export function statusKey(status) {
|
||||
const s = status && typeof status === 'object' ? status : {};
|
||||
return `${s.phase ?? 0}/${s.level ?? 1}/${s.skillLevel ?? 1}/${s.equipLevel ?? 0}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fields a composed record takes from the chess record (its identity); every other field comes from the character.
|
||||
* tools/build-data.mjs fails the build when a chess field is neither one of these nor set by composeUnitRecord.
|
||||
*/
|
||||
export const IDENTITY_FIELDS = Object.freeze([
|
||||
'chessId', 'baseId', 'goldenId', 'isGolden', 'tier', 'identifier', 'isHidden', 'isDiy', 'visible', 'chessType', 'backup',
|
||||
'shopSortId', 'bonds', 'garrisonIds', 'price', 'sellPrice', 'upgradeNum', 'upgradeChessId', 'status', 'diyRequirement',
|
||||
]);
|
||||
|
||||
/** The form of `charId` at a chess `status`, or null. */
|
||||
export function unitForm(backups, charId, status) {
|
||||
const forms = backups?.units?.[charId]?.forms;
|
||||
return (forms && Object.prototype.hasOwnProperty.call(forms, statusKey(status)) && forms[statusKey(status)]) || null;
|
||||
}
|
||||
|
||||
const byNatural = (a, b) => String(a).localeCompare(String(b), 'en', { numeric: true });
|
||||
|
||||
/**
|
||||
* A chess record fielded with the character of `unit` at `form`, with the selected skill and module (a new object).
|
||||
* 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
|
||||
* `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.
|
||||
* @param {object} identity the data/chess.json record whose identity the unit takes (IDENTITY_FIELDS)
|
||||
* @param {object} unit backups.units[charId]
|
||||
* @param {object} form backups.units[charId].forms[statusKey(identity.status)]
|
||||
* @param {{ skillIndex: number, moduleId?: string|null, standInFor?: string|null, bonds?: string[]|null }} sel
|
||||
* `bonds` replaces the identity's (a DIY slot's are empty: the pick's derived bonds); `standInFor` = the replaced
|
||||
* operator's charId (the official 补位 mark on the avatar)
|
||||
* @returns {object|null} null when a part is missing
|
||||
*/
|
||||
export function composeUnitRecord(identity, unit, form, { skillIndex, moduleId = null, standInFor = null, bonds = null } = {}) {
|
||||
if (!identity || typeof identity !== 'object' || !unit || !form) return null;
|
||||
const out = {};
|
||||
for (const k of IDENTITY_FIELDS) if (Object.prototype.hasOwnProperty.call(identity, k)) out[k] = identity[k];
|
||||
if (Array.isArray(bonds)) out.bonds = [...bonds];
|
||||
const equipLevel = identity.status?.equipLevel || 0;
|
||||
const skills = Array.isArray(form.skills) ? form.skills : [];
|
||||
const skill = skills.find((s) => s && s.index === skillIndex) ?? null;
|
||||
const mods = Array.isArray(form.modules) ? form.modules : null;
|
||||
const mod = moduleId && mods ? mods.find((m) => m && m.uniEquipId === moduleId) ?? null : null;
|
||||
const talents = mod ? composeTalents(form.talents, mod.talentChanges) : form.talents;
|
||||
// summons: listed by the character, produced by the selected skill or a talent (buildChess `tokens`)
|
||||
const known = new Set(form.tokens || []);
|
||||
const tokenIds = new Set([...(form.displayTokens || []), skill?.overrideTokenKey, ...(talents || []).map((t) => t?.tokenKey)]
|
||||
.filter((id) => id && known.has(id)));
|
||||
const info = moduleId ? unit.moduleNames?.[moduleId] ?? null : null;
|
||||
const golden = !!identity.isGolden;
|
||||
const a = unit.assets || {};
|
||||
Object.assign(out, {
|
||||
charId: unit.charId, name: unit.name, appellation: unit.appellation, rarity: unit.rarity, profession: unit.profession,
|
||||
subProfessionId: unit.subProfessionId, subProfessionName: unit.subProfessionName, position: unit.position,
|
||||
nationId: unit.nationId,
|
||||
stats: mod ? composeStats(form.stats, mod.attr) : form.stats,
|
||||
immunities: form.immunities, rangeId: form.rangeId, rangeGrid: form.rangeGrid,
|
||||
dmgType: form.dmgType, attackKind: form.attackKind, projectile: form.projectile, canHitFly: form.canHitFly,
|
||||
targetPriority: form.targetPriority,
|
||||
trait: (mod && mod.traitOverride) || form.trait,
|
||||
skill: skill ? { ...skill } : null,
|
||||
talents,
|
||||
tokens: [...tokenIds].sort(byNatural),
|
||||
module: moduleId
|
||||
? { id: moduleId, name: info?.name ?? null, type: info?.typeName ?? null, level: equipLevel, active: equipLevel > 0 && !!mod }
|
||||
: (equipLevel > 0 ? { id: null, name: null, type: null, level: equipLevel, active: false } : null),
|
||||
assets: {
|
||||
avatar: golden ? a.avatarGolden : a.avatar, portrait: golden ? a.portraitGolden : a.portrait, spine: a.spine,
|
||||
skillIcon: skill?.iconId || null, subProfIcon: a.subProfIcon,
|
||||
},
|
||||
skills: skills.map((s) => ({ ...s, isDefault: s.index === skillIndex })),
|
||||
});
|
||||
if (golden && equipLevel > 0) {
|
||||
out.statsBase = form.stats;
|
||||
out.traitBase = form.trait;
|
||||
out.talentsBase = form.talents;
|
||||
out.modules = (mods || []).map((m) => ({ ...m, isDefault: m.uniEquipId === moduleId }));
|
||||
}
|
||||
if (standInFor) out.standInFor = standInFor;
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The 补位 record of a chess: a NORMAL chess (normal or elite) fielded as its official stand-in — `backup.charId` at
|
||||
* the chess's own status, skill `backup.skillIndex`, module `backup.uniEquipId` (none when null), potential 0 — with
|
||||
* the chess's bonds, 特质, tier, price and merge. Null for a PRESET (特许: always the real operator) or DIY chess, or
|
||||
* when the data lacks the unit / form.
|
||||
* @param {object} chess data/chess.json record
|
||||
* @param {object} backups data/backups.json
|
||||
*/
|
||||
export function standInRecord(chess, backups) {
|
||||
const b = chess?.backup;
|
||||
if (!b || chess.chessType !== 'NORMAL' || !b.charId || b.charId === chess.charId) return null;
|
||||
const unit = backups?.units?.[b.charId] ?? null;
|
||||
return composeUnitRecord(chess, unit, unitForm(backups, b.charId, chess.status),
|
||||
{ skillIndex: b.skillIndex, moduleId: b.uniEquipId ?? null, standInFor: chess.charId });
|
||||
}
|
||||
|
||||
/**
|
||||
* A 自选 slot (DIY chess record, normal or elite) filled with a prototype `charId` (backups.diy.prototypes[tier]):
|
||||
* the slot's identity (tier, price, no 特质) with the pick's derived bonds (backups.diy.operators[charId].bonds). The
|
||||
* skill and module are the caller's — which skill a prototype carries in a slot is not in the data (DESIGN 0.2.0
|
||||
* draft, open question). Null when the pick is not a legal prototype of the slot's tier or the data lacks it.
|
||||
*/
|
||||
export function diyRecord(slot, charId, backups, { skillIndex, moduleId = null } = {}) {
|
||||
if (!slot?.isDiy || !(backups?.diy?.prototypes?.[slot.tier] || []).includes(charId)) return null;
|
||||
const unit = backups.units?.[charId] ?? null;
|
||||
return composeUnitRecord(slot, unit, unitForm(backups, charId, slot.status),
|
||||
{ skillIndex, moduleId, bonds: backups.diy.operators?.[charId]?.bonds ?? null });
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
// test/backups.test.js — the data of 补位 (stand-ins) and 自选 (DIY slots): chess.json `backup` and data/backups.json
|
||||
// (docs/DATA.md §18; tools/build-data.mjs buildBackups; shared/standIn.js). The gameplay of both features comes later;
|
||||
// these tests pin the data the gameplay will read: the 133 base chess and their types, the 17 stand-in characters and
|
||||
// their forms, every NORMAL chess resolving to its stand-in, the 4 DIY slots, the legal picks and the faction bonds.
|
||||
// With the official-data cache (.cache/gamedata) the backup fields, the stand-in numbers and the bond derivation are
|
||||
// re-derived from the raw tables.
|
||||
// Run: node --test test/backups.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 { standInRecord, diyRecord, composeUnitRecord, unitForm, statusKey, IDENTITY_FIELDS } from '../shared/standIn.js';
|
||||
import { composeStats, composeTalents, resolveLoadout, loadoutRecord, normalizeChess } from '../server/sim/simdata.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', 'skill_table', 'battle_equip_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 backups = load('backups');
|
||||
const bonds = load('bonds');
|
||||
const config = load('config');
|
||||
const raw = (f) => JSON.parse(readFileSync(join(CACHE, 'excel', `${f}.json`), 'utf8'));
|
||||
|
||||
const all = Object.values(chess);
|
||||
const base = all.filter((c) => !c.isGolden);
|
||||
const ofType = (t) => base.filter((c) => c.chessType === t);
|
||||
const isFiniteNum = (v) => typeof v === 'number' && Number.isFinite(v);
|
||||
const ELITES = ['char_608_acpion', 'char_609_acguad', 'char_610_acfend', 'char_611_acnipe', 'char_612_accast', 'char_613_acmedc', 'char_614_acsupo', 'char_615_acspec', 'char_617_sharp2'];
|
||||
const RESERVES = ['char_600_cpione', 'char_601_cguard', 'char_602_cdfend', 'char_603_csnipe', 'char_604_ccast', 'char_605_cmedic', 'char_606_csuppo', 'char_607_cspec'];
|
||||
|
||||
test('chess: 133 base chess — 74 PRESET / 55 NORMAL / 4 DIY — and every record keeps the five official backup fields', () => {
|
||||
assert.equal(base.length, 133);
|
||||
assert.deepEqual([ofType('PRESET').length, ofType('NORMAL').length, ofType('DIY').length], [74, 55, 4]);
|
||||
for (const c of all) {
|
||||
assert.deepEqual(Object.keys(c.backup), ['charId', 'tmplId', 'skillIndex', 'uniEquipId', 'potRank'], `${c.chessId}: backup fields`);
|
||||
assert.deepEqual(c.backup, chess[c.baseId].backup, `${c.chessId}: the elite shares its base row's backup`);
|
||||
assert.equal(c.backup.tmplId, null);
|
||||
}
|
||||
for (const c of ofType('PRESET')) assert.equal(c.backup.charId, c.charId, `${c.chessId}: 特许 — its own backup`);
|
||||
for (const c of ofType('NORMAL')) {
|
||||
assert.ok(c.backup.charId && c.backup.charId !== c.charId, `${c.chessId}: a NORMAL chess names another character`);
|
||||
assert.equal(c.backup.potRank, 0, `${c.chessId}: stand-ins fight at potential 0`);
|
||||
assert.ok(c.tier >= 3, `${c.chessId}: no NORMAL chess below tier 3`);
|
||||
}
|
||||
for (const c of ofType('DIY')) assert.deepEqual(c.backup, { charId: null, tmplId: null, skillIndex: 0, uniEquipId: null, potRank: 0 });
|
||||
// the only stored potential above 0: 宴 and 野鬃, both PRESET (their own backup)
|
||||
assert.deepEqual(base.filter((c) => c.backup.potRank !== 0).map((c) => c.chessId), ['chess_char_1_18_a', 'chess_char_1_19_a']);
|
||||
assert.deepEqual(ofType('NORMAL').filter((c) => c.isHidden).map((c) => c.chessId), ['chess_char_5_18_a', 'chess_char_6_10_a']);
|
||||
});
|
||||
|
||||
test('chess: the backup fields are the official shop row, verbatim', { skip: !HAS_CACHE && 'no .cache/gamedata' }, () => {
|
||||
const act = raw('activity_table').activity.AUTOCHESS_SEASON.act2autochess;
|
||||
for (const c of all) {
|
||||
const s = act.charShopChessDatas[c.baseId];
|
||||
assert.deepEqual(c.backup, { charId: s.backupCharId, tmplId: s.backupTmplId, skillIndex: s.backupCharSkillIndex, uniEquipId: s.backupCharUniEquipId, potRank: s.backupCharPotRank }, c.chessId);
|
||||
assert.equal(c.chessType, s.chessType, c.chessId);
|
||||
}
|
||||
});
|
||||
|
||||
test('backups: the 17 stand-in characters, each with a form for every status it fights at', () => {
|
||||
const ids = Object.keys(backups.units);
|
||||
assert.deepEqual(ids, [...RESERVES, ...ELITES]);
|
||||
assert.deepEqual([...new Set(ofType('NORMAL').map((c) => c.backup.charId))].sort(), [...ids].sort(), 'the units are exactly the NORMAL chess backups');
|
||||
const statKeys = ['maxHp', 'atk', 'def', 'res', 'cost', 'blockCnt', 'bat', 'aspd', 'respawnTime', 'spRecovery', 'moveSpeed'];
|
||||
for (const [id, u] of Object.entries(backups.units)) {
|
||||
assert.equal(u.charId, id);
|
||||
assert.equal(u.rarity, ELITES.includes(id) ? 6 : 4, `${id}: rarity`);
|
||||
assert.equal(u.isNotObtainable, true, `${id}: a prototype cannot be owned`);
|
||||
assert.ok(u.name && u.profession && u.subProfessionId && u.subProfessionName && u.position, `${id}: identity`);
|
||||
assert.deepEqual(u.standsIn, ofType('NORMAL').filter((c) => c.backup.charId === id).map((c) => c.chessId).sort((a, b) => a.localeCompare(b, 'en', { numeric: true })), `${id}: standsIn`);
|
||||
assert.ok(u.assets.avatar === id && u.assets.spine === id && u.assets.portrait === `${id}_1` && u.assets.subProfIcon === `sub_${u.subProfessionId}_icon`, `${id}: assets`);
|
||||
// reserves: E2 Lv1 / E2 Lv60 module 1; elites also the tier-6 elite row (module 3)
|
||||
assert.deepEqual(Object.keys(u.forms), ELITES.includes(id) ? ['2/1/4/0', '2/60/7/1', '2/60/7/3'] : ['2/1/4/0', '2/60/7/1'], `${id}: forms`);
|
||||
for (const [key, f] of Object.entries(u.forms)) {
|
||||
const label = `${id}@${key}`;
|
||||
assert.equal(statusKey(f.status), key, label);
|
||||
for (const k of statKeys) assert.ok(isFiniteNum(f.stats[k]), `${label}: stats.${k}`);
|
||||
assert.ok(f.stats.maxHp > 0 && f.stats.atk > 0 && f.stats.bat > 0, `${label}: positive stats`);
|
||||
assert.ok(Array.isArray(f.rangeGrid) && f.rangeGrid.length > 0, `${label}: range`);
|
||||
assert.ok(['phys', 'arts', 'heal'].includes(f.dmgType) && ['melee', 'ranged', 'heal'].includes(f.attackKind), `${label}: classification`);
|
||||
assert.ok(typeof f.trait?.desc === 'string' && f.trait.desc.length > 0, `${label}: trait`);
|
||||
assert.ok(Array.isArray(f.talents) && f.talents.length > 0, `${label}: talents`);
|
||||
assert.deepEqual(f.skills.map((s) => s.index), u.charId === 'char_617_sharp2' ? [0] : [0, 1, 2], `${label}: every skill unlocked at E2`);
|
||||
for (const s of f.skills) {
|
||||
assert.equal(s.level, f.status.skillLevel, `${label} ${s.skillId}: level`);
|
||||
assert.ok(typeof s.trigger?.rule === 'string' && !('isDefault' in s), `${label} ${s.skillId}: trigger, no selection`);
|
||||
assert.ok(!/\{[a-z_@.[\]]+(:[0-9.%]+)?\}/i.test(s.desc), `${label} ${s.skillId}: resolved text`);
|
||||
for (const [k, v] of Object.entries(s.bb)) assert.ok(isFiniteNum(v), `${label} ${s.skillId}: bb ${k}`);
|
||||
}
|
||||
assert.deepEqual(f.tokens, [], `${label}: no prototype summons anything`);
|
||||
if (f.status.equipLevel === 0) assert.equal(f.modules, undefined, `${label}: no module phase on the normal form`);
|
||||
else assert.deepEqual(f.modules.map((m) => [m.uniEquipId, m.level]), ELITES.includes(id) ? [[`uniequip_002_${id.replace(/^char_\d+_/, '')}`, f.status.equipLevel]] : [], `${label}: modules`);
|
||||
}
|
||||
}
|
||||
// the official 重装 class row applies to the stand-ins' MANUAL skills (the §21.29 deviation is per chess and names
|
||||
// none of them)
|
||||
for (const id of ['char_602_cdfend', 'char_610_acfend']) for (const s of backups.units[id].forms['2/1/4/0'].skills) assert.equal(s.trigger.rule, 'TAKE_DAMAGE', `${id} ${s.skillId}`);
|
||||
assert.equal(backups.units.char_608_acpion.forms['2/1/4/0'].skills[2].trigger.rule, 'SKILL_RANGE', '郁金香 S3 对周围的敌人: its own x-1');
|
||||
assert.equal(backups.units.char_617_sharp2.assets.avatarGolden, 'char_617_sharp2_2', '领主·Sharp has E2 art');
|
||||
});
|
||||
|
||||
test('backups: every NORMAL chess (both forms) resolves to its stand-in with the chess skill and module, as a chess-shaped record', () => {
|
||||
let n = 0;
|
||||
for (const c of all.filter((x) => x.chessType === 'NORMAL')) {
|
||||
const r = standInRecord(c, backups);
|
||||
assert.ok(r, `${c.chessId}: stand-in`);
|
||||
n++;
|
||||
// the chess keeps its identity: bonds, 特质, tier, price, merge, status
|
||||
for (const k of IDENTITY_FIELDS) if (k in c) assert.deepEqual(r[k], c[k], `${c.chessId}: ${k} kept`);
|
||||
assert.equal(r.charId, c.backup.charId);
|
||||
assert.equal(r.standInFor, c.charId);
|
||||
assert.equal(r.name, backups.units[c.backup.charId].name);
|
||||
// skill: the chess's index at the chess skill level; one default = skill
|
||||
assert.equal(r.skill.index, c.backup.skillIndex, `${c.chessId}: skill index`);
|
||||
assert.equal(r.skill.level, c.status.skillLevel, `${c.chessId}: skill level`);
|
||||
const defs = r.skills.filter((s) => s.isDefault);
|
||||
assert.equal(defs.length, 1);
|
||||
const { isDefault, ...d } = defs[0];
|
||||
assert.deepEqual(d, r.skill);
|
||||
assert.equal(r.assets.skillIcon, r.skill.iconId);
|
||||
// module: the chess's (none at tiers 3–4 and for the reserves), active only on the elite form
|
||||
assert.equal(r.module?.id ?? null, c.backup.uniEquipId ?? null, `${c.chessId}: module id`);
|
||||
if (c.status.equipLevel === 0) {
|
||||
assert.ok(!r.module?.active && r.statsBase === undefined && r.modules === undefined, `${c.chessId}: normal form, no module`);
|
||||
} else {
|
||||
assert.equal(r.module.active, !!c.backup.uniEquipId, `${c.chessId}: module active`);
|
||||
assert.equal(r.module.level, c.status.equipLevel);
|
||||
const dm = r.modules.find((m) => m.isDefault) ?? null;
|
||||
assert.equal(dm?.uniEquipId ?? null, c.backup.uniEquipId ?? null);
|
||||
assert.deepEqual(composeStats(r.statsBase, dm?.attr), r.stats, `${c.chessId}: statsBase + module = stats`);
|
||||
assert.deepEqual(dm?.traitOverride ?? r.traitBase, r.trait);
|
||||
assert.deepEqual(composeTalents(r.talentsBase, dm?.talentChanges), r.talents);
|
||||
}
|
||||
// the chess's loadout machinery reads it unchanged: no loadout = the backup selection; the sim normalises it
|
||||
assert.equal(loadoutRecord(r, resolveLoadout(r, null)), r);
|
||||
const def = normalizeChess(r);
|
||||
assert.deepEqual([def.id, def.baseId, def.charId, def.spine, def.skill.id, def.tier], [c.chessId, c.baseId, c.backup.charId, c.backup.charId, r.skill.skillId, c.tier]);
|
||||
assert.deepEqual(def.bonds, c.bonds);
|
||||
}
|
||||
assert.equal(n, 110);
|
||||
for (const c of all.filter((x) => x.chessType !== 'NORMAL')) assert.equal(standInRecord(c, backups), null, `${c.chessId}: PRESET / DIY have no stand-in`);
|
||||
// spot checks (zh_CN client data): 妮芙 → Stormeye S3 旋臂 + MAR-X; 莫斯提马 (T4) → Stormeye S2 without a module even
|
||||
// on the elite; 琳琅诗怀雅 → 预备干员-医疗 S3; 维娜·维多利亚 → 领主·Sharp S1 with LOR-X Lv3
|
||||
const s = (id) => standInRecord(chess[id], backups);
|
||||
assert.deepEqual([s('chess_char_5_22_b').skill.name, s('chess_char_5_22_b').module.type, s('chess_char_5_22_b').bonds], ['旋臂', 'MAR-X', ['swiftShip']]);
|
||||
assert.deepEqual([s('chess_char_4_02_b').skill.name, s('chess_char_4_02_b').module], ['心手合一', { id: null, name: null, type: null, level: 1, active: false }]);
|
||||
assert.deepEqual([s('chess_char_3_04_a').name, s('chess_char_3_04_a').skill.name, s('chess_char_3_04_a').garrisonIds], ['预备干员-医疗', '治疗强化·γ型', ['garrison_98_a']]);
|
||||
assert.deepEqual([s('chess_char_6_07_b').skill.name, s('chess_char_6_07_b').module.type, s('chess_char_6_07_b').module.level, s('chess_char_6_07_b').assets.avatar], ['沉默的爆发', 'LOR-X', 3, 'char_617_sharp2_2']);
|
||||
// 26 of the 55 stand-ins fight in another profession than the operator they replace
|
||||
assert.equal(ofType('NORMAL').filter((c) => standInRecord(c, backups).profession !== c.profession).length, 26);
|
||||
});
|
||||
|
||||
test('backups: the stand-in numbers re-derived from the raw official tables', { skip: !HAS_CACHE && 'no .cache/gamedata' }, () => {
|
||||
const CT = raw('character_table'), ST = raw('skill_table'), BE = raw('battle_equip_table');
|
||||
const PH = { PHASE_0: 0, PHASE_1: 1, PHASE_2: 2 };
|
||||
let n = 0;
|
||||
for (const c of all.filter((x) => x.chessType === 'NORMAL')) {
|
||||
const r = standInRecord(c, backups);
|
||||
const ch = CT[c.backup.charId];
|
||||
const P = ch.phases[c.status.phase];
|
||||
const k0 = P.attributesKeyFrames[0], k1 = P.attributesKeyFrames[P.attributesKeyFrames.length - 1];
|
||||
const t = (c.status.level - k0.level) / (k1.level - k0.level || 1);
|
||||
const f = (key) => k0.data[key] + (k1.data[key] - k0.data[key]) * t;
|
||||
const exp = { maxHp: f('maxHp'), atk: f('atk'), def: f('def'), res: f('magicResistance'), aspd: f('attackSpeed'), blockCnt: f('blockCnt'), cost: f('cost') };
|
||||
const map = { max_hp: 'maxHp', atk: 'atk', def: 'def', magic_resistance: 'res', attack_speed: 'aspd', block_cnt: 'blockCnt', cost: 'cost' };
|
||||
if (c.status.equipLevel > 0 && c.backup.uniEquipId) {
|
||||
for (const b of BE[c.backup.uniEquipId].phases.find((p) => p.equipLevel === c.status.equipLevel).attributeBlackboard) if (map[b.key]) exp[map[b.key]] += b.value;
|
||||
}
|
||||
for (const [key, v] of Object.entries(exp)) assert.ok(Math.abs(v - r.stats[key]) <= 0.5 + 1e-9, `${c.chessId} → ${ch.name}: ${key} ${r.stats[key]} vs official ${v}`);
|
||||
const se = ch.skills[c.backup.skillIndex];
|
||||
assert.ok(PH[se.unlockCond?.phase ?? 'PHASE_0'] <= c.status.phase, `${c.chessId}: S${c.backup.skillIndex + 1} unlocked at E${c.status.phase}`);
|
||||
const lv = ST[se.skillId].levels[c.status.skillLevel - 1];
|
||||
assert.equal(r.skill.skillId, se.skillId);
|
||||
assert.deepEqual([r.skill.spCost, r.skill.initSp, r.skill.duration, r.skill.name], [lv.spData.spCost, lv.spData.initSp, lv.duration, lv.name], `${c.chessId}: skill sp`);
|
||||
for (const e of lv.blackboard) if (!(e.valueStr && e.value === 0)) assert.ok(Math.abs(r.skill.bb[e.key] - e.value) < 1e-6, `${c.chessId}: skill bb ${e.key}`);
|
||||
n++;
|
||||
}
|
||||
assert.equal(n, 110);
|
||||
});
|
||||
|
||||
test('DIY: four slots (tiers 5, 5, 6, 6) — price 4, sell 1, TIER_6, no 特质, no bonds; listed from the matching shop level', () => {
|
||||
const slots = backups.diy.slots;
|
||||
assert.deepEqual(Object.keys(slots), ['chess_char_5_diy1_a', 'chess_char_5_diy2_a', 'chess_char_6_diy1_a', 'chess_char_6_diy2_a']);
|
||||
for (const [id, s] of Object.entries(slots)) {
|
||||
assert.equal(s.goldenId, id.replace(/_a$/, '_b'));
|
||||
assert.equal(s.shopLevel, s.tier, `${id}: enters the shop at the shop level of its tier`);
|
||||
assert.equal(s.requirement, 'TIER_6');
|
||||
for (const c of [chess[id], chess[s.goldenId]]) {
|
||||
assert.deepEqual([c.isDiy, c.chessType, c.tier, c.price, c.sellPrice, c.diyRequirement, c.visible], [true, 'DIY', s.tier, 4, 1, 'TIER_6', false], c.chessId);
|
||||
assert.deepEqual([c.bonds, c.garrisonIds, c.charId, c.upgradeNum], [[], [], null, c.isGolden ? 0 : 3], c.chessId);
|
||||
}
|
||||
assert.deepEqual(chess[s.goldenId].status, { phase: 2, level: 60, skillLevel: 7, equipLevel: s.tier === 6 ? 3 : 1 });
|
||||
}
|
||||
});
|
||||
|
||||
test('DIY: prototype picks — the 9 elites at tiers 5 and 6, six 4★ reserves (no 先锋 / 特种) at tier 5 only', () => {
|
||||
const { prototypes } = backups.diy;
|
||||
assert.deepEqual(prototypes['6'], ELITES);
|
||||
assert.deepEqual(prototypes['5'], [...RESERVES.filter((id) => !['char_600_cpione', 'char_607_cspec'].includes(id)), ...ELITES]);
|
||||
assert.equal(prototypes['5'].length, 15);
|
||||
// every pick composes in every slot form, with any of its skills (which skill a prototype carries in a slot is an open
|
||||
// question — the data supports any answer), with the derived bonds (协防干员: no prototype has a faction) and no 特质
|
||||
for (const [id, s] of Object.entries(backups.diy.slots)) {
|
||||
for (const slot of [chess[id], chess[s.goldenId]]) {
|
||||
for (const p of prototypes[s.tier]) {
|
||||
const form = unitForm(backups, p, slot.status);
|
||||
assert.ok(form, `${slot.chessId} ${p}: form`);
|
||||
for (const sk of form.skills) {
|
||||
const mod = slot.status.equipLevel > 0 ? form.modules[0]?.uniEquipId ?? null : null;
|
||||
const r = diyRecord(slot, p, backups, { skillIndex: sk.index, moduleId: mod });
|
||||
assert.ok(r && r.skill.index === sk.index && r.charId === p, `${slot.chessId} ${p} S${sk.index + 1}`);
|
||||
assert.deepEqual([r.bonds, r.garrisonIds, r.price, r.tier], [['emptyShip'], [], 4, s.tier]);
|
||||
if (mod) assert.equal(r.module.active, true, `${slot.chessId} ${p}: module`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.equal(diyRecord(chess.chess_char_6_diy1_a, 'char_601_cguard', backups, { skillIndex: 2 }), null, 'a reserve is not a tier-6 pick');
|
||||
assert.equal(diyRecord(chess.chess_char_5_diy1_a, 'char_600_cpione', backups, { skillIndex: 2 }), null, '预备干员-先锋 is not a pick');
|
||||
assert.equal(diyRecord(chess.chess_char_5_01_a, 'char_608_acpion', backups, { skillIndex: 2 }), null, 'not a DIY slot');
|
||||
});
|
||||
|
||||
test('DIY: the owned-6★ pool and the faction → bond rule (mainPower + every subPower vs powerIdList, else 协防干员)', () => {
|
||||
const { ownedPool, operators, prototypes } = backups.diy;
|
||||
const roster = new Set(base.map((c) => c.charId).filter(Boolean));
|
||||
assert.equal(ownedPool.length, 78);
|
||||
for (const id of ownedPool) {
|
||||
assert.ok(!roster.has(id), `${id}: a roster operator is never a pick`);
|
||||
assert.deepEqual([operators[id].rarity, operators[id].obtainable], [6, true], id);
|
||||
}
|
||||
assert.deepEqual(Object.keys(operators).sort(), [...new Set([...ownedPool, ...prototypes['5'], ...prototypes['6']])].sort());
|
||||
const core = Object.values(bonds).filter((b) => b.isCore && b.powerIdList.length);
|
||||
assert.equal(core.length, 8);
|
||||
for (const [id, o] of Object.entries(operators)) {
|
||||
const hit = core.filter((b) => b.powerIdList.some((p) => o.powers.includes(p))).map((b) => b.bondId);
|
||||
assert.deepEqual(o.bonds, hit.length ? hit : [config.economy.fallbackBondId], `${id}: bonds from powers`);
|
||||
}
|
||||
const tally = {};
|
||||
for (const id of ownedPool) { const k = operators[id].bonds.join('+'); tally[k] = (tally[k] || 0) + 1; }
|
||||
assert.deepEqual(tally, { emptyShip: 44, yanShip: 14, victoriaShip: 8, sargonShip: 4, siracusaShip: 4, lateranoShip: 2, kazimierzShip: 1, 'yanShip+victoriaShip': 1 });
|
||||
assert.deepEqual(operators.char_017_huang.bonds, ['yanShip', 'victoriaShip'], '煌: 炎 + 维多利亚 from her subPower (mainPower rhodes / elite)');
|
||||
for (const id of [...prototypes['5'], ...prototypes['6']]) assert.deepEqual(operators[id].bonds, ['emptyShip'], `${id}: no faction`);
|
||||
});
|
||||
|
||||
test('DIY: powers and the owned pool re-derived from character_table', { skip: !HAS_CACHE && 'no .cache/gamedata' }, () => {
|
||||
const CT = raw('character_table');
|
||||
const act = raw('activity_table').activity.AUTOCHESS_SEASON.act2autochess;
|
||||
const roster = new Set(Object.values(act.charShopChessDatas).map((r) => r.charId).filter(Boolean));
|
||||
const pool = Object.entries(CT).filter(([id, c]) => id.startsWith('char_') && c.rarity === 'TIER_6' && !['TOKEN', 'TRAP'].includes(c.profession) && !c.isNotObtainable && !roster.has(id)).map(([id]) => id);
|
||||
assert.deepEqual([...backups.diy.ownedPool].sort(), pool.sort());
|
||||
for (const [id, o] of Object.entries(backups.diy.operators)) {
|
||||
const c = CT[id];
|
||||
const want = [...new Set([c.mainPower, ...(c.subPower || [])].flatMap((p) => [p?.nationId, p?.groupId, p?.teamId]).filter(Boolean))];
|
||||
assert.deepEqual(o.powers, want, `${id}: powers`);
|
||||
}
|
||||
// nine obtainable 6★ get their core bond from a subPower only (鸿雪, 真言, 假日威龙陈, 弑君者, 予愿安洁莉娜, 结城理, 涤火杰西卡, 薇薇安娜, 煌)
|
||||
const coreOf = (powers) => Object.values(bonds).filter((b) => b.isCore && b.powerIdList.some((p) => powers.includes(p))).map((b) => b.bondId);
|
||||
const subOnly = backups.diy.ownedPool.filter((id) => {
|
||||
const m = CT[id].mainPower;
|
||||
return coreOf([m?.nationId, m?.groupId, m?.teamId].filter(Boolean)).join() !== coreOf(backups.diy.operators[id].powers).join();
|
||||
});
|
||||
assert.equal(subOnly.length, 9);
|
||||
});
|
||||
|
||||
test('composeUnitRecord: identity from the chess, everything else from the unit (no field of the replaced operator leaks)', () => {
|
||||
const c = chess.chess_char_6_17_b; // 耀骑士临光 (summons “耀阳”, modules) → 郁金香
|
||||
const r = standInRecord(c, backups);
|
||||
assert.equal(r.charId, 'char_608_acpion');
|
||||
assert.deepEqual(r.tokens, [], 'the replaced operator\'s summons are gone');
|
||||
assert.ok(r.modules.every((m) => m.uniEquipId.endsWith('_acpion')), 'only the stand-in\'s modules');
|
||||
assert.ok(r.skills.every((s) => s.skillId.startsWith('skchr_acpion_')), 'only the stand-in\'s skills');
|
||||
assert.notDeepEqual(r.talents, c.talents);
|
||||
assert.equal(composeUnitRecord(c, null, null, { skillIndex: 0 }), null);
|
||||
assert.equal(standInRecord({ ...c, status: { ...c.status, level: 2 } }, backups), null, 'no form for an unknown status');
|
||||
});
|
||||
+1
-1
@@ -24,7 +24,7 @@ const CACHE = join(ROOT, '.cache', 'gamedata');
|
||||
const HAS_CACHE = ['excel/activity_table.json', 'excel/character_table.json', 'excel/skill_table.json', 'excel/battle_equip_table.json',
|
||||
'levels/enemydata/enemy_database.json', 'levels/activities/act1autochess/level_autochess_enemy_data.json']
|
||||
.every((rel) => existsSync(join(CACHE, rel)));
|
||||
const FILES = ['config', 'chess', 'bonds', 'garrisons', 'items', 'bands', 'effects', 'choices', 'enemies', 'factions', 'waves', 'stages', 'bosses', 'tokens'];
|
||||
const FILES = ['config', 'chess', 'bonds', 'garrisons', 'items', 'bands', 'effects', 'choices', 'enemies', 'factions', 'waves', 'stages', 'bosses', 'tokens', 'backups'];
|
||||
|
||||
/** Load one data file (fails with a helpful message when the build has not run). */
|
||||
function load(name) {
|
||||
|
||||
+261
-5
@@ -4,7 +4,7 @@
|
||||
// Reads the official zh_CN client data (Kengxxiao/ArknightsGameData) plus the research JSON in
|
||||
// docs/research/ and emits compact, game-ready JSON into data/:
|
||||
// config, chess, bonds, garrisons, items, bands, effects, choices, enemies, factions, waves,
|
||||
// stages, bosses, tokens (every field is documented in docs/DATA.md).
|
||||
// stages, bosses, tokens, backups (every field is documented in docs/DATA.md).
|
||||
//
|
||||
// Usage: node tools/build-data.mjs [--refresh | --offline] [--out <dir>] [--cache <dir>]
|
||||
// [--report <file>] [--quiet] [--no-research] [--force]
|
||||
@@ -27,15 +27,17 @@
|
||||
// .cache/build-data-report.json (never silently dropped); integrity errors make the exit code 1.
|
||||
//
|
||||
// Sections (search for "// ====="): CLI & IO · text/blackboard helpers · context loading ·
|
||||
// chess · tokens · bonds · garrisons · items · bands · effects · choices · enemies · factions ·
|
||||
// chess · backups · tokens · bonds · garrisons · items · bands · effects · choices · enemies · factions ·
|
||||
// waves · stages · bosses · config · validation · main.
|
||||
|
||||
import { mkdir, readFile, writeFile, rename } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { isDeepStrictEqual } from 'node:util';
|
||||
import { Grid, DEPLOY_REFUSED_TILES } from '../server/sim/grid.js';
|
||||
import { bandBondIds } from '../shared/bandBonds.js';
|
||||
import { statusKey, composeUnitRecord, standInRecord, unitForm } from '../shared/standIn.js';
|
||||
|
||||
// ===== CLI & IO ==================================================================================
|
||||
|
||||
@@ -775,6 +777,11 @@ function buildChess(ctx) {
|
||||
identifier: cd.identifier,
|
||||
isHidden: !!shop.isHidden, isDiy, visible: !shop.isHidden && !isDiy,
|
||||
chessType: shop.chessType,
|
||||
// the official stand-in fields of the shop row, verbatim (both forms; data/backups.json, shared/standIn.js)
|
||||
backup: {
|
||||
charId: shop.backupCharId ?? null, tmplId: shop.backupTmplId ?? null, skillIndex: shop.backupCharSkillIndex ?? null,
|
||||
uniEquipId: shop.backupCharUniEquipId ?? null, potRank: shop.backupCharPotRank ?? null,
|
||||
},
|
||||
shopSortId: shop.shopLevelSortId,
|
||||
charId: shop.charId || null,
|
||||
name: null, appellation: null, rarity: null, profession: null, subProfessionId: null, subProfessionName: null,
|
||||
@@ -994,6 +1001,228 @@ function buildChess(ctx) {
|
||||
return { chess: out, tokenOwners };
|
||||
}
|
||||
|
||||
// ===== backups (补位 stand-ins, 自选 DIY slots) ===================================================
|
||||
|
||||
/**
|
||||
* 自选 picks beyond the 6★ the slot requirement names (diyChessDict says only TIER_6): PRTS 「卫戍协议:盟约
|
||||
* 下半/PRTS盟约记录」 干员数据库 "可选干员范围为除所有预设干员以外的玩家拥有的六星干员、所有六星原型干员,第5阶可额外从6名四星
|
||||
* 原型干员(先锋、特种职业除外)中选取" — per slot tier, the extra prototype rarity and the professions left out.
|
||||
*/
|
||||
const DIY_EXTRA_PROTOTYPES = Object.freeze({ 5: Object.freeze({ rarity: 4, excludedProfessions: Object.freeze(['PIONEER', 'SPECIAL']) }) });
|
||||
|
||||
const rarityOf = (char) => Number(String(char?.rarity).replace('TIER_', '')) || null;
|
||||
const statusCmp = (a, b) => a.phase - b.phase || a.level - b.level || a.skillLevel - b.skillLevel || a.equipLevel - b.equipLevel;
|
||||
|
||||
/**
|
||||
* The character part of a data/backups.json unit (DATA.md §18): who it is, its art ids (the elite form uses the E2 art
|
||||
* when it exists, as a chess does) and its module names (a composed record's `module` names the module on the normal
|
||||
* form too, where no module phase is built).
|
||||
*/
|
||||
function buildUnitHead(ctx, charId) {
|
||||
const { charTable, uniequip } = ctx;
|
||||
const char = charTable[charId];
|
||||
const moduleNames = {};
|
||||
for (const id of uniequip.charEquip?.[charId] || []) {
|
||||
const meta = uniequip.equipDict?.[id];
|
||||
if (meta) moduleNames[id] = { name: meta.uniEquipName || null, typeName: `${meta.typeName1 || ''}${meta.typeName2 ? '-' + meta.typeName2 : ''}` };
|
||||
}
|
||||
return {
|
||||
charId, name: char.name, appellation: char.appellation, rarity: rarityOf(char), profession: char.profession,
|
||||
subProfessionId: char.subProfessionId, subProfessionName: uniequip.subProfDict?.[char.subProfessionId]?.subProfessionName || null,
|
||||
position: char.position, nationId: char.nationId || null, isNotObtainable: !!char.isNotObtainable,
|
||||
assets: {
|
||||
avatar: charId, avatarGolden: hasE2Art(ctx, charId, 'avatar') ? `${charId}_2` : charId,
|
||||
portrait: `${charId}_1`, portraitGolden: `${charId}_${hasE2Art(ctx, charId, 'portrait') ? 2 : 1}`,
|
||||
spine: charId, subProfIcon: `sub_${char.subProfessionId}_icon`,
|
||||
},
|
||||
moduleNames,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A unit form (DATA.md §18): the character at one training status with nothing selected — the operator fields of a
|
||||
* chess record: stats / trait / talents without a module, every skill unlocked at the status at its skill level (the
|
||||
* trigger resolved per skill as for a chess), its summons, and at `equipLevel > 0` every ADVANCED module of that level.
|
||||
* The helpers and rules of buildChess; checkUnitFormParity proves the two agree on every PRESET chess.
|
||||
* @param {{ chessId?: string|null, skillIndex?: number|null }} [opts] `chessId`: the NORMAL chess id of a
|
||||
* TRIGGER_DEVIATIONS entry (parity check only — a stand-in's form belongs to no chess, so no deviation applies);
|
||||
* `skillIndex`: a skill listed even when locked (buildChess always lists the default)
|
||||
*/
|
||||
function buildUnitForm(ctx, charId, status, { chessId = null, skillIndex = null } = {}) {
|
||||
const { charTable, uniequip, battleEquip } = ctx;
|
||||
const char = charTable[charId];
|
||||
const { phase, level, skillLevel, equipLevel } = status;
|
||||
const label = chessId ? `chess ${chessId}` : `unit ${charId}@${statusKey(status)}`;
|
||||
const attrs = interpolateAttrs(char, phase, level);
|
||||
if (!attrs) warn(`${label}: cannot interpolate attributes`);
|
||||
const stats = statsFrom(attrs, {});
|
||||
const rangeId = char.phases?.[phase]?.rangeId || null;
|
||||
const base = traitRecord(ctx, char, phase, level, [], chessId || charId);
|
||||
const skills = [];
|
||||
(char.skills || []).forEach((se, i) => {
|
||||
if (!se?.skillId || (i !== skillIndex && !unlocked(se.unlockCond, phase, level))) return;
|
||||
const s = buildSkill(ctx, se.skillId, skillLevel, null, label);
|
||||
if (!s) return;
|
||||
s.trigger = resolveTrigger(ctx, char, charId, i, s, { operator: true, chessId });
|
||||
s.index = i;
|
||||
s.overrideTokenKey = se.overrideTokenKey || null;
|
||||
skills.push(s);
|
||||
});
|
||||
const talents = mergeTalentChanges(baseTalentList(ctx, char, phase, level, label), []);
|
||||
const modules = [];
|
||||
if (equipLevel > 0) {
|
||||
for (const id of uniequip.charEquip?.[charId] || []) {
|
||||
const meta = uniequip.equipDict?.[id];
|
||||
if (!meta || meta.type === 'INITIAL') continue;
|
||||
const ph = battleEquip[id]?.phases?.find((p) => p.equipLevel === equipLevel) || null;
|
||||
if (!ph) { warn(`${label}: module ${id} has no level ${equipLevel} (not selectable)`); continue; }
|
||||
const parts = splitModuleParts(ph);
|
||||
const hasTraitPart = parts.op.some((pt) => bestCandidate(pt.overrideTraitDataBundle?.candidates, phase, level));
|
||||
const tr = hasTraitPart ? traitRecord(ctx, char, phase, level, parts.op, chessId || charId) : null;
|
||||
if (tr && JSON.stringify(tr.classify) !== JSON.stringify(base.classify)) warn(`${label}: module ${id} changes the combat classification (not applied by loadouts)`);
|
||||
modules.push({
|
||||
uniEquipId: id, name: meta.uniEquipName || null,
|
||||
typeName: `${meta.typeName1 || ''}${meta.typeName2 ? '-' + meta.typeName2 : ''}`,
|
||||
typeIcon: meta.typeIcon || null, icon: meta.uniEquipIcon || id, level: equipLevel,
|
||||
attr: moduleAttr(ph),
|
||||
traitOverride: tr ? tr.trait : null,
|
||||
talentChanges: moduleTalentChanges(ctx, parts.op, phase, level, label),
|
||||
});
|
||||
}
|
||||
}
|
||||
// summons: every id the character lists or a skill / talent / module talent names that character_table has — the
|
||||
// composed record keeps the ones its selection produces (shared/standIn.js composeUnitRecord)
|
||||
const displayTokens = Object.keys(char.displayTokenDict || {});
|
||||
const tokenIds = [...displayTokens, ...skills.map((s) => s.overrideTokenKey), ...talents.map((t) => t.tokenKey),
|
||||
...modules.flatMap((m) => m.talentChanges.map((t) => t.tokenKey))];
|
||||
const form = {
|
||||
status: { phase, level, skillLevel, equipLevel },
|
||||
stats, immunities: stats ? immunitiesOf(attrs) : null,
|
||||
rangeId, rangeGrid: rangeGrid(ctx, rangeId),
|
||||
...base.classify,
|
||||
trait: base.trait,
|
||||
skills, talents,
|
||||
displayTokens,
|
||||
tokens: [...new Set(tokenIds.filter((id) => id && charTable[id]))].sort(naturalCmp),
|
||||
};
|
||||
if (equipLevel > 0) form.modules = modules;
|
||||
return form;
|
||||
}
|
||||
|
||||
/**
|
||||
* data/backups.json (DATA.md §18; DESIGN 0.2.0 draft): the data of 补位 and 自选.
|
||||
* - `units`: every character a NORMAL chess names as `backup.charId` — the 17 原型干员 (预备干员 char_600–607, the 6★
|
||||
* 罗德岛特派高级资深干员 char_608–615 and 领主·Sharp char_617) — with a form for every status it fights at: the statuses
|
||||
* of the chess it stands in for (normal and elite) and of the DIY slots it may fill. No unit for a PRESET chess
|
||||
* (特许: always the real operator; its backup is itself) or a DIY slot (no backup).
|
||||
* - `diy`: the slots (tier, elite id, the shop level that lists them, the rarity requirement), the prototype picks per
|
||||
* slot tier (DIY_EXTRA_PROTOTYPES), the owned-6★ pool (obtainable, not a roster operator: no chess names it, hidden
|
||||
* chess included — "不可甄选加入已在名单中的固定干员") and, for every pick, its faction ids (`mainPower` and every
|
||||
* `subPower`: "依据其「所属势力」「隐藏势力」等属性决定其盟约") and the core bonds whose `powerIdList` meets them, else
|
||||
* `constData.fallbackBondId` (协防干员) — PRTS 「卫戍协议」 "甄选加入的干员会根据其实际阵营所属分配核心盟约,若没有可匹配的
|
||||
* 则改为分配协防干员盟约".
|
||||
*/
|
||||
function buildBackups(ctx, chess) {
|
||||
const { act, ac, charTable } = ctx;
|
||||
const need = new Map(); // charId → Map(statusKey → status)
|
||||
const addNeed = (charId, st) => {
|
||||
if (!need.has(charId)) need.set(charId, new Map());
|
||||
need.get(charId).set(statusKey(st), st);
|
||||
};
|
||||
const standsIn = new Map(); // charId → base chess ids
|
||||
for (const c of Object.values(chess)) {
|
||||
const b = c.backup;
|
||||
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 (b.potRank !== 0) warn(`chess ${c.chessId}: backup potential ${b.potRank} (stand-in forms are built at potential 0)`);
|
||||
if (b.tmplId) warn(`chess ${c.chessId}: backup template ${b.tmplId} is not built`);
|
||||
addNeed(b.charId, c.status);
|
||||
if (!c.isGolden) standsIn.set(b.charId, [...(standsIn.get(b.charId) || []), c.chessId]);
|
||||
}
|
||||
const standInIds = [...standsIn.keys()].sort(naturalCmp);
|
||||
|
||||
const shopLevelOf = {};
|
||||
for (const d of Object.values(act.shopLevelDisplayDataDict || {})) for (const id of d.charChessDiySlotIdList || []) shopLevelOf[id] = d.shopLevel;
|
||||
const slots = {};
|
||||
const prototypes = {};
|
||||
const requirements = new Set();
|
||||
for (const s of Object.values(chess).filter((c) => c.isDiy && !c.isGolden).sort((x, y) => naturalCmp(x.chessId, y.chessId))) {
|
||||
const golden = chess[s.goldenId];
|
||||
if (!golden) continue;
|
||||
requirements.add(s.diyRequirement);
|
||||
slots[s.chessId] = { tier: s.tier, goldenId: s.goldenId, shopLevel: shopLevelOf[s.chessId] ?? null, requirement: s.diyRequirement };
|
||||
const extra = DIY_EXTRA_PROTOTYPES[s.tier];
|
||||
const picks = standInIds.filter((id) => {
|
||||
const ch = charTable[id];
|
||||
return ch.rarity === s.diyRequirement || (!!extra && rarityOf(ch) === extra.rarity && !extra.excludedProfessions.includes(ch.profession));
|
||||
});
|
||||
if (prototypes[s.tier] && JSON.stringify(prototypes[s.tier]) !== JSON.stringify(picks)) warn(`DIY tier ${s.tier}: slots disagree on the prototype picks`);
|
||||
prototypes[s.tier] = picks;
|
||||
for (const id of picks) { addNeed(id, s.status); addNeed(id, golden.status); }
|
||||
}
|
||||
|
||||
const units = {};
|
||||
for (const id of [...need.keys()].sort(naturalCmp)) {
|
||||
const unit = buildUnitHead(ctx, id);
|
||||
unit.standsIn = (standsIn.get(id) || []).sort(naturalCmp);
|
||||
unit.forms = {};
|
||||
for (const st of [...need.get(id).values()].sort(statusCmp)) {
|
||||
const form = buildUnitForm(ctx, id, st);
|
||||
if (form.tokens.length) warn(`unit ${id}@${statusKey(st)}: summons ${form.tokens.join(', ')} have no tokens.json variant for a stand-in owner`);
|
||||
unit.forms[statusKey(st)] = form;
|
||||
}
|
||||
units[id] = unit;
|
||||
}
|
||||
|
||||
const roster = new Set(Object.values(act.charShopChessDatas).map((r) => r.charId).filter(Boolean));
|
||||
const ownedPool = Object.keys(charTable).filter((id) => {
|
||||
const ch = charTable[id];
|
||||
return id.startsWith('char_') && requirements.has(ch.rarity) && ch.profession !== 'TOKEN' && ch.profession !== 'TRAP'
|
||||
&& !ch.isNotObtainable && !roster.has(id);
|
||||
}).sort(naturalCmp);
|
||||
const coreBonds = Object.values(ac.bondInfoDict || {}).filter((b) => b.isPower && (b.powerIdList || []).length);
|
||||
const fallback = act.constData.fallbackBondId;
|
||||
const operators = {};
|
||||
for (const id of [...new Set([...ownedPool, ...Object.values(prototypes).flat()])].sort(naturalCmp)) {
|
||||
const ch = charTable[id];
|
||||
const powers = [];
|
||||
for (const p of [ch.mainPower ?? { nationId: ch.nationId, groupId: ch.groupId, teamId: ch.teamId }, ...(ch.subPower || [])]) {
|
||||
for (const k of ['nationId', 'groupId', 'teamId']) if (p?.[k] && !powers.includes(p[k])) powers.push(p[k]);
|
||||
}
|
||||
const hit = coreBonds.filter((b) => b.powerIdList.some((x) => powers.includes(x))).map((b) => b.bondId);
|
||||
operators[id] = {
|
||||
name: ch.name, rarity: rarityOf(ch), profession: ch.profession, subProfessionId: ch.subProfessionId,
|
||||
obtainable: !ch.isNotObtainable, powers, bonds: hit.length ? hit : [fallback],
|
||||
};
|
||||
}
|
||||
return { units, diy: { slots, prototypes, ownedPool, operators } };
|
||||
}
|
||||
|
||||
/**
|
||||
* The stand-in forms come from buildUnitForm, not buildChess: prove the two agree. Every PRESET chess is its own backup
|
||||
* (charShopChessDatas: backupCharId = charId with the default skill and module), so its own unit form composed with
|
||||
* that selection (shared/standIn.js composeUnitRecord) must give back the chess record field for field — a chess field
|
||||
* that is neither an identity field (IDENTITY_FIELDS) nor set by composeUnitRecord fails here as well.
|
||||
* @returns {string[]} errors
|
||||
*/
|
||||
function checkUnitFormParity(ctx, chess) {
|
||||
const errors = [];
|
||||
for (const c of Object.values(chess)) {
|
||||
if (c.chessType !== 'PRESET' || c.backup?.charId !== c.charId) continue;
|
||||
if (c.backup.skillIndex !== c.skill?.index || (c.backup.uniEquipId ?? null) !== (c.module?.id ?? null)) {
|
||||
errors.push(`unit parity ${c.chessId}: the backup skill / module differ from the chess default`);
|
||||
continue;
|
||||
}
|
||||
const form = buildUnitForm(ctx, c.charId, c.status, { chessId: c.baseId, skillIndex: c.skill.index });
|
||||
const rec = composeUnitRecord(c, buildUnitHead(ctx, c.charId), form, { skillIndex: c.backup.skillIndex, moduleId: c.backup.uniEquipId });
|
||||
if (!isDeepStrictEqual(rec, c)) {
|
||||
const keys = [...new Set([...Object.keys(rec || {}), ...Object.keys(c)])].filter((k) => !isDeepStrictEqual(rec?.[k], c[k]));
|
||||
errors.push(`unit parity ${c.chessId}: buildUnitForm + composeUnitRecord differ from buildChess in ${keys.join(', ')}`);
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
// ===== tokens ===================================================================================
|
||||
|
||||
/** Classify a token / map character (heal tokens have no MEDIC profession). */
|
||||
@@ -3119,7 +3348,7 @@ function findNonFinite(obj, path, out) {
|
||||
function validateAll(f) {
|
||||
const errors = [];
|
||||
const err = (m) => errors.push(m);
|
||||
const { config, chess, bonds, garrisons, items, bands, effects, choices, enemies, factions, waves, stages, bosses, tokens } = f;
|
||||
const { config, chess, bonds, garrisons, items, bands, effects, choices, enemies, factions, waves, stages, bosses, tokens, backups } = f;
|
||||
for (const [name, obj] of Object.entries(f)) {
|
||||
const bad = [];
|
||||
findNonFinite(obj, name, bad);
|
||||
@@ -3160,6 +3389,30 @@ function validateAll(f) {
|
||||
}
|
||||
}
|
||||
}
|
||||
// 补位 / 自选 (data/backups.json, DATA.md §18): a PRESET chess is its own backup; every NORMAL chess (both forms)
|
||||
// composes into its stand-in (shared/standIn.js standInRecord) with the chess's skill and module; every DIY slot is a
|
||||
// DIY chess and every prototype pick of its tier has a form for both slot statuses; derived bonds exist
|
||||
if (backups) {
|
||||
for (const c of Object.values(chess)) {
|
||||
const b = c.backup;
|
||||
if (c.chessType === 'PRESET' && b?.charId !== c.charId) err(`chess ${c.chessId}: a PRESET chess's backup must be itself`);
|
||||
if (c.chessType === 'DIY' && b?.charId !== null) err(`chess ${c.chessId}: a DIY slot has no backup`);
|
||||
if (c.chessType !== 'NORMAL') continue;
|
||||
const r = standInRecord(c, backups);
|
||||
if (!r) { err(`chess ${c.chessId}: no stand-in ${b?.charId}@${statusKey(c.status)} in backups.json`); continue; }
|
||||
if (!r.stats || !Array.isArray(r.rangeGrid) || !r.trait) err(`chess ${c.chessId}: stand-in ${b.charId} without stats / range / trait`);
|
||||
if (r.skill?.index !== b.skillIndex || r.skills.filter((s) => s.isDefault).length !== 1) err(`chess ${c.chessId}: stand-in skill ${b.skillIndex} is not unlocked at ${statusKey(c.status)}`);
|
||||
if (b.uniEquipId && c.status.equipLevel > 0 && !r.module?.active) err(`chess ${c.chessId}: stand-in module ${b.uniEquipId} has no level ${c.status.equipLevel}`);
|
||||
if (r.tokens.length) err(`chess ${c.chessId}: stand-in ${b.charId} summons ${r.tokens.join(', ')} (no tokens.json variant)`);
|
||||
}
|
||||
for (const [id, s] of Object.entries(backups.diy.slots)) {
|
||||
const slot = chess[id], golden = chess[s.goldenId];
|
||||
if (!slot?.isDiy || slot.isGolden || slot.tier !== s.tier || !golden?.isDiy) { err(`DIY slot ${id}: not a DIY chess pair of tier ${s.tier}`); continue; }
|
||||
if (!backups.diy.prototypes[s.tier]?.length) err(`DIY slot ${id}: no prototype picks`);
|
||||
for (const p of backups.diy.prototypes[s.tier] || []) for (const rec of [slot, golden]) if (!unitForm(backups, p, rec.status)) err(`DIY slot ${rec.chessId}: prototype ${p} has no form ${statusKey(rec.status)}`);
|
||||
}
|
||||
for (const [id, o] of Object.entries(backups.diy.operators)) for (const b of o.bonds) if (!bonds[b]) err(`DIY pick ${id}: bond ${b} missing`);
|
||||
}
|
||||
for (const b of Object.values(bonds)) {
|
||||
for (const m of b.members) if (!chess[m]) err(`bond ${b.bondId}: member ${m} missing`);
|
||||
if (!b.thresholds.length) err(`bond ${b.bondId}: no thresholds`);
|
||||
@@ -3234,6 +3487,7 @@ async function main() {
|
||||
const ctx = await loadContext();
|
||||
log('building…');
|
||||
const { chess, tokenOwners } = buildChess(ctx);
|
||||
const backups = buildBackups(ctx, chess);
|
||||
const effects = buildEffects(ctx);
|
||||
const bonds = buildBonds(ctx, chess, effects);
|
||||
const garrisons = buildGarrisons(ctx, chess);
|
||||
@@ -3250,9 +3504,9 @@ async function main() {
|
||||
// whose bond the mode switches off
|
||||
for (const b of Object.values(bands)) b.bondIds = bandBondIds(b, { bonds, pools: choices.pools });
|
||||
const config = buildConfig(ctx, waves, stages, bands);
|
||||
const files = { config, chess, bonds, garrisons, items, bands, effects, choices, enemies, factions, waves, stages, bosses, tokens };
|
||||
const files = { config, chess, bonds, garrisons, items, bands, effects, choices, enemies, factions, waves, stages, bosses, tokens, backups };
|
||||
|
||||
const errors = validateAll(files);
|
||||
const errors = [...validateAll(files), ...checkUnitFormParity(ctx, chess)];
|
||||
let total = 0;
|
||||
const sizes = {};
|
||||
const texts = {};
|
||||
@@ -3282,6 +3536,8 @@ async function main() {
|
||||
bands: Object.keys(bands).length, effects: Object.keys(effects).length, enemies: Object.keys(enemies).length,
|
||||
waves: Object.keys(waves).length, stages: Object.keys(stages).length, bosses: Object.keys(bosses).length,
|
||||
tokens: Object.keys(tokens).length, factionEntries: Object.keys(factions.entries).length,
|
||||
backupUnits: Object.keys(backups.units).length,
|
||||
backupForms: Object.values(backups.units).reduce((n, u) => n + Object.keys(u.forms).length, 0),
|
||||
},
|
||||
sizes, totalBytes: total, out: OPTS.out, written: write, warnings, errors,
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user