added a way to auto import full animations
This commit is contained in:
@@ -0,0 +1,372 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const test = require('node:test');
|
||||
const vm = require('node:vm');
|
||||
|
||||
const plugin = fs.readFileSync(path.join(__dirname, '..', 'AnimationDirector.js'), 'utf8');
|
||||
const io = { fs, path };
|
||||
|
||||
function harness(capture = false) {
|
||||
const source = plugin.replace(' Plugin.register(PLUGIN_ID, {', `
|
||||
globalThis.api = { desktopFiles, importFilePath, insidePack, importResourceId, readImportPack, findImportPack, importPackRegistry,
|
||||
resolveImportModel, importModelCandidates, companionImportFiles, definitionActorKeys, defaultImportClipRoles,
|
||||
validateImportGeometry, automaticallyImportAnimation, loadImportTextures, commitAutomaticImport };
|
||||
Plugin.register(PLUGIN_ID, {`).replace(' function commitAutomaticImport(plan) {',
|
||||
` function commitAutomaticImport(plan) { ${capture ? 'globalThis.importedPlan = plan; return;' : ''}`);
|
||||
const storage = new Map(), dialogs = [], errors = [], undo = [], messages = [];
|
||||
let sequence = 0;
|
||||
class Group {
|
||||
static all = [];
|
||||
constructor(data) { Object.assign(this, data, { uuid: `group-${++sequence}`, children: [] }); }
|
||||
init() { Group.all.push(this); }
|
||||
addTo(parent) { this.parent = parent; if (typeof parent === 'object') parent.children.push(this); }
|
||||
}
|
||||
class Texture {
|
||||
static all = [];
|
||||
constructor(data) { Object.assign(this, data); }
|
||||
fromPath(file) { this.path = file; this.name = path.basename(file); return this; }
|
||||
add(undo) { assert.equal(undo, false); Texture.all.push(this); }
|
||||
}
|
||||
const context = {
|
||||
Project: { uuid: 'original' }, Group, Cube: { all: [] }, Texture, Outliner: { elements: [] },
|
||||
Plugin: { register() {} }, requireNativeModule: require,
|
||||
localStorage: { getItem: (key) => storage.get(key), setItem: (key, value) => storage.set(key, value) },
|
||||
Formats: { geckolib_model: { id: 'geckolib_model' } },
|
||||
newProject(format) {
|
||||
assert.equal(format.id, 'geckolib_model');
|
||||
context.Project = { uuid: 'new-project' };
|
||||
return true;
|
||||
},
|
||||
Undo: {
|
||||
initEdit(aspects) { undo.push({ type: 'begin', aspects }); },
|
||||
finishEdit(action, aspects) { undo.push({ type: 'finish', action, aspects }); },
|
||||
cancelEdit() { undo.push({ type: 'cancel' }); Group.all = []; Texture.all = []; context.Animation.all = []; },
|
||||
},
|
||||
Animator: {
|
||||
loadFile(file, names) {
|
||||
context.loadedAnimation = JSON.parse(file.content);
|
||||
context.Animation.all = names.map((name) => ({ name, select() {} }));
|
||||
},
|
||||
},
|
||||
Animation: { all: [] }, Canvas: { updateAll() {}, updateView() {} },
|
||||
setTimeout() { return 1; }, clearTimeout() {}, console,
|
||||
Blockbench: { showMessageBox(options) { errors.push(options.message); }, showQuickMessage(message) { messages.push(message); } },
|
||||
Filesystem: { importFile(options, callback) { callback(context.selectedFile ? [context.selectedFile] : []); } },
|
||||
Dialog: class {
|
||||
constructor(options) { this.options = options; }
|
||||
show() {
|
||||
dialogs.push(this.options);
|
||||
const result = Object.fromEntries(Object.entries(this.options.form).filter(([, value]) => 'value' in value)
|
||||
.map(([key, value]) => [key, value.value]));
|
||||
const choice = context.answer?.(this.options, result) ?? result;
|
||||
if (choice === false) this.options.onCancel();
|
||||
else this.options.onConfirm(choice);
|
||||
}
|
||||
},
|
||||
};
|
||||
vm.runInNewContext(source, context);
|
||||
return { ...context.api, context, storage, dialogs, errors, undo, messages };
|
||||
}
|
||||
|
||||
function fixtures(t) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'animationdirector-import-'));
|
||||
t.after(() => {
|
||||
assert.equal(path.dirname(root), fs.realpathSync(os.tmpdir()));
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
const write = (file, data) => {
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(file, typeof data === 'string' ? data : JSON.stringify(data));
|
||||
return file;
|
||||
};
|
||||
const makePack = (name, metadata = {}) => {
|
||||
const folder = path.join(root, name);
|
||||
write(path.join(folder, 'pack.mcmeta'), { animationframework: { id: `test:${name}`, ...metadata } });
|
||||
return harness().readImportPack(folder, io);
|
||||
};
|
||||
const model = (pack, id, description = {}) => {
|
||||
const [namespace, resource] = id.split(':');
|
||||
return write(path.join(pack.root, 'assets', namespace, 'geckolib', 'models', `${resource}.geo.json`),
|
||||
{ ...description, 'minecraft:geometry': [{ description: { texture_width: 64, texture_height: 64 }, bones: [{ name: 'body' }] }] });
|
||||
};
|
||||
return { root, write, makePack, model };
|
||||
}
|
||||
|
||||
function scopedFilesystem() {
|
||||
return Object.fromEntries(['existsSync', 'readFileSync', 'readdirSync', 'statSync']
|
||||
.map((method) => [method, fs[method].bind(fs)]));
|
||||
}
|
||||
|
||||
test('Blockbench scoped filesystem without realpathSync supports folder registration and resource lookup', (t) => {
|
||||
const f = fixtures(t), h = harness(), pack = f.makePack('source');
|
||||
h.context.requireNativeModule = (name) => name === 'fs' ? scopedFilesystem() : path;
|
||||
const restricted = h.desktopFiles();
|
||||
assert.equal(restricted.fs.realpathSync, undefined);
|
||||
const model = f.model(pack, 'test:source/entity/wolf.m');
|
||||
const source = h.readImportPack(pack.root, restricted);
|
||||
assert.equal(source.root, path.resolve(pack.root));
|
||||
assert.equal(h.findImportPack(model, restricted).root, source.root);
|
||||
assert.equal(h.resolveImportModel(source, [source], 'minecraft:wolf', 'm', '', restricted).file, model);
|
||||
assert.equal(h.importPackRegistry(source, [source.root], restricted).warnings.length, 0);
|
||||
assert.throws(() => h.insidePack(source.root, '../outside.json', restricted), /escapes/);
|
||||
});
|
||||
|
||||
test('scoped filesystem fallback rejects linked paths instead of bypassing containment checks', (t) => {
|
||||
const f = fixtures(t), h = harness(), pack = f.makePack('source');
|
||||
const link = path.join(pack.root, 'linked');
|
||||
const target = path.join(f.root, 'outside');
|
||||
fs.mkdirSync(target);
|
||||
fs.symlinkSync(target, link, process.platform === 'win32' ? 'junction' : 'dir');
|
||||
assert.throws(() => h.importFilePath(link, { fs: scopedFilesystem(), path }), /Linked paths/);
|
||||
});
|
||||
|
||||
test('denied filesystem permission reports an actionable message', () => {
|
||||
const h = harness();
|
||||
h.context.requireNativeModule = (name) => name === 'fs' ? undefined : path;
|
||||
assert.throws(() => h.desktopFiles(), /Allow filesystem access/);
|
||||
});
|
||||
|
||||
test('model lookup respects local/import/shared priority per gender candidate, not per provider', (t) => {
|
||||
const f = fixtures(t), h = harness();
|
||||
const source = f.makePack('source', { model_imports: [{ pack: 'test:provider', entities: ['minecraft:wolf'] }] });
|
||||
const provider = f.makePack('provider');
|
||||
f.model(source, 'test:source/entity/wolf');
|
||||
const imported = f.model(provider, 'test:provider/entity/wolf.m');
|
||||
const shared = f.model(provider, 'animationframework:entity/wolf.m');
|
||||
assert.equal(h.resolveImportModel(source, [source, provider], 'minecraft:wolf', 'm', '', io).file, imported);
|
||||
assert.equal(h.resolveImportModel(source, [source, provider], 'minecraft:wolf', 'm', '', io).kind, 'Imported test:provider');
|
||||
f.model(source, 'test:source/entity/wolf.m');
|
||||
assert.equal(h.resolveImportModel(source, [source, provider], 'minecraft:wolf', 'm', '', io).kind, 'Local');
|
||||
source.metadata.model_imports = [];
|
||||
fs.unlinkSync(path.join(source.root, 'assets/test/geckolib/models/source/entity/wolf.m.geo.json'));
|
||||
assert.equal(h.resolveImportModel(source, [source, provider], 'minecraft:wolf', 'm', '', io).file, shared);
|
||||
});
|
||||
|
||||
test('imports are entity-specific and non-transitive; shared overrides follow folder order', (t) => {
|
||||
const f = fixtures(t), h = harness();
|
||||
const source = f.makePack('source', { model_imports: [{ pack: 'test:b', entities: ['minecraft:wolf'] }] });
|
||||
const b = f.makePack('b', { model_imports: [{ pack: 'test:c', entities: ['minecraft:wolf'] }] });
|
||||
const c = f.makePack('c');
|
||||
f.model(c, 'test:c/entity/wolf.m');
|
||||
f.model(b, 'test:b/entity/horse.m');
|
||||
assert.equal(h.resolveImportModel(source, [source, b, c], 'minecraft:wolf', 'm', '', io), null);
|
||||
assert.equal(h.resolveImportModel(source, [source, b, c], 'minecraft:horse', 'm', '', io), null);
|
||||
const top = f.model(b, 'animationframework:entity/wolf.m');
|
||||
f.model(c, 'animationframework:entity/wolf.m');
|
||||
assert.equal(h.resolveImportModel(source, [source, b, c], 'minecraft:wolf', 'm', '', io).file, top);
|
||||
});
|
||||
|
||||
test('sparse default revisions resolve oldest applicable model before current shared models', (t) => {
|
||||
const f = fixtures(t), h = harness();
|
||||
const source = f.makePack('source');
|
||||
const defaults = f.makePack('defaults', { id: 'needsofnature:default_pack', default_model_revision: 4 });
|
||||
const old = f.model(defaults, 'needsofnature:default_pack_compat/revision_2/entity/horse.m');
|
||||
const later = f.model(defaults, 'needsofnature:default_pack_compat/revision_3/entity/horse.m');
|
||||
const current = f.model(defaults, 'animationframework:entity/horse.m');
|
||||
assert.equal(h.resolveImportModel(source, [source, defaults], 'minecraft:horse', 'm', '', io).file, old);
|
||||
source.metadata.default_model_revision = 3;
|
||||
assert.equal(h.resolveImportModel(source, [source, defaults], 'minecraft:horse', 'm', '', io).file, later);
|
||||
source.metadata.default_model_revision = 4;
|
||||
assert.equal(h.resolveImportModel(source, [source, defaults], 'minecraft:horse', 'm', '', io).file, current);
|
||||
});
|
||||
|
||||
test('variant, slim and male+female candidates use NoN fallback order, including legacy shared paths', (t) => {
|
||||
const f = fixtures(t), h = harness(), source = f.makePack('source');
|
||||
assert.deepEqual(Array.from(h.importModelCandidates('minecraft:wolf', 'mf', 'snow')), [
|
||||
'wolf_snow.mf', 'wolf.mf', 'wolf_snow', 'wolf', 'wolf_snow.m', 'wolf.m', 'wolf_snow.f', 'wolf.f',
|
||||
]);
|
||||
assert.equal(h.importModelCandidates('minecraft:player', 'f', '', true)[0], 'player_slim.f');
|
||||
const legacy = f.model(source, 'animationframework:wolf.f');
|
||||
assert.equal(h.resolveImportModel(source, [source], 'minecraft:wolf', 'm', '', io).file, legacy);
|
||||
});
|
||||
|
||||
test('companion lookup accepts either entry point and detects namespace alternatives without broad scans', (t) => {
|
||||
const f = fixtures(t), h = harness(), pack = f.makePack('source');
|
||||
const def = f.write(path.join(pack.root, 'data/test/afw_animdefs/example.json'), {});
|
||||
const animation = f.write(path.join(pack.root, 'assets/other/animations/afw/example.animation.json'), {});
|
||||
assert.equal(h.findImportPack(def, io).root, pack.root);
|
||||
assert.deepEqual(Array.from(h.companionImportFiles(pack, def, io).animations), [animation]);
|
||||
assert.deepEqual(Array.from(h.companionImportFiles(pack, animation, io).definitions), [def]);
|
||||
assert.throws(() => h.companionImportFiles(pack, path.join(pack.root, 'random.json'), io), /Choose data/);
|
||||
const nested = f.write(path.join(pack.root, 'assets/test/geckolib/animations/afw/nested/nested.animation.json'), {});
|
||||
const nestedDef = f.write(path.join(pack.root, 'data/test/afw_animdefs/nested.json'), {});
|
||||
assert.equal(h.companionImportFiles(pack, nested, io).id, 'nested');
|
||||
assert.deepEqual(Array.from(h.companionImportFiles(pack, nested, io).definitions), [nestedDef]);
|
||||
});
|
||||
|
||||
test('resource paths cannot escape packs, and invalid/cyclic models are rejected before importing', (t) => {
|
||||
const f = fixtures(t), h = harness(), pack = f.makePack('source');
|
||||
assert.throws(() => h.insidePack(pack.root, '../escape.json', io), /escapes/);
|
||||
for (const id of ['test:../file', 'test:a/../../file', 'test:/absolute', 'test:a//file']) assert.equal(h.importResourceId(id), null);
|
||||
assert.throws(() => h.validateImportGeometry({ 'minecraft:geometry': [{ bones: [{ name: 'a', parent: 'b' }, { name: 'b', parent: 'a' }] }] }), /Cyclic/);
|
||||
assert.throws(() => h.validateImportGeometry({ 'minecraft:geometry': [{ bones: [{ name: 'a' }, { name: 'a' }] }] }), /unique/);
|
||||
});
|
||||
|
||||
test('registry preserves explicit priority, inserts only new source first, and reports unavailable folders', (t) => {
|
||||
const f = fixtures(t), h = harness(), source = f.makePack('source'), top = f.makePack('top');
|
||||
assert.deepEqual(Array.from(h.importPackRegistry(source, [top.root, source.root], io).packs, (pack) => pack.root), [top.root, source.root]);
|
||||
assert.deepEqual(Array.from(h.importPackRegistry(source, [top.root], io).packs, (pack) => pack.root), [source.root, top.root]);
|
||||
assert.equal(h.importPackRegistry(source, [path.join(f.root, 'missing')], io).warnings.length, 1);
|
||||
});
|
||||
|
||||
test('actor keys match NoN including required tags, repeated roles and entity variants', () => {
|
||||
const h = harness();
|
||||
assert.deepEqual(Array.from(h.definitionActorKeys([
|
||||
{ entity_types: ['minecraft:wolf'], actor_tags: ['gender.male'] },
|
||||
{ entity_types: ['minecraft:wolf'], actor_tags: ['gender.male'] },
|
||||
{ label: 'Custom Role' },
|
||||
{ entity_types: ['minecraft:fox'], actor_tags_any: ['gender.male', 'gender.female'], entity_variant: 'snow', age: 'baby' },
|
||||
])), ['wolf_gender_male', 'wolf_gender_male_2', 'custom_role', 'fox_any_gender_female_gender_male_snow_baby']);
|
||||
});
|
||||
|
||||
test('short clip names are inferred only for unambiguous actors; repeated entities require explicit choices', () => {
|
||||
const h = harness();
|
||||
assert.deepEqual(Array.from(h.defaultImportClipRoles([
|
||||
{ entity_types: ['minecraft:player'] }, { entity_types: ['minecraft:wolf'], actor_tags: ['gender.male'] },
|
||||
], ['player', 'wolf'])), ['player', 'wolf']);
|
||||
assert.deepEqual(Array.from(h.defaultImportClipRoles([
|
||||
{ entity_types: ['minecraft:player'], actor_tags: ['gender.male'] },
|
||||
{ entity_types: ['minecraft:player'], actor_tags: ['gender.female'] },
|
||||
], ['player', 'player2'])), ['', '']);
|
||||
});
|
||||
|
||||
test('complete import prepares all stages, actor models, local textures and definition fields without touching the current project', async (t) => {
|
||||
const f = fixtures(t), h = harness(true), source = f.makePack('source');
|
||||
const def = { actors: [{ label: 'wolf', entity_types: ['minecraft:wolf', 'minecraft:fox'], actor_tags: ['gender.male'] }],
|
||||
animators: ['Creator'], content_tags: ['example'], stages: [{ stage: 1, non_peak: true }, { stage: 2 }] };
|
||||
const defFile = f.write(path.join(source.root, 'data/test/afw_animdefs/example.json'), def);
|
||||
f.write(path.join(source.root, 'assets/test/geckolib/animations/afw/example.animation.json'), { animations: {
|
||||
p1_wolf: { animation_length: 1, bones: { body: { rotation: [0, 0, 0] } } },
|
||||
p2_wolf: { animation_length: 1, bones: { body: { rotation: [1, 2, 3] } } },
|
||||
} });
|
||||
f.model(source, 'test:source/entity/wolf.m', { afw_texture: 'test:textures/wolf.png' });
|
||||
f.write(path.join(source.root, 'assets/test/textures/wolf.png'), 'local image');
|
||||
h.context.selectedFile = { path: defFile };
|
||||
h.context.requireNativeModule = (name) => name === 'fs' ? scopedFilesystem() : path;
|
||||
await h.automaticallyImportAnimation();
|
||||
assert.deepEqual(h.errors, []);
|
||||
assert.equal(h.context.Project.uuid, 'original');
|
||||
const plan = h.context.importedPlan;
|
||||
assert.equal(plan.rows[0].resolved.kind, 'Local');
|
||||
assert.equal(plan.rows[0].entity, 'minecraft:wolf');
|
||||
const actorForm = h.dialogs.find((dialog) => dialog.title.endsWith('Actors')).form;
|
||||
assert.equal(actorForm.entity_0.type, 'select');
|
||||
assert.equal(actorForm.entity_0.value, 'minecraft:wolf');
|
||||
assert.equal(actorForm.entity_0.options['minecraft:fox'], 'minecraft:fox');
|
||||
assert.equal(actorForm.gender_0.value, 'm');
|
||||
assert.equal(plan.rows[0].textures.size, 1);
|
||||
assert.deepEqual(Array.from(plan.imported.stageNames), ['example.p1', 'example.p2']);
|
||||
assert.ok(plan.imported.fileObj.animations['example.p2'].bones.actor1_body);
|
||||
assert.equal(plan.definition.actors[0].label, 'wolf');
|
||||
assert.equal(plan.definition.stages[1].non_peak, undefined);
|
||||
assert.equal(h.undo.length, 0);
|
||||
assert.ok(h.storage.get('animationdirector_pack_folders').includes(source.root.replace(/\\/g, '\\\\')));
|
||||
});
|
||||
|
||||
test('definition actors exclude extra clips, their effects and stages containing only unused actors', async (t) => {
|
||||
const f = fixtures(t), h = harness(true), source = f.makePack('source');
|
||||
const file = f.write(path.join(source.root, 'data/test/afw_animdefs/example.json'), {
|
||||
actors: [{ label: 'wolf', entity_types: ['minecraft:wolf'] }], stages: [{ stage: 1 }],
|
||||
});
|
||||
f.write(path.join(source.root, 'assets/test/animations/afw/example.animation.json'), { animations: {
|
||||
p1_unused: { animation_length: 99, bones: { discarded: { rotation: [1, 2, 3] } },
|
||||
sound_effects: { '0': { effect: 'discarded_sound' } }, particle_effects: { '0': { effect: 'discarded_particle' } } },
|
||||
p1_wolf: { animation_length: 1, bones: { body: { rotation: [0, 0, 0] } } },
|
||||
p2_unused: { animation_length: 99, bones: {} },
|
||||
} });
|
||||
f.model(source, 'test:source/entity/wolf.m');
|
||||
h.context.selectedFile = { path: file };
|
||||
h.context.answer = (dialog, result) => dialog.title.endsWith('Actors') ? { ...result, gender_0: 'm' } : result;
|
||||
await h.automaticallyImportAnimation();
|
||||
assert.deepEqual(h.errors, []);
|
||||
const plan = h.context.importedPlan;
|
||||
assert.equal(plan.rows.length, 1);
|
||||
assert.deepEqual(Array.from(plan.parsed.actorLabels), ['wolf']);
|
||||
assert.deepEqual(Array.from(plan.imported.stageNames), ['example.p1']);
|
||||
const clip = plan.imported.fileObj.animations['example.p1'];
|
||||
assert.equal(clip.animation_length, 1);
|
||||
assert.deepEqual(Object.keys(clip.bones), ['actor1_body']);
|
||||
assert.equal(clip.sound_effects, undefined);
|
||||
assert.equal(clip.particle_effects, undefined);
|
||||
assert.match(h.dialogs.at(-1).form.info.text, /Ignored animation actors.*unused/);
|
||||
});
|
||||
|
||||
test('a definition actor without a distinct clip still requires a valid mapping', async (t) => {
|
||||
const f = fixtures(t), h = harness(true), source = f.makePack('source');
|
||||
const file = f.write(path.join(source.root, 'data/test/afw_animdefs/example.json'), {
|
||||
actors: [{ label: 'wolf', entity_types: ['minecraft:wolf'] },
|
||||
{ label: 'player', entity_types: ['minecraft:player'] }], stages: [{ stage: 1 }],
|
||||
});
|
||||
f.write(path.join(source.root, 'assets/test/animations/afw/example.animation.json'), {
|
||||
animations: { p1_wolf: { animation_length: 1, bones: {} } },
|
||||
});
|
||||
h.context.selectedFile = { path: file };
|
||||
await h.automaticallyImportAnimation();
|
||||
assert.match(h.errors[0], /Each definition actor must be assigned a distinct animation actor/);
|
||||
assert.equal(h.context.importedPlan, undefined);
|
||||
assert.equal(h.context.Project.uuid, 'original');
|
||||
});
|
||||
|
||||
test('canceling actor review leaves the open project and saved folders unchanged', async (t) => {
|
||||
const f = fixtures(t), h = harness(true), source = f.makePack('source');
|
||||
const file = f.write(path.join(source.root, 'data/test/afw_animdefs/example.json'), {
|
||||
actors: [{ label: 'wolf', entity_types: ['minecraft:wolf'] }], stages: [{ stage: 1 }],
|
||||
});
|
||||
f.write(path.join(source.root, 'assets/test/animations/afw/example.animation.json'), {
|
||||
animations: { p1_wolf: { animation_length: 1, bones: {} } },
|
||||
});
|
||||
h.context.selectedFile = { path: file };
|
||||
h.context.answer = () => false;
|
||||
await h.automaticallyImportAnimation();
|
||||
assert.equal(h.context.Project.uuid, 'original');
|
||||
assert.equal(h.context.importedPlan, undefined);
|
||||
assert.equal(h.storage.size, 0);
|
||||
});
|
||||
|
||||
test('missing models can be supplied manually, and their locally supplied textures are not downloaded', async (t) => {
|
||||
const f = fixtures(t), h = harness(true), source = f.makePack('source');
|
||||
const file = f.write(path.join(source.root, 'data/test/afw_animdefs/example.json'), {
|
||||
actors: [{ label: 'wolf', entity_types: ['minecraft:wolf'] }], stages: [{ stage: 1 }],
|
||||
});
|
||||
f.write(path.join(source.root, 'assets/test/geckolib/animations/afw/example.animation.json'), {
|
||||
animations: { p1_wolf: { animation_length: 1, bones: { body: { position: [1, 2, 3] } } } },
|
||||
});
|
||||
const model = f.model(source, 'test:unrelated/wolf.m', { afw_texture: 'test:textures/unavailable.png' });
|
||||
h.context.selectedFile = { path: file };
|
||||
h.context.answer = (dialog, result) => dialog.title.endsWith('Actors') ? { ...result, gender_0: 'm' }
|
||||
: dialog.title.endsWith('Models') ? { ...result, model_0: { path: model } } : result;
|
||||
await h.automaticallyImportAnimation();
|
||||
assert.deepEqual(h.errors, []);
|
||||
assert.equal(h.context.importedPlan.rows[0].resolved.kind, 'Manually selected');
|
||||
assert.equal(h.context.importedPlan.rows[0].textures.size, 0);
|
||||
assert.match(h.dialogs.at(-1).form.info.text, /no base texture found locally/);
|
||||
});
|
||||
|
||||
test('batch import uses a new project, one undo entry, actor prefixes and texture override bindings', () => {
|
||||
const h = harness();
|
||||
const plan = { parsed: { animationId: 'example' }, blockEntry: null,
|
||||
definition: { actors: [{ label: 'wolf', entity_types: ['minecraft:wolf'] }], animators: ['Creator'], stages: [{ stage: 1 }] },
|
||||
imported: { stageNames: ['example.p1'], fileObj: { animations: { 'example.p1': { bones: { actor1_body: { rotation: [1, 2, 3] } } } } } },
|
||||
rows: [{ label: 'wolf', json: { afw_bone_textures: { body: 'test:textures/wolf.png' } },
|
||||
geo: { bones: [{ name: 'body' }] }, defaultTexture: 'test:textures/wolf.png',
|
||||
textures: new Map([['test:textures/wolf.png', { file: 'wolf.png' }]]) }] };
|
||||
h.commitAutomaticImport(plan);
|
||||
assert.equal(h.context.Project.uuid, 'new-project');
|
||||
assert.equal(h.context.Group.all[0].name, 'actor1_body');
|
||||
assert.deepEqual(h.undo.map((entry) => entry.type), ['begin', 'finish']);
|
||||
assert.equal(h.undo[1].aspects.automatic_animation_import, true);
|
||||
assert.equal(h.undo[1].aspects.groups.length, 1);
|
||||
const override = JSON.parse(h.context.Project.multiactor_bone_texture_overrides);
|
||||
assert.equal(override[h.context.Group.all[0].uuid].texture, 'test:textures/wolf.png');
|
||||
assert.ok(JSON.parse(h.context.Project.animationdirector_animdef_definitions).example);
|
||||
});
|
||||
|
||||
test('unexpected animation-load failures cancel the batch rather than keeping a partial import', () => {
|
||||
const h = harness();
|
||||
h.context.Animator.loadFile = () => { throw new Error('load failed'); };
|
||||
assert.throws(() => h.commitAutomaticImport({ parsed: { animationId: 'example' }, rows: [],
|
||||
definition: {}, imported: { fileObj: {}, stageNames: [] } }), /load failed/);
|
||||
assert.deepEqual(h.undo.map((entry) => entry.type), ['begin', 'cancel']);
|
||||
});
|
||||
@@ -7,7 +7,7 @@ const vm = require('node:vm');
|
||||
function harness() {
|
||||
const source = fs.readFileSync(path.join(__dirname, '..', 'AnimationDirector.js'), 'utf8').replace(
|
||||
' Plugin.register(PLUGIN_ID, {',
|
||||
' globalThis.blockTest = { readBlockRequirementForm, buildBlockRequirementGuideBoxes, stripBlockRequirementGuideBones, showAnimationDefinitionReviewDialog, rebuildBlockRequirementGuides, getAuthoredBlockRequirement, compileGeckoModelJson, createBlockRequirementPanel, hideInternalBlockGuideFaces, refreshBlockGuideFaces, getPanel: () => blockRequirementPanel };\n Plugin.register(PLUGIN_ID, {'
|
||||
' globalThis.blockTest = { readBlockRequirementForm, buildBlockRequirementGuideBoxes, stripBlockRequirementGuideBones, showAnimationDefinitionReviewDialog, rebuildBlockRequirementGuides, getAuthoredBlockRequirement, compileGeckoModelJson, createBlockRequirementPanel, hideInternalBlockGuideFaces, refreshBlockGuideFaces, prepareAnimationDefinitionImport, applyAnimationDefinitionImport, importAnimationDefinition, getSavedAnimdef, getPanel: () => blockRequirementPanel };\n Plugin.register(PLUGIN_ID, {'
|
||||
);
|
||||
const project = { uuid: 'project-one', multiactor_actor_names: JSON.stringify({ 1: 'player', 2: 'wolf' }) };
|
||||
const dialogs = [];
|
||||
@@ -18,6 +18,7 @@ function harness() {
|
||||
const previewEvents = new Map();
|
||||
const timers = new Map();
|
||||
const previewUpdates = [];
|
||||
const menus = [];
|
||||
let sequence = 0;
|
||||
class Group {
|
||||
static all = [];
|
||||
@@ -45,7 +46,7 @@ function harness() {
|
||||
}
|
||||
const context = {
|
||||
Project: project, Group, Cube, Plugin: { register(id, definition) { context.registration = definition; } },
|
||||
Action: class { constructor(id, options) { Object.assign(this, options); } },
|
||||
Action: class { constructor(id, options) { Object.assign(this, options, { id }); } },
|
||||
Panel: class {
|
||||
constructor(id, options) {
|
||||
this.options = options;
|
||||
@@ -55,7 +56,7 @@ function harness() {
|
||||
},
|
||||
setTimeout(callback) { const id = ++sequence; timers.set(id, callback); return id; },
|
||||
clearTimeout(id) { timers.delete(id); },
|
||||
MenuBar: { menus: { tools: { addAction() {} } }, update() {} },
|
||||
MenuBar: { menus: { tools: { addAction(menu) { menus.push(menu); } } }, update() {} },
|
||||
THREE: { MeshBasicMaterial: class { constructor(options) { Object.assign(this, options); } }, DoubleSide: 2 },
|
||||
Canvas: {
|
||||
updateAll() {
|
||||
@@ -85,7 +86,7 @@ function harness() {
|
||||
console,
|
||||
};
|
||||
vm.runInNewContext(source, context);
|
||||
return { ...context.blockTest, context, project, dialogs, exports, errors, edits, events, previewEvents, timers, previewUpdates, Group, Cube };
|
||||
return { ...context.blockTest, context, project, dialogs, exports, errors, edits, events, previewEvents, timers, previewUpdates, menus, Group, Cube };
|
||||
}
|
||||
|
||||
function wallForm(overrides = {}) {
|
||||
@@ -93,6 +94,139 @@ function wallForm(overrides = {}) {
|
||||
facing: '', clearance_width: 1, clearance_height: 2, clearance_depth: 1, wall_thickness: 1, ...overrides };
|
||||
}
|
||||
|
||||
function importAnalysis() {
|
||||
return { actorIndices: [1, 2], actorLabels: new Map([[1, 'player'], [2, 'wolf']]),
|
||||
stages: [{ stageNumber: 1, name: 'test.p1', loop: true, cycleSeconds: 1 },
|
||||
{ stageNumber: 2, name: 'test.p2', loop: false, cycleSeconds: 2 }], warnings: [] };
|
||||
}
|
||||
|
||||
test('animation menu orders import and creation actions and has no deprecated split-file tools', () => {
|
||||
const h = harness();
|
||||
h.context.registration.onload();
|
||||
const menu = h.menus.find((entry) => entry.id === 'animationdirector_anims');
|
||||
assert.deepEqual(Array.from(menu.children, (action) => action.name), [
|
||||
'Automatically Import Animation', 'Manual Import', 'Actor Preferences',
|
||||
'Replace Actor Model', 'Export Animations', 'Block Requirements', 'Create Animation Definition',
|
||||
]);
|
||||
assert.deepEqual(Array.from(menu.children[1].children, (action) => action.name), [
|
||||
'Import Actor', 'Import Animation', 'Import Animation Definition', 'Pack Folders',
|
||||
]);
|
||||
const source = fs.readFileSync(path.join(__dirname, '..', 'AnimationDirector.js'), 'utf8');
|
||||
assert.doesNotMatch(source, /importSplitActorAnimation|buildSplitImportFile|analyzeSplitAnimationFiles|exportSelectedActorAnimation|exportAllActorAnimations|animationdirector_deprecated/);
|
||||
});
|
||||
|
||||
test('import prefills generator fields, builds guides and retains fields without UI controls on export', () => {
|
||||
const h = harness();
|
||||
const source = {
|
||||
display_name: 'Imported animation', animators: ['Animator'], content_tags: ['test'],
|
||||
system_tags: ['manualpeak'], weight: 2.5, required_union_tags: ['example.tag'],
|
||||
join_after: ['example:previous'], match_content_on_join: true, attack_eligible: false,
|
||||
liquid_gain_multiplier: 0.5, manual_peak: { priority: 5 }, speed: 1.5,
|
||||
water: 'surface', position_anchor_actor: 'wolf',
|
||||
camera: { torso_tracking: 'continuous', block_requirement_collision: 'ignore' },
|
||||
addon_data: { example: { flag: true } }, escapable: false,
|
||||
actors: [
|
||||
{ label: 'player', entity_types: ['minecraft:player'], activity: 'passive', receiver: true, age: 'adult' },
|
||||
{ label: 'wolf', entity_types: ['minecraft:wolf'], actor_tags: ['gender.male'], activity: 'active',
|
||||
injector: 'V', injects_into: 'player', held_item: { items: ['#minecraft:swords'], hand: 'either', prop_slot: 'right' } },
|
||||
],
|
||||
stages: [
|
||||
{ stage: 1, loop: true, cycle_seconds: 0.75, allow_join: false, escapable: true,
|
||||
speed: 1.25, stage_duration_multiplier: 0.5, cycle_midpoint_offset_seconds: 0.1,
|
||||
props: { right: { item: 'minecraft:carrot' } } },
|
||||
{ stage: 2, loop: false, cycle_seconds: 1.75, allow_join: true, escapable: false, non_peak: true },
|
||||
],
|
||||
block_requirements: { type: 'wall', blocks: '#minecraft:fences', height: { min: 3 },
|
||||
width: { min: 2, max: 2, both_axes: true }, facing: 'front', clearance: { width: 1, height: 2, depth: 1 } },
|
||||
};
|
||||
h.applyAnimationDefinitionImport({ id: 'test' }, importAnalysis(), source);
|
||||
const dialog = h.dialogs.at(-1);
|
||||
const values = Object.fromEntries(Object.entries(dialog.form)
|
||||
.filter(([, field]) => field && typeof field === 'object' && 'value' in field).map(([key, field]) => [key, field.value]));
|
||||
assert.equal(values.display_name, source.display_name);
|
||||
assert.equal(values.animators, 'Animator');
|
||||
assert.equal(values.weight, 2.5);
|
||||
assert.equal(values.system_tags, 'manualpeak');
|
||||
assert.equal(values.manual_peak_priority, 5);
|
||||
assert.equal(values.stub_actor_1_receiver, true);
|
||||
assert.equal(values.stub_actor_2_injector, 'V');
|
||||
assert.deepEqual(Array.from(values.stub_actor_2_injects_into), ['player']);
|
||||
assert.equal(values.stub_actor_2_held_items, '#minecraft:swords');
|
||||
assert.equal(values.stub_actor_2_held_hand, 'either');
|
||||
assert.equal(values.stub_stage_1_duration_multiplier, 0.5);
|
||||
assert.equal(values.stub_stage_1_cycle_seconds, 0.75);
|
||||
assert.equal(values.stub_stage_2_peaked, true);
|
||||
assert.ok(h.Cube.all.length > 0);
|
||||
assert.equal(h.edits.length, 1);
|
||||
assert.equal(h.edits[0].action, 'Import animation definition');
|
||||
assert.equal(h.edits[0].aspects.animdef_edit, true);
|
||||
assert.equal(dialog.onConfirm(values), true);
|
||||
const exported = h.exports.at(-1);
|
||||
assert.deepEqual(exported.stages[0].props, source.stages[0].props);
|
||||
assert.deepEqual(exported.addon_data, source.addon_data);
|
||||
assert.equal(exported.actors[0].age, 'adult');
|
||||
assert.equal(exported.actors[1].injects_into, 'player');
|
||||
assert.equal(exported.block_requirements.width.both_axes, true);
|
||||
assert.equal(h.errors.length, 0);
|
||||
});
|
||||
|
||||
test('import maps explicit labels before positional fallbacks and updates actor references', () => {
|
||||
const h = harness();
|
||||
const source = { actors: [{ label: 'wolf', injector: 'V', injects_into: 'bottom' },
|
||||
{ label: 'bottom', receiver: true }], stages: [{ stage: 1 }, { stage: 2 }], position_anchor_actor: 'bottom' };
|
||||
const definition = h.prepareAnimationDefinitionImport(source, importAnalysis());
|
||||
assert.equal(definition.actors[0].label, 'player');
|
||||
assert.equal(definition.actors[0].receiver, true);
|
||||
assert.equal(definition.actors[1].label, 'wolf');
|
||||
assert.deepEqual(Array.from(definition.actors[1].injects_into), ['player']);
|
||||
assert.equal(definition.position_anchor_actor, 'player');
|
||||
assert.equal(source.actors[1].label, 'bottom', 'Input JSON is not mutated');
|
||||
const positional = h.prepareAnimationDefinitionImport({ actors: [{ receiver: true }, { injector: 'V' }],
|
||||
stages: [{ stage: 1 }, { stage: 2 }] }, importAnalysis());
|
||||
assert.equal(positional.actors[0].label, 'player');
|
||||
assert.equal(positional.actors[1].label, 'wolf');
|
||||
});
|
||||
|
||||
test('invalid imports leave definitions and guides unchanged', () => {
|
||||
const h = harness();
|
||||
h.rebuildBlockRequirementGuides('test', h.readBlockRequirementForm(wallForm()));
|
||||
const before = h.project.animationdirector_block_requirements;
|
||||
const source = { actors: [{}, {}], stages: [{ stage: 1 }, { stage: 2 }] };
|
||||
for (const invalid of [null, { animations: {} }, { ...source, actors: [{}] },
|
||||
{ ...source, stages: [{ stage: 1 }] }, { ...source, stages: [{ stage: 1 }, { stage: 1 }] },
|
||||
{ ...source, actors: [{ label: 'same' }, { label: 'same' }] },
|
||||
{ ...source, actors: [{ injects_into: 'unknown' }, {}] },
|
||||
{ ...source, content_tags: 'wrong' },
|
||||
{ ...source, block_requirements: { type: 'wall', width: { min: 2, max: 1 } } }]) {
|
||||
assert.throws(() => h.applyAnimationDefinitionImport({ id: 'test' }, importAnalysis(), invalid));
|
||||
}
|
||||
assert.equal(h.project.animationdirector_block_requirements, before);
|
||||
assert.equal(h.project.animationdirector_animdef_definitions, undefined);
|
||||
assert.equal(h.edits.length, 1);
|
||||
});
|
||||
|
||||
test('import without a requirement removes old guides and undo restores definition metadata too', () => {
|
||||
const h = harness();
|
||||
h.context.registration.onload();
|
||||
h.rebuildBlockRequirementGuides('test', h.readBlockRequirementForm(wallForm()));
|
||||
const before = {};
|
||||
h.events.get('create_undo_save')({ save: before, aspects: { block_requirement_edit: true, animdef_edit: true } });
|
||||
h.applyAnimationDefinitionImport({ id: 'test' }, importAnalysis(), {
|
||||
actors: [{}, {}], stages: [{ stage: 1 }, { stage: 2 }], animation_tags: ['defeat'] });
|
||||
assert.equal(h.Cube.all.length, 0);
|
||||
assert.equal(h.getAuthoredBlockRequirement('test').requirement, null);
|
||||
assert.deepEqual(Array.from(h.getSavedAnimdef('test').system_tags), ['defeat']);
|
||||
assert.equal(h.dialogs.at(-1).form.stub_stage_2_peaked.value, false, 'Missing peak flags are not invented');
|
||||
const after = {};
|
||||
h.events.get('create_undo_save')({ save: after, aspects: { block_requirement_edit: true, animdef_edit: true } });
|
||||
h.events.get('load_undo_save')({ save: before });
|
||||
assert.equal(h.getAuthoredBlockRequirement('test').requirement.type, 'wall');
|
||||
assert.equal(Object.keys(h.getSavedAnimdef('test')).length, 0);
|
||||
h.events.get('load_undo_save')({ save: after });
|
||||
assert.equal(h.getAuthoredBlockRequirement('test').requirement, null);
|
||||
assert.equal(h.getSavedAnimdef('test').actors.length, 2);
|
||||
});
|
||||
|
||||
test('building, editing, refreshing and removing guides never resets actor or folder texture overrides', () => {
|
||||
const h = harness();
|
||||
const actor = new h.Group({ name: 'actor1_body' });
|
||||
|
||||
Reference in New Issue
Block a user