diff --git a/test/golden/README.md b/test/golden/README.md index 8c6b35d8..2cdd8ddc 100644 --- a/test/golden/README.md +++ b/test/golden/README.md @@ -7,7 +7,7 @@ through. - `tools/golden.mjs` builds the corpus from `data/*.json` (fixed order, fixed seeds) and reduces every scenario to a digest. Its header documents the families and the digest fields. -- `test/golden/.json` hold the digests (`roster`, `bonds`, `fields`, `matches`). +- `test/golden/.json` hold the digests (`roster`, `bonds`, `fields`, `matches`, `diy`). - `test/golden.test.js` recomputes them and compares. On a mismatch it prints the scenario, the field and old → new. ## The corpus @@ -18,6 +18,7 @@ through. | `bonds` | 46 battles: every bond at its activation threshold (1 layer) and at its top tier (999 layers) | | `fields` | 22 battles: every Final Assault / Hidden Core leader on a pair and a solo template (shared pool, 200 s cap) and the 联防 field with 1 and 2 helpers on its own map (carried HP / SP, a knocked-out operator, two leakers' enemies) | | `matches` | 16 bot-only matches to the end in virtual time: solo 标准 / 险境 / 绝境 / 终极 × 2 seeds, co-op 2 / 3 / 4, one server-run combat match, two runs boosted to the Hidden Core | +| `diy` | 6 battles of 自选 pieces (a DIY slot with its `diy` pick, `shared/diy.js`): every owned 6★ with an operator kit (`kits/index.js OPERATOR_KITS` — 推进之王 so far) in each form of tiers 5 and 6 (normal; elite with no module and with each module) under each skill, then every prototype pick with a kit at its locked selection, normal and elite, 12 per battle against the round's real wave; a new operator kit adds its scenarios | Battles go through the production BattleSpec path (`server/sim/spec.js`, as browsers and the server's headless fields run them); matches construct `Match` directly with a `VirtualScheduler`. Every option is explicit, so a change to a @@ -29,7 +30,7 @@ within a tick (they are counted, not hashed) and wall-clock time. Damage / heali to 2 decimals. `npm test` runs the fast subset (the scenarios marked `"fast": true`: every chess record with its default loadout, -every stage and every non-leader enemy kind, every bond at its top tier, six fields, five matches — 53 of the 133). `GOLDEN_FULL=1` checks everything. +every stage and every non-leader enemy kind, every bond at its top tier, six fields, five matches, the first 自选 battle of the operator kits and of the prototypes — 55 of the 139). `GOLDEN_FULL=1` checks everything. ## Workflow diff --git a/test/golden/diy.json b/test/golden/diy.json new file mode 100644 index 00000000..e7f3eca8 --- /dev/null +++ b/test/golden/diy.json @@ -0,0 +1,255 @@ +{ + "family": "diy", + "about": "自选 pieces: every kitted owned 6★ × form (tiers 5 / 6, normal / elite × module) × skill, every kitted prototype at its locked selection", + "note": "Generated by `npm run golden:update` (tools/golden.mjs). A refactor must never change this file; see test/golden/README.md.", + "columns": { + "units": ["player","ref","kind","def","skill","module","hp","alive","deploys","kills","dmg","taken","heal","attacks","casts"], + "enemies": ["spawned","killed","leaked","dmg","taken"], + "hooks": ["battleStart","deploy","tick","beforeAttack","attack","hit","damaged","heal","kill","death","skillStart","skillEnd","ammoUsed","spGain","statusApplied","blocked","enemySpawn","enemyLeak","battleEnd","fatal","layerGain","elementBurst","dodge"] + }, + "scenarios": { + "diy-001": { + "about": "5_diy1_a=siege/S1 5_diy2_a=siege/S2 5_diy1_a=siege/S3 5_diy2_b=siege/S1 5_diy1_b=siege/S2 5_diy2_b=siege/S3 5_diy1_b=siege/S1/002_siege 5_diy2_b=siege/S2/002_siege 5_diy1_b=siege/S3/002_siege 5_diy2_b=siege/S1/003_siege 5_diy1_b=siege/S2/003_siege 5_diy2_b=siege/S3/003_siege", + "fast": true, + "kind": "normal", + "stageId": "act1autochess_m01", + "modeId": "mode_multi_normal", + "round": 12, + "seed": 2851314323, + "waveId": "act1autochess_h05", + "time": 230, + "ticks": 6900, + "reason": "timeout", + "killed": 0, + "total": 93, + "unspawned": 0, + "bossHpLeft": null, + "rngDraws": 3, + "errors": {"count":0,"kinds":[]}, + "players": {"p1":{"killed":0,"total":93,"perfect":false,"leaks":93,"leaksUncounted":0,"coins":0,"dmg":76088,"bossDmg":0,"heal":0,"deaths":28,"layerGains":{}}}, + "units": [ + ["p1",1,"op","chess_char_5_diy1_a",0,null,0,0,122,0,923,4929,0,7,1], + ["p1",2,"op","chess_char_5_diy2_a",1,null,0,0,123,0,7443,4929,0,19,3], + ["p1",3,"op","chess_char_5_diy1_a",2,null,0,0,124,0,7649,4929,0,23,0], + ["p1",4,"op","chess_char_5_diy2_b",0,"none",0,0,117,0,4072,6138,0,11,0], + ["p1",5,"op","chess_char_5_diy1_b",1,"none",2046,1,125,0,9788,4092,0,14,2], + ["p1",6,"op","chess_char_5_diy2_b",2,"none",0,0,82,0,3152,4092,0,4,1], + ["p1",7,"op","chess_char_5_diy1_b",0,"uniequip_002_siege",0,0,90,0,2443,4092,0,7,1], + ["p1",8,"op","chess_char_5_diy2_b",1,"uniequip_002_siege",0,0,99,0,19095,4092,0,8,3], + ["p1",9,"op","chess_char_5_diy1_b",2,"uniequip_002_siege",0,0,111,0,3317,4092,0,5,1], + ["p1",10,"op","chess_char_5_diy2_b",0,"uniequip_003_siege",0,0,71,0,1958,4492,0,6,0], + ["p1",11,"op","chess_char_5_diy1_b",1,"uniequip_003_siege",0,0,68,0,13962,4492,0,7,2], + ["p1",12,"op","chess_char_5_diy2_b",2,"uniequip_003_siege",0,0,63,0,2285,4492,0,10,0], + ["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null], + ["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null], + ["-","~3","device","trap_1105_accrate",null,null,100,1,3,0,0,0,0,0,null] + ], + "enemies": {"enemy_1267_nhpbr_2":[36,0,35,9319,36291],"enemy_1270_nhstlk_2":[27,0,27,22708,32133],"enemy_9006_actoxi":[30,0,30,22834,7664]}, + "hooks": [1,125,6900,243,243,280,280,0,28,120,14,14,0,10303,0,52,93,92,1,28,0,0,0], + "events": {"atk":260,"deploy":29,"die":28,"dmg":280,"fx":22,"leak":92,"skill":28,"spawn":108,"status":26}, + "fx": {"aoe":10,"dp":12}, + "snaps": "11fd58d9 8c9b2fa6 4326c48e 207bf323 f5892cfd eb207b8b 716efaa3 73d27b32 157261e1 b639e83b 8c6960a9 594efc22 ede97e52 8c0b4015 9a8cdd7e 6bb87f5f 4bf762d2 9cd529d0 40b20b8e c21a56fc 4f34951d b94bd6bf 54fb0c18" + }, + "diy-002": { + "about": "6_diy1_a=siege/S1 6_diy2_a=siege/S2 6_diy1_a=siege/S3 6_diy2_b=siege/S1 6_diy1_b=siege/S2 6_diy2_b=siege/S3 6_diy1_b=siege/S1/002_siege 6_diy2_b=siege/S2/002_siege 6_diy1_b=siege/S3/002_siege 6_diy2_b=siege/S1/003_siege 6_diy1_b=siege/S2/003_siege 6_diy2_b=siege/S3/003_siege", + "fast": false, + "kind": "normal", + "stageId": "act1autochess_m02", + "modeId": "mode_multi_hard", + "round": 13, + "seed": 197068430, + "waveId": "act1autochess_h06", + "time": 223.3, + "ticks": 6699, + "reason": "cleared", + "killed": 2, + "total": 123, + "unspawned": 0, + "bossHpLeft": null, + "rngDraws": 5, + "errors": {"count":0,"kinds":[]}, + "players": {"p1":{"killed":2,"total":123,"perfect":false,"leaks":121,"leaksUncounted":0,"coins":0,"dmg":66245,"bossDmg":0,"heal":0,"deaths":26,"layerGains":{}}}, + "units": [ + ["p1",1,"op","chess_char_6_diy1_a",0,null,0,0,154,0,8115,4929,0,23,1], + ["p1",2,"op","chess_char_6_diy2_a",1,null,1643,1,144,0,365,3286,0,8,2], + ["p1",3,"op","chess_char_6_diy1_a",2,null,1643,1,151,0,246,3286,0,7,1], + ["p1",4,"op","chess_char_6_diy2_b",0,"none",2046,1,142,0,233,4092,0,9,2], + ["p1",5,"op","chess_char_6_diy1_b",1,"none",2046,1,155,0,18574,4092,0,24,6], + ["p1",6,"op","chess_char_6_diy2_b",2,"none",2046,1,153,0,1079,4092,0,11,0], + ["p1",7,"op","chess_char_6_diy1_b",0,"uniequip_002_siege",2046,1,132,0,203,4092,0,6,2], + ["p1",8,"op","chess_char_6_diy2_b",1,"uniequip_002_siege",2046,1,156,0,1316,4092,0,4,2], + ["p1",9,"op","chess_char_6_diy1_b",2,"uniequip_002_siege",0,0,94,0,677,4092,0,4,1], + ["p1",10,"op","chess_char_6_diy2_b",0,"uniequip_003_siege",0,0,119,1,9041,4612,0,19,1], + ["p1",11,"op","chess_char_6_diy1_b",1,"uniequip_003_siege",0,0,79,1,24153,4612,0,21,4], + ["p1",12,"op","chess_char_6_diy2_b",2,"uniequip_003_siege",0,0,134,0,2242,6918,0,12,0] + ], + "enemies": {"enemy_1006_shield_3":[21,0,21,20551,4442],"enemy_1043_zomsbr":[66,2,64,10572,58576],"enemy_1402_tgshd_2":[36,0,36,21072,3227]}, + "hooks": [1,156,6699,282,282,308,308,7543,28,149,22,22,0,17563,2,43,123,121,1,28,0,0,0], + "events": {"atk":308,"deploy":33,"die":28,"dmg":308,"fx":70,"leak":121,"skill":44,"spawn":135,"status":28}, + "fx": {"aoe":14,"dp":20,"expose":36}, + "snaps": "d7b739d1 f5e170c4 bc463f9c 6ebd80ed 93f8e1ae ea1947a7 9541a28f e8dbac5b b0b3c196 14ae4a64 46640828 01c55ea9 0f261678 f447848b 0aad3542 db5850a3 631cc5ca 455c1dbc 68ad7134 24c71ebb 34a3ee93 37b05130 7a58a87e" + }, + "diy-003": { + "about": "5_diy1_a=cdfend 5_diy2_b=cdfend 5_diy1_a=cguard 5_diy2_b=cguard 5_diy1_a=csnipe 5_diy2_b=csnipe 5_diy1_a=ccast 5_diy2_b=ccast 5_diy1_a=cmedic 5_diy2_b=cmedic 5_diy1_a=csuppo 5_diy2_b=csuppo", + "fast": true, + "kind": "normal", + "stageId": "act1autochess_m03", + "modeId": "mode_single_normal", + "round": 12, + "seed": 3865246633, + "waveId": "act1autochess_h05", + "time": 196.5, + "ticks": 5895, + "reason": "cleared", + "killed": 8, + "total": 93, + "unspawned": 0, + "bossHpLeft": null, + "rngDraws": 0, + "errors": {"count":0,"kinds":[]}, + "players": {"p1":{"killed":8,"total":93,"perfect":false,"leaks":85,"leaksUncounted":0,"coins":0,"dmg":306215,"bossDmg":0,"heal":22436,"deaths":11,"layerGains":{}}}, + "units": [ + ["p1",1,"op","chess_char_5_diy1_a",2,null,0,0,114,0,690,6384,0,13,0], + ["p1",2,"op","chess_char_5_diy2_b",2,"none",0,0,98,1,7246,10191,0,56,1], + ["p1",3,"op","chess_char_5_diy1_a",2,null,2564,1,116,1,16020,8139,0,28,1], + ["p1",4,"op","chess_char_5_diy2_b",2,"none",0,0,74,3,34176,12589,0,37,2], + ["p1",5,"op","chess_char_5_diy1_a",2,null,871,1,8,1,22261,400,0,129,3], + ["p1",6,"op","chess_char_5_diy2_b",2,"none",1510,1,12,0,32187,200,0,108,3], + ["p1",7,"op","chess_char_5_diy1_a",2,null,1133,1,14,0,30588,200,0,77,2], + ["p1",8,"op","chess_char_5_diy2_b",2,"none",1160,1,4,1,71148,200,0,154,3], + ["p1",9,"op","chess_char_5_diy1_a",2,null,1218,1,6,0,0,600,18186,48,3], + ["p1",10,"op","chess_char_5_diy2_b",2,"none",1374,1,115,0,0,8077,4251,12,2], + ["p1",11,"op","chess_char_5_diy1_a",2,null,932,1,13,1,40672,2000,0,123,3], + ["p1",12,"op","chess_char_5_diy2_b",2,"none",1131,1,15,0,51226,800,0,132,3], + ["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null], + ["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null], + ["-","~3","device","trap_1105_accrate",null,null,100,1,3,0,0,0,0,0,null] + ], + "enemies": {"enemy_1267_nhpbr_2":[36,8,28,17650,131613],"enemy_1270_nhstlk_2":[27,0,27,15235,63417],"enemy_9006_actoxi":[30,0,30,16896,111185]}, + "hooks": [1,116,5895,1089,1089,1215,1215,60,19,104,26,24,0,28385,254,28,93,85,1,19,0,0,0], + "events": {"atk":1089,"deploy":20,"die":19,"dmg":1215,"fx":8,"heal":60,"leak":85,"skill":50,"spawn":108,"status":504}, + "fx": {"zone":8}, + "snaps": "b1b2dda9 6ac577d6 84811e80 eb6305ea 52a34c95 c2fd4731 45525f0b dbcc3ddb ddfcbc11 0874e859 206cd671 4c83f44b f4f5331a 194da7d5 1fe13dbf 6b7331b4 4cf19629 7bae5e13 5b7078fc 65a8a1f5" + }, + "diy-004": { + "about": "5_diy1_a=acfend 5_diy2_b=acfend 5_diy1_a=acpion 5_diy2_b=acpion 5_diy1_a=acguad 5_diy2_b=acguad 5_diy1_a=acnipe 5_diy2_b=acnipe 5_diy1_a=accast 5_diy2_b=accast 5_diy1_a=acmedc 5_diy2_b=acmedc", + "fast": false, + "kind": "normal", + "stageId": "act1autochess_m04", + "modeId": "mode_multi_abyss", + "round": 12, + "seed": 1210847140, + "waveId": "act1autochess_h05", + "time": 223.43, + "ticks": 6703, + "reason": "cleared", + "killed": 40, + "total": 117, + "unspawned": 0, + "bossHpLeft": null, + "rngDraws": 397, + "errors": {"count":0,"kinds":[]}, + "players": {"p1":{"killed":40,"total":117,"perfect":false,"leaks":77,"leaksUncounted":0,"coins":0,"dmg":508171,"bossDmg":0,"heal":36177,"deaths":23,"layerGains":{}}}, + "units": [ + ["p1",1,"op","chess_char_5_diy1_a",2,null,0,0,121,0,0,5208,0,0,0], + ["p1",2,"op","chess_char_5_diy2_b",2,"uniequip_002_acfend",0,0,142,0,0,6742,0,0,0], + ["p1",3,"op","chess_char_5_diy1_a",2,null,0,0,115,8,47722,3058,0,0,3], + ["p1",4,"op","chess_char_5_diy2_b",2,"uniequip_002_acpion",0,0,105,13,161423,5812,0,0,4], + ["p1",5,"op","chess_char_5_diy1_a",2,null,0,0,106,0,0,11589,0,0,0], + ["p1",6,"op","chess_char_5_diy2_b",2,"uniequip_002_acguad",0,0,100,0,0,23135,0,0,0], + ["p1",7,"op","chess_char_5_diy1_a",2,null,0,0,132,1,25233,2544,0,35,1], + ["p1",8,"op","chess_char_5_diy2_b",2,"uniequip_002_acnipe",0,0,134,6,97015,3174,0,52,1], + ["p1",9,"op","chess_char_5_diy1_a",2,null,0,0,77,0,6653,2744,0,3,0], + ["p1",10,"op","chess_char_5_diy2_b",2,"uniequip_002_accast",0,0,7,12,170125,1749,0,23,1], + ["p1",11,"op","chess_char_5_diy1_a",2,null,1224,1,145,0,0,4574,11586,27,1], + ["p1",12,"op","chess_char_5_diy2_b",2,"uniequip_002_acmedc",1344,1,144,0,0,12319,24591,35,2], + ["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null], + ["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null], + ["-","~3","device","trap_1105_accrate",null,null,100,1,3,0,0,0,0,0,null] + ], + "enemies": {"enemy_1005_yokai":[51,23,28,0,117732],"enemy_1005_yokai_2":[54,17,37,23728,170538],"enemy_1005_yokai_3":[12,0,12,48910,219901]}, + "hooks": [1,145,6703,519,519,1175,1135,111,63,140,13,12,0,15564,0,0,117,77,1,63,0,0,40], + "events": {"atk":611,"deploy":25,"die":63,"dmg":1135,"fx":131,"heal":104,"leak":77,"skill":25,"spawn":132,"status":14}, + "fx": {"aoe":1,"dodge":40,"dp":28,"slash":56,"spGain":6}, + "snaps": "423cf145 067f6592 f56d6f02 b1d829d2 e53e0bb1 72567741 d21a21ce 2c85772f 70e07937 b6b3a5b0 8508d9a4 99e6a37c a9cef3f9 5b0003e7 57ea2acb a24ed29e 503fa8af 8d19a130 b107e612 3fe818a1 30c61e9a 488e4d12 f00a2f4b" + }, + "diy-005": { + "about": "6_diy1_a=acfend 6_diy2_b=acfend 5_diy1_a=sharp2 5_diy2_b=sharp2 6_diy1_a=acpion 6_diy2_b=acpion 5_diy1_a=acspec 5_diy2_b=acspec 6_diy1_a=acguad 6_diy2_b=acguad 5_diy1_a=acsupo 5_diy2_b=acsupo", + "fast": false, + "kind": "normal", + "stageId": "act1autochess_m05", + "modeId": "mode_multi_funny", + "round": 13, + "seed": 823748262, + "waveId": "act1autochess_h06", + "time": 230, + "ticks": 6900, + "reason": "timeout", + "killed": 63, + "total": 123, + "unspawned": 0, + "bossHpLeft": null, + "rngDraws": 117, + "errors": {"count":0,"kinds":[]}, + "players": {"p1":{"killed":63,"total":123,"perfect":false,"leaks":60,"leaksUncounted":0,"coins":0,"dmg":274732,"bossDmg":0,"heal":0,"deaths":20,"layerGains":{}}}, + "units": [ + ["p1",1,"op","chess_char_6_diy1_a",2,null,1834,1,132,5,19446,3374,0,80,2], + ["p1",2,"op","chess_char_6_diy2_b",2,"uniequip_002_acfend",2142.36,1,141,5,29488,4720,0,101,2], + ["p1",3,"op","chess_char_5_diy1_a",0,null,1865,1,154,3,27639,3730,0,42,1], + ["p1",4,"op","chess_char_5_diy2_b",0,"uniequip_002_sharp2",2374,1,152,11,43106,4748,0,39,1], + ["p1",5,"op","chess_char_6_diy1_a",2,null,0,0,135,3,18986,4587,0,18,1], + ["p1",6,"op","chess_char_6_diy2_b",2,"uniequip_002_acpion",1808.52,1,143,18,92036,3903,0,40,4], + ["p1",7,"op","chess_char_5_diy1_a",2,null,117.2,1,130,12,39744,6807,0,115,0], + ["p1",8,"op","chess_char_5_diy2_b",2,"uniequip_002_acspec",1448,1,1,0,0,0,0,0,0], + ["p1",9,"op","chess_char_6_diy1_a",2,null,2638,1,3,0,0,0,0,0,0], + ["p1",10,"op","chess_char_6_diy2_b",2,"uniequip_002_acguad",3531,1,5,0,0,0,0,0,0], + ["p1",11,"op","chess_char_5_diy1_a",2,null,1160,1,153,0,1743,2320,0,7,0], + ["p1",12,"op","chess_char_5_diy2_b",2,"uniequip_002_acsupo",1120.4,1,145,0,2545,3170,0,7,1] + ], + "enemies": {"enemy_1019_jshoot_2":[57,37,20,9953,112024],"enemy_1299_ymkilr_2":[27,6,20,25821,123649],"enemy_1422_lrsldr_2":[39,20,19,1584,123193]}, + "hooks": [1,154,6900,835,835,3335,3375,0,83,142,12,15,0,16427,38,83,123,59,1,83,0,0,0], + "events": {"atk":851,"deploy":31,"die":83,"dmg":3367,"fx":150,"leak":59,"skill":22,"spawn":135,"status":47}, + "fx": {"aoe":83,"displace":3,"dp":26,"slash":38}, + "snaps": "aa4b712f 51a2360b 59868653 c815c7eb 7dd47d06 3f3f03f4 89e883c3 60e1e7f6 2355716a 5d734980 75cde05f c0dd657c 4e5bd257 0f8a040d b1aafe05 c04cbb36 e6d37890 8457998a a19143e5 d709bc2b 4283543b 0d9edf54 6ef9963d" + }, + "diy-006": { + "about": "6_diy1_a=sharp2 6_diy2_b=sharp2 6_diy1_a=acspec 6_diy2_b=acspec 6_diy1_a=acnipe 6_diy2_b=acnipe 6_diy1_a=accast 6_diy2_b=accast 6_diy1_a=acmedc 6_diy2_b=acmedc 6_diy1_a=acsupo 6_diy2_b=acsupo", + "fast": false, + "kind": "normal", + "stageId": "act1autochess_m06", + "modeId": "mode_single_hard", + "round": 13, + "seed": 2464539579, + "waveId": "act1autochess_h06", + "time": 210.23, + "ticks": 6307, + "reason": "cleared", + "killed": 5, + "total": 81, + "unspawned": 0, + "bossHpLeft": null, + "rngDraws": 123, + "errors": {"count":0,"kinds":[]}, + "players": {"p1":{"killed":5,"total":81,"perfect":false,"leaks":76,"leaksUncounted":0,"coins":0,"dmg":185202,"bossDmg":0,"heal":4048,"deaths":30,"layerGains":{}}}, + "units": [ + ["p1",1,"op","chess_char_6_diy1_a",0,null,0,0,3,0,11325,1865,0,7,1], + ["p1",2,"op","chess_char_6_diy2_b",0,"uniequip_002_sharp2",0,0,6,3,52957,2494,0,16,1], + ["p1",3,"op","chess_char_6_diy1_a",2,null,1154,1,112,0,15444,8178,0,22,0], + ["p1",4,"op","chess_char_6_diy2_b",2,"uniequip_002_acspec",0,0,109,2,51553,12663,0,57,0], + ["p1",5,"op","chess_char_6_diy1_a",2,null,1272,1,113,0,4256,2544,0,15,0], + ["p1",6,"op","chess_char_6_diy2_b",2,"uniequip_002_acnipe",0,0,105,0,10180,3286,0,19,0], + ["p1",7,"op","chess_char_6_diy1_a",2,null,0,0,5,0,3060,1372,0,2,0], + ["p1",8,"op","chess_char_6_diy2_b",2,"uniequip_002_accast",0,0,2,0,30589,1819,0,4,1], + ["p1",9,"op","chess_char_6_diy1_a",2,null,0,0,8,0,0,2028,2464,7,1], + ["p1",10,"op","chess_char_6_diy2_b",2,"uniequip_002_acmedc",0,0,61,0,0,3890,1584,6,1], + ["p1",11,"op","chess_char_6_diy1_a",2,null,0,0,58,0,2490,3291,0,7,1], + ["p1",12,"op","chess_char_6_diy2_b",2,"uniequip_002_acsupo",0,0,60,0,3347,3320,0,8,0] + ], + "enemies": {"enemy_1267_nhpbr_2":[27,5,22,7765,89733],"enemy_1270_nhstlk_2":[36,0,36,14278,68347],"enemy_1272_nhtank_2":[18,0,18,24706,27121]}, + "hooks": [1,113,6307,262,262,449,476,16,35,111,6,21,0,5827,86,24,81,76,1,35,0,0,0], + "events": {"atk":283,"deploy":32,"die":35,"dmg":441,"fx":58,"heal":15,"leak":76,"skill":12,"spawn":93,"status":142}, + "fx": {"aoe":7,"displace":21,"spGain":6,"zone":24}, + "snaps": "1dcf4706 8bd55683 d93e4d02 ab832ad5 132699ab f37b039d f12175ca fe30225b 98f4086a 9e118a68 e442622f c82a3e9e 777d93ae 7e2cfb3c cbae0ce5 8369a93d 0a5fedc7 5775c6c3 ecf86170 0ec0ecc7 25a93cea" + } + } +} diff --git a/tools/golden.mjs b/tools/golden.mjs index 20081225..ff569f91 100644 --- a/tools/golden.mjs +++ b/tools/golden.mjs @@ -28,6 +28,10 @@ // matches 16 bot-only matches (solo 标准 / 险境 / 绝境 / 终极 ×2 seeds, co-op 2 / 3 / 4, one server-run combat match, // two with LP and layers raised at the first prep so they reach the Hidden Core) run to the end in virtual // time with the match's default bot rehearsal +// diy 自选 pieces (PlayerBattleInput `diy`, shared/diy.js): every owned 6★ with an operator kit (kits/index.js +// OPERATOR_KITS) in each form of tiers 5 and 6 — normal, elite with no module and with each module — under +// each of its skills, then every prototype pick with a kit at its locked selection, both forms of each tier; +// 12 pieces per battle on a real stage against the round's real wave three times over (diyScenarios) // Battles run through the production BattleSpec path (server/sim/spec.js buildBattleSpec → createBattleFromSpec, the // path browsers and the server's headless fields use) with every option explicit; matches construct Match directly // with a VirtualScheduler (as tools/botbench.mjs) — test-harness defaults never move a digest. @@ -64,11 +68,14 @@ import { computeBonds, bondSnapshot } from '../server/match/bondsMeta.js'; import { Match } from '../server/match/Match.js'; import { VirtualScheduler } from '../server/match/scheduler.js'; import { resolveRecordLoadout, loadoutRecord, attackRangeGrid } from '../shared/loadoutRecord.js'; +import { diyRecordOf, DIY_TIERS } from '../shared/diy.js'; +import { unitForm } from '../shared/standIn.js'; +import { OPERATOR_KITS, KITTED_CHARS } from '../server/sim/content/kits/index.js'; import { GEO } from '../shared/constants.js'; const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); export const GOLDEN_DIR = join(ROOT, 'test', 'golden'); -export const FAMILY_NAMES = Object.freeze(['roster', 'bonds', 'fields', 'matches']); +export const FAMILY_NAMES = Object.freeze(['roster', 'bonds', 'fields', 'matches', 'diy']); const QUIET = Object.freeze({ warn() {}, error() {}, info() {}, log() {}, debug() {} }); const data = getData({ log: QUIET }); @@ -730,15 +737,137 @@ export function runMatch(cfg) { return digest; } +// --------------------------------------------------------------------------------------------------------------- +// family: diy (自选 pieces: a DIY slot fielded with its `diy` pick — simdata getDiy, shared/diy.js) + +const DIY_SLOTS = data.backups?.diy?.slots ?? {}; +/** The slot record of a tier's first slot, normal or elite (the forms every slot of the tier shares). */ +const diySlotRec = (tier, elite) => { + const base = Object.keys(DIY_SLOTS).find((id) => DIY_SLOTS[id].tier === tier); + return base ? data.chess[elite ? DIY_SLOTS[base].goldenId : base] : null; +}; + +/** + * The 自选 configurations of the corpus, in a fixed order: every owned 6★ with an operator kit (OPERATOR_KITS, file + * order) in each form of both tiers — normal (no module: none is active), elite with no module and with each module of + * the form — under each skill; then every prototype pick with a kit (KITTED_CHARS: its stand-in kit, a 预备干员's generic + * kit) at its locked selection, normal and elite, per tier. `{ tier, elite, pick, group }`. + */ +function diyConfigs() { + const diy = data.backups?.diy; + if (!diy) return []; + const out = []; + for (const charId of Object.keys(OPERATOR_KITS)) { + if (!diy.ownedPool.includes(charId)) continue; + for (const tier of DIY_TIERS) { + for (const elite of [false, true]) { + const form = unitForm(data.backups, charId, diySlotRec(tier, elite)?.status); + if (!form) continue; + for (const uniEquipId of elite ? [null, ...(form.modules ?? []).map((m) => m.uniEquipId)] : [null]) { + for (const s of form.skills) out.push({ tier, elite, pick: { charId, skillIndex: s.index, uniEquipId }, group: 'operator' }); + } + } + } + } + for (const tier of DIY_TIERS) { + for (const charId of diy.prototypes?.[tier] ?? []) { + if (!KITTED_CHARS.includes(charId)) continue; + for (const elite of [false, true]) out.push({ tier, elite, pick: { charId }, group: 'prototype' }); + } + } + return out; +} + +/** + * Lay 自选 pieces on a player's board like `layout` (melee — most blockers first — on the deployable tiles the enemy + * ground paths cross most, ranged ones beside the paths, every 7th one facing UP / LEFT / DOWN), their class read from + * the composed record (`w.rec`, shared/diy.js diyRecordOf). No summons are placed (the 自选 hand pieces come with the + * per-player shop). + */ +function diyLayout(stageId, wanted) { + const map = buildDeployMap(data.stages[stageId], { field: 'normal' }); + const traffic = pathTraffic(stageId, 'normal', 0); + const pathList = [...traffic.keys()].map((k) => k.split(',').map(Number)); + const dist = (r, c) => (pathList.length ? Math.min(...pathList.map(([pr, pc]) => Math.max(Math.abs(pr - r), Math.abs(pc - c)))) : 9); + const used = new Set(); + const tiles = (cls) => { + const out = []; + for (let r = 12; r >= 9; r--) for (let c = 2; c <= 10; c++) if (!used.has(tileKey(r, c)) && canPlace(map, cls, r, c)) out.push([r, c]); + if (cls === 'melee') return out.sort((a, b) => (traffic.get(tileKey(b[0], b[1])) || 0) - (traffic.get(tileKey(a[0], a[1])) || 0)); + const rank = (r, c) => { const d = dist(r, c); return d === 0 ? 1.5 : d; }; + return out.sort((a, b) => rank(a[0], a[1]) - rank(b[0], b[1])); + }; + const cls = (w) => positionClass(w.rec, w.diy.uniEquipId ?? null); + const order = wanted.slice().sort((a, b) => ((cls(b) === 'melee') - (cls(a) === 'melee')) || (cls(a) === 'melee' ? (b.rec.stats.blockCnt || 0) - (a.rec.stats.blockCnt || 0) : 0)); + const units = []; + let uid = 1; + for (const w of order) { + const free = tiles(cls(w)); + if (!free.length) continue; + const [r, c] = free[0]; + used.add(tileKey(r, c)); + const dir = units.length % 7 === 6 ? ['UP', 'LEFT', 'DOWN'][Math.floor(units.length / 7) % 3] : 'RIGHT'; + units.push({ uid: uid++, kind: 'chess', chessId: w.chessId, diy: { ...w.diy }, row: r, col: c, dir, items: [] }); + } + return units; +} + +/** + * The diy family: the configurations (diyConfigs) 12 per battle, each on the next of its tier's two slots (the elite + * twin for an elite form); a real stage and mode in turn, the round of the strongest piece (BAND_ROUND), the round's + * real wave three times over, no bonds / items / band (the 自选 bonds come with the per-player roster). `pass` 0 = the + * first battle of each group (operator kits, prototypes): the fast subset. + */ +export function diyScenarios() { + const configs = diyConfigs(); + const scenarios = []; + const firstOf = new Set(); + for (let i = 0, n = 0; i < configs.length; i += 12, n++) { + const chunk = configs.slice(i, i + 12); + const stageId = STAGES[n % STAGES.length]; + const modeId = ROSTER_MODES[n % ROSTER_MODES.length]; + const gd = gdFor(modeId); + const turn = {}; + const wanted = chunk.map((c) => { + const ids = Object.keys(DIY_SLOTS).filter((id) => DIY_SLOTS[id].tier === c.tier); + turn[c.tier] = (turn[c.tier] ?? -1) + 1; + const base = ids[turn[c.tier] % ids.length]; + const chessId = c.elite ? DIY_SLOTS[base].goldenId : base; + const rec = diyRecordOf(data.chess[chessId], c.pick, data); + if (!rec) throw new Error(`diy: ${c.pick.charId} is no legal pick of ${chessId}`); + return { chessId, diy: c.pick, rec, band: c.tier + (c.elite ? 1 : 0) }; + }); + const units = diyLayout(stageId, wanted); + if (units.length !== wanted.length) throw new Error(`diy: ${wanted.length - units.length} pieces do not fit on ${stageId}`); + const round = BAND_ROUND[Math.max(...wanted.map((w) => w.band))]; + const seed = deriveSeed(20261005, `diy:${n}`); + const wave = normalWave(gd, round, seed); + const pid = 'p1'; + const groups = [...new Set(chunk.map((c) => c.group))]; + const pass = groups.some((g) => !firstOf.has(g)) ? 0 : 1; + for (const g of groups) firstOf.add(g); + scenarios.push({ + id: `diy-${String(n + 1).padStart(3, '0')}`, family: 'diy', kind: 'normal', modeId, round, stageId, seed, pass, + rect: { ...GEO.NORMAL_RECT }, timeLimit: wave.timeLimit, routes: wave.routes, waveId: wave.templateId, enemyOverrides: wave.overrides, + flags: { layerGainsEnabled: true, ...gd.dp }, + players: [playerInput(pid, 0, units)], + spawns: [0, 1, 2].flatMap((k) => wave.spawns.map((s) => ({ ...s, time: s.time + k * Math.round(wave.timeLimit / 4), ownerPlayerId: pid }))), + about: `${units.map((u) => `${u.chessId.replace(/^chess_char_/, '')}=${u.diy.charId.replace(/^char_\d+_/, '')}${u.diy.skillIndex != null ? `/S${u.diy.skillIndex + 1}` : ''}${u.diy.uniEquipId ? `/${u.diy.uniEquipId.replace(/^uniequip_/, '')}` : ''}`).join(' ')}`, + }); + } + return scenarios; +} + // --------------------------------------------------------------------------------------------------------------- // families, fast subset, comparison -const GENERATORS = { roster: rosterScenarios, bonds: bondScenarios, fields: fieldScenarios, matches: matchScenarios }; +const GENERATORS = { roster: rosterScenarios, bonds: bondScenarios, fields: fieldScenarios, matches: matchScenarios, diy: diyScenarios }; const ABOUT = { roster: 'every visible chess record × every selectable skill / module, every stage, every non-leader enemy kind, items, bands, 机变 cards, map cards, placeable summons', bonds: 'every bond at its activation threshold (layers 1) and at its top tier (layers 999)', fields: 'Final Assault / Hidden Core leaders (pair + solo templates, shared pool, 200 s cap) and 联防 fields (1 / 2 helpers)', matches: 'bot-only matches run to the end in virtual time (solo ×4 difficulties ×2 seeds, co-op 2/3/4, one server-run, two boosted to the Hidden Core)', + diy: '自选 pieces: every kitted owned 6★ × form (tiers 5 / 6, normal / elite × module) × skill, every kitted prototype at its locked selection', }; /** * The default test subset (GOLDEN_FULL=1 runs everything): the pass-0 roster battles (every chess record with its