Add The Frontier, a second setting, and a picker to choose between them

Cold Fork is a freight yard: 29 events across two stages, a four-day cycle, and
its own relationship and skill vocabulary. src/engine/ was not touched. Adding it
needed three changes outside the engine: one save slot per pack, app.js holding
the current pack, and turn.js no longer naming the mailroom (DECISIONS #34).
build.js now handles imports wrapped across lines. A conformance suite runs over
every registered pack. DECISIONS #35 records three calls on open questions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxdJFbLq1rBKsUeticGV1g
This commit is contained in:
JesseMarkowitz
2026-09-12 21:03:50 -04:00
co-authored by Claude Opus 5
parent 4957c24e31
commit 7f20be83e8
25 changed files with 3015 additions and 300 deletions
+115 -4
View File
@@ -10,9 +10,17 @@ 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() {
/**
* Boot the app fresh against a clean fake DOM.
*
* `pack` is written to the remembered-pack slot before booting, which is how a
* returning player lands back in a setting. Pass null to boot with nothing
* remembered and nothing saved, which is the first-time player who gets the
* setting picker instead of a turn.
*/
async function boot({ pack = 'corporateladder' } = {}) {
const dom = installFakeDom();
if (pack) dom.storage.setItem('theladder.pack.v1', pack);
// 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);
@@ -112,7 +120,7 @@ test('progress is written to local storage and restored on the next boot', async
enabledOptions(dom.root)[0].click();
firstByClass(dom.root, 'button--next').click();
const saved = JSON.parse(dom.storage.getItem('theladder.save.v1'));
const saved = JSON.parse(dom.storage.getItem('theladder.save.v1.corporateladder'));
assert.equal(saved.state.turn, 3);
// Boot again against the same storage: the run should resume, not restart.
@@ -142,7 +150,7 @@ test('every played turn is logged, separately from the save', async () => {
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'));
const save = JSON.parse(dom.storage.getItem('theladder.save.v1.corporateladder'));
assert.equal(save.state.history.length, 3);
assert.ok(!JSON.stringify(save).includes('rating'), 'feedback is not mixed into the save');
} finally {
@@ -366,3 +374,106 @@ test('the ledger reads as English, and leaves bookkeeping out of it', async () =
dom.restore();
}
});
test('a first-time player is asked which setting to play', async () => {
// Nothing saved and nothing remembered: the front door is the picker rather
// than whichever pack happens to be first in the registry.
const dom = await boot({ pack: null });
try {
assert.ok(firstByClass(dom.root, 'screen--packs'), 'the picker rendered');
assert.match(text(dom.root), /Corporate Ladder/);
assert.match(text(dom.root), /The Frontier/);
assert.match(text(dom.root), /Not started/, 'a pack with no run says so');
const start = byClass(dom.root, 'button').find((b) => /Start here/.test(b.textContent));
assert.ok(start, 'the picker offers a way in');
start.click();
assert.ok(firstByClass(dom.root, 'screen--turn'), 'choosing a setting starts it');
} finally {
dom.restore();
}
});
test('switching settings keeps both runs', async () => {
// The defect this guards against: one global save slot, so the first switch
// silently threw the other career away.
const dom = await boot({ pack: 'corporateladder' });
try {
enabledOptions(dom.root)[0].click();
byClass(dom.root, 'button').find((b) => /Next/.test(b.textContent))?.click();
assert.match(text(dom.root), /Day 2/, 'a turn was played in the first setting');
byClass(dom.root, 'button').find((b) => /Change setting/.test(b.textContent)).click();
const toFrontier = byClass(dom.root, 'button')
.find((b) => /Start here|Go back to this run/.test(b.textContent));
toFrontier.click();
assert.match(text(dom.root), /Hollis Vane/, 'the second setting is running');
assert.match(text(dom.root), /Day 1/, 'and it started fresh');
// Both slots are on disk, independently.
const corporate = JSON.parse(dom.storage.getItem('theladder.save.v1.corporateladder'));
const frontier = JSON.parse(dom.storage.getItem('theladder.save.v1.frontier'));
assert.equal(corporate.state.turn, 2, 'the first run is still where it was');
assert.equal(frontier.state.turn, 1);
assert.equal(corporate.state.meta.packId, 'corporateladder');
assert.equal(frontier.state.meta.packId, 'frontier');
// And going back resumes rather than restarting.
byClass(dom.root, 'button').find((b) => /Change setting/.test(b.textContent)).click();
assert.match(text(dom.root), /Day 2, Mail Associate I, saved/,
'the picker reports where the other run is');
byClass(dom.root, 'button').find((b) => /Go back to this run/.test(b.textContent)).click();
assert.match(text(dom.root), /Day 2/, 'the first career resumed where it was left');
assert.match(text(dom.root), /Marlene Hobb/);
} finally {
dom.restore();
}
});
test('a run saved before settings were selectable is not lost', async () => {
// The old single slot, migrated on read. Someone mid-career when this
// shipped should find their career, not a new one.
const dom = installFakeDom();
try {
const { startGame } = await import('../src/engine/game.js');
const { corporateLadder } = await import('../content/corporateladder/index.js');
const legacy = startGame(corporateLadder, { seed: 'legacy' });
legacy.turn = 61;
dom.storage.setItem('theladder.save.v1',
JSON.stringify({ format: 1, savedAt: null, state: legacy }));
const { boot: bootApp } = await import(`../src/ui/app.js?t=${Math.random()}`);
bootApp(dom.root);
assert.ok(firstByClass(dom.root, 'screen--turn'), 'it booted into the run, not the picker');
assert.match(text(dom.root), /Day 61/, 'the old career came back');
} finally {
dom.restore();
}
});
test('the header reads the second pack\'s display map, not the first\'s', async () => {
// `display` is a per-pack contract with the interface, and a typo in it is
// only visible at runtime. Corporate Ladder's version is covered above; this
// is the same assertion against a pack with entirely different stat names.
const dom = await boot({ pack: 'frontier' });
try {
const stats = text(firstByClass(dom.root, 'stats'));
assert.match(stats, /Money/);
assert.match(stats, /\$40/, 'money is formatted as money');
assert.match(stats, /Nerve/);
assert.match(stats, /Trade/);
assert.match(stats, /Standing/);
assert.doesNotMatch(stats, /Negotiation|Logistics/, 'no leakage from the other pack');
assert.doesNotMatch(stats, /Owed at the store/, 'the ledger is hidden while it is zero');
assert.match(text(dom.root), /Cold Fork/, 'the people panel is named by the pack');
assert.match(text(dom.root), /Hollis Vane/);
assert.match(text(dom.root), /Marshal Ward/);
assert.match(text(dom.root), /Hired Hand/, 'the stage title comes from the pack');
assert.match(text(dom.root), /Stage day/, 'and so does the name of the day');
} finally {
dom.restore();
}
});