feedback5: merge W5G — 补位 end to end: the 干员持有 tab (drop NORMAL chess), room.ownership, stand-ins in every field, 「替补:X」 badges, the stand-in's body on the board and detail card, golden standins family
Golden regenerated on the merged code (the textual merge had no conflict and equals the regenerated files): against feedback5 only the new match coop2-NORMAL-14-standins and the new standins.json (10 battles); against fb5-standin-game exactly the W5F and W5J scenarios (roster-001/002/007/009/016/026/027/028/038/040/041/042/044, bond-egirShip-low / siracusaShip-high / suntShip-high, boss-boss_2-pair / boss_6-pair, solo-NORMAL-1, coop2-NORMAL-3, coop3-ABYSS-5, coop4-FUNNY-6, coop2-NORMAL-8-serverrun); standins unchanged. test/match 536/536, the stand-in / golden / docs suites 175/175.
This commit is contained in:
commit
ce0d85c78a
51 files changed
+2741
-188
No files matched your search
@@ -0,0 +1,66 @@
|
||||
// The 补位 golden corpus covers what it claims (test/golden/README.md, tools/golden.mjs — 0.2.0): reads the stored
|
||||
// digests only (test/golden.test.js recomputes them). The `standins` family fields every NORMAL chess record (normal and
|
||||
// elite) as its stand-in — the battle unit's def is the chess id, its loadout the backup selection — so all 17 stand-ins
|
||||
// run; every active skill a chess names for its stand-in is cast in some battle (the ON_DEPLOY passives of 预备干员-特种
|
||||
// and Misery count no cast: their units deploy); no battle reports a content error. The 补位 match fields stand-ins.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
const read = (p) => JSON.parse(readFileSync(new URL(p, import.meta.url), 'utf8'));
|
||||
const CHESS = read('../data/chess.json');
|
||||
const BACKUPS = read('../data/backups.json');
|
||||
const STANDINS = read('./golden/standins.json');
|
||||
const MATCHES = read('./golden/matches.json');
|
||||
const COL = Object.fromEntries(STANDINS.columns.units.map((k, i) => [k, i]));
|
||||
|
||||
const normal = Object.values(CHESS).filter((c) => c.chessType === 'NORMAL' && c.backup && c.backup.charId !== c.charId);
|
||||
|
||||
test('standins: every NORMAL chess record (normal + elite) is fielded once, with its backup skill, without content errors', () => {
|
||||
assert.equal(normal.length, 110);
|
||||
const seen = new Map();
|
||||
for (const [id, sc] of Object.entries(STANDINS.scenarios)) {
|
||||
assert.equal(sc.errors.count, 0, `${id}: ${sc.errors.kinds.join('; ')}`);
|
||||
for (const u of sc.units) if (u[COL.kind] === 'op') seen.set(u[COL.def], u);
|
||||
}
|
||||
assert.deepEqual([...seen.keys()].sort(), normal.map((c) => c.chessId).sort());
|
||||
for (const c of normal) {
|
||||
const u = seen.get(c.chessId);
|
||||
assert.equal(u[COL.skill], c.backup.skillIndex, `${c.chessId}: the backup skill`);
|
||||
assert.ok(u[COL.deploys] >= 1, `${c.chessId}: deployed`);
|
||||
}
|
||||
assert.equal(new Set(normal.map((c) => c.backup.charId)).size, 17, 'all 17 stand-ins');
|
||||
});
|
||||
|
||||
test('standins: every active skill a chess names for its stand-in is cast; passive ones deploy', () => {
|
||||
const cast = new Map();
|
||||
for (const sc of Object.values(STANDINS.scenarios)) {
|
||||
for (const u of sc.units) {
|
||||
if (u[COL.kind] !== 'op') continue;
|
||||
const c = CHESS[u[COL.def]];
|
||||
const k = `${c.backup.charId}|${u[COL.skill]}`;
|
||||
cast.set(k, (cast.get(k) || 0) + (u[COL.casts] || 0));
|
||||
}
|
||||
}
|
||||
for (const c of normal) {
|
||||
const k = `${c.backup.charId}|${c.backup.skillIndex}`;
|
||||
const form = Object.values(BACKUPS.units[c.backup.charId].forms)[0];
|
||||
const sk = form.skills.find((s) => s.index === c.backup.skillIndex);
|
||||
if (sk.skillType === 'PASSIVE' || sk.spType === 'ON_DEPLOY') continue;
|
||||
assert.ok(cast.get(k) > 0, `${c.backup.charId} S${c.backup.skillIndex + 1} (${sk.name}) never cast`);
|
||||
}
|
||||
});
|
||||
|
||||
test('matches: the 补位 match\'s human seat fields stand-ins (listed per round) and the match ends without errors', () => {
|
||||
const dg = MATCHES.scenarios['coop2-NORMAL-14-standins'];
|
||||
assert.ok(dg, 'scenario present');
|
||||
assert.deepEqual(dg.errors, { engine: 0, logged: 0, sim: 0, dispatcher: 0 });
|
||||
const fielded = dg.standIns.rounds.filter(([, s]) => s).flatMap(([, s]) => s.split(' '));
|
||||
assert.ok(new Set(fielded).size >= 3, `stand-ins fielded: ${[...new Set(fielded)].join(', ')}`);
|
||||
for (const f of fielded) {
|
||||
const [id, charId] = f.split('→');
|
||||
const c = CHESS[`chess_char_${id}`];
|
||||
assert.ok(dg.standIns.notOwned.includes(c.baseId || c.chessId), `${f}: not owned`);
|
||||
assert.equal(charId, c.backup.charId);
|
||||
}
|
||||
});
|
||||
@@ -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/<family>.json` hold the digests (`roster`, `bonds`, `fields`, `matches`).
|
||||
- `test/golden/<family>.json` hold the digests (`roster`, `bonds`, `fields`, `matches`, `standins`).
|
||||
- `test/golden.test.js` recomputes them and compares. On a mismatch it prints the scenario, the field and old → new.
|
||||
|
||||
## The corpus
|
||||
@@ -17,7 +17,8 @@ through.
|
||||
| `roster` | 49 battles: every visible chess record (normal and elite) with every selectable skill and module (DESIGN §16), 12 operators per battle on a real stage (all 11 in turn) against the round's real wave three times over, every non-leader enemy kind of `data/enemies.json` as extra spawns (half of them bounties), placeable summons on the board, every equipment item, band, battle-side 机变 card and stage map card in turn, bonds from the board |
|
||||
| `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 |
|
||||
| `matches` | 17 matches to the end in virtual time: 16 bot-only (solo 标准 / 险境 / 绝境 / 终极 × 2 seeds, co-op 2 / 3 / 4, one server-run combat match, two runs boosted to the Hidden Core) and one co-op match whose human seat (AI 托管, offline) does not own 7 NORMAL chess — they fight as their 补位 stand-ins (its digest lists them per round, `standIns`) |
|
||||
| `standins` | 10 battles: every NORMAL chess record (normal and elite, 110) fielded as its 补位 stand-in (`standIn: true`: the stand-in's body, backup skill / module and kit — all 17 stand-ins, every skill a chess names for them), 12 per battle by strength band, laid out by the stand-in's position on a real stage, against the round's real wave three times over plus 8 ground enemy kinds (melee stand-ins always meet an enemy), an item each, bonds from the board |
|
||||
|
||||
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,8 @@ 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, six matches, the five stand-in
|
||||
battles of the normal records — 59 of the 144). `GOLDEN_FULL=1` checks everything.
|
||||
|
||||
## Workflow
|
||||
|
||||
|
||||
+130
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"family": "matches",
|
||||
"about": "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)",
|
||||
"about": "matches run to the end in virtual time: bot-only (solo ×4 difficulties ×2 seeds, co-op 2/3/4, one server-run, two boosted to the Hidden Core) and one co-op match whose human seat fields 补位 stand-ins",
|
||||
"note": "Generated by `npm run golden:update` (tools/golden.mjs). A refactor must never change this file; see test/golden/README.md.",
|
||||
"columns": {
|
||||
"rounds": ["alive","lp","prepFunds","funds","pending","level","deployed","board","layers","bonds","killed","total","leaks","gold"]
|
||||
@@ -1482,6 +1482,135 @@
|
||||
"bonds": ["arcaneShip 0/302","deputShip 1/328","egirShip 0/300","emptyShip 0/300","indomShip 1/326","investShip 0/300","kazimierzShip 0/300","kjeragShip 0/300","lateranoShip 0/300","maniShip 0/300","miraShip 0/300","preciShip 0/309","raidShip 0/300","sargonShip 0/300","siracusaShip 0/300","skillfulShip 1/314","soloShip 1/318","steadShip 2/384","suntShip 1/310","swiftShip 0/300","victoriaShip 1/342","visiShip 0/306","yanShip 0/310"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"coop2-NORMAL-14-standins": {
|
||||
"about": "coop NORMAL, 1 bot seat(s) + 1 human seat (AI 托管) without 7 operators (补位 stand-ins), seed 14",
|
||||
"fast": true,
|
||||
"mode": "coop",
|
||||
"difficulty": "NORMAL",
|
||||
"seats": 1,
|
||||
"seed": 14,
|
||||
"combat": "client",
|
||||
"boost": null,
|
||||
"setup": {"stageId":"act1autochess_m01","bossId":"boss_5","hiddenBossId":"boss_10","factions":["DOT","INVISIBLE","REFLECTION"]},
|
||||
"end": {"victory":true,"roundsPassed":14,"hiddenReached":false,"hiddenCleared":false,"reason":"victory","teamLp":2,"bossPoolLeft":0,"virtualMs":116550},
|
||||
"rng": {"setup":25,"shop":495,"waves":0,"draft":34,"bots":17791,"meta":11},
|
||||
"errors": {"engine":0,"logged":0,"sim":0,"dispatcher":0},
|
||||
"rounds": [
|
||||
{
|
||||
"round": 1,
|
||||
"h_0": [1,28,4,0,0,1,2,"58414d69",0,"arcaneShip 0/2, preciShip 0/2",5,5,0,4],
|
||||
"ai_0": [1,23,4,0,0,1,2,"40f57285",0,"kazimierzShip 0/2, swiftShip 0/2",5,5,0,4]
|
||||
},
|
||||
{
|
||||
"round": 2,
|
||||
"h_0": [1,28,5,0,0,1,4,"f52e5aa9",0,"arcaneShip 0/4, preciShip 0/4",6,6,0,9],
|
||||
"ai_0": [1,23,5,0,0,1,4,"1c3bb4d9",4,"kazimierzShip 0/4, swiftShip 1/4",6,6,0,9]
|
||||
},
|
||||
{
|
||||
"round": 3,
|
||||
"h_0": [1,28,6,0,0,1,7,"77be43d5",0,"arcaneShip 0/4, kazimierzShip 0/4, preciShip 0/4, swiftShip 0/4, yanShip 0/1",6,6,0,15],
|
||||
"ai_0": [1,23,6,0,0,1,7,"751ce61a",4,"kazimierzShip 0/4, sargonShip 0/2, swiftShip 1/4",6,6,0,15]
|
||||
},
|
||||
{
|
||||
"round": 4,
|
||||
"h_0": [1,28,7,0,0,2,8,"7cb948b2",8,"arcaneShip 0/4, kazimierzShip 0/8, preciShip 0/4, raidShip 1/8, soloShip 1/0, swiftShip 0/8, yanShip 0/1",9,9,0,22],
|
||||
"ai_0": [1,23,7,0,0,1,8,"b679c297",10,"arcaneShip 0/2, deputShip 1/2, kazimierzShip 0/4, preciShip 0/2, sargonShip 0/2, swiftShip 1/8",9,9,0,22]
|
||||
},
|
||||
{
|
||||
"round": 5,
|
||||
"h_0": [1,28,8,0,0,3,8,"d543ab44",16,"arcaneShip 0/4, kazimierzShip 0/8, preciShip 0/4, raidShip 1/16, soloShip 1/0, swiftShip 0/8, yanShip 0/1",21,21,0,30],
|
||||
"ai_0": [1,23,8,0,0,2,8,"b5d78234",34,"arcaneShip 0/2, deputShip 1/4, kazimierzShip 0/8, lateranoShip 1/5, preciShip 1/7, sargonShip 0/2, suntShip 1/2, swiftShip 1/16",21,21,0,30]
|
||||
},
|
||||
{
|
||||
"round": 6,
|
||||
"h_0": [1,28,9,0,2,3,8,"9b4b0e34",36,"arcaneShip 0/4, kazimierzShip 0/8, preciShip 0/4, raidShip 1/28, soloShip 1/0, swiftShip 1/8, yanShip 0/1",31,31,0,39],
|
||||
"ai_0": [1,23,9,0,1,3,8,"c6b88ba7",52,"arcaneShip 0/2, deputShip 2/8, kazimierzShip 0/8, lateranoShip 1/15, preciShip 0/10, sargonShip 0/2, suntShip 1/6, swiftShip 1/23",31,31,0,39]
|
||||
},
|
||||
{
|
||||
"round": 7,
|
||||
"h_0": [1,28,12,0,2,4,8,"f09ca8b6",44,"arcaneShip 0/4, kazimierzShip 0/8, preciShip 0/4, raidShip 1/36, soloShip 1/0, swiftShip 1/8, yanShip 0/1",34,34,0,50],
|
||||
"ai_0": [1,23,11,0,1,4,8,"f6f38045",77,"arcaneShip 0/2, deputShip 2/12, kazimierzShip 0/8, lateranoShip 1/25, preciShip 0/10, sargonShip 0/2, suntShip 1/10, swiftShip 1/30",34,34,0,50]
|
||||
},
|
||||
{
|
||||
"round": 8,
|
||||
"h_0": [1,25,13,0,2,4,8,"1070819b",63,"arcaneShip 1/7, kazimierzShip 0/8, preciShip 0/4, raidShip 1/48, skillfulShip 1/0, swiftShip 1/8, yanShip 0/1",12,15,3,62],
|
||||
"ai_0": [1,23,12,0,0,4,8,"87633ca6",106,"arcaneShip 0/2, deputShip 2/16, kazimierzShip 0/8, lateranoShip 1/35, preciShip 0/10, sargonShip 0/2, skillfulShip 1/4, suntShip 1/14, swiftShip 1/37",15,15,0,62]
|
||||
},
|
||||
{
|
||||
"round": 9,
|
||||
"h_0": [1,23,14,0,0,4,8,"70a00d2f",50,"arcaneShip 1/10, kazimierzShip 1/12, preciShip 1/9, raidShip 0/48, skillfulShip 1/3, soloShip 1/0, swiftShip 1/16, yanShip 0/1",21,23,2,76],
|
||||
"ai_0": [1,21,12,0,0,4,8,"8dc0d798",161,"arcaneShip 0/2, deputShip 1/18, kazimierzShip 0/8, lateranoShip 1/47, preciShip 1/20, sargonShip 0/2, skillfulShip 1/11, suntShip 1/16, swiftShip 1/49",21,23,2,75]
|
||||
},
|
||||
{
|
||||
"round": 10,
|
||||
"h_0": [1,23,12,0,0,5,8,"59b986b7",73,"arcaneShip 1/10, kazimierzShip 1/19, preciShip 1/14, raidShip 0/48, skillfulShip 1/6, soloShip 1/3, swiftShip 1/21, yanShip 0/1",31,31,0,87],
|
||||
"ai_0": [1,21,12,0,0,5,8,"fa71d132",188,"arcaneShip 0/2, deputShip 0/18, kazimierzShip 0/8, lateranoShip 1/71, preciShip 1/25, sargonShip 0/2, skillfulShip 1/16, suntShip 1/16, swiftShip 1/60",31,31,0,88]
|
||||
},
|
||||
{
|
||||
"round": 11,
|
||||
"h_0": [1,19,12,0,0,5,8,"b7278144",146,"arcaneShip 0/10, kazimierzShip 1/34, preciShip 2/19, raidShip 1/61, skillfulShip 0/9, soloShip 1/6, suntShip 1/0, swiftShip 1/26, yanShip 0/1",21,25,4,99],
|
||||
"ai_0": [1,19,12,0,0,5,8,"e240abda",226,"arcaneShip 0/2, deputShip 0/18, kazimierzShip 0/8, lateranoShip 1/79, preciShip 1/33, raidShip 1/5, sargonShip 0/2, skillfulShip 1/21, suntShip 1/16, swiftShip 1/67, visiShip 1/5",23,25,2,100]
|
||||
},
|
||||
{
|
||||
"round": 12,
|
||||
"h_0": [1,12,12,0,0,6,8,"955db8af",172,"arcaneShip 0/10, kazimierzShip 1/42, preciShip 2/24, raidShip 1/69, skillfulShip 0/9, soloShip 1/6, suntShip 1/0, swiftShip 1/31, yanShip 0/1",24,31,7,111],
|
||||
"ai_0": [1,9,12,0,2,6,8,"3c27819a",264,"arcaneShip 0/2, deputShip 0/18, kazimierzShip 0/8, lateranoShip 1/87, preciShip 1/41, raidShip 1/10, sargonShip 0/2, skillfulShip 1/26, suntShip 1/16, swiftShip 1/74, visiShip 1/10",17,31,14,113]
|
||||
},
|
||||
{
|
||||
"round": 13,
|
||||
"h_0": [1,2,12,0,0,6,8,"1bd1e6d0",244,"arcaneShip 1/10, kazimierzShip 1/54, preciShip 2/34, raidShip 1/105, skillfulShip 0/9, soloShip 0/6, suntShip 1/0, swiftShip 1/41, yanShip 0/1",20,57,37,122],
|
||||
"ai_0": [0,0,14,0,0,6,0,"811c9dc5",0,"arcaneShip 0/2, deputShip 0/18, kazimierzShip 0/8, lateranoShip 0/95, preciShip 0/49, raidShip 0/15, sargonShip 0/2, skillfulShip 0/31, suntShip 0/16, swiftShip 0/81, visiShip 0/15",15,57,42,126]
|
||||
}
|
||||
],
|
||||
"players": {
|
||||
"h_0": {
|
||||
"alive": true,
|
||||
"victory": true,
|
||||
"roundsPassed": 14,
|
||||
"eliminatedRound": null,
|
||||
"lp": 2,
|
||||
"bandId": "band_amedic",
|
||||
"title": {"id":"comment_1","name":"卫戍之星","picId":"comment_icon_1","text":"对敌方领袖造成伤害最高(仅胜利时)"},
|
||||
"trophies": 4,
|
||||
"reward": 400,
|
||||
"stats": {"activatedLayers":276,"bossDamage":390000,"dmgDealt":2811982,"gold":135,"itemsEquipped":23,"kills":254,"leaks":53,"lpLost":26,"merges":5,"perfectRounds":8,"refreshes":21},
|
||||
"lineup": ["6_05_a@12,3[3_08_e_a+3_05_e_a]","4_14_b@11,3[3_11_e_b+6_05_e_a]","1_17_b@10,3","6_19_a@10,4","6_17_a@10,7[4_08_e_a+1_05_e_b]","6_06_a@10,8[1_01_e_a+4_04_e_a]","3_12_a@9,4[4_04_e_b+1_02_e_b]","3_13_b@9,8[2_01_e_a+4_03_e_a]"],
|
||||
"bonds": ["arcaneShip 0/14","kazimierzShip 1/62","kjeragShip 0/8","preciShip 2/48","raidShip 1/105","sargonShip 1/2","skillfulShip 0/9","soloShip 0/6","suntShip 1/0","swiftShip 1/59","yanShip 0/1"]
|
||||
},
|
||||
"ai_0": {
|
||||
"alive": false,
|
||||
"victory": false,
|
||||
"roundsPassed": 12,
|
||||
"eliminatedRound": 13,
|
||||
"lp": 0,
|
||||
"bandId": "band_chiave",
|
||||
"title": {"id":"comment_6","name":"挥金如土","picId":"comment_icon_6","text":"消耗资金最多"},
|
||||
"trophies": 3,
|
||||
"reward": 280,
|
||||
"stats": {"activatedLayers":0,"bossDamage":0,"dmgDealt":2138966,"gold":126,"itemsEquipped":15,"kills":234,"leaks":60,"lpLost":24,"merges":3,"perfectRounds":9,"refreshes":23},
|
||||
"lineup": [],
|
||||
"bonds": ["arcaneShip 0/2","deputShip 0/18","kazimierzShip 0/8","lateranoShip 0/95","preciShip 0/49","raidShip 0/15","sargonShip 0/2","skillfulShip 0/31","suntShip 0/16","swiftShip 0/81","visiShip 0/15"]
|
||||
}
|
||||
},
|
||||
"standIns": {
|
||||
"notOwned": ["chess_char_3_12_a","chess_char_3_18_a","chess_char_5_20_a","chess_char_6_05_a","chess_char_6_06_a","chess_char_6_17_a","chess_char_6_19_a"],
|
||||
"rounds": [
|
||||
[1,""],
|
||||
[2,""],
|
||||
[3,""],
|
||||
[4,"3_12_a→char_608_acpion"],
|
||||
[5,"3_12_a→char_608_acpion"],
|
||||
[6,"3_12_a→char_608_acpion"],
|
||||
[7,"3_12_a→char_608_acpion"],
|
||||
[8,"3_12_a→char_608_acpion"],
|
||||
[9,"3_12_a→char_608_acpion"],
|
||||
[10,"3_12_a→char_608_acpion"],
|
||||
[11,"3_12_a→char_608_acpion 6_05_a→char_611_acnipe"],
|
||||
[12,"3_12_a→char_608_acpion 6_05_a→char_611_acnipe"],
|
||||
[13,"3_12_a→char_608_acpion 6_05_a→char_611_acnipe 6_17_a→char_608_acpion"]
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,578 @@
|
||||
{
|
||||
"family": "standins",
|
||||
"about": "every NORMAL chess record (normal + elite) fielded as its 补位 stand-in (all 17 stand-ins and the skills the chess name for them), 12 per battle on real stages and waves",
|
||||
"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": {
|
||||
"standin-01": {
|
||||
"about": "normal band 4 as stand-ins: chess_char_3_12_a→char_608_acpion chess_char_4_09_a→char_617_sharp2 chess_char_3_05_a→char_601_cguard chess_char_3_18_a→char_609_acguad chess_char_4_04_a→char_607_cspec chess_char_4_07_a→char_601_cguard chess_char_4_16_a→char_615_acspec chess_char_3_01_a→char_603_csnipe chess_char_3_04_a→char_605_cmedic chess_char_3_21_a→char_611_acnipe chess_char_4_02_a→char_611_acnipe chess_char_4_13_a→char_614_acsupo",
|
||||
"fast": true,
|
||||
"kind": "normal",
|
||||
"stageId": "act1autochess_m02",
|
||||
"modeId": "mode_single_normal",
|
||||
"round": 8,
|
||||
"seed": 2069558672,
|
||||
"waveId": "act1autochess_h01",
|
||||
"time": 170,
|
||||
"ticks": 5100,
|
||||
"reason": "timeout",
|
||||
"killed": 21,
|
||||
"total": 50,
|
||||
"unspawned": 0,
|
||||
"bossHpLeft": null,
|
||||
"rngDraws": 402,
|
||||
"errors": {"count":0,"kinds":[]},
|
||||
"players": {
|
||||
"p1": {
|
||||
"killed": 21,
|
||||
"total": 50,
|
||||
"perfect": false,
|
||||
"leaks": 29,
|
||||
"leaksUncounted": 0,
|
||||
"coins": 4,
|
||||
"dmg": 284159,
|
||||
"bossDmg": 0,
|
||||
"heal": 26726,
|
||||
"deaths": 22,
|
||||
"layerGains": {"raidShip":14,"visiShip":2}
|
||||
}
|
||||
},
|
||||
"units": [
|
||||
["p1",1,"op","chess_char_3_12_a",2,null,0,0,67,4,56082,3680,0,37,3],
|
||||
["p1",2,"op","chess_char_4_09_a",0,null,0,0,77,3,56268,5968,0,34,2],
|
||||
["p1",3,"op","chess_char_3_05_a",2,null,2109.99,1,74,4,33622,8823,0,39,2],
|
||||
["p1",4,"op","chess_char_3_18_a",2,null,0,0,7,3,33048,9522,0,77,2],
|
||||
["p1",5,"op","chess_char_4_04_a",1,null,435.7,1,80,0,2375,13673,0,113,0],
|
||||
["p1",6,"op","chess_char_4_07_a",1,null,0,0,71,1,16766,11343,0,37,1],
|
||||
["p1",7,"op","chess_char_4_16_a",1,null,807.8,1,85,3,27738,6036,0,83,0],
|
||||
["p1",8,"op","chess_char_3_01_a",2,null,1271,1,5,1,16680,0,0,152,3],
|
||||
["p1",9,"op","chess_char_3_04_a",2,null,1218,1,8,0,0,587,26726,59,3],
|
||||
["p1",10,"op","chess_char_3_21_a",2,null,1272,1,84,1,21722,1272,0,62,1],
|
||||
["p1",11,"op","chess_char_4_02_a",1,null,1272,1,82,1,9400,1724,0,71,1],
|
||||
["p1",12,"op","chess_char_4_13_a",1,null,0,0,78,0,10458,1624,0,28,1]
|
||||
],
|
||||
"enemies": {
|
||||
"enemy_10001_trslim": [1,1,0,8,4860],
|
||||
"enemy_1000_gopro_2": [1,1,0,22,1836],
|
||||
"enemy_10018_sgrobh": [1,1,0,2668,8100],
|
||||
"enemy_1001_bigbo": [1,0,1,0,2592],
|
||||
"enemy_10027_vtsk": [1,0,1,11845,916],
|
||||
"enemy_10031_cnvsld": [1,0,1,0,0],
|
||||
"enemy_10034_cnvsax": [1,0,1,0,0],
|
||||
"enemy_10042_prtrop": [1,0,1,808,3807],
|
||||
"enemy_1116_liprr_2": [21,12,9,5388,124143],
|
||||
"enemy_1119_vofsd": [12,5,6,21861,99812],
|
||||
"enemy_1121_lifbos_2": [9,1,7,13749,38094]
|
||||
},
|
||||
"hooks": [1,85,5100,943,943,1330,1321,3766,43,76,19,28,0,21170,28,44,50,27,1,43,6,0,9],
|
||||
"events": {"atk":1028,"bounty":2,"deploy":35,"die":49,"dmg":1321,"fx":68,"heal":59,"layer":6,"leak":27,"skill":35,"spawn":62,"status":134},
|
||||
"fx": {"blink":6,"dodge":9,"dp":7,"garrison":6,"liberate":15,"slash":24,"telegraph":1},
|
||||
"snaps": "e6528b42 398001f2 644c5ee8 df7b48f9 a8a51847 554e1092 13c60701 49f04e5d fb95fb75 38fa86a2 687082e0 9c39cb98 7dd1bdd1 a74eebb6 b9c1e236 8fc6086d 944683e9"
|
||||
},
|
||||
"standin-02": {
|
||||
"about": "normal band 5 as stand-ins: chess_char_4_17_a→char_610_acfend chess_char_4_18_a→char_602_cdfend chess_char_5_05_a→char_602_cdfend chess_char_4_19_a→char_608_acpion chess_char_5_01_a→char_600_cpione chess_char_4_22_a→char_609_acguad chess_char_4_23_a→char_609_acguad chess_char_5_06_a→char_609_acguad chess_char_4_20_a→char_603_csnipe chess_char_4_24_a→char_604_ccast chess_char_5_02_a→char_604_ccast chess_char_5_03_a→char_612_accast",
|
||||
"fast": true,
|
||||
"kind": "normal",
|
||||
"stageId": "act1autochess_m05",
|
||||
"modeId": "mode_multi_abyss",
|
||||
"round": 10,
|
||||
"seed": 3710344357,
|
||||
"waveId": "act1autochess_h03",
|
||||
"time": 190,
|
||||
"ticks": 5700,
|
||||
"reason": "timeout",
|
||||
"killed": 9,
|
||||
"total": 68,
|
||||
"unspawned": 0,
|
||||
"bossHpLeft": null,
|
||||
"rngDraws": 113,
|
||||
"errors": {"count":0,"kinds":[]},
|
||||
"players": {
|
||||
"p1": {"killed":9,"total":68,"perfect":false,"leaks":59,"leaksUncounted":0,"coins":0,"dmg":98109,"bossDmg":0,"heal":0,"deaths":23,"layerGains":{"preciShip":12}}
|
||||
},
|
||||
"units": [
|
||||
["p1",1,"op","chess_char_4_17_a",1,null,0,0,3,0,57,2604,0,4,1],
|
||||
["p1",2,"op","chess_char_4_18_a",1,null,0,0,78,0,2350,6810,0,6,1],
|
||||
["p1",3,"op","chess_char_5_05_a",2,null,0,0,70,0,14,4256,0,1,0],
|
||||
["p1",4,"op","chess_char_4_19_a",1,null,0,0,66,0,316,3058,0,13,1],
|
||||
["p1",5,"op","chess_char_5_01_a",2,null,0,0,91,0,35,3675,0,2,0],
|
||||
["p1",6,"op","chess_char_4_22_a",1,null,0,0,71,0,5165,5276,0,9,0],
|
||||
["p1",7,"op","chess_char_4_23_a",1,null,0,0,64,0,5497,5276,0,9,0],
|
||||
["p1",8,"op","chess_char_5_06_a",2,null,0,0,1,0,0,2638,0,0,0],
|
||||
["p1",9,"op","chess_char_4_20_a",1,null,1271,1,92,1,11656,2542,0,25,0],
|
||||
["p1",10,"op","chess_char_4_24_a",1,null,0,0,90,0,3736,3399,0,8,0],
|
||||
["p1",11,"op","chess_char_5_02_a",2,null,0,0,84,3,13253,2266,0,25,1],
|
||||
["p1",12,"op","chess_char_5_03_a",2,null,0,0,4,5,56032,1372,0,10,1]
|
||||
],
|
||||
"enemies": {
|
||||
"enemy_10042_prtrop_2": [1,0,1,4567,14393],
|
||||
"enemy_10043_sailor": [1,1,0,1137,23649],
|
||||
"enemy_10044_wintun": [1,0,1,4374,12094],
|
||||
"enemy_10045_parrot": [1,0,1,0,4099],
|
||||
"enemy_10054_cjhot": [1,0,1,1676,1665],
|
||||
"enemy_1005_yokai": [27,8,19,0,29182],
|
||||
"enemy_1005_yokai_2": [27,0,24,11401,2816],
|
||||
"enemy_1005_yokai_3": [6,0,6,20017,14371],
|
||||
"enemy_10065_ftzlc": [1,0,1,0,560],
|
||||
"enemy_10067_ftsjc": [1,0,1,0,680],
|
||||
"enemy_1006_shield": [1,0,1,0,760]
|
||||
},
|
||||
"hooks": [1,92,5700,337,337,558,523,0,32,88,5,5,0,12532,1,13,68,56,1,32,3,0,41],
|
||||
"events": {"atk":345,"deploy":24,"die":32,"dmg":523,"fx":81,"layer":3,"leak":56,"skill":10,"spawn":80,"status":2},
|
||||
"fx": {"aoe":4,"dodge":41,"dp":4,"explode":27,"garrison":3,"garrisonGrant":1,"zone":1},
|
||||
"snaps": "9d2468c6 9a32f006 99b2aa33 a01ecc39 97bb00b0 2fcede4b fe594f15 377f7691 e87b0fd3 af44759d 7b13ea89 61cd9b40 4f9d1182 5905fb57 d697ca06 c37e9513 3102b854 296cdd49 4d5efa28"
|
||||
},
|
||||
"standin-03": {
|
||||
"about": "normal band 5 as stand-ins: chess_char_5_08_a→char_610_acfend chess_char_5_14_a→char_608_acpion chess_char_5_19_a→char_617_sharp2 chess_char_5_07_a→char_601_cguard chess_char_5_13_a→char_609_acguad chess_char_5_17_a→char_615_acspec chess_char_5_10_a→char_606_csuppo chess_char_5_11_a→char_613_acmedc chess_char_5_12_a→char_614_acsupo chess_char_5_15_a→char_603_csnipe chess_char_5_18_a→char_611_acnipe chess_char_5_20_a→char_614_acsupo",
|
||||
"fast": true,
|
||||
"kind": "normal",
|
||||
"stageId": "act2autochess_m01",
|
||||
"modeId": "mode_multi_funny",
|
||||
"round": 10,
|
||||
"seed": 3083705594,
|
||||
"waveId": "act1autochess_h03",
|
||||
"time": 190,
|
||||
"ticks": 5700,
|
||||
"reason": "timeout",
|
||||
"killed": 37,
|
||||
"total": 74,
|
||||
"unspawned": 0,
|
||||
"bossHpLeft": null,
|
||||
"rngDraws": 374,
|
||||
"errors": {"count":0,"kinds":[]},
|
||||
"players": {"p1":{"killed":37,"total":74,"perfect":false,"leaks":37,"leaksUncounted":0,"coins":8,"dmg":331499,"bossDmg":0,"heal":49226,"deaths":12,"layerGains":{}}},
|
||||
"units": [
|
||||
["p1",1,"op","chess_char_5_08_a",2,null,0,0,6,0,4409,3444,0,59,1],
|
||||
["p1",2,"op","chess_char_5_14_a",2,null,0,0,86,16,131680,8403,0,53,6],
|
||||
["p1",3,"op","chess_char_5_19_a",0,null,0,0,90,8,38187,15644,0,91,2],
|
||||
["p1",4,"op","chess_char_5_07_a",2,null,0,0,7,2,18936,10110,0,69,2],
|
||||
["p1",5,"op","chess_char_5_13_a",2,null,1872.24,1,11,5,68168,12979,0,127,4],
|
||||
["p1",6,"op","chess_char_5_17_a",2,null,0,0,81,2,7848,5571,0,85,0],
|
||||
["p1",7,"op","chess_char_5_10_a",2,null,932,1,2,1,8617,0,0,74,2],
|
||||
["p1",8,"op","chess_char_5_11_a",2,null,1224,1,3,0,0,0,49226,72,2],
|
||||
["p1",9,"op","chess_char_5_12_a",2,null,1160,1,4,0,22812,0,0,88,2],
|
||||
["p1",10,"op","chess_char_5_15_a",2,null,0,0,85,2,8089,12138,0,87,2],
|
||||
["p1",11,"op","chess_char_5_18_a",2,null,0,0,1,1,13952,1272,0,34,1],
|
||||
["p1",12,"op","chess_char_5_20_a",2,null,742,1,91,0,8800,3202,0,43,1]
|
||||
],
|
||||
"enemies": {
|
||||
"enemy_1006_shield_2": [1,1,0,679,8000],
|
||||
"enemy_1006_shield_3": [1,0,1,11277,13035],
|
||||
"enemy_10073_mpcar": [1,1,0,12,1600],
|
||||
"enemy_1007_slime": [1,1,0,0,440],
|
||||
"enemy_10081_mpplai": [1,1,0,0,2000],
|
||||
"enemy_10087_hlchgr": [1,1,0,58,4800],
|
||||
"enemy_10094_crstf": [1,1,0,28,3200],
|
||||
"enemy_10097_crshd": [1,0,1,936,5046],
|
||||
"enemy_1166_dusbr_2": [42,27,15,3496,107410],
|
||||
"enemy_1170_dushld_2": [12,2,8,27941,44857],
|
||||
"enemy_9011_acrefr": [12,2,4,28337,141112]
|
||||
},
|
||||
"hooks": [1,91,5700,1118,1118,1648,1663,132,49,78,25,27,0,27793,3468,54,74,29,1,49,0,1,9],
|
||||
"events": {"atk":1244,"bounty":4,"deploy":17,"die":49,"dmg":1661,"fx":130,"heal":126,"leak":29,"skill":50,"spawn":86,"status":661},
|
||||
"fx": {"aoe":30,"burst":1,"displace":1,"dodge":16,"dp":22,"phase":1,"slash":48,"sp":1,"spGain":10},
|
||||
"snaps": "dbcb5cec 56a8a8f6 e8d97a5d d2594a66 b6f753a4 6b5239d5 0d94f1a8 53bcdab2 582202c7 d547ef42 438122c0 f5345cd2 dbac677c 38aa1103 537a6dbf c9e9e9aa 7c7ed647 9cfd6d5b 94abfb24"
|
||||
},
|
||||
"standin-04": {
|
||||
"about": "normal band 6 as stand-ins: chess_char_6_03_a→char_610_acfend chess_char_6_02_a→char_608_acpion chess_char_6_07_a→char_617_sharp2 chess_char_6_10_a→char_617_sharp2 chess_char_6_11_a→char_608_acpion chess_char_6_06_a→char_609_acguad chess_char_5_22_a→char_611_acnipe chess_char_6_01_a→char_611_acnipe chess_char_6_04_a→char_614_acsupo chess_char_6_05_a→char_611_acnipe chess_char_6_08_a→char_612_accast chess_char_6_09_a→char_613_acmedc",
|
||||
"fast": true,
|
||||
"kind": "normal",
|
||||
"stageId": "act2autochess_m04",
|
||||
"modeId": "mode_single_hard",
|
||||
"round": 12,
|
||||
"seed": 428893583,
|
||||
"waveId": "act1autochess_h05",
|
||||
"time": 201.7,
|
||||
"ticks": 6051,
|
||||
"reason": "cleared",
|
||||
"killed": 136,
|
||||
"total": 158,
|
||||
"unspawned": 0,
|
||||
"bossHpLeft": null,
|
||||
"rngDraws": 644,
|
||||
"errors": {"count":0,"kinds":[]},
|
||||
"players": {
|
||||
"p1": {
|
||||
"killed": 136,
|
||||
"total": 158,
|
||||
"perfect": false,
|
||||
"leaks": 22,
|
||||
"leaksUncounted": 0,
|
||||
"coins": 8,
|
||||
"dmg": 1309822,
|
||||
"bossDmg": 0,
|
||||
"heal": 23527,
|
||||
"deaths": 12,
|
||||
"layerGains": {"sargonShip":5}
|
||||
}
|
||||
},
|
||||
"units": [
|
||||
["p1",1,"op","chess_char_6_03_a",2,null,0,0,174,0,698,7812,0,6,0],
|
||||
["p1",2,"op","chess_char_6_02_a",2,null,1529,1,179,1,2975,3058,0,7,2],
|
||||
["p1",3,"op","chess_char_6_07_a",0,null,0,0,172,9,135322,3730,0,74,1],
|
||||
["p1",4,"op","chess_char_6_10_a",0,null,0,0,166,5,94409,3730,0,75,2],
|
||||
["p1",5,"op","chess_char_6_11_a",2,null,1026.4,1,178,11,100320,8547,0,40,4],
|
||||
["p1",6,"op","chess_char_6_06_a",2,null,2080.4,1,156,9,111400,19283,0,111,4],
|
||||
["p1",7,"op","chess_char_5_22_a",2,null,1272,1,3,2,25528,0,0,19,2],
|
||||
["p1",8,"op","chess_char_6_01_a",2,null,1272,1,4,34,325813,0,0,164,2],
|
||||
["p1",9,"op","chess_char_6_04_a",2,null,1160,1,13,7,65845,0,0,121,2],
|
||||
["p1",10,"op","chess_char_6_05_a",2,null,1272,1,14,18,234561,2453,0,122,2],
|
||||
["p1",11,"op","chess_char_6_08_a",2,null,1372,1,5,39,212952,0,0,57,2],
|
||||
["p1",12,"op","chess_char_6_09_a",2,null,1224,1,11,0,0,0,23527,37,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]
|
||||
],
|
||||
"enemies": {
|
||||
"enemy_10098_crhro": [1,1,0,3450,41472],
|
||||
"enemy_10099_crvln": [1,1,0,2453,20736],
|
||||
"enemy_1009_lurker": [1,1,0,16,3041],
|
||||
"enemy_1010_demon": [1,1,0,0,10368],
|
||||
"enemy_1010_demon_2": [28,22,6,15447,543909],
|
||||
"enemy_10112_ymgds": [1,1,0,0,6912],
|
||||
"enemy_10116_ymgtop": [1,1,0,2924,13824],
|
||||
"enemy_10118_ymgprc": [1,0,1,3965,18459],
|
||||
"enemy_1071_dftman": [63,63,0,447,243855],
|
||||
"enemy_1072_dlancer": [60,45,15,19911,429319]
|
||||
},
|
||||
"hooks": [1,181,6051,918,918,1945,1944,62,148,170,25,24,0,31170,4160,44,158,22,1,149,2,0,13],
|
||||
"events": {"atk":1080,"bounty":4,"deploy":21,"die":148,"dmg":1944,"fx":87,"heal":45,"layer":2,"leak":22,"skill":49,"spawn":172,"status":440},
|
||||
"fx": {"aoe":7,"dodge":13,"dp":18,"garrison":2,"revive":1,"slash":29,"sp":12,"spGain":3,"telegraph":2},
|
||||
"snaps": "29a29463 6fc96b2d 184b448a fce3c126 45d67357 2b2b80de f966fc58 66e1b5c9 ce0eff29 79805bf9 289d6433 dbecb111 d3142708 bec781dd 26e4cc8e 0c071920 829264cd 362b2f26 16b5df6a 9fda7720 7730ec59"
|
||||
},
|
||||
"standin-05": {
|
||||
"about": "normal band 6 as stand-ins: chess_char_6_17_a→char_608_acpion chess_char_6_13_a→char_615_acspec chess_char_6_19_a→char_615_acspec chess_char_6_12_a→char_612_accast chess_char_6_15_a→char_614_acsupo chess_char_6_18_a→char_612_accast chess_char_6_20_a→char_613_acmedc",
|
||||
"fast": true,
|
||||
"kind": "normal",
|
||||
"stageId": "act1autochess_m03",
|
||||
"modeId": "mode_multi_normal",
|
||||
"round": 12,
|
||||
"seed": 4096833445,
|
||||
"waveId": "act1autochess_h05",
|
||||
"time": 214.33,
|
||||
"ticks": 6430,
|
||||
"reason": "cleared",
|
||||
"killed": 7,
|
||||
"total": 104,
|
||||
"unspawned": 0,
|
||||
"bossHpLeft": null,
|
||||
"rngDraws": 246,
|
||||
"errors": {"count":0,"kinds":[]},
|
||||
"players": {
|
||||
"p1": {"killed":7,"total":104,"perfect":false,"leaks":97,"leaksUncounted":0,"coins":0,"dmg":426178,"bossDmg":0,"heal":1353,"deaths":22,"layerGains":{"raidShip":3}}
|
||||
},
|
||||
"units": [
|
||||
["p1",1,"op","chess_char_6_17_a",2,null,0,0,128,1,103267,9556,0,43,1],
|
||||
["p1",2,"op","chess_char_6_13_a",2,null,0,0,132,1,25092,9232,0,87,0],
|
||||
["p1",3,"op","chess_char_6_19_a",2,null,0,0,134,2,29265,9232,0,141,0],
|
||||
["p1",4,"op","chess_char_6_12_a",2,null,671.78,1,7,1,143681,975,0,85,2],
|
||||
["p1",5,"op","chess_char_6_15_a",2,null,0,0,133,2,54479,4965,0,113,2],
|
||||
["p1",6,"op","chess_char_6_18_a",2,null,1372,1,85,0,70393,1372,0,42,2],
|
||||
["p1",7,"op","chess_char_6_20_a",2,null,1224,1,4,0,0,1353,1353,3,1],
|
||||
["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null],
|
||||
["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null],
|
||||
["-","~3","device","trap_1105_accrate",null,null,100,1,3,0,0,0,0,0,null]
|
||||
],
|
||||
"enemies": {
|
||||
"enemy_10122_uacann_2": [1,0,1,6380,12182],
|
||||
"enemy_10124_uashld_2": [1,0,1,0,5764],
|
||||
"enemy_10127_rkmbst_2": [1,0,1,834,0],
|
||||
"enemy_10138_xdsnow": [1,0,1,0,1276],
|
||||
"enemy_10141_xdpeng_2": [1,0,1,4211,14094],
|
||||
"enemy_10144_xdelk_2": [1,0,1,0,42],
|
||||
"enemy_10156_mncrer": [1,0,1,0,738],
|
||||
"enemy_10159_mntrjn": [1,0,1,0,0],
|
||||
"enemy_1381_winman": [27,5,22,3499,98764],
|
||||
"enemy_1381_winman_2": [39,1,38,5799,153206],
|
||||
"enemy_1387_winshd": [30,1,29,15960,140112]
|
||||
},
|
||||
"hooks": [1,134,6430,620,620,988,988,6,29,128,8,24,0,14868,2437,28,104,97,1,29,1,0,0],
|
||||
"events": {"atk":651,"deploy":27,"die":31,"dmg":901,"fx":58,"heal":3,"layer":1,"leak":97,"skill":16,"spawn":114,"status":514},
|
||||
"fx": {"aoe":5,"blink":2,"camouflage":1,"displace":22,"dodge":3,"dp":2,"explode":6,"garrison":1,"garrisonGrant":2,"slash":8,"zone":6},
|
||||
"snaps": "8d7b3223 b6dafa64 3940bc8e fd79b9e2 e5e85618 6fcfc479 90958489 71bfbb91 d12c224c cd2c9c7f 4a9fff33 68e48a86 4b97e027 5f8e8dad da62239a 8601c0dc 73717b6d 9e4f784f 818e7ed1 c1f2a855 33118eab 9bafc0bc"
|
||||
},
|
||||
"standin-06": {
|
||||
"about": "elite band 5 as stand-ins: chess_char_3_12_b→char_608_acpion chess_char_4_09_b→char_617_sharp2 chess_char_3_05_b→char_601_cguard chess_char_3_18_b→char_609_acguad chess_char_4_04_b→char_607_cspec chess_char_4_07_b→char_601_cguard chess_char_4_16_b→char_615_acspec chess_char_3_01_b→char_603_csnipe chess_char_3_04_b→char_605_cmedic chess_char_3_21_b→char_611_acnipe chess_char_4_02_b→char_611_acnipe chess_char_4_13_b→char_614_acsupo",
|
||||
"fast": false,
|
||||
"kind": "normal",
|
||||
"stageId": "act1autochess_m06",
|
||||
"modeId": "mode_multi_hard",
|
||||
"round": 10,
|
||||
"seed": 1442657168,
|
||||
"waveId": "act1autochess_h03",
|
||||
"time": 185.83,
|
||||
"ticks": 5575,
|
||||
"reason": "cleared",
|
||||
"killed": 40,
|
||||
"total": 98,
|
||||
"unspawned": 0,
|
||||
"bossHpLeft": null,
|
||||
"rngDraws": 293,
|
||||
"errors": {"count":0,"kinds":[]},
|
||||
"players": {
|
||||
"p1": {"killed":40,"total":98,"perfect":false,"leaks":58,"leaksUncounted":0,"coins":8,"dmg":553335,"bossDmg":0,"heal":19916,"deaths":32,"layerGains":{"raidShip":24}}
|
||||
},
|
||||
"units": [
|
||||
["p1",1,"op","chess_char_3_12_b",2,"none",1904,1,135,10,142984,5468,880,20,3],
|
||||
["p1",2,"op","chess_char_4_09_b",0,"none",0,0,6,7,58726,3510,0,13,1],
|
||||
["p1",3,"op","chess_char_3_05_b",2,"none",0,0,90,1,27607,9128,0,18,2],
|
||||
["p1",4,"op","chess_char_3_18_b",2,"none",0,0,89,3,34118,9190,2808,27,2],
|
||||
["p1",5,"op","chess_char_4_04_b",1,"none",0,0,134,1,8258,8846,0,21,0],
|
||||
["p1",6,"op","chess_char_4_07_b",1,"none",0,0,93,0,19765,10676,0,20,1],
|
||||
["p1",7,"op","chess_char_4_16_b",1,"none",0,0,133,2,17401,11631,0,26,0],
|
||||
["p1",8,"op","chess_char_3_01_b",2,"none",0,0,128,1,21787,3020,0,27,1],
|
||||
["p1",9,"op","chess_char_3_04_b",2,"none",0,0,4,0,0,6998,16229,17,2],
|
||||
["p1",10,"op","chess_char_3_21_b",2,"none",0,0,114,11,155605,7911,0,47,1],
|
||||
["p1",11,"op","chess_char_4_02_b",1,"none",0,0,2,3,47003,1483,0,29,1],
|
||||
["p1",12,"op","chess_char_4_13_b",1,"none",0,0,96,1,20081,3262,0,25,2]
|
||||
],
|
||||
"enemies": {
|
||||
"enemy_10162_mnctpt": [1,1,0,7990,39813],
|
||||
"enemy_1019_jshoot": [1,1,0,423,4479],
|
||||
"enemy_1019_jshoot_2": [1,1,0,1526,6718],
|
||||
"enemy_1021_bslime": [1,1,0,3434,6121],
|
||||
"enemy_1023_jmage": [1,1,0,2086,5474],
|
||||
"enemy_1025_reveng": [1,1,0,0,12442],
|
||||
"enemy_1026_aghost": [1,1,0,0,9456],
|
||||
"enemy_1043_zomsbr": [1,0,1,0,0],
|
||||
"enemy_1251_lysyta_2": [57,25,32,15811,231796],
|
||||
"enemy_1252_lysytb_2": [21,7,14,21523,186411],
|
||||
"enemy_1329_cbshld_2": [12,1,11,28332,50624]
|
||||
},
|
||||
"hooks": [1,135,5575,471,471,727,756,61,72,134,16,33,0,6314,36,49,98,58,1,81,3,0,6],
|
||||
"events": {"atk":553,"bounty":4,"deploy":37,"die":76,"dmg":756,"fx":57,"heal":58,"layer":3,"leak":58,"skill":32,"spawn":110,"status":119},
|
||||
"fx": {"blink":4,"camouflage":1,"dodge":11,"dp":13,"explode":1,"garrison":3,"slash":24},
|
||||
"snaps": "2e7a8cb5 d50cd79b d7949cae 1b9749a6 6e3d202d f87f73b6 45e24574 d9fad5af 5495e86c 3357c483 195f9b7f 05cf2852 258f0a18 7db86d77 fcd4dcb8 b89c90a0 d44a9d5e 0de542a5 f00c7481"
|
||||
},
|
||||
"standin-07": {
|
||||
"about": "elite band 6 as stand-ins: chess_char_4_17_b→char_610_acfend chess_char_4_18_b→char_602_cdfend chess_char_5_05_b→char_602_cdfend chess_char_4_19_b→char_608_acpion chess_char_5_01_b→char_600_cpione chess_char_4_22_b→char_609_acguad chess_char_4_23_b→char_609_acguad chess_char_5_06_b→char_609_acguad chess_char_4_20_b→char_603_csnipe chess_char_4_24_b→char_604_ccast chess_char_5_02_b→char_604_ccast chess_char_5_03_b→char_612_accast",
|
||||
"fast": false,
|
||||
"kind": "normal",
|
||||
"stageId": "act2autochess_m02",
|
||||
"modeId": "mode_single_normal",
|
||||
"round": 12,
|
||||
"seed": 816286799,
|
||||
"waveId": "act1autochess_h05",
|
||||
"time": 222.97,
|
||||
"ticks": 6689,
|
||||
"reason": "cleared",
|
||||
"killed": 8,
|
||||
"total": 80,
|
||||
"unspawned": 0,
|
||||
"bossHpLeft": null,
|
||||
"rngDraws": 80,
|
||||
"errors": {"count":0,"kinds":[]},
|
||||
"players": {
|
||||
"p1": {
|
||||
"killed": 8,
|
||||
"total": 80,
|
||||
"perfect": false,
|
||||
"leaks": 72,
|
||||
"leaksUncounted": 0,
|
||||
"coins": 4,
|
||||
"dmg": 126420,
|
||||
"bossDmg": 0,
|
||||
"heal": 0,
|
||||
"deaths": 22,
|
||||
"layerGains": {"preciShip":8,"sargonShip":18}
|
||||
}
|
||||
},
|
||||
"units": [
|
||||
["p1",1,"op","chess_char_4_17_b",1,"none",0,0,3,0,42,3211,0,2,1],
|
||||
["p1",2,"op","chess_char_4_18_b",1,"none",0,0,83,0,30,9306,0,1,1],
|
||||
["p1",3,"op","chess_char_5_05_b",2,"none",0,0,97,0,572,5816,0,1,1],
|
||||
["p1",4,"op","chess_char_4_19_b",1,"none",1847.63,1,107,1,367,3864,0,5,1],
|
||||
["p1",5,"op","chess_char_5_01_b",2,"none",1036.53,1,105,0,2197,3985,0,5,3],
|
||||
["p1",6,"op","chess_char_4_22_b",1,"none",3191,1,109,0,1178,14884,0,5,1],
|
||||
["p1",7,"op","chess_char_4_23_b",1,"none",0,0,67,0,3484,6610,0,8,1],
|
||||
["p1",8,"op","chess_char_5_06_b",2,"uniequip_002_acguad",0,0,10,0,0,3820,0,0,0],
|
||||
["p1",9,"op","chess_char_4_20_b",1,"none",1255.06,1,106,2,16976,4326,0,31,1],
|
||||
["p1",10,"op","chess_char_4_24_b",1,"none",1007.59,1,108,0,5460,3072,0,18,1],
|
||||
["p1",11,"op","chess_char_5_02_b",2,"none",0,0,102,1,8032,4080,0,19,1],
|
||||
["p1",12,"op","chess_char_5_03_b",2,"uniequip_002_accast",0,0,4,4,88082,1749,0,10,1],
|
||||
["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null],
|
||||
["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null]
|
||||
],
|
||||
"enemies": {
|
||||
"enemy_1005_yokai_2": [18,2,16,3894,7075],
|
||||
"enemy_1041_lazerd_2": [24,3,21,41810,33530],
|
||||
"enemy_1042_frostd": [30,1,29,0,24206],
|
||||
"enemy_1045_hammer": [1,1,0,1914,15552],
|
||||
"enemy_1045_hammer_2": [1,0,1,6132,25057],
|
||||
"enemy_1046_agent": [1,1,0,57,3888],
|
||||
"enemy_1050_lslime": [1,0,1,9425,1320],
|
||||
"enemy_1061_zomshd": [1,0,1,31,9241],
|
||||
"enemy_1062_rager_2": [1,0,1,1459,14551],
|
||||
"enemy_1067_snslime": [1,0,1,0,0],
|
||||
"enemy_1071_dftman": [1,0,1,0,0]
|
||||
},
|
||||
"hooks": [1,109,6689,304,304,581,573,378,30,102,13,13,0,8920,9,11,80,72,1,33,2,0,8],
|
||||
"events": {"atk":330,"bounty":2,"deploy":27,"die":30,"dmg":573,"fx":22,"layer":2,"leak":72,"skill":26,"spawn":94,"status":103},
|
||||
"fx": {"aoe":1,"dodge":8,"dp":6,"garrison":2,"garrisonGrant":1,"palsy":1,"revive":3},
|
||||
"snaps": "ef9ff66a 4f4ac6fc 133f7f1c a187a8c8 46d89701 4bb496a8 0dfccf57 81173658 8c42876d 4718c77c d3b80c8e df3c692d 0576a74b 7684de92 e59b0c43 849795d5 e99812de 3f883cd6 6bb9cdf6 b246f597 289cd790 2f34e6ff 997f51f3"
|
||||
},
|
||||
"standin-08": {
|
||||
"about": "elite band 6 as stand-ins: chess_char_5_08_b→char_610_acfend chess_char_5_14_b→char_608_acpion chess_char_5_19_b→char_617_sharp2 chess_char_5_07_b→char_601_cguard chess_char_5_13_b→char_609_acguad chess_char_5_17_b→char_615_acspec chess_char_5_10_b→char_606_csuppo chess_char_5_11_b→char_613_acmedc chess_char_5_12_b→char_614_acsupo chess_char_5_15_b→char_603_csnipe chess_char_5_18_b→char_611_acnipe chess_char_5_20_b→char_614_acsupo",
|
||||
"fast": false,
|
||||
"kind": "normal",
|
||||
"stageId": "act1autochess_m01",
|
||||
"modeId": "mode_multi_abyss",
|
||||
"round": 12,
|
||||
"seed": 2456427706,
|
||||
"waveId": "act1autochess_h05",
|
||||
"time": 230,
|
||||
"ticks": 6900,
|
||||
"reason": "timeout",
|
||||
"killed": 32,
|
||||
"total": 179,
|
||||
"unspawned": 0,
|
||||
"bossHpLeft": null,
|
||||
"rngDraws": 1052,
|
||||
"errors": {"count":0,"kinds":[]},
|
||||
"players": {"p1":{"killed":32,"total":179,"perfect":false,"leaks":147,"leaksUncounted":0,"coins":2,"dmg":1703220,"bossDmg":0,"heal":106481,"deaths":9,"layerGains":{}}},
|
||||
"units": [
|
||||
["p1",1,"op","chess_char_5_08_b",2,"uniequip_002_acfend",3285.08,1,201,4,80453,15794,0,116,3],
|
||||
["p1",2,"op","chess_char_5_14_b",2,"uniequip_002_acpion",1904,1,200,8,678474,11374,0,31,9],
|
||||
["p1",3,"op","chess_char_5_19_b",0,"uniequip_002_sharp2",4154.5,1,202,4,187598,17962,0,51,2],
|
||||
["p1",4,"op","chess_char_5_07_b",2,"none",3334,1,199,2,46104,18401,0,36,3],
|
||||
["p1",5,"op","chess_char_5_13_b",2,"uniequip_002_acguad",0,0,160,5,101185,25518,0,50,3],
|
||||
["p1",6,"op","chess_char_5_17_b",2,"uniequip_002_acspec",1448,1,8,2,21853,46495,0,206,0],
|
||||
["p1",7,"op","chess_char_5_10_b",2,"none",1131,1,4,1,46614,0,0,155,4],
|
||||
["p1",8,"op","chess_char_5_11_b",2,"uniequip_002_acmedc",2262.4,1,5,0,0,0,106481,74,3],
|
||||
["p1",9,"op","chess_char_5_12_b",2,"uniequip_002_acsupo",1430,1,11,2,97118,0,0,130,3],
|
||||
["p1",10,"op","chess_char_5_15_b",2,"none",1510,1,13,0,84181,0,0,228,4],
|
||||
["p1",11,"op","chess_char_5_18_b",2,"uniequip_002_acnipe",2380.5,1,6,4,265046,0,0,156,3],
|
||||
["p1",12,"op","chess_char_5_20_b",2,"uniequip_002_acsupo",1430,1,14,0,94594,0,0,125,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_1072_dlancer": [1,1,0,3008,37150],
|
||||
"enemy_1081_sotisd": [1,1,0,39568,92876],
|
||||
"enemy_1092_mdgint": [1,0,1,0,67],
|
||||
"enemy_1116_liprr": [1,0,1,0,7702],
|
||||
"enemy_1116_liprr_2": [1,0,1,0,1829],
|
||||
"enemy_1118_lidbox_2": [1,0,1,0,1344],
|
||||
"enemy_1119_vofsd": [1,0,1,0,959],
|
||||
"enemy_1121_lifbos": [1,0,1,0,922],
|
||||
"enemy_1249_lysdb_2": [60,9,49,23770,693077],
|
||||
"enemy_1251_lysyta": [63,20,43,6676,488381],
|
||||
"enemy_1254_lypa_2": [48,1,47,62523,378913]
|
||||
},
|
||||
"hooks": [1,202,6900,1760,1760,3076,3094,145,41,186,40,39,0,26130,16747,54,179,145,1,45,0,0,8],
|
||||
"events": {"atk":1979,"bounty":1,"deploy":20,"die":41,"dmg":3094,"fx":274,"heal":126,"leak":145,"skill":79,"spawn":194,"status":1237},
|
||||
"fx": {"aoe":84,"dodge":29,"dp":17,"shieldBreak":60,"slash":72,"sp":5,"spGain":7},
|
||||
"snaps": "4d4dc5ab 98e9e455 46d76035 722418a9 b67e68a3 866e42e8 786ec7e2 2c2a7307 3657963b 8fad1c0c 513f1b73 84a84fa5 d1698d65 9ae19dde 551dcc8c 8e2b8aef a8f4f525 70f7b5a7 f8b9e9b9 0c6627fe 9c4013d8 fa4c2add 6f3c1f53"
|
||||
},
|
||||
"standin-09": {
|
||||
"about": "elite band 7 as stand-ins: chess_char_6_03_b→char_610_acfend chess_char_6_02_b→char_608_acpion chess_char_6_07_b→char_617_sharp2 chess_char_6_10_b→char_617_sharp2 chess_char_6_11_b→char_608_acpion chess_char_6_06_b→char_609_acguad chess_char_5_22_b→char_611_acnipe chess_char_6_01_b→char_611_acnipe chess_char_6_04_b→char_614_acsupo chess_char_6_05_b→char_611_acnipe chess_char_6_08_b→char_612_accast chess_char_6_09_b→char_613_acmedc",
|
||||
"fast": false,
|
||||
"kind": "normal",
|
||||
"stageId": "act1autochess_m04",
|
||||
"modeId": "mode_multi_funny",
|
||||
"round": 13,
|
||||
"seed": 2308517178,
|
||||
"waveId": "act1autochess_h06",
|
||||
"time": 199.53,
|
||||
"ticks": 5986,
|
||||
"reason": "cleared",
|
||||
"killed": 158,
|
||||
"total": 158,
|
||||
"unspawned": 0,
|
||||
"bossHpLeft": null,
|
||||
"rngDraws": 279,
|
||||
"errors": {"count":0,"kinds":[]},
|
||||
"players": {
|
||||
"p1": {"killed":158,"total":158,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":8,"dmg":924432,"bossDmg":0,"heal":29522,"deaths":0,"layerGains":{"sargonShip":16}}
|
||||
},
|
||||
"units": [
|
||||
["p1",1,"op","chess_char_6_03_b",2,"uniequip_002_acfend",4974.95,1,8,0,0,0,0,0,0],
|
||||
["p1",2,"op","chess_char_6_02_b",2,"uniequip_002_acpion",1904,1,6,0,0,0,0,0,0],
|
||||
["p1",3,"op","chess_char_6_07_b",0,"uniequip_002_sharp2",2494,1,9,0,0,0,0,0,0],
|
||||
["p1",4,"op","chess_char_6_10_b",0,"uniequip_002_sharp2",2494,1,11,24,119442,0,0,173,3],
|
||||
["p1",5,"op","chess_char_6_11_b",2,"uniequip_002_acpion",1624,1,13,38,184185,11513,0,52,20],
|
||||
["p1",6,"op","chess_char_6_06_b",2,"uniequip_002_acguad",3396,1,14,19,66075,855,0,156,4],
|
||||
["p1",7,"op","chess_char_5_22_b",2,"uniequip_002_acnipe",1587,1,4,0,0,0,0,0,0],
|
||||
["p1",8,"op","chess_char_6_01_b",2,"uniequip_002_acnipe",1643,1,5,0,0,0,0,0,0],
|
||||
["p1",9,"op","chess_char_6_04_b",2,"uniequip_002_acsupo",83.4,1,15,11,95944,2382,0,135,2],
|
||||
["p1",10,"op","chess_char_6_05_b",2,"uniequip_002_acnipe",1643,1,7,19,77978,0,0,93,2],
|
||||
["p1",11,"op","chess_char_6_08_b",2,"uniequip_002_accast",1819,1,10,47,380809,0,0,82,2],
|
||||
["p1",12,"op","chess_char_6_09_b",2,"uniequip_002_acmedc",1324,1,12,0,0,17709,29522,83,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_1121_lifbos_2": [1,1,0,0,24000],
|
||||
"enemy_1148_dssbr": [1,1,0,0,2688],
|
||||
"enemy_1148_dssbr_2": [1,1,0,0,3456],
|
||||
"enemy_1158_divman": [1,1,0,195,3168],
|
||||
"enemy_1160_hvyslr": [1,1,0,0,9600],
|
||||
"enemy_1160_hvyslr_2": [1,1,0,0,13440],
|
||||
"enemy_1161_tidmag": [1,1,0,1363,5760],
|
||||
"enemy_1161_tidmag_2": [1,1,0,824,7680],
|
||||
"enemy_1249_lysdb_2": [60,60,0,1374,345600],
|
||||
"enemy_1251_lysyta": [51,51,0,0,134640],
|
||||
"enemy_1329_cbshld": [39,39,0,3643,374400]
|
||||
},
|
||||
"hooks": [1,173,5986,867,867,1620,1744,149,158,158,35,34,0,31554,3298,63,158,0,1,158,2,1,21],
|
||||
"events": {"atk":965,"bounty":4,"deploy":12,"die":158,"dmg":1722,"fx":309,"heal":116,"layer":2,"skill":69,"spawn":173,"status":425},
|
||||
"fx": {"aoe":2,"burst":1,"dodge":27,"dp":58,"garrison":2,"shieldBreak":60,"slash":146,"sp":13},
|
||||
"snaps": "268546d4 ec010f3d d556f759 749d85b5 fd1848fa 9a69b0ed 35a96974 ceeb72dd adfeed08 f497b058 ffade80c ec17b5c7 49c46293 8eebc960 34dab525 36f0055b b3c7d1bd 036e5819 41df2c8f 33def1f4"
|
||||
},
|
||||
"standin-10": {
|
||||
"about": "elite band 7 as stand-ins: chess_char_6_17_b→char_608_acpion chess_char_6_13_b→char_615_acspec chess_char_6_19_b→char_615_acspec chess_char_6_12_b→char_612_accast chess_char_6_15_b→char_614_acsupo chess_char_6_18_b→char_612_accast chess_char_6_20_b→char_613_acmedc",
|
||||
"fast": false,
|
||||
"kind": "normal",
|
||||
"stageId": "act1autochess_m07",
|
||||
"modeId": "mode_single_hard",
|
||||
"round": 13,
|
||||
"seed": 3949114127,
|
||||
"waveId": "act1autochess_h06",
|
||||
"time": 216.2,
|
||||
"ticks": 6486,
|
||||
"reason": "cleared",
|
||||
"killed": 25,
|
||||
"total": 131,
|
||||
"unspawned": 0,
|
||||
"bossHpLeft": null,
|
||||
"rngDraws": 245,
|
||||
"errors": {"count":0,"kinds":[]},
|
||||
"players": {
|
||||
"p1": {"killed":25,"total":131,"perfect":false,"leaks":106,"leaksUncounted":0,"coins":0,"dmg":508719,"bossDmg":0,"heal":10260,"deaths":25,"layerGains":{"raidShip":6}}
|
||||
},
|
||||
"units": [
|
||||
["p1",1,"op","chess_char_6_17_b",2,"uniequip_002_acpion",0,0,164,8,199340,14855,0,64,9],
|
||||
["p1",2,"op","chess_char_6_13_b",2,"uniequip_002_acspec",0,0,163,6,43893,11872,0,99,0],
|
||||
["p1",3,"op","chess_char_6_19_b",2,"uniequip_002_acspec",1484,1,165,3,65411,16324,0,126,0],
|
||||
["p1",4,"op","chess_char_6_12_b",2,"uniequip_002_accast",2265.49,1,4,4,78264,281,0,32,2],
|
||||
["p1",5,"op","chess_char_6_15_b",2,"uniequip_002_acsupo",0,0,147,2,52466,6493,0,38,3],
|
||||
["p1",6,"op","chess_char_6_18_b",2,"uniequip_002_accast",1819,1,8,2,69344,618,0,44,2],
|
||||
["p1",7,"op","chess_char_6_20_b",2,"uniequip_002_acmedc",1464,1,9,0,0,3373,10260,17,1],
|
||||
["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null],
|
||||
["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null]
|
||||
],
|
||||
"enemies": {
|
||||
"enemy_1006_shield_3": [21,1,20,14948,163592],
|
||||
"enemy_1043_zomsbr": [66,20,46,789,124139],
|
||||
"enemy_1162_magmot": [1,0,1,4063,11534],
|
||||
"enemy_1165_duhond": [1,1,0,139,4645],
|
||||
"enemy_1165_duhond_2": [1,0,1,0,0],
|
||||
"enemy_1166_dusbr": [1,0,1,0,0],
|
||||
"enemy_1166_dusbr_2": [1,0,1,0,0],
|
||||
"enemy_1168_dumage": [1,1,0,1476,7465],
|
||||
"enemy_1168_dumage_2": [1,0,1,3178,0],
|
||||
"enemy_1169_duphlx": [1,0,1,0,0],
|
||||
"enemy_1402_tgshd_2": [36,2,34,29224,197345]
|
||||
},
|
||||
"hooks": [1,165,6486,549,549,865,877,6809,50,159,17,36,0,8486,1125,49,131,106,1,50,1,1,0],
|
||||
"events": {"atk":554,"deploy":32,"die":53,"dmg":802,"fx":530,"heal":21,"layer":1,"leak":106,"skill":34,"spawn":140,"status":378},
|
||||
"fx": {"aoe":4,"blink":3,"burst":1,"displace":17,"dodge":3,"dp":17,"expose":414,"garrison":1,"garrisonGrant":3,"slash":67},
|
||||
"snaps": "80709a69 1b180600 2040e410 16970933 0b0b610d 4e3060e6 d2f5fa1d d015c650 26f1c266 9350b3c3 ad710861 7c181d0b d41c00a0 64db1e01 858cfe5f 40faa038 5cf23687 498f5f47 50bc9ad2 848c94df 4806ebb3 9bfdaf83"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
// test/lobby-ownership.test.js — room.ownership end to end over WebSocket (0.2.0 补位, 干员持有): lenient validation
|
||||
// (only a malformed list is refused; non-droppable ids are dropped), storage on the session (follows the player into
|
||||
// rooms, survives a resume) and on the seat, seats[].notOwned handed to the Match when it starts (bots: none), never to a
|
||||
// running match (ROOM_STARTED: stored for the next one — the setting is out of match), a spectator's kept on its session,
|
||||
// and the real match exposing it as m.private.standIns.
|
||||
import { describe, test, before, after, afterEach } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { startServer } from '../server/index.js';
|
||||
import { StubMatch } from '../server/match/StubMatch.js';
|
||||
import { TestClient } from './helpers/wsClient.js';
|
||||
import { ERR, PHASE } from '../shared/constants.js';
|
||||
|
||||
const SILVER = 'chess_char_4_22_a'; // 银灰 (NORMAL)
|
||||
const SARIA = 'chess_char_5_11_a'; // 塞雷娅 (NORMAL)
|
||||
const CATHY = 'chess_char_4_11_a'; // 凯瑟琳 (PRESET)
|
||||
|
||||
class RecordingStub extends StubMatch {
|
||||
static instances = [];
|
||||
constructor(opts) { super(opts); this.opts = opts; RecordingStub.instances.push(this); }
|
||||
}
|
||||
|
||||
function clientPool(getUrl) {
|
||||
const open = new Set();
|
||||
return {
|
||||
async player(name, token) {
|
||||
const c = await TestClient.connect(getUrl());
|
||||
open.add(c);
|
||||
const w = await c.hello(name, token);
|
||||
c.id = w.playerId;
|
||||
c.token = w.token;
|
||||
return c;
|
||||
},
|
||||
async closeAll() {
|
||||
await Promise.all([...open].map((c) => c.terminate().catch(() => {})));
|
||||
open.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
const quietLog = () => {
|
||||
const errors = [];
|
||||
return { errors, log: { info() {}, warn() {}, debug() {}, error: (...a) => errors.push(a.map(String).join(' ')) } };
|
||||
};
|
||||
const ok = async (c, msg) => { const r = await c.request(msg); assert.equal(r.t, 'ok', `${msg.t}: ${JSON.stringify(r)}`); return r; };
|
||||
const err = async (c, msg, code) => { const r = await c.request(msg); assert.equal(r.t, 'error', JSON.stringify(r)); assert.equal(r.code, code, JSON.stringify(r)); return r; };
|
||||
async function createRoom(c, mode = 'coop', difficulty = 'NORMAL') {
|
||||
await ok(c, { t: 'room.create', mode, difficulty });
|
||||
return c.waitFor('room.state', (s) => s.hostId === c.id && s.mode === mode);
|
||||
}
|
||||
|
||||
describe('room.ownership (lobby, stub match)', () => {
|
||||
let srv;
|
||||
let pool;
|
||||
const cap = quietLog();
|
||||
before(async () => {
|
||||
srv = await startServer({ port: 0, host: '127.0.0.1', log: cap.log, MatchClass: RecordingStub, heavyBurst: 12, heavyPerSec: 4 });
|
||||
pool = clientPool(() => `ws://127.0.0.1:${srv.port}/ws`);
|
||||
});
|
||||
afterEach(async () => { RecordingStub.instances = []; await pool.closeAll(); });
|
||||
after(async () => { await srv?.close(); });
|
||||
|
||||
test('lenient: a malformed list is BAD_MSG (nothing stored); other ids are dropped; stored per session and seat; bots none', async () => {
|
||||
const host = await pool.player('Host');
|
||||
await err(host, { t: 'room.ownership', notOwned: { [SILVER]: true } }, ERR.BAD_MSG);
|
||||
await err(host, { t: 'room.ownership', notOwned: ['bad id'] }, ERR.BAD_MSG);
|
||||
// outside a room: accepted (PRESET / elite / unknown ids dropped), kept on the session
|
||||
await ok(host, { t: 'room.ownership', notOwned: [SARIA, CATHY, 'chess_char_9_99_a', `${SILVER.slice(0, -1)}b`, SILVER, SILVER] });
|
||||
const st = await createRoom(host);
|
||||
const guest = await pool.player('Guest');
|
||||
await ok(guest, { t: 'room.join', code: st.code });
|
||||
await guest.waitFor('room.state', (s) => s.seats.some((x) => x && x.playerId === guest.id));
|
||||
await ok(guest, { t: 'room.ownership', notOwned: [SARIA] });
|
||||
await ok(guest, { t: 'room.ownership', notOwned: [SILVER] }); // re-sent: the latest wins
|
||||
await ok(host, { t: 'room.addBot' });
|
||||
// the guest drops and resumes: the session keeps its list
|
||||
await guest.terminate();
|
||||
const back = await TestClient.connect(`ws://127.0.0.1:${srv.port}/ws`);
|
||||
await back.hello('Guest', guest.token);
|
||||
await ok(back, { t: 'room.ready', ready: true });
|
||||
await ok(host, { t: 'room.start' });
|
||||
await host.waitFor('m.public', (p) => p.phase === 'INFO_CHECK');
|
||||
const seats = RecordingStub.instances.at(-1).opts.seats;
|
||||
const by = (id) => seats.find((s) => s.playerId === id);
|
||||
assert.deepEqual(by(host.id).notOwned, [SILVER, SARIA].sort());
|
||||
assert.ok(Object.isFrozen(by(host.id).notOwned));
|
||||
assert.deepEqual(by(guest.id).notOwned, [SILVER]);
|
||||
assert.equal(seats.find((s) => s.isBot).notOwned, null, 'bots own every operator');
|
||||
// during the match: refused for this match (ROOM_STARTED) but stored for the next one
|
||||
const r = await err(host, { t: 'room.ownership', notOwned: [] }, ERR.ROOM_STARTED);
|
||||
assert.match(r.detail || '', /next match/);
|
||||
await back.terminate();
|
||||
assert.deepEqual(cap.errors, []);
|
||||
});
|
||||
|
||||
test('a spectator\'s list stays on its session (never on a seat, never handed to the match)', async () => {
|
||||
const host = await pool.player('Host2');
|
||||
const st = await createRoom(host);
|
||||
const spec = await pool.player('Watcher');
|
||||
await ok(spec, { t: 'room.spectate', code: st.code });
|
||||
await spec.waitFor('room.state', (s) => (s.spectators || []).some((x) => x.playerId === spec.id));
|
||||
await ok(spec, { t: 'room.ownership', notOwned: [SILVER] });
|
||||
await ok(host, { t: 'room.start' });
|
||||
await host.waitFor('m.public', (p) => p.phase === 'INFO_CHECK');
|
||||
const seats = RecordingStub.instances.at(-1).opts.seats;
|
||||
assert.ok(!seats.some((s) => s.playerId === spec.id));
|
||||
assert.equal(seats[0].notOwned, null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('room.ownership (lobby + real match)', () => {
|
||||
let srv;
|
||||
let pool;
|
||||
const cap = quietLog();
|
||||
before(async () => {
|
||||
srv = await startServer({ port: 0, host: '127.0.0.1', log: cap.log, seedFn: () => 4242 });
|
||||
pool = clientPool(() => `ws://127.0.0.1:${srv.port}/ws`);
|
||||
});
|
||||
afterEach(async () => { await pool.closeAll(); });
|
||||
after(async () => { await srv?.close(); });
|
||||
|
||||
test('the list the seat had at the start is the match\'s m.private.standIns; a change during the briefing waits for the next match', async () => {
|
||||
const a = await pool.player('Solo');
|
||||
await ok(a, { t: 'room.ownership', notOwned: [SILVER] });
|
||||
await createRoom(a, 'solo');
|
||||
await ok(a, { t: 'room.start' });
|
||||
await a.waitFor('m.public', (p) => p.phase === PHASE.INFO_CHECK, 5000);
|
||||
const priv = await a.waitFor('m.private', (p) => Array.isArray(p.standIns), 3000);
|
||||
assert.deepEqual(priv.standIns, [SILVER]);
|
||||
// stored for the next match only: this match keeps [SILVER] (PlayerState.standIns is fixed; test/match/standin-ownership)
|
||||
await err(a, { t: 'room.ownership', notOwned: [SARIA] }, ERR.ROOM_STARTED);
|
||||
await ok(a, { t: 'g.infoReady' });
|
||||
await a.waitFor('m.public', (p) => p.phase === PHASE.BAND_DRAFT, 3000);
|
||||
assert.deepEqual(cap.errors, []);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,230 @@
|
||||
// test/match/standin-ownership.test.js — 0.2.0 补位 end to end on the server (the approved plan, owner's decision
|
||||
// 2026-10-05): the not-owned list (room.ownership → seats[].notOwned) is checked leniently, fixed for the match and
|
||||
// applied to that player's own pieces only — the chess keeps its identity (name, bonds, price, merge) and its battle
|
||||
// unit is the official stand-in (PlayerBattleInput `standIn: true`: the stand-in's body, skill, talents, module, range,
|
||||
// position), in the player's own field, the 联防 field and the boss fields alike, client-run and server-run identical.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { C2S, validateC2S, checkNotOwned, OWNERSHIP_LIMITS, checkLoadout } from '../../shared/protocol.js';
|
||||
import { isDroppableChess, standInRecord } from '../../shared/standIn.js';
|
||||
import { buildBattleSpec, createBattleFromSpec } from '../../server/sim/spec.js';
|
||||
import { DataSource, hasGeneratedData } from '../../server/sim/simdata.js';
|
||||
import { uniteBattleOpts } from '../../server/match/unite.js';
|
||||
import { placeClass } from '../../server/match/board.js';
|
||||
import { PHASE, GEO } from '../../shared/constants.js';
|
||||
import { DATA, makeMatch, give, legalTileFor } from './harness.js';
|
||||
|
||||
const REAL = { skip: !hasGeneratedData() };
|
||||
const chess = (id) => (Object.hasOwn(DATA.chess, id) ? DATA.chess[id] : null);
|
||||
const wire = (msg) => JSON.parse(JSON.stringify(msg));
|
||||
|
||||
const SILVER = 'chess_char_4_22_a'; // 银灰 (NORMAL) → Sharp S2, melee
|
||||
const MLYSS = 'chess_char_6_11_a'; // 缪尔赛思 (NORMAL, RANGED, summons 流形) → 郁金香 S3 + SOL-X, melee
|
||||
const SARIA = 'chess_char_5_11_a'; // 塞雷娅 (NORMAL, MELEE) → Touch S3 + PHY-X, ranged
|
||||
const CATHY = 'chess_char_4_11_a'; // 凯瑟琳 (PRESET): never droppable
|
||||
const INSIDE = 'chess_char_1_01_a'; // 隐现 (PRESET) with a loadout choice
|
||||
|
||||
test('droppable chess = the 55 NORMAL base chess; checkNotOwned keeps those, drops the rest; the wire validator is structural', () => {
|
||||
const all = Object.values(DATA.chess);
|
||||
const drop = all.filter(isDroppableChess);
|
||||
assert.equal(drop.length, 55);
|
||||
assert.ok(drop.every((c) => c.chessType === 'NORMAL' && !c.isGolden && c.backup.charId !== c.charId));
|
||||
assert.equal(all.filter((c) => c.chessType === 'PRESET' && isDroppableChess(c)).length, 0, 'PRESET always fields its own operator');
|
||||
assert.equal(drop.filter((c) => c.visible).length, 53, '53 in this season\'s shop (2 retired forms are hidden)');
|
||||
const res = checkNotOwned([SARIA, SILVER, SILVER, CATHY, chess(SILVER).goldenId, 'chess_char_9_99_a', 'chess_diy_5_1_a', MLYSS], chess);
|
||||
assert.deepEqual(res, { ok: true, notOwned: [SILVER, SARIA, MLYSS].sort(), dropped: 5 });
|
||||
assert.deepEqual(checkNotOwned([], chess), { ok: true, notOwned: [], dropped: 0 });
|
||||
for (const bad of [null, {}, 'x', [1], ['a b'], new Array(OWNERSHIP_LIMITS.notOwned + 1).fill(SILVER)]) {
|
||||
assert.equal(checkNotOwned(bad, chess).error, 'BAD_MSG', JSON.stringify(bad)?.slice(0, 40));
|
||||
}
|
||||
assert.ok(C2S['room.ownership']);
|
||||
assert.equal(validateC2S({ t: 'room.ownership', notOwned: [SILVER, 'whatever_id'] }), null);
|
||||
assert.match(validateC2S({ t: 'room.ownership', notOwned: { a: 1 } }), /bad field notOwned/);
|
||||
assert.match(validateC2S({ t: 'room.ownership' }), /bad field notOwned/);
|
||||
});
|
||||
|
||||
/** A co-op match: p_0 does not own 银灰 / 缪尔赛思 / 塞雷娅 (+ junk), p_1 owns everything, the bot never drops. */
|
||||
function ownershipMatch(extra = {}) {
|
||||
const loadout = checkLoadout({ [INSIDE]: { skill: 0 } }, chess).loadout;
|
||||
const seats = [
|
||||
{ seat: 0, playerId: 'p_0', name: 'P0', isBot: false, connected: true, loadout, notOwned: [SILVER, MLYSS, SARIA, CATHY, 'junk_id'] },
|
||||
{ seat: 1, playerId: 'p_1', name: 'P1', isBot: false, connected: true },
|
||||
{ seat: 2, playerId: 'ai_0', name: 'AI0', isBot: true, connected: true, notOwned: [SILVER] },
|
||||
];
|
||||
return makeMatch({ mode: 'coop', seats, seed: 11, ...extra });
|
||||
}
|
||||
function clearBoard(ps) {
|
||||
for (const p of [...ps.board.values()]) ps.returnCopies(p);
|
||||
ps.board.clear();
|
||||
for (let i = 0; i < ps.hand.length; i++) if (ps.hand[i]) { ps.returnCopies(ps.hand[i]); ps.hand[i] = null; }
|
||||
ps.recompute();
|
||||
}
|
||||
|
||||
test('seats[].notOwned → PlayerState.standIns (re-checked, frozen; bots own everything); m.private.standIns', REAL, () => {
|
||||
const h = ownershipMatch().start();
|
||||
const p0 = h.ps('p_0');
|
||||
assert.deepEqual([...p0.standIns], [SILVER, SARIA, MLYSS].sort());
|
||||
assert.ok(Object.isFrozen(p0.standIns));
|
||||
assert.deepEqual([...h.ps('p_1').standIns], []);
|
||||
assert.deepEqual([...h.ps('ai_0').standIns], [], 'bots own every operator');
|
||||
h.m.flush(true);
|
||||
assert.deepEqual(h.lastTo('p_0', 'm.private').standIns, [SILVER, SARIA, MLYSS].sort());
|
||||
assert.deepEqual(h.lastTo('p_1', 'm.private').standIns, []);
|
||||
// a malformed list (stale client) changes nothing; a later call cannot happen through the protocol during a match
|
||||
assert.equal(p0.setNotOwned({ bad: true }), false);
|
||||
assert.equal(p0.standIns.length, 3);
|
||||
h.m.dispose();
|
||||
});
|
||||
|
||||
test('a not-owned chess keeps its identity and fights as the stand-in: backup selection over the loadout, the stand-in\'s position, no summons', REAL, () => {
|
||||
const h = ownershipMatch().start();
|
||||
const m = h.m;
|
||||
h.toPrep(1);
|
||||
const p0 = h.ps('p_0');
|
||||
const p1 = h.ps('p_1');
|
||||
for (const [id, body, skill] of [[SILVER, 'char_609_acguad', 1], [chess(SILVER).goldenId, 'char_609_acguad', 1], [MLYSS, 'char_608_acpion', 2]]) {
|
||||
const rec = chess(id);
|
||||
assert.ok(p0.fieldsStandIn(rec) && p0.fieldsStandIn(id), id);
|
||||
assert.ok(!p1.fieldsStandIn(rec), 'only the player who dropped it');
|
||||
const f = p0.fieldRecord(rec);
|
||||
assert.equal(f.charId, body);
|
||||
assert.equal(f.standInFor, rec.charId);
|
||||
assert.deepEqual([f.chessId, f.tier, f.price, f.bonds], [rec.chessId, rec.tier, rec.price, rec.bonds], 'identity kept');
|
||||
assert.equal(p0.loadoutFor(rec).skillIndex, skill, 'the backup skill');
|
||||
assert.equal(p1.fieldRecord(rec), rec);
|
||||
}
|
||||
// the elite's module is the backup's (郁金香 SOL-X on 缪尔赛思's elite), never the player's loadout
|
||||
assert.equal(p0.loadoutFor(chess(chess(MLYSS).goldenId)).moduleId, chess(chess(MLYSS).goldenId).backup.uniEquipId);
|
||||
assert.equal(p0.loadoutFor(chess(INSIDE)).skillIndex, 0, 'an owned chess keeps its loadout');
|
||||
// placement class = the deployed body's position (缪尔赛思 RANGED → 郁金香 melee; 塞雷娅 MELEE → Touch ranged)
|
||||
assert.equal(placeClass(p1, chess(MLYSS)), 'ranged');
|
||||
assert.equal(placeClass(p0, chess(MLYSS)), 'melee');
|
||||
assert.equal(placeClass(p1, chess(SARIA)), 'melee');
|
||||
assert.equal(placeClass(p0, chess(SARIA)), 'ranged');
|
||||
// summons: an owned 缪尔赛思 on the board sends her 流形 to the hand; a stand-in makes none (none of the 17 has one)
|
||||
clearBoard(p0);
|
||||
clearBoard(p1);
|
||||
const t0 = legalTileFor(m, p0, MLYSS);
|
||||
const t1 = legalTileFor(m, p1, MLYSS);
|
||||
give(m, p0, MLYSS, 'board', t0);
|
||||
give(m, p1, MLYSS, 'board', t1);
|
||||
const tokens = (ps) => [...ps.hand, ...ps.temp].filter((p) => p && p.kind === 'token').map((p) => p.id);
|
||||
assert.deepEqual(tokens(p1), ['token_10030_mlyss_wtrman']);
|
||||
assert.deepEqual(tokens(p0), []);
|
||||
// identity rules still read the chess: sell price, bonds
|
||||
assert.equal(p0.gd.sellPrice(MLYSS), chess(MLYSS).sellPrice);
|
||||
for (const b of chess(MLYSS).bonds) assert.ok(p0.bonds[b] && p0.bonds[b].count >= 1, `bond ${b} counts the stand-in`);
|
||||
h.invariants();
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
/** The browser's DataSource (battle/runner.js loadBrowserSim): own JSON copies of the data files, backups included. */
|
||||
function browserSource() {
|
||||
const copy = (v) => JSON.parse(JSON.stringify(v));
|
||||
return new DataSource({ chess: copy(DATA.chess), enemies: copy(DATA.enemies), tokens: copy(DATA.tokens), stages: copy(DATA.stages), waves: copy(DATA.waves), backups: copy(DATA.backups) }, null);
|
||||
}
|
||||
|
||||
test('battleInput: the player\'s not-owned chess carries standIn: true (no loadout fields); client-run and server-run battles are identical', REAL, () => {
|
||||
const h = ownershipMatch().start();
|
||||
const m = h.m;
|
||||
h.toPrep(1);
|
||||
const p0 = h.ps('p_0');
|
||||
clearBoard(p0);
|
||||
const sg = chess(SILVER).goldenId;
|
||||
const a = give(m, p0, sg, 'board', legalTileFor(m, p0, sg));
|
||||
const b = give(m, p0, INSIDE, 'board', legalTileFor(m, p0, INSIDE));
|
||||
const c = give(m, p0, SARIA, 'board', legalTileFor(m, p0, SARIA));
|
||||
const input = p0.battleInput();
|
||||
const by = new Map(input.units.map((u) => [u.uid, u]));
|
||||
assert.equal(by.get(a.uid).standIn, true);
|
||||
assert.ok(!('skillIndex' in by.get(a.uid)) && !('moduleId' in by.get(a.uid)), 'a stand-in carries no loadout fields');
|
||||
assert.equal(by.get(c.uid).standIn, true);
|
||||
assert.ok(!('standIn' in by.get(b.uid)));
|
||||
assert.equal(by.get(b.uid).skillIndex, 0, 'the owned chess keeps its loadout');
|
||||
// the production spec path keeps the mark; both data sources field the same stand-in and fight the same battle
|
||||
const wave = m.wave;
|
||||
const spec = wire(buildBattleSpec({
|
||||
battleId: 't.1', fieldId: 'n:p_0', kind: 'normal', seed: 77, modeId: m.modeId, round: 1, stageId: m.stageId,
|
||||
rect: { ...GEO.NORMAL_RECT }, timeLimit: 40, players: [input], spawns: wave.spawns.map((s) => ({ ...s, ownerPlayerId: 'p_0' })),
|
||||
routes: wave.routes, flags: { layerGainsEnabled: true, ...m.gd.dp }, enemyOverrides: wave.overrides ?? {}, content: 'full',
|
||||
}));
|
||||
assert.equal(spec.players[0].units.find((u) => u.uid === a.uid).standIn, true);
|
||||
const run = (ds) => {
|
||||
const bt = createBattleFromSpec(wire(spec), ds, { quiet: true, recordEvents: false });
|
||||
const u = bt.allyUnits.find((x) => x.uid === a.uid);
|
||||
const def = { charId: u.def.charId, standInFor: u.def.standInFor, skill: u.def.skill?.id, bonds: u.def.bonds, name: u.name };
|
||||
let n = 0;
|
||||
while (!bt.finished && n++ < 30 * 45) bt.step();
|
||||
const res = bt.result();
|
||||
return { def, time: res.time, killed: res.killed, units: bt.allyUnits.map((x) => [x.uid, x.defId, Math.round(x.hp * 100), x.stats?.dmg | 0, x.skill?.activations ?? null]), rng: bt.rng.state() };
|
||||
};
|
||||
const server = run(m.ds);
|
||||
const client = run(browserSource());
|
||||
assert.equal(server.def.charId, 'char_609_acguad');
|
||||
assert.equal(server.def.standInFor, chess(sg).charId);
|
||||
assert.equal(server.def.skill, standInRecord(chess(sg), DATA.backups).skill.skillId);
|
||||
assert.deepEqual(server.def.bonds, chess(sg).bonds);
|
||||
assert.deepEqual(client, server, 'the browser\'s battle equals the server\'s');
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('联防 and the Final Assault field carry each player\'s own marks (p_0 stand-in, p_1 the real operator)', REAL, () => {
|
||||
const h = ownershipMatch({ fake: true, clientCombat: true, clients: false }).start();
|
||||
const m = h.m;
|
||||
// 联防: the helpers' inputs (unite.js uniteBattleOpts → PlayerState.battleInput with carry / reached)
|
||||
h.toPrep(1);
|
||||
const p0 = h.ps('p_0');
|
||||
const p1 = h.ps('p_1');
|
||||
clearBoard(p0);
|
||||
clearBoard(p1);
|
||||
const a0 = give(m, p0, SILVER, 'board', legalTileFor(m, p0, SILVER));
|
||||
const a1 = give(m, p1, SILVER, 'board', legalTileFor(m, p1, SILVER));
|
||||
const { players } = uniteBattleOpts(m, { helpers: [p0, p1], leaked: [] }, 60);
|
||||
const u0 = players.find((p) => p.playerId === 'p_0').units.find((u) => u.uid === a0.uid);
|
||||
const u1 = players.find((p) => p.playerId === 'p_1').units.find((u) => u.uid === a1.uid);
|
||||
assert.equal(u0.standIn, true);
|
||||
assert.ok(!('standIn' in u1) && Number.isInteger(u1.skillIndex));
|
||||
m.dispose();
|
||||
|
||||
// the boss field: jump to the Final Assault prep, deploy, and read the field's BattleSpec
|
||||
const hb = ownershipMatch({ fake: true, clientCombat: true, clients: false });
|
||||
const mb = hb.m;
|
||||
const orig = mb.startRound.bind(mb);
|
||||
let jumped = false;
|
||||
mb.startRound = (r) => { if (!jumped) { jumped = true; return orig(mb.gd.bossRound); } return orig(r); };
|
||||
hb.start();
|
||||
hb.toPrep(mb.gd.bossRound);
|
||||
const b0 = hb.ps('p_0');
|
||||
const b1 = hb.ps('p_1');
|
||||
clearBoard(b0);
|
||||
clearBoard(b1);
|
||||
const s0 = give(mb, b0, SARIA, 'board', legalTileFor(mb, b0, SARIA));
|
||||
const s1 = give(mb, b1, SARIA, 'board', legalTileFor(mb, b1, SARIA));
|
||||
hb.drive(() => mb.phase === PHASE.FINAL_ASSAULT);
|
||||
assert.equal(mb.phase, PHASE.FINAL_ASSAULT);
|
||||
const units = mb.fields.flatMap((f) => (f.spec ? f.spec.players : [])).flatMap((p) => p.units.map((u) => ({ ...u, owner: p.playerId })));
|
||||
const f0 = units.find((u) => u.uid === s0.uid);
|
||||
const f1 = units.find((u) => u.uid === s1.uid);
|
||||
assert.ok(f0 && f1, 'both deployed on the boss field');
|
||||
assert.equal(f0.standIn, true);
|
||||
assert.ok(!('standIn' in f1));
|
||||
mb.dispose();
|
||||
});
|
||||
|
||||
test('prep scouting (m.field): a teammate\'s stand-in shows the stand-in\'s art on the board, the original in the hand (standInFor on both)', REAL, () => {
|
||||
const h = ownershipMatch().start();
|
||||
const m = h.m;
|
||||
h.toPrep(1);
|
||||
const p0 = h.ps('p_0');
|
||||
clearBoard(p0);
|
||||
const onBoard = give(m, p0, SILVER, 'board', legalTileFor(m, p0, SILVER));
|
||||
const held = give(m, p0, MLYSS, 'hand');
|
||||
assert.deepEqual(m.handle('p_1', { t: 'g.watch', fieldId: 'n:p_0' }), { ok: true });
|
||||
const meta = wire(h.lastTo('p_1', 'm.field'));
|
||||
const by = new Map(meta.units.map((u) => [u.uid, u]));
|
||||
const bu = by.get(onBoard.uid);
|
||||
assert.deepEqual([bu.defId, bu.name, bu.spine, bu.avatar, bu.standInFor, bu.skillIndex], [SILVER, 'Sharp', 'char_609_acguad', 'char_609_acguad', chess(SILVER).charId, 1]);
|
||||
const hu = by.get(held.uid);
|
||||
assert.deepEqual([hu.defId, hu.name, hu.spine, hu.standInFor], [MLYSS, chess(MLYSS).name, chess(MLYSS).assets.spine, chess(MLYSS).charId]);
|
||||
m.dispose();
|
||||
});
|
||||
@@ -0,0 +1,265 @@
|
||||
// 0.2.0 补位 — client side (the approved plan, owner's decision 2026-10-05): the 干员持有 tab's model (storage, toggles,
|
||||
// the roster by tier, export / import) and its server sync (room.ownership), and the match UI of a not-owned chess —
|
||||
// the shop / reward card keeps the chess (name, bonds, price) with a 「替补:X」 badge and the stand-in's skill, the own
|
||||
// pieces' tags, the board / deploy rules of the stand-in's body (position, range), and the detail card's body (portrait,
|
||||
// class, skill, talents) from the stand-in for the player's own pieces (m.private.standIns) and any unit carrying
|
||||
// UnitInfo `standInFor`.
|
||||
import { test, describe } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
globalThis.fetch = async (url) => {
|
||||
const name = String(url).split('/').pop();
|
||||
try {
|
||||
const body = readFileSync(path.join(ROOT, 'data', name), 'utf8');
|
||||
return { ok: true, status: 200, json: async () => JSON.parse(body) };
|
||||
} catch {
|
||||
return { ok: false, status: 404, json: async () => ({}) };
|
||||
}
|
||||
};
|
||||
|
||||
const M = await import('../../public/js/ui/ownershipModel.js');
|
||||
const { installOwnershipSync, SYNC_DEBOUNCE_MS } = await import('../../public/js/ui/loadoutSync.js');
|
||||
const { createStore } = await import('../../public/js/store.js');
|
||||
const G = await import('../../public/js/ui/gameLogic.js');
|
||||
const { ChessCard } = await import('../../public/js/ui/shopBar.js');
|
||||
const { ChessDetail, resolveDetail } = await import('../../public/js/ui/detailPanel.js');
|
||||
const { standInPieces } = await import('../../public/js/screens/game/standInTags.js');
|
||||
const { OwnCard } = await import('../../public/js/screens/ownership.js');
|
||||
const { data } = await import('../../public/js/data.js');
|
||||
const { DATA } = await import('../match/harness.js');
|
||||
|
||||
await data.loadAll('chess', 'bonds', 'assets', 'garrisons', 'items', 'backups');
|
||||
|
||||
const getChess = (id) => (Object.hasOwn(DATA.chess, id) ? DATA.chess[id] : null);
|
||||
const BACKUPS = DATA.backups;
|
||||
const SILVER = 'chess_char_4_22_a'; // 银灰 → Sharp (S2 亮剑)
|
||||
const MLYSS = 'chess_char_6_11_a'; // 缪尔赛思 (RANGED) → 郁金香 (MELEE)
|
||||
const SARIA = 'chess_char_5_11_a'; // 塞雷娅 (MELEE) → Touch (RANGED)
|
||||
const CATHY = 'chess_char_4_11_a'; // 凯瑟琳 (PRESET)
|
||||
|
||||
function* walk(v) {
|
||||
if (Array.isArray(v)) { for (const x of v) yield* walk(x); return; }
|
||||
if (!v || typeof v !== 'object') return;
|
||||
yield v;
|
||||
yield* walk(v.props?.children);
|
||||
}
|
||||
const hasClass = (v, c) => typeof v?.props?.class === 'string' && v.props.class.split(/\s+/).includes(c);
|
||||
const textOf = (v) => [...walk(v)].flatMap((n) => (Array.isArray(n.props?.children) ? n.props.children : [n.props?.children])).filter((x) => typeof x === 'string' || typeof x === 'number').join('');
|
||||
|
||||
describe('干员持有 model (ui/ownershipModel.js)', () => {
|
||||
test('storage: tolerant parse, clean sorted ids, round trip; hostile keys dropped', () => {
|
||||
assert.deepEqual(M.parseStoredOwnership(null), []);
|
||||
assert.deepEqual(M.parseStoredOwnership({ v: 1, notOwned: [SILVER, 'bad id', SILVER, '__proto__', 7, SARIA] }), [SILVER, SARIA].sort());
|
||||
assert.deepEqual(M.parseStoredOwnership([MLYSS]), [MLYSS], 'a bare array of an older build');
|
||||
assert.deepEqual(M.toStoredOwnership([SARIA, SILVER]), { v: M.OWNERSHIP_VERSION, notOwned: [SILVER, SARIA].sort() });
|
||||
assert.deepEqual(M.sanitizeNotOwned([SILVER, CATHY, 'chess_char_9_99_a', getChess(SILVER).goldenId], getChess), [SILVER], 'droppable chess only');
|
||||
});
|
||||
test('toggles: setOwned / isOwned return new sorted lists', () => {
|
||||
let list = [];
|
||||
list = M.setOwned(list, SARIA, false);
|
||||
list = M.setOwned(list, SILVER);
|
||||
assert.deepEqual(list, [SILVER, SARIA].sort());
|
||||
assert.equal(M.isOwned(list, SILVER), false);
|
||||
assert.equal(M.isOwned(list, MLYSS), true);
|
||||
assert.deepEqual(M.setOwned(list, SILVER, true), [SARIA]);
|
||||
assert.deepEqual(list, [SILVER, SARIA].sort(), 'input unchanged');
|
||||
});
|
||||
test('roster: the 53 NORMAL chess of the shop by tier (6 / 13 / 17 / 17); each has its stand-in summary', () => {
|
||||
const roster = M.ownershipRoster(data.list('chess'));
|
||||
assert.equal(roster.length, 53);
|
||||
assert.ok(!roster.some((c) => c.chessType !== 'NORMAL' || c.isGolden || !c.visible));
|
||||
assert.deepEqual(M.rosterByTier(roster).map((g) => [g.tier, g.list.length]), [[3, 6], [4, 13], [5, 17], [6, 17]]);
|
||||
for (const c of roster) {
|
||||
const si = M.standInSummary(c, BACKUPS);
|
||||
assert.ok(si && si.charId === c.backup.charId && si.skill.index === c.backup.skillIndex, c.chessId);
|
||||
}
|
||||
assert.equal(M.standInSummary(getChess(SILVER), BACKUPS).name, 'Sharp');
|
||||
assert.equal(M.notOwnedCount([SILVER, CATHY, 'chess_char_6_10_a'], roster), 1, 'PRESET / retired ids do not count');
|
||||
});
|
||||
test('export / import: versioned envelope, bare arrays and text; junk, newer versions and other kinds refused', () => {
|
||||
const text = M.serializeOwnership([SARIA, SILVER], { now: 0 });
|
||||
const env = JSON.parse(text);
|
||||
assert.deepEqual([env.kind, env.v, env.count, env.notOwned], [M.OWNERSHIP_EXPORT_KIND, 1, 2, [SILVER, SARIA].sort()]);
|
||||
assert.deepEqual(M.parseOwnershipImport(text), { ok: true, notOwned: [SILVER, SARIA].sort() });
|
||||
assert.deepEqual(M.parseOwnershipImport([MLYSS]), { ok: true, notOwned: [MLYSS] });
|
||||
assert.deepEqual(M.parseOwnershipImport({ v: 1, notOwned: [] }), { ok: true, notOwned: [] }, 'an empty list = everything owned');
|
||||
assert.equal(M.parseOwnershipImport('').ok, false);
|
||||
assert.equal(M.parseOwnershipImport('{nope').ok, false);
|
||||
assert.equal(M.parseOwnershipImport({ v: 9, notOwned: [] }).ok, false);
|
||||
assert.equal(M.parseOwnershipImport({ kind: 'stronghold.loadout', v: 1, entries: {} }).ok, false);
|
||||
assert.equal(M.parseOwnershipImport('x'.repeat(M.OWNERSHIP_IMPORT_MAX_BYTES + 1)).ok, false);
|
||||
});
|
||||
test('the 干员持有 card is a switch: operator → stand-in; a tap asks for the other state', () => {
|
||||
const calls = [];
|
||||
const card = OwnCard({ m: data.get('assets'), chess: getChess(SILVER), backups: BACKUPS, owned: true, onToggle: (id, owned) => calls.push([id, owned]) });
|
||||
assert.equal(card.props.role, 'switch');
|
||||
assert.equal(card.props['aria-checked'], 'true');
|
||||
assert.ok(textOf(card).includes('银灰') && textOf(card).includes('Sharp') && textOf(card).includes('S2'));
|
||||
card.props.onClick();
|
||||
assert.deepEqual(calls, [[SILVER, false]]);
|
||||
const off = OwnCard({ m: null, chess: getChess(SILVER), backups: BACKUPS, owned: false, onToggle() {} });
|
||||
assert.ok(hasClass(off, 'is-off'));
|
||||
assert.equal(off.props['aria-checked'], 'false');
|
||||
});
|
||||
});
|
||||
|
||||
// ---- sync -------------------------------------------------------------------------------------------------------------
|
||||
|
||||
function fakeNet() {
|
||||
const listeners = new Map();
|
||||
const net = {
|
||||
status: 'online', sent: [], replies: [],
|
||||
on(t, fn) { if (!listeners.has(t)) listeners.set(t, new Set()); listeners.get(t).add(fn); return () => listeners.get(t).delete(fn); },
|
||||
emit(t, msg) { for (const fn of listeners.get(t) || []) fn(msg); },
|
||||
request(t, fields) {
|
||||
net.sent.push({ t, ...fields });
|
||||
const r = net.replies.shift();
|
||||
return r && r.error ? Promise.reject(Object.assign(new Error(r.error), { code: r.error })) : Promise.resolve({ t: 'ok' });
|
||||
},
|
||||
};
|
||||
return net;
|
||||
}
|
||||
function fakeTimers() {
|
||||
let now = 0;
|
||||
let seq = 0;
|
||||
const q = new Map();
|
||||
return {
|
||||
setTimeout: (fn, ms) => { const id = ++seq; q.set(id, { at: now + ms, fn }); return id; },
|
||||
clearTimeout: (id) => q.delete(id),
|
||||
async advance(ms) {
|
||||
const end = now + ms;
|
||||
for (;;) {
|
||||
const next = [...q.entries()].filter(([, t]) => t.at <= end).sort((a, b) => a[1].at - b[1].at)[0];
|
||||
if (!next) break;
|
||||
q.delete(next[0]);
|
||||
now = next[1].at;
|
||||
next[1].fn();
|
||||
for (let i = 0; i < 5; i++) await Promise.resolve();
|
||||
}
|
||||
now = end;
|
||||
for (let i = 0; i < 5; i++) await Promise.resolve();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('ownership sync: room.ownership after welcome and after edits (debounced); in a match stored for the next one (told once)', async () => {
|
||||
const net = fakeNet();
|
||||
const T = fakeTimers();
|
||||
const told = [];
|
||||
const target = createStore({ notOwned: [SILVER], open: false, ownSync: 'idle' });
|
||||
const s = installOwnershipSync({ net, timers: T, target, notify: (t) => told.push(t) });
|
||||
net.emit('welcome', {});
|
||||
await T.advance(100);
|
||||
assert.deepEqual(net.sent, [{ t: 'room.ownership', notOwned: [SILVER] }]);
|
||||
assert.equal(target.get().ownSync, 'synced');
|
||||
target.set({ notOwned: [SILVER, SARIA].sort() });
|
||||
await T.advance(SYNC_DEBOUNCE_MS + 10);
|
||||
assert.deepEqual(net.sent[1], { t: 'room.ownership', notOwned: [SILVER, SARIA].sort() });
|
||||
// a running match: ROOM_STARTED = stored for the next match, not an error; the edit is told once
|
||||
net.replies.push({ error: 'ROOM_STARTED' });
|
||||
target.set({ notOwned: [] });
|
||||
await T.advance(SYNC_DEBOUNCE_MS + 10);
|
||||
assert.equal(target.get().ownSync, 'locked');
|
||||
assert.equal(told.length, 1);
|
||||
assert.match(told[0], /下一局生效/);
|
||||
// back in the lobby: sent again (accepted)
|
||||
net.emit('room.state', { inMatch: false });
|
||||
await T.advance(SYNC_DEBOUNCE_MS + 10);
|
||||
assert.deepEqual(net.sent.at(-1), { t: 'room.ownership', notOwned: [] });
|
||||
assert.equal(target.get().ownSync, 'synced');
|
||||
s.dispose();
|
||||
});
|
||||
|
||||
// ---- match UI -------------------------------------------------------------------------------------------------------------
|
||||
|
||||
describe('match UI of a not-owned chess', () => {
|
||||
const priv = { standIns: [SILVER, MLYSS, SARIA], loadout: {}, board: [], hand: [], temp: [] };
|
||||
test('gameLogic: own pieces by m.private.standIns (normal + elite); the composed record keeps the identity', () => {
|
||||
assert.deepEqual(G.standInIds(priv), [SILVER, MLYSS, SARIA]);
|
||||
assert.equal(G.fieldsStandIn(priv, getChess(SILVER)), true);
|
||||
assert.equal(G.fieldsStandIn(priv, getChess(getChess(SILVER).goldenId)), true, 'the elite too');
|
||||
assert.equal(G.fieldsStandIn({ standIns: [] }, getChess(SILVER)), false);
|
||||
assert.equal(G.fieldsStandIn(null, getChess(SILVER)), false);
|
||||
const si = G.standInOf(getChess(SILVER), BACKUPS);
|
||||
assert.deepEqual([si.charId, si.name, si.standInFor, si.chessId, si.price, si.bonds], ['char_609_acguad', 'Sharp', getChess(SILVER).charId, SILVER, getChess(SILVER).price, getChess(SILVER).bonds]);
|
||||
assert.equal(G.standInOf(getChess(SILVER), BACKUPS), si, 'cached');
|
||||
assert.equal(G.standInOf(getChess(CATHY), BACKUPS), null, 'PRESET');
|
||||
assert.equal(G.standInLabel(si), '替补:Sharp');
|
||||
const lo = G.standInLoadout(si, getChess, BACKUPS);
|
||||
assert.equal(lo.skill.skillId, 'skchr_acguad_2', 'its backup skill (S2), never the player\'s loadout');
|
||||
// the deployed record: the stand-in's for a dropped chess, the loadout's otherwise
|
||||
assert.equal(G.deployedRecord(getChess(SILVER), priv, getChess, BACKUPS).charId, 'char_609_acguad');
|
||||
assert.equal(G.deployedRecord(getChess(SILVER), { standIns: [] }, getChess, BACKUPS).charId, getChess(SILVER).charId);
|
||||
const eg = getChess(getChess(MLYSS).goldenId);
|
||||
assert.equal(G.deployedModuleId(eg, priv, getChess, BACKUPS), eg.backup.uniEquipId, 'the backup module (SOL-X)');
|
||||
});
|
||||
|
||||
test('placement: a dropped chess is placed by its stand-in\'s position (缪尔赛思 → 郁金香 melee, 塞雷娅 → Touch ranged)', () => {
|
||||
const stage = null; // (the deploy tiles are not needed for the placement class)
|
||||
const ctx = G.placementContext({ priv, stage, editable: true, getChess, backups: BACKUPS });
|
||||
assert.equal(G.piecePosition(ctx, { kind: 'chess', id: MLYSS }), 'MELEE');
|
||||
assert.equal(G.piecePosition(ctx, { kind: 'chess', id: SARIA }), 'RANGED');
|
||||
const own = G.placementContext({ priv: { standIns: [] }, stage, editable: true, getChess, backups: BACKUPS });
|
||||
assert.equal(G.piecePosition(own, { kind: 'chess', id: MLYSS }), 'RANGED');
|
||||
assert.equal(G.piecePosition(own, { kind: 'chess', id: SARIA }), 'MELEE');
|
||||
});
|
||||
|
||||
test('shop / reward card: the chess\'s name, price and bonds stay, the 「替补:X」 badge and the stand-in\'s skill show', () => {
|
||||
const slot = { kind: 'chess', id: SILVER, price: 3, basePrice: 3 };
|
||||
const card = ChessCard({ slot, idx: 0, priv, onBuy() {}, onDetail() {} });
|
||||
const nodes = [...walk(card)];
|
||||
const badge = nodes.find((v) => hasClass(v, 'scard__standin'));
|
||||
assert.ok(badge, 'badge');
|
||||
assert.equal(badge.props['data-standin'], 'char_609_acguad');
|
||||
assert.equal(badge.props.children, '替补:Sharp');
|
||||
assert.equal(nodes.find((v) => hasClass(v, 'scard__name')).props.children, '银灰', 'the card keeps the chess\'s name');
|
||||
const skill = nodes.find((v) => v?.type && v.props?.lo);
|
||||
assert.equal(skill.props.lo.skill.skillId, 'skchr_acguad_2');
|
||||
const owned = ChessCard({ slot, idx: 0, priv: { standIns: [] }, onBuy() {}, onDetail() {} });
|
||||
assert.equal([...walk(owned)].find((v) => hasClass(v, 'scard__standin')), undefined, 'owned: no badge');
|
||||
});
|
||||
|
||||
test('detail card: own piece / card → the stand-in\'s body under the chess\'s name; a unit with standInFor likewise', () => {
|
||||
const piece = { uid: 41, kind: 'chess', id: SILVER, golden: false, items: [] };
|
||||
const pieces = new Map([[41, { piece, area: 'hand', idx: 0 }]]);
|
||||
const r = resolveDetail({ kind: 'piece', uid: 41 }, pieces, { priv, backups: BACKUPS });
|
||||
assert.equal(r.standIn?.charId, 'char_609_acguad');
|
||||
assert.equal(resolveDetail({ kind: 'piece', uid: 41 }, pieces, { priv: { standIns: [] }, backups: BACKUPS }).standIn, null);
|
||||
assert.equal(resolveDetail({ kind: 'chess', id: SILVER }, new Map(), { priv, backups: BACKUPS }).standIn?.charId, 'char_609_acguad', 'a shop card');
|
||||
const u = resolveDetail({ kind: 'unit', unit: { id: 9, uid: 99, side: 'ally', kind: 'op', defId: SARIA, standInFor: getChess(SARIA).charId } }, new Map(), { priv: null, backups: BACKUPS });
|
||||
assert.equal(u.standIn?.charId, 'char_613_acmedc', 'a teammate\'s unit says it itself');
|
||||
const blocks = ChessDetail({ chess: r.chess, piece, editable: false, bonds: [], loadout: null, standIn: r.standIn });
|
||||
const head = blocks.find((b) => b.key === 'head');
|
||||
const tag = [...walk(head)].find((v) => hasClass(v, 'dtag-standin'));
|
||||
assert.equal(tag?.props.children, '替补:Sharp');
|
||||
assert.equal([...walk(head)].find((v) => hasClass(v, 'dhead__name')).props.children, '银灰');
|
||||
const skill = blocks.find((b) => b.key === 'skill');
|
||||
assert.ok(JSON.stringify(skill.props).includes(r.standIn.skill.name), 'the stand-in\'s skill');
|
||||
const talents = blocks.find((b) => b.key === 'talents');
|
||||
for (const t of r.standIn.talents.filter((x) => x.name && !x.hidden)) assert.ok(JSON.stringify(talents.props).includes(t.name), `talent ${t.name}`);
|
||||
assert.ok(!JSON.stringify(blocks).includes(getChess(SILVER).skill.name), 'not the replaced operator\'s skill');
|
||||
});
|
||||
|
||||
test('own prep pieces with stand-ins get a tag (hand, temp, board); others none', () => {
|
||||
const p = {
|
||||
standIns: [SILVER],
|
||||
hand: [{ uid: 1, kind: 'chess', id: SILVER }, { uid: 2, kind: 'chess', id: CATHY }, null],
|
||||
temp: [{ uid: 3, kind: 'chess', id: getChess(SILVER).goldenId }],
|
||||
board: [{ uid: 4, kind: 'chess', id: SILVER, row: 10, col: 4 }, { uid: 5, kind: 'token', id: 'x', row: 9, col: 4 }],
|
||||
};
|
||||
assert.deepEqual(standInPieces(p, getChess, BACKUPS).map((x) => [x.uid, x.area, x.label]), [[1, 'hand', '替补:Sharp'], [3, 'temp', '替补:Sharp'], [4, 'board', '替补:Sharp']]);
|
||||
assert.deepEqual(standInPieces({ ...p, standIns: [] }, getChess, BACKUPS), []);
|
||||
});
|
||||
|
||||
test('the board model of a dropped chess is the stand-in\'s (render/app.js pieceInfo reads m.private.standIns; the view is rebuilt when its model changes)', () => {
|
||||
const src = readFileSync(path.join(ROOT, 'public/js/render/app.js'), 'utf8');
|
||||
assert.match(src, /area === 'board' && chess && standInList\.includes\(chess\.baseId \|\| chess\.chessId\) \? data\.standIn\(piece\.id\) : null/);
|
||||
assert.match(src, /standInList = Array\.isArray\(src\.standIns\)/);
|
||||
assert.match(src, /const sig = `\$\{info\.kind\}\|\$\{info\.defId\}\|\$\{info\.golden \? 1 : 0\}\|\$\{info\.spine \|\| ''\}`/);
|
||||
assert.match(readFileSync(path.join(ROOT, 'public/js/render/app/info.js'), 'utf8'), /standInFor: typeof u\.standInFor === 'string'/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
// Real-server browser E2E of 0.2.0 补位 end to end (the approved plan, owner's decision 2026-10-05): the 干员持有 tab of
|
||||
// the 干员调配 overlay marks 银灰 as not owned (real clicks; persisted in localStorage; room.ownership → session → seat →
|
||||
// the next match's m.private.standIns); in the match the shop card keeps 银灰 (name, price) with 「替补:Sharp」; bought,
|
||||
// the hand piece carries the tag; deployed, the board model is Sharp's; the detail card shows Sharp's skill under
|
||||
// 银灰's name; and in the browser's own battle (client-side combat) the unit is the stand-in — the b.start spec marks it
|
||||
// `standIn: true`, the local sim fields char_609_acguad with its kit, and the battle view draws Sharp's Spine model.
|
||||
//
|
||||
// SP_E2E=1 node --test test/ui/standin.e2e.test.js
|
||||
//
|
||||
// The server is test/e2e/fastServer.mjs with its starter-kit hooks (no starter operators, 银灰 in the first shop slot,
|
||||
// 20 funds); everything else is the real match engine and the real UI driven by real mouse input.
|
||||
// Screenshots: test/e2e/out/standin-*.png.
|
||||
import { test, describe } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { existsSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { Client, ROOT, sleep, hasChrome, startRealServer, problemsOf } from '../e2e/client.mjs';
|
||||
|
||||
const ENABLED = process.env.SP_E2E === '1' && hasChrome() && existsSync(path.join(ROOT, 'public/assets'));
|
||||
const SILVER = 'chess_char_4_22_a'; // 银灰 (NORMAL) → Sharp (char_609_acguad), S2 亮剑
|
||||
const SHARP = 'char_609_acguad';
|
||||
|
||||
describe('0.2.0 补位 — a not-owned operator fights as its stand-in (real server)', { skip: !ENABLED && 'set SP_E2E=1 (Chrome + public/assets)' }, () => {
|
||||
test('干员持有: drop 银灰 → buy it (badge) → deploy (Sharp model, Sharp card) → the local battle fields Sharp', { timeout: 6 * 60 * 1000 }, async () => {
|
||||
const srv = await startRealServer({ fast: { timerScale: 0.5, combatSpeed: 2, startRound: 1, kit: 0, shop: [SILVER] } });
|
||||
const P = (await import('puppeteer-core')).default;
|
||||
const c = new Client(P, srv.base, 'standin', { prefix: 'standin' });
|
||||
try {
|
||||
await c.open();
|
||||
await c.enter('替补');
|
||||
// 1) 干员调配 → 干员持有: mark 银灰 未持有 (real clicks), synced with the server, persisted in the browser
|
||||
await c.click('.lobby-screen [data-testid="loadout-open"]');
|
||||
await c.page.waitForSelector('.lo .lo-tab[data-tab="ownership"]', { visible: true, timeout: 15000 });
|
||||
await c.click('.lo .lo-tab[data-tab="ownership"]');
|
||||
await c.page.waitForSelector(`.own-card[data-chess="${SILVER}"]`, { visible: true, timeout: 15000 });
|
||||
const cards = await c.page.evaluate(() => [...document.querySelectorAll('.own-card')].length);
|
||||
assert.equal(cards, 53, 'the 53 NORMAL chess of the shop');
|
||||
await c.click(`.own-card[data-chess="${SILVER}"]`);
|
||||
await c.page.waitForFunction((id) => document.querySelector(`.own-card[data-chess="${id}"]`)?.getAttribute('aria-checked') === 'false', { timeout: 3000 }, SILVER);
|
||||
await c.page.waitForFunction(() => /已同步/.test(document.querySelector('[data-testid="ownership-sync"]')?.textContent || ''), { timeout: 8000 });
|
||||
const card = await c.page.evaluate((id) => document.querySelector(`.own-card[data-chess="${id}"]`)?.textContent || '', SILVER);
|
||||
assert.ok(card.includes('银灰') && card.includes('Sharp') && card.includes('未持有'), card);
|
||||
const stored = await c.page.evaluate(() => JSON.parse(localStorage.getItem('sp.pref.ownership') || 'null'));
|
||||
assert.deepEqual(stored, { v: 1, notOwned: [SILVER] });
|
||||
await c.shot('ownership');
|
||||
await c.page.keyboard.press('Escape');
|
||||
await c.page.waitForFunction(() => !document.querySelector('.lo'), { timeout: 3000 });
|
||||
|
||||
// 2) a solo 标准 match: the briefing's m.private carries the list
|
||||
await c.click('.mode-card', '独立模拟');
|
||||
await c.click('.diff-card', '标准模拟');
|
||||
await c.click('.create-box button', '开始独立模拟');
|
||||
await c.waitFor((s) => !!s.room, 'solo room');
|
||||
if (!(await c.st()).phase) await c.click('.room-bar__right button', '开始模拟', { timeout: 20000 });
|
||||
await c.waitFor((s) => s.phase === 'INFO_CHECK', 'briefing', 30000);
|
||||
const standIns = await c.page.evaluate(() => globalThis.__SP__.store.get().match.private?.standIns ?? null);
|
||||
assert.deepEqual(standIns, [SILVER], 'the match received the not-owned list');
|
||||
await c.click('.brief__foot .btn--primary', '准备就绪');
|
||||
await c.waitFor((s) => s.phase === 'BAND_DRAFT', 'band draft', 30000);
|
||||
await c.click('.dband', null, { nth: 1 });
|
||||
await c.click('.draft-detail__btns .btn--primary', '确认选择');
|
||||
await c.waitFor((s) => s.phase === 'PREP' && !s.ready && s.funds >= 3, 'prep with funds', 60000);
|
||||
await sleep(1800); // camera flight
|
||||
|
||||
// 3) the shop card: 银灰 with 「替补:Sharp」; two taps buy it
|
||||
await c.page.waitForSelector('.scard .scard__standin', { visible: true, timeout: 10000 });
|
||||
const shopCard = await c.page.evaluate(() => {
|
||||
const el = document.querySelector('.scard .scard__standin')?.closest('.scard');
|
||||
return el ? { name: el.querySelector('.scard__name')?.textContent, badge: el.querySelector('.scard__standin')?.textContent, standIn: el.querySelector('.scard__standin')?.dataset.standin } : null;
|
||||
});
|
||||
assert.deepEqual(shopCard, { name: '银灰', badge: '替补:Sharp', standIn: SHARP });
|
||||
await c.shot('shop');
|
||||
await c.click('.scard', '银灰');
|
||||
await sleep(300);
|
||||
await c.click('.scard', '银灰');
|
||||
await c.waitFor((s) => s.hand > 0, 'bought', 8000);
|
||||
await sleep(900);
|
||||
const [piece] = (await c.handPieces('chess')).filter((p) => p.id === SILVER);
|
||||
assert.ok(piece, '银灰 is in the hand');
|
||||
// the hand piece keeps 银灰's model, tagged 「替补:Sharp」
|
||||
await c.page.waitForFunction((uid) => document.querySelector(`.sitag[data-uid="${uid}"]`)?.textContent === '替补:Sharp', { timeout: 5000 }, piece.uid);
|
||||
const handModel = await c.page.evaluate((uid) => globalThis.__SP_VIEW__?.raw?.debug?.views?.get(`p:${uid}`)?.info?.spine ?? null, piece.uid);
|
||||
assert.notEqual(handModel, SHARP, 'the hand shows the original operator');
|
||||
await c.shot('hand');
|
||||
|
||||
// 4) deploy it (drag → direction wheel): the board model becomes Sharp's; the card shows Sharp's body
|
||||
const tile = await c.freeTileFor(piece.uid);
|
||||
assert.ok(tile, 'a legal tile');
|
||||
await c.drag(await c.piecePoint(piece.uid), await c.tilePoint(tile.row, tile.col));
|
||||
await c.page.waitForSelector('.fwheel__dia', { timeout: 4000 });
|
||||
await c.swipe('RIGHT');
|
||||
await c.waitFor((s) => s.board > 0, 'placed', 8000);
|
||||
await c.page.waitForFunction((uid, sharp) => {
|
||||
const v = globalThis.__SP_VIEW__?.raw?.debug?.views?.get(`p:${uid}`);
|
||||
return v && v.info?.spine === sharp && v.info?.standInFor;
|
||||
}, { timeout: 8000 }, piece.uid, SHARP);
|
||||
await sleep(600);
|
||||
let detail = null;
|
||||
for (const at of [0.72, 0.4, 0.88]) {
|
||||
const p = await c.piecePoint(piece.uid, at);
|
||||
if (!p) continue;
|
||||
await c.page.mouse.click(p.x, p.y, { button: 'right' });
|
||||
detail = await c.page.waitForSelector('.dpanel .dtag-standin', { timeout: 2500 }).then(() => c.page.evaluate(() => ({
|
||||
tag: document.querySelector('.dpanel .dtag-standin')?.textContent || '',
|
||||
name: document.querySelector('.dpanel .dhead__name')?.textContent || '',
|
||||
skill: document.querySelector('.dpanel .dskill')?.dataset.skill || '',
|
||||
})), () => null);
|
||||
if (detail) break;
|
||||
}
|
||||
assert.ok(detail, 'the detail card opens for the deployed piece');
|
||||
assert.deepEqual(detail, { tag: '替补:Sharp', name: '银灰', skill: 'skchr_acguad_2' });
|
||||
await c.shot('board');
|
||||
await c.page.keyboard.press('Escape');
|
||||
|
||||
// 5) ready → the local battle fields Sharp (spec standIn: true; the sim's def; the view's Spine model)
|
||||
await c.click('.readybtn');
|
||||
await c.waitFor((s) => s.phase === 'COMBAT', 'combat', 60000);
|
||||
const got = await c.page.waitForFunction((uid, sharp) => {
|
||||
const r = globalThis.__SP_RUNNER__;
|
||||
const e = r && [...r._entries.values()].find((x) => x.own && x.battle);
|
||||
if (!e) return false;
|
||||
const u = e.battle.allyUnits.find((x) => x.uid === uid);
|
||||
if (!u) return false;
|
||||
const views = globalThis.__SP_VIEW__?.raw?.debug?.views;
|
||||
const v = views ? [...views.values()].find((x) => x.info?.uid === uid && !String(x.id).startsWith('p:')) : null;
|
||||
if (!v || !v.spineReady) return false;
|
||||
const entry = (e.spec.players || []).flatMap((p) => p.units || []).find((x) => x.uid === uid) || null;
|
||||
return {
|
||||
authoritative: e.authoritative, spec: entry && { chessId: entry.chessId, standIn: entry.standIn ?? null, skillIndex: entry.skillIndex ?? null },
|
||||
charId: u.def?.charId, standInFor: u.def?.standInFor, skill: u.skill?.id ?? null, bonds: u.def?.bonds,
|
||||
viewSpine: v.info.spine, viewStandInFor: v.info.standInFor ?? null, model: String(v._actorEntry?.skel || ''),
|
||||
};
|
||||
}, { timeout: 30000, polling: 150 }, piece.uid, SHARP).then((h) => h.jsonValue());
|
||||
assert.equal(got.authoritative, true, 'the own normal battle is simulated by this browser');
|
||||
assert.deepEqual(got.spec, { chessId: SILVER, standIn: true, skillIndex: null }, 'b.start spec marks the stand-in');
|
||||
assert.equal(got.charId, SHARP, 'the local sim fields Sharp');
|
||||
assert.equal(got.skill, 'skchr_acguad_2', 'with the chess\'s backup skill (S2 亮剑)');
|
||||
assert.equal(got.viewSpine, SHARP);
|
||||
assert.ok(got.viewStandInFor, 'the view knows it is a stand-in');
|
||||
assert.match(got.model, /char_609_acguad/, 'the battle view draws Sharp\'s Spine model');
|
||||
await sleep(800);
|
||||
await c.shot('combat');
|
||||
// the battle ends and the round settles normally (the result upload is accepted)
|
||||
await c.waitFor((s) => (s.phase === 'PREP' && s.round >= 2) || !!s.result, 'next prep', 180000);
|
||||
assert.deepEqual(problemsOf([c]), []);
|
||||
} finally {
|
||||
if (c.problems.length) console.log(c.problems.slice(0, 20).join('\n'));
|
||||
await c.close();
|
||||
await srv.stop();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user