From e482f071573106ef67a54fa679704d0e7dede112 Mon Sep 17 00:00:00 2001 From: JesseMarkowitz Date: Thu, 10 Sep 2026 04:44:49 -0400 Subject: [PATCH] Add the interface, then rebuild the economy around the first playtest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MVP is playable: a turn screen, a career retrospective, an optional feedback widget, local-storage autosave with file export/import, and a build that flattens everything into one self-contained HTML file. A turn has two phases — the scenario and its options, then what the choice did and, separately, what the day cost regardless. Numbers that move without the player seeing why are most of what makes them meaningless. Locked options are shown greyed with the requirement spelled out rather than hidden, so a player can see the door they cannot open yet. The interface is named entirely by the pack. `pack.display` maps a state path to a label, a format and an order, which is why the header reads "Standing" for a path called `reputation` and why debt disappears while it is zero. The first playtest then found three things, all of them fair. *"Wasn't clear what daily drain was for."* The ledger was labelling changes by the path that moved — "Money −$18" — when the upkeep entry that caused them had a perfectly good name. It now says "Coffee, transit, lunch". The labels were in the data the whole time and never reached the screen. *"Should probably pay rent weekly / get paid every other week… would be good to have day of week and week # shown."* Packs can now declare a calendar, and the engine derives the day name, cycle and index from the turn number before the event is drawn — so content can require a Friday and the header can say "Thursday · Week 2 · Day 12". Upkeep entries take `every` and `offset`, making wages fall on alternate Fridays and rent every Monday. The turn screen carries a diary line — "Wages tomorrow · Rent in 4 days" — computed generically from whatever a pack schedules. *"Always in debt — could never get ahead."* The old economy bled $25 a day regardless of play. It is now roughly break-even at baseline, with a `spare_shift` event that appears *because* you are broke: the way out of a hole should be visible from inside it. Across archetypes a careful player ends around $1,400, an unplanned one treads water, and a careless one sinks into real debt. That retune broke the coverage test, usefully. Random play stopped reaching hard times at all, which made the entire debt branch look dead — it was not, since random play is not a plausible player. Coverage is now the union across five archetypes, including one that is broke *and* well-liked, because the "ask a friend for money" options sit in a state no single-axis strategy reaches. Not covered by any of this: styling and dark mode, which only a human with a browser can check. This machine has none that can render a page. 109 tests. Reasoning in docs/DECISIONS.md §14-21. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VMSFHyVPitUoosW5wyEADj --- README.md | 73 ++++- content/corporateladder/events/hard-times.js | 6 +- content/corporateladder/events/routine.js | 87 ++++++ content/corporateladder/index.js | 62 +++- docs/DECISIONS.md | 112 +++++++ index.html | 22 ++ src/engine/game.js | 43 ++- src/engine/state.js | 4 +- src/engine/validate.js | 19 ++ src/io/download.js | 30 ++ src/io/feedback-log.js | 103 +++++++ src/io/save.js | 77 +++++ src/ui/app.js | 230 +++++++++++++++ src/ui/components.js | 92 ++++++ src/ui/dom.js | 26 ++ src/ui/engine-bridge.js | 34 +++ src/ui/format.js | 151 ++++++++++ src/ui/screens/retrospective.js | 87 ++++++ src/ui/screens/turn.js | 171 +++++++++++ src/ui/styles.css | 289 +++++++++++++++++++ src/ui/summary.js | 67 +++++ test/build.test.js | 47 +++ test/content.test.js | 101 +++++-- test/fixtures/fake-dom.js | 162 +++++++++++ test/fixtures/strategies.js | 87 ++++++ test/game.test.js | 47 +++ test/ui.test.js | 277 ++++++++++++++++++ test/validate.test.js | 23 ++ tools/build.js | 147 ++++++++++ 29 files changed, 2613 insertions(+), 63 deletions(-) create mode 100644 index.html create mode 100644 src/io/download.js create mode 100644 src/io/feedback-log.js create mode 100644 src/io/save.js create mode 100644 src/ui/app.js create mode 100644 src/ui/components.js create mode 100644 src/ui/dom.js create mode 100644 src/ui/engine-bridge.js create mode 100644 src/ui/format.js create mode 100644 src/ui/screens/retrospective.js create mode 100644 src/ui/screens/turn.js create mode 100644 src/ui/styles.css create mode 100644 src/ui/summary.js create mode 100644 test/build.test.js create mode 100644 test/fixtures/fake-dom.js create mode 100644 test/fixtures/strategies.js create mode 100644 test/ui.test.js create mode 100644 tools/build.js 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; +});