0.2.5-dev: community round 30 and follow-ups

- Stock: fixed trait gifts (缪尔赛思 / 变形者集群 变形同构体, 耶拉 不融冰, 蜜蜡 / 卡涅利安 迅捷作战粮, 诗怀雅 盟约之币) still occupy stock but are granted past the cap; an operator card already shown in the shop or a reward can be taken after the pool runs out and overdraws (GitHub #498, the owner's decision); item buys and item rewards keep the stock check. 洛洛 / 焰尾 sub-pools use the measured weights (PR #497 by @2321Robin; the occupancy idea of PR #496 by @2321Robin re-implemented differently).
- Boss pairs are drawn at random once per match and fixed to battle start; the Hidden Core reuses them; a leaver does not split a full pair (the owner's decision).
- 阿戈尔 in 联防 no longer re-applies the opening devour HP loss or a false revive; Touch's team medic stays for the match after its holder leaves (owner-approved, official lifetime unverified).
- Auto-skill ranges: 司霆惊蛰 S2, 斩业星熊 S3 against air, 予愿安洁莉娜 S3; 自在's post-rebirth invulnerability is also untargetable (#510); Horn / Clementia / catapult / 忍冬 first hit (PR #502 by @Xinglan233); 不融冰 / tear gas trigger on real damage.
- Bond layer-up sound in round start / draft / prep / battle (#482): one channel, a new cue replaces the old, no extra cooldown; a prep update that adds layers and activates a bond plays one cue.
- Client: multi-swing fix (#506), refresh single-flight (PR #507 by @robll-v1), LP tower keeps the settled value with a −N tick (PR #504 by @robll-v1), export with only 潜能 / 练度 changes (PR #503 by @Xinglan233), spectator rosters list DIY members (PR #505 by @Xinglan233), banned members beside bond tooltips (PR #494 by @YuriRestia), recovery action above the away overlay (PR #511 by @RainoutOvO), the /dev/sim-check.html consistency page (PR #492 by @Cloudnyco), Korean (PR #490 by @D-Roote) and English Victoria wording (PR #499 by @YuriRestia).
- golden: 45 of 287 scenarios move against 0.2.4 (roster 17, bonds 3, fields 6, matches 17, standins 2), each attributed in docs/history/0.2.5.md §30.9 / §30.16.
This commit is contained in:
sganggs committed 2026-10-12 14:07:08 +08:00
1 parent bc50cac01f
commit 221e6b8dd2
134 files changed
+4590 -1201

No files matched your search

+3 -2
View File
@@ -544,11 +544,12 @@ test('阿戈尔 5: a member devoured by several 阿戈尔 spends one slot at mos
});
test('阿戈尔 5: an operator\'s own save or revive uses no slot (斯卡蒂\'s DRE-Y, M3茧甲, 埃芒加德 [ASSUMED for the band]); the slot waits for its first unanswered knock-out', () => {
// 斯卡蒂 (elite, module DRE-Y by default) enters 联防 at half HP in front of 乌尔比安: his mark is lethal, DRE-Y saves her
// 斯卡蒂 (elite, module DRE-Y by default) starts a normal field at half HP in front of 乌尔比安: his mark is lethal,
// DRE-Y saves her. Half HP is a fixture here; a restored 联防 target no longer pays opening devour damage twice.
// (PRTS: once per deployment, full HP with max HP −60 %) — no knock-out, so no slot
const SKADI = 'chess_char_3_05_b', ULPIA = 'chess_char_5_05_a', GHOST = 'chess_char_2_07_a', HN = 'chess_char_3_09_a', DEEP = 'chess_char_1_04_a';
const h = makeBattle({
kind: 'unite', autoFinish: false, timeLimit: 60, hooks: ['damaged', 'death', 'deploy'], captureNoisy: true,
kind: 'normal', autoFinish: false, timeLimit: 60, hooks: ['damaged', 'death', 'deploy'], captureNoisy: true,
units: [
{ chessId: ULPIA, row: 10, col: 3 }, { chessId: SKADI, row: 10, col: 4, dir: 'UP', carryState: { hpPct: 0.5 } },
{ chessId: GHOST, row: 12, col: 3 }, { chessId: HN, row: 12, col: 5 }, { chessId: DEEP, row: 12, col: 7 },
+116
View File
@@ -0,0 +1,116 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, chessRec, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
test('Clementia S3 ignores allied erosion bursts but still marks enemy bursts', () => {
const h = makeBattle({ autoFinish: false, hooks: ['elementBurst'],
units: [
{ uid: 1, diy: { slot: 6, charId: 'char_4231_clemnt', skillIndex: 2 }, elite: true, row: 10, col: 3 },
{ uid: 2, chessId: 'chess_char_1_02_a', row: 10, col: 4 },
], defs: { enemies: { target: enemyRec({ key: 'target', hp: 1e7, atk: 0, speed: 0 }) } },
});
h.step();
const u = h.unit(1), ally = h.unit(2);
u.skill.gainSp(999, 'test'); assert.ok(u.skill.activate('test'));
h.b.dealDamage(null, ally, { type: 'element', element: 'erosion', amount: 2000 });
assert.equal(h.hooksOf('elementBurst').filter(c => c.target === ally).length, 1, 'a real allied burst occurred');
assert.equal(u.mem.clemntBombs.marks.size, 0, 'friendly injury must not schedule a shot');
const enemy = h.spawn('target', { pos: [10, 6] });
h.b.dealDamage(u, enemy, { type: 'element', element: 'erosion', amount: 2000 });
assert.ok(u.mem.clemntBombs.marks.has(enemy), 'enemy erosion still schedules the bombardment');
checkInvariants(h.b); assert.deepEqual(h.b.errors, []);
});
for (const skillIndex of [0, 1, 2]) test(`Horn S${skillIndex + 1} can attack an unblockable fragment on her own tile`, () => {
const h = makeBattle({ autoFinish: false, hooks: ['damaged'], captureNoisy: true,
units: [{ uid: 1, chessId: 'chess_char_5_08_a', skillIndex, row: 10, col: 3 }],
defs: { enemies: { fragment: enemyRec({ key: 'fragment', hp: 1e7, atk: 0, speed: 0 }) } },
});
h.step();
const u = h.unit(1), e = h.spawn('fragment', { pos: [10, 3] });
h.b.addBuff(e, { key: 'test:fragment', persist: true, flags: { unblockable: true } });
u.skill.gainSp(999, 'test'); h.run(4);
assert.equal(u.blocking.length, 0, 'the target cannot be blocked');
assert.ok(e.hp < e.s.maxHp, 'her extra melee range includes her own tile');
assert.ok(u.skill.activations > 0, 'a target on that tile also satisfies skill automation');
checkInvariants(h.b); assert.deepEqual(h.b.errors, []);
});
test('bounty catapult normal triple attack splashes adjacent and diagonal allies within 1.5 tiles', () => {
const rec = (id, tauntLevel = 0) => chessRec({ id, stats: { maxHp: 1e6, atk: 0, def: 0, blockCnt: 0, tauntLevel }, skill: null });
const h = makeBattle({ autoFinish: false, flags: { dpInit: 999 },
defs: { chess: { target: rec('target', 5), bystander: rec('bystander') } },
units: [
{ uid: 1, chessId: 'target', row: 10, col: 5 },
{ uid: 2, chessId: 'bystander', row: 10, col: 6 },
{ uid: 3, chessId: 'bystander', row: 11, col: 6 },
{ uid: 4, chessId: 'bystander', row: 12, col: 7 },
],
});
h.step();
const e = h.spawn('enemy_10162_mnctpt', { pos: [10, 8], mods: { speedMul: 0 } });
assert.ok(h.runUntil(() => h.unit(1).hp < h.unit(1).s.maxHp, 10), 'the primary attack reaches its target');
for (const uid of [1, 2, 3]) assert.equal(h.unit(uid).s.maxHp - h.unit(uid).hp, e.s.atk * 3, `unit ${uid}: all three stones`);
assert.equal(h.unit(4).hp, h.unit(4).s.maxHp, 'beyond the splash');
checkInvariants(h.b); assert.deepEqual(h.b.errors, []);
});
for (const skillIndex of [0, 1, 2]) test(`Horn S${skillIndex + 1} retains melee splash only for S2 and leaves adjacent blind tiles untargeted`, () => {
const h = makeBattle({ autoFinish: false, units: [{ uid: 1, chessId: 'chess_char_5_08_a', skillIndex, row: 10, col: 3 }],
defs: { enemies: { fragment: enemyRec({ key: 'fragment', hp: 1e7, atk: 0, speed: 0 }) } },
});
h.step(); const u = h.unit(1);
const own = h.spawn('fragment', { pos: [10, 3] }), near = h.spawn('fragment', { pos: [10, 4] });
for (const e of [own, near]) h.b.addBuff(e, { key: 'test:fragment', persist: true, flags: { unblockable: true } });
u.skill.gainSp(999, 'test'); h.run(4);
assert.ok(own.hp < own.s.maxHp);
assert.equal(near.hp < near.s.maxHp, skillIndex === 1, 'only S2 grants splash to melee attacks');
h.b.kill(own, null); const hp = near.hp; h.run(4);
assert.equal(near.hp, hp, 'the adjacent blind tile does not become an attack target');
checkInvariants(h.b); assert.deepEqual(h.b.errors, []);
});
test('Horn prioritizes her ordinary ranged targets over an unblockable enemy on her own tile', () => {
const h = makeBattle({ autoFinish: false, units: [{ uid: 1, chessId: 'chess_char_5_08_a', row: 10, col: 3 }],
defs: { enemies: { fragment: enemyRec({ key: 'fragment', hp: 1e7, atk: 0, speed: 0 }) } },
});
h.step();
const own = h.spawn('fragment', { pos: [10, 3] }), far = h.spawn('fragment', { pos: [10, 6] });
h.b.addBuff(own, { key: 'test:fragment', persist: true, flags: { unblockable: true } });
h.run(4);
assert.ok(far.hp < far.s.maxHp); assert.equal(own.hp, own.s.maxHp);
checkInvariants(h.b); assert.deepEqual(h.b.errors, []);
});
test('Horn changing to melee does not remove splash from an already flying shell', () => {
const h = makeBattle({ autoFinish: false, hooks: ['attack'], captureNoisy: true, units: [{ uid: 1, chessId: 'chess_char_5_08_a', skillIndex: 2, row: 10, col: 3 }],
defs: { enemies: { dummy: enemyRec({ key: 'dummy', hp: 1e7, atk: 0, speed: 0 }) } },
});
h.step(); const u = h.unit(1);
h.b.addBuff(u, { key: 'test:fast', persist: true, mods: { aspd: 500 } });
u.skill.gainSp(999, 'test'); assert.ok(u.skill.activate('test'));
const far = h.spawn('dummy', { pos: [10, 8] }), side = h.spawn('dummy', { pos: [11, 8] });
assert.ok(h.runUntil(() => h.hooksOf('attack').some(c => c.attacker === u), 1));
h.spawn('dummy', { pos: [10, 3] }); h.run(0.4);
assert.ok(h.hooksOf('attack').filter(c => c.attacker === u).length >= 2, 'a melee attack follows before the shell lands');
assert.ok(far.hp < far.s.maxHp); assert.equal(side.s.maxHp - side.hp, far.s.maxHp - far.hp);
checkInvariants(h.b); assert.deepEqual(h.b.errors, []);
});
for (const hidden of [false, true]) test(`${hidden ? 'hidden' : 'final'} Helm actually fires a shell which stuns the target's 3×3 area`, () => {
const rec = (id, atk) => chessRec({ id, stats: { atk, maxHp: 1e7, blockCnt: 0 }, skill: null });
const h = makeBattle({ kind: hidden ? 'hidden' : 'boss', autoFinish: false, timeLimit: 90, flags: { dpInit: 999 },
hooks: ['statusApplied'], defs: { chess: { main: rec('main', 900), bystander: rec('bystander', 0) } },
kits: { main: () => ({ trait: { noAttack: true } }), bystander: () => ({ trait: { noAttack: true } }) },
units: [{ uid: 1, chessId: 'main', row: 10, col: 6 }, { uid: 2, chessId: 'bystander', row: 9, col: 5 },
{ uid: 3, chessId: 'bystander', row: 11, col: 7 }, { uid: 4, chessId: 'bystander', row: 12, col: 3 }],
});
h.step();
const e = h.spawn(hidden ? 'enemy_9013_acstmk_2' : 'enemy_9013_acstmk', { pos: [3, 10], tag: 'boss', mods: { speedMul: 0 } });
e.profile.noAttack = true;
assert.ok(h.runUntil(() => h.b.enemies.some(e => e.defId === 'enemy_9016_acstmr'), 60), 'shell was fired');
assert.ok(h.runUntil(() => h.hooksOf('statusApplied').some(c => c.target === h.unit(1) && c.status === 'stun'), 25), 'shell arrived');
const stunned = new Set(h.hooksOf('statusApplied').filter(c => c.status === 'stun').map(c => c.target.uid));
for (const uid of [1, 2, 3]) assert.ok(stunned.has(uid), `3×3 includes ${uid}`);
assert.ok(!stunned.has(4), 'outside the 3×3'); checkInvariants(h.b); assert.deepEqual(h.b.errors, []);
});
+88
View File
@@ -0,0 +1,88 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, chessRec, checkInvariants } from '../helpers/battleHarness.js';
import { makeMatch } from '../match/harness.js';
import { uniteBattleOpts } from '../../server/match/unite.js';
import { FORCED_EXIT } from '../../server/sim/constants.js';
const egir = (layers = 0, count = 5) => ({ count, active: true, tier: count >= 5 ? 2 : 1, layers });
const close = (a, b) => assert.ok(Math.abs(a - b) < 1e-8, `${a} != ${b}`);
const defs = { chess: Object.fromEntries([['A', 10000], ['B', 7000], ['C', 20000], ['D', 10000], ['E', 10000]]
.map(([id, maxHp]) => [id, chessRec({ id, bonds: ['egirShip'], stats: { atk: 100, def: 0, maxHp, blockCnt: 1 },
skill: { spCost: 30, initSp: 5, duration: 10 } })])) };
const units = [['A', 10, 3], ['B', 10, 4], ['C', 10, 5], ['D', 12, 3], ['E', 12, 5]]
.map(([chessId, row, col], i) => ({ uid: i + 1, kind: 'chess', chessId, row, col, dir: 'RIGHT' }));
function carryInput(t, own, lineup, bonds) {
const m = makeMatch({ mode: 'coop', humans: 3, fake: true }).m;
t.after(() => m.dispose());
const unitsEnd = own.result().perPlayer.p1.unitsEnd;
const ps = { playerId: 'p1', board: new Map(lineup.map(u => [u.uid, { uid: u.uid, kind: 'chess' }])),
battleInput: ({ side, colOffset, carry }) => ({ playerId: 'p1', seat: 0, side, colOffset, bonds,
units: lineup.map(u => ({ ...u, carryState: carry.get(u.uid) })) }) };
const { players } = uniteBattleOpts({ gd: m.gd, round: 3, lastResults: new Map([['p1', { unitsEnd }]]), dispatch() {} },
{ helpers: [ps], leaked: [] }, 60);
return { players, unitsEnd };
}
test('normal result → unite rebuilds Egir attributes but preserves every living HP ratio and SP', t => {
const own = makeBattle({ defs, units, bonds: { egirShip: egir() }, autoFinish: false });
own.step(2); own.b.forceEnd();
const { players, unitsEnd } = carryInput(t, own, units, { egirShip: egir(own.b.getPlayer('p1').bonds.egirShip.layers) });
const next = makeBattle({ kind: 'unite', defs, players, autoFinish: false }); next.b.start();
for (const old of unitsEnd) {
const u = next.unit(old.uid);
assert.equal(u.alive, old.alive);
close(u.hpRatio, old.hpPct); close(u.skill.spTotal, old.sp);
assert.deepEqual(u.findBuff('bond:egir:devour')?.mods, own.unit(old.uid).findBuff('bond:egir:devour')?.mods);
}
assert.equal(next.b.result().perPlayer.p1.deaths, 0);
assert.deepEqual(next.b.result().perPlayer.p1.layerGains, {});
checkInvariants(next.b);
});
test('a previous full-health survivor cannot be newly killed by opening devour during carry restoration', t => {
const weak = { chess: { ...defs.chess, B: { ...defs.chess.B, stats: { ...defs.chess.B.stats, maxHp: 2000 } } } };
const three = units.slice(0, 3).map(u => u.uid === 3 ? { ...u, row: 12, col: 7 } : u);
const own = makeBattle({ defs: weak, units: three, bonds: { egirShip: egir(0, 3) }, autoFinish: false, timeLimit: 120 });
own.run(80); own.b.forceEnd();
assert.equal(own.unit(2).hpRatio, 1, 'food redeployed before its old battle ended');
const { players } = carryInput(t, own, three, { egirShip: egir(own.b.getPlayer('p1').bonds.egirShip.layers, 3) });
const next = makeBattle({ kind: 'unite', defs: weak, players, autoFinish: false }); next.b.start();
assert.equal(next.unit(2).alive, true); assert.equal(next.unit(2).hpRatio, 1);
assert.equal(next.b.result().perPlayer.p1.deaths, 0);
close(next.unit(1).findBuff('bond:egir:devour').mods.atkFinal, 100);
});
test('forced-down carry still contributes devour attributes without consuming any of the three later revives', () => {
const lineup = units.map(u => ({ ...u, carryState: u.uid === 2 ? { down: true } : { hpPct: 0.4, sp: 17 } }));
const next = makeBattle({ kind: 'unite', defs, players: [{ playerId: 'p1', units: lineup, bonds: { egirShip: egir() } }], autoFinish: false });
next.b.start();
assert.equal(next.unit(2).alive, false); assert.equal(next.unit(2).removeReason, FORCED_EXIT);
close(next.unit(1).findBuff('bond:egir:devour').mods.atkFinal, 200);
const survived = [];
for (const uid of [4, 5, 1, 3]) { next.b.loseHp(next.unit(uid), 1e9); survived.push(next.unit(uid).alive); }
assert.deepEqual(survived, [true, true, true, false]);
checkInvariants(next.b);
});
test('carry preserves positive ratios below 1% and fractional HP when the new max HP is smaller', t => {
const lineup = [{ ...units[0], dir: 'UP' }];
const own = makeBattle({ defs, units: lineup, bonds: { egirShip: egir() }, autoFinish: false });
own.b.start(); own.unit(1).hp = 1; own.b.forceEnd();
const { players, unitsEnd } = carryInput(t, own, lineup, {});
close(players[0].units[0].carryState.hpPct, unitsEnd[0].hpPct);
const small = { chess: { A: { ...defs.chess.A, stats: { ...defs.chess.A.stats, maxHp: 1000 } } } };
const next = makeBattle({ kind: 'unite', defs: small, players, autoFinish: false }); next.b.start();
assert.ok(next.unit(1).alive && next.unit(1).hp > 0 && next.unit(1).hp < 1);
close(next.unit(1).hpRatio, unitsEnd[0].hpPct);
});
test('ordinary/boss devour and fresh unite units still receive opening devour damage', () => {
for (const kind of ['normal', 'unite', 'boss', 'hidden']) {
const h = makeBattle({ kind, defs, units, bonds: { egirShip: egir() }, autoFinish: false }); h.b.start();
assert.ok(h.unit(2).hpRatio < 0.5, kind);
assert.ok(h.unit(3).hpRatio < 0.7, kind);
checkInvariants(h.b);
}
});
+45
View File
@@ -0,0 +1,45 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, enemyRec, chessRec, checkInvariants } from '../helpers/battleHarness.js';
import { DREAD_TAG } from '../../server/sim/content/kits/ops/op-ascln.js';
for (const [item, status] of [['5_01', 'palsy'], ['5_02', 'cold']]) for (const quality of ['a', 'b']) {
const id = `chess_item_${item}_e_${quality}`;
test(`${id}: Ascalon poison still procs ${status} while she cannot attack`, () => {
const h = makeBattle({ autoFinish: false, hooks: ['damaged', 'attack', 'statusApplied'], captureNoisy: true,
units: [{ uid: 1, diy: { slot: 6, charId: 'char_4132_ascln', skillIndex: 0 }, row: 10, col: 5, items: [id] }],
defs: { enemies: { dummy: enemyRec({ key: 'dummy', hp: 1e7, atk: 0, speed: 0 }) } },
});
h.step(); const u = h.unit(1), e = h.spawn('dummy', { pos: [10, 6] });
const rng = h.b.rng; h.b.rng = Object.assign(() => 0.999, rng);
assert.ok(h.runUntil(() => h.hooksOf('damaged').some(c => c.source === u && c.dmg.isAttack), 5));
assert.equal(e.findBuff(status), null, 'the attack did not roll the item');
const attacks = h.hooksOf('attack').length;
h.b.addBuff(u, { key: 'test:noAttack', flags: { disarm: true }, duration: 5 });
h.b.rng = Object.assign(() => 0, rng);
assert.ok(h.runUntil(() => h.hooksOf('damaged').some(c => c.dmg.tags?.includes(DREAD_TAG)), 2));
assert.equal(h.hooksOf('attack').length, attacks, 'only the already-applied poison deals damage');
assert.ok(e.findBuff(status), 'a real poison damage tick triggers the item');
checkInvariants(h.b); assert.deepEqual(h.b.errors, []);
});
test(`${id}: only carrier damage to enemies rolls ${status}; gauge, HP loss and bare attacks do not`, () => {
const h = makeBattle({ autoFinish: false, defs: {
chess: { quiet: chessRec({ id: 'quiet', stats: { atk: 0 }, skill: null }) },
enemies: { dummy: enemyRec({ key: 'dummy', hp: 1e7, atk: 0, speed: 0 }) },
}, units: [{ uid: 1, chessId: 'quiet', row: 10, col: 3, items: [id] }, { uid: 2, chessId: 'quiet', row: 10, col: 4 }] });
h.step(); const u = h.unit(1), ally = h.unit(2), e = h.spawn('dummy', { pos: [10, 8] });
h.b.rng = Object.assign(() => 0, h.b.rng);
h.b.emit('attack', { attacker: u, targets: [e] });
h.b.dealDamage(u, e, { amount: 1, type: 'element', element: 'erosion' });
h.b.loseHp(e, 1, { source: u });
h.b.dealDamage(null, e, { amount: 1, type: 'true' });
h.b.dealDamage(ally, e, { amount: 1, type: 'true' });
h.b.dealDamage(u, ally, { amount: 1, type: 'true' });
h.b.dealDamage(u, e, { amount: 0, type: 'true' });
assert.equal(e.findBuff(status), null); assert.equal(ally.findBuff(status), null);
h.b.dealDamage(u, e, { amount: 1, type: 'arts', canDodge: false, tags: ['skill', 'dot'] });
assert.ok(e.findBuff(status));
checkInvariants(h.b); assert.deepEqual(h.b.errors, []);
});
}
+2 -2
View File
@@ -1249,7 +1249,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);
});
@@ -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);
}
});
+94
View File
@@ -0,0 +1,94 @@
// 追凶's first damage instance also carries its arts addition (PRTS 忍冬, talent remarks).
// Real full-content battles with controlled durable enemies; no HP/SP/ATK writes or forced casts.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
const NORMAL = 'chess_char_3_18_a';
const ELITE = 'chess_char_3_18_b';
const ENEMY = 'enemy_vulpis_probe';
const approx = (a, b) => assert.ok(Math.abs(a - b) < 1e-6, `${a} vs ${b}`);
const hunt = (c) => c.dmg?.tags?.includes('vulpisHunt');
const finished = (h) => { assert.deepEqual(h.b.errors, []); checkInvariants(h.b); };
function scenario(entry = {}, enemy = {}) {
return makeBattle({
units: [{ chessId: NORMAL, row: 10, col: 4, ...entry }],
defs: { enemies: { [ENEMY]: enemyRec({ key: ENEMY, hp: 1e7, speed: 0, ...enemy }) } },
autoFinish: false, timeLimit: 40, flags: { startOpCooldown: 3 }, hooks: ['damaged'], captureNoisy: true,
});
}
const variants = [
{ label: 'normal', chessId: NORMAL },
{ label: 'elite without module', chessId: ELITE, moduleId: 'none' },
{ label: 'elite SOL-X', chessId: ELITE, moduleId: 'uniequip_002_vulpis' },
{ label: 'elite SOL-Y', chessId: ELITE, moduleId: 'uniequip_003_vulpis' },
];
for (const { label, ...entry } of variants) for (const skillIndex of [0, 1, 2]) {
test(`追凶: first ordinary hit includes one arts addition (${label}, S${skillIndex + 1})`, () => {
const h = scenario({ ...entry, skillIndex });
h.step();
const u = h.unit(entry.chessId);
if (entry.moduleId) assert.equal(u.def.loadout.moduleId, entry.moduleId, 'the requested module is equipped');
const e = h.spawn(ENEMY, { pos: [10, 5] });
const hp = e.hp;
assert.ok(h.runUntil(() => h.hooksOf('damaged').some((c) => c.source === u), 1));
const hits = h.hooksOf('damaged').filter((c) => c.source === u);
const main = hits.filter((c) => !hunt(c));
assert.equal(main.length, 1, 'one ordinary attack');
assert.equal(main[0].dmg.isSkill, false, 'no skill at the first hit');
assert.equal(main[0].type, 'phys');
assert.ok(e.alive && e.hp > 0, 'the first attack did not kill the target');
const riders = hits.filter(hunt);
assert.equal(riders.length, 1, 'the very first hit carries 追凶');
assert.equal(riders[0].t, main[0].t);
assert.equal(riders[0].type, 'arts');
assert.ok(riders[0].dmg.tags.includes('addition'));
approx(riders[0].amount, u.s.atk * u.def.talents[0].bb.atk_scale);
approx(hp - e.hp, main[0].amount + riders[0].amount);
assert.equal(h.eventsOf('dmg').filter((c) => c[1] === e.id).length, 2, 'both actual damage events exist');
finished(h);
});
}
for (const chessId of [NORMAL, ELITE]) for (const skillIndex of [0, 1, 2]) {
test(`追凶: first skill damage carries its addition (${chessId}, S${skillIndex + 1})`, () => {
const h = scenario({ chessId, skillIndex });
h.run(16); // every selected skill is naturally ready, with no enemies yet
const u = h.unit(chessId);
const e = h.spawn(ENEMY, { pos: [10, skillIndex === 1 ? 7 : skillIndex === 2 ? 6 : 5] });
const hp = e.hp;
assert.ok(h.runUntil(() => h.hooksOf('damaged').some((c) => c.source === u && c.dmg.isSkill), 2));
const hits = h.hooksOf('damaged').filter((c) => c.source === u && c.target === e);
const main = hits.filter((c) => !hunt(c));
assert.ok(main.some((c) => c.dmg.isSkill));
// S1 has a physical attack and a separate arts skill instance; S2/S3 have one main instance each.
assert.equal(main.length, skillIndex === 0 ? 2 : 1);
const riders = hits.filter(hunt);
assert.equal(riders.length, main.length, 'one addition per qualifying damage instance, including the first');
for (const c of riders) approx(c.amount, u.s.atk * u.def.talents[0].bb.atk_scale);
approx(hp - e.hp, hits.reduce((s, c) => s + c.amount, 0));
assert.ok(e.alive && e.hp > 0);
finished(h);
});
}
test('追凶: each enemy starts its own fixed window; repeated damage does not extend it', () => {
const h = scenario();
h.step();
const u = h.unit(NORMAL);
const e = h.spawn(ENEMY, { pos: [10, 5] });
h.run(12);
const main = h.hooksOf('damaged').filter((c) => c.source === u && c.target === e && !hunt(c));
const riders = h.hooksOf('damaged').filter((c) => c.source === u && c.target === e && hunt(c));
assert.ok(main.some((c) => c.t > main[0].t + 10));
assert.equal(riders.length, main.filter((c) => c.t - main[0].t <= 10 + 1e-9).length);
assert.ok(riders.every((c) => c.t - main[0].t <= 10 + 1e-9));
const another = h.spawn(ENEMY, { pos: [10, 6] });
// Naturally ready S3 can reach both enemies; the new target gets its own first-hit addition.
assert.ok(h.runUntil(() => h.hooksOf('damaged').some((c) => c.target === another), 10));
const first = h.hooksOf('damaged').filter((c) => c.target === another);
assert.equal(first.filter(hunt).length, 1);
assert.equal(first.filter(hunt)[0].t, first.find((c) => !hunt(c)).t);
finished(h);
});
+37
View File
@@ -0,0 +1,37 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, chessRec, checkInvariants } from '../helpers/battleHarness.js';
import * as enemiesMod from '../../server/sim/content/enemies.js';
import { canTargetEnemy } from '../../server/sim/targeting.js';
const grid = [[0, 0], [0, 1], [0, 2], [0, 3], [0, 4]];
for (const rule of ['DEFAULT', 'ACTIVE_RANGE', 'SKILL_RANGE', 'SP_FULL']) test(`Xi post-rebirth invincibility prevents attacks; ${rule} retains its own trigger semantics`, () => {
const h = makeBattle({ content: 'generic', extraContent: [enemiesMod], autoFinish: false, captureNoisy: true,
defs: { chess: { probe: chessRec({ id: 'probe', profession: 'SNIPER', projectile: 'none',
stats: { atk: 10, maxHp: 1e7, bat: 1, blockCnt: 0 }, rangeGrid: grid,
skill: { spCost: 2, initSp: 0, duration: 0, trigger: { rule, grid } } }) } },
kits: { probe: () => ({ skill: { duration: 0, spCost: 2, initSp: 0, trigger: { rule, grid }, onStart() {} } }) },
units: [{ chessId: 'probe', row: 10, col: 4 }], enemies: [{ key: 'enemy_1517_xi', pos: [10, 7], mods: { speedMul: 0 } }],
});
h.step(); const u = h.unit('probe'), e = h.enemy('enemy_1517_xi');
h.b.kill(e, null); h.run(4.8);
let arrival = null;
h.b.addProjectile({ from: u, target: e, speed: 3, onHit: ({ battle, target }) => {
arrival = battle.dealDamage(u, target, { type: 'true', amount: 1000 });
} });
h.run(0.4);
assert.equal(e.form, 'form2'); assert.ok(e.s.flags.invulnerable);
assert.equal(canTargetEnemy(u, e, u.profile), false);
const attacks = u.stats.attacks, casts = u.skill.activations, hp = e.hp;
h.run(3);
assert.equal(u.stats.attacks, attacks);
if (rule === 'DEFAULT' || rule === 'ACTIVE_RANGE') assert.equal(u.skill.activations, casts);
else assert.ok(u.skill.activations > casts, 'presence-only and SP-only skills are not forcibly suppressed');
assert.equal(arrival, 0, 'an already flying projectile still does no damage');
assert.equal(h.b.dealDamage(u, e, { type: 'arts', amount: 1000 }), 0);
assert.equal(h.b.dealDamage(u, e, { type: 'element', element: 'neural', amount: 1000 }), 0);
assert.equal(e.hp, hp);
h.run(7.2);
assert.equal(canTargetEnemy(u, e, u.profile), true); assert.ok(u.stats.attacks > attacks);
checkInvariants(h.b); assert.deepEqual(h.b.errors, []);
});
+2 -2
View File
@@ -290,8 +290,8 @@ test('lost models, live LP, detail card order, static game data (user playtest #
assert.match(DESIGN, /official `eventTypeIcon` \(`detailPanel\.js garrisonTypeIconKey`/);
assert.match(DESIGN, /pendingLp\? \/\* COMBAT \/ 联防 of a normal round/);
assert.match(DESIGN, /`ownLeaks\(local, server\)`/);
assert.match(PLAYING, /顶栏的目标生命值会\*\*立即\*\*显示扣除后的数值/);
assert.match(README, /漏怪时顶栏的目标生命值实时减少/);
assert.match(PLAYING, /顶栏的目标生命值\*\*先不变\*\*.*结算时才显示扣除后的数值.*GitHub #479/);
assert.match(README, /漏怪时顶栏用红字 \+ 「−N」标记待扣的目标生命值/);
});
test('user playtest #4 (DESIGN §18): picking by tile, timers, 机变 two taps, down / element state, content — code and every doc agree', () => {
+37 -38
View File
@@ -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",
@@ -1285,20 +1284,20 @@
["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",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,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",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,150,150,266,266,0,19,19,7,4,3,6742,29,17,17,0,1,20,2,0,0],
"events": {"atk":183,"deploy":9,"die":19,"dmg":264,"engage":8,"fx":38,"layer":2,"skill":11,"spawn":28,"status":92},
"fx": {"aoe":1,"camouflage":1,"counter":3,"displace":16,"dp":2,"garrison":2,"liberate":2,"phase":3,"ripple":4,"sermon":1,"shield":2,"wolfShadow":1},
"snaps": "a9326071 ba5f4858 674158f2 668d5d48 ab9377c5"
},
"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",
@@ -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",
+207 -209
View File
@@ -144,42 +144,42 @@
"killed": 8,
"total": 8,
"unspawned": 0,
"bossHpLeft": 5119551,
"rngDraws": 825,
"bossHpLeft": 5151915,
"rngDraws": 834,
"errors": {"count":0,"kinds":[]},
"players": {
"p1": {"killed":8,"total":8,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":825320,"bossDmg":458231,"heal":21045,"deaths":1,"layerGains":{}},
"p2": {"killed":0,"total":0,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":917367,"bossDmg":419879,"heal":6188,"deaths":2,"layerGains":{}}
"p1": {"killed":8,"total":8,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":798821,"bossDmg":425815,"heal":21045,"deaths":1,"layerGains":{}},
"p2": {"killed":0,"total":0,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":911503,"bossDmg":419932,"heal":6270,"deaths":2,"layerGains":{}}
},
"units": [
["p1",1,"op","chess_char_5_19_b",2,"uniequip_002_mlynar",4297.7,1,9,0,266158,2455,463,74,3],
["p1",2,"op","chess_char_4_01_b",2,"uniequip_002_rmixer",6030.59,1,11,0,8108,15611,8378,34,1],
["p1",3,"op","chess_char_5_14_b",1,"none",2391.4,1,7,0,35553,997,272,17,13],
["p1",4,"op","chess_char_5_17_b",1,"uniequip_002_f12yin",3011.8,1,13,0,6024,0,0,20,1],
["p1",5,"op","chess_char_5_15_b",1,"uniequip_002_thorn2",1287,1,1,0,182518,0,0,72,14],
["p1",1,"op","chess_char_5_19_b",2,"uniequip_002_mlynar",4297.7,1,9,0,266158,2455,745,74,3],
["p1",2,"op","chess_char_4_01_b",2,"uniequip_002_rmixer",6030.59,1,11,0,8108,15611,5431,34,1],
["p1",3,"op","chess_char_5_14_b",1,"none",2391.4,1,7,0,25560,997,272,17,10],
["p1",4,"op","chess_char_5_17_b",1,"uniequip_002_f12yin",3011.8,1,13,0,10666,0,0,20,1],
["p1",5,"op","chess_char_5_15_b",1,"uniequip_002_thorn2",1287,1,1,0,147722,0,0,72,11],
["p1",6,"op","chess_char_5_20_b",2,"uniequip_002_aglina",2660.35,1,3,0,89986,0,0,44,4],
["p1",7,"op","chess_char_5_21_b",1,"uniequip_002_sntlla",3046.23,1,17,1,59635,1709,1709,103,3],
["p1",8,"op","chess_char_5_22_b",1,"uniequip_002_nymph",1772.1,1,43,1,128594,2045,273,50,16],
["p1",9,"op","chess_char_5_23_b",1,"uniequip_002_reckpr",1771,1,5,0,0,0,9950,16,3],
["p1",7,"op","chess_char_5_21_b",1,"uniequip_002_sntlla",3046.23,1,17,1,60191,1709,1709,103,3],
["p1",8,"op","chess_char_5_22_b",1,"uniequip_002_nymph",1772.1,1,43,1,141685,2045,273,44,16],
["p1",9,"op","chess_char_5_23_b",1,"uniequip_002_reckpr",1771,1,5,0,0,0,12615,16,2],
["p1",10,"op","chess_char_4_02_b",2,"uniequip_002_mostma",1864.5,1,15,0,48745,0,0,34,2],
["p2",11,"op","chess_char_4_11_b",1,"uniequip_002_cathy",1093.92,1,49,0,0,6969,1080,0,1],
["p2",12,"op","chess_char_4_04_b",1,"uniequip_002_ines",3900.44,1,48,2,298987,0,0,273,7],
["p2",13,"op","chess_char_4_07_b",1,"uniequip_002_bpipe",1393.95,1,8,0,6851,2741,1513,24,8],
["p2",14,"op","chess_char_4_16_b",2,"uniequip_002_texas2",2117.31,1,51,3,134890,3198,1245,171,12],
["p2",13,"op","chess_char_4_07_b",1,"uniequip_002_bpipe",1393.95,1,8,0,6240,2741,1513,24,8],
["p2",14,"op","chess_char_4_16_b",2,"uniequip_002_texas2",2198.65,1,51,3,134890,3198,1326,171,12],
["p2",15,"op","chess_char_4_09_b",1,"uniequip_002_mizuki",2752.8,1,10,0,103788,1136,833,77,5],
["p2",16,"op","chess_char_4_06_b",1,"uniequip_002_kroos2",1717.1,1,2,1,192768,0,0,245,3],
["p2",16,"op","chess_char_4_06_b",1,"uniequip_002_kroos2",1717.1,1,2,1,183825,0,0,248,3],
["p2",17,"op","chess_char_4_10_b",1,"uniequip_002_aroma",1830.4,1,4,0,0,0,0,0,0],
["p2",18,"op","chess_char_4_12_b",2,"uniequip_002_glady",2455.2,1,18,0,59278,1515,1515,50,3],
["p2",19,"op","chess_char_4_13_b",1,"uniequip_002_gnosis",2187.9,1,40,0,54119,2190,2,60,16],
["p2",18,"op","chess_char_4_12_b",2,"uniequip_002_glady",2455.2,1,18,0,69310,1515,1515,56,3],
["p2",19,"op","chess_char_4_13_b",1,"uniequip_002_gnosis",2187.9,1,40,0,47776,2190,2,53,14],
["p2",20,"op","chess_char_4_14_b",1,"uniequip_002_lionhd",1788.6,1,6,0,66688,0,0,47,13],
["p2",21,"token","token_10041_cathy_catsld",null,null,100,1,21,0,0,0,0,0,null],
["p2",22,"token","token_10041_cathy_catsld",null,null,100,1,22,0,0,0,0,0,null]
],
"enemies": {"enemy_1005_yokai_2":[6,6,0,3548,62190],"enemy_1430_lrrook":[2,2,0,0,802449],"enemy_9017_achunt":[2,0,0,37019,880449]},
"hooks": [1,49,6000,1363,1401,2938,2977,4532,13,25,128,122,22,37234,997,0,10,0,1,13,0,11,0],
"events": {"atk":1400,"deploy":39,"die":25,"dmg":2967,"engage":17,"fx":786,"heal":36,"skill":250,"spawn":32,"status":502},
"fx": {"aoe":34,"appear":2,"blink":14,"burst":11,"catShield":1,"charge":8,"counter":22,"crit":92,"dilemma":5,"disappear":2,"explosion":13,"frostNova":16,"garrisonGrant":1,"iceSpike":89,"overclock":1,"pull":3,"quadShot":2,"ripple":20,"sentry":5,"sermon":1,"shadowWeave":9,"shield":5,"soul":3,"sp":14,"stealth":7,"swordRain":92,"swordStorm":12,"tornado":3,"tornadoPulse":15,"weightless":4,"zone":280},
"snaps": "48098e21 84cc8d09 9e22eede 8777b0de 5b2c7c4f 4969a87a 300e3231 8f2aae8d 594d2add 27a55abf 76ee1392 de169eb6 deea8c8f 23e8c816 27aabce9 ef0f6997 a7a322da 22b337fc 0b0aabbd f290764e"
"enemies": {"enemy_1005_yokai_2":[6,6,0,3548,62190],"enemy_1430_lrrook":[2,2,0,0,802449],"enemy_9017_achunt":[2,0,0,37019,848085]},
"hooks": [1,49,6000,1359,1398,2874,2913,4613,13,25,119,114,22,36850,972,0,10,0,1,13,0,11,0],
"events": {"atk":1396,"deploy":39,"die":25,"dmg":2903,"engage":17,"fx":722,"heal":36,"skill":233,"spawn":32,"status":511},
"fx": {"aoe":30,"appear":2,"blink":14,"burst":11,"catShield":1,"charge":8,"counter":22,"crit":101,"dilemma":5,"disappear":2,"explosion":13,"frostNova":14,"garrisonGrant":1,"iceSpike":89,"overclock":1,"pull":3,"quadShot":2,"ripple":20,"sentry":5,"sermon":1,"shadowWeave":9,"shield":5,"soul":3,"sp":7,"stealth":7,"swordRain":92,"swordStorm":12,"tornado":3,"tornadoPulse":15,"weightless":4,"zone":220},
"snaps": "48098e21 84cc8d09 9e22eede 8777b0de 5b2c7c4f 4969a87a 300e3231 8f2aae8d 4263d20f ac9beb19 ef6d231a cef39f5d 4a5af757 e2b11181 12bc00c2 a0d267f9 d1c2531d 0871c5d6 0022566b f70f7ef5"
},
"boss-boss_2-solo": {
"about": "假想敌:铳 solo pool 3000000: chess_char_4_17_b chess_char_4_18_b chess_char_4_23_b chess_char_4_19_b chess_char_4_22_b chess_char_4_20_b chess_char_4_21_b chess_char_4_24_b chess_char_4_25_b chess_char_4_26_b",
@@ -227,112 +227,109 @@
"round": 14,
"seed": 1428555614,
"waveId": "act1autochess_h07_03",
"time": 193.03,
"ticks": 5791,
"time": 169.2,
"ticks": 5076,
"reason": "cleared",
"killed": 19,
"killed": 18,
"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":18,"total":6,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":1516502,"bossDmg":1398136,"heal":10352,"deaths":1,"layerGains":{}},
"p2": {"killed":0,"total":0,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":2667879,"bossDmg":2601864,"heal":41836,"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,6873,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,29,1184,0,29,1],
["p1",4,"op","chess_char_6_01_b",2,"uniequip_002_lemuen",2344.65,1,13,5,815234,3173,0,52,7],
["p1",5,"op","chess_char_6_02_b",2,"uniequip_002_sbell2",2106.51,1,17,0,248050,6003,751,41,2],
["p1",6,"op","chess_char_6_04_b",2,"uniequip_002_skadi2",162.95,1,59,1,26550,16018,1505,0,3],
["p1",7,"op","chess_char_6_05_b",2,"uniequip_002_pasngr",1664.48,1,21,1,284438,9843,1204,82,7],
["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,476210,0,0,176,3],
["p2",13,"op","chess_char_6_19_b",2,"uniequip_002_blkkgt",3043.7,1,62,1,28,3044,0,9,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,279951,2036,0,70,3],
["p2",16,"op","chess_char_6_12_b",1,"uniequip_002_rosmon",3493.03,1,16,4,223610,3571,443,62,3],
["p2",17,"op","chess_char_6_13_b",1,"uniequip_002_angel2",2680.15,1,20,3,1466393,5212,1901,316,5],
["p2",18,"op","chess_char_6_14_b",2,"uniequip_002_lumen",2297.79,1,22,0,0,3550,6225,52,1],
["p2",19,"op","chess_char_6_16_b",2,"uniequip_002_halo2",1741.19,1,25,1,72989,1414,0,121,4],
["p2",20,"op","chess_char_6_18_b",2,"uniequip_002_whitw2",1695.1,1,58,1,122067,1695,0,22,2],
["p2",21,"op","chess_char_6_20_b",2,"uniequip_002_agoat2",1828.31,1,18,0,0,1775,32488,54,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,38205,0,0,76,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],
["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,6,0,51353,291]
},
"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,5076,1385,1385,3756,4577,2685,23,35,61,57,333,38781,1915,4,21,0,1,22,0,8,1],
"events": {"atk":1650,"deploy":38,"die":35,"dmg":4308,"engage":17,"fx":1132,"heal":400,"skill":118,"spawn":57,"status":797},
"fx": {
"airstrike": 116,
"airstrike": 109,
"appear": 1,
"beam": 11,
"beam": 10,
"blink": 2,
"bombard": 39,
"bombardShell": 39,
"bombard": 56,
"bombardShell": 57,
"buff": 3,
"burst": 17,
"camouflage": 1,
"burst": 8,
"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": 368,
"extraAttack": 3,
"finale": 1,
"firewall": 1,
"garrisonGrant": 1,
"lightning": 79,
"link": 8,
"lock": 39,
"lightning": 76,
"link": 7,
"lock": 60,
"lure": 2,
"phase": 44,
"phase": 35,
"radiantSword": 3,
"scorchBurst": 4,
"scorchBurst": 1,
"slash": 11,
"snow": 36,
"snow": 35,
"split": 4,
"steal": 6,
"steal": 5,
"storm": 7,
"summon": 16,
"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 042e449b f5822070 07445b3e ac69ddc0 37b52b7d c59632d4 a8b2eca1 6bbfab6e afcc528d e64bf70f bd7aa0ef 2bd32f6f"
},
"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,37 +518,37 @@
"round": 14,
"seed": 2302764647,
"waveId": "act2autochess_h07_05",
"time": 102.27,
"ticks": 3068,
"time": 102.9,
"ticks": 3087,
"reason": "cleared",
"killed": 16,
"total": 8,
"unspawned": 0,
"bossHpLeft": 0,
"rngDraws": 874,
"rngDraws": 908,
"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":16,"total":8,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":1233427,"bossDmg":928200,"heal":24829,"deaths":1,"layerGains":{}},
"p2": {"killed":0,"total":0,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":1040342,"bossDmg":631800,"heal":41304,"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",4,"op","chess_char_6_01_b",2,"uniequip_002_lemuen",1447.37,1,1,3,514889,991,821,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",6,"op","chess_char_6_04_b",2,"uniequip_002_skadi2",1757.8,1,3,0,4001,5151,438,0,2],
["p1",7,"op","chess_char_6_05_b",2,"uniequip_002_pasngr",1586.2,1,18,0,141216,3329,1053,18,5],
["p1",8,"op","chess_char_6_08_b",2,"uniequip_002_reed2",1834.8,1,5,0,74259,868,10288,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",10,"op","chess_char_6_11_b",2,"uniequip_002_mlyss",2082.3,1,9,0,52150,6246,961,65,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],
["p1",12,"token","token_10030_mlyss_wtrman",null,null,1668,1,10,0,35402,874,464,52,1],
["p2",13,"op","chess_char_6_15_b",2,"uniequip_002_qiubai",4181.76,1,55,4,378608,9705,860,100,3],
["p2",14,"op","chess_char_6_19_b",2,"uniequip_002_blkkgt",2069.61,1,13,2,128245,3313,0,22,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",16,"op","chess_char_6_17_b",2,"uniequip_002_nearl2",6521.3,1,53,1,164109,10334,0,40,2],
["p2",17,"op","chess_char_6_12_b",1,"uniequip_002_rosmon",5557.09,1,2,0,150406,888,888,18,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],
@@ -569,11 +566,11 @@
"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_2016_csphtm": [2,2,0,67552,1560000],
"enemy_2017_csphts": [8,8,0,1223,343985]
},
"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,54,3087,673,673,1759,2039,3851,21,34,60,52,50,26592,1290,30,18,0,1,19,0,17,86],
"events": {"atk":826,"deploy":36,"die":34,"dmg":1888,"engage":22,"fx":534,"heal":254,"skill":112,"spawn":47,"status":632},
"fx": {
"airstrike": 15,
"blink": 15,
@@ -582,8 +579,8 @@
"burst": 17,
"copy": 1,
"displace": 4,
"dodge": 81,
"dp": 2,
"dodge": 86,
"dp": 3,
"drone": 102,
"droneLock": 3,
"drones": 1,
@@ -592,7 +589,7 @@
"finale": 2,
"firewall": 1,
"garrisonGrant": 1,
"lightning": 43,
"lightning": 45,
"link": 3,
"lock": 33,
"lure": 2,
@@ -612,7 +609,7 @@
"tide": 2,
"wanted": 11
},
"snaps": "d3cd88f1 0b40f2d2 3fada850 9d166f76 eb1c5713 8daf7ec1 58aeec40 fd26682e 39226066 3ce9d7e2 8cf2e7c7"
"snaps": "d3cd88f1 0b40f2d2 3fada850 9d166f76 eb1c5713 8daf7ec1 58aeec40 fd26682e adab14c8 5be402ff eb823ff1"
},
"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 +626,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 +648,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",
@@ -922,94 +919,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",
@@ -1192,36 +1190,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",
+226 -226
View File
File diff suppressed because it is too large. Load diff
+378 -378
View File
File diff suppressed because it is too large. Load diff
+57 -57
View File
@@ -303,49 +303,49 @@
"round": 10,
"seed": 1442657168,
"waveId": "act1autochess_h03",
"time": 187.4,
"ticks": 5622,
"time": 188.1,
"ticks": 5643,
"reason": "cleared",
"killed": 30,
"killed": 26,
"total": 98,
"unspawned": 0,
"bossHpLeft": null,
"rngDraws": 315,
"rngDraws": 260,
"errors": {"count":0,"kinds":[]},
"players": {
"p1": {"killed":30,"total":98,"perfect":false,"leaks":68,"leaksUncounted":0,"coins":6,"dmg":472224,"bossDmg":0,"heal":15707,"deaths":31,"layerGains":{"raidShip":24}}
"p1": {"killed":26,"total":98,"perfect":false,"leaks":72,"leaksUncounted":0,"coins":2,"dmg":365014,"bossDmg":0,"heal":14451,"deaths":30,"layerGains":{"raidShip":26}}
},
"units": [
["p1",1,"op","chess_char_3_12_b",2,"none",1904,1,139,4,33527,4810,222,6,1],
["p1",2,"op","chess_char_4_09_b",0,"none",0,0,6,0,13369,3510,0,5,1],
["p1",3,"op","chess_char_3_05_b",2,"none",0,0,90,1,32273,11344,0,20,2],
["p1",4,"op","chess_char_3_18_b",2,"none",0,0,12,1,32236,5744,2553,22,1],
["p1",5,"op","chess_char_4_04_b",1,"none",0,0,138,0,13918,12430,0,38,9],
["p1",6,"op","chess_char_4_07_b",1,"none",0,0,93,0,13096,7538,0,19,1],
["p1",7,"op","chess_char_4_16_b",1,"none",0,0,137,3,45194,14837,0,51,13],
["p1",8,"op","chess_char_3_01_b",2,"none",0,0,132,6,26811,3020,0,27,1],
["p1",9,"op","chess_char_3_04_b",2,"none",0,0,4,0,0,3135,12932,15,1],
["p1",10,"op","chess_char_3_21_b",2,"none",0,0,113,10,201155,6858,0,55,1],
["p1",11,"op","chess_char_4_02_b",1,"none",0,0,2,2,47025,1483,0,25,1],
["p1",12,"op","chess_char_4_13_b",1,"none",0,0,101,3,13621,2704,0,23,1]
["p1",1,"op","chess_char_3_12_b",2,"none",0,0,141,4,34546,8030,567,13,2],
["p1",2,"op","chess_char_4_09_b",0,"none",0,0,6,1,4723,3510,0,2,1],
["p1",3,"op","chess_char_3_05_b",2,"none",0,0,95,2,10329,8035,0,8,1],
["p1",4,"op","chess_char_3_18_b",2,"none",0,0,107,1,28674,9956,3574,31,2],
["p1",5,"op","chess_char_4_04_b",1,"none",1181,1,143,0,23896,8829,0,32,9],
["p1",6,"op","chess_char_4_07_b",1,"none",0,0,92,0,10794,10491,0,13,1],
["p1",7,"op","chess_char_4_16_b",1,"none",1370,1,142,5,49422,13987,0,51,16],
["p1",8,"op","chess_char_3_01_b",2,"none",0,0,126,3,12357,3020,0,28,1],
["p1",9,"op","chess_char_3_04_b",2,"none",0,0,4,0,0,1374,10311,10,1],
["p1",10,"op","chess_char_3_21_b",2,"none",0,0,5,8,161347,7970,0,41,1],
["p1",11,"op","chess_char_4_02_b",1,"none",0,0,2,1,21755,1483,0,24,1],
["p1",12,"op","chess_char_4_13_b",1,"none",0,0,93,1,7170,2704,0,14,1]
],
"enemies": {
"enemy_10162_mnctpt": [1,0,1,12236,28023],
"enemy_1019_jshoot": [1,1,0,769,4479],
"enemy_1019_jshoot_2": [1,1,0,1779,6718],
"enemy_1021_bslime": [1,1,0,2095,6121],
"enemy_1023_jmage": [1,1,0,2403,5474],
"enemy_1025_reveng": [1,1,0,0,12442],
"enemy_1026_aghost": [1,1,0,0,9456],
"enemy_10162_mnctpt": [1,0,1,24498,31642],
"enemy_1019_jshoot": [1,1,0,1057,4479],
"enemy_1019_jshoot_2": [1,1,0,1489,6718],
"enemy_1021_bslime": [1,1,0,0,6121],
"enemy_1023_jmage": [1,0,1,1739,4719],
"enemy_1025_reveng": [1,0,1,0,0],
"enemy_1026_aghost": [1,0,1,0,0],
"enemy_1043_zomsbr": [1,0,1,0,0],
"enemy_1251_lysyta_2": [57,18,39,8755,173039],
"enemy_1252_lysytb_2": [21,5,16,29535,174396],
"enemy_1329_cbshld_2": [12,1,11,19842,52076]
"enemy_1251_lysyta_2": [57,19,38,10100,171673],
"enemy_1252_lysytb_2": [21,3,18,22220,98462],
"enemy_1329_cbshld_2": [12,1,11,18287,41199]
},
"hooks": [1,139,5622,474,474,678,694,42,61,138,33,33,0,6117,24,44,98,68,1,66,3,0,7],
"events": {"atk":549,"bounty":3,"deploy":41,"die":70,"dmg":694,"engage":11,"fx":35,"heal":40,"layer":3,"leak":68,"skill":66,"spawn":110,"status":83},
"fx": {"blink":9,"camouflage":1,"dodge":8,"dp":5,"explode":1,"garrison":3,"slash":8},
"snaps": "be6d1821 33db37f0 a6ec1fa2 487058c3 2aef7534 379bf51a d87bcaba 6953291e ea8de47f e0cfe2ef c5d6d6f0 9fd5858c 99048d37 dfd46fb1 d86de307 637bf595 72c11ba9 adb364dc bb18d903"
"hooks": [1,143,5643,434,434,611,621,53,56,141,37,36,0,5387,16,38,98,72,1,76,4,0,5],
"events": {"atk":484,"bounty":1,"deploy":45,"die":69,"dmg":621,"engage":11,"fx":47,"heal":49,"layer":4,"leak":72,"skill":73,"spawn":110,"status":72},
"fx": {"blink":13,"camouflage":1,"dodge":6,"dp":6,"explode":1,"garrison":4,"slash":16},
"snaps": "be6d1821 a39efdfb 21249099 b87f9e0b e72ea2bf 19eea832 a489c558 7e2006d5 2678e482 1cfd1c39 6932b88d 1754c6ee bf334ae2 ab95ad5f 981485c7 305e6e06 6a8a10b3 af7b8b0c c92211d8"
},
"standin-07": {
"about": "elite band 6 as stand-ins: chess_char_4_17_b→char_610_acfend chess_char_4_18_b→char_602_cdfend chess_char_5_05_b→char_602_cdfend chess_char_4_19_b→char_608_acpion chess_char_5_01_b→char_600_cpione chess_char_4_22_b→char_609_acguad chess_char_4_23_b→char_609_acguad chess_char_5_06_b→char_609_acguad chess_char_4_20_b→char_603_csnipe chess_char_4_24_b→char_604_ccast chess_char_5_02_b→char_604_ccast chess_char_5_03_b→char_612_accast",
@@ -359,7 +359,7 @@
"time": 223.2,
"ticks": 6696,
"reason": "cleared",
"killed": 10,
"killed": 11,
"total": 80,
"unspawned": 0,
"bossHpLeft": null,
@@ -367,13 +367,13 @@
"errors": {"count":0,"kinds":[]},
"players": {
"p1": {
"killed": 10,
"killed": 11,
"total": 80,
"perfect": false,
"leaks": 70,
"leaks": 69,
"leaksUncounted": 0,
"coins": 4,
"dmg": 144421,
"dmg": 165203,
"bossDmg": 0,
"heal": 0,
"deaths": 25,
@@ -381,38 +381,38 @@
}
},
"units": [
["p1",1,"op","chess_char_4_17_b",1,"none",0,0,3,0,189,3211,0,1,1],
["p1",2,"op","chess_char_4_18_b",1,"none",0,0,86,0,60,9306,0,2,1],
["p1",1,"op","chess_char_4_17_b",1,"none",0,0,3,0,210,3211,0,2,1],
["p1",2,"op","chess_char_4_18_b",1,"none",0,0,86,0,404,9306,0,2,1],
["p1",3,"op","chess_char_5_05_b",2,"none",0,0,100,0,572,5816,0,1,1],
["p1",4,"op","chess_char_4_19_b",1,"none",1847.63,1,110,1,466,3864,0,8,1],
["p1",4,"op","chess_char_4_19_b",1,"none",1847.63,1,110,0,1427,3864,0,9,1],
["p1",5,"op","chess_char_5_01_b",2,"none",1036.53,1,108,0,2197,3985,0,5,3],
["p1",6,"op","chess_char_4_22_b",1,"none",3191,1,112,0,1090,14040,0,8,1],
["p1",7,"op","chess_char_4_23_b",1,"none",0,0,71,0,3484,6865,0,8,1],
["p1",6,"op","chess_char_4_22_b",1,"none",3191,1,112,0,1918,14040,0,9,1],
["p1",7,"op","chess_char_4_23_b",1,"none",0,0,71,0,4833,6856,0,9,1],
["p1",8,"op","chess_char_5_06_b",2,"uniequip_002_acguad",0,0,10,0,0,3820,0,0,0],
["p1",9,"op","chess_char_4_20_b",1,"none",1255.06,1,109,2,15866,4785,0,30,1],
["p1",10,"op","chess_char_4_24_b",1,"none",1007.59,1,111,1,5309,3072,0,18,1],
["p1",11,"op","chess_char_5_02_b",2,"none",0,0,105,1,8684,4080,0,19,1],
["p1",12,"op","chess_char_5_03_b",2,"uniequip_002_accast",0,0,4,5,106504,1749,0,10,1],
["p1",9,"op","chess_char_4_20_b",1,"none",1255.06,1,109,2,17110,4785,0,31,1],
["p1",10,"op","chess_char_4_24_b",1,"none",1007.59,1,111,0,5710,3072,0,20,1],
["p1",11,"op","chess_char_5_02_b",2,"none",0,0,105,1,8810,4080,0,19,1],
["p1",12,"op","chess_char_5_03_b",2,"uniequip_002_accast",0,0,4,8,122012,1749,0,10,1],
["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null],
["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null]
],
"enemies": {
"enemy_1005_yokai_2": [18,4,14,3323,11182],
"enemy_1041_lazerd_2": [24,3,21,42917,34367],
"enemy_1042_frostd": [30,1,29,0,33047],
"enemy_1045_hammer": [1,1,0,2515,15552],
"enemy_1045_hammer_2": [1,0,1,5062,16522],
"enemy_1046_agent": [1,1,0,57,3888],
"enemy_1050_lslime": [1,0,1,8323,7921],
"enemy_1061_zomshd": [1,0,1,61,13892],
"enemy_1062_rager_2": [1,0,1,2335,17050],
"enemy_1005_yokai_2": [18,4,14,3014,9642],
"enemy_1041_lazerd_2": [24,4,20,41823,46999],
"enemy_1042_frostd": [30,1,29,0,34310],
"enemy_1045_hammer": [1,1,0,1699,15552],
"enemy_1045_hammer_2": [1,0,1,4725,19083],
"enemy_1046_agent": [1,1,0,72,3888],
"enemy_1050_lslime": [1,0,1,9832,11882],
"enemy_1061_zomshd": [1,0,1,92,15423],
"enemy_1062_rager_2": [1,0,1,3328,18424],
"enemy_1067_snslime": [1,0,1,0,0],
"enemy_1071_dftman": [1,0,1,0,0]
},
"hooks": [1,112,6696,302,302,560,551,566,35,105,13,13,0,9244,8,13,80,70,1,35,2,0,9],
"events": {"atk":326,"bounty":2,"deploy":30,"die":35,"dmg":551,"engage":11,"fx":25,"layer":2,"leak":70,"skill":26,"spawn":94,"status":113},
"fx": {"aoe":1,"dodge":9,"dp":7,"garrison":2,"garrisonGrant":1,"palsy":2,"revive":3},
"snaps": "801e90ec c1cca6cb 81d33420 f867f133 edacd763 1628b012 6c63447d 731a52ce e4d1fee0 1aa30026 5afb0d3a 3292f917 f65a695f 7f9749da 215a493b 0a35d6fd 8300297d 71eb2c83 2ae65721 93a77bf5 f413d9ea 3b2f4b8c ef24d1bf"
"hooks": [1,112,6696,313,313,621,613,584,36,105,13,13,0,9350,10,16,80,69,1,36,2,0,8],
"events": {"atk":340,"bounty":2,"deploy":30,"die":36,"dmg":613,"engage":11,"fx":22,"layer":2,"leak":69,"skill":26,"spawn":94,"status":119},
"fx": {"aoe":1,"dodge":8,"dp":6,"garrison":2,"garrisonGrant":1,"palsy":1,"revive":3},
"snaps": "801e90ec 5b00cfa7 02fcc988 d17a26a7 7d9490e3 7c20747e 258b5555 d69fd966 78a9129e f1c1c23d 005c3248 a96fccfa 482d2cd2 45b5fc41 ee00fc07 df8408ae 69422636 2d376e7f 1809c675 8e61a10d 5a971160 902deae3 ff470735"
},
"standin-08": {
"about": "elite band 6 as stand-ins: chess_char_5_08_b→char_610_acfend chess_char_5_14_b→char_608_acpion chess_char_5_19_b→char_617_sharp2 chess_char_5_07_b→char_601_cguard chess_char_5_13_b→char_609_acguad chess_char_5_17_b→char_615_acspec chess_char_5_10_b→char_606_csuppo chess_char_5_11_b→char_613_acmedc chess_char_5_12_b→char_614_acsupo chess_char_5_15_b→char_603_csnipe chess_char_5_18_b→char_611_acnipe chess_char_5_20_b→char_614_acsupo",
+23
View File
@@ -91,6 +91,29 @@ test('build-i18n matching: exact, blackboard templates, plain from raw, composit
assert.equal(shapeKey('攻击力<@ba.vup>+{atk:0%}</>'), shapeKey('攻击力+80%'));
});
test('build-i18n corrections: tried before the official text, the plain twin is stripped from the corrected raw one', () => {
const zh = [{ t: { a: '攻击力<@ba.vup>+50%</>' } }];
const en = [{ t: { a: 'ATK <@ba.vup>+50%</> flat' } }];
const data = { effects: { e1: { descRaw: '攻击力<@ba.vup>+50%</>', desc: '攻击力+50%' } } };
const { overlay, report } = buildOverlay({ zh, en, data, corrections: { _about: 'x', '攻击力<@ba.vup>+50%</>': 'ATK <@ba.vup>+50%</> per item' } });
const e = applyRecordOverlay(data.effects.e1, overlay.files.effects.e1).value;
assert.equal(e.descRaw, 'ATK <@ba.vup>+50%</> per item');
assert.equal(e.desc, 'ATK +50% per item');
assert.equal(report.coverage.effects.correction, 2);
assert.equal(report.coverage.effects.translated, 2);
// another language reads its own corrections the same way (build-i18n loads tools/i18n/corrections-<lang>.json)
const ko = buildOverlay({ zh, en: [{ t: { a: '공격력 <@ba.vup>+50%</>' } }], data, lang: 'ko', corrections: { '攻击力<@ba.vup>+50%</>': '장비 1개당 공격력 <@ba.vup>+50%</>' } });
assert.equal(applyRecordOverlay(data.effects.e1, ko.overlay.files.effects.e1).value.descRaw, '장비 1개당 공격력 <@ba.vup>+50%</>');
assert.equal(ko.report.coverage.effects.correction, 2);
});
test('data/i18n/en.json: the 维多利亚 6-member ATK bonus reads per item, as installVictoria applies it (corrections-en.json)', () => {
const en = JSON.parse(readFileSync(path.join(ROOT, 'data', 'i18n', 'en.json'), 'utf8'));
for (const t of [en.files.bonds.victoriaShip.effectDesc, en.files.bonds.victoriaShip.effectDescRaw, en.files.effects.bondeffect_victoria.desc]) {
assert.match(t, /\+50%(?:<\/>)? ATK per equipment carried, or (?:<@ba\.vup>)?\+80%(?:<\/>)? per upgraded equipment/);
}
});
test('data/i18n/en.json: shape, the 盟约 season of the EN build, coverage and the names map', () => {
assert.equal(OVERLAY.version, 1);
assert.equal(OVERLAY.lang, 'en');
+40
View File
@@ -0,0 +1,40 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeMatch } from './harness.js';
import { BOND_LAYER_CAP, PHASE } from '../../shared/constants.js';
const cues = h => h.sent.filter(([, msg]) => msg.t === 'm.layer');
function prep(t) {
const h = makeMatch({ mode: 'coop', humans: 2, fake: true }).start().toPrep(1);
t.after(() => h.m.dispose());
h.sent.length = 0;
return h;
}
test('prep gains unicast the actual increase once, even for an inactive bond', t => {
const h = prep(t), p = h.ps('p_0');
assert.equal(p.bonds.sargonShip?.active ?? false, false);
assert.equal(p.addLayers('sargonShip', 3), 3);
assert.deepEqual(cues(h), [['p_0', { t: 'm.layer', playerId: 'p_0', bondId: 'sargonShip', add: 3 }]]);
assert.equal(h.bc.some(msg => msg.t === 'm.layer'), false);
p.layers.sargonShip = BOND_LAYER_CAP - 1;
p.addLayers('sargonShip', 10);
assert.equal(cues(h).at(-1)[1].add, 1);
const n = cues(h).length;
for (const v of [1, 0, -2, NaN, Infinity]) p.addLayers('sargonShip', v);
p.addLayers('not-a-bond', 2);
p.addLayers('kjeragShip', 2, { requireActive: true });
assert.equal(cues(h).length, n);
});
test('only live prep phases emit, never settlement/backfill or resynchronization views', t => {
const h = prep(t), p = h.ps('p_0');
for (const phase of Object.values(PHASE)) {
h.m.phase = phase; h.sent.length = 0;
p.addLayers('sargonShip', 1);
assert.equal(cues(h).length, [PHASE.ROUND_START, PHASE.SP_DRAFT, PHASE.PREP].includes(phase) ? 1 : 0, phase);
}
h.m.phase = PHASE.PREP; h.sent.length = 0;
p.recompute(); p.privateView(); h.m.publicView();
assert.equal(cues(h).length, 0);
});
+5 -3
View File
@@ -264,9 +264,11 @@ test('Final Assault: two client-run pair fields share the server pool (b.progres
h.drive(() => m.phase === PHASE.FINAL_ASSAULT);
const [b1, b2] = fields(h);
assert.deepEqual([b1.fieldId, b2.fieldId], ['b1', 'b2']);
assert.deepEqual([b1.mode, b1.authority, b2.mode, b2.authority], ['client', 'p_0', 'client', 'p_2'], 'lowest seat of each pair');
const authorities = [b1, b2].map(f => [...f.players].sort((a, b) => h.ps(a).seat - h.ps(b).seat)[0]);
assert.deepEqual([b1.mode, b1.authority, b2.mode, b2.authority], ['client', authorities[0], 'client', authorities[1]], 'lowest seat of each pair');
const startMsgs = h.sent.filter(([, x]) => x.t === 'b.start' && x.kind === 'boss');
assert.deepEqual(startMsgs.map(([pid, x]) => `${pid}:${x.fieldId}:${x.authoritative ? 'A' : 'r'}`).sort(), ['p_0:b1:A', 'p_1:b1:r', 'p_2:b2:A', 'p_3:b2:r']);
assert.deepEqual(startMsgs.map(([pid, x]) => `${pid}:${x.fieldId}:${x.authoritative ? 'A' : 'r'}`).sort(),
[b1, b2].flatMap((f, i) => f.players.map(pid => `${pid}:${f.fieldId}:${pid === authorities[i] ? 'A' : 'r'}`)).sort());
assert.equal(startMsgs[0][1].spec.boss.poolMax, m.bossPool.maxHp);
const max = m.bossPool.maxHp;
h.sched.advance(3000);
@@ -597,7 +599,7 @@ test('boss bounty eligibility: forced real results on a team victory qualify; sy
function bossPaid(seed, { leaks = 0, leaksBy = null, leaked = {} }) {
const { h, m, f } = faWithForger(seed);
const card = DATA.choices.cards.bounty.find((c) => c.payout === 'perfect' && m.gd.enemy(c.enemyKey));
const ps = f.players.map((pid) => h.ps(pid));
const ps = f.players.map((pid) => h.ps(pid)).sort((a, b) => a.seat - b.seat); // return payouts by player, not random side
for (const p of ps) m.addBounty(p, { ...card, rounds: 2, multiRound: false });
const before = ps.map((p) => p.pendingFunds);
const lp0 = m.teamLp;
+83
View File
@@ -0,0 +1,83 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { DATA, makeMatch, give, checkInvariants } from './harness.js';
import { GameData } from '../../server/match/gamedata.js';
import { computeBonds } from '../../server/match/bondsMeta.js';
function emptyPrep() {
const h = makeMatch({ mode: 'solo', difficulty: 'HARD', seed: 11, fake: true }).start().toPrep(1);
const p = h.ps('p_0');
for (const x of p.allChess()) p.returnCopies(x);
p.board.clear(); p.hand.fill(null); p.temp.fill(null); p.offers.length = 0; p.bandId = null; p.recompute();
return { h, m: h.m, p };
}
for (const g of Object.values(DATA.garrisons).filter(g => g.effectKey === 'SERVER_GAIN_EQUIP')) {
for (const owner of g.owners) test(`fixed gift ${g.garrisonId}/${owner} is produced past stock and still occupies it`, () => {
const { m, p } = emptyPrep(), id = g.bbStr.chess, cap = m.itemPool.cap(id);
assert.ok(Number.isInteger(cap));
for (let i = 0; i < cap; i++) assert.ok(p.acquireItem(id));
assert.equal(m.itemPool.left(id), 0);
assert.ok(p.acquireChess(owner, { fromPool: false }));
assert.equal(m.itemPool.held(id), cap + g.bb.count, 'the trait must finish all grants, including merges');
assert.equal(m.itemPool.left(id), 0, 'overstock prevents new shop draws');
assert.equal(p.acquireItem(id), null, 'an ordinary stock-backed grant is still refused');
checkInvariants(m); m.dispose();
});
}
test('unlimited fixed-item gifts do not exempt unrelated strategy equipment from stock', () => {
const { m, p } = emptyPrep(), id = 'chess_item_5_07_e_a';
assert.ok(p.acquireItem(id)); assert.ok(p.acquireItem(id));
assert.equal(p.acquireItem(id, { requirePool: false }), null, 'commercial packaging is not a fixed operator gift');
assert.equal(m.itemPool.held(id), 2); checkInvariants(m); m.dispose();
});
for (const invest of [false, true]) test(`repeated Muelsyse acquisition and elite gifts remain unlimited (investor=${invest})`, () => {
const { m, p } = emptyPrep(), id = 'chess_item_6_09_e_a';
if (invest) {
const members = Object.values(DATA.chess).filter(c => c.visible && !c.isGolden && c.bonds.includes('investShip')).slice(0, 3);
for (const c of members) give(m, p, c.chessId);
}
assert.equal(m.dispatcher.investRepeat(p), invest ? 2 : 1);
const expected = invest ? [2, 4, 8, 10, 12] : [1, 2, 4, 5, 6];
for (const n of expected) {
assert.ok(p.acquireChess('chess_char_6_11_a'));
assert.equal(m.itemPool.held(id), n); checkInvariants(m);
// Stow the non-merging isomorphs on holders so this stock test does not exhaust the player's inventory.
for (const item of [...p.hand, ...p.temp].filter(x => x?.kind === 'item')) {
const holder = p.allChess().find(c => c.items.length < m.gd.equipPerChess);
if (holder) assert.ok(p.equip(item.uid, holder.uid).ok);
}
}
m.dispose();
});
test('Damazti strategy grants its fixed item at rounds 5/10/15 past cap; releasing overflow does not free stock early', () => {
const { m, p } = emptyPrep(), id = 'chess_item_6_09_e_a';
for (let i = 0; i < 3; i++) assert.ok(p.acquireItem(id));
p.bandId = 'band_damaztic'; p.recompute();
for (const [i, round] of [5, 10, 15].entries()) {
m.round = round; m.dispatch(p, 'onRoundStart', { round });
assert.equal(m.itemPool.held(id), 4 + i); checkInvariants(m);
}
const copy = () => [...p.hand, ...p.temp].find(x => x?.kind === 'item' && x.id === id);
for (let i = 0; i < 3; i++) { assert.ok(p.destroy(copy().uid).ok); assert.equal(m.itemPool.left(id), 0); }
assert.ok(p.destroy(copy().uid).ok);
assert.equal(m.itemPool.held(id), 2); assert.equal(m.itemPool.left(id), 1);
checkInvariants(m); m.dispose();
});
for (const mu of ['chess_char_6_11_a', 'chess_char_6_11_b']) test(`seven distinct Yan operators plus Yan-converted ${mu} activate nine Yan`, () => {
const gd = new GameData(DATA, 'mode_multi_hard');
const seven = Object.values(DATA.chess).filter(c => c.visible && !c.isGolden && c.bonds.includes('yanShip')).slice(0, 7);
assert.equal(seven.length, 7);
const pieces = seven.map((c, i) => ({ uid: i + 1, kind: 'chess', id: c.chessId, items: [] }));
pieces.push({ uid: 8, kind: 'chess', id: mu, items: [{ id: 'chess_item_6_09_e_a' }, { id: 'chess_item_3_04_e_a' }] });
const state = { board: new Map(pieces.map((p, i) => [`${9 + i % 4},${3 + Math.floor(i / 4)}`, p])), hand: [], layers: {} };
const b = computeBonds(gd, state);
assert.equal(b.yanShip.count, 9); assert.equal(b.yanShip.tier, 3); assert.equal(b.yanShip.harmony, 1);
pieces[7].items.pop();
const withoutConversion = computeBonds(gd, state);
assert.equal(withoutConversion.yanShip.count, 8); assert.equal(withoutConversion.yanShip.tier, 2);
});
+1 -1
View File
@@ -221,7 +221,7 @@ test('onLeave: a quitter has no place in the Final Assault pairing or the boss p
assert.equal(alive.length, 2);
assert.ok(!alive.includes(first));
h.drive(() => m.phase === PHASE.FINAL_ASSAULT);
assert.deepEqual(m.fields.map((f) => f.players), [alive], 'one boss field for the two players left');
assert.deepEqual(m.fields.map((f) => [...f.players].sort()), [[...alive].sort()], 'one boss field for the two players left');
assert.equal(m.bossPool.maxHp, bossPoolHp(m.gd, m.bossId, 2), 'the co-op boss pool of two alive players');
assert.equal(m.bossPool.maxHp, m.gd.boss(m.bossId).bloodPoint[m.gd.difficulty] * 2, 'bloodPoint × the two players left; the quitter does not count (DESIGN §25.13.4)');
const end = h.runToEnd();
+3 -1
View File
@@ -173,7 +173,9 @@ 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
clear(p0);
+5 -3
View File
@@ -103,15 +103,17 @@ test('shop slots per level (+ the item slot), buy prices by tier, SOLD_OUT, NO_F
assert.equal(ps.hand[9].id, slot0.id, 'hand fills right→left');
assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot: 0 }), { error: ERR.SOLD_OUT });
assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot: 15 }).error, ERR.BAD_TARGET);
// a display is not a reservation: an empty pool makes the buy fail
// a display is not a reservation, but its operator remains purchasable after the pool is exhausted (#498)
const s1 = ps.shop.slots[1];
const taken = m.pool.take(s1.id, 100);
assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot: 1 }), { error: ERR.SOLD_OUT });
assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot: 1 }), { ok: true });
assert.equal(m.pool.entries.get(s1.id).left, -1);
m.pool.give(s1.id, taken);
// full hand blocks buys
const fillers = chessOfTier(3).filter((x) => m.pool.has(x) && x !== s1.id);
for (let i = 0; ps.hand.some((x) => x == null); i++) give(m, ps, fillers[i]);
if (ps.countCopies(m.gd.baseIdOf(s1.id)) === 0) assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot: 1 }), { error: ERR.HAND_FULL });
ps.shop.slots[1] = { ...s1, sold: false };
assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot: 1 }), { error: ERR.HAND_FULL });
const itemSlot = ps.shop.slots.findIndex((s) => s && s.kind === 'item');
assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot: itemSlot }), { error: ERR.HAND_FULL });
checkInvariants(m);
@@ -17,6 +17,9 @@ import { teamFrameIds } from '../../public/js/ui/gameLogic.js';
function bossPrep(humans, seed) {
const h = makeMatch({ mode: 'coop', difficulty: 'FUNNY', humans, seed, fake: true }).start();
const m = h.m;
// Explicit layout fixture: these cases test mirrored metadata, not the random draw (covered separately).
const ids = m.order.map(p => p.playerId);
m.bossPairs = [ids.slice(0, 2), ids.slice(2)].filter(g => g.length);
h.drive(() => m.phase === PHASE.PREP && m.round === m.gd.bossRound, { ready: true });
for (const ps of m.players.values()) { ps.ready = false; for (const k of [...ps.board.keys()]) ps.board.delete(k); ps.recompute(); }
m.flush(true);
@@ -17,6 +17,7 @@ import { leaderStand } from '../../public/js/render/prepfield.js';
function bossPrep(seed) {
const h = makeMatch({ mode: 'coop', difficulty: 'FUNNY', humans: 4, seed, fake: true }).start();
const m = h.m;
m.bossPairs = [['p_0', 'p_1'], ['p_2', 'p_3']]; // explicit mirrored-layout fixture, not the pairing policy
h.drive(() => m.phase === PHASE.PREP && m.round === m.gd.bossRound, { ready: true });
return h;
}
@@ -64,6 +65,7 @@ test('a scout of a boss round\'s prep is the boss field: the player\'s half (mir
test('the eliminated viewer is pushed the boss-field scout at the boss round\'s start (item 56); a normal round\'s scout stays the normal board', () => {
const h = makeMatch({ mode: 'coop', difficulty: 'FUNNY', humans: 3, seed: 56, fake: true }).start();
const m = h.m;
m.bossPairs = [['p_0', 'p_1'], ['p_2']]; // keep this scout fixture's named left/right roles
h.toPrep(1);
h.ps('p_2').eliminate(1);
h.toPrep(2);
@@ -101,6 +101,7 @@ test('client combat: an eliminated human is started on the field of the player i
test('Final Assault: an eliminated viewer is started on its preference\'s boss field; a shared field records the tapped player only', () => {
const h = makeMatch({ mode: 'coop', difficulty: 'FUNNY', humans: 4, seed: 54, fake: true, instant: false, script: (b) => (b.kind === 'boss' ? { bossDps: 1 } : {}) }).start();
const m = h.m;
m.bossPairs = [['p_0', 'p_1'], ['p_2', 'p_3']]; // named watcher-role fixture; random assignment has its own tests
h.drive(() => m.phase === PHASE.PREP && m.round === 14);
h.ps('p_3').eliminate(13);
assert.deepEqual(m.handle('p_3', { t: 'g.watch', fieldId: 'n:p_2' }), { ok: true }, 'records the preference in prep');
+58 -17
View File
@@ -6,15 +6,54 @@ import { pairPlayers, bossPoolHp, SharedBossPool, hiddenEligible } from '../../s
import { GameData } from '../../server/match/gamedata.js';
import { FakeBattle } from './fakeBattle.js';
import { DATA, makeMatch, checkInvariants } from './harness.js';
import { createRng } from '../../server/sim/rng.js';
const bossFields = () => FakeBattle.instances.filter((b) => b.kind === 'boss' || b.kind === 'hidden');
test('pairing by seat: (1,2), (3,4); an odd player alone', () => {
test('pairing is seeded, independent of input order, includes everyone once and can draw every partner', () => {
const p = (seat) => ({ seat, playerId: `s${seat}` });
assert.deepEqual(pairPlayers([p(0)]).map((g) => g.map((x) => x.seat)), [[0]]);
assert.deepEqual(pairPlayers([p(2), p(0)]).map((g) => g.map((x) => x.seat)), [[0, 2]]);
assert.deepEqual(pairPlayers([p(3), p(1), p(0)]).map((g) => g.map((x) => x.seat)), [[0, 1], [3]]);
assert.deepEqual(pairPlayers([p(3), p(1), p(0), p(2)]).map((g) => g.map((x) => x.seat)), [[0, 1], [2, 3]]);
const partners = new Set(), lone = new Set();
for (let seed = 1; seed <= 64; seed++) for (const n of [1, 2, 3, 4]) {
const alive = Array.from({ length: n }, (_, i) => p(i));
const groups = pairPlayers(alive, createRng(seed));
assert.deepEqual(groups, pairPlayers([...alive].reverse(), createRng(seed)));
assert.equal(groups.length, Math.ceil(n / 2));
assert.deepEqual(groups.flat().map(x => x.seat).sort(), alive.map(x => x.seat));
if (n === 4) partners.add(groups.find(g => g.some(x => x.seat === 0)).find(x => x.seat !== 0).seat);
if (n === 3) lone.add(groups.find(g => g.length === 1)[0].seat);
}
assert.deepEqual([...partners].sort(), [1, 2, 3]);
assert.deepEqual([...lone].sort(), [0, 1, 2]);
});
test('previous boss partners and sides persist; only orphaned survivors form a new pair', () => {
const all = [0, 1, 2, 3].map(seat => ({ seat, playerId: `s${seat}` }));
const previous = [['s3', 's0'], ['s2', 's1']];
const ids = groups => groups.map(g => g.map(p => p.playerId));
assert.deepEqual(ids(pairPlayers(all, createRng(99), previous)), previous);
assert.deepEqual(ids(pairPlayers(all.filter(p => p.seat !== 0), createRng(99), previous)), [['s2', 's1'], ['s3']]);
assert.deepEqual(ids(pairPlayers(all.filter(p => p.seat > 1), createRng(99), previous)), [['s3', 's2']]);
});
test('boss preview, actual fields and Hidden Core retain one per-match draw', () => {
const h = makeMatch({ mode: 'coop', difficulty: 'HARD', humans: 4, seed: 31, fake: true,
script: b => ['boss', 'hidden'].includes(b.kind) ? { bossDps: 1e9 } : {} }).start();
try {
h.drive(() => h.m.phase === PHASE.PREP && h.m.round === 14);
const pairs = structuredClone(h.m.bossPairs);
assert.deepEqual(h.m.bossWaves.map(w => w.players), pairs);
for (const p of h.m.order) {
p.bondCountBonus.yanShip = 3; p.layers.yanShip = 400; p.recompute();
const group = h.m.bossGroupOf(p);
assert.equal(group.side, pairs.find(g => g.includes(p.playerId)).indexOf(p.playerId) === 0 ? 'L' : 'R');
}
h.drive(() => h.m.phase === PHASE.FINAL_ASSAULT);
assert.deepEqual(h.m.fields.map(f => f.players), pairs);
h.drive(() => h.m.phase === PHASE.PREP && h.m.round === 15);
assert.deepEqual(h.m.bossWaves.map(w => w.players), pairs);
h.drive(() => h.m.phase === PHASE.HIDDEN_CORE);
assert.deepEqual(h.m.fields.map(f => f.players), pairs);
} finally { h.m.dispose(); }
});
test('boss pool = bloodPoint[difficulty] × the players alive at the fight\'s start (solo × 1) × tuning; config restores the fixed pool; shared and never negative', () => {
@@ -200,22 +239,24 @@ test('Final Assault: a fighting player may only watch its own boss field; elimin
h.drive(() => m.phase === PHASE.PREP && m.round === 14);
h.ps('p_3').eliminate(13);
h.drive(() => m.phase === PHASE.FINAL_ASSAULT);
assert.deepEqual(m.fields.map((f) => [f.fieldId, f.players]), [['b1', ['p_0', 'p_1']], ['b2', ['p_2']]]);
assert.equal(m.handle('p_0', { t: 'g.watch', fieldId: 'b2' }).error, 'BAD_TARGET', 'the other group is hidden');
assert.equal(m.handle('p_2', { t: 'g.watch', fieldId: 'b1' }).error, 'BAD_TARGET');
assert.deepEqual(m.handle('p_1', { t: 'g.watch', fieldId: 'b1' }), { ok: true });
const pair = m.fields.find(f => f.players.length === 2), lone = m.fields.find(f => f.players.length === 1);
assert.deepEqual(m.fields.flatMap(f => f.players).sort(), ['p_0', 'p_1', 'p_2']);
const [first, second] = pair.players, third = lone.players[0];
assert.equal(m.handle(first, { t: 'g.watch', fieldId: lone.fieldId }).error, 'BAD_TARGET', 'the other group is hidden');
assert.equal(m.handle(third, { t: 'g.watch', fieldId: pair.fieldId }).error, 'BAD_TARGET');
assert.deepEqual(m.handle(second, { t: 'g.watch', fieldId: pair.fieldId }), { ok: true });
assert.deepEqual(m.handle('p_3', { t: 'g.watch', fieldId: 'b2' }), { ok: true }, 'an eliminated player spectates freely');
assert.deepEqual(m.handle('p_3', { t: 'g.watch', fieldId: 'b1' }), { ok: true });
assert.equal(m.watchers.get('p_0'), 'b1', 'the refused watch keeps the default (own) field');
assert.equal(m.watchers.get(first), pair.fieldId, 'the refused watch keeps the default (own) field');
// the prep-scouting id of a player of the other group is no way around the rule, and never detaches the viewer
const scouted = h.allTo('p_0', 'm.field').length;
assert.deepEqual(m.handle('p_0', { t: 'g.watch', fieldId: 'n:p_2' }), { error: 'BAD_TARGET', detail: 'no such field' });
assert.deepEqual(m.handle('p_0', { t: 'g.watch', fieldId: 'n:p_1' }), { error: 'BAD_TARGET', detail: 'no such field' });
assert.equal(h.allTo('p_0', 'm.field').length, scouted, 'no board of the other group was sent');
assert.equal(m.watchers.get('p_0'), 'b1', 'still streaming the own boss field');
const snaps = h.allTo('p_0', 'b.snap').length;
const scouted = h.allTo(first, 'm.field').length;
assert.deepEqual(m.handle(first, { t: 'g.watch', fieldId: `n:${third}` }), { error: 'BAD_TARGET', detail: 'no such field' });
assert.deepEqual(m.handle(first, { t: 'g.watch', fieldId: `n:${second}` }), { error: 'BAD_TARGET', detail: 'no such field' });
assert.equal(h.allTo(first, 'm.field').length, scouted, 'no board of the other group was sent');
assert.equal(m.watchers.get(first), pair.fieldId, 'still streaming the own boss field');
const snaps = h.allTo(first, 'b.snap').length;
h.sched.advance(500);
assert.ok(h.allTo('p_0', 'b.snap').length > snaps, 'b.snap of b1 keeps coming');
assert.ok(h.allTo(first, 'b.snap').length > snaps, 'snapshots of the own field keep coming');
m.dispose();
});
+3 -2
View File
@@ -43,8 +43,9 @@ test('multi-round bounties spawn in the Final Assault on the owner\'s half, show
assert.equal(rt.motion === 'FLY', fly, 'route motion matches the enemy');
assert.equal(s.count, card.count);
assert.ok(s.mods.hpMul > 0, 'bounty enemies take the round\'s multipliers');
if (s.ownerPlayerId === 'p_0') assert.ok(rt.end[1] < 10, `left player's bounty goes to the left goal (${rt.end})`);
else { assert.equal(s.ownerPlayerId, 'p_1'); assert.ok(rt.end[1] > 10, `right player's bounty goes to the right goal (${rt.end})`); }
const side = f.opts.players.find(p => p.playerId === s.ownerPlayerId)?.side;
assert.ok(side === 'L' || side === 'R', 'bounty has an actual field owner');
assert.ok(side === 'L' ? rt.end[1] < 10 : rt.end[1] > 10, `owner's ${side} bounty goes to its own goal (${rt.end})`);
}
const pending = p0.pendingFunds;
const end = h.runToEnd();
+4 -2
View File
@@ -156,14 +156,16 @@ test('merge whose copies are not deployed (temp) with a full hand overflows the
m.dispose();
});
test('reward pick respects the pool and a full hand; offers expire at prep end', () => {
test('displayed operator rewards survive stock exhaustion but respect a full hand and expire at prep end', () => {
const { h, m, ps } = prep(24);
ps.pushRewardOffer('merge');
const offer = ps.offers[0];
const id0 = offer.slots[0].id;
const taken = m.pool.take(id0, 100);
assert.deepEqual(m.handle('p_0', { t: 'g.reward', idx: 0 }), { error: ERR.SOLD_OUT });
assert.deepEqual(m.handle('p_0', { t: 'g.reward', idx: 0 }), { ok: true });
assert.equal(m.pool.entries.get(id0).left, -1);
m.pool.give(id0, taken);
ps.pushRewardOffer('merge', { ids: [id0] });
const others = chessOfTier(3).filter((c) => m.pool.has(c) && !offer.slots.some((s) => s.id === c));
for (let i = 0; ps.hand.some((p) => p == null); i++) give(m, ps, others[i]);
assert.deepEqual(m.handle('p_0', { t: 'g.reward', idx: 0 }), { error: ERR.HAND_FULL });
+7 -6
View File
@@ -145,13 +145,14 @@ describe('#7 the Final Assault prep deploys on the boss field', () => {
h.setStage(STAGE);
h.toPrep(14);
const m = h.m;
assert.equal(m.deployFieldOf(h.ps('p_1')), 'bossR');
assert.equal(m.deployFieldOf(h.ps('p_2')), 'bossL', 'the odd player alone on the left half');
m.onLeave('p_0');
assert.equal(m.deployFieldOf(h.ps('p_1')), 'bossL');
assert.equal(m.deployFieldOf(h.ps('p_2')), 'bossR');
const [left, right] = m.bossPairs.find(g => g.length === 2), lone = m.bossPairs.find(g => g.length === 1)[0];
assert.equal(m.deployFieldOf(h.ps(right)), 'bossR');
assert.equal(m.deployFieldOf(h.ps(lone)), 'bossL', 'the odd player alone on the left half');
m.onLeave(left);
assert.equal(m.deployFieldOf(h.ps(right)), 'bossL');
assert.equal(m.deployFieldOf(h.ps(lone)), 'bossR');
// re-checked at once (Match._quit recomputes the players left), not at their next action
for (const [id, field] of [['p_1', 'bossL'], ['p_2', 'bossR']]) {
for (const [id, field] of [[right, 'bossL'], [lone, 'bossR']]) {
assert.equal(h.ps(id)._deployField, field, `${id} board re-checked on ${field}`);
assert.equal(h.ps(id)._legalityStale, false);
}
+20 -2
View File
@@ -123,10 +123,10 @@ test('effect-only items are never shop items: not in shopItemsByTier, never in t
m.dispose();
});
test('维多利亚 25-layer reward and 洛洛的定制品: the 4 special 维式重锤 (and nothing else)', () => {
test('维多利亚 25-layer reward: the 4 special 维式重锤 (and nothing else)', () => {
const special = ['chess_item_2_03_e_a', 'chess_item_3_09_e_a', 'chess_item_3_10_e_a', 'chess_item_4_09_e_a'];
const m = makeMatch({ mode: 'coop', seed: 3, fake: true }).m;
for (const pid of ['pool_equip_vict', 'pool_equip_rockr']) {
for (const pid of ['pool_equip_vict']) {
assert.deepEqual([...DATA.choices.pools[pid].items].sort(), special, pid);
const seen = new Set();
for (let i = 0; i < 200; i++) seen.add(m.rollPool(pid).id);
@@ -135,6 +135,24 @@ test('维多利亚 25-layer reward and 洛洛的定制品: the 4 special 维式
m.dispose();
});
test('洛洛 and 焰尾 use the measured eight-item and 10/10/3 subsets; exhausted shop items are excluded', () => {
const m = makeMatch({ mode: 'coop', seed: 3, fake: true }).m;
const rock = DATA.choices.pools.pool_equip_rockr.weighted;
assert.deepEqual(rock.map(([id, n]) => [m.gd.item(id).name, n]), [
['维式重锤', 10], ['战栗维式重锤', 2], ['坚固维式重锤', 2], ['加速维式重锤', 2], ['灼燃维式重锤', 2],
['有限加速器', 1], ['护盾无人机', 4], ['激光发射器', 4],
]);
assert.deepEqual(DATA.choices.pools.pool_char_pinus.weighted.map(([id, n]) => [m.gd.chess(id).name, n]), [['野鬃', 10], ['灰毫', 10], ['远牙', 3]]);
const seen = new Set();
for (let i = 0; i < 300; i++) seen.add(m.rollPool('pool_equip_rockr').id);
assert.deepEqual([...seen].sort(), rock.map(x => x[0]).sort());
const normal = rock[0][0], ps = m.order[0];
for (let i = 0; i < m.itemPool.cap(normal); i++) assert.ok(ps.acquireItem(normal));
assert.equal(m.itemPool.left(normal), 0);
for (let i = 0; i < 100; i++) assert.notEqual(m.rollPool('pool_equip_rockr').id, normal);
m.dispose();
});
test('per-match disabled bonds: 3 core + 4 add-on (NORMAL+), FUNNY static + 0 + 1; weight-0 never drawn; subset ban rule', () => {
for (const [modeId, core, addon] of [['mode_multi_hard', 3, 4], ['mode_single_abyss', 3, 4], ['mode_multi_funny', 0, 1], ['mode_single_normal', 3, 4]]) {
const gd = new GameData(DATA, modeId);
+38
View File
@@ -7,6 +7,33 @@ import { cardPickable } from '../../public/js/ui/choiceOverlay.js';
const PACK = 'chess_item_5_07_e_a';
const byName = name => Object.values(DATA.chess).find(c => !c.isGolden && c.name === name).chessId;
test('four already displayed operator offers may exceed stock; selling first repays the deficit', () => {
const h = makeMatch({ mode: 'coop', humans: 4, seed: 11, fake: true }).start().toPrep();
const m = h.m, id = byName('溯光星源'), ps = [...m.players.values()];
assert.equal(m.pool.cap(id), 10);
for (let i = 0; i < 3; i++) for (let j = 0; j < 3; j++) ps[i].acquireChess(id);
assert.equal(m.pool.left(id), 1);
for (const p of ps) {
p.funds = 100;
p.shop.slots[0] = { kind: 'chess', id, basePrice: 3, frozen: true, sold: false };
}
assert.equal(m.pool.left(id), 1, 'displaying an offer does not reserve stock');
for (const p of ps) {
const funds = p.funds; assert.ok(p.buy(0).ok, 'every pre-existing offer stays purchasable');
assert.equal(p.funds, funds - 3); assert.equal(p.shop.slots[0].sold, true);
assert.equal(p.buy(0).error, 'SOLD_OUT', 'the same offer cannot be bought twice');
}
assert.equal(m.pool.entries.get(id).left, -3); assert.equal(m.pool.left(id), 0);
assert.equal(m.pool.roll(m.rngShop, { tier: 6, filter: candidate => candidate === id }), null);
for (let i = 0; i < 4; i++) {
const piece = ps[i].allChess().find(c => c.id === id); assert.ok(piece);
assert.ok(ps[i].sell(piece.uid).ok);
assert.equal(m.pool.left(id), Math.max(0, i - 2));
checkInvariants(m);
}
m.dispose();
});
test('a duplicate draft card becomes unavailable when the preceding player exhausts equipment stock', () => {
const h = makeMatch({ mode: 'coop', humans: 2, seed: 11, fake: true }).start().toPrep();
const m = h.m, a = h.ps('p_0'), b = h.ps('p_1');
@@ -29,6 +56,17 @@ test('measured operator capacities and exceptions: co-op doubles shared stock, n
assert.equal(solo.itemPoolCopies(PACK), 2); assert.equal(coop.itemPoolCopies(PACK), 2);
});
test('an operator reward shown before stock exhaustion still arrives and records its copy', () => {
const h = makeMatch({ mode: 'coop', humans: 2, seed: 11, fake: true }).start().toPrep();
const m = h.m, a = h.ps('p_0'), b = h.ps('p_1'), id = byName('溯光星源');
a.pushRewardOffer('test', { ids: [id] });
for (let i = 0; i < m.pool.cap(id); i++) b.acquireChess(id);
assert.equal(m.pool.left(id), 0);
assert.ok(a.pickReward(0).ok); assert.equal(a.offers.length, 0);
assert.ok(a.allChess().some(c => c.id === id));
assert.equal(m.pool.entries.get(id).left, -1); checkInvariants(m); m.dispose();
});
test('shared equipment occupancy includes hand/equipped/elite, never displayed offers; destroying frees stock', () => {
const h = makeMatch({ mode: 'coop', humans: 2, seed: 11, fake: true }).start().toPrep();
const m = h.m, a = h.ps('p_0'), b = h.ps('p_1');
+59
View File
@@ -0,0 +1,59 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeMatch } from './harness.js';
import { Battle } from '../../server/sim/Battle.js';
import { getDefaultSource } from '../../server/sim/simdata.js';
const MEDICS = new Set(['char_605_cmedic', 'char_613_acmedc']);
const medics = b => b.allyUnits.filter(u => MEDICS.has(u.defId) && u.alive && !u.removed);
const clean = () => ({ leaked: [], perfect: true, coins: 0, layerGains: {}, killed: 0, damageDealt: 0 });
function setup(t) {
const h = makeMatch({ mode: 'coop', humans: 3, seed: 334401, fake: true }).start().toPrep();
t.after(() => h.m.dispose()); h.setStage('act2autochess_m01');
for (const p of h.m.order) p.bandId = null;
h.ps('p_0').bandId = 'band_amedic'; return h;
}
function field(h, elites = 0) {
const opts = h.m._normalOpts(h.ps('p_1'));
opts.players[0].units = ['chess_char_1_01_b', 'chess_char_1_02_b'].slice(0, elites)
.map((chessId, i) => ({ uid: 100 + i, kind: 'chess', chessId, row: 10 + i, col: 4 }));
const b = new Battle({ ...opts, data: getDefaultSource(), spawns: [], timeLimit: 300, autoFinish: false });
b.start(); assert.equal(b.errorCount, 0); return b;
}
for (const reason of ['eliminated', 'quit']) for (const elites of [0, 2]) {
test(`Touch team grant survives holder ${reason}, receiver elites=${elites}`, t => {
const h = setup(t), before = field(h, elites), expected = elites ? 'char_613_acmedc' : 'char_605_cmedic';
assert.deepEqual(medics(before).map(u => u.defId), [expected]);
if (reason === 'quit') h.m.onLeave('p_0');
else {
h.ps('p_0').lp = 1;
h.m.lastResults = new Map(h.m.order.map(p => [p.playerId, clean()]));
h.m.lastResults.set('p_0', { ...clean(), perfect: false, leaked: [{ enemyKey: 'enemy_1422_lrsldr', counted: true }] });
h.m.settle(null, null); h.toPrep(2); h.setStage('act2autochess_m01');
}
assert.equal(h.ps('p_0').alive, false);
assert.equal(h.ps('p_1').privateView().prepMapChars.length, 1);
const after = field(h, elites);
assert.deepEqual(medics(after).map(u => u.defId), [expected]);
assert.equal(after.players[0].input.contentInfo.teammates.some(p => p.playerId === 'p_0'), false,
'retaining Touch does not revive dead teammate contexts');
for (let i = 0; i < 30; i++) before.step();
assert.deepEqual(medics(before).map(u => u.defId), [expected]);
h.ps('p_0').bandId = 'band_amiya';
assert.equal(field(h).players[0].input.contentInfo.matchBands.includes('band_amiya'), false,
'other eliminated strategies are not retained by this exception');
});
}
test('Touch requires a confirmed selection and does not leak to another match', t => {
const h = makeMatch({ mode: 'coop', humans: 3, seed: 334405, fake: true }).start();
t.after(() => h.m.dispose()); h.drive(() => h.m.phase === 'BAND_DRAFT', { ready: false });
const holder = h.m.players.get(h.m.draftTurn());
assert.equal(h.m.bandFocus(holder, 'band_amedic').ok, true);
assert.equal(h.m.teamMedicGranted(), false);
assert.equal(h.m.pickBand(holder, 'band_amedic').ok, true);
h.m.onLeave(holder.playerId); assert.equal(h.m.teamMedicGranted(), true);
const next = makeMatch({ mode: 'coop', humans: 3, seed: 334405, fake: true }).start();
t.after(() => next.m.dispose()); assert.equal(next.m.teamMedicGranted(), false);
});
+94 -1
View File
@@ -20,8 +20,10 @@ import { createBattleRunner } from '../../public/js/battle/runner.js';
import { createStore, initialState } from '../../public/js/store.js';
import * as specMod from '../../server/sim/spec.js';
import { DataSource } from '../../server/sim/simdata.js';
import { DATA, makeMatch, checkInvariants } from './harness.js';
import { DATA, makeMatch, checkInvariants, legalTileFor } from './harness.js';
import { FakeBattle } from './fakeBattle.js';
import { ownerBoard } from '../../public/js/ui/watchBonds.js';
import { bondMembers, diyGetter, diyRecordFor, memberHeadCount } from '../../public/js/ui/gameLogic.js';
const row = (h, pid) => h.m.publicView().players.find((p) => p.playerId === pid);
const entry = (list, id) => (list || []).find((b) => b.bondId === id) || null;
@@ -273,3 +275,94 @@ test('runner: a battle that disables gains (联防 / boss) publishes no live lay
assert.deepEqual(r.store.get().match.battle.bondLayers, {});
r.runner.dispose();
});
const DIY_SLOT = 'chess_char_5_diy1_a';
const HUANG_PICK = { charId: 'char_017_huang', skillIndex: 1, uniEquipId: 'uniequip_002_huang' };
const SIEGE_PICK = { charId: 'char_112_siege', skillIndex: 2, uniEquipId: 'uniequip_002_siege' };
const DIY_DATA = { chess: DATA.chess, backups: DATA.backups };
const getChess = (id) => DATA.chess[id] || null;
/** The same record lookups as BondPopup: own picks from m.private, a teammate's from each field unit. */
function diyRows(bondId, priv) {
const get = diyGetter(getChess, priv, DIY_DATA);
const record = (p) => (p.diy ? diyRecordFor(getChess(p.id), p.diy, DIY_DATA) : null) || get(p.id);
return bondMembers(DATA.bonds[bondId], priv, [], get, () => null, record);
}
/** Controlled match fixture: legal seat picks, acquire/merge and g.move; the match builds the unmodified b.start. */
function diyWatchMatch(elite) {
const seats = [SIEGE_PICK, HUANG_PICK].map((pick, seat) => ({
seat, playerId: `p_${seat}`, name: `P${seat}`, isBot: false, connected: true, diy: { [DIY_SLOT]: pick },
}));
const h = makeMatch({ mode: 'coop', seats, spectators: ['s_spec'], seed: 7309, clientCombat: true, clients: false }).start();
h.toPrep(1);
const ps = h.ps('p_1');
let piece;
for (let i = 0; i < (elite ? 3 : 1); i++) piece = ps.acquireChess(DIY_SLOT);
assert.ok(piece);
const tile = legalTileFor(h.m, ps, piece.id);
assert.ok(tile);
assert.deepEqual(h.m.handle('p_1', { t: 'g.move', uid: piece.uid, to: { area: 'board', row: tile[0], col: tile[1] } }), { ok: true });
return h;
}
for (const elite of [false, true]) test(`runner: watched ${elite ? 'elite' : 'normal'} DIY operator is a bond member before and after deployment`, async (t) => {
const h = diyWatchMatch(elite);
t.after(() => h.m.dispose());
const assertHuang = (priv) => {
for (const bondId of ['yanShip', 'victoriaShip']) {
const rows = diyRows(bondId, priv);
assert.equal(memberHeadCount(rows, false), 1, `${bondId}: the fielded DIY member counts once`);
const row = rows.find((x) => x.diy);
assert.deepEqual([row.id, row.rec.charId, row.name, row.onBoard, row.rec.isGolden], [DIY_SLOT, HUANG_PICK.charId, '煌', true, false]);
assert.deepEqual(row.pick, HUANG_PICK, 'the watched owner\'s skill and module, not our pick in the same slot');
}
};
assertHuang(ownerBoard(h.m.prepFieldMeta(h.ps('p_1')), 'p_1'));
assert.deepEqual(diyRows('victoriaShip', h.ps('p_0').privateView()).filter((x) => x.diy).map((x) => [x.rec.charId, x.owned]), [[SIEGE_PICK.charId, false]], 'our unbought pick stays our own');
assert.equal(diyRows('yanShip', h.ps('p_0').privateView()).some((x) => x.diy), false);
for (const pid of ['p_0', 'p_1']) assert.deepEqual(h.m.handle(pid, { t: 'g.ready', ready: true }), { ok: true });
h.runToPhase(PHASE.COMBAT, 1);
assert.deepEqual(h.m.handle('p_0', { t: 'g.watch', fieldId: 'n:p_1' }), { error: 'WRONG_PHASE', detail: 'own battle running' });
assert.deepEqual(h.m.handle('s_spec', { t: 'g.watch', fieldId: 'n:p_1' }), { ok: true });
const start = JSON.parse(JSON.stringify(h.lastTo('s_spec', 'b.start')));
assert.deepEqual([start.watch, start.authoritative, start.elapsed], [true, false, 0]);
assert.equal(start.spec.players[0].units[0].chessId, elite ? DIY_SLOT.replace(/_a$/, '_b') : DIY_SLOT);
assert.deepEqual(start.spec.players[0].units[0].diy, HUANG_PICK);
const r = rig();
t.after(() => r.runner.dispose());
r.net.emit('b.start', start);
await r.settle();
const field = r.store.get().match.field;
assert.equal(field.units.some((u) => u.kind === 'op'), false, 'the initial meta precedes deployment');
const board = () => ownerBoard(field, 'p_1', r.runner.ownerOps('p_1', start.fieldId));
t.diagnostic(JSON.stringify({ elite, specPick: start.spec.players[0].units[0].diy, initialMetaOps: field.units.filter((u) => u.kind === 'op').length, runnerOps: r.runner.ownerOps('p_1'), yanMembers: memberHeadCount(diyRows('yanShip', board()), false) }));
assertHuang(board());
r.advance(20000, 50);
const unit = r.runner._entries.get(start.battleId).battle.allyUnits.find((u) => u.kind === 'op');
assert.ok(unit.deployed, 'the real simulation deployed the operator');
assertHuang(board());
const op = r.runner.ownerOps('p_1')[0];
assert.notEqual(op.diy, unit.def.loadout.diy, 'the popup receives a copy of the frozen sim pick');
op.diy.charId = SIEGE_PICK.charId;
assert.deepEqual(r.runner.ownerOps('p_1')[0].diy, HUANG_PICK, 'a consumer cannot change the battle or the next read');
assert.deepEqual(r.runner.ownerOps('p_0'), [], 'another owner');
assert.deepEqual(r.runner.ownerOps('p_1', 'n:p_0'), [], 'another field');
// A fresh runner after a spectator reconnect, and switching back from another field, retain the member too.
const starts = h.allTo('s_spec', 'b.start').length;
h.sched.advance(1000);
h.m.addSpectator('s_spec');
assert.equal(h.allTo('s_spec', 'b.start').length, starts + 1, 'the spectator reconnect emits a fresh b.start');
assert.ok(h.lastTo('s_spec', 'b.start').elapsed > 0, 'the resumed replica catches up to the match clock');
const resumed = rig();
t.after(() => resumed.runner.dispose());
resumed.net.emit('b.start', JSON.parse(JSON.stringify(h.lastTo('s_spec', 'b.start'))));
await resumed.settle();
assertHuang(ownerBoard(resumed.store.get().match.field, 'p_1', resumed.runner.ownerOps('p_1')));
r.net.emit('b.start', h.lastTo('p_0', 'b.start'));
await r.settle();
assert.deepEqual(r.runner.ownerOps('p_1'), [], 'switching fields drops the other field\'s members');
r.net.emit('b.start', start);
await r.settle();
assertHuang(ownerBoard(r.store.get().match.field, 'p_1', r.runner.ownerOps('p_1')));
});
@@ -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 });
+83
View File
@@ -0,0 +1,83 @@
// Preparation counts and converted battle memberships through the actual browser simulation loader.
import { test, before, after, describe } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync } from 'node:fs';
import { getData } from '../../server/data.js';
import { GameData } from '../../server/match/gamedata.js';
import { computeBonds } from '../../server/match/bondsMeta.js';
import { getDefaultSource } from '../../server/sim/simdata.js';
import { buildBattleSpec } from '../../server/sim/spec.js';
import { Grid } from '../../server/sim/grid.js';
import { GEO } from '../../shared/constants.js';
const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const enabled = process.env.SIM_E2E === '1' && existsSync(CHROME);
const raw = getData({ log: { info() {}, warn() {}, error() {} } });
const gd = new GameData(raw, 'mode_multi_hard');
const grid = new Grid(getDefaultSource().getStage('act1autochess_m01'), GEO.NORMAL_RECT);
function fixture(ids, items) {
const taken = new Set(), board = new Map(), units = [];
ids.forEach((id, i) => {
const c = raw.chess[id];
let tile;
for (let r = 9; r <= 12 && !tile; r++) for (let col = 2; col <= 10 && !tile; col++) {
if (!taken.has(`${r},${col}`) && grid.canStand(r, col, { ranged: c.position === 'RANGED' })) tile = [r, col];
}
assert.ok(tile, id); taken.add(tile.join(','));
const its = i === ids.length - 1 ? items : [];
board.set(tile.join(','), { uid: i + 1, kind: 'chess', id, items: its.map(id => ({ id })) });
units.push({ uid: i + 1, chessId: id, row: tile[0], col: tile[1], items: its });
});
const bonds = computeBonds(gd, { board, hand: [], layers: {} });
return { spec: buildBattleSpec({ battleId: 'morph', fieldId: 'n:p1', kind: 'normal', modeId: 'mode_multi_hard',
seed: 29, stageId: 'act1autochess_m01', timeLimit: 10, flags: { dpInit: 999 },
players: [{ playerId: 'p1', bonds, units }],
}), bonds, uid: ids.length };
}
describe('converted bonds in the actual browser battle', { skip: !enabled }, () => {
let srv, browser, page;
before(async () => {
const { startServer } = await import('../../server/index.js');
const P = (await import('puppeteer-core')).default;
srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
browser = await P.launch({ executablePath: CHROME, headless: true, args: ['--no-sandbox'] });
page = await browser.newPage(); await page.goto(`http://127.0.0.1:${srv.port}/sim/spec.js`);
});
after(async () => { await browser?.close(); await srv?.close(); });
async function inspect(f) {
return page.evaluate(async ({ spec, uid }) => {
const { loadBrowserSim } = await import('/js/battle/runner.js');
const { spec: S, ds } = await loadBrowserSim();
const { unitBonds, isMember } = await import('/sim/content/support/index.js');
const b = S.createBattleFromSpec(spec, ds, { quiet: true, recordEvents: false });
for (let i = 0; i < 30; i++) b.step();
const u = b.allyUnits.find(u => u.kind === 'op' && u.uid === uid);
return { own: unitBonds(u), members: Object.keys(spec.players[0].bonds).filter(id => isMember(b, u, id)),
yan: b.getPlayer('p1').bonds.yanShip, atk: u.s.atk, baseAtk: u.base.atk,
yanyou: b.allyUnits.filter(u => u.alive && u.mem.bondYan).length, errors: b.errors };
}, f);
}
for (const mu of ['chess_char_6_11_a', 'chess_char_6_11_b']) test(`7 Yan plus converted ${mu}: nine Yan really summons two 炎佑`, async () => {
const seven = Object.values(raw.chess).filter(c => c.visible && !c.isGolden && c.bonds.includes('yanShip')).slice(0, 7).map(c => c.chessId);
const f = fixture([...seven, mu], ['chess_item_6_09_e_a', 'chess_item_3_04_e_a']);
assert.equal(f.bonds.yanShip.count, 9);
const out = await inspect(f);
assert.equal(out.yan.count, 9); assert.equal(out.yan.tier, 3);
assert.ok(out.own.includes('yanShip')); assert.ok(out.atk > out.baseAtk);
assert.equal(out.yanyou, 2); assert.deepEqual(out.errors, []);
});
test('all 36 normal/advanced conversion item pairings retain battle membership', async () => {
const grants = Object.values(raw.items).filter(r => r.giveBondId);
assert.equal(grants.length, 36);
for (const it of grants) {
const id = it.giveBondId;
const two = Object.values(raw.chess).filter(c => c.visible && !c.isGolden && c.bonds.includes(id) && !c.bonds.includes('maniShip')).slice(0, 2).map(c => c.chessId);
const outsider = Object.values(raw.chess).find(c => c.visible && !c.isGolden && c.tier <= 2 && !c.bonds.includes(id) && !c.bonds.includes('maniShip'));
const f = fixture([...two, outsider.chessId], [it.id, it.isGolden ? 'chess_item_6_09_e_b' : 'chess_item_6_09_e_a']);
const out = await inspect(f);
assert.ok(out.own.includes(id) && out.members.includes(id), `${it.id}: ${JSON.stringify(out)}`);
assert.deepEqual(out.errors, []);
}
});
});
+101
View File
@@ -0,0 +1,101 @@
// The cross-engine check (tools/sim-check.mjs, public/dev/simcheck — docs/SIM.md「11. Tools」): the hashes are pinned
// (references made before still compare; the same values as the copy of core.js in 争锋频道 / Duel Channel), a change in
// one tick is found in its block and named by the dump comparison, the deep walk sees another realm's Map / Set, the
// check page loads the data files the client loads, and the corpus covers the golden battle families, traces a battle
// the same whatever ran before it, and ends it with the result digest a plain run gives.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import vm from 'node:vm';
import { readFileSync } from 'node:fs';
import { hex, hashNumbers, hashValue, deepHash, trace, tracer, compare, diffDumps, selectScenarios, textFingerprint } from '../../public/dev/simcheck/core.js';
import { SIM_DATA_FILES, scenariosOf } from '../../public/dev/simcheck/stronghold.js';
import { corpusSpecs, nodeAdapter, FAMILIES, parseOptions, bundleIdentity } from '../../tools/sim-check.mjs';
import { createBattleFromSpec, resultDigest } from '../../server/sim/spec.js';
import { DataSource, getDefaultSource } from '../../server/sim/simdata.js';
test('simcheck dumps preserve special IEEE values through JSON and still locate a one-bit difference', () => {
const adapter = {
start: () => ({ t: 0 }), step: r => ++r.t <= 1,
state: () => [-0, Infinity, -Infinity, NaN, 1 + Number.EPSILON], result: () => 'same',
};
const a = trace(adapter, { id: 'bits' }, { dump: [1, 1] }).dumps;
const b = JSON.parse(JSON.stringify(a));
assert.deepEqual(diffDumps(a, b), []);
assert.deepEqual(b[1].state.slice(0, 4), ['-0', 'Infinity', '-Infinity', 'NaN']);
b[1].state[0] = 0; b[1].state[4] = 1;
const diff = diffDumps(a, b);
assert.deepEqual(diff.map(x => x.index), [0, 4]);
assert.notEqual(diff[0].refBits, diff[0].gotBits);
});
test('simcheck rejects incomplete runs and invalid sampling/dump parameters', () => {
const adapter = { maxTicks: 2, start: () => ({}), step: () => true, state: () => [1], result: () => 'not finished' };
assert.throws(() => trace(adapter, { id: 'loop' }), /maxTicks/);
assert.throws(() => tracer(adapter, { id: 'bad' }, { every: 0 }), /interval/);
assert.throws(() => tracer(adapter, { id: 'bad' }, { dump: [2, 1] }), /window/);
assert.throws(() => tracer(adapter, { id: 'bad' }, { dump: [1, Infinity] }), /window/);
});
test('simcheck selection and CLI reject empty, mismatched and misspelled requests', () => {
const scenarios = [{ id: 'a', family: 'one' }, { id: 'b', family: 'two' }];
const subsets = [{ id: 'one', pick: s => s.family === 'one' }];
assert.equal(selectScenarios(scenarios, subsets, 'one').list.length, 1);
assert.throws(() => selectScenarios(scenarios, subsets, 'typo'), /unknown subset/);
assert.throws(() => selectScenarios(scenarios, subsets, 'one', 'typo'), /unknown scenario/);
assert.throws(() => selectScenarios(scenarios, subsets, 'one', 'b'), /no scenarios/);
for (const args of [['--engines'], ['--engines', 'oops'], ['--subset', 'all'], ['--engines', 'chrome', '--subset', 'typo'], ['--oops', 'x']]) assert.throws(() => parseOptions(args));
});
test('simcheck bundle identity changes with corpus or dirty runtime/data content', () => {
const specs = [{ id: 'a', spec: { seed: 1 } }], files = [{ url: '/data/chess.json', hash: textFingerprint('old') }];
const a = bundleIdentity(specs, files);
assert.notEqual(a, bundleIdentity([{ id: 'a', spec: { seed: 2 } }], files));
assert.notEqual(a, bundleIdentity(specs, [{ url: files[0].url, hash: textFingerprint('new') }]));
assert.equal(a, bundleIdentity(JSON.parse(JSON.stringify(specs)), JSON.parse(JSON.stringify(files))));
});
test('simcheck hashes: pinned values, NaN as one value, −0 apart from 0', () => {
assert.equal(hex(hashNumbers([0, -0, 1, 0.1, -2.5e-300, 1e300, NaN, Infinity])), '8b3e38ba');
assert.equal(hex(hashValue({ a: [1, 'x', null, true], b: { c: 0.5 } })), 'a024c2ef');
assert.equal(hex(hashNumbers([NaN])), hex(hashNumbers([0 / 0])));
assert.notEqual(hex(hashNumbers([0])), hex(hashNumbers([-0])));
assert.equal(hex(deepHash([{ s: new Set([1, 2]), m: new Map([['k', 3]]), arr: [4.5] }])), '92f56136');
const other = vm.runInNewContext('({ s: new Set([1, 2]), m: new Map([["k", 3]]), arr: [4.5] })');
assert.equal(hex(deepHash([other])), '92f56136', 'another realm\'s Map / Set');
});
test('simcheck compare: a one-ulp change at tick 45 is found in its block and named by the dumps', () => {
const toy = (bump) => ({
start: () => ({ n: 0, x: 0.1 }),
step: (r) => { if (r.n >= 100) return false; r.n++; r.x = r.x * 1.01 + 0.1; if (bump && r.n === 45) r.x += r.x * Number.EPSILON; return true; },
state: (r) => [r.n, r.x], labels: () => ['n', 'x'], view: (r) => ({ x: Math.round(r.x) }), deep: (r) => hashNumbers([r.x]), result: (r) => String(Math.round(r.x)),
});
const ref = trace(toy(false), { id: 't' });
assert.deepEqual(compare(ref, trace(toy(false), { id: 't' })), { ok: true, at: null, result: false, ticks: null });
assert.deepEqual(compare(ref, trace(toy(true), { id: 't' })).at, { level: 'state', block: 1, from: 31, to: 60 });
const a = tracer(toy(false), { id: 't' }, { dump: [31, 60] }).finish(), b = tracer(toy(true), { id: 't' }, { dump: [31, 60] }).finish();
const d = diffDumps(a.dumps, b.dumps);
assert.equal(d[0].tick, 45);
assert.equal(d[0].label, 'x');
});
test('simcheck page: the data files are the browser runner\'s', () => {
const src = readFileSync(new URL('../../public/js/battle/runner.js', import.meta.url), 'utf8');
const m = /export const SIM_DATA_FILES = Object\.freeze\((\[[^\]]*\])\)/.exec(src);
assert.ok(m, 'runner.js SIM_DATA_FILES');
assert.deepEqual([...SIM_DATA_FILES], JSON.parse(m[1].replace(/'/g, '"')));
});
test('simcheck corpus: the golden battle families, a battle traced the same whatever ran before, the plain run\'s result', () => {
const doc = { specs: corpusSpecs() }, sc = scenariosOf(doc);
assert.deepEqual([...new Set(sc.map((s) => s.family))], [...FAMILIES]);
assert.equal(new Set(sc.map((s) => s.id)).size, sc.length);
assert.ok(sc.some((s) => s.fast) && sc.some((s) => !s.fast));
const pick = sc.filter((s) => s.family === 'bonds')[0], other = sc.filter((s) => s.family === 'roster')[0];
const alone = trace(nodeAdapter(doc), pick), ad = nodeAdapter(doc);
trace(ad, other);
assert.deepEqual(trace(ad, pick), alone);
const spec = doc.specs.find((s) => s.id === pick.id).spec;
const plain = resultDigest(createBattleFromSpec(spec, new DataSource(getDefaultSource().raw, null), { quiet: true }).runToEnd(200000)).hash;
assert.equal(alone.result, plain);
});
+53
View File
@@ -0,0 +1,53 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync } from 'node:fs';
import { Client, ROOT, hasChrome, startRealServer } from '../e2e/client.mjs';
const enabled = process.env.SP_E2E === '1' && hasChrome() && existsSync(`${ROOT}/public/assets`);
test('buying Matterhorn sends two live layer cues through the real client; resync/reload stays silent',
{ skip: !enabled, timeout: 120000 }, async () => {
const P = (await import('puppeteer-core')).default;
const srv = await startRealServer({ fast: { timerScale: 0.1, startRound: 2, kit: 0, shop: ['chess_char_1_02_a'] } });
const c = new Client(P, srv.base, 'bond-audio', { prefix: 'bond-audio' });
try {
await c.open(); await c.enter('叠层音效测试');
await c.click('.mode-card', '独立模拟'); await c.click('.diff-card', '险境');
await c.click('.create-box button', '开始独立模拟');
await c.waitFor(s => !!s.room, 'solo room');
if (!(await c.st()).phase) await c.click('.room-bar__right button', '开始模拟');
await c.waitFor(s => s.phase === 'INFO_CHECK', 'briefing');
await c.click('.brief__foot .btn--primary', '准备就绪');
await c.waitFor(s => s.phase === 'BAND_DRAFT', 'band draft');
await c.page.click('.dband'); await c.click('.draft-detail__btns .btn--primary', '确认选择');
await c.waitFor(s => s.phase === 'PREP' && !s.ready, 'prep');
await c.page.evaluate(async () => {
const { audio } = await import('/js/audio.js');
const { net } = await import('/js/net.js');
window.__layerCues = []; window.__layerFrames = [];
const play = audio._playBondCue.bind(audio);
audio._playBondCue = (url, volume) => { window.__layerCues.push({ url, volume }); play(url, volume); };
net.on('m.layer', m => window.__layerFrames.push(m));
});
await c.click('.scard', '角峰'); await c.click('.scard', '角峰');
await c.page.waitForFunction(() => window.__layerFrames.length === 2);
const result = await c.page.evaluate(async () => {
const { audio } = await import('/js/audio.js');
// Wait for the actual local asset decoder; then its queued cue continuation starts the source.
await Promise.all([...audio.buffers.values()]); await Promise.resolve();
return { frames: window.__layerFrames, cues: window.__layerCues, playing: !!audio.bondCueNode,
state: audio.ctx?.state, layers: globalThis.__SP__.store.get().match.private.bonds };
});
assert.deepEqual(result.frames.map(m => [m.bondId, m.add]).sort(), [['kjeragShip', 2], ['steadShip', 2]]);
assert.equal(result.cues.length, 2); assert.equal(result.playing, true); assert.equal(result.state, 'running');
assert.ok(result.cues.every(x => /act1autochess_b_ui_buffup/.test(x.url)));
const count = result.cues.length;
// g.watch on own prep asks the real server for state again, without a new gain.
await c.page.evaluate(() => globalThis.__SP__.net.request('g.watch', { fieldId: `n:${globalThis.__SP__.store.get().me.playerId}` }));
assert.equal(await c.page.evaluate(() => window.__layerCues.length), count);
await c.page.reload({ waitUntil: 'networkidle0' });
await c.waitFor(s => s.phase === 'PREP', 'restored prep');
assert.equal(await c.page.evaluate(async () => !!(await import('/js/audio.js')).audio.bondCueNode), false);
assert.equal(c.problems.filter(x => x.startsWith('pageerror:')).length, 0, c.problems.join('\n'));
} finally { await c.close(); await srv.stop(); }
});
+106
View File
@@ -0,0 +1,106 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { AudioManager } from '../../public/js/audio.js';
import { audioEarly, keepEarly } from '../../public/js/screens/game/early.js';
function harness() {
const sources = [];
const a = new AudioManager({ getManifest: () => ({ audio: { sfx: { ui: { bondUp: '/bond.mp3' } } } }) });
a.ctx = {
createGain: () => ({ gain: {}, connect() {}, disconnect() {} }),
createBufferSource: () => {
const s = { started: false, stopped: false, connect() {}, disconnect() {},
start() { this.started = true; }, stop() { this.stopped = true; this.onended?.(); } };
sources.push(s); return s;
},
};
a.sfxGain = {};
a._buffer = async () => ({ duration: 1 });
return { a, sources };
}
const flush = () => new Promise(resolve => queueMicrotask(resolve));
test('a real layer gain plays once regardless of amount; invalid gains are silent', async () => {
const { a, sources } = harness();
for (const n of [0, -1, NaN, Infinity, '1']) a.layerGain('sargonShip', n);
a.layerGain(null, 1); a.layerGain('', 1);
await flush(); assert.equal(sources.length, 0);
a.layerGain('sargonShip', 5); await flush();
assert.equal(sources.length, 1); assert.equal(sources[0].started, true);
a.stopBondCue();
});
test('each new layer cue immediately replaces the playing cue, with no cooldown', async () => {
const { a, sources } = harness();
a.layerGain('sargonShip', 1); await flush();
a.handleBattleEvents([['layer', 'p_1', 'sargonShip', 2]]); await flush();
assert.equal(sources.length, 2); assert.equal(sources[0].stopped, true);
assert.equal(sources[1].started, true); assert.equal(sources[1].stopped, false);
sources[0].onended(); assert.equal(a.bondCueNode.src, sources[1], 'late cleanup must not clear the replacement');
a.stopBondCue(); assert.equal(sources[1].stopped, true);
});
test('only the newest request can start after asynchronous decoding', async () => {
const { a, sources } = harness(), resolves = [];
a._buffer = () => new Promise(resolve => resolves.push(resolve));
a.layerGain('kjeragShip', 1); a.layerGain('defend', 2);
resolves[1]({ duration: 1 }); await flush();
resolves[0]({ duration: 1 }); await flush();
assert.equal(sources.length, 1); assert.equal(sources[0].started, true);
a.stopBondCue();
});
test('field entry, stopping and muting cancel a cue that is still decoding', async () => {
for (const cancel of [a => a.setFieldUnits([]), a => a.stopBondCue(), a => a.setVolumes({ muted: true }), a => a.setVolumes({ sfx: 0 })]) {
const { a, sources } = harness(); let resolve;
a._buffer = () => new Promise(r => { resolve = r; });
a.layerGain('sargonShip', 1); cancel(a); resolve({ duration: 1 }); await flush();
assert.equal(sources.length, 0);
}
});
test('locked/muted audio and missing assets never queue old layer sounds', async () => {
const { a, sources } = harness(), ctx = a.ctx;
a.ctx = null; a.layerGain('sargonShip', 1); a.ctx = ctx;
a.setVolumes({ muted: true }); a.layerGain('sargonShip', 1); a.setVolumes({ muted: false });
a._buffer = async () => null; a.layerGain('sargonShip', 1); await flush();
a._buffer = async () => { throw new Error('missing'); }; a.layerGain('sargonShip', 1); await flush();
assert.equal(sources.length, 0); assert.equal(a.bondCueNode, null);
});
test('returning to prep cancels old battle audio but preserves a new prep gain received before the phase view', async () => {
const { a, sources } = harness(); let resolve;
a._buffer = () => new Promise(r => { resolve = r; });
a.handleBattleEvents([['layer', 'p_0', 'sargonShip', 1]]);
a.stopBondCue('battle'); resolve({ duration: 1 }); await flush();
assert.equal(sources.length, 0);
a.layerGain('sargonShip', 1);
a.stopBondCue('battle'); resolve({ duration: 1 }); await flush();
assert.equal(sources.length, 1); assert.equal(sources[0].started, true);
a.stopBondCue();
});
test('battle layer tuples use the same exclusive channel; late field replay has no layer cue', async () => {
const { a, sources } = harness();
const events = [['layer', 'p_0', 'sargonShip', 1], ['layer', 'p_1', 'kjeragShip', 2]];
a.handleBattleEvents(events); await flush(); assert.equal(sources.length, 1);
a.stopBondCue();
assert.equal(events.some(keepEarly), false);
a.handleBattleEvents(audioEarly(events)); await flush(); assert.equal(sources.length, 1);
});
test('a prep update that adds layers and activates a bond plays one cue, not a cut-off restart', async () => {
const { a, sources } = harness();
a.layerGain('sargonShip', 2);
await flush();
// screens/game.js: the active-bond count rose in the same update — it keeps the layer cue
if (!a.bondCueRecent('prep')) a.sfx('bondUp', { volume: 0.7, scope: 'prep' });
await flush();
assert.equal(sources.length, 1, 'one source');
assert.equal(sources[0].stopped, false, 'the layer cue is not cut off');
// an activation with no layer cue of its own still sounds
const b = harness();
if (!b.a.bondCueRecent('prep')) b.a.sfx('bondUp', { volume: 0.7, scope: 'prep' });
await flush();
assert.equal(b.sources.length, 1);
});
+13 -10
View File
@@ -1,6 +1,6 @@
// Real-server browser E2E of the community reports of 2026-10-06, items 51 / 55 (the boss round's prep). A co-op match
// jumped to the boss round (fastServer SP_START_ROUND=boss), 2 humans (seats 0 and 3) + 2 AI seats, every board placed
// by the jump (SP_AUTO_PLACE), so the pairs are (host, AI 1) and (AI 2, guest) — the guest is the right-hand player.
// by the jump (SP_AUTO_PLACE). Pairing is random; both clients must use the published pair and side.
// * item 51: each human's prep shows its own pieces on its half (the guest's mirrored onto the right half) and its
// partner's pieces on the other half (read-only 'm:<uid>' views from m.private bossMate, facing as the battle will
// place them), the leader standing on the boss field; the team panel frames the pair's two avatars (green, the
@@ -33,6 +33,7 @@ const prepView = (c) => c.page.evaluate(() => {
const pick = (pre) => entries.filter(([k]) => String(k).startsWith(pre)).map(([, x]) => ({ id: x.info?.defId, owner: x.info?.ownerId ?? null, x: Math.round(x.x), y: Math.round(x.y), dir: x.info?.dir ?? null }));
return {
me: s.me.playerId, phase: s.match.public?.phase, camKind: v?.debug?.camKind ?? null, mode: v?.mode ?? null,
pair: s.match.public?.bossPairs?.find(g => g.includes(s.me.playerId)) ?? [],
field: s.match.field ? { fieldId: s.match.field.fieldId, kind: s.match.field.kind, prep: !!s.match.field.prep, side: s.match.field.side ?? null } : null,
own: pick('p:'), mates: pick('m:'), scout: entries.filter(([k]) => typeof k === 'number' || /^\d+$/.test(String(k))).length,
leader: v?.debug?.leader ? { key: v.debug.leader.key, shown: !!v.debug.leader.view?.root?.visible } : null,
@@ -68,6 +69,7 @@ async function coopAtBoss(P, base, prefix) {
if (s.phase !== 'BAND_DRAFT') { picked.add(c.label); continue; }
if (picked.has(c.label) || s.draft?.turn !== s.me) continue;
await c.click('.dband:not(.is-taken)', null, { nth: c === host ? 2 : 5 });
await c.click('.draft-detail__btns .btn--primary', '确认选择');
picked.add(c.label);
}
await sleep(250);
@@ -91,15 +93,16 @@ describe('the boss round\'s prep: the pair together, the frames, a watcher\'s bo
const gv = await prepView(guest);
await host.shot('prep');
await guest.shot('prep');
// the host holds the left half, its AI partner the right one; the guest the right half, its AI partner the left
assert.equal(hv.camKind, 'bossPrep');
assert.equal(gv.camKind, 'bossPrep');
assert.deepEqual([hv.bossMate?.side, gv.bossMate?.side], ['R', 'L'], JSON.stringify({ hv: hv.bossMate, gv: gv.bossMate }));
assert.ok(hv.own.length >= 4 && hv.own.every((u) => u.x <= 10), `host's pieces on the left half: ${JSON.stringify(hv.own)}`);
assert.ok(gv.own.length >= 4 && gv.own.every((u) => u.x >= 10), `guest's pieces mirrored onto the right half: ${JSON.stringify(gv.own)}`);
assert.ok(hv.mates.length >= 4 && hv.mates.every((u) => u.x >= 10 && u.owner === hv.bossMate.playerId), `the AI partner's pieces on the right half: ${JSON.stringify(hv.mates)}`);
assert.ok(gv.mates.length >= 4 && gv.mates.every((u) => u.x <= 10 && u.owner === gv.bossMate.playerId), `the AI partner's pieces on the left half: ${JSON.stringify(gv.mates)}`);
assert.ok(hv.mates.some((u) => u.dir === 'LEFT'), 'mirrored onto the right half: RIGHT reads LEFT');
for (const v of [hv, gv]) {
assert.equal(v.camKind, 'bossPrep');
assert.equal(v.pair.length, 2);
const right = v.pair.indexOf(v.me) === 1;
assert.equal(v.bossMate?.playerId, v.pair.find(id => id !== v.me));
assert.equal(v.bossMate.side, right ? 'L' : 'R');
assert.ok(v.own.length >= 4 && v.own.every(u => right ? u.x >= 10 : u.x <= 10), `own side: ${JSON.stringify(v)}`);
assert.ok(v.mates.length >= 4 && v.mates.every(u => (right ? u.x <= 10 : u.x >= 10) && u.owner === v.bossMate.playerId));
assert.ok((right ? v.own : v.mates).some(u => u.dir === 'LEFT'), 'the right-half units mirror their facing');
}
assert.ok(hv.leader?.shown && gv.leader?.shown, 'the leader stands on the boss field for both');
assert.deepEqual(hv.framed.sort(), [hv.names[hv.me], hv.names[hv.bossMate.playerId]].sort(), 'the host and its partner framed');
assert.deepEqual(gv.framed.sort(), [gv.names[gv.me], gv.names[gv.bossMate.playerId]].sort(), 'the guest and its partner framed');
+3 -4
View File
@@ -15,7 +15,6 @@ import {
rangeGridBox, shortcutFor, sanitizeSettings, DEFAULT_SETTINGS, normalizeResult, cycleField, fieldLabel, homeFieldId,
activeBubbles, sortedPlayers, tileKey, prepCapsuleLabel, prepCamera, dropFailureReason, terrainInfo,
} from '../../public/js/ui/gameLogic.js';
import { pairPlayers } from '../../server/match/finalAssault.js';
import { PHASE, GEO } from '../../shared/constants.js';
import { setLang, setMessages } from '../../shared/i18n.js';
@@ -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');
+168
View File
@@ -0,0 +1,168 @@
// The existing stronghold.loadout export carries every operator's potential/cultivation, including DIY-only ones.
// Real server + headless Chrome, normal UI input only; no storage/store/network mutation. No art is required.
// SP_E2E=1 node --test test/ui/loadout-ops-export.e2e.test.js
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { Client, hasChrome, startRealServer } from '../e2e/client.mjs';
const KALTS = 'char_003_kalts';
const INSIDE = 'chess_char_1_01_a';
const SLOT = 'chess_char_6_diy1_a';
const slot = `.diy-slot[data-slot="${SLOT}"]`;
const picker = '[data-testid="diy-picker"]';
const io = '[data-testid="loadout-io-text"]';
describe('loadout export includes DIY-only cultivation (real server, headless Chrome)', {
skip: !(process.env.SP_E2E === '1' && hasChrome()) && 'set SP_E2E=1 (and have Chrome)',
}, () => {
let srv, P;
before(async () => { srv = await startRealServer(); P = (await import('puppeteer-core')).default; });
after(async () => { await srv?.stop(); });
async function open(label) {
const c = new Client(P, srv.base, label, { prefix: 'loadout-ops-export', w: 1280, h: 800 });
try {
await c.open();
await c.enter('调配回归');
await c.click('.lobby-screen [data-testid="loadout-open"]');
await c.page.waitForSelector('.lo-card');
return c;
} catch (e) { await c.close(); throw e; }
}
const tab = (c, name) => c.click(`.lo-tab[data-tab="${name}"]`);
const disabled = (c, id) => c.page.$eval(`[data-testid="${id}"]`, e => e.disabled);
const saved = (c) => c.page.evaluate(() => {
const p = JSON.parse(localStorage.getItem('sp.pref.loadout') || '{}');
return { entries: p.entries || {}, ops: p.ops || {} };
});
async function chooseKalts(c) {
await tab(c, 'diy');
await c.click(`${slot} :is(.diy-slot__fill, [data-testid="diy-change"])`);
await c.page.waitForSelector(picker);
await c.click(`${picker} input[type="search"]`);
await c.page.keyboard.type('凯尔希');
await c.click(`${picker} .diy-opt[data-char="${KALTS}"]`);
await c.click('[data-testid="diy-confirm"]');
await c.page.waitForSelector(picker, { hidden: true });
}
async function setKalts(c, patch) {
await tab(c, 'diy');
for (const [key, value] of Object.entries(patch)) await c.page.select(`${slot} select[data-cult="${key}"]`, String(value));
}
async function changeNormal(c) {
await tab(c, 'loadout');
await c.click(`.lo-card[data-chess="${INSIDE}"] .lo-q--skill[data-skill="0"]`);
await c.page.select(`.lo-card[data-chess="${INSIDE}"] select[data-cult="potential"]`, '2');
}
async function exportText(c, id = 'loadout-export') {
assert.equal(await disabled(c, id), false, `${id} must allow the saved settings to be exported`);
await c.click(`[data-testid="${id}"]`);
await c.page.waitForSelector(io);
const text = await c.page.$eval(io, e => e.value);
await c.page.keyboard.press('Escape');
await c.page.waitForSelector(io, { hidden: true });
return text;
}
async function importText(c, text, id = 'loadout-import') {
await c.click(`[data-testid="${id}"]`);
await c.page.waitForSelector(io);
await c.click(io);
await c.page.keyboard.type(text);
await c.click('[data-testid="loadout-io-apply"]');
await c.page.waitForSelector(io, { hidden: true });
}
for (const [name, normal, patch] of [
['DIY potential only', false, { potential: 1 }],
['DIY cultivation only', false, { cultivate: 0 }],
['DIY potential and cultivation', false, { potential: 1, cultivate: 0 }],
['normal only', true, null],
['normal and DIY', true, { potential: 1, cultivate: 0 }],
]) {
test(`${name}: export then import in a fresh browser preserves entries and ops`, { timeout: 90000 }, async () => {
let c, target, diyText;
try {
c = await open(name);
if (patch) {
await chooseKalts(c);
await setKalts(c, patch);
await c.shot(`${name.replaceAll(' ', '-')}-settings`);
diyText = await exportText(c, 'diy-export');
const diy = JSON.parse(diyText);
assert.equal(diy.kind, 'stronghold.diy');
assert.equal('ops' in diy, false, 'the DIY roster format stays unchanged');
assert.equal(diy.picks[SLOT].charId, KALTS);
}
if (normal) await changeNormal(c);
await tab(c, 'loadout');
const expected = await saved(c);
if (patch) assert.deepEqual(expected.ops[KALTS], patch);
if (!normal) {
assert.deepEqual(expected.entries, {});
assert.match(await c.page.$eval('.lo-top__right', e => e.textContent), /已调整\s*0/);
assert.equal(await c.page.$eval('.lo-top__right button:last-child', e => e.disabled), true,
'normal reset still covers only its roster');
}
console.log(JSON.stringify({ scenario: name, saved: expected, exportDisabled: await disabled(c, 'loadout-export') }));
await c.shot(`${name.replaceAll(' ', '-')}-before-export`);
const text = await exportText(c);
const payload = JSON.parse(text);
assert.equal(payload.kind, 'stronghold.loadout');
assert.equal(payload.v, 1);
assert.equal(payload.count, normal ? 1 : 0, 'count retains its existing entry semantics');
assert.deepEqual({ entries: payload.entries, ops: payload.ops }, expected);
await c.shot(`${name.replaceAll(' ', '-')}-exported`);
target = await open(`${name} import`);
if (diyText) {
await tab(target, 'diy');
await importText(target, diyText, 'diy-import');
await tab(target, 'loadout');
}
await importText(target, text);
assert.deepEqual(await saved(target), expected, 'a fresh browser keeps the complete exported loadout');
await target.page.reload({ waitUntil: 'domcontentloaded' });
await target.page.waitForSelector('.lobby-screen');
assert.deepEqual(await saved(target), expected, 'the imported loadout persists across reload');
if (patch) {
assert.deepEqual(await target.page.evaluate(() => JSON.parse(localStorage.getItem('sp.pref.diy')).picks), JSON.parse(diyText).picks);
await target.click('.lobby-screen [data-testid="loadout-open"]');
await tab(target, 'diy');
await target.page.waitForSelector(`${slot} select[data-cult]`);
assert.deepEqual(await target.page.$$eval(`${slot} select[data-cult]`, els => Object.fromEntries(els.map(e => [e.dataset.cult, Number(e.value)]))),
{ potential: patch.potential ?? 6, cultivate: patch.cultivate ?? 3 }, 'the restored pick displays its restored settings');
await target.shot(`${name.replaceAll(' ', '-')}-imported`);
}
assert.deepEqual([...c.problems, ...target.problems].filter(p => p.startsWith('pageerror:')), []);
console.log(JSON.stringify({ scenario: name, payload, imported: await saved(target), resourceProblems: c.problems.length + target.problems.length }));
} finally { await target?.close(); await c?.close(); }
});
}
test('defaults, clearing a pick and normal reset preserve the existing scopes', { timeout: 90000 }, async () => {
let c;
try {
c = await open('reset');
assert.equal(await disabled(c, 'loadout-export'), true);
await chooseKalts(c);
await tab(c, 'loadout');
assert.equal(await disabled(c, 'loadout-export'), true, 'a roster choice alone is not loadout settings');
await setKalts(c, { potential: 1, cultivate: 0 });
await changeNormal(c);
await c.click('.lo-top__right button', '全部恢复默认');
await c.click('.modal button', '恢复默认');
await c.page.waitForSelector('.modal', { hidden: true });
assert.deepEqual(await saved(c), { entries: {}, ops: { [KALTS]: { potential: 1, cultivate: 0 } } });
assert.equal(await disabled(c, 'loadout-export'), false, 'normal reset retains exportable DIY settings');
await tab(c, 'diy');
await c.click(`${slot} [data-testid="diy-clear"]`);
await tab(c, 'loadout');
assert.equal(await disabled(c, 'loadout-export'), false, 'clearing a pick retains its per-operator settings');
await chooseKalts(c);
await setKalts(c, { potential: 6, cultivate: 3 });
await tab(c, 'loadout');
assert.deepEqual(await saved(c), { entries: {}, ops: {} });
assert.equal(await disabled(c, 'loadout-export'), true, 'back to defaults: nothing to export');
assert.deepEqual(c.problems.filter(p => p.startsWith('pageerror:')), []);
} finally { await c?.close(); }
});
});
+7 -1
View File
@@ -200,7 +200,13 @@ describe('GitHub issue #8 item 1: 本局信息 in the strategy draft (real serve
const arcane = await centre(page, '.minfo-dlg .brief-bond[data-bond="arcaneShip"] .bond__disc');
await page.mouse.move(arcane.x, arcane.y);
await page.waitForSelector('.tooltip.is-shown', { visible: true, timeout: 3000 });
assert.equal(await page.$eval('.tooltip.is-shown', (el) => el.textContent), '奥术:本局禁用(该盟约不会激活)');
assert.equal(await page.$eval('.tooltip.is-shown .tooltip__main', (el) => el.textContent), '奥术:本局禁用(该盟约不会激活)');
const listed = await page.$$eval('.tooltip--aside .brief-bantip__txt b', els => els.map(e => e.textContent));
const arcaneNames = await page.evaluate(() => {
const { store, data } = globalThis.__SP__;
return store.get().match.public.bannedChess.map(id => data.lookup('chess', id)).filter(c => c?.bonds?.includes('arcaneShip')).map(c => c.name).sort();
});
assert.deepEqual(listed.sort(), arcaneNames, 'the aside shows precisely this bond\'s banned members');
const status = await page.$eval('[data-testid="match-info-status"]', (el) => el.textContent.trim());
assert.equal(status, '轮到你决策', 'solo: untimed — no seconds');
const box = await page.$eval('.minfo-dlg', (el) => { const r = el.getBoundingClientRect(); return { top: r.top, bottom: r.bottom, left: r.left, right: r.right }; });
+12
View File
@@ -106,6 +106,18 @@ test('MatchBondRow: greyed by state (✕ disc), the banned-member badge, the red
assert.equal(badge('deputShip'), undefined);
assert.equal(tipOf('deputShip').props.text, '助力');
assert.equal(disc('deputShip').props.tier, DATA.bonds.deputShip.thresholds.length);
// the box beside the tip: one line per banned member (avatar, name, its bonds with this one highlighted); none ⇒ null
const aside = tipOf('preciShip').props.aside;
const ops = [...walk(aside)].filter((v) => hasClass(v, 'brief-bantip__op'));
const bannedPreci = DATA.bonds.preciShip.visibleMembers.filter((id) => PUB.bannedChess.includes(id));
assert.deepEqual(ops.map((v) => v.props['data-chess']), bannedPreci);
for (const op of ops) {
const c = DATA.chess[op.props['data-chess']];
assert.ok([...walk(op)].some((v) => v.type === UnitThumb && v.props.id === c.chessId));
assert.match(textOf(op), new RegExp('^' + c.name));
assert.ok([...walk(op)].some((v) => hasClass(v, 'is-this') && textOf(v) === '精准'));
}
assert.equal(tipOf('deputShip').props.aside, null);
});
test('MatchLegend: the grey and the badge; "或本模式禁用" only when the mode switches bonds off', () => {
+44
View File
@@ -910,6 +910,50 @@ describe('in-match UI (mock harness, headless Chrome)', { skip: !ENABLED && 'set
});
}
test('session replacement: recovery action stays clickable above the AI away overlay', async () => {
const { page, problems } = await open('phase=PREP', { render: 'fallback' });
await page.click('.gtop__exit');
await page.waitForSelector('.modal');
const buttons = await page.$$('.modal__actions .btn');
await buttons[1].click(); // 暂离
await page.waitForSelector('.awayov[aria-label="AI 托管中"]', { visible: true });
await page.evaluate(async () => {
const { net } = await import('/js/net.js');
globalThis.__RECOVERY_CONNECTS__ = 0;
net.connect = () => { globalThis.__RECOVERY_CONNECTS__++; };
globalThis.__MOCK__.store.set({ connection: { status: 'closed', ping: 0, attempt: 0, retryAt: 0, lastError: { code: 'REPLACED' }, everOnline: true } });
});
await page.waitForSelector('.conn-banner .btn', { visible: true });
await sleep(300);
assert.deepEqual(await page.evaluate(() => {
const conn = globalThis.__MOCK__.store.get().connection;
return [conn.status, conn.lastError.code];
}), ['closed', 'REPLACED']);
assert.ok(await page.$('.awayov'), 'the away overlay remains during recovery');
assert.match(await page.$eval('.conn-banner', (el) => el.textContent), /该身份已在其他页面登录/);
assert.equal(await page.$eval('.conn-banner .btn', (el) => el.textContent.trim()), '在此页面继续');
const center = await page.$eval('.conn-banner .btn', (btn) => {
const r = btn.getBoundingClientRect();
const x = r.left + r.width / 2, y = r.top + r.height / 2;
return { x, y, hit: btn.contains(document.elementFromPoint(x, y)) };
});
assert.ok(center.hit, 'the recovery button receives pointer input above the away overlay');
await page.screenshot({ path: path.join(OUT, 'ui-recovery-above-away.png') });
await page.evaluate(async () => {
const { confirmDialog } = await import('/js/ui/components.js');
void confirmDialog({ title: 'Recovery layering check', text: 'Global dialog stays above connection recovery.' });
});
await page.waitForSelector('.modal');
assert.equal(await page.$eval('.conn-banner .btn', btn => {
const r = btn.getBoundingClientRect(); return btn.contains(document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2));
}), false, 'the connection banner does not steal input from a global modal');
await page.keyboard.press('Escape'); await page.waitForSelector('.modal', { hidden: true });
await page.mouse.click(center.x, center.y);
assert.equal(await page.evaluate(() => globalThis.__RECOVERY_CONNECTS__), 1, 'the pointer click invokes the recovery action');
assert.deepEqual(problems.filter((p) => p.startsWith('pageerror:')), []);
await page.close();
});
test('watch: the other pair of the Final Assault is refused and the eye stays put', async () => {
const { page, problems } = await open('phase=FINAL_ASSAULT');
const rows = await page.$$('.team__row');
+6 -6
View File
@@ -1,5 +1,5 @@
// Regression tests (Node) of user playtest #3, HUD / UI items:
// 2 the own LP drops live while the own battle's enemies enter the blue gate — pendingLoss / liveLp (the settle
// 2 the own LP's pending loss is marked live while the own battle's enemies enter the blue gate — pendingLoss / liveLp (the settle
// rule min(lpCapPerRound, counted leaks), reset when the settled m.private lands, 联防中 during 联防), the team
// panel rows (rowLp: own live value, teammates' m.public players[].pendingLp), the LP tower's −N tick, and the
// server's pendingLp (server/match/match/views.js _pendingLpView)
@@ -59,7 +59,7 @@ const hasClass = (v, c) => typeof v?.props?.class === 'string' && v.props.class.
// ---- 2: live LP ----------------------------------------------------------------------------------------------------
describe('2: the own LP drops live while the battle runs', () => {
describe("2: the own LP's pending loss is marked live while the battle runs", () => {
test('pendingLoss is the settle rule: min(lpCapPerRound, counted leaks), never negative', () => {
assert.equal(pendingLoss(0), 0);
assert.equal(pendingLoss(3), 3);
@@ -150,7 +150,7 @@ describe('2: the own LP drops live while the battle runs', () => {
assert.deepEqual(rowLp({ lp: 30 }, { phase: PHASE.FINAL_ASSAULT }, { lp: 99, pending: 5 }), { lp: 30, pending: 0, unite: false, left: null }, 'boss rounds: the row keeps its own share');
});
test('LP tower: lp − pending in red with a −N tick keyed by the pending value (it pops again on every leak), the 联防中 tag', () => {
test('LP tower: the settled LP in red with a −N tick keyed by the pending value (it pops again on every leak) — the official keeps the current value until settlement (GitHub #479), the 联防中 tag', () => {
const plain = LpTower({ value: 24, size: 'lg' });
assert.ok(!hasClass(plain, 'is-pending'));
assert.equal(textOf(plain), '24');
@@ -160,11 +160,11 @@ describe('2: the own LP drops live while the battle runs', () => {
assert.equal(pend.key, 3, 'keyed: a new count re-mounts the tick (its pop animation plays again)');
assert.equal(textOf(pend), '−3');
const val = [...walk(v)].find((n) => hasClass(n, 'lp__val'));
assert.equal(textOf(val), '21');
assert.equal(textOf(val), '24', 'the number keeps the settled LP; the −3 tick carries the pending loss (GitHub #479)');
assert.equal(v.props.title, 'x');
const unite = LpTower({ value: 5, pending: 9, note: '联防中' });
assert.equal(textOf([...walk(unite)].find((n) => hasClass(n, 'lp__val'))), '0', 'clamped');
assert.equal(textOf([...walk(unite)].find((n) => hasClass(n, 'lp__pend'))), '−5');
assert.equal(textOf([...walk(unite)].find((n) => hasClass(n, 'lp__val'))), '5');
assert.equal(textOf([...walk(unite)].find((n) => hasClass(n, 'lp__pend'))), '−5', 'the tick clamps at the LP left');
assert.equal(textOf([...walk(unite)].find((n) => hasClass(n, 'lp__note'))), '联防中');
assert.equal(textOf(LpTower({ value: null, pending: 3 })), '--');
});
+31
View File
@@ -0,0 +1,31 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { prepCamera, deployFieldOf, teamFrameIds } from '../../public/js/ui/gameLogic/camera.js';
import { makeMatch } from '../match/harness.js';
import { PHASE } from '../../shared/constants.js';
for (const humans of [2, 3, 4]) test(`random ${humans}-player boss preparation: client camera, placement and partner frames match server`, () => {
const h = makeMatch({ mode: 'coop', difficulty: 'HARD', humans, seed: 31, fake: true }).start();
try {
h.drive(() => h.m.phase === PHASE.PREP && h.m.round === 14);
const pub = h.m.publicView();
assert.deepEqual(pub.bossPairs, h.m.bossPairs);
for (const p of h.m.alivePlayers()) {
const expected = h.m.bossGroupOf(p);
assert.equal(prepCamera(pub, p.playerId).opts.side, expected.side);
assert.equal(deployFieldOf(pub, p.playerId), h.m.deployFieldOf(p));
assert.deepEqual([...teamFrameIds(pub, p.playerId)], expected.players.length > 1 ? expected.players : []);
}
h.m.onLeave(h.m.bossPairs[0][0]);
const after = h.m.publicView();
for (const p of h.m.alivePlayers()) assert.equal(deployFieldOf(after, p.playerId), h.m.deployFieldOf(p));
} finally { h.m.dispose(); }
});
test('unknown/new pairing is not guessed from seats; older servers still have their ordered fallback', () => {
const pub = { phase: PHASE.PREP, round: 14, bossRound: 14, players: [{ playerId: 'a', seat: 0 }, { playerId: 'b', seat: 1 }] };
assert.equal(prepCamera(pub, 'b').opts.side, 'R');
assert.equal(prepCamera({ ...pub, bossPairs: null }, 'b').kind, 'prep');
assert.equal(prepCamera({ ...pub, bossPairs: [['b', 'a']] }, 'b').opts.side, 'L');
assert.deepEqual([...teamFrameIds({ ...pub, bossPairs: [['b', 'a']] }, 'a')], ['b', 'a']);
});
+84
View File
@@ -0,0 +1,84 @@
// test/ui/refresh-tap.e2e.test.js — GitHub #501 (single-ptilopsis): 高延迟下连按「刷新」只发送一次请求. Through the mock
// harness (public/dev/game-mock.html) in headless Chrome, with the mock's reply to g.refresh delayed: the first tap
// sends one request and dims the button, a spam tap and the R key while it is on the wire send nothing, the shop pays
// once, and the next tap after the reply refreshes again. Opt-in: SP_E2E=1 (needs Chrome; CHROME_PATH overrides).
//
// SP_E2E=1 node --test test/ui/refresh-tap.e2e.test.js
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync } from 'node:fs';
const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const ENABLED = process.env.SP_E2E === '1' && existsSync(CHROME);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const BTN = '.shopbar__tools .toolbtn--amber';
describe('the shop refresh single-flights (GitHub #501, mock harness, headless Chrome)', { skip: !ENABLED && 'set SP_E2E=1 (and have Chrome) to run' }, () => {
let srv;
let browser;
let base;
before(async () => {
const { startServer } = await import('../../server/index.js');
const puppeteer = (await import('puppeteer-core')).default;
srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
base = `http://127.0.0.1:${srv.port}`;
browser = await puppeteer.launch({ executablePath: CHROME, headless: true, args: ['--no-sandbox'] });
});
after(async () => {
await browser?.close();
await srv?.close();
});
test('a tap under a lagging shop pays once: the button dims, the extra tap / R key are dropped, the next tap works', async () => {
const page = await browser.newPage();
await page.setViewport({ width: 1920, height: 1080, deviceScaleFactor: 1 });
await page.goto(`${base}/dev/game-mock.html?shot=1&phase=PREP&render=fallback`, { waitUntil: 'networkidle0' });
await page.waitForFunction(() => !!document.querySelector('.screen:not(.gload)') && !!globalThis.__SP_VIEW__, { timeout: 20000 });
// the mock answers instantly; give g.refresh a high RTT, and count what the client actually sends (gameActions.js
// looks net.request up at call time; the mock's own request record only sees the request after the delay)
await page.evaluate(async () => {
const { net } = await import('/js/net.js');
const orig = net.request.bind(net);
globalThis.__REFRESHES__ = 0;
net.request = (t, ...rest) => {
if (t === 'g.refresh') globalThis.__REFRESHES__ += 1;
return new Promise((res, rej) => {
setTimeout(() => orig(t, ...rest).then(res, rej), t === 'g.refresh' ? 700 : 0);
});
};
});
const btnState = () => page.evaluate(() => {
const b = document.querySelector('.shopbar__tools .toolbtn--amber');
return { disabled: !!b?.disabled, title: b?.title || null };
});
const refreshes = () => page.evaluate(() => globalThis.__REFRESHES__);
const funds = () => page.evaluate(() => globalThis.__MOCK__.S().priv.funds);
assert.equal((await btnState()).disabled, false, 'the 刷新 button starts enabled');
const n0 = await refreshes();
const f0 = await funds();
await page.click(BTN);
await sleep(150);
assert.equal(await refreshes(), n0 + 1, 'the tap sent one g.refresh');
assert.deepEqual(await btnState(), { disabled: true, title: '正在刷新…' }, 'the button dims and says it is waiting');
// the spam tap (the disabled button swallows clicks) and the R hotkey while pending: neither reaches the socket
await page.evaluate(() => document.querySelector('.shopbar__tools .toolbtn--amber')?.click());
await page.keyboard.press('r');
await sleep(200);
assert.equal(await refreshes(), n0 + 1, 'nothing else was sent');
await page.waitForFunction(() => !document.querySelector('.shopbar__tools .toolbtn--amber')?.disabled, { timeout: 5000 });
const back = await btnState();
assert.equal(back.disabled, false, 'the button is enabled again after the reply');
assert.match(back.title, /刷新商店/, 'the tooltip is back to the hotkey hint');
assert.equal(await funds(), f0 - 1, 'the shop paid exactly one refresh');
await page.click(BTN);
await sleep(150);
assert.equal(await refreshes(), n0 + 2, 'a refresh after the reply is sent again');
await page.waitForFunction(() => !document.querySelector('.shopbar__tools .toolbtn--amber')?.disabled, { timeout: 5000 });
assert.equal(await funds(), f0 - 2, 'and paid once more');
await page.close();
});
});
+87
View File
@@ -0,0 +1,87 @@
// test/ui/refresh-tap.test.js — GitHub #501 (single-ptilopsis): 高延迟下连按「刷新」会重复扣费. The intent layer now
// single-flights it (ui/gameActions.js): a tap while the previous g.refresh is still on the wire is dropped — the
// button dims through onRefreshBusy (game.js → ui/shopBar.js) and the R hotkey (the same actions.refresh) stays inert
// until the reply lands. The browser run of the same case is test/ui/refresh-tap.e2e.test.js (SP_E2E=1).
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const read = (p) => readFileSync(path.join(ROOT, p), 'utf8');
const { net } = await import('../../public/js/net.js');
const { actions, onRefreshBusy } = await import('../../public/js/ui/gameActions.js');
describe('the shop refresh single-flights (GitHub #501)', () => {
test('a refresh while the previous one is on the wire sends nothing; the busy signal brackets the request; a later one sends again', async () => {
const sent = [];
let release;
const prev = net.request;
net.request = (t) => { sent.push(t); return new Promise((r) => { release = () => r({ ok: true }); }); };
const seen = [];
const off = onRefreshBusy((b) => seen.push(b));
try {
assert.deepEqual(seen, [false], 'subscribing fires once with the current (idle) state');
const first = actions.refresh();
assert.equal(sent.length, 1);
assert.equal(seen.at(-1), true, 'busy from the moment of the send');
assert.equal(await actions.refresh(), false, 'the duplicate resolves false without waiting');
assert.equal(sent.length, 1, 'and never reaches the socket');
release();
assert.equal(await first, true);
assert.deepEqual(seen, [false, true, false], 'the reply clears the busy state');
const again = actions.refresh();
assert.equal(sent.length, 2, 'a refresh after the reply is sent again');
release();
assert.equal(await again, true);
assert.deepEqual(seen, [false, true, false, true, false]);
} finally {
off();
net.request = prev;
}
});
test('an unsubscribe stops updates, and a remount receives the in-flight state', async () => {
const seen = [];
const off = onRefreshBusy((b) => seen.push(b));
off();
const prev = net.request;
let release;
net.request = () => new Promise(resolve => { release = resolve; });
let offNew;
try {
const pending = actions.refresh();
const remounted = [];
offNew = onRefreshBusy(b => remounted.push(b));
assert.deepEqual(remounted, [true]);
release({ ok: true }); await pending;
assert.deepEqual(remounted, [true, false]); assert.deepEqual(seen, [false]);
} finally { offNew?.(); net.request = prev; }
});
for (const code of ['OFFLINE', 'DISCONNECTED', 'TIMEOUT', 'NOT_ENOUGH_FUNDS']) test(`${code} clears the pending lock without sending the duplicate`, async () => {
const prev = net.request, states = []; let reject, sent = 0;
net.request = () => { sent++; return new Promise((_, fail) => { reject = fail; }); };
const off = onRefreshBusy(b => states.push(b));
try {
const first = actions.refresh();
assert.equal(await actions.refresh(), false); assert.equal(sent, 1);
reject(Object.assign(new Error(code), { code }));
assert.equal(await first, false); assert.deepEqual(states, [false, true, false]);
net.request = async () => { sent++; return { ok: true }; };
assert.equal(await actions.refresh(), true); assert.equal(sent, 2);
} finally { off(); net.request = prev; }
});
test('the wiring: game.js subscribes and hands the state to the bar; the button dims and says what it waits for', () => {
const game = read('public/js/screens/game.js');
assert.match(game, /onRefreshBusy\(setRefreshBusy\)/, 'the game screen subscribes');
assert.match(game, /<\$\{ShopBar\}[\s\S]*?refreshBusy=\$\{refreshBusy\}/, 'and the bar receives it');
const bar = read('public/js/ui/shopBar.js');
assert.match(bar, /disabled=\$\{!!refReason \|\| refreshBusy\}/, 'busy disables the 刷新 button');
assert.match(bar, /t\('正在刷新…'\)/, 'and its tooltip says what it is waiting for');
});
});
+120
View File
@@ -0,0 +1,120 @@
// Isolated component/tool fixtures, with production modules and CSS in a real browser.
import { test, before, after, describe } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { CHROME, hasChrome } from '../e2e/client.mjs';
describe('round30 tool and compound-tooltip browser boundaries', { skip: !(process.env.SP_E2E === '1' && hasChrome()) }, () => {
let srv, browser, page;
before(async () => {
const { startServer } = await import('../../server/index.js');
srv = await startServer({ host: '127.0.0.1', port: 0, quiet: true });
const P = (await import('puppeteer-core')).default;
browser = await P.launch({ executablePath: CHROME, headless: true, args: ['--no-sandbox', '--disable-background-timer-throttling'] });
page = await browser.newPage();
page.on('pageerror', e => console.error('fixture page error:', e.message));
});
after(async () => { await browser?.close(); await srv?.close(); });
async function blank(query = '') {
await page.goto(`http://127.0.0.1:${srv.port}/dev/simcheck/ui.js${query}`);
await page.setContent('<html><head><link rel="stylesheet" href="/css/theme.css"><link rel="stylesheet" href="/css/components.css"></head><body><main id="fixture"></main></body></html>');
}
async function tooltip(width, height, anchor = '50%', touch = false) {
await page.setViewport({ width, height, hasTouch: touch }); await blank();
await page.evaluate(async (anchor) => {
const { h, render } = await import('/vendor/preact.module.js');
const { Tooltip, TooltipLayer } = await import('/js/ui/components.js');
document.documentElement.style.cssText = 'font-size:100px;--t:130px';
const root = document.getElementById('fixture');
root.style.cssText = `position:fixed;top:45%;left:${anchor};font-size:16px`;
window.changeTip = (names = 'A', aside = true) => render(h('div', {},
h(Tooltip, { text: 'same count: 12', aside: aside ? h('div', {}, ...Array.from({ length: 12 }, (_, i) => h('div', {}, `${names}-${i} Long operator name`))) : null }, h('button', { id: 'anchor' }, 'bond')),
h(TooltipLayer)), root);
window.changeTip();
}, anchor);
await page.hover('#anchor'); await page.waitForSelector('.tooltip.is-shown');
}
for (const [w, h] of [[1440, 900], [390, 844], [844, 390]]) test(`compound tooltip fits ${w}×${h}, all anchor positions`, async () => {
for (const anchor of ['1%', '50%', '90%']) {
await tooltip(w, h, anchor);
const box = await page.$eval('.tooltip', e => {
const a = e.getBoundingClientRect(), main = e.querySelector('.tooltip__main').getBoundingClientRect(), side = e.querySelector('.tooltip--aside').getBoundingClientRect();
return { left: a.left, right: a.right, top: a.top, bottom: a.bottom, overlap: main.left < side.right && side.left < main.right && main.top < side.bottom && side.top < main.bottom,
overflow: e.scrollHeight > e.clientHeight, canScroll: getComputedStyle(e).overflowY === 'auto' && getComputedStyle(e).pointerEvents === 'auto' };
});
assert.ok(box.left >= 7 && box.right <= w - 7 && box.top >= 3 && box.bottom <= h - 3, JSON.stringify(box));
assert.equal(box.overlap, false); if (box.overflow) assert.equal(box.canScroll, true);
await page.keyboard.press('Escape'); await page.waitForSelector('.tooltip', { hidden: true });
}
});
test('visible tooltip updates equal-count member lists and removes an absent aside', async () => {
await tooltip(800, 600);
await page.evaluate(() => window.changeTip('B'));
await page.waitForFunction(() => document.querySelector('.tooltip--aside')?.textContent.includes('B-11'));
assert.equal(await page.$eval('.tooltip', e => e.textContent.includes('A-')), false);
await page.evaluate(() => window.changeTip('B', false));
await page.waitForSelector('.tooltip--aside', { hidden: true });
await page.mouse.move(1, 1); await page.hover('#anchor'); await page.waitForSelector('.tooltip.is-shown');
assert.equal(await page.$eval('.tooltip', e => getComputedStyle(e).pointerEvents), 'none');
});
test('touch long press keeps the long list scrollable and outside tap closes it', async () => {
await tooltip(390, 260, '40%', true);
await page.keyboard.press('Escape'); await page.$eval('#anchor', e => e.blur());
const rect = await page.$eval('#anchor', e => { const r = e.getBoundingClientRect(); return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; });
await page.touchscreen.touchStart(rect.x, rect.y);
await page.waitForSelector('.tooltip.is-shown'); await page.touchscreen.touchEnd();
await new Promise(r => setTimeout(r, 1750));
assert.ok(await page.$('.tooltip.is-shown'));
await page.focus('.tooltip'); await page.keyboard.press('End');
await page.waitForFunction(() => document.querySelector('.tooltip')?.scrollTop > 0);
await page.touchscreen.tap(2, 2); await page.waitForSelector('.tooltip', { hidden: true });
});
const toolHtml = readFileSync(new URL('../../public/dev/sim-check.html', import.meta.url), 'utf8').replace(/<script[\s\S]*?<\/script>/g, '');
async function tool({ mode = '', query = '', count = 1 } = {}) {
await blank(query); await page.setContent(toolHtml);
await page.evaluate(async ({ mode, count }) => {
const { mountCheck } = await import('/dev/simcheck/ui.js');
const { trace } = await import('/dev/simcheck/core.js');
const scenarios = Array.from({ length: count }, (_, i) => ({ id: `case-${i}`, family: 'fast' }));
window.failAt = mode;
const adapter = {
start: () => { if (window.failAt === 'start') throw Error('start failure'); return { tick: 0 }; },
step: r => { if (window.failAt === 'step') throw Error('step failure'); return ++r.tick <= 2; },
state: r => [r.tick + (window.failAt === 'diff' ? 1 : 0)],
result: () => { if (window.failAt === 'result') throw Error('result failure'); return 'ok'; },
};
const saved = window.failAt; window.failAt = '';
const reference = { every: 1, scenarios: Object.fromEntries(scenarios.map(s => [s.id, trace(adapter, s, { every: 1 })])) };
window.failAt = saved;
mountCheck(document.getElementById('app'), { project: 'test', title: 'check', subtitle: '<img src=x onerror="window.injected=true">', adapter, scenarios,
subsets: [{ id: 'fast', label: 'fast', pick: () => true }], reference });
}, { mode, count });
}
for (const mode of ['start', 'step', 'result']) test(`sim check recovers from ${mode} exceptions and allows rerun`, async () => {
await tool({ mode }); await page.click('.sc-run');
await page.waitForFunction(() => window.__simcheck?.status === 'error');
assert.equal(await page.$eval('.sc-run', b => b.disabled), false);
assert.equal(await page.evaluate(() => !!window.injected), false);
await page.evaluate(() => { window.failAt = ''; }); await page.click('.sc-run');
await page.waitForFunction(() => window.__simcheck?.status === 'done');
assert.deepEqual(await page.evaluate(() => [window.__simcheck.expected, window.__simcheck.completed, window.__simcheck.summary.ok]), [1, 1, 1]);
});
for (const query of ['?only=typo', '?subset=typo']) test(`sim check rejects empty or unknown selection ${query}`, async () => {
await tool({ query }); await page.click('.sc-run');
await page.waitForFunction(() => window.__simcheck?.status === 'error');
assert.equal(await page.$('.sc-allok'), null);
});
test('long difference list scrolls to its report; a failed dump restores its button', async () => {
await page.setViewport({ width: 390, height: 400 }); await tool({ mode: 'diff', count: 30 });
await page.click('.sc-run'); await page.waitForFunction(() => window.__simcheck?.status === 'done');
assert.equal(await page.$$eval('.sc-list li', rows => rows.length), 30);
await page.evaluate(() => { window.failAt = 'start'; }); await page.click('.sc-list button');
await page.waitForFunction(() => document.querySelector('.sc-status').textContent.includes('start failure'));
assert.equal(await page.$eval('.sc-list button', b => b.disabled), false);
await page.$eval('.sc-report', e => e.scrollIntoView());
assert.ok(await page.evaluate(() => window.scrollY > 0));
assert.ok(await page.$eval('.sc-report', e => e.getBoundingClientRect().top < window.innerHeight));
assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth));
});
});