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:
co-authored by
Claude Opus 5
parent
4957c24e31
commit
7f20be83e8
+115
-4
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user