Merge branch 'master' into fix/spine-start-anim

This commit is contained in:
Cromemadnd Lancity authored and GitHub committed 2026-10-11 20:27:12 +08:00
commit e2b0aab534
577 files changed
+43650 -10558

No files matched your search

+327
View File
@@ -0,0 +1,327 @@
// HTTP(S)_PROXY for the asset downloader. Node's built-in fetch honours it only when
// the process was started with NODE_USE_ENV_PROXY=1 (Node >=22.21 and >=24). The
// npm undici dispatcher does not reach that fetch, so fetch-assets restarts once
// and every other default fetch fails closed.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { EventEmitter } from 'node:events';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawn, spawnSync } from 'node:child_process';
import { createServer } from 'node:http';
import { connect as netConnect } from 'node:net';
import { Downloader } from '../tools/assets/downloader.mjs';
import { cachedJson } from '../tools/assets/cache.mjs';
import {
PROXY_ENV_KEYS, REEXEC_MARKER, envProxyPlan, guardDefaultFetch, nodeHonoursEnvProxy,
proxyConfigured, restartForEnvProxy, startedWithEnvProxy,
} from '../tools/assets/env-proxy.mjs';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const SCRIPT = join(ROOT, 'tools', 'fetch-assets.mjs');
const PROXY = 'http://127.0.0.1:9';
const STRIP = [...PROXY_ENV_KEYS, 'NO_PROXY', 'no_proxy', 'ALL_PROXY', 'all_proxy', 'NODE_USE_ENV_PROXY', REEXEC_MARKER];
function cleanEnv(extra = {}) {
const env = { ...process.env };
for (const key of STRIP) delete env[key];
return Object.assign(env, extra);
}
test('version gate: fetch() env proxy is Node >=22.21 and >=24, not 23 or early 22', () => {
assert.equal(nodeHonoursEnvProxy('22.20.0'), false);
assert.equal(nodeHonoursEnvProxy('22.21.0'), true);
assert.equal(nodeHonoursEnvProxy('v22.22.0'), true);
assert.equal(nodeHonoursEnvProxy('23.11.0'), false);
assert.equal(nodeHonoursEnvProxy('24.0.0'), true);
assert.equal(nodeHonoursEnvProxy('24.5.0'), true);
assert.equal(nodeHonoursEnvProxy('25.1.0'), true);
assert.equal(nodeHonoursEnvProxy(''), false);
assert.equal(nodeHonoursEnvProxy('nope'), false);
});
test('startup switch: the variable, the flag, and NODE_OPTIONS; a late or lookalike value does not count', () => {
assert.equal(startedWithEnvProxy({ NODE_USE_ENV_PROXY: '1' }, []), true);
assert.equal(startedWithEnvProxy({ NODE_USE_ENV_PROXY: 'true' }, []), false);
assert.equal(startedWithEnvProxy({ NODE_USE_ENV_PROXY: '' }, []), false);
assert.equal(startedWithEnvProxy({}, ['--use-env-proxy']), true);
assert.equal(startedWithEnvProxy({}, ['--use_env_proxy']), true);
assert.equal(startedWithEnvProxy({}, ['--no-warnings']), false);
assert.equal(startedWithEnvProxy({ NODE_OPTIONS: '--no-warnings --use-env-proxy' }, []), true);
assert.equal(startedWithEnvProxy({ NODE_OPTIONS: '--use-env-proxy-extra' }, []), false);
assert.equal(startedWithEnvProxy({}, []), false);
});
test('plan: unset proxy is off; a proxy restarts, is already on, or (old Node) goes direct with a warning', () => {
assert.equal(proxyConfigured({}), false);
assert.equal(proxyConfigured({ HTTP_PROXY: ' ', NO_PROXY: 'localhost' }), false);
assert.equal(envProxyPlan({}).action, 'off');
assert.equal(envProxyPlan({ NO_PROXY: '127.0.0.1' }).action, 'off');
assert.equal(envProxyPlan({ HTTP_PROXY: 'http://proxy.example:8080' }, '22.20.0', false).action, 'unsupported');
assert.match(envProxyPlan({ HTTP_PROXY: 'http://proxy.example:8080' }, '23.11.0', true).reason, /Node 23\.11\.0/);
assert.match(envProxyPlan({ HTTP_PROXY: 'http://proxy.example:8080' }, '22.20.0', false).reason, /downloading directly instead/);
assert.equal(envProxyPlan({ http_proxy: 'http://proxy.example:8080' }, '22.21.0', false).action, 'reexec');
assert.match(envProxyPlan({ HTTPS_PROXY: 'http://proxy.example:8080' }, '24.0.0', false).reason, /Refusing to fetch directly/);
// The flag on the object is not the startup snapshot: the third argument is.
assert.equal(envProxyPlan({ HTTP_PROXY: 'http://proxy.example:8080', NODE_USE_ENV_PROXY: '1' }, '22.22.0', false).action, 'reexec');
assert.equal(envProxyPlan({ HTTP_PROXY: 'http://proxy.example:8080', NODE_USE_ENV_PROXY: '1' }, '24.5.0', true).action, 'already');
assert.equal(envProxyPlan({ HTTPS_PROXY: 'http://proxy.example:8080' }, '25.0.0', true).action, 'already');
});
test('an explicit fetch is returned unchanged', () => {
const fetchImpl = async () => new Response('ok');
assert.equal(guardDefaultFetch(fetchImpl), fetchImpl);
});
test('downloader and index cache fail closed under HTTP_PROXY, and an injected fetch still runs', async (t) => {
// The only test that mutates process.env. Node runs tests in a file concurrently;
// the plan tests above pass their own env object and do not read process.env.
const dir = await mkdtemp(join(tmpdir(), 'sp-env-proxy-'));
t.after(() => rm(dir, { recursive: true, force: true }));
const saved = STRIP.map((key) => [key, process.env[key]]);
for (const key of STRIP) delete process.env[key];
process.env.HTTP_PROXY = 'http://127.0.0.1:9';
process.env.HTTPS_PROXY = process.env.HTTP_PROXY;
try {
const plan = envProxyPlan();
const bare = new Downloader({
root: dir, ledgerPath: join(dir, 'ledger.json'), retries: 1, backoffMs: 0, log() {},
});
if (plan.action !== 'already') {
const refused = await bare.fetchWithRetries('https://example.invalid/a.json', 'json');
assert.match(refused.error, /Refusing to fetch directly/);
await assert.rejects(guardDefaultFetch()('https://example.invalid/a.json'), /Refusing to fetch directly/);
// Set after startup. Node has already decided not to proxy, so this must still refuse.
process.env.NODE_USE_ENV_PROXY = '1';
const late = await bare.fetchWithRetries('https://example.invalid/b.json', 'json');
assert.match(late.error, /not started with NODE_USE_ENV_PROXY=1/);
delete process.env.NODE_USE_ENV_PROXY;
await assert.rejects(
cachedJson({
cacheFile: join(dir, 'missing.json'), url: 'https://example.invalid/index.json',
log() {}, backoffMs: 0, timeoutMs: 1000,
}),
/Refusing to fetch directly/,
);
}
let called = 0;
const injected = new Downloader({
root: dir, ledgerPath: join(dir, 'ledger.json'), retries: 1, backoffMs: 0, log() {},
fetchImpl: async () => { called += 1; return Response.json({ ok: true }); },
});
const got = await injected.fetchWithRetries('https://example.invalid/a.json', 'json');
assert.equal(called, 1);
assert.equal(got.buf.toString(), '{"ok":true}');
assert.deepEqual(await cachedJson({
cacheFile: join(dir, 'ok.json'), url: 'https://example.invalid/index.json', log() {}, backoffMs: 0,
fetchImpl: async () => Response.json({ ok: true }),
}), { ok: true });
} finally {
for (const [key, value] of saved) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
}
});
test('restart decision: nothing to do, one spawn, or a hard stop — and the marker cannot loop', () => {
const prev = process.exitCode;
const spawned = [];
const logs = [];
const child = new EventEmitter();
const spawnImpl = (...args) => { spawned.push(args); return child; };
try {
assert.equal(restartForEnvProxy({
env: cleanEnv(), spawnImpl, error: (line) => logs.push(line),
}), false);
assert.equal(spawned.length, 0);
const proxyEnv = cleanEnv({ HTTP_PROXY: 'http://proxy.example:8080', KEEP: 'yes' });
const stopped = restartForEnvProxy({
env: proxyEnv,
execPath: process.execPath,
execArgv: ['--no-warnings'],
argv: ['node', SCRIPT, '--help'],
spawnImpl,
error: (line) => logs.push(line),
});
if (!nodeHonoursEnvProxy(process.versions.node)) {
assert.equal(stopped, false, 'an old Node goes on in this process (direct download)');
assert.equal(spawned.length, 0);
assert.match(logs.join('\n'), /does not route fetch/);
return;
}
assert.equal(stopped, true);
assert.equal(spawned.length, 1);
const [bin, args, opts] = spawned[0];
assert.equal(bin, process.execPath);
assert.deepEqual(args, ['--no-warnings', SCRIPT, '--help']);
assert.equal(opts.stdio, 'inherit');
assert.equal(opts.env.NODE_USE_ENV_PROXY, '1');
assert.equal(opts.env[REEXEC_MARKER], '1');
assert.equal(opts.env.KEEP, 'yes');
assert.equal(opts.env.HTTP_PROXY, 'http://proxy.example:8080');
assert.match(logs.join('\n'), /restarting once with NODE_USE_ENV_PROXY=1/);
child.emit('close', 0);
spawned.length = 0;
logs.length = 0;
assert.equal(restartForEnvProxy({
env: cleanEnv({ HTTP_PROXY: 'http://proxy.example:8080', [REEXEC_MARKER]: '1' }),
spawnImpl,
error: (line) => logs.push(line),
}), true);
assert.equal(spawned.length, 0);
assert.match(logs.join('\n'), /refused to restart again/);
} finally {
process.exitCode = prev;
}
});
function runHelp(extra) {
return spawnSync(process.execPath, [SCRIPT, '--help'], {
encoding: 'utf8', env: cleanEnv(extra), timeout: 20000,
});
}
test('fetch-assets --help restarts once when HTTP(S)_PROXY is set, and a second restart is refused', () => {
const supported = nodeHonoursEnvProxy(process.versions.node);
const first = runHelp({ HTTP_PROXY: PROXY, HTTPS_PROXY: PROXY });
if (!supported) {
assert.equal(first.status, 1, first.stderr);
assert.match(first.stderr, /does not route fetch/);
assert.equal(first.stdout, '');
return;
}
assert.equal(first.status, 0, first.stderr);
assert.match(first.stdout, /--allow-shrink/);
assert.equal((first.stderr.match(/restarting once with NODE_USE_ENV_PROXY=1/g) || []).length, 1, first.stderr);
const already = runHelp({ HTTP_PROXY: PROXY, NODE_USE_ENV_PROXY: '1' });
assert.equal(already.status, 0, already.stderr);
assert.match(already.stdout, /--allow-shrink/);
assert.doesNotMatch(already.stderr, /restarting once/);
const loop = runHelp({ HTTP_PROXY: PROXY, [REEXEC_MARKER]: '1' });
assert.equal(loop.status, 1, loop.stderr);
assert.match(loop.stderr, /refused to restart again/);
assert.equal(loop.stdout, '');
});
function listen(server) {
return new Promise((resolve, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', () => resolve(server.address().port));
});
}
test('built-in fetch tunnels through HTTP_PROXY only when NODE_USE_ENV_PROXY was set at startup', { timeout: 30000 }, async (t) => {
if (!nodeHonoursEnvProxy(process.versions.node)) return;
const hits = [];
const sockets = new Set();
const proxy = createServer((req, res) => {
hits.push(`REQ ${req.method}`);
res.writeHead(500);
res.end();
});
proxy.on('connect', (req, client, head) => {
hits.push(`CONNECT ${req.url}`);
const cut = req.url.lastIndexOf(':');
const upstream = netConnect(Number(req.url.slice(cut + 1)), req.url.slice(0, cut), () => {
client.write('HTTP/1.1 200 Connection Established\r\n\r\n');
if (head.length) upstream.write(head);
upstream.pipe(client);
client.pipe(upstream);
});
sockets.add(upstream);
upstream.on('error', () => client.destroy());
client.on('error', () => upstream.destroy());
});
proxy.on('connection', (socket) => {
sockets.add(socket);
socket.on('close', () => sockets.delete(socket));
});
const target = createServer((req, res) => {
hits.push(`TARGET ${req.url}`);
res.end('from-target');
});
target.on('connection', (socket) => {
sockets.add(socket);
socket.on('close', () => sockets.delete(socket));
});
const proxyPort = await listen(proxy);
const targetPort = await listen(target);
t.after(() => {
for (const socket of sockets) socket.destroy();
proxy.close();
target.close();
});
const targetUrl = `http://127.0.0.1:${targetPort}/x`;
const proxyUrl = `http://127.0.0.1:${proxyPort}`;
const snippet = `
const res = await fetch(process.env.TARGET, { signal: AbortSignal.timeout(2500) });
console.log(JSON.stringify({ status: res.status, body: await res.text() }));
`;
// spawn, not spawnSync: a synchronous child would freeze this server, and fetch would time out.
const run = (extra) => new Promise((resolve, reject) => {
hits.length = 0;
const child = spawn(process.execPath, ['--input-type=module', '-e', snippet], {
env: cleanEnv({ TARGET: targetUrl, ...extra }), stdio: ['ignore', 'pipe', 'pipe'],
});
let stdout = '';
let stderr = '';
const timer = setTimeout(() => { child.kill(); reject(new Error(`fetch child hung\n${stderr}`)); }, 8000);
child.stdout.on('data', (chunk) => { stdout += chunk; });
child.stderr.on('data', (chunk) => { stderr += chunk; });
child.on('error', (e) => { clearTimeout(timer); reject(e); });
child.on('close', (code) => {
clearTimeout(timer);
if (code !== 0) { reject(new Error(stderr || `exit ${code}`)); return; }
const body = JSON.parse(stdout.trim().split('\n').pop());
resolve({ body, hits: [...hits] });
});
});
const direct = await run({});
assert.deepEqual(direct.body, { status: 200, body: 'from-target' });
assert.deepEqual(direct.hits, ['TARGET /x']);
const ignored = await run({ HTTP_PROXY: proxyUrl, HTTPS_PROXY: proxyUrl });
assert.deepEqual(ignored.body, { status: 200, body: 'from-target' });
assert.deepEqual(ignored.hits, ['TARGET /x'], 'a proxy set without the startup switch is ignored by fetch()');
const tunneled = await run({ http_proxy: proxyUrl, NODE_USE_ENV_PROXY: '1' });
assert.deepEqual(tunneled.body, { status: 200, body: 'from-target' });
assert.deepEqual(tunneled.hits, [`CONNECT 127.0.0.1:${targetPort}`, 'TARGET /x']);
const both = await run({ HTTP_PROXY: proxyUrl, HTTPS_PROXY: proxyUrl, NODE_USE_ENV_PROXY: '1' });
assert.equal(both.body.body, 'from-target');
assert.deepEqual(both.hits, [`CONNECT 127.0.0.1:${targetPort}`, 'TARGET /x']);
const bypass = await run({ HTTP_PROXY: proxyUrl, HTTPS_PROXY: proxyUrl, NO_PROXY: '127.0.0.1', NODE_USE_ENV_PROXY: '1' });
assert.equal(bypass.body.body, 'from-target');
assert.deepEqual(bypass.hits, ['TARGET /x']);
});
test('an old Node with HTTP(S)_PROXY set warns and downloads directly instead of stopping (0.2.2: a stray proxy variable must not break setup)', () => {
const prev = process.exitCode;
const spawned = [];
const logs = [];
try {
const goOn = restartForEnvProxy({
env: cleanEnv({ HTTPS_PROXY: 'http://proxy.example:8080' }),
version: '22.20.0',
spawnImpl: (...args) => { spawned.push(args); return new EventEmitter(); },
error: (line) => logs.push(line),
});
assert.equal(goOn, false, 'the caller continues in this process');
assert.equal(spawned.length, 0, 'no restart: this Node cannot proxy fetch()');
assert.notEqual(process.exitCode, 1, 'no failure exit code');
assert.match(logs.join('\n'), /Node 22\.20\.0 does not route fetch\(\) through it .*downloading directly instead/);
} finally {
process.exitCode = prev;
}
});
+40
View File
@@ -443,10 +443,12 @@ describe('audio banks and plan id sets', () => {
const r = resolveTemplate(tpl, { root: ROOT, spine: new Map([['k', { skel: '/assets/s.skel', atlas: '/assets/s.atlas', textures: [] }]]) });
assert.deepEqual(Object.keys(r.value).sort(), ['keep', 'm']);
assert.deepEqual(r.misses.sort(), ['a', 'b.c']);
assert.deepEqual(r.droppedLeaves, ['a', 'b.c'], 'a leaf no alternative of which is on disk is a dropped leaf');
assert.equal(collectLeaves(tpl).length, 2);
const ok = resolveTemplate({ p: { alts: [{ rel: 'nope.png', urls: ['x'] }, { rel: 'package.json', urls: ['y'] }] } }, { root: ROOT, spine: new Map() });
assert.equal(ok.value.p, '/assets/package.json');
assert.equal(ok.fallbacks.length, 1);
assert.deepEqual(ok.droppedLeaves, [], 'a fallback on disk is not a drop');
});
test('an array whose lines are all missing is dropped, with the entry it empties (a voice slot never downloaded)', () => {
@@ -462,6 +464,44 @@ describe('audio banks and plan id sets', () => {
assert.deepEqual(r.value.voice, { b: { select: ['/assets/package.json'] } }, 'operator a has no line on disk: no entry at all');
assert.deepEqual(r.value.empty, [], 'an array that was empty in the template stays');
assert.deepEqual(r.misses.sort(), ['voice.a.place[0]', 'voice.a.select[0]', 'voice.a.select[1]', 'voice.b.select[0]']);
assert.deepEqual(r.droppedLeaves.sort(), r.misses.slice().sort(), 'every dropped line is a dropped leaf too');
});
// A unit's attack sound is a leaf like any other: when its file was never downloaded (or a checkout lost it) the whole
// key vanishes from data/assets.json and the client plays nothing — there is no fallback and no URL to retry. The
// resolution therefore reports it as a dropped leaf, which the run prints and --strict fails on
// (tools/fetch-assets.mjs; docs/ASSETS.md "A dropped leaf is reported, never silent").
test('move a resolved sound aside: the leaf leaves the manifest and is reported as dropped, never silently', async (t) => {
const { mkdtemp, mkdir, writeFile, rm } = await import('node:fs/promises');
const { tmpdir } = await import('node:os');
const root = await mkdtemp(join(tmpdir(), 'sp-leaf-drop-'));
t.after(() => rm(root, { recursive: true, force: true }));
const rel = 'audio/sfx/player/p_atk/p_atk_somechar_n.mp3';
const imp = 'audio/sfx/player/p_imp/p_imp_somechar_n.mp3';
const ui = 'audio/sfx/general/g_ui/g_ui_btn_h.mp3';
const leaf = (r) => ({ alts: [{ rel: r, urls: [`https://raw.githubusercontent.com/o/r/main/${r}`], kind: 'mp3' }] });
const tpl = { audio: { sfx: { units: { char_x: { attack: leaf(rel), hit: leaf(imp) } }, ui: { click: leaf(ui) } } } };
for (const r of [rel, imp, ui]) {
await mkdir(join(root, dirname(r)), { recursive: true });
await writeFile(join(root, r), 'ID3');
}
const before = resolveTemplate(tpl, { root, spine: new Map() });
assert.equal(JSON.stringify(before.value), JSON.stringify({
audio: {
sfx: {
units: { char_x: { attack: `/assets/${rel}`, hit: `/assets/${imp}` } },
ui: { click: `/assets/${ui}` },
},
},
}), 'every file on disk: the manifest is exactly the plan, byte for byte');
assert.deepEqual([before.misses, before.droppedLeaves, before.fallbacks], [[], [], []]);
await rm(join(root, rel)); // the download never happened / the file went missing
const after = resolveTemplate(tpl, { root, spine: new Map() });
assert.equal(Object.hasOwn(after.value.audio.sfx.units.char_x, 'attack'), false, 'the key is gone from the manifest');
assert.equal(after.value.audio.sfx.units.char_x.hit, `/assets/${imp}`, 'the roles whose file is on disk stay');
assert.deepEqual(after.droppedLeaves, ['audio.sfx.units.char_x.attack'], 'and the drop is reported, not silent');
assert.ok(after.misses.includes('audio.sfx.units.char_x.attack'), 'the wider miss list names it too');
});
});
+6 -6
View File
@@ -1,7 +1,7 @@
// test/backups.test.js — the data of 补位 (stand-ins) and 自选 (DIY slots): chess.json `backup` and data/backups.json
// (docs/DATA.md §18; tools/build-data.mjs buildBackups; shared/standIn.js, shared/diy.js). These tests pin the data the
// gameplay reads: the 133 base chess and their types, the 17 stand-in characters and their forms, every NORMAL chess
// resolving to its stand-in, the 4 DIY slots, the legal picks (the 71 owned 6★ with their forms and summons, the
// resolving to its stand-in, the 4 DIY slots, the legal picks (the 72 owned 6★ with their forms and summons, the
// prototypes and their locked selections) and the faction bonds.
// With the official-data cache (.cache/gamedata) the backup fields, the stand-in numbers and the bond derivation are
// re-derived from the raw tables.
@@ -237,9 +237,9 @@ test('DIY: prototype picks — the 9 elites at tiers 5 and 6, six 4★ reserves
assert.equal(diyRecordOf(chess.chess_char_5_01_a, { charId: 'char_608_acpion' }, data), null, 'not a DIY slot');
});
test('DIY: the 71 owned 6★ picks — a form at every slot status with all three skills, every module at stage 1 and 3, their summons', () => {
test('DIY: the 72 owned 6★ picks — a form at every slot status with all three skills, every module at stage 1 and 3, their summons', () => {
const { ownedPool } = backups.diy;
assert.equal(ownedPool.length, 71);
assert.equal(ownedPool.length, 72);
let summoners = 0;
for (const id of ownedPool) {
const u = backups.units[id];
@@ -311,7 +311,7 @@ test('DIY: prototype picks carry the skill / module of their 补位 rows at the
test('DIY: the owned-6★ pool and the faction → bond rule (mainPower + every subPower vs powerIdList, else 协防干员)', () => {
const { ownedPool, operators, prototypes } = backups.diy;
const roster = new Set(base.map((c) => c.charId).filter(Boolean));
assert.equal(ownedPool.length, 71);
assert.equal(ownedPool.length, 72);
for (const id of ownedPool) {
assert.ok(!roster.has(id), `${id}: a roster operator is never a pick`);
assert.deepEqual([operators[id].rarity, operators[id].obtainable], [6, true], id);
@@ -325,7 +325,7 @@ test('DIY: the owned-6★ pool and the faction → bond rule (mainPower + every
}
const tally = {};
for (const id of ownedPool) { const k = operators[id].bonds.join('+'); tally[k] = (tally[k] || 0) + 1; }
assert.deepEqual(tally, { emptyShip: 38, yanShip: 14, victoriaShip: 8, sargonShip: 4, siracusaShip: 4, lateranoShip: 1, kazimierzShip: 1, 'yanShip+victoriaShip': 1 });
assert.deepEqual(tally, { emptyShip: 38, yanShip: 14, victoriaShip: 8, sargonShip: 4, siracusaShip: 4, lateranoShip: 1, kazimierzShip: 1, egirShip: 1, 'yanShip+victoriaShip': 1 });
// the collab operators are out of the data and the pool (the owner's decision of 2026-10-05: copyright)
assert.deepEqual(backups.diy.excluded.map((id) => [id, backups.units[id], operators[id]]),
['char_456_ash', 'char_1029_yato2', 'char_1048_orchd2', 'char_4123_ela', 'char_4141_marcil', 'char_4182_oblvns', 'char_4217_makoto'].map((id) => [id, undefined, undefined]),
@@ -341,7 +341,7 @@ test('DIY: powers and the owned pool re-derived from character_table', { skip: !
const collab = (c) => [c.mainPower, ...(c.subPower || [])].some((p) => ['rainbow', 'action4', 'mujica', 'sees', 'laios'].includes(p?.teamId))
|| /^(?:MH|RS|AM|PS|DD)\d/.test(c.displayNumber || '');
const legal = Object.entries(CT).filter(([id, c]) => id.startsWith('char_') && c.rarity === 'TIER_6' && !['TOKEN', 'TRAP'].includes(c.profession) && !c.isNotObtainable && !roster.has(id));
assert.equal(legal.length, 78, 'the excel\'s 78 obtainable 6★ outside the chess pool');
assert.equal(legal.length, 79, 'the excel\'s 79 obtainable 6★ outside the chess pool');
assert.deepEqual([...backups.diy.ownedPool].sort(), legal.filter(([, c]) => !collab(c)).map(([id]) => id).sort());
assert.deepEqual([...backups.diy.excluded].sort(), legal.filter(([, c]) => collab(c)).map(([id]) => id).sort());
for (const [id, o] of Object.entries(backups.diy.operators)) {
+153
View File
@@ -0,0 +1,153 @@
// Issue #344: a failed --refresh must not feed an unverified stale table into the build.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync } from 'node:fs';
import { mkdir, mkdtemp, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { spawnSync } from 'node:child_process';
const SCRIPT = fileURLToPath(new URL('../tools/build-data.mjs', import.meta.url));
const source = await readFile(SCRIPT, 'utf8');
// Exercise the real downloader without running the CLI or needing full official game tables.
const begin = source.indexOf('async function ensureGamedata(');
const end = source.indexOf('/** Load (and cache in memory)', begin);
assert.ok(begin >= 0 && end > begin, 'locate the official-data downloader');
const downloaderSource = source.slice(begin, end);
const TABLES = ['excel/character_table.json', 'excel/skill_table.json'];
const OLD = '{"revision":"old"}';
const NEW = '{"revision":"new"}';
async function fixture(t) {
const dir = await mkdtemp(join(tmpdir(), 'sp-data-refresh-'));
t.after(() => rm(dir, { recursive: true, force: true }));
const cache = join(dir, 'cache');
await mkdir(join(cache, 'excel'), { recursive: true });
for (const rel of TABLES) await writeFile(join(cache, rel), OLD);
return { dir, cache };
}
function downloader(cache, fetchImpl, opts = { refresh: true }) {
const requests = [], warnings = [];
const env = {
CACHE_DIR: cache, OPTS: { refresh: false, offline: false, ...opts },
GAMEDATA_URL: 'https://example.invalid/gamedata/',
existsSync, mkdir, dirname, join, writeFile, rename, process, AbortSignal,
log: () => {}, warn: (message) => warnings.push(message),
// Skip only retry delays; all attempts, JSON validation and file writes are real.
setTimeout: (callback) => callback(),
fetch: async (url) => { requests.push(url); return fetchImpl(url); },
};
const ensure = new Function(...Object.keys(env), `${downloaderSource}\nreturn ensureGamedata;`)(...Object.values(env));
return { ensure, requests, warnings };
}
test('refresh rejects mixed fresh/stale inputs after one table exhausts its retries', async (t) => {
const { cache } = await fixture(t);
const { ensure, requests, warnings } = downloader(cache, async (url) => {
if (url.endsWith('skill_table.json')) throw new Error('simulated outage');
return new Response(NEW);
});
const results = await Promise.allSettled(TABLES.map(ensure));
assert.equal(results[0].status, 'fulfilled');
assert.equal(results[1].status, 'rejected', 'an old cache cannot satisfy a refresh');
assert.match(results[1].reason.message, /cannot obtain excel\/skill_table.json: simulated outage/);
assert.equal(requests.filter((url) => url.endsWith('skill_table.json')).length, 4);
assert.equal(await readFile(join(cache, TABLES[0]), 'utf8'), NEW);
assert.equal(await readFile(join(cache, TABLES[1]), 'utf8'), OLD);
assert.deepEqual(warnings, [], 'download failure is fatal, not a stale-cache warning');
});
for (const [name, response] of [
['HTTP failure', () => new Response('', { status: 503 })],
['invalid JSON', () => new Response('{truncated')],
]) {
test(`refresh rejects ${name} even with an existing cache`, async (t) => {
const { cache } = await fixture(t);
const { ensure, requests } = downloader(cache, response);
await assert.rejects(ensure(TABLES[1]), /cannot obtain excel\/skill_table.json:/);
assert.equal(requests.length, 4);
assert.equal(await readFile(join(cache, TABLES[1]), 'utf8'), OLD);
});
}
test('refresh without a cached table reports the download failure', async (t) => {
const { cache } = await fixture(t);
await rm(join(cache, TABLES[1]));
const { ensure, requests } = downloader(cache, () => { throw new Error('simulated outage'); });
await assert.rejects(ensure(TABLES[1]), /cannot obtain excel\/skill_table.json: simulated outage/);
assert.equal(requests.length, 4);
assert.equal(existsSync(join(cache, TABLES[1])), false);
});
test('successful refresh replaces every cached table', async (t) => {
const { cache } = await fixture(t);
const { ensure, requests, warnings } = downloader(cache, () => new Response(NEW));
const files = await Promise.all(TABLES.map(ensure));
assert.deepEqual(await Promise.all(files.map((file) => readFile(file, 'utf8'))), [NEW, NEW]);
assert.equal(requests.length, 2);
assert.deepEqual(warnings, []);
});
test('refresh can recover on the final retry', async (t) => {
const { cache } = await fixture(t);
let attempts = 0;
const { ensure, warnings } = downloader(cache, () => {
if (++attempts < 4) throw new Error('temporary outage');
return new Response(NEW);
});
assert.equal(await readFile(await ensure(TABLES[1]), 'utf8'), NEW);
assert.equal(attempts, 4);
assert.deepEqual(warnings, []);
});
for (const offline of [false, true]) {
test(`${offline ? 'offline' : 'normal'} mode reuses cached tables without a request`, async (t) => {
const { cache } = await fixture(t);
const { ensure, requests } = downloader(cache, () => { throw new Error('unexpected request'); }, { offline });
assert.equal(await readFile(await ensure(TABLES[1]), 'utf8'), OLD);
assert.deepEqual(requests, []);
});
}
test('offline mode rejects a missing table without a request', async (t) => {
const { cache } = await fixture(t);
await rm(join(cache, TABLES[1]));
const { ensure, requests } = downloader(cache, () => { throw new Error('unexpected request'); }, { offline: true });
await assert.rejects(ensure(TABLES[1]), /missing cached file excel\/skill_table.json \(offline mode\)/);
assert.deepEqual(requests, []);
});
for (const force of [false, true]) {
test(`CLI refresh failure leaves generated files untouched${force ? ' even with --force' : ''}`, async (t) => {
const { dir, cache } = await fixture(t);
const out = join(dir, 'data');
const report = join(dir, 'report.json');
await mkdir(out);
for (const name of ['chess.json', 'config.json']) await writeFile(join(out, name), OLD);
await writeFile(report, OLD);
const preload = join(dir, 'mock-fetch.mjs');
await writeFile(preload, `
globalThis.fetch = async (url) => {
if (url.endsWith('/skill_table.json')) throw new Error('simulated outage');
return new Response(${JSON.stringify(NEW)});
};
`);
const result = spawnSync(process.execPath, [
'--import', pathToFileURL(preload).href, SCRIPT,
'--refresh', '--cache', cache, '--out', out, '--report', report,
...(force ? ['--force'] : []),
], { encoding: 'utf8', timeout: 30_000 });
assert.equal(result.error, undefined);
assert.equal(result.status, 1, result.stderr);
// A schema error from these tiny fixtures would also exit 1: require the actual download error.
assert.match(result.stderr, /cannot obtain excel\/skill_table.json: simulated outage/);
assert.doesNotMatch(result.stdout + result.stderr, /using stale cache|building…/);
assert.equal(await readFile(join(cache, TABLES[0]), 'utf8'), NEW, 'another download succeeded');
assert.equal(await readFile(join(cache, TABLES[1]), 'utf8'), OLD);
assert.deepEqual((await readdir(out)).sort(), ['chess.json', 'config.json']);
for (const name of ['chess.json', 'config.json']) assert.equal(await readFile(join(out, name), 'utf8'), OLD);
assert.equal(await readFile(report, 'utf8'), OLD);
});
}
+49 -17
View File
@@ -6,26 +6,56 @@ import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { computeBuildTag, buildTag, resetBuildTag, BUILD_INPUTS } from '../server/index.js';
import { computeBuildTag, buildTag, resetBuildTag } from '../server/index.js';
import { checkBuildOnce } from '../public/js/ui/buildGuard.js';
/** A throwaway root with the browser runtime layout (one file per BUILD_INPUTS entry). */
/** A throwaway browser runtime, independent of the build tag's input list. */
function fixture() {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'sp-build-'));
for (const rel of BUILD_INPUTS) {
for (const rel of [
'public/index.html', 'public/js/file.js', 'public/css/file.css',
'server/sim/content/kits/skill.js', 'shared/protocol.js', 'data/chess.json',
]) {
const abs = path.join(root, rel);
if (path.extname(rel)) {
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, 'x');
} else {
fs.mkdirSync(abs, { recursive: true });
fs.writeFileSync(path.join(abs, 'file.js'), 'x');
}
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, 'x');
}
return root;
}
test('BUILD_INPUTS: only the runtime the browser loads (server/, data/ and shared/ are not part of it)', () => {
assert.deepEqual([...BUILD_INPUTS], ['public/index.html', 'public/js', 'public/css']);
for (const rel of ['server/sim/content/kits/skill.js', 'shared/protocol.js', 'data/chess.json']) {
test(`buildTag: an update to ${rel} is visible to an old page after restart only`, async () => {
const root = fixture();
try {
resetBuildTag();
const known = buildTag(root);
const fetchFn = async () => ({ ok: true, json: async () => ({ build: buildTag(root) }) });
fs.writeFileSync(path.join(root, rel), 'updated runtime');
assert.notEqual(computeBuildTag(root), known, 'the updated file changes the startup fingerprint');
assert.equal(buildTag(root), known, 'the running process keeps its startup tag');
assert.equal((await checkBuildOnce({ known, fetchFn })).status, 'current');
resetBuildTag(); // The next process computes its tag from the deployed files.
assert.equal((await checkBuildOnce({ known, fetchFn })).status, 'new');
} finally {
resetBuildTag();
fs.rmSync(root, { recursive: true, force: true });
}
});
}
test('computeBuildTag: server-only code and downloaded media do not change the browser build', () => {
const root = fixture();
try {
const known = computeBuildTag(root);
for (const rel of ['server/index.js', 'server/http/routes.js', 'server/data.js', 'public/assets/sprite.png']) {
const abs = path.join(root, rel);
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, 'changed');
}
assert.equal(computeBuildTag(root), known);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
test('computeBuildTag: stable for one tree, different when a runtime file changes (size or mtime)', () => {
@@ -39,7 +69,7 @@ test('computeBuildTag: stable for one tree, different when a runtime file change
const b = computeBuildTag(root);
assert.notEqual(b, a, 'a changed runtime file is a new build');
// …and so is a rewrite with the same size but a new mtime (a deploy of identical bytes keeps its timestamp)
const target = path.join(root, 'public/css/file.js');
const target = path.join(root, 'public/css/file.css');
const st = fs.statSync(target);
fs.utimesSync(target, st.atime, new Date(st.mtimeMs + 5000));
assert.notEqual(computeBuildTag(root), b);
@@ -52,10 +82,12 @@ test('computeBuildTag: dot files and editor backups are ignored (the static serv
const root = fixture();
try {
const a = computeBuildTag(root);
fs.writeFileSync(path.join(root, 'public/js/.DS_Store'), 'x');
fs.writeFileSync(path.join(root, 'public/css/main.css~'), 'x');
fs.mkdirSync(path.join(root, 'public/js/.cache'), { recursive: true });
fs.writeFileSync(path.join(root, 'public/js/.cache/leftover.js'), 'x');
for (const rel of ['public/js', 'server/sim', 'shared', 'data']) {
fs.writeFileSync(path.join(root, rel, '.DS_Store'), 'x');
fs.writeFileSync(path.join(root, rel, 'file.js~'), 'x');
fs.mkdirSync(path.join(root, rel, '.cache'), { recursive: true });
fs.writeFileSync(path.join(root, rel, '.cache/leftover.js'), 'x');
}
assert.equal(computeBuildTag(root), a, 'a .DS_Store / backup / dot directory in the tree is not a new build');
} finally {
fs.rmSync(root, { recursive: true, force: true });
+149
View File
@@ -0,0 +1,149 @@
// test/ci.test.js — tools/ci.mjs (the local run of .github/workflows/ci.yml): its step list is ci.yml's, the reader of
// the workflow, the arguments, the env and the summary. Nothing here boots a server or runs a step.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import {
CI_TEST_ENV, ROOT, SKIPPED_CI_STEPS, SMOKE_PATHS, STEPS, binPath, formatDuration, formatSummary, isSkippedCiStep,
parseArgs, parseWorkflow, selectSteps, stepEnv,
} from '../tools/ci.mjs';
const workflow = parseWorkflow(fs.readFileSync(path.join(ROOT, '.github/workflows/ci.yml'), 'utf8'));
const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8'));
/** ci.yml's smoke run block as smoke() in tools/ci.mjs mirrors it (lines trimmed, comments dropped). */
const SMOKE_CI_RUN = [
'node server/index.js > server.log 2>&1 &',
'pid=$!',
'ok=',
'for i in $(seq 1 30); do',
'if curl -fsS http://127.0.0.1:3000/healthz; then ok=1; break; fi',
'sleep 1',
'done',
'echo',
'[ -n "$ok" ] || { cat server.log; exit 1; }',
'curl -fsS -o index.out http://127.0.0.1:3000/',
"grep -qi '<html' index.out",
'curl -fsS -o /dev/null http://127.0.0.1:3000/vendor/pixi.min.js',
'node tools/doctor.mjs || true',
'kill $pid || true',
];
test('every ci.yml step is mirrored by a tools/ci.mjs step or skipped on purpose, and none is invented', () => {
const ciSteps = Object.entries(workflow.jobs).flatMap(([job, j]) => j.steps.map((s) => ({ job, ...s })));
assert.ok(ciSteps.length >= 6, 'ci.yml was read');
const unknown = ciSteps.filter((s) => !isSkippedCiStep(s) && !STEPS.some((l) => l.ci.job === s.job && l.ci.name === s.name));
assert.deepEqual(unknown.map((s) => `${s.job}: ${s.name || s.uses}`), [], 'ci.yml steps tools/ci.mjs does not run');
for (const l of STEPS) {
assert.ok(ciSteps.some((s) => s.job === l.ci.job && s.name === l.ci.name), `${l.id} mirrors no ci.yml step (${l.ci.job}: ${l.ci.name})`);
}
for (const k of SKIPPED_CI_STEPS) assert.ok(ciSteps.some((s) => isSkippedCiStep(s) && (k.uses ? s.uses?.startsWith(k.uses) : s.name === k.name)), `stale skip ${k.uses || k.name}`);
// the local order is the workflow's (job test, then job tooling)
const order = ciSteps.filter((s) => !isSkippedCiStep(s)).map((s) => STEPS.find((l) => l.ci.job === s.job && l.ci.name === s.name).id);
assert.deepEqual(order, STEPS.map((s) => s.id));
});
test('the commands match ci.yml: the run lines, the npm scripts ci.yml calls, the smoke script, the job env, no step config', () => {
const run = (job, name) => workflow.jobs[job].steps.find((s) => s.name === name).run;
for (const l of STEPS) {
const r = run(l.ci.job, l.ci.name);
if (l.npm) {
assert.equal(r, `npm run ${l.npm}`, l.id);
assert.equal(pkg.scripts[l.npm], l.describe, `package.json script ${l.npm} is what ${l.id} runs`);
} else if (l.id !== 'smoke') assert.equal(r, l.describe, l.id);
}
const smokeRun = run('test', STEPS.find((s) => s.id === 'smoke').ci.name);
const urls = [...smokeRun.matchAll(/http:\/\/127\.0\.0\.1:3000([^\s;]*)/g)].map((m) => m[1] || '/');
assert.deepEqual([...new Set(urls)].sort(), [...SMOKE_PATHS].sort());
// The whole smoke script, comments and indentation aside: any edit to it (the wait loop, the page check, …) fails
// here, so that smoke() in tools/ci.mjs is looked at and this copy updated with it.
const normalized = smokeRun.split('\n').map((l) => l.trim()).filter((l) => l && !l.startsWith('#'));
assert.deepEqual(normalized, SMOKE_CI_RUN, "ci.yml's smoke step changed: update smoke() in tools/ci.mjs and SMOKE_CI_RUN");
// job env: ci.yml's job test env is CI_TEST_ENV; any other job has none (stepEnv gives the tooling steps nothing)
for (const [job, j] of Object.entries(workflow.jobs)) {
assert.deepEqual(j.env, job === 'test' ? CI_TEST_ENV : {}, `env of job ${job}`);
}
// per-step config (env, if, continue-on-error, working-directory, with, …) on a mirrored step is not mirrored
for (const l of STEPS) {
const s = workflow.jobs[l.ci.job].steps.find((x) => x.name === l.ci.name);
assert.deepEqual(s.other || [], [], `${l.id}: ci.yml step keys tools/ci.mjs does not mirror`);
assert.ok(!s.shell || l.id === 'smoke', `${l.id}: shell: ${s.shell}`);
}
assert.equal(pkg.scripts.ci, 'node tools/ci.mjs');
});
test('the workflow reader: jobs, flat env, named and uses steps, run blocks; comments and nested blocks skipped, other step keys named', () => {
const yml = [
'name: X', 'on:', ' push:', 'jobs:', ' a:', ' runs-on: x', ' env:', " K: '1'", ' L: "two"', ' steps:',
' - uses: actions/checkout@v7', ' - uses: actions/setup-node@v7', ' with:', ' node-version: 22',
' # a comment', ' - name: One', ' if: always()', ' run: echo 1', ' env:', ' X: 1',
' - name: Block', ' shell: bash', ' run: |',
' a', '', ' b', ' c', ' b:', ' steps:', ' - run: x', 'other: 1', ' ignored: 2',
].join('\r\n');
assert.deepEqual(parseWorkflow(yml), {
jobs: {
a: { env: { K: '1', L: 'two' }, steps: [
{ uses: 'actions/checkout@v7' }, { uses: 'actions/setup-node@v7', other: ['with'] }, { name: 'One', run: 'echo 1', other: ['if', 'env'] },
{ name: 'Block', shell: 'bash', run: 'a\n\n b\nc' },
] },
b: { env: {}, steps: [{ run: 'x' }] },
},
});
assert.deepEqual(parseWorkflow('name: no jobs\n'), { jobs: {} });
});
test('arguments: --keep-going, --only (comma list, repeated, = form) in workflow order, --list, --help; bad ones throw', () => {
assert.deepEqual(parseArgs([]), { keepGoing: false, only: null, list: false, help: false });
assert.deepEqual(parseArgs(['-k', '--only', 'lint,test', '--only=smoke']), { keepGoing: true, only: ['lint', 'test', 'smoke'], list: false, help: false });
assert.deepEqual(selectSteps(['lint', 'test', 'smoke']).map((s) => s.id), ['test', 'smoke', 'lint']);
assert.equal(selectSteps(null).length, STEPS.length);
assert.equal(parseArgs(['--list']).list, true);
assert.equal(parseArgs(['-h']).help, true);
assert.throws(() => parseArgs(['--only', 'golden']), /unknown step "golden"/);
assert.throws(() => parseArgs(['--only']), /needs step names/);
assert.throws(() => parseArgs(['--fast']), /unknown option --fast/);
});
test("env: the job test steps get ci.yml's env for unset keys only; the tooling steps get the caller's env as is", () => {
const test_ = STEPS.find((s) => s.id === 'test');
const lint = STEPS.find((s) => s.id === 'lint');
assert.deepEqual(stepEnv(test_, { PATH: 'p', SP_E2E: '1' }), { PATH: 'p', SP_E2E: '1', SP_REAL_E2E: '0', RENDER_E2E: '0', NO_COLOR: '1' });
assert.deepEqual(stepEnv(lint, { PATH: 'p' }), { PATH: 'p' });
});
test('summary: one row per step (status, name, duration, note), the failed steps named; durations', () => {
assert.equal(formatDuration(42.4), '42ms');
assert.equal(formatDuration(1234), '1.2s');
assert.equal(formatDuration(125_000), '2m05s');
const s = formatSummary([
{ id: 'setup', status: 'ok', ms: 400 },
{ id: 'test', status: 'fail', ms: 61_000, note: 'exit 1' },
{ id: 'typecheck', status: 'not run' },
]);
assert.equal(s, [
'', '── ci summary ──',
' ok setup 400ms',
' FAIL test 1m01s exit 1',
' - typecheck',
'FAILED: test', '',
].join('\n'));
assert.match(formatSummary([{ id: 'lint', status: 'ok', ms: 1 }]), /all steps passed/);
});
test('the eslint and tsc entry points come from their package.json bin; a missing package says to run npm ci', () => {
assert.ok(fs.existsSync(binPath('eslint', 'eslint')));
assert.ok(fs.existsSync(binPath('typescript', 'tsc')));
assert.throws(() => binPath('no-such-package-sp', 'x'), /npm ci/);
});
test('node tools/ci.mjs --list and a bad option run without npm (exit 0 / 2)', () => {
const list = spawnSync(process.execPath, ['tools/ci.mjs', '--list'], { cwd: ROOT, encoding: 'utf8' });
assert.equal(list.status, 0, list.stderr);
for (const s of STEPS) assert.match(list.stdout, new RegExp(`^${s.id} `, 'm'));
const bad = spawnSync(process.execPath, ['tools/ci.mjs', '--only', 'nope'], { cwd: ROOT, encoding: 'utf8' });
assert.equal(bad.status, 2);
assert.match(bad.stderr, /unknown step "nope"/);
});
+147
View File
@@ -0,0 +1,147 @@
// Control delivery across two connections while using the real client and server hello handlers.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createIdentity, Net } from '../public/js/net.js';
import { Network, SessionRegistry } from '../server/net.js';
import { validateC2S } from '../shared/protocol.js';
function storage() {
const m = new Map();
return { getItem: (k) => m.get(k) ?? null, setItem: (k, v) => m.set(k, String(v)), removeItem: (k) => m.delete(k) };
}
function hub() {
const members = new Set();
return { create() {
const channel = {
listeners: [],
postMessage(data) {
for (const peer of members) if (peer !== channel) queueMicrotask(() => {
if (members.has(peer)) for (const fn of peer.listeners) fn({ data: structuredClone(data) });
});
},
addEventListener(type, fn) { if (type === 'message') this.listeners.push(fn); },
close() { members.delete(channel); },
};
members.add(channel); return channel;
} };
}
async function harness(t) {
const local = storage(), ownerSession = storage(), guestSession = storage(), channels = hub();
const registry = new SessionRegistry(), server = new Network({ registry, handler: { onMessage() {} } });
const ownerSeat = registry.create('Owner'), guestSeat = registry.create('Guest'), clients = [];
t.after(() => { for (const net of clients) net.close(); clearInterval(server.heartbeatTimer); clearInterval(server.sweepTimer); });
const ownerChannel = channels.create();
const owner = createIdentity({ local, session: ownerSession, channel: ownerChannel, tabId: 'z-owner', queryMs: 1, now: () => 100 });
await owner.init(); owner.saveToken(ownerSeat.token); owner.rememberMatch({ name: 'Owner', code: 'ROOM1' });
const guest = createIdentity({ local, session: guestSession, channel: channels.create(), tabId: 'a-guest', queryMs: 1, now: () => 200 });
await guest.init(); guest.saveToken(guestSeat.token);
const client = (identity) => {
const sockets = [], welcomes = [], frames = [];
class Socket {
constructor() { this.readyState = 0; this.sent = []; sockets.push(this); }
send(raw) { this.sent.push(JSON.parse(raw)); }
// Client close need not reach the server before an already sent hello on this connection.
close(code) { this.readyState = 3; this.onclose?.({ code }); }
open() { this.readyState = 1; this.onopen?.(); }
}
const net = new Net({ WebSocket: Socket, getToken: () => identity.getToken(), getTokenClaim: () => identity.getTokenClaim() });
clients.push(net);
net.on('welcome', (m) => { welcomes.push(m); identity.saveToken(m.token); });
net.setName('Owner');
const ws = sockets[0]; ws.open();
const conn = { session: null, ip: '?', key: null, ws: {
readyState: 1, bufferedAmount: 0,
send(raw) { frames.push(JSON.parse(raw)); ws.onmessage?.({ data: raw }); },
close(code) { this.readyState = 3; ws.close(code); },
} };
return { net, ws, conn, welcomes, frames, sockets,
hello: () => ws.sent.find((m) => m.t === 'hello'),
deliver() {
const msg = ws.sent.find((m) => m.t === 'hello');
assert.equal(validateC2S(msg), null);
server.onHelloMsg(conn, msg, Date.now());
},
};
};
return { local, ownerSession, guestSession, channels, ownerSeat, guestSeat, ownerChannel, owner, guest, client,
async reload() {
const id = createIdentity({ local, session: ownerSession, channel: channels.create(), tabId: 'zz-reloaded', queryMs: 1, now: () => 300 });
assert.equal(await id.init(), ownerSeat.token); return id;
},
};
}
test('a recovery hello already in flight cannot displace the original window after its reload welcome', async (t) => {
const h = await harness(t);
h.ownerChannel.close();
assert.equal(await h.guest.resume(h.guest.recoverable()[0].id), true);
const pending = h.client(h.guest);
assert.equal(pending.hello().noReplace, true);
const owner = h.client(await h.reload()); owner.deliver();
assert.equal(h.guest.getToken(), h.guestSeat.token);
pending.deliver();
assert.equal(pending.frames.at(-1).code, 'SESSION_IN_USE');
assert.equal(owner.net.status, 'online'); assert.equal(owner.net.lastError, null);
assert.equal(h.ownerSeat.ws, owner.conn.ws);
assert.equal(pending.welcomes.length, 0);
assert.equal(h.guestSession.getItem('sp.token'), h.guestSeat.token);
});
test('a later welcomed holder also yields; its buffered reconnect cannot override the earlier welcome', async (t) => {
const h = await harness(t);
h.ownerChannel.close();
assert.equal(await h.guest.resume(h.guest.recoverable()[0].id), true);
const recovered = h.client(h.guest); recovered.deliver();
assert.equal(recovered.net.status, 'online');
const delayedReconnect = h.client(h.guest);
assert.equal(delayedReconnect.hello().claimAt, 200);
assert.equal(delayedReconnect.hello().noReplace, undefined);
const owner = h.client(await h.reload()); owner.deliver();
assert.equal(h.guest.getToken(), null);
assert.equal(h.guestSession.getItem('sp.token'), null);
delayedReconnect.deliver();
assert.equal(delayedReconnect.frames.at(-1).code, 'SESSION_IN_USE');
assert.equal(owner.net.status, 'online'); assert.equal(owner.net.lastError, null);
assert.equal(h.ownerSeat.ws, owner.conn.ws);
assert.equal(h.ownerSeat.claimAt, 100);
});
test('shared recovery can bind an offline seat and a same-stamp reconnect can replace its own socket', async (t) => {
const h = await harness(t);
h.ownerChannel.close();
assert.equal(await h.guest.resume(h.guest.recoverable()[0].id), true);
const first = h.client(h.guest); first.deliver();
assert.equal(first.welcomes[0].playerId, h.ownerSeat.playerId);
const reconnect = h.client(h.guest); reconnect.deliver();
assert.equal(reconnect.welcomes[0].playerId, h.ownerSeat.playerId);
const again = h.client(h.guest); again.deliver();
assert.equal(again.net.status, 'online');
assert.equal(again.welcomes[0].playerId, h.ownerSeat.playerId);
assert.equal(h.ownerSeat.claimAt, 200);
});
test('a stale welcome is discarded before online status, queued actions or welcome listeners', async (t) => {
const h = await harness(t);
h.ownerChannel.close();
assert.equal(await h.guest.resume(h.guest.recoverable()[0].id), true);
const pending = h.client(h.guest), published = [], statuses = [];
pending.net.on('*', (m) => published.push(m));
pending.net.on('status', (s) => statuses.push(s.status));
const action = pending.net.request('room.create', { mode: 'solo', difficulty: 'NORMAL' });
const rejected = assert.rejects(action, (e) => e.code === 'CLOSED');
h.guest.rejectToken();
pending.deliver();
assert.equal(pending.welcomes.length, 0);
assert.equal(published.length, 0);
assert.ok(!statuses.includes('online'));
assert.ok(!pending.ws.sent.some((m) => m.t === 'room.create'));
assert.equal(pending.sockets.length, 2, 'retry uses a new connection, never a repeat hello on the old session');
assert.equal(h.guestSession.getItem('sp.token'), h.guestSeat.token);
pending.net.close(); await rejected;
});
test('hello ownership fields reject malformed inputs but remain optional', () => {
const hello = { t: 'hello', name: 'Owner', token: 'seat' };
for (const fields of [{}, { noReplace: true }, { noReplace: false, claimAt: 100 }]) assert.equal(validateC2S({ ...hello, ...fields }), null);
for (const fields of [{ noReplace: 'yes' }, { claimAt: -1 }, { claimAt: Infinity }, { claimAt: '100' }]) assert.equal(typeof validateC2S({ ...hello, ...fields }), 'string');
});
+143
View File
@@ -0,0 +1,143 @@
// Real BroadcastChannel election followed by real client/server session handshakes.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { once } from 'node:events';
import { BroadcastChannel } from 'node:worker_threads';
import WebSocket from 'ws';
import { startServer } from '../server/index.js';
import { CLAIM_QUERY_MS, createIdentity, Net } from '../public/js/net.js';
function storage(seed = {}) {
const values = new Map(Object.entries(seed));
return {
getItem: (key) => values.get(key) ?? null,
setItem: (key, value) => values.set(key, String(value)),
removeItem: (key) => values.delete(key),
};
}
function nextEvent(net, type) {
return new Promise((resolve, reject) => {
const off = net.on(type, (value) => { clearTimeout(timer); off(); resolve(value); });
const timer = setTimeout(() => { off(); reject(new Error(`Timed out waiting for ${type}`)); }, 2000);
});
}
async function harness(t) {
const srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
const clients = [], channels = [], replaced = [];
const local = storage();
const channelName = `sp.identity.test.${process.pid}.${srv.port}`;
t.after(async () => {
clients.forEach((net) => net.close());
channels.forEach((channel) => channel.close());
await srv.close();
});
return {
local, replaced,
tab(tabId, session = storage(), setTimeout) {
const channel = new BroadcastChannel(channelName);
channels.push(channel);
const id = createIdentity({ local, session, channel, tabId, setTimeout });
return { id, session, channel };
},
async connect(name, tab) {
const net = new Net({ url: `ws://127.0.0.1:${srv.port}/ws`, WebSocket, getToken: () => tab?.id.getToken() ?? null });
clients.push(net);
net.on('replaced', (error) => replaced.push({ name, code: error.code }));
net.on('welcome', (welcome) => tab?.id.saveToken(welcome.token));
const welcome = nextEvent(net, 'welcome');
net.setName(name);
return { net, welcome: await welcome };
},
};
}
async function closeClient(net) {
const closed = once(net.ws, 'close');
net.close();
await closed;
}
async function assertOnline(h, clients) {
// A round trip from each socket also lets a queued replacement close reach the client.
const replies = await Promise.allSettled(clients.map(({ net }) => net.request('ping', { c: Date.now() })));
assert.deepEqual(h.replaced, [], 'no client is replaced by another tab');
for (const [index, client] of clients.entries()) {
assert.equal(client.net.status, 'online');
assert.equal(client.net.lastError, null);
assert.equal(replies[index].status, 'fulfilled');
assert.equal(replies[index].value.t, 'pong');
}
}
for (const [firstId, secondId] of [['a-tab', 'b-tab'], ['b-tab', 'a-tab']]) {
test(`staggered tabs ${firstId} then ${secondId} keep separate server sessions`, { timeout: 5000 }, async (t) => {
const h = await harness(t);
const seed = await h.connect('Seed');
await closeClient(seed.net);
h.local.setItem('sp.tokens', JSON.stringify([seed.welcome.token]));
const deadlines = [];
const holdDeadline = (fn, ms) => { assert.equal(ms, CLAIM_QUERY_MS); deadlines.push(fn); };
const first = h.tab(firstId, storage(), holdDeadline);
const firstInit = first.id.init();
// The first who has already been sent when the second channel is created.
const second = h.tab(secondId, storage(), holdDeadline);
const delivered = new Promise((resolve) => {
second.channel.addEventListener('message', (event) => {
if (event.data.type === 'test.barrier') resolve();
});
});
first.channel.addEventListener('message', (event) => {
if (event.data.type === 'who') first.channel.postMessage({ type: 'test.barrier' });
});
const secondInit = second.id.init();
// FIFO delivery puts this barrier after any real reply to the later who. Both default
// 150 ms deadlines remain pending, without relying on sleeps or a fake channel hub.
await delivered;
assert.equal(deadlines.length, 2);
deadlines[0]();
await firstInit;
deadlines[1]();
await secondInit;
const a = await h.connect('First', first);
const b = await h.connect('Second', second);
await assertOnline(h, [a, b]);
assert.notEqual(a.welcome.token, b.welcome.token);
assert.notEqual(a.welcome.playerId, b.welcome.playerId);
const resumed = firstId < secondId ? a : b;
assert.equal(resumed.welcome.token, seed.welcome.token);
assert.equal(resumed.welcome.playerId, seed.welcome.playerId);
});
}
test('live token ownership survives a duplicated tab, reload and reconnect', { timeout: 5000 }, async (t) => {
const h = await harness(t);
const owner = h.tab('z-owner');
await owner.id.init();
const a = await h.connect('Owner', owner);
// A duplicated tab copies sessionStorage, but its smaller tab ID cannot evict a live owner.
const duplicate = h.tab('a-duplicate', storage({ 'sp.token': a.welcome.token }));
await duplicate.id.init();
const b = await h.connect('Duplicate', duplicate);
assert.notEqual(a.welcome.token, b.welcome.token);
assert.notEqual(a.welcome.playerId, b.welcome.playerId);
await assertOnline(h, [a, b]);
await closeClient(a.net);
owner.channel.close();
const reloaded = h.tab('z-reloaded', owner.session);
await reloaded.id.init();
const a2 = await h.connect('Owner', reloaded);
assert.equal(a2.welcome.token, a.welcome.token);
assert.equal(a2.welcome.playerId, a.welcome.playerId);
const reconnected = nextEvent(a2.net, 'welcome');
a2.net.reconnectNow();
const welcome = await reconnected;
assert.equal(welcome.token, a.welcome.token);
assert.equal(welcome.playerId, a.welcome.playerId);
await assertOnline(h, [a2, b]);
});
+175
View File
@@ -790,6 +790,58 @@ describe('identity (reconnect-token selection across tabs)', () => {
assert.equal(ty, null);
});
for (const [firstId, secondId, expected] of [
['a-tab', 'b-tab', ['tokShared', null]],
['b-tab', 'a-tab', [null, 'tokShared']],
]) {
test(`staggered channels: ${firstId} starts before ${secondId}`, async () => {
const { createIdentity, CLAIM_QUERY_MS } = await mod('net.js');
const hub = channelHub(), timers = fakeTimers(), local = memStorage();
local.setItem('sp.tokens', JSON.stringify(['tokShared']));
const firstSession = memStorage(), secondSession = memStorage();
const first = createIdentity({ local, session: firstSession, tabId: firstId,
channel: hub.create(), setTimeout: timers.setTimeout });
const firstPick = first.init();
timers.advance(30);
// This channel did not exist when the first tab broadcast its query.
const second = createIdentity({ local, session: secondSession, tabId: secondId,
channel: hub.create(), setTimeout: timers.setTimeout });
const secondPick = second.init();
await new Promise((r) => setImmediate(r));
timers.advance(CLAIM_QUERY_MS - 30);
assert.equal(await firstPick, expected[0]);
timers.advance(30);
assert.equal(await secondPick, expected[1]);
assert.deepEqual([first.getToken(), second.getToken()], expected);
assert.deepEqual([firstSession.getItem('sp.token'), secondSession.getItem('sp.token')], expected);
});
}
test('staggered elections reserve fallback candidates while a live owner keeps its token', async () => {
const { createIdentity, CLAIM_QUERY_MS } = await mod('net.js');
const hub = channelHub(), timers = fakeTimers(), local = memStorage();
const owner = createIdentity({ local, session: memStorage(), tabId: 'z-owner', channel: hub.create() });
await owner.init();
owner.saveToken('tokOwned');
local.setItem('sp.tokens', JSON.stringify(['tokOwned', 'tokFree']));
const first = createIdentity({ local, session: memStorage(), tabId: 'a-tab',
channel: hub.create(), setTimeout: timers.setTimeout });
const firstPick = first.init();
timers.advance(30);
const duplicateSession = memStorage();
duplicateSession.setItem('sp.token', 'tokOwned');
const second = createIdentity({ local, session: duplicateSession, tabId: 'b-tab',
channel: hub.create(), setTimeout: timers.setTimeout });
const secondPick = second.init();
await new Promise((r) => setImmediate(r));
timers.advance(CLAIM_QUERY_MS - 30);
assert.equal(await firstPick, 'tokFree');
timers.advance(30);
assert.equal(await secondPick, null);
assert.equal(duplicateSession.getItem('sp.token'), null, 'the copied live token is discarded');
assert.equal(owner.getToken(), 'tokOwned', 'a live owner outranks even a smaller tab id');
});
test('without BroadcastChannel only the own token is used; init is idempotent; welcome during init wins', async () => {
const { createIdentity } = await mod('net.js');
const local = memStorage();
@@ -1256,3 +1308,126 @@ describe('multi-device & browser compatibility (static)', () => {
assert.match(src, /--tap-min: 44px/);
});
});
describe('explicit local match recovery (#431)', () => {
async function recoveryPair() {
const { createIdentity } = await mod('net.js');
const local = memStorage(), hub = channelHub(), ownerSession = memStorage(), guestSession = memStorage();
const ownerChannel = hub.create();
const owner = createIdentity({ local, session: ownerSession, tabId: 'z-owner', channel: ownerChannel, queryMs: 5, now: () => 100 });
const guest = createIdentity({ local, session: guestSession, tabId: 'a-guest', channel: hub.create(), queryMs: 5, now: () => 200 });
await owner.init(); owner.saveToken('seat-owner'); owner.rememberMatch({ name: 'Owner', code: 'ROOM1' });
await guest.init(); guest.saveToken('seat-guest');
return { createIdentity, local, hub, ownerSession, guestSession, ownerChannel, owner, guest, id: guest.recoverable()[0].id };
}
test('live: a smaller tab id cannot reclaim an active seat', async () => {
const { guest, owner, id } = await recoveryPair();
assert.equal(await guest.resume(id), false);
assert.equal(guest.getToken(), 'seat-guest');
assert.equal(owner.getToken(), 'seat-owner');
});
test('reload: the original session wins over a recovery attempt during navigation', async () => {
const p = await recoveryPair();
p.ownerChannel.close();
assert.equal(await p.guest.resume(p.id), true, 'old document is gone');
const reloaded = p.createIdentity({ local: p.local, session: p.ownerSession, tabId: 'zz-reloaded', channel: p.hub.create(), queryMs: 5 });
assert.equal(await reloaded.init(), 'seat-owner');
assert.equal(p.ownerSession.getItem('sp.token'), 'seat-owner');
assert.equal(p.guest.getToken(), 'seat-guest', 'unconfirmed recovery yields before hello');
});
test('midInit: the session token answers claims before init finishes', async () => {
const p = await recoveryPair();
p.ownerChannel.close();
let finish;
const reloaded = p.createIdentity({ local: p.local, session: p.ownerSession, tabId: 'zz-reloaded', channel: p.hub.create(), setTimeout: (fn) => { finish = fn; } });
const pending = reloaded.init();
assert.equal(await p.guest.resume(p.id), false);
finish();
assert.equal(await pending, 'seat-owner');
assert.equal(p.ownerSession.getItem('sp.token'), 'seat-owner');
assert.equal(p.guest.getToken(), 'seat-guest');
});
test('an occupied session token never falls back to a different recent identity', async () => {
const p = await recoveryPair();
p.local.setItem('sp.tokens', JSON.stringify(['seat-free', 'seat-owner']));
const duplicate = p.createIdentity({ local: p.local, session: p.ownerSession.clone(), tabId: '0-copy', channel: p.hub.create(), queryMs: 5 });
assert.equal(await duplicate.init(), null);
assert.equal(duplicate.getToken(), null);
});
test('resume persists neither its token nor a welcome stamp until the server welcomes it', async () => {
const p = await recoveryPair();
p.ownerChannel.close();
const stamp = p.guestSession.getItem('sp.tokenWelcome');
assert.equal(await p.guest.resume(p.id), true);
assert.equal(p.guestSession.getItem('sp.token'), 'seat-guest');
assert.equal(p.guestSession.getItem('sp.tokenWelcome'), stamp);
p.guest.saveToken('seat-owner');
assert.equal(p.guestSession.getItem('sp.token'), 'seat-owner');
assert.ok(p.guestSession.getItem('sp.tokenWelcome'));
});
test('an earlier welcome stamp survives reload and beats a later live welcome', async () => {
const p = await recoveryPair();
p.ownerChannel.close();
assert.equal(await p.guest.resume(p.id), true);
p.guest.saveToken('seat-owner'); // recovery's welcome is later than the original owner's
const originalStamp = p.ownerSession.getItem('sp.tokenWelcome');
const reloaded = p.createIdentity({ local: p.local, session: p.ownerSession, tabId: 'zz-reloaded', channel: p.hub.create(), queryMs: 5, now: () => 300 });
assert.equal(await reloaded.init(), 'seat-owner');
reloaded.saveToken('seat-owner');
assert.equal(p.ownerSession.getItem('sp.tokenWelcome'), originalStamp, 'reconnect does not reset seniority');
});
test('a welcome received during init cannot be overwritten or erased by its pending result', async () => {
const { createIdentity } = await mod('net.js');
const local = memStorage(), session = memStorage(), hub = channelHub();
session.setItem('sp.token', 'old');
let finish;
const id = createIdentity({ local, session, channel: hub.create(), setTimeout: (fn) => { finish = fn; } });
const pending = id.init();
id.saveToken('new'); finish();
assert.equal(await pending, 'new');
assert.equal(session.getItem('sp.token'), 'new');
});
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(), []);
}
});
});
+35 -3
View File
@@ -302,6 +302,22 @@ test('余 文火慢炖: R8 round start — exactly 1 active bond +36 layers, oth
cover('band_yu');
});
test('余 文火慢炖 adds no layers to a bond without layers (noStack: 独行, 绝技 …) — the owner\'s decision of 2026-10-08', () => {
assert.deepEqual(Object.values(DATA.bonds).filter((b) => b.noStack).map((b) => b.name).sort(), ['协防干员', '独行', '绝技', '调和'].sort());
for (const [bonus, want] of [
[{ soloShip: 1 }, {}], // the one active bond is 独行: nothing at all
[{ suntShip: 5 }, {}], // … or 绝技
[{ soloShip: 1, yanShip: 20 }, { yanShip: 12 }], // two active (the sentence's count): the layered one +12, 独行 none
]) {
const s = setup({ band: 'band_yu' });
Object.assign(s.ps.bondCountBonus, bonus); s.ps.recompute();
for (const id of Object.keys(bonus)) assert.ok(s.ps.bonds[id]?.active, `${id} active`);
s.roundStart(8);
const got = Object.fromEntries(Object.entries(s.ps.layers).filter(([, v]) => v > 0));
assert.deepEqual(got, want, JSON.stringify(bonus));
}
});
test('凯瑟琳 定向投放: every shop upgrade offers 3 different shop items (any tier — community report of 2026-10-06), 1 free pick', () => {
const { m, ps } = setup({ band: 'band_cathy', seed: 9 });
for (const lvl of [2, 3]) {
@@ -556,18 +572,22 @@ test('埃芒加德 命结之秘: the first 3 knock-downs of the battle revive at
cover('band_ermengard');
});
test('克莱门莎 崇高牺牲: a <阿戈尔> operator knocked down ⇒ +its tier <阿戈尔> layers (no activation needed; none in 联防/boss)', () => {
test('克莱门莎 崇高牺牲: a <阿戈尔> operator knocked down ⇒ +its tier <阿戈尔> layers (requires activation; none in 联防/boss)', () => {
const ops = { t_eg: op('t_eg', { bonds: ['egirShip'], tier: 3 }), t_x: op('t_x', { bonds: ['yanShip'], tier: 5 }) };
const h = fight({ band: 'band_clementia', ops, units: [{ chessId: 't_eg', row: 10, col: 4 }, { chessId: 't_x', row: 11, col: 4 }], foes: [[10, 9]] });
const h = fight({ band: 'band_clementia', bonds: active('egirShip'), ops, units: [{ chessId: 't_eg', row: 10, col: 4 }, { chessId: 't_x', row: 11, col: 4 }], foes: [[10, 9]] });
h.step(1);
h.b.dealDamage(foe(h), h.unit('t_eg'), { amount: 1e6, type: 'true' });
h.b.dealDamage(foe(h), h.unit('t_x'), { amount: 1e6, type: 'true' });
h.b.retreat(h.unit('t_eg')); // not knocked down: nothing (already dead anyway)
assert.deepEqual(h.b.result().perPlayer.p1.layerGains, { egirShip: 3 });
const u = fight({ band: 'band_clementia', kind: 'unite', ops, units: [{ chessId: 't_eg', row: 10, col: 4 }], foes: [[10, 9]] });
const u = fight({ band: 'band_clementia', bonds: active('egirShip'), kind: 'unite', ops, units: [{ chessId: 't_eg', row: 10, col: 4 }], foes: [[10, 9]] });
u.step(1);
u.b.dealDamage(foe(u), u.unit('t_eg'), { amount: 1e6, type: 'true' });
assert.deepEqual(u.b.result().perPlayer.p1.layerGains, {});
const inactive = fight({ band: 'band_clementia', ops, units: [{ chessId: 't_eg', row: 10, col: 4 }], foes: [[10, 9]] });
inactive.step();
inactive.b.dealDamage(foe(inactive), inactive.unit('t_eg'), { amount: 1e6, type: 'true' });
assert.deepEqual(inactive.b.result().perPlayer.p1.layerGains, {}, 'an inactive 阿戈尔 bond gains no layers');
cover('band_clementia');
});
@@ -587,6 +607,18 @@ test('大帝 加急调派: every deployment halves the operator\'s next redeploy
cover('band_emperor');
});
test('大帝 加急调派 stacks without a cap: the 21st and 22nd deployments still halve the next redeploy (PRTS "※该策略效果可无限叠加"; GitHub #328, PR #329)', () => {
const h = fight({ band: 'band_emperor', units: [{ chessId: 't_op', row: 10, col: 4 }] });
h.step(1);
const u = h.unit('t_op');
for (let n = 1; n <= 22; n++) {
h.b.retreat(u, { reason: 'raid' });
close(u.respawnAt - u.deathAt, 20 / 2 ** n, `after deployment ${n}`);
if (n < 22) assert.ok(h.b._deploy(u), `deployment ${n + 1}`);
}
assert.equal(u.findBuff('band:band_emperor').stacks, 22);
});
test('桑葚 药枚实验: the units on the right-most column get a 25 % chance per attack of 1 shield layer (max 1)', () => {
const ops = { t_op: op('t_op'), t_b: op('t_b') };
for (const [v, want] of [[0.24, 1], [0.26, 0]]) {
+31
View File
@@ -71,6 +71,37 @@ test('迅捷: member skill end +12 SP (p=1 at high L), ≥40 layers every operat
assert.equal(off.s_x, 0);
});
test('迅捷 refills 引星棘刺 S1 at once: it is cast at most once per attack interval, and her attacks go on untouched (GitHub #298)', () => {
// S1 度算浪波: AUTO, no duration, no attack of its own, SP_FULL, cost 6; 迅捷 refunds 12 + 15 SP at ≥ 40 layers
const run = ({ aspd = 0, enemy = true, silence = false } = {}) => {
const h = makeBattle({
seed: 1, bonds: { swiftShip: bond(2, 229, 3) }, autoFinish: false, timeLimit: 30,
units: [{ chessId: 'chess_char_5_15_b', skillIndex: 0, row: 10, col: 4 }],
enemies: enemy ? [{ key: 'enemy_still', route: { motion: 'WALK', start: [10, 6], end: [10, 6], checkpoints: [] } }] : [],
defs: { enemies: { enemy_still: enemyRec({ key: 'enemy_still', hp: 1e9, atk: 0, speed: 0, blockCnt: 0 }) } },
});
h.step();
const u = h.unit('chess_char_5_15_b');
if (aspd) h.b.addBuff(u, { key: 'test:aspd', mods: { aspd } });
if (silence) h.b.addBuff(u, { key: 'test:mute', flags: { silence: true } });
if (enemy) assert.ok(h.b.enemiesInKeys(u.rangeKeys, u, u.profile).length, 'an enemy stays in range');
u.skill.gainSp(u.skill.spCost, 'test');
const n0 = u.skill.activations, a0 = u.stats.attacks;
h.run(6);
checkInvariants(h.b);
return { n: u.skill.activations - n0, attacks: u.stats.attacks - a0, interval: u.s.interval };
};
const base = run(), muted = run({ silence: true });
// the first at once, then one per interval (1.36 s): 5 in 6 s — every tick before (180)
assert.ok(base.n >= 4 && base.n <= 5, `one cast per attack interval (${base.interval.toFixed(2)} s): ${base.n} in 6 s`);
assert.ok(base.attacks > 0 && base.attacks === muted.attacks, `the casts take no attack (${base.attacks} vs ${muted.attacks} silenced)`);
const fast = run({ aspd: 500 }), fastMuted = run({ aspd: 500, silence: true });
assert.ok(fast.n >= 23 && fast.n <= 25, `ASPD +500 (interval ${fast.interval.toFixed(2)} s): ${fast.n} casts`);
assert.equal(fast.attacks, fastMuted.attacks, 'still no attack taken');
const alone = run({ enemy: false });
assert.ok(alone.n >= 4 && alone.n <= 5 && alone.attacks === 0, `no enemy: still fires at full SP (#124), one per interval — ${alone.n}`);
});
test('迅捷 / 突袭: SP gifts after end do not recharge a zero-SP deployment skill or trigger a ready raid', () => {
const h = makeBattle({
defs: { chess: { t_deploy: chessRec({ id: 't_deploy', bonds: ['swiftShip', 'raidShip'], skill: { spCost: 0 } }) } },
+3
View File
@@ -406,6 +406,9 @@ test('机密商店 E2E (co-op): two identical cards are two cards — both can b
// six 变形同构体: every slot draws the one item `coin` names
const one = Object.values(DATA.items).find((i) => i.name === '变形同构体' && !i.isGolden).id;
const data = dataWith('mode_multi_hard', 3, 'shop');
// This case isolates card-index ownership with sufficient stock; shared-stock.test.js covers exhaustion.
data.config = { ...data.config, economy: { ...data.config.economy,
itemPoolCopiesOverrides: { ...data.config.economy.itemPoolCopiesOverrides, [one]: 8 } } };
data.choices = { ...data.choices, shopDraft: { ...data.choices.shopDraft, rounds: [3], slots: Array.from({ length: 6 }, () => ({ coin: 1 })), coin: one } };
const h = makeMatch({ mode: 'coop', difficulty: 'HARD', humans: 2, bots: 2, seed: 43, data, registry: REG, fake: true });
const m = h.m;
+87
View File
@@ -0,0 +1,87 @@
// Community reports of 2026-10-10: actual attacks / skills, including death and redeployment in the same tick.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, chessRec, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
const bond = (layers = 0) => ({ count: 3, active: true, tier: 1, layers });
const dummy = enemyRec({ key: 'dummy', hp: 1e9, atk: 0, speed: 0, mass: 0 });
const wall = (id, opts = {}) => chessRec({ id, skill: null, stats: { maxHp: 1e7, atk: 0, def: 0, blockCnt: 3 }, ...opts });
const finish = (h) => { checkInvariants(h.b); assert.deepEqual(h.b.errors, []); };
const near = (actual, expected) => assert.ok(Math.abs(actual - expected) < 1e-6, `${actual} != ${expected}`);
for (const key of ['enemy_1045_hammer', 'enemy_1045_hammer_2', 'enemy_1160_hvyslr', 'enemy_1160_hvyslr_2']) {
test(`${key}: a lethal stunning strike cannot stun the deployment created by 不屈`, () => {
const h = makeBattle({ autoFinish: false, timeLimit: 100, defs: { chess: { wall: wall('wall') } },
units: [{ chessId: 'wall', row: 10, col: 5 }], bonds: { indomShip: bond(300) } });
const nth = key.includes('hvyslr') ? 4 : 3;
h.step(); const u = h.unit('wall'); const e = h.spawn(key, { pos: [10, 5], mods: { speedMul: 0 } });
assert.ok(h.runUntil(() => e.stats.attacks >= nth - 1, 40));
const seq = u.deploySeq; u.hp = 1;
assert.ok(h.runUntil(() => e.stats.attacks >= nth, 20));
assert.ok(u.alive && u.deploySeq > seq, 'the lethal stunning strike immediately redeployed the operator');
assert.ok(!u.s.flags.stun, 'the old attack cannot put stun on that new deployment');
// The next stunning strike that does not kill must still stun normally.
assert.ok(h.runUntil(() => e.stats.attacks >= nth * 2, 40));
assert.ok(u.s.flags.stun); finish(h);
});
}
for (const skadi of [false, true]) {
test(`${skadi ? '浊心斯卡蒂 custom' : 'default'} bard keeps its regeneration while stunned, stops after leaving`, () => {
const bard = chessRec({ id: 'bard', profession: 'SUPPORT', subProfessionId: 'bard', attackKind: 'none', dmgType: 'heal',
skill: null, rangeGrid: [[0, 0], [0, 1]], stats: { atk: 1000 } });
const h = makeBattle({ autoFinish: false, defs: { chess: { bard, wall: wall('wall') } },
units: [{ chessId: skadi ? 'chess_char_6_04_a' : 'bard', row: 10, col: 4, skillIndex: 1 }, { chessId: 'wall', row: 10, col: 5 }] });
h.step(); const u = h.b.allyUnits[0], a = h.unit('wall'); a.hp = 100;
if (skadi) { u.skill.gainSp(999, 'init'); assert.ok(u.skill.activate('test')); }
h.run(1); const regen = a.s.hpRegen; assert.ok(regen > 0);
h.b.applyStatus(u, 'stun', { duration: 10 }); h.run(2);
near(a.s.hpRegen, regen); const hp = a.hp; h.run(1); assert.ok(a.hp > hp);
h.b.kill(u, null); h.run(1); near(a.s.hpRegen, 0); finish(h);
});
}
test('黄沙罗盘 + 浓茶 bypass both the active skill lock and noSp, including the caster; ordinary SP still obeys them', () => {
const op = (id, bonds) => chessRec({ id, bonds, skill: { spCost: 100, initSp: 0, duration: 20 } });
const h = makeBattle({ autoFinish: false, defs: { chess: { a: op('a', ['sargonShip']), b: op('b', ['sargonShip']), c: op('c', []) } },
units: [{ chessId: 'a', row: 10, col: 4, items: ['chess_item_6_07_e_a', 'chess_item_2_04_e_a'] },
{ chessId: 'b', row: 11, col: 4 }, { chessId: 'c', row: 12, col: 4 }] });
h.step(); const a = h.unit('a'), b = h.unit('b'), c = h.unit('c');
for (const u of [a, b, c]) { u.skill.gainSp(100, 'init'); h.b.addBuff(u, { key: 'test:lock', flags: { noSp: true } }); }
assert.ok(b.skill.activate('test')); assert.ok(c.skill.activate('test')); assert.ok(a.skill.activate('test'));
near(a.skill.sp, 3); near(b.skill.sp, 3); near(c.skill.sp, 0);
near(b.skill.gainSp(5, 'item'), 0); near(b.skill.sp, 3);
a.skill.end('test'); near(a.skill.sp, 33); finish(h);
});
for (const elite of [false, true]) {
test(`蕾缪安 S3 real ammo consumption gains active bonds with three operators in the row (${elite ? 'elite' : 'normal'})`, () => {
const id = `chess_char_6_01_${elite ? 'b' : 'a'}`;
const h = makeBattle({ autoFinish: false, timeLimit: 120, hooks: ['ammoUsed'],
defs: { chess: { a: wall('a'), b: wall('b') }, enemies: { dummy } },
bonds: { lateranoShip: bond(), preciShip: bond() },
units: [{ chessId: id, row: 10, col: 4 }, { chessId: 'a', row: 10, col: 2 }, { chessId: 'b', row: 10, col: 3 }] });
h.step(); const u = h.unit(id); h.spawn('dummy', { pos: [10, 6] });
for (let i = 0; i < 2; i++) { u.skill.gainSp(999, 'init'); assert.ok(u.skill.activate('test')); assert.ok(h.runUntil(() => !u.skill.active, 30)); }
const spent = h.hooksOf('ammoUsed').filter((c) => c.unit === u).length;
assert.ok(spent >= 10); const gains = h.result().perPlayer.p1.layerGains;
for (const b of ['lateranoShip', 'preciShip']) assert.equal(gains[b], Math.floor(spent / 10) * (elite ? 4 : 2), b);
finish(h);
});
}
for (const id of ['chess_char_4_23_a', 'chess_char_5_18_a']) {
test(`${id} 百炼嘉维尔 S2 damages a final boss but cannot pull it`, () => {
const sharedBoss = { hp: 1e9, maxHp: 1e9, damage(pid, n) { this.hp -= n; } };
const h = makeBattle({ kind: 'boss', sharedBoss, autoFinish: false, hooks: ['damaged'], captureNoisy: true,
units: [{ chessId: id, row: 10, col: 3, skillIndex: 1 }] });
h.step(); const u = h.unit(id);
const e = h.spawn('enemy_9017_achunt', { pos: [3, 5], tag: 'boss', mods: { speedMul: 0 } });
e.profile.noAttack = true;
u.skill.gainSp(999, 'init'); assert.ok(u.skill.activate('test')); h.run(3);
assert.ok(h.hooksOf('damaged').some((c) => c.source === u && c.target === e), 'S2 actually hits the leader');
near(e.x, 5); near(e.y, 3);
// An excessive force isolates the leader exclusion from its ordinary high weight.
near(h.b.pullToFront(e, u, 99), 0); near(e.x, 5); near(e.y, 3); finish(h);
});
}
+116
View File
@@ -0,0 +1,116 @@
// Community follow-up: deployment clocks, air-only casts, champagne healing and airborne splash.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, chessRec, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
const TRAP = 'token_10031_swire2_gdtrap';
const SW = 'chess_char_3_04_a';
const wall = chessRec({ id: 'wall', skill: null, stats: { maxHp: 1e7, atk: 0, def: 0, blockCnt: 3 } });
const dummy = enemyRec({ key: 'dummy', hp: 1e9, atk: 0, speed: 0 });
const done = (h) => { checkInvariants(h.b); assert.deepEqual(h.b.errors, []); };
const diy = (charId, skillIndex, tier = 5) => ({ slot: `chess_char_${tier}_diy1_a`, charId, skillIndex });
test('deployment duration starts at battle start, never advances from repeated start/read calls', () => {
const h = makeBattle({ autoFinish: false, units: [{ uid: 1, diy: diy('char_1015_aglna2', 0), row: 10, col: 4 }] });
const u = h.unit(1);
assert.equal(h.b.started, false); assert.equal(u.deployed, false);
assert.ok(!u.skill?.active);
h.b.start(); const duration = u.skill.duration;
assert.equal(u.skill.timeLeft, duration); assert.equal(u.skill.activations, 1);
for (let i = 0; i < 10; i++) { h.b.start(); h.b.snapshot(); }
assert.equal(h.b.time, 0); assert.equal(u.skill.timeLeft, duration);
h.run(2); assert.ok(Math.abs(u.skill.timeLeft - (duration - 2)) < 1e-8);
done(h);
});
for (const tier of [5, 6]) for (const elite of [false, true]) {
test(`赤刃陈 S3 opens for a flyer in its expanded range and only the wave damages it (T${tier}, elite=${elite})`, () => {
const h = makeBattle({ autoFinish: false, flags: { startOpCooldown: 3 }, hooks: ['damaged'], captureNoisy: true,
defs: { enemies: { fly: enemyRec({ key: 'fly', hp: 1e9, atk: 0, speed: 0, motion: 'FLY' }) } },
units: [{ uid: 1, diy: diy('char_1050_chen3', 2, tier), elite, row: 10, col: 4 }] });
h.step(); const u = h.unit(1); const e = h.spawn('fly', { pos: [10, 6] });
u.skill.gainSp(999, 'init'); h.run(2);
assert.equal(u.skill.activations, 0, 'the operation cooldown still applies');
assert.ok(h.runUntil(() => u.skill.active, 2), 'air-only target can start the sword wave');
h.run(2);
const hits = h.hooksOf('damaged').filter(c => c.source === u && c.target === e);
assert.ok(hits.length > 0, 'the wave hits the flyer');
assert.ok(hits.every(c => c.dmg.tags.includes('chen3:wave')), 'normal S3 swings remain ground-only');
done(h);
});
}
test('赤刃陈 S3 still rejects an out-of-range, hidden or sleeping flyer, and cannot cast while stunned', () => {
const h = makeBattle({ autoFinish: false,
defs: { enemies: { fly: enemyRec({ key: 'fly', hp: 1e9, atk: 0, speed: 0, motion: 'FLY' }) } },
units: [{ uid: 1, diy: diy('char_1050_chen3', 2), row: 10, col: 4 }] });
h.step(); const u = h.unit(1); u.skill.gainSp(999, 'init');
const far = h.spawn('fly', { pos: [12, 12] }); h.run(0.3); assert.equal(u.skill.activations, 0);
const e = h.spawn('fly', { pos: [10, 6] });
h.b.addBuff(e, { key: 'test:stealth', flags: { stealth: true } });
h.run(0.3); assert.equal(u.skill.activations, 0);
h.b.removeBuff(e, 'test:stealth'); h.b.applyStatus(e, 'sleep', { duration: 0.4 });
h.run(0.2); assert.equal(u.skill.activations, 0);
h.b.applyStatus(u, 'stun', { duration: 0.7 }); h.run(0.5); assert.equal(u.skill.activations, 0);
h.run(0.4); assert.equal(u.skill.activations, 1); assert.ok(far.alive); done(h);
});
test('琳琅诗怀雅 S2 receives the deployment coin and throws without waiting for the first merchant payment', () => {
const h = makeBattle({ autoFinish: false, units: [{ uid: 1, chessId: SW, row: 10, col: 4, skillIndex: 1, potential: 1 }] });
h.b.start(); const u = h.unit(1);
assert.equal(u.skill.kind, 'passive'); assert.equal(u.mem.coins, 1);
assert.equal(h.b.allyUnits.filter(a => a.defId === TRAP).length, 0);
h.step(); assert.equal(h.b.allyUnits.filter(a => a.defId === TRAP).length, 1); assert.equal(u.mem.coins, 0);
h.run(1); assert.equal(h.b.allyUnits.filter(a => a.defId === TRAP).length, 1, 'no free extra bombs');
done(h);
});
for (const skillIndex of [0, 1]) {
test(`莎草 S${skillIndex + 1} heals/shields an adjacent operator but never chains to 琳琅诗怀雅's actual bomb`, () => {
const h = makeBattle({ autoFinish: false, hooks: ['heal'], captureNoisy: true,
flat: { rows: { 9: '##ERRrrRrrSrrrrrrrS##', 10: '##hrrRrrrrfrrrrrrrf##', 11: '##hrrRrrrrfrrrrrrrf##' } },
defs: { chess: { wall } },
units: [{ uid: 1, chessId: SW, row: 9, col: 5 },
{ uid: 2, chessId: 'chess_char_2_06_a', row: 10, col: 6, skillIndex }, { uid: 3, chessId: 'wall', row: 10, col: 5 }] });
h.step(); const bomb = h.b.allyUnits.find(a => a.defId === TRAP); assert.ok(bomb);
assert.deepEqual([bomb.tileR, bomb.tileC], [9, 6]);
const p = h.unit(2), a = h.unit(3); a.hp = 100;
p.skill.gainSp(999, 'init');
assert.ok(h.runUntil(() => !!a.findBuff('papyrs:shield'), 5), 'a real heal gave the operator a shield');
h.run(3);
assert.equal(bomb.findBuff('papyrs:shield'), null);
assert.ok(!h.hooksOf('heal').some(c => c.source === p && c.target === bomb));
if (skillIndex === 1) assert.notEqual(p.mem.papyrsLock, bomb);
done(h);
});
}
for (const skillIndex of [0, 1, 2]) {
test(`予愿 S${skillIndex + 1}: ground caster splash skips her while airborne and hits after landing`, () => {
const h = makeBattle({ autoFinish: false, timeLimit: 120, hooks: ['damaged'], captureNoisy: true,
defs: { chess: { wall }, enemies: { dummy } },
units: [{ uid: 1, diy: diy('char_1015_aglna2', skillIndex), row: 9, col: 5 },
{ uid: 2, chessId: 'wall', row: 10, col: 5 }] });
h.step(); const u = h.unit(1), bait = h.unit(2);
if (skillIndex > 0) assert.ok(u.skill.activate('test', { free: true }));
h.run(3); assert.ok(u.s.flags.liftoff, 'past S2 invulnerable chant, still airborne');
const e = h.spawn('enemy_1161_tidmag', { pos: [10, 7], mods: { hpMul: 1e4, speedMul: 0, atkMul: 0.1 } });
h.run(7);
assert.ok(h.hooksOf('damaged').some(c => c.source === e && c.target === bait), 'caster attacked the adjacent bait');
assert.ok(!h.hooksOf('damaged').some(c => c.source === e && c.target === u), 'ordinary ground splash cannot hit her');
u.skill.end('test'); u.skill.spCostMul = 1000; h.run(7);
assert.ok(h.hooksOf('damaged').some(c => c.source === e && c.target === u), 'same splash reaches her after landing');
done(h);
});
}
test('予愿起飞 still takes the brute leader splash explicitly marked 无视无法选择', () => {
const h = makeBattle({ autoFinish: false, hooks: ['damaged'], captureNoisy: true,
defs: { chess: { wall } },
units: [{ uid: 1, diy: diy('char_1015_aglna2', 0), row: 9, col: 5 }, { uid: 2, chessId: 'wall', row: 9, col: 4 }],
enemies: [{ key: 'enemy_1320_wdrrl_2', route: { motion: 'WALK', start: [9, 8], end: [9, 1], checkpoints: [] }, mods: { hpMul: 1e3, atkMul: 0.1 } }] });
h.run(14); const u = h.unit(1);
assert.ok(u.s.flags.liftoff);
assert.ok(h.hooksOf('damaged').some(c => c.target === u && c.dmg.ignoreSelect));
done(h);
});
+209
View File
@@ -0,0 +1,209 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, chessRec, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
const done = (h) => { checkInvariants(h.b); assert.deepEqual(h.b.errors, []); };
test('six Kazimierz stuns a nearby hovering enemy while blocking ground enemies (#463)', () => {
const h = makeBattle({ autoFinish: false, hooks: ['status'],
defs: { chess: { wall: chessRec({ id: 'wall', bonds: ['kazimierzShip'], skill: null, stats: { maxHp: 1e7, atk: 10, block: 3 } }) },
enemies: { dummy: enemyRec({ key: 'dummy', hp: 1e9, atk: 0, speed: 0 }) } },
bonds: { kazimierzShip: { count: 6, active: true, tier: 3, layers: 1 } },
units: [{ uid: 1, chessId: 'wall', row: 10, col: 4 }] });
h.step(); const u = h.unit(1);
const ground = h.spawn('dummy', { pos: [10, 4], routeIndex: 0 });
const near = h.spawn('enemy_2025_syufo', { pos: [10, 4.5], routeIndex: 0 });
const far = h.spawn('enemy_2025_syufo', { pos: [10, 7], routeIndex: 0 });
for (const e of [near, far]) h.b.addBuff(e, { key: 'test:stationary', flags: { noMove: true, disarm: true } });
assert.ok(near.s.flags.unblockable);
h.step(2); assert.equal(ground.blockedBy, u);
assert.ok(h.runUntil(() => near.s.flags.stun, 4), 'the pulse can stun hovering targets');
assert.ok(!near.s.flags.unblockable, 'hovering ends while stunned');
assert.ok(!far.s.flags.stun, '周围 is the official 0.8 radius, not the whole field');
done(h);
});
test('a Raid revive inside the tick hook stays on the enemy tile instead of the taunt', () => {
const h = makeBattle({ autoFinish: false,
defs: { enemies: { dummy: enemyRec({ key: 'dummy', hp: 1e9, atk: 0, speed: 0 }) } },
bonds: { raidShip: { count: 2, active: true, tier: 1, layers: 5 } },
units: [{ uid: 1, chessId: 'chess_char_4_16_a', skillIndex: 1, row: 10, col: 4 }] });
h.step(); const u = h.unit(1);
const local = h.spawn('dummy', { pos: [10, 4], routeIndex: 0 });
const far = h.spawn('dummy', { pos: [10, 8], routeIndex: 0 });
h.b.addBuff(far, { key: 'test:taunt', mods: { taunt: 10 } });
let did = false;
h.b.on('tick', () => {
if (did || h.b.time < 7 / 30 - 1e-9 || u.blocking.length < 1) return;
did = true;
h.b.kill(u, null);
assert.ok(h.b.redeploy(u, { free: true }));
assert.equal(u.blocking.length, 0, 'redeploy clears the block list before the next tick');
});
h.step(8);
assert.equal(did, true);
h.step(1);
assert.deepEqual([u.tileR, u.tileC], [10, 4], 'the enemy is still on this tile when the revive poll runs');
assert.equal(local.blockedBy, u);
done(h);
});
test('an ordinary Raid poll prefers the nearer gate over a farther taunt', () => {
const probe = chessRec({ id: 'probe', position: 'MELEE', bonds: ['raidShip'], rangeGrid: [[0, 0]], skill: null, stats: { maxHp: 5000, atk: 10, block: 2, def: 0 } });
const h = makeBattle({ autoFinish: false,
defs: { chess: { probe }, enemies: { dummy: enemyRec({ key: 'dummy', hp: 1e9, atk: 0, speed: 0 }) } },
bonds: { raidShip: { count: 2, active: true, tier: 1, layers: 5 } },
units: [{ uid: 1, chessId: 'probe', row: 10, col: 5 }] });
h.step(); const u = h.unit(1);
const near = h.spawn('dummy', { pos: [9, 3], routeIndex: 0 });
const far = h.spawn('dummy', { pos: [9, 8], routeIndex: 0 });
h.b.addBuff(far, { key: 'test:taunt', mods: { taunt: 10 } });
assert.ok(h.b.remainingDistance(near) < h.b.remainingDistance(far));
h.run(10.5);
assert.ok(h.b.enemiesInKeys(u.rangeKeys, u, u.profile).includes(near), 'ordinary order is remaining distance, then id');
assert.ok(!h.b.enemiesInKeys(u.rangeKeys, u, u.profile).includes(far));
done(h);
});
test('a retreat redeploy does not arm the Raid revive reselect', () => {
const wide = chessRec({ id: 'wide', position: 'RANGED', bonds: ['raidShip'], rangeGrid: [[0, 0], [0, 1], [0, -1]], skill: null, stats: { maxHp: 5000, atk: 10, block: 0 } });
const h = makeBattle({ autoFinish: false,
defs: { chess: { wide }, enemies: { dummy: enemyRec({ key: 'dummy', hp: 1e9, atk: 0, speed: 0 }) } },
bonds: { raidShip: { count: 2, active: true, tier: 1, layers: 5 } },
units: [{ uid: 1, chessId: 'wide', row: 10, col: 5 }] });
h.step(); const u = h.unit(1);
const e = h.spawn('dummy', { pos: [10, 6], routeIndex: 0 });
h.run(0.4);
assert.ok(h.b.enemiesInKeys(u.rangeKeys, u, u.profile).includes(e));
const tile = [u.tileR, u.tileC];
h.b.retreat(u, { reason: 'retreat' });
assert.ok(h.b.redeploy(u, { free: true }));
const seq = u.deploySeq;
h.run(0.3);
assert.deepEqual([u.tileR, u.tileC], tile, 'an enemy already in range keeps the old poll from jumping');
assert.equal(u.deploySeq, seq);
done(h);
});
test('a revived Raid operator that has already blocked an enemy stays in place', () => {
const h = makeBattle({ autoFinish: false,
defs: { enemies: { dummy: enemyRec({ key: 'dummy', hp: 1e9, atk: 0, speed: 0 }) } },
bonds: { raidShip: { count: 2, active: true, tier: 1, layers: 5 } },
units: [{ uid: 1, chessId: 'chess_char_4_16_a', skillIndex: 1, row: 10, col: 4 }] });
h.step(); const u = h.unit(1);
const local = h.spawn('dummy', { pos: [10, 4], routeIndex: 0 });
const far = h.spawn('dummy', { pos: [10, 8], routeIndex: 0 });
h.b.addBuff(far, { key: 'test:taunt', mods: { taunt: 10 } });
h.b.kill(u, null); assert.ok(h.b.redeploy(u, { free: true }));
h.step(2); assert.equal(local.blockedBy, u);
h.run(0.3); assert.deepEqual([u.tileR, u.tileC], [10, 4]); done(h);
});
for (const taunt of [false, true]) test(`revived unblocked Texas S2 reselects a Raid target by taunt then remaining route (${taunt})`, () => {
const h = makeBattle({ autoFinish: false, hooks: ['deploy'],
defs: { enemies: { dummy: enemyRec({ key: 'dummy', hp: 1e9, atk: 0, speed: 0 }) } },
bonds: { raidShip: { count: 2, active: true, tier: 1, layers: 5 } },
units: [{ uid: 1, chessId: 'chess_char_4_16_a', skillIndex: 1, row: 10, col: 4 }] });
h.step(); const u = h.unit(1);
h.spawn('dummy', { pos: [10, 5.2], routeIndex: 0 });
const nearGate = h.spawn('dummy', { pos: [9, 3], routeIndex: 0 });
const far = h.spawn('dummy', { pos: [10, 8], routeIndex: 0 });
if (taunt) h.b.addBuff(far, { key: 'test:taunt', mods: { taunt: 10 } });
h.run(0.3); assert.deepEqual([u.tileR, u.tileC], [10, 4], 'initial deployment still uses the ordinary range gate');
h.b.kill(u, null); assert.ok(h.b.redeploy(u, { free: true }));
assert.equal(u.blocking.length, 0);
h.run(0.3);
const target = taunt ? far : nearGate;
assert.ok(h.b.enemiesInKeys(u.rangeKeys, u, u.profile).includes(target), 'the higher-priority target is in the new range');
assert.notDeepEqual([u.tileR, u.tileC], [10, 4]);
const count = u.deploySeq; h.run(0.3); assert.equal(u.deploySeq, count, 'Raid itself cannot continuously rearm the revival check');
done(h);
});
test('Skadi S1 and 坚守 sharing retain transfer ancestry and do not revisit either sharing effect', () => {
const op = (id, bonds) => chessRec({ id, bonds, skill: null, stats: { maxHp: 1e7, atk: 0, def: 0 } });
const h = makeBattle({ autoFinish: false, defs: { chess: {
a: op('a', ['steadShip']), b: op('b', ['steadShip']), c: op('c', ['steadShip']), d: op('d', []),
} }, bonds: { steadShip: { count: 3, active: true, tier: 2, layers: 10 } },
units: [{ uid: 1, chessId: 'chess_char_6_04_a', row: 10, col: 4, skillIndex: 0 },
{ chessId: 'a', row: 9, col: 4 }, { chessId: 'b', row: 11, col: 4 },
{ chessId: 'c', row: 10, col: 3 }, { chessId: 'd', row: 10, col: 5 }] });
h.step(); assert.ok(h.unit(1).skill.activate('test', { free: true }));
let attempts = 0;
// Fail fast on the broken implementation without letting the branching recursion freeze the test worker.
h.b.on('hit', (c) => { if (++attempts > 64) c.dmg.cancel = true; }, { priority: 1000 });
h.b.dealDamage(null, h.unit('d'), { amount: 1000, type: 'true' });
assert.ok(attempts <= 12, `bounded transfer tree, got ${attempts} hits`);
assert.ok(h.unit('d').hp < h.unit('d').s.maxHp); done(h);
});
for (const mode of ['both', 'stead', 'skadi']) test(`Skadi S1 and 坚守 conserve a 1000 true hit (${mode})`, () => {
const op = (id, bonds) => chessRec({ id, bonds, skill: null, stats: { maxHp: 1e7, atk: 0, def: 0 } });
const both = mode === 'both';
const units = [];
if (mode !== 'stead') units.push({ uid: 1, chessId: 'chess_char_6_04_a', row: 10, col: 4, skillIndex: 0 });
units.push({ chessId: 'a', row: 9, col: 4 }, { chessId: 'b', row: 11, col: 4 }, { chessId: 'c', row: 10, col: 3 }, { chessId: 'd', row: 10, col: 5 });
const h = makeBattle({ autoFinish: false, defs: { chess: {
a: op('a', ['steadShip']), b: op('b', ['steadShip']), c: op('c', ['steadShip']), d: op('d', []),
} }, bonds: mode === 'skadi' ? {} : { steadShip: { count: 3, active: true, tier: 2, layers: 10 } }, units });
h.step();
if (mode !== 'stead') assert.ok(h.unit(1).skill.activate('test', { free: true }));
let attempts = 0;
h.b.on('hit', (c) => { if (++attempts > 64) c.dmg.cancel = true; }, { priority: 1000 });
const loss = (id) => { const u = h.unit(id); return u.s.maxHp - u.hp; };
h.b.dealDamage(null, h.unit('d'), { amount: 1000, type: 'true' });
const parts = [loss('d'), loss('a'), loss('b'), loss('c')];
if (mode !== 'stead') parts.push(loss(1));
const sum = parts.reduce((s, n) => s + n, 0);
const near = (got, want) => Math.abs(got - want) < 1e-3;
assert.ok(near(sum, 1000), `total ${sum}`);
if (both) {
assert.ok(near(loss('d'), 100), `victim ${loss('d')}`);
for (const id of ['a', 'b', 'c']) assert.ok(near(loss(id), 400 / 3), `${id} ${loss(id)}`);
assert.ok(near(loss(1), 500), `skadi ${loss(1)}`);
assert.ok(attempts <= 12, `bounded transfer tree, got ${attempts} hits`);
const before = attempts;
h.b.dealDamage(null, h.unit('d'), { amount: 1000, type: 'true' });
assert.ok(attempts - before <= 12, `second hit stayed bounded, got ${attempts - before}`);
assert.ok(near(loss('d'), 200) && near(loss(1), 1000), 'the second hit conserves the same split');
} else if (mode === 'stead') {
assert.ok(near(loss('d'), 600), `victim ${loss('d')}`);
for (const id of ['a', 'b', 'c']) assert.ok(near(loss(id), 400 / 3), `${id} ${loss(id)}`);
assert.ok(attempts <= 4, `stead-only hits ${attempts}`);
} else {
assert.ok(near(loss('d'), 500) && near(loss(1), 500));
assert.ok(near(loss('a'), 0) && near(loss('b'), 0) && near(loss('c'), 0));
assert.ok(attempts <= 2, `skadi-only hits ${attempts}`);
}
done(h);
});
for (const combo of [false, true]) test(`家族徽章 consumes its bonus on the first damage immediately after stealth ends (combo=${combo})`, () => {
const op = chessRec({ id: 'op', bonds: ['siracusaShip'], skill: null, rangeGrid: [[0, 0]], stats: { atk: 1000 } });
const h = makeBattle({ autoFinish: false, defs: { chess: { op }, enemies: { dummy: enemyRec({ key: 'dummy', hp: 1e9, atk: 0, speed: 0 }) } },
units: [{ uid: 1, chessId: 'op', row: 10, col: 4, items: ['chess_item_6_11_e_b', ...(combo ? ['chess_item_3_01_e_a'] : [])] }] });
h.step(); const u = h.unit(1), base = u.s.atk;
h.b.addBuff(u, { key: 'test:hidden', flags: { stealth: true } }); h.run(2);
const boosted = u.s.atk; assert.ok(boosted > base);
h.b.removeBuff(u, 'test:hidden'); const e = h.spawn('dummy', { pos: [10, 9] }); const hp = e.hp;
h.b.dealDamage(u, e, { amount: 100, type: 'true', isAttack: true });
assert.equal(u.s.atk, base, 'no waiting for the next 0.25-second growth poll');
assert.ok(Math.abs(hp - e.hp - (100 + (combo ? boosted * 8 : 0))) < 1e-6);
const hp2 = e.hp; h.b.dealDamage(u, e, { amount: 100, type: 'true', isAttack: true });
assert.equal(hp2 - e.hp, 100, 'bonus consumed once'); done(h);
});
for (const elite of [false, true]) test(`松果 S2 growth restarts on redeployment without resetting the lifetime activation sequence (${elite})`, () => {
const id = `chess_char_3_10_${elite ? 'b' : 'a'}`;
const h = makeBattle({ autoFinish: false, units: [{ uid: 1, chessId: id, skillIndex: 1, row: 10, col: 4 }] });
h.step(); const u = h.unit(1), values = [];
for (let i = 0; i < 3; i++) {
assert.ok(u.skill.activate('test', { free: true })); values.push(u.findBuff('skill:pinecn_atk').mods.atkPct); u.skill.end('test');
}
assert.ok(values[2] > values[0]); const activations = u.skill.activations;
h.b.retreat(u, { reason: 'test' }); assert.ok(h.b.redeploy(u, { free: true }));
assert.ok(u.skill.activate('test', { free: true }));
assert.equal(u.findBuff('skill:pinecn_atk').mods.atkPct, values[0]);
assert.equal(u.skill.activations, activations + 1); done(h);
});
for (const elite of [false, true]) test(`哈洛德 S2 starts for an ally at full HP with only elemental injury (${elite})`, () => {
const wall = chessRec({ id: 'wall', skill: null, stats: { maxHp: 1e6, atk: 0 } });
const h = makeBattle({ autoFinish: false, defs: { chess: { wall } },
units: [{ uid: 1, chessId: `chess_char_2_05_${elite ? 'b' : 'a'}`, skillIndex: 1, row: 10, col: 4 },
{ uid: 2, chessId: 'wall', row: 10, col: 5 }] });
h.step(); const u = h.unit(1), a = h.unit(2); assert.equal(u.skill.id, 'skchr_harold_2');
u.skill.gainSp(999, 'init'); h.run(0.5); assert.equal(u.skill.activations, 0, 'no injury means no cast');
h.b.dealDamage(null, a, { type: 'element', element: 'burn', amount: 600 }); assert.equal(a.hp, a.s.maxHp);
assert.ok(h.runUntil(() => u.skill.activations > 0, 4)); assert.ok(a.elem.burn < 600); done(h);
});
+26
View File
@@ -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);
});
}
+106 -3
View File
@@ -186,6 +186,67 @@ test(`${nm('enemy_9008_acbunn')}: attacks several targets at once while stealthe
assert.ok(atk.targets.length >= 2, `targets ${atk.targets.length}`);
});
// 骨刺 — PRTS 天赋 (级别0) 「自身普通攻击索敌不受阻挡影响 / 不会攻击飞行单位,隐匿(解除阻挡0秒后恢复) / 隐匿期间可同时攻击3个目标」
// and the level description 「隐匿;隐匿状态下同时攻击3个目标。」 (PR #365, the targeting part checked against PRTS).
function boneArena(o = {}) {
const h = arena({
units: ['t_wall', 't_wall2', 't_wall3', 't_wall4'].map((chessId, i) => ({ chessId, row: 9 + (i % 2), col: 6 + Math.floor(i / 2) })),
captureNoisy: true, hooks: ['attack'], ...o,
});
h.step();
for (const u of h.allies()) h.b.addBuff(u, { key: 'test:noBlock', persist: true, flags: { noBlock: true } });
const e = put(h, 'enemy_9008_acbunn', [9, 6]);
return { h, e };
}
/** The targets of `e`'s next attack. */
function boneNext(h, e) {
const n = h.hooksOf('attack').filter((c) => c.attacker === e).length;
assert.ok(h.runUntil(() => h.hooksOf('attack').filter((c) => c.attacker === e).length > n, 8), 'a new attack');
return h.hooksOf('attack').filter((c) => c.attacker === e)[n].targets;
}
test(`${nm('enemy_9008_acbunn')}: 3 targets while hidden, 1 once blocked or revealed — by priority, not its blocker first; 3 again when hidden`, () => {
for (const cause of ['block', 'reveal']) {
const { h, e } = boneArena();
const wall = h.unit('t_wall'), taunter = h.unit('t_wall4');
assert.equal(new Set(boneNext(h, e)).size, 3, `${cause}: hidden — three distinct targets of the four in reach`);
h.b.addBuff(taunter, { key: 'test:taunt', mods: { taunt: 5 } });
if (cause === 'block') { h.b.removeBuff(wall, 'test:noBlock'); h.step(2); assert.equal(e.blockedBy, wall); }
else h.b.addBuff(e, { key: 'test:reveal', persist: true, flags: { reveal: true } });
assert.deepEqual(boneNext(h, e), [taunter], `${cause}: one target, the taunt-5 one (索敌不受阻挡影响)`);
if (cause === 'block') { h.b.addBuff(wall, { key: 'test:noBlock', persist: true, flags: { noBlock: true } }); h.b.releaseBlocked(wall); }
else h.b.removeBuff(e, 'test:reveal');
assert.equal(boneNext(h, e).length, 3, `${cause}: hidden again (0 s after the block) — three`);
checkInvariants(h.b);
}
});
test(`${nm('enemy_9008_acbunn')}: never targets a flying unit (炎佑, taunt 5), hidden or revealed`, () => {
for (const revealed of [false, true]) {
const { h, e } = boneArena({ content: 'full', extraContent: [] });
const [air] = spawnYanyou(h.b, 'p1', { count: 1, hp: 50000 });
assert.ok(air?.isFlying);
air.x = 6.5; air.y = 9;
h.b.applyStatus(air, 'stun', { duration: 30, force: true });
h.b.addBuff(air, { key: 'test:taunt', mods: { taunt: 5 } });
if (revealed) h.b.addBuff(e, { key: 'test:reveal', persist: true, flags: { reveal: true } });
const t = boneNext(h, e);
assert.equal(t.length, revealed ? 1 : 3);
assert.ok(!t.includes(air), `${revealed ? 'revealed' : 'hidden'}: the flyer is no target`);
}
});
test(`${nm('enemy_9008_acbunn')}: its 3 hidden targets come from its normal attack's reach (2 + the 0.25 ally collider)`, () => {
const { h, e } = boneArena();
const edge = h.unit('t_wall3'), outside = h.unit('t_wall4');
edge.x = e.x + 2.1; edge.y = e.y;
outside.x = e.x + 2.3; outside.y = e.y;
h.b.addBuff(edge, { key: 'test:taunt', mods: { taunt: 5 } });
const t = boneNext(h, e);
assert.ok(t.includes(edge), 'the ally 2.1 away is one of the three');
assert.ok(!t.includes(outside), 'the one 2.3 away is out of reach');
});
test(`${nm('enemy_1175_dushdo_2')}: stealth; next to 深池伙友卫队精英 its attack interval drops by traitAbility.base_attack_time`, () => {
const h = arena();
h.step();
@@ -1037,6 +1098,28 @@ test(`${nm('enemy_1112_emppnt')} / 中枢: every attack is a shell that lands 3
}
});
test(`${nm('enemy_1112_emppnt_2')} / 先兆者: the shell's 1.2 circle takes every ally whose collider touches it — the impact tile and the 8 around it, not the cross (GitHub #364)`, () => {
for (const key of ['enemy_1112_emppnt_2', 'enemy_1112_emppnt']) {
// the enemy on (10, 8) reaches only the wall on (10, 6): the impact; the others stand around it, out of its reach
const h = arena({ units: [
{ chessId: 't_wall', row: 10, col: 6 }, { chessId: 't_wall2', row: 10, col: 5 },
{ chessId: 't_wall3', row: 9, col: 5 }, { chessId: 't_wall4', row: 10, col: 4 },
], captureNoisy: true });
h.step();
const e = put(h, key, [10, 8], { route: 2 });
assert.ok(h.runUntil(() => e.stats.attacks >= 1, 10), 'attacks');
const shell = h.eventsOf('fx').find((ev) => ev[1] === 'bombardShell');
assert.ok(Math.abs(shell[4].r - 1.45) < 1e-9, 'drawn at the reach: 1.2 + the 0.25 collider');
h.run(3.1);
const taken = (id) => h.unit(id).stats.taken;
for (const [id, what] of [['t_wall', 'the impact'], ['t_wall2', 'its orthogonal neighbour (1 away)'], ['t_wall3', 'a diagonal neighbour (√2 away)']]) {
assert.ok(Math.abs(taken(id) - e.s.atk) < 1e-6, `${key}: ${what} takes the shell`);
}
assert.equal(taken('t_wall4'), 0, `${key}: two tiles away stays out`);
checkInvariants(h.b);
}
});
test(`${nm('enemy_10084_hlegle')}: never attacks a flying ally (不会攻击飞行单位) — a ground one in range instead`, () => {
// a flying ally (the 炎佑 is one) deployed after the wall, so the engine's order (latest deployed) would pick it
const h = arena({ units: [{ chessId: 't_wall', row: 10, col: 6 }, { chessId: 't_wall2', row: 10, col: 8 }] });
@@ -1985,7 +2068,7 @@ test(`${nm('enemy_10098_crhro')}: 重生 once after ${tb('enemy_10098_crhro', 'r
assert.ok(!e.alive);
});
test('失衡: 弧光锋卫 bleeds per tile pushed; 冒失的小弟 is stunned; 拥霜羽兽 drops its egg (faster, unblockable)', () => {
test('失衡: 弧光锋卫 bleeds every 0.066 s while unbalanced; 冒失的小弟 is stunned; 拥霜羽兽 drops its egg (faster, unblockable)', () => {
const h = arena({ hooks: ['statusApplied'] });
h.step();
const j = put(h, 'enemy_1328_cbjedi', [10, 7]);
@@ -1993,17 +2076,20 @@ test('失衡: 弧光锋卫 bleeds per tile pushed; 冒失的小弟 is stunned;
const p = put(h, 'enemy_10141_xdpeng_2', [12, 7]);
h.step(2);
const hp0 = j.hp;
const moved = h.b.displace(j, { x: 1, y: 0 }, 1, { force: 3 });
// 弧光锋卫: its 失衡 state — a 受力等级 0 push holds 0.8 s (24 frames) — bleeds unbalanced_bleed.damage every .interval s
// (PRTS "处于失衡状态时,每0.066s受到400点无来源真实持续伤害"): 12 hits; until 0.2.2 one hit per tile moved [ASSUMED]
h.b.push(j, j.s.massLevel, { from: { x: j.x - 1, y: j.y } });
h.b.displace(g, { x: 1, y: 0 }, 1, { force: 3 });
h.b.displace(p, { x: 1, y: 0 }, 1, { force: 3 });
h.step();
approx(hp0 - j.hp, (tb('enemy_1328_cbjedi', 'unbalanced_bleed.damage') * moved) / (tb('enemy_1328_cbjedi', 'unbalanced_bleed.interval') * 5), 0.05);
assert.equal(statuses(h, g.id, 'stun').length, 1);
approx(statuses(h, g.id, 'stun')[0].duration, tb('enemy_10112_ymgds', 'StunAfterUnbalance.stun'));
assert.ok(p.s.flags.unblockable);
assert.equal(p.findBuff('ab:noEgg').mods.moveMul, 1 + tb('enemy_10141_xdpeng_2', 'speed.move_speed'));
assert.ok(p.profile.noAttack, '拥霜羽兽 失去蛋的模式: 不进行普通攻击 (no attack, so no stand for its clip)');
assert.equal(typeof p.profile.canTarget === 'function' && p.profile.canTarget({ isFlying: true }), false, '拥霜羽兽: 不会攻击飞行单位');
h.run(0.8);
approx(hp0 - j.hp, 12 * tb('enemy_1328_cbjedi', 'unbalanced_bleed.damage'), 1e-6);
// 雪孩子: pushed into high ground (row 12 col 2 is 'h' on the flat stage) ⇒ hitWall.value
const sn = put(h, 'enemy_10138_xdsnow', [10, 3]);
const sn2 = put(h, 'enemy_10138_xdsnow', [11, 6]);
@@ -2627,6 +2713,23 @@ test('假想敌:铳 (h07_02 override) never uses 最终之罚 in the regular b
assert.ok(!h.eventsOf('fx').some((f) => f[1] === 'charge'));
});
test('隐秘核心 铳: another gun alone grants 80% reduction from the very first hit; no stale reduction after it leaves', () => {
const h = bossArena({ units: [{ chessId: 't_wall', row: 10, col: 7 }] });
h.step();
const g = put(h, 'enemy_9017_achunt_2', [3, 10], { tag: 'boss' });
const other = put(h, 'enemy_9017_achunt_2', [3, 4], { tag: 'boss' });
const full = 1000 * (1 - g.s.res / 100);
approx(h.b.dealDamage(null, g, { amount: 1000, type: 'arts' }), full * 0.2);
// Leaving the field without killing the shared boss pool isolates the companion condition.
other.alive = false; other.deployed = false;
approx(h.b.dealDamage(null, g, { amount: 1000, type: 'arts' }), full);
const spring = put(h, 'enemy_9020_actrpc', [3, 6], { tag: 'part' });
approx(h.b.dealDamage(null, g, { amount: 1000, type: 'arts' }), full * 0.2);
spring.alive = false; spring.deployed = false;
approx(h.b.dealDamage(null, g, { amount: 1000, type: 'arts' }), full);
checkInvariants(h.b); assert.deepEqual(h.b.errors, []);
});
test('假想敌:铳 (隐秘核心): damage ×0.2 while springs live; 盲信之誓 lines hurt operators on them; 末日布道 dash', () => {
const h = bossArena({ units: [{ chessId: 't_wall', row: 10, col: 7 }], setup(b) { b.enemyOverrides = W.act1autochess_h08_02.overrides; } });
h.step();
+2
View File
@@ -195,6 +195,8 @@ test('薄绿 (3_08) 聚能涡旋 pushes the target towards her centre whatever h
fill(u);
assert.ok(h.runUntil(() => u.skill.active, 5));
h.run(3);
assert.ok(h.eventsOf('fx').some((f) => f[1] === 'displace' && f[4]?.keepFacing === true),
`${dir}: Mint S2's inward push inherits the common facing metadata`);
return h.enemy('enemy_d');
};
for (const dir of ['UP', 'RIGHT']) {
+105
View File
@@ -0,0 +1,105 @@
// Player-confirmed: 远牙's granted trait gains on body / substitute switches.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, chessRec, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
const bond = (active = true, layers = 0) => ({ count: active ? 3 : 0, active, tier: active ? 1 : 0, layers });
const gains = (h) => h.result().perPlayer.p1.layerGains;
const enter = (h, u) => {
h.b.dealDamage(null, u, { amount: 1e9, type: 'true' });
assert.ok(u.alive && u.deployed && u.trait.doll, 'the lethal body hit switches to a substitute');
};
const leave = (h, u) => {
h.b.removeBuff(u, 'trait:substitute');
assert.ok(u.alive && u.deployed && !u.trait.doll, 'ending the substitute returns to the body');
};
for (const sfx of ['a', 'b']) {
for (const id of [`chess_char_2_11_${sfx}`, `chess_char_5_13_${sfx}`]) {
test(`远牙 + ${id}: both swaps gain 精准/卡西米尔, with the original battle cap`, () => {
const per = sfx === 'a' ? 4 : 8;
const cap = per * 6;
const h = makeBattle({
units: [{ chessId: `chess_char_4_20_${sfx}`, row: 10, col: 3 }, { chessId: id, row: 10, col: 8 }],
bonds: { kazimierzShip: bond(), preciShip: bond() },
autoFinish: false, timeLimit: 300, hooks: ['dollSwap'],
});
h.step();
const u = h.unit(id);
assert.deepEqual(gains(h), { kazimierzShip: per, preciShip: per }, 'initial deployment');
enter(h, u);
assert.deepEqual(gains(h), { kazimierzShip: per * 2, preciShip: per * 2 }, 'gain at the switch event, before another tick');
h.runUntil(() => !u.trait.doll, 30);
assert.deepEqual(gains(h), { kazimierzShip: per * 3, preciShip: per * 3 }, 'natural return to body');
h.run(2); // finish the return animation before the next lethal hit
for (let i = 0; i < 4; i++) {
enter(h, u);
leave(h, u); // two real swaps between ticks must both count
h.run(2);
}
assert.deepEqual(gains(h), { kazimierzShip: cap, preciShip: cap }, 'swaps share the initial deployment cap');
assert.equal(h.hooksOf('dollSwap').filter((c) => c.unit === u).length, 10);
h.b.retreat(u, { reason: 'test' });
h.b.redeploy(u, { free: true });
assert.deepEqual(gains(h), { kazimierzShip: cap, preciShip: cap }, 'a later real deployment does not reset the cap');
checkInvariants(h.b);
});
}
}
test('远牙 grants once to a dollkeeper even with duplicate holders; inactive 精准 does not gain or consume its cap', () => {
const id = 'chess_char_2_11_a';
const h = makeBattle({
units: [{ chessId: 'chess_char_4_20_a', row: 10, col: 3 }, { chessId: 'chess_char_4_20_a', row: 10, col: 5 }, { chessId: id, row: 10, col: 8 }],
bonds: { kazimierzShip: bond(), preciShip: bond(false) }, autoFinish: false,
});
h.step();
const u = h.unit(id);
enter(h, u);
assert.deepEqual(gains(h), { kazimierzShip: 8 }, 'one granted trait, no inactive bond');
Object.assign(h.b.getPlayer('p1').bonds.preciShip, bond());
leave(h, u);
assert.deepEqual(gains(h), { kazimierzShip: 12, preciShip: 4 });
checkInvariants(h.b);
});
test('归鲨 S2 ending triggers the trait granted by 远牙', () => {
const id = 'chess_char_5_13_a';
const h = makeBattle({
defs: { enemies: { dummy: enemyRec({ key: 'dummy', hp: 1e8, atk: 0, speed: 0 }) } },
enemies: [{ key: 'dummy', pos: [10, 9] }],
units: [{ chessId: 'chess_char_4_20_a', row: 10, col: 3 }, { chessId: id, row: 10, col: 8 }],
bonds: { kazimierzShip: bond(), preciShip: bond() }, autoFinish: false,
});
h.step();
const u = h.unit(id);
u.skill.gainSp(1000);
assert.ok(h.runUntil(() => u.skill.active, 3));
assert.ok(h.runUntil(() => u.trait.doll, 30), 'S2 switches form when it ends');
assert.deepEqual(gains(h), { kazimierzShip: 8, preciShip: 8 });
checkInvariants(h.b);
});
test('other deployment layer traits and timed attribute windows keep their existing triggers', () => {
const h = makeBattle({
defs: { chess: { doll: {
...chessRec({ id: 'doll', profession: 'SPECIAL', subProfessionId: 'dollkeeper', bonds: ['raidShip'], skill: null }),
garrisonIds: ['garrison_106_a', 'garrison_107_a', 'garrison_143_a', 'garrison_155_a', 'garrison_115_a'],
} } },
units: [{ chessId: 'doll', row: 10, col: 8 }], bonds: { raidShip: bond(), kazimierzShip: bond(true, 10) },
autoFinish: false,
});
h.step();
const u = h.unit('doll');
const initial = { ...gains(h) };
h.run(5);
const remaining = u.findBuff('gar:garrison_115_a').timeLeft;
enter(h, u);
assert.equal(u.findBuff('gar:garrison_115_a').timeLeft, remaining, 'switch does not refresh the window');
h.run(2);
const beforeReturn = u.findBuff('gar:garrison_115_a').timeLeft;
leave(h, u);
assert.equal(u.findBuff('gar:garrison_115_a').timeLeft, beforeReturn);
assert.deepEqual(gains(h), initial, 'no additional layers from other deployment traits');
checkInvariants(h.b);
});
+2 -2
View File
@@ -450,8 +450,8 @@ test('enemy-side BUFF damage (PRTS 伤害分类 "深水区/涨潮水蚀", "弧
const j = h.spawn('enemy_1328_cbjedi', { pos: [10, 7], routeIndex: 0, mods: { speedMul: 0 } });
h.step(2);
const hp0 = j.hp;
h.b.displace(j, { x: 1, y: 0 }, 1, { force: 3 });
h.step();
h.b.push(j, 3, { from: { x: j.x - 1, y: j.y } }); // its 失衡 state (the bleed runs while it lasts)
h.run(0.2);
const bleed = h.hooksOf('damaged').filter((c) => c.target === j);
assert.ok(bleed.length >= 1 && hp0 > j.hp, 'bled');
for (const c of bleed) { assert.equal(c.source, null); assert.equal(c.type, 'true'); assert.ok(!hpLoss(c), '失衡 bleed is damage'); }
+12 -3
View File
@@ -49,7 +49,7 @@ test('50: two 空弦 made 阿戈尔 (变形同构体 + 阿戈尔重刃) in a row
}
});
test('45: 寻呼模块 / 简易通讯机 on 缪尔赛思 below shop level 6 — nothing qualifies (调和, tier ≤ shop level), the item is used up', () => {
test('45: 寻呼模块 / 简易通讯机 on 缪尔赛思 below shop level 6 — nothing qualifies (调和, tier ≤ shop level), the item is used up with a toast saying why', () => {
for (const itemId of ['chess_item_4_01_e_a', 'chess_item_2_06_e_a']) {
for (const level of [5, 6]) {
const h = makeMatch({ mode: 'solo', difficulty: 'NORMAL', humans: 1, seed: 7, fake: true }).start();
@@ -60,11 +60,20 @@ test('45: 寻呼模块 / 简易通讯机 on 缪尔赛思 below shop level 6 —
const item = ps.acquireItem(itemId, { source: 'test' });
const owned = () => [...ps.board.values(), ...ps.hand, ...ps.temp].filter((p) => p && p.id === 'chess_char_6_11_a').length;
const n0 = owned(), offers0 = ps.offers.length;
const warns = [];
const toast0 = h.m.toast.bind(h.m);
h.m.toast = (who, kind, text) => { if (kind === 'warn') warns.push(text?.msgid ?? text); return toast0(who, kind, text); };
assert.deepEqual(h.m.handle('p_0', { t: 'g.equip', itemUid: item.uid, targetUid: mly.uid }), { ok: true });
assert.equal(ps.find(item.uid), null, `${itemId} @${level}: consumed`);
const got = owned() - n0 + (ps.offers.length - offers0);
if (level < 6) assert.equal(got, 0, `${itemId} @${level}: nothing (no 调和 operator of tier ≤ ${level})`);
else assert.equal(got, 1, `${itemId} @6: 缪尔赛思 (granted, or offered by 寻呼模块)`);
// used up with nothing: a toast says why (GitHub #401, the owner's OK of 2026-10-09)
if (level < 6) {
assert.equal(got, 0, `${itemId} @${level}: nothing (no 调和 operator of tier ≤ ${level})`);
assert.deepEqual(warns, ['{who}:没有可获得的同盟约干员'], `${itemId} @${level}: the toast`);
} else {
assert.equal(got, 1, `${itemId} @6: 缪尔赛思 (granted, or offered by 寻呼模块)`);
assert.deepEqual(warns, [], `${itemId} @6: no toast`);
}
}
}
});
@@ -0,0 +1,106 @@
// test/content/feedback7-allytarget-kits.test.js — a skill that 白铁's 铁钳号·原型机 (a registered ally target) opens must act on
// it (Grok's review of round 22: 蕾缪安 S3, 艾丽妮 S3, 陈 S2 and 引星棘刺 S2 opened on the device alone and were spent on
// nothing — their kits picked enemies only). The owner's rule (2026-10-08): the device draws aggro like an enemy. So:
// - these four kits now take ally targets in their selectors (the hits are cancelled by the device, its 受击回复 counts;
// 蕾缪安's locks spend her bullets);
// - a skill whose effect does not reach it — one that picks its own victims and has no kit flag `allyTargets: true` — is
// not opened by it (skills.js `allyTargetsOk`), e.g. 德克萨斯 S2 剑雨: it waits for an enemy.
// Run: node --test test/content/feedback7-allytarget-kits.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, chessRec, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
const CRAB = 'token_10027_ironmn_pile3';
const IRON = { uid: 2, diy: { slot: 6, charId: 'char_4072_ironmn', skillIndex: 2 }, elite: true, row: 12, col: 9, dir: 'RIGHT' };
const crabAt = (col) => ({ uid: 10, kind: 'token', tokenId: CRAB, ownerUid: 2, row: 10, col, dir: 'RIGHT' });
const diy = (charId, skillIndex) => ({ uid: 1, diy: { slot: 6, charId, skillIndex }, elite: true, row: 10, col: 4, dir: 'RIGHT' });
function field(unit, extra = {}) {
const h = makeBattle({
timeLimit: 60, autoFinish: false, seed: 3, flags: { dpPerSec: 0, dpMax: 999, dpInit: 99 },
hooks: ['hit', 'skillStart', 'skillEnd', 'ammoUsed'], captureNoisy: true,
defs: { enemies: { e_d: enemyRec({ key: 'e_d', hp: 1e7, speed: 0, def: 0, atk: 0 }) } },
units: [unit, IRON, crabAt(5)], ...extra,
});
h.step();
const u = h.unit(1);
const crab = h.b.allyUnits.find((a) => a.defId === CRAB);
return { h, u, crab };
}
/** Hits of `u`'s skill on the device (cancelled by it). */
const skillHits = (h, u, crab) => h.hooksOf('hit').filter((c) => c.target === crab && (c.source === u || c.credit === u) && c.dmg?.isSkill);
test('蕾缪安 S3: on the device alone she locks it, spends her bullets on it and ends with the last one', () => {
const { h, u, crab } = field({ uid: 1, chessId: 'chess_char_6_01_a', skillIndex: 2, row: 10, col: 4, dir: 'RIGHT' });
assert.equal(u.skill.id, 'skchr_lemuen_3');
u.skill.gainSp(999, 'test');
const ammo = u.skill.ammo;
assert.ok(h.runUntil(() => u.skill.activations > 0, 5), 'opened');
assert.ok(h.runUntil(() => !u.skill.active, 15), 'ended');
assert.ok(h.hooksOf('skillEnd').some((c) => c.unit === u && c.reason === 'ammo'), 'by its bullets');
assert.ok(h.hooksOf('ammoUsed').filter((c) => c.unit === u).length >= ammo, `${ammo} bullets spent`);
assert.equal(crab.hp, crab.s.maxHp);
checkInvariants(h.b);
});
test('艾丽妮 S3 / 陈 S2: on the device alone their strikes land on it (cancelled)', () => {
for (const [charId, si, id] of [['char_4009_irene', 2, 'skchr_irene_3'], ['char_010_chen', 1, 'skchr_chen_2']]) {
const { h, u, crab } = field(diy(charId, si));
assert.equal(u.skill.id, id);
u.skill.gainSp(999, 'test');
assert.ok(h.runUntil(() => u.skill.activations > 0, 5), `${id} opened`);
h.run(4);
assert.ok(skillHits(h, u, crab).length >= 1, `${id} hit the device`);
assert.equal(crab.hp, crab.s.maxHp);
checkInvariants(h.b);
}
});
test('引星棘刺 S2: on the device alone the alchemy unit lands on it, not on the far tile ahead', () => {
const { h, u, crab } = field({ uid: 1, chessId: 'chess_char_5_15_a', skillIndex: 1, row: 10, col: 4, dir: 'RIGHT' });
assert.equal(u.skill.id, 'skchr_thorn2_2');
u.skill.gainSp(999, 'test');
assert.ok(h.runUntil(() => u.skill.activations > 0, 5), 'opened');
const z = u.mem.zones?.[0];
assert.ok(z, 'a zone');
assert.ok(Math.abs(z.x - crab.x) < 0.3 && Math.abs(z.y - crab.y) < 0.3, `thrown at the device (${z.x}, ${z.y})`);
checkInvariants(h.b);
});
test('a skill whose effect picks enemies only (德克萨斯 S2 剑雨) is not opened by the device; an enemy opens it', () => {
const { h, u } = field({ uid: 1, chessId: 'chess_char_1_08_a', skillIndex: 1, row: 10, col: 4, dir: 'RIGHT' });
assert.equal(u.skill.id, 'skchr_texas_2');
u.skill.gainSp(999, 'test');
const ch = u.skill.charges;
h.run(5);
assert.equal(u.skill.activations, 0, 'kept for an enemy');
assert.equal(u.skill.charges, ch);
h.spawn('e_d', { pos: [10, 6] });
assert.ok(h.runUntil(() => u.skill.activations > 0, 5), 'an enemy opens it');
checkInvariants(h.b);
});
test('the engine rule: the device opens a skill acting through the attacks or flagged allyTargets — not an onStart-only or a no-attack one', () => {
const id = 'test_cast_a';
const opens = (skill) => {
const h = makeBattle({
defs: { chess: { [id]: chessRec({ id, profession: 'SNIPER', rangeGrid: [[0, 0], [0, 1]], skill: { skillType: 'MANUAL', spCost: 1, initSp: 1 } }) } },
kits: { [id]: () => ({ skill: { spCost: 1, initSp: 1, trigger: 'DEFAULT', ...skill } }) },
units: [{ uid: 1, chessId: id, row: 10, col: 4, dir: 'RIGHT' }, IRON, crabAt(5)],
timeLimit: 20, autoFinish: false, seed: 2, flags: { dpPerSec: 0, dpMax: 999 },
});
h.step();
const u = h.unit(1);
u.skill.gainSp(999, 'test');
h.run(3);
checkInvariants(h.b);
return u.skill.activations > 0;
};
assert.equal(opens({ kind: 'duration', duration: 3 }), true, 'a timed skill that keeps attacking');
assert.equal(opens({ kind: 'instant', attack: { atkScale: 2 } }), true, 'a cast that is the next attack');
assert.equal(opens({ kind: 'instant', onStart() {} }), false, 'an onStart-only cast');
assert.equal(opens({ kind: 'duration', duration: 3, attack: { noAttack: true } }), false, 'a timed skill that stops attacking');
assert.equal(opens({ kind: 'instant', onStart() {}, allyTargets: true }), true, 'flagged by its kit');
assert.equal(opens({ kind: 'duration', duration: 3, allyTargets: false }), false, 'opted out by its kit');
});
@@ -0,0 +1,87 @@
// test/content/feedback7-champagne-hold.test.js — 琳琅诗怀雅 S2's 香槟炸弹 (token_10031_swire2_gdtrap) under the owner's
// decision D2 of 2026-10-08: it holds 禁疗, loses no HP (活性源石, damage, a 流失) and never leaves because of its HP; its
// 见面礼 is unchanged (the first ground enemy that touches it, then it is gone). Both kits: 琳琅诗怀雅's own bomb and the
// token kit (content/tokens.js champagne, a bomb spawned without her kit).
// Run: node --test test/content/feedback7-champagne-hold.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
const TRAP = 'token_10031_swire2_gdtrap';
const SW = 'chess_char_3_04_a';
for (const path of ['owner', 'token']) test(`香槟炸弹 (${path}) rejects ordinary allied auras and inspiration without isolating normal summons (#476)`, () => {
const { h, bomb: ownBomb } = herBomb({ units: [
{ uid: 2, chessId: 'chess_char_5_20_a', row: 10, col: 4, skillIndex: 0 },
{ uid: 3, chessId: 'chess_char_6_04_a', row: 10, col: 5, skillIndex: 1 },
] });
const angelina = h.unit(2), skadi = h.unit(3);
if (path === 'token') h.b.retreat(ownBomb, { reason: 'expired', permanent: true });
const bomb = path === 'owner' ? ownBomb : h.b.spawnToken(h.unit(1), TRAP, 9, 6);
const control = h.b.spawnToken(skadi, 'token_10017_skadi2_dedant', 9, 4);
assert.ok(bomb && control);
assert.ok(skadi.skill.activate('test', { free: true })); h.run(0.7);
assert.equal(h.b.allySelectable(bomb, angelina), false);
assert.equal(bomb.s.aspd, bomb.base.aspd);
assert.equal(bomb.s.hpRegen, 0);
assert.equal(bomb.findBuff('inspire'), null);
assert.equal(bomb.findBuff('inspire:def'), null);
assert.ok(h.b.allySelectable(control, angelina), 'a normal unhealable summon is still selectable');
assert.ok(control.s.aspd > control.base.aspd, 'Angelina still affects the ordinary summon');
assert.ok(bomb.alive && bomb.hp === bomb.s.maxHp); checkInvariants(h.b);
});
/** 琳琅诗怀雅 on (9,5) of a field whose only free x-6 tile is (9,6) — 活性源石 when `infected`; she throws one bomb there. */
function herBomb({ infected = false, units = [], enemies = [] } = {}) {
const row9 = infected ? '##ERRriRrrSrrrrrrrS##' : '##ERRrrRrrSrrrrrrrS##';
const h = makeBattle({
flat: { rows: { 9: row9, 10: '##hrrRrrrrfrrrrrrrf##', 11: '##hrrRrrrrfrrrrrrrf##' } },
defs: { enemies: { e_w: enemyRec({ key: 'e_w', hp: 1e6, speed: 1, def: 0, atk: 0 }) } },
timeLimit: 60, autoFinish: false, hooks: ['heal', 'death', 'damaged'], captureNoisy: true,
units: [{ uid: 1, chessId: SW, row: 9, col: 5 }, ...units], enemies,
});
h.step();
h.unit(1).mem.coins = 1;
assert.ok(h.runUntil(() => h.b.allyUnits.some((t) => t.alive && t.defId === TRAP), 6), 'she threw a bomb');
const bomb = h.b.allyUnits.find((t) => t.alive && t.defId === TRAP);
assert.deepEqual([bomb.tileR, bomb.tileC], [9, 6]);
return { h, bomb, sw: h.unit(1) };
}
test('香槟炸弹 (her kit): no HP loss on 活性源石, 禁疗, a lethal 流失 or hit leaves it at full HP and on the field', () => {
const { h, bomb } = herBomb({ infected: true });
assert.ok(bomb.s.flags.untargetable && bomb.s.flags.noHeal && bomb.s.flags.healFree, '不可选中 + 禁疗');
assert.ok(!bomb.s.flags.invulnerable, 'no 无敌 flag (no badge on the client)');
h.run(3.2);
assert.equal(bomb.hp, bomb.s.maxHp, '活性源石 takes nothing');
assert.equal(h.b.heal(h.unit(1), bomb, 500), 0, 'no heal reaches it');
assert.equal(h.b.injuredAlliesInKeys([9 * 21 + 6], h.unit(1)).length, 0, 'no healer picks it');
h.b.dealDamage(null, bomb, { amount: 1e6, type: 'true', tags: ['test'] });
h.b.loseHp(bomb, 1e6, { tags: ['test'] });
h.step();
assert.ok(bomb.alive && bomb.deployed, 'not gone because of its HP');
assert.equal(bomb.hp, bomb.s.maxHp);
assert.equal(h.hooksOf('death').filter((c) => c.unit === bomb).length, 0);
checkInvariants(h.b);
});
test('香槟炸弹 (her kit): its 见面礼 is unchanged — the first ground enemy touching it is hit and slowed, then the bomb is gone', () => {
const { h, bomb } = herBomb({ enemies: [{ key: 'e_w', route: 0, time: 1 }] });
assert.ok(h.runUntil(() => !bomb.alive, 30), 'set off');
const e = h.b.enemies.find((x) => x.alive);
assert.ok(e && e.hp < 1e6 && e.findBuff('sluggish'), 'hit + 停顿');
assert.ok(h.hooksOf('death').some((c) => c.unit === bomb && c.reason === 'expired'), 'used up');
checkInvariants(h.b);
});
test('香槟炸弹 (the token kit, spawned without her kit): the same 禁疗, no HP loss, no knock-out', () => {
const h = makeBattle({ timeLimit: 30, autoFinish: false, hooks: ['death'], units: [{ uid: 1, chessId: SW, row: 12, col: 3 }] });
h.step();
const bomb = h.b.spawnToken(h.unit(1), TRAP, 9, 6);
assert.ok(bomb && bomb.s.flags.noHeal && bomb.s.flags.healFree && bomb.s.flags.untargetable);
h.b.loseHp(bomb, 1e6, { tags: ['test'] });
h.b.dealDamage(null, bomb, { amount: 1e6, type: 'true', tags: ['test'] });
h.step();
assert.ok(bomb.alive && bomb.hp === bomb.s.maxHp);
assert.equal(h.b.heal(h.unit(1), bomb, 500), 0);
checkInvariants(h.b);
});
@@ -0,0 +1,56 @@
// test/content/feedback7-champagne-stealth.test.js — 琳琅诗怀雅's 香槟炸弹 does not go off on a 隐匿 enemy that is neither
// revealed nor blocked (community report: 「香槟炸到没有破隐的隐匿敌人了」). PRTS 作战机制 §隐匿: "隐匿效果使得获得该效果的单位无法被
// 任何敌方的能力索敌选中" — the bomb's pages carry no 无视隐匿 note, and the other contact traps (多萝西's 共振装置, 望's 棋子)
// select their enemies so too. A revealed (反隐) or blocked one, and every visible ground enemy, still set it off.
// Run: node --test test/content/feedback7-champagne-stealth.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
const TRAP = 'token_10031_swire2_gdtrap';
const trapHits = (h, e) => h.hooksOf('damaged').filter((c) => c.target === e && c.dmg?.tags?.includes('trap'));
test('her bombs: a 隐匿 walker crosses one unharmed; a visible walker after it sets it off', () => {
const h = makeBattle({
timeLimit: 120, autoFinish: false, hooks: ['damaged', 'death'], captureNoisy: true,
defs: { enemies: { e_w: enemyRec({ key: 'e_w', hp: 1e6, speed: 1, def: 0, atk: 0 }) } },
units: [{ uid: 1, chessId: 'chess_char_3_04_a', row: 9, col: 5 }],
});
h.step();
const sw = h.unit(1);
const bombAt = (r, c) => h.b.allyUnits.find((t) => t.alive && t.defId === TRAP && t.tileR === r && t.tileC === c);
for (let i = 0; i < 15 && !bombAt(9, 7); i++) { sw.mem.coins = 3; h.run(2); }
const bomb = bombAt(9, 7);
assert.ok(bomb, 'a bomb on (9,7), on the walkers\' road before her block');
const hidden = h.spawn('e_w', { route: 0 });
h.b.addBuff(hidden, { key: 'test:stealth', persist: true, flags: { stealth: true } });
assert.ok(h.runUntil(() => hidden.x < 6.6, 20), 'it walked over (9,7)');
assert.ok(bomb.alive, 'not set off');
assert.equal(trapHits(h, hidden).length, 0);
const seen = h.spawn('e_w', { route: 0 });
assert.ok(h.runUntil(() => !bomb.alive, 20), 'a visible walker sets it off');
assert.ok(trapHits(h, seen).length > 0 && seen.findBuff('sluggish'));
checkInvariants(h.b);
});
test('a bomb (the token kit) under a standing 隐匿 enemy waits; revealed (反隐), it goes off', () => {
const h = makeBattle({
timeLimit: 30, autoFinish: false, hooks: ['damaged'], captureNoisy: true,
defs: { enemies: { e_d: enemyRec({ key: 'e_d', hp: 1e6, speed: 0, def: 0, atk: 0 }) } },
units: [{ uid: 1, chessId: 'chess_char_3_04_a', row: 12, col: 3 }], enemies: [{ key: 'e_d', pos: [9, 7] }],
});
h.step();
const e = h.b.enemies.find((x) => x.alive);
h.b.addBuff(e, { key: 'test:stealth', persist: true, flags: { stealth: true } });
const bomb = h.b.spawnToken(h.unit(1), TRAP, 9, 7);
assert.ok(bomb);
h.run(2);
assert.ok(bomb.alive && !e.blockedBy, 'a 隐匿 enemy on its tile does not set it off');
assert.equal(trapHits(h, e).length, 0);
h.b.addBuff(e, { key: 'test:reveal', persist: true, flags: { reveal: true } });
h.step(2);
assert.ok(!bomb.alive, 'revealed: it goes off');
assert.ok(trapHits(h, e).length > 0);
checkInvariants(h.b);
});
+69
View File
@@ -0,0 +1,69 @@
// test/content/feedback7-ines-raid.test.js — 伊内丝 S3 独影归途 under 突袭 (community report: 伊内丝激活突袭瞬移以后3技能被吞).
// S3 costs no SP and runs from every deployment but the first (skill_table skchr_ines_3: PASSIVE, spCost 0, "部署后…";
// her kit casts it in its deploy hook). The 突袭 jump (activity_table bondeffect_raid "保留技力立即再部署") retreats her —
// ending the running S3 — and redeploys her with the SP kept (Battle.redeploy keepSp): that snapshot held 0 charges and
// overwrote the charge reset() gives a free skill at every deployment, so the landing could not recast S3. A skill that
// costs SP still keeps its SP / charges.
// Run: node --test test/content/feedback7-ines-raid.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
function field(skillIndex) {
const landings = [];
const h = makeBattle({
defs: { enemies: { e_walk: enemyRec({ key: 'e_walk', hp: 1e7, speed: 0.5, atk: 0 }) } },
timeLimit: 20, autoFinish: false, seed: 1, flags: { dpInit: 99, dpPerSec: 0, dpMax: 999 },
bonds: { raidShip: { count: 2, active: true, tier: 1, layers: 0 } },
units: [{ uid: 1, chessId: 'chess_char_4_04_a', row: 12, col: 3, dir: 'RIGHT', skillIndex }],
enemies: [{ key: 'e_walk', time: 1.2, routeIndex: 0, count: 1 }],
hooks: ['skillStart', 'skillEnd', 'death'],
setup: (b) => {
// priority 1000: after the keepSp restore, before her kit's deploy hook (which casts S3)
b.on('deploy', ({ unit }) => {
if (unit.uid !== 1 || b.time < 0.5) return;
const sk = unit.skill;
landings.push({ t: b.time, tile: [unit.tileR, unit.tileC], charges: sk.charges, sp: sk.sp, active: sk.active });
}, { priority: 1000 });
},
});
h.step();
return { h, landings, u: h.b.allyUnits.find((x) => x.uid === 1) };
}
test('伊内丝 S3 starts again on the 突袭 landing: the free charge reset() gave is not overwritten by the kept 0', () => {
const { h, landings, u } = field(2);
assert.equal(u.skill.id, 'skchr_ines_3');
h.run(4);
assert.ok(h.hooksOf('death').some((c) => c.unit === u && c.reason === 'raid'), 'she jumped');
assert.equal(landings.length, 1);
assert.notDeepEqual(landings[0].tile, [12, 3], 'landed beside the enemy');
assert.equal(landings[0].charges, 1, 'armed at the landing');
const starts = h.hooksOf('skillStart').filter((c) => c.unit === u);
assert.equal(starts.length, 2, 'the second deployment and the landing');
assert.ok(Math.abs(starts[1].t - landings[0].t) < 1e-9);
assert.equal(u.skill.active, true);
assert.ok(u.skill.timeLeft > 5, `a fresh S3 (${u.skill.timeLeft.toFixed(2)} s left)`);
assert.ok(u.findBuff('ines:s3'), 'its ATK buff');
checkInvariants(h.b);
});
test('突袭 still keeps the SP of a skill that costs SP (伊内丝 S2: a full charge, and a partial bar)', () => {
const full = field(1);
assert.equal(full.u.skill.id, 'skchr_ines_2');
full.u.skill.gainSp(999, 'test');
full.h.run(4);
assert.equal(full.landings.length, 1);
assert.equal(full.landings[0].charges, 1);
assert.equal(full.landings[0].sp, full.u.skill.spCost);
checkInvariants(full.h.b);
const part = field(1);
const initSp = part.u.skill.sp;
part.h.run(12);
assert.equal(part.landings.length, 1, 'the idle trigger');
assert.equal(part.landings[0].charges, 0);
assert.ok(part.landings[0].sp > initSp + 5, `kept ${part.landings[0].sp} (a bar that started at ${initSp})`);
checkInvariants(part.h.b);
});
@@ -0,0 +1,55 @@
// test/content/feedback7-thorn2-regen.test.js — 引星棘刺's alchemy units restore HP as 生命回复速度 (community report:
// 炼金师分支干员的炼金单元无法给不屈者等禁疗的干员提供生命恢复). S1 度算浪波 "每秒回复相当于攻击力…的生命值" and S2 解构涌潮
// "友方单位每秒恢复相当于攻击力…的生命" carry 锡人 S2's wording and blackboard key (hp_recovery_per_sec_ratio), and PRTS 锡人
// 备注 says that recovery "为增加目标的“生命回复速度”属性,不受治疗加成和禁疗影响" — an hpRegen buff, which 不屈者 (no heal
// from others) and 禁疗 units take too. Up to 0.2.1 both were heals (battle.heal), which those units refuse.
// Run: node --test test/content/feedback7-thorn2-regen.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
/** 引星棘刺 (skill `skillIndex`) beside 角峰 (10,5), 折桠 — 不屈者 — (10,6) and a 禁疗 角峰 (10,7); her skill cast at once. */
function field(skillIndex) {
const h = makeBattle({
defs: { enemies: { e_dummy: enemyRec({ key: 'e_dummy', hp: 1e9, speed: 0, atk: 0 }) } },
timeLimit: 20, autoFinish: false, seed: 2, flags: { dpInit: 99, dpPerSec: 0, dpMax: 999 }, hooks: ['heal'], captureNoisy: true,
units: [
{ uid: 1, chessId: 'chess_char_5_15_a', row: 10, col: 4, dir: 'RIGHT', skillIndex },
{ uid: 2, chessId: 'chess_char_1_02_a', row: 10, col: 5 },
{ uid: 3, chessId: 'chess_char_2_17_a', row: 10, col: 6 },
{ uid: 4, chessId: 'chess_char_1_02_a', row: 10, col: 7 },
],
});
h.step();
const [thorn, plain, unyield, banned] = [1, 2, 3, 4].map((uid) => h.b.allyUnits.find((u) => u.uid === uid));
h.b.addBuff(banned, { key: 'test:healFree', status: 'healFree', flags: { noHeal: true, healFree: true } }); // 禁疗
plain.hp = plain.s.maxHp * 0.3;
unyield.hp = unyield.s.maxHp * 0.2; // S1 picks the lowest HP ratio: its 3×3 covers all three
banned.hp = banned.s.maxHp * 0.3;
const hp0 = new Map([plain, unyield, banned].map((a) => [a, a.hp]));
h.spawn('e_dummy', { pos: [10, 6] });
thorn.skill.gainSp(999, 'test');
assert.ok(thorn.skill.activate('manual'), thorn.skill.id);
h.run(3.2);
return { h, thorn, plain, unyield, banned, gained: (a) => a.hp - hp0.get(a) };
}
for (const [skillIndex, id, key] of [[0, 'skchr_thorn2_1', 'hp_recovery_per_sec_ratio'], [1, 'skchr_thorn2_2', 'hp_recovery_per_sec_ratio_chr']]) {
test(`引星棘刺 ${id}: the alchemy unit's 生命回复速度 reaches 不屈者 (折桠) and 禁疗 operators as much as the others`, () => {
const { h, thorn, plain, unyield, banned, gained } = field(skillIndex);
assert.equal(thorn.skill.id, id);
assert.equal(unyield.profile.noHeal, true, '不屈者: no heal from others');
const perSec = thorn.s.atk * thorn.def.skill.bb[key];
for (const a of [plain, unyield, banned]) {
const regen = a.buffs.find((b) => b.source === thorn && b.mods?.hpRegen > 0);
assert.ok(regen, `${a.defId}: an hpRegen buff`);
assert.ok(Math.abs(regen.mods.hpRegen - perSec) < 1e-6, `${regen.mods.hpRegen} ≈ ${perSec}`);
assert.ok(gained(a) > perSec, `${a.defId} recovered ${gained(a).toFixed(1)}`);
}
assert.ok(Math.abs(gained(unyield) - gained(plain)) < 1, 'the same for 不屈者');
assert.ok(Math.abs(gained(banned) - gained(plain)) < 1, 'the same under 禁疗');
assert.equal(h.hooksOf('heal').filter((c) => c.source === thorn).length, 0, 'no heal of hers (治疗加成 does not apply)');
checkInvariants(h.b);
});
}
@@ -0,0 +1,88 @@
// test/content/feedback7-yanyou-noair.test.js — 炎佑 is a flying ally (PRTS “炎佑” 行动方式 飞行): the enemy effects PRTS marks
// 不可对空 never reach it (community report: 炎祐好像吃到了不该吃到的地面伤害). The death blasts of 高能源石虫 / 冰爆源石虫 /
// 卷心籽 ("…无视迷彩,不可对空"), 水遁忍者's 漩涡形态 ("每秒对半径1.0范围内的所有我方单位造成…(无视迷彩,不可对空)"), “萨科塔昂首”'s
// 祈祷邀约 ("令全场我方单位(不可对空,无视迷彩)获得15s【受邀祈祷】"), 鼎沸's pulses ("攻击范围内的所有我方单位每秒受到…(不可对空)")
// and the attack of “斩胄之剑” / “破胄之锤” ("普通攻击对攻击范围内的所有我方单位造成…物理普通伤害,不可对空") took it until 0.2.1; a
// ground operator in reach still does.
// Run: node --test test/content/feedback7-yanyou-noair.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, checkInvariants } from '../helpers/battleHarness.js';
import { spawnYanyou } from '../../server/sim/content/tokens.js';
/** 角峰 on (10,5), the 炎佑 dragon moved onto (10,6). */
function field(seed = 4) {
const h = makeBattle({ timeLimit: 60, autoFinish: false, seed, hooks: ['damaged'], captureNoisy: true,
units: [{ uid: 1, chessId: 'chess_char_1_02_a', row: 10, col: 5 }] });
h.step();
const dragon = spawnYanyou(h.b, 'p1', { count: 1 })[0];
assert.ok(dragon && dragon.isFlying, 'a flying ally');
assert.ok(h.b.relocate(dragon, 10, 6));
return { h, dragon, op: h.unit(1) };
}
const still = (e) => { e.base.moveSpeed = 0; e.s.moveSpeed = 0; };
const tough = (e) => { e.base.maxHp = 1e9; e.s.maxHp = 1e9; e.hp = 1e9; };
/** Damage `src` dealt to `t` other than its normal attacks. */
const taken = (h, t, src) => h.hooksOf('damaged').filter((c) => c.target === t && c.source === src && !c.dmg?.isAttack).reduce((s, c) => s + c.amount, 0);
for (const [name, key, prep] of [
['高能源石虫', 'enemy_1021_bslime', null],
['冰爆源石虫', 'enemy_1067_snslime', null],
['卷心籽 (ignited)', 'enemy_10065_ftzlc', (e) => { e.mem.ab.ignited = true; }],
]) {
test(`${name}'s death blast (不可对空) spares 炎佑 and still hits a ground operator`, () => {
const { h, dragon, op } = field();
const e = h.spawn(key, { pos: [10, 6] });
still(e);
if (prep) prep(e);
h.step();
const hp0 = dragon.hp;
h.b.dealDamage(null, e, { amount: e.hp + 10, type: 'true', tags: ['test'] });
h.run(0.3);
assert.equal(e.alive, false);
assert.ok(taken(h, op, e) > 0, 'the ground operator is hit');
assert.equal(taken(h, dragon, e), 0, 'the flyer is not');
assert.equal(dragon.hp, hp0);
if (key === 'enemy_1067_snslime') {
assert.ok(op.findBuff('cold'), '寒冷 on the ground operator');
assert.equal(dragon.findBuff('cold'), null, 'no 寒冷 on the flyer');
}
checkInvariants(h.b);
});
}
test('水遁忍者\'s 漩涡形态 (不可对空) hits the ground operator beside it, never 炎佑', () => {
const { h, dragon, op } = field(6);
const e = h.spawn('enemy_10116_ymgtop', { pos: [10, 6] });
still(e);
tough(e);
assert.ok(h.runUntil(() => taken(h, op, e) > 0, 40), 'the vortex reached the operator');
h.run(3);
assert.equal(taken(h, dragon, e), 0);
checkInvariants(h.b);
});
test('“萨科塔昂首”\'s 祈祷邀约 (不可对空) slows the ground operator, not 炎佑', () => {
const { h, dragon, op } = field(7);
const e = h.spawn('enemy_10085_hllevi_2', { pos: [10, 8] });
still(e);
tough(e);
assert.ok(h.runUntil(() => !!op.findBuff('ab:roar'), 40), 'the ground operator is invited');
assert.equal(dragon.findBuff('ab:roar'), null);
checkInvariants(h.b);
});
for (const [name, key] of [['鼎沸', 'enemy_10054_cjhot'], ['“斩胄之剑”', 'enemy_9014_acstma'], ['“破胄之锤”', 'enemy_9015_acstmb']]) {
test(`${name} (不可对空) hits the ground operator beside it, never 炎佑`, () => {
const { h, dragon, op } = field(8);
const e = h.spawn(key, { pos: [10, 6] });
still(e);
tough(e);
h.run(12);
const dealt = (t) => h.hooksOf('damaged').filter((c) => c.target === t && c.source === e).reduce((s, c) => s + c.amount, 0);
assert.ok(dealt(op) > 0, 'the ground operator is hit');
assert.equal(dealt(dragon), 0, 'the flyer is not');
checkInvariants(h.b);
});
}
+27
View File
@@ -465,6 +465,33 @@ test('ADD_BOND 耀骑士临光 145 / 160 (real chess): self + front get 144 (red
}
});
test('respawnTimeByBond 144 has no 5% floor (#370): redeployMul is max(0, 1 + respawn_time * steps)', () => {
for (const [gid, rt] of [['garrison_144_a', -0.015], ['garrison_144_b', -0.03]]) {
assert.equal(GR(gid).bb.respawn_time, rt);
assert.deepEqual(Object.keys(GR(gid).bb).sort(), ['divide_num', 'respawn_time']);
// 96 / 192 layers: the old 0.05 floor bound the elite / the normal form (raw 0.04); 300: below zero
for (const layers of [9, 96, 192, 300]) {
const k = Math.floor(layers / 3);
const want = Math.max(0, 1 + rt * k);
const h = battle({
units: [{ id: 'op', g: [gid], row: 10, col: 4, o: { stats: { respawnTime: 100 } } }],
bonds: { kazimierzShip: B(layers) },
});
const u = h.unit('op');
approx(u.findBuff(`gar:${gid}`).mods.redeployMul, want, `${gid} @ ${layers}`);
h.b.dealDamage(null, u, { amount: 1e9, type: 'true' });
assert.equal(u.alive, false);
approx(u.respawnAt - u.deathAt, 100 * want, `${gid} timer @ ${layers}`);
if (want === 0) {
h.step(2);
assert.ok(u.alive && u.deployed, `${gid} @ ${layers}: a 0 s timer brings it straight back`);
assert.equal(h.b.errorCount, 0);
}
}
cover(gid);
}
});
// ---------------------------------------------------------------------------------------------------------------------
// 魔王
+20
View File
@@ -839,6 +839,26 @@ test('塞雷娅 99 / 白面鸮 86: the copier runs the front operator\'s prep-en
assert.equal(w.ps.shop.freeRefreshes, 3);
});
// Maintainer-confirmed replay of the linked official-game footage (#473): only Ptilopsis copying these two gifts.
for (const targetId of ['chess_char_4_12_a', 'chess_char_6_03_a']) {
for (const ownRow of [false, true]) for (const sourceRow of [false, true]) {
test(`白面 copies ${targetId} gift without either row condition (own=${ownRow}, source=${sourceRow})`, () => {
const s = setup(11);
const white = give(s.m, s.ps, 'chess_char_4_21_a', 'board', [11, 4]); white.dir = 'DOWN';
give(s.m, s.ps, targetId, 'board', [10, 4]);
const fillers = plain(c => c.position === 'RANGED').slice(0, 4); let filler = 0;
for (const [r, on] of [[11, ownRow], [10, sourceRow]]) if (on) {
give(s.m, s.ps, fillers[filler++], 'board', [r, 3]);
give(s.m, s.ps, fillers[filler++], 'board', [r, 5]);
}
s.ps.recompute(); const before = s.ps.round.gainedChess;
s.roundStart();
assert.equal(s.ps.round.gainedChess - before, 1 + (sourceRow ? 1 : 0), 'copier always gives one; original still checks its own row');
s.h.invariants();
});
}
}
test('triggerGainEffects export (band 铃兰): re-runs 获得时 garrisons ×投资人 and returns the effects run', () => {
const s = setup();
const p = give(s.m, s.ps, 'chess_char_5_03_a', 'board', [10, 4]); // 烛煌 炎/维多利亚 +5
+71
View File
@@ -0,0 +1,71 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, chessRec, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
const GUARDIANS = [
{ chessId: 'chess_char_5_11_b', skillIndex: 0 },
{ diy: { slot: 6, charId: 'char_2025_shu', skillIndex: 0 }, elite: true },
];
function guardian(entry) {
const h = makeBattle({ autoFinish: false, captureNoisy: true, hooks: ['heal', 'skillStart'],
defs: { chess: { patient: chessRec({ id: 'patient', skill: null, stats: { maxHp: 100000 } }) } },
units: [{ uid: 1, ...entry, row: 10, col: 4 }, { uid: 2, chessId: 'patient', row: 10, col: 5 }] });
h.step();const u = h.unit(1), p = h.unit(2);u.skill.gainSp(999);return { h, u, p };
}
for (const entry of GUARDIANS) {
const name = entry.chessId ? '塞雷娅' : '黍';
test(`${name} S1 heals without enemies, respects half HP and keeps attack cadence`, () => {
const { h, u, p } = guardian(entry);
p.hp = p.s.maxHp * 0.5001;h.run(0.5);assert.equal(u.skill.activations, 0);
p.hp = p.s.maxHp * 0.5;u.atkCd = 1;
h.run(0.5);assert.equal(u.skill.activations, 0, 'wait for the attack interval');
assert.ok(h.runUntil(() => u.skill.activations > 0, 1));
assert.ok(p.hp > p.s.maxHp * 0.5, 'exact half HP is eligible');
p.hp = p.s.maxHp * 0.2;
assert.ok(h.runUntil(() => u.skill.activations >= 2, 4));
const casts = h.hooksOf('skillStart').filter(c => c.unit === u);
assert.ok(casts[1].t - casts[0].t >= u.base.bat - 0.04, 'charges are paced by attacks');
checkInvariants(h.b);assert.deepEqual(h.b.errors, []);
});
test(`${name} S1 preserves exact normal/fast attack intervals and waits out disarm`, () => {
for (const aspd of [0, 100]) {
const { h, u, p } = guardian(entry);
p.hp *= 0.1;
if (aspd) h.b.addBuff(u, { key: 'test:aspd', mods: { aspd } });
h.b.on('heal', c => { if (c.source === u) u.skill.gainSp(999); });
const charges = u.skill.charges;
h.b.applyStatus(u, 'disarm', { duration: 1 });
h.run(0.5);
assert.equal(u.skill.activations, 0);
assert.equal(u.skill.pending, false);
assert.equal(u.skill.charges, charges);
h.run(4);
const times = h.hooksOf('heal').filter(c => c.source === u).map(c => c.t);
assert.ok(times.length >= 3);
for (let i = 1; i < times.length; i++) assert.ok(Math.abs(times[i] - times[i - 1] - u.s.interval) < 1e-9);
checkInvariants(h.b);assert.deepEqual(h.b.errors, []);
}
});
test(`${name} S1 cannot heal a forbidden patient or cast while silenced; invalid pending heal refunds the charge`, () => {
const { h, u, p } = guardian(entry);p.hp *= 0.2;
h.b.addBuff(p, { key: 'test:noheal', flags: { noHeal: true } });h.run(2);assert.equal(u.skill.activations, 0);
h.b.removeBuff(p, 'test:noheal');h.b.applyStatus(u, 'silence', { duration: 2 });h.run(1);assert.equal(u.skill.activations, 0);
h.run(1.1);assert.ok(u.skill.activations > 0);
u.skill.gainSp(999);const charges = u.skill.charges;u.atkCd = 1;
assert.ok(u.skill.activate('test'));p.hp = p.s.maxHp;h.step();
assert.equal(u.skill.pending, false);assert.equal(u.skill.charges, charges);
checkInvariants(h.b);assert.deepEqual(h.b.errors, []);
});
}
for (const motion of ['WALK', 'FLY']) {
test(`凯尔希·思衡托 S2 opens for a selectable ${motion} enemy only in the expanded range, without an injured ally`, () => {
const h = makeBattle({ autoFinish: false,
defs: { enemies: { target: enemyRec({ key: 'target', motion, hp: 1e8, atk: 0, speed: 0 }) } },
units: [{ uid: 1, diy: { slot: 6, charId: 'char_1052_kalts2', skillIndex: 1 }, elite: true, row: 10, col: 4 }] });
h.step();const u = h.unit(1);u.skill.gainSp(999);h.run(0.5);assert.equal(u.skill.activations, 0);
const e = h.spawn('target', { pos: [10, 7] });h.b.applyStatus(e, 'sleep', { duration: 1 });
assert.equal(h.b.enemiesInKeys(u.baseRangeKeys, u, { canHitFly: true }).length, 0);
h.run(0.5);assert.equal(u.skill.activations, 0, 'sleep cannot open the mixed skill');
assert.ok(h.runUntil(() => u.skill.active, 1));
assert.equal(u.skill.activations, 1);checkInvariants(h.b);assert.deepEqual(h.b.errors, []);
});
}
+156
View File
@@ -0,0 +1,156 @@
// 隐秘核心铳's 【末日布道】 (PR #347 by @CXUtk): every 碎铳之簧 chases the casting gun for the gain's whole 5 s — PRTS 末日布道
// "令全场的“碎铳之簧”获得5秒增益:移动速度+800%,无敌,增益期间切换为追逐模式", “碎铳之簧” 追逐模式 "持续追踪令其进入该形态的场上的
// 假想敌:铳移动" (no arrival clause). Until 0.2.1 reaching the gun (or 1 tile from it) ended the chase. Each gun casts on its
// own data timer (initCooldown 20, cooldown 45 — no stagger between the two guns of a pair field, no lock while a chase
// runs: the data has neither, and the handbook's 「向自身」 means the later call takes the springs).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import { makeBattle, chessRec, checkInvariants } from '../helpers/battleHarness.js';
import * as enemies from '../../server/sim/content/enemies.js';
import * as bosses from '../../server/sim/content/bosses.js';
const W = JSON.parse(fs.readFileSync(new URL('../../data/waves.json', import.meta.url), 'utf8'));
const E = JSON.parse(fs.readFileSync(new URL('../../data/enemies.json', import.meta.url), 'utf8'));
const GUN = 'enemy_9017_achunt_2';
const SPRING = 'enemy_9020_actrpc';
const wall = chessRec({ id: 'pursuit_wall', profession: 'TANK', stats: { maxHp: 1e9, atk: 0, def: 1000, blockCnt: 3 }, rangeGrid: [[0, 0]], skill: null });
/** The real h08_02(_s) template (leader + parts only, or no spawns with `manual`), one non-attacking wall per side. */
function arena({ solo = false, manual = false, cooldown = null } = {}) {
const tpl = structuredClone(W[solo ? 'act1autochess_h08_02_s' : 'act1autochess_h08_02']);
tpl.spawns = manual ? [] : tpl.spawns.filter((s) => s.tag === 'boss' || s.tag === 'part');
if (cooldown != null) {
tpl.overrides[GUN] ??= { skills: structuredClone(E[GUN].skills) };
const skill = tpl.overrides[GUN].skills.find((s) => s.prefabKey === '2');
skill.initCooldown = 0;
skill.cooldown = cooldown;
}
return makeBattle({
kind: 'hidden', waveTemplate: tpl, content: 'generic', extraContent: [enemies, bosses], seed: 7,
autoFinish: false, timeLimit: 300, recordEvents: false, hooks: [],
defs: { chess: { pursuit_wall: wall } }, kits: { pursuit_wall: () => ({ trait: { noAttack: true } }) },
sharedBoss: { hp: 1e12, maxHp: 1e12, damage(pid, n) { this.hp -= n; } },
players: [
{ playerId: 'p1', seat: 0, side: 'L', colOffset: 0, units: [{ uid: 1, chessId: 'pursuit_wall', row: 3, col: 5, abs: true }] },
...solo ? [] : [{ playerId: 'p2', seat: 1, side: 'R', colOffset: 8, units: [{ uid: 2, chessId: 'pursuit_wall', row: 3, col: 15, abs: true }] }],
],
setup(b) { b.enemyOverrides = tpl.overrides; b.opts.waveTemplate = tpl; b.opts.bossMirror = !manual; },
});
}
/** Every 【末日布道】 call (a new chase object on the spring): { time, gun }. */
function recordCalls(h) {
const calls = [];
let last = null;
h.b.on('tick', () => {
const d = h.enemies().find((e) => e.defId === SPRING)?.mem.ab.dash;
if (!d || d === last) return;
last = d;
calls.push({ time: h.b.time, gun: d.gun });
});
return calls;
}
test('隐秘核心铳 pair: each gun calls on its own data timer (20 s after it spawns, then every 45 s); the later call takes the springs', () => {
const h = arena();
const spawned = new Map();
h.b.on('enemySpawn', (c) => { if (c.enemy.defId === GUN) spawned.set(c.enemy, h.b.time); });
const calls = recordCalls(h);
h.run(150);
assert.equal(spawned.size, 2, 'the pair field has two guns (the mirrored one)');
const skill = W.act1autochess_h08_02.overrides[GUN].skills.find((s) => s.prefabKey === '2');
for (const [gun, t0] of spawned) {
const own = calls.filter((c) => c.gun === gun).map((c) => c.time);
assert.equal(own.length, 3, 'three calls each in 150 s');
assert.ok(Math.abs(own[0] - t0 - skill.initCooldown) < h.TICK * 2, `first call ${skill.initCooldown} s after its spawn`);
for (let i = 1; i < own.length; i++) assert.ok(Math.abs(own[i] - own[i - 1] - skill.cooldown) < h.TICK * 2, `then every ${skill.cooldown} s`);
}
// the two mirrored guns call within a tick or two of each other (no half-cooldown stagger), the later one wins
for (let i = 1; i < calls.length; i += 2) assert.ok(calls[i].time - calls[i - 1].time < h.TICK * 3, 'the two calls come together');
assert.equal(h.b.errorCount, 0);
checkInvariants(h.b);
});
test('隐秘核心铳: reaching a moving gun keeps pursuit, invulnerability and disarm for the full 5 s', () => {
const h = arena({ solo: true, manual: true, cooldown: 45 });
h.step();
const gun = h.spawn(GUN, { pos: [3, 5], tag: 'boss', mods: { speedMul: 0 } });
const spring = h.spawn(SPRING, { pos: [3, 4.8], tag: 'part' });
h.step(2);
const d = spring.mem.ab.dash;
assert.ok(d, 'arriving next to the gun does not end the chase');
h.run(1);
gun.x = 6;
const oldX = spring.x;
h.run(1);
assert.ok(spring.x > oldX, 'it keeps following the gun when the gun moves');
assert.equal(spring.mem.ab.dash, d);
assert.ok(spring.s.flags.invulnerable && spring.s.flags.disarm);
h.run(d.until - h.b.time - h.TICK * 2);
assert.equal(spring.mem.ab.dash, d, 'still chasing just before the 5 s are over');
assert.ok(spring.s.flags.invulnerable && spring.s.flags.disarm);
h.run(h.TICK * 4);
assert.equal(spring.mem.ab.dash, null, 'over once the gain ends');
assert.ok(!spring.s.flags.invulnerable && !spring.s.flags.disarm);
assert.equal(h.b.errorCount, 0);
});
test('隐秘核心铳: the other gun\'s call during a chase retargets every spring to that gun for a new 5 s', () => {
const h = arena({ manual: true, cooldown: 45 });
h.step();
const left = h.spawn(GUN, { pos: [3, 5], tag: 'boss', mods: { speedMul: 0 } });
const right = h.spawn(GUN, { pos: [3, 15], tag: 'boss', mods: { speedMul: 0 } });
const call = right.mem.ab.list.find((a) => a.cd === 45 && a.fire);
call.left = 999; // the right gun's call is not ready yet
const spring = h.spawn(SPRING, { pos: [3, 10], tag: 'part' });
h.step(2);
const first = spring.mem.ab.dash;
assert.ok(first && first.gun === left, 'the first call takes the spring to the left gun');
h.run(1);
assert.equal(spring.mem.ab.dash, first, 'chasing the left gun');
call.left = 0; // the right gun's call comes ready mid-chase
h.step(2);
const second = spring.mem.ab.dash;
assert.ok(second && second !== first && second.gun === right, 'the later call takes the spring at once');
assert.ok(Math.abs(second.until - h.b.time - 5) < h.TICK * 3, 'for a new 5 s');
const oldX = spring.x;
h.run(1);
assert.ok(spring.x > oldX, 'it now runs to the right gun');
assert.ok(spring.s.flags.invulnerable && spring.s.flags.disarm);
assert.equal(h.b.errorCount, 0);
});
test('隐秘核心铳: with no gun left on the field the spring holds still, invulnerable and disarmed, until the gain ends', () => {
const h = arena({ solo: true, manual: true, cooldown: 45 });
h.step();
const gun = h.spawn(GUN, { pos: [3, 5], tag: 'boss', mods: { speedMul: 0 } });
const spring = h.spawn(SPRING, { pos: [3, 1], tag: 'part' });
h.step(2);
const d = spring.mem.ab.dash;
assert.ok(d, 'chasing');
h.b.kill(gun, null);
h.step();
const at = [spring.x, spring.y];
h.run(1);
assert.deepEqual([spring.x, spring.y], at, 'no gun to follow: it stands');
assert.equal(spring.mem.ab.dash, d, 'the chase is not over');
assert.ok(spring.s.flags.invulnerable && spring.s.flags.disarm);
h.run(d.until - h.b.time + h.TICK * 2);
assert.equal(spring.mem.ab.dash, null);
assert.ok(!spring.s.flags.invulnerable);
assert.equal(h.b.errorCount, 0);
});
test('隐秘核心铳: solo keeps the template initial cooldown and repeated call timing', () => {
const h = arena({ solo: true });
const calls = recordCalls(h);
h.run(100);
assert.equal(calls.length, 2);
const tpl = W.act1autochess_h08_02_s;
const spawnTime = tpl.spawns.find((s) => s.tag === 'boss').time;
const skill = (tpl.overrides[GUN]?.skills ?? E[GUN].skills).find((s) => s.prefabKey === '2');
assert.ok(Math.abs(calls[0].time - spawnTime - skill.initCooldown) < h.TICK * 2);
assert.ok(Math.abs(calls[1].time - calls[0].time - skill.cooldown) < h.TICK * 2);
assert.equal(h.b.errorCount, 0);
});
+109 -12
View File
@@ -617,7 +617,7 @@ test('黄沙罗盘: initial SP +30 / +50; (萨尔贡) first skill end +30 SP; +
u.skill.sp = 0;
u.skill.activate('test', { free: true });
u.skill.end('test');
close(u.skill.sp, 0, 'only the first skill end');
close(u.skill.sp, 3, 'only the first skill end gives 30; this cast still gives its own 3 through the lock');
cover(A('6_07'), B('6_07'), A('2_04'), B('2_04'));
});
@@ -741,6 +741,16 @@ const plain = (pred = () => true) => Object.values(DATA.chess)
.filter((c) => c.visible && !c.isGolden && (c.garrisonIds || []).every((g) => DATA.garrisons[g].eventType === 'IN_BATTLE') && pred(c))
.map((c) => c.chessId).sort();
/** Record the chess granted by item effects (acquireChess with an `item:` source). */
/** The warn toasts sent to the match's players from now on: [{ msgid, who, name }] (server msg() objects). */
function spyWarns(m) {
const got = [];
const orig = m.toast.bind(m);
m.toast = (ps, kind, text) => {
if (kind === 'warn') got.push({ msgid: text?.msgid ?? text, who: text?.params?.who?.dn ?? null, name: text?.params?.name?.dn ?? null });
return orig(ps, kind, text);
};
return got;
}
function spyGrants(ps) {
const got = [];
const orig = ps.acquireChess.bind(ps);
@@ -944,7 +954,7 @@ test('拟态物质: owning 2 copies gives the 3rd (→ elite); otherwise a rando
cover(A('5_05'), B('5_05'));
});
test('拟态物质 with 2 copies owned and none left in the pool gives nothing — never a same-bond operator; with copies left the 3rd still merges (GitHub #207)', () => {
test('拟态物质 grants the third copy even when stock is empty; the gain still merges and occupies stock (measured pool rule)', () => {
// the report: an elite 溯光星源 (3 of the Ⅵ阶's 5 copies) and 2 normal ones — the pool is empty, the item text's 否则
// (a random same-bond operator) is only for fewer than 2 owned
const cid = 'chess_char_6_16_a';
@@ -956,24 +966,28 @@ test('拟态物质 with 2 copies owned and none left in the pool gives nothing
const t = give(m, ps, cid, 'hand');
give(m, ps, cid, 'hand');
assert.equal(m.pool.left(cid), 0, 'elite 3 + 2 normal = the pool cap 5');
const before = chessIn(ps);
const got = spyGrants(ps);
const warns = spyWarns(m);
const it = giveItem(m, ps, item);
assert.deepEqual(equip(it, t), OK, item);
assert.deepEqual(got, [], `${item}: nothing granted`);
assert.deepEqual(chessIn(ps), before, `${item}: no other operator, no second elite`);
assert.deepEqual(got, [cid], `${item}: same operator granted despite empty stock`);
assert.deepEqual(chessIn(ps), [elite, elite], `${item}: the third normal copy merges`);
assert.ok(!ps.find(it.uid), `${item}: consumed`);
assert.deepEqual(warns, [], `${item}: a successful grant must not report an empty-pool failure`);
assert.equal(m.pool.entries.get(cid).left, -1, 'the sixth owned copy still occupies the pool');
}
// 2 normal copies, no elite, the pool drained by other players: nothing either
// 2 normal copies, no elite, the pool drained by other players: the third copy is still granted.
{
const { m, ps, equip } = setup({ seed: 9 });
const t = give(m, ps, cid, 'hand');
give(m, ps, cid, 'hand');
m.pool.take(cid, m.pool.left(cid));
const got = spyGrants(ps);
const warns = spyWarns(m);
assert.deepEqual(equip(giveItem(m, ps, A('5_05')), t), OK);
assert.deepEqual(got, []);
assert.deepEqual(chessIn(ps), [cid, cid]);
assert.deepEqual(got, [cid]);
assert.deepEqual(chessIn(ps), [elite]);
assert.deepEqual(warns, []);
}
// 2 normal copies with copies left: the 3rd merges into the elite
{
@@ -981,12 +995,27 @@ test('拟态物质 with 2 copies owned and none left in the pool gives nothing
const t = give(m, ps, cid, 'hand');
give(m, ps, cid, 'hand');
assert.equal(m.pool.left(cid), 3);
const warns = spyWarns(m);
assert.deepEqual(equip(giveItem(m, ps, A('5_05')), t), OK);
assert.deepEqual(chessIn(ps), [elite]);
assert.deepEqual(warns, [], 'a grant: no warn toast');
assert.equal(m.pool.left(cid), 2, 'the elite holds 3 copies');
}
});
test('拟态物质 with fewer than 2 copies samples eligible identities independently of remaining stock', () => {
// 缪尔赛思's only bond is 调和 and she is its only member: one copy owned, the rest of her pool taken by others
const cid = 'chess_char_6_11_a';
const { m, ps, equip } = setup({ seed: 9 });
const t = give(m, ps, cid, 'hand');
m.pool.take(cid, m.pool.left(cid));
const got = spyGrants(ps);
const warns = spyWarns(m);
assert.deepEqual(equip(giveItem(m, ps, A('5_05')), t), OK);
assert.deepEqual(got, [cid], 'the only eligible identity remains available even with no stock');
assert.deepEqual(warns, []);
});
test('博士投影: golden promotes at once; normal stays equipped and promotes at the next round start', () => {
const { h, m, ps, equip } = setup();
const cid = plain((c) => c.tier === 2)[0];
@@ -1121,10 +1150,11 @@ test('画卷 (Art): copies the operator on the tile / in front with its elite st
const copy = ps.hand.find((p) => p && p.kind === 'chess');
assert.equal(copy && copy.id, DATA.chess[cid].goldenId, 'elite copy in the hand');
// the copied 激光发射器 pairs with the original's (items merge "无论是否被装备") ⇒ the golden goes to the hand;
// 变形同构体 never merges ⇒ the copy wears it too
assert.deepEqual(copy.items.map((x) => x.id), [A('6_09')]);
// 变形同构体 never merges ⇒ its copy waits in the hand too, unequipped (PRTS 画卷 备注 "获得的装备为未装备状态";
// until 0.2.2 the copy wore it)
assert.deepEqual(copy.items.map((x) => x.id), [], 'the copy wears nothing');
assert.deepEqual(ps.find(target.uid).piece.items.map((x) => x.id), [A('6_09')]);
assert.deepEqual(handIds(ps, 'item'), [B('3_03')], 'merged golden in the hand (regression: was equipped on the copy)');
assert.deepEqual(handIds(ps, 'item').slice().sort(), [B('3_03'), A('6_09')].sort(), 'the merged golden and the copied 变形同构体 in the hand');
const art2 = giveItem(m, ps, 'chess_item_6_02_m');
assert.equal(m.handle('p_0', { t: 'g.art', itemUid: art2.uid, row: 11, col: 7 }).error, 'BAD_TARGET', 'no operator in range');
cover('chess_item_6_02_m');
@@ -1139,7 +1169,23 @@ test('教鞭 (Art): a 战术特训 bounty (PRTS "于3个战术特训的悬赏任
const { m, ps } = setup({ seed: 60 + s });
for (let k = 0; k < 2; k++) {
const art = giveItem(m, ps, 'chess_item_6_03_m');
const deadline = m.deadline;
assert.deepEqual(m.handle('p_0', { t: 'g.art', itemUid: art.uid, row: 10, col: 5 }), OK);
const pending = ps.personalChoice;
assert.equal(ps.bounties.length, k, 'offering cards does not add a bounty');
assert.equal(pending.cards.length, 3);
assert.equal(new Set(pending.cards.map((c) => c.effectId)).size, 3);
assert.equal(pending.round, m.round);
assert.equal(pending.sourceItemId, art.id);
assert.equal(m.deadline, deadline, 'uses the existing PREP clock');
assert.equal(ps.find(art.uid), null);
assert.equal(ps.round.arts, k + 1, 'consumed when offered');
for (const c of pending.cards) assert.ok(training.has(c.effectId) && !m.gd.inactiveEnemies.has(c.enemyKey));
assert.deepEqual(m.handle('p_0', { t: 'g.choice', idx: 1, choiceId: pending.id }), OK);
assert.equal(ps.personalChoice, null);
assert.equal(ps.bounties[k].card.effectId, pending.cards[1].effectId, 'the submitted card is applied');
assert.equal(ps.round.arts, k + 1, 'picking does not consume another Art');
assert.equal(m.handle('p_0', { t: 'g.choice', idx: 1, choiceId: pending.id }).error, 'BAD_TARGET');
}
assert.equal(ps.bounties.length, 2);
for (const b of ps.bounties) {
@@ -1150,7 +1196,7 @@ test('教鞭 (Art): a 战术特训 bounty (PRTS "于3个战术特训的悬赏任
seen.add(b.card.effectId);
}
}
assert.ok(seen.size >= 5, 'random pick');
assert.ok(seen.size >= 5, 'different offered cards across seeds');
const { h, m } = setup({ mode: 'coop', humans: 2, seed: 7 });
const p0 = h.ps('p_0'), p1 = h.ps('p_1');
const art = giveItem(m, p0, 'chess_item_6_01_m');
@@ -1161,9 +1207,60 @@ test('教鞭 (Art): a 战术特训 bounty (PRTS "于3个战术特训的悬赏任
const passed = p1.hand.find((p) => p && p.id === 'chess_item_6_01_m');
assert.deepEqual(m.handle('p_1', { t: 'g.art', itemUid: passed.uid, row: 10, col: 5 }), OK);
assert.match(p1.bounties[0].card.effectId, /^enemyeffect_b_/);
assert.equal(p1.personalChoice, null, '神秘顾客 still applies its random bounty immediately');
cover('chess_item_6_03_m', 'chess_item_6_01_m');
});
test('教鞭: pending and empty-pool failures keep the Art, Arts count and RNG; short offers use actual cards', () => {
const { m, ps } = setup();
const first = giveItem(m, ps, 'chess_item_6_03_m');
const second = giveItem(m, ps, 'chess_item_6_03_m', 'temp');
assert.deepEqual(ps.useArt(first.uid, 10, 5), OK);
const pending = ps.personalChoice;
const rng = m.rngMeta.state(), uid = m.uidSeq;
assert.deepEqual(ps.useArt(second.uid, 10, 5), { error: 'BAD_TARGET', detail: '请先完成当前教鞭选择' });
assert.equal(ps.personalChoice, pending);
assert.ok(ps.find(second.uid));
assert.equal(ps.round.arts, 1);
assert.equal(m.rngMeta.state(), rng);
assert.equal(m.uidSeq, uid);
assert.equal(m.pickPersonalChoice(ps, -1, pending.id).error, 'BAD_TARGET');
assert.equal(m.pickPersonalChoice(ps, 0.5, pending.id).error, 'BAD_TARGET');
assert.equal(m.pickPersonalChoice(ps, 3, pending.id).error, 'BAD_TARGET');
assert.equal(ps.personalChoice, pending);
assert.deepEqual(m.pickPersonalChoice(ps, 0, pending.id), OK);
const training = DATA.choices.cards.bounty.filter((c) => c.payout === 'perfect');
m.gd.inactiveEnemies = new Set(training.map((c) => c.enemyKey));
assert.deepEqual(ps.useArt(second.uid, 10, 5), { error: 'BAD_TARGET', detail: '当前没有可用的战术特训' });
assert.ok(ps.find(second.uid));
assert.equal(ps.round.arts, 1);
assert.equal(ps.personalChoice, null);
assert.equal(m.rngMeta.state(), rng);
const allowed = training.find((c) => c.multiRound && m.gd.enemy(c.enemyKey));
m.gd.inactiveEnemies.delete(allowed.enemyKey);
assert.deepEqual(ps.useArt(second.uid, 10, 5), OK);
assert.ok(ps.personalChoice.cards.length > 0 && ps.personalChoice.cards.length < 3);
assert.equal(new Set(ps.personalChoice.cards.map((c) => c.effectId)).size, ps.personalChoice.cards.length);
assert.equal(ps.find(second.uid), null, 'Arts in temp are consumed too');
const view = ps.privateView().personalChoice;
assert.ok(view.cards.every((c) => c.kind === 'bounty' && c.rounds === 2));
assert.ok(view.cards.every((c) => !/每场/.test(c.descRaw || c.desc)), 'multi-round candidates use the same two-battle text as applied bounties');
m.dispose();
});
test('教鞭: an open choice goes with a player who is eliminated, and nothing of it is left in its views', () => {
const { h, m } = setup({ mode: 'coop', humans: 2, seed: 8 });
const a = h.ps('p_0');
const art = giveItem(m, a, 'chess_item_6_03_m');
assert.deepEqual(a.useArt(art.uid, 10, 5), { ok: true });
assert.equal(a.privateView().personalChoice.cards.length, 3);
a.eliminate(m.round);
assert.equal(a.personalChoice, null);
assert.equal(a.privateView().personalChoice, null);
assert.equal(a.privateView().canReady, false);
m.dispose();
});
test('merging: two identical normal items become the golden one (upgradeNum 2); upgradeNum-100 items never merge', () => {
const never = Object.values(DATA.items).filter((r) => r.itemType === 'EQUIP' && !r.isGolden && r.upgradeNum === 100).map((r) => r.name).sort();
assert.deepEqual(never, ['人事部文档', '信标', '变形同构体', '寻呼模块', '拟态物质', '突变细胞'].sort());
+2 -1
View File
@@ -421,8 +421,9 @@ test('2_16 拉普兰德 S1 日晷: toggle (持续时间无限) ATK +atk; blocks
const bb = bbAlt(id);
for (const roll of [true, false]) {
const seen = [];
// 120 hits a minute at bat 0.5, each at the 5 % floor (12.5): 1500 in the 60 s, short of the normal form's 1791 HP
const h = run({
defs: { enemies: { e: dummy('e', { atk: 300, bat: 0.5 }) } }, units: [U(id, 9, 5, { carryState: READY })], enemies: [{ key: 'e', pos: [9, 5] }],
defs: { enemies: { e: dummy('e', { atk: 250, bat: 0.5 }) } }, units: [U(id, 9, 5, { carryState: READY })], enemies: [{ key: 'e', pos: [9, 5] }],
setup: (b) => { b.rng.chance = (p) => { seen.push(p); return roll; }; },
});
const u = h.unit(id);
+28
View File
@@ -113,6 +113,30 @@ test('信仰搅拌机 S1 铳骑主考官 (自动触发 ⇒ DEFAULT: hurt SP, fir
}
});
test('信仰搅拌机 S1 reloads the latest-deployed 拉特兰 ammo user around him, not the nearest (PRTS 备注; GitHub #325, PR #329)', () => {
for (const redeploy of [false, true]) {
// 隐现 ×2: the orthogonal neighbour deployed first, the diagonal one after it (both on his 8 surrounding tiles)
const h = battle([U('chess_char_4_01_a', 10, 5, 0), U('chess_char_1_01_a', 10, 4, null, null, { uid: 2 }), U('chess_char_1_01_a', 9, 4, null, null, { uid: 3 })]);
h.step();
const u = h.unit('chess_char_4_01_a'), near = h.unit(2), far = h.unit(3);
if (redeploy) { // a redeploy is the latest deployment
h.b.retreat(near, { reason: 'raid' });
assert.ok(h.b._deploy(near));
}
const latest = redeploy ? near : far, other = redeploy ? far : near;
assert.ok(latest.deploySeq > other.deploySeq);
for (const a of [near, far]) { assert.ok(a.skill.activate('test', { free: true })); a.skill.ammoLeft = 5; }
const e = h.spawn('enemy_dummy', { pos: [10, 6] });
assert.ok(u.skill.activate('test', { free: true }));
h.b.forceAttack(u, [e]);
h.run(0.5);
const reloads = h.eventsOf('fx').filter((x) => x[1] === 'reload');
assert.ok(reloads.length > 0, 'a reload happened');
assert.ok(reloads.every((x) => x[4].id === latest.id), `${redeploy ? 'after a redeploy, the redeployed one' : 'the later-deployed diagonal one'} is reloaded`);
checkInvariants(h.b);
}
});
test('信仰搅拌机 S2 八臂电锯侠: ammo skill, ATK/DEF up; a lethal hit is negated for ammo_cost bullets; with fewer it is still negated, every bullet goes and the skill ends (PRTS 备注)', () => {
for (const id of pair('chess_char_4_01_a')) {
const bb = D(id, 1).skill.bb;
@@ -509,6 +533,8 @@ test('歌蕾蒂娅 S1 缺水的大洋裂断: charges; next attack atk_scale (×1
assert.equal(sk.length, 1);
approx(sk[0].amount, u.s.atk * bb.atk_scale * t1.atk_scale, 1e-6, `${id} hit`);
assert.ok(e.x < x0 - 0.5, `${id}: pulled towards her (${x0} → ${e.x})`);
assert.ok(h.eventsOf('fx').some((f) => f[1] === 'displace' && f[4]?.keepFacing === true),
`${id}: S1 pull carries the common facing metadata`);
// 中力 vs weight 1 (受力等级 0): "必定拉至身前" — to the 急停 radius 0.6708 around her centre (PRTS 推与拉), never past it
approx(e.x, u.x + 0.6708, 1e-6, `${id}: stops at her front`);
}
@@ -531,6 +557,8 @@ test('歌蕾蒂娅 S2 缺水的掌握怒海: BAT +0.5 s, wider range, 2 targets
assert.equal(hits.length, bb['attack@max_target']);
for (const c of hits) approx(c.amount, u.s.atk * bb['attack@atk_scale'] * t1.atk_scale, 1e-6);
for (const e of atk.targets) assert.ok(e.x < 6 - 0.4, `pulled (${e.x})`);
assert.ok(h.eventsOf('fx').some((f) => f[1] === 'displace' && f[4]?.keepFacing === true),
`${id}: S2 pull carries the common facing metadata`);
}
});
+75 -2
View File
@@ -178,6 +178,19 @@ test('圣约送葬人 module REA-Y (已知悉): ASPD +12 with ≥ 2 enemies in r
}
});
test('圣约送葬人 heals 50 per enemy hit with REA-Y or no module, 60 with REA-X (the REA-Y line\'s 12 is its ASPD, GitHub #400)', () => {
for (const [moduleId, want] of [['uniequip_003_excu2', 50], ['uniequip_002_excu2', 60], ['none', 50]]) {
const h = run({
defs: { enemies: { enemy_dummy: dummy('enemy_dummy') } },
units: [{ chessId: 'chess_char_5_01_b', row: 10, col: 4, moduleId }],
enemies: [{ key: 'enemy_dummy', pos: [10, 5] }],
});
const u = h.unit('chess_char_5_01_b');
assert.equal(u.profile.selfHeal, want, moduleId);
done(h);
}
});
// =================================================================================================================
// 缇缇
@@ -1000,7 +1013,7 @@ test('凛御银灰 S3 变革已至: skill range, line attacks bird_atk_scale ×
// =================================================================================================================
// 引星棘刺
test('引星棘刺 S1 度算浪波: an alchemy unit on the lowest-HP ally: DEF +def and hp ratio × ATK/s on the 3×3 around it (+3 s 心相)', () => {
test('引星棘刺 S1 度算浪波: an alchemy unit on the lowest-HP ally: DEF +def and 生命回复速度 hp ratio × ATK/s on the 3×3 around it (+3 s 心相)', () => {
for (const id of pair('15')) {
const h = run({
defs: { chess: { t_low: ally('t_low', { stats: { def: 100 } }), t_near: ally('t_near', { stats: { def: 100 } }), t_far: ally('t_far', { stats: { def: 100 } }) }, enemies: { enemy_dummy: dummy('enemy_dummy') } },
@@ -1019,7 +1032,10 @@ test('引星棘刺 S1 度算浪波: an alchemy unit on the lowest-HP ally: DEF +
h.run(1.1);
for (const a of [low, near]) assert.equal(a.s.def, 100 + bb.def, a.defId);
assert.equal(far.s.def, 100);
approx(heals(h, u, (c) => c.target === near)[0].amount, u.s.atk * bb.hp_recovery_per_sec_ratio);
// 生命回复速度 (an hpRegen buff per unit, like 锡人 S2), not a heal of hers
for (const a of [low, near]) approx(a.buffs.find((b) => b.key === z.key)?.mods.hpRegen, u.s.atk * bb.hp_recovery_per_sec_ratio, a.defId);
assert.equal(far.buffs.some((b) => b.key === z.key), false);
assert.equal(heals(h, u, () => true).length, 0);
mute(h, u);
h.run(z.dur);
assert.ok(!u.mem.zones.includes(z), 'expired');
@@ -1028,6 +1044,63 @@ test('引星棘刺 S1 度算浪波: an alchemy unit on the lowest-HP ally: DEF +
}
});
test('引星棘刺 S1 度算浪波: two alchemy units on one ally add their DEF (PRTS 备注 「效果均可叠加」, GitHub #388)', () => {
// two 引星棘刺 (a normal and an elite: +60 and +75) throw at the same lowest-HP ally; one shared buff key used to let the
// last unit's DEF replace the other's (+75 instead of +135) while both heals already landed
const [na, el] = pair('15');
const h = run({
defs: { chess: { t_low: ally('t_low', { stats: { def: 100 } }) } },
units: [entry(na, 'skchr_thorn2_1', { row: 10, col: 3 }), entry(el, 'skchr_thorn2_1', { row: 11, col: 3 }), { chessId: 't_low', row: 10, col: 5 }],
enemies: [],
});
const a = sel(h, na, 'skchr_thorn2_1'), b = sel(h, el, 'skchr_thorn2_1');
const low = h.unit('t_low');
h.step();
low.hp = low.s.maxHp * 0.3;
for (const u of [a, b]) u.skill.gainSp(1000);
h.runUntil(() => [a, b].every((u) => (u.mem.zones || []).some((z) => z.type === 'guard')), 10);
h.run(1.1);
const [za, zb] = [a, b].map((u) => u.mem.zones.find((z) => z.type === 'guard'));
assert.deepEqual([za.r, za.c, zb.r, zb.c], [10, 5, 10, 5], 'both on the lowest-HP ally');
assert.equal(low.s.def, 100 + bbOf(a).def + bbOf(b).def, '+60 +75');
assert.ok(low.hp > low.s.maxHp * 0.3, 'and they restore its HP');
// one unit runs out: the other's DEF stays
mute(h, a); mute(h, b);
h.run(Math.max(0, za.dur - za.t) + 0.6);
assert.ok(!a.mem.zones.includes(za) && b.mem.zones.includes(zb), 'the elite\'s unit (7 s) outlasts the normal one (6 s)');
assert.equal(low.s.def, 100 + bbOf(b).def, 'its DEF stays alone');
h.run(zb.dur);
assert.equal(low.s.def, 100, 'no DEF bonus afterwards');
done(h);
});
test('引星棘刺 S1 度算浪波: at an HP-ratio tie the unit goes to the latest deployed ally (PRTS 备注 「优先选择生命比例最低>最晚部署的我方单位」)', () => {
for (const id of pair('15')) {
const h = run({
defs: { chess: { t_first: ally('t_first'), t_last: ally('t_last') } },
// t_first stands nearer to her, t_last deploys after it (until 0.2.2 a tie went to more blocking, then the nearer)
units: [entry(id, 'skchr_thorn2_1', { row: 10, col: 3 }), { chessId: 't_first', row: 11, col: 4 }, { chessId: 't_last', row: 9, col: 5 }],
enemies: [],
});
const u = sel(h, id, 'skchr_thorn2_1');
const first = h.unit('t_first'), last = h.unit('t_last');
h.step();
assert.ok(last.deploySeq > first.deploySeq, 't_last is the later deployment');
first.hp = first.s.maxHp * 0.5; last.hp = last.s.maxHp * 0.5;
cast(h, u);
const z = u.mem.zones.find((x) => x.type === 'guard');
assert.deepEqual([z.r, z.c], [9, 5], 'the latest deployed of the two at 50 %');
// the lowest ratio still comes first
last.hp = last.s.maxHp; first.hp = first.s.maxHp * 0.4;
const n0 = u.skill.activations;
u.skill.gainSp(1000);
assert.ok(h.runUntil(() => u.skill.activations > n0, 15), 'cast again');
const z2 = u.mem.zones.filter((x) => x.type === 'guard').at(-1);
assert.deepEqual([z2.r, z2.c], [11, 4], 'a lower ratio beats a later deployment');
done(h);
}
});
test('引星棘刺 S1 度算浪波 (AUTO): fires as soon as its SP is full, no enemy needed (GitHub #124)', () => {
for (const id of pair('15')) {
const h = run({
+10 -6
View File
@@ -78,6 +78,8 @@ test('every tier-6 skill × module choice (normal + elite) survives a real wave
units: [{ chessId: id, row: 10, col: 4, skillIndex: s.index, moduleId, carryState: READY }, { chessId: 'chess_char_1_01_a', row: 9, col: 4 }, { chessId: 'chess_char_2_06_a', row: 11, col: 5 }],
enemies: [{ key: 'enemy_1007_slime', count: 8, interval: 1.5 }, { key: 'enemy_1007_slime', route: 1, count: 8, interval: 1.5, time: 3 }],
});
// (a medic acts on the hurt only, and the slimes may fall before they reach anyone: the team starts at half HP)
if (base.profession === 'MEDIC') { h.step(); for (const a of h.b.allyUnits) a.hp = a.s.maxHp / 2; }
h.runToEnd(90);
done(h);
const u = h.b.allyUnits.find((x) => x.defId === id);
@@ -119,15 +121,15 @@ test('6_01 蕾缪安 S2 归乡邀约: ASPD/ATK +; a wanted enemy is aimed at for
const h = run({
defs: { enemies: { el: dummy('el', { rank: 'ELITE' }) } },
units: [U(id, sid, 10, 4, { carryState: READY })], enemies: [{ key: 'el', pos: [10, 6] }],
setup(b) { b.on('enemySpawn', ({ enemy }) => b.addBuff(enemy, { key: 'lemuen:wanted' })); },
});
const u = h.unit(id);
usesSkill(u, sid);
assert.ok(h.runUntil(() => u.skill.active, 10));
approx(skillBuff(u).mods.aspd, bb.attack_speed, 'ASPD +');
approx(skillBuff(u).mods.atkPct, bb.atk, 'ATK +');
assert.ok(h.runUntil(() => !!h.enemies()[0]?.findBuff('lemuen:wanted'), 10), 'real detection marks the enemy');
assert.ok(h.runUntil(() => !!u.mem.lemAim, 5), 'aims at the wanted enemy');
const ammo = u.skill.ammoLeft, t0 = h.b.time;
const ammo = u.skill.ammoLeft, t0 = h.b.time, aimingAtk = u.s.atk;
const atks0 = h.hooksOf('attack').filter((c) => c.attacker === u).length;
assert.ok(h.runUntil(() => !u.mem.lemAim, 10));
approx(h.b.time - t0, bb['attack@aim_duration'], 'aims the full time (1e7 HP)', 0.03);
@@ -137,7 +139,7 @@ test('6_01 蕾缪安 S2 归乡邀约: ASPD/ATK +; a wanted enemy is aimed at for
assert.equal(snipe.length, 1);
assert.equal(snipe[0].dmg.canDodge, false);
const wanted = u.def.bonds.includes('lateranoShip') ? tal(id).damage_scale : 1;
approx(snipe[0].amount, u.s.atk * bb['attack@fin_atk_scale'] * wanted, `${id} fin × wanted`);
approx(snipe[0].amount, aimingAtk * bb['attack@fin_atk_scale'] * wanted, `${id} fin × wanted`);
done(h);
}
});
@@ -258,7 +260,7 @@ test('6_03 余 S1 今日做东: taunt +1 while carried; TAKE_DAMAGE cast, HP/DEF
const sid = 'skchr_yu_1', bb = bbOf(id, sid);
const h = run({
defs: { enemies: { hit: enemyRec({ key: 'hit', hp: 1e7, speed: 0, atk: 300, bat: 1 }) } },
units: [U(id, sid, 10, 4, { carryState: READY })], enemies: [{ key: 'hit', pos: [10, 4] }],
units: [U(id, sid, 10, 4, { carryState: READY })], enemies: [{ key: 'hit', pos: [10, 4] }], hooks: [...HOOKS, 'elementBurst'],
});
const u = h.unit(id);
usesSkill(u, sid);
@@ -270,8 +272,10 @@ test('6_03 余 S1 今日做东: taunt +1 while carried; TAKE_DAMAGE cast, HP/DEF
approx(skillBuff(u).mods.defPct, bb.def);
const t0 = h.b.time;
h.run(4);
const burns = h.hooksOf('elementHit').filter((c) => c.source === u && c.dmg.element === 'burn' && c.t > t0 && (c.dmg.tags || []).includes('skill'));
const taken = h.hooksOf('damaged').filter((c) => c.target === u && c.dmg?.isAttack && c.t > t0);
// the burns build a 燃烧 burst on the attacker, which refuses same-element fills while it runs: compare up to it
const end = h.hooksOf('elementBurst').find((c) => c.element === 'burn' && c.t > t0)?.t ?? Infinity;
const burns = h.hooksOf('elementHit').filter((c) => c.source === u && c.dmg.element === 'burn' && c.t > t0 && c.t <= end && (c.dmg.tags || []).includes('skill'));
const taken = h.hooksOf('damaged').filter((c) => c.target === u && c.dmg?.isAttack && c.t > t0 && c.t <= end);
assert.ok(burns.length >= 1 && burns.length === taken.length, `one burn per attack taken (${burns.length}/${taken.length})`);
approx(burns[0].dmg.amount, u.s.atk * bb.ep_damage_ratio, 'ep_damage_ratio × ATK');
done(h);
+2 -1
View File
@@ -609,7 +609,8 @@ test('1_19 野鬃: 夹枪冲锋 wider range, ATK +atk, pushes targets away; elit
approx(u.s.atk, u.base.atk * (1 + bb.atk));
const e = h.enemies()[0];
assert.ok(e.x >= 6.5, `pushed to x=${e.x}`);
assert.ok(h.eventsOf('fx').some((f) => f[1] === 'displace'));
assert.ok(h.eventsOf('fx').some((f) => f[1] === 'displace' && f[4]?.keepFacing === true),
'Wild Mane S2 inherits the common push facing rule');
done(h);
const idb = 'chess_char_1_19_b', t = tal(idb);
+23
View File
@@ -121,6 +121,8 @@ test('莫斯提马 S3: ripple hits every enemy in range, ATK +90 %, knock-back;
assert.ok(es.every((e) => hits.some((c) => c.target === e)), 'all three enemies hit by one ripple');
approx(hits[0].amount, u.s.atk, 1e-6, 'full damage (res 0)');
assert.ok(es.some((e, i) => e.x > x0[i] + 0.2), 'knocked back');
assert.ok(h.eventsOf('fx').some((f) => f[1] === 'displace' && f[4]?.keepFacing === true),
'Mostima S3 inherits the common push facing rule');
checkInvariants(h.b);
});
@@ -454,6 +456,27 @@ test('歌蕾蒂娅 S3: binds the farthest target, tornado pulses 85 % ATK arts e
checkInvariants(h.b);
});
test('歌蕾蒂娅 S3: the skill-end 捕网 has radius 1, the tornado 1.5 (PRTS 备注; GitHub #324, PR #329)', () => {
const id = 'chess_char_4_12_a';
const h = makeBattle({ defs: { enemies: { enemy_dummy: dummy() } }, units: [{ chessId: id, row: 10, col: 3 }], timeLimit: 200, autoFinish: false });
h.step();
const u = h.unit(id);
h.spawn('enemy_dummy', { pos: [10, 6] }); // the farthest enemy: bound, the tornado sits on it
h.step();
assert.ok(u.skill.activate('test', { free: true }));
assert.deepEqual([u.mem.tornado.x, u.mem.tornado.y], [6, 10]);
const at = (x, y) => { const e = h.spawn('enemy_dummy', { pos: [10, 6] }); e.x = x; e.y = y; return e; };
const inner = at(6, 9.1), outer = at(6, 11.25); // 0.9 and 1.25 from the centre
h.step();
for (const e of [inner, outer]) assert.ok(e.findBuff(`glady:slow:${u.id}`), 'both inside the tornado (slowed)');
const p0 = [inner.x, inner.y, outer.x, outer.y];
u.skill.end('test');
h.run(0.6);
assert.ok(Math.hypot(inner.x - p0[0], inner.y - p0[1]) > 0.3, 'the net pulls the enemy 0.9 from the centre');
assert.deepEqual([outer.x, outer.y], [p0[2], p0[3]], 'the one 1.25 from the centre stays: outside the net');
checkInvariants(h.b);
});
test('歌蕾蒂娅 阿戈尔的波涛: 深海猎人 regen 2.5 %/s and −25 % damage from 海怪; elite drag damage', () => {
const id = 'chess_char_4_12_a', t0 = D(id).talents[0].bb;
const sea = withTags(dummy({ key: 'enemy_sea', atk: 0 }), ['seamonster']);
+2 -2
View File
@@ -711,7 +711,7 @@ test('凛御银灰 S2 auto-cast: SKILL_RANGE — an enemy inside the skill range
});
// ------------------------------------------------------------------------------------------------------------------
test('引星棘刺 S2: alchemy unit (+3 s with an op in range) — 120 % ATK arts/s, healing ×0.5 on ground enemies, heals allies; T1 ATK +10 %; T2 ASPD ±10 on long roads', () => {
test('引星棘刺 S2: alchemy unit (+3 s with an op in range) — 120 % ATK arts/s, healing ×0.5 on ground enemies, allies recover (生命回复速度); T1 ATK +10 %; T2 ASPD ±10 on long roads', () => {
const h = makeBattle({
defs: { enemies: { enemy_dummy: dummy() }, chess: { t_near: ally('t_near') } },
units: [{ chessId: 'chess_char_5_15_a', row: 10, col: 3 }, { chessId: 't_near', row: 11, col: 5 }],
@@ -734,7 +734,7 @@ test('引星棘刺 S2: alchemy unit (+3 s with an op in range) — 120 % ATK art
const ticks = tagged(h, 'alchemy', e);
assert.ok(ticks.length >= 1);
approx(ticks[0].amount, u.s.atk * bb.atk_scale, 1e-6);
assert.ok(near.hp > 5000, 'allies inside heal');
assert.ok(near.hp > 5000, 'allies inside recover');
h.run(bb.projectile_delay_time + t0.projectile_extend);
assert.equal(u.mem.zones.length, 0, 'expired');
clean(h);
+26
View File
@@ -1097,6 +1097,32 @@ test('异客: the S3 storm stops when she leaves the field', () => {
assert.equal(h.eventsOf('fx').filter((e) => e[1] === 'lightning').length, n, 'no strike after her retreat');
});
test('异客: the S3 storm is range x-1 — the 13-tile diamond — around the tile of the target\'s centre (PRTS 备注; GitHub #322, PR #329)', () => {
// the storm's target stands off its tile centre at (5.4, 10.4) — tile (10, 5) — and is removed right after the cast, so
// every strike starts on the probe (no chain bounce from the target) when, and only when, the probe is in the zone
const strikes = ([dx, dy]) => {
const h = battle({ units: [{ chessId: 'chess_char_6_05_a', row: 10, col: 3 }], autoFinish: false });
h.step();
const u = h.unit('chess_char_6_05_a');
const tgt = h.spawn('enemy_dummy2', { pos: [10, 5], mods: { hpMul: 2 } });
tgt.x = 5.4; tgt.y = 10.4;
h.step();
assert.ok(u.skill.activate('test', { free: true }));
const storm = h.eventsOf('fx').find((e) => e[1] === 'storm');
assert.deepEqual([storm[2], storm[3]], [5, 10], 'the storm sits on the centre of the target\'s tile');
h.b.dealDamage(null, tgt, { amount: 1e12, type: 'true' });
const p = h.spawn('enemy_dummy', { pos: [10, 5] });
p.x = 5 + dx; p.y = 10 + dy;
h.run(4.2);
checkInvariants(h.b);
return h.dmg.filter((d) => d.src === u.id && d.tgt === p.id && d.tags.includes('storm')).length;
};
// in the diamond (|Δrow| + |Δcol| ≤ 2 by tile): (2, 0) / (0, −2) / (1.95, 0.4) lay outside the old 1.5 circle
for (const off of [[0, 0], [2, 0], [0, -2], [-2, 0], [1.45, 1.45], [1.95, 0.4]]) assert.equal(strikes(off), 8, `(${off}) struck by all 8 strikes`);
// outside it: tile (+2, +1), and tile (+3, 0) — (1.55, 1.05) was inside the old circle around the target's position
for (const off of [[2, 1], [1.55, 1.05], [0, 2.6]]) assert.equal(strikes(off), 0, `(${off}) never struck`);
});
test('蕾缪安: 通缉 needs a continuous 8 s stay in a 拉特兰 range (leaving restarts the count)', () => {
const h = battle({
units: [{ chessId: 'chess_char_6_01_a', row: 12, col: 2 }, { chessId: 'test_lat_a', row: 10, col: 2 }],
+88
View File
@@ -0,0 +1,88 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
const LEM = 'chess_char_6_01_a';
function field({ skillIndex = 2, second = false, unite = false } = {}) {
const units = [{ uid: 1, chessId: LEM, row: 12, col: 2, skillIndex, potential: 1 },
{ uid: 2, chessId: 'chess_char_2_01_a', row: 9, col: 7 }];
if (second) units.push({ uid: 3, chessId: LEM, row: 12, col: 4, skillIndex, potential: 1 });
const config = { defs: { enemies: { target: enemyRec({ key: 'target', rank: 'ELITE', hp: 1e9, speed: 0 }) } },
units, enemies: [{ key: 'target', pos: [9, 8] }], autoFinish: false, hooks: ['skillStart'] };
if (unite) {
config.kind = 'unite';
config.players = [{ playerId: 'p1', seat: 0, side: 'L', colOffset: 0, bonds: {}, units: [{ kind: 'chess', ...units[0] }] },
{ playerId: 'p2', seat: 1, side: 'L', colOffset: 0, bonds: {}, units: [{ kind: 'chess', ...units[1] }] }];
delete config.units;
}
const h = makeBattle(config);h.step();return { h, u: h.unit(1), e: h.enemies()[0] };
}
function done(h) { checkInvariants(h.b); assert.deepEqual(h.b.errors, []); }
for (const elite of [false, true]) test(`rear player's 蕾缪安 S3 locks and spends ammo on the front player's wanted target (elite=${elite})`, () => {
const h = makeBattle({ kind: 'unite', autoFinish: false, flags: { startOpCooldown: 3 },
hooks: ['ammoUsed'], captureNoisy: true,
defs: { enemies: { target: enemyRec({ key: 'target', rank: 'ELITE', hp: 1e9, atk: 0, speed: 0 }) } },
players: [
{ playerId: 'front', seat: 0, side: 'L', colOffset: 0, bonds: {},
units: [{ kind: 'chess', uid: 1, chessId: 'chess_char_2_01_a', row: 9, col: 7 }] },
{ playerId: 'rear', seat: 1, side: 'L', colOffset: 0, bonds: {},
units: [{ kind: 'chess', uid: 2, chessId: elite ? 'chess_char_6_01_b' : LEM, row: 12, col: 2,
skillIndex: 2, carryState: { sp: 999 } }] },
], enemies: [{ key: 'target', pos: [9, 8] }],
});
h.step(); const u = h.unit(2), e = h.enemies()[0];
assert.equal(u.ownerId, 'rear'); assert.equal(h.b.enemiesInKeys(u.baseRangeKeys, u, u.profile).length, 0);
assert.ok(h.runUntil(() => h.hooksOf('ammoUsed').some(c => c.unit === u), 25));
assert.ok(e.findBuff('lemuen:wanted')); assert.ok(u.mem.lemLocks.some(l => l.e === e));
assert.ok(u.skill.activations > 0); done(h);
});
for (const skillIndex of [0, 1, 2]) for (const unite of [false, true]) {
test(`通缉 opens S${skillIndex + 1} outside original range (unite=${unite})`, () => {
const { h, u, e } = field({ skillIndex, unite });
u.skill.gainSp(999);h.run(8.5);
assert.ok(e.findBuff('lemuen:wanted'));
assert.ok(h.hooksOf('skillStart').some(c => c.unit === u));
done(h);
});
}
test('a new 通缉 timer waits for selectability; an existing timer continues through sleep', () => {
for (const startBeforeSleep of [false, true]) {
const { h, u, e } = field();
if (startBeforeSleep) h.run(0.5);
h.b.applyStatus(e, 'sleep', { duration: 10 });
u.skill.gainSp(999);h.run(8.5);
assert.equal(!!e.findBuff('lemuen:wanted'), startBeforeSleep);
assert.equal(u.skill.active, false, 'sleep cannot trigger the skill');
h.run(2);
assert.equal(u.skill.active, startBeforeSleep, 'waking a marked enemy opens S3');
if (!startBeforeSleep) { h.run(8);assert.ok(e.findBuff('lemuen:wanted')); }
done(h);
}
});
test('each 蕾缪安 clears only her own timer/mark on exit and starts fresh on redeployment', () => {
const { h, u, e } = field({ second: true });const other = h.unit(3);
h.run(8.5);assert.ok(e.findBuff('lemuen:wanted'));
h.b.retreat(u, { reason: 'test' });assert.ok(e.findBuff('lemuen:wanted'), 'other source remains');
h.b.retreat(other, { reason: 'test' });assert.equal(e.findBuff('lemuen:wanted'), null);
h.b.redeploy(u, { free: true });h.run(0.5);assert.equal(e.findBuff('lemuen:wanted'), null);
h.run(8);assert.ok(e.findBuff('lemuen:wanted'));
done(h);
});
test('immediate redeployment cannot attack through a cleared wanted range', () => {
const { h, u, e } = field();h.run(8.5);assert.ok(e.findBuff('lemuen:wanted'));
h.b.retreat(u, { reason: 'test' });
assert.equal(e.findBuff('lemuen:wanted'), null);
assert.equal(h.b.enemiesInKeys(u.extraRangeKeys || [], u, u.profile).length, 0);
h.b.redeploy(u, { free: true });const before = u.stats.attacks;u.atkCd = 0;
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);
});
+44
View File
@@ -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);
});
+114
View File
@@ -0,0 +1,114 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { KITS } from '../../server/sim/content/kits/index.js';
import { makeBattle, chessRec, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
const GUAY = 'uniequip_003_shu', CRUY = 'uniequip_003_ulpia';
const approx = (a, b) => assert.ok(Math.abs(a - b) < 1e-6, `${a} != ${b}`);
const target = enemyRec({ key: 'dummy', hp: 1e8, speed: 0, def: 0, atk: 0 });
const war = chessRec({ id: 'war', profession: 'WARRIOR', skill: null, stats: { atk: 100, blockCnt: 0 } });
const med = chessRec({ id: 'med', profession: 'MEDIC', skill: null, stats: { atk: 100, blockCnt: 0 } });
const helpers = [2, 3, 4].map((uid) => ({ uid, chessId: 'war', row: 10, col: uid }));
function shu(tier, others = []) {
const h = makeBattle({ defs: { enemies: { dummy: target }, chess: { war, med } }, autoFinish: false,
units: [{ uid: 1, diy: { slot: tier, charId: 'char_2025_shu', skillIndex: 2, uniEquipId: GUAY }, elite: true, row: 11, col: 5 }, ...others] });
h.step(); return { h, u: h.unit(1) };
}
test('黍 GUA-Y stage 1/3 reduces incoming damage by 15%, independently of HP, after deployment', () => {
for (const tier of [5, 6]) {
const { h, u } = shu(tier);
approx(u.s.dmgTakenMul, 0.85);
const e = h.spawn('dummy', { pos: [10, 9] });
const hp = u.hp;
h.b.dealDamage(e, u, { amount: 100, type: 'true' });
approx(hp - u.hp, 85);
const hp2 = u.hp;
h.b.dealDamage(e, u, { amount: 100, type: 'elemental' });
approx(hp2 - u.hp, 100);
checkInvariants(h.b); assert.equal(h.b.errors.length, 0);
}
});
test('黍 GUA-Y stage 3 requires two conditions, buffs each operator once, and drops the bonus when a condition ends', () => {
for (const tier of [5, 6]) {
const { h, u } = shu(tier, [...helpers, { uid: 5, chessId: 'med', row: 9, col: 3 }]);
h.run(0.3);
const a = h.unit(2);
approx(a.s.atk / a.base.atk, tier === 6 ? 1.05 : 1);
assert.equal(a.s.aspd, tier === 6 ? 116 : 112);
h.b.retreat(h.unit(5), { permanent: true });
h.run(0.7);
approx(a.s.atk / a.base.atk, 1);
assert.equal(u.s.aspd, tier === 6 ? 116 : 112);
checkInvariants(h.b); assert.equal(h.b.errors.length, 0);
}
});
function ulpia({ moduleId = CRUY, potential = 6, stage3 = false } = {}) {
// The mode's tier-5 elite only exposes module stage 1. Exercise its stage-3 talent separately using the
// official CRU-Y blackboard (battle_equip_table, 2026-10-09); do not change the production slot's module level.
const kits = stage3 ? { chess_char_5_05_a: (bb, raw, def) => {
const rec = structuredClone(raw);
rec.talents.find((t) => t.index === 1).bb = {
max_stack_cnt: potential === 6 ? 10 : 9, max_hp: 150, atk: 38,
'ulpia_t_1[abyssal].max_hp': 90, 'ulpia_t_1[abyssal].atk': 22.799999,
'ulpia_t_1[abyssal].max_stack_cnt': potential === 6 ? 10 : 9,
block_cnt: 1, add_block_cnt_stack_cnt: 9,
};
return KITS.chess_char_5_05_a(bb, rec, def);
} } : undefined;
const h = makeBattle({ autoFinish: false, kits, defs: { enemies: { dummy: target, victim: enemyRec({ key: 'victim', hp: 1, atk: 0, speed: 0 }) } },
units: [{ uid: 1, chessId: 'chess_char_5_05_b', row: 10, col: 5, moduleId, skillIndex: 0, potential },
{ uid: 2, chessId: 'chess_char_3_05_a', row: 11, col: 5 },
{ uid: 3, chessId: 'chess_char_1_01_a', row: 9, col: 5 }] });
h.step(); return { h, u: h.unit(1), hunter: h.unit(2), other: h.unit(3) };
}
test('乌尔比安 CRU-Y scales damage against enemies blocked by any ally, including skill damage', () => {
const { h, u, hunter } = ulpia();
const e = h.spawn('dummy', { pos: [10, 7] });
const amount = () => h.b.dealDamage(u, e, { amount: 100, type: 'phys', isSkill: true });
approx(amount(), 100);
e.blockedBy = hunter; hunter.blocking.push(e);
approx(amount(), 110);
e.blockedBy = null; hunter.blocking.length = 0;
checkInvariants(h.b); assert.equal(h.b.errors.length, 0);
});
test('乌尔比安 CRU-Y hunters share kill stacks; block +1 at nine, potential cap ten, other operators do not trigger it', () => {
for (const potential of [1, 6]) {
const { h, u, hunter, other } = ulpia({ potential, stage3: true });
const kill = (killer) => h.b.dealDamage(killer, h.spawn('victim', { pos: [10, 8] }), { amount: 100, type: 'true' });
kill(other); assert.equal(u.findBuff('ulpia:blood'), null);
const block = u.s.blockCnt;
for (let n = 1; n <= 12; n++) {
kill(hunter);
assert.equal(u.findBuff('ulpia:blood')?.stacks, Math.min(n, potential === 6 ? 10 : 9));
assert.equal(u.s.blockCnt, block + (n >= 9 ? 1 : 0));
}
assert.ok(hunter.findBuff('ulpia:bloodShare'));
checkInvariants(h.b); assert.equal(h.b.errors.length, 0);
}
});
test('乌尔比安 without the new module still only gains stacks from his own kills', () => {
const { h, u, hunter } = ulpia({ moduleId: 'none' });
h.b.dealDamage(hunter, h.spawn('victim', { pos: [10, 8] }), { amount: 100, type: 'true' });
assert.equal(u.findBuff('ulpia:blood'), null);
h.b.dealDamage(u, h.spawn('victim', { pos: [10, 8] }), { amount: 100, type: 'true' });
assert.equal(u.findBuff('ulpia:blood').stacks, 1);
checkInvariants(h.b); assert.equal(h.b.errors.length, 0);
});
test('乌尔比安 CRU-Y counts a withdrawn hunter’s delayed credited kill only while Ulpianus is deployed', () => {
const { h, u, hunter } = ulpia({ stage3: true });
h.b.retreat(hunter, { permanent: true });
h.b.dealDamage(hunter, h.spawn('victim', { pos: [10, 8] }), { amount: 100, type: 'true' });
assert.equal(u.findBuff('ulpia:blood')?.stacks, 1);
h.b.retreat(u, { permanent: true });
h.b.dealDamage(hunter, h.spawn('victim', { pos: [10, 8] }), { amount: 100, type: 'true' });
assert.ok((u.findBuff('ulpia:blood')?.stacks ?? 0) <= 1);
checkInvariants(h.b); assert.deepEqual(h.b.errors, []);
});
+33
View File
@@ -319,6 +319,39 @@ test('S3 赤霄·天喟 (MANUAL, data ACTIVE_RANGE on 3-12): range 3-12, each at
}
});
// 用户报告: 「自选干员赤刃明霄陈三技能的龙剑气看不到」. 波是 kit 自己管的探针(会拐弯、跟技能同寿),不在快照的 projectile
// 列表里,所以客户端只能靠 fx 事件画它——原来只在起手发了一个 `dash`、每次命中发 `slash`,波本身**没有任何事件**。
// 现在每 WAVE_FX_EVERY(0.12) 秒发一次 `chen3Wave`:位置 + 上一个点(拖尾)+ 方向(月牙朝向)。
test('S3: the 龙剑气 streams its position to the client as `chen3Wave` fx (position, previous point, direction)', () => {
const { h, u } = field({ tier: 6, elite: true, skill: 2 });
u.skill.gainSp(999);
h.spawn('enemy_dummy', { pos: [10, 6] }); // a target in range: the MANUAL skill casts
const waveFx = () => h.events.filter((e) => e[0] === 'fx' && e[1] === 'chen3Wave');
assert.ok(h.runUntil(() => u.skill.active, 2));
h.run(1);
const fx = waveFx();
assert.ok(fx.length >= 6, `每 0.12 秒一次 ⇒ 1 秒约 8 次(实际 ${fx.length})`);
for (const [, , x, y, ex] of fx) {
assert.equal(ex.id, u.id, '带着她的 id(可定位到玩家/队伍)');
assert.equal(ex.skill, 'chen3:wave');
assert.ok(Math.abs(x - u.tileC) <= 12 && Math.abs(y - u.tileR) <= 12, '波在场上');
}
// 位置随时间前移、方向与朝向一致(row 10 向右),from 是上一个点(拖尾)
const [, , x0, , ex0] = fx[0];
const last = fx[fx.length - 1];
assert.ok(last[2] > x0, `向前推进(${x0.toFixed(2)} → ${last[2].toFixed(2)})`);
assert.deepEqual([last[4].dc, last[4].dr], [1, 0], '方向 = 她的朝向(向右)');
assert.ok(Math.abs(ex0.fromX - u.tileC) < 0.5 && Math.abs(ex0.fromY - u.tileR) < 0.5, '第一个事件从上场点开始');
assert.ok(last[4].fromX < last[2], 'from 是上一个事件的点(拖尾向前)');
// 技能结束后不再发
h.runUntil(() => !u.skill.active, 25);
h.step();
const n = waveFx().length;
h.run(2);
assert.equal(waveFx().length, n, '技能结束后不再有波的 fx');
done(h);
});
test('S3: the sword wave leaves her tile forward at 1.5 tiles/s, hits each enemy within 1.3 once per straight run (air too) for max(6 % of its HP, 530 % / 550 % ATK) arts, turns clockwise at the field\'s edge / high ground / gates and forgets whom it hit, and is gone with the skill', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const sk = skillOf(tier, elite, S3);
+251
View File
@@ -0,0 +1,251 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
import { KITTED_CHARS } from '../../server/sim/content/kits/index.js';
import { diyPool } from '../../shared/diy.js';
import { performAttack, effectiveProfile } from '../../server/sim/ai.js';
const C = 'char_4231_clemnt';
const data = Object.fromEntries(['chess', 'backups'].map((f) => [f, JSON.parse(readFileSync(new URL(`../../data/${f}.json`, import.meta.url)))]));
const approx = (a, b, label = '') => assert.ok(Math.abs(a - b) < 1e-5 * Math.max(1, Math.abs(b)), `${label}: ${a} != ${b}`);
const enemy = (key, opts = {}) => enemyRec({ key, hp: 1e8, atk: 0, speed: 0, ...opts });
const defs = { enemies: { dummy: enemy('dummy'), light: enemy('light', { mass: 3 }), mid: enemy('mid', { mass: 4 }), heavy: enemy('heavy', { mass: 9 }),
fly: enemy('fly', { motion: 'FLY' }), zero: enemy('zero', { mass: 0 }), armored: enemy('armored', { def: 100 }) } };
function field({ tier = 6, elite = true, skill = 0, potential = 6, dir = 'RIGHT', row = 10, col = 3, flags = {} } = {}) {
const h = makeBattle({ seed: 23, autoFinish: false, timeLimit: 180, defs, flags,
hooks: ['damaged', 'attack', 'elementBurst', 'ammoUsed'], captureNoisy: true,
units: [{ uid: 1, diy: { slot: tier, charId: C, skillIndex: skill }, elite, potential, row, col, dir }] });
h.step(); return { h, u: h.unit(1) };
}
function done(h) { checkInvariants(h.b); assert.deepEqual(h.b.errors, []); }
function start(u) { u.skill.gainSp(999, 'init'); assert.equal(u.skill.activate('test'), true); }
const hits = (h, tag) => h.hooksOf('damaged').filter((c) => c.dmg?.tags?.includes(tag));
for (const tier of [5, 6]) for (const elite of [false, true]) test(`风暴潮 air-only automation retains ground-only normal attacks (${tier}/${elite})`, () => {
const { h, u } = field({ tier, elite, skill: 1, flags: { startOpCooldown: 3 } });
const e = h.spawn('fly', { pos: [10, 6] }); u.skill.gainSp(999, 'init');
h.run(2); assert.equal(u.skill.activations, 0, 'operation cooldown still applies');
assert.ok(h.runUntil(() => u.skill.active, 2)); h.run(3);
const damaged = h.hooksOf('damaged').filter(c => c.source === u && c.target === e);
assert.ok(damaged.length > 0, 'the vortex hits the air-only target');
assert.ok(damaged.every(c => !c.dmg.isAttack), 'normal attacks remain ground-only'); done(h);
});
test('风暴潮 auto-cast rejects distant/hidden/sleeping flyers and waits through stun', () => {
const { h, u } = field({ skill: 1 }); u.skill.gainSp(999, 'init');
h.spawn('fly', { pos: [12, 12] }); h.run(0.3); assert.equal(u.skill.activations, 0);
const e = h.spawn('fly', { pos: [10, 6] }); h.b.addBuff(e, { key: 'test:hide', flags: { stealth: true } });
h.run(0.3); assert.equal(u.skill.activations, 0);
h.b.removeBuff(e, 'test:hide'); h.b.applyStatus(e, 'sleep', { duration: 0.5 });
h.run(0.3); assert.equal(u.skill.activations, 0);
h.b.applyStatus(u, 'stun', { duration: 0.6 }); h.run(0.4); assert.equal(u.skill.activations, 0);
h.run(0.5); assert.equal(u.skill.activations, 1); done(h);
});
test('克莱门莎 is selectable with authored skills in both slots, every form and potential', () => {
assert.ok(KITTED_CHARS.includes(C));
for (const tier of [5, 6]) {
assert.ok(diyPool(tier, { data, kitted: KITTED_CHARS }).includes(C));
for (const elite of [false, true]) for (const skill of [0, 1, 2]) for (const potential of [1, 6]) {
const { h, u } = field({ tier, elite, skill, potential });
assert.equal(u.def.charId, C); assert.equal(u.kit.skillSource, 'skills'); assert.ok(!u.kit.generic);
assert.equal(u.skill.id, `skchr_clemnt_${skill + 1}`);
assert.deepEqual([u.profile.dmgType, u.profile.canHitFly, u.s.blockCnt, u.base.bat], ['phys', false, 2, 1.2]);
assert.ok(u.def.bonds.includes('egirShip'));
assert.equal(u.skill.rule, skill ? 'ACTIVE_RANGE' : 'DEFAULT');
done(h);
}
}
});
test('生死定夺 checks current versus initial DEF for each physical hit; scales before DEF, never elemental damage', () => {
for (const potential of [1, 3, 6]) {
const { h, u } = field({ potential });
const e = h.spawn('armored', { pos: [10, 6] }); h.b.rng = () => 0.3;
approx(h.b.dealDamage(u, e, { amount: 1000, type: 'phys' }), 900);
h.b.addBuff(e, { key: 'test:def', mods: { defFlat: -50 } });
approx(h.b.dealDamage(u, e, { amount: 1000, type: 'phys' }), 1000 * (potential >= 3 ? 1.5 : 1.35) - 50);
approx(h.b.dealDamage(u, e, { amount: 1000, type: 'elemental' }), 1000);
const z = h.spawn('dummy', { pos: [10, 7] }); h.b.addBuff(z, { key: 'test:def', mods: { defFlat: -50 } });
approx(h.b.dealDamage(u, z, { amount: 1000, type: 'phys' }), 1000);
done(h);
}
});
test('习得性防御 handles all facings, boundary tiles, blocked enemies, seamonsters, and damage types', () => {
for (const dir of ['RIGHT', 'LEFT', 'UP', 'DOWN']) {
const { h, u } = field({ dir, col: 5 });
const e = h.spawn('dummy', { pos: [10, 5] });
const take = (type = 'arts') => { u.hp = u.s.maxHp; return h.b.dealDamage(e, u, { amount: 100, type }); };
approx(take(), 100 * 0.9 * 0.65); // same tile/column belongs to the forward half-plane
const [dr, dc] = { RIGHT: [0, 1], LEFT: [0, -1], UP: [1, 0], DOWN: [-1, 0] }[dir];
e.x = 5 - dc; e.y = 10 - dr; approx(take(), 90);
e.blockedBy = u; u.blocking.push(e); approx(take(), 90 * 0.65);
e.def = { ...e.def, tags: ['seamonster'] }; approx(take(), 90 * 0.45);
approx(take('true'), 100); approx(take('elemental'), 100);
e.blockedBy = null; u.blocking.length = 0; done(h);
}
});
test('冲蚀 is every fourth attack and fills erosion from actual post-mitigation damage, with no air target', () => {
for (const elite of [false, true]) {
const { h, u } = field({ elite }); const e = h.spawn('dummy', { pos: [10, 4] }); h.b.rng = () => 0.99;
h.run(5);
const dealt = h.hooksOf('damaged').filter((c) => c.source === u && c.dmg.isAttack);
assert.ok(dealt.length >= 4);
dealt.forEach((c, i) => assert.equal(c.dmg.isSkill, i % 4 === 3));
const r = hits(h, 'clemnt:rider'); assert.ok(r.length > 0);
const first = dealt.find((c) => c.dmg.isSkill);
approx(r[0].amount, first.amount * (elite ? 0.3 : 0.25));
assert.ok(e.elem.erosion > 0); done(h);
}
});
test('风暴潮 carries at most eight total weight, excludes flyers/self-bound enemies and releases after three tiles', () => {
const { h, u } = field({ skill: 1 });
const es = [0, 1, 2].map(() => h.spawn('light', { pos: [10, 4] }));
const heavy = h.spawn('heavy', { pos: [10, 4] }), fly = h.spawn('fly', { pos: [10, 4] });
const bound = h.spawn('zero', { pos: [10, 4] }); h.b.addBuff(bound, { key: 'test:bound', flags: { selfBound: true, noMove: true } });
start(u); assert.ok(u.s.flags.disarm); h.run(0.7);
assert.equal(u.mem.clemntCabin.passengers.length, 2);
assert.equal(u.mem.clemntCabin.mass, 2);
assert.ok(es[0].s.flags.unblockable && es[0].s.flags.disarm);
for (const e of [heavy, fly, bound]) assert.ok(!u.mem.clemntCabin.passengers.includes(e));
h.run(0.9); assert.ok(u.mem.clemntCabin.stopped); approx(u.mem.clemntCabin.x, 6);
assert.ok(!u.s.flags.disarm); assert.ok(!es[0].s.flags.unblockable);
approx(es[0].x, 6); done(h);
});
test('风暴潮 caps passengers at fifteen and cleans them on early skill end', () => {
const { h, u } = field({ skill: 1 });
for (let n = 0; n < 20; n++) h.spawn('zero', { pos: [10, 4] });
start(u); h.run(0.4); assert.equal(u.mem.clemntCabin.passengers.length, 15);
const es = [...u.mem.clemntCabin.passengers]; u.skill.stop();
assert.ok(es.every((e) => !e.s.flags.disarm && !e.s.flags.unblockable && !e.s.flags.noMove));
assert.equal(u.mem.clemntCabin, null); done(h);
});
for (const reason of ['death', 'hidden', 'self-bound', 'teleport-immune', 'no-path']) {
test(`风暴潮 releases ${reason} passengers before admitting a replacement in the same tick`, () => {
const { h, u } = field({ skill: 1 });
const victim = h.spawn('light', { pos: [10, 4] });
const survivor = h.spawn('light', { pos: [10, 4] });
start(u); h.run(0.7);
const c = u.mem.clemntCabin, carry = `clemnt:carry:${u.id}`;
assert.deepEqual(c.passengers.map((e) => e.id), [victim.id, survivor.id]);
assert.equal(c.mass, 2);
if (reason === 'death') h.b.dealDamage(u, victim, { amount: 1e12, type: 'true' });
if (reason === 'hidden') victim.hidden = true;
if (reason === 'self-bound') h.b.addBuff(victim, { key: 'test:bound', flags: { selfBound: true } });
if (reason === 'teleport-immune') victim.def = { ...victim.def, immune: new Set(['teleport']) };
if (reason === 'no-path') {
victim.x = 7; victim.y = 12;
for (const [r, col] of [[11, 6], [11, 7], [11, 8], [12, 6], [12, 8]]) h.b.grid.setObstacle(r, col, true);
assert.equal(h.b.grid.findPath(12, 7, Math.round(c.y), Math.round(c.x)), null);
}
const replacement = h.spawn('light', { pos: [c.y, c.x] });
h.step();
assert.deepEqual(c.passengers.map((e) => e.id), [survivor.id, replacement.id]);
assert.equal(c.mass, 2);
assert.ok(!victim.findBuff(carry));
assert.ok(replacement.findBuff(carry));
approx(replacement.x, c.x); approx(survivor.x, c.x);
h.step(); assert.equal(c.mass, 2, 'released weight is returned only once');
done(h);
});
}
for (const [kind, count] of [['zero', 15], ['mid', 1]]) {
test(`风暴潮 frees the ${kind === 'zero' ? 'fifteenth slot' : 'four-weight budget'} after a passenger dies`, () => {
const { h, u } = field({ skill: 1 });
const riders = Array.from({ length: count }, () => h.spawn(kind, { pos: [10, 4] }));
start(u); h.run(0.7);
const c = u.mem.clemntCabin;
assert.equal(c.passengers.length, count);
h.b.dealDamage(u, riders[0], { amount: 1e12, type: 'true' });
const replacement = h.spawn(kind, { pos: [c.y, c.x] });
h.step();
assert.equal(c.passengers.length, count);
assert.ok(!c.passengers.includes(riders[0]));
assert.ok(c.passengers.includes(replacement));
assert.equal(c.mass, kind === 'zero' ? 8 : 4);
done(h);
});
}
test('风暴潮 returns a released passenger’s current weight after a weight modifier', () => {
const { h, u } = field({ skill: 1 });
const victim = h.spawn('light', { pos: [10, 4] });
start(u); h.run(0.7);
const c = u.mem.clemntCabin;
assert.equal(c.mass, 5);
h.b.addBuff(victim, { key: 'test:lighter', mods: { massFlat: -1 }, flags: { selfBound: true } });
assert.equal(victim.weight, 2);
h.step();
assert.equal(c.passengers.length, 0);
assert.equal(c.mass, 7, 'return current weight 2, not boarding weight 3');
done(h);
});
test('风暴潮 stops before a blocked tile; vortex keeps its continuous stop point, hits air and applies normal erosion resistance', () => {
const { h, u } = field({ skill: 1 }); h.b.grid.setObstacle(10, 5, true);
const fly = h.spawn('fly', { pos: [10, 5.55] });
fly.def = { ...fly.def, epResistance: 99 };
start(u); h.run(0.7); assert.ok(u.mem.clemntCabin.stopped);
approx(u.mem.clemntCabin.x, 4.1); h.run(1.1);
approx(fly.findBuff(`clemnt:vortex:${u.id}`).mods.moveMul, 0.3); approx(fly.elem.erosion, 10); // damage formula's 5% floor at 99 resistance
assert.ok(hits(h, 'clemnt:vortex').some((c) => c.target === fly)); done(h);
});
test('风暴潮 normal attack hits five ground enemies in total, never all neighbours or a flyer', () => {
const { h, u } = field({ skill: 1 }); start(u); h.run(1.6);
const es = Array.from({ length: 9 }, () => h.spawn('heavy', { pos: [10, 4] }));
const fly = h.spawn('fly', { pos: [10, 4] });
const before = h.hooksOf('damaged').length;
performAttack(h.b, u, effectiveProfile(u), [es[0]]);
const victims = new Set(h.hooksOf('damaged').slice(before).filter((c) => c.dmg.isAttack).map((c) => c.target));
assert.equal(victims.size, 5); assert.ok(!victims.has(fly)); done(h);
});
for (const elite of [false, true]) test(`风暴潮 splash uses the published 1.1 collision radius and 100% ATK (${elite})`, () => {
const { h, u } = field({ skill: 1, elite }); start(u); h.run(1.6); h.b.rng = () => 0.99;
const main = h.spawn('dummy', { pos: [10, 4] });
const near = h.spawn('dummy', { pos: [10, 5] });
const outside = h.spawn('dummy', { pos: [10, 5.2] });
const body = h.spawn('dummy', { pos: [10, 5.8] });
body.hitArea = { w: 1.6, h: 1, dx: 0, dy: 0 };
const hp = [main, near, outside, body].map(e => e.hp);
performAttack(h.b, u, effectiveProfile(u), [main]);
approx(hp[0] - main.hp, u.s.atk * u.skill.bb['attack@aoe_atk_scale']);
approx(hp[1] - near.hp, u.s.atk);
assert.equal(outside.hp, hp[2], 'a point outside radius 1.1 is not splashed');
approx(hp[3] - body.hp, u.s.atk, 'a large collision body overlapping radius 1.1 is splashed');
done(h);
});
test('与海为敌 normal attacks hit three and spend no ammunition; remote erosion bursts fire three cross-shaped mixed hits', () => {
const { h, u } = field({ skill: 2 }); start(u); h.b.rng = () => 0.99;
const close = Array.from({ length: 3 }, () => h.spawn('dummy', { pos: [10, 4] }));
performAttack(h.b, u, effectiveProfile(u), close);
assert.equal(u.skill.ammoLeft, 10);
const remote = h.spawn('dummy', { pos: [11, 9] }), near = h.spawn('dummy', { pos: [12, 9] });
const diagonal = h.spawn('dummy', { pos: [12, 8] }), fly = h.spawn('fly', { pos: [11, 9] });
h.b.dealDamage(u, remote, { amount: 1000, type: 'element', element: 'erosion' }); h.step();
assert.equal(u.skill.ammoLeft, 9); u.skill.stop(); h.run(2.6);
const bombs = hits(h, 'clemnt:bomb');
for (const e of [remote, near]) {
assert.equal(bombs.filter((c) => c.target === e && c.type === 'phys').length, 3);
assert.equal(bombs.filter((c) => c.target === e && c.type === 'elemental').length, 3);
}
assert.ok(!bombs.some((c) => c.target === diagonal || c.target === fly)); done(h);
});
test('与海为敌 selects nearest marked enemy, suppresses duplicate bombing tiles and does not mark air or sleeping units', () => {
const { h, u } = field({ skill: 2 }); start(u);
const far = h.spawn('dummy', { pos: [12, 9] }), near = h.spawn('dummy', { pos: [11, 7] });
const same = h.spawn('dummy', { pos: [11, 7] }), fly = h.spawn('fly', { pos: [11, 8] });
const asleep = h.spawn('dummy', { pos: [11, 6] }); h.b.applyStatus(asleep, 'sleep', { duration: 5 });
for (const target of [far, near, same, fly, asleep]) h.b.emit('elementBurst', { source: null, target, element: 'erosion' });
h.step(); assert.equal(u.skill.ammoLeft, 9); assert.ok(!u.mem.clemntBombs.marks.has(near));
h.step(); assert.equal(u.skill.ammoLeft, 8); assert.ok(!u.mem.clemntBombs.marks.has(far));
h.run(1.1); assert.equal(u.skill.ammoLeft, 8); assert.equal(u.mem.clemntBombs.marks.size, 0); done(h);
});
+27
View File
@@ -300,6 +300,33 @@ test('S3 临死模式 (a 主动关闭 only — no strategy of this mode closes i
}
});
// 社区反馈 (PR #383 by @Convey123): 「绿条被打空以后受到伤害就会涨红条,这里看不到红条」 — the official HP bar shows the 我执 pool as a red bar
// growing over the drained green one. The sim has kept the pool since 0.2.0 (unit.mem.hsEgo); the snapshot now lists its share of the
// cap (b.snap `neg`, snapshot.js negView — display only) and render/units.js draws it (test/render/hud-readouts.test.js).
test('T1 业火 我执: the pool reaches the client as b.snap `neg` — its share of the cap (2 × max HP) from the first lethal hit, growing with the damage, gone with 我执', () => {
const { h, u } = field({ tier: 6, elite: true, skill: 0, others: [{ uid: 2, chessId: TEXAS, row: 12, col: 9 }] });
const e = h.spawn('enemy_dummy', { pos: [10, 8] });
h.step();
const max = u.s.maxHp;
const negOf = () => (h.b.snapshot().neg || []).find((x) => x[0] === u.id);
assert.equal(h.b.snapshot().neg, undefined, 'not in 我执: no list');
h.b.dealDamage(e, u, { amount: u.hp + 300, type: 'true' });
h.step();
assert.ok(u.mem.hsEgo, '已进入 我执');
const cap = 2 * max; // max_minus_hp_ratio
approx(negOf()[1], 300 / cap, 'the share of the cap', 0.02);
h.b.dealDamage(e, u, { amount: 400, type: 'true' });
h.step();
approx(negOf()[1], 700 / cap, 'it grew with the damage', 0.02);
assert.ok(negOf()[1] < 1, 'still below a full pool');
assert.ok(u.hp / max < 0.01, 'her green bar sits on the 1-HP floor: the red bar is the whole story');
assert.equal(h.b.snapshot().units.find((t) => t[0] === u.id).length, 9, 'the tuple is not widened');
assert.ok(h.runUntil(() => !u.mem.hsEgo, 60), 'the quiet-time regeneration clears the pool');
h.step();
assert.equal(h.b.snapshot().neg, undefined, 'no pool, no red bar');
done(h);
});
test('T1 业火 我执: a lethal hit leaves her on the field (HP floor) with the excess as negative HP; later damage goes there (受击回复 SP still), 禁疗 meanwhile; 200 % of max HP knocks her out; 4 s without damage ⇒ 生命回复速度 5 %/s clears it and she leaves 我执', () => {
for (const [tier, elite] of [[5, false], [6, true]]) {
const t0 = formOf(tier, elite).talents.find((t) => t.index === 0).bb;
+3 -2
View File
@@ -212,8 +212,9 @@ test('S1 坚守阵线 (AUTO, SP_FULL): at full SP with no enemy, 持续时间无
const life = tokenTalent(tier, elite, mod).duration;
assert.ok(h.runUntil(() => u.skill.active, sk.spCost + 1), `${label(f)}: cast at ${sk.spCost} s with no enemy`);
approx(h.b.time, sk.spCost, `${label(f)}: at full SP`, 0.02);
// the first shield expired before (life < cost) and came back at life + 30: it gets the 20 s now
assert.ok(t.alive);
// the first shield expired before (life < cost) and came back at life + 30: it gets the 20 s now — or, T6 (60 + 30 =
// the 90 s cost), on its return a tick after the cast (its timers count from its deployment on the first tick)
assert.ok(h.runUntil(() => t.alive, 0.1), `${label(f)}: the shield is on the field`);
h.step();
approx(u.s.atk, u.base.atk * (1 + sk.bb.atk), `${label(f)}: ATK`);
approx(u.s.def, u.base.def * (1 + sk.bb.def + 0.18), `${label(f)}: DEF (+18 % T1)`);
+3 -1
View File
@@ -207,7 +207,9 @@ test('S2 驱凶辟邪: passive ASPD +15 / +20; cast on his attack, the target is
approx(h.b.time - t0, sk.bb.paper_duration, `T${tier}: after 5 s`, 0.05);
const dmg = h.hooksOf('damaged').slice(n0);
const burst = dmg.filter((c) => c.source === u && c.dmg.tags.includes('lmlee:burst'));
const hits = h.hooksOf('damaged').filter((c) => c.target === t && c.source === u && !c.dmg.tags.includes('lmlee:burst') && h.b.time >= t0).length;
// his hits on it before the burst (one of his hits in the burst's own tick lands after it and is not counted)
const all = h.hooksOf('damaged');
const hits = all.slice(0, all.indexOf(burst[0])).filter((c) => c.target === t && c.source === u && !c.dmg.tags.includes('lmlee:burst')).length;
assert.deepEqual(burst.map((c) => c.target).sort((a, b) => a.id - b.id), [t, near].sort((a, b) => a.id - b.id), `T${tier}: radius ${MARK_RADIUS}, the flyer too, not 2 tiles away`);
assert.ok(!burst.some((c) => c.target === far));
for (const c of burst) {
+2 -1
View File
@@ -248,9 +248,10 @@ test('S3 延异视阈: enemy shots on his range fly at 5 % of their speed (back
for (const g of h.enemies()) h.b.applyStatus(g, 'stun', { duration: 99, source: null }); // no new shot
const held = h.b.projectiles.list.filter((p) => p.source && p.source.side === 'enemy').length;
assert.ok(held >= 1, `${held} shots in the air as it ends`);
const taken = u.stats.taken;
h.runUntil(() => !u.skill.active, 2);
h.step();
// (until the end a held shot still reaches him at its 5 % speed — one a second from the gun 2 tiles away)
const taken = u.stats.taken;
const left = h.b.projectiles.list.filter((p) => p.source && p.source.side === 'enemy');
if (!disarm) {
assert.equal(left.length, 0, 'cleared');
+3 -2
View File
@@ -124,10 +124,11 @@ test('S1 旋刃 (切换: on until she leaves; data DEFAULT): range −1 (the far
const b = h.spawn('enemy_dummy', { pos: [11, 5] }), c = h.spawn('enemy_dummy', { pos: [9, 6] });
const far = h.spawn('enemy_dummy', { pos: [12, 5] }); // 2 from A, 1 from B: reachable from B only
h.run(0.1);
const n0 = atkHits(h, u).length;
const before0 = atkHits(h, u), n0 = before0.length;
h.run(4);
const after = atkHits(h, u).slice(n0);
const id = after[0].dmg.attackId;
// the first attack thrown after that (one in flight then has already hit A before the others came)
const id = after.find((x) => !before0.some((y) => y.dmg.attackId === x.dmg.attackId)).dmg.attackId;
const chain = after.filter((x) => x.dmg.attackId === id);
assert.equal(chain.length, 4, `T${tier}: the hit + 3 bounces`);
assert.equal(chain[0].target, lone, `T${tier}: her target`);
+58 -19
View File
@@ -101,12 +101,23 @@ test('望 in every 自选 form: her kit, stats + module attributes, 3-3, blocks
assert.equal(validateDiyPicks({ [SLOT[6]]: { charId: WANG, skillIndex: 2, uniEquipId: X } }, { data, kitted: KITTED_CHARS }).ok, true);
});
test('铸子: a lone 棋子 never sets off; two side by side activate each other — an enemy (air too) on one sets it off and it is used up; it stays active once its partner is gone', () => {
{ // lone
const { h, st } = field({ tier: 6, elite: true, skill: 1, stones: [[9, 6]] });
test('铸子: the battle-start deployment of a placed 棋子 is its 手动部署 — one 跟子 next to it, so a lone 棋子 is live; with no free neighbour it never sets off; two side by side activate each other — an enemy (air too) on one sets it off and it is used up; it stays active once its partner is gone', () => {
{ // lone: its 跟子 上 of it (its neighbours are road, 下 is off the field) — they connect
const { h, u, st } = field({ tier: 6, elite: true, skill: 1, stones: [[9, 6]] });
assert.deepEqual(placed(h), [[10, 6]], 'the battle start drops one 跟子, 上 first among equal tiles');
assert.equal(u.mem.wang.stock, 6, 'the 棋子 takes one of her 7; the 跟子 none');
h.spawn('enemy_dummy', { pos: [9, 6] });
h.step();
assert.ok(!st[0].alive && stoneHits(h).length > 0, 'a lone 棋子 with its 跟子 sets off');
done(h);
}
{ // no free neighbour (operators on 上 / 右 / 左, 下 off the field): no 跟子, never sets off
const others = [[10, 6], [9, 7], [9, 5]].map(([row, col], i) => ({ uid: 10 + i, chessId: 'chess_char_1_01_a', row, col }));
const { h, st } = field({ tier: 6, elite: true, skill: 1, stones: [[9, 6]], others });
assert.equal(placed(h).length, 0, 'nowhere to put it');
h.spawn('enemy_dummy', { pos: [9, 6] });
h.run(3);
assert.equal(stoneHits(h).length, 0, 'a lone 棋子');
assert.equal(stoneHits(h).length, 0, 'a 棋子 with no partner');
assert.ok(st[0].alive);
done(h);
}
@@ -115,7 +126,7 @@ test('铸子: a lone 棋子 never sets off; two side by side activate each other
h.step();
const a = at(st, 9, 6), b = at(st, 9, 7);
assert.ok(a.mem.wangNode.act && b.mem.wangNode.act, 'connected ⇒ active');
assert.equal(placed(h).length, 0, 'the battle-start deployment drops no 跟子');
assert.deepEqual(placed(h), [[10, 6], [10, 7]], 'the battle start drops one 跟子 上 of each');
const fl = h.spawn('enemy_fly', { pos: [9, 7] });
h.step();
assert.ok(!b.alive && a.alive, 'the flyer set off its 棋子, which is used up');
@@ -142,11 +153,10 @@ test('铸子: a spent 棋子 comes back on its tile 2 s later for its cost (TRP-
const dp0 = h.b.players[0].dp;
assert.ok(h.runUntil(() => b.alive, 1), 'back');
approx(h.b.players[0].dp, dp0 - (mod === X ? 2 : 3), `${mod ?? 'none'}: its cost`);
// the 跟子: 上 of (10,7) is (11,7) — deployable ground; no enemy anywhere ⇒ undeployable first: (10,7)'s neighbours on
// the flat stage are all deployable road but 右 (10,8) … the order 上 > 右 > 下 > 左 among equals
const f = u.mem.wang.followers;
assert.equal(f.length, 1, 'one 跟子');
assert.deepEqual([f[0].r, f[0].c], [11, 7], '上 first among equal tiles');
// the battle start dropped one 跟子 上 of each (equal road tiles: 上 > 右 > 下 > 左); the return of (10,7) finds its 上
// (11,7) taken by its own and (10,6) by the other 棋子, so 右 (10,8)
assert.deepEqual(placed(h), [[11, 6], [11, 7], [10, 8]], `${mod ?? 'none'}: 上 at the battle start, then 右`);
assert.equal(u.mem.wang.followers.length, 3);
done(h);
}
{ // an enemy tile first; undeployable ground before deployable ground
@@ -156,23 +166,36 @@ test('铸子: a spent 棋子 comes back on its tile 2 s later for its cost (TRP-
h.step();
assert.ok(!b.alive);
h.b.kill(h.b.enemies.find((x) => x.alive), null);
// the battle start: (11,9) deploys first (a column top to bottom) — its 右 (11,10) is undeployable floor, before its
// road 上; then (10,9) takes its floor 右 (10,10)
assert.deepEqual(placed(h), [[11, 10], [10, 10]], '不可部署地块 > 可部署地面地块 (before 上)');
const fl = h.spawn('enemy_fly', { pos: [9, 9] }); // 下 of (10,9) holds an enemy (a flyer: no block on the trap rule)
assert.ok(h.runUntil(() => b.alive, 3), 'back (a flyer does not stop it)');
assert.deepEqual(placed(h), [[9, 9]], 'the enemy tile first (before the undeployable 右 (10,10))');
assert.deepEqual(placed(h)[2], [9, 9], 'the return: the enemy tile first');
h.step();
assert.ok(h.hooksOf('damaged').some((c) => c.target === fl && c.source === u && c.dmg.tags.includes('wang:stone')), 'the 跟子 sets off at once — its damage is 望\'s');
assert.equal(u.mem.wang.followers.length, 0);
assert.equal(u.mem.wang.followers.length, 2, 'the two on the floor stay; the one on the flyer is spent');
done(h);
}
{ // no enemy around: undeployable ground (10,10) before the road
{ // a return with no enemy around: undeployable ground (10,10) before the road
const { h, u, st } = field({ tier: 6, elite: true, skill: 1, stones: [[10, 9], [11, 9]], dp: 50 });
const b = at(st, 10, 9);
const e = h.spawn('enemy_dummy', { pos: [10, 9] });
h.step();
h.b.kill(e, null);
u.mem.wang.followers.length = 0; // (the battle start's 跟子 gone: the floor beside it is free again)
const n0 = placed(h).length;
assert.ok(h.runUntil(() => b.alive, 3));
assert.deepEqual(placed(h), [[10, 10]], '不可部署地块 > 可部署地面地块');
void u;
assert.deepEqual(placed(h).slice(n0), [[10, 10]], '不可部署地块 > 可部署地面地块');
done(h);
}
{ // a 跟子 on the tile of a piece still to come at the battle start goes when that piece deploys there ("跟子所在格进行部署…
// 时,该跟子会随之消失"): (10,3)'s 上 holds an operator, so its 跟子 takes 右 (10,4) — the next 棋子's tile —, which
// deploys there and drops its own 上 (11,4)
const { h, u } = field({ tier: 6, elite: true, skill: 1, stones: [[10, 3], [10, 4]], others: [{ uid: 10, chessId: 'chess_char_1_01_a', row: 11, col: 3 }] });
assert.deepEqual(placed(h), [[10, 4], [11, 4]]);
assert.deepEqual(u.mem.wang.followers.map((f) => [f.r, f.c]), [[11, 4]], 'the one on (10,4) is gone');
assert.ok(h.eventsOf('fx').some((e) => e[1] === 'disappear' && e[2] === 4 && e[3] === 10), 'it vanishes');
done(h);
}
{ // 望 away ⇒ her 棋子 leave and wait for her; her 跟子 are gone
@@ -269,6 +292,8 @@ test('her stock (the owner\'s decision of 2026-10-06): the placed 棋子 occupy
assert.deepEqual([u.skill.activations, w.stock], [1, 7], `${S}: 立即获得两枚棋子`);
h.step();
assert.ok(!u.s.flags.noSp, `${S}: below the cap, no 阻回`);
assert.ok(u.skill.nextCastAt > h.b.time, `${S}: the next cast waits one attack interval (GitHub #298)`);
h.run(u.skill.nextCastAt - h.b.time); // wait it out
u.skill.gainSp(999);
h.step();
assert.deepEqual([u.skill.activations, w.stock], [2, 8], `${S}: 最多拥有8枚`);
@@ -306,13 +331,14 @@ test('S2 连星 (AUTO, SP_FULL): a triggered piece hits every enemy on its conne
const { h, u } = field({ tier, elite, skill: 1, stones: [[10, 6], [10, 7]] });
const onLine = h.spawn('enemy_fly', { pos: [10, 9] }); // 3 tiles right of (10,6)…(10,7)+2: on the horizontal line
const beyond = h.spawn('enemy_dummy', { pos: [10, 11] }); // 4 tiles past (10,7)
const off = h.spawn('enemy_dummy', { pos: [11, 7] }); // off the line (vertical never activated)
const vertical = h.spawn('enemy_dummy', { pos: [12, 7] }); // its battle-start 跟子 (11,7) is 上 of it: the vertical line is on
const off = h.spawn('enemy_dummy', { pos: [9, 4] }); // on neither line
const trig = h.spawn('enemy_runner', { pos: [10, 7] });
h.b.applyStatus(trig, 'stun', { duration: 99, force: true }); // it stays on the tile (the slow is read off its speed stat)
h.step();
const hit = stoneHits(h);
const victims = new Set(hit.map((c) => c.target));
assert.ok(victims.has(trig) && victims.has(onLine), `T${tier}: the line, air included`);
assert.ok(victims.has(trig) && victims.has(onLine) && victims.has(vertical), `T${tier}: the lines, air included`);
assert.ok(!victims.has(beyond) && !victims.has(off), `T${tier}: not beyond ± 3 / off the line`);
for (const c of hit.filter((x) => x.target === trig)) approx(c.amount, u.s.atk * sk.bb['attack@atk_scale'] * 1.24, `T${tier}: 2 stacks`, 1e-4);
const slow = trig.findBuff('wang:slow');
@@ -359,8 +385,9 @@ test('S3 天下劫 (MANUAL, ACTIVE_RANGE 4-12, 20 bullets): passive — the piec
assert.equal(u.skill.attackOverride()?.noAttack, true, `T${tier}: 停止攻击`);
assert.equal(u.mem.wang.stock, 8, `T${tier}: +8, capped`);
const fl = placed(h).map(([r, c]) => `${r},${c}`);
assert.equal(fl.length, 5, `T${tier}: (7 − 2 placed) + 8 − 8 over the cap`);
assert.deepEqual(fl.slice(0, 2).sort(), ['10,7', '9,5'], `T${tier}: the enemies' tiles first (${fl})`);
assert.deepEqual(fl.slice(0, 2), ['12,6', '12,7'], `T${tier}: the battle start's 跟子, 上 of each (${fl})`);
assert.equal(fl.length, 7, `T${tier}: then (7 − 2 placed) + 8 − 8 over the cap`);
assert.deepEqual(fl.slice(2, 4).sort(), ['10,7', '9,5'], `T${tier}: the overflow on the enemies' tiles first (${fl})`);
assert.ok(h.hooksOf('damaged').some((c) => c.target === a && c.source === u && c.dmg.tags.includes('wang:stone')), `T${tier}: they set off`);
// inside her range a 棋子 comes back onto an enemy ("可部署至敌人所在位置") — and sets off again
h.b.kill(a, null); h.b.kill(b, null);
@@ -390,6 +417,18 @@ test('S3 天下劫 (MANUAL, ACTIVE_RANGE 4-12, 20 bullets): passive — the piec
}
});
test('S3 天下劫: the overflow 跟子 take her range by the 铸子 order (S3 备注 "采用相同的位置选择优先顺序") — enemy tiles first, an enemy on undeployable floor before one on the road her target order puts first, then the floor', () => {
const { h, u } = field({ tier: 6, elite: true, skill: 2, row: 10, col: 6, stones: [[11, 4], [11, 5]], dp: 300 });
assert.deepEqual(placed(h), [[12, 4], [12, 5]], 'the battle start');
u.skill.gainSp(999);
h.spawn('enemy_dummy', { pos: [10, 10] }); // on tile_floor
h.spawn('enemy_dummy', { pos: [10, 8] }); // on the road, nearer the goal
assert.ok(h.runUntil(() => u.skill.active, 2));
const fl = placed(h).slice(2).map(([r, c]) => `${r},${c}`);
assert.deepEqual(fl, ['10,10', '10,8', '9,10', '11,10', '12,10'], `(7 − 2) + 8 − 8 = 5: the enemies' tiles by class, then the undeployable tiles (${fl})`);
done(h);
});
test('the hand: deploying her in prep brings 7 棋子 (TRP-X "可同时部署的陷阱数量提升": 8) — the match\'s data view reads the module variant\'s deploy limit', () => {
const gd = new GameData(getData({ log: { warn() {}, error() {}, info() {} } }), 'mode_multi_hard');
const data = { chess: CHESS, backups: BACKUPS };
@@ -0,0 +1,56 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, enemyRec } from '../helpers/battleHarness.js';
test('Dorothy, Pozëmka, and Schwarz DEF reductions fall back to the strongest unexpired application', () => {
const h = makeBattle({
autoFinish: false,
timeLimit: 10,
flags: { dpInit: 0, dpPerSec: 0 },
defs: { enemies: { enemy_dummy: enemyRec({ key: 'enemy_dummy', hp: 1e9, def: 1000, speed: 0 }) } },
units: [
{ uid: 1, diy: { slot: 'chess_char_6_diy1_a', charId: 'char_4048_doroth', skillIndex: 0, uniEquipId: null }, elite: true, row: 10, col: 4 },
{ uid: 2, kind: 'token', tokenId: 'token_10025_doroth_recttp', ownerUid: 1, row: 9, col: 6 },
{ uid: 3, diy: { slot: 'chess_char_6_diy2_a', charId: 'char_4055_bgsnow', skillIndex: 0, uniEquipId: null }, elite: true, row: 10, col: 5 },
{ uid: 4, kind: 'token', tokenId: 'token_10026_bgsnow_subbow', ownerUid: 3, row: 9, col: 5 },
{ uid: 5, diy: { slot: 'chess_char_5_diy1_a', charId: 'char_340_shwaz', skillIndex: 2, uniEquipId: null }, elite: true, row: 9, col: 4 },
],
});
h.step();
// Keep the real kits and projectile travel; prevent extra attacks refreshing their reductions.
for (const unit of h.allies()) unit.atkCd = 100;
const trap = h.unit(2), typewriter = h.unit(4), schwarz = h.unit(5);
const enemy = h.spawn('enemy_dummy', { pos: [9, 6] });
h.step();
assert.equal(enemy.s.def, 700, 'Dorothy S1 applies the initial 30% reduction');
h.run(0.9 - h.b.time);
assert.equal(h.b.forceAttack(typewriter, [enemy]), true);
h.run(1.8 - h.b.time);
schwarz.skill.gainSp(999);
schwarz.atkCd = 0;
h.step();
assert.equal(schwarz.skill.active, true, 'Schwarz S3 guarantees her armor-piercing talent');
schwarz.atkCd = 100;
h.run(2.1 - h.b.time);
const reductions = h.hooksOf('statusApplied').filter((event) => event.target === enemy && event.status === 'defDown');
assert.deepEqual(reductions.map(({ source, value, duration }) => [source.id, value, duration]), [
[trap.id, 0.3, 5],
[typewriter.id, 0.25, 5],
[schwarz.id, 0.2, 5],
]);
for (const [index, expectedTime] of [[0, 0], [1, 1], [2, 2]]) {
assert.ok(Math.abs(reductions[index].t - expectedTime) < 0.1, `reduction ${index + 1} lands near ${expectedTime}s`);
}
assert.equal(enemy.s.def, 700, 'weaker applications do not reduce the active strength');
h.run(5.1 - h.b.time);
assert.equal(enemy.s.def, 750, 'the typewriter reduction survives Dorothy S1 expiring');
h.run(6.1 - h.b.time);
assert.equal(enemy.s.def, 800, 'Schwarz reduction survives the typewriter reduction expiring');
h.run(7.1 - h.b.time);
assert.equal(enemy.s.def, 1000, 'DEF returns to its base value after the final application expires');
assert.deepEqual(h.b.errors, []);
h.invariants();
});
+32 -2
View File
@@ -16,6 +16,7 @@ import { join, dirname } from 'node:path';
import { tmpdir } from 'node:os';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { stripPotential } from '../shared/potential.js';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
// DATA_DIR lets the suite validate an alternative build output (e.g. `--out /tmp/x`).
@@ -271,7 +272,9 @@ test('config: modes, rounds and templates', () => {
assert.equal(config.economy.benchSize, 10);
assert.equal(config.economy.deployCap, 8);
assert.equal(config.economy.mergeCountOverrides.chess_char_2_11_a, 2);
assert.equal(config.economy.poolCopiesOverrides.chess_char_6_11_a, 4);
assert.equal(config.economy.poolCopiesOverrides.chess_char_2_11_a, 8);
assert.equal(config.economy.coopPoolMultiplier, 2);
assert.equal(config.economy.itemPoolCopiesOverrides.chess_item_5_07_e_a, 2);
assert.deepEqual(config.hiddenCore.single, 350);
assert.deepEqual(config.hiddenCore.multi, 1200);
assert.equal(config.titles.length, 6);
@@ -558,7 +561,8 @@ test('chess: golden modules[] (+ statsBase/traitBase/talentsBase) compose back t
// default loadout = the record's own stats / trait / talents
assert.deepEqual(composeStats(c.statsBase, dm?.attr), c.stats, `${c.chessId}: statsBase + default attr = stats`);
assert.deepEqual(dm?.traitOverride ?? c.traitBase, c.trait, `${c.chessId}: trait`);
assert.deepEqual(composeTalents(c.talentsBase, dm?.talentChanges), c.talents, `${c.chessId}: talents`);
// (the record's own lists chain their lower-potential entries — shared/potential.js; a composed list carries none)
assert.deepEqual(composeTalents(c.talentsBase, dm?.talentChanges), stripPotential(c.talents), `${c.chessId}: talents`);
assert.equal(new Set(c.modules.map((m) => m.uniEquipId)).size, c.modules.length);
for (const m of c.modules) {
nMods++;
@@ -727,6 +731,32 @@ test('official spot checks (hard-coded values from the zh_CN client data)', () =
assert.equal(config.modes.mode_multi_abyss.enemyScale['6'].hp, 2.239488); // 1.2^4 × 1.08
});
test('a module trait part that only adds a display line writes that line only (GitHub #400): 圣约送葬人 REA-Y heals 50 per enemy hit, its line ASPD +12', () => {
// official battle_equip_table uniequip_003_excu2: a DISPLAY part whose blackboard `value` 12 belongs to its own
// 「攻击速度+{value}」; the class trait's 「回复自身{value}生命」 stays the base 50 (REA-X's TRAIT_DATA_ONLY part rewrites it: 60)
const g = chess.chess_char_5_01_b;
const y = g.modules.find((m) => m.uniEquipId === 'uniequip_003_excu2').traitOverride;
assert.equal(y.desc, g.traitBase.desc, 'the class trait as without a module');
assert.match(y.desc, /每攻击到一个敌人回复自身50生命/);
assert.match(y.descRaw, /回复自身<@ba\.kw>50<\/>生命/);
assert.equal(y.moduleDesc, '攻击范围内存在2名及以上敌人时攻击速度+12');
assert.match(g.modules.find((m) => m.uniEquipId === 'uniequip_002_excu2').traitOverride.desc, /回复自身60生命/, 'REA-X');
// no other module record carries a trait line that differs from its owner's class trait only in a number its own
// display line uses (the class of this bug)
const forms = [...Object.values(chess), ...Object.values(D.backups.units || {}).flatMap((u) => Object.values(u.forms || {}))];
for (const f of forms) {
for (const m of f.modules || []) {
const o = m.traitOverride;
if (!o?.moduleDesc || !f.traitBase?.desc || o.desc === f.traitBase.desc) continue;
const nums = (t) => String(t).match(/\d+(?:\.\d+)?/g) || [];
const sameWords = o.desc.replace(/\d+(?:\.\d+)?/g, '#') === f.traitBase.desc.replace(/\d+(?:\.\d+)?/g, '#');
if (!sameWords) continue;
const moved = nums(o.desc).filter((n, i) => n !== nums(f.traitBase.desc)[i]);
assert.ok(!moved.every((n) => nums(o.moduleDesc).includes(n)), `${f.chessId || f.charId} ${m.uniEquipId}: ${o.desc} / ${o.moduleDesc}`);
}
}
});
/** Read an official cache file (only called when HAS_CACHE). */
const raw = (rel) => JSON.parse(readFileSync(join(CACHE, rel), 'utf8'));
+186
View File
@@ -0,0 +1,186 @@
// test/diag.test.js — the client's error log and bug-report diagnostics (public/js/diag.js; 设置 → 问题反馈): what an
// entry keeps (message, a short stack without the page's host), repeats counted on one entry, the cap, the report's
// lines, the battle attached as a replayable b.start, and the player names / room code / token never in the text.
import { test, describe, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import {
recordError, errorLog, errorCount, clearErrorLog, messageOf, stackOf, stripHosts, redact, redactValues, secretsOf, buildDiagnostics,
setBattleSource, currentBattle, LOG_MAX, STACK_LINES,
} from '../public/js/diag.js';
import { APP_VERSION } from '../shared/constants.js';
const v8Error = (msg = 'boom') => {
const e = new TypeError(msg);
e.stack = [`TypeError: ${msg}`, ...Array.from({ length: 9 }, (_, i) => ` at f${i} (http://192.168.1.5:3000/js/ui/a.js:${i + 1}:7)`)].join('\n');
return e;
};
describe('the error log', () => {
beforeEach(() => clearErrorLog());
test('an entry keeps the message and the first frames of the stack, without the page\'s scheme and host', () => {
const e = recordError('error', v8Error('x is undefined'), null, 1000);
assert.equal(e.message, 'TypeError: x is undefined');
const frames = e.stack.split('\n');
assert.equal(frames.length, STACK_LINES);
assert.equal(frames[0], 'at f0 (/js/ui/a.js:1:7)');
assert.ok(!e.stack.includes('192.168.1.5'));
// Firefox / Safari stacks list frames only: the first line is a frame and stays
assert.equal(stackOf({ stack: 'g@https://sp.example/sim/x.js:3:9\nh@https://sp.example/sim/y.js:1:1' }), 'g@/sim/x.js:3:9\nh@/sim/y.js:1:1');
assert.equal(stackOf('a string'), null);
});
test('messages of a NetError, a {code, msg} push, a string and a plain object', () => {
assert.equal(messageOf(Object.assign(new Error('请求超时'), { name: 'NetError', code: 'TIMEOUT' })), 'NetError: 请求超时 (TIMEOUT)');
assert.equal(messageOf({ code: 'ROOM_FULL', msg: '房间已满' }), '房间已满 (ROOM_FULL)');
assert.equal(messageOf({ code: 'OFFLINE' }), 'OFFLINE');
assert.equal(messageOf(' plain text '), 'plain text');
assert.equal(messageOf({ a: 1 }), '{"a":1}');
assert.equal(messageOf(null), 'null');
assert.ok(messageOf('x'.repeat(500)).length <= 300);
assert.equal(stripHosts('see http://10.0.0.2:3000/x.js and https://a.b/c'), 'see /x.js and /c');
});
test('the same error again raises the count of its entry; another source or message is a new entry', () => {
recordError('sim', '[sim] skill failed', null, 1000);
recordError('sim', '[sim] skill failed', null, 2000);
recordError('sim', '[sim] skill failed', null, 3000);
recordError('runner', '[sim] skill failed', null, 4000);
recordError('runner', 'other', null, 5000);
const log = errorLog();
assert.deepEqual(log.map((e) => [e.source, e.count]), [['sim', 3], ['runner', 1], ['runner', 1]]);
assert.equal(log[0].lastAt, 3000);
assert.equal(errorCount(), 5);
});
test('the log keeps the newest LOG_MAX entries and the report says how many were dropped', () => {
for (let i = 0; i < LOG_MAX + 5; i++) recordError('error', `e${i}`, null, i * 1000);
const log = errorLog();
assert.equal(log.length, LOG_MAX);
assert.equal(log[0].message, 'e5');
const text = buildDiagnostics({ state: {}, now: 100000, pageStart: 0 });
assert.match(text, /errors: 45 recorded, 5 oldest entries dropped/);
assert.match(text, /#1 00:00:44 error: e44/, 'newest first');
clearErrorLog();
assert.equal(errorCount(), 0);
assert.equal(errorLog().length, 0);
});
});
describe('the report', () => {
beforeEach(() => clearErrorLog());
const state = {
connection: { status: 'online', ping: 41.6, attempt: 0, lastError: null },
me: { playerId: 'p_1', name: '阿米娅', token: 'tok-secret-123' },
session: { entered: true },
room: { code: 'K7Q2', mode: 'coop', difficulty: 'HARD', seats: [{ name: '阿米娅' }, { name: 'Kal' }, { name: 'Kaltsit' }, null], spectators: [{ name: 'W' }] },
match: {
public: { phase: 'COMBAT', round: 9, players: [{ playerId: 'p_1', name: '阿米娅' }, { playerId: 'p_2', name: 'Kal' }] },
battle: { kind: 'unite', fieldId: 'u', authoritative: true, done: false, speed: 2 },
},
};
const env = { ua: 'Mozilla/5.0 Test', gpu: 'ANGLE (Test GPU)', viewport: '844x390', dpr: 2, touch: true, cores: 8, lang: 'zh-CN', uiLang: 'zh' };
test('version, time, browser, device, connection, where the player is, the battle, the settings and the errors', () => {
recordError('request', { code: 'ROOM_FULL', msg: '房间已满' }, null, Date.UTC(2026, 9, 8, 12, 0, 5));
recordError('sim', '[sim] onAttack (char_391_rosmon) failed: TypeError: x', null, Date.UTC(2026, 9, 8, 12, 0, 9));
recordError('sim', '[sim] onAttack (char_391_rosmon) failed: TypeError: x', null, Date.UTC(2026, 9, 8, 12, 0, 12));
const text = buildDiagnostics({ state, env, settings: { quality: 'low', damageNumbers: true, muted: false },
now: Date.UTC(2026, 9, 8, 12, 1, 0), pageStart: Date.UTC(2026, 9, 8, 11, 48, 30) });
const lines = text.split('\n');
assert.equal(lines[0], '```text');
assert.equal(lines[1], 'Stronghold-Protocol diagnostics');
assert.equal(lines[2], `version: ${APP_VERSION}`);
assert.equal(lines[3], 'time: 2026-10-08T12:01:00.000Z | page open 12m 30s');
assert.ok(lines.includes('browser: Mozilla/5.0 Test'));
assert.ok(lines.includes('gpu: ANGLE (Test GPU)'));
assert.ok(lines.includes('device: 844x390 | @2x | touch | 8 threads | lang zh-CN | ui zh'));
assert.ok(lines.includes('connection: online | ping 42 ms'));
assert.ok(lines.includes('where: phase COMBAT | round 9 | mode coop | difficulty HARD | players 2'));
assert.ok(lines.includes('battle: unite | field u | authoritative | running | 2x'));
assert.ok(lines.includes('settings: quality low | damage numbers'));
assert.ok(lines.includes('errors: 3 recorded'));
assert.ok(lines.includes('#1 12:00:09 (x2, last 12:00:12) sim: [sim] onAttack (char_391_rosmon) failed: TypeError: x'));
assert.ok(lines.includes('#2 12:00:05 request: 房间已满 (ROOM_FULL)'));
assert.equal(lines.at(-1), '```');
assert.ok(!text.includes('```json'), 'no battle attached');
});
test('player names, the room code and the reconnect token never reach the text, wherever they appear', () => {
recordError('request', '阿米娅 left room K7Q2 (token tok-secret-123)', 'Kaltsit and Kal and W');
const text = buildDiagnostics({ state, env });
for (const secret of ['阿米娅', 'Kaltsit', 'Kal ', 'K7Q2', 'tok-secret-123']) assert.ok(!text.includes(secret), secret);
assert.match(text, /request: <me> left room <room> \(token <token>\)/);
// the longer name is replaced first, so 'Kaltsit' does not become '<player2>tsit'; the one-letter 'W' as a word
assert.ok(text.includes('\n <player3> and <player2> and <player4>\n'));
assert.deepEqual(secretsOf(state).map(([, p]) => p), ['<me>', '<player2>', '<player3>', '<player4>', '<room>', '<token>']);
assert.equal(redact('ab a cab W-W Windows', [['a', 'X'], ['ab', 'Y'], ['W', 'V'], ['', 'Z']]), 'Y X cab V-V Windows',
'every value, short or long, is a whole token');
assert.equal(redact('player and players', [['player', '<p1>'], ['Kaltsit', '<player2>']]), '<p1> and players',
'a longer word is not cut');
assert.equal(redact('a.b a+b', [['a.b', 'R']]), 'R a+b', 'regex characters in a value are literal');
assert.equal(redact('char chess_char_1013_chen2', [['char', '<me>']]), '<me> chess_char_1013_chen2',
'a name does not eat a chess id');
});
test('a name like char replaces a whole token and an exact JSON string, never a longer id', () => {
const named = {
...state,
me: { ...state.me, name: 'char' },
room: { ...state.room, seats: [{ name: 'char' }, { name: 'Ship' }], spectators: [] },
match: { ...state.match, public: { ...state.match.public, players: [{ playerId: 'p_1', name: 'char' }, { playerId: 'p_2', name: 'Ship' }] } },
};
recordError('sim', 'char failed on chess_char_1013_chen2', 'yanShip stayed');
const spec = {
v: 1,
units: [{ id: 'chess_char_1013_chen2', key: 'char', note: 'charge the char', ship: 'yanShip' }],
name: 'char',
tag: 'Ship',
};
const text = buildDiagnostics({
state: named, env, battle: { battleId: 'k1', fieldId: 'u', kind: 'unite', spec, time: 1 },
});
assert.match(text, /sim: <me> failed on chess_char_1013_chen2/);
assert.ok(text.includes('yanShip stayed'), 'Ship does not match inside yanShip');
const json = JSON.parse(text.slice(text.indexOf('```json\n') + 8, text.lastIndexOf('\n```')));
assert.equal(json.spec.units[0].id, 'chess_char_1013_chen2');
assert.equal(json.spec.units[0].key, '<me>');
assert.equal(json.spec.units[0].note, 'charge the char');
assert.equal(json.spec.units[0].ship, 'yanShip');
assert.equal(json.spec.name, '<me>');
assert.equal(json.spec.tag, '<player2>');
assert.equal(JSON.stringify(json).includes('"char"'), false);
assert.deepEqual(redactValues(
{ id: 'chess_char_1013_chen2', char: 'keep', name: 'char', note: 'charge the char', list: ['char', 'chess_char_1013_chen2'] },
[['char', '<me>']],
), { id: 'chess_char_1013_chen2', '<me>': 'keep', name: '<me>', note: 'charge the char', list: ['<me>', 'chess_char_1013_chen2'] });
});
test('the battle on screen is attached as a b.start the dev tools replay (player ids, no names)', () => {
const spec = { v: 1, battleId: 'k1-1.9.40.u', fieldId: 'u', kind: 'unite', seed: 7, round: 9, players: [{ playerId: 'p_1' }, { playerId: 'p_2' }] };
const text = buildDiagnostics({ state, env, battle: { battleId: spec.battleId, fieldId: 'u', kind: 'unite', spec, time: 12.4 } });
assert.ok(text.includes('\n\nbattle k1-1.9.40.u | unite | round 9 | copied at 12.40 s\n```json\n'));
const json = JSON.parse(text.slice(text.indexOf('```json\n') + 8, text.lastIndexOf('\n```')));
assert.deepEqual(json, { t: 'b.start', battleId: spec.battleId, fieldId: 'u', kind: 'unite', spec });
});
test('an empty page: the title screen, nothing recorded, unknown fields left out', () => {
const text = buildDiagnostics({ state: {}, now: 0, pageStart: 0 });
assert.match(text, /\nwhere: title\n/);
assert.match(text, /\nconnection: unknown\n/);
assert.match(text, /\ndevice: unknown\n/);
assert.match(text, /\nerrors: none recorded\n```$/);
assert.ok(!/browser:|gpu:|battle:|settings:/.test(text));
});
test('the battle source: none, a battle, and a source that throws', () => {
setBattleSource(null);
assert.equal(currentBattle(), null);
setBattleSource(() => ({ battleId: 'b', spec: {} }));
assert.deepEqual(currentBattle(), { battleId: 'b', spec: {} });
setBattleSource(() => { throw new Error('gone'); });
assert.equal(currentBattle(), null);
setBattleSource(null);
});
});
+2 -2
View File
@@ -35,11 +35,11 @@ test('slots: two per tier (5, 6), each with its elite twin', () => {
assert.equal(diyTokenOwner(SIEGE, chess.chess_char_6_diy1_b.status), 'char_112_siege@2/60/7/3');
});
test('diyPool: tier 5 = 15 prototypes + 71 owned 6★, tier 6 = 9 + 71; no preset, no collab; with the kit registry only kitted operators', () => {
test('diyPool: tier 5 = 15 prototypes + 72 owned 6★, tier 6 = 9 + 72; no preset, no collab; with the kit registry only kitted operators', () => {
const p5 = diyPool(5, { data: DATA }), p6 = diyPool(6, { data: DATA });
assert.deepEqual(p5, [...RESERVES5, ...ELITES, ...ownedPool]);
assert.deepEqual(p6, [...ELITES, ...ownedPool]);
assert.deepEqual([p5.length, p6.length], [86, 80]);
assert.deepEqual([p5.length, p6.length], [87, 81]);
const roster = new Set(Object.values(chess).map((c) => c.charId).filter(Boolean));
for (const id of p5) assert.ok(!roster.has(id) && !COLLAB.includes(id), id);
assert.deepEqual(diyPool(4, { data: DATA }), [], 'no 自选 slot at tier 4');
+26 -12
View File
@@ -8,7 +8,8 @@
// local art, 标准 = 战场#01 only; user playtest #3 (DESIGN §17): temp overflow kept until the first prep its player can
// act in (never wiped at the round start), the 回环射手 boomerang and 蕾缪安's shells one by one, the live LP, the detail
// card order and the static game data; user playtest #4 (DESIGN §18): picking by the tile under the pointer and the
// dragged model held under it, a single human untimed, the strategy draft's one countdown, 机变 two taps, knocked-out
// dragged model held under it (standing on that tile while it is a legal target, §27.62), a single human untimed, the
// strategy draft's one countdown, 机变 two taps, knocked-out
// operators and the official element gauges, live stats, the shop-only items, skill summons, 炎佑; user playtest #5
// (DESIGN §19): blocking by contact radius, 联防 forced exit, huge-boss hit areas and 自缚, the element pipeline rules,
// boss-field deployment, the phone prep camera — and the normative §3 / §5.1 / §5.5 / §6.1 / §7 lines that changed; user
@@ -22,7 +23,7 @@
// the 突变细胞 bench rule (§21.1: the carrier destroyed, its new operator gained into the 整备区 — official footage, PR #2),
// the closing additions §21.26–§21.28 (GitHub issues #1 / #5 / #8), the owner's deliberate trigger deviation for six
// 重装 skills (§21.29, GitHub issue #4 / PR #12) and the operator battle voice the user asked for the same day (§21.30,
// battle only — the 休整期 is silent).
// battle only — since 0.2.2 a tap on an operator says 选中干员 in every phase).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, readdirSync } from 'node:fs';
@@ -294,11 +295,15 @@ test('lost models, live LP, detail card order, static game data (user playtest #
});
test('user playtest #4 (DESIGN §18): picking by tile, timers, 机变 two taps, down / element state, content — code and every doc agree', () => {
// #1 the tile under the pointer; the dragged model held under the pointer (no touch lift, probe or body shapes)
// #1 the tile under the pointer; the dragged model held under the pointer (no touch lift, probe or body shapes) —
// standing on the pointer's tile while that is a legal target (§27.62, the official deploy drag)
assert.equal(ENEMY_REACH, 0.6);
const app = readFileSync(join(ROOT, 'public/js/render/app.js'), 'utf8');
const tune = readFileSync(join(ROOT, 'public/js/render/app/tune.js'), 'utf8');
assert.match(tune, /export const DRAG_HOLD_TILES = 0\.45;/);
assert.match(app, /dragStandTile\(p\)/);
assert.match(DESIGN, /`dragStandTile/);
assert.match(PLAYING, /干员模型直接站在这一格上/);
assert.ok(!/TOUCH_LIFT_TILES|drawnAt|pickShape|pieceDragOver/.test(app), 'no touch lift, pixel probe or body shapes (user playtest #4 item 1)');
assert.match(DESIGN, /`DRAG_HOLD_TILES` = 0\.45 tile/);
assert.match(DESIGN, /`ENEMY_REACH` 0\.6 tile/);
@@ -318,11 +323,11 @@ test('user playtest #4 (DESIGN §18): picking by tile, timers, 机变 two taps,
assert.match(META, /solo \/ single human untimed/);
assert.match(PLAYING, /只有你一名玩家/);
// #4 one countdown: BAND_TURN_SECONDS per turn (code = data = docs)
assert.equal(BAND_TURN_SECONDS, 30);
assert.equal(BAND_TURN_SECONDS, 50);
assert.equal(DATA.config.timers.bandTurn, BAND_TURN_SECONDS);
assert.match(DESIGN, /`BAND_TURN_SECONDS` 30 s per turn = m\.public\.deadline/);
assert.match(META, /`Match\.BAND_TURN_SECONDS` 30/);
assert.match(PLAYING, /\*\*每人 30 秒\*\*/);
assert.match(DESIGN, /`BAND_TURN_SECONDS` 50 s per turn = m\.public\.deadline/);
assert.match(META, /`Match\.BAND_TURN_SECONDS` 50/);
assert.match(PLAYING, /\*\*每人 50 秒\*\*/);
assert.ok(!/12 s\/turn/.test(DESIGN) && !/`bandTurn` 12/.test(META) && !/每人 12 秒/.test(PLAYING), 'the old 12 s turn is gone');
assert.match(META, /`ev\.preview` true/, 'META: onBattleStart handlers must not change the match for the stats preview');
// #2 机变 two taps
@@ -490,7 +495,8 @@ test('user playtest #6 (DESIGN §20): summons, skill triggers, blocking, push fo
assert.match(sec(17), /\*\*revised by §20\.6\*\*/);
assert.ok(!/the own battle's count stays on show as an upper bound tagged 联防中/.test(DESIGN), '§17.5: the frozen 联防 count is gone');
assert.match(sec(8), /uniteLeft\? \/\* 联防: the leaker's enemies still standing/);
assert.match(sec(14), /`b\.progress \{ battleId, gt, killed, total, leaks\?, left\?, bossDmg\?, by\?, done\? \}`/);
assert.match(sec(14), /`b\.progress \{ battleId, gt, killed, total, resolved\?, leaks\?, left\?, bossDmg\?, by\?, leaksBy\?, done\? \}`/);
assert.match(doc('shared/protocol.js'), /leaksBy: \(v\) => isMap\(v, RESULT_LIMITS\.players, isId, \(x\) => isNum\(x, 0, 1e6\)\)/);
assert.match(sec(6), /3 \*\*different\*\* free chess of tier `min\(level\+1, 6\)`/);
assert.match(S20, /105 cards: 56 next-battle incl\. 源石虫·特训, 42 two-battle, 7 multi-round/);
// #11 / #9 / #13: the gauge look (§18.3 / §19.5 superseded, §8.2 fill), model scale and fear (§9, §5.3, §2)
@@ -868,7 +874,7 @@ test('the deliberate trigger deviation (DESIGN §21.29): six 重装 skills DEFAU
assert.match(doc('CHANGELOG.md'), /深巡、雷蛇的二技能,号角的二、三技能,灰毫的一、二技能改为攻击范围内有敌人时就释放/);
});
test('干员战斗语音 (DESIGN §21.30): the manifest data, the official priorities, and the 休整期 stays silent', async () => {
test('干员战斗语音 (DESIGN §21.30): the manifest data, the official priorities, and only a tap\'s 选中干员 speaks outside battle', async () => {
const { VOICE_PRIORITY, VOICE_COOLDOWN_MS, resultVoiceSlot } = await import('../public/js/audio.js');
const manifest = JSON.parse(readFileSync(join(ROOT, 'data/assets.json'), 'utf8'));
const voice = manifest.audio?.voice ?? {};
@@ -902,22 +908,30 @@ test('干员战斗语音 (DESIGN §21.30): the manifest data, the official prior
assert.equal(VOICE_PRIORITY.select, 10);
assert.equal(VOICE_COOLDOWN_MS.skill1, 10000);
assert.equal(VOICE_COOLDOWN_MS.faceEnemy, 3000);
assert.equal(VOICE_COOLDOWN_MS.select, 1500);
assert.equal(VOICE_COOLDOWN_MS.select, 0, 'official FOCUS_CHAR cooldown 0 (1.5 s until 0.2.2)');
assert.equal(resultVoiceSlot({ perfect: true }), 'resultThree');
// the docs
assert.match(DESIGN, /### 21\.30 /);
assert.match(DESIGN, /\*\*Where each line plays — battle only\.\*\*/);
assert.match(doc('docs/ASSETS.md'), /\| 干员战斗语音 \|/);
assert.match(doc('docs/ASSETS.md'), /voiceChars/);
// 0.2.2: the Japanese dub beside the Chinese one (audio.voiceJp, settings 语音语言, the full zip's switch)
assert.equal(Object.keys(manifest.audio?.voiceJp ?? {}).length, charIds.length, 'every voiced operator has its JP tree');
assert.equal(manifest.stats.voiceJpChars, charIds.length);
assert.match(doc('docs/ASSETS.md'), /voiceJp: \{ \[charId\]: \{ …the slots of `voice` \} \}/);
assert.match(doc('docs/DEPLOY.md'), /`FULL_ZIP_JP_VOICE` 改成 `false` 时/);
assert.match(PLAYING, /「语音语言」选 \*\*中文 \/ 日本語\*\*(默认中文,和界面语言无关/);
assert.match(PLAYING, /会说一句官方的「选中干员」语音,休整期也一样/);
assert.match(SIM, /\['engage', id\]/);
// the code: every slot the client asks for comes from a running battle's own stream — the three prep-only lines
// (干员报到 / 编入队伍 / 任命队长) are never requested, and 选中干员 sits behind the panel's combat flag
// (干员报到 / 编入队伍 / 任命队长) are never requested — except 选中干员, the detail panel's line on every tap of an
// operator, in every phase (0.2.2: the owner's request of 2026-10-08)
const panel = doc('public/js/ui/detailPanel.js');
const game = doc('public/js/screens/game.js');
for (const [name, src] of [['audio.js', doc('public/js/audio.js')], ['game.js', game], ['detailPanel.js', panel]]) {
assert.ok(!/voice\([^)]*'(gacha|squad|squadFirst)'/.test(src), `${name}: no prep slot is played`);
}
assert.match(panel, /const selectKey = voice && detail\?\.type === 'chess'/);
assert.match(game, /voice=\$\{combat\}/);
assert.match(game, /voice=\$\{true\}/);
assert.match(game, /audio\.voice\(charId, resultVoiceSlot\(/);
});
+51
View File
@@ -0,0 +1,51 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
import { once } from 'node:events';
import { Client } from './client.mjs';
test('closing an exited browser releases stderr even when a helper inherited its pipe', { timeout: 5000 }, async () => {
const child = spawn(process.execPath, ['-e', `
const { spawn } = require('node:child_process');
const helper = spawn(process.execPath, ['-e', 'setTimeout(() => {}, 5000)'],
{ stdio: ['ignore', 'ignore', 2], detached: true });
helper.unref();
process.send({ pid: helper.pid }, () => process.exit(0));
`], { stdio: ['ignore', 'pipe', 'pipe', 'ipc'] });
const exited = once(child, 'exit');
let helperPid;
try {
[{ pid: helperPid }] = await once(child, 'message');
child.stderr.resume();
await exited;
assert.equal(child.exitCode, 0);
assert.equal(child.stderr.destroyed, false, 'the helper still holds the other end of stderr');
const client = new Client(null, '', 'cleanup');
client.browser = { process: () => child, close: async () => {} };
await client.close();
assert.equal(child.stderr.destroyed, true, 'the exited browser no longer keeps this test alive');
} finally {
if (helperPid) { try { process.kill(helperPid, 'SIGTERM'); } catch { /* already exited */ } }
for (const stream of child.stdio) stream?.destroy?.();
if (child.exitCode == null && child.signalCode == null) child.kill();
}
});
test('a failed shutdown leaves a live browser and its pipes alone', async () => {
let destroyed = 0;
const child = { exitCode: null, signalCode: null, stdio: [{ destroy: () => destroyed++ }] };
const client = new Client(null, '', 'cleanup');
client.browser = { process: () => child, close: async () => { throw new Error('not closed'); } };
await client.close();
assert.equal(destroyed, 0);
});
test('a browser terminated by a signal releases only its owned pipes; an absent browser is harmless', async () => {
let destroyed = 0;
const client = new Client(null, '', 'cleanup');
await client.close();
client.browser = { process: () => ({ exitCode: null, signalCode: 'SIGTERM', stdio: [null, { destroy: () => destroyed++ }] }),
close: async () => {} };
await client.close();
assert.equal(destroyed, 1);
});
+13 -3
View File
@@ -61,10 +61,11 @@ export function freePort() {
* `port`: e.g. the port of a server just stopped — a server restart the open clients reconnect to).
* @param {{ port?: number, fast?: { timerScale?: number, combatSpeed?: number, startRound?: 'boss'|'hidden'|number, kit?: number, chess?: string[],
* items?: string[], idleBots?: boolean, kits?: string[][], botChess?: string[], autoPlace?: boolean, eliminate?: number[], stage?: string,
* finishAfter?: number } }} [opts]
* finishAfter?: number, slowSpawns?: string } }} [opts]
* kits: per-human starter kits (seat order); botChess: the AI seats' kit; autoPlace: the kits go onto the board at the
* first prep; eliminate: humans (seat order) eliminated at the jump; stage: the stage of every match; finishAfter: the
* match ends (RESULT) once that round has settled — fastServer.mjs hooks
* match ends (RESULT) once that round has settled; slowSpawns: '<humanIdx>:<factor>' — that human's normal battles
* spawn at factor × the scheduled times — fastServer.mjs hooks
* @returns {Promise<{ base: string, port: number, logs: string[], stop: (o?: { hard?: boolean }) => Promise<void> }>}
*/
export async function startRealServer(opts = {}) {
@@ -90,6 +91,7 @@ export async function startRealServer(opts = {}) {
if (opts.fast.stage) env.SP_STAGE = String(opts.fast.stage);
if (opts.fast.level) env.SP_START_LEVEL = String(opts.fast.level);
if (opts.fast.finishAfter) env.SP_FINISH_AFTER = String(opts.fast.finishAfter);
if (opts.fast.slowSpawns) env.SP_SLOW_SPAWNS = String(opts.fast.slowSpawns);
}
const child = spawn(process.execPath, [entry], { cwd: ROOT, env, stdio: ['ignore', 'pipe', 'pipe'] });
const logs = [];
@@ -187,7 +189,15 @@ export class Client {
throw new Error(`${this.label}: timed out waiting for ${what} (last: ${JSON.stringify(last)})`);
}
async close() { await this.browser?.close().catch(() => {}); }
async close() {
const child = this.browser?.process();
await this.browser?.close().catch(() => {});
// Chrome's crash/update helpers can inherit stderr after the launched browser exits. Release only our
// exited child's pipes; otherwise readline keeps the test alive after all its assertions have finished.
if (child && (child.exitCode != null || child.signalCode != null)) {
for (const stream of child.stdio) stream?.destroy?.();
}
}
/** Real mouse click on the `nth` visible, uncovered element matching `sel` whose text includes `text`. */
async click(sel, text = null, { timeout = 15000, optional = false, nth = 0, button = 'left', any = false } = {}) {
+3 -1
View File
@@ -56,7 +56,9 @@ async function checkHud(c, where) {
const s = globalThis.__SP__.store.get();
const pub = s.match.public; const priv = s.match.private;
const txt = (sel) => document.querySelector(sel)?.textContent?.trim() ?? null;
const shopChess = (priv?.shop?.slots || []).filter((x) => x && x.kind !== 'item').length;
// a chess slot is any slot but the item's: a slot a 调度中心 upgrade opened stays null until the next roll and shows an
// empty card (DESIGN §27.52, ui/shopBar.js chessSlots)
const shopChess = (priv?.shop?.slots || []).filter((x) => !x || x.kind !== 'item').length;
return {
phase: pub?.phase, round: pub?.round, lastRound: pub?.lastRound, players: (pub?.players || []).length,
lp: priv?.lp, teamLp: pub?.teamLp, funds: priv?.funds, cap: priv?.deployCap, count: priv?.deployCount,
+18
View File
@@ -26,6 +26,9 @@
// SP_START_SHOP stocks its first slots) — test/ui/diy.e2e.test.js: a tier-5 自选 piece is sold from level 5.
// SP_FINISH_AFTER=<round>: the match ends (a defeat: m.result, the RESULT screen) once that round has settled, instead
// of going on to the next round — test/ui/standin.e2e.test.js: the result lineup of the board just fought with.
// SP_SLOW_SPAWNS=<humanIdx>:<factor>: that human's (seat order) normal battles spawn their enemies at <factor> × the
// scheduled times (and intervals) — a leaker's battle then runs to the round's time limit, long after a teammate's quick
// one (test/ui/watch-bonds.e2e.test.js, "after the own battle": a window for watching and a reload).
// Not a test file (node --test runs it as a no-op module when NODE_TEST_CONTEXT is set).
import { startServer } from '../../server/index.js';
@@ -52,6 +55,8 @@ if (!process.env.NODE_TEST_CONTEXT) {
const forcedStage = String(process.env.SP_STAGE || '').trim();
const startLevel = Math.max(0, Math.min(6, Number(process.env.SP_START_LEVEL) || 0));
const finishAfter = Math.max(0, Number(process.env.SP_FINISH_AFTER) || 0);
const [slowIdx, slowFactor] = String(process.env.SP_SLOW_SPAWNS || '').split(':').map(Number);
const slowSpawns = Number.isInteger(slowIdx) && slowIdx >= 0 && slowFactor > 0 ? { idx: slowIdx, factor: slowFactor } : null;
class FastMatch extends Match {
constructor(opts) {
@@ -127,6 +132,19 @@ if (!process.env.NODE_TEST_CONTEXT) {
super.afterSettle();
}
_ccField(args) {
const f = super._ccField(args);
const ps = slowSpawns && f.kind === 'normal' ? this.order.filter((x) => !x.isBot)[slowSpawns.idx] : null;
if (ps && f.players.includes(ps.playerId)) {
for (const x of f.spec.spawns) {
if (!x || !Number.isFinite(Number(x.time))) continue;
x.time = Number(x.time) * slowSpawns.factor;
if (Number(x.interval) > 0) x.interval = Number(x.interval) * slowSpawns.factor;
}
}
return f;
}
scheduleBotPrep(ps, i = 0) {
if (!idleBots) { super.scheduleBotPrep(ps, i); return; }
const round = this.round;
+119
View File
@@ -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`);
}
}
});
+103
View 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`);
});
+176
View File
@@ -0,0 +1,176 @@
// test/feedback7-voices.test.js — the Japanese voice dub beside the Chinese one (0.2.2; the owner's request of 2026-10-08
// 「全套的日配语音」): tools/assets/plan.mjs plans `audio.voiceJp` — the same slots and file names from the
// ArknightsAssets2 `voice/` folder (the JP dub), under audio/voice/jp/ — the committed data/assets.json lists it for every
// operator the Chinese tree has (checked against public/assets when present), the settings carry 语音语言 (中文 by
// default, the four language packs translate the row), and tools/package.mjs FULL_ZIP_JP_VOICE decides whether the full
// zip ships the JP files (default: yes) — held back, they are neither missing nor deleted by an update.
// The client's choice of line and its fallback: test/ui/audio.test.js (voiceLine).
// Run: node --test test/feedback7-voices.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { buildPlan, VOICE_JP_LANG } from '../tools/assets/plan.mjs';
import { indexAudio, VOICE_DIRS } from '../tools/assets/audio.mjs';
import { RAW } from '../tools/assets/sources.mjs';
import { artPlan, FULL_ZIP_JP_VOICE, JP_VOICE_DIR } from '../tools/package.mjs';
import { DEFAULT_SETTINGS, VOICE_LANGS, sanitizeSettings } from '../public/js/ui/gameLogic.js';
const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
const readJson = (rel) => JSON.parse(fs.readFileSync(path.join(ROOT, rel), 'utf8'));
/** A charword_table.json with two operators: one the game fields (planned), one it does not (a 预备干员: no voice). */
const CHARWORD = {
charWords: {
a: { charId: 'char_263_skadi', wordKey: 'char_263_skadi', placeType: 'BATTLE_SELECT', voiceId: 'CN_021', voiceIndex: 21, voiceAsset: 'char_263_skadi/CN_021' },
b: { charId: 'char_263_skadi', wordKey: 'char_263_skadi', placeType: 'BATTLE_SELECT', voiceId: 'CN_022', voiceIndex: 22, voiceAsset: 'char_263_skadi/CN_022' },
c: { charId: 'char_263_skadi', wordKey: 'char_263_skadi', placeType: 'BATTLE_START', voiceId: 'CN_019', voiceIndex: 19, voiceAsset: 'char_263_skadi/CN_019' },
d: { charId: 'char_263_skadi', wordKey: 'char_263_skadi', placeType: 'GACHA', voiceId: 'CN_011', voiceIndex: 11, voiceAsset: 'char_263_skadi/CN_011' },
e: { charId: 'char_602_cdfend', wordKey: 'char_602_cdfend', placeType: 'BATTLE_SELECT', voiceId: 'CN_021', voiceIndex: 21, voiceAsset: 'char_602_cdfend/CN_021' },
},
};
const OPS = { char_263_skadi: { name: '斯卡蒂', skills: [] } };
const plan = (extra = {}) => buildPlan({ assets07: {}, ops03: {}, enemies05: {}, maps05: {}, audio: indexAudio({}), modelsData: {},
charword: CHARWORD, extraOperators: OPS, ...extra }).template;
test('plan: audio.voiceJp is the JP dub (ArknightsAssets2 voice/) of the very slots and file names of audio.voice, under audio/voice/jp/', () => {
assert.equal(VOICE_JP_LANG, 'jp');
assert.equal(VOICE_DIRS.jp, 'voice', 'the dump folder of the JP dub');
const t = plan();
const cn = t.audio.voice.char_263_skadi;
const jp = t.audio.voiceJp.char_263_skadi;
assert.deepEqual(Object.keys(jp).sort(), ['select', 'start'], 'the battle slots only, like the Chinese tree (干员报到 not planned)');
assert.deepEqual(Object.keys(jp).sort(), Object.keys(cn).sort());
assert.equal(jp.select.length, 2, 'one leaf per line: 选中干员1 / 2');
assert.deepEqual(jp.select.map((l) => l.alts[0].rel), ['audio/voice/jp/char_263_skadi/cn_021.mp3', 'audio/voice/jp/char_263_skadi/cn_022.mp3']);
assert.deepEqual(jp.select[0].alts[0].urls, [`${RAW.aa2voice}voice/char_263_skadi/cn_021.mp3`]);
assert.deepEqual(cn.select.map((l) => l.alts[0].rel), ['audio/voice/cn/char_263_skadi/cn_021.mp3', 'audio/voice/cn/char_263_skadi/cn_022.mp3']);
assert.deepEqual(cn.select[0].alts[0].urls, [`${RAW.aa2voice}voice_cn/char_263_skadi/cn_021.mp3`]);
assert.equal(jp.start.alts[0].rel, 'audio/voice/jp/char_263_skadi/cn_019.mp3');
assert.equal(t.audio.voiceJp.char_602_cdfend, undefined, 'an operator the game does not field (a stand-in) gets neither tree');
assert.equal(t.audio.voice.char_602_cdfend, undefined);
assert.deepEqual(Object.keys(t.audio), ['bgm', 'bossBgm', 'voice', 'voiceJp', 'sfx'], 'voiceJp right after voice');
// --voice-lang puts another dub in audio.voice; voiceJp stays the JP one; voiceJp: false plans no JP tree
const en = plan({ voiceLang: 'en' });
assert.equal(en.audio.voice.char_263_skadi.start.alts[0].rel, 'audio/voice/en/char_263_skadi/cn_019.mp3');
assert.equal(en.audio.voiceJp.char_263_skadi.start.alts[0].rel, 'audio/voice/jp/char_263_skadi/cn_019.mp3');
assert.equal(plan({ voiceJp: false }).audio.voiceJp, undefined);
// --voice-all widens both trees alike
assert.ok(plan({ voiceSlots: null }).audio.voiceJp.char_263_skadi.gacha);
});
test('data/assets.json: voiceJp gives every voiced operator the JP twin of each Chinese line (192 operators, 2688 files); stand-ins, 盟约·辅助干员 and summons have neither', () => {
const m = readJson('data/assets.json');
const cn = m.audio.voice;
const jp = m.audio.voiceJp;
assert.ok(jp && typeof jp === 'object', 'the manifest carries the JP tree');
assert.deepEqual(Object.keys(jp), Object.keys(cn), 'the same operators');
assert.equal(m.stats.voiceJpChars, Object.keys(jp).length);
assert.equal(m.stats.voiceChars, Object.keys(cn).length);
assert.equal(Object.keys(jp).length, 192);
// the JP tree is the Chinese one with the folder swapped: same slots, same lines in the same order, same file names
assert.deepEqual(JSON.parse(JSON.stringify(cn).replaceAll('/assets/audio/voice/cn/', '/assets/audio/voice/jp/')), jp);
const lines = [];
const walk = (x) => { if (typeof x === 'string') lines.push(x); else if (Array.isArray(x)) x.forEach(walk); else if (x && typeof x === 'object') Object.values(x).forEach(walk); };
walk(jp);
assert.equal(lines.length, 2688);
for (const u of lines) assert.match(u, /^\/assets\/audio\/voice\/jp\/char_[^/]+\/cn_\d+\.mp3$/);
// no voice: the 17 stand-ins of data/backups.json (预备干员 / 原型干员), the mode's 盟约·辅助干员, every summon
const backups = readJson('data/backups.json');
const standIns = Object.entries(backups.units).filter(([, u]) => Array.isArray(u.standsIn) ? u.standsIn.length : u.standsIn).map(([id]) => id);
assert.equal(standIns.length, 17);
for (const id of [...standIns, 'char_616_pithst', ...Object.keys(readJson('data/tokens.json'))]) {
assert.equal(jp[id], undefined, `${id}: no JP line`);
assert.equal(cn[id], undefined, `${id}: no line`);
}
// the files themselves, where this checkout has downloaded the art
const dir = path.join(ROOT, 'public', 'assets', 'audio', 'voice', 'jp');
if (fs.existsSync(dir)) for (const u of lines) assert.ok(fs.statSync(path.join(ROOT, 'public', u)).size > 0, u);
});
test('settings 语音语言: 中文 by default, 日本語 kept, nothing else; the row is translated in every language pack', () => {
assert.deepEqual([...VOICE_LANGS], ['cn', 'jp']);
assert.equal(DEFAULT_SETTINGS.voiceLang, 'cn', 'not tied to the interface language: 中文 until the player picks 日本語');
assert.equal(sanitizeSettings({}).voiceLang, 'cn');
assert.equal(sanitizeSettings({ voiceLang: 'jp' }).voiceLang, 'jp');
assert.equal(sanitizeSettings({ voiceLang: 'en' }).voiceLang, 'cn', 'no English / Korean dub');
const ui = fs.readFileSync(path.join(ROOT, 'public/js/ui/settings.js'), 'utf8');
assert.match(ui, /updateSettings\(\{ voiceLang: id \}\)/);
assert.match(ui, /const VOICE_LANG_NAMES = \{ cn: '中文', jp: '日本語' \};/, 'each dub named in its own language');
for (const code of ['en', 'ja', 'ko', 'zh-TW']) {
const pack = readJson(`public/i18n/${code}.json`);
assert.ok(typeof pack['语音语言'] === 'string' && pack['语音语言'] && pack['语音语言'] !== '语音语言', `${code}: 语音语言`);
}
});
test('package: the full zip ships the JP dub by default; FULL_ZIP_JP_VOICE off holds it back (not missing, not an orphan, never removed by an update); the lite zip has no art', () => {
assert.equal(FULL_ZIP_JP_VOICE, true, '0.2.2 ships both dubs in the full zip');
assert.equal(JP_VOICE_DIR, 'public/assets/audio/voice/jp/');
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sp-voicejp-'));
try {
const cnRel = 'public/assets/audio/voice/cn/char_263_skadi/cn_021.mp3';
const jpRel = 'public/assets/audio/voice/jp/char_263_skadi/cn_021.mp3';
const jpRel2 = 'public/assets/audio/voice/jp/char_263_skadi/cn_022.mp3';
for (const rel of [cnRel, jpRel]) { fs.mkdirSync(path.dirname(path.join(dir, rel)), { recursive: true }); fs.writeFileSync(path.join(dir, rel), 'ID3'); }
fs.mkdirSync(path.join(dir, 'data'));
const url = (rel) => `/${rel.slice('public/'.length)}`;
fs.writeFileSync(path.join(dir, 'data', 'assets.json'), JSON.stringify({ audio: {
voice: { char_263_skadi: { select: url(cnRel) } }, voiceJp: { char_263_skadi: { select: [url(jpRel), url(jpRel2)] } } } }));
const on = artPlan(dir);
assert.ok(on.files.includes(cnRel) && on.files.includes(jpRel), 'both dubs ship');
assert.deepEqual(on.missing, [jpRel2], 'a listed JP file not on disk is missing, like any art');
assert.deepEqual(on.held, []);
const off = artPlan(dir, { jpVoice: false });
assert.ok(off.files.includes(cnRel), 'the Chinese dub ships');
assert.ok(!off.files.includes(jpRel), 'the JP files stay out of the full zip');
assert.deepEqual(off.held, [jpRel, jpRel2], 'held back on purpose (setup downloads them; an update never deletes them)');
assert.deepEqual(off.missing, [], 'not missing');
assert.deepEqual(off.orphans, [], 'not orphans either');
assert.deepEqual(artPlan(dir, { lite: true }).files, [], 'the lite zip carries no art');
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
// the update zip keeps a held file out of `removed` (tools/package.mjs buildUpdate)
const src = fs.readFileSync(path.join(ROOT, 'tools/package.mjs'), 'utf8');
assert.match(src, /removable: \(rel\) => !removalProblem\(rel\) && !heldBack\.has\(rel\)/);
});
test('选中干员 on every card tap (review of fb7-voices): two shop / reward cards of one operator are two taps, a re-render is none', async () => {
// shop / reward cards hand only the chess id to the detail target (no piece, no unit): game.js numbers every card it opens
// (`tap`), resolveDetail keeps it, and the panel's 选中 key (selectVoiceKey) includes it — the second 斯卡蒂 card spoke
// nothing before, nor replaced the first card's line (the pool deals duplicates)
const origFetch = globalThis.fetch;
globalThis.fetch = async (url) => {
const name = String(url).split('/').pop();
let body;
try { body = readJson(`data/${name}`); } catch { return { ok: false, status: 404, json: async () => ({}) }; }
return { ok: true, status: 200, json: async () => body };
};
try {
const { resolveDetail, selectVoiceKey } = await import('../public/js/ui/detailPanel.js');
const { data } = await import('../public/js/data.js');
await data.loadAll('chess', 'backups');
const id = Object.values(readJson('data/chess.json')).find((c) => c.charId === 'char_263_skadi' && !c.isGolden).chessId;
const card = (tap) => resolveDetail({ kind: 'chess', id, hint: null, tap }, new Map(), { priv: null });
const a = card(1), b = card(2);
assert.equal(a.chess.charId, 'char_263_skadi');
assert.deepEqual([a.tap, b.tap], [1, 2], 'the tap rides along');
assert.notEqual(selectVoiceKey(a), selectVoiceKey(b), 'the second card of the same operator is a new opening');
assert.equal(selectVoiceKey(card(1)), selectVoiceKey(a), 'the same tap resolved again (a re-render) says nothing more');
assert.equal(resolveDetail({ kind: 'chess', id }, new Map(), { priv: null }).tap, undefined, 'no tap, no field');
// pieces and battle units keep their own identity; a non-operator card has no key
assert.equal(selectVoiceKey({ type: 'chess', chess: { chessId: 'x' }, piece: { uid: 4 } }), 'x:4:');
assert.equal(selectVoiceKey({ type: 'chess', chess: { chessId: 'x' }, unitId: 9 }), 'x:9:');
assert.equal(selectVoiceKey({ type: 'item' }), null);
assert.equal(selectVoiceKey(null), null);
// the panel keys its 选中 effect on it, and the game screen numbers every operator card it opens
assert.match(fs.readFileSync(path.join(ROOT, 'public/js/ui/detailPanel.js'), 'utf8'), /const selectKey = voice && detail\?\.type === 'chess' \? selectVoiceKey\(detail\) : null;/);
const game = fs.readFileSync(path.join(ROOT, 'public/js/screens/game.js'), 'utf8');
assert.match(game, /onDetail=\$\{\(id, kind, hint\) => setDetail\(\{ kind: kind === 'item' \? 'item' : 'chess', id, hint: hint \|\| null, tap: \+\+cardTap\.current \}\)\}/);
} finally {
globalThis.fetch = origFetch;
}
});
+38
View File
@@ -96,4 +96,42 @@ test('fetch-assets still runs as a script: --help lists --allow-shrink', () => {
assert.equal(r.status, 0, r.stderr);
assert.match(r.stdout, /--allow-shrink/);
assert.match(r.stdout, /--prune .*\n.*implies --allow-shrink/);
assert.match(r.stdout, /--strict\s+exit 1 when a leaf was dropped/);
assert.match(r.stdout, /SP_ASSETS_STRICT=1 is the same as --strict/);
});
// A leaf whose every alternative file is missing on disk disappears from data/assets.json: the client has no entry at all
// (a unit whose attack sound was never downloaded plays nothing), and the shrink guard cannot see it once it is gone from
// the committed manifest. The run reports it as `droppedLeaves`; --strict / SP_ASSETS_STRICT=1 makes it a failure for CI
// and packaging runs, which is the only part that changes a run's outcome (docs/ASSETS.md).
test('--strict / SP_ASSETS_STRICT=1 fails a run that dropped a leaf for having no file on disk, instead of warning', async () => {
const { parseArgs, runExitCode, leafDropSummary, strictLeafDropError } = await import('../tools/fetch-assets.mjs');
const leaf = 'audio.sfx.units.char_x.attack';
assert.equal(parseArgs([]).strict, false, 'a plain run only warns');
assert.equal(parseArgs(['--strict']).strict, true);
assert.throws(() => parseArgs(['--stricts']), /unknown option/);
// the environment variable is the CI / packaging switch, like SP_ASSET_SOURCE
const saved = process.env.SP_ASSETS_STRICT;
try {
for (const v of ['1', 'true', 'YES', 'on']) { process.env.SP_ASSETS_STRICT = v; assert.equal(parseArgs([]).strict, true, `SP_ASSETS_STRICT=${v}`); }
for (const v of ['', '0', 'false', 'no', 'off']) { process.env.SP_ASSETS_STRICT = v; assert.equal(parseArgs([]).strict, false, `SP_ASSETS_STRICT=${v}`); }
delete process.env.SP_ASSETS_STRICT;
assert.equal(parseArgs([]).strict, false, 'unset');
} finally {
if (saved === undefined) delete process.env.SP_ASSETS_STRICT; else process.env.SP_ASSETS_STRICT = saved;
}
// the exit code: a dropped leaf fails a strict run and only warns otherwise
assert.equal(runExitCode({ droppedLeaves: 1 }), 0, 'default: reported, the run still succeeds');
assert.equal(runExitCode({ droppedLeaves: 48, strict: true }), 1, '--strict: the drop is a failure');
assert.equal(runExitCode({ droppedLeaves: 0, strict: true }), 0, 'nothing dropped: a strict run succeeds');
assert.equal(runExitCode({}), 0);
assert.equal(runExitCode({ requiredMisses: 1 }), 1, 'unchanged: a missing avatar / portrait / Front Spine always fails');
assert.equal(runExitCode({ manifestWritten: false }), 1, 'unchanged: the shrink guard keeps the run red');
// and the messages name the leaf
assert.match(strictLeafDropError([leaf]), /^ERROR: 1 leaf dropped for having no alternative on disk \(--strict \/ SP_ASSETS_STRICT=1\): audio\.sfx\.units\.char_x\.attack$/);
assert.match(strictLeafDropError([leaf].concat(Array.from({ length: 12 }, (_, i) => `ui.g${i}`))), /all 13 in \.cache/);
assert.equal(leafDropSummary([leaf]), '[assets] 1 leaf dropped: no alternative on disk (audio.sfx.units.char_x.attack)');
assert.match(leafDropSummary(['a', 'b']), /^\[assets\] 2 leaves dropped: no alternative on disk \(a, b\)$/);
assert.match(leafDropSummary(Array.from({ length: 9 }, (_, i) => `f${i}`)), /\(f0, f1, f2, f3, f4, f5, f6, f7, …\)$/, 'long lists are truncated on the line');
assert.match(leafDropSummary([]), /^\[assets\] no leaf dropped: every planned leaf has a file on disk$/);
});
+1
View File
@@ -0,0 +1 @@
{"solo-normal":{"victory":false,"roundsPassed":13,"hiddenReached":false,"hiddenCleared":false,"reason":"defeat","teamLp":0,"modeId":"mode_single_normal","difficulty":"NORMAL","stageId":"act2autochess_m04","bossId":"boss_1","hiddenBossId":"boss_8","seed":101,"durationMs":291900,"players":[{"playerId":"p_0","seat":0,"name":"P0","isBot":false,"left":false,"alive":true,"victory":false,"roundsPassed":13,"eliminatedRound":null,"lp":0,"bandId":"band_sarkazb","lineup":[{"id":"chess_char_1_14_b","golden":true,"tier":1,"row":11,"col":7,"items":[]},{"id":"chess_char_3_13_b","golden":true,"tier":3,"row":11,"col":8,"items":["chess_item_3_06_e_a","chess_item_4_08_e_a"]},{"id":"chess_char_3_14_a","golden":false,"tier":3,"row":10,"col":5,"items":["chess_item_3_03_e_a","chess_item_5_02_e_a"]},{"id":"chess_char_5_14_a","golden":false,"tier":5,"row":10,"col":8,"items":["chess_item_3_01_e_a","chess_item_1_01_e_b"]},{"id":"chess_char_1_02_b","golden":true,"tier":1,"row":9,"col":6,"items":["chess_item_3_11_e_a","chess_item_2_01_e_b"]},{"id":"chess_char_1_20_b","golden":true,"tier":1,"row":9,"col":7,"items":[]},{"id":"chess_char_5_06_a","golden":false,"tier":5,"row":9,"col":8,"items":["chess_item_3_08_e_a"]},{"id":"chess_char_4_07_b","golden":true,"tier":4,"row":9,"col":9,"items":["chess_item_6_05_e_a","chess_item_3_09_e_a"]}],"bonds":[{"bondId":"indomShip","count":3,"active":true,"tier":2,"layers":30},{"bondId":"visiShip","count":2,"active":true,"tier":1,"layers":19},{"bondId":"kjeragShip","count":3,"active":true,"tier":1,"layers":12},{"bondId":"skillfulShip","count":3,"active":true,"tier":1,"layers":8},{"bondId":"suntShip","count":5,"active":true,"tier":2,"layers":0},{"bondId":"steadShip","count":1,"active":false,"tier":0,"layers":8},{"bondId":"swiftShip","count":1,"active":false,"tier":0,"layers":6},{"bondId":"preciShip","count":0,"active":false,"tier":0,"layers":2},{"bondId":"arcaneShip","count":0,"active":false,"tier":0,"layers":2}],"stats":{"dmgDealt":2724948,"kills":327,"leaks":19,"gold":133,"refreshes":14,"merges":5,"itemsEquipped":21,"bossDamage":509756,"activatedLayers":69,"lpLost":10,"perfectRounds":12},"title":{"id":"comment_2","name":"不朽盟约","picId":"comment_icon_2","text":"激活的盟约层数总和最高"},"trophies":0,"reward":320}]},"coop4-hard":{"victory":true,"roundsPassed":14,"hiddenReached":false,"hiddenCleared":false,"reason":"victory","teamLp":37,"modeId":"mode_multi_hard","difficulty":"HARD","stageId":"act2autochess_m04","bossId":"boss_2","hiddenBossId":"boss_10","seed":102,"durationMs":308166.2697753906,"players":[{"playerId":"p_0","seat":0,"name":"P0","isBot":false,"left":false,"alive":true,"victory":true,"roundsPassed":14,"eliminatedRound":null,"lp":16,"bandId":"band_quintus","lineup":[{"id":"chess_char_3_09_b","golden":true,"tier":3,"row":11,"col":8,"items":["chess_item_4_08_e_a","chess_item_4_07_e_a"]},{"id":"chess_char_3_17_a","golden":false,"tier":3,"row":10,"col":3,"items":["chess_item_4_03_e_a","chess_item_3_05_e_b"]},{"id":"chess_char_6_19_a","golden":false,"tier":6,"row":10,"col":4,"items":["chess_item_3_01_e_a","chess_item_2_04_e_a"]},{"id":"chess_char_1_10_b","golden":true,"tier":1,"row":10,"col":5,"items":[]},{"id":"chess_char_4_04_a","golden":false,"tier":4,"row":10,"col":7,"items":[]},{"id":"chess_char_6_07_a","golden":false,"tier":6,"row":10,"col":8,"items":["chess_item_4_04_e_a"]},{"id":"chess_char_2_18_a","golden":false,"tier":2,"row":9,"col":8,"items":["chess_item_1_01_e_a","chess_item_1_05_e_a"]},{"id":"chess_char_5_07_a","golden":false,"tier":5,"row":9,"col":9,"items":["chess_item_4_06_e_a","chess_item_3_03_e_a"]}],"bonds":[{"bondId":"raidShip","count":2,"active":true,"tier":1,"layers":147},{"bondId":"kazimierzShip","count":3,"active":true,"tier":1,"layers":78},{"bondId":"arcaneShip","count":2,"active":true,"tier":1,"layers":18},{"bondId":"steadShip","count":2,"active":true,"tier":1,"layers":3},{"bondId":"soloShip","count":1,"active":true,"tier":1,"layers":0},{"bondId":"suntShip","count":2,"active":true,"tier":1,"layers":0},{"bondId":"visiShip","count":1,"active":false,"tier":0,"layers":21},{"bondId":"swiftShip","count":1,"active":false,"tier":0,"layers":18},{"bondId":"kjeragShip","count":1,"active":false,"tier":0,"layers":16}],"stats":{"dmgDealt":6871190,"kills":393,"leaks":21,"gold":148,"refreshes":23,"merges":3,"itemsEquipped":33,"bossDamage":945575,"activatedLayers":246,"lpLost":10,"perfectRounds":7},"title":{"id":"comment_3","name":"坚若磐石","picId":"comment_icon_3","text":"目标生命值损失最少"},"trophies":5,"reward":425},{"playerId":"p_1","seat":1,"name":"P1","isBot":false,"left":false,"alive":true,"victory":true,"roundsPassed":14,"eliminatedRound":null,"lp":6,"bandId":"band_ducklord","lineup":[{"id":"chess_char_4_13_a","golden":false,"tier":4,"row":12,"col":5,"items":[]},{"id":"chess_char_1_02_b","golden":true,"tier":1,"row":12,"col":8,"items":["chess_item_4_06_e_b","chess_item_4_08_e_a"]},{"id":"chess_char_3_14_b","golden":true,"tier":3,"row":11,"col":8,"Line truncated
+4 -3
View File
@@ -1,5 +1,6 @@
// test/full-potential.test.js — every operator fights at FULL potential (潜能 6), the owner's decision of 2026-10-07
// (GitHub #252, PR #255; tools/build-data.mjs OPERATOR_POTENTIAL, docs/DATA.md §2): every chess (normal and elite), 自选
// test/full-potential.test.js — the data records are built at FULL potential (潜能 6: the default — the owner's decisions of
// 2026-10-07, GitHub #252 / PR #255, and 2026-10-08: a lower potential is the player's 干员调配 setting, test/potential.test.js;
// tools/build-data.mjs FULL_RANK, docs/DATA.md §2 / §2.3): every chess (normal and elite), 自选
// pick and 补位 stand-in adds its character_table `potentialRanks` attribute steps and takes its talent / module-talent
// candidates up to `requiredPotentialRank` 5; a summon picks its talent candidates at its owner's potential but takes
// none of the owner's attribute steps. The evidence: a tournament video shows 刺玫 (chess_char_1_06_a) at ATK 435 and
@@ -92,7 +93,7 @@ test('every chess, 自选 form and stand-in form: the raw keyframes plus every p
for (const [charId, u] of Object.entries(backups.units)) {
for (const f of Object.values(u.forms)) { check(`${charId}@${f.status.phase}/${f.status.level}`, charId, f.status, f.stats); forms++; }
}
assert.equal(forms, 256);
assert.equal(forms, 259);
// the 原型干员 stand-ins have no potential ranks: their numbers are the plain keyframes
for (const id of ['char_600_cpione', 'char_602_cdfend', 'char_608_acpion', 'char_617_sharp2']) assert.equal((CT[id].potentialRanks || []).length, 0, id);
});
+1 -1
View File
@@ -11,7 +11,7 @@ const MATCHES = read('./golden/matches.json');
const BACKUPS = read('../data/backups.json');
test('matches: the 自选 match\'s human seat draws its 自选 pieces in its shop and fields 推进之王 in battle', () => {
const [id, dg] = Object.entries(MATCHES.scenarios).find(([k]) => /-diy$/.test(k)) || [];
const [id, dg] = Object.entries(MATCHES.scenarios).find(([k]) => /-coverage-diy$/.test(k)) || [];
assert.ok(dg, 'scenario present');
assert.deepEqual(dg.errors, { engine: 0, logged: 0, sim: 0, dispatcher: 0 }, id);
assert.deepEqual(Object.keys(dg.diy.picks).sort(), Object.keys(BACKUPS.diy.slots).sort(), 'all four slots filled');
+4 -2
View File
@@ -52,11 +52,13 @@ test('standins: every active skill a chess names for its stand-in is cast; passi
});
test('matches: the 补位 match\'s human seat fields stand-ins (listed per round) and the match ends without errors', () => {
const dg = MATCHES.scenarios['coop2-NORMAL-14-standins'];
const dg = MATCHES.scenarios['coop2-NORMAL-1-coverage-standins'];
assert.ok(dg, 'scenario present');
assert.deepEqual(dg.errors, { engine: 0, logged: 0, sim: 0, dispatcher: 0 });
const fielded = dg.standIns.rounds.filter(([, s]) => s).flatMap(([, s]) => s.split(' '));
assert.ok(new Set(fielded).size >= 3, `stand-ins fielded: ${[...new Set(fielded)].join(', ')}`);
// (≥ 2 since a level-up leaves its new slot empty — GitHub #332, PR #333 by @2321Robin: this match's shop rolls, and with
// them which stand-ins the human seat fields, part from the stream in which the upgrade drew a card into the new slot)
assert.ok(new Set(fielded).size >= 2, `stand-ins fielded: ${[...new Set(fielded)].join(', ')}`);
for (const f of fielded) {
const [id, charId] = f.split('→');
const c = CHESS[`chess_char_${id}`];
+3 -1
View File
@@ -17,7 +17,7 @@ through.
| `roster` | 49 battles: every visible chess record (normal and elite) with every selectable skill and module (DESIGN §16), 12 operators per battle on a real stage (all 11 in turn) against the round's real wave three times over, every non-leader enemy kind of `data/enemies.json` as extra spawns (half of them bounties), placeable summons on the board, every equipment item, band, battle-side 机变 card and stage map card in turn, bonds from the board |
| `bonds` | 46 battles: every bond at its activation threshold (1 layer) and at its top tier (999 layers) |
| `fields` | 22 battles: every Final Assault / Hidden Core leader on a pair and a solo template (shared pool, 200 s cap) and the 联防 field with 1 and 2 helpers on the round's stage, both halves (carried HP / SP, a knocked-out operator, two leakers' enemies) |
| `matches` | 18 matches to the end in virtual time: 16 bot-only (solo 标准 / 险境 / 绝境 / 终极 × 2 seeds, co-op 2 / 3 / 4, one server-run combat match, two runs boosted to the Hidden Core), one co-op match whose human seat (AI 托管, offline) does not own 9 NORMAL chess — they fight as their 补位 stand-ins (its digest lists them per round, `standIns`) — and one whose human seat slots 自选 picks (推进之王 and prototypes) with its 调度中心 at level 5 from the first prep: its own shop draws them and its AI fields 推进之王 (its digest lists per round the 自选 shop draws and the fielded pieces, `diy`) |
| `matches` | 20 matches to the end in virtual time: 16 bot-only (solo 标准 / 险境 / 绝境 / 终极 × 2 seeds, co-op 2 / 3 / 4, one server-run combat match, two runs boosted to the Hidden Core), one co-op match whose human seat (AI 托管, offline) does not own 9 NORMAL chess — they fight as their 补位 stand-ins (its digest lists them per round, `standIns`) — and one whose human seat slots 自选 picks (推进之王 and prototypes) with its 调度中心 at level 5 from the first prep: its own shop draws them and its AI fields 推进之王 (its digest lists per round the 自选 shop draws and the fielded pieces, `diy`) |
| `standins` | 10 battles: every NORMAL chess record (normal and elite, 110) fielded as its 补位 stand-in (`standIn: true`: the stand-in's body, backup skill / module and kit — all 17 stand-ins, every skill a chess names for them), 12 per battle by strength band, laid out by the stand-in's position on a real stage, against the round's real wave three times over plus 8 ground enemy kinds (melee stand-ins always meet an enemy), an item each, bonds from the board |
| `diy` | battles of 自选 pieces (a DIY slot with its `diy` pick, `shared/diy.js`): every owned 6★ with an operator kit (`kits/index.js OPERATOR_KITS`) in each form of tiers 5 and 6 (normal; elite with no module and with each module) under each skill, then every prototype pick with a kit at its locked selection, normal and elite, 12 per battle against the round's real wave; a new operator kit adds its scenarios (the battle count grows with the kits) |
@@ -58,3 +58,5 @@ node tools/golden.mjs --twice # determinism: the corpus twice
- A new engine hook, event or chess record does not change the digests of the existing scenarios by itself — the
hook list is frozen in the tool and the corpus is regenerated only on `golden:update` — but new data records do join
the corpus there (the roster family enumerates `data/chess.json`).
After the measured shared-stock update (DESIGN §29.8), the original stand-in / DIY seeds remain as outcome regressions even when they field no such operator. Companion seeds `coop2-NORMAL-1-coverage-standins` and `coop2-NORMAL-5-coverage-diy` exercise those full paths; the corpus now contains 287 scenarios.
+763 -771
View File
File diff suppressed because it is too large. Load diff
+3770 -3733
View File
File diff suppressed because it is too large. Load diff
+791 -831
View File
File diff suppressed because it is too large. Load diff
+833 -605
View File
File diff suppressed because it is too large. Load diff
+1557 -1564
View File
File diff suppressed because it is too large. Load diff
+255 -243
View File
@@ -20,58 +20,58 @@
"time": 170,
"ticks": 5100,
"reason": "timeout",
"killed": 19,
"killed": 17,
"total": 50,
"unspawned": 0,
"bossHpLeft": null,
"rngDraws": 544,
"rngDraws": 557,
"errors": {"count":0,"kinds":[]},
"players": {
"p1": {
"killed": 19,
"killed": 17,
"total": 50,
"perfect": false,
"leaks": 31,
"leaks": 33,
"leaksUncounted": 0,
"coins": 4,
"dmg": 266231,
"dmg": 236662,
"bossDmg": 0,
"heal": 17643,
"heal": 15792,
"deaths": 24,
"layerGains": {"raidShip":13,"visiShip":2}
}
},
"units": [
["p1",1,"op","chess_char_3_12_a",2,null,0,0,74,4,60651,4029,0,28,3],
["p1",2,"op","chess_char_4_09_a",0,null,0,0,1,1,29760,2984,0,18,1],
["p1",3,"op","chess_char_3_05_a",2,null,2081.5,1,76,2,31495,11964,0,47,2],
["p1",4,"op","chess_char_3_18_a",2,null,0,0,7,1,10578,3995,0,17,1],
["p1",5,"op","chess_char_4_04_a",1,null,0,0,92,1,17499,7084,0,74,10],
["p1",6,"op","chess_char_4_07_a",1,null,0,0,79,1,19230,13744,0,43,1],
["p1",7,"op","chess_char_4_16_a",1,null,215.72,1,94,1,9789,11775,0,117,14],
["p1",8,"op","chess_char_3_01_a",2,null,1271,1,5,0,22413,0,0,149,2],
["p1",9,"op","chess_char_3_04_a",2,null,1218,1,86,0,0,1599,17643,36,2],
["p1",10,"op","chess_char_3_21_a",2,null,716.85,1,6,6,42226,555,0,132,2],
["p1",11,"op","chess_char_4_02_a",1,null,1272,1,89,1,7563,1272,0,68,1],
["p1",12,"op","chess_char_4_13_a",1,null,0,0,83,1,15028,1624,0,33,2]
["p1",1,"op","chess_char_3_12_a",2,null,0,0,74,3,46613,4029,0,22,2],
["p1",2,"op","chess_char_4_09_a",0,null,0,0,1,1,29729,2984,0,18,1],
["p1",3,"op","chess_char_3_05_a",2,null,2081.5,1,76,2,30991,12632,0,46,2],
["p1",4,"op","chess_char_3_18_a",2,null,0,0,7,1,9726,3542,0,15,1],
["p1",5,"op","chess_char_4_04_a",1,null,0,0,89,1,15692,6722,0,87,9],
["p1",6,"op","chess_char_4_07_a",1,null,0,0,79,1,16650,11296,0,36,1],
["p1",7,"op","chess_char_4_16_a",1,null,807.8,1,92,0,7403,10573,0,114,13],
["p1",8,"op","chess_char_3_01_a",2,null,1271,1,5,1,19644,0,0,139,2],
["p1",9,"op","chess_char_3_04_a",2,null,1218,1,87,0,0,1599,15792,34,2],
["p1",10,"op","chess_char_3_21_a",2,null,161.7,1,6,5,40982,1110,0,138,2],
["p1",11,"op","chess_char_4_02_a",1,null,1272,1,91,1,8881,1653,0,49,1],
["p1",12,"op","chess_char_4_13_a",1,null,0,0,83,1,10351,1624,0,23,1]
],
"enemies": {
"enemy_10001_trslim": [1,1,0,8,4860],
"enemy_1000_gopro_2": [1,1,0,0,1836],
"enemy_10018_sgrobh": [1,1,0,3753,8100],
"enemy_1001_bigbo": [1,0,1,1235,2656],
"enemy_10027_vtsk": [1,0,1,12888,1289],
"enemy_10018_sgrobh": [1,1,0,3793,8100],
"enemy_1001_bigbo": [1,0,1,1235,2597],
"enemy_10027_vtsk": [1,0,1,16004,2114],
"enemy_10031_cnvsld": [1,0,1,0,0],
"enemy_10034_cnvsax": [1,0,1,1235,5687],
"enemy_10034_cnvsax": [1,0,1,1235,5433],
"enemy_10042_prtrop": [1,0,1,0,0],
"enemy_1116_liprr_2": [21,13,8,5313,129729],
"enemy_1119_vofsd": [12,2,8,12831,69965],
"enemy_1121_lifbos_2": [9,1,6,15758,42109]
"enemy_1116_liprr_2": [21,11,10,5878,116132],
"enemy_1119_vofsd": [12,2,9,10383,58920],
"enemy_1121_lifbos_2": [9,1,7,12260,36669]
},
"hooks": [1,94,5100,874,874,1402,1411,3740,43,84,41,38,0,18511,42,43,50,27,1,46,5,0,2],
"events": {"atk":983,"bounty":2,"deploy":44,"die":57,"dmg":1411,"engage":11,"fx":90,"heal":36,"layer":5,"leak":27,"skill":79,"spawn":62,"status":157},
"fx": {"beam":12,"blink":14,"dodge":2,"dp":7,"garrison":5,"liberate":12,"phase":13,"slash":24,"telegraph":1},
"snaps": "8b8be66d b3674b6a 84ebce5a c4efeba6 05a2d3cd bf6f3985 249c5ebd 2bf68848 6faa6eb8 0275f0fb f4b16de1 f88b620c 3545e4d2 aa0d5a15 ecf73cfe 95a2451e a178591a"
"hooks": [1,92,5100,827,827,1351,1361,3159,41,84,37,34,0,17166,30,38,50,31,1,43,5,0,1],
"events": {"atk":944,"bounty":2,"deploy":42,"die":53,"dmg":1361,"engage":11,"fx":78,"heal":34,"layer":5,"leak":31,"skill":71,"spawn":62,"status":135},
"fx": {"beam":12,"blink":12,"dodge":1,"dp":5,"garrison":5,"liberate":14,"phase":12,"slash":16,"telegraph":1},
"snaps": "857e2323 698cb0a7 94c7a5e0 2aa6acaa f7e53f3d 9d657030 cccc9aca 21e3ad7e b37dbf1e b5cd0443 6f5b7d8d b64e245e 27c5f782 281fb1da df6dc58e f267a1fb 6cc0aa3d"
},
"standin-02": {
"about": "normal band 5 as stand-ins: chess_char_4_17_a→char_610_acfend chess_char_4_18_a→char_602_cdfend chess_char_5_05_a→char_602_cdfend chess_char_4_19_a→char_608_acpion chess_char_5_01_a→char_600_cpione chess_char_4_22_a→char_609_acguad chess_char_4_23_a→char_609_acguad chess_char_5_06_a→char_609_acguad chess_char_4_20_a→char_603_csnipe chess_char_4_24_a→char_604_ccast chess_char_5_02_a→char_604_ccast chess_char_5_03_a→char_612_accast",
@@ -92,39 +92,39 @@
"rngDraws": 106,
"errors": {"count":0,"kinds":[]},
"players": {
"p1": {"killed":9,"total":68,"perfect":false,"leaks":59,"leaksUncounted":0,"coins":0,"dmg":99809,"bossDmg":0,"heal":0,"deaths":22,"layerGains":{"preciShip":12}}
"p1": {"killed":9,"total":68,"perfect":false,"leaks":59,"leaksUncounted":0,"coins":0,"dmg":99471,"bossDmg":0,"heal":0,"deaths":23,"layerGains":{"preciShip":12}}
},
"units": [
["p1",1,"op","chess_char_4_17_a",1,null,0,0,3,1,360,2604,0,3,1],
["p1",2,"op","chess_char_4_18_a",1,null,0,0,85,0,2350,6810,0,6,1],
["p1",3,"op","chess_char_5_05_a",2,null,0,0,73,0,28,4256,0,2,1],
["p1",4,"op","chess_char_4_19_a",1,null,0,0,66,0,158,3058,0,11,1],
["p1",5,"op","chess_char_5_01_a",2,null,154.94,1,91,0,52,3520,0,3,0],
["p1",6,"op","chess_char_4_22_a",1,null,0,0,72,0,6886,5276,0,9,0],
["p1",1,"op","chess_char_4_17_a",1,null,0,0,3,0,94,2604,0,4,1],
["p1",2,"op","chess_char_4_18_a",1,null,0,0,85,0,2100,6810,0,6,1],
["p1",3,"op","chess_char_5_05_a",2,null,0,0,73,0,240,4256,0,2,1],
["p1",4,"op","chess_char_4_19_a",1,null,0,0,66,0,316,3058,0,10,1],
["p1",5,"op","chess_char_5_01_a",2,null,0,0,91,0,52,3675,0,3,0],
["p1",6,"op","chess_char_4_22_a",1,null,0,0,70,0,6886,5276,0,9,0],
["p1",7,"op","chess_char_4_23_a",1,null,0,0,65,0,5497,5276,0,9,0],
["p1",8,"op","chess_char_5_06_a",2,null,0,0,1,0,0,2638,0,0,0],
["p1",9,"op","chess_char_4_20_a",1,null,1271,1,92,1,11656,2542,0,25,0],
["p1",10,"op","chess_char_4_24_a",1,null,0,0,90,0,4203,3399,0,9,0],
["p1",11,"op","chess_char_5_02_a",2,null,0,0,78,2,12811,2266,0,25,1],
["p1",12,"op","chess_char_5_03_a",2,null,0,0,4,5,55808,1372,0,10,1]
["p1",9,"op","chess_char_4_20_a",1,null,1271,1,92,0,12203,2542,0,26,0],
["p1",10,"op","chess_char_4_24_a",1,null,0,0,90,1,4077,3399,0,9,0],
["p1",11,"op","chess_char_5_02_a",2,null,0,0,78,3,12264,2266,0,25,1],
["p1",12,"op","chess_char_5_03_a",2,null,0,0,4,5,55742,1372,0,10,1]
],
"enemies": {
"enemy_10042_prtrop_2": [1,0,1,4245,13584],
"enemy_10043_sailor": [1,1,0,1107,23649],
"enemy_10044_wintun": [1,0,1,4066,10026],
"enemy_10045_parrot": [1,0,1,0,4099],
"enemy_10054_cjhot": [1,0,1,2423,2176],
"enemy_1005_yokai": [27,8,19,0,30014],
"enemy_1005_yokai_2": [27,0,24,11167,2144],
"enemy_1005_yokai_3": [6,0,6,20008,18599],
"enemy_10065_ftzlc": [1,0,1,0,560],
"enemy_10042_prtrop_2": [1,0,1,4499,14163],
"enemy_10043_sailor": [1,1,0,1347,23649],
"enemy_10044_wintun": [1,0,1,3812,10909],
"enemy_10045_parrot": [1,0,1,0,4930],
"enemy_10054_cjhot": [1,0,1,2461,2249],
"enemy_1005_yokai": [27,8,19,0,29182],
"enemy_1005_yokai_2": [27,0,23,11721,2144],
"enemy_1005_yokai_3": [6,0,6,19313,16526],
"enemy_10065_ftzlc": [1,0,1,19,640],
"enemy_10067_ftsjc": [1,0,1,0,680],
"enemy_1006_shield": [1,0,1,0,760]
},
"hooks": [1,92,5700,326,326,561,533,0,31,87,6,6,0,12663,1,14,68,56,1,31,3,0,38],
"events": {"atk":334,"deploy":24,"die":31,"dmg":533,"engage":11,"fx":77,"layer":3,"leak":56,"skill":12,"spawn":80,"status":2},
"fx": {"aoe":4,"dodge":38,"dp":3,"explode":27,"garrison":3,"garrisonGrant":1,"zone":1},
"snaps": "bb11ef91 9b91da1e 3c4f008c 878e68d7 24bb4a4a 8d8aeac2 afd35f69 eb5d4d7c eb84350d df25cd79 28a5ee62 7ca7b30b 51dff947 2a598d70 a2618471 b503b719 ee4a9a4a ece62e64 443cb0c6"
"hooks": [1,92,5700,333,333,563,537,0,32,87,6,6,0,12428,1,13,68,55,1,32,3,0,36],
"events": {"atk":341,"deploy":24,"die":32,"dmg":537,"engage":11,"fx":77,"layer":3,"leak":55,"skill":12,"spawn":80,"status":2},
"fx": {"aoe":4,"dodge":36,"dp":3,"explode":29,"garrison":3,"garrisonGrant":1,"zone":1},
"snaps": "bb11ef91 04ff2745 d8f916e6 a29399b5 aef2a63d 90d7e60c a0ca5872 c22c50eb 7e078fec 964b7af6 0837d8c7 6ba5bd75 485c780b 5255dfcf 1e9adc41 7e0f5468 b5284b15 3995f9cb f71908f0"
},
"standin-03": {
"about": "normal band 5 as stand-ins: chess_char_5_08_a→char_610_acfend chess_char_5_14_a→char_608_acpion chess_char_5_19_a→char_617_sharp2 chess_char_5_07_a→char_601_cguard chess_char_5_13_a→char_609_acguad chess_char_5_17_a→char_615_acspec chess_char_5_10_a→char_606_csuppo chess_char_5_11_a→char_613_acmedc chess_char_5_12_a→char_614_acsupo chess_char_5_15_a→char_603_csnipe chess_char_5_18_a→char_611_acnipe chess_char_5_20_a→char_614_acsupo",
@@ -138,44 +138,44 @@
"time": 190,
"ticks": 5700,
"reason": "timeout",
"killed": 41,
"killed": 38,
"total": 74,
"unspawned": 0,
"bossHpLeft": null,
"rngDraws": 278,
"rngDraws": 294,
"errors": {"count":0,"kinds":[]},
"players": {"p1":{"killed":41,"total":74,"perfect":false,"leaks":33,"leaksUncounted":0,"coins":8,"dmg":322054,"bossDmg":0,"heal":40884,"deaths":10,"layerGains":{}}},
"players": {"p1":{"killed":38,"total":74,"perfect":false,"leaks":36,"leaksUncounted":0,"coins":8,"dmg":312097,"bossDmg":0,"heal":42234,"deaths":10,"layerGains":{}}},
"units": [
["p1",1,"op","chess_char_5_08_a",2,null,2206.8,1,91,0,5895,3841,0,57,1],
["p1",2,"op","chess_char_5_14_a",2,null,0,0,90,23,138688,7595,0,63,6],
["p1",3,"op","chess_char_5_19_a",0,null,349.6,1,89,6,47622,15311,0,100,3],
["p1",4,"op","chess_char_5_07_a",2,null,2564,1,92,0,12112,3824,0,24,1],
["p1",5,"op","chess_char_5_13_a",2,null,1801,1,11,7,56384,10325,0,123,4],
["p1",6,"op","chess_char_5_17_a",2,null,20,1,81,1,7284,4998,0,77,0],
["p1",7,"op","chess_char_5_10_a",2,null,932,1,2,1,7776,0,0,58,2],
["p1",8,"op","chess_char_5_11_a",2,null,1224,1,3,0,0,0,40884,67,2],
["p1",9,"op","chess_char_5_12_a",2,null,1160,1,4,0,19936,0,0,79,2],
["p1",10,"op","chess_char_5_15_a",2,null,0,0,84,2,8336,14152,0,96,2],
["p1",11,"op","chess_char_5_18_a",2,null,0,0,1,1,11149,1272,0,22,1],
["p1",12,"op","chess_char_5_20_a",2,null,0,0,5,0,6872,1972,0,38,2]
["p1",1,"op","chess_char_5_08_a",2,null,571.8,1,6,2,14891,7695,0,131,2],
["p1",2,"op","chess_char_5_14_a",2,null,0,0,85,11,96636,7465,0,52,5],
["p1",3,"op","chess_char_5_19_a",0,null,377,1,90,7,47254,14046,0,101,3],
["p1",4,"op","chess_char_5_07_a",2,null,600,1,93,1,4886,7468,0,39,1],
["p1",5,"op","chess_char_5_13_a",2,null,544,1,11,6,68190,10905,0,127,4],
["p1",6,"op","chess_char_5_17_a",2,null,0,0,77,5,15138,2547,0,80,0],
["p1",7,"op","chess_char_5_10_a",2,null,932,1,2,0,9784,0,0,81,2],
["p1",8,"op","chess_char_5_11_a",2,null,1224,1,3,0,0,0,42234,72,2],
["p1",9,"op","chess_char_5_12_a",2,null,1160,1,4,0,23322,0,0,90,2],
["p1",10,"op","chess_char_5_15_a",2,null,0,0,84,2,8345,13516,0,95,3],
["p1",11,"op","chess_char_5_18_a",2,null,1272,1,91,4,16112,1272,0,26,1],
["p1",12,"op","chess_char_5_20_a",2,null,1972,1,92,0,7539,1972,0,44,1]
],
"enemies": {
"enemy_1006_shield_2": [1,1,0,582,8000],
"enemy_1006_shield_3": [1,0,1,11325,14488],
"enemy_1006_shield_3": [1,0,1,9612,8819],
"enemy_10073_mpcar": [1,1,0,12,1600],
"enemy_1007_slime": [1,1,0,0,440],
"enemy_10081_mpplai": [1,1,0,0,2000],
"enemy_10087_hlchgr": [1,1,0,38,4800],
"enemy_10094_crstf": [1,1,0,28,3200],
"enemy_10097_crshd": [1,1,0,0,8000],
"enemy_1166_dusbr_2": [42,28,14,2956,110452],
"enemy_1170_dushld_2": [12,5,6,26204,45033],
"enemy_9011_acrefr": [12,1,7,22144,124041]
"enemy_10097_crshd": [1,0,1,869,1576],
"enemy_1166_dusbr_2": [42,29,13,4731,113146],
"enemy_1170_dushld_2": [12,2,8,31659,41655],
"enemy_9011_acrefr": [12,1,4,19354,126861]
},
"hooks": [1,92,5700,1025,1025,1522,1523,112,51,79,26,26,0,27108,3400,51,74,28,1,52,0,0,10],
"events": {"atk":1128,"bounty":4,"deploy":18,"die":51,"dmg":1522,"engage":11,"fx":134,"heal":106,"leak":28,"skill":55,"spawn":86,"status":609},
"fx": {"aoe":30,"displace":1,"dodge":16,"dp":29,"phase":1,"slash":48,"sp":1,"spGain":8},
"snaps": "530ce9ab 3259d8c0 918a3cff a85e2d90 3b23751a 8f259c98 9faf0458 b98e7e80 e64fb37d 38b17953 2458f514 b2894d2f 6c036845 609d268d 5a2493c6 23bae8dc e4a6485d 424e64d1 745ddf7a"
"hooks": [1,93,5700,1246,1246,1770,1779,131,48,75,26,27,0,29163,3246,53,74,27,1,48,0,1,11],
"events": {"atk":1352,"bounty":4,"deploy":19,"die":48,"dmg":1773,"engage":11,"fx":151,"heal":120,"leak":27,"skill":55,"spawn":86,"status":633},
"fx": {"aoe":60,"burst":1,"displace":4,"dodge":20,"dp":16,"phase":1,"slash":40,"sp":1,"spGain":8},
"snaps": "530ce9ab 077d8667 72a08c6b e8e968c7 ba780244 415e96b1 b2fbf900 eeae800d cb865a16 5f7fac5a 0b386348 57872ff3 639e0975 3111f879 121dc45e 0686e918 a1655184 a964d89f e67acf8a"
},
"standin-04": {
"about": "normal band 6 as stand-ins: chess_char_6_03_a→char_610_acfend chess_char_6_02_a→char_608_acpion chess_char_6_07_a→char_617_sharp2 chess_char_6_10_a→char_617_sharp2 chess_char_6_11_a→char_608_acpion chess_char_6_06_a→char_609_acguad chess_char_5_22_a→char_611_acnipe chess_char_6_01_a→char_611_acnipe chess_char_6_04_a→char_614_acsupo chess_char_6_05_a→char_611_acnipe chess_char_6_08_a→char_612_accast chess_char_6_09_a→char_613_acmedc",
@@ -186,50 +186,62 @@
"round": 12,
"seed": 428893583,
"waveId": "act1autochess_h05",
"time": 201.43,
"ticks": 6043,
"time": 207.8,
"ticks": 6234,
"reason": "cleared",
"killed": 158,
"killed": 148,
"total": 158,
"unspawned": 0,
"bossHpLeft": null,
"rngDraws": 612,
"rngDraws": 613,
"errors": {"count":0,"kinds":[]},
"players": {
"p1": {"killed":158,"total":158,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":8,"dmg":1471553,"bossDmg":0,"heal":29310,"deaths":8,"layerGains":{"sargonShip":5}}
"p1": {
"killed": 148,
"total": 158,
"perfect": false,
"leaks": 10,
"leaksUncounted": 0,
"coins": 8,
"dmg": 1388100,
"bossDmg": 0,
"heal": 27495,
"deaths": 10,
"layerGains": {"sargonShip":5}
}
},
"units": [
["p1",1,"op","chess_char_6_03_a",2,null,0,0,169,0,1288,5814,0,12,1],
["p1",2,"op","chess_char_6_02_a",2,null,0,0,171,5,21360,3058,0,2,4],
["p1",3,"op","chess_char_6_07_a",0,null,0,0,6,19,254549,1865,0,146,3],
["p1",4,"op","chess_char_6_10_a",0,null,1865,1,177,6,128769,1865,0,109,2],
["p1",5,"op","chess_char_6_11_a",2,null,1529,1,174,28,230418,10062,0,75,10],
["p1",6,"op","chess_char_6_06_a",2,null,2149.08,1,158,16,114781,20845,0,115,4],
["p1",7,"op","chess_char_5_22_a",2,null,1272,1,3,2,27889,0,0,23,2],
["p1",8,"op","chess_char_6_01_a",2,null,1272,1,4,36,251215,0,0,157,2],
["p1",9,"op","chess_char_6_04_a",2,null,1160,1,13,5,66116,0,0,122,2],
["p1",10,"op","chess_char_6_05_a",2,null,1272,1,14,18,242842,2453,0,127,2],
["p1",11,"op","chess_char_6_08_a",2,null,1372,1,5,21,132325,0,0,40,2],
["p1",12,"op","chess_char_6_09_a",2,null,1224,1,11,0,0,0,29310,46,2],
["p1",1,"op","chess_char_6_03_a",2,null,0,0,166,1,1712,5959,0,13,1],
["p1",2,"op","chess_char_6_02_a",2,null,0,0,170,3,26692,3058,0,5,4],
["p1",3,"op","chess_char_6_07_a",0,null,1449.4,1,178,14,168046,2281,0,114,2],
["p1",4,"op","chess_char_6_10_a",0,null,0,0,177,5,96562,3730,0,103,2],
["p1",5,"op","chess_char_6_11_a",2,null,0,0,163,24,167712,9566,0,43,7],
["p1",6,"op","chess_char_6_06_a",2,null,2638,1,159,12,113713,20421,0,107,4],
["p1",7,"op","chess_char_5_22_a",2,null,1272,1,3,4,34571,0,0,26,2],
["p1",8,"op","chess_char_6_01_a",2,null,1272,1,4,42,290196,0,0,158,2],
["p1",9,"op","chess_char_6_04_a",2,null,1160,1,13,4,65352,0,0,118,2],
["p1",10,"op","chess_char_6_05_a",2,null,1272,1,14,15,205666,2453,0,131,2],
["p1",11,"op","chess_char_6_08_a",2,null,1372,1,5,22,217877,0,0,51,2],
["p1",12,"op","chess_char_6_09_a",2,null,1224,1,11,0,0,0,27495,42,2],
["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null],
["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null]
],
"enemies": {
"enemy_10098_crhro": [1,1,0,1577,41472],
"enemy_10098_crhro": [1,1,0,2026,41472],
"enemy_10099_crvln": [1,1,0,2453,20736],
"enemy_1009_lurker": [1,1,0,23,3041],
"enemy_1009_lurker": [1,1,0,14,3041],
"enemy_1010_demon": [1,1,0,0,10368],
"enemy_1010_demon_2": [28,28,0,11599,580608],
"enemy_1010_demon_2": [28,24,4,11437,555442],
"enemy_10112_ymgds": [1,1,0,0,6912],
"enemy_10116_ymgtop": [1,1,0,2924,13824],
"enemy_10118_ymgprc": [1,1,0,10510,69120],
"enemy_1071_dftman": [63,63,0,587,243855],
"enemy_1072_dlancer": [60,60,0,16287,497664]
"enemy_10116_ymgtop": [1,1,0,2398,13824],
"enemy_10118_ymgprc": [1,0,1,6540,35966],
"enemy_1071_dftman": [63,63,0,455,243855],
"enemy_1072_dlancer": [60,55,5,22144,476588]
},
"hooks": [1,177,6043,1060,1060,2060,2055,76,166,166,36,35,0,37056,3588,58,158,0,1,168,2,0,13],
"events": {"atk":1212,"bounty":4,"deploy":17,"die":166,"dmg":2055,"engage":11,"fx":181,"heal":58,"layer":2,"skill":71,"spawn":172,"status":436},
"fx": {"aoe":5,"dodge":17,"dp":47,"garrison":2,"revive":1,"slash":91,"sp":14,"spGain":2,"telegraph":2},
"snaps": "484fd662 4f4e076d 081948b0 d7bf1df0 c0aa2905 1b27e150 ab6cac50 d1148fdb fccd25dd 3bdf0cd3 bc78cb58 5914e59f e9a5d19b c18806ba 780d90bd f332e4d3 1b1a7814 b06c5778 2ebf5ca8 a00fc7c0 e0236981"
"hooks": [1,178,6234,1000,1000,2074,2072,74,158,168,32,32,0,36983,4066,67,158,10,1,160,2,0,11],
"events": {"atk":1142,"bounty":4,"deploy":18,"die":158,"dmg":2072,"engage":11,"fx":161,"heal":53,"layer":2,"leak":10,"skill":64,"spawn":172,"status":472},
"fx": {"aoe":11,"dodge":14,"dp":38,"garrison":2,"revive":1,"slash":77,"sp":13,"spGain":3,"telegraph":2},
"snaps": "484fd662 19a9b252 827467e6 d8836b51 4e1a5c20 f5f33eaf 009d313f 487fac84 6e33c253 481fa961 eee28282 47fecc48 ec2c4a14 046a7402 d017b22b a2a8910b b53ce9db 2504d954 222d228d 78e5e524 2b44a10f"
},
"standin-05": {
"about": "normal band 6 as stand-ins: chess_char_6_17_a→char_608_acpion chess_char_6_13_a→char_615_acspec chess_char_6_19_a→char_615_acspec chess_char_6_12_a→char_612_accast chess_char_6_15_a→char_614_acsupo chess_char_6_18_a→char_612_accast chess_char_6_20_a→char_613_acmedc",
@@ -240,47 +252,47 @@
"round": 12,
"seed": 4096833445,
"waveId": "act1autochess_h05",
"time": 220.27,
"ticks": 6608,
"time": 212.97,
"ticks": 6389,
"reason": "cleared",
"killed": 5,
"killed": 8,
"total": 104,
"unspawned": 0,
"bossHpLeft": null,
"rngDraws": 225,
"rngDraws": 301,
"errors": {"count":0,"kinds":[]},
"players": {
"p1": {"killed":5,"total":104,"perfect":false,"leaks":99,"leaksUncounted":0,"coins":0,"dmg":418583,"bossDmg":0,"heal":451,"deaths":23,"layerGains":{"raidShip":3}}
"p1": {"killed":8,"total":104,"perfect":false,"leaks":96,"leaksUncounted":0,"coins":2,"dmg":460557,"bossDmg":0,"heal":451,"deaths":20,"layerGains":{"raidShip":3}}
},
"units": [
["p1",1,"op","chess_char_6_17_a",2,null,0,0,130,1,102521,9556,0,46,1],
["p1",2,"op","chess_char_6_13_a",2,null,0,0,135,0,25983,10386,0,85,0],
["p1",3,"op","chess_char_6_19_a",2,null,1154,1,136,3,24753,9232,0,127,0],
["p1",4,"op","chess_char_6_12_a",2,null,671.78,1,7,0,141278,975,0,83,2],
["p1",5,"op","chess_char_6_15_a",2,null,0,0,134,1,53745,4965,0,110,2],
["p1",6,"op","chess_char_6_18_a",2,null,1372,1,85,0,70303,1372,0,42,2],
["p1",1,"op","chess_char_6_17_a",2,null,0,0,126,1,104619,9556,0,47,1],
["p1",2,"op","chess_char_6_13_a",2,null,1154,1,133,2,32325,8078,0,97,0],
["p1",3,"op","chess_char_6_19_a",2,null,0,0,131,2,41575,8078,0,186,0],
["p1",4,"op","chess_char_6_12_a",2,null,671.78,1,7,0,142335,975,0,76,2],
["p1",5,"op","chess_char_6_15_a",2,null,0,0,132,3,51646,4965,0,107,2],
["p1",6,"op","chess_char_6_18_a",2,null,1372,1,88,0,88056,1372,0,51,2],
["p1",7,"op","chess_char_6_20_a",2,null,1224,1,4,0,0,451,451,1,1],
["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null],
["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null],
["-","~3","device","trap_1105_accrate",null,null,100,1,3,0,0,0,0,0,null]
],
"enemies": {
"enemy_10122_uacann_2": [1,0,1,7356,12074],
"enemy_10122_uacann_2": [1,0,1,6747,13260],
"enemy_10124_uashld_2": [1,0,1,0,5764],
"enemy_10127_rkmbst_2": [1,0,1,1154,0],
"enemy_10138_xdsnow": [1,0,1,0,1276],
"enemy_10127_rkmbst_2": [1,0,1,0,1544],
"enemy_10138_xdsnow": [1,0,1,0,638],
"enemy_10141_xdpeng_2": [1,0,1,771,638],
"enemy_10144_xdelk_2": [1,0,1,0,0],
"enemy_10156_mncrer": [1,0,1,0,1476],
"enemy_10144_xdelk_2": [1,0,1,0,42],
"enemy_10156_mncrer": [1,1,0,535,9555],
"enemy_10159_mntrjn": [1,0,1,0,0],
"enemy_1381_winman": [27,4,23,2007,92343],
"enemy_1381_winman_2": [39,0,39,8923,168815],
"enemy_1387_winshd": [30,1,29,16725,136197]
"enemy_1381_winman": [27,6,21,7780,137676],
"enemy_1381_winman_2": [39,1,38,5087,154280],
"enemy_1387_winshd": [30,0,30,12555,137161]
},
"hooks": [1,136,6608,592,592,980,980,2,28,129,8,25,0,15091,2491,28,104,99,1,28,1,0,0],
"events": {"atk":623,"deploy":29,"die":30,"dmg":888,"engage":6,"fx":65,"heal":1,"layer":1,"leak":99,"skill":52,"spawn":114,"status":507},
"fx": {"aoe":5,"blink":2,"camouflage":1,"displace":29,"dodge":1,"dp":2,"explode":7,"garrison":1,"garrisonGrant":2,"slash":8,"zone":7},
"snaps": "7d1098cf a2545bc6 e235d106 8dfcbe4d 7418beb3 b07a14d3 d8595728 777b37c0 fcf9e593 4701b892 b3bb788e 48db2071 75eccfeb 54b87291 0c274801 025d0454 6e6490df e548bc32 a587ffae 097958c2 b5acd030 f192d5d8"
"hooks": [1,133,6389,675,675,1045,1045,6,28,126,8,22,0,13925,3343,28,104,96,1,28,1,0,0],
"events": {"atk":709,"bounty":1,"deploy":26,"die":30,"dmg":967,"engage":6,"fx":53,"heal":5,"layer":1,"leak":96,"skill":46,"spawn":114,"status":516},
"fx": {"aoe":5,"blink":2,"camouflage":1,"displace":12,"dodge":3,"dp":2,"explode":9,"garrison":1,"garrisonGrant":2,"slash":8,"zone":8},
"snaps": "7d1098cf 59e3d11b cdb269b4 4ce1ab07 78ea8d35 932f66d3 7b5e2c7d fba1ea1a d602485a b01c1226 3b96f2b4 303da3fb e2925a99 b225a1be e78fac84 13118dda 4a2a4367 08723224 14d9e17a 730130f1 b2bb0da6 61083881"
},
"standin-06": {
"about": "elite band 5 as stand-ins: chess_char_3_12_b→char_608_acpion chess_char_4_09_b→char_617_sharp2 chess_char_3_05_b→char_601_cguard chess_char_3_18_b→char_609_acguad chess_char_4_04_b→char_607_cspec chess_char_4_07_b→char_601_cguard chess_char_4_16_b→char_615_acspec chess_char_3_01_b→char_603_csnipe chess_char_3_04_b→char_605_cmedic chess_char_3_21_b→char_611_acnipe chess_char_4_02_b→char_611_acnipe chess_char_4_13_b→char_614_acsupo",
@@ -291,49 +303,49 @@
"round": 10,
"seed": 1442657168,
"waveId": "act1autochess_h03",
"time": 185.13,
"ticks": 5554,
"time": 187.4,
"ticks": 5622,
"reason": "cleared",
"killed": 24,
"killed": 30,
"total": 98,
"unspawned": 0,
"bossHpLeft": null,
"rngDraws": 262,
"rngDraws": 315,
"errors": {"count":0,"kinds":[]},
"players": {
"p1": {"killed":24,"total":98,"perfect":false,"leaks":74,"leaksUncounted":0,"coins":4,"dmg":379938,"bossDmg":0,"heal":9793,"deaths":31,"layerGains":{"raidShip":24}}
"p1": {"killed":30,"total":98,"perfect":false,"leaks":68,"leaksUncounted":0,"coins":6,"dmg":472224,"bossDmg":0,"heal":15707,"deaths":31,"layerGains":{"raidShip":24}}
},
"units": [
["p1",1,"op","chess_char_3_12_b",2,"none",1904,1,138,4,33527,4810,222,6,1],
["p1",2,"op","chess_char_4_09_b",0,"none",0,0,6,2,10500,3510,0,5,1],
["p1",3,"op","chess_char_3_05_b",2,"none",0,0,90,1,29453,11344,0,19,2],
["p1",1,"op","chess_char_3_12_b",2,"none",1904,1,139,4,33527,4810,222,6,1],
["p1",2,"op","chess_char_4_09_b",0,"none",0,0,6,0,13369,3510,0,5,1],
["p1",3,"op","chess_char_3_05_b",2,"none",0,0,90,1,32273,11344,0,20,2],
["p1",4,"op","chess_char_3_18_b",2,"none",0,0,12,1,32236,5744,2553,22,1],
["p1",5,"op","chess_char_4_04_b",1,"none",0,0,137,0,21284,11506,0,43,9],
["p1",6,"op","chess_char_4_07_b",1,"none",0,0,93,0,13230,7538,0,21,1],
["p1",7,"op","chess_char_4_16_b",1,"none",0,0,135,2,22963,13645,0,38,12],
["p1",8,"op","chess_char_3_01_b",2,"none",0,0,128,2,11972,3020,0,20,1],
["p1",9,"op","chess_char_3_04_b",2,"none",0,0,4,0,0,1374,7019,10,1],
["p1",10,"op","chess_char_3_21_b",2,"none",0,0,111,9,171439,4263,0,48,1],
["p1",11,"op","chess_char_4_02_b",1,"none",0,0,2,0,19713,1483,0,16,1],
["p1",12,"op","chess_char_4_13_b",1,"none",0,0,101,3,13621,3085,0,23,1]
["p1",5,"op","chess_char_4_04_b",1,"none",0,0,138,0,13918,12430,0,38,9],
["p1",6,"op","chess_char_4_07_b",1,"none",0,0,93,0,13096,7538,0,19,1],
["p1",7,"op","chess_char_4_16_b",1,"none",0,0,137,3,45194,14837,0,51,13],
["p1",8,"op","chess_char_3_01_b",2,"none",0,0,132,6,26811,3020,0,27,1],
["p1",9,"op","chess_char_3_04_b",2,"none",0,0,4,0,0,3135,12932,15,1],
["p1",10,"op","chess_char_3_21_b",2,"none",0,0,113,10,201155,6858,0,55,1],
["p1",11,"op","chess_char_4_02_b",1,"none",0,0,2,2,47025,1483,0,25,1],
["p1",12,"op","chess_char_4_13_b",1,"none",0,0,101,3,13621,2704,0,23,1]
],
"enemies": {
"enemy_10162_mnctpt": [1,0,1,13739,28023],
"enemy_10162_mnctpt": [1,0,1,12236,28023],
"enemy_1019_jshoot": [1,1,0,769,4479],
"enemy_1019_jshoot_2": [1,1,0,1779,6718],
"enemy_1021_bslime": [1,1,0,2095,6121],
"enemy_1023_jmage": [1,0,1,2948,0],
"enemy_1025_reveng": [1,0,1,0,12429],
"enemy_1023_jmage": [1,1,0,2403,5474],
"enemy_1025_reveng": [1,1,0,0,12442],
"enemy_1026_aghost": [1,1,0,0,9456],
"enemy_1043_zomsbr": [1,0,1,0,0],
"enemy_1251_lysyta_2": [57,15,42,6671,144893],
"enemy_1252_lysytb_2": [21,4,17,25366,122606],
"enemy_1329_cbshld_2": [12,1,11,17957,45213]
"enemy_1251_lysyta_2": [57,18,39,8755,173039],
"enemy_1252_lysytb_2": [21,5,16,29535,174396],
"enemy_1329_cbshld_2": [12,1,11,19842,52076]
},
"hooks": [1,138,5554,424,424,606,623,37,55,137,32,32,0,5902,24,41,98,74,1,58,3,0,6],
"events": {"atk":484,"bounty":2,"deploy":40,"die":63,"dmg":623,"engage":11,"fx":33,"heal":35,"layer":3,"leak":74,"skill":64,"spawn":110,"status":81},
"fx": {"blink":8,"camouflage":1,"dodge":7,"dp":5,"explode":1,"garrison":3,"slash":8},
"snaps": "be6d1821 33db37f0 1c42b6f6 5af04203 5e6c5512 babe8154 f176e4bc af2beda0 d11d4dd9 232a9af5 dbd39714 cdc694f1 7413a229 4dd3df34 ebd96e47 5f8b58f1 42af7a0d e3ac653d afa3953c"
"hooks": [1,139,5622,474,474,678,694,42,61,138,33,33,0,6117,24,44,98,68,1,66,3,0,7],
"events": {"atk":549,"bounty":3,"deploy":41,"die":70,"dmg":694,"engage":11,"fx":35,"heal":40,"layer":3,"leak":68,"skill":66,"spawn":110,"status":83},
"fx": {"blink":9,"camouflage":1,"dodge":8,"dp":5,"explode":1,"garrison":3,"slash":8},
"snaps": "be6d1821 33db37f0 a6ec1fa2 487058c3 2aef7534 379bf51a d87bcaba 6953291e ea8de47f e0cfe2ef c5d6d6f0 9fd5858c 99048d37 dfd46fb1 d86de307 637bf595 72c11ba9 adb364dc bb18d903"
},
"standin-07": {
"about": "elite band 6 as stand-ins: chess_char_4_17_b→char_610_acfend chess_char_4_18_b→char_602_cdfend chess_char_5_05_b→char_602_cdfend chess_char_4_19_b→char_608_acpion chess_char_5_01_b→char_600_cpione chess_char_4_22_b→char_609_acguad chess_char_4_23_b→char_609_acguad chess_char_5_06_b→char_609_acguad chess_char_4_20_b→char_603_csnipe chess_char_4_24_b→char_604_ccast chess_char_5_02_b→char_604_ccast chess_char_5_03_b→char_612_accast",
@@ -344,14 +356,14 @@
"round": 12,
"seed": 816286799,
"waveId": "act1autochess_h05",
"time": 223.57,
"ticks": 6707,
"time": 223.2,
"ticks": 6696,
"reason": "cleared",
"killed": 10,
"total": 80,
"unspawned": 0,
"bossHpLeft": null,
"rngDraws": 81,
"rngDraws": 84,
"errors": {"count":0,"kinds":[]},
"players": {
"p1": {
@@ -361,7 +373,7 @@
"leaks": 70,
"leaksUncounted": 0,
"coins": 4,
"dmg": 143200,
"dmg": 144421,
"bossDmg": 0,
"heal": 0,
"deaths": 25,
@@ -372,35 +384,35 @@
["p1",1,"op","chess_char_4_17_b",1,"none",0,0,3,0,189,3211,0,1,1],
["p1",2,"op","chess_char_4_18_b",1,"none",0,0,86,0,60,9306,0,2,1],
["p1",3,"op","chess_char_5_05_b",2,"none",0,0,100,0,572,5816,0,1,1],
["p1",4,"op","chess_char_4_19_b",1,"none",1847.63,1,110,0,230,3864,0,7,1],
["p1",5,"op","chess_char_5_01_b",2,"none",1036.53,1,108,1,2433,3985,0,6,3],
["p1",6,"op","chess_char_4_22_b",1,"none",3191,1,112,0,959,14040,0,6,1],
["p1",4,"op","chess_char_4_19_b",1,"none",1847.63,1,110,1,466,3864,0,8,1],
["p1",5,"op","chess_char_5_01_b",2,"none",1036.53,1,108,0,2197,3985,0,5,3],
["p1",6,"op","chess_char_4_22_b",1,"none",3191,1,112,0,1090,14040,0,8,1],
["p1",7,"op","chess_char_4_23_b",1,"none",0,0,71,0,3484,6865,0,8,1],
["p1",8,"op","chess_char_5_06_b",2,"uniequip_002_acguad",0,0,10,0,0,3820,0,0,0],
["p1",9,"op","chess_char_4_20_b",1,"none",1255.06,1,109,2,15968,4785,0,30,1],
["p1",10,"op","chess_char_4_24_b",1,"none",1007.59,1,111,1,5553,3072,0,19,1],
["p1",9,"op","chess_char_4_20_b",1,"none",1255.06,1,109,2,15866,4785,0,30,1],
["p1",10,"op","chess_char_4_24_b",1,"none",1007.59,1,111,1,5309,3072,0,18,1],
["p1",11,"op","chess_char_5_02_b",2,"none",0,0,105,1,8684,4080,0,19,1],
["p1",12,"op","chess_char_5_03_b",2,"uniequip_002_accast",0,0,4,5,105067,1749,0,10,1],
["p1",12,"op","chess_char_5_03_b",2,"uniequip_002_accast",0,0,4,5,106504,1749,0,10,1],
["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null],
["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null]
],
"enemies": {
"enemy_1005_yokai_2": [18,3,15,3234,10312],
"enemy_1041_lazerd_2": [24,3,21,43648,33749],
"enemy_1042_frostd": [30,2,28,0,34315],
"enemy_1045_hammer": [1,1,0,2029,15552],
"enemy_1045_hammer_2": [1,0,1,3891,18162],
"enemy_1046_agent": [1,1,0,72,3888],
"enemy_1050_lslime": [1,0,1,9027,5281],
"enemy_1005_yokai_2": [18,4,14,3323,11182],
"enemy_1041_lazerd_2": [24,3,21,42917,34367],
"enemy_1042_frostd": [30,1,29,0,33047],
"enemy_1045_hammer": [1,1,0,2515,15552],
"enemy_1045_hammer_2": [1,0,1,5062,16522],
"enemy_1046_agent": [1,1,0,57,3888],
"enemy_1050_lslime": [1,0,1,8323,7921],
"enemy_1061_zomshd": [1,0,1,61,13892],
"enemy_1062_rager_2": [1,0,1,2632,17550],
"enemy_1062_rager_2": [1,0,1,2335,17050],
"enemy_1067_snslime": [1,0,1,0,0],
"enemy_1071_dftman": [1,0,1,0,0]
},
"hooks": [1,112,6707,296,296,579,572,568,35,105,13,13,0,9191,8,15,80,70,1,35,2,0,7],
"events": {"atk":322,"bounty":2,"deploy":30,"die":35,"dmg":572,"engage":11,"fx":22,"layer":2,"leak":70,"skill":26,"spawn":94,"status":113},
"fx": {"aoe":1,"dodge":7,"dp":6,"garrison":2,"garrisonGrant":1,"palsy":2,"revive":3},
"snaps": "801e90ec 4ebbd3e8 f780653d ed5ed324 bd024323 babc565e 46a42524 cf9f3776 b854d31f 975c3869 9b059794 4a0e03a1 6e53c58b 0baee204 14791829 482e0160 46afd15b 48ab4dc8 11b59232 d2527d0d 9a65bb0e ae5b68b3 5e9c3da0"
"hooks": [1,112,6696,302,302,560,551,566,35,105,13,13,0,9244,8,13,80,70,1,35,2,0,9],
"events": {"atk":326,"bounty":2,"deploy":30,"die":35,"dmg":551,"engage":11,"fx":25,"layer":2,"leak":70,"skill":26,"spawn":94,"status":113},
"fx": {"aoe":1,"dodge":9,"dp":7,"garrison":2,"garrisonGrant":1,"palsy":2,"revive":3},
"snaps": "801e90ec c1cca6cb 81d33420 f867f133 edacd763 1628b012 6c63447d 731a52ce e4d1fee0 1aa30026 5afb0d3a 3292f917 f65a695f 7f9749da 215a493b 0a35d6fd 8300297d 71eb2c83 2ae65721 93a77bf5 f413d9ea 3b2f4b8c ef24d1bf"
},
"standin-08": {
"about": "elite band 6 as stand-ins: chess_char_5_08_b→char_610_acfend chess_char_5_14_b→char_608_acpion chess_char_5_19_b→char_617_sharp2 chess_char_5_07_b→char_601_cguard chess_char_5_13_b→char_609_acguad chess_char_5_17_b→char_615_acspec chess_char_5_10_b→char_606_csuppo chess_char_5_11_b→char_613_acmedc chess_char_5_12_b→char_614_acsupo chess_char_5_15_b→char_603_csnipe chess_char_5_18_b→char_611_acnipe chess_char_5_20_b→char_614_acsupo",
@@ -411,50 +423,50 @@
"round": 12,
"seed": 2456427706,
"waveId": "act1autochess_h05",
"time": 226.63,
"ticks": 6799,
"time": 214.67,
"ticks": 6440,
"reason": "cleared",
"killed": 42,
"killed": 21,
"total": 179,
"unspawned": 0,
"bossHpLeft": null,
"rngDraws": 981,
"rngDraws": 964,
"errors": {"count":0,"kinds":[]},
"players": {"p1":{"killed":42,"total":179,"perfect":false,"leaks":137,"leaksUncounted":0,"coins":2,"dmg":1733157,"bossDmg":0,"heal":77968,"deaths":13,"layerGains":{}}},
"players": {"p1":{"killed":21,"total":179,"perfect":false,"leaks":158,"leaksUncounted":0,"coins":2,"dmg":1381372,"bossDmg":0,"heal":100047,"deaths":13,"layerGains":{}}},
"units": [
["p1",1,"op","chess_char_5_08_b",2,"uniequip_002_acfend",3242.12,1,7,5,122124,8280,0,159,4],
["p1",2,"op","chess_char_5_14_b",2,"uniequip_002_acpion",1904,1,201,14,520028,10508,0,46,6],
["p1",3,"op","chess_char_5_19_b",0,"uniequip_002_sharp2",4154.5,1,207,5,264095,31935,0,79,3],
["p1",4,"op","chess_char_5_07_b",2,"none",3334,1,205,2,42880,20656,0,36,3],
["p1",5,"op","chess_char_5_13_b",2,"uniequip_002_acguad",3411,1,206,4,80947,12473,0,40,2],
["p1",6,"op","chess_char_5_17_b",2,"uniequip_002_acspec",1448,1,188,4,90888,27092,0,116,0],
["p1",7,"op","chess_char_5_10_b",2,"none",1131,1,4,2,64373,0,0,134,4],
["p1",8,"op","chess_char_5_11_b",2,"uniequip_002_acmedc",2262.4,1,5,0,0,0,77968,77,3],
["p1",9,"op","chess_char_5_12_b",2,"uniequip_002_acsupo",1430,1,11,3,105459,0,0,135,3],
["p1",10,"op","chess_char_5_15_b",2,"none",1510,1,13,0,83399,0,0,215,4],
["p1",11,"op","chess_char_5_18_b",2,"uniequip_002_acnipe",2380.5,1,6,3,266479,0,0,178,3],
["p1",12,"op","chess_char_5_20_b",2,"uniequip_002_acsupo",1430,1,14,0,92485,0,0,125,3],
["p1",1,"op","chess_char_5_08_b",2,"uniequip_002_acfend",0,0,7,2,70503,39277,0,119,3],
["p1",2,"op","chess_char_5_14_b",2,"uniequip_002_acpion",0,0,196,2,237060,10276,0,19,3],
["p1",3,"op","chess_char_5_19_b",0,"uniequip_002_sharp2",4154.5,1,204,3,203174,20994,0,54,2],
["p1",4,"op","chess_char_5_07_b",2,"none",3334,1,202,2,41436,22288,0,35,3],
["p1",5,"op","chess_char_5_13_b",2,"uniequip_002_acguad",0,0,131,4,81334,12863,0,40,2],
["p1",6,"op","chess_char_5_17_b",2,"uniequip_002_acspec",1448,1,122,3,87842,29575,0,142,0],
["p1",7,"op","chess_char_5_10_b",2,"none",1131,1,4,0,72698,0,0,145,4],
["p1",8,"op","chess_char_5_11_b",2,"uniequip_002_acmedc",2262.4,1,5,0,0,0,100047,73,3],
["p1",9,"op","chess_char_5_12_b",2,"uniequip_002_acsupo",1430,1,11,1,90852,0,0,128,3],
["p1",10,"op","chess_char_5_15_b",2,"none",1510,1,13,0,87700,0,0,231,4],
["p1",11,"op","chess_char_5_18_b",2,"uniequip_002_acnipe",2380.5,1,6,4,316532,0,0,185,3],
["p1",12,"op","chess_char_5_20_b",2,"uniequip_002_acsupo",1430,1,14,0,92242,0,0,124,3],
["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null],
["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null],
["-","~3","device","trap_1105_accrate",null,null,100,1,3,0,0,0,0,0,null]
],
"enemies": {
"enemy_1072_dlancer": [1,1,0,1609,37150],
"enemy_1081_sotisd": [1,0,1,749,28802],
"enemy_1092_mdgint": [1,0,1,0,0],
"enemy_1116_liprr": [1,0,1,0,7850],
"enemy_1116_liprr_2": [1,0,1,0,1829],
"enemy_1081_sotisd": [1,0,1,0,27240],
"enemy_1092_mdgint": [1,0,1,0,606],
"enemy_1116_liprr": [1,0,1,0,3570],
"enemy_1116_liprr_2": [1,0,1,0,2364],
"enemy_1118_lidbox_2": [1,0,1,0,809],
"enemy_1119_vofsd": [1,0,1,0,889],
"enemy_1121_lifbos": [1,0,1,4806,7013],
"enemy_1249_lysdb_2": [60,18,42,37448,866144],
"enemy_1251_lysyta": [63,22,41,7583,520464],
"enemy_1254_lypa_2": [48,1,47,58748,262208]
"enemy_1119_vofsd": [1,0,1,0,919],
"enemy_1121_lifbos": [1,0,1,1448,5072],
"enemy_1249_lysdb_2": [60,8,52,39217,619286],
"enemy_1251_lysyta": [63,12,51,4824,426834],
"enemy_1254_lypa_2": [48,0,48,88175,257522]
},
"hooks": [1,207,6799,1790,1790,3161,3225,149,55,192,38,43,0,26658,17254,63,179,137,1,55,0,0,6],
"events": {"atk":2022,"bounty":1,"deploy":25,"die":55,"dmg":3200,"engage":11,"fx":302,"heal":133,"leak":137,"skill":88,"spawn":194,"status":1230},
"fx": {"aoe":120,"displace":8,"dodge":31,"dp":20,"liberate":1,"phase":1,"shieldBreak":60,"slash":48,"sp":1,"spGain":12},
"snaps": "c4ba1bfb fae78ec3 4853328f 674d8548 bcbe6142 1a53e0eb 214c2462 a608c08f 504eb3ba 4e0e6e68 d4d1daf8 a6dc8003 1ce13419 a363753a 07e954ca 3e70563d 7e686001 0f4877a6 ba4f9efe e6778a6d 6675e221 520b5fe5 4464a0d3"
"hooks": [1,204,6440,1716,1716,2812,2842,139,34,192,33,34,0,22299,16472,43,179,158,1,34,0,0,5],
"events": {"atk":1954,"bounty":1,"deploy":22,"die":34,"dmg":2823,"engage":11,"fx":218,"heal":112,"leak":158,"skill":72,"spawn":194,"status":1233},
"fx": {"aoe":90,"displace":6,"dodge":22,"dp":5,"shieldBreak":59,"slash":24,"spGain":12},
"snaps": "c31057ad 32bd73d4 3d2277e9 f67b1fc5 1447b8f6 7c45e488 90e4af74 d4c9b4df 6903fd8a 2b8e8161 bee60241 1666f57f b7fe3a52 2e85cd5e bab57ab2 e6ed121a 8f131939 e4a0c14c c3a4cc3c 0ffa3610 9ae55880 0d548569"
},
"standin-09": {
"about": "elite band 7 as stand-ins: chess_char_6_03_b→char_610_acfend chess_char_6_02_b→char_608_acpion chess_char_6_07_b→char_617_sharp2 chess_char_6_10_b→char_617_sharp2 chess_char_6_11_b→char_608_acpion chess_char_6_06_b→char_609_acguad chess_char_5_22_b→char_611_acnipe chess_char_6_01_b→char_611_acnipe chess_char_6_04_b→char_614_acsupo chess_char_6_05_b→char_611_acnipe chess_char_6_08_b→char_612_accast chess_char_6_09_b→char_613_acmedc",
@@ -465,31 +477,31 @@
"round": 13,
"seed": 2308517178,
"waveId": "act1autochess_h06",
"time": 198.43,
"ticks": 5953,
"time": 199.07,
"ticks": 5972,
"reason": "cleared",
"killed": 158,
"total": 158,
"unspawned": 0,
"bossHpLeft": null,
"rngDraws": 232,
"rngDraws": 236,
"errors": {"count":0,"kinds":[]},
"players": {
"p1": {"killed":158,"total":158,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":8,"dmg":924222,"bossDmg":0,"heal":29638,"deaths":1,"layerGains":{"sargonShip":20}}
"p1": {"killed":158,"total":158,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":8,"dmg":924222,"bossDmg":0,"heal":29282,"deaths":1,"layerGains":{"sargonShip":20}}
},
"units": [
["p1",1,"op","chess_char_6_03_b",2,"uniequip_002_acfend",4974.95,1,8,0,0,0,0,0,0],
["p1",2,"op","chess_char_6_02_b",2,"uniequip_002_acpion",1904,1,6,0,0,0,0,0,0],
["p1",3,"op","chess_char_6_07_b",0,"uniequip_002_sharp2",2494,1,9,0,0,0,0,0,0],
["p1",4,"op","chess_char_6_10_b",0,"uniequip_002_sharp2",2494,1,11,27,129672,0,0,173,3],
["p1",5,"op","chess_char_6_11_b",2,"uniequip_002_acpion",1904,1,13,35,186014,11957,0,60,20],
["p1",6,"op","chess_char_6_06_b",2,"uniequip_002_acguad",3531,1,14,18,61248,763,0,155,4],
["p1",4,"op","chess_char_6_10_b",0,"uniequip_002_sharp2",2494,1,11,16,123097,0,0,174,3],
["p1",5,"op","chess_char_6_11_b",2,"uniequip_002_acpion",1834,1,13,46,173899,11626,0,64,21],
["p1",6,"op","chess_char_6_06_b",2,"uniequip_002_acguad",3531,1,14,10,57010,808,0,155,4],
["p1",7,"op","chess_char_5_22_b",2,"uniequip_002_acnipe",1587,1,4,0,0,0,0,0,0],
["p1",8,"op","chess_char_6_01_b",2,"uniequip_002_acnipe",1643,1,5,0,948,0,0,4,1],
["p1",9,"op","chess_char_6_04_b",2,"uniequip_002_acsupo",2465,1,141,7,73191,2465,0,92,2],
["p1",10,"op","chess_char_6_05_b",2,"uniequip_002_acnipe",1643,1,7,20,89868,0,0,93,2],
["p1",11,"op","chess_char_6_08_b",2,"uniequip_002_accast",1819,1,10,51,383282,0,0,78,2],
["p1",12,"op","chess_char_6_09_b",2,"uniequip_002_acmedc",1394,1,12,0,0,16988,29638,83,3],
["p1",8,"op","chess_char_6_01_b",2,"uniequip_002_acnipe",1643,1,5,0,1136,0,0,4,1],
["p1",9,"op","chess_char_6_04_b",2,"uniequip_002_acsupo",2465,1,141,7,70868,2465,0,92,2],
["p1",10,"op","chess_char_6_05_b",2,"uniequip_002_acnipe",1643,1,7,21,100965,0,0,95,2],
["p1",11,"op","chess_char_6_08_b",2,"uniequip_002_accast",1819,1,10,58,397247,0,0,81,2],
["p1",12,"op","chess_char_6_09_b",2,"uniequip_002_acmedc",1324,1,12,0,0,17058,29282,83,3],
["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null],
["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null],
["-","~3","device","trap_1105_accrate",null,null,100,1,3,0,0,0,0,0,null]
@@ -505,12 +517,12 @@
"enemy_1161_tidmag_2": [1,1,0,1758,7680],
"enemy_1249_lysdb_2": [60,60,0,2085,345600],
"enemy_1251_lysyta": [51,51,0,0,134640],
"enemy_1329_cbshld": [39,39,0,2290,374400]
"enemy_1329_cbshld": [39,39,0,2143,374400]
},
"hooks": [1,174,5953,812,812,1599,1718,168,159,159,37,36,0,29335,2593,62,158,0,1,159,3,1,17],
"events": {"atk":917,"bounty":4,"deploy":13,"die":159,"dmg":1707,"engage":7,"fx":298,"heal":127,"layer":3,"skill":73,"spawn":173,"status":324},
"fx": {"aoe":2,"burst":1,"dodge":19,"dp":55,"garrison":3,"shieldBreak":60,"slash":144,"sp":12,"strike":2},
"snaps": "526d4c63 f61576c6 e1be6631 53288d14 7de80a4f 26575d3b 14b2dc7b 09bff769 03f7af9b 266de1dd a9bf82a4 d80af3de da82ae39 706186e0 9d862b1f bcccab65 19598dab ba7df686 a486add4 e9e501f2"
"hooks": [1,174,5972,824,824,1576,1711,169,159,159,38,37,0,29559,2586,66,158,0,1,159,3,1,13],
"events": {"atk":932,"bounty":4,"deploy":13,"die":159,"dmg":1694,"engage":7,"fx":305,"heal":128,"layer":3,"skill":75,"spawn":173,"status":321},
"fx": {"aoe":2,"burst":1,"dodge":15,"dp":67,"garrison":3,"shieldBreak":60,"slash":145,"sp":12},
"snaps": "526d4c63 f61576c6 6bbd0cd4 7174c0e8 a2878fe6 eb717890 641bc6bd dea6ff24 12805446 5bced5fc d42fb8a0 6da05c89 1cb24ef4 0bc3d3a8 0d40f7b7 4febd78a 059a9cdd af2722be f07b6e0b 15259ad8"
},
"standin-10": {
"about": "elite band 7 as stand-ins: chess_char_6_17_b→char_608_acpion chess_char_6_13_b→char_615_acspec chess_char_6_19_b→char_615_acspec chess_char_6_12_b→char_612_accast chess_char_6_15_b→char_614_acsupo chess_char_6_18_b→char_612_accast chess_char_6_20_b→char_613_acmedc",
@@ -524,43 +536,43 @@
"time": 230,
"ticks": 6900,
"reason": "timeout",
"killed": 28,
"killed": 35,
"total": 131,
"unspawned": 0,
"bossHpLeft": null,
"rngDraws": 263,
"rngDraws": 294,
"errors": {"count":0,"kinds":[]},
"players": {
"p1": {"killed":28,"total":131,"perfect":false,"leaks":103,"leaksUncounted":0,"coins":2,"dmg":538265,"bossDmg":0,"heal":10974,"deaths":25,"layerGains":{"raidShip":6}}
"p1": {"killed":35,"total":131,"perfect":false,"leaks":96,"leaksUncounted":0,"coins":2,"dmg":619321,"bossDmg":0,"heal":10342,"deaths":25,"layerGains":{"raidShip":6}}
},
"units": [
["p1",1,"op","chess_char_6_17_b",2,"uniequip_002_acpion",954.91,1,164,10,211581,13444,0,77,10],
["p1",2,"op","chess_char_6_13_b",2,"uniequip_002_acspec",0,0,165,5,46991,13356,0,106,0],
["p1",3,"op","chess_char_6_19_b",2,"uniequip_002_acspec",1484,1,166,4,75038,16324,0,138,0],
["p1",1,"op","chess_char_6_17_b",2,"uniequip_002_acpion",1904,1,164,17,294164,12495,0,72,11],
["p1",2,"op","chess_char_6_13_b",2,"uniequip_002_acspec",0,0,162,6,48878,11872,0,120,0],
["p1",3,"op","chess_char_6_19_b",2,"uniequip_002_acspec",0,0,165,3,73010,17808,0,155,0],
["p1",4,"op","chess_char_6_12_b",2,"uniequip_002_accast",2265.49,1,4,4,78264,281,0,32,2],
["p1",5,"op","chess_char_6_15_b",2,"uniequip_002_acsupo",0,0,147,2,54506,6494,0,39,3],
["p1",6,"op","chess_char_6_18_b",2,"uniequip_002_accast",1819,1,8,3,71885,900,0,43,2],
["p1",7,"op","chess_char_6_20_b",2,"uniequip_002_acmedc",1464,1,9,0,0,4263,10974,19,1],
["p1",5,"op","chess_char_6_15_b",2,"uniequip_002_acsupo",0,0,148,2,54506,6494,0,39,3],
["p1",6,"op","chess_char_6_18_b",2,"uniequip_002_accast",1819,1,8,3,70499,618,0,43,2],
["p1",7,"op","chess_char_6_20_b",2,"uniequip_002_acmedc",1464,1,9,0,0,3912,10342,19,1],
["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null],
["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null]
],
"enemies": {
"enemy_1006_shield_3": [21,1,20,16907,164634],
"enemy_1043_zomsbr": [66,22,44,908,136175],
"enemy_1162_magmot": [1,0,1,4704,11483],
"enemy_1006_shield_3": [21,1,20,14897,198077],
"enemy_1043_zomsbr": [66,27,39,736,157473],
"enemy_1162_magmot": [1,0,1,4072,11483],
"enemy_1165_duhond": [1,1,0,93,4645],
"enemy_1165_duhond_2": [1,0,1,0,0],
"enemy_1166_dusbr": [1,0,1,0,0],
"enemy_1166_dusbr_2": [1,0,1,0,0],
"enemy_1168_dumage": [1,1,0,1897,7465],
"enemy_1168_dumage_2": [1,1,0,4932,10783],
"enemy_1169_duphlx": [1,0,1,0,1489],
"enemy_1402_tgshd_2": [36,2,33,25620,201592]
"enemy_1168_dumage": [1,1,0,1476,7465],
"enemy_1168_dumage_2": [1,1,0,2802,10783],
"enemy_1169_duphlx": [1,1,0,0,6636],
"enemy_1402_tgshd_2": [36,3,32,29405,222760]
},
"hooks": [1,166,6900,578,578,900,914,7048,53,158,18,38,0,8737,1167,48,131,102,1,53,1,1,0],
"events": {"atk":585,"bounty":1,"deploy":33,"die":56,"dmg":848,"engage":6,"fx":539,"heal":25,"layer":1,"leak":102,"skill":78,"spawn":140,"status":384},
"fx": {"aoe":4,"blink":3,"burst":1,"displace":18,"dodge":1,"dp":20,"expose":414,"garrison":1,"garrisonGrant":3,"slash":74},
"snaps": "fba27448 40f8ddeb 904aa1c4 7f5fd3e3 9795959e b8c7a6c7 f5b1092e b4109451 8082d0cc a86cc61b fb1870c6 9cf94d5d 1d3df05d ceb38c64 2d46f54e 5fd7022a d8ef7fcc 55250098 09b8ad07 d6c97808 bbb9cfcc a2163482 34deba04"
"hooks": [1,165,6900,601,601,972,984,7120,60,158,19,39,0,8732,1171,48,131,95,1,60,1,1,0],
"events": {"atk":606,"bounty":1,"deploy":32,"die":63,"dmg":914,"engage":6,"fx":616,"heal":23,"layer":1,"leak":95,"skill":78,"spawn":140,"status":380},
"fx": {"aoe":4,"blink":3,"burst":1,"displace":21,"dodge":2,"dp":28,"expose":471,"garrison":1,"garrisonGrant":3,"slash":82},
"snaps": "cecbf93c f496d3e4 d807c314 abf565d9 f7b9d0bb ee3cb80c 81f317ce d42a4241 ff69a55b 34e7bc7d 5062a64e 2487e36c 4d49be6c 617a5f43 281d4afe e2d98f64 0bab3df5 64a0694f 515878f6 902a19c4 f21a0d0e 8507b4d3 06359f6f"
}
}
}
+8 -1
View File
@@ -366,8 +366,15 @@ export function checkInvariants(b) {
for (const pr of b.projectiles.list) if (![pr.x, pr.y, pr.tx, pr.ty].every(Number.isFinite)) bad(`projectile at ${pr.x},${pr.y} → ${pr.tx},${pr.ty}`);
if (b._emitDepth) bad(`hook emit depth ${b._emitDepth} between steps`);
for (const pp of Object.values(b._perPlayer ?? {})) {
for (const k of ['killed', 'total', 'coins', 'damageDealt', 'bossDamage', 'healingDone', 'deaths']) if (!Number.isFinite(pp[k])) bad(`result ${k}=${pp[k]}`);
for (const k of ['killed', 'total', 'killedInTotal', 'leakedInTotal', 'coins', 'damageDealt', 'bossDamage', 'healingDone', 'deaths']) if (!Number.isFinite(pp[k])) bad(`result ${k}=${pp[k]}`);
// The capsule's counters are attributed differently on purpose: a knock-out goes to the enemy's owner (as its `total`
// did), while a leak goes to the player whose half the enemy reached (`_recordLeak`) — in a 联防 field an enemy
// spawned on one half and leaked on the other bills the leaker, so per player only `killedInTotal ≤ total` holds
// (BattleResult's per-player `resolved` clamps the sum to `total`, and the battle-level sum is exact)
if (pp.killedInTotal > pp.total) bad(`capsule ${pp.killedInTotal} > ${pp.total}`);
if (pp.total < 0 || pp.killedInTotal < 0 || pp.leakedInTotal < 0) bad('negative capsule counter');
}
if (b.killedInTotal + b.leakedInTotal > b.total) bad(`battle capsule ${b.killedInTotal}+${b.leakedInTotal} > ${b.total}`);
return true;
}
+3 -3
View File
@@ -95,13 +95,13 @@ test('data/i18n/en.json: shape, the 盟约 season of the EN build, coverage and
assert.equal(OVERLAY.version, 1);
assert.equal(OVERLAY.lang, 'en');
assert.equal(OVERLAY.meta.source.season, 'act2autochess', 'built from an EN client that has 卫戍协议:盟约 下半');
// the 自选 data (0.2.0) brings 71 owned 6★, some of them (and some newer modules) not in the EN client yet: their
// names, skills, talents and modules stay Chinese — measured 97.6 % in all, skills 95.9 / talents 95.1 % (2026-10-05)
// 0.2.3 adds Clementia before her EN release: official untranslated names/skills/talents remain Chinese.
// Measured 2026-10-09: total 97.4%, skills 95.9%, talents 94.9%; other category floors stay unchanged.
assert.ok(OVERLAY.meta.totals.pct >= 97, `coverage ${OVERLAY.meta.totals.pct} %`);
for (const kind of ['operators', 'skills', 'talents', 'modules', 'traits', 'enemies', 'bonds', 'items', 'effects', 'bands', 'garrisons', 'stages', 'tokens', 'choices', 'config']) {
const c = OVERLAY.meta.coverage[kind];
assert.ok(c && c.texts > 0, `coverage of ${kind}`);
assert.ok(c.pct >= (['operators', 'skills', 'talents', 'modules'].includes(kind) ? 95 : 98), `${kind}: ${c.pct} %`);
assert.ok(c.pct >= (kind === 'talents' ? 94.9 : ['operators', 'skills', 'modules'].includes(kind) ? 95 : 98), `${kind}: ${c.pct} %`);
}
assert.deepEqual(Object.keys(OVERLAY.files).filter((f) => !DATA_FILES.includes(f)), []);
assert.equal(OVERLAY.names['琳琅诗怀雅'], 'Swire the Elegant Wit');
+208
View File
@@ -0,0 +1,208 @@
// test/ipv6-bind.test.js — dual-stack listen (accepted as #188, ported from PR #291 without /dev/grant,
// the settings migration, the firewall scripts, or a Dockerfile HOST change).
//
// With HOST unset the server binds '::' (Node leaves ipv6Only off, so one socket answers IPv6 and IPv4).
// That default is the only host retried, and only on EAFNOSUPPORT / EADDRNOTAVAIL / EINVAL. An explicit HOST,
// including 0.0.0.0 and 127.0.0.1, is tried once. The local URL brackets an IPv6 literal. The image stays on
// 0.0.0.0 until an alpine boot of the fallback has actually been run.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import http from 'node:http';
import net from 'node:net';
import { readFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { classifyAddresses, hostUrl } from '../tools/doctor.mjs';
import { DEFAULT_BIND_HOST, listenAddress, bindCandidates } from '../server/http/config.js';
import { displayHost } from '../server/http/boot.js';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const doc = (p) => readFileSync(join(ROOT, p), 'utf8');
function restoreHost(saved) {
if (saved === undefined) delete process.env.HOST;
else process.env.HOST = saved;
}
test('the listen entry points default to ::, and the image stays on IPv4', () => {
assert.match(doc('server/http/config.js'), /export const DEFAULT_BIND_HOST = '::';/);
assert.match(doc('server/http/config.js'), /opts\.host \|\| process\.env\.HOST\) \|\| DEFAULT_BIND_HOST/);
assert.match(doc('server/http/boot.js'), /for \(const u of lanUrls\(srv\.port\)\)/);
assert.match(doc('scripts/run-server.cmd'), /^\s*set "HOST=::"$/m);
assert.match(doc('scripts/install-service-windows.ps1'), /\[string\]\$BindHost = '::',/);
assert.match(doc('scripts/install-service-windows.ps1'), /Select-String -Pattern 'http:\/\/'/);
assert.doesNotMatch(doc('scripts/install-service-windows.ps1'), /http:\/\/\\d/);
assert.match(doc('scripts/launch.mjs'), /process\.env\.HOST \|\| '::'/);
assert.match(doc('tools/doctor.mjs'), /process\.env\.HOST \|\| '::'/);
assert.match(doc('README.md'), /\| `HOST` \| `::` \|/);
assert.match(doc('Dockerfile'), /HOST=0\.0\.0\.0/);
assert.doesNotMatch(doc('Dockerfile'), /HOST=::/);
});
test('listenAddress: the option wins over the environment, the environment over the default', () => {
const saved = process.env.HOST;
try {
delete process.env.HOST;
assert.deepEqual(listenAddress({}), { port: 3000, host: DEFAULT_BIND_HOST });
assert.equal(listenAddress({ host: '' }).host, DEFAULT_BIND_HOST, 'an empty host is no host');
process.env.HOST = '127.0.0.1';
assert.equal(listenAddress({}).host, '127.0.0.1', 'HOST is kept when it is set');
assert.equal(listenAddress({ host: '0.0.0.0' }).host, '0.0.0.0', 'the option wins over HOST');
} finally {
restoreHost(saved);
}
assert.equal(listenAddress({ port: 0 }).port, 0, 'port 0 is a real port (an ephemeral one)');
assert.equal(listenAddress({ host: '127.0.0.1' }).host, '127.0.0.1');
assert.throws(() => listenAddress({ port: 70000 }), RangeError);
});
test('bindCandidates: only the default is retried, an explicit host is literal', () => {
assert.deepEqual(bindCandidates(DEFAULT_BIND_HOST), ['::', '0.0.0.0']);
assert.deepEqual(bindCandidates('0.0.0.0'), ['0.0.0.0']);
assert.deepEqual(bindCandidates('127.0.0.1'), ['127.0.0.1']);
assert.deepEqual(bindCandidates('192.168.1.7'), ['192.168.1.7']);
assert.deepEqual(bindCandidates('2001:db8::1'), ['2001:db8::1']);
});
test('an IPv6 literal is bracketed in a URL, a wildcard bind reads localhost', () => {
assert.equal(hostUrl('192.168.1.7', 3000), 'http://192.168.1.7:3000');
assert.equal(hostUrl('100.64.0.9', 3000), 'http://100.64.0.9:3000');
assert.equal(hostUrl('240e:3b7:8c4:40f0::1000', 3000), 'http://[240e:3b7:8c4:40f0::1000]:3000');
assert.equal(hostUrl('fe80::320d:9eff:fe07:e79a', 8080), 'http://[fe80::320d:9eff:fe07:e79a]:8080');
assert.equal(displayHost('0.0.0.0'), 'localhost');
assert.equal(displayHost('::'), 'localhost');
assert.equal(displayHost('::1'), '[::1]');
assert.equal(displayHost('2001:db8::1'), '[2001:db8::1]');
assert.equal(displayHost('127.0.0.1'), '127.0.0.1');
});
test('classifyAddresses: IPv6 kinds, and one entry per /64', () => {
const v6 = (name, ...addresses) => [name, addresses.map((address) => ({ family: 'IPv6', address, internal: false }))];
const list = classifyAddresses(Object.fromEntries([
v6('以太网', 'fe80::1', '240e:3b7:8c4:40f0:bb2b:c23f:a265:a95d', '240e:3b7:8c4:40f0:7c4a:8d15:f95:fc16', 'fd00::5'),
v6('vEthernet (Default Switch)', '2001:db8::1', '2002:c0a8:101::1'),
v6('Tailscale', 'fd7a:115c:a1e0::1'),
['Loopback Pseudo-Interface 1', [{ family: 'IPv6', address: '::1', internal: true }]],
]));
const kindOf = (address) => list.find((a) => a.address === address)?.kind;
assert.equal(kindOf('240e:3b7:8c4:40f0:bb2b:c23f:a265:a95d'), 'public');
assert.equal(kindOf('fe80::1'), 'linklocal');
assert.equal(kindOf('fd00::5'), 'lan');
assert.equal(kindOf('fd7a:115c:a1e0::1'), 'vpn');
assert.equal(kindOf('2001:db8::1'), 'virtual');
assert.equal(kindOf('2002:c0a8:101::1'), 'virtual');
assert.equal(list.filter((a) => a.kind === 'public').length, 1, 'the two addresses of one /64 collapse into one');
assert.ok(!list.some((a) => a.address === '::1'), 'loopback is internal');
});
test('lanUrls lists IPv4 first, brackets IPv6, and drops link-local', async () => {
const { lanUrls } = await import('../server/index.js');
const urls = lanUrls(3000);
for (const u of urls) assert.match(u, /^http:\/\/(\[[0-9a-fA-F:]+\]|\d{1,3}(?:\.\d{1,3}){3}):3000$/, u);
assert.ok(!urls.some((u) => u.includes('[fe80:')), 'no link-local URLs');
const firstV6 = urls.findIndex((u) => u.includes('['));
if (firstV6 > 0) assert.ok(urls.slice(0, firstV6).every((u) => !u.includes('[')), 'IPv4 comes before IPv6');
});
test('the default bind is one dual-stack socket, and an explicit host is not rewritten', async (t) => {
const hasV6 = await new Promise((resolve) => {
const probe = net.createServer();
probe.once('error', () => resolve(false));
probe.listen(0, '::', () => probe.close(() => resolve(true)));
});
if (!hasV6) { t.skip('this host has no IPv6'); return; }
const saved = process.env.HOST;
delete process.env.HOST;
const { startServer } = await import('../server/index.js');
const get = (srv, host) => new Promise((resolve) => {
const req = http.get({ host, port: srv.port, path: '/healthz' }, (res) => { res.resume(); resolve(res.statusCode); });
req.on('error', (e) => resolve(e.code || 0));
});
const dual = await startServer({ port: 0, quiet: true });
try {
assert.equal(dual.host, '::');
assert.equal(dual.server.address().family, 'IPv6');
assert.match(dual.url, /^http:\/\/localhost:\d+$/);
assert.equal(await get(dual, '::1'), 200);
assert.equal(await get(dual, '127.0.0.1'), 200);
} finally {
await dual.close();
}
const loop = await startServer({ port: 0, host: '::1', quiet: true });
try {
assert.equal(loop.host, '::1');
assert.match(loop.url, /^http:\/\/\[::1\]:\d+$/);
assert.equal(await get(loop, '::1'), 200);
} finally {
await loop.close();
}
const v4 = await startServer({ port: 0, host: '0.0.0.0', quiet: true });
try {
assert.equal(v4.host, '0.0.0.0');
assert.match(v4.url, /^http:\/\/localhost:\d+$/);
assert.equal(await get(v4, '127.0.0.1'), 200);
assert.notEqual(await get(v4, '::1'), 200, 'HOST=0.0.0.0 stays IPv4 only');
} finally {
await v4.close();
restoreHost(saved);
}
});
test('when :: cannot be bound, the default falls back to 0.0.0.0; EADDRINUSE does not', async () => {
const proto = http.Server.prototype;
const orig = proto.listen;
const saved = process.env.HOST;
delete process.env.HOST;
const { startServer } = await import('../server/index.js');
const stub = (failCode) => {
const tried = [];
proto.listen = function (port, host, ...rest) {
tried.push(host);
if (host === '::') {
const err = Object.assign(new Error(failCode), { code: failCode });
process.nextTick(() => this.emit('error', err));
return this;
}
return orig.call(this, port, host, ...rest);
};
return tried;
};
try {
const tried = stub('EAFNOSUPPORT');
const srv = await startServer({ port: 0, quiet: true });
try {
assert.deepEqual(tried, ['::', '0.0.0.0']);
assert.equal(srv.host, '0.0.0.0');
assert.match(srv.url, /^http:\/\/localhost:\d+$/);
assert.equal(srv.server.address().family, 'IPv4');
} finally {
await srv.close();
}
const refused = stub('EADDRINUSE');
await assert.rejects(() => startServer({ port: 0, quiet: true }), (e) => e && e.code === 'EADDRINUSE');
assert.deepEqual(refused, ['::'], 'a port conflict is not an IPv6-missing error');
const explicit = [];
proto.listen = function (port, host, ...rest) {
explicit.push(host);
const err = Object.assign(new Error('EAFNOSUPPORT'), { code: 'EAFNOSUPPORT' });
process.nextTick(() => this.emit('error', err));
return this;
};
await assert.rejects(
() => startServer({ port: 0, host: '::1', quiet: true }),
(e) => e && e.code === 'EAFNOSUPPORT',
);
assert.deepEqual(explicit, ['::1'], 'an explicit host is not retried');
} finally {
proto.listen = orig;
restoreHost(saved);
}
});
+145
View File
@@ -0,0 +1,145 @@
// test/lobby-ai-last.test.js — the co-op room option 「AI 队友最后选择」 (room.setAiPicksLast {on}; GitHub #338): host-only,
// before the match only, co-op rooms only, off by default; room.state.aiPicksLast shows it to everybody; the match gets
// it as opts.aiPicksLast when it starts (server/match/Match.js orders the drafts with it — test/match/ai-picks-last.test.js).
import { describe, test, before, after, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import { startServer } from '../server/index.js';
import { StubMatch } from '../server/match/StubMatch.js';
import { TestClient } from './helpers/wsClient.js';
import { ERR } from '../shared/constants.js';
import { validateC2S } from '../shared/protocol.js';
function clientPool(getUrl) {
const open = new Set();
return {
async connect() { const c = await TestClient.connect(getUrl()); open.add(c); return c; },
async player(name, token) {
const c = await this.connect();
const w = await c.hello(name, token);
c.id = w.playerId;
c.token = w.token;
return c;
},
async closeAll() {
await Promise.all([...open].map((c) => c.terminate().catch(() => {})));
open.clear();
},
};
}
const quietLog = () => {
const errors = [];
return { errors, log: { info() {}, warn() {}, debug() {}, error: (...a) => errors.push(a.map(String).join(' ')) } };
};
const ok = async (c, msg) => { const r = await c.request(msg); assert.equal(r.t, 'ok', `${msg.t}: ${JSON.stringify(r)}`); return r; };
const err = async (c, msg, code) => { const r = await c.request(msg); assert.equal(r.t, 'error', JSON.stringify(r)); assert.equal(r.code, code, JSON.stringify(r)); return r; };
const seatOf = (state, id) => state.seats.find((s) => s && s.playerId === id) || null;
async function createRoom(c, mode = 'coop') {
await ok(c, { t: 'room.create', mode, difficulty: 'NORMAL' });
return c.waitFor('room.state', (s) => s.hostId === c.id && s.mode === mode);
}
async function joinRoom(c, code) {
await ok(c, { t: 'room.join', code });
return c.waitFor('room.state', (s) => s.code === code && !!seatOf(s, c.id));
}
/** StubMatch that records the options each match was started with. */
const started = [];
class RecordingMatch extends StubMatch {
constructor(opts) {
super(opts);
started.push(opts);
}
}
test('protocol: room.setAiPicksLast takes exactly a boolean `on`', () => {
assert.equal(validateC2S({ t: 'room.setAiPicksLast', on: true }), null);
assert.equal(validateC2S({ t: 'room.setAiPicksLast', on: false }), null);
for (const on of [undefined, null, 1, 0, 'true', 'yes', {}, []]) {
assert.notEqual(validateC2S({ t: 'room.setAiPicksLast', on }), null, JSON.stringify(on));
}
});
describe('room.setAiPicksLast (lobby)', () => {
let srv;
let pool;
const cap = quietLog();
before(async () => {
srv = await startServer({ port: 0, host: '127.0.0.1', log: cap.log, MatchClass: RecordingMatch, lobbyGraceMs: 60_000 });
pool = clientPool(() => `ws://127.0.0.1:${srv.port}/ws`);
});
afterEach(async () => { await pool.closeAll(); });
after(async () => {
await srv?.close();
assert.deepEqual(cap.errors, [], 'no server errors logged');
});
test('off by default; host-only; every seat sees it in room.state; a change un-readies the guests like the difficulty', async () => {
const host = await pool.player('Host');
const st = await createRoom(host);
assert.equal(st.aiPicksLast, false);
const guest = await pool.player('Guest');
const js = await joinRoom(guest, st.code);
assert.equal(js.aiPicksLast, false);
await err(guest, { t: 'room.setAiPicksLast', on: true }, ERR.NOT_HOST);
await ok(guest, { t: 'room.ready', ready: true });
await host.waitFor('room.state', (s) => seatOf(s, guest.id)?.ready);
await ok(host, { t: 'room.setAiPicksLast', on: true });
const on = await guest.waitFor('room.state', (s) => s.aiPicksLast === true);
assert.equal(seatOf(on, guest.id).ready, false, 'the rules changed: the guest confirms again');
await host.waitFor('room.state', (s) => s.aiPicksLast === true);
// the same value again changes nothing (no broadcast needed, still ok)
await ok(host, { t: 'room.setAiPicksLast', on: true });
await ok(host, { t: 'room.setAiPicksLast', on: false });
await guest.waitFor('room.state', (s) => s.aiPicksLast === false);
// a malformed value never reaches the lobby
await err(host, { t: 'room.setAiPicksLast', on: 'yes' }, ERR.BAD_MSG);
});
test('solo rooms have no AI teammates: refused, the state stays off', async () => {
const host = await pool.player('Solo');
const st = await createRoom(host, 'solo');
assert.equal(st.aiPicksLast, false);
await err(host, { t: 'room.setAiPicksLast', on: true }, ERR.BAD_TARGET);
});
test('the match gets it at the start; it cannot change during the match; the room keeps it for the next match and a new host', async () => {
const host = await pool.player('Host');
const st = await createRoom(host);
const guest = await pool.player('Guest');
await joinRoom(guest, st.code);
await ok(host, { t: 'room.addBot' });
await ok(host, { t: 'room.setAiPicksLast', on: true });
await ok(guest, { t: 'room.ready', ready: true });
await host.waitFor('room.state', (s) => s.aiPicksLast && seatOf(s, guest.id)?.ready);
const n = started.length;
await ok(host, { t: 'room.start' });
await guest.waitFor('room.state', (s) => s.inMatch && s.aiPicksLast === true);
assert.equal(started.length, n + 1);
assert.equal(started[n].aiPicksLast, true, 'opts.aiPicksLast');
await err(host, { t: 'room.setAiPicksLast', on: false }, ERR.ROOM_STARTED);
await err(guest, { t: 'room.setAiPicksLast', on: false }, ERR.NOT_HOST);
// the stub match ends once every human confirmed the briefing → back in the lobby with the option kept
guest.clearInbox();
await ok(host, { t: 'g.infoReady' });
await ok(guest, { t: 'g.infoReady' });
const back = await guest.waitFor('room.state', (s) => !s.inMatch);
assert.equal(back.aiPicksLast, true);
// host migration keeps the room's settings
await ok(host, { t: 'room.leave' });
const migrated = await guest.waitFor('room.state', (s) => s.hostId === guest.id);
assert.equal(migrated.aiPicksLast, true);
await ok(guest, { t: 'room.setAiPicksLast', on: false });
await guest.waitFor('room.state', (s) => s.aiPicksLast === false);
});
test('without the option the match gets aiPicksLast false', async () => {
const host = await pool.player('Host');
await createRoom(host);
await ok(host, { t: 'room.addBot' });
const n = started.length;
await ok(host, { t: 'room.start' });
await host.waitFor('room.state', (s) => s.inMatch);
assert.equal(started[n].aiPicksLast, false);
});
});
+49 -1
View File
@@ -1,7 +1,7 @@
// test/lobby-loadout.test.js — room.loadout end to end over WebSocket (DESIGN §16): strict validation, storage on the
// session (follows the player into rooms, survives a resume) and on the seat, seats[].loadout handed to the Match
// (bots: none), re-sendable until the match leaves INFO_CHECK (real Match: accepted in the briefing, WRONG_PHASE after),
// the heavy-intent rate limit.
// the heavy-intent rate limit; its per-operator 潜能 / 练度 `ops` (0.2.2): validated with it, stored and handed over beside it.
import { describe, test, before, after, afterEach } from 'node:test';
import assert from 'node:assert/strict';
@@ -111,6 +111,39 @@ describe('room.loadout (lobby, stub match)', () => {
await back.terminate();
});
test('潜能 / 练度 (ops, 0.2.2): strict validation, defaults dropped, stored per session and seat beside the loadout; a message without ops sets none', async () => {
const VENDLA = C('chess_char_1_06_a').charId; // a 特许 operator of the roster
const KALTS = 'char_003_kalts'; // an owned-6★ 自选 pick
const a = await pool.player('Ops');
await err(a, { t: 'room.loadout', entries: {}, ops: [] }, ERR.BAD_MSG);
await err(a, { t: 'room.loadout', entries: {}, ops: { [VENDLA]: {} } }, ERR.BAD_MSG);
await err(a, { t: 'room.loadout', entries: {}, ops: { [VENDLA]: { potential: 0 } } }, ERR.BAD_MSG);
await err(a, { t: 'room.loadout', entries: {}, ops: { [VENDLA]: { potential: 7 } } }, ERR.BAD_MSG);
await err(a, { t: 'room.loadout', entries: {}, ops: { [VENDLA]: { cultivate: 4 } } }, ERR.BAD_MSG);
await err(a, { t: 'room.loadout', entries: {}, ops: { [VENDLA]: { potential: 2, elite: 1 } } }, ERR.BAD_MSG);
await err(a, { t: 'room.loadout', entries: {}, ops: { char_609_acguad: { potential: 1 } } }, ERR.BAD_TARGET); // a prototype
await err(a, { t: 'room.loadout', entries: {}, ops: { char_999_nobody: { potential: 1 } } }, ERR.BAD_TARGET);
// a refusal of the ops refuses the entries too (nothing stored)
await err(a, { t: 'room.loadout', entries: { [INSIDE]: { skill: 0 } }, ops: { char_999_nobody: { potential: 1 } } }, ERR.BAD_TARGET);
await ok(a, { t: 'room.loadout', entries: { [INSIDE]: { skill: 0 } }, ops: { [VENDLA]: { potential: 1, cultivate: 0 }, [KALTS]: { cultivate: 2 }, [C(INSIDE).charId]: { potential: 6, cultivate: 3 } } });
await createRoom(a, 'solo');
await ok(a, { t: 'room.start' });
await a.waitFor('m.public', (p) => p.phase === 'INFO_CHECK');
const seat = RecordingStub.instances.at(-1).opts.seats[0];
assert.deepEqual(seat.loadout, { [INSIDE]: { skill: 0, module: 'uniequip_002_inside' } });
assert.deepEqual(seat.ops, { [VENDLA]: { potential: 1, cultivate: 0 }, [KALTS]: { potential: 6, cultivate: 2 } }, 'complete entries, defaults dropped');
assert.ok(Object.isFrozen(seat.ops) && Object.isFrozen(seat.ops[VENDLA]));
// an older client's message (no ops): none set — the next match fields every operator at 潜能 6 / 精英2 Lv.60
await err(a, { t: 'room.loadout', entries: {} }, ERR.ROOM_STARTED);
const b = await pool.player('Old');
await ok(b, { t: 'room.loadout', entries: { [INSIDE]: { skill: 0 } } });
await createRoom(b, 'solo');
await ok(b, { t: 'room.start' });
await b.waitFor('m.public', (p) => p.phase === 'INFO_CHECK');
assert.deepEqual(RecordingStub.instances.at(-1).opts.seats[0].ops, {});
assert.deepEqual(cap.errors, []);
});
test('heavy-intent limit: a burst of room.loadout beyond the bucket is rate-limited', async () => {
const a = await pool.player('Spam');
const replies = await Promise.all(Array.from({ length: 10 }, () => a.request({ t: 'room.loadout', entries: {} })));
@@ -146,4 +179,19 @@ describe('room.loadout (lobby + real match)', () => {
assert.match(r.detail || '', /locked/);
assert.deepEqual(cap.errors, []);
});
test('潜能 / 练度 (0.2.2): taken with the loadout in the briefing (m.private.ops), stated in the battle input; locked afterwards', async () => {
const VENDLA = C('chess_char_1_06_a').charId;
const a = await pool.player('Solo2');
await createRoom(a, 'solo');
await ok(a, { t: 'room.start' });
await a.waitFor('m.public', (p) => p.phase === PHASE.INFO_CHECK, 5000);
await ok(a, { t: 'room.loadout', entries: {}, ops: { [VENDLA]: { potential: 2, cultivate: 1 } } });
const priv = await a.waitFor('m.private', (p) => p.ops && p.ops[VENDLA], 3000);
assert.deepEqual(priv.ops, { [VENDLA]: { potential: 2, cultivate: 1 } });
await ok(a, { t: 'g.infoReady' });
await a.waitFor('m.public', (p) => p.phase === PHASE.BAND_DRAFT, 3000);
await err(a, { t: 'room.loadout', entries: {}, ops: {} }, ERR.WRONG_PHASE);
assert.deepEqual(cap.errors, []);
});
});
+3 -2
View File
@@ -50,7 +50,7 @@ describe('extract.py helpers (no UnityPy needed)', { skip: !PY && 'no python3' }
assert.equal(enemySpines.sub, 'spine/enemy');
// every listed id is a token of the game data without a web model (data/assets.json tokens[id].spine), sorted, unique
const ids = tokenSpines.ids;
assert.equal(ids.length, 39);
assert.equal(ids.length, 40);
assert.deepEqual([...ids].sort(), ids);
assert.equal(new Set(ids).size, ids.length);
const tokens = JSON.parse(readFileSync(path.join(ROOT, 'data/tokens.json'), 'utf8'));
@@ -58,7 +58,8 @@ describe('extract.py helpers (no UnityPy needed)', { skip: !PY && 'no python3' }
const web = JSON.parse(readFileSync(path.join(ROOT, 'data/assets.json'), 'utf8')).tokens;
for (const id of ids) {
assert.ok(tokens[id] || backups.tokens[id], `${id}: a token of the game data`);
assert.equal(web[id]?.spine, undefined, `${id}: no web model`);
if (id === 'token_10031_swire2_gdtrap') assert.ok(web[id]?.spine, 'champagne has a web skin fallback');
else assert.equal(web[id]?.spine, undefined, `${id}: no web model`);
}
// the 自选 summons whose prefab draws nothing (an EmptyAnimator: displayType HIDDEN / an effect) are left out
for (const id of ['token_10039_ulpia_block', 'token_10055_phatm2_mndclv', 'token_10058_sbell2_icetgt', 'token_10065_demetr_dmtpos',
+10 -8
View File
@@ -1,4 +1,4 @@
// test/local-token-models.test.js — the token (summon) models only the local client has (0.2.0): 39 summons — most of the
// test/local-token-models.test.js — the token (summon) models only the local client has (0.2.0): 40 summon overlays — most of the
// 自选 picks' (data/backups.json `tokens`), 凯瑟琳's 爬行号·防护单元 and 凛御银灰's 风雪之眼 — have no battle Spine in
// any community dump (fetch-assets reported "missing skel"), so the client drew their avatar diamond. Their official
// skeletons come from the battle token packs of the local client (tools/local-extract/extract.py TOKEN_SPINES →
@@ -55,7 +55,7 @@ function localManifest(ids, drop = null) {
describe('the 自选 summon models from the local client are an optional overlay', () => {
test('the committed metadata: the TOKEN_SPINES of extract.py, each a skeleton + same-stem atlas + pages, its roles on its own clips', () => {
assert.equal(IDS.length, 39);
assert.equal(IDS.length, 40);
if (TOKEN_SPINES) assert.deepEqual(IDS, [...TOKEN_SPINES].sort(), 'one entry per extract.py TOKEN_SPINES id');
for (const id of IDS) {
const m = COMMITTED[id];
@@ -116,11 +116,13 @@ describe('the 自选 summon models from the local client are an optional overlay
assert.deepEqual(withLocal, IDS);
for (const id of IDS) {
assert.deepEqual(MANIFEST.tokens[id].spineLocal, { group: `spine/token/${id}`, ...COMMITTED[id] }, `${id}: the committed metadata`);
assert.equal(MANIFEST.tokens[id].spine, undefined, `${id}: no web model (it was the avatar diamond)`);
assert.equal(spineEntry(MANIFEST, id), null, `${id}: without the local art the client keeps the diamond`);
const web = MANIFEST.tokens[id].spine;
if (id === 'token_10031_swire2_gdtrap') assert.ok(validSpine(web), 'champagne keeps its web skin fallback');
else assert.equal(web, undefined, `${id}: no web model`);
assert.deepEqual(spineEntry(MANIFEST, id), web || null, `${id}: without local art keep the existing fallback`);
}
// the tokens that always had a web model keep it, without an overlay
for (const [id, t] of Object.entries(MANIFEST.tokens)) if (t.spine) { assert.ok(validSpine(t.spine), id); assert.equal(t.spineLocal, undefined, id); }
for (const [id, t] of Object.entries(MANIFEST.tokens)) if (t.spine) { assert.ok(validSpine(t.spine), id); if (id !== 'token_10031_swire2_gdtrap') assert.equal(t.spineLocal, undefined, id); }
});
test('the committed metadata is what the extracted models parse to', { skip: !IDS.every((id) => existsSync(path.join(ASSETS, 'local/spine/token', id))) && 'token models not extracted (tools/local-extract)' }, async () => {
@@ -166,10 +168,10 @@ describe('client: the official summon model only when data/local-assets.json lis
assert.equal(e.pma, true);
assert.deepEqual(e.anims, m.anims);
assert.equal(e.local, true);
assert.equal(e.fallback, null, 'no web model to fall back to');
assert.equal(e.fallback, MANIFEST.tokens[id].spine || null, 'retain the existing web model as fallback');
assert.equal(spineEntry(MANIFEST, id, { local }), e, 'one entry object per manifest pair');
assert.equal(spineEntry(MANIFEST, id, { local: localManifest([]) }), null, 'not extracted → no model');
assert.equal(spineEntry(MANIFEST, id, { local: localManifest([id], m.textures[0]) }), null, 'a page missing → no model');
assert.deepEqual(spineEntry(MANIFEST, id, { local: localManifest([]) }), MANIFEST.tokens[id].spine || null, 'not extracted → original fallback');
assert.deepEqual(spineEntry(MANIFEST, id, { local: localManifest([id], m.textures[0]) }), MANIFEST.tokens[id].spine || null, 'missing local page → original fallback');
}
assert.equal(spineEntry(MANIFEST, 'token_10028_vigil_wolf', { local }), MANIFEST.tokens.token_10028_vigil_wolf.spine, 'a web token unchanged');
const store = createAssets({ manifest: MANIFEST, localManifest: local });
+208
View File
@@ -0,0 +1,208 @@
// The co-op room option 「AI 队友最后选择」 (opts.aiPicksLast; GitHub #338): in the strategy draft (BAND_DRAFT) and the
// 机变 draft (SP_DRAFT) every human seat picks before every AI seat. The drawn order is kept inside each group (a stable
// partition AFTER the usual shuffle): no extra random draw, so the option off is today's order and the option on moves
// no random stream. A human under AI 托管, disconnected or departed is still a human (only room.addBot seats are AI).
// A human's skip passes the turn to the other humans still to pick, ahead of the AI seats [ASSUMED]; with no other
// human left it goes to the end as without the option.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { PHASE } from '../../shared/constants.js';
import { makeMatch } from './harness.js';
const isAi = (pid) => pid.startsWith('ai_');
/** the stable humans-first partition of a drawn order */
const humansFirst = (order) => [...order.filter((p) => !isAi(p)), ...order.filter(isAi)];
/** humans first, then AI seats, each in seat order — 1 host + 3 AI by default */
const seatsOf = (ids) => ids.map((playerId, seat) => ({ seat, playerId, name: playerId, isBot: isAi(playerId), connected: true }));
/** A match driven to BAND_DRAFT (every human confirms the briefing); `before(m)` runs while still in INFO_CHECK. */
function toBandDraft(o, before) {
const h = makeMatch({ mode: 'coop', ...o }).start();
if (before) before(h.m);
for (const ps of h.m.players.values()) if (!ps.isBot && !ps.left) h.m.handle(ps.playerId, { t: 'g.infoReady' });
h.run(() => h.m.phase !== PHASE.INFO_CHECK);
assert.equal(h.m.phase, PHASE.BAND_DRAFT);
return h;
}
/** The band-draft order (taken at its start, before any skip) and the next draws of the random streams after it. */
function bandOrder(o, before) {
const h = toBandDraft(o, before);
const order = h.m.draft.order.slice();
const draws = { draft: h.m.rngDraft(), shop: h.m.rngShop(), bots: h.m.rngBots(), meta: h.m.rngMeta(), waves: h.m.rngWaves() };
h.m.dispose();
return { order, draws };
}
test('the option: off by default, on only when opts.aiPicksLast === true, never in solo', () => {
const mk = (o) => { const h = makeMatch(o); const v = h.m.aiPicksLast; h.m.dispose(); return v; };
assert.equal(mk({ mode: 'coop', humans: 1, bots: 3 }), false);
assert.equal(mk({ mode: 'coop', humans: 1, bots: 3, aiPicksLast: true }), true);
assert.equal(mk({ mode: 'coop', humans: 1, bots: 3, aiPicksLast: 'yes' }), false, 'only a real boolean');
assert.equal(mk({ mode: 'solo', humans: 1, aiPicksLast: true }), false, 'solo: nothing to order');
});
test('band draft, option off: the order is exactly today\'s for the same seed (no option = off)', () => {
let humanNotFirst = 0;
for (let seed = 1; seed <= 12; seed++) {
const o = { seats: seatsOf(['p_0', 'ai_0', 'ai_1', 'ai_2']), seed };
const def = bandOrder(o);
const off = bandOrder({ ...o, aiPicksLast: false });
assert.deepEqual(off, def, `seed ${seed}`);
if (def.order[0] !== 'p_0') humanNotFirst++;
}
assert.ok(humanNotFirst > 0, 'the shuffle does put an AI first without the option (so the next test means something)');
});
test('band draft, option on, 1 human + 3 AI: the human always picks first; the same shuffle, every random stream unchanged', () => {
for (let seed = 1; seed <= 12; seed++) {
const o = { seats: seatsOf(['p_0', 'ai_0', 'ai_1', 'ai_2']), seed };
const off = bandOrder(o);
const on = bandOrder({ ...o, aiPicksLast: true });
assert.equal(on.order[0], 'p_0', `seed ${seed}`);
assert.deepEqual(on.order, humansFirst(off.order), `seed ${seed}: the AI seats keep their drawn order`);
assert.deepEqual(on.draws, off.draws, `seed ${seed}: no extra random draw`);
}
});
test('band draft, option on, 2 humans + 2 AI on interleaved seats: both humans first, in their drawn order', () => {
const ids = ['p_0', 'ai_0', 'p_1', 'ai_1'];
const seen = new Set();
for (let seed = 1; seed <= 12; seed++) {
const o = { seats: seatsOf(ids), seed };
const off = bandOrder(o);
const on = bandOrder({ ...o, aiPicksLast: true });
assert.deepEqual(on.order, humansFirst(off.order), `seed ${seed}`);
assert.deepEqual(on.order.slice(0, 2).sort(), ['p_0', 'p_1']);
seen.add(on.order[0]);
}
assert.equal(seen.size, 2, 'the humans\' own order is still drawn (either may lead)');
});
test('band draft, option on: all humans, or all AI seats (tools/matchrun), keep today\'s order', () => {
for (const ids of [['p_0', 'p_1', 'p_2', 'p_3'], ['ai_0', 'ai_1', 'ai_2', 'ai_3']]) {
for (let seed = 1; seed <= 4; seed++) {
const o = { seats: seatsOf(ids), seed };
if (ids[0] === 'ai_0') {
// no human: INFO_CHECK ends by itself
const run = (x) => { const h = makeMatch({ mode: 'coop', ...x }).start(); h.run(() => h.m.phase !== PHASE.INFO_CHECK); const r = h.m.draft.order.slice(); h.m.dispose(); return r; };
assert.deepEqual(run({ ...o, aiPicksLast: true }), run(o), `all AI, seed ${seed}`);
} else {
assert.deepEqual(bandOrder({ ...o, aiPicksLast: true }), bandOrder(o), `all humans, seed ${seed}`);
}
}
}
});
test('band draft, option on: a human under AI 托管, disconnected or departed is still ordered as a human', () => {
for (let seed = 1; seed <= 8; seed++) {
const o = { seats: seatsOf(['p_0', 'p_1', 'p_2', 'ai_0']), seed, aiPicksLast: true };
const before = (m) => {
m.handle('p_0', { t: 'g.autoplay', on: true });
m.onDisconnect('p_1');
m.onLeave('p_2');
};
const h = toBandDraft(o, before);
assert.equal(h.m.draft.order[3], 'ai_0', `seed ${seed}: ${h.m.draft.order}`);
h.m.dispose();
}
});
test('band draft, option on: a skip passes the turn to the other humans, ahead of the AI seats; the last human\'s skip goes to the end [ASSUMED]', () => {
const h = toBandDraft({ seats: seatsOf(['p_0', 'ai_0', 'p_1', 'ai_1']), seed: 3, aiPicksLast: true });
const m = h.m;
const [h1, h2, a1, a2] = m.draft.order;
assert.ok(!isAi(h1) && !isAi(h2) && isAi(a1) && isAi(a2), String(m.draft.order));
// the first human passes: behind the other human, still before the AI seats
assert.deepEqual(m.handle(h1, { t: 'g.bandSkip' }), { ok: true });
assert.deepEqual(m.draft.order, [h2, h1, a1, a2]);
assert.equal(m.draftTurn(), h2);
// the second human passes: behind the first again
assert.deepEqual(m.handle(h2, { t: 'g.bandSkip' }), { ok: true });
assert.deepEqual(m.draft.order, [h1, h2, a1, a2]);
assert.deepEqual(m.handle(h1, { t: 'g.band', bandId: 'band_amiya' }), { ok: true });
assert.equal(m.draftTurn(), h2);
m.dispose();
// a lone human (host + AI): no other human to pass to — the skip moves it to the end as without the option
const h3 = toBandDraft({ seats: seatsOf(['p_0', 'ai_0', 'ai_1', 'ai_2']), seed: 3, aiPicksLast: true });
const order = h3.m.draft.order.slice();
assert.equal(order[0], 'p_0');
assert.deepEqual(h3.m.handle('p_0', { t: 'g.bandSkip' }), { ok: true });
assert.deepEqual(h3.m.draft.order, [...order.slice(1), 'p_0']);
h3.m.dispose();
// option off: the skip still goes to the very end (today's rule)
const h4 = toBandDraft({ seats: seatsOf(['p_0', 'ai_0', 'p_1', 'ai_1']), seed: 3 });
h4.run(() => !isAi(h4.m.draftTurn() || 'ai_'));
const o4 = h4.m.draft.order.slice();
const at = h4.m.draft.idx;
const cur = h4.m.draftTurn();
assert.deepEqual(h4.m.handle(cur, { t: 'g.bandSkip' }), { ok: true });
assert.deepEqual(h4.m.draft.order, [...o4.slice(0, at), ...o4.slice(at + 1), cur]);
h4.m.dispose();
});
/** Drive a fake-battle match into the SP draft of round `r`; `flip(m)` runs just before it is entered. */
function toSpDraft(o, r, flip) {
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', fake: true, ...o }).start();
const m = h.m;
h.drive(() => m.phase === PHASE.ROUND_START && m.round === r);
assert.equal(m.round, r);
if (flip) flip(m);
h.run(() => m.phase === PHASE.SP_DRAFT);
assert.equal(m.phase, PHASE.SP_DRAFT);
return h;
}
test('机变 draft, option off vs on from the same state: on = the humans-first partition of the same shuffle; no random stream moves', () => {
for (const [ids, seed] of [[['p_0', 'ai_0', 'ai_1', 'ai_2'], 14], [['p_0', 'ai_0', 'p_1', 'ai_1'], 5], [['p_0', 'ai_0', 'ai_1', 'ai_2'], 2]]) {
const o = { seats: seatsOf(ids), seed };
const off = toSpDraft(o, 3);
const on = toSpDraft(o, 3, (m) => { m.aiPicksLast = true; });
assert.deepEqual(on.m.sp.order, humansFirst(off.m.sp.order), `seed ${seed}`);
assert.deepEqual(on.m.sp.cards, off.m.sp.cards, 'the same cards');
for (const k of ['rngDraft', 'rngShop', 'rngBots', 'rngMeta', 'rngWaves']) assert.equal(on.m[k](), off.m[k](), `${k} unchanged (seed ${seed})`);
off.m.dispose();
on.m.dispose();
}
});
test('机变 draft, option on, 1 human + 3 AI: the human picks first in every 机变 round of the match', () => {
for (const seed of [1, 14]) {
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', fake: true, seats: seatsOf(['p_0', 'ai_0', 'ai_1', 'ai_2']), seed, aiPicksLast: true }).start();
const m = h.m;
const firsts = {};
const enter = m.enterSpDraft;
m.enterSpDraft = function () {
enter.call(this);
if (this.sp) firsts[this.round] = this.sp.order[0];
};
h.drive(() => h.ended != null || m.round > 9);
const rounds = m.gd.spRounds().filter((r) => r <= 9);
assert.deepEqual(rounds, [3, 6, 9]);
for (const r of rounds) if (firsts[r] != null) assert.equal(firsts[r], 'p_0', `seed ${seed} R${r}`);
assert.ok(firsts[3] === 'p_0', 'round 3 is always reached');
m.dispose();
}
});
test('机变 draft, option on, 2 humans + 2 AI interleaved, one under AI 托管: both humans ahead of the AI seats', () => {
const o = { seats: seatsOf(['p_0', 'ai_0', 'p_1', 'ai_1']), seed: 5 };
const h = toSpDraft({ ...o, aiPicksLast: true }, 3, (m) => { m.handle('p_1', { t: 'g.autoplay', on: true }); });
assert.deepEqual(h.m.sp.order.slice(0, 2).sort(), ['p_0', 'p_1']);
assert.ok(h.m.sp.order.slice(2).every(isAi));
h.m.dispose();
});
test('solo: the option changes nothing (seat order, no skip)', () => {
const run = (aiPicksLast) => {
const h = makeMatch({ mode: 'solo', humans: 1, seed: 7, aiPicksLast }).start();
h.m.handle('p_0', { t: 'g.infoReady' });
h.run(() => h.m.phase !== PHASE.INFO_CHECK);
const r = { order: h.m.draft.order.slice(), next: h.m.rngDraft() };
h.m.dispose();
return r;
};
assert.deepEqual(run(true), run(undefined));
});
+4 -2
View File
@@ -254,8 +254,10 @@ test('withdrawing a deployed summon into a full hand: HAND_FULL like any other c
assert.ok(drone && drone.count === 1, 'one 狼群');
// (9,6): inside 伺夜's range ("只能部署在召唤者攻击范围内", player report #9 after 0.1.0)
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: drone.uid, to: { area: 'board', row: 9, col: 6 } }), { ok: true });
// golden items never merge with each other: a plain full hand
for (let i = 0; i < ps.hand.length; i++) if (!ps.hand[i]) giveItem(m, ps, 'chess_item_1_02_e_b', 'hand', i);
// One distinct golden per empty slot: goldens do not merge, and each base stays within its shared cap.
const fillers = Object.values(DATA.items).filter((r) => r.isGolden && r.itemType === 'EQUIP' && (m.gd.itemPoolCopies(r.id) ?? 0) >= 2).map((r) => r.id);
let n = 0;
for (let i = 0; i < ps.hand.length; i++) if (!ps.hand[i]) giveItem(m, ps, fillers[n++], 'hand', i);
assert.ok(ps.hand.every(Boolean) && ps.tempEmpty);
assert.equal(m.handle('p_0', { t: 'g.move', uid: drone.uid, to: { area: 'hand', idx: 0 } }).error, ERR.HAND_FULL, 'a new card for a full hand');
assert.equal(ps.board.get('9,6'), drone, 'the 狼群 stays on the board');
+15
View File
@@ -68,6 +68,21 @@ test('调和: +1 to core bonds with ≥ 1 real member on the board while active'
assert.equal(s.yanShip.count, 2);
});
test('actual normal/elite 缪尔赛思 activates every available core bond at two real members; duplicates add only one', () => {
for (const id of gd.bondIds.filter(id => gd.bond(id).isCore && !gd.modeInactiveBonds.has(id))) {
const two = members(id, 2, c => !c.bonds.includes('maniShip'));
assert.equal(two.length, 2, id);
for (const mu of ['chess_char_6_11_a', 'chess_char_6_11_b']) {
const s = computeBonds(gd, state({ board: [piece(mu), ...two.map(id => piece(id))] }));
assert.equal(s[id].count, 3, `${id}: two operators plus ${mu}`); assert.ok(s[id].active);
}
const dup = computeBonds(gd, state({ board: [piece('chess_char_6_11_a'), piece('chess_char_6_11_b'), ...two.map(id => piece(id))] }));
assert.equal(dup[id].count, 3, `${id}: duplicate harmony is not +2`);
const hand = computeBonds(gd, state({ board: two.map(id => piece(id)), hand: [piece('chess_char_6_11_a')] }));
assert.equal(hand[id].count, 2, `${id}: harmony needs deployment`);
}
});
test('独行 (downward): active only while exactly 1 distinct 独行 operator is on the board', () => {
const solo = members('soloShip', 2);
assert.equal(tierFor(DATA.bonds.soloShip, 1), 1);
+31
View File
@@ -0,0 +1,31 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeMatch } from './harness.js';
test('a solo bot used for rehearsal has no AI teammate and gets no extra grace', () => {
const h = makeMatch({ mode: 'solo', seats: [{ seat: 0, playerId: 'ai_0', name: 'AI', isBot: true }], fake: true, instant: false }).start();
const m = h.m; m.setDeadline(0); m.order[0].lp = 40; m.round = m.gd.bossRound;
m.startFinalAssault(false);
assert.equal(m.bossOvertimeStartReal, m.gd.bossLevelTime(m.round)); m.dispose();
});
for (const hidden of [false, true]) for (const bots of [0, 1]) {
test(`boss overtime follows the visible clock unless an AI teammate is present (hidden=${hidden}, bots=${bots})`, () => {
const h = makeMatch({ mode: 'coop', humans: 2, bots, fake: true, instant: false }).start();
const m = h.m;
m.setDeadline(0);
for (const p of m.order) p.lp = 40;
m.round = hidden ? m.gd.hiddenRound : m.gd.bossRound;
if (hidden) m.teamLp = 100;
m.startFinalAssault(hidden);
const level = m.gd.bossLevelTime(m.round);
const after = bots ? m.gd.bossOvertimeAfterReal : level;
assert.equal(m.overtimeAt, m.sched.now() + after * m.gd.combatTimeScale / m.gameSpeed * 1000);
const lp = m.teamLp;
m._applyOvertime((after + 0.9) * m.gd.combatTimeScale); assert.equal(m.teamLp, lp);
m._applyOvertime((after + 1) * m.gd.combatTimeScale); assert.equal(m.teamLp, lp - 1);
m._applyOvertime((after + 1) * m.gd.combatTimeScale); assert.equal(m.teamLp, lp - 1, 'no repeated deduction');
m._applyOvertime((after + 3) * m.gd.combatTimeScale); assert.equal(m.teamLp, lp - 3);
m.dispose();
});
}
+4 -2
View File
@@ -88,8 +88,10 @@ test('终极 Final Assault (+200 layers per active bond): one pool (bloodPoint
for (const k of MUL_STATS) assert.ok(!(k in b.mods), `${u.name}: ${b.key} is a 直接乘算 bonus, not ${k} (${JSON.stringify(b.mods)})`);
}
const { add, mul } = aggregateMods(u.buffs);
const want = Math.max(0, ((u.base.atk + (add.atkFlat ?? 0)) * Math.max(0, 1 + (add.atkPct ?? 0)) + (add.atkFinal ?? 0)) * (mul.atkMul ?? 1));
assert.ok(Math.abs(u.s.atk - want) <= 1e-6 * Math.max(1, want), `${u.name}: ATK = ((base + flat) × (1 + Σ%) + 最终加算) × Π(提升至/runes)`);
// the 练度 multiplier (0.2.2, the unit's own cultMul: default 精英2 Lv.60 = ×1.1) is a separate factor, after 直接乘算
const cult = u.cultMul ? u.cultMul.atk : 1;
const want = Math.max(0, ((u.base.atk + (add.atkFlat ?? 0)) * Math.max(0, 1 + (add.atkPct ?? 0)) + (add.atkFinal ?? 0)) * (mul.atkMul ?? 1) * cult);
assert.ok(Math.abs(u.s.atk - want) <= 1e-6 * Math.max(1, want), `${u.name}: ATK = ((base + flat) × (1 + Σ%) + 最终加算) × Π(提升至/runes) × 练度`);
}
}
assert.ok(ops >= 12 && withBonds >= 8, `the bots' lineups carry bond / item bonuses (${withBonds} of ${ops})`);
+98
View File
@@ -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);
});
+103 -2
View File
@@ -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);
@@ -38,9 +100,45 @@ test('bounty pick: the card the own board can beat, never one it cannot (even wh
assert.equal(botPickCard(m, ps, [hard, easy, huge].map((c, idx) => ({ ...c, idx })), [0, 1, 2]), 1);
ps.lp = 2;
assert.equal(botPickCard(m, ps, [hard, huge, easy].map((c, idx) => ({ ...c, idx })), [0, 1, 2]), 2, 'low LP: still the beatable one');
const raw = [hard, easy].map((c) => DATA.choices.cards.bounty.find((x) => x.effectId === c.id));
assert.deepEqual(m.offerBountyChoice(ps, raw, 'chess_item_6_03_m'), { ok: true });
assert.deepEqual(m.autoPickPersonalChoice(ps, 'bot'), { ok: true });
assert.equal(ps.personalChoice, null);
assert.equal(ps.bounties.at(-1).card.enemyKey, easy.enemyKey, 'personal cards use bounty scoring too');
const metaState = m.rngMeta.state(), botState = m.rngBots.state();
m.autoPickPersonalChoice(ps, 'bot');
m.autoPickPersonalChoice(ps, 'random');
assert.equal(m.rngMeta.state(), metaState);
assert.equal(m.rngBots.state(), botState, 'nothing pending: no RNG draws');
m.dispose();
});
test('a bot\'s 教鞭 pick is deterministic: one seed offers the same three cards and picks the same one, drawing only on the bots\' rng', () => {
const runs = [];
for (let k = 0; k < 2; k++) {
const h = soloBot({ seed: 5, difficulty: 'HARD' }).start();
const m = h.m;
const ps = m.order[0];
h.run(() => m.phase === PHASE.PREP && m.round === 1);
ps.lp = 999;
h.run(() => m.phase === PHASE.PREP && m.round === 5);
const art = giveItem(m, ps, 'chess_item_6_03_m');
assert.deepEqual(ps.useArt(art.uid, 10, 5), { ok: true });
const offered = ps.personalChoice.cards.map((c) => c.effectId);
assert.equal(offered.length, 3);
assert.equal(new Set(offered).size, 3, 'three different cards');
const meta = m.rngMeta.state(), bots = m.rngBots.state();
assert.deepEqual(m.autoPickPersonalChoice(ps, 'bot'), { ok: true });
assert.equal(m.rngMeta.state(), meta, 'the pick leaves the meta rng alone');
assert.notEqual(m.rngBots.state(), bots, 'one scoring draw per card from the bots\' rng');
assert.equal(ps.personalChoice, null);
runs.push({ offered, picked: ps.bounties.at(-1).card.effectId, bots: m.rngBots.state() });
assert.ok(offered.includes(runs[k].picked));
m.dispose();
}
assert.deepEqual(runs[0], runs[1]);
});
test('bounty pick in real drafts (绝境 R3 悬赏决策): a card the board likely beats (p ≥ 0.5) whenever a near-sure one (p ≥ 0.9) is offered', () => {
let drafts = 0;
for (const seed of [1, 2, 3, 4]) {
@@ -138,9 +236,12 @@ test('bounty Arts (教鞭): kept after a battle with leaks, used after a perfect
assert.ok(owns(ps, WHIP), 'kept after a battle with leaks (not destroyed)');
h.run(() => m.phase === PHASE.PREP && m.round === 4);
m.lastResults.set(ps.playerId, clean);
giveItem(m, ps, WHIP);
const bounties = ps.bounties.length;
h.run(() => m.phase === PHASE.COMBAT && m.round === 4);
assert.ok(!owns(ps, WHIP) && ps.bounties.length > bounties, 'used after a perfect battle');
assert.ok(!owns(ps, WHIP) && ps.bounties.length === bounties + 2, 'both Arts used and picked after a perfect battle');
assert.equal(ps.personalChoice, null);
assert.equal(ps.round.arts, 2);
assert.equal(m.errorCount, 0);
m.dispose();
}
+79 -2
View File
@@ -3,11 +3,62 @@ import { test } from 'node:test';
import assert from 'node:assert/strict';
import { PHASE } from '../../shared/constants.js';
import { fieldModel, planLayout, rehearse, rangeTiles, REHEARSAL_VARIANTS, LAYOUT_PARAMS, botPickCard, botPickBand } from '../../server/match/bot.js';
import { FIELD, canPlace, placeClass, parseKey } from '../../server/match/board.js';
import { makeMatch, checkInvariants, give, DATA } from './harness.js';
import { FIELD, canPlace, placeClass, parseKey, legalTiles, tileKey } from '../../server/match/board.js';
import { makeMatch, checkInvariants, give, giveItem, DATA } from './harness.js';
import { makeBattle } from '../helpers/battleHarness.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 saturated layout, When placing a self-range defender, Then prefer a free enemy path tile', () => {
const h = makeMatch({ mode: 'solo', seed: 11 }).start().toPrep();
const m = h.m;
const ps = m.order[0];
try {
const pieces = ['chess_char_6_03_a', 'chess_char_5_11_a', 'chess_char_1_02_a',
'chess_char_1_10_a', 'chess_char_2_08_a', 'chess_char_3_16_a'].map((id) => give(m, ps, id, 'hand'));
const plan = planLayout(m, ps, pieces);
// act2autochess_m04, first wave: the ground route crosses these deployable tiles.
const road = new Set(['9,9', '9,8', '9,7', '9,6', '11,5', '11,4', '10,4', '9,4', '9,3']);
assert.equal(m.stageId, 'act2autochess_m04');
assert.ok(road.has(plan.get(pieces.at(-1).uid)), `Cuora must block the route, got ${plan.get(pieces.at(-1).uid)}`);
for (const [key, expectedToBlock] of [[plan.get(pieces.at(-1).uid), true], ['12,5', false]]) {
const [row, col] = parseKey(key);
const battle = makeBattle({ stageId: m.stageId, waveTemplate: 'act1autochess_01',
units: [{ chessId: 'chess_char_3_16_a', row, col }] });
battle.runToEnd();
assert.equal(battle.hooksOf('blocked').length > 0, expectedToBlock, `blocking at ${key}`);
assert.equal(battle.result().perPlayer.p1.damageDealt > 0, expectedToBlock, `damage at ${key}`);
}
// When every path position is occupied, a legal fallback remains available.
const fallback = planLayout(m, ps, [pieces.at(-1)], undefined, { occupied: road });
const key = fallback.get(pieces.at(-1).uid);
assert.ok(key && !road.has(key));
assert.equal(ps.deployMap().get(key), 'melee');
} finally { m.dispose(); }
});
test('self-range blockers (range 0-1, only their own tile) fill the free enemy-road tiles before any other tile, on every stage and seed', () => {
// official range_table "0-1": the one grid (0,0) — 角峰 古米 泡泡 折桠 菲莱 蛇屠箱 塞雷娅 余
const guards = Object.values(DATA.chess).filter((c) => c && !c.isGolden && c.rangeGrid && c.rangeGrid.length === 1 && c.rangeGrid[0].every((v) => v === 0));
assert.equal(guards.length, 8);
const stages = new Set();
for (let seed = 1; seed <= 40; seed++) {
const h = makeMatch({ mode: 'solo', seed }).start().toPrep();
const m = h.m;
const ps = m.order[0];
try {
stages.add(m.stageId);
const pieces = guards.map((c) => give(m, ps, c.chessId, 'hand'));
const plan = planLayout(m, ps, pieces);
const model = fieldModel(m, ps);
const road = legalTiles(ps.deployMap(), 'melee').filter(([r, c]) => model.ground.has(tileKey(r, c)));
const onRoad = pieces.filter((p) => model.ground.has(plan.get(p.uid))).length;
assert.equal(onRoad, Math.min(pieces.length, road.length), `seed ${seed}, ${m.stageId}: ${onRoad} of ${pieces.length} guards on the ${road.length} road tiles`);
} finally { m.dispose(); }
}
assert.equal(stages.size, 8, 'every stage was tried');
});
test('field model: the round\'s routes traced over the own board, weighted by the enemies that use them', () => {
const h = soloBot({ seed: 2 }).start();
const m = h.m;
@@ -222,6 +273,32 @@ test('a sliced rehearsal is dropped when the prep ends first; the default layout
m.dispose();
});
test('AI takeover resolves a personal choice in untimed PREP, including generator-error Ready cleanup', () => {
for (const broken of [false, true]) {
const h = makeMatch({ mode: 'coop', humans: 1, seed: 6, fake: true, botSliceMs: 0 }).start();
h.toPrep(1);
const m = h.m, ps = h.ps('p_0');
assert.equal(m.deadline, 0, 'single-human co-op is untimed');
const art = giveItem(m, ps, 'chess_item_6_03_m');
assert.deepEqual(ps.useArt(art.uid, 10, 5), { ok: true });
const pick = m.autoPickPersonalChoice.bind(m), modes = [];
m.autoPickPersonalChoice = (p, mode) => {
if (p.personalChoice) modes.push(mode);
if (broken && mode === 'bot') throw new Error('scripted choice scoring failure');
return pick(p, mode);
};
assert.deepEqual(m.handle(ps.playerId, { t: 'g.autoplay', on: true }), { ok: true });
assert.ok(h.run(() => m.phase !== PHASE.PREP));
assert.equal(ps.personalChoice, null);
assert.equal(ps.bounties.length, 1);
assert.equal(ps.round.arts, 1);
assert.ok(ps.ready);
assert.ok(modes.includes(broken ? 'random' : 'bot'));
assert.equal(m.errorCount > 0, broken);
m.dispose();
}
});
test('economy: the bot fills the board first (8 units by round 4), levels on its curve and keeps a hand slot free', () => {
for (const seed of [5, 8]) {
const h = soloBot({ seed }).start();
+17
View File
@@ -22,6 +22,23 @@ import { DATA, makeMatch, checkInvariants } from './harness.js';
const gd = new GameData(DATA, 'mode_multi_hard');
test('client result names DIY and stand-in operators from the authoritative lineup, never the client or empty slot (#469)', () => {
const ordinary = Object.values(DATA.chess).find(c => c.visible && !c.isGolden && gd.standIn(c.chessId));
const cases = [
{ chessId: 'chess_char_6_diy1_a', diy: { charId: 'char_4231_clemnt', skillIndex: 2, uniEquipId: null }, expected: '克莱门莎' },
{ chessId: ordinary.chessId, standIn: true, expected: gd.standIn(ordinary.chessId).name },
];
for (const { expected, ...unit } of cases) {
const spec = buildBattleSpec({ battleId: 'names', fieldId: 'n:p', kind: 'normal', seed: 1, round: 3, timeLimit: 60,
players: [{ playerId: 'p', units: [{ uid: 7, kind: 'chess', row: 10, col: 3, items: [], ...unit }], bonds: {} }],
spawns: [{ enemyKey: plainKey, count: 1, time: 1 }], flags: { layerGainsEnabled: true } });
const raw = { reason: 'cleared', time: 20, killed: 1, total: 1, errors: 0,
perPlayer: { p: perPlayer({ killed: 1, total: 1, unitStats: [{ uid: 7, defId: 'forged', name: 'forged', kind: 'op', dmg: 1e6 }] }) } };
const v = validateClientResult(spec, raw, { gd }); assert.ok(v.ok, v.reason);
assert.equal(v.result.perPlayer.p.unitStats[0].name, expected);
}
});
test('eliminated humans auto-observe the first field at COMBAT start (display replica) and may switch freely', () => {
const h = makeMatch({ mode: 'coop', humans: 3, bots: 1, seed: 9301, fake: true, clientCombat: true, instant: false, pace: 'paced',
script: () => ({ duration: 6 }) }).start();
+149 -7
View File
@@ -9,7 +9,7 @@ import { PHASE } from '../../shared/constants.js';
import { validateC2S, isBattleResult } from '../../shared/protocol.js';
import { buildBattleSpec, createBattleFromSpec, resultDigest, compactResult, LocalBossPool, jsonClone } from '../../server/sim/spec.js';
import { DataSource } from '../../server/sim/simdata.js';
import { validateClientResult, specBounds } from '../../server/match/fields.js';
import { validateClientResult, specBounds, syntheticResult } from '../../server/match/fields.js';
import { CreditPool, SharedBossPool } from '../../server/match/finalAssault.js';
import { FakeBattle } from './fakeBattle.js';
import { DATA, makeMatch, checkInvariants, give, chessOfTier } from './harness.js';
@@ -95,7 +95,14 @@ test('COMBAT: humans get their own spec (authoritative), bots are simulated by t
assert.deepEqual(m.handle('p_0', { t: 'b.progress', battleId: human.battleId, gt: 4, killed: 3, total: 9, leaks: 0 }), { ok: true });
m.flush(true);
const pub = m.publicView();
assert.deepEqual(pub.fields.find((f) => f.fieldId === 'n:p_0').progress, { killed: 3, total: 9, done: false });
// `resolved` is null while the authority has not reported one — never 0, so the client's `resolved ?? killed` fallback
// holds (a report without `resolved` must read as "unknown", not as "nothing resolved")
assert.deepEqual(pub.fields.find((f) => f.fieldId === 'n:p_0').progress, { killed: 3, resolved: null, total: 9, done: false });
// a report that carries `resolved` is adopted, a reported 0 included (PR #157 capsule numerator)
assert.deepEqual(m.handle('p_0', { t: 'b.progress', battleId: human.battleId, gt: 5, killed: 3, total: 9, leaks: 0, resolved: 0 }), { ok: true });
m.flush(true);
const pub2 = m.publicView();
assert.deepEqual(pub2.fields.find((f) => f.fieldId === 'n:p_0').progress, { killed: 3, resolved: 0, total: 9, done: false });
// a stale / foreign report is ignored (never an error toast)
assert.deepEqual(m.handle('p_1', { t: 'b.progress', battleId: human.battleId, gt: 9, killed: 9, total: 9 }), { ok: true });
assert.equal(human.progress.killed, 3);
@@ -279,7 +286,7 @@ test('Final Assault: two client-run pair fields share the server pool (b.progres
m.dispose();
});
test('Final Assault: leaks and leader LP effects from b.progress drain the team LP; overtime after 150 real s at 1 LP/real s; team LP 0 → b.end → defeat', () => {
test('Final Assault: leaks and leader LP effects from b.progress drain the team LP; all-human overtime after 120 real s at 1 LP/real s; team LP 0 → b.end → defeat', () => {
const h = makeMatch({ mode: 'coop', difficulty: 'FUNNY', humans: 2, seed: 9109, fake: true, clientCombat: true, instant: false,
script: (b) => (b.kind === 'boss' ? { bossDps: 0, leakEvents: [{ at: 2, lpr: 3 }], lpLossEvents: [{ at: 3, amount: 2 }] } : { duration: 2 }) }).start();
const m = h.m;
@@ -289,14 +296,14 @@ test('Final Assault: leaks and leader LP effects from b.progress drain the team
h.sched.advance(4000);
assert.equal(m.teamLp, lp0 - 5, 'leak lpr 3 + leader effect 2');
const lp1 = m.teamLp;
// overtime (bossTurnHpReduceTime): 150 REAL s like the level's 120 s countdown, then 1 LP per real second
// All-human teams start overtime at the visible level countdown, without the AI team's extra 30 seconds.
const t0 = m._bossStartAt;
const pub = () => h.bc.filter((x) => x.t === 'm.public').pop();
m.flush(true);
assert.equal(pub().deadline, t0 + 120000, 'the HUD counts the boss level\'s 120 s down');
assert.equal(pub().overtimeAt, t0 + 150000, 'and knows when the drain starts');
h.sched.advance(t0 + 150000 - m.sched.now() + 500);
assert.equal(m.teamLp, lp1, 'no drain before 150 real s (the countdown ran out at 120 s, the battle went on)');
assert.equal(pub().overtimeAt, t0 + 120000, 'and knows when the drain starts');
h.sched.advance(t0 + 120000 - m.sched.now() + 500);
assert.equal(m.teamLp, lp1, 'the first deduction is at the next whole real second');
h.sched.advance(5000);
assert.ok(m.teamLp <= lp1 - 4 && m.teamLp >= lp1 - 6, `overtime drain ≈ 5 LP over 5 real s (${lp1} → ${m.teamLp})`);
const end = h.runToEnd();
@@ -509,6 +516,10 @@ test('Final Assault: an early boss b.result (forced at t≈0) does not end the p
test('Final Assault: a 999-layer kill faster than the budget — the \'cleared\' b.result whose report covers the pool waits for the budget, no takeover (QA 6b)', () => {
const { h, m, f } = faWithForger(9122);
const ps = h.ps('p_0');
const training = DATA.choices.cards.bounty.find((c) => c.payout === 'perfect' && m.gd.enemy(c.enemyKey));
m.addBounty(ps, { ...training, rounds: 2, multiRound: false });
const pending = ps.pendingFunds;
const max = m.bossPool.maxHp;
h.sched.advance(500); // 1 game s on the field clock: the budget credits ≤ 20 % of the pool
const gt = m._fieldElapsed(f);
@@ -537,10 +548,141 @@ test('Final Assault: a 999-layer kill faster than the budget — the \'cleared\'
assert.equal(f.resultSource, 'client', 'the held client result completed the field');
assert.equal(m.verifyStats.takeovers, takeovers0);
assert.equal(m.verifyStats.rejected, rejected0);
assert.equal(ps.pendingFunds, pending + training.coin, 'validated held client result earns the server card amount');
assert.equal(ps.bounties[0].roundsLeft, 1);
assert.deepEqual(m.handle('p_0', { t: 'b.result', battleId: f.battleId, result: res }), { ok: true });
assert.equal(ps.pendingFunds, pending + training.coin, 'duplicate accepted result does not pay twice');
assert.equal(ps.bounties[0].roundsLeft, 1);
for (const pid of f.players) assert.ok(Math.abs((f.bossBy[pid] || 0) - Math.min(by[pid], max)) <= max * 1e-9 + 5, `${pid} credited as reported (${f.bossBy[pid]} vs ${by[pid]})`);
m.dispose();
});
test('boss bounty eligibility: forced real results on a team victory qualify; synthetic, leaked and team defeat do not', () => {
for (const [ending, synthetic, perfect, leaked, eligible] of [
['cleared', false, true, [], true],
['cleared', true, true, [], false],
['cleared', false, false, [], false],
['cleared', false, true, [{ counted: true }], false],
['cleared', false, true, [{ counted: false }], true],
['forced', false, true, [], false],
]) {
const { m, f } = faWithForger(9122);
const ps = m.players.get(f.players[0]);
const card = DATA.choices.cards.bounty.find((c) => c.payout === 'perfect' && m.gd.enemy(c.enemyKey));
m.addBounty(ps, { ...card, rounds: 2, multiRound: false });
m.addBounty(ps, { ...card, rounds: 2, multiRound: false });
const res = syntheticResult(f.players);
if (!synthetic) delete res.synthetic;
res.reason = 'forced';
Object.assign(res.perPlayer[ps.playerId], { perfect, leaked, coins: 3 });
const pending = ps.pendingFunds, gained = ps.stats.fundsGained;
m._finalEnding = ending;
m.bossPool.hp = 0; // even a late clearing report cannot override an already-registered team defeat
m._finishFinal(false, () => res);
assert.equal(ps.pendingFunds, pending + 3 + (eligible ? 2 * card.coin : 0));
assert.equal(ps.stats.fundsGained, gained + 3 + (eligible ? 2 * card.coin : 0));
assert.deepEqual(ps.bounties.map((b) => b.roundsLeft), [1, 1]);
m.dispose();
}
});
// ---- a perfect-payout bounty and what the authority reported (a modified client could tell the two halves apart) -------
/**
* A Final Assault pair field played by hand (the SimClient of p_0, the authority, is muted): both players hold the same
* perfect-payout card; the authority sends one b.progress — `leaks` (the field's LP cost) and `leaksBy` (its per-player
* split, when given) — then a 'cleared' b.result whose leaked lists hold `leaked[pid]` counted leaks (lpr 1); the fight
* is driven to its end. Returns the funds each player was paid (kill coins: none) and what the server charged.
*/
function bossPaid(seed, { leaks = 0, leaksBy = null, leaked = {} }) {
const { h, m, f } = faWithForger(seed);
const card = DATA.choices.cards.bounty.find((c) => c.payout === 'perfect' && m.gd.enemy(c.enemyKey));
const ps = f.players.map((pid) => h.ps(pid));
for (const p of ps) m.addBounty(p, { ...card, rounds: 2, multiRound: false });
const before = ps.map((p) => p.pendingFunds);
const lp0 = m.teamLp;
const max = m.bossPool.maxHp;
h.sched.advance(500);
const gt = m._fieldElapsed(f);
const by = { [f.players[0]]: max * 0.6, [f.players[1]]: max * 0.4 + 5 };
const progress = { t: 'b.progress', battleId: f.battleId, gt, killed: 0, total: 0, bossDmg: max + 5, by, leaks, ...(leaksBy ? { leaksBy } : {}) };
assert.equal(validateC2S(progress), null);
assert.deepEqual(m.handle('p_0', progress), { ok: true });
const entry = (pid) => ({ enemyKey: card.enemyKey, mods: null, lpr: 1, sourcePlayerId: pid, tag: null, counted: true, spawned: true });
const res = { reason: 'cleared', time: gt, killed: 0, total: 0, errors: 0,
perPlayer: Object.fromEntries(f.players.map((pid) => {
const list = Array.from({ length: leaked[pid] || 0 }, () => entry(pid));
return [pid, { killed: 0, total: 0, leaked: list, perfect: list.length === 0, layerGains: {}, coins: 0, damageDealt: by[pid], bossDamage: by[pid], healingDone: 0, deaths: 0, unitsEnd: [], unitStats: [] }];
})) };
assert.equal(isBattleResult(res), true);
assert.deepEqual(m.handle('p_0', { t: 'b.result', battleId: f.battleId, result: res }), { ok: true });
h.drive(() => m._finalEnding != null || m.phase !== PHASE.FINAL_ASSAULT, { maxSteps: 2e5 });
const out = { ending: m._finalEnding, source: f.resultSource, coin: card.coin, charged: lp0 - m.teamLp, paid: ps.map((p, i) => p.pendingFunds - before[i]), left: ps.map((p) => p.bounties.map((b) => b.roundsLeft)) };
m.dispose();
return out;
}
test('Final Assault, a modified authority: LP charged by b.progress while both leaked lists are empty pays no perfect-payout card', () => {
const r = bossPaid(9124, { leaks: 1, leaked: {} });
assert.equal(r.ending, 'cleared');
assert.equal(r.source, 'client');
assert.equal(r.charged, 1, 'the server charged the team leak');
assert.deepEqual(r.paid, [0, 0], 'a result that shows no leak after a charged one is not believed');
assert.deepEqual(r.left, [[1], [1]], 'the boss battle still used one of the cards\' battles');
});
test('Final Assault, a modified authority: one seat\'s reported leak cannot be moved to the other — nobody is paid', () => {
// progress: the leak is p_0's; the result pins it on p_1 so that p_0 reads perfect (and p_1 cannot be paid anyway)
const r = bossPaid(9125, { leaks: 1, leaksBy: { p_0: 1, p_1: 0 }, leaked: { p_1: 1 } });
assert.equal(r.ending, 'cleared');
assert.equal(r.charged, 1);
assert.deepEqual(r.paid, [0, 0]);
// …and with no split at all the result decides nothing either: the same leak pinned on p_1
assert.deepEqual(bossPaid(9126, { leaks: 1, leaked: { p_1: 1 } }).paid, [0, 0]);
});
test('Final Assault, honest authority: the seat whose half let nothing through is paid, the leaker is not; a leader\'s own LP effect costs nobody the card', () => {
const leak = bossPaid(9127, { leaks: 1, leaksBy: { p_0: 0, p_1: 1 }, leaked: { p_1: 1 } });
assert.equal(leak.charged, 1);
assert.deepEqual(leak.paid, [leak.coin, 0]);
// 2 LP of leader "扣除目标生命" effects (no leaked enemy): the report says so, the result agrees — both halves are perfect
const fx = bossPaid(9128, { leaks: 2, leaksBy: { p_0: 0, p_1: 0 }, leaked: {} });
assert.equal(fx.charged, 2);
assert.deepEqual(fx.paid, [fx.coin, fx.coin]);
// nothing charged, nothing leaked: as before
assert.deepEqual(bossPaid(9129, { leaks: 0, leaked: {} }).paid.map((n, i, a) => n === a[0]), [true, true]);
assert.ok(bossPaid(9130, { leaks: 0, leaked: {} }).paid.every((n) => n > 0));
});
test('Final Assault, honest clients (client-side combat): a leak on one half costs that seat its perfect-payout card, not its partner; the leader\'s own LP effect costs nobody', () => {
const h = makeMatch({ mode: 'coop', difficulty: 'FUNNY', humans: 2, seed: 9132, fake: true, clientCombat: true, instant: false,
script: (b) => (b.kind === 'boss' ? { bossDps: 20000, leaks: { p_1: 1 }, leakEvents: [{ at: 1, lpr: 1 }], lpLossEvents: [{ at: 1.5, amount: 2 }] } : { duration: 2 }) }).start();
const m = h.m;
h.autoHumans();
h.drive(() => m.phase === PHASE.FINAL_ASSAULT);
const card = DATA.choices.cards.bounty.find((c) => c.payout === 'perfect' && m.gd.enemy(c.enemyKey));
const [p0, p1] = [h.ps('p_0'), h.ps('p_1')];
for (const p of [p0, p1]) m.addBounty(p, { ...card, rounds: 2, multiRound: false });
const [a, b] = [p0.pendingFunds, p1.pendingFunds];
const end = h.runToEnd();
assert.equal(end.victory, true);
const f = fields(h)[0];
assert.equal(f.resultSource, 'client');
assert.equal(f.lpReported, 3, 'leak lpr 1 + leader effect 2');
assert.deepEqual(f.leaksBy, { p_1: 1 }, 'the authority split it: p_1\'s one leak (the other 2 LP are the leader\'s effect), nothing on p_0');
assert.equal(p0.pendingFunds - a, card.coin, 'the seat that let nothing through is paid');
assert.equal(p1.pendingFunds - b, 0, 'the seat that leaked is not');
m.dispose();
});
test('b.progress carries `leaksBy`, the per-player split of a boss field\'s leak LP (shared/protocol.js)', () => {
const base = { t: 'b.progress', battleId: 'a.1.1.b1', gt: 3, killed: 0, total: 0, leaks: 2, bossDmg: 10, by: { p_0: 10 } };
assert.equal(validateC2S({ ...base, leaksBy: { p_0: 1, p_1: 0.5 } }), null);
assert.equal(validateC2S({ ...base, leaksBy: {} }), null);
assert.equal(validateC2S(base), null, 'optional: a client that never sends it still validates');
for (const bad of [{ p_0: -1 }, { p_0: 'x' }, { p_0: 1e7 }, [1, 2], 'p_0', null, { 'a b': 1 }]) assert.notEqual(validateC2S({ ...base, leaksBy: bad }), null, JSON.stringify(bad));
});
test('Final Assault: a \'cleared\' b.result whose report does NOT cover the pool is still handed over (the partner takes the field)', () => {
const { h, m, f } = faWithForger(9123);
const max = m.bossPool.maxHp;
+41 -1
View File
@@ -4,7 +4,7 @@ import assert from 'node:assert/strict';
import { PHASE, ERR } from '../../shared/constants.js';
import { Match } from '../../server/match/Match.js';
import { StubMatch } from '../../server/match/StubMatch.js';
import { DATA, makeMatch, checkInvariants, give, chessOfTier } from './harness.js';
import { DATA, makeMatch, checkInvariants, give, giveItem, chessOfTier } from './harness.js';
import { bossPoolHp } from '../../server/match/finalAssault.js';
const STATUSES = ['acting', 'ready', 'deciding', 'combat', 'done', 'helping', 'left', 'dead'];
@@ -51,6 +51,7 @@ test('m.private shape in PREP matches DESIGN §8.3 (pieces, slots, bonds, effect
assert.ok(s.kind === 'chess' || s.kind === 'item');
}
assert.equal(priv.shop.rewardOffer, null);
assert.equal(priv.personalChoice, null);
assert.equal(priv.deployCap, 8);
assert.ok(priv.effects.some((e) => e.iconKind === 'band' && e.name));
assert.ok(priv.nextEnemies.length > 0);
@@ -109,6 +110,45 @@ test('disconnect: the seat keeps playing; draft turns / prep auto-resolve at dea
m.dispose();
});
test('教鞭: simultaneous private choices survive reconnect; foreign, duplicate and old IDs cannot add bounties', () => {
const h = makeMatch({ mode: 'coop', humans: 2, spectators: ['watcher'], seed: 63, fake: true }).start();
h.toPrep(1);
const m = h.m, a = h.ps('p_0'), b = h.ps('p_1');
for (const ps of [a, b]) {
const art = giveItem(m, ps, 'chess_item_6_03_m');
assert.deepEqual(ps.useArt(art.uid, 10, 5), { ok: true });
}
const aChoice = a.privateView().personalChoice, bChoice = b.privateView().personalChoice;
assert.notEqual(aChoice.id, bChoice.id);
assert.equal(m.handle('p_0', { t: 'g.choice', idx: 0, choiceId: bChoice.id }).error, ERR.BAD_TARGET);
assert.equal(a.bounties.length + b.bounties.length, 0);
m.onDisconnect('p_0');
m.onReconnect('p_0');
assert.deepEqual(h.lastTo('p_0', 'm.private').personalChoice, aChoice);
m.flush(true);
assert.deepEqual(h.lastTo('p_1', 'm.private').personalChoice, bChoice);
assert.ok(!h.allTo('watcher', 'm.private').length);
assert.ok(!JSON.stringify(m.publicView()).includes(aChoice.id));
assert.ok(!JSON.stringify(m.prepFieldMeta(a)).includes(aChoice.id));
assert.deepEqual(m.handle('p_0', { t: 'g.choice', idx: 1, choiceId: aChoice.id }), { ok: true });
m.flush(true);
assert.equal(h.lastTo('p_0', 'm.private').personalChoice, null);
assert.equal(m.handle('p_0', { t: 'g.choice', idx: 1, choiceId: aChoice.id }).error, ERR.BAD_TARGET);
const next = giveItem(m, a, 'chess_item_6_03_m');
assert.deepEqual(a.useArt(next.uid, 10, 5), { ok: true });
const nextId = a.personalChoice.id;
assert.notEqual(nextId, aChoice.id);
assert.equal(m.handle('p_0', { t: 'g.choice', idx: 0, choiceId: aChoice.id }).error, ERR.BAD_TARGET);
assert.equal(a.personalChoice.id, nextId);
assert.equal(a.bounties.length, 1);
m.onDisconnect('p_1');
h.sched.advance(m.deadline - h.sched.now() + 1);
m.onReconnect('p_1');
assert.equal(h.lastTo('p_1', 'm.private').personalChoice, null);
assert.equal(b.bounties.length, 1, 'offline choice picked at the existing PREP deadline');
m.dispose();
});
test('g.autoplay lets the bot play a human seat (buys, places, readies); turning it off returns control', () => {
const h = makeMatch({ mode: 'solo', seed: 64, fake: true }).start();
const m = h.m;
+2 -1
View File
@@ -96,7 +96,8 @@ test('seats[].diy → PlayerState.diy (re-checked, frozen; bots none); the playe
const want = diyRecord(T5A, SIEGE_PICK, { elite, data: DATA });
assert.deepEqual([rec.name, rec.charId, rec.diyFor, rec.bonds, rec.garrisonIds, rec.tier, rec.price, rec.sellPrice], [want.name, SIEGE, T5A, ['victoriaShip'], [], 5, 4, 1]);
assert.equal(rec.skill.skillId, 'skchr_siege_3');
assert.deepEqual(p0.loadoutFor(h.m.gd.chess(id)), { skillIndex: 2, moduleId: elite ? 'uniequip_002_siege' : null }, 'the pick, never the loadout');
// (0.2.2: an owned pick at the player's 潜能 / 练度 — none set: 6 / 3)
assert.deepEqual(p0.loadoutFor(h.m.gd.chess(id)), { skillIndex: 2, moduleId: elite ? 'uniequip_002_siege' : null, potential: 6, cultivate: 3 }, 'the pick, never the loadout');
assert.equal(h.m.gd.chess(id).name, DATA.chess[id].name, 'the match\'s own record is the slot');
}
const s1 = DATA.backups.units[SIEGE].forms['2/1/4/0'].skills.find((x) => x.index === 0).skillId;
+3
View File
@@ -113,6 +113,7 @@ test('band draft timers (user playtest #4 item 4): one countdown of BAND_TURN_SE
assert.equal(new Set([...h2.m.players.values()].map((ps) => ps.bandId)).size, 4, 'the rest get distinct free strategies');
h2.m.dispose();
// data and code say the same: data/config.json timers.bandTurn (tools/build-data.mjs) = Match.BAND_TURN_SECONDS
assert.equal(BAND_TURN_SECONDS, 50, 'owner official-play report: 50 seconds per multiplayer pick');
assert.equal(DATA.config.timers.bandTurn, BAND_TURN_SECONDS);
});
@@ -150,6 +151,8 @@ test('机变 (co-op): 6 shared cards, random order, 30 s first / 16 s others, ti
assert.deepEqual(sp.order.slice().sort(), ['p_0', 'p_1', 'p_2']);
assert.equal(Math.round((m2.deadline - h2.sched.now()) / 1000), 30, 'first picker 30 s');
const [a, b, c] = sp.order;
assert.equal(m2.handle(a, { t: 'g.choice', idx: 2, choiceId: 'old.choice.1' }).error, ERR.WRONG_PHASE);
assert.deepEqual(m2.sp.picks, {}, 'personal IDs never fall through to the global draft');
assert.equal(m2.handle(b, { t: 'g.choice', idx: 0 }).error, ERR.NOT_YOUR_TURN);
assert.deepEqual(m2.handle(a, { t: 'g.choice', idx: 2 }), { ok: true });
assert.equal(m2.handle(a, { t: 'g.choice', idx: 3 }).error, ERR.ALREADY);
+27
View File
@@ -214,6 +214,15 @@ test('ready gating: temp must be empty (a freed hand slot pulls the temp piece i
assert.equal(a.hand[3], t);
assert.ok(a.tempEmpty);
assert.equal(a.privateView().canReady, true);
const art = giveItem(m, a, 'chess_item_6_03_m', 'temp');
assert.deepEqual(a.useArt(art.uid, 10, 5), { ok: true });
assert.ok(a.tempEmpty);
assert.equal(a.privateView().canReady, false);
assert.deepEqual(a.setReady(true), { error: ERR.BAD_TARGET, detail: '请先完成教鞭选择' });
assert.deepEqual(a.setReady(false), { ok: true });
assert.deepEqual(m.handle('p_0', { t: 'g.choice', idx: 0, choiceId: a.personalChoice.id }), { ok: true });
assert.equal(a.privateView().canReady, true);
assert.equal(a.round.arts, 1);
assert.deepEqual(m.handle('p_0', { t: 'g.ready', ready: true }), { ok: true });
assert.deepEqual(m.handle('p_0', { t: 'g.refresh' }), { error: ERR.WRONG_PHASE, detail: 'ready' });
assert.deepEqual(m.handle('p_0', { t: 'g.ready', ready: false }), { ok: true });
@@ -235,13 +244,26 @@ test('prep deadline (co-op): unready players are auto-readied and their temp is
const a = h.ps('p_0');
const id = chessOfTier(1).find((x) => m.pool.has(x));
const left = m.pool.left(id);
const art = giveItem(m, a, 'chess_item_6_03_m');
assert.deepEqual(a.useArt(art.uid, 10, 5), { ok: true });
fillHand(a);
give(m, a, id, 'temp');
assert.ok(!a.tempEmpty, 'a full hand: it waits in temp');
const endPrep = m.endPrep.bind(m);
let checked = false;
m.endPrep = () => {
assert.equal(a.personalChoice, null, 'deadline picks before endPrep builds the battle');
assert.equal(a.bounties.length, 1);
assert.equal(a.round.arts, 1);
assert.ok(a.ready && a.tempEmpty);
checked = true;
endPrep();
};
assert.ok(m.deadline > h.sched.now(), 'co-op prep is timed');
assert.equal(Math.round((m.deadline - h.sched.now()) / 1000), m.gd.prepTime(1));
h.sched.advance(m.deadline - h.sched.now() + 1);
assert.notEqual(m.phase, PHASE.PREP);
assert.ok(checked);
assert.ok(a.tempEmpty);
assert.equal(m.pool.left(id), left, 'temp chess sold back to the pool');
checkInvariants(m);
@@ -252,7 +274,12 @@ test('solo prep and 机变 are untimed', () => {
const h = makeMatch({ mode: 'solo', difficulty: 'NORMAL', seed: 13 }).start();
h.toPrep(1);
assert.equal(h.m.deadline, 0);
const ps = h.ps('p_0');
const art = giveItem(h.m, ps, 'chess_item_6_03_m');
assert.deepEqual(ps.useArt(art.uid, 10, 5), { ok: true });
const id = ps.personalChoice.id;
h.sched.advance(10 * 60 * 1000);
assert.equal(h.m.phase, PHASE.PREP);
assert.equal(ps.personalChoice.id, id, 'no separate personal-choice timer');
h.m.dispose();
});
+5 -3
View File
@@ -191,9 +191,11 @@ test('#9 hand and temp full: a re-orientation that would push 狼群 out is refu
const toasts = [];
const toast = m.toast.bind(m);
m.toast = (p, kind, text) => { toasts.push(renderMessage(text)); return toast(p, kind, text); }; // string or msg()
const golden = Object.keys(DATA.items).find((id) => DATA.items[id].isGolden && DATA.items[id].itemType === 'EQUIP');
while (ps.hand.includes(null)) giveItem(m, ps, golden);
while (ps.temp.includes(null)) giveItem(m, ps, golden, 'temp');
// One distinct golden per empty slot: goldens do not merge, and each base stays within its shared cap.
const fillers = Object.values(DATA.items).filter((r) => r.isGolden && r.itemType === 'EQUIP' && (m.gd.itemPoolCopies(r.id) ?? 0) >= 2).map((r) => r.id);
let n = 0;
while (ps.hand.includes(null)) giveItem(m, ps, fillers[n++]);
while (ps.temp.includes(null)) giveItem(m, ps, fillers[n++], 'temp');
// facing LEFT would leave (11,5) outside her range and the wolf has nowhere to go: refused, nothing changes
assert.equal(move(m, vigil.uid, board(10, 4), 'LEFT').error, ERR.HAND_FULL);
assert.equal(ps.board.get('10,4').dir, 'RIGHT');
+52
View File
@@ -17,6 +17,8 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { createBattleRunner, keepsState, compactHeld, HELD_MAX, EV_SLICE } from '../../public/js/battle/runner.js';
import { SnapshotBuffer } from '../../public/js/render/interp.js';
import { keepEarly, audioEarly } from '../../public/js/screens/game/early.js';
import { AudioManager } from '../../public/js/audio.js';
import { createStore, initialState } from '../../public/js/store.js';
import * as specMod from '../../server/sim/spec.js';
import { DataSource } from '../../server/sim/simdata.js';
@@ -243,6 +245,56 @@ test('screens/game.js buffers the form fx with the state-bearing events it repla
assert.match(src, /lastFieldRef\.current = field\.fieldId;\s+reentryRef\.current = null;/);
});
// GitHub PR #292 (by @LimitlessHPPK) wanted the whole pre-entry buffer handed to the sound as well, so that a deploy-tick cast
// ahead of its unit's spawn would be heard. The review kept the hold of such a cast (audio.js `pendingSkill`, which the live
// path needs: `onEv` already forwards every list) and refused the replay: the buffer is every spawn, die, deploy, status and
// skill since the field's m.field (up to 1500 of them), so the sound would play the field's old deaths, deploy lines and casts
// all at once on entering.
test('entering a field late: the view replays every buffered state event, the sound only the spawn tuples that teach it who is there', () => {
const src = readFileSync(path.join(ROOT, 'public/js/screens/game.js'), 'utf8');
assert.match(src, /view\.pushEvents\(early\);/, 'the view gets the buffered list');
assert.match(src, /audio\.handleBattleEvents\(audioEarly\(early\)\);/, 'the sound gets audioEarly of it');
assert.ok(!/audio\.handleBattleEvents\(early\)/.test(src), 'never the whole list');
const stream = [
['atk', 1, 2, 'none'], // stale cosmetics: dropped by keepEarly
['dmg', 2, 40, 'phys'],
['skill', 1, 1], // a deploy-tick cast, ahead of its unit's spawn
['spawn', { id: 1, side: 'ally', kind: 'op', spine: 'char_a', skillIndex: 2 }],
['spawn', { id: 2, side: 'enemy', kind: 'enemy', spine: 'enemy_x' }],
['deploy', 1],
['skill', 1, 0],
['fx', 'form', 2, 3, { id: 2, form: 'translator_youling' }],
['die', 2, 'killed'],
];
const early = stream.filter(keepEarly);
assert.deepEqual(early.map((e) => e[0]), ['skill', 'spawn', 'spawn', 'deploy', 'skill', 'fx', 'die'], 'the pre-entry buffer keeps every state-bearing kind');
assert.deepEqual(audioEarly(early).map((e) => e[0]), ['spawn', 'spawn'], 'the sound is told who is on the field, nothing else');
assert.deepEqual(audioEarly(null), [], 'no buffer, nothing');
// through a real AudioManager: the replay makes no sound at all (no old death, deploy line or cast) but tracks the units,
// so the first live cast after entering sounds
const vm = { audio: { sfx: { ui: {}, battle: {}, units: { char_a: { skill: '/s/a_skill.mp3', born: '/s/a_born.mp3' }, enemy_x: { die: '/s/x_die.mp3' } } }, voice: {} } };
const a = new AudioManager({ win: null, getManifest: () => vm });
a.ctx = {};
const played = [];
a._play = (url) => { played.push(url); };
a.voice = () => { played.push('voice'); return true; };
a.setFieldUnits([]);
a.handleBattleEvents(audioEarly(early));
assert.deepEqual(played, [], 'the replay is silent');
assert.equal(a.units.size, 2, 'and has taught the sound both units');
a.handleBattleEvents([['skill', 1, 1]]);
assert.ok(played.includes('/s/a_skill.mp3'), 'the next live cast of a known unit sounds');
// (the contrast: the whole list would have played the buffered death, the deploy and the cast on entering)
const b = new AudioManager({ win: null, getManifest: () => vm });
b.ctx = {};
const heard = [];
b._play = (url) => { heard.push(url); };
b.voice = () => true;
b.setFieldUnits([]);
b.handleBattleEvents(early);
assert.ok(heard.length >= 2, `the whole list would sound ${heard.length} old cues at once`);
});
/**
* The view's side too (render/app.js): the runner's frames go into the render engine's SnapshotBuffer, whose clock
* follows the snapshots, and processEvents takes the due events with the 1.5 game s stale-cosmetic window — a form fx
@@ -56,6 +56,7 @@ test('a move of the partner reaches the player\'s m.private and a scout of eithe
const h = bossPrep(4, 5102);
const m = h.m;
const b = place(h, 'p_1', chessOfTier(1)[1]);
const held = give(m, h.ps('p_0'), chessOfTier(1)[2]); // into p_0's hand
m.flush(true);
// p_3 (eliminated) scouts p_0: the scout shows p_0's half and p_1's board on the other half
h.ps('p_3').eliminate(13);
@@ -63,6 +64,9 @@ test('a move of the partner reaches the player\'s m.private and a scout of eithe
let scout = h.lastTo('p_3', 'm.field');
assert.deepEqual([scout.kind, scout.side, scout.mate], ['boss', 'L', { playerId: 'p_1', side: 'R' }]);
assert.ok(scout.units.some((u) => u.uid === b.piece.uid && u.x >= 10), 'the partner\'s piece on the right half');
// the area tags survive the boss-half remap (the rows do not): the bond popup tells the bench from the board (GitHub #385)
assert.equal(scout.units.find((u) => u.uid === b.piece.uid).area, 'board');
assert.equal(scout.units.find((u) => u.uid === held.uid).area, 'hand', 'a held operator stays tagged as held');
// p_1 moves its piece: p_0's m.private and p_3's scout of p_0 follow
const ps1 = h.ps('p_1');
const to = legalTileFor(m, ps1, b.piece.id, new Set([`${b.tile[0]},${b.tile[1]}`]));
+36 -25
View File
@@ -1,8 +1,9 @@
// Community report of 2026-10-06, item 19: 「升级商店获得新的商店位时用新卡补上,而不是空着」. The official tutorial's 休整期
// page says 「升级:消耗资金升级调度中心。升级后将出现更多的商品栏位、可调度干员以及新装备」: the upgrade itself opens the
// new level's extra slot. Until 0.2.0 the shop kept its old slot count until the next roll (a refresh or the round start),
// so a level-up from 1 to 2 (险境: 3 → 4 operator slots) or 3 to 4 (4 → 5) showed no new card. Now the upgrade adds the
// extra slots at once, each with a new card drawn at the new level, and leaves the cards already shown where they are.
// GitHub #332 / PR #333 by @2321Robin (the footage of an official 1→2 upgrade: bilibili BV1AXwuzdEys 1:39): a 调度中心
// upgrade opens the new level's extra slots at once but EMPTY — the official shop shows the new slot, draws no card into
// it, and fills it with the next roll (a manual refresh or the round start). The official texts only name the slots: the
// tutorial's 休整期 page 「升级后将出现更多的商品栏位」, PRTS 帮助 「增加刷新栏位」. 0.2.0 drew a card into each new slot instead
// (item 19 of the community report of 2026-10-06, 「升级商店获得新的商店位时用新卡补上,而不是空着」, one uncorroborated remark);
// before that the shop kept its old slot count until the next roll. Either way the upgrade leaves the cards shown where they are.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeMatch, DATA } from './harness.js';
@@ -11,7 +12,7 @@ import { GameData } from '../../server/match/gamedata.js';
const ids = (slots) => slots.map((s) => (s ? `${s.kind}:${s.id}${s.sold ? '(sold)' : ''}` : null));
const tierOf = (id) => DATA.chess[id]?.tier ?? 0;
test('a level-up opens the new level\'s operator slot at once with a new card; the cards shown stay in place', () => {
test('a level-up opens the new level\'s operator slot EMPTY; the cards shown stay in place; a refresh fills it', () => {
const h = makeMatch({ mode: 'solo', difficulty: 'NORMAL', seed: 7, fake: true }).start();
h.toPrep(1);
const ps = h.ps('p_0');
@@ -20,35 +21,37 @@ test('a level-up opens the new level\'s operator slot at once with a new card; t
const s1 = ps.shop.slots.slice();
assert.equal(s1.length, 4);
assert.deepEqual(h.m.handle('p_0', { t: 'g.levelUp' }), { ok: true });
// level 2: 4 operator slots + the item slot — the three cards and the item kept (the same objects), one new card
// level 2: 4 operator slots + the item slot — the three cards and the item kept (the same objects), the new slot empty
assert.equal(ps.shop.level, 2);
assert.deepEqual(ps.shop.layout, { chess: 4, item: 1 });
assert.equal(ps.shop.slots.length, 5);
for (let i = 0; i < 3; i++) assert.equal(ps.shop.slots[i], s1[i], `operator card ${i} kept`);
assert.equal(ps.shop.slots[4], s1[3], 'the item card stays after the operator cards');
const added = ps.shop.slots[3];
assert.ok(added && added.kind === 'chess' && !added.sold, `a new operator card: ${JSON.stringify(added)}`);
assert.ok(tierOf(added.id) >= 1 && tierOf(added.id) <= 2, `drawn at the new level (tier ${tierOf(added.id)})`);
assert.equal(added.frozen, false);
assert.equal(ps.shop.slots[3], null, 'the new slot is open and empty');
// the player sees it right away (m.private shop)
assert.deepEqual(ids(ps.privateView().shop.slots), ids(ps.shop.slots));
// 2 → 3 keeps 4 operator slots: nothing rerolled
// a manual refresh fills every slot at the new level (the empty one included)
assert.deepEqual(h.m.handle('p_0', { t: 'g.refresh' }), { ok: true });
assert.equal(ps.shop.slots.length, 5);
for (const s of ps.shop.slots) assert.ok(s && !s.sold, 'every slot carries a fresh card after the refresh');
for (const s of ps.shop.slots) if (s.kind === 'chess') assert.ok(tierOf(s.id) >= 1 && tierOf(s.id) <= 2, `drawn at level 2 (tier ${tierOf(s.id)})`);
// 2 → 3 keeps 4 operator slots: nothing rerolled, nothing added
const s2 = ps.shop.slots.slice();
h.m.handle('p_0', { t: 'g.levelUp' });
assert.equal(ps.shop.level, 3);
assert.deepEqual(ps.shop.slots, s2, 'no extra slot at level 3, no reroll');
// 3 → 4: the fifth operator slot
// 3 → 4: the fifth operator slot, empty again
h.m.handle('p_0', { t: 'g.levelUp' });
assert.equal(ps.shop.level, 4);
assert.deepEqual(ps.shop.layout, { chess: 5, item: 1 });
for (let i = 0; i < 4; i++) assert.equal(ps.shop.slots[i], s2[i]);
assert.equal(ps.shop.slots[5], s2[4]);
assert.ok(ps.shop.slots[4]?.kind === 'chess' && tierOf(ps.shop.slots[4].id) <= 4);
assert.equal(ps.shop.slots[4], null, 'the new slot is open and empty');
h.invariants();
h.m.dispose();
});
test('a bought card stays sold; a frozen shop freezes the new card too [ASSUMED], and the round start keeps it', () => {
test('a bought card stays sold; the empty new slot has nothing to freeze; the round start fills the shop to the new level', () => {
const h = makeMatch({ mode: 'solo', difficulty: 'NORMAL', seed: 11, fake: true }).start();
h.toPrep(1);
const ps = h.ps('p_0');
@@ -58,35 +61,43 @@ test('a bought card stays sold; a frozen shop freezes the new card too [ASSUMED]
assert.deepEqual(h.m.handle('p_0', { t: 'g.freeze' }), { ok: true });
h.m.handle('p_0', { t: 'g.levelUp' });
assert.equal(ps.shop.slots[0].sold, true, 'the bought slot is not refilled');
const added = ps.shop.slots[3];
assert.ok(added && !added.sold && added.frozen === true, 'the new card follows the freeze toggle');
assert.equal(ps.shop.slots[3], null, 'the new slot is open and empty — nothing to freeze');
const keep = ps.shop.slots.filter((s) => s && !s.sold && s.frozen).map((s) => s.id);
assert.equal(keep.length, 4, 'two operator cards, the new one and the item');
// the next round start keeps every frozen card (in place) and rolls only the sold slot
assert.equal(keep.length, 3, 'two operator cards and the item carry the freeze toggle');
// the round start fills every slot of the new level: frozen cards kept in place, the sold and the empty slot rolled
h.drive(() => h.m.phase === 'PREP' && h.m.round === 2);
assert.deepEqual(ps.shop.slots.slice(1).map((s) => s.id), keep);
assert.equal(ps.shop.slots.length, 5);
for (const s of ps.shop.slots) assert.ok(s, 'the round start leaves no empty slot');
assert.deepEqual(ps.shop.slots.slice(1).map((s) => s.id).slice(0, 2), keep.slice(0, 2), 'the frozen operator cards kept');
assert.equal(ps.shop.slots[4].id, keep[2], 'the frozen item kept after the operator slots');
assert.notEqual(ps.shop.slots[0].id, undefined, 'the sold slot rerolled');
h.invariants();
h.m.dispose();
});
test('every mode\'s slot table: a level-up adds exactly the slots the new level has more of (标准: 3,4,4,4,4,5)', () => {
test('every mode\'s slot table: a level-up opens exactly the new positions, empty (标准: 3,4,4,4,4,5)', () => {
for (const [mode, difficulty] of [['solo', 'FUNNY'], ['solo', 'NORMAL'], ['coop', 'FUNNY'], ['coop', 'ABYSS']]) {
const h = makeMatch({ mode, difficulty, seed: 3, fake: true, humans: 1, bots: mode === 'coop' ? 1 : 0 }).start();
h.toPrep(1);
const ps = h.ps('p_0');
const gd = h.m.gd;
ps.funds = 500;
let empty = 0;
for (let lv = 2; lv <= gd.maxShopLevel; lv++) {
const before = ps.shop.slots.slice();
assert.deepEqual(h.m.handle('p_0', { t: 'g.levelUp' }), { ok: true });
const { chess, item } = gd.shopSlots(lv);
assert.equal(ps.shop.slots.length, chess + item, `${mode} ${difficulty} L${lv}`);
const grew = chess - gd.shopSlots(lv - 1).chess;
const kept = ps.shop.slots.filter((s) => before.includes(s)).length;
assert.equal(kept, before.length, `${mode} ${difficulty} L${lv}: every shown card kept`);
assert.equal(ps.shop.slots.length - before.length, grew + (item - gd.shopSlots(lv - 1).item));
const grew = (chess - gd.shopSlots(lv - 1).chess) + (item - gd.shopSlots(lv - 1).item);
const kept = ps.shop.slots.filter((s) => s && before.includes(s)).length;
assert.equal(kept, before.filter((s) => s).length, `${mode} ${difficulty} L${lv}: every shown card kept`);
// the empty positions accumulate: nothing fills them between the upgrades (no refresh / round start here)
empty += grew;
assert.equal(ps.shop.slots.filter((s) => !s).length, empty, `${mode} ${difficulty} L${lv}: the new positions are open and empty`);
}
// one refresh fills everything at the final level
assert.deepEqual(h.m.handle('p_0', { t: 'g.refresh' }), { ok: true });
for (const s of ps.shop.slots) assert.ok(s, `${mode} ${difficulty}: the refresh leaves no empty slot`);
h.invariants();
h.m.dispose();
}
Loaded 100 of 577 files, more files were not shown because too many files have changed in this diff. Show more