chore: sync ammo readout with upstream dev (#552)
This commit is contained in:
222 files changed
+10419
-2026
No files matched your search
@@ -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();
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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, []);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
@@ -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, []);
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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)', () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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] });
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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, []);
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
+536
-535
File diff suppressed because it is too large.
Load diff
+59
-59
@@ -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",
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(); }
|
||||
});
|
||||
@@ -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 });
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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')));
|
||||
});
|
||||
@@ -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
@@ -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\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\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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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\(\{/, '而且用的就是它');
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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, []);
|
||||
}
|
||||
});
|
||||
@@ -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), []);
|
||||
});
|
||||
@@ -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, []);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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(); }
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(); }
|
||||
});
|
||||
});
|
||||
@@ -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; \}/);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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 }; });
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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\('项目仓库'\)/);
|
||||
});
|
||||
@@ -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'));
|
||||
|
||||
@@ -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 })), '--');
|
||||
});
|
||||
|
||||
@@ -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, /不高于一半/);
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user