1import test from 'node:test';2import assert from 'node:assert/strict';3import { LevelSchemaError, normalizeLevel, validateLevel } from '../src/level-schema.js';4import { createGame } from '../src/engine.js';5import { playableLevelFromView } from '../src/level-view.js';6import { loadMod } from '../src/mods.js';7import { decodeSharedPack, encodeSharedPack, exportCommonJS } from '../src/share.js';89test('normalizes legacy level shorthand into cloned canonical data', () => {10 const source = {11 width: 4,12 height: 1,13 objects: [{ text: 'miau', x: 0, y: 0 }, { noun: 'cat', x: 1, y: 0 }],14 automata: [{ id: 'patrol', property: 'tick', pattern: ['L', 'W', { action: 'move', dx: 1, dy: 0, cue: 'step', sound: 'move' }] }],15 };16 const canonical = normalizeLevel(source);17 assert.deepEqual(canonical.objects, [18 { kind: 'word', text: 'MIAU', x: 0, y: 0 },19 { kind: 'object', noun: 'CAT', x: 1, y: 0 },20 ]);21 assert.deepEqual(canonical.automata[0].pattern, [22 { action: 'move', dx: -1, dy: 0 },23 { action: 'wait' },24 { action: 'move', dx: 1, dy: 0, cue: 'step', sound: 'move' },25 ]);26 source.objects[0].text = 'ROCK';27 source.automata[0].pattern[0] = 'R';28 assert.equal(canonical.objects[0].text, 'MIAU');29 assert.deepEqual(canonical.automata[0].pattern[0], { action: 'move', dx: -1, dy: 0 });30});3132test('pack profile owns identity and size policy', () => {33 const level = { width: 1, height: 1, objects: [] };34 assert.deepEqual(validateLevel(level), []);35 const issues = validateLevel(level, { profile: 'pack', path: 'levels[0]' });36 assert.equal(issues[0].code, 'level-id');37 assert.equal(issues[0].path, 'levels[0].id');38});3940test('invalid vectors and unsupported fields expose stable issue codes', () => {41 const result = validateLevel({42 width: 2,43 height: 1,44 objects: [],45 automata: [{ id: 'clock', property: 'tick', pattern: [{ action: 'move', dx: 1, dy: 1, extra: true }] }],46 });47 assert.deepEqual(result.map(issue => issue.code), ['unsupported-field', 'automaton-vector']);48 assert.throws(() => normalizeLevel({ width: 2, height: 1, objects: [], automata: [{ id: 'clock', property: 'tick', pattern: ['Q'] }] }), error => {49 assert.ok(error instanceof LevelSchemaError);50 assert.equal(error.issues[0].code, 'automaton-action');51 return true;52 });53});5455test('rejects unknown level and object fields instead of dropping authored data', () => {56 const issues = validateLevel({ width: 1, height: 1, channel: 'gate', objects: [{ noun: 'CAT', x: 0, y: 0, leaves: true }] });57 assert.deepEqual(issues.map(issue => issue.path), ['level.channel', 'level.objects[0].leaves']);58 assert.deepEqual(issues.map(issue => issue.code), ['unsupported-field', 'unsupported-field']);59});6061test('rejects conflicting object fields and falsey explicit kinds', () => {62 const issues = validateLevel({ width: 2, height: 1, objects: [63 { kind: 'word', text: 'MIAU', noun: 'ROCK', x: 0, y: 0 },64 { kind: 'object', noun: 'MIAU', text: 'FLAG', x: 1, y: 0 },65 ] });66 assert.deepEqual(issues.map(issue => issue.path), ['level.objects[0].noun', 'level.objects[1].text']);67 assert.deepEqual(validateLevel({ width: 1, height: 1, objects: [{ kind: '', noun: 'MIAU', x: 0, y: 0 }] }).map(issue => issue.code), ['object-kind']);68});6970test('engine diagnostics retain the second automaton identity and canonical issue', () => {71 assert.throws(() => createGame({72 width: 1,73 height: 1,74 objects: [],75 automata: [76 { id: 'first', property: 'TICK', pattern: [{ action: 'wait' }] },77 { id: 'second', property: 'MOVE', pattern: [{ action: 'move', dx: 1, dy: 1 }] },78 ],79 }), error => {80 assert.equal(error.issueCode, 'automaton-vector');81 assert.equal(error.issuePath, 'level.automata[1].pattern[0]');82 assert.match(error.message, /automata second pattern\[0\]/);83 return true;84 });85});8687test('catalog and Gift view metadata stays outside the playable level boundary', () => {88 const authored = { id: 'core:proof', width: 1, height: 1, objects: [] };89 const view = {90 ...authored,91 globalId: 'core:proof', localId: 'proof', packId: 'core',92 packName: 'Miau Core', origin: 'bundled', palette: { MIAU: '🐈' },93 };94 assert.deepEqual(createGame(playableLevelFromView(view)).getState(), createGame(authored).getState());95 assert.equal(view.localId, 'proof');96 assert.deepEqual(validateLevel(playableLevelFromView({ ...view, unsupported: true })).map(issue => issue.path), ['level.unsupported']);97});9899test('direct, CommonJS, and shared levels agree on malformed coordinates', () => {100 const level = { id: 'broken', width: 1, height: 1, objects: [{ noun: 'MIAU', x: '0', y: 0 }] };101 const pack = { manifest: { id: 'schema-parity', name: 'Schema Parity', version: '1.0.0' }, levels: [level] };102 assert.throws(() => createGame(level), error => error.issueCode === 'coordinate-type' && error.issuePath === 'level.objects[0]');103 assert.throws(() => loadMod(`module.exports = ${JSON.stringify(pack)};`), error => {104 assert.equal(error.issues[0].code, 'coordinate-type');105 assert.equal(error.issues[0].path, 'levels[0].objects[0]');106 return true;107 });108 assert.throws(() => encodeSharedPack(pack), error => {109 assert.equal(error.issues[0].code, 'coordinate-type');110 assert.equal(error.issues[0].path, 'levels[0].objects[0]');111 return true;112 });113});114115test('legacy automaton shorthand round trips as canonical gameplay data', () => {116 const word = (text, x, y) => ({ text, x, y });117 const level = {118 id: 'patrol', title: 'Patrol', width: 7, height: 3,119 objects: [120 word('MIAU', 0, 0), word('IS', 1, 0), word('YOU', 2, 0),121 word('FENN', 0, 1), word('IS', 1, 1), word('TICK', 2, 1),122 { noun: 'MIAU', x: 4, y: 0 }, { noun: 'FENN', x: 4, y: 1 },123 ],124 automata: [{ id: 'fox', subject: 'FENN', property: 'tick', pattern: ['R', 'W', 'L'] }],125 };126 const pack = { manifest: { id: 'schema-patrol', name: 'Schema Patrol', version: '1.0.0' }, levels: [level] };127 const local = loadMod(`module.exports = ${JSON.stringify(pack)};`);128 const exported = loadMod(exportCommonJS(pack));129 const shared = decodeSharedPack(encodeSharedPack(pack));130 for (const candidate of [local, exported, shared]) {131 assert.deepEqual(candidate.levels[0].automata[0].pattern, [132 { action: 'move', dx: 1, dy: 0 }, { action: 'wait' }, { action: 'move', dx: -1, dy: 0 },133 ]);134 assert.deepEqual(createGame(candidate.levels[0]).getState(), createGame(level).getState());135 assert.deepEqual(createGame(candidate.levels[0]).wait().state, createGame(level).wait().state);136 }137});