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
+125
-35
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user