Add the interface, then rebuild the economy around the first playtest

The MVP is playable: a turn screen, a career retrospective, an optional
feedback widget, local-storage autosave with file export/import, and a build
that flattens everything into one self-contained HTML file.

A turn has two phases — the scenario and its options, then what the choice did
and, separately, what the day cost regardless. Numbers that move without the
player seeing why are most of what makes them meaningless. Locked options are
shown greyed with the requirement spelled out rather than hidden, so a player
can see the door they cannot open yet.

The interface is named entirely by the pack. `pack.display` maps a state path to
a label, a format and an order, which is why the header reads "Standing" for a
path called `reputation` and why debt disappears while it is zero.

The first playtest then found three things, all of them fair.

*"Wasn't clear what daily drain was for."* The ledger was labelling changes by
the path that moved — "Money −$18" — when the upkeep entry that caused them had
a perfectly good name. It now says "Coffee, transit, lunch". The labels were in
the data the whole time and never reached the screen.

*"Should probably pay rent weekly / get paid every other week… would be good to
have day of week and week # shown."* Packs can now declare a calendar, and the
engine derives the day name, cycle and index from the turn number before the
event is drawn — so content can require a Friday and the header can say
"Thursday · Week 2 · Day 12". Upkeep entries take `every` and `offset`, making
wages fall on alternate Fridays and rent every Monday. The turn screen carries
a diary line — "Wages tomorrow · Rent in 4 days" — computed generically from
whatever a pack schedules.

*"Always in debt — could never get ahead."* The old economy bled $25 a day
regardless of play. It is now roughly break-even at baseline, with a
`spare_shift` event that appears *because* you are broke: the way out of a hole
should be visible from inside it. Across archetypes a careful player ends around
$1,400, an unplanned one treads water, and a careless one sinks into real debt.

That retune broke the coverage test, usefully. Random play stopped reaching hard
times at all, which made the entire debt branch look dead — it was not, since
random play is not a plausible player. Coverage is now the union across five
archetypes, including one that is broke *and* well-liked, because the "ask a
friend for money" options sit in a state no single-axis strategy reaches.

Not covered by any of this: styling and dark mode, which only a human with a
browser can check. This machine has none that can render a page.

109 tests. Reasoning in docs/DECISIONS.md §14-21.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VMSFHyVPitUoosW5wyEADj
This commit is contained in:
JesseMarkowitz
2026-09-10 04:44:49 -04:00
co-authored by Claude Opus 5
parent 1d4ac0fdbd
commit e482f07157
29 changed files with 2613 additions and 63 deletions
+47
View File
@@ -0,0 +1,47 @@
// The single-file build.
//
// A build that produces a file proves nothing; this one runs the bundled
// script against the fake DOM and plays a turn through it. That is the closest
// thing to opening dist/theladder.html that is available without a browser —
// it catches a broken bundle, which is otherwise only discovered by whoever
// was handed the file.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { installFakeDom, byClass, firstByClass } from './fixtures/fake-dom.js';
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
test('the bundle builds, and the bundled game boots and plays', () => {
execFileSync('node', ['tools/build.js'], { cwd: root, stdio: 'pipe' });
const html = readFileSync(resolve(root, 'dist/theladder.html'), 'utf8');
assert.doesNotMatch(html, /<link\s+rel="stylesheet"/, 'the stylesheet is inlined');
assert.doesNotMatch(html, /type="module"/, 'no module scripts survive; file:// would block them');
assert.doesNotMatch(html, /^[ \t]*import\s/m, 'no bare import statements remain');
assert.match(html, /--paper:/, 'the CSS made it in');
const script = html.match(/<script>([\s\S]*?)<\/script>/)?.[1];
assert.ok(script, 'the bundled script is present');
const dom = installFakeDom();
try {
// eslint-disable-next-line no-new-func -- running the build output is the point
new Function(script)();
assert.ok(firstByClass(dom.root, 'screen--turn'), 'the bundled game rendered a turn');
assert.match(dom.root.textContent, /Day 1/);
assert.match(dom.root.textContent, /Marlene Hobb/);
const playable = byClass(dom.root, 'option').filter((b) => !b.disabled);
assert.ok(playable.length > 0, 'the bundled game offers choices');
playable[0].click();
assert.ok(firstByClass(dom.root, 'outcome'), 'a turn can be played in the bundle');
} finally {
dom.restore();
}
});
+73 -28
View File
@@ -17,6 +17,7 @@ import { corporateLadder as pack } from '../content/corporateladder/index.js';
import { validatePack } from '../src/engine/validate.js';
import { startGame, currentTurn, takeTurn } from '../src/engine/game.js';
import { createRng } from '../src/engine/rng.js';
import { play, sweep, median } from './fixtures/strategies.js';
/** Play `turns` turns with independently-seeded random choices. */
function randomRun(runSeed, turns, visit = () => {}) {
@@ -39,15 +40,11 @@ test('the pack is valid, with no warnings', () => {
assert.deepEqual(result.warnings, []);
});
test('no content is dead: every event fires and every option can be taken', () => {
const firedEvents = new Set();
const takenOptions = new Set();
for (let seed = 0; seed < 60; seed++) {
randomRun(seed, 200, (turn, _state, choice) => {
firedEvents.add(turn.event.id);
takenOptions.add(`${turn.event.id}.${choice}`);
});
}
test('no content is dead: every event and option is reachable by some player', () => {
// Across archetypes, not just random play: several things in the pack exist
// for a player pursuing something, and "broke but well-liked" in particular
// is a state no single-axis strategy ever reaches.
const { firedEvents, takenOptions } = sweep(pack, { seeds: 12, turns: 200 });
const allEvents = pack.events.map((e) => e.id);
const allOptions = pack.events.flatMap((e) => e.options.map((o) => `${e.id}.${o.id}`));
@@ -55,24 +52,67 @@ test('no content is dead: every event fires and every option can be taken', () =
assert.deepEqual(allOptions.filter((o) => !takenOptions.has(o)), [], 'options that can never be taken');
});
test('how you play changes where you end up', () => {
const { byStrategy } = sweep(pack, { seeds: 12, turns: 200 });
const finalMoney = (name) => median(byStrategy[name].map((s) => s.money));
// The shape that matters: minding the money climbs, ignoring it sinks, and
// playing without a plan roughly treads water. Exact figures will move with
// tuning; the ordering is the design.
assert.ok(finalMoney('thrifty') > finalMoney('random'),
'a careful player should get ahead');
assert.ok(finalMoney('random') > finalMoney('spendthrift'),
'a careless player should struggle');
assert.ok(finalMoney('thrifty') > 800,
`a careful player should be comfortably ahead, was ${finalMoney('thrifty')}`);
});
test('hard times is uncommon for a careful player and real for a careless one', () => {
const countHardTimes = (strategy) => {
let days = 0;
play(pack, { strategy, seed: 'ht', turns: 200,
visit: ({ turn }) => { if (turn.event.id === 'rent_bounced') days++; } });
return days;
};
assert.equal(countHardTimes('thrifty'), 0, 'minding the money should keep you out of trouble');
assert.ok(countHardTimes('spendthrift') > 0, 'ignoring it should land you in it');
});
test('every turn always offers something to do, however badly the run is going', () => {
// randomRun asserts this on every turn; 40 long runs is the coverage.
for (let seed = 100; seed < 140; seed++) randomRun(seed, 150);
});
test('the economy bleeds, so doing nothing about money eventually bites', () => {
const turns = [];
for (let seed = 0; seed < 30; seed++) {
let firstHardTimes = null;
randomRun(seed, 120, (turn, state) => {
if (turn.event.id === 'rent_bounced' && firstHardTimes === null) firstHardTimes = state.turn;
});
assert.ok(firstHardTimes, `run ${seed} never hit hard times in 120 turns`);
turns.push(firstHardTimes);
test('money arrives and leaves on a rhythm the player can plan around', () => {
const wages = [];
const rent = [];
let state = startGame(pack, { seed: 'rhythm' });
for (let t = 0; t < 30; t++) {
const turn = currentTurn(pack, state);
const result = takeTurn(pack, state, turn.options.find((o) => o.available).option.id);
for (const change of result.record.upkeep) {
if (change.label === 'Wages') wages.push(state.turn);
if (change.label === 'Rent') rent.push(state.turn);
}
state = result.state;
}
turns.sort((a, b) => a - b);
const median = turns[Math.floor(turns.length / 2)];
assert.ok(median > 10 && median < 45, `hard times should land mid-run, median was turn ${median}`);
assert.deepEqual(wages, [5, 19], 'paid on alternate Fridays');
assert.deepEqual(rent, [8, 15, 22, 29], 'rent every Monday from the second week');
});
test('the calendar names the day and counts the week', () => {
let state = startGame(pack, { seed: 'calendar' });
assert.deepEqual(
[state.calendar.name, state.calendar.cycle, state.calendar.index],
['Monday', 1, 0],
);
for (let t = 0; t < 11; t++) {
const turn = currentTurn(pack, state);
state = takeTurn(pack, state, turn.options.find((o) => o.available).option.id).state;
}
assert.equal(state.turn, 12);
assert.deepEqual([state.calendar.name, state.calendar.cycle], ['Friday', 2]);
});
test('hard times is survivable without any relationship or skill', () => {
@@ -84,17 +124,22 @@ test('hard times is survivable without any relationship or skill', () => {
test('the run recovers from hard times rather than sticking there', () => {
let sawNegative = false;
let recovered = false;
randomRun(7, 200, (_turn, state) => {
if (state.money < 0) sawNegative = true;
else if (sawNegative) recovered = true;
});
assert.ok(sawNegative && recovered, 'money should go negative and come back');
play(pack, { strategy: 'spendthrift', seed: 'recovery', turns: 200,
visit: ({ state }) => {
if (state.money < 0) sawNegative = true;
else if (sawNegative) recovered = true;
} });
assert.ok(sawNegative, 'a careless run should go broke');
assert.ok(recovered, 'and should be able to climb back out');
});
test('debt cannot spiral without limit: lending stops and the charge escalates', () => {
let peakDebt = 0;
for (let seed = 0; seed < 40; seed++) {
randomRun(seed, 200, (_turn, state) => { peakDebt = Math.max(peakDebt, state.debt); });
for (const strategy of ['spendthrift', 'desperate', 'random']) {
for (let seed = 0; seed < 12; seed++) {
play(pack, { strategy, seed, turns: 200,
visit: ({ state }) => { peakDebt = Math.max(peakDebt, state.debt); } });
}
}
// The loan is gated at debt < 2000 and lends 500, so 2500 plus stalled
// interest is the ceiling. Well above that means the gate has stopped working.
+162
View File
@@ -0,0 +1,162 @@
// A minimal DOM, enough to drive the UI headlessly.
//
// Not a browser and not a substitute for clicking the real thing — it exists
// so that the wiring (imports, handlers, the render/choose/log path) is
// exercised by the test suite rather than discovered by a player. Anything
// involving real files or real downloads is out of its scope and has to be
// checked by hand.
class FakeNode {
constructor() {
this.childNodes = [];
this.parentNode = null;
}
get textContent() {
return this.childNodes.map((child) => child.textContent).join('');
}
}
class FakeText extends FakeNode {
constructor(text) {
super();
this.nodeValue = String(text);
}
get textContent() {
return this.nodeValue;
}
}
class FakeElement extends FakeNode {
constructor(tagName) {
super();
this.tagName = tagName.toUpperCase();
this.className = '';
this.id = '';
this.attributes = {};
this.listeners = {};
this.dataset = {};
this.style = '';
// Predefined so `key in el` behaves the way it does in a real element:
// these are set as properties, everything else falls through to attributes.
this.disabled = false;
this.value = '';
this.rows = 0;
this.type = '';
this.accept = '';
this.href = '';
this.download = '';
this.files = null;
}
append(...nodes) {
for (const node of nodes) {
node.parentNode = this;
this.childNodes.push(node);
}
}
replaceChildren(...nodes) {
this.childNodes = [];
this.append(...nodes);
}
remove() {
const siblings = this.parentNode?.childNodes;
if (siblings) siblings.splice(siblings.indexOf(this), 1);
}
setAttribute(name, value) { this.attributes[name] = String(value); }
getAttribute(name) { return this.attributes[name] ?? null; }
addEventListener(type, handler) {
(this.listeners[type] ??= []).push(handler);
}
dispatch(type, event = {}) {
for (const handler of this.listeners[type] ?? []) handler({ target: this, ...event });
}
click() { this.dispatch('click'); }
}
function walk(node, out = []) {
out.push(node);
for (const child of node.childNodes) if (child instanceof FakeElement) walk(child, out);
return out;
}
/** Every element under `root` whose class list contains `className`. */
export function byClass(root, className) {
return walk(root).filter((el) => el.className.split(/\s+/).includes(className));
}
/** The first such element, or undefined. */
export function firstByClass(root, className) {
return byClass(root, className)[0];
}
/** Every element of a tag. */
export function byTag(root, tagName) {
return walk(root).filter((el) => el.tagName === tagName.toUpperCase());
}
class FakeStorage {
constructor() { this.map = new Map(); }
getItem(key) { return this.map.has(key) ? this.map.get(key) : null; }
setItem(key, value) { this.map.set(key, String(value)); }
removeItem(key) { this.map.delete(key); }
clear() { this.map.clear(); }
}
/**
* Install the fake DOM as globals and return a handle for the test. Call the
* returned `restore` afterwards so suites do not leak into each other.
*/
export function installFakeDom() {
const root = new FakeElement('div');
root.id = 'app';
const document = {
createElement: (tag) => new FakeElement(tag),
createTextNode: (text) => new FakeText(text),
getElementById: (id) => (id === 'app' ? root : null),
body: new FakeElement('body'),
};
const listeners = {};
const window = {
addEventListener: (type, handler) => { (listeners[type] ??= []).push(handler); },
};
const previous = {};
const globals = {
document,
window,
Node: FakeNode,
localStorage: new FakeStorage(),
requestAnimationFrame: (fn) => setTimeout(fn, 0),
confirm: () => true,
alert: () => {},
};
for (const [key, value] of Object.entries(globals)) {
previous[key] = globalThis[key];
globalThis[key] = value;
}
return {
document,
window,
root,
storage: globals.localStorage,
/** Fire a window event, e.g. beforeunload. */
fireWindow: (type) => { for (const handler of listeners[type] ?? []) handler({}); },
restore() {
for (const [key, value] of Object.entries(previous)) {
if (value === undefined) delete globalThis[key];
else globalThis[key] = value;
}
},
};
}
export { FakeElement, FakeText, FakeNode };
+87
View File
@@ -0,0 +1,87 @@
// Player archetypes for content probing.
//
// "Is this content reachable?" is not the same question as "does random play
// reach it?" — a random player is not a plausible player, and several things
// in a pack exist precisely for a player pursuing something. Coverage is
// therefore the union across archetypes, and each one is a rough caricature of
// a way somebody actually plays.
//
// NOTE: seed the chooser differently from the game. Both draw one number per
// turn from the same generator, so a shared seed correlates the choice with
// the event draw and silently makes combinations unreachable.
import { startGame, currentTurn, takeTurn } from '../../src/engine/game.js';
import { createRng } from '../../src/engine/rng.js';
const moneyGain = (resolved) => (resolved.option.effects ?? [])
.filter((e) => e.path === 'money' && e.op === 'add')
.reduce((sum, e) => sum + e.value, 0);
const relationshipGain = (resolved) => (resolved.option.effects ?? [])
.filter((e) => e.path.startsWith('relationships.'))
.reduce((sum, e) => sum + e.value, 0);
const best = (options, score) => [...options].sort((a, b) => score(b) - score(a))[0];
export const strategies = {
/** No plan at all. */
random: (options, rng) => rng.pick(options),
/** Takes the money every time. */
thrifty: (options) => best(options, moneyGain),
/** Spends it every time. */
spendthrift: (options) => best(options, (o) => -moneyGain(o)),
/** Chases standing with people. */
sociable: (options) => best(options, relationshipGain),
/**
* Broke but well-liked — the state the "ask a friend for money" options were
* written for, and one no single-axis strategy ever reaches.
*/
desperate: (options, rng) => (rng.next() < 0.25
? rng.pick(options)
: best(options, (o) => relationshipGain(o) - moneyGain(o) / 40)),
};
/** Play one run to `turns`, calling `visit` before each choice. */
export function play(pack, { strategy = 'random', seed = 'run', turns = 200, visit } = {}) {
const choose = strategies[strategy];
const rng = createRng(`chooser-${strategy}-${seed}`);
let state = startGame(pack, { seed: `game-${strategy}-${seed}` });
for (let t = 0; t < turns; t++) {
const turn = currentTurn(pack, state);
const available = turn.options.filter((o) => o.available);
if (available.length === 0) throw new Error(`no available option on "${turn.event.id}"`);
const chosen = choose(available, rng);
visit?.({ turn, state, chosen });
state = takeTurn(pack, state, chosen.option.id).state;
}
return state;
}
/** Play every archetype across several seeds, reporting what was reached. */
export function sweep(pack, { seeds = 12, turns = 200 } = {}) {
const firedEvents = new Set();
const takenOptions = new Set();
const byStrategy = {};
for (const strategy of Object.keys(strategies)) {
const finals = [];
for (let seed = 0; seed < seeds; seed++) {
const state = play(pack, {
strategy,
seed,
turns,
visit: ({ turn, chosen }) => {
firedEvents.add(turn.event.id);
takenOptions.add(`${turn.event.id}.${chosen.option.id}`);
},
});
finals.push(state);
}
byStrategy[strategy] = finals;
}
return { firedEvents, takenOptions, byStrategy };
}
export const median = (values) =>
[...values].sort((a, b) => a - b)[Math.floor(values.length / 2)];
+47
View File
@@ -202,3 +202,50 @@ test('a character can start somewhere other than the pack default', () => {
assert.equal(state.relationships.pat.fears_you, 50, 'unnamed stats keep the pack default');
assert.equal(state.relationships.chief.likes_you, 50, 'other characters are unaffected');
});
test('a pack with a calendar gets day-of-cycle state it can gate on', () => {
const dated = structuredClone(testPack);
dated.calendar = { cycleLength: 7, cycleName: 'Week', unitNames: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] };
let state = startGame(dated, { seed: 'calendar' });
assert.deepEqual(
{ index: state.calendar.index, cycle: state.calendar.cycle, name: state.calendar.name },
{ index: 0, cycle: 1, name: 'Mon' },
);
for (let i = 0; i < 7; i++) {
state = takeTurn(dated, state, currentTurn(dated, state).options.find((o) => o.available).option.id).state;
}
assert.equal(state.turn, 8);
assert.deepEqual(
{ index: state.calendar.index, cycle: state.calendar.cycle, name: state.calendar.name },
{ index: 0, cycle: 2, name: 'Mon' },
);
});
test('a pack with no calendar has none: the engine imposes no week', () => {
const state = startGame(testPack, { seed: 'no-calendar' });
assert.deepEqual(state.calendar, {});
});
test('scheduled upkeep falls due on its own period, not every turn', () => {
const scheduled = structuredClone(testPack);
scheduled.upkeep = [
{ label: 'daily', effects: [{ path: 'money', op: 'add', value: -1 }] },
{ label: 'weekly rent', every: 7, offset: 6, effects: [{ path: 'money', op: 'add', value: -50 }] },
{ label: 'fortnightly pay', every: 14, offset: 6, effects: [{ path: 'money', op: 'add', value: 200 }] },
];
let state = startGame(scheduled, { seed: 'schedule' });
const dueOn = [];
for (let turn = 1; turn <= 22; turn++) {
const result = takeTurn(scheduled, state, currentTurn(scheduled, state).options.find((o) => o.available).option.id);
dueOn.push(result.record.upkeep.map((c) => c.label));
state = result.state;
}
assert.deepEqual(dueOn[0], ['daily'], 'turn 1 is an ordinary day');
assert.deepEqual(dueOn[6], ['daily', 'weekly rent', 'fortnightly pay'], 'turn 7 is rent and payday');
assert.deepEqual(dueOn[13], ['daily', 'weekly rent'], 'turn 14 is rent only');
assert.deepEqual(dueOn[20], ['daily', 'weekly rent', 'fortnightly pay'], 'turn 21 is both again');
});
+277
View File
@@ -0,0 +1,277 @@
// UI wiring tests.
//
// These drive the real app module against a fake DOM (test/fixtures/fake-dom.js).
// They prove the wiring runs — imports resolve, handlers fire, the screens
// render the right text, choices reach the engine, saves and the feedback log
// are written. They are not a substitute for playing it in a browser: layout,
// styling, file downloads and file imports are outside their reach.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { installFakeDom, byClass, firstByClass, byTag } from './fixtures/fake-dom.js';
/** Boot the app fresh against a clean fake DOM. */
async function boot() {
const dom = installFakeDom();
// A fresh module instance per test: the app holds module-level state.
const { boot: bootApp } = await import(`../src/ui/app.js?t=${Math.random()}`);
bootApp(dom.root);
return dom;
}
const text = (root) => root.textContent;
const optionButtons = (root) => byClass(root, 'option');
const enabledOptions = (root) => optionButtons(root).filter((b) => !b.disabled);
test('the app boots to a playable turn screen', async () => {
const dom = await boot();
try {
assert.ok(firstByClass(dom.root, 'screen--turn'), 'the turn screen rendered');
assert.match(text(dom.root), /Day 1/, 'the day counter is showing');
assert.match(text(dom.root), /Marlene Hobb/, 'the characters are listed');
assert.match(text(dom.root), /Trevor Pike/);
assert.ok(enabledOptions(dom.root).length > 0, 'there is something to click');
} finally {
dom.restore();
}
});
test('the header shows the pack\'s declared stats, and hides what it asks to hide', async () => {
const dom = await boot();
try {
const stats = text(firstByClass(dom.root, 'stats'));
assert.match(stats, /Money/);
assert.match(stats, /\$520/, 'money is formatted as money');
assert.match(stats, /Negotiation/);
assert.match(stats, /Standing/, 'reputation is renamed by the pack');
assert.doesNotMatch(stats, /Debt/, 'debt is hidden while it is zero');
assert.doesNotMatch(stats, /Notable/, 'hidden state stays out of the header');
} finally {
dom.restore();
}
});
test('choosing an option shows the outcome, the deltas and the upkeep', async () => {
const dom = await boot();
try {
enabledOptions(dom.root)[0].click();
assert.ok(firstByClass(dom.root, 'outcome'), 'the outcome view rendered');
assert.match(text(dom.root), /What that changed/);
assert.match(text(dom.root), /And then the day happened/, 'upkeep is shown separately');
const upkeep = text(firstByClass(dom.root, 'outcome__upkeep'));
// Labelled by what caused it, not by the path it moved — this is the
// answer to "what was that daily drain for?".
assert.match(upkeep, /Coffee, transit, lunch/);
assert.doesNotMatch(upkeep, /Money/, 'the bare path name is not useful here');
assert.ok(byClass(dom.root, 'change').length > 0, 'individual changes are listed');
} finally {
dom.restore();
}
});
test('advancing moves to the next turn', async () => {
const dom = await boot();
try {
enabledOptions(dom.root)[0].click();
firstByClass(dom.root, 'button--next').click();
assert.ok(firstByClass(dom.root, 'scenario'), 'back to choosing');
assert.match(text(dom.root), /Day 2/);
} finally {
dom.restore();
}
});
test('a locked option is shown, disabled, and says what it needs', async () => {
const dom = await boot();
try {
// Play until a locked option appears — the pack gates several.
let locked = null;
for (let i = 0; i < 40 && !locked; i++) {
locked = optionButtons(dom.root).find((b) => b.disabled);
if (locked) break;
enabledOptions(dom.root)[0].click();
firstByClass(dom.root, 'button--next').click();
}
assert.ok(locked, 'a gated option should turn up within 40 turns');
assert.match(locked.textContent, /Needs /, 'the requirement is spelled out');
locked.click(); // must be inert
assert.ok(firstByClass(dom.root, 'scenario'), 'clicking a locked option does nothing');
} finally {
dom.restore();
}
});
test('progress is written to local storage and restored on the next boot', async () => {
const dom = await boot();
try {
enabledOptions(dom.root)[0].click();
firstByClass(dom.root, 'button--next').click();
enabledOptions(dom.root)[0].click();
firstByClass(dom.root, 'button--next').click();
const saved = JSON.parse(dom.storage.getItem('theladder.save.v1'));
assert.equal(saved.state.turn, 3);
// Boot again against the same storage: the run should resume, not restart.
const { boot: bootAgain } = await import(`../src/ui/app.js?t=${Math.random()}`);
const secondRoot = dom.document.createElement('div');
bootAgain(secondRoot);
assert.match(text(secondRoot), /Day 3/, 'the saved run resumed');
} finally {
dom.restore();
}
});
test('every played turn is logged, separately from the save', async () => {
const dom = await boot();
try {
for (let i = 0; i < 3; i++) {
enabledOptions(dom.root)[0].click();
firstByClass(dom.root, 'button--next').click();
}
const log = JSON.parse(dom.storage.getItem('theladder.feedback.v1'));
assert.equal(log.length, 3);
const entry = log[0];
assert.ok(entry.event_id && entry.action_taken, 'the choice is recorded');
assert.ok(Array.isArray(entry.changes) && entry.changes.length > 0, 'the diff is recorded');
assert.ok(Array.isArray(entry.offered), 'the options offered are recorded');
assert.equal(entry.rating, null, 'feedback starts empty');
const save = JSON.parse(dom.storage.getItem('theladder.save.v1'));
assert.equal(save.state.history.length, 3);
assert.ok(!JSON.stringify(save).includes('rating'), 'feedback is not mixed into the save');
} finally {
dom.restore();
}
});
test('the feedback widget is optional, and records a rating when used', async () => {
const dom = await boot();
try {
enabledOptions(dom.root)[0].click();
const ratings = byClass(dom.root, 'rating');
assert.equal(ratings.length, 3, 'up, neutral and down');
ratings[0].click();
const log = JSON.parse(dom.storage.getItem('theladder.feedback.v1'));
assert.equal(log[0].rating, 'up');
// Clicking the same rating again clears it.
byClass(dom.root, 'rating')[0].click();
assert.equal(JSON.parse(dom.storage.getItem('theladder.feedback.v1'))[0].rating, null);
} finally {
dom.restore();
}
});
test('the comment fields write through without re-rendering under the cursor', async () => {
const dom = await boot();
try {
enabledOptions(dom.root)[0].click();
firstByClass(dom.root, 'feedback__toggle').click();
const fields = byTag(firstByClass(dom.root, 'feedback__detail'), 'textarea');
assert.equal(fields.length, 2, 'a comment box and a "what did you want to do" box');
fields[0].value = 'the wheel bit made me laugh';
fields[0].dispatch('input');
fields[1].value = 'I wanted to just hide in the stairwell';
fields[1].dispatch('input');
// The same element objects must still be live — a re-render would have
// replaced them and thrown the player out of the box they are typing in.
assert.equal(byTag(firstByClass(dom.root, 'feedback__detail'), 'textarea')[0], fields[0]);
// Written through on the way to the next turn.
firstByClass(dom.root, 'button--next').click();
const entry = JSON.parse(dom.storage.getItem('theladder.feedback.v1'))[0];
assert.equal(entry.comment, 'the wheel bit made me laugh');
assert.equal(entry.desired_but_unavailable_action, 'I wanted to just hide in the stairwell');
} finally {
dom.restore();
}
});
test('the retrospective summarises the run and is not an ending', async () => {
const dom = await boot();
try {
for (let i = 0; i < 6; i++) {
enabledOptions(dom.root)[0].click();
firstByClass(dom.root, 'button--next').click();
}
firstByClass(dom.root, 'button').click(); // "Wrap up my career"
assert.ok(firstByClass(dom.root, 'screen--retrospective'));
const body = text(dom.root);
assert.match(body, /Career retrospective/);
assert.match(body, /6 days in the mailroom/);
assert.match(body, /Where you stand with people/);
assert.match(body, /Marlene Hobb/);
assert.match(body, /Situations you saw/);
firstByClass(dom.root, 'button--primary').click(); // "Keep going"
assert.ok(firstByClass(dom.root, 'screen--turn'), 'the player can carry on');
assert.match(text(dom.root), /Day 7/);
} finally {
dom.restore();
}
});
test('a run with nothing notable still gets a retrospective', async () => {
const dom = await boot();
try {
firstByClass(dom.root, 'button').click(); // wrap up on turn one
assert.ok(firstByClass(dom.root, 'screen--retrospective'));
assert.match(text(dom.root), /Nothing that anybody would write down/);
} finally {
dom.restore();
}
});
test('the header names the day and counts the week', async () => {
const dom = await boot();
try {
const hud = text(firstByClass(dom.root, 'hud'));
assert.match(hud, /Monday/, 'the day is named');
assert.match(hud, /Week 1 · Day 1/, 'the week and the day number are both shown');
} finally {
dom.restore();
}
});
test('the diary says what is coming, so the drain is not a mystery', async () => {
const dom = await boot();
try {
const diary = text(firstByClass(dom.root, 'diary'));
assert.match(diary, /Wages in 4 days/, 'payday is announced before it lands');
assert.match(diary, /Rent in 7 days/);
assert.doesNotMatch(diary, /Loan service/, 'charges that do not apply are not listed');
} finally {
dom.restore();
}
});
test('wages and rent appear in the ledger on the days they fall due', async () => {
const dom = await boot();
try {
const ledgerOn = [];
for (let day = 1; day <= 8; day++) {
enabledOptions(dom.root)[0].click();
const upkeep = firstByClass(dom.root, 'outcome__upkeep');
ledgerOn.push(upkeep ? upkeep.textContent : '');
firstByClass(dom.root, 'button--next').click();
}
assert.match(ledgerOn[0], /Coffee, transit, lunch/, 'small change every day');
assert.doesNotMatch(ledgerOn[0], /Wages/, 'no wages on day 1');
assert.match(ledgerOn[4], /Wages/, 'paid on day 5, the first Friday');
assert.match(ledgerOn[7], /Rent/, 'rent on day 8, the second Monday');
} finally {
dom.restore();
}
});
+23
View File
@@ -141,3 +141,26 @@ test('a starting relationship for a stat that does not exist is caught', () => {
});
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/);
});