The stamping scheme was wrong in a way worth recording. tools/stamp.js rewrote
src/build-info.js before every serve and build, so after each commit the
committed stamp named the *previous* commit — as it does right now, reading
76072ed+ while HEAD is 5c1784d — and the next serve rewrote it and dirtied the
tree again. A generated value does not belong in a tracked file if anything
routinely regenerates it.
src/build-info.js is now permanent and reads `commit: 'dev'`. tools/build.js
substitutes the real commit into the bundled output only, and fails loudly if
the substitution finds nothing to replace. So dist/theladder.html names the
commit that produced it, running from source honestly reads "0.3.0 · dev", and
the working tree never churns. tools/stamp.js is gone and `npm run serve` is a
plain static server again.
Also here, for picking this up later: a "Where things stand" section in the
README with the five open questions in the order they are likely to matter —
whether money stops mattering late, the negotiation gate that four early options
sit behind, the feedback widget nobody uses, how thin Dispatch is next to the
mailroom, and third-tier versus second-pack.
121 tests. docs/DECISIONS.md §25 corrected to describe what the code now does.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VMSFHyVPitUoosW5wyEADj
424 lines
21 KiB
Markdown
424 lines
21 KiB
Markdown
# Architecture decisions
|
||
|
||
Why the code is shaped the way it is. Append to this file as decisions are
|
||
made; the reasoning is worth more later than the conclusion.
|
||
|
||
## 1. All game state is addressable by path
|
||
|
||
Every value in a run — `money`, `skills.negotiation`,
|
||
`relationships.boss.likes_you`, `flags.took_loan` — is reachable by a dotted
|
||
path string, and conditions and effects are expressed against those paths.
|
||
|
||
The planning docs' schema had `requires: { skill, min }` and
|
||
`effects: { money, skills, relationships }`. Both hardcode the state shape into
|
||
the engine: the first gate that wants a relationship threshold, a money floor
|
||
or a flag, and the first effect that touches a fourth kind of state, means
|
||
editing engine code. Path addressing costs one `getPath`/`setPath` pair and
|
||
removes the whole class of change.
|
||
|
||
Everything else in the engine is built on this primitive: option gates, event
|
||
triggers, the soft-failure branch, and — later — promotion tiers.
|
||
|
||
## 2. Conditions and effects are data, evaluated generically
|
||
|
||
A condition is `{ path, op, value }` or a combinator (`all` / `any` / `not`).
|
||
An effect is `{ path, op, value }` with `add` / `set` / `push` / `remove`.
|
||
The engine knows the operators; it never knows what a path *means*.
|
||
|
||
Bounds live in the pack's state declaration (`min` / `max`) and are applied by
|
||
the effect layer, so clamping is content's decision, not the engine's.
|
||
|
||
## 3. There is no such thing as a "random event"
|
||
|
||
Every turn, the engine filters the entire event pool by stage and conditions,
|
||
then draws by weight. A routine turn is a high-weight event with loose
|
||
conditions; a rare interruption is a low-weight one; the hard-times branch is
|
||
an event whose conditions include a money threshold. The engine branches on
|
||
none of it, which is what the planning docs asked for in
|
||
`03-claude-code-build-prompt.md` §3 — but achieved by deleting the category
|
||
rather than by carefully not special-casing it.
|
||
|
||
Consequence: every stage needs at least one event that no exclusion rule can
|
||
remove (no `requires`, no `once`, no `cooldown`), or the pool can run dry
|
||
mid-run. The validator enforces this.
|
||
|
||
## 4. The engine owns some state; content may read it but not write it
|
||
|
||
`turn`, `stage`, `seen`, `lastSeen`, `history` and `meta` are engine
|
||
bookkeeping. Content can read them in conditions — "only after day 10", "only
|
||
if this event has never fired" — which is useful and safe. Writing them is a
|
||
validation error, so data cannot corrupt the loop.
|
||
|
||
`seen.<eventId>` is a count and `lastSeen.<eventId>` a turn number, which is
|
||
why event ids must be valid path segments.
|
||
|
||
## 5. Seeded, serializable RNG
|
||
|
||
Not in the planning docs; added because it is nearly free and pays three ways:
|
||
tests are deterministic, playtest reports are reproducible from seed plus
|
||
choice sequence, and the feedback log becomes replayable rather than merely
|
||
descriptive. mulberry32 was chosen because its entire state is one uint32, so
|
||
the generator's position round-trips through JSON with no special handling.
|
||
|
||
The seed and position live in `meta` inside the save file. Nothing about a run
|
||
exists outside its state object.
|
||
|
||
## 6. The turn loop is a pure function
|
||
|
||
`takeTurn(pack, state, optionId) -> { state, record }`. No mutable game object,
|
||
no hidden state. A saved game resumes into exactly the run it left — there is a
|
||
test that plays 30 turns straight, plays the same 30 turns with a JSON round
|
||
trip in the middle, and deep-equals the results.
|
||
|
||
## 7. Content is validated at load, not discovered at play
|
||
|
||
`validatePack` checks that every path referenced by a condition or effect is
|
||
declared, every character id resolves, ids are usable as path segments, no
|
||
event is unreachable, and every stage has a fallback. Without it, a typo in
|
||
content fails silently forty turns into a playthrough — the exact failure mode
|
||
that makes "just add data" unsafe for a non-programmer.
|
||
|
||
## 8. Distribution: ES modules in dev, one inlined file to ship
|
||
|
||
Chrome and Firefox block ES module imports and `fetch` over `file://`, so
|
||
"open the HTML file" and "modular JS with JSON content" are in direct conflict.
|
||
Resolved by serving during development (`npm run serve`) and inlining
|
||
everything into a single self-contained HTML file for distribution
|
||
(`npm run build`).
|
||
|
||
Related: content is authored as `.js` modules exporting plain objects rather
|
||
than `.json`. Same data, but comments and multiline strings are available —
|
||
which matters a great deal for narrative text — and it sidesteps `fetch`
|
||
entirely.
|
||
|
||
## 9. Tuning values live in the content pack
|
||
|
||
Relationship stats run 0–100 starting at 50, clamped; typical choice effects
|
||
are a few points, so one choice matters but several turns of consistent
|
||
behaviour are needed to move someone. Money is dollars. Turn is a day.
|
||
|
||
None of these are engine constants. They are declared in the pack manifest, so
|
||
retuning the game — or a pack that runs on weeks instead of days — touches no
|
||
code.
|
||
|
||
## 10. Naming
|
||
|
||
The product is **The Ladder**; the default content pack is **Corporate
|
||
Ladder**. The planning docs use "Corporate Ladder" as the working title for
|
||
both, which would have baked one setting into the product name, against the
|
||
explicit goal that corporate is one pack among many.
|
||
|
||
## 11. Per-turn upkeep is declared by the pack
|
||
|
||
The MVP spec wants "basic income and basic recurring costs", but effects only
|
||
run when a player chooses something. Baking wages and rent into every option
|
||
would be tedious and easy to get wrong.
|
||
|
||
`pack.upkeep` is a list of `{ label, requires?, effects }` applied at the end of
|
||
each turn, after the choice lands. It is still data — the engine reads it and
|
||
knows nothing about wages — and the conditions make it expressive enough to be
|
||
useful: Corporate Ladder's loan service charge is three conditional entries
|
||
forming a step function that escalates with the size of the debt, which needed
|
||
no multiply operation and no engine change.
|
||
|
||
Upkeep changes are recorded separately from choice changes in the turn record,
|
||
so the UI can show "you chose this" and "and then the month happened" as
|
||
different things.
|
||
|
||
## 12. Characters may start somewhere other than the pack default
|
||
|
||
`relationshipStats` sets the defaults; a character's `startingRelationship`
|
||
overrides individual stats. Trevor starts below the default on `likes_you`
|
||
because he arrived two weeks before you and has priced that in — which is
|
||
characterisation expressed as data rather than as an opening cutscene.
|
||
|
||
Nobody starts feared: `fears_you` defaults to 10 rather than 50, because a
|
||
midpoint default would mean every new hire arrives already intimidating.
|
||
|
||
## 13. Content is tested for reachability, not just validity
|
||
|
||
`validatePack` proves a pack *can* run. It does not prove any of it is ever
|
||
seen. The content suite plays 60 seeded runs and asserts that every event fires
|
||
and every option is taken at least once — dead content is otherwise invisible,
|
||
and two whole events plus a hard-times escape route were in fact unreachable on
|
||
the first pass because Trevor's gate sat above anything a player could
|
||
plausibly reach.
|
||
|
||
**A trap that cost a cycle:** the chooser's RNG must be seeded differently from
|
||
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.
|
||
|
||
## 22. Stage is the one piece of engine state content may write
|
||
|
||
A promotion is `{ path: 'stage', op: 'set', value: 'dispatch' }` on an option's
|
||
effects. Nothing else about it exists in the engine — `stage` was already how
|
||
events are filtered, and moving the player between stages is a content
|
||
decision, so the reserved-write rule now allows it.
|
||
|
||
It allows it narrowly. Only `set`, and only to a stage that actually has events;
|
||
both are checked by the validator. A typo cannot strand the player somewhere
|
||
with an empty event pool.
|
||
|
||
Wages differ by rank through two upkeep entries gated on `stage`, not through
|
||
any notion of rank in the engine. The hard-times events dropped their `stage`
|
||
entirely and so apply everywhere — rent is due wherever you work, and that
|
||
branch has to follow the player up rather than vanishing on promotion.
|
||
|
||
## 23. What "reachable" means for an option
|
||
|
||
The coverage test used to ask whether an archetype ever *took* an option. That
|
||
is the wrong question: whether a bot picks something is an artefact of how the
|
||
bot scores, not a property of the pack. `rent_bounced.ask_trevor` was offered
|
||
and unlocked 419 times across a sweep and chosen once, and the test called it
|
||
dead content.
|
||
|
||
Events are still checked on firing, since the engine draws those. Options are
|
||
checked on *availability*: every option must, at some point, be offered to a
|
||
player unlocked. What they then do with it is theirs.
|
||
|
||
## 24. Variety is a measured property of the pack
|
||
|
||
Playtest: a 183-turn session, *"mailroom stayed mostly interesting despite the
|
||
number of repeats… what might be of interest is seeing how many turns are
|
||
played versus how many unique situations."*
|
||
|
||
So it is now measured, in three places.
|
||
|
||
`tools/analyze-log.js` reads an exported feedback log and reports turns against
|
||
distinct situations, how often each recurred, which options were most used,
|
||
which locked gates players kept meeting, and anything they typed. The
|
||
retrospective shows the player-facing version of the same thing.
|
||
|
||
And a test asserts no single event exceeds 25% of a 200-turn run, and that a run
|
||
sees more than 60% of what was reachable in the stages it visited — measured
|
||
against reachable content, since a run that never leaves the mailroom cannot see
|
||
Dispatch and counting that as thin would be measuring career progress instead.
|
||
|
||
That guardrail exists because the first version of Dispatch failed it badly:
|
||
eight events against the mailroom's nineteen meant its two floor events were 46%
|
||
of every promoted run, one every three turns. Promotion was moving the player
|
||
into *thinner* content. Three more events and a weight rebalance took the top
|
||
two to 24.5%, and the heaviest recurrence from every ~3 turns to every ~5.
|
||
|
||
## 25. The screen says which build it is
|
||
|
||
Playtest: *"need entry on the main screen showing what version we're running…
|
||
so it's easy to tell when new stuff is."*
|
||
|
||
A status line at the top of every screen names the game, the pack, the player's
|
||
current title, and the build.
|
||
|
||
`src/build-info.js` is tracked and permanently reads `commit: 'dev'`; it is
|
||
never rewritten. `tools/build.js` substitutes the real commit into the *bundled
|
||
output* only, so `dist/theladder.html` states exactly which commit produced it
|
||
while the working tree never churns. A build from a dirty tree gets a `+`
|
||
suffix — "5c1784d+" is honest in a way a bare SHA would not be, and the build
|
||
fails loudly if the substitution finds nothing to replace.
|
||
|
||
Running from source therefore reads "0.3.0 · dev", which is what it is.
|
||
|
||
The first attempt stamped the tracked file in place before every serve and
|
||
build. It was wrong in a way worth recording: after each commit the committed
|
||
stamp named the *previous* commit, and the next serve rewrote it and dirtied the
|
||
tree. A generated value does not belong in a tracked file if anything routinely
|
||
regenerates it.
|
||
|
||
## 26. The whole game is playable from the keyboard
|
||
|
||
Playtest: *"when I get the results, the focus should automatically be on the
|
||
next day, so I can just hit Enter."*
|
||
|
||
Every screen marks one control with `dataset.autofocus` and the app focuses it
|
||
after each render: the first *available* option while choosing, the next-turn
|
||
button once the result is in, "keep going" on the retrospective. A run is
|
||
therefore playable on Enter alone, with Tab and Shift+Tab to reach a different
|
||
option.
|
||
|
||
Locked options need no special handling — the browser's own tab order skips
|
||
disabled controls, so they stay visible without being in the way, which is
|
||
exactly the behaviour wanted.
|
||
|
||
## 27. The ledger is written for a player, not for the engine
|
||
|
||
Two fixes from the same playtest note, *"what is notable? under what changed?"*.
|
||
|
||
`notable` is the retrospective's own list of moments. Reading "Notable —
|
||
changed" in the turn ledger tells nobody anything, so a display rule may now
|
||
say `inLedger: false` and it is left out.
|
||
|
||
Flags are the opposite: a flag flipping is a fact about the world and worth
|
||
seeing. But "the envelope: now true" is engine talk. A display rule can supply
|
||
`whenTrue` and `whenFalse` sentences — "The envelope is in your locker." — and
|
||
the ledger prints those instead of a delta.
|
||
|
||
## 28. Exported logs live in logs/, and are not committed
|
||
|
||
`logs/` holds exported feedback logs and `npm run analyze` reads the newest one
|
||
there when given no argument. The logs are gitignored: they record what a real
|
||
person did turn by turn, including anything they typed into the feedback box.
|
||
That is not repository content, and making it so once would put it in the
|
||
history for good.
|
||
|
||
## 29. The economy needs a test on its arithmetic, not just on its outcomes
|
||
|
||
A real 182-turn session spent 30% of its turns below zero, went negative on turn
|
||
18 and never recovered, and chose the same hard-times option — "take every shift
|
||
going" — thirty-seven times.
|
||
|
||
The cause was a tuning change made without re-deriving what it meant. Rent went
|
||
from 385 to 455 while chasing a different problem, which took the mailroom
|
||
baseline from the designed −$3/day to −$13/day. Worse, at the debt that run
|
||
accumulated, the weekly service charge came to $180 against an escape option
|
||
worth $120: the hole was inescapable *by arithmetic*, whatever the player did.
|
||
|
||
Nothing caught it. Every archetype either optimised money or was promoted out of
|
||
the problem before it bit, and the 25%-dominance test passed because
|
||
`rent_bounced` was only 17% of turns.
|
||
|
||
Three things came out of it.
|
||
|
||
The baseline is now asserted directly — a test sums the pack's own upkeep over
|
||
twenty fortnights and requires the mailroom to net between −90 and +10, and
|
||
Dispatch to be better but under +250. That test would have failed the moment
|
||
rent changed. Outcome tests over simulated play are worth having, but they are a
|
||
slow and noisy way to detect a number that is simply wrong.
|
||
|
||
A `lifer` archetype was added: it refuses any option that would change stage —
|
||
generically, by looking for an effect on `stage` — and otherwise plays for
|
||
people. It reproduces the session that found this, and no other archetype can.
|
||
|
||
And a test now holds that no archetype spends more than a quarter of a run below
|
||
zero. Hard times is a state a player passes through. Living in it is the
|
||
failure mode.
|
||
|
||
Fixes: mailroom wages 980 → 1120, the escape option 120 → 220 so it is worth
|
||
more than a week's rent, `rent_bounced` from weight 5000 with no cooldown to 900
|
||
on a cooldown of 3 — being broke should colour a run, not replace it — and
|
||
`debt_collector` from weight 25 to 45, since at 25 it appeared six times in 262
|
||
turns and debt became a one-way ratchet.
|
||
|
||
## 30. Gaps are measured within a run, never across two
|
||
|
||
`tools/analyze-log.js` reported "every ~-5 turns". One export can hold several
|
||
playthroughs and turn numbers restart with each, so a first-seen in run two
|
||
compared against a last-seen in run one produces nonsense.
|
||
|
||
Spans and pair-counts are now accumulated per run and pooled, so the figure is a
|
||
proper weighted average of within-run gaps. It is worth stating the general
|
||
form: any statistic over a log has to respect the run boundary, because a log is
|
||
not one sequence.
|