Merge upstream 0.2.2 and resolve reroll conflicts (#408)

This commit is contained in:
Siornya committed 2026-10-10 00:46:46 +08:00
commit be095d8638
447 files changed
+33376 -8843

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;
}
});
+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"/);
});
+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]);
});
+52
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();
+28
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]) {
@@ -587,6 +603,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 } }) } },
+89 -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]);
+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,67 @@
// 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';
/** 琳琅诗怀雅 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);
}
});
// ---------------------------------------------------------------------------------------------------------------------
// 魔王
+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);
});
+102 -4
View File
@@ -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);
@@ -958,11 +968,14 @@ test('拟态物质 with 2 copies owned and none left in the pool gives nothing
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.ok(!ps.find(it.uid), `${item}: consumed`);
// …and says why (GitHub #401, the owner's OK of 2026-10-09)
assert.deepEqual(warns, [{ msgid: '{who}:卡池中已没有{name}', who: '拟态物质', name: '溯光星源' }], `${item}: the toast`);
}
// 2 normal copies, no elite, the pool drained by other players: nothing either
{
@@ -971,9 +984,11 @@ test('拟态物质 with 2 copies owned and none left in the pool gives nothing
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(warns.map((w) => w.msgid), ['{who}:卡池中已没有{name}']);
}
// 2 normal copies with copies left: the 3rd merges into the elite
{
@@ -981,12 +996,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 and no same-bond operator left gives nothing and says so (GitHub #401)', () => {
// 缪尔赛思'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, [], 'nothing granted');
assert.deepEqual(warns, [{ msgid: '{who}:没有可获得的同盟约干员', who: '拟态物质', name: null }]);
});
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 +1151,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 +1170,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 +1197,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 +1208,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);
+24
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;
+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({
+7 -3
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);
@@ -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);
+21
View File
@@ -454,6 +454,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 }],
+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);
+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();
});
+29 -1
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`).
@@ -558,7 +559,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 +729,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);
});
});
+22 -8
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/);
@@ -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\(/);
});
+4 -2
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 = [];
+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;
+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 (191 operators, 2674 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, 191);
// 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, 2674);
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;
}
});
+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
+3 -2
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
+3 -1
View File
@@ -56,7 +56,9 @@ test('matches: the 补位 match\'s human seat fields stand-ins (listed per round
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}`];
+763 -771
View File
File diff suppressed because it is too large. Load diff
+3097 -3115
View File
File diff suppressed because it is too large. Load diff
+782 -819
View File
File diff suppressed because it is too large. Load diff
+531 -529
View File
File diff suppressed because it is too large. Load diff
+1556 -1563
View File
File diff suppressed because it is too large. Load diff
+251 -239
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,10 +356,10 @@
"round": 12,
"seed": 816286799,
"waveId": "act1autochess_h05",
"time": 223.57,
"ticks": 6707,
"time": 223.2,
"ticks": 6696,
"reason": "cleared",
"killed": 10,
"killed": 9,
"total": 80,
"unspawned": 0,
"bossHpLeft": null,
@@ -355,13 +367,13 @@
"errors": {"count":0,"kinds":[]},
"players": {
"p1": {
"killed": 10,
"killed": 9,
"total": 80,
"perfect": false,
"leaks": 70,
"leaks": 71,
"leaksUncounted": 0,
"coins": 4,
"dmg": 143200,
"dmg": 146478,
"bossDmg": 0,
"heal": 0,
"deaths": 25,
@@ -377,30 +389,30 @@
["p1",6,"op","chess_char_4_22_b",1,"none",3191,1,112,0,959,14040,0,6,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,17210,4785,0,32,1],
["p1",10,"op","chess_char_4_24_b",1,"none",1007.59,1,111,2,5402,3072,0,19,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,3,107254,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_1005_yokai_2": [18,3,15,3213,10312],
"enemy_1041_lazerd_2": [24,3,21,43210,35712],
"enemy_1042_frostd": [30,1,29,0,34310],
"enemy_1045_hammer": [1,1,0,2029,15552],
"enemy_1045_hammer_2": [1,0,1,3891,18162],
"enemy_1045_hammer_2": [1,0,1,4288,18162],
"enemy_1046_agent": [1,1,0,72,3888],
"enemy_1050_lslime": [1,0,1,9027,5281],
"enemy_1050_lslime": [1,0,1,9094,6601],
"enemy_1061_zomshd": [1,0,1,61,13892],
"enemy_1062_rager_2": [1,0,1,2632,17550],
"enemy_1062_rager_2": [1,0,1,2628,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},
"hooks": [1,112,6696,299,299,578,571,566,34,105,13,13,0,9182,8,14,80,71,1,34,2,0,7],
"events": {"atk":324,"bounty":2,"deploy":30,"die":34,"dmg":571,"engage":11,"fx":22,"layer":2,"leak":71,"skill":26,"spawn":94,"status":111},
"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"
"snaps": "801e90ec c1cca6cb f2a5a38b 6dc62d98 cb792a41 23ec9de6 62a40e3b eb8791c8 5cf201e4 e0c50494 b5bd31db 490ef7be f2c93ce1 ee84f980 68266f8f d66f411d 6f00ab8c 98dfa057 343e0f1c 7a9470b3 c574bda9 8be7c32a dadff196"
},
"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;
}
+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, []);
});
});
+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
@@ -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})`);
+40 -1
View File
@@ -38,9 +38,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 +174,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();
+144 -2
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);
@@ -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;
+2
View File
@@ -150,6 +150,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();
});
+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();
}
+85 -1
View File
@@ -11,7 +11,7 @@ import { fieldModel } from '../../server/match/bot.js';
import { FIELD, parseKey } from '../../server/match/board.js';
import { createRng } from '../../server/sim/rng.js';
import { FakeBattle } from './fakeBattle.js';
import { DATA, makeMatch, checkInvariants } from './harness.js';
import { DATA, makeMatch, checkInvariants, giveItem } from './harness.js';
const bossFields = () => FakeBattle.instances.filter((b) => b.kind === 'boss' || b.kind === 'hidden');
@@ -56,6 +56,90 @@ test('multi-round bounties spawn in the Final Assault on the owner\'s half, show
m.dispose();
});
// A 战术特训 card pays its coins when the player's own battle is perfect (PRTS "若各自行动阶段就达成完美作战,获得N资金");
// the Final Assault and the Hidden Core are such battles — they pay it with the kill coins, at the next income.
test('perfect-payout bounties in the boss rounds: a 战术特训 card\'s coins join the kill coins at the next income (R14 → R15, then the Hidden Core)', () => {
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 2, seed: 61, fake: true,
script: (b) => (b.kind === 'boss' || b.kind === 'hidden' ? { bossDps: 1e9, coins: { p_0: 3 } } : {}) }).start();
const m = h.m;
h.toPrep(14);
const ps = h.ps('p_0');
const [card, second] = DATA.choices.cards.bounty.filter((c) => c.payout === 'perfect' && m.gd.enemy(c.enemyKey) && !m.gd.inactiveEnemies.has(c.enemyKey));
m.addBounty(ps, card);
const pending = ps.pendingFunds, gained = ps.stats.fundsGained;
h.drive(() => m.phase === PHASE.FINAL_ASSAULT);
m.hiddenLayerSum = 1e6; // the Hidden Core opens
assert.ok(bossFields()[0].opts.spawns.some((s) => s.tag === 'bounty' && s.enemyKey === card.enemyKey && s.ownerPlayerId === ps.playerId), 'the card\'s enemies are in the boss battle');
h.run(() => m.runner === null);
assert.equal(ps.pendingFunds, pending + 3 + card.coin, 'kill coins + one card amount, regardless of its enemy count');
assert.equal(ps.stats.fundsGained, gained + 3 + card.coin);
const left = bountyBattles(card);
assert.equal(ps.bounties.length, left > 1 ? 1 : 0, 'the boss battle used one of its battles');
if (left > 1) assert.equal(ps.bounties[0].roundsLeft, left - 1);
h.toPrep(15);
assert.equal(ps.pendingFunds, 0);
assert.equal(ps.funds, m.gd.income(15) + pending + 3 + card.coin, 'the base income is separate from the bounty funds');
m.addBounty(ps, second);
const owed = ps.bounties.filter((b) => b.card.payout === 'perfect').reduce((n, b) => n + b.card.coin, 0);
h.drive(() => m.phase === PHASE.HIDDEN_CORE);
assert.ok(bossFields().at(-1).opts.spawns.some((s) => s.tag === 'bounty' && s.enemyKey === second.enemyKey), 'and in the Hidden Core');
h.runToEnd();
assert.equal(ps.pendingFunds, 3 + owed, 'the Hidden Core pays by the same rule');
assert.equal(ps.bounties.length, 0, 'every bounty used its battles');
checkInvariants(m);
m.dispose();
});
test('教鞭 in the boss rounds (R14, R15): the card the owner chose spawns in the boss battle and pays at the next income, the Hidden Core takes another choice', () => {
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 2, seed: 61, fake: true,
script: (b) => (b.kind === 'boss' || b.kind === 'hidden' ? { bossDps: 1e9, coins: { p_0: 3 } } : {}) }).start();
const m = h.m;
h.toPrep(14);
const ps = h.ps('p_0');
const art = giveItem(m, ps, 'chess_item_6_03_m');
assert.deepEqual(ps.useArt(art.uid, 10, 5), { ok: true });
assert.equal(ps.bounties.length, 0, 'nothing is applied before the owner confirms');
assert.equal(ps.setReady(true).error, 'BAD_TARGET', 'Ready waits for the choice');
const card = ps.personalChoice.cards[2];
assert.deepEqual(m.handle(ps.playerId, { t: 'g.choice', idx: 2, choiceId: ps.personalChoice.id }), { ok: true });
assert.equal(ps.bounties[0].card.effectId, card.effectId, 'the card of the tapped slot');
const pending = ps.pendingFunds;
h.drive(() => m.phase === PHASE.FINAL_ASSAULT);
m.hiddenLayerSum = 1e6;
assert.ok(bossFields()[0].opts.spawns.some((s) => s.tag === 'bounty' && s.enemyKey === card.enemyKey && s.ownerPlayerId === ps.playerId), 'the chosen card\'s enemies come in the boss battle');
h.run(() => m.runner === null);
assert.equal(ps.pendingFunds, pending + 3 + card.coin, 'kill coins + the chosen card\'s coins');
h.toPrep(15);
assert.equal(ps.funds, m.gd.income(15) + pending + 3 + card.coin, 'paid at the R15 income, apart from the base income');
const hiddenArt = giveItem(m, ps, 'chess_item_6_03_m');
assert.deepEqual(ps.useArt(hiddenArt.uid, 10, 5), { ok: true });
const hiddenCard = ps.personalChoice.cards[0];
assert.deepEqual(m.handle(ps.playerId, { t: 'g.choice', idx: 0, choiceId: ps.personalChoice.id }), { ok: true });
const owed = ps.bounties.filter((b) => b.card.payout === 'perfect').reduce((n, b) => n + b.card.coin, 0);
h.drive(() => m.phase === PHASE.HIDDEN_CORE);
assert.ok(bossFields().at(-1).opts.spawns.some((s) => s.tag === 'bounty' && s.enemyKey === hiddenCard.enemyKey));
h.runToEnd();
assert.equal(ps.pendingFunds, 3 + owed, 'the Hidden Core pays by the same rule');
checkInvariants(m);
m.dispose();
});
test('boss victory without a perfect own result keeps the kill coins but gives no perfect-card funds', () => {
const h = makeMatch({ mode: 'coop', humans: 2, seed: 61, fake: true,
script: (b) => (b.kind === 'boss' ? { bossDps: 1e9, leaks: { p_0: 1 }, coins: { p_0: 3 } } : {}) }).start();
h.toPrep(14);
const m = h.m, ps = h.ps('p_0');
const card = DATA.choices.cards.bounty.find((c) => c.payout === 'perfect' && !m.gd.inactiveEnemies.has(c.enemyKey));
m.addBounty(ps, { ...card, rounds: 2, multiRound: false });
const gained = ps.stats.fundsGained;
const result = h.drive(() => h.ended != null);
assert.ok(result && h.ended.victory);
assert.equal(ps.pendingFunds, 3);
assert.equal(ps.stats.fundsGained, gained + 3);
assert.equal(ps.bounties[0].roundsLeft, 1);
m.dispose();
});
test('solo Final Assault: the bounty spawns on the `_s` template; bountySpawns route choice by side', () => {
const h = makeMatch({ mode: 'solo', difficulty: 'HARD', seed: 62, fake: true, script: (b) => (b.kind === 'boss' ? { bossDps: 1e9 } : {}) }).start();
const m = h.m;
+16
View File
@@ -397,6 +397,22 @@ test('collectViolations reports corrupted state (funds, pool accounting, duplica
m.dispose();
});
test('collectViolations: a 教鞭 choice belongs to its owner\'s open prep only', () => {
const { m, ps } = prepSolo({ seed: 922 });
const art = giveItem(m, ps, 'chess_item_6_03_m');
assert.deepEqual(ps.useArt(art.uid, 10, 5), { ok: true });
assert.deepEqual(collectViolations(m), [], 'an open choice in its own prep is fine');
const pc = ps.personalChoice;
pc.round = m.round - 1;
assert.match(collectViolations(m).join('\n'), /personal choice outside its own prep/, 'a choice left from an earlier round');
pc.round = m.round;
pc.cards = [pc.cards[0], pc.cards[0]];
assert.match(collectViolations(m).join('\n'), /personal choice of 2 cards/, 'the same card twice');
ps.personalChoice = null;
assert.deepEqual(collectViolations(m), []);
m.dispose();
});
test('the rule auditor flags an LP loss that does not match the leaks (engine regression guard)', () => {
const h = makeMatch({ mode: 'coop', humans: 2, seed: 921, fake: true, script: (b) => (b.kind === 'normal' ? { leaks: Object.fromEntries(b.players.map((p) => [p, 2])) } : {}) }).start();
const m = h.m;
+4 -1
View File
@@ -7,7 +7,8 @@
// seed, matchNo (the room's match number: part of the battleId prefix), data (default: real data/*.json),
// fake (true → test/match/fakeBattle.js as BattleClass), script (FakeBattle.script), registry, instant (virtual
// scheduler runs battles synchronously; default true), timerScale, battleContent, botRehearsal (default 0),
// botSliceMs (bot rehearsal slice budget; default: unbounded in virtual time).
// botSliceMs (bot rehearsal slice budget; default: unbounded in virtual time), aiPicksLast (the room option
// AI 队友最后选择: the drafts order every human seat before every AI seat).
// Combat mode: clientCombat (default false here: the legacy server-run mode most suites were written for; production
// defaults to client-side combat, DESIGN §14). With clientCombat: true every human gets a scripted browser
// (test/match/simClient.js SimClient: h.clients) unless clients: false; pace 'instant' | 'paced', perPlayer
@@ -77,6 +78,7 @@ export function makeMatch(o = {}) {
clientCombat: o.clientCombat ?? false,
verify: o.verify ?? 'off',
headlessSliceMs: o.headlessSliceMs,
aiPicksLast: o.aiPicksLast,
});
const m = h.m;
if (m.clientCombat && o.clients !== false) {
@@ -122,6 +124,7 @@ export function makeMatch(o = {}) {
if (idx != null) m.handle(ps.playerId, { t: 'g.choice', idx });
}
if (ready && m.phase === 'PREP' && ps.alive && !ps.ready) {
if (ps.personalChoice) m.handle(ps.playerId, { t: 'g.choice', idx: 0, choiceId: ps.personalChoice.id });
if (!ps.tempEmpty) ps.resolveTemp();
m.handle(ps.playerId, { t: 'g.ready', ready: true });
}
+281
View File
@@ -0,0 +1,281 @@
// test/match/hud-capsule-resolved.test.js — the HUD capsule's server half (PR #157): m.public.fields[].progress carries
// the field's own `resolved` (knocked out + leaked among the enemies the round scheduled), and
// * a bot / server-run field has no authority reporting b.progress — its capsule must read the battle's own counters
// (`timelineSample` → Battle.resolved), not the report-driven `progress.leaks`, which stays 0 forever (maintainer
// review point ①);
// * `progress.resolved` starts at **null**, never 0: only a real reported number is adopted, so the client's
// `resolved ?? killed` fallback keeps working (a `Number(null) === 0` would defeat it) — and a reported 0 is a real
// value, told apart from "not reported" (review point ②);
// * a finished two-half field (联防 with two helpers, the boss pair) publishes the FIELD's own `resolved` — the capsule's
// number — never the sum of the players' own clamps: an enemy that spawns on one half and leaks on the other is
// billed to one player's `total` and to the other's `leakedInTotal`, so both players' min(total, …) read 0 for it
// while the field resolved it (found reviewing the capsule port, 2026-10-08).
// Run: node --test test/match/hud-capsule-resolved.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { PHASE } from '../../shared/constants.js';
import { makeMatch, DATA } from './harness.js';
import { timelineSample, timelineAt, validateClientResult, runHeadless } from '../../server/match/fields.js';
import { buildBattleSpec, createBattleFromSpec, compactResult, battleProgress } from '../../server/sim/spec.js';
import { GameData } from '../../server/match/gamedata.js';
import { MatchViews } from '../../server/match/match/views.js';
import { makeBattle } from '../helpers/battleHarness.js';
import { teammateProgress } from '../../public/js/battle/observe.js';
import { uniteLeftMark } from '../../public/js/battle/runner.js';
const fields = (h) => h.m.fields;
const progressOf = (m, fieldId) => (m.publicView().fields.find((f) => f.fieldId === fieldId) || {}).progress;
test('① 机器人战场 (mode server, 无上报): 胶囊读该战斗自己的计数器,不是停在 0 的 progress.leaks', () => {
// no FakeBattle: the bot's field runs the real sim on the server (HeadlessJob + timeline), and nobody reports b.progress
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 1, bots: 1, seed: 7, instant: false, clientCombat: true, clients: false }).start();
const m = h.m;
h.toPrep(1);
m.handle('p_0', { t: 'g.ready', ready: true });
h.run(() => m.phase === PHASE.COMBAT);
const f = fields(h).find((x) => x.fieldId === 'n:ai_0');
assert.equal(f.mode, 'server', 'the bot field runs on the server');
assert.equal(f.authority, null, 'no authority ever reports it');
assert.ok(f.timeline && f.timeline.length > 1, 'it has a progress timeline');
assert.equal(f.progress.leaks, 0, 'nothing was reported: progress.leaks stays 0');
assert.equal(f.progress.resolved, null, 'and progress.resolved stays null (never a fabricated 0)');
let filled = 0;
for (let i = 0; i < 20 && !f.done; i++) {
h.sched.advance(2000);
const pr = progressOf(m, 'n:ai_0');
// the capsule reads the battle's own counters, sampled on the field clock
assert.equal(pr.resolved, timelineAt(f.timeline, m._fieldElapsed(f))[3], 'resolved = the timeline sample');
assert.ok(pr.resolved != null && pr.resolved <= pr.total, 'a real number, never above the denominator');
if (pr.resolved > 0) { filled = pr.resolved; break; }
}
assert.ok(filled > 0, 'the bot field\'s capsule fills up (a report-driven number would have stayed at 0)');
// the sample is composed of the battle's own two counters — the field's own scheduled enemies that were resolved
assert.equal(timelineSample(f.battle)[3], Math.min(f.battle.total, f.battle.killedInTotal + f.battle.leakedInTotal));
assert.equal(progressOf(m, 'n:ai_0').resolved, timelineAt(f.timeline, m._fieldElapsed(f))[3], 'clamped by the field clock');
});
test('① 一场只漏不杀的机器人战场: resolved 由 leakedInTotal 顶起来 (不是 0)', () => {
// three walkers, nobody to stop them: the field's own enemies leak — leakedInTotal is what the capsule must show
const h = makeBattle({ seed: 5, enemies: [{ key: 'enemy_1005_yokai', count: 3 }], content: 'full' });
h.runToEnd(120);
assert.equal(h.b.total, 3);
assert.equal(h.b.leakedInTotal, 3, 'the field leaked all three of its own enemies');
assert.equal(h.b.leakedCount, 3, 'counted leaks — the LP charge, unchanged from master');
const sample = timelineSample(h.b);
assert.equal(sample[3], 3, 'the timeline sample carries the capsule numerator');
assert.equal(battleProgress(h.b).resolved, 3, 'b.progress too (what a teammate HUD reads)');
assert.equal(battleProgress(h.b).total, 3);
assert.equal(battleProgress(h.b).killed, 0);
});
test('② 上报没有 resolved ⇒ progress.resolved = null,客户端回落到 killed;上报 resolved: 0 ⇒ 采用 0', () => {
const h = makeMatch({ mode: 'coop', humans: 2, bots: 1, seed: 9103, fake: true, clientCombat: true, clients: false, script: () => ({ duration: 6 }) }).start();
const m = h.m;
h.toPrep(1);
m.handle('p_0', { t: 'g.ready', ready: true });
m.handle('p_1', { t: 'g.ready', ready: true });
h.run(() => m.phase === PHASE.COMBAT);
const human = fields(h).find((x) => x.fieldId === 'n:p_0');
assert.equal(human.mode, 'client');
assert.equal(human.progress.resolved, null, 'a fresh field: resolved unknown, not 0');
// a report without `resolved` (an older client / a field that cannot count it) leaves it unknown
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);
assert.deepEqual(progressOf(m, 'n:p_0'), { killed: 3, resolved: null, total: 9, done: false });
// … and the teammate HUD falls back to `killed` (master's display: resolved ?? killed)
const mine = (pub) => teammateProgress(pub, 'p_1').find((x) => x.playerId === 'p_0');
assert.deepEqual(mine(m.publicView()), { playerId: 'p_0', name: 'P0', isBot: false, killed: 3, resolved: 3, total: 9, done: false });
// a reported 0 is a real value: adopted, and the HUD shows 0 (not the fallback)
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);
assert.deepEqual(progressOf(m, 'n:p_0'), { killed: 3, resolved: 0, total: 9, done: false });
assert.deepEqual(mine(m.publicView()), { playerId: 'p_0', name: 'P0', isBot: false, killed: 3, resolved: 0, total: 9, done: false });
});
test('a done field\'s m.public progress carries the reported resolved (never a fabricated one)', () => {
const h = makeMatch({ mode: 'coop', humans: 2, bots: 1, seed: 9104, fake: true, clientCombat: true, clients: false, script: () => ({ duration: 2 }) }).start();
const m = h.m;
h.toPrep(1);
m.handle('p_0', { t: 'g.ready', ready: true });
m.handle('p_1', { t: 'g.ready', ready: true });
h.run(() => m.phase === PHASE.COMBAT);
const human = fields(h).find((x) => x.fieldId === 'n:p_0');
// a result carrying the capsule numbers: 5 counted knock-outs (a split child included), 3 of the round's own resolved
const result = {
reason: 'cleared', time: 6, killed: 5, total: 3, resolved: 3,
perPlayer: { p_0: { killed: 5, total: 3, resolved: 3, leaked: [], perfect: true, layerGains: {}, coins: 0, damageDealt: 0, bossDamage: 0, healingDone: 0, deaths: 0, unitsEnd: [], unitStats: [] } },
errors: 0,
};
assert.deepEqual(m.handle('p_0', { t: 'b.result', battleId: human.battleId, result }), { ok: true });
m.flush(true);
const pr = progressOf(m, 'n:p_0');
assert.equal(pr.done, true);
assert.equal(pr.killed, 5, '`killed` keeps the counted reading (may exceed the denominator)');
assert.equal(pr.total, 3, 'the denominator is the round\'s own list');
assert.equal(pr.resolved, 3, 'the capsule numerator of the finished field');
});
test('a 联防 battle\'s live-counter mark still moves for a spawn — `total` no longer does (a split child / a summon is outside the capsule)', () => {
// the runner re-reads the leakers' enemies still standing (uniteLeft) only when this mark moves; before the capsule
// counted the round's own enemies only, every counted spawn raised `total` and so moved it
const h = makeBattle({ seed: 41, autoFinish: false, timeLimit: 60, enemies: [{ key: 'enemy_1195_sfyin', pos: [9, 6] }] });
h.step();
const m0 = uniteLeftMark(h.b);
assert.equal(h.b.total, 1);
assert.equal(uniteLeftMark(h.b), m0, 'nothing happened: the same mark');
const child = h.spawn('enemy_1196_msfyin', { pos: [9, 4] }); // a runtime spawn: counted, outside the denominator
assert.ok(child && child.counted && !child.inTotal);
assert.equal(h.b.total, 1, 'the denominator did not move');
assert.notEqual(uniteLeftMark(h.b), m0, 'the mark did: the spawn is a standing enemy the counter must read');
const m1 = uniteLeftMark(h.b);
h.b.kill(child, null);
assert.notEqual(uniteLeftMark(h.b), m1, 'a knock-out moves it');
});
// ---- an enemy that crosses the midline of a two-half field -------------------------------------------------------------
//
// The capsule counts per FIELD (Battle.resolved = min(total, killedInTotal + leakedInTotal)), but the sim also keeps every
// player's own counters: `total` and a knock-out belong to the half the enemy spawned on, a leak to the half whose tile it
// reached (deploy.js _recordLeak). A lane that runs through both halves — the 联防 lane (col 18 → col 2), the boss pair's
// crossing routes — bills one enemy's `total` to one player and its leak to the other, so each player's own
// min(total, killedInTotal + leakedInTotal) clamps it away: 0 and 0 for an enemy the field resolved. A finished field must
// publish the field's own number (what the capsule showed), never the sum of the players' clamps.
const gd = new GameData(DATA, 'mode_multi_hard');
/** m.public.fields[].progress of a finished client-combat field (the done branch of Match._fieldProgress). */
const doneView = (result, progress = { killed: 0, total: 0, leaks: 0, resolved: null }) => MatchViews.prototype._fieldProgress.call({}, { cc: true, done: true, result, progress });
const clone = (v) => JSON.parse(JSON.stringify(v));
/** A 联防 spec of two helpers who fielded nothing, and one escaped enemy that walks the whole lane (col 18 → col 2). */
function uniteCrossSpec() {
return buildBattleSpec({
battleId: 'mid-u', fieldId: 'u', kind: 'unite', seed: 11, modeId: 'mode_multi_normal', round: 1, stageId: 'act1autochess_m02',
rect: { r0: 9, r1: 12, c0: 0, c1: 20 }, timeLimit: 90,
players: [
{ playerId: 'p_1', seat: 1, side: 'L', colOffset: 8, units: [], bonds: {} }, // the half the lane starts on (col 18)
{ playerId: 'p_2', seat: 2, side: 'L', colOffset: 0, units: [], bonds: {} }, // the half it leaves on (col 2)
],
routes: [{ motion: 'WALK', start: [9, 18], end: [9, 2], checkpoints: [] }],
spawns: [{ time: 1, enemyKey: 'enemy_1007_slime', routeIndex: 0, count: 1, sourcePlayerId: 'p_0', mods: { hpMul: 0.8, atkMul: 0.8, speedMul: 1, slot: 'N' } }],
flags: { layerGainsEnabled: false, dpInit: 10, dpPerSec: 1, dpMax: 99 },
});
}
const newBattle = (spec) => createBattleFromSpec(spec, null, { recordEvents: false, quiet: true });
test('联防 whose escaped enemies enter on one helper\'s half and leak on the other\'s: the finished field publishes the capsule\'s own 4/4, not 0/4', () => {
let ran = null;
// every client runs the 联防 spec with the REAL sim (the fake battles only stand in for the normal round); the helpers
// fielded nothing, so the leaker's four escaped enemies walk the whole lane and leak on the far half
const realUnite = (result, spec) => {
if (spec.kind !== 'unite') return result;
const b = createBattleFromSpec(spec, h.m.ds, { recordEvents: false, quiet: true });
const res = b.runToEnd(4000);
ran = { res, capsule: battleProgress(b) };
return compactResult(res);
};
const h = makeMatch({ mode: 'coop', humans: 3, seed: 9111, fake: true, clientCombat: true,
script: (b) => (b.kind === 'normal' ? { leaks: { p_0: 4 } } : {}),
perPlayer: Object.fromEntries(['p_0', 'p_1', 'p_2'].map((pid) => [pid, { tamper: realUnite }])) }).start();
const m = h.m;
h.toPrep(1);
const unite = () => fields(h).find((x) => x.fieldId === 'u');
assert.ok(h.drive(() => (unite() && unite().done) || h.ended != null) && unite().done, 'the 联防 field finished');
const f = unite();
assert.equal(f.resultSource, 'client', 'the authority\'s own result was accepted');
assert.ok(ran, 'the authority ran the 联防 spec with the real sim');
// the premise: the enemies crossed the midline — `total` on the half they entered, the leaks on the half they left
const per = Object.values(ran.res.perPlayer);
assert.deepEqual(per.map((p) => p.total).sort(), [0, 4], 'every total is billed to the half the enemies spawned on');
assert.deepEqual(per.map((p) => p.leaked.length).sort(), [0, 4], 'every leak to the half they reached');
assert.deepEqual(per.map((p) => p.resolved), [0, 0], 'so each player\'s own min(total, …) reads 0');
assert.equal(ran.capsule.resolved, 4, 'while the field\'s capsule read 4/4');
assert.equal(ran.res.resolved, 4);
// what the server keeps and publishes is the field's number
assert.equal(f.result.resolved, 4, 'the validated result carries the field-level numerator');
m.flush(true);
assert.deepEqual(progressOf(m, 'u'), { killed: 0, resolved: 4, total: 4, done: true }, 'm.public: the capsule\'s own 4/4');
m.dispose();
});
test('the boss pair: an enemy spawned on one half that leaks on the other half\'s goal reads 1/1 in the published progress too', () => {
const PAIR = [
{ playerId: 'L', seat: 0, side: 'L', colOffset: 0, units: [], bonds: {} },
{ playerId: 'R', seat: 1, side: 'R', colOffset: 8, units: [], bonds: {} },
];
// route 3 of the flat boss stage: the centre gate (col 10, the left half's) → the right goal (col 18, the right half's)
const h = makeBattle({ kind: 'boss', players: PAIR, enemies: [{ key: 'enemy_1007_slime', time: 0, route: 3 }], timeLimit: Infinity, content: 'none', autoFinish: true });
h.runToEnd(120);
const res = h.result();
assert.equal(res.reason, 'cleared');
assert.equal(res.perPlayer.L.total, 1, 'billed to the half it spawned on');
assert.equal(res.perPlayer.R.leaked.length, 1, 'leaked on the other half');
assert.deepEqual([res.perPlayer.L.resolved, res.perPlayer.R.resolved], [0, 0], 'each player\'s own clamp reads 0');
assert.equal(res.resolved, 1, 'the field resolved it');
const spec = buildBattleSpec({ battleId: 'mid-b', fieldId: 'b:pair', kind: 'boss', seed: 3, round: 8, players: PAIR,
spawns: [{ time: 0, enemyKey: 'enemy_1007_slime', routeIndex: 3, count: 1 }], flags: { layerGainsEnabled: false } });
const v = validateClientResult(spec, compactResult(res), { gd });
assert.ok(v.ok, v.reason);
assert.equal(v.result.resolved, 1);
assert.deepEqual(doneView(v.result), { killed: 0, resolved: 1, total: 1, done: true });
});
test('a server-run 联防 field (a bot, a takeover) reads the same: Battle.result()\'s field-level number, and so does its timeline', () => {
const spec = uniteCrossSpec();
const run = runHeadless(newBattle(spec), { players: spec.players.map((p) => p.playerId) });
const res = run.result;
assert.equal(res.reason, 'cleared');
assert.deepEqual(Object.values(res.perPlayer).map((p) => [p.total, p.leaked.length, p.resolved]).sort(), [[0, 1, 0], [1, 0, 0]], 'the lane crossed the midline');
assert.equal(res.resolved, 1);
assert.deepEqual(doneView(res), { killed: 0, resolved: 1, total: 1, done: true }, 'the done field of a server run');
assert.equal(run.timeline[run.timeline.length - 1][3], 1, 'the live timeline sample of that field read 1 too');
// SP_VERIFY compares a client result with the server's run: both go through the same validation
assert.equal(validateClientResult(spec, compactResult(res), { gd }).result.resolved, 1);
});
test('validateClientResult: the field-level resolved is kept as reported (clamped to the field\'s total); the players\' own numbers are only the fallback', () => {
const spec = uniteCrossSpec();
const raw = compactResult(newBattle(spec).runToEnd(200));
assert.equal(raw.resolved, 1);
const checked = (edit) => {
const r = clone(raw);
edit(r);
const v = validateClientResult(spec, r, { gd });
assert.ok(v.ok, v.reason);
return v.result;
};
assert.equal(checked(() => {}).resolved, 1, 'as the client reported it (the per-player numbers are both 0 here)');
assert.equal(checked((r) => { r.resolved = 0; }).resolved, 0, 'a reported 0 is a value');
assert.equal(checked((r) => { r.resolved = 99; }).resolved, 1, 'never above the field\'s total');
// an older client (or a hand-made result) that reports no field-level number: the players' own, when every one has one
assert.equal(checked((r) => { delete r.resolved; r.perPlayer.p_1.resolved = 1; }).resolved, 1, 'the fallback is the sum of the players\' own numbers');
assert.equal(checked((r) => { delete r.resolved; r.perPlayer.p_1.resolved = 5; r.perPlayer.p_2.resolved = 5; }).resolved, 1, 'the fallback too is clamped to the total');
// a number that is not a non-negative integer counts as not reported
for (const junk of [-1, 0.5, '1', null, NaN]) assert.equal(checked((r) => { r.resolved = junk; r.perPlayer.p_1.resolved = 1; }).resolved, 1, `junk ${String(junk)} → the fallback`);
// nothing reported anywhere, or one player without a number: absent — the teammate UI falls back to `killed`, never to a fabricated 0
assert.equal('resolved' in checked((r) => { delete r.resolved; delete r.perPlayer.p_1.resolved; delete r.perPlayer.p_2.resolved; }), false);
assert.equal('resolved' in checked((r) => { delete r.resolved; delete r.perPlayer.p_2.resolved; }), false);
assert.equal(doneView(checked((r) => { delete r.resolved; delete r.perPlayer.p_1.resolved; delete r.perPlayer.p_2.resolved; })).resolved, null, 'm.public: null, not 0');
});
test('a finished field\'s progress: the result\'s field-level numerator wins, the players\' own are the fallback, a synthetic result keeps the field\'s live report', () => {
const pp = (o) => ({ killed: 0, total: 3, leaked: [], perfect: true, layerGains: {}, coins: 0, ...o });
// the field-level number wins over the players' own (a crossing field sums to less than it resolved)
assert.deepEqual(doneView({ perPlayer: { a: pp({ total: 3, resolved: 0 }), b: pp({ total: 0, resolved: 0 }) }, resolved: 2 }), { killed: 0, resolved: 2, total: 3, done: true });
assert.equal(doneView({ perPlayer: { a: pp({ resolved: 0 }) }, resolved: 9 }).resolved, 3, 'clamped to the published total');
assert.equal(doneView({ perPlayer: { a: pp({ resolved: 0 }) }, resolved: 0 }).resolved, 0, 'a reported 0 is a value');
// no field-level number: the sum of the players' own when every one reported, else unknown (never a fabricated 0)
assert.equal(doneView({ perPlayer: { a: pp({ resolved: 1 }), b: pp({ resolved: 2 }) } }).resolved, 3);
assert.equal(doneView({ perPlayer: { a: pp({ resolved: 1 }), b: pp({}) } }).resolved, null);
assert.equal(doneView({ perPlayer: { a: pp({}) } }).resolved, null);
// a synthetic result (the field could not run: zeroes everywhere) shows the last live report instead
const synthetic = { synthetic: true, resolved: 0, perPlayer: { a: pp({ total: 0, resolved: 0 }) } };
assert.deepEqual(doneView(synthetic, { killed: 2, total: 5, leaks: 0, resolved: 3 }), { killed: 2, resolved: 3, total: 5, done: true });
assert.deepEqual(doneView(synthetic, { killed: 2, total: 5, leaks: 0, resolved: null }), { killed: 2, resolved: null, total: 5, done: true });
});
+63
View File
@@ -202,6 +202,69 @@ test('co-op spectator over websockets: watches the real match like an eliminated
assert.deepEqual(errors, []);
});
// GitHub #120 (PR #120 by @salt-fishes — the in-match 观战席 list, ui/hud.js SpectatorPill): the host frees a spectator seat WHILE
// the match runs. The server took room.removeSpectator at any time (server/lobby.js removeSpectator; its header says "any
// time") but the lobby tests only removed one in the lobby: this is the running match.
test('co-op spectator removed by the host while the match runs: room.closed {kicked}, the seat freed and refilled, no frame after it, the other spectator watches on', async () => {
FakeBattle.reset();
FakeBattle.script = () => ({ duration: 1 });
const watcher = async (name) => {
const srvNow = await server();
const c = await TestClient.connect(`ws://127.0.0.1:${srvNow.port}/ws`);
clients.push(c);
const w = await c.hello(name);
c.id = w.playerId;
c.token = w.token;
return c;
};
const a = await player('A');
await ok(a, { t: 'room.create', mode: 'coop', difficulty: 'NORMAL' });
const st = await a.waitFor('room.state');
await ok(a, { t: 'room.addBot' });
const s1 = await watcher('Spec1');
const s2 = await watcher('Spec2');
await ok(s1, { t: 'room.spectate', code: st.code });
await ok(s2, { t: 'room.spectate', code: st.code });
await ok(a, { t: 'room.start' });
await s1.waitFor('m.public', (p) => p.phase === 'INFO_CHECK');
await ok(a, { t: 'g.infoReady' });
await pickCoopBands([a]);
await a.waitFor('m.public', (p) => p.phase === 'PREP' && p.round === 1, 10000);
const running = await a.waitFor('room.state', (x) => x.inMatch && x.spectators.length === 2);
assert.deepEqual(running.spectators.map((x) => x.playerId), [s1.id, s2.id], 'both seats are listed while the match runs: what the game screen\'s capsule shows');
// who may remove: the host only, and only a spectator of this room
assert.equal((await s1.request({ t: 'room.removeSpectator', playerId: s2.id })).code, 'NOT_HOST', 'a spectator cannot remove another');
assert.equal((await a.request({ t: 'room.removeSpectator', playerId: 'nobody' })).code, 'BAD_TARGET');
assert.equal((await a.request({ t: 'room.removeSpectator', playerId: a.id })).code, 'BAD_TARGET', 'a player is no spectator');
// the host removes the first one: told room.closed {kicked}; the room.state of the match now lists the other only
await ok(a, { t: 'room.removeSpectator', playerId: s1.id });
assert.equal((await s1.waitFor('room.closed', () => true, 5000)).reason, 'kicked');
const after = await a.waitFor('room.state', (x) => x.inMatch && x.spectators.length === 1);
assert.deepEqual(after.spectators.map((x) => x.playerId), [s2.id]);
assert.equal((await s2.waitFor('room.state', (x) => x.inMatch && x.spectators.length === 1)).spectators[0].playerId, s2.id, 'the other spectator is told too');
assert.equal((await a.request({ t: 'room.removeSpectator', playerId: s1.id })).code, 'BAD_TARGET', 'a seat already freed');
assert.equal((await s1.request({ t: 'g.watch', fieldId: `n:${a.id}` })).code, 'NOT_IN_ROOM', 'out of the room: no more watching');
// the match runs on: the removed seat gets no frame of it any more, the other spectator and the player do
const mark = s1.log.length;
s2.log.length = 0;
await ok(a, { t: 'g.ready', ready: true });
await s2.waitFor('b.start', (x) => x.kind === 'normal', 10000);
await a.waitFor('m.public', (p) => p.phase === 'PREP' && p.round === 2, 15000);
assert.deepEqual(s1.log.slice(mark).filter((x) => x.t !== 'pong').map((x) => x.t), [], 'nothing reaches the removed spectator');
assert.ok(s2.log.some((x) => x.t === 'm.public'), 'the other spectator still gets the match');
// the freed seat can be taken again (no ban list: the key is all it takes)
const s3 = await watcher('Spec3');
await ok(s3, { t: 'room.spectate', code: st.code });
const refilled = await a.waitFor('room.state', (x) => x.inMatch && x.spectators.length === 2);
assert.deepEqual(refilled.spectators.map((x) => x.playerId), [s2.id, s3.id]);
assert.deepEqual(errors, []);
await ok(a, { t: 'g.leave' });
});
test('server-run fallback (SP_COMBAT=server): the match simulates every field and streams m.field + b.snap', async () => {
FakeBattle.reset();
FakeBattle.script = () => ({ duration: 1 });
+6 -1
View File
@@ -22,7 +22,12 @@ test('observeTarget: prep, own battle running / over, boss pairs, eliminated pla
assert.deepEqual(observeTarget(players[1], combat(true), 'a', { ownDone: true }), { fieldId: 'n:b' }, 'the local battle already ended');
assert.deepEqual(observeTarget(players[1], combat(false), 'a'), { fieldId: 'n:b' });
assert.deepEqual(observeTarget(players[0], combat(false), 'a', { observing: true }), { back: true });
assert.deepEqual(teammateProgress(combat(false), 'a'), [{ playerId: 'b', name: 'B', isBot: false, killed: 3, total: 8, done: false }]);
// the teammate's capsule reads `resolved` (the field's own enemies knocked out or leaked); a report without one falls
// back to `killed` (public/js/battle/observe.js teammateProgress)
assert.deepEqual(teammateProgress(combat(false), 'a'), [{ playerId: 'b', name: 'B', isBot: false, killed: 3, resolved: 3, total: 8, done: false }]);
const reported = pubOf('COMBAT', [{ fieldId: 'n:b', kind: 'normal', players: ['b'], live: true, progress: { killed: 5, resolved: 3, total: 8, done: false } }], players);
assert.deepEqual(teammateProgress(reported, 'a'), [{ playerId: 'b', name: 'B', isBot: false, killed: 5, resolved: 3, total: 8, done: false }],
'a reported `resolved` wins over `killed` (5 counted knock-outs — splits included —, 3 of the round\'s own enemies resolved)');
const fa = pubOf('FINAL_ASSAULT', [
{ fieldId: 'b1', kind: 'boss', players: ['a', 'b'], live: true },
{ fieldId: 'b2', kind: 'boss', players: ['d'], live: true },
+7 -1
View File
@@ -430,7 +430,13 @@ test('#7 a leaked enemy that splits (磨砻: DeadSpawn ×2) raises the counter
seen.push(p.uniteLeft);
h.sched.advance(500);
}
assert.ok(b.total > SENT, `the 磨砻 split (${SENT} → ${b.total} on the field)`);
// the split children are runtime spawns: they enter neither part of the HUD capsule (PR #157: the denominator counts
// only what the 联防 scheduled — `b.total` — and the numerator only that set's own knock-outs / leaks), while the
// 联防 live counter (m.public uniteLeft) still bills them to the leaker
const kids = b.units.filter((u) => u.side === 'enemy' && !u.inTotal).length;
assert.ok(kids > 0, `the 磨砻 split into ${kids} runtime children`);
assert.equal(b.total, SENT, `the children stay out of the capsule's denominator (total = the ${SENT} the 联防 scheduled)`);
assert.equal(b.resolved, Math.min(SENT, b.killedInTotal + b.leakedInTotal), 'the capsule numerator counts only the 联防\'s own enemies');
const rises = seen.filter((v, i) => i > 0 && v > seen[i - 1]).length;
assert.ok(rises > 0, `the counter rose after a split: ${seen.filter((v, i) => i === 0 || v !== seen[i - 1]).join(' → ')}`);
assert.ok(Math.max(...seen) > SENT, 'above the number sent in (the old clamp hid it)');
+146
View File
@@ -0,0 +1,146 @@
// test/match/potential.test.js — the player's 潜能 / 练度 on the server (0.2.2, the owner's decision of 2026-10-08:
// 「调配干员里自己设置吧,默认满潜满加成」): room.loadout `ops` (shared/protocol.js checkLoadoutOps) → seats[].ops →
// PlayerState.ops (re-checked, frozen; bots none) → every owned operator's PlayerBattleInput entry states `potential` /
// `cultivate` (defaults 6 / 3 — also an old loadout without ops), never a 补位 stand-in or a prototype 自选 pick; the
// 自选 summons' hand count follows the owner's potential; m.private.ops; the scouting units; Match.setLoadout takes
// them in INFO_CHECK only.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { ERR, PHASE } from '../../shared/constants.js';
import { validateC2S, checkLoadoutOps, isLoadoutOps, cultivationCharIds, LOADOUT_LIMITS } from '../../shared/protocol.js';
import { buildBattleSpec, createBattleFromSpec } from '../../server/sim/spec.js';
import { DATA, makeMatch } from './harness.js';
const chess = (id) => (Object.hasOwn(DATA.chess, id) ? DATA.chess[id] : null);
const VENDLA = 'chess_char_1_06_a'; // 刺玫 (PRESET)
const VENDLA_CHAR = chess(VENDLA).charId;
const GRANI = Object.values(DATA.chess).find((c) => c.chessType === 'NORMAL' && !c.isGolden && c.visible && c.backup?.charId && c.backup.charId !== c.charId);
const T6A = 'chess_char_6_diy1_a';
const T5A = 'chess_char_5_diy1_a';
const KALTS = 'char_003_kalts';
const WANG = 'char_2027_wang';
const SHARP = 'char_609_acguad';
const IDS = cultivationCharIds(DATA.chess, DATA.backups);
function place(ps, chessId, row, col) {
const p = ps.newPiece('chess', chessId);
ps.board.set(`${row},${col}`, p);
return p;
}
test('room.loadout ops: structure (≤ 256 charIds → { potential 1–6?, cultivate 0–3? }) and the strict data check', () => {
const ok = (ops) => validateC2S({ t: 'room.loadout', entries: {}, ops }) === null;
assert.ok(validateC2S({ t: 'room.loadout', entries: {} }) === null, 'ops optional (an older client)');
assert.ok(ok({}));
assert.ok(ok({ [VENDLA_CHAR]: { potential: 1 } }));
assert.ok(ok({ [VENDLA_CHAR]: { cultivate: 0 } }));
assert.ok(ok({ [VENDLA_CHAR]: { potential: 6, cultivate: 3 } }));
for (const bad of [[], null, { [VENDLA_CHAR]: {} }, { [VENDLA_CHAR]: { potential: 0 } }, { [VENDLA_CHAR]: { potential: 6.5 } },
{ [VENDLA_CHAR]: { cultivate: -1 } }, { [VENDLA_CHAR]: { cultivate: 4 } }, { [VENDLA_CHAR]: { potential: '3' } }, { 'a b': { potential: 1 } },
{ [VENDLA_CHAR]: { potential: 1, trust: 100 } }]) assert.ok(!ok(bad), JSON.stringify(bad));
const many = Object.fromEntries(Array.from({ length: LOADOUT_LIMITS.ops + 1 }, (_, i) => [`char_${i}`, { potential: 1 }]));
assert.ok(!isLoadoutOps(many));
// the operators one may set: the 112-chess roster's charIds (hidden twins share them) and the 71 owned 自选 picks
assert.ok(IDS.has(VENDLA_CHAR) && IDS.has(KALTS) && IDS.has(WANG));
assert.ok(!IDS.has(SHARP) && !IDS.has(GRANI.backup.charId), 'a prototype / stand-in character is no setting');
assert.ok(IDS.size >= 180 && IDS.size <= 200, `${IDS.size}`);
const has = (id) => IDS.has(id);
assert.deepEqual(checkLoadoutOps(undefined, has), { ok: true, ops: {} });
assert.deepEqual(checkLoadoutOps({ [VENDLA_CHAR]: { potential: 2 }, [KALTS]: { potential: 6, cultivate: 3 } }, has), { ok: true, ops: { [VENDLA_CHAR]: { potential: 2, cultivate: 3 } } });
assert.equal(checkLoadoutOps({ [SHARP]: { potential: 2 } }, has).error, 'BAD_TARGET');
assert.equal(checkLoadoutOps({ [VENDLA_CHAR]: { potential: 9 } }, has).error, 'BAD_MSG');
});
test('battle input: every owned operator states 潜能 / 练度 — the human\'s settings, the defaults (6 / 3) for the rest, for a seat without ops and for bots', () => {
const ops = { [VENDLA_CHAR]: { potential: 1, cultivate: 0 } };
const seats = [
{ seat: 0, playerId: 'p_0', name: 'P0', isBot: false, connected: true, loadout: null, ops, notOwned: [GRANI.chessId] },
{ seat: 1, playerId: 'p_1', name: 'P1', isBot: false, connected: true, loadout: {} }, // an old seat: no ops
{ seat: 2, playerId: 'ai_0', name: 'AI0', isBot: true, connected: true, ops },
];
const h = makeMatch({ mode: 'coop', seats, seed: 3 }).start();
const m = h.m;
const [p0, p1, bot] = ['p_0', 'p_1', 'ai_0'].map((id) => h.ps(id));
assert.deepEqual(p0.ops, { [VENDLA_CHAR]: { potential: 1, cultivate: 0 } });
assert.ok(Object.isFrozen(p0.ops) && Object.isFrozen(p0.ops[VENDLA_CHAR]));
assert.deepEqual([p1.ops, bot.ops], [{}, {}], 'no settings / a bot: the defaults');
h.flushAll();
assert.deepEqual(h.lastTo('p_0', 'm.private').ops, p0.ops, 'm.private exposes the effective settings');
h.toPrep(1);
for (const ps of [p0, p1, bot]) { for (const p of [...ps.board.values()]) ps.returnCopies(p); ps.board.clear(); }
place(p0, VENDLA, 9, 3);
place(p0, chess(VENDLA).goldenId, 10, 3);
place(p0, 'chess_char_1_02_a', 11, 3);
place(p0, GRANI.chessId, 12, 3); // not owned: its stand-in
place(p1, VENDLA, 9, 3);
place(bot, VENDLA, 9, 3);
const by = (ps, id) => ps.battleInput().units.find((u) => u.chessId === id);
assert.deepEqual([by(p0, VENDLA).potential, by(p0, VENDLA).cultivate], [1, 0], 'the human\'s setting');
assert.deepEqual([by(p0, chess(VENDLA).goldenId).potential, by(p0, chess(VENDLA).goldenId).cultivate], [1, 0], 'the elite shares the charId');
assert.deepEqual([by(p0, 'chess_char_1_02_a').potential, by(p0, 'chess_char_1_02_a').cultivate], [6, 3], 'no entry: 满潜满加成');
assert.deepEqual(['potential' in by(p0, GRANI.chessId), 'cultivate' in by(p0, GRANI.chessId), by(p0, GRANI.chessId).standIn], [false, false, true], 'a stand-in has neither');
assert.deepEqual([by(p1, VENDLA).potential, by(p1, VENDLA).cultivate], [6, 3], 'an old loadout: the defaults');
assert.deepEqual([by(bot, VENDLA).potential, by(bot, VENDLA).cultivate], [6, 3], 'bots: the defaults');
// the spec keeps them and the battle fields them
const spec = buildBattleSpec(m._normalOpts(p0));
const b = createBattleFromSpec(spec, undefined, { quiet: true });
const u = b.allyUnits.find((x) => x.defId === VENDLA);
const e = b.allyUnits.find((x) => x.defId === 'chess_char_1_02_a');
assert.deepEqual([u.base.atk, u.base.cost, u.cultivate, u.s.atk], [413, 17, 0, 413]);
assert.deepEqual([e.cultivate, e.s.maxHp], [3, e.base.maxHp * 1.1]);
// the scouting units carry them (a teammate's card)
const scout = m.prepFieldMeta(p0).units;
const sv = scout.find((x) => x.defId === VENDLA);
assert.deepEqual([sv.potential, sv.cultivate], [1, 0]);
const sg = scout.find((x) => x.defId === GRANI.chessId);
assert.deepEqual([sg.potential, sg.cultivate], [undefined, undefined]);
assert.deepEqual([scout.find((x) => x.defId === 'chess_char_1_02_a').potential, scout.find((x) => x.defId === 'chess_char_1_02_a').cultivate], [undefined, 3]);
m.dispose();
});
test('自选: an owned pick states its operator\'s settings, a prototype neither; 望\'s 棋子 hand count follows her potential', () => {
const ops = { [KALTS]: { potential: 2, cultivate: 1 }, [WANG]: { potential: 2 } };
const seats = [{ seat: 0, playerId: 'p_0', name: 'P0', isBot: false, connected: true, ops,
diy: { [T6A]: { charId: KALTS, skillIndex: 0 }, [T5A]: { charId: SHARP }, chess_char_6_diy2_a: { charId: WANG, skillIndex: 0 } } }];
const h = makeMatch({ mode: 'solo', seats, seed: 7 }).start();
const m = h.m;
const p0 = h.ps('p_0');
h.toPrep(1);
for (const p of [...p0.board.values()]) p0.returnCopies(p);
p0.board.clear();
place(p0, T6A, 10, 3);
place(p0, T5A, 10, 5);
const units = p0.battleInput().units;
const k = units.find((u) => u.chessId === T6A);
const s = units.find((u) => u.chessId === T5A);
assert.deepEqual([k.diy.charId, k.potential, k.cultivate], [KALTS, 2, 1]);
assert.deepEqual([s.diy.charId, 'potential' in s, 'cultivate' in s], [SHARP, false, false]);
// 望 (潜能 2): 6 棋子 to the hand, not 7
const lo = p0.loadoutFor(p0.gd.chess('chess_char_6_diy2_a'));
assert.equal(lo.potential, 2);
assert.deepEqual(p0.gd.placeableTokens('chess_char_6_diy2_a', lo), [{ tokenId: 'token_10064_wang_stone1', count: 6 }]);
assert.deepEqual(p0.gd.placeableTokens('chess_char_6_diy2_a', { ...lo, potential: 6 }), [{ tokenId: 'token_10064_wang_stone1', count: 7 }]);
m.dispose();
});
test('Match.setLoadout: the settings change with the loadout in INFO_CHECK only; re-checked; a refusal changes nothing', () => {
const h = makeMatch({ mode: 'coop', humans: 2, bots: 1, seed: 4 }).start();
const m = h.m;
const ps = h.ps('p_0');
assert.deepEqual(ps.ops, {});
assert.deepEqual(m.setLoadout('p_0', {}, { [VENDLA_CHAR]: { potential: 3 } }), { ok: true });
assert.deepEqual(ps.ops, { [VENDLA_CHAR]: { potential: 3, cultivate: 3 } });
assert.deepEqual(m.setLoadout('p_0', {}), { ok: true }, 'no ops argument: unchanged');
assert.deepEqual(ps.ops, { [VENDLA_CHAR]: { potential: 3, cultivate: 3 } });
assert.equal(m.setLoadout('p_0', {}, { [SHARP]: { potential: 1 } }).error, ERR.BAD_TARGET);
assert.equal(m.setLoadout('p_0', { chess_char_1_01_a: { skill: 0 } }, { [VENDLA_CHAR]: { potential: 9 } }).error, ERR.BAD_TARGET);
assert.deepEqual([ps.ops, ps.loadout], [{ [VENDLA_CHAR]: { potential: 3, cultivate: 3 } }, {}], 'a refusal changes nothing');
assert.deepEqual(m.setLoadout('p_0', {}, null), { ok: true }, 'null: none set');
assert.deepEqual(ps.ops, {});
for (const id of ['p_0', 'p_1']) m.handle(id, { t: 'g.infoReady' });
h.sched.advance(1);
assert.equal(m.phase, PHASE.BAND_DRAFT);
assert.equal(m.setLoadout('p_0', {}, { [VENDLA_CHAR]: { potential: 1 } }).error, ERR.WRONG_PHASE, 'locked after INFO_CHECK');
assert.deepEqual(ps.ops, {});
m.dispose();
});
+7 -1
View File
@@ -37,6 +37,7 @@ test('#44 prep scout: held pieces are units on the hand row; deploying moves the
const u = first.units.find((x) => x.uid === piece.uid);
assert.ok(u, `${pid}: the held operator is a unit of the scout`);
assert.deepEqual([u.x, u.y], [0, GEO.HAND_ROW], `${pid}: standing on the first hand slot`);
assert.equal(u.area, 'hand', 'tagged with its area: the bond popup counts it as held, not in play (GitHub #385)');
assert.equal(u.kind, 'op');
assert.equal(u.items, undefined, 'no items equipped yet');
}
@@ -48,6 +49,7 @@ test('#44 prep scout: held pieces are units on the hand row; deploying moves the
const last = h.lastTo('p_0', 'm.field');
const u = last.units.find((x) => x.uid === piece.uid);
assert.deepEqual([u.x, u.y], [c, r], 'deployed onto the watched board');
assert.equal(u.area, 'board');
assert.equal(last.units.some((x) => x.uid === piece.uid && x.y === GEO.HAND_ROW), false, 'and gone from the hand row');
m.dispose();
});
@@ -74,6 +76,7 @@ test('#44 prep scout: a held item is a kind-item unit on the hand row; equipped
assert.ok(iu, 'the held item is a unit of the scout');
assert.equal(iu.kind, 'item');
assert.equal(iu.y, GEO.HAND_ROW);
assert.equal(iu.area, 'hand');
// equip the item onto the operator (still in the hand): the operator's unit carries the item id
assert.deepEqual(m.handle('p_1', { t: 'g.equip', itemUid: item.uid, targetUid: op.uid }), { ok: true });
const after = h.lastTo('p_0', 'm.field');
@@ -109,7 +112,10 @@ test("PR #129 review: moving a hand piece to another slot is a scout change (x f
const first = h.lastTo('p_0', 'm.field');
const tu = first.units.find((x) => x.uid === c.uid);
assert.deepEqual([tu.x, tu.y], [GEO.TEMP_C0, GEO.TEMP_ROW], 'the temp piece scouts on the temp row (first slot, col 4)');
assert.equal(first.units.find((x) => x.uid === a.uid).x, 0);
assert.equal(tu.area, 'temp');
const hu = first.units.find((x) => x.uid === a.uid);
assert.equal(hu.x, 0);
assert.equal(hu.area, 'hand');
// a hand piece moved to another slot keeps uid and id — the slot must be in the signature for the push to fire
assert.deepEqual(m.handle('p_1', { t: 'g.move', uid: a.uid, to: { area: 'hand', idx: 3 } }), { ok: true });
+90
View File
@@ -55,3 +55,93 @@ test('#87 prep watch: seat B moves during prep and seat A\'s view of B changes b
assert.equal(h.allTo(S, 'm.private').length, 0);
m.dispose();
});
for (const clientCombat of [false, true]) {
test(`#346 prep watch: a bounty choice refreshes existing teammate and spectator views (clientCombat=${clientCombat})`, (t) => {
const S = 's_spec';
// Only the earlier battles are fake; the draft, choice, views and notifications are real.
const h = makeMatch({ mode: 'coop', difficulty: 'HARD', humans: 2, seed: 1, fake: true, clientCombat, spectators: [S] }).start();
const m = h.m;
t.after(() => m.dispose());
assert.ok(h.drive(() => m.phase === PHASE.SP_DRAFT));
assert.equal(m.round, 3);
assert.equal(m.sp.family, 'bounty');
assert.equal(m.spTurn(), 'p_0');
assert.equal(m.sp.cards[0].name, '悬赏·持续III');
for (const pid of ['p_1', S]) assert.deepEqual(m.handle(pid, { t: 'g.watch', fieldId: 'n:p_0' }), { ok: true });
h.flushAll();
const before = h.lastTo(S, 'm.field');
const counts = ['p_1', S].map((pid) => [pid, h.allTo(pid, 'm.field').length]);
assert.deepEqual(m.handle('p_0', { t: 'g.choice', idx: 0 }), { ok: true });
h.flushAll();
for (const [pid, count] of counts) {
const fields = h.allTo(pid, 'm.field');
assert.equal(fields.length, count + 1, `${pid}: the choice pushes one updated scout view`);
const after = fields.at(-1);
assert.deepEqual(after.units, before.units, 'no board, hand or temp piece changed');
assert.deepEqual(after.effects.map((e) => e.name), [...before.effects.map((e) => e.name), '悬赏·持续III']);
assert.deepEqual(after.nextEnemies.filter((e) => e.tag === 'bounty').map((e) => [e.enemyKey, e.count]), [['enemy_1272_nhtank', 1]]);
assert.deepEqual(after.nextEnemies.filter((e) => e.tag !== 'bounty'), before.nextEnemies);
assert.deepEqual(after, m.prepFieldMeta(h.ps('p_0')), 'the watcher receives the current public view');
assert.equal(Object.hasOwn(after, 'funds'), false);
assert.equal(Object.hasOwn(after, 'shop'), false);
}
// Exercise dedup even when the player is dirty, rather than just flushing an empty queue.
m.markPrivate(h.ps('p_0'));
h.flushAll();
h.flushAll();
for (const [pid, count] of counts) assert.equal(h.allTo(pid, 'm.field').length, count + 1, `${pid}: unchanged public content is not pushed again`);
assert.equal(h.allTo(S, 'm.private').length, 0, 'the spectator never receives private player state');
assert.deepEqual(m.handle(S, { t: 'g.watch', fieldId: 'n:p_0' }), { ok: true });
assert.equal(h.allTo(S, 'm.field').length, counts[1][1] + 2, 'an explicit watch still resends the current view');
assert.equal(h.allTo('p_1', 'm.field').length, counts[0][1] + 1, 'the resend does not duplicate the other watcher\'s view');
// Shop actions are only legal after the draft ends, in PREP.
h.toPrep(3);
h.flushAll();
const prepCounts = ['p_1', S].map((pid) => [pid, h.allTo(pid, 'm.field').length]);
assert.deepEqual(m.handle('p_0', { t: 'g.freeze' }), { ok: true });
h.flushAll();
for (const [pid, count] of prepCounts) assert.equal(h.allTo(pid, 'm.field').length, count, `${pid}: a shop toggle does not change the scout view`);
assert.equal(h.allTo(S, 'm.private').length, 0);
assert.equal(m.errorCount, 0);
});
}
for (const field of ['effects', 'nextEnemies']) {
test(`#346 prep watch: ${field} changes alone refresh the existing view`, (t) => {
const h = makeMatch({ mode: 'coop', difficulty: 'HARD', humans: 2, seed: 1, fake: true, spectators: ['s_spec'] }).start();
const m = h.m;
t.after(() => m.dispose());
assert.ok(h.drive(() => m.phase === PHASE.SP_DRAFT));
assert.deepEqual(m.handle('p_0', { t: 'g.choice', idx: 0 }), { ok: true });
assert.deepEqual(m.handle('s_spec', { t: 'g.watch', fieldId: 'n:p_0' }), { ok: true });
h.flushAll();
const before = h.lastTo('s_spec', 'm.field');
const count = h.allTo('s_spec', 'm.field').length;
const ps = h.ps('p_0');
const bounty = ps.bounties[0];
if (field === 'effects') bounty.roundsLeft--;
else bounty.card.count++;
m.markPrivate(ps);
h.flushAll();
assert.equal(h.allTo('s_spec', 'm.field').length, count + 1);
const after = h.lastTo('s_spec', 'm.field');
assert.deepEqual(after.units, before.units);
if (field === 'effects') {
assert.equal(after.effects.at(-1).counter, 1, 'the remaining bounty battles update in place');
assert.deepEqual(after.nextEnemies, before.nextEnemies);
} else {
const enemies = after.nextEnemies.filter((e) => e.tag === 'bounty');
assert.ok(enemies.every((e) => e.enemyKey === 'enemy_1272_nhtank'));
assert.equal(enemies.reduce((n, e) => n + e.count, 0), 2, 'both bounty enemies reach the preview');
assert.deepEqual(after.effects, before.effects);
}
assert.equal(h.allTo('s_spec', 'm.private').length, 0);
assert.equal(m.errorCount, 0);
});
}
+10 -2
View File
@@ -217,6 +217,12 @@ test('boss field: the local pool follows b.pool (server hp − unacknowledged lo
assert.ok(last.bossDmg <= pool.cum);
assert.ok(last.by && typeof last.by === 'object');
assert.equal(typeof last.leaks, 'number');
// the per-player split of the leak LP (the server holds the result's leaked lists to it, Match._bossLeaksAgree): the
// leaks of every player of the field, never more than the field's LP cost (leader effects make up the rest)
assert.ok(last.leaksBy && typeof last.leaksBy === 'object', 'boss progress carries leaksBy');
assert.deepEqual(Object.keys(last.leaksBy).sort(), start.spec.players.map((p) => p.playerId).sort());
assert.ok(Object.values(last.leaksBy).every((n) => Number.isFinite(n) && n >= 0));
assert.ok(Object.values(last.leaksBy).reduce((a, b) => a + b, 0) <= last.leaks + 1e-9);
r.net.emit('b.pool', { hp: pool.maxHp * 0.5, max: pool.maxHp, teamLp: 20, acked: { [start.fieldId]: pool.cum } });
assert.ok(Math.abs(pool.hp - pool.maxHp * 0.5) < 1e-6, 'server hp when everything is acknowledged');
r.runner.dispose();
@@ -432,8 +438,10 @@ test('live unit stats (user playtest #4 item 7): unitStats(id) reads the battle
assert.equal(got.interval, Math.round(s.interval * 100) / 100);
assert.equal(got.blockCnt, s.blockCnt);
assert.equal(got.hp, Math.round(ally.hp));
assert.equal(got.base.atk, Math.round(ally.base.atk));
assert.equal(got.base.maxHp, Math.round(ally.base.maxHp));
// the unit's own numbers: its base with its 练度 (0.2.2: the match states 精英2 Lv.60 by default — ×1.1)
assert.equal(ally.cultivate, 3);
assert.equal(got.base.atk, Math.round(ally.base.atk * ally.cultMul.atk));
assert.equal(got.base.maxHp, Math.round(ally.base.maxHp * ally.cultMul.hp));
assert.equal(r.runner.unitStats(ally.id, start.fieldId)?.id, ally.id, 'on the named field');
assert.equal(r.runner.unitStats(ally.id, 'n:someone_else'), null, 'another field: nothing');
assert.equal(r.runner.unitStats(999999), null, 'unknown unit');
+154
View File
@@ -0,0 +1,154 @@
// A Picture Scroll (画卷) gain can consume its own target in an immediate three-copy promotion (GitHub #389, PR #390).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeMatch, legalTileFor } from './harness.js';
const OP = 'chess_char_1_06_a'; // 刺玫, the operator of the report
const SCROLL = 'chess_item_6_02_m';
const HAMMER = 'chess_item_1_01_e_a';
const SHIELD = 'chess_item_1_02_e_a';
const FILLER = 'chess_item_6_09_e_a'; // non-mergeable
const OK = { ok: true };
function prep(t, { copies = 2, golden = false, items = [HAMMER] } = {}) {
const h = makeMatch({ mode: 'solo', difficulty: 'FUNNY', seed: 8 }).start();
t.after(() => h.m.dispose());
h.toPrep(1, { band: 'band_dusk' });
const m = h.m, ps = h.ps('p_0');
assert.equal(m.gd.mergeCount(OP), 3);
const id = golden ? m.gd.goldenIdOf(OP) : OP;
const target = ps.acquireChess(id);
const [row, col] = legalTileFor(m, ps, id);
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: target.uid, to: { area: 'board', row, col }, dir: 'RIGHT' }), OK);
for (let i = 1; i < copies; i++) ps.acquireChess(id);
for (const itemId of items) {
const item = ps.acquireItem(itemId);
assert.deepEqual(m.handle('p_0', { t: 'g.equip', itemUid: item.uid, targetUid: target.uid }), OK);
}
const art = ps.hand.find((p) => p?.id === SCROLL);
assert.ok(art, 'Dusk grants the scroll in round 1');
h.invariants();
const cast = () => m.handle('p_0', { t: 'g.art', itemUid: art.uid, row, col, dir: 'RIGHT' });
return { h, m, ps, target, art, row, col, cast };
}
function itemIds(ps) {
return [...ps.hand, ...ps.temp, ...ps.board.values()].filter(Boolean)
.flatMap((p) => p.kind === 'item' ? [p.id] : (p.items || []).map((it) => it.id)).sort();
}
test('Picture Scroll: a third normal copy preserves the target equipment through promotion', (t) => {
const { h, m, ps, target, art, row, col, cast } = prep(t);
const funds = ps.funds;
assert.deepEqual(cast(), OK);
const elite = ps.board.get(`${row},${col}`);
assert.equal(elite.id, m.gd.goldenIdOf(OP));
assert.equal(elite.dir, 'RIGHT');
assert.equal(ps.find(target.uid), null, 'the original target was consumed');
assert.equal(ps.find(art.uid), null, 'the scroll was consumed');
assert.deepEqual(itemIds(ps), [m.gd.item(HAMMER).goldenId]);
assert.ok(ps.hand.some((p) => p?.id === m.gd.item(HAMMER).goldenId), 'the original and copied hammer merge into an advanced hammer in hand');
assert.deepEqual(elite.items, []);
assert.equal(ps.stats.merges, 1);
assert.equal(ps.stats.itemMerges, 1);
assert.equal(ps.funds, funds);
h.invariants();
});
for (const advanced of [false, true]) {
test(`Picture Scroll: promotion copies both ${advanced ? 'advanced' : 'normal'} equipped items, none of them worn by the elite`, (t) => {
const items = [HAMMER, SHIELD].map((id) => advanced ? id.replace(/_a$/, '_b') : id);
const { h, m, ps, row, col, cast } = prep(t, { items });
assert.deepEqual(cast(), OK);
const elite = ps.board.get(`${row},${col}`);
const upgraded = items.map((id) => m.gd.item(id).goldenId || id).sort();
// advanced items do not merge: the returned originals and the copies all wait in the hand ("获得的装备为未装备状态")
assert.deepEqual(itemIds(ps), advanced ? [...items, ...items].sort() : upgraded);
assert.deepEqual(elite.items, [], 'the promotion unequips, and the copies are not put back on the elite');
assert.equal([...ps.hand, ...ps.temp].filter((p) => p?.kind === 'item').length, advanced ? 4 : 2);
h.invariants();
});
}
for (const golden of [false, true]) {
test(`Picture Scroll: one ${golden ? 'elite' : 'normal'} target still copies its equipment without promotion`, (t) => {
const { h, m, ps, target, cast } = prep(t, { copies: 1, golden });
assert.deepEqual(cast(), OK);
assert.equal(ps.find(target.uid)?.piece, target);
assert.equal(ps.allChess().length, 2);
assert.equal(ps.allChess().filter((p) => p.id === target.id).length, 2);
assert.deepEqual(itemIds(ps), [m.gd.item(HAMMER).goldenId]);
assert.equal(ps.stats.merges, 0);
h.invariants();
});
}
test('Picture Scroll: a third copy without equipment still promotes normally', (t) => {
const { h, m, ps, row, col, cast } = prep(t, { items: [] });
assert.deepEqual(cast(), OK);
assert.equal(ps.board.get(`${row},${col}`).id, m.gd.goldenIdOf(OP));
assert.deepEqual(itemIds(ps), []);
h.invariants();
});
test('Picture Scroll: a promotion at full hand and temp still copies the hammer', (t) => {
const { h, m, ps, cast } = prep(t);
while (ps.hand.some((p) => p == null) || ps.temp.some((p) => p == null)) assert.ok(ps.acquireItem(FILLER));
h.invariants();
assert.deepEqual(cast(), OK);
assert.deepEqual(itemIds(ps).filter((id) => id !== FILLER), [m.gd.item(HAMMER).goldenId]);
assert.equal(itemIds(ps).filter((id) => id === FILLER).length, 13);
h.invariants();
});
test('Picture Scroll: a full hand and temp without promotion keeps the refused scroll and target equipment', (t) => {
const { h, ps, target, art, cast } = prep(t, { copies: 1 });
while (ps.hand.some((p) => p == null) || ps.temp.some((p) => p == null)) assert.ok(ps.acquireItem(FILLER));
const before = ps.privateView();
assert.equal(cast().error, 'HAND_FULL');
assert.ok(ps.find(art.uid));
assert.deepEqual(target.items.map((p) => p.id), [HAMMER]);
assert.deepEqual(ps.privateView(), before);
h.invariants();
});
// PRTS 画卷 备注 「使用后销毁,获得的装备为未装备状态」: without a promotion too, the copied items are gained like any item —
// the hand, overflow temp — and never put on the copy (until 0.2.2 a copy that did not merge was equipped onto it)
const ADV = [HAMMER, SHIELD].map((id) => id.replace(/_a$/, '_b'));
const looseItems = (ps) => [...ps.hand, ...ps.temp].filter((p) => p?.kind === 'item').map((p) => p.id).sort();
for (const golden of [false, true]) {
test(`Picture Scroll: one ${golden ? 'elite' : 'normal'} target — the copied (advanced) items arrive unequipped in the hand`, (t) => {
const { h, m, ps, target, cast } = prep(t, { copies: 1, golden, items: ADV });
assert.deepEqual(cast(), OK);
const copy = ps.allChess().find((p) => p.id === target.id && p !== target);
assert.ok(copy, 'the copy was gained');
assert.deepEqual(copy.items, [], 'the copy wears nothing');
assert.deepEqual(target.items.map((p) => p.id).sort(), [...ADV].sort(), 'the target keeps its own');
assert.deepEqual(looseItems(ps), [...ADV].sort(), 'the copied items wait in the hand');
assert.equal(m.gd.chess(copy.id).isGolden, golden);
h.invariants();
});
}
test('Picture Scroll: a full hand — the copy and its items overflow into temp; an item with no slot left is destroyed like any gained item', (t) => {
for (const free of [3, 2]) {
const { h, ps, target, cast } = prep(t, { copies: 1, items: ADV });
while (ps.hand.some((p) => p == null)) assert.ok(ps.acquireItem(FILLER));
while (ps.temp.filter((p) => p == null).length > free) assert.ok(ps.acquireItem(FILLER));
const toasts = () => h.allTo('p_0', 'm.toast').map((x) => JSON.stringify(x));
const before = toasts().length;
assert.deepEqual(cast(), OK);
const copy = ps.allChess().find((p) => p.id === target.id && p !== target);
const where = copy ? ps.find(copy.uid)?.area : null;
assert.ok(where === 'temp' || where === 'hand', `free ${free}: the copy was gained into temp (the hand fills from temp once the scroll left)`);
assert.deepEqual(copy.items, [], `free ${free}: the copy wears nothing`);
assert.deepEqual(target.items.map((p) => p.id).sort(), [...ADV].sort());
const got = looseItems(ps).filter((id) => id !== FILLER);
if (free === 3) assert.deepEqual(got, [...ADV].sort(), 'room for the copy and both items');
else {
assert.equal(got.length, 1, 'room for the copy and one item: the other is destroyed');
assert.ok(toasts().slice(before).some((s) => s.includes('整备区已满,获得的装备已销毁')), 'with the usual toast');
}
h.invariants();
}
});
+153
View File
@@ -0,0 +1,153 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { ERR } from '../../shared/constants.js';
import { makeMatch, legalTileFor, checkInvariants } from './harness.js';
const SILENCE = 'chess_char_2_02_a';
const CATHY = 'chess_char_4_11_a';
const EQUIPMENT = 'chess_item_1_02_e_b'; // Elite equipment does not merge.
function setup(t) {
const h = makeMatch({ mode: 'solo', difficulty: 'FUNNY', seed: 1, fake: true }).start();
t.after(() => h.m.dispose());
h.toPrep(1);
return { h, m: h.m, ps: h.ps('p_0') };
}
function deploy(m, ps, piece, chessId = piece.id) {
const tile = legalTileFor(m, ps, chessId);
assert.ok(tile);
assert.deepEqual(m.handle(ps.playerId, { t: 'g.move', uid: piece.uid, to: { area: 'board', row: tile[0], col: tile[1] } }), { ok: true });
}
function equip(m, ps, owner, count) {
return Array.from({ length: count }, () => {
const item = ps.acquireItem(EQUIPMENT, { silent: true });
assert.ok(item);
assert.deepEqual(m.handle(ps.playerId, { t: 'g.equip', itemUid: item.uid, targetUid: owner.uid }), { ok: true });
return item;
});
}
function fillStorage(ps) {
while ([...ps.hand, ...ps.temp].some((p) => p === null)) assert.ok(ps.acquireItem(EQUIPMENT, { silent: true }));
}
function swapIntoTemp(m, ps, piece) {
const idx = ps.hand.indexOf(piece);
const filler = ps.temp.find((p) => p?.kind === 'item');
assert.ok(idx >= 0 && filler);
assert.deepEqual(m.handle(ps.playerId, { t: 'g.move', uid: filler.uid, to: { area: 'hand', idx } }), { ok: true });
assert.equal(ps.find(piece.uid).area, 'temp');
}
function snapshot(ps) {
return structuredClone({
hand: ps.hand, temp: ps.temp, board: ps.board, funds: ps.funds, pendingFunds: ps.pendingFunds,
stats: ps.stats, round: ps.round, tempDue: ps._tempDue,
});
}
const inventory = (ps) => [...ps.hand, ...ps.temp, ...ps.board.values()].filter(Boolean);
const tokensOf = (ps, owner) => inventory(ps).filter((p) => p.kind === 'token' && p.ownerUid === owner.uid);
for (const area of ['hand', 'temp']) {
for (const itemCount of [1, 2]) {
test(`sale uses the freed ${area} summon slot to return ${itemCount} equipment item(s)`, (t) => {
const { h, m, ps } = setup(t);
const owner = ps.acquireChess(SILENCE, { silent: true });
deploy(m, ps, owner);
const items = equip(m, ps, owner, itemCount);
const other = ps.acquireChess(SILENCE, { silent: true });
deploy(m, ps, other);
const [stack] = tokensOf(ps, owner);
fillStorage(ps);
if (area === 'temp') swapIntoTemp(m, ps, stack);
assert.equal(ps.find(stack.uid).area, area);
// Two items need one existing free slot as well as the slot released by the stack.
if (itemCount === 2) {
const filler = ps.temp.find((p) => p?.kind === 'item');
assert.deepEqual(m.handle(ps.playerId, { t: 'g.destroy', uid: filler.uid }), { ok: true });
}
const untouched = structuredClone(inventory(ps).filter((p) => p !== owner && p !== stack));
const funds = ps.funds;
const poolBefore = m.pool.left(SILENCE);
const copies = owner.poolCopies;
assert.deepEqual(m.handle(ps.playerId, { t: 'g.sell', uid: owner.uid }), { ok: true });
assert.equal(ps.find(owner.uid), null);
assert.deepEqual(tokensOf(ps, owner), []);
assert.equal(ps.funds, funds + 1);
assert.equal(m.pool.left(SILENCE), poolBefore + copies);
assert.deepEqual(owner.items, []);
for (const item of items) {
assert.equal(ps.find(item.uid).piece, item, 'the original equipment object and UID return');
assert.ok(['hand', 'temp'].includes(ps.find(item.uid).area));
}
assert.equal(ps.find(items[0].uid).area, area, 'hand is preferred, with overflow into temp');
for (const piece of untouched) assert.deepEqual(ps.find(piece.uid).piece, piece);
assert.equal(inventory(ps).length, untouched.length + items.length);
assert.deepEqual(h.logs.warn, []);
checkInvariants(m);
});
}
}
for (const { name, ownerId, area, itemCount, stackCount } of [
{ name: 'one hand stack cannot hold two equipment items', ownerId: SILENCE, area: 'hand', itemCount: 2, stackCount: 1 },
{ name: 'one temp stack cannot hold two equipment items', ownerId: SILENCE, area: 'temp', itemCount: 2, stackCount: 1 },
{ name: 'a two-summon stack frees only one slot', ownerId: CATHY, area: 'hand', itemCount: 2, stackCount: 2 },
{ name: 'board summons and foreign stacks free no usable slot', ownerId: SILENCE, area: 'board', itemCount: 1, stackCount: 1 },
]) {
test(`sale rejects atomically when ${name}`, (t) => {
const { m, ps } = setup(t);
const owner = ps.acquireChess(ownerId, { silent: true });
deploy(m, ps, owner);
const items = equip(m, ps, owner, itemCount);
const other = ps.acquireChess(SILENCE, { silent: true });
deploy(m, ps, other);
const [stack] = tokensOf(ps, owner);
assert.equal(stack.count, stackCount);
if (area === 'board') deploy(m, ps, stack, owner.id);
fillStorage(ps);
if (area === 'temp') swapIntoTemp(m, ps, stack);
assert.equal(ps.find(stack.uid).area, area);
const before = snapshot(ps);
const poolBefore = m.pool.left(ownerId);
assert.equal(m.handle(ps.playerId, { t: 'g.sell', uid: owner.uid }).error, ERR.HAND_FULL);
assert.deepEqual(snapshot(ps), before, 'pieces, equipment, funds, and bookkeeping remain unchanged');
assert.equal(m.pool.left(ownerId), poolBefore);
for (const item of items) assert.equal(ps.find(item.uid).area, 'equipped');
checkInvariants(m);
// Free exactly one additional slot and retry the same public intent.
const filler = ps.temp.find((p) => p?.kind === 'item');
assert.deepEqual(m.handle(ps.playerId, { t: 'g.destroy', uid: filler.uid }), { ok: true });
assert.deepEqual(m.handle(ps.playerId, { t: 'g.sell', uid: owner.uid }), { ok: true });
assert.equal(ps.find(owner.uid), null);
assert.deepEqual(tokensOf(ps, owner), []);
assert.equal(ps.funds, before.funds + 1);
for (const item of items) {
assert.equal(ps.find(item.uid).piece, item);
assert.ok(['hand', 'temp'].includes(ps.find(item.uid).area));
}
assert.equal(tokensOf(ps, other).length, 1);
checkInvariants(m);
});
}
for (const area of ['hand', 'temp']) {
test(`sale still counts the operator's own ${area} slot`, (t) => {
const { m, ps } = setup(t);
const owner = ps.acquireChess(SILENCE, { silent: true });
const [item] = equip(m, ps, owner, 1);
fillStorage(ps);
if (area === 'temp') swapIntoTemp(m, ps, owner);
assert.equal(ps.find(owner.uid).area, area);
const funds = ps.funds;
assert.deepEqual(m.handle(ps.playerId, { t: 'g.sell', uid: owner.uid }), { ok: true });
assert.equal(ps.find(owner.uid), null);
assert.equal(ps.find(item.uid).piece, item);
assert.equal(ps.find(item.uid).area, area);
assert.equal(ps.funds, funds + 1);
checkInvariants(m);
});
}
+4
View File
@@ -35,6 +35,8 @@ test('real four-player sockets: host-only proposal, unanimous sync, reconnect an
return c;
};
const host = await player('Host');
const ops = { char_017_huang: { potential: 2, cultivate: 1 } };
await ok(host, { t: 'room.loadout', entries: {}, ops });
await ok(host, { t: 'room.create', mode: 'coop', difficulty: 'HARD' });
const room = await host.waitFor('room.state');
const guests = [];
@@ -76,6 +78,8 @@ test('real four-player sockets: host-only proposal, unanimous sync, reconnect an
assert.notDeepEqual(setupOf(fresh[0]), { ...setupOf(initial), setupRevision: 1 });
assert.equal(srv.lobby.rooms.get(room.code).match, m, 'room/match instance and sockets retained');
assert.ok(all.every((c) => c.isOpen));
assert.deepEqual(m.players.get(host.id).ops, ops, 'the new loadout fields survive reroll');
assert.deepEqual((await host.waitFor('m.private', (p) => p.ops?.char_017_huang?.potential === 2)).ops, ops);
await err(guests[0], { t: 'g.infoReady', setupRevision: 0 }, ERR.BAD_TARGET);
await err(guests[0], { t: 'g.infoReady' }, ERR.BAD_TARGET);
await err(host, { t: 'room.rerollSetup', setupRevision: 0 }, ERR.BAD_TARGET);
+5 -2
View File
@@ -53,9 +53,10 @@ test('four humans must agree; rerolls the entire official setup and stock, prese
const players = [...m.order];
const rngs = [m.rngSetup, m.rngShop, m.rngDraft, m.rngWaves, m.rngBots, m.rngMeta].map((r) => [r, r.state()]);
const p0 = h.ps('p_0');
assert.equal(p0.setLoadout(p0.loadout, { char_017_huang: { potential: 2, cultivate: 1 } }), true);
assert.equal(p0.setDiy({ chess_char_5_diy1_a: { charId: 'char_017_huang', skillIndex: 0 } }), true);
p0.initDiyStock(new Set([...m.disabledBonds, ...m.staticInactiveBonds]));
const settings = [p0.loadout, p0.standIns, p0.diy];
const settings = [p0.loadout, p0.standIns, p0.diy, p0.ops];
const stock = p0.diyStock;
m.handle('p_1', { t: 'g.infoReady' });
h.sched.advance(500);
@@ -80,8 +81,10 @@ test('four humans must agree; rerolls the entire official setup and stock, prese
assert.equal(m.stage, m.gd.stage(m.stageId));
assert.notEqual(m.pool, pool);
assert.deepEqual(m.order, players);
assert.deepEqual([p0.loadout, p0.standIns, p0.diy], settings);
assert.deepEqual([p0.loadout, p0.standIns, p0.diy, p0.ops], settings);
assert.equal(p0.diy, settings[2]);
assert.equal(p0.ops, settings[3], 'per-operator potential and cultivation stay unchanged');
assert.deepEqual(h.lastTo('p_0', 'm.private').ops, settings[3]);
assert.notEqual(p0.diyStock, stock);
const off = new Set([...bans.drawn, ...bans.staticOff]);
const diy = p0.gd.chess('chess_char_5_diy1_a');
+3
View File
@@ -129,10 +129,13 @@ export class SimClient {
_progress(e) {
const p = battleProgress(e.battle);
const msg = { t: 'b.progress', battleId: e.battleId, gt: p.gt, killed: Math.min(p.killed, p.total), total: p.total, done: p.done };
// the HUD capsule's numerator, like public/js/battle/runner.js (kept only when the battle reports one)
if (Number.isFinite(p.resolved)) msg.resolved = Math.min(p.resolved, p.total);
const bossLike = e.spec.kind === 'boss' || e.spec.kind === 'hidden';
if (bossLike) {
const pool = e.battle.sharedBoss;
msg.leaks = e.meter.lp;
if (p.leaksBy) msg.leaksBy = p.leaksBy; // like public/js/battle/runner.js
msg.bossDmg = pool && Number.isFinite(pool.cum) ? pool.cum : 0;
if (pool && pool.byPlayer) msg.by = { ...pool.byPlayer };
} else {
+5
View File
@@ -42,6 +42,11 @@ test('protocol: g.unitStats { seq? } and g.bandFocus { bandId? } validate; m.uni
assert.equal(validateC2S({ t: 'g.bandFocus', bandId: 'band_bldsk' }), null);
assert.notEqual(validateC2S({ t: 'g.bandFocus', bandId: 'a b' }), null);
assert.ok(S2C.includes('m.unitStats'));
assert.equal(validateC2S({ t: 'g.choice', idx: 0 }), null);
assert.equal(validateC2S({ t: 'g.choice', idx: 2, choiceId: 'seed-2.choice.12' }), null);
for (const choiceId of [null, '', 'a b', 12, 'a'.repeat(65)]) {
assert.notEqual(validateC2S({ t: 'g.choice', idx: 0, choiceId }), null);
}
});
test('unitStatsEntry: effective stats next to the base, rounded for display; the interval from bat / aspd when absent', () => {
+249
View File
@@ -0,0 +1,249 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { setTimeout as pause } from 'node:timers/promises';
import { PHASE } from '../../shared/constants.js';
import { runHeadless, validateClientResult, RESULT_GRACE_MS } from '../../server/match/fields.js';
import { RealScheduler } from '../../server/match/scheduler.js';
import { compactResult, createBattleFromSpec, resultDigest } from '../../server/sim/spec.js';
import { makeMatch } from './harness.js';
const SLICE_MS = 0.000001;
function combat(t, verify = 'all', options = {}) {
const h = makeMatch({ mode: 'coop', humans: 1, bots: 0, seed: 345, clientCombat: true,
clients: false, verify, headlessSliceMs: SLICE_MS, timerScale: 0, ...options });
t.after(() => h.m.dispose());
h.start();
assert.ok(h.drive(() => h.m.phase === PHASE.COMBAT));
return h;
}
function results(h, f) {
const run = runHeadless(createBattleFromSpec(f.spec, h.m.ds, { recordEvents: false, quiet: true }), { players: f.players });
const checked = validateClientResult(f.spec, compactResult(run.result), { gd: h.m.gd });
assert.ok(checked.ok, checked.reason);
const server = checked.result;
assert.ok(server.perPlayer.p_0.leaked.length > 0, 'the empty board leaks real enemies');
const forged = structuredClone(server);
forged.killed = forged.total;
Object.assign(forged.perPlayer.p_0, { leaked: [], perfect: true, killed: forged.perPlayer.p_0.total });
const plausible = validateClientResult(f.spec, forged, { gd: h.m.gd });
assert.ok(plausible.ok, plausible.reason);
return { server, forged: plausible.result };
}
function report(h, f, result) {
assert.deepEqual(h.m.handle('p_0', { t: 'b.result', battleId: f.battleId, result }), { ok: true });
}
function observeSimulation(m, onStep = () => {}) {
const seen = { battles: 0, steps: 0 };
const create = m._specBattle.bind(m);
m._specBattle = (...args) => {
const battle = create(...args);
seen.battles++;
const step = battle.step.bind(battle);
battle.step = (...stepArgs) => {
const result = step(...stepArgs);
seen.steps++;
onStep(battle, seen);
return result;
};
return battle;
};
return seen;
}
test('all verification yields between slices and settles with the synchronous server digest', (t) => {
const h = combat(t);
const m = h.m;
const f = m.fields[0];
const { server, forged } = results(h, f);
let between = null;
const seen = observeSimulation(m, (battle, state) => {
if (state.steps === 1) h.sched.setTimeout(() => {
between = { steps: state.steps, finished: battle.finished, checked: m.verifyStats.checked, done: f.done };
}, 0);
});
report(h, f, forged);
assert.equal(seen.steps, 0, 'report handling schedules simulation without stepping it');
assert.equal(f.done, false, 'the result awaits verification');
assert.equal(m.lastResults.has('p_0'), false, 'settlement cannot consume the unverified report');
assert.ok(h.run(() => m.verifyStats.checked === 1));
assert.ok(between && between.steps > 0, 'an unrelated timer runs after simulation starts');
assert.deepEqual([between.finished, between.checked, between.done], [false, 0, false], 'the timer runs between unfinished slices');
assert.equal(resultDigest(f.result).hash, resultDigest(server).hash, 'sliced verification matches synchronous simulation');
assert.equal(m.verifyStats.mismatches, 1);
assert.equal(m.verifyStats.rejected, 0, 'the forgery passes plausibility validation');
h.runToPhase(PHASE.SETTLE);
assert.deepEqual(m.lastResults.get('p_0').leaked, server.perPlayer.p_0.leaked);
assert.equal(m.errorCount, 0);
});
for (const verify of ['all', 'sample']) {
test(`${verify} verification lets an unrelated real timer run between battle slices`, async (t) => {
const h = makeMatch({ mode: 'coop', humans: 1, bots: 0, seed: 345, clientCombat: true,
clients: false, verify, headlessSliceMs: SLICE_MS, timerScale: 0 });
const m = h.m;
// Install the real scheduler before start, while the harness has no queued timers.
const scheduler = new RealScheduler();
m.sched = scheduler;
t.after(() => { m.dispose(); scheduler.dispose(); });
const waitFor = async (predicate) => {
const deadline = Date.now() + 10000;
while (!predicate() && Date.now() < deadline) await pause(1);
assert.ok(predicate(), `timed out in phase ${m.phase}`);
};
h.autoHumans().start();
await waitFor(() => m.phase === PHASE.COMBAT);
const f = m.fields[0];
const result = compactResult(runHeadless(createBattleFromSpec(f.spec, m.ds, { recordEvents: false, quiet: true }), { players: f.players }).result);
let between = null;
observeSimulation(m, (battle, seen) => {
if (seen.steps === 1) setTimeout(() => {
between = { steps: seen.steps, finished: battle.finished, checked: m.verifyStats.checked, done: f.done };
}, 0);
});
report(h, f, result);
await waitFor(() => between !== null);
assert.ok(between.steps > 0, 'the timer was scheduled by the first actual battle step');
assert.deepEqual([between.finished, between.checked, between.done], [false, 0, verify === 'sample'], 'Node executes the timer while verification is unfinished');
await waitFor(() => m.verifyStats.checked === 1);
assert.equal(resultDigest(f.result).hash, resultDigest(result).hash);
assert.equal(m.verifyStats.mismatches, 0);
assert.equal(m.errorCount, 0);
});
}
test('duplicate reports during all verification start one simulation and settle once', (t) => {
const h = combat(t);
const m = h.m;
const f = m.fields[0];
const { forged } = results(h, f);
const seen = observeSimulation(m);
let settlements = 0;
const settle = m.settle.bind(m);
m.settle = (...args) => { settlements++; return settle(...args); };
report(h, f, forged);
assert.equal(f.done, false, 'the duplicate arrives while verification is pending');
report(h, f, forged);
assert.ok(h.run(() => seen.steps > 0));
report(h, f, forged);
h.runToPhase(PHASE.SETTLE);
report(h, f, forged);
assert.equal(seen.battles, 1);
assert.equal(m.verifyStats.checked, 1);
assert.equal(m.verifyStats.mismatches, 1);
assert.equal(settlements, 1);
});
test('sample verification keeps the accepted result and finishes after the phase changes', (t) => {
// Seed 345 selects the first field for sampling; zero phase delays let settlement overtake verification.
const h = combat(t, 'sample');
const m = h.m;
const f = m.fields[0];
const { forged } = results(h, f);
const seen = observeSimulation(m);
report(h, f, forged);
assert.equal(f.done, true, 'sampling accepts the report immediately');
assert.equal(seen.steps, 0);
h.runToPhase(PHASE.SETTLE);
assert.equal(m.verifyStats.checked, 0, 'the sampled battle is still running after settlement');
assert.ok(h.run(() => m.verifyStats.checked === 1));
assert.equal(m.verifyStats.mismatches, 1);
assert.equal(resultDigest(f.result).hash, resultDigest(forged).hash);
assert.deepEqual(m.lastResults.get('p_0').leaked, [], 'sampling never replaces the accepted result');
assert.ok(h.logs.warn.some((line) => line.includes('client result differs')));
assert.equal(m.errorCount, 0);
});
for (const event of ['disconnect and reconnect', 'expired deadline']) {
test(`pending all verification does not restart simulation after ${event}`, (t) => {
const h = combat(t);
const m = h.m;
const f = m.fields[0];
const { forged } = results(h, f);
const seen = observeSimulation(m);
// Leave an already-due deadline callback queued when the result arrives.
if (event === 'expired deadline') h.sched.t += f.spec.timeLimit / m.gameSpeed * 1000 + RESULT_GRACE_MS + 1;
report(h, f, forged);
assert.equal(f.done, false);
if (event === 'disconnect and reconnect') {
m.onDisconnect('p_0');
m.onReconnect('p_0');
assert.equal(h.lastTo('p_0', 'b.start').authoritative, false, 'reconnect does not request a second result');
}
h.runToPhase(PHASE.SETTLE);
assert.equal(seen.battles, 1);
assert.equal(m.verifyStats.takeovers, 0, 'a received result no longer needs a client authority');
assert.equal(m.verifyStats.checked, 1);
assert.equal(m.verifyStats.mismatches, 1);
});
}
test('resuming a solo pause does not rearm a result deadline while verification is pending', (t) => {
const h = combat(t, 'all', { mode: 'solo' });
const m = h.m;
const f = m.fields[0];
const { forged } = results(h, f);
const seen = observeSimulation(m);
assert.deepEqual(m.handle('p_0', { t: 'g.pause', on: true }), { ok: true });
report(h, f, forged);
assert.equal(f.done, false);
assert.deepEqual(m.handle('p_0', { t: 'g.pause', on: false }), { ok: true });
h.sched.t += f.spec.timeLimit / m.gameSpeed * 1000 + RESULT_GRACE_MS + 1;
h.runToPhase(PHASE.SETTLE);
assert.equal(seen.battles, 1);
assert.equal(m.verifyStats.takeovers, 0);
assert.equal(m.verifyStats.checked, 1);
});
test('a verification comparison error falls back to a server result and completes the field', (t) => {
const h = combat(t);
const m = h.m;
const f = m.fields[0];
const { server, forged } = results(h, f);
const seen = observeSimulation(m);
const create = m._specBattle.bind(m);
m._specBattle = (...args) => {
const battle = create(...args);
if (seen.battles === 1) {
const result = battle.result.bind(battle);
battle.result = () => {
const out = result();
Object.defineProperty(out.perPlayer.p_0, 'unitStats', { get() { throw new Error('verification result cannot be normalized'); } });
return out;
};
}
return battle;
};
report(h, f, forged);
h.runToPhase(PHASE.SETTLE);
assert.equal(seen.battles, 2, 'the failed comparison is followed by one independent server run');
assert.equal(f.resultSource, 'server');
assert.equal(resultDigest(f.result).hash, resultDigest(server).hash, 'fallback never accepts the forged result');
assert.equal(m.errorCount, 1);
assert.ok(h.logs.error.some((line) => line.includes('verification result cannot be normalized')));
assert.deepEqual(h.sched.errors, []);
});
for (const verify of ['all', 'sample']) {
test(`dispose stops pending ${verify} verification without later completion`, (t) => {
const h = combat(t, verify, { timerScale: 1 });
const m = h.m;
const f = m.fields[0];
const { forged } = results(h, f);
const seen = observeSimulation(m);
report(h, f, forged);
assert.ok(h.run(() => seen.steps > 0));
assert.equal(m.verifyStats.checked, 0, 'dispose interrupts an unfinished verification');
m.dispose();
const steps = seen.steps;
h.sched.runAll({ maxSteps: 10000 });
assert.equal(seen.steps, steps, 'no further battle steps execute');
assert.equal(m.verifyStats.checked, 0);
assert.equal(m.verifyStats.mismatches, 0);
assert.equal(f.done, verify === 'sample');
assert.equal(m.errorCount, 0);
});
}
+123
View File
@@ -0,0 +1,123 @@
// test/perfbench.test.js — the battle perf tooling: the committed specs of public/dev/perf (tools/capture-specs.mjs) still
// build and run with the current data and sim, the capture helpers, the perf page's frame figures (public/dev/frame-stats.js),
// and tools/perfbench.mjs's profile summary and per-platform Chrome flags. The page itself:
// test/render/battle-perf.browser.test.js (opt-in).
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, existsSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { getData } from '../server/data.js';
import { DataSource } from '../server/sim/simdata.js';
import { createBattleFromSpec } from '../server/sim/spec.js';
import { specMeta, pickDefaults, cleanStart } from '../tools/capture-specs.mjs';
import { aggregateProfile, gpuArgs, areaOf } from '../tools/perfbench.mjs';
import { frameFigures } from '../public/dev/frame-stats.js';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const DIR = path.join(ROOT, 'public/dev/perf');
const index = JSON.parse(readFileSync(path.join(DIR, 'index.json'), 'utf8'));
describe('committed perf specs (public/dev/perf)', () => {
const ds = new DataSource(getData({ log: { warn() {}, error() {}, info() {} } }), null);
test('the index lists one spec of each kind, each file a clean b.start', () => {
assert.deepEqual(index.map((s) => s.kind), ['normal', 'unite', 'boss', 'hidden']);
for (const s of index) {
const f = path.join(DIR, `${s.name}.json`);
assert.ok(existsSync(f), s.name);
const msg = JSON.parse(readFileSync(f, 'utf8'));
assert.equal(msg.t, 'b.start');
assert.equal(msg.kind, s.kind);
assert.equal(msg.elapsed, 0);
assert.equal(msg.authoritative, true);
assert.equal(msg.spec.stageId, s.stageId);
assert.equal(msg.spec.players.length, s.players);
}
});
for (const s of index) {
test(`${s.name}: builds with the current data and runs 20 game seconds`, () => {
const msg = JSON.parse(readFileSync(path.join(DIR, `${s.name}.json`), 'utf8'));
const b = createBattleFromSpec(msg.spec, ds, { recordEvents: false });
for (let i = 0; i < 600 && !b.finished; i++) b.step();
assert.ok(b.finished || b.time >= 19.9, `battle time ${b.time}`);
const snap = b.snapshot();
assert.ok(Array.isArray(snap.units) && snap.units.length > 0, 'units on the field');
});
}
});
describe('tools/capture-specs.mjs helpers', () => {
const start = (kind, n) => ({ t: 'b.start', kind, spec: { stageId: 'st', players: [{ units: Array(n).fill({}) }, { units: Array(n).fill({}) }] }, elapsed: 3.5, startAt: 9, serverNow: 9, authoritative: false });
test('specMeta names and counts a b.start', () => {
assert.deepEqual(specMeta('HARD', start('unite', 8), 9), { name: 'hard-unite-r9', title: '联防 · 第 9 回合', kind: 'unite', round: 9, stageId: 'st', players: 2, units: 16 });
});
test('pickDefaults keeps the latest normal / unite / boss / hidden, the last send of a resent battle', () => {
const resent = start('boss', 8);
const picked = pickDefaults([
{ round: 4, msg: start('normal', 1) }, { round: 13, msg: start('normal', 8) }, { round: 7, msg: start('unite', 4) },
{ round: 9, msg: start('unite', 8) }, { round: 14, msg: start('boss', 8) }, { round: 14, msg: resent },
]);
assert.deepEqual(picked.map((c) => `${c.msg.kind}-r${c.round}`), ['normal-r13', 'unite-r9', 'boss-r14']);
assert.equal(picked[2].msg, resent);
});
test('cleanStart: from the start, authoritative, no capture clocks', () => {
const c = cleanStart(start('normal', 1));
assert.deepEqual([c.elapsed, c.startAt, c.serverNow, c.authoritative, c.watch, c.done], [0, 0, 0, true, false, false]);
});
});
describe("the perf page's frame figures (public/dev/frame-stats.js)", () => {
test('average fps, nearest-rank percentiles and the stutter share of a sample', () => {
assert.deepEqual(frameFigures([40, 16, 17, 17], 33.4), { fps: 44.4, p50: 17, p95: 40, p99: 40, over33: 25 });
});
test('one frame time is enough; no frame time gives null figures, not NaN / Infinity (the single-frame sample)', () => {
assert.deepEqual(frameFigures([16.7], 33.4), { fps: 59.9, p50: 16.7, p95: 16.7, p99: 16.7, over33: 0 });
assert.deepEqual(frameFigures([], 33.4), { fps: null, p50: null, p95: null, p99: null, over33: null });
assert.equal(frameFigures([0, 0], 33.4).fps, null);
});
});
describe('tools/perfbench.mjs', () => {
test('Chrome GPU flags per platform: D3D11 on Windows, Metal on macOS, the default elsewhere', () => {
assert.ok(gpuArgs('win32').includes('--use-angle=d3d11'));
assert.ok(gpuArgs('darwin').includes('--use-angle=metal'));
assert.ok(!gpuArgs('linux').some((a) => a.startsWith('--use-angle')));
});
test('areaOf sorts call frames by source', () => {
assert.equal(areaOf({ url: 'http://h/vendor/pixi-spine.js', functionName: 'apply' }), 'spine');
assert.equal(areaOf({ url: 'http://h/vendor/pixi.min.js', functionName: 'render' }), 'pixi');
assert.equal(areaOf({ url: 'http://h/sim/Battle.js', functionName: 'step' }), 'sim');
assert.equal(areaOf({ url: '', functionName: '(idle)' }), 'idle');
});
test('aggregateProfile: idle excluded; self time by area / function; stages inclusive, once per stack', () => {
const cf = (functionName, url) => ({ functionName, url, lineNumber: 0 });
const profile = {
nodes: [
{ id: 1, callFrame: cf('(root)', ''), children: [2, 6, 7] },
{ id: 2, callFrame: cf('frameBody', 'http://h/js/render/app.js'), children: [3] },
{ id: 3, callFrame: cf('update', 'http://h/js/render/spine.js'), children: [4] },
{ id: 4, callFrame: cf('update', 'http://h/js/render/spine.js'), children: [5] }, // recursion: counted once
{ id: 5, callFrame: cf('apply', 'http://h/vendor/pixi-spine.js'), children: [] },
{ id: 6, callFrame: cf('(idle)', ''), children: [] },
{ id: 7, callFrame: cf('(program)', ''), children: [] },
],
samples: [5, 5, 3, 6, 6, 7],
timeDeltas: [1000, 1000, 1000, 5000, 5000, 1000],
};
const s = aggregateProfile(profile);
assert.equal(s.busyMs, 4);
assert.deepEqual(s.areas.map((a) => [a.area, a.pct]), [['spine', 50], ['render', 25], ['program', 25]]);
const st = Object.fromEntries(s.stages.map((x) => [x.stage, x.pct]));
assert.equal(st.spinePose, 75);
assert.equal(st.frameBody, 75);
assert.equal(st.program, 25);
assert.equal(st.sim, 0);
assert.match(s.top[0].fn, /^apply \/vendor\/pixi-spine\.js:1$/);
});
});
+181
View File
@@ -0,0 +1,181 @@
// test/potential.test.js — potential (潜能 1–6) is a per-player runtime input (0.2.2, the owner's decision of 2026-10-08:
// 「调配干员里自己设置吧,默认满潜满加成」): the data records are built at full potential and carry what every lower
// potential changes (shared/potential.js; docs/DATA.md §2.3 — `potDown` leaves, chained talent entries). These tests
// check the composition at every potential against the raw official tables (character_table keyframes plus the
// `potentialRanks` steps up to the potential, talent candidates up to `requiredPotentialRank`), the summons at their
// owner's potential, and the data model (full potential = the record itself; a resolved record carries no annotation).
// With the official-data cache (.cache/gamedata) the re-derivations run; the spot checks always do.
// Run: node --test test/potential.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, existsSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
atPotential, atRank, talentAtRank, stripPotential, hasPotentialData, rankOf, isPotential, isCultivate, cultivateMul,
cultivatedStats, cultivationOf, CULTIVATE_EFFECTS, POTENTIAL_DEFAULT, CULTIVATE_DEFAULT, FULL_RANK,
} from '../shared/potential.js';
import { composeTalents } from '../shared/loadoutRecord.js';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const DATA = process.env.DATA_DIR || join(ROOT, 'data');
const CACHE = join(ROOT, '.cache', 'gamedata');
const HAS_CACHE = ['activity_table', 'character_table'].every((f) => existsSync(join(CACHE, 'excel', `${f}.json`)));
const load = (name) => JSON.parse(readFileSync(join(DATA, `${name}.json`), 'utf8'));
const chess = load('chess');
const tokens = load('tokens');
const backups = load('backups');
const effects = load('effects');
const raw = (f) => JSON.parse(readFileSync(join(CACHE, 'excel', `${f}.json`), 'utf8'));
const talent = (rec, name) => rec.talents.find((t) => t.name === name);
test('刺玫 at every potential: 潜能1 ATK 413 / cost 17 / 70 s and 土壤基肥改良 8%, each step at its rank, 潜能6 = the record itself', () => {
const v = chess.chess_char_1_06_a;
const at = (p) => atPotential(v, p);
const row = (p) => [at(p).stats.atk, at(p).stats.cost, at(p).stats.respawnTime, talent(at(p), '土壤基肥改良').bb.heal_scale];
assert.deepEqual(row(1), [413, 17, 70, 1.08]);
assert.deepEqual(row(2), [413, 16, 70, 1.08], '潜能2 部署费用-1');
assert.deepEqual(row(3), [413, 16, 66, 1.08], '潜能3 再部署时间-4秒');
assert.deepEqual(row(4), [435, 16, 66, 1.08], '潜能4 攻击力+22');
assert.deepEqual(row(5), [435, 16, 66, 1.11], '潜能5 天赋效果增强');
assert.deepEqual(row(6), [435, 15, 66, 1.11], '潜能6 部署费用-1');
assert.equal(at(6), v, 'full potential is the record itself');
assert.equal(at(null), v);
assert.equal(talent(at(1), '土壤基肥改良').desc, '攻击范围内生命上限最高的友方角色受到的治疗效果提升8%');
// the elite: statsBase and stats move together, the talent from its own E2 candidates
const e = chess.chess_char_1_06_b;
assert.deepEqual([atPotential(e, 1).stats.atk, atPotential(e, 1).statsBase.atk, atPotential(e, 1).stats.cost], [531, 508, 17]);
assert.equal(talent(atPotential(e, 4), '土壤基肥改良').bb.heal_scale, 1.15);
});
test('a resolved record carries no annotation; the full record minus them is what 0.2.1 shipped (the stats and talents of 潜能6)', () => {
let n = 0;
for (const c of Object.values(chess)) {
if (c.isDiy || !c.charId) continue;
for (let p = 1; p <= 5; p++) {
const r = atPotential(c, p);
assert.notEqual(r, c);
assert.equal(JSON.stringify(r).match(/"pot(Down|Min|Below)"/), null, `${c.chessId} @${p}`);
}
if (hasPotentialData(c)) n++;
}
assert.ok(n >= 250, `chess records that change below full potential: ${n}`);
// composed lists never carry chains (shared/loadoutRecord.js composeTalents)
const e = chess.chess_char_1_06_b;
assert.equal(JSON.stringify(composeTalents(e.talentsBase, e.modules[0].talentChanges)).includes('potBelow'), false);
assert.deepEqual(stripPotential({ a: [{ potMin: 4, potBelow: { x: 1 }, x: 2 }], potDown: { 0: {} } }), { a: [{ x: 2 }] });
});
test('every chess and 自选 form at every potential: the raw keyframes plus the potential steps up to it (character_table)', { skip: !HAS_CACHE && 'no .cache/gamedata' }, () => {
const CT = raw('character_table');
const KEY = { MAX_HP: 'maxHp', ATK: 'atk', DEF: 'def', MAGIC_RESISTANCE: 'res', COST: 'cost', RESPAWN_TIME: 'respawnTime', ATTACK_SPEED: 'aspd' };
const steps = (charId, rank) => {
const out = {};
for (const r of (CT[charId].potentialRanks || []).slice(0, rank)) {
for (const m of r.buff?.attributes?.attributeModifiers || []) out[KEY[m.attributeType]] = (out[KEY[m.attributeType]] || 0) + m.value;
}
return out;
};
const frame = (charId, phase, level) => {
const P = CT[charId].phases[phase];
const k0 = P.attributesKeyFrames[0], k1 = P.attributesKeyFrames[P.attributesKeyFrames.length - 1];
const t = (level - k0.level) / (k1.level - k0.level || 1);
const f = (key) => k0.data[key] + (k1.data[key] - k0.data[key]) * t;
return { maxHp: f('maxHp'), atk: f('atk'), def: f('def'), res: f('magicResistance'), cost: f('cost'), respawnTime: f('respawnTime'), aspd: f('attackSpeed') };
};
const check = (label, charId, status, stats, rank) => {
const exp = frame(charId, status.phase, status.level);
for (const [k, v] of Object.entries(steps(charId, rank))) exp[k] += v;
for (const [k, v] of Object.entries(exp)) assert.ok(Math.abs(v - stats[k]) <= 0.5 + 1e-9, `${label} rank ${rank}: ${k} ${stats[k]} vs official ${v}`);
};
let n = 0;
for (const c of Object.values(chess)) {
if (c.isDiy || !c.charId) continue;
for (let rank = 0; rank <= FULL_RANK; rank++) { const r = atRank(c, rank); check(c.chessId, c.charId, c.status, r.statsBase ?? r.stats, rank); n++; }
}
assert.equal(n, 258 * 6);
let forms = 0;
for (const [charId, u] of Object.entries(backups.units)) {
for (const f of Object.values(u.forms)) {
for (let rank = 0; rank <= FULL_RANK; rank++) check(`${charId}@${f.status.phase}/${f.status.level}`, charId, f.status, atRank(f, rank).stats, rank);
forms++;
}
}
assert.equal(forms, 256);
});
test('talent candidates at every potential: each talent is the last one unlocked with requiredPotentialRank ≤ the rank', { skip: !HAS_CACHE && 'no .cache/gamedata' }, () => {
const CT = raw('character_table');
const PH = { PHASE_0: 0, PHASE_1: 1, PHASE_2: 2 };
const ok = (c, phase, level, rank) => {
const u = c.unlockCondition;
const p = PH[u?.phase ?? 'PHASE_0'];
return (c.requiredPotentialRank || 0) <= rank && (p < phase || (p === phase && (u?.level || 1) <= level));
};
let moved = 0;
const checkList = (label, charId, status, list) => {
for (let rank = 0; rank <= FULL_RANK; rank++) {
(CT[charId].talents || []).forEach((t, index) => {
const cands = (t.candidates || []).filter((x) => ok(x, status.phase, status.level, rank));
const best = cands[cands.length - 1];
if (!best || (!best.name && !best.description && !best.blackboard.length && !best.tokenKey)) return;
const rec = talentAtRank(list.find((x) => x.index === index), rank);
assert.ok(rec, `${label} talent ${index}`);
for (const b of best.blackboard) if (!(b.valueStr && b.value === 0)) assert.ok(Math.abs(rec.bb[b.key] - b.value) < 1e-9, `${label} talent ${index} rank ${rank} ${b.key}: ${rec.bb[b.key]} vs ${b.value}`);
if (rank === 0 && (best.requiredPotentialRank || 0) === 0 && (CT[charId].talents[index].candidates || []).some((x) => (x.requiredPotentialRank || 0) > 0 && ok(x, status.phase, status.level, FULL_RANK))) moved++;
});
}
};
for (const c of Object.values(chess)) {
if (c.isDiy || !c.charId) continue;
checkList(c.chessId, c.charId, c.status, c.talentsBase ?? c.talents);
}
for (const [charId, u] of Object.entries(backups.units)) for (const f of Object.values(u.forms)) checkList(`${charId}@${f.status.phase}/${f.status.level}`, charId, f.status, f.talents);
assert.ok(moved >= 150, `talents whose potential step moves them: ${moved}`);
});
test('summons follow their owner\'s potential: Mon3tr 不毁重构 3 s / 1200 below 潜能5, 望\'s 棋子 6 deployed / 7 held below 潜能3, “小自在” 15 层', () => {
const mon = backups.tokens.token_10002_kalts_mon3tr.variants['char_003_kalts@2/1/4/0'];
assert.deepEqual([atPotential(mon, 4).talents[1].bb.stun, atPotential(mon, 4).talents[1].bb.value], [3, 1200]);
assert.deepEqual([atPotential(mon, 5).talents[1].bb.stun, atPotential(mon, 5).talents[1].bb.value], [3.5, 1400]);
const stone = backups.tokens.token_10064_wang_stone1.variants['char_2027_wang@2/1/4/0'];
assert.deepEqual([atPotential(stone, 2).stats.deployLimit, atPotential(stone, 2).stats.deckStack], [6, 7]);
assert.deepEqual([atPotential(stone, 3).stats.deployLimit, atPotential(stone, 3).stats.deckStack], [7, 8]);
const tvar = backups.tokens.token_10064_wang_stone1.variants['char_2027_wang@2/60/7/1'];
assert.equal(atPotential(tvar, 1).byModule.uniequip_002_wang.stats.deployLimit, 7, 'TRP-X: 6 + 1 below 潜能3');
assert.equal(tvar.byModule.uniequip_002_wang.stats.deployLimit, 8);
// tokens.json: 夕's “小自在” 化境 18 → 15 层 below 潜能5
const dusk = tokens.token_10015_dusk_drgn.variants.chess_char_5_12_a;
assert.deepEqual([atPotential(dusk, 4).talents[0].bb.max_stack_cnt, dusk.talents[0].bb.max_stack_cnt], [15, 18]);
// the owner's own talent text follows too (望's kit reads 「最多拥有N枚」 from it)
assert.match(atPotential(backups.units.char_2027_wang.forms['2/60/7/1'], 1).talents[0].desc, /最多拥有7枚/);
});
test('练度 (自持有): the CHAR_MAP effects in cultivateEffectList order, their char_attribute_mul as ×ATK / ×DEF / ×HP', { skip: !HAS_CACHE && 'no .cache/gamedata' }, () => {
const list = raw('activity_table').autoChessData.cultivateEffectList.slice().sort((a, b) => a.cultivateNum - b.cultivateNum);
assert.deepEqual(list.map((x) => x.effectId), [...CULTIVATE_EFFECTS]);
list.forEach((row, tier) => {
const m = cultivateMul(effects, tier);
assert.equal(effects[row.effectId].effectType, 'CHAR_MAP');
assert.deepEqual([m.atk, m.def, m.hp].map((x) => Math.round((x - 1) * 100)), [row.atkPer, row.defPer, row.hpPer], row.effectId);
});
});
test('练度 helpers: the four tiers, the default, the products, who has neither (补位 / 原型 自选)', () => {
assert.deepEqual([0, 1, 2, 3].map((n) => cultivateMul(effects, n)), [
{ atk: 1, def: 1, hp: 1 }, { atk: 1.05, def: 1.05, hp: 1 }, { atk: 1.1, def: 1.05, hp: 1.05 }, { atk: 1.1, def: 1.1, hp: 1.1 },
]);
assert.equal(cultivateMul(effects, 4), null);
assert.equal(cultivateMul(effects, null), null);
assert.deepEqual(cultivatedStats({ atk: 435, def: 77, maxHp: 1137, cost: 15 }, cultivateMul(effects, 3)), { atk: 435 * 1.1, def: 77 * 1.1, maxHp: 1137 * 1.1, cost: 15 });
assert.deepEqual([POTENTIAL_DEFAULT, CULTIVATE_DEFAULT, rankOf(1), rankOf(6), rankOf(0), rankOf('6')], [6, 3, 0, 5, 5, 5]);
assert.deepEqual([isPotential(0), isPotential(1), isPotential(6), isPotential(7), isPotential(2.5)], [false, true, true, false, false]);
assert.deepEqual([isCultivate(-1), isCultivate(0), isCultivate(3), isCultivate(4)], [false, true, true, false]);
const v = chess.chess_char_1_06_a;
assert.deepEqual(cultivationOf(v, null), { potential: 6, cultivate: 3 }, 'no setting: 满潜满加成');
assert.deepEqual(cultivationOf(v, { [v.charId]: { potential: 1 } }), { potential: 1, cultivate: 3 });
assert.deepEqual(cultivationOf(v, { [v.charId]: { potential: 9, cultivate: 0 } }), { potential: 6, cultivate: 0 }, 'a bad value is the default');
assert.equal(cultivationOf({ ...v, standInFor: 'char_x' }, null), null, '补位');
assert.equal(cultivationOf({ ...v, diyProto: true }, null), null, '原型 自选');
});
+428
View File
@@ -0,0 +1,428 @@
// 敌人普攻后摇:可选快照元数据的生产、校验与位置插值;不改动战斗规则。
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { SnapshotBuffer, normalizeSnapshot } from '../../public/js/render/interp.js';
import { ANIM, UF } from '../../shared/constants.js';
import { makeBattle, chessRec, checkInvariants } from '../helpers/battleHarness.js';
import { snapFrame } from '../../server/match/fields.js';
import { TICK } from '../../server/sim/constants.js';
import { rupture } from '../../server/sim/content/kits/ops/op-weedy.js';
const tuple = (x, anim = ANIM.ATTACK) => [1, x, 9, 100, 100, 0, 0, 0, anim];
const frame = (t, x, until, anim) => ({ t, units: [tuple(x, anim)], ...(until === undefined ? {} : { stand: [[1, until]] }) });
const near = (got, want) => assert.ok(Math.abs(got - want) < 1e-8, `${got} ≠ ${want}`);
test('stand:仅保留快照中存在的单位与有限、未来的结束时间', () => {
const valid = normalizeSnapshot({ t: 'b.snap', gt: 1, units: [tuple(0)], stand: [
null, 'x', [1], [2, 2], [1, '2'], [1, NaN], [1, Infinity], [1, 1], [1, 0.9], [1, 1.08],
] });
assert.deepEqual([...valid.stand], [[1, 1.08]]);
for (const stand of [undefined, null, {}, [], [[1, 1]], [[2, 2]]]) {
assert.equal(normalizeSnapshot({ t: 1, units: [tuple(0)], stand }).stand, null);
}
});
test('standCut:仅保留已有单位的有限、非负且不晚于快照的时间', () => {
const valid = normalizeSnapshot({ t: 'b.snap', gt: 1, units: [tuple(0)], standCut: [
null, 'x', [1], [2, 0.9], [1, '0.9'], [1, NaN], [1, Infinity], [1, -1], [1, 1.01], [1, 0.9],
] });
assert.deepEqual([...valid.standCut], [[1, 0.9]]);
for (const standCut of [undefined, null, {}, [], [[1, -1]], [[2, 0]], [[1, 1.01]]]) {
assert.equal(normalizeSnapshot({ t: 1, units: [tuple(0)], standCut }).standCut, null);
}
assert.equal(normalizeSnapshot({ t: 1, units: [tuple(0)], standCut: [[1, 0], [1, 1]] }).standCut.get(1), 1);
});
test('后摇结束前保持位置,结束后插值剩余区间;HP/SP 仍按整段时间插值', () => {
const b = new SnapshotBuffer();
b.push(frame(1, 0, 1.08), 0);
const next = frame(1.1, 0.02, undefined, ANIM.MOVE);
next.units[0][3] = 50;
next.units[0][5] = 10;
b.push(next, 0.05);
for (const t of [1, 1.04, 1.079]) {
const s = b.sample(t).get(1);
assert.equal(s.x, 0);
assert.equal(s.vx, 0);
assert.equal(s.anim, ANIM.ATTACK);
}
const middle = b.sample(1.05).get(1);
near(middle.hp, 75);
near(middle.sp, 5);
near(b.sample(1.09).get(1).x, 0.01);
near(b.sample(1.09).get(1).vx, 1);
near(b.sample(1.1).get(1).x, 0.02);
});
test('旧快照、过期元数据及攻击→移动动画不推断停步', () => {
for (const until of [undefined, 0.9, 1, NaN, Infinity]) {
const b = new SnapshotBuffer();
b.push(frame(1, 0, until), 0);
b.push(frame(1.1, 0.1, undefined, ANIM.MOVE), 0.05);
near(b.sample(1.05).get(1).x, 0.05);
near(b.sample(1.05).get(1).vx, 1);
}
});
test('后摇结束时间恰为下一帧时,位置在该帧之前保持不动', () => {
const b = new SnapshotBuffer();
b.push(frame(1, 0, 1.1), 0);
b.push(frame(1.1, 0.02), 0.05);
assert.equal(b.sample(1.09).get(1).x, 0);
near(b.sample(1.1).get(1).x, 0.02);
});
test('后摇尚未到期但新帧已移动时,不阻止权威位置更新', () => {
const b = new SnapshotBuffer();
b.push(frame(1, 0, 2), 0);
b.push(frame(1.1, 0.1, undefined, ANIM.MOVE), 0.05);
near(b.sample(1.05).get(1).x, 0.05);
});
test('后摇结束时间落在两帧之间,但该区间有打断时,插值和外推均使用完整区间', () => {
for (const cutAt of [1, 1.04, 1.1]) {
const b = new SnapshotBuffer();
b.push(frame(1, 0, 1.08), 0);
b.push({ ...frame(1.1, 0.1, undefined, ANIM.MOVE), standCut: [[1, cutAt]] }, 0.05);
near(b.sample(1.05).get(1).x, 0.05);
near(b.sample(1.099).get(1).vx, 1);
near(b.sample(1.101).get(1).vx, 1);
near(b.sample(1.15).get(1).x, 0.15);
}
});
test('旧的打断记录不影响后续未被打断的后摇', () => {
const b = new SnapshotBuffer();
b.push(frame(1, 0, 1.08), 0);
b.push({ ...frame(1.1, 0.02), standCut: [[1, 0.9]] }, 0.05);
assert.equal(b.sample(1.04).get(1).x, 0);
near(b.sample(1.09).get(1).vx, 1);
near(b.sample(1.15).get(1).vx, 1);
});
test('后摇恢复段进入外推时速度连续,零长度移动区间不产生无穷速度', () => {
const b = new SnapshotBuffer();
b.push(frame(1, 0, 1.08), 0);
b.push(frame(1.1, 0.02, undefined, ANIM.MOVE), 0.05);
near(b.sample(1.099).get(1).vx, 1);
near(b.sample(1.101).get(1).vx, 1);
near(b.sample(1.15).get(1).x, 0.07);
const edge = new SnapshotBuffer();
edge.push(frame(1, 0, 1.1), 0);
edge.push(frame(1.1, 0.02), 0.05);
near(edge.sample(1.15).get(1).x, 0.02);
assert.equal(edge.sample(1.15).get(1).vx, 0);
});
test('停步期间及网络停顿越过结束时间后,都不使用攻击前的速度外推', () => {
const b = new SnapshotBuffer({ maxExtrapolate: 0.2 });
b.push(frame(0.9, -0.1), 0);
b.push(frame(1, 0, 1.08), 0.05);
for (const t of [1.04, 1.09, 5]) {
const s = b.sample(t).get(1);
assert.equal(s.x, 0);
assert.equal(s.vx, 0);
}
b.push(frame(1.1, 0.02, undefined, ANIM.MOVE), 0.1);
assert.ok(b.sample(1.15).get(1).x > 0.02, '新位置确认后恢复原有外推');
});
test('瞬移与重新部署的跳转优先于后摇插值', () => {
for (const [x, anim] of [[8, ANIM.MOVE], [0.1, ANIM.DEPLOY]]) {
const b = new SnapshotBuffer({ teleport: 2 });
b.push(frame(1, 0, 1.04), 0);
b.push(frame(1.1, x, undefined, anim), 0.05);
assert.equal(b.sample(1.09).get(1).x, 0);
near(b.sample(1.1).get(1).x, x);
assert.equal(b.sample(1.09).get(1).vx, 0);
near(b.sample(1.15).get(1).x, x);
assert.equal(b.sample(1.15).get(1).vx, 0);
}
});
test('死亡或眩晕的最新帧不沿用旧速度外推;恢复移动后仍可外推', () => {
for (const anim of [ANIM.DIE, ANIM.STUN]) {
const b = new SnapshotBuffer();
b.push(frame(0.9, -0.1, undefined, ANIM.MOVE), 0);
const stopped = frame(1, 0, undefined, anim);
if (anim === ANIM.DIE) stopped.units[0][3] = 0;
b.push(stopped, 0.05);
for (const t of [1.01, 1.24, 5]) {
assert.equal(b.sample(t).get(1).x, 0);
assert.equal(b.sample(t).get(1).vx, 0);
}
b.push(frame(1.1, 0.02, undefined, ANIM.MOVE), 0.1);
near(b.sample(1.15).get(1).x, 0.03);
}
});
test('最新帧中已消失的单位不外推,并从复用的采样结果中删除', () => {
const b = new SnapshotBuffer(), out = new Map();
b.push(frame(0.9, -0.1, undefined, ANIM.MOVE), 0);
b.push(frame(1, 0, 1.08), 0.05);
b.push({ t: 1.1, units: [] }, 0.1);
assert.equal(b.sample(1.05, out).get(1).x, 0);
assert.equal(b.sample(1.05, out).get(1).vx, 0);
assert.equal(b.sample(1.2, out).has(1), false);
});
function arena(key, wallRow = 10) {
return makeBattle({
defs: { chess: { t_wall: chessRec({ id: 't_wall', stats: { maxHp: 1e9, atk: 0, def: 1e6 }, skill: null }) } },
units: [{ chessId: 't_wall', row: wallRow, col: 6 }],
enemies: [{ key, time: 0, route: 0 }],
content: 'none', autoFinish: false, timeLimit: 60,
});
}
test('真实隐形弩手:Battle → b.snap → SnapshotBuffer 在后摇结束前不滑动', () => {
const h = arena('enemy_1019_jshoot');
let prev, pair;
for (let i = 0; i < 1200 && !pair; i++) {
h.step();
if (i % 3 !== 2) continue;
const next = h.b.snapshot();
const e = h.enemy('enemy_1019_jshoot');
const a = prev?.units.find((u) => u[0] === e.id), z = next.units.find((u) => u[0] === e.id);
const until = prev?.stand?.find((v) => v[0] === e.id)?.[1];
if (a && z && until > prev.t && until < next.t && (a[1] !== z[1] || a[2] !== z[2])) pair = { a, z, until, prev, next };
prev = next;
}
assert.ok(pair, '真实攻击后摇跨越两帧且随后恢复移动');
const { a, z, until, prev: A, next: B } = pair;
const b = new SnapshotBuffer();
b.push(snapFrame('test', A), 0);
b.push(snapFrame('test', B), 0.05);
const held = b.sample((A.t + until) / 2).get(a[0]);
assert.equal(held.x, a[1]);
assert.equal(held.y, a[2]);
assert.equal(held.vx, 0);
const moved = b.sample((until + B.t) / 2).get(a[0]);
near(moved.x, (a[1] + z[1]) / 2);
near(moved.y, (a[2] + z[2]) / 2);
near(b.sample(B.t - 1e-6).get(a[0]).vx, b.sample(B.t + 1e-6).get(a[0]).vx);
checkInvariants(h.b);
assert.equal(h.b.errors.length, 0);
});
test('真实隐形弩手:失衡跨帧打断时不按旧后摇时间冻结和集中追赶', () => {
const h = arena('enemy_1019_jshoot');
assert.ok(h.runUntil(() => {
const e = h.enemy('enemy_1019_jshoot');
return e && e.atkStandUntil - h.b.time > TICK && e.atkStandUntil - h.b.time < 3 * TICK;
}, 10));
const e = h.enemy('enemy_1019_jshoot'), A = h.b.snapshot();
const until = A.stand.find((s) => s[0] === e.id)[1];
h.step();
const cutAt = Math.round(h.b.time * 1000) / 1000;
assert.ok(h.b.displace(e, { x: -1, y: 0 }, 0.6) > 0);
h.step(2);
const B = h.b.snapshot();
assert.ok(until > A.t && until < B.t);
assert.equal(B.standCut.find((s) => s[0] === e.id)[1], cutAt);
const b = new SnapshotBuffer();
b.push(snapFrame('test', A), 0);
b.push(snapFrame('test', B), 0.05);
const a = A.units.find((u) => u[0] === e.id), z = B.units.find((u) => u[0] === e.id);
near(b.sample((A.t + B.t) / 2).get(e.id).x, (a[1] + z[1]) / 2);
near(b.sample(B.t + 1e-6).get(e.id).vx, (z[1] - a[1]) / (B.t - A.t));
checkInvariants(h.b);
assert.equal(h.b.errors.length, 0);
});
test('真实隐形弩手:后摇中击退并被移动创伤击杀,死亡帧保留打断且尸体不外推', () => {
const h = arena('enemy_1019_jshoot');
assert.ok(h.runUntil(() => {
const e = h.enemy('enemy_1019_jshoot');
return e && e.atkStandUntil - h.b.time > TICK && e.atkStandUntil - h.b.time < 3 * TICK;
}, 10));
const e = h.enemy('enemy_1019_jshoot'), A = h.b.snapshot();
const until = A.stand.find((s) => s[0] === e.id)[1];
h.step();
const cutAt = Math.round(h.b.time * 1000) / 1000;
// 温蒂的真实移动创伤实现:击退后下一次伤害结算在同一个快照间隔内击杀目标。
rupture(h.b, h.unit('t_wall'), e, { duration: 5, perTile: 1e6, interval: TICK });
near(h.b.push(e, e.s.massLevel + 1, { dir: { x: -1, y: 0 }, fixed: true }), 2.14);
h.step(2);
const B = h.b.snapshot();
assert.equal(e.alive, false);
assert.ok(until > A.t && until < B.t);
assert.equal(B.standCut?.find((s) => s[0] === e.id)?.[1], cutAt);
assert.equal(B.stand?.some((s) => s[0] === e.id) ?? false, false);
const a = A.units.find((u) => u[0] === e.id), z = B.units.find((u) => u[0] === e.id);
assert.equal(z[8], ANIM.DIE);
const b = new SnapshotBuffer();
b.push(snapFrame('test', A), 0);
b.push(snapFrame('test', B), 0.05);
near(b.sample((A.t + B.t) / 2).get(e.id).x, (a[1] + z[1]) / 2);
for (const t of [B.t, B.t + 0.01, B.t + 0.24, B.t + 1]) {
assert.equal(b.sample(t).get(e.id).x, z[1]);
assert.equal(b.sample(t).get(e.id).vx, 0);
}
b.update(0.05);
for (let i = 1; i <= 90; i++) b.update(0.05 + i / 120);
assert.ok(b.renderT > B.t);
assert.equal(b.sample().get(e.id).x, z[1]);
h.run(0.9);
const C = h.b.snapshot();
assert.equal(C.units.some((u) => u[0] === e.id), false);
assert.equal(C.standCut?.some((s) => s[0] === e.id) ?? false, false);
b.push(snapFrame('test', C), 0.5);
assert.equal(b.sample(C.t + 0.1).has(e.id), false);
checkInvariants(h.b);
assert.equal(h.b.errors.length, 0);
});
test('真实隐形弩手:AI 更新前死亡也记录后摇终止,隐藏和死亡窗口外不发送记录', () => {
const h = arena('enemy_1019_jshoot');
assert.ok(h.runUntil(() => h.enemy('enemy_1019_jshoot')?.atkStandUntil > h.b.time + 0.1, 10));
const e = h.enemy('enemy_1019_jshoot');
const at = Math.round(h.b.time * 1000) / 1000;
h.b.kill(e, h.unit('t_wall'));
assert.equal(Math.round(e.atkStandCutAt * 1000) / 1000, at);
assert.deepEqual(h.b.snapshot().standCut, [[e.id, at]]);
e.hidden = true;
assert.equal(h.b.snapshot().standCut, undefined);
e.hidden = false;
h.run(0.9);
assert.equal(h.b.snapshot().standCut, undefined);
checkInvariants(h.b);
assert.equal(h.b.errors.length, 0);
});
for (const status of ['stun', 'freeze', 'sleep', 'levitate']) {
test(`真实隐形弩手:移动后${status}的最新帧不继续滑动,解除后恢复移动`, () => {
const h = arena('enemy_1019_jshoot');
h.step(3);
const e = h.enemy('enemy_1019_jshoot'), A = h.b.snapshot();
h.step();
assert.equal(h.b.applyStatus(e, status, { duration: 1, force: true, source: h.unit('t_wall') }), true);
h.step(2);
const B = h.b.snapshot(), z = B.units.find((u) => u[0] === e.id);
assert.equal(z[8], ANIM.STUN);
assert.notEqual(A.units.find((u) => u[0] === e.id)[1], z[1]);
const b = new SnapshotBuffer();
b.push(snapFrame('test', A), 0);
b.push(snapFrame('test', B), 0.05);
assert.equal(b.sample(B.t + 0.1).get(e.id).x, z[1]);
assert.equal(b.sample(B.t + 0.1).get(e.id).vx, 0);
h.run(1.1);
const C = h.b.snapshot();
assert.equal(e.s.flags.stun, false);
assert.ok(C.units.find((u) => u[0] === e.id)[1] < z[1]);
b.push(snapFrame('test', C), 0.6);
assert.ok(b.sample(C.t + 0.1).get(e.id).vx < 0);
checkInvariants(h.b);
assert.equal(h.b.errors.length, 0);
});
}
test('真实隐形弩手:刚被阻挡的最新帧不继续滑动,阻挡者离场后恢复移动', () => {
const h = arena('enemy_1019_jshoot', 9);
let prev, pair;
for (let i = 0; i < 1200 && !pair; i++) {
h.step();
if (i % 3 !== 2) continue;
const next = h.b.snapshot(), e = h.enemy('enemy_1019_jshoot');
const a = prev?.units.find((u) => u[0] === e.id), z = next.units.find((u) => u[0] === e.id);
if (a && z && (z[7] & UF.BLOCKED) && a[1] !== z[1] && !next.stand?.some((s) => s[0] === e.id)) pair = { A: prev, B: next, e, z };
prev = next;
}
assert.ok(pair, '移动与首次阻挡发生于同一个快照间隔,且没有后摇元数据');
const { A, B, e, z } = pair;
const b = new SnapshotBuffer();
b.push(snapFrame('test', A), 0);
b.push(snapFrame('test', B), 0.05);
assert.equal(b.sample(B.t + 0.24).get(e.id).x, z[1]);
assert.equal(b.sample(B.t + 0.24).get(e.id).vx, 0);
h.b.retreat(h.unit('t_wall'), { permanent: true });
h.step(3);
const C = h.b.snapshot();
assert.equal(e.blockedBy, null);
assert.ok(C.units.find((u) => u[0] === e.id)[1] < z[1]);
b.push(snapFrame('test', C), 0.1);
assert.ok(b.sample(C.t + 0.1).get(e.id).vx < 0);
checkInvariants(h.b);
assert.equal(h.b.errors.length, 0);
});
test('真实隐形弩手:恐惧打断在延迟帧之后仍恢复线性插值', () => {
const h = arena('enemy_1019_jshoot');
assert.ok(h.runUntil(() => {
const e = h.enemy('enemy_1019_jshoot');
return e && e.atkStandUntil - h.b.time > 4 * TICK && e.atkStandUntil - h.b.time < 5 * TICK;
}, 10));
const e = h.enemy('enemy_1019_jshoot'), A = h.b.snapshot();
const until = A.stand.find((s) => s[0] === e.id)[1];
h.step();
assert.equal(h.b.applyStatus(e, 'fear', { duration: 1, source: h.unit('t_wall') }), true);
// 本地 runner 在卡帧时会推进多个 tick 后再发出一帧。
h.step(5);
const B = h.b.snapshot();
assert.ok(e.s.flags.fear && until > A.t && until < B.t);
assert.ok(B.standCut.some((s) => s[0] === e.id && s[1] >= A.t));
const b = new SnapshotBuffer();
b.push(snapFrame('test', A), 0);
b.push(snapFrame('test', B), (B.t - A.t) / 2);
const a = A.units.find((u) => u[0] === e.id), z = B.units.find((u) => u[0] === e.id);
const s = b.sample((A.t + until) / 2).get(e.id);
const alpha = (until - A.t) / (2 * (B.t - A.t));
near(s.x, a[1] + (z[1] - a[1]) * alpha);
assert.ok(s.x > a[1]);
checkInvariants(h.b);
assert.equal(h.b.errors.length, 0);
});
test('真实枯朽之种:命中帧即动画末尾,没有后摇时仍连续移动', () => {
const h = arena('enemy_1269_nhfly');
let prev, checked = false;
for (let i = 0; i < 1200 && !checked; i++) {
h.step();
if (i % 3 !== 2) continue;
const next = h.b.snapshot();
const e = h.enemy('enemy_1269_nhfly');
assert.ok(!next.stand?.some((s) => s[0] === e.id), '零后摇不发送 stand');
const a = prev?.units.find((u) => u[0] === e.id), z = next.units.find((u) => u[0] === e.id);
if (a && z && a[8] === ANIM.ATTACK && z[8] === ANIM.MOVE && (a[1] !== z[1] || a[2] !== z[2])) {
const b = new SnapshotBuffer();
b.push(prev, 0);
b.push(next, 0.05);
const s = b.sample((prev.t + next.t) / 2).get(e.id);
near(s.x, (a[1] + z[1]) / 2);
near(s.y, (a[2] + z[2]) / 2);
checked = true;
}
prev = next;
}
assert.ok(checked, '真实攻击→移动帧对');
assert.equal(h.b.errors.length, 0);
});
test('快照不泄漏无效后摇:盟友、隐藏、未部署、死亡、恐惧、眩晕、到期及失衡打断', () => {
const h = arena('enemy_1019_jshoot');
assert.ok(h.runUntil(() => h.enemy('enemy_1019_jshoot')?.atkStandUntil > h.b.time + 0.1, 10));
const e = h.enemy('enemy_1019_jshoot');
const has = () => h.b.snapshot().stand?.some((s) => s[0] === e.id) ?? false;
assert.ok(has());
const ally = h.unit('t_wall');
ally.atkStandUntil = e.atkStandUntil;
assert.ok(!h.b.snapshot().stand.some((s) => s[0] === ally.id));
for (const key of ['hidden', 'alive', 'deployed']) {
const old = e[key];
e[key] = key === 'hidden';
assert.equal(has(), false, key);
e[key] = old;
}
for (const key of ['fear', 'stun']) {
e.s.flags[key] = true;
assert.equal(has(), false, key);
e.s.flags[key] = false;
}
for (const until of [h.b.time, -Infinity, NaN, Infinity]) {
e.atkStandUntil = until;
assert.equal(has(), false);
}
e.atkStandUntil = h.b.time + 1;
assert.ok(h.b.displace(e, { x: -1, y: 0 }, 0.2) > 0, '实际发生位移');
assert.equal(has(), false, '失衡已清除后摇');
});
+61
View File
@@ -0,0 +1,61 @@
// test/render/battle-perf.browser.test.js — the battle perf page (public/dev/battle-perf.html) in headless Chrome: every
// committed spec starts, the battle runner steps the sim and the field view draws it, with a clean console; the sample
// hook tools/perfbench.mjs reads returns its figures.
//
// Opt-in (starts Chrome, needs the downloaded assets): RENDER_E2E=1 node --test test/render/battle-perf.browser.test.js
// Chrome path: $CHROME_PATH or the platform default of tools/perfbench.mjs.
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { gpuArgs } from '../../tools/perfbench.mjs';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const CHROME = process.env.CHROME_PATH || {
win32: 'C:/Program Files/Google/Chrome/Application/chrome.exe',
darwin: '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
linux: '/usr/bin/google-chrome',
}[process.platform];
const enabled = process.env.RENDER_E2E === '1' && !!CHROME && existsSync(CHROME) && existsSync(path.join(ROOT, 'public/assets'));
const skip = enabled ? false : 'set RENDER_E2E=1 (needs Chrome and the downloaded assets)';
const index = JSON.parse(readFileSync(path.join(ROOT, 'public/dev/perf/index.json'), 'utf8'));
describe('battle perf page in headless Chrome', { skip }, () => {
let srv, browser;
before(async () => {
const puppeteer = (await import('puppeteer-core')).default;
const { startServer } = await import('../../server/index.js');
srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
browser = await puppeteer.launch({ executablePath: CHROME, headless: true, args: gpuArgs() });
});
after(async () => {
await browser?.close();
await srv?.close();
});
for (const s of index) {
test(`${s.name}: the battle runs and draws, the sample hook reports`, async () => {
const page = await browser.newPage();
const problems = [];
page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`));
page.on('console', (m) => { if (m.type() === 'error') problems.push(`console: ${m.text()}`); });
page.on('response', (r) => { if (r.status() >= 400) problems.push(`HTTP ${r.status()} ${r.url()}`); });
await page.setViewport({ width: 844, height: 390, deviceScaleFactor: 2 });
await page.goto(`http://127.0.0.1:${srv.port}/dev/battle-perf.html?spec=${s.name}`);
await page.waitForFunction('window.__perf && (window.__perf.ready || window.__perf.error)', { timeout: 60000 });
assert.equal(await page.evaluate(() => window.__perf.error), null);
await new Promise((r) => setTimeout(r, 3000));
const r = await page.evaluate(() => window.__perf.sample(2000));
const short = await page.evaluate(() => window.__perf.sample(0)); // still a full frame time, not 0 / 0
await page.close();
assert.deepEqual(problems, []);
assert.ok(r.fps > 10, `fps ${r.fps}`);
assert.ok(r.ticks > 60, `sim ticks ${r.ticks}`);
assert.ok(r.units > 0, `units ${r.units}`);
assert.ok(Number.isFinite(r.p95) && r.p95 > 0);
assert.ok(Number.isFinite(short.fps) && short.fps > 0 && short.p95 > 0, `sample(0): ${JSON.stringify(short)}`);
});
}
});
+43
View File
@@ -0,0 +1,43 @@
// test/render/champagne-hpbar.test.js — 琳琅诗怀雅's 香槟炸弹 (token_10031_swire2_gdtrap) never shows an HP bar: PRTS 香槟炸弹
// 备注 "即使自身生命值未满,模型下方也不会显示生命值槽" (the owner's report of 2026-10-08, 「香槟会掉血,会被治疗」). Allies
// otherwise always draw one (render/units.js _updateHud). Only the bar changes; its HP and healing stay as they are.
// Run: node --test test/render/champagne-hpbar.test.js
import { test, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { installFakePixi, fakeViewCtx } from './fakepixi.js';
import { presetCamera } from '../../public/js/render/projection.js';
let fake, UnitView;
before(async () => {
fake = installFakePixi();
({ UnitView } = await import('../../public/js/render/units.js'));
});
after(() => fake.restore());
const cam = () => presetCamera('normal', { width: 1280, height: 720 });
const view = (defId, id) => new UnitView(fakeViewCtx(fake.P, { cam }), { id, side: 'ally', kind: 'summon', defId, tier: 1, x: 11, y: 10, maxHp: 1000, hp: 1000 }, {});
const frames = (v) => { for (let i = 0; i < 2; i++) v.update(1 / 60, cam(), i / 60); };
const bar = (v) => [v.hpBg.visible, v.hpFill.visible, v.hpGhost.visible];
test('香槟炸弹 shows no HP bar at full HP, below it, or healed back up', () => {
const bomb = view('token_10031_swire2_gdtrap', 1);
frames(bomb);
assert.deepEqual(bar(bomb), [false, false, false], 'full HP');
bomb.sync({ x: 11, y: 10, hp: 860, maxHp: 1000, sp: 0, spMax: 0, flags: 0, anim: 0 }, 1);
frames(bomb);
assert.equal(bomb.hp, 860, 'the view keeps its HP');
assert.deepEqual(bar(bomb), [false, false, false], 'hurt (活性源石: 70 true damage a second)');
bomb.sync({ x: 11, y: 10, hp: 1000, maxHp: 1000, sp: 0, spMax: 0, flags: 0, anim: 0 }, 2);
frames(bomb);
assert.deepEqual(bar(bomb), [false, false, false], 'healed');
});
test('another summon still draws its HP bar, full or hurt', () => {
const other = view('token_10000_silent_healrb', 2);
frames(other);
assert.deepEqual(bar(other), [true, true, true]);
other.sync({ x: 11, y: 10, hp: 400, maxHp: 1000, sp: 0, spMax: 0, flags: 0, anim: 0 }, 1);
frames(other);
assert.deepEqual(bar(other), [true, true, true]);
});
+54 -2
View File
@@ -1,9 +1,10 @@
// test/render/drag.test.js — drop target resolution and the pointer state machine (no DOM, fake timers).
// test/render/drag.test.js — drop target resolution, the pointer state machine (no DOM, fake timers) and the tile a
// dragged unit is drawn standing on (dragStandTile; the harness's 100 px grid stands in for the camera's tile pick).
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { GEO } from '../../shared/constants.js';
import { tileSlot, pieceSlot, pieceTile, resolveDrop, isLegal, sameSlot, createDragController } from '../../public/js/render/drag.js';
import { tileSlot, pieceSlot, pieceTile, resolveDrop, isLegal, sameSlot, createDragController, dragStandTile } from '../../public/js/render/drag.js';
describe('slots & targets', () => {
test('tileSlot classifies hand, temp, board and everything else', () => {
@@ -293,3 +294,54 @@ describe('drag controller', () => {
} finally { console.error = orig; }
});
});
// The official deploy drag (the owner's recording of 2026-10-09; the report in PR #403): the dragged unit is drawn
// standing on the tile under the pointer while that is a legal target, and held under the pointer elsewhere. The tile
// is the drag target itself, so the pick, tileHover, the highlight and the drop stay the pointer's tile.
describe('the tile a dragged unit stands on (dragStandTile)', () => {
test('a legal board tile or bench slot is stood on; an illegal tile, a temp slot, no target hold it under the pointer', () => {
assert.deepEqual(dragStandTile({ target: { area: 'board', row: 10, col: 3 }, legal: true }), { row: 10, col: 3, bench: false });
assert.deepEqual(dragStandTile({ target: { area: 'hand', idx: 6 }, legal: true }), { row: GEO.HAND_ROW, col: 6, bench: true });
assert.equal(dragStandTile({ target: { area: 'board', row: 10, col: 3 }, legal: false }), null, 'an illegal tile');
assert.equal(dragStandTile({ target: { area: 'hand', idx: 6 }, legal: false }), null, 'the own bench slot / an illegal slot');
assert.equal(dragStandTile({ target: { area: 'temp', idx: 1 }, legal: true }), null, 'a temp slot is never a target');
assert.equal(dragStandTile({ target: { area: 'outside', clientX: 1, clientY: 2 }, legal: true }), null);
assert.equal(dragStandTile({ target: null, legal: false }), null, 'off the grid, over the shop bar');
assert.equal(dragStandTile({ target: { area: 'board', row: 10, col: 3 }, legal: 1 }), null, 'legal must be true');
assert.equal(dragStandTile({ target: { area: 'board', row: 9.5, col: 3 }, legal: true }), null);
assert.equal(dragStandTile({ target: { area: 'hand' }, legal: true }), null);
assert.equal(dragStandTile(null), null);
assert.equal(dragStandTile(undefined), null);
assert.deepEqual(dragStandTile({ target: { area: 'hand', idx: 2 }, legal: true }, { HAND_ROW: 3 }), { row: 3, col: 2, bench: true }, 'geo is a parameter');
});
test('over the drag path: one tile while the pointer moves inside it, the next legal tile at once, none where no drop lands (mouse and touch)', () => {
for (const pointerType of ['mouse', 'touch']) {
const h = harness({ domCover: true, canPlace: (p, r, c) => !(r === 11 && c === 4) });
h.c.setEditable(true);
const stand = () => dragStandTile(h.events.filter((e) => e[0] === 'pieceDragMove').pop()[1]);
const at = (x, y) => { h.ev('pointerMove', x, y, { pointerType }); return stand(); };
h.ev('pointerDown', 50, 550, { pointerType });
assert.equal(at(50, 530), null, `${pointerType}: on its own bench slot (no target) it is held under the pointer`);
assert.ok(h.c.dragging);
// the pointer anywhere on (9,4) (x 400–499, y 300–399 in the harness grid): the unit stands on (9,4) the whole time
const inside = [[410, 390], [450, 350], [495, 305], [402, 301]].map(([x, y]) => at(x, y));
assert.deepEqual(inside, Array(4).fill({ row: 9, col: 4, bench: false }), `${pointerType}: moving inside the tile leaves it there`);
assert.deepEqual(at(450, 299), { row: 10, col: 4, bench: false }, `${pointerType}: the next tile takes it at once`);
assert.equal(at(450, 150), null, `${pointerType}: an illegal tile (11,4)`);
assert.equal(at(550, 450), null, `${pointerType}: a temp slot`);
assert.deepEqual(at(250, 550), { row: GEO.HAND_ROW, col: 2, bench: true }, `${pointerType}: another bench slot`);
assert.equal(at(250, 950), null, `${pointerType}: over the shop bar (DOM over the canvas)`);
assert.equal(at(-20, 300), null, `${pointerType}: off the grid`);
assert.deepEqual(at(350, 250), { row: 10, col: 3, bench: false });
h.ev('pointerUp', 350, 250, { pointerType });
assert.deepEqual(h.events.find((e) => e[0] === 'pieceDrop')[1].target, { area: 'board', row: 10, col: 3 }, `${pointerType}: dropped on the tile it stood on`);
// a board piece lifts on its own tile (a legal drop: the direction wheel re-orients it in place)
h.events.length = 0;
h.ev('pointerDown', 450, 250, { pointerType });
assert.deepEqual(at(470, 230), { row: 10, col: 4, bench: false }, `${pointerType}: a board piece stands on its own tile`);
h.ev('pointerUp', 470, 230, { pointerType });
assert.deepEqual(h.events.find((e) => e[0] === 'pieceDrop')[1].target, { area: 'board', row: 10, col: 4 });
}
});
});
+135
View File
@@ -0,0 +1,135 @@
// test/render/eyemask.test.js — closed eyes without the stencil masks (GitHub #177; PR #384 by @Convey123, re-implemented):
// the eyelids of these models are Spine clipping attachments, the masks are off on a crowded field or below high
// quality (render/app.js pickClipping), and SpineActor._eyeMaskFallback (render/spine.js) hides a covered slot whose
// centre leaves the clip polygon.
//
// Real runtime objects throughout: the official skeletons read by @pixi-spine/runtime-3.8, drawn by its real Spine
// (pixi-spine's slot containers and meshes on @pixi/core display objects — a 16 px canvas stand-in is all it needs
// headless) inside the real SpineActor. The eyeballs are meshes, drawn through `slot.currentMesh` — PR #384 hid
// `slot.currentSprite` (null on a mesh slot) and its test stubbed one onto every slot, so nothing was hidden in the
// game. The skeletons are not in the repository (public/assets: `npm run assets`): without them those tests skip.
// Run: node --test test/render/eyemask.test.js
import { test, describe, before } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, readFileSync } from 'node:fs';
import path from 'node:path';
import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const require = createRequire(path.join(ROOT, 'package.json'));
const ASSETS = JSON.parse(readFileSync(path.join(ROOT, 'data/assets.json'), 'utf8'));
const dirOf = (id) => path.join(ROOT, 'public', 'assets', 'spine', 'op', id, 'front');
const have = (id) => existsSync(path.join(dirOf(id), `${id}.skel`)) && existsSync(path.join(dirOf(id), `${id}.atlas`));
const MODELS = ['char_4058_pepe', 'char_4082_qiubai', 'char_4010_etlchi', 'char_1033_swire2'];
let SpineActor, pointInPolygon, rt, base, core;
before(async () => {
// headless pixi: its shader-precision probe and Texture.WHITE ask for a canvas; no WebGL context is needed to build
// and update a Spine's display tree
class HeadlessCanvas { constructor() { this.width = 16; this.height = 16; } getContext(kind) { return kind === '2d' ? { fillStyle: '', fillRect() {}, clearRect() {}, drawImage() {} } : null; } }
globalThis.HTMLCanvasElement ??= HeadlessCanvas;
globalThis.document ??= { createElement: () => new HeadlessCanvas() };
rt = require('@pixi-spine/runtime-3.8');
base = require('@pixi-spine/base');
core = require('@pixi/core');
globalThis.PIXI = { spine: { Spine: rt.Spine } }; // SpineActor builds `new PIXI.spine.Spine(data)`
({ SpineActor, pointInPolygon } = await import('../../public/js/render/spine.js'));
});
const cache = new Map();
function skeletonData(id) {
if (!cache.has(id)) {
const atlas = new base.TextureAtlas(readFileSync(path.join(dirOf(id), `${id}.atlas`), 'utf8'), (line, cb) => cb(new core.BaseTexture(null, { width: 1024, height: 1024 })));
cache.set(id, new rt.SkeletonBinary(new rt.AtlasAttachmentLoader(atlas)).readSkeletonData(new Uint8Array(readFileSync(path.join(dirOf(id), `${id}.skel`)))));
}
return cache.get(id);
}
/** A real SpineActor of `id` with its manifest entry, clipping masks on or off. */
function actor(id, clip) {
const a = new SpineActor(skeletonData(id), ASSETS.chars[id].spine.front);
a.setClipping(clip);
return a;
}
/** Slots whose container the fallback hid: still attached (pixi-spine shows those), yet not visible. */
const hidden = (a) => a.spine.skeleton.slots.filter((s) => s.getAttachment() && a.spine.slotContainers[s.data.index].visible === false).map((s) => s.data.name).sort();
/** Hidden slots `t` s into clip `clip` ('Die' through SpineActor.die — a knock-down — or 'Idle'), masks off. A fresh
* actor per time: pixi-spine's state.update takes a delta. */
function hiddenAt(id, clip, t) {
const a = actor(id, false);
if (clip === 'Die') a.die(); else a.spine.state.setAnimation(0, clip, true);
a.update(t);
return hidden(a);
}
test('pointInPolygon: inside / outside of a convex and a concave polygon', () => {
const square = [0, 0, 10, 0, 10, 10, 0, 10];
assert.equal(pointInPolygon(5, 5, square, 4), true);
for (const [x, y] of [[-1, 5], [11, 5], [5, -3]]) assert.equal(pointInPolygon(x, y, square, 4), false);
const l = [0, 0, 10, 0, 10, 4, 4, 4, 4, 10, 0, 10]; // an L: the notch is outside
assert.equal(pointInPolygon(6, 6, l, 6), false);
assert.equal(pointInPolygon(2, 6, l, 6), true);
assert.equal(pointInPolygon(8, 2, l, 6), true);
});
describe('eyelids on the official skeletons, drawn by the real pixi-spine runtime', { skip: !MODELS.every(have) && 'the operator skeletons are not in public/assets (npm run assets)' }, () => {
test('the eyeballs are meshes drawn through slot.currentMesh, inside the eyelid clip\'s container', () => {
const a = actor('char_4058_pepe', true);
a.update(0.1);
const slots = a.spine.skeleton.slots.filter((s) => /^F_[LR]_Eye_[BC]$/.test(s.data.name));
assert.equal(slots.length, 4);
const clip = a.spine.skeleton.slots.find((s) => s.clippingContainer);
for (const s of slots) {
assert.equal(s.getAttachment().constructor.name, 'MeshAttachment', s.data.name);
assert.ok(s.currentMesh && !s.currentSprite, `${s.data.name}: a mesh, no sprite`);
assert.equal(a.spine.slotContainers[s.data.index].parent, clip.clippingContainer, `${s.data.name} is clipped`);
}
});
test('佩佩 knocked down (Die): the four eyeball / eye-white slots hide once the eyes close (0.13 → 1.00 s), none before', () => {
for (const t of [0, 0.1]) assert.deepEqual(hiddenAt('char_4058_pepe', 'Die', t), [], `open at ${t}`);
for (const t of [0.3, 0.52, 1]) assert.deepEqual(hiddenAt('char_4058_pepe', 'Die', t), ['F_L_Eye_B', 'F_L_Eye_C', 'F_R_Eye_B', 'F_R_Eye_C'], `closed at ${t}`);
});
test('仇白 knocked down: both eyeballs hide once the eyes close', () => {
for (const t of [0, 0.1]) assert.deepEqual(hiddenAt('char_4082_qiubai', 'Die', t), [], `open at ${t}`);
for (const t of [0.2, 0.52, 1]) assert.deepEqual(hiddenAt('char_4082_qiubai', 'Die', t), ['F_L_Eyeball', 'F_R_Eyeball'], `closed at ${t}`);
});
test('隐德来希 / 琳琅诗怀雅 blink in Idle (2.13–2.37 s / 2.90–3.00 s): hidden then, never with the eyes open', () => {
for (const t of [0.5, 1.9, 3]) assert.deepEqual(hiddenAt('char_4010_etlchi', 'Idle', t), [], `隐德来希 open at ${t}`);
for (const t of [2.13, 2.25, 2.37]) assert.ok(hiddenAt('char_4010_etlchi', 'Idle', t).length >= 4, `隐德来希 blinking at ${t}`);
for (const t of [0.5, 2.6, 3.2]) assert.deepEqual(hiddenAt('char_1033_swire2', 'Idle', t), [], `琳琅诗怀雅 open at ${t}`);
for (const t of [2.9, 2.97, 3]) assert.deepEqual(hiddenAt('char_1033_swire2', 'Idle', t), ['F_H_L_Eyeball', 'F_H_L_Eyew', 'F_H_R_Eyeball', 'F_H_R_Eyew'], `琳琅诗怀雅 at ${t}`);
});
test('a whole clip sampled at 30 Hz: hiding is rare outside a knock-down and never takes more than the eyes', () => {
// [model, clip, seconds, share of frames with something hidden: at least, at most]
for (const [id, clip, dur, lo, hi] of [
['char_4058_pepe', 'Idle', 4, 0, 0], ['char_4058_pepe', 'Die', 1.5, 0.5, 1],
['char_4082_qiubai', 'Idle', 4, 0, 0.35], ['char_4082_qiubai', 'Die', 1, 0.5, 1],
['char_4010_etlchi', 'Idle', 4, 0.02, 0.2], ['char_1033_swire2', 'Idle', 4, 0.01, 0.2],
]) {
let hit = 0, frames = 0, most = 0;
for (let t = 0; t <= dur; t += 1 / 30) { const n = hiddenAt(id, clip, t).length; frames++; if (n) hit++; most = Math.max(most, n); }
assert.ok(hit / frames >= lo && hit / frames <= hi, `${id} ${clip}: ${Math.round(hit / frames * 100)} % of frames (want ${lo * 100}–${hi * 100} %)`);
assert.ok(most <= 8, `${id} ${clip}: at most ${most} slots in one frame`);
}
});
test('with the masks on nothing is hidden (the stencil cuts), and turning them back on shows what the fallback hid', () => {
const on = actor('char_4058_pepe', true);
on.die();
on.update(0.52);
assert.deepEqual(hidden(on), [], 'masks on: the fallback stays out');
const off = actor('char_4058_pepe', false);
off.die();
off.update(0.52);
assert.equal(hidden(off).length, 4, 'masks off: the closed eyes are hidden');
off.setClipping(true);
assert.deepEqual(hidden(off), [], 'shown again for the stencil');
const clip = off.spine.skeleton.slots.find((s) => s.clippingContainer);
assert.equal(clip.clippingContainer.mask, clip.currentGraphics, 'the mask is back on');
});
});
@@ -71,6 +71,27 @@ test('an enemy attacking quicker than its clip plays it faster, one clip per att
assert.equal(a.current, 'Move', 'the rest of the clip at 2×: 0.23 s');
});
// #246 (by @TsangAsuna, accepted by the owner on 2026-10-07): seen late, an attack whose rhythm leaves room for the whole
// clip plays it complete from the wind-up — 重犯's iron ball lifts before the slam instead of the slam appearing alone
test('an attack seen late plays the whole clip from its wind-up when the rhythm leaves room for it (重犯), else from the strike frame', () => {
const LIFBOS = 'enemy_1121_lifbos'; // 重犯: Attack_grey 2.333 s, strike at 1.0 s; attacks every 3 s
const a = actor(LIFBOS, true);
a.attack(3);
assert.equal(a.current, 'Attack_grey');
assert.equal(track(a).trackTime, 0, 'from the wind-up (the ball lifts), not the 1.0 s strike frame');
assert.equal(track(a).timeScale, 1, 'at its own speed');
run(a, 2.25);
assert.equal(a.current, 'Attack_grey', 'the whole 2.333 s clip plays (it used to end 1.333 s after the strike frame)');
run(a, 0.15);
assert.equal(a.current, 'Move_grey', 'then the resting clip');
const quick = actor(LIFBOS, true);
quick.attack(2); // quicker than the clip: no room for the wind-up
assert.ok(Math.abs(track(quick).trackTime - 1) < 1e-9, 'from the strike frame');
const op = actor(LIFBOS, false);
op.attack(3); // an operator's looping attack is unchanged
assert.ok(Math.abs(track(op).trackTime - 1) < 1e-9);
});
test('an operator keeps its attack loop stretched over the attack rhythm (unchanged)', () => {
const a = actor(JSHOOT, false);
a.attack(2.7);
+6 -1
View File
@@ -1,7 +1,8 @@
// test/render/fieldview.browser.test.js — field-view features in headless Chrome through the dev demo
// (public/dev/render-demo.html): the enemy preview pen (research 09 §2.2 / 08 §4.2) on the 3D and the 2D board and
// the 0.25 s pan to it and back, the Final Assault prep on the player's half of the boss field (research 09 §1.2:
// drop targets / directions stay in board space, the right half mirrored) with the round's leader on its spawn tile and
// drop targets / directions stay in board space, the right half mirrored; a dragged unit stands on its target's
// boss-field tile) with the round's leader on its spawn tile and
// its red hit tiles beside a range preview (community report #12), damage numbers that never touch, the
// automatic rebuild of a 3D board whose WebGL context was lost, and frame rates on a 4× throttled CPU.
// Screenshots → test/e2e/out/pen-*.png, fa-prep-*.png, fa-leader-*.png, dmgnum-*.png; perf → test/e2e/out/fieldview-perf.json.
@@ -194,6 +195,9 @@ describe('field view features in headless Chrome', { skip }, () => {
await page.mouse.move(cx, cy);
await page.mouse.down();
for (let i = 1; i <= 12; i++) await page.mouse.move(cx + (target.x - cx) * i / 12, cy + (target.y - cy) * i / 12);
// over the legal target the dragged unit stands on it (the official deploy drag): on that board tile's boss-field
// tile, as the drop will place it — row − 7, the column mirrored on the right half
const stood = await page.evaluate((uid) => { const v = window.__demo.view.debug.views.get('p:' + uid); return { x: v.x, y: v.y, lift: v.lift }; }, info.hand.uid);
await page.mouse.up();
await wait(400);
const drops = await page.evaluate(() => window.__drops);
@@ -222,6 +226,7 @@ describe('field view features in headless Chrome', { skip }, () => {
await page.close();
assert.deepEqual(problems, []);
assert.deepEqual(drops[0], { area: 'board', row: target.row, col: target.col }, `drop target in board space: ${JSON.stringify(drops)}`);
assert.deepEqual(stood, { x: side === 'R' ? 20 - target.col : target.col, y: target.row - 7, lift: 0.3 }, 'while dragged over it: standing on the target\'s boss-field tile');
assert.deepEqual(dirs, { a: side === 'R' ? 'LEFT' : 'RIGHT', u: 'UP' });
assert.deepEqual(past, { field: true, wallBelow: null, below: null, wallAbove: true, pen: null, pad: true });
});
+191
View File
@@ -19,6 +19,9 @@ import { installFakePixi, fakeViewCtx } from './fakepixi.js';
import { presetCamera } from '../../public/js/render/projection.js';
import { UF, ANIM } from '../../shared/constants.js';
import { makeBattle, chessRec } from '../helpers/battleHarness.js';
import * as enemiesMod from '../../server/sim/content/enemies.js';
import { enemyStealthed } from '../../server/sim/targeting.js';
import { flagsOf } from '../../server/sim/snapshot.js';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const assets = JSON.parse(readFileSync(path.join(ROOT, 'data/assets.json'), 'utf8'));
@@ -133,6 +136,194 @@ async function enemy(id, info = {}) {
return v;
}
describe('骨刺 effective stealth drives A/B clips', () => {
const BONE = 'enemy_9008_acbunn';
const near = (a, b) => assert.ok(Math.abs(a - b) < 1e-6, `${a} ≈ ${b}`);
function arena() {
const h = makeBattle({
content: 'generic', extraContent: [enemiesMod], seed: 7, autoFinish: false,
defs: { chess: { wall: chessRec({ id: 'wall', profession: 'TANK', stats: { atk: 0, maxHp: 1e7, def: 0, res: 0, blockCnt: 3 }, rangeGrid: [[0, 0]], skill: null }) } },
kits: { wall: () => ({ trait: { noAttack: true } }) }, units: [{ chessId: 'wall', row: 9, col: 6 }],
});
h.step();
const e = h.spawn(BONE, { pos: [9, 6], routeIndex: 0, mods: { speedMul: 0 } });
return { h, e, wall: h.unit('wall') };
}
function pose(v, h, e, hidden) {
const flags = flagsOf(e);
assert.equal(enemyStealthed(e), hidden);
assert.equal(!!(flags & UF.STEALTH), hidden);
v.sync({ ...sample(flags), x: e.x, y: e.y }, h.b.time);
assert.equal(v.form, hidden ? null : 'revealed');
assert.equal(clip(v), hidden ? 'Idle_A' : 'Idle_B');
v.fadeIn = 1;
v.update(1 / 60, cam(), h.b.time);
assert.equal(v.root.alpha, hidden ? 0.45 : 1);
}
test('骨刺: actual blocking, blocker stun, reveal and expiry give A → B → A → B → A', async () => {
const { h, e, wall } = arena();
const v = await enemy(BONE);
try {
pose(v, h, e, true);
h.step(2); assert.equal(e.blockedBy, wall); pose(v, h, e, false);
h.b.applyStatus(wall, 'stun', { duration: 30, force: true });
h.step(2); assert.equal(e.blockedBy, null); pose(v, h, e, true);
h.b.addBuff(e, { key: 'test:reveal', duration: 1, flags: { reveal: true } });
pose(v, h, e, false);
h.run(1.1); pose(v, h, e, true);
} finally { v.destroy(); }
});
test('骨刺: overlapping block and reveal keep B until both end', async () => {
const { h, e, wall } = arena();
const v = await enemy(BONE);
try {
h.step(2); assert.equal(e.blockedBy, wall);
h.b.addBuff(e, { key: 'test:reveal', duration: 0.1, flags: { reveal: true } });
pose(v, h, e, false);
h.run(0.2); assert.equal(e.blockedBy, wall); pose(v, h, e, false);
h.b.addBuff(e, { key: 'test:reveal', duration: 1, flags: { reveal: true } });
h.b.applyStatus(wall, 'stun', { duration: 30, force: true });
h.step(2); assert.equal(e.blockedBy, null); pose(v, h, e, false);
h.run(1.1); pose(v, h, e, true);
} finally { v.destroy(); }
});
test('骨刺: switching an ongoing attack maps A 0.8 ↔ B 1.0 without another attack or a repeated-snapshot restart', async () => {
const v = await enemy(BONE);
try {
v.sync(sample(UF.STEALTH), 1);
v.atkInterval = 4;
v.onAttack(null, 1.1);
v.actor.clock = 2;
v.actor.spine.state.tracks[0].trackTime = 0.8;
const last = v.lastAtk, interval = v.atkInterval;
v.imp = { dirty: false };
v.sync(sample(0), 2);
assert.equal(clip(v), 'Attack_B');
near(v.actor.spine.state.tracks[0].trackTime, 1);
near(v.actor.attackUntil, 2.5);
assert.equal(v.imp.dirty, true);
const track = v.actor.spine.state.tracks[0];
v.sync(sample(0), 2.1);
assert.equal(v.actor.spine.state.tracks[0], track);
v.sync(sample(UF.STEALTH), 2.2);
assert.equal(clip(v), 'Attack_A');
near(v.actor.spine.state.tracks[0].trackTime, 0.8);
near(v.actor.attackUntil, 2.7);
assert.equal(v.lastAtk, last);
assert.equal(v.atkInterval, interval);
assert.equal(v.actor.wound, false);
v.imp = null;
} finally { v.imp = null; v.destroy(); }
});
for (const fromB of [false, true]) {
test(`骨刺: ${fromB ? 'B → A truncated' : 'A → B'} wind-up retains its deadline and tail speed`, async () => {
const v = await enemy(BONE);
try {
v.sync(sample(fromB ? 0 : UF.STEALTH), 1);
v.actor.clock = 2;
const lead = fromB ? 0.633 : 0.333;
assert.ok(v.actor.windUp(4, lead));
v.actor.spine.state.tracks[0].trackTime = fromB ? 0.1 : 0.2;
const until = v.actor.windUntil, last = v.lastAtk, interval = v.atkInterval;
v.sync(sample(fromB ? UF.STEALTH : 0), 2);
const track = v.actor.spine.state.tracks[0];
assert.equal(clip(v), fromB ? 'Attack_A' : 'Attack_B');
near(track.trackTime, fromB ? 0 : 0.4);
near(track.timeScale, fromB ? 0.533 / lead : 1);
assert.equal(v.actor.windUntil, until);
assert.equal(v.actor.windTs, 1);
assert.equal(v.actor.wound, true);
near(v.actor.attackUntil, 2 + lead + 1.5 - (fromB ? 0.533 : 0.733));
assert.equal(v.lastAtk, last);
assert.equal(v.atkInterval, interval);
v.sync(sample(fromB ? UF.STEALTH : 0), 2.1);
assert.equal(v.actor.spine.state.tracks[0], track);
v.actor.update(lead + 0.001);
assert.equal(track.timeScale, 1, 'the original tail speed resumes');
} finally { v.destroy(); }
});
}
for (const control of [UF.FROZEN, UF.STUNNED, UF.SLEEP]) {
test(`骨刺: control ${control} keeps the skeleton frozen while B/A attachments are applied immediately`, async () => {
const v = await enemy(BONE);
try {
v.sync(sample(UF.STEALTH | control), 1);
let zeroUpdates = 0;
const update = v.actor.spine.update.bind(v.actor.spine);
v.actor.spine.update = (dt) => { if (dt === 0) zeroUpdates++; update(dt); };
for (const hidden of [false, true]) {
v.sync(sample(control | (hidden ? UF.STEALTH : 0)), 2);
assert.equal(clip(v), hidden ? 'Idle_A' : 'Idle_B');
assert.equal(v.actor.mode, 'stun');
assert.equal(v.actor.frozen, true);
assert.equal(v.actor.spine.state.tracks[0].mixDuration, 0);
}
assert.equal(zeroUpdates, 2);
v.sync(sample(UF.STEALTH, ANIM.MOVE), 3);
assert.equal(v.actor.frozen, false);
assert.equal(clip(v), 'Move_A');
} finally { v.destroy(); }
});
}
test('骨刺: a first snapshot with flags = 0 selects B even though previous flags were also 0', async () => {
const v = await enemy(BONE);
try {
assert.equal(v.flags, 0);
v.sync(sample(0), 1);
assert.equal(v.form, 'revealed');
assert.equal(clip(v), 'Idle_B');
} finally { v.destroy(); }
});
for (const finalFlags of [0, UF.STEALTH, UF.FROZEN]) {
test(`骨刺: delayed model loading applies the latest snapshot (${finalFlags}), including frozen B`, async () => {
const a = store(BONE);
let resolve;
a.spine.acquire = () => new Promise((r) => { resolve = r; });
const v = new UnitView(fakeViewCtx(fake.P, { assets: a, cam }), { id: 9, side: 'enemy', kind: 'enemy', defId: BONE, spine: BONE, maxHp: 1000 });
const proto = fake.P.spine.Spine.prototype, update = proto.update;
let zeroUpdates = 0;
proto.update = function (dt) { if (dt === 0) zeroUpdates++; return update.call(this, dt); };
try {
v.sync(sample(0), 1);
assert.equal(v.actor, null);
assert.equal(v.form, 'revealed');
v.sync(sample(finalFlags), 2);
resolve({ animations: Object.keys(a.spineEntry().animations).map((name) => ({ name })) });
await tick(); await tick();
assert.ok(v.actor);
assert.equal(clip(v), finalFlags & UF.STEALTH ? 'Idle_A' : 'Idle_B');
assert.ok(zeroUpdates > 0, 'the loaded pose is applied at zero time');
assert.equal(v.actor.frozen, !!(finalFlags & UF.FROZEN));
} finally { proto.update = update; v.destroy(); }
});
}
for (const hidden of [true, false]) {
test(`骨刺: ${hidden ? 'A' : 'B'} idle, move and death; a DIE snapshot selects its form before dying`, async () => {
const flags = hidden ? UF.STEALTH : 0, suffix = hidden ? 'A' : 'B';
const v = await enemy(BONE), w = await enemy(BONE);
try {
v.sync(sample(flags, ANIM.MOVE), 1); assert.equal(clip(v), `Move_${suffix}`);
v.sync(sample(flags, ANIM.IDLE), 2); assert.equal(clip(v), `Idle_${suffix}`);
v.die(); assert.equal(clip(v), `Die_${suffix}`);
w.sync(sample(hidden ? 0 : UF.STEALTH), 1);
w.sync(sample(flags, ANIM.DIE), 2); assert.equal(clip(w), `Die_${suffix}`);
w.sync(sample(hidden ? 0 : UF.STEALTH, ANIM.DIE), 3);
frames(w, 10);
assert.equal(clip(w), `Die_${suffix}`, 'death keeps its chosen form');
assert.equal(w.form, hidden ? null : 'revealed');
} finally { v.destroy(); w.destroy(); }
});
}
});
describe('转译基底·α forms (user report after 0.1.0, #5)', () => {
const TR = 'enemy_10081_mpplai';
test('every clip of the three forms exists in the skeleton; the change clips last the official 2 s; the manifest keeps the original form', () => {
+132
View File
@@ -0,0 +1,132 @@
// test/render/fxchen3wave.test.js — 赤刃明霄陈 S3 赤霄·天喟 的「龙剑气」(用户报告: 三技能的龙剑气看不到).
//
// The wave is a kit-managed probe in the sim (server/sim/content/kits/ops/op-chen3.js): it leaves her tile forward at
// 1.5 tiles/s, turns 90° at the field's edge / high ground / gates, and lives as long as the skill. It is not in the
// snapshot's projectile list, so the client can only draw it from `fx` events — and before this change the sim sent
// none for the wave itself (one `dash` puff at the cast, one `slash` spark per enemy hit): the 龙剑气 was invisible.
// The sim now emits `fx('chen3Wave', { x, y, fromX, fromY, dr, dc, id, skill })` every 0.12 s — its position, the
// previous point (the trail) and its direction (the crescent's heading) — and render/fx.js draws the 'qi' archetype.
// Against the headless fake PIXI (test/render/fakepixi.js), like fxflame.test.js.
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { installFakePixi, fakeViewCtx } from './fakepixi.js';
import { presetCamera } from '../../public/js/render/projection.js';
let fake, FX;
before(async () => {
fake = installFakePixi();
FX = await import('../../public/js/render/fx.js');
});
after(() => fake.restore());
const DT = 1 / 120;
const cam = presetCamera('normal', { width: 1600, height: 900 });
const unit = (id, x, y, o = {}) => ({ id, x, y, z: 0, hover: 0, _headTiles: 1.2, alive: true, destroyed: false, info: { defId: 'x' }, onHit() {}, ...o });
function makeFx(views, ts = 1) {
const P = fake.P;
const ctx = fakeViewCtx(P);
const map = new Map(views.map((v) => [v.id, v]));
const fx = new FX.FxSystem({
P, layers: ctx.layers, cam: () => cam, heightAt: () => 0, settings: { quality: 'high', damageNumbers: true },
timeScale: () => ts, loadLevel: () => 0, subProfOf: () => null, view: (id) => map.get(id) || null,
screenSize: () => ({ width: 1600, height: 900 }), fieldTop: () => 120,
});
return { fx, map };
}
const run = (fx, seconds) => { for (let t = 0; t < seconds - 1e-9; t += DT) fx.update(DT); };
/** One sim event of the wave: its position, the previous point and the direction (`dc` cols, `dr` rows). */
const wave = (fx, id, x, y, fromX, fromY, dc = 1, dr = 0) => fx.simFx('chen3Wave', x, y, { x, y, fromX, fromY, dr, dc, id, skill: 'chen3:wave' });
/** Screen box of the drawn trail/head: min/max of every live particle position. */
const drawn = (fx) => {
const list = fx.parts.filter((p) => p.sp && p.sp.visible !== false);
if (!list.length) return null;
const xs = list.map((p) => p.sp.position.x), ys = list.map((p) => p.sp.position.y);
return { n: list.length, x0: Math.min(...xs), x1: Math.max(...xs), y0: Math.min(...ys), y1: Math.max(...ys) };
};
describe('赤刃明霄陈 S3 龙剑气: 波自己会被画出来 (issue: 看不到)', () => {
test('its own archetype — a travelling blade, not the generic sparkle or a dust puff', () => {
assert.equal(FX.FX_KINDS.chen3Wave.a, 'qi');
assert.equal(FX.fxSpec('chen3Wave').a, 'qi');
assert.equal(FX.fxSpec('chen3Wave').pt, true, 'drawn at the wave (never anchored to the caster)');
});
test('one event draws a trail and a crescent head at the wave, not at her', () => {
const her = unit(1, 3, 10), waveAt = { x: 8, y: 10 };
const { fx } = makeFx([her]);
assert.equal(drawn(fx), null, 'nothing before the event');
wave(fx, her.id, waveAt.x, waveAt.y, 7.8, 10);
run(fx, 1 / 60);
const box = drawn(fx);
assert.ok(box && box.n >= 2, `the trail and the head are drawn (${box && box.n} pieces)`);
const at = cam.project(waveAt.x, waveAt.y, 0.35, { x: 0, y: 0, s: 0, depth: 0 });
const hers = cam.project(her.x, her.y, 0.5, { x: 0, y: 0, s: 0, depth: 0 });
const near = (v, t) => Math.abs(v - t) <= 90;
assert.ok(near(box.x1, at.x) || near(box.x0, at.x), `drawn around the wave (${Math.round(box.x0)}…${Math.round(box.x1)} vs ${Math.round(at.x)})`);
assert.ok(Math.abs(box.x1 - hers.x) > 60, 'not at her tile');
});
// streak() reuses the effect system's projection scratch: a head placed from it sat on the trail's start, one event
// (~0.2 tile) behind the wave
test('the crescent head sits on the wave itself (not on the trail\'s start) and points the way it travels', () => {
const her = unit(1, 3, 10);
const { fx } = makeFx([her]);
const heads = () => fx.parts.filter((p) => p.sp.visible !== false && p.sp.texture === fx.tex.slash);
wave(fx, her.id, 8, 10, 7.6, 10, 1, 0); // moving right (+col)
const right = heads();
assert.equal(right.length, 1, 'one crescent head');
const at = cam.project(8, 10, 0.35, { x: 0, y: 0, s: 0, depth: 0 });
assert.ok(Math.abs(right[0].x - at.x) < 0.5 && Math.abs(right[0].y - at.y) < 0.5,
`head at the wave (${right[0].x.toFixed(1)}, ${right[0].y.toFixed(1)}) vs (${at.x.toFixed(1)}, ${at.y.toFixed(1)})`);
assert.ok(Math.abs(right[0].sp.rotation - Math.PI / 2) < 0.35, `bulge to the right (rot ${right[0].sp.rotation.toFixed(2)})`);
run(fx, 1);
wave(fx, her.id, 8, 10, 8, 9.6, 0, 1); // moving up the screen (+row: the far side)
const up = heads();
assert.equal(up.length, 1);
assert.ok(Math.abs(up[0].sp.rotation) < 0.35, `bulge up (rot ${up[0].sp.rotation.toFixed(2)})`);
});
test('the events of a sweep join into one trail that goes the way it travels', () => {
const her = unit(1, 3, 10);
const { fx } = makeFx([her]);
let prev = 3;
const fronts = [];
for (let i = 0; i < 8; i++) { // 0.12 s apart at 1.5 tiles/s ⇒ 0.18 tile a step
const x = 3 + 0.18 * (i + 1);
wave(fx, her.id, x, 10, prev, 10);
prev = x;
run(fx, 0.12);
fronts.push(drawn(fx).x1);
}
const box = drawn(fx);
assert.ok(box && box.n >= 4, `the recent steps are still alive (${box && box.n} pieces)`);
// the drawn front walks forward with the wave (each event is drawn ahead of the last, the older ones still fading)
for (let i = 1; i < fronts.length; i++) {
assert.ok(fronts[i] > fronts[i - 1] - 1, `step ${i}: the front moved on (${Math.round(fronts[i - 1])} → ${Math.round(fronts[i])})`);
}
assert.ok(fronts[fronts.length - 1] - fronts[0] > 60, `the sweep crossed the field (${Math.round(fronts[0])} → ${Math.round(fronts[fronts.length - 1])} px)`);
});
test('a wave boxed in (no movement) and a junk extra never throw', () => {
const her = unit(1, 3, 10);
const { fx } = makeFx([her]);
wave(fx, her.id, 8, 10, 8, 10, 0, 0); // standing still: no direction
wave(fx, her.id, 8, 10, undefined, undefined);
fx.simFx('chen3Wave', 8, 10, { id: her.id }); // bare event
fx.simFx('chen3Wave', NaN, NaN, { id: her.id, dr: 1, dc: 0 });
run(fx, 0.2);
assert.ok(fx.counts.particles > 0, 'still drawn');
});
test('the whole thing is gone once the events stop', () => {
const her = unit(1, 3, 10);
const { fx } = makeFx([her]);
wave(fx, her.id, 8, 10, 7.8, 10);
run(fx, 0.05);
assert.ok(drawn(fx).n > 0);
run(fx, 1); // every trail/head particle expires (life 0.34 s max)
assert.equal(drawn(fx), null, 'nothing is left hanging on the field');
});
});
+60
View File
@@ -0,0 +1,60 @@
// test/render/fxchill.test.js — the 'chill' fx of coldWind (render/fx/simfx.js + zones.js snowfall), PR #386 by
// @leiming2333: 谢拉格's 盟约寒风 blows a blizzard UP the field (the official direction, the owner's confirmation of
// 2026-10-08) with a stronger cold flash; an operator's own coldWind (灵知 S3, 麦哲伦 S1's pulse every 3 s — emitted with
// the unit's `id`) keeps the light downward flurry and the softer flash. Against the headless fake PIXI
// (test/render/fakepixi.js), like fxchen3wave.test.js.
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { installFakePixi, fakeViewCtx } from './fakepixi.js';
import { presetCamera } from '../../public/js/render/projection.js';
let fake, FX;
before(async () => {
fake = installFakePixi();
FX = await import('../../public/js/render/fx.js');
});
after(() => fake.restore());
const W = 1600, H = 900;
const cam = presetCamera('normal', { width: W, height: H });
function makeFx(quality = 'high') {
const P = fake.P;
const ctx = fakeViewCtx(P);
const unit = { id: 7, x: 5, y: 10, z: 0, hover: 0, _headTiles: 1.2, alive: true, destroyed: false, info: { defId: 'x' }, onHit() {} };
return new FX.FxSystem({
P, layers: ctx.layers, cam: () => cam, heightAt: () => 0, settings: { quality, damageNumbers: true },
timeScale: () => 1, loadLevel: () => 0, subProfOf: () => null, view: (id) => (id === unit.id ? unit : null),
screenSize: () => ({ width: W, height: H }), fieldTop: () => 120,
});
}
const live = (fx) => fx.parts.filter((p) => p.sp && p.sp.visible !== false);
describe('coldWind: 谢拉格\'s 盟约寒风 blows up the field, an operator\'s own cold keeps the light flurry', () => {
test('the bond\'s gust (the device emits no anchor unit): 48 flakes and 10 streaks rising from the bottom, the stronger flash', () => {
const fx = makeFx();
// server/sim/content/devices.js startColdWind: the field centre, the player, the cold stacks, the cold duration
fx.simFx('coldWind', 8, 9, { x: 8, y: 9, playerId: 'p1', n: 3, duration: 5 });
const ps = live(fx);
assert.equal(ps.length, 58, '48 flakes + 10 streaks at quality high');
assert.ok(ps.every((p) => p.vy < 0), 'every particle rises (screen y grows downward)');
assert.ok(ps.every((p) => p.vx > 0), 'on a crosswind');
assert.ok(ps.every((p) => p.y >= H * 0.55 - 1e-9), 'lifted off the lower part of the screen (some start below it)');
assert.equal(ps.filter((p) => p.sp.texture === fx.tex.streak).length, 10, 'gust streaks');
assert.deepEqual([fx.tintA, fx.tintDur], [0.45, 1]);
const low = makeFx('low');
low.simFx('coldWind', 8, 9, { x: 8, y: 9, playerId: 'p1', n: 3, duration: 5 });
assert.equal(live(low).length, 26, 'quality low: 22 flakes + 4 streaks');
});
test('an operator\'s own coldWind (灵知 S3, 麦哲伦\'s pulse: `id` = the unit): the light flurry drifting down, the softer flash', () => {
const fx = makeFx();
fx.simFx('coldWind', 5, 10, { x: 5, y: 10, id: 7, n: 2 });
const ps = live(fx);
assert.equal(ps.length, 26);
assert.ok(ps.every((p) => p.vy > 0), 'falling as before');
assert.equal(ps.filter((p) => p.sp.texture === fx.tex.streak).length, 0, 'no gust streaks');
assert.deepEqual([fx.tintA, fx.tintDur], [0.35, 0.8]);
});
});
+349
View File
@@ -0,0 +1,349 @@
// test/render/hud-readouts.test.js — the HP-bar readouts, client side (R22-HUD; PR #303 and #378 by @IceCodeNew, PR #383
// by @Convey123, folded into one change): render/interp.js carries b.snap `ammo` [[id, left, magazine]], `wolves`
// [[id, left, max]] and `neg` [[id, fill]] beside the unit tuples and samples them from the older snapshot; render/units.js
// draws the ammo skill's segmented yellow bar in place of the SP bar, 伺夜's 狼影 as a row of diamonds and the 我执 pool
// as the red bar over the drained HP bar; an ammo skill has no standing aura (headless fake PIXI, test/render/fakepixi.js).
// The server side is test/sim/hud-readouts.test.js and test/content/op_hsgma2.test.js.
// Run: node --test test/render/hud-readouts.test.js
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { installFakePixi, fakeViewCtx } from './fakepixi.js';
import { presetCamera } from '../../public/js/render/projection.js';
import { SnapshotBuffer, normalizeSnapshot } from '../../public/js/render/interp.js';
import { renderInfo } from '../../public/js/render/app/info.js';
import { UF } from '../../shared/constants.js';
import { makeBattle, enemyRec } from '../helpers/battleHarness.js';
import { unitInfo } from '../../server/sim/snapshot.js';
let fake, FX, UnitView, COLORS, AMMO_BAR, WOLF_PIPS;
before(async () => {
fake = installFakePixi();
FX = await import('../../public/js/render/fx.js');
({ UnitView, AMMO_BAR, WOLF_PIPS } = await import('../../public/js/render/units.js'));
({ COLORS } = await import('../../public/js/render/style.js'));
});
after(() => { FX?.setSimProjectileSpeeds(null); fake.restore(); });
const cam = () => presetCamera('normal', { width: 1280, height: 720 });
const tuple = (id, hp = 1000, extra = {}) => [id, 5, 10, hp, 1000, extra.sp ?? 0, extra.spMax ?? 0, extra.flags ?? 0, 0];
const view = (info = {}, extra = {}) => new UnitView(fakeViewCtx(fake.P, { cam, ...extra }),
{ id: 1, side: 'ally', kind: 'op', defId: 'char_x', tier: 3, x: 5, y: 10, maxHp: 1000, dir: 'RIGHT', ...info });
const sample = (o = {}) => ({ x: 5, y: 10, hp: 1000, maxHp: 1000, sp: 0, spMax: 0, flags: 0, anim: 0, vx: 0, vy: 0, ...o });
const frames = (v, n = 2) => { for (let i = 0; i < n; i++) v.update(1 / 60, cam(), i / 60); };
const barWidth = (v) => v.hpBg.width - 2;
describe('interp: ammo / wolves / neg', () => {
test('sampled from the older snapshot (they step at snapshot boundaries) and cleared by a snapshot without them', () => {
const buf = new SnapshotBuffer({ delay: 0 });
buf.push({ t: 1, units: [tuple(1), tuple(2)], ammo: [[1, 13, 14]], wolves: [[2, 2, 3]], neg: [[2, 0.25]] }, 0);
buf.push({ t: 2, units: [tuple(1), tuple(2)], ammo: [[1, 12, 14]], wolves: [[2, 1, 3]], neg: [[2, 0.5]] }, 0.5);
buf.push({ t: 3, units: [tuple(1), tuple(2)] }, 1);
// between the first two snapshots nothing slides: a round, a 狼影 or a share of the pool is not halfway
const out = buf.sample(1.99);
assert.deepEqual([out.get(1).ammo, out.get(1).wolves, out.get(1).neg], [[13, 14], null, 0]);
assert.deepEqual([out.get(2).ammo, out.get(2).wolves, out.get(2).neg], [null, [2, 3], 0.25]);
buf.sample(2, out);
assert.deepEqual([out.get(1).ammo, out.get(2).wolves, out.get(2).neg], [[12, 14], [1, 3], 0.5]);
// a snapshot without the lists takes the readouts off the reused sample objects
buf.sample(3, out);
assert.deepEqual([out.get(1).ammo, out.get(2).wolves, out.get(2).neg], [null, null, 0]);
});
test('malformed rows are dropped: fractions, negatives, a count above its maximum, a maximum under 1, unknown ids, junk', () => {
const s = normalizeSnapshot({
t: 1, units: [tuple(1), tuple(2), tuple(3)],
ammo: [[1, 13, 14], [2, -1, 14], [2, 2, 1], [2, 1.5, 4], [9, 1, 2], [3, 15, 14], null, 'x', [3, 0, 0]],
wolves: [[2, 3, 3], [1, 2.5, 3], [1, 4, 3], [3, 1, 0], [9, 1, 3]],
neg: [[1, 0.4], [2, 0], [3, -1], [3, 'x'], [9, 0.5], [2, 5], null],
});
assert.deepEqual([...s.ammo], [[1, [13, 14]]]);
assert.deepEqual([...s.wolves], [[2, [3, 3]]]);
assert.deepEqual([...s.neg], [[1, 0.4], [2, 1]], 'a share above 1 is a full pool; 0 / negative / non-numbers are no pool');
const none = normalizeSnapshot({ t: 1, units: [tuple(1)] });
assert.deepEqual([none.ammo, none.wolves, none.neg], [null, null, null]);
assert.equal(none.units.get(1).length, 9, 'the tuples are never widened by the readouts');
});
});
describe('UnitView: the ammo skill\'s segmented bar', () => {
test('replaces the SP bar while the magazine runs: a yellow cell per round, the fill is rounds left / magazine, a thin cut at every boundary', () => {
const v = view();
v.sync(sample({ flags: UF.SKILL, sp: 22.3, spMax: 24, ammo: [13, 14] }));
frames(v);
const bw = barWidth(v);
assert.equal(v.spBg.visible && v.spFill.visible, true);
assert.equal(v.spFill.tint, COLORS.ammo);
assert.ok(Math.abs(v.spFill.width - bw * 13 / 14) < 1e-9, 'rounds left over the magazine, exactly (not the 0.1-rounded SP)');
const cuts = v.ammoCuts.filter((c) => c.visible);
assert.equal(cuts.length, 13, 'one cut per boundary of 14 cells');
const step = bw / 14, x0 = v.spFill.position.x;
cuts.forEach((c, i) => {
assert.ok(Math.abs(c.position.x - (x0 + (i + 1) * step - 0.5)) < 1e-9, `cut ${i + 1} on the boundary`);
assert.equal(c.width, 1);
assert.equal(c.height, v.spFill.height);
assert.equal(c.position.y, v.spFill.position.y);
assert.equal(c.tint, COLORS.hpBack);
});
assert.ok(v.spFill.height > v.hpFill.height * 0.6, 'taller than the plain SP bar (the HP bar\'s thickness × AMMO_BAR.height)');
assert.ok(Math.abs(v.spFill.height - Math.max(2, v.hpFill.height * AMMO_BAR.height)) < 1e-9);
// a round is spent: the fill loses 1/14 and the cuts stay
v.sync(sample({ flags: UF.SKILL, sp: 20.6, spMax: 24, ammo: [12, 14] }));
frames(v);
assert.ok(Math.abs(v.spFill.width - bw * 12 / 14) < 1e-9);
assert.equal(v.ammoCuts.filter((c) => c.visible).length, 13);
// the last round, then the magazine ends: the plain SP bar is back (cyan, no cuts)
v.sync(sample({ flags: UF.SKILL, ammo: [0, 14], sp: 0, spMax: 24 }));
frames(v);
assert.equal(v.spFill.width, 0);
v.sync(sample({ flags: 0, sp: 6, spMax: 24, ammo: null }));
frames(v);
assert.equal(v.ammoCuts.filter((c) => c.visible).length, 0, 'no cuts without a magazine');
assert.notEqual(v.spFill.tint, COLORS.ammo);
assert.ok(Math.abs(v.spFill.width - bw * 6 / 24) < 1e-9, 'the SP fraction of the tuple');
v.destroy();
});
test('a magazine needs no SP cost to show; a dense one keeps its exact fill and draws no cuts under AMMO_BAR.minCell px; an enemy never has the bar', () => {
const v = view();
v.sync(sample({ flags: UF.SKILL, ammo: [20, 40] })); // spMax 0: an auto skill with no SP cost
frames(v);
const bw = barWidth(v);
assert.equal(v.spFill.visible, true, 'the bar shows without an SP cost');
assert.ok(Math.abs(v.spFill.width - bw / 2) < 1e-9);
const cell = bw / 40;
assert.equal(v.ammoCuts.filter((c) => c.visible).length, cell >= AMMO_BAR.minCell ? 39 : 0, `${cell.toFixed(2)} px cells`);
v.sync(sample({ flags: UF.SKILL, ammo: [20, 400] }));
frames(v);
assert.equal(v.ammoCuts.filter((c) => c.visible).length, 0, 'under 2 px a cell: no cuts');
assert.ok(Math.abs(v.spFill.width - bw * 20 / 400) < 1e-9, 'the fill is still exact');
v.destroy();
const e = view({ id: 2, side: 'enemy', kind: 'enemy', defId: 'enemy_x' });
e.sync(sample({ hp: 500, flags: UF.SKILL, ammo: [3, 4] }));
frames(e);
assert.equal(e.spFill.visible, false, 'enemies have no SP bar, and so no magazine');
assert.equal(e.ammoCuts.filter((c) => c.visible).length, 0);
e.destroy();
});
test('the prep view draws no bars, so no cells either', () => {
const v = view({}, {});
v.prep = true;
v.sync(sample({ flags: UF.SKILL, ammo: [5, 14] }));
frames(v);
assert.equal(v.spFill.visible, false);
assert.equal(v.ammoCuts.filter((c) => c.visible).length, 0);
v.destroy();
});
});
describe('UnitView: 伺夜\'s 狼影 pips', () => {
const pack = (extra = {}) => view({ kind: 'token', defId: 'token_10028_vigil_wolf', ...extra });
test('one diamond per 狼影 the talent allows under the HP bar, the first ones lit for the shadows left', () => {
const v = pack();
v.sync(sample({ wolves: [2, 3] }));
frames(v);
const r = v._wolfPips;
assert.equal(r.root.visible, true);
assert.equal(r.lit.length, 3);
assert.deepEqual(r.lit.map((d) => d.alpha), [1, 1, WOLF_PIPS.dim]);
assert.ok(r.lit.every((d) => d.visible && d.tint === COLORS.wolf && Math.abs(d.rotation - Math.PI / 4) < 1e-9), 'pale diamonds');
assert.ok(r.back.every((d) => d.visible && d.tint === COLORS.hpBack), 'on a dark backing');
assert.ok(r.lit[0].position.y > v.hpBg.position.y, 'below the HP bar');
assert.ok(r.lit[0].position.x > v.hpFill.position.x && r.lit[2].position.x < v.hpFill.position.x + barWidth(v), 'inside the bar\'s span');
assert.ok(r.lit[1].position.x > r.lit[0].position.x && r.lit[2].position.x > r.lit[1].position.x, 'left to right');
assert.equal(v.spFill.visible, false, 'not an SP bar or a skill indicator');
// a 狼影 is spent: the last lit one dims; one is gained: it lights again
v.sync(sample({ wolves: [1, 3] }));
frames(v);
assert.deepEqual(r.lit.map((d) => d.alpha), [1, WOLF_PIPS.dim, WOLF_PIPS.dim]);
v.sync(sample({ wolves: [3, 3] }));
frames(v);
assert.deepEqual(r.lit.map((d) => d.alpha), [1, 1, 1]);
// a smaller maximum hides the surplus diamonds
v.sync(sample({ wolves: [1, 2] }));
frames(v);
assert.equal(r.lit.filter((d) => d.visible).length, 2);
v.destroy();
});
test('hidden without a count (an older snapshot cannot leave stale pips), in the prep view, and for enemies', () => {
const v = pack();
v.sync(sample({ wolves: [2, 3] }));
frames(v);
assert.equal(v._wolfPips.root.visible, true);
v.sync(sample({}));
frames(v);
assert.equal(v._wolfPips.root.visible, false, 'no row in the snapshot: no pips');
v.sync(sample({ wolves: [2, 3] }));
v.prep = true;
frames(v);
assert.equal(v._wolfPips.root.visible, false, 'the prep board draws no bars');
v.destroy();
const e = view({ id: 2, side: 'enemy', kind: 'enemy', defId: 'enemy_x' });
e.sync(sample({ hp: 500, wolves: [2, 3] }));
frames(e);
assert.equal(e._wolfPips, null, 'only allies carry the row');
e.destroy();
});
test('the element gauge row moves down below the pips (and below the SP bar when there is one)', () => {
const plain = view();
plain.sync(sample({ el: 'burn', elFill: 0.4, elUntil: 0, elDur: 0 }));
frames(plain);
const v = pack();
v.sync(sample({ wolves: [2, 3], el: 'burn', elFill: 0.4, elUntil: 0, elDur: 0 }));
frames(v);
const pipsBottom = v._wolfPips.back[0].position.y + (v._wolfPips.back[0].width * Math.SQRT2) / 2;
assert.ok(v._elBar.bg.position.y - v._elBar.bg.height / 2 >= pipsBottom - 1e-6, 'the gauge row starts under the pips');
assert.ok(v._elBar.bg.position.y > plain._elBar.bg.position.y + 4, 'and so sits lower than without pips');
plain.destroy();
v.destroy();
});
});
describe('UnitView: the 我执 pool as the red bar', () => {
const ego = (extra = {}) => view({ defId: 'char_1044_hsgma2', tier: 6, ...extra });
test('drawn over the drained HP bar from its left edge: width = the bar × the pool\'s share, the pool\'s red, gone with the pool', () => {
const v = ego();
v.sync(sample({ hp: 1 }));
frames(v);
assert.equal(v.negFill.visible, false, 'no pool, no red bar');
assert.equal(v.negFill.tint, COLORS.hpNeg);
v.sync(sample({ hp: 1, neg: 0.5 }));
frames(v);
assert.equal(v.negFill.visible, true);
assert.ok(Math.abs(v.negFill.width - barWidth(v) * 0.5) < 1e-9, 'half the pool: half the bar');
assert.equal(v.negFill.position.x, v.hpFill.position.x, 'from the bar\'s left edge');
assert.equal(v.negFill.position.y, v.hpFill.position.y);
assert.equal(v.negFill.height, v.hpFill.height);
assert.ok(v.hpFill.width < 1, 'the green bar is drained meanwhile');
v.sync(sample({ hp: 1, neg: 1 }));
frames(v);
assert.ok(Math.abs(v.negFill.width - barWidth(v)) < 1e-9, 'a full pool fills the bar');
v.sync(sample({ hp: 600, neg: 0 }));
frames(v);
assert.equal(v.negFill.visible, false, 'she left 我执');
v.destroy();
});
test('not in the prep view, and gone once she falls', () => {
const p = ego();
p.prep = true;
p.sync(sample({ hp: 1, neg: 0.6 }));
frames(p);
assert.equal(p.negFill.visible, false);
p.destroy();
const v = ego();
v.sync(sample({ hp: 1, neg: 1 }));
frames(v);
assert.equal(v.negFill.visible, true);
v.die();
for (let i = 0; i < 300; i++) v.update(1 / 60, cam(), i / 60);
assert.equal(v.alive, false);
assert.equal(v.negFill.visible, false, 'no bars for a knocked-out operator');
v.destroy();
});
});
describe('an ammo skill has no standing aura', () => {
const unit = (id, info) => ({ id, x: 3, y: 10, z: 0, hover: 0, _headTiles: 1.2, alive: true, destroyed: false, isEnemy: false, maxHp: 3000, info, onHit() {} });
const makeFx = (views) => {
const ctx = fakeViewCtx(fake.P);
const map = new Map(views.map((v) => [v.id, v]));
const c = presetCamera('normal', { width: 1600, height: 900 });
return new FX.FxSystem({
P: fake.P, layers: ctx.layers, cam: () => c, heightAt: () => 0, settings: { quality: 'high', damageNumbers: true },
timeScale: () => 2, loadLevel: () => 0, subProfOf: () => null, view: (id) => map.get(id) || null,
screenSize: () => ({ width: 1600, height: 900 }), fieldTop: () => 120,
});
};
test('UnitInfo.ammoSkill: the activation burst plays, the ground glow and hex do not (a plain skill keeps both)', () => {
const magazine = unit(1, { defId: 'char_x', ammoSkill: true }), plain = unit(2, { defId: 'char_y' });
const fx = makeFx([magazine, plain]);
fx.skill(magazine, true);
assert.ok(fx.parts.length > 0 && fx.rings.length > 0, 'the cast itself is still shown');
assert.equal(fx.auras.size, 0, 'no glow and hex under a magazine');
fx.skill(magazine, false);
fx.skill(plain, true);
assert.equal(fx.auras.size, 1, 'a plain skill keeps its aura');
fx.destroy();
});
test('the flag travels: unitInfo (ammo skills of allies only) → renderInfo', () => {
const h = makeBattle({
defs: { enemies: { enemy_dummy: enemyRec({ key: 'enemy_dummy', hp: 1e7, speed: 0 }) } }, timeLimit: 30, seed: 7,
units: [{ chessId: 'chess_char_1_01_a', row: 10, col: 3 }, { chessId: 'chess_char_1_02_a', row: 11, col: 3 }],
enemies: [{ key: 'enemy_dummy', pos: [10, 5] }],
});
h.run(0.5);
assert.equal(renderInfo(unitInfo(h.unit('chess_char_1_01_a'))).ammoSkill, true);
assert.equal(renderInfo(unitInfo(h.unit('chess_char_1_02_a'))).ammoSkill, undefined);
assert.equal(renderInfo(unitInfo(h.enemy('enemy_dummy'))).ammoSkill, undefined);
assert.equal(renderInfo({ id: 1, ammoSkill: 'yes' }).ammoSkill, undefined, 'only a real true counts');
});
});
describe('the chain: sim → snapshot → SnapshotBuffer → UnitView', () => {
test('隐现 S2: 14 cells, 13 after the first round, one more gone per attack; no aura; the plain SP bar after the last round', () => {
const h = makeBattle({
defs: { enemies: { enemy_dummy: enemyRec({ key: 'enemy_dummy', hp: 1e7, speed: 0 }) } },
units: [{ chessId: 'chess_char_1_01_a', row: 10, col: 3 }],
enemies: [{ key: 'enemy_dummy', pos: [10, 5] }],
timeLimit: 200, seed: 7,
});
const u = h.unit('chess_char_1_01_a');
assert.ok(h.runUntil(() => u.skill.active, 80));
const v = new UnitView(fakeViewCtx(fake.P, { cam }), renderInfo(unitInfo(u)));
const buffer = new SnapshotBuffer({ delay: 0 });
const show = () => {
const snap = h.b.snapshot();
buffer.push(snap, snap.t);
v.sync(buffer.sample(snap.t).get(u.id), snap.t);
v.update(1 / 60, cam(), h.b.time);
};
show();
const bw = barWidth(v);
assert.equal(u.skill.ammoLeft, 13, 'the activation tick already fired the first round');
assert.ok(Math.abs(v.spFill.width - bw * 13 / 14) < 1e-9);
assert.equal(v.ammoCuts.filter((c) => c.visible).length, 13);
const left = u.skill.ammoLeft;
assert.ok(h.runUntil(() => u.skill.ammoLeft < left && u.skill.active, 10));
show();
assert.ok(Math.abs(v.spFill.width - bw * 12 / 14) < 1e-9, 'one attack removes exactly one round');
assert.ok(h.runUntil(() => !u.skill.active, 60));
show();
show();
assert.equal(v.ammoCuts.filter((c) => c.visible).length, 0, 'the magazine is spent: the plain SP bar is back');
assert.notEqual(v.spFill.tint, COLORS.ammo);
v.destroy();
});
test('伺夜\'s placed pack: the pips follow 2 → 3 → 2 and are gone when the pack falls', () => {
const h = makeBattle({
units: [{ chessId: 'chess_char_3_19_a', row: 12, col: 3, uid: 1 }, { kind: 'token', tokenId: 'token_10028_vigil_wolf', ownerUid: 1, row: 11, col: 5, uid: 2 }],
timeLimit: 120, defs: { enemies: { enemy_d: enemyRec({ key: 'enemy_d', hp: 1e9, speed: 0, atk: 0 }) } }, enemies: [{ key: 'enemy_d', pos: [1, 1] }],
});
h.run(0.2);
const w = h.b.allyUnits.find((x) => x.defId === 'token_10028_vigil_wolf');
const v = new UnitView(fakeViewCtx(fake.P, { cam }), renderInfo(unitInfo(w)));
const buffer = new SnapshotBuffer({ delay: 0 });
const lit = () => {
const snap = h.b.snapshot();
buffer.push(snap, snap.t);
v.sync(buffer.sample(snap.t).get(w.id), snap.t);
v.update(1 / 60, cam(), h.b.time);
return v._wolfPips ? v._wolfPips.lit.filter((d) => d.visible && d.alpha === 1).length : 0;
};
assert.equal(lit(), 2);
h.run(25);
assert.equal(lit(), 3);
h.b.dealDamage(null, w, { amount: 1e9, type: 'true' });
h.step(); // (a snapshot of the same game time as the last one would be dropped as a duplicate)
assert.equal(lit(), 2);
v.destroy();
});
});
+343
View File
@@ -0,0 +1,343 @@
// Real renderer startup and cancellation, without downloaded art.
// RENDER_E2E=1 node --test test/render/initialization.browser.test.js
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, mkdirSync, readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const OUT = path.join(ROOT, 'test/e2e/out');
const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const skip = process.env.RENDER_E2E === '1' && existsSync(CHROME) ? false : 'set RENDER_E2E=1 (needs Chrome)';
const HTML = `<!doctype html><html><head><link rel="stylesheet" href="/css/theme.css"><link rel="stylesheet" href="/css/screens/game.css"></head>
<body style="margin:0"><div id="host" style="position:relative;width:1000px;height:650px"></div><div id="hooks"></div></body></html>`;
// These wrappers keep the real browser/Pixi behavior and record resources owned by each view.
function trackBrowserResources() {
const state = window.__initialization = { apps: [], observers: [], listeners: [], calls: 0, settled: 0 };
const timers = new Map();
const set = window.setTimeout.bind(window), clear = window.clearTimeout.bind(window);
window.setTimeout = (fn, ms, ...args) => {
const id = set(() => { timers.delete(id); fn(...args); }, ms);
if (ms === 12000) timers.set(id, () => fn(...args));
return id;
};
window.clearTimeout = (id) => { timers.delete(id); clear(id); };
state.expire = () => {
for (const [id, fn] of [...timers]) { clear(id); timers.delete(id); fn(); }
};
const NativeObserver = window.ResizeObserver;
window.ResizeObserver = class extends NativeObserver {
constructor(fn) { super(fn); this.targets = new Set(); state.observers.push(this); }
observe(target, ...args) { this.targets.add(target); return super.observe(target, ...args); }
unobserve(target) { this.targets.delete(target); return super.unobserve(target); }
disconnect() { this.targets.clear(); return super.disconnect(); }
};
const add = EventTarget.prototype.addEventListener, remove = EventTarget.prototype.removeEventListener;
const tracked = (target, type) => target instanceof HTMLCanvasElement ||
(target === document && type === 'visibilitychange') || (target === window && type === 'resize');
const capture = (opts) => typeof opts === 'boolean' ? opts : !!opts?.capture;
EventTarget.prototype.addEventListener = function (type, fn, opts) {
if (tracked(this, type) && !state.listeners.some((r) => r.target === this && r.type === type && r.fn === fn && r.capture === capture(opts))) {
state.listeners.push({ target: this, type, fn, capture: capture(opts) });
}
return add.call(this, type, fn, opts);
};
EventTarget.prototype.removeEventListener = function (type, fn, opts) {
state.listeners = state.listeners.filter((r) => !(r.target === this && r.type === type && r.fn === fn && r.capture === capture(opts)));
return remove.call(this, type, fn, opts);
};
state.snapshot = () => ({
apps: state.apps.map(({ app, ticker, canvas }) => ({ destroyed: app.renderer === null, ticking: ticker.started, attached: canvas.isConnected })),
observers: state.observers.filter((r) => r.targets.size).length,
listeners: state.listeners.map((r) => r.type),
canvases: document.querySelectorAll('#host canvas').length,
fallbacks: document.querySelectorAll('#host .ff').length,
});
}
function trackPixiApplications() {
const Application = PIXI.Application;
PIXI.Application = class extends Application {
constructor(...args) {
super(...args);
window.__initialization.apps.push({ app: this, ticker: this.ticker, canvas: this.view });
}
};
}
const FACTORY_WRAPPER = `export * from '/js/render/app.js?implementation';
import { createFieldView as create } from '/js/render/app.js?implementation';
export async function createFieldView(...args) {
window.__initialization.calls++;
if (window.__initialization.ignoreSignal) args[1] = { ...args[1], signal: undefined };
try { return await create(...args); }
finally { window.__initialization.settled++; }
}`;
describe('field initialization in headless Chrome', { skip }, () => {
let srv, browser, pixi;
before(async () => {
const puppeteer = (await import('puppeteer-core')).default;
const { startServer } = await import('../../server/index.js');
srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
browser = await puppeteer.launch({ executablePath: CHROME, headless: true, args: ['--no-first-run'] });
pixi = readFileSync(path.join(ROOT, 'public/vendor/pixi.min.js'), 'utf8') + `\n;(${trackPixiApplications.toString()})();`;
});
after(async () => { await browser?.close(); await srv?.close(); });
async function open(t, { holdPixi = false } = {}) {
const page = await browser.newPage();
t.after(() => page.close());
const errors = [];
page.on('pageerror', (err) => errors.push(err.message));
await page.setViewport({ width: 1100, height: 750 });
await page.evaluateOnNewDocument(trackBrowserResources);
await page.setRequestInterception(true);
let releasePixi;
const requested = new Promise((resolve) => {
page.on('request', (request) => {
const url = new URL(request.url());
if (url.pathname === '/initialization-test') return request.respond({ status: 200, contentType: 'text/html', body: HTML });
if (url.pathname === '/js/render/app.js' && !url.search) return request.respond({ status: 200, contentType: 'text/javascript', body: FACTORY_WRAPPER });
if (url.pathname === '/vendor/pixi.min.js') {
releasePixi = () => request.respond({ status: 200, contentType: 'text/javascript', body: pixi });
resolve();
if (!holdPixi) return releasePixi();
return;
}
return request.continue();
});
});
await page.goto(`http://127.0.0.1:${srv.port}/initialization-test?board=2d`);
// Empty manifests prevent optional local art requests while preserving the real asset store.
await page.evaluate(async () => {
const { assets } = await import('/js/assets.js');
assets.seed({ version: 1, chars: {}, enemies: {}, tokens: {}, items: {} });
assets.seedLocal({ version: 1, files: {} });
const { data } = await import('/js/data.js');
await data.load('stages');
window.__initialization.stage = data.list('stages')[0];
window.__initialization.host = document.getElementById('host');
});
return { page, errors, requested, release: () => releasePixi() };
}
async function start(page) {
await page.evaluate(async () => {
const { mountFieldView } = await import('/js/ui/fieldHost.js');
const s = window.__initialization;
s.mount = mountFieldView(s.host).then((view) => {
s.view = view;
view.setStage(s.stage);
view.setPrep({ board: [], hand: [], temp: [] }, { editable: true });
s.clicks = [];
view.on('tileClick', (click) => s.clicks.push([click.row, click.col]));
});
});
}
async function pixiFinished(page) {
await page.evaluate(async () => {
const { ensurePixi } = await import('/js/render/app/pixi.js');
await ensurePixi();
await new Promise(requestAnimationFrame);
});
}
async function clickFallback(page) {
await page.waitForSelector('#host .ff-tile--floor');
const tile = await page.$eval('#host .ff-tile--floor', (element) => {
const r = element.getBoundingClientRect();
return { x: r.x + r.width / 2, y: r.y + r.height / 2, row: +element.dataset.row, col: +element.dataset.col };
});
await page.mouse.click(tile.x, tile.y);
return [tile.row, tile.col];
}
test('successful startup owns one running app and destroy releases its browser resources', async (t) => {
const { page, errors } = await open(t);
await start(page);
await page.waitForFunction(() => window.__initialization.view?.kind === 'engine');
const mounted = await page.evaluate(() => window.__initialization.snapshot());
assert.equal(mounted.canvases, 1);
assert.deepEqual(mounted.apps, [{ destroyed: false, ticking: true, attached: true }]);
assert.equal(mounted.observers, 1);
assert.ok(mounted.listeners.includes('pointerdown'));
assert.ok(mounted.listeners.includes('visibilitychange'));
const destroyed = await page.evaluate(() => {
const s = window.__initialization;
s.view.destroy(); s.view.destroy();
return s.snapshot();
});
assert.deepEqual(destroyed, { apps: [{ destroyed: true, ticking: false, attached: false }], observers: 0, listeners: [], canvases: 0, fallbacks: 0 });
assert.deepEqual(errors, []);
});
for (const ignoreSignal of [false, true]) test(`a timed-out Pixi load cannot cover the clickable fallback (${ignoreSignal ? 'legacy factory ignores cancellation' : 'cancellable factory'})`, async (t) => {
const { page, errors, requested, release } = await open(t, { holdPixi: true });
await page.evaluate((value) => { window.__initialization.ignoreSignal = value; }, ignoreSignal);
await start(page);
await requested;
await page.evaluate(() => window.__initialization.expire());
await page.waitForFunction(() => window.__initialization.view?.kind === 'fallback');
const target = await clickFallback(page);
assert.deepEqual(await page.evaluate(() => window.__initialization.clicks), [target]);
await release();
await pixiFinished(page);
await page.waitForFunction(() => window.__initialization.settled === 1);
await clickFallback(page);
const late = await page.evaluate(() => ({ ...window.__initialization.snapshot(), clicks: window.__initialization.clicks }));
assert.deepEqual(late.clicks, [target, target], 'real pointer input still reaches the fallback after Pixi finishes');
assert.equal(late.canvases, 0, 'the abandoned engine must not append an orphan canvas');
assert.equal(late.fallbacks, 1);
assert.ok(late.apps.every((app) => app.destroyed && !app.ticking && !app.attached));
assert.deepEqual(late.listeners, []);
assert.equal(late.observers, 1, 'only the fallback observes the host');
if (!ignoreSignal) {
mkdirSync(OUT, { recursive: true });
await page.screenshot({ path: path.join(OUT, 'render-initialization-fallback.png') });
}
const destroyed = await page.evaluate(() => { window.__initialization.view.destroy(); return window.__initialization.snapshot(); });
assert.equal(destroyed.observers, 0);
assert.equal(destroyed.canvases, 0);
assert.equal(destroyed.fallbacks, 0);
assert.deepEqual(errors, []);
});
test('timeout during board-art startup immediately releases the allocated app and permits a fresh engine', async (t) => {
const { page, errors } = await open(t);
await page.evaluate(async () => {
const { assets } = await import('/js/assets.js');
const s = window.__initialization, local = assets.local.bind(assets);
assets.local = () => {
if (!s.apps.length) return local();
assets.local = local;
s.artBlocked = true;
return new Promise((resolve) => { s.releaseArt = async () => resolve(await local()); });
};
});
await start(page);
await page.waitForFunction(() => window.__initialization.artBlocked);
const allocated = await page.evaluate(() => window.__initialization.snapshot());
assert.deepEqual(allocated.apps, [{ destroyed: false, ticking: true, attached: true }]);
await page.evaluate(() => window.__initialization.expire());
await page.waitForFunction(() => window.__initialization.view?.kind === 'fallback');
const cancelled = await page.evaluate(() => window.__initialization.snapshot());
assert.deepEqual(cancelled.apps, [{ destroyed: true, ticking: false, attached: false }], 'cancellation frees the real renderer before the delayed art resolves');
assert.equal(cancelled.canvases, 0);
assert.equal(cancelled.observers, 1);
assert.deepEqual(cancelled.listeners, []);
const target = await clickFallback(page);
assert.deepEqual(await page.evaluate(() => window.__initialization.clicks), [target]);
await page.evaluate(async () => {
const s = window.__initialization;
await s.releaseArt();
s.view.destroy();
s.view = null;
});
await page.waitForFunction(() => window.__initialization.settled === 1);
await start(page);
await page.waitForFunction(() => window.__initialization.view?.kind === 'engine');
const replacement = await page.evaluate(async () => {
await new Promise(requestAnimationFrame);
const s = window.__initialization, app = s.view.raw.debug.app;
app.renderer.render(app.stage);
return s.snapshot();
});
assert.deepEqual(replacement.apps, [
{ destroyed: true, ticking: false, attached: false },
{ destroyed: false, ticking: true, attached: true },
], 'a replacement engine can render with the surviving shared caches');
assert.equal(replacement.canvases, 1);
const destroyed = await page.evaluate(() => { window.__initialization.view.destroy(); return window.__initialization.snapshot(); });
assert.ok(destroyed.apps.every((app) => app.destroyed && !app.ticking && !app.attached));
assert.equal(destroyed.observers, 0);
assert.deepEqual(destroyed.listeners, []);
assert.equal(destroyed.canvases, 0);
assert.deepEqual(errors, []);
});
test('a setup exception releases partial resources without removing another host child', async (t) => {
const { page, errors } = await open(t);
await page.evaluate(async () => {
const { assets } = await import('/js/assets.js');
const s = window.__initialization, subscribe = assets.onChange.bind(assets);
s.sibling = document.createElement('span');
s.sibling.textContent = 'Existing host content';
s.host.appendChild(s.sibling);
// Fail after input and resize resources exist, while the real view is still being built.
assets.onChange = (fn) => {
if (s.observers.some((observer) => observer.targets.has(s.host))) {
assets.onChange = subscribe;
throw new Error('asset subscription unavailable');
}
return subscribe(fn);
};
});
await start(page);
await page.waitForFunction(() => window.__initialization.view?.kind === 'fallback');
const failed = await page.evaluate(() => ({ ...window.__initialization.snapshot(), sibling: window.__initialization.sibling.isConnected }));
assert.deepEqual(failed.apps, [{ destroyed: true, ticking: false, attached: false }]);
assert.equal(failed.sibling, true, 'failed initialization only cleans up its own resources');
assert.equal(failed.canvases, 0);
assert.equal(failed.observers, 1);
assert.deepEqual(failed.listeners, []);
const target = await clickFallback(page);
assert.deepEqual(await page.evaluate(() => window.__initialization.clicks), [target]);
const destroyed = await page.evaluate(() => { window.__initialization.view.destroy(); return window.__initialization.snapshot(); });
assert.equal(destroyed.observers, 0);
assert.equal(destroyed.fallbacks, 0);
assert.deepEqual(errors, []);
});
test('unmount during startup cannot erase a newer hook mount on the same host', async (t) => {
const { page, errors, requested, release } = await open(t, { holdPixi: true });
await page.evaluate(async () => {
const { h, render } = await import('/vendor/preact.module.js');
const { useFieldView } = await import('/js/ui/fieldHost.js');
const s = window.__initialization;
function Field() { s.hook = useFieldView({ current: s.host }); return null; }
s.renderHook = (key) => render(key ? h(Field, { key }) : null, document.getElementById('hooks'));
s.renderHook('first');
});
await requested;
await page.evaluate(() => {
const s = window.__initialization;
s.renderHook(null);
window.__SP_RENDER__ = 'fallback';
s.renderHook('second');
});
await page.waitForFunction(() => window.__initialization.hook?.kind === 'fallback');
await page.evaluate(() => {
const s = window.__initialization;
s.current = s.hook.view;
s.current.setStage(s.stage);
s.current.setPrep({ board: [], hand: [], temp: [] }, { editable: true });
s.clicks = [];
s.current.on('tileClick', (click) => s.clicks.push([click.row, click.col]));
s.expire();
});
await release();
await pixiFinished(page);
await page.waitForFunction(() => window.__initialization.settled === 1);
const late = await page.evaluate(() => ({ ...window.__initialization.snapshot(), current: window.__SP_VIEW__ === window.__initialization.current }));
assert.equal(late.fallbacks, 1, 'the old mount must not clear the newer view from the shared host');
assert.equal(late.canvases, 0);
assert.equal(late.current, true);
assert.ok(late.apps.every((app) => app.destroyed && !app.ticking && !app.attached));
const target = await clickFallback(page);
assert.deepEqual(await page.evaluate(() => window.__initialization.clicks), [target]);
const destroyed = await page.evaluate(() => {
window.__initialization.renderHook(null);
return { ...window.__initialization.snapshot(), current: window.__SP_VIEW__ };
});
assert.equal(destroyed.current, null);
assert.equal(destroyed.observers, 0);
assert.equal(destroyed.canvases, 0);
assert.equal(destroyed.fallbacks, 0);
assert.deepEqual(destroyed.listeners, []);
assert.deepEqual(errors, []);
});
});
+61 -20
View File
@@ -9,11 +9,13 @@
// #4.1 picking by tile ("地上都画好了一个一个方格,点击对应方格就选中那个方格的人物就行"): a press anywhere on a tile — board
// (adjacent rows and columns, high ground), bench, temporary bench — selects the unit on it and an empty tile
// nothing (a press on a unit's head, drawn over the tile behind it, is a press on that tile), in prep and in battle
// (allies by their tile, enemies by their ground position or drawn body); a dragged unit is held under the pointer — its drawn
// feet DRAG_HOLD_TILES below it, the model around it — for a mouse and a finger (phone, 844×390); the drop, its
// tileHover and the direction wheel are the tile under the pointer; equipment dropped on a tile equips the unit
// on it (not the one in front whose head is drawn there); on a phone a release on a bench slot lands there and one
// over the shop bar goes back.
// (allies by their tile, enemies by their ground position or drawn body); a dragged unit stands on the tile under
// the pointer while that is a legal target — wherever the pointer is on the tile, as in the official deploy drag
// (the owner's recording of 2026-10-09; PR #403's report) — and is held under the pointer elsewhere (over the
// shop bar: its drawn feet DRAG_HOLD_TILES below it, the model around it), for a mouse and a finger (phone,
// 844×390); the drop, its tileHover and the direction wheel are the tile under the pointer; equipment dropped on
// a tile equips the unit on it (not the one in front whose head is drawn there); on a phone a release on a bench
// slot lands there and one over the shop bar goes back.
//
// Opt-in (starts Chrome): RENDER_E2E=1 node --test test/render/models.browser.test.js
// Chrome path: $CHROME_PATH or the macOS default. Screenshots → test/e2e/out/models-*.png.
@@ -315,13 +317,23 @@ describe('user playtest #3 items 1 and 7 (mock match, headless Chrome)', { skip
assert.deepEqual(problems, []);
});
/** The dragged view against the pointer (client px): its drawn feet, px per tile, and the Spine model's drawn bounds. */
/** The dragged view against the pointer (client px): its drawn feet, px per tile, and the Spine model's drawn bounds;
* its world point (wx = col, wy = row, wz) and the height of the board tile under it (hz). */
const ghostAt = (page, uid) => page.evaluate((u) => {
const R = globalThis.__SP_VIEW__.raw, cv = R.debug.app.view.getBoundingClientRect(), v = R.debug.views.get('p:' + u);
const b = v.actor && v.spineReady && !v.imp ? v.actor.spine.getBounds() : null;
return { lift: v.lift, x: cv.left + v.screen.x, y: cv.top + v.screen.y, s: v.screen.s, model: b && { x0: cv.left + b.x, y0: cv.top + b.y, x1: cv.left + b.x + b.width, y1: cv.top + b.y + b.height } };
return { lift: v.lift, x: cv.left + v.screen.x, y: cv.top + v.screen.y, s: v.screen.s, model: b && { x0: cv.left + b.x, y0: cv.top + b.y, x1: cv.left + b.x + b.width, y1: cv.top + b.y + b.height },
wx: v.x, wy: v.y, wz: v.z, hz: R.debug.tiles.heightAt(Math.round(v.y), Math.round(v.x)) };
}, uid);
/** The model is under the pointer: its feet DRAG_HOLD_TILES (0.45) below it, the pointer inside its drawn body. */
/** Over a legal target the model stands on the pointer's tile (the official deploy drag): its world point is that
* tile — the tile's top on the board, the bench pad on the bench — and it is lifted. */
const assertStands = (g, [row, col], label) => {
assert.ok(g.lift > 0, `${label}: lifted`);
assert.ok(Math.abs(g.wx - col) < 1e-6 && Math.abs(g.wy - row) < 1e-6, `${label}: stands on (${row},${col}) (at ${g.wx.toFixed(3)},${g.wy.toFixed(3)})`);
if (row !== 7) assert.ok(Math.abs(g.wz - g.hz) < 1e-6, `${label}: on the tile's top (z ${g.wz} vs ${g.hz})`);
else assert.ok(g.wz > 0, `${label}: on the bench pad (z ${g.wz})`);
};
/** Elsewhere the model is under the pointer: its feet DRAG_HOLD_TILES (0.45) below it, the pointer inside its drawn body. */
const assertHeld = (g, ptr, label) => {
assert.ok(g.lift > 0, `${label}: lifted`);
assert.ok(Math.abs(g.x - ptr.x) < 1.5 && Math.abs(g.y - ptr.y - 0.45 * g.s) < 1.5, `${label}: feet 0.45 tile below the pointer (feet ${g.x.toFixed(1)},${g.y.toFixed(1)}, pointer ${ptr.x.toFixed(1)},${ptr.y.toFixed(1)}, s ${g.s.toFixed(1)})`);
@@ -331,7 +343,7 @@ describe('user playtest #3 items 1 and 7 (mock match, headless Chrome)', { skip
}
};
test('#4.1 drag (mouse): the unit is held under the pointer; target, tileHover and the direction wheel are the pointer\'s tile', async () => {
test('#4.1 drag (mouse): the unit stands on the pointer\'s tile over a legal target, held under the pointer elsewhere; target, tileHover and the direction wheel are the pointer\'s tile', async () => {
const { page, problems } = await open('phase=PREP');
const hand = await page.evaluate(() => { const p = globalThis.__MOCK__.S().priv; const i = p.hand.findIndex((x) => x && x.kind === 'chess'); return { uid: p.hand[i].uid, idx: i }; });
await page.evaluate(() => { window.__hover = []; globalThis.__SP_VIEW__.raw.on('tileHover', (t) => window.__hover.push(t && [t.row, t.col])); });
@@ -340,17 +352,46 @@ describe('user playtest #3 items 1 and 7 (mock match, headless Chrome)', { skip
await page.mouse.move(from.x, from.y); await page.mouse.down();
for (let i = 1; i <= 12; i++) { await page.mouse.move(from.x + ((to.x - from.x) * i) / 12, from.y + ((to.y - from.y) * i) / 12); await sleep(16); }
await sleep(150);
assertHeld(await ghostAt(page, hand.uid), to, 'mouse');
const first = await ghostAt(page, hand.uid);
assertStands(first, [9, 4], 'mouse');
assert.deepEqual(await page.evaluate(() => window.__hover.at(-1)), [9, 4], 'tileHover = the tile under the pointer');
await page.screenshot({ path: path.join(OUT, 'models-drag-ghost.png') });
// the whole way: the model stays under the pointer (sampled at every step of a second drag leg)
// the pointer moving about inside the tile leaves the model where it is (the official recording: the finger moves,
// the model stays on its tile)
for (const [u, v] of [[-0.6, -0.3], [0.6, -0.3], [-0.5, 0.7], [0, 0]]) { // (the nearest strip is hidden by row 8: SPOTS)
const pt = await tileSpot(page, 9, 4, u, v);
await page.mouse.move(pt.x, pt.y);
await sleep(40);
const g = await ghostAt(page, hand.uid);
assertStands(g, [9, 4], `mouse at (${u}, ${v}) of the tile`);
assert.ok(Math.abs(g.x - first.x) < 0.5 && Math.abs(g.y - first.y) < 0.5, `mouse at (${u}, ${v}): the model did not move on screen`);
}
// the whole way: on a legal tile the model stands on the pointer's tile, elsewhere it is under the pointer (sampled
// at every step of a second drag leg; the legal tiles are the view's own 'legal' highlight)
const legal = new Set(await page.evaluate(() => globalThis.__SP_VIEW__.raw.debug.tiles.highlights.get('legal').tiles.map(([r, c]) => `${r},${c}`)));
const leg = await tileSpot(page, 10, 5);
const seen = { stands: 0, held: 0 };
for (let i = 1; i <= 6; i++) {
const pt = { x: to.x + ((leg.x - to.x) * i) / 6, y: to.y + ((leg.y - to.y) * i) / 6 };
await page.mouse.move(pt.x, pt.y);
await sleep(40);
assertHeld(await ghostAt(page, hand.uid), pt, `mouse step ${i}`);
const tile = await page.evaluate(() => window.__hover.at(-1));
if (legal.has(`${tile}`)) { seen.stands++; assertStands(await ghostAt(page, hand.uid), tile, `mouse step ${i}`); }
else { seen.held++; assertHeld(await ghostAt(page, hand.uid), pt, `mouse step ${i}`); }
}
assert.ok(seen.stands > 0, `the leg crosses legal tiles (${JSON.stringify(seen)})`);
// over the shop bar (DOM over the canvas: no target) the model is held under the pointer, so the player still sees
// what they carry
const bar = await page.evaluate(() => { const el = [...document.querySelectorAll('.scard')][2], r = el && el.getBoundingClientRect(); return r && { x: r.left + r.width / 2, y: r.top + r.height * 0.3 }; });
assert.ok(bar, 'premise: a shop card');
assert.ok(await page.evaluate((x, y) => !!document.elementFromPoint(x, y)?.closest('.shopbar'), bar.x, bar.y), 'premise: the point is on the shop bar');
await page.mouse.move(bar.x, bar.y, { steps: 6 });
await sleep(60);
assert.equal(await page.evaluate(() => window.__hover.at(-1)), null, 'no tile under the shop bar');
assertHeld(await ghostAt(page, hand.uid), bar, 'mouse over the shop bar');
await page.mouse.move(to.x, to.y, { steps: 6 });
await sleep(60);
assertStands(await ghostAt(page, hand.uid), [9, 4], 'mouse back on (9,4)');
await page.mouse.move(to.x, to.y);
await sleep(60);
await page.mouse.up();
@@ -402,10 +443,10 @@ describe('user playtest #3 items 1 and 7 (mock match, headless Chrome)', { skip
assert.deepEqual(problems, []);
});
// a phone (touch, landscape 844×390): the finger holds the model like the mouse (no lift above the finger); on the
// bench (the lowest canvas row, the shop bar right under it) a release on the slot lands there, one on the shop bar
// goes back
test('#4.1 touch (phone): held under the finger, dropped on the tile under it; a release over the shop bar goes back', async () => {
// a phone (touch, landscape 844×390): the finger drags the model like the mouse — standing on the finger's tile over a
// legal target, under the finger elsewhere (no lift above the finger); on the bench (the lowest canvas row, the shop
// bar right under it) a release on the slot lands there, one on the shop bar goes back
test('#4.1 touch (phone): stands on the finger\'s tile (under the finger over the shop bar), dropped on the tile under it; a release over the shop bar goes back', async () => {
const page = await browser.newPage();
const problems = [];
page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`));
@@ -436,10 +477,10 @@ describe('user playtest #3 items 1 and 7 (mock match, headless Chrome)', { skip
await page.keyboard.press('Escape');
await sleep(200);
}
// a bench operator onto the board: held under the finger, the wheel on the finger's tile
// a bench operator onto the board: standing on the finger's tile, the wheel on that tile
const hand = await page.evaluate(() => { const p = globalThis.__MOCK__.S().priv; const i = p.hand.findIndex((x) => x && x.kind === 'chess'); return { uid: p.hand[i].uid, idx: i }; });
const to = await tileSpot(page, 9, 4, -0.3, 0.3);
await touchDrag(await tileSpot(page, 7, hand.idx), to, { hold: async () => assertHeld(await ghostAt(page, hand.uid), to, 'touch') });
await touchDrag(await tileSpot(page, 7, hand.idx), to, { hold: async () => assertStands(await ghostAt(page, hand.uid), [9, 4], 'touch') });
await page.waitForSelector('.fwheel__dia', { timeout: 3000 });
const w = await page.evaluate(() => { const r = document.querySelector('.fwheel__dia').getBoundingClientRect(); return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; });
const c = await tileSpot(page, 9, 4);
@@ -457,9 +498,9 @@ describe('user playtest #3 items 1 and 7 (mock match, headless Chrome)', { skip
return null;
}, slot);
assert.ok(bar, 'premise: the shop bar covers the canvas right below the bench slot');
await touchDrag(await tileSpot(page, unit.row, unit.col), bar);
await touchDrag(await tileSpot(page, unit.row, unit.col), bar, { hold: async () => assertHeld(await ghostAt(page, unit.uid), bar, 'touch over the shop bar') });
assert.ok(await page.evaluate((u) => globalThis.__MOCK__.S().priv.board.some((b) => b.uid === u), unit.uid), 'released over the shop bar: back on the board');
await touchDrag(await tileSpot(page, unit.row, unit.col), slot);
await touchDrag(await tileSpot(page, unit.row, unit.col), slot, { hold: async () => assertStands(await ghostAt(page, unit.uid), [7, 5], 'touch on the free bench slot') });
assert.equal(await page.evaluate(() => globalThis.__MOCK__.S().priv.hand[5]?.uid ?? null), unit.uid, 'released on the bench slot: it is there');
await page.screenshot({ path: path.join(OUT, 'models-touch-phone.png') });
await page.close();
Loaded 100 of 447 files, more files were not shown because too many files have changed in this diff. Show more