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
+125 -35
View File
@@ -1,8 +1,11 @@
// The application: wiring the engine to the screens.
//
// This is the one module that names a content pack, which makes it the seam a
// future pack-picker slots into. Everything below it takes the pack as an
// argument.
// This is the one module that knows there is more than one content pack. It
// holds the current pack and its schema, and everything below it still takes
// the pack as an argument — the screens, the engine and the io layer are all
// as pack-agnostic as they were when there was only one.
//
// Each pack has its own save slot, so switching settings never destroys a run.
//
// Rendering is a full rebuild of the root element. At this size it is fast and
// removes a whole class of stale-view bug — with one deliberate exception: the
@@ -10,21 +13,24 @@
// rebuilding the DOM under a cursor would throw the player out of the box they
// are typing in.
import { corporateLadder } from '../../content/corporateladder/index.js';
import { packs, packById, defaultPack } from '../../content/index.js';
import { validatePack } from '../engine/validate.js';
import { resolveSchema } from '../engine/state.js';
import { startGame, currentTurn, takeTurn, findOptionById } from './engine-bridge.js';
import { turnScreen } from './screens/turn.js';
import { retrospectiveScreen } from './screens/retrospective.js';
import { packsScreen } from './screens/packs.js';
import { fill, focusMarked } from './dom.js';
import { saveLocal, loadLocal, clearLocal, serializeSave, parseSave, storageAvailable } from '../io/save.js';
import {
saveLocal, loadLocal, clearLocal, serializeSave, parseSave, storageAvailable,
lastPlayed, rememberPack,
} from '../io/save.js';
import { logTurn, attachFeedback, readLog, serializeLog, clearLog, feedbackCount } from '../io/feedback-log.js';
import { downloadText, readFile, stamp } from '../io/download.js';
const pack = corporateLadder;
const schema = resolveSchema(pack);
const app = {
pack: null,
schema: null,
state: null,
view: null,
root: null,
@@ -35,7 +41,7 @@ function choosingView() {
return {
screen: 'turn',
phase: 'choosing',
turn: currentTurn(pack, app.state),
turn: currentTurn(app.pack, app.state),
feedback: emptyFeedback(),
feedbackOpen: false,
};
@@ -45,23 +51,41 @@ const emptyFeedback = () =>
({ rating: null, comment: '', desiredAction: '', saved: false, touched: false, turnId: null });
function render() {
const screen = app.view.screen === 'retrospective'
? retrospectiveScreen(pack, app.state, schema, actions)
: turnScreen(pack, app.state, schema, app.view, actions);
const screen = app.view.screen === 'packs'
? packsScreen(packs, packsView(), actions)
: app.view.screen === 'retrospective'
? retrospectiveScreen(app.pack, app.state, app.schema, actions)
: turnScreen(app.pack, app.state, app.schema, app.view, actions);
fill(app.root, screen);
// After the screen is in the document, put the caret where the player most
// likely wants it. This is what makes the game playable on Enter alone.
focusMarked(app.root);
}
/**
* The picker's view of the world: which pack is current, and what is saved in
* each of the others. Reading every slot is cheap and means the screen can say
* "day 41, Dispatch Coordinator" rather than just "saved".
*/
function packsView() {
const runs = {};
for (const candidate of packs) {
const saved = candidate.id === app.pack?.id
? app.state
: loadLocal(candidate.id);
if (saved) runs[candidate.id] = { turn: saved.turn, stage: saved.stage };
}
return { currentPackId: app.pack?.id ?? null, runs, canGoBack: app.state !== null };
}
const actions = {
onChoose(optionId) {
const option = findOptionById(app.view.turn.event, optionId);
const { state, record } = takeTurn(pack, app.state, optionId);
const { state, record } = takeTurn(app.pack, app.state, optionId);
app.state = state;
saveLocal(state);
saveLocal(app.pack.id, state);
const entry = logTurn(record, state, pack);
const entry = logTurn(record, state, app.pack);
app.view = {
screen: 'turn',
phase: 'outcome',
@@ -120,14 +144,37 @@ const actions = {
onNewGame() {
const played = app.state?.history?.length ?? 0;
if (played > 0 && !confirm(
`This ends the current run at ${pack.turnUnit} ${app.state.turn}. ` +
`This ends the current run at ${app.pack.turnUnit} ${app.state.turn}. ` +
'Export a save first if you want to keep it. Start a new game?',
)) return;
flushFeedback();
app.state = startGame(pack, { seed: String(Date.now()), schema, now: new Date().toISOString() });
clearLocal();
saveLocal(app.state);
app.view = choosingView();
clearLocal(app.pack.id);
beginRun(app.pack);
render();
},
/** Open the setting picker. */
onPacks() {
flushFeedback();
app.view = { ...(app.view ?? {}), screen: 'packs' };
render();
},
/**
* Switch settings. The run you are leaving has already been written to its
* own slot every turn, so this is not destructive in either direction — and
* coming back to a pack resumes it rather than restarting it.
*/
onChoosePack(packId) {
const chosen = packById(packId);
if (!chosen) return;
flushFeedback();
if (chosen.id === app.pack?.id) {
app.view = choosingView();
render();
return;
}
if (!enterPack(chosen)) return;
render();
},
@@ -143,9 +190,9 @@ const actions = {
const file = input.files?.[0];
if (!file) return;
try {
const state = parseSave(await readFile(file), pack.id);
const state = parseSave(await readFile(file), app.pack.id);
app.state = state;
saveLocal(state);
saveLocal(app.pack.id, state);
app.view = choosingView();
render();
} catch (error) {
@@ -194,30 +241,73 @@ function flushFeedback() {
});
}
export function boot(rootElement) {
/**
* Make `pack` the current setting: validate it, adopt its schema, and either
* resume its saved run or start one. Returns false when the pack will not
* load, having already put the error on screen.
*/
function enterPack(pack) {
const validation = validatePack(pack);
if (!validation.valid) {
fill(rootElement, errorScreen(validation.errors));
return;
fill(app.root, errorScreen(pack, validation.errors));
return false;
}
for (const warning of validation.warnings) console.warn(`[content] ${warning}`);
for (const warning of validation.warnings) console.warn(`[content ${pack.id}] ${warning}`);
app.pack = pack;
app.schema = resolveSchema(pack);
rememberPack(pack.id);
const restored = loadLocal(pack.id);
// The packId check is belt and braces now that slots are per pack, but an
// imported or hand-edited save can still carry the wrong name.
if (restored && restored.meta?.packId === pack.id) {
app.state = restored;
app.view = choosingView();
} else {
beginRun(pack);
}
return true;
}
/** A fresh run in `pack`, written to that pack's slot. */
function beginRun(pack) {
app.state = startGame(pack, {
seed: String(Date.now()),
schema: app.schema,
now: new Date().toISOString(),
});
saveLocal(pack.id, app.state);
app.view = choosingView();
}
export function boot(rootElement) {
app.root = rootElement;
if (!storageAvailable()) {
console.warn('[save] local storage is unavailable; progress will not persist. Use Export save.');
}
app.root = rootElement;
const restored = loadLocal();
app.state = restored && restored.meta?.packId === pack.id
? restored
: startGame(pack, { seed: String(Date.now()), schema, now: new Date().toISOString() });
app.view = choosingView();
render();
// Land a returning player back where they were. A first-time player with
// more than one setting to choose from gets the picker as the front door
// rather than being dropped into whichever pack happens to be first.
const remembered = packById(lastPlayed() ?? '');
const anySaved = packs.some((pack) => loadLocal(pack.id) !== null);
if (!remembered && !anySaved && packs.length > 1) {
app.view = { screen: 'packs' };
render();
} else if (!enterPack(remembered ?? defaultPack)) {
return;
} else {
render();
}
// Anything half-typed survives a tab close.
window.addEventListener('beforeunload', flushFeedback);
}
function errorScreen(errors) {
function errorScreen(pack, errors) {
const list = document.createElement('ul');
for (const error of errors) {
const item = document.createElement('li');
@@ -227,7 +317,7 @@ function errorScreen(errors) {
const wrapper = document.createElement('div');
wrapper.className = 'screen screen--error';
const heading = document.createElement('h1');
heading.textContent = 'This content pack will not load.';
heading.textContent = `"${pack.name ?? pack.id}" will not load.`;
wrapper.append(heading, list);
return wrapper;
}