feedback5: merge feedback4 (3af5b84) — PRs #186 / #134 / #185 / #24 / #183 ported to the split layout (terrainInfo / TERRAIN_TIPS into a new ui/gameLogic/terrain.js re-exported by the gameLogic.js barrel, closesOnFieldPress into ui/gameLogic/shortcuts.js, the rest in place; 42 strings through t(), #183's 设置 too); golden unchanged
Ported by hand: - public/js/ui/gameLogic.js is a re-export shim on feedback5: #185's TERRAIN_TIPS / terrainInfo went into the new public/js/ui/gameLogic/terrain.js (the barrel gains one re-export line); closesOnFieldPress 'terrain' into ui/gameLogic/shortcuts.js. - i18n (docs/I18N.md): every new user-visible string goes through t() with an English entry in public/i18n/en.json (42, after the 补位 strings): the terrain names / tags / fact table are N_() and translated where terrainInfo hands them out, the mechanism lines are msgids with their numbers as params (a line with an optional half is two whole msgids, the list joins use an array param), the card's 地形机制 / 这一格 titles in ui/detailPanel.js. Official EN terms where the EN client has them (stage_table tileInfo: Active Originium, Mire, Exhaust Grille, Deep Water Zone, Incursion Point, Protection Objective, Tunnel Entry / Exit, High Ground; Camouflage). The Chinese output is byte-identical to feedback4's terrainInfo on all 5187 tiles of the 13 stages and on synthetic stages. - public/js/screens/game.js (MatchScreen still lives there): the import keeps feedback5's names plus terrainInfo / fieldTile; the terrain stage is the one view.setStage gets, so it includes feedback5's fieldStage — a 联防 field is played on its own map (GitHub #41), and a tap there explains that map's tile, not the round stage's. The tileClick handler and render/app.js emitTileClick / ui/fallbackField.js auto-merged in place. terrainInfo reads the stage only, so a 补位 / 自选 board explains its tiles the same way (their pieces only change a piece's art and name). - public/js/ui/detailPanel.js: the terrain branch of resolveDetail before feedback5's stand-in / 自选 helpers. - public/js/screens/title.js (#183): feedback5's LangToggle / t() imports plus GIcon / SettingsModal; the gear button's aria-label / title are t('设置') (en.json already has "Settings"). - tools/fetch-assets.mjs / tools/assets/downloader.mjs: --add-only (keepExisting, the font skip) beside #24's --asset-source / SP_ASSET_SOURCE and the shared MirrorPolicy; feedback5's extraOperators / moduleTypes and the voice plan unchanged. - data/assets.json: feedback5's manifest plus #134's audio.sfx.battle.leak (after heal, the plan order); stats files 6993 → 6994 and bytes +23 240 as measured on feedback4; the hash recomputed with the generator's contentHash (c01aa739a2d3 → 6a547fc5c937). A field-wise JSON diff against feedback5 shows only those four fields. - docs: DESIGN §24 intro and §24.5 / §24.6 as on feedback4 (the rest of DESIGN.md is feedback5's); ASSETS.md, DEPLOY.md, PLAYING.md auto-merged. - Auto-merged and checked: audio.js (the sim still emits ['leak', id], server/sim/battle/deploy.js), render/style.js, textures.js, tiles.js, board3d/materials.js, boardArt.js, tools/assets/*, tools/setup.mjs, extract.py, the tests. Checks: the PRs' unit tests (audio 22, gameLogic 44, terrain-detail 1, originium-art 4, boardart 15, board3d-extract 9, asset-network 21, fetch-assets-shrink 4), i18n 9 / i18n-data 8 / i18n-server 4, client-static 313, docs-consistency 24, kits_layout 6 pass; lint 0 errors (no new warnings), check:imports, typecheck; golden: all 283 scenarios match, nothing regenerated.
This commit is contained in:
commit
7059528c47
40 files changed
+1944
-97
No files matched your search
@@ -0,0 +1,370 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { createServer } from 'node:http';
|
||||
import { MirrorPolicy, selectDownloadSource } from '../tools/assets/network.mjs';
|
||||
import { downloadUrls, githubProxyUrl, githubSourceUrl, normalizeProxyPrefix } from '../tools/assets/sources.mjs';
|
||||
import { Downloader } from '../tools/assets/downloader.mjs';
|
||||
import { cachedJson, loadIndexes } from '../tools/assets/cache.mjs';
|
||||
import { resolveTemplate } from '../tools/assets/manifest.mjs';
|
||||
import { processModels } from '../tools/assets/spine.mjs';
|
||||
|
||||
const RAW = 'https://raw.githubusercontent.com/o/r/main/a.json';
|
||||
const PROXY = 'https://gh-proxy.com/' + RAW;
|
||||
const CDN = 'https://cdn.jsdelivr.net/gh/o/r@main/a.json';
|
||||
const quiet = () => {};
|
||||
|
||||
async function fixture(t) {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'sp-network-'));
|
||||
t.after(() => rm(dir, { recursive: true, force: true }));
|
||||
return dir;
|
||||
}
|
||||
|
||||
test('default, manual source and offline mode perform no public IP lookup', async (t) => {
|
||||
const fetch = t.mock.method(globalThis, 'fetch', async () => { assert.fail('unexpected network request'); });
|
||||
assert.equal(await selectDownloadSource({ log: quiet }), 'direct');
|
||||
for (const mode of ['direct', 'mirror']) assert.equal(await selectDownloadSource({ mode, log: quiet }), mode);
|
||||
assert.equal(await selectDownloadSource({ offline: true }), 'direct');
|
||||
await assert.rejects(selectDownloadSource({ mode: 'invalid' }), /unknown asset source/);
|
||||
await assert.rejects(selectDownloadSource({ mode: 'auto' }), /unknown asset source/);
|
||||
assert.equal(fetch.mock.callCount(), 0);
|
||||
});
|
||||
|
||||
test('mirror startup log names its proxy and states the validation scope', async () => {
|
||||
const logs = [];
|
||||
assert.equal(await selectDownloadSource({ mode: 'mirror', proxyPrefix: 'https://mirror.example/p/', log: (msg) => logs.push(msg) }), 'mirror');
|
||||
assert.match(logs[0], /https:\/\/mirror\.example\/p\//);
|
||||
assert.match(logs[0], /仅校验格式和大小/);
|
||||
});
|
||||
|
||||
test('URL order, voice support, custom prefix, and no double prefix', () => {
|
||||
assert.deepEqual(downloadUrls(RAW, { source: 'mirror' }), [PROXY, RAW, CDN]);
|
||||
assert.deepEqual(downloadUrls(RAW), [RAW, CDN]);
|
||||
assert.deepEqual(downloadUrls(RAW, { source: 'direct', proxyPrefix: 'https://custom.example/' }), [RAW, CDN]);
|
||||
const voice = 'https://raw.githubusercontent.com/ArknightsAssets/ArknightsAssets2/voice/assets/x.mp3';
|
||||
assert.deepEqual(downloadUrls(voice, { source: 'mirror' }), ['https://gh-proxy.com/' + voice, voice]);
|
||||
assert.deepEqual(downloadUrls(PROXY, { source: 'mirror' }), [PROXY, RAW, CDN]);
|
||||
assert.deepEqual(downloadUrls(PROXY), [RAW, CDN]);
|
||||
assert.deepEqual(downloadUrls('https://old-proxy.example/prefix/' + RAW, { source: 'mirror', proxyPrefix: '' }), [RAW, CDN]);
|
||||
assert.deepEqual(downloadUrls(PROXY, { source: 'mirror', proxyPrefix: 'https://new-proxy.example/' }), ['https://new-proxy.example/' + RAW, RAW, CDN]);
|
||||
assert.equal(githubSourceUrl('https://old-proxy.example/' + PROXY), RAW);
|
||||
assert.equal(githubSourceUrl(RAW + '/https://example.com/path'), RAW + '/https://example.com/path');
|
||||
for (const url of ['https://example.com/a', 'https://github.com.evil.example/a', 'https://user:secret@github.com/a']) {
|
||||
assert.equal(githubProxyUrl(url), null);
|
||||
}
|
||||
assert.equal(githubProxyUrl(RAW, 'https://mirror.example/prefix'), 'https://mirror.example/prefix/' + RAW);
|
||||
assert.equal(normalizeProxyPrefix(), 'https://gh-proxy.com/');
|
||||
for (const proxyPrefix of ['', ' ']) {
|
||||
assert.equal(normalizeProxyPrefix(proxyPrefix), '');
|
||||
assert.equal(githubProxyUrl(RAW, proxyPrefix), null);
|
||||
assert.deepEqual(downloadUrls(RAW, { source: 'mirror', proxyPrefix }), [RAW, CDN]);
|
||||
}
|
||||
for (const prefix of ['http://mirror.example/', 'https://mirror.example/?q=x', 'https://user:secret@mirror.example/']) {
|
||||
assert.throws(() => normalizeProxyPrefix(prefix), /HTTPS URL/);
|
||||
}
|
||||
});
|
||||
|
||||
test('downloader uses proxy, validates payload, falls back, and skips existing files', async (t) => {
|
||||
const dir = await fixture(t);
|
||||
const calls = [];
|
||||
const dl = new Downloader({ root: dir, ledgerPath: join(dir, 'ledger.json'), source: 'mirror', retries: 1, backoffMs: 0, log: quiet,
|
||||
fetchImpl: async (url) => {
|
||||
calls.push(url);
|
||||
return url === PROXY ? new Response('<html>bad gateway</html>') : Response.json({ ok: true });
|
||||
} });
|
||||
const job = { rel: 'a.json', urls: [RAW], kind: 'json' };
|
||||
assert.equal((await dl.run([job])).get(job.rel).url, RAW);
|
||||
assert.deepEqual(calls, [PROXY, RAW]);
|
||||
assert.equal((await dl.run([job])).get(job.rel).status, 'skip');
|
||||
assert.equal(calls.length, 2);
|
||||
});
|
||||
|
||||
test('proxy provenance is recognized as the primary asset, not a fallback asset', async (t) => {
|
||||
const dir = await fixture(t);
|
||||
const dl = new Downloader({ root: dir, ledgerPath: join(dir, 'ledger.json'), source: 'mirror', log: quiet,
|
||||
fetchImpl: async () => Response.json({ ok: true }) });
|
||||
const job = { rel: 'a.json', urls: [RAW], kind: 'json' };
|
||||
assert.equal((await dl.run([job])).get(job.rel).url, PROXY);
|
||||
for (const source of [PROXY, 'https://old-proxy.example/prefix/' + RAW]) {
|
||||
const resolved = resolveTemplate({ icon: { alts: [job] } }, { root: dir, spine: new Map(), sourceOf: () => source });
|
||||
assert.deepEqual(resolved.fallbacks, [], 'disabling/changing the proxy does not change default proxy provenance');
|
||||
assert.equal(resolved.value.icon, '/assets/a.json');
|
||||
}
|
||||
});
|
||||
|
||||
test('Spine pages from old proxy ledger entries respect direct and explicitly disabled mirror mode', async (t) => {
|
||||
const base = 'https://raw.githubusercontent.com/o/r/main/';
|
||||
const png = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aXioAAAAASUVORK5CYII=', 'base64');
|
||||
for (const prefix of ['https://gh-proxy.com/', 'https://old-proxy.example/prefix/']) {
|
||||
for (const opts of [{ source: 'direct' }, { source: 'mirror', proxyPrefix: '' }]) {
|
||||
const dir = await fixture(t);
|
||||
const atlas = 'extra.png\nsize: 1,1\nformat: RGBA8888\nfilter: Linear,Linear\nrepeat: none\n';
|
||||
await writeFile(join(dir, 'model.atlas'), atlas);
|
||||
const calls = [];
|
||||
const dl = new Downloader({ root: dir, ledgerPath: join(dir, 'ledger.json'), log: quiet, ...opts,
|
||||
fetchImpl: async (url) => {
|
||||
calls.push(url);
|
||||
return url === base + 'extra.png' ? new Response(png) : new Response('', { status: 404 });
|
||||
} });
|
||||
dl.ledger.files['model.atlas'] = { url: prefix + base + 'model.atlas', bytes: Buffer.byteLength(atlas) };
|
||||
const model = { key: 'model', dir: '', baseUrl: base, pngs: [],
|
||||
skel: { rel: 'model.skel', urls: [base + 'model.skel'], kind: 'skel' },
|
||||
atlas: { rel: 'model.atlas', urls: [base + 'model.atlas'], kind: 'atlas' } };
|
||||
await processModels(new Map([['model', model]]), { root: dir, dl, cachePath: join(dir, 'spine.json'), log: quiet });
|
||||
assert.ok(calls.includes(base + 'extra.png'));
|
||||
assert.ok(calls.every((url) => url.startsWith(base) || url.startsWith('https://cdn.jsdelivr.net/')));
|
||||
assert.equal(dl.ledger.files['extra.png'].url, base + 'extra.png');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('all three indexes use the selected proxy and cached/offline reads make no request', async (t) => {
|
||||
const dir = await fixture(t);
|
||||
const calls = [];
|
||||
const opts = { source: 'mirror', log: quiet, fetchImpl: async (url) => { calls.push(url); return Response.json({ index: true }); } };
|
||||
const result = await loadIndexes(dir, opts);
|
||||
assert.deepEqual(result, { audioData: { index: true }, modelsData: { index: true }, charword: { index: true } });
|
||||
assert.equal(calls.length, 3);
|
||||
assert.ok(calls.some((url) => url.endsWith('/charword_table.json')));
|
||||
assert.ok(calls.every((url) => url.startsWith('https://gh-proxy.com/https://raw.githubusercontent.com/')));
|
||||
await loadIndexes(dir, { ...opts, offline: true, refresh: true });
|
||||
assert.equal(calls.length, 3);
|
||||
assert.deepEqual(JSON.parse(await readFile(join(dir, '.cache', 'ark-models', 'models_data.json'), 'utf8')), { index: true });
|
||||
});
|
||||
|
||||
test('index proxy miss falls back to raw', async (t) => {
|
||||
const dir = await fixture(t);
|
||||
const calls = [];
|
||||
assert.deepEqual(await cachedJson({ cacheFile: join(dir, 'index.json'), url: RAW, source: 'mirror', log: quiet,
|
||||
fetchImpl: async (url) => { calls.push(url); return url === PROXY ? new Response('', { status: 404 }) : Response.json({ ok: true }); } }), { ok: true });
|
||||
assert.deepEqual(calls, [PROXY, RAW]);
|
||||
});
|
||||
|
||||
test('direct retries that recover do not print a failure hint or contact the prefix proxy', async (t) => {
|
||||
const dir = await fixture(t);
|
||||
const calls = [];
|
||||
const logs = [];
|
||||
const dl = new Downloader({ root: dir, ledgerPath: join(dir, 'ledger.json'), retries: 1, backoffMs: 0,
|
||||
log: (msg) => logs.push(msg), fetchImpl: async (url) => {
|
||||
calls.push(url);
|
||||
return url.startsWith('https://raw.githubusercontent.com/') ? new Response('', { status: 503 }) : Response.json({ ok: true });
|
||||
} });
|
||||
await dl.run([1, 2].map((i) => ({ rel: `${i}.json`, urls: [RAW + i], kind: 'json' })));
|
||||
assert.equal(calls.length, 4);
|
||||
assert.ok(calls.every((url) => url.startsWith('https://raw.githubusercontent.com/') || url.startsWith('https://cdn.jsdelivr.net/')));
|
||||
assert.equal(logs.filter((msg) => msg.includes('--asset-source=mirror')).length, 0);
|
||||
});
|
||||
|
||||
test('default index retries that fall back successfully do not print a failure hint', async (t) => {
|
||||
const dir = await fixture(t);
|
||||
const calls = [];
|
||||
const logs = [];
|
||||
const result = await cachedJson({ cacheFile: join(dir, 'index.json'), url: RAW, backoffMs: 0,
|
||||
log: (msg) => logs.push(msg), fetchImpl: async (url) => {
|
||||
calls.push(url);
|
||||
return url === RAW ? new Response('', { status: 503 }) : Response.json({ ok: true });
|
||||
} });
|
||||
assert.deepEqual(result, { ok: true });
|
||||
assert.deepEqual(calls, [RAW, RAW, RAW, CDN]);
|
||||
assert.equal(logs.filter((msg) => msg.includes('--asset-source=mirror')).length, 0);
|
||||
});
|
||||
|
||||
test('empty proxy setting disables every downloader and index proxy request in mirror mode', async (t) => {
|
||||
const dir = await fixture(t);
|
||||
const calls = [];
|
||||
const opts = { source: 'mirror', proxyPrefix: '', log: quiet, fetchImpl: async (url) => { calls.push(url); return Response.json({ ok: true }); } };
|
||||
await loadIndexes(dir, opts);
|
||||
const dl = new Downloader({ root: dir, ledgerPath: join(dir, 'ledger.json'), ...opts });
|
||||
await dl.run([{ rel: 'a.json', urls: [RAW], kind: 'json' }]);
|
||||
assert.equal(calls.length, 4);
|
||||
assert.ok(calls.every((url) => url.startsWith('https://raw.githubusercontent.com/')));
|
||||
});
|
||||
|
||||
test('one circuit spans indexes, assets and fonts and stops after three failed proxy attempts', async (t) => {
|
||||
const dir = await fixture(t);
|
||||
const calls = [];
|
||||
const log = [];
|
||||
const mirrorPolicy = new MirrorPolicy({ source: 'mirror', log: (msg) => log.push(msg) });
|
||||
const opts = { source: 'mirror', mirrorPolicy, log: quiet, fetchImpl: async (url) => {
|
||||
calls.push(url);
|
||||
return url.startsWith('https://gh-proxy.com/') ? new Response('', { status: 503 }) : Response.json({ ok: true });
|
||||
} };
|
||||
await loadIndexes(dir, opts); // three consecutive proxy failures across the indexes
|
||||
assert.equal(mirrorPolicy.disabled, true);
|
||||
const dl = new Downloader({ root: join(dir, 'assets'), ledgerPath: join(dir, 'ledger.json'), concurrency: 1, ...opts });
|
||||
await dl.run([1, 2, 3].map((i) => ({ rel: `${i}.json`, urls: [RAW + i], kind: 'json' })));
|
||||
const fonts = new Downloader({ root: join(dir, 'fonts'), ledgerPath: join(dir, 'fonts-ledger.json'), ...opts });
|
||||
await fonts.run([{ rel: 'font.json', urls: [RAW + '/font'], kind: 'json' }]);
|
||||
assert.equal(calls.filter((url) => url.startsWith('https://gh-proxy.com/')).length, 3, 'no retries on the proxy, no later proxy requests');
|
||||
assert.equal(dl.totals.ok, 3);
|
||||
assert.equal(fonts.totals.ok, 1);
|
||||
assert.equal(log.length, 1, 'one circuit-open warning for the shared invocation');
|
||||
});
|
||||
|
||||
test('successful and not-found proxy responses reset the failure streak, opened circuit stays off', async (t) => {
|
||||
const dir = await fixture(t);
|
||||
const statuses = [503, 503, 200, 503, 404, 503, 503, 503];
|
||||
let proxyCalls = 0;
|
||||
const dl = new Downloader({ root: dir, ledgerPath: join(dir, 'ledger.json'), source: 'mirror', concurrency: 1, log: quiet,
|
||||
fetchImpl: async (url) => {
|
||||
if (!url.startsWith('https://gh-proxy.com/')) return Response.json({ ok: true });
|
||||
const status = statuses[proxyCalls++] ?? 200;
|
||||
return status === 200 ? Response.json({ ok: true }) : new Response('', { status });
|
||||
} });
|
||||
await dl.run(Array.from({ length: 10 }, (_, i) => ({ rel: `${i}.json`, urls: [RAW + i], kind: 'json' })));
|
||||
assert.equal(proxyCalls, 8);
|
||||
assert.equal(dl.totals.ok, 10);
|
||||
});
|
||||
|
||||
test('invalid payloads trip the same circuit as HTTP failures', async (t) => {
|
||||
const dir = await fixture(t);
|
||||
let proxyCalls = 0;
|
||||
const dl = new Downloader({ root: dir, ledgerPath: join(dir, 'ledger.json'), source: 'mirror', concurrency: 1, log: quiet,
|
||||
fetchImpl: async (url) => {
|
||||
if (!url.startsWith('https://gh-proxy.com/')) return Response.json({ ok: true });
|
||||
proxyCalls++;
|
||||
return new Response('<html>upstream error</html>');
|
||||
} });
|
||||
await dl.run([1, 2, 3, 4].map((i) => ({ rel: `${i}.json`, urls: [RAW + i], kind: 'json' })));
|
||||
assert.equal(proxyCalls, 3);
|
||||
assert.equal(dl.totals.ok, 4);
|
||||
});
|
||||
|
||||
test('slow proxy requests use a short deadline, abort, and fall back without repeated waits', async (t) => {
|
||||
const dir = await fixture(t);
|
||||
let proxyCalls = 0;
|
||||
const mirrorPolicy = new MirrorPolicy({ source: 'mirror', timeoutMs: 10, failureLimit: 2, log: quiet });
|
||||
const dl = new Downloader({ root: dir, ledgerPath: join(dir, 'ledger.json'), source: 'mirror', mirrorPolicy, concurrency: 1, log: quiet,
|
||||
fetchImpl: async (url, { signal }) => {
|
||||
if (!url.startsWith('https://gh-proxy.com/')) return Response.json({ ok: true });
|
||||
proxyCalls++;
|
||||
return new Promise((_, reject) => signal.addEventListener('abort', () => reject(signal.reason), { once: true }));
|
||||
} });
|
||||
const keepAlive = setInterval(quiet, 1000);
|
||||
const started = Date.now();
|
||||
try { await dl.run([1, 2, 3].map((i) => ({ rel: `${i}.json`, urls: [RAW + i], kind: 'json' }))); }
|
||||
finally { clearInterval(keepAlive); }
|
||||
assert.equal(proxyCalls, 2);
|
||||
assert.equal(dl.totals.ok, 3);
|
||||
assert.ok(Date.now() - started < 3000, 'uses the mirror deadline, not 120 seconds times three');
|
||||
});
|
||||
|
||||
test('opening the circuit aborts concurrent proxy work and later jobs go direct', async (t) => {
|
||||
const dir = await fixture(t);
|
||||
let proxyCalls = 0;
|
||||
let aborted = 0;
|
||||
const mirrorPolicy = new MirrorPolicy({ source: 'mirror', failureLimit: 1, log: quiet });
|
||||
const dl = new Downloader({ root: dir, ledgerPath: join(dir, 'ledger.json'), source: 'mirror', mirrorPolicy, concurrency: 3, log: quiet,
|
||||
fetchImpl: async (url, { signal }) => {
|
||||
if (!url.startsWith('https://gh-proxy.com/')) return Response.json({ ok: true });
|
||||
proxyCalls++;
|
||||
if (proxyCalls === 3) return new Response('', { status: 503 });
|
||||
return new Promise((_, reject) => signal.addEventListener('abort', () => { aborted++; reject(signal.reason); }, { once: true }));
|
||||
} });
|
||||
await dl.run([1, 2, 3, 4, 5].map((i) => ({ rel: `${i}.json`, urls: [RAW + i], kind: 'json' })));
|
||||
assert.equal(proxyCalls, 3);
|
||||
assert.equal(aborted, 2);
|
||||
assert.equal(dl.totals.ok, 5);
|
||||
});
|
||||
|
||||
test('the mirror body idle deadline covers stalled response bodies for indexes and files', async (t) => {
|
||||
const dir = await fixture(t);
|
||||
const server = createServer((req, res) => {
|
||||
res.writeHead(200, { 'content-type': 'application/json' });
|
||||
res.write('{"ok":'); // Headers arrive, but the body never finishes.
|
||||
});
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(0, '127.0.0.1', resolve);
|
||||
});
|
||||
t.after(() => new Promise((resolve) => { server.close(resolve); server.closeAllConnections(); }));
|
||||
let proxyCalls = 0;
|
||||
const mirrorPolicy = new MirrorPolicy({ source: 'mirror', timeoutMs: 100, failureLimit: 2, log: quiet });
|
||||
const opts = { mirrorPolicy, log: quiet, fetchImpl: async (url, options) => {
|
||||
if (!url.startsWith('https://gh-proxy.com/')) return Response.json({ ok: true });
|
||||
proxyCalls++;
|
||||
return fetch(`http://127.0.0.1:${server.address().port}`, options);
|
||||
} };
|
||||
const started = Date.now();
|
||||
assert.deepEqual(await cachedJson({ cacheFile: join(dir, 'index.json'), url: RAW, ...opts }), { ok: true });
|
||||
const dl = new Downloader({ root: dir, ledgerPath: join(dir, 'ledger.json'), concurrency: 1, ...opts });
|
||||
await dl.run([1, 2].map((i) => ({ rel: `${i}.json`, urls: [RAW + i], kind: 'json' })));
|
||||
assert.equal(proxyCalls, 2);
|
||||
assert.equal(dl.totals.ok, 2);
|
||||
assert.ok(Date.now() - started < 4000, 'stalled body reads time out instead of waiting indefinitely');
|
||||
});
|
||||
|
||||
test('mirror header timeout does not cap a steadily streaming index or asset body', async (t) => {
|
||||
const dir = await fixture(t);
|
||||
const payload = Buffer.from('{"streamed":true}');
|
||||
const delayedResponse = () => {
|
||||
let offset = 0;
|
||||
let timer;
|
||||
return new Response(new ReadableStream({
|
||||
start(controller) {
|
||||
timer = setInterval(() => {
|
||||
if (offset >= payload.length) {
|
||||
clearInterval(timer);
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
controller.enqueue(payload.subarray(offset, ++offset));
|
||||
}, 20);
|
||||
},
|
||||
cancel() { clearInterval(timer); },
|
||||
}), { headers: { 'content-type': 'application/json', 'content-length': String(payload.length) } });
|
||||
};
|
||||
const mirrorPolicy = new MirrorPolicy({ source: 'mirror', timeoutMs: 100, log: quiet });
|
||||
const opts = { mirrorPolicy, log: quiet, fetchImpl: async (url) => url.startsWith('https://gh-proxy.com/') ? delayedResponse() : Response.json({ direct: true }) };
|
||||
const started = Date.now();
|
||||
assert.deepEqual(await cachedJson({ cacheFile: join(dir, 'index.json'), url: RAW, ...opts }), { streamed: true });
|
||||
const dl = new Downloader({ root: join(dir, 'assets'), ledgerPath: join(dir, 'ledger.json'), ...opts });
|
||||
const result = await dl.run([{ rel: 'slow.json', urls: [RAW], kind: 'json' }]);
|
||||
assert.equal(result.get('slow.json').status, 'ok');
|
||||
assert.ok(Date.now() - started > 100, 'the body takes longer than the header deadline while making progress');
|
||||
});
|
||||
|
||||
test('CLI source defaults to direct and retains the 0.1.1 shrink/local-spine flags', async () => {
|
||||
const { parseArgs } = await import('../tools/fetch-assets.mjs');
|
||||
const old = process.env.SP_ASSET_SOURCE;
|
||||
try {
|
||||
delete process.env.SP_ASSET_SOURCE;
|
||||
assert.equal(parseArgs([]).source, 'direct');
|
||||
process.env.SP_ASSET_SOURCE = 'mirror';
|
||||
assert.equal(parseArgs([]).source, 'mirror');
|
||||
assert.equal(parseArgs([]).voiceLang, 'cn');
|
||||
assert.equal(parseArgs([]).voiceAll, false);
|
||||
const opts = parseArgs(['--asset-source=direct', '--allow-shrink', '--local-spines', '--voice-lang=jp', '--voice-all']);
|
||||
assert.equal(opts.source, 'direct');
|
||||
assert.equal(opts.allowShrink, true);
|
||||
assert.equal(opts.localSpines, true);
|
||||
assert.equal(opts.voiceLang, 'jp');
|
||||
assert.equal(opts.voiceAll, true);
|
||||
} finally {
|
||||
if (old === undefined) delete process.env.SP_ASSET_SOURCE;
|
||||
else process.env.SP_ASSET_SOURCE = old;
|
||||
}
|
||||
});
|
||||
|
||||
test('SP_GITHUB_PROXY is parsed only for online mirror mode', async () => {
|
||||
const { resolveProxyPrefix } = await import('../tools/fetch-assets.mjs');
|
||||
assert.equal(resolveProxyPrefix('direct', false, 'not-an-https-url'), '');
|
||||
assert.equal(resolveProxyPrefix('mirror', true, 'not-an-https-url'), '');
|
||||
assert.equal(resolveProxyPrefix('mirror', false, ''), '');
|
||||
assert.throws(() => resolveProxyPrefix('mirror', false, 'not-an-https-url'), /Invalid URL/);
|
||||
});
|
||||
|
||||
test('setup and asset CLI document and validate source options before doing work', () => {
|
||||
for (const script of ['tools/setup.mjs', 'tools/fetch-assets.mjs']) {
|
||||
const help = spawnSync(process.execPath, [script, '--help'], { encoding: 'utf8' });
|
||||
assert.equal(help.status, 0, help.stderr);
|
||||
assert.match(help.stdout, /--asset-source/);
|
||||
assert.match(help.stdout, /SP_GITHUB_PROXY/);
|
||||
const invalid = spawnSync(process.execPath, [script, '--asset-source=invalid'], { encoding: 'utf8' });
|
||||
assert.notEqual(invalid.status, 0);
|
||||
assert.match(invalid.stderr, /unknown asset source/);
|
||||
}
|
||||
});
|
||||
@@ -968,6 +968,13 @@ describe('screen helpers', () => {
|
||||
assert.equal(findUiAsset({ ui: {} }, ['x']), null);
|
||||
});
|
||||
|
||||
test('title exposes the shared settings modal', () => {
|
||||
const source = readFileSync(path.join(PUBLIC, 'js/screens/title.js'), 'utf8');
|
||||
assert.match(source, /import \{ SettingsModal \} from '\.\.\/ui\/settings\.js'/);
|
||||
assert.match(source, /class="title-settings fsbtn tapx"/, 'title screen includes the settings control');
|
||||
assert.match(source, /<\$\{SettingsModal\} open=\$\{settingsOpen\}/, 'settings control opens the shared modal');
|
||||
});
|
||||
|
||||
test('lobby: normalizeCode / parseRoomParam / difficultyInfo', async () => {
|
||||
const { normalizeCode, parseRoomParam, difficultyInfo, MODE_TEXT } = await mod('screens/lobby.js');
|
||||
assert.equal(normalizeCode('ab-c d9'), 'ABCD');
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
// test/render/board3d-extract.test.js — the local-client exports behind the official 3D board (DESIGN §13/§15):
|
||||
// tools/local-extract/extract.py's board jobs (gate / objective effects, blower texture, water & noise maps), the
|
||||
// derived three.js maps (BC5 normal → RGB normal, Unity metallic/gloss → roughness), the prefab mesh keys, and —
|
||||
// derived three.js maps (BC5 normal → RGB normal, Unity metallic/gloss → roughness), the WebP copies of the textures
|
||||
// the renderer downloads (extract.py WEBP), the prefab mesh keys, and —
|
||||
// when a client was extracted here — the files and manifest entries the renderer loads (render/board3d/load.js).
|
||||
|
||||
import { test, describe } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import { readFileSync, existsSync, mkdtempSync, rmSync } from 'node:fs';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { decodePng } from '../../tools/crop-board-atlas.mjs';
|
||||
@@ -18,6 +20,7 @@ const TOOL = path.join(ROOT, 'tools/local-extract');
|
||||
const ENV = { ...process.env, PYTHONDONTWRITEBYTECODE: '1' };
|
||||
const PY = ['python3', 'python'].find((bin) => spawnSync(bin, ['--version']).status === 0);
|
||||
const HAS_PIL = !!PY && spawnSync(PY, ['-c', 'import PIL'], { env: ENV }).status === 0;
|
||||
const HAS_WEBP = HAS_PIL && spawnSync(PY, ['-c', "import sys; from PIL import features; sys.exit(0 if features.check('webp') else 1)"], { env: ENV }).status === 0;
|
||||
const manifest = (() => { try { return JSON.parse(readFileSync(path.join(ROOT, 'data/local-assets.json'), 'utf8')); } catch { return null; } })();
|
||||
|
||||
function py(code) {
|
||||
@@ -62,6 +65,68 @@ print(json.dumps({'n': [list(rn.getpixel((i, 0))) for i in range(3)], 'm': [list
|
||||
assert.deepEqual(out.m, [[255, 255, 0], [255, 55, 0]]);
|
||||
});
|
||||
|
||||
test('WebP copies: exactly the board textures the renderer downloads; normal and data maps stay lossless', () => {
|
||||
const r = spawnSync(PY, [path.join(TOOL, 'extract.py'), '--print-jobs'], { encoding: 'utf8', env: ENV });
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
const { webp } = JSON.parse(r.stdout);
|
||||
assert.deepEqual(webp.map((w) => `${w.sub}/${w.name}`).sort(), Object.values(PACK_IMAGES).map(([g, n]) => `${g}/${n}`).sort());
|
||||
const mode = Object.fromEntries(webp.map((w) => [w.name, w.mode]));
|
||||
for (const n of ['TX_autochessi_N_rgb', '[ucp]TX_water_normal', 'TX_autochessi_M_rough', 'T_noise_clouds_01']) assert.equal(mode[n], 'lossless', n);
|
||||
for (const n of ['TX_autochessi_D', 'TX_autochessi_BG', 'TX_autochessi_common_D']) assert.equal(mode[n], 'lossy', n);
|
||||
});
|
||||
|
||||
test('run_webp / --webp: a copy beside the PNG that the manifest lists; normals exact, alpha and hidden RGB kept', { skip: !HAS_WEBP && 'no Pillow with WebP' }, () => {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), 'sp-webp-'));
|
||||
try {
|
||||
const out = py(`
|
||||
from pathlib import Path
|
||||
from PIL import Image
|
||||
root = Path(${JSON.stringify(dir)})
|
||||
sub = root / 'map' / 'autochess'
|
||||
sub.mkdir(parents=True)
|
||||
d = Image.new('RGBA', (64, 64), (200, 40, 40, 0)) # transparent texels whose colour the opaque board material shows
|
||||
for x in range(32):
|
||||
for y in range(64): d.putpixel((x, y), (x * 8, y * 4, 90, 255))
|
||||
d.save(sub / 'TX_autochessi_D.png')
|
||||
n = Image.new('RGB', (64, 64))
|
||||
for x in range(64):
|
||||
for y in range(64): n.putpixel((x, y), ((x * 37) % 256, (y * 53) % 256, 200 + (x + y) % 56))
|
||||
n.save(sub / 'TX_autochessi_N_rgb.png')
|
||||
entry = lambda name, kind: {'path': f'/assets/local/map/autochess/{name}.png', 'w': 64, 'h': 64, 'kind': kind}
|
||||
groups = {'map/autochess': {'TX_autochessi_D': entry('TX_autochessi_D', 'Texture2D'),
|
||||
'TX_autochessi_N_rgb': entry('TX_autochessi_N_rgb', 'Derived'),
|
||||
'TX_autochessi_E': entry('TX_autochessi_E', 'Texture2D')}}
|
||||
mf = root / 'local-assets.json'
|
||||
mf.write_text(json.dumps({'version': 1, 'source': 'local-client', 'count': 3, 'groups': groups}))
|
||||
logs = []
|
||||
code = e.webp_only(root, mf, logs.append)
|
||||
doc = json.loads(mf.read_text())
|
||||
g = doc['groups']['map/autochess']
|
||||
px = lambda p: list(Image.open(p).convert('RGBA').getdata())
|
||||
a, b = px(sub / 'TX_autochessi_D.png'), px(sub / 'TX_autochessi_D.webp')
|
||||
hidden = [q for p, q in zip(a, b) if p[3] == 0]
|
||||
print(json.dumps({'code': code, 'paths': {k: v['path'] for k, v in g.items()}, 'modes': {k: v.get('webp') for k, v in g.items()},
|
||||
'count': doc['count'], 'pngKept': (sub / 'TX_autochessi_D.png').exists(),
|
||||
'alphaSame': all(p[3] == q[3] for p, q in zip(a, b)),
|
||||
'normalSame': px(sub / 'TX_autochessi_N_rgb.png') == px(sub / 'TX_autochessi_N_rgb.webp'),
|
||||
'hidden': [sum(q[i] for q in hidden) / len(hidden) for i in range(3)],
|
||||
'again': e.run_webp(root, 'map/autochess', doc['groups'], logs.append)}))`);
|
||||
assert.equal(out.code, 0);
|
||||
const at = (n, ext) => `/assets/local/map/autochess/${n}.${ext}`;
|
||||
// TX_autochessi_E is listed without a PNG on disk: its entry stays as it was
|
||||
assert.deepEqual(out.paths, { TX_autochessi_D: at('TX_autochessi_D', 'webp'), TX_autochessi_N_rgb: at('TX_autochessi_N_rgb', 'webp'), TX_autochessi_E: at('TX_autochessi_E', 'png') });
|
||||
assert.deepEqual(out.modes, { TX_autochessi_D: 'lossy', TX_autochessi_N_rgb: 'lossless', TX_autochessi_E: null });
|
||||
assert.equal(out.count, 3);
|
||||
assert.ok(out.pngKept, 'the PNG stays beside the copy (crop tool, setup)');
|
||||
assert.ok(out.alphaSame, 'alpha lossless');
|
||||
assert.ok(out.normalSame, 'normal map lossless');
|
||||
out.hidden.forEach((v, i) => assert.ok(Math.abs(v - [200, 40, 40][i]) < 12, `RGB under alpha 0 kept: ${out.hidden}`));
|
||||
assert.equal(out.again, 2, 'a second run rewrites both copies');
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('prefab_node names the exported OBJ of a mesh whose name is shared (map/fx has two Start_back meshes)', () => {
|
||||
const out = py(`
|
||||
from types import SimpleNamespace as NS
|
||||
@@ -92,6 +157,7 @@ describe('vendored three.js', () => {
|
||||
|
||||
describe('extracted board files (when a local client was extracted)', { skip: !manifest?.groups?.['map/fx'] && 'map/fx not extracted' }, () => {
|
||||
const onDisk = (p) => path.join(ROOT, 'public', decodeURIComponent(p));
|
||||
const pngOf = (p) => p.replace(/\.webp$/, '.png'); // a WebP copy (extract.py WEBP) keeps its PNG beside it
|
||||
const hasFiles = existsSync(path.join(ROOT, 'public/assets/local/map/fx'));
|
||||
|
||||
test('every pack slot the renderer loads is in the manifest (and on disk)', () => {
|
||||
@@ -113,13 +179,24 @@ describe('extracted board files (when a local client was extracted)', { skip: !m
|
||||
|
||||
test('derived maps on disk: RGB normals point out of the surface; roughness mirrors the smoothness', { skip: !hasFiles && 'files not extracted' }, () => {
|
||||
const g = manifest.groups['map/autochess'];
|
||||
const n = decodePng(readFileSync(onDisk(g.TX_autochessi_N_rgb.path)));
|
||||
const n = decodePng(readFileSync(onDisk(pngOf(g.TX_autochessi_N_rgb.path))));
|
||||
let zSum = 0, cnt = 0;
|
||||
for (let i = 0; i < n.rgba.length; i += 4 * 97) { zSum += n.rgba[i + 2]; cnt++; }
|
||||
assert.ok(zSum / cnt > 230, `mean z ${zSum / cnt}`);
|
||||
const m = decodePng(readFileSync(onDisk(g.TX_autochessi_M.path)));
|
||||
const r = decodePng(readFileSync(onDisk(g.TX_autochessi_M_rough.path)));
|
||||
const r = decodePng(readFileSync(onDisk(pngOf(g.TX_autochessi_M_rough.path))));
|
||||
assert.deepEqual([r.w, r.h], [m.w, m.h]);
|
||||
for (let i = 0; i < m.rgba.length; i += 4 * 131) assert.equal(r.rgba[i + 1], 255 - m.rgba[i + 3]);
|
||||
});
|
||||
|
||||
test('the WebP copies the manifest lists are on disk with their PNGs beside them', { skip: !hasFiles && 'files not extracted' }, () => {
|
||||
for (const [g, entries] of Object.entries(manifest.groups)) {
|
||||
for (const [n, e] of Object.entries(entries)) {
|
||||
if (!/\.webp$/.test(e?.path || '')) continue;
|
||||
assert.ok(existsSync(onDisk(e.path)), `${g}/${n}`);
|
||||
assert.ok(existsSync(onDisk(pngOf(e.path))), `${g}/${n}: PNG`);
|
||||
assert.ok(e.webp === 'lossy' || e.webp === 'lossless', `${g}/${n}: mode ${e.webp}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,7 @@ import { FX_KINDS, fxSpec, tilesAround } from '../../public/js/render/fx.js';
|
||||
import { statusIconKey } from '../../public/js/render/style.js';
|
||||
import { STATUS_KEYS } from '../../public/js/render/textures.js';
|
||||
import { createAssets, localAssetUrl } from '../../public/js/assets.js';
|
||||
import { loadBoardArt, resetBoardArt, sourceUrl } from '../../public/js/render/boardArt.js';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const stages = JSON.parse(readFileSync(path.join(ROOT, 'data/stages.json'), 'utf8'));
|
||||
@@ -311,4 +312,26 @@ describe('local-art manifest helpers', () => {
|
||||
const broken = createAssets({ manifest: {}, fetch: async () => { throw new Error('offline'); } });
|
||||
assert.equal(await broken.local(), null);
|
||||
});
|
||||
|
||||
test('board art loads the crop-table textures at the URLs the manifest lists (one download with the 3D board)', async () => {
|
||||
const at = (n, ext) => `/assets/local/map/autochess/${n}.${ext}`;
|
||||
const m = { groups: { 'map/autochess': { TX_autochessi_D: { path: at('TX_autochessi_D', 'webp') }, TX_autochessi_BG: { path: at('TX_autochessi_BG', 'webp') } } } };
|
||||
const asked = [];
|
||||
const store = { local: async () => m, localUrl: (g, n) => localAssetUrl(m, g, n), image: async (u) => { asked.push(u); return { width: 4 }; } };
|
||||
assert.equal(sourceUrl(store, at('TX_autochessi_D', 'png')), at('TX_autochessi_D', 'webp'));
|
||||
assert.equal(sourceUrl(store, at('TX_autochessi_common_D', 'png')), at('TX_autochessi_common_D', 'png'), 'not in the manifest: the table path');
|
||||
assert.equal(sourceUrl({}, '/x/y.png'), '/x/y.png');
|
||||
const tiles = { version: 2, materials: {}, source: { D: { path: at('TX_autochessi_D', 'png') }, common: { path: at('TX_autochessi_common_D', 'png') }, BG: { path: at('TX_autochessi_BG', 'png') } } };
|
||||
const realFetch = globalThis.fetch;
|
||||
globalThis.fetch = async (u) => (u === '/assets/local/map/autochess/tiles.json' ? { ok: true, json: async () => tiles } : { ok: false, status: 404 });
|
||||
try {
|
||||
resetBoardArt();
|
||||
const art = await loadBoardArt(store);
|
||||
assert.ok(art?.images.D && art.images.common && art.images.BG);
|
||||
assert.deepEqual(asked.sort(), [at('TX_autochessi_BG', 'webp'), at('TX_autochessi_D', 'webp'), at('TX_autochessi_common_D', 'png')]);
|
||||
} finally {
|
||||
globalThis.fetch = realFetch;
|
||||
resetBoardArt();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
// GitHub #184 (second half): 活性源石 was drawn as two different materials — the 2D atlas cell was a beveled brick with
|
||||
// random crystal clusters, the 3D board's quad a world-space crust with pulsing veins — so the same tile read as two
|
||||
// different floors. These are the couplings that keep it ONE material: the palette of `render/style.js ORIGINIUM`, the
|
||||
// concrete slab it lies on, and no per-tile frame (which made a field of 活性源石 look like separate patches).
|
||||
// The pixels themselves are checked in test/render/originium-cell.e2e.test.js (it needs a real canvas).
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const read = (p) => readFileSync(path.join(ROOT, p), 'utf8');
|
||||
const style = read('public/js/render/style.js');
|
||||
const textures = read('public/js/render/textures.js');
|
||||
const materials = read('public/js/render/board3d/materials.js');
|
||||
const tiles = read('public/js/render/tiles.js');
|
||||
|
||||
/** The source of one exported function of a module (its JSDoc through the closing brace at column 0). */
|
||||
function fnSource(src, name) {
|
||||
const at = src.indexOf(`export function ${name}(`);
|
||||
assert.ok(at > 0, `${name} not found`);
|
||||
const end = src.indexOf('\n}\n', at);
|
||||
return src.slice(src.lastIndexOf('/**', at), end + 2);
|
||||
}
|
||||
|
||||
test('活性源石: one palette for both boards, and the canvas converts the working space', () => {
|
||||
assert.match(style, /export const ORIGINIUM = Object\.freeze\(\{/, 'the palette lives in style.js');
|
||||
for (const k of ['base', 'crust', 'vein', 'spec']) assert.match(style, new RegExp(`${k}: Object\\.freeze\\(\\[`), k);
|
||||
assert.match(style, /export function linearToSrgb255/, 'the sRGB conversion the canvas needs');
|
||||
// the 2D drawer takes its colours from the palette (no private copy) and converts them for the canvas
|
||||
assert.match(textures, /import \{[^}]*ORIGINIUM[^}]*\} from '\.\/style\.js'/);
|
||||
assert.match(textures, /ORIGINIUM\.vein/);
|
||||
assert.match(textures, /linearToSrgb255\(/, 'the canvas stores sRGB, the palette is the shaders\' linear space');
|
||||
// so does the 3D shader, through the vec3 helper
|
||||
assert.match(materials, /import \{ ORIGINIUM \} from '\.\.\/style\.js'/);
|
||||
assert.match(materials, /const V3 = \(c\) => `vec3\(/);
|
||||
for (const k of ['base', 'crust', 'vein', 'spec']) assert.match(materials, new RegExp(`V3\\(ORIGINIUM\\.${k}\\)`), k);
|
||||
// the identical recipe (the shader's own thresholds, mirrored in the drawer's smoothstep calls)
|
||||
assert.match(materials, /smoothstep\(0\.38, 0\.62, n \* 0\.7 \+ n2 \* 0\.45\)/);
|
||||
assert.match(textures, /smoothstep\(0\.38, 0\.62, n1 \* 0\.7 \+ n2 \* 0\.45\)/);
|
||||
assert.match(materials, /smoothstep\(0\.86, 0\.98, 1\.0 - abs\(n2 \* 2\.0 - 1\.0\)\)/);
|
||||
assert.match(textures, /smoothstep\(0\.86, 0\.98, 1 - Math\.abs\(n2 \* 2 - 1\)\)/);
|
||||
});
|
||||
|
||||
test('活性源石: the 2D tile lies on the same concrete the 3D board lays its crust on', () => {
|
||||
// board3d/atlas.js `concrete` and textures.js DEFAULT_ART_LAYERS.infection must name the same atlas rect: with the
|
||||
// board art installed the 2D tile is composed exactly like the 3D one (official slab + our crust).
|
||||
const atlas = read('public/js/render/board3d/atlas.js');
|
||||
const m2 = atlas.match(/concrete: S\('D', \[([\d, ]+)\]\)/);
|
||||
const m1 = textures.match(/infection: Object\.freeze\(\[Object\.freeze\(\{ src: 'D', rect: \[([\d, ]+)\] \}\)/);
|
||||
assert.ok(m1 && m2, 'both define the slab');
|
||||
assert.equal(m1[1].replace(/\s/g, ''), m2[1].replace(/\s/g, ''), 'the same concrete cell');
|
||||
});
|
||||
|
||||
test('活性源石: neither board fades the tile out at its own border', () => {
|
||||
// the 3D crust used to be multiplied by a per-tile `edge` fade (min(vUv, 1 − vUv)), which left a transparent band
|
||||
// between neighbouring tiles; the drawer must reach the cell's edges the same way. Its siblings (mire / water) keep
|
||||
// their own fade — this is about 活性源石 only.
|
||||
const inf = fnSource(materials, 'infectionMaterial');
|
||||
assert.ok(!/min\(e\.x, e\.y\)|float edge/.test(inf), 'no per-tile edge fade in the infection shader');
|
||||
assert.match(inf, /float a = clamp\(crust \* 0\.72 \+ vein, 0\.0, 1\.0\)/);
|
||||
const mire = fnSource(materials, 'mireMaterial');
|
||||
assert.match(mire, /float edge = smoothstep/, 'the mire keeps its own fade');
|
||||
// the 2D cell has no frame either: no bevelTile in the drawer's two variants
|
||||
const draw = textures.slice(textures.indexOf(' infection(ctx, x, y, s, r) {'), textures.indexOf(' cliff(ctx, x, y, s) {'));
|
||||
assert.ok(!/bevelTile/.test(draw), 'the old beveled brick is gone');
|
||||
assert.match(draw, /floorBase\(ctx, x, y, s, r\); originiumOverlay\(ctx, x, y, s, false\);/);
|
||||
assert.match(draw, /infection2\(ctx, x, y, s, r\) \{ floorBase\(ctx, x, y, s, r\); originiumOverlay\(ctx, x, y, s, true\); \}/);
|
||||
});
|
||||
|
||||
test('活性源石: two variants are registered, picked by tile, and the pulse uses the shared tint', () => {
|
||||
assert.match(textures, /'deepsea', 'infection', 'infection2', 'cliff'/);
|
||||
assert.match(textures, /originium2\(ctx, x, y, s\) \{ originiumOverlay\(ctx, x, y, s, true\); \}/, 'the proc layer for the art path');
|
||||
assert.match(textures, /infection2: Object\.freeze\(\[Object\.freeze\(\{ src: 'D', rect: \[256, 512, 256, 256\] \}\), Object\.freeze\(\{ proc: 'originium2' \}\)\]\)/);
|
||||
assert.match(tiles, /case 'infection': return h < 4 \? 'infection' : 'infection2';/);
|
||||
assert.match(tiles, /add\('infect', 'glow', P\.BLEND_MODES\.ADD, ORIGINIUM\.glow, 1\)/);
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
// GitHub #184 (second half), the pixels: the 2D board's 活性源石 cell must be the material the 3D board's shader draws
|
||||
// (`render/board3d/materials.js infectionMaterial`) — the palette of `render/style.js ORIGINIUM`, the crust reaching the
|
||||
// tile's edges (no per-tile frame: a field of 活性源石 must read as ONE floor, not as separate patches), and the cell
|
||||
// joining itself without a seam (the noise wraps, so a repeated cell lines up).
|
||||
// SP_E2E=1 CHROME_PATH=… node --test test/render/originium-cell.e2e.test.js
|
||||
// Both configurations are checked: the atlas a deployment WITH the board art renders (the official concrete slab + our
|
||||
// crust, `DEFAULT_ART_LAYERS.infection`) and the procedural one upstream players without the art see (`MAT_DRAW.infection`).
|
||||
// The couplings between the two renderers' sources are in test/render/originium-art.test.js (no browser needed).
|
||||
|
||||
import { test, describe, before, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { existsSync } from 'node:fs';
|
||||
|
||||
const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
|
||||
const ENABLED = process.env.SP_E2E === '1' && existsSync(CHROME);
|
||||
const f3 = (v) => Math.round(v * 1000) / 1000;
|
||||
|
||||
describe('活性源石 cell (2D board)', { skip: !ENABLED && 'set SP_E2E=1 (and have Chrome) to run' }, () => {
|
||||
let srv;
|
||||
let browser;
|
||||
let base;
|
||||
let configs = []; // [{ name, art, infection, infection2, road, palette }]
|
||||
|
||||
before(async () => {
|
||||
const { startServer } = await import('../../server/index.js');
|
||||
const puppeteer = (await import('puppeteer-core')).default;
|
||||
srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
|
||||
base = `http://127.0.0.1:${srv.port}`;
|
||||
browser = await puppeteer.launch({ executablePath: CHROME, headless: true, args: ['--no-sandbox', '--force-device-scale-factor=1'] });
|
||||
const page = await browser.newPage();
|
||||
await page.goto(`${base}/dev/game-mock.html?phase=PREP&render=fallback`, { waitUntil: 'domcontentloaded' });
|
||||
await page.addScriptTag({ url: '/vendor/pixi.min.js' }); // textures.js builds its atlas with PIXI
|
||||
configs = await page.evaluate(async () => {
|
||||
const m = await import('/js/render/textures.js');
|
||||
const st = await import('/js/render/style.js');
|
||||
const { assets } = await import('/js/assets.js');
|
||||
const { loadBoardArt } = await import('/js/render/boardArt.js');
|
||||
// the atlas a deployment with the board art renders, and the procedural one (no art) upstream sees
|
||||
await assets.load?.().catch?.(() => null);
|
||||
const art = await loadBoardArt(assets).catch(() => null);
|
||||
const read = (atlas, name) => {
|
||||
const uv = atlas.uv[name];
|
||||
if (!uv) return null;
|
||||
const W = atlas.canvas.width, H = atlas.canvas.height;
|
||||
const x0 = Math.round(uv[0] * W), y0 = Math.round(uv[1] * H);
|
||||
const w = Math.round((uv[2] - uv[0]) * W), h = Math.round((uv[3] - uv[1]) * H);
|
||||
const img = atlas.canvas.getContext('2d').getImageData(x0, y0, w, h);
|
||||
const px = [];
|
||||
for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {
|
||||
const k = (y * w + x) * 4;
|
||||
px.push({ x, y, r: img.data[k] / 255, g: img.data[k + 1] / 255, b: img.data[k + 2] / 255, a: img.data[k + 3] / 255 });
|
||||
}
|
||||
return { w, h, px, data: Array.from(img.data) };
|
||||
};
|
||||
const cells = (atlas, name) => ({ name, art: atlas.art, artCount: atlas.artCount,
|
||||
infection: read(atlas, 'infection'), infection2: read(atlas, 'infection2'), road: read(atlas, 'road') });
|
||||
// the vein's DISPLAYED colour (what a canvas stores): the palette converted out of the shaders' working space
|
||||
const hex = st.linearToHex(st.ORIGINIUM.vein);
|
||||
const rgb = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255);
|
||||
const palette = { veinHex: hex, gr: rgb[1] / rgb[0], br: rgb[2] / rgb[0] };
|
||||
const out = [cells(m.tileAtlas(art || null), art ? 'board art' : 'no art installed')];
|
||||
if (art) out.push(cells(m.tileAtlas(null), 'procedural'));
|
||||
return out.map((c) => ({ ...c, palette }));
|
||||
});
|
||||
await page.close();
|
||||
});
|
||||
after(async () => {
|
||||
await browser?.close();
|
||||
await srv?.close();
|
||||
});
|
||||
|
||||
/** The numbers of one cell: veins, edges, ring vs middle, and the difference to another cell. */
|
||||
function stats(c) {
|
||||
const veinish = (p) => p.a > 0.6 && p.r > 0.55 && p.g > 0.2 && p.g < 0.8 && p.b < 0.5;
|
||||
const mean = (arr, k) => arr.reduce((n, p) => n + p[k], 0) / Math.max(1, arr.length);
|
||||
const hot = c.px.filter(veinish);
|
||||
const ring = c.px.filter((p) => Math.min(p.x, p.y, c.w - 1 - p.x, c.h - 1 - p.y) < c.w * 0.08);
|
||||
const lum = (arr) => arr.reduce((n, p) => n + (0.2126 * p.r + 0.7152 * p.g + 0.0722 * p.b) * p.a, 0) / Math.max(1, arr.length);
|
||||
const band = (pred) => { const a = c.px.filter(pred); return f3(a.filter(veinish).length / Math.max(1, a.length)); };
|
||||
// the vein CORES are opaque, so their colour does not depend on the slab underneath (the art config draws one):
|
||||
// the brightest few percent are exactly those, whichever base the cell has
|
||||
const core = [...c.px].sort((p, q) => (q.r + q.g + q.b) - (p.r + p.g + p.b)).slice(0, Math.max(1, Math.round(c.px.length * 0.03)));
|
||||
return {
|
||||
veinPct: f3(hot.length / c.px.length), ringVeinPct: f3(ring.filter(veinish).length / ring.length),
|
||||
hotGR: f3(mean(hot, 'g') / mean(hot, 'r')), hotBR: f3(mean(hot, 'b') / mean(hot, 'r')), borderLum: f3(lum(ring)),
|
||||
coreGR: f3(mean(core, 'g') / mean(core, 'r')), coreBR: f3(mean(core, 'b') / mean(core, 'r')),
|
||||
// our own crust at the tile's borders (the base slab may be official art with edges of its own, so the veins — the
|
||||
// layer this change drew — are what shows whether the tile joins the next one)
|
||||
veinsL: band((p) => p.x < 3), veinsR: band((p) => p.x >= c.w - 3),
|
||||
veinsT: band((p) => p.y < 3), veinsB: band((p) => p.y >= c.h - 3),
|
||||
};
|
||||
}
|
||||
const diff = (a, b) => f3(a.data.reduce((n, v, i) => n + Math.abs(v - b.data[i]), 0) / a.data.length);
|
||||
|
||||
test('the cell wears the shared palette: the veins hit the colour the 3D shader writes', () => {
|
||||
for (const cfg of configs) {
|
||||
const s = stats(cfg.infection);
|
||||
const { gr, br, veinHex } = cfg.palette;
|
||||
assert.ok(s.veinPct >= 0.05, `${cfg.name}: the crust carries veins (${s.veinPct})`);
|
||||
assert.ok(Math.abs(s.coreGR - gr) <= 0.06, `${cfg.name}: the veins wear the palette's colour (g/r ${s.coreGR} vs ${f3(gr)} for ${veinHex})`);
|
||||
assert.ok(Math.abs(s.coreBR - br) <= 0.06, `${cfg.name}: …and its blue (b/r ${s.coreBR} vs ${f3(br)})`);
|
||||
// the whole cell is that material, not the bare slab the atlas holds for an ordinary tile
|
||||
assert.ok(diff(cfg.infection, cfg.road) > 8, `${cfg.name}: the overlay really draws over the slab (${diff(cfg.infection, cfg.road)})`);
|
||||
}
|
||||
});
|
||||
|
||||
test('no per-tile frame — the crust reaches the edges, and the cell joins itself', () => {
|
||||
for (const cfg of configs) {
|
||||
const s = stats(cfg.infection);
|
||||
// veins at the border like anywhere else: the old beveled brick had a frame and none at the very edge
|
||||
assert.ok(s.ringVeinPct >= s.veinPct * 0.6, `${cfg.name}: veins reach the tile's border (${s.ringVeinPct} at the ring vs ${s.veinPct})`);
|
||||
// a repeated cell must line up: the noise wraps, so opposite edges sample the same field — the veins run through
|
||||
// the edge on both sides instead of stopping at it. Compared exactly on the cell WE compose (floor + crust); with
|
||||
// the official slab under the crust the vein/not-vein classification of the composite pixels follows the slab's
|
||||
// own shading (its edges are the official art's, on every tile of the board), so there only the ring rule above
|
||||
// applies — an edge band is 3 of 240 columns, and one slab of the noise is not the cell's average.
|
||||
if (!cfg.art) {
|
||||
for (const [a, b, where] of [[s.veinsL, s.veinsR, 'left/right'], [s.veinsT, s.veinsB, 'top/bottom']]) {
|
||||
assert.ok(Math.abs(a - b) <= 0.06, `${cfg.name}: ${where} edge veins ${a} vs ${b}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('two variants, so a field of 活性源石 does not repeat one pattern', () => {
|
||||
for (const cfg of configs) {
|
||||
assert.ok(cfg.infection2, `${cfg.name}: infection2 is an atlas cell`);
|
||||
const a = stats(cfg.infection), b = stats(cfg.infection2);
|
||||
assert.ok(Math.abs(a.veinPct - b.veinPct) <= 0.02, `${cfg.name}: the same recipe (${a.veinPct} vs ${b.veinPct})`);
|
||||
assert.ok(diff(cfg.infection, cfg.infection2) > 6, `${cfg.name}: the second variant is a different pattern (${diff(cfg.infection, cfg.infection2)})`);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -664,3 +664,62 @@ describe('impact sounds (user playtest #4 item 6)', () => {
|
||||
} finally { globalThis.performance = perf; restore(); }
|
||||
});
|
||||
});
|
||||
|
||||
// =====================================================================================================================
|
||||
// 漏怪 sound (user request "接下来加漏怪的音效", then "应该是原版明日方舟关卡中的怪进蓝门的音效"). The sim emits
|
||||
// `['leak', id]` when an enemy reaches its goal (Battle.leak) — NOT a `die` — so until now an escape was completely
|
||||
// silent, for the player's own field and for a 联防 the helpers could not hold alike.
|
||||
//
|
||||
// The cue is the ORIGINAL Arknights stage alarm an enemy entering the exit plays in any normal stage: the manifest's
|
||||
// `sfx.battle.leak`, bank `battle.ON_ENEMY_REACHED_EXIT`, file `Battle/b_ui/b_ui_alarmenter`. (The autochess banks
|
||||
// have nothing named for an escape — all 13,948 SFX banks searched — but the stage itself does.) The official bank is
|
||||
// a one-shot: `maxSoundAllowed: 1` with `popOldest: true` on the `Battle_UI_Important` mixer.
|
||||
|
||||
describe('漏怪 sound', () => {
|
||||
async function rig() {
|
||||
const fw = fakeWindow();
|
||||
const urls = [];
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = async (u) => { urls.push(u); return { ok: true, arrayBuffer: async () => new ArrayBuffer(8) }; };
|
||||
const a = new AudioManager({ win: fw.win, getManifest: () => manifest });
|
||||
a.install();
|
||||
fw.fire('pointerdown');
|
||||
const settle = () => new Promise((r) => setTimeout(r, 10));
|
||||
return { a, fw, urls, settle, restore: () => { globalThis.fetch = origFetch; } };
|
||||
}
|
||||
|
||||
test('an escaped enemy plays the original stage exit alarm — and no death sound (a leak is not a `die`)', async () => {
|
||||
const { a, urls, settle, restore } = await rig();
|
||||
try {
|
||||
const url = manifest.audio.sfx.battle.leak;
|
||||
assert.ok(url, '前提:清单里有 sfx.battle.leak');
|
||||
assert.match(url, /b_ui_alarmenter\.mp3$/, '就是原版关卡里怪进蓝门那一声');
|
||||
a.handleBattleEvents([['leak', 7]]);
|
||||
await settle();
|
||||
assert.ok(asked(urls, url), `漏怪 plays ${url}`);
|
||||
assert.equal(askedCount(urls, manifest.audio.sfx.battle.enemyDie), 0, 'a leak is not a death — no death sound');
|
||||
} finally { restore(); }
|
||||
});
|
||||
|
||||
test('leaks of one disaster are ONE alarm (the cue is 1.44 s long), a later one rings again', async () => {
|
||||
const { a, fw, urls, settle, restore } = await rig();
|
||||
try {
|
||||
const url = manifest.audio.sfx.battle.leak;
|
||||
a.handleBattleEvents([['leak', 1]]);
|
||||
await settle();
|
||||
assert.equal(askedCount(urls, url), 1, 'the first escape rings');
|
||||
// the plays themselves, not the fetches: the buffer is cached after the first one
|
||||
const before = fw.made.started;
|
||||
// six more at once — a wiped board, or a 联防 the helpers could not hold
|
||||
a.handleBattleEvents([['leak', 2], ['leak', 3], ['leak', 4], ['leak', 5], ['leak', 6], ['leak', 7]]);
|
||||
await settle();
|
||||
assert.equal(fw.made.started - before, 0, 'one disaster never stacks alarms (the official bank allows 1)');
|
||||
// a genuine later leak is a new disaster and rings again, once the cue (1.44 s) has finished
|
||||
await new Promise((r) => setTimeout(r, 1600));
|
||||
a.handleBattleEvents([['leak', 8]]);
|
||||
await settle();
|
||||
assert.equal(fw.made.started - before, 1, 'a later leak rings again');
|
||||
assert.ok(a.limiter.active <= a.limiter.maxVoices);
|
||||
} finally { restore(); }
|
||||
});
|
||||
});
|
||||
+109
-1
@@ -12,7 +12,7 @@ import {
|
||||
bondMembers, memberHeadCount, bannedPerBond, priceTone, mergeProgress, shopBlockReason, deploySets, indexPieces, placementContext, canPlace,
|
||||
boardTargets, dropIntent, normalizeDraft, normalizeSp, groupEnemies, factionTypes, snapHud, bossFrac, attackInterval, fmtNum,
|
||||
rangeGridBox, shortcutFor, sanitizeSettings, DEFAULT_SETTINGS, normalizeResult, cycleField, fieldLabel, homeFieldId,
|
||||
activeBubbles, sortedPlayers, tileKey, prepCapsuleLabel, prepCamera, dropFailureReason,
|
||||
activeBubbles, sortedPlayers, tileKey, prepCapsuleLabel, prepCamera, dropFailureReason, terrainInfo,
|
||||
} from '../../public/js/ui/gameLogic.js';
|
||||
import { pairPlayers } from '../../server/match/finalAssault.js';
|
||||
import { PHASE, GEO } from '../../shared/constants.js';
|
||||
@@ -585,3 +585,111 @@ describe('equipment dropped on a tile goes to the unit on it', () => {
|
||||
assert.deepEqual(dropIntent(ctx, art.uid, { area: 'board', row: 11, col: 3 }), { t: 'g.art', fields: { itemUid: art.uid, row: 11, col: 3 } });
|
||||
});
|
||||
});
|
||||
|
||||
// GitHub issue #184 「建议加入对于特殊地形的单击信息提示」: tapping a special terrain tile explains it — in the stage's own
|
||||
// numbers (the same bb / special parameters the sim runs on), while an ordinary tile says nothing at all.
|
||||
describe('special terrain tip', () => {
|
||||
/** The first (row, col) of `glyph` in a real stage of data/stages.json. */
|
||||
const at = (stageId, glyph) => {
|
||||
const st = stages[stageId];
|
||||
for (let row = 0; row < st.rows.length; row++) {
|
||||
const col = st.rows[row].indexOf(glyph);
|
||||
if (col >= 0) return { st, row, col };
|
||||
}
|
||||
throw new Error(`no ${glyph} in ${stageId}`);
|
||||
};
|
||||
|
||||
test('each terrain of the mode, with the numbers of the stage it stands on', () => {
|
||||
// 活性源石 (战场#04): the official tile parameters (damage 70/s, +20% ATK, +20 ASPD, 300 s)
|
||||
const inf = at('act1autochess_m04', 'i');
|
||||
const tip = terrainInfo(inf.st, inf.row, inf.col);
|
||||
assert.equal(tip.key, 'infection');
|
||||
assert.equal(tip.name, '活性源石');
|
||||
assert.equal(tip.tag, '特殊地形');
|
||||
assert.deepEqual([tip.row, tip.col], [inf.row, inf.col]);
|
||||
assert.match(tip.lines[0], /每秒受到 70 点真实伤害/, 'damage from the stage\'s own bb');
|
||||
assert.match(tip.lines[1], /攻击力 \+20%、攻击速度 \+20/);
|
||||
assert.match(tip.lines[2], /300 秒/);
|
||||
assert.deepEqual(tip.facts, ['可部署', '地面单位可通过']);
|
||||
// 沼泽 (战场#06): one 陷入沼泽 layer a second, −5% ASPD (−5% move for enemies), 2 layers at 重量 ≥ 3, 10 at most
|
||||
const mire = terrainInfo(...(() => { const g = at('act2autochess_m02', 'm'); return [g.st, g.row, g.col]; })());
|
||||
assert.equal(mire.name, '沼泽');
|
||||
assert.ok(mire.lines.some((l) => /攻击速度 −5%/.test(l) && /移动速度 −5%/.test(l)));
|
||||
assert.ok(mire.lines.some((l) => /重量 ≥ 3 的敌人一次获得 2 层/.test(l)));
|
||||
assert.ok(mire.lines.some((l) => /最多 10 层/.test(l)));
|
||||
// 排气格栅 (战场#07): the 隐匿-like rule and its limit — the enemy it blocks still hits it (review on #185)
|
||||
const smog = at('act2autochess_m03', 'g');
|
||||
const smogTip = terrainInfo(smog.st, smog.row, smog.col);
|
||||
assert.equal(smogTip.name, '排气格栅');
|
||||
assert.match(smogTip.lines[0], /不会被敌方的远程攻击选中(效果相当于隐匿)/);
|
||||
assert.match(smogTip.lines[1], /挡住敌人的干员仍会被它攻击到/);
|
||||
// 深水区 (战场#05): drowning numbers, and 拒绝部署 although the level's own buildableType is ALL (grid.js)
|
||||
const sea = at('act1autochess_m05', 'd');
|
||||
const seaTip = terrainInfo(sea.st, sea.row, sea.col);
|
||||
assert.equal(seaTip.name, '深水区');
|
||||
assert.ok(seaTip.lines.some((l) => /每秒受到 40 点伤害/.test(l)));
|
||||
assert.ok(seaTip.lines.some((l) => /攻击速度 −60%/.test(l) && /移动速度 ×0.6/.test(l)));
|
||||
// …and what that damage IS: sourceless, and not 环境伤害 (devices.js tickDeepsea; review on #185)
|
||||
assert.ok(seaTip.lines.some((l) => /无来源伤害/.test(l) && /不归类为环境伤害/.test(l)));
|
||||
assert.ok(seaTip.lines.some((l) => /拒绝部署/.test(l)));
|
||||
assert.ok(seaTip.facts.includes('不可部署'));
|
||||
// the gates and teleports every stage carries (tile_start / tile_end / tile_telin / tile_telout)
|
||||
for (const [glyph, name] of [['S', '红门'], ['E', '蓝门'], ['I', '传送入口'], ['O', '传送出口']]) {
|
||||
const g = at('act1autochess_m04', glyph);
|
||||
const t = terrainInfo(g.st, g.row, g.col);
|
||||
assert.equal(t.name, name, glyph);
|
||||
assert.ok(t.lines.length >= 1);
|
||||
}
|
||||
});
|
||||
|
||||
test('an ordinary tile says nothing (the press keeps its other meanings); nonsense input is safe', () => {
|
||||
const st = stages['act1autochess_m04'];
|
||||
const inf = at('act1autochess_m04', 'i');
|
||||
assert.ok(terrainInfo(inf.st, inf.row, inf.col), 'the stage does carry one tile that answers');
|
||||
// every non-special glyph the stage uses: the bench, the blocked rows, road / floor, the fence, the separator…
|
||||
for (const glyph of ['a', 'A', '#', 'X', 'r', 'f', 'b', 'h', 'p']) {
|
||||
let found = null;
|
||||
for (let row = 0; row < st.rows.length && !found; row++) {
|
||||
const col = st.rows[row].indexOf(glyph);
|
||||
if (col >= 0) found = { row, col };
|
||||
}
|
||||
if (!found) continue; // a glyph this stage does not use says nothing to test
|
||||
assert.equal(terrainInfo(st, found.row, found.col), null, glyph);
|
||||
}
|
||||
assert.equal(terrainInfo(inf.st, -1, inf.col), null);
|
||||
assert.equal(terrainInfo(inf.st, inf.st.rows.length, 0), null);
|
||||
assert.equal(terrainInfo(inf.st, inf.row, 999), null);
|
||||
assert.equal(terrainInfo(inf.st, 1.5, 2), null);
|
||||
assert.equal(terrainInfo(inf.st, '3', 2), null);
|
||||
assert.equal(terrainInfo(null, 0, 0), null);
|
||||
assert.equal(terrainInfo(undefined, 0, 0), null);
|
||||
assert.equal(terrainInfo({}, 0, 0), null);
|
||||
// a stage without the tile legend (or without that glyph) explains nothing rather than guessing
|
||||
assert.equal(terrainInfo({ rows: ['i'] }, 0, 0), null);
|
||||
assert.equal(terrainInfo({ rows: ['i'], tiles: {} }, 0, 0), null);
|
||||
assert.equal(terrainInfo({ rows: ['i'], tiles: { i: null } }, 0, 0), null);
|
||||
// …and a legend entry the mode never gave a tip (its own ordinary floor) is not a tip either
|
||||
assert.equal(terrainInfo({ rows: ['z'], tiles: { z: { tileKey: 'tile_floor', special: null } } }, 0, 0), null);
|
||||
});
|
||||
|
||||
test('the facts come from the tile\'s own legend entry; missing terrain parameters never crash', () => {
|
||||
const stage = {
|
||||
rows: ['ih'],
|
||||
tiles: {
|
||||
i: { tileKey: 'tile_infection', height: 'LOW', buildable: 'RANGED', groundPassable: false, special: 'infection' },
|
||||
h: { tileKey: 'tile_smog', height: 'HIGH', buildable: 'NONE', groundPassable: false, special: 'smog' },
|
||||
},
|
||||
special: {},
|
||||
};
|
||||
assert.deepEqual(terrainInfo(stage, 0, 0).facts, ['仅远程位可部署', '只有空中单位能通过']);
|
||||
assert.deepEqual(terrainInfo(stage, 0, 1).facts, ['不可部署', '高台', '只有空中单位能通过']);
|
||||
// a stage whose `special` is missing: the mechanism is still explained, just without the stage's numbers
|
||||
const bare = { rows: ['ih'], tiles: stage.tiles };
|
||||
assert.deepEqual(terrainInfo(bare, 0, 0).lines, ['在其上的我方单位与经过的敌方单位持续受到伤害']);
|
||||
assert.deepEqual(terrainInfo(bare, 0, 1).lines, ['站在排气格栅上的干员不会被敌方的远程攻击选中(效果相当于隐匿)', '但挡住敌人的干员仍会被它攻击到']);
|
||||
// 沼泽 without its parameters falls back to the official template numbers
|
||||
const mire = { rows: ['m'], tiles: { m: { tileKey: 'tile_mire', height: 'LOW', buildable: 'ALL', groundPassable: true, special: 'mire' } } };
|
||||
assert.match(terrainInfo(mire, 0, 0).lines[0], /每 1 秒获得 1 层/);
|
||||
assert.match(terrainInfo(mire, 0, 0).lines[2], /最多 10 层/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
// GitHub issue #184 「建议加入对于特殊地形的单击信息提示」: the tap's target (`screens/game.js` tileClick hands over
|
||||
// `{ kind: 'terrain', terrain }`, resolved from the stage the board on screen is built from) turns into the panel's
|
||||
// terrain card — and a target that carries no tip resolves to nothing rather than opening an empty card.
|
||||
// The words / numbers themselves are covered in test/ui/gameLogic.test.js (terrainInfo) and the tap itself reaches them
|
||||
// in the browser (test/ui/terrain-tip.e2e.test.js); this is the link between the two.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const { resolveDetail } = await import('../../public/js/ui/detailPanel.js');
|
||||
const { terrainInfo } = await import('../../public/js/ui/gameLogic.js');
|
||||
|
||||
/** The first (row, col) of `glyph` in a stage of data/stages.json. */
|
||||
function at(stage, glyph) {
|
||||
for (let row = 0; row < stage.rows.length; row++) {
|
||||
const col = stage.rows[row].indexOf(glyph);
|
||||
if (col >= 0) return { row, col };
|
||||
}
|
||||
throw new Error(`no ${glyph} in ${stage.id}`);
|
||||
}
|
||||
|
||||
test('a terrain target resolves into the panel card; a tip-less target resolves to nothing', () => {
|
||||
const stages = JSON.parse(readFileSync(path.join(ROOT, 'data', 'stages.json'), 'utf8'));
|
||||
const st = stages['act1autochess_m04'];
|
||||
const p = at(st, 'i');
|
||||
const info = terrainInfo(st, p.row, p.col);
|
||||
assert.equal(info.name, '活性源石');
|
||||
assert.deepEqual(resolveDetail({ kind: 'terrain', terrain: info }, new Map()), { type: 'terrain', terrain: info });
|
||||
// the screen never hands a tip-less tile over (terrainInfo returns null there), but a stray target must not throw or
|
||||
// open an empty card
|
||||
assert.equal(resolveDetail({ kind: 'terrain' }, new Map()), null);
|
||||
assert.equal(resolveDetail({ kind: 'terrain', terrain: null }, new Map()), null);
|
||||
assert.equal(resolveDetail({ kind: 'terrain', terrain: '活性源石' }, new Map()), null);
|
||||
assert.deepEqual(resolveDetail({ kind: 'terrain', terrain: {} }, new Map()), { type: 'terrain', terrain: {} },
|
||||
'a well-formed but empty tip still resolves (the screen filtered it before)');
|
||||
assert.equal(resolveDetail(null, new Map()), null);
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
// Browser checks of the special-terrain tip (GitHub issue #184 「建议加入对于特殊地形的单击信息提示」) on the in-match mock
|
||||
// harness (public/dev/game-mock.html):
|
||||
// SP_E2E=1 CHROME_PATH=… node --test test/ui/terrain-tip.e2e.test.js
|
||||
//
|
||||
// A tap on the GROUND — nothing stands there — makes the tile explain itself, on both field implementations: the engine
|
||||
// canvas (render/app.js `tileClick`, reached by clicking the tile's own screen position) and the DOM fallback
|
||||
// (ui/fallbackField.js, the tile div). Asserted: the mock's own stage (act2autochess_m01) carries gates, a tap on 蓝门 /
|
||||
// 红门 opens the card with its mechanism line, a tap on an ordinary floor tile opens nothing (and closes the card that was
|
||||
// open), and no scenario logs a console error. The words and the numbers are unit-tested in test/ui/gameLogic.test.js
|
||||
// (terrainInfo, against every terrain of the real stages) — this file is about the tap reaching them.
|
||||
|
||||
import { test, describe, before, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { existsSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
|
||||
const ENABLED = process.env.SP_E2E === '1' && existsSync(CHROME);
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
/** Tiles of the mock's stage (act2autochess_m01) the tip must answer for, and one ordinary tile it must not. */
|
||||
const GATE = { row: 9, col: 10, name: '红门' };
|
||||
const GOAL = { row: 9, col: 2, name: '蓝门' };
|
||||
const FLOOR = { row: 10, col: 5, name: null };
|
||||
|
||||
describe('special terrain tip in the browser', { skip: !ENABLED && 'set SP_E2E=1 (and have Chrome) to run' }, () => {
|
||||
let srv;
|
||||
let browser;
|
||||
let base;
|
||||
|
||||
before(async () => {
|
||||
const { startServer } = await import('../../server/index.js');
|
||||
const puppeteer = (await import('puppeteer-core')).default;
|
||||
srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
|
||||
base = `http://127.0.0.1:${srv.port}`;
|
||||
browser = await puppeteer.launch({ executablePath: CHROME, headless: true, args: ['--no-sandbox', '--force-device-scale-factor=1'] });
|
||||
});
|
||||
after(async () => {
|
||||
await browser?.close();
|
||||
await srv?.close();
|
||||
});
|
||||
|
||||
async function open(url, { w = 1600, h = 900, waitUntil = 'networkidle0' } = {}) {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: w, height: h });
|
||||
const problems = [];
|
||||
page.on('console', (m) => { if (m.type() === 'error') problems.push(`console: ${m.text()}`); });
|
||||
page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`));
|
||||
page.on('requestfailed', (r) => { if (r.failure()?.errorText !== 'net::ERR_ABORTED') problems.push(`requestfailed: ${r.url()} ${r.failure()?.errorText}`); });
|
||||
// the engine keeps fetching art (and SwiftShader is slow to build the board), so it waits on __SP_VIEW__ instead
|
||||
await page.goto(`${base}${url}`, { waitUntil, timeout: 60000 });
|
||||
return { page, problems };
|
||||
}
|
||||
|
||||
/** The open detail card's text (null while none is open). */
|
||||
const card = (page) => page.evaluate(() => {
|
||||
const el = document.querySelector('.dpanel');
|
||||
return el ? el.textContent.replace(/\s+/g, ' ').trim() : null;
|
||||
});
|
||||
|
||||
test('the fallback (DOM) board: a tap on 蓝门 / 红门 opens the card, an ordinary tile opens nothing', async () => {
|
||||
const { page, problems } = await open('/dev/game-mock.html?phase=PREP&render=fallback');
|
||||
await page.waitForSelector('.ff-tile', { timeout: 15000 });
|
||||
await sleep(400);
|
||||
const tap = (row, col) => page.evaluate(([r, c]) => {
|
||||
const el = document.querySelector(`.ff-tile[data-row="${r}"][data-col="${c}"]`);
|
||||
if (!el) return false;
|
||||
const b = el.getBoundingClientRect();
|
||||
el.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, cancelable: true, button: 0, pointerId: 1, pointerType: 'mouse', clientX: b.left + b.width / 2, clientY: b.top + b.height / 2 }));
|
||||
return true;
|
||||
}, [row, col]);
|
||||
|
||||
assert.ok(await tap(GOAL.row, GOAL.col), 'the mock board draws the 蓝门 tile');
|
||||
await page.waitForSelector('.dpanel', { timeout: 5000 });
|
||||
let text = await card(page);
|
||||
assert.match(text, /蓝门/, text);
|
||||
assert.match(text, /目标生命值/, text);
|
||||
assert.match(text, /保护目标/, text);
|
||||
|
||||
// the 红门 (the enemies' own entrance) says its own thing
|
||||
assert.ok(await tap(GATE.row, GATE.col));
|
||||
await sleep(200);
|
||||
text = await card(page);
|
||||
assert.match(text, /红门/, text);
|
||||
assert.match(text, /从这里出场/, text);
|
||||
|
||||
// an ordinary floor tile: nothing to explain, and the card that was open closes with the press
|
||||
assert.ok(await tap(FLOOR.row, FLOOR.col));
|
||||
await sleep(200);
|
||||
assert.equal(await card(page), null, 'an ordinary tile opens nothing');
|
||||
assert.deepEqual(problems, []);
|
||||
await page.close();
|
||||
});
|
||||
|
||||
test('the engine canvas: the same tap on a tile\'s own screen position opens the card', async () => {
|
||||
const { page, problems } = await open('/dev/game-mock.html?phase=PREP&render=engine', { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => !!globalThis.__SP_VIEW__?.tileScreen, { timeout: 30000 });
|
||||
await sleep(1200); // models / camera
|
||||
const at = (row, col) => page.evaluate(([r, c]) => {
|
||||
const t = globalThis.__SP_VIEW__.tileScreen(r, c);
|
||||
return t && { x: t.x, y: t.y };
|
||||
}, [row, col]);
|
||||
const pt = await at(GOAL.row, GOAL.col);
|
||||
assert.ok(pt, 'the 蓝门 tile has a screen position');
|
||||
await page.mouse.click(pt.x, pt.y);
|
||||
await page.waitForSelector('.dpanel', { timeout: 5000 });
|
||||
let text = await card(page);
|
||||
assert.match(text, /蓝门/, text);
|
||||
assert.match(text, /目标生命值/, text);
|
||||
|
||||
const floor = await at(FLOOR.row, FLOOR.col);
|
||||
await page.mouse.click(floor.x, floor.y);
|
||||
await sleep(250);
|
||||
assert.equal(await card(page), null, 'the floor tile closes it again');
|
||||
assert.deepEqual(problems, []);
|
||||
await page.close();
|
||||
});
|
||||
|
||||
test('Final Assault prep: the tap reports the BOARD tile, so the boss field explains ITS tile (review on #185)', async () => {
|
||||
const { page, problems } = await open('/dev/game-mock.html?phase=PREP&variant=boss&render=engine', { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => !!globalThis.__SP_VIEW__?.tileScreen, { timeout: 30000 });
|
||||
await page.waitForFunction(() => globalThis.__SP_VIEW__.raw.prepField?.().kind === 'bossPrep', { timeout: 15000 });
|
||||
await sleep(1200); // camera / models (a deployment with the local board art also builds the 3D board here)
|
||||
// A board tile of the player's half + the stage tile it draws (stage 2–5 shown as board 9–12): the card must be about
|
||||
// the stage tile. Reporting the drawn tile instead (the old groundTile) made the screen convert a second time, which
|
||||
// on this board explains a DIFFERENT tile — or none at all.
|
||||
const target = await page.evaluate(async () => {
|
||||
const { data } = await import('/js/data.js');
|
||||
const gl = await import('/js/ui/gameLogic.js');
|
||||
const S = globalThis.__MOCK__.S();
|
||||
const stage = data.lookup('stages', S.pub.stageId);
|
||||
for (let row = 9; row <= 12; row++) {
|
||||
for (let col = 0; col <= 10; col++) {
|
||||
const [sr, sc] = gl.fieldTile('bossL', row, col);
|
||||
const info = gl.terrainInfo(stage, sr, sc);
|
||||
if (!info) continue;
|
||||
const t = globalThis.__SP_VIEW__.tileScreen(row, col);
|
||||
if (t) return { row, col, sr, sc, name: info.name, x: t.x, y: t.y };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
});
|
||||
assert.ok(target, 'the boss field has a special tile on the player half');
|
||||
// The boss-prep camera is still settling when the board appears (and building the 3D board shifts a machine's timing),
|
||||
// so wait for the tile's screen position to stop moving, and re-read it before each try.
|
||||
const at = () => page.evaluate(([r, c]) => {
|
||||
const t = globalThis.__SP_VIEW__.tileScreen(r, c);
|
||||
return t && { x: t.x, y: t.y };
|
||||
}, [target.row, target.col]);
|
||||
let settled = null;
|
||||
for (let i = 0; i < 40; i++) {
|
||||
const p = await at();
|
||||
if (p && settled && Math.abs(p.x - settled.x) < 1 && Math.abs(p.y - settled.y) < 1) break;
|
||||
settled = p;
|
||||
await sleep(150);
|
||||
}
|
||||
assert.ok(settled, 'the boss-field tile still has a screen position');
|
||||
let text = null;
|
||||
for (let i = 0; i < 3 && text === null; i++) {
|
||||
const p = (await at()) || settled;
|
||||
await page.mouse.click(p.x, p.y);
|
||||
try { await page.waitForSelector('.dpanel', { timeout: 3000 }); } catch { continue; } // a tap that missed: retry
|
||||
text = await card(page);
|
||||
}
|
||||
assert.ok(text !== null, 'the tap opened the terrain card');
|
||||
assert.match(text, new RegExp(target.name), `board (${target.row},${target.col}) → stage (${target.sr},${target.sc}) is ${target.name}, card: ${text}`);
|
||||
assert.deepEqual(problems, []);
|
||||
await page.close();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user