- tools/local-extract/extract.py TOKEN_SPINES (`--only spine/token`): each token's battle prefab in the battle token packs (pkgrps/btl_pfb_tokens_*.ab; the Windows build has all 39, the iOS build lacks btl_pfb_tokens_0), the skeleton of its Front (or only) Spine renderer, written to public/assets/local/spine/token/<id>/ like the enemy models (sized pma atlas, premultiplied pages; the newer RGBA pages keep their own alpha). The enemy writer is shared (write_spine; the two enemy models re-extract byte for byte). The skinpack job of 乌尔比安's block is gone: it extracted nothing, and that token draws no model in the game (nor do 圣聆初雪's, 酒神's, 贝洛内's and 予愿安洁莉娜's hidden tokens: no Spine renderer, no avatar in either install). - tools/assets/local-token-spines.json (committed, `fetch-assets --local-spines`) → data/assets.json tokens[id].spineLocal for 35 自选 summons, 凯瑟琳's 爬行号·防护单元 and 凛御银灰's three 风雪之眼; the manifest is the same with or without the extraction (JSON diff: only the 39 spineLocal entries and the hash). spine.mjs LOCAL_SPINE_ROLES maps clips the resolver cannot read (电弧's 戴乌 C_Skill1_*, 酒神's 本能的召唤 Loop / End, 白铁's platforms End, 战术锚点 on its Default pose) [ASSUMED where named]. - assets.js spineEntry: a token resolves its spineLocal like an enemy (no web model behind it: the diamond stays when it is not listed or fails to load). Scale as the web tokens (UNIT.modelScale). - setup reports an extraction without the summon models; docs: ASSETS.md "Token models from the local client", DEPLOY.md §6, DESIGN §13 (design/client.md), README. - Tests: test/local-token-models.test.js (new), test/local-extract.test.js (job table, prefab walk, alpha merge), test/assets.test.js (the local token models load like the client does), test/package.test.js (the full zip ships them), test/static-local-art.test.js. Golden unchanged.
107 lines
5.9 KiB
JavaScript
107 lines
5.9 KiB
JavaScript
// The optional local-client art manifest (data/local-assets.json, DESIGN §13): an install without it must get an
|
||
// empty manifest (200) instead of a 404, and an install with it must get the real file. The docs and the setup /
|
||
// doctor messages say what falls back without it (GitHub issue #42, DESIGN §22.5).
|
||
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 http from 'node:http';
|
||
import { fileURLToPath } from 'node:url';
|
||
import { createStaticHandler } from '../server/index.js';
|
||
|
||
const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||
|
||
function serve(dataDir) {
|
||
const handler = createStaticHandler({ publicDir: path.join(ROOT, 'public'), dataDir, sharedDir: path.join(ROOT, 'shared') });
|
||
const srv = http.createServer((req, res) => {
|
||
const [p, q] = req.url.split('?');
|
||
handler(req, res, p, q || '');
|
||
});
|
||
return new Promise((resolve) => srv.listen(0, '127.0.0.1', () => resolve(srv)));
|
||
}
|
||
|
||
function get(srv, url, method = 'GET') {
|
||
return new Promise((resolve, reject) => {
|
||
const req = http.request({ host: '127.0.0.1', port: srv.address().port, path: url, method }, (res) => {
|
||
let body = '';
|
||
res.setEncoding('utf8');
|
||
res.on('data', (d) => { body += d; });
|
||
res.on('end', () => resolve({ status: res.statusCode, type: res.headers['content-type'], body }));
|
||
});
|
||
req.on('error', reject);
|
||
req.end();
|
||
});
|
||
}
|
||
|
||
test('missing data/local-assets.json → 200 empty manifest (no 404 on installs without local art)', async () => {
|
||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sp-noart-'));
|
||
fs.writeFileSync(path.join(dir, 'config.json'), '{}');
|
||
const srv = await serve(dir);
|
||
try {
|
||
const r = await get(srv, '/data/local-assets.json');
|
||
assert.equal(r.status, 200);
|
||
assert.match(r.type, /application\/json/);
|
||
const doc = JSON.parse(r.body);
|
||
assert.deepEqual(doc.groups, {});
|
||
const h = await get(srv, '/data/local-assets.json', 'HEAD');
|
||
assert.equal(h.status, 200);
|
||
assert.equal(h.body, '');
|
||
// other missing data files still 404
|
||
assert.equal((await get(srv, '/data/nope.json')).status, 404);
|
||
assert.equal((await get(srv, '/data/sub/local-assets.json')).status, 404);
|
||
} finally {
|
||
srv.close();
|
||
fs.rmSync(dir, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
test('present data/local-assets.json is served as-is', async () => {
|
||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sp-art-'));
|
||
const doc = { version: 1, source: 'local-client', count: 1, groups: { module: { 'mar-x': { path: '/assets/local/module/mar-x.png' } } } };
|
||
fs.writeFileSync(path.join(dir, 'local-assets.json'), JSON.stringify(doc));
|
||
const srv = await serve(dir);
|
||
try {
|
||
const r = await get(srv, '/data/local-assets.json');
|
||
assert.equal(r.status, 200);
|
||
assert.deepEqual(JSON.parse(r.body), doc);
|
||
} finally {
|
||
srv.close();
|
||
fs.rmSync(dir, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
test('docs and messages say what falls back without the local art and how a server without the client gets it (GitHub issue #42)', async () => {
|
||
const read = (p) => fs.readFileSync(path.join(ROOT, p), 'utf8');
|
||
const { LOCAL_ART_FALLBACK, LOCAL_ART_COPY_HINT } = await import('../tools/setup.mjs');
|
||
for (const f of ['README.md', 'docs/DEPLOY.md', 'tools/setup.mjs', 'tools/doctor.mjs']) {
|
||
assert.ok(!/不影响游戏|其他功能不受影响|游戏不受影响/.test(read(f)), `${f}: never "the local art does not matter"`);
|
||
}
|
||
for (const re of [/3D 棋盘/, /界面图标/, /源石虫/]) assert.match(LOCAL_ART_FALLBACK, re);
|
||
assert.ok(!/表情|玩法说明/.test(LOCAL_ART_FALLBACK), 'the emotes and the 玩法说明 pages are downloaded, not local-only');
|
||
assert.match(LOCAL_ART_COPY_HINT, /同一版本的整合包/);
|
||
// setup's row is printed on every start (scripts/launch.mjs): it names the fallbacks and points to DEPLOY §6; doctor adds the hint
|
||
const noClientRow = read('tools/setup.mjs').split('\n').find((l) => l.includes("'未检测到本机明日方舟客户端'"));
|
||
assert.ok(noClientRow && noClientRow.includes('LOCAL_ART_FALLBACK') && noClientRow.includes('DEPLOY.md 第 6 节') && !noClientRow.includes('LOCAL_ART_COPY_HINT'), noClientRow);
|
||
assert.match(read('tools/doctor.mjs'), /未提取:\$\{LOCAL_ART_FALLBACK\}(\$\{LOCAL_ART_COPY_HINT\})/);
|
||
const deploy = read('docs/DEPLOY.md');
|
||
const s6 = deploy.slice(deploy.indexOf('## 6. 本地客户端素材'));
|
||
assert.ok(deploy.includes('## 6. 本地客户端素材') && s6.length > 200, 'DEPLOY §6');
|
||
for (const re of [/同一版本/, /public\/assets\/local\//, /data\/local-assets\.json/, /3D 棋盘/, /源石虫/, /表情/, /玩法说明/]) assert.match(s6, re);
|
||
// 0.2.0: the summon models of the local client (extract.py TOKEN_SPINES) — named in the fallbacks, DEPLOY §6 and README,
|
||
// and an extraction made before them is reported by setup as lacking them (re-extract with --local)
|
||
assert.match(LOCAL_ART_FALLBACK, /召唤物/);
|
||
assert.match(s6, /召唤物/);
|
||
assert.match(s6, /--only spine\/token/);
|
||
const { localGaps } = await import('../tools/setup.mjs');
|
||
assert.equal(localGaps({ enemySpines: true, tokenSpines: true }), '');
|
||
assert.equal(localGaps({ enemySpines: true, tokenSpines: false }), ',缺少新版的自选召唤物模型');
|
||
assert.equal(localGaps({ enemySpines: false, tokenSpines: false }), ',缺少新版的灼热/炽焰源石虫模型和自选召唤物模型');
|
||
assert.match(read('tools/setup.mjs'), /\$\{localGaps\(local\)\}(重新提取:--local)/);
|
||
const readme = read('README.md');
|
||
assert.match(readme, /召唤物/);
|
||
assert.match(readme, /表情和「玩法说明」的教程图随上面的素材一起从公开镜像下载/);
|
||
assert.match(readme, /\*\*同一版本\*\*的整合包/);
|
||
assert.ok(!/需本地提取/.test(read('docs/PLAYING.md')), 'PLAYING: the 玩法说明 pages come with the download');
|
||
});
|