const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const os = require('node:os'); const vm = require('node:vm'); const test = require('node:test'); function harness() { const storage = new Map(), edits = [], elements = new Map(); const source = fs.readFileSync(path.join(__dirname, '..', 'AnimationDirector.js'), 'utf8') .replace(' Plugin.register(PLUGIN_ID, {', ` globalThis.api = { loadParticleLibrary, particleJsonPaths, particleThumbnailRows, availableParticleLocators, applyLibraryParticle, showParticleLibrary, refreshSoundLibraryPanel, NON_PARTICLE_IDS, PARTICLE_SOURCE_KEY, buildParticlePreview, mapAnimationParticles, clearParticlePreviews, setParticlePreviewsEnabled, PARTICLE_PREVIEW_PROPERTY, PARTICLE_PREVIEW_PREFIX, cleanEffectPreviewFields }; Plugin.register(PLUGIN_ID, {`); class EffectAnimator { constructor(animation) { this.animation = animation; this.particle = []; } select() {} createKeyframe(data, time, channel) { const frame = { channel, time, animator: this, select() {}, extend(data) { this.data_points = data.data_points.map((point) => ({ ...point, getUndoCopy() { return { ...this }; } })); } }; frame.extend(data); this[channel].push(frame); return frame; } } const context = { Plugin: { register() {} }, Buffer, requireNativeModule: require, console, Format: { id: 'geckolib_model' }, localStorage: { getItem(key) { return storage.get(key); }, setItem(key, value) { storage.set(key, value); } }, Animation: { selected: { animators: {} }, all: [] }, EffectAnimator, Timeline: { time: 2, selected: [] }, Locator: { all: [] }, Undo: { initEdit(aspects) { edits.push({ before: aspects.keyframes.slice() }); }, finishEdit(label, aspects) { Object.assign(edits.at(-1), { label, after: aspects?.keyframes.slice() }); } }, Dialog: class { constructor(options) { context.dialog = options; } show() {} delete() {} }, Blockbench: { isWeb: false }, document: { getElementById(id) { return elements.get(id); }, createElement() { return { style: {}, addEventListener(event, callback) { this.click = callback; }, remove() { elements.delete(this.id); } }; }, }, }; elements.set('panel_keyframe', { appendChild(button) { elements.set(button.id, button); } }); vm.runInNewContext(source, context); return { ...context.api, context, storage, edits, elements }; } test('particle library includes all registered NoN particles without an asset source', () => { const h = harness(); const library = h.loadParticleLibrary(); const registry = fs.readFileSync(path.join(__dirname, '..', '..', 'NON', 'src', 'main', 'java', 'com', 'nonid', 'particle', 'NonParticles.java'), 'utf8'); const registered = [...registry.matchAll(/NeedsOfNature\.id\("([a-z_]+)"\)/g)].map((match) => `needsofnature:${match[1]}`).sort(); assert.deepEqual(Array.from(library.ids), registered); assert.equal(library.warnings.length, 0); }); test('particle library discovers nested IDs from asset folders and deduplicates built-ins', (t) => { const h = harness(), root = fs.mkdtempSync(path.join(os.tmpdir(), 'ad-particles-')); t.after(() => fs.rmSync(root, { recursive: true, force: true })); for (const name of ['minecraft/particles/heart.json', 'addon/particles/nested/spark.json', 'needsofnature/particles/smallheart.json', 'minecraft/textures/particle/not_a_particle.json']) { const file = path.join(root, 'assets', name); fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, '{"textures":[]}'); } h.storage.set(h.PARTICLE_SOURCE_KEY, path.join(root, 'assets')); const library = h.loadParticleLibrary(); assert.ok(library.ids.includes('minecraft:heart')); assert.ok(library.ids.includes('addon:nested/spark')); assert.equal(library.ids.filter((id) => id === 'needsofnature:smallheart').length, 1); assert.ok(library.ids.slice(0, h.NON_PARTICLE_IDS.length).every((id) => id.startsWith('needsofnature:')), 'NoN particles appear before every other namespace'); assert.deepEqual(Array.from(library.ids.slice(h.NON_PARTICLE_IDS.length)), ['addon:nested/spark', 'minecraft:heart'], 'Other particles remain alphabetically sorted'); assert.ok(!library.ids.some((id) => id.includes('not_a_particle'))); assert.equal(library.warnings.length, 0); const archive = { files: new Map(['assets/minecraft/particles/heart.json', 'assets/test/particles/sub/spark.json', 'assets/test/particles/../escape.json', 'assets/test/sounds.json'].map((name) => [name, {}])) }; const names = h.particleJsonPaths(archive, { fs, path }); assert.ok(names.includes('assets/test/particles/sub/spark.json')); // IDs are additionally validated before the catalog accepts archive entries. assert.equal(names.includes('assets/test/sounds.json'), false); }); test('applying a particle preserves locators and scripts while removing an unrelated preview file', () => { const h = harness(), animation = h.context.Animation.selected; const effects = new h.context.EffectAnimator(animation); const frame = effects.createKeyframe({ data_points: [{ effect: 'minecraft:heart', locator: 'actor1_d', script: 'custom', file: 'old.json' }, { effect: 'minecraft:smoke', locator: 'actor2_d' }] }, 1, 'particle'); h.context.Timeline.selected = [frame]; h.applyLibraryParticle('needsofnature:liquid_particle_falling'); assert.equal(frame.data_points[0].effect, 'needsofnature:liquid_particle_falling'); assert.equal(frame.data_points[0].locator, 'actor1_d'); assert.equal(frame.data_points[0].script, 'custom'); assert.equal(frame.data_points[0].file, ''); assert.equal(frame.data_points[1].effect, 'minecraft:smoke'); assert.equal(h.edits[0].before[0], frame); assert.equal(h.edits[0].label, 'Apply particle from library'); }); test('particle insertion uses the playhead and appends cues without overwriting existing ones', () => { const h = harness(); h.applyLibraryParticle('minecraft:heart', true); const frames = h.context.Animation.selected.animators.effects.particle; assert.equal(frames.length, 1); assert.equal(frames[0].time, 2); assert.equal(frames[0].channel, 'particle'); assert.equal(h.edits[0].before.length, 0); assert.equal(h.edits[0].after[0], frames[0]); frames[0].data_points[0].locator = 'actor1_d'; h.applyLibraryParticle('minecraft:smoke', true); assert.equal(frames.length, 1); assert.equal(frames[0].data_points.length, 2); assert.equal(frames[0].data_points[0].locator, 'actor1_d'); assert.equal(frames[0].data_points[1].effect, 'minecraft:smoke'); assert.equal(frames[0].data_points[1].file, undefined, 'No particle preview is assigned'); h.context.Animation.selected = null; assert.throws(() => h.applyLibraryParticle('minecraft:heart'), /Select an animation/); assert.throws(() => h.applyLibraryParticle('../escape'), /Invalid particle ID/); }); test('keyframe library buttons match their channels and do not duplicate on refresh', () => { const h = harness(), particleId = 'animationdirector_particle_library_button', soundId = 'animationdirector_sound_library_button'; for (const channels of [[], ['particle'], ['sound'], ['rotation'], ['particle', 'sound'], ['particle', 'particle']]) { h.context.Timeline.selected = channels.map((channel) => ({ channel })); h.refreshSoundLibraryPanel(); assert.equal(h.elements.has(particleId), channels.length > 0 && channels.every((channel) => channel === 'particle')); assert.equal(h.elements.has(soundId), channels.length > 0 && channels.every((channel) => channel === 'sound')); } const button = h.elements.get(particleId); h.refreshSoundLibraryPanel(); assert.equal(h.elements.get(particleId), button); button.click(); assert.equal(h.context.dialog.title, 'NoN Particle Library'); const component = h.context.dialog.component, data = component.data(); data.query = 'liquid'; data.category = 'needsofnature'; const rows = component.computed.rows.call(data); assert.ok(rows.length > 0 && rows.every((id) => id.startsWith('needsofnature:') && id.includes('liquid'))); assert.doesNotMatch(component.template, /@click="preview|@click="play/); h.context.Blockbench.isWeb = true; h.refreshSoundLibraryPanel(); assert.equal(h.elements.has(particleId), false); }); test('locator selector lists unique project locators and preselects the current cue locator', () => { const h = harness(), animation = h.context.Animation.selected; h.context.Locator.all = [{ name: 'actor2_d' }, { name: 'actor1_d' }, { name: 'actor2_d' }]; assert.deepEqual(Array.from(h.availableParticleLocators()), ['actor1_d', 'actor2_d']); h.showParticleLibrary(); let data = h.context.dialog.component.data(); assert.equal(data.locator, 'actor1_d', 'New cues default to the first available locator'); const effects = new h.context.EffectAnimator(animation); const frame = effects.createKeyframe({ data_points: [{ effect: 'minecraft:heart', locator: 'actor2_d' }] }, 1, 'particle'); h.context.Timeline.selected = [frame]; h.showParticleLibrary(); data = h.context.dialog.component.data(); assert.equal(data.locator, 'actor2_d'); data.selected = 'minecraft:smoke'; data.locator = 'actor1_d'; h.context.dialog.component.methods.apply.call(data, false); assert.equal(frame.data_points[0].locator, 'actor1_d'); assert.equal(frame.data_points[0].effect, 'minecraft:smoke'); assert.equal(data.error, ''); h.applyLibraryParticle('minecraft:heart', true, 'actor2_d'); assert.equal(animation.animators.effects.particle[0].data_points[0].locator, 'actor2_d'); h.context.Locator.all = []; assert.throws(() => h.applyLibraryParticle('minecraft:heart', false, 'actor2_d'), /no longer available/); }); test('mixed locator selections keep their existing locators unless explicitly changed', () => { const h = harness(), effects = new h.context.EffectAnimator(h.context.Animation.selected); h.context.Locator.all = [{ name: 'actor1_d' }, { name: 'actor2_d' }]; const first = effects.createKeyframe({ data_points: [{ effect: 'minecraft:heart', locator: 'actor1_d' }] }, 1, 'particle'); const second = effects.createKeyframe({ data_points: [{ effect: 'minecraft:heart', locator: 'actor2_d' }] }, 2, 'particle'); h.context.Timeline.selected = [first, second]; h.showParticleLibrary(); assert.equal(h.context.dialog.component.data().locator, ''); h.applyLibraryParticle('minecraft:smoke'); assert.equal(first.data_points[0].locator, 'actor1_d'); assert.equal(second.data_points[0].locator, 'actor2_d'); second.data_points[0].locator = ''; h.showParticleLibrary(); assert.equal(h.context.dialog.component.data().locator, '', 'A mix of assigned and unassigned cues also stays unchanged'); }); test('sprite thumbnails use the first texture, support cross-source assets and cache only image data', (t) => { const h = harness(), root = fs.mkdtempSync(path.join(os.tmpdir(), 'ad-particle-sprites-')); t.after(() => fs.rmSync(root, { recursive: true, force: true })); const write = (name, data) => { const file = path.join(root, name); fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, data); return file; }; const sprite = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aXaAAAAAASUVORK5CYII=', 'base64'); write('pack/assets/test/particles/spark.json', JSON.stringify({ textures: ['other:bright', 'other:second'] })); const png = write('sprites/assets/other/textures/particle/bright.png', sprite); write('pack/assets/test/particles/broken.json', '{ invalid json'); write('pack/assets/test/particles/unsafe.json', JSON.stringify({ textures: ['other:../escape'] })); write('pack/assets/test/particles/empty.json', JSON.stringify({ textures: [] })); h.storage.set(h.PARTICLE_SOURCE_KEY, path.join(root, 'pack')); h.storage.set('animationdirector_prop_source', path.join(root, 'sprites')); const library = h.loadParticleLibrary(); assert.equal(library.thumbnails.size, 0, 'Opening the catalog does not eagerly decode all sprites'); const rows = h.particleThumbnailRows(['test:spark', 'test:broken', 'test:unsafe', 'test:empty', 'needsofnature:smallheart'], library); assert.equal(rows[0].thumbnail, `data:image/png;base64,${sprite.toString('base64')}`); assert.ok(rows.slice(1).every((row) => row.thumbnail === '')); assert.equal(library.thumbnailBytes, sprite.length); fs.unlinkSync(png); assert.equal(h.particleThumbnailRows(['test:spark'], library)[0].thumbnail, rows[0].thumbnail); assert.equal(library.thumbnailBytes, sprite.length, 'Repeated renders do not grow the cache'); const refreshed = h.loadParticleLibrary(); assert.equal(h.particleThumbnailRows(['test:spark'], refreshed)[0].thumbnail, '', 'Refresh clears cached sprites'); }); test('sprite thumbnails enforce image dimensions and total cache limits', (t) => { const h = harness(), root = fs.mkdtempSync(path.join(os.tmpdir(), 'ad-particle-limits-')); t.after(() => fs.rmSync(root, { recursive: true, force: true })); const particles = path.join(root, 'assets/test/particles'), textures = path.join(root, 'assets/test/textures/particle'); fs.mkdirSync(particles, { recursive: true }); fs.mkdirSync(textures, { recursive: true }); fs.writeFileSync(path.join(particles, 'big.json'), JSON.stringify({ textures: ['test:big'] })); const bytes = Buffer.alloc(24); Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).copy(bytes); bytes.writeUInt32BE(2048, 16); bytes.writeUInt32BE(1, 20); fs.writeFileSync(path.join(textures, 'big.png'), bytes); h.storage.set(h.PARTICLE_SOURCE_KEY, root); const library = h.loadParticleLibrary(); assert.equal(h.particleThumbnailRows(['test:big'], library)[0].thumbnail, ''); assert.equal(library.thumbnailBytes, 0); library.thumbnails.clear(); library.thumbnailBytes = 8 * 1024 * 1024; assert.equal(h.particleThumbnailRows(['test:big'], library)[0].thumbnail, ''); assert.deepEqual(Array.from(h.particleThumbnailRows(['test:big'], null), (row) => row.thumbnail), ['']); }); function previewHarness(t) { const h = harness(), root = fs.mkdtempSync(path.join(os.tmpdir(), 'ad-particle-preview-')); t.after(() => fs.rmSync(root, { recursive: true, force: true })); const particles = path.join(root, 'assets/needsofnature/particles'), textures = path.join(root, 'assets/needsofnature/textures/particle'); fs.mkdirSync(particles, { recursive: true }); fs.mkdirSync(textures, { recursive: true }); const sprite = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aXaAAAAAASUVORK5CYII=', 'base64'); for (const name of ['smallheart', 'liquid_particle', 'liquid_particle_falling', 'ripped_fabric']) { fs.writeFileSync(path.join(particles, `${name}.json`), JSON.stringify({ textures: [`needsofnature:${name}`] })); fs.writeFileSync(path.join(textures, `${name}.png`), sprite); } h.storage.set(h.PARTICLE_SOURCE_KEY, root); h.context.Locator.all = [{ name: 'actor1_d' }]; h.context.WinterskyScene = {}; h.context.Wintersky = { Config: class { constructor(scene, values) { this.scene = scene; Object.assign(this, values); this.texture = { dispose() { this.disposed = true; } }; } setFromJSON(json) { this.json = json; return this; } } }; h.context.Animator = { particle_effects: {}, preview() {} }; const animation = h.context.Animation.selected; h.context.Animation.all = [animation]; const effects = animation.animators.effects = new h.context.EffectAnimator(animation); const add = (effect, locator = 'actor1_d', extra = {}) => effects.createKeyframe({ data_points: [{ effect, locator, ...extra }] }, 1, 'particle').data_points[0]; return { ...h, add, sprite }; } test('NoN preview presets are one-shot, world-space particles with a one-pixel spawn offset', () => { const h = harness(), png = Buffer.alloc(24); png.writeUInt32BE(16, 16); png.writeUInt32BE(80, 20); const texture = `data:image/png;base64,${png.toString('base64')}`; for (const id of ['liquid_particle', 'liquid_particle_falling', 'smallheart', 'ripped_fabric']) { const json = h.buildParticlePreview(`needsofnature:${id}`, texture), c = json.particle_effect.components; assert.equal(c['minecraft:emitter_rate_instant'].num_particles, 1); assert.ok(c['minecraft:emitter_lifetime_once'].active_time > 0); assert.equal(c['minecraft:emitter_local_space'].position, false); assert.equal(c['minecraft:emitter_shape_point'].offset[1], -1 / 16); if (id === 'smallheart') assert.equal(c['minecraft:particle_initial_speed'], 2); if (id === 'liquid_particle_falling') { assert.equal(c['minecraft:particle_motion_dynamic'].linear_acceleration[1], -2.4); assert.equal(c['minecraft:particle_appearance_billboard'].size[0], 0.12); } if (id === 'liquid_particle') { assert.match(c['minecraft:particle_motion_parametric'].relative_position[1], /particle_age < 1/); assert.match(c['minecraft:particle_motion_parametric'].relative_position[1], /-1\.2 \*/); assert.equal(c['minecraft:particle_appearance_billboard'].size[0], 0.12); } if (id === 'ripped_fabric') { assert.equal(c['minecraft:particle_appearance_billboard'].uv.flipbook.max_frame, 5); assert.equal(c['minecraft:particle_appearance_billboard'].size[0], 0.19); assert.equal(c['minecraft:particle_motion_dynamic'].linear_acceleration[1], -0.16); } } assert.equal(h.buildParticlePreview('minecraft:dust', texture), null); assert.equal(h.buildParticlePreview('needsofnature:smallheart', ''), null); }); test('native preview mapping preserves gameplay cues, locators and manual preview files', (t) => { const h = previewHarness(t); const heart = h.add('needsofnature:smallheart', 'actor1_d', { script: 'v.custom = 1;' }); const manual = h.add('needsofnature:smallheart', 'actor1_d', { file: 'manual.json' }); const missingLocator = h.add('needsofnature:liquid_particle', 'missing'); const unknown = h.add('minecraft:dust'); h.mapAnimationParticles(h.context.Animation.all); const key = `${h.PARTICLE_PREVIEW_PREFIX}needsofnature:smallheart`; const native = h.context.Animator.particle_effects[key]; assert.ok(native); assert.equal(native.config.scene, h.context.WinterskyScene); assert.equal(native.config.preview_texture, `data:image/png;base64,${h.sprite.toString('base64')}`); assert.equal(heart.file, key); assert.equal(heart.effect, 'needsofnature:smallheart'); assert.equal(heart.locator, 'actor1_d'); assert.equal(heart.script, 'v.custom = 1;'); assert.equal(manual.file, 'manual.json'); assert.ok(!missingLocator.file); assert.ok(!unknown.file); h.mapAnimationParticles(h.context.Animation.all); assert.equal(h.context.Animator.particle_effects[key], native, 'Repeated selection reuses the config'); heart.effect = 'needsofnature:liquid_particle_falling'; h.mapAnimationParticles(h.context.Animation.all); assert.equal(heart.file, `${h.PARTICLE_PREVIEW_PREFIX}needsofnature:liquid_particle_falling`); h.context.Locator.all = []; h.mapAnimationParticles(h.context.Animation.all); assert.equal(heart.file, '', 'Missing locators do not preview at the actor origin'); }); test('disabling and refreshing previews disposes emitters and regenerates saved virtual references', (t) => { const h = previewHarness(t), point = h.add('needsofnature:smallheart'); h.mapAnimationParticles(h.context.Animation.all); const key = point.file, native = h.context.Animator.particle_effects[key]; let deleted = 0; native.emitters.test = { delete() { deleted++; } }; h.setParticlePreviewsEnabled(false); assert.equal(deleted, 1); assert.equal(native.config.texture.disposed, true); assert.equal(h.context.Animator.particle_effects[key], undefined); assert.equal(point.file, ''); assert.equal(h.storage.get('animationdirector_particle_previews'), 'false'); point.file = key; point[h.PARTICLE_PREVIEW_PROPERTY] = key; h.mapAnimationParticles(h.context.Animation.all); assert.equal(point.file, '', 'Disabled previews remove saved virtual references'); h.setParticlePreviewsEnabled(true); assert.equal(point.file, key); const next = h.context.Animator.particle_effects[key]; h.loadParticleLibrary(); assert.equal(next.config.texture.disposed, true); assert.equal(point.file, ''); h.mapAnimationParticles(h.context.Animation.all); assert.equal(point.file, key); h.clearParticlePreviews(); assert.equal(point.file, ''); }); test('particle preview fields are removed from every exported cue without changing the source project', () => { const h = harness(), source = { particle_effects: { '0.1': [{ effect: 'needsofnature:smallheart', locator: 'actor1_d', script: 'test', file: 'virtual.json', [h.PARTICLE_PREVIEW_PROPERTY]: 'virtual.json' }], '0.2': { effect: 'needsofnature:liquid_particle', locator: 'actor2_d', file: 'virtual.json' } } }; const exported = h.cleanEffectPreviewFields(JSON.parse(JSON.stringify(source))); assert.equal(exported.particle_effects['0.1'][0].file, undefined); assert.equal(exported.particle_effects['0.1'][0][h.PARTICLE_PREVIEW_PROPERTY], undefined); assert.equal(exported.particle_effects['0.1'][0].locator, 'actor1_d'); assert.equal(exported.particle_effects['0.1'][0].script, 'test'); assert.equal(exported.particle_effects['0.2'].effect, 'needsofnature:liquid_particle'); assert.equal(source.particle_effects['0.1'][0].file, 'virtual.json'); });