Files
TheLadder/test/validate.test.js
JesseMarkowitzandClaude Opus 5 112e558dbb Fix repayments that overcharged, and act on the first Frontier playtest
A debt repayment took its whole instalment even when less was owed: four
dollars owed at the store cost twenty. Content could not express "pay what is
owed", so an add effect's amount may now be read from state, with a cap. Five
repayments across both packs use it, and a conformance test holds every pack to
it. The turn record now says which options were hidden, so the analyser stops
counting doors nobody saw, and it reads only feedback logs.

From the same session: wages are labelled as wages, a clamped change says where
it stopped, two unreadable option labels are rewritten, the sale barn no longer
offers what the player already owns, Ward hints at what the promotion waits for,
and the office has five more events. DECISIONS #38-40.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6UDQ9o6L6Ey173U7XVou6
2026-09-15 10:23:15 -04:00

205 lines
8.0 KiB
JavaScript

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('an amount read from state must read a declared path, with numeric scale and cap', () => {
const unknown = broken((pack) => {
pack.events[0].options[0].effects.push({ path: 'money', op: 'add', value: { from: 'skills.wleding', times: -1 } });
});
assert.match(messages(unknown), /reads its amount from "skills\.wleding".*not declared/s);
const badCap = broken((pack) => {
pack.events[0].options[0].effects.push({ path: 'money', op: 'add', value: { from: 'money', cap: 'lots' } });
});
assert.match(messages(badCap), /non-numeric cap/);
const fine = broken((pack) => {
pack.events[0].options[0].effects.push({ path: 'money', op: 'add', value: { from: 'money', times: -1, cap: 5 } });
});
assert.deepEqual(fine.errors, []);
});
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/);
});