feedback5 WX: the 39 summon models no web dump carries come from the local client (extract.py TOKEN_SPINES) and are drawn instead of the avatar diamond when data/local-assets.json lists them
- 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.
This commit is contained in:
1 parent
83d76e507d
commit
575333137c
17 files changed
+2738
-111
No files matched your search
+3
-3
@@ -2,7 +2,7 @@
|
||||
// data/assets.json. The pure helpers (animation-role resolver, atlas
|
||||
// normalizer, PNG/WOFF2/audio helpers, plan id sets) are always tested; the
|
||||
// on-disk checks run only when public/assets exists (it is git-ignored and
|
||||
// produced by `npm run assets`); the optional local-client enemy models
|
||||
// produced by `npm run assets`); the optional local-client enemy and token models
|
||||
// (spineLocal, tools/local-extract) only when data/local-assets.json lists them.
|
||||
|
||||
import { test, describe } from 'node:test';
|
||||
@@ -661,10 +661,10 @@ describe('generated manifest data/assets.json', () => {
|
||||
for (const e of Object.values(manifest.enemies)) if (e.spine) models.set(e.spine.skel, e.spine);
|
||||
for (const t of Object.values(manifest.tokens)) if (t.spine) models.set(t.spine.skel, t.spine);
|
||||
assert.ok(models.size > 400);
|
||||
// the official enemy models of the local client, as the client resolves them (DESIGN §13: only when listed)
|
||||
// the official enemy and token models of the local client, as the client resolves them (DESIGN §13: only when listed)
|
||||
const localPath = join(ROOT, 'data', 'local-assets.json');
|
||||
const local = existsSync(localPath) ? JSON.parse(readFileSync(localPath, 'utf8')) : null;
|
||||
for (const id of Object.keys(manifest.enemies)) {
|
||||
for (const id of [...Object.keys(manifest.enemies), ...Object.keys(manifest.tokens)]) {
|
||||
const s = local ? spineEntry(manifest, id, { local }) : null;
|
||||
if (s?.local && [s.skel, s.atlas, ...s.textures].every((u) => existsSync(join(PUBLIC, u)))) models.set(s.skel, s);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
// tools/local-extract/extract.py (DESIGN §13 / §15): the job table, the manifest merge of --only, and the helpers that
|
||||
// summarise Materials (map/<theme>/materials.json) and GameObjects (mesh/<bundle>/prefab.json) for the official 3D
|
||||
// board, and enemy_scales.py's table of the official enemy model sizes (build-data MODEL_SCALES). The Python helpers
|
||||
// run without UnityPy (duck-typed fakes); the real-output checks skip when nothing was extracted on this machine.
|
||||
// board, the token Spine models (TOKEN_SPINES: the Front renderer of each battle token prefab, the merged pages), and
|
||||
// enemy_scales.py's table of the official enemy model sizes (build-data MODEL_SCALES). The Python helpers run without
|
||||
// UnityPy (duck-typed fakes; merge_alpha needs Pillow); the real-output checks skip when nothing was extracted on this
|
||||
// machine. The token models' manifest side: test/local-token-models.test.js.
|
||||
|
||||
import { test, describe } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
@@ -39,6 +41,100 @@ describe('extract.py helpers (no UnityPy needed)', { skip: !PY && 'no python3' }
|
||||
assert.equal(new Set(jobs.map((j) => j.sub)).size, jobs.length, 'one output dir per job');
|
||||
});
|
||||
|
||||
test('job table: the token Spine models (TOKEN_SPINES) come from the battle token packs into spine/token/<id>; no plain job writes under spine/', () => {
|
||||
const r = spawnSync(PY, [path.join(TOOL, 'extract.py'), '--print-jobs'], { encoding: 'utf8', env: ENV });
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
const { jobs, tokenSpines, enemySpines } = JSON.parse(r.stdout);
|
||||
assert.equal(tokenSpines.bundles, 'pkgrps/btl_pfb_tokens_*.ab');
|
||||
assert.equal(tokenSpines.sub, 'spine/token');
|
||||
assert.equal(enemySpines.sub, 'spine/enemy');
|
||||
// every listed id is a token of the game data without a web model (data/assets.json tokens[id].spine), sorted, unique
|
||||
const ids = tokenSpines.ids;
|
||||
assert.equal(ids.length, 39);
|
||||
assert.deepEqual([...ids].sort(), ids);
|
||||
assert.equal(new Set(ids).size, ids.length);
|
||||
const tokens = JSON.parse(readFileSync(path.join(ROOT, 'data/tokens.json'), 'utf8'));
|
||||
const backups = JSON.parse(readFileSync(path.join(ROOT, 'data/backups.json'), 'utf8'));
|
||||
const web = JSON.parse(readFileSync(path.join(ROOT, 'data/assets.json'), 'utf8')).tokens;
|
||||
for (const id of ids) {
|
||||
assert.ok(tokens[id] || backups.tokens[id], `${id}: a token of the game data`);
|
||||
assert.equal(web[id]?.spine, undefined, `${id}: no web model`);
|
||||
}
|
||||
// the 自选 summons whose prefab draws nothing (an EmptyAnimator: displayType HIDDEN / an effect) are left out
|
||||
for (const id of ['token_10039_ulpia_block', 'token_10055_phatm2_mndclv', 'token_10058_sbell2_icetgt', 'token_10065_demetr_dmtpos',
|
||||
'token_10071_aglna2_agairp']) assert.ok(!ids.includes(id), id);
|
||||
assert.ok(!jobs.some((j) => j.sub.startsWith('spine/')), 'the old skinpack job of 乌尔比安\'s block extracted nothing (its skin prefab holds no art)');
|
||||
});
|
||||
|
||||
test('token helpers: prefab container ids, --only selection of model ids, the Front renderer, atlas page names', () => {
|
||||
const out = py(`
|
||||
W = {'token_10009_weedy_cannon', 'token_10002_kalts_mon3tr'}
|
||||
ids = ['token_a', 'token_b']
|
||||
nodes = [('Back', True, {'m_PathID': 2}), ('Front', True, {'m_PathID': 1}), ('Down', True, {'m_PathID': 3})]
|
||||
print(json.dumps({
|
||||
'ids': [e.token_prefab_id(c, W) for c in ['dyn/battle/prefabs/[uc]tokens/token_10009_weedy_cannon.prefab',
|
||||
'battle/prefabs/tokens/token_10002_kalts_mon3tr.prefab',
|
||||
'dyn/battle/prefabs/skins/character/token_10002_kalts_mon3tr/token_10002_kalts_mon3tr_boc#6.prefab',
|
||||
'dyn/battle/prefabs/[uc]tokens/token_10000_silent_healrb.prefab', None]],
|
||||
'only': [e.spine_ids(o, 'spine/token', ids) for o in ([], ['spine'], ['spine/token'], ['spine/token/token_b'], ['spine/enemy'], ['map'])],
|
||||
'pick': [e.pick_spine_node(nodes)[0], e.pick_spine_node([('Spine', True, {}), ('X', True, {})])[0],
|
||||
e.pick_spine_node([('Front', False, {}), ('Other', True, {})])[0], e.pick_spine_node([('A', False, {})])[0], e.pick_spine_node([])],
|
||||
'pages': e.atlas_pages('\\ntoken_x.png\\nsize: 64,64\\nformat: RGBA8888\\nC_Body\\n rotate: false\\n xy: 2, 2\\n\\ntoken_x2.png\\nsize: 32,32\\nC_Leg\\n xy: 0, 0\\n'),
|
||||
}))`);
|
||||
assert.deepEqual(out.ids, ['token_10009_weedy_cannon', 'token_10002_kalts_mon3tr', null, null, null]);
|
||||
assert.deepEqual(out.only, [['token_a', 'token_b'], ['token_a', 'token_b'], ['token_a', 'token_b'], ['token_b'], [], []]);
|
||||
assert.deepEqual(out.pick, ['Front', 'Spine', 'Other', 'A', null]);
|
||||
assert.deepEqual(out.pages, ['token_x.png', 'token_x2.png']);
|
||||
});
|
||||
|
||||
test('prefab_spine_nodes: the SkeletonAnimation renderers of a prefab tree, depth first, inactive branches marked', () => {
|
||||
// a directional token prefab: Graphic / DefaultSkin / FaceSwitcher / {Front, Back} (each with its own skeleton), an
|
||||
// inactive Spare renderer, a component in another bundle (m_FileID ≠ 0) and a MonoBehaviour without a type tree
|
||||
const out = py(`
|
||||
class O:
|
||||
def __init__(self, kind, tree=None, err=False): self.type, self._t, self._e = NS(name=kind), tree, err
|
||||
def read_typetree(self):
|
||||
if self._e: raise ValueError('no type tree')
|
||||
return self._t
|
||||
objs = {}
|
||||
def go(pid, name, kids=(), sda=None, active=1):
|
||||
tr = 100 + pid
|
||||
comps = [{'component': {'m_FileID': 0, 'm_PathID': tr}}]
|
||||
objs[tr] = O('Transform', {'m_Children': [{'m_FileID': 0, 'm_PathID': 100 + k} for k in kids], 'm_GameObject': {'m_FileID': 0, 'm_PathID': pid}})
|
||||
if sda is not None:
|
||||
objs[200 + pid] = O('MonoBehaviour', {'skeletonDataAsset': {'m_FileID': 0, 'm_PathID': sda}})
|
||||
comps.append({'component': {'m_FileID': 0, 'm_PathID': 200 + pid}})
|
||||
objs[pid] = O('GameObject', {'m_Name': name, 'm_IsActive': active, 'm_Component': comps})
|
||||
go(1, 'token_x', kids=(2, 7))
|
||||
go(2, 'Graphic', kids=(3,))
|
||||
go(3, 'FaceSwitcher', kids=(4, 5, 6))
|
||||
go(4, 'Front', sda=41)
|
||||
go(5, 'Back', sda=51)
|
||||
go(6, 'Spare', sda=61, active=0)
|
||||
go(7, 'Modes')
|
||||
objs[107]._t['m_Children'] = []
|
||||
objs[1]._t['m_Component'] += [{'component': {'m_FileID': 3, 'm_PathID': 999}}, {'component': {'m_FileID': 0, 'm_PathID': 300}}]
|
||||
objs[300] = O('MonoBehaviour', err=True)
|
||||
nodes = e.prefab_spine_nodes(objs[1].read_typetree(), objs)
|
||||
print(json.dumps({'nodes': [[n, a, r['m_PathID']] for n, a, r in nodes], 'pick': e.pick_spine_node(nodes)[2]['m_PathID']}))`);
|
||||
assert.deepEqual(out.nodes, [['Front', true, 41], ['Back', true, 51], ['Spare', false, 61]]);
|
||||
assert.equal(out.pick, 41, 'the Front skeleton (the fetched tokens use Front / Spine too)');
|
||||
});
|
||||
|
||||
test('merge_alpha: the [alpha] texture as A, else the page\'s own alpha; RGB clamped to A (premultiplied input), multiplied for straight input', { skip: spawnSync(PY, ['-c', 'import PIL']).status !== 0 && 'no Pillow' }, () => {
|
||||
const out = py(`
|
||||
from PIL import Image
|
||||
rgb = Image.new('RGB', (2, 1)); rgb.putdata([(200, 100, 50), (90, 90, 90)])
|
||||
alpha = Image.new('RGB', (2, 1)); alpha.putdata([(128, 128, 128), (255, 255, 255)])
|
||||
rgba = Image.new('RGBA', (2, 1)); rgba.putdata([(60, 200, 10, 100), (0, 0, 0, 0)])
|
||||
print(json.dumps({'split': list(e.merge_alpha(rgb, alpha).getdata()), 'own': list(e.merge_alpha(rgba, None).getdata()),
|
||||
'opaque': list(e.merge_alpha(rgb, None).getdata()), 'straight': list(e.merge_alpha(rgba, None, True).getdata())}))`);
|
||||
assert.deepEqual(out.split, [[128, 100, 50, 128], [90, 90, 90, 255]], 'A from the [alpha] texture, RGB ≤ A');
|
||||
assert.deepEqual(out.own, [[60, 100, 10, 100], [0, 0, 0, 0]], 'an RGBA page keeps its alpha (the newer tokens: _UseAlphaTex 0)');
|
||||
assert.deepEqual(out.opaque, [[200, 100, 50, 255], [90, 90, 90, 255]], 'no alpha anywhere: opaque');
|
||||
assert.deepEqual(out.straight, [[23, 78, 3, 100], [0, 0, 0, 0]], '_StraightAlphaInput 1: RGB × A / 255 (Pillow truncates)');
|
||||
});
|
||||
|
||||
test('material_info: textures by name with tiling, floats, colours, sorted keywords; unresolvable refs → null', () => {
|
||||
const out = py(`
|
||||
class P:
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
// test/local-token-models.test.js — the token (summon) models only the local client has (0.2.0): 39 summons — most of the
|
||||
// 自选 picks' (data/backups.json `tokens`), 凯瑟琳's 爬行号·防护单元 and 凛御银灰's 风雪之眼 — have no battle Spine in
|
||||
// any community dump (fetch-assets reported "missing skel"), so the client drew their avatar diamond. Their official
|
||||
// skeletons come from the battle token packs of the local client (tools/local-extract/extract.py TOKEN_SPINES →
|
||||
// public/assets/local/spine/token/<id>/, optional and git-ignored) and are an OVERLAY like the enemy models of
|
||||
// test/feedback1d-models.test.js: data/assets.json tokens[id].spineLocal names the files of the data/local-assets.json
|
||||
// group `spine/token/<id>` with the metadata of the committed tools/assets/local-token-spines.json — never read from the
|
||||
// disk, so the manifest is the same with or without the extraction. The client (assets.js spineEntry) draws the model
|
||||
// when data/local-assets.json lists every one of its files; without it (or when it fails to load) the diamond stays.
|
||||
// Tokens whose prefab draws nothing (乌尔比安's 从不混淆的方向, 酒神's 迷狂牢笼, …) are not in the list (extract.py).
|
||||
// The extractor side (job table, prefab walk, alpha merge): test/local-extract.test.js.
|
||||
|
||||
import { test, describe, before, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { existsSync, readFileSync, mkdtempSync, mkdirSync, writeFileSync, 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 { buildPlan } from '../tools/assets/plan.mjs';
|
||||
import { resolveTemplate, collectLeaves } from '../tools/assets/manifest.mjs';
|
||||
import {
|
||||
findLocalTokenModels, localSpineMeta, loadLocalSpines, LOCAL_TOKEN_SPINES_FILE, localTokenSpineGroup, LOCAL_SPINE_ROLES, applyRoleFix,
|
||||
} from '../tools/assets/spine.mjs';
|
||||
import { roleAnimationNames } from '../tools/assets/anim-roles.mjs';
|
||||
import { indexAudio } from '../tools/assets/audio.mjs';
|
||||
import { createAssets, spineEntry, validSpine } from '../public/js/assets.js';
|
||||
import { installFakePixi, fakeViewCtx } from './render/fakepixi.js';
|
||||
import { presetCamera } from '../public/js/render/projection.js';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const ASSETS = path.join(ROOT, 'public/assets');
|
||||
const readJson = (rel) => JSON.parse(readFileSync(path.join(ROOT, rel), 'utf8'));
|
||||
const COMMITTED = readJson(LOCAL_TOKEN_SPINES_FILE).models;
|
||||
const MANIFEST = readJson('data/assets.json');
|
||||
const PY = ['python3', 'python'].find((bin) => spawnSync(bin, ['--version']).status === 0);
|
||||
/** extract.py TOKEN_SPINES (--print-jobs needs no dependencies). */
|
||||
const TOKEN_SPINES = (() => {
|
||||
if (!PY) return null;
|
||||
const r = spawnSync(PY, [path.join(ROOT, 'tools/local-extract/extract.py'), '--print-jobs'], { encoding: 'utf8', env: { ...process.env, PYTHONDONTWRITEBYTECODE: '1' } });
|
||||
return r.status === 0 ? JSON.parse(r.stdout).tokenSpines.ids : null;
|
||||
})();
|
||||
const IDS = Object.keys(COMMITTED).sort();
|
||||
|
||||
/** A data/local-assets.json listing the extracted files of `ids` (extract.py's group layout), minus `drop`. */
|
||||
function localManifest(ids, drop = null) {
|
||||
const groups = {};
|
||||
for (const id of ids) {
|
||||
const g = groups[localTokenSpineGroup(id)] = {};
|
||||
const m = COMMITTED[id];
|
||||
for (const f of [m.skel, m.atlas, ...m.textures]) if (f !== drop) g[f] = { path: `/assets/local/spine/token/${id}/${f}`, kind: f.endsWith('.png') ? 'Texture2D' : 'TextAsset' };
|
||||
}
|
||||
return { version: 1, source: 'local-client', groups };
|
||||
}
|
||||
|
||||
describe('the 自选 summon models from the local client are an optional overlay', () => {
|
||||
test('the committed metadata: the TOKEN_SPINES of extract.py, each a skeleton + same-stem atlas + pages, its roles on its own clips', () => {
|
||||
assert.equal(IDS.length, 39);
|
||||
if (TOKEN_SPINES) assert.deepEqual(IDS, [...TOKEN_SPINES].sort(), 'one entry per extract.py TOKEN_SPINES id');
|
||||
for (const id of IDS) {
|
||||
const m = COMMITTED[id];
|
||||
assert.match(m.skel, /\.skel$/, id);
|
||||
assert.equal(m.atlas, m.skel.replace(/\.skel$/, '.atlas'), `${id}: next to the skeleton (pixi-spine finds the atlas by its name)`);
|
||||
assert.ok(m.textures.length >= 1 && m.textures.every((t) => t.endsWith('.png')), id);
|
||||
assert.equal(m.pma, true, `${id}: premultiplied pages (extract.py merge_alpha)`);
|
||||
const names = Object.keys(m.animations);
|
||||
assert.ok(names.includes(m.anims.idle), `${id} idle ${m.anims.idle}`);
|
||||
assert.ok(m.anims.attack && names.includes(m.anims.attack.loop), `${id} attack`);
|
||||
for (const n of roleAnimationNames(m.anims)) assert.ok(names.includes(n), `${id} role clip ${n}`);
|
||||
}
|
||||
// 凛御银灰's three 风雪之眼 share one skeleton (one prefab each)
|
||||
assert.deepEqual([...new Set(['1', '2', '3'].map((n) => COMMITTED[`token_10057_svash2_eagle${n}`].skel))], ['token_10057_svash2_eagle.skel']);
|
||||
});
|
||||
|
||||
test('role fixes: clips the resolver cannot read by name are mapped (电弧\'s 戴乌, 酒神\'s 本能的召唤, 白铁\'s platforms, the anchor)', () => {
|
||||
for (const [id, fix] of Object.entries(LOCAL_SPINE_ROLES)) {
|
||||
assert.ok(COMMITTED[id], `${id} is a local model`);
|
||||
for (const [role, v] of Object.entries(fix)) assert.deepEqual(COMMITTED[id].anims[role], v, `${id} ${role}`);
|
||||
}
|
||||
const tower = COMMITTED.token_10051_radian_tower1.anims;
|
||||
assert.deepEqual([tower.idle, tower.deploy, tower.attack.loop, tower.die], ['C_Skill1_Idle', 'C_Skill1_Start', 'C_Skill1_Attack', 'C_Skill1_Die'],
|
||||
'戴乌: its C_Skill1_* set, not the 0 s C_Default pose');
|
||||
assert.equal(COMMITTED.token_10054_phatm2_encdool.anims.idle, 'Loop');
|
||||
assert.equal(COMMITTED.token_10027_ironmn_pile1.anims.die, 'End');
|
||||
assert.equal(COMMITTED.token_10068_kalts2_mtship.anims.idle, 'Default', '战术锚点: its Idle clip hides its only attachment');
|
||||
// a fix naming a clip the skeleton lacks is not applied
|
||||
const roles = { idle: 'Idle', die: null };
|
||||
assert.deepEqual(applyRoleFix(roles, { idle: 'Nope', die: 'End' }, { Idle: 1, End: 1 }), { roles: { idle: 'Idle', die: 'End' }, missing: ['idle'] });
|
||||
assert.deepEqual(applyRoleFix(roles, undefined, {}), { roles, missing: [] });
|
||||
});
|
||||
|
||||
test('plan: the token gets spineLocal from the committed metadata as it is; no metadata → no overlay; nothing to download', () => {
|
||||
const plan = (localTokenSpines) => buildPlan({
|
||||
assets07: readJson('docs/research/07-assets.json'), ops03: readJson('docs/research/03-operators.json'),
|
||||
enemies05: readJson('docs/research/05-enemies.json'), maps05: readJson('docs/research/05-maps.json'),
|
||||
audio: indexAudio({}), modelsData: { data: {} }, extraTokenIds: IDS, localTokenSpines,
|
||||
});
|
||||
const before = plan(undefined);
|
||||
for (const id of IDS) assert.equal(before.template.tokens[id].spineLocal, undefined, `${id}: no overlay without metadata`);
|
||||
const p = plan(COMMITTED);
|
||||
const tmp = mkdtempSync(path.join(tmpdir(), 'sp-tokplan-'));
|
||||
try {
|
||||
const { value } = resolveTemplate({ tokens: Object.fromEntries(IDS.map((id) => [id, p.template.tokens[id]])) }, { root: tmp, spine: new Map() });
|
||||
for (const id of IDS) assert.deepEqual(value.tokens[id].spineLocal, { group: `spine/token/${id}`, ...COMMITTED[id] }, id);
|
||||
} finally { rmSync(tmp, { recursive: true, force: true }); }
|
||||
assert.ok(!collectLeaves(p.template).some((l) => /spineLocal/.test(l.path)), 'the overlay is no file to download');
|
||||
assert.ok(p.notes.includes('token_10002_kalts_mon3tr: official Spine from the local client when extracted (spineLocal)'));
|
||||
});
|
||||
|
||||
test('data/assets.json: tokens[id].spineLocal = the committed metadata for exactly these tokens; no /assets/local/ URL; no web model changed', () => {
|
||||
const urls = [];
|
||||
const walk = (n) => { if (typeof n === 'string') { if (n.includes('/assets/local/')) urls.push(n); } else if (n && typeof n === 'object') Object.values(n).forEach(walk); };
|
||||
walk(MANIFEST);
|
||||
assert.deepEqual(urls, [], 'setup / doctor / the manifest tests count every /assets/ URL as required');
|
||||
const withLocal = Object.keys(MANIFEST.tokens).filter((id) => MANIFEST.tokens[id].spineLocal).sort();
|
||||
assert.deepEqual(withLocal, IDS);
|
||||
for (const id of IDS) {
|
||||
assert.deepEqual(MANIFEST.tokens[id].spineLocal, { group: `spine/token/${id}`, ...COMMITTED[id] }, `${id}: the committed metadata`);
|
||||
assert.equal(MANIFEST.tokens[id].spine, undefined, `${id}: no web model (it was the avatar diamond)`);
|
||||
assert.equal(spineEntry(MANIFEST, id), null, `${id}: without the local art the client keeps the diamond`);
|
||||
}
|
||||
// the tokens that always had a web model keep it, without an overlay
|
||||
for (const [id, t] of Object.entries(MANIFEST.tokens)) if (t.spine) { assert.ok(validSpine(t.spine), id); assert.equal(t.spineLocal, undefined, id); }
|
||||
});
|
||||
|
||||
test('the committed metadata is what the extracted models parse to', { skip: !IDS.every((id) => existsSync(path.join(ASSETS, 'local/spine/token', id))) && 'token models not extracted (tools/local-extract)' }, async () => {
|
||||
const found = await findLocalTokenModels(ASSETS);
|
||||
const { meta, problems } = await localSpineMeta(ASSETS, Object.fromEntries(IDS.map((id) => [id, found[id]])));
|
||||
assert.deepEqual(problems, []);
|
||||
for (const id of IDS) assert.deepEqual(meta[id], COMMITTED[id], `${id}: re-run node tools/fetch-assets.mjs --local-spines`);
|
||||
// and the shared data/local-assets.json lists every file of each (the client draws the model only then)
|
||||
const local = JSON.parse(readFileSync(path.join(ROOT, 'data/local-assets.json'), 'utf8'));
|
||||
for (const id of IDS) assert.ok(spineEntry(MANIFEST, id, { local })?.local, `${id}: listed in data/local-assets.json`);
|
||||
});
|
||||
|
||||
test('findLocalTokenModels: skeleton + same-stem atlas + page PNGs under local/spine/token/<id>/; loadLocalSpines', async () => {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), 'sp-toklocal-'));
|
||||
try {
|
||||
const put = (rel, body = 'x') => { mkdirSync(path.dirname(path.join(dir, rel)), { recursive: true }); writeFileSync(path.join(dir, rel), body); };
|
||||
put('local/spine/token/token_10057_svash2_eagle1/token_10057_svash2_eagle.skel');
|
||||
put('local/spine/token/token_10057_svash2_eagle1/token_10057_svash2_eagle.atlas');
|
||||
put('local/spine/token/token_10057_svash2_eagle1/token_10057_svash2_eagle.png');
|
||||
put('local/spine/token/token_x_noatlas/token_x_noatlas.skel');
|
||||
put('local/spine/token/enemy_1305_mhslim/enemy_1305_mhslim.skel');
|
||||
put('local/spine/token/enemy_1305_mhslim/enemy_1305_mhslim.atlas');
|
||||
put('local/spine/token/enemy_1305_mhslim/enemy_1305_mhslim.png');
|
||||
const found = await findLocalTokenModels(dir);
|
||||
assert.deepEqual(Object.keys(found), ['token_10057_svash2_eagle1'], 'one stem per folder; only token ids');
|
||||
assert.equal(found.token_10057_svash2_eagle1.atlas, 'local/spine/token/token_10057_svash2_eagle1/token_10057_svash2_eagle.atlas');
|
||||
assert.deepEqual(await findLocalTokenModels(path.join(dir, 'missing')), {});
|
||||
assert.deepEqual(await loadLocalSpines(path.join(ROOT, LOCAL_TOKEN_SPINES_FILE)), COMMITTED);
|
||||
assert.deepEqual(await loadLocalSpines(path.join(dir, 'none.json')), {});
|
||||
} finally { rmSync(dir, { recursive: true, force: true }); }
|
||||
});
|
||||
});
|
||||
|
||||
describe('client: the official summon model only when data/local-assets.json lists it', () => {
|
||||
test('assets.js spineEntry: every file listed → the official model; anything missing → none (the diamond); web models unchanged', () => {
|
||||
const local = localManifest(IDS);
|
||||
for (const id of IDS) {
|
||||
const m = COMMITTED[id];
|
||||
const e = spineEntry(MANIFEST, id, { local });
|
||||
assert.equal(e.skel, `/assets/local/spine/token/${id}/${m.skel}`);
|
||||
assert.equal(e.atlas, `/assets/local/spine/token/${id}/${m.atlas}`);
|
||||
assert.deepEqual(e.textures, m.textures.map((t) => `/assets/local/spine/token/${id}/${t}`));
|
||||
assert.equal(e.pma, true);
|
||||
assert.deepEqual(e.anims, m.anims);
|
||||
assert.equal(e.local, true);
|
||||
assert.equal(e.fallback, null, 'no web model to fall back to');
|
||||
assert.equal(spineEntry(MANIFEST, id, { local }), e, 'one entry object per manifest pair');
|
||||
assert.equal(spineEntry(MANIFEST, id, { local: localManifest([]) }), null, 'not extracted → no model');
|
||||
assert.equal(spineEntry(MANIFEST, id, { local: localManifest([id], m.textures[0]) }), null, 'a page missing → no model');
|
||||
}
|
||||
assert.equal(spineEntry(MANIFEST, 'token_10028_vigil_wolf', { local }), MANIFEST.tokens.token_10028_vigil_wolf.spine, 'a web token unchanged');
|
||||
const store = createAssets({ manifest: MANIFEST, localManifest: local });
|
||||
assert.equal(store.spineEntry('token_10002_kalts_mon3tr').skel, '/assets/local/spine/token/token_10002_kalts_mon3tr/token_10002_kalts_mon3tr.skel');
|
||||
assert.equal(createAssets({ manifest: MANIFEST }).spineEntry('token_10002_kalts_mon3tr'), null, 'before / without local(): the diamond');
|
||||
});
|
||||
|
||||
describe('UnitView', () => {
|
||||
let fake, UnitView;
|
||||
before(async () => {
|
||||
fake = installFakePixi();
|
||||
({ UnitView } = await import('../public/js/render/units.js'));
|
||||
});
|
||||
after(() => fake.restore());
|
||||
const tick = () => new Promise((r) => setImmediate(r));
|
||||
const cam = () => presetCamera('normal', { width: 1280, height: 720 });
|
||||
const info = (id) => ({ id: 7, side: 'ally', kind: 'token', defId: id, spine: id, avatar: id, tier: 1, x: 4, y: 10, maxHp: 1000, dir: 'RIGHT' });
|
||||
|
||||
test('a summon view draws its official model with its own clips; a failed load keeps the diamond and is retried', async () => {
|
||||
const local = localManifest(IDS);
|
||||
const asked = [], released = [];
|
||||
const store = (fail) => ({
|
||||
picture: () => null, image: async () => null,
|
||||
spineEntry: (id) => spineEntry(MANIFEST, id, { local }),
|
||||
spine: {
|
||||
acquire: async (e) => { asked.push(e.skel); if (fail(e)) throw new Error('404'); return { animations: Object.keys(e.animations).map((name) => ({ name })) }; },
|
||||
release: (e) => released.push(e.skel),
|
||||
},
|
||||
});
|
||||
const id = 'token_10051_radian_tower1';
|
||||
const ok = new UnitView(fakeViewCtx(fake.P, { assets: store(() => false), cam }), info(id));
|
||||
await tick(); await tick();
|
||||
assert.equal(ok.actor?.entry.skel, `/assets/local/spine/token/${id}/${COMMITTED[id].skel}`, 'the official model');
|
||||
assert.equal(ok.actor.entry.anims.idle, 'C_Skill1_Idle', 'with the mapped clips');
|
||||
asked.length = 0;
|
||||
const bad = new UnitView(fakeViewCtx(fake.P, { assets: store(() => true), cam }), info(id));
|
||||
await tick(); await tick(); await tick();
|
||||
assert.deepEqual(asked, [`/assets/local/spine/token/${id}/${COMMITTED[id].skel}`], 'tried once, no web model behind it');
|
||||
assert.ok(released.includes(asked[0]), 'the failed acquire is released');
|
||||
assert.equal(bad.actor, null, 'the avatar diamond stays');
|
||||
assert.ok(bad._retryAt > 0, 'and the model is loaded again later (SPINE_RETRY_MS)');
|
||||
for (const v of [ok, bad]) v.destroy?.();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -27,7 +27,8 @@ test('selection: runtime files in; tests, maintainer tools, other docs, dev page
|
||||
const { keep, drop } = selectTracked([
|
||||
'server/index.js', 'server/sim/rng.js', 'shared/constants.js', 'public/index.html', 'public/js/main.js', 'public/i18n/en.json',
|
||||
'data/chess.json', 'data/i18n/en.json', 'tools/setup.mjs', 'tools/vendor.mjs', 'tools/fetch-assets.mjs', 'tools/doctor.mjs',
|
||||
'tools/crop-board-atlas.mjs', 'tools/assets/plan.mjs', 'tools/assets/local-enemy-spines.json', 'tools/local-extract/extract.py',
|
||||
'tools/crop-board-atlas.mjs', 'tools/assets/plan.mjs', 'tools/assets/local-enemy-spines.json', 'tools/assets/local-token-spines.json',
|
||||
'tools/local-extract/extract.py',
|
||||
'tools/local-extract/LICENSE-Ark-Unpacker.txt', 'scripts/start.sh', 'scripts/start-windows.bat', 'scripts/launch.mjs',
|
||||
'scripts/install-service-windows.ps1', 'docs/PLAYING.md', 'docs/DEPLOY.md', ...RUNTIME_RESEARCH, 'package.json',
|
||||
'package-lock.json', 'LICENSE', 'NOTICE.md', 'THIRD-PARTY-NOTICES.md', 'README.md', 'CHANGELOG.md',
|
||||
@@ -42,10 +43,11 @@ test('selection: runtime files in; tests, maintainer tools, other docs, dev page
|
||||
'public/.DS_Store', 'tools/local-extract/.venv-extract/x.py', 'node_modules/ws/index.js',
|
||||
]);
|
||||
for (const f of ['server/index.js', 'public/i18n/en.json', 'data/i18n/en.json', 'tools/assets/local-enemy-spines.json',
|
||||
'tools/local-extract/LICENSE-Ark-Unpacker.txt', 'scripts/install-service-windows.ps1', 'docs/research/07-assets.json', 'CHANGELOG.md']) {
|
||||
'tools/assets/local-token-spines.json', 'tools/local-extract/LICENSE-Ark-Unpacker.txt', 'scripts/install-service-windows.ps1',
|
||||
'docs/research/07-assets.json', 'CHANGELOG.md']) {
|
||||
assert.ok(keep.includes(f), `keeps ${f}`);
|
||||
}
|
||||
assert.equal(keep.length, 34, keep.join(' '));
|
||||
assert.equal(keep.length, 35, keep.join(' '));
|
||||
assert.equal(drop.length, 40, drop.join(' '));
|
||||
assert.ok(!keep.some((f) => drop.includes(f)));
|
||||
});
|
||||
@@ -149,10 +151,12 @@ function fakeCheckout() {
|
||||
put('public/assets/char/char_1048_orchd2.png', PNG);
|
||||
put('public/assets/local/g/x.webp', PNG);
|
||||
put('public/assets/local/g/x.png', PNG);
|
||||
put('public/assets/local/spine/token/token_x/token_x.skel', 'skel'); // a token model only the local client has (0.2.0)
|
||||
put('public/assets/.DS_Store', 'junk');
|
||||
put('public/fonts/fonts.css', '@font-face{}\n');
|
||||
put('public/fonts/x.woff2', PNG);
|
||||
put('data/local-assets.json', JSON.stringify({ version: 1, groups: { g: { x: { path: '/assets/local/g/x.webp' } } } }));
|
||||
put('data/local-assets.json', JSON.stringify({ version: 1, groups: { g: { x: { path: '/assets/local/g/x.webp' } },
|
||||
'spine/token/token_x': { 'token_x.skel': { path: '/assets/local/spine/token/token_x/token_x.skel' } } } }));
|
||||
put('.cache/assets-report.json', '{}');
|
||||
put('pv/clip.txt', 'promo');
|
||||
const git = (...args) => spawnSync('git', ['-C', dir, ...args], { encoding: 'utf8' });
|
||||
@@ -170,7 +174,7 @@ const SHIPPED_TRACKED = ['CHANGELOG.md', 'LICENSE', 'NOTICE.md', 'README.md', 'T
|
||||
'tools/assets/network.mjs', 'tools/crop-board-atlas.mjs', 'tools/doctor.mjs', 'tools/fetch-assets.mjs', 'tools/local-extract/extract.py',
|
||||
'tools/setup.mjs', 'tools/vendor.mjs'].sort();
|
||||
const ART = ['data/local-assets.json', 'public/assets/char/a.png', 'public/assets/local/g/x.png', 'public/assets/local/g/x.webp',
|
||||
'public/assets/ui/b c.png', 'public/fonts/fonts.css', 'public/fonts/x.woff2'];
|
||||
'public/assets/local/spine/token/token_x/token_x.skel', 'public/assets/ui/b c.png', 'public/fonts/fonts.css', 'public/fonts/x.woff2'];
|
||||
|
||||
test('dry-run on a temporary checkout: the full and the lite file lists, the summary, nothing written', () => {
|
||||
const { dir } = fakeCheckout();
|
||||
|
||||
@@ -88,7 +88,18 @@ test('docs and messages say what falls back without the local art and how a serv
|
||||
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');
|
||||
|
||||
Reference in new issue
Block a user