Files
TheLadder/docs/DECISIONS.md
T
JesseMarkowitzandClaude Opus 5 1d4ac0fdbd Add the Corporate Ladder pack, and two engine features it needed
Seventeen events across the mailroom: ordinary days, Marlene Hobb and Trevor
Pike, a misrouted envelope that can be delivered or kept, and a hard-times
branch that fires on negative money and always leaves an ungated way out.

Writing real content turned up two things the engine could not express.

Per-turn upkeep (`pack.upkeep`): the MVP spec wants recurring income and costs,
but effects only run when a player chooses something, and baking wages into
every option would be tedious and easy to get wrong. Upkeep entries carry their
own conditions, which turned out to be enough for the loan service charge to be
a three-step function that escalates with the size of the debt — no multiply
operation, no engine change. Upkeep changes are recorded separately from choice
changes so the UI can distinguish them.

Per-character starting relationships: Trevor starts below the pack default on
`likes_you` because he arrived two weeks earlier and has priced that in.
Characterisation as data rather than as an opening cutscene. Nobody starts
feared, either — `fears_you` defaults to 10, since a midpoint default would mean
every new hire arrives already intimidating.

The content suite plays 60 seeded runs and asserts every event fires and every
option is taken at least once. That caught real dead content: Trevor's favour
chain sat behind a gate no plausible player could reach, taking two events and a
hard-times escape route with it, and debt could pass 5,500 while the service
charge stayed flat, so the failure spiral flattened out instead of tightening.
Both fixed in the data — the gate softened and given another route up, the loan
capped and its charge tiered.

The validator also gained a real fix: it counted an event with a cooldown as a
stage's fallback, but a cooldown can exclude it, so a stage could still run dry.

86 tests. Reasoning in docs/DECISIONS.md §11-13, including the seeding trap that
makes a correlated chooser look exactly like a content bug.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VMSFHyVPitUoosW5wyEADj
2026-09-09 21:58:20 -04:00

152 lines
7.3 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.