0.2.3: prepare follow-up fixes and client features (#407, #408, #410, #413, #431, #435, #436, #440, #444)
Add special-target activation without bypassing attack cadence, cooperative card preferences and emotes, skill sound banks, per-operator voice choices, text sizing, install prompts, closed-tab recovery and unanimous setup rerolls. Retain late deployment elapsed time and resolve echo/counter pulses without recursive hook overflow. Independent final review remains pending. Golden: roster-016, roster-035, roster-036, roster-045; boss-boss_1-pair, boss-boss_3-pair, boss-boss_5-pair, boss-boss_7-pair, hidden-boss_9-pair; solo-HARD-9-boosted: Lemuen attack cadence. diy-132: Necras extra activation targets. coop2-NORMAL-14-standins: cooperative purchase preference and simulation fixes. hidden-boss_10-solo: echo pulse FIFO resolution. See DESIGN 28.13-28.24 for assumptions, sources and contributor credit.
This commit is contained in:
1 parent
314564c139
commit
f3658a0dd4
104 files changed
+3674
-741
No files matched your search
@@ -1308,3 +1308,49 @@ describe('multi-device & browser compatibility (static)', () => {
|
||||
assert.match(src, /--tap-min: 44px/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('explicit local match recovery (#431)', () => {
|
||||
test('an idle tab can reclaim a closed match tab without taking a live one', async () => {
|
||||
const { createIdentity } = await mod('net.js');
|
||||
const local = memStorage(), hub = channelHub(), ca = hub.create(), cb = hub.create();
|
||||
const a = createIdentity({ local, session: memStorage(), tabId: 'a', channel: ca, queryMs: 5 });
|
||||
const b = createIdentity({ local, session: memStorage(), tabId: 'b', channel: cb, queryMs: 5 });
|
||||
await a.init(); a.saveToken('idle-token');
|
||||
await b.init(); b.saveToken('match-token'); b.rememberMatch({ name: 'Doctor B', code: 'ABC123' });
|
||||
const [item] = a.recoverable();
|
||||
assert.equal(item.name, 'Doctor B');
|
||||
assert.equal(JSON.stringify(item).includes('match-token'), false);
|
||||
assert.equal(await a.resume(item.id), false, 'live original keeps its seat');
|
||||
assert.equal(a.getToken(), 'idle-token');
|
||||
cb.close();
|
||||
assert.equal(await a.resume(item.id), true);
|
||||
assert.equal(a.getToken(), 'match-token');
|
||||
a.rememberMatch(null);
|
||||
assert.deepEqual(JSON.parse(local.getItem('sp.matches')), {});
|
||||
ca.close();
|
||||
});
|
||||
test('simultaneous recoveries pick one winner; absent channels and unknown ids cannot recover', async () => {
|
||||
const { createIdentity } = await mod('net.js');
|
||||
const local = memStorage(), hub = channelHub();
|
||||
local.setItem('sp.tokens', JSON.stringify(['idle-b', 'idle-a']));
|
||||
const sa = memStorage(), sb = memStorage(); sa.setItem('sp.token', 'idle-a'); sb.setItem('sp.token', 'idle-b');
|
||||
const a = createIdentity({ local, session: sa, tabId: 'a', channel: hub.create(), queryMs: 5 });
|
||||
const b = createIdentity({ local, session: sb, tabId: 'b', channel: hub.create(), queryMs: 5 });
|
||||
await Promise.all([a.init(), b.init()]);
|
||||
const closed = createIdentity({ local, session: memStorage(), channel: null });
|
||||
closed.saveToken('closed-token'); closed.rememberMatch({ name: 'C', code: 'XYZ' });
|
||||
const id = a.recoverable()[0].id;
|
||||
assert.deepEqual(await Promise.all([a.resume(id), b.resume(id)]), [true, false]);
|
||||
assert.equal(await b.resume('unknown'), false);
|
||||
assert.equal(await closed.resume(id), false);
|
||||
});
|
||||
test('server replacement clears expired metadata and malformed storage remains safe', async () => {
|
||||
const { createIdentity } = await mod('net.js');
|
||||
const local = memStorage(); const a = createIdentity({ local, session: memStorage(), channel: null });
|
||||
a.saveToken('old'); a.rememberMatch({ name: 'D', code: 'AAA' }); a.saveToken('new');
|
||||
assert.deepEqual(a.recoverable(), []);
|
||||
for (const bad of ['null', '[]', '{', '"hello"']) {
|
||||
local.setItem('sp.matches', bad); assert.deepEqual(a.recoverable(), []);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
// Real reflected hits against a scaled hit-count enemy must finish without recursive hook overflow.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { makeBattle, checkInvariants } from '../helpers/battleHarness.js';
|
||||
const ECHO = 'enemy_9023_acdums';
|
||||
for (const chessId of ['chess_char_1_06_b', 'chess_char_2_08_a']) {
|
||||
test(`${chessId}: a 67-hit echo resolves all counter pulses including its lethal hit`, () => {
|
||||
const h = makeBattle({ units: [{ chessId, row: 10, col: 4 }], autoFinish: false, captureNoisy: true, hooks: ['damaged'] });
|
||||
h.step();
|
||||
const u = h.unit(chessId);
|
||||
h.b.addBuff(u, { key: 'test:survive', persist: true, mods: { hpFlat: 1e8 } });
|
||||
u.hp = u.s.maxHp;
|
||||
u.skill.sp = u.skill.spCost; u.skill.charges = 1;
|
||||
assert.ok(u.skill.activate('manual'));
|
||||
const echo = h.spawn(ECHO, { pos: [10, 5], mods: { hpMul: 6.7, speedMul: 0 } });
|
||||
assert.equal(echo.s.maxHp, 67);
|
||||
const t = h.eventsOf('fx').length;
|
||||
h.b.dealDamage(u, echo, { amount: 1, type: 'arts', isAttack: true });
|
||||
assert.equal(echo.alive, false);
|
||||
assert.equal(h.b.errors.length, 0);
|
||||
assert.equal(h.eventsOf('fx').slice(t).filter((f) => f[1] === 'explode' && f[4]?.kind === 'echoPulse').length, 67);
|
||||
assert.equal(echo.mem.ab.pulsing, false);
|
||||
assert.deepEqual(echo.mem.ab.pulses, []);
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
}
|
||||
@@ -59,3 +59,11 @@ test('immediate redeployment cannot attack through a cleared wanted range', () =
|
||||
h.step();assert.equal(u.stats.attacks, before, 'the allies phase sees the cleared range immediately');
|
||||
done(h);
|
||||
});
|
||||
|
||||
for (const skillIndex of [0, 1, 2]) test(`wanted S${skillIndex + 1} waits for the next attack, not the next tick`, () => {
|
||||
const { h, u, e } = field({ skillIndex });
|
||||
u.skill.sp = 0;h.run(8.5);assert.ok(e.findBuff('lemuen:wanted'));
|
||||
u.atkCd = 0.7;u.skill.gainSp(999);
|
||||
h.run(0.3);assert.equal(u.skill.activations, 0);
|
||||
h.run(0.6);assert.equal(u.skill.activations, 1);done(h);
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
// #440: special targeting supplies the DEFAULT attack opportunity, without extending S2 links.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
|
||||
import { COLS } from '../../server/sim/constants.js';
|
||||
|
||||
function field(skillIndex, kind = 'unite') {
|
||||
const h = makeBattle({ kind, timeLimit: 90, autoFinish: false, seed: 5,
|
||||
flags: { dpPerSec: 0, dpMax: 999 }, hooks: ['skillStart', 'damaged'], captureNoisy: true,
|
||||
defs: { enemies: { weak: enemyRec({ key: 'weak', hp: 100, speed: 0, mass: 0 }),
|
||||
walker: enemyRec({ key: 'walker', hp: 1e9, speed: 0.5, mass: 0 }) } },
|
||||
units: [{ uid: 1, elite: true, row: 10, col: 3,
|
||||
diy: { slot: 'chess_char_6_diy1_a', charId: 'char_450_necras', skillIndex, uniEquipId: null } }],
|
||||
});
|
||||
h.step();
|
||||
const u = h.unit(1);u.skill.sp = 0;
|
||||
const weak = h.spawn('weak', { pos: [10, 6] });h.step();h.b.kill(weak, null);h.run(0.2);
|
||||
const summon = u.mem.necras.skels.find((s) => s.alive);assert.ok(summon);
|
||||
const e = h.spawn('walker', { pos: [10, 9] });
|
||||
assert.ok(h.runUntil(() => e.blockedBy === summon, 20));h.step(3);
|
||||
const key = Math.round(e.y) * COLS + Math.round(e.x);
|
||||
assert.ok(!u.baseRangeKeys.includes(key) && u.extraRangeKeys.includes(key));
|
||||
return { h, u, e, summon };
|
||||
}
|
||||
function done(h) { checkInvariants(h.b);assert.deepEqual(h.b.errors, []); }
|
||||
for (const kind of ['normal', 'unite']) for (const skillIndex of [0, 1]) {
|
||||
test(`Necras S${skillIndex + 1} starts on its next attack against a summon-blocked remote target (${kind})`, () => {
|
||||
const { h, u, e } = field(skillIndex, kind);
|
||||
u.skill.gainSp(999);u.atkCd = 0.7;
|
||||
h.run(0.3);assert.equal(u.skill.activations, 0, 'retain attack cadence');
|
||||
h.run(0.6);assert.equal(u.skill.activations, 1);
|
||||
if (skillIndex === 1) assert.ok(!u.mem.necrasLinks?.some((l) => l.e === e), 'remote trigger does not extend S2 effect range');
|
||||
done(h);
|
||||
});
|
||||
}
|
||||
test('remote Necras trigger needs a selectable enemy and the owning summon', () => {
|
||||
const { h, u, e, summon } = field(1);
|
||||
e.base.moveSpeed = 0;e.markDirty();
|
||||
h.b.applyStatus(e, 'sleep', { duration: 5 });u.skill.gainSp(999);u.atkCd = 0;
|
||||
h.run(1);assert.equal(u.skill.activations, 0, 'sleep cannot open DEFAULT');
|
||||
h.b.retreat(summon, { reason: 'test' });
|
||||
h.run(5);assert.equal(u.skill.activations, 0, 'expired block does not retain an extra trigger');
|
||||
done(h);
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
// test/feedback6-leizi2-attack-sfx.test.js — user report 「司霆惊蛰三技能攻击没有音效」 (2026-10-08).
|
||||
//
|
||||
// 司霆惊蛰 (char_1043_leizi2, 自选 6★ 解放者) attacks only while a skill runs (the trait: 通常不攻击), and the official
|
||||
// client has no normal-mode bank for her at all: every bank of hers is a numbered ABILITY variant whose file names the
|
||||
// skill mode — excel/audio_data.json:
|
||||
//
|
||||
// battle.ON_ABILITY_ON.char_1043_leizi2.attack.1.2.1 → p_atk_lzxqlkl_d1 技能1
|
||||
// battle.ON_ABILITY_START.char_1043_leizi2.attack.4 → p_atk_lzxqlkl_h2 技能2 (ON also carries _h)
|
||||
// battle.ON_ABILITY_START.char_1043_leizi2.attack.7 → p_atk_lzxqlkl_s 技能3
|
||||
//
|
||||
// `pickUnitSfx` refuses skill-mode files for an operator's NORMAL attack (community report #54, 银灰 — the file of a
|
||||
// skill mode must not ring on every normal attack), so she had no `attack` sound at all and her attacks were silent.
|
||||
// The fix keeps that rule and adds the mode's own file per skill index: tools/assets/audio.mjs pickModeAttacks() reads
|
||||
// the mode letter of each bank's file (_d / _h / _s = 技能1 / 2 / 3, the same letter an operator's activation sounds
|
||||
// use), plan.mjs writes it as sfx.units[id].attacks = { '<skillIndex>': url }, and public/js/audio.js plays it while
|
||||
// that skill is active (the ['skill', id, 1] / 0 events) — never outside it.
|
||||
//
|
||||
// Run: node --test test/feedback6-leizi2-attack-sfx.test.js
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { pickModeAttacks, skillModeLetter, SLOT_MODE_LETTER } from '../tools/assets/audio.mjs';
|
||||
|
||||
const P = (f) => `player/${f.startsWith('p_imp') ? 'p_imp' : 'p_atk'}/${f}.mp3`;
|
||||
const LEIZI2 = 'char_1043_leizi2';
|
||||
const manifest = JSON.parse(readFileSync(new URL('../data/assets.json', import.meta.url), 'utf8'));
|
||||
|
||||
/** 司霆惊蛰's own banks, exactly as excel/audio_data.json lists them. */
|
||||
const LEIZI2_BANKS = new Map([
|
||||
['ON_UNIT_BORN', [P('b_char_lzxqlkl')]],
|
||||
['ON_ABILITY_ON.attack.1.2.1', [P('p_atk_lzxqlkl_d1')]],
|
||||
['ON_ABILITY_HIT.attack.1.2.1', [P('p_imp_lzxqlkl_n')]],
|
||||
['ON_ABILITY_HIT.attack.1.2.2', [P('p_imp_lzxqlkl_n')]],
|
||||
['ON_ABILITY_HIT.attack.1.2.3', [P('p_imp_lzxqlkl_n')]],
|
||||
['ON_ABILITY_START.attack.4', [P('p_atk_lzxqlkl_h2')]],
|
||||
['ON_ABILITY_ON.attack.4', [P('p_atk_lzxqlkl_h')]],
|
||||
['ON_ABILITY_HIT.attack.4', [P('p_imp_lzxqlkl_h')]],
|
||||
['ON_ABILITY_START.attack.7', [P('p_atk_lzxqlkl_s')]],
|
||||
['ON_UNIT_DEAD', [P('b_char_dead')]],
|
||||
]);
|
||||
|
||||
test('pickModeAttacks: 司霆惊蛰 gets one attack sound per skill mode (d / h / s), the START bank first', () => {
|
||||
assert.deepEqual(pickModeAttacks(LEIZI2_BANKS), {
|
||||
d: [P('p_atk_lzxqlkl_d1')],
|
||||
h: [P('p_atk_lzxqlkl_h2')], // ON_ABILITY_START.attack.4, not the ON_ABILITY_ON.attack.4 (_h) variant
|
||||
s: [P('p_atk_lzxqlkl_s')],
|
||||
});
|
||||
// nothing else is taken from her banks: the _n impacts and the born / dead sounds are pickUnitSfx's business
|
||||
assert.deepEqual(Object.keys(pickModeAttacks(LEIZI2_BANKS)).sort(), ['d', 'h', 's']);
|
||||
});
|
||||
|
||||
test('pickModeAttacks: only ability banks, only skill-mode files, nothing for a normal-mode unit', () => {
|
||||
// a normal attack bank (_n) is never a mode: it stays the operator's normal attack (pickUnitSfx)
|
||||
assert.deepEqual(pickModeAttacks(new Map([['ON_ABILITY_START.attack', [P('p_atk_sword_n')]]])), {});
|
||||
// the unsuffixed default mode of an operator whose library is normal-mode only
|
||||
assert.deepEqual(pickModeAttacks(new Map([['ON_ABILITY_HIT.combat', [P('p_imp_spear_n')]]])), {});
|
||||
// a mode file under a non-attack ability (a talent, a mode switch) is not an attack sound
|
||||
assert.deepEqual(pickModeAttacks(new Map([['ON_ABILITY_START.T.1', [P('p_atk_x_s')]]])), {});
|
||||
// empty / absent banks
|
||||
assert.deepEqual(pickModeAttacks(new Map()), {});
|
||||
assert.deepEqual(pickModeAttacks(undefined), {});
|
||||
// ON_ABILITY_ON only (no START bank): still read
|
||||
assert.deepEqual(pickModeAttacks(new Map([['ON_ABILITY_ON.attack.2', [P('p_atk_firesword_h')]]])), { h: [P('p_atk_firesword_h')] });
|
||||
});
|
||||
|
||||
test('skillModeLetter / SLOT_MODE_LETTER: 技能1 / 2 / 3 = _d / _h / _s (+ digits), _n is the normal mode', () => {
|
||||
assert.equal(skillModeLetter(P('p_atk_lzxqlkl_s')), 's');
|
||||
assert.equal(skillModeLetter(P('p_atk_lzxqlkl_d1')), 'd');
|
||||
assert.equal(skillModeLetter('/assets/audio/sfx/player/p_skill/p_skill_khlrftcrdfr_h.mp3'), 'h');
|
||||
assert.equal(skillModeLetter(P('p_atk_sword_n')), null);
|
||||
assert.equal(skillModeLetter(P('p_atk_snwlprdg_n1')), null);
|
||||
assert.equal(skillModeLetter(null), null);
|
||||
assert.deepEqual(SLOT_MODE_LETTER, { 0: 'd', 1: 'h', 2: 's' });
|
||||
});
|
||||
|
||||
test('the committed manifest carries her three mode attacks under the skill indexes', () => {
|
||||
const u = manifest.audio.sfx.units[LEIZI2];
|
||||
assert.equal(u.attack, undefined, 'she has no normal-mode attack bank — that was the silent attack');
|
||||
assert.deepEqual(u.attacks, {
|
||||
0: '/assets/audio/sfx/player/p_atk/p_atk_lzxqlkl_d1.mp3',
|
||||
1: '/assets/audio/sfx/player/p_atk/p_atk_lzxqlkl_h2.mp3',
|
||||
2: '/assets/audio/sfx/player/p_atk/p_atk_lzxqlkl_s.mp3',
|
||||
});
|
||||
// her activation sounds (the other half of the chain) name the same modes: _d / _h / _s for 技能1 / 2 / 3
|
||||
assert.deepEqual(u.skills, {
|
||||
0: '/assets/audio/sfx/player/p_skill/p_skill_lzxqlkl_d.mp3',
|
||||
1: '/assets/audio/sfx/player/p_skill/p_skill_lzxqlkl_h.mp3',
|
||||
2: '/assets/audio/sfx/player/p_skill/p_skill_lzxqlkl_s.mp3',
|
||||
});
|
||||
assert.equal(u.hit, '/assets/audio/sfx/player/p_imp/p_imp_lzxqlkl_n.mp3', 'her normal impact is untouched');
|
||||
});
|
||||
|
||||
test('every manifest unit with both tables agrees with the mode convention (d / h / s = 技能1 / 2 / 3)', () => {
|
||||
const units = manifest.audio.sfx.units;
|
||||
let both = 0;
|
||||
for (const [id, u] of Object.entries(units)) {
|
||||
if (!u?.attacks || !u?.skills) continue;
|
||||
both++;
|
||||
for (const [i, url] of Object.entries(u.attacks)) {
|
||||
const a = skillModeLetter(url);
|
||||
assert.ok(a, `${id}: attacks[${i}] is a skill-mode file (${url})`);
|
||||
assert.equal(a, SLOT_MODE_LETTER[i], `${id}: attacks[${i}] is 技能${Number(i) + 1} (the _${a} mode)`);
|
||||
}
|
||||
}
|
||||
assert.ok(both >= 5, `some operators carry both tables (found ${both})`);
|
||||
});
|
||||
|
||||
test('no operator takes a skill-mode file as its normal attack or impact (community report #54 stays fixed)', () => {
|
||||
const units = manifest.audio.sfx.units;
|
||||
for (const [id, u] of Object.entries(units)) {
|
||||
if (!id.startsWith('char_')) continue;
|
||||
for (const kind of ['attack', 'hit']) {
|
||||
const url = u?.[kind];
|
||||
if (typeof url !== 'string') continue;
|
||||
assert.equal(skillModeLetter(url), null, `${id}.${kind} = ${url} is a skill mode's file`);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
// test/feedback7-chen3-s3-sfx.test.js — user report 「赤刃明霄陈开启三技能后斩击音效应该和普通攻击不一样」 (2026-10-08).
|
||||
//
|
||||
// 赤刃明霄陈 (char_1050_chen3) has both kinds of bank, and BOTH of her S3's sounds differ from her normal attack —
|
||||
// excel/audio_data.json, her ability banks (`attack.<n>` = the mode; the mode is in the file name):
|
||||
//
|
||||
// battle.ON_ABILITY_START.char_1050_chen3.attack.0 → p_atk_hljdswd_n 普通攻击(默认形态)
|
||||
// battle.ON_ABILITY_HIT.char_1050_chen3.attack.0 → p_imp_hljdswd_n 普通攻击的命中
|
||||
// battle.ON_ABILITY_START.char_1050_chen3.attack.1 → p_atk_hljdfswd_d 技能1
|
||||
// battle.ON_ABILITY_HIT.char_1050_chen3.attack.1.0 → p_imp_hljdrfswd_d 技能1 的命中
|
||||
// battle.ON_ABILITY_HIT.char_1050_chen3.attack.1.1 → p_imp_hljdylfswd_d 技能1 的命中(第二个变体)
|
||||
// battle.ON_ABILITY_START.char_1050_chen3.attack.3 → p_atk_hljdswd_s 技能3
|
||||
// battle.ON_ABILITY_HIT.char_1050_chen3.attack.3 → p_imp_hljdswd_s 技能3 的命中 ← 这就是「斩击」
|
||||
//
|
||||
// `pickUnitSfx` refuses skill-mode files for the NORMAL attack / impact (社区反馈 #54, 银灰) and therefore keeps the `_n`
|
||||
// pair — correct. The fix of 司霆惊蛰 (test/feedback6-…) already plays the mode's own swing while the skill runs; this
|
||||
// report is the other half: without a per-mode IMPACT the S3 slash still landed with p_imp_hljdswd_n, i.e. it sounded
|
||||
// like her normal attack. tools/assets/audio.mjs pickModeHits() now reads `ON_ABILITY_HIT` per mode (same mode letters,
|
||||
// same slot rule), plan.mjs writes it as sfx.units[id].hits = { '<skillIndex>': url }, and public/js/audio.js plays it for
|
||||
// an attack that was aimed while that skill ran (the attacker's state travels with its impact bookkeeping).
|
||||
//
|
||||
// Run: node --test test/feedback7-chen3-s3-sfx.test.js
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { pickModeAttacks, pickModeHits, skillModeLetter, SLOT_MODE_LETTER } from '../tools/assets/audio.mjs';
|
||||
|
||||
const P = (f) => `player/${f.startsWith('p_imp') ? 'p_imp' : 'p_atk'}/${f}.mp3`;
|
||||
const CHEN3 = 'char_1050_chen3';
|
||||
const manifest = JSON.parse(readFileSync(new URL('../data/assets.json', import.meta.url), 'utf8'));
|
||||
|
||||
/** 赤刃明霄陈's own banks, exactly as excel/audio_data.json lists them. */
|
||||
const CHEN3_BANKS = new Map([
|
||||
['ON_ABILITY_START.attack.0', [P('p_atk_hljdswd_n')]],
|
||||
['ON_ABILITY_HIT.attack.0', [P('p_imp_hljdswd_n')]],
|
||||
['ON_ABILITY_START.attack.1', [P('p_atk_hljdfswd_d')]],
|
||||
['ON_ABILITY_HIT.attack.1.0', [P('p_imp_hljdrfswd_d')]],
|
||||
['ON_ABILITY_HIT.attack.1.1', [P('p_imp_hljdylfswd_d')]],
|
||||
['ON_ABILITY_START.attack.3', [P('p_atk_hljdswd_s')]],
|
||||
['ON_ABILITY_HIT.attack.3', [P('p_imp_hljdswd_s')]],
|
||||
]);
|
||||
|
||||
test('pickModeAttacks / pickModeHits: 赤刃明霄陈 S3 的挥砍与命中都和普通攻击不同(技能1 两个命中变体取第一个)', () => {
|
||||
assert.deepEqual(pickModeAttacks(CHEN3_BANKS), { d: [P('p_atk_hljdfswd_d')], s: [P('p_atk_hljdswd_s')] });
|
||||
assert.deepEqual(pickModeHits(CHEN3_BANKS), { d: [P('p_imp_hljdrfswd_d')], s: [P('p_imp_hljdswd_s')] });
|
||||
// the normal pair (`attack.0`, files ending in `_n`) belongs to pickUnitSfx, never to a mode
|
||||
assert.equal(skillModeLetter(P('p_atk_hljdswd_n')), null);
|
||||
assert.equal(skillModeLetter(P('p_imp_hljdswd_n')), null);
|
||||
});
|
||||
|
||||
test('pickModeHits: only ON_ABILITY_HIT banks, and a mode without one keeps the unit\'s normal impact', () => {
|
||||
// a START bank alone is a swing, not an impact
|
||||
assert.deepEqual(pickModeHits(new Map([['ON_ABILITY_START.attack.4', [P('p_atk_lzxqlkl_h2')]]])), {});
|
||||
// 司霆惊蛰: 技能2 swings and lands `_h`, 技能3 has no HIT bank at all (its slash keeps the normal impact)
|
||||
const leizi = new Map([
|
||||
['ON_ABILITY_ON.attack.1.2.1', [P('p_atk_lzxqlkl_d1')]],
|
||||
['ON_ABILITY_HIT.attack.4', [P('p_imp_lzxqlkl_h')]],
|
||||
['ON_ABILITY_START.attack.7', [P('p_atk_lzxqlkl_s')]],
|
||||
]);
|
||||
assert.deepEqual(pickModeHits(leizi), { h: [P('p_imp_lzxqlkl_h')] });
|
||||
assert.deepEqual(pickModeAttacks(leizi), { d: [P('p_atk_lzxqlkl_d1')], s: [P('p_atk_lzxqlkl_s')] });
|
||||
assert.deepEqual(pickModeHits(new Map()), {});
|
||||
assert.deepEqual(pickModeHits(undefined), {});
|
||||
});
|
||||
|
||||
test('the committed manifest: her S3 swing and impact are the `_s` pair, her normal pair is untouched', () => {
|
||||
const u = manifest.audio.sfx.units[CHEN3];
|
||||
assert.equal(u.attack, '/assets/audio/sfx/player/p_atk/p_atk_hljdswd_n.mp3', '普通攻击的挥砍');
|
||||
assert.equal(u.hit, '/assets/audio/sfx/player/p_imp/p_imp_hljdswd_n.mp3', '普通攻击的命中');
|
||||
assert.equal(u.attacks?.['2'], '/assets/audio/sfx/player/p_atk/p_atk_hljdswd_s.mp3', 'S3 的挥砍');
|
||||
assert.equal(u.hits?.['2'], '/assets/audio/sfx/player/p_imp/p_imp_hljdswd_s.mp3', 'S3 的斩击(命中)');
|
||||
assert.equal(u.attacks?.['0'], '/assets/audio/sfx/player/p_atk/p_atk_hljdfswd_d.mp3', '技能1 的挥砍');
|
||||
assert.equal(u.hits?.['0'], '/assets/audio/sfx/player/p_imp/p_imp_hljdrfswd_d.mp3', '技能1 的命中');
|
||||
});
|
||||
|
||||
test('every manifest unit with a hits table agrees with the mode convention (d / h / s = 技能1 / 2 / 3)', () => {
|
||||
const units = manifest.audio.sfx.units;
|
||||
let n = 0;
|
||||
for (const [id, u] of Object.entries(units)) {
|
||||
for (const [i, url] of Object.entries(u?.hits || {})) {
|
||||
n++;
|
||||
const letter = skillModeLetter(url);
|
||||
assert.ok(letter, `${id}: hits[${i}] is a skill-mode file (${url})`);
|
||||
assert.equal(letter, SLOT_MODE_LETTER[i], `${id}: hits[${i}] is 技能${Number(i) + 1} (the _${letter} mode)`);
|
||||
}
|
||||
}
|
||||
assert.ok(n >= 10, `some operators carry mode impacts (found ${n})`);
|
||||
});
|
||||
|
||||
test('no operator takes a skill-mode file as its normal attack or impact (community report #54 stays fixed)', () => {
|
||||
const units = manifest.audio.sfx.units;
|
||||
let n = 0;
|
||||
for (const [id, u] of Object.entries(units)) {
|
||||
if (!id.startsWith('char_')) continue;
|
||||
for (const kind of ['attack', 'hit']) {
|
||||
const url = u?.[kind];
|
||||
if (typeof url !== 'string') continue;
|
||||
n++;
|
||||
assert.equal(skillModeLetter(url), null, `${id}.${kind} = ${url} is a skill mode's file`);
|
||||
}
|
||||
}
|
||||
assert.ok(n > 300, `${n} normal attack / impact entries checked`);
|
||||
});
|
||||
+36
-36
@@ -5697,68 +5697,68 @@
|
||||
"round": 13,
|
||||
"seed": 326579774,
|
||||
"waveId": "act1autochess_h06",
|
||||
"time": 196.33,
|
||||
"ticks": 5890,
|
||||
"time": 197.77,
|
||||
"ticks": 5933,
|
||||
"reason": "cleared",
|
||||
"killed": 123,
|
||||
"total": 123,
|
||||
"unspawned": 0,
|
||||
"bossHpLeft": null,
|
||||
"rngDraws": 23,
|
||||
"rngDraws": 25,
|
||||
"errors": {"count":0,"kinds":[]},
|
||||
"players": {"p1":{"killed":123,"total":123,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":1103139,"bossDmg":0,"heal":29758,"deaths":1,"layerGains":{}}},
|
||||
"players": {"p1":{"killed":123,"total":123,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":1103139,"bossDmg":0,"heal":43932,"deaths":0,"layerGains":{}}},
|
||||
"units": [
|
||||
["p1",1,"op","chess_char_5_diy1_b",0,"uniequip_002_necras",2070.2,1,3,0,0,0,0,0,0],
|
||||
["p1",2,"op","chess_char_5_diy2_b",1,"uniequip_002_necras",2070.2,1,4,1,18891,0,0,2,1],
|
||||
["p1",3,"op","chess_char_5_diy1_b",2,"uniequip_002_necras",2070.2,1,11,0,35621,0,0,33,5],
|
||||
["p1",4,"op","chess_char_6_diy1_a",0,null,1103.11,1,13,2,20136,484,0,38,3],
|
||||
["p1",3,"op","chess_char_5_diy1_b",2,"uniequip_002_necras",2070.2,1,11,2,28423,0,0,32,5],
|
||||
["p1",4,"op","chess_char_6_diy1_a",0,null,1103.11,1,13,2,17971,484,0,35,3],
|
||||
["p1",5,"op","chess_char_6_diy2_a",1,null,1587.3,1,5,1,5091,0,0,2,1],
|
||||
["p1",6,"op","chess_char_6_diy1_a",2,null,1587.3,1,8,8,73989,0,0,46,7],
|
||||
["p1",7,"op","chess_char_6_diy2_b",0,"none",1938.2,1,12,3,24655,0,0,37,3],
|
||||
["p1",8,"op","chess_char_6_diy1_b",1,"none",1938.2,1,167,9,89706,1938,0,60,4],
|
||||
["p1",9,"op","chess_char_6_diy2_b",2,"none",1938.2,1,9,20,183323,0,0,82,7],
|
||||
["p1",6,"op","chess_char_6_diy1_a",2,null,1587.3,1,8,6,59953,0,0,37,6],
|
||||
["p1",7,"op","chess_char_6_diy2_b",0,"none",1938.2,1,12,3,40643,0,0,34,6],
|
||||
["p1",8,"op","chess_char_6_diy1_b",1,"none",133.43,1,14,17,152041,1805,0,86,7],
|
||||
["p1",9,"op","chess_char_6_diy2_b",2,"none",1938.2,1,9,12,135375,0,0,57,6],
|
||||
["p1",10,"op","chess_char_6_diy1_b",0,"uniequip_002_necras",2158.2,1,6,0,869,0,0,2,1],
|
||||
["p1",11,"op","chess_char_6_diy2_b",1,"uniequip_002_necras",2158.2,1,7,4,26920,0,0,1,3],
|
||||
["p1",12,"op","chess_char_6_diy1_b",2,"uniequip_002_necras",1932.6,1,10,9,35710,226,0,36,5],
|
||||
["p1",11,"op","chess_char_6_diy2_b",1,"uniequip_002_necras",2158.2,1,7,3,26240,0,0,1,2],
|
||||
["p1",12,"op","chess_char_6_diy1_b",2,"uniequip_002_necras",1932.6,1,10,6,30800,226,0,33,5],
|
||||
["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null],
|
||||
["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null],
|
||||
["p1","~1","token","token_10043_necras_skeltn",null,null,141.28,0,24,0,4555,5621,2399,9,null],
|
||||
["p1","~1","token","token_10043_necras_skeltn",null,null,1814.64,0,24,0,2543,3948,2399,6,null],
|
||||
["p1","~2","token","token_10043_necras_skeltn",null,null,3488,1,27,0,1341,2008,2008,2,null],
|
||||
["p1","~3","token","token_10043_necras_skeltn",null,null,0,0,31,1,3094,3488,0,7,null],
|
||||
["p1","~4","token","token_10043_necras_skeltn",null,null,0,0,32,1,6630,5232,0,4,1],
|
||||
["p1","~5","token","token_10043_necras_skeltn",null,null,0,0,33,0,2913,6512,3024,8,null],
|
||||
["p1","~6","token","token_10043_necras_skeltn",null,null,1374.21,1,35,38,346689,10935,426,48,8],
|
||||
["p1","~6","token","token_10043_necras_skeltn",null,null,4026.44,1,35,43,391096,8283,426,48,8],
|
||||
["p1","~7","token","token_10043_necras_skeltn",null,null,3488,1,40,0,0,0,0,0,null],
|
||||
["p1","~8","token","token_10043_necras_skeltn",null,null,1744,0,41,0,0,0,0,0,null],
|
||||
["p1","~9","token","token_10043_necras_skeltn",null,null,0,0,47,0,202,1535,0,1,0],
|
||||
["p1","~10","token","token_10043_necras_skeltn",null,null,0,0,48,13,104269,9857,2930,12,2],
|
||||
["p1","~11","token","token_10043_necras_skeltn",null,null,0,0,49,2,14104,7574,4334,30,null],
|
||||
["p1","~12","token","token_10043_necras_skeltn",null,null,643.36,0,51,0,231,2845,0,1,0],
|
||||
["p1","~13","token","token_10043_necras_skeltn",null,null,0,0,52,0,5636,3488,0,11,null],
|
||||
["p1","~9","token","token_10043_necras_skeltn",null,null,3431.01,0,45,1,924,57,0,3,null],
|
||||
["p1","~10","token","token_10043_necras_skeltn",null,null,0,0,48,0,202,1535,0,1,0],
|
||||
["p1","~11","token","token_10043_necras_skeltn",null,null,0,0,49,13,104269,9857,2930,12,2],
|
||||
["p1","~12","token","token_10043_necras_skeltn",null,null,0,0,50,2,14104,7574,4334,30,null],
|
||||
["p1","~13","token","token_10043_necras_skeltn",null,null,643.36,0,52,0,231,2845,0,1,0],
|
||||
["p1","~14","token","token_10043_necras_skeltn",null,null,1312.64,0,54,0,1462,1757,0,3,0],
|
||||
["p1","~15","token","token_10043_necras_skeltn",null,null,1535,1,55,0,0,0,0,0,null],
|
||||
["p1","~16","token","token_10043_necras_skeltn",null,null,3070,1,56,0,0,0,0,0,null],
|
||||
["p1","~17","token","token_10043_necras_skeltn",null,null,3488,1,57,0,0,0,0,0,null],
|
||||
["p1","~18","token","token_10043_necras_skeltn",null,null,1535,0,60,0,0,0,0,0,0],
|
||||
["p1","~19","token","token_10043_necras_skeltn",null,null,3070,0,61,0,0,0,0,0,0],
|
||||
["p1","~20","token","token_10043_necras_skeltn",null,null,3070,0,82,0,0,0,0,0,0],
|
||||
["p1","~21","token","token_10043_necras_skeltn",null,null,3488,0,89,0,0,0,0,0,0],
|
||||
["p1","~22","token","token_10043_necras_skeltn",null,null,0,0,96,0,2647,3488,0,6,null],
|
||||
["p1","~23","token","token_10043_necras_skeltn",null,null,3280.31,0,102,0,7450,5000,5413,17,null],
|
||||
["p1","~24","token","token_10043_necras_skeltn",null,null,13603.2,1,103,6,58696,20,26,7,3],
|
||||
["p1","~25","token","token_10043_necras_skeltn",null,null,340.04,0,106,1,6379,3520,745,14,null],
|
||||
["p1","~26","token","token_10043_necras_skeltn",null,null,264.69,1,124,1,3626,3223,0,8,null],
|
||||
["p1","~27","token","token_10043_necras_skeltn",null,null,1672.16,0,130,1,5196,6256,4565,11,0],
|
||||
["p1","~28","token","token_10043_necras_skeltn",null,null,643.36,0,146,0,231,2873,28,1,0],
|
||||
["p1","~29","token","token_10043_necras_skeltn",null,null,1228.28,0,147,1,6396,5161,2902,12,0],
|
||||
["p1","~30","token","token_10043_necras_skeltn",null,null,3337.3,1,157,1,5095,804,653,11,0],
|
||||
["p1","~31","token","token_10043_necras_skeltn",null,null,3459.5,1,168,0,1387,210,305,4,null]
|
||||
["p1","~20","token","token_10043_necras_skeltn",null,null,1072.87,0,77,1,4090,2415,0,9,null],
|
||||
["p1","~21","token","token_10043_necras_skeltn",null,null,3070,0,83,0,0,0,0,0,0],
|
||||
["p1","~22","token","token_10043_necras_skeltn",null,null,3488,0,90,0,0,0,0,0,0],
|
||||
["p1","~23","token","token_10043_necras_skeltn",null,null,0,0,97,0,2647,3488,0,6,null],
|
||||
["p1","~24","token","token_10043_necras_skeltn",null,null,2594.33,0,98,0,798,894,0,2,null],
|
||||
["p1","~25","token","token_10043_necras_skeltn",null,null,3488,1,104,3,27488,17532,18152,57,null],
|
||||
["p1","~26","token","token_10043_necras_skeltn",null,null,5232,1,105,0,0,0,0,0,0],
|
||||
["p1","~27","token","token_10043_necras_skeltn",null,null,0,0,108,1,6842,3860,745,15,null],
|
||||
["p1","~28","token","token_10043_necras_skeltn",null,null,1072.87,0,126,1,4090,2415,0,9,null],
|
||||
["p1","~29","token","token_10043_necras_skeltn",null,null,0,0,130,1,3566,4074,586,8,null],
|
||||
["p1","~30","token","token_10043_necras_skeltn",null,null,3488,1,145,2,8178,9328,9328,16,null],
|
||||
["p1","~31","token","token_10043_necras_skeltn",null,null,2651.32,1,146,0,335,837,0,1,null]
|
||||
],
|
||||
"enemies": {"enemy_1019_jshoot_2":[57,57,0,9221,255302],"enemy_1299_ymkilr_2":[27,27,0,81708,492687],"enemy_1422_lrsldr_2":[39,39,0,3129,355830]},
|
||||
"hooks": [1,169,5890,915,915,1387,1387,138,132,145,54,52,0,36568,14,133,123,0,1,132,0,0,0],
|
||||
"events": {"atk":915,"deploy":44,"die":145,"dmg":1387,"engage":33,"fx":349,"heal":52,"skill":106,"spawn":168,"status":14},
|
||||
"fx": {"aoe":166,"grow":138,"sleep":14,"summon":31},
|
||||
"snaps": "90c70f41 c302dd2b 54cd7edf e58852e9 bff57786 f6e87429 f72cca13 5c878308 5dae900e 9943c059 9d89ea84 f721c49b ce40836f b3189627 f100922e 9c82ebda 12c800d9 bd8c14ef 4b6a90f7 fe7535da"
|
||||
"enemies": {"enemy_1019_jshoot_2":[57,57,0,8725,255302],"enemy_1299_ymkilr_2":[27,27,0,87984,492687],"enemy_1422_lrsldr_2":[39,39,0,3746,355830]},
|
||||
"hooks": [1,168,5933,918,918,1441,1441,159,132,144,54,53,0,36962,19,139,123,0,1,132,0,0,0],
|
||||
"events": {"atk":918,"deploy":43,"die":144,"dmg":1441,"engage":32,"fx":355,"heal":74,"skill":107,"spawn":168,"status":19},
|
||||
"fx": {"aoe":146,"grow":159,"sleep":19,"summon":31},
|
||||
"snaps": "90c70f41 c302dd2b ce97444e f56da20e 7867fc43 c6389923 0e8c4561 4f741322 44a83b86 92652efc ba0316f9 978ef38d 12494606 71523afe 0ac4155c 4b9bbd7b 25d1835c 9345e5a3 94e6cb6a 4af2c8cc"
|
||||
},
|
||||
"diy-133": {
|
||||
"about": "5_diy1_a=kalts2/S1 5_diy2_a=kalts2/S2 5_diy1_a=kalts2/S3 5_diy2_b=kalts2/S1 5_diy1_b=kalts2/S2 5_diy2_b=kalts2/S3 6_diy1_a=kalts2/S1 6_diy2_a=kalts2/S2 6_diy1_a=kalts2/S3 6_diy2_b=kalts2/S1 6_diy1_b=kalts2/S2 6_diy2_b=kalts2/S3",
|
||||
|
||||
+258
-256
@@ -17,42 +17,42 @@
|
||||
"round": 14,
|
||||
"seed": 1379500996,
|
||||
"waveId": "act1autochess_h07_01",
|
||||
"time": 198.03,
|
||||
"ticks": 5941,
|
||||
"reason": "cleared",
|
||||
"time": 200,
|
||||
"ticks": 6000,
|
||||
"reason": "forced",
|
||||
"killed": 20,
|
||||
"total": 16,
|
||||
"unspawned": 0,
|
||||
"bossHpLeft": 0,
|
||||
"rngDraws": 624,
|
||||
"bossHpLeft": 26388,
|
||||
"rngDraws": 616,
|
||||
"errors": {"count":0,"kinds":[]},
|
||||
"players": {
|
||||
"p1": {"killed":20,"total":16,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":2580943,"bossDmg":2141675,"heal":19370,"deaths":1,"layerGains":{}},
|
||||
"p2": {"killed":0,"total":0,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":1723678,"bossDmg":1458325,"heal":61060,"deaths":0,"layerGains":{}}
|
||||
"p1": {"killed":20,"total":16,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":2492630,"bossDmg":2081416,"heal":24007,"deaths":0,"layerGains":{}},
|
||||
"p2": {"killed":0,"total":0,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":1785615,"bossDmg":1492196,"heal":63058,"deaths":0,"layerGains":{}}
|
||||
},
|
||||
"units": [
|
||||
["p1",1,"op","chess_char_6_03_b",2,"uniequip_002_yu",2060.79,1,13,0,0,12547,10824,0,0],
|
||||
["p1",2,"op","chess_char_6_06_b",2,"uniequip_002_pepe",1839.57,1,15,0,0,4688,524,0,0],
|
||||
["p1",3,"op","chess_char_6_07_b",2,"uniequip_002_siege2",2662.34,1,17,0,0,5403,1853,0,0],
|
||||
["p1",4,"op","chess_char_6_01_b",2,"uniequip_002_lemuen",709.43,1,7,8,1345935,1252,0,63,8],
|
||||
["p1",5,"op","chess_char_6_02_b",2,"uniequip_002_sbell2",2126.3,1,9,0,0,1417,0,0,0],
|
||||
["p1",6,"op","chess_char_6_04_b",2,"uniequip_002_skadi2",0,0,19,0,71666,12281,3597,0,4],
|
||||
["p1",7,"op","chess_char_6_05_b",2,"uniequip_002_pasngr",1586.2,1,24,3,640231,1558,444,98,10],
|
||||
["p1",8,"op","chess_char_6_08_b",2,"uniequip_002_reed2",1834.8,1,11,0,0,593,0,0,0],
|
||||
["p1",9,"op","chess_char_6_09_b",0,"uniequip_002_cello",1454.2,1,26,3,308334,2483,51,51,51],
|
||||
["p1",10,"op","chess_char_6_11_b",2,"uniequip_002_mlyss",1015.63,1,21,0,133231,1763,39,214,5],
|
||||
["p1",1,"op","chess_char_6_03_b",2,"uniequip_002_yu",2665.18,1,13,0,0,12709,11440,0,0],
|
||||
["p1",2,"op","chess_char_6_06_b",2,"uniequip_002_pepe",3227.66,1,15,0,0,3107,2118,0,0],
|
||||
["p1",3,"op","chess_char_6_07_b",2,"uniequip_002_siege2",3015.02,1,17,0,0,6076,2611,0,0],
|
||||
["p1",4,"op","chess_char_6_01_b",2,"uniequip_002_lemuen",709.43,1,7,10,1231170,1252,0,61,8],
|
||||
["p1",5,"op","chess_char_6_02_b",2,"uniequip_002_sbell2",2126.3,1,9,0,0,2382,0,0,0],
|
||||
["p1",6,"op","chess_char_6_04_b",2,"uniequip_002_skadi2",1341.02,1,19,0,77075,11593,3192,0,5],
|
||||
["p1",7,"op","chess_char_6_05_b",2,"uniequip_002_pasngr",1586.2,1,24,5,645358,853,853,99,10],
|
||||
["p1",8,"op","chess_char_6_08_b",2,"uniequip_002_reed2",1092.93,1,11,0,0,2188,0,0,0],
|
||||
["p1",9,"op","chess_char_6_09_b",0,"uniequip_002_cello",1454.2,1,26,1,317350,3825,709,51,51],
|
||||
["p1",10,"op","chess_char_6_11_b",2,"uniequip_002_mlyss",2082.3,1,21,0,136761,974,599,216,5],
|
||||
["p1",11,"token","token_10017_skadi2_dedant",null,null,138,0,28,0,0,0,0,0,null],
|
||||
["p1",12,"token","token_10030_mlyss_wtrman",null,null,0,0,22,0,22803,4807,1334,27,1],
|
||||
["p2",13,"op","chess_char_6_15_b",2,"uniequip_002_qiubai",4435.2,1,33,10,787333,16947,0,190,3],
|
||||
["p2",14,"op","chess_char_6_19_b",2,"uniequip_002_blkkgt",3043.7,1,16,1,75440,6403,0,0,1],
|
||||
["p1",12,"token","token_10030_mlyss_wtrman",null,null,0,0,22,0,24621,3767,1334,28,1],
|
||||
["p2",13,"op","chess_char_6_15_b",2,"uniequip_002_qiubai",4435.2,1,33,5,811980,16947,0,192,3],
|
||||
["p2",14,"op","chess_char_6_19_b",2,"uniequip_002_blkkgt",3043.7,1,16,1,75440,2090,0,0,1],
|
||||
["p2",15,"op","chess_char_5_01_b",1,"uniequip_002_excu2",2545.4,1,18,0,0,0,0,0,0],
|
||||
["p2",16,"op","chess_char_6_17_b",2,"uniequip_002_nearl2",3952.3,1,23,1,218717,3293,0,133,4],
|
||||
["p2",17,"op","chess_char_6_12_b",1,"uniequip_002_rosmon",2545.15,1,8,0,0,4493,0,0,0],
|
||||
["p2",18,"op","chess_char_6_13_b",1,"uniequip_002_angel2",2119.76,1,10,0,48487,5750,2454,0,0],
|
||||
["p2",19,"op","chess_char_6_14_b",2,"uniequip_002_lumen",3028.91,1,20,0,0,1127,12010,16,1],
|
||||
["p2",20,"op","chess_char_6_16_b",2,"uniequip_002_halo2",1741.19,1,25,0,235654,1496,0,202,4],
|
||||
["p2",21,"op","chess_char_6_18_b",2,"uniequip_002_whitw2",1695.1,1,12,1,343425,890,0,0,3],
|
||||
["p2",22,"op","chess_char_6_20_b",2,"uniequip_002_agoat2",1828.31,1,27,0,0,0,46597,45,2],
|
||||
["p2",16,"op","chess_char_6_17_b",2,"uniequip_002_nearl2",3952.3,1,23,0,229606,2042,0,134,4],
|
||||
["p2",17,"op","chess_char_6_12_b",1,"uniequip_002_rosmon",3175.48,1,8,0,0,1853,0,0,0],
|
||||
["p2",18,"op","chess_char_6_13_b",1,"uniequip_002_angel2",2412.14,1,10,0,64538,9447,3757,0,0],
|
||||
["p2",19,"op","chess_char_6_14_b",2,"uniequip_002_lumen",3028.91,1,20,0,0,0,6776,9,1],
|
||||
["p2",20,"op","chess_char_6_16_b",2,"uniequip_002_halo2",1741.19,1,25,0,242658,2644,0,204,4],
|
||||
["p2",21,"op","chess_char_6_18_b",2,"uniequip_002_whitw2",804.61,1,12,1,346767,2486,0,0,3],
|
||||
["p2",22,"op","chess_char_6_20_b",2,"uniequip_002_agoat2",1828.31,1,27,0,0,4344,52525,46,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],
|
||||
@@ -60,30 +60,31 @@
|
||||
["-","~5","device","trap_1105_accrate",null,null,100,1,5,0,0,0,0,0,null],
|
||||
["-","~6","device","trap_1105_accrate",null,null,100,1,6,0,0,0,0,0,null],
|
||||
["p2","~1","token","token_10019_nearl2_sword",null,null,8280,0,38,0,3661,0,0,25,null],
|
||||
["p1","~1","token","token_10030_mlyss_wtrman",null,null,875.29,0,39,1,6710,1115,0,12,null],
|
||||
["p1","~1","token","token_10030_mlyss_wtrman",null,null,1989.96,0,39,1,6710,0,0,12,null],
|
||||
["p1","~2","token","token_10030_mlyss_wtrman",null,null,303.83,0,43,0,8824,1571,0,12,null],
|
||||
["p1","~1","token","token_10017_skadi2_dedant",null,null,100,0,53,0,0,0,0,0,null],
|
||||
["p2","~2","token","token_10019_nearl2_sword",null,null,6600,0,55,0,3661,0,0,25,null],
|
||||
["p1","~3","token","token_10030_mlyss_wtrman",null,null,1442,1,58,0,28966,705,705,52,1],
|
||||
["p1","~4","token","token_10030_mlyss_wtrman",null,null,1989.96,0,61,0,6040,0,0,12,null],
|
||||
["p1","~3","token","token_10030_mlyss_wtrman",null,null,1442,1,58,2,31187,1151,1151,53,1],
|
||||
["p1","~4","token","token_10030_mlyss_wtrman",null,null,1989.96,0,61,0,5369,0,0,12,null],
|
||||
["p1","~2","token","token_10017_skadi2_dedant",null,null,138,0,64,0,0,0,0,0,null],
|
||||
["p2","~3","token","token_10019_nearl2_sword",null,null,6600,0,65,0,3654,0,0,25,null],
|
||||
["p1","~5","token","token_10030_mlyss_wtrman",null,null,1442,0,67,0,2686,0,0,12,null],
|
||||
["p2","~3","token","token_10019_nearl2_sword",null,null,6600,0,65,0,3657,0,0,25,null],
|
||||
["p1","~5","token","token_10030_mlyss_wtrman",null,null,1442,0,67,1,3021,0,0,12,null],
|
||||
["p1","~6","token","token_10030_mlyss_wtrman",null,null,1442,0,70,0,4846,0,0,12,null],
|
||||
["p1","~7","token","token_10030_mlyss_wtrman",null,null,1442,1,71,0,672,0,0,3,null],
|
||||
["p2","~4","token","token_10019_nearl2_sword",null,null,6600,1,72,0,3647,0,0,4,null]
|
||||
["p1","~7","token","token_10030_mlyss_wtrman",null,null,1442,1,71,0,337,0,0,4,null],
|
||||
["p2","~4","token","token_10019_nearl2_sword",null,null,6600,1,72,0,3648,0,0,6,null],
|
||||
["p1","~3","token","token_10017_skadi2_dedant",null,null,100,1,74,0,0,0,0,0,null]
|
||||
],
|
||||
"enemies": {
|
||||
"enemy_1005_yokai": [4,4,0,0,13759],
|
||||
"enemy_1006_shield_2": [14,14,0,19099,601974],
|
||||
"enemy_1061_zomshd": [2,2,0,1052,88636],
|
||||
"enemy_9013_acstmk": [1,1,0,46552,3600000],
|
||||
"enemy_9016_acstmr": [8,7,0,0,251]
|
||||
"enemy_9013_acstmk": [1,0,0,46337,3573612],
|
||||
"enemy_9016_acstmr": [8,7,0,0,263]
|
||||
},
|
||||
"hooks": [1,71,5941,1319,1319,3122,3493,3694,30,42,98,94,69,41511,2716,10,29,0,1,29,0,21,0],
|
||||
"events": {"atk":1625,"deploy":36,"die":42,"dmg":3192,"engage":19,"fx":854,"heal":336,"skill":192,"spawn":70,"status":607},
|
||||
"fx": {"airstrike":26,"beam":54,"blink":1,"bombard":69,"bombardShell":69,"burst":21,"copy":2,"dp":5,"drone":328,"droneLock":18,"drones":3,"finale":1,"garrisonGrant":1,"lightning":129,"link":2,"lock":69,"phase":1,"radiantSword":4,"shell":8,"slash":1,"snow":5,"split":6,"storm":10,"summon":8,"talent":4,"tide":4,"wanted":5},
|
||||
"snaps": "d0c7ebf7 c2a47f5c 6df72cfc f267f95e d216374a ed68fbff db7fd85b 64c3b233 8d07119c 37a755de af8e384d 37e6901f f432740d 5617ea7a bd1267d1 796410c7 47858302 d2d260f8 5c90313f 65608f5f"
|
||||
"hooks": [1,72,6000,1324,1324,3177,3555,4943,28,40,99,94,69,42113,2727,11,29,0,1,28,0,21,0],
|
||||
"events": {"atk":1635,"deploy":37,"die":40,"dmg":3251,"engage":19,"fx":848,"heal":347,"skill":193,"spawn":71,"status":629},
|
||||
"fx": {"airstrike":27,"beam":54,"blink":1,"bombard":64,"bombardShell":65,"burst":21,"copy":2,"dp":5,"drone":334,"droneLock":17,"drones":3,"finale":1,"garrisonGrant":1,"lightning":125,"link":2,"lock":69,"phase":1,"radiantSword":4,"shell":8,"slash":1,"snow":5,"split":6,"storm":10,"summon":8,"talent":4,"tide":5,"wanted":5},
|
||||
"snaps": "d0c7ebf7 c2a47f5c d51bb8c3 2d417d2a e0e24d2b 03e06f98 c1c51721 923667aa 93a574cd 45824693 fddb0f84 fda2294a 9028c7a3 85522803 4853f594 0a4870e7 bbefbbd9 c61aa475 3ca5a376 929494fd"
|
||||
},
|
||||
"boss-boss_1-solo": {
|
||||
"about": "假想敌:胄 solo pool 1800000: 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",
|
||||
@@ -226,110 +227,112 @@
|
||||
"round": 14,
|
||||
"seed": 1428555614,
|
||||
"waveId": "act1autochess_h07_03",
|
||||
"time": 165.03,
|
||||
"ticks": 4951,
|
||||
"time": 193.03,
|
||||
"ticks": 5791,
|
||||
"reason": "cleared",
|
||||
"killed": 19,
|
||||
"total": 6,
|
||||
"unspawned": 0,
|
||||
"bossHpLeft": 0,
|
||||
"rngDraws": 1278,
|
||||
"rngDraws": 1399,
|
||||
"errors": {"count":0,"kinds":[]},
|
||||
"players": {
|
||||
"p1": {"killed":19,"total":6,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":1570654,"bossDmg":1472280,"heal":5235,"deaths":2,"layerGains":{}},
|
||||
"p2": {"killed":0,"total":0,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":2613753,"bossDmg":2527720,"heal":37017,"deaths":3,"layerGains":{}}
|
||||
"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":{}}
|
||||
},
|
||||
"units": [
|
||||
["p1",1,"op","chess_char_6_03_b",2,"uniequip_002_yu",4070,1,7,0,0,0,3196,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,44,1804,324,32,1],
|
||||
["p1",4,"op","chess_char_6_01_b",2,"uniequip_002_lemuen",2344.65,1,13,4,902572,2208,317,53,7],
|
||||
["p1",5,"op","chess_char_6_02_b",2,"uniequip_002_sbell2",2126.3,1,17,3,323222,5411,272,52,3],
|
||||
["p1",6,"op","chess_char_6_04_b",2,"uniequip_002_skadi2",532.64,1,63,1,27583,12835,687,0,3],
|
||||
["p1",7,"op","chess_char_6_05_b",2,"uniequip_002_pasngr",823.13,1,67,1,178714,7514,404,45,3],
|
||||
["p1",8,"op","chess_char_6_08_b",2,"uniequip_002_reed2",1834.8,1,5,0,59,0,36,25,2],
|
||||
["p1",9,"op","chess_char_6_09_b",0,"uniequip_002_cello",1454.2,1,15,1,59,0,0,15,15],
|
||||
["p1",10,"op","chess_char_6_11_b",2,"uniequip_002_mlyss",2082.3,1,23,0,101077,0,0,170,4],
|
||||
["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",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,0,465270,0,0,170,3],
|
||||
["p2",13,"op","chess_char_6_19_b",2,"uniequip_002_blkkgt",3043.7,1,60,1,29,3044,0,9,1],
|
||||
["p2",14,"op","chess_char_5_01_b",1,"uniequip_002_excu2",2545.4,1,58,0,16,3865,960,16,1],
|
||||
["p2",15,"op","chess_char_6_17_b",2,"uniequip_002_nearl2",6916.53,1,32,0,293853,2679,0,72,3],
|
||||
["p2",16,"op","chess_char_6_12_b",1,"uniequip_002_rosmon",3493.03,1,16,6,216145,3498,625,60,3],
|
||||
["p2",17,"op","chess_char_6_13_b",1,"uniequip_002_angel2",2680.15,1,20,2,1433169,5683,1699,302,5],
|
||||
["p2",18,"op","chess_char_6_14_b",2,"uniequip_002_lumen",2297.79,1,22,0,0,3550,4812,50,1],
|
||||
["p2",19,"op","chess_char_6_16_b",2,"uniequip_002_halo2",1741.19,1,25,0,67561,1414,0,113,3],
|
||||
["p2",20,"op","chess_char_6_18_b",2,"uniequip_002_whitw2",1695.1,1,59,0,105769,1695,0,21,2],
|
||||
["p2",21,"op","chess_char_6_20_b",2,"uniequip_002_agoat2",1828.31,1,18,0,0,1833,28921,49,2],
|
||||
["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],
|
||||
["-","~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,37309,0,0,74,1],
|
||||
["p1","~1","token","token_10030_mlyss_wtrman",null,null,1586.2,1,24,0,44067,0,0,87,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,0,0,0,0,null],
|
||||
["p1","~2","token","token_10030_mlyss_wtrman",null,null,1989.96,0,42,0,1,0,0,1,null],
|
||||
["p1","~1","token","token_10017_skadi2_dedant",null,null,100,0,49,0,0,0,0,0,null],
|
||||
["p1","~3","token","token_10030_mlyss_wtrman",null,null,1586.2,0,50,0,3,0,0,3,null],
|
||||
["p2","~2","token","token_10019_nearl2_sword",null,null,6600,0,52,0,10647,0,0,0,null],
|
||||
["p1","~4","token","token_10030_mlyss_wtrman",null,null,1989.96,0,54,0,0,0,0,0,null],
|
||||
["p1","~1","token","token_10040_siege2_vlion",null,null,2804.82,0,55,0,10,2715,0,10,null],
|
||||
["p1","~2","token","token_10040_siege2_vlion",null,null,5520,0,56,0,2,0,0,2,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","~2","token","token_10017_skadi2_dedant",null,null,100,1,64,0,0,0,0,0,null],
|
||||
["p1","~5","token","token_10030_mlyss_wtrman",null,null,1586.2,1,65,0,0,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]
|
||||
],
|
||||
"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,0,154793],
|
||||
"enemy_1209_sfden_2": [2,2,0,1350,154793],
|
||||
"enemy_1210_msfden_2": [2,2,0,0,386],
|
||||
"enemy_9021_acduml": [1,1,0,0,4000000],
|
||||
"enemy_9023_acdums": [8,7,0,49006,317]
|
||||
"enemy_9023_acdums": [9,7,0,62774,362]
|
||||
},
|
||||
"hooks": [1,65,4951,1349,1349,3657,4441,2191,25,37,64,57,319,35751,1888,4,21,0,1,24,0,12,1],
|
||||
"events": {"atk":1613,"deploy":40,"die":37,"dmg":4177,"engage":19,"fx":1131,"heal":431,"skill":121,"spawn":58,"status":789},
|
||||
"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},
|
||||
"fx": {
|
||||
"airstrike": 115,
|
||||
"airstrike": 116,
|
||||
"appear": 1,
|
||||
"beam": 10,
|
||||
"beam": 11,
|
||||
"blink": 2,
|
||||
"bombard": 60,
|
||||
"bombardShell": 60,
|
||||
"bombard": 39,
|
||||
"bombardShell": 39,
|
||||
"buff": 3,
|
||||
"burst": 12,
|
||||
"burst": 17,
|
||||
"camouflage": 1,
|
||||
"copy": 1,
|
||||
"disappear": 1,
|
||||
"displace": 1,
|
||||
"dodge": 1,
|
||||
"dp": 4,
|
||||
"drone": 199,
|
||||
"droneLock": 21,
|
||||
"dp": 5,
|
||||
"drone": 208,
|
||||
"droneLock": 18,
|
||||
"drones": 2,
|
||||
"explode": 392,
|
||||
"extraAttack": 5,
|
||||
"explode": 449,
|
||||
"extraAttack": 4,
|
||||
"finale": 1,
|
||||
"firewall": 1,
|
||||
"garrisonGrant": 1,
|
||||
"lightning": 37,
|
||||
"link": 7,
|
||||
"lock": 60,
|
||||
"lure": 3,
|
||||
"phase": 38,
|
||||
"lightning": 79,
|
||||
"link": 8,
|
||||
"lock": 39,
|
||||
"lure": 2,
|
||||
"phase": 44,
|
||||
"radiantSword": 3,
|
||||
"scorchBurst": 2,
|
||||
"scorchBurst": 4,
|
||||
"slash": 11,
|
||||
"snow": 37,
|
||||
"snow": 36,
|
||||
"split": 4,
|
||||
"steal": 5,
|
||||
"storm": 3,
|
||||
"summon": 15,
|
||||
"steal": 6,
|
||||
"storm": 7,
|
||||
"summon": 16,
|
||||
"sunBurst": 1,
|
||||
"talent": 6,
|
||||
"talent": 8,
|
||||
"telegraph": 2,
|
||||
"tide": 3,
|
||||
"wanted": 1
|
||||
"wanted": 2
|
||||
},
|
||||
"snaps": "68fffef1 494368df d4448d17 4e91833a 23e11a5a 8752e6c6 6f9be873 fa966775 dfb5037e cefeae7b 8ab8ba7b e65d3634 fa7102a8 076e446e 4f16e0ab 97fb5ca2 77c75b43"
|
||||
"snaps": "68fffef1 494368df 4e38f532 f87d9068 690476ee 03b01e26 0bed04bd 2fd59226 69665385 05fe5c2d 998b698f 48323861 b99302e0 9efaad8f a695d151 50c5bc45 dd3e6215 601aae77 2a462704 b1188c19"
|
||||
},
|
||||
"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",
|
||||
@@ -518,99 +521,98 @@
|
||||
"round": 14,
|
||||
"seed": 2302764647,
|
||||
"waveId": "act2autochess_h07_05",
|
||||
"time": 103.97,
|
||||
"ticks": 3119,
|
||||
"time": 102.27,
|
||||
"ticks": 3068,
|
||||
"reason": "cleared",
|
||||
"killed": 18,
|
||||
"killed": 16,
|
||||
"total": 8,
|
||||
"unspawned": 0,
|
||||
"bossHpLeft": 0,
|
||||
"rngDraws": 867,
|
||||
"rngDraws": 874,
|
||||
"errors": {"count":0,"kinds":[]},
|
||||
"players": {
|
||||
"p1": {"killed":18,"total":8,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":1249795,"bossDmg":929511,"heal":26462,"deaths":1,"layerGains":{}},
|
||||
"p2": {"killed":0,"total":0,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":1109971,"bossDmg":630489,"heal":42330,"deaths":1,"layerGains":{}}
|
||||
"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":{}}
|
||||
},
|
||||
"units": [
|
||||
["p1",1,"op","chess_char_6_03_b",2,"uniequip_002_yu",4070,1,7,0,42174,5551,11075,18,1],
|
||||
["p1",2,"op","chess_char_6_06_b",2,"uniequip_002_pepe",3232.9,1,12,1,64021,2256,958,12,1],
|
||||
["p1",3,"op","chess_char_6_07_b",2,"uniequip_002_siege2",3411.1,1,14,0,76000,4697,2195,34,1],
|
||||
["p1",4,"op","chess_char_6_01_b",2,"uniequip_002_lemuen",1617,1,1,2,587890,2311,1987,32,4],
|
||||
["p1",5,"op","chess_char_6_02_b",2,"uniequip_002_sbell2",2126.3,1,16,0,54457,230,21,11,2],
|
||||
["p1",6,"op","chess_char_6_04_b",2,"uniequip_002_skadi2",1694.16,1,3,0,6859,6498,1793,0,3],
|
||||
["p1",7,"op","chess_char_6_05_b",2,"uniequip_002_pasngr",1586.2,1,18,0,143648,2793,596,18,4],
|
||||
["p1",8,"op","chess_char_6_08_b",2,"uniequip_002_reed2",1834.8,1,5,1,49062,868,4571,26,2],
|
||||
["p1",9,"op","chess_char_6_09_b",0,"uniequip_002_cello",0,0,20,2,143676,6495,603,20,20],
|
||||
["p1",10,"op","chess_char_6_11_b",2,"uniequip_002_mlyss",2082.3,1,9,1,53802,2964,1391,64,3],
|
||||
["p1",1,"op","chess_char_6_03_b",2,"uniequip_002_yu",4070,1,7,1,8305,1825,6494,6,1],
|
||||
["p1",2,"op","chess_char_6_06_b",2,"uniequip_002_pepe",0,0,12,0,25770,5629,769,5,1],
|
||||
["p1",3,"op","chess_char_6_07_b",2,"uniequip_002_siege2",3411.1,1,14,0,76065,2161,834,30,1],
|
||||
["p1",4,"op","chess_char_6_01_b",2,"uniequip_002_lemuen",1381.55,1,1,4,511267,991,755,31,4],
|
||||
["p1",5,"op","chess_char_6_02_b",2,"uniequip_002_sbell2",2126.3,1,16,1,128892,1418,956,23,2],
|
||||
["p1",6,"op","chess_char_6_04_b",2,"uniequip_002_skadi2",1757.8,1,3,0,4001,5151,305,0,2],
|
||||
["p1",7,"op","chess_char_6_05_b",2,"uniequip_002_pasngr",1586.2,1,18,0,143584,3329,1053,18,5],
|
||||
["p1",8,"op","chess_char_6_08_b",2,"uniequip_002_reed2",1834.8,1,5,0,74636,868,8481,45,2],
|
||||
["p1",9,"op","chess_char_6_09_b",0,"uniequip_002_cello",1454.2,1,20,4,160370,6470,1752,23,23],
|
||||
["p1",10,"op","chess_char_6_11_b",2,"uniequip_002_mlyss",0,0,9,0,50925,6392,964,63,2],
|
||||
["p1",11,"token","token_10017_skadi2_dedant",null,null,138,0,22,0,0,0,0,0,null],
|
||||
["p1",12,"token","token_10030_mlyss_wtrman",null,null,0,0,10,0,18615,2939,1271,35,1],
|
||||
["p2",13,"op","chess_char_6_15_b",2,"uniequip_002_qiubai",3508.08,1,56,5,369668,10048,0,93,4],
|
||||
["p2",14,"op","chess_char_6_19_b",2,"uniequip_002_blkkgt",2043.7,1,13,1,152329,5717,0,19,3],
|
||||
["p2",15,"op","chess_char_5_01_b",1,"uniequip_002_excu2",2081.5,1,15,0,12767,1079,540,9,1],
|
||||
["p2",16,"op","chess_char_6_17_b",2,"uniequip_002_nearl2",6504.15,1,58,2,186781,6913,0,46,2],
|
||||
["p2",17,"op","chess_char_6_12_b",1,"uniequip_002_rosmon",5344.3,1,2,2,194696,4516,2646,20,2],
|
||||
["p2",18,"op","chess_char_6_13_b",1,"uniequip_002_angel2",3562.16,1,17,2,73558,4871,2224,8,1],
|
||||
["p2",19,"op","chess_char_6_14_b",2,"uniequip_002_lumen",1336.23,1,4,0,0,2258,9039,16,1],
|
||||
["p2",20,"op","chess_char_6_16_b",2,"uniequip_002_halo2",1741.19,1,19,0,22189,174,0,27,2],
|
||||
["p2",21,"op","chess_char_6_18_b",2,"uniequip_002_whitw2",0,0,6,0,88775,1695,0,12,1],
|
||||
["p2",22,"op","chess_char_6_20_b",2,"uniequip_002_agoat2",1828.31,1,21,0,0,2787,27882,28,1],
|
||||
["p1",12,"token","token_10030_mlyss_wtrman",null,null,607.48,1,10,0,35076,1816,755,52,1],
|
||||
["p2",13,"op","chess_char_6_15_b",2,"uniequip_002_qiubai",4181.76,1,55,4,380444,9705,860,99,3],
|
||||
["p2",14,"op","chess_char_6_19_b",2,"uniequip_002_blkkgt",3043.7,1,13,1,126431,2339,0,21,2],
|
||||
["p2",15,"op","chess_char_5_01_b",1,"uniequip_002_excu2",1908.49,1,15,1,32435,2117,1225,22,1],
|
||||
["p2",16,"op","chess_char_6_17_b",2,"uniequip_002_nearl2",6521.3,1,53,1,166515,10334,0,40,2],
|
||||
["p2",17,"op","chess_char_6_12_b",1,"uniequip_002_rosmon",5557.09,1,2,0,150406,888,888,17,1],
|
||||
["p2",18,"op","chess_char_6_13_b",1,"uniequip_002_angel2",3437.9,1,17,0,76446,5687,2518,8,1],
|
||||
["p2",19,"op","chess_char_6_14_b",2,"uniequip_002_lumen",2088.9,1,4,0,0,2258,6052,8,1],
|
||||
["p2",20,"op","chess_char_6_16_b",2,"uniequip_002_halo2",1741.19,1,19,0,17095,1031,0,18,2],
|
||||
["p2",21,"op","chess_char_6_18_b",2,"uniequip_002_whitw2",0,0,6,0,83803,1695,0,12,1],
|
||||
["p2",22,"op","chess_char_6_20_b",2,"uniequip_002_agoat2",1828.31,1,21,0,0,2305,29761,25,1],
|
||||
["p2","~1","token","token_10019_nearl2_sword",null,null,1907.57,0,32,0,9195,6372,0,12,null],
|
||||
["p1","~1","token","token_10030_mlyss_wtrman",null,null,2301.84,0,36,0,4738,0,0,12,null],
|
||||
["p1","~1","token","token_10040_siege2_vlion",null,null,1631.32,0,39,0,2850,3136,0,7,null],
|
||||
["p2","~2","token","token_10019_nearl2_sword",null,null,2413.5,0,45,0,13,3587,0,23,null],
|
||||
["p1","~1","token","token_10030_mlyss_wtrman",null,null,2301.84,0,36,0,5259,0,0,12,null],
|
||||
["p1","~1","token","token_10040_siege2_vlion",null,null,136.97,0,39,0,4275,4590,0,10,null],
|
||||
["p1","~2","token","token_10030_mlyss_wtrman",null,null,0,0,45,0,2575,1668,0,7,null],
|
||||
["p2","~2","token","token_10019_nearl2_sword",null,null,5742.56,0,46,0,0,857,0,0,null],
|
||||
["p1","~1","token","token_10017_skadi2_dedant",null,null,100,0,48,0,0,0,0,0,null],
|
||||
["p1","~2","token","token_10030_mlyss_wtrman",null,null,0,0,52,0,2003,1668,0,8,null]
|
||||
["p1","~3","token","token_10030_mlyss_wtrman",null,null,1668,1,56,0,0,0,0,0,null]
|
||||
],
|
||||
"enemies": {
|
||||
"enemy_1010_demon_2": [2,2,0,1457,128995],
|
||||
"enemy_2009_csaudc": [4,4,0,1050,137594],
|
||||
"enemy_2010_csdcr": [2,2,0,6581,103196],
|
||||
"enemy_2016_csphtm": [2,2,0,70829,1560000],
|
||||
"enemy_2017_csphts": [10,10,0,3407,429982]
|
||||
"enemy_2009_csaudc": [4,4,0,2694,137594],
|
||||
"enemy_2010_csdcr": [2,2,0,5885,103196],
|
||||
"enemy_2016_csphtm": [2,2,0,67665,1560000],
|
||||
"enemy_2017_csphts": [8,8,0,1223,343985]
|
||||
},
|
||||
"hooks": [1,56,3119,685,685,1889,2222,4724,24,38,60,51,43,26422,1127,30,20,0,1,23,0,17,64],
|
||||
"events": {"atk":852,"deploy":36,"die":38,"dmg":2064,"engage":23,"fx":534,"heal":240,"skill":111,"spawn":48,"status":580},
|
||||
"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},
|
||||
"fx": {
|
||||
"airstrike": 14,
|
||||
"blink": 18,
|
||||
"airstrike": 15,
|
||||
"blink": 15,
|
||||
"bombard": 33,
|
||||
"bombardShell": 33,
|
||||
"burst": 17,
|
||||
"cleanse": 2,
|
||||
"copy": 1,
|
||||
"dodge": 64,
|
||||
"dp": 3,
|
||||
"displace": 4,
|
||||
"dodge": 81,
|
||||
"dp": 2,
|
||||
"drone": 102,
|
||||
"droneLock": 3,
|
||||
"drones": 1,
|
||||
"emergency": 2,
|
||||
"explode": 29,
|
||||
"extraAttack": 3,
|
||||
"finale": 3,
|
||||
"explode": 24,
|
||||
"extraAttack": 7,
|
||||
"finale": 2,
|
||||
"firewall": 1,
|
||||
"garrisonGrant": 1,
|
||||
"lightning": 47,
|
||||
"link": 4,
|
||||
"lightning": 43,
|
||||
"link": 3,
|
||||
"lock": 33,
|
||||
"lure": 2,
|
||||
"palsy": 1,
|
||||
"radiantSword": 2,
|
||||
"scorchBurst": 1,
|
||||
"shield": 13,
|
||||
"slash": 33,
|
||||
"snow": 21,
|
||||
"split": 2,
|
||||
"shield": 8,
|
||||
"slash": 22,
|
||||
"snow": 24,
|
||||
"split": 3,
|
||||
"steal": 1,
|
||||
"storm": 4,
|
||||
"storm": 5,
|
||||
"strike": 3,
|
||||
"summon": 13,
|
||||
"summon": 11,
|
||||
"sunBurst": 4,
|
||||
"talent": 4,
|
||||
"telegraph": 6,
|
||||
"tide": 3,
|
||||
"wanted": 7
|
||||
"telegraph": 2,
|
||||
"tide": 2,
|
||||
"wanted": 11
|
||||
},
|
||||
"snaps": "d3cd88f1 0b40f2d2 1bc3ac15 f797dbfa 67a1d811 c33ee6e2 949c4a4b a8645ba6 e4cccf0c f1968adc 303367ba"
|
||||
"snaps": "d3cd88f1 0b40f2d2 3fada850 9d166f76 eb1c5713 8daf7ec1 58aeec40 fd26682e 39226066 3ce9d7e2 8cf2e7c7"
|
||||
},
|
||||
"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",
|
||||
@@ -811,56 +813,56 @@
|
||||
"round": 14,
|
||||
"seed": 120974514,
|
||||
"waveId": "act1autochess_h07_07",
|
||||
"time": 171.13,
|
||||
"ticks": 5134,
|
||||
"time": 173.63,
|
||||
"ticks": 5209,
|
||||
"reason": "cleared",
|
||||
"killed": 16,
|
||||
"total": 16,
|
||||
"unspawned": 0,
|
||||
"bossHpLeft": 0,
|
||||
"rngDraws": 910,
|
||||
"rngDraws": 918,
|
||||
"errors": {"count":0,"kinds":[]},
|
||||
"players": {
|
||||
"p1": {"killed":16,"total":16,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":1769900,"bossDmg":1675565,"heal":25043,"deaths":0,"layerGains":{}},
|
||||
"p2": {"killed":0,"total":0,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":2703080,"bossDmg":2324435,"heal":40114,"deaths":0,"layerGains":{}}
|
||||
"p1": {"killed":16,"total":16,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":1730751,"bossDmg":1610239,"heal":24718,"deaths":0,"layerGains":{}},
|
||||
"p2": {"killed":0,"total":0,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":2742228,"bossDmg":2389761,"heal":40810,"deaths":0,"layerGains":{}}
|
||||
},
|
||||
"units": [
|
||||
["p1",1,"op","chess_char_6_03_b",2,"uniequip_002_yu",4070,1,5,0,0,0,0,0,0],
|
||||
["p1",2,"op","chess_char_6_06_b",2,"uniequip_002_pepe",3232.9,1,7,0,0,0,0,0,0],
|
||||
["p1",3,"op","chess_char_6_07_b",2,"uniequip_002_siege2",3411.1,1,11,0,0,0,0,0,0],
|
||||
["p1",4,"op","chess_char_6_01_b",2,"uniequip_002_lemuen",1617,1,1,2,1131550,0,0,82,8],
|
||||
["p1",4,"op","chess_char_6_01_b",2,"uniequip_002_lemuen",1617,1,1,2,1075028,0,0,78,8],
|
||||
["p1",5,"op","chess_char_6_02_b",2,"uniequip_002_sbell2",2126.3,1,9,0,0,0,0,0,0],
|
||||
["p1",6,"op","chess_char_6_04_b",2,"uniequip_002_skadi2",1669.91,1,17,0,41867,19445,1580,0,5],
|
||||
["p1",6,"op","chess_char_6_04_b",2,"uniequip_002_skadi2",1669.91,1,17,0,43766,18917,1278,0,5],
|
||||
["p1",7,"op","chess_char_6_05_b",2,"uniequip_002_pasngr",1586.2,1,3,0,0,0,0,0,0],
|
||||
["p1",8,"op","chess_char_6_08_b",2,"uniequip_002_reed2",1834.8,1,13,0,161084,0,19850,120,3],
|
||||
["p1",9,"op","chess_char_6_09_b",0,"uniequip_002_cello",1454.2,1,15,1,254912,0,0,44,44],
|
||||
["p1",10,"op","chess_char_6_11_b",2,"uniequip_002_mlyss",2082.3,1,19,0,140358,12849,1604,170,4],
|
||||
["p1",8,"op","chess_char_6_08_b",2,"uniequip_002_reed2",1834.8,1,13,0,165894,0,20171,122,3],
|
||||
["p1",9,"op","chess_char_6_09_b",0,"uniequip_002_cello",1454.2,1,15,1,261942,1172,0,45,45],
|
||||
["p1",10,"op","chess_char_6_11_b",2,"uniequip_002_mlyss",2082.3,1,19,1,142272,12632,1260,172,4],
|
||||
["p1",11,"token","token_10017_skadi2_dedant",null,null,138,0,22,0,0,0,0,0,null],
|
||||
["p1",12,"token","token_10030_mlyss_wtrman",null,null,0,0,20,0,7684,2977,0,17,1],
|
||||
["p2",13,"op","chess_char_6_15_b",2,"uniequip_002_qiubai",4435.2,1,26,2,403485,0,0,134,3],
|
||||
["p1",12,"token","token_10030_mlyss_wtrman",null,null,0,0,20,0,8204,2977,0,18,1],
|
||||
["p2",13,"op","chess_char_6_15_b",2,"uniequip_002_qiubai",4435.2,1,26,3,408287,0,0,133,3],
|
||||
["p2",14,"op","chess_char_6_19_b",2,"uniequip_002_blkkgt",3043.7,1,2,0,0,0,0,0,0],
|
||||
["p2",15,"op","chess_char_5_01_b",1,"uniequip_002_excu2",2545.4,1,6,0,0,0,0,0,0],
|
||||
["p2",16,"op","chess_char_6_17_b",2,"uniequip_002_nearl2",6844.55,1,31,2,137844,15939,0,41,3],
|
||||
["p2",17,"op","chess_char_6_12_b",1,"uniequip_002_rosmon",2480.38,1,16,2,379931,10260,0,64,3],
|
||||
["p2",18,"op","chess_char_6_13_b",1,"uniequip_002_angel2",2653.35,1,21,6,1358147,5265,2873,294,6],
|
||||
["p2",16,"op","chess_char_6_17_b",2,"uniequip_002_nearl2",6916.53,1,31,1,136739,14292,0,40,3],
|
||||
["p2",17,"op","chess_char_6_12_b",1,"uniequip_002_rosmon",2540.38,1,16,4,396919,10380,0,65,3],
|
||||
["p2",18,"op","chess_char_6_13_b",1,"uniequip_002_angel2",2680.15,1,21,4,1362550,5319,2968,297,6],
|
||||
["p2",19,"op","chess_char_6_14_b",2,"uniequip_002_lumen",2088.9,1,8,0,0,0,0,0,0],
|
||||
["p2",20,"op","chess_char_6_16_b",2,"uniequip_002_halo2",1582.9,1,12,1,140177,0,0,100,4],
|
||||
["p2",21,"op","chess_char_6_18_b",2,"uniequip_002_whitw2",1695.1,1,14,1,283496,0,0,77,3],
|
||||
["p2",22,"op","chess_char_6_20_b",2,"uniequip_002_agoat2",1828.31,1,18,0,0,0,37241,59,2],
|
||||
["p1","~1","token","token_10030_mlyss_wtrman",null,null,0,0,34,0,10512,2454,630,12,null],
|
||||
["p1","~2","token","token_10030_mlyss_wtrman",null,null,0,0,41,0,5418,1593,0,9,1],
|
||||
["p2",20,"op","chess_char_6_16_b",2,"uniequip_002_halo2",1582.9,1,12,0,145556,0,0,103,4],
|
||||
["p2",21,"op","chess_char_6_18_b",2,"uniequip_002_whitw2",1695.1,1,14,1,292178,0,0,77,3],
|
||||
["p2",22,"op","chess_char_6_20_b",2,"uniequip_002_agoat2",1828.31,1,18,0,0,1103,37842,58,2],
|
||||
["p1","~1","token","token_10030_mlyss_wtrman",null,null,0,0,34,0,10960,2454,630,13,null],
|
||||
["p1","~2","token","token_10030_mlyss_wtrman",null,null,0,0,41,0,5575,1593,0,9,1],
|
||||
["p1","~1","token","token_10017_skadi2_dedant",null,null,100,0,42,0,0,0,0,0,null],
|
||||
["p1","~3","token","token_10030_mlyss_wtrman",null,null,0,0,46,0,2005,1322,0,6,1],
|
||||
["p1","~3","token","token_10030_mlyss_wtrman",null,null,0,0,46,0,2108,1322,0,6,1],
|
||||
["p1","~2","token","token_10017_skadi2_dedant",null,null,138,0,48,0,0,0,0,0,null],
|
||||
["p1","~4","token","token_10030_mlyss_wtrman",null,null,122.63,1,49,0,10116,2003,0,28,1],
|
||||
["p1","~5","token","token_10030_mlyss_wtrman",null,null,0,0,50,0,4393,3044,1379,12,null],
|
||||
["p1","~4","token","token_10030_mlyss_wtrman",null,null,122.63,1,49,0,10458,2003,0,29,1],
|
||||
["p1","~5","token","token_10030_mlyss_wtrman",null,null,0,0,50,0,4545,2968,1379,12,null],
|
||||
["p1","~3","token","token_10017_skadi2_dedant",null,null,100,1,51,0,0,0,0,0,null]
|
||||
],
|
||||
"enemies": {"enemy_1067_snslime":[8,8,0,8997,111795],"enemy_1081_sotisd":[4,4,0,0,257989],"enemy_1249_lysdb_2":[4,4,0,0,103196],"enemy_9033_acdeer":[1,1,0,43366,4000000]},
|
||||
"hooks": [1,49,5134,1270,1270,3029,3331,3007,22,27,92,86,300,37421,1393,1,17,0,1,21,0,11,0],
|
||||
"events": {"atk":1436,"deploy":32,"die":27,"dmg":3070,"engage":16,"fx":669,"heal":444,"skill":178,"spawn":47,"status":497},
|
||||
"fx": {"airstrike":100,"beam":5,"blink":2,"bombard":69,"bombardShell":69,"burst":11,"column":45,"copy":4,"dp":4,"drone":234,"droneLock":11,"drones":3,"explode":6,"garrisonGrant":1,"link":1,"lock":69,"phase":1,"scorchBurst":2,"shieldBreak":4,"snow":5,"split":2,"steal":6,"sunBurst":4,"talent":4,"tide":5,"wanted":2},
|
||||
"snaps": "e8f77b71 c1cf2cef 36ab0e73 ccc2551f 2b2e3882 dd02a2c0 0af53ff0 4dd61228 eb6652cc d394b2db 63e0edd0 aa384869 69ec5174 2cf805c7 d39727e2 8d04b1b9 23c424be cf78b290"
|
||||
"enemies": {"enemy_1067_snslime":[8,8,0,11760,111795],"enemy_1081_sotisd":[4,4,0,0,257989],"enemy_1249_lysdb_2":[4,4,0,0,103196],"enemy_9033_acdeer":[1,1,0,40235,4000000]},
|
||||
"hooks": [1,49,5209,1281,1281,3051,3357,2765,22,27,93,88,305,37705,1418,3,17,0,1,21,0,10,0],
|
||||
"events": {"atk":1445,"deploy":32,"die":27,"dmg":3088,"engage":16,"fx":675,"heal":436,"skill":181,"spawn":47,"status":511},
|
||||
"fx": {"airstrike":104,"beam":5,"blink":2,"bombard":63,"bombardShell":64,"burst":10,"column":45,"copy":4,"dp":4,"drone":245,"droneLock":15,"drones":3,"explode":6,"garrisonGrant":1,"link":1,"lock":69,"phase":1,"scorchBurst":2,"shieldBreak":4,"snow":5,"split":2,"steal":6,"sunBurst":4,"talent":4,"tide":5,"wanted":1},
|
||||
"snaps": "e8f77b71 c1cf2cef a6688abd b9a6fe99 07c13245 40140044 ddfccb97 273b0a5a 3f6c7803 621a62a4 561c43b2 75e4ac28 41579a2d 3b4b24fa 4aa3b38d e05ffd35 6d9a4f36 45587dcb"
|
||||
},
|
||||
"boss-boss_7-solo": {
|
||||
"about": "“萨米的意志” solo pool 2000000: chess_char_5_08_b chess_char_5_11_b chess_char_5_05_b chess_char_5_06_b chess_char_5_13_b chess_char_5_07_b chess_char_5_02_b chess_char_5_03_b chess_char_5_10_b chess_char_5_12_b",
|
||||
@@ -1074,72 +1076,72 @@
|
||||
"killed": 8,
|
||||
"total": 8,
|
||||
"unspawned": 0,
|
||||
"bossHpLeft": 2705390,
|
||||
"rngDraws": 2092,
|
||||
"bossHpLeft": 2992115,
|
||||
"rngDraws": 1828,
|
||||
"errors": {"count":0,"kinds":[]},
|
||||
"players": {
|
||||
"p1": {"killed":8,"total":8,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":2847989,"bossDmg":1144813,"heal":26393,"deaths":3,"layerGains":{}},
|
||||
"p2": {"killed":0,"total":0,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":4559292,"bossDmg":1700319,"heal":131365,"deaths":10,"layerGains":{}}
|
||||
"p1": {"killed":8,"total":8,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":2840858,"bossDmg":1130306,"heal":26454,"deaths":3,"layerGains":{}},
|
||||
"p2": {"killed":0,"total":0,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":3891982,"bossDmg":1427731,"heal":133966,"deaths":7,"layerGains":{}}
|
||||
},
|
||||
"units": [
|
||||
["p1",1,"op","chess_char_6_03_b",2,"uniequip_002_yu",7326,1,15,0,3237,9562,15614,31,3],
|
||||
["p1",1,"op","chess_char_6_03_b",2,"uniequip_002_yu",7326,1,15,0,3237,9163,15665,31,3],
|
||||
["p1",2,"op","chess_char_6_06_b",2,"uniequip_002_pepe",3232.9,1,11,0,22947,281,0,23,1],
|
||||
["p1",3,"op","chess_char_6_07_b",2,"uniequip_002_siege2",2302.22,1,17,0,49362,1605,0,26,2],
|
||||
["p1",4,"op","chess_char_6_01_b",2,"uniequip_002_lemuen",528.27,1,7,1,2380803,4432,0,60,9],
|
||||
["p1",5,"op","chess_char_6_02_b",2,"uniequip_002_sbell2",871.14,1,9,0,37700,13770,0,46,2],
|
||||
["p1",6,"op","chess_char_6_04_b",2,"uniequip_002_skadi2",719.06,1,54,0,30456,11524,527,0,4],
|
||||
["p1",7,"op","chess_char_6_05_b",2,"uniequip_002_pasngr",2696.54,1,13,1,70578,0,0,0,11],
|
||||
["p1",8,"op","chess_char_6_08_b",2,"uniequip_002_reed2",1834.8,1,19,0,54992,0,9335,73,2],
|
||||
["p1",9,"op","chess_char_6_09_b",0,"uniequip_002_cello",0,0,21,1,160062,13974,0,27,27],
|
||||
["p1",10,"op","chess_char_6_11_b",2,"uniequip_002_mlyss",2082.3,1,59,0,29453,10289,306,74,2],
|
||||
["p1",3,"op","chess_char_6_07_b",2,"uniequip_002_siege2",2291.42,1,17,0,49362,1605,0,26,2],
|
||||
["p1",4,"op","chess_char_6_01_b",2,"uniequip_002_lemuen",913.1,1,7,1,2371886,3937,0,57,9],
|
||||
["p1",5,"op","chess_char_6_02_b",2,"uniequip_002_sbell2",871.14,1,9,0,38138,13770,0,46,2],
|
||||
["p1",6,"op","chess_char_6_04_b",2,"uniequip_002_skadi2",719.06,1,54,0,30456,11499,452,0,4],
|
||||
["p1",7,"op","chess_char_6_05_b",2,"uniequip_002_pasngr",2696.54,1,13,1,71156,0,0,0,11],
|
||||
["p1",8,"op","chess_char_6_08_b",2,"uniequip_002_reed2",1834.8,1,19,0,55032,0,9419,73,2],
|
||||
["p1",9,"op","chess_char_6_09_b",0,"uniequip_002_cello",0,0,21,1,160062,14044,0,27,27],
|
||||
["p1",10,"op","chess_char_6_11_b",2,"uniequip_002_mlyss",2082.3,1,59,0,30214,11261,306,74,2],
|
||||
["p1",11,"token","token_10017_skadi2_dedant",null,null,138,0,28,0,0,0,0,0,null],
|
||||
["p1",12,"token","token_10030_mlyss_wtrman",null,null,0,0,24,0,0,1670,348,4,1],
|
||||
["p2",13,"op","chess_char_6_15_b",2,"uniequip_002_qiubai",0,0,51,4,1181207,26626,0,330,3],
|
||||
["p2",14,"op","chess_char_6_19_b",2,"uniequip_002_blkkgt",0,0,56,0,619703,17069,0,79,5],
|
||||
["p2",15,"op","chess_char_5_01_b",1,"uniequip_002_excu2",0,0,12,0,19399,8026,5640,64,2],
|
||||
["p2",16,"op","chess_char_6_17_b",2,"uniequip_002_nearl2",0,0,64,1,633128,29140,0,72,3],
|
||||
["p2",17,"op","chess_char_6_12_b",1,"uniequip_002_rosmon",0,0,8,0,905789,18226,0,72,3],
|
||||
["p2",18,"op","chess_char_6_13_b",1,"uniequip_002_angel2",2314.38,1,14,0,663280,10817,6140,266,5],
|
||||
["p2",19,"op","chess_char_6_14_b",2,"uniequip_002_lumen",0,0,20,0,0,3342,19329,77,1],
|
||||
["p2",20,"op","chess_char_6_16_b",2,"uniequip_002_halo2",0,0,10,0,102836,1583,0,121,3],
|
||||
["p2",21,"op","chess_char_6_18_b",2,"uniequip_002_whitw2",1844.61,1,27,0,352326,5115,0,25,3],
|
||||
["p2",22,"op","chess_char_6_20_b",2,"uniequip_002_agoat2",1828.31,1,25,0,0,15360,100256,80,2],
|
||||
["p2",13,"op","chess_char_6_15_b",2,"uniequip_002_qiubai",903.1,1,68,4,817795,32754,0,248,4],
|
||||
["p2",14,"op","chess_char_6_19_b",2,"uniequip_002_blkkgt",0,0,64,0,355848,15836,0,48,4],
|
||||
["p2",15,"op","chess_char_5_01_b",1,"uniequip_002_excu2",1415.78,1,66,0,123216,11064,6422,44,2],
|
||||
["p2",16,"op","chess_char_6_17_b",2,"uniequip_002_nearl2",1602.9,1,65,1,499184,28500,0,76,3],
|
||||
["p2",17,"op","chess_char_6_12_b",1,"uniequip_002_rosmon",1,1,8,0,1061491,19179,0,73,3],
|
||||
["p2",18,"op","chess_char_6_13_b",1,"uniequip_002_angel2",2436.5,1,14,0,526339,8805,4488,259,5],
|
||||
["p2",19,"op","chess_char_6_14_b",2,"uniequip_002_lumen",3342.24,1,20,0,0,0,19549,78,1],
|
||||
["p2",20,"op","chess_char_6_16_b",2,"uniequip_002_halo2",1582.9,1,10,0,103786,0,0,127,3],
|
||||
["p2",21,"op","chess_char_6_18_b",2,"uniequip_002_whitw2",1859.61,1,27,0,346326,5155,0,25,3],
|
||||
["p2",22,"op","chess_char_6_20_b",2,"uniequip_002_agoat2",1758.31,1,25,0,0,14000,103507,78,2],
|
||||
["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null],
|
||||
["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null],
|
||||
["-","~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],
|
||||
["-","~5","device","trap_1105_accrate",null,null,100,1,5,0,0,0,0,0,null],
|
||||
["-","~6","device","trap_1105_accrate",null,null,100,1,6,0,0,0,0,0,null],
|
||||
["p1","~1","token","token_10030_mlyss_wtrman",null,null,0,0,45,0,2647,1882,0,21,1],
|
||||
["p1","~1","token","token_10030_mlyss_wtrman",null,null,0,0,45,0,2612,1882,0,21,1],
|
||||
["p2","~1","token","token_10019_nearl2_sword",null,null,1739.66,0,47,0,7453,6540,0,21,null],
|
||||
["p1","~2","token","token_10030_mlyss_wtrman",null,null,0,0,48,0,390,3127,0,9,null],
|
||||
["p1","~3","token","token_10030_mlyss_wtrman",null,null,0,0,49,0,613,1322,0,10,null],
|
||||
["p1","~2","token","token_10030_mlyss_wtrman",null,null,0,0,48,0,363,3127,0,9,null],
|
||||
["p1","~3","token","token_10030_mlyss_wtrman",null,null,0,0,49,0,645,1322,0,10,null],
|
||||
["p1","~1","token","token_10040_siege2_vlion",null,null,3730,0,52,0,4750,270,0,10,null],
|
||||
["p1","~1","token","token_10017_skadi2_dedant",null,null,138,0,55,0,0,0,0,0,null],
|
||||
["p2","~2","token","token_10019_nearl2_sword",null,null,0,0,57,0,50543,6744,0,0,null],
|
||||
["p1","~1","token","token_10017_skadi2_dedant",null,null,138,0,56,0,0,0,0,0,null],
|
||||
["p2","~2","token","token_10019_nearl2_sword",null,null,6876.71,0,57,0,25272,1338,0,0,null],
|
||||
["p1","~4","token","token_10030_mlyss_wtrman",null,null,1322,1,60,0,0,310,263,0,1],
|
||||
["p1","~2","token","token_10017_skadi2_dedant",null,null,100,0,61,0,0,0,0,0,null],
|
||||
["p1","~2","token","token_10040_siege2_vlion",null,null,3913.91,0,62,0,0,119,0,0,null],
|
||||
["p2","~3","token","token_10019_nearl2_sword",null,null,205.57,0,63,0,23627,7004,0,0,null],
|
||||
["p1","~3","token","token_10017_skadi2_dedant",null,null,100,1,65,0,0,0,0,0,null]
|
||||
["p1","~2","token","token_10040_siege2_vlion",null,null,3916.52,0,62,0,0,115,0,0,null],
|
||||
["p2","~3","token","token_10019_nearl2_sword",null,null,3182.15,0,63,0,25272,4024,0,0,null],
|
||||
["p1","~3","token","token_10017_skadi2_dedant",null,null,100,1,67,0,0,0,0,0,null]
|
||||
],
|
||||
"enemies": {
|
||||
"enemy_1267_nhpbr_2": [6,6,0,1900,141894],
|
||||
"enemy_9007_acelem": [2,2,0,13271,309587],
|
||||
"enemy_9017_achunt_2": [2,0,0,58763,2894610],
|
||||
"enemy_9018_actrpa": [1,0,0,61321,1190947],
|
||||
"enemy_9019_actrpb": [1,0,0,28448,1109516],
|
||||
"enemy_9020_actrpc": [1,0,0,21621,1863552]
|
||||
"enemy_9007_acelem": [2,2,0,14390,309587],
|
||||
"enemy_9017_achunt_2": [2,0,0,57663,2607885],
|
||||
"enemy_9018_actrpa": [1,0,0,53759,1092472],
|
||||
"enemy_9019_actrpb": [1,0,0,24773,1015854],
|
||||
"enemy_9020_actrpc": [1,0,0,22601,1668902]
|
||||
},
|
||||
"hooks": [1,63,6000,1773,1773,9719,19351,3054,26,38,96,92,314,48736,3494,21,13,0,1,33,0,18,0],
|
||||
"events": {"atk":2403,"deploy":44,"die":38,"dmg":18745,"engage":22,"fx":2239,"heal":960,"skill":188,"spawn":53,"status":946},
|
||||
"hooks": [1,66,6000,1649,1649,8552,17196,2968,22,35,96,88,305,47649,3685,17,13,0,1,31,0,19,0],
|
||||
"events": {"atk":2230,"deploy":47,"die":35,"dmg":16662,"engage":22,"fx":2182,"heal":905,"skill":184,"spawn":53,"status":898},
|
||||
"fx": {
|
||||
"airstrike": 113,
|
||||
"airstrike": 93,
|
||||
"beam": 26,
|
||||
"blink": 5,
|
||||
"bombard": 78,
|
||||
"bombardShell": 78,
|
||||
"burst": 18,
|
||||
"bombard": 69,
|
||||
"bombardShell": 70,
|
||||
"burst": 19,
|
||||
"camouflage": 1,
|
||||
"copy": 3,
|
||||
"dash": 24,
|
||||
@@ -1148,8 +1150,8 @@
|
||||
"drone": 334,
|
||||
"droneLock": 11,
|
||||
"drones": 3,
|
||||
"extraAttack": 23,
|
||||
"finale": 5,
|
||||
"extraAttack": 15,
|
||||
"finale": 4,
|
||||
"firewall": 3,
|
||||
"garrisonGrant": 1,
|
||||
"lightning": 118,
|
||||
@@ -1160,20 +1162,20 @@
|
||||
"radiantSword": 3,
|
||||
"revive": 3,
|
||||
"shieldBreak": 9,
|
||||
"slash": 55,
|
||||
"slash": 44,
|
||||
"snow": 26,
|
||||
"split": 2,
|
||||
"steal": 5,
|
||||
"storm": 11,
|
||||
"summon": 5,
|
||||
"sunBurst": 4,
|
||||
"sunBurst": 3,
|
||||
"talent": 4,
|
||||
"tide": 4,
|
||||
"undying": 1,
|
||||
"wanted": 6,
|
||||
"zone": 6
|
||||
},
|
||||
"snaps": "8b102142 7109c20e 6cb62905 cd421024 07699f86 52b1f74f 9eb6286f 13679ac3 4a76f2fe f3f7a2a9 bfaa974c 4043041c 78cb5e5f e4afd571 ee6c6d9a fd48d22e 44c582cd 445a604a 58683b3b b2f7ef86"
|
||||
"snaps": "8b102142 7109c20e 6cb62905 16f4d84e db38e8d1 fb736fc0 d644e65e bf490768 454f169f 723043e7 883b6557 609aa7bb bfa05477 7d38be83 76b95dc1 8ac15f69 7c25d190 03595be2 40606ec4 e87eb589"
|
||||
},
|
||||
"hidden-boss_9-solo": {
|
||||
"about": "假想敌:铳 solo pool 2800000: chess_char_5_08_b chess_char_5_11_b chess_char_5_05_b chess_char_5_06_b chess_char_5_13_b chess_char_5_07_b chess_char_5_02_b chess_char_5_03_b chess_char_5_10_b chess_char_5_12_b",
|
||||
@@ -1332,36 +1334,36 @@
|
||||
"time": 200,
|
||||
"ticks": 6000,
|
||||
"reason": "forced",
|
||||
"killed": 12,
|
||||
"killed": 11,
|
||||
"total": 5,
|
||||
"unspawned": 0,
|
||||
"bossHpLeft": 7226192,
|
||||
"rngDraws": 103,
|
||||
"bossHpLeft": 7221428,
|
||||
"rngDraws": 99,
|
||||
"errors": {"count":0,"kinds":[]},
|
||||
"players": {"p1":{"killed":12,"total":5,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":591594,"bossDmg":373808,"heal":141080,"deaths":9,"layerGains":{}}},
|
||||
"players": {"p1":{"killed":11,"total":5,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":572445,"bossDmg":378572,"heal":140471,"deaths":8,"layerGains":{}}},
|
||||
"units": [
|
||||
["p1",1,"op","chess_char_4_17_b",1,"uniequip_002_hsguma",3864.3,1,29,2,51,3864,0,14,0],
|
||||
["p1",2,"op","chess_char_4_18_b",1,"uniequip_002_mudrok",5783.26,1,25,0,17,18719,11206,15,2],
|
||||
["p1",3,"op","chess_char_4_23_b",2,"uniequip_002_gvial2",2579.06,1,26,2,230272,25481,4759,191,4],
|
||||
["p1",4,"op","chess_char_4_19_b",2,"uniequip_002_flamtl",3960.66,1,33,0,16,16650,0,15,0],
|
||||
["p1",5,"op","chess_char_4_22_b",2,"uniequip_002_svrash",2563.92,1,22,5,198993,51878,7500,123,2],
|
||||
["p1",6,"op","chess_char_4_20_b",2,"uniequip_002_fartth",1560.9,1,30,0,115397,1561,0,56,6],
|
||||
["p1",7,"op","chess_char_4_21_b",1,"uniequip_002_plosis",1428.79,1,23,0,0,15153,80115,84,2],
|
||||
["p1",8,"op","chess_char_4_24_b",1,"uniequip_002_billro",2395.8,1,34,3,46848,3732,0,52,3],
|
||||
["p1",9,"op","chess_char_4_25_b",2,"uniequip_002_cetsyr",1404.57,1,24,0,0,31236,5310,0,2],
|
||||
["p1",10,"op","chess_char_4_26_b",0,"uniequip_002_bldsk",1674.2,1,3,0,0,3378,32189,47,8]
|
||||
["p1",1,"op","chess_char_4_17_b",1,"uniequip_002_hsguma",3864.3,1,29,3,56,3864,0,14,0],
|
||||
["p1",2,"op","chess_char_4_18_b",1,"uniequip_002_mudrok",4803.42,1,25,0,16,18391,9981,15,2],
|
||||
["p1",3,"op","chess_char_4_23_b",2,"uniequip_002_gvial2",3075.04,1,26,2,206116,24874,5090,191,4],
|
||||
["p1",4,"op","chess_char_4_19_b",2,"uniequip_002_flamtl",3960.66,1,5,0,15,16060,0,14,0],
|
||||
["p1",5,"op","chess_char_4_22_b",2,"uniequip_002_svrash",0,0,22,2,199997,48631,7490,117,2],
|
||||
["p1",6,"op","chess_char_4_20_b",2,"uniequip_002_fartth",1560.9,1,30,0,119396,1561,0,57,6],
|
||||
["p1",7,"op","chess_char_4_21_b",1,"uniequip_002_plosis",1052.5,1,23,0,0,16534,81043,85,2],
|
||||
["p1",8,"op","chess_char_4_24_b",1,"uniequip_002_billro",2395.8,1,2,4,46849,3478,0,54,4],
|
||||
["p1",9,"op","chess_char_4_25_b",2,"uniequip_002_cetsyr",1076.51,1,24,0,0,32684,5419,0,2],
|
||||
["p1",10,"op","chess_char_4_26_b",0,"uniequip_002_bldsk",1674.2,1,3,0,0,2208,31448,46,8]
|
||||
],
|
||||
"enemies": {
|
||||
"enemy_1005_yokai_3": [1,1,0,17270,107495],
|
||||
"enemy_9010_acpupp": [2,1,0,19915,109930],
|
||||
"enemy_9021_acduml_2": [1,0,0,4507,373808],
|
||||
"enemy_9022_acdumm": [1,0,0,3098,0],
|
||||
"enemy_9023_acdums": [10,10,0,124838,360]
|
||||
"enemy_1005_yokai_3": [1,1,0,16542,107495],
|
||||
"enemy_9010_acpupp": [2,2,0,18441,86026],
|
||||
"enemy_9021_acduml_2": [1,0,0,4507,378572],
|
||||
"enemy_9022_acdumm": [1,0,0,3215,0],
|
||||
"enemy_9023_acdums": [10,8,0,123292,352]
|
||||
},
|
||||
"hooks": [1,34,6000,686,686,2437,2957,6444,21,21,29,28,0,9973,109,18,15,0,1,51,0,24,2],
|
||||
"events": {"atk":1008,"deploy":19,"die":21,"dmg":2942,"engage":7,"fx":874,"heal":228,"skill":60,"spawn":25,"status":330},
|
||||
"fx": {"appear":3,"bandage":8,"beam":21,"block":15,"burst":24,"disappear":3,"dodge":2,"ember":2,"explode":510,"featherArrow":6,"garrisonGrant":1,"healField":2,"jungleSoul":4,"mote":53,"phase":56,"redistribute":30,"revive":1,"reweave":2,"riposte":1,"rockslide":2,"sandChains":1,"sandChainsCharged":2,"shieldBreak":22,"slash":40,"spGift":7,"summon":8,"telegraph":2,"thorns":43,"truesilver":2,"undying":1},
|
||||
"snaps": "66102eb2 711577a7 5b34ba48 b88d4bb5 a7c05e6b bfd5e766 4b333a83 ed69496e 06b28ff3 a3aff9fb 53f4bd37 c96d8bc9 870f8b31 27aa3bc7 c1567cdf b461354b 0d40f693 b5835929 ced76f1b fe45cd95"
|
||||
"hooks": [1,32,6000,682,682,2353,2829,6095,19,19,30,29,0,10668,109,17,15,0,1,40,0,22,3],
|
||||
"events": {"atk":996,"deploy":17,"die":19,"dmg":2814,"engage":7,"fx":832,"heal":226,"skill":62,"spawn":25,"status":320},
|
||||
"fx": {"appear":1,"bandage":8,"beam":21,"block":11,"burst":22,"disappear":1,"dodge":3,"ember":2,"explode":484,"featherArrow":6,"garrisonGrant":1,"healField":2,"jungleSoul":4,"mote":52,"phase":54,"redistribute":30,"reweave":2,"riposte":1,"rockslide":2,"sandChains":2,"sandChainsCharged":2,"shieldBreak":22,"slash":40,"spGift":4,"summon":8,"telegraph":2,"thorns":42,"truesilver":2,"undying":1},
|
||||
"snaps": "66102eb2 711577a7 5b34ba48 b88d4bb5 a7c05e6b bfd5e766 4b333a83 ed69496e 09c18528 619b7ea1 9d890c8e 85de072b 0f2ae8c9 af506320 0f8016e6 188a65ff fa7de72b e09b1905 868c7205 6205ddcc"
|
||||
},
|
||||
"unite-1": {
|
||||
"about": "联防 1 helper(s), 11 leaked: chess_char_6_03_b chess_char_6_06_b chess_char_6_07_b chess_char_6_01_b chess_char_6_02_b chess_char_6_04_b chess_char_6_05_b chess_char_6_08_b token_10017_skadi2_dedant",
|
||||
|
||||
+29
-29
@@ -1322,7 +1322,7 @@
|
||||
"combat": "client",
|
||||
"boost": {"lp":400,"layers":300},
|
||||
"setup": {"stageId":"act1autochess_m02","bossId":"boss_5","hiddenBossId":"boss_10","factions":["TIMES","ELEMENT","DOT"]},
|
||||
"end": {"victory":true,"roundsPassed":15,"hiddenReached":true,"hiddenCleared":true,"reason":"victory","teamLp":397,"bossPoolLeft":0,"virtualMs":113700},
|
||||
"end": {"victory":true,"roundsPassed":15,"hiddenReached":true,"hiddenCleared":true,"reason":"victory","teamLp":399,"bossPoolLeft":0,"virtualMs":113700},
|
||||
"rng": {"setup":25,"shop":346,"waves":0,"draft":42,"bots":10350,"meta":3},
|
||||
"errors": {"engine":0,"logged":0,"sim":0,"dispatcher":0},
|
||||
"rounds": [
|
||||
@@ -1385,12 +1385,12 @@
|
||||
"victory": true,
|
||||
"roundsPassed": 15,
|
||||
"eliminatedRound": null,
|
||||
"lp": 397,
|
||||
"lp": 399,
|
||||
"bandId": "band_paganini",
|
||||
"title": {"id":"comment_1","name":"卫戍之星","picId":"comment_icon_1","text":"对敌方领袖造成伤害最高(仅胜利时)"},
|
||||
"trophies": 0,
|
||||
"reward": 425,
|
||||
"stats": {"activatedLayers":2279,"bossDamage":4580000,"dmgDealt":6596436,"gold":175,"itemsEquipped":25,"kills":376,"leaks":1,"lpLost":1,"merges":5,"perfectRounds":12,"refreshes":34},
|
||||
"stats": {"activatedLayers":2279,"bossDamage":4580000,"dmgDealt":6596430,"gold":175,"itemsEquipped":25,"kills":375,"leaks":1,"lpLost":1,"merges":5,"perfectRounds":12,"refreshes":34},
|
||||
"lineup": ["4_02_b@12,7[3_08_e_a+1_02_e_b]","6_01_a@12,8[3_03_e_a+3_02_e_a]","6_16_a@11,5","3_18_a@11,8[4_11_e_a+4_05_e_b]","4_10_a@10,4[1_05_e_b]","6_05_a@10,5","4_01_b@10,6[4_06_e_a+4_05_e_a]","1_08_b@9,9[4_10_e_a]"],
|
||||
"bonds": ["arcaneShip 2/389","deputShip 0/300","egirShip 0/300","emptyShip 0/300","indomShip 0/308","investShip 0/300","kazimierzShip 0/308","kjeragShip 0/300","lateranoShip 1/320","maniShip 0/300","miraShip 0/300","preciShip 1/302","raidShip 0/300","sargonShip 0/300","siracusaShip 1/345","skillfulShip 0/335","soloShip 1/307","steadShip 0/300","suntShip 1/300","swiftShip 1/316","victoriaShip 0/300","visiShip 0/300","yanShip 0/300"]
|
||||
}
|
||||
@@ -1517,8 +1517,8 @@
|
||||
"combat": "client",
|
||||
"boost": null,
|
||||
"setup": {"stageId":"act1autochess_m01","bossId":"boss_5","hiddenBossId":"boss_10","factions":["DOT","INVISIBLE","REFLECTION"]},
|
||||
"end": {"victory":true,"roundsPassed":14,"hiddenReached":false,"hiddenCleared":false,"reason":"victory","teamLp":40,"bossPoolLeft":0,"virtualMs":116900},
|
||||
"rng": {"setup":25,"shop":489,"waves":0,"draft":34,"bots":18039,"meta":11},
|
||||
"end": {"victory":true,"roundsPassed":14,"hiddenReached":false,"hiddenCleared":false,"reason":"victory","teamLp":30,"bossPoolLeft":0,"virtualMs":119900},
|
||||
"rng": {"setup":25,"shop":428,"waves":0,"draft":34,"bots":18039,"meta":10},
|
||||
"errors": {"engine":0,"logged":0,"sim":0,"dispatcher":0},
|
||||
"rounds": [
|
||||
{
|
||||
@@ -1563,28 +1563,28 @@
|
||||
},
|
||||
{
|
||||
"round": 9,
|
||||
"h_0": [1,28,14,0,0,4,8,"6e857f7b",102,"arcaneShip 1/11, kazimierzShip 1/20, preciShip 0/8, raidShip 1/60, skillfulShip 0/3, soloShip 1/0, suntShip 1/0, swiftShip 1/11, yanShip 0/1",23,23,0,78],
|
||||
"ai_0": [1,23,12,0,2,4,8,"8aab1ba8",138,"arcaneShip 0/2, deputShip 1/16, kazimierzShip 0/8, lateranoShip 1/35, preciShip 1/17, sargonShip 0/2, skillfulShip 1/9, soloShip 0/5, suntShip 1/14, swiftShip 1/47",23,23,0,73]
|
||||
"h_0": [1,28,14,0,0,4,8,"256af2e6",37,"arcaneShip 1/11, kazimierzShip 1/12, miraShip 0/3, preciShip 0/8, raidShip 0/48, skillfulShip 1/3, soloShip 1/0, suntShip 1/0, swiftShip 1/11, yanShip 0/7",22,23,1,78],
|
||||
"ai_0": [1,23,12,0,0,4,8,"10cd80b7",133,"arcaneShip 0/2, deputShip 0/14, kazimierzShip 0/8, lateranoShip 1/36, preciShip 1/23, sargonShip 0/2, skillfulShip 1/7, soloShip 1/5, suntShip 1/12, swiftShip 1/50",23,23,0,72]
|
||||
},
|
||||
{
|
||||
"round": 10,
|
||||
"h_0": [1,28,12,0,0,5,8,"00fe855c",129,"arcaneShip 1/14, kazimierzShip 1/32, preciShip 0/8, raidShip 1/72, skillfulShip 0/3, soloShip 1/0, suntShip 1/0, swiftShip 1/11, yanShip 0/1",31,31,0,88],
|
||||
"ai_0": [1,23,14,0,0,5,8,"b70033a0",160,"arcaneShip 0/2, deputShip 0/16, kazimierzShip 0/8, lateranoShip 1/38, preciShip 2/27, sargonShip 0/2, skillfulShip 1/14, soloShip 1/5, suntShip 1/14, swiftShip 1/62",31,31,0,88]
|
||||
"h_0": [1,28,12,0,0,5,8,"904868f5",47,"arcaneShip 1/14, kazimierzShip 1/16, miraShip 0/3, preciShip 0/8, raidShip 0/48, skillfulShip 1/6, soloShip 1/0, suntShip 1/0, swiftShip 1/11, yanShip 0/7",31,31,0,88],
|
||||
"ai_0": [1,23,12,0,0,5,8,"fc0a1877",163,"arcaneShip 0/2, deputShip 0/14, kazimierzShip 0/8, lateranoShip 1/39, preciShip 1/33, sargonShip 0/2, skillfulShip 1/12, soloShip 1/5, suntShip 1/12, swiftShip 1/62",31,31,0,87]
|
||||
},
|
||||
{
|
||||
"round": 11,
|
||||
"h_0": [1,28,12,0,0,5,8,"b2b9f9bd",153,"arcaneShip 1/14, kazimierzShip 1/40, miraShip 1/4, preciShip 0/8, raidShip 1/80, skillfulShip 0/3, soloShip 1/0, suntShip 1/0, swiftShip 1/15, yanShip 0/1",25,25,0,100],
|
||||
"ai_0": [1,23,12,0,0,5,8,"6ec28fe0",193,"arcaneShip 0/2, deputShip 0/16, kazimierzShip 0/8, lateranoShip 1/41, preciShip 2/40, sargonShip 0/2, skillfulShip 1/19, soloShip 1/5, suntShip 1/14, swiftShip 1/74",25,25,0,101]
|
||||
"h_0": [1,27,12,0,0,5,8,"0d65116f",125,"arcaneShip 1/17, kazimierzShip 1/31, miraShip 1/3, preciShip 0/8, raidShip 1/60, skillfulShip 0/9, soloShip 1/3, suntShip 1/0, swiftShip 1/11, yanShip 0/7",22,25,3,99],
|
||||
"ai_0": [1,23,12,0,0,5,8,"6cafa713",213,"arcaneShip 0/2, deputShip 0/14, kazimierzShip 0/8, lateranoShip 1/49, preciShip 2/53, sargonShip 0/2, skillfulShip 1/17, soloShip 1/5, suntShip 1/12, swiftShip 1/77",25,25,0,100]
|
||||
},
|
||||
{
|
||||
"round": 12,
|
||||
"h_0": [1,28,12,0,0,6,8,"c8e0476d",188,"arcaneShip 1/17, kazimierzShip 1/52, miraShip 1/8, preciShip 0/8, raidShip 1/92, skillfulShip 0/3, soloShip 1/0, suntShip 1/0, swiftShip 1/19, yanShip 0/1",25,31,6,112],
|
||||
"ai_0": [1,23,12,0,0,6,8,"54a25cd7",229,"arcaneShip 0/2, deputShip 0/16, kazimierzShip 0/8, lateranoShip 1/47, preciShip 2/50, sargonShip 0/2, skillfulShip 1/24, soloShip 1/5, suntShip 1/14, swiftShip 1/89",31,31,0,112]
|
||||
"h_0": [1,27,12,0,0,6,8,"42ad03f7",80,"arcaneShip 1/20, emptyShip 1/0, kazimierzShip 1/35, miraShip 1/3, preciShip 1/8, raidShip 0/60, skillfulShip 0/9, soloShip 1/3, suntShip 1/0, swiftShip 1/11, yanShip 0/7",22,31,9,111],
|
||||
"ai_0": [1,23,12,0,0,6,8,"189b8c70",273,"arcaneShip 0/2, deputShip 0/14, kazimierzShip 0/8, lateranoShip 1/67, preciShip 2/75, sargonShip 0/2, skillfulShip 1/25, soloShip 1/5, suntShip 1/12, swiftShip 1/89",31,31,0,111]
|
||||
},
|
||||
{
|
||||
"round": 13,
|
||||
"h_0": [1,18,12,0,0,6,8,"205ea1e7",227,"arcaneShip 1/20, kazimierzShip 1/64, miraShip 1/12, preciShip 0/8, raidShip 1/108, skillfulShip 0/3, soloShip 1/0, suntShip 1/0, swiftShip 1/23, yanShip 0/1",23,57,34,124],
|
||||
"ai_0": [1,22,12,0,0,6,8,"dc15adde",271,"arcaneShip 0/2, deputShip 0/16, kazimierzShip 0/8, lateranoShip 1/53, preciShip 2/63, sargonShip 0/2, skillfulShip 1/29, soloShip 1/5, suntShip 1/14, swiftShip 1/107, visiShip 1/0",56,57,1,126]
|
||||
"h_0": [1,17,12,0,0,6,8,"d8bc61ba",191,"arcaneShip 1/20, emptyShip 1/0, kazimierzShip 1/62, miraShip 0/3, preciShip 1/8, raidShip 1/87, skillfulShip 0/9, soloShip 1/3, suntShip 1/0, swiftShip 1/11, yanShip 0/7",28,57,29,123],
|
||||
"ai_0": [1,13,12,0,0,6,8,"09231657",325,"arcaneShip 0/2, deputShip 0/14, kazimierzShip 0/8, lateranoShip 1/81, preciShip 2/93, sargonShip 0/2, skillfulShip 1/33, soloShip 1/5, suntShip 2/12, swiftShip 1/101",27,57,30,122]
|
||||
}
|
||||
],
|
||||
"players": {
|
||||
@@ -1593,28 +1593,28 @@
|
||||
"victory": true,
|
||||
"roundsPassed": 14,
|
||||
"eliminatedRound": null,
|
||||
"lp": 18,
|
||||
"lp": 17,
|
||||
"bandId": "band_amedic",
|
||||
"title": {"id":"comment_4","name":"精英云集","picId":"comment_icon_4","text":"晋升精锐次数最多"},
|
||||
"title": {"id":"comment_5","name":"万事俱备","picId":"comment_icon_5","text":"装备配发数量最多"},
|
||||
"trophies": 4,
|
||||
"reward": 400,
|
||||
"stats": {"activatedLayers":220,"bossDamage":344186,"dmgDealt":3000543,"gold":134,"itemsEquipped":22,"kills":272,"leaks":40,"lpLost":10,"merges":3,"perfectRounds":11,"refreshes":21},
|
||||
"lineup": ["6_18_a@12,3[4_11_e_a]","4_10_a@12,4","1_17_b@11,3[4_03_e_a+1_01_e_a]","5_20_a@11,8[6_05_e_a+3_06_e_a]","3_17_b@10,3","3_12_a@10,4[1_02_e_a+3_11_e_a]","5_07_a@10,7[4_02_e_b+2_01_e_b]","1_19_b@10,8[2_01_e_b+4_04_e_b]"],
|
||||
"bonds": ["arcaneShip 2/24","kazimierzShip 1/64","miraShip 1/12","preciShip 0/8","raidShip 1/108","siracusaShip 1/12","skillfulShip 0/3","soloShip 1/0","suntShip 1/0","swiftShip 0/23","yanShip 0/1"]
|
||||
"stats": {"activatedLayers":194,"bossDamage":206158,"dmgDealt":2761977,"gold":135,"itemsEquipped":20,"kills":267,"leaks":42,"lpLost":11,"merges":4,"perfectRounds":9,"refreshes":19},
|
||||
"lineup": ["3_10_a@12,3","6_12_a@11,3[1_02_e_a+2_01_e_a]","3_09_a@11,8[1_01_e_a+6_05_e_a]","3_17_a@10,3[1_05_e_a+3_08_e_a]","3_12_b@10,4[3_04_e_a+4_04_e_a]","5_07_a@10,7","1_19_b@10,8[4_05_e_a+2_01_e_b]","1_17_b@9,8[4_03_e_a+4_02_e_b]"],
|
||||
"bonds": ["arcaneShip 2/20","emptyShip 1/0","kazimierzShip 1/62","miraShip 1/3","preciShip 1/8","raidShip 1/87","skillfulShip 0/9","soloShip 1/3","suntShip 1/0","swiftShip 1/11","yanShip 0/7"]
|
||||
},
|
||||
"ai_0": {
|
||||
"alive": true,
|
||||
"victory": true,
|
||||
"roundsPassed": 14,
|
||||
"eliminatedRound": null,
|
||||
"lp": 22,
|
||||
"lp": 13,
|
||||
"bandId": "band_chiave",
|
||||
"title": {"id":"comment_1","name":"卫戍之星","picId":"comment_icon_1","text":"对敌方领袖造成伤害最高(仅胜利时)"},
|
||||
"trophies": 4,
|
||||
"reward": 400,
|
||||
"stats": {"activatedLayers":302,"bossDamage":435814,"dmgDealt":3507011,"gold":140,"itemsEquipped":17,"kills":299,"leaks":1,"lpLost":1,"merges":3,"perfectRounds":12,"refreshes":22},
|
||||
"lineup": ["3_21_a@12,3[4_08_e_a+6_02_e_a]","4_25_a@12,4","2_01_a@12,5[3_01_e_a]","6_12_a@11,3[1_05_e_a+3_11_e_a]","4_14_b@11,4[1_05_e_b+4_07_e_a]","1_01_b@10,3[3_08_e_a]","5_01_a@10,4[4_05_e_a]","2_13_a@9,4[4_02_e_b]"],
|
||||
"bonds": ["arcaneShip 0/2","deputShip 0/16","emptyShip 1/0","kazimierzShip 0/8","lateranoShip 1/56","preciShip 2/76","sargonShip 0/2","skillfulShip 1/34","soloShip 0/5","suntShip 1/14","swiftShip 1/119","visiShip 1/3"]
|
||||
"stats": {"activatedLayers":383,"bossDamage":573842,"dmgDealt":3560504,"gold":134,"itemsEquipped":20,"kills":276,"leaks":30,"lpLost":10,"merges":5,"perfectRounds":12,"refreshes":14},
|
||||
"lineup": ["2_01_a@12,3[3_01_e_a+3_03_e_a]","4_14_b@12,7[4_08_e_a+3_04_e_b]","1_01_b@11,3[2_01_e_a]","4_09_a@11,8","3_21_b@10,3[6_02_e_a+5_01_e_a]","2_13_b@10,7[4_04_e_a+4_02_e_a]","6_17_a@10,8","6_01_a@9,8[3_07_e_a]"],
|
||||
"bonds": ["arcaneShip 0/2","deputShip 0/14","kazimierzShip 0/8","lateranoShip 1/93","miraShip 1/0","preciShip 2/109","sargonShip 0/2","siracusaShip 0/6","skillfulShip 1/43","soloShip 1/5","suntShip 1/12","swiftShip 1/121"]
|
||||
}
|
||||
},
|
||||
"standIns": {
|
||||
@@ -1628,11 +1628,11 @@
|
||||
[6,"3_12_a→char_608_acpion"],
|
||||
[7,"3_12_a→char_608_acpion"],
|
||||
[8,"3_12_a→char_608_acpion"],
|
||||
[9,"3_12_a→char_608_acpion 5_20_a→char_614_acsupo"],
|
||||
[10,"3_12_a→char_608_acpion 5_20_a→char_614_acsupo"],
|
||||
[11,"3_12_a→char_608_acpion 5_20_a→char_614_acsupo"],
|
||||
[12,"3_12_a→char_608_acpion 5_20_a→char_614_acsupo"],
|
||||
[13,"3_12_a→char_608_acpion 5_20_a→char_614_acsupo"]
|
||||
[9,"3_12_a→char_608_acpion"],
|
||||
[10,"3_12_a→char_608_acpion"],
|
||||
[11,"3_12_a→char_608_acpion 4_16_a→char_615_acspec"],
|
||||
[12,"3_12_a→char_608_acpion"],
|
||||
[13,"3_12_b→char_608_acpion 4_16_a→char_615_acspec"]
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
+54
-54
@@ -1010,71 +1010,71 @@
|
||||
"round": 12,
|
||||
"seed": 3545615126,
|
||||
"waveId": "act1autochess_h05",
|
||||
"time": 226.4,
|
||||
"ticks": 6792,
|
||||
"reason": "cleared",
|
||||
"killed": 104,
|
||||
"time": 230,
|
||||
"ticks": 6900,
|
||||
"reason": "timeout",
|
||||
"killed": 105,
|
||||
"total": 126,
|
||||
"unspawned": 0,
|
||||
"bossHpLeft": null,
|
||||
"rngDraws": 605,
|
||||
"rngDraws": 633,
|
||||
"errors": {"count":0,"kinds":[]},
|
||||
"players": {
|
||||
"p1": {
|
||||
"killed": 104,
|
||||
"killed": 105,
|
||||
"total": 126,
|
||||
"perfect": false,
|
||||
"leaks": 23,
|
||||
"leaks": 22,
|
||||
"leaksUncounted": 0,
|
||||
"coins": 10,
|
||||
"dmg": 4885870,
|
||||
"dmg": 5000855,
|
||||
"bossDmg": 0,
|
||||
"heal": 80735,
|
||||
"deaths": 7,
|
||||
"heal": 92236,
|
||||
"deaths": 4,
|
||||
"layerGains": {"preciShip":4}
|
||||
}
|
||||
},
|
||||
"units": [
|
||||
["p1",1,"op","chess_char_5_19_b",2,"uniequip_002_mlynar",4297.7,1,130,8,555859,4578,280,79,4],
|
||||
["p1",2,"op","chess_char_6_03_a",2,null,2973.3,1,122,1,90690,18321,9211,67,2],
|
||||
["p1",3,"op","chess_char_6_06_a",2,null,2445.3,1,135,8,553051,18507,837,91,2],
|
||||
["p1",4,"op","chess_char_5_17_b",1,"uniequip_002_f12yin",3011.8,1,8,8,248468,28911,11557,331,1],
|
||||
["p1",5,"op","chess_char_5_20_b",2,"uniequip_002_aglina",1520.2,1,10,2,160362,0,0,48,5],
|
||||
["p1",6,"op","chess_char_5_21_b",1,"uniequip_002_sntlla",1791.9,1,85,1,369795,3362,889,125,3],
|
||||
["p1",7,"op","chess_char_5_22_b",1,"uniequip_002_nymph",1772.1,1,128,1,4914,2027,255,5,3],
|
||||
["p1",8,"op","chess_char_5_23_b",1,"uniequip_002_reckpr",1771,1,127,0,0,4860,52445,66,4],
|
||||
["p1",9,"op","chess_char_6_01_a",2,null,1273.8,1,5,43,1148844,1462,452,98,5],
|
||||
["p1",10,"op","chess_char_6_02_a",2,null,1855.7,1,7,27,1342901,1863,429,73,3],
|
||||
["p1",11,"op","chess_char_6_04_a",2,null,1322.2,1,9,0,96272,9110,2272,0,4],
|
||||
["p1",12,"op","chess_char_6_05_a",2,null,1336.5,1,59,3,314713,3845,2107,69,5],
|
||||
["p1",1,"op","chess_char_5_19_b",2,"uniequip_002_mlynar",4297.7,1,2,18,602485,3572,935,94,5],
|
||||
["p1",2,"op","chess_char_6_03_a",2,null,4459.95,1,4,1,125610,20086,15136,82,3],
|
||||
["p1",3,"op","chess_char_6_06_a",2,null,2445.3,1,139,7,554612,19329,583,71,2],
|
||||
["p1",4,"op","chess_char_5_17_b",1,"uniequip_002_f12yin",3011.8,1,8,9,204346,33430,14620,337,1],
|
||||
["p1",5,"op","chess_char_5_20_b",2,"uniequip_002_aglina",1520.2,1,10,2,144300,0,0,44,5],
|
||||
["p1",6,"op","chess_char_5_21_b",1,"uniequip_002_sntlla",1791.9,1,85,1,319504,3362,889,122,3],
|
||||
["p1",7,"op","chess_char_5_22_b",1,"uniequip_002_nymph",1772.1,1,1,1,13078,1242,569,7,3],
|
||||
["p1",8,"op","chess_char_5_23_b",1,"uniequip_002_reckpr",1771,1,3,0,0,1352,54601,78,4],
|
||||
["p1",9,"op","chess_char_6_01_a",2,null,1273.8,1,5,38,1233629,1462,645,97,5],
|
||||
["p1",10,"op","chess_char_6_02_a",2,null,1855.7,1,7,23,1379878,2121,288,76,3],
|
||||
["p1",11,"op","chess_char_6_04_a",2,null,1322.2,1,9,3,102446,10462,1863,0,4],
|
||||
["p1",12,"op","chess_char_6_05_a",2,null,1336.5,1,59,2,320966,3845,2107,69,5],
|
||||
["p1",13,"token","token_10017_skadi2_dedant",null,null,100,0,13,0,0,0,0,0,null],
|
||||
["p1","~1","token","token_10017_skadi2_dedant",null,null,100,0,60,0,0,0,0,0,null],
|
||||
["p1","~2","token","token_10017_skadi2_dedant",null,null,100,0,101,0,0,0,0,0,null],
|
||||
["p1","~3","token","token_10017_skadi2_dedant",null,null,100,0,144,0,0,0,0,0,null],
|
||||
["p1","~4","token","token_10017_skadi2_dedant",null,null,100,1,152,0,0,0,0,0,null]
|
||||
["p1","~3","token","token_10017_skadi2_dedant",null,null,100,0,141,0,0,0,0,0,null],
|
||||
["p1","~4","token","token_10017_skadi2_dedant",null,null,100,1,149,0,0,0,0,0,null]
|
||||
],
|
||||
"enemies": {
|
||||
"enemy_10045_parrot": [1,0,1,0,59936],
|
||||
"enemy_10098_crhro": [1,1,0,8773,185752],
|
||||
"enemy_10124_uashld_2": [12,9,3,44779,1527741],
|
||||
"enemy_10045_parrot": [1,0,1,0,53667],
|
||||
"enemy_10098_crhro": [1,0,1,6535,161143],
|
||||
"enemy_10124_uashld_2": [12,8,3,46823,1543522],
|
||||
"enemy_1019_jshoot_2": [1,1,0,1629,16718],
|
||||
"enemy_1043_zomsbr": [72,61,11,66,1082509],
|
||||
"enemy_1061_zomshd": [30,25,5,9861,1798745],
|
||||
"enemy_1043_zomsbr": [72,62,10,101,1105813],
|
||||
"enemy_1061_zomshd": [30,26,4,12386,1814925],
|
||||
"enemy_1072_dlancer": [1,1,0,271,37150],
|
||||
"enemy_1165_duhond": [1,1,0,0,17337],
|
||||
"enemy_1165_duhond": [1,1,0,86,17337],
|
||||
"enemy_1196_msfyin_2": [1,1,0,0,9],
|
||||
"enemy_1209_sfden_2": [1,1,0,2344,111451],
|
||||
"enemy_1210_msfden_2": [1,0,1,0,13],
|
||||
"enemy_1273_stmgun_2": [1,0,1,23833,36689],
|
||||
"enemy_1209_sfden_2": [1,1,0,781,111451],
|
||||
"enemy_1210_msfden_2": [1,0,1,0,36],
|
||||
"enemy_1273_stmgun_2": [1,1,0,26361,111451],
|
||||
"enemy_1355_mrfly": [1,1,0,0,37150],
|
||||
"enemy_1433_dsbasi_2": [1,1,0,0,35293],
|
||||
"enemy_2001_duckmi_2": [1,1,0,0,37150],
|
||||
"enemy_9006_actoxi": [1,0,1,0,5530]
|
||||
"enemy_9006_actoxi": [1,0,1,0,25174]
|
||||
},
|
||||
"hooks": [1,151,6792,1051,1131,7506,7729,62877,111,138,41,38,29,39389,1162,40,127,23,1,120,2,9,8],
|
||||
"events": {"atk":2382,"bounty":5,"deploy":24,"die":115,"dmg":7649,"engage":10,"fx":578,"heal":102,"layer":2,"leak":23,"skill":79,"spawn":144,"status":2786},
|
||||
"fx": {"aoe":7,"bombard":29,"bombardShell":29,"burst":9,"crit":56,"dodge":8,"explode":29,"firewall":2,"garrison":2,"iceSpike":99,"lightning":127,"lock":29,"lure":3,"revive":1,"snow":75,"sp":2,"storm":5,"summon":1,"talent":1,"telegraph":8,"tide":4,"wanted":47,"weightless":5},
|
||||
"snaps": "fe3def4e d483c136 ee2f3c0d 07a1e547 9d76e528 885dce23 cc4899ef 0f9faea8 eff043fe 32300c6f 2224c6d6 e3df2d18 9d122a08 27bee743 0d07fc4f f4421c12 c5ec3c4a fe540660 e802505f c9d74e16 6b47afbe e6a0a4fc 8246b490"
|
||||
"hooks": [1,148,6900,1084,1158,7800,8054,66853,109,134,43,38,29,40854,1160,49,127,21,1,125,2,12,7],
|
||||
"events": {"atk":2485,"bounty":5,"deploy":21,"die":113,"dmg":7974,"engage":10,"fx":604,"heal":118,"layer":2,"leak":21,"skill":81,"spawn":144,"status":2763},
|
||||
"fx": {"aoe":8,"bombard":29,"bombardShell":29,"burst":12,"crit":66,"dodge":7,"explode":30,"firewall":3,"garrison":2,"iceSpike":99,"lightning":139,"lock":29,"lure":3,"revive":1,"snow":74,"soul":1,"sp":1,"storm":5,"summon":1,"talent":1,"telegraph":7,"tide":4,"wanted":48,"weightless":5},
|
||||
"snaps": "fe3def4e 60ce0e98 68e4eff1 0ab18079 e3445176 db32929f bf23ff70 1142d3b6 bc663bbd dde23a5f 8df8a873 85d69b81 c70cdc4f 94132718 683de6ff 1eccc1af f1c61b7b 2bf8e444 50898a85 e25629e3 913e8ab6 2f3a3189 fa6f8200"
|
||||
},
|
||||
"roster-017": {
|
||||
"about": "pass 0 band 6: chess_char_6_15_a chess_char_6_19_a chess_char_6_07_a chess_char_6_17_a chess_char_6_08_a chess_char_6_09_a chess_char_6_11_a chess_char_6_12_a chess_char_6_13_a chess_char_6_14_a chess_char_6_16_a chess_char_6_18_a",
|
||||
@@ -2195,7 +2195,7 @@
|
||||
"hooks": [1,142,5560,344,344,550,908,981,125,128,57,54,6,14497,442,7,125,0,1,126,2,25,0],
|
||||
"events": {"atk":520,"bounty":4,"deploy":17,"die":128,"dmg":908,"engage":6,"fx":229,"heal":14,"layer":2,"skill":111,"spawn":142,"status":726},
|
||||
"fx": {"bombard":2,"bombardShell":2,"burst":25,"copy":1,"expose":118,"frostNova":1,"garrison":2,"heal":3,"phase":1,"pulse":2,"shieldBreak":1,"snow":14,"soul":2,"sp":39,"talent":1,"wanted":15},
|
||||
"snaps": "1fd81580 e8b29c39 8fe3be2e 22869cd5 83901ef4 36107212 007246d4 0fbda2dd d9aefee2 c69d102c cac8d601 30f7a8ad 606c1bdc d241a31d d8ce3067 1c139be5 14565097 4a7b95ab ecf99e3e"
|
||||
"snaps": "1fd81580 e8b29c39 8fe3be2e 22869cd5 83901ef4 e9e63622 d75e0fd3 81f144f2 93d80511 36f4b653 603069de 9337b18e 35d5c75b f92e33e2 ae44a508 733b9e86 63b04f00 a1c2df3c f8192841"
|
||||
},
|
||||
"roster-036": {
|
||||
"about": "pass 1 band 7: chess_char_6_03_b chess_char_6_15_a chess_char_6_19_a chess_char_6_17_a chess_char_6_12_a chess_char_6_13_a chess_char_6_14_a chess_char_6_16_a chess_char_6_18_a chess_char_6_20_a chess_char_6_01_b chess_char_6_02_b",
|
||||
@@ -2262,7 +2262,7 @@
|
||||
"hooks": [1,157,6221,639,638,1374,1624,8342,92,147,56,54,59,23902,3684,26,130,55,1,92,2,4,0],
|
||||
"events": {"atk":680,"bounty":2,"deploy":24,"die":92,"dmg":1417,"engage":9,"fx":644,"heal":524,"layer":2,"leak":55,"skill":110,"spawn":145,"status":871},
|
||||
"fx": {"airstrike":15,"aoe":3,"burst":4,"displace":2,"explode":16,"expose":460,"frostNova":17,"garrison":2,"healField":15,"link":1,"phase":1,"snow":37,"sunBurst":2,"talent":7,"telegraph":2,"wanted":60},
|
||||
"snaps": "352a6117 5197ba48 10f9a2ae 33f679df 3665a8ee 49550c0d 3f360174 ce1f9cce 89c5196c 39a34bf7 31facd72 20cbae52 17caf576 03e5ee75 5a29cf38 cd4bf8d3 6a8928c3 7ccff12b 527fbb07 bf017b13 a9045a61"
|
||||
"snaps": "352a6117 5197ba48 22ff25c2 16105f43 67bd4112 04c5a2b1 e4e63a95 9087d64d 41e697b3 8e3b24fc fa002205 4a75e62c 71798688 e3c0247f 3b14b2c2 04aa6c52 2d90b642 4764ab7a 541b8b1e 598e0a94 ca28421b"
|
||||
},
|
||||
"roster-037": {
|
||||
"about": "pass 1 band 7: chess_char_6_06_b chess_char_6_15_b chess_char_6_07_b chess_char_6_04_b chess_char_6_05_b chess_char_6_08_b chess_char_6_09_b chess_char_6_11_b chess_char_6_12_b chess_char_6_13_b chess_char_6_14_b chess_char_6_16_b token_10017_skadi2_dedant token_10030_mlyss_wtrman",
|
||||
@@ -2790,9 +2790,9 @@
|
||||
"leaks": 0,
|
||||
"leaksUncounted": 0,
|
||||
"coins": 8,
|
||||
"dmg": 1238376,
|
||||
"dmg": 1238096,
|
||||
"bossDmg": 0,
|
||||
"heal": 9730,
|
||||
"heal": 9576,
|
||||
"deaths": 0,
|
||||
"layerGains": {"lateranoShip":12,"preciShip":8}
|
||||
}
|
||||
@@ -2801,15 +2801,15 @@
|
||||
["p1",1,"op","chess_char_6_03_a",1,null,5054.61,1,7,0,0,0,0,0,0],
|
||||
["p1",2,"op","chess_char_6_06_a",1,null,2445.3,1,9,1,1,0,0,1,1],
|
||||
["p1",3,"op","chess_char_6_07_a",1,null,2420,1,10,0,0,0,0,0,0],
|
||||
["p1",4,"op","chess_char_6_01_a",1,null,1273.8,1,4,26,302286,0,0,36,3],
|
||||
["p1",4,"op","chess_char_6_01_a",1,null,1273.8,1,4,31,302479,0,0,39,3],
|
||||
["p1",5,"op","chess_char_6_02_a",1,null,1855.7,1,5,0,0,0,0,0,0],
|
||||
["p1",6,"op","chess_char_6_04_a",1,null,1322.2,1,11,0,0,0,0,0,1],
|
||||
["p1",7,"op","chess_char_6_05_a",1,null,1336.5,1,13,5,85739,0,0,19,2],
|
||||
["p1",7,"op","chess_char_6_05_a",1,null,1336.5,1,13,5,87114,0,0,19,2],
|
||||
["p1",8,"op","chess_char_6_08_a",1,null,1497.1,1,6,0,0,0,0,0,0],
|
||||
["p1",9,"op","chess_char_6_09_a",2,null,1029.6,1,14,11,378876,864,864,111,2],
|
||||
["p1",10,"op","chess_char_6_11_a",1,null,1634.6,1,12,6,173885,0,0,95,3],
|
||||
["p1",11,"op","chess_char_6_12_a",2,null,4367.19,1,15,18,228756,5384,5403,45,2],
|
||||
["p1",12,"op","chess_char_6_13_a",2,null,1892,1,8,1,68833,3462,3462,0,0],
|
||||
["p1",9,"op","chess_char_6_09_a",2,null,1029.6,1,14,11,371732,711,711,110,2],
|
||||
["p1",10,"op","chess_char_6_11_a",1,null,1634.6,1,12,7,173044,0,0,95,3],
|
||||
["p1",11,"op","chess_char_6_12_a",2,null,4367.19,1,15,13,223002,5384,5403,46,2],
|
||||
["p1",12,"op","chess_char_6_13_a",2,null,1892,1,8,0,80724,3462,3462,0,0],
|
||||
["p1",13,"token","token_10017_skadi2_dedant",null,null,100,0,16,0,0,0,0,0,null],
|
||||
["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null],
|
||||
["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null],
|
||||
@@ -2825,18 +2825,18 @@
|
||||
"enemy_10073_mpcar": [1,1,0,0,3110],
|
||||
"enemy_1023_jmage": [1,1,0,1159,3421],
|
||||
"enemy_1045_hammer_2": [12,12,0,6631,373248],
|
||||
"enemy_1061_zomshd": [48,48,0,940,788876],
|
||||
"enemy_1158_divman": [1,1,0,285,5132],
|
||||
"enemy_1061_zomshd": [48,48,0,940,788596],
|
||||
"enemy_1158_divman": [1,1,0,131,5132],
|
||||
"enemy_1199_sfjin": [1,1,0,0,10109],
|
||||
"enemy_1200_msfjin": [1,1,0,0,47],
|
||||
"enemy_1270_nhstlk_2": [1,1,0,0,24883],
|
||||
"enemy_1402_tgshd_2": [1,1,0,54,20218],
|
||||
"enemy_2001_duckmi_2": [1,1,0,0,9331]
|
||||
},
|
||||
"hooks": [1,90,5499,356,338,701,1128,8573,68,74,14,12,41,25781,515,20,68,0,1,68,10,51,0],
|
||||
"events": {"atk":361,"bounty":4,"deploy":19,"die":74,"dmg":945,"engage":6,"fx":193,"layer":10,"skill":26,"spawn":90,"status":1102},
|
||||
"fx": {"airstrike":12,"burst":51,"crit":28,"dp":3,"expose":6,"garrison":6,"link":1,"lock":28,"snow":5,"summon":3,"summonStun":3,"talent":1,"wanted":46},
|
||||
"snaps": "a85a8369 0a33b2f2 724f8cee 96cdeaea 2a60b953 251ce27e ef31d396 4fd327c7 e1fcf4f3 2413fe96 23f1c1b9 e60d68c2 414b5196 26676467 18daab40 3c5e3c0d 5fcd0834 02715784 017438eb"
|
||||
"hooks": [1,90,5499,357,339,694,1119,8499,68,74,14,12,41,25781,509,19,68,0,1,68,10,50,0],
|
||||
"events": {"atk":362,"bounty":4,"deploy":19,"die":74,"dmg":936,"engage":6,"fx":192,"layer":10,"skill":26,"spawn":90,"status":1088},
|
||||
"fx": {"airstrike":12,"burst":50,"crit":28,"dp":3,"expose":6,"garrison":6,"link":1,"lock":28,"snow":5,"summon":3,"summonStun":3,"talent":1,"wanted":46},
|
||||
"snaps": "a85a8369 6b6410a1 1af1f862 32a99902 bdbc1150 56475174 2f96ad8a 1db64d03 21d24dd9 2db9fdb3 1c302c59 053458d5 efcdeffa 11f04efa c2d50d10 498289e0 092a22da 8661ea58 38a6f6ff"
|
||||
},
|
||||
"roster-046": {
|
||||
"about": "pass 2 band 7: chess_char_6_03_b chess_char_6_15_a chess_char_6_19_a chess_char_6_17_a chess_char_6_14_a chess_char_6_16_a chess_char_6_18_a chess_char_6_20_a chess_char_6_01_b chess_char_6_02_b chess_char_6_04_b chess_char_6_05_b token_10017_skadi2_dedant",
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import * as BE from '../../server/match/botEmotes.js';
|
||||
import { EMOTE_COOLDOWN_MS } from '../../shared/constants.js';
|
||||
import { makeMatch, give, giveItem, DATA } from './harness.js';
|
||||
const E = (name) => `autochess_battle_${name}`;
|
||||
function fixture() {
|
||||
const human = { playerId: 'human', isBot: false, alive: true };
|
||||
const ai = { playerId: 'ai', isBot: true, alive: true, lastEmoteAt: -Infinity };
|
||||
const other = { playerId: 'other', isBot: true, alive: true, lastEmoteAt: -Infinity };
|
||||
const messages = [];let now = 10000;
|
||||
const m = { round: 1, order: [human, ai, other], lastResults: new Map(), sched: { now: () => now },
|
||||
alivePlayers: () => m.order.filter((p) => p.alive), broadcast: (x) => messages.push(x) };
|
||||
return { m, human, ai, other, messages, advance: () => { now += EMOTE_COOLDOWN_MS + 1; } };
|
||||
}
|
||||
test('AI emotes use the existing whitelist and cooldown, never impersonate a human', () => {
|
||||
const { m, ai, human, messages, advance } = fixture();
|
||||
assert.equal(BE.sendEmote(m, human, E('thanks')), false);
|
||||
assert.equal(BE.sendEmote(m, ai, 'bad'), false);
|
||||
assert.equal(BE.sendEmote(m, ai, E('thanks')), true);
|
||||
assert.equal(BE.sendEmote(m, ai, E('thanks')), false);
|
||||
advance();assert.equal(BE.sendEmote(m, ai, E('thanks')), true);
|
||||
ai.alive = false;advance();assert.equal(BE.sendEmote(m, ai, E('thanks')), false);
|
||||
assert.equal(messages.length, 2);
|
||||
});
|
||||
test('one eligible AI answers a human, cooling seats are skipped; AI messages do not recurse', () => {
|
||||
const { m, human, ai, other, messages } = fixture();ai.lastEmoteAt = m.sched.now();
|
||||
assert.equal(BE.onHumanEmote(m, human.playerId, E('thanks')), true);
|
||||
assert.deepEqual(messages.map((x) => x.playerId), [other.playerId]);
|
||||
assert.equal(BE.onHumanEmote(m, ai.playerId, E('thanks')), false);
|
||||
assert.equal(BE.onHumanEmote(m, 'unknown', E('thanks')), false);
|
||||
});
|
||||
test('pure AI and solo games stay silent at every entry point', () => {
|
||||
for (const pureAI of [true, false]) {
|
||||
const { m, human, ai, messages } = fixture();m.order = pureAI ? [ai] : [human];
|
||||
BE.sendEmote(m, ai, E('thanks'));BE.onHumanEmote(m, human.playerId, E('thanks'));
|
||||
BE.onSettle(m);BE.onPickCard(m, ai, { kind: 'bounty' });BE.onStartUnite(m, { helpers: [ai] });
|
||||
BE.onGiftTicker(m, ai, human.playerId);for (let n=0;n<3;n++) BE.onMerge(m, ai, { kind: 'chess' });
|
||||
assert.deepEqual(messages, []);
|
||||
}
|
||||
});
|
||||
test('settlement uses this round, breaks kill ties by damage and cannot repeat after cooldown', () => {
|
||||
const { m, ai, other, messages, advance } = fixture();
|
||||
ai.stats = { kills: 999 };other.stats = { kills: 0 };
|
||||
m.lastResults.set(ai.playerId, { killed: 2, damageDealt: 10, leaked: [{ counted: true }], perfect: false });
|
||||
m.lastResults.set(other.playerId, { killed: 2, damageDealt: 20, leaked: [], perfect: true });
|
||||
assert.equal(BE.pickSettleEmote(m, ai), E('scared'));assert.equal(BE.pickSettleEmote(m, other), E('call'));
|
||||
assert.equal(BE.onSettle(m), true);advance();assert.equal(BE.onSettle(m), false);
|
||||
assert.equal(messages.length, 2);m.round++;assert.equal(BE.onSettle(m), true);
|
||||
});
|
||||
test('third chess merge reacts once per round, not to items or every later merge', () => {
|
||||
const { m, ai, messages, advance } = fixture();
|
||||
BE.onMerge(m, ai, { kind: 'item' });
|
||||
for(let n=0;n<8;n++) { BE.onMerge(m, ai, { kind: 'chess' });advance(); }
|
||||
assert.equal(messages.length, 1);BE.resetRoundCounters(m);
|
||||
for(let n=0;n<3;n++) { BE.onMerge(m, ai, { kind: 'chess' });advance(); }
|
||||
assert.equal(messages.length, 2);
|
||||
});
|
||||
test('gift reactions identify the source player rather than an operator display name', () => {
|
||||
const { m, ai, human, other, messages } = fixture();
|
||||
assert.equal(BE.onGiftTicker(m, ai, '琳琅诗怀雅'), false);
|
||||
assert.equal(BE.onGiftTicker(m, ai, other.playerId), false);
|
||||
assert.equal(BE.onGiftTicker(m, ai, human.playerId), true);
|
||||
assert.equal(messages.length, 1);
|
||||
});
|
||||
test('environment switch suppresses every emote entry point', () => {
|
||||
const r = spawnSync(process.execPath, ['--input-type=module', '-e', `
|
||||
import {sendEmote} from './server/match/botEmotes.js';
|
||||
const ai={playerId:'ai',isBot:true,alive:true};
|
||||
if(sendEmote({order:[ai,{isBot:false}]},ai,'autochess_battle_thanks')) process.exit(2);
|
||||
`], { env: { ...process.env, SP_BOT_EMOTES: '0' }, encoding: 'utf8' });
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
});
|
||||
test('actual match emote and merge hooks broadcast without errors', (t) => {
|
||||
const h = makeMatch({ mode: 'coop', humans: 1, bots: 1, seed: 1, fake: true }).start().toPrep();t.after(() => h.m.dispose());
|
||||
const [human, ai] = h.m.order;human.lastEmoteAt = ai.lastEmoteAt = -Infinity;
|
||||
assert.equal(h.m.emote(human, E('thanks')).ok, true);
|
||||
const c = Object.values(DATA.chess).find((c) => c.visible && !c.isGolden && c.tier === 1);
|
||||
for (let n=0;n<3;n++) ai.acquireChess(c.chessId);
|
||||
assert.equal(h.m._botEmotesMerges.get(ai.playerId), 1);
|
||||
});
|
||||
|
||||
test('real beacon delivery from a human makes the receiving bot thank the correct player', (t) => {
|
||||
const h = makeMatch({ mode: 'coop', humans: 2, seed: 31, fake: true }).start().toPrep();
|
||||
t.after(() => h.m.dispose());
|
||||
const [human, ai] = h.m.order;
|
||||
ai.isBot = true; ai.lastEmoteAt = -Infinity;
|
||||
const c = Object.values(DATA.chess).find((c) => c.visible && !c.isGolden && c.tier === 2 && c.bonds.length);
|
||||
const carrier = give(h.m, human, c.chessId, 'hand');
|
||||
const item = giveItem(h.m, human, 'chess_item_5_04_e_a');
|
||||
assert.equal(h.m.handle(human.playerId, { t: 'g.equip', itemUid: item.uid, targetUid: carrier.uid }).ok, true);
|
||||
h.m.round = 2;
|
||||
h.m.dispatch(human, 'onRoundStart', { round: 2 });
|
||||
assert.ok(h.bc.filter((e) => e.t === 'm.emote').some((e) => e.playerId === ai.playerId && e.id === E('thanks')));
|
||||
assert.equal(human.effects.filter((e) => e.key === 'effect:builtin_gift').length, 0);
|
||||
assert.equal(h.m.errorCount, 0);
|
||||
});
|
||||
@@ -7,12 +7,74 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { PHASE } from '../../shared/constants.js';
|
||||
import { botPickCard, bountyKillChance, itemTarget, arrange, botPrepBegin, botPrepEnd, bondPlan, rangeTiles, effDps, rangeRec } from '../../server/match/bot.js';
|
||||
import { botPickCard, bountyKillChance, itemTarget, arrange, botPrepBegin, botPrepEnd, bondPlan, rangeTiles, effDps, rangeRec, buyScoreOf } from '../../server/match/bot.js';
|
||||
import { parseKey, tileKey } from '../../server/match/board.js';
|
||||
import { makeMatch, checkInvariants, give, giveItem, legalTileFor, DATA } from './harness.js';
|
||||
|
||||
const soloBot = (o = {}) => makeMatch({ mode: 'solo', difficulty: 'NORMAL', seats: [{ seat: 0, playerId: 'ai_0', name: 'AI', isBot: true, connected: true }], ...o });
|
||||
|
||||
test('user: Given a faction strategy teammate, When scoring shared high-tier chess, Then humans and bots receive the same preference even with full stock', () => {
|
||||
const { m } = makeMatch({ humans: 3, seed: 11 }).start().toPrep();
|
||||
const [ps, mate, other] = m.order;
|
||||
try {
|
||||
mate.bandId = other.bandId = null;
|
||||
ps.shop.level = 6;
|
||||
// 贾维 names 叙拉古; 铃兰 (V) and 荒芜拉普兰德 (VI) both belong to it.
|
||||
for (const id of ['chess_char_5_10_a', 'chess_char_6_18_a']) {
|
||||
mate.isBot = false;
|
||||
const ordinary = buyScoreOf(m, ps, id);
|
||||
mate.bandId = 'band_chiave';
|
||||
const cooperative = buyScoreOf(m, ps, id);
|
||||
assert.ok(cooperative < ordinary, `${m.gd.chess(id).name}: leave faction stock for the strategy`);
|
||||
mate.isBot = true;
|
||||
assert.equal(buyScoreOf(m, ps, id), cooperative, 'bot teammates count equally');
|
||||
other.bandId = 'band_chiave';
|
||||
assert.equal(buyScoreOf(m, ps, id), cooperative, 'multiple teammates do not multiply the penalty');
|
||||
mate.alive = other.alive = false;
|
||||
assert.equal(buyScoreOf(m, ps, id), ordinary, 'eliminated players no longer need cards');
|
||||
mate.alive = other.alive = true;
|
||||
other.bandId = null;
|
||||
m.gd.modeInactiveBonds.add('siracusaShip');
|
||||
const inactive = buyScoreOf(m, ps, id);
|
||||
mate.bandId = null;
|
||||
assert.equal(buyScoreOf(m, ps, id), inactive, 'inactive faction does not trigger yielding');
|
||||
m.gd.modeInactiveBonds.delete('siracusaShip');
|
||||
}
|
||||
const unaffected = Object.values(DATA.chess).filter((c) => c.visible && !c.isGolden &&
|
||||
(c.tier < 5 || !c.bonds.includes('siracusaShip')));
|
||||
for (const c of unaffected) {
|
||||
mate.bandId = null;
|
||||
const ordinary = buyScoreOf(m, ps, c.chessId);
|
||||
mate.bandId = 'band_chiave';
|
||||
assert.equal(buyScoreOf(m, ps, c.chessId), ordinary, c.name);
|
||||
}
|
||||
} finally { m.dispose(); }
|
||||
});
|
||||
|
||||
test('user: Given a teammate faction strategy, When buying or taking a reward, Then prefer an alternative but retain an immediate own merge', () => {
|
||||
const yu = 'chess_char_6_03_a';
|
||||
const nearl = 'chess_char_6_17_a';
|
||||
for (const { reward = false, ownPair = false, band = 'band_duyaoy', expected } of [
|
||||
{ expected: nearl },
|
||||
{ reward: true, expected: nearl },
|
||||
{ ownPair: true, expected: yu },
|
||||
{ band: null, expected: yu },
|
||||
]) {
|
||||
const { m } = makeMatch({ humans: 2, seed: 11 }).start().toPrep();
|
||||
const [ps, mate] = m.order;
|
||||
try {
|
||||
mate.bandId = band;
|
||||
if (ownPair) for (let i = 0; i < 2; i++) give(m, ps, yu, 'hand');
|
||||
ps.shop.level = 6;
|
||||
ps.funds = reward ? 0 : 5;
|
||||
ps.shop.slots = reward ? [] : [yu, nearl].map((id) => ({ kind: 'chess', id, basePrice: 5, sold: false }));
|
||||
if (reward) ps.pushRewardOffer('merge', { tier: 6, ids: [yu, nearl] });
|
||||
botPrepBegin(m, ps);
|
||||
assert.equal(reward ? ps.allChess()[0]?.id : ps.shop.slots.find((s) => s.sold)?.id, expected);
|
||||
} finally { m.dispose(); }
|
||||
}
|
||||
});
|
||||
|
||||
/** A 悬赏决策 card as the draft builds it (choices.js buildCards), by its enemy's name. */
|
||||
function bountyCard(enemyName) {
|
||||
const c = DATA.choices.cards.bounty.find((x) => x.draft && DATA.enemies[x.enemyKey] && DATA.enemies[x.enemyKey].name === enemyName);
|
||||
|
||||
@@ -24,6 +24,8 @@ function randomIntent(rng, m, ps) {
|
||||
const uid = () => (pieces.length && rng() < 0.8 ? rng.pick(pieces) : 1 + rng.int(5000));
|
||||
const to = () => (rng() < 0.6 ? { area: 'board', row: rng() < 0.8 ? 9 + rng.int(4) : rng.int(19), col: rng() < 0.8 ? 2 + rng.int(9) : rng.int(21) } : { area: 'hand', idx: rng.int(10) });
|
||||
switch (t) {
|
||||
case 'g.infoReady': return { t, setupRevision: m.setupRevision ?? 0 };
|
||||
case 'g.rerollVote': return { t, voteId: m.setupVote?.id ?? 1 + rng.int(10), agree: rng() < 0.5 };
|
||||
case 'g.band': return { t, bandId: rng() < 0.8 ? rng.pick(BANDS) : rng.pick(['constructor', '__proto__', 'band_x', 'a'.repeat(64)]) };
|
||||
case 'g.buy': return { t, slot: rng.int(16) };
|
||||
case 'g.sell': case 'g.destroy': return { t, uid: uid() };
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { startServer } from '../../server/index.js';
|
||||
import { TestClient } from '../helpers/wsClient.js';
|
||||
import { ERR } from '../../shared/constants.js';
|
||||
|
||||
const ok = async (c, msg) => {
|
||||
const r = await c.request(msg);
|
||||
assert.equal(r.t, 'ok', JSON.stringify(r));
|
||||
};
|
||||
const err = async (c, msg, code) => {
|
||||
const r = await c.request(msg);
|
||||
assert.equal(r.t, 'error', JSON.stringify(r));
|
||||
assert.equal(r.code, code, JSON.stringify(r));
|
||||
};
|
||||
const setupOf = (pub) => ({ stageId: pub.stageId, bossId: pub.bossId, hiddenBossId: pub.hiddenBossId,
|
||||
factions: pub.factions, disabledBonds: pub.disabledBonds, bannedChess: pub.bannedChess, setupRevision: pub.setupRevision });
|
||||
|
||||
test('real four-player sockets: host-only proposal, unanimous sync, reconnect and stale requests', { timeout: 15000 }, async (t) => {
|
||||
const errors = [];
|
||||
const srv = await startServer({ port: 0, host: '127.0.0.1', seedFn: () => 123,
|
||||
log: { info() {}, warn() {}, debug() {}, error: (...args) => errors.push(args.map(String).join(' ')) } });
|
||||
const clients = [];
|
||||
t.after(async () => {
|
||||
await Promise.all(clients.map((c) => c.terminate()));
|
||||
await srv.close();
|
||||
assert.deepEqual(errors, []);
|
||||
});
|
||||
const player = async (name, token) => {
|
||||
const c = await TestClient.connect(`ws://127.0.0.1:${srv.port}/ws`);
|
||||
clients.push(c);
|
||||
const w = await c.hello(name, token);
|
||||
c.id = w.playerId;
|
||||
c.token = w.token;
|
||||
return c;
|
||||
};
|
||||
const host = await player('Host');
|
||||
await ok(host, { t: 'room.create', mode: 'coop', difficulty: 'HARD' });
|
||||
const room = await host.waitFor('room.state');
|
||||
const guests = [];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const c = await player(`Guest${i}`);
|
||||
guests.push(c);
|
||||
await ok(c, { t: 'room.join', code: room.code });
|
||||
await ok(c, { t: 'room.ready', ready: true });
|
||||
}
|
||||
const spectator = await player('Viewer');
|
||||
await ok(spectator, { t: 'room.spectate', code: room.code });
|
||||
await ok(host, { t: 'room.start' });
|
||||
const initial = await host.waitFor('m.public', (p) => p.phase === 'INFO_CHECK');
|
||||
const m = srv.lobby.rooms.get(room.code).match;
|
||||
await err(guests[0], { t: 'room.rerollSetup', setupRevision: 0 }, ERR.NOT_HOST);
|
||||
await err(spectator, { t: 'room.rerollSetup', setupRevision: 0 }, ERR.SPECTATOR);
|
||||
await ok(guests[0], { t: 'g.infoReady', setupRevision: 0 });
|
||||
await ok(host, { t: 'room.rerollSetup', setupRevision: 0 });
|
||||
const voting = await host.waitFor('m.public', (p) => !!p.rerollVote);
|
||||
assert.equal(voting.deadline, 0);
|
||||
assert.equal(voting.rerollVote.voters.length, 4);
|
||||
assert.deepEqual(voting.rerollVote.agreed, [host.id]);
|
||||
const voteId = voting.rerollVote.id;
|
||||
await err(spectator, { t: 'g.rerollVote', voteId, agree: true }, ERR.SPECTATOR);
|
||||
await err(guests[0], { t: 'room.cancelReroll', voteId }, ERR.NOT_HOST);
|
||||
await ok(guests[0], { t: 'g.rerollVote', voteId, agree: true });
|
||||
await ok(guests[1], { t: 'g.rerollVote', voteId, agree: true });
|
||||
assert.equal(m.setupRevision, 0, '3/4 agrees never refreshes');
|
||||
await ok(guests[2], { t: 'g.rerollVote', voteId, agree: true });
|
||||
const all = [host, ...guests, spectator];
|
||||
const fresh = await Promise.all(all.map((c) => c.waitFor('m.public', (p) => p.setupRevision === 1)));
|
||||
for (const p of fresh) {
|
||||
assert.deepEqual(setupOf(p), setupOf(fresh[0]));
|
||||
assert.equal(p.deadline, fresh[0].deadline, 'all players share the same new deadline');
|
||||
assert.equal(Math.ceil((p.deadline - p.serverNow) / 1000), 25, 'each player sees a fresh 25-second countdown');
|
||||
assert.equal(p.rerollVote, null);
|
||||
assert.ok(p.players.every((ps) => !ps.ready));
|
||||
}
|
||||
assert.notDeepEqual(setupOf(fresh[0]), { ...setupOf(initial), setupRevision: 1 });
|
||||
assert.equal(srv.lobby.rooms.get(room.code).match, m, 'room/match instance and sockets retained');
|
||||
assert.ok(all.every((c) => c.isOpen));
|
||||
await err(guests[0], { t: 'g.infoReady', setupRevision: 0 }, ERR.BAD_TARGET);
|
||||
await err(guests[0], { t: 'g.infoReady' }, ERR.BAD_TARGET);
|
||||
await err(host, { t: 'room.rerollSetup', setupRevision: 0 }, ERR.BAD_TARGET);
|
||||
// Disconnect/reconnect retains this room and returns the current full setup.
|
||||
const guest = guests[2];
|
||||
await guest.terminate();
|
||||
await host.waitFor('m.public', (p) => p.setupRevision === 1 && p.players.some((ps) => ps.playerId === guest.id && !ps.connected));
|
||||
const resumed = await player('Guest2', guest.token);
|
||||
assert.equal(resumed.id, guest.id);
|
||||
assert.deepEqual(setupOf(await resumed.waitFor('m.public', (p) => p.setupRevision === 1)), setupOf(fresh[0]));
|
||||
const restoredRoom = await resumed.waitFor('room.state');
|
||||
assert.equal(restoredRoom.code, room.code);
|
||||
for (const c of [host, guests[0], guests[1], resumed]) await ok(c, { t: 'g.infoReady', setupRevision: 1 });
|
||||
await host.waitFor('m.public', (p) => p.phase === 'BAND_DRAFT');
|
||||
await err(host, { t: 'room.rerollSetup', setupRevision: 1 }, ERR.WRONG_PHASE);
|
||||
});
|
||||
|
||||
test('real sockets: reject/cancel/disconnect keep the setup and resume the countdown', { timeout: 15000 }, async (t) => {
|
||||
const srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
|
||||
const clients = [];
|
||||
t.after(async () => { await Promise.all(clients.map((c) => c.terminate())); await srv.close(); });
|
||||
for (const name of ['Host', 'Guest']) {
|
||||
const c = await TestClient.connect(`ws://127.0.0.1:${srv.port}/ws`);
|
||||
clients.push(c);
|
||||
await c.hello(name);
|
||||
}
|
||||
const [host, guest] = clients;
|
||||
await ok(host, { t: 'room.create', mode: 'coop', difficulty: 'NORMAL' });
|
||||
const room = await host.waitFor('room.state');
|
||||
await ok(guest, { t: 'room.join', code: room.code });
|
||||
await ok(guest, { t: 'room.ready', ready: true });
|
||||
await ok(host, { t: 'room.start' });
|
||||
const initial = await host.waitFor('m.public', (p) => p.phase === 'INFO_CHECK');
|
||||
const m = srv.lobby.rooms.get(room.code).match;
|
||||
for (const finish of ['reject', 'cancel', 'disconnect']) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 310));
|
||||
await ok(host, { t: 'room.rerollSetup', setupRevision: 0 });
|
||||
const voting = await host.waitFor('m.public', (p) => !!p.rerollVote);
|
||||
if (finish === 'reject') await ok(guest, { t: 'g.rerollVote', voteId: voting.rerollVote.id, agree: false });
|
||||
if (finish === 'cancel') await ok(host, { t: 'room.cancelReroll', voteId: voting.rerollVote.id });
|
||||
if (finish === 'disconnect') await guest.terminate();
|
||||
await host.waitFor('m.public', (p) => !p.rerollVote && p.deadline > 0);
|
||||
assert.equal(m.setupRevision, 0);
|
||||
assert.deepEqual(setupOf(m.publicView()), setupOf(initial));
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,233 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { makeMatch } from './harness.js';
|
||||
import { ERR, PHASE } from '../../shared/constants.js';
|
||||
import { validateC2S } from '../../shared/protocol.js';
|
||||
import { createRng, deriveSeed } from '../../server/sim/rng.js';
|
||||
import { setupMatchWaves } from '../../server/match/waves.js';
|
||||
import { drawDisabledBonds } from '../../server/match/pool.js';
|
||||
|
||||
const snapshot = (m) => ({ stageId: m.stageId, bossId: m.bossId, hiddenBossId: m.hiddenBossId,
|
||||
factions: [...m.factions], schedule: m.factions.schedule, bans: [...m.bannedChess], drawn: [...m.disabledBonds] });
|
||||
const approve = (m, id, voteId = m.setupVote.id) => m.handle(id, { t: 'g.rerollVote', voteId, agree: true });
|
||||
|
||||
test('reroll with one second left gives everyone a fresh full countdown; old timeout cannot advance it', (t) => {
|
||||
const h = makeMatch({ humans: 2 }).start();
|
||||
t.after(() => h.m.dispose());
|
||||
const m = h.m;
|
||||
const fullMs = m.scaled(m.gd.timer('infoCheck') * 1000);
|
||||
h.sched.advance(fullMs - 1000);
|
||||
assert.equal(m.deadline - h.sched.now(), 1000);
|
||||
assert.deepEqual(m.requestSetupReroll('p_0', 0), { ok: true });
|
||||
h.sched.advance(5000);
|
||||
assert.deepEqual(approve(m, 'p_1'), { ok: true });
|
||||
const fresh = h.lastBc('m.public');
|
||||
assert.equal(fresh.deadline - fresh.serverNow, fullMs);
|
||||
assert.ok(fresh.players.every((p) => !p.ready));
|
||||
h.sched.advance(fullMs - 1);
|
||||
assert.equal(m.phase, PHASE.INFO_CHECK);
|
||||
h.sched.advance(1);
|
||||
assert.equal(m.phase, PHASE.BAND_DRAFT);
|
||||
});
|
||||
|
||||
test('protocol requires setup revisions and vote identities; old initial ready remains supported', () => {
|
||||
assert.equal(validateC2S({ t: 'room.rerollSetup', setupRevision: 0 }), null);
|
||||
assert.equal(validateC2S({ t: 'room.cancelReroll', voteId: 1 }), null);
|
||||
assert.equal(validateC2S({ t: 'g.rerollVote', voteId: 1, agree: false }), null);
|
||||
assert.equal(validateC2S({ t: 'g.infoReady' }), null);
|
||||
assert.equal(validateC2S({ t: 'g.infoReady', setupRevision: 2 }), null);
|
||||
for (const msg of [{ t: 'room.rerollSetup' }, { t: 'room.rerollSetup', setupRevision: -1 },
|
||||
{ t: 'room.rerollSetup', setupRevision: 0.5 }, { t: 'g.rerollVote', voteId: 0, agree: true },
|
||||
{ t: 'g.rerollVote', voteId: 1 }, { t: 'g.rerollVote', voteId: 1, agree: 'yes' },
|
||||
{ t: 'room.cancelReroll' }, { t: 'g.infoReady', setupRevision: -1 }]) {
|
||||
assert.notEqual(validateC2S(msg), null, JSON.stringify(msg));
|
||||
}
|
||||
});
|
||||
|
||||
test('four humans must agree; rerolls the entire official setup and stock, preserves seats/settings/RNG', (t) => {
|
||||
const h = makeMatch({ humans: 4, difficulty: 'HARD', seed: 123, spectators: ['viewer'] }).start();
|
||||
t.after(() => h.m.dispose());
|
||||
const m = h.m;
|
||||
const before = snapshot(m);
|
||||
const pool = m.pool;
|
||||
const players = [...m.order];
|
||||
const rngs = [m.rngSetup, m.rngShop, m.rngDraft, m.rngWaves, m.rngBots, m.rngMeta].map((r) => [r, r.state()]);
|
||||
const p0 = h.ps('p_0');
|
||||
assert.equal(p0.setDiy({ chess_char_5_diy1_a: { charId: 'char_017_huang', skillIndex: 0 } }), true);
|
||||
p0.initDiyStock(new Set([...m.disabledBonds, ...m.staticInactiveBonds]));
|
||||
const settings = [p0.loadout, p0.standIns, p0.diy];
|
||||
const stock = p0.diyStock;
|
||||
m.handle('p_1', { t: 'g.infoReady' });
|
||||
h.sched.advance(500);
|
||||
assert.deepEqual(m.requestSetupReroll('p_0', 0), { ok: true });
|
||||
assert.deepEqual(m.setupVote.voters, ['p_0', 'p_1', 'p_2', 'p_3']);
|
||||
assert.equal(m.deadline, 0);
|
||||
assert.deepEqual(approve(m, 'p_1'), { ok: true });
|
||||
assert.deepEqual(approve(m, 'p_2'), { ok: true });
|
||||
h.sched.advance(30000);
|
||||
assert.equal(m.phase, PHASE.INFO_CHECK);
|
||||
assert.deepEqual(snapshot(m), before, 'nothing rerolled until everyone agrees');
|
||||
assert.equal(m.handle('viewer', { t: 'g.rerollVote', voteId: m.setupVote.id, agree: true }).error, ERR.SPECTATOR);
|
||||
assert.deepEqual(approve(m, 'p_3'), { ok: true });
|
||||
assert.equal(m.setupRevision, 1);
|
||||
assert.equal(m.setupVote, null);
|
||||
const rng = createRng(deriveSeed(m.seed, 'reroll-setup:1'));
|
||||
const setup = setupMatchWaves(m.gd, rng);
|
||||
const bans = drawDisabledBonds(m.gd, rng);
|
||||
assert.deepEqual(snapshot(m), { stageId: setup.stageId, bossId: setup.bossId, hiddenBossId: setup.hiddenBossId,
|
||||
factions: [...setup.factions], schedule: setup.factions.schedule, bans: bans.banned, drawn: bans.drawn });
|
||||
assert.notDeepEqual(snapshot(m), before);
|
||||
assert.equal(m.stage, m.gd.stage(m.stageId));
|
||||
assert.notEqual(m.pool, pool);
|
||||
assert.deepEqual(m.order, players);
|
||||
assert.deepEqual([p0.loadout, p0.standIns, p0.diy], settings);
|
||||
assert.equal(p0.diy, settings[2]);
|
||||
assert.notEqual(p0.diyStock, stock);
|
||||
const off = new Set([...bans.drawn, ...bans.staticOff]);
|
||||
const diy = p0.gd.chess('chess_char_5_diy1_a');
|
||||
assert.equal(p0.diyBanned.includes(diy.chessId), diy.bonds.every((b) => off.has(b)));
|
||||
assert.equal(p0.diyStock.has(diy.chessId), !p0.diyBanned.includes(diy.chessId));
|
||||
for (const [r, state] of rngs) assert.equal(r.state(), state);
|
||||
for (const id of m.gd.visibleChess) assert.equal(m.pool.has(id), !bans.banned.includes(id));
|
||||
assert.ok(m.order.every((p) => !p.infoReady));
|
||||
assert.equal(m.deadline, h.sched.now() + m.scaled(m.gd.timer('infoCheck') * 1000));
|
||||
assert.equal(h.lastBc('m.public').setupRevision, 1);
|
||||
h.invariants();
|
||||
for (const p of m.order) assert.deepEqual(m.handle(p.playerId, { t: 'g.infoReady', setupRevision: 1 }), { ok: true });
|
||||
h.sched.advance(0);
|
||||
assert.equal(m.phase, PHASE.BAND_DRAFT);
|
||||
});
|
||||
|
||||
test('reject/cancel restores exact remaining countdown and keeps setup and ready flags', (t) => {
|
||||
const h = makeMatch({ humans: 2, timerScale: 0.5 }).start();
|
||||
t.after(() => h.m.dispose());
|
||||
const m = h.m;
|
||||
const before = snapshot(m);
|
||||
m.handle('p_1', { t: 'g.infoReady' });
|
||||
h.sched.advance(1000);
|
||||
const remaining = m.deadline - h.sched.now();
|
||||
m.requestSetupReroll('p_0', 0);
|
||||
h.sched.advance(10000);
|
||||
assert.deepEqual(m.handle('p_1', { t: 'g.rerollVote', voteId: m.setupVote.id, agree: false }), { ok: true });
|
||||
assert.deepEqual(snapshot(m), before);
|
||||
assert.equal(m.setupRevision, 0);
|
||||
assert.equal(h.ps('p_1').infoReady, true);
|
||||
assert.equal(m.deadline, h.sched.now() + remaining, 'real remaining ms, no extra timer scaling');
|
||||
m.requestSetupReroll('p_0', 0);
|
||||
assert.deepEqual(m.cancelSetupReroll('p_0', m.setupVote.id), { ok: true });
|
||||
assert.equal(m.deadline, h.sched.now() + remaining);
|
||||
h.sched.advance(remaining - 1);
|
||||
assert.equal(m.phase, PHASE.INFO_CHECK);
|
||||
h.sched.advance(1);
|
||||
assert.equal(m.phase, PHASE.BAND_DRAFT);
|
||||
});
|
||||
|
||||
test('reroll cancels queued all-ready transition; stale ready and previous vote cannot affect a new setup', (t) => {
|
||||
const h = makeMatch({ humans: 2 }).start();
|
||||
t.after(() => h.m.dispose());
|
||||
const m = h.m;
|
||||
for (const p of m.order) m.handle(p.playerId, { t: 'g.infoReady' });
|
||||
assert.ok(m._infoAdvanceTimer);
|
||||
m.requestSetupReroll('p_0', 0);
|
||||
const oldVote = m.setupVote.id;
|
||||
assert.equal(m.handle('p_1', { t: 'g.infoReady' }).error, ERR.WRONG_PHASE);
|
||||
approve(m, 'p_1');
|
||||
h.sched.advance(0);
|
||||
assert.equal(m.phase, PHASE.INFO_CHECK);
|
||||
for (const msg of [{ t: 'g.infoReady' }, { t: 'g.infoReady', setupRevision: 0 }]) {
|
||||
assert.equal(m.handle('p_1', msg).error, ERR.BAD_TARGET);
|
||||
}
|
||||
assert.equal(m.requestSetupReroll('p_0', 0).error, ERR.BAD_TARGET);
|
||||
assert.equal(m.requestSetupReroll('p_0', 1).error, ERR.RATE);
|
||||
h.sched.advance(300);
|
||||
assert.deepEqual(m.requestSetupReroll('p_0', 1), { ok: true });
|
||||
assert.equal(approve(m, 'p_1', oldVote).error, ERR.BAD_TARGET);
|
||||
assert.equal(m.cancelSetupReroll('p_0', oldVote).error, ERR.BAD_TARGET);
|
||||
assert.equal(m.setupVote.agreed.size, 1);
|
||||
});
|
||||
|
||||
test('solo and one human with bots refresh immediately without a forced timer; fixed rules remain fixed', (t) => {
|
||||
for (const mode of ['solo', 'coop']) {
|
||||
const h = makeMatch({ mode, humans: 1, bots: 2, difficulty: 'FUNNY' }).start();
|
||||
t.after(() => h.m.dispose());
|
||||
const m = h.m;
|
||||
const stage = m.stageId;
|
||||
const staticOff = [...m.staticInactiveBonds];
|
||||
assert.deepEqual(m.requestSetupReroll('p_0', 0), { ok: true });
|
||||
assert.equal(m.stageId, stage);
|
||||
assert.deepEqual(m.staticInactiveBonds, staticOff);
|
||||
assert.equal(m.deadline, 0);
|
||||
assert.equal(m.setupRevision, 1);
|
||||
assert.equal(m.setupVote, null);
|
||||
assert.equal(h.ps('p_0').infoReady, false);
|
||||
assert.ok(m.order.filter((p) => p.isBot).every((p) => p.infoReady));
|
||||
m.handle('p_0', { t: 'g.infoReady', setupRevision: 1 });
|
||||
h.sched.advance(0);
|
||||
assert.equal(m.phase, PHASE.BAND_DRAFT);
|
||||
assert.equal(m.requestSetupReroll('p_0', 1).error, ERR.WRONG_PHASE);
|
||||
}
|
||||
});
|
||||
|
||||
test('disconnect and leave cancel rather than count as consent; reconnect sees current vote/setup', (t) => {
|
||||
const h = makeMatch({ humans: 3 }).start();
|
||||
t.after(() => h.m.dispose());
|
||||
const m = h.m;
|
||||
const before = snapshot(m);
|
||||
m.requestSetupReroll('p_0', 0);
|
||||
const id = m.setupVote.id;
|
||||
m.onReconnect('p_1');
|
||||
assert.equal(h.lastTo('p_1', 'm.public').rerollVote.id, id);
|
||||
m.onDisconnect('p_1');
|
||||
assert.equal(m.setupVote, null);
|
||||
assert.deepEqual(snapshot(m), before);
|
||||
h.sched.advance(300);
|
||||
assert.equal(m.requestSetupReroll('p_0', 0).error, ERR.NOT_READY);
|
||||
m.onReconnect('p_1');
|
||||
assert.equal(h.lastTo('p_1', 'm.public').rerollVote, null);
|
||||
m.requestSetupReroll('p_0', 0);
|
||||
m.onLeave('p_1');
|
||||
assert.equal(m.setupVote, null);
|
||||
assert.equal(m.setupRevision, 0);
|
||||
h.sched.advance(300);
|
||||
m.requestSetupReroll('p_0', 0);
|
||||
assert.deepEqual(m.setupVote.voters, ['p_0', 'p_2']);
|
||||
approve(m, 'p_2');
|
||||
assert.equal(m.setupRevision, 1);
|
||||
});
|
||||
|
||||
test('autoplay humans still explicitly consent; dispose stops a paused vote and invalid actors are refused', (t) => {
|
||||
const h = makeMatch({ humans: 2, bots: 1 }).start();
|
||||
t.after(() => h.m.dispose());
|
||||
const m = h.m;
|
||||
m.handle('p_1', { t: 'g.autoplay', on: true });
|
||||
assert.equal(m.requestSetupReroll('ai_0', 0).error, ERR.NOT_IN_ROOM);
|
||||
assert.equal(m.requestSetupReroll('stranger', 0).error, ERR.NOT_IN_ROOM);
|
||||
m.requestSetupReroll('p_0', 0);
|
||||
assert.equal(m.setupVote.agreed.size, 1);
|
||||
h.sched.advance(30000);
|
||||
assert.equal(m.setupRevision, 0);
|
||||
assert.equal(m.phase, PHASE.INFO_CHECK);
|
||||
m.dispose();
|
||||
assert.equal(m.requestSetupReroll('p_0', 0).error, ERR.WRONG_PHASE);
|
||||
h.sched.advance(30000);
|
||||
assert.equal(m.setupRevision, 0);
|
||||
});
|
||||
|
||||
test('failed DIY stock preparation leaves every opening field and player stock intact', (t) => {
|
||||
const h = makeMatch({ humans: 2, difficulty: 'HARD' }).start();
|
||||
t.after(() => h.m.dispose());
|
||||
const m = h.m, before = snapshot(m), pool = m.pool, stage = m.stage;
|
||||
const stocks = m.order.map((ps) => ps.diyStock), banned = m.order.map((ps) => ps.diyBanned);
|
||||
h.sched.advance(1000);
|
||||
const remaining = m.deadline - h.sched.now();
|
||||
m.requestSetupReroll('p_0', 0);
|
||||
const original = h.ps('p_1').buildDiyStock;
|
||||
h.ps('p_1').buildDiyStock = () => { throw Error('stock preparation failed'); };
|
||||
assert.equal(approve(m, 'p_1').error, ERR.INTERNAL);
|
||||
h.ps('p_1').buildDiyStock = original;
|
||||
assert.deepEqual(snapshot(m), before); assert.equal(m.pool, pool); assert.equal(m.stage, stage);
|
||||
for (const [i, ps] of m.order.entries()) { assert.equal(ps.diyStock, stocks[i]); assert.equal(ps.diyBanned, banned[i]); }
|
||||
assert.equal(m.setupRevision, 0); assert.equal(m.setupVote, null);
|
||||
assert.equal(m.deadline - h.sched.now(), remaining);
|
||||
assert.equal(h.logs.error.length, 1, 'failure is reported once');
|
||||
});
|
||||
@@ -50,7 +50,7 @@ const M = {
|
||||
audio: {
|
||||
bgm: { prep: { loop: '/bgm/prep.mp3' }, lobby: { intro: '/bgm/i.mp3', loop: '/bgm/l.mp3' } },
|
||||
bossBgm: { boss_1: { loop: '/bgm/b1.mp3' } },
|
||||
sfx: { ui: { buy: '/sfx/buy.mp3' }, battle: { deploy: '/sfx/dep.mp3' }, units: { char_010_chen: { attack: '/sfx/c_atk.mp3', skills: { 2: '/sfx/c_s3.mp3' }, skill: '/sfx/c_s.mp3' } } },
|
||||
sfx: { ui: { buy: '/sfx/buy.mp3' }, battle: { deploy: '/sfx/dep.mp3' }, units: { char_010_chen: { attack: '/sfx/c_atk.mp3', hit: '/sfx/c_hit.mp3', skills: { 2: '/sfx/c_s3.mp3' }, skill: '/sfx/c_s.mp3', attacks: { 2: '/sfx/c_atk_s3.mp3' }, hits: { 2: '/sfx/c_hit_s3.mp3' } } } },
|
||||
},
|
||||
};
|
||||
|
||||
@@ -134,6 +134,12 @@ describe('URL helpers (synthetic manifest)', () => {
|
||||
assert.equal(unitSfxUrl(M, 'char_010_chen', 'skill', 2), '/sfx/c_s3.mp3');
|
||||
assert.equal(unitSfxUrl(M, 'char_010_chen', 'skill', 0), '/sfx/c_s.mp3');
|
||||
assert.equal(unitSfxUrl(M, 'char_010_chen_2', 'attack'), '/sfx/c_atk.mp3');
|
||||
// the running skill's own attack sound (sfx.units[id].attacks, tools/assets/audio.mjs pickModeAttacks)
|
||||
assert.equal(unitSfxUrl(M, 'char_010_chen', 'attack', 2), '/sfx/c_atk_s3.mp3');
|
||||
assert.equal(unitSfxUrl(M, 'char_010_chen', 'attack', 0), '/sfx/c_atk.mp3', 'no attacks[0] ⇒ the normal bank');
|
||||
assert.equal(unitSfxUrl(M, 'char_010_chen', 'hit', 2), '/sfx/c_hit_s3.mp3', 'the running skill\'s own impact');
|
||||
assert.equal(unitSfxUrl(M, 'char_010_chen', 'hit', 1), '/sfx/c_hit.mp3', 'no hits[1] ⇒ the normal impact');
|
||||
assert.equal(unitSfxUrl(M, 'char_010_chen', 'die', 2), null, 'only an attack / an impact has a per-skill file');
|
||||
assert.equal(unitSfxUrl(M, 'nobody', 'attack'), null);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { test, before, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { installFakePixi, fakeViewCtx } from './fakepixi.js';
|
||||
import { presetCamera } from '../../public/js/render/projection.js';
|
||||
let fake, UnitView;
|
||||
before(async () => { fake = installFakePixi(); ({ UnitView } = await import('../../public/js/render/units.js')); });
|
||||
after(() => fake.restore());
|
||||
function rig(rate = 1) {
|
||||
let resolve;
|
||||
const entry = { skel: '/op.skel', atlas: '/op.atlas', textures: ['/op.png'], anims: { idle: 'Idle', deploy: 'Start', die: 'Die', attack: { loop: 'Attack' } }, animations: { Idle: 1, Start: 1, Die: 1, Attack: 1 } };
|
||||
const assets = { spineEntry: () => entry, hasBack: () => false, spine: { acquire: () => new Promise((r) => { resolve = r; }), release() {} } };
|
||||
const cam = presetCamera('prep', { width: 1280, height: 720 });
|
||||
const ctx = fakeViewCtx(fake.P, { assets, cam: () => cam }); ctx.animRate = () => rate;
|
||||
const v = new UnitView(ctx, { id: 1, side: 'ally', kind: 'chess', defId: 'char_op', x: 5, y: 12, maxHp: 1000, dir: 'RIGHT' }, { prep: true });
|
||||
return { v, advance: (dt) => v.update(dt, cam, dt), ready: async () => { await new Promise((r) => setImmediate(r)); resolve({ animations: [{ name: 'Idle' }, { name: 'Start' }, { name: 'Die' }, { name: 'Attack' }] }); await new Promise((r) => setImmediate(r)); } };
|
||||
}
|
||||
test('late model continues the current deployment at elapsed game time', async () => {
|
||||
const h = rig(2); h.v.onDeploy(); h.advance(0.1); await h.ready();
|
||||
assert.equal(h.v.actor.mode, 'deploy'); assert.ok(Math.abs(h.v.actor.deployElapsed() - 0.2) < 1e-6); h.v.destroy();
|
||||
});
|
||||
test('model arriving after Start duration does not replay the old entrance', async () => {
|
||||
const h = rig(); h.v.onDeploy(); h.advance(2); await h.ready();
|
||||
assert.equal(h.v.actor.mode, 'base'); assert.equal(h.v._pendingDeployElapsed, null); h.v.destroy();
|
||||
});
|
||||
test('an attack or death supersedes the pending entrance', async () => {
|
||||
for (const action of ['attack', 'die']) {
|
||||
const h = rig(); h.v.onDeploy();
|
||||
if (action === 'attack') h.v.onAttack(null, 1, 'none'); else h.v.die();
|
||||
await h.ready(); assert.notEqual(h.v.actor.mode, 'deploy'); assert.equal(h.v._pendingDeployElapsed, null); h.v.destroy();
|
||||
}
|
||||
});
|
||||
test('a destroyed view never consumes a late model as a fresh entrance', async () => {
|
||||
const h = rig(); h.v.onDeploy(); h.v.destroy(); await h.ready(); assert.equal(h.v.actor, null);
|
||||
});
|
||||
+143
-1
@@ -367,7 +367,7 @@ describe('operator battle voice', () => {
|
||||
assert.equal(a.voiceLang, 'cn', 'no other dub: anything but jp is 中文');
|
||||
// the settings store hands the choice over (installAudio and ui/settings.js)
|
||||
const settings = readFileSync(path.join(ROOT, 'public/js/ui/settings.js'), 'utf8');
|
||||
assert.match(settings, /audio\.setVoiceLang\(s\.voiceLang\)/);
|
||||
assert.match(settings, /audio\.setVoiceLang\(s\.voiceLang, s\.voiceOverrides\)/);
|
||||
assert.match(settings, /t\('语音语言'\)/);
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
@@ -870,6 +870,148 @@ describe('impact sounds (user playtest #4 item 6)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// 司霆惊蛰's S3 attack had no sound: she is 解放者 (she only attacks while a skill runs) and the official client has no
|
||||
// normal-mode bank of hers at all — every attack of hers is a skill mode's (`attacks`, tools/assets/audio.mjs
|
||||
// pickModeAttacks). 赤刃明霄陈's S3 slashes are the other half of the same idea: the mode's IMPACT differs too (`hits`,
|
||||
// pickModeHits) — without it her S3 still landed with p_imp_hljdswd_n, i.e. it sounded like her normal attack. Both
|
||||
// tables play only while that skill is active (the ['skill', id, 1] / 0 events).
|
||||
describe('the running skill\'s own attack / impact sound (sfx.units[id].attacks / hits)', () => {
|
||||
const LEIZI2 = 'char_1043_leizi2';
|
||||
const CHEN3 = 'char_1050_chen3';
|
||||
const enemyId = () => Object.keys(manifest.audio.sfx.units).find((k) => k.startsWith('enemy_') && manifest.audio.sfx.units[k].attack);
|
||||
async function rig(units) {
|
||||
const fw = fakeWindow();
|
||||
const urls = [];
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = async (u) => { urls.push(u); return { ok: true, arrayBuffer: async () => new ArrayBuffer(8) }; };
|
||||
const a = new AudioManager({ win: fw.win, getManifest: () => manifest });
|
||||
a.install();
|
||||
fw.fire('pointerdown');
|
||||
a.setFieldUnits(units);
|
||||
const settle = () => new Promise((r) => setTimeout(r, 5));
|
||||
return { a, urls, settle, restore: () => { globalThis.fetch = origFetch; } };
|
||||
}
|
||||
const clear = (a) => { a.limiter.lastByUnit.clear(); a.limiter.lastByUrl.clear(); };
|
||||
// …and forget the decoded buffers too: these assertions count REQUESTS, and a sound the manager already holds is
|
||||
// played from the cache without a second fetch (a real battle hears it either way).
|
||||
const fresh = (a) => { clear(a); a.buffers.clear(); };
|
||||
/** One attack plus the impact that belongs to it (the dmg inside IMPACT_WINDOW_MS). */
|
||||
const swingAndHit = (a) => a.handleBattleEvents([['atk', 1, 2, 'none'], ['dmg', 2, 120, 'phys']]);
|
||||
|
||||
|
||||
test('司霆惊蛰 S3: her attack rings the S3 mode\'s file while the skill runs, and only then', async () => {
|
||||
const u = manifest.audio.sfx.units[LEIZI2];
|
||||
assert.equal(u.attack, undefined, '前提:她没有普通形态的攻击音(这正是那次「没有音效」)');
|
||||
assert.ok(u.attacks['2'] && /_s\.mp3$/.test(u.attacks['2']), '前提:清单带 S3 形态的攻击音');
|
||||
const { a, urls, settle, restore } = await rig([
|
||||
{ id: 1, side: 'ally', kind: 'chess', spine: LEIZI2, skillIndex: 2 }, { id: 2, side: 'enemy', kind: 'enemy', spine: enemyId() },
|
||||
]);
|
||||
try {
|
||||
// 技能没开(解放者本就不攻击,模拟里也不会发 atk;真发了也仍然是静的)
|
||||
a.handleBattleEvents([['atk', 1, 2, 'none']]);
|
||||
await settle();
|
||||
assert.equal(askedCount(urls, u.attacks['2']), 0, '没有技能就没有她的攻击音');
|
||||
// S3 开启 → ['skill', id, 1]
|
||||
a.handleBattleEvents([['skill', 1, 1]]);
|
||||
await settle();
|
||||
const before = askedCount(urls, u.attacks['2']);
|
||||
a.handleBattleEvents([['atk', 1, 2, 'none']]);
|
||||
await settle();
|
||||
assert.equal(askedCount(urls, u.attacks['2']), before + 1, 'S3 期间她的攻击响 S3 的形态音');
|
||||
assert.ok(!asked(urls, u.skill), '技能发动音是另一条事件(这次没发),别混进攻击音');
|
||||
// 重复的 atk 会被同键冷却挡(既有口径,这里确认它没被新分支绕过)
|
||||
a.handleBattleEvents([['atk', 1, 2, 'none']]);
|
||||
await settle();
|
||||
assert.equal(askedCount(urls, u.attacks['2']), before + 1, '同一单位 160 ms 内只响一次');
|
||||
// S3 结束 → ['skill', id, 0]
|
||||
clear(a);
|
||||
a.handleBattleEvents([['skill', 1, 0]]);
|
||||
a.handleBattleEvents([['atk', 1, 2, 'none']]);
|
||||
await settle();
|
||||
assert.equal(askedCount(urls, u.attacks['2']), before + 1, '技能结束后又静音');
|
||||
} finally { restore(); }
|
||||
});
|
||||
|
||||
test('a unit with both banks: the mode file inside the skill, its normal attack outside it', async () => {
|
||||
const id = Object.keys(manifest.audio.sfx.units).find((k) => k.startsWith('char_') && manifest.audio.sfx.units[k].attack && manifest.audio.sfx.units[k].attacks?.['2']);
|
||||
const u = manifest.audio.sfx.units[id];
|
||||
const { a, urls, settle, restore } = await rig([
|
||||
{ id: 1, side: 'ally', kind: 'chess', spine: id, skillIndex: 2 }, { id: 2, side: 'enemy', kind: 'enemy', spine: enemyId() },
|
||||
]);
|
||||
try {
|
||||
a.handleBattleEvents([['atk', 1, 2, 'none']]);
|
||||
await settle();
|
||||
assert.equal(askedCount(urls, u.attack), 1, `${id}: 技能外是普通攻击音`);
|
||||
assert.equal(askedCount(urls, u.attacks['2']), 0, `${id}: 技能外不响形态音`);
|
||||
clear(a);
|
||||
a.handleBattleEvents([['skill', 1, 1]]);
|
||||
a.handleBattleEvents([['atk', 1, 2, 'none']]);
|
||||
await settle();
|
||||
assert.equal(askedCount(urls, u.attacks['2']), 1, `${id}: 技能期间响形态音`);
|
||||
assert.equal(askedCount(urls, u.attack), 1, `${id}: 形态音取代、而不是叠加普通攻击音`);
|
||||
} finally { restore(); }
|
||||
});
|
||||
|
||||
test('a unit whose manifest has no attacks table for that slot keeps its normal bank (and no crash)', async () => {
|
||||
const id = Object.keys(manifest.audio.sfx.units).find((k) => k.startsWith('char_') && manifest.audio.sfx.units[k].attack && !manifest.audio.sfx.units[k].attacks?.['2']);
|
||||
const u = manifest.audio.sfx.units[id];
|
||||
const { a, urls, settle, restore } = await rig([
|
||||
{ id: 1, side: 'ally', kind: 'chess', spine: id, skillIndex: 2 }, { id: 2, side: 'enemy', kind: 'enemy', spine: enemyId() },
|
||||
]);
|
||||
try {
|
||||
a.handleBattleEvents([['skill', 1, 1]]);
|
||||
a.handleBattleEvents([['atk', 1, 2, 'none']]);
|
||||
await settle();
|
||||
assert.equal(askedCount(urls, u.attack), 1, `${id}: 没有形态音就用普通攻击音`);
|
||||
} finally { restore(); }
|
||||
});
|
||||
|
||||
test('赤刃明霄陈 S3: 挥砍与斩击都用 S3 的 `_s` 对,技能外回到普通 `_n` 对', async () => {
|
||||
const u = manifest.audio.sfx.units[CHEN3];
|
||||
assert.ok(u.attacks['2'] && u.hits['2'] && /_s\.mp3$/.test(u.attacks['2']) && /_s\.mp3$/.test(u.hits['2']), '前提:清单带 S3 的挥砍与斩击');
|
||||
const { a, urls, settle, restore } = await rig([
|
||||
{ id: 1, side: 'ally', kind: 'chess', spine: CHEN3, skillIndex: 2 }, { id: 2, side: 'enemy', kind: 'enemy', spine: enemyId() },
|
||||
]);
|
||||
try {
|
||||
fresh(a);
|
||||
swingAndHit(a);
|
||||
await settle();
|
||||
assert.equal(askedCount(urls, u.attack), 1, '技能外:普通挥砍');
|
||||
assert.equal(askedCount(urls, u.hit), 1, '技能外:普通命中');
|
||||
fresh(a);
|
||||
a.handleBattleEvents([['skill', 1, 1]]); // S3 开启
|
||||
swingAndHit(a);
|
||||
await settle();
|
||||
assert.equal(askedCount(urls, u.attacks['2']), 1, 'S3:挥砍换成 S3 的');
|
||||
assert.equal(askedCount(urls, u.hits['2']), 1, 'S3:斩击(命中)也换成 S3 的');
|
||||
assert.equal(askedCount(urls, u.attack), 1, 'S3:没有叠加普通挥砍');
|
||||
assert.equal(askedCount(urls, u.hit), 1, 'S3:没有叠加普通命中');
|
||||
fresh(a);
|
||||
a.handleBattleEvents([['skill', 1, 0]]); // S3 结束
|
||||
swingAndHit(a);
|
||||
await settle();
|
||||
assert.equal(askedCount(urls, u.attack), 2, '技能结束:普通挥砍回来了');
|
||||
assert.equal(askedCount(urls, u.hit), 2, '技能结束:普通命中回来了');
|
||||
assert.equal(askedCount(urls, u.hits['2']), 1, '技能结束:不再用 S3 的斩击');
|
||||
} finally { restore(); }
|
||||
});
|
||||
|
||||
test('a mode without a hits entry keeps the unit\'s normal impact while the skill runs', async () => {
|
||||
const u = manifest.audio.sfx.units[LEIZI2];
|
||||
assert.ok(u.attacks['2'] && !u.hits?.['2'], '前提:她的 S3 有挥砍、没有命中 bank(官方就没有)');
|
||||
const { a, urls, settle, restore } = await rig([
|
||||
{ id: 1, side: 'ally', kind: 'chess', spine: LEIZI2, skillIndex: 2 }, { id: 2, side: 'enemy', kind: 'enemy', spine: enemyId() },
|
||||
]);
|
||||
try {
|
||||
a.handleBattleEvents([['skill', 1, 1]]);
|
||||
swingAndHit(a);
|
||||
await settle();
|
||||
assert.equal(askedCount(urls, u.attacks['2']), 1, 'S3 的挥砍');
|
||||
assert.equal(askedCount(urls, u.hit), 1, '命中仍是普通的那条');
|
||||
} finally { restore(); }
|
||||
});
|
||||
});
|
||||
|
||||
// =====================================================================================================================
|
||||
// 漏怪 sound (user request "接下来加漏怪的音效", then "应该是原版明日方舟关卡中的怪进蓝门的音效"). The sim emits
|
||||
// `['leak', id]` when an enemy reaches its goal (Battle.leak) — NOT a `die` — so until now an escape was completely
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
// Real browser / server checks for local identity recovery, per-operator voice, install offers and large text.
|
||||
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);
|
||||
describe('0.2.3 follow-up client features', { skip: !ENABLED }, () => {
|
||||
let server, browser, base;
|
||||
before(async () => {
|
||||
const { startServer } = await import('../../server/index.js');
|
||||
const puppeteer = (await import('puppeteer-core')).default;
|
||||
server = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
|
||||
base = `http://127.0.0.1:${server.port}`;
|
||||
browser = await puppeteer.launch({ executablePath: CHROME, headless: true, args: ['--no-sandbox'] });
|
||||
});
|
||||
after(async () => { await browser?.close(); await server?.close(); });
|
||||
async function pageIn(ctx, phone = false) {
|
||||
const page = await ctx.newPage();
|
||||
await page.setViewport({ width: phone ? 844 : 1440, height: phone ? 390 : 900 });
|
||||
await page.evaluateOnNewDocument(() => { localStorage.setItem('sp.name', 'Recovery Test'); sessionStorage.setItem('sp.entered', '1'); });
|
||||
await page.goto(base, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => globalThis.__SP__?.store.get().connection.status === 'online' && document.querySelector('.lobby-screen'));
|
||||
return page;
|
||||
}
|
||||
test('closed second window resumes the same server player and match through the recovery UI', async () => {
|
||||
const ctx = await browser.createBrowserContext();
|
||||
try {
|
||||
const a = await pageIn(ctx), b = await pageIn(ctx);
|
||||
const before = await b.evaluate(async () => {
|
||||
const { net, store } = globalThis.__SP__;
|
||||
await net.request('room.create', { mode: 'solo', difficulty: 'NORMAL' });
|
||||
await net.request('room.start', {});
|
||||
return { id: store.get().me.playerId, code: store.get().room.code };
|
||||
});
|
||||
await b.waitForFunction(() => globalThis.__SP__.store.get().match.public?.phase === 'INFO_CHECK');
|
||||
await a.waitForSelector('[data-testid="resume-local-match"]');
|
||||
const refused = await a.evaluate(async () => { const { identity } = await import('/js/net.js'); return identity.resume(identity.recoverable()[0].id); });
|
||||
assert.equal(refused, false, 'live window is not displaced');
|
||||
assert.equal(await b.evaluate(() => globalThis.__SP__.store.get().connection.status), 'online');
|
||||
await b.close();
|
||||
await a.click('[data-testid="resume-local-match"]');
|
||||
await a.waitForSelector('.modal .btn--block');
|
||||
await Promise.all([a.waitForNavigation({ waitUntil: 'domcontentloaded' }), a.click('.modal .btn--block')]);
|
||||
await a.waitForFunction(() => globalThis.__SP__?.store.get().match.public?.phase === 'INFO_CHECK');
|
||||
const after = await a.evaluate(() => { const s = globalThis.__SP__.store.get(); return { id: s.me.playerId, code: s.room.code }; });
|
||||
assert.deepEqual(after, before);
|
||||
} finally { await ctx.close(); }
|
||||
});
|
||||
test('co-op opening reroll shows a unanimous vote, clears stale readiness and can be rejected', { timeout: 45000 }, async () => {
|
||||
const ctx = await browser.createBrowserContext();
|
||||
try {
|
||||
const host = await pageIn(ctx), guest = await pageIn(ctx);
|
||||
const code = await host.evaluate(async () => {
|
||||
const { net, store } = globalThis.__SP__;
|
||||
await net.request('room.create', { mode: 'coop', difficulty: 'NORMAL' });
|
||||
return store.get().room.code;
|
||||
});
|
||||
await guest.evaluate(async (code) => {
|
||||
const { net } = globalThis.__SP__;
|
||||
await net.request('room.join', { code }); await net.request('room.ready', { ready: true });
|
||||
}, code);
|
||||
await host.evaluate(() => globalThis.__SP__.net.request('room.start', {}));
|
||||
for (const p of [host, guest]) await p.waitForFunction(() => globalThis.__SP__.store.get().match.public?.phase === 'INFO_CHECK');
|
||||
await guest.evaluate(() => globalThis.__SP__.net.request('g.infoReady', { setupRevision: 0 }));
|
||||
await host.waitForSelector('.brief-reroll button');
|
||||
await host.bringToFront();
|
||||
await host.click('.brief-reroll button');
|
||||
await guest.waitForSelector('.brief-reroll .btn--primary');
|
||||
assert.equal(await guest.evaluate(() => globalThis.__SP__.store.get().match.public.deadline), 0);
|
||||
await guest.bringToFront();
|
||||
await guest.click('.brief-reroll .btn--primary');
|
||||
for (const p of [host, guest]) await p.waitForFunction(() => globalThis.__SP__.store.get().match.public?.setupRevision === 1);
|
||||
const state = await guest.evaluate(() => globalThis.__SP__.store.get().match.public);
|
||||
assert.equal(state.rerollVote, null);
|
||||
assert.ok(state.players.filter((p) => !p.isBot).every((p) => !p.ready));
|
||||
await host.waitForFunction(() => !!document.querySelector('.brief-reroll button:not(:disabled)'));
|
||||
// Wait out the documented server anti-burst interval before a second request.
|
||||
await new Promise((resolve) => setTimeout(resolve, 350));
|
||||
await host.bringToFront();
|
||||
await host.click('.brief-reroll button');
|
||||
await guest.waitForSelector('.brief-reroll .btn--danger');
|
||||
await guest.bringToFront();
|
||||
await guest.click('.brief-reroll .btn--danger');
|
||||
await host.waitForFunction(() => !globalThis.__SP__.store.get().match.public.rerollVote);
|
||||
assert.equal(await host.evaluate(() => globalThis.__SP__.store.get().match.public.setupRevision), 1);
|
||||
} finally { await ctx.close(); }
|
||||
});
|
||||
test('operator voice selection persists across reload and follows global after reset', async () => {
|
||||
const ctx = await browser.createBrowserContext();
|
||||
try {
|
||||
const page = await pageIn(ctx, true);
|
||||
const open = async () => { await page.evaluate(async () => (await import('/js/ui/loadoutSync.js')).openLoadout()); await page.waitForSelector('.lo-card__pick'); await page.click('.lo-card__pick'); await page.waitForSelector('[data-voice-char] select', { visible: true }); };
|
||||
await open();
|
||||
const id = await page.$eval('[data-voice-char]', (e) => e.dataset.voiceChar);
|
||||
await page.select('[data-voice-char] select', 'jp');
|
||||
assert.equal(await page.evaluate(async (id) => (await import('/js/audio.js')).audio.voiceOverrides[id], id), 'jp');
|
||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => globalThis.__SP__?.store.get().connection.status === 'online');
|
||||
await open();
|
||||
assert.equal(await page.$eval('[data-voice-char] select', (e) => e.value), 'jp');
|
||||
await page.select('[data-voice-char] select', '');
|
||||
assert.equal(await page.evaluate((id) => JSON.parse(localStorage.getItem('sp.pref.settings')).voiceOverrides[id], id), undefined);
|
||||
} finally { await ctx.close(); }
|
||||
});
|
||||
test('install manifest is eligible and a dismissed offer is consumed through the actual button', async () => {
|
||||
const ctx = browser.defaultBrowserContext();
|
||||
let page;
|
||||
try {
|
||||
page = await pageIn(ctx);
|
||||
const client = await page.createCDPSession();
|
||||
const manifest = await client.send('Page.getAppManifest');
|
||||
assert.deepEqual(manifest.errors, []);
|
||||
const install = await client.send('Page.getInstallabilityErrors');
|
||||
assert.deepEqual(install.installabilityErrors, []);
|
||||
await page.evaluate(() => {
|
||||
const e = new Event('beforeinstallprompt', { cancelable: true });
|
||||
window.promptCalls = 0;
|
||||
e.prompt = async () => { window.promptCalls++; return { outcome: 'dismissed' }; };
|
||||
window.dispatchEvent(e);
|
||||
});
|
||||
await page.waitForSelector('[data-testid="pwa-install"]');
|
||||
await page.click('[data-testid="pwa-install"]');
|
||||
await page.waitForFunction(() => !document.querySelector('[data-testid="pwa-install"]'));
|
||||
assert.equal(await page.evaluate(() => window.promptCalls), 1);
|
||||
} finally { await page?.close(); }
|
||||
});
|
||||
test('extra-large settings stay within a landscape phone in every shipped interface language', async () => {
|
||||
const ctx = await browser.createBrowserContext();
|
||||
try {
|
||||
const page = await pageIn(ctx, true);
|
||||
for (const lang of ['zh-CN', 'en', 'ja', 'ko', 'zh-TW']) {
|
||||
await page.evaluate(async (lang) => {
|
||||
await (await import('/js/ui/lang.js')).switchLang(lang);
|
||||
(await import('/js/ui/settings.js')).updateSettings({ textSize: 'xl' });
|
||||
}, lang);
|
||||
await page.click('[data-testid="settings-btn"]');
|
||||
await page.waitForSelector('.modal .set-list');
|
||||
const overflow = await page.$eval('.modal__body', (e) => e.scrollWidth - e.clientWidth);
|
||||
const excess = await page.$eval('.modal__body', (e) => { const right = e.getBoundingClientRect().right; return [...e.querySelectorAll('*')].filter((x) => x.getBoundingClientRect().right > right + 1).map((x) => [x.className, x.textContent.slice(0, 50), Math.round(x.getBoundingClientRect().width)]); });
|
||||
assert.ok(overflow <= 1, `${lang}: settings overflow ${overflow} ${JSON.stringify(excess)}`);
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForFunction(() => !document.querySelector('.modal .set-list'));
|
||||
}
|
||||
} finally { await ctx.close(); }
|
||||
});
|
||||
});
|
||||
@@ -723,7 +723,7 @@ describe('keyboard & settings', () => {
|
||||
test('sanitizeSettings', () => {
|
||||
assert.deepEqual(sanitizeSettings(null), { ...DEFAULT_SETTINGS });
|
||||
assert.deepEqual(sanitizeSettings({ bgm: 3, sfx: -1, voice: 2, muted: 'yes', damageNumbers: false, quality: 'ultra' }),
|
||||
{ bgm: 1, sfx: 0, voice: 1, voiceLang: 'cn', muted: false, damageNumbers: false, quality: 'high', keys: { ...DEFAULT_SETTINGS.keys } },
|
||||
{ bgm: 1, sfx: 0, voice: 1, voiceLang: 'cn', voiceOverrides: {}, muted: false, damageNumbers: false, quality: 'high', textSize: 'sm', keys: { ...DEFAULT_SETTINGS.keys } },
|
||||
'a saved profile without `keys` (before 0.2.0) gets the default key map (test/ui/feedback5-hotkeys.test.js)');
|
||||
assert.equal(sanitizeSettings({ bgm: 0.5 }).voice, DEFAULT_SETTINGS.voice, 'a saved profile without `voice` gets the default');
|
||||
// 语音语言 (0.2.2): 中文 by default — a profile saved before it, or any other value, plays the Chinese dub
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { createPwaInstall } from '../../public/js/pwa.js';
|
||||
function rig(standalone = false) {
|
||||
const win = new EventTarget(), display = new EventTarget();
|
||||
display.matches = standalone;
|
||||
win.matchMedia = () => display;
|
||||
const pwa = createPwaInstall(win);
|
||||
const offer = (outcome = 'dismissed', fail = false) => {
|
||||
let calls = 0;
|
||||
const e = new Event('beforeinstallprompt', { cancelable: true });
|
||||
e.prompt = async () => { calls++; if (fail) throw Error('unavailable'); return { outcome }; };
|
||||
win.dispatchEvent(e);
|
||||
return { calls: () => calls, e };
|
||||
};
|
||||
return { pwa, win, display, offer };
|
||||
}
|
||||
test('install offer is consumed even on dismissal, duplicate clicks, or rejected prompt', async () => {
|
||||
const { pwa, offer } = rig();
|
||||
const states = []; const off = pwa.subscribe((x) => states.push(x));
|
||||
assert.equal(pwa.available(), false);
|
||||
const first = offer();
|
||||
assert.equal(first.e.defaultPrevented, true);
|
||||
assert.equal(pwa.available(), true);
|
||||
const a = pwa.request(), b = pwa.request();
|
||||
assert.equal(await a, false); assert.equal(await b, false); assert.equal(first.calls(), 1);
|
||||
const broken = offer('accepted', true);
|
||||
assert.equal(await pwa.request(), false); assert.equal(await pwa.request(), false); assert.equal(broken.calls(), 1);
|
||||
const next = offer('accepted');
|
||||
assert.equal(await pwa.request(), true); assert.equal(next.calls(), 1);
|
||||
assert.deepEqual(states, [false, true, false, true, false, true, false]);
|
||||
off(); pwa.dispose();
|
||||
});
|
||||
test('installed / standalone hides stale offers; disposing removes listeners', async () => {
|
||||
const { pwa, win, display, offer } = rig();
|
||||
offer(); display.matches = true; display.dispatchEvent(new Event('change'));
|
||||
assert.equal(pwa.available(), false); assert.equal(await pwa.request(), false);
|
||||
display.matches = false; win.dispatchEvent(new Event('appinstalled')); offer();
|
||||
assert.equal(pwa.available(), false);
|
||||
pwa.dispose();
|
||||
assert.equal(offer().e.defaultPrevented, false);
|
||||
const native = rig(true); native.offer(); assert.equal(native.pwa.available(), false); native.pwa.dispose();
|
||||
});
|
||||
test('manifest uses actual 192/512 icons shipped outside optional game assets', () => {
|
||||
const m = JSON.parse(readFileSync(new URL('../../public/manifest.json', import.meta.url)));
|
||||
assert.equal(m.start_url, '/'); assert.equal(m.scope, '/');
|
||||
for (const icon of m.icons) {
|
||||
assert.ok(icon.src.startsWith('/icons/'));
|
||||
const bytes = readFileSync(new URL('../../public' + icon.src, import.meta.url));
|
||||
const size = Number(icon.sizes.split('x')[0]);
|
||||
assert.equal(bytes.readUInt32BE(16), size); assert.equal(bytes.readUInt32BE(20), size);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { AudioManager } from '../../public/js/audio.js';
|
||||
import { indexAudio, pickModeAttacks } from '../../tools/assets/audio.mjs';
|
||||
import { buildPlan } from '../../tools/assets/plan.mjs';
|
||||
|
||||
const id = 'char_1043_leizi2';
|
||||
function rig() {
|
||||
const entry = { attack: '/normal_n.mp3', hit: '/normal_hit_n.mp3', attacks: { 2: '/mode_s.mp3' }, hits: { 2: '/impact_s.mp3' }, mix: { attack: { p: 0 }, hit: { vol: 0.1 } }, attackMix: { 2: { vol: 0.6 } } };
|
||||
const a = new AudioManager({ getManifest: () => ({ audio: { sfx: { units: { [id]: entry } } } }), random: () => 0.5 });
|
||||
a.ctx = {};
|
||||
const played = [];
|
||||
a._play = (url, opts) => played.push({ url, ...opts });
|
||||
const units = [{ id: 1, spine: id, side: 'ally', kind: 'chess', skillIndex: 2 }, { id: 2, spine: 'enemy_1_test', side: 'enemy' }];
|
||||
a.setFieldUnits(units);
|
||||
return { a, entry, played, units };
|
||||
}
|
||||
|
||||
test('mode uses its own chance and volume, never the normal bank mix', () => {
|
||||
const { a, entry, played } = rig();
|
||||
a.unit(id, 'attack', 1, 2, true);
|
||||
a.unit(id, 'hit', 1, 2, true);
|
||||
assert.deepEqual(played.map((p) => [p.url, p.volume]), [['/mode_s.mp3', 0.33], ['/impact_s.mp3', 0.55]]);
|
||||
entry.attackMix[2].p = 0;
|
||||
a.unit(id, 'attack', 1, 2, true);
|
||||
assert.equal(played.length, 2, 'silent mode does not fall back to normal');
|
||||
});
|
||||
|
||||
test('an in-flight impact remembers the mode at attack time after skill end', () => {
|
||||
const { a, played } = rig();
|
||||
a.handleBattleEvents([['skill', 1, 1], ['atk', 1, 2, 'none'], ['skill', 1, 0], ['dmg', 2, 10, 'phys']]);
|
||||
assert.deepEqual(played.map((p) => p.url), ['/mode_s.mp3', '/impact_s.mp3']);
|
||||
assert.equal(a.units.get(1).skillActive, false);
|
||||
});
|
||||
|
||||
test('end before spawn cancels a queued start; field changes discard stale modes', () => {
|
||||
const { a, played, units } = rig();
|
||||
a.setFieldUnits([]);
|
||||
a.handleBattleEvents([['skill', 1, 1], ['skill', 1, 0], ['spawn', units[0]], ['atk', 1, 2, 'none']]);
|
||||
assert.equal(a.pendingSkill.size, 0);
|
||||
assert.equal(a.units.get(1).skillActive, false);
|
||||
assert.equal(played.length, 0);
|
||||
a.setFieldUnits([{ ...units[0], skillActive: true }, units[1]]);
|
||||
a.handleBattleEvents([['atk', 1, 2, 'none']]);
|
||||
assert.equal(played.at(-1).url, '/mode_s.mp3', 'late join snapshot restores active mode');
|
||||
a.setFieldUnits(units);
|
||||
assert.equal(a.lastAttacker.size, 0);
|
||||
assert.equal(a.units.get(1).skillActive, false);
|
||||
});
|
||||
|
||||
test('mixed mode banks are not guessed; the selected official bank retains its mix in the plan', () => {
|
||||
assert.deepEqual(pickModeAttacks(new Map([['ON_ABILITY_START.attack', ['/a_s.mp3', '/b_h.mp3']]])), {});
|
||||
const audio = indexAudio({ soundFXBanks: [{ name: `battle.ON_ABILITY_START.${id}.attack.7`, sounds: [
|
||||
{ asset: 'Audio/Sound_Beta_2/Player/p_atk/test_s', weight: 1, minVolume: 0.6, maxVolume: 0.6 },
|
||||
{ asset: '', weight: 1 },
|
||||
] }] });
|
||||
const { template } = buildPlan({ assets07: {}, ops03: {}, extraOperators: { [id]: { skills: [{ index: 2, skillId: 'test' }] } }, enemies05: {}, maps05: {}, audio, modelsData: {} });
|
||||
assert.deepEqual(template.audio.sfx.units[id].attackMix, { 2: { p: 0.5, vol: 0.6 } });
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
// Browser check of 设置 →「文字大小」 (opt-in, needs Chrome, ~30 s):
|
||||
// SP_E2E=1 CHROME_PATH=… node --test test/ui/text-scale.e2e.test.js
|
||||
//
|
||||
// A 844×390 landscape phone: 1rem is clamped at 40 px, so the .18rem body text is 7.2 CSS px. The setting steps the text
|
||||
// root `--t` (css/theme.css) instead of the layout root: the readable text grows, while `documentElement`'s font-size,
|
||||
// the modal's rem-sized box and — through the dev mock's 休整期 screen — the field host, the canvas and the shop bar stay
|
||||
// exactly where they were. Static counterpart: test/ui/text-scale.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 PHONE = { width: 844, height: 390 };
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
/** The settings the mock harness reads: `sp.pref.settings` with one text step (the argument is passed into the page —
|
||||
* evaluateOnNewDocument cannot see a closure). */
|
||||
const seedSettings = (page, textSize) => page.evaluateOnNewDocument((ts) => {
|
||||
try { localStorage.setItem('sp.pref.settings', JSON.stringify({ textSize: ts })); } catch { /* ignore */ }
|
||||
}, textSize);
|
||||
|
||||
describe('文字大小 on a phone (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', '--force-device-scale-factor=1'] });
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await browser?.close();
|
||||
await srv?.close();
|
||||
});
|
||||
|
||||
/** A page watching for console / page errors, with `sp.pref.settings` seeded before the first script runs. */
|
||||
async function open(url, textSize, { seed = true } = {}) {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport(PHONE);
|
||||
const problems = [];
|
||||
page.on('console', (m) => { if (m.type() === 'error') problems.push(`console: ${m.text()}`); });
|
||||
page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`));
|
||||
if (seed && textSize) await seedSettings(page, textSize);
|
||||
await page.goto(url, { waitUntil: 'networkidle0', timeout: 60000 });
|
||||
return { page, problems };
|
||||
}
|
||||
|
||||
/** What the settings modal shows at this step: the roots, a readable label, its Latin micro label, the box. */
|
||||
const modalMetrics = (page) => page.evaluate(() => {
|
||||
const px = (sel, prop = 'fontSize') => {
|
||||
const el = document.querySelector(sel);
|
||||
return el ? parseFloat(getComputedStyle(el)[prop]) : null;
|
||||
};
|
||||
const box = document.querySelector('.modal__box')?.getBoundingClientRect();
|
||||
const body = document.querySelector('.modal__body');
|
||||
const on = document.querySelector('[data-testid="text-size"] button.is-on');
|
||||
// nothing may stick out of the modal's box (the label wraps and the four steps fit its row)
|
||||
let outRight = 0;
|
||||
for (const el of document.querySelectorAll('.modal__box *')) {
|
||||
const r = el.getBoundingClientRect();
|
||||
if (box && r.width > 0) outRight = Math.max(outRight, r.right - box.right);
|
||||
}
|
||||
return {
|
||||
root: parseFloat(getComputedStyle(document.documentElement).fontSize),
|
||||
label: px('.set-row__label'),
|
||||
micro: px('.set-row__label .micro'),
|
||||
modalWidth: box ? Math.round(box.width) : null,
|
||||
bodyOverflowX: body ? body.scrollWidth - body.clientWidth : null,
|
||||
outRight: Math.round(outRight),
|
||||
step: on?.textContent ?? null,
|
||||
hint: document.querySelector('.set-textsize-note')?.textContent ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
test('the steps grow the readable text; the layout root and the modal box do not move', async () => {
|
||||
const { page, problems } = await open(`${base}/`, null, { seed: false });
|
||||
await page.waitForSelector('input', { timeout: 15000 });
|
||||
await page.type('input', '文字大小');
|
||||
await page.evaluate(() => [...document.querySelectorAll('button')].find((b) => /开始/.test(b.textContent))?.click());
|
||||
await page.waitForSelector('.lobby-screen', { timeout: 15000 });
|
||||
await page.click('.lobby-screen [data-testid="settings-btn"]');
|
||||
await page.waitForSelector('.modal .set-list', { timeout: 5000 });
|
||||
await sleep(200);
|
||||
|
||||
const small = await modalMetrics(page);
|
||||
assert.equal(small.step, '小', 'the default is the design\'s own sizes');
|
||||
assert.equal(small.root, 40, 'a landscape phone clamps the layout root at 40 px');
|
||||
assert.ok(Math.abs(small.label - 0.18 * 40) < 0.1, `the body text starts at the reported 7.2 px (${small.label})`);
|
||||
assert.ok(small.hint && /棋盘/.test(small.hint), 'the row says the layout is not touched');
|
||||
|
||||
const step = async (name) => {
|
||||
await page.evaluate((n) => {
|
||||
const btn = [...document.querySelectorAll('[data-testid="text-size"] button')].find((b) => b.textContent.trim() === n);
|
||||
btn?.click();
|
||||
}, name);
|
||||
await sleep(200);
|
||||
return modalMetrics(page);
|
||||
};
|
||||
|
||||
const md = await step('中');
|
||||
const lg = await step('大');
|
||||
const xl = await step('特大');
|
||||
assert.equal(md.step, '中');
|
||||
assert.equal(xl.step, '特大', 'the pick sticks');
|
||||
assert.ok(md.label > small.label * 1.5 && lg.label > md.label && xl.label > lg.label,
|
||||
`the readable text grows step by step (${[small.label, md.label, lg.label, xl.label].join(' → ')})`);
|
||||
assert.ok(Math.abs(xl.label - 0.18 * 88) < 0.2, `特大 reaches the 88 px text root (${xl.label})`);
|
||||
assert.ok(xl.micro > small.micro * 1.9, `the 4.4 px micro labels follow (${small.micro} → ${xl.micro})`);
|
||||
for (const m of [md, lg, xl]) {
|
||||
assert.equal(m.root, 40, 'the layout root never moves: the field camera and the DOM board read 1rem');
|
||||
assert.equal(m.modalWidth, small.modalWidth, 'the modal\'s box is sized in rem and does not change');
|
||||
assert.ok(m.bodyOverflowX <= 1, `the rows do not overflow the modal body (${m.bodyOverflowX} px)`);
|
||||
assert.ok(m.outRight <= 1, `nothing sticks out of the modal's box (${m.outRight} px): the label wraps and the four steps fit`);
|
||||
}
|
||||
|
||||
// saved with the settings and restored on the next visit
|
||||
assert.deepEqual(await page.evaluate(() => JSON.parse(localStorage.getItem('sp.pref.settings')).textSize), 'xl');
|
||||
await page.reload({ waitUntil: 'networkidle0' });
|
||||
await page.waitForSelector('.lobby-screen', { timeout: 15000 });
|
||||
await page.click('.lobby-screen [data-testid="settings-btn"]');
|
||||
await page.waitForSelector('.modal .set-list', { timeout: 5000 });
|
||||
const again = await modalMetrics(page);
|
||||
assert.equal(again.step, '特大', 'the choice survives a reload');
|
||||
assert.equal(again.root, 40);
|
||||
assert.deepEqual(problems, []);
|
||||
await page.close();
|
||||
});
|
||||
|
||||
test('the in-match field, canvas and shop bar do not move; the settings dialog in a match grows', async () => {
|
||||
const measure = async (textSize) => {
|
||||
const { page, problems } = await open(`${base}/dev/game-mock.html?shot=1&phase=PREP&variant=settings`, textSize);
|
||||
await page.waitForFunction(() => !!document.querySelector('.screen:not(.gload)'), { timeout: 15000 });
|
||||
await sleep(900);
|
||||
const m = await page.evaluate(() => {
|
||||
const box = (sel) => {
|
||||
const el = document.querySelector(sel);
|
||||
if (!el) return null;
|
||||
const r = el.getBoundingClientRect();
|
||||
return [Math.round(r.x), Math.round(r.y), Math.round(r.width), Math.round(r.height)];
|
||||
};
|
||||
return {
|
||||
root: parseFloat(getComputedStyle(document.documentElement).fontSize),
|
||||
field: box('.gm__field'),
|
||||
canvas: box('.gm__field canvas'),
|
||||
shopbar: box('.gm__hud .shopbar'),
|
||||
label: (() => { const el = document.querySelector('.set-row__label'); return el ? parseFloat(getComputedStyle(el).fontSize) : null; })(),
|
||||
};
|
||||
});
|
||||
return { ...m, problems, page };
|
||||
};
|
||||
|
||||
const small = await measure('sm');
|
||||
const big = await measure('xl');
|
||||
assert.ok(small.field && small.shopbar, 'the prep screen with its HUD is on screen');
|
||||
assert.equal(small.root, 40);
|
||||
assert.equal(big.root, 40, 'the text step never reaches the root');
|
||||
assert.deepEqual(big.field, small.field, 'the field host (host.clientWidth / clientHeight) is unchanged');
|
||||
assert.deepEqual(big.canvas, small.canvas, 'and so is the canvas the renderer resizes to it');
|
||||
assert.deepEqual(big.shopbar, small.shopbar, 'the shop bar is a fixed HUD box: still the design\'s size');
|
||||
assert.ok(big.label > small.label * 1.9, `while the dialog's readable text grows (${small.label} → ${big.label})`);
|
||||
assert.deepEqual(small.problems, []);
|
||||
assert.deepEqual(big.problems, []);
|
||||
await small.page.close();
|
||||
await big.page.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,240 @@
|
||||
// 设置 →「文字大小」 (the owner's decision of 2026-10-09): the interface text root `--t` of css/theme.css, stepped by
|
||||
// the settings store (`sp.pref.settings` textSize), applied to <html data-text> by ui/settings.js applyTextSize.
|
||||
// A phone clamps the LAYOUT root `1rem` at 40 px (theme.css), which left the .18rem body text at 7.2 CSS px, while the
|
||||
// browser's own font settings only inflate glyphs inside fixed boxes and page zoom is off (index.html's viewport,
|
||||
// ui/device.js). Only font-size declarations read `--t`: the five modules that turn `1rem` into pixels — ui/fieldHost.js
|
||||
// hudPadding / hudBands (the prep camera's HUD clearance), ui/gameLogic/panel.js panelSlots (the detail card's side),
|
||||
// ui/underframe.js (the tile labels' budget) and ui/fallbackField.js (the DOM board) — must never move with it, and the
|
||||
// field itself is sized from the host element's clientWidth (render/app.js).
|
||||
//
|
||||
// What this file guards: the default is the design's own sizes; a bad or missing value falls back to 小; the step lands
|
||||
// on <html data-text>; the layout root's clamp is untouched; and every font-size that stayed on a plain `rem` is one of
|
||||
// the deliberate exclusions (the in-match HUD's fixed boxes, the board-pinned DOM text, geometry-derived sizes, the
|
||||
// ≥16 px iOS-zoom guards) — a new readable size that forgets `--t` fails here, the way `tools/i18n.mjs extract 0` fails
|
||||
// on an unwrapped string. Browser counterpart: test/ui/text-scale.e2e.test.js (SP_E2E=1).
|
||||
|
||||
import { describe, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync, readdirSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { DEFAULT_SETTINGS, TEXT_SIZES, sanitizeSettings } from '../../public/js/ui/gameLogic.js';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const read = (p) => readFileSync(path.join(ROOT, p), 'utf8');
|
||||
const CSS = 'public/css';
|
||||
|
||||
/** Every stylesheet under public/css (index.html loads all of them, devices.css last), as { rel, src }. */
|
||||
function stylesheets() {
|
||||
const out = [];
|
||||
const visit = (rel) => {
|
||||
for (const e of readdirSync(path.join(ROOT, rel), { withFileTypes: true })) {
|
||||
const r = `${rel}/${e.name}`;
|
||||
if (e.isDirectory()) visit(r);
|
||||
else if (r.endsWith('.css')) out.push({ rel: r, src: read(r).replace(/\/\*[\s\S]*?\*\//g, '') });
|
||||
}
|
||||
};
|
||||
visit(CSS);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Rules as { selector, body } (flat; @media blocks are walked into, as test/client-static.test.js does). */
|
||||
function cssRules(src) {
|
||||
const out = [];
|
||||
const re = /([^{}]+)\{([^{}]*)\}/g;
|
||||
let m;
|
||||
while ((m = re.exec(src))) out.push({ selector: m[1].trim(), body: m[2] });
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The font sizes that deliberately stay on a plain `rem` (or a bare px): the in-match HUD chrome in fixed boxes, the
|
||||
* DOM text pinned onto board tiles, the geometry-derived sizes and the ≥16 px iOS-zoom guards. Listed by selector, the
|
||||
* same list the codemod used — a rule that stops being an exclusion (or a new one that should have used `--t`) shows up
|
||||
* as a failure with its file and selector.
|
||||
*/
|
||||
const NOT_SCALED = [
|
||||
// pinned onto board tiles by JS-computed pixels (ui/standInTags.js, ui/underframe.js)
|
||||
/\.sitag/, /\.tempnote/, /\.uframe/, /\.ff-/,
|
||||
// in-match HUD chrome whose box the design fixed (a later pass: shop cards, top bar, bond strip, corner buttons)
|
||||
/\.gm__/, /\.roundbox/, /\.capsule/, /\.lp\b/, /\.lp--/, /\.misstag/, /\.bossbar/, /\.readywrap/,
|
||||
/\.bstrip/, /\.bslot/, /\.effect/, /\.efftip/, /\.ebubble/, /\.ticker/, /\.dpbox/, /\.vswitch/,
|
||||
/\.specpill/, /\.otwarn/, /\.team/, /\.enemybtn/, /\.readybtn/, /\.ewheel/, /\.fwheel/, /\.chud/, /\.toolbtn/,
|
||||
/\.shopbar/, /\.scard/, /\.scr\b/,
|
||||
// text inside a fixed-height bar (the HP bar of the detail card, the boss bar's fill)
|
||||
/\.dhp\b/,
|
||||
// the 机变 card: its grid fits 7 lines of the effect text (game-panels.css), so a bigger font would clamp text away
|
||||
/\.spcard/,
|
||||
// decorative wordmarks and boot/rotate overlays whose size is the composition
|
||||
/\.title-cn/, /\.title-en/, /\.title-bg__target/, /\.brief-boss__q/, /\.boot__/, /\.rotate-hint/,
|
||||
// the join code's letter-spaced field, the px-floored 本局信息 dialog, the 16 px diagnostics box (iOS zoom guard)
|
||||
/\.field--code/, /\.minfo-dlg/, /\.set-diag__text/,
|
||||
// the keyboard hints of the 快捷键 list (a shared rule with the HUD's .toolbtn kbd)
|
||||
/\.set-hint kbd/,
|
||||
];
|
||||
|
||||
const notScaled = (selector) => NOT_SCALED.some((re) => re.test(selector));
|
||||
|
||||
/**
|
||||
* Whole stylesheets a later pass owns: the shop bar's fixed cards (their height feeds the prep camera's HUD band,
|
||||
* ui/fieldHost.js HUD_REM), and the 干员调配 / 统计 screens, whose text is already px-floored (`max(.15rem, 11px)`) and
|
||||
* therefore readable on a phone as it is. Scaling them is step 2/3 of the change, not a forgotten line.
|
||||
*/
|
||||
const NOT_SCALED_FILES = [`${CSS}/screens/game-shop.css`, `${CSS}/screens/loadout.css`, `${CSS}/screens/stats.css`];
|
||||
|
||||
/** A font-size value that is neither scaled nor an exclusion: a plain `rem`, or `max(<rem>, …)`. */
|
||||
function plainRem(value) {
|
||||
if (/var\(/.test(value)) return false; // --t, or a geometry-derived size
|
||||
if (/(^|[^r])em\b|%/.test(value.replace(/rem\b/g, ''))) return false;
|
||||
return /^\s*(max\(\s*)?\d*\.?\d+rem\b/.test(value);
|
||||
}
|
||||
|
||||
describe('文字大小: the setting', () => {
|
||||
test('the default is 小 (the design\'s own sizes) and a saved profile without it keeps it', () => {
|
||||
assert.deepEqual([...TEXT_SIZES], ['sm', 'md', 'lg', 'xl'], 'the steps, in the settings order');
|
||||
assert.equal(DEFAULT_SETTINGS.textSize, 'sm');
|
||||
assert.equal(sanitizeSettings(null).textSize, 'sm');
|
||||
assert.equal(sanitizeSettings({ bgm: 0.4 }).textSize, 'sm', 'a profile saved before the setting existed');
|
||||
});
|
||||
|
||||
test('every step survives sanitising; anything else falls back to 小', () => {
|
||||
for (const v of TEXT_SIZES) assert.equal(sanitizeSettings({ textSize: v }).textSize, v);
|
||||
for (const bad of ['huge', 'SM', 'xl2', 'md ', 1, true, null, undefined, {}, []]) {
|
||||
assert.equal(sanitizeSettings({ textSize: bad }).textSize, 'sm', String(bad));
|
||||
}
|
||||
assert.equal(sanitizeSettings({ textSize: 'xl', quality: 'low' }).quality, 'low', 'the other settings are untouched');
|
||||
});
|
||||
|
||||
test('applyTextSize writes the step to <html data-text>, and 小 when it is not a step', async () => {
|
||||
const { applyTextSize } = await import('../../public/js/ui/settings.js');
|
||||
const el = { dataset: {} };
|
||||
const prev = globalThis.document;
|
||||
globalThis.document = { documentElement: el };
|
||||
try {
|
||||
for (const v of TEXT_SIZES) {
|
||||
applyTextSize(v);
|
||||
assert.equal(el.dataset.text, v);
|
||||
}
|
||||
for (const bad of ['', 'big', null, undefined, 42]) {
|
||||
applyTextSize(bad);
|
||||
assert.equal(el.dataset.text, 'sm', String(bad));
|
||||
}
|
||||
applyTextSize(undefined);
|
||||
assert.equal(el.dataset.text, 'sm', 'the attribute is always written, never left stale');
|
||||
} finally {
|
||||
if (prev === undefined) delete globalThis.document; else globalThis.document = prev;
|
||||
}
|
||||
});
|
||||
|
||||
test('the store applies the step on every change, and at boot before the first render', async () => {
|
||||
const { settingsStore, updateSettings } = await import('../../public/js/ui/settings.js');
|
||||
const el = { dataset: {} };
|
||||
const prev = globalThis.document;
|
||||
globalThis.document = { documentElement: el };
|
||||
try {
|
||||
updateSettings({ textSize: 'lg' });
|
||||
assert.equal(el.dataset.text, 'lg', 'picking a step re-renders the text immediately');
|
||||
assert.equal(settingsStore.get().textSize, 'lg', 'and it is saved with the settings (sp.pref.settings)');
|
||||
updateSettings({ textSize: 'sm' });
|
||||
assert.equal(el.dataset.text, 'sm');
|
||||
} finally {
|
||||
updateSettings({ textSize: DEFAULT_SETTINGS.textSize });
|
||||
if (prev === undefined) delete globalThis.document; else globalThis.document = prev;
|
||||
}
|
||||
});
|
||||
|
||||
test('the settings modal offers the four steps (data-testid text-size), below 画面质量', () => {
|
||||
const src = read('public/js/ui/settings.js');
|
||||
assert.match(src, /data-testid="text-size"/);
|
||||
assert.match(src, /const TEXT_SCALES = \[\[?.*'sm'.*'md'.*'lg'.*'xl'/s, 'the steps with their labels');
|
||||
assert.match(src, /applyTextSize\(settingsStore\.get\(\)\.textSize\)/, 'applied at module load (before main.js renders)');
|
||||
assert.ok(src.indexOf("t('文字大小')") > src.indexOf("t('画面质量')"), 'the row is below the quality row');
|
||||
assert.match(read('public/css/screens/game.css'), /\.set-seg\.set-textsize button \{ min-width: \.62rem; \}/,
|
||||
'four steps fit the row on a phone (1rem = 40 px)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('文字大小: the CSS text root', () => {
|
||||
const sheets = stylesheets();
|
||||
const theme = sheets.find((s) => s.rel === `${CSS}/theme.css`).src;
|
||||
|
||||
test('theme.css defines --t for 小 and every step, and nothing else defines it', () => {
|
||||
assert.match(theme, /:root \{ --t: 1rem; \}/, '小 = the design\'s own sizes');
|
||||
for (const [attr, value] of [['md', /--t: max\(1rem, 64px\)/], ['lg', /--t: max\(calc\(1rem \* 1\.15\), 76px\)/], ['xl', /--t: max\(calc\(1rem \* 1\.3\), 88px\)/]]) {
|
||||
assert.match(theme, new RegExp(`:root\\[data-text="${attr}"\\] \\{ ${value.source}; \\}`), `the ${attr} step`);
|
||||
}
|
||||
for (const { rel, src } of sheets) {
|
||||
if (rel === `${CSS}/theme.css`) continue;
|
||||
assert.doesNotMatch(src, /--t\s*:/, `${rel}: --t is defined in theme.css only`);
|
||||
}
|
||||
});
|
||||
|
||||
test('the layout root 1rem is untouched — the five rem→px readers must not move', () => {
|
||||
assert.match(theme, /html \{\n {2}font-size: clamp\(40px, min\(calc\(100vw \/ 19\.2\), calc\(100vh \/ 10\.8\)\), 240px\);/,
|
||||
'the desktop clamp');
|
||||
assert.match(theme, /font-size: clamp\(40px, min\(calc\(100vw \/ 19\.2\), calc\(100svh \/ 10\.8\)\), 240px\);/,
|
||||
'the svh clamp (mobile toolbars)');
|
||||
assert.doesNotMatch(theme, /font-size: clamp\([^)]*var\(--t\)/, 'the root scale never reads the text root');
|
||||
// and the modules that convert it keep their own floor: a text step must not reach them
|
||||
for (const f of ['public/js/ui/fieldHost.js', 'public/js/ui/gameLogic/panel.js', 'public/js/ui/underframe.js', 'public/js/ui/fallbackField.js']) {
|
||||
const src = read(f);
|
||||
assert.doesNotMatch(src, /--t\b|data-text/, `${f}: the layout must not read the text root`);
|
||||
assert.match(src, /getComputedStyle\(document\.documentElement\)\.fontSize|rem\b/, `${f}: still converts 1rem`);
|
||||
}
|
||||
});
|
||||
|
||||
test('readable text reads --t (body, .micro, buttons, modals, panels, settings)', () => {
|
||||
const want = [
|
||||
[theme, /body \{[^}]*font-size: calc\(\.18 \* var\(--t\)\)/s, 'theme.css body (the 7.2 px text)'],
|
||||
[theme, /\.micro \{[^}]*font-size: calc\(\.11 \* var\(--t\)\)/s, 'theme.css .micro (92 render sites)'],
|
||||
[theme, /:root\[data-text="md"\]/, 'the steps'],
|
||||
];
|
||||
for (const [src, re, what] of want) assert.match(src, re, what);
|
||||
const byRel = new Map(sheets.map((s) => [s.rel, s.src]));
|
||||
const samples = [
|
||||
['components.css', /\.btn \{[^}]*font-size: calc\(\.18 \* var\(--t\)\)/s],
|
||||
['screens/game.css', /\.set-row__label \{[^}]*font-size: calc\(\.18 \* var\(--t\)\)/s],
|
||||
['screens/game-panels.css', /\.dtext \{ font-size: calc\(\.15 \* var\(--t\)\); line-height: 1\.65;/],
|
||||
['screens/guide.css', /\.guide__tips li \{ font-size: calc\(\.18 \* var\(--t\)\)/],
|
||||
['screens/lobby.css', /\.mode-card__desc/, ],
|
||||
];
|
||||
for (const [file, re] of samples) assert.match(byRel.get(`${CSS}/${file}`), re, file);
|
||||
});
|
||||
|
||||
test('a newer reader would be caught: every plain rem font-size is a documented exclusion', () => {
|
||||
const bad = [];
|
||||
let scaled = 0;
|
||||
for (const { rel, src } of sheets) {
|
||||
if (NOT_SCALED_FILES.includes(rel)) continue;
|
||||
for (const { selector, body } of cssRules(src)) {
|
||||
for (const m of body.matchAll(/font-size:([^;}]*)/g)) {
|
||||
const value = m[1];
|
||||
if (/var\(--t\)/.test(value)) { scaled++; continue; }
|
||||
if (!plainRem(value)) continue;
|
||||
if (notScaled(selector)) continue;
|
||||
bad.push(`${rel}: ${selector} { font-size:${value.trim()} }`);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.deepEqual(bad, [], `not on --t and not a documented exclusion (${scaled} declarations do read --t):`);
|
||||
assert.ok(scaled >= 250, `${scaled} declarations read --t — a mass revert would fail here`);
|
||||
});
|
||||
|
||||
test('the in-match HUD, the board-pinned text and the iOS zoom guards stay as they were', () => {
|
||||
const byRel = new Map(sheets.map((s) => [s.rel, s.src]));
|
||||
const plain = [
|
||||
['screens/game-panels.css', /\.sitag \{[^}]*font-size: max\(\.12rem, 9px\)/, '.sitag'],
|
||||
['screens/game-panels.css', /\.tempnote__title \{[^}]*font-size: \.15rem/, '.tempnote__title'],
|
||||
['screens/game-panels.css', /\.uframe__label \{[^}]*font-size: \.14rem/, '.uframe__label'],
|
||||
['screens/game-panels.css', /\.spcard__desc \{ flex: none; font-size: \.2rem/, '.spcard__desc (7 clamped lines)'],
|
||||
['screens/game-panels.css', /\.dhp span \{[^}]*font-size: \.12rem/, '.dhp span (a fixed-height HP bar)'],
|
||||
['screens/game-shop.css', /\.scard__name \{[^}]*font-size: \.19rem/, 'the shop card'],
|
||||
['screens/game.css', /\.bslot \.bond__name \{[^}]*font-size: \.14rem/, 'the bond strip'],
|
||||
['screens/game.css', /\.bossbar__txt \{[^}]*font-size: \.14rem/, 'the boss bar'],
|
||||
['screens/game.css', /\.lp--md \{ font-size: \.24rem; \}/, 'the top bar LP'],
|
||||
['screens/game.css', /\.sp-coarse \.set-diag__text \{ font-size: 16px; \}/, 'the 16 px iOS-zoom guard'],
|
||||
['theme.css', /\.rotate-hint \{[\s\S]*?font-size: 16px/, 'the rotate hint'],
|
||||
];
|
||||
for (const [file, re, what] of plain) assert.match(byRel.get(`${CSS}/${file}`), re, what);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { sanitizeSettings } from '../../public/js/ui/gameLogic/settings.js';
|
||||
import { sanitizeVoiceOverrides, voiceLangFor } from '../../public/js/voicePrefs.js';
|
||||
import { AudioManager, voiceLine } from '../../public/js/audio.js';
|
||||
const a = 'char_263_skadi', b = 'char_103_angel';
|
||||
test('old settings migrate to inherit, malformed or prototype entries are discarded', () => {
|
||||
assert.deepEqual(sanitizeSettings({ voiceLang: 'jp' }).voiceOverrides, {});
|
||||
assert.deepEqual(sanitizeVoiceOverrides(Object.assign(Object.create({ [b]: 'jp' }), { [a]: 'jp', bad: 'cn', char_1_no: 'en' })), { [a]: 'jp' });
|
||||
for (const raw of [null, [], 1, 'jp']) assert.deepEqual(sanitizeVoiceOverrides(raw), {});
|
||||
});
|
||||
test('per-character override survives persistence; deletion restores the current global setting', () => {
|
||||
const settings = sanitizeSettings(JSON.parse(JSON.stringify({ voiceLang: 'cn', voiceOverrides: { [a]: 'jp', [b]: 'cn' } })));
|
||||
const manager = new AudioManager();
|
||||
manager.setVoiceLang(settings.voiceLang, settings.voiceOverrides);
|
||||
assert.equal(voiceLangFor(a, manager.voiceLang, manager.voiceOverrides), 'jp');
|
||||
manager.setVoiceLang('jp');
|
||||
assert.equal(voiceLangFor(b, manager.voiceLang, manager.voiceOverrides), 'cn');
|
||||
delete settings.voiceOverrides[b];
|
||||
manager.setVoiceLang('jp', settings.voiceOverrides);
|
||||
assert.equal(voiceLangFor(b, manager.voiceLang, manager.voiceOverrides), 'jp');
|
||||
assert.equal(voiceLangFor('unknown', 'cn', manager.voiceOverrides), 'cn');
|
||||
});
|
||||
test('the per-operator JP preference retains the existing per-slot Chinese fallback', () => {
|
||||
const tree = { voice: { [a]: { select: '/cn.mp3' } }, voiceJp: { [a]: { skill1: '/jp.mp3' } } };
|
||||
assert.equal(voiceLine(tree, a, 'select', voiceLangFor(a, 'cn', { [a]: 'jp' })).url, '/cn.mp3');
|
||||
assert.equal(voiceLine(tree, a, 'skill1', voiceLangFor(a, 'cn', { [a]: 'jp' })).url, '/jp.mp3');
|
||||
});
|
||||
Reference in new issue
Block a user