chore: sync ammo readout with upstream dev (#552)

This commit is contained in:
IceCodeNew committed 2026-10-12 15:03:43 +00:00
commit 0295d95d08
222 files changed
+10419 -2026

No files matched your search

+85
View File
@@ -711,6 +711,91 @@ test('Touch 外勤医疗: every player of a match with the band gets 预备干
cover('band_amedic');
});
test('外勤医疗: 预备干员-医疗 and Touch keep preplaced targeting order against a flying 守墓石像', () => {
const defs = {
harold: chessRec({ id: 'harold', profession: 'TANK', skill: null, stats: { atk: 0, maxHp: 1e7, blockCnt: 0 } }),
elite1: chessRec({ id: 'elite1', golden: true, profession: 'TANK', skill: null, stats: { atk: 0, maxHp: 1e7, blockCnt: 0 } }),
elite2: chessRec({ id: 'elite2', golden: true, profession: 'TANK', skill: null, stats: { atk: 0, maxHp: 1e7, blockCnt: 0 } }),
};
for (const [eliteCount, mapCharId] of [[1, 'char_605_cmedic'], [2, 'char_613_acmedc']]) {
const h = makeBattle({
stageId: 'act2autochess_m01', bandId: 'band_amedic', autoFinish: false, timeLimit: 100,
defs: { chess: defs }, units: [
{ chessId: 'harold', row: 11, col: 3 },
{ chessId: 'elite1', row: 12, col: 8 },
...(eliteCount === 2 ? [{ chessId: 'elite2', row: 9, col: 8 }] : []),
],
});
h.step();
const medic = h.unit(mapCharId), harold = h.unit('harold');
const ops = h.allies().filter((u) => u.kind === 'op');
assert.ok(medic?.alive && harold?.alive, mapCharId);
assert.ok(ops.every((u) => medic.deploySeq > u.deploySeq), 'battleStart creates the map character after initial deployment');
assert.ok(ops.every((u) => medic.aggroSeq < u.aggroSeq), 'its targeting order precedes every initial operator');
assert.equal(medic.s.taunt, harold.s.taunt, 'the two targets have equal taunt');
const gargoyle = h.spawn('enemy_1172_dugago', { pos: [10, 3], routeIndex: 0, mods: { speedMul: 0 } });
assert.ok(gargoyle);
h.b.kill(gargoyle, null); // first knock-out changes 守墓石像 into its flying form
assert.ok(gargoyle.alive);
const attacks = [];
h.b.on('enemyAttackStart', ({ enemy, targets }) => {
if (enemy === gargoyle && gargoyle.motion === 'FLY') attacks.push({ target: targets[0], x: enemy.x, y: enemy.y });
});
assert.ok(h.runUntil(() => attacks.length > 0, 20), 'flying gargoyle starts an attack');
const { target, x, y } = attacks[0];
const reach = gargoyle.base.rangeRadius + 0.25;
for (const u of [harold, medic]) assert.ok((u.x - x) ** 2 + (u.y - y) ** 2 <= reach ** 2, `${u.defId} is in range at target lock`);
assert.equal(gargoyle.blockedBy, null);
assert.equal(target, harold, `${mapCharId}: the later ordinary operator is selected`);
h.b.addBuff(medic, { key: 'test:taunt', mods: { taunt: 1 }, persist: true });
assert.ok(h.runUntil(() => attacks.length > 1, 20), 'gargoyle starts another attack');
assert.equal(attacks[1].target, medic, 'higher taunt still takes precedence over deployment order');
checkInvariants(h.b);
}
});
test('外勤医疗: 联防右侧 Touch retains its prepared targeting order', () => {
const defs = {
harold: chessRec({ id: 'harold', profession: 'TANK', skill: null, stats: { atk: 0, maxHp: 1e7, blockCnt: 0 } }),
elite1: chessRec({ id: 'elite1', golden: true, profession: 'TANK', skill: null, stats: { atk: 0, maxHp: 1e7, blockCnt: 0 } }),
elite2: chessRec({ id: 'elite2', golden: true, profession: 'TANK', skill: null, stats: { atk: 0, maxHp: 1e7, blockCnt: 0 } }),
};
const players = [
{ playerId: 'L', seat: 0, side: 'L', colOffset: 0, bandId: 'band_amedic', bonds: {}, playerEffects: [], units: [] },
{ playerId: 'R', seat: 1, side: 'R', colOffset: 8, bandId: 'band_amedic', bonds: {}, playerEffects: [], units: [
{ uid: 1, kind: 'chess', chessId: 'harold', row: 11, col: 3 },
{ uid: 2, kind: 'chess', chessId: 'elite1', row: 12, col: 8 },
{ uid: 3, kind: 'chess', chessId: 'elite2', row: 9, col: 8 },
] },
];
const h = makeBattle({ kind: 'unite', stageId: 'act2autochess_m01', players, defs: { chess: defs }, autoFinish: false });
h.step();
const leftMedic = h.allies().find((u) => u.ownerId === 'L' && u.defId === 'char_605_cmedic');
const rightTouch = h.allies().find((u) => u.ownerId === 'R' && u.defId === 'char_613_acmedc');
const harold = h.allies().find((u) => u.ownerId === 'R' && u.defId === 'harold');
assert.ok(leftMedic && rightTouch && harold);
assert.deepEqual([leftMedic.tileR, leftMedic.tileC], [10, 2]);
assert.deepEqual([rightTouch.tileR, rightTouch.tileC], [10, 10]);
assert.ok(leftMedic.aggroSeq < rightTouch.aggroSeq && rightTouch.aggroSeq < harold.aggroSeq);
assert.ok(rightTouch.deploySeq > harold.deploySeq);
const gargoyle = h.spawn('enemy_1172_dugago', { pos: [10, 11], routeIndex: 0, mods: { speedMul: 0 } });
assert.ok(gargoyle);
h.b.kill(gargoyle, null);
const attacks = [];
h.b.on('enemyAttackStart', ({ enemy, targets }) => {
if (enemy === gargoyle && enemy.motion === 'FLY') attacks.push({ target: targets[0], x: enemy.x, y: enemy.y });
});
assert.ok(h.runUntil(() => attacks.length > 0, 20));
const { target, x, y } = attacks[0];
const reach = gargoyle.base.rangeRadius + 0.25;
for (const u of [harold, rightTouch]) assert.ok((u.x - x) ** 2 + (u.y - y) ** 2 <= reach ** 2);
assert.equal(gargoyle.blockedBy, null);
assert.equal(target, harold);
checkInvariants(h.b);
});
test('coverage: every band is exercised by a test above', () => {
cover('band_dusk'); // 夕: 画卷 grant (fixed-round test) + 墨色真颜 (battle test)
const missing = Object.keys(DATA.bands).filter((id) => !COVER.has(id)).sort();
+3 -2
View File
@@ -544,11 +544,12 @@ test('阿戈尔 5: a member devoured by several 阿戈尔 spends one slot at mos
});
test('阿戈尔 5: an operator\'s own save or revive uses no slot (斯卡蒂\'s DRE-Y, M3茧甲, 埃芒加德 [ASSUMED for the band]); the slot waits for its first unanswered knock-out', () => {
// 斯卡蒂 (elite, module DRE-Y by default) enters 联防 at half HP in front of 乌尔比安: his mark is lethal, DRE-Y saves her
// 斯卡蒂 (elite, module DRE-Y by default) starts a normal field at half HP in front of 乌尔比安: his mark is lethal,
// DRE-Y saves her. Half HP is a fixture here; a restored 联防 target no longer pays opening devour damage twice.
// (PRTS: once per deployment, full HP with max HP −60 %) — no knock-out, so no slot
const SKADI = 'chess_char_3_05_b', ULPIA = 'chess_char_5_05_a', GHOST = 'chess_char_2_07_a', HN = 'chess_char_3_09_a', DEEP = 'chess_char_1_04_a';
const h = makeBattle({
kind: 'unite', autoFinish: false, timeLimit: 60, hooks: ['damaged', 'death', 'deploy'], captureNoisy: true,
kind: 'normal', autoFinish: false, timeLimit: 60, hooks: ['damaged', 'death', 'deploy'], captureNoisy: true,
units: [
{ chessId: ULPIA, row: 10, col: 3 }, { chessId: SKADI, row: 10, col: 4, dir: 'UP', carryState: { hpPct: 0.5 } },
{ chessId: GHOST, row: 12, col: 3 }, { chessId: HN, row: 12, col: 5 }, { chessId: DEEP, row: 12, col: 7 },
+116
View File
@@ -0,0 +1,116 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, chessRec, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
test('Clementia S3 ignores allied erosion bursts but still marks enemy bursts', () => {
const h = makeBattle({ autoFinish: false, hooks: ['elementBurst'],
units: [
{ uid: 1, diy: { slot: 6, charId: 'char_4231_clemnt', skillIndex: 2 }, elite: true, row: 10, col: 3 },
{ uid: 2, chessId: 'chess_char_1_02_a', row: 10, col: 4 },
], defs: { enemies: { target: enemyRec({ key: 'target', hp: 1e7, atk: 0, speed: 0 }) } },
});
h.step();
const u = h.unit(1), ally = h.unit(2);
u.skill.gainSp(999, 'test'); assert.ok(u.skill.activate('test'));
h.b.dealDamage(null, ally, { type: 'element', element: 'erosion', amount: 2000 });
assert.equal(h.hooksOf('elementBurst').filter(c => c.target === ally).length, 1, 'a real allied burst occurred');
assert.equal(u.mem.clemntBombs.marks.size, 0, 'friendly injury must not schedule a shot');
const enemy = h.spawn('target', { pos: [10, 6] });
h.b.dealDamage(u, enemy, { type: 'element', element: 'erosion', amount: 2000 });
assert.ok(u.mem.clemntBombs.marks.has(enemy), 'enemy erosion still schedules the bombardment');
checkInvariants(h.b); assert.deepEqual(h.b.errors, []);
});
for (const skillIndex of [0, 1, 2]) test(`Horn S${skillIndex + 1} can attack an unblockable fragment on her own tile`, () => {
const h = makeBattle({ autoFinish: false, hooks: ['damaged'], captureNoisy: true,
units: [{ uid: 1, chessId: 'chess_char_5_08_a', skillIndex, row: 10, col: 3 }],
defs: { enemies: { fragment: enemyRec({ key: 'fragment', hp: 1e7, atk: 0, speed: 0 }) } },
});
h.step();
const u = h.unit(1), e = h.spawn('fragment', { pos: [10, 3] });
h.b.addBuff(e, { key: 'test:fragment', persist: true, flags: { unblockable: true } });
u.skill.gainSp(999, 'test'); h.run(4);
assert.equal(u.blocking.length, 0, 'the target cannot be blocked');
assert.ok(e.hp < e.s.maxHp, 'her extra melee range includes her own tile');
assert.ok(u.skill.activations > 0, 'a target on that tile also satisfies skill automation');
checkInvariants(h.b); assert.deepEqual(h.b.errors, []);
});
test('bounty catapult normal triple attack splashes adjacent and diagonal allies within 1.5 tiles', () => {
const rec = (id, tauntLevel = 0) => chessRec({ id, stats: { maxHp: 1e6, atk: 0, def: 0, blockCnt: 0, tauntLevel }, skill: null });
const h = makeBattle({ autoFinish: false, flags: { dpInit: 999 },
defs: { chess: { target: rec('target', 5), bystander: rec('bystander') } },
units: [
{ uid: 1, chessId: 'target', row: 10, col: 5 },
{ uid: 2, chessId: 'bystander', row: 10, col: 6 },
{ uid: 3, chessId: 'bystander', row: 11, col: 6 },
{ uid: 4, chessId: 'bystander', row: 12, col: 7 },
],
});
h.step();
const e = h.spawn('enemy_10162_mnctpt', { pos: [10, 8], mods: { speedMul: 0 } });
assert.ok(h.runUntil(() => h.unit(1).hp < h.unit(1).s.maxHp, 10), 'the primary attack reaches its target');
for (const uid of [1, 2, 3]) assert.equal(h.unit(uid).s.maxHp - h.unit(uid).hp, e.s.atk * 3, `unit ${uid}: all three stones`);
assert.equal(h.unit(4).hp, h.unit(4).s.maxHp, 'beyond the splash');
checkInvariants(h.b); assert.deepEqual(h.b.errors, []);
});
for (const skillIndex of [0, 1, 2]) test(`Horn S${skillIndex + 1} retains melee splash only for S2 and leaves adjacent blind tiles untargeted`, () => {
const h = makeBattle({ autoFinish: false, units: [{ uid: 1, chessId: 'chess_char_5_08_a', skillIndex, row: 10, col: 3 }],
defs: { enemies: { fragment: enemyRec({ key: 'fragment', hp: 1e7, atk: 0, speed: 0 }) } },
});
h.step(); const u = h.unit(1);
const own = h.spawn('fragment', { pos: [10, 3] }), near = h.spawn('fragment', { pos: [10, 4] });
for (const e of [own, near]) h.b.addBuff(e, { key: 'test:fragment', persist: true, flags: { unblockable: true } });
u.skill.gainSp(999, 'test'); h.run(4);
assert.ok(own.hp < own.s.maxHp);
assert.equal(near.hp < near.s.maxHp, skillIndex === 1, 'only S2 grants splash to melee attacks');
h.b.kill(own, null); const hp = near.hp; h.run(4);
assert.equal(near.hp, hp, 'the adjacent blind tile does not become an attack target');
checkInvariants(h.b); assert.deepEqual(h.b.errors, []);
});
test('Horn prioritizes her ordinary ranged targets over an unblockable enemy on her own tile', () => {
const h = makeBattle({ autoFinish: false, units: [{ uid: 1, chessId: 'chess_char_5_08_a', row: 10, col: 3 }],
defs: { enemies: { fragment: enemyRec({ key: 'fragment', hp: 1e7, atk: 0, speed: 0 }) } },
});
h.step();
const own = h.spawn('fragment', { pos: [10, 3] }), far = h.spawn('fragment', { pos: [10, 6] });
h.b.addBuff(own, { key: 'test:fragment', persist: true, flags: { unblockable: true } });
h.run(4);
assert.ok(far.hp < far.s.maxHp); assert.equal(own.hp, own.s.maxHp);
checkInvariants(h.b); assert.deepEqual(h.b.errors, []);
});
test('Horn changing to melee does not remove splash from an already flying shell', () => {
const h = makeBattle({ autoFinish: false, hooks: ['attack'], captureNoisy: true, units: [{ uid: 1, chessId: 'chess_char_5_08_a', skillIndex: 2, row: 10, col: 3 }],
defs: { enemies: { dummy: enemyRec({ key: 'dummy', hp: 1e7, atk: 0, speed: 0 }) } },
});
h.step(); const u = h.unit(1);
h.b.addBuff(u, { key: 'test:fast', persist: true, mods: { aspd: 500 } });
u.skill.gainSp(999, 'test'); assert.ok(u.skill.activate('test'));
const far = h.spawn('dummy', { pos: [10, 8] }), side = h.spawn('dummy', { pos: [11, 8] });
assert.ok(h.runUntil(() => h.hooksOf('attack').some(c => c.attacker === u), 1));
h.spawn('dummy', { pos: [10, 3] }); h.run(0.4);
assert.ok(h.hooksOf('attack').filter(c => c.attacker === u).length >= 2, 'a melee attack follows before the shell lands');
assert.ok(far.hp < far.s.maxHp); assert.equal(side.s.maxHp - side.hp, far.s.maxHp - far.hp);
checkInvariants(h.b); assert.deepEqual(h.b.errors, []);
});
for (const hidden of [false, true]) test(`${hidden ? 'hidden' : 'final'} Helm actually fires a shell which stuns the target's 3×3 area`, () => {
const rec = (id, atk) => chessRec({ id, stats: { atk, maxHp: 1e7, blockCnt: 0 }, skill: null });
const h = makeBattle({ kind: hidden ? 'hidden' : 'boss', autoFinish: false, timeLimit: 90, flags: { dpInit: 999 },
hooks: ['statusApplied'], defs: { chess: { main: rec('main', 900), bystander: rec('bystander', 0) } },
kits: { main: () => ({ trait: { noAttack: true } }), bystander: () => ({ trait: { noAttack: true } }) },
units: [{ uid: 1, chessId: 'main', row: 10, col: 6 }, { uid: 2, chessId: 'bystander', row: 9, col: 5 },
{ uid: 3, chessId: 'bystander', row: 11, col: 7 }, { uid: 4, chessId: 'bystander', row: 12, col: 3 }],
});
h.step();
const e = h.spawn(hidden ? 'enemy_9013_acstmk_2' : 'enemy_9013_acstmk', { pos: [3, 10], tag: 'boss', mods: { speedMul: 0 } });
e.profile.noAttack = true;
assert.ok(h.runUntil(() => h.b.enemies.some(e => e.defId === 'enemy_9016_acstmr'), 60), 'shell was fired');
assert.ok(h.runUntil(() => h.hooksOf('statusApplied').some(c => c.target === h.unit(1) && c.status === 'stun'), 25), 'shell arrived');
const stunned = new Set(h.hooksOf('statusApplied').filter(c => c.status === 'stun').map(c => c.target.uid));
for (const uid of [1, 2, 3]) assert.ok(stunned.has(uid), `3×3 includes ${uid}`);
assert.ok(!stunned.has(4), 'outside the 3×3'); checkInvariants(h.b); assert.deepEqual(h.b.errors, []);
});
+88
View File
@@ -0,0 +1,88 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, chessRec, checkInvariants } from '../helpers/battleHarness.js';
import { makeMatch } from '../match/harness.js';
import { uniteBattleOpts } from '../../server/match/unite.js';
import { FORCED_EXIT } from '../../server/sim/constants.js';
const egir = (layers = 0, count = 5) => ({ count, active: true, tier: count >= 5 ? 2 : 1, layers });
const close = (a, b) => assert.ok(Math.abs(a - b) < 1e-8, `${a} != ${b}`);
const defs = { chess: Object.fromEntries([['A', 10000], ['B', 7000], ['C', 20000], ['D', 10000], ['E', 10000]]
.map(([id, maxHp]) => [id, chessRec({ id, bonds: ['egirShip'], stats: { atk: 100, def: 0, maxHp, blockCnt: 1 },
skill: { spCost: 30, initSp: 5, duration: 10 } })])) };
const units = [['A', 10, 3], ['B', 10, 4], ['C', 10, 5], ['D', 12, 3], ['E', 12, 5]]
.map(([chessId, row, col], i) => ({ uid: i + 1, kind: 'chess', chessId, row, col, dir: 'RIGHT' }));
function carryInput(t, own, lineup, bonds) {
const m = makeMatch({ mode: 'coop', humans: 3, fake: true }).m;
t.after(() => m.dispose());
const unitsEnd = own.result().perPlayer.p1.unitsEnd;
const ps = { playerId: 'p1', board: new Map(lineup.map(u => [u.uid, { uid: u.uid, kind: 'chess' }])),
battleInput: ({ side, colOffset, carry }) => ({ playerId: 'p1', seat: 0, side, colOffset, bonds,
units: lineup.map(u => ({ ...u, carryState: carry.get(u.uid) })) }) };
const { players } = uniteBattleOpts({ gd: m.gd, round: 3, lastResults: new Map([['p1', { unitsEnd }]]), dispatch() {} },
{ helpers: [ps], leaked: [] }, 60);
return { players, unitsEnd };
}
test('normal result → unite rebuilds Egir attributes but preserves every living HP ratio and SP', t => {
const own = makeBattle({ defs, units, bonds: { egirShip: egir() }, autoFinish: false });
own.step(2); own.b.forceEnd();
const { players, unitsEnd } = carryInput(t, own, units, { egirShip: egir(own.b.getPlayer('p1').bonds.egirShip.layers) });
const next = makeBattle({ kind: 'unite', defs, players, autoFinish: false }); next.b.start();
for (const old of unitsEnd) {
const u = next.unit(old.uid);
assert.equal(u.alive, old.alive);
close(u.hpRatio, old.hpPct); close(u.skill.spTotal, old.sp);
assert.deepEqual(u.findBuff('bond:egir:devour')?.mods, own.unit(old.uid).findBuff('bond:egir:devour')?.mods);
}
assert.equal(next.b.result().perPlayer.p1.deaths, 0);
assert.deepEqual(next.b.result().perPlayer.p1.layerGains, {});
checkInvariants(next.b);
});
test('a previous full-health survivor cannot be newly killed by opening devour during carry restoration', t => {
const weak = { chess: { ...defs.chess, B: { ...defs.chess.B, stats: { ...defs.chess.B.stats, maxHp: 2000 } } } };
const three = units.slice(0, 3).map(u => u.uid === 3 ? { ...u, row: 12, col: 7 } : u);
const own = makeBattle({ defs: weak, units: three, bonds: { egirShip: egir(0, 3) }, autoFinish: false, timeLimit: 120 });
own.run(80); own.b.forceEnd();
assert.equal(own.unit(2).hpRatio, 1, 'food redeployed before its old battle ended');
const { players } = carryInput(t, own, three, { egirShip: egir(own.b.getPlayer('p1').bonds.egirShip.layers, 3) });
const next = makeBattle({ kind: 'unite', defs: weak, players, autoFinish: false }); next.b.start();
assert.equal(next.unit(2).alive, true); assert.equal(next.unit(2).hpRatio, 1);
assert.equal(next.b.result().perPlayer.p1.deaths, 0);
close(next.unit(1).findBuff('bond:egir:devour').mods.atkFinal, 100);
});
test('forced-down carry still contributes devour attributes without consuming any of the three later revives', () => {
const lineup = units.map(u => ({ ...u, carryState: u.uid === 2 ? { down: true } : { hpPct: 0.4, sp: 17 } }));
const next = makeBattle({ kind: 'unite', defs, players: [{ playerId: 'p1', units: lineup, bonds: { egirShip: egir() } }], autoFinish: false });
next.b.start();
assert.equal(next.unit(2).alive, false); assert.equal(next.unit(2).removeReason, FORCED_EXIT);
close(next.unit(1).findBuff('bond:egir:devour').mods.atkFinal, 200);
const survived = [];
for (const uid of [4, 5, 1, 3]) { next.b.loseHp(next.unit(uid), 1e9); survived.push(next.unit(uid).alive); }
assert.deepEqual(survived, [true, true, true, false]);
checkInvariants(next.b);
});
test('carry preserves positive ratios below 1% and fractional HP when the new max HP is smaller', t => {
const lineup = [{ ...units[0], dir: 'UP' }];
const own = makeBattle({ defs, units: lineup, bonds: { egirShip: egir() }, autoFinish: false });
own.b.start(); own.unit(1).hp = 1; own.b.forceEnd();
const { players, unitsEnd } = carryInput(t, own, lineup, {});
close(players[0].units[0].carryState.hpPct, unitsEnd[0].hpPct);
const small = { chess: { A: { ...defs.chess.A, stats: { ...defs.chess.A.stats, maxHp: 1000 } } } };
const next = makeBattle({ kind: 'unite', defs: small, players, autoFinish: false }); next.b.start();
assert.ok(next.unit(1).alive && next.unit(1).hp > 0 && next.unit(1).hp < 1);
close(next.unit(1).hpRatio, unitsEnd[0].hpPct);
});
test('ordinary/boss devour and fresh unite units still receive opening devour damage', () => {
for (const kind of ['normal', 'unite', 'boss', 'hidden']) {
const h = makeBattle({ kind, defs, units, bonds: { egirShip: egir() }, autoFinish: false }); h.b.start();
assert.ok(h.unit(2).hpRatio < 0.5, kind);
assert.ok(h.unit(3).hpRatio < 0.7, kind);
checkInvariants(h.b);
}
});
+6 -6
View File
@@ -70,12 +70,12 @@ test('stats-only enemies attach no ability and fight with their data stats', ()
}
});
// Abilities the client makes silenceable although their handbook line is NORMAL (the battle prefab's buff template checks
// SILENCED): 萨卡兹枯朽战士 / 组长's PollutedDie = template projectile_on_killed, the 死亡爆炸 template of 高能源石虫's
// SILENCE-flagged blast too (community report of 2026-10-06, item 10; DESIGN §25.18.2)
// 萨卡兹枯朽战士 / 组长's PollutedDie is silenced even though the handbook line is NORMAL and the PRTS / in-game text
// says 「不可沉默」. The owner's official-play recall of 2026-10-12 (GitHub #470) is that a death while silenced
// creates no fog. Only these two keys share that ability (DESIGN §30.23; 0.2.0's client-template reading is §25.18.2).
const SILENCE_BY_TEMPLATE = new Set(['enemy_1267_nhpbr', 'enemy_1267_nhpbr_2']);
test('沉默: exactly the abilities whose handbook line is SILENCE-flagged can be silenced (every authored enemy), plus the ones whose client template checks it', () => {
test('沉默: exactly the abilities whose handbook line is SILENCE-flagged can be silenced (every authored enemy), plus 萨卡兹枯朽战士 / 组长\'s death fog', () => {
const h = arena();
h.step();
for (const key of [...Object.keys(KITS), ...Object.keys(BOSS_KITS)]) {
@@ -768,8 +768,8 @@ for (const key of ['enemy_1267_nhpbr', 'enemy_1267_nhpbr_2']) {
approx(h.unit('t_wall').stats.taken, 3 * tb(key, 'PollutedDie.polluted_damage_low'));
});
// PRTS 特殊机制 死亡爆炸 "默认可沉默…若不处于沉默状态,将会…释放"; client template projectile_on_killed: CheckAbnormalFlag
// SILENCED (unset) before EmitProjectile (community report of 2026-10-06, item 10: a silenced one still poisoned)
// Owner's official-play recall of 2026-10-12 (GitHub #470): a death while the silence icon is already showing
// creates no fog. That contradicts the PRTS / in-game text 「不可沉默」. The same PollutedDie ability is on both keys.
test(`${nm(key)}: silenced when it dies, it releases no 污染秽蚀; a silence that ran out does not stop it`, () => {
const h = arena({ units: [{ chessId: 't_wall', row: 10, col: 6 }] });
h.step();
@@ -50,7 +50,8 @@ test('#2 麦哲伦 S1: an enemy on a standing drone\'s range (air units too) cas
const { h, u, ps } = field(MG, 0, [[DRONE_F, 12, 9]]);
assert.equal(u.skill.rule, 'DEFAULT');
assert.ok(ps[0].alive && ps[0].rangeKeySet.has(12 * COLS + 10) && outOfHerRange(u, 12, 10), 'the setup');
h.spawn('enemy_dummy', { pos: [11, 13] }); // outside every area
// (11, 13) would be held at the field edge, on the drone's tile (11, 10). Stay on the field and outside every area.
h.spawn('enemy_dummy', { pos: [11, 6] });
assert.ok(!casts(h, u), 'nobody in her range or a drone\'s: no cast');
h.spawn('enemy_dummy', { pos: [12, 10] });
assert.ok(h.runUntil(() => u.skill.activations === 1, 1), 'an enemy on the drone\'s range casts S1');
+45
View File
@@ -0,0 +1,45 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, enemyRec, chessRec, checkInvariants } from '../helpers/battleHarness.js';
import { DREAD_TAG } from '../../server/sim/content/kits/ops/op-ascln.js';
for (const [item, status] of [['5_01', 'palsy'], ['5_02', 'cold']]) for (const quality of ['a', 'b']) {
const id = `chess_item_${item}_e_${quality}`;
test(`${id}: Ascalon poison still procs ${status} while she cannot attack`, () => {
const h = makeBattle({ autoFinish: false, hooks: ['damaged', 'attack', 'statusApplied'], captureNoisy: true,
units: [{ uid: 1, diy: { slot: 6, charId: 'char_4132_ascln', skillIndex: 0 }, row: 10, col: 5, items: [id] }],
defs: { enemies: { dummy: enemyRec({ key: 'dummy', hp: 1e7, atk: 0, speed: 0 }) } },
});
h.step(); const u = h.unit(1), e = h.spawn('dummy', { pos: [10, 6] });
const rng = h.b.rng; h.b.rng = Object.assign(() => 0.999, rng);
assert.ok(h.runUntil(() => h.hooksOf('damaged').some(c => c.source === u && c.dmg.isAttack), 5));
assert.equal(e.findBuff(status), null, 'the attack did not roll the item');
const attacks = h.hooksOf('attack').length;
h.b.addBuff(u, { key: 'test:noAttack', flags: { disarm: true }, duration: 5 });
h.b.rng = Object.assign(() => 0, rng);
assert.ok(h.runUntil(() => h.hooksOf('damaged').some(c => c.dmg.tags?.includes(DREAD_TAG)), 2));
assert.equal(h.hooksOf('attack').length, attacks, 'only the already-applied poison deals damage');
assert.ok(e.findBuff(status), 'a real poison damage tick triggers the item');
checkInvariants(h.b); assert.deepEqual(h.b.errors, []);
});
test(`${id}: only carrier damage to enemies rolls ${status}; gauge, HP loss and bare attacks do not`, () => {
const h = makeBattle({ autoFinish: false, defs: {
chess: { quiet: chessRec({ id: 'quiet', stats: { atk: 0 }, skill: null }) },
enemies: { dummy: enemyRec({ key: 'dummy', hp: 1e7, atk: 0, speed: 0 }) },
}, units: [{ uid: 1, chessId: 'quiet', row: 10, col: 3, items: [id] }, { uid: 2, chessId: 'quiet', row: 10, col: 4 }] });
h.step(); const u = h.unit(1), ally = h.unit(2), e = h.spawn('dummy', { pos: [10, 8] });
h.b.rng = Object.assign(() => 0, h.b.rng);
h.b.emit('attack', { attacker: u, targets: [e] });
h.b.dealDamage(u, e, { amount: 1, type: 'element', element: 'erosion' });
h.b.loseHp(e, 1, { source: u });
h.b.dealDamage(null, e, { amount: 1, type: 'true' });
h.b.dealDamage(ally, e, { amount: 1, type: 'true' });
h.b.dealDamage(u, ally, { amount: 1, type: 'true' });
h.b.dealDamage(u, e, { amount: 0, type: 'true' });
assert.equal(e.findBuff(status), null); assert.equal(ally.findBuff(status), null);
h.b.dealDamage(u, e, { amount: 1, type: 'arts', canDodge: false, tags: ['skill', 'dot'] });
assert.ok(e.findBuff(status));
checkInvariants(h.b); assert.deepEqual(h.b.errors, []);
});
}
+2 -2
View File
@@ -659,9 +659,9 @@ test('modules: removing the elite module changes the trait / talent behaviour th
const id = 'chess_char_2_10_b';
const [a, b] = withAndWithout((m) => {
const h = run({ defs: { enemies: { e: dummy('e') } }, units: [{ chessId: id, row: 10, col: 4, carryState: IDLE, ...mod(m) }], enemies: [{ key: 'e', pos: [10, 6] }] });
h.runUntil(() => dealt(h, h.unit(id)).length > 0, 5);
h.runUntil(() => dealt(h, h.unit(id), (c) => c.dmg.tags.includes('funnel')).length > 0, 5);
const u = h.unit(id);
const r = dealt(h, u)[0].amount / u.s.atk;
const r = dealt(h, u, (c) => c.dmg.tags.includes('funnel'))[0].amount / u.s.atk;
done(h);
return r;
});
+4 -2
View File
@@ -1070,7 +1070,7 @@ test('6_17 耀骑士临光 S2 逐夜烁光: on deploy ATK + and 3 护盾 layers
// =================================================================================================================
// 6_18 荒芜拉普兰德
test('6_18 荒芜拉普兰德 S1 慵怠者悲鸣: passive 浮游单元+1 (2 hits per attack); toggled on: ATK + and the drones lock still enemies anywhere', () => {
test('6_18 荒芜拉普兰德 S1 慵怠者悲鸣: passive 浮游单元+1 (one body plus two additions); toggled on: ATK + and the drones lock still enemies anywhere', () => {
for (const id of both('chess_char_6_18')) {
const sid = 'skchr_whitw2_1', bb = bbOf(id, sid);
const idle = run({ defs: { enemies: { e: dummy('e') } }, units: [U(id, sid, 10, 4, { carryState: IDLE })], enemies: [{ key: 'e', pos: [10, 6] }] });
@@ -1079,7 +1079,9 @@ test('6_18 荒芜拉普兰德 S1 慵怠者悲鸣: passive 浮游单元+1 (2 hits
const a0 = idle.hooksOf('attack').find((c) => c.attacker === iu);
assert.ok(a0);
idle.run(1);
assert.equal(dealt(idle, iu, (c) => c.dmg.attackId === dealt(idle, iu)[0].dmg.attackId).length, 2, 'drone +1');
const volley = dealt(idle, iu, (c) => c.dmg.attackId === dealt(idle, iu)[0].dmg.attackId);
assert.equal(volley.filter((c) => c.dmg.isAttack).length, 1, 'one body hit');
assert.equal(volley.filter((c) => c.dmg.tags.includes('funnel')).length, 2, 'drone +1');
done(idle);
const h = run({
+4 -1
View File
@@ -7,6 +7,7 @@ import { makeBattle, enemyRec, chessRec, checkInvariants } from '../helpers/batt
import { getDefaultSource } from '../../server/sim/simdata.js';
import { ELEMENT_GAUGE_MAX, PROJECTILE_SPEEDS, BOOMERANG_RETURN_SPEED } from '../../server/sim/constants.js';
import { sortEnemyTargets } from '../../server/sim/targeting.js';
import { DRONE_TAG as ROCKROCK_DRONE } from '../../server/sim/content/kits/ops/chess_char_2_10-rockr.js';
const ds = getDefaultSource();
const raw = (id) => ds.rawChess(id);
@@ -1341,7 +1342,9 @@ test('2_10 洛洛: 自负此轭 drones stay locked on their target while the ski
const t0 = h.b.time;
h.run(4);
const atks = h.hooksOf('attack').filter((c) => c.attacker === u && c.t > t0);
assert.ok(atks.length > 0 && atks.every((c) => c.targets.length === 1 && c.targets[0] === first), 'locked');
assert.ok(atks.length > 0 && atks.every((c) => c.targets.length === 1 && c.targets[0] === other), 'body keeps normal targeting');
const drones = h.hooksOf('damaged').filter((c) => c.source === u && c.t > t0 && c.dmg.tags?.includes(ROCKROCK_DRONE));
assert.ok(drones.length > 0 && drones.every((c) => c.target === first && c.dmg.isSkill && !c.dmg.isAttack), 'drone stays locked');
done(h);
});
+34 -16
View File
@@ -488,13 +488,13 @@ test('3_13 至简: 神工意匠 charges: ATK% arts ×2 per cast; 忽有所悟 cr
const u = h.unit(id);
assert.equal(u.skill.maxCharges, bb.ct);
h.run(60);
const normal = h.hooksOf('damaged').filter((c) => c.source === u && c.dmg?.isAttack && !c.dmg.isSkill).map((c) => c.amount / u.s.atk);
const normal = h.hooksOf('damaged').filter((c) => c.source === u && c.dmg?.tags.includes('funnel') && !c.dmg.isSkill).map((c) => c.amount / u.s.atk);
const ramp = (v) => [tb.max_atk_scale, tb.max_atk_scale * t0.atk_scale].some((x) => Math.abs(v - x) < 1e-6);
assert.ok(normal.slice(10).every(ramp), 'ramped drone hits: max or max × crit');
assert.ok(normal.some((v) => Math.abs(v - tb.max_atk_scale * t0.atk_scale) < 1e-6), 'some crits');
assert.ok(normal.some((v) => Math.abs(v - tb.max_atk_scale) < 1e-6), 'some plain hits');
const skillAtks = h.hooksOf('attack').filter((c) => c.attacker === u && c.isSkill).length;
const sk = h.hooksOf('damaged').filter((c) => c.source === u && c.dmg?.isSkill).map((c) => c.amount / u.s.atk);
const sk = h.hooksOf('damaged').filter((c) => c.source === u && c.dmg?.isSkill && c.dmg.isAttack).map((c) => c.amount / u.s.atk);
assert.ok(skillAtks >= 2);
assert.equal(sk.length, skillAtks * 2, 'two hits per cast');
for (const v of sk) assert.ok([bb.atk_scale, bb.atk_scale * t0.atk_scale].some((x) => Math.abs(v - x) < 1e-6), `skill hit ${v}`);
@@ -790,16 +790,25 @@ test('3_20 耶拉: 低眉 ATK (ground tiles in range); 心随意动 +1 drone, AT
h.step();
approx(u.s.atk, u.base.atk * (1 + t0['kjera_t_1[high].atk']));
h.run(4);
const first = h.hooksOf('damaged').filter((c) => c.source === u && c.dmg?.isAttack).map((c) => c.amount / u.s.atk);
const first = h.hooksOf('damaged').filter((c) => c.source === u && c.dmg?.tags.includes('funnel')).map((c) => c.amount / u.s.atk);
assert.ok(first[1] > first[0], 'drone ramps on the same target');
fill(u);
assert.ok(h.runUntil(() => u.skill.active, 5));
const tOn = h.b.time;
approx(u.s.atk, u.base.atk * (1 + t0['kjera_t_1[high].atk'] + bb.atk));
const full = u.s.atk * u.s.atkScaleMul;
h.runUntil(() => !u.skill.active, 30);
const sk = h.hooksOf('attack').filter((c) => c.attacker === u && c.isSkill);
assert.ok(sk.length > 5 && sk.every((c) => c.targets.length === 1 + bb['attack@cnt']), 'two drone locks');
const during = (c) => c.t + 1e-9 >= tOn - h.b.dt && c.t + 1e-9 < h.b.time;
const atks = h.hooksOf('attack').filter((c) => c.attacker === u && during(c) && !c.isSkill);
assert.ok(atks.length > 5 && atks.every((c) => c.targets.length === 1), 'the body keeps a single in-range target');
const drones = h.hooksOf('damaged').filter((c) => c.source === u && during(c) && c.dmg?.tags?.includes('kjera:drone'));
assert.ok(drones.length >= atks.length, 'released drones keep firing');
const bodies = h.hooksOf('damaged').filter((c) => c.source === u && during(c) && c.dmg?.isAttack && !c.dmg.isSkill
&& Math.abs(c.amount - full) < 1e-4);
assert.ok(bodies.length >= 2, 'her own hits stay at 100% ATK');
const colds = h.hooksOf('statusApplied').filter((c) => c.source === u && c.status === 'cold');
assert.ok(colds.length >= 1 && colds.length < sk.length * 2, `cold procs (${colds.length}/${sk.length * 2})`);
const chances = bodies.length + drones.length;
assert.ok(colds.length >= 1 && colds.length < chances, `cold procs (${colds.length}/${chances})`);
approx(colds[0].duration, bb['attack@cold']);
done(h);
});
@@ -1076,24 +1085,33 @@ test('3_19 伺夜: the pack never takes a later board unit’s tile; a 狼群 pi
});
test('3_20 耶拉: 心随意动 with a single enemy — both drones lock it and ramp in parallel', () => {
const id = 'chess_char_3_20_b', bb = BB(id), tb = TR(id);
const id = 'chess_char_3_20_b', tb = TR(id);
const h = makeBattle({
defs: { enemies: { enemy_a: dummy('enemy_a') } }, timeLimit: 80, hooks: ['damaged', 'attack'], captureNoisy: true,
units: [{ chessId: id, row: 10, col: 2 }], enemies: [{ key: 'enemy_a', pos: [10, 4] }],
});
const u = h.unit(id);
h.run(12);
const plain = h.hooksOf('damaged').filter((c) => c.source === u && c.dmg?.isAttack).map((c) => c.amount / u.s.atk);
const plain = h.hooksOf('damaged').filter((c) => c.source === u && c.dmg?.tags.includes('funnel')).map((c) => c.amount / u.s.atk);
approx(plain[plain.length - 1], tb.max_atk_scale, 1e-6, 'module ramp cap');
fill(u);
assert.ok(h.runUntil(() => u.skill.active, 5));
const t0 = h.b.time;
const n0 = h.hooksOf('damaged').length;
h.run(8);
const sk = h.hooksOf('attack').filter((c) => c.attacker === u && c.isSkill);
assert.ok(sk.length >= 3 && sk.every((c) => c.targets.length === 1 + bb['attack@cnt'] && c.targets.every((t) => t.defId === 'enemy_a')));
const hits = h.hooksOf('damaged').slice(n0).filter((c) => c.source === u && c.dmg?.isAttack);
assert.ok(hits.length >= 2 * (sk.length - 1));
for (let i = 0; i + 1 < hits.length; i += 2) approx(hits[i].amount, hits[i + 1].amount, 1e-9, 'same volley, same scale');
const atks = h.hooksOf('attack').filter((c) => c.attacker === u && c.t + 1e-9 >= t0 - h.b.dt && !c.isSkill);
assert.ok(atks.length >= 3 && atks.every((c) => c.targets.length === 1 && c.targets.every((t) => t.defId === 'enemy_a')), 'one body target');
const drones = h.hooksOf('damaged').slice(n0).filter((c) => c.source === u && c.dmg?.tags?.includes('kjera:drone'));
assert.ok(drones.length >= 4, 'both drones stay on the lone enemy');
const byT = new Map();
for (const c of drones) (byT.get(c.t) ?? byT.set(c.t, []).get(c.t)).push(c);
const pairs = [...byT.values()].filter((v) => v.length === 2);
assert.ok(pairs.length >= 2, 'the two drones share a volley');
for (const v of pairs) approx(v[0].amount, v[1].amount, 1e-9, 'same volley, same scale');
const full = u.s.atk * u.s.atkScaleMul;
const body = h.hooksOf('damaged').slice(n0).filter((c) => c.source === u && c.dmg?.isAttack && !c.dmg.isSkill
&& Math.abs(c.amount - full) < 1e-4);
assert.ok(body.length >= 2, 'body stays at 100% ATK while the drones ramp');
done(h);
});
@@ -1159,7 +1177,7 @@ test('精锐 numbers from the golden blackboards (module traits handled by the p
const ml = m.unit(e13);
assert.equal(ml.skill.maxCharges, BB(e13).ct);
m.run(25);
const ramps = m.hooksOf('damaged').filter((c) => c.source === ml && c.dmg?.isAttack && !c.dmg.isSkill).map((c) => c.amount / ml.s.atk);
const ramps = m.hooksOf('damaged').filter((c) => c.source === ml && c.dmg?.tags.includes('funnel') && !c.dmg.isSkill).map((c) => c.amount / ml.s.atk);
assert.ok(ramps.some((v) => Math.abs(v - TR(e13).max_atk_scale) < 1e-6), 'module cap 120 %');
assert.ok(ramps.every((v) => v <= TR(e13).max_atk_scale * TB(e13, 0).atk_scale + 1e-6));
done(m);
@@ -1249,7 +1267,7 @@ test('忍冬 追凶 never feeds on itself: a hit turned into HP loss credited to
const e = h.enemy('enemy_times');
const atks = u.stats.attacks;
assert.ok(atks >= 3);
// first attack marks the enemy (1 HP), every later attack: 1 HP + one 追凶 bonus instance (1 HP)
assert.equal(e.s.maxHp - e.hp, 1 + 2 * (atks - 1));
// Every attack, the first included: 1 HP + one 追凶 bonus instance (1 HP), never recursive bonuses.
assert.equal(e.s.maxHp - e.hp, 2 * atks);
done(h);
});
+10 -3
View File
@@ -138,12 +138,16 @@ test('耶拉 S2: two drones ramp independently on two targets, ATK up; 低眉 +1
const n0 = dmgBy(h, u).length;
h.run(8);
const hits = dmgBy(h, u).slice(n0);
const onA = hits.filter((c) => c.target === a).map((c) => c.amount), onB = hits.filter((c) => c.target === b).map((c) => c.amount);
const drones = hits.filter((c) => c.dmg?.tags?.includes('kjera:drone'));
const onA = drones.filter((c) => c.target === a).map((c) => c.amount), onB = drones.filter((c) => c.target === b).map((c) => c.amount);
assert.ok(onA.length >= 3 && onB.length >= 3, 'both locked targets attacked');
const atk = u.s.atk;
approx(onA[0], atk * tr.init_atk_scale, 1e-6, 'ramp start');
approx(onA[1], atk * (tr.init_atk_scale + tr.delta_atk_scale), 1e-6, 'ramp +15 %');
approx(onB[0], atk * tr.init_atk_scale, 1e-6, 'second drone starts its own ramp');
approx(onB[1], atk * (tr.init_atk_scale + tr.delta_atk_scale), 1e-6, 'second drone ramps on its own');
const body = hits.filter((c) => c.dmg?.isAttack && !c.dmg.isSkill);
assert.ok(body.length >= 1 && body.some((c) => Math.abs(c.amount - atk) < 1e-4), 'her own hit stays at 100% ATK');
h.run(15);
assert.ok(noisy(h, 'statusApplied').some((c) => c.source === u && c.status === 'cold' && c.duration === bb['attack@cold']), 'cold proc');
checkInvariants(h.b);
@@ -1032,14 +1036,17 @@ test('耶拉 S2: against a lone enemy both drones lock it and ramp independently
const n0 = dmgBy(h, u).length;
h.run(4);
const hits = dmgBy(h, u).slice(n0);
const drones = hits.filter((c) => c.dmg?.tags?.includes('kjera:drone'));
const byT = new Map();
for (const c of hits) (byT.get(c.t) ?? byT.set(c.t, []).get(c.t)).push(c.amount / u.s.atk);
for (const c of drones) (byT.get(c.t) ?? byT.set(c.t, []).get(c.t)).push(c.amount / u.s.atk);
const volleys = [...byT.values()];
assert.ok(volleys.length >= 3);
assert.ok(volleys.every((v) => v.length === 2), 'two drone hits per attack');
assert.ok(volleys.every((v) => v.length === 2), 'two drone hits per tick');
approx(volleys[0][0], tr.init_atk_scale, 1e-6);
approx(volleys[0][1], tr.init_atk_scale, 1e-6, 'second drone starts its own ramp');
approx(volleys[1][1], tr.init_atk_scale + tr.delta_atk_scale, 1e-6);
const full = u.s.atk * u.s.atkScaleMul;
assert.ok(hits.some((c) => c.dmg?.isAttack && !c.dmg.isSkill && Math.abs(c.amount - full) < 1e-4), 'body at 100% ATK');
checkInvariants(h.b);
});
+5 -3
View File
@@ -997,8 +997,9 @@ test('荒芜拉普兰德: 叙拉古的荣幸 +5 initial SP on every deployment;
// (S3 may be running — its drones do not normal-attack): wait for a normal attack after stage 3
assert.ok(h.runUntil(() => h.dmg.some((d) => d.src === u.id && d.attack && d.tgt === e.id && d.t > ts), 60));
const last = h.dmg.filter((d) => d.src === u.id && d.attack && d.tgt === e.id).at(-1);
const same = h.dmg.filter((d) => d.src === u.id && d.attack && d.t === last.t);
assert.equal(same.length, 2, 'two drone hits per attack');
const same = h.dmg.filter((d) => d.src === u.id && d.t === last.t);
assert.equal(same.filter((d) => d.attack).length, 1, 'one body hit per attack');
assert.equal(same.filter((d) => d.tags.includes('funnel')).length, 2, 'two drone additions per attack');
});
test('缪尔赛思: 流形 copies stats/range/damage type but not the attack shape (锏 2-hit); a capped melee copy steals nothing more', () => {
@@ -1120,7 +1121,8 @@ test('异客: the S3 storm is range x-1 — the 13-tile diamond — around the t
// in the diamond (|Δrow| + |Δcol| ≤ 2 by tile): (2, 0) / (0, −2) / (1.95, 0.4) lay outside the old 1.5 circle
for (const off of [[0, 0], [2, 0], [0, -2], [-2, 0], [1.45, 1.45], [1.95, 0.4]]) assert.equal(strikes(off), 8, `(${off}) struck by all 8 strikes`);
// outside it: tile (+2, +1), and tile (+3, 0) — (1.55, 1.05) was inside the old circle around the target's position
for (const off of [[2, 1], [1.55, 1.05], [0, 2.6]]) assert.equal(strikes(off), 0, `(${off}) never struck`);
// three tiles off along the columns: the field has no row 13 (a probe there is held on row 12, inside the diamond)
for (const off of [[2, 1], [1.55, 1.05], [2.6, 0]]) assert.equal(strikes(off), 0, `(${off}) never struck`);
});
test('蕾缪安: 通缉 needs a continuous 8 s stay in a 拉特兰 range (leaving restarts the count)', () => {
+7 -3
View File
@@ -80,18 +80,22 @@ test('a 自选 pick: 澄闪 is offered at tiers 5 and 6 and a roster with her pa
{ ok: true, picks: { [SLOT[6]]: { charId: GD, skillIndex: 2, uniEquipId: FY } } });
});
test('her normal attack (no skill): one drone hit per attack — init, +delta per hit on the same target, up to the cap (FUN-X from 0.35, FUN-Y to 1.2); 精准导流 lowers the RES it meets', () => {
for (const f of [[5, false, null], [5, true, FX], [6, true, FY]]) {
test('her normal attack (no skill): a body hit and one drone addition — init, +delta per hit on the same target, up to the cap (FUN-X from 0.35, FUN-Y to 1.2); 精准导流 lowers the RES of both', () => {
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
const { h, u } = field({ tier, elite, mod, skill: 2 });
const e = h.spawn('enemy_res', { pos: [10, 6] });
h.run(12);
const hits = mine(h, u).filter((c) => c.target === e && c.dmg.isAttack);
const drones = mine(h, u).filter((c) => c.target === e && c.dmg.tags.includes('funnel'));
assert.ok(hits.length >= 8, `${label(f)}: ${hits.length} attacks`);
assert.equal(drones.length, hits.length, `${label(f)}: one addition per body hit`);
const tb = traitOf(tier, elite, mod).bb, pen = talentOf(tier, elite, mod, 1).bb.magic_resist_penetrate_fixed;
hits.slice(0, 8).forEach((c, i) => {
const ramp = Math.min(tb.max_atk_scale, tb.init_atk_scale + i * tb.delta_atk_scale);
approx(c.amount, u.s.atk * ramp * (1 - (50 - pen) / 100), `${label(f)}: attack ${i + 1} at ${ramp}`);
approx(c.amount, u.s.atk * (1 - (50 - pen) / 100), `${label(f)}: body attack ${i + 1}`);
approx(drones[i].amount, c.amount * ramp, `${label(f)}: drone at ${ramp}`);
assert.equal(drones[i].dmg.attackId, c.dmg.attackId, 'same attack action');
assert.equal(c.type, 'arts');
});
done(h);
+1 -1
View File
@@ -330,7 +330,7 @@ test('S2 连星 (AUTO, SP_FULL): a triggered piece hits every enemy on its conne
assert.deepEqual([sk.spCost, sk.bb['attack@atk_scale'], sk.bb['attack@move_speed'], sk.bb['attack@duration']], elite ? [16, 4.8, -0.4, 6] : [17, 4.2, -0.35, 6], `T${tier}`);
const { h, u } = field({ tier, elite, skill: 1, stones: [[10, 6], [10, 7]] });
const onLine = h.spawn('enemy_fly', { pos: [10, 9] }); // 3 tiles right of (10,6)…(10,7)+2: on the horizontal line
const beyond = h.spawn('enemy_dummy', { pos: [10, 11] }); // 4 tiles past (10,7)
const beyond = h.spawn('enemy_dummy', { pos: [10, 2] }); // 4 tiles before (10,6) (the field ends at column 10)
const vertical = h.spawn('enemy_dummy', { pos: [12, 7] }); // its battle-start 跟子 (11,7) is 上 of it: the vertical line is on
const off = h.spawn('enemy_dummy', { pos: [9, 4] }); // on neither line
const trig = h.spawn('enemy_runner', { pos: [10, 7] });
+267
View File
@@ -0,0 +1,267 @@
// Controlled regression inputs: real kit, seeded Battle, synthetic enemy records and authored routes.
// No live enemy movement/HP edits. These cases are distinct from the normal Match/browser route.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
import { bodyInKeys } from '../../server/sim/body.js';
import { DRONE_TAG } from '../../server/sim/content/kits/ops/chess_char_2_10-rockr.js';
import { getDefaultSource } from '../../server/sim/simdata.js';
const route = {
motion: 'FLY', start: [10, 6], end: [9, 2],
steps: [{ t: 'wait', s: 3 }, { t: 'move', p: [12, 6] }, { t: 'wait', s: 8 },
{ t: 'move', p: [10, 6] }, { t: 'wait', s: 40 }, { t: 'move', p: [9, 2] }],
};
const stationary = { motion: 'FLY', start: [10, 5], end: [9, 2], steps: [{ t: 'wait', s: 90 }, { t: 'move', p: [9, 2] }] };
const setup = (id, { skillIndex = 1, moduleId, newcomer = false, staysOutside = false, fragile = false,
outsideHp, stationaryLock = false, killer = false } = {}) => {
const lockRoute = staysOutside ? { ...route, steps: [...route.steps.slice(0, 2), { t: 'wait', s: 90 }, { t: 'move', p: [9, 2] }] } : route;
const h = makeBattle({ seed: 5, timeLimit: 100, autoFinish: false, captureNoisy: true,
hooks: ['damaged', 'attack', 'skillStart', 'skillEnd'],
defs: { enemies: {
enemy_lock: enemyRec({ key: 'enemy_lock', hp: 1e7, speed: 1, motion: 'FLY' }),
enemy_fragile: enemyRec({ key: 'enemy_fragile', hp: 300, speed: 1, motion: 'FLY' }),
enemy_outside: enemyRec({ key: 'enemy_outside', hp: outsideHp, speed: 1, motion: 'FLY' }),
enemy_killer: enemyRec({ key: 'enemy_killer', hp: 1e7, atk: 1e5, range: 4, motion: 'FLY' }),
} },
units: [{ chessId: id, row: 10, col: 4, skillIndex, moduleId, carryState: { sp: 999 } }],
enemies: [{ key: fragile ? 'enemy_fragile' : outsideHp ? 'enemy_outside' : 'enemy_lock',
route: fragile || stationaryLock ? stationary : lockRoute },
...(newcomer ? [{ key: 'enemy_lock', route: stationary, time: 7 }] : []),
...(killer ? [{ key: 'enemy_killer', route: stationary, time: 7 }] : [])],
});
h.step();
return { h, u: h.unit(id), target: h.enemies()[0] };
};
const hits = (h, u, target, after) => h.hooksOf('damaged').filter(c => c.source === u && c.target === target && c.t >= after && c.amount > 0);
const drone = c => c.dmg.tags?.includes(DRONE_TAG);
const approx = (actual, expected) => assert.ok(Math.abs(actual - expected) < 1e-7, `${actual} vs ${expected}`);
const done = h => { checkInvariants(h.b); assert.deepEqual(h.b.errors, []); };
for (const id of ['chess_char_2_10_a', 'chess_char_2_10_b']) {
test(`${id} S2: damage continues after a living target leaves the body range`, () => {
const { h, u, target } = setup(id);
assert.ok(u.skill.active);
assert.ok(h.runUntil(() => !bodyInKeys(target, u.rangeKeySet), 10));
assert.ok(hits(h, u, target, 0).length > 0, 'the in-range control receives damage before leaving');
h.run(2); // discard any projectile already in flight at the boundary
const t = h.b.time;
const bodyAttacks = u.stats.attacks;
h.run(4);
assert.ok(target.alive && u.alive && u.skill.active && !bodyInKeys(target, u.rangeKeySet));
const outHits = hits(h, u, target, t);
assert.ok(outHits.length > 0, 'a living out-of-range lock must still receive drone damage');
assert.ok(outHits.every(c => drone(c) && c.type === 'arts' && c.dmg.isSkill && !c.dmg.isAttack), 'pursuit damage must be an independent arts skill drone');
assert.equal(u.stats.attacks, bodyAttacks, 'the body cannot make attacks with no enemy in range');
done(h);
});
test(`${id} S2: an in-range newcomer cannot steal a living out-of-range drone lock`, () => {
const { h, u, target } = setup(id, { newcomer: true });
assert.ok(h.runUntil(() => h.enemies().length === 2, 10));
assert.ok(target.alive && !bodyInKeys(target, u.rangeKeySet));
h.run(2);
const t = h.b.time;
h.run(3);
const outHits = hits(h, u, target, t);
assert.ok(outHits.length > 0, 'the original target keeps receiving drone damage with another target in range');
assert.ok(outHits.every(c => drone(c) && c.dmg.isSkill && !c.dmg.isAttack), 'the body cannot attack the out-of-range lock');
const other = h.enemies().find(e => e !== target);
assert.equal(u.mem.rockLock, target);
assert.equal(hits(h, u, other, t).filter(drone).length, 0, 'the drone ignores the newcomer');
const body = hits(h, u, other, t).filter(c => c.dmg.isAttack);
assert.ok(body.length > 0, 'the body still attacks the in-range newcomer');
assert.ok(body.every(c => !c.dmg.isSkill && !drone(c)));
body.forEach(c => approx(c.amount, u.s.atk * u.s.atkScaleMul));
done(h);
});
test(`${id} S2: a target returning into range receives damage`, () => {
const { h, u, target } = setup(id);
assert.ok(h.runUntil(() => !bodyInKeys(target, u.rangeKeySet), 10));
assert.ok(h.runUntil(() => bodyInKeys(target, u.rangeKeySet), 20));
const t = h.b.time;
h.run(3);
const ds = hits(h, u, target, 0).filter(drone);
assert.ok(ds.some(c => c.t >= t));
assert.equal(u.mem.rockLock, target);
ds.slice(1).forEach((c, i) => approx(c.t - ds[i].t, Math.ceil(u.s.interval * 30 - 1e-9) / 30));
ds.slice(-3).forEach(c => approx(c.amount, u.s.atk * u.s.atkScaleMul * u.profile.funnel.max));
done(h);
});
test(`${id} S2: a target killed by combat releases the lock for another in-range enemy`, () => {
const { h, u, target } = setup(id, { fragile: true, newcomer: true });
assert.equal(u.mem.rockLock, target);
assert.ok(h.runUntil(() => !target.alive, 7));
assert.ok(h.runUntil(() => h.enemies().some(e => e !== target && e.alive), 10));
const other = h.enemies().find(e => e !== target && e.alive);
const t = h.b.time;
h.run(3);
assert.ok(u.alive && u.skill.active && other.alive);
assert.equal(u.mem.rockLock, other);
assert.ok(hits(h, u, other, t).length > 0);
done(h);
});
test(`${id} S2: the duration end clears the lock and stops out-of-range damage`, () => {
const { h, u, target } = setup(id, { staysOutside: true });
assert.ok(h.runUntil(() => !bodyInKeys(target, u.rangeKeySet), 10));
assert.ok(h.runUntil(() => !u.skill.active, 45));
assert.equal(h.hooksOf('skillEnd').find(c => c.unit === u)?.reason, 'duration');
assert.equal(u.mem.rockLock, null);
h.run(2);
const t = h.b.time;
h.run(4);
assert.ok(target.alive && u.alive && !bodyInKeys(target, u.rangeKeySet));
assert.equal(hits(h, u, target, t).length, 0);
done(h);
});
test(`${id} S1: damage stops outside the body range`, () => {
const { h, u, target } = setup(id, { skillIndex: 0 });
assert.ok(u.skill.active);
assert.ok(h.runUntil(() => !bodyInKeys(target, u.rangeKeySet), 10));
h.run(2);
const t = h.b.time;
h.run(4);
assert.equal(hits(h, u, target, t).length, 0);
done(h);
});
test(`${id} S2: an out-of-range combat kill waits for a candidate and restarts the trait ramp`, () => {
const { h, u, target } = setup(id, { outsideHp: id.endsWith('_a') ? 4000 : 5000, newcomer: true });
assert.ok(h.runUntil(() => !bodyInKeys(target, u.rangeKeySet), 10));
assert.ok(target.alive);
assert.ok(h.runUntil(() => !target.alive, 10));
assert.ok(drone(hits(h, u, target, 0).at(-1)), 'the independent drone delivers the killing blow');
if (!h.enemies().some(e => e !== target && e.alive)) {
h.step(); assert.equal(u.mem.rockLock, null, 'no out-of-range candidate is acquired');
}
assert.ok(h.runUntil(() => h.enemies().some(e => e !== target && e.alive), 10));
const other = h.enemies().find(e => e !== target && e.alive);
assert.ok(h.runUntil(() => hits(h, u, other, 0).some(drone), 2));
const first = hits(h, u, other, 0).find(drone);
approx(first.amount, u.s.atk * u.s.atkScaleMul * u.profile.funnel.init);
assert.equal(u.mem.rockLock, other);
done(h);
});
test(`${id} S2: explicit stop restores the normal multiplier and clears released state`, () => {
const { h, u, target } = setup(id, { staysOutside: true });
const saved = u.mem.rockDrone.mul, key = u.mem.rockDrone.key;
assert.ok(h.runUntil(() => !bodyInKeys(target, u.rangeKeySet), 10));
u.skill.stop();
assert.equal(u.profile[key], saved);
assert.equal(u.mem.rockDrone, null); assert.equal(u.mem.rockLock, null);
h.run(2); const t = h.b.time; h.run(4);
assert.equal(hits(h, u, target, t).length, 0);
done(h);
});
test(`${id} S2: caster combat death clears the released state`, () => {
const { h, u, target } = setup(id, { killer: true, staysOutside: true });
assert.ok(h.runUntil(() => !u.alive, 12));
assert.equal(u.skill.active, false); assert.equal(u.mem.rockDrone, null); assert.equal(u.mem.rockLock, null);
h.run(2); const t = h.b.time; h.run(3);
assert.equal(hits(h, u, target, t).length, 0);
done(h);
});
test(`${id} S2: live ASPD controls drone cadence; overload uses the live trait cap`, () => {
const { h, u } = setup(id, { stationaryLock: true });
const f = { ...u.profile.funnel }, scales = [];
h.b.on('damaged', c => { if (c.source === u && drone(c)) scales.push({ t: h.b.time, scale: c.amount / (u.s.atk * u.s.atkScaleMul) }); });
h.run(4);
h.b.addBuff(u, { key: 'test:aspd', mods: { aspd: 100 } });
const interval = u.s.interval, t = h.b.time;
h.run(4);
const fast = scales.filter(c => c.t > t + 1.3);
assert.ok(fast.length > 3);
fast.slice(1).forEach((c, i) => approx(c.t - fast[i].t, Math.ceil(interval * 30 - 1e-9) / 30));
h.run(15);
approx(scales.at(-1).scale, f.max * u.def.skill.bb.scale);
u.skill.stop();
assert.deepEqual(u.profile.funnel, f);
assert.ok(u.s.flags.stun, 'stopping during overload keeps the existing post-overload stun');
done(h);
});
}
for (const moduleId of ['none', 'uniequip_002_rockr']) {
test(`elite S2 ${moduleId}: the resolved trait supplies initial ramp and increment`, () => {
const { h, u, target } = setup('chess_char_2_10_b', { moduleId, stationaryLock: true });
const f = u.profile.funnel;
h.run(3);
hits(h, u, target, 0).filter(drone).forEach((c, i) => approx(c.amount, u.s.atk * u.s.atkScaleMul * (f.init + i * f.delta)));
assert.equal(f.init, moduleId === 'none' ? 0.2 : 0.35);
done(h);
});
}
for (const sameTarget of [false, true]) {
test(`S2: a pre-skill projectile at ${sameTarget ? 'the same' : 'another'} target cannot change the released ramp`, () => {
const h = makeBattle({ seed: 5, autoFinish: false, captureNoisy: true, hooks: ['damaged', 'attack'],
defs: { enemies: { old: enemyRec({ key: 'old', hp: 1e7, speed: 0, motion: 'FLY' }),
preferred: enemyRec({ key: 'preferred', hp: 1e7, speed: 0, motion: 'FLY', taunt: 5 }) } },
units: [{ chessId: 'chess_char_2_10_a', row: 10, col: 4, carryState: { sp: 0 } }],
enemies: [{ key: 'old', pos: [10, 6] }],
});
h.step(); // launch an ordinary shot, which has not landed yet
const u = h.unit('chess_char_2_10_a'), old = h.enemies()[0];
assert.equal(h.hooksOf('attack').length, 1); assert.equal(h.hooksOf('damaged').length, 0);
const target = sameTarget ? old : h.spawn('preferred', { pos: [10, 5] });
h.step(); // update the spatial index for the second target
u.skill.gainSp(999); u.skill.activate('test');
assert.equal(u.mem.rockLock, target);
h.run(2);
assert.ok(hits(h, u, old, 0).some(c => c.dmg.isAttack), 'the old shot really lands');
const ds = hits(h, u, target, 0).filter(drone);
assert.ok(ds.length >= 3);
ds.slice(0, 3).forEach((c, i) => approx(c.amount, u.s.atk * u.s.atkScaleMul * (u.profile.funnel.init + i * u.profile.funnel.delta)));
done(h);
});
}
test('S2: the shared targetability filter releases an unselectable lock and acquires a legal candidate', () => {
const { h, u, target } = setup('chess_char_2_10_a', { newcomer: true });
assert.ok(h.runUntil(() => h.enemies().length === 2, 10));
const other = h.enemies().find(e => e !== target);
h.b.addBuff(target, { key: 'test:untargetable', flags: { untargetable: true } });
h.run(2); const t = h.b.time; h.run(2);
assert.equal(hits(h, u, target, t).length, 0);
assert.equal(u.mem.rockLock, other);
assert.ok(hits(h, u, other, t).some(drone));
done(h);
});
for (const [id, moduleId] of [['chess_char_2_10_a', undefined], ['chess_char_2_10_b', 'none'], ['chess_char_2_10_b', 'uniequip_002_rockr']]) {
test(`${id} ${moduleId ?? 'normal'}: skill data, DEFAULT trigger, range and talent remain intact`, () => {
for (const skillIndex of [0, 1]) {
const raw = getDefaultSource().getChess(id, { skillIndex, moduleId }).raw;
const h = makeBattle({ seed: 5, autoFinish: false, timeLimit: 100, captureNoisy: true,
defs: { enemies: { e: enemyRec({ key: 'e', hp: 1e7, speed: 0 }) } },
units: [{ chessId: id, row: 10, col: 4, skillIndex, moduleId, carryState: { sp: 999 } }],
});
h.run(2);
const u = h.unit(id);
assert.equal(u.skill.active, false, 'DEFAULT waits for a target despite full SP');
assert.deepEqual([u.skill.id, u.skill.rule, u.skill.spCost, u.skill.initSp, u.skill.duration, u.skill.spType],
[raw.skill.skillId, raw.skill.trigger.rule, raw.skill.spCost, raw.skill.initSp, raw.skill.duration, 'time']);
assert.deepEqual(u.liveRangeGrid, raw.rangeGrid);
assert.deepEqual([u.profile.canHitFly, u.profile.dmgType, u.profile.attack, u.s.blockCnt], [true, 'arts', 'ranged', 1]);
h.spawn('e', { pos: [10, 6] });
assert.ok(h.runUntil(() => u.skill.active, 3));
approx(u.s.aspd - u.base.aspd, raw.skill.bb.attack_speed);
const t = raw.talents.find(v => v.index === 0).bb;
h.runUntil(() => h.b.time >= 15.1, 20);
approx(u.findBuff('rockr:rock').stacks, 1);
approx(u.findBuff('rockr:rock').mods.atkPct, t.atk);
u.skill.stop();
assert.deepEqual(u.liveRangeGrid, raw.rangeGrid);
h.runUntil(() => h.b.time >= t.interval * (t.max_stack_cnt + 1), 80);
assert.equal(u.findBuff('rockr:rock').stacks, t.max_stack_cnt);
done(h);
}
});
}
@@ -0,0 +1,61 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
import { rotateOffset } from '../../server/sim/dir.js';
const CASES = [
['char_1043_leizi2', 1, [0, 3]],
['char_1044_hsgma2', 2, [0, 2]],
['char_1015_aglna2', 2, [1, -1]],
];
function field(charId, skillIndex, { tier = 6, elite = true, dir = 'RIGHT', cooldown = 0 } = {}) {
const h = makeBattle({ units: [{ uid: 1, diy: { slot: tier, charId, skillIndex }, elite, row: 10, col: 5, dir }],
defs: { enemies: { dummy: enemyRec({ key: 'dummy', hp: 1e9, atk: 0, speed: 0 }),
flyer: enemyRec({ key: 'flyer', hp: 1e9, atk: 0, speed: 0, motion: 'FLY' }) } },
flags: { startOpCooldown: cooldown }, autoFinish: false, timeLimit: 90 });
h.step();
return { h, u: h.unit(1) };
}
for (const [charId, skillIndex, delta] of CASES) {
test(`${charId}: all DIY forms cast for a target in the actual skill area outside normal targeting`, () => {
for (const tier of [5, 6]) for (const elite of [false, true]) for (const dir of ['UP', 'RIGHT', 'DOWN', 'LEFT']) {
for (const key of ['dummy', 'flyer']) {
const { h, u } = field(charId, skillIndex, { tier, elite, dir });
// Leizi's terrain flood and Hoshiguma's x-2 are symmetric; Angelina's rear diagonal follows her facing.
const [dr, dc] = charId === 'char_1015_aglna2' ? rotateOffset(...delta, dir) : delta;
h.spawn(key, { pos: [10 + dr, 5 + dc] });
u.skill.addCharge(1); h.step();
assert.equal(u.skill.activations, 1, `${charId}/${tier}/${elite}/${dir}/${key}`);
assert.equal(h.b.errors.length, 0); checkInvariants(h.b);
}
}
});
test(`${charId}: expanded auto-cast keeps targetability and operation cooldown`, () => {
for (const flag of ['stealth', 'untargetable', 'sleep']) {
const { h, u } = field(charId, skillIndex);
const e = h.spawn('flyer', { pos: [10 + delta[0], 5 + delta[1]] });
h.b.addBuff(e, { key: 'test:hidden', flags: { [flag]: true } });
u.skill.addCharge(1); h.run(0.5);
assert.equal(u.skill.activations, 0, flag);
h.b.removeBuff(e, 'test:hidden'); h.step();
assert.equal(u.skill.activations, 1, `${flag} removed`);
}
const { h, u } = field(charId, skillIndex, { cooldown: 3 });
h.spawn('flyer', { pos: [10 + delta[0], 5 + delta[1]] });
u.skill.addCharge(1); h.run(1);
assert.equal(u.skill.activations, 0);
h.runUntil(() => u.skill.activations > 0, 3);
assert.equal(u.skill.activations, 1);
assert.ok(u.skill.lastStart >= 3);
});
}
test('Leizi S2 uses low-terrain reachability and permanent range extension, without changing ordinary range', () => {
for (const extended of [false, true]) {
const { h, u } = field('char_1043_leizi2', 1);
if (extended) h.b.addBuff(u, { key: 'test:range', mods: { rangeExtend: 1 }, persist: true });
h.spawn('flyer', { pos: [10, 9] }); u.skill.addCharge(1); h.step();
assert.equal(u.skill.activations, extended ? 1 : 0);
}
});
+2
View File
@@ -511,6 +511,7 @@ test('band map characters: spawnMapChar puts 预备干员-医疗 at its stage sl
assert.ok(med);
assert.deepEqual([med.tileR, med.tileC], [10, 2]);
const gu = h.unit('test_guard');
assert.ok(med.aggroSeq > gu.aggroSeq, 'an ad-hoc map character keeps ordinary summon priority');
gu.hp = 5000;
h.run(4);
assert.ok(gu.hp > 5000, 'healed');
@@ -518,6 +519,7 @@ test('band map characters: spawnMapChar puts 预备干员-医疗 at its stage sl
h.step();
const touch = spawnMapChar(h.b, 'p1', TOKEN_IDS.touch);
assert.ok(touch);
assert.ok(touch.aggroSeq > gu.aggroSeq, 'an ad-hoc Touch also keeps ordinary summon priority');
touch.skill.activate('test', { free: true });
gu.hp = 3000;
h.run(4);
+94
View File
@@ -0,0 +1,94 @@
// 追凶's first damage instance also carries its arts addition (PRTS 忍冬, talent remarks).
// Real full-content battles with controlled durable enemies; no HP/SP/ATK writes or forced casts.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
const NORMAL = 'chess_char_3_18_a';
const ELITE = 'chess_char_3_18_b';
const ENEMY = 'enemy_vulpis_probe';
const approx = (a, b) => assert.ok(Math.abs(a - b) < 1e-6, `${a} vs ${b}`);
const hunt = (c) => c.dmg?.tags?.includes('vulpisHunt');
const finished = (h) => { assert.deepEqual(h.b.errors, []); checkInvariants(h.b); };
function scenario(entry = {}, enemy = {}) {
return makeBattle({
units: [{ chessId: NORMAL, row: 10, col: 4, ...entry }],
defs: { enemies: { [ENEMY]: enemyRec({ key: ENEMY, hp: 1e7, speed: 0, ...enemy }) } },
autoFinish: false, timeLimit: 40, flags: { startOpCooldown: 3 }, hooks: ['damaged'], captureNoisy: true,
});
}
const variants = [
{ label: 'normal', chessId: NORMAL },
{ label: 'elite without module', chessId: ELITE, moduleId: 'none' },
{ label: 'elite SOL-X', chessId: ELITE, moduleId: 'uniequip_002_vulpis' },
{ label: 'elite SOL-Y', chessId: ELITE, moduleId: 'uniequip_003_vulpis' },
];
for (const { label, ...entry } of variants) for (const skillIndex of [0, 1, 2]) {
test(`追凶: first ordinary hit includes one arts addition (${label}, S${skillIndex + 1})`, () => {
const h = scenario({ ...entry, skillIndex });
h.step();
const u = h.unit(entry.chessId);
if (entry.moduleId) assert.equal(u.def.loadout.moduleId, entry.moduleId, 'the requested module is equipped');
const e = h.spawn(ENEMY, { pos: [10, 5] });
const hp = e.hp;
assert.ok(h.runUntil(() => h.hooksOf('damaged').some((c) => c.source === u), 1));
const hits = h.hooksOf('damaged').filter((c) => c.source === u);
const main = hits.filter((c) => !hunt(c));
assert.equal(main.length, 1, 'one ordinary attack');
assert.equal(main[0].dmg.isSkill, false, 'no skill at the first hit');
assert.equal(main[0].type, 'phys');
assert.ok(e.alive && e.hp > 0, 'the first attack did not kill the target');
const riders = hits.filter(hunt);
assert.equal(riders.length, 1, 'the very first hit carries 追凶');
assert.equal(riders[0].t, main[0].t);
assert.equal(riders[0].type, 'arts');
assert.ok(riders[0].dmg.tags.includes('addition'));
approx(riders[0].amount, u.s.atk * u.def.talents[0].bb.atk_scale);
approx(hp - e.hp, main[0].amount + riders[0].amount);
assert.equal(h.eventsOf('dmg').filter((c) => c[1] === e.id).length, 2, 'both actual damage events exist');
finished(h);
});
}
for (const chessId of [NORMAL, ELITE]) for (const skillIndex of [0, 1, 2]) {
test(`追凶: first skill damage carries its addition (${chessId}, S${skillIndex + 1})`, () => {
const h = scenario({ chessId, skillIndex });
h.run(16); // every selected skill is naturally ready, with no enemies yet
const u = h.unit(chessId);
const e = h.spawn(ENEMY, { pos: [10, skillIndex === 1 ? 7 : skillIndex === 2 ? 6 : 5] });
const hp = e.hp;
assert.ok(h.runUntil(() => h.hooksOf('damaged').some((c) => c.source === u && c.dmg.isSkill), 2));
const hits = h.hooksOf('damaged').filter((c) => c.source === u && c.target === e);
const main = hits.filter((c) => !hunt(c));
assert.ok(main.some((c) => c.dmg.isSkill));
// S1 has a physical attack and a separate arts skill instance; S2/S3 have one main instance each.
assert.equal(main.length, skillIndex === 0 ? 2 : 1);
const riders = hits.filter(hunt);
assert.equal(riders.length, main.length, 'one addition per qualifying damage instance, including the first');
for (const c of riders) approx(c.amount, u.s.atk * u.def.talents[0].bb.atk_scale);
approx(hp - e.hp, hits.reduce((s, c) => s + c.amount, 0));
assert.ok(e.alive && e.hp > 0);
finished(h);
});
}
test('追凶: each enemy starts its own fixed window; repeated damage does not extend it', () => {
const h = scenario();
h.step();
const u = h.unit(NORMAL);
const e = h.spawn(ENEMY, { pos: [10, 5] });
h.run(12);
const main = h.hooksOf('damaged').filter((c) => c.source === u && c.target === e && !hunt(c));
const riders = h.hooksOf('damaged').filter((c) => c.source === u && c.target === e && hunt(c));
assert.ok(main.some((c) => c.t > main[0].t + 10));
assert.equal(riders.length, main.filter((c) => c.t - main[0].t <= 10 + 1e-9).length);
assert.ok(riders.every((c) => c.t - main[0].t <= 10 + 1e-9));
const another = h.spawn(ENEMY, { pos: [10, 6] });
// Naturally ready S3 can reach both enemies; the new target gets its own first-hit addition.
assert.ok(h.runUntil(() => h.hooksOf('damaged').some((c) => c.target === another), 10));
const first = h.hooksOf('damaged').filter((c) => c.target === another);
assert.equal(first.filter(hunt).length, 1);
assert.equal(first.filter(hunt)[0].t, first.find((c) => !hunt(c)).t);
finished(h);
});
+37
View File
@@ -0,0 +1,37 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, chessRec, checkInvariants } from '../helpers/battleHarness.js';
import * as enemiesMod from '../../server/sim/content/enemies.js';
import { canTargetEnemy } from '../../server/sim/targeting.js';
const grid = [[0, 0], [0, 1], [0, 2], [0, 3], [0, 4]];
for (const rule of ['DEFAULT', 'ACTIVE_RANGE', 'SKILL_RANGE', 'SP_FULL']) test(`Xi post-rebirth invincibility prevents attacks; ${rule} retains its own trigger semantics`, () => {
const h = makeBattle({ content: 'generic', extraContent: [enemiesMod], autoFinish: false, captureNoisy: true,
defs: { chess: { probe: chessRec({ id: 'probe', profession: 'SNIPER', projectile: 'none',
stats: { atk: 10, maxHp: 1e7, bat: 1, blockCnt: 0 }, rangeGrid: grid,
skill: { spCost: 2, initSp: 0, duration: 0, trigger: { rule, grid } } }) } },
kits: { probe: () => ({ skill: { duration: 0, spCost: 2, initSp: 0, trigger: { rule, grid }, onStart() {} } }) },
units: [{ chessId: 'probe', row: 10, col: 4 }], enemies: [{ key: 'enemy_1517_xi', pos: [10, 7], mods: { speedMul: 0 } }],
});
h.step(); const u = h.unit('probe'), e = h.enemy('enemy_1517_xi');
h.b.kill(e, null); h.run(4.8);
let arrival = null;
h.b.addProjectile({ from: u, target: e, speed: 3, onHit: ({ battle, target }) => {
arrival = battle.dealDamage(u, target, { type: 'true', amount: 1000 });
} });
h.run(0.4);
assert.equal(e.form, 'form2'); assert.ok(e.s.flags.invulnerable);
assert.equal(canTargetEnemy(u, e, u.profile), false);
const attacks = u.stats.attacks, casts = u.skill.activations, hp = e.hp;
h.run(3);
assert.equal(u.stats.attacks, attacks);
if (rule === 'DEFAULT' || rule === 'ACTIVE_RANGE') assert.equal(u.skill.activations, casts);
else assert.ok(u.skill.activations > casts, 'presence-only and SP-only skills are not forcibly suppressed');
assert.equal(arrival, 0, 'an already flying projectile still does no damage');
assert.equal(h.b.dealDamage(u, e, { type: 'arts', amount: 1000 }), 0);
assert.equal(h.b.dealDamage(u, e, { type: 'element', element: 'neural', amount: 1000 }), 0);
assert.equal(e.hp, hp);
h.run(7.2);
assert.equal(canTargetEnemy(u, e, u.profile), true); assert.ok(u.stats.attacks > attacks);
checkInvariants(h.b); assert.deepEqual(h.b.errors, []);
});
+2 -2
View File
@@ -290,8 +290,8 @@ test('lost models, live LP, detail card order, static game data (user playtest #
assert.match(DESIGN, /official `eventTypeIcon` \(`detailPanel\.js garrisonTypeIconKey`/);
assert.match(DESIGN, /pendingLp\? \/\* COMBAT \/ 联防 of a normal round/);
assert.match(DESIGN, /`ownLeaks\(local, server\)`/);
assert.match(PLAYING, /顶栏的目标生命值会\*\*立即\*\*显示扣除后的数值/);
assert.match(README, /漏怪时顶栏的目标生命值实时减少/);
assert.match(PLAYING, /顶栏的目标生命值\*\*先不变\*\*.*结算时才显示扣除后的数值.*GitHub #479/);
assert.match(README, /漏怪时顶栏用红字 \+ 「−N」标记待扣的目标生命值/);
});
test('user playtest #4 (DESIGN §18): picking by tile, timers, 机变 two taps, down / element state, content — code and every doc agree', () => {
+26
View File
@@ -12,6 +12,7 @@ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
import { dirname, join, normalize } from 'node:path';
import { fileURLToPath } from 'node:url';
import { designText, DESIGN_DIRS } from './helpers/designDocs.js';
import { PLAYER_MARKDOWN, rewritePlayerDoc, selectTracked, trackedFiles } from '../tools/package.mjs';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
/** Each document with the least number of paths it names (a guard against an extraction that finds nothing). */
@@ -141,3 +142,28 @@ test('DESIGN: every "DESIGN §N" cited in the repository (code comments, tests,
assert.ok(cited >= 700, `only ${cited} citations found — is the scan broken?`);
assert.deepEqual(bad, [], 'citations of a section or subsection with no heading');
});
test('shipped markdown: a source link exists or is releases/latest, and the packaged copy only links to shipped files', () => {
const shipped = new Set(selectTracked(trackedFiles(ROOT)).keep);
const version = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version;
assert.ok(PLAYER_MARKDOWN.length >= 6, PLAYER_MARKDOWN.join(' '));
for (const rel of PLAYER_MARKDOWN) {
const md = readFileSync(join(ROOT, rel), 'utf8');
for (const m of md.matchAll(/\]\(([^)\s]+)\)/g)) {
const href = m[1];
if (/^(?:[a-z]+:|#)/i.test(href)) continue;
const raw = href.replace(/#.*$/, '');
const norm = normalize(join(dirname(rel), raw)).split('\\').join('/');
if (norm.startsWith('..')) assert.match(norm, /^(?:\.\.\/)+releases\/latest$/, `${rel} → ${href}`);
else assert.ok(existsSync(join(ROOT, norm)), `${rel} → ${href}`);
}
const next = rewritePlayerDoc(md, rel, shipped, version);
for (const m of next.matchAll(/\]\(([^)\s]+)\)/g)) {
const href = m[1];
if (/^(?:[a-z][a-z0-9+.-]*:|#)/i.test(href)) continue;
const raw = href.replace(/#.*$/, '');
const norm = normalize(join(dirname(rel), raw)).split('\\').join('/');
assert.ok(shipped.has(norm), `${rel} packaged link ${href} (${norm}) is not in the zip`);
}
}
});
+152 -153
View File
@@ -93,33 +93,33 @@
"round": 5,
"seed": 4108057433,
"waveId": "act1autochess_05",
"time": 51.27,
"ticks": 1538,
"time": 48.13,
"ticks": 1444,
"reason": "cleared",
"killed": 21,
"total": 21,
"unspawned": 0,
"bossHpLeft": null,
"rngDraws": 28,
"rngDraws": 27,
"errors": {"count":0,"kinds":[]},
"players": {"p1":{"killed":21,"total":21,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":68325,"bossDmg":0,"heal":0,"deaths":0,"layerGains":{}}},
"units": [
["p1",1,"op","chess_char_3_12_a",2,null,1393.34,1,3,6,11192,1174,0,24,1],
["p1",1,"op","chess_char_3_12_a",2,null,1529.25,1,3,6,7131,1038,0,19,1],
["p1",2,"op","chess_char_1_06_a",1,null,1250.7,1,4,2,2639,0,0,12,1],
["p1",3,"op","chess_char_1_13_a",1,null,1004.3,1,6,4,2502,0,0,11,1],
["p1",4,"op","chess_char_3_08_a",1,null,1753.4,1,8,1,8314,0,0,10,1],
["p1",5,"op","chess_char_3_09_a",1,null,1393.7,1,9,0,15349,0,0,26,1],
["p1",6,"op","chess_char_3_10_a",1,null,1815,1,10,0,7744,0,0,8,1],
["p1",7,"op","chess_char_3_11_a",1,null,1447.6,1,5,8,15366,0,0,18,2],
["p1",8,"op","chess_char_3_13_a",1,null,1170.4,1,7,0,5220,0,0,29,6],
["p1",7,"op","chess_char_3_11_a",1,null,1447.6,1,5,7,12933,0,0,16,2],
["p1",8,"op","chess_char_3_13_a",1,null,1170.4,1,7,1,11714,0,0,29,6],
["-","~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_1249_lysdb_2":[5,5,0,168,27000],"enemy_1251_lysyta":[15,15,0,36,37125],"enemy_1328_cbjedi":[1,1,0,970,9000]},
"hooks": [1,31,1538,152,152,197,192,0,21,21,14,13,0,8070,8,11,21,0,1,21,0,0,0],
"events": {"atk":171,"deploy":8,"die":21,"dmg":192,"engage":8,"fx":38,"skill":27,"spawn":31,"status":15},
"enemies": {"enemy_1249_lysdb_2":[5,5,0,56,27000],"enemy_1251_lysyta":[15,15,0,12,37125],"enemy_1328_cbjedi":[1,1,0,970,9000]},
"hooks": [1,31,1444,137,137,208,203,0,21,21,14,13,0,7412,7,9,21,0,1,21,0,0,0],
"events": {"atk":156,"deploy":8,"die":21,"dmg":203,"engage":8,"fx":38,"skill":27,"spawn":31,"status":14},
"fx": {"aoe":1,"displace":13,"pull":13,"shieldBreak":5,"strike":2,"taunt":1,"volley":2,"zone":1},
"snaps": "81dd16a7 8822f255 d9175ef3 a516292e 44ea0bd2 1e3df8ec"
"snaps": "81dd16a7 afef1090 f721859c df68d4ec 22578dbf"
},
"bond-deputShip-high": {
"about": "助力 high: tier 2 layers 999; chess_char_4_11_b chess_char_1_06_b chess_char_1_13_b chess_char_2_14_b chess_char_1_06_a chess_char_1_13_a chess_char_4_10_b chess_char_4_12_b token_10041_cathy_catsld token_10041_cathy_catsld",
@@ -257,31 +257,31 @@
"round": 5,
"seed": 3165180428,
"waveId": "act1autochess_05",
"time": 64.67,
"ticks": 1940,
"time": 64.27,
"ticks": 1928,
"reason": "cleared",
"killed": 17,
"total": 17,
"unspawned": 0,
"bossHpLeft": null,
"rngDraws": 87,
"rngDraws": 97,
"errors": {"count":0,"kinds":[]},
"players": {"p1":{"killed":17,"total":17,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":90450,"bossDmg":0,"heal":395,"deaths":2,"layerGains":{}}},
"players": {"p1":{"killed":17,"total":17,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":90450,"bossDmg":0,"heal":362,"deaths":2,"layerGains":{}}},
"units": [
["p1",1,"op","chess_char_4_01_a",2,null,2901.8,1,4,0,479,0,0,3,0],
["p1",2,"op","chess_char_4_04_a",1,null,1936,1,6,2,9803,0,0,13,2],
["p1",1,"op","chess_char_4_01_a",2,null,2901.8,1,4,0,1278,0,0,8,0],
["p1",2,"op","chess_char_4_04_a",1,null,1936,1,6,1,11213,0,0,19,2],
["p1",3,"op","chess_char_2_14_a",1,null,1355.2,1,1,0,0,0,0,0,0],
["p1",4,"op","chess_char_3_10_a",1,null,1815,1,2,0,0,0,0,0,0],
["p1",5,"op","chess_char_3_20_a",1,null,1233.1,1,3,1,5410,0,0,25,1],
["p1",6,"op","chess_char_3_21_a",2,null,0,0,7,4,28473,1622,122,43,1],
["p1",7,"op","chess_char_4_02_a",2,null,1570.8,1,5,3,26148,0,0,18,1],
["p1",8,"op","chess_char_4_06_a",1,null,0,0,8,7,20136,1644,273,40,1]
["p1",5,"op","chess_char_3_20_a",1,null,1233.1,1,3,3,11450,0,0,24,1],
["p1",6,"op","chess_char_3_21_a",2,null,0,0,7,6,23766,1606,106,38,1],
["p1",7,"op","chess_char_4_02_a",2,null,1570.8,1,5,4,23679,0,0,18,1],
["p1",8,"op","chess_char_4_06_a",1,null,0,0,8,3,19064,1627,256,38,1]
],
"enemies": {"enemy_10042_prtrop":[4,4,0,1099,21600],"enemy_10042_prtrop_2":[2,2,0,1706,14400],"enemy_10043_sailor":[11,11,0,461,54450]},
"hooks": [1,25,1940,162,162,235,235,373,19,19,6,5,0,7198,16,10,17,0,1,19,0,0,0],
"events": {"atk":190,"deploy":8,"die":19,"dmg":234,"engage":6,"fx":32,"skill":11,"spawn":25,"status":43},
"fx": {"crit":9,"displace":6,"ripple":7,"shadowWeave":5,"shield":2,"shieldBreak":1,"stealth":2},
"snaps": "6de03b40 4dd7bab5 7a43012b 232aff6d fa4140b1 96914be3 e3a548b2"
"enemies": {"enemy_10042_prtrop":[4,4,0,1429,21600],"enemy_10042_prtrop_2":[2,2,0,1606,14400],"enemy_10043_sailor":[11,11,0,197,54450]},
"hooks": [1,25,1928,162,162,247,247,342,19,19,6,5,0,7171,19,8,17,0,1,19,0,0,0],
"events": {"atk":171,"deploy":8,"die":19,"dmg":246,"engage":6,"fx":65,"skill":11,"spawn":25,"status":48},
"fx": {"crit":9,"displace":5,"drone":34,"ripple":7,"shadowWeave":5,"shield":2,"shieldBreak":1,"stealth":2},
"snaps": "6de03b40 bcb3ddb5 0d638656 71757fb2 ed5fc1b0 be14aa0b 29f78bf7"
},
"bond-emptyShip-high": {
"about": "协防干员 high: tier 1 layers 999; chess_char_4_17_b chess_char_4_18_b chess_char_4_19_b chess_char_4_22_b chess_char_2_14_b chess_char_3_10_b chess_char_4_20_b chess_char_4_21_b",
@@ -623,25 +623,25 @@
"total": 17,
"unspawned": 0,
"bossHpLeft": null,
"rngDraws": 9,
"rngDraws": 13,
"errors": {"count":0,"kinds":[]},
"players": {"p1":{"killed":17,"total":17,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":177984,"bossDmg":0,"heal":2132,"deaths":0,"layerGains":{}}},
"units": [
["p1",1,"op","chess_char_1_02_b",1,"uniequip_002_yak",4680.5,1,2,0,0,0,0,0,0],
["p1",2,"op","chess_char_5_11_b",1,"uniequip_002_demkni",6111.11,1,1,0,0,0,1531,0,4],
["p1",3,"op","chess_char_2_05_b",1,"uniequip_002_harold",17101.41,1,4,0,0,50,600,7,1],
["p1",4,"op","chess_char_3_11_b",1,"uniequip_002_snhunt",2356.25,1,7,10,115541,1041,0,21,9],
["p1",5,"op","chess_char_3_14_b",1,"uniequip_002_slbell",2170.63,1,5,1,3944,50,0,1,1],
["p1",6,"op","chess_char_3_20_b",1,"uniequip_002_kjera",1993.75,1,8,0,5820,50,0,7,1],
["p1",4,"op","chess_char_3_11_b",1,"uniequip_002_snhunt",2356.25,1,7,10,108776,1041,0,21,9],
["p1",5,"op","chess_char_3_14_b",1,"uniequip_002_slbell",2170.63,1,5,1,840,50,0,1,1],
["p1",6,"op","chess_char_3_20_b",1,"uniequip_002_kjera",1993.75,1,8,0,16312,50,0,7,1],
["p1",7,"op","chess_char_4_13_b",1,"uniequip_002_gnosis",2684.88,1,3,1,1414,50,0,1,2],
["p1",8,"op","chess_char_5_12_b",0,"uniequip_002_dusk",2407.13,1,6,5,48718,1141,0,10,10],
["p1",8,"op","chess_char_5_12_b",0,"uniequip_002_dusk",2407.13,1,6,5,48094,1141,0,10,10],
["p1","~1","token","token_10015_dusk_drgn",null,null,0,0,12,0,2547,1997,0,8,null]
],
"enemies": {"enemy_1267_nhpbr_2":[11,11,0,2135,69696],"enemy_1270_nhstlk_2":[4,4,0,0,73728],"enemy_1272_nhtank_2":[2,2,0,2243,34560]},
"hooks": [1,26,2174,65,65,115,115,12,18,18,28,28,0,7475,1579,3,17,0,1,18,0,0,0],
"events": {"atk":69,"deploy":9,"die":18,"dmg":115,"engage":6,"fx":40,"heal":12,"skill":56,"spawn":26,"status":20},
"fx": {"coldWind":2,"frostNova":2,"healAoe":4,"strike":9,"summon":1,"volley":9,"zone":13},
"snaps": "cc4cf53b 9139c2c7 206b1332 d27de872 77344198 6c0c2544 4d5c5c01 7258305b"
"hooks": [1,26,2174,65,65,121,121,12,18,18,28,28,0,7475,1579,3,17,0,1,18,0,0,0],
"events": {"atk":65,"deploy":9,"die":18,"dmg":121,"engage":6,"fx":48,"heal":12,"skill":56,"spawn":26,"status":20},
"fx": {"coldWind":2,"drone":8,"frostNova":2,"healAoe":4,"strike":9,"summon":1,"volley":9,"zone":13},
"snaps": "cc4cf53b 9139c2c7 206b1332 d27de872 bcb0ffd8 0b210404 84dba741 37d26448"
},
"bond-lateranoShip-low": {
"about": "拉特兰 low: tier 1 layers 1; chess_char_3_06_a chess_char_3_05_a chess_char_1_01_a chess_char_2_01_a chess_char_3_01_a chess_char_3_08_a chess_char_3_09_a chess_char_3_10_a",
@@ -724,34 +724,34 @@
"round": 5,
"seed": 1709081824,
"waveId": "act1autochess_05",
"time": 46.1,
"ticks": 1383,
"time": 45.7,
"ticks": 1371,
"reason": "cleared",
"killed": 53,
"total": 16,
"unspawned": 0,
"bossHpLeft": null,
"rngDraws": 19,
"rngDraws": 17,
"errors": {"count":0,"kinds":[]},
"players": {"p1":{"killed":53,"total":16,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":30278,"bossDmg":0,"heal":0,"deaths":0,"layerGains":{}}},
"players": {"p1":{"killed":53,"total":16,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":30448,"bossDmg":0,"heal":0,"deaths":0,"layerGains":{}}},
"units": [
["p1",1,"op","chess_char_3_12_a",2,null,2567.4,1,7,0,0,0,0,0,0],
["p1",2,"op","chess_char_3_16_a",1,null,2390.3,1,8,0,0,0,0,0,0],
["p1",3,"op","chess_char_6_11_a",2,null,1634.6,1,3,6,559,0,0,9,1],
["p1",4,"op","chess_char_1_03_a",1,null,1145.1,1,5,1,3,0,0,6,1],
["p1",5,"op","chess_char_2_04_a",1,null,1074.7,1,10,20,7319,0,0,25,1],
["p1",3,"op","chess_char_6_11_a",2,null,1634.6,1,3,1,1,0,0,9,1],
["p1",4,"op","chess_char_1_03_a",1,null,1145.1,1,5,0,0,0,0,5,1],
["p1",5,"op","chess_char_2_04_a",1,null,1074.7,1,10,16,7786,0,0,24,1],
["p1",6,"op","chess_char_3_11_a",1,null,1447.6,1,11,6,8144,0,0,15,2],
["p1",7,"op","chess_char_3_13_a",1,null,1170.4,1,6,4,4460,0,0,23,6],
["p1",8,"op","chess_char_3_14_a",1,null,1376.1,1,9,6,8729,0,0,22,1],
["p1",9,"token","token_10030_mlyss_wtrman",null,null,936.9,1,4,3,1064,0,0,7,1],
["p1",7,"op","chess_char_3_13_a",1,null,1170.4,1,6,6,6718,0,0,23,6],
["p1",8,"op","chess_char_3_14_a",1,null,1376.1,1,9,8,6949,0,0,22,1],
["p1",9,"token","token_10030_mlyss_wtrman",null,null,936.9,1,4,5,851,0,0,7,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_1195_sfyin":[11,11,0,0,19800],"enemy_1196_msfyin":[22,22,0,0,44],"enemy_1197_sfshu":[5,5,0,0,11250],"enemy_1198_msfshu":[15,15,0,0,45]},
"hooks": [1,64,1383,107,107,174,174,0,53,53,13,10,0,8024,30,0,53,0,1,53,0,0,0],
"events": {"atk":138,"deploy":9,"die":53,"dmg":174,"engage":7,"fx":25,"skill":23,"spawn":64,"status":44},
"hooks": [1,64,1371,105,105,177,177,0,53,53,13,10,0,7976,31,0,53,0,1,53,0,0,0],
"events": {"atk":135,"deploy":9,"die":53,"dmg":177,"engage":7,"fx":25,"skill":23,"spawn":64,"status":42},
"fx": {"copy":1,"dp":1,"sleep":3,"strike":2,"summon":16,"volley":2},
"snaps": "ccd30115 f5d257e4 89df9ecc 0a718fd3 10bdb391"
"snaps": "ccd30115 c7745b19 5c5ce90e 017253c9 61763456"
},
"bond-maniShip-high": {
"about": "调和 high: tier 1 layers 999; chess_char_5_19_b chess_char_5_17_b chess_char_6_11_b chess_char_1_03_a chess_char_2_04_a chess_char_5_15_b chess_char_5_20_b chess_char_5_21_b token_10030_mlyss_wtrman",
@@ -762,38 +762,37 @@
"round": 10,
"seed": 2327237136,
"waveId": "act1autochess_h03",
"time": 82.1,
"ticks": 2463,
"time": 81.13,
"ticks": 2434,
"reason": "cleared",
"killed": 23,
"total": 23,
"unspawned": 0,
"bossHpLeft": null,
"rngDraws": 178,
"rngDraws": 189,
"errors": {"count":0,"kinds":[]},
"players": {"p1":{"killed":23,"total":23,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":169484,"bossDmg":0,"heal":734,"deaths":0,"layerGains":{}}},
"players": {"p1":{"killed":23,"total":23,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":169484,"bossDmg":0,"heal":350,"deaths":0,"layerGains":{}}},
"units": [
["p1",1,"op","chess_char_5_19_b",2,"uniequip_002_mlynar",4297.7,1,5,3,18276,0,0,14,2],
["p1",2,"op","chess_char_5_17_b",1,"uniequip_002_f12yin",3011.8,1,7,4,24,0,0,23,1],
["p1",3,"op","chess_char_6_11_b",2,"uniequip_002_mlyss",2082.3,1,8,1,19826,0,0,61,2],
["p1",4,"op","chess_char_1_03_a",1,null,1145.1,1,11,2,21914,0,0,31,1],
["p1",5,"op","chess_char_2_04_a",1,null,1074.7,1,12,1,18374,0,0,41,2],
["p1",6,"op","chess_char_5_15_b",1,"uniequip_002_thorn2",1287,1,4,7,53929,0,0,13,6],
["p1",7,"op","chess_char_5_20_b",2,"uniequip_002_aglina",1520.2,1,6,1,3661,0,0,10,2],
["p1",8,"op","chess_char_5_21_b",1,"uniequip_002_sntlla",1791.9,1,10,0,25279,0,0,77,2],
["p1",9,"token","token_10030_mlyss_wtrman",null,null,1041,1,9,3,4736,245,245,31,1],
["p1",1,"op","chess_char_5_19_b",2,"uniequip_002_mlynar",4297.7,1,5,1,17717,0,0,14,2],
["p1",2,"op","chess_char_5_17_b",1,"uniequip_002_f12yin",3011.8,1,7,4,23,0,0,22,1],
["p1",3,"op","chess_char_6_11_b",2,"uniequip_002_mlyss",2082.3,1,8,1,17037,0,0,58,2],
["p1",4,"op","chess_char_1_03_a",1,null,1145.1,1,11,1,21954,0,0,29,1],
["p1",5,"op","chess_char_2_04_a",1,null,1074.7,1,12,2,18244,0,0,41,1],
["p1",6,"op","chess_char_5_15_b",1,"uniequip_002_thorn2",1287,1,4,12,54201,0,0,13,6],
["p1",7,"op","chess_char_5_20_b",2,"uniequip_002_aglina",1520.2,1,6,0,2254,0,0,9,2],
["p1",8,"op","chess_char_5_21_b",1,"uniequip_002_sntlla",1791.9,1,10,0,29583,0,0,74,2],
["p1",9,"token","token_10030_mlyss_wtrman",null,null,1041,1,9,2,4897,0,0,29,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],
["p1","~1","token","token_10030_mlyss_wtrman",null,null,1041,0,24,0,1753,490,490,12,null],
["p1","~2","token","token_10030_mlyss_wtrman",null,null,1041,0,33,0,1710,0,0,10,null],
["p1","~3","token","token_10030_mlyss_wtrman",null,null,1041,1,39,1,2,0,0,2,null]
["p1","~1","token","token_10030_mlyss_wtrman",null,null,1041,0,24,0,2227,350,350,12,null],
["p1","~2","token","token_10030_mlyss_wtrman",null,null,1041,0,35,0,1347,0,0,10,null]
],
"enemies": {"enemy_1288_duskls":[11,11,0,0,63415],"enemy_1288_duskls_2":[7,7,0,0,48419],"enemy_1292_duskld":[5,5,0,734,57650]},
"hooks": [1,38,2463,265,296,481,481,171,23,25,19,15,0,11359,190,29,23,0,1,51,0,0,0],
"events": {"atk":317,"deploy":12,"die":25,"dmg":481,"engage":12,"fx":204,"skill":34,"spawn":38,"status":380},
"fx": {"aoe":2,"copy":1,"crit":4,"dp":2,"ember":23,"iceSpike":63,"palsy":1,"sleep":3,"split":3,"weightless":2,"zone":100},
"snaps": "24b90f66 39c55e4f 51797d0d 8b80519c f3d7297e 8f1b0295 2c03cb7c ef7c22a9 47e39061"
"enemies": {"enemy_1288_duskls":[11,11,0,105,63415],"enemy_1288_duskls_2":[7,7,0,0,48419],"enemy_1292_duskld":[5,5,0,245,57650]},
"hooks": [1,37,2434,253,289,488,488,139,23,25,18,15,0,11300,203,28,23,0,1,50,0,0,0],
"events": {"atk":305,"deploy":11,"die":25,"dmg":488,"engage":11,"fx":204,"skill":33,"spawn":37,"status":416},
"fx": {"aoe":2,"copy":1,"crit":7,"dp":2,"ember":23,"iceSpike":60,"palsy":3,"sleep":3,"split":2,"weightless":2,"zone":99},
"snaps": "24b90f66 4246b2db ffc0a48a e5761418 331d10dd 3e937d45 939ef84f ca1dd79a ff0a278a"
},
"bond-miraShip-low": {
"about": "奇迹 low: tier 1 layers 1; chess_char_4_01_a chess_char_2_11_a chess_char_3_18_a chess_char_3_01_a chess_char_3_17_a chess_char_3_20_a chess_char_3_21_a chess_char_4_02_a",
@@ -811,24 +810,24 @@
"total": 17,
"unspawned": 0,
"bossHpLeft": null,
"rngDraws": 35,
"rngDraws": 55,
"errors": {"count":0,"kinds":[]},
"players": {"p1":{"killed":17,"total":17,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":126144,"bossDmg":0,"heal":0,"deaths":0,"layerGains":{}}},
"units": [
["p1",1,"op","chess_char_4_01_a",2,null,2901.8,1,3,0,0,0,0,0,0],
["p1",2,"op","chess_char_2_11_a",1,null,2257.29,1,6,0,0,0,0,0,0],
["p1",3,"op","chess_char_3_18_a",2,null,1629.1,1,8,0,0,0,0,0,0],
["p1",4,"op","chess_char_3_01_a",2,null,1663.13,1,1,5,24785,0,0,23,1],
["p1",5,"op","chess_char_3_17_a",1,null,2470.6,1,4,4,38664,0,0,32,3],
["p1",6,"op","chess_char_3_20_a",1,null,913.22,1,5,1,6272,320,0,30,1],
["p1",7,"op","chess_char_3_21_a",2,null,1500.4,1,2,4,24122,0,0,21,1],
["p1",8,"op","chess_char_4_02_a",2,null,723.8,1,7,3,32301,847,0,17,1]
["p1",4,"op","chess_char_3_01_a",2,null,1663.13,1,1,2,24889,0,0,22,1],
["p1",5,"op","chess_char_3_17_a",1,null,2470.6,1,4,3,34513,0,0,30,3],
["p1",6,"op","chess_char_3_20_a",1,null,913.22,1,5,4,18617,320,0,30,1],
["p1",7,"op","chess_char_3_21_a",2,null,1500.4,1,2,7,15387,0,0,19,1],
["p1",8,"op","chess_char_4_02_a",2,null,264,1,7,1,32738,1307,0,17,1]
],
"enemies": {"enemy_1116_liprr":[11,11,0,920,69696],"enemy_1116_liprr_2":[4,4,0,247,34560],"enemy_1118_lidbox_2":[2,2,0,0,21888]},
"hooks": [1,25,1569,131,131,278,278,0,17,17,7,6,0,4875,9,8,17,0,1,17,0,0,0],
"events": {"atk":163,"deploy":8,"die":17,"dmg":277,"engage":5,"fx":20,"skill":13,"spawn":25,"status":62},
"fx": {"buff":1,"displace":8,"ripple":5,"shield":2,"shieldBreak":1,"volley":3},
"snaps": "cb2e289d 5504ba16 91f48812 b5b92713 b5b86756 360f3526"
"enemies": {"enemy_1116_liprr":[11,11,0,620,69696],"enemy_1116_liprr_2":[4,4,0,741,34560],"enemy_1118_lidbox_2":[2,2,0,266,21888]},
"hooks": [1,25,1569,126,126,283,283,0,17,17,7,5,0,4873,12,7,17,0,1,17,0,0,0],
"events": {"atk":137,"deploy":8,"die":17,"dmg":283,"engage":5,"fx":55,"skill":12,"spawn":25,"status":62},
"fx": {"buff":1,"displace":7,"drone":37,"ripple":5,"shield":2,"volley":3},
"snaps": "84698390 95c61d5c f4fb1346 74a078bf f8644cfb 64bec820"
},
"bond-miraShip-high": {
"about": "奇迹 high: tier 1 layers 999; chess_char_4_01_b chess_char_2_11_b chess_char_4_04_b chess_char_4_07_b chess_char_3_01_b chess_char_5_22_b chess_char_4_02_b chess_char_4_06_b",
@@ -1092,24 +1091,24 @@
"total": 30,
"unspawned": 0,
"bossHpLeft": null,
"rngDraws": 45,
"rngDraws": 42,
"errors": {"count":0,"kinds":[]},
"players": {"p1":{"killed":30,"total":30,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":262819,"bossDmg":0,"heal":0,"deaths":1,"layerGains":{}}},
"players": {"p1":{"killed":30,"total":30,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":262899,"bossDmg":0,"heal":0,"deaths":1,"layerGains":{}}},
"units": [
["p1",1,"op","chess_char_1_12_b",1,"uniequip_002_estell",2847.9,1,2,0,0,0,0,0,0],
["p1",2,"op","chess_char_2_08_b",1,"uniequip_002_bubble",3710.3,1,3,0,0,0,0,0,0],
["p1",3,"op","chess_char_3_06_b",1,"uniequip_002_philae",3462.8,1,4,0,0,0,0,0,0],
["p1",4,"op","chess_char_4_23_b",2,"uniequip_002_gvial2",1532.04,1,5,16,168462,1560,0,77,1],
["p1",5,"op","chess_char_4_22_b",2,"uniequip_002_svrash",0,0,6,12,52198,2826,0,33,1],
["p1",4,"op","chess_char_4_23_b",2,"uniequip_002_gvial2",2117.06,1,5,15,140375,975,0,70,1],
["p1",5,"op","chess_char_4_22_b",2,"uniequip_002_svrash",0,0,6,10,44559,2826,0,33,1],
["p1",6,"op","chess_char_2_06_b",0,"uniequip_002_papyrs",1802.9,1,7,0,0,0,0,0,0],
["p1",7,"op","chess_char_3_13_b",1,"uniequip_002_malist",1512.5,1,8,2,42159,0,0,46,11],
["p1",7,"op","chess_char_3_13_b",1,"uniequip_002_malist",1512.5,1,8,5,77965,0,0,42,11],
["p1",8,"op","chess_char_4_25_b",2,"uniequip_002_cetsyr",1838.1,1,1,0,0,0,0,0,1]
],
"enemies": {"enemy_1006_shield_3":[3,3,0,2843,138240],"enemy_1043_zomsbr":[23,23,0,0,69875],"enemy_1402_tgshd_2":[4,4,0,1251,59904]},
"hooks": [1,38,2462,165,165,397,597,1388,31,31,14,14,0,6375,0,5,30,0,1,43,0,0,0],
"events": {"atk":207,"deploy":8,"die":31,"dmg":597,"engage":3,"fx":110,"skill":28,"spawn":38,"status":41},
"enemies": {"enemy_1006_shield_3":[3,3,0,2550,138240],"enemy_1043_zomsbr":[23,23,0,0,69635],"enemy_1402_tgshd_2":[4,4,0,1251,59904]},
"hooks": [1,38,2462,153,153,408,408,1297,31,31,14,14,0,6375,0,4,30,0,1,40,0,0,0],
"events": {"atk":183,"deploy":8,"die":31,"dmg":408,"engage":3,"fx":110,"skill":28,"spawn":38,"status":39},
"fx": {"expose":58,"jungleSoul":1,"mote":14,"redistribute":15,"reweave":1,"slash":20,"truesilver":1},
"snaps": "d81207a4 e5f41037 e0b88fa7 7a6a3918 dbdfa4d8 a6f93588 e957bb33 9d7565d2 294af894"
"snaps": "d252a2b9 064fa22f 580a6127 93f56817 48dfb65c c53e2705 8414849f 496b43f5 c88e5e4d"
},
"bond-siracusaShip-low": {
"about": "叙拉古 low: tier 1 layers 1; chess_char_3_06_a chess_char_1_08_a chess_char_2_16_a chess_char_3_02_a chess_char_3_03_a chess_char_3_04_a chess_char_3_05_a chess_char_1_07_a",
@@ -1276,29 +1275,29 @@
"total": 17,
"unspawned": 0,
"bossHpLeft": null,
"rngDraws": 4,
"rngDraws": 6,
"errors": {"count":0,"kinds":[]},
"players": {
"p1": {"killed":17,"total":17,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":126144,"bossDmg":0,"heal":0,"deaths":2,"layerGains":{"siracusaShip":4}}
"p1": {"killed":17,"total":17,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":126144,"bossDmg":0,"heal":0,"deaths":2,"layerGains":{"siracusaShip":6}}
},
"units": [
["p1",1,"op","chess_char_3_16_a",1,null,2987.88,1,3,0,0,0,0,0,0],
["p1",2,"op","chess_char_4_01_a",2,null,4077.11,1,5,2,8508,566,0,10,1],
["p1",3,"op","chess_char_1_08_a",1,null,0,0,6,1,7992,2615,0,13,1],
["p1",4,"op","chess_char_3_18_a",2,null,0,0,10,5,39236,2036,0,38,1],
["p1",5,"op","chess_char_3_19_a",2,null,1978.63,1,7,2,10333,0,0,21,1],
["p1",6,"op","chess_char_3_20_a",1,null,1541.38,1,9,2,682,0,0,8,1],
["p1",7,"op","chess_char_3_21_a",2,null,1875.5,1,11,1,19449,0,0,23,1],
["p1",8,"op","chess_char_4_02_a",2,null,1963.5,1,4,4,38728,0,0,7,1],
["p1",4,"op","chess_char_3_18_a",2,null,0,0,10,5,40927,2036,0,38,1],
["p1",5,"op","chess_char_3_19_a",2,null,1978.63,1,7,3,9846,0,0,20,1],
["p1",6,"op","chess_char_3_20_a",1,null,1541.38,1,9,0,2764,0,0,6,1],
["p1",7,"op","chess_char_3_21_a",2,null,1875.5,1,11,2,17854,0,0,23,1],
["p1",8,"op","chess_char_4_02_a",2,null,1963.5,1,4,4,37037,0,0,7,1],
["p1",9,"token","token_10028_vigil_wolf",null,null,852.9,1,8,0,1216,27,0,2,null],
["-","~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_1116_liprr":[11,11,0,41,69696],"enemy_1116_liprr_2":[4,4,0,297,34560],"enemy_1118_lidbox_2":[2,2,0,4906,21888]},
"hooks": [1,28,1502,150,150,266,266,0,19,19,7,4,3,6742,32,17,17,0,1,23,2,0,0],
"events": {"atk":187,"deploy":9,"die":19,"dmg":264,"engage":8,"fx":39,"layer":2,"skill":11,"spawn":28,"status":98},
"fx": {"aoe":1,"camouflage":1,"counter":3,"displace":17,"dp":2,"garrison":2,"liberate":2,"phase":3,"ripple":4,"sermon":1,"shield":2,"wolfShadow":1},
"snaps": "a9326071 61ede413 67d5b134 ed466837 32fec6af"
"hooks": [1,28,1502,147,147,266,266,0,19,19,7,4,3,6742,29,17,17,0,1,20,3,0,0],
"events": {"atk":178,"deploy":9,"die":19,"dmg":264,"engage":8,"fx":43,"layer":3,"skill":11,"spawn":28,"status":92},
"fx": {"aoe":1,"camouflage":1,"counter":3,"displace":16,"dp":2,"drone":4,"garrison":3,"liberate":2,"phase":3,"ripple":4,"sermon":1,"shield":2,"wolfShadow":1},
"snaps": "44b7a79e a057c22e 1399d939 c2bd253f 9479758a"
},
"bond-soloShip-high": {
"about": "独行 high: tier 1 layers 999; chess_char_5_19_b chess_char_1_08_b chess_char_5_13_b chess_char_5_14_b chess_char_5_17_b chess_char_5_12_b chess_char_5_15_b chess_char_5_20_b",
@@ -1576,8 +1575,8 @@
"round": 5,
"seed": 4091762637,
"waveId": "act1autochess_05",
"time": 55.83,
"ticks": 1675,
"time": 51.33,
"ticks": 1540,
"reason": "cleared",
"killed": 17,
"total": 17,
@@ -1585,16 +1584,16 @@
"bossHpLeft": null,
"rngDraws": 6,
"errors": {"count":0,"kinds":[]},
"players": {"p1":{"killed":17,"total":17,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":123047,"bossDmg":0,"heal":3612,"deaths":0,"layerGains":{}}},
"players": {"p1":{"killed":17,"total":17,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":123085,"bossDmg":0,"heal":2666,"deaths":0,"layerGains":{}}},
"units": [
["p1",1,"op","chess_char_3_06_a",1,null,3577.42,1,6,0,15374,3173,0,15,1],
["p1",2,"op","chess_char_3_02_a",1,null,2102.1,1,7,7,38048,0,0,26,1],
["p1",1,"op","chess_char_3_06_a",1,null,3577.42,1,6,2,8709,2130,0,11,1],
["p1",2,"op","chess_char_3_02_a",1,null,2102.1,1,7,6,32218,0,0,25,1],
["p1",3,"op","chess_char_3_03_a",1,null,1515.8,1,5,0,0,0,0,0,0],
["p1",4,"op","chess_char_3_04_a",1,null,2050.4,1,8,1,28010,0,0,0,0],
["p1",5,"op","chess_char_3_05_a",2,null,5978.83,1,9,4,20606,245,0,18,1],
["p1",6,"op","chess_char_1_06_a",1,null,1250.7,1,3,4,15626,0,1411,30,1],
["p1",7,"op","chess_char_2_05_a",1,null,1552.32,1,4,0,0,0,2201,17,1],
["p1",8,"op","chess_char_2_10_a",1,null,1185.8,1,10,0,5382,0,0,29,1],
["p1",5,"op","chess_char_3_05_a",2,null,5978.83,1,9,3,21417,342,0,18,1],
["p1",6,"op","chess_char_1_06_a",1,null,1250.7,1,3,2,14787,0,1005,26,1],
["p1",7,"op","chess_char_2_05_a",1,null,1552.32,1,4,0,0,0,1661,14,1],
["p1",8,"op","chess_char_2_10_a",1,null,1185.8,1,10,1,17944,0,0,29,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],
["p1","~1","token","token_10031_swire2_gdtrap",null,null,1000,0,11,0,0,0,0,0,null],
@@ -1615,11 +1614,11 @@
["p1","~16","token","token_10031_swire2_gdtrap",null,null,1000,0,43,0,0,0,0,0,null],
["p1","~17","token","token_10031_swire2_gdtrap",null,null,1000,1,44,0,0,0,0,0,null]
],
"enemies": {"enemy_1116_liprr":[11,11,0,192,69696],"enemy_1116_liprr_2":[4,4,0,199,34560],"enemy_1118_lidbox_2":[2,2,0,3027,21888]},
"hooks": [1,44,1675,165,165,298,312,28,17,33,6,2,0,6349,15,14,17,0,1,17,0,0,0],
"events": {"atk":165,"deploy":25,"die":33,"dmg":312,"engage":5,"fx":104,"heal":28,"skill":10,"spawn":44,"status":43},
"fx": {"aoe":5,"coin":18,"counter":4,"explode":16,"liberate":1,"overload":1,"phase":3,"strike":38,"summon":17,"taunt":1},
"snaps": "950a6182 a670e309 094b33b8 126a0a37 d2dc7eef 6d3cae8b"
"enemies": {"enemy_1116_liprr":[11,11,0,108,69696],"enemy_1116_liprr_2":[4,4,0,307,34560],"enemy_1118_lidbox_2":[2,2,0,2057,21888]},
"hooks": [1,44,1540,146,146,307,314,24,17,33,6,2,0,6067,15,14,17,0,1,17,0,0,0],
"events": {"atk":146,"deploy":25,"die":33,"dmg":314,"engage":5,"fx":115,"heal":24,"skill":10,"spawn":44,"status":43},
"fx": {"aoe":3,"coin":17,"counter":4,"drone":23,"explode":16,"liberate":1,"overload":1,"phase":2,"strike":30,"summon":17,"taunt":1},
"snaps": "d1974070 80b823c0 1cdb05ab d65e859c c593343d 34f0b4a2"
},
"bond-victoriaShip-high": {
"about": "维多利亚 high: tier 2 layers 999; chess_char_4_11_b chess_char_4_07_b chess_char_1_06_b chess_char_2_05_b chess_char_2_10_b chess_char_3_08_b chess_char_4_13_b chess_char_4_14_b token_10041_cathy_catsld token_10041_cathy_catsld",
@@ -1630,8 +1629,8 @@
"round": 10,
"seed": 11223320,
"waveId": "act1autochess_h03",
"time": 62.87,
"ticks": 1886,
"time": 48.8,
"ticks": 1464,
"reason": "cleared",
"killed": 12,
"total": 12,
@@ -1639,27 +1638,27 @@
"bossHpLeft": null,
"rngDraws": 0,
"errors": {"count":0,"kinds":[]},
"players": {"p1":{"killed":12,"total":12,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":141088,"bossDmg":0,"heal":6750,"deaths":0,"layerGains":{}}},
"players": {"p1":{"killed":12,"total":12,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":141088,"bossDmg":0,"heal":3250,"deaths":0,"layerGains":{}}},
"units": [
["p1",1,"op","chess_char_4_11_b",1,"uniequip_002_cathy",2908.4,1,5,0,0,0,0,0,0],
["p1",2,"op","chess_char_4_07_b",1,"uniequip_002_bpipe",2621.3,1,7,0,0,0,0,0,0],
["p1",3,"op","chess_char_1_06_b",1,"uniequip_002_vendla",1542.2,1,8,5,28240,0,1739,7,1],
["p1",4,"op","chess_char_2_05_b",1,"uniequip_002_harold",17910.32,1,10,0,0,5469,5011,10,1],
["p1",5,"op","chess_char_2_10_b",1,"uniequip_002_rockr",1546.6,1,11,7,93826,1281,0,42,1],
["p1",3,"op","chess_char_1_06_b",1,"uniequip_002_vendla",1542.2,1,8,0,0,0,0,0,0],
["p1",4,"op","chess_char_2_05_b",1,"uniequip_002_harold",17910.32,1,10,0,0,2609,3250,6,1],
["p1",5,"op","chess_char_2_10_b",1,"uniequip_002_rockr",1546.6,1,11,12,131060,641,0,23,1],
["p1",6,"op","chess_char_3_08_b",1,"uniequip_002_mint",2184.6,1,4,0,0,0,0,0,0],
["p1",7,"op","chess_char_4_13_b",1,"uniequip_002_gnosis",2187.9,1,6,0,0,0,0,0,0],
["p1",8,"op","chess_char_4_14_b",1,"uniequip_002_lionhd",1788.6,1,9,0,19021,0,0,7,6],
["p1",8,"op","chess_char_4_14_b",1,"uniequip_002_lionhd",1788.6,1,9,0,10028,0,0,3,4],
["p1",9,"token","token_10041_cathy_catsld",null,null,100,1,12,0,0,0,0,0,null],
["p1",10,"token","token_10041_cathy_catsld",null,null,100,1,13,0,0,0,0,0,null],
["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null],
["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null],
["-","~3","device","trap_1105_accrate",null,null,100,1,3,0,0,0,0,0,null]
],
"enemies": {"enemy_1005_yokai_2":[4,4,0,1532,15428],"enemy_1041_lazerd_2":[3,3,0,5218,44790],"enemy_1042_frostd":[5,5,0,0,80870]},
"hooks": [1,25,1886,79,79,76,76,12,12,12,9,9,0,4756,266,0,12,0,1,12,0,0,0],
"events": {"atk":79,"deploy":10,"die":12,"dmg":76,"engage":3,"fx":10,"heal":12,"skill":18,"spawn":25,"status":17},
"fx": {"counter":2,"explosion":6,"overload":1,"taunt":1},
"snaps": "baf019b9 04dccd49 7607a313 d5443e4e 65c3d507 5c447217 1a74a703"
"enemies": {"enemy_1005_yokai_2":[4,4,0,641,15428],"enemy_1041_lazerd_2":[3,3,0,2609,44790],"enemy_1042_frostd":[5,5,0,0,80870]},
"hooks": [1,25,1464,38,38,59,59,6,12,12,6,5,0,3288,194,0,12,0,1,12,0,0,0],
"events": {"atk":38,"deploy":10,"die":12,"dmg":59,"engage":2,"fx":28,"heal":6,"skill":11,"spawn":25,"status":17},
"fx": {"drone":23,"explosion":4,"overload":1},
"snaps": "41450a5d 84679dbf e939cdb3 4cc777cb 3c9f9b60"
},
"bond-visiShip-low": {
"about": "远见 low: tier 1 layers 1; chess_char_3_12_a chess_char_3_03_a chess_char_2_02_a chess_char_3_08_a chess_char_3_09_a chess_char_3_10_a chess_char_3_11_a chess_char_3_13_a token_10000_silent_healrb",
@@ -1670,32 +1669,32 @@
"round": 5,
"seed": 3923365115,
"waveId": "act1autochess_05",
"time": 54.2,
"ticks": 1626,
"time": 64.23,
"ticks": 1927,
"reason": "cleared",
"killed": 17,
"total": 17,
"unspawned": 0,
"bossHpLeft": null,
"rngDraws": 36,
"rngDraws": 34,
"errors": {"count":0,"kinds":[]},
"players": {"p1":{"killed":17,"total":17,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":98550,"bossDmg":0,"heal":4275,"deaths":0,"layerGains":{}}},
"players": {"p1":{"killed":17,"total":17,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":98550,"bossDmg":0,"heal":4764,"deaths":2,"layerGains":{}}},
"units": [
["p1",1,"op","chess_char_3_12_a",2,null,2567.4,1,3,0,0,0,0,0,0],
["p1",1,"op","chess_char_3_12_a",2,null,412.94,1,3,2,1572,2154,0,9,1],
["p1",2,"op","chess_char_3_03_a",1,null,1515.8,1,6,0,0,0,0,0,0],
["p1",3,"op","chess_char_2_02_a",1,null,1540,1,1,0,0,0,3047,13,2],
["p1",4,"op","chess_char_3_08_a",1,null,1724.8,1,4,3,17803,29,0,8,1],
["p1",5,"op","chess_char_3_09_a",1,null,1393.7,1,5,3,25124,547,0,26,1],
["p1",6,"op","chess_char_3_10_a",1,null,1815,1,2,2,7065,1716,0,9,1],
["p1",7,"op","chess_char_3_11_a",1,null,1405.8,1,7,6,38190,825,0,25,3],
["p1",8,"op","chess_char_3_13_a",1,null,914,1,8,3,10369,1485,0,36,7],
["p1",9,"token","token_10000_silent_healrb",null,null,1000,1,29,0,0,0,1229,16,null]
["p1",3,"op","chess_char_2_02_a",1,null,1540,1,1,0,0,0,3246,15,2],
["p1",4,"op","chess_char_3_08_a",1,null,1753.4,1,4,3,11114,0,0,8,1],
["p1",5,"op","chess_char_3_09_a",1,null,1393.7,1,5,3,26048,1077,0,32,1],
["p1",6,"op","chess_char_3_10_a",1,null,0,0,2,1,10166,3109,0,9,1],
["p1",7,"op","chess_char_3_11_a",1,null,0,0,7,4,27868,2323,0,20,2],
["p1",8,"op","chess_char_3_13_a",1,null,1170.4,1,8,4,21783,1518,0,34,7],
["p1",9,"token","token_10000_silent_healrb",null,null,1000,0,29,0,0,0,1518,19,null]
],
"enemies": {"enemy_1116_liprr":[11,11,0,2103,54450],"enemy_1116_liprr_2":[4,4,0,783,27000],"enemy_1118_lidbox_2":[2,2,0,1716,17100]},
"hooks": [1,28,1626,168,168,178,178,29,17,19,15,15,0,7317,2,24,17,0,1,17,0,0,0],
"events": {"atk":195,"deploy":11,"die":19,"dmg":178,"engage":5,"fx":47,"heal":29,"skill":30,"spawn":26,"status":23},
"fx": {"aoe":1,"displace":16,"liberate":2,"phase":4,"pull":16,"strike":3,"summon":2,"volley":3},
"snaps": "1a69c505 b8848494 3f8ff510 ec364fad 577e2d85 ed7ffada"
"enemies": {"enemy_1116_liprr":[11,11,0,1531,54450],"enemy_1116_liprr_2":[4,4,0,1291,27000],"enemy_1118_lidbox_2":[2,2,0,7359,17100]},
"hooks": [1,28,1927,192,192,223,223,34,19,22,15,14,0,8243,1,23,17,0,1,19,0,0,0],
"events": {"atk":215,"deploy":11,"die":22,"dmg":223,"engage":6,"fx":36,"heal":34,"skill":29,"spawn":26,"status":22},
"fx": {"aoe":1,"displace":11,"liberate":2,"phase":5,"pull":11,"strike":2,"summon":2,"volley":2},
"snaps": "78218668 baf3658a cf85bac1 a4b51e2f ad4c883e fe1c9c59 f50febd4"
},
"bond-visiShip-high": {
"about": "远见 high: tier 1 layers 999; chess_char_4_17_b chess_char_4_18_b chess_char_3_03_b chess_char_4_19_b chess_char_4_16_b chess_char_2_02_b chess_char_4_20_b chess_char_4_21_b token_10000_silent_healrb",
@@ -1744,8 +1743,8 @@
"round": 5,
"seed": 1009948961,
"waveId": "act1autochess_05",
"time": 54.6,
"ticks": 1638,
"time": 54.17,
"ticks": 1625,
"reason": "cleared",
"killed": 22,
"total": 22,
@@ -1753,15 +1752,15 @@
"bossHpLeft": null,
"rngDraws": 0,
"errors": {"count":0,"kinds":[]},
"players": {"p1":{"killed":22,"total":22,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":91008,"bossDmg":0,"heal":38,"deaths":0,"layerGains":{}}},
"players": {"p1":{"killed":22,"total":22,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":91008,"bossDmg":0,"heal":3,"deaths":0,"layerGains":{}}},
"units": [
["p1",1,"op","chess_char_3_16_a",1,null,2390.3,1,8,0,0,0,0,0,0],
["p1",2,"op","chess_char_3_03_a",1,null,1515.8,1,9,3,14449,0,0,22,0],
["p1",3,"op","chess_char_3_18_a",2,null,583.3,1,10,7,26209,1084,38,38,2],
["p1",4,"op","chess_char_1_03_a",1,null,1145.1,1,4,1,3119,0,0,4,1],
["p1",2,"op","chess_char_3_03_a",1,null,1515.8,1,9,4,13532,0,0,21,0],
["p1",3,"op","chess_char_3_18_a",2,null,548.01,1,10,5,26735,1084,3,36,2],
["p1",4,"op","chess_char_1_03_a",1,null,1145.1,1,4,2,2971,0,0,4,1],
["p1",5,"op","chess_char_2_04_a",1,null,1074.7,1,5,0,0,0,0,0,0],
["p1",6,"op","chess_char_3_14_a",1,null,1376.1,1,11,3,6509,0,0,28,1],
["p1",7,"op","chess_char_3_17_a",1,null,2470.6,1,12,8,40722,0,0,46,3],
["p1",6,"op","chess_char_3_14_a",1,null,1376.1,1,11,3,6548,0,0,28,1],
["p1",7,"op","chess_char_3_17_a",1,null,2470.6,1,12,8,41222,0,0,46,3],
["p1",8,"op","chess_char_3_19_a",2,null,1582.9,1,6,0,0,0,0,0,1],
["p1",9,"token","token_10028_vigil_wolf",null,null,880,1,7,0,0,0,0,0,null],
["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null],
@@ -1769,10 +1768,10 @@
["-","~3","device","trap_1105_accrate",null,null,100,1,3,0,0,0,0,0,null]
],
"enemies": {"enemy_1166_dusbr":[15,15,0,54,51840],"enemy_1169_duphlx":[5,5,0,0,23040],"enemy_1170_dushld":[2,2,0,1030,16128]},
"hooks": [1,34,1638,145,145,225,225,14,22,22,8,6,0,6587,37,4,22,0,1,28,0,0,0],
"events": {"atk":181,"deploy":9,"die":22,"dmg":225,"engage":5,"fx":8,"skill":14,"spawn":34,"status":107},
"hooks": [1,34,1625,142,142,233,233,1,22,22,8,6,0,6560,35,4,22,0,1,26,0,0,0],
"events": {"atk":177,"deploy":9,"die":22,"dmg":233,"engage":5,"fx":8,"skill":14,"spawn":34,"status":102},
"fx": {"camouflage":1,"dp":3,"volley":3,"wolfShadow":1},
"snaps": "f3cb4a09 d9ee8095 8cf05251 475fb772 f816b385 6d26de1c"
"snaps": "f3cb4a09 ead3ba17 97f1f28e 6f24aae2 b498c568 eb22abcb"
},
"bond-yanShip-high": {
"about": "炎 high: tier 3 layers 999; chess_char_4_17_b chess_char_6_03_b chess_char_3_03_b chess_char_3_04_b chess_char_1_03_b chess_char_2_04_b chess_char_5_03_b chess_char_5_12_b chess_char_5_23_b",
+39 -39
View File
@@ -4155,29 +4155,29 @@
"bossHpLeft": null,
"rngDraws": 1138,
"errors": {"count":0,"kinds":[]},
"players": {"p1":{"killed":0,"total":138,"perfect":false,"leaks":138,"leaksUncounted":0,"coins":0,"dmg":455852,"bossDmg":0,"heal":0,"deaths":15,"layerGains":{}}},
"players": {"p1":{"killed":0,"total":138,"perfect":false,"leaks":138,"leaksUncounted":0,"coins":0,"dmg":545909,"bossDmg":0,"heal":0,"deaths":15,"layerGains":{}}},
"units": [
["p1",1,"op","chess_char_5_diy1_a",0,null,1236.4,1,8,0,61914,0,0,80,3],
["p1",2,"op","chess_char_5_diy2_a",1,null,1236.4,1,11,0,64924,0,0,51,1],
["p1",3,"op","chess_char_5_diy1_a",2,null,1236.4,1,13,0,62811,0,0,93,3],
["p1",4,"op","chess_char_5_diy2_b",0,"none",1496,1,15,0,74201,0,0,82,3],
["p1",5,"op","chess_char_5_diy1_b",1,"none",1496,1,4,0,72338,0,0,44,1],
["p1",6,"op","chess_char_5_diy2_b",2,"none",1496,1,9,0,97519,0,0,75,3],
["p1",7,"op","chess_char_5_diy1_b",0,"uniequip_002_gdglow",1595,1,163,0,6333,3190,0,4,1],
["p1",8,"op","chess_char_5_diy2_b",1,"uniequip_002_gdglow",0,0,95,0,1891,3190,0,16,0],
["p1",9,"op","chess_char_5_diy1_b",2,"uniequip_002_gdglow",0,0,157,0,3203,4785,0,25,0],
["p1",10,"op","chess_char_5_diy2_b",0,"uniequip_003_gdglow",0,0,152,0,2419,4488,0,22,0],
["p1",11,"op","chess_char_5_diy1_b",1,"uniequip_003_gdglow",0,0,149,0,1370,4488,0,15,0],
["p1",12,"op","chess_char_5_diy2_b",2,"uniequip_003_gdglow",0,0,107,0,6927,2992,0,13,1],
["p1",1,"op","chess_char_5_diy1_a",0,null,1236.4,1,8,0,74713,0,0,80,3],
["p1",2,"op","chess_char_5_diy2_a",1,null,1236.4,1,11,0,73083,0,0,51,1],
["p1",3,"op","chess_char_5_diy1_a",2,null,1236.4,1,13,0,77690,0,0,93,3],
["p1",4,"op","chess_char_5_diy2_b",0,"none",1496,1,15,0,88835,0,0,82,3],
["p1",5,"op","chess_char_5_diy1_b",1,"none",1496,1,4,0,80271,0,0,44,1],
["p1",6,"op","chess_char_5_diy2_b",2,"none",1496,1,9,0,110904,0,0,75,3],
["p1",7,"op","chess_char_5_diy1_b",0,"uniequip_002_gdglow",1595,1,163,0,7094,3190,0,4,1],
["p1",8,"op","chess_char_5_diy2_b",1,"uniequip_002_gdglow",0,0,95,0,4933,3190,0,16,0],
["p1",9,"op","chess_char_5_diy1_b",2,"uniequip_002_gdglow",0,0,157,0,7955,4785,0,25,0],
["p1",10,"op","chess_char_5_diy2_b",0,"uniequip_003_gdglow",0,0,152,0,6694,4488,0,22,0],
["p1",11,"op","chess_char_5_diy1_b",1,"uniequip_003_gdglow",0,0,149,0,4284,4488,0,15,0],
["p1",12,"op","chess_char_5_diy2_b",2,"uniequip_003_gdglow",0,0,107,0,9453,2992,0,13,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_1166_dusbr_2":[45,0,45,7678,132417],"enemy_1169_duphlx_2":[51,0,51,6281,141717],"enemy_1170_dushld_2":[42,0,42,9174,181718]},
"hooks": [1,163,6073,559,559,2092,2092,0,15,153,16,13,0,24679,578,15,138,138,1,15,0,0,0],
"events": {"atk":559,"deploy":22,"die":15,"dmg":2092,"engage":12,"fx":1777,"leak":138,"skill":29,"spawn":153,"status":576},
"enemies": {"enemy_1166_dusbr_2":[45,0,45,7678,168139],"enemy_1169_duphlx_2":[51,0,51,6281,168619],"enemy_1170_dushld_2":[42,0,42,9174,209150]},
"hooks": [1,163,6073,559,559,2612,2612,0,15,153,16,13,0,24679,578,15,138,138,1,15,0,0,0],
"events": {"atk":559,"deploy":22,"die":15,"dmg":2612,"engage":12,"fx":1777,"leak":138,"skill":29,"spawn":153,"status":576},
"fx": {"drone":1138,"droneLock":518,"drones":16,"explode":105},
"snaps": "bdd99668 c46a9ee9 9c11177a eebd0b2c 8cd16121 d2197dca fedac0f3 43f32cf5 bf91603f 06844f89 604fed15 8020ffd5 cb5d37fc 02479270 70c2225e 7397b81e 4dddf649 d98396d8 2f92ae08 3954d416 2533bfc0"
"snaps": "92781428 6430fa6b c6dc707c f08a198d fc3a79fb 860d747b c0bc2e24 fbd9411c b6d17260 56587aa5 c13636cb 72ee3e4c f6c337e7 09705474 b4dd8303 227da0fa c51ac9e5 c5fff7d6 5fdb73d0 7ad219bb 0c191fb1"
},
"diy-099": {
"about": "6_diy1_a=gdglow/S1 6_diy2_a=gdglow/S2 6_diy1_a=gdglow/S3 6_diy2_b=gdglow/S1 6_diy1_b=gdglow/S2 6_diy2_b=gdglow/S3 6_diy1_b=gdglow/S1/002_gdglow 6_diy2_b=gdglow/S2/002_gdglow 6_diy1_b=gdglow/S3/002_gdglow 6_diy2_b=gdglow/S1/003_gdglow 6_diy1_b=gdglow/S2/003_gdglow 6_diy2_b=gdglow/S3/003_gdglow",
@@ -4188,37 +4188,37 @@
"round": 13,
"seed": 213318073,
"waveId": "act1autochess_h06",
"time": 215.43,
"ticks": 6463,
"time": 214.47,
"ticks": 6434,
"reason": "cleared",
"killed": 68,
"killed": 75,
"total": 129,
"unspawned": 0,
"bossHpLeft": null,
"rngDraws": 1845,
"rngDraws": 1833,
"errors": {"count":0,"kinds":[]},
"players": {"p1":{"killed":68,"total":129,"perfect":false,"leaks":61,"leaksUncounted":0,"coins":0,"dmg":1469349,"bossDmg":0,"heal":0,"deaths":6,"layerGains":{}}},
"players": {"p1":{"killed":75,"total":129,"perfect":false,"leaks":54,"leaksUncounted":0,"coins":0,"dmg":1599812,"bossDmg":0,"heal":0,"deaths":6,"layerGains":{}}},
"units": [
["p1",1,"op","chess_char_6_diy1_a",0,null,1236.4,1,3,10,114795,0,0,95,3],
["p1",2,"op","chess_char_6_diy2_a",1,null,1236.4,1,4,6,98029,0,0,44,1],
["p1",3,"op","chess_char_6_diy1_a",2,null,1236.4,1,11,1,115795,0,0,50,3],
["p1",4,"op","chess_char_6_diy2_b",0,"none",1496,1,13,1,39972,0,0,45,3],
["p1",5,"op","chess_char_6_diy1_b",1,"none",1496,1,5,10,174655,0,0,43,1],
["p1",6,"op","chess_char_6_diy2_b",2,"none",1496,1,8,5,130906,0,0,91,3],
["p1",7,"op","chess_char_6_diy1_b",0,"uniequip_002_gdglow",1633.5,1,12,11,200948,0,0,91,4],
["p1",8,"op","chess_char_6_diy2_b",1,"uniequip_002_gdglow",1633.5,1,14,6,298003,0,0,53,1],
["p1",9,"op","chess_char_6_diy1_b",2,"uniequip_002_gdglow",1633.5,1,9,10,201809,0,0,87,3],
["p1",10,"op","chess_char_6_diy2_b",0,"uniequip_003_gdglow",1496,1,144,2,38398,2992,0,33,2],
["p1",11,"op","chess_char_6_diy1_b",1,"uniequip_003_gdglow",1496,1,138,1,10182,2992,0,57,0],
["p1",12,"op","chess_char_6_diy2_b",2,"uniequip_003_gdglow",1496,1,132,5,45856,2992,0,15,1],
["p1",1,"op","chess_char_6_diy1_a",0,null,1236.4,1,3,4,106436,0,0,92,3],
["p1",2,"op","chess_char_6_diy2_a",1,null,1236.4,1,4,4,131514,0,0,43,1],
["p1",3,"op","chess_char_6_diy1_a",2,null,1236.4,1,11,2,122309,0,0,46,3],
["p1",4,"op","chess_char_6_diy2_b",0,"none",1496,1,13,0,53284,0,0,46,3],
["p1",5,"op","chess_char_6_diy1_b",1,"none",1496,1,5,13,162190,0,0,43,1],
["p1",6,"op","chess_char_6_diy2_b",2,"none",1496,1,8,7,158296,0,0,91,3],
["p1",7,"op","chess_char_6_diy1_b",0,"uniequip_002_gdglow",1633.5,1,12,21,277787,0,0,91,4],
["p1",8,"op","chess_char_6_diy2_b",1,"uniequip_002_gdglow",1633.5,1,14,8,261532,0,0,53,1],
["p1",9,"op","chess_char_6_diy1_b",2,"uniequip_002_gdglow",1633.5,1,9,7,186106,0,0,87,3],
["p1",10,"op","chess_char_6_diy2_b",0,"uniequip_003_gdglow",0,0,92,2,67871,2992,0,23,2],
["p1",11,"op","chess_char_6_diy1_b",1,"uniequip_003_gdglow",0,0,91,1,35064,2992,0,57,0],
["p1",12,"op","chess_char_6_diy2_b",2,"uniequip_003_gdglow",1496,1,136,5,37424,2992,0,23,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_10124_uashld_2":[18,4,14,4488,546846],"enemy_1043_zomsbr":[66,50,16,912,309132],"enemy_1061_zomshd":[45,14,31,3576,649372]},
"hooks": [1,149,6463,724,724,4112,4112,49312,74,135,25,20,0,34562,907,8,129,61,1,74,0,0,0],
"events": {"atk":724,"deploy":18,"die":74,"dmg":4112,"engage":12,"fx":2496,"leak":61,"skill":45,"spawn":143,"status":473},
"fx": {"drone":1845,"droneLock":450,"drones":25,"explode":176},
"snaps": "f541f090 c32b0887 8a0d0ec8 0025b950 5d8fbbd2 abfb2750 083f5116 896707cd 0f55d1b0 2db8f520 09351c4c 9ef017f3 d1c0123c 8ea2215f d2213fdc 38a8b05f 2c5a266c cbe1b254 464f79f1 fb548a9b 31a8d689 3650bdc6"
"enemies": {"enemy_10124_uashld_2":[18,8,10,2992,638700],"enemy_1043_zomsbr":[66,53,13,912,328686],"enemy_1061_zomshd":[45,14,31,5072,669451]},
"hooks": [1,147,6434,716,716,4774,4774,49638,81,135,25,19,0,34415,861,9,129,54,1,81,0,0,0],
"events": {"atk":716,"deploy":16,"die":81,"dmg":4774,"engage":12,"fx":2485,"leak":54,"skill":44,"spawn":143,"status":469},
"fx": {"drone":1833,"droneLock":445,"drones":25,"explode":182},
"snaps": "8997e50d 3106dc76 e94c98b3 86739ec6 32b588ca 7728ff26 deca17c2 9c61006c 87a4e62c 83004cb1 3efd9d05 00bc8ef7 6b6d9f39 14e1ea96 9b5a8ef8 297316e3 3bc788c7 32245b95 f1986285 2cfa6f21 bd536ac3 ce7828b3"
},
"diy-100": {
"about": "5_diy1_a=haak/S1 5_diy2_a=haak/S2 5_diy1_a=haak/S3 5_diy2_b=haak/S1 5_diy1_b=haak/S2 5_diy2_b=haak/S3 5_diy1_b=haak/S1/002_haak 5_diy2_b=haak/S2/002_haak 5_diy1_b=haak/S3/002_haak 5_diy2_b=haak/S1/003_haak 5_diy1_b=haak/S2/003_haak 5_diy2_b=haak/S3/003_haak",
+275 -274
View File
@@ -144,42 +144,42 @@
"killed": 8,
"total": 8,
"unspawned": 0,
"bossHpLeft": 5119551,
"rngDraws": 825,
"bossHpLeft": 5151915,
"rngDraws": 834,
"errors": {"count":0,"kinds":[]},
"players": {
"p1": {"killed":8,"total":8,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":825320,"bossDmg":458231,"heal":21045,"deaths":1,"layerGains":{}},
"p2": {"killed":0,"total":0,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":917367,"bossDmg":419879,"heal":6188,"deaths":2,"layerGains":{}}
"p1": {"killed":8,"total":8,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":798821,"bossDmg":425815,"heal":21045,"deaths":1,"layerGains":{}},
"p2": {"killed":0,"total":0,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":911503,"bossDmg":419932,"heal":6270,"deaths":2,"layerGains":{}}
},
"units": [
["p1",1,"op","chess_char_5_19_b",2,"uniequip_002_mlynar",4297.7,1,9,0,266158,2455,463,74,3],
["p1",2,"op","chess_char_4_01_b",2,"uniequip_002_rmixer",6030.59,1,11,0,8108,15611,8378,34,1],
["p1",3,"op","chess_char_5_14_b",1,"none",2391.4,1,7,0,35553,997,272,17,13],
["p1",4,"op","chess_char_5_17_b",1,"uniequip_002_f12yin",3011.8,1,13,0,6024,0,0,20,1],
["p1",5,"op","chess_char_5_15_b",1,"uniequip_002_thorn2",1287,1,1,0,182518,0,0,72,14],
["p1",1,"op","chess_char_5_19_b",2,"uniequip_002_mlynar",4297.7,1,9,0,266158,2455,745,74,3],
["p1",2,"op","chess_char_4_01_b",2,"uniequip_002_rmixer",6030.59,1,11,0,8108,15611,5431,34,1],
["p1",3,"op","chess_char_5_14_b",1,"none",2391.4,1,7,0,25560,997,272,17,10],
["p1",4,"op","chess_char_5_17_b",1,"uniequip_002_f12yin",3011.8,1,13,0,10666,0,0,20,1],
["p1",5,"op","chess_char_5_15_b",1,"uniequip_002_thorn2",1287,1,1,0,147722,0,0,72,11],
["p1",6,"op","chess_char_5_20_b",2,"uniequip_002_aglina",2660.35,1,3,0,89986,0,0,44,4],
["p1",7,"op","chess_char_5_21_b",1,"uniequip_002_sntlla",3046.23,1,17,1,59635,1709,1709,103,3],
["p1",8,"op","chess_char_5_22_b",1,"uniequip_002_nymph",1772.1,1,43,1,128594,2045,273,50,16],
["p1",9,"op","chess_char_5_23_b",1,"uniequip_002_reckpr",1771,1,5,0,0,0,9950,16,3],
["p1",7,"op","chess_char_5_21_b",1,"uniequip_002_sntlla",3046.23,1,17,1,60191,1709,1709,103,3],
["p1",8,"op","chess_char_5_22_b",1,"uniequip_002_nymph",1772.1,1,43,1,141685,2045,273,44,16],
["p1",9,"op","chess_char_5_23_b",1,"uniequip_002_reckpr",1771,1,5,0,0,0,12615,16,2],
["p1",10,"op","chess_char_4_02_b",2,"uniequip_002_mostma",1864.5,1,15,0,48745,0,0,34,2],
["p2",11,"op","chess_char_4_11_b",1,"uniequip_002_cathy",1093.92,1,49,0,0,6969,1080,0,1],
["p2",12,"op","chess_char_4_04_b",1,"uniequip_002_ines",3900.44,1,48,2,298987,0,0,273,7],
["p2",13,"op","chess_char_4_07_b",1,"uniequip_002_bpipe",1393.95,1,8,0,6851,2741,1513,24,8],
["p2",14,"op","chess_char_4_16_b",2,"uniequip_002_texas2",2117.31,1,51,3,134890,3198,1245,171,12],
["p2",13,"op","chess_char_4_07_b",1,"uniequip_002_bpipe",1393.95,1,8,0,6240,2741,1513,24,8],
["p2",14,"op","chess_char_4_16_b",2,"uniequip_002_texas2",2198.65,1,51,3,134890,3198,1326,171,12],
["p2",15,"op","chess_char_4_09_b",1,"uniequip_002_mizuki",2752.8,1,10,0,103788,1136,833,77,5],
["p2",16,"op","chess_char_4_06_b",1,"uniequip_002_kroos2",1717.1,1,2,1,192768,0,0,245,3],
["p2",16,"op","chess_char_4_06_b",1,"uniequip_002_kroos2",1717.1,1,2,1,183825,0,0,248,3],
["p2",17,"op","chess_char_4_10_b",1,"uniequip_002_aroma",1830.4,1,4,0,0,0,0,0,0],
["p2",18,"op","chess_char_4_12_b",2,"uniequip_002_glady",2455.2,1,18,0,59278,1515,1515,50,3],
["p2",19,"op","chess_char_4_13_b",1,"uniequip_002_gnosis",2187.9,1,40,0,54119,2190,2,60,16],
["p2",18,"op","chess_char_4_12_b",2,"uniequip_002_glady",2455.2,1,18,0,69310,1515,1515,56,3],
["p2",19,"op","chess_char_4_13_b",1,"uniequip_002_gnosis",2187.9,1,40,0,47776,2190,2,53,14],
["p2",20,"op","chess_char_4_14_b",1,"uniequip_002_lionhd",1788.6,1,6,0,66688,0,0,47,13],
["p2",21,"token","token_10041_cathy_catsld",null,null,100,1,21,0,0,0,0,0,null],
["p2",22,"token","token_10041_cathy_catsld",null,null,100,1,22,0,0,0,0,0,null]
],
"enemies": {"enemy_1005_yokai_2":[6,6,0,3548,62190],"enemy_1430_lrrook":[2,2,0,0,802449],"enemy_9017_achunt":[2,0,0,37019,880449]},
"hooks": [1,49,6000,1363,1401,2938,2977,4532,13,25,128,122,22,37234,997,0,10,0,1,13,0,11,0],
"events": {"atk":1400,"deploy":39,"die":25,"dmg":2967,"engage":17,"fx":786,"heal":36,"skill":250,"spawn":32,"status":502},
"fx": {"aoe":34,"appear":2,"blink":14,"burst":11,"catShield":1,"charge":8,"counter":22,"crit":92,"dilemma":5,"disappear":2,"explosion":13,"frostNova":16,"garrisonGrant":1,"iceSpike":89,"overclock":1,"pull":3,"quadShot":2,"ripple":20,"sentry":5,"sermon":1,"shadowWeave":9,"shield":5,"soul":3,"sp":14,"stealth":7,"swordRain":92,"swordStorm":12,"tornado":3,"tornadoPulse":15,"weightless":4,"zone":280},
"snaps": "48098e21 84cc8d09 9e22eede 8777b0de 5b2c7c4f 4969a87a 300e3231 8f2aae8d 594d2add 27a55abf 76ee1392 de169eb6 deea8c8f 23e8c816 27aabce9 ef0f6997 a7a322da 22b337fc 0b0aabbd f290764e"
"enemies": {"enemy_1005_yokai_2":[6,6,0,3548,62190],"enemy_1430_lrrook":[2,2,0,0,802449],"enemy_9017_achunt":[2,0,0,37019,848085]},
"hooks": [1,49,6000,1359,1398,2874,2913,4613,13,25,119,114,22,36850,972,0,10,0,1,13,0,11,0],
"events": {"atk":1396,"deploy":39,"die":25,"dmg":2903,"engage":17,"fx":722,"heal":36,"skill":233,"spawn":32,"status":511},
"fx": {"aoe":30,"appear":2,"blink":14,"burst":11,"catShield":1,"charge":8,"counter":22,"crit":101,"dilemma":5,"disappear":2,"explosion":13,"frostNova":14,"garrisonGrant":1,"iceSpike":89,"overclock":1,"pull":3,"quadShot":2,"ripple":20,"sentry":5,"sermon":1,"shadowWeave":9,"shield":5,"soul":3,"sp":7,"stealth":7,"swordRain":92,"swordStorm":12,"tornado":3,"tornadoPulse":15,"weightless":4,"zone":220},
"snaps": "48098e21 84cc8d09 9e22eede 8777b0de 5b2c7c4f 4969a87a 300e3231 8f2aae8d 4263d20f ac9beb19 ef6d231a cef39f5d 4a5af757 e2b11181 12bc00c2 a0d267f9 d1c2531d 0871c5d6 0022566b f70f7ef5"
},
"boss-boss_2-solo": {
"about": "假想敌:铳 solo pool 3000000: chess_char_4_17_b chess_char_4_18_b chess_char_4_23_b chess_char_4_19_b chess_char_4_22_b chess_char_4_20_b chess_char_4_21_b chess_char_4_24_b chess_char_4_25_b chess_char_4_26_b",
@@ -227,112 +227,109 @@
"round": 14,
"seed": 1428555614,
"waveId": "act1autochess_h07_03",
"time": 193.03,
"ticks": 5791,
"time": 170.4,
"ticks": 5112,
"reason": "cleared",
"killed": 19,
"total": 6,
"unspawned": 0,
"bossHpLeft": 0,
"rngDraws": 1399,
"rngDraws": 1416,
"errors": {"count":0,"kinds":[]},
"players": {
"p1": {"killed":19,"total":6,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":1311182,"bossDmg":1211754,"heal":15185,"deaths":2,"layerGains":{}},
"p2": {"killed":0,"total":0,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":2873270,"bossDmg":2788246,"heal":46782,"deaths":3,"layerGains":{}}
"p1": {"killed":19,"total":6,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":1542879,"bossDmg":1424512,"heal":11484,"deaths":2,"layerGains":{}},
"p2": {"killed":0,"total":0,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":2641521,"bossDmg":2575488,"heal":50009,"deaths":3,"layerGains":{}}
},
"units": [
["p1",1,"op","chess_char_6_03_b",2,"uniequip_002_yu",4070,1,7,0,0,963,7372,0,1],
["p1",2,"op","chess_char_6_06_b",2,"uniequip_002_pepe",3232.9,1,11,0,0,22,0,0,0],
["p1",3,"op","chess_char_6_07_b",2,"uniequip_002_siege2",3296.85,1,9,1,54,7553,554,47,2],
["p1",4,"op","chess_char_6_01_b",2,"uniequip_002_lemuen",1668.07,1,66,3,560206,5126,838,41,5],
["p1",5,"op","chess_char_6_02_b",2,"uniequip_002_sbell2",2084.44,1,17,1,258588,5844,732,41,2],
["p1",6,"op","chess_char_6_04_b",2,"uniequip_002_skadi2",757.15,1,59,1,26013,13391,2675,0,3],
["p1",7,"op","chess_char_6_05_b",2,"uniequip_002_pasngr",2068.72,1,21,1,300324,8951,2948,93,7],
["p1",8,"op","chess_char_6_08_b",2,"uniequip_002_reed2",1834.8,1,5,1,87,2172,67,30,2],
["p1",9,"op","chess_char_6_09_b",0,"uniequip_002_cello",1454.2,1,15,2,78,2307,0,17,17],
["p1",10,"op","chess_char_6_11_b",2,"uniequip_002_mlyss",2082.3,1,23,0,121760,0,0,203,5],
["p1",1,"op","chess_char_6_03_b",2,"uniequip_002_yu",4070,1,7,0,0,0,7258,0,1],
["p1",2,"op","chess_char_6_06_b",2,"uniequip_002_pepe",3232.9,1,11,0,0,0,0,0,0],
["p1",3,"op","chess_char_6_07_b",2,"uniequip_002_siege2",3411.1,1,9,1,47,6231,541,47,1],
["p1",4,"op","chess_char_6_01_b",2,"uniequip_002_lemuen",2344.65,1,13,6,837990,7545,698,53,7],
["p1",5,"op","chess_char_6_02_b",2,"uniequip_002_sbell2",1834.46,1,17,1,262042,7260,906,41,2],
["p1",6,"op","chess_char_6_04_b",2,"uniequip_002_skadi2",0,0,59,1,26548,14647,1235,0,3],
["p1",7,"op","chess_char_6_05_b",2,"uniequip_002_pasngr",814.83,1,21,0,273606,9866,827,82,6],
["p1",8,"op","chess_char_6_08_b",2,"uniequip_002_reed2",1834.8,1,5,0,30,0,19,13,1],
["p1",9,"op","chess_char_6_09_b",0,"uniequip_002_cello",1454.2,1,15,0,37,0,0,9,9],
["p1",10,"op","chess_char_6_11_b",2,"uniequip_002_mlyss",2082.3,1,23,0,103926,1501,0,178,4],
["p1",11,"token","token_10017_skadi2_dedant",null,null,138,0,26,0,0,0,0,0,null],
["p2",12,"op","chess_char_6_15_b",2,"uniequip_002_qiubai",4181.76,1,31,1,525926,1350,73,197,3],
["p2",13,"op","chess_char_6_19_b",2,"uniequip_002_blkkgt",3043.7,1,61,0,27,3044,0,8,1],
["p2",14,"op","chess_char_5_01_b",1,"uniequip_002_excu2",2545.4,1,57,0,15,3807,900,15,1],
["p2",15,"op","chess_char_6_17_b",2,"uniequip_002_nearl2",6916.53,1,32,0,361017,2679,0,91,3],
["p2",16,"op","chess_char_6_12_b",1,"uniequip_002_rosmon",3493.03,1,16,6,251273,3571,545,71,4],
["p2",17,"op","chess_char_6_13_b",1,"uniequip_002_angel2",2680.15,1,20,2,1500382,6607,1632,344,6],
["p2",18,"op","chess_char_6_14_b",2,"uniequip_002_lumen",2297.79,1,22,0,0,3550,6766,52,1],
["p2",19,"op","chess_char_6_16_b",2,"uniequip_002_halo2",1741.19,1,25,0,85000,1414,0,136,4],
["p2",20,"op","chess_char_6_18_b",2,"uniequip_002_whitw2",1695.1,1,58,1,117690,1695,0,22,2],
["p2",21,"op","chess_char_6_20_b",2,"uniequip_002_agoat2",1828.31,1,18,0,0,1717,36867,52,2],
["p2",12,"op","chess_char_6_15_b",2,"uniequip_002_qiubai",4181.76,1,31,1,481322,0,0,177,3],
["p2",13,"op","chess_char_6_19_b",2,"uniequip_002_blkkgt",3043.7,1,62,1,27,3044,0,8,1],
["p2",14,"op","chess_char_5_01_b",1,"uniequip_002_excu2",2545.4,1,56,0,14,3710,780,14,1],
["p2",15,"op","chess_char_6_17_b",2,"uniequip_002_nearl2",6916.53,1,32,0,283256,2036,0,71,3],
["p2",16,"op","chess_char_6_12_b",1,"uniequip_002_rosmon",3493.03,1,16,4,224007,3644,370,62,3],
["p2",17,"op","chess_char_6_13_b",1,"uniequip_002_angel2",2653.35,1,20,3,1430530,5238,1847,317,5],
["p2",18,"op","chess_char_6_14_b",2,"uniequip_002_lumen",2297.79,1,22,0,0,3550,6333,55,1],
["p2",19,"op","chess_char_6_16_b",2,"uniequip_002_halo2",1741.19,1,25,1,73665,1414,0,122,4],
["p2",20,"op","chess_char_6_18_b",2,"uniequip_002_whitw2",1695.1,1,58,1,122083,1695,0,20,2],
["p2",21,"op","chess_char_6_20_b",2,"uniequip_002_agoat2",1828.31,1,18,0,0,2296,40680,56,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],
["-","~4","device","trap_1105_accrate",null,null,100,1,4,0,0,0,0,0,null],
["p1","~1","token","token_10030_mlyss_wtrman",null,null,1586.2,1,24,0,44067,0,0,87,1],
["p1","~1","token","token_10030_mlyss_wtrman",null,null,1586.2,1,24,0,38652,0,0,77,1],
["p2","~1","token","token_10019_nearl2_sword",null,null,8280,0,41,0,10647,0,0,0,null],
["p1","~2","token","token_10030_mlyss_wtrman",null,null,1989.96,0,42,0,1,0,0,1,null],
["p1","~2","token","token_10030_mlyss_wtrman",null,null,1989.96,0,42,0,0,0,0,0,null],
["p1","~1","token","token_10017_skadi2_dedant",null,null,100,0,49,0,0,0,0,0,null],
["p1","~3","token","token_10030_mlyss_wtrman",null,null,1586.2,0,50,0,3,0,0,3,null],
["p2","~2","token","token_10019_nearl2_sword",null,null,6600,0,52,0,10647,0,0,0,null],
["p1","~3","token","token_10030_mlyss_wtrman",null,null,1586.2,0,50,0,2,0,0,3,null],
["p2","~2","token","token_10019_nearl2_sword",null,null,6600,0,51,0,10647,0,0,0,null],
["p1","~1","token","token_10040_siege2_vlion",null,null,4000,0,53,0,0,0,0,0,null],
["p1","~4","token","token_10030_mlyss_wtrman",null,null,1989.96,0,55,0,0,0,0,0,null],
["p1","~2","token","token_10017_skadi2_dedant",null,null,100,0,60,0,0,0,0,0,null],
["p2","~3","token","token_10019_nearl2_sword",null,null,6600,0,62,0,10647,0,0,0,null],
["p1","~5","token","token_10030_mlyss_wtrman",null,null,1586.2,0,64,0,0,0,0,0,null],
["p1","~3","token","token_10017_skadi2_dedant",null,null,100,1,68,0,0,0,0,0,null],
["p1","~2","token","token_10040_siege2_vlion",null,null,4000,1,69,0,0,0,0,0,null]
["p2","~3","token","token_10019_nearl2_sword",null,null,6600,0,61,0,5323,0,0,0,null],
["p1","~5","token","token_10030_mlyss_wtrman",null,null,1586.2,1,64,0,0,0,0,0,null]
],
"enemies": {
"enemy_1197_sfshu_2": [2,2,0,0,28809],
"enemy_1198_msfshu_2": [6,6,0,0,102],
"enemy_1209_sfden_2": [2,2,0,1350,154793],
"enemy_1209_sfden_2": [2,2,0,1501,154793],
"enemy_1210_msfden_2": [2,2,0,0,386],
"enemy_9021_acduml": [1,1,0,0,4000000],
"enemy_9023_acdums": [9,7,0,62774,362]
"enemy_9023_acdums": [8,7,0,61276,310]
},
"hooks": [1,67,5791,1558,1558,4205,5122,4446,25,38,72,66,329,44235,2200,3,22,0,1,24,0,17,1],
"events": {"atk":1800,"deploy":41,"die":38,"dmg":4833,"engage":18,"fx":1197,"heal":476,"skill":138,"spawn":60,"status":867},
"hooks": [1,63,5112,1410,1410,3944,4887,3003,25,37,60,56,333,38109,1949,4,21,0,1,24,0,10,1],
"events": {"atk":1660,"deploy":38,"die":37,"dmg":4617,"engage":17,"fx":1143,"heal":456,"skill":116,"spawn":57,"status":840},
"fx": {
"airstrike": 116,
"airstrike": 97,
"appear": 1,
"beam": 11,
"beam": 10,
"blink": 2,
"bombard": 39,
"bombardShell": 39,
"bombard": 60,
"bombardShell": 60,
"buff": 3,
"burst": 17,
"camouflage": 1,
"burst": 10,
"copy": 1,
"disappear": 1,
"displace": 1,
"dodge": 1,
"dp": 5,
"drone": 208,
"dp": 4,
"drone": 211,
"droneLock": 18,
"drones": 2,
"explode": 449,
"extraAttack": 4,
"explode": 396,
"extraAttack": 3,
"finale": 1,
"firewall": 1,
"garrisonGrant": 1,
"lightning": 79,
"link": 8,
"lock": 39,
"lightning": 65,
"link": 6,
"lock": 60,
"lure": 2,
"phase": 44,
"phase": 37,
"radiantSword": 3,
"scorchBurst": 4,
"scorchBurst": 1,
"slash": 11,
"snow": 36,
"snow": 32,
"split": 4,
"steal": 6,
"storm": 7,
"summon": 16,
"steal": 5,
"storm": 6,
"summon": 14,
"sunBurst": 1,
"talent": 8,
"talent": 6,
"telegraph": 2,
"tide": 3,
"wanted": 2
"wanted": 1
},
"snaps": "68fffef1 494368df 4e38f532 f87d9068 690476ee 03b01e26 0bed04bd 2fd59226 69665385 05fe5c2d 998b698f 48323861 b99302e0 9efaad8f a695d151 50c5bc45 dd3e6215 601aae77 2a462704 b1188c19"
"snaps": "b66f8306 b821cb0a 25c089ba 9c4aeabf ff5b59a1 81ad00f2 65ec46c4 9c8b1887 7078d3f4 c6f903d8 e7e0653d f8ed103e 4c094f35 defa0e76 715cdfde 21af37d1 18687ace"
},
"boss-boss_3-solo": {
"about": "假想敌:管 solo pool 2000000: chess_char_5_08_b chess_char_5_11_b chess_char_5_05_b chess_char_5_06_b chess_char_5_13_b chess_char_5_07_b chess_char_5_02_b chess_char_5_03_b chess_char_5_10_b chess_char_5_12_b",
@@ -521,98 +518,101 @@
"round": 14,
"seed": 2302764647,
"waveId": "act2autochess_h07_05",
"time": 102.27,
"ticks": 3068,
"time": 113.93,
"ticks": 3418,
"reason": "cleared",
"killed": 16,
"killed": 18,
"total": 8,
"unspawned": 0,
"bossHpLeft": 0,
"rngDraws": 874,
"rngDraws": 1040,
"errors": {"count":0,"kinds":[]},
"players": {
"p1": {"killed":16,"total":8,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":1231000,"bossDmg":925772,"heal":23117,"deaths":2,"layerGains":{}},
"p2": {"killed":0,"total":0,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":1042770,"bossDmg":634228,"heal":41304,"deaths":1,"layerGains":{}}
"p1": {"killed":18,"total":8,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":1216056,"bossDmg":897045,"heal":28286,"deaths":1,"layerGains":{}},
"p2": {"killed":0,"total":0,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":1143710,"bossDmg":662955,"heal":48827,"deaths":1,"layerGains":{}}
},
"units": [
["p1",1,"op","chess_char_6_03_b",2,"uniequip_002_yu",4070,1,7,1,8305,1825,6494,6,1],
["p1",2,"op","chess_char_6_06_b",2,"uniequip_002_pepe",0,0,12,0,25770,5629,769,5,1],
["p1",3,"op","chess_char_6_07_b",2,"uniequip_002_siege2",3411.1,1,14,0,76065,2161,834,30,1],
["p1",4,"op","chess_char_6_01_b",2,"uniequip_002_lemuen",1381.55,1,1,4,511267,991,755,31,4],
["p1",5,"op","chess_char_6_02_b",2,"uniequip_002_sbell2",2126.3,1,16,1,128892,1418,956,23,2],
["p1",6,"op","chess_char_6_04_b",2,"uniequip_002_skadi2",1757.8,1,3,0,4001,5151,305,0,2],
["p1",7,"op","chess_char_6_05_b",2,"uniequip_002_pasngr",1586.2,1,18,0,143584,3329,1053,18,5],
["p1",8,"op","chess_char_6_08_b",2,"uniequip_002_reed2",1834.8,1,5,0,74636,868,8481,45,2],
["p1",9,"op","chess_char_6_09_b",0,"uniequip_002_cello",1454.2,1,20,4,160370,6470,1752,23,23],
["p1",10,"op","chess_char_6_11_b",2,"uniequip_002_mlyss",0,0,9,0,50925,6392,964,63,2],
["p1",1,"op","chess_char_6_03_b",2,"uniequip_002_yu",5616.6,1,7,0,40882,5641,10343,19,1],
["p1",2,"op","chess_char_6_06_b",2,"uniequip_002_pepe",0,0,12,0,45219,4832,0,9,1],
["p1",3,"op","chess_char_6_07_b",2,"uniequip_002_siege2",4707.32,1,14,0,69280,1067,12,29,1],
["p1",4,"op","chess_char_6_01_b",2,"uniequip_002_lemuen",2231.46,1,1,4,556823,2311,2052,34,5],
["p1",5,"op","chess_char_6_02_b",2,"uniequip_002_sbell2",2934.29,1,16,1,57925,311,62,13,2],
["p1",6,"op","chess_char_6_04_b",2,"uniequip_002_skadi2",2425.76,1,3,1,8028,8274,2281,0,3],
["p1",7,"op","chess_char_6_05_b",2,"uniequip_002_pasngr",2188.96,1,18,0,132719,2320,583,10,4],
["p1",8,"op","chess_char_6_08_b",2,"uniequip_002_reed2",2532.02,1,5,0,66232,4273,8665,35,2],
["p1",9,"op","chess_char_6_09_b",0,"uniequip_002_cello",2006.8,1,20,4,150463,4798,603,22,22],
["p1",10,"op","chess_char_6_11_b",2,"uniequip_002_mlyss",2873.57,1,9,0,58683,4198,2160,70,3],
["p1",11,"token","token_10017_skadi2_dedant",null,null,138,0,22,0,0,0,0,0,null],
["p1",12,"token","token_10030_mlyss_wtrman",null,null,607.48,1,10,0,35076,1816,755,52,1],
["p2",13,"op","chess_char_6_15_b",2,"uniequip_002_qiubai",4181.76,1,55,4,380444,9705,860,99,3],
["p2",14,"op","chess_char_6_19_b",2,"uniequip_002_blkkgt",3043.7,1,13,1,126431,2339,0,21,2],
["p2",15,"op","chess_char_5_01_b",1,"uniequip_002_excu2",1908.49,1,15,1,32435,2117,1225,22,1],
["p2",16,"op","chess_char_6_17_b",2,"uniequip_002_nearl2",6521.3,1,53,1,166515,10334,0,40,2],
["p2",17,"op","chess_char_6_12_b",1,"uniequip_002_rosmon",5557.09,1,2,0,150406,888,888,17,1],
["p2",18,"op","chess_char_6_13_b",1,"uniequip_002_angel2",3437.9,1,17,0,76446,5687,2518,8,1],
["p2",19,"op","chess_char_6_14_b",2,"uniequip_002_lumen",2088.9,1,4,0,0,2258,6052,8,1],
["p2",20,"op","chess_char_6_16_b",2,"uniequip_002_halo2",1741.19,1,19,0,17095,1031,0,18,2],
["p2",21,"op","chess_char_6_18_b",2,"uniequip_002_whitw2",0,0,6,0,83803,1695,0,12,1],
["p2",22,"op","chess_char_6_20_b",2,"uniequip_002_agoat2",1828.31,1,21,0,0,2305,29761,25,1],
["p1",12,"token","token_10030_mlyss_wtrman",null,null,0,0,10,0,19817,3192,1524,31,1],
["p2",13,"op","chess_char_6_15_b",2,"uniequip_002_qiubai",5144.83,1,57,5,403688,10468,0,108,4],
["p2",14,"op","chess_char_6_19_b",2,"uniequip_002_blkkgt",4200.31,1,13,1,134710,5030,0,28,2],
["p2",15,"op","chess_char_5_01_b",1,"uniequip_002_excu2",2908.6,1,15,1,37774,1799,1225,28,1],
["p2",16,"op","chess_char_6_17_b",2,"uniequip_002_nearl2",6853.26,1,59,1,179630,12262,0,45,3],
["p2",17,"op","chess_char_6_12_b",1,"uniequip_002_rosmon",6763.77,1,2,1,180722,1758,1758,21,2],
["p2",18,"op","chess_char_6_13_b",1,"uniequip_002_angel2",5067.92,1,17,0,84079,6189,3393,8,1],
["p2",19,"op","chess_char_6_14_b",2,"uniequip_002_lumen",2882.68,1,4,0,0,1505,8590,12,1],
["p2",20,"op","chess_char_6_16_b",2,"uniequip_002_halo2",2184.4,1,19,0,17625,2062,0,20,3],
["p2",21,"op","chess_char_6_18_b",2,"uniequip_002_whitw2",0,0,6,0,87978,1695,0,12,1],
["p2",22,"op","chess_char_6_20_b",2,"uniequip_002_agoat2",2293.7,1,21,0,0,2305,33861,30,2],
["p2","~1","token","token_10019_nearl2_sword",null,null,1907.57,0,32,0,9195,6372,0,12,null],
["p1","~1","token","token_10030_mlyss_wtrman",null,null,2301.84,0,36,0,5259,0,0,12,null],
["p1","~1","token","token_10040_siege2_vlion",null,null,136.97,0,39,0,4275,4590,0,10,null],
["p1","~2","token","token_10030_mlyss_wtrman",null,null,0,0,45,0,2575,1668,0,7,null],
["p1","~1","token","token_10040_siege2_vlion",null,null,1489.93,0,39,0,2850,3276,0,7,null],
["p2","~2","token","token_10019_nearl2_sword",null,null,5742.56,0,46,0,0,857,0,0,null],
["p1","~1","token","token_10017_skadi2_dedant",null,null,100,0,48,0,0,0,0,0,null],
["p1","~3","token","token_10030_mlyss_wtrman",null,null,1668,1,56,0,0,0,0,0,null]
["p1","~2","token","token_10030_mlyss_wtrman",null,null,0,0,54,0,1875,1668,0,4,null],
["p2","~3","token","token_10019_nearl2_sword",null,null,8280,1,60,0,8308,0,0,0,null]
],
"enemies": {
"enemy_1010_demon_2": [2,2,0,1457,128995],
"enemy_2009_csaudc": [4,4,0,2694,137594],
"enemy_2010_csdcr": [2,2,0,5885,103196],
"enemy_2016_csphtm": [2,2,0,67665,1560000],
"enemy_2017_csphts": [8,8,0,1223,343985]
"enemy_2009_csaudc": [4,4,0,3423,137594],
"enemy_2010_csdcr": [2,2,0,3368,103196],
"enemy_2016_csphtm": [2,2,0,77468,1560000],
"enemy_2017_csphts": [10,10,0,2141,429982]
},
"hooks": [1,54,3068,668,668,1754,2039,3872,22,35,59,52,50,26377,1286,31,18,0,1,20,0,17,81],
"events": {"atk":821,"deploy":36,"die":35,"dmg":1888,"engage":22,"fx":526,"heal":252,"skill":111,"spawn":47,"status":628},
"hooks": [1,58,3418,689,689,1884,2194,4917,24,39,65,54,61,28062,1207,34,20,0,1,23,0,16,86],
"events": {"atk":868,"deploy":38,"die":39,"dmg":2022,"engage":22,"fx":575,"heal":290,"skill":119,"spawn":49,"status":603},
"fx": {
"airstrike": 15,
"blink": 15,
"bombard": 33,
"bombardShell": 33,
"burst": 17,
"airstrike": 20,
"blink": 19,
"bombard": 34,
"bombardShell": 35,
"burst": 16,
"cleanse": 1,
"copy": 1,
"displace": 4,
"dodge": 81,
"dp": 2,
"displace": 3,
"dodge": 86,
"dp": 3,
"drone": 102,
"droneLock": 3,
"drones": 1,
"explode": 24,
"extraAttack": 7,
"emergency": 1,
"explode": 27,
"extraAttack": 12,
"finale": 2,
"firewall": 1,
"garrisonGrant": 1,
"lightning": 43,
"lightning": 49,
"link": 3,
"lock": 33,
"lock": 42,
"lure": 2,
"radiantSword": 2,
"scorchBurst": 1,
"shield": 8,
"palsy": 2,
"radiantSword": 3,
"scorchBurst": 2,
"shield": 7,
"slash": 22,
"snow": 24,
"split": 3,
"split": 2,
"steal": 1,
"storm": 5,
"storm": 4,
"strike": 3,
"summon": 11,
"sunBurst": 4,
"summon": 14,
"sunBurst": 5,
"talent": 4,
"telegraph": 2,
"tide": 2,
"wanted": 11
"telegraph": 3,
"tide": 3,
"wanted": 12
},
"snaps": "d3cd88f1 0b40f2d2 3fada850 9d166f76 eb1c5713 8daf7ec1 58aeec40 fd26682e 39226066 3ce9d7e2 8cf2e7c7"
"snaps": "d3cd88f1 0b40f2d2 3fada850 9d166f76 d6a2dc2f c102808a ed0ca02a c983beea 73a86f16 1366a22a c494f9ea 1ff19f73"
},
"boss-boss_5-solo": {
"about": "卢西恩,“猩红血钻” solo pool 780000: chess_char_5_08_b chess_char_5_11_b chess_char_5_05_b chess_char_5_06_b chess_char_5_13_b chess_char_5_07_b chess_char_5_02_b chess_char_5_03_b chess_char_5_10_b chess_char_5_12_b",
@@ -629,21 +629,21 @@
"killed": 9,
"total": 5,
"unspawned": 0,
"bossHpLeft": 342977,
"rngDraws": 352,
"bossHpLeft": 354904,
"rngDraws": 347,
"errors": {"count":0,"kinds":[]},
"players": {"p1":{"killed":9,"total":5,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":587221,"bossDmg":437023,"heal":43949,"deaths":0,"layerGains":{}}},
"players": {"p1":{"killed":9,"total":5,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":575294,"bossDmg":425096,"heal":43949,"deaths":0,"layerGains":{}}},
"units": [
["p1",1,"op","chess_char_5_08_b",2,"uniequip_002_horn",794.96,1,2,1,117029,5141,3331,60,3],
["p1",1,"op","chess_char_5_08_b",2,"uniequip_002_horn",794.96,1,2,1,94937,5141,3331,60,3],
["p1",2,"op","chess_char_5_11_b",1,"uniequip_002_demkni",5285.28,1,1,0,0,0,10953,0,24],
["p1",3,"op","chess_char_5_05_b",2,"uniequip_002_ulpia",6419.33,1,26,1,54171,17435,14580,8,1],
["p1",4,"op","chess_char_5_06_b",2,"uniequip_002_etlchi",3626.7,1,6,4,113584,499,499,53,4],
["p1",4,"op","chess_char_5_06_b",2,"uniequip_002_etlchi",3626.7,1,6,4,121029,499,499,53,4],
["p1",5,"op","chess_char_5_13_b",1,"uniequip_002_ghost2",3579.42,1,9,0,0,1773,1478,0,0],
["p1",6,"op","chess_char_5_07_b",2,"uniequip_002_surtr",1,0,10,0,54445,10291,5283,15,1],
["p1",6,"op","chess_char_5_07_b",2,"uniequip_002_surtr",2509.97,1,27,0,53528,10729,5283,15,1],
["p1",7,"op","chess_char_5_02_b",2,"uniequip_002_titi",1582.9,1,4,0,0,0,0,0,0],
["p1",8,"op","chess_char_5_03_b",2,"uniequip_002_blaze2",1619.2,1,7,3,161964,5642,803,143,5],
["p1",9,"op","chess_char_5_10_b",2,"uniequip_002_lisa",1628,1,5,0,15095,4433,89,34,3],
["p1",10,"op","chess_char_5_12_b",0,"uniequip_002_dusk",615.02,1,8,0,67722,6725,5374,40,32],
["p1",8,"op","chess_char_5_03_b",2,"uniequip_002_blaze2",1619.2,1,7,3,155416,5642,803,143,5],
["p1",9,"op","chess_char_5_10_b",2,"uniequip_002_lisa",1628,1,5,0,14741,4433,89,34,3],
["p1",10,"op","chess_char_5_12_b",0,"uniequip_002_dusk",615.02,1,8,0,78261,6725,5374,40,32],
["p1","~1","token","token_10015_dusk_drgn",null,null,1218.29,0,13,0,3212,2338,1559,10,null],
["p1","~1","token","token_10039_ulpia_block",null,null,3500,0,23,0,0,0,0,0,null]
],
@@ -651,14 +651,14 @@
"enemy_1249_lysdb_2": [2,2,0,532,19907],
"enemy_2009_csaudc": [2,2,0,1153,26542],
"enemy_2010_csdcr": [1,1,0,1406,19907],
"enemy_2016_csphtm": [1,0,0,23772,437023],
"enemy_2016_csphtm": [1,0,0,24210,425096],
"enemy_2017_csphts": [5,4,0,868,78127],
"enemy_5601_entlec": [2,2,0,0,5716]
},
"hooks": [1,27,6000,392,392,718,1128,4548,11,14,73,72,105,19224,954,15,13,0,1,57,0,4,56],
"events": {"atk":402,"deploy":14,"die":14,"dmg":799,"engage":8,"fx":235,"heal":243,"skill":145,"spawn":25,"status":183},
"fx": {"anchor":1,"aoe":1,"blink":5,"bloodBattle":1,"burst":4,"candle":2,"dodge":56,"ember":1,"explode":22,"healAoe":24,"overload":3,"shieldBreak":2,"splash":105,"summon":6,"teleport":2},
"snaps": "8fce255c d8151793 2376ccec 5d7e1fa7 b2d32b1e 41f8ace8 e6a36358 5fa51831 3946c3c8 ef05c1d4 1eb5ed0b a122bbb0 0fef027b 0b3562c8 cc3d5a85 aa3da3a3 38f45804 c569d14b ac5ec1fb 323cea5a"
"hooks": [1,28,6000,392,392,705,1121,4548,11,14,73,72,105,19344,955,15,13,0,1,57,0,4,52],
"events": {"atk":402,"deploy":15,"die":14,"dmg":792,"engage":8,"fx":232,"heal":243,"skill":145,"spawn":25,"status":179},
"fx": {"anchor":1,"aoe":1,"blink":5,"bloodBattle":1,"burst":4,"candle":2,"dodge":52,"ember":1,"explode":22,"healAoe":24,"overload":3,"revive":1,"shieldBreak":2,"splash":105,"summon":6,"teleport":2},
"snaps": "8fce255c d8151793 2376ccec 5d7e1fa7 b2d32b1e 4e4a64b4 a2af501d fcaa66ec dfb4e7aa 691d5be9 f60f849e f96b690a d0668215 7175bcde 4b94143d f0657105 cd976d36 a98aa464 7667ab5f 9cd2b6c6"
},
"boss-boss_6-pair": {
"about": "阿利斯泰尔,帝国余晖 pair pool 7960000: chess_char_5_19_b chess_char_4_01_b chess_char_5_14_b chess_char_5_17_b chess_char_5_15_b chess_char_5_20_b chess_char_5_21_b chess_char_5_22_b chess_char_5_23_b chess_char_4_02_b | chess_char_4_11_b chess_char_4_04_b chess_char_4_07_b chess_char_4_16_b chess_char_4_09_b chess_char_4_06_b chess_char_4_10_b chess_char_4_12_b chess_char_4_13_b chess_char_4_14_b token_10041_cathy_catsld token_10041_cathy_catsld",
@@ -813,41 +813,41 @@
"round": 14,
"seed": 120974514,
"waveId": "act1autochess_h07_07",
"time": 173.63,
"ticks": 5209,
"time": 173.17,
"ticks": 5195,
"reason": "cleared",
"killed": 16,
"total": 16,
"unspawned": 0,
"bossHpLeft": 0,
"rngDraws": 918,
"rngDraws": 910,
"errors": {"count":0,"kinds":[]},
"players": {
"p1": {"killed":16,"total":16,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":1730751,"bossDmg":1610239,"heal":24718,"deaths":0,"layerGains":{}},
"p2": {"killed":0,"total":0,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":2742228,"bossDmg":2389761,"heal":40810,"deaths":0,"layerGains":{}}
"p1": {"killed":16,"total":16,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":1709189,"bossDmg":1588676,"heal":24718,"deaths":0,"layerGains":{}},
"p2": {"killed":0,"total":0,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":2763791,"bossDmg":2411324,"heal":40783,"deaths":0,"layerGains":{}}
},
"units": [
["p1",1,"op","chess_char_6_03_b",2,"uniequip_002_yu",4070,1,5,0,0,0,0,0,0],
["p1",2,"op","chess_char_6_06_b",2,"uniequip_002_pepe",3232.9,1,7,0,0,0,0,0,0],
["p1",3,"op","chess_char_6_07_b",2,"uniequip_002_siege2",3411.1,1,11,0,0,0,0,0,0],
["p1",4,"op","chess_char_6_01_b",2,"uniequip_002_lemuen",1617,1,1,2,1075028,0,0,78,8],
["p1",4,"op","chess_char_6_01_b",2,"uniequip_002_lemuen",1617,1,1,3,1053668,0,0,78,8],
["p1",5,"op","chess_char_6_02_b",2,"uniequip_002_sbell2",2126.3,1,9,0,0,0,0,0,0],
["p1",6,"op","chess_char_6_04_b",2,"uniequip_002_skadi2",1669.91,1,17,0,43766,18917,1278,0,5],
["p1",7,"op","chess_char_6_05_b",2,"uniequip_002_pasngr",1586.2,1,3,0,0,0,0,0,0],
["p1",8,"op","chess_char_6_08_b",2,"uniequip_002_reed2",1834.8,1,13,0,165894,0,20171,122,3],
["p1",8,"op","chess_char_6_08_b",2,"uniequip_002_reed2",1834.8,1,13,0,165894,0,20171,121,3],
["p1",9,"op","chess_char_6_09_b",0,"uniequip_002_cello",1454.2,1,15,1,261942,1172,0,45,45],
["p1",10,"op","chess_char_6_11_b",2,"uniequip_002_mlyss",2082.3,1,19,1,142272,12632,1260,172,4],
["p1",10,"op","chess_char_6_11_b",2,"uniequip_002_mlyss",2082.3,1,19,1,142069,12632,1260,172,4],
["p1",11,"token","token_10017_skadi2_dedant",null,null,138,0,22,0,0,0,0,0,null],
["p1",12,"token","token_10030_mlyss_wtrman",null,null,0,0,20,0,8204,2977,0,18,1],
["p2",13,"op","chess_char_6_15_b",2,"uniequip_002_qiubai",4435.2,1,26,3,408287,0,0,133,3],
["p2",14,"op","chess_char_6_19_b",2,"uniequip_002_blkkgt",3043.7,1,2,0,0,0,0,0,0],
["p2",15,"op","chess_char_5_01_b",1,"uniequip_002_excu2",2545.4,1,6,0,0,0,0,0,0],
["p2",16,"op","chess_char_6_17_b",2,"uniequip_002_nearl2",6916.53,1,31,1,136739,14292,0,40,3],
["p2",17,"op","chess_char_6_12_b",1,"uniequip_002_rosmon",2540.38,1,16,4,396919,10380,0,65,3],
["p2",18,"op","chess_char_6_13_b",1,"uniequip_002_angel2",2680.15,1,21,4,1362550,5319,2968,297,6],
["p2",17,"op","chess_char_6_12_b",1,"uniequip_002_rosmon",2540.38,1,16,3,395884,10380,0,65,3],
["p2",18,"op","chess_char_6_13_b",1,"uniequip_002_angel2",2653.35,1,21,4,1355054,5319,2941,295,6],
["p2",19,"op","chess_char_6_14_b",2,"uniequip_002_lumen",2088.9,1,8,0,0,0,0,0,0],
["p2",20,"op","chess_char_6_16_b",2,"uniequip_002_halo2",1582.9,1,12,0,145556,0,0,103,4],
["p2",21,"op","chess_char_6_18_b",2,"uniequip_002_whitw2",1695.1,1,14,1,292178,0,0,77,3],
["p2",20,"op","chess_char_6_16_b",2,"uniequip_002_halo2",1582.9,1,12,0,144110,0,0,103,4],
["p2",21,"op","chess_char_6_18_b",2,"uniequip_002_whitw2",1695.1,1,14,1,323718,0,0,77,3],
["p2",22,"op","chess_char_6_20_b",2,"uniequip_002_agoat2",1828.31,1,18,0,0,1103,37842,58,2],
["p1","~1","token","token_10030_mlyss_wtrman",null,null,0,0,34,0,10960,2454,630,13,null],
["p1","~2","token","token_10030_mlyss_wtrman",null,null,0,0,41,0,5575,1593,0,9,1],
@@ -859,10 +859,10 @@
["p1","~3","token","token_10017_skadi2_dedant",null,null,100,1,51,0,0,0,0,0,null]
],
"enemies": {"enemy_1067_snslime":[8,8,0,11760,111795],"enemy_1081_sotisd":[4,4,0,0,257989],"enemy_1249_lysdb_2":[4,4,0,0,103196],"enemy_9033_acdeer":[1,1,0,40235,4000000]},
"hooks": [1,49,5209,1281,1281,3051,3357,2765,22,27,93,88,305,37705,1418,3,17,0,1,21,0,10,0],
"events": {"atk":1445,"deploy":32,"die":27,"dmg":3088,"engage":16,"fx":675,"heal":436,"skill":181,"spawn":47,"status":511},
"fx": {"airstrike":104,"beam":5,"blink":2,"bombard":63,"bombardShell":64,"burst":10,"column":45,"copy":4,"dp":4,"drone":245,"droneLock":15,"drones":3,"explode":6,"garrisonGrant":1,"link":1,"lock":69,"phase":1,"scorchBurst":2,"shieldBreak":4,"snow":5,"split":2,"steal":6,"sunBurst":4,"talent":4,"tide":5,"wanted":1},
"snaps": "e8f77b71 c1cf2cef a6688abd b9a6fe99 07c13245 40140044 ddfccb97 273b0a5a 3f6c7803 621a62a4 561c43b2 75e4ac28 41579a2d 3b4b24fa 4aa3b38d e05ffd35 6d9a4f36 45587dcb"
"hooks": [1,49,5195,1278,1278,3120,3426,2763,22,27,93,88,303,37593,1413,3,17,0,1,21,0,10,0],
"events": {"atk":1442,"deploy":32,"die":27,"dmg":3157,"engage":16,"fx":670,"heal":436,"skill":181,"spawn":47,"status":511},
"fx": {"airstrike":104,"beam":5,"blink":2,"bombard":62,"bombardShell":63,"burst":10,"column":45,"copy":4,"dp":4,"drone":242,"droneLock":15,"drones":3,"explode":6,"garrisonGrant":1,"link":1,"lock":69,"phase":1,"scorchBurst":2,"shieldBreak":4,"snow":5,"split":2,"steal":6,"sunBurst":4,"talent":4,"tide":5,"wanted":1},
"snaps": "cbd449f6 0fc766d0 b5a9198a 1965b1bc 8e6f8d60 822d854f ceed08a1 a52227ff 7ea8a6e4 a812122a 48bcd4d6 32e527fa ea780f22 4030b232 09f24b6e 2211aae3 ab22490c adfcf0bd"
},
"boss-boss_7-solo": {
"about": "“萨米的意志” solo pool 2000000: chess_char_5_08_b chess_char_5_11_b chess_char_5_05_b chess_char_5_06_b chess_char_5_13_b chess_char_5_07_b chess_char_5_02_b chess_char_5_03_b chess_char_5_10_b chess_char_5_12_b",
@@ -922,94 +922,95 @@
"time": 200,
"ticks": 6000,
"reason": "forced",
"killed": 15,
"killed": 18,
"total": 14,
"unspawned": 0,
"bossHpLeft": 12404101,
"rngDraws": 801,
"bossHpLeft": 12470327,
"rngDraws": 722,
"errors": {"count":0,"kinds":[]},
"players": {
"p1": {"killed":15,"total":14,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":2215476,"bossDmg":1070309,"heal":58998,"deaths":14,"layerGains":{}},
"p2": {"killed":0,"total":0,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":1279788,"bossDmg":924060,"heal":27987,"deaths":25,"layerGains":{}}
"p1": {"killed":18,"total":14,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":2276269,"bossDmg":985262,"heal":41403,"deaths":15,"layerGains":{}},
"p2": {"killed":0,"total":0,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":1191003,"bossDmg":943816,"heal":23084,"deaths":21,"layerGains":{}}
},
"units": [
["p1",1,"op","chess_char_5_19_b",2,"uniequip_002_mlynar",3979.46,1,68,3,59812,22848,1042,28,3],
["p1",2,"op","chess_char_4_01_b",2,"uniequip_002_rmixer",7781.4,1,88,0,597,10499,997,4,1],
["p1",3,"op","chess_char_5_14_b",1,"none",4782.8,1,71,3,362707,14245,6320,0,15],
["p1",4,"op","chess_char_5_17_b",1,"uniequip_002_f12yin",6023.6,1,69,0,39722,30151,8288,63,2],
["p1",5,"op","chess_char_5_15_b",1,"uniequip_002_thorn2",709.08,1,76,2,921735,6082,1643,103,13],
["p1",6,"op","chess_char_5_20_b",2,"uniequip_002_aglina",0,0,83,1,191630,18072,3781,36,4],
["p1",7,"op","chess_char_5_21_b",1,"uniequip_002_sntlla",881.8,1,82,0,3590,6716,430,5,1],
["p1",8,"op","chess_char_5_22_b",1,"uniequip_002_nymph",0,0,80,1,180009,7537,448,19,7],
["p1",9,"op","chess_char_5_23_b",1,"uniequip_002_reckpr",3001.6,1,62,0,15030,9354,35036,28,3],
["p1",10,"op","chess_char_4_02_b",2,"uniequip_002_mostma",3204,1,78,3,440644,11570,1014,26,2],
["p2",11,"op","chess_char_4_11_b",1,"uniequip_002_cathy",0,0,73,0,0,9019,3,0,2],
["p2",12,"op","chess_char_4_04_b",1,"uniequip_002_ines",2082.13,1,87,1,33223,15101,6191,110,5],
["p2",13,"op","chess_char_4_07_b",1,"uniequip_002_bpipe",4587.28,1,92,0,28848,13491,4316,26,8],
["p2",14,"op","chess_char_4_16_b",2,"uniequip_002_texas2",0,0,77,0,155229,20706,7082,45,8],
["p2",15,"op","chess_char_4_09_b",1,"uniequip_002_mizuki",4392.08,1,86,0,89084,10204,1419,30,3],
["p2",16,"op","chess_char_4_06_b",1,"uniequip_002_kroos2",0,0,84,2,612597,3434,0,158,3],
["p2",17,"op","chess_char_4_10_b",1,"uniequip_002_aroma",1830.4,1,93,0,77261,6468,4637,71,3],
["p2",18,"op","chess_char_4_12_b",2,"uniequip_002_glady",0,0,75,0,54940,8803,3893,26,2],
["p2",19,"op","chess_char_4_13_b",1,"uniequip_002_gnosis",2187.9,1,91,0,33124,4455,79,38,11],
["p2",20,"op","chess_char_4_14_b",1,"uniequip_002_lionhd",0,0,64,0,195483,3942,365,54,13],
["p1",1,"op","chess_char_5_19_b",2,"uniequip_002_mlynar",2289.94,1,53,1,100479,15599,698,44,3],
["p1",2,"op","chess_char_4_01_b",2,"uniequip_002_rmixer",8085.64,1,76,0,4934,11577,653,8,2],
["p1",3,"op","chess_char_5_14_b",1,"none",4782.8,1,11,5,429323,20994,17859,40,23],
["p1",4,"op","chess_char_5_17_b",1,"uniequip_002_f12yin",6023.6,1,87,0,60190,23370,6042,27,2],
["p1",5,"op","chess_char_5_15_b",1,"uniequip_002_thorn2",0,0,80,1,733079,5578,430,69,8],
["p1",6,"op","chess_char_5_20_b",2,"uniequip_002_aglina",0,0,70,1,461840,11328,243,29,3],
["p1",7,"op","chess_char_5_21_b",1,"uniequip_002_sntlla",0,0,75,0,8961,7168,0,18,2],
["p1",8,"op","chess_char_5_22_b",1,"uniequip_002_nymph",886.2,1,74,5,224545,6202,0,36,10],
["p1",9,"op","chess_char_5_23_b",1,"uniequip_002_reckpr",3542,1,88,0,16232,9141,15477,12,2],
["p1",10,"op","chess_char_4_02_b",2,"uniequip_002_mostma",3729,1,85,1,236686,7458,0,26,3],
["p2",11,"op","chess_char_4_11_b",1,"uniequip_002_cathy",2014.55,1,73,0,0,5402,0,0,1],
["p2",12,"op","chess_char_4_04_b",1,"uniequip_002_ines",1319.07,1,63,1,35286,12845,5536,125,6],
["p2",13,"op","chess_char_4_07_b",1,"uniequip_002_bpipe",4577.28,1,90,0,4436,11556,2372,1,1],
["p2",14,"op","chess_char_4_16_b",2,"uniequip_002_texas2",987.72,1,89,1,167433,19491,3293,64,11],
["p2",15,"op","chess_char_4_09_b",1,"uniequip_002_mizuki",1500.55,1,62,0,138625,10173,2890,51,4],
["p2",16,"op","chess_char_4_06_b",1,"uniequip_002_kroos2",805.46,1,71,2,602554,4129,1500,171,3],
["p2",17,"op","chess_char_4_10_b",1,"uniequip_002_aroma",1830.4,1,94,0,83393,5856,4026,77,3],
["p2",18,"op","chess_char_4_12_b",2,"uniequip_002_glady",0,0,79,0,28501,6850,1940,11,1],
["p2",19,"op","chess_char_4_13_b",1,"uniequip_002_gnosis",2187.9,1,65,0,42006,3559,1371,54,17],
["p2",20,"op","chess_char_4_14_b",1,"uniequip_002_lionhd",288.45,1,84,0,88770,5234,157,35,6],
["p2",21,"token","token_10041_cathy_catsld",null,null,100,1,21,0,0,0,0,0,null],
["p2",22,"token","token_10041_cathy_catsld",null,null,100,1,22,0,0,0,0,0,null]
],
"enemies": {
"enemy_10027_vtsk": [6,3,0,149405,1092909],
"enemy_10027_vtsk": [6,6,0,122226,1203674],
"enemy_1005_yokai": [4,4,0,0,10699],
"enemy_1229_darmy_2": [8,8,0,3936,294231],
"enemy_9013_acstmk_2": [1,0,0,58642,1995899],
"enemy_9014_acstma": [5,4,0,5260,53368],
"enemy_9015_acstmb": [3,2,0,15454,71943],
"enemy_9016_acstmr": [8,7,0,0,207]
"enemy_1229_darmy_2": [8,8,0,4099,294231],
"enemy_9013_acstmk_2": [1,0,0,55096,1929673],
"enemy_9014_acstma": [6,5,0,8751,27362],
"enemy_9015_acstmb": [3,2,0,13340,21281],
"enemy_9016_acstmr": [8,6,0,0,182]
},
"hooks": [1,91,6000,981,982,3131,3322,9373,67,69,109,108,4,28792,692,18,35,0,1,60,0,4,3],
"events": {"atk":1134,"deploy":56,"die":69,"dmg":3309,"engage":17,"fx":717,"heal":46,"skill":217,"spawn":57,"status":920},
"hooks": [1,92,6000,989,994,3078,3208,8581,68,69,111,105,5,28677,1173,11,36,0,1,57,0,6,9],
"events": {"atk":1147,"deploy":56,"die":69,"dmg":3196,"engage":18,"fx":675,"heal":44,"skill":216,"spawn":58,"status":984},
"fx": {
"aoe": 33,
"appear": 9,
"beam": 48,
"blink": 14,
"burst": 4,
"charge": 14,
"counter": 4,
"crit": 87,
"dilemma": 3,
"disappear": 9,
"dodge": 3,
"explode": 8,
"explosion": 13,
"frostNova": 11,
"iceSpike": 4,
"overclock": 2,
"palsy": 2,
"phase": 4,
"pull": 2,
"quadShot": 3,
"revive": 2,
"ripple": 10,
"sentry": 4,
"sermon": 1,
"shadowWeave": 10,
"aoe": 57,
"appear": 5,
"beam": 45,
"blink": 16,
"burst": 6,
"charge": 8,
"counter": 5,
"crit": 75,
"dilemma": 4,
"disappear": 5,
"displace": 4,
"dodge": 9,
"explode": 11,
"explosion": 6,
"frostNova": 17,
"iceSpike": 14,
"overclock": 1,
"palsy": 1,
"phase": 2,
"pull": 1,
"quadShot": 2,
"revive": 1,
"ripple": 15,
"sentry": 3,
"sermon": 2,
"shadowWeave": 12,
"shell": 8,
"shield": 4,
"shield": 3,
"slippery": 3,
"soul": 4,
"sp": 10,
"spGain": 10,
"stealth": 5,
"soul": 6,
"sp": 8,
"spGain": 2,
"stealth": 6,
"summon": 4,
"swordRain": 113,
"swordStorm": 8,
"swordRain": 130,
"swordStorm": 11,
"telegraph": 6,
"tornado": 2,
"tornadoPulse": 10,
"weightless": 4,
"zone": 222
"tornado": 1,
"tornadoPulse": 5,
"weightless": 3,
"zone": 152
},
"snaps": "d02c996b 56caaf8f 98ada11d caf314f0 6d1e9373 60b5c4bc c0eeef76 df8917c9 ab98ad25 ff44ca0f 82bc2afa d48662e7 63ccec80 984bb208 6b10ba88 ccb1051b cca27d8b ba01f70a f5c4f257 da196656"
"snaps": "d02c996b bf196ad8 6d4d8184 70be9a2b 4bb3bf6a aa3de044 80e4d293 1ec6c7a8 38342de7 2db02069 4ad38abc 4dd8ef16 5e433b8a 0f842362 4ae4d533 100f2e9e 8fbb7d4c 5f28d85e a6990f85 440ef24a"
},
"hidden-boss_8-solo": {
"about": "假想敌:胄 solo pool 7200000: chess_char_4_17_b chess_char_4_18_b chess_char_4_23_b chess_char_4_19_b chess_char_4_22_b chess_char_4_20_b chess_char_4_21_b chess_char_4_24_b chess_char_4_25_b chess_char_4_26_b",
@@ -1076,12 +1077,12 @@
"killed": 8,
"total": 8,
"unspawned": 0,
"bossHpLeft": 2992511,
"bossHpLeft": 2988263,
"rngDraws": 1828,
"errors": {"count":0,"kinds":[]},
"players": {
"p1": {"killed":8,"total":8,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":2840728,"bossDmg":1130176,"heal":26454,"deaths":3,"layerGains":{}},
"p2": {"killed":0,"total":0,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":3891716,"bossDmg":1427465,"heal":133966,"deaths":7,"layerGains":{}}
"p2": {"killed":0,"total":0,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":3904460,"bossDmg":1431713,"heal":133966,"deaths":7,"layerGains":{}}
},
"units": [
["p1",1,"op","chess_char_6_03_b",2,"uniequip_002_yu",7326,1,15,0,3237,9163,15665,31,3],
@@ -1096,7 +1097,7 @@
["p1",10,"op","chess_char_6_11_b",2,"uniequip_002_mlyss",2082.3,1,59,0,30083,11261,306,74,2],
["p1",11,"token","token_10017_skadi2_dedant",null,null,138,0,28,0,0,0,0,0,null],
["p1",12,"token","token_10030_mlyss_wtrman",null,null,0,0,24,0,0,1670,348,4,1],
["p2",13,"op","chess_char_6_15_b",2,"uniequip_002_qiubai",903.1,1,68,4,817795,32754,0,248,4],
["p2",13,"op","chess_char_6_15_b",2,"uniequip_002_qiubai",903.1,1,68,4,819088,32754,0,248,4],
["p2",14,"op","chess_char_6_19_b",2,"uniequip_002_blkkgt",0,0,64,0,355848,15836,0,48,4],
["p2",15,"op","chess_char_5_01_b",1,"uniequip_002_excu2",1415.78,1,66,0,123216,11064,6422,44,2],
["p2",16,"op","chess_char_6_17_b",2,"uniequip_002_nearl2",1602.9,1,65,1,499184,28500,0,76,3],
@@ -1104,7 +1105,7 @@
["p2",18,"op","chess_char_6_13_b",1,"uniequip_002_angel2",2436.5,1,14,0,526339,8805,4488,259,5],
["p2",19,"op","chess_char_6_14_b",2,"uniequip_002_lumen",3342.24,1,20,0,0,0,19549,78,1],
["p2",20,"op","chess_char_6_16_b",2,"uniequip_002_halo2",1582.9,1,10,0,103786,0,0,127,3],
["p2",21,"op","chess_char_6_18_b",2,"uniequip_002_whitw2",1859.61,1,27,0,346061,5155,0,25,3],
["p2",21,"op","chess_char_6_18_b",2,"uniequip_002_whitw2",1859.61,1,27,0,357512,5155,0,25,3],
["p2",22,"op","chess_char_6_20_b",2,"uniequip_002_agoat2",1758.31,1,25,0,0,14000,103507,78,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],
@@ -1128,13 +1129,13 @@
"enemies": {
"enemy_1267_nhpbr_2": [6,6,0,1900,141894],
"enemy_9007_acelem": [2,2,0,14390,309587],
"enemy_9017_achunt_2": [2,0,0,57663,2607489],
"enemy_9018_actrpa": [1,0,0,53759,1092472],
"enemy_9019_actrpb": [1,0,0,24773,1015854],
"enemy_9020_actrpc": [1,0,0,22601,1668902]
"enemy_9017_achunt_2": [2,0,0,57663,2611737],
"enemy_9018_actrpa": [1,0,0,53759,1094596],
"enemy_9019_actrpb": [1,0,0,24773,1018036],
"enemy_9020_actrpc": [1,0,0,22601,1673092]
},
"hooks": [1,66,6000,1649,1649,8552,17196,2968,22,35,96,88,305,47649,3685,17,13,0,1,31,0,19,0],
"events": {"atk":2230,"deploy":47,"die":35,"dmg":16662,"engage":22,"fx":2182,"heal":905,"skill":184,"spawn":53,"status":898},
"hooks": [1,66,6000,1649,1649,8574,17284,2968,22,35,96,88,305,47649,3706,17,13,0,1,31,0,19,0],
"events": {"atk":2230,"deploy":47,"die":35,"dmg":16750,"engage":22,"fx":2182,"heal":905,"skill":184,"spawn":53,"status":898},
"fx": {
"airstrike": 93,
"beam": 26,
@@ -1175,7 +1176,7 @@
"wanted": 6,
"zone": 6
},
"snaps": "8b102142 0561d2e7 b88fff99 8716a28b 1badf400 91b895ee 78b15f27 974adf64 c118d65e 831cdc6f 0c7385df d5ab8494 2e6d06b7 f460f583 e7a7bafc 6ab1c142 80a01218 1de18f0f 84c4afee 5fb9b9bc"
"snaps": "8b102142 0561d2e7 b88fff99 8716a28b 029ba95f b318b100 89a71563 dbd3fc50 39edd668 e8bad5d2 6041dbb0 1d54607c baabef98 3f4c928d 4b565fc8 77ef652f 56feb3e3 b839d42c 8bf608c0 5b35e9d9"
},
"hidden-boss_9-solo": {
"about": "假想敌:铳 solo pool 2800000: chess_char_5_08_b chess_char_5_11_b chess_char_5_05_b chess_char_5_06_b chess_char_5_13_b chess_char_5_07_b chess_char_5_02_b chess_char_5_03_b chess_char_5_10_b chess_char_5_12_b",
@@ -1192,36 +1193,36 @@
"killed": 3,
"total": 3,
"unspawned": 0,
"bossHpLeft": 1849908,
"rngDraws": 78,
"bossHpLeft": 1836987,
"rngDraws": 90,
"errors": {"count":0,"kinds":[]},
"players": {"p1":{"killed":3,"total":3,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":3002745,"bossDmg":950092,"heal":61752,"deaths":3,"layerGains":{}}},
"players": {"p1":{"killed":3,"total":3,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":3042226,"bossDmg":963013,"heal":64860,"deaths":3,"layerGains":{}}},
"units": [
["p1",1,"op","chess_char_5_08_b",2,"uniequip_002_horn",3052.5,1,1,0,476949,9572,0,109,4],
["p1",2,"op","chess_char_5_11_b",1,"uniequip_002_demkni",5285.28,1,3,0,769,1695,18201,17,32],
["p1",3,"op","chess_char_5_05_b",2,"uniequip_002_ulpia",8327.72,1,27,0,197729,21991,14245,30,3],
["p1",4,"op","chess_char_5_06_b",2,"uniequip_002_etlchi",4121.7,1,7,3,1449449,10784,10860,189,5],
["p1",5,"op","chess_char_5_13_b",1,"uniequip_002_ghost2",3793.81,1,23,0,90247,12807,2014,38,2],
["p1",6,"op","chess_char_5_07_b",2,"uniequip_002_surtr",8329.03,1,30,0,47939,16562,10068,16,3],
["p1",7,"op","chess_char_5_02_b",2,"uniequip_002_titi",1582.9,1,5,0,173222,0,1141,26,1],
["p1",8,"op","chess_char_5_03_b",2,"uniequip_002_blaze2",1570.62,1,6,0,261744,1667,1618,126,5],
["p1",9,"op","chess_char_5_10_b",2,"uniequip_002_lisa",1628,1,19,0,11649,6861,3605,32,4],
["p1",10,"op","chess_char_5_12_b",0,"uniequip_002_dusk",1965.7,1,2,0,293047,0,0,55,43],
["p1","~1","token","token_10039_ulpia_block",null,null,3445,0,25,0,0,55,0,0,null]
["p1",1,"op","chess_char_5_08_b",2,"uniequip_002_horn",3052.5,1,1,0,485565,9572,0,110,4],
["p1",2,"op","chess_char_5_11_b",1,"uniequip_002_demkni",5285.28,1,3,0,976,1715,18804,21,33],
["p1",3,"op","chess_char_5_05_b",2,"uniequip_002_ulpia",8327.72,1,27,0,194374,22994,14665,30,3],
["p1",4,"op","chess_char_5_06_b",2,"uniequip_002_etlchi",4121.7,1,7,3,1502013,10613,10689,188,5],
["p1",5,"op","chess_char_5_13_b",1,"uniequip_002_ghost2",3224.69,1,23,0,90135,13221,2565,39,2],
["p1",6,"op","chess_char_5_07_b",2,"uniequip_002_surtr",8408.39,1,30,0,41860,16626,11246,15,3],
["p1",7,"op","chess_char_5_02_b",2,"uniequip_002_titi",1582.9,1,5,0,164732,0,1669,26,1],
["p1",8,"op","chess_char_5_03_b",2,"uniequip_002_blaze2",1570.62,1,6,0,258919,1667,1618,126,5],
["p1",9,"op","chess_char_5_10_b",2,"uniequip_002_lisa",1628,1,19,0,3326,6861,3605,32,4],
["p1",10,"op","chess_char_5_12_b",0,"uniequip_002_dusk",1965.7,1,2,0,300325,0,0,54,42],
["p1","~1","token","token_10039_ulpia_block",null,null,3450,0,25,0,0,50,0,0,null]
],
"enemies": {
"enemy_10067_ftsjc": [1,1,0,1161,21565],
"enemy_5601_entlec": [6,5,0,0,195393],
"enemy_5601_entlec": [6,5,0,0,192872],
"enemy_9007_acelem": [2,2,0,843,119439],
"enemy_9017_achunt_2": [1,0,0,26511,950092],
"enemy_9018_actrpa": [1,0,0,13528,521917],
"enemy_9019_actrpb": [1,0,0,11378,495636],
"enemy_9020_actrpc": [1,0,0,9816,698702]
"enemy_9017_achunt_2": [1,0,0,27050,963013],
"enemy_9018_actrpa": [1,0,0,14222,535267],
"enemy_9019_actrpb": [1,0,0,10981,495224],
"enemy_9020_actrpc": [1,0,0,10459,714846]
},
"hooks": [1,31,6000,736,736,2859,7301,4071,11,14,102,98,89,23761,2136,8,13,0,1,60,0,7,0],
"events": {"atk":1173,"deploy":18,"die":14,"dmg":6843,"engage":10,"fx":824,"heal":298,"skill":200,"spawn":24,"status":370},
"fx": {"anchor":3,"aoe":3,"beam":21,"burst":7,"candle":6,"charge":7,"dash":12,"dollEnd":1,"downed":1,"ember":2,"explode":1,"healAoe":32,"link":597,"meltdown":2,"overload":4,"phase":11,"revive":1,"shieldBreak":10,"splash":89,"substitute":2,"swap":1,"teleport":2,"wake":9},
"snaps": "a908aa8e 92af617b 47569ab8 b6349ee0 6a67acc6 34c7150b e9dd0fa5 d0b8a47d dbb7a067 d4bfa101 8208a605 2015078d a23dde6d e5181a02 01a93ffe 4842918b 44122ec7 02bb5829 0ec1885e faaabcfa"
"hooks": [1,31,6000,737,737,2895,7352,4277,11,14,102,98,89,23822,2082,10,13,0,1,58,0,6,0],
"events": {"atk":1180,"deploy":18,"die":14,"dmg":6900,"engage":10,"fx":825,"heal":308,"skill":200,"spawn":24,"status":362},
"fx": {"anchor":3,"aoe":3,"beam":22,"burst":6,"candle":6,"charge":7,"dash":12,"dollEnd":1,"downed":1,"ember":2,"explode":1,"healAoe":33,"link":597,"meltdown":1,"overload":4,"palsy":1,"phase":11,"revive":1,"shieldBreak":10,"splash":89,"substitute":2,"swap":1,"teleport":2,"wake":9},
"snaps": "a908aa8e 47086a5b ae11e298 9cf2c3e8 58fb69ef 2137945c 95f527fb e578cf06 1d999f5b 03ef509d 922f4e89 45ae69ae d5aee084 f310ee44 51e2ba61 1e0729b8 bca314e5 c2dd8505 976be9bc be1a9f9f"
},
"hidden-boss_10-pair": {
"about": "假想敌:管 pair pool 15200000: chess_char_5_19_b chess_char_4_01_b chess_char_5_14_b chess_char_5_17_b chess_char_5_15_b chess_char_5_20_b chess_char_5_21_b chess_char_5_22_b chess_char_5_23_b chess_char_4_02_b | chess_char_4_11_b chess_char_4_04_b chess_char_4_07_b chess_char_4_16_b chess_char_4_09_b chess_char_4_06_b chess_char_4_10_b chess_char_4_12_b chess_char_4_13_b chess_char_4_14_b token_10041_cathy_catsld token_10041_cathy_catsld",
+282 -282
View File
File diff suppressed because it is too large. Load diff
+536 -535
View File
File diff suppressed because it is too large. Load diff
+59 -59
View File
@@ -238,10 +238,10 @@
"enemy_1071_dftman": [63,63,0,455,243855],
"enemy_1072_dlancer": [60,55,5,22144,476588]
},
"hooks": [1,178,6234,1000,1000,2074,2072,74,158,168,32,32,0,36983,4066,67,158,10,1,160,2,0,11],
"hooks": [1,178,6234,1000,1000,2074,2072,74,158,168,32,32,0,36983,4064,67,158,10,1,160,2,0,11],
"events": {"atk":1142,"bounty":4,"deploy":18,"die":158,"dmg":2072,"engage":11,"fx":161,"heal":53,"layer":2,"leak":10,"skill":64,"spawn":172,"status":472},
"fx": {"aoe":11,"dodge":14,"dp":38,"garrison":2,"revive":1,"slash":77,"sp":13,"spGain":3,"telegraph":2},
"snaps": "484fd662 19a9b252 827467e6 d8836b51 4e1a5c20 f5f33eaf 009d313f 487fac84 6e33c253 481fa961 eee28282 47fecc48 ec2c4a14 046a7402 d017b22b a2a8910b b53ce9db 2504d954 222d228d 78e5e524 2b44a10f"
"snaps": "484fd662 19a9b252 827467e6 5c638330 c6d0b532 c1538ba9 691c2e95 65eab1de e256c73c 337bd270 302985e3 18a84929 737e1169 1cc75867 71696cfe c0e56f12 25c880fa 10b7e655 a65f5fd8 02e1a041 602de50a"
},
"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",
@@ -303,49 +303,49 @@
"round": 10,
"seed": 1442657168,
"waveId": "act1autochess_h03",
"time": 187.4,
"ticks": 5622,
"time": 188.1,
"ticks": 5643,
"reason": "cleared",
"killed": 30,
"killed": 26,
"total": 98,
"unspawned": 0,
"bossHpLeft": null,
"rngDraws": 315,
"rngDraws": 260,
"errors": {"count":0,"kinds":[]},
"players": {
"p1": {"killed":30,"total":98,"perfect":false,"leaks":68,"leaksUncounted":0,"coins":6,"dmg":472224,"bossDmg":0,"heal":15707,"deaths":31,"layerGains":{"raidShip":24}}
"p1": {"killed":26,"total":98,"perfect":false,"leaks":72,"leaksUncounted":0,"coins":2,"dmg":365014,"bossDmg":0,"heal":14451,"deaths":30,"layerGains":{"raidShip":26}}
},
"units": [
["p1",1,"op","chess_char_3_12_b",2,"none",1904,1,139,4,33527,4810,222,6,1],
["p1",2,"op","chess_char_4_09_b",0,"none",0,0,6,0,13369,3510,0,5,1],
["p1",3,"op","chess_char_3_05_b",2,"none",0,0,90,1,32273,11344,0,20,2],
["p1",4,"op","chess_char_3_18_b",2,"none",0,0,12,1,32236,5744,2553,22,1],
["p1",5,"op","chess_char_4_04_b",1,"none",0,0,138,0,13918,12430,0,38,9],
["p1",6,"op","chess_char_4_07_b",1,"none",0,0,93,0,13096,7538,0,19,1],
["p1",7,"op","chess_char_4_16_b",1,"none",0,0,137,3,45194,14837,0,51,13],
["p1",8,"op","chess_char_3_01_b",2,"none",0,0,132,6,26811,3020,0,27,1],
["p1",9,"op","chess_char_3_04_b",2,"none",0,0,4,0,0,3135,12932,15,1],
["p1",10,"op","chess_char_3_21_b",2,"none",0,0,113,10,201155,6858,0,55,1],
["p1",11,"op","chess_char_4_02_b",1,"none",0,0,2,2,47025,1483,0,25,1],
["p1",12,"op","chess_char_4_13_b",1,"none",0,0,101,3,13621,2704,0,23,1]
["p1",1,"op","chess_char_3_12_b",2,"none",0,0,141,4,34546,8030,567,13,2],
["p1",2,"op","chess_char_4_09_b",0,"none",0,0,6,1,4723,3510,0,2,1],
["p1",3,"op","chess_char_3_05_b",2,"none",0,0,95,2,10329,8035,0,8,1],
["p1",4,"op","chess_char_3_18_b",2,"none",0,0,107,1,28674,9956,3574,31,2],
["p1",5,"op","chess_char_4_04_b",1,"none",1181,1,143,0,23896,8829,0,32,9],
["p1",6,"op","chess_char_4_07_b",1,"none",0,0,92,0,10794,10491,0,13,1],
["p1",7,"op","chess_char_4_16_b",1,"none",1370,1,142,5,49422,13987,0,51,16],
["p1",8,"op","chess_char_3_01_b",2,"none",0,0,126,3,12357,3020,0,28,1],
["p1",9,"op","chess_char_3_04_b",2,"none",0,0,4,0,0,1374,10311,10,1],
["p1",10,"op","chess_char_3_21_b",2,"none",0,0,5,8,161347,7970,0,41,1],
["p1",11,"op","chess_char_4_02_b",1,"none",0,0,2,1,21755,1483,0,24,1],
["p1",12,"op","chess_char_4_13_b",1,"none",0,0,93,1,7170,2704,0,14,1]
],
"enemies": {
"enemy_10162_mnctpt": [1,0,1,12236,28023],
"enemy_1019_jshoot": [1,1,0,769,4479],
"enemy_1019_jshoot_2": [1,1,0,1779,6718],
"enemy_1021_bslime": [1,1,0,2095,6121],
"enemy_1023_jmage": [1,1,0,2403,5474],
"enemy_1025_reveng": [1,1,0,0,12442],
"enemy_1026_aghost": [1,1,0,0,9456],
"enemy_10162_mnctpt": [1,0,1,24498,31642],
"enemy_1019_jshoot": [1,1,0,1057,4479],
"enemy_1019_jshoot_2": [1,1,0,1489,6718],
"enemy_1021_bslime": [1,1,0,0,6121],
"enemy_1023_jmage": [1,0,1,1739,4719],
"enemy_1025_reveng": [1,0,1,0,0],
"enemy_1026_aghost": [1,0,1,0,0],
"enemy_1043_zomsbr": [1,0,1,0,0],
"enemy_1251_lysyta_2": [57,18,39,8755,173039],
"enemy_1252_lysytb_2": [21,5,16,29535,174396],
"enemy_1329_cbshld_2": [12,1,11,19842,52076]
"enemy_1251_lysyta_2": [57,19,38,10100,171673],
"enemy_1252_lysytb_2": [21,3,18,22220,98462],
"enemy_1329_cbshld_2": [12,1,11,18287,41199]
},
"hooks": [1,139,5622,474,474,678,694,42,61,138,33,33,0,6117,24,44,98,68,1,66,3,0,7],
"events": {"atk":549,"bounty":3,"deploy":41,"die":70,"dmg":694,"engage":11,"fx":35,"heal":40,"layer":3,"leak":68,"skill":66,"spawn":110,"status":83},
"fx": {"blink":9,"camouflage":1,"dodge":8,"dp":5,"explode":1,"garrison":3,"slash":8},
"snaps": "be6d1821 33db37f0 a6ec1fa2 487058c3 2aef7534 379bf51a d87bcaba 6953291e ea8de47f e0cfe2ef c5d6d6f0 9fd5858c 99048d37 dfd46fb1 d86de307 637bf595 72c11ba9 adb364dc bb18d903"
"hooks": [1,143,5643,434,434,611,621,53,56,141,37,36,0,5387,16,38,98,72,1,76,4,0,5],
"events": {"atk":484,"bounty":1,"deploy":45,"die":69,"dmg":621,"engage":11,"fx":47,"heal":49,"layer":4,"leak":72,"skill":73,"spawn":110,"status":72},
"fx": {"blink":13,"camouflage":1,"dodge":6,"dp":6,"explode":1,"garrison":4,"slash":16},
"snaps": "be6d1821 a39efdfb 21249099 b87f9e0b e72ea2bf 19eea832 a489c558 7e2006d5 2678e482 1cfd1c39 6932b88d 1754c6ee bf334ae2 ab95ad5f 981485c7 305e6e06 6a8a10b3 af7b8b0c c92211d8"
},
"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",
@@ -359,7 +359,7 @@
"time": 223.2,
"ticks": 6696,
"reason": "cleared",
"killed": 10,
"killed": 11,
"total": 80,
"unspawned": 0,
"bossHpLeft": null,
@@ -367,13 +367,13 @@
"errors": {"count":0,"kinds":[]},
"players": {
"p1": {
"killed": 10,
"killed": 11,
"total": 80,
"perfect": false,
"leaks": 70,
"leaks": 69,
"leaksUncounted": 0,
"coins": 4,
"dmg": 144421,
"dmg": 165203,
"bossDmg": 0,
"heal": 0,
"deaths": 25,
@@ -381,38 +381,38 @@
}
},
"units": [
["p1",1,"op","chess_char_4_17_b",1,"none",0,0,3,0,189,3211,0,1,1],
["p1",2,"op","chess_char_4_18_b",1,"none",0,0,86,0,60,9306,0,2,1],
["p1",1,"op","chess_char_4_17_b",1,"none",0,0,3,0,210,3211,0,2,1],
["p1",2,"op","chess_char_4_18_b",1,"none",0,0,86,0,404,9306,0,2,1],
["p1",3,"op","chess_char_5_05_b",2,"none",0,0,100,0,572,5816,0,1,1],
["p1",4,"op","chess_char_4_19_b",1,"none",1847.63,1,110,1,466,3864,0,8,1],
["p1",4,"op","chess_char_4_19_b",1,"none",1847.63,1,110,0,1427,3864,0,9,1],
["p1",5,"op","chess_char_5_01_b",2,"none",1036.53,1,108,0,2197,3985,0,5,3],
["p1",6,"op","chess_char_4_22_b",1,"none",3191,1,112,0,1090,14040,0,8,1],
["p1",7,"op","chess_char_4_23_b",1,"none",0,0,71,0,3484,6865,0,8,1],
["p1",6,"op","chess_char_4_22_b",1,"none",3191,1,112,0,1918,14040,0,9,1],
["p1",7,"op","chess_char_4_23_b",1,"none",0,0,71,0,4833,6856,0,9,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,109,2,15866,4785,0,30,1],
["p1",10,"op","chess_char_4_24_b",1,"none",1007.59,1,111,1,5309,3072,0,18,1],
["p1",11,"op","chess_char_5_02_b",2,"none",0,0,105,1,8684,4080,0,19,1],
["p1",12,"op","chess_char_5_03_b",2,"uniequip_002_accast",0,0,4,5,106504,1749,0,10,1],
["p1",9,"op","chess_char_4_20_b",1,"none",1255.06,1,109,2,17110,4785,0,31,1],
["p1",10,"op","chess_char_4_24_b",1,"none",1007.59,1,111,0,5710,3072,0,20,1],
["p1",11,"op","chess_char_5_02_b",2,"none",0,0,105,1,8810,4080,0,19,1],
["p1",12,"op","chess_char_5_03_b",2,"uniequip_002_accast",0,0,4,8,122012,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,4,14,3323,11182],
"enemy_1041_lazerd_2": [24,3,21,42917,34367],
"enemy_1042_frostd": [30,1,29,0,33047],
"enemy_1045_hammer": [1,1,0,2515,15552],
"enemy_1045_hammer_2": [1,0,1,5062,16522],
"enemy_1046_agent": [1,1,0,57,3888],
"enemy_1050_lslime": [1,0,1,8323,7921],
"enemy_1061_zomshd": [1,0,1,61,13892],
"enemy_1062_rager_2": [1,0,1,2335,17050],
"enemy_1005_yokai_2": [18,4,14,3014,9642],
"enemy_1041_lazerd_2": [24,4,20,41823,46999],
"enemy_1042_frostd": [30,1,29,0,34310],
"enemy_1045_hammer": [1,1,0,1699,15552],
"enemy_1045_hammer_2": [1,0,1,4725,19083],
"enemy_1046_agent": [1,1,0,72,3888],
"enemy_1050_lslime": [1,0,1,9832,11882],
"enemy_1061_zomshd": [1,0,1,92,15423],
"enemy_1062_rager_2": [1,0,1,3328,18424],
"enemy_1067_snslime": [1,0,1,0,0],
"enemy_1071_dftman": [1,0,1,0,0]
},
"hooks": [1,112,6696,302,302,560,551,566,35,105,13,13,0,9244,8,13,80,70,1,35,2,0,9],
"events": {"atk":326,"bounty":2,"deploy":30,"die":35,"dmg":551,"engage":11,"fx":25,"layer":2,"leak":70,"skill":26,"spawn":94,"status":113},
"fx": {"aoe":1,"dodge":9,"dp":7,"garrison":2,"garrisonGrant":1,"palsy":2,"revive":3},
"snaps": "801e90ec c1cca6cb 81d33420 f867f133 edacd763 1628b012 6c63447d 731a52ce e4d1fee0 1aa30026 5afb0d3a 3292f917 f65a695f 7f9749da 215a493b 0a35d6fd 8300297d 71eb2c83 2ae65721 93a77bf5 f413d9ea 3b2f4b8c ef24d1bf"
"hooks": [1,112,6696,313,313,621,613,584,36,105,13,13,0,9350,10,16,80,69,1,36,2,0,8],
"events": {"atk":340,"bounty":2,"deploy":30,"die":36,"dmg":613,"engage":11,"fx":22,"layer":2,"leak":69,"skill":26,"spawn":94,"status":119},
"fx": {"aoe":1,"dodge":8,"dp":6,"garrison":2,"garrisonGrant":1,"palsy":1,"revive":3},
"snaps": "801e90ec 5b00cfa7 02fcc988 d17a26a7 7d9490e3 7c20747e 258b5555 d69fd966 78a9129e f1c1c23d 005c3248 a96fccfa 482d2cd2 45b5fc41 ee00fc07 df8408ae 69422636 2d376e7f 1809c675 8e61a10d 5a971160 902deae3 ff470735"
},
"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",
+23
View File
@@ -91,6 +91,29 @@ test('build-i18n matching: exact, blackboard templates, plain from raw, composit
assert.equal(shapeKey('攻击力<@ba.vup>+{atk:0%}</>'), shapeKey('攻击力+80%'));
});
test('build-i18n corrections: tried before the official text, the plain twin is stripped from the corrected raw one', () => {
const zh = [{ t: { a: '攻击力<@ba.vup>+50%</>' } }];
const en = [{ t: { a: 'ATK <@ba.vup>+50%</> flat' } }];
const data = { effects: { e1: { descRaw: '攻击力<@ba.vup>+50%</>', desc: '攻击力+50%' } } };
const { overlay, report } = buildOverlay({ zh, en, data, corrections: { _about: 'x', '攻击力<@ba.vup>+50%</>': 'ATK <@ba.vup>+50%</> per item' } });
const e = applyRecordOverlay(data.effects.e1, overlay.files.effects.e1).value;
assert.equal(e.descRaw, 'ATK <@ba.vup>+50%</> per item');
assert.equal(e.desc, 'ATK +50% per item');
assert.equal(report.coverage.effects.correction, 2);
assert.equal(report.coverage.effects.translated, 2);
// another language reads its own corrections the same way (build-i18n loads tools/i18n/corrections-<lang>.json)
const ko = buildOverlay({ zh, en: [{ t: { a: '공격력 <@ba.vup>+50%</>' } }], data, lang: 'ko', corrections: { '攻击力<@ba.vup>+50%</>': '장비 1개당 공격력 <@ba.vup>+50%</>' } });
assert.equal(applyRecordOverlay(data.effects.e1, ko.overlay.files.effects.e1).value.descRaw, '장비 1개당 공격력 <@ba.vup>+50%</>');
assert.equal(ko.report.coverage.effects.correction, 2);
});
test('data/i18n/en.json: the 维多利亚 6-member ATK bonus reads per item, as installVictoria applies it (corrections-en.json)', () => {
const en = JSON.parse(readFileSync(path.join(ROOT, 'data', 'i18n', 'en.json'), 'utf8'));
for (const t of [en.files.bonds.victoriaShip.effectDesc, en.files.bonds.victoriaShip.effectDescRaw, en.files.effects.bondeffect_victoria.desc]) {
assert.match(t, /\+50%(?:<\/>)? ATK per equipment carried, or (?:<@ba\.vup>)?\+80%(?:<\/>)? per upgraded equipment/);
}
});
test('data/i18n/en.json: shape, the 盟约 season of the EN build, coverage and the names map', () => {
assert.equal(OVERLAY.version, 1);
assert.equal(OVERLAY.lang, 'en');
+40
View File
@@ -0,0 +1,40 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeMatch } from './harness.js';
import { BOND_LAYER_CAP, PHASE } from '../../shared/constants.js';
const cues = h => h.sent.filter(([, msg]) => msg.t === 'm.layer');
function prep(t) {
const h = makeMatch({ mode: 'coop', humans: 2, fake: true }).start().toPrep(1);
t.after(() => h.m.dispose());
h.sent.length = 0;
return h;
}
test('prep gains unicast the actual increase once, even for an inactive bond', t => {
const h = prep(t), p = h.ps('p_0');
assert.equal(p.bonds.sargonShip?.active ?? false, false);
assert.equal(p.addLayers('sargonShip', 3), 3);
assert.deepEqual(cues(h), [['p_0', { t: 'm.layer', playerId: 'p_0', bondId: 'sargonShip', add: 3 }]]);
assert.equal(h.bc.some(msg => msg.t === 'm.layer'), false);
p.layers.sargonShip = BOND_LAYER_CAP - 1;
p.addLayers('sargonShip', 10);
assert.equal(cues(h).at(-1)[1].add, 1);
const n = cues(h).length;
for (const v of [1, 0, -2, NaN, Infinity]) p.addLayers('sargonShip', v);
p.addLayers('not-a-bond', 2);
p.addLayers('kjeragShip', 2, { requireActive: true });
assert.equal(cues(h).length, n);
});
test('only live prep phases emit, never settlement/backfill or resynchronization views', t => {
const h = prep(t), p = h.ps('p_0');
for (const phase of Object.values(PHASE)) {
h.m.phase = phase; h.sent.length = 0;
p.addLayers('sargonShip', 1);
assert.equal(cues(h).length, [PHASE.ROUND_START, PHASE.SP_DRAFT, PHASE.PREP].includes(phase) ? 1 : 0, phase);
}
h.m.phase = PHASE.PREP; h.sent.length = 0;
p.recompute(); p.privateView(); h.m.publicView();
assert.equal(cues(h).length, 0);
});
+66 -1
View File
@@ -7,12 +7,77 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { PHASE } from '../../shared/constants.js';
import { botPickCard, bountyKillChance, itemTarget, arrange, botPrepBegin, botPrepEnd, bondPlan, rangeTiles, effDps, rangeRec, buyScoreOf } from '../../server/match/bot.js';
import { botPickBand, botPickCard, bountyKillChance, itemTarget, arrange, botPrepBegin, botPrepEnd, bondPlan, rangeTiles, effDps, rangeRec, buyScoreOf } from '../../server/match/bot.js';
import { parseKey, tileKey } from '../../server/match/board.js';
import { makeMatch, checkInvariants, give, giveItem, legalTileFor, DATA } from './harness.js';
const soloBot = (o = {}) => makeMatch({ mode: 'solo', difficulty: 'NORMAL', seats: [{ seat: 0, playerId: 'ai_0', name: 'AI', isBot: true, connected: true }], ...o });
test('#514: a living teammate soft-reserves an opening bond, and elimination releases it', () => {
const { m } = makeMatch({ humans: 2, difficulty: 'HARD', seed: 11 }).start().toPrep();
const [human, bot] = m.order;
try {
bot.isBot = true;
human.bandId = null;
const chessOf = (bond) => Object.values(DATA.chess).find((c) => c.visible && !c.isGolden && c.tier <= 3
&& Array.isArray(c.bonds) && c.bonds.includes(bond)
&& c.bonds.filter((b) => m.gd.bond(b)?.isCore).length === 1);
const usable = m.gd.bondIds.filter((id) => m.gd.bond(id)?.isCore && !m.gd.modeInactiveBonds.has(id) && chessOf(id));
assert.ok(usable.length >= 2, 'two core bonds with a single-core chess');
const [a, b] = usable;
give(m, bot, chessOf(a).chessId, 'hand');
give(m, bot, chessOf(b).chessId, 'hand');
const focus = () => { bot._botFocus = null; bot._botFocusId = null; return bondPlan(m, bot).focus; };
const natural = focus();
assert.ok(natural === a || natural === b, natural);
const other = natural === a ? b : a;
let found = false;
for (let layers = 10; layers <= 60 && !found; layers += 10) {
bot.layers[other] = layers;
if (focus() === other) found = true;
}
assert.equal(focus(), other, 'one layer step is enough for the other bond to lead');
const band = m.gd.bandIds().find((id) => {
const bonds = m.gd.bandBondIds(id);
return bonds.includes(other) && !bonds.includes(natural);
});
assert.ok(band, `a strategy names ${other} and not ${natural}`);
human.bandId = band;
assert.notEqual(focus(), other, 'the opening strategy pulls focus off the reserved bond');
human.alive = false;
assert.equal(focus(), other, 'elimination ends the reservation');
} finally { m.dispose(); }
});
test('#514: strategy selection stays possible, and less likely, while a teammate holds that opening', () => {
const { m } = makeMatch({ humans: 2, difficulty: 'HARD', seed: 3 }).start().toPrep();
const [human, bot] = m.order;
try {
bot.isBot = true;
human.bandId = null;
const tally = (id, n = 400) => {
let hits = 0;
for (let i = 0; i < n; i++) if (botPickBand(m, bot) === id) hits++;
return hits;
};
const open = new Map();
for (let i = 0; i < 400; i++) {
const id = botPickBand(m, bot);
open.set(id, (open.get(id) || 0) + 1);
}
const ranked = [...open.entries()].filter(([id]) => m.gd.bandBondIds(id).length > 0).sort((x, y) => y[1] - x[1]);
const [band, base] = ranked[0];
assert.ok(base > 5, `${band} is drawn often enough to compare (${base})`);
human.bandId = band;
const reserved = tally(band);
assert.ok(reserved > 0, 'a soft reservation can still draw the strategy');
assert.ok(reserved < base, `reserved ${reserved} times, unreserved ${base}`);
human.alive = false;
const released = tally(band);
assert.ok(released > reserved, `elimination raises the rate from ${reserved} to ${released}`);
} finally { m.dispose(); }
});
test('user: Given a faction strategy teammate, When scoring shared high-tier chess, Then humans and bots receive the same preference even with full stock', () => {
const { m } = makeMatch({ humans: 3, seed: 11 }).start().toPrep();
const [ps, mate, other] = m.order;
+5 -3
View File
@@ -264,9 +264,11 @@ test('Final Assault: two client-run pair fields share the server pool (b.progres
h.drive(() => m.phase === PHASE.FINAL_ASSAULT);
const [b1, b2] = fields(h);
assert.deepEqual([b1.fieldId, b2.fieldId], ['b1', 'b2']);
assert.deepEqual([b1.mode, b1.authority, b2.mode, b2.authority], ['client', 'p_0', 'client', 'p_2'], 'lowest seat of each pair');
const authorities = [b1, b2].map(f => [...f.players].sort((a, b) => h.ps(a).seat - h.ps(b).seat)[0]);
assert.deepEqual([b1.mode, b1.authority, b2.mode, b2.authority], ['client', authorities[0], 'client', authorities[1]], 'lowest seat of each pair');
const startMsgs = h.sent.filter(([, x]) => x.t === 'b.start' && x.kind === 'boss');
assert.deepEqual(startMsgs.map(([pid, x]) => `${pid}:${x.fieldId}:${x.authoritative ? 'A' : 'r'}`).sort(), ['p_0:b1:A', 'p_1:b1:r', 'p_2:b2:A', 'p_3:b2:r']);
assert.deepEqual(startMsgs.map(([pid, x]) => `${pid}:${x.fieldId}:${x.authoritative ? 'A' : 'r'}`).sort(),
[b1, b2].flatMap((f, i) => f.players.map(pid => `${pid}:${f.fieldId}:${pid === authorities[i] ? 'A' : 'r'}`)).sort());
assert.equal(startMsgs[0][1].spec.boss.poolMax, m.bossPool.maxHp);
const max = m.bossPool.maxHp;
h.sched.advance(3000);
@@ -597,7 +599,7 @@ test('boss bounty eligibility: forced real results on a team victory qualify; sy
function bossPaid(seed, { leaks = 0, leaksBy = null, leaked = {} }) {
const { h, m, f } = faWithForger(seed);
const card = DATA.choices.cards.bounty.find((c) => c.payout === 'perfect' && m.gd.enemy(c.enemyKey));
const ps = f.players.map((pid) => h.ps(pid));
const ps = f.players.map((pid) => h.ps(pid)).sort((a, b) => a.seat - b.seat); // return payouts by player, not random side
for (const p of ps) m.addBounty(p, { ...card, rounds: 2, multiRound: false });
const before = ps.map((p) => p.pendingFunds);
const lp0 = m.teamLp;
+83
View File
@@ -0,0 +1,83 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { DATA, makeMatch, give, checkInvariants } from './harness.js';
import { GameData } from '../../server/match/gamedata.js';
import { computeBonds } from '../../server/match/bondsMeta.js';
function emptyPrep() {
const h = makeMatch({ mode: 'solo', difficulty: 'HARD', seed: 11, fake: true }).start().toPrep(1);
const p = h.ps('p_0');
for (const x of p.allChess()) p.returnCopies(x);
p.board.clear(); p.hand.fill(null); p.temp.fill(null); p.offers.length = 0; p.bandId = null; p.recompute();
return { h, m: h.m, p };
}
for (const g of Object.values(DATA.garrisons).filter(g => g.effectKey === 'SERVER_GAIN_EQUIP')) {
for (const owner of g.owners) test(`fixed gift ${g.garrisonId}/${owner} is produced past stock and still occupies it`, () => {
const { m, p } = emptyPrep(), id = g.bbStr.chess, cap = m.itemPool.cap(id);
assert.ok(Number.isInteger(cap));
for (let i = 0; i < cap; i++) assert.ok(p.acquireItem(id));
assert.equal(m.itemPool.left(id), 0);
assert.ok(p.acquireChess(owner, { fromPool: false }));
assert.equal(m.itemPool.held(id), cap + g.bb.count, 'the trait must finish all grants, including merges');
assert.equal(m.itemPool.left(id), 0, 'overstock prevents new shop draws');
assert.equal(p.acquireItem(id), null, 'an ordinary stock-backed grant is still refused');
checkInvariants(m); m.dispose();
});
}
test('unlimited fixed-item gifts do not exempt unrelated strategy equipment from stock', () => {
const { m, p } = emptyPrep(), id = 'chess_item_5_07_e_a';
assert.ok(p.acquireItem(id)); assert.ok(p.acquireItem(id));
assert.equal(p.acquireItem(id, { requirePool: false }), null, 'commercial packaging is not a fixed operator gift');
assert.equal(m.itemPool.held(id), 2); checkInvariants(m); m.dispose();
});
for (const invest of [false, true]) test(`repeated Muelsyse acquisition and elite gifts remain unlimited (investor=${invest})`, () => {
const { m, p } = emptyPrep(), id = 'chess_item_6_09_e_a';
if (invest) {
const members = Object.values(DATA.chess).filter(c => c.visible && !c.isGolden && c.bonds.includes('investShip')).slice(0, 3);
for (const c of members) give(m, p, c.chessId);
}
assert.equal(m.dispatcher.investRepeat(p), invest ? 2 : 1);
const expected = invest ? [2, 4, 8, 10, 12] : [1, 2, 4, 5, 6];
for (const n of expected) {
assert.ok(p.acquireChess('chess_char_6_11_a'));
assert.equal(m.itemPool.held(id), n); checkInvariants(m);
// Stow the non-merging isomorphs on holders so this stock test does not exhaust the player's inventory.
for (const item of [...p.hand, ...p.temp].filter(x => x?.kind === 'item')) {
const holder = p.allChess().find(c => c.items.length < m.gd.equipPerChess);
if (holder) assert.ok(p.equip(item.uid, holder.uid).ok);
}
}
m.dispose();
});
test('Damazti strategy grants its fixed item at rounds 5/10/15 past cap; releasing overflow does not free stock early', () => {
const { m, p } = emptyPrep(), id = 'chess_item_6_09_e_a';
for (let i = 0; i < 3; i++) assert.ok(p.acquireItem(id));
p.bandId = 'band_damaztic'; p.recompute();
for (const [i, round] of [5, 10, 15].entries()) {
m.round = round; m.dispatch(p, 'onRoundStart', { round });
assert.equal(m.itemPool.held(id), 4 + i); checkInvariants(m);
}
const copy = () => [...p.hand, ...p.temp].find(x => x?.kind === 'item' && x.id === id);
for (let i = 0; i < 3; i++) { assert.ok(p.destroy(copy().uid).ok); assert.equal(m.itemPool.left(id), 0); }
assert.ok(p.destroy(copy().uid).ok);
assert.equal(m.itemPool.held(id), 2); assert.equal(m.itemPool.left(id), 1);
checkInvariants(m); m.dispose();
});
for (const mu of ['chess_char_6_11_a', 'chess_char_6_11_b']) test(`seven distinct Yan operators plus Yan-converted ${mu} activate nine Yan`, () => {
const gd = new GameData(DATA, 'mode_multi_hard');
const seven = Object.values(DATA.chess).filter(c => c.visible && !c.isGolden && c.bonds.includes('yanShip')).slice(0, 7);
assert.equal(seven.length, 7);
const pieces = seven.map((c, i) => ({ uid: i + 1, kind: 'chess', id: c.chessId, items: [] }));
pieces.push({ uid: 8, kind: 'chess', id: mu, items: [{ id: 'chess_item_6_09_e_a' }, { id: 'chess_item_3_04_e_a' }] });
const state = { board: new Map(pieces.map((p, i) => [`${9 + i % 4},${3 + Math.floor(i / 4)}`, p])), hand: [], layers: {} };
const b = computeBonds(gd, state);
assert.equal(b.yanShip.count, 9); assert.equal(b.yanShip.tier, 3); assert.equal(b.yanShip.harmony, 1);
pieces[7].items.pop();
const withoutConversion = computeBonds(gd, state);
assert.equal(withoutConversion.yanShip.count, 8); assert.equal(withoutConversion.yanShip.tier, 2);
});
+1 -1
View File
@@ -221,7 +221,7 @@ test('onLeave: a quitter has no place in the Final Assault pairing or the boss p
assert.equal(alive.length, 2);
assert.ok(!alive.includes(first));
h.drive(() => m.phase === PHASE.FINAL_ASSAULT);
assert.deepEqual(m.fields.map((f) => f.players), [alive], 'one boss field for the two players left');
assert.deepEqual(m.fields.map((f) => [...f.players].sort()), [[...alive].sort()], 'one boss field for the two players left');
assert.equal(m.bossPool.maxHp, bossPoolHp(m.gd, m.bossId, 2), 'the co-op boss pool of two alive players');
assert.equal(m.bossPool.maxHp, m.gd.boss(m.bossId).bloodPoint[m.gd.difficulty] * 2, 'bloodPoint × the two players left; the quitter does not count (DESIGN §25.13.4)');
const end = h.runToEnd();
+38 -10
View File
@@ -2,7 +2,8 @@
// the picks (room.diy → seats[].diy) are checked leniently, fixed for the match and applied to that player only — the
// player's data view makes a slotted slot the operator (name, class, bonds from its factions, no 特质, the pick's skill
// and module; the slot's tier, price and merge), each slotted piece has its own stock (8 at tier 5, 5 at tier 6), never
// enters the shared pool, joins only its player's shop once the 调度中心 reaches the slot's level, leaves the shop when
// enters the shared pool, joins only its player's shop once the 调度中心 reaches the slot's level, joins that player's
// promotion reward at the reward tier (one shop level earlier: shop 4 → tier 5, shop 5 → tier 6), leaves the shop when
// every bond of it is banned, merges 3 → elite, and fields its operator in battle (PlayerBattleInput `diy`), client-run
// and server-run identical; prep scouting and the result name the operator.
import { test } from 'node:test';
@@ -173,18 +174,39 @@ test('buy → 3 copies merge into the elite (the stock pays the copies); sell /
for (let i = 0; i < 8; i++) gain(p0, T5A);
assert.equal(p0.diyStock.left(T5A), 0);
p0.shop.slots[0] = { kind: 'chess', id: T5A, basePrice: 4, frozen: false, sold: false };
assert.equal(m.handle('p_0', { t: 'g.buy', slot: 0 }).error, 'SOLD_OUT');
assert.ok(m.handle('p_0', { t: 'g.buy', slot: 0 }).ok, 'a displayed operator remains purchasable after exhaustion');
assert.equal(p0.allChess().filter(p => p0.gd.baseIdOf(p.id) === T5A).reduce((n, p) => n + p.poolCopies, 0), 9);
assert.equal(p0.diyStock.left(T5A), 0, 'the extra copy does not reopen available stock');
h.invariants();
// reward offers (the temporary refresh) draw it once the 调度中心 is at the slot's level
// The promotion reward draws 自选 of the reward tier (shop 4 → tier 5, shop 5 → tier 6), one shop level before the
// ordinary shop (owner 2026-10-12, #515). clear() returned the drained T5A copies; the loops roll this stock.
clear(p0);
p0.offers = [];
let hit = 0;
p0.shop.level = 4;
for (let i = 0; i < 200; i++) { const o = p0.pushRewardOffer('merge'); if (o && o.slots.some((s) => /_diy/.test(s.id))) hit++; p0.offers = []; }
assert.equal(hit, 0, 'a level-4 promotion reward (tier 5) never holds a tier-5 自选 piece');
p0.shop.level = 6;
for (let i = 0; i < 300; i++) { const o = p0.pushRewardOffer('merge'); if (o && o.slots.some((s) => s.id === T6A)) hit++; p0.offers = []; }
assert.ok(hit > 0, 'a level-6 reward can hold the tier-6 piece');
assert.deepEqual(p0.diyStock.snapshot(), { [T5A]: 8, [T5B]: 8, [T6A]: 5 });
const tally = (level, n) => {
p0.shop.level = level;
const c = { t5: 0, t6: 0, any: 0 };
for (let i = 0; i < n; i++) {
const o = p0.pushRewardOffer('merge');
if (o) {
const ids = o.slots.map((s) => s.id);
if (ids.some((id) => id === T5A || id === T5B)) c.t5++;
if (ids.some((id) => id === T6A || id === T6B)) c.t6++;
if (ids.some((id) => /_diy/.test(id))) c.any++;
}
p0.offers = [];
}
return c;
};
assert.equal(tally(3, 80).any, 0, 'a level-3 promotion reward (tier 4) holds no 自选 piece');
const lv4 = tally(4, 200);
assert.ok(lv4.t5 > 0, 'a level-4 promotion reward (tier 5) can hold a tier-5 自选 piece');
assert.equal(lv4.t6, 0, 'a level-4 promotion reward never holds a tier-6 自选 piece');
const lv5 = tally(5, 300);
assert.ok(lv5.t6 > 0, 'a level-5 promotion reward (tier 6) can hold the tier-6 piece');
assert.equal(lv5.t5, 0, 'while tier 6 still has copies, a level-5 reward does not walk down to a tier-5 自选 piece');
const lv6 = tally(6, 300);
assert.ok(lv6.t6 > 0, 'a level-6 reward can still hold the tier-6 piece');
// elimination returns everything
gain(p0, T6A);
p0.eliminate(m.round);
@@ -209,6 +231,12 @@ test('bans: a 自选 piece whose every bond is off this match has no stock (out
h.toPrep(1);
p0.shop.level = 5;
for (let i = 0; i < 300; i++) { p0.rollShop(); assert.ok(!p0.shop.slots.some((s) => s && s.id === T5A)); }
p0.shop.level = 4;
for (let i = 0; i < 40; i++) {
const o = p0.pushRewardOffer('merge');
assert.ok(!o || !o.slots.some((s) => s.id === T5A), 'a banned slot has no stock, so the tier-5 reward never holds it');
p0.offers = [];
}
h.m.flush(true);
assert.deepEqual(h.lastTo('p_0', 'm.private').diyBanned, [T5A]);
h.invariants();
+5 -3
View File
@@ -103,15 +103,17 @@ test('shop slots per level (+ the item slot), buy prices by tier, SOLD_OUT, NO_F
assert.equal(ps.hand[9].id, slot0.id, 'hand fills right→left');
assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot: 0 }), { error: ERR.SOLD_OUT });
assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot: 15 }).error, ERR.BAD_TARGET);
// a display is not a reservation: an empty pool makes the buy fail
// a display is not a reservation, but its operator remains purchasable after the pool is exhausted (#498)
const s1 = ps.shop.slots[1];
const taken = m.pool.take(s1.id, 100);
assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot: 1 }), { error: ERR.SOLD_OUT });
assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot: 1 }), { ok: true });
assert.equal(m.pool.entries.get(s1.id).left, -1);
m.pool.give(s1.id, taken);
// full hand blocks buys
const fillers = chessOfTier(3).filter((x) => m.pool.has(x) && x !== s1.id);
for (let i = 0; ps.hand.some((x) => x == null); i++) give(m, ps, fillers[i]);
if (ps.countCopies(m.gd.baseIdOf(s1.id)) === 0) assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot: 1 }), { error: ERR.HAND_FULL });
ps.shop.slots[1] = { ...s1, sold: false };
assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot: 1 }), { error: ERR.HAND_FULL });
const itemSlot = ps.shop.slots.findIndex((s) => s && s.kind === 'item');
assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot: itemSlot }), { error: ERR.HAND_FULL });
checkInvariants(m);
@@ -17,6 +17,9 @@ import { teamFrameIds } from '../../public/js/ui/gameLogic.js';
function bossPrep(humans, seed) {
const h = makeMatch({ mode: 'coop', difficulty: 'FUNNY', humans, seed, fake: true }).start();
const m = h.m;
// Explicit layout fixture: these cases test mirrored metadata, not the random draw (covered separately).
const ids = m.order.map(p => p.playerId);
m.bossPairs = [ids.slice(0, 2), ids.slice(2)].filter(g => g.length);
h.drive(() => m.phase === PHASE.PREP && m.round === m.gd.bossRound, { ready: true });
for (const ps of m.players.values()) { ps.ready = false; for (const k of [...ps.board.keys()]) ps.board.delete(k); ps.recompute(); }
m.flush(true);
@@ -17,6 +17,7 @@ import { leaderStand } from '../../public/js/render/prepfield.js';
function bossPrep(seed) {
const h = makeMatch({ mode: 'coop', difficulty: 'FUNNY', humans: 4, seed, fake: true }).start();
const m = h.m;
m.bossPairs = [['p_0', 'p_1'], ['p_2', 'p_3']]; // explicit mirrored-layout fixture, not the pairing policy
h.drive(() => m.phase === PHASE.PREP && m.round === m.gd.bossRound, { ready: true });
return h;
}
@@ -64,6 +65,7 @@ test('a scout of a boss round\'s prep is the boss field: the player\'s half (mir
test('the eliminated viewer is pushed the boss-field scout at the boss round\'s start (item 56); a normal round\'s scout stays the normal board', () => {
const h = makeMatch({ mode: 'coop', difficulty: 'FUNNY', humans: 3, seed: 56, fake: true }).start();
const m = h.m;
m.bossPairs = [['p_0', 'p_1'], ['p_2']]; // keep this scout fixture's named left/right roles
h.toPrep(1);
h.ps('p_2').eliminate(1);
h.toPrep(2);
@@ -101,6 +101,7 @@ test('client combat: an eliminated human is started on the field of the player i
test('Final Assault: an eliminated viewer is started on its preference\'s boss field; a shared field records the tapped player only', () => {
const h = makeMatch({ mode: 'coop', difficulty: 'FUNNY', humans: 4, seed: 54, fake: true, instant: false, script: (b) => (b.kind === 'boss' ? { bossDps: 1 } : {}) }).start();
const m = h.m;
m.bossPairs = [['p_0', 'p_1'], ['p_2', 'p_3']]; // named watcher-role fixture; random assignment has its own tests
h.drive(() => m.phase === PHASE.PREP && m.round === 14);
h.ps('p_3').eliminate(13);
assert.deepEqual(m.handle('p_3', { t: 'g.watch', fieldId: 'n:p_2' }), { ok: true }, 'records the preference in prep');
+58 -17
View File
@@ -6,15 +6,54 @@ import { pairPlayers, bossPoolHp, SharedBossPool, hiddenEligible } from '../../s
import { GameData } from '../../server/match/gamedata.js';
import { FakeBattle } from './fakeBattle.js';
import { DATA, makeMatch, checkInvariants } from './harness.js';
import { createRng } from '../../server/sim/rng.js';
const bossFields = () => FakeBattle.instances.filter((b) => b.kind === 'boss' || b.kind === 'hidden');
test('pairing by seat: (1,2), (3,4); an odd player alone', () => {
test('pairing is seeded, independent of input order, includes everyone once and can draw every partner', () => {
const p = (seat) => ({ seat, playerId: `s${seat}` });
assert.deepEqual(pairPlayers([p(0)]).map((g) => g.map((x) => x.seat)), [[0]]);
assert.deepEqual(pairPlayers([p(2), p(0)]).map((g) => g.map((x) => x.seat)), [[0, 2]]);
assert.deepEqual(pairPlayers([p(3), p(1), p(0)]).map((g) => g.map((x) => x.seat)), [[0, 1], [3]]);
assert.deepEqual(pairPlayers([p(3), p(1), p(0), p(2)]).map((g) => g.map((x) => x.seat)), [[0, 1], [2, 3]]);
const partners = new Set(), lone = new Set();
for (let seed = 1; seed <= 64; seed++) for (const n of [1, 2, 3, 4]) {
const alive = Array.from({ length: n }, (_, i) => p(i));
const groups = pairPlayers(alive, createRng(seed));
assert.deepEqual(groups, pairPlayers([...alive].reverse(), createRng(seed)));
assert.equal(groups.length, Math.ceil(n / 2));
assert.deepEqual(groups.flat().map(x => x.seat).sort(), alive.map(x => x.seat));
if (n === 4) partners.add(groups.find(g => g.some(x => x.seat === 0)).find(x => x.seat !== 0).seat);
if (n === 3) lone.add(groups.find(g => g.length === 1)[0].seat);
}
assert.deepEqual([...partners].sort(), [1, 2, 3]);
assert.deepEqual([...lone].sort(), [0, 1, 2]);
});
test('previous boss partners and sides persist; only orphaned survivors form a new pair', () => {
const all = [0, 1, 2, 3].map(seat => ({ seat, playerId: `s${seat}` }));
const previous = [['s3', 's0'], ['s2', 's1']];
const ids = groups => groups.map(g => g.map(p => p.playerId));
assert.deepEqual(ids(pairPlayers(all, createRng(99), previous)), previous);
assert.deepEqual(ids(pairPlayers(all.filter(p => p.seat !== 0), createRng(99), previous)), [['s2', 's1'], ['s3']]);
assert.deepEqual(ids(pairPlayers(all.filter(p => p.seat > 1), createRng(99), previous)), [['s3', 's2']]);
});
test('boss preview, actual fields and Hidden Core retain one per-match draw', () => {
const h = makeMatch({ mode: 'coop', difficulty: 'HARD', humans: 4, seed: 31, fake: true,
script: b => ['boss', 'hidden'].includes(b.kind) ? { bossDps: 1e9 } : {} }).start();
try {
h.drive(() => h.m.phase === PHASE.PREP && h.m.round === 14);
const pairs = structuredClone(h.m.bossPairs);
assert.deepEqual(h.m.bossWaves.map(w => w.players), pairs);
for (const p of h.m.order) {
p.bondCountBonus.yanShip = 3; p.layers.yanShip = 400; p.recompute();
const group = h.m.bossGroupOf(p);
assert.equal(group.side, pairs.find(g => g.includes(p.playerId)).indexOf(p.playerId) === 0 ? 'L' : 'R');
}
h.drive(() => h.m.phase === PHASE.FINAL_ASSAULT);
assert.deepEqual(h.m.fields.map(f => f.players), pairs);
h.drive(() => h.m.phase === PHASE.PREP && h.m.round === 15);
assert.deepEqual(h.m.bossWaves.map(w => w.players), pairs);
h.drive(() => h.m.phase === PHASE.HIDDEN_CORE);
assert.deepEqual(h.m.fields.map(f => f.players), pairs);
} finally { h.m.dispose(); }
});
test('boss pool = bloodPoint[difficulty] × the players alive at the fight\'s start (solo × 1) × tuning; config restores the fixed pool; shared and never negative', () => {
@@ -200,22 +239,24 @@ test('Final Assault: a fighting player may only watch its own boss field; elimin
h.drive(() => m.phase === PHASE.PREP && m.round === 14);
h.ps('p_3').eliminate(13);
h.drive(() => m.phase === PHASE.FINAL_ASSAULT);
assert.deepEqual(m.fields.map((f) => [f.fieldId, f.players]), [['b1', ['p_0', 'p_1']], ['b2', ['p_2']]]);
assert.equal(m.handle('p_0', { t: 'g.watch', fieldId: 'b2' }).error, 'BAD_TARGET', 'the other group is hidden');
assert.equal(m.handle('p_2', { t: 'g.watch', fieldId: 'b1' }).error, 'BAD_TARGET');
assert.deepEqual(m.handle('p_1', { t: 'g.watch', fieldId: 'b1' }), { ok: true });
const pair = m.fields.find(f => f.players.length === 2), lone = m.fields.find(f => f.players.length === 1);
assert.deepEqual(m.fields.flatMap(f => f.players).sort(), ['p_0', 'p_1', 'p_2']);
const [first, second] = pair.players, third = lone.players[0];
assert.equal(m.handle(first, { t: 'g.watch', fieldId: lone.fieldId }).error, 'BAD_TARGET', 'the other group is hidden');
assert.equal(m.handle(third, { t: 'g.watch', fieldId: pair.fieldId }).error, 'BAD_TARGET');
assert.deepEqual(m.handle(second, { t: 'g.watch', fieldId: pair.fieldId }), { ok: true });
assert.deepEqual(m.handle('p_3', { t: 'g.watch', fieldId: 'b2' }), { ok: true }, 'an eliminated player spectates freely');
assert.deepEqual(m.handle('p_3', { t: 'g.watch', fieldId: 'b1' }), { ok: true });
assert.equal(m.watchers.get('p_0'), 'b1', 'the refused watch keeps the default (own) field');
assert.equal(m.watchers.get(first), pair.fieldId, 'the refused watch keeps the default (own) field');
// the prep-scouting id of a player of the other group is no way around the rule, and never detaches the viewer
const scouted = h.allTo('p_0', 'm.field').length;
assert.deepEqual(m.handle('p_0', { t: 'g.watch', fieldId: 'n:p_2' }), { error: 'BAD_TARGET', detail: 'no such field' });
assert.deepEqual(m.handle('p_0', { t: 'g.watch', fieldId: 'n:p_1' }), { error: 'BAD_TARGET', detail: 'no such field' });
assert.equal(h.allTo('p_0', 'm.field').length, scouted, 'no board of the other group was sent');
assert.equal(m.watchers.get('p_0'), 'b1', 'still streaming the own boss field');
const snaps = h.allTo('p_0', 'b.snap').length;
const scouted = h.allTo(first, 'm.field').length;
assert.deepEqual(m.handle(first, { t: 'g.watch', fieldId: `n:${third}` }), { error: 'BAD_TARGET', detail: 'no such field' });
assert.deepEqual(m.handle(first, { t: 'g.watch', fieldId: `n:${second}` }), { error: 'BAD_TARGET', detail: 'no such field' });
assert.equal(h.allTo(first, 'm.field').length, scouted, 'no board of the other group was sent');
assert.equal(m.watchers.get(first), pair.fieldId, 'still streaming the own boss field');
const snaps = h.allTo(first, 'b.snap').length;
h.sched.advance(500);
assert.ok(h.allTo('p_0', 'b.snap').length > snaps, 'b.snap of b1 keeps coming');
assert.ok(h.allTo(first, 'b.snap').length > snaps, 'snapshots of the own field keep coming');
m.dispose();
});
+3 -2
View File
@@ -43,8 +43,9 @@ test('multi-round bounties spawn in the Final Assault on the owner\'s half, show
assert.equal(rt.motion === 'FLY', fly, 'route motion matches the enemy');
assert.equal(s.count, card.count);
assert.ok(s.mods.hpMul > 0, 'bounty enemies take the round\'s multipliers');
if (s.ownerPlayerId === 'p_0') assert.ok(rt.end[1] < 10, `left player's bounty goes to the left goal (${rt.end})`);
else { assert.equal(s.ownerPlayerId, 'p_1'); assert.ok(rt.end[1] > 10, `right player's bounty goes to the right goal (${rt.end})`); }
const side = f.opts.players.find(p => p.playerId === s.ownerPlayerId)?.side;
assert.ok(side === 'L' || side === 'R', 'bounty has an actual field owner');
assert.ok(side === 'L' ? rt.end[1] < 10 : rt.end[1] > 10, `owner's ${side} bounty goes to its own goal (${rt.end})`);
}
const pending = p0.pendingFunds;
const end = h.runToEnd();
+75
View File
@@ -0,0 +1,75 @@
// Real production Match + Battle, legal normal intents from an empty board. Seed 8 fixes the shop/wave route.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { Battle } from '../../server/sim/Battle.js';
import { makeMatch } from './harness.js';
test('Rockrock killed by a real enemy and automatically revived by Ermengard starts the same target at init', () => {
const observed = [];
class ObservedBattle extends Battle {
constructor(opts) {
super(opts);
const log = { round: opts.round, deaths: [], deploys: [], firstHits: [] };
observed.push(log);
const seen = new Set(), launches = new Map();
this.on('death', c => {
if (c.unit.defId === 'chess_char_2_10_a') log.deaths.push({ t: this.time, seq: c.unit.deploySeq,
target: c.unit.trait.funnelTarget, scale: c.unit.trait.funnelScale, reason: c.reason });
}, { priority: 1000 });
this.on('death', c => {
if (c.unit.defId === 'chess_char_2_10_a') log.deaths.at(-1).revivedBy = c.revivedBy;
}, { priority: -1000 });
this.on('deploy', c => {
if (c.unit.defId === 'chess_char_2_10_a') log.deploys.push({ seq: c.unit.deploySeq,
target: c.unit.trait.funnelTarget, scale: c.unit.trait.funnelScale });
}, { priority: -1000 });
this.on('attack', c => {
if (c.attacker.defId !== 'chess_char_2_10_a') return;
// This unmodified Rockrock route has no nested attack callback. The attack hook follows its shot creation.
launches.set(this._attackSeq, { t: this.time, seq: c.attacker.deploySeq });
});
this.on('damaged', c => {
const u = c.source;
const launch = launches.get(c.dmg?.attackId);
if (u?.defId !== 'chess_char_2_10_a' || seen.has(u.deploySeq) || launch?.seq !== u.deploySeq) return;
seen.add(u.deploySeq);
log.firstHits.push({ t: this.time, launchAt: launch.t, seq: u.deploySeq, target: c.target.id,
scale: u.trait.funnelScale, hpBefore: c.target.hp + c.amount });
});
}
}
const h = makeMatch({ mode: 'solo', seed: 8 });
h.m.BattleClass = ObservedBattle; // read-only observers, no combat state writes
const p = h.ps('p_0'), act = msg => assert.deepEqual(h.m.handle('p_0', msg), { ok: true });
try {
h.start().toPrep(1, { band: 'band_ermengard' });
act({ t: 'g.buy', slot: 0 });
act({ t: 'g.move', uid: p.hand.find(x => x?.id === 'chess_char_1_09_a').uid,
to: { area: 'board', row: 12, col: 6 }, dir: 'RIGHT' });
h.toPrep(2, { band: 'band_ermengard' });
act({ t: 'g.levelUp' });
h.toPrep(5, { band: 'band_ermengard' });
assert.equal(p.shop.slots[3].id, 'chess_char_2_10_a');
act({ t: 'g.buy', slot: 3 });
act({ t: 'g.move', uid: p.hand.find(x => x?.id === 'chess_char_2_10_a').uid,
to: { area: 'board', row: 12, col: 8 }, dir: 'RIGHT' });
h.toPrep(7, { band: 'band_ermengard' });
const log = observed.find(x => x.round === 6);
assert.ok(log?.deaths.length > 0);
const paired = log.deaths.map(d => ({ death: d, hit: log.firstHits.find(x => x.seq > d.seq) }))
.filter(x => x.hit && x.death.target === x.hit.target && x.death.scale > 0.2);
assert.ok(paired.length > 0, JSON.stringify(log));
for (const { death, hit } of paired) {
assert.equal(death.reason, 'killed');
assert.equal(death.revivedBy, 'band');
assert.ok(hit.launchAt >= death.t, 'fresh attack after the knockout and revival');
assert.ok(hit.hpBefore > 0, 'the same enemy was alive when the fresh drone hit');
const deploy = log.deploys.find(x => x.seq === hit.seq);
assert.equal(deploy.target, null);
assert.ok(Math.abs(deploy.scale - 0.2) < 1e-8);
assert.ok(Math.abs(hit.scale - 0.2) < 1e-8, JSON.stringify({ death, hit }));
}
assert.deepEqual(h.logs.error, []);
h.invariants();
} finally { h.m.dispose(); }
});
+4 -2
View File
@@ -156,14 +156,16 @@ test('merge whose copies are not deployed (temp) with a full hand overflows the
m.dispose();
});
test('reward pick respects the pool and a full hand; offers expire at prep end', () => {
test('displayed operator rewards survive stock exhaustion but respect a full hand and expire at prep end', () => {
const { h, m, ps } = prep(24);
ps.pushRewardOffer('merge');
const offer = ps.offers[0];
const id0 = offer.slots[0].id;
const taken = m.pool.take(id0, 100);
assert.deepEqual(m.handle('p_0', { t: 'g.reward', idx: 0 }), { error: ERR.SOLD_OUT });
assert.deepEqual(m.handle('p_0', { t: 'g.reward', idx: 0 }), { ok: true });
assert.equal(m.pool.entries.get(id0).left, -1);
m.pool.give(id0, taken);
ps.pushRewardOffer('merge', { ids: [id0] });
const others = chessOfTier(3).filter((c) => m.pool.has(c) && !offer.slots.some((s) => s.id === c));
for (let i = 0; ps.hand.some((p) => p == null); i++) give(m, ps, others[i]);
assert.deepEqual(m.handle('p_0', { t: 'g.reward', idx: 0 }), { error: ERR.HAND_FULL });
+7 -6
View File
@@ -145,13 +145,14 @@ describe('#7 the Final Assault prep deploys on the boss field', () => {
h.setStage(STAGE);
h.toPrep(14);
const m = h.m;
assert.equal(m.deployFieldOf(h.ps('p_1')), 'bossR');
assert.equal(m.deployFieldOf(h.ps('p_2')), 'bossL', 'the odd player alone on the left half');
m.onLeave('p_0');
assert.equal(m.deployFieldOf(h.ps('p_1')), 'bossL');
assert.equal(m.deployFieldOf(h.ps('p_2')), 'bossR');
const [left, right] = m.bossPairs.find(g => g.length === 2), lone = m.bossPairs.find(g => g.length === 1)[0];
assert.equal(m.deployFieldOf(h.ps(right)), 'bossR');
assert.equal(m.deployFieldOf(h.ps(lone)), 'bossL', 'the odd player alone on the left half');
m.onLeave(left);
assert.equal(m.deployFieldOf(h.ps(right)), 'bossL');
assert.equal(m.deployFieldOf(h.ps(lone)), 'bossR');
// re-checked at once (Match._quit recomputes the players left), not at their next action
for (const [id, field] of [['p_1', 'bossL'], ['p_2', 'bossR']]) {
for (const [id, field] of [[right, 'bossL'], [lone, 'bossR']]) {
assert.equal(h.ps(id)._deployField, field, `${id} board re-checked on ${field}`);
assert.equal(h.ps(id)._legalityStale, false);
}
+20 -2
View File
@@ -123,10 +123,10 @@ test('effect-only items are never shop items: not in shopItemsByTier, never in t
m.dispose();
});
test('维多利亚 25-layer reward and 洛洛的定制品: the 4 special 维式重锤 (and nothing else)', () => {
test('维多利亚 25-layer reward: the 4 special 维式重锤 (and nothing else)', () => {
const special = ['chess_item_2_03_e_a', 'chess_item_3_09_e_a', 'chess_item_3_10_e_a', 'chess_item_4_09_e_a'];
const m = makeMatch({ mode: 'coop', seed: 3, fake: true }).m;
for (const pid of ['pool_equip_vict', 'pool_equip_rockr']) {
for (const pid of ['pool_equip_vict']) {
assert.deepEqual([...DATA.choices.pools[pid].items].sort(), special, pid);
const seen = new Set();
for (let i = 0; i < 200; i++) seen.add(m.rollPool(pid).id);
@@ -135,6 +135,24 @@ test('维多利亚 25-layer reward and 洛洛的定制品: the 4 special 维式
m.dispose();
});
test('洛洛 and 焰尾 use the measured eight-item and 10/10/3 subsets; exhausted shop items are excluded', () => {
const m = makeMatch({ mode: 'coop', seed: 3, fake: true }).m;
const rock = DATA.choices.pools.pool_equip_rockr.weighted;
assert.deepEqual(rock.map(([id, n]) => [m.gd.item(id).name, n]), [
['维式重锤', 10], ['战栗维式重锤', 2], ['坚固维式重锤', 2], ['加速维式重锤', 2], ['灼燃维式重锤', 2],
['有限加速器', 1], ['护盾无人机', 4], ['激光发射器', 4],
]);
assert.deepEqual(DATA.choices.pools.pool_char_pinus.weighted.map(([id, n]) => [m.gd.chess(id).name, n]), [['野鬃', 10], ['灰毫', 10], ['远牙', 3]]);
const seen = new Set();
for (let i = 0; i < 300; i++) seen.add(m.rollPool('pool_equip_rockr').id);
assert.deepEqual([...seen].sort(), rock.map(x => x[0]).sort());
const normal = rock[0][0], ps = m.order[0];
for (let i = 0; i < m.itemPool.cap(normal); i++) assert.ok(ps.acquireItem(normal));
assert.equal(m.itemPool.left(normal), 0);
for (let i = 0; i < 100; i++) assert.notEqual(m.rollPool('pool_equip_rockr').id, normal);
m.dispose();
});
test('per-match disabled bonds: 3 core + 4 add-on (NORMAL+), FUNNY static + 0 + 1; weight-0 never drawn; subset ban rule', () => {
for (const [modeId, core, addon] of [['mode_multi_hard', 3, 4], ['mode_single_abyss', 3, 4], ['mode_multi_funny', 0, 1], ['mode_single_normal', 3, 4]]) {
const gd = new GameData(DATA, modeId);
+38
View File
@@ -7,6 +7,33 @@ import { cardPickable } from '../../public/js/ui/choiceOverlay.js';
const PACK = 'chess_item_5_07_e_a';
const byName = name => Object.values(DATA.chess).find(c => !c.isGolden && c.name === name).chessId;
test('four already displayed operator offers may exceed stock; selling first repays the deficit', () => {
const h = makeMatch({ mode: 'coop', humans: 4, seed: 11, fake: true }).start().toPrep();
const m = h.m, id = byName('溯光星源'), ps = [...m.players.values()];
assert.equal(m.pool.cap(id), 10);
for (let i = 0; i < 3; i++) for (let j = 0; j < 3; j++) ps[i].acquireChess(id);
assert.equal(m.pool.left(id), 1);
for (const p of ps) {
p.funds = 100;
p.shop.slots[0] = { kind: 'chess', id, basePrice: 3, frozen: true, sold: false };
}
assert.equal(m.pool.left(id), 1, 'displaying an offer does not reserve stock');
for (const p of ps) {
const funds = p.funds; assert.ok(p.buy(0).ok, 'every pre-existing offer stays purchasable');
assert.equal(p.funds, funds - 3); assert.equal(p.shop.slots[0].sold, true);
assert.equal(p.buy(0).error, 'SOLD_OUT', 'the same offer cannot be bought twice');
}
assert.equal(m.pool.entries.get(id).left, -3); assert.equal(m.pool.left(id), 0);
assert.equal(m.pool.roll(m.rngShop, { tier: 6, filter: candidate => candidate === id }), null);
for (let i = 0; i < 4; i++) {
const piece = ps[i].allChess().find(c => c.id === id); assert.ok(piece);
assert.ok(ps[i].sell(piece.uid).ok);
assert.equal(m.pool.left(id), Math.max(0, i - 2));
checkInvariants(m);
}
m.dispose();
});
test('a duplicate draft card becomes unavailable when the preceding player exhausts equipment stock', () => {
const h = makeMatch({ mode: 'coop', humans: 2, seed: 11, fake: true }).start().toPrep();
const m = h.m, a = h.ps('p_0'), b = h.ps('p_1');
@@ -29,6 +56,17 @@ test('measured operator capacities and exceptions: co-op doubles shared stock, n
assert.equal(solo.itemPoolCopies(PACK), 2); assert.equal(coop.itemPoolCopies(PACK), 2);
});
test('an operator reward shown before stock exhaustion still arrives and records its copy', () => {
const h = makeMatch({ mode: 'coop', humans: 2, seed: 11, fake: true }).start().toPrep();
const m = h.m, a = h.ps('p_0'), b = h.ps('p_1'), id = byName('溯光星源');
a.pushRewardOffer('test', { ids: [id] });
for (let i = 0; i < m.pool.cap(id); i++) b.acquireChess(id);
assert.equal(m.pool.left(id), 0);
assert.ok(a.pickReward(0).ok); assert.equal(a.offers.length, 0);
assert.ok(a.allChess().some(c => c.id === id));
assert.equal(m.pool.entries.get(id).left, -1); checkInvariants(m); m.dispose();
});
test('shared equipment occupancy includes hand/equipped/elite, never displayed offers; destroying frees stock', () => {
const h = makeMatch({ mode: 'coop', humans: 2, seed: 11, fake: true }).start().toPrep();
const m = h.m, a = h.ps('p_0'), b = h.ps('p_1');
+59
View File
@@ -0,0 +1,59 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeMatch } from './harness.js';
import { Battle } from '../../server/sim/Battle.js';
import { getDefaultSource } from '../../server/sim/simdata.js';
const MEDICS = new Set(['char_605_cmedic', 'char_613_acmedc']);
const medics = b => b.allyUnits.filter(u => MEDICS.has(u.defId) && u.alive && !u.removed);
const clean = () => ({ leaked: [], perfect: true, coins: 0, layerGains: {}, killed: 0, damageDealt: 0 });
function setup(t) {
const h = makeMatch({ mode: 'coop', humans: 3, seed: 334401, fake: true }).start().toPrep();
t.after(() => h.m.dispose()); h.setStage('act2autochess_m01');
for (const p of h.m.order) p.bandId = null;
h.ps('p_0').bandId = 'band_amedic'; return h;
}
function field(h, elites = 0) {
const opts = h.m._normalOpts(h.ps('p_1'));
opts.players[0].units = ['chess_char_1_01_b', 'chess_char_1_02_b'].slice(0, elites)
.map((chessId, i) => ({ uid: 100 + i, kind: 'chess', chessId, row: 10 + i, col: 4 }));
const b = new Battle({ ...opts, data: getDefaultSource(), spawns: [], timeLimit: 300, autoFinish: false });
b.start(); assert.equal(b.errorCount, 0); return b;
}
for (const reason of ['eliminated', 'quit']) for (const elites of [0, 2]) {
test(`Touch team grant survives holder ${reason}, receiver elites=${elites}`, t => {
const h = setup(t), before = field(h, elites), expected = elites ? 'char_613_acmedc' : 'char_605_cmedic';
assert.deepEqual(medics(before).map(u => u.defId), [expected]);
if (reason === 'quit') h.m.onLeave('p_0');
else {
h.ps('p_0').lp = 1;
h.m.lastResults = new Map(h.m.order.map(p => [p.playerId, clean()]));
h.m.lastResults.set('p_0', { ...clean(), perfect: false, leaked: [{ enemyKey: 'enemy_1422_lrsldr', counted: true }] });
h.m.settle(null, null); h.toPrep(2); h.setStage('act2autochess_m01');
}
assert.equal(h.ps('p_0').alive, false);
assert.equal(h.ps('p_1').privateView().prepMapChars.length, 1);
const after = field(h, elites);
assert.deepEqual(medics(after).map(u => u.defId), [expected]);
assert.equal(after.players[0].input.contentInfo.teammates.some(p => p.playerId === 'p_0'), false,
'retaining Touch does not revive dead teammate contexts');
for (let i = 0; i < 30; i++) before.step();
assert.deepEqual(medics(before).map(u => u.defId), [expected]);
h.ps('p_0').bandId = 'band_amiya';
assert.equal(field(h).players[0].input.contentInfo.matchBands.includes('band_amiya'), false,
'other eliminated strategies are not retained by this exception');
});
}
test('Touch requires a confirmed selection and does not leak to another match', t => {
const h = makeMatch({ mode: 'coop', humans: 3, seed: 334405, fake: true }).start();
t.after(() => h.m.dispose()); h.drive(() => h.m.phase === 'BAND_DRAFT', { ready: false });
const holder = h.m.players.get(h.m.draftTurn());
assert.equal(h.m.bandFocus(holder, 'band_amedic').ok, true);
assert.equal(h.m.teamMedicGranted(), false);
assert.equal(h.m.pickBand(holder, 'band_amedic').ok, true);
h.m.onLeave(holder.playerId); assert.equal(h.m.teamMedicGranted(), true);
const next = makeMatch({ mode: 'coop', humans: 3, seed: 334405, fake: true }).start();
t.after(() => next.m.dispose()); assert.equal(next.m.teamMedicGranted(), false);
});
+94 -1
View File
@@ -20,8 +20,10 @@ import { createBattleRunner } from '../../public/js/battle/runner.js';
import { createStore, initialState } from '../../public/js/store.js';
import * as specMod from '../../server/sim/spec.js';
import { DataSource } from '../../server/sim/simdata.js';
import { DATA, makeMatch, checkInvariants } from './harness.js';
import { DATA, makeMatch, checkInvariants, legalTileFor } from './harness.js';
import { FakeBattle } from './fakeBattle.js';
import { ownerBoard } from '../../public/js/ui/watchBonds.js';
import { bondMembers, diyGetter, diyRecordFor, memberHeadCount } from '../../public/js/ui/gameLogic.js';
const row = (h, pid) => h.m.publicView().players.find((p) => p.playerId === pid);
const entry = (list, id) => (list || []).find((b) => b.bondId === id) || null;
@@ -273,3 +275,94 @@ test('runner: a battle that disables gains (联防 / boss) publishes no live lay
assert.deepEqual(r.store.get().match.battle.bondLayers, {});
r.runner.dispose();
});
const DIY_SLOT = 'chess_char_5_diy1_a';
const HUANG_PICK = { charId: 'char_017_huang', skillIndex: 1, uniEquipId: 'uniequip_002_huang' };
const SIEGE_PICK = { charId: 'char_112_siege', skillIndex: 2, uniEquipId: 'uniequip_002_siege' };
const DIY_DATA = { chess: DATA.chess, backups: DATA.backups };
const getChess = (id) => DATA.chess[id] || null;
/** The same record lookups as BondPopup: own picks from m.private, a teammate's from each field unit. */
function diyRows(bondId, priv) {
const get = diyGetter(getChess, priv, DIY_DATA);
const record = (p) => (p.diy ? diyRecordFor(getChess(p.id), p.diy, DIY_DATA) : null) || get(p.id);
return bondMembers(DATA.bonds[bondId], priv, [], get, () => null, record);
}
/** Controlled match fixture: legal seat picks, acquire/merge and g.move; the match builds the unmodified b.start. */
function diyWatchMatch(elite) {
const seats = [SIEGE_PICK, HUANG_PICK].map((pick, seat) => ({
seat, playerId: `p_${seat}`, name: `P${seat}`, isBot: false, connected: true, diy: { [DIY_SLOT]: pick },
}));
const h = makeMatch({ mode: 'coop', seats, spectators: ['s_spec'], seed: 7309, clientCombat: true, clients: false }).start();
h.toPrep(1);
const ps = h.ps('p_1');
let piece;
for (let i = 0; i < (elite ? 3 : 1); i++) piece = ps.acquireChess(DIY_SLOT);
assert.ok(piece);
const tile = legalTileFor(h.m, ps, piece.id);
assert.ok(tile);
assert.deepEqual(h.m.handle('p_1', { t: 'g.move', uid: piece.uid, to: { area: 'board', row: tile[0], col: tile[1] } }), { ok: true });
return h;
}
for (const elite of [false, true]) test(`runner: watched ${elite ? 'elite' : 'normal'} DIY operator is a bond member before and after deployment`, async (t) => {
const h = diyWatchMatch(elite);
t.after(() => h.m.dispose());
const assertHuang = (priv) => {
for (const bondId of ['yanShip', 'victoriaShip']) {
const rows = diyRows(bondId, priv);
assert.equal(memberHeadCount(rows, false), 1, `${bondId}: the fielded DIY member counts once`);
const row = rows.find((x) => x.diy);
assert.deepEqual([row.id, row.rec.charId, row.name, row.onBoard, row.rec.isGolden], [DIY_SLOT, HUANG_PICK.charId, '煌', true, false]);
assert.deepEqual(row.pick, HUANG_PICK, 'the watched owner\'s skill and module, not our pick in the same slot');
}
};
assertHuang(ownerBoard(h.m.prepFieldMeta(h.ps('p_1')), 'p_1'));
assert.deepEqual(diyRows('victoriaShip', h.ps('p_0').privateView()).filter((x) => x.diy).map((x) => [x.rec.charId, x.owned]), [[SIEGE_PICK.charId, false]], 'our unbought pick stays our own');
assert.equal(diyRows('yanShip', h.ps('p_0').privateView()).some((x) => x.diy), false);
for (const pid of ['p_0', 'p_1']) assert.deepEqual(h.m.handle(pid, { t: 'g.ready', ready: true }), { ok: true });
h.runToPhase(PHASE.COMBAT, 1);
assert.deepEqual(h.m.handle('p_0', { t: 'g.watch', fieldId: 'n:p_1' }), { error: 'WRONG_PHASE', detail: 'own battle running' });
assert.deepEqual(h.m.handle('s_spec', { t: 'g.watch', fieldId: 'n:p_1' }), { ok: true });
const start = JSON.parse(JSON.stringify(h.lastTo('s_spec', 'b.start')));
assert.deepEqual([start.watch, start.authoritative, start.elapsed], [true, false, 0]);
assert.equal(start.spec.players[0].units[0].chessId, elite ? DIY_SLOT.replace(/_a$/, '_b') : DIY_SLOT);
assert.deepEqual(start.spec.players[0].units[0].diy, HUANG_PICK);
const r = rig();
t.after(() => r.runner.dispose());
r.net.emit('b.start', start);
await r.settle();
const field = r.store.get().match.field;
assert.equal(field.units.some((u) => u.kind === 'op'), false, 'the initial meta precedes deployment');
const board = () => ownerBoard(field, 'p_1', r.runner.ownerOps('p_1', start.fieldId));
t.diagnostic(JSON.stringify({ elite, specPick: start.spec.players[0].units[0].diy, initialMetaOps: field.units.filter((u) => u.kind === 'op').length, runnerOps: r.runner.ownerOps('p_1'), yanMembers: memberHeadCount(diyRows('yanShip', board()), false) }));
assertHuang(board());
r.advance(20000, 50);
const unit = r.runner._entries.get(start.battleId).battle.allyUnits.find((u) => u.kind === 'op');
assert.ok(unit.deployed, 'the real simulation deployed the operator');
assertHuang(board());
const op = r.runner.ownerOps('p_1')[0];
assert.notEqual(op.diy, unit.def.loadout.diy, 'the popup receives a copy of the frozen sim pick');
op.diy.charId = SIEGE_PICK.charId;
assert.deepEqual(r.runner.ownerOps('p_1')[0].diy, HUANG_PICK, 'a consumer cannot change the battle or the next read');
assert.deepEqual(r.runner.ownerOps('p_0'), [], 'another owner');
assert.deepEqual(r.runner.ownerOps('p_1', 'n:p_0'), [], 'another field');
// A fresh runner after a spectator reconnect, and switching back from another field, retain the member too.
const starts = h.allTo('s_spec', 'b.start').length;
h.sched.advance(1000);
h.m.addSpectator('s_spec');
assert.equal(h.allTo('s_spec', 'b.start').length, starts + 1, 'the spectator reconnect emits a fresh b.start');
assert.ok(h.lastTo('s_spec', 'b.start').elapsed > 0, 'the resumed replica catches up to the match clock');
const resumed = rig();
t.after(() => resumed.runner.dispose());
resumed.net.emit('b.start', JSON.parse(JSON.stringify(h.lastTo('s_spec', 'b.start'))));
await resumed.settle();
assertHuang(ownerBoard(resumed.store.get().match.field, 'p_1', resumed.runner.ownerOps('p_1')));
r.net.emit('b.start', h.lastTo('p_0', 'b.start'));
await r.settle();
assert.deepEqual(r.runner.ownerOps('p_1'), [], 'switching fields drops the other field\'s members');
r.net.emit('b.start', start);
await r.settle();
assertHuang(ownerBoard(r.store.get().match.field, 'p_1', r.runner.ownerOps('p_1')));
});
+106
View File
@@ -0,0 +1,106 @@
// GitHub #531 / #530: the player zip must not ship a package.json whose `npm test` is a silent green, and the
// Markdown it ships must not keep relative links to files the zip does not contain. The repository copies stay as they
// are; tools/package.mjs rewrites the staged copies. These tests call that rewrite directly (no zip, no browser).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
PLAYER_MARKDOWN, PLAYER_NPM_SCRIPTS, playerPackageJson, playerPackageProblems, releaseTag, rewritePlayerDoc,
selectTracked, trackedFiles,
} from '../tools/package.mjs';
const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
const pkg = JSON.parse(readFileSync(path.join(ROOT, 'package.json'), 'utf8'));
const DISHONEST = ['test', 'golden', 'golden:update', 'build-data', 'check:imports', 'ci', 'package', 'package:lite', 'lint', 'typecheck'];
const KEPT = [...PLAYER_NPM_SCRIPTS, 'dev'];
test('release tags drop a trailing -dev and keep an already released version', () => {
assert.equal(releaseTag('0.2.5-dev'), 'v0.2.5');
assert.equal(releaseTag('0.2.4'), 'v0.2.4');
assert.equal(releaseTag('9.9.9'), 'v9.9.9');
});
test('the packaged package.json keeps only scripts whose node files ship', () => {
const shipped = selectTracked(trackedFiles(ROOT)).keep;
const next = playerPackageJson(pkg, shipped);
for (const name of DISHONEST) assert.equal(next.scripts[name], undefined, name);
for (const name of KEPT) assert.equal(next.scripts[name], pkg.scripts[name], name);
assert.deepEqual(Object.keys(next), Object.keys(pkg));
assert.deepEqual(playerPackageProblems(pkg, shipped), []);
assert.equal(pkg.scripts.test, 'node --test', 'the repository package.json still runs the suite');
assert.equal(pkg.scripts.lint, 'eslint .');
const keys = Object.keys(next);
assert.deepEqual(keys.filter((k) => k !== 'scripts'), Object.keys(pkg).filter((k) => k !== 'scripts'));
});
test('a script with no shipped node file is dropped, including a bare node --test', () => {
const shipped = new Set(['server/index.js', 'tools/vendor.mjs']);
const next = playerPackageJson({
name: 'x', version: '1.0.0',
scripts: {
start: 'node server/index.js',
dev: 'node --watch server/index.js',
test: 'node --test',
named: 'node --test test/a.test.js',
lint: 'eslint .',
both: 'node tools/vendor.mjs && node tools/missing.mjs',
vendor: 'node tools/vendor.mjs',
},
}, shipped);
assert.deepEqual(Object.keys(next.scripts).sort(), ['dev', 'start', 'vendor']);
assert.ok(playerPackageProblems({ scripts: { start: 'node server/missing.js', setup: 'node tools/setup.mjs', doctor: 'node tools/doctor.mjs', launch: 'node scripts/launch.mjs', postinstall: 'node tools/vendor.mjs', vendor: 'node tools/vendor.mjs', assets: 'node tools/vendor.mjs' } }, shipped).length > 0);
});
function linkTarget(from, href) {
const raw = href.split('#')[0];
const dir = path.posix.dirname(from);
return path.posix.normalize(path.posix.join(dir, raw));
}
test('packaged copies of the shipped docs point unshipped relative links at the release tag', () => {
const shipped = new Set(selectTracked(trackedFiles(ROOT)).keep);
const version = pkg.version;
const tag = releaseTag(version);
const host = 'https://github.com/sganggs/Stronghold-Protocol';
const focus = new Set(['README.md', 'docs/PLAYING.md', 'docs/DEPLOY.md']);
let fileRewrites = 0;
assert.ok(PLAYER_MARKDOWN.includes('README.md') && PLAYER_MARKDOWN.includes('docs/PLAYING.md') && PLAYER_MARKDOWN.includes('docs/DEPLOY.md'));
for (const rel of PLAYER_MARKDOWN) {
const md = readFileSync(path.join(ROOT, rel), 'utf8');
for (const m of md.matchAll(/\]\(([^)\s]+)\)/g)) {
const href = m[1];
if (/^(?:[a-z][a-z0-9+.-]*:|#)/i.test(href)) continue;
const target = linkTarget(rel, href);
const onDisk = target.startsWith('..') ? null : path.join(ROOT, target);
const releaseLatest = /^(?:\.\.\/)+releases\/latest$/.test(target);
assert.ok(releaseLatest || (onDisk && existsSync(onDisk)), `${rel} source link ${href} (${target})`);
}
const next = rewritePlayerDoc(md, rel, shipped, version);
assert.equal(rewritePlayerDoc(next, rel, shipped, version), next, `${rel} rewrite is idempotent`);
const before = [...md.matchAll(/(!?\[[^\]]*\]\()([^)\s]+)\)/g)];
const after = [...next.matchAll(/(!?\[[^\]]*\]\()([^)\s]+)\)/g)];
assert.equal(before.length, after.length, rel);
for (let i = 0; i < after.length; i++) {
const href = after[i][2];
if (/^(?:[a-z][a-z0-9+.-]*:|#)/i.test(href)) {
if (before[i][2] !== href) assert.ok(href.startsWith(`${host}/`), `${rel} ${before[i][2]} → ${href}`);
continue;
}
const target = linkTarget(rel, href);
assert.ok(!target.startsWith('..') && shipped.has(target), `${rel} still links to ${href} (${target}), which the zip does not ship`);
}
if (!focus.has(rel)) continue;
for (let i = 0; i < before.length; i++) {
if (before[i][2] === after[i][2]) continue;
if (/\.(?:png|jpe?g|gif|webp|svg)(?:#|$)/i.test(before[i][2])) {
assert.match(after[i][2], new RegExp(`^${host}/raw/${tag}/`));
} else {
fileRewrites++;
assert.match(after[i][2], new RegExp(`^${host}/(?:blob/${tag}/|releases/)`));
}
}
}
assert.ok(fileRewrites >= 23, `README / PLAYING / DEPLOY rewrote ${fileRewrites} file links, want at least 23`);
});
+36 -2
View File
@@ -11,8 +11,8 @@ import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
FOLDER, REFUSE, RUNTIME_RESEARCH, entryProblems, isRefused, packageOutIsUnsafe, plan, resolveSpecifier, scanFiles,
selectTracked,
FOLDER, REFUSE, RUNTIME_RESEARCH, entryProblems, isRefused, packageOutIsUnsafe, plan, playerPackageJson,
rewritePlayerDoc, resolveSpecifier, scanFiles, selectTracked,
} from '../tools/package.mjs';
// a fake home path, built at run time so the repository never contains one (the local push guard scans every patch)
@@ -311,6 +311,40 @@ test('content packs ship: every committed pack of both layouts, never one only o
}
});
test('the stage drops dishonest npm scripts and rewrites unshipped doc links', { skip: !hasZipTool && 'no zip / tar' }, () => {
const { dir, put } = fakeCheckout();
const out = fs.mkdtempSync(path.join(os.tmpdir(), 'sp-package-out-'));
const readme = '# r\n\n[design](docs/DESIGN.md)\n[play](docs/PLAYING.md)\n[rel](../../releases/latest)\n![shot](docs/img/x.jpg)\n';
try {
put('README.md', readme);
put('docs/PLAYING.md', '# p\n\n[idx](research/00-INDEX.md)\n[home](../README.md#操作)\n');
put('docs/DEPLOY.md', '# d\n\n[packs](PACKS.md)\n[win](WINDOWS.md)\n[notice](../NOTICE.md)\n');
const r = runTool(['--root', dir, '--out', out, '--no-install', '--allow-dirty', '--lite', '--keep-stage']);
assert.equal(r.status, 0, r.stdout + r.stderr);
const stage = path.join(out, `${FOLDER}-v9.9.9-lite`, FOLDER);
const stagedPkg = JSON.parse(fs.readFileSync(path.join(stage, 'package.json'), 'utf8'));
assert.deepEqual(Object.keys(stagedPkg.scripts).sort(), ['assets', 'doctor', 'launch', 'postinstall', 'setup', 'start', 'vendor']);
assert.equal(JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')).scripts.test, 'node --test');
const host = 'https://github.com/sganggs/Stronghold-Protocol';
const got = fs.readFileSync(path.join(stage, 'README.md'), 'utf8');
assert.equal(got, `# r\n\n[design](${host}/blob/v9.9.9/docs/DESIGN.md)\n[play](docs/PLAYING.md)\n[rel](${host}/releases/latest)\n![shot](${host}/raw/v9.9.9/docs/img/x.jpg)\n`);
assert.equal(fs.readFileSync(path.join(dir, 'README.md'), 'utf8'), readme, 'the git checkout keeps its relative links');
const playing = fs.readFileSync(path.join(stage, 'docs/PLAYING.md'), 'utf8');
assert.match(playing, new RegExp(`\\(${host}/blob/v9.9.9/docs/research/00-INDEX.md\\)`));
assert.match(playing, /\(\.\.\/README\.md#操作\)/);
const deploy = fs.readFileSync(path.join(stage, 'docs/DEPLOY.md'), 'utf8');
assert.match(deploy, new RegExp(`\\(${host}/blob/v9.9.9/docs/PACKS.md\\)`));
assert.match(deploy, new RegExp(`\\(${host}/blob/v9.9.9/docs/WINDOWS.md\\)`));
assert.match(deploy, /\(\.\.\/NOTICE\.md\)/);
const shipped = new Set(plan(dir, { lite: true, allowDirty: true, scan: false, measure: false }).tracked);
assert.equal(rewritePlayerDoc(readme, 'README.md', shipped, '9.9.9'), got);
assert.equal(playerPackageJson(JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')), shipped).scripts.test, undefined);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
fs.rmSync(out, { recursive: true, force: true });
}
});
test('the personal scan reads bytes, never reports the matched text, and skips files it cannot read', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sp-package-scan-'));
try {
+139
View File
@@ -0,0 +1,139 @@
// Execute the workflow's actual script with API doubles; this is not a GitHub Actions integration test.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
const workflow = readFileSync(new URL('../.github/workflows/pr-labeler.yml', import.meta.url), 'utf8');
const block = workflow.match(/^ {10}script: \|\n((?: {12}.*\n|\n)+)$/m);
assert.ok(block, 'the workflow contains one inline script');
const script = block[1].replace(/^ {12}/gm, '');
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
const run = new AsyncFunction('github', 'context', 'core', script);
const repo = { owner: 'sganggs', repo: 'Stronghold-Protocol' };
function harness(title, { labels = [], action = 'opened', changes, eventTitle = title, labelError, addError } = {}) {
const calls = [];
const messages = [];
const pr = { title, labels: labels.map((name) => ({ name })) };
const context = {
repo,
issue: { ...repo, number: 123 },
payload: {
action, changes,
pull_request: {
number: 123, title: eventTitle,
head: { ref: 'fix/example', repo: { full_name: 'contributor/Stronghold-Protocol', fork: true } },
},
},
};
const github = { rest: {
pulls: { get: async (params) => {
assert.deepEqual(params, { ...repo, pull_number: 123 });
calls.push(['get', params]);
return { data: pr };
} },
issues: {
getLabel: async (params) => {
assert.deepEqual(params, { ...repo, name: params.name });
calls.push(['getLabel', params]);
if (labelError) throw labelError;
return { data: { name: params.name } };
},
addLabels: async (params) => {
assert.deepEqual(params, { ...repo, issue_number: 123, labels: params.labels });
calls.push(['addLabels', params]);
if (addError) throw addError;
for (const name of params.labels) pr.labels.push({ name });
},
},
} };
return { calls, messages, pr, context, run: () => run(github, context, { info: (message) => messages.push(message) }) };
}
test('only PR metadata events, minimal permission, pinned action and no executable PR input', () => {
assert.match(workflow, /^on:\n {2}pull_request_target:\n {4}types: \[opened, edited, reopened\]\n\npermissions:/m);
assert.match(workflow, /^permissions:\n {2}pull-requests: write\n\njobs:/m);
assert.equal((workflow.match(/^ {6}- uses:/gm) || []).length, 1);
assert.match(workflow, /uses: actions\/github-script@[a-f0-9]{40} # v8/);
assert.doesNotMatch(workflow, /actions\/checkout|^\s*run:|\$\{\{|secrets\./m);
});
for (const [title, expected] of [
['fix: repair', 'bug'], ['bug: repair', 'bug'], ['docs: update', 'documentation'],
['feat: add', 'enhancement'], ['feature: add', 'enhancement'],
['FIX(ui): repair', 'bug'], ['BuG(server):修复', 'bug'], ['Docs(readme):说明', 'documentation'],
['FEAT(ui): add', 'enhancement'], ['Feature(界面):新增', 'enhancement'], [' fix:修复 ', 'bug'],
['fix: " ; process.exit(1); // ${{ secrets.TOKEN }} $(echo unsafe)', 'bug'],
]) {
test(`maps ${JSON.stringify(title)} to ${expected}`, async () => {
const h = harness(title, { labels: ['help wanted'] });
await h.run();
assert.deepEqual(h.calls.map(([name]) => name), ['get', 'getLabel', 'addLabels']);
assert.deepEqual(h.calls[2][1].labels, [expected]);
assert.deepEqual(h.pr.labels.map(({ name }) => name), ['help wanted', expected]);
});
}
for (const title of [
'chore: maintain', 'refactor: cleanup', 'test: coverage', 'ci: improve', 'perf: speed', 'style: format',
'bugfix: repair', 'fixes: repair', 'prefix fix: repair', 'fix repair', 'fix[]: repair',
'fix(): repair', 'fix(ui)(server): repair', 'fix(ui: repair', 'fix(ui\nserver): repair', '',
]) {
test(`ignores ${JSON.stringify(title)}`, async () => {
const h = harness(title, { labels: ['help wanted'] });
await h.run();
assert.deepEqual(h.calls.map(([name]) => name), ['get']);
assert.deepEqual(h.pr.labels, [{ name: 'help wanted' }]);
});
}
test('repeated runs skip an existing label and preserve every previous label', async () => {
const h = harness('fix: repair', { labels: ['help wanted', 'enhancement'] });
await h.run();
await h.run();
assert.equal(h.calls.filter(([name]) => name === 'addLabels').length, 1);
assert.deepEqual(h.pr.labels.map(({ name }) => name), ['help wanted', 'enhancement', 'bug']);
const existing = harness('BUG:修复', { labels: ['BUG', 'documentation'] });
await existing.run();
assert.deepEqual(existing.calls.map(([name]) => name), ['get']);
});
test('title edits use current PR metadata and add the new type without removing the old type', async () => {
const h = harness('docs(ui):说明', {
action: 'edited', changes: { title: { from: 'fix: repair' } }, eventTitle: 'fix: repair', labels: ['bug'],
});
await h.run();
assert.deepEqual(h.pr.labels.map(({ name }) => name), ['bug', 'documentation']);
});
test('body or base edits do not make any API calls', async () => {
for (const changes of [undefined, {}, { body: { from: 'old body' } }, { base: { ref: { from: 'old' } } }]) {
const h = harness('fix: repair', { action: 'edited', changes });
await h.run();
assert.deepEqual(h.calls, []);
}
});
test('opened and reopened fork events add labels in the base repository', async () => {
for (const action of ['opened', 'reopened']) {
const h = harness('feat: add', { action });
await h.run();
assert.deepEqual(h.calls.at(-1), ['addLabels', { ...repo, issue_number: 123, labels: ['enhancement'] }]);
}
});
test('a missing repository label is skipped without creating or adding it', async () => {
const h = harness('fix: repair', { labelError: Object.assign(new Error('Not Found'), { status: 404 }) });
await h.run();
assert.deepEqual(h.calls.map(([name]) => name), ['get', 'getLabel']);
assert.deepEqual(h.pr.labels, []);
assert.equal(h.messages.length, 1);
});
test('permission and API failures remain visible', async () => {
for (const status of [403, 500]) {
const error = Object.assign(new Error('API failure'), { status });
await assert.rejects(harness('fix: repair', { labelError: error }).run(), error);
await assert.rejects(harness('fix: repair', { addError: error }).run(), error);
}
});
+67
View File
@@ -0,0 +1,67 @@
// 敌方血条/名字牌的头顶高度:定位要用"原点以上的高度"(bounds.y + bounds.height),不是包围盒高度。
// 数值取自 data/assets.json 里真实的 setup-pose bounds(逐腐兽 / 猎狗pro / 大鲍勃 / 锏)。
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)), '../..');
// units.js 读的是浏览器全局的 PIXI(脚本标签加载),模块只在运行时碰它
globalThis.PIXI = globalThis.PIXI || { Container: class {}, Graphics: class {}, Sprite: class {} };
const { headTilesForUnit, HEAD_TILES_CAP, HEAD_TILES_CAP_BOSS } = await import('../../public/js/render/units.js');
const { UNIT } = await import('../../public/js/render/style.js');
/** The real setup-pose bounds (data/assets.json enemies.*.spine.bounds), with the official model factor. */
const CASES = {
逐腐兽: { y: -404.687, height: 722.64, modelScale: 0.8889 },
疯狂的逐腐兽: { y: -404.687, height: 722.64, modelScale: 0.8889 },
猎狗pro: { y: -3.745, height: 257.006, modelScale: 0.963 },
大鲍勃: { y: -2.62, height: 408.999, modelScale: 1.1111, boss: true },
锏: { y: -798, height: 1183, modelScale: 1, boss: true },
};
const head = (name, extra = {}) => {
const c = CASES[name];
return headTilesForUnit({
isEnemy: true, isBoss: !!c.boss, spineShown: true, hasBounds: true,
bounds: { y: c.y, height: c.height }, modelK: c.modelScale, modelKY: 1, ...extra,
});
};
describe('敌方血条的头顶高度(bounds.y 不能忽略)', () => {
test('逐腐兽:原点在身体中间,血条按"原点以上" 0.8 格,而不是包围盒高度的 1.85 格', () => {
const tiles = head('逐腐兽');
assert.ok(Math.abs(tiles - 0.81) < 0.02, `逐腐兽 ≈ 0.81 格,实得 ${tiles.toFixed(3)}`);
assert.ok(tiles < head('逐腐兽', { bounds: { height: CASES.逐腐兽.height } }) - 0.9, '与忽略 bounds.y 的老算法差约一格');
assert.equal(head('疯狂的逐腐兽').toFixed(3), tiles.toFixed(3), '同一个模型,同样的高度');
});
test('脚锚定的敌人(y ≈ 0)几乎不变:猎狗pro / 大鲍勃', () => {
assert.ok(Math.abs(head('猎狗pro') - 0.71) < 0.01, `猎狗pro ≈ 0.71 格,实得 ${head('猎狗pro').toFixed(3)}`);
// y = −3.7(猎狗pro)时两种算法的差只有 0.01 格:脚锚定的模型几乎不受这次修正影响
assert.ok(Math.abs(head('猎狗pro') - head('猎狗pro', { bounds: { height: CASES.猎狗pro.height } })) < 0.02, 'y≈0 时两种算法几乎一致');
assert.ok(Math.abs(head('大鲍勃') - 1.31) < 0.02, `大鲍勃 ≈ 1.31 格,实得 ${head('大鲍勃').toFixed(3)}`);
});
test('上限仍然兜底:模型再高,血条也不会飞出屏幕', () => {
assert.ok(Math.abs(head('锏') - 1.11) < 0.02, `锏 ≈ 1.11 格(385/320×0.92),实得 ${head('锏').toFixed(3)}`);
assert.ok(head('锏') <= HEAD_TILES_CAP_BOSS);
assert.ok(head('逐腐兽') <= HEAD_TILES_CAP);
const absurd = headTilesForUnit({ isEnemy: true, isBoss: false, spineShown: true, bounds: { y: 0, height: 5000 }, modelK: 1, modelKY: 1 });
assert.equal(absurd, HEAD_TILES_CAP, '超过上限就截到上限');
});
test('回退分支:没有 bounds 用 chibi 头顶余量 × 模型系数;非敌方用固定余量;Boss 无模型用 Boss 高度', () => {
assert.equal(headTilesForUnit({ isEnemy: true, isBoss: false, spineShown: true, bounds: null, modelK: 1, modelKY: 1 }), UNIT.headroom);
assert.equal(headTilesForUnit({ isEnemy: true, isBoss: false, spineShown: true, bounds: null, modelK: 1.5, modelKY: 1 }), Math.min(HEAD_TILES_CAP, UNIT.headroom * 1.5));
assert.equal(headTilesForUnit({ isEnemy: true, isBoss: true, spineShown: false, bounds: null, modelK: 1, modelKY: 1 }), HEAD_TILES_CAP, 'Boss 没有模型时按 Boss 高度');
assert.equal(headTilesForUnit({ isEnemy: false, isBoss: false, spineShown: true, bounds: { y: 0, height: 500 }, modelK: 1, modelKY: 1 }), UNIT.headroom, '干员/召唤物一律用统一余量');
});
test('实现只有一处:纯函数算 y + height,units.js 把 bounds 交给它(防止有人改回 height)', () => {
const units = readFileSync(path.join(ROOT, 'public/js/render/units.js'), 'utf8');
assert.match(units, /const top = Number\.isFinite\(b\.y\) \? b\.y \+ b\.height : b\.height;/, 'top = y + height');
assert.match(units, /bounds: actor && actor\.entry \? actor\.entry\.bounds : null/, 'units.js 把 bounds 交给纯函数');
assert.match(units, /headTilesForUnit\(\{/, '而且用的就是它');
});
});
+19 -2
View File
@@ -1,6 +1,6 @@
// test/render/board3d-extract.test.js — the local-client exports behind the official 3D board (DESIGN §13/§15):
// tools/local-extract/extract.py's board jobs (gate / objective effects, blower texture, water & noise maps), the
// derived three.js maps (BC5 normal → RGB normal, Unity metallic/gloss → roughness), the WebP copies of the textures
// derived three.js maps (BC5 / DXT5nm normal → RGB normal, Unity metallic/gloss → roughness), the WebP copies of the textures
// the renderer downloads (extract.py WEBP), the prefab mesh keys, and —
// when a client was extracted here — the files and manifest entries the renderer loads (render/board3d/load.js).
@@ -65,6 +65,23 @@ print(json.dumps({'n': [list(rn.getpixel((i, 0))) for i in range(3)], 'm': [list
assert.deepEqual(out.m, [[255, 255, 0], [255, 55, 0]]);
});
test('derived normal map: the DXT5nm layout (R = 1, G = Y, A = X) gives the same normals as BC5 (R = X, G = Y)', { skip: !HAS_PIL && 'no Pillow' }, () => {
const out = py(`
from PIL import Image
xy = [(128, 128), (255, 128), (128, 20), (90, 200), (0, 128), (37, 37)]
bc5 = Image.new('RGB', (len(xy), 1)); nm = Image.new('RGBA', (len(xy), 1)); opaque = Image.new('RGBA', (len(xy), 1))
for i, (x, y) in enumerate(xy):
bc5.putpixel((i, 0), (x, y, 0)); nm.putpixel((i, 0), (255, y, y, x)); opaque.putpixel((i, 0), (x, y, 0, 255))
px = lambda im: [list(im.getpixel((i, 0))) for i in range(len(xy))]
print(json.dumps({'bc5': px(e.derive_normal_rg(bc5)), 'nm': px(e.derive_normal_rg(nm)), 'opaque': px(e.derive_normal_rg(opaque)),
'mode': e.derive_normal_rg(nm).mode, 'xy': xy}))`);
assert.equal(out.mode, 'RGB');
assert.deepEqual(out.nm, out.bc5, 'X read from A when R is 1');
assert.deepEqual(out.opaque, out.bc5, 'a BC5 map decoded with an opaque alpha');
assert.deepEqual(out.nm.map((p) => p.slice(0, 2)), out.xy, 'XY kept');
assert.ok(out.nm[0][2] >= 254 && out.nm[3][2] > 128, 'z rebuilt, not the 128 of x = 1');
});
test('WebP copies: exactly the board textures the renderer downloads; normal and data maps stay lossless', () => {
const r = spawnSync(PY, [path.join(TOOL, 'extract.py'), '--print-jobs'], { encoding: 'utf8', env: ENV });
assert.equal(r.status, 0, r.stderr);
@@ -182,7 +199,7 @@ describe('extracted board files (when a local client was extracted)', { skip: !m
const n = decodePng(readFileSync(onDisk(pngOf(g.TX_autochessi_N_rgb.path))));
let zSum = 0, cnt = 0;
for (let i = 0; i < n.rgba.length; i += 4 * 97) { zSum += n.rgba[i + 2]; cnt++; }
assert.ok(zSum / cnt > 230, `mean z ${zSum / cnt}`);
assert.ok(zSum / cnt > 230, `mean z ${zSum / cnt} (128 = made by an extract.py that read a DXT5nm map as BC5: re-extract with --only map/autochess)`);
const m = decodePng(readFileSync(onDisk(g.TX_autochessi_M.path)));
const r = decodePng(readFileSync(onDisk(pngOf(g.TX_autochessi_M_rough.path))));
assert.deepEqual([r.w, r.h], [m.w, m.h]);
+494
View File
@@ -0,0 +1,494 @@
// test/render/board3d-scene.test.js — the official battlefield scenes (DESIGN §15): tools/local-extract/scenes.py
// (the scene table, the board-space conversion, the chunk records, the sub-graphic groups, the RGBM lightmap decode;
// run without UnityPy), fetch_scenes.py's bundle selection, the browser side (render/board3d/load.js chunk reader and
// loader with a fake store; scene.js BoardScene with a stub renderer: the scene replaces the procedural tiles and
// background, its parts follow the built areas, a missing scene keeps the procedural board), and — when scenes were
// extracted here — the files the manifest lists.
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, existsSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import * as THREE from 'three';
import { BoardScene, sceneGroupVisible } from '../../public/js/render/board3d/scene.js';
import { AREAS } from '../../public/js/render/board3d/layout.js';
import { resolveUvTable } from '../../public/js/render/board3d/atlas.js';
import { presetCamera } from '../../public/js/render/projection.js';
import { sceneMaterial, sceneWaterMaterial, sceneBlend, focusUniforms, SCENE_LIGHT } from '../../public/js/render/board3d/materials.js';
import {
parseSceneChunks, sceneGroupRects, loadScenePack, sceneListed, resetBoardPack, sceneGroup, sceneMaterialKey, mergeSceneChunks,
SCENE_TEX_GROUP, SCENE_VERSION, SCENE_VERTEX_BYTES,
} from '../../public/js/render/board3d/load.js';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const TOOL = path.join(ROOT, 'tools/local-extract');
const ENV = { ...process.env, PYTHONDONTWRITEBYTECODE: '1' };
const PY = ['python3', 'python'].find((bin) => spawnSync(bin, ['--version']).status === 0);
const HAS_PIL = !!PY && spawnSync(PY, ['-c', 'import PIL'], { env: ENV }).status === 0;
const stages = JSON.parse(readFileSync(path.join(ROOT, 'data/stages.json'), 'utf8'));
const manifest = (() => { try { return JSON.parse(readFileSync(path.join(ROOT, 'data/local-assets.json'), 'utf8')); } catch { return null; } })();
function py(code) {
const src = `import sys, json, struct\nsys.path.insert(0, ${JSON.stringify(TOOL)})\nimport scenes as s\n${code}`;
const r = spawnSync(PY, ['-c', src], { encoding: 'utf8', env: ENV });
assert.equal(r.status, 0, r.stderr);
return JSON.parse(r.stdout);
}
/** One quad as a scene chunk record + its bytes (the layout of scenes.py pack_chunk). */
function quadChunk({ x0 = 0, y0 = 0, z = 0, material = 'M', lightmap = 0, group = '' } = {}) {
const n = 4, idx = [0, 1, 2, 0, 2, 3];
const buf = new ArrayBuffer(n * SCENE_VERTEX_BYTES + idx.length * 2);
new Float32Array(buf, 0, 12).set([x0, y0, z, x0 + 1, y0, z, x0 + 1, y0 + 1, z, x0, y0 + 1, z]);
new Float32Array(buf, 48, 8).set([0, 0, 1, 0, 1, 1, 0, 1]);
new Float32Array(buf, 80, 8).set([0, 0, 0.5, 0, 0.5, 0.5, 0, 0.5]);
new Int8Array(buf, 112, 16).set([0, 0, 127, 0, 0, 0, 127, 0, 0, 0, 127, 0, 0, 0, 127, 0]);
new Uint8Array(buf, 128, 16).fill(255);
new Uint16Array(buf, 144, 6).set(idx);
return { rec: { material, lightmap, group, verts: n, tris: 2, index32: false, bounds: [x0, y0, z, x0 + 1, y0 + 1, z] }, bytes: new Uint8Array(buf) };
}
/** scene.json + scene.bin of a few quads. */
function fakeScene(parts) {
const chunks = parts.map(quadChunk);
const total = chunks.reduce((a, c) => a + c.bytes.length, 0);
const bin = new Uint8Array(total);
let off = 0;
const recs = chunks.map((c) => { bin.set(c.bytes, off); const r = { ...c.rec, offset: off }; off += c.bytes.length; return r; });
const doc = {
version: SCENE_VERSION, stage: 'act2autochess_m02', lightmaps: 1, lightRange: 8, binBytes: total,
light: { dir: [0.36, 0.45, 0.82], color: [1, 0.93, 0.87], intensity: 1.1 },
materials: {
M: { shader: 'Torappu/Scene/StandardRealtimeShadow', keywords: [], colors: { _Color: [1, 1, 1, 1] }, floats: { _ShadowStrength: 0.9 }, textures: {}, kind: 'lit', map: 'TX_A' },
Cut: { shader: 'Torappu/Scene/StandardRealtimeShadow', keywords: ['_ALPHATEST_ON'], colors: {}, floats: { _Cutoff: 0.4 }, textures: {}, kind: 'lit', map: 'TX_A' },
W: { shader: 'Torappu/Scene/StylizedWater', keywords: [], colors: { _DeepWaterColor: [0, 0.27, 0.4, 0.67] }, floats: {}, textures: {}, kind: 'water' },
},
chunks: recs,
};
return { doc, bin: bin.buffer };
}
const PARTS = [
{ material: 'M', x0: 9, y0: 15 }, // the base (pen side)
{ material: 'W', x0: 20, y0: 12, lightmap: -1 }, // water of the base
{ material: 'M', x0: 4, y0: 9, group: 'HIDDEN@1' }, // left normal field
{ material: 'Cut', x0: 5, y0: 9, group: 'HIDDEN@1' },
{ material: 'M', x0: 16, y0: 9, group: 'HIDDEN@2' }, // right normal field
{ material: 'M', x0: 10, y0: 3, group: 'HIDDEN@3' }, // boss field
];
function fakeScenePack() {
const { doc, bin } = fakeScene(PARTS);
const chunks = parseSceneChunks(doc, bin);
const img = { width: 4, height: 4 };
return { key: 'scene-test', stage: doc.stage, doc, chunks, groups: sceneGroupRects(chunks), images: { tex: { TX_A: img }, lm: [img] } };
}
function stubRenderer() {
const calls = [];
return {
calls,
domElement: { addEventListener() {}, removeEventListener() {} },
shadowMap: { enabled: true, needsUpdate: false },
capabilities: { getMaxAnisotropy: () => 8 },
info: { render: { calls: 7, triangles: 1234 }, memory: { textures: 3, geometries: 5 } },
setClearColor() {}, setPixelRatio() {}, setSize() {}, render() {}, dispose() { calls.push(['dispose']); },
};
}
function fakePack() {
const img = { width: 4, height: 4 };
return { key: 'test', images: { D: img, common: img, gate: img, BG: img }, meshes: { gate: {} }, tiles: null, uv: resolveUvTable(null), materials: {} };
}
const sceneMeshes = (board) => Object.entries(board.meshes).filter(([k, m]) => k.startsWith('scene') && m).map(([, m]) => m);
describe('scenes.py (no UnityPy needed)', { skip: !PY && 'no python3' }, () => {
test('scene table: one prebuilt scene per battlefield of data/stages.json, with the theme bundles of its materials', () => {
const r = spawnSync(PY, [path.join(TOOL, 'extract.py'), '--print-jobs'], { encoding: 'utf8', env: ENV });
assert.equal(r.status, 0, r.stderr);
const { scenes } = JSON.parse(r.stdout);
assert.equal(scenes.sub, 'scene');
assert.equal(scenes.tex, SCENE_TEX_GROUP);
const ids = scenes.stages.map((x) => x.id);
const battlefields = Object.keys(stages).filter((id) => /^act\dautochess_m\d+$/.test(id)).sort();
assert.deepEqual([...ids].sort(), battlefields, 'every battlefield has a scene');
for (const st of scenes.stages) {
const season = st.id.replace(/_m\d+$/, '');
assert.equal(st.bundle, `scenes/activities/${season}/level_${st.id}/level_${st.id}.ab`);
assert.ok(st.deps.length > 0 && st.deps.every((d) => d.startsWith('arts/maps/') && d.endsWith('.ab')), st.id);
}
const sand = scenes.stages.filter((x) => x.deps.includes('arts/maps/map_autochesssand/res.ab')).map((x) => x.id);
assert.deepEqual(sand, ['act1autochess_m01', 'act1autochess_m02', 'act1autochess_m03', 'act1autochess_m04', 'act1autochess_m05',
'act1autochess_m06', 'act1autochess_m07', 'act2autochess_m02', 'act2autochess_m04'], 'the sand-theme battlefields (official asset index)');
assert.ok(!scenes.stages.some((x) => x.deps.includes('arts/maps/map_autochess/res.ab')), 'no battlefield uses the metal theme');
});
test('--only scene / scene/<id> selects scenes; a client without scene bundles skips them', () => {
const out = py(`import extract as e
print(json.dumps({'all': len(e.scene_ids([])), 'scene': len(e.scene_ids(['scene'])), 'one': e.scene_ids(['scene/act2autochess_m02']),
'other': e.scene_ids(['emoticon']), 'missing': s.find_scene('act2autochess_m02', ['/no/such/root']) is None}))`);
assert.deepEqual(out, { all: 11, scene: 11, one: ['act2autochess_m02'], other: [], missing: true });
});
test('board space: Unity anchor-local (x, y, −z); transforms compose; a mirrored transform has a negative determinant', () => {
const out = py(`m = s.mul(s.trs((1, 2, 3), (0, 0, 0, 1), (2, 2, 2)), s.trs((1, 0, 0), (0, 0, 0, 1), (1, 1, 1)))
rot = s.trs((0, 0, 0), (0, 0, 0.7071068, 0.7071068), (1, 1, 1))
print(json.dumps({'board': s.to_board((-6.0, -4.0, -0.4), (-10.0, -9.0, 0.0)), 'm': [m[0][3], m[1][3], m[2][3], m[0][0]],
'rot': [round(rot[0][1], 4), round(rot[1][0], 4)], 'det': [s.det3(s.IDENTITY), s.det3(s.trs((0, 0, 0), (0, 0, 0, 1), (1, 1, -1)))]}))`);
assert.deepEqual(out.board, [4, 5, 0.4], 'tile (row 5, col 4) on high ground');
assert.deepEqual(out.m, [3, 2, 3, 2]);
assert.deepEqual(out.rot, [-1, 1], 'a quarter turn about z');
assert.deepEqual(out.det, [1, -1]);
});
test('pack_chunk: 36 bytes per vertex + 16-bit indices padded to 4 bytes (what load.js parseSceneChunks reads)', () => {
const out = py(`ch = {'pos': [(0, 0, 0), (1, 0, 0), (0, 1, 0.5)], 'uv': [(0, 0), (1, 0), (0, 1)], 'uv1': [(0.1, 0.2), (0.3, 0.2), (0.1, 0.4)],
'nrm': [(0, 0, 1)] * 3, 'col': [(1, 0.5, 0, 1)] * 3, 'idx': [0, 1, 2]}
data, idx32 = s.pack_chunk(ch)
print(json.dumps({'len': len(data), 'idx32': idx32, 'hex': data.hex()}))`);
assert.equal(out.idx32, false);
assert.equal(out.len, 3 * SCENE_VERTEX_BYTES + 8, '3 vertices, 3 indices (6 bytes) padded to 8');
const bin = Uint8Array.from(Buffer.from(out.hex, 'hex')).buffer;
const [c] = parseSceneChunks({ chunks: [{ material: 'M', lightmap: 0, group: '', verts: 3, tris: 1, offset: 0, index32: false, bounds: [0, 0, 0, 1, 1, 0.5] }] }, bin);
assert.deepEqual([...c.position], [0, 0, 0, 1, 0, 0, 0, 1, 0.5]);
assert.deepEqual([...c.uv1].map((v) => Math.round(v * 10) / 10), [0.1, 0.2, 0.3, 0.2, 0.1, 0.4]);
assert.deepEqual([...c.normal.slice(0, 4)], [0, 0, 127, 0]);
assert.deepEqual([...c.color.slice(0, 4)], [255, 128, 0, 255]);
assert.deepEqual([...c.index], [0, 1, 2]);
});
test('hidden_groups: the objects listed after each sub-graphic name of a MapSubGraphicHolder', () => {
const out = py(`def name(t):
b = t.encode(); return struct.pack('<i', len(b)) + b + b'\\0' * (-len(b) % 4)
ptr = lambda pid: struct.pack('<iq', 0, pid)
raw = struct.pack('<i', 2) + name('HIDDEN@1') + struct.pack('<ii', 1, 2) + ptr(11) + ptr(12) + struct.pack('<i', 1) + ptr(21) + struct.pack('<i', 0) \\
+ name('HIDDEN@2') + struct.pack('<ii', 1, 1) + ptr(13) + struct.pack('<i', 0) + ptr(99)
objs = {11: 1, 12: 1, 13: 1, 21: 1}
print(json.dumps({str(k): v for k, v in s.hidden_groups(raw, objs).items()}))`);
assert.deepEqual(out, { 11: 'HIDDEN@1', 12: 'HIDDEN@1', 21: 'HIDDEN@1', 13: 'HIDDEN@2' }, 'pointers to objects of the file; an unknown id is no pointer');
});
test('decode_rgbm: the RGBM light as (L / LIGHT_RANGE)^(1/2.2); a page without alpha is kept', { skip: !HAS_PIL && 'no Pillow' }, () => {
const out = py(`from PIL import Image
img = Image.new('RGBA', (2, 1)); img.putdata([(255, 128, 0, 51), (255, 255, 255, 255)])
d = s.decode_rgbm(img)
print(json.dumps({'mode': d.mode, 'px': list(d.getdata()), 'range': s.LIGHT_RANGE, 'plain': s.decode_rgbm(Image.new('RGB', (1, 1), (9, 8, 7))).getpixel((0, 0))}))`);
assert.equal(out.mode, 'RGB');
// texel (1, 0.5, 0, A = 0.2): L = 5^2.2 · 0.2^2.2 · rgb → stored (L / range)^(1/2.2) = (5^2.2 / range)^(1/2.2) · 0.2 · rgb^(1/2.2)
const k = (5 ** 2.2 / out.range) ** (1 / 2.2);
const want = [1, 128 / 255, 0].map((c) => Math.round(255 * Math.min(1, k * 0.2) * c ** (1 / 2.2)));
for (let i = 0; i < 3; i++) assert.ok(Math.abs(out.px[0][i] - want[i]) <= 2, `channel ${i}: ${out.px[0][i]} vs ${want[i]}`);
assert.deepEqual(out.px[1], [255, 255, 255], 'clamped at the range');
assert.deepEqual(out.plain, [9, 8, 7]);
});
test('fetch_scenes.py: CDN file names and the bundles of the scene table', () => {
const src = `import sys, json\nsys.path.insert(0, ${JSON.stringify(TOOL)})\nimport fetch_scenes as f\nnames = ['arts/maps/map_autochess/res.ab', 'scenes/activities/act2autochess/level_act2autochess_m02/level_act2autochess_m02.ab', 'scenes/activities/act2autochess/level_act2autochess_m02/level_act2autochess_m02/lightingdata.ab', 'scenes/activities/act44side/level_act44side_01/level_act44side_01.ab']\nprint(json.dumps({'dat': f.dat_name('scenes/a/level_x/level_x.ab'), 'hash': f.dat_name('avg/a#1.ab'), 'wanted': f.wanted(names, ['act2autochess_m02'])}))`;
const r = spawnSync(PY, ['-c', src], { encoding: 'utf8', env: ENV });
assert.equal(r.status, 0, r.stderr);
const out = JSON.parse(r.stdout);
assert.equal(out.dat, 'scenes_a_level_x_level_x.dat');
assert.equal(out.hash, 'avg_a__1.dat');
assert.equal(out.wanted.length, 2, 'the scene and its lighting data, nothing else');
});
});
describe('scene chunks (load.js)', () => {
test('parseSceneChunks: typed-array views per chunk; a record that does not fit the buffer rejects the scene', () => {
const { doc, bin } = fakeScene(PARTS);
const chunks = parseSceneChunks(doc, bin);
assert.equal(chunks.length, PARTS.length);
assert.equal(chunks[0].position.length, 12);
assert.deepEqual([...chunks[2].position.slice(0, 3)], [4, 9, 0]);
assert.deepEqual([...chunks[0].index], [0, 1, 2, 0, 2, 3]);
assert.equal(chunks[1].lightmap, -1);
assert.equal(parseSceneChunks(doc, bin.slice(0, bin.byteLength - 4)), null, 'truncated bin');
assert.equal(parseSceneChunks({ chunks: [{ ...doc.chunks[0], offset: 2 }] }, bin), null, 'unaligned offset');
assert.equal(parseSceneChunks({ chunks: [{ ...doc.chunks[0], verts: 0 }] }, bin), null);
assert.equal(parseSceneChunks(null, bin), null);
assert.equal(parseSceneChunks(doc, null), null);
});
test('sceneMaterialKey / mergeSceneChunks: one mesh per material and lightmap page; indices keep each quad', () => {
assert.equal(sceneMaterialKey({ material: 'M', lightmap: 3 }, { kind: 'lit' }), 'M|3');
assert.equal(sceneMaterialKey({ material: 'M', lightmap: 0 }, null), 'M|0');
assert.equal(sceneMaterialKey({ material: 'W', lightmap: -1 }, { kind: 'water' }), 'W|');
assert.equal(sceneMaterialKey({ material: 'W', lightmap: 2 }, { kind: 'water' }), 'W|', 'water has no lightmap page');
const { doc, bin } = fakeScene([{ material: 'M', x0: 0, y0: 0 }, { material: 'M', x0: 3, y0: 1, group: 'HIDDEN@1' }]);
const chunks = parseSceneChunks(doc, bin);
assert.equal(mergeSceneChunks([chunks[0]]), chunks[0], 'a single chunk stays the scene.bin view');
assert.equal(mergeSceneChunks([]), null);
assert.equal(mergeSceneChunks(null), null);
const merged = mergeSceneChunks(chunks);
assert.equal(merged.position.length / 3, 8);
assert.ok(merged.index instanceof Uint16Array);
assert.deepEqual([...merged.index], [0, 1, 2, 0, 2, 3, 4, 5, 6, 4, 6, 7]);
assert.equal(merged.position[12], 3, 'the second quad keeps its board position');
assert.equal(merged.normal[18], 127);
assert.deepEqual([...merged.color.slice(16, 20)], [255, 255, 255, 255]);
assert.deepEqual([...merged.uv1.slice(8, 12)], [0, 0, 0.5, 0]);
assert.deepEqual([...chunks[1].index], [0, 1, 2, 0, 2, 3], 'the source chunk is not rewritten');
assert.deepEqual(merged.bounds, [0, 0, 0, 4, 2, 0]);
const big = new Float32Array(65535 * 3);
const one = {
material: 'M', lightmap: 0, group: '', bounds: null,
position: big, uv: new Float32Array(65535 * 2), uv1: new Float32Array(65535 * 2),
normal: new Int8Array(65535 * 4), color: new Uint8Array(65535 * 4), index: new Uint16Array([0, 0, 0]),
};
const extra = {
material: 'M', lightmap: 0, group: 'HIDDEN@1', bounds: null,
position: new Float32Array([9, 8, 7]), uv: new Float32Array(2), uv1: new Float32Array(2),
normal: new Int8Array(4), color: new Uint8Array(4), index: new Uint16Array([0, 0, 0]),
};
const wide = mergeSceneChunks([one, extra]);
assert.ok(wide.index instanceof Uint32Array, 'past 65535 vertices the index is 32-bit');
assert.equal(wide.position.length / 3, 65536);
assert.equal(wide.index[3], 65535);
assert.equal(wide.position[65535 * 3], 9);
assert.throws(() => mergeSceneChunks([{ ...chunks[0], material: 'Other' }, chunks[1]]), /mixes/);
});
test('sceneGroupRects / sceneGroupVisible: the official sub-graphics are the fields of the built areas', () => {
const { doc, bin } = fakeScene(PARTS);
const rects = sceneGroupRects(parseSceneChunks(doc, bin));
assert.deepEqual(Object.keys(rects).sort(), ['HIDDEN@1', 'HIDDEN@2', 'HIDDEN@3']);
assert.deepEqual(rects['HIDDEN@1'], [4, 9, 6, 10], 'union of the group\'s chunks');
const shown = (areas) => Object.keys(rects).filter((g) => sceneGroupVisible(rects[g], areas)).sort();
assert.deepEqual(shown(AREAS.normal), ['HIDDEN@1'], 'own field');
assert.deepEqual(shown(AREAS.unite), ['HIDDEN@1', 'HIDDEN@2'], 'both normal fields');
assert.deepEqual(shown(AREAS.boss), ['HIDDEN@3'], 'the boss field');
assert.deepEqual(shown(AREAS.all), ['HIDDEN@1', 'HIDDEN@2', 'HIDDEN@3']);
assert.equal(sceneGroupVisible(null, AREAS.normal), true, 'a part without bounds is shown');
});
test('loadScenePack: scene.json + scene.bin + lightmap pages + textures through the store; null when not listed or malformed', async () => {
resetBoardPack();
const { doc, bin } = fakeScene(PARTS);
const group = sceneGroup('act2autochess_m02');
const urls = { [`${group}|scene`]: '/s/scene.json', [`${group}|bin`]: '/s/scene.bin', [`${group}|lm0`]: '/s/lm0.webp', [`${SCENE_TEX_GROUP}|TX_A`]: '/t/TX_A.webp' };
const seen = [];
const store = { local: async () => ({ groups: {} }), localUrl: (g, n) => urls[`${g}|${n}`] || null, image: async (u) => { seen.push(u); return { width: 8, height: 8 }; } };
const original = globalThis.fetch;
let json = doc;
globalThis.fetch = async (u) => (u === '/s/scene.json' ? { ok: true, json: async () => json } : u === '/s/scene.bin' ? { ok: true, arrayBuffer: async () => bin } : { ok: false });
try {
assert.equal(await sceneListed(store, 'act2autochess_m02'), true);
assert.equal(await sceneListed(store, 'act2autochess_m01'), false);
assert.equal(await loadScenePack(store, 'act2autochess_m01'), null, 'not extracted');
const pack = await loadScenePack(store, 'act2autochess_m02');
assert.equal(pack.chunks.length, PARTS.length);
assert.deepEqual(seen.sort(), ['/s/lm0.webp', '/t/TX_A.webp']);
assert.ok(pack.images.tex.TX_A && pack.images.lm[0]);
assert.deepEqual(Object.keys(pack.groups).sort(), ['HIDDEN@1', 'HIDDEN@2', 'HIDDEN@3']);
assert.equal(await loadScenePack(store, 'act2autochess_m02'), pack, 'cached');
resetBoardPack();
json = { ...doc, version: SCENE_VERSION + 1 };
assert.equal(await loadScenePack(store, 'act2autochess_m02'), null, 'unknown version → the procedural board');
json = doc;
assert.ok(await loadScenePack(store, 'act2autochess_m02'), 'a failed load is not cached');
assert.equal(await loadScenePack(null, 'act2autochess_m02').catch(() => 'threw'), await loadScenePack(store, 'act2autochess_m02'), 'cached per stage');
} finally { globalThis.fetch = original; resetBoardPack(); }
});
});
describe('scene materials', () => {
test('sceneBlend: alpha test / blend by the Unity keywords', () => {
assert.equal(sceneBlend({ keywords: ['_NORMALMAP'] }), 'opaque');
assert.equal(sceneBlend({ keywords: ['_ALPHATEST_ON'] }), 'cutout');
assert.equal(sceneBlend({ keywords: ['_ALPHABLEND_ON', '_EMISSION'] }), 'blend');
assert.equal(sceneBlend(null), 'opaque');
});
test('sceneMaterial: linear albedo tint, lightmap range, key light, cutoff; unlit fallback without a lightmap page', () => {
const focus = focusUniforms(THREE);
const map = new THREE.Texture(), lm = new THREE.Texture();
const info = { keywords: ['_ALPHATEST_ON'], colors: { _Color: [0.5, 1, 1, 1] }, floats: { _Cutoff: 0.4, _ShadowStrength: 0.9 } };
const m = sceneMaterial(THREE, { map, lightMap: lm, info, lightRange: 8, light: { dir: [0, 0, 2], color: [1, 1, 1], intensity: 1.1 } }, focus);
assert.equal(m.uniforms.uMap.value, map);
assert.equal(m.uniforms.uLm.value, lm);
assert.ok(Math.abs(m.uniforms.uColor.value.x - 0.5 ** 2.2) < 1e-6, 'authored in gamma space');
assert.equal(m.uniforms.uCutoff.value, 0.4);
assert.equal(m.uniforms.uShadow.value, 0.9);
assert.equal(m.uniforms.uLmScale.value, 8 * SCENE_LIGHT.indirect);
assert.deepEqual(m.uniforms.uLightDir.value.toArray(), [0, 0, 1], 'normalised');
assert.ok(Math.abs(m.uniforms.uLightColor.value.x - 1.1 * SCENE_LIGHT.direct) < 1e-6);
assert.equal(m.uniforms.uFocus, focus.uFocus, 'the shared focus falloff');
assert.equal(m.transparent, false);
assert.ok(m.defines.SCENE_MAP && m.defines.SCENE_LM);
const bare = sceneMaterial(THREE, { info: { keywords: ['_ALPHABLEND_ON'] } }, focus);
assert.equal(bare.uniforms.uLmScale.value, 0);
assert.equal(bare.uniforms.uAmbient.value, SCENE_LIGHT.ambient);
assert.equal(bare.uniforms.uCutoff.value, 0);
assert.equal(bare.transparent, true);
assert.equal(bare.depthWrite, false);
assert.ok(!bare.defines.SCENE_MAP && !bare.defines.SCENE_LM);
const w = sceneWaterMaterial(THREE, {}, focus, { colors: { _DeepWaterColor: [0, 0.27, 0.4, 0.67] } });
assert.deepEqual(w.uniforms.uDeep.value.toArray(), [0, 0.27, 0.4, 0.67]);
assert.equal(w.transparent, true);
assert.ok(!w.defines.WATER_MAPS, 'no water maps: a flat tint');
});
});
describe('BoardScene with an official scene (stub renderer)', () => {
const stage = stages.act2autochess_m02;
test('the scene replaces the procedural tiles and background; devices, gates, edges and terrain stay', () => {
const board = new BoardScene(THREE, fakePack(), { renderer: stubRenderer() });
board.setStage(stage);
assert.ok(board.meshes.board && board.meshes.bg, 'procedural board first');
assert.equal(board.official, null);
assert.equal(board.setScenePack(stage.id, fakeScenePack()), true);
assert.ok(board.official, 'official scene on');
assert.ok(!board.meshes.board && !board.meshes.glass && !board.meshes.decal && !board.meshes.pipe && !board.meshes.bg && !board.meshes.bgShadow);
assert.ok(board.meshes.crates && board.meshes.edges && board.meshes.mire, Object.keys(board.meshes).join());
const meshes = sceneMeshes(board);
assert.equal(meshes.length, 3, 'base + the left field: the two M quads share one mesh');
assert.deepEqual(meshes.map((m) => m.geometry.getAttribute('position').count).sort((a, b) => a - b), [4, 4, 8]);
const xs = meshes.flatMap((m) => {
const a = m.geometry.getAttribute('position').array;
const out = [];
for (let i = 0; i < a.length; i += 3) out.push(a[i]);
return out;
});
assert.ok(xs.includes(9) && xs.includes(4), 'base and the left field');
assert.ok(!xs.includes(16) && !xs.includes(11), 'the right field and the boss field stay out');
for (const m of meshes) {
assert.ok(m.geometry.getAttribute('aLm') && m.geometry.getAttribute('aColor').normalized && m.geometry.getAttribute('normal').normalized);
assert.equal(m.castShadow, false);
}
const water = meshes.find((m) => m.material.uniforms.uDeep);
assert.ok(water && water.material.transparent);
board.resize(1280, 720, 1);
board.render(presetCamera('normal', { width: 1280, height: 720 }), 2.5);
assert.equal(water.material.uniforms.uTime.value, 2.5, 'water animates');
board.destroy();
});
test('the scene\'s fields follow the built areas; materials and textures are reused across rebuilds', () => {
const board = new BoardScene(THREE, fakePack(), { renderer: stubRenderer() });
const pack = fakeScenePack();
board.setScenePack(stage.id, pack);
board.setStage(stage);
const lit = sceneMeshes(board).find((m) => m.material.uniforms.uMap).material;
assert.equal(lit.uniforms.uMap.value.colorSpace, THREE.SRGBColorSpace);
assert.equal(lit.uniforms.uLm.value.colorSpace, THREE.NoColorSpace);
board.setArea(AREAS.boss);
assert.equal(sceneMeshes(board).length, 2, 'base + the boss field, the two M quads merged');
board.setArea(AREAS.unite);
assert.equal(sceneMeshes(board).length, 3, 'base + both normal fields: M merged, water, cutout');
assert.equal(sceneMeshes(board).find((m) => m.material.uniforms.uMap).material, lit, 'same material object');
assert.equal(board.sceneRes.size, 1);
board.destroy();
assert.equal(board.sceneRes.size, 0, 'scene resources disposed');
});
test('another stage, or dropping the scene, shows the procedural board again', () => {
const board = new BoardScene(THREE, fakePack(), { renderer: stubRenderer() });
board.setScenePack(stage.id, fakeScenePack());
board.setStage(stages.act2autochess_m01);
assert.equal(board.official, null);
assert.ok(board.meshes.board && board.meshes.bg && !sceneMeshes(board).length, 'a stage without a scene');
board.setStage(stage);
assert.ok(board.official && !board.meshes.board);
assert.equal(board.setScenePack(stage.id, null), true);
assert.equal(board.official, null);
assert.ok(board.meshes.board && board.meshes.bg && !sceneMeshes(board).length);
assert.equal(board.setScenePack(stage.id, null), false, 'nothing to drop');
assert.equal(board.setScenePack('', fakeScenePack()), false);
board.destroy();
});
test('a different lightmap page stays its own mesh; water pages share one', () => {
const board = new BoardScene(THREE, fakePack(), { renderer: stubRenderer() });
const parts = [
{ material: 'M', x0: 4, y0: 9, lightmap: 0, group: 'HIDDEN@1' },
{ material: 'M', x0: 9, y0: 15, lightmap: 1 },
{ material: 'W', x0: 5, y0: 9, lightmap: 0, group: 'HIDDEN@1' },
{ material: 'W', x0: 6, y0: 9, lightmap: 1, group: 'HIDDEN@1' },
];
const { doc, bin } = fakeScene(parts);
const chunks = parseSceneChunks(doc, bin);
board.setScenePack(stage.id, { key: 'split', stage: stage.id, doc, chunks, groups: sceneGroupRects(chunks), images: { tex: {}, lm: [] } });
board.setStage(stage);
const meshes = sceneMeshes(board);
assert.equal(meshes.length, 3, 'M|0, M|1, and one water mesh');
const water = meshes.filter((m) => m.material.uniforms.uDeep);
assert.equal(water.length, 1);
assert.equal(water[0].geometry.getAttribute('position').count, 8, 'both water quads');
assert.equal(board.sceneWater.length, 1);
board.destroy();
});
test('a scene whose chunks are all hidden parts of other areas keeps the procedural board', () => {
const board = new BoardScene(THREE, fakePack(), { renderer: stubRenderer() });
const { doc, bin } = fakeScene([{ material: 'M', x0: 10, y0: 3, group: 'HIDDEN@3' }]);
const chunks = parseSceneChunks(doc, bin);
board.setScenePack(stage.id, { key: 'k2', stage: stage.id, doc, chunks, groups: sceneGroupRects(chunks), images: { tex: {}, lm: [] } });
board.setStage(stage);
assert.equal(board.official, null);
assert.ok(board.meshes.board && board.meshes.bg);
board.destroy();
});
});
describe('extracted scenes (when scene bundles were extracted here)', { skip: !Object.keys(manifest?.groups || {}).some((g) => g.startsWith('scene/act')) && 'no scene in data/local-assets.json' }, () => {
const groups = manifest?.groups || {};
const file = (p) => path.join(ROOT, 'public', p.replace(/^\//, ''));
for (const g of Object.keys(groups).filter((k) => k.startsWith('scene/act'))) {
test(`${g}: scene.json records fit scene.bin; lightmap pages and textures are listed and on disk`, { skip: !existsSync(file(groups[g].scene?.path || '/x')) && 'files not on this machine' }, () => {
const doc = JSON.parse(readFileSync(file(groups[g].scene.path), 'utf8'));
const bin = readFileSync(file(groups[g].bin.path));
assert.equal(doc.version, SCENE_VERSION);
assert.equal(`scene/${doc.stage}`, g);
assert.equal(doc.binBytes, bin.length);
const chunks = parseSceneChunks(doc, bin.buffer.slice(bin.byteOffset, bin.byteOffset + bin.length));
assert.ok(chunks && chunks.length === doc.chunks.length && chunks.length > 0);
assert.ok(chunks.some((c) => !c.group), 'a base part');
for (const c of chunks) {
let max = 0;
for (const i of c.index) if (i > max) max = i;
assert.ok(max < c.position.length / 3, 'indices in range');
assert.ok(doc.materials[c.material], `material ${c.material}`);
if (c.lightmap >= 0) assert.ok(c.lightmap < doc.lightmaps && groups[g][`lm${c.lightmap}`], `lightmap page ${c.lightmap}`);
}
for (const m of Object.values(doc.materials)) if (m.map) assert.ok(groups[SCENE_TEX_GROUP]?.[m.map], `texture ${m.map}`);
const L = doc.light;
assert.ok(L && Math.abs(Math.hypot(...L.dir) - 1) < 1e-3 && L.dir[2] > 0, 'the key light shines down on the board');
});
}
test('each built area is one mesh per material and lightmap, with every shown vertex and no switched-off part', () => {
const views = [['normal', AREAS.normal], ['boss', AREAS.boss], ['unite', AREAS.unite]];
for (const g of Object.keys(groups).filter((k) => k.startsWith('scene/act'))) {
if (!existsSync(file(groups[g].scene?.path || '/x'))) continue;
const doc = JSON.parse(readFileSync(file(groups[g].scene.path), 'utf8'));
const bin = readFileSync(file(groups[g].bin.path));
const chunks = parseSceneChunks(doc, bin.buffer.slice(bin.byteOffset, bin.byteOffset + bin.length));
const rects = sceneGroupRects(chunks);
const stage = stages[doc.stage];
const board = new BoardScene(THREE, fakePack(), { renderer: stubRenderer() });
board.setScenePack(doc.stage, { key: g, stage: doc.stage, doc, chunks, groups: rects, images: { tex: {}, lm: [] } });
board.setStage(stage);
for (const [name, area] of views) {
board.setArea(area);
const shown = chunks.filter((c) => !c.group || sceneGroupVisible(rects[c.group], area));
const keys = new Set(shown.map((c) => sceneMaterialKey(c, doc.materials[c.material])));
const meshes = sceneMeshes(board);
assert.equal(meshes.length, keys.size, `${doc.stage} ${name}: ${shown.length} chunks → ${keys.size} meshes`);
assert.ok(meshes.length > 0 && meshes.length <= 16, `${doc.stage} ${name}: ${meshes.length} scene draw calls`);
let verts = 0;
for (const m of meshes) verts += m.geometry.getAttribute('position').count;
assert.equal(verts, shown.reduce((a, c) => a + c.position.length / 3, 0), `${doc.stage} ${name} vertices`);
if (name !== 'unite') assert.ok(board.root.children.length <= 19, `${doc.stage} ${name}: ${board.root.children.length} meshes beside one battle device still fit 20 draws`);
}
assert.equal(board.meshes.board, undefined, `${doc.stage}: the scene replaces the metal tiles`);
board.setScenePack(doc.stage, null);
assert.ok(board.meshes.board && board.meshes.bg, `${doc.stage}: dropping the scene restores the metal board`);
board.destroy();
}
});
});
+26 -8
View File
@@ -96,7 +96,7 @@ describe('render engine in headless Chrome', { skip }, () => {
} finally { await page.close(); }
});
test('外勤医疗 reserve medic is visible but not draggable in normal and right boss preparation', async () => {
test('外勤医疗 reserve medic opens its detail from normal and right boss preparation without being draggable', async () => {
const { page, problems } = await open('scene=prep&panel=0', 1600, 900);
const first = await page.evaluate(() => {
const v = window.__demo.view;
@@ -108,8 +108,14 @@ describe('render engine in headless Chrome', { skip }, () => {
v.setPrep(st, { editable: true });
const medic = v.debug.views.get('m:-1');
const p = v.tileScreen(10, 2), rect = v.debug.app.view.getBoundingClientRect();
let clicked = null;
const off = v.on('pieceClick', (e) => { clicked = e; });
v.debug.app.view.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, button: 0, pointerId: 61, clientX: p.x, clientY: p.y }));
off();
const out = { exists: !!medic, id: medic?.info.defId, x: medic?.x, y: medic?.y,
draggable: !!v.debug.pick.pieceAt(p.x - rect.left, p.y - rect.top) };
draggable: !!v.debug.pick.pieceAt(p.x - rect.left, p.y - rect.top),
clicked: clicked ? { uid: clicked.uid, id: clicked.unit?.defId, row: clicked.unit?.y, col: clicked.unit?.x,
prepMapChar: clicked.prepMapChar } : null };
return out;
});
await wait(500);
@@ -118,7 +124,13 @@ describe('render engine in headless Chrome', { skip }, () => {
const v = window.__demo.view;
v.setCamera('bossPrep', { side: 'R', instant: true });
const mirrored = v.debug.views.get('m:-1');
return [mirrored?.x, mirrored?.y, mirrored?.dir];
const p = v.tileScreen(10, 2);
let clicked = null;
const off = v.on('pieceClick', (e) => { clicked = e; });
v.debug.app.view.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, button: 0, pointerId: 62, clientX: p.x, clientY: p.y }));
off();
return { view: [mirrored?.x, mirrored?.y, mirrored?.dir],
clicked: clicked ? [clicked.unit?.y, clicked.unit?.x, clicked.unit?.dir, clicked.prepMapChar] : null };
});
await wait(500);
await page.screenshot({ path: path.join(OUT, 'touch-prep-boss-right.png') });
@@ -127,7 +139,8 @@ describe('render engine in headless Chrome', { skip }, () => {
const st = JSON.parse(JSON.stringify(window.__demo.scene.state));
st.prepMapChars = [];
v.setPrep(st, { editable: true });
return !v.debug.views.has('m:-1');
const p = v.tileScreen(10, 2), rect = v.debug.app.view.getBoundingClientRect();
return !v.debug.views.has('m:-1') && !v.debug.pick.mapCharAt(p.x - rect.left, p.y - rect.top);
});
const fallback = await page.evaluate(async () => {
const { createFallbackView } = await import('/js/ui/fallbackField.js');
@@ -138,18 +151,23 @@ describe('render engine in headless Chrome', { skip }, () => {
ff.setPrep({ board: [], hand: [], temp: [], prepMapChars: [window.__touchPrepChar] }, { editable: true });
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
const el = host.querySelector('.ff-piece--map-char');
let clicked = null;
const off = ff.on('pieceClick', (e) => { clicked = e; });
el?.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, button: 0, pointerId: 63 }));
off();
const out = { exists: !!el, name: el?.title, pointerEvents: el ? getComputedStyle(el).pointerEvents : null,
movableId: el?.dataset.uid ?? null };
movableId: el?.dataset.uid ?? null, clicked: clicked ? [clicked.unit?.defId, clicked.prepMapChar] : null };
ff.destroy(); host.remove();
return out;
});
await page.close();
assert.deepEqual(problems, []);
assert.deepEqual(first, { exists: true, id: 'char_605_cmedic', x: 2, y: 10,
draggable: false });
assert.deepEqual(boss, [18, 3, 'LEFT']);
draggable: false, clicked: { uid: -1, id: 'char_605_cmedic', row: 10, col: 2, prepMapChar: true } });
assert.deepEqual(boss, { view: [18, 3, 'LEFT'], clicked: [10, 2, 'RIGHT', true] });
assert.equal(removed, true);
assert.deepEqual(fallback, { exists: true, name: '预备干员-医疗', pointerEvents: 'none', movableId: null });
assert.deepEqual(fallback, { exists: true, name: '预备干员-医疗', pointerEvents: 'auto', movableId: null,
clicked: ['char_605_cmedic', true] });
});
test('fx gallery: every sim fx kind, projectile and status renders without errors; board art in use', async () => {
@@ -2,7 +2,7 @@
// attack at the clip's own speed (faster only when the attacks come quicker than the clip) and then its resting clip —
// Move while the sim walks it again (server/sim/ai.js attackStand stands it for exactly that clip). It used to stretch
// the clip over the whole attack interval and loop it for 1.4 intervals, so a ranged enemy seemed to slide while it
// attacked. Operators keep the clip looping over their attack rhythm. Headless fake PIXI (test/render/fakepixi.js).
// attacked. Operators retain rhythm scaling but stop after each attack (#506). Headless fake PIXI.
import { test, before, after } from 'node:test';
import assert from 'node:assert/strict';
@@ -88,20 +88,69 @@ test('an attack seen late plays the whole clip from its wind-up when the rhythm
quick.attack(2); // quicker than the clip: no room for the wind-up
assert.ok(Math.abs(track(quick).trackTime - 1) < 1e-9, 'from the strike frame');
const op = actor(LIFBOS, false);
op.attack(3); // an operator's looping attack is unchanged
op.attack(3); // late operator events still start at the strike frame
assert.ok(Math.abs(track(op).trackTime - 1) < 1e-9);
});
test('an operator keeps its attack loop stretched over the attack rhythm (unchanged)', () => {
test('an operator keeps rhythm scaling but stops after this attack recovery instead of swinging twice', () => {
const a = actor(JSHOOT, false);
a.attack(2.7);
assert.equal(track(a).loop, true);
assert.equal(track(a).loop, false);
assert.ok(Math.abs(track(a).timeScale - 1 / 2.7) < 1e-9);
run(a, 2);
assert.equal(a.current, 'Attack', 'still in attack mode 2 s later (1.4 × the interval)');
assert.equal(a.current, 'Move', 'no second swing without a second actual attack');
assert.ok(a.windUp(2.7, 0.2)); run(a, 0.2); const prepared = track(a);
a.attack(2.7); assert.equal(track(a), prepared, 'consume the prepared attack without replaying it');
assert.equal(track(a).loop, false); run(a, 2); assert.equal(a.current, 'Move');
});
test('UnitView: enemy models play a clip per attack, operator models do not', async () => {
test('operator attacks with no look-ahead restart once each, including attacks faster than recovery', () => {
const a = actor(JSHOOT, false);
a.attack(0.08); const first = track(a); run(a, 0.08);
a.attack(0.08); assert.notEqual(track(a), first, 'the next real attack starts a new clip');
assert.equal(track(a).loop, false); run(a, 0.4); assert.equal(a.current, 'Move');
a.windUp(0.08, 0.02); run(a, 0.02); a.attack(0.08); run(a, 0.02);
assert.ok(a.windUp(0.08, 0.02), 'the next queued attack can interrupt the preceding recovery');
});
test('Provence: one real attack has one visual clip across slow/fast rhythms; late events keep the strike frame', () => {
const entry = assets.chars.char_145_prove.spine.front;
for (const interval of [0.08, 1.6, 8]) {
const a = new SpineActor(dataOf(entry), entry);
const clip = entry.anims.attack.loop, hit = entry.hits[clip][0];
a.attack(interval);
assert.equal(track(a).loop, false); assert.equal(track(a).trackTime, hit);
run(a, interval + 4); assert.notEqual(a.current, clip, 'no extra swing without an attack');
assert.ok(a.windUp(interval, 0.1)); run(a, 0.1); const prepared = track(a);
a.attack(interval); assert.equal(track(a), prepared); assert.equal(track(a).loop, false);
run(a, interval + 4); assert.notEqual(a.current, clip);
a.destroy();
}
});
test('real Spine timeline: batched wind-up updates retain operator hit-phase correction', async () => {
const { AnimationState, AnimationStateData, SkeletonData, Skeleton, Animation } = await import('@pixi-spine/runtime-3.8');
const FakeSpine = fake.P.spine.Spine;
fake.P.spine.Spine = class extends FakeSpine {
constructor(data) { super(data); this.skeleton = new Skeleton(data); this.stateData = new AnimationStateData(data); this.state = new AnimationState(this.stateData); }
update(dt) { this.state.update(dt); this.state.apply(this.skeleton); }
};
try {
const entry = assets.chars.char_145_prove.spine.front;
for (const dt of [1 / 60, 1 / 15, 0.1, 0.2]) {
const data = new SkeletonData(); data.animations = Object.entries(entry.animations).map(([name, duration]) => new Animation(name, [], duration));
const a = new SpineActor(data, entry);
assert.ok(a.windUp(1.6, 0.2)); run(a, 0.2, dt); const prepared = track(a);
a.attack(1.6);
const hit = entry.hits[entry.anims.attack.loop][0];
assert.ok(Math.abs(hit - track(a).trackTime) / track(a).timeScale <= 0.065, `dt ${dt}: the prepared strike must stay near the real attack`);
assert.equal(track(a), prepared); assert.equal(track(a).loop, false);
run(a, 3); assert.notEqual(a.current, entry.anims.attack.loop); a.destroy();
}
} finally { fake.P.spine.Spine = FakeSpine; }
});
test('UnitView: enemy models retain clipPerAttack speed rules; operators retain rhythm scaling', async () => {
const entry = entryOf(JSHOOT);
const store = { picture: () => null, image: async () => null, spineEntry: () => entry, spine: { acquire: async () => dataOf(entry), release() {} } };
const cam = () => presetCamera('normal', { width: 1280, height: 720 });
+79
View File
@@ -0,0 +1,79 @@
// Damage-number rendering: the default path still pops every hit; official mode shows only a marked red integer
// or a yellow MISS, and does not merge those into the typed running totals.
import { test, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { installFakePixi, fakeViewCtx } from './fakepixi.js';
import { presetCamera } from '../../public/js/render/projection.js';
let fake, FX;
before(async () => {
fake = installFakePixi();
FX = await import('../../public/js/render/fx.js');
});
after(() => fake?.restore());
const cam = presetCamera('normal', { width: 1600, height: 900 });
function unit(id, x, y) {
return { id, x, y, z: 0, hover: 0, _headTiles: 1.2, alive: true, destroyed: false, maxHp: 3000, info: { defId: 'char_x' }, onHit() {} };
}
function makeFx(views) {
const map = new Map(views.map((v) => [v.id, v]));
const settings = { quality: 'high', damageNumbers: true, officialDamageNumbers: false };
const fx = new FX.FxSystem({
...fakeViewCtx(fake.P), settings, cam: () => cam, heightAt: () => 0, timeScale: () => 2,
loadLevel: () => 0, subProfOf: () => null, view: (id) => map.get(id) || null,
screenSize: () => ({ width: 1600, height: 900 }), fieldTop: () => 120,
});
return { fx, settings };
}
test('official mode hides ordinary hits and heals, and draws a marked red number without merging', () => {
const v = unit(1, 4, 9);
const { fx, settings } = makeFx([v]);
fx.damage(v, 40, 'phys', null);
fx.damage(v, 60, 'phys', null);
assert.equal(fx.nums.length, 1, 'default mode still merges same-style hits');
assert.equal(fx.nums[0].style, 'phys');
assert.equal(fx.nums[0].value, 100);
assert.equal(fx.nums[0].text.text, '100');
fx.heal(v, 15);
assert.ok(fx.nums.some((n) => n.style === 'heal' && n.text.text === '+15'));
for (const n of fx.nums.splice(0)) fx._releaseNum(n);
settings.officialDamageNumbers = true;
fx.damage(v, 40, 'phys', null);
fx.heal(v, 15);
assert.equal(fx.nums.length, 0, 'an unmarked hit and a heal draw no digits');
fx.damage(v, 40, 'phys', null, 'r');
fx.damage(v, 25, 'true', null, 'r');
assert.equal(fx.nums.length, 2, 'official reds are separate popups');
assert.deepEqual(fx.nums.map((n) => n.text.text), ['40', '25']);
assert.ok(fx.nums.every((n) => n.style === 'crit'));
fx.damage(v, 0, 'phys', null, 'r');
assert.equal(fx.nums.at(-1).text.text, '0');
assert.equal(fx.nums.at(-1).style, 'crit');
settings.damageNumbers = false;
const held = fx.nums.length;
fx.damage(v, 90, 'phys', null, 'r');
fx.simFx('dodge', v.x, v.y, { id: v.id, num: 'm' });
assert.equal(fx.nums.length, held, 'turning damage numbers off hides official digits too');
});
test('a dodge marked num:m shows MISS only while official numbers are on', () => {
const v = unit(3, 6, 10);
const { fx, settings } = makeFx([v]);
fx.simFx('dodge', v.x, v.y, { id: v.id, num: 'm' });
assert.equal(fx.nums.filter((n) => n.style === 'miss').length, 0);
settings.officialDamageNumbers = true;
fx.simFx('dodge', v.x, v.y, { id: v.id });
assert.equal(fx.nums.filter((n) => n.style === 'miss').length, 0);
fx.simFx('dodge', v.x, v.y, { id: v.id, num: 'm' });
const miss = fx.nums.filter((n) => n.style === 'miss');
assert.equal(miss.length, 1);
assert.equal(miss[0].text.text, 'MISS');
});
+12 -4
View File
@@ -41,6 +41,12 @@ function view(info, opts) {
return new U.UnitView(ctx, { id: 1, side: 'enemy', kind: 'enemy', tier: 1, x: 6, y: 10, maxHp: 1000, motion: 'FLY', ...info });
}
const width = (key) => A.enemies[key].spine.bounds.width;
/**
* How far a model's setup-pose box reaches above its own origin (render/units.js headTilesForUnit): the bar clears the
* visible top, and these fixtures' synthetic box is deliberately not feet-anchored (y −150 of height 390 → 240 units),
* so a bar placed on the raw `height` would float 390/240 ≈ 1.6× too high.
*/
const topUnits = (v) => { const b = v.actor.entry.bounds; return Number.isFinite(b.y) ? b.y + b.height : b.height; };
describe('#9 enemy models at their official prefab scale', () => {
test('data: 威龙 0.16 / 0.27, 妖怪 0.20 / 0.27, 妖怪MKII 0.22 / 0.27; standard-size enemies carry none', () => {
@@ -69,8 +75,10 @@ describe('#9 enemy models at their official prefab scale', () => {
assert.equal(U.enemyModelScale(E.enemy_1005_yokai_3), 0.5926);
assert.equal(U.enemyModelScale(null), 1);
assert.equal(U.enemyModelScale({ modelScale: 'x' }), 1);
// the HP bar sits on its (smaller) model: 390 units × 1/320 × 0.5926 × 0.92, clamped at 0.55
assert.ok(Math.abs(drone._headTiles - Math.max(0.55, 390 * UNIT.modelScale * 0.5926 * 0.92)) < 1e-9);
// the HP bar sits on its (smaller) model, on however far the setup-pose box reaches *above the origin*
// (bounds.y + bounds.height — feet-anchored models have y ≈ 0, 逐腐兽's origin is mid-body), × 1/320 × 0.5926 × 0.92,
// clamped at 0.55
assert.ok(Math.abs(drone._headTiles - Math.max(0.55, topUnits(drone) * UNIT.modelScale * 0.5926 * 0.92)) < 1e-9, `bar ${drone._headTiles}`);
assert.ok(bird._headTiles > drone._headTiles);
});
@@ -118,8 +126,8 @@ describe('official prefab quirks: vertical stretch and mirrored models', () => {
assert.equal(U.enemyModelScaleY(null), 1);
assert.equal(U.enemyModelScaleY({ modelScaleY: 'x' }), 1);
assert.equal(U.enemyModelScaleY({ modelScaleY: 99 }), 1, 'absurd values fall back to uniform');
// the HP bar rides the stretched model: 390 units × 1/320 × 0.7037 × 1.263 × 0.92
assert.ok(Math.abs(drone._headTiles - 390 * UNIT.modelScale * 0.7037 * 1.263 * 0.92) < 1e-9, `bar ${drone._headTiles}`);
// the HP bar rides the stretched model: (bounds.y + bounds.height) × 1/320 × 0.7037 × 1.263 × 0.92
assert.ok(Math.abs(drone._headTiles - topUnits(drone) * UNIT.modelScale * 0.7037 * 1.263 * 0.92) < 1e-9, `bar ${drone._headTiles}`);
assert.ok(drone._headTiles > plain._headTiles);
});
+135
View File
@@ -0,0 +1,135 @@
// Official battlefield scenes in the 0.2.5 asset pack (PR #533, GitHub #532).
// The repository never stores the bundles or the extracted files. The full release zip includes
// public/assets/local/scene when the maintainer extracted it; a lite zip and a source install do not,
// and setup may fetch + extract on that machine. Missing extractor dependencies keep the metal board.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { artPlan, formatSummary, isPlayerFile, isRefused } from '../tools/package.mjs';
import { localGaps, sceneInstallPlan } from '../tools/setup.mjs';
const ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..');
test('sceneInstallPlan: fetch only with a client and extractor deps; otherwise the metal board', () => {
const present = sceneInstallPlan({ hasScenes: true, client: false });
assert.equal(present.run, false);
assert.equal(present.state, 'ok');
assert.match(present.detail, /金属地块/);
const check = sceneInstallPlan({ hasScenes: false, check: true, client: true, python: true });
assert.equal(check.run, false);
assert.equal(check.state, 'skip');
assert.match(check.detail, /--scenes/);
assert.match(check.detail, /金属地块/);
const noClient = sceneInstallPlan({ hasScenes: false, client: false, python: true });
assert.equal(noClient.run, false);
assert.equal(noClient.state, 'skip');
assert.match(noClient.detail, /完整包/);
assert.match(noClient.detail, /金属地块/);
const incomplete = sceneInstallPlan({ hasScenes: false, client: 'incomplete', python: true, force: true });
assert.equal(incomplete.run, false);
assert.equal(incomplete.state, 'warn');
assert.match(incomplete.detail, /金属地块/);
const noPy = sceneInstallPlan({ hasScenes: false, client: true, python: false });
assert.equal(noPy.run, false);
assert.equal(noPy.state, 'warn');
assert.match(noPy.detail, /Python/);
assert.match(noPy.detail, /金属地块/);
const noDeps = sceneInstallPlan({ hasScenes: false, client: true, python: true, deps: false });
assert.equal(noDeps.run, false);
assert.equal(noDeps.state, 'warn');
assert.match(noDeps.detail, /UnityPy/);
assert.match(noDeps.detail, /金属地块/);
const declined = sceneInstallPlan({ hasScenes: false, client: true, python: true, declined: true });
assert.equal(declined.run, false);
assert.equal(declined.state, 'skip');
const ask = sceneInstallPlan({ hasScenes: false, client: true, python: true });
assert.equal(ask.run, false);
assert.equal(ask.state, 'ask');
assert.match(ask.detail, /金属地块/);
for (const extra of [{ force: true, declined: true }, { yes: true }]) {
const go = sceneInstallPlan({ hasScenes: false, client: true, python: true, ...extra });
assert.equal(go.run, true, JSON.stringify(extra));
assert.match(go.detail, /金属地块/);
}
});
test('an older extraction is told it lacks the battlefield scenes', () => {
assert.equal(localGaps({ enemySpines: true, tokenSpines: true }), '');
assert.match(localGaps({ enemySpines: true, tokenSpines: true, scenes: false }), /各战场官方场景/);
assert.match(localGaps({ enemySpines: false, tokenSpines: false, scenes: false }), /各战场官方场景/);
});
test('official scene bundles and extracted scenes are not git files; the extractor scripts are', () => {
const gi = fs.readFileSync(path.join(ROOT, '.gitignore'), 'utf8');
assert.match(gi, /^public\/assets\/$/m);
assert.match(gi, /^data\/local-assets\.json$/m);
assert.match(gi, /^\.cache\/$/m);
assert.match(gi, /official-scenes/);
assert.equal(isRefused('.cache/official-scenes/26-04-13-12-31-42_e9b560/scenes/a.ab'), true);
assert.equal(isRefused('public/assets/local/scene/act2autochess_m02/scene.bin'), false);
assert.equal(isPlayerFile('public/assets/local/scene/act2autochess_m02/scene.bin'), false);
assert.equal(isPlayerFile('tools/local-extract/scenes.py'), true);
assert.equal(isPlayerFile('tools/local-extract/fetch_scenes.py'), true);
});
test('the full asset plan ships extracted scenes; the lite plan ships none', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sp-scenes-'));
try {
const sceneDir = path.join(dir, 'public/assets/local/scene/act2autochess_m02');
const texDir = path.join(dir, 'public/assets/local/scene/tex');
fs.mkdirSync(sceneDir, { recursive: true });
fs.mkdirSync(texDir, { recursive: true });
fs.mkdirSync(path.join(dir, 'data'), { recursive: true });
fs.writeFileSync(path.join(dir, 'data/assets.json'), '{}\n');
fs.writeFileSync(path.join(sceneDir, 'scene.bin'), 'mesh-bytes');
fs.writeFileSync(path.join(sceneDir, 'scene.json'), '{}\n');
fs.writeFileSync(path.join(texDir, 'TX_A.webp'), 'webp');
fs.writeFileSync(path.join(dir, 'data/local-assets.json'), JSON.stringify({
version: 1,
groups: {
'scene/act2autochess_m02': {
scene: { path: '/assets/local/scene/act2autochess_m02/scene.json' },
bin: { path: '/assets/local/scene/act2autochess_m02/scene.bin' },
},
'scene/tex': { TX_A: { path: '/assets/local/scene/tex/TX_A.webp' } },
},
}));
const full = artPlan(dir, { lite: false });
for (const f of [
'public/assets/local/scene/act2autochess_m02/scene.bin',
'public/assets/local/scene/act2autochess_m02/scene.json',
'public/assets/local/scene/tex/TX_A.webp',
'data/local-assets.json',
]) assert.ok(full.files.includes(f), f);
assert.ok(full.scenes.includes('public/assets/local/scene/act2autochess_m02/scene.bin'));
assert.equal(full.scenes.length, 3);
const lite = artPlan(dir, { lite: true });
assert.deepEqual(lite.files, []);
assert.deepEqual(lite.scenes, []);
const summary = formatSummary({
lite: false, version: '0.2.5-dev', tracked: [], art: [], localArt: true, generated: [], dropped: {}, orphans: [], held: [],
scanned: 0, names: 0, problems: [], scenes: { files: full.scenes, bytes: 9 + 3 + 4 },
bytes: { tracked: 0, art: 16, modules: 0, vendor: 0, total: 16, leftOut: 0, orphans: 0 },
});
assert.match(summary, /battlefield scenes: 3 files/);
const empty = formatSummary({
lite: false, version: '0.2.5-dev', tracked: [], art: [], localArt: true, generated: [], dropped: {}, orphans: [], held: [],
scanned: 0, names: 0, problems: [], scenes: { files: [], bytes: 0 },
bytes: { tracked: 0, art: 0, modules: 0, vendor: 0, total: 0, leftOut: 0, orphans: 0 },
});
assert.match(empty, /battlefield scenes: none/);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
+2
View File
@@ -61,6 +61,8 @@ test('special values follow ECMA-262', () => {
for (const [y, x, want] of table) same(atan2(y, x), want, `atan2(${y}, ${x})`);
same(powi(2, 0), 1, 'x^0'); same(powi(0, 0), 1, '0^0'); same(powi(0, 3), 0, '0^3'); same(powi(-2, 3), -8, '(-2)^3');
same(powi(2, -2), 0.25, 'x^-n'); same(powi(Infinity, 2), Infinity, '∞²'); same(powi(NaN, 0), 1, 'NaN^0');
// a zero base: Math.pow's exact results, the sign of −0 kept by odd powers
for (const z of [0, -0]) for (let n = -3; n <= 3; n++) same(powi(z, n), Math.pow(z, n), `(${Object.is(z, -0) ? '-0' : '0'})^${n}`);
});
// exact references: x = M·2^E with M an integer
+11 -2
View File
@@ -177,8 +177,8 @@ for (const kind of ['boss', 'hidden']) for (const side of ['L', 'R']) {
});
}
test('#475: even buildable hand, temporary-storage, side-margin and off-rect tiles remain invalid Raid landings', () => {
for (const pos of [[0, 3], [1, 3], [0, 17], [1, 17], [3, 1], [3, 19], [6, 17]]) {
test('#475: even buildable hand, temporary-storage and side-margin tiles remain invalid Raid landings', () => {
for (const pos of [[0, 3], [1, 3], [0, 17], [1, 17], [3, 1], [3, 19]]) {
const h = emptyHalfLanding({ pos, ranged: true });
const u = h.unit('probe'), home = [u.tileR, u.tileC];
h.run(11);
@@ -187,6 +187,15 @@ test('#475: even buildable hand, temporary-storage, side-margin and off-rect til
}
});
test('#475: no Raid lands off the rectangle — an enemy spawned on (6,17) is held on the edge row and stands on (5,17)', () => {
// Battle._clampPos keeps an enemy FIELD_EDGE_INSET inside the field, so its tile is the edge tile (body.js).
const h = emptyHalfLanding({ pos: [6, 17], ranged: true });
const u = h.unit('probe');
h.run(11);
assert.deepEqual([u.tileR, u.tileC], [5, 17]);
checkInvariants(h.b);
});
test('#475: empty-half Raid still respects melee/high-ground, water and occupied/downed-unit reservations', () => {
for (const glyph of ['h', 'd']) {
const h = emptyHalfLanding({ pos: [3, 17], glyph });
+334
View File
@@ -0,0 +1,334 @@
// Unreleased funnel drones add damage to the caster's attack (PRTS 分支特性信息 驭械术师).
// Controlled Battle inputs; the production Match / browser reproduction is recorded separately.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, chessRec, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
import { hitCount } from '../../server/sim/content/enemies/helpers.js';
import { bodyInKeys } from '../../server/sim/body.js';
import { DRONE_TAG } from '../../server/sim/content/kits/ops/chess_char_3_20-kjera.js';
const near = (a, b, label) => assert.ok(Math.abs(a - b) < 1e-6, `${label}: ${a} vs ${b}`);
const ordinary = ['2_10', '3_13', '3_20', '4_03', '6_18'].flatMap((id) => ['a', 'b'].map((f) => ({ chessId: `chess_char_${id}_${f}` })));
const glow = [5, 6].flatMap((slot) => [false, true].flatMap((elite) => (elite ? [null, 'uniequip_002_gdglow', 'uniequip_003_gdglow'] : [null]).map((uniEquipId) => ({
diy: { slot, charId: 'char_377_gdglow', skillIndex: 2, uniEquipId }, elite,
}))));
function field(unit, enemy = {}, opts = {}) {
const h = makeBattle({
seed: 3, autoFinish: false, timeLimit: 120, captureNoisy: true, hooks: ['damaged', 'attack'],
defs: { enemies: { enemy_dummy: enemyRec({ key: 'enemy_dummy', hp: 1e8, speed: 0, mass: 0, ...enemy }) } },
units: [{ uid: 1, row: 10, col: 4, ...unit }], ...opts,
});
h.step();
return { h, u: h.unit(1), e: h.spawn('enemy_dummy', { pos: [10, 6] }) };
}
const mine = (h, u) => h.hooksOf('damaged').filter((c) => c.source === u);
const volleys = (h, u) => {
const out = new Map();
for (const c of mine(h, u)) {
const key = c.dmg.attackId;
if (key > 0) { const l = out.get(key) ?? []; l.push(c); out.set(key, l); }
}
return [...out.values()];
};
function done(h) { checkInvariants(h.b); assert.deepEqual(h.b.errors, []); }
test('all normal/elite funnel casters and eight Goldenglow forms: a body hit plus a separate ramped addition', () => {
for (const unit of [...ordinary, ...glow]) {
const { h, u, e } = field(unit);
h.run(2);
const vs = volleys(h, u);
assert.ok(vs.length >= 2, `${u.defId}: two attacks`);
for (const [i, v] of vs.slice(0, 2).entries()) {
assert.equal(v.length, 2, `${u.defId}: body and drone, attack ${i + 1}`);
const [body, drone] = v;
assert.ok(body.dmg.isAttack && !body.dmg.tags.includes('addition'));
assert.ok(!drone.dmg.isAttack && drone.dmg.tags.includes('addition') && drone.dmg.tags.includes('funnel'));
const crit = u.def.charId === 'char_4054_malist' ? u.def.talents[0].bb.atk_scale : 1;
assert.ok([u.s.atk, u.s.atk * crit].some((n) => Math.abs(body.amount - n) < 1e-6));
near(drone.amount / body.amount, u.profile.funnel.init + i * u.profile.funnel.delta, 'independent drone ramp');
}
near(1e8 - e.hp, mine(h, u).reduce((sum, c) => sum + c.amount, 0), 'actual HP agrees with all damage events');
done(h);
}
});
test('Lolo ATK 283: each component passes through RES, hit-count HP and a one-hit barrier separately', () => {
for (const mode of ['plain', 'res', 'times', 'barrier']) {
const { h, u, e } = field({ chessId: 'chess_char_2_10_a' }, { res: mode === 'res' ? 50 : 0, hp: mode === 'times' ? 100 : 1e8 });
near(u.s.atk, 283, 'unbuffed Lolo');
if (mode === 'times') hitCount(h.b, e, true);
if (mode === 'barrier') h.b.addBuff(e, { key: 'test:barrier', shieldHits: 1 });
h.run(0.7);
const loss = (mode === 'times' ? 100 : 1e8) - e.hp;
near(loss, { plain: 339.6, res: 169.8, times: 2, barrier: 56.6 }[mode], mode);
assert.equal(h.hooksOf('attack').filter((c) => c.attacker === u).length, 1, 'one attack action');
done(h);
}
});
test('unreleased attribute skills keep a body hit; released Kjera drones and Lolo S2 do not gain one', () => {
// S1 (skillIndex 0) keeps the unreleased pair. S2 (skillIndex 1) releases the drone; rockrock-s2-lock covers it.
for (const id of ['chess_char_2_10_a', 'chess_char_2_10_b']) {
const { h, u } = field({ chessId: id, skillIndex: 0 });
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 2));
const start = mine(h, u).length;
h.run(2);
const active = mine(h, u).slice(start);
assert.ok(active.some((c) => c.dmg.isAttack && !c.dmg.tags.includes('addition')), `${id} S1: body`);
assert.ok(active.some((c) => c.dmg.tags.includes('funnel')), `${id} S1: drone`);
for (const v of volleys(h, u)) assert.equal(v.length, 2, `${id}: one pair per attack`);
done(h);
}
for (const id of ['chess_char_3_20_a', 'chess_char_3_20_b', 'chess_char_4_03_a', 'chess_char_4_03_b']) {
const { h, u } = field({ chessId: id });
h.run(2);
const before = mine(h, u).filter((c) => c.dmg.tags?.includes('funnel'));
assert.ok(before.length >= 1, `${id}: a pre-skill drone hit`);
const preScale = before.at(-1).amount / u.s.atk;
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 5));
const t0 = h.b.time;
h.run(2);
const full = u.s.atk * u.s.atkScaleMul;
const later = mine(h, u).filter((c) => c.t + 1e-9 >= t0 - h.b.dt);
const body = later.filter((c) => c.dmg.isAttack && !c.dmg.isSkill && !(c.dmg.tags || []).includes('funnel'));
const drones = later.filter((c) => (c.dmg.tags || []).includes(DRONE_TAG));
assert.ok(body.length > 0, `${id}: the operator's own hit`);
assert.ok(drones.length >= 2, `${id}: both released drones`);
for (const c of body) {
near(c.amount, full, `${id} body is 100% ATK`);
assert.ok(!(c.dmg.tags || []).includes('addition'));
}
for (const c of drones) {
assert.ok(c.dmg.isSkill && !c.dmg.isAttack && c.type === 'arts', `${id}: drone skill hit`);
assert.ok(!(c.dmg.tags || []).includes('addition') && !(c.dmg.tags || []).includes('funnel'));
const r = c.amount / full;
assert.ok(r + 1e-6 >= u.profile.funnel.init && r <= u.profile.funnel.max + 1e-6, `${id} drone scale ${r}`);
}
const first = drones[0].amount / full;
if (id.startsWith('chess_char_4_03')) near(first, u.profile.funnel.init, `${id} restarts at the initial scale`);
else assert.ok(first > u.profile.funnel.init + 1e-6 && first + 1e-6 >= Math.min(u.profile.funnel.max, preScale + u.profile.funnel.delta) - 1e-6,
`${id} continues the pre-skill ramp (${first} after ${preScale})`);
done(h);
}
});
test('Whitw2 S1 passive and head-wolf stage 3: only the drone count grows', () => {
for (const id of ['chess_char_6_18_a', 'chess_char_6_18_b']) {
const { h, u } = field({ chessId: id, skillIndex: 0 });
h.run(0.7);
assert.equal(volleys(h, u)[0].filter((c) => c.dmg.isAttack).length, 1);
assert.equal(volleys(h, u)[0].filter((c) => c.dmg.tags.includes('funnel')).length, 2);
// Controlled input keeps S1 off while its deployment-time talent progresses.
h.b.addBuff(u, { key: 'test:no-skill', flags: { silence: true } });
h.run(65);
assert.equal(u.mem.wolfStage, 3);
const last = volleys(h, u).at(-1);
assert.equal(last.filter((c) => c.dmg.isAttack).length, 1);
assert.equal(last.filter((c) => c.dmg.tags.includes('funnel')).length, 3);
done(h);
}
});
test('Minimalist charged S2: two body hits and two drone additions share one talent roll and one attack action', () => {
for (const id of ['chess_char_3_13_a', 'chess_char_3_13_b']) {
const { h, u } = field({ chessId: id });
u.skill.gainSp(999);
h.run(2);
const vs = volleys(h, u).filter((v) => v[0].dmg.isSkill);
assert.ok(vs.length > 0);
for (const v of vs) {
assert.equal(v.length, 4);
const body = v.filter((c) => c.dmg.isAttack), drone = v.filter((c) => c.dmg.tags.includes('funnel'));
assert.equal(body.length, 2); assert.equal(drone.length, 2);
near(body[0].amount, body[1].amount, 'same talent roll on both body hits');
near(drone[0].amount, drone[1].amount, 'same ramp and talent roll on both drone hits');
assert.ok(drone[0].amount / body[0].amount <= u.profile.funnel.max + 1e-6);
}
done(h);
}
});
test('Whitw2 S1 on: the toggle keeps one body hit and both unreleased additions', () => {
for (const id of ['chess_char_6_18_a', 'chess_char_6_18_b']) {
const { h, u } = field({ chessId: id, skillIndex: 0 });
assert.ok(u.skill.activate('test', { free: true }));
const t0 = h.b.time;
h.run(2);
const vs = volleys(h, u).filter((v) => v[0].t + 1e-9 >= t0);
assert.ok(vs.length >= 1, `${id}: attacks while S1 is on`);
for (const v of vs) {
const body = v.filter((c) => c.dmg.isAttack && !(c.dmg.tags || []).includes('addition'));
const drone = v.filter((c) => (c.dmg.tags || []).includes('funnel'));
assert.equal(body.length, 1, `${id}: the 100% body is not dropped`);
assert.equal(drone.length, 2, `${id}: both unreleased drones`);
near(body[0].amount, u.s.atk * u.s.atkScaleMul, `${id} body`);
for (const c of drone) {
assert.ok(!c.dmg.isAttack);
const r = c.amount / body[0].amount;
assert.ok(r + 1e-6 >= u.profile.funnel.init && r <= u.profile.funnel.max + 1e-6, `${id} ramp ${r}`);
}
}
done(h);
}
});
test('Whitw2 S2 stays drone-only: no full-ATK body beside the ramp', () => {
for (const id of ['chess_char_6_18_a', 'chess_char_6_18_b']) {
const { h, u } = field({ chessId: id, skillIndex: 1 });
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 8));
const t0 = h.b.time;
h.run(2);
const full = u.s.atk * u.s.atkScaleMul;
const active = mine(h, u).filter((c) => c.t + 1e-9 >= t0 - h.b.dt && c.dmg.isSkill);
assert.ok(active.length > 0, `${id}: skill hits`);
assert.ok(active.every((c) => c.dmg.isAttack && !(c.dmg.tags || []).includes('funnel')), `${id}: ramp stays on the attack`);
assert.equal(active.filter((c) => Math.abs(c.amount - full) < 1e-4).length, 0, `${id}: no 100% body`);
done(h);
}
});
test('Kjera S2: a lock that leaves her range still takes drone hits, and the body does not follow it', () => {
const route = {
motion: 'FLY', start: [10, 6], end: [9, 2],
steps: [{ t: 'wait', s: 1.2 }, { t: 'move', p: [12, 6] }, { t: 'wait', s: 20 }, { t: 'move', p: [9, 2] }],
};
for (const id of ['chess_char_3_20_a', 'chess_char_4_03_a']) {
const h = makeBattle({
seed: 4, autoFinish: false, timeLimit: 40, captureNoisy: true, hooks: ['damaged', 'attack'],
defs: { enemies: { enemy_lock: enemyRec({ key: 'enemy_lock', hp: 1e8, speed: 1, motion: 'FLY' }) } },
units: [{ uid: 1, row: 10, col: 4, chessId: id, carryState: { sp: 999 } }],
enemies: [{ key: 'enemy_lock', route }],
});
h.step();
const u = h.unit(1), e = h.enemies()[0];
assert.ok(u.skill.active, `${id} opens on the carried SP`);
assert.ok(h.runUntil(() => e.alive && !bodyInKeys(e, u.rangeKeySet), 8), `${id}: the lock leaves range`);
h.run(1.5);
const t = h.b.time, attacks = u.stats.attacks;
h.run(3);
assert.ok(e.alive && u.skill.active && !bodyInKeys(e, u.rangeKeySet));
const out = mine(h, u).filter((c) => c.target === e && c.t + 1e-9 >= t);
assert.ok(out.length > 0, `${id}: damage outside range`);
assert.ok(out.every((c) => (c.dmg.tags || []).includes(DRONE_TAG) && c.dmg.isSkill && !c.dmg.isAttack), `${id}: drone only`);
assert.equal(u.stats.attacks, attacks, `${id}: the body does not attack with nobody in range`);
done(h);
}
});
test('one attached volley: attack-SP, attack equipment and afterHit fire once while two damage instances share attackId', () => {
const h = makeBattle({
content: 'full', autoFinish: false, captureNoisy: true, hooks: ['damaged', 'attack'],
defs: {
chess: { test_funnel: chessRec({ id: 'test_funnel', profession: 'CASTER', subProf: 'funnel',
rangeGrid: [[0, 0], [0, 1], [0, 2]], stats: { atk: 100, bat: 2 },
skill: { spType: 'INCREASE_WHEN_ATTACK', spCost: 100, initSp: 0 } }) },
enemies: { enemy_dummy: enemyRec({ key: 'enemy_dummy', hp: 10000, speed: 0, mass: 0 }) },
},
units: [{ uid: 1, chessId: 'test_funnel', row: 10, col: 4, items: ['chess_item_4_03_e_a'] }],
});
h.step();
const u = h.unit(1), e = h.spawn('enemy_dummy', { pos: [10, 6] });
const callbacks = [];
// Read-only callback observer on the controlled profile; no damage or attack is added by it.
u.profile.afterHit = (b, source, target, ctx) => callbacks.push({ source, target, dealt: ctx.dealt });
h.run(0.7);
const hits = mine(h, u);
assert.equal(hits.length, 2);
assert.equal(hits[0].dmg.attackId, hits[1].dmg.attackId);
assert.equal(h.hooksOf('attack').length, 1);
assert.equal(u.stats.attacks, 1);
assert.equal(u.skill.spType, 'attack');
near(u.skill.spTotal, 1, 'one attack-SP gain');
assert.equal(u.s.aspd, u.base.aspd + 1, 'one accelerator stack');
assert.equal(callbacks.length, 1);
near(callbacks[0].dealt, 120, 'afterHit receives the combined actual HP damage');
near(10000 - e.hp, 120, 'body and addition HP damage');
done(h);
});
test('damage equipment: Pegasus riders stay bounded and burn sees both arts components without recursive attack events', () => {
for (const items of [['chess_item_6_01_e_a', 'chess_item_5_09_e_a'], ['chess_item_4_09_e_a']]) {
const { h, u, e } = field({ chessId: 'chess_char_2_10_a', items });
h.run(0.7);
const main = mine(h, u).filter((c) => c.dmg.attackId > 0);
assert.equal(main.length, 2, 'body plus drone');
assert.equal(main[0].type, 'arts'); assert.equal(main[1].type, 'arts');
assert.equal(h.hooksOf('attack').length, 1, 'riders are not extra attack actions');
if (items.length === 2) {
const riders = mine(h, u).filter((c) => c.dmg.tags.includes('item:pegasus'));
assert.equal(riders.length, 2, 'one rider per component; no recursion');
assert.ok(riders.every((c) => c.type === 'true' && !c.dmg.isAttack));
near(1e8 - e.hp, mine(h, u).reduce((sum, c) => sum + c.amount, 0), 'all bounded damage agrees with HP');
} else {
near(e.elem.burn, 0.1 * main.reduce((sum, c) => sum + c.amount, 0), 'burn from body and drone arts damage');
}
done(h);
}
});
test('lethal body or addition: one death and no remaining instances redirected to another target', () => {
for (const hp of [200, 320]) {
const { h, u, e } = field({ chessId: 'chess_char_2_10_a' }, { hp }, { hooks: ['damaged', 'attack', 'death', 'kill'] });
const other = h.spawn('enemy_dummy', { pos: [11, 6] });
h.run(0.7);
assert.equal(e.alive, false);
const hits = mine(h, u);
assert.equal(hits.length, hp === 200 ? 1 : 2);
assert.ok(hits.every((c) => c.target === e));
near(hits.reduce((sum, c) => sum + c.amount, 0), hp === 200 ? 283 : 339.6, 'mitigated amounts before HP clamping');
near(u.stats.dmg, hp, 'damage statistics count only HP actually removed');
near(other.hp, hp, 'no transfer to a spare target');
assert.equal(h.hooksOf('death').filter((c) => c.unit === e).length, 1);
assert.equal(h.hooksOf('kill').filter((c) => c.victim === e && c.killer === u).length, 1);
done(h);
}
});
test('a target killed during projectile flight receives neither the body nor drone on impact', () => {
const { h, u, e } = field({ chessId: 'chess_char_2_10_a' });
assert.ok(h.runUntil(() => h.hooksOf('attack').length === 1, 1));
assert.equal(mine(h, u).length, 0, 'projectile is still flying');
h.b.kill(e, null);
h.run(0.7);
assert.equal(mine(h, u).length, 0);
done(h);
});
test('Minimalist S2: one charge and one talent roll for all four arts instances', () => {
for (const id of ['chess_char_3_13_a', 'chess_char_3_13_b']) {
const { h, u } = field({ chessId: id });
const original = h.b.rng.chance;
let talentRolls = 0;
h.b.rng.chance = (p) => { talentRolls++; return original(p); };
u.skill.gainSp(999);
h.run(0.7);
const hits = mine(h, u);
assert.equal(hits.length, 4);
assert.ok(hits.every((c) => c.type === 'arts' && c.dmg.isSkill));
assert.equal(new Set(hits.map((c) => c.dmg.attackId)).size, 1);
assert.equal(u.skill.activations, 1);
assert.equal(u.skill.charges, u.skill.maxCharges - 1);
assert.equal(talentRolls, 1);
assert.equal(h.hooksOf('attack').length, 1);
done(h);
}
});
test('Siracusa rolls once for each body, while two attached additions and its own riders do not roll again', () => {
const { h, u } = field({ chessId: 'chess_char_6_18_a', skillIndex: 0 }, {}, {
bonds: { siracusaShip: { count: 6, active: true, tier: 2, layers: 0 } },
});
const original = h.b.rng;
let rolls = 0;
h.b.rng = Object.assign(() => { rolls++; return original(); }, original);
h.run(0.7);
const attacks = h.hooksOf('attack').filter((c) => c.attacker === u).length;
assert.ok(attacks > 0);
assert.equal(mine(h, u).filter((c) => c.dmg.tags.includes('funnel')).length, attacks * 2);
assert.equal(rolls, attacks, 'one ordinary-damage roll, no additional/rider recursion');
done(h);
});
+158
View File
@@ -0,0 +1,158 @@
// PRTS 分支特性信息 驭械术师: changing targets or redeploying resets the drone ramp.
// Controlled battle inputs here; the real Match route is covered in test/match/funnel-redeploy.test.js.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
const make = (id = 'chess_char_2_10_a', extra = []) => makeBattle({
units: [{ chessId: id, row: 10, col: 4 }, ...extra],
defs: { enemies: { dummy: enemyRec({ key: 'dummy', hp: 1e7, speed: 0 }) } },
enemies: [{ key: 'dummy', pos: [10, 6] }], timeLimit: 300, autoFinish: false,
hooks: ['damaged'], captureNoisy: true,
});
const hits = (h, u) => h.hooksOf('damaged').filter((c) => c.source === u && c.target.side === 'enemy');
const steps = (h, u) => hits(h, u).filter((c) => c.dmg?.tags?.includes('funnel'));
const near = (a, b) => assert.ok(Math.abs(a - b) < 1e-8, `${a} != ${b}`);
const ramp = (h, u) => {
h.runUntil(() => steps(h, u).length >= 8, 30);
assert.ok(steps(h, u).length >= 8);
};
for (const id of ['chess_char_2_10_a', 'chess_char_2_10_b', 'chess_char_4_03_a', 'chess_char_4_03_b']) {
test(`${id}: same living target starts at the configured initial scale after automatic redeployment`, () => {
const h = make(id), u = h.unit(id);
ramp(h, u);
const e = h.enemies()[0];
assert.ok(u.trait.funnelScale > u.profile.funnel.init);
const before = hits(h, u).length;
h.b.dealDamage(e, u, { amount: 1e7, type: 'true' });
assert.equal(u.alive, false);
h.runUntil(() => u.alive, 100);
assert.equal(u.alive, true);
assert.equal(e.alive, true);
assert.equal(u.trait.funnelTarget, null);
near(u.trait.funnelScale, u.profile.funnel.init);
assert.equal(h.runUntil(() => hits(h, u).length > before, 5), true);
assert.ok(hits(h, u).length > before);
assert.equal(hits(h, u).at(-1).target, e);
near(u.trait.funnelScale, u.profile.funnel.init);
checkInvariants(h.b);
assert.deepEqual(h.b.errors, []);
});
}
for (const id of ['chess_char_3_13_a', 'chess_char_3_13_b', 'chess_char_3_20_a', 'chess_char_3_20_b']) {
test(`${id}: per-target funnel map restarts on redeployment`, () => {
const h = make(id), u = h.unit(id);
ramp(h, u);
const e = h.enemies()[0];
assert.ok(u.trait.funnelMap.get(e.id).s > u.profile.funnel.init);
h.b.retreat(u);
assert.equal(h.b.redeploy(u), true);
assert.equal(u.trait.funnelMap.size, 0);
const before = hits(h, u).length;
assert.equal(h.runUntil(() => hits(h, u).length > before, 5), true);
assert.ok(hits(h, u).length > before);
near(u.trait.funnelMap.get(e.id).s, u.profile.funnel.init);
checkInvariants(h.b);
});
}
test('continuous attacks ramp and cap; changing the living target resets normally', () => {
const h = make(), u = h.unit('chess_char_2_10_a');
ramp(h, u);
near(u.trait.funnelScale, u.profile.funnel.max);
const replacement = h.spawn('dummy', { pos: [10, 5] });
const before = hits(h, u).length;
assert.equal(h.runUntil(() => hits(h, u).length > before, 5), true);
assert.ok(hits(h, u).length > before);
assert.equal(hits(h, u).at(-1).target, replacement);
near(u.trait.funnelScale, u.profile.funnel.init);
});
test('another unit deployment and a free move keep this ramp; unrelated trait state survives redeployment', () => {
const h = make('chess_char_2_10_a', [{ chessId: 'chess_char_1_02_a', row: 12, col: 3 }]);
const u = h.unit('chess_char_2_10_a'), other = h.unit('chess_char_1_02_a');
ramp(h, u);
const scale = u.trait.funnelScale, target = u.trait.funnelTarget;
u.trait.testUnrelated = { value: 7 }; // controlled sentinel: no production trait object may be replaced
const sentinel = u.trait.testUnrelated;
h.b.retreat(other);
assert.equal(h.b.redeploy(other), true);
assert.equal(u.trait.funnelTarget, target);
near(u.trait.funnelScale, scale);
assert.equal(h.b.moveRedeploy(u, 10, 3), true);
assert.equal(u.trait.funnelTarget, target);
near(u.trait.funnelScale, scale);
h.b.retreat(u);
assert.equal(h.b.redeploy(u), true);
assert.equal(u.trait.testUnrelated, sentinel);
near(u.trait.funnelScale, u.profile.funnel.init);
});
test('an old in-flight drone can advance the reset ramp before the first fresh drone lands', (t) => {
for (const drainOld of [false, true]) {
const h = make(), u = h.unit('chess_char_2_10_a');
ramp(h, u);
const e = h.enemies()[0], launches = new Map(), impacts = [], events = [];
const state = () => ({ at: h.b.time, currentSeq: u.deploySeq, scale: u.trait.funnelScale, hp: e.hp });
h.b.on('attack', (c) => {
if (c.attacker !== u) return;
const shot = { kind: 'launch', attackId: h.b._attackSeq, ...state() };
launches.set(shot.attackId, shot);
events.push(shot);
});
h.b.on('deploy', (c) => {
if (c.unit === u) events.push({ kind: 'deploy', target: u.trait.funnelTarget, ...state() });
}, { priority: -1000 });
h.b.on('damaged', (c) => {
if (c.source !== u || c.target !== e || !(c.dmg?.attackId > 0)) return;
const launch = launches.get(c.dmg.attackId);
const hit = { kind: 'impact', attackId: c.dmg.attackId, launchAt: launch?.at,
launchSeq: launch?.currentSeq, ...state(), amount: c.amount, hpBefore: e.hp + c.amount,
addition: !!c.dmg.tags?.includes('funnel'), isAttack: !!c.dmg.isAttack };
impacts.push(hit);
events.push(hit);
});
const volleysLanded = () => new Set(impacts.map((i) => i.attackId)).size;
assert.equal(h.runUntil(() => launches.size === 1, 3), true);
const oldShot = [...launches.values()][0];
assert.equal(impacts.length, 0);
assert.ok(h.b.projectiles.list.some((p) => p.source === u && p.target === e));
if (drainOld) assert.equal(h.runUntil(() => volleysLanded() >= 1, 1), true);
h.b.retreat(u);
assert.equal(h.b.redeploy(u), true);
const seq = u.deploySeq;
assert.equal(h.runUntil(() => impacts.some((i) => i.launchSeq === seq), 2), true);
t.diagnostic(JSON.stringify({ drainOld, events }));
const deployment = events.find((i) => i.kind === 'deploy');
assert.equal(deployment.target, null);
near(deployment.scale, u.profile.funnel.init);
const of = (id) => impacts.filter((i) => i.attackId === id);
const oldParts = of(oldShot.attackId);
const fresh = impacts.find((i) => i.launchSeq === seq);
const freshParts = of(fresh.attackId);
assert.equal(oldParts[0].launchSeq, oldShot.currentSeq);
assert.notEqual(oldParts[0].launchSeq, fresh.launchSeq);
assert.ok(oldParts.at(-1).at <= fresh.at);
assert.equal(oldParts[0].currentSeq, drainOld ? oldShot.currentSeq : seq);
near(oldParts[0].scale, drainOld ? u.profile.funnel.max : u.profile.funnel.init);
near(fresh.scale, u.profile.funnel.init + (drainOld ? 0 : u.profile.funnel.delta));
for (const parts of [oldParts, freshParts]) {
assert.equal(parts.filter((p) => p.isAttack).length, 1);
assert.equal(parts.filter((p) => p.addition).length, 1);
const body = parts.find((p) => p.isAttack);
const add = parts.find((p) => p.addition);
near(body.amount, u.s.atk * u.s.atkScaleMul);
near(add.amount, body.amount * body.scale);
}
const oldSum = oldParts.reduce((s, p) => s + p.amount, 0);
const freshSum = freshParts.reduce((s, p) => s + p.amount, 0);
near(oldShot.hp - oldParts.at(-1).hp, oldSum);
near(oldParts.at(-1).hp, freshParts[0].hpBefore);
near(freshParts[0].hpBefore - freshParts.at(-1).hp, freshSum);
assert.ok(freshParts.at(-1).hp > 0);
checkInvariants(h.b);
assert.deepEqual(h.b.errors, []);
}
});
+53
View File
@@ -0,0 +1,53 @@
// The tile of a target is its position rounded half to even — PRTS 作战机制 「受击方坐标经round函数取整(四舍六入五成双)
// 得到其所属格坐标,按是否属于该格判定」 — not Math.round, which rounds every tie up: an enemy exactly halfway between
// columns 10 and 11 (x = 10.5) is on column 10, one at x = 11.5 on column 12.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, chessRec, enemyRec } from '../helpers/battleHarness.js';
import { roundHalfEven } from '../../server/sim/detmath.js';
import { bodyKeys, bodyOnTile, bodyTileReach } from '../../server/sim/body.js';
import { tileKeyOf } from '../../server/sim/targeting.js';
import { COLS } from '../../server/sim/constants.js';
test('roundHalfEven: ties to the even integer, the rest to the nearest; never −0', () => {
const cases = [[0.5, 0], [1.5, 2], [2.5, 2], [3.5, 4], [10.5, 10], [11.5, 12], [-0.5, 0], [-1.5, -2], [-2.5, -2],
[2.4999999999999996, 2], [2.5000000000000004, 3], [-0.4, 0], [-0, 0], [7, 7], [4503599627370495.5, 4503599627370496]];
for (const [x, want] of cases) assert.ok(Object.is(roundHalfEven(x), want), `${x} → ${roundHalfEven(x)}, want ${want}`);
for (let i = -2000; i <= 2000; i++) {
const x = i / 8, v = roundHalfEven(x), d = Math.abs(x - v);
assert.ok(Number.isInteger(v) && (d < 0.5 || (d === 0.5 && v % 2 === 0)), `${x} → ${v}`);
if (d !== 0.5) assert.equal(v, Math.round(x) + 0, `${x}: away from a tie it is Math.round`);
}
});
test('a point target exactly halfway is on the even tile: bodyKeys, bodyOnTile, bodyTileReach, tileKeyOf', () => {
const e = { x: 10.5, y: 3.5 };
assert.deepEqual(bodyKeys(e), [4 * COLS + 10]);
assert.ok(bodyOnTile(e, 4, 10) && !bodyOnTile(e, 4, 11) && !bodyOnTile(e, 3, 10));
assert.equal(bodyTileReach(e, 4, 10), 0);
assert.equal(bodyTileReach(e, 4, 12), 2);
assert.equal(tileKeyOf(e), 4 * COLS + 10);
assert.equal(tileKeyOf({ x: 11.5, y: 2.5 }), 2 * COLS + 12);
});
test('the battle\'s enemy index puts an enemy at x = 10.5 on column 10, not 11', () => {
const h = makeBattle({
kind: 'boss', content: 'none', autoFinish: false, timeLimit: 60,
defs: {
chess: { t_one: chessRec({ id: 't_one', stats: { atk: 100, blockCnt: 0, maxHp: 1e7 }, rangeGrid: [[0, 0]], skill: null }) },
enemies: { enemy_still: enemyRec({ key: 'enemy_still', hp: 1e8, speed: 0 }) },
},
players: [{ playerId: 'p1', seat: 0, side: 'L', coords: 'field', units: [{ uid: 1, kind: 'chess', chessId: 't_one', row: 1, col: 1 }], bonds: {} }],
});
h.step();
const e = h.spawn('enemy_still', { pos: [3, 10], routeIndex: 0 });
e.x = 10.5;
e.y = 3;
h.step();
e.x = 10.5;
e.y = 3;
const u = h.unit('t_one');
assert.deepEqual(h.b.enemiesInKeys([3 * COLS + 10], u, { canHitFly: true }).map((x) => x.id), [e.id]);
assert.deepEqual(h.b.enemiesInKeys([3 * COLS + 11], u, { canHitFly: true }).map((x) => x.id), []);
});
+83
View File
@@ -0,0 +1,83 @@
// Preparation counts and converted battle memberships through the actual browser simulation loader.
import { test, before, after, describe } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync } from 'node:fs';
import { getData } from '../../server/data.js';
import { GameData } from '../../server/match/gamedata.js';
import { computeBonds } from '../../server/match/bondsMeta.js';
import { getDefaultSource } from '../../server/sim/simdata.js';
import { buildBattleSpec } from '../../server/sim/spec.js';
import { Grid } from '../../server/sim/grid.js';
import { GEO } from '../../shared/constants.js';
const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const enabled = process.env.SIM_E2E === '1' && existsSync(CHROME);
const raw = getData({ log: { info() {}, warn() {}, error() {} } });
const gd = new GameData(raw, 'mode_multi_hard');
const grid = new Grid(getDefaultSource().getStage('act1autochess_m01'), GEO.NORMAL_RECT);
function fixture(ids, items) {
const taken = new Set(), board = new Map(), units = [];
ids.forEach((id, i) => {
const c = raw.chess[id];
let tile;
for (let r = 9; r <= 12 && !tile; r++) for (let col = 2; col <= 10 && !tile; col++) {
if (!taken.has(`${r},${col}`) && grid.canStand(r, col, { ranged: c.position === 'RANGED' })) tile = [r, col];
}
assert.ok(tile, id); taken.add(tile.join(','));
const its = i === ids.length - 1 ? items : [];
board.set(tile.join(','), { uid: i + 1, kind: 'chess', id, items: its.map(id => ({ id })) });
units.push({ uid: i + 1, chessId: id, row: tile[0], col: tile[1], items: its });
});
const bonds = computeBonds(gd, { board, hand: [], layers: {} });
return { spec: buildBattleSpec({ battleId: 'morph', fieldId: 'n:p1', kind: 'normal', modeId: 'mode_multi_hard',
seed: 29, stageId: 'act1autochess_m01', timeLimit: 10, flags: { dpInit: 999 },
players: [{ playerId: 'p1', bonds, units }],
}), bonds, uid: ids.length };
}
describe('converted bonds in the actual browser battle', { skip: !enabled }, () => {
let srv, browser, page;
before(async () => {
const { startServer } = await import('../../server/index.js');
const P = (await import('puppeteer-core')).default;
srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
browser = await P.launch({ executablePath: CHROME, headless: true, args: ['--no-sandbox'] });
page = await browser.newPage(); await page.goto(`http://127.0.0.1:${srv.port}/sim/spec.js`);
});
after(async () => { await browser?.close(); await srv?.close(); });
async function inspect(f) {
return page.evaluate(async ({ spec, uid }) => {
const { loadBrowserSim } = await import('/js/battle/runner.js');
const { spec: S, ds } = await loadBrowserSim();
const { unitBonds, isMember } = await import('/sim/content/support/index.js');
const b = S.createBattleFromSpec(spec, ds, { quiet: true, recordEvents: false });
for (let i = 0; i < 30; i++) b.step();
const u = b.allyUnits.find(u => u.kind === 'op' && u.uid === uid);
return { own: unitBonds(u), members: Object.keys(spec.players[0].bonds).filter(id => isMember(b, u, id)),
yan: b.getPlayer('p1').bonds.yanShip, atk: u.s.atk, baseAtk: u.base.atk,
yanyou: b.allyUnits.filter(u => u.alive && u.mem.bondYan).length, errors: b.errors };
}, f);
}
for (const mu of ['chess_char_6_11_a', 'chess_char_6_11_b']) test(`7 Yan plus converted ${mu}: nine Yan really summons two 炎佑`, async () => {
const seven = Object.values(raw.chess).filter(c => c.visible && !c.isGolden && c.bonds.includes('yanShip')).slice(0, 7).map(c => c.chessId);
const f = fixture([...seven, mu], ['chess_item_6_09_e_a', 'chess_item_3_04_e_a']);
assert.equal(f.bonds.yanShip.count, 9);
const out = await inspect(f);
assert.equal(out.yan.count, 9); assert.equal(out.yan.tier, 3);
assert.ok(out.own.includes('yanShip')); assert.ok(out.atk > out.baseAtk);
assert.equal(out.yanyou, 2); assert.deepEqual(out.errors, []);
});
test('all 36 normal/advanced conversion item pairings retain battle membership', async () => {
const grants = Object.values(raw.items).filter(r => r.giveBondId);
assert.equal(grants.length, 36);
for (const it of grants) {
const id = it.giveBondId;
const two = Object.values(raw.chess).filter(c => c.visible && !c.isGolden && c.bonds.includes(id) && !c.bonds.includes('maniShip')).slice(0, 2).map(c => c.chessId);
const outsider = Object.values(raw.chess).find(c => c.visible && !c.isGolden && c.tier <= 2 && !c.bonds.includes(id) && !c.bonds.includes('maniShip'));
const f = fixture([...two, outsider.chessId], [it.id, it.isGolden ? 'chess_item_6_09_e_b' : 'chess_item_6_09_e_a']);
const out = await inspect(f);
assert.ok(out.own.includes(id) && out.members.includes(id), `${it.id}: ${JSON.stringify(out)}`);
assert.deepEqual(out.errors, []);
}
});
});
+152
View File
@@ -0,0 +1,152 @@
// Official red damage numbers (PRTS 作战机制 「伤害红字(暴击)」, https://prts.wiki/w/作战机制).
// The mark is display-only: HP, return values and event counts stay the same. A qualifying hit appends 'r' on the
// dmg tuple that already fires; a dodge of such a hit sets num:'m' on the dodge fx that already fires. No new event.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { officialDamageMark } from '../../server/sim/damage.js';
import { makeBattle, chessRec, enemyRec } from '../helpers/battleHarness.js';
const approx = (a, b, eps = 1e-6) => assert.ok(Math.abs(a - b) <= eps, `${a} ≈ ${b}`);
function field() {
const h = makeBattle({
defs: {
chess: { t_a: chessRec({ id: 't_a', stats: { atk: 1000 } }) },
enemies: { enemy_dummy: enemyRec({ key: 'enemy_dummy', hp: 100000, def: 400, res: 0, speed: 0 }) },
},
units: [{ chessId: 't_a', row: 9, col: 3 }],
enemies: [{ key: 'enemy_dummy', pos: [11, 8] }],
content: 'none',
});
h.step();
return h;
}
function added(h, kind, fn) {
const before = h.eventsOf(kind).length;
const result = fn();
return { result, ev: h.eventsOf(kind).slice(before) };
}
test('officialDamageMark is the PRTS 1.5× test, including a 0-damage invulnerable hit', () => {
assert.equal(officialDamageMark(150, 100), 'r');
assert.equal(officialDamageMark(149.9, 100), null);
assert.equal(officialDamageMark(0, 0), 'r', '0 ≥ 0 × 1.5 (PRTS 无敌: 弑君者 flash still shows red 0)');
assert.equal(officialDamageMark(Number.NaN, 10), null);
});
test('a landed hit is marked only when the pre-shield total reaches 1.5× the penetration-free expected damage', () => {
const h = field();
const e = h.enemy('enemy_dummy');
const op = h.unit('t_a');
const plain = added(h, 'dmg', () => h.b.dealDamage(null, e, { amount: 100, type: 'true' }));
assert.equal(plain.result, 100);
assert.deepEqual(plain.ev[0], ['dmg', e.id, 100, 'true'], 'below 1.5×: the tuple stays four elements');
h.b.addBuff(e, { key: 'fr12', mods: { dmgTakenMul: 1.2 } });
const mild = added(h, 'dmg', () => h.b.dealDamage(null, e, { amount: 100, type: 'true' }));
assert.equal(mild.result, 120);
assert.equal(mild.ev[0].length, 4, '脆弱 ×1.2 is below 1.5× the penetration-free expected damage');
h.b.removeBuff(e, e.findBuff('fr12'));
h.b.addBuff(e, { key: 'fr15', mods: { dmgTakenMul: 1.5 } });
const edge = added(h, 'dmg', () => h.b.dealDamage(null, e, { amount: 100, type: 'true' }));
assert.equal(edge.result, 150);
assert.deepEqual(edge.ev[0], ['dmg', e.id, 150, 'true', 'r'], 'exactly 1.5× is red');
h.b.removeBuff(e, e.findBuff('fr15'));
h.b.addBuff(op, { key: 'dealt', mods: { dmgDealtMul: 2 } });
const dealt = added(h, 'dmg', () => h.b.dealDamage(op, e, { amount: 100, type: 'true' }));
assert.equal(dealt.result, 200);
assert.equal(e.hp, 100000 - 100 - 120 - 150 - 200);
assert.equal(dealt.ev[0][4], 'r', 'source damage-dealt multipliers are excluded from expected damage');
h.b.removeBuff(op, op.findBuff('dealt'));
const mul = added(h, 'dmg', () => h.b.dealDamage(null, e, { amount: 100, type: 'true', mul: 2 }));
assert.equal(mul.result, 200);
assert.equal(mul.ev[0].length, 4, 'dmg.mul is the instance scale and stays on both sides of the comparison');
const pen = added(h, 'dmg', () => h.b.dealDamage(null, e, { amount: 1000, type: 'phys', defIgnorePct: 1 }));
assert.equal(pen.result, 1000);
assert.equal(pen.ev[0][4], 'r', 'DEF penetration is excluded from expected damage (600) but counted in the final (1000)');
const blocked = added(h, 'dmg', () => h.b.dealDamage(null, e, { amount: 1000, type: 'phys' }));
assert.equal(blocked.result, 600);
assert.equal(blocked.ev[0].length, 4, 'the same hit without penetration is not red');
});
test('shields, hit-count husks, sub-threshold reds and element fills do not invent events', () => {
const h = field();
const e = h.enemy('enemy_dummy');
h.b.addBuff(e, { key: 'fr', mods: { dmgTakenMul: 2 } });
h.b.addBuff(e, { key: 'sh', shield: 100000 });
const soaked = added(h, 'dmg', () => h.b.dealDamage(null, e, { amount: 100, type: 'true' }));
assert.equal(soaked.result, 0);
assert.equal(soaked.ev.length, 0, 'a barrier that removes the hit emits nothing');
h.b.removeBuff(e, 'sh');
h.b.removeBuff(e, 'fr');
h.b.addBuff(e, { key: 'hc', flags: { hitCount: true } });
const husk = added(h, 'dmg', () => h.b.dealDamage(null, e, { amount: 1000, type: 'true' }));
assert.equal(husk.result, 1);
assert.deepEqual(husk.ev[0], ['dmg', e.id, 1, 'true'], 'a hit-count rewrite to 1 is not red against a large expected value');
h.b.removeBuff(e, 'hc');
h.b.addBuff(e, { key: 'tiny', mods: { dmgTakenMul: 4 } });
const tiny = added(h, 'dmg', () => h.b.dealDamage(null, e, { amount: 0.1, type: 'true' }));
approx(tiny.result, 0.4);
assert.equal(tiny.ev.length, 0, 'a red total under 0.5 still emits no dmg event');
h.b.removeBuff(e, 'tiny');
const gauge = added(h, 'dmg', () => h.b.dealDamage(null, e, { amount: 80, type: 'element', element: 'burn' }));
assert.equal(gauge.ev.length, 1);
assert.equal(gauge.ev[0].length, 4, 'an element gauge fill is not an HP crit');
assert.equal(gauge.ev[0][3], 'burn');
const rewritten = field();
const t = rewritten.enemy('enemy_dummy');
rewritten.b.addBuff(t, { key: 'fr', mods: { dmgTakenMul: 2 } });
rewritten.b.on('hpDamage', (c) => { c.amount = 1; });
const cut = added(rewritten, 'dmg', () => rewritten.b.dealDamage(null, t, { amount: 100, type: 'true' }));
assert.equal(cut.result, 1);
assert.deepEqual(cut.ev[0], ['dmg', t.id, 1, 'true', 'r'], 'the 1.5× test uses the pre-rewrite total; the shown integer is what reaches HP');
});
test('invulnerable and sleeping hits stay event-free; a dodge marks MISS only for a would-be red hit', () => {
const h = field();
const e = h.enemy('enemy_dummy');
const hp = e.hp;
h.b.addBuff(e, { key: 'inv', duration: 5, flags: { invulnerable: true } });
const inv = added(h, 'dmg', () => h.b.dealDamage(null, e, { amount: 500, type: 'true' }));
assert.equal(inv.result, 0);
assert.equal(inv.ev.length, 0);
assert.equal(e.hp, hp);
h.b.removeBuff(e, e.findBuff('inv'));
h.b.addBuff(e, { key: 'sleep', duration: 5, flags: { sleep: true } });
const sleep = added(h, 'dmg', () => h.b.dealDamage(null, e, { amount: 500, type: 'true' }));
assert.equal(sleep.result, 0);
assert.equal(sleep.ev.length, 0);
h.b.removeBuff(e, e.findBuff('sleep'));
let rolls = 0;
h.b.rng = () => { rolls++; return 0; };
h.b.addBuff(e, { key: 'dodge', mods: { dodgePhys: 1 } });
const miss = added(h, 'fx', () => h.b.dealDamage(null, e, { amount: 1000, type: 'phys', defIgnorePct: 1 }));
assert.equal(miss.result, 0);
assert.equal(rolls, 1, 'the hypothetical red test does not roll again');
assert.equal(e.hp, hp);
const redFx = miss.ev.filter((ev) => ev[1] === 'dodge');
assert.equal(redFx.length, 1);
assert.equal(redFx[0][4].id, e.id);
assert.equal(redFx[0][4].num, 'm');
rolls = 0;
const plain = added(h, 'fx', () => h.b.dealDamage(null, e, { amount: 100, type: 'phys' }));
assert.equal(plain.result, 0);
assert.equal(rolls, 1);
const quiet = plain.ev.filter((ev) => ev[1] === 'dodge');
assert.equal(quiet.length, 1);
assert.equal(Object.hasOwn(quiet[0][4], 'num'), false);
});
+101
View File
@@ -0,0 +1,101 @@
// The cross-engine check (tools/sim-check.mjs, public/dev/simcheck — docs/SIM.md「11. Tools」): the hashes are pinned
// (references made before still compare; the same values as the copy of core.js in 争锋频道 / Duel Channel), a change in
// one tick is found in its block and named by the dump comparison, the deep walk sees another realm's Map / Set, the
// check page loads the data files the client loads, and the corpus covers the golden battle families, traces a battle
// the same whatever ran before it, and ends it with the result digest a plain run gives.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import vm from 'node:vm';
import { readFileSync } from 'node:fs';
import { hex, hashNumbers, hashValue, deepHash, trace, tracer, compare, diffDumps, selectScenarios, textFingerprint } from '../../public/dev/simcheck/core.js';
import { SIM_DATA_FILES, scenariosOf } from '../../public/dev/simcheck/stronghold.js';
import { corpusSpecs, nodeAdapter, FAMILIES, parseOptions, bundleIdentity } from '../../tools/sim-check.mjs';
import { createBattleFromSpec, resultDigest } from '../../server/sim/spec.js';
import { DataSource, getDefaultSource } from '../../server/sim/simdata.js';
test('simcheck dumps preserve special IEEE values through JSON and still locate a one-bit difference', () => {
const adapter = {
start: () => ({ t: 0 }), step: r => ++r.t <= 1,
state: () => [-0, Infinity, -Infinity, NaN, 1 + Number.EPSILON], result: () => 'same',
};
const a = trace(adapter, { id: 'bits' }, { dump: [1, 1] }).dumps;
const b = JSON.parse(JSON.stringify(a));
assert.deepEqual(diffDumps(a, b), []);
assert.deepEqual(b[1].state.slice(0, 4), ['-0', 'Infinity', '-Infinity', 'NaN']);
b[1].state[0] = 0; b[1].state[4] = 1;
const diff = diffDumps(a, b);
assert.deepEqual(diff.map(x => x.index), [0, 4]);
assert.notEqual(diff[0].refBits, diff[0].gotBits);
});
test('simcheck rejects incomplete runs and invalid sampling/dump parameters', () => {
const adapter = { maxTicks: 2, start: () => ({}), step: () => true, state: () => [1], result: () => 'not finished' };
assert.throws(() => trace(adapter, { id: 'loop' }), /maxTicks/);
assert.throws(() => tracer(adapter, { id: 'bad' }, { every: 0 }), /interval/);
assert.throws(() => tracer(adapter, { id: 'bad' }, { dump: [2, 1] }), /window/);
assert.throws(() => tracer(adapter, { id: 'bad' }, { dump: [1, Infinity] }), /window/);
});
test('simcheck selection and CLI reject empty, mismatched and misspelled requests', () => {
const scenarios = [{ id: 'a', family: 'one' }, { id: 'b', family: 'two' }];
const subsets = [{ id: 'one', pick: s => s.family === 'one' }];
assert.equal(selectScenarios(scenarios, subsets, 'one').list.length, 1);
assert.throws(() => selectScenarios(scenarios, subsets, 'typo'), /unknown subset/);
assert.throws(() => selectScenarios(scenarios, subsets, 'one', 'typo'), /unknown scenario/);
assert.throws(() => selectScenarios(scenarios, subsets, 'one', 'b'), /no scenarios/);
for (const args of [['--engines'], ['--engines', 'oops'], ['--subset', 'all'], ['--engines', 'chrome', '--subset', 'typo'], ['--oops', 'x']]) assert.throws(() => parseOptions(args));
});
test('simcheck bundle identity changes with corpus or dirty runtime/data content', () => {
const specs = [{ id: 'a', spec: { seed: 1 } }], files = [{ url: '/data/chess.json', hash: textFingerprint('old') }];
const a = bundleIdentity(specs, files);
assert.notEqual(a, bundleIdentity([{ id: 'a', spec: { seed: 2 } }], files));
assert.notEqual(a, bundleIdentity(specs, [{ url: files[0].url, hash: textFingerprint('new') }]));
assert.equal(a, bundleIdentity(JSON.parse(JSON.stringify(specs)), JSON.parse(JSON.stringify(files))));
});
test('simcheck hashes: pinned values, NaN as one value, −0 apart from 0', () => {
assert.equal(hex(hashNumbers([0, -0, 1, 0.1, -2.5e-300, 1e300, NaN, Infinity])), '8b3e38ba');
assert.equal(hex(hashValue({ a: [1, 'x', null, true], b: { c: 0.5 } })), 'a024c2ef');
assert.equal(hex(hashNumbers([NaN])), hex(hashNumbers([0 / 0])));
assert.notEqual(hex(hashNumbers([0])), hex(hashNumbers([-0])));
assert.equal(hex(deepHash([{ s: new Set([1, 2]), m: new Map([['k', 3]]), arr: [4.5] }])), '92f56136');
const other = vm.runInNewContext('({ s: new Set([1, 2]), m: new Map([["k", 3]]), arr: [4.5] })');
assert.equal(hex(deepHash([other])), '92f56136', 'another realm\'s Map / Set');
});
test('simcheck compare: a one-ulp change at tick 45 is found in its block and named by the dumps', () => {
const toy = (bump) => ({
start: () => ({ n: 0, x: 0.1 }),
step: (r) => { if (r.n >= 100) return false; r.n++; r.x = r.x * 1.01 + 0.1; if (bump && r.n === 45) r.x += r.x * Number.EPSILON; return true; },
state: (r) => [r.n, r.x], labels: () => ['n', 'x'], view: (r) => ({ x: Math.round(r.x) }), deep: (r) => hashNumbers([r.x]), result: (r) => String(Math.round(r.x)),
});
const ref = trace(toy(false), { id: 't' });
assert.deepEqual(compare(ref, trace(toy(false), { id: 't' })), { ok: true, at: null, result: false, ticks: null });
assert.deepEqual(compare(ref, trace(toy(true), { id: 't' })).at, { level: 'state', block: 1, from: 31, to: 60 });
const a = tracer(toy(false), { id: 't' }, { dump: [31, 60] }).finish(), b = tracer(toy(true), { id: 't' }, { dump: [31, 60] }).finish();
const d = diffDumps(a.dumps, b.dumps);
assert.equal(d[0].tick, 45);
assert.equal(d[0].label, 'x');
});
test('simcheck page: the data files are the browser runner\'s', () => {
const src = readFileSync(new URL('../../public/js/battle/runner.js', import.meta.url), 'utf8');
const m = /export const SIM_DATA_FILES = Object\.freeze\((\[[^\]]*\])\)/.exec(src);
assert.ok(m, 'runner.js SIM_DATA_FILES');
assert.deepEqual([...SIM_DATA_FILES], JSON.parse(m[1].replace(/'/g, '"')));
});
test('simcheck corpus: the golden battle families, a battle traced the same whatever ran before, the plain run\'s result', () => {
const doc = { specs: corpusSpecs() }, sc = scenariosOf(doc);
assert.deepEqual([...new Set(sc.map((s) => s.family))], [...FAMILIES]);
assert.equal(new Set(sc.map((s) => s.id)).size, sc.length);
assert.ok(sc.some((s) => s.fast) && sc.some((s) => !s.fast));
const pick = sc.filter((s) => s.family === 'bonds')[0], other = sc.filter((s) => s.family === 'roster')[0];
const alone = trace(nodeAdapter(doc), pick), ad = nodeAdapter(doc);
trace(ad, other);
assert.deepEqual(trace(ad, pick), alone);
const spec = doc.specs.find((s) => s.id === pick.id).spec;
const plain = resultDigest(createBattleFromSpec(spec, new DataSource(getDefaultSource().raw, null), { quiet: true }).runToEnd(200000)).hash;
assert.equal(alone.result, plain);
});
+53
View File
@@ -0,0 +1,53 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync } from 'node:fs';
import { Client, ROOT, hasChrome, startRealServer } from '../e2e/client.mjs';
const enabled = process.env.SP_E2E === '1' && hasChrome() && existsSync(`${ROOT}/public/assets`);
test('buying Matterhorn sends two live layer cues through the real client; resync/reload stays silent',
{ skip: !enabled, timeout: 120000 }, async () => {
const P = (await import('puppeteer-core')).default;
const srv = await startRealServer({ fast: { timerScale: 0.1, startRound: 2, kit: 0, shop: ['chess_char_1_02_a'] } });
const c = new Client(P, srv.base, 'bond-audio', { prefix: 'bond-audio' });
try {
await c.open(); await c.enter('叠层音效测试');
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', '开始模拟');
await c.waitFor(s => s.phase === 'INFO_CHECK', 'briefing');
await c.click('.brief__foot .btn--primary', '准备就绪');
await c.waitFor(s => s.phase === 'BAND_DRAFT', 'band draft');
await c.page.click('.dband'); await c.click('.draft-detail__btns .btn--primary', '确认选择');
await c.waitFor(s => s.phase === 'PREP' && !s.ready, 'prep');
await c.page.evaluate(async () => {
const { audio } = await import('/js/audio.js');
const { net } = await import('/js/net.js');
window.__layerCues = []; window.__layerFrames = [];
const play = audio._playBondCue.bind(audio);
audio._playBondCue = (url, volume) => { window.__layerCues.push({ url, volume }); play(url, volume); };
net.on('m.layer', m => window.__layerFrames.push(m));
});
await c.click('.scard', '角峰'); await c.click('.scard', '角峰');
await c.page.waitForFunction(() => window.__layerFrames.length === 2);
const result = await c.page.evaluate(async () => {
const { audio } = await import('/js/audio.js');
// Wait for the actual local asset decoder; then its queued cue continuation starts the source.
await Promise.all([...audio.buffers.values()]); await Promise.resolve();
return { frames: window.__layerFrames, cues: window.__layerCues, playing: !!audio.bondCueNode,
state: audio.ctx?.state, layers: globalThis.__SP__.store.get().match.private.bonds };
});
assert.deepEqual(result.frames.map(m => [m.bondId, m.add]).sort(), [['kjeragShip', 2], ['steadShip', 2]]);
assert.equal(result.cues.length, 2); assert.equal(result.playing, true); assert.equal(result.state, 'running');
assert.ok(result.cues.every(x => /act1autochess_b_ui_buffup/.test(x.url)));
const count = result.cues.length;
// g.watch on own prep asks the real server for state again, without a new gain.
await c.page.evaluate(() => globalThis.__SP__.net.request('g.watch', { fieldId: `n:${globalThis.__SP__.store.get().me.playerId}` }));
assert.equal(await c.page.evaluate(() => window.__layerCues.length), count);
await c.page.reload({ waitUntil: 'networkidle0' });
await c.waitFor(s => s.phase === 'PREP', 'restored prep');
assert.equal(await c.page.evaluate(async () => !!(await import('/js/audio.js')).audio.bondCueNode), false);
assert.equal(c.problems.filter(x => x.startsWith('pageerror:')).length, 0, c.problems.join('\n'));
} finally { await c.close(); await srv.stop(); }
});
+106
View File
@@ -0,0 +1,106 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { AudioManager } from '../../public/js/audio.js';
import { audioEarly, keepEarly } from '../../public/js/screens/game/early.js';
function harness() {
const sources = [];
const a = new AudioManager({ getManifest: () => ({ audio: { sfx: { ui: { bondUp: '/bond.mp3' } } } }) });
a.ctx = {
createGain: () => ({ gain: {}, connect() {}, disconnect() {} }),
createBufferSource: () => {
const s = { started: false, stopped: false, connect() {}, disconnect() {},
start() { this.started = true; }, stop() { this.stopped = true; this.onended?.(); } };
sources.push(s); return s;
},
};
a.sfxGain = {};
a._buffer = async () => ({ duration: 1 });
return { a, sources };
}
const flush = () => new Promise(resolve => queueMicrotask(resolve));
test('a real layer gain plays once regardless of amount; invalid gains are silent', async () => {
const { a, sources } = harness();
for (const n of [0, -1, NaN, Infinity, '1']) a.layerGain('sargonShip', n);
a.layerGain(null, 1); a.layerGain('', 1);
await flush(); assert.equal(sources.length, 0);
a.layerGain('sargonShip', 5); await flush();
assert.equal(sources.length, 1); assert.equal(sources[0].started, true);
a.stopBondCue();
});
test('each new layer cue immediately replaces the playing cue, with no cooldown', async () => {
const { a, sources } = harness();
a.layerGain('sargonShip', 1); await flush();
a.handleBattleEvents([['layer', 'p_1', 'sargonShip', 2]]); await flush();
assert.equal(sources.length, 2); assert.equal(sources[0].stopped, true);
assert.equal(sources[1].started, true); assert.equal(sources[1].stopped, false);
sources[0].onended(); assert.equal(a.bondCueNode.src, sources[1], 'late cleanup must not clear the replacement');
a.stopBondCue(); assert.equal(sources[1].stopped, true);
});
test('only the newest request can start after asynchronous decoding', async () => {
const { a, sources } = harness(), resolves = [];
a._buffer = () => new Promise(resolve => resolves.push(resolve));
a.layerGain('kjeragShip', 1); a.layerGain('defend', 2);
resolves[1]({ duration: 1 }); await flush();
resolves[0]({ duration: 1 }); await flush();
assert.equal(sources.length, 1); assert.equal(sources[0].started, true);
a.stopBondCue();
});
test('field entry, stopping and muting cancel a cue that is still decoding', async () => {
for (const cancel of [a => a.setFieldUnits([]), a => a.stopBondCue(), a => a.setVolumes({ muted: true }), a => a.setVolumes({ sfx: 0 })]) {
const { a, sources } = harness(); let resolve;
a._buffer = () => new Promise(r => { resolve = r; });
a.layerGain('sargonShip', 1); cancel(a); resolve({ duration: 1 }); await flush();
assert.equal(sources.length, 0);
}
});
test('locked/muted audio and missing assets never queue old layer sounds', async () => {
const { a, sources } = harness(), ctx = a.ctx;
a.ctx = null; a.layerGain('sargonShip', 1); a.ctx = ctx;
a.setVolumes({ muted: true }); a.layerGain('sargonShip', 1); a.setVolumes({ muted: false });
a._buffer = async () => null; a.layerGain('sargonShip', 1); await flush();
a._buffer = async () => { throw new Error('missing'); }; a.layerGain('sargonShip', 1); await flush();
assert.equal(sources.length, 0); assert.equal(a.bondCueNode, null);
});
test('returning to prep cancels old battle audio but preserves a new prep gain received before the phase view', async () => {
const { a, sources } = harness(); let resolve;
a._buffer = () => new Promise(r => { resolve = r; });
a.handleBattleEvents([['layer', 'p_0', 'sargonShip', 1]]);
a.stopBondCue('battle'); resolve({ duration: 1 }); await flush();
assert.equal(sources.length, 0);
a.layerGain('sargonShip', 1);
a.stopBondCue('battle'); resolve({ duration: 1 }); await flush();
assert.equal(sources.length, 1); assert.equal(sources[0].started, true);
a.stopBondCue();
});
test('battle layer tuples use the same exclusive channel; late field replay has no layer cue', async () => {
const { a, sources } = harness();
const events = [['layer', 'p_0', 'sargonShip', 1], ['layer', 'p_1', 'kjeragShip', 2]];
a.handleBattleEvents(events); await flush(); assert.equal(sources.length, 1);
a.stopBondCue();
assert.equal(events.some(keepEarly), false);
a.handleBattleEvents(audioEarly(events)); await flush(); assert.equal(sources.length, 1);
});
test('a prep update that adds layers and activates a bond plays one cue, not a cut-off restart', async () => {
const { a, sources } = harness();
a.layerGain('sargonShip', 2);
await flush();
// screens/game.js: the active-bond count rose in the same update — it keeps the layer cue
if (!a.bondCueRecent('prep')) a.sfx('bondUp', { volume: 0.7, scope: 'prep' });
await flush();
assert.equal(sources.length, 1, 'one source');
assert.equal(sources[0].stopped, false, 'the layer cue is not cut off');
// an activation with no layer cue of its own still sounds
const b = harness();
if (!b.a.bondCueRecent('prep')) b.a.sfx('bondUp', { volume: 0.7, scope: 'prep' });
await flush();
assert.equal(b.sources.length, 1);
});
+13 -10
View File
@@ -1,6 +1,6 @@
// Real-server browser E2E of the community reports of 2026-10-06, items 51 / 55 (the boss round's prep). A co-op match
// jumped to the boss round (fastServer SP_START_ROUND=boss), 2 humans (seats 0 and 3) + 2 AI seats, every board placed
// by the jump (SP_AUTO_PLACE), so the pairs are (host, AI 1) and (AI 2, guest) — the guest is the right-hand player.
// by the jump (SP_AUTO_PLACE). Pairing is random; both clients must use the published pair and side.
// * item 51: each human's prep shows its own pieces on its half (the guest's mirrored onto the right half) and its
// partner's pieces on the other half (read-only 'm:<uid>' views from m.private bossMate, facing as the battle will
// place them), the leader standing on the boss field; the team panel frames the pair's two avatars (green, the
@@ -33,6 +33,7 @@ const prepView = (c) => c.page.evaluate(() => {
const pick = (pre) => entries.filter(([k]) => String(k).startsWith(pre)).map(([, x]) => ({ id: x.info?.defId, owner: x.info?.ownerId ?? null, x: Math.round(x.x), y: Math.round(x.y), dir: x.info?.dir ?? null }));
return {
me: s.me.playerId, phase: s.match.public?.phase, camKind: v?.debug?.camKind ?? null, mode: v?.mode ?? null,
pair: s.match.public?.bossPairs?.find(g => g.includes(s.me.playerId)) ?? [],
field: s.match.field ? { fieldId: s.match.field.fieldId, kind: s.match.field.kind, prep: !!s.match.field.prep, side: s.match.field.side ?? null } : null,
own: pick('p:'), mates: pick('m:'), scout: entries.filter(([k]) => typeof k === 'number' || /^\d+$/.test(String(k))).length,
leader: v?.debug?.leader ? { key: v.debug.leader.key, shown: !!v.debug.leader.view?.root?.visible } : null,
@@ -68,6 +69,7 @@ async function coopAtBoss(P, base, prefix) {
if (s.phase !== 'BAND_DRAFT') { picked.add(c.label); continue; }
if (picked.has(c.label) || s.draft?.turn !== s.me) continue;
await c.click('.dband:not(.is-taken)', null, { nth: c === host ? 2 : 5 });
await c.click('.draft-detail__btns .btn--primary', '确认选择');
picked.add(c.label);
}
await sleep(250);
@@ -91,15 +93,16 @@ describe('the boss round\'s prep: the pair together, the frames, a watcher\'s bo
const gv = await prepView(guest);
await host.shot('prep');
await guest.shot('prep');
// the host holds the left half, its AI partner the right one; the guest the right half, its AI partner the left
assert.equal(hv.camKind, 'bossPrep');
assert.equal(gv.camKind, 'bossPrep');
assert.deepEqual([hv.bossMate?.side, gv.bossMate?.side], ['R', 'L'], JSON.stringify({ hv: hv.bossMate, gv: gv.bossMate }));
assert.ok(hv.own.length >= 4 && hv.own.every((u) => u.x <= 10), `host's pieces on the left half: ${JSON.stringify(hv.own)}`);
assert.ok(gv.own.length >= 4 && gv.own.every((u) => u.x >= 10), `guest's pieces mirrored onto the right half: ${JSON.stringify(gv.own)}`);
assert.ok(hv.mates.length >= 4 && hv.mates.every((u) => u.x >= 10 && u.owner === hv.bossMate.playerId), `the AI partner's pieces on the right half: ${JSON.stringify(hv.mates)}`);
assert.ok(gv.mates.length >= 4 && gv.mates.every((u) => u.x <= 10 && u.owner === gv.bossMate.playerId), `the AI partner's pieces on the left half: ${JSON.stringify(gv.mates)}`);
assert.ok(hv.mates.some((u) => u.dir === 'LEFT'), 'mirrored onto the right half: RIGHT reads LEFT');
for (const v of [hv, gv]) {
assert.equal(v.camKind, 'bossPrep');
assert.equal(v.pair.length, 2);
const right = v.pair.indexOf(v.me) === 1;
assert.equal(v.bossMate?.playerId, v.pair.find(id => id !== v.me));
assert.equal(v.bossMate.side, right ? 'L' : 'R');
assert.ok(v.own.length >= 4 && v.own.every(u => right ? u.x >= 10 : u.x <= 10), `own side: ${JSON.stringify(v)}`);
assert.ok(v.mates.length >= 4 && v.mates.every(u => (right ? u.x <= 10 : u.x >= 10) && u.owner === v.bossMate.playerId));
assert.ok((right ? v.own : v.mates).some(u => u.dir === 'LEFT'), 'the right-half units mirror their facing');
}
assert.ok(hv.leader?.shown && gv.leader?.shown, 'the leader stands on the boss field for both');
assert.deepEqual(hv.framed.sort(), [hv.names[hv.me], hv.names[hv.bossMate.playerId]].sort(), 'the host and its partner framed');
assert.deepEqual(gv.framed.sort(), [gv.names[gv.me], gv.names[gv.bossMate.playerId]].sort(), 'the guest and its partner framed');
+4 -5
View File
@@ -15,7 +15,6 @@ import {
rangeGridBox, shortcutFor, sanitizeSettings, DEFAULT_SETTINGS, normalizeResult, cycleField, fieldLabel, homeFieldId,
activeBubbles, sortedPlayers, tileKey, prepCapsuleLabel, prepCamera, dropFailureReason, terrainInfo,
} from '../../public/js/ui/gameLogic.js';
import { pairPlayers } from '../../server/match/finalAssault.js';
import { PHASE, GEO } from '../../shared/constants.js';
import { setLang, setMessages } from '../../shared/i18n.js';
@@ -723,7 +722,7 @@ describe('keyboard & settings', () => {
test('sanitizeSettings', () => {
assert.deepEqual(sanitizeSettings(null), { ...DEFAULT_SETTINGS });
assert.deepEqual(sanitizeSettings({ bgm: 3, sfx: -1, voice: 2, muted: 'yes', damageNumbers: false, quality: 'ultra' }),
{ bgm: 1, sfx: 0, voice: 1, voiceLang: 'cn', voiceOverrides: {}, muted: false, damageNumbers: false, quality: 'high', textSize: 'sm', keys: { ...DEFAULT_SETTINGS.keys } },
{ bgm: 1, sfx: 0, voice: 1, voiceLang: 'cn', voiceOverrides: {}, muted: false, damageNumbers: false, officialDamageNumbers: false, quality: 'high', textSize: 'sm', keys: { ...DEFAULT_SETTINGS.keys } },
'a saved profile without `keys` (before 0.2.0) gets the default key map (test/ui/feedback5-hotkeys.test.js)');
assert.equal(sanitizeSettings({ bgm: 0.5 }).voice, DEFAULT_SETTINGS.voice, 'a saved profile without `voice` gets the default');
// 语音语言 (0.2.2): 中文 by default — a profile saved before it, or any other value, plays the Chinese dub
@@ -762,16 +761,16 @@ describe('prepCamera (research 09 §1.2: the Final Assault prep on the own half
assert.equal(prepCamera(null, 'a').kind, 'prep');
assert.equal(prepCamera({ round: 14, bossRound: null, hiddenRound: null, players: [] }, 'a').kind, 'prep');
});
test('boss / hidden round prep: bossPrep on the side of the server pairing (alive players by seat, two by two)', () => {
test('older servers without bossPairs keep the legacy seat-order prep camera fallback', () => {
const players = [pl('d', 3), pl('a', 0), pl('c', 2), pl('b', 1)];
for (const round of [14, 15]) {
const sides = Object.fromEntries(players.map((p) => [p.playerId, prepCamera(pub(round, players), p.playerId)]));
assert.deepEqual(Object.fromEntries(Object.entries(sides).map(([k, v]) => [k, `${v.kind}:${v.opts.side}`])),
{ a: 'bossPrep:L', b: 'bossPrep:R', c: 'bossPrep:L', d: 'bossPrep:R' });
}
// somebody left (eliminated): the pairs close up exactly like the server's (Match._quit re-plans the boss waves)
// Legacy servers closed the ordered pairs up when somebody left; new servers publish bossPairs explicitly.
const left = [pl('a', 0), pl('b', 1, false), pl('c', 2), pl('d', 3)];
const groups = pairPlayers(left.filter((p) => p.alive).map((p) => ({ ...p })));
const groups = [[left[0], left[2]], [left[3]]];
for (const g of groups) g.forEach((p, j) => assert.equal(prepCamera(pub(14, left), p.playerId).opts.side, j === 1 ? 'R' : 'L', p.playerId));
assert.equal(prepCamera(pub(14, left), 'c').opts.side, 'R', 'c moves up to the right half');
assert.equal(prepCamera(pub(14, left), 'b').kind, 'prep', 'an eliminated player has no half');
+12
View File
@@ -44,6 +44,18 @@ test('user Given strategy medics When inspecting them Then their healing tiles r
}
});
test('user Given a prepared map medic When its card opens Then its board-space range is visible before battle', () => {
for (const id of ['char_605_cmedic', 'char_613_acmedc']) {
const unit = medic(id, { id: -1, uid: -1, area: 'board', x: 2, y: 10, dir: 'RIGHT' });
const r = inspectRange({ ...args(unit), target: { kind: 'unit', unit, prepMapChar: true }, showPrep: true,
field: { prep: false }, snapshot: [99, 18, 3, 1202, 1202] });
assert.deepEqual([r.row, r.col, r.dir], [10, 2, 'RIGHT'], 'use the original board coordinates even on the mirrored boss half');
assert.equal(rangeTiles(r.grid, r.row, r.col, r.dir).length, 12);
assert.equal(inspectRange({ ...args(unit), target: { kind: 'unit', unit }, showPrep: true }), null,
'other unit cards do not become prep-range previews');
}
});
test('user Given a moving battle medic When its current range changes Then the overlay follows live range and position', () => {
const r = inspectRange(args(medic(), { field: { prep: false }, snapshot: [71, 8, 10, 120, 200], live: { range: tokens.char_613_acmedc.skill.rangeGrid } }));
assert.equal(r.grid.length, 18);
+168
View File
@@ -0,0 +1,168 @@
// The existing stronghold.loadout export carries every operator's potential/cultivation, including DIY-only ones.
// Real server + headless Chrome, normal UI input only; no storage/store/network mutation. No art is required.
// SP_E2E=1 node --test test/ui/loadout-ops-export.e2e.test.js
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { Client, hasChrome, startRealServer } from '../e2e/client.mjs';
const KALTS = 'char_003_kalts';
const INSIDE = 'chess_char_1_01_a';
const SLOT = 'chess_char_6_diy1_a';
const slot = `.diy-slot[data-slot="${SLOT}"]`;
const picker = '[data-testid="diy-picker"]';
const io = '[data-testid="loadout-io-text"]';
describe('loadout export includes DIY-only cultivation (real server, headless Chrome)', {
skip: !(process.env.SP_E2E === '1' && hasChrome()) && 'set SP_E2E=1 (and have Chrome)',
}, () => {
let srv, P;
before(async () => { srv = await startRealServer(); P = (await import('puppeteer-core')).default; });
after(async () => { await srv?.stop(); });
async function open(label) {
const c = new Client(P, srv.base, label, { prefix: 'loadout-ops-export', w: 1280, h: 800 });
try {
await c.open();
await c.enter('调配回归');
await c.click('.lobby-screen [data-testid="loadout-open"]');
await c.page.waitForSelector('.lo-card');
return c;
} catch (e) { await c.close(); throw e; }
}
const tab = (c, name) => c.click(`.lo-tab[data-tab="${name}"]`);
const disabled = (c, id) => c.page.$eval(`[data-testid="${id}"]`, e => e.disabled);
const saved = (c) => c.page.evaluate(() => {
const p = JSON.parse(localStorage.getItem('sp.pref.loadout') || '{}');
return { entries: p.entries || {}, ops: p.ops || {} };
});
async function chooseKalts(c) {
await tab(c, 'diy');
await c.click(`${slot} :is(.diy-slot__fill, [data-testid="diy-change"])`);
await c.page.waitForSelector(picker);
await c.click(`${picker} input[type="search"]`);
await c.page.keyboard.type('凯尔希');
await c.click(`${picker} .diy-opt[data-char="${KALTS}"]`);
await c.click('[data-testid="diy-confirm"]');
await c.page.waitForSelector(picker, { hidden: true });
}
async function setKalts(c, patch) {
await tab(c, 'diy');
for (const [key, value] of Object.entries(patch)) await c.page.select(`${slot} select[data-cult="${key}"]`, String(value));
}
async function changeNormal(c) {
await tab(c, 'loadout');
await c.click(`.lo-card[data-chess="${INSIDE}"] .lo-q--skill[data-skill="0"]`);
await c.page.select(`.lo-card[data-chess="${INSIDE}"] select[data-cult="potential"]`, '2');
}
async function exportText(c, id = 'loadout-export') {
assert.equal(await disabled(c, id), false, `${id} must allow the saved settings to be exported`);
await c.click(`[data-testid="${id}"]`);
await c.page.waitForSelector(io);
const text = await c.page.$eval(io, e => e.value);
await c.page.keyboard.press('Escape');
await c.page.waitForSelector(io, { hidden: true });
return text;
}
async function importText(c, text, id = 'loadout-import') {
await c.click(`[data-testid="${id}"]`);
await c.page.waitForSelector(io);
await c.click(io);
await c.page.keyboard.type(text);
await c.click('[data-testid="loadout-io-apply"]');
await c.page.waitForSelector(io, { hidden: true });
}
for (const [name, normal, patch] of [
['DIY potential only', false, { potential: 1 }],
['DIY cultivation only', false, { cultivate: 0 }],
['DIY potential and cultivation', false, { potential: 1, cultivate: 0 }],
['normal only', true, null],
['normal and DIY', true, { potential: 1, cultivate: 0 }],
]) {
test(`${name}: export then import in a fresh browser preserves entries and ops`, { timeout: 90000 }, async () => {
let c, target, diyText;
try {
c = await open(name);
if (patch) {
await chooseKalts(c);
await setKalts(c, patch);
await c.shot(`${name.replaceAll(' ', '-')}-settings`);
diyText = await exportText(c, 'diy-export');
const diy = JSON.parse(diyText);
assert.equal(diy.kind, 'stronghold.diy');
assert.equal('ops' in diy, false, 'the DIY roster format stays unchanged');
assert.equal(diy.picks[SLOT].charId, KALTS);
}
if (normal) await changeNormal(c);
await tab(c, 'loadout');
const expected = await saved(c);
if (patch) assert.deepEqual(expected.ops[KALTS], patch);
if (!normal) {
assert.deepEqual(expected.entries, {});
assert.match(await c.page.$eval('.lo-top__right', e => e.textContent), /已调整\s*0/);
assert.equal(await c.page.$eval('.lo-top__right button:last-child', e => e.disabled), true,
'normal reset still covers only its roster');
}
console.log(JSON.stringify({ scenario: name, saved: expected, exportDisabled: await disabled(c, 'loadout-export') }));
await c.shot(`${name.replaceAll(' ', '-')}-before-export`);
const text = await exportText(c);
const payload = JSON.parse(text);
assert.equal(payload.kind, 'stronghold.loadout');
assert.equal(payload.v, 1);
assert.equal(payload.count, normal ? 1 : 0, 'count retains its existing entry semantics');
assert.deepEqual({ entries: payload.entries, ops: payload.ops }, expected);
await c.shot(`${name.replaceAll(' ', '-')}-exported`);
target = await open(`${name} import`);
if (diyText) {
await tab(target, 'diy');
await importText(target, diyText, 'diy-import');
await tab(target, 'loadout');
}
await importText(target, text);
assert.deepEqual(await saved(target), expected, 'a fresh browser keeps the complete exported loadout');
await target.page.reload({ waitUntil: 'domcontentloaded' });
await target.page.waitForSelector('.lobby-screen');
assert.deepEqual(await saved(target), expected, 'the imported loadout persists across reload');
if (patch) {
assert.deepEqual(await target.page.evaluate(() => JSON.parse(localStorage.getItem('sp.pref.diy')).picks), JSON.parse(diyText).picks);
await target.click('.lobby-screen [data-testid="loadout-open"]');
await tab(target, 'diy');
await target.page.waitForSelector(`${slot} select[data-cult]`);
assert.deepEqual(await target.page.$$eval(`${slot} select[data-cult]`, els => Object.fromEntries(els.map(e => [e.dataset.cult, Number(e.value)]))),
{ potential: patch.potential ?? 6, cultivate: patch.cultivate ?? 3 }, 'the restored pick displays its restored settings');
await target.shot(`${name.replaceAll(' ', '-')}-imported`);
}
assert.deepEqual([...c.problems, ...target.problems].filter(p => p.startsWith('pageerror:')), []);
console.log(JSON.stringify({ scenario: name, payload, imported: await saved(target), resourceProblems: c.problems.length + target.problems.length }));
} finally { await target?.close(); await c?.close(); }
});
}
test('defaults, clearing a pick and normal reset preserve the existing scopes', { timeout: 90000 }, async () => {
let c;
try {
c = await open('reset');
assert.equal(await disabled(c, 'loadout-export'), true);
await chooseKalts(c);
await tab(c, 'loadout');
assert.equal(await disabled(c, 'loadout-export'), true, 'a roster choice alone is not loadout settings');
await setKalts(c, { potential: 1, cultivate: 0 });
await changeNormal(c);
await c.click('.lo-top__right button', '全部恢复默认');
await c.click('.modal button', '恢复默认');
await c.page.waitForSelector('.modal', { hidden: true });
assert.deepEqual(await saved(c), { entries: {}, ops: { [KALTS]: { potential: 1, cultivate: 0 } } });
assert.equal(await disabled(c, 'loadout-export'), false, 'normal reset retains exportable DIY settings');
await tab(c, 'diy');
await c.click(`${slot} [data-testid="diy-clear"]`);
await tab(c, 'loadout');
assert.equal(await disabled(c, 'loadout-export'), false, 'clearing a pick retains its per-operator settings');
await chooseKalts(c);
await setKalts(c, { potential: 6, cultivate: 3 });
await tab(c, 'loadout');
assert.deepEqual(await saved(c), { entries: {}, ops: {} });
assert.equal(await disabled(c, 'loadout-export'), true, 'back to defaults: nothing to export');
assert.deepEqual(c.problems.filter(p => p.startsWith('pageerror:')), []);
} finally { await c?.close(); }
});
});
+48
View File
@@ -0,0 +1,48 @@
// GitHub #538: a loadout file that carries `ops` (including `ops: {}`) replaces 潜能 / 练度. That list includes
// 自选 operators, which only backups.json names. Importing it while that file is missing used to drop them and still
// save, clearing a 练度 the file did not mean to erase. The whole import is refused first, with the 自选编队 data check.
// No browser: the refusal is a function, and ioApply must call it after a successful parse and before any write.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
globalThis.fetch = async () => ({ ok: false, status: 404, json: async () => ({}) });
const warn = console.warn;
console.warn = (...a) => { if (!String(a[0]).startsWith('[data]')) warn(...a); };
const { loadoutOpsImportRefusal } = await import('../../public/js/screens/loadout.js');
const NOT_LOADED = '导入失败:游戏数据没有载入,未做任何改动。请刷新页面;仍不行时,请检查广告拦截插件和网络';
const LOADING = '干员数据仍在载入,请稍候再导入';
const statusOf = (map, dflt = 'ready') => (name) => map[name] ?? dflt;
test('a loadout ops payload is refused with the DIY data check; entries-only is not', () => {
const noBackups = statusOf({ backups: 'missing' });
const bothMissing = statusOf({ chess: 'missing', backups: 'missing' });
const ready = statusOf({});
assert.equal(loadoutOpsImportRefusal(null, true, noBackups), null, 'no ops key: current 练度 stays');
assert.equal(loadoutOpsImportRefusal(undefined, true, noBackups), null);
assert.deepEqual(loadoutOpsImportRefusal({}, true, noBackups), { text: NOT_LOADED, tone: 'error' }, 'ops: {} still replaces 练度');
assert.deepEqual(loadoutOpsImportRefusal({ char_112_siege: { potential: 1 } }, true, noBackups), { text: NOT_LOADED, tone: 'error' });
assert.deepEqual(loadoutOpsImportRefusal({ char_112_siege: { potential: 1 } }, true, bothMissing), { text: NOT_LOADED, tone: 'error' });
assert.deepEqual(loadoutOpsImportRefusal({}, false, ready), { text: LOADING, tone: 'warn' }, 'still loading: nothing is written');
assert.equal(loadoutOpsImportRefusal({ char_112_siege: { potential: 1 } }, true, ready), null, 'both files ready: the import may proceed');
assert.equal(loadoutOpsImportRefusal(null, false, noBackups), null, 'entries-only does not wait on backups');
});
test('ioApply asks loadoutOpsImportRefusal after parseImport and before cultivationCharIds or applyLoadoutEntries', () => {
const src = readFileSync(path.join(ROOT, 'public/js/screens/loadout.js'), 'utf8');
const at = src.indexOf('const ioApply = () => {');
assert.ok(at > 0, 'ioApply');
const body = src.slice(at, src.indexOf('\n };\n', at));
const parsed = body.indexOf('if (!res.ok)');
const guard = body.indexOf('loadoutOpsImportRefusal(res.ops, ready)');
const ids = body.indexOf('cultivationCharIds(');
const apply = body.indexOf('applyLoadoutEntries(');
assert.ok(parsed > 0 && guard > parsed, 'refuse only a payload that parsed');
assert.ok(ids > guard && apply > guard, 'no write and no DIY id list until the refusal returns null');
assert.match(body.slice(guard), /^loadoutOpsImportRefusal\(res\.ops, ready\);\s*if \(opsRefused\) \{ toast\(opsRefused\.text, opsRefused\.tone\); return; \}/);
});
+174
View File
@@ -0,0 +1,174 @@
// Awaiting game data must never let an obsolete loadout overwrite a later edit/reset.
// These are controlled readiness inputs; the real screen route is the same prepare/flush path.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { createStore } from '../../public/js/store.js';
import { installLoadoutSync, SYNC_DEBOUNCE_MS, RETRY_MS } from '../../public/js/ui/loadoutSync.js';
import { cultivationCharIds } from '../../shared/protocol.js';
const CHESS = JSON.parse(readFileSync(new URL('../../data/chess.json', import.meta.url)));
const BACKUPS = JSON.parse(readFileSync(new URL('../../data/backups.json', import.meta.url)));
const INSIDE = 'chess_char_1_01_a';
const CHAR = CHESS[INSIDE].charId;
const ENTRIES = { [INSIDE]: { skill: 0 } };
const OPS = { [CHAR]: { potential: 1 } };
const IDS = cultivationCharIds(CHESS, BACKUPS);
const ticks = async () => { for (let i = 0; i < 8; i++) await Promise.resolve(); };
function deferred() {
let resolve, reject;
const promise = new Promise((yes, no) => { resolve = yes; reject = no; });
return { promise, resolve, reject };
}
function waitingData() {
const started = deferred(), result = deferred();
return { started: started.promise, ...result, ready() { started.resolve(); return result.promise; } };
}
function harness({ chess = async () => CHESS, backups = async () => BACKUPS } = {}) {
const listeners = new Map(), pending = new Map();
let time = 0, timerId = 0;
const timers = {
setTimeout(fn, ms) { const id = ++timerId; pending.set(id, { fn, at: time + ms }); return id; },
clearTimeout(id) { pending.delete(id); },
async advance(ms) {
time += ms;
for (const [id, task] of [...pending]) if (task.at <= time) { pending.delete(id); task.fn(); await ticks(); }
await ticks();
},
};
const net = {
status: 'online', sent: [], failures: [],
on(name, fn) { listeners.set(name, fn); return () => listeners.delete(name); },
emit(name, msg = {}) { listeners.get(name)?.(msg); },
async request(t, fields) {
net.sent.push({ t, ...fields });
const code = net.failures.shift();
if (code) throw Object.assign(new Error(code), { code });
return { t: 'ok' };
},
};
const target = createStore({ entries: ENTRIES, ops: OPS, open: true, sync: 'idle' });
const sync = installLoadoutSync({ net, timers, target, getChessReady: chess, getBackupsReady: backups,
lookupChess: (id) => CHESS[id], operatorIds: () => IDS });
return { net, timers, target, ...sync };
}
const empty = { t: 'room.loadout', entries: {}, ops: {} };
test('sync: a reset supersedes the old loadout awaiting backups, even after the reset was accepted', async (t) => {
const wait = waitingData(), h = harness({ backups: wait.ready });
t.after(h.dispose);
const old = h.flush();
await wait.started;
// The screen resets ops and entries separately, then the debounced flush sends the empty configuration.
h.target.set({ ops: {} });
h.target.set({ entries: {} });
await h.timers.advance(SYNC_DEBOUNCE_MS);
assert.deepEqual(h.net.sent, [empty]);
assert.equal(h.target.get().sync, 'synced');
wait.resolve(BACKUPS);
await old;
assert.deepEqual(h.net.sent, [empty], 'the late old skill must never go back to the server');
assert.deepEqual(h.target.get().entries, {});
assert.deepEqual(h.target.get().ops, {});
assert.equal(h.target.get().sync, 'synced');
});
test('sync: a later nonempty edit wins while the old backups preparation is waiting', async (t) => {
const wait = waitingData(); let calls = 0;
const h = harness({ backups: () => ++calls === 1 ? wait.ready() : Promise.resolve(BACKUPS) });
t.after(h.dispose);
const old = h.flush();
await wait.started;
h.target.set({ entries: { [INSIDE]: { skill: 0, module: 'none' } }, ops: { [CHAR]: { potential: 2 } } });
await h.flush();
wait.resolve(BACKUPS);
await old;
assert.deepEqual(h.net.sent, [{ t: 'room.loadout', entries: { [INSIDE]: { skill: 0, module: 'none' } }, ops: { [CHAR]: { potential: 2 } } }]);
assert.equal(h.target.get().sync, 'synced');
});
test('sync: an edit invalidates preparation before its debounce runs', async (t) => {
const wait = waitingData(), h = harness({ backups: wait.ready });
t.after(h.dispose);
const old = h.flush();
await wait.started;
h.target.set({ entries: {}, ops: {} });
wait.resolve(BACKUPS);
await old;
assert.deepEqual(h.net.sent, [], 'no stale message during the new edit debounce');
assert.equal(h.target.get().sync, 'pending');
await h.timers.advance(SYNC_DEBOUNCE_MS);
assert.deepEqual(h.net.sent, [empty]);
assert.equal(h.target.get().sync, 'synced');
});
for (const failure of ['missing', 'rejected']) test(`sync: obsolete ${failure} data does not replace the accepted reset status`, async (t) => {
const wait = waitingData(), h = harness({ backups: wait.ready });
t.after(h.dispose);
t.mock.method(console, 'warn', () => {});
const old = h.flush();
await wait.started;
h.target.set({ entries: {}, ops: {} });
await h.flush();
if (failure === 'missing') wait.resolve(null); else wait.reject(new Error('download failed'));
await old;
assert.deepEqual(h.net.sent, [empty]);
assert.equal(h.target.get().sync, 'synced');
});
test('sync: welcome invalidates preparation from the earlier session', async (t) => {
const wait = waitingData(); let calls = 0;
const h = harness({ backups: () => ++calls === 1 ? wait.ready() : Promise.resolve(BACKUPS) });
t.after(h.dispose);
const old = h.flush();
await wait.started;
h.net.emit('welcome');
await h.timers.advance(50);
assert.equal(h.target.get().sync, 'synced');
wait.resolve(null);
await old;
assert.deepEqual(h.net.sent, [{ t: 'room.loadout', entries: ENTRIES, ops: OPS }]);
assert.equal(h.target.get().sync, 'synced');
});
test('sync: an obsolete result does not swallow the newest reset RATE retry', async (t) => {
const wait = waitingData(), h = harness({ backups: wait.ready });
t.after(h.dispose);
const old = h.flush();
await wait.started;
h.target.set({ entries: {}, ops: {} });
h.net.failures.push('RATE');
await h.flush();
assert.equal(h.target.get().sync, 'pending');
wait.resolve(BACKUPS);
await old;
assert.equal(h.target.get().sync, 'pending');
await h.timers.advance(RETRY_MS);
assert.deepEqual(h.net.sent, [empty, empty]);
assert.equal(h.target.get().sync, 'synced');
});
test('sync: a current missing-data preparation remains an error and can be retried', async (t) => {
let missing = true;
const h = harness({ backups: async () => missing ? null : BACKUPS });
t.after(h.dispose);
await h.flush();
assert.deepEqual(h.net.sent, []);
assert.equal(h.target.get().sync, 'error');
missing = false;
await h.flush();
assert.deepEqual(h.net.sent, [{ t: 'room.loadout', entries: ENTRIES, ops: OPS }]);
assert.equal(h.target.get().sync, 'synced');
});
test('sync: unrelated screen state changes do not cancel a current preparation', async (t) => {
const wait = waitingData(), h = harness({ backups: wait.ready });
t.after(h.dispose);
const current = h.flush();
await wait.started;
h.target.set({ sel: INSIDE });
wait.resolve(BACKUPS);
await current;
assert.deepEqual(h.net.sent, [{ t: 'room.loadout', entries: ENTRIES, ops: OPS }]);
assert.equal(h.target.get().sync, 'synced');
});
+7 -1
View File
@@ -200,7 +200,13 @@ describe('GitHub issue #8 item 1: 本局信息 in the strategy draft (real serve
const arcane = await centre(page, '.minfo-dlg .brief-bond[data-bond="arcaneShip"] .bond__disc');
await page.mouse.move(arcane.x, arcane.y);
await page.waitForSelector('.tooltip.is-shown', { visible: true, timeout: 3000 });
assert.equal(await page.$eval('.tooltip.is-shown', (el) => el.textContent), '奥术:本局禁用(该盟约不会激活)');
assert.equal(await page.$eval('.tooltip.is-shown .tooltip__main', (el) => el.textContent), '奥术:本局禁用(该盟约不会激活)');
const listed = await page.$$eval('.tooltip--aside .brief-bantip__txt b', els => els.map(e => e.textContent));
const arcaneNames = await page.evaluate(() => {
const { store, data } = globalThis.__SP__;
return store.get().match.public.bannedChess.map(id => data.lookup('chess', id)).filter(c => c?.bonds?.includes('arcaneShip')).map(c => c.name).sort();
});
assert.deepEqual(listed.sort(), arcaneNames, 'the aside shows precisely this bond\'s banned members');
const status = await page.$eval('[data-testid="match-info-status"]', (el) => el.textContent.trim());
assert.equal(status, '轮到你决策', 'solo: untimed — no seconds');
const box = await page.$eval('.minfo-dlg', (el) => { const r = el.getBoundingClientRect(); return { top: r.top, bottom: r.bottom, left: r.left, right: r.right }; });
+12
View File
@@ -106,6 +106,18 @@ test('MatchBondRow: greyed by state (✕ disc), the banned-member badge, the red
assert.equal(badge('deputShip'), undefined);
assert.equal(tipOf('deputShip').props.text, '助力');
assert.equal(disc('deputShip').props.tier, DATA.bonds.deputShip.thresholds.length);
// the box beside the tip: one line per banned member (avatar, name, its bonds with this one highlighted); none ⇒ null
const aside = tipOf('preciShip').props.aside;
const ops = [...walk(aside)].filter((v) => hasClass(v, 'brief-bantip__op'));
const bannedPreci = DATA.bonds.preciShip.visibleMembers.filter((id) => PUB.bannedChess.includes(id));
assert.deepEqual(ops.map((v) => v.props['data-chess']), bannedPreci);
for (const op of ops) {
const c = DATA.chess[op.props['data-chess']];
assert.ok([...walk(op)].some((v) => v.type === UnitThumb && v.props.id === c.chessId));
assert.match(textOf(op), new RegExp('^' + c.name));
assert.ok([...walk(op)].some((v) => hasClass(v, 'is-this') && textOf(v) === '精准'));
}
assert.equal(tipOf('deputShip').props.aside, null);
});
test('MatchLegend: the grey and the badge; "或本模式禁用" only when the mode switches bonds off', () => {
+44
View File
@@ -910,6 +910,50 @@ describe('in-match UI (mock harness, headless Chrome)', { skip: !ENABLED && 'set
});
}
test('session replacement: recovery action stays clickable above the AI away overlay', async () => {
const { page, problems } = await open('phase=PREP', { render: 'fallback' });
await page.click('.gtop__exit');
await page.waitForSelector('.modal');
const buttons = await page.$$('.modal__actions .btn');
await buttons[1].click(); // 暂离
await page.waitForSelector('.awayov[aria-label="AI 托管中"]', { visible: true });
await page.evaluate(async () => {
const { net } = await import('/js/net.js');
globalThis.__RECOVERY_CONNECTS__ = 0;
net.connect = () => { globalThis.__RECOVERY_CONNECTS__++; };
globalThis.__MOCK__.store.set({ connection: { status: 'closed', ping: 0, attempt: 0, retryAt: 0, lastError: { code: 'REPLACED' }, everOnline: true } });
});
await page.waitForSelector('.conn-banner .btn', { visible: true });
await sleep(300);
assert.deepEqual(await page.evaluate(() => {
const conn = globalThis.__MOCK__.store.get().connection;
return [conn.status, conn.lastError.code];
}), ['closed', 'REPLACED']);
assert.ok(await page.$('.awayov'), 'the away overlay remains during recovery');
assert.match(await page.$eval('.conn-banner', (el) => el.textContent), /该身份已在其他页面登录/);
assert.equal(await page.$eval('.conn-banner .btn', (el) => el.textContent.trim()), '在此页面继续');
const center = await page.$eval('.conn-banner .btn', (btn) => {
const r = btn.getBoundingClientRect();
const x = r.left + r.width / 2, y = r.top + r.height / 2;
return { x, y, hit: btn.contains(document.elementFromPoint(x, y)) };
});
assert.ok(center.hit, 'the recovery button receives pointer input above the away overlay');
await page.screenshot({ path: path.join(OUT, 'ui-recovery-above-away.png') });
await page.evaluate(async () => {
const { confirmDialog } = await import('/js/ui/components.js');
void confirmDialog({ title: 'Recovery layering check', text: 'Global dialog stays above connection recovery.' });
});
await page.waitForSelector('.modal');
assert.equal(await page.$eval('.conn-banner .btn', btn => {
const r = btn.getBoundingClientRect(); return btn.contains(document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2));
}), false, 'the connection banner does not steal input from a global modal');
await page.keyboard.press('Escape'); await page.waitForSelector('.modal', { hidden: true });
await page.mouse.click(center.x, center.y);
assert.equal(await page.evaluate(() => globalThis.__RECOVERY_CONNECTS__), 1, 'the pointer click invokes the recovery action');
assert.deepEqual(problems.filter((p) => p.startsWith('pageerror:')), []);
await page.close();
});
test('watch: the other pair of the Final Assault is refused and the eye stays put', async () => {
const { page, problems } = await open('phase=FINAL_ASSAULT');
const rows = await page.$$('.team__row');
+59
View File
@@ -0,0 +1,59 @@
// Settings, wiring and the title-page repository link for the official damage-number option (#412) and #543.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { sanitizeSettings } from '../../public/js/ui/gameLogic/settings.js';
import { fallbackFloat } from '../../public/js/ui/fallbackField.js';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '../..');
const read = (rel) => readFileSync(join(ROOT, rel), 'utf8');
test('official damage numbers default off and only a real boolean is kept', () => {
assert.equal(sanitizeSettings(null).officialDamageNumbers, false);
assert.equal(sanitizeSettings({}).officialDamageNumbers, false, 'a profile saved before the setting stays on today\'s numbers');
assert.equal(sanitizeSettings({ officialDamageNumbers: true }).officialDamageNumbers, true);
assert.equal(sanitizeSettings({ officialDamageNumbers: true, damageNumbers: false }).damageNumbers, false);
for (const bad of ['yes', 1, null, undefined]) {
assert.equal(sanitizeSettings({ officialDamageNumbers: bad }).officialDamageNumbers, false, String(bad));
}
});
test('the settings screen, the field view and the simplified board follow the same flag', () => {
const settings = read('public/js/ui/settings.js');
assert.match(settings, /t\('官方伤害数字'\)/);
assert.match(settings, /officialDamageNumbers/);
const app = read('public/js/render/app.js');
assert.match(app, /officialDamageNumbers/);
assert.match(app, /fx\.damage\(v, amt, e\[3\], src, e\[4\]\)/);
assert.deepEqual(fallbackFloat(['dmg', 7, 40, 'phys'], { damageNumbers: true }), { id: 7, text: '40', type: 'phys' });
assert.deepEqual(fallbackFloat(['heal', 7, 12], { damageNumbers: true, officialDamageNumbers: false }), { id: 7, text: '+12', type: 'heal' });
assert.equal(fallbackFloat(['dmg', 7, 40, 'phys'], { damageNumbers: true, officialDamageNumbers: true }), null);
assert.equal(fallbackFloat(['heal', 7, 12], { damageNumbers: true, officialDamageNumbers: true }), null);
assert.deepEqual(fallbackFloat(['dmg', 7, 0, 'phys', 'r'], { damageNumbers: true, officialDamageNumbers: true }), { id: 7, text: '0', type: 'crit' });
assert.deepEqual(fallbackFloat(['dmg', 7, 80, 'arts', 'r'], { damageNumbers: true, officialDamageNumbers: true }), { id: 7, text: '80', type: 'crit' });
assert.deepEqual(fallbackFloat(['fx', 'dodge', 1, 2, { id: 7, num: 'm' }], { damageNumbers: true, officialDamageNumbers: true }), { id: 7, text: 'MISS', type: 'miss' });
assert.equal(fallbackFloat(['fx', 'dodge', 1, 2, { id: 7, num: 'm' }], { damageNumbers: true }), null);
assert.equal(fallbackFloat(['dmg', 7, 80, 'phys', 'r'], { damageNumbers: false, officialDamageNumbers: true }), null);
});
test('every interface pack translates the new settings and title strings', () => {
for (const lang of ['en', 'ja', 'ko', 'zh-TW']) {
const pack = JSON.parse(read(`public/i18n/${lang}.json`));
for (const id of ['官方伤害数字', '只显示达到官方暴击条件的红色数字。关闭时仍显示每一次命中。', '项目仓库']) {
assert.equal(typeof pack[id], 'string', `${lang} ${id}`);
assert.ok(pack[id].length > 0, `${lang} ${id}`);
if (lang !== 'zh-TW') assert.notEqual(pack[id], id, `${lang} still has the Chinese source`);
}
}
});
test('the title page links the project repository in a new tab', () => {
const src = read('public/js/screens/title.js');
assert.match(src, /class="title-repo"/);
assert.match(src, /href="https:\/\/github\.com\/sganggs\/Stronghold-Protocol"/);
assert.match(src, /target="_blank"/);
assert.match(src, /rel="noopener noreferrer"/);
assert.match(src, /t\('项目仓库'\)/);
});
+38 -7
View File
@@ -1,6 +1,7 @@
// Real-server browser E2E of user playtest #2 item 10 (+ item 6 on the real flow): the boss round's prep shows the
// player's own half of the boss field (tiles, gates, objective) and placing works — solo (标准: R9 最终攻势; 险境:
// R15 隐秘核心) and co-op (two humans: the host prepares on the LEFT half, the guest on the mirrored RIGHT half) — then
// R15 隐秘核心) and co-op (two humans: each prepares on the half m.public.bossPairs assigns, the second of the pair
// on the mirrored RIGHT half) — then
// the Final Assault starts with the placed units on the boss field. Along the way (item 6): the pen stays hidden with
// the board (🔍▶▶ shows it, 🔍◀◀ "返回战场" hides it again), scouting a teammate (前往查看) and coming back, and in
// the boss battle.
@@ -21,6 +22,7 @@ import assert from 'node:assert/strict';
import { existsSync } from 'node:fs';
import path from 'node:path';
import { Client, ROOT, sleep, hasChrome, startRealServer, problemsOf, waitForFunctionLong } from '../e2e/client.mjs';
import { prepCamera } from '../../public/js/ui/gameLogic/camera.js';
const ENABLED = process.env.SP_E2E === '1' && hasChrome() && existsSync(path.join(ROOT, 'public/assets'));
const ONLY = process.env.SP_P2_REAL || '';
@@ -101,6 +103,29 @@ async function placeOne(c, dir = 'RIGHT') {
throw new Error(`${c.label}: nothing could be placed`);
}
/**
* The half this client prepares on, from the published pairing (`m.public.bossPairs` through `prepCamera`).
* The first of a pair is on the left half; the second is on the mirrored right half. Seat order is not a side.
*/
async function assignedBossSide(c) {
const snap = await c.page.evaluate(() => {
const s = globalThis.__SP__.store.get();
const pub = s.match.public || {};
return {
me: s.me?.playerId ?? null,
phase: pub.phase ?? null, round: pub.round ?? null, bossRound: pub.bossRound ?? null, hiddenRound: pub.hiddenRound ?? null,
bossPairs: pub.bossPairs ?? null,
players: (pub.players || []).map((p) => ({ playerId: p.playerId, seat: p.seat, alive: p.alive !== false })),
};
});
const cam = prepCamera(snap, snap.me);
const pair = (Array.isArray(snap.bossPairs) ? snap.bossPairs : []).find((g) => Array.isArray(g) && g.includes(snap.me));
assert.equal(cam.kind, 'bossPrep', `${c.label}: boss-round prep (pairs ${JSON.stringify(snap.bossPairs)})`);
assert.ok(pair, `${c.label}: a published pair`);
assert.equal(cam.opts.side, pair.indexOf(snap.me) % 2 === 1 ? 'R' : 'L', `${c.label}: the side is the pair index`);
return { side: cam.opts.side, me: snap.me, pair, bossPairs: snap.bossPairs };
}
/** The boss-round prep checks shared by every variant. */
async function checkBossPrep(c, side, tag) {
const v = await viewState(c);
@@ -344,7 +369,7 @@ describe('user playtest #2 item 10 — boss-round prep on the boss field (real s
}
});
test('co-op 险境 (R14): host on the left half, guest on the mirrored right half; scouting and 返回 keep the pen hidden', { skip: only('coop'), timeout: 10 * 60 * 1000 }, async () => {
test('co-op 险境 (R14): each player on the half bossPairs assigns (the right half mirrored); scouting and 返回 keep the pen hidden', { skip: only('coop'), timeout: 10 * 60 * 1000 }, async () => {
const srv = await startRealServer({ fast: { timerScale: 1, combatSpeed: 8, startRound: 'boss', items: ['chess_item_6_03_m'] } });
const P = await pptr();
const host = new Client(P, srv.base, 'host', { prefix: 'fix' });
@@ -386,8 +411,12 @@ describe('user playtest #2 item 10 — boss-round prep on the boss field (real s
await sleep(1800);
const hs = await host.st();
assert.equal(hs.round, hs.bossRound, 'the boss round');
await checkBossPrep(host, 'L', 'host');
await checkBossPrep(guest, 'R', 'guest');
const hostSeat = await assignedBossSide(host);
const guestSeat = await assignedBossSide(guest);
assert.ok(hostSeat.pair.includes(guestSeat.me), `the two players share a pair (${JSON.stringify(hostSeat.bossPairs)})`);
assert.notEqual(hostSeat.side, guestSeat.side, 'a pair occupies both halves');
await checkBossPrep(host, hostSeat.side, 'host');
await checkBossPrep(guest, guestSeat.side, 'guest');
const hp = await placeOne(host, 'RIGHT');
const gp = await placeOne(guest, 'RIGHT');
await sleep(600);
@@ -404,12 +433,14 @@ describe('user playtest #2 item 10 — boss-round prep on the boss field (real s
const w = await viewState(host);
assert.equal(w.penVisible, 0, 'scouting: no pen figure with the teammate\'s board');
assert.ok(w.band[1] <= 13, `scouting: no pen rows (${w.band})`);
assert.equal(w.camKind, 'bossPrep', 'scouting: the boss field');
assert.deepEqual(w.prepField, { kind: 'bossPrep', side: guestSeat.side, mirror: guestSeat.side === 'R' }, 'scouting: the teammate\'s half');
await host.click('.gm__watching button, .team__back', null, { timeout: 4000 });
await host.page.waitForFunction(() => document.querySelector('.gm')?.dataset.camera === 'bossPrep', { timeout: 6000 });
await sleep(900);
const back = await viewState(host);
assert.equal(back.penVisible, 0, '返回: no pen figure');
assert.deepEqual(back.prepField, { kind: 'bossPrep', side: 'L', mirror: false }, '返回: the own boss half again');
assert.deepEqual(back.prepField, { kind: 'bossPrep', side: hostSeat.side, mirror: hostSeat.side === 'R' }, '返回: the own boss half again');
}
await penRoundTrip(guest, 'guest');
// Real clients open their own 教鞭 candidates together; refreshing one tab restores that same private choice.
@@ -454,8 +485,8 @@ describe('user playtest #2 item 10 — boss-round prep on the boss field (real s
await c.page.waitForFunction(() => globalThis.__SP__.store.get().match.private.personalChoice === null, { timeout: 6000 });
}
for (const c of [host, guest]) await c.click('.readybtn');
await checkBossBattle(host, hp, 'L', 'host');
await checkBossBattle(guest, gp, 'R', 'guest');
await checkBossBattle(host, hp, hostSeat.side, 'host');
await checkBossBattle(guest, gp, guestSeat.side, 'guest');
assert.deepEqual(problemsOf([host, guest]), []);
} finally {
for (const c of [host, guest]) if (c.problems.length) console.log(c.label, c.problems.slice(0, 20).join('\n'));
+6 -6
View File
@@ -1,5 +1,5 @@
// Regression tests (Node) of user playtest #3, HUD / UI items:
// 2 the own LP drops live while the own battle's enemies enter the blue gate — pendingLoss / liveLp (the settle
// 2 the own LP's pending loss is marked live while the own battle's enemies enter the blue gate — pendingLoss / liveLp (the settle
// rule min(lpCapPerRound, counted leaks), reset when the settled m.private lands, 联防中 during 联防), the team
// panel rows (rowLp: own live value, teammates' m.public players[].pendingLp), the LP tower's −N tick, and the
// server's pendingLp (server/match/match/views.js _pendingLpView)
@@ -59,7 +59,7 @@ const hasClass = (v, c) => typeof v?.props?.class === 'string' && v.props.class.
// ---- 2: live LP ----------------------------------------------------------------------------------------------------
describe('2: the own LP drops live while the battle runs', () => {
describe("2: the own LP's pending loss is marked live while the battle runs", () => {
test('pendingLoss is the settle rule: min(lpCapPerRound, counted leaks), never negative', () => {
assert.equal(pendingLoss(0), 0);
assert.equal(pendingLoss(3), 3);
@@ -150,7 +150,7 @@ describe('2: the own LP drops live while the battle runs', () => {
assert.deepEqual(rowLp({ lp: 30 }, { phase: PHASE.FINAL_ASSAULT }, { lp: 99, pending: 5 }), { lp: 30, pending: 0, unite: false, left: null }, 'boss rounds: the row keeps its own share');
});
test('LP tower: lp − pending in red with a −N tick keyed by the pending value (it pops again on every leak), the 联防中 tag', () => {
test('LP tower: the settled LP in red with a −N tick keyed by the pending value (it pops again on every leak) — the official keeps the current value until settlement (GitHub #479), the 联防中 tag', () => {
const plain = LpTower({ value: 24, size: 'lg' });
assert.ok(!hasClass(plain, 'is-pending'));
assert.equal(textOf(plain), '24');
@@ -160,11 +160,11 @@ describe('2: the own LP drops live while the battle runs', () => {
assert.equal(pend.key, 3, 'keyed: a new count re-mounts the tick (its pop animation plays again)');
assert.equal(textOf(pend), '−3');
const val = [...walk(v)].find((n) => hasClass(n, 'lp__val'));
assert.equal(textOf(val), '21');
assert.equal(textOf(val), '24', 'the number keeps the settled LP; the −3 tick carries the pending loss (GitHub #479)');
assert.equal(v.props.title, 'x');
const unite = LpTower({ value: 5, pending: 9, note: '联防中' });
assert.equal(textOf([...walk(unite)].find((n) => hasClass(n, 'lp__val'))), '0', 'clamped');
assert.equal(textOf([...walk(unite)].find((n) => hasClass(n, 'lp__pend'))), '−5');
assert.equal(textOf([...walk(unite)].find((n) => hasClass(n, 'lp__val'))), '5');
assert.equal(textOf([...walk(unite)].find((n) => hasClass(n, 'lp__pend'))), '−5', 'the tick clamps at the LP left');
assert.equal(textOf([...walk(unite)].find((n) => hasClass(n, 'lp__note'))), '联防中');
assert.equal(textOf(LpTower({ value: null, pending: 3 })), '--');
});
+13 -9
View File
@@ -134,15 +134,19 @@ test('GitHub #260: Touch / 预备干员-医疗 cards say 干员 · 医疗 and sh
['char_613_acmedc', ['恳切福音', '攻击距离<@ba.vup>+2</>', '攫升', '治疗目标时使其获得3点技力', '超脱', '获得5点技力', '恢复友方单位生命', '初始', '持续']],
['char_605_cmedic', ['治疗强化·β型', '攻击力<@ba.vup>+50%</>', '攻击提升', '攻击力+4%', '恢复友方单位生命']],
]) {
// a battle click resolves the unit as its token record
const detail = resolveDetail({ kind: 'unit', unit: { id: 91, side: 'ally', defId: id } }, new Map());
assert.equal(detail.type, 'token');
const text = allText(TokenDetail(detail));
assert.match(text, /干员/);
assert.match(text, /医疗/);
assert.doesNotMatch(text, /召唤物/);
for (const w of want) assert.ok(text.includes(w), `${id}: ${w}`);
assert.equal(/PRTS 修正/.test(text), id === 'char_613_acmedc', `${id}: the 低于一半 line only under 恳切福音`);
for (const unit of [
{ id: 91, side: 'ally', defId: id }, // battle click
{ id: -1, uid: -1, kind: 'token', side: 'ally', defId: id, area: 'board', x: 2, y: 10 }, // preparation preview click
]) {
const detail = resolveDetail({ kind: 'unit', unit, prepMapChar: unit.id === -1 }, new Map());
assert.equal(detail.type, 'token');
const text = allText(TokenDetail(detail));
assert.match(text, /干员/);
assert.match(text, /医疗/);
assert.doesNotMatch(text, /召唤物/);
for (const w of want) assert.ok(text.includes(w), `${id}: ${w}`);
assert.equal(/PRTS 修正/.test(text), id === 'char_613_acmedc', `${id}: the 低于一半 line only under 恳切福音`);
}
}
// the record keeps the official sentence; the line under it is what touchGospel does (strictly below hp_ratio)
assert.match(TOKENS.char_613_acmedc.skill.desc, /不高于一半/);
+31
View File
@@ -0,0 +1,31 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { prepCamera, deployFieldOf, teamFrameIds } from '../../public/js/ui/gameLogic/camera.js';
import { makeMatch } from '../match/harness.js';
import { PHASE } from '../../shared/constants.js';
for (const humans of [2, 3, 4]) test(`random ${humans}-player boss preparation: client camera, placement and partner frames match server`, () => {
const h = makeMatch({ mode: 'coop', difficulty: 'HARD', humans, seed: 31, fake: true }).start();
try {
h.drive(() => h.m.phase === PHASE.PREP && h.m.round === 14);
const pub = h.m.publicView();
assert.deepEqual(pub.bossPairs, h.m.bossPairs);
for (const p of h.m.alivePlayers()) {
const expected = h.m.bossGroupOf(p);
assert.equal(prepCamera(pub, p.playerId).opts.side, expected.side);
assert.equal(deployFieldOf(pub, p.playerId), h.m.deployFieldOf(p));
assert.deepEqual([...teamFrameIds(pub, p.playerId)], expected.players.length > 1 ? expected.players : []);
}
h.m.onLeave(h.m.bossPairs[0][0]);
const after = h.m.publicView();
for (const p of h.m.alivePlayers()) assert.equal(deployFieldOf(after, p.playerId), h.m.deployFieldOf(p));
} finally { h.m.dispose(); }
});
test('unknown/new pairing is not guessed from seats; older servers still have their ordered fallback', () => {
const pub = { phase: PHASE.PREP, round: 14, bossRound: 14, players: [{ playerId: 'a', seat: 0 }, { playerId: 'b', seat: 1 }] };
assert.equal(prepCamera(pub, 'b').opts.side, 'R');
assert.equal(prepCamera({ ...pub, bossPairs: null }, 'b').kind, 'prep');
assert.equal(prepCamera({ ...pub, bossPairs: [['b', 'a']] }, 'b').opts.side, 'L');
assert.deepEqual([...teamFrameIds({ ...pub, bossPairs: [['b', 'a']] }, 'a')], ['b', 'a']);
});
+84
View File
@@ -0,0 +1,84 @@
// test/ui/refresh-tap.e2e.test.js — GitHub #501 (single-ptilopsis): 高延迟下连按「刷新」只发送一次请求. Through the mock
// harness (public/dev/game-mock.html) in headless Chrome, with the mock's reply to g.refresh delayed: the first tap
// sends one request and dims the button, a spam tap and the R key while it is on the wire send nothing, the shop pays
// once, and the next tap after the reply refreshes again. Opt-in: SP_E2E=1 (needs Chrome; CHROME_PATH overrides).
//
// SP_E2E=1 node --test test/ui/refresh-tap.e2e.test.js
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync } from 'node:fs';
const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const ENABLED = process.env.SP_E2E === '1' && existsSync(CHROME);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const BTN = '.shopbar__tools .toolbtn--amber';
describe('the shop refresh single-flights (GitHub #501, mock harness, headless Chrome)', { skip: !ENABLED && 'set SP_E2E=1 (and have Chrome) to run' }, () => {
let srv;
let browser;
let base;
before(async () => {
const { startServer } = await import('../../server/index.js');
const puppeteer = (await import('puppeteer-core')).default;
srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
base = `http://127.0.0.1:${srv.port}`;
browser = await puppeteer.launch({ executablePath: CHROME, headless: true, args: ['--no-sandbox'] });
});
after(async () => {
await browser?.close();
await srv?.close();
});
test('a tap under a lagging shop pays once: the button dims, the extra tap / R key are dropped, the next tap works', async () => {
const page = await browser.newPage();
await page.setViewport({ width: 1920, height: 1080, deviceScaleFactor: 1 });
await page.goto(`${base}/dev/game-mock.html?shot=1&phase=PREP&render=fallback`, { waitUntil: 'networkidle0' });
await page.waitForFunction(() => !!document.querySelector('.screen:not(.gload)') && !!globalThis.__SP_VIEW__, { timeout: 20000 });
// the mock answers instantly; give g.refresh a high RTT, and count what the client actually sends (gameActions.js
// looks net.request up at call time; the mock's own request record only sees the request after the delay)
await page.evaluate(async () => {
const { net } = await import('/js/net.js');
const orig = net.request.bind(net);
globalThis.__REFRESHES__ = 0;
net.request = (t, ...rest) => {
if (t === 'g.refresh') globalThis.__REFRESHES__ += 1;
return new Promise((res, rej) => {
setTimeout(() => orig(t, ...rest).then(res, rej), t === 'g.refresh' ? 700 : 0);
});
};
});
const btnState = () => page.evaluate(() => {
const b = document.querySelector('.shopbar__tools .toolbtn--amber');
return { disabled: !!b?.disabled, title: b?.title || null };
});
const refreshes = () => page.evaluate(() => globalThis.__REFRESHES__);
const funds = () => page.evaluate(() => globalThis.__MOCK__.S().priv.funds);
assert.equal((await btnState()).disabled, false, 'the 刷新 button starts enabled');
const n0 = await refreshes();
const f0 = await funds();
await page.click(BTN);
await sleep(150);
assert.equal(await refreshes(), n0 + 1, 'the tap sent one g.refresh');
assert.deepEqual(await btnState(), { disabled: true, title: '正在刷新…' }, 'the button dims and says it is waiting');
// the spam tap (the disabled button swallows clicks) and the R hotkey while pending: neither reaches the socket
await page.evaluate(() => document.querySelector('.shopbar__tools .toolbtn--amber')?.click());
await page.keyboard.press('r');
await sleep(200);
assert.equal(await refreshes(), n0 + 1, 'nothing else was sent');
await page.waitForFunction(() => !document.querySelector('.shopbar__tools .toolbtn--amber')?.disabled, { timeout: 5000 });
const back = await btnState();
assert.equal(back.disabled, false, 'the button is enabled again after the reply');
assert.match(back.title, /刷新商店/, 'the tooltip is back to the hotkey hint');
assert.equal(await funds(), f0 - 1, 'the shop paid exactly one refresh');
await page.click(BTN);
await sleep(150);
assert.equal(await refreshes(), n0 + 2, 'a refresh after the reply is sent again');
await page.waitForFunction(() => !document.querySelector('.shopbar__tools .toolbtn--amber')?.disabled, { timeout: 5000 });
assert.equal(await funds(), f0 - 2, 'and paid once more');
await page.close();
});
});
+87
View File
@@ -0,0 +1,87 @@
// test/ui/refresh-tap.test.js — GitHub #501 (single-ptilopsis): 高延迟下连按「刷新」会重复扣费. The intent layer now
// single-flights it (ui/gameActions.js): a tap while the previous g.refresh is still on the wire is dropped — the
// button dims through onRefreshBusy (game.js → ui/shopBar.js) and the R hotkey (the same actions.refresh) stays inert
// until the reply lands. The browser run of the same case is test/ui/refresh-tap.e2e.test.js (SP_E2E=1).
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)), '../..');
const read = (p) => readFileSync(path.join(ROOT, p), 'utf8');
const { net } = await import('../../public/js/net.js');
const { actions, onRefreshBusy } = await import('../../public/js/ui/gameActions.js');
describe('the shop refresh single-flights (GitHub #501)', () => {
test('a refresh while the previous one is on the wire sends nothing; the busy signal brackets the request; a later one sends again', async () => {
const sent = [];
let release;
const prev = net.request;
net.request = (t) => { sent.push(t); return new Promise((r) => { release = () => r({ ok: true }); }); };
const seen = [];
const off = onRefreshBusy((b) => seen.push(b));
try {
assert.deepEqual(seen, [false], 'subscribing fires once with the current (idle) state');
const first = actions.refresh();
assert.equal(sent.length, 1);
assert.equal(seen.at(-1), true, 'busy from the moment of the send');
assert.equal(await actions.refresh(), false, 'the duplicate resolves false without waiting');
assert.equal(sent.length, 1, 'and never reaches the socket');
release();
assert.equal(await first, true);
assert.deepEqual(seen, [false, true, false], 'the reply clears the busy state');
const again = actions.refresh();
assert.equal(sent.length, 2, 'a refresh after the reply is sent again');
release();
assert.equal(await again, true);
assert.deepEqual(seen, [false, true, false, true, false]);
} finally {
off();
net.request = prev;
}
});
test('an unsubscribe stops updates, and a remount receives the in-flight state', async () => {
const seen = [];
const off = onRefreshBusy((b) => seen.push(b));
off();
const prev = net.request;
let release;
net.request = () => new Promise(resolve => { release = resolve; });
let offNew;
try {
const pending = actions.refresh();
const remounted = [];
offNew = onRefreshBusy(b => remounted.push(b));
assert.deepEqual(remounted, [true]);
release({ ok: true }); await pending;
assert.deepEqual(remounted, [true, false]); assert.deepEqual(seen, [false]);
} finally { offNew?.(); net.request = prev; }
});
for (const code of ['OFFLINE', 'DISCONNECTED', 'TIMEOUT', 'NOT_ENOUGH_FUNDS']) test(`${code} clears the pending lock without sending the duplicate`, async () => {
const prev = net.request, states = []; let reject, sent = 0;
net.request = () => { sent++; return new Promise((_, fail) => { reject = fail; }); };
const off = onRefreshBusy(b => states.push(b));
try {
const first = actions.refresh();
assert.equal(await actions.refresh(), false); assert.equal(sent, 1);
reject(Object.assign(new Error(code), { code }));
assert.equal(await first, false); assert.deepEqual(states, [false, true, false]);
net.request = async () => { sent++; return { ok: true }; };
assert.equal(await actions.refresh(), true); assert.equal(sent, 2);
} finally { off(); net.request = prev; }
});
test('the wiring: game.js subscribes and hands the state to the bar; the button dims and says what it waits for', () => {
const game = read('public/js/screens/game.js');
assert.match(game, /onRefreshBusy\(setRefreshBusy\)/, 'the game screen subscribes');
assert.match(game, /<\$\{ShopBar\}[\s\S]*?refreshBusy=\$\{refreshBusy\}/, 'and the bar receives it');
const bar = read('public/js/ui/shopBar.js');
assert.match(bar, /disabled=\$\{!!refReason \|\| refreshBusy\}/, 'busy disables the 刷新 button');
assert.match(bar, /t\('正在刷新…'\)/, 'and its tooltip says what it is waiting for');
});
});
+120
View File
@@ -0,0 +1,120 @@
// Isolated component/tool fixtures, with production modules and CSS in a real browser.
import { test, before, after, describe } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { CHROME, hasChrome } from '../e2e/client.mjs';
describe('round30 tool and compound-tooltip browser boundaries', { skip: !(process.env.SP_E2E === '1' && hasChrome()) }, () => {
let srv, browser, page;
before(async () => {
const { startServer } = await import('../../server/index.js');
srv = await startServer({ host: '127.0.0.1', port: 0, quiet: true });
const P = (await import('puppeteer-core')).default;
browser = await P.launch({ executablePath: CHROME, headless: true, args: ['--no-sandbox', '--disable-background-timer-throttling'] });
page = await browser.newPage();
page.on('pageerror', e => console.error('fixture page error:', e.message));
});
after(async () => { await browser?.close(); await srv?.close(); });
async function blank(query = '') {
await page.goto(`http://127.0.0.1:${srv.port}/dev/simcheck/ui.js${query}`);
await page.setContent('<html><head><link rel="stylesheet" href="/css/theme.css"><link rel="stylesheet" href="/css/components.css"></head><body><main id="fixture"></main></body></html>');
}
async function tooltip(width, height, anchor = '50%', touch = false) {
await page.setViewport({ width, height, hasTouch: touch }); await blank();
await page.evaluate(async (anchor) => {
const { h, render } = await import('/vendor/preact.module.js');
const { Tooltip, TooltipLayer } = await import('/js/ui/components.js');
document.documentElement.style.cssText = 'font-size:100px;--t:130px';
const root = document.getElementById('fixture');
root.style.cssText = `position:fixed;top:45%;left:${anchor};font-size:16px`;
window.changeTip = (names = 'A', aside = true) => render(h('div', {},
h(Tooltip, { text: 'same count: 12', aside: aside ? h('div', {}, ...Array.from({ length: 12 }, (_, i) => h('div', {}, `${names}-${i} Long operator name`))) : null }, h('button', { id: 'anchor' }, 'bond')),
h(TooltipLayer)), root);
window.changeTip();
}, anchor);
await page.hover('#anchor'); await page.waitForSelector('.tooltip.is-shown');
}
for (const [w, h] of [[1440, 900], [390, 844], [844, 390]]) test(`compound tooltip fits ${w}×${h}, all anchor positions`, async () => {
for (const anchor of ['1%', '50%', '90%']) {
await tooltip(w, h, anchor);
const box = await page.$eval('.tooltip', e => {
const a = e.getBoundingClientRect(), main = e.querySelector('.tooltip__main').getBoundingClientRect(), side = e.querySelector('.tooltip--aside').getBoundingClientRect();
return { left: a.left, right: a.right, top: a.top, bottom: a.bottom, overlap: main.left < side.right && side.left < main.right && main.top < side.bottom && side.top < main.bottom,
overflow: e.scrollHeight > e.clientHeight, canScroll: getComputedStyle(e).overflowY === 'auto' && getComputedStyle(e).pointerEvents === 'auto' };
});
assert.ok(box.left >= 7 && box.right <= w - 7 && box.top >= 3 && box.bottom <= h - 3, JSON.stringify(box));
assert.equal(box.overlap, false); if (box.overflow) assert.equal(box.canScroll, true);
await page.keyboard.press('Escape'); await page.waitForSelector('.tooltip', { hidden: true });
}
});
test('visible tooltip updates equal-count member lists and removes an absent aside', async () => {
await tooltip(800, 600);
await page.evaluate(() => window.changeTip('B'));
await page.waitForFunction(() => document.querySelector('.tooltip--aside')?.textContent.includes('B-11'));
assert.equal(await page.$eval('.tooltip', e => e.textContent.includes('A-')), false);
await page.evaluate(() => window.changeTip('B', false));
await page.waitForSelector('.tooltip--aside', { hidden: true });
await page.mouse.move(1, 1); await page.hover('#anchor'); await page.waitForSelector('.tooltip.is-shown');
assert.equal(await page.$eval('.tooltip', e => getComputedStyle(e).pointerEvents), 'none');
});
test('touch long press keeps the long list scrollable and outside tap closes it', async () => {
await tooltip(390, 260, '40%', true);
await page.keyboard.press('Escape'); await page.$eval('#anchor', e => e.blur());
const rect = await page.$eval('#anchor', e => { const r = e.getBoundingClientRect(); return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; });
await page.touchscreen.touchStart(rect.x, rect.y);
await page.waitForSelector('.tooltip.is-shown'); await page.touchscreen.touchEnd();
await new Promise(r => setTimeout(r, 1750));
assert.ok(await page.$('.tooltip.is-shown'));
await page.focus('.tooltip'); await page.keyboard.press('End');
await page.waitForFunction(() => document.querySelector('.tooltip')?.scrollTop > 0);
await page.touchscreen.tap(2, 2); await page.waitForSelector('.tooltip', { hidden: true });
});
const toolHtml = readFileSync(new URL('../../public/dev/sim-check.html', import.meta.url), 'utf8').replace(/<script[\s\S]*?<\/script>/g, '');
async function tool({ mode = '', query = '', count = 1 } = {}) {
await blank(query); await page.setContent(toolHtml);
await page.evaluate(async ({ mode, count }) => {
const { mountCheck } = await import('/dev/simcheck/ui.js');
const { trace } = await import('/dev/simcheck/core.js');
const scenarios = Array.from({ length: count }, (_, i) => ({ id: `case-${i}`, family: 'fast' }));
window.failAt = mode;
const adapter = {
start: () => { if (window.failAt === 'start') throw Error('start failure'); return { tick: 0 }; },
step: r => { if (window.failAt === 'step') throw Error('step failure'); return ++r.tick <= 2; },
state: r => [r.tick + (window.failAt === 'diff' ? 1 : 0)],
result: () => { if (window.failAt === 'result') throw Error('result failure'); return 'ok'; },
};
const saved = window.failAt; window.failAt = '';
const reference = { every: 1, scenarios: Object.fromEntries(scenarios.map(s => [s.id, trace(adapter, s, { every: 1 })])) };
window.failAt = saved;
mountCheck(document.getElementById('app'), { project: 'test', title: 'check', subtitle: '<img src=x onerror="window.injected=true">', adapter, scenarios,
subsets: [{ id: 'fast', label: 'fast', pick: () => true }], reference });
}, { mode, count });
}
for (const mode of ['start', 'step', 'result']) test(`sim check recovers from ${mode} exceptions and allows rerun`, async () => {
await tool({ mode }); await page.click('.sc-run');
await page.waitForFunction(() => window.__simcheck?.status === 'error');
assert.equal(await page.$eval('.sc-run', b => b.disabled), false);
assert.equal(await page.evaluate(() => !!window.injected), false);
await page.evaluate(() => { window.failAt = ''; }); await page.click('.sc-run');
await page.waitForFunction(() => window.__simcheck?.status === 'done');
assert.deepEqual(await page.evaluate(() => [window.__simcheck.expected, window.__simcheck.completed, window.__simcheck.summary.ok]), [1, 1, 1]);
});
for (const query of ['?only=typo', '?subset=typo']) test(`sim check rejects empty or unknown selection ${query}`, async () => {
await tool({ query }); await page.click('.sc-run');
await page.waitForFunction(() => window.__simcheck?.status === 'error');
assert.equal(await page.$('.sc-allok'), null);
});
test('long difference list scrolls to its report; a failed dump restores its button', async () => {
await page.setViewport({ width: 390, height: 400 }); await tool({ mode: 'diff', count: 30 });
await page.click('.sc-run'); await page.waitForFunction(() => window.__simcheck?.status === 'done');
assert.equal(await page.$$eval('.sc-list li', rows => rows.length), 30);
await page.evaluate(() => { window.failAt = 'start'; }); await page.click('.sc-list button');
await page.waitForFunction(() => document.querySelector('.sc-status').textContent.includes('start failure'));
assert.equal(await page.$eval('.sc-list button', b => b.disabled), false);
await page.$eval('.sc-report', e => e.scrollIntoView());
assert.ok(await page.evaluate(() => window.scrollY > 0));
assert.ok(await page.$eval('.sc-report', e => e.getBoundingClientRect().top < window.innerHeight));
assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth));
});
});