Files
ark-stronghold-protocol/test/local-extract.test.js
T
sganggs 6464e4b46a Playtest #6 fixes: summons placed by hand, boss pool stall, bounties, 联防 leak counter, distinct offers, 机变 card text, phone buy tap, skill triggers, blocking, push force, 孤立, enemies, element bar
- #1 #2 skill / device summons (赫默's drone, 巫恋's doll, 凯瑟琳's devices) are hand cards placed by the player;
  placed summons deploy once at battle start, then a skill summon re-appears at its tile on each cast (the user's call)
- #3 叙拉古 隐匿 ends on time (32 + 0.4 × layers s); 忍冬 S3 迷彩 lasts until her next cast (official), kept apart from 隐匿
- #4 multi-round bounty cards last two battles (the user's call; MULTI_ROUND_BOUNTY_BATTLES), blue / red card text
- #5 the shared boss pool's float dust (≈3.6e-12 HP) stalled Final Assault leaders at 0 %; < 1 HP now clears the pool,
  and the team LP reaching 0 first is a defeat (PRTS)
- #6 #10 机变 cards show their official effect text; an invisible 详情 button no longer swallows the confirming tap on phones
- #7 live ×N counter of a leaker's enemies still on the 联防 field (LP loss stays capped at 10)
- #8 #15 #16 skill triggers per 技能策略: skill-range triggers, injured-ally heals (古米, 塞雷娅), tank take-damage
  triggers with a 3 s cooldown; 新约能天使 S2 shields the highest-aggro operator in range
- #9 official per-enemy model scales (威龙)
- #11 element bar under the unit's HP bar (white = 元素值 left, element-coloured refill after a burst), floor rounding
- #12 枯朽战车 kit per PRTS; every ranged enemy reaches its target's 0.25 collider
- #13 恐惧 wander (萨科塔之翼 at half HP, operator fears make enemies flee) with re-planning after pushes
- #14 push / pull distance by 力度 − 重量 (PRTS table); #17 every blocker attacks what it blocks; ASPD floor 20
- #18 炎佑 is 孤立: no heals or ally buffs
- #19 every free pick-one chess offer (promotion reward, 信标, 寻呼模块) shows different operators
- DESIGN §20 and the normative lines, SIM / META / DATA / PLAYING updated; 2747 unit tests and all 27 browser suites
  green; dev recordings regenerated
2026-10-01 19:08:23 +08:00

177 lines
11 KiB
JavaScript

// 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.
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, existsSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const TOOL = path.join(ROOT, 'tools/local-extract');
const PY = ['python3', 'python'].find((bin) => spawnSync(bin, ['--version']).status === 0);
const ENV = { ...process.env, PYTHONDONTWRITEBYTECODE: '1' };
const manifest = (() => { try { return JSON.parse(readFileSync(path.join(ROOT, 'data/local-assets.json'), 'utf8')); } catch { return null; } })();
/** Run python code with `extract` imported as `e`; returns the parsed JSON it prints. */
function py(code) {
const src = `import sys, json\nfrom types import SimpleNamespace as NS\nsys.path.insert(0, ${JSON.stringify(TOOL)})\nimport extract as e\n${code}`;
const r = spawnSync(PY, ['-c', src], { encoding: 'utf8', env: ENV });
assert.equal(r.status, 0, r.stderr);
return JSON.parse(r.stdout);
}
describe('extract.py helpers (no UnityPy needed)', { skip: !PY && 'no python3' }, () => {
test('job table: map themes export Materials, the board meshes are exported with their prefab', () => {
const r = spawnSync(PY, [path.join(TOOL, 'extract.py'), '--print-jobs'], { encoding: 'utf8', env: ENV });
assert.equal(r.status, 0, r.stderr);
const { jobs } = JSON.parse(r.stdout);
for (const sub of ['map/autochess', 'map/autochesssand']) assert.deepEqual(jobs.find((j) => j.sub === sub).kinds, ['Material', 'Texture2D'], sub);
const mesh = jobs.filter((j) => j.sub.startsWith('mesh/'));
assert.deepEqual(mesh.map((j) => j.bundle), [
'arts/maps/map_autochess/bkg_mesh.ab', 'arts/maps/common/meshes/s_background_common.ab',
'arts/maps/common/meshes/s_common_box_01.ab', 'arts/maps/common/meshes/s_wind_device.ab',
]);
for (const j of mesh) assert.deepEqual(j.kinds, ['GameObject', 'Material', 'Mesh', 'Texture2D']);
assert.equal(new Set(jobs.map((j) => j.sub)).size, jobs.length, 'one output dir per job');
});
test('material_info: textures by name with tiling, floats, colours, sorted keywords; unresolvable refs → null', () => {
const out = py(`
class P:
def __init__(self, obj=None, pid=1, err=False): self.m_PathID, self._o, self._e = pid, obj, err
def read(self):
if self._e: raise FileNotFoundError('cab-x not found')
return self._o
tex = lambda n: NS(m_Name=n)
v2 = lambda x, y: NS(x=x, y=y)
mat = NS(m_Name='MT', m_Shader=P(err=True), m_ValidKeywords=['_NORMALMAP', 'HG_LIGHTMAP'],
m_SavedProperties=NS(
m_TexEnvs=[('_MainTex', NS(m_Texture=P(tex('TX_D')), m_Scale=v2(2, 1), m_Offset=v2(0, -0.0))),
('_BumpMap', NS(m_Texture=P(pid=0), m_Scale=v2(1, 1), m_Offset=v2(0, 0))),
('_EmissionMap', NS(m_Texture=P(err=True), m_Scale=v2(1, 1), m_Offset=v2(0, 0)))],
m_Floats=[('_Cutoff', 0.40799999237060547), ('_Bad', float('nan'))],
m_Colors=[('_EmissionColor', NS(r=0.559, g=0.559, b=0.559, a=1))]))
bg = NS(m_Name='BG', m_Shader=P(NS(m_ParsedForm=NS(m_Name='Torappu/Unlit/Texture'))), m_ShaderKeywords='B A',
m_SavedProperties=NS(m_TexEnvs=[], m_Floats=[], m_Colors=[]))
print(json.dumps([e.material_info(mat), e.material_info(bg)]))`);
assert.deepEqual(out[0], {
shader: null, keywords: ['HG_LIGHTMAP', '_NORMALMAP'],
textures: { _MainTex: { texture: 'TX_D', scale: [2, 1], offset: [0, 0] } },
floats: { _Bad: 0, _Cutoff: 0.408 }, colors: { _EmissionColor: [0.559, 0.559, 0.559, 1] },
});
assert.deepEqual(out[1], { shader: 'Torappu/Unlit/Texture', keywords: ['A', 'B'], textures: {}, floats: {}, colors: {} });
});
test('dep_bundles: the shader bundles load beside jobs that export Materials only', () => {
const out = py(`
import tempfile, pathlib
d = pathlib.Path(tempfile.mkdtemp())
(d / 'shaders').mkdir()
for n in ('standarddirectional.ab', 'other.ab', 'notes.txt'): (d / 'shaders' / n).write_bytes(b'')
(d / 'shaders' / 'sub.ab').mkdir()
rel = lambda ps: [p.relative_to(d).as_posix() for p in ps]
print(json.dumps({'mat': rel(e.dep_bundles(d, {'Texture2D', 'Material'})), 'sprite': rel(e.dep_bundles(d, {'Sprite'})),
'none': rel(e.dep_bundles(d / 'missing', {'Material'}))}))`);
assert.deepEqual(out, { mat: ['shaders/other.ab', 'shaders/standarddirectional.ab'], sprite: [], none: [] });
});
test('prefab_node: local transform, parent, mesh and materials; no -0.0 in the output', () => {
const out = py(`
class P:
def __init__(self, obj=None, pid=1, err=False): self.m_PathID, self._o, self._e = pid, obj, err
def read(self):
if self._e: raise FileNotFoundError('external')
return self._o
class Transform:
def __init__(self, **k): self.__dict__.update(k)
class MeshFilter:
def __init__(self, **k): self.__dict__.update(k)
class MeshRenderer:
def __init__(self, **k): self.__dict__.update(k)
v3 = lambda x, y, z: NS(x=x, y=y, z=z)
root_go = NS(m_Name='Root')
root_tr = Transform(m_LocalPosition=v3(0, 0, 0), m_LocalRotation=NS(x=0, y=0, z=0, w=1), m_LocalScale=v3(1, 1, 1),
m_Father=P(pid=0), m_GameObject=P(root_go))
child_tr = Transform(m_LocalPosition=v3(-0.0, 3.5254242420196533, -8), m_LocalRotation=NS(x=0, y=-0.0, z=0, w=1),
m_LocalScale=v3(0.01, 0.01, 0.01), m_Father=P(root_tr), m_GameObject=None)
go = NS(m_Name='S_Background_shadow', m_Component=[
NS(component=P(child_tr)),
NS(component=P(MeshFilter(m_Mesh=P(NS(m_Name='S_Background_shadow'))))),
NS(component=P(MeshRenderer(m_Materials=[P(NS(m_Name='lambert1')), P(err=True)]))),
NS(component=P(err=True)),
])
print(json.dumps(e.prefab_node(go)))`);
assert.deepEqual(out, {
name: 'S_Background_shadow', parent: 'Root', pos: [0, 3.525424, -8], rot: [0, 0, 0, 1], scale: [0.01, 0.01, 0.01],
mesh: 'S_Background_shadow', materials: ['lambert1', null],
});
const raw = spawnSync(PY, ['-c', `import sys; sys.path.insert(0, ${JSON.stringify(TOOL)}); import extract as e, json; print(json.dumps(e.vec(type('V', (), {'x': -0.0, 'y': float('inf'), 'z': 1e-9})())))`], { encoding: 'utf8', env: ENV });
assert.equal(raw.stdout.trim(), '[0.0, 0, 0.0]');
});
});
describe('extracted board-scene files (when a local client was extracted)', { skip: !manifest && 'no data/local-assets.json' }, () => {
const onDisk = (p) => path.join(ROOT, 'public', p);
const hasFiles = existsSync(path.join(ROOT, 'public/assets/local/mesh'));
test('map/autochess materials.json: the official material parameters, textures present in the group', { skip: !manifest?.groups?.['map/autochess']?.materials && 'not extracted' }, () => {
const g = manifest.groups['map/autochess'];
assert.equal(g.materials.kind, 'Materials');
if (!hasFiles) return;
const mats = JSON.parse(readFileSync(onDisk(g.materials.path), 'utf8'));
assert.deepEqual(Object.keys(mats), ['MT_autochess', 'MT_autochess_Transparent', 'MT_autochess_common', 'MT_autochessi_BG']);
assert.equal(mats.MT_autochess.textures._MainTex.texture, 'TX_autochessi_D');
assert.equal(mats.MT_autochess.textures._BumpMap.texture, 'TX_autochessi_N');
assert.ok(mats.MT_autochess.keywords.includes('_EMISSION'));
assert.equal(mats.MT_autochessi_BG.shader, 'Torappu/Unlit/Texture');
// external shader references resolve through the shaders/*.ab bundles (they were all null before)
for (const k of ['MT_autochess', 'MT_autochess_Transparent', 'MT_autochess_common']) assert.equal(mats[k].shader, 'Torappu/Scene/StandardDirectional', k);
const sand = JSON.parse(readFileSync(onDisk(manifest.groups['map/autochesssand'].materials.path), 'utf8'));
for (const [k, m] of Object.entries(sand)) assert.match(m.shader || '', /^Torappu\/Scene\/(StandardRealtimeShadow|StylizedWater)$/, k);
for (const m of Object.values(mats)) for (const t of Object.values(m.textures)) assert.ok(g[t.texture], `${t.texture} extracted`);
});
test('mesh/*: OBJ files with their vertex counts and a prefab.json each', { skip: !Object.keys(manifest?.groups || {}).some((k) => k.startsWith('mesh/')) && 'not extracted' }, () => {
const groups = Object.entries(manifest.groups).filter(([k]) => k.startsWith('mesh/'));
assert.deepEqual(groups.map(([k]) => k).sort(), ['mesh/map_autochess_bkg', 'mesh/s_background_common', 'mesh/s_common_box_01', 'mesh/s_wind_device']);
for (const [name, g] of groups) {
assert.equal(g.prefab?.kind, 'Prefab', name);
const meshes = Object.values(g).filter((x) => x.kind === 'Mesh');
assert.ok(meshes.length >= 1, name);
if (!hasFiles) continue;
const prefab = JSON.parse(readFileSync(onDisk(g.prefab.path), 'utf8'));
assert.equal(prefab.length, g.prefab.count);
for (const node of prefab) {
assert.equal(node.pos.length, 3);
assert.equal(node.rot.length, 4);
if (node.mesh) assert.ok(g[node.mesh], `${name}: prefab mesh ${node.mesh} exported`);
}
for (const m of meshes) {
const obj = readFileSync(onDisk(m.path), 'utf8');
const v = obj.split('\n').filter((l) => l.startsWith('v ')).length;
assert.equal(v, m.verts, m.path);
assert.ok(obj.split('\n').some((l) => l.startsWith('f ')), `${m.path} has faces`);
}
}
const bkg = JSON.parse(readFileSync(onDisk(manifest.groups['mesh/map_autochess_bkg'].prefab.path), 'utf8'));
assert.deepEqual(bkg.map((n) => n.name), ['S_Background_common', 'S_Background_shadow']);
});
});
describe('enemy_scales.py (official enemy model sizes → build-data MODEL_SCALES, user playtest #6 item 9)', { skip: !PY && 'no python3' }, () => {
test('group_table: prefabs off the 0.27 standard, grouped by value, "enemy_" dropped; build-data carries the same values', () => {
const src = `import sys, json\nsys.path.insert(0, ${JSON.stringify(TOOL)})\nimport enemy_scales as s\n`
+ `print(json.dumps(s.group_table({'enemy_1005_yokai_3': 0.16, 'enemy_1005_yokai': 0.2, 'enemy_1040_bombd': 0.2, 'enemy_10083_hlbird': 0.27, 'enemy_x': None})))`;
const r = spawnSync(PY, ['-c', src], { encoding: 'utf8', env: ENV });
assert.equal(r.status, 0, r.stderr);
const lines = JSON.parse(r.stdout);
assert.deepEqual(lines, [" [0.16, ['1005_yokai_3']],", " [0.2, ['1005_yokai', '1040_bombd']],"]);
const bd = readFileSync(path.join(ROOT, 'tools/build-data.mjs'), 'utf8');
assert.ok(bd.includes(lines[0].trim()), 'build-data MODEL_SCALES: 威龙 0.16');
});
});