import { test } from 'node:test'; import assert from 'node:assert/strict'; import { validatePack, assertValidPack } from '../src/engine/validate.js'; import { testPack } from './fixtures/test-pack.js'; /** A copy of the fixture with one thing broken. */ function broken(mutate) { const pack = structuredClone(testPack); mutate(pack); return validatePack(pack); } const messages = (result) => result.errors.join('\n'); test('the fixture pack is valid and warning-free', () => { const result = validatePack(testPack); assert.deepEqual(result.errors, []); assert.deepEqual(result.warnings, []); }); test('an effect writing an undeclared path is caught', () => { const result = broken((pack) => { pack.events[0].options[0].effects.push({ path: 'skills.wleding', op: 'add', value: 1 }); }); assert.match(messages(result), /skills\.wleding.*not declared/s); }); test('a relationship effect naming a character who does not exist is caught', () => { const result = broken((pack) => { pack.events[0].options[0].effects.push({ path: 'relationships.dave.likes_you', op: 'add', value: 1 }); }); assert.match(messages(result), /relationships\.dave\.likes_you.*not declared/s); }); test('a condition reading an undeclared path is caught', () => { const result = broken((pack) => { pack.events[0].options[1].requires = [{ path: 'skills.charisma', op: '>=', value: 1 }]; }); assert.match(messages(result), /skills\.charisma.*not declared/s); }); test('content may read engine bookkeeping but not write it', () => { const readsTurn = broken((pack) => { // Not the floor event — see the fallback rule below. pack.events.find((e) => e.id === 'audit').requires = [{ path: 'turn', op: '>=', value: 3 }]; }); assert.deepEqual(readsTurn.errors, []); const writesTurn = broken((pack) => { pack.events[0].options[0].effects.push({ path: 'turn', op: 'add', value: 5 }); }); assert.match(messages(writesTurn), /engine-owned state/); }); test('declaring a reserved root as pack state is caught', () => { const result = broken((pack) => { pack.state['history'] = { initial: [] }; }); assert.match(messages(result), /reserved root/); }); test('unknown operators are caught before they reach the engine', () => { const badCondition = broken((pack) => { pack.events[0].options[0].requires = [{ path: 'money', op: 'exceeds', value: 1 }]; }); assert.match(messages(badCondition), /unknown condition operator/); const badEffect = broken((pack) => { pack.events[0].options[0].effects[0].op = 'multiply'; }); assert.match(messages(badEffect), /unknown effect operation/); }); test('add with a non-numeric value is caught', () => { const result = broken((pack) => { pack.events[0].options[0].effects[0].value = 'ten'; }); assert.match(messages(result), /non-numeric/); }); test('duplicate ids are caught', () => { assert.match(messages(broken((pack) => { pack.events[1].id = 'routine'; })), /duplicate id/); assert.match(messages(broken((pack) => { pack.characters[1].id = 'chief'; })), /duplicate id/); assert.match(messages(broken((pack) => { pack.events[0].options[1].id = 'work'; })), /duplicate id/); }); test('an id that cannot be a path segment is caught, because ids become state paths', () => { const result = broken((pack) => { pack.events[0].id = 'routine day'; }); assert.match(messages(result), /must match/); }); test('an event with no options is caught', () => { const result = broken((pack) => { pack.events[0].options = []; }); assert.match(messages(result), /no options/); }); test('a zero weight is caught, since the event could never fire', () => { const result = broken((pack) => { pack.events[0].weight = 0; }); assert.match(messages(result), /greater than zero/); }); test('a stage with no unconditional event is caught, since the pool can run dry', () => { const result = broken((pack) => { pack.events[0].requires = [{ path: 'money', op: '>', value: 0 }]; }); assert.match(messages(result), /no unconditional event/); }); test('a starting stage with no events at all is caught', () => { const result = broken((pack) => { pack.startingStage = 'penthouse'; }); assert.match(messages(result), /startingStage "penthouse" has no events/); }); test('a character with no role_type warns but still plays', () => { const result = broken((pack) => { delete pack.characters[0].role_type; }); assert.deepEqual(result.errors, []); assert.match(result.warnings.join('\n'), /role_type/); }); test('assertValidPack throws with every error listed', () => { const pack = structuredClone(testPack); pack.events[0].options[0].effects.push({ path: 'nope', op: 'add', value: 1 }); assert.throws(() => assertValidPack(pack), /is invalid/); assert.doesNotThrow(() => assertValidPack(testPack)); }); test('upkeep is validated like any other effect list', () => { const badPath = broken((pack) => { pack.upkeep = [{ label: 'wage', effects: [{ path: 'monie', op: 'add', value: 20 }] }]; }); assert.match(messages(badPath), /monie.*not declared/s); const empty = broken((pack) => { pack.upkeep = [{ label: 'wage', effects: [] }]; }); assert.match(messages(empty), /has no effects/); const writesReserved = broken((pack) => { pack.upkeep = [{ effects: [{ path: 'turn', op: 'add', value: 1 }] }]; }); assert.match(messages(writesReserved), /engine-owned state/); }); test('a starting relationship for a stat that does not exist is caught', () => { const result = broken((pack) => { pack.characters[0].startingRelationship = { respects_you: 10 }; }); assert.match(messages(result), /unknown stat "respects_you"/); }); test('a malformed calendar is caught', () => { const badLength = broken((pack) => { pack.calendar = { cycleLength: 0 }; }); assert.match(messages(badLength), /cycleLength 0 must be a positive/); const mismatched = broken((pack) => { pack.calendar = { cycleLength: 7, unitNames: ['Mon', 'Tue'] }; }); assert.match(messages(mismatched), /unitNames has 2 entries but cycleLength is 7/); }); test('a malformed upkeep schedule is caught', () => { const badEvery = broken((pack) => { pack.upkeep = [{ label: 'rent', every: 0, effects: [{ path: 'money', op: 'add', value: -1 }] }]; }); assert.match(messages(badEvery), /every 0 must be a positive/); const orphanOffset = broken((pack) => { pack.upkeep = [{ label: 'rent', offset: 3, effects: [{ path: 'money', op: 'add', value: -1 }] }]; }); assert.deepEqual(orphanOffset.errors, []); assert.match(orphanOffset.warnings.join('\n'), /offset has no effect without every/); }); test('content may move the player between stages, but only to a real one', () => { const good = broken((pack) => { pack.events.push({ id: 'upstairs', stage: 'attic', weight: 1, description: 'Up you go.', options: [{ id: 'go', label: 'Go up.', effects: [] }], }); pack.events[0].options[0].effects.push({ path: 'stage', op: 'set', value: 'attic' }); }); assert.deepEqual(good.errors, []); const nowhere = broken((pack) => { pack.events[0].options[0].effects.push({ path: 'stage', op: 'set', value: 'penthouse' }); }); assert.match(messages(nowhere), /stage "penthouse", which has no events/); const wrongOp = broken((pack) => { pack.events[0].options[0].effects.push({ path: 'stage', op: 'add', value: 1 }); }); assert.match(messages(wrongOp), /may only be written with set/); });