Both settings now carry a charge levied by standing itself, which no purchase ends. Corporate Ladder's steps on money (1200/-60, 2400/-120, 3600/-240): a flat charge cannot flatten a slope, since late income scales with how well the player plays, and the flat charge needed to stop an optimiser accumulating put ordinary archetypes below zero for 44% of a run. Stepped, the balance settles around $4,250 and ordinary play comes out better than before it existed. The Frontier's is flat at -4 a run, because its late game never accumulated in the first place. Found in the first two human sessions of this work: - A career saved before a pack gained a stat had no path for it, and conditions compare numbers only, so every event, option and upkeep entry gated on a new stat silently did not exist for that run. Loading now fills those in. - 'Wrap up my career' sat immediately after the last option in tab order, so one Tab too many and Enter read as the game ending at day 31. It goes last, and the retrospective says plainly that it is a summary. - A 181-turn session ran against a stale bundle and the change under test never fired. Every logged turn now records app_version and build. - src/version.js had reported 0.3.0 since 0.3.0, while playtesters were told to check the status bar. A test now holds it to package.json. - 'Put money against the book' was shown locked to a player who owed nothing, six times. Options gated on a state the player cannot choose to enter are now hidden rather than greyed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RxdJFbLq1rBKsUeticGV1g
336 lines
11 KiB
JavaScript
336 lines
11 KiB
JavaScript
// 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;
|
|
}
|