diff --git a/README.md b/README.md index 433e4f6..0f54f31 100644 --- a/README.md +++ b/README.md @@ -25,25 +25,52 @@ currently playable only from a test harness. | Turn loop, per-turn upkeep | done | | Content validator + reachability tests | done | | Corporate Ladder pack — 17 events, 2 characters | done | -| UI | not started | -| Save to local storage, export/import | not started | -| Feedback widget and log export | not started | -| Retrospective screen | not started | +| Turn screen, retrospective, feedback widget | done | +| Local-storage save, file export/import | done | +| Single-file build | done | +| Calendar, scheduled wages and rent | done | +| Played in a real browser | done — first playtest 2026-09-09 | -## Running +The first playtest confirmed the interface, saves and the exported log all work +in a real browser. Its findings — an unexplained daily drain, no sense of the +week, and an economy nobody could get ahead in — are what the calendar and the +retuned economy are for. + +Note that the development machine has no browser that can render a page, so the +UI tests drive the real modules against a minimal fake DOM. That covers wiring, +not layout: **styling and dark mode are only ever verified by a human opening +it.** + +## Playing it ```sh -npm test # engine test suite (node's built-in runner, no dependencies) -npm run serve # static server on :8080 for development +npm run serve # then open http://localhost:8080 +``` + +A server is needed during development because browsers refuse ES module imports +over `file://`. If port 8080 is taken, run `python3 -m http.server ` +instead. + +To hand the game to someone else: + +```sh +npm run build # writes dist/theladder.html +``` + +That is one self-contained file with the stylesheet and every module inlined. +It plays by double-clicking it — no server, no network, nothing installed. + +```sh +npm test # 109 tests: engine, content, UI wiring, and the build ``` There are no runtime dependencies and no build step for development. Node is -used only to run tests and, later, the single-file build. +used only to run the tests and the single-file build. ## Layout ``` -src/engine/ the game engine — imports nothing from content/ +src/engine/ the game engine — imports nothing from content/, or from src/ui/ paths.js dotted-path get/set over state conditions.js generic predicates: { path, op, value }, all/any/not effects.js generic mutations: add/set/push/remove, with clamping @@ -52,8 +79,14 @@ src/engine/ the game engine — imports nothing from content/ game.js the turn loop rng.js seeded, serializable RNG validate.js load-time content checking +src/ui/ screens, formatting and the app controller + app.js the only module that names a content pack + screens/ the turn screen and the retrospective + format.js state paths and conditions rendered as English +src/io/ local-storage save, file export/import, the feedback log content/ content packs (settings) -test/ engine tests, run against a fixture pack, never real content +tools/build.js flattens everything into one self-contained HTML file +test/ engine tests against a fixture pack; content and UI tests docs/ DECISIONS.md — why things are the way they are Planning/ the original design documents ``` @@ -120,8 +153,24 @@ Conditions may read engine bookkeeping (`turn`, `stage`, `seen.`, `lastSeen.`) as well as pack state. Effects may not write it. A pack also declares `upkeep` — a list of `{ label, requires?, effects }` -applied at the end of every turn, after the choice. Corporate Ladder uses it for -wages, living costs, and a loan service charge that steps up as the debt grows. +applied at the end of a turn, after the choice. Adding `every` and `offset` +makes an entry periodic rather than daily: Corporate Ladder pays wages on +alternate Fridays (`{ every: 14, offset: 4 }`), charges rent every Monday, and +adds a loan service charge that steps up as the debt grows. + +A pack may also declare a `calendar`: + +```js +calendar: { + cycleLength: 7, + cycleName: 'Week', + unitNames: ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'], +}, +``` + +The engine derives `calendar.index`, `calendar.cycle` and `calendar.name` from +the turn number before each event is drawn, so content can require a Friday and +the interface can title the turn. Declare no calendar and there is no week. Run `validatePack(pack)` when loading in development — it catches undeclared paths, unknown characters, unusable ids, unreachable events and stages with no diff --git a/content/corporateladder/events/hard-times.js b/content/corporateladder/events/hard-times.js index a551f76..b1e2c17 100644 --- a/content/corporateladder/events/hard-times.js +++ b/content/corporateladder/events/hard-times.js @@ -91,11 +91,11 @@ export const hardTimes = [ { id: 'pay_chunk', label: 'Pay a real chunk of it down.', - requires: [{ path: 'money', op: '>=', value: 200 }], + requires: [{ path: 'money', op: '>=', value: 350 }], result: 'He is audibly disappointed.', effects: [ - { path: 'money', op: 'add', value: -200 }, - { path: 'debt', op: 'add', value: -200 }, + { path: 'money', op: 'add', value: -350 }, + { path: 'debt', op: 'add', value: -350 }, { path: 'reputation', op: 'add', value: 1 }, ], }, diff --git a/content/corporateladder/events/routine.js b/content/corporateladder/events/routine.js index e541860..3045638 100644 --- a/content/corporateladder/events/routine.js +++ b/content/corporateladder/events/routine.js @@ -344,6 +344,93 @@ export const routine = [ }, ], }, + + { + id: 'late_run', + stage: 'mailroom', + weight: 35, + cooldown: 5, + description: + 'The evening courier run needs covering. It is an hour, it is nobody\'s ' + + 'favourite hour, and it pays.', + options: [ + { + id: 'take_run', + label: 'Take the run.', + result: 'An hour in the loading bay. The city looks better from there than it has any right to.', + effects: [ + { path: 'money', op: 'add', value: 70 }, + { path: 'skills.logistics', op: 'add', value: 1 }, + ], + }, + { + id: 'swap_it', + label: 'Take it, and trade it to Trevor for the Friday one.', + requires: [{ path: 'skills.negotiation', op: '>=', value: 15 }], + result: 'Friday pays the same and finishes earlier. Trevor works this out on Friday.', + effects: [ + { path: 'money', op: 'add', value: 70 }, + { path: 'skills.negotiation', op: 'add', value: 2 }, + { path: 'relationships.trevor.likes_you', op: 'add', value: -3 }, + { path: 'relationships.trevor.fears_you', op: 'add', value: 2 }, + ], + }, + { + id: 'go_home', + label: 'Go home.', + result: 'You are home by six. It is a strange feeling and it does not last.', + effects: [{ path: 'relationships.marlene.likes_you', op: 'add', value: -1 }], + }, + ], + }, + + { + // Deliberately surfaced by being broke: when the money runs low the board + // starts offering work. The way out of a hole should be visible from + // inside it, not something you had to have planned for. + id: 'spare_shift', + stage: 'mailroom', + weight: 300, + cooldown: 2, + requires: [{ path: 'money', op: '<', value: 350 }], + description: + 'There is a shift going spare on the board — someone else\'s weekend, ' + + 'going cheap because nobody wants it. Marlene has not taken it down.', + options: [ + { + id: 'take_shift', + label: 'Take it.', + result: 'Two days of somebody else\'s work. The money is real.', + effects: [ + { path: 'money', op: 'add', value: 140 }, + { path: 'skills.logistics', op: 'add', value: 1 }, + { path: 'relationships.marlene.likes_you', op: 'add', value: 1 }, + ], + }, + { + id: 'take_two', + label: 'Take it, and ask what else is going.', + requires: [{ path: 'skills.negotiation', op: '>=', value: 15 }], + result: + 'There is more going than the board admits to. Marlene finds you ' + + 'another two days without writing either of them down.', + effects: [ + { path: 'money', op: 'add', value: 265 }, + { path: 'skills.negotiation', op: 'add', value: 2 }, + { path: 'reputation', op: 'add', value: -1 }, + ], + }, + { + id: 'leave_it', + label: 'Leave it for someone who needs it.', + result: 'Trevor takes it. Trevor mentions taking it.', + effects: [ + { path: 'reputation', op: 'add', value: 2 }, + { path: 'relationships.trevor.likes_you', op: 'add', value: 2 }, + ], + }, + ], + }, ]; export default routine; diff --git a/content/corporateladder/index.js b/content/corporateladder/index.js index e19f864..cc45e97 100644 --- a/content/corporateladder/index.js +++ b/content/corporateladder/index.js @@ -18,11 +18,20 @@ export const corporateLadder = { mailroom: { label: 'Mailroom', title: 'Mail Associate I' }, }, - // Money is deliberately tight: the baseline is a slow bleed (see `upkeep`), - // so the pressure that eventually forces a hard-times turn comes from doing - // nothing about it, and most of the ways out cost standing rather than cash. + // Turn 1 is a Monday. Content can gate on the day — and, more importantly, + // money arrives and leaves on a rhythm the player can plan around instead of + // draining by an unexplained amount every day. + calendar: { + cycleLength: 7, + cycleName: 'Week', + unitNames: ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'], + }, + + // Money is tight but not hopeless. The baseline bleeds about three dollars a + // day, so a careful player creeps forward and a careless one does not — the + // pressure comes from what choices cost, not from an unexplained drain. state: { - 'money': { initial: 400, min: -5000, max: 1000000 }, + 'money': { initial: 520, min: -5000, max: 1000000 }, 'debt': { initial: 0, min: 0, max: 1000000 }, 'reputation': { initial: 50, min: 0, max: 100 }, 'skills.negotiation': { initial: 5, min: 0, max: 100 }, @@ -38,37 +47,60 @@ export const corporateLadder = { // Nobody fears a new hire. Trevor's defaults are lower still — see // characters.js, where he starts two weeks ahead of you and knows it. relationshipStats: { - likes_you: { initial: 50, min: 0, max: 100 }, - fears_you: { initial: 10, min: 0, max: 100 }, - wants_to_help_you: { initial: 40, min: 0, max: 100 }, + likes_you: { label: 'Likes you', initial: 50, min: 0, max: 100 }, + fears_you: { label: 'Wary of you', initial: 10, min: 0, max: 100 }, + wants_to_help_you: { label: 'Will help you', initial: 40, min: 0, max: 100 }, }, - // Applied at the end of every turn, after the player's choice. Net -25 a day - // before anything else happens, so a run drifts toward trouble in about - // sixteen days if it is left alone. + // Money moves on a schedule the player can feel: paid on alternate Fridays, + // rent out every Monday, and only small change in between. Over a fortnight + // the baseline is about forty dollars down — a drift, not a bleed. upkeep: [ - { label: 'Wages', effects: [{ path: 'money', op: 'add', value: 115 }] }, - { label: 'Living costs', effects: [{ path: 'money', op: 'add', value: -140 }] }, + { label: 'Coffee, transit, lunch', effects: [{ path: 'money', op: 'add', value: -18 }] }, + // Turn 1 is a Monday, so offset 4 is the first Friday and every 14 makes it + // alternate Fridays. Rent lands on Mondays, starting the second week. + { label: 'Wages', every: 14, offset: 4, effects: [{ path: 'money', op: 'add', value: 980 }] }, + { label: 'Rent', every: 7, offset: 7, effects: [{ path: 'money', op: 'add', value: -455 }] }, // The service charge steps up with the size of the debt, so the spiral // tightens instead of flattening out. A step function in data costs the // engine nothing; a percentage would have needed a multiply operation. { label: 'Loan service charge', + every: 7, + offset: 7, requires: [{ path: 'debt', op: '>', value: 0 }], - effects: [{ path: 'money', op: 'add', value: -10 }], + effects: [{ path: 'money', op: 'add', value: -70 }], }, { label: 'Loan service charge (arrears)', + every: 7, + offset: 7, requires: [{ path: 'debt', op: '>=', value: 1000 }], - effects: [{ path: 'money', op: 'add', value: -15 }], + effects: [{ path: 'money', op: 'add', value: -40 }], }, { label: 'Loan service charge (collections)', + every: 7, + offset: 7, requires: [{ path: 'debt', op: '>=', value: 2000 }], - effects: [{ path: 'money', op: 'add', value: -25 }], + effects: [{ path: 'money', op: 'add', value: -70 }], }, ], + // How the interface names and formats this pack's state. The UI reads only + // this map, so renaming a stat or reordering the header is content's job. + display: { + 'money': { label: 'Money', format: 'money', order: 1 }, + 'debt': { label: 'Debt', format: 'money', order: 2, hideWhenZero: true }, + 'reputation': { label: 'Standing', format: 'meter', order: 3 }, + 'skills.negotiation': { label: 'Negotiation', format: 'meter', order: 4 }, + 'skills.logistics': { label: 'Logistics', format: 'meter', order: 5 }, + 'notable': { hidden: true }, + 'flags.took_loan': { label: 'a loan on the books', hidden: true }, + 'flags.holds_the_envelope': { label: 'the envelope', hidden: true }, + 'flags.covered_for_trevor': { label: "Trevor's Thursday", hidden: true }, + }, + characters, events, }; diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 6044124..529c554 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -149,3 +149,115 @@ the game's. Both draw exactly one number per turn from the same generator, so a shared seed correlates the choice index with the event draw and makes whole option combinations structurally unreachable. It presents as a content bug and is not one. Any headless playtest harness written later has to know this. + +## 14. The interface is named by the pack, not by the UI + +`pack.display` maps a state path to `{ label, format, order, hidden, +hideWhenZero }`. The header reads it to decide what to show, in what order, and +whether a value is money or a bar. Anything the pack does not describe falls +back to a name derived from the path, so content is never *required* to supply +a label just to be playable. + +This is why the header says "Standing" for a path called `reputation`, and why +`debt` disappears while it is zero — both are content's decisions. A Wild West +pack renames the whole interface without touching a UI file. + +## 15. A turn has two phases + +Choosing, then outcome. The outcome phase shows what the choice did, as a list +of deltas, and separately what the day cost regardless — wages, rent, the loan. +Collapsing these into one screen would have made the numbers move without the +player seeing why, which is most of what makes them mean anything. + +Locked options are rendered greyed with the requirement spelled out ("Needs +Negotiation 15+") rather than hidden. A player should be able to see the door +they cannot open yet; content can still opt into hiding one with +`whenLocked: 'hide'`. + +The feedback widget lives in the outcome phase, because that is the moment a +player has an opinion — they have now seen both the options and the result. + +## 16. Full re-render, with one deliberate exception + +The root element is rebuilt from state on every change. At this size it is +instant and it removes a whole class of stale-view bug. + +The exception is the feedback text fields, which update the model without +re-rendering: rebuilding the DOM under a cursor throws the player out of the +box they are typing in. Their contents are flushed to the log on a timer, when +the turn advances, and on `beforeunload`. + +A related bug worth remembering: the flush originally skipped writing when the +feedback was empty, which meant *clearing* a rating did not clear it from the +log. Withdrawing feedback is itself feedback. The guard is now "has the player +touched this", not "is there content". + +## 17. The single-file build is verified by running it + +`tools/build.js` inlines the stylesheet and flattens the module graph into one +script. It is a deliberately small bundler that understands only the module +syntax this project uses and **throws on anything it does not recognise** — a +silent mis-bundle is far worse than a failed build. + +The build is covered by a test that executes the bundled script against the +fake DOM and plays a turn through it. A build that merely produces a file +proves nothing; a broken bundle is otherwise discovered by whoever was handed +the file. + +## 18. What the tests do not cover + +There is no browser on this machine that can render the page — headless Firefox +cannot get a framebuffer, and no Chromium is installed. The UI tests drive the +real app modules against a minimal fake DOM (`test/fixtures/fake-dom.js`), which +covers wiring: imports resolve, handlers fire, screens render the right text, +choices reach the engine, saves and the log are written. + +It does not cover layout, CSS, the dark-mode palette, file downloads or file +imports. Those need a human with a browser, and should be treated as unverified +until someone has clicked them. + +## 19. The calendar is the pack's, derived by the engine + +First playtest: *"wasn't clear what daily drain was for… should probably pay +rent weekly / get paid every other week… would be good to have day of week +shown and week # instead of just day #"*. + +A pack may declare `calendar: { cycleLength, cycleName, unitNames }`. The +engine derives `calendar.index`, `calendar.cycle` and `calendar.name` from the +turn number and writes them into engine-owned state *before* the turn's event +is drawn — so content can require a Friday, and upkeep can charge rent on a +Monday. A pack that declares no calendar has none; the engine imposes no week +of its own, and `turnUnit` remains whatever the pack says. + +## 20. Upkeep runs on a schedule + +`every` and `offset` on an upkeep entry make it periodic: wages are +`{ every: 14, offset: 4 }` — alternate Fridays, given that turn 1 is a Monday — +and rent is `{ every: 7, offset: 7 }`. What was a flat $25/day of unexplained +drain is now $18 of small change, a fortnightly paycheque and a weekly rent +cheque, which is both easier to plan around and easier to feel. + +Two display consequences, both of which were the real cause of the "unclear +drain" complaint. The ledger now labels a change with the upkeep entry that +caused it ("Rent −$455") rather than the path it moved ("Money −$455"). And +the turn screen carries a diary line — "Wages tomorrow · Rent in 4 days" — +computed generically from any pack's scheduled entries, so the player is told +what is coming before it happens rather than after. + +## 21. Content coverage is measured across player archetypes + +Playtest: *"always in debt — could never get ahead"*. The economy was retuned so +that a careful player climbs, a careless one sinks, and an unplanned one treads +water, with a `spare_shift` event that surfaces *because* you are broke — the +way out of a hole should be visible from inside it. + +That retune broke the old coverage test, and usefully. Random play stopped +reaching hard times at all, which made the whole debt branch look dead. It was +not dead; random play is simply not a plausible player. + +Coverage is now the union across five archetypes (`test/fixtures/strategies.js`) +— random, thrifty, spendthrift, sociable, and "desperate", which is broke *and* +well-liked. That last one exists because the "ask a friend for money" options +sit in a state no single-axis strategy ever reaches. The economy's shape is +asserted as an ordering between archetypes rather than as absolute figures, so +tuning does not churn the tests. diff --git a/index.html b/index.html new file mode 100644 index 0000000..a4dc1f6 --- /dev/null +++ b/index.html @@ -0,0 +1,22 @@ + + + + + + The Ladder + + + + +
+

Loading…

+
+ + + + diff --git a/src/engine/game.js b/src/engine/game.js index 4a670e1..9b75228 100644 --- a/src/engine/game.js +++ b/src/engine/game.js @@ -17,19 +17,53 @@ export class MoveError extends Error {} /** Draw the next event and record where the generator ended up. */ function advance(pack, state) { - const rng = createRng(state.meta.seed, state.meta.rngPosition ?? undefined); - const event = selectEvent(pack, state, rng); + // The calendar is settled before the draw, so an event can require a Friday. + const dated = applyCalendar(pack, state); + const rng = createRng(dated.meta.seed, dated.meta.rngPosition ?? undefined); + const event = selectEvent(pack, dated, rng); if (!event) { throw new ContentError( - `no event is eligible at stage "${state.stage}" on turn ${state.turn}; ` + + `no event is eligible at stage "${dated.stage}" on turn ${dated.turn}; ` + 'every stage needs at least one event with no conditions', ); } - let next = setPath(state, 'meta.currentEventId', event.id); + let next = setPath(dated, 'meta.currentEventId', event.id); next = setPath(next, 'meta.rngPosition', rng.getPosition()); return next; } +/** + * Derive the calendar from the turn number. + * + * A pack that declares `calendar` gets `calendar.index` (position in the + * cycle), `calendar.cycle` (which cycle, 1-based) and `calendar.name` written + * into engine-owned state before the turn's event is chosen — so content can + * gate on the day of the week, and upkeep can charge rent on one of them. A + * pack that declares no calendar simply has none; the engine imposes no week. + */ +function applyCalendar(pack, state) { + const calendar = pack.calendar; + if (!calendar?.cycleLength) return state; + + const elapsed = state.turn - 1; + const index = ((elapsed % calendar.cycleLength) + calendar.cycleLength) % calendar.cycleLength; + let next = setPath(state, 'calendar.index', index); + next = setPath(next, 'calendar.cycle', Math.floor(elapsed / calendar.cycleLength) + 1); + return setPath(next, 'calendar.name', calendar.unitNames?.[index] ?? null); +} + +/** + * Whether a scheduled upkeep entry falls due this turn. `every` is a period in + * turns and `offset` the first turn it lands on (zero-based), so wages every + * fortnight starting on the seventh day are `{ every: 14, offset: 6 }`. An + * entry with no `every` runs every turn. + */ +function isDue(entry, state) { + if (!entry.every) return true; + const elapsed = state.turn - 1 - (entry.offset ?? 0); + return elapsed >= 0 && elapsed % entry.every === 0; +} + /** * Apply the pack's per-turn upkeep — wages in, rent out, whatever a loan * costs to carry. Each entry may carry its own conditions, evaluated after the @@ -40,6 +74,7 @@ function applyUpkeep(pack, state, schema) { let next = state; const changes = []; for (const entry of pack.upkeep ?? []) { + if (!isDue(entry, next)) continue; if (!check(next, entry.requires).passed) continue; const applied = applyEffects(next, entry.effects, schema); next = applied.state; diff --git a/src/engine/state.js b/src/engine/state.js index 1d009f7..2097ce3 100644 --- a/src/engine/state.js +++ b/src/engine/state.js @@ -11,7 +11,8 @@ import { setPath } from './paths.js'; * day 10", "only if this event hasn't fired" — but may not declare or write * them, which is what keeps engine bookkeeping from being corrupted by data. */ -export const RESERVED_ROOTS = new Set(['turn', 'stage', 'seen', 'lastSeen', 'history', 'meta']); +export const RESERVED_ROOTS = + new Set(['turn', 'stage', 'seen', 'lastSeen', 'history', 'meta', 'calendar']); /** * Flatten a pack's state declarations into one path -> rule map, expanding the @@ -51,6 +52,7 @@ export function createState(pack, { seed, schema = resolveSchema(pack) } = {}) { stage: pack.startingStage, seen: {}, lastSeen: {}, + calendar: {}, history: [], }; for (const [path, rule] of Object.entries(schema)) { diff --git a/src/engine/validate.js b/src/engine/validate.js index 5e4c2e2..d8c3a97 100644 --- a/src/engine/validate.js +++ b/src/engine/validate.js @@ -95,8 +95,27 @@ export function validatePack(pack) { list.forEach(walk); }; + if (pack.calendar) { + const { cycleLength, unitNames } = pack.calendar; + if (!Number.isInteger(cycleLength) || cycleLength < 1) { + fail('calendar', `cycleLength ${cycleLength} must be a positive whole number`); + } + if (unitNames && unitNames.length !== cycleLength) { + fail('calendar', `unitNames has ${unitNames.length} entries but cycleLength is ${cycleLength}`); + } + } + (pack.upkeep ?? []).forEach((entry, index) => { const where = `upkeep[${index}]${entry.label ? ` "${entry.label}"` : ''}`; + if (entry.every !== undefined && (!Number.isInteger(entry.every) || entry.every < 1)) { + fail(where, `every ${entry.every} must be a positive whole number of turns`); + } + if (entry.offset !== undefined && (!Number.isInteger(entry.offset) || entry.offset < 0)) { + fail(where, `offset ${entry.offset} must be zero or a positive whole number`); + } + if (entry.offset !== undefined && entry.every === undefined) { + warn(where, 'offset has no effect without every'); + } checkConditions(where, entry.requires); if (!entry.effects?.length) fail(where, 'has no effects'); for (const effect of entry.effects ?? []) { diff --git a/src/io/download.js b/src/io/download.js new file mode 100644 index 0000000..6d9a18b --- /dev/null +++ b/src/io/download.js @@ -0,0 +1,30 @@ +// Handing the player a file. +// +// Object URLs are revoked on the next frame rather than immediately: Safari +// has historically cancelled the download if the URL goes away too soon. + +export function downloadText(filename, text, type = 'application/json') { + const url = URL.createObjectURL(new Blob([text], { type })); + const link = document.createElement('a'); + link.href = url; + link.download = filename; + document.body.append(link); + link.click(); + link.remove(); + requestAnimationFrame(() => URL.revokeObjectURL(url)); +} + +/** Read a File the player picked, as text. */ +export function readFile(file) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(String(reader.result)); + reader.onerror = () => reject(new Error('Could not read that file.')); + reader.readAsText(file); + }); +} + +/** A timestamp suffix that sorts, for filenames. */ +export function stamp(date = new Date()) { + return date.toISOString().slice(0, 19).replace(/[:T]/g, '-'); +} diff --git a/src/io/feedback-log.js b/src/io/feedback-log.js new file mode 100644 index 0000000..f75fd3b --- /dev/null +++ b/src/io/feedback-log.js @@ -0,0 +1,103 @@ +// The feedback and instrumentation log. +// +// Deliberately separate from the save: this is research data for the designer, +// not part of anyone's career. It is exported on its own and never merged into +// a save file. +// +// Each entry records more than the spec's minimum, for two reasons. The full +// before/after diff makes "what did that choice actually do" answerable months +// later without replaying anything. The list of options that were *offered but +// locked* is the strongest available signal that a choice set is too narrow — +// players rarely type into a comment box, but the gates they keep bouncing off +// are recorded whether they say anything or not. + +const KEY = 'theladder.feedback.v1'; +const FORMAT = 1; + +function safely(action, fallback = null) { + try { + return action(); + } catch { + return fallback; + } +} + +function readRaw() { + return safely(() => { + const raw = localStorage.getItem(KEY); + return raw ? JSON.parse(raw) : []; + }, []) ?? []; +} + +function writeRaw(entries) { + return safely(() => { + localStorage.setItem(KEY, JSON.stringify(entries)); + return true; + }, false); +} + +/** + * Record a turn. `record` is what takeTurn returned; `state` is the state it + * produced, carried for the seed and the running totals at that moment. + */ +export function logTurn(record, state, pack) { + const entries = readRaw(); + entries.push({ + logged_at: new Date().toISOString(), + pack_id: pack.id, + pack_version: pack.version ?? null, + seed: state.meta.seed, + turn_id: `${state.meta.seed}:${record.turn}`, + turn: record.turn, + stage: record.stage, + event_id: record.eventId, + action_taken: record.optionId, + action_label: record.optionLabel, + changes: record.changes, + upkeep: record.upkeep, + offered: record.offered, + rating: null, + comment: null, + desired_but_unavailable_action: null, + }); + writeRaw(entries); + return entries.at(-1); +} + +/** + * Attach the player's feedback to a turn already logged. Feedback arrives + * after the choice — often several seconds after — so it is a second write + * rather than part of the first. + */ +export function attachFeedback(turnId, { rating, comment, desiredAction }) { + const entries = readRaw(); + const entry = entries.findLast((e) => e.turn_id === turnId); + if (!entry) return false; + if (rating !== undefined) entry.rating = rating; + if (comment !== undefined) entry.comment = comment || null; + if (desiredAction !== undefined) entry.desired_but_unavailable_action = desiredAction || null; + return writeRaw(entries); +} + +export function readLog() { + return readRaw(); +} + +export function clearLog() { + return safely(() => { localStorage.removeItem(KEY); return true; }, false); +} + +/** How many entries carry actual player feedback, for the export summary. */ +export function feedbackCount(entries = readRaw()) { + return entries.filter((e) => e.rating || e.comment || e.desired_but_unavailable_action).length; +} + +export function serializeLog(entries = readRaw()) { + return JSON.stringify({ + format: FORMAT, + exported_at: new Date().toISOString(), + turns_logged: entries.length, + turns_with_feedback: feedbackCount(entries), + entries, + }, null, 2); +} diff --git a/src/io/save.js b/src/io/save.js new file mode 100644 index 0000000..ed919a9 --- /dev/null +++ b/src/io/save.js @@ -0,0 +1,77 @@ +// Saving and loading. +// +// Local storage is the live save; the file export exists so a cleared cache or +// a crashed browser does not take a career with it. A save is the whole game +// state and nothing else — the engine keeps no state outside it, so this is a +// complete record by construction. + +const KEY = 'theladder.save.v1'; +const FORMAT = 1; + +/** Local storage throws in private windows and when site data is blocked. */ +function safely(action, fallback = null) { + try { + return action(); + } catch { + return fallback; + } +} + +export function saveLocal(state) { + return safely(() => { + localStorage.setItem(KEY, JSON.stringify({ format: FORMAT, savedAt: new Date().toISOString(), state })); + return true; + }, false); +} + +export function loadLocal() { + return safely(() => { + const raw = localStorage.getItem(KEY); + if (!raw) return null; + const parsed = JSON.parse(raw); + return parsed?.state ?? null; + }); +} + +export function clearLocal() { + return safely(() => { localStorage.removeItem(KEY); return true; }, false); +} + +/** True if the browser will actually remember anything. */ +export function storageAvailable() { + return safely(() => { + const probe = '__theladder_probe__'; + localStorage.setItem(probe, '1'); + localStorage.removeItem(probe); + return true; + }, false); +} + +/** A save file's contents, as a string ready to be downloaded. */ +export function serializeSave(state) { + return JSON.stringify({ format: FORMAT, savedAt: new Date().toISOString(), state }, null, 2); +} + +/** + * Parse an imported save, refusing anything that is not one. A file from a + * different pack is rejected by name rather than loaded into a game whose + * events and characters do not match it. + */ +export function parseSave(text, expectedPackId) { + let parsed; + try { + parsed = JSON.parse(text); + } catch { + throw new Error('That file is not valid JSON.'); + } + const state = parsed?.state; + if (!state || typeof state !== 'object' || !state.meta || typeof state.turn !== 'number') { + throw new Error('That does not look like a saved game.'); + } + if (expectedPackId && state.meta.packId !== expectedPackId) { + throw new Error( + `That save is from "${state.meta.packId}", but this is "${expectedPackId}".`, + ); + } + return state; +} diff --git a/src/ui/app.js b/src/ui/app.js new file mode 100644 index 0000000..301234e --- /dev/null +++ b/src/ui/app.js @@ -0,0 +1,230 @@ +// 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. +// +// 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 { corporateLadder } from '../../content/corporateladder/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 { fill } from './dom.js'; +import { saveLocal, loadLocal, clearLocal, serializeSave, parseSave, storageAvailable } 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 = { + 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(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 === 'retrospective' + ? retrospectiveScreen(pack, app.state, schema, actions) + : turnScreen(pack, app.state, schema, app.view, actions); + fill(app.root, screen); +} + +const actions = { + onChoose(optionId) { + const option = findOptionById(app.view.turn.event, optionId); + const { state, record } = takeTurn(pack, app.state, optionId); + app.state = state; + saveLocal(state); + + const entry = logTurn(record, state, 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 ${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(); + 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 { + const state = parseSave(await readFile(file), pack.id); + app.state = state; + saveLocal(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, + }); +} + +export function boot(rootElement) { + const validation = validatePack(pack); + if (!validation.valid) { + fill(rootElement, errorScreen(validation.errors)); + return; + } + for (const warning of validation.warnings) console.warn(`[content] ${warning}`); + 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(); + + // Anything half-typed survives a tab close. + window.addEventListener('beforeunload', flushFeedback); +} + +function errorScreen(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 = 'This content pack will not load.'; + wrapper.append(heading, list); + return wrapper; +} diff --git a/src/ui/components.js b/src/ui/components.js new file mode 100644 index 0000000..586aaae --- /dev/null +++ b/src/ui/components.js @@ -0,0 +1,92 @@ +// Pieces shared between the turn screen and the retrospective. + +import { h } from './dom.js'; +import { formatValue, labelFor, headerStats } from './format.js'; +import { getPath } from '../engine/paths.js'; + +/** A labelled bar for a bounded stat. */ +export function meter(label, value, bounds, { compact = false } = {}) { + const min = bounds?.min ?? 0; + const max = bounds?.max ?? 100; + const fraction = max === min ? 0 : Math.max(0, Math.min(1, (value - min) / (max - min))); + return h('div', { class: compact ? 'meter meter--compact' : 'meter' }, + h('div', { class: 'meter__head' }, + h('span', { class: 'meter__label' }, label), + h('span', { class: 'meter__value' }, String(value)), + ), + h('div', { class: 'meter__track', role: 'img', 'aria-label': `${label}: ${value} of ${max}` }, + h('div', { class: 'meter__fill', style: `width: ${(fraction * 100).toFixed(1)}%` }), + ), + ); +} + +/** The stat strip: money and debt as figures, bounded stats as bars. */ +export function statPanel(pack, state, schema) { + return h('div', { class: 'stats' }, + headerStats(pack, state, schema).map((stat) => ( + stat.rule.format === 'meter' + ? meter(stat.label, stat.value, stat.bounds, { compact: true }) + : h('div', { class: `figure figure--${stat.path.replace(/\./g, '-')}` }, + h('span', { class: 'figure__label' }, stat.label), + h('span', { + class: `figure__value${stat.value < 0 ? ' figure__value--negative' : ''}`, + }, formatValue(pack, stat.path, stat.value)), + ) + )), + ); +} + +/** One character, with how they currently regard the player. */ +export function characterCard(pack, state, schema, character) { + return h('article', { class: 'person' }, + h('header', { class: 'person__head' }, + h('h3', { class: 'person__name' }, character.name), + character.title && h('p', { class: 'person__title' }, character.title), + ), + h('p', { class: 'person__description' }, character.description), + h('div', { class: 'person__meters' }, + Object.keys(pack.relationshipStats ?? {}).map((stat) => { + const path = `relationships.${character.id}.${stat}`; + return meter( + pack.relationshipStats[stat].label ?? stat, + getPath(state, path), + schema[path], + { compact: true }, + ); + }), + ), + ); +} + +/** The change list shown after a choice: what moved, and by how much. */ +export function changeList(pack, changes, { className = 'changes' } = {}) { + const meaningful = changes.filter((c) => c.from !== c.to); + if (!meaningful.length) return h('p', { class: 'changes changes--none' }, 'Nothing measurable changed.'); + + return h('ul', { class: className }, + meaningful.map((change) => { + const rising = typeof change.to === 'number' && typeof change.from === 'number' + ? change.to > change.from + : Boolean(change.to); + return h('li', { class: `change change--${rising ? 'up' : 'down'}` }, + // Upkeep changes carry the label of the entry that caused them — + // "Rent", "Wages" — which is far more use than the path's own name. + h('span', { class: 'change__label' }, change.label ?? labelFor(pack, change.path)), + h('span', { class: 'change__delta' }, deltaText(pack, change)), + change.clamped && h('span', { class: 'change__note' }, 'at its limit'), + ); + }), + ); +} + +function deltaText(pack, change) { + if (typeof change.to !== 'number' || typeof change.from !== 'number') { + return typeof change.to === 'boolean' ? (change.to ? 'now true' : 'no longer true') : 'changed'; + } + const delta = change.to - change.from; + const money = pack.display?.[change.path]?.format === 'money'; + const magnitude = money + ? `$${Math.abs(Math.round(delta)).toLocaleString('en-US')}` + : String(Math.abs(delta)); + return `${delta > 0 ? '+' : '−'}${magnitude}`; +} diff --git a/src/ui/dom.js b/src/ui/dom.js new file mode 100644 index 0000000..19157ac --- /dev/null +++ b/src/ui/dom.js @@ -0,0 +1,26 @@ +// A very small DOM helper. Not a framework — just enough to build elements +// without a wall of createElement calls. + +export function h(tag, props = {}, ...children) { + const el = document.createElement(tag); + for (const [key, value] of Object.entries(props ?? {})) { + if (value === null || value === undefined || value === false) continue; + if (key === 'class') el.className = value; + else if (key === 'dataset') Object.assign(el.dataset, value); + else if (key.startsWith('on') && typeof value === 'function') { + el.addEventListener(key.slice(2).toLowerCase(), value); + } else if (key in el && key !== 'list') el[key] = value; + else el.setAttribute(key, value); + } + for (const child of children.flat(Infinity)) { + if (child === null || child === undefined || child === false) continue; + el.append(child instanceof Node ? child : document.createTextNode(String(child))); + } + return el; +} + +/** Replace an element's contents. */ +export function fill(container, ...children) { + container.replaceChildren(...children.flat(Infinity).filter((c) => c !== null && c !== undefined && c !== false)); + return container; +} diff --git a/src/ui/engine-bridge.js b/src/ui/engine-bridge.js new file mode 100644 index 0000000..a2edb00 --- /dev/null +++ b/src/ui/engine-bridge.js @@ -0,0 +1,34 @@ +// The UI's view of the engine, with the pack's schema bound in. +// +// Re-exported here rather than imported directly so the screens never have to +// thread a schema argument through, and so there is one obvious place to look +// for everything the interface is allowed to do to a game. + +import { resolveSchema } from '../engine/state.js'; +import * as game from '../engine/game.js'; +import { findOption } from '../engine/select.js'; + +const schemaCache = new WeakMap(); + +function schemaFor(pack) { + if (!schemaCache.has(pack)) schemaCache.set(pack, resolveSchema(pack)); + return schemaCache.get(pack); +} + +export function startGame(pack, options = {}) { + return game.startGame(pack, { schema: schemaFor(pack), ...options }); +} + +export function currentTurn(pack, state) { + return game.currentTurn(pack, state); +} + +export function takeTurn(pack, state, optionId, options = {}) { + return game.takeTurn(pack, state, optionId, { + schema: schemaFor(pack), + now: new Date().toISOString(), + ...options, + }); +} + +export const findOptionById = findOption; diff --git a/src/ui/format.js b/src/ui/format.js new file mode 100644 index 0000000..2ee6c68 --- /dev/null +++ b/src/ui/format.js @@ -0,0 +1,151 @@ +// Turning state paths and conditions into English. +// +// Display names, number formats and which stats appear in the header all come +// from the pack's `display` map, so a new setting renames and re-orders the +// interface without touching the UI. Anything the pack does not describe falls +// back to a derivation from the path itself, so content is never *required* to +// supply a label just to be playable. + +import { getPath } from '../engine/paths.js'; +import { check } from '../engine/conditions.js'; + +const titleCase = (text) => + text.replace(/_/g, ' ').replace(/^\w/, (c) => c.toUpperCase()); + +/** The pack's display rules for a path, if it has any. */ +export function displayFor(pack, path) { + return pack.display?.[path] ?? null; +} + +/** A human name for a path, derived from the path when the pack is silent. */ +export function labelFor(pack, path) { + const declared = displayFor(pack, path)?.label; + if (declared) return declared; + + const segments = path.split('.'); + if (segments[0] === 'relationships' && segments.length === 3) { + const character = pack.characters?.find((c) => c.id === segments[1]); + const stat = pack.relationshipStats?.[segments[2]]?.label ?? titleCase(segments[2]); + return `${character?.name ?? titleCase(segments[1])} — ${stat.toLowerCase()}`; + } + return titleCase(segments.at(-1)); +} + +/** Money is written as money; everything else is a plain number. */ +export function formatValue(pack, path, value) { + if (typeof value === 'boolean') return value ? 'yes' : 'no'; + if (displayFor(pack, path)?.format === 'money') return formatMoney(value); + return String(value); +} + +export function formatMoney(amount) { + const rounded = Math.round(amount); + const body = `$${Math.abs(rounded).toLocaleString('en-US')}`; + return rounded < 0 ? `−${body}` : body; +} + +/** A signed change, for the outcome list: "+$45", "−3". */ +export function formatDelta(pack, path, from, to) { + const delta = to - from; + if (displayFor(pack, path)?.format === 'money') { + return `${delta > 0 ? '+' : '−'}$${Math.abs(Math.round(delta)).toLocaleString('en-US')}`; + } + return `${delta > 0 ? '+' : '−'}${Math.abs(delta)}`; +} + +/** + * One condition as a requirement the player can read: "Negotiation 15+", + * "Trevor — likes you 48+", "not in debt". + */ +export function describeCondition(pack, condition) { + if (!condition || typeof condition !== 'object') return ''; + if (Array.isArray(condition)) return condition.map((c) => describeCondition(pack, c)).join(', '); + if (condition.all) return condition.all.map((c) => describeCondition(pack, c)).join(', '); + if (condition.any) return condition.any.map((c) => describeCondition(pack, c)).join(' or '); + if (condition.not) return `not ${describeCondition(pack, condition.not)}`; + + const { path, op, value } = condition; + const label = labelFor(pack, path); + + if (typeof value === 'boolean') return value ? label.toLowerCase() : `no ${label.toLowerCase()}`; + + const amount = formatValue(pack, path, value); + switch (op) { + case '>=': return `${label} ${amount}+`; + case '>': return `${label} over ${amount}`; + case '<=': return `${label} ${amount} or less`; + case '<': return `${label} under ${amount}`; + case '==': return `${label} exactly ${amount}`; + case '!=': return `${label} other than ${amount}`; + default: return `${label} ${op} ${amount}`; + } +} + +/** What a locked option is waiting for. */ +export function requirementText(pack, failedConditions) { + const parts = failedConditions.map((c) => describeCondition(pack, c)).filter(Boolean); + return parts.length ? `Needs ${parts.join(', ')}` : 'Not available yet'; +} + +/** + * The stats the header shows, in the pack's declared order, skipping anything + * the pack marks hidden or asks to hide while it is zero. + */ +export function headerStats(pack, state, schema) { + return Object.entries(pack.display ?? {}) + .filter(([, rule]) => !rule.hidden) + .map(([path, rule]) => ({ + path, + rule, + label: labelFor(pack, path), + value: getPath(state, path), + bounds: schema[path], + })) + .filter((stat) => !(stat.rule.hideWhenZero && !stat.value)) + .sort((a, b) => (a.rule.order ?? 99) - (b.rule.order ?? 99)); +} + +/** + * How the current turn is titled. A pack with a calendar gets its day name and + * cycle ("Thursday", "Week 2 · Day 12"); one without falls back to the turn + * number alone, which is what the engine guarantees. + */ +export function turnHeading(pack, state) { + const unit = capitalize(pack.turnUnit ?? 'turn'); + const name = state.calendar?.name; + if (!name) return { title: `${unit} ${state.turn}`, detail: null }; + + const cycleName = pack.calendar?.cycleName ?? 'Cycle'; + return { + title: name, + detail: `${cycleName} ${state.calendar.cycle} · ${unit} ${state.turn}`, + }; +} + +/** + * The pack's scheduled upkeep, as "what is coming and when". Answers the + * question a player actually has — when am I next paid, when is rent due — + * without them having to reverse-engineer it from the ledger. + */ +export function upcomingUpkeep(pack, state, { limit = 3 } = {}) { + const upcoming = []; + for (const entry of pack.upkeep ?? []) { + if (!entry.every || !entry.label) continue; + if (!check(state, entry.requires).passed) continue; + + const offset = entry.offset ?? 0; + const elapsed = state.turn - 1 - offset; + const turnsAway = elapsed < 0 ? -elapsed : (entry.every - (elapsed % entry.every)) % entry.every; + const amount = (entry.effects ?? []) + .filter((e) => e.op === 'add' && typeof e.value === 'number') + .reduce((sum, e) => sum + e.value, 0); + + upcoming.push({ label: entry.label, turnsAway, incoming: amount > 0 }); + } + return upcoming + .sort((a, b) => a.turnsAway - b.turnsAway) + .filter((entry, index, all) => all.findIndex((e) => e.label === entry.label) === index) + .slice(0, limit); +} + +const capitalize = (text) => text.charAt(0).toUpperCase() + text.slice(1); diff --git a/src/ui/screens/retrospective.js b/src/ui/screens/retrospective.js new file mode 100644 index 0000000..b5a7f04 --- /dev/null +++ b/src/ui/screens/retrospective.js @@ -0,0 +1,87 @@ +// The career retrospective. +// +// Structurally identical however the run went — this is a summary, not a +// scoreboard, and there is no win state to congratulate anyone on. It is also +// not an ending: the player can close it and carry on from the same turn. + +import { h } from '../dom.js'; +import { statPanel, characterCard } from '../components.js'; +import { formatMoney, labelFor } from '../format.js'; +import { summarize } from '../summary.js'; + +export function retrospectiveScreen(pack, state, schema, actions) { + const summary = summarize(pack, state); + const unit = pack.turnUnit ?? 'turn'; + + return h('div', { class: 'screen screen--retrospective' }, + h('header', { class: 'retro__head' }, + h('p', { class: 'retro__eyebrow' }, pack.name), + h('h1', { class: 'retro__title' }, 'Career retrospective'), + h('p', { class: 'retro__subtitle' }, + `${summary.turns} ${unit}${summary.turns === 1 ? '' : 's'} in the ` + + `${(pack.stages?.[summary.stage]?.label ?? summary.stage).toLowerCase()}.`), + ), + + h('section', { class: 'retro__section' }, + h('h2', { class: 'retro__heading' }, 'Where you ended up'), + statPanel(pack, state, schema), + ), + + h('section', { class: 'retro__section' }, + h('h2', { class: 'retro__heading' }, 'How it went'), + h('dl', { class: 'facts' }, + fact(`Highest your ${primaryLabel(pack, summary)} got`, + summary.peak === null ? '—' : formatMoney(summary.peak)), + fact('Lowest it got', summary.trough === null ? '—' : formatMoney(summary.trough)), + summary.daysInTheRed > 0 && fact(`${capitalize(unit)}s in the red`, String(summary.daysInTheRed)), + summary.bestDay && fact(`Best ${unit}`, + `${formatMoney(summary.bestDay.net)} — ${summary.bestDay.label}`), + summary.worstDay && fact(`Worst ${unit}`, + `${formatMoney(summary.worstDay.net)} — ${summary.worstDay.label}`), + summary.favouriteAction && fact('What you did most', + `${summary.favouriteAction.label} (${summary.favouriteAction.count}×)`), + fact('Situations you saw', `${summary.eventsSeen} of ${summary.eventsTotal}`), + summary.lockedEncounters > 0 && fact('Doors you could not open yet', + String(summary.lockedEncounters)), + ), + ), + + h('section', { class: 'retro__section' }, + h('h2', { class: 'retro__heading' }, 'Where you stand with people'), + h('div', { class: 'retro__people' }, + (pack.characters ?? []).map((character) => characterCard(pack, state, schema, character)), + ), + ), + + h('section', { class: 'retro__section' }, + h('h2', { class: 'retro__heading' }, 'Notable moments'), + summary.notable.length + ? h('ol', { class: 'moments' }, summary.notable.map((moment) => h('li', { class: 'moment' }, moment))) + : h('p', { class: 'moments moments--none' }, + 'Nothing that anybody would write down. That happens.'), + ), + + h('footer', { class: 'controls' }, + h('button', { class: 'button button--primary', onClick: actions.onResume }, + 'Keep going'), + h('button', { class: 'button button--quiet', onClick: actions.onExportSave }, 'Export save'), + h('button', { class: 'button button--quiet', onClick: actions.onExportFeedback }, + 'Export feedback log'), + h('button', { class: 'button button--quiet', onClick: actions.onNewGame }, 'Start again'), + ), + ); +} + +/** The pack's own word for its main resource, lowercased for mid-sentence use. */ +function primaryLabel(pack, summary) { + return summary.primaryPath ? labelFor(pack, summary.primaryPath).toLowerCase() : 'balance'; +} + +function fact(label, value) { + return h('div', { class: 'facts__item' }, + h('dt', { class: 'facts__label' }, label), + h('dd', { class: 'facts__value' }, value), + ); +} + +const capitalize = (text) => text.charAt(0).toUpperCase() + text.slice(1); diff --git a/src/ui/screens/turn.js b/src/ui/screens/turn.js new file mode 100644 index 0000000..3378c2d --- /dev/null +++ b/src/ui/screens/turn.js @@ -0,0 +1,171 @@ +// The turn screen, in two phases. +// +// Choosing: the scenario and its options, with locked ones shown greyed and +// labelled with what they are waiting for — a player should be able to see the +// door they cannot open yet, because that is most of what makes the numbers +// mean anything. +// +// Outcome: what happened, what moved, and what the day cost regardless of the +// choice. The feedback widget lives here rather than in the choosing phase, +// because this is the moment a player has an opinion. + +import { h } from '../dom.js'; +import { statPanel, characterCard, changeList } from '../components.js'; +import { requirementText, turnHeading, upcomingUpkeep } from '../format.js'; + +export function turnScreen(pack, state, schema, view, actions) { + const heading = turnHeading(pack, state); + + return h('div', { class: 'screen screen--turn' }, + h('header', { class: 'hud' }, + h('div', { class: 'hud__where' }, + h('p', { class: 'hud__stage' }, pack.stages?.[state.stage]?.title ?? state.stage), + h('h1', { class: 'hud__day' }, heading.title), + heading.detail && h('p', { class: 'hud__detail' }, heading.detail), + ), + statPanel(pack, state, schema), + ), + + diary(pack, state), + + h('div', { class: 'layout' }, + h('main', { class: 'scene' }, + view.phase === 'outcome' + ? outcomeView(pack, view, actions) + : choosingView(pack, view, actions), + ), + h('aside', { class: 'people' }, + h('h2', { class: 'people__heading' }, 'The mailroom'), + (pack.characters ?? []).map((character) => characterCard(pack, state, schema, character)), + ), + ), + + h('footer', { class: 'controls' }, + h('button', { class: 'button', onClick: actions.onRetrospective }, + 'Wrap up my career'), + h('button', { class: 'button button--quiet', onClick: actions.onExportSave }, 'Export save'), + h('button', { class: 'button button--quiet', onClick: actions.onImportSave }, 'Import save'), + h('button', { class: 'button button--quiet', onClick: actions.onExportFeedback }, + 'Export feedback log'), + h('button', { class: 'button button--quiet', onClick: actions.onNewGame }, 'New game'), + ), + ); +} + +/** + * What is coming and when. A player asked what the daily drain was for; the + * answer is that most of it is not daily, and this says so before the money + * moves rather than after. + */ +function diary(pack, state) { + const upcoming = upcomingUpkeep(pack, state); + if (!upcoming.length) return null; + + const unit = pack.turnUnit ?? 'turn'; + return h('p', { class: 'diary' }, + upcoming.map((entry, index) => h('span', { + class: `diary__entry diary__entry--${entry.incoming ? 'in' : 'out'}`, + }, + index > 0 ? ' · ' : '', + entry.label, + entry.turnsAway === 0 + ? ' today' + : entry.turnsAway === 1 ? ` tomorrow` : ` in ${entry.turnsAway} ${unit}s`, + )), + ); +} + +function choosingView(pack, view, actions) { + const { event, options } = view.turn; + const visible = options.filter((o) => !o.hidden); + + return h('section', { class: 'scenario' }, + h('p', { class: 'scenario__text' }, event.description), + h('ul', { class: 'options' }, + visible.map(({ option, available, failed }) => h('li', { class: 'options__item' }, + h('button', { + class: `option${available ? '' : ' option--locked'}`, + disabled: !available, + onClick: available ? () => actions.onChoose(option.id) : null, + }, + h('span', { class: 'option__label' }, option.label), + !available && h('span', { class: 'option__requirement' }, requirementText(pack, failed)), + ), + )), + ), + ); +} + +function outcomeView(pack, view, actions) { + const { record, option } = view; + + return h('section', { class: 'outcome' }, + h('p', { class: 'outcome__chose' }, option.label), + option.result && h('p', { class: 'outcome__text' }, option.result), + + h('div', { class: 'outcome__ledger' }, + h('h3', { class: 'outcome__heading' }, 'What that changed'), + changeList(pack, record.changes), + record.upkeep.length > 0 && h('div', { class: 'outcome__upkeep' }, + h('h3', { class: 'outcome__heading' }, 'And then the day happened'), + changeList(pack, record.upkeep), + ), + ), + + feedbackWidget(view, actions), + + h('button', { class: 'button button--primary button--next', onClick: actions.onNext }, + `Next ${pack.turnUnit ?? 'turn'}`), + ); +} + +// Entirely optional: ignoring it does nothing at all, which is the point. +function feedbackWidget(view, actions) { + const { feedback } = view; + const ratingButton = (value, label) => h('button', { + class: `rating${feedback.rating === value ? ' rating--on' : ''}`, + type: 'button', + 'aria-pressed': String(feedback.rating === value), + onClick: () => actions.onRate(value), + }, label); + + return h('section', { class: 'feedback' }, + h('div', { class: 'feedback__row' }, + h('span', { class: 'feedback__prompt' }, 'How was that?'), + h('div', { class: 'feedback__ratings' }, + ratingButton('up', '👍'), + ratingButton('neutral', '😐'), + ratingButton('down', '👎'), + ), + h('button', { + class: 'feedback__toggle', + type: 'button', + onClick: actions.onToggleFeedbackDetail, + }, view.feedbackOpen ? 'Fewer options' : 'Say more'), + ), + + view.feedbackOpen && h('div', { class: 'feedback__detail' }, + h('label', { class: 'field' }, + h('span', { class: 'field__label' }, 'Anything you want to tell the designer?'), + h('textarea', { + class: 'field__input', + rows: 2, + value: feedback.comment ?? '', + onInput: (e) => actions.onComment(e.target.value), + }), + ), + h('label', { class: 'field' }, + h('span', { class: 'field__label' }, + "Something you wanted to do that wasn't offered?"), + h('textarea', { + class: 'field__input', + rows: 2, + value: feedback.desiredAction ?? '', + onInput: (e) => actions.onDesiredAction(e.target.value), + }), + ), + ), + + feedback.saved && h('p', { class: 'feedback__saved' }, 'Noted — thank you.'), + ); +} diff --git a/src/ui/styles.css b/src/ui/styles.css new file mode 100644 index 0000000..0e9b72e --- /dev/null +++ b/src/ui/styles.css @@ -0,0 +1,289 @@ +/* The Ladder — one stylesheet, no framework. + The look is office stationery: manila, rules, stamped figures. Muted enough + that seventeen scenarios in a row do not tire the eye. */ + +:root { + --paper: #f2eee4; + --card: #fffdf7; + --ink: #26231d; + --ink-soft: #6b6659; + --ink-faint: #938c7c; + --rule: #ded6c4; + --rule-strong: #c9bfa8; + --accent: #7c5a2c; + --accent-soft: #efe3cd; + --up: #4a6b3d; + --down: #a4402f; + --focus: #2f6f9e; + + --radius: 3px; + --sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + --serif: Georgia, "Iowan Old Style", "Palatino Linotype", Palatino, serif; +} + +@media (prefers-color-scheme: dark) { + :root { + --paper: #1c1a17; + --card: #24211d; + --ink: #e8e3d8; + --ink-soft: #a8a294; + --ink-faint: #7d776a; + --rule: #38342d; + --rule-strong: #4a453b; + --accent: #c9a05e; + --accent-soft: #3a3227; + --up: #85ad72; + --down: #d17a68; + --focus: #6fa8d4; + } +} + +* { box-sizing: border-box; } + +body { + margin: 0; + background: var(--paper); + color: var(--ink); + font-family: var(--sans); + font-size: 16px; + line-height: 1.5; + -webkit-font-smoothing: antialiased; +} + +.app { max-width: 1080px; margin: 0 auto; padding: 24px 20px 48px; } +.booting { color: var(--ink-faint); padding: 48px 20px; text-align: center; } + +/* ---------- head-up display ---------- */ + +.hud { + display: flex; + flex-wrap: wrap; + gap: 20px 32px; + align-items: flex-end; + justify-content: space-between; + padding-bottom: 16px; + border-bottom: 2px solid var(--rule-strong); +} + +.hud__stage { + margin: 0; + font-size: 12px; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--ink-faint); +} + +.hud__day { margin: 2px 0 0; font-size: 26px; font-weight: 600; letter-spacing: -0.01em; } +.hud__detail { margin: 1px 0 0; font-size: 13px; color: var(--ink-soft); } + +.diary { + margin: 14px 0 0; padding: 8px 12px; + font-size: 12.5px; color: var(--ink-soft); + background: var(--card); border: 1px solid var(--rule); border-radius: var(--radius); +} +.diary__entry--in { color: var(--up); } +.diary__entry--out { color: var(--ink-soft); } + +.stats { display: flex; flex-wrap: wrap; gap: 10px 26px; align-items: flex-end; } + +.figure { display: flex; flex-direction: column; gap: 1px; min-width: 92px; } +.figure__label { + font-size: 11px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--ink-faint); +} +.figure__value { font-family: var(--serif); font-size: 21px; font-variant-numeric: tabular-nums; } +.figure__value--negative { color: var(--down); } + +/* ---------- meters ---------- */ + +.meter { min-width: 150px; } +.meter--compact { min-width: 132px; } +.meter__head { + display: flex; justify-content: space-between; gap: 10px; align-items: baseline; + font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--ink-faint); +} +.meter__value { font-variant-numeric: tabular-nums; color: var(--ink-soft); } +.meter__track { + margin-top: 4px; height: 5px; background: var(--rule); border-radius: 99px; overflow: hidden; +} +.meter__fill { + height: 100%; background: var(--accent); border-radius: 99px; + transition: width 280ms ease; +} + +/* ---------- layout ---------- */ + +.layout { + display: grid; + grid-template-columns: minmax(0, 1fr) 280px; + gap: 36px; + margin-top: 28px; + align-items: start; +} + +@media (max-width: 860px) { + .layout { grid-template-columns: minmax(0, 1fr); gap: 28px; } +} + +/* ---------- the scenario ---------- */ + +.scenario__text, .outcome__text { + font-family: var(--serif); + font-size: 20px; + line-height: 1.55; + margin: 0 0 24px; +} + +.options { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; } + +.option { + display: flex; flex-direction: column; gap: 3px; width: 100%; + padding: 14px 16px; text-align: left; cursor: pointer; + background: var(--card); color: inherit; font: inherit; + border: 1px solid var(--rule-strong); border-radius: var(--radius); + transition: border-color 120ms ease, transform 120ms ease, background 120ms ease; +} +.option:hover:not(:disabled) { border-color: var(--accent); transform: translateX(2px); } +.option:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; } +.option__label { font-size: 16px; } + +.option--locked { + cursor: not-allowed; background: transparent; border-style: dashed; + border-color: var(--rule); color: var(--ink-faint); +} +.option__requirement { + font-size: 12px; letter-spacing: 0.04em; color: var(--ink-faint); font-style: italic; +} + +/* ---------- outcome ---------- */ + +.outcome__chose { + margin: 0 0 10px; font-size: 12px; letter-spacing: 0.12em; + text-transform: uppercase; color: var(--accent); +} +.outcome__ledger { + margin: 26px 0; padding: 18px 20px; + background: var(--card); border: 1px solid var(--rule); border-radius: var(--radius); +} +.outcome__heading { + margin: 0 0 10px; font-size: 11px; letter-spacing: 0.12em; + text-transform: uppercase; color: var(--ink-faint); font-weight: 600; +} +.outcome__upkeep { margin-top: 18px; padding-top: 16px; border-top: 1px dashed var(--rule); } + +.changes { list-style: none; margin: 0; padding: 0; display: grid; gap: 5px; } +.changes--none { color: var(--ink-faint); font-style: italic; margin: 0; } +.change { display: flex; gap: 10px; align-items: baseline; font-size: 14px; } +.change__label { color: var(--ink-soft); } +.change__delta { font-variant-numeric: tabular-nums; font-weight: 600; margin-left: auto; } +.change--up .change__delta { color: var(--up); } +.change--down .change__delta { color: var(--down); } +.change__note { font-size: 11px; color: var(--ink-faint); font-style: italic; } + +.button--next { margin-top: 8px; } + +/* ---------- people ---------- */ + +.people__heading, .retro__heading { + margin: 0 0 12px; font-size: 11px; letter-spacing: 0.12em; + text-transform: uppercase; color: var(--ink-faint); font-weight: 600; +} + +.person { + padding: 16px; margin-bottom: 14px; + background: var(--card); border: 1px solid var(--rule); border-radius: var(--radius); +} +.person__name { margin: 0; font-size: 16px; font-weight: 600; } +.person__title { + margin: 1px 0 0; font-size: 11px; letter-spacing: 0.08em; + text-transform: uppercase; color: var(--ink-faint); +} +.person__description { + margin: 10px 0 14px; font-size: 13px; line-height: 1.5; color: var(--ink-soft); + font-family: var(--serif); +} +.person__meters { display: grid; gap: 8px; } + +/* ---------- feedback ---------- */ + +.feedback { + margin: 24px 0; padding: 14px 16px; + border: 1px dashed var(--rule-strong); border-radius: var(--radius); +} +.feedback__row { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; } +.feedback__prompt { font-size: 13px; color: var(--ink-soft); } +.feedback__ratings { display: flex; gap: 6px; } + +.rating { + padding: 4px 10px; font-size: 16px; line-height: 1.2; cursor: pointer; + background: transparent; border: 1px solid var(--rule); border-radius: var(--radius); + filter: grayscale(1); opacity: 0.65; transition: all 120ms ease; +} +.rating:hover { opacity: 1; filter: none; } +.rating--on { filter: none; opacity: 1; border-color: var(--accent); background: var(--accent-soft); } +.rating:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; } + +.feedback__toggle { + margin-left: auto; padding: 0; font: inherit; font-size: 12px; cursor: pointer; + background: none; border: none; color: var(--ink-faint); text-decoration: underline; +} +.feedback__detail { display: grid; gap: 12px; margin-top: 14px; } +.field { display: grid; gap: 4px; } +.field__label { font-size: 12px; color: var(--ink-faint); } +.field__input { + width: 100%; padding: 8px 10px; font: inherit; font-size: 14px; resize: vertical; + background: var(--card); color: inherit; + border: 1px solid var(--rule); border-radius: var(--radius); +} +.field__input:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; } +.feedback__saved { margin: 10px 0 0; font-size: 12px; color: var(--up); } + +/* ---------- buttons and controls ---------- */ + +.controls { + display: flex; flex-wrap: wrap; gap: 8px; align-items: center; + margin-top: 36px; padding-top: 18px; border-top: 1px solid var(--rule); +} + +.button { + padding: 9px 15px; font: inherit; font-size: 14px; cursor: pointer; + background: var(--card); color: inherit; + border: 1px solid var(--rule-strong); border-radius: var(--radius); + transition: border-color 120ms ease, background 120ms ease; +} +.button:hover { border-color: var(--accent); } +.button:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; } +.button--primary { background: var(--accent); border-color: var(--accent); color: #fffdf7; } +.button--primary:hover { filter: brightness(1.08); } +.button--quiet { border-color: transparent; background: transparent; color: var(--ink-faint); } +.button--quiet:hover { color: var(--ink); border-color: var(--rule); } + +/* ---------- retrospective ---------- */ + +.retro__head { padding-bottom: 20px; border-bottom: 2px solid var(--rule-strong); } +.retro__eyebrow { + margin: 0; font-size: 11px; letter-spacing: 0.14em; + text-transform: uppercase; color: var(--ink-faint); +} +.retro__title { margin: 6px 0 4px; font-size: 32px; font-weight: 600; letter-spacing: -0.02em; } +.retro__subtitle { margin: 0; color: var(--ink-soft); font-family: var(--serif); font-size: 17px; } + +.retro__section { margin-top: 34px; } +.retro__people { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 14px; } +.retro__people .person { margin-bottom: 0; } + +.facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: 14px; margin: 0; } +.facts__item { + padding: 12px 14px; background: var(--card); + border: 1px solid var(--rule); border-radius: var(--radius); +} +.facts__label { + font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--ink-faint); +} +.facts__value { margin: 3px 0 0; font-family: var(--serif); font-size: 16px; } + +.moments { margin: 0; padding-left: 22px; display: grid; gap: 7px; } +.moment { font-family: var(--serif); font-size: 16px; } +.moments--none { padding: 0; color: var(--ink-faint); font-style: italic; } + +.screen--error { padding: 40px 0; } +.screen--error h1 { font-size: 20px; color: var(--down); } diff --git a/src/ui/summary.js b/src/ui/summary.js new file mode 100644 index 0000000..f2f9ed7 --- /dev/null +++ b/src/ui/summary.js @@ -0,0 +1,67 @@ +// Reading a career back out of its history. +// +// Everything here is derived generically from the turn record and the pack's +// display map — no path is named. A pack with different stats, or none of them +// called money, gets the same retrospective for free. + +/** Paths the pack formats as money, in display order. */ +function moneyPaths(pack) { + return Object.entries(pack.display ?? {}) + .filter(([, rule]) => rule.format === 'money') + .sort((a, b) => (a[1].order ?? 99) - (b[1].order ?? 99)) + .map(([path]) => path); +} + +/** Every value a path passed through, turn by turn. */ +function trace(history, path) { + const values = []; + for (const record of history) { + for (const change of [...record.changes, ...record.upkeep]) { + if (change.path === path && typeof change.to === 'number') values.push(change.to); + } + } + return values; +} + +export function summarize(pack, state) { + const history = state.history ?? []; + const primary = moneyPaths(pack)[0] ?? null; + const primaryTrace = primary ? trace(history, primary) : []; + + const byLabel = new Map(); + for (const record of history) { + byLabel.set(record.optionLabel, (byLabel.get(record.optionLabel) ?? 0) + 1); + } + const favourite = [...byLabel.entries()].sort((a, b) => b[1] - a[1])[0] ?? null; + + const netByTurn = history.map((record) => ({ + turn: record.turn, + label: record.optionLabel, + net: [...record.changes, ...record.upkeep] + .filter((c) => c.path === primary && typeof c.to === 'number') + .reduce((sum, c) => sum + (c.to - c.from), 0), + })); + const ranked = [...netByTurn].sort((a, b) => b.net - a.net); + + const lockedEncounters = history.reduce( + (sum, record) => sum + record.offered.filter((o) => !o.available).length, 0, + ); + + const eventsSeen = new Set(history.map((r) => r.eventId)).size; + + return { + turns: history.length, + stage: state.stage, + notable: state.notable ?? [], + primaryPath: primary, + peak: primaryTrace.length ? Math.max(...primaryTrace) : null, + trough: primaryTrace.length ? Math.min(...primaryTrace) : null, + daysInTheRed: primaryTrace.filter((value) => value < 0).length, + favouriteAction: favourite ? { label: favourite[0], count: favourite[1] } : null, + bestDay: ranked[0]?.net > 0 ? ranked[0] : null, + worstDay: ranked.at(-1)?.net < 0 ? ranked.at(-1) : null, + lockedEncounters, + eventsSeen, + eventsTotal: (pack.events ?? []).length, + }; +} diff --git a/test/build.test.js b/test/build.test.js new file mode 100644 index 0000000..48d709f --- /dev/null +++ b/test/build.test.js @@ -0,0 +1,47 @@ +// The single-file build. +// +// A build that produces a file proves nothing; this one runs the bundled +// script against the fake DOM and plays a turn through it. That is the closest +// thing to opening dist/theladder.html that is available without a browser — +// it catches a broken bundle, which is otherwise only discovered by whoever +// was handed the file. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { installFakeDom, byClass, firstByClass } from './fixtures/fake-dom.js'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); + +test('the bundle builds, and the bundled game boots and plays', () => { + execFileSync('node', ['tools/build.js'], { cwd: root, stdio: 'pipe' }); + const html = readFileSync(resolve(root, 'dist/theladder.html'), 'utf8'); + + assert.doesNotMatch(html, /([\s\S]*?)<\/script>/)?.[1]; + assert.ok(script, 'the bundled script is present'); + + const dom = installFakeDom(); + try { + // eslint-disable-next-line no-new-func -- running the build output is the point + new Function(script)(); + + assert.ok(firstByClass(dom.root, 'screen--turn'), 'the bundled game rendered a turn'); + assert.match(dom.root.textContent, /Day 1/); + assert.match(dom.root.textContent, /Marlene Hobb/); + + const playable = byClass(dom.root, 'option').filter((b) => !b.disabled); + assert.ok(playable.length > 0, 'the bundled game offers choices'); + playable[0].click(); + assert.ok(firstByClass(dom.root, 'outcome'), 'a turn can be played in the bundle'); + } finally { + dom.restore(); + } +}); diff --git a/test/content.test.js b/test/content.test.js index d09abcc..e222422 100644 --- a/test/content.test.js +++ b/test/content.test.js @@ -17,6 +17,7 @@ import { corporateLadder as pack } from '../content/corporateladder/index.js'; import { validatePack } from '../src/engine/validate.js'; import { startGame, currentTurn, takeTurn } from '../src/engine/game.js'; import { createRng } from '../src/engine/rng.js'; +import { play, sweep, median } from './fixtures/strategies.js'; /** Play `turns` turns with independently-seeded random choices. */ function randomRun(runSeed, turns, visit = () => {}) { @@ -39,15 +40,11 @@ test('the pack is valid, with no warnings', () => { assert.deepEqual(result.warnings, []); }); -test('no content is dead: every event fires and every option can be taken', () => { - const firedEvents = new Set(); - const takenOptions = new Set(); - for (let seed = 0; seed < 60; seed++) { - randomRun(seed, 200, (turn, _state, choice) => { - firedEvents.add(turn.event.id); - takenOptions.add(`${turn.event.id}.${choice}`); - }); - } +test('no content is dead: every event and option is reachable by some player', () => { + // Across archetypes, not just random play: several things in the pack exist + // for a player pursuing something, and "broke but well-liked" in particular + // is a state no single-axis strategy ever reaches. + const { firedEvents, takenOptions } = sweep(pack, { seeds: 12, turns: 200 }); const allEvents = pack.events.map((e) => e.id); const allOptions = pack.events.flatMap((e) => e.options.map((o) => `${e.id}.${o.id}`)); @@ -55,24 +52,67 @@ test('no content is dead: every event fires and every option can be taken', () = assert.deepEqual(allOptions.filter((o) => !takenOptions.has(o)), [], 'options that can never be taken'); }); +test('how you play changes where you end up', () => { + const { byStrategy } = sweep(pack, { seeds: 12, turns: 200 }); + const finalMoney = (name) => median(byStrategy[name].map((s) => s.money)); + + // The shape that matters: minding the money climbs, ignoring it sinks, and + // playing without a plan roughly treads water. Exact figures will move with + // tuning; the ordering is the design. + assert.ok(finalMoney('thrifty') > finalMoney('random'), + 'a careful player should get ahead'); + assert.ok(finalMoney('random') > finalMoney('spendthrift'), + 'a careless player should struggle'); + assert.ok(finalMoney('thrifty') > 800, + `a careful player should be comfortably ahead, was ${finalMoney('thrifty')}`); +}); + +test('hard times is uncommon for a careful player and real for a careless one', () => { + const countHardTimes = (strategy) => { + let days = 0; + play(pack, { strategy, seed: 'ht', turns: 200, + visit: ({ turn }) => { if (turn.event.id === 'rent_bounced') days++; } }); + return days; + }; + assert.equal(countHardTimes('thrifty'), 0, 'minding the money should keep you out of trouble'); + assert.ok(countHardTimes('spendthrift') > 0, 'ignoring it should land you in it'); +}); + test('every turn always offers something to do, however badly the run is going', () => { // randomRun asserts this on every turn; 40 long runs is the coverage. for (let seed = 100; seed < 140; seed++) randomRun(seed, 150); }); -test('the economy bleeds, so doing nothing about money eventually bites', () => { - const turns = []; - for (let seed = 0; seed < 30; seed++) { - let firstHardTimes = null; - randomRun(seed, 120, (turn, state) => { - if (turn.event.id === 'rent_bounced' && firstHardTimes === null) firstHardTimes = state.turn; - }); - assert.ok(firstHardTimes, `run ${seed} never hit hard times in 120 turns`); - turns.push(firstHardTimes); +test('money arrives and leaves on a rhythm the player can plan around', () => { + const wages = []; + const rent = []; + let state = startGame(pack, { seed: 'rhythm' }); + for (let t = 0; t < 30; t++) { + const turn = currentTurn(pack, state); + const result = takeTurn(pack, state, turn.options.find((o) => o.available).option.id); + for (const change of result.record.upkeep) { + if (change.label === 'Wages') wages.push(state.turn); + if (change.label === 'Rent') rent.push(state.turn); + } + state = result.state; } - turns.sort((a, b) => a - b); - const median = turns[Math.floor(turns.length / 2)]; - assert.ok(median > 10 && median < 45, `hard times should land mid-run, median was turn ${median}`); + + assert.deepEqual(wages, [5, 19], 'paid on alternate Fridays'); + assert.deepEqual(rent, [8, 15, 22, 29], 'rent every Monday from the second week'); +}); + +test('the calendar names the day and counts the week', () => { + let state = startGame(pack, { seed: 'calendar' }); + assert.deepEqual( + [state.calendar.name, state.calendar.cycle, state.calendar.index], + ['Monday', 1, 0], + ); + for (let t = 0; t < 11; t++) { + const turn = currentTurn(pack, state); + state = takeTurn(pack, state, turn.options.find((o) => o.available).option.id).state; + } + assert.equal(state.turn, 12); + assert.deepEqual([state.calendar.name, state.calendar.cycle], ['Friday', 2]); }); test('hard times is survivable without any relationship or skill', () => { @@ -84,17 +124,22 @@ test('hard times is survivable without any relationship or skill', () => { test('the run recovers from hard times rather than sticking there', () => { let sawNegative = false; let recovered = false; - randomRun(7, 200, (_turn, state) => { - if (state.money < 0) sawNegative = true; - else if (sawNegative) recovered = true; - }); - assert.ok(sawNegative && recovered, 'money should go negative and come back'); + play(pack, { strategy: 'spendthrift', seed: 'recovery', turns: 200, + visit: ({ state }) => { + if (state.money < 0) sawNegative = true; + else if (sawNegative) recovered = true; + } }); + assert.ok(sawNegative, 'a careless run should go broke'); + assert.ok(recovered, 'and should be able to climb back out'); }); test('debt cannot spiral without limit: lending stops and the charge escalates', () => { let peakDebt = 0; - for (let seed = 0; seed < 40; seed++) { - randomRun(seed, 200, (_turn, state) => { peakDebt = Math.max(peakDebt, state.debt); }); + for (const strategy of ['spendthrift', 'desperate', 'random']) { + for (let seed = 0; seed < 12; seed++) { + play(pack, { strategy, seed, turns: 200, + visit: ({ state }) => { peakDebt = Math.max(peakDebt, state.debt); } }); + } } // The loan is gated at debt < 2000 and lends 500, so 2500 plus stalled // interest is the ceiling. Well above that means the gate has stopped working. diff --git a/test/fixtures/fake-dom.js b/test/fixtures/fake-dom.js new file mode 100644 index 0000000..9ca94c3 --- /dev/null +++ b/test/fixtures/fake-dom.js @@ -0,0 +1,162 @@ +// A minimal DOM, enough to drive the UI headlessly. +// +// Not a browser and not a substitute for clicking the real thing — it exists +// so that the wiring (imports, handlers, the render/choose/log path) is +// exercised by the test suite rather than discovered by a player. Anything +// involving real files or real downloads is out of its scope and has to be +// checked by hand. + +class FakeNode { + constructor() { + this.childNodes = []; + this.parentNode = null; + } + + get textContent() { + return this.childNodes.map((child) => child.textContent).join(''); + } +} + +class FakeText extends FakeNode { + constructor(text) { + super(); + this.nodeValue = String(text); + } + + get textContent() { + return this.nodeValue; + } +} + +class FakeElement extends FakeNode { + constructor(tagName) { + super(); + this.tagName = tagName.toUpperCase(); + this.className = ''; + this.id = ''; + this.attributes = {}; + this.listeners = {}; + this.dataset = {}; + this.style = ''; + // Predefined so `key in el` behaves the way it does in a real element: + // these are set as properties, everything else falls through to attributes. + this.disabled = false; + this.value = ''; + this.rows = 0; + this.type = ''; + this.accept = ''; + this.href = ''; + this.download = ''; + this.files = null; + } + + append(...nodes) { + for (const node of nodes) { + node.parentNode = this; + this.childNodes.push(node); + } + } + + replaceChildren(...nodes) { + this.childNodes = []; + this.append(...nodes); + } + + remove() { + const siblings = this.parentNode?.childNodes; + if (siblings) siblings.splice(siblings.indexOf(this), 1); + } + + setAttribute(name, value) { this.attributes[name] = String(value); } + getAttribute(name) { return this.attributes[name] ?? null; } + + addEventListener(type, handler) { + (this.listeners[type] ??= []).push(handler); + } + + dispatch(type, event = {}) { + for (const handler of this.listeners[type] ?? []) handler({ target: this, ...event }); + } + + click() { this.dispatch('click'); } +} + +function walk(node, out = []) { + out.push(node); + for (const child of node.childNodes) if (child instanceof FakeElement) walk(child, out); + return out; +} + +/** Every element under `root` whose class list contains `className`. */ +export function byClass(root, className) { + return walk(root).filter((el) => el.className.split(/\s+/).includes(className)); +} + +/** The first such element, or undefined. */ +export function firstByClass(root, className) { + return byClass(root, className)[0]; +} + +/** Every element of a tag. */ +export function byTag(root, tagName) { + return walk(root).filter((el) => el.tagName === tagName.toUpperCase()); +} + +class FakeStorage { + constructor() { this.map = new Map(); } + getItem(key) { return this.map.has(key) ? this.map.get(key) : null; } + setItem(key, value) { this.map.set(key, String(value)); } + removeItem(key) { this.map.delete(key); } + clear() { this.map.clear(); } +} + +/** + * Install the fake DOM as globals and return a handle for the test. Call the + * returned `restore` afterwards so suites do not leak into each other. + */ +export function installFakeDom() { + const root = new FakeElement('div'); + root.id = 'app'; + const document = { + createElement: (tag) => new FakeElement(tag), + createTextNode: (text) => new FakeText(text), + getElementById: (id) => (id === 'app' ? root : null), + body: new FakeElement('body'), + }; + const listeners = {}; + const window = { + addEventListener: (type, handler) => { (listeners[type] ??= []).push(handler); }, + }; + + const previous = {}; + const globals = { + document, + window, + Node: FakeNode, + localStorage: new FakeStorage(), + requestAnimationFrame: (fn) => setTimeout(fn, 0), + confirm: () => true, + alert: () => {}, + }; + for (const [key, value] of Object.entries(globals)) { + previous[key] = globalThis[key]; + globalThis[key] = value; + } + + return { + document, + window, + root, + storage: globals.localStorage, + /** Fire a window event, e.g. beforeunload. */ + fireWindow: (type) => { for (const handler of listeners[type] ?? []) handler({}); }, + restore() { + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) delete globalThis[key]; + else globalThis[key] = value; + } + }, + }; +} + +export { FakeElement, FakeText, FakeNode }; diff --git a/test/fixtures/strategies.js b/test/fixtures/strategies.js new file mode 100644 index 0000000..14d2a4e --- /dev/null +++ b/test/fixtures/strategies.js @@ -0,0 +1,87 @@ +// Player archetypes for content probing. +// +// "Is this content reachable?" is not the same question as "does random play +// reach it?" — a random player is not a plausible player, and several things +// in a pack exist precisely for a player pursuing something. Coverage is +// therefore the union across archetypes, and each one is a rough caricature of +// a way somebody actually plays. +// +// NOTE: seed the chooser differently from the game. Both draw one number per +// turn from the same generator, so a shared seed correlates the choice with +// the event draw and silently makes combinations unreachable. + +import { startGame, currentTurn, takeTurn } from '../../src/engine/game.js'; +import { createRng } from '../../src/engine/rng.js'; + +const moneyGain = (resolved) => (resolved.option.effects ?? []) + .filter((e) => e.path === 'money' && e.op === 'add') + .reduce((sum, e) => sum + e.value, 0); + +const relationshipGain = (resolved) => (resolved.option.effects ?? []) + .filter((e) => e.path.startsWith('relationships.')) + .reduce((sum, e) => sum + e.value, 0); + +const best = (options, score) => [...options].sort((a, b) => score(b) - score(a))[0]; + +export const strategies = { + /** No plan at all. */ + random: (options, rng) => rng.pick(options), + /** Takes the money every time. */ + thrifty: (options) => best(options, moneyGain), + /** Spends it every time. */ + spendthrift: (options) => best(options, (o) => -moneyGain(o)), + /** Chases standing with people. */ + sociable: (options) => best(options, relationshipGain), + /** + * Broke but well-liked — the state the "ask a friend for money" options were + * written for, and one no single-axis strategy ever reaches. + */ + desperate: (options, rng) => (rng.next() < 0.25 + ? rng.pick(options) + : best(options, (o) => relationshipGain(o) - moneyGain(o) / 40)), +}; + +/** Play one run to `turns`, calling `visit` before each choice. */ +export function play(pack, { strategy = 'random', seed = 'run', turns = 200, visit } = {}) { + const choose = strategies[strategy]; + const rng = createRng(`chooser-${strategy}-${seed}`); + let state = startGame(pack, { seed: `game-${strategy}-${seed}` }); + + for (let t = 0; t < turns; t++) { + const turn = currentTurn(pack, state); + const available = turn.options.filter((o) => o.available); + if (available.length === 0) throw new Error(`no available option on "${turn.event.id}"`); + const chosen = choose(available, rng); + visit?.({ turn, state, chosen }); + state = takeTurn(pack, state, chosen.option.id).state; + } + return state; +} + +/** Play every archetype across several seeds, reporting what was reached. */ +export function sweep(pack, { seeds = 12, turns = 200 } = {}) { + const firedEvents = new Set(); + const takenOptions = new Set(); + const byStrategy = {}; + + for (const strategy of Object.keys(strategies)) { + const finals = []; + for (let seed = 0; seed < seeds; seed++) { + const state = play(pack, { + strategy, + seed, + turns, + visit: ({ turn, chosen }) => { + firedEvents.add(turn.event.id); + takenOptions.add(`${turn.event.id}.${chosen.option.id}`); + }, + }); + finals.push(state); + } + byStrategy[strategy] = finals; + } + return { firedEvents, takenOptions, byStrategy }; +} + +export const median = (values) => + [...values].sort((a, b) => a - b)[Math.floor(values.length / 2)]; diff --git a/test/game.test.js b/test/game.test.js index 89cd208..acd79d8 100644 --- a/test/game.test.js +++ b/test/game.test.js @@ -202,3 +202,50 @@ test('a character can start somewhere other than the pack default', () => { assert.equal(state.relationships.pat.fears_you, 50, 'unnamed stats keep the pack default'); assert.equal(state.relationships.chief.likes_you, 50, 'other characters are unaffected'); }); + +test('a pack with a calendar gets day-of-cycle state it can gate on', () => { + const dated = structuredClone(testPack); + dated.calendar = { cycleLength: 7, cycleName: 'Week', unitNames: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] }; + + let state = startGame(dated, { seed: 'calendar' }); + assert.deepEqual( + { index: state.calendar.index, cycle: state.calendar.cycle, name: state.calendar.name }, + { index: 0, cycle: 1, name: 'Mon' }, + ); + + for (let i = 0; i < 7; i++) { + state = takeTurn(dated, state, currentTurn(dated, state).options.find((o) => o.available).option.id).state; + } + assert.equal(state.turn, 8); + assert.deepEqual( + { index: state.calendar.index, cycle: state.calendar.cycle, name: state.calendar.name }, + { index: 0, cycle: 2, name: 'Mon' }, + ); +}); + +test('a pack with no calendar has none: the engine imposes no week', () => { + const state = startGame(testPack, { seed: 'no-calendar' }); + assert.deepEqual(state.calendar, {}); +}); + +test('scheduled upkeep falls due on its own period, not every turn', () => { + const scheduled = structuredClone(testPack); + scheduled.upkeep = [ + { label: 'daily', effects: [{ path: 'money', op: 'add', value: -1 }] }, + { label: 'weekly rent', every: 7, offset: 6, effects: [{ path: 'money', op: 'add', value: -50 }] }, + { label: 'fortnightly pay', every: 14, offset: 6, effects: [{ path: 'money', op: 'add', value: 200 }] }, + ]; + + let state = startGame(scheduled, { seed: 'schedule' }); + const dueOn = []; + for (let turn = 1; turn <= 22; turn++) { + const result = takeTurn(scheduled, state, currentTurn(scheduled, state).options.find((o) => o.available).option.id); + dueOn.push(result.record.upkeep.map((c) => c.label)); + state = result.state; + } + + assert.deepEqual(dueOn[0], ['daily'], 'turn 1 is an ordinary day'); + assert.deepEqual(dueOn[6], ['daily', 'weekly rent', 'fortnightly pay'], 'turn 7 is rent and payday'); + assert.deepEqual(dueOn[13], ['daily', 'weekly rent'], 'turn 14 is rent only'); + assert.deepEqual(dueOn[20], ['daily', 'weekly rent', 'fortnightly pay'], 'turn 21 is both again'); +}); diff --git a/test/ui.test.js b/test/ui.test.js new file mode 100644 index 0000000..0a9b241 --- /dev/null +++ b/test/ui.test.js @@ -0,0 +1,277 @@ +// UI wiring tests. +// +// These drive the real app module against a fake DOM (test/fixtures/fake-dom.js). +// They prove the wiring runs — imports resolve, handlers fire, the screens +// render the right text, choices reach the engine, saves and the feedback log +// are written. They are not a substitute for playing it in a browser: layout, +// styling, file downloads and file imports are outside their reach. + +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() { + const dom = installFakeDom(); + // 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); + return dom; +} + +const text = (root) => root.textContent; +const optionButtons = (root) => byClass(root, 'option'); +const enabledOptions = (root) => optionButtons(root).filter((b) => !b.disabled); + +test('the app boots to a playable turn screen', async () => { + const dom = await boot(); + try { + assert.ok(firstByClass(dom.root, 'screen--turn'), 'the turn screen rendered'); + assert.match(text(dom.root), /Day 1/, 'the day counter is showing'); + assert.match(text(dom.root), /Marlene Hobb/, 'the characters are listed'); + assert.match(text(dom.root), /Trevor Pike/); + assert.ok(enabledOptions(dom.root).length > 0, 'there is something to click'); + } finally { + dom.restore(); + } +}); + +test('the header shows the pack\'s declared stats, and hides what it asks to hide', async () => { + const dom = await boot(); + try { + const stats = text(firstByClass(dom.root, 'stats')); + assert.match(stats, /Money/); + assert.match(stats, /\$520/, 'money is formatted as money'); + assert.match(stats, /Negotiation/); + assert.match(stats, /Standing/, 'reputation is renamed by the pack'); + assert.doesNotMatch(stats, /Debt/, 'debt is hidden while it is zero'); + assert.doesNotMatch(stats, /Notable/, 'hidden state stays out of the header'); + } finally { + dom.restore(); + } +}); + +test('choosing an option shows the outcome, the deltas and the upkeep', async () => { + const dom = await boot(); + try { + enabledOptions(dom.root)[0].click(); + + assert.ok(firstByClass(dom.root, 'outcome'), 'the outcome view rendered'); + assert.match(text(dom.root), /What that changed/); + assert.match(text(dom.root), /And then the day happened/, 'upkeep is shown separately'); + + const upkeep = text(firstByClass(dom.root, 'outcome__upkeep')); + // Labelled by what caused it, not by the path it moved — this is the + // answer to "what was that daily drain for?". + assert.match(upkeep, /Coffee, transit, lunch/); + assert.doesNotMatch(upkeep, /Money/, 'the bare path name is not useful here'); + assert.ok(byClass(dom.root, 'change').length > 0, 'individual changes are listed'); + } finally { + dom.restore(); + } +}); + +test('advancing moves to the next turn', async () => { + const dom = await boot(); + try { + enabledOptions(dom.root)[0].click(); + firstByClass(dom.root, 'button--next').click(); + + assert.ok(firstByClass(dom.root, 'scenario'), 'back to choosing'); + assert.match(text(dom.root), /Day 2/); + } finally { + dom.restore(); + } +}); + +test('a locked option is shown, disabled, and says what it needs', async () => { + const dom = await boot(); + try { + // Play until a locked option appears — the pack gates several. + let locked = null; + for (let i = 0; i < 40 && !locked; i++) { + locked = optionButtons(dom.root).find((b) => b.disabled); + if (locked) break; + enabledOptions(dom.root)[0].click(); + firstByClass(dom.root, 'button--next').click(); + } + assert.ok(locked, 'a gated option should turn up within 40 turns'); + assert.match(locked.textContent, /Needs /, 'the requirement is spelled out'); + locked.click(); // must be inert + assert.ok(firstByClass(dom.root, 'scenario'), 'clicking a locked option does nothing'); + } finally { + dom.restore(); + } +}); + +test('progress is written to local storage and restored on the next boot', async () => { + const dom = await boot(); + try { + enabledOptions(dom.root)[0].click(); + firstByClass(dom.root, 'button--next').click(); + enabledOptions(dom.root)[0].click(); + firstByClass(dom.root, 'button--next').click(); + + const saved = JSON.parse(dom.storage.getItem('theladder.save.v1')); + assert.equal(saved.state.turn, 3); + + // Boot again against the same storage: the run should resume, not restart. + const { boot: bootAgain } = await import(`../src/ui/app.js?t=${Math.random()}`); + const secondRoot = dom.document.createElement('div'); + bootAgain(secondRoot); + assert.match(text(secondRoot), /Day 3/, 'the saved run resumed'); + } finally { + dom.restore(); + } +}); + +test('every played turn is logged, separately from the save', async () => { + const dom = await boot(); + try { + for (let i = 0; i < 3; i++) { + enabledOptions(dom.root)[0].click(); + firstByClass(dom.root, 'button--next').click(); + } + + const log = JSON.parse(dom.storage.getItem('theladder.feedback.v1')); + assert.equal(log.length, 3); + + const entry = log[0]; + assert.ok(entry.event_id && entry.action_taken, 'the choice is recorded'); + assert.ok(Array.isArray(entry.changes) && entry.changes.length > 0, 'the diff is recorded'); + 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')); + assert.equal(save.state.history.length, 3); + assert.ok(!JSON.stringify(save).includes('rating'), 'feedback is not mixed into the save'); + } finally { + dom.restore(); + } +}); + +test('the feedback widget is optional, and records a rating when used', async () => { + const dom = await boot(); + try { + enabledOptions(dom.root)[0].click(); + + const ratings = byClass(dom.root, 'rating'); + assert.equal(ratings.length, 3, 'up, neutral and down'); + + ratings[0].click(); + const log = JSON.parse(dom.storage.getItem('theladder.feedback.v1')); + assert.equal(log[0].rating, 'up'); + + // Clicking the same rating again clears it. + byClass(dom.root, 'rating')[0].click(); + assert.equal(JSON.parse(dom.storage.getItem('theladder.feedback.v1'))[0].rating, null); + } finally { + dom.restore(); + } +}); + +test('the comment fields write through without re-rendering under the cursor', async () => { + const dom = await boot(); + try { + enabledOptions(dom.root)[0].click(); + firstByClass(dom.root, 'feedback__toggle').click(); + + const fields = byTag(firstByClass(dom.root, 'feedback__detail'), 'textarea'); + assert.equal(fields.length, 2, 'a comment box and a "what did you want to do" box'); + + fields[0].value = 'the wheel bit made me laugh'; + fields[0].dispatch('input'); + fields[1].value = 'I wanted to just hide in the stairwell'; + fields[1].dispatch('input'); + + // The same element objects must still be live — a re-render would have + // replaced them and thrown the player out of the box they are typing in. + assert.equal(byTag(firstByClass(dom.root, 'feedback__detail'), 'textarea')[0], fields[0]); + + // Written through on the way to the next turn. + firstByClass(dom.root, 'button--next').click(); + const entry = JSON.parse(dom.storage.getItem('theladder.feedback.v1'))[0]; + assert.equal(entry.comment, 'the wheel bit made me laugh'); + assert.equal(entry.desired_but_unavailable_action, 'I wanted to just hide in the stairwell'); + } finally { + dom.restore(); + } +}); + +test('the retrospective summarises the run and is not an ending', async () => { + const dom = await boot(); + try { + for (let i = 0; i < 6; i++) { + enabledOptions(dom.root)[0].click(); + firstByClass(dom.root, 'button--next').click(); + } + firstByClass(dom.root, 'button').click(); // "Wrap up my career" + + assert.ok(firstByClass(dom.root, 'screen--retrospective')); + const body = text(dom.root); + assert.match(body, /Career retrospective/); + assert.match(body, /6 days in the mailroom/); + assert.match(body, /Where you stand with people/); + assert.match(body, /Marlene Hobb/); + assert.match(body, /Situations you saw/); + + firstByClass(dom.root, 'button--primary').click(); // "Keep going" + assert.ok(firstByClass(dom.root, 'screen--turn'), 'the player can carry on'); + assert.match(text(dom.root), /Day 7/); + } finally { + dom.restore(); + } +}); + +test('a run with nothing notable still gets a retrospective', async () => { + const dom = await boot(); + try { + firstByClass(dom.root, 'button').click(); // wrap up on turn one + assert.ok(firstByClass(dom.root, 'screen--retrospective')); + assert.match(text(dom.root), /Nothing that anybody would write down/); + } finally { + dom.restore(); + } +}); + +test('the header names the day and counts the week', async () => { + const dom = await boot(); + try { + const hud = text(firstByClass(dom.root, 'hud')); + assert.match(hud, /Monday/, 'the day is named'); + assert.match(hud, /Week 1 · Day 1/, 'the week and the day number are both shown'); + } finally { + dom.restore(); + } +}); + +test('the diary says what is coming, so the drain is not a mystery', async () => { + const dom = await boot(); + try { + const diary = text(firstByClass(dom.root, 'diary')); + assert.match(diary, /Wages in 4 days/, 'payday is announced before it lands'); + assert.match(diary, /Rent in 7 days/); + assert.doesNotMatch(diary, /Loan service/, 'charges that do not apply are not listed'); + } finally { + dom.restore(); + } +}); + +test('wages and rent appear in the ledger on the days they fall due', async () => { + const dom = await boot(); + try { + const ledgerOn = []; + for (let day = 1; day <= 8; day++) { + enabledOptions(dom.root)[0].click(); + const upkeep = firstByClass(dom.root, 'outcome__upkeep'); + ledgerOn.push(upkeep ? upkeep.textContent : ''); + firstByClass(dom.root, 'button--next').click(); + } + assert.match(ledgerOn[0], /Coffee, transit, lunch/, 'small change every day'); + assert.doesNotMatch(ledgerOn[0], /Wages/, 'no wages on day 1'); + assert.match(ledgerOn[4], /Wages/, 'paid on day 5, the first Friday'); + assert.match(ledgerOn[7], /Rent/, 'rent on day 8, the second Monday'); + } finally { + dom.restore(); + } +}); diff --git a/test/validate.test.js b/test/validate.test.js index 57189ea..e102708 100644 --- a/test/validate.test.js +++ b/test/validate.test.js @@ -141,3 +141,26 @@ test('a starting relationship for a stat that does not exist is caught', () => { }); assert.match(messages(result), /unknown stat "respects_you"/); }); + +test('a malformed calendar is caught', () => { + const badLength = broken((pack) => { pack.calendar = { cycleLength: 0 }; }); + assert.match(messages(badLength), /cycleLength 0 must be a positive/); + + const mismatched = broken((pack) => { + pack.calendar = { cycleLength: 7, unitNames: ['Mon', 'Tue'] }; + }); + assert.match(messages(mismatched), /unitNames has 2 entries but cycleLength is 7/); +}); + +test('a malformed upkeep schedule is caught', () => { + const badEvery = broken((pack) => { + pack.upkeep = [{ label: 'rent', every: 0, effects: [{ path: 'money', op: 'add', value: -1 }] }]; + }); + assert.match(messages(badEvery), /every 0 must be a positive/); + + const orphanOffset = broken((pack) => { + pack.upkeep = [{ label: 'rent', offset: 3, effects: [{ path: 'money', op: 'add', value: -1 }] }]; + }); + assert.deepEqual(orphanOffset.errors, []); + assert.match(orphanOffset.warnings.join('\n'), /offset has no effect without every/); +}); diff --git a/tools/build.js b/tools/build.js new file mode 100644 index 0000000..9e8bd9e --- /dev/null +++ b/tools/build.js @@ -0,0 +1,147 @@ +// Build a single self-contained HTML file. +// +// Chrome and Firefox refuse ES module imports over file://, so the modular +// source that is pleasant to develop cannot be the thing you hand someone. +// This inlines the stylesheet and flattens the module graph into one script, +// producing a file that plays by double-clicking it. +// +// It is a deliberately small bundler that understands only the module syntax +// this project actually uses, and throws on anything it does not recognise — +// a silent mis-bundle would be far worse than a failed build. + +import { readFile, writeFile, mkdir } from 'node:fs/promises'; +import { dirname, resolve, relative } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const entryHtml = resolve(root, 'index.html'); +const outDir = resolve(root, 'dist'); +const outFile = resolve(outDir, 'theladder.html'); + +const id = (path) => relative(root, path).replaceAll('\\', '/'); + +/** Rewrite one module's source into a registry factory. */ +function transform(source, path) { + const exports = []; + let body = source; + + // import { a, b as c } from './x.js' + body = body.replace( + /^[ \t]*import\s*\{([^}]*)\}\s*from\s*['"]([^'"]+)['"];?[ \t]*$/gm, + (_m, names, from) => `const {${names.replaceAll(' as ', ': ')}} = __require(${JSON.stringify(id(resolve(dirname(path), from)))});`, + ); + // import * as ns from './x.js' + body = body.replace( + /^[ \t]*import\s*\*\s*as\s+(\w+)\s+from\s*['"]([^'"]+)['"];?[ \t]*$/gm, + (_m, name, from) => `const ${name} = __require(${JSON.stringify(id(resolve(dirname(path), from)))});`, + ); + // import name from './x.js' + body = body.replace( + /^[ \t]*import\s+(\w+)\s+from\s*['"]([^'"]+)['"];?[ \t]*$/gm, + (_m, name, from) => `const ${name} = __require(${JSON.stringify(id(resolve(dirname(path), from)))}).default;`, + ); + + // export { a, b } (no `from` — re-exports are not used in this project) + body = body.replace(/^[ \t]*export\s*\{([^}]*)\};?[ \t]*$/gm, (_m, names) => { + for (const entry of names.split(',').map((n) => n.trim()).filter(Boolean)) { + const [local, exported = local] = entry.split(/\s+as\s+/).map((n) => n.trim()); + exports.push([exported, local]); + } + return ''; + }); + + // export function / const / let / class + body = body.replace(/^[ \t]*export\s+(async\s+)?(function|const|let|class)\s+(\w+)/gm, (_m, async_, kind, name) => { + exports.push([name, name]); + return `${async_ ?? ''}${kind} ${name}`; + }); + + // export default + body = body.replace(/^[ \t]*export\s+default\s+/gm, () => { + exports.push(['default', '__default']); + return 'const __default = '; + }); + + const leftover = body.match(/^\s*(import|export)\s/m); + if (leftover) { + throw new Error(`${id(path)}: unsupported module syntax near "${leftover[0].trim()}"`); + } + + const assignments = exports.map(([exported, local]) => + ` __exports[${JSON.stringify(exported)}] = ${local};`).join('\n'); + + return `__define(${JSON.stringify(id(path))}, (__exports, __require) => {\n${body}\n${assignments}\n});`; +} + +async function collect(entryPath, seen = new Map()) { + if (seen.has(entryPath)) return seen; + const source = await readFile(entryPath, 'utf8'); + seen.set(entryPath, null); // reserve the slot before recursing + + const specifiers = [...source.matchAll(/^[ \t]*import\s.*?from\s*['"]([^'"]+)['"];?[ \t]*$/gm)] + .map((match) => match[1]) + .filter((specifier) => specifier.startsWith('.')); + + for (const specifier of specifiers) { + await collect(resolve(dirname(entryPath), specifier), seen); + } + seen.set(entryPath, transform(source, entryPath)); + return seen; +} + +async function build() { + const html = await readFile(entryHtml, 'utf8'); + + const styleMatch = html.match(//); + if (!styleMatch) throw new Error('index.html has no stylesheet link to inline'); + const css = await readFile(resolve(root, styleMatch[1]), 'utf8'); + + const scriptMatch = html.match(/`); + + await mkdir(outDir, { recursive: true }); + await writeFile(outFile, output, 'utf8'); + + const kb = (Buffer.byteLength(output) / 1024).toFixed(1); + console.log(`built ${id(outFile)} — ${modules.size} modules, ${kb} kB`); +} + +build().catch((error) => { + console.error(`build failed: ${error.message}`); + process.exitCode = 1; +});