// The application: wiring the engine to the screens. // // 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 // feedback text fields update the model without re-rendering, because // rebuilding the DOM under a cursor would throw the player out of the box they // are typing in. import { packs, packById, defaultPack } from '../../content/index.js'; import { validatePack } from '../engine/validate.js'; import { resolveSchema, hydrate } 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, 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 app = { pack: null, schema: null, state: null, view: null, root: null, }; /** Reset the per-turn view, reading the current turn out of the game state. */ function choosingView() { return { screen: 'turn', phase: 'choosing', turn: currentTurn(app.pack, app.state), feedback: emptyFeedback(), feedbackOpen: false, }; } const emptyFeedback = () => ({ rating: null, comment: '', desiredAction: '', saved: false, touched: false, turnId: null }); function render() { 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(app.pack, app.state, optionId); app.state = state; saveLocal(app.pack.id, state); const entry = logTurn(record, state, app.pack); app.view = { screen: 'turn', phase: 'outcome', turn: app.view.turn, record, option, feedback: { ...emptyFeedback(), turnId: entry?.turn_id ?? null }, feedbackOpen: false, }; render(); }, onNext() { flushFeedback(); app.view = choosingView(); render(); }, onRate(rating) { app.view.feedback.rating = app.view.feedback.rating === rating ? null : rating; app.view.feedback.saved = app.view.feedback.rating !== null; app.view.feedback.touched = true; flushFeedback(); render(); }, // No render: rebuilding the DOM would move the cursor out of the field. onComment(text) { app.view.feedback.comment = text; app.view.feedback.touched = true; scheduleFlush(); }, onDesiredAction(text) { app.view.feedback.desiredAction = text; app.view.feedback.touched = true; scheduleFlush(); }, onToggleFeedbackDetail() { app.view.feedbackOpen = !app.view.feedbackOpen; render(); }, onRetrospective() { flushFeedback(); app.view = { ...app.view, screen: 'retrospective' }; render(); }, onResume() { app.view = choosingView(); render(); }, onNewGame() { const played = app.state?.history?.length ?? 0; if (played > 0 && !confirm( `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(); 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(); }, onExportSave() { downloadText(`theladder-save-${stamp()}.json`, serializeSave(app.state)); }, onImportSave() { const input = document.createElement('input'); input.type = 'file'; input.accept = 'application/json,.json'; input.addEventListener('change', async () => { const file = input.files?.[0]; if (!file) return; try { // Same repair as a local load: an exported file is exactly as old as // the run it came from. const imported = parseSave(await readFile(file), app.pack.id); const { state } = hydrate(app.pack, imported, app.schema); app.state = state; saveLocal(app.pack.id, state); app.view = choosingView(); render(); } catch (error) { alert(`That save could not be loaded.\n\n${error.message}`); } }); input.click(); }, onExportFeedback() { flushFeedback(); const entries = readLog(); if (entries.length === 0) { alert('Nothing logged yet — play a turn or two first.'); return; } downloadText(`theladder-feedback-${stamp()}.json`, serializeLog(entries)); const withFeedback = feedbackCount(entries); if (confirm( `Exported ${entries.length} logged turns (${withFeedback} carry feedback).\n\n` + 'Clear the log now that it is saved?', )) clearLog(); }, }; // Text fields write through on a delay so a long comment is not one storage // write per keystroke, and always flush before anything that could lose it. let flushTimer = null; function scheduleFlush() { clearTimeout(flushTimer); flushTimer = setTimeout(flushFeedback, 500); } function flushFeedback() { clearTimeout(flushTimer); const feedback = app.view?.feedback; if (!feedback?.turnId) return; // `touched` rather than "has content": a player who clears a rating or // deletes a comment is withdrawing it, and that has to be written through // too. Turns the player simply ignored are never written at all. if (!feedback.touched) return; attachFeedback(feedback.turnId, { rating: feedback.rating, comment: feedback.comment, desiredAction: feedback.desiredAction, }); } /** * 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(app.root, errorScreen(pack, validation.errors)); return false; } 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) { // A career saved before the pack gained a stat has no path for it, and // every numeric comparison against `undefined` is false — so the events, // options and upkeep gated on a new stat would quietly not exist for a // player already mid-run. Fill them in, and write the repair back. const repaired = hydrate(pack, restored, app.schema); if (repaired.added.length > 0) { console.info(`[save ${pack.id}] filled in new state: ${repaired.added.join(', ')}`); saveLocal(pack.id, repaired.state); } app.state = repaired.state; 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.'); } // 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(pack, errors) { const list = document.createElement('ul'); for (const error of errors) { const item = document.createElement('li'); item.textContent = error; list.append(item); } const wrapper = document.createElement('div'); wrapper.className = 'screen screen--error'; const heading = document.createElement('h1'); heading.textContent = `"${pack.name ?? pack.id}" will not load.`; wrapper.append(heading, list); return wrapper; }