Files
TheLadder/README.md
T
JesseMarkowitzandClaude Opus 5 cc59b81155 Add the engine core: path-addressed state, data-driven turns
The planning docs commit to one thing above all: adding content must never
require touching the engine. This lays the foundation that makes that true.

All game state is addressable by dotted path (money, skills.negotiation,
relationships.boss.likes_you, flags.took_loan), and conditions and effects are
generic operations over those paths. The schema in the planning docs had
`requires: { skill, min }` and `effects: { money, skills, relationships }`,
both of which hardcode the state shape into the engine; the first gate wanting
a relationship threshold or a flag would have meant an engine change.

There is deliberately no "random event" category. Every turn filters the whole
event pool by stage and conditions and draws by weight, so a routine turn, a
rare interruption and the hard-times branch differ only in their data.

Also here, neither in the planning docs but both cheap and load-bearing:

- A seeded, serializable RNG. Runs replay exactly from seed plus choices, which
  makes tests deterministic and playtest reports reproducible. The generator's
  position is one uint32 living in the save.
- A load-time content validator. Undeclared paths, unknown character ids, ids
  that cannot be path segments, events that can never fire and stages with no
  fallback event are all caught on load instead of forty turns into a game.

The turn loop is a pure function, so a saved game resumes into exactly the run
it left — covered by a test that plays thirty turns, plays the same thirty with
a JSON round trip in the middle, and deep-equals the two.

72 tests, no dependencies. Reasoning recorded in docs/DECISIONS.md.

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

121 lines
4.2 KiB
Markdown

# The Ladder
A turn-based career simulation game that runs entirely in the browser. You
start at the bottom of an organisation and play turns — a day at a time —
making choices that move your money, your skills and your standing with the
people around you. There is no win state and no game over: a bad run means
worse options, not an ending, and you can stop whenever you like and read the
retrospective.
The engine knows nothing about offices, mailrooms or bosses. A **setting is a
content pack** — characters, events, choices, stat names and tuning, all data.
`corporateladder` is the default pack; a Wild West or lemonade-stand pack would
be new data and no new code.
## Status
Engine core is built and tested. No UI yet.
| Piece | State |
| --- | --- |
| Path-addressed state, conditions, effects | done |
| Seeded RNG, save/resume | done |
| Event selection, option gating | done |
| Turn loop | done |
| Content validator | done |
| Corporate Ladder content pack | not started |
| UI, save/export, feedback log | not started |
## Running
```sh
npm test # engine test suite (node's built-in runner, no dependencies)
npm run serve # static server on :8080 for development
```
There are no runtime dependencies and no build step for development. Node is
used only to run tests and, later, the single-file build.
## Layout
```
src/engine/ the game engine — imports nothing from content/
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
state.js pack -> schema and initial state
select.js which event fires this turn, which options are available
game.js the turn loop
rng.js seeded, serializable RNG
validate.js load-time content checking
content/ content packs (settings)
test/ engine tests, run against a fixture pack, never real content
docs/ DECISIONS.md — why things are the way they are
Planning/ the original design documents
```
**The rule that keeps this honest: nothing in `src/engine/` may import from
`content/`.** The engine names no stat, no character and no setting of its own.
## How content works
A pack declares what state exists and what it starts at, the relationship stats
every character carries, the characters, and the events. Everything else falls
out of that.
```js
export default {
id: 'corporateladder',
startingStage: 'mailroom',
state: {
'money': { initial: 1200, min: -5000, max: 1000000 },
'skills.negotiation': { initial: 1, min: 0, max: 100 },
'flags.took_loan': { initial: false },
},
// Expanded per character into relationships.<id>.<stat>, so adding a
// character adds its relationship state automatically.
relationshipStats: {
likes_you: { initial: 50, min: 0, max: 100 },
fears_you: { initial: 50, min: 0, max: 100 },
wants_to_help_you: { initial: 50, min: 0, max: 100 },
},
characters: [
{ id: 'boss', name: '...', role_type: 'boss', description: '...' },
],
events: [
{
id: 'mail_run',
stage: 'mailroom',
weight: 100, // relative likelihood among eligible events
description: 'A cart of mail and two hours to move it.',
options: [
{
id: 'hustle',
label: 'Get it done fast.',
requires: [{ path: 'skills.negotiation', op: '>=', value: 3 }],
effects: [
{ path: 'money', op: 'add', value: 40 },
{ path: 'relationships.boss.likes_you', op: 'add', value: 3 },
],
},
],
},
],
};
```
An event may also carry `once: true`, a `cooldown` in turns, and `requires`
conditions of its own. There is no separate notion of a random event: every
turn draws from the whole eligible pool by weight.
Conditions may read engine bookkeeping (`turn`, `stage`, `seen.<eventId>`,
`lastSeen.<eventId>`) as well as pack state. Effects may not write it.
Run `validatePack(pack)` when loading in development — it catches undeclared
paths, unknown characters, unusable ids, unreachable events and stages with no
fallback event, all of which otherwise fail silently mid-playthrough.